feat(app): a lost password with the recovery phrase in SimpleLogin's sheet (ADR 0020, 7)

An account that signs in with SimpleLogin opens its keys with its
password. Where that is lost, the unlock step's "Forgot your password?"
opens one step: the recovery phrase, and a new password twice. There is
no address and no code: the SimpleLogin sign-in of a moment ago stands
for them (D20).

- The device opens the keys with the phrase (`keys.beginPhrase`) and
  wraps them under the new password (`AccountCrypto.newKeysPassword`).
  It then signs the phrase's proof bound to that password
  (`signKeysResetProof`), which `keys.resetPassword` takes with the
  sign-in. The keys stay, and every other session ends.
- A phrase that doesn't open the keys is refused on the device, and the
  instance gets no proof to count. Two passwords that differ are
  refused before anything is sent.
- The gateway's `beginPhrase` and `resetPassword` run on the account's
  own session, not yet the device's. The repository's `resetPassword`
  ties them together, and the cubit and sheet have their `forgot` step.
  Three texts are new, in English and German.
- halkyon_base_server exports `isKeysPassword` for the interop test,
  which checks against the register's checks, with real libsodium, what
  the device sends.

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:25:47 +02:00
commit 9e9f5bf406
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -279,6 +279,108 @@ void main() {
},
);
test(
'a lost password: the new one is a password the register takes, '
"wrapping the same keys and proving itself, and the phrase's proof "
"checks against the phrase's own key, bound to that password",
() async {
const newPassword = 'a new horse battery';
final next = await crypto.newKeysPassword(newPassword, keys.keys);
final taken = register.KeysPassword.fromJson(next.toJson());
final other = register.KeysPassword.fromJson(
(await crypto.newKeysPassword(
'another horse battery',
keys.keys,
)).toJson(),
);
final nonce = Uint8List.fromList(List.filled(32, 7));
final signature = await crypto.signKeysResetProof(
keys.phrase,
protocol.KeyProofChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
encryptionPublicKey: keys.setup.encryptionPublicKey,
signingPublicKey: keys.setup.signingPublicKey,
recoveryKeys: keys.setup.recoveryKeys,
),
instanceUrl: instanceUrl,
password: next,
);
Future<bool> checks(
ByteData publicKey,
register.KeysPassword bound, {
register.KeyProofPurpose purpose =
register.KeyProofPurpose.passwordReset,
}) => register.isAccountSignature(
register.keyProofMessage(
challengeId,
nonce,
instanceUrl: 'https://learn.halkyon.eu/',
purpose: purpose,
digest: register.newKeysPasswordDigest(bound),
),
signature: Uint8List.sublistView(signature),
publicKey: Uint8List.sublistView(publicKey),
);
expect(register.isKeysPassword(taken), isTrue);
expect(await checks(keysSetup.recoveryProofKey, taken), isTrue);
expect(await checks(keysSetup.passwordProofKey, taken), isFalse);
expect(await checks(keysSetup.recoveryProofKey, other), isFalse);
expect(
await checks(
keysSetup.recoveryProofKey,
taken,
purpose: register.KeyProofPurpose.newPassword,
),
isFalse,
);
final proven = await crypto.proveKeysPassword(
newPassword,
protocol.KeysChallenge(
challenge: challengeId,
nonce: ByteData.sublistView(nonce),
salt: next.salt,
costs: next.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(taken.passwordProofKey),
),
isTrue,
);
expect(
await crypto.openKeys(
proven.unlock,
next.passwordKeys,
encryptionPublicKey: keys.keys.encryptionPublicKey,
signingPublicKey: keys.keys.signingPublicKey,
),
keys.keys,
);
await expectLater(
crypto.newKeysPassword('too short', keys.keys),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.passwordTooShort,
),
),
);
},
);
test(
"linking again: the phrase's proof checks against the phrase's own "
'key, bound to the sign-in it links',