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:
parent
904b2e41b2
commit
9e9f5bf406
5 changed files with 756 additions and 2 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -259,6 +259,51 @@ void main() {
|
|||
expect(keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
|
||||
test('with the password lost, opens them with the recovery phrase under '
|
||||
'a new password typed twice, and goes back to the password when '
|
||||
'asked', () async {
|
||||
final material = await crypto.newKeys(password);
|
||||
gateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
final cubit = cubitFor();
|
||||
await cubit.started();
|
||||
cubit.forgotChosen();
|
||||
expect(cubit.state, at(SimpleLoginStep.forgot));
|
||||
cubit.unlockShownAgain();
|
||||
expect(cubit.state, at(SimpleLoginStep.unlock));
|
||||
cubit.forgotChosen();
|
||||
|
||||
const newPassword = 'a new horse battery';
|
||||
await cubit.forgotEntered(fakePhrase, newPassword, 'a new horse');
|
||||
expect(cubit.state, at(SimpleLoginStep.forgot));
|
||||
expect(cubit.state.passwordsDiffer, isTrue);
|
||||
expect(gateway.calls, isNot(contains('beginPhrase')));
|
||||
|
||||
await cubit.forgotEntered(otherFakePhrase, newPassword, newPassword);
|
||||
expect(
|
||||
cubit.state,
|
||||
at(
|
||||
SimpleLoginStep.forgot,
|
||||
problem: const PasswordFailed(PasswordProblem.phraseDoesNotFit),
|
||||
),
|
||||
);
|
||||
expect(cubit.state.passwordsDiffer, isFalse);
|
||||
expect(auth.status, const SignedOut());
|
||||
|
||||
await cubit.forgotEntered(fakePhrase, newPassword, newPassword);
|
||||
|
||||
expect(cubit.state, at(SimpleLoginStep.done));
|
||||
expect(
|
||||
auth.status,
|
||||
const SignedIn(authUserId: 'person-9', isIdentified: true),
|
||||
);
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
|
||||
test('of before, without keys, makes them with the handle it '
|
||||
'lacks', () async {
|
||||
gateway.addAccount('person-9', subject: 'simplelogin-anna');
|
||||
|
|
|
|||
|
|
@ -208,6 +208,81 @@ void main() {
|
|||
expect(sessionOf('halkyon_base.keys/unlock'), contains('anna-token'));
|
||||
});
|
||||
|
||||
test("sets a lost password on the account's own session, not yet the "
|
||||
"device's: a sign-in taken or run out begins again, a phrase that "
|
||||
"doesn't prove itself is the keys' refusal", () async {
|
||||
final grant = SessionGrant(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
AuthSuccess(
|
||||
authStrategy: 'session',
|
||||
token: 'anna-token',
|
||||
authUserId: UuidValue.withValidation(
|
||||
'0191f0c4-2222-7000-8000-000000000002',
|
||||
),
|
||||
scopeNames: {'identified'},
|
||||
),
|
||||
);
|
||||
Future<void> reset() => gateway.resetPassword(
|
||||
KeysOwnerProof(
|
||||
challenge: _Instance.attempt,
|
||||
signature: ByteData(64),
|
||||
instanceUrl: gateway.instanceUrl,
|
||||
attempt: _Instance.attempt,
|
||||
),
|
||||
KeysPassword(
|
||||
salt: ByteData(16),
|
||||
costs: PasswordCosts(
|
||||
algorithm: 'argon2id13',
|
||||
opsLimit: 3,
|
||||
memLimit: 64 * 1024 * 1024,
|
||||
),
|
||||
passwordProofKey: ByteData(32),
|
||||
passwordKeys: WrappedKeys(
|
||||
nonce: ByteData(24),
|
||||
cipherText: ByteData(48),
|
||||
),
|
||||
),
|
||||
as: grant,
|
||||
);
|
||||
|
||||
instance.refusals['halkyon_base.keys/resetPassword'] = OidcRefusedException(
|
||||
reason: OidcRefusal.unknownAttempt,
|
||||
);
|
||||
await expectLater(reset(), failsWith(SimpleLoginProblem.startAgain));
|
||||
expect(
|
||||
sessionOf('halkyon_base.keys/resetPassword'),
|
||||
contains('anna-token'),
|
||||
);
|
||||
|
||||
instance.calls.clear();
|
||||
instance.refusals['halkyon_base.keys/resetPassword'] =
|
||||
PasswordRefusedException(reason: PasswordRefusal.wrongCredentials);
|
||||
await expectLater(
|
||||
reset(),
|
||||
throwsA(
|
||||
isA<PasswordFailed>().having(
|
||||
(failed) => failed.problem,
|
||||
'problem',
|
||||
PasswordProblem.wrongCredentials,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
instance.refusals['halkyon_base.keys/beginPhrase'] =
|
||||
PasswordRefusedException(reason: PasswordRefusal.noPassword);
|
||||
await expectLater(
|
||||
gateway.beginPhrase(as: grant),
|
||||
throwsA(
|
||||
isA<PasswordFailed>().having(
|
||||
(failed) => failed.problem,
|
||||
'problem',
|
||||
PasswordProblem.noPassword,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(sessionOf('halkyon_base.keys/beginPhrase'), contains('anna-token'));
|
||||
});
|
||||
|
||||
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
|
||||
expect(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -298,6 +298,93 @@ void main() {
|
|||
expect(keys.kept, isEmpty);
|
||||
expect(crypto.unlocks.single.isForgotten, isTrue);
|
||||
});
|
||||
|
||||
group('with the password lost (ADR 0020, 7)', () {
|
||||
const newPassword = 'a new horse battery';
|
||||
|
||||
test('the recovery phrase opens them and a new password wraps them: '
|
||||
"once entered, the session and the same keys are the device's, "
|
||||
'and the new password opens them, the old one no longer', () async {
|
||||
final pending = await simpleLogin.resetPassword(
|
||||
account,
|
||||
phrase: fakePhrase,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
expect(auth.status, const SignedOut());
|
||||
|
||||
await simpleLogin.enter(pending);
|
||||
|
||||
expect(auth.usedSessions, [account.grant]);
|
||||
expect(keys.kept, {'person-9': material.keys});
|
||||
await expectLater(
|
||||
simpleLogin.unlock(account, password),
|
||||
keysFailWith(PasswordProblem.wrongCredentials),
|
||||
);
|
||||
expect(
|
||||
(await simpleLogin.unlock(account, newPassword)).keys,
|
||||
material.keys,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses a phrase that doesn't open the keys on the device: the "
|
||||
'instance gets no proof to count, and the password stays', () async {
|
||||
await expectLater(
|
||||
simpleLogin.resetPassword(
|
||||
account,
|
||||
phrase: otherFakePhrase,
|
||||
newPassword: newPassword,
|
||||
),
|
||||
keysFailWith(PasswordProblem.phraseDoesNotFit),
|
||||
);
|
||||
await expectLater(
|
||||
simpleLogin.resetPassword(
|
||||
account,
|
||||
phrase: 'not a phrase',
|
||||
newPassword: newPassword,
|
||||
),
|
||||
keysFailWith(PasswordProblem.notAPhrase),
|
||||
);
|
||||
|
||||
expect(gateway.calls, isNot(contains('resetPassword')));
|
||||
expect(keys.kept, isEmpty);
|
||||
expect(
|
||||
(await simpleLogin.unlock(account, password)).keys,
|
||||
material.keys,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a new password too short on the device, before anything '
|
||||
'is sent', () async {
|
||||
await expectLater(
|
||||
simpleLogin.resetPassword(
|
||||
account,
|
||||
phrase: fakePhrase,
|
||||
newPassword: 'too short',
|
||||
),
|
||||
keysFailWith(PasswordProblem.passwordTooShort),
|
||||
);
|
||||
|
||||
expect(gateway.calls, isNot(contains('resetPassword')));
|
||||
});
|
||||
|
||||
test('takes the sign-in it was made with: once it is used, the change '
|
||||
'begins again with SimpleLogin', () async {
|
||||
await simpleLogin.resetPassword(
|
||||
account,
|
||||
phrase: fakePhrase,
|
||||
newPassword: newPassword,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
simpleLogin.resetPassword(
|
||||
account,
|
||||
phrase: fakePhrase,
|
||||
newPassword: 'yet another horse battery',
|
||||
),
|
||||
failsWith(SimpleLoginProblem.startAgain),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('an account of before, without keys', () {
|
||||
|
|
|
|||
|
|
@ -177,6 +177,42 @@ void main() {
|
|||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('an account whose password was lost opens its keys with the '
|
||||
'recovery phrase under a new password, a phrase that is not its own '
|
||||
'refused', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
dependencies.simpleLoginGateway.addAccount(
|
||||
'person-9',
|
||||
subject: 'simplelogin-anna',
|
||||
handle: 'anna',
|
||||
keys: material.setup,
|
||||
);
|
||||
await start(tester);
|
||||
|
||||
await tapButton(tester, 'Continue with SimpleLogin');
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('A new password'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Back'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Your password'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
const newPassword = 'a new horse battery';
|
||||
await tester.enterText(field('Recovery phrase'), otherFakePhrase);
|
||||
await tester.enterText(field('New password'), newPassword);
|
||||
await tester.enterText(field('New password again'), newPassword);
|
||||
await tapButton(tester, 'Sign in');
|
||||
expect(find.text("The recovery phrase isn't right."), findsOneWidget);
|
||||
|
||||
await tester.enterText(field('Recovery phrase'), fakePhrase);
|
||||
await tapButton(tester, 'Sign in');
|
||||
|
||||
expect(find.text('App of person-9'), findsOneWidget);
|
||||
expect(dependencies.keys.kept, {'person-9': material.keys});
|
||||
});
|
||||
|
||||
testWidgets('someone whose SimpleLogin account was lost links theirs '
|
||||
'again with its handle and recovery phrase', (tester) async {
|
||||
final material = await dependencies.crypto.newKeys(password);
|
||||
|
|
|
|||
Loading…
Reference in a new issue