feat(auth): change the password and the recovery phrase while signed in

ADR 0017, step 3. `password.beginChange` and `password.change` take the
old password's SRP proof and a TOTP or recovery code, and the same keys
wrapped under the new password; a session alone changes nothing, and an
account without a confirmed authenticator is told to set one up first.
`password.setRecoveryKeys` replaces the keys' wrapping under the
recovery phrase for a TOTP code, so a device holding the keys can make
a new phrase.

A change of password ends every other session of the account in every
service of the instance; resets of the password and of the account end
them all. The register ends its own and keeps a notice per member
(`sessions_ended_notice`, a migration that only creates it), told at
once and hourly until it confirms, as deletions are told: it names a
moment, so sessions made since stay, and the session that asked for a
change. A member answering one ends its sessions made before that
moment; a member signing someone in after a reset ends its own first
and makes the new session only once its clock is past the moment.

A reset not due says when it takes effect (`takesEffectAt` on
`PasswordRefusedException`), across members too.

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-20 14:53:58 +02:00
commit d00da1f24a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -50,6 +50,9 @@ final class _Mailbox implements CodeMailer {
/// says (confirming by default).
final class _Members implements MemberNotifier {
final told = <(Uri, UuidValue)>[];
/// The sessions ended they were told of: member, account, moment, kept.
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
bool Function(Uri member) answers = (_) => true;
@override
@ -57,6 +60,17 @@ final class _Members implements MemberNotifier {
told.add((member, account));
return answers(member);
}
@override
Future<bool> sessionsEnded(
Uri member,
UuidValue account, {
required DateTime endedBefore,
UuidValue? keepSession,
}) async {
sessionsTold.add((member, account, endedBefore, keepSession));
return answers(member);
}
}
void main() {
@ -623,8 +637,10 @@ void main() {
password: device.newPassword(),
totpCode: codeOf(enrolment, at: now),
),
annaOnTodo,
(account: annaOnTodo, endedBefore: now),
);
// Every member is told to end the account's sessions too.
expect(members.sessionsTold.single, (_todo, annaOnTodo, now, null));
final forReset = await remote.beginPasswordSignIn(
session,
@ -679,12 +695,140 @@ void main() {
code: mailbox.codeFor('anna@example.org'),
setup: fresh.setup(),
),
annaOnTodo,
(account: annaOnTodo, endedBefore: now),
);
});
});
});
group('changes while signed in (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
setUp(() {
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
});
test(
"cross for a member's device: the password, keeping the "
"member's session, the phrase's wrapping, and a reset's date",
() async {
final device = await PasswordDevice.make();
await atNow(() async {
final annaOnTodo = const Uuid().v7obj();
final annasPhone = const Uuid().v7obj();
final request = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
);
await remote.setPassword(
session,
person: annaOnTodo,
setup: device.setup(),
request: request,
code: mailbox.codeFor('anna@example.org'),
);
final enrolment = await remote.enrolAuthenticator(
session,
person: annaOnTodo,
name: 'Phone',
label: 'anna@example.org',
);
await remote.confirmAuthenticator(
session,
person: annaOnTodo,
authenticator: enrolment.authenticator,
code: codeOf(enrolment),
);
now = now.add(totpPeriod);
final challenge = await remote.beginPasswordChange(
session,
person: annaOnTodo,
);
final answer = device.answer(challenge);
await remote.changePassword(
session,
person: annaOnTodo,
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: codeOf(enrolment),
password: device.newPassword(),
keepSession: annasPhone,
);
expect(members.sessionsTold.single, (
_todo,
annaOnTodo,
now,
annasPhone,
));
now = now.add(totpPeriod);
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
await remote.setRecoveryKeys(
session,
person: annaOnTodo,
code: codeOf(enrolment),
recoveryKeys: fresh,
);
await expectLater(
remote.setRecoveryKeys(
session,
person: annaOnTodo,
code: codeOf(enrolment),
recoveryKeys: fresh,
),
refused(PasswordRefusal.wrongCode),
);
final start = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
);
final pending = await remote.startAccountReset(
session,
address: 'anna@example.org',
language: 'en',
request: start,
code: mailbox.codeFor('anna@example.org'),
);
final early = await remote.sendSignInCode(
session,
address: 'anna@example.org',
language: 'en',
network: '203.0.113.7',
);
await expectLater(
remote.completeAccountReset(
session,
address: 'anna@example.org',
request: early,
code: mailbox.codeFor('anna@example.org'),
setup: device.setup(),
),
throwsA(
isA<PasswordRefusedException>().having(
(error) => error.takesEffectAt,
'takesEffectAt',
pending.takesEffectAt,
),
),
);
});
},
);
});
group('ways in without a working password (ADR 0017)', () {
late DateTime now;
Future<T> atNow<T>(Future<T> Function() body) =>