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
7 changed files with 40952 additions and 93 deletions

View file

@ -52,6 +52,15 @@ final class const NewAccountMaterial({
/// The device's answer to SRP's first step: A and M1 for the register, and
/// whether the register's M2 is the one that proves it holds the verifier.
/// A new recovery phrase made on the device ([AccountCrypto.newPhrase]): the
/// phrase to show, the account's keys wrapped under it, and the public half
/// of its proof key, which the register keeps.
typedef NewPhraseMaterial = ({
String phrase,
protocol.WrappedKeys recoveryKeys,
ByteData recoveryProofKey,
});
final class const SrpAnswer({
required final ByteData clientValue,
required final ByteData clientProof,
@ -118,14 +127,17 @@ abstract interface class AccountCrypto() {
required Uint8List signingPublicKey,
});
/// [keys]' signature proving the recovery phrase for [challenge].
/// The signature proving the recovery [phrase] for [challenge]: by the
/// phrase's own proof key, derived from it alone while it is typed and
/// kept nowhere, never the account's signing key, which every signed-in
/// device keeps. Refused (`PasswordProblem.notAPhrase`) for what is no
/// recovery phrase.
Future<ByteData> signKeyProof(
UnlockedAccountKeys keys,
String phrase,
protocol.KeyProofChallenge challenge,
);
/// A new recovery phrase for [keys], and the keys wrapped under it.
Future<({String phrase, protocol.WrappedKeys recoveryKeys})> newPhrase(
UnlockedAccountKeys keys,
);
/// A new recovery phrase for [keys]: the keys wrapped under it, and the
/// public half of its proof key.
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys);
}

View file

@ -104,15 +104,16 @@ abstract interface class PasswordGateway() {
required protocol.NewPassword password,
});
/// A new recovery phrase's wrapping of the keys: [answer] to [challenge]
/// ([beginChange]) proves the password, with a [totpCode] or
/// [recoveryCode].
/// A new recovery phrase's wrapping of the keys and its [proofKey]'s
/// public half: [answer] to [challenge] ([beginChange]) proves the
/// password, with a [totpCode] or [recoveryCode].
Future<void> setRecoveryKeys(
protocol.PasswordChallenge challenge,
SrpAnswer answer, {
String? totpCode,
String? recoveryCode,
required protocol.WrappedKeys keys,
required ByteData proofKey,
});
/// Asks for the reset of a lost authenticator: [answer] to [challenge]

View file

@ -50,6 +50,7 @@ final class const PendingSignIn({
final class const NewPhrase(
final String phrase,
final protocol.WrappedKeys _recoveryKeys,
final ByteData _recoveryProofKey,
) {
@override
String toString() => 'NewPhrase(…)';
@ -340,7 +341,7 @@ class PasswordRepository(
/// instance takes it with [keepPhrase].
Future<NewPhrase> newPhrase() async {
final made = await _crypto.newPhrase(await _keysHere());
return NewPhrase(made.phrase, made.recoveryKeys);
return NewPhrase(made.phrase, made.recoveryKeys, made.recoveryProofKey);
}
/// [phrase] in place of the old recovery phrase, with the [password] and
@ -364,6 +365,7 @@ class PasswordRepository(
await _crypto.answer(unlock, challenge),
totpCode: code.replaceAll(' ', ''),
keys: phrase._recoveryKeys,
proofKey: phrase._recoveryProofKey,
);
_changed();
}
@ -385,8 +387,10 @@ class PasswordRepository(
ByteData? signature;
if (phrase != null) {
keyChallenge = await _gateway.beginKeyProof(unlocked.address);
final keys = await _openWithPhrase(keyChallenge, phrase);
signature = await _crypto.signKeyProof(keys, keyChallenge);
// Opened only to say here, rather than on the instance, that the
// phrase doesn't fit.
await _openWithPhrase(keyChallenge, phrase);
signature = await _crypto.signKeyProof(phrase, keyChallenge);
}
final challenge = await _challengeOf(unlocked);
final answer = await _crypto.answer(unlocked._unlock, challenge);
@ -415,7 +419,7 @@ class PasswordRepository(
final typed = address.trim();
final challenge = await _gateway.beginKeyProof(typed);
final keys = await _openWithPhrase(challenge, phrase);
final signature = await _crypto.signKeyProof(keys, challenge);
final signature = await _crypto.signKeyProof(phrase, challenge);
final grant = await _gateway.resetPassword(
challenge,
signature,

View file

@ -196,6 +196,7 @@ class ServerpodPasswordGateway(
String? totpCode,
String? recoveryCode,
required protocol.WrappedKeys keys,
required ByteData proofKey,
}) => _failing(
() => _base.password.setRecoveryKeys(
challenge: challenge.challenge,
@ -204,6 +205,7 @@ class ServerpodPasswordGateway(
totpCode: totpCode,
recoveryCode: recoveryCode,
recoveryKeys: keys,
recoveryProofKey: proofKey,
),
);

View file

@ -204,12 +204,17 @@ class SodiumAccountCrypto() implements AccountCrypto {
@override
Future<ByteData> signKeyProof(
UnlockedAccountKeys keys,
String phrase,
protocol.KeyProofChallenge challenge,
) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final opened = _keysOf(sodium, keys);
final KeyPair proof;
try {
proof = recoveryProofKeysOf(sodium, phrase);
} on KeyRefused {
throw const PasswordFailed(PasswordProblem.notAPhrase);
}
try {
return ByteData.sublistView(
_own(
@ -218,20 +223,18 @@ class SodiumAccountCrypto() implements AccountCrypto {
challenge.challenge.toBytes(),
Uint8List.sublistView(challenge.nonce),
),
secretKey: opened.signing.secretKey,
secretKey: proof.secretKey,
),
),
);
} finally {
opened.dispose();
proof.secretKey.dispose();
}
}),
);
@override
Future<({String phrase, protocol.WrappedKeys recoveryKeys})> newPhrase(
UnlockedAccountKeys keys,
) => Isolate.run(
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) => Isolate.run(
() => _guarded(() async {
final sodium = await SodiumSumoInit.init();
final opened = _keysOf(sodium, keys);
@ -241,6 +244,9 @@ class SodiumAccountCrypto() implements AccountCrypto {
return (
phrase: phrase,
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, opened, key)),
recoveryProofKey: ByteData.sublistView(
recoveryProofPublicKeyOf(sodium, phrase),
),
);
} finally {
key.dispose();
@ -307,6 +313,10 @@ NewAccountMaterial _material(
wrapAccountKeys(sodium, keys, unlocked.keyEncryptionKey),
),
recoveryKeys: _wrappedOf(wrapAccountKeys(sodium, keys, recoveryKey)),
// Copied by recoveryProofPublicKeyOf already.
recoveryProofKey: ByteData.sublistView(
recoveryProofPublicKeyOf(sodium, phrase),
),
),
phrase: phrase,
keys: _unlockedOf(keys),

View file

@ -42,6 +42,30 @@ class FakeAccountCrypto() implements AccountCrypto {
),
);
/// The public half of [phrase]'s proof key, as the fake makes it.
static ByteData proofKeyOf(String phrase) => ByteData.sublistView(
utf8.encode('proof key of ${_words(phrase)}'),
);
/// The signature of [phrase]'s proof key, as the fake makes it.
static ByteData signatureOf(String phrase) =>
ByteData.sublistView(utf8.encode('signed by ${_words(phrase)}'));
/// Whether [signature] is by the proof key whose public half is
/// [proofKey].
static bool proves(ByteData signature, ByteData? proofKey) {
if (proofKey == null) {
return false;
}
final signed = utf8.decode(Uint8List.sublistView(signature));
final key = utf8.decode(Uint8List.sublistView(proofKey));
return signed.startsWith('signed by ') &&
key == 'proof key of ${signed.substring('signed by '.length)}';
}
static String _words(String phrase) =>
phrase.trim().toLowerCase().split(RegExp(r'\s+')).join(' ');
static UnlockedAccountKeys _open(
String opener,
protocol.WrappedKeys wrapped,
@ -81,6 +105,7 @@ class FakeAccountCrypto() implements AccountCrypto {
signingPublicKey: ByteData.sublistView(keys.signingPublicKey),
passwordKeys: wrap(password, keys),
recoveryKeys: wrap(phrase, keys),
recoveryProofKey: proofKeyOf(phrase),
),
phrase: phrase,
keys: keys,
@ -174,13 +199,19 @@ class FakeAccountCrypto() implements AccountCrypto {
@override
Future<ByteData> signKeyProof(
UnlockedAccountKeys keys,
String phrase,
protocol.KeyProofChallenge challenge,
) async => ByteData.sublistView(keys.signingSecretKey);
) async {
if (phrase.trim().split(RegExp(r'\s+')).length != 24) {
throw const PasswordFailed(PasswordProblem.notAPhrase);
}
return signatureOf(phrase);
}
@override
Future<({String phrase, protocol.WrappedKeys recoveryKeys})> newPhrase(
UnlockedAccountKeys keys,
) async =>
(phrase: otherFakePhrase, recoveryKeys: wrap(otherFakePhrase, keys));
Future<NewPhraseMaterial> newPhrase(UnlockedAccountKeys keys) async => (
phrase: otherFakePhrase,
recoveryKeys: wrap(otherFakePhrase, keys),
recoveryProofKey: proofKeyOf(otherFakePhrase),
);
}

View file

@ -31,6 +31,9 @@ final class FakePasswordAccount(
String? password;
protocol.WrappedKeys? passwordKeys;
protocol.WrappedKeys? recoveryKeys;
/// The public half of the recovery phrase's proof key.
ByteData? recoveryProofKey;
ByteData encryptionPublicKey = ByteData(32);
ByteData signingPublicKey = ByteData(32);
final authenticators = <AccountAuthenticator>[];
@ -105,6 +108,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
..password = password
..passwordKeys = FakeAccountCrypto.wrap(password, keys)
..recoveryKeys = FakeAccountCrypto.wrap(phrase, keys)
..recoveryProofKey = FakeAccountCrypto.proofKeyOf(phrase)
..encryptionPublicKey = ByteData.sublistView(keys.encryptionPublicKey)
..signingPublicKey = ByteData.sublistView(keys.signingPublicKey);
if (finished) {
@ -314,6 +318,7 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
..password = utf8.decode(Uint8List.sublistView(setup.verifier))
..passwordKeys = setup.passwordKeys
..recoveryKeys = setup.recoveryKeys
..recoveryProofKey = setup.recoveryProofKey
..encryptionPublicKey = setup.encryptionPublicKey
..signingPublicKey = setup.signingPublicKey;
}
@ -532,8 +537,11 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
String? totpCode,
String? recoveryCode,
required protocol.WrappedKeys keys,
required ByteData proofKey,
}) => _call('setRecoveryKeys', () {
_ownerProved(challenge, answer, totpCode, recoveryCode).recoveryKeys = keys;
_ownerProved(challenge, answer, totpCode, recoveryCode)
..recoveryKeys = keys
..recoveryProofKey = proofKey;
});
@override
@ -552,7 +560,10 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
if (_proven(request, code) != account.address) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
} else if (signature == null) {
} else if (keyChallenge == null ||
signature == null ||
accounts[_keyProofs.remove(keyChallenge.challenge.uuid)] != account ||
!FakeAccountCrypto.proves(signature, account.recoveryProofKey)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
return account.authenticatorReset ??= clock.now().add(fakeResetDelay);
@ -592,13 +603,14 @@ class FakePasswordGateway(final AuthGateway _auth) implements PasswordGateway {
String? totpCode,
String? recoveryCode,
}) => _call('resetPassword', () {
// The phrase opened the keys on the device (the fake crypto refuses a
// wrong one); the challenge names the account.
// The challenge names the account, whose phrase's proof key must have
// signed: never the account's own keys.
final account = accounts[_keyProofs.remove(challenge.challenge.uuid)];
if (account?.password == null) {
if (account?.password == null ||
!FakeAccountCrypto.proves(signature, account!.recoveryProofKey)) {
throw const PasswordFailed(PasswordProblem.wrongCredentials);
}
_secondFactor(account!, totpCode: totpCode, recoveryCode: recoveryCode);
_secondFactor(account, totpCode: totpCode, recoveryCode: recoveryCode);
account
..password = utf8.decode(Uint8List.sublistView(password.verifier))
..passwordKeys = password.passwordKeys;