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

@ -366,15 +366,21 @@ final class _Register implements AccountRegister {
);
});
/// The moment a reset ended the account's sessions at the register.
DateTime endedBefore = DateTime.utc(2026, 10, 20, 12);
@override
Future<UuidValue> resetPasswordWithPhrase(
Future<AccountSessionsEnded> resetPasswordWithPhrase(
Session session, {
required UuidValue challenge,
required ByteData signature,
required NewPassword password,
String? totpCode,
String? recoveryCode,
}) => _answer('resetPasswordWithPhrase', () => _proven().account);
}) => _answer(
'resetPasswordWithPhrase',
() => (account: _proven().account, endedBefore: endedBefore),
);
@override
Future<ResetPending> startAccountReset(
@ -389,13 +395,56 @@ final class _Register implements AccountRegister {
);
@override
Future<UuidValue> completeAccountReset(
Future<AccountSessionsEnded> completeAccountReset(
Session session, {
required String address,
required UuidValue request,
required String code,
required PasswordSetup setup,
}) => _answer('completeAccountReset', () => _proven().account);
}) => _answer(
'completeAccountReset',
() => (account: _proven().account, endedBefore: endedBefore),
);
/// The sessions kept by the password changes asked, by person.
final changed = <UuidValue, UuidValue?>{};
@override
Future<PasswordChallenge> beginPasswordChange(
Session session, {
required UuidValue person,
}) => _answer(
'beginPasswordChange',
() => PasswordChallenge(
challenge: const Uuid().v7obj(),
salt: ByteData(16),
costs: newPasswordCosts,
serverValue: ByteData(384),
),
);
@override
Future<void> changePassword(
Session session, {
required UuidValue person,
required UuidValue challenge,
required ByteData clientValue,
required ByteData clientProof,
String? totpCode,
String? recoveryCode,
required NewPassword password,
UuidValue? keepSession,
}) => _answer('changePassword', () {
changed[person] = keepSession;
});
@override
Future<void> setRecoveryKeys(
Session session, {
required UuidValue person,
required String code,
required WrappedKeys recoveryKeys,
}) => _answer('setRecoveryKeys', () {});
@override
Future<void> deleteAccount(Session session, UuidValue person) =>
@ -985,6 +1034,146 @@ void main() {
});
});
group('told by the register that sessions ended (ADR 0017)', () {
Future<InstanceAnswer> tell(
Map<String, Object?> body, {
String? authorization = 'Bearer $_secret',
}) => answerSessionsEnded(
session,
jsonEncode(body),
authorization: authorization,
);
Future<DateTime> createdAt(UuidValue id) async =>
(await ServerSideSession.db.findById(session, id))!.createdAt;
test('ends those of the account made before the moment, but the one '
'kept and those made since, and nobody else', () async {
final bensPhone = await signInNewDevice(session, ben);
final endedBefore = (await createdAt(
bensPhone,
)).add(const Duration(microseconds: 1));
await Future<void>.delayed(const Duration(milliseconds: 5));
final bensTablet = await signInNewDevice(session, ben);
final answer = await tell({
'account': ben.uuid,
'endedBefore': endedBefore.toIso8601String(),
'keepSession': bensLaptop.uuid,
});
expect(answer.status, 200);
final left = {
for (final row in await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
))
row.id!,
};
expect(left, {bensLaptop, bensTablet});
expect(
await ServerSideSession.db.findById(session, annasPhone),
isNotNull,
);
expect(register.asked, isEmpty);
});
test('refuses without the secret and what it cannot read, and is '
'answered by no register, ending nothing', () async {
final everything = {
'account': ben.uuid,
'endedBefore': DateTime.now().toUtc().toIso8601String(),
};
expect((await tell(everything, authorization: null)).status, 401);
expect(
(await tell(everything, authorization: 'Bearer wrong')).status,
401,
);
expect(
(await tell({'account': ben.uuid, 'endedBefore': 'soon'})).status,
400,
);
expect((await tell({'account': ben.uuid})).status, 400);
useBase(sessionBuilder);
expect((await tell(everything)).status, 404);
expect(
await ServerSideSession.db.findById(session, bensLaptop),
isNotNull,
);
});
});
group('changing a password, by asking the register (ADR 0017)', () {
test('keeps the session asking: its ID goes to the register, which '
'tells every service', () async {
final challenge = await endpoints.password.beginChange(asBen());
await endpoints.password.change(
asBen(),
challenge: challenge.challenge,
clientValue: ByteData(384),
clientProof: ByteData(32),
totpCode: '123456',
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
verifier: ByteData(384),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
);
expect(register.asked, ['beginPasswordChange', 'changePassword']);
expect(register.changed, {ben: bensLaptop});
});
test('a reset ends the sessions here made before the register ended '
'them, the new one made after', () async {
register
..proof = PasswordProof(
account: ben,
serverProof: ByteData(32),
encryptionPublicKey: ByteData(32),
signingPublicKey: ByteData(32),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
needsAuthenticator: false,
)
..endedBefore = DateTime.now().toUtc();
final signedIn = await endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: const Uuid().v7obj(),
signature: ByteData(64),
password: NewPassword(
salt: ByteData(16),
costs: newPasswordCosts,
verifier: ByteData(384),
passwordKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(112),
),
),
totpCode: '123456',
);
expect(signedIn.authUserId, ben);
final left = await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(ben),
);
expect(left.single.id, isNot(bensLaptop));
expect(
left.single.createdAt.isBefore(register.endedBefore),
isFalse,
);
});
});
group('pruning', () {
late UuidValue carla;