feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)

Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.

- `keys.set(attempt, setup, handle?)`: the first keys of an account that
  SimpleLogin was added to (anonymous before, or signing in with GitHub
  or a code), with the handle where it has none; the sign-in alone, as
  there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
  lost password, with the phrase): every other session ends, in every
  service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
  itself with the proof and the sign-in; `requireOwnerToDelete` no longer
  asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
  challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
  instance API carry the five new operations and the deletion's proof.

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-23 22:11:49 +02:00
commit 7c58d23424
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -321,6 +321,76 @@ final class _Register implements AccountRegister {
return account;
});
/// The first keys set, by person, with the sign-in and handle they
/// came with; the changes of keys asked, by name, with the proof (as the
/// member bound it) and the session kept.
final firstKeys = <UuidValue, ({UuidValue attempt, String? handle})>{};
final keysChanges =
<({String change, KeysOwnerProof proof, UuidValue? keepSession})>[];
@override
Future<void> setFirstKeys(
Session session, {
required UuidValue person,
required UuidValue attempt,
required KeysSetup setup,
String? handle,
}) => _answer('setFirstKeys', () {
firstKeys[person] = (attempt: attempt, handle: handle);
});
@override
Future<KeyProofChallenge> beginPhraseKeyProof(
Session session,
UuidValue person,
) => _answer(
'beginPhraseKeyProof',
() => KeyProofChallenge(
challenge: const Uuid().v7obj(),
nonce: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
recoveryKeys: WrappedKeys(nonce: ByteData(24), cipherText: ByteData(112)),
),
);
@override
Future<void> changeKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => _answer('changeKeysPassword', () {
keysChanges.add((
change: 'password',
proof: proof,
keepSession: keepSession,
));
});
@override
Future<void> resetKeysPassword(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required KeysPassword password,
UuidValue? keepSession,
}) => _answer('resetKeysPassword', () {
keysChanges.add((change: 'reset', proof: proof, keepSession: keepSession));
});
@override
Future<void> setKeysRecovery(
Session session, {
required UuidValue person,
required KeysOwnerProof proof,
required WrappedKeys recoveryKeys,
required ByteData recoveryProofKey,
}) => _answer('setKeysRecovery', () {
keysChanges.add((change: 'phrase', proof: proof, keepSession: null));
});
@override
Future<KeysChallenge> beginPasswordKeyProof(
Session session,
@ -602,14 +672,17 @@ final class _Register implements AccountRegister {
Session session,
UuidValue person, {
DeletionProof? proof,
KeysOwnerProof? keysProof,
}) => _answer('deleteAccount', () {
deleted.add(person);
deletedWith.add(proof);
deletedWithKeys.add(keysProof);
accounts.remove(person);
});
/// The proofs deletions came with, as the member bound them.
final deletedWith = <DeletionProof?>[];
final deletedWithKeys = <KeysOwnerProof?>[];
final changedWith = <OwnerProof>[];
@override
@ -1348,6 +1421,99 @@ void main() {
expect(register.asked, isEmpty);
});
test('are set first at the register with the sign-in and handle, '
'for the caller', () async {
final attempt = const Uuid().v7obj();
await endpoints.keys.set(
asBen(),
attempt: attempt,
setup: (await KeysDevice.make()).setup(),
handle: 'ben',
);
expect(register.asked, ['setFirstKeys']);
expect(register.firstKeys, {ben: (attempt: attempt, handle: 'ben')});
});
test('are changed at the register, each proof bound to the base URL '
'the device addressed here, this session kept where every other '
'ends', () async {
final proof = KeysOwnerProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: testInstanceUrl,
attempt: const Uuid().v7obj(),
);
final password = KeysPassword(
salt: ByteData(16),
costs: newPasswordCosts,
passwordProofKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
);
await endpoints.keys.changePassword(
asBen(),
proof: proof,
password: password,
);
await endpoints.keys.beginPhrase(asBen());
await endpoints.keys.resetPassword(
asBen(),
proof: proof,
password: password,
);
await endpoints.keys.setRecoveryKeys(
asBen(),
proof: proof,
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
recoveryProofKey: ByteData(32),
);
expect(register.asked, [
'changeKeysPassword',
'beginPhraseKeyProof',
'resetKeysPassword',
'setKeysRecovery',
]);
expect(
[
for (final change in register.keysChanges)
(change.change, change.keepSession),
],
[
('password', bensLaptop),
('reset', bensLaptop),
('phrase', null),
],
);
expect(
register.keysChanges.map((change) => change.proof.instanceUrl),
everyElement(testInstanceUrl),
);
await expectLater(
endpoints.keys.changePassword(
asBen(),
proof: proof.copyWith(instanceUrl: 'https://evil.example/'),
password: password,
),
throwsA(
isA<PasswordRefusedException>().having(
(refusal) => refusal.reason,
'reason',
PasswordRefusal.otherInstance,
),
),
);
expect(register.asked, hasLength(4));
});
test("count the session's own wrong proofs, which the register "
'refuses: the tenth in a row ends the session here', () async {
register.failing = PasswordRefusedException(
@ -1445,6 +1611,29 @@ void main() {
expect(register.deletedWith.single?.totpCode, '123456');
});
test('passes the proof of keys without SRP on to the register, with '
'its SimpleLogin sign-in, bound alike', () async {
final attempt = const Uuid().v7obj();
await endpoints.account.delete(
asBen(),
keysProof: KeysOwnerProof(
challenge: const Uuid().v7obj(),
signature: ByteData(64),
instanceUrl: 'HTTP://LOCALHOST:0',
attempt: attempt,
),
);
expect(register.deleted, [ben]);
expect(register.deletedWith.single, isNull);
expect(
register.deletedWithKeys.single?.instanceUrl,
testInstanceUrl,
);
expect(register.deletedWithKeys.single?.attempt, attempt);
});
test('of an anonymous person deletes it here alone, once the '
'register says it has no such account', () async {
await endpoints.account.delete(asAnna());