feat(keys): halkyon_keys, an account's keys on the device

ADR 0017's step 1, on libsodium (sodium 4.1.0+1, whose build hook
compiles the libsodium 1.0.22 it ships: a C compiler and make wherever
it builds):
- Argon2id (64 MiB, 3 passes, kept with the account and never below
  that) makes a master key, which libsodium's KDF splits into SRP's
  secret (x) and the key-encryption key.
- The account's X25519 and Ed25519 key pairs, their private halves
  wrapped with XChaCha20-Poly1305 under the KEK and under the recovery
  key, bound to their public keys.
- A 24-word BIP-39 recovery phrase (the English list, MIT) and the
  recovery key derived from it.
- Data keys sealed to the account with crypto_box_seal, bound to their
  IDs; content in ADR 0002's envelope, encrypted with a data key and
  signed with Ed25519 (detached), bound to a context the caller names.
- Secrets in libsodium's guarded memory where its API takes them.
- Tests: libsodium's Argon2id known answers, BIP-39's English vectors,
  round trips and every refusal.

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 12:01:52 +02:00
commit 3bc0bae6e3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 4495 additions and 0 deletions

View file

@ -0,0 +1,198 @@
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
Future<void> main() async {
final sodium = await SodiumSumoInit.init();
const cheap = PasswordKdf(opsLimit: 1, memLimit: 8192);
final salt = Uint8List.fromList(List.generate(16, (i) => i));
Matcher refusedFor(KeyRefusal reason) => throwsA(
isA<KeyRefused>().having((refused) => refused.reason, 'reason', reason),
);
test('key pairs: X25519 to encrypt to, Ed25519 to sign with', () {
final keys = AccountKeys.generate(sodium);
expect(keys.encryptionPublicKey, hasLength(32));
expect(keys.signingPublicKey, hasLength(32));
expect(
AccountKeys.generate(sodium).encryptionPublicKey,
isNot(keys.encryptionPublicKey),
);
});
group('wrapped under the password', () {
final keys = AccountKeys.generate(sodium);
final password = derivePasswordKeys(
sodium,
password: 'a password',
salt: salt,
kdf: cheap,
);
final wrapped = wrapAccountKeys(sodium, keys, password.keyEncryptionKey);
AccountKeys unwrap(
WrappedAccountKeys what,
SecureKey key, {
Uint8List? encryptionPublicKey,
Uint8List? signingPublicKey,
}) => unwrapAccountKeys(
sodium,
what,
key,
encryptionPublicKey: encryptionPublicKey ?? keys.encryptionPublicKey,
signingPublicKey: signingPublicKey ?? keys.signingPublicKey,
);
test('the same password opens them on another device, the same keys', () {
final again = derivePasswordKeys(
sodium,
password: 'a password',
salt: salt,
kdf: cheap,
);
final opened = unwrap(
WrappedAccountKeys.fromJson(wrapped.toJson()),
again.keyEncryptionKey,
);
expect(
opened.encryption.secretKey.extractBytes(),
keys.encryption.secretKey.extractBytes(),
);
expect(
opened.signing.secretKey.extractBytes(),
keys.signing.secretKey.extractBytes(),
);
// They work: a signature made with the opened key checks.
final message = Uint8List.fromList([1, 2, 3]);
expect(
sodium.crypto.sign.verifyDetached(
message: message,
signature: sodium.crypto.sign.detached(
message: message,
secretKey: opened.signing.secretKey,
),
publicKey: keys.signingPublicKey,
),
isTrue,
);
});
test('nothing secret is in what the register keeps', () {
final json = wrapped.toJson();
expect(json.keys, unorderedEquals(['nonce', 'cipherText']));
expect(wrapped.nonce, hasLength(24));
// 32 + 64 bytes of keys and Poly1305's 16-byte tag.
expect(wrapped.cipherText, hasLength(112));
});
test('a wrong password is refused', () {
final wrong = derivePasswordKeys(
sodium,
password: 'a passwort',
salt: salt,
kdf: cheap,
);
expect(
() => unwrap(wrapped, wrong.keyEncryptionKey),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
});
test('a ciphertext or nonce changed is refused', () {
final cipherText = Uint8List.fromList(wrapped.cipherText)..[5] ^= 1;
final nonce = Uint8List.fromList(wrapped.nonce)..[0] ^= 1;
for (final tampered in [
WrappedAccountKeys(nonce: wrapped.nonce, cipherText: cipherText),
WrappedAccountKeys(nonce: nonce, cipherText: wrapped.cipherText),
WrappedAccountKeys(
nonce: wrapped.nonce,
cipherText: wrapped.cipherText.sublist(1),
),
]) {
expect(
() => unwrap(tampered, password.keyEncryptionKey),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
}
});
test("another account's public keys don't open them: they are bound to "
'their own', () {
final other = AccountKeys.generate(sodium);
expect(
() => unwrap(
wrapped,
password.keyEncryptionKey,
encryptionPublicKey: other.encryptionPublicKey,
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
expect(
() => unwrap(
wrapped,
password.keyEncryptionKey,
signingPublicKey: other.signingPublicKey,
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
expect(
() => unwrap(
wrapped,
password.keyEncryptionKey,
signingPublicKey: Uint8List(31),
),
refusedFor(KeyRefusal.notTheAccountsKeys),
);
});
});
test('wrapped again under the recovery key, the phrase opens them', () {
final keys = AccountKeys.generate(sodium);
final phrase = newRecoveryPhrase(sodium);
final wrapped = wrapAccountKeys(
sodium,
keys,
recoveryKeyOf(sodium, phrase),
);
final opened = unwrapAccountKeys(
sodium,
wrapped,
recoveryKeyOf(sodium, phrase),
encryptionPublicKey: keys.encryptionPublicKey,
signingPublicKey: keys.signingPublicKey,
);
expect(
opened.encryption.secretKey.extractBytes(),
keys.encryption.secretKey.extractBytes(),
);
expect(
() => unwrapAccountKeys(
sodium,
wrapped,
recoveryKeyOf(sodium, newRecoveryPhrase(sodium)),
encryptionPublicKey: keys.encryptionPublicKey,
signingPublicKey: keys.signingPublicKey,
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
});
test('every wrapping takes a new nonce', () {
final keys = AccountKeys.generate(sodium);
final key = sodium.crypto.aeadXChaCha20Poly1305IETF.keygen();
final first = wrapAccountKeys(sodium, keys, key);
final second = wrapAccountKeys(sodium, keys, key);
expect(first.nonce, isNot(second.nonce));
expect(first.cipherText, isNot(second.cipherText));
});
test("what isn't wrapped account keys can't be read as such", () {
expect(
() => WrappedAccountKeys.fromJson({'nonce': 'AAAA'}),
throwsFormatException,
);
});
}

View file

@ -0,0 +1,90 @@
import 'dart:typed_data';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
/// [hex] as bytes.
Uint8List _bytes(String hex) => Uint8List.fromList([
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
]);
/// [bytes] in lower-case hex.
String _hex(List<int> bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
Future<void> main() async {
final sodium = await SodiumSumoInit.init();
// libsodium's own known answers for crypto_pwhash with Argon2id
// (libsodium 1.0.18, test/default/pwhash_argon2id.c's tv(), vectors 0
// and 6, and their lines 1 and 7 of pwhash_argon2id.exp): the password
// and salt as the test cuts them (the password to its length, the salt
// to 16 bytes), the output length, the passes and the memory.
const vectors =
<
({
String password,
int length,
String salt,
int outLen,
int opsLimit,
int memLimit,
String expected,
})
>[
(
password:
'a347ae92bce9f80f6f595a4480fc9c2fe7e7d7148d371e9487d75f5c23008ffa'
'e065577a928febd9b1973a5a95073acdbeb6a030cfc0d79caa2dc5cd011cef02'
'c08da232d76d52dfbca38ca8dcbd665b17d1665f7cf5fe59772ec909733b24de'
'97d6f58d220b20c60d7c07ec1fd93c52c31020300c6c1facd77937a597c7a6',
length: 127,
salt: '5541fbc995d5c197ba290346d2c559de',
outLen: 155,
opsLimit: 5,
memLimit: 7256678,
expected:
'18acec5d6507739f203d1f5d9f1d862f7c2cdac4f19d2bdff64487e60d969e3c'
'ed615337b9eec6ac4461c6ca07f0939741e57c24d0005c7ea171a0ee1e734824'
'9d135b38f222e4dad7b9a033ed83f5ca27277393e316582033c74affe2566a2b'
'ea47f91f0fd9fe49ece7e1f79f3ad6e9b23e0277c8ecc4b313225748dd2a80f5'
'679534a0700e246a79a49b3f74eb89ec6205fe1eeb941c73b1fcf1',
),
(
password:
'b540beb016a5366524d4605156493f9874514a5aa58818cd0c6dfffaa9e90205'
'f17b',
length: 34,
salt: '44071f6d181561670bda728d43fb79b4',
outLen: 231,
opsLimit: 1,
memLimit: 1631659,
expected:
'7fb72409b0987f8190c3729710e98c3f80c5a8727d425fdcde7f3644d467fe97'
'3f5b5fee683bd3fce812cb9ae5e9921a2d06c2f1905e4e839692f2b934b682f1'
'1a2fe2b90482ea5dd234863516dba6f52dc0702d324ec77d860c2e181f84472b'
'd7104fedce071ffa93c5309494ad51623d214447a7b2b1462dc7d5d55a1f6fd5'
'b54ce024118d86f0c6489d16545aaa87b6689dad9f2fb47fda9894f8e12b87d9'
'78b483ccd4cc5fd9595cdc7a818452f915ce2f7df95ec12b1c72e3788d473441'
'd884f9748eb14703c21b45d82fd667b85f5b2d98c13303b3fe76285531a826b6'
'fc0fe8e3dddecf',
),
];
for (final (index, vector) in vectors.indexed) {
test("Argon2id gives libsodium's known answer $index", () {
final key = sodium.crypto.pwhash.callRaw(
outLen: vector.outLen,
password: Int8List.fromList(_bytes(vector.password)),
salt: _bytes(vector.salt),
opsLimit: vector.opsLimit,
memLimit: vector.memLimit,
alg: CryptoPwhashAlgorithm.argon2id13,
);
expect(_bytes(vector.password), hasLength(vector.length));
expect(_hex(key.extractBytes()), vector.expected);
key.dispose();
});
}
}

View file

@ -0,0 +1,102 @@
// BIP-39's English test vectors (trezor/python-mnemonic, vectors.json, MIT
// License): the entropy and the mnemonic it makes.
/// (entropy in hex, mnemonic).
const bip39Vectors = <(String, String)>[
(
'00000000000000000000000000000000',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
(
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f',
'legal winner thank year wave sausage worth useful legal winner thank yellow',
),
(
'80808080808080808080808080808080',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage above',
),
(
'ffffffffffffffffffffffffffffffff',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong',
),
(
'000000000000000000000000000000000000000000000000',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent',
),
(
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f',
'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will',
),
(
'808080808080808080808080808080808080808080808080',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always',
),
(
'ffffffffffffffffffffffffffffffffffffffffffffffff',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when',
),
(
'0000000000000000000000000000000000000000000000000000000000000000',
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art',
),
(
'7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f',
'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title',
),
(
'8080808080808080808080808080808080808080808080808080808080808080',
'letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless',
),
(
'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote',
),
(
'9e885d952ad362caeb4efe34a8e91bd2',
'ozone drill grab fiber curtain grace pudding thank cruise elder eight picnic',
),
(
'6610b25967cdcca9d59875f5cb50b0ea75433311869e930b',
'gravity machine north sort system female filter attitude volume fold club stay feature office ecology stable narrow fog',
),
(
'68a79eaca2324873eacc50cb9c6eca8cc68ea5d936f98787c60c7ebc74e6ce7c',
'hamster diagram private dutch cause delay private meat slide toddler razor book happy fancy gospel tennis maple dilemma loan word shrug inflict delay length',
),
(
'c0ba5a8e914111210f2bd131f3d5e08d',
'scheme spot photo card baby mountain device kick cradle pact join borrow',
),
(
'6d9be1ee6ebd27a258115aad99b7317b9c8d28b6d76431c3',
'horn tenant knee talent sponsor spell gate clip pulse soap slush warm silver nephew swap uncle crack brave',
),
(
'9f6a2878b2520799a44ef18bc7df394e7061a224d2c33cd015b157d746869863',
'panda eyebrow bullet gorilla call smoke muffin taste mesh discover soft ostrich alcohol speed nation flash devote level hobby quick inner drive ghost inside',
),
(
'23db8160a31d3e0dca3688ed941adbf3',
'cat swing flag economy stadium alone churn speed unique patch report train',
),
(
'8197a4a47f0425faeaa69deebc05ca29c0a5b5cc76ceacc0',
'light rule cinnamon wrap drastic word pride squirrel upgrade then income fatal apart sustain crack supply proud access',
),
(
'066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad',
'all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform',
),
(
'f30f8c1da665478f49b001d94c5fc452',
'vessel ladder alter error federal sibling chat ability sun glass valve picture',
),
(
'c10ec20dc3cd9f652c7fac2f1230f7a3c828389a14392f05',
'scissors invite lock maple supreme raw rapid void congress muscle digital elegant little brisk hair mango congress clump',
),
(
'f585c11aec520db57dd353c69554b21a89b20fb0650966fa0a9d6f74fd989d8f',
'void come effort suffer camp survey warrior heavy shoot primary clutch crush open amazing screen patrol group space point ten exist slush involve unfold',
),
];

View file

@ -0,0 +1,197 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
Future<void> main() async {
final sodium = await SodiumSumoInit.init();
Matcher refusedFor(KeyRefusal reason) => throwsA(
isA<KeyRefused>().having((refused) => refused.reason, 'reason', reason),
);
group('data keys, sealed to the account', () {
final account = AccountKeys.generate(sodium);
final dataKey = DataKey.generate(sodium, 'agenda-2026');
final sealed = sealDataKey(sodium, dataKey, account.encryptionPublicKey);
test("any device that opens the account opens the store's key", () {
final opened = openDataKey(
sodium,
sealed,
keyId: 'agenda-2026',
keys: account,
);
expect(opened.keyId, 'agenda-2026');
expect(opened.key.extractBytes(), dataKey.key.extractBytes());
});
test("another account's keys don't open it", () {
expect(
() => openDataKey(
sodium,
sealed,
keyId: 'agenda-2026',
keys: AccountKeys.generate(sodium),
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
});
test('a sealed key changed is refused', () {
final tampered = Uint8List.fromList(sealed)..[40] ^= 1;
expect(
() => openDataKey(
sodium,
tampered,
keyId: 'agenda-2026',
keys: account,
),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
});
test("a key sealed under one ID doesn't open as another's: a server "
"can't swap stores' keys", () {
expect(
() => openDataKey(
sodium,
sealed,
keyId: 'studyset-7',
keys: account,
),
refusedFor(KeyRefusal.wrongDataKey),
);
});
test('an ID must be 1 to 255 bytes', () {
expect(() => DataKey.generate(sodium, ''), throwsArgumentError);
expect(
() => DataKey.generate(sodium, 'x' * 256),
throwsArgumentError,
);
});
});
group('content, sealed and signed', () {
final author = AccountKeys.generate(sodium);
final dataKey = DataKey.generate(sodium, 'agenda-2026');
final context = utf8.encode('agenda_item/0192f3a4');
final plainText = Uint8List.fromList(
utf8.encode('{"title": "Study N3: 34 cards"}'),
);
final sealed = sealContent(
sodium,
plainText: plainText,
dataKey: dataKey,
author: author,
context: context,
);
Uint8List open(
SealedContent what, {
DataKey? withKey,
Uint8List? signedBy,
List<int>? at,
}) => openContent(
sodium,
what,
dataKey: withKey ?? dataKey,
authorSigningPublicKey: signedBy ?? author.signingPublicKey,
context: at ?? context,
);
test("opens with the data key, its signature the author's", () {
expect(open(sealed), plainText);
expect(sealed.keyId, 'agenda-2026');
expect(sealed.nonce, hasLength(24));
expect(sealed.signature, hasLength(64));
// The payload is no plain text.
expect(
utf8.decode(sealed.payload, allowMalformed: true),
isNot(contains('Study')),
);
});
test('every sealing takes a new nonce', () {
final again = sealContent(
sodium,
plainText: plainText,
dataKey: dataKey,
author: author,
context: context,
);
expect(again.nonce, isNot(sealed.nonce));
expect(again.payload, isNot(sealed.payload));
});
test('another data key is refused, even under the same ID', () {
expect(
() => open(sealed, withKey: DataKey.generate(sodium, 'studyset-7')),
refusedFor(KeyRefusal.wrongDataKey),
);
expect(
() => open(sealed, withKey: DataKey.generate(sodium, 'agenda-2026')),
refusedFor(KeyRefusal.wrongKeyOrTampered),
);
});
test('a payload, nonce, ID or signature changed is refused as forged', () {
SealedContent changed({
String? keyId,
Uint8List? nonce,
Uint8List? payload,
Uint8List? signature,
}) => SealedContent(
keyId: keyId ?? sealed.keyId,
nonce: nonce ?? sealed.nonce,
payload: payload ?? sealed.payload,
signature: signature ?? sealed.signature,
);
for (final tampered in [
changed(payload: Uint8List.fromList(sealed.payload)..[0] ^= 1),
changed(nonce: Uint8List.fromList(sealed.nonce)..[23] ^= 1),
changed(signature: Uint8List.fromList(sealed.signature)..[10] ^= 1),
changed(signature: sealed.signature.sublist(1)),
]) {
expect(() => open(tampered), refusedFor(KeyRefusal.forgedSignature));
}
// Another ID than the key's is caught before anything else.
expect(
() => open(changed(keyId: 'agenda-2027')),
refusedFor(KeyRefusal.wrongDataKey),
);
});
test("someone else's signature, or content moved to another row, is "
'refused', () {
expect(
() => open(
sealed,
signedBy: AccountKeys.generate(sodium).signingPublicKey,
),
refusedFor(KeyRefusal.forgedSignature),
);
expect(
() => open(sealed, at: utf8.encode('agenda_item/0192f3a5')),
refusedFor(KeyRefusal.forgedSignature),
);
});
test('content resealed by someone holding the data key but not the '
"author's signing key doesn't pass as the author's", () {
final forger = AccountKeys.generate(sodium);
final forged = sealContent(
sodium,
plainText: Uint8List.fromList(utf8.encode('{"title": "forged"}')),
dataKey: dataKey,
author: forger,
context: context,
);
expect(() => open(forged), refusedFor(KeyRefusal.forgedSignature));
expect(open(forged, signedBy: forger.signingPublicKey), isNotEmpty);
});
});
}

View file

@ -0,0 +1,138 @@
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
Future<void> main() async {
final sodium = await SodiumSumoInit.init();
// Cheap costs for most tests; the production costs are checked once below.
const cheap = PasswordKdf(opsLimit: 1, memLimit: 8192);
final salt = Uint8List.fromList(List.generate(16, (i) => i));
PasswordKeys keysOf(String password, {Uint8List? withSalt}) =>
derivePasswordKeys(
sodium,
password: password,
salt: withSalt ?? salt,
kdf: cheap,
);
test('the same password and salt make the same keys, on any device', () {
final first = keysOf('correct horse battery staple');
final second = keysOf('correct horse battery staple');
expect(first.srpPrivateValue, second.srpPrivateValue);
expect(
first.keyEncryptionKey.extractBytes(),
second.keyEncryptionKey.extractBytes(),
);
});
test('another password, or another salt, makes other keys', () {
final keys = keysOf('correct horse battery staple');
final otherPassword = keysOf('correct horse battery stapler');
final otherSalt = keysOf(
'correct horse battery staple',
withSalt: Uint8List.fromList(List.generate(16, (i) => i + 1)),
);
for (final other in [otherPassword, otherSalt]) {
expect(other.srpPrivateValue, isNot(keys.srpPrivateValue));
expect(
other.keyEncryptionKey.extractBytes(),
isNot(keys.keyEncryptionKey.extractBytes()),
);
}
});
test("SRP's secret and the key-encryption key are unrelated halves", () {
final keys = keysOf('a password');
final srp = keys.srpSecret.extractBytes();
expect(srp, hasLength(32));
expect(keys.keyEncryptionKey.extractBytes(), hasLength(32));
expect(keys.keyEncryptionKey.extractBytes(), isNot(srp));
expect(keys.srpPrivateValue.bitLength, lessThanOrEqualTo(256));
expect(keys.srpPrivateValue, greaterThan(BigInt.zero));
});
test('a salt of another length is refused', () {
expect(
() => derivePasswordKeys(
sodium,
password: 'a password',
salt: Uint8List(15),
kdf: cheap,
),
throwsArgumentError,
);
});
test('new salts are 16 random bytes', () {
final first = newPasswordSalt(sodium);
expect(first, hasLength(16));
expect(newPasswordSalt(sodium), isNot(first));
});
test('new passwords cost 64 MiB and 3 passes, and that works', () {
expect(PasswordKdf.current.memLimit, 64 * 1024 * 1024);
expect(PasswordKdf.current.opsLimit, 3);
// The floor is apart from what new passwords take, so raising the
// latter never locks out an account made before.
expect(PasswordKdf.minimum.memLimit, 64 * 1024 * 1024);
expect(PasswordKdf.minimum.opsLimit, 3);
expect(
PasswordKdf.current.memLimit,
greaterThanOrEqualTo(PasswordKdf.minimum.memLimit),
);
expect(
PasswordKdf.current.opsLimit,
greaterThanOrEqualTo(PasswordKdf.minimum.opsLimit),
);
final keys = derivePasswordKeys(
sodium,
password: 'a password',
salt: salt,
);
expect(keys.srpPrivateValue, greaterThan(BigInt.zero));
keys.dispose();
});
group('the costs kept with an account', () {
test('the floor itself is read', () {
final read = PasswordKdf.fromJson(PasswordKdf.minimum.toJson());
expect(read.opsLimit, PasswordKdf.minimum.opsLimit);
expect(read.memLimit, PasswordKdf.minimum.memLimit);
});
test('round-trip', () {
const raised = PasswordKdf(opsLimit: 4, memLimit: 128 * 1024 * 1024);
final json = raised.toJson();
expect(json['algorithm'], 'argon2id13');
final read = PasswordKdf.fromJson(json);
expect(read.opsLimit, 4);
expect(read.memLimit, 128 * 1024 * 1024);
});
test('below the floor, or of another algorithm, are refused: a register '
"can't make guessing cheaper", () {
for (final json in <Map<String, Object?>>[
{'algorithm': 'argon2id13', 'opsLimit': 2, 'memLimit': 64 << 20},
{'algorithm': 'argon2id13', 'opsLimit': 3, 'memLimit': 32 << 20},
{'algorithm': 'argon2i13', 'opsLimit': 3, 'memLimit': 64 << 20},
{'algorithm': 'argon2id13', 'opsLimit': '3', 'memLimit': 64 << 20},
{},
]) {
expect(
() => PasswordKdf.fromJson(json),
throwsA(
isA<KeyRefused>().having(
(refused) => refused.reason,
'reason',
KeyRefusal.weakPasswordKdf,
),
),
reason: '$json',
);
}
});
});
}

View file

@ -0,0 +1,132 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:halkyon_keys/src/bip39_english.dart';
import 'package:sodium/sodium_sumo.dart';
import 'package:test/test.dart';
import 'bip39_vectors.dart';
/// [hex] as bytes.
Uint8List _bytes(String hex) => Uint8List.fromList([
for (var i = 0; i < hex.length; i += 2)
int.parse(hex.substring(i, i + 2), radix: 16),
]);
Future<void> main() async {
final sodium = await SodiumSumoInit.init();
Matcher refusedFor(KeyRefusal reason) => throwsA(
isA<KeyRefused>().having((refused) => refused.reason, 'reason', reason),
);
test("the word list is BIP-39's English list: 2,048 words, sorted, each "
'once', () {
expect(bip39English, hasLength(2048));
expect(bip39English.toSet(), hasLength(2048));
expect([...bip39English]..sort(), bip39English);
// The SHA-256 of english.txt, a word a line.
expect(
sha256.convert('${bip39English.join('\n')}\n'.codeUnits).toString(),
'2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda',
);
});
group("BIP-39's English test vectors (trezor/python-mnemonic)", () {
for (final (entropy, mnemonic) in bip39Vectors) {
test(mnemonic.split(' ').take(3).join(' '), () {
expect(recoveryPhraseOf(_bytes(entropy)), mnemonic);
expect(entropyOfRecoveryPhrase(mnemonic), _bytes(entropy));
});
}
});
test('a new phrase is 24 words, never the same twice, and stands for '
'256 bits', () {
final phrase = newRecoveryPhrase(sodium);
expect(phrase.split(' '), hasLength(24));
expect(newRecoveryPhrase(sodium), isNot(phrase));
expect(entropyOfRecoveryPhrase(phrase), hasLength(recoveryEntropyBytes));
});
test('case and the spaces between words make no difference', () {
final phrase = newRecoveryPhrase(sodium);
final typed = ' ${phrase.toUpperCase().replaceAll(' ', '\n ')} ';
expect(entropyOfRecoveryPhrase(typed), entropyOfRecoveryPhrase(phrase));
});
group('refused', () {
final phrase = newRecoveryPhrase(sodium);
final words = phrase.split(' ');
test('a phrase of another length', () {
for (final count in [0, 11, 13, 23, 25]) {
final shorter = [
for (var i = 0; i < count; i++) words[i % words.length],
].join(' ');
expect(
() => entropyOfRecoveryPhrase(shorter),
refusedFor(KeyRefusal.phraseLength),
reason: '$count words',
);
}
});
test('a word not on the list', () {
expect(
() => entropyOfRecoveryPhrase([...words.take(23), 'zzzz'].join(' ')),
refusedFor(KeyRefusal.phraseWord),
);
});
test('a checksum that fails: a word changed, or two swapped', () {
// A fixed vector, so the changes are known to fail (a random phrase
// changed passes the 8-bit checksum once in 256).
final fixed = bip39Vectors
.firstWhere((vector) => vector.$2.startsWith('letter advice cage'))
.$2
.split(' ');
final changed = [...fixed]..[0] = 'ability';
final swapped = [...fixed]
..[0] = fixed[1]
..[1] = fixed[0];
for (final wrong in [changed, swapped]) {
expect(
() => entropyOfRecoveryPhrase(wrong.join(' ')),
refusedFor(KeyRefusal.phraseChecksum),
);
}
});
test(
"a 12-word BIP-39 phrase is no recovery phrase: it hasn't 256 bits",
() {
expect(
() => recoveryKeyOf(sodium, bip39Vectors.first.$2),
refusedFor(KeyRefusal.phraseLength),
);
},
);
});
test('the recovery key is the same from the same phrase, another from '
'another', () {
final phrase = newRecoveryPhrase(sodium);
final key = recoveryKeyOf(sodium, phrase).extractBytes();
expect(key, hasLength(32));
expect(recoveryKeyOf(sodium, phrase.toUpperCase()).extractBytes(), key);
expect(
recoveryKeyOf(sodium, newRecoveryPhrase(sodium)).extractBytes(),
isNot(key),
);
// The key isn't the phrase's bits themselves.
expect(key, isNot(entropyOfRecoveryPhrase(phrase)));
});
test('entropy of a length BIP-39 has no phrase for is refused', () {
for (final length in [0, 15, 17, 33]) {
expect(() => recoveryPhraseOf(Uint8List(length)), throwsArgumentError);
}
});
}