feat(app): a reset of the keys without the phrase, its banner, and the keys' proof key for a password that signs in (ADR 0020, D31–D35)
All checks were successful
CI / check (push) Successful in 31m14s
CI / deploy (push) Has been skipped

SimpleLogin's sheet, its password and its recovery phrase both lost:
under the lost-password step's phrase field, Lost your recovery phrase
too? says what stays and what is lost and asks for new keys
(`keys.startReset`) with the sign-in of a moment ago, which the device
then drops. The next sign-in says when while it waits, and once due
offers new keys, the sign-up's own password and phrase steps
(`keys.completeReset`), or the password, which calls it off (D31).

Every app's home shows `KeysResetBanner` where "too old" shows, and the
account section's SimpleLogin row the same words (`KeysResetNotice`),
each with Call it off, the password's proof alone
(`keys.callOffResets`, D32), and, once due on a device that holds the
keys, Finish here, which wraps the same keys anew (D33, D35). Both show
only a reset of an account that signs in with SimpleLogin alone; ADR
0017's resets show in its own section, which now shares `AlertBox` with
them.

An account whose password signs in has no Forgot your password? there,
and is asked its password and a code once while its keys lack the proof
key, at a sign-in or on adding SimpleLogin (`password.setProofKey`,
D34). `PasswordUnlock` carries the proof key, which its forgetting
leaves, and the keys' password proves for a purpose given.

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 22:37:17 +02:00
commit 1b79214764
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 2711 additions and 248 deletions

View file

@ -14,6 +14,7 @@ void main() {
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
proofKey: Uint8List.fromList(List.filled(32, 5)),
srpSecret: Uint8List.fromList(List.filled(32, 7)),
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
);
@ -24,6 +25,11 @@ void main() {
expect(x, everyElement(0));
expect(key, everyElement(0));
expect(
unlock.proofKey,
everyElement(5),
reason: 'the public half of the proof key is no secret',
);
});
test('a forgotten unlock is spent: its secrets are read no more, so no '
@ -35,6 +41,7 @@ void main() {
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
proofKey: Uint8List.fromList(List.filled(32, 5)),
srpSecret: Uint8List.fromList(List.filled(32, 7)),
keyEncryptionKey: Uint8List.fromList(List.filled(32, 9)),
);

View file

@ -185,6 +185,101 @@ void main() {
},
);
test(
'a password that signs in gives its keys the proof key it makes (ADR '
"0020, D34): the owner's SRP proof binds that key for the register, "
"and the password's proof for unlocking then checks against it",
() async {
final verifier = register.numberOf(Uint8List.sublistView(setup.verifier));
final challenge = register.srpChallenge(register.passwordGroup, verifier);
final unlock = await crypto.unlock(
password,
salt: Uint8List.sublistView(made.setup.salt),
costs: made.setup.costs,
);
final proofKey = ByteData.sublistView(
Uint8List.fromList(unlock.proofKey),
);
final answer = await crypto.answer(
unlock,
protocol.PasswordChallenge(
challenge: challengeId,
salt: made.setup.salt,
costs: made.setup.costs,
serverValue: ByteData.sublistView(
register.padded(
challenge.publicValue,
register.passwordGroup.length,
),
),
),
instanceUrl: instanceUrl,
purpose: device.PasswordProofPurpose.proofKey,
digest: device.proofKeyDigest(unlock.proofKey),
);
unlock.forget();
register.SrpVerification verify(ByteData key) => register.srpVerify(
register.passwordGroup,
identity: register.passwordIdentity(
challengeId,
'https://learn.halkyon.eu/',
purpose: register.PasswordProofPurpose.proofKey,
digest: register.proofKeyDigest(key),
),
salt: Uint8List.sublistView(setup.salt),
verifier: verifier,
challenge: challenge,
clientValue: register.numberOf(
Uint8List.sublistView(answer.clientValue),
),
clientProof: Uint8List.sublistView(answer.clientProof),
);
expect(proofKey.lengthInBytes, 32);
expect(verify(proofKey), isA<register.SrpAccepted>());
// Another key swapped in on the way: refused.
expect(verify(ByteData(32)), isA<register.SrpRefused>());
final nonce = Uint8List.fromList(List.filled(32, 5));
final proven = await crypto.proveKeysPassword(
password,
protocol.KeysChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
salt: made.setup.salt,
costs: made.setup.costs,
),
instanceUrl: instanceUrl,
);
expect(
await register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: 'https://learn.halkyon.eu/',
purpose: register.KeyProofPurpose.unlockKeys,
),
signature: Uint8List.sublistView(proven.proof.signature),
publicKey: Uint8List.sublistView(proofKey),
),
isTrue,
);
expect(
await crypto.openKeys(
proven.unlock,
made.setup.passwordKeys,
encryptionPublicKey: Uint8List.sublistView(
made.setup.encryptionPublicKey,
),
signingPublicKey: Uint8List.sublistView(made.setup.signingPublicKey),
),
made.keys,
);
expect(proven.unlock.proofKey, unlock.proofKey);
},
);
group('an account that signs in with SimpleLogin (ADR 0020, D19)', () {
late NewKeysMaterial keys;
late register.KeysSetup keysSetup;
@ -212,6 +307,40 @@ void main() {
);
});
test(
"calling off a reset of the keys: the password's proof made for it "
'checks against its proof key for that alone (D32)',
() async {
final nonce = Uint8List.fromList(List.filled(32, 7));
final proven = await crypto.proveKeysPassword(
password,
protocol.KeysChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
salt: keys.setup.salt,
costs: keys.setup.costs,
),
instanceUrl: instanceUrl,
purpose: device.KeyProofPurpose.callOffResets,
);
proven.unlock.forget();
Future<bool> checks(register.KeyProofPurpose purpose) =>
register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: 'https://learn.halkyon.eu/',
purpose: purpose,
),
signature: Uint8List.sublistView(proven.proof.signature),
publicKey: Uint8List.sublistView(keysSetup.passwordProofKey),
);
expect(await checks(register.KeyProofPurpose.callOffResets), isTrue);
expect(await checks(register.KeyProofPurpose.unlockKeys), isFalse);
},
);
test(
"unlocking: the password's proof checks against its own proof key, "
'bound to the instance and to what it is for; the keys the register '