feat(base): the changes of SimpleLogin accounts' keys, each with the password and a fresh sign-in (ADR 0020, D20)

Every change takes the password's proof by its own proof key (the
recovery phrase's for a lost password) and a SimpleLogin sign-in of the
last ten minutes on the device (`KeysOwnerProof`, its attempt), as ADR
0017's take the password and a code. The sign-in is checked before the
proof and taken with the change, so a wrong proof leaves it standing and
each sign-in serves one change.

- `keys.set(attempt, setup, handle?)`: the first keys of an account that
  SimpleLogin was added to (anonymous before, or signing in with GitHub
  or a code), with the handle where it has none; the sign-in alone, as
  there is no password to prove yet.
- `keys.changePassword`, `keys.beginPhrase` and `keys.resetPassword` (a
  lost password, with the phrase): every other session ends, in every
  service, as ADR 0017's change has it.
- `keys.setRecoveryKeys`: a new phrase.
- `account.delete(keysProof)`: an account without SRP's verifier deletes
  itself with the proof and the sign-in; `requireOwnerToDelete` no longer
  asks such an account for SRP.
- `newKeysPasswordDigest` on both sides, held to each other by a test; a
  challenge purpose of its own for the phrase's proof while signed in.
- Members ask the register for all of it; `RemoteRegister` and the
  instance API carry the five new operations and the deletion's proof.

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-23 22:11:49 +02:00
commit 7c58d23424
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 3163 additions and 78 deletions

View file

@ -22,6 +22,10 @@ import 'package:halkyon_base_client/src/protocol/keys/device_keys.dart'
as _invv3u3l;
import 'package:halkyon_base_client/src/protocol/keys/keys_challenge.dart'
as _ivkzjgkp;
import 'package:halkyon_base_client/src/protocol/keys/keys_owner_proof.dart'
as _i1sdq72h;
import 'package:halkyon_base_client/src/protocol/keys/keys_password.dart'
as _i7elvg0l;
import 'package:halkyon_base_client/src/protocol/keys/keys_proof.dart'
as _iznbn55x;
import 'package:halkyon_base_client/src/protocol/keys/keys_setup.dart'
@ -78,14 +82,22 @@ class EndpointAccount extends _isc.EndpointRef {
/// register deletes it and tells every member, this one deleting its own
/// data at once; an anonymous person is this service's alone. An account
/// with a password deletes itself only with [proof] (the password's proof
/// and a code, `requireOwnerToDelete`), which the register checks, its
/// base URL the one the device addressed here.
_ida.Future<void> delete({_ifxctuoq.DeletionProof? proof}) =>
caller.callServerEndpoint<void>(
'halkyon_base.account',
'delete',
{'proof': proof},
);
/// and a code, `requireOwnerToDelete`), one that signs in with SimpleLogin
/// only with [keysProof] (the password's proof for a challenge of
/// `keys.begin` and a SimpleLogin sign-in of the last ten minutes on the
/// device, Halkyon Learn's ADR 0020, D20), which the register checks,
/// their base URL the one the device addressed here.
_ida.Future<void> delete({
_ifxctuoq.DeletionProof? proof,
_i1sdq72h.KeysOwnerProof? keysProof,
}) => caller.callServerEndpoint<void>(
'halkyon_base.account',
'delete',
{
'proof': proof,
'keysProof': keysProof,
},
);
/// How the caller signs in besides their sessions: with GitHub, with an
/// email address, both, or neither (anonymous).
@ -231,9 +243,11 @@ class EndpointInstance extends _isc.EndpointRef {
}
/// The keys of an account that signs in with SimpleLogin (Halkyon Learn's
/// ADR 0020, 7 and D19), for its signed-in devices: the password proven
/// with its own proof key, which the instance's register checks (ADR 0016).
/// Refusals are `PasswordRefusedException`s.
/// ADR 0020, 6, 7, D19 and D20), for its signed-in devices: the password
/// proven with its own proof key, which the instance's register checks (ADR
/// 0016), and every change with that proof and a SimpleLogin sign-in of the
/// last ten minutes on the device (`KeysOwnerProof`). Refusals are
/// `PasswordRefusedException`s; the sign-in's, `OidcRefusedException`s.
/// {@category Endpoint}
class EndpointKeys extends _isc.EndpointRef {
EndpointKeys(_isc.EndpointCaller caller) : super(caller);
@ -265,6 +279,93 @@ class EndpointKeys extends _isc.EndpointRef {
'unlock',
{'proof': proof},
);
/// Your first keys, for an account SimpleLogin was added to that has none
/// (`simpleLogin.complete` says `hasKeys`), all made on your device and
/// described by [setup]; [handle] where the account has none (it said no
/// `handle`), null otherwise. Takes your SimpleLogin sign-in of the last
/// ten minutes on this device, under [attempt]. Refused for an account
/// with keys (`PasswordRefusal.passwordExists`), a setup out of range
/// (`PasswordRefusal.invalidSetup`), a handle refused or missing
/// (`ProfileRefusedException`), and without that sign-in
/// (`OidcRefusal.unknownAttempt`).
_ida.Future<void> set({
required _isc.UuidValue attempt,
required _iec2fqr0.KeysSetup setup,
String? handle,
}) => caller.callServerEndpoint<void>(
'halkyon_base.keys',
'set',
{
'attempt': attempt,
'setup': setup,
'handle': handle,
},
);
/// The first step of proving your recovery phrase, for a password you
/// lost: your keys wrapped under the phrase's key and your public keys,
/// which your device opens with the phrase, and a nonce to sign with its
/// proof key, within two minutes, once. Refused for an account without
/// such keys (`PasswordRefusal.noPassword`).
_ida.Future<_ir3ecgdn.KeyProofChallenge> beginPhrase() =>
caller.callServerEndpoint<_ir3ecgdn.KeyProofChallenge>(
'halkyon_base.keys',
'beginPhrase',
{},
);
/// Changes your password to [password] (the same keys, wrapped under the
/// new password's key on your device), with [proof]: the old password's
/// for a challenge of [begin], binding the new password, and your
/// SimpleLogin sign-in of the last ten minutes on this device. Every
/// other session of yours ends, in every service of the instance; this
/// one stays. The proof counts against this session, as [unlock]'s.
_ida.Future<void> changePassword({
required _i1sdq72h.KeysOwnerProof proof,
required _i7elvg0l.KeysPassword password,
}) => caller.callServerEndpoint<void>(
'halkyon_base.keys',
'changePassword',
{
'proof': proof,
'password': password,
},
);
/// Sets a new [password] for the one you lost, with [proof]: your
/// recovery phrase's for a challenge of [beginPhrase], binding the new
/// password, and your SimpleLogin sign-in of the last ten minutes on this
/// device. Every other session of yours ends, as [changePassword] has it.
_ida.Future<void> resetPassword({
required _i1sdq72h.KeysOwnerProof proof,
required _i7elvg0l.KeysPassword password,
}) => caller.callServerEndpoint<void>(
'halkyon_base.keys',
'resetPassword',
{
'proof': proof,
'password': password,
},
);
/// A new recovery phrase: [recoveryKeys], your keys wrapped under it on
/// your device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with [proof] as
/// [changePassword] takes it, binding the new phrase.
_ida.Future<void> setRecoveryKeys({
required _i1sdq72h.KeysOwnerProof proof,
required _i3so7e0p.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
}) => caller.callServerEndpoint<void>(
'halkyon_base.keys',
'setRecoveryKeys',
{
'proof': proof,
'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey,
},
);
}
/// Getting back into an account (ADR 0017): a lost authenticator, a lost

View file

@ -0,0 +1,134 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What a change of an account that signs in with SimpleLogin takes
/// (Halkyon Learn's ADR 0020, D20), as ADR 0017's `OwnerProof` takes the
/// password and a code: a key proof for a challenge of `keys.begin` (the
/// password's proof key), or of `keys.beginPhrase` for a lost password
/// (the recovery phrase's), and a SimpleLogin sign-in of the last ten
/// minutes on this device, the attempt it ended (`simpleLogin.complete`),
/// taken by the change.
abstract class KeysOwnerProof
implements _isc.SerializableModel, _isc.ProtocolSerialization {
KeysOwnerProof._({
required this.challenge,
required this.signature,
required this.instanceUrl,
required this.attempt,
});
factory KeysOwnerProof({
required _isc.UuidValue challenge,
required _idt.ByteData signature,
required String instanceUrl,
required _isc.UuidValue attempt,
}) = _KeysOwnerProofImpl;
factory KeysOwnerProof.fromJson(Map<String, dynamic> jsonSerialization) {
return KeysOwnerProof(
challenge: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['challenge'],
),
signature: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['signature'],
),
instanceUrl: jsonSerialization['instanceUrl'] as String,
attempt: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['attempt'],
),
);
}
_isc.UuidValue challenge;
/// Ed25519's signature over `keyProofMessage`, 64 bytes.
_idt.ByteData signature;
/// The instance's base URL the device addressed (scheme, host, port,
/// path): the server it reached checks it is its own before the
/// register binds the proof to it.
String instanceUrl;
/// The SimpleLogin sign-in's attempt.
_isc.UuidValue attempt;
/// Returns a shallow copy of this [KeysOwnerProof]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
KeysOwnerProof copyWith({
_isc.UuidValue? challenge,
_idt.ByteData? signature,
String? instanceUrl,
_isc.UuidValue? attempt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.KeysOwnerProof',
'challenge': challenge.toJson(),
'signature': signature.toJson(),
'instanceUrl': instanceUrl,
'attempt': attempt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.KeysOwnerProof',
'challenge': challenge.toJson(),
'signature': signature.toJson(),
'instanceUrl': instanceUrl,
'attempt': attempt.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _KeysOwnerProofImpl extends KeysOwnerProof {
_KeysOwnerProofImpl({
required _isc.UuidValue challenge,
required _idt.ByteData signature,
required String instanceUrl,
required _isc.UuidValue attempt,
}) : super._(
challenge: challenge,
signature: signature,
instanceUrl: instanceUrl,
attempt: attempt,
);
/// Returns a shallow copy of this [KeysOwnerProof]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
KeysOwnerProof copyWith({
_isc.UuidValue? challenge,
_idt.ByteData? signature,
String? instanceUrl,
_isc.UuidValue? attempt,
}) {
return KeysOwnerProof(
challenge: challenge ?? this.challenge,
signature: signature ?? this.signature.clone(),
instanceUrl: instanceUrl ?? this.instanceUrl,
attempt: attempt ?? this.attempt,
);
}
}

View file

@ -0,0 +1,132 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_base_client/src/protocol/protocol.dart' as _iwgbpyo5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../password/password_costs.dart' as _ioe6tior;
import '../password/wrapped_keys.dart' as _i2rxumte;
/// A new password for the keys of an account that signs in with SimpleLogin
/// (Halkyon Learn's ADR 0020, 7 and D19), all made on the device, which
/// holds the keys: the salt and costs it derived the password's keys with,
/// the public half of the password's proof key, and the account's private
/// keys wrapped under the password's key. As ADR 0017's `NewPassword`,
/// without SRP's verifier.
abstract class KeysPassword
implements _isc.SerializableModel, _isc.ProtocolSerialization {
KeysPassword._({
required this.salt,
required this.costs,
required this.passwordProofKey,
required this.passwordKeys,
});
factory KeysPassword({
required _idt.ByteData salt,
required _ioe6tior.PasswordCosts costs,
required _idt.ByteData passwordProofKey,
required _i2rxumte.WrappedKeys passwordKeys,
}) = _KeysPasswordImpl;
factory KeysPassword.fromJson(Map<String, dynamic> jsonSerialization) {
return KeysPassword(
salt: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['salt']),
costs: _iwgbpyo5.Protocol().deserialize<_ioe6tior.PasswordCosts>(
jsonSerialization['costs'],
),
passwordProofKey: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['passwordProofKey'],
),
passwordKeys: _iwgbpyo5.Protocol().deserialize<_i2rxumte.WrappedKeys>(
jsonSerialization['passwordKeys'],
),
);
}
_idt.ByteData salt;
_ioe6tior.PasswordCosts costs;
/// The public half of the new password's proof key (Ed25519).
_idt.ByteData passwordProofKey;
_i2rxumte.WrappedKeys passwordKeys;
/// Returns a shallow copy of this [KeysPassword]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
KeysPassword copyWith({
_idt.ByteData? salt,
_ioe6tior.PasswordCosts? costs,
_idt.ByteData? passwordProofKey,
_i2rxumte.WrappedKeys? passwordKeys,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.KeysPassword',
'salt': salt.toJson(),
'costs': costs.toJson(),
'passwordProofKey': passwordProofKey.toJson(),
'passwordKeys': passwordKeys.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.KeysPassword',
'salt': salt.toJson(),
'costs': costs.toJsonForProtocol(),
'passwordProofKey': passwordProofKey.toJson(),
'passwordKeys': passwordKeys.toJsonForProtocol(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _KeysPasswordImpl extends KeysPassword {
_KeysPasswordImpl({
required _idt.ByteData salt,
required _ioe6tior.PasswordCosts costs,
required _idt.ByteData passwordProofKey,
required _i2rxumte.WrappedKeys passwordKeys,
}) : super._(
salt: salt,
costs: costs,
passwordProofKey: passwordProofKey,
passwordKeys: passwordKeys,
);
/// Returns a shallow copy of this [KeysPassword]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
KeysPassword copyWith({
_idt.ByteData? salt,
_ioe6tior.PasswordCosts? costs,
_idt.ByteData? passwordProofKey,
_i2rxumte.WrappedKeys? passwordKeys,
}) {
return KeysPassword(
salt: salt ?? this.salt.clone(),
costs: costs ?? this.costs.copyWith(),
passwordProofKey: passwordProofKey ?? this.passwordProofKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
);
}
}

View file

@ -29,7 +29,12 @@ enum SignInChallengePurpose implements _isc.SerializableModel {
/// by signing a nonce with the password's proof key (Halkyon Learn's ADR
/// 0020, D19): to get the keys on a device, or for a change. Counted per
/// session (`asOwnerProofTry`).
passwordProof;
passwordProof,
/// Proving the recovery phrase of an account without SRP's verifier,
/// signed in, by signing a nonce with the phrase's proof key, for a lost
/// password (Halkyon Learn's ADR 0020, D20). Counted per session.
phraseProof;
static SignInChallengePurpose fromJson(String name) {
switch (name) {
@ -41,6 +46,8 @@ enum SignInChallengePurpose implements _isc.SerializableModel {
return SignInChallengePurpose.change;
case 'passwordProof':
return SignInChallengePurpose.passwordProof;
case 'phraseProof':
return SignInChallengePurpose.phraseProof;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "SignInChallengePurpose"',

View file

@ -28,6 +28,8 @@ import 'auth/sign_in_ways.dart' as _ie6tbh85;
import 'instance/instance_basics.dart' as _i8jixqx6;
import 'keys/device_keys.dart' as _iz50cwqw;
import 'keys/keys_challenge.dart' as _itebbsfc;
import 'keys/keys_owner_proof.dart' as _iis161ma;
import 'keys/keys_password.dart' as _i70xpmnz;
import 'keys/keys_proof.dart' as _iviximyh;
import 'keys/keys_setup.dart' as _iqdhlx3t;
import 'password/account_reset_kind.dart' as _i91ka1it;
@ -63,6 +65,8 @@ export 'auth/sign_in_ways.dart';
export 'instance/instance_basics.dart';
export 'keys/device_keys.dart';
export 'keys/keys_challenge.dart';
export 'keys/keys_owner_proof.dart';
export 'keys/keys_password.dart';
export 'keys/keys_proof.dart';
export 'keys/keys_setup.dart';
export 'password/account_reset_kind.dart';
@ -163,6 +167,12 @@ class Protocol extends _isc.SerializationManager {
if (t == _itebbsfc.KeysChallenge) {
return _itebbsfc.KeysChallenge.fromJson(data) as T;
}
if (t == _iis161ma.KeysOwnerProof) {
return _iis161ma.KeysOwnerProof.fromJson(data) as T;
}
if (t == _i70xpmnz.KeysPassword) {
return _i70xpmnz.KeysPassword.fromJson(data) as T;
}
if (t == _iviximyh.KeysProof) {
return _iviximyh.KeysProof.fromJson(data) as T;
}
@ -281,6 +291,13 @@ class Protocol extends _isc.SerializationManager {
return (data != null ? _itebbsfc.KeysChallenge.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_iis161ma.KeysOwnerProof?>()) {
return (data != null ? _iis161ma.KeysOwnerProof.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_i70xpmnz.KeysPassword?>()) {
return (data != null ? _i70xpmnz.KeysPassword.fromJson(data) : null) as T;
}
if (t == _isc.getType<_iviximyh.KeysProof?>()) {
return (data != null ? _iviximyh.KeysProof.fromJson(data) : null) as T;
}
@ -436,6 +453,8 @@ class Protocol extends _isc.SerializationManager {
_i8jixqx6.InstanceBasics => 'InstanceBasics',
_iz50cwqw.DeviceKeys => 'DeviceKeys',
_itebbsfc.KeysChallenge => 'KeysChallenge',
_iis161ma.KeysOwnerProof => 'KeysOwnerProof',
_i70xpmnz.KeysPassword => 'KeysPassword',
_iviximyh.KeysProof => 'KeysProof',
_iqdhlx3t.KeysSetup => 'KeysSetup',
_i91ka1it.AccountResetKind => 'AccountResetKind',
@ -497,6 +516,10 @@ class Protocol extends _isc.SerializationManager {
return 'DeviceKeys';
case _itebbsfc.KeysChallenge():
return 'KeysChallenge';
case _iis161ma.KeysOwnerProof():
return 'KeysOwnerProof';
case _i70xpmnz.KeysPassword():
return 'KeysPassword';
case _iviximyh.KeysProof():
return 'KeysProof';
case _iqdhlx3t.KeysSetup():
@ -586,6 +609,12 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'KeysChallenge') {
return deserialize<_itebbsfc.KeysChallenge>(data['data']);
}
if (dataClassName == 'KeysOwnerProof') {
return deserialize<_iis161ma.KeysOwnerProof>(data['data']);
}
if (dataClassName == 'KeysPassword') {
return deserialize<_i70xpmnz.KeysPassword>(data['data']);
}
if (dataClassName == 'KeysProof') {
return deserialize<_iviximyh.KeysProof>(data['data']);
}

View file

@ -66,7 +66,7 @@ abstract class SimpleLoginCompleted
/// Whether the account signed in to, or SimpleLogin was added to, has its
/// keys (`keys.unlock` opens them); one without (an account of before,
/// adding SimpleLogin) makes them next.
/// adding SimpleLogin) makes them next (`keys.set`).
bool hasKeys;
/// The account's handle; null for one without (it chooses one with its

View file

@ -158,6 +158,28 @@ Uint8List newPasswordDigest({
keysCipherText,
]);
/// The SHA-256 of a new password for the keys of an account that signs in
/// with SimpleLogin as the device sends it, framed: what a proof for it
/// binds (the old password's, or the phrase's).
Uint8List newKeysPasswordDigest({
required List<int> salt,
required String algorithm,
required int opsLimit,
required int memLimit,
required List<int> proofKey,
required List<int> keysNonce,
required List<int> keysCipherText,
}) => _sha256([
utf8.encode('halkyon new keys password v1'),
salt,
utf8.encode(algorithm),
utf8.encode('$opsLimit'),
utf8.encode('$memLimit'),
proofKey,
keysNonce,
keysCipherText,
]);
/// The SHA-256 of a new recovery phrase as the device sends it (the keys
/// wrapped under it and its proof key's public half), framed: what the
/// owner's proof for it binds.