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

@ -21,5 +21,7 @@ export 'src/recovery_phrase.dart'
newRecoveryPhrase,
recoveryEntropyBytes,
recoveryKeyOf,
recoveryPhraseOf;
recoveryPhraseOf,
recoveryProofKeysOf,
recoveryProofPublicKeyOf;
export 'src/sealed_content.dart' show SealedContent, openContent, sealContent;

View file

@ -14,6 +14,11 @@ import 'package:sodium/sodium_sumo.dart';
/// libsodium's KDF context for the recovery key (exactly 8 bytes).
const _recoveryContext = 'hlkrcvy1';
/// libsodium's KDF context for the seed of the phrase's proof key (exactly
/// 8 bytes): a sibling of the recovery key, never derivable from it or from
/// the account's keys.
const _proofContext = 'hlkrprf1';
/// How many random bytes a new phrase stands for: 256 bits, 24 words.
const recoveryEntropyBytes = 32;
@ -90,7 +95,39 @@ Uint8List entropyOfRecoveryPhrase(String phrase) {
/// libsodium's KDF, in guarded memory. Refused as
/// [entropyOfRecoveryPhrase] refuses, and for a phrase of fewer than 256
/// bits (recovery phrases are 24 words).
SecureKey recoveryKeyOf(SodiumSumo sodium, String phrase) {
SecureKey recoveryKeyOf(SodiumSumo sodium, String phrase) =>
_subkeyOf(sodium, phrase, _recoveryContext);
/// The phrase's proof key (ADR 0017): an Ed25519 pair whose seed is
/// derived from [phrase]'s 256 bits alone, under a KDF context of its own,
/// so neither the recovery key nor the account's keys (which every
/// signed-in device keeps) can make it. The register keeps its public half
/// ([recoveryProofPublicKeyOf]) and takes a signature by it as proof that
/// whoever asks holds the phrase; the device derives the pair only while
/// the phrase is typed, and keeps nothing of it. Refused as
/// [recoveryKeyOf] refuses.
KeyPair recoveryProofKeysOf(SodiumSumo sodium, String phrase) {
final seed = _subkeyOf(sodium, phrase, _proofContext);
try {
return sodium.crypto.sign.seedKeyPair(seed);
} finally {
seed.dispose();
}
}
/// The public half of [phrase]'s proof key, which the register keeps.
Uint8List recoveryProofPublicKeyOf(SodiumSumo sodium, String phrase) {
final pair = recoveryProofKeysOf(sodium, phrase);
try {
return Uint8List.fromList(pair.publicKey);
} finally {
pair.secretKey.dispose();
}
}
/// The 32-byte subkey of [phrase]'s 256 bits for [context], through
/// libsodium's KDF, in guarded memory; refused as [recoveryKeyOf] refuses.
SecureKey _subkeyOf(SodiumSumo sodium, String phrase, String context) {
final entropy = entropyOfRecoveryPhrase(phrase);
try {
if (entropy.length != recoveryEntropyBytes) {
@ -100,7 +137,7 @@ SecureKey recoveryKeyOf(SodiumSumo sodium, String phrase) {
try {
return sodium.crypto.kdf.deriveFromKey(
masterKey: master,
context: _recoveryContext,
context: context,
subkeyId: BigInt.one,
subkeyLen: 32,
);

View file

@ -124,6 +124,60 @@ Future<void> main() async {
expect(key, isNot(entropyOfRecoveryPhrase(phrase)));
});
group("the phrase's proof key", () {
test('is an Ed25519 pair of the phrase alone: the same from the same '
'phrase, another from another, signing what verifies under its '
'public half', () {
final phrase = newRecoveryPhrase(sodium);
final pair = recoveryProofKeysOf(sodium, phrase);
expect(pair.publicKey, hasLength(sodium.crypto.sign.publicKeyBytes));
expect(
recoveryProofKeysOf(sodium, phrase.toUpperCase()).publicKey,
pair.publicKey,
);
expect(recoveryProofPublicKeyOf(sodium, phrase), pair.publicKey);
expect(
recoveryProofKeysOf(sodium, newRecoveryPhrase(sodium)).publicKey,
isNot(pair.publicKey),
);
final message = Uint8List.fromList([1, 2, 3]);
final signature = sodium.crypto.sign.detached(
message: message,
secretKey: pair.secretKey,
);
expect(
sodium.crypto.sign.verifyDetached(
message: message,
signature: signature,
publicKey: pair.publicKey,
),
isTrue,
);
});
test("is neither the recovery key nor the account's keys: a device "
"holding the account's keys can't make it", () {
final phrase = newRecoveryPhrase(sodium);
final proof = recoveryProofKeysOf(sodium, phrase);
final seed = sodium.crypto.sign.skToSeed(proof.secretKey).extractBytes();
expect(seed, isNot(recoveryKeyOf(sodium, phrase).extractBytes()));
expect(seed, isNot(entropyOfRecoveryPhrase(phrase)));
final account = AccountKeys.generate(sodium);
expect(proof.publicKey, isNot(account.signingPublicKey));
});
test('is refused for what is no recovery phrase', () {
expect(
() => recoveryProofKeysOf(sodium, bip39Vectors.first.$2),
refusedFor(KeyRefusal.phraseLength),
);
expect(
() => recoveryProofKeysOf(sodium, '${'abandon ' * 23}zoo'),
refusedFor(KeyRefusal.phraseChecksum),
);
});
});
test('entropy of a length BIP-39 has no phrase for is refused', () {
for (final length in [0, 15, 17, 33]) {
expect(() => recoveryPhraseOf(Uint8List(length)), throwsArgumentError);