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
7 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 '

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 {

View file

@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/halkyon_app.dart' hide SignInWays;
import 'package:halkyon_base_client/halkyon_base_client.dart';
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'
show OidcRefusal, OidcRefusedException, OidcStart;
@ -35,9 +35,26 @@ final class _Instance() {
/// Each call, by its endpoint and method, with the session it carried.
final calls = <({String call, String? session})>[];
/// What each call sent, by its endpoint and method.
final bodies = <String, String>{};
/// What a sign-in ends with.
SimpleLoginCompleted completed = SimpleLoginCompleted(
outcome: SimpleLoginOutcome.newcomer,
suggestedHandle: 'anna_mueller',
hasKeys: false,
);
/// The ways into the device's person's account.
SignInWays ways = SignInWays(gitHub: false, email: false);
/// When a reset asked for takes effect.
static final resetAt = DateTime.utc(2026, 10, 27, 12);
http.Client client() => MockClient((request) async {
final call = request.url.path.substring(1);
calls.add((call: call, session: request.headers['authorization']));
bodies[call] = request.body;
if (refusals[call] case final refusal?) {
return switch (refusal) {
final int status => http.Response('', status),
@ -74,11 +91,31 @@ final class _Instance() {
200,
),
'halkyon_base.simpleLogin/complete' => http.Response(
sent(completed.toJson()),
200,
),
'halkyon_base.account/signInWays' => http.Response(
sent(ways.toJson()),
200,
),
'halkyon_base.keys/startReset' => http.Response(
sent(ResetPending(takesEffectAt: resetAt).toJson()),
200,
),
'halkyon_base.keys/completeReset' ||
'halkyon_base.keys/callOffResets' ||
'halkyon_base.password/setProofKey' => http.Response('null', 200),
'halkyon_base.password/beginChange' => http.Response(
sent(
SimpleLoginCompleted(
outcome: SimpleLoginOutcome.newcomer,
suggestedHandle: 'anna_mueller',
hasKeys: false,
PasswordChallenge(
challenge: attempt,
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
serverValue: ByteData(384),
).toJson(),
),
200,
@ -283,6 +320,204 @@ void main() {
expect(sessionOf('halkyon_base.keys/beginPhrase'), contains('anna-token'));
});
group('a reset of the keys and their proof key (ADR 0020, D31–D35)', () {
final session = AuthSuccess(
authStrategy: 'session',
token: 'anna-token',
authUserId: UuidValue.withValidation(
'0191f0c4-2222-7000-8000-000000000002',
),
scopeNames: {'identified'},
);
final grant = SessionGrant(session.authUserId.uuid, session);
test("says when a reset of the device's person's keys takes effect, only "
'for keys their password opens alone', () async {
instance.ways = SignInWays(
gitHub: false,
email: false,
simpleLogin: true,
keys: true,
accountResetAt: _Instance.resetAt,
);
expect(await gateway.standing(), (
simpleLogin: true,
keysResetAt: _Instance.resetAt,
));
expect(sessionOf('halkyon_base.account/signInWays'), contains('device'));
instance.ways = instance.ways.copyWith(password: true);
expect(await gateway.standing(), (simpleLogin: true, keysResetAt: null));
});
test('says on arrival whether the password signs in, whether the keys '
'lack their proof key, and when a reset of them takes effect, but for '
"a password that signs in, whose resets are ADR 0017's", () async {
final start = await gateway.begin(SimpleLoginReturn.scheme);
instance.completed = SimpleLoginCompleted(
outcome: SimpleLoginOutcome.signedIn,
session: session,
hasKeys: true,
handle: 'anna',
hasProofKey: true,
accountResetAt: _Instance.resetAt,
);
expect(
await gateway.complete(start.attempt, 'code'),
isA<SimpleLoginAccount>()
.having((account) => account.passwordSignsIn, 'signs in', isFalse)
.having((account) => account.proofKeyMissing, 'missing', isFalse)
.having((account) => account.resetAt, 'reset', _Instance.resetAt),
);
instance.completed = instance.completed.copyWith(
passwordSignsIn: true,
hasProofKey: false,
);
expect(
await gateway.complete(start.attempt, 'code'),
isA<SimpleLoginAccount>()
.having((account) => account.passwordSignsIn, 'signs in', isTrue)
.having((account) => account.proofKeyMissing, 'missing', isTrue)
.having((account) => account.resetAt, 'reset', isNull),
);
});
test("asks for and carries out a reset on the account's own session, not "
"yet the device's, and calls it off on the device's", () async {
expect(
await gateway.startReset(_Instance.attempt, as: grant),
_Instance.resetAt,
);
await gateway.completeReset(
_Instance.attempt,
KeysSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
passwordProofKey: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
recoveryProofKey: ByteData(32),
),
as: grant,
);
await gateway.callOffResets(
KeysProof(
challenge: _Instance.attempt,
signature: ByteData(64),
instanceUrl: gateway.instanceUrl,
),
);
expect(sessionOf('halkyon_base.keys/startReset'), contains('anna-token'));
expect(
sessionOf('halkyon_base.keys/completeReset'),
contains('anna-token'),
);
expect(
sessionOf('halkyon_base.keys/callOffResets'),
contains('device-token'),
);
});
test('a reset before its time says when', () async {
instance.refusals['halkyon_base.keys/completeReset'] =
PasswordRefusedException(
reason: PasswordRefusal.notDue,
takesEffectAt: _Instance.resetAt,
);
await expectLater(
gateway.completeReset(
_Instance.attempt,
KeysSetup(
salt: ByteData(16),
costs: PasswordCosts(
algorithm: 'argon2id13',
opsLimit: 3,
memLimit: 64 * 1024 * 1024,
),
passwordProofKey: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(48),
),
recoveryProofKey: ByteData(32),
),
as: grant,
),
throwsA(
isA<PasswordFailed>()
.having(
(failed) => failed.problem,
'problem',
PasswordProblem.notDue,
)
.having(
(failed) => failed.takesEffectAt,
'when',
_Instance.resetAt,
),
),
);
});
test("sets the keys' proof key with the owner's proof and its code, on "
"the account's own session", () async {
final challenge = await gateway.beginOwnerProof(as: grant);
await gateway.setProofKey(
(
challenge: challenge,
answer: SrpAnswer(
clientValue: ByteData(384),
clientProof: ByteData(32),
verifies: (_) => true,
),
totpCode: '123456',
recoveryCode: null,
),
ByteData(32),
as: grant,
);
expect(
sessionOf('halkyon_base.password/beginChange'),
contains('anna-token'),
);
expect(
sessionOf('halkyon_base.password/setProofKey'),
contains('anna-token'),
);
final sent = jsonDecode(
instance.bodies['halkyon_base.password/setProofKey']!,
) as Map<String, Object?>;
expect(sent['proof'], containsPair('totpCode', '123456'));
expect(
sent['proof'],
containsPair('instanceUrl', gateway.instanceUrl),
);
expect(sent, contains('proofKey'));
});
});
test("puts the instance's refusals of SimpleLogin as the app's problems", () {
expect(
{

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
@ -387,6 +388,251 @@ void main() {
});
});
group('a reset of the keys, the password and the phrase lost (ADR 0020, '
'D31, D33)', () {
const newPassword = 'a new horse battery';
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,
);
});
Future<SimpleLoginAccount> signedIn({bool link = false}) async =>
(await simpleLogin.signIn(link: link))! as SimpleLoginAccount;
test('is asked for with a SimpleLogin sign-in, which it takes, and '
'takes effect 72 hours later; asked again, it keeps its time, which '
'the next sign-in says', () async {
await withClock(Clock.fixed(asked), () async {
final account = await signedIn();
expect(account.resetAt, isNull);
expect(await simpleLogin.startReset(account), due);
await expectLater(
simpleLogin.startReset(account),
failsWith(SimpleLoginProblem.startAgain),
);
});
await withClock(
Clock.fixed(asked.add(const Duration(hours: 1))),
() async {
final again = await signedIn();
expect(again.resetAt, due);
expect(await simpleLogin.startReset(again), due);
},
);
expect(auth.usedSessions, isEmpty, reason: 'nobody signed in with it');
expect(keys.kept, isEmpty);
});
test('once due, gives the account new keys under a new password and '
'phrase: once entered, the session and the new keys are the '
"device's, and the old password opens nothing", () async {
await withClock(Clock.fixed(asked), () async {
await simpleLogin.startReset(await signedIn());
});
await withClock(Clock.fixed(due), () async {
final account = await signedIn();
final made = await simpleLogin.makeKeys(newPassword, into: account);
expect(made.keys, isNot(material.keys));
await simpleLogin.enter(
await simpleLogin.completeReset(account, made: made),
);
expect(auth.usedSessions, [account.grant]);
expect(keys.kept, {'person-9': made.keys});
expect(gateway.accounts['person-9']!.keys, made.setup);
expect(gateway.accounts['person-9']!.resetAt, isNull);
await expectLater(
simpleLogin.unlock(account, password),
keysFailWith(PasswordProblem.wrongCredentials),
);
});
});
test('is refused before its time, saying when, and with nothing '
'asked', () async {
await withClock(Clock.fixed(asked), () async {
final account = await signedIn();
final made = await simpleLogin.makeKeys(newPassword, into: account);
await expectLater(
simpleLogin.completeReset(account, made: made),
keysFailWith(PasswordProblem.noReset),
);
await simpleLogin.startReset(await signedIn());
});
await withClock(
Clock.fixed(due.subtract(const Duration(minutes: 1))),
() async {
final account = await signedIn();
final made = await simpleLogin.makeKeys(newPassword, into: account);
await expectLater(
simpleLogin.completeReset(account, made: made),
throwsA(
isA<PasswordFailed>()
.having(
(failed) => failed.problem,
'problem',
PasswordProblem.notDue,
)
.having((failed) => failed.takesEffectAt, 'when', due),
),
);
},
);
expect(gateway.accounts['person-9']!.keys, material.setup);
expect(keys.kept, isEmpty);
});
test('carried out on a device that holds the keys, signed in, keeps them '
'under the new password and phrase (D33)', () async {
await withClock(Clock.fixed(asked), () async {
await simpleLogin.startReset(await signedIn());
});
await auth.useSession(const SessionGrant('person-9', 'session-9'));
keys.kept['person-9'] = material.keys;
await withClock(Clock.fixed(due), () async {
final account = await signedIn(link: true);
expect(account.grant, isNull);
expect(account.resetAt, due);
final made = await simpleLogin.makeKeys(newPassword, into: account);
expect(made.keys, material.keys);
await simpleLogin.enter(
await simpleLogin.completeReset(account, made: made),
);
expect(auth.usedSessions, [
const SessionGrant('person-9', 'session-9'),
]);
expect(keys.kept, {'person-9': material.keys});
expect(gateway.accounts['person-9']!.keys, made.setup);
});
});
group('called off on a signed-in device (D32)', () {
setUp(() async {
await withClock(Clock.fixed(asked), () async {
await simpleLogin.startReset(await signedIn());
});
await auth.useSession(const SessionGrant('person-9', 'session-9'));
keys.kept['person-9'] = material.keys;
});
test('with the password, read anew everywhere', () async {
var changes = 0;
final following = simpleLogin.changes.listen((_) => changes++);
addTearDown(following.cancel);
expect(
await simpleLogin.standing(),
SimpleLoginLinked(resetAt: due, keysHere: true),
);
await simpleLogin.callOffReset(password);
await pumpEventQueue();
expect(gateway.accounts['person-9']!.resetAt, isNull);
expect(changes, greaterThan(0));
expect(await simpleLogin.standing(), const SimpleLoginLinked());
expect(crypto.unlocks.every((unlock) => unlock.isForgotten), isTrue);
});
test('not with a wrong password', () async {
await expectLater(
simpleLogin.callOffReset('wrong horse battery'),
keysFailWith(PasswordProblem.wrongCredentials),
);
expect(gateway.accounts['person-9']!.resetAt, due);
});
});
});
group("the keys' proof key of a password that signs in (ADR 0020, "
'D34)', () {
late NewKeysMaterial material;
late SimpleLoginAccount account;
setUp(() async {
material = await crypto.newKeys(password);
gateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
passwordSigningIn: password,
proofKeyMissing: true,
);
account = (await simpleLogin.signIn())! as SimpleLoginAccount;
});
test('is missing, as the sign-in says, so the password opens '
'nothing yet', () async {
expect(account.passwordSignsIn, isTrue);
expect(account.proofKeyMissing, isTrue);
await expectLater(
simpleLogin.unlock(account, password),
keysFailWith(PasswordProblem.noPassword),
);
});
test('is set by the password and a code, as the key that password makes, '
'so the password then opens the keys', () async {
await simpleLogin.addProofKey(
account,
password: password,
code: '123 456',
);
expect(gateway.accounts['person-9']!.proofKeyMissing, isFalse);
expect(crypto.unlocks.single.isForgotten, isTrue);
expect(
(await simpleLogin.unlock(account, password)).keys,
material.keys,
);
});
test('is not set by a wrong password, nor without the right '
'code', () async {
await expectLater(
simpleLogin.addProofKey(
account,
password: 'wrong horse battery',
code: fakeTotpCode,
),
keysFailWith(PasswordProblem.wrongCredentials),
);
await expectLater(
simpleLogin.addProofKey(account, password: password, code: '000000'),
keysFailWith(PasswordProblem.wrongCode),
);
expect(gateway.accounts['person-9']!.proofKeyMissing, isTrue);
expect(crypto.unlocks.every((unlock) => unlock.isForgotten), isTrue);
});
test('nor is a reset of the keys asked for: the password resets as '
'ADR 0017 has it', () async {
gateway.accounts['person-9']!.proofKeyMissing = false;
final again = (await simpleLogin.signIn())! as SimpleLoginAccount;
await expectLater(
simpleLogin.startReset(again),
keysFailWith(PasswordProblem.noPassword),
);
});
});
group('an account of before, without keys', () {
late SimpleLoginAccount account;
@ -564,6 +810,34 @@ void main() {
expect(await simpleLogin.standing(), const SimpleLoginLinked());
});
test('a reset of its keys waiting says when, and whether the device '
'holds the keys, which keep it (D35, D33)', () async {
final at = DateTime.utc(2026, 10, 27, 12);
gateway.addAccount('person-9', subject: 'simplelogin-ben', resetAt: at);
await auth.useSession(const SessionGrant('person-9', 'session-9'));
expect(await simpleLogin.standing(), SimpleLoginLinked(resetAt: at));
keys.kept['person-9'] = crypto.keysNumbered(1);
expect(
await simpleLogin.standing(),
SimpleLoginLinked(resetAt: at, keysHere: true),
);
});
test('an account whose password signs in shows none: its resets are '
"ADR 0017's, in its own section", () async {
gateway.addAccount(
'person-9',
subject: 'simplelogin-ben',
passwordSigningIn: password,
resetAt: DateTime.utc(2026, 10, 27, 12),
);
await auth.useSession(const SessionGrant('person-9', 'session-9'));
expect(await simpleLogin.standing(), const SimpleLoginLinked());
});
test('changes once SimpleLogin is added, and with the person', () async {
await auth.switchToGitHub();
var changes = 0;

View file

@ -0,0 +1,171 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:material_ui/material_ui.dart';
const password = 'correct horse battery';
void main() {
late FakeAuthGateway gateway;
late TestAppDependencies dependencies;
late NewKeysMaterial material;
setUp(() async {
gateway = FakeAuthGateway();
dependencies = TestAppDependencies(gateway: gateway);
dependencies.simpleLoginGateway.isOffered = true;
material = await dependencies.crypto.newKeys(password);
await gateway.useSession(const SessionGrant('person-9', 'session-9'));
});
tearDown(() => dependencies.dispose());
/// An account that signs in with SimpleLogin, its reset of the keys
/// taking effect [at].
void resetAt(DateTime at) => dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
resetAt: at,
);
/// An app's home, with the banner at its top, and its account section.
Future<void> show(WidgetTester tester) async {
tester.view.physicalSize = const Size(412, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
testApp(
const Scaffold(
body: Column(
children: [
KeysResetBanner(),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SimpleLoginSection(gapAbove: 16),
),
],
),
),
dependencies: dependencies,
),
);
await tester.pumpAndSettle();
}
Finder field(String label) => find.widgetWithText(TextField, label);
Future<void> tapButton(WidgetTester tester, String label) async {
final button = find.widgetWithText(FilledButton, label).last;
await tester.ensureVisible(button);
await tester.tap(button);
await tester.pumpAndSettle();
}
testWidgets('nothing while no reset of the keys waits', (tester) async {
dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
);
await show(tester);
expect(find.byType(MaterialBanner), findsNothing);
expect(find.text('A reset is waiting'), findsNothing);
expect(find.text('Signs you in'), findsOneWidget);
});
testWidgets('a reset waiting shows at the top of the home and in the '
'section, saying when; the password alone calls it off, a wrong one '
'refused (ADR 0020, D35, D32)', (tester) async {
resetAt(clock.now().add(const Duration(hours: 72)));
dependencies.keys.kept['person-9'] = material.keys;
await show(tester);
expect(find.byType(MaterialBanner), findsOneWidget);
expect(find.text('A reset is waiting'), findsOneWidget);
expect(
find.textContaining('whoever has it can set them up'),
findsNWidgets(2),
);
expect(find.text('Finish here'), findsNothing);
await tester.tap(find.widgetWithText(TextButton, 'Call it off').first);
await tester.pumpAndSettle();
expect(find.text('Call off the reset'), findsOneWidget);
await tester.enterText(field('Password'), 'wrong horse battery');
await tapButton(tester, 'Call it off');
expect(find.text("The password isn't right."), findsOneWidget);
await tester.enterText(field('Password'), password);
await tapButton(tester, 'Call it off');
expect(find.text('Reset called off.'), findsOneWidget);
expect(find.byType(MaterialBanner), findsNothing);
expect(find.text('A reset is waiting'), findsNothing);
expect(
dependencies.simpleLoginGateway.accounts['person-9']!.resetAt,
isNull,
);
});
testWidgets('due on a device that holds the keys, it is finished here: a '
'new password and phrase for the same keys (D33)', (tester) async {
resetAt(clock.now().subtract(const Duration(hours: 1)));
dependencies.keys.kept['person-9'] = material.keys;
await show(tester);
expect(find.textContaining('finish here'), findsNWidgets(2));
await tester.tap(find.widgetWithText(TextButton, 'Finish here').first);
await tester.pumpAndSettle();
expect(find.text('A new password'), findsOneWidget);
expect(
find.textContaining('Choose a new password: your keys stay'),
findsOneWidget,
);
const newPassword = 'a new horse battery';
await tester.enterText(field('New password'), newPassword);
await tester.enterText(field('New password again'), newPassword);
await tapButton(tester, 'Continue');
await tapButton(tester, "I've written it down");
final words = wordsOf(fakePhrase);
for (final element in find.byType(TextField).evaluate()) {
if ((element.widget as TextField).decoration?.labelText case final label?
when label.startsWith('Word ')) {
await tester.enterText(
field(label),
words[int.parse(label.substring('Word '.length)) - 1],
);
}
}
await tapButton(tester, 'Set up the keys');
expect(find.text('New password and recovery phrase set.'), findsOneWidget);
expect(find.byType(MaterialBanner), findsNothing);
expect(dependencies.keys.kept, {'person-9': material.keys});
final account = dependencies.simpleLoginGateway.accounts['person-9']!;
expect(account.keys, isNot(material.setup));
// The same keys, wrapped anew.
expect(
Uint8List.sublistView(account.keys!.encryptionPublicKey),
Uint8List.sublistView(material.setup.encryptionPublicKey),
);
});
testWidgets('due on a device without the keys, it says whoever has the '
'SimpleLogin account can set them up now, and offers only calling it '
'off', (tester) async {
resetAt(clock.now().subtract(const Duration(hours: 1)));
await show(tester);
expect(
find.textContaining('whoever has it can set them up now'),
findsNWidgets(2),
);
expect(find.text('Finish here'), findsNothing);
expect(find.widgetWithText(TextButton, 'Call it off'), findsNWidgets(2));
});
}

View file

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
@ -213,6 +214,132 @@ void main() {
expect(dependencies.keys.kept, {'person-9': material.keys});
});
testWidgets('with the recovery phrase lost too, new keys are asked for, '
'72 hours later; the next sign-in says when, and the password calls it '
'off (ADR 0020, D31)', (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();
await tester.tap(
find.widgetWithText(TextButton, 'Lost your recovery phrase too?'),
);
await tester.pumpAndSettle();
expect(find.text('New keys'), findsOneWidget);
expect(find.textContaining('72 hours from now'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Back'));
await tester.pumpAndSettle();
expect(find.text('A new password'), findsOneWidget);
await tester.tap(
find.widgetWithText(TextButton, 'Lost your recovery phrase too?'),
);
await tester.pumpAndSettle();
await tapButton(tester, 'Ask for new keys');
expect(find.text('New keys asked for'), findsOneWidget);
final at = dependencies.simpleLoginGateway.accounts['person-9']!.resetAt;
expect(at, isNotNull);
await tapButton(tester, 'Close');
expect(find.text('Continue with SimpleLogin'), findsOneWidget);
expect(gateway.status, const SignedOut());
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.text('A reset is waiting'), findsOneWidget);
await tester.enterText(field('Password'), password);
await tapButton(tester, 'Open');
expect(find.text('App of person-9'), findsOneWidget);
expect(
dependencies.simpleLoginGateway.accounts['person-9']!.resetAt,
isNull,
);
});
testWidgets('a reset due gives new keys with a new password and phrase, or '
'the old ones open with the password', (tester) async {
final material = await dependencies.crypto.newKeys(password);
dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
resetAt: clock.now().subtract(const Duration(hours: 1)),
);
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.text('Your new keys'), findsOneWidget);
await tester.tap(find.widgetWithText(ListTile, 'I have my password'));
await tester.pumpAndSettle();
expect(find.text('Your password'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Back'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Set up new keys'));
await tester.pumpAndSettle();
expect(find.text('New keys'), findsOneWidget);
const newPassword = 'a new horse battery';
await tester.enterText(field('New password'), newPassword);
await tester.enterText(field('New password again'), newPassword);
await tapButton(tester, 'Continue');
await tapButton(tester, "I've written it down");
await typeCheckedWords(tester, fakePhrase);
await tapButton(tester, 'Set up the keys');
expect(find.text('App of person-9'), findsOneWidget);
expect(dependencies.keys.kept['person-9'], isNot(material.keys));
});
testWidgets('an account whose password signs in too gives its keys their '
'proof key with the password and a code, once, and offers no lost '
'password here (ADR 0020, D34)', (tester) async {
final material = await dependencies.crypto.newKeys(password);
dependencies.simpleLoginGateway.addAccount(
'person-9',
subject: 'simplelogin-anna',
handle: 'anna',
keys: material.setup,
passwordSigningIn: password,
proofKeyMissing: true,
);
await start(tester);
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.text('Your password and a code'), findsOneWidget);
expect(find.textContaining('as @anna'), findsOneWidget);
await tester.enterText(field('Password'), password);
await tester.enterText(field('Code from your authenticator app'), '000000');
await tapButton(tester, 'Open');
expect(
find.text("This code isn't right. Enter the one your app shows now."),
findsOneWidget,
);
await tester.enterText(field('Password'), password);
await tester.enterText(
field('Code from your authenticator app'),
fakeTotpCode,
);
await tapButton(tester, 'Open');
expect(find.text('App of person-9'), findsOneWidget);
expect(dependencies.keys.kept, {'person-9': material.keys});
await gateway.signOut();
await tester.pumpAndSettle();
await tapButton(tester, 'Continue with SimpleLogin');
expect(find.text('Your password'), findsOneWidget);
expect(find.text('Forgot your password?'), findsNothing);
});
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);