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
5 changed files with 43506 additions and 126 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import 'package:halkyon_base_server/src/generated/password/password_signed_in.da
|
|||
as _ikpyb5fh;
|
||||
import 'package:halkyon_base_server/src/generated/password/reset_pending.dart'
|
||||
as _isakxto6;
|
||||
import 'package:halkyon_base_server/src/generated/password/wrapped_keys.dart'
|
||||
as _iji0wmu8;
|
||||
import 'package:halkyon_base_server/src/generated/profile/profile.dart'
|
||||
as _it4tom37;
|
||||
import 'package:halkyon_base_server/src/generated/profile/profile_refusal.dart'
|
||||
|
|
@ -1107,6 +1109,114 @@ class _PasswordEndpoint {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<_isz0atlx.PasswordChallenge> beginChange(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'password',
|
||||
method: 'beginChange',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'password',
|
||||
methodName: 'beginChange',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_isz0atlx.PasswordChallenge>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> change(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue challenge,
|
||||
required _idt.ByteData clientValue,
|
||||
required _idt.ByteData clientProof,
|
||||
String? totpCode,
|
||||
String? recoveryCode,
|
||||
required _ijye8mcv.NewPassword password,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'password',
|
||||
method: 'change',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'password',
|
||||
methodName: 'change',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'challenge': challenge,
|
||||
'clientValue': clientValue,
|
||||
'clientProof': clientProof,
|
||||
'totpCode': totpCode,
|
||||
'recoveryCode': recoveryCode,
|
||||
'password': password,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> setRecoveryKeys(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String code,
|
||||
required _iji0wmu8.WrappedKeys recoveryKeys,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'password',
|
||||
method: 'setRecoveryKeys',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'password',
|
||||
methodName: 'setRecoveryKeys',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'code': code,
|
||||
'recoveryKeys': recoveryKeys,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PasswordSignInEndpoint {
|
||||
|
|
|
|||
Loading…
Reference in a new issue