import 'dart:typed_data'; import 'package:halkyon_keys/halkyon_keys.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 main() async { final sodium = await SodiumSumoInit.init(); Matcher refusedFor(KeyRefusal reason) => throwsA( isA().having((refused) => refused.reason, 'reason', reason), ); 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))); }); group("the phrase's proof key", () { test('is an Ed25519 pair of the phrase alone: the same from the same ' 'phrase, another from another, signing what verifies under its ' 'public half', () { final phrase = newRecoveryPhrase(sodium); final pair = recoveryProofKeysOf(sodium, phrase); expect(pair.publicKey, hasLength(sodium.crypto.sign.publicKeyBytes)); expect( recoveryProofKeysOf(sodium, phrase.toUpperCase()).publicKey, pair.publicKey, ); expect(recoveryProofPublicKeyOf(sodium, phrase), pair.publicKey); expect( recoveryProofKeysOf(sodium, newRecoveryPhrase(sodium)).publicKey, isNot(pair.publicKey), ); final message = Uint8List.fromList([1, 2, 3]); final signature = sodium.crypto.sign.detached( message: message, secretKey: pair.secretKey, ); expect( sodium.crypto.sign.verifyDetached( message: message, signature: signature, publicKey: pair.publicKey, ), isTrue, ); }); test("is neither the recovery key nor the account's keys: a device " "holding the account's keys can't make it", () { final phrase = newRecoveryPhrase(sodium); final proof = recoveryProofKeysOf(sodium, phrase); final seed = sodium.crypto.sign.skToSeed(proof.secretKey).extractBytes(); expect(seed, isNot(recoveryKeyOf(sodium, phrase).extractBytes())); expect(seed, isNot(entropyOfRecoveryPhrase(phrase))); final account = AccountKeys.generate(sodium); expect(proof.publicKey, isNot(account.signingPublicKey)); }); test('is refused for what is no recovery phrase', () { expect( () => recoveryProofKeysOf(sodium, bip39Vectors.first.$2), refusedFor(KeyRefusal.phraseLength), ); expect( () => recoveryProofKeysOf(sodium, '${'abandon ' * 23}zoo'), refusedFor(KeyRefusal.phraseChecksum), ); }); }); 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); } }); }