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

@ -34,6 +34,8 @@ import 'package:halkyon_base_client/src/protocol/password/password_signed_in.dar
as _ix90myvx;
import 'package:halkyon_base_client/src/protocol/password/reset_pending.dart'
as _ippuf33k;
import 'package:halkyon_base_client/src/protocol/password/wrapped_keys.dart'
as _i3so7e0p;
import 'package:halkyon_base_client/src/protocol/profile/profile.dart'
as _i8qdmv6n;
import 'package:halkyon_base_client/src/protocol/profile/profile_refusal.dart'
@ -308,8 +310,9 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
);
/// Sets a new [password] with the recovery phrase's [signature] for
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Signs
/// in. Refusals as `resetPasswordWithPhrase`'s.
/// [challenge] and a [totpCode] or [recoveryCode]; the keys stay. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. Refusals as `resetPasswordWithPhrase`'s.
_ida.Future<_iacc.AuthSuccess> resetPassword({
required _isc.UuidValue challenge,
required _idt.ByteData signature,
@ -350,8 +353,9 @@ class EndpointAccountRecovery extends _isc.EndpointRef {
/// Carries out the reset of the whole account at [address] once due, as
/// a new [code] mailed for [request] proves: [setup] replaces its
/// password and keys, and an authenticator must be set up again. Signs
/// in. Refusals as `completeAccountReset`'s.
/// password and keys, and an authenticator must be set up again. Every
/// session of the account ends, in every service of the instance, and
/// this one signs in. Refusals as `completeAccountReset`'s.
_ida.Future<_iacc.AuthSuccess> completeAccountReset({
required String address,
required _isc.UuidValue request,
@ -472,6 +476,55 @@ class EndpointPassword extends _isc.EndpointRef {
'code': code,
},
);
/// The first step of the caller proving their password to change it:
/// its salt and costs, and SRP's B. Refusals as `beginPasswordChange`'s.
_ida.Future<_iykren7a.PasswordChallenge> beginChange() =>
caller.callServerEndpoint<_iykren7a.PasswordChallenge>(
'halkyon_base.password',
'beginChange',
{},
);
/// Changes the caller's password to [password] (the same keys, wrapped
/// under the new password's key on the device), with the old password's
/// proof for [challenge] ([beginChange]) and a [totpCode] or
/// [recoveryCode]. Every other session of theirs ends, in every service of
/// the instance; this one stays. Refusals as `changePassword`'s.
_ida.Future<void> change({
required _isc.UuidValue challenge,
required _idt.ByteData clientValue,
required _idt.ByteData clientProof,
String? totpCode,
String? recoveryCode,
required _i95l60wq.NewPassword password,
}) => caller.callServerEndpoint<void>(
'halkyon_base.password',
'change',
{
'challenge': challenge,
'clientValue': clientValue,
'clientProof': clientProof,
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'password': password,
},
);
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, for a [code] from one of
/// their authenticators. Refusals as `setRecoveryKeys`'.
_ida.Future<void> setRecoveryKeys({
required String code,
required _i3so7e0p.WrappedKeys recoveryKeys,
}) => caller.callServerEndpoint<void>(
'halkyon_base.password',
'setRecoveryKeys',
{
'code': code,
'recoveryKeys': recoveryKeys,
},
);
}
/// Signing in with an address, a password and a code from an authenticator

View file

@ -20,10 +20,14 @@ abstract class PasswordRefusedException
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
PasswordRefusedException._({required this.reason});
PasswordRefusedException._({
required this.reason,
this.takesEffectAt,
});
factory PasswordRefusedException({
required _i647hpdf.PasswordRefusal reason,
DateTime? takesEffectAt,
}) = _PasswordRefusedExceptionImpl;
factory PasswordRefusedException.fromJson(
@ -33,20 +37,33 @@ abstract class PasswordRefusedException
reason: _i647hpdf.PasswordRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
takesEffectAt: jsonSerialization['takesEffectAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['takesEffectAt'],
),
);
}
_i647hpdf.PasswordRefusal reason;
/// When the reset waiting takes effect, for [PasswordRefusal.notDue];
/// null otherwise.
DateTime? takesEffectAt;
/// Returns a shallow copy of this [PasswordRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
PasswordRefusedException copyWith({_i647hpdf.PasswordRefusal? reason});
PasswordRefusedException copyWith({
_i647hpdf.PasswordRefusal? reason,
DateTime? takesEffectAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.PasswordRefusedException',
'reason': reason.toJson(),
if (takesEffectAt != null) 'takesEffectAt': takesEffectAt?.toJson(),
};
}
@ -55,24 +72,40 @@ abstract class PasswordRefusedException
return {
'__className__': 'halkyon_base.PasswordRefusedException',
'reason': reason.toJson(),
if (takesEffectAt != null) 'takesEffectAt': takesEffectAt?.toJson(),
};
}
@override
String toString() {
return 'PasswordRefusedException(reason: $reason)';
return 'PasswordRefusedException(reason: $reason, takesEffectAt: $takesEffectAt)';
}
}
class _Undefined {}
class _PasswordRefusedExceptionImpl extends PasswordRefusedException {
_PasswordRefusedExceptionImpl({required _i647hpdf.PasswordRefusal reason})
: super._(reason: reason);
_PasswordRefusedExceptionImpl({
required _i647hpdf.PasswordRefusal reason,
DateTime? takesEffectAt,
}) : super._(
reason: reason,
takesEffectAt: takesEffectAt,
);
/// Returns a shallow copy of this [PasswordRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
PasswordRefusedException copyWith({_i647hpdf.PasswordRefusal? reason}) {
return PasswordRefusedException(reason: reason ?? this.reason);
PasswordRefusedException copyWith({
_i647hpdf.PasswordRefusal? reason,
Object? takesEffectAt = _Undefined,
}) {
return PasswordRefusedException(
reason: reason ?? this.reason,
takesEffectAt: takesEffectAt is DateTime?
? takesEffectAt
: this.takesEffectAt,
);
}
}