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 main() async { final sodium = await SodiumSumoInit.init(); Matcher refusedFor(KeyRefusal reason) => throwsA( isA().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, to: account.encryptionPublicKey, sealer: account, ); DataKey open( Uint8List sealed, { String keyId = 'agenda-2026', AccountKeys? keys, Uint8List? sealedBy, }) => openDataKey( sodium, sealed, keyId: keyId, keys: keys ?? account, sealedBy: sealedBy ?? account.signingPublicKey, ); test("any device that opens the account opens the store's key", () { final opened = open(sealed); expect(opened.keyId, 'agenda-2026'); expect(opened.key.extractBytes(), dataKey.key.extractBytes()); }); test("another account's keys don't open it: it was sealed, and signed, " 'for this one', () { expect( () => open(sealed, keys: AccountKeys.generate(sodium)), refusedFor(KeyRefusal.forgedSignature), ); }); test('a sealed key changed is refused', () { for (final at in [10, 80, sealed.length - 1]) { final tampered = Uint8List.fromList(sealed)..[at] ^= 1; expect(() => open(tampered), refusedFor(KeyRefusal.forgedSignature)); } }); test("a key the register seals itself doesn't open: it knows the " "account's public key, but can't sign as the account, so new content " 'is never encrypted with a key it holds', () { final registers = DataKey.generate(sodium, 'agenda-2026'); // Sealed to the account as an anonymous box, signed by another key. final planted = sealDataKey( sodium, registers, to: account.encryptionPublicKey, sealer: AccountKeys.generate(sodium), ); expect(() => open(planted), refusedFor(KeyRefusal.forgedSignature)); // Or with the account's signature of another key moved onto it. final moved = Uint8List.fromList([ ...sealed.sublist(0, sodium.crypto.sign.bytes), ...planted.sublist(sodium.crypto.sign.bytes), ]); expect(() => open(moved), refusedFor(KeyRefusal.forgedSignature)); }); test('shared, it opens as sealed by whoever shared it, and only so', () { final ben = AccountKeys.generate(sodium); final shared = sealDataKey( sodium, dataKey, to: ben.encryptionPublicKey, sealer: account, ); expect( open(shared, keys: ben).key.extractBytes(), dataKey.key.extractBytes(), ); expect( () => open(shared, keys: ben, sealedBy: ben.signingPublicKey), refusedFor(KeyRefusal.forgedSignature), ); }); test("a key sealed under one ID doesn't open as another's: a server " "can't swap stores' keys", () { expect( () => open(sealed, keyId: 'studyset-7'), refusedFor(KeyRefusal.wrongDataKey), ); }); test('an ID must be 1 to 255 bytes', () { expect(() => DataKey.generate(sodium, ''), throwsArgumentError); expect( () => DataKey.generate(sodium, 'x' * 256), throwsArgumentError, ); }); test( 'a sealed key too short to hold a box is refused, never thrown on', () { // Signed by the sealer, so only the box's own length can fail it. final signature = sodium.crypto.sign.detached( message: Uint8List(0), secretKey: account.signing.secretKey, ); for (final box in [Uint8List(1), Uint8List(47)]) { expect( () => open(Uint8List.fromList([...signature, ...box])), throwsA(isA()), reason: '${box.length}', ); } }, ); }); 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? 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); }); }); }