feat(base): a reset of the keys without the phrase, and the keys' proof key for a password that signs in (ADR 0020, D31–D34)

An account that signs in with SimpleLogin alone, its password and its
recovery phrase both lost, asks for new keys with a SimpleLogin sign-in
(`keys.startReset`); another sign-in carries it out 72 hours later
(`keys.completeReset`, `notDue` with when before), with the same keys
rewrapped where the device still holds them (D33). No mail: every
signed-in device shows it, and any right proof of the password's or the
phrase's key, or a mend, calls it off; `keys.callOffResets` takes the
password's proof alone (D32). The row is ADR 0017's account reset of kind
`account`, so no migration; ADR 0017's reset by mail now takes only an
account whose password signs in.

An account with ADR 0017's password that added SimpleLogin had keys but
no proof key, so no new device signed in with SimpleLogin could open
them: `password.setProofKey` sets the one that same password makes, with
the owner's proof binding it (D34), and ADR 0017's new password takes it
away. Only an account that signs in with SimpleLogin alone changes
through `keys`. `simpleLogin.complete` says whether the password signs
in, whether the keys have the proof key, and when a reset takes effect.

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:51:00 +02:00
commit fe2ef02c92
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -920,6 +920,393 @@ void main() {
});
});
group('a reset, the password and the phrase lost (D31–D33)', () {
late KeysDevice next;
/// The tests' time, moved on by hand.
late DateTime now;
/// Anna's tablet, signed in with SimpleLogin a moment ago, without
/// her keys.
late UuidValue annasTablet;
setUp(() async {
next = await KeysDevice.make(seed: 11);
now = DateTime.now().toUtc();
annasTablet = await signInNewDevice(
session,
anna,
method: 'simplelogin',
);
});
/// [body] at the tests' time.
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
TestSessionBuilder onTablet() => signedIn(anna, annasTablet);
TestSessionBuilder onLaptop() => signedIn(anna, annasLaptop);
/// Asks for the reset on [as] (her tablet unless given), with a
/// SimpleLogin sign-in of hers just now.
Future<ResetPending> start({TestSessionBuilder? as}) => atNow(() async {
final device = as ?? onTablet();
return await endpoints.keys.startReset(
device,
attempt: await signInAgain(device, 'sl-anna'),
);
});
/// Carries it out on [as] (her tablet unless given) with [setup]
/// (the next keys unless given) and a SimpleLogin sign-in of hers
/// just now.
Future<void> complete({TestSessionBuilder? as, KeysSetup? setup}) =>
atNow(() async {
final device = as ?? onTablet();
await endpoints.keys.completeReset(
device,
attempt: await signInAgain(device, 'sl-anna'),
setup: setup ?? next.setup(),
);
});
Future<List<AccountReset>> resetsOf(UuidValue person) =>
AccountReset.db.find(
session,
where: (t) => t.authUserId.equals(person),
);
Future<AccountPassword> keysOf(UuidValue person) async =>
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
))!;
test('is asked with a SimpleLogin sign-in alone, taken, and takes '
'effect 72 hours later; asked again meanwhile, it keeps its '
'time, which her devices and the next sign-in are told', () async {
final attempt = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
final pending = await atNow(
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
);
expect(pending.takesEffectAt, now.add(resetDelay));
expect(
(await resetsOf(anna)).single.kind,
AccountResetKind.account,
);
expect(
await idp().isKept(session, attempt: attempt, caller: anna),
isFalse,
);
now = now.add(const Duration(hours: 1));
expect((await start()).takesEffectAt, pending.takesEffectAt);
expect(
(await atNow(
() => endpoints.account.signInWays(onPhone()),
)).accountResetAt,
pending.takesEffectAt,
);
final signedOut = sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
final completed = await atNow(() async {
final begun = await endpoints.simpleLogin.begin(
signedOut,
back: SimpleLoginReturn.loopback,
);
return await endpoints.simpleLogin.complete(
signedOut,
attempt: begun.attempt,
code: simpleLogin.authorize(
begun.authorizationUrl,
subject: 'sl-anna',
),
);
});
expect(completed.accountResetAt, pending.takesEffectAt);
// Her keys stay as they were until it is carried out.
expect(
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
device.passwordProofKey,
);
});
test('is carried out once due, with a new SimpleLogin sign-in: the '
'new keys in place of the old, her link staying, the reset gone '
'and every other session ended', () async {
final before = await keysOf(anna);
await start();
now = now.add(resetDelay);
await complete();
final row = await keysOf(anna);
expect(row.id, before.id);
expect(row.verifier, isNull);
expect(
Uint8List.sublistView(row.passwordProofKey!),
next.passwordProofKey,
);
expect(
Uint8List.sublistView(row.recoveryProofKey!),
next.recoveryProofKey,
);
expect(
Uint8List.sublistView(row.signingPublicKey),
next.signingPublicKey,
);
expect(await resetsOf(anna), isEmpty);
expect(await sessionsOf(anna), {annasTablet});
expect(
(await OidcIdentity.db.find(
session,
where: (t) => t.authUserId.equals(anna),
)).single.subject,
'sl-anna',
);
await expectLater(
endpoints.keys.unlock(
onTablet(),
await device.prove(await endpoints.keys.begin(onTablet())),
),
refused(PasswordRefusal.wrongCredentials),
);
final keys = await endpoints.keys.unlock(
onTablet(),
await next.prove(await endpoints.keys.begin(onTablet())),
);
expect(
Uint8List.sublistView(keys.signingPublicKey),
next.signingPublicKey,
);
});
test('keeps the keys a device of hers still holds, wrapped under the '
'new password and phrase (D33)', () async {
await start(as: onPhone());
now = now.add(resetDelay);
final rewrapped = next.setup().copyWith(
encryptionPublicKey: ByteData.sublistView(
device.encryptionPublicKey,
),
signingPublicKey: ByteData.sublistView(device.signingPublicKey),
);
await complete(as: onPhone(), setup: rewrapped);
final row = await keysOf(anna);
expect(
Uint8List.sublistView(row.signingPublicKey),
device.signingPublicKey,
);
expect(
Uint8List.sublistView(row.passwordProofKey!),
next.passwordProofKey,
);
expect(await sessionsOf(anna), {annasPhone});
});
test('refuses before its time, saying when; keys out of range; and '
'once it lapsed, or without one, nothing changing', () async {
final pending = await start();
now = pending.takesEffectAt.subtract(const Duration(minutes: 1));
await expectLater(
complete(),
throwsA(
isA<PasswordRefusedException>()
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
.having(
(e) => e.takesEffectAt,
'takesEffectAt',
pending.takesEffectAt,
),
),
);
now = pending.takesEffectAt;
await expectLater(
complete(
setup: next.setup().copyWith(salt: ByteData(3)),
),
refused(PasswordRefusal.invalidSetup),
);
expect(await resetsOf(anna), hasLength(1));
now = now.add(resetLapsesAfter);
await expectLater(complete(), refused(PasswordRefusal.noReset));
await AccountReset.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(anna),
);
await expectLater(complete(), refused(PasswordRefusal.noReset));
expect(
Uint8List.sublistView((await keysOf(anna)).passwordProofKey!),
device.passwordProofKey,
);
expect(
await sessionsOf(anna),
containsAll([annasPhone, annasLaptop]),
);
});
test("refuses without a SimpleLogin sign-in of hers: none, another's, "
'one run out or taken, asking for nothing then', () async {
final bens = await atNow(
() => signInAgain(signedIn(ben, bensLaptop), 'sl-ben'),
);
final ranOut = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
now = now.add(const Duration(minutes: 11));
for (final attempt in [const Uuid().v4obj(), bens, ranOut]) {
await expectLater(
atNow(
() => endpoints.keys.startReset(onTablet(), attempt: attempt),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
}
expect(await resetsOf(anna), isEmpty);
await start();
now = now.add(resetDelay);
final taken = await atNow(() => signInAgain(onTablet(), 'sl-anna'));
await atNow(
() => endpoints.keys.completeReset(
onTablet(),
attempt: taken,
setup: next.setup(),
),
);
await start();
now = now.add(resetDelay);
final other = (await KeysDevice.make(seed: 12)).setup();
await expectLater(
atNow(
() => endpoints.keys.completeReset(
onTablet(),
attempt: taken,
setup: other,
),
),
refusedAsOidc(OidcRefusal.unknownAttempt),
);
expect(await resetsOf(anna), hasLength(1));
});
test('is for an account with such keys alone: none, or a password '
'that signs in (ADR 0017, its reset by mail)', () async {
final bens = signedIn(ben, bensLaptop);
Future<void> startAndComplete() => atNow(() async {
await expectLater(
endpoints.keys.startReset(
bens,
attempt: await signInAgain(bens, 'sl-ben'),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.completeReset(
bens,
attempt: await signInAgain(bens, 'sl-ben'),
setup: next.setup(),
),
refused(PasswordRefusal.noPassword),
);
});
await startAndComplete();
await AccountPassword.db.insertRow(
session,
passwordRowOf(
ben,
(await PasswordDevice.make()).setup(),
now: DateTime.now().toUtc(),
),
);
await startAndComplete();
expect(await resetsOf(ben), isEmpty);
});
test("is called off by her password's proof on a signed-in device "
'(D32), never by another key, nor a proof for another '
'purpose', () async {
await start();
Future<void> callOff(KeysProof proof) =>
endpoints.keys.callOffResets(onPhone(), proof: proof);
for (final keys in [
next.passwordProofKeys,
device.recoveryProofKeys,
]) {
await expectLater(
callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
keys: keys,
),
),
refused(PasswordRefusal.wrongCredentials),
);
}
await expectLater(
callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPhrase,
),
),
refused(PasswordRefusal.wrongCredentials),
);
expect(await resetsOf(anna), hasLength(1));
await callOff(
await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
),
);
expect(await resetsOf(anna), isEmpty);
await expectLater(
endpoints.keys.callOffResets(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
),
proof: await device.prove(
await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.callOffResets,
),
),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
});
test('is called off by any proof of her password or her phrase: '
'opening her keys on a device, or a lost password set '
'anew', () async {
await start();
await endpoints.keys.unlock(
onLaptop(),
await device.prove(await endpoints.keys.begin(onLaptop())),
);
expect(await resetsOf(anna), isEmpty);
await start();
final attempt = await atNow(() => signInAgain(onPhone(), 'sl-anna'));
await atNow(
() async => await endpoints.keys.resetPassword(
onPhone(),
proof: await device.ownerProof(
attempt,
phrase: await endpoints.keys.beginPhrase(onPhone()),
purpose: KeyProofPurpose.passwordReset,
digest: newKeysPasswordDigest(next.password()),
),
password: next.password(),
),
);
expect(await resetsOf(anna), isEmpty);
});
});
group('the ways in', () {
test('say the account has keys, and no password that signs in, nor '
'an authenticator to add', () async {