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
2 changed files with 43506 additions and 126 deletions

View file

@ -0,0 +1,459 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../support/mailbox.dart';
import '../support/password_device.dart';
import '../support/password_support.dart';
import '../test_tools/serverpod_test_tools.dart';
final _todo = Uri.parse('http://todo.test:8082');
/// The members of the tests' instance, as the register tells them.
final class _Members implements MemberNotifier {
final sessionsTold = <(Uri, UuidValue, DateTime, UuidValue?)>[];
bool answering = true;
@override
Future<bool> accountGone(Uri member, UuidValue account) async => answering;
@override
Future<bool> sessionsEnded(
Uri member,
UuidValue account, {
required DateTime endedBefore,
UuidValue? keepSession,
}) async {
sessionsTold.add((member, account, endedBefore, keepSession));
return answering;
}
}
/// Changing what keeps an account while signed in (ADR 0017, step 3): the
/// password, with the old one and a code, and the recovery phrase's
/// wrapping, with a code; and the sessions every change or reset of the
/// password ends, in every service of the instance.
void main() {
withServerpod(
'Changing the password',
rollbackDatabase: RollbackDatabase.disabled,
(sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late Mailbox mailbox;
late _Members members;
late PasswordDevice device;
late DateTime now;
late UuidValue anna;
late UuidValue annasPhone;
late UuidValue annasLaptop;
late AuthenticatorEnrolment enrolment;
Future<T> atNow<T>(Future<T> Function() body) =>
withClock(Clock(() => now), body);
void nextStep() => now = now.add(totpPeriod);
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.unauthenticated(),
);
TestSessionBuilder onPhone() => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
anna.uuid,
{identified},
authId: annasPhone.uuid,
),
);
Future<({UuidValue request, String code})> mailedCode(
String address,
) async {
final request = await endpoints.accountRecovery.sendCode(
notSignedIn(),
address: address,
language: 'en',
);
return (request: request, code: mailbox.codeFor(address));
}
Future<PasswordSignedIn> signIn({
BigInt? privateValue,
String? totpCode,
}) => atNow(() async {
final challenge = await endpoints.passwordSignIn.begin(
notSignedIn(),
address: 'anna@example.org',
);
final answer = device.answer(challenge, privateValue: privateValue);
return await endpoints.passwordSignIn.complete(
notSignedIn(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: totpCode ?? codeOf(enrolment, at: now),
);
});
/// Changes Anna's password from her phone, the old one proved by
/// [privateValue] (hers by default) with [totpCode] (her
/// authenticator's now by default).
Future<void> change({
BigInt? privateValue,
String? totpCode,
bool withoutCode = false,
NewPassword? password,
TestSessionBuilder? as,
}) => atNow(() async {
final challenge = await endpoints.password.beginChange(onPhone());
final answer = device.answer(challenge, privateValue: privateValue);
await endpoints.password.change(
as ?? onPhone(),
challenge: challenge.challenge,
clientValue: answer.clientValue,
clientProof: answer.clientProof,
totpCode: withoutCode ? null : totpCode ?? codeOf(enrolment, at: now),
password: password ?? device.newPassword(),
);
});
Future<Set<UuidValue>> sessionsOf(UuidValue person) async => {
for (final row in await ServerSideSession.db.find(
session,
where: (t) => t.authUserId.equals(person),
))
row.id!,
};
setUp(() async {
useBase(sessionBuilder, accounts: HoldsRegister(members: [_todo]));
session = sessionBuilder.build();
pod = session.serverpod;
await deleteAll(session);
await SessionsEndedNotice.db.deleteWhere(
session,
where: (t) => t.id > 0,
);
mailbox = Mailbox();
EmailCodes.of(pod).mailer = mailbox;
members = _Members();
MemberNotifier.use(pod, members);
usePasswordKeys(pod, testPasswordKeys);
now = DateTime.utc(2026, 10, 20, 12);
device = await PasswordDevice.make();
anna = await newAnonymousPerson(session);
annasPhone = await signInNewDevice(session, anna);
annasLaptop = await signInNewDevice(session, anna);
final code = await mailedCode('anna@example.org');
await endpoints.password.set(
onPhone(),
setup: device.setup(),
request: code.request,
code: code.code,
);
enrolment = await endpoints.authenticator.enrol(
onPhone(),
name: 'Phone',
label: 'anna@example.org',
);
await atNow(
() => endpoints.authenticator.confirm(
onPhone(),
authenticator: enrolment.authenticator,
code: codeOf(enrolment, at: now),
),
);
nextStep();
});
tearDown(() async {
EmailCodes.of(pod).mailer = null;
MemberNotifier.use(pod, null);
usePasswordKeys(pod, null);
final cleanup = sessionBuilder.build();
await SessionsEndedNotice.db.deleteWhere(
cleanup,
where: (t) => t.id > 0,
);
await deleteAll(cleanup);
});
group('the password', () {
test('changes with the old one and a code, the keys staying, every '
'other session ending here and at the members', () async {
final before = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
final old = device.privateValue;
await change();
nextStep();
final signedIn = await signIn();
expect(signedIn.authUserId, anna);
nextStep();
await expectLater(
signIn(privateValue: old),
refused(PasswordRefusal.wrongCredentials),
);
final after = (await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!;
expect(
Uint8List.sublistView(after.encryptionPublicKey),
Uint8List.sublistView(before.encryptionPublicKey),
);
expect(
Uint8List.sublistView(after.recoveryKeysCipherText),
Uint8List.sublistView(before.recoveryKeysCipherText),
);
// The phone that changed it stays; the laptop is signed out; the
// sign-in since is a session of its own.
final sessions = await sessionsOf(anna);
expect(sessions, contains(annasPhone));
expect(sessions, isNot(contains(annasLaptop)));
final (member, account, endedBefore, kept) =
members.sessionsTold.first;
expect(member, _todo);
expect(account, anna);
expect(endedBefore, DateTime.utc(2026, 10, 20, 12, 0, 30));
expect(kept, annasPhone);
expect(await SessionsEndedNotice.db.count(session), 0);
});
test('is refused alike for a wrong old password, a missing or wrong '
"code, and another person's proof, nothing changing", () async {
final kept = Uint8List.sublistView(
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.verifier,
);
final mine = device.privateValue;
await expectLater(
change(privateValue: BigInt.from(4242)),
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
await expectLater(
change(withoutCode: true),
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
await expectLater(
change(totpCode: '000000'),
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
change(
as: sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{identified},
authId: bensLaptop.uuid,
),
),
),
refused(PasswordRefusal.wrongCredentials),
);
device.privateValue = mine;
await expectLater(
change(
password: device.newPassword().copyWith(salt: ByteData(3)),
),
refused(PasswordRefusal.invalidSetup),
);
expect(
Uint8List.sublistView(
(await AccountPassword.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(anna),
))!.verifier,
),
kept,
);
expect(await sessionsOf(anna), {annasPhone, annasLaptop});
expect(members.sessionsTold, isEmpty);
});
test('is begun only by someone with a password', () async {
final ben = await newAnonymousPerson(session);
final bensLaptop = await signInNewDevice(session, ben);
await expectLater(
endpoints.password.beginChange(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
authId: bensLaptop.uuid,
),
),
),
refused(PasswordRefusal.noPassword),
);
});
test('tells a member that missed it again, hourly, until it '
'confirms', () async {
members.answering = false;
await change();
expect(await SessionsEndedNotice.db.count(session), 1);
members.answering = true;
expect(await deliverSessionNotices(session), 1);
expect(await SessionsEndedNotice.db.count(session), 0);
});
});
group('the recovery phrase', () {
test("takes new wrapped keys for a code: the old phrase's wrapping "
'goes', () async {
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
await atNow(
() => endpoints.password.setRecoveryKeys(
onPhone(),
code: codeOf(enrolment, at: now),
recoveryKeys: fresh,
),
);
final proof = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
expect(proof.recoveryKeys.toJson(), fresh.toJson());
});
test('is refused for a wrong code, keys out of range, and without a '
'password, keeping the wrapping', () async {
final fresh = WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(wrappedKeysLength),
);
await expectLater(
atNow(
() => endpoints.password.setRecoveryKeys(
onPhone(),
code: '000000',
recoveryKeys: fresh,
),
),
refused(PasswordRefusal.wrongCode),
);
await expectLater(
atNow(
() => endpoints.password.setRecoveryKeys(
onPhone(),
code: codeOf(enrolment, at: now),
recoveryKeys: WrappedKeys(
nonce: ByteData(24),
cipherText: ByteData(8),
),
),
),
refused(PasswordRefusal.invalidSetup),
);
final ben = await newAnonymousPerson(session);
await expectLater(
endpoints.password.setRecoveryKeys(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
ben.uuid,
{},
),
),
code: '123456',
recoveryKeys: fresh,
),
refused(PasswordRefusal.noPassword),
);
final proof = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
expect(
proof.recoveryKeys.toJson(),
device.recoveryKeys.toJson(),
);
});
});
group('a reset', () {
test('of the password with the phrase ends the sessions at the '
'members too, the new session made after', () async {
final proof = await endpoints.accountRecovery.beginKeyProof(
notSignedIn(),
address: 'anna@example.org',
);
final signature = await device.sign(proof);
final signedIn = await atNow(
() => endpoints.accountRecovery.resetPassword(
notSignedIn(),
challenge: proof.challenge,
signature: signature,
password: device.newPassword(),
totpCode: codeOf(enrolment, at: now),
),
);
expect(signedIn.authUserId, anna);
final sessions = await sessionsOf(anna);
expect(sessions, hasLength(1));
expect(sessions, isNot(contains(annasPhone)));
expect(sessions, isNot(contains(annasLaptop)));
expect(members.sessionsTold.single.$2, anna);
expect(members.sessionsTold.single.$4, isNull);
});
test('not due says when it takes effect', () async {
final start = await mailedCode('anna@example.org');
final pending = await atNow(
() => endpoints.accountRecovery.startAccountReset(
notSignedIn(),
address: 'anna@example.org',
language: 'en',
request: start.request,
code: start.code,
),
);
final complete = await mailedCode('anna@example.org');
final fresh = await PasswordDevice.make(seed: 99);
await expectLater(
atNow(
() => endpoints.accountRecovery.completeAccountReset(
notSignedIn(),
address: 'anna@example.org',
request: complete.request,
code: complete.code,
setup: fresh.setup(),
),
),
throwsA(
isA<PasswordRefusedException>()
.having((e) => e.reason, 'reason', PasswordRefusal.notDue)
.having(
(e) => e.takesEffectAt,
'takesEffectAt',
pending.takesEffectAt,
),
),
);
});
});
},
);
}

View file

@ -193,8 +193,8 @@ void main() {
where: (t) => t.authUserId.equals(anna),
))!;
expect(
after.salt.buffer.asUint8List(),
before.salt.buffer.asUint8List(),
Uint8List.sublistView(after.salt),
Uint8List.sublistView(before.salt),
);
expect(await AuthUser.db.count(session), 1);
});