import 'dart:typed_data'; import 'package:halkyon_keys/halkyon_keys.dart'; import 'package:sodium/sodium_sumo.dart'; import 'package:test/test.dart'; Future 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().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, ); }); }