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
2 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,
);