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
2 changed files with 114 additions and 10 deletions

View file

@ -1,5 +1,6 @@
/// An account's keys on the device (ADR 0017): what the password unlocks
/// (Argon2id, split into SRP's x and the key-encryption key), the account's
/// (Argon2id, split into SRP's x, the key-encryption key and the password's
/// proof key), the account's
/// key pairs and their wrapping, the recovery phrase and its key, data keys
/// sealed to the account, and content sealed and signed in ADR 0002's
/// envelope. libsodium (the `sodium` package, its sumo API for Argon2id)

View file

@ -7,9 +7,11 @@ import 'package:sodium/sodium_sumo.dart';
/// The password's keys (ADR 0017): Argon2id over the password and the
/// account's salt makes one master key, which libsodium's KDF splits into
/// SRP's secret (from which x is made, the one value SRP takes in place of
/// the password) and the key-encryption key that wraps the account's private
/// keys. The password, the master key and both halves never leave the
/// device.
/// the password), the key-encryption key that wraps the account's private
/// keys, and the seed of the password's proof key (Halkyon Learn's ADR
/// 0020, D19), with which an account that keeps no SRP verifier proves the
/// password. The password, the master key and the three subkeys never leave
/// the device; of the proof key, only its public half does.
/// libsodium's KDF context for the password's subkeys (exactly 8 bytes).
const _passwordContext = 'hlkpass1';
@ -20,6 +22,9 @@ final _srpSubkey = BigInt.one;
/// The subkey that wraps the account's private keys.
final _keyEncryptionSubkey = BigInt.two;
/// The subkey that seeds the password's proof key.
final _proofSubkey = BigInt.from(3);
/// How long each derived key is.
const _keyBytes = 32;
@ -99,6 +104,11 @@ final class PasswordKeys._(
/// The key that wraps the account's private keys.
final SecureKey keyEncryptionKey,
/// The seed of the password's proof key ([proofKeyPair]).
final SecureKey _proofSeed,
final SodiumSumo _sodium,
) {
/// The number x SRP takes in place of the password: the SRP subkey's 256
/// bits, big-endian. It is the password's Argon2id in all but name, so a
@ -111,11 +121,30 @@ final class PasswordKeys._(
return value;
});
/// Wipes and frees both keys; neither may be read afterwards (libsodium
/// The password's proof key: an Ed25519 pair seeded by a subkey of its
/// own, so neither SRP's verifier nor the account's keys (which every
/// signed-in device keeps unwrapped) can make it. The register keeps its
/// public half ([proofPublicKey]) and takes a signature by it as proof of
/// the password, as it takes the recovery phrase's (`recoveryProofKeysOf`).
/// The caller disposes of its secret key.
KeyPair proofKeyPair() => _sodium.crypto.sign.seedKeyPair(_proofSeed);
/// The public half of the password's proof key, which the register keeps.
Uint8List get proofPublicKey {
final pair = proofKeyPair();
try {
return Uint8List.fromList(pair.publicKey);
} finally {
pair.secretKey.dispose();
}
}
/// Wipes and frees the three keys; none may be read afterwards (libsodium
/// has freed their memory).
void dispose() {
srpSecret.dispose();
keyEncryptionKey.dispose();
_proofSeed.dispose();
}
}
@ -154,6 +183,13 @@ PasswordKeys derivePasswordKeys(
subkeyId: _keyEncryptionSubkey,
subkeyLen: _keyBytes,
),
sodium.crypto.kdf.deriveFromKey(
masterKey: masterKey,
context: _passwordContext,
subkeyId: _proofSubkey,
subkeyLen: _keyBytes,
),
sodium,
);
} finally {
masterKey.dispose();