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:
parent
b4c93e03de
commit
e8953d1560
4 changed files with 114 additions and 10 deletions
|
|
@ -11,7 +11,7 @@ builds or tests a package depending on this one needs a C compiler and `make`.
|
|||
|
||||
| Piece | API | Parameters |
|
||||
|---|---|---|
|
||||
| The password's keys | `newPasswordSalt`, `derivePasswordKeys` → `PasswordKeys` (`srpSecret`, `srpPrivateValue`, `keyEncryptionKey`) | Argon2id v1.3, 64 MiB, 3 passes (`PasswordKdf.current`), a 16-byte salt, a 32-byte master key; libsodium's KDF (context `hlkpass1`) splits it into SRP's secret (subkey 1, whose 256 bits big-endian are SRP's x) and the key-encryption key (subkey 2) |
|
||||
| The password's keys | `newPasswordSalt`, `derivePasswordKeys` → `PasswordKeys` (`srpSecret`, `srpPrivateValue`, `keyEncryptionKey`, `proofKeyPair`, `proofPublicKey`) | Argon2id v1.3, 64 MiB, 3 passes (`PasswordKdf.current`), a 16-byte salt, a 32-byte master key; libsodium's KDF (context `hlkpass1`) splits it into SRP's secret (subkey 1, whose 256 bits big-endian are SRP's x), the key-encryption key (subkey 2) and the seed of the password's Ed25519 proof key (subkey 3, Halkyon Learn's ADR 0020, D19), with which an account that keeps no SRP verifier proves the password |
|
||||
| The password as typed | `normalizePassword` (`PasswordRefused`) | RFC 8265's OpaqueString: other spaces to U+0020, Unicode NFC (`unorm_dart`, pinned exactly), empty or control characters refused; `derivePasswordKeys` applies it, so composed and decomposed accents unlock alike |
|
||||
| The costs kept with an account | `PasswordKdf` (`toJson`, `fromJson`) | kept beside the salt, so they can be raised for new passwords; `fromJson` refuses another algorithm or costs below `PasswordKdf.minimum`, kept apart from the current costs so raising those never locks out an older account |
|
||||
| The account's key pairs | `AccountKeys.generate` | X25519 to encrypt to, Ed25519 to sign with |
|
||||
|
|
@ -23,7 +23,7 @@ builds or tests a package depending on this one needs a C compiler and `make`.
|
|||
| Refusals | `KeyRefused` with a `KeyRefusal` | `wrongKeyOrTampered`, `notTheAccountsKeys`, `wrongDataKey`, `forgedSignature`, `malformed` (bytes from elsewhere of another length, checked before libsodium sees them), `weakPasswordKdf`, `excessivePasswordKdf`, `phraseLength`, `phraseWord`, `phraseChecksum` |
|
||||
|
||||
Secrets stay in libsodium's guarded memory (`SecureKey`) where its API takes one:
|
||||
the master key, both password subkeys, the private halves of the key pairs, the
|
||||
the master key, the three password subkeys, the private halves of the key pairs, the
|
||||
recovery key, the phrase's proof key and data keys. `dispose` wipes and frees a
|
||||
holder's keys, which may not be read afterwards.
|
||||
|
||||
|
|
@ -45,9 +45,10 @@ What leaves guarded memory, exactly:
|
|||
|
||||
## What never leaves the device
|
||||
|
||||
The password, the master key, SRP's secret and x, the key-encryption key, the recovery
|
||||
phrase and its key, the private halves of the key pairs, data keys and content in the
|
||||
clear. What the register keeps is the salt and the costs, both public keys, the private
|
||||
The password, the master key, SRP's secret and x, the key-encryption key, the private
|
||||
half of the password's proof key, the recovery phrase and its key, the private halves of the key pairs, data keys and content in the
|
||||
clear. What the register keeps is the salt and the costs, both public keys, the public half of
|
||||
the password's proof key (for an account without SRP's verifier), the private
|
||||
keys wrapped twice (under the key-encryption key and under the recovery key), data keys
|
||||
sealed to the account, and sealed content: none of it opens without the password or the
|
||||
phrase.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue