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

@ -9,6 +9,7 @@ import 'package:halkyon_base_server/src/register/accounts_mode.dart';
import 'package:halkyon_base_server/src/register/deletion_notices.dart';
import 'package:halkyon_base_server/src/register/local_register.dart';
import 'package:halkyon_base_server/src/register/register_protocol.dart';
import 'package:halkyon_base_server/src/register/sessions_ended.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -323,7 +324,7 @@ Future<Map<String, Object?>?> _answer(
);
return {'challenge': challenge.toJson()};
case RegisterOperation.resetPasswordWithPhrase:
final account = await register.resetPasswordWithPhrase(
final ended = await register.resetPasswordWithPhrase(
session,
challenge: arguments.id('challenge'),
signature: arguments.bytes('signature'),
@ -331,7 +332,7 @@ Future<Map<String, Object?>?> _answer(
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
);
return {'account': account.uuid};
return _sessionsEnded(ended);
case RegisterOperation.startAccountReset:
final pending = await register.startAccountReset(
session,
@ -342,18 +343,51 @@ Future<Map<String, Object?>?> _answer(
);
return {'pending': pending.toJson()};
case RegisterOperation.completeAccountReset:
final account = await register.completeAccountReset(
final ended = await register.completeAccountReset(
session,
address: arguments.string('address'),
request: arguments.id('request'),
code: arguments.string('code'),
setup: arguments.model('setup', PasswordSetup.fromJson),
);
return {'account': account.uuid};
return _sessionsEnded(ended);
case RegisterOperation.beginPasswordChange:
final challenge = await register.beginPasswordChange(
session,
person: arguments.id('person'),
);
return {'challenge': challenge.toJson()};
case RegisterOperation.changePassword:
await register.changePassword(
session,
person: arguments.id('person'),
challenge: arguments.id('challenge'),
clientValue: arguments.bytes('clientValue'),
clientProof: arguments.bytes('clientProof'),
totpCode: arguments.optionalString('totpCode'),
recoveryCode: arguments.optionalString('recoveryCode'),
password: arguments.model('password', NewPassword.fromJson),
keepSession: arguments.optionalId('keepSession'),
);
return const {};
case RegisterOperation.setRecoveryKeys:
await register.setRecoveryKeys(
session,
person: arguments.id('person'),
code: arguments.string('code'),
recoveryKeys: arguments.model('recoveryKeys', WrappedKeys.fromJson),
);
return const {};
}
return null;
}
/// [ended] as the register answers it.
Map<String, Object?> _sessionsEnded(AccountSessionsEnded ended) => {
'account': ended.account.uuid,
'endedBefore': ended.endedBefore.toUtc().toIso8601String(),
};
/// A member answering the register that [body]'s account is gone
/// ([accountGonePath]): that person's data here is deleted, here alone (the
/// register deleted the account), and the register told so. On the register
@ -391,6 +425,46 @@ Future<InstanceAnswer> answerRegister(
return (status: 200, body: const <String, Object?>{});
}
/// A member answering the register that [body]'s account's sessions ended
/// ([sessionsEndedPath], ADR 0017): those here created before the moment it
/// names end, but the one it keeps, and the register is told so. On the
/// register itself, nothing is answered.
Future<InstanceAnswer> answerSessionsEnded(
Session session,
String body, {
required String? authorization,
}) async {
final pod = session.serverpod;
if (!_carriesSecret(pod, authorization)) {
return _unauthorized;
}
if (HalkyonBase.maybeOf(pod)?.accounts is! MemberOfRegister) {
return _notHere;
}
final arguments = _arguments(body);
if (arguments == null) {
return _unreadable;
}
final UuidValue account;
final DateTime endedBefore;
final UuidValue? keepSession;
try {
final read = _Arguments(arguments);
account = read.id('account');
endedBefore = DateTime.parse(read.string('endedBefore')).toUtc();
keepSession = read.optionalId('keepSession');
} on FormatException {
return _unreadable;
}
await endSessionsHere(
session,
account,
endedBefore: endedBefore,
keepSession: keepSession,
);
return (status: 200, body: const <String, Object?>{});
}
/// A request's arguments, each read as what it must be; a [FormatException]
/// for anything else.
extension type const _Arguments(Map<String, Object?> _map) {