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

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_base_client/srp.dart' as srp;
import 'package:halkyon_keys/halkyon_keys.dart' show recoveryProofPublicKeyOf;
import 'package:sodium/sodium_sumo.dart';
Matcher failsWith(PasswordProblem problem) => throwsA(
@ -160,8 +161,9 @@ void main() {
);
});
test("signs the key proof with the account's key, as the register checks "
'it', () async {
test("signs the key proof with the phrase's own proof key, whose public "
"half the setup carries: never the account's key, which a signed-in "
'device keeps', () async {
final challenge = protocol.KeyProofChallenge(
challenge: protocol.UuidValue.fromString(
'01994a3c-7b2e-7c00-8a1b-0123456789ab',
@ -174,20 +176,46 @@ void main() {
recoveryKeys: account.setup.recoveryKeys,
);
final signature = await crypto.signKeyProof(account.keys, challenge);
final signature = await crypto.signKeyProof(account.phrase, challenge);
final sodium = await SodiumSumoInit.init();
expect(
sodium.crypto.sign.verifyDetached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
List.filled(32, 9),
),
signature: Uint8List.sublistView(signature),
publicKey: account.keys.signingPublicKey,
bool verifies(Uint8List publicKey) => sodium.crypto.sign.verifyDetached(
message: srp.keyProofMessage(
challenge.challenge.toBytes(),
List.filled(32, 9),
),
signature: Uint8List.sublistView(signature),
publicKey: publicKey,
);
expect(
verifies(Uint8List.sublistView(account.setup.recoveryProofKey)),
isTrue,
);
expect(verifies(account.keys.signingPublicKey), isFalse);
await expectLater(
crypto.signKeyProof('not a phrase', challenge),
throwsA(
isA<PasswordFailed>().having(
(failed) => failed.problem,
'problem',
PasswordProblem.notAPhrase,
),
),
);
});
test("a new phrase carries its proof key's public half, which its "
'signatures verify under', () async {
final made = await crypto.newPhrase(account.keys);
final sodium = await SodiumSumoInit.init();
expect(
Uint8List.sublistView(made.recoveryProofKey),
recoveryProofPublicKeyOf(sodium, made.phrase),
);
expect(
Uint8List.sublistView(made.recoveryProofKey),
isNot(Uint8List.sublistView(account.setup.recoveryProofKey)),
);
});
test("answers SRP's first step with A as long as N", () async {