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
2 changed files with 1973 additions and 52 deletions

View file

@ -15,6 +15,7 @@ import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/keys_device.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
@ -1243,6 +1244,179 @@ void main() {
);
});
});
group("the keys' proof key, for SimpleLogin (ADR 0020, D34)", () {
/// The proof key her password makes for her keys, as a device
/// derives it beside SRP's x.
late KeysDevice keys;
setUp(() async => keys = await KeysDevice.make(seed: 31));
/// Sets [key] (her password's unless given) with the owner's proof
/// made for [provedFor] (the key sent unless given) and [purpose],
/// by her password (or [privateValue]'s) and a code (or [totpCode],
/// or none).
Future<void> setProofKey({
ByteData? key,
ByteData? provedFor,
PasswordProofPurpose purpose = PasswordProofPurpose.proofKey,
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
}) => atNow(() async {
final sent = key ?? ByteData.sublistView(keys.passwordProofKey);
await endpoints.password.setProofKey(
onPhone(),
proof: device.ownerProof(
await endpoints.password.beginChange(onPhone()),
purpose: purpose,
digest: proofKeyDigest(provedFor ?? sent),
privateValue: privateValue,
totpCode: withoutCode
? null
: totpCode ?? codeOf(enrolment, at: now),
),
proofKey: sent,
);
});
Future<AccountPassword> row() async =>
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
test('is set with her password and a code, and her keys then open '
'with the password alone on a device signed in with SimpleLogin, '
'as they open on her phone', () async {
await expectLater(
endpoints.keys.begin(onLaptop()),
refused(PasswordRefusal.noPassword),
);
await setProofKey();
final challenge = await endpoints.keys.begin(onLaptop());
expect(Uint8List.sublistView(challenge.salt), device.salt);
final opened = await endpoints.keys.unlock(
onLaptop(),
await keys.prove(challenge),
);
expect(opened.passwordKeys.toJson(), device.passwordKeys.toJson());
expect(
Uint8List.sublistView(opened.signingPublicKey),
device.signingPublicKey,
);
expect((await row()).verifier!.lengthInBytes, isPositive);
});
test('refuses without a code, a wrong code or password, a proof for '
'another key or purpose, and a key out of range, setting '
'none', () async {
await expectLater(
setProofKey(withoutCode: true),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
setProofKey(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
await expectLater(
setProofKey(privateValue: BigInt.from(7)),
refused(PasswordRefusal.wrongCredentials),
);
nextStep();
await expectLater(
setProofKey(provedFor: ByteData(32)),
refused(PasswordRefusal.wrongCredentials),
);
nextStep();
await expectLater(
setProofKey(purpose: PasswordProofPurpose.newPhrase),
refused(PasswordRefusal.wrongCredentials),
);
nextStep();
await expectLater(
setProofKey(key: ByteData(31)),
refused(PasswordRefusal.invalidSetup),
);
expect((await row()).passwordProofKey, isNull);
});
test("changes nothing through the keys' own changes, which are an "
"account's that signs in with SimpleLogin alone: her password "
'still signs in', () async {
await setProofKey();
nextStep();
final other = await KeysDevice.make(seed: 32);
final before = await row();
final attempt = const Uuid().v7obj();
await expectLater(
endpoints.keys.changePassword(
onPhone(),
proof: await keys.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPassword,
digest: newKeysPasswordDigest(other.password()),
),
password: other.password(),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.beginPhrase(onPhone()),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.setRecoveryKeys(
onPhone(),
proof: await keys.ownerProof(
attempt,
challenge: await endpoints.keys.begin(onPhone()),
purpose: KeyProofPurpose.newPhrase,
digest: newPhraseDigest(
other.recoveryKeys,
ByteData.sublistView(other.recoveryProofKey),
),
),
recoveryKeys: other.recoveryKeys,
recoveryProofKey: ByteData.sublistView(other.recoveryProofKey),
),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.startReset(onPhone(), attempt: attempt),
refused(PasswordRefusal.noPassword),
);
await expectLater(
endpoints.keys.completeReset(
onPhone(),
attempt: attempt,
setup: other.setup(),
),
refused(PasswordRefusal.noPassword),
);
final after = await row();
expect(after.toJson(), before.toJson());
expect((await signIn()).authUserId, anna);
});
test('goes with a new password, as its key was made of the old: set '
'again the same way', () async {
await setProofKey();
nextStep();
await change();
expect((await row()).passwordProofKey, isNull);
await expectLater(
endpoints.keys.begin(onLaptop()),
refused(PasswordRefusal.noPassword),
);
});
});
},
);
}

View file

@ -13,6 +13,7 @@ import 'package:test/test.dart';
import '../support/curve25519.dart';
import '../support/fixtures.dart';
import '../support/keys_device.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
@ -3325,6 +3326,35 @@ void main() {
);
});
test('is no way into an account whose password only opens its keys, '
"as SimpleLogin's do (ADR 0020, D31): its address's codes ask "
'for no reset and carry out none waiting', () async {
await giveKeys(session, anna, await KeysDevice.make());
await expectLater(start(), refused(PasswordRefusal.noPassword));
// One its SimpleLogin sign-in asked for (`keys.startReset`), due.
await AccountReset.db.insertRow(
session,
AccountReset(
authUserId: anna,
kind: AccountResetKind.account,
takesEffectAt: now.subtract(const Duration(hours: 1)),
createdAt: now.subtract(resetDelay),
),
);
await expectLater(
finish(await PasswordDevice.make(seed: 99)),
refused(PasswordRefusal.noPassword),
);
final row = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
expect(row.verifier, isNull);
expect(await AccountReset.db.count(session), 1);
expect(mailbox.notices, isEmpty);
});
test('takes only a code asked for it: one asked for anything else '
'does nothing, and stays as it was', () async {
await annaComplete();