fix(auth)!: the recovery phrase is proved by a key of its own

A key proof was a signature by the account's signing key, which every
signed-in device keeps in its keyring: whoever could read one, with a
code from its authenticator, set a new password without the password or
the phrase (the second reading's H1). The phrase now proves itself with
an Ed25519 key derived from its bits alone (halkyon_keys'
recoveryProofKeysOf, KDF context hlkrprf1, a sibling of the recovery
key), whose public half every setup, new phrase and account reset sends
and the register keeps (account_password.recoveryProofKey); the device
derives the secret half only while the phrase is typed. An account
without one (none exist outside development) proves no phrase until it
sets a new one; decoys check against a decoy proof key alike.

Migration (module, Learn's and Todo's servers, Learn's device schema):
ALTER TABLE "account_password" ADD COLUMN "recoveryProofKey" bytea;
nullable, so rows made before keep working and no data is touched.

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 21:03:15 +02:00
commit aea0c447fc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 40952 additions and 93 deletions

View file

@ -459,7 +459,8 @@ class EndpointPassword extends _isc.EndpointRef {
);
/// A new recovery phrase: [recoveryKeys], the caller's keys wrapped under
/// it on the device, replace the old wrapping, with the password's proof
/// it on the device, replace the old wrapping, and [recoveryProofKey], the
/// public half of its proof key, the old one, with the password's proof
/// for [challenge] ([beginChange]) and a [totpCode] or [recoveryCode], as
/// [change] takes. Refusals as `setRecoveryKeys`'.
_ida.Future<void> setRecoveryKeys({
@ -469,6 +470,7 @@ class EndpointPassword extends _isc.EndpointRef {
String? totpCode,
String? recoveryCode,
required _i3so7e0p.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
}) => caller.callServerEndpoint<void>(
'halkyon_base.password',
'setRecoveryKeys',
@ -479,6 +481,7 @@ class EndpointPassword extends _isc.EndpointRef {
'totpCode': totpCode,
'recoveryCode': recoveryCode,
'recoveryKeys': recoveryKeys,
'recoveryProofKey': recoveryProofKey,
},
);
}

View file

@ -18,8 +18,8 @@ import '../password/wrapped_keys.dart' as _i2rxumte;
/// What a device needs to prove the recovery phrase (ADR 0017): the
/// account's private keys wrapped under the phrase's key and its public
/// keys, which the device opens with the phrase, and a nonce it signs with
/// the signing key so opened, within two minutes, once. Alike for an address
/// nobody has.
/// the phrase's own proof key, within two minutes, once. Alike for an
/// address nobody has.
abstract class KeyProofChallenge
implements _isc.SerializableModel, _isc.ProtocolSerialization {
KeyProofChallenge._({

View file

@ -31,6 +31,7 @@ abstract class PasswordSetup
required this.signingPublicKey,
required this.passwordKeys,
required this.recoveryKeys,
required this.recoveryProofKey,
});
factory PasswordSetup({
@ -41,6 +42,7 @@ abstract class PasswordSetup
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required _i2rxumte.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
}) = _PasswordSetupImpl;
factory PasswordSetup.fromJson(Map<String, dynamic> jsonSerialization) {
@ -64,6 +66,9 @@ abstract class PasswordSetup
recoveryKeys: _iwgbpyo5.Protocol().deserialize<_i2rxumte.WrappedKeys>(
jsonSerialization['recoveryKeys'],
),
recoveryProofKey: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['recoveryProofKey'],
),
);
}
@ -82,6 +87,10 @@ abstract class PasswordSetup
_i2rxumte.WrappedKeys recoveryKeys;
/// The public half of the recovery phrase's proof key (Ed25519, from the
/// phrase alone).
_idt.ByteData recoveryProofKey;
/// Returns a shallow copy of this [PasswordSetup]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -93,6 +102,7 @@ abstract class PasswordSetup
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
_i2rxumte.WrappedKeys? recoveryKeys,
_idt.ByteData? recoveryProofKey,
});
@override
Map<String, dynamic> toJson() {
@ -105,6 +115,7 @@ abstract class PasswordSetup
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJson(),
'recoveryKeys': recoveryKeys.toJson(),
'recoveryProofKey': recoveryProofKey.toJson(),
};
}
@ -119,6 +130,7 @@ abstract class PasswordSetup
'signingPublicKey': signingPublicKey.toJson(),
'passwordKeys': passwordKeys.toJsonForProtocol(),
'recoveryKeys': recoveryKeys.toJsonForProtocol(),
'recoveryProofKey': recoveryProofKey.toJson(),
};
}
@ -137,6 +149,7 @@ class _PasswordSetupImpl extends PasswordSetup {
required _idt.ByteData signingPublicKey,
required _i2rxumte.WrappedKeys passwordKeys,
required _i2rxumte.WrappedKeys recoveryKeys,
required _idt.ByteData recoveryProofKey,
}) : super._(
salt: salt,
costs: costs,
@ -145,6 +158,7 @@ class _PasswordSetupImpl extends PasswordSetup {
signingPublicKey: signingPublicKey,
passwordKeys: passwordKeys,
recoveryKeys: recoveryKeys,
recoveryProofKey: recoveryProofKey,
);
/// Returns a shallow copy of this [PasswordSetup]
@ -159,6 +173,7 @@ class _PasswordSetupImpl extends PasswordSetup {
_idt.ByteData? signingPublicKey,
_i2rxumte.WrappedKeys? passwordKeys,
_i2rxumte.WrappedKeys? recoveryKeys,
_idt.ByteData? recoveryProofKey,
}) {
return PasswordSetup(
salt: salt ?? this.salt.clone(),
@ -169,6 +184,7 @@ class _PasswordSetupImpl extends PasswordSetup {
signingPublicKey: signingPublicKey ?? this.signingPublicKey.clone(),
passwordKeys: passwordKeys ?? this.passwordKeys.copyWith(),
recoveryKeys: recoveryKeys ?? this.recoveryKeys.copyWith(),
recoveryProofKey: recoveryProofKey ?? this.recoveryProofKey.clone(),
);
}
}