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

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -326,6 +327,192 @@ void main() {
});
});
group('the password and the phrase lost: a reset of the keys (ADR 0020, '
'D31, D33)', () {
final asked = DateTime.utc(2026, 10, 24, 12);
final due = asked.add(const Duration(hours: 72));
late NewKeysMaterial material;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
);
});
test('is asked for from the lost password, saying when it takes '
'effect; nobody is signed in meanwhile', () async {
await withClock(Clock.fixed(asked), () async {
final cubit = cubitFor();
await cubit.started();
cubit
..forgotChosen()
..phraseLost();
expect(cubit.state, at(SimpleLoginStep.resetAsk));
cubit.forgotChosen();
expect(cubit.state, at(SimpleLoginStep.forgot));
cubit.phraseLost();
await cubit.resetAsked();
expect(cubit.state, at(SimpleLoginStep.resetAsked));
expect(cubit.state.resetAt, due);
expect(gateway.accounts['person-9']!.resetAt, due);
expect(auth.status, const SignedOut());
expect(keys.kept, isEmpty);
cubit.finished();
expect(cubit.state, at(SimpleLoginStep.closed));
});
});
test('waiting, the next sign-in says when, and the password opening the '
'keys calls it off', () async {
gateway.accounts['person-9']!.resetAt = due;
await withClock(Clock.fixed(asked), () async {
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.unlock));
expect(cubit.state.resetAt, due);
await cubit.unlockEntered(password);
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.resetAt, isNull);
});
});
test('due, gives new keys under a new password and phrase, or opens the '
'old ones with the password', () async {
gateway.accounts['person-9']!.resetAt = due;
await withClock(Clock.fixed(due), () async {
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.resetDue));
cubit.passwordChosen();
expect(cubit.state, at(SimpleLoginStep.unlock));
cubit.resetDueShownAgain();
expect(cubit.state, at(SimpleLoginStep.resetDue));
cubit.newKeysChosen();
expect(cubit.state, at(SimpleLoginStep.password));
expect(cubit.state.resetting, isTrue);
await makeKeys(cubit);
expect(cubit.state, at(SimpleLoginStep.done));
expect(
auth.status,
const SignedIn(authUserId: 'person-9', isIdentified: true),
);
final account = gateway.accounts['person-9']!;
expect(account.keys, isNot(material.setup));
expect(account.resetAt, isNull);
expect(keys.kept['person-9'], isNot(material.keys));
});
});
test('due on a device that holds the keys, signed in, keeps them under '
'the new password and phrase (D33)', () async {
gateway.accounts['person-9']!.resetAt = due;
await auth.useSession(const SessionGrant('person-9', 'session-9'));
keys.kept['person-9'] = material.keys;
await withClock(Clock.fixed(due), () async {
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.password));
expect(cubit.state.resetting, isTrue);
await makeKeys(cubit);
expect(cubit.state, at(SimpleLoginStep.done));
expect(keys.kept, {'person-9': material.keys});
expect(gateway.accounts['person-9']!.resetAt, isNull);
expect(auth.usedSessions, hasLength(1), reason: 'no other session');
});
});
test('waiting on a device that holds the keys, adding SimpleLogin is done '
'at once', () async {
gateway.accounts['person-9']!.resetAt = due;
await auth.useSession(const SessionGrant('person-9', 'session-9'));
await withClock(Clock.fixed(asked), () async {
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.resetAt, due);
});
});
});
group('a password that signs in too (ADR 0020, D34)', () {
late NewKeysMaterial material;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
passwordSigningIn: password,
proofKeyMissing: true,
);
});
test('opens the keys once it and a code set their proof key, a wrong '
'code refused', () async {
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.proofKey));
expect(cubit.state.signedInAs, 'anna');
await cubit.proofKeyEntered(password, '000000');
expect(
cubit.state,
at(
SimpleLoginStep.proofKey,
problem: const PasswordFailed(PasswordProblem.wrongCode),
),
);
expect(auth.status, const SignedOut());
await cubit.proofKeyEntered(password, fakeTotpCode);
expect(cubit.state, at(SimpleLoginStep.done));
expect(keys.kept, {'person-9': material.keys});
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
});
test('with the proof key, is asked alone, without the lost phrase, as its '
"resets are ADR 0017's", () async {
gateway.accounts['person-9']!.proofKeyMissing = false;
final cubit = cubitFor();
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.unlock));
expect(cubit.state.passwordSignsIn, isTrue);
});
test('adding SimpleLogin on a device signed in asks the same, and keeps '
'its session', () async {
await auth.useSession(const SessionGrant('person-9', 'session-9'));
keys.kept['person-9'] = material.keys;
final cubit = cubitFor(link: true);
await cubit.started();
expect(cubit.state, at(SimpleLoginStep.proofKey));
await cubit.proofKeyEntered(password, fakeTotpCode);
expect(cubit.state, at(SimpleLoginStep.done));
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
expect(auth.usedSessions, hasLength(1), reason: 'no other session');
expect(keys.kept, {'person-9': material.keys});
});
});
group("SimpleLogin added to the account of the device's person", () {
test('a person without keys keeps those their device holds, and their '
'session, once the keys are set', () async {