feat(keys): the password's own proof key (ADR 0020, D19)

A third subkey of the password's Argon2id (context `hlkpass1`, subkey 3)
seeds an Ed25519 pair: `PasswordKeys.proofKeyPair` and `proofPublicKey`.
An account that keeps no SRP verifier proves its password by signing the
register's nonce with it, as it proves the recovery phrase; neither SRP's
verifier nor the unwrapped account keys a signed-in device keeps can make
it.

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-23 21:20:56 +02:00
commit e8953d1560
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -54,6 +54,72 @@ Future<void> main() async {
expect(keys.srpPrivateValue, greaterThan(BigInt.zero));
});
group("the password's proof key (Halkyon Learn's ADR 0020, D19)", () {
test('is the same for the same password and salt, another for another '
'password or salt', () {
final keys = keysOf('correct horse battery staple');
expect(
keysOf('correct horse battery staple').proofPublicKey,
keys.proofPublicKey,
);
expect(keys.proofPublicKey, hasLength(sodium.crypto.sign.publicKeyBytes));
expect(
keysOf('correct horse battery stapler').proofPublicKey,
isNot(keys.proofPublicKey),
);
expect(
keysOf(
'correct horse battery staple',
withSalt: Uint8List.fromList(List.generate(16, (i) => i + 1)),
).proofPublicKey,
isNot(keys.proofPublicKey),
);
});
test("is unrelated to SRP's secret and the key-encryption key, so "
'neither the register nor a device holding the unwrapped keys can '
'make it', () {
final keys = keysOf('a password');
final pair = keys.proofKeyPair();
final seed = sodium.crypto.sign.skToSeed(pair.secretKey).extractBytes();
expect(seed, hasLength(32));
expect(seed, isNot(keys.srpSecret.extractBytes()));
expect(seed, isNot(keys.keyEncryptionKey.extractBytes()));
expect(
pair.publicKey,
isNot(AccountKeys.generate(sodium).signingPublicKey),
);
pair.secretKey.dispose();
});
test('signs what the register checks against its public half', () {
final keys = keysOf('a password');
final pair = keys.proofKeyPair();
final message = Uint8List.fromList([1, 2, 3]);
final signature = sodium.crypto.sign.detached(
message: message,
secretKey: pair.secretKey,
);
pair.secretKey.dispose();
expect(
sodium.crypto.sign.verifyDetached(
message: message,
signature: signature,
publicKey: keys.proofPublicKey,
),
isTrue,
);
expect(
sodium.crypto.sign.verifyDetached(
message: message,
signature: signature,
publicKey: keysOf('another password').proofPublicKey,
),
isFalse,
);
});
});
test(
'a salt of another length, as an instance might send one, is refused',
() {