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
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue