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

View file

@ -15,13 +15,18 @@ const testPasswordKeys = (
);
/// A device with an account's password, as halkyon_keys makes one, without
/// libsodium: SRP's x and the account's keys random, the signing key a real
/// Ed25519 pair, so it can prove the recovery phrase. What the register
/// checks is all here; what only a device opens (the wrapped keys) is
/// random bytes of the right length.
/// libsodium: SRP's x and the account's keys random, the signing key and the
/// recovery phrase's proof key real Ed25519 pairs, so it can sign and prove
/// the phrase. What the register checks is all here; what only a device
/// opens (the wrapped keys) is random bytes of the right length.
final class PasswordDevice {
PasswordDevice._(this._random, this.signingKeys, this.signingPublicKey)
: salt = _bytes(_random, 16),
PasswordDevice._(
this._random,
this.signingKeys,
this.signingPublicKey,
this.proofKeys,
this.recoveryProofKey,
) : salt = _bytes(_random, 16),
privateValue = numberOf(_bytes(_random, 32)),
encryptionPublicKey = _bytes(_random, 32);
@ -30,16 +35,29 @@ final class PasswordDevice {
final random = Random(seed);
final keys = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
final public = await keys.extractPublicKey();
return PasswordDevice._(random, keys, Uint8List.fromList(public.bytes));
final proof = await Ed25519().newKeyPairFromSeed(_bytes(random, 32));
final proofPublic = await proof.extractPublicKey();
return PasswordDevice._(
random,
keys,
Uint8List.fromList(public.bytes),
proof,
Uint8List.fromList(proofPublic.bytes),
);
}
final Random _random;
/// The account's signing key pair, which the recovery phrase opens.
/// The account's signing key pair, which the password and the recovery
/// phrase open, and every signed-in device keeps.
final SimpleKeyPair signingKeys;
final Uint8List signingPublicKey;
final Uint8List encryptionPublicKey;
/// The recovery phrase's own proof key, from the phrase alone.
SimpleKeyPair proofKeys;
Uint8List recoveryProofKey;
/// The password's salt and x, as Argon2id would make them.
Uint8List salt;
BigInt privateValue;
@ -72,8 +90,23 @@ final class PasswordDevice {
signingPublicKey: ByteData.sublistView(signingPublicKey),
passwordKeys: passwordKeys,
recoveryKeys: recoveryKeys,
recoveryProofKey: ByteData.sublistView(recoveryProofKey),
);
/// A new recovery phrase for the same keys: its wrapping of them and its
/// proof key, which become the device's.
Future<({WrappedKeys recoveryKeys, ByteData recoveryProofKey})>
newPhrase() async {
proofKeys = await Ed25519().newKeyPairFromSeed(_bytes(_random, 32));
recoveryProofKey = Uint8List.fromList(
(await proofKeys.extractPublicKey()).bytes,
);
return (
recoveryKeys: _wrapped(),
recoveryProofKey: ByteData.sublistView(recoveryProofKey),
);
}
/// A new password for the same keys: the device's salt and x change.
NewPassword newPassword() {
salt = _bytes(_random, 16);
@ -113,8 +146,8 @@ final class PasswordDevice {
);
}
/// The signature proving the recovery phrase for [challenge]: the
/// account's own, or [keys]' where given.
/// The signature proving the recovery phrase for [challenge]: by the
/// phrase's proof key, or [keys]' where given.
Future<ByteData> sign(
KeyProofChallenge challenge, {
SimpleKeyPair? keys,
@ -124,7 +157,7 @@ final class PasswordDevice {
challenge.challenge,
Uint8List.sublistView(challenge.nonce),
),
keyPair: keys ?? signingKeys,
keyPair: keys ?? proofKeys,
);
return ByteData.sublistView(Uint8List.fromList(signature.bytes));
}