feat(base): a reset of the keys without the phrase, and the keys' proof key for a password that signs in (ADR 0020, D31–D34)

An account that signs in with SimpleLogin alone, its password and its
recovery phrase both lost, asks for new keys with a SimpleLogin sign-in
(`keys.startReset`); another sign-in carries it out 72 hours later
(`keys.completeReset`, `notDue` with when before), with the same keys
rewrapped where the device still holds them (D33). No mail: every
signed-in device shows it, and any right proof of the password's or the
phrase's key, or a mend, calls it off; `keys.callOffResets` takes the
password's proof alone (D32). The row is ADR 0017's account reset of kind
`account`, so no migration; ADR 0017's reset by mail now takes only an
account whose password signs in.

An account with ADR 0017's password that added SimpleLogin had keys but
no proof key, so no new device signed in with SimpleLogin could open
them: `password.setProofKey` sets the one that same password makes, with
the owner's proof binding it (D34), and ADR 0017's new password takes it
away. Only an account that signs in with SimpleLogin alone changes
through `keys`. `simpleLogin.complete` says whether the password signs
in, whether the keys have the proof key, and when a reset takes effect.

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-24 21:51:00 +02:00
commit fe2ef02c92
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1973 additions and 52 deletions

View file

@ -193,9 +193,11 @@ class EndpointInstance extends _isc.EndpointRef {
/// The keys of an account that signs in with SimpleLogin (Halkyon Learn's
/// ADR 0020, 6, 7, D19 and D20), for its signed-in devices: the password
/// proven with its own proof key, which the instance's register checks (ADR
/// 0016), and every change with that proof and a SimpleLogin sign-in of the
/// last ten minutes on the device (`KeysOwnerProof`). Refusals are
/// `PasswordRefusedException`s; the sign-in's, `OidcRefusedException`s.
/// 0016), every change with that proof and a SimpleLogin sign-in of the
/// last ten minutes on the device (`KeysOwnerProof`), and, the password and
/// the phrase lost, a reset to new keys 72 hours later (D31 to D33).
/// Refusals are `PasswordRefusedException`s; the sign-in's,
/// `OidcRefusedException`s.
/// {@category Endpoint}
class EndpointKeys extends _isc.EndpointRef {
EndpointKeys(_isc.EndpointCaller caller) : super(caller);
@ -314,6 +316,57 @@ class EndpointKeys extends _isc.EndpointRef {
'recoveryProofKey': recoveryProofKey,
},
);
/// Asks for the reset of your keys, your password and your recovery
/// phrase lost (D31), with your SimpleLogin sign-in of the last ten
/// minutes on this device, under [attempt]: it takes effect 72 hours
/// later, which this answers, when [completeReset] gives you new keys.
/// Your signed-in devices show it meanwhile, and your password or your
/// phrase opening your keys anywhere calls it off, as [callOffResets]
/// does. Asked again while it waits, it keeps its time. Refused for an
/// account without such keys (`PasswordRefusal.noPassword`; one whose
/// password signs in resets as `accountRecovery` has it) and without that
/// sign-in (`OidcRefusal.unknownAttempt`).
_ida.Future<_ippuf33k.ResetPending> startReset({
required _isc.UuidValue attempt,
}) => caller.callServerEndpoint<_ippuf33k.ResetPending>(
'halkyon_base.keys',
'startReset',
{'attempt': attempt},
);
/// Carries out the reset of your keys, due now, with your SimpleLogin
/// sign-in of the last ten minutes on this device, under [attempt]:
/// [setup], all made on your device (your old keys wrapped anew where it
/// still holds them, D33), replaces your password, keys and phrase. What
/// was encrypted under keys no device holds any more is lost; your
/// handle, profile and SimpleLogin stay. Every other session of yours
/// ends, in every service of the instance; this one stays. Refused before
/// its time (`PasswordRefusal.notDue`, with when), without such a reset
/// or once it lapsed (`PasswordRefusal.noReset`), for a setup out of range
/// (`PasswordRefusal.invalidSetup`), and as [startReset] refuses.
_ida.Future<void> completeReset({
required _isc.UuidValue attempt,
required _iec2fqr0.KeysSetup setup,
}) => caller.callServerEndpoint<void>(
'halkyon_base.keys',
'completeReset',
{
'attempt': attempt,
'setup': setup,
},
);
/// Calls off a reset of your keys waiting (D32), once [proof] proved your
/// password for it, for a challenge of [begin]: whoever asked for it took
/// over your SimpleLogin account. The proof counts against this session,
/// as [unlock]'s. Refusals as [unlock]'s.
_ida.Future<void> callOffResets({required _iznbn55x.KeysProof proof}) =>
caller.callServerEndpoint<void>(
'halkyon_base.keys',
'callOffResets',
{'proof': proof},
);
}
/// Getting back into an account (ADR 0017): a lost authenticator, a lost
@ -605,6 +658,25 @@ class EndpointPassword extends _isc.EndpointRef {
{'proof': proof},
);
/// Sets [proofKey], the public half of the keys' proof key the caller's
/// password makes on the device, with the owner's [proof] binding it: the
/// keys then open with the password alone on a device signed in with
/// SimpleLogin (`keys.unlock`; Halkyon Learn's ADR 0020, D34). A new
/// password takes it away, as it is made of the password. Counted against
/// this session as every owner's proof is. Refusals as
/// `setPasswordProofKey`'s.
_ida.Future<void> setProofKey({
required _ikq9b2yz.OwnerProof proof,
required _idt.ByteData proofKey,
}) => caller.callServerEndpoint<void>(
'halkyon_base.password',
'setProofKey',
{
'proof': proof,
'proofKey': proofKey,
},
);
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with the owner's [proof], as
@ -901,7 +973,9 @@ class EndpointSimpleLogin extends _isc.EndpointRef {
/// signed in, SimpleLogin added to the caller's account. Serverpod issues
/// no session for another person inside a signed-in call, and nobody is
/// switched to another account. For an account, whether it has its keys
/// (`keys.unlock` opens them; one without makes them) and its handle; and
/// (`keys.unlock` opens them; one without makes them), whether its
/// password signs in too and has the keys' proof key (D34), when a reset
/// of its keys takes effect (D31), and its handle; and
/// for ten minutes the attempt's ID stands for this sign-in, for a change
/// the device asks for (ADR 0020, D20). Refused as [begin] refuses its
/// caller, and as `completeSimpleLogin` refuses.

View file

@ -28,7 +28,12 @@ abstract class SimpleLoginCompleted
this.suggestedHandle,
bool? hasKeys,
this.handle,
}) : hasKeys = hasKeys ?? false;
bool? passwordSignsIn,
bool? hasProofKey,
this.accountResetAt,
}) : hasKeys = hasKeys ?? false,
passwordSignsIn = passwordSignsIn ?? false,
hasProofKey = hasProofKey ?? false;
factory SimpleLoginCompleted({
required _i2lb2chw.SimpleLoginOutcome outcome,
@ -36,6 +41,9 @@ abstract class SimpleLoginCompleted
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
}) = _SimpleLoginCompletedImpl;
factory SimpleLoginCompleted.fromJson(
@ -55,6 +63,19 @@ abstract class SimpleLoginCompleted
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['hasKeys']),
handle: jsonSerialization['handle'] as String?,
passwordSignsIn: jsonSerialization['passwordSignsIn'] == null
? null
: _isc.BoolJsonExtension.fromJson(
jsonSerialization['passwordSignsIn'],
),
hasProofKey: jsonSerialization['hasProofKey'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['hasProofKey']),
accountResetAt: jsonSerialization['accountResetAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['accountResetAt'],
),
);
}
@ -73,6 +94,22 @@ abstract class SimpleLoginCompleted
/// keys).
String? handle;
/// Whether the account's password signs in too (ADR 0017's, with SRP
/// and a code): its changes and resets are the password sign-in's, never
/// `keys`' (D34).
bool passwordSignsIn;
/// Whether `keys.unlock` opens the keys: they have the password's proof
/// key (D19). A password that signs in gets it once its password and a
/// code set it (`password.setProofKey`, D34): on adding SimpleLogin, and
/// again after a new password, as the key is made of the password.
bool hasProofKey;
/// When a reset of the keys waiting (or due, not carried out) takes
/// effect (D31): the password or the phrase opening them calls it off;
/// due, `keys.completeReset` carries it out. Null for none.
DateTime? accountResetAt;
/// Returns a shallow copy of this [SimpleLoginCompleted]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -82,6 +119,9 @@ abstract class SimpleLoginCompleted
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
});
@override
Map<String, dynamic> toJson() {
@ -92,6 +132,9 @@ abstract class SimpleLoginCompleted
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
'hasKeys': hasKeys,
if (handle != null) 'handle': handle,
'passwordSignsIn': passwordSignsIn,
'hasProofKey': hasProofKey,
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
};
}
@ -104,6 +147,9 @@ abstract class SimpleLoginCompleted
if (suggestedHandle != null) 'suggestedHandle': suggestedHandle,
'hasKeys': hasKeys,
if (handle != null) 'handle': handle,
'passwordSignsIn': passwordSignsIn,
'hasProofKey': hasProofKey,
if (accountResetAt != null) 'accountResetAt': accountResetAt?.toJson(),
};
}
@ -122,12 +168,18 @@ class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
String? suggestedHandle,
bool? hasKeys,
String? handle,
bool? passwordSignsIn,
bool? hasProofKey,
DateTime? accountResetAt,
}) : super._(
outcome: outcome,
session: session,
suggestedHandle: suggestedHandle,
hasKeys: hasKeys,
handle: handle,
passwordSignsIn: passwordSignsIn,
hasProofKey: hasProofKey,
accountResetAt: accountResetAt,
);
/// Returns a shallow copy of this [SimpleLoginCompleted]
@ -140,6 +192,9 @@ class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
Object? suggestedHandle = _Undefined,
bool? hasKeys,
Object? handle = _Undefined,
bool? passwordSignsIn,
bool? hasProofKey,
Object? accountResetAt = _Undefined,
}) {
return SimpleLoginCompleted(
outcome: outcome ?? this.outcome,
@ -151,6 +206,11 @@ class _SimpleLoginCompletedImpl extends SimpleLoginCompleted {
: this.suggestedHandle,
hasKeys: hasKeys ?? this.hasKeys,
handle: handle is String? ? handle : this.handle,
passwordSignsIn: passwordSignsIn ?? this.passwordSignsIn,
hasProofKey: hasProofKey ?? this.hasProofKey,
accountResetAt: accountResetAt is DateTime?
? accountResetAt
: this.accountResetAt,
);
}
}

View file

@ -46,6 +46,10 @@ enum PasswordProofPurpose(final String label) {
/// Deleting the account.
deleteAccount('delete account'),
/// Setting the keys' proof key, made of the same password, for devices
/// signed in with SimpleLogin (Halkyon Learn's ADR 0020, D34).
proofKey('keys proof key'),
}
/// SRP's identity I for the [challenge] (its 16 bytes) on [instanceUrl],
@ -112,6 +116,10 @@ enum KeyProofPurpose(final String label) {
/// Linking a new SimpleLogin ID in place of a lost one, by the phrase.
mendLink('mend link'),
/// Calling off a reset of the keys waiting, by the password (Halkyon
/// Learn's ADR 0020, D32).
callOffResets('call off resets'),
}
/// What the device signs with the recovery phrase's proof key to prove the
@ -205,6 +213,12 @@ Uint8List mendLinkDigest(List<int> attempt) =>
Uint8List authenticatorDigest(List<int> id) =>
_sha256([utf8.encode('halkyon authenticator v1'), id]);
/// The SHA-256 of the public half of the keys' proof key a password makes
/// (Halkyon Learn's ADR 0020, D34), framed: what the owner's proof for
/// setting it binds.
Uint8List proofKeyDigest(List<int> proofKey) =>
_sha256([utf8.encode('halkyon keys proof key v1'), proofKey]);
Uint8List _sha256(List<List<int>> parts) =>
Uint8List.fromList(sha256.convert(_framed(parts)).bytes);