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:
parent
470a0b633c
commit
3bc0bae6e3
6 changed files with 4495 additions and 0 deletions
198
packages/halkyon_keys/test/account_keys_test.dart
Normal file
198
packages/halkyon_keys/test/account_keys_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
90
packages/halkyon_keys/test/argon2id_test.dart
Normal file
90
packages/halkyon_keys/test/argon2id_test.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
102
packages/halkyon_keys/test/bip39_vectors.dart
Normal file
102
packages/halkyon_keys/test/bip39_vectors.dart
Normal 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',
|
||||
),
|
||||
];
|
||||
197
packages/halkyon_keys/test/data_keys_and_content_test.dart
Normal file
197
packages/halkyon_keys/test/data_keys_and_content_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
138
packages/halkyon_keys/test/password_keys_test.dart
Normal file
138
packages/halkyon_keys/test/password_keys_test.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
132
packages/halkyon_keys/test/recovery_phrase_test.dart
Normal file
132
packages/halkyon_keys/test/recovery_phrase_test.dart
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue