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:
parent
38bb090575
commit
aea0c447fc
1 changed files with 40952 additions and 93 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue