feat(base): mending a broken SimpleLogin link with the handle and the recovery phrase (ADR 0020, D7)

Someone whose SimpleLogin identity is new to the register but who has an
account ("I already have one") names their handle and proves the
account's recovery phrase; the new identity takes the old one's place.

- `simpleLogin.beginMend(attempt, handle)`: the keys wrapped under the
  phrase's key, the public keys and a nonce, for the account with that
  handle (whatever its case). Only an account with keys and a SimpleLogin
  identity is mended, a link it had, as nobody adds SimpleLogin signed
  out (D10); a handle nobody has, or any other account's, gets a decoy
  from `signInDecoyKey`, the same each time. At most 10 an hour per
  handle and 30 per network.
- `simpleLogin.mend(attempt, proof)`: the phrase's proof for `mendLink`,
  binding the attempt (`mendLinkDigest`, on both sides), so a proof
  relayed from another sign-in links nobody's identity but the device's
  own; checked for the waiting identity first, which leaves the proof
  unspent. Every session of the account ends, in every service, and the
  sign-in stands ten minutes for a change, such as a lost password.
- SimpleLogin needs `SIGN_IN_DECOY_KEY` alone of ADR 0017's three keys
  (`signInDecoyKeyOf`); `addInstanceRoutes` stops a server with a
  SimpleLogin app and without it (`checkSimpleLoginMending`).
- Members ask the register for both; `RemoteRegister` and the instance
  API carry them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 22:26:58 +02:00
commit e5b9578916
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 1256 additions and 24 deletions

View file

@ -37,6 +37,7 @@ export 'src/password/key_proof.dart'
canonicalInstanceUrl,
isAccountSignature,
keyProofMessage,
mendLinkDigest,
newKeysPasswordDigest,
newPasswordDigest,
newPhraseDigest,

View file

@ -1384,6 +1384,58 @@ class Endpoints extends _is.EndpointDispatch {
setup: params['setup'],
),
),
'beginMend': _is.MethodConnector(
name: 'beginMend',
params: {
'attempt': _is.ParameterDescription(
name: 'attempt',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'handle': _is.ParameterDescription(
name: 'handle',
type: _is.getType<String>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
.beginMend(
session,
attempt: params['attempt'],
handle: params['handle'],
),
),
'mend': _is.MethodConnector(
name: 'mend',
params: {
'attempt': _is.ParameterDescription(
name: 'attempt',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
'proof': _is.ParameterDescription(
name: 'proof',
type: _is.getType<_iy592cuk.KeysProof>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['simpleLogin'] as _ia8c9alv.SimpleLoginEndpoint)
.mend(
session,
attempt: params['attempt'],
proof: params['proof'],
),
),
},
);
modules['halkyon_oidc'] = _ipoqfiqr.Endpoints()

View file

@ -34,7 +34,12 @@ enum SignInChallengePurpose implements _is.SerializableModel {
/// Proving the recovery phrase of an account without SRP's verifier,
/// signed in, by signing a nonce with the phrase's proof key, for a lost
/// password (Halkyon Learn's ADR 0020, D20). Counted per session.
phraseProof;
phraseProof,
/// Proving the recovery phrase signed out, to mend a broken SimpleLogin
/// link (Halkyon Learn's ADR 0020, 8): begun for a handle, a decoy's
/// for one nobody has.
mend;
static SignInChallengePurpose fromJson(String name) {
switch (name) {
@ -48,6 +53,8 @@ enum SignInChallengePurpose implements _is.SerializableModel {
return SignInChallengePurpose.passwordProof;
case 'phraseProof':
return SignInChallengePurpose.phraseProof;
case 'mend':
return SignInChallengePurpose.mend;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SignInChallengePurpose"',

View file

@ -57,3 +57,5 @@ simpleLogin:
- complete:
- checkHandle:
- signUp:
- beginMend:
- mend:

View file

@ -25,6 +25,7 @@ abstract class SimpleLoginProof
this.suggestedHandle,
bool? hasKeys,
this.handle,
this.endedBefore,
}) : hasKeys = hasKeys ?? false;
factory SimpleLoginProof({
@ -33,6 +34,7 @@ abstract class SimpleLoginProof
String? suggestedHandle,
bool? hasKeys,
String? handle,
DateTime? endedBefore,
}) = _SimpleLoginProofImpl;
factory SimpleLoginProof.fromJson(Map<String, dynamic> jsonSerialization) {
@ -48,6 +50,11 @@ abstract class SimpleLoginProof
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['hasKeys']),
handle: jsonSerialization['handle'] as String?,
endedBefore: jsonSerialization['endedBefore'] == null
? null
: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['endedBefore'],
),
);
}
@ -63,6 +70,11 @@ abstract class SimpleLoginProof
/// The account's handle; null for none.
String? handle;
/// For a link mended (8): the moment every session of the account made
/// before ended, which the session issued for it comes after
/// (`issueSessionAfter`); null otherwise.
DateTime? endedBefore;
/// Returns a shallow copy of this [SimpleLoginProof]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -72,6 +84,7 @@ abstract class SimpleLoginProof
String? suggestedHandle,
bool? hasKeys,
String? handle,
DateTime? endedBefore,
});
@override
Map<String, dynamic> toJson() {
@ -82,6 +95,7 @@ abstract class SimpleLoginProof
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
'hasKeys': hasKeys,
if (handle != null) 'handle': handle,
if (endedBefore != null) 'endedBefore': endedBefore?.toJson(),
};
}
@ -105,12 +119,14 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
String? suggestedHandle,
bool? hasKeys,
String? handle,
DateTime? endedBefore,
}) : super._(
outcome: outcome,
account: account,
suggestedHandle: suggestedHandle,
hasKeys: hasKeys,
handle: handle,
endedBefore: endedBefore,
);
/// Returns a shallow copy of this [SimpleLoginProof]
@ -123,6 +139,7 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
Object? suggestedHandle = _Undefined,
bool? hasKeys,
Object? handle = _Undefined,
Object? endedBefore = _Undefined,
}) {
return SimpleLoginProof(
outcome: outcome ?? this.outcome,
@ -132,6 +149,7 @@ class _SimpleLoginProofImpl extends SimpleLoginProof {
: this.suggestedHandle,
hasKeys: hasKeys ?? this.hasKeys,
handle: handle is String? ? handle : this.handle,
endedBefore: endedBefore is DateTime? ? endedBefore : this.endedBefore,
);
}
}

View file

@ -186,6 +186,13 @@ Uint8List newPhraseDigest(WrappedKeys recoveryKeys, ByteData proofKey) =>
Uint8List.sublistView(proofKey),
]);
/// The SHA-256 of [attempt] (its 16 bytes), framed: what a proof that
/// mends a SimpleLogin link binds (Halkyon Learn's ADR 0020, 8), the
/// attempt whose identity takes the old one's place, so a proof relayed
/// from another sign-in links nobody's identity but the device's own.
Uint8List mendLinkDigest(UuidValue attempt) =>
_sha256([utf8.encode('halkyon mend link v1'), attempt.toBytes()]);
/// The SHA-256 of authenticator [id] (its 16 bytes), framed: what the
/// owner's proof for confirming or removing it binds.
Uint8List authenticatorDigest(UuidValue id) =>

View file

@ -42,29 +42,41 @@ void checkPasswordKeys(Serverpod pod) => passwordKeysOf(pod);
/// (Compose passes unset variables on as empty strings). Throws a
/// [StateError] for a key set but shorter than [passwordKeyMinLength].
PasswordKeys? passwordKeysFrom(String? Function(String name) password) {
String? key(String name) {
final value = password(name) ?? '';
if (value.isEmpty) {
return null;
}
if (value.length < passwordKeyMinLength) {
throw StateError(
'The password $name is ${value.length} characters long: it must be '
'at least $passwordKeyMinLength (e.g. `openssl rand -hex 32`).',
);
}
return value;
}
final totp = key(_totpKey);
final recovery = key(_recoveryCodeKey);
final decoy = key(_decoyKey);
final totp = _keyFrom(password, _totpKey);
final recovery = _keyFrom(password, _recoveryCodeKey);
final decoy = _keyFrom(password, _decoyKey);
if (totp == null || recovery == null || decoy == null) {
return null;
}
return (totpKey: totp, recoveryCodeKey: recovery, decoyKey: decoy);
}
/// [pod]'s key for decoys alone (`signInDecoyKey`, `SIGN_IN_DECOY_KEY`):
/// what mending a SimpleLogin link needs of these keys (Halkyon Learn's ADR
/// 0020, 8), so an instance offering SimpleLogin alone sets it without
/// TOTP's. Null while unset. Throws a [StateError] for one too short.
String? signInDecoyKeyOf(Serverpod pod) => switch (_keysOf[pod]) {
(:final keys) => keys?.decoyKey,
null => _keyFrom(pod.getPassword, _decoyKey),
};
/// The key [password] holds under [name]; null for none, empty counting as
/// unset. Throws a [StateError] for one shorter than
/// [passwordKeyMinLength].
String? _keyFrom(String? Function(String name) password, String name) {
final value = password(name) ?? '';
if (value.isEmpty) {
return null;
}
if (value.length < passwordKeyMinLength) {
throw StateError(
'The password $name is ${value.length} characters long: it must be '
'at least $passwordKeyMinLength (e.g. `openssl rand -hex 32`).',
);
}
return value;
}
/// Whether people sign in with a password and TOTP on [pod]: it holds the
/// register's keys, and email sign-in's pepper and mailer, since the
/// addresses' hashes are made with the one and codes and notices of resets

View file

@ -1666,6 +1666,94 @@ Future<AccountPassword> _proveOwnKey(
return password;
}
/// The first step of proving the recovery phrase of the account [password]
/// is (null: nobody's, a decoy made of [decoyKey] and [decoyFor]) signed
/// out, to mend its SimpleLogin link (Halkyon Learn's ADR 0020, 8), as
/// [beginKeyProof] begins one for an address: the keys wrapped under the
/// phrase's key and the public keys, a decoy's alike, and a nonce. The
/// decoy is computed for an account too, so both take as long.
Future<KeyProofChallenge> beginMendKeyProof(
Session session, {
required AccountPassword? password,
required String decoyKey,
required String decoyFor,
}) async {
final decoy = await _decoyKeys(decoyKey, decoyFor);
final nonce = _randomBytes(32);
// INSERT INTO "sign_in_challenge" (…) VALUES (…)
final kept = await SignInChallenge.db.insertRow(
session,
SignInChallenge(
authUserId: password?.authUserId,
purpose: SignInChallengePurpose.mend,
// What the decoy is made of, so its proof key checks the proof alike.
addressHash: decoyFor,
secret: _hex(nonce),
createdAt: clock.now().toUtc(),
),
);
return KeyProofChallenge(
challenge: kept.id!,
nonce: ByteData.sublistView(nonce),
encryptionPublicKey:
password?.encryptionPublicKey ??
ByteData.sublistView(decoy.encryptionPublicKey),
signingPublicKey:
password?.signingPublicKey ??
ByteData.sublistView(decoy.signingPublicKey),
recoveryKeys: password == null
? WrappedKeys(
nonce: ByteData.sublistView(decoy.recoveryKeysNonce),
cipherText: ByteData.sublistView(decoy.recoveryKeysCipherText),
)
: WrappedKeys(
nonce: password.recoveryKeysNonce,
cipherText: password.recoveryKeysCipherText,
),
);
}
/// Takes the challenge [proof] answers (once, one of [beginMendKeyProof])
/// and checks its signature by the recovery phrase's proof key, for
/// [KeyProofPurpose.mendLink] and [digest] (`mendLinkDigest`): the
/// account's password row. Refused ([PasswordRefusedException]) for a
/// challenge unknown, used or too old ([PasswordRefusal.challengeExpired]),
/// and alike for a signature by another key and a handle nobody has, checked
/// against the decoy's key so it takes as long
/// ([PasswordRefusal.wrongCredentials]).
Future<AccountPassword> proveMendKeyProof(
Session session, {
required KeysProof proof,
required String decoyKey,
required List<int> digest,
}) async {
final kept = await takeChallenge(
session,
proof.challenge,
SignInChallengePurpose.mend,
);
final decoy = await _decoyKeys(decoyKey, kept.addressHash);
final password = await _passwordOf(session, kept.authUserId);
final proofKey = password?.recoveryProofKey;
final signed = await isAccountSignature(
keyProofMessage(
proof.challenge,
_unhex(kept.secret),
instanceUrl: proof.instanceUrl,
purpose: KeyProofPurpose.mendLink,
digest: digest,
),
signature: Uint8List.sublistView(proof.signature),
publicKey: proofKey == null
? decoy.recoveryProofKey
: Uint8List.sublistView(proofKey),
);
if (!signed || password == null || proofKey == null) {
throw refusedFor(PasswordRefusal.wrongCredentials);
}
return password;
}
/// [proof]'s key proof alone, without the SimpleLogin sign-in it names.
KeysProof keyProofOf(KeysOwnerProof proof) => KeysProof(
challenge: proof.challenge,

View file

@ -19,3 +19,7 @@ values:
### signed in, by signing a nonce with the phrase's proof key, for a lost
### password (Halkyon Learn's ADR 0020, D20). Counted per session.
- phraseProof
### Proving the recovery phrase signed out, to mend a broken SimpleLogin
### link (Halkyon Learn's ADR 0020, 8): begun for a handle, a decoy's
### for one nobody has.
- mend

View file

@ -179,6 +179,26 @@ abstract interface class AccountRegister {
required KeysSetup setup,
});
/// The first step of mending the SimpleLogin link of the account with
/// [handle] for someone whose identity waits under [attempt], the begins
/// from [network] counted. Refusals as `beginSimpleLoginMend`'s.
Future<KeyProofChallenge> beginSimpleLoginMend(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
});
/// Mends the SimpleLogin link of the account whose recovery phrase
/// [proof] proved, with the identity waiting under [attempt]: the account
/// signed in to, which the member issues its own session for, after
/// [SimpleLoginProof.endedBefore]. Refusals as `mendSimpleLogin`'s.
Future<SimpleLoginProof> mendSimpleLogin(
Session session, {
required UuidValue attempt,
required KeysProof proof,
});
/// The first step of [person] proving their password with its proof key
/// (an account that signs in with SimpleLogin). Refusals as
/// `beginPasswordKeyProof`'s.

View file

@ -227,6 +227,21 @@ Future<Map<String, Object?>?> _answer(
setup: arguments.model('setup', KeysSetup.fromJson),
);
return {'account': account.uuid};
case RegisterOperation.beginSimpleLoginMend:
final challenge = await register.beginSimpleLoginMend(
session,
attempt: arguments.id('attempt'),
handle: arguments.string('handle'),
network: arguments.string('network'),
);
return {'challenge': challenge.toJson()};
case RegisterOperation.mendSimpleLogin:
final proof = await register.mendSimpleLogin(
session,
attempt: arguments.id('attempt'),
proof: arguments.model('proof', KeysProof.fromJson),
);
return {'proof': proof.toJson()};
case RegisterOperation.beginPasswordKeyProof:
final challenge = await register.beginPasswordKeyProof(
session,

View file

@ -20,7 +20,7 @@ import 'package:serverpod/serverpod.dart';
/// instance's secret. Caddy never forwards them: the services reach each
/// other on the instance's own network. Throws as [checkInstanceSecret],
/// [checkPasswordKeys], [gitHubSignInEnds], [offeredWaysOf],
/// [simpleLoginAppOf] and [trustedProxies] do, so a server set up so stops
/// [simpleLoginAppOf], [checkSimpleLoginMending] and [trustedProxies] do, so a server set up so stops
/// as it starts.
void addInstanceRoutes(Serverpod pod) {
checkInstanceSecret(pod);
@ -33,6 +33,8 @@ void addInstanceRoutes(Serverpod pod) {
// SimpleLogin app with no callback SimpleLogin takes (ADR 0020).
offeredWaysOf(pod);
simpleLoginAppOf(pod);
// And a SimpleLogin app without the key for mending's decoys.
checkSimpleLoginMending(pod);
// And proxies that are no addresses: counted by the proxy's address, every
// visitor would share each per-network limit.
trustedProxies();

View file

@ -425,6 +425,26 @@ final class LocalRegister implements AccountRegister {
setup: setup,
);
@override
Future<KeyProofChallenge> beginSimpleLoginMend(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) => simplelogin.beginSimpleLoginMend(
session,
attempt: attempt,
handle: handle,
network: network,
);
@override
Future<SimpleLoginProof> mendSimpleLogin(
Session session, {
required UuidValue attempt,
required KeysProof proof,
}) => simplelogin.mendSimpleLogin(session, attempt: attempt, proof: proof);
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,

View file

@ -34,6 +34,8 @@ abstract final class RegisterOperation {
static const completeSimpleLogin = 'complete-simplelogin';
static const checkSimpleLoginHandle = 'check-simplelogin-handle';
static const signUpWithSimpleLogin = 'sign-up-with-simplelogin';
static const beginSimpleLoginMend = 'begin-simplelogin-mend';
static const mendSimpleLogin = 'mend-simplelogin';
static const beginPasswordKeyProof = 'begin-password-key-proof';
static const unlockKeys = 'unlock-keys';
static const setFirstKeys = 'set-first-keys';

View file

@ -338,6 +338,38 @@ final class RemoteRegister implements AccountRegister {
'${RegisterOperation.signUpWithSimpleLogin}: no answer',
));
@override
Future<KeyProofChallenge> beginSimpleLoginMend(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) async => _model<KeyProofChallenge>(
RegisterOperation.beginSimpleLoginMend,
await _ask(RegisterOperation.beginSimpleLoginMend, {
'attempt': attempt.uuid,
'handle': handle,
'network': network,
}),
'challenge',
KeyProofChallenge.fromJson,
);
@override
Future<SimpleLoginProof> mendSimpleLogin(
Session session, {
required UuidValue attempt,
required KeysProof proof,
}) async => _model<SimpleLoginProof>(
RegisterOperation.mendSimpleLogin,
await _ask(RegisterOperation.mendSimpleLogin, {
'attempt': attempt.uuid,
'proof': proof.toJson(),
}),
'proof',
SimpleLoginProof.fromJson,
);
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,

View file

@ -2,10 +2,13 @@ import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/auth/identified.dart';
import 'package:halkyon_base_server/src/auth/scopes.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/password/passwords.dart';
import 'package:halkyon_base_server/src/profile/profile_rules.dart';
import 'package:halkyon_base_server/src/register/local_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:halkyon_base_server/src/simplelogin/handle_suggestion.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_callback.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_config.dart';
@ -371,6 +374,173 @@ Future<void> takeSimpleLoginConfirmation(
}
}
/// The first step of mending the SimpleLogin link of the account with
/// [handle] (Halkyon Learn's ADR 0020, 8 and D7), for someone signed in
/// with SimpleLogin whose identity nobody has, which waits under [attempt]
/// ("I already have one"): the account's keys wrapped under its recovery
/// phrase's key and its public keys, which the device opens with the
/// phrase, and a nonce it signs with the phrase's proof key, within two
/// minutes, once (`beginMendKeyProof`). Only an account with keys and a
/// SimpleLogin identity is mended, a link it had (nobody adds SimpleLogin
/// signed out, D10); a handle nobody has, or any other account's, gets a
/// decoy alike. At most 10 an hour for a handle (whatever its case) and 30
/// from a [network] ([PasswordRefusal.tooManyAttempts]).
///
/// Refused ([OidcRefusal.unknownAttempt]) where no identity waits, and
/// ([PasswordRefusal.unavailable]) where the register has no key for
/// decoys (`SIGN_IN_DECOY_KEY`), which a server with a SimpleLogin app
/// checks as it starts ([checkSimpleLoginMending]).
Future<KeyProofChallenge> beginSimpleLoginMend(
Session session, {
required UuidValue attempt,
required String handle,
required String network,
}) async {
final idp = _simpleLoginOn(session);
final decoyKey = _decoyKeyOn(session);
if (!await idp.isKept(session, attempt: attempt)) {
throw OidcRefusedException(reason: OidcRefusal.unknownAttempt);
}
final handleKey = handle.toLowerCase();
if (!await _mendsPerHandle.tryRecordAttempt(session, key: handleKey) ||
!await _mendsPerNetwork.tryRecordAttempt(session, key: network)) {
throw refusedFor(PasswordRefusal.tooManyAttempts);
}
return await beginMendKeyProof(
session,
password: await _mendable(session, handleKey),
decoyKey: decoyKey,
decoyFor: 'handle|$handleKey',
);
}
/// Mends the SimpleLogin link of the account whose recovery phrase [proof]
/// proved for a challenge of [beginSimpleLoginMend] (D7): the identity
/// waiting under [attempt], which the proof binds (`mendLinkDigest`), takes
/// the place of the account's old one, and every session of the account
/// ends, in every service, as whoever came in through the old link may
/// still be in. Signs them in as [completeSimpleLogin] does, the sign-in
/// standing ten minutes for a change (a lost password, D20).
///
/// Refused ([OidcRefusal.unknownAttempt]) where no identity waits, checked
/// before the proof, which it then leaves unspent; as `proveMendKeyProof`
/// refuses ([PasswordRefusedException]); and as `OidcIdp.takeKept` and
/// `OidcIdp.link` refuse. Nothing changes then.
Future<SimpleLoginProof> mendSimpleLogin(
Session session, {
required UuidValue attempt,
required KeysProof proof,
}) async {
final idp = _simpleLoginOn(session);
final decoyKey = _decoyKeyOn(session);
if (!await idp.isKept(session, attempt: attempt)) {
throw OidcRefusedException(reason: OidcRefusal.unknownAttempt);
}
final password = await proveMendKeyProof(
session,
proof: proof,
decoyKey: decoyKey,
digest: mendLinkDigest(attempt),
);
final account = password.authUserId;
final (identity, endedBefore) = await session.db.transaction((
transaction,
) async {
final identity = await idp.takeKept(
session,
attempt: attempt,
transaction: transaction,
);
await idp.unlink(
session,
authUserId: account,
provider: identity.provider,
transaction: transaction,
);
await idp.link(
session,
authUserId: account,
provider: identity.provider,
subject: identity.subject,
transaction: transaction,
);
final endedBefore = await endSessionsEverywhere(
session,
account,
transaction: transaction,
);
return (identity, endedBefore);
});
await announceSessionsEnded(session, account);
final signedIn = await _signedInAs(
session,
idp,
attempt: attempt,
identity: identity,
account: account,
outcome: SimpleLoginOutcome.signedIn,
);
return signedIn.copyWith(endedBefore: endedBefore);
}
/// The keys of the account with the handle [handleKey] (lower case) whose
/// SimpleLogin link could be mended: one with keys and a SimpleLogin
/// identity; null for any other, and for a handle nobody has.
Future<AccountPassword?> _mendable(Session session, String handleKey) async {
// SELECT * FROM "public_profile" WHERE "handleKey" = $1 LIMIT 1
final profile = await PublicProfile.db.findFirstRow(
session,
where: (t) => t.handleKey.equals(handleKey),
);
if (profile == null) {
return null;
}
final person = profile.authUserId;
// SELECT count(*) FROM "halkyon_oidc_identity"
// WHERE "authUserId" = $1 AND "provider" = $2
if (await OidcIdentity.db.count(
session,
where: (t) =>
t.authUserId.equals(person) &
t.provider.equals(simpleLoginProvider),
) ==
0) {
return null;
}
// SELECT * FROM "account_password" WHERE "authUserId" = $1 LIMIT 1
return await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
);
}
/// The register's key for decoys, which mending takes
/// ([PasswordRefusal.unavailable] without).
String _decoyKeyOn(Session session) =>
signInDecoyKeyOf(session.serverpod) ??
(throw refusedFor(PasswordRefusal.unavailable));
/// How many mends begin for one handle in an hour: each takes a guess at
/// its recovery phrase.
final _mendsPerHandle = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'simplelogin',
source: 'mends_per_handle',
maxAttempts: 10,
timeframe: const Duration(hours: 1),
),
);
/// How many mends begin from one network in an hour.
final _mendsPerNetwork = DatabaseRateLimiter(
RateLimiterConfig(
domain: 'simplelogin',
source: 'mends_per_network',
maxAttempts: 30,
timeframe: const Duration(hours: 1),
),
);
/// How many handles someone new checks for one attempt in an hour: enough
/// to try a few while typing.
final _handleChecksPerAttempt = DatabaseRateLimiter(

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/auth/offered_ways.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/password/password_config.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_callback.dart';
import 'package:halkyon_oidc_server/oidc.dart';
import 'package:http/http.dart' as http;
@ -53,6 +54,21 @@ SimpleLoginApp? simpleLoginAppOf(Serverpod pod) => switch (_appOf[pod]) {
),
};
/// Throws a [StateError] where [pod] has a SimpleLogin app but no key for
/// decoys (`signInDecoyKey`, `SIGN_IN_DECOY_KEY`), without which mending a
/// broken link (Halkyon Learn's ADR 0020, 8) would tell which handles have
/// an account: hosts call it as they start (`addInstanceRoutes`), so such
/// a server stops rather than serving SimpleLogin without it.
void checkSimpleLoginMending(Serverpod pod) {
if (simpleLoginAppOf(pod) != null && signInDecoyKeyOf(pod) == null) {
throw StateError(
'SimpleLogin mends broken links with decoys for unknown handles: set '
'the password signInDecoyKey (SIGN_IN_DECOY_KEY, e.g. `openssl rand '
'-hex 32`).',
);
}
}
/// The SimpleLogin app [password] (a pod's passwords, by name) holds: the
/// client's ID and secret (`simpleLoginClientId`, `simpleLoginClientSecret`;
/// in a deploy's `.env` `SIMPLELOGIN_CLIENT_ID`, `SIMPLELOGIN_CLIENT_SECRET`),

View file

@ -2,6 +2,7 @@ import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/common/network.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_base_server/src/halkyon_base.dart';
import 'package:halkyon_base_server/src/password/key_proof.dart';
import 'package:halkyon_base_server/src/register/account_register.dart';
import 'package:halkyon_base_server/src/register/people.dart';
import 'package:halkyon_base_server/src/simplelogin/simplelogin_config.dart';
@ -128,4 +129,68 @@ class SimpleLoginEndpoint extends Endpoint {
);
return await issueSession(session, account, method: simpleLoginMethod);
}
/// The first step of mending your SimpleLogin link (ADR 0020, 8 and D7),
/// for someone whose identity waits under [attempt] ([complete] said
/// `newcomer`) who already has an account, with [handle]: the keys
/// wrapped under its recovery phrase's key and its public keys, which
/// your device opens with the phrase, and a nonce to sign with the
/// phrase's proof key for [mend]; a handle nobody has gets a decoy alike.
/// At most 10 for a handle and 30 from a network in an hour
/// (`PasswordRefusal.tooManyAttempts`). Refused for a signed-in caller
/// (`OidcRefusal.wrongCaller`), and as `beginSimpleLoginMend` refuses.
Future<KeyProofChallenge> beginMend(
Session session, {
required UuidValue attempt,
required String handle,
}) {
if (session.authenticated != null) {
throw OidcRefusedException(reason: OidcRefusal.wrongCaller);
}
return AccountRegister.of(session.serverpod).beginSimpleLoginMend(
session,
attempt: attempt,
handle: handle,
network: networkOf(session),
);
}
/// Mends your SimpleLogin link with [proof], the recovery phrase's for a
/// challenge of [beginMend], binding [attempt] (`mendLinkDigest`) and the
/// base URL your device addressed here: the identity waiting under
/// [attempt] takes your account's old one's place, every other session of
/// the account ends, in every service, and this one signs in, as
/// [complete] signs in (the sign-in standing ten minutes for a change,
/// such as `keys.resetPassword`). Refused for a signed-in caller
/// (`OidcRefusal.wrongCaller`), for a proof that proves nothing, alike for
/// a handle nobody has (`PasswordRefusal.wrongCredentials`), and as
/// `mendSimpleLogin` refuses.
Future<SimpleLoginCompleted> mend(
Session session, {
required UuidValue attempt,
required KeysProof proof,
}) async {
if (session.authenticated != null) {
throw OidcRefusedException(reason: OidcRefusal.wrongCaller);
}
final pod = session.serverpod;
final mended = await AccountRegister.of(pod).mendSimpleLogin(
session,
attempt: attempt,
proof: proof.copyWith(
instanceUrl: deviceInstanceUrlOn(pod, proof.instanceUrl),
),
);
return SimpleLoginCompleted(
outcome: SimpleLoginOutcome.signedIn,
session: await issueSessionAfter(
session,
mended.account!,
method: simpleLoginMethod,
endedBefore: mended.endedBefore!,
),
hasKeys: mended.hasKeys,
handle: mended.handle,
);
}
}

View file

@ -12,3 +12,7 @@ fields:
hasKeys: bool, default=false
### The account's handle; null for none.
handle: String?
### For a link mended (8): the moment every session of the account made
### before ended, which the session issued for it comes after
### (`issueSessionAfter`); null otherwise.
endedBefore: DateTime?