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:
parent
f8076dcacf
commit
d00da1f24a
1 changed files with 43506 additions and 126 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue