import 'dart:typed_data'; import 'package:halkyon_content/halkyon_content.dart'; import 'package:halkyon_content/testing.dart'; import 'package:test/test.dart'; /// A shared space's data keys sealed to each member on a device (Halkyon's /// ADR 0044, D3), held to the public keys each device pinned on first sight /// (D18), with libsodium as its build hook compiles it. void main() { const space = 'space-1'; late UnlockedAccountKeys aliceAccount; late UnlockedAccountKeys bobAccount; late UnlockedAccountKeys malloryAccount; late FakeSharedKeysServer server; late SodiumContentCipher aliceCipher; late SodiumContentCipher bobCipher; late MemoryKeyPinStore alicePins; late KeyPins aliceKeyPins; late MemoryKeyPinStore bobPins; late SharedKeys alice; late SharedKeys bob; PublicKeys keysOf(UnlockedAccountKeys account) => ( encryption: account.encryptionPublicKey, signing: account.signingPublicKey, ); Future cipherOf(UnlockedAccountKeys account) async { final cipher = SodiumContentCipher(); await cipher.unlock(account); addTearDown(cipher.dispose); return cipher; } Matcher sealedTo(Set people, {Set changed = const {}}) => isA() .having((o) => o.sealedTo, 'sealedTo', people) .having((o) => o.changed, 'changed', changed); Matcher openedThese( Set keys, { Set refused = const {}, Set changed = const {}, }) => isA() .having((o) => o.opened, 'opened', keys) .having((o) => o.refused, 'refused', refused) .having((o) => o.changed, 'changed', changed); setUpAll(() async { aliceAccount = await newAccountKeys(); bobAccount = await newAccountKeys(); malloryAccount = await newAccountKeys(); }); setUp(() async { server = FakeSharedKeysServer() ..addMember(space, 'alice', keys: keysOf(aliceAccount)) ..addMember(space, 'bob', keys: keysOf(bobAccount)); aliceCipher = await cipherOf(aliceAccount); bobCipher = await cipherOf(bobAccount); alicePins = MemoryKeyPinStore(); bobPins = MemoryKeyPinStore(); aliceKeyPins = KeyPins(alicePins); alice = SharedKeys( server.as('alice'), aliceKeyPins, aliceCipher, me: 'alice', ); bob = SharedKeys(server.as('bob'), KeyPins(bobPins), bobCipher, me: 'bob'); }); test('a key shared with the members opens on their devices, and the ' 'safety codes on both sides agree', () async { aliceCipher.newKey('k1'); expect(await alice.share(space, 'k1'), sealedTo({'alice', 'bob'})); expect(await bob.openMine(space), openedThese({'k1'})); expect(bobCipher.has('k1'), isTrue); final code = await alice.safetyCodeWith('bob'); expect(code, isNotNull); expect(await bob.safetyCodeWith('alice'), code); }); test('content sealed with a space key opens from any member seen in the ' 'space, the person included, and from no one else (D20)', () async { const context = [1, 2, 3]; final plain = Uint8List.fromList([4, 5, 6]); aliceCipher.newKey('k1'); await alice.share(space, 'k1'); await bob.openMine(space); // Mallory, never a member, holds the key all the same. final mallory = await cipherOf(malloryAccount); expect( mallory.openSharedKey( 'k1', aliceCipher.sealKeyTo('k1', keysOf(malloryAccount)), sealedBy: aliceAccount.signingPublicKey, ), isTrue, ); final bobs = bobCipher.seal(plain, keyId: 'k1', context: context); final alices = aliceCipher.seal(plain, keyId: 'k1', context: context); final mallorys = mallory.seal(plain, keyId: 'k1', context: context); expect(aliceCipher.unseal(bobs, context: context), plain); expect(aliceCipher.unseal(alices, context: context), plain); expect(bobCipher.unseal(alices, context: context), plain); expect(aliceCipher.unseal(mallorys, context: context), isNull); expect(bobCipher.unseal(mallorys, context: context), isNull); }); test('keys swapped after they were pinned are neither sealed to nor ' 'trusted, and named until trusted anew', () async { aliceCipher.newKey('k1'); await alice.share(space, 'k1'); await bob.openMine(space); final mallorys = keysOf(malloryAccount); server.handOutKeys('bob', mallorys); aliceCipher.newKey('k2'); expect( await alice.share(space, 'k2'), sealedTo({'alice'}, changed: {'bob'}), ); server ..handOutKeys('bob', keysOf(bobAccount)) ..handOutKeys('alice', mallorys); expect( await bob.openMine(space), openedThese({}, refused: {'k1'}, changed: {'alice'}), ); // Bob's keys changed for a reason Alice trusts. server ..handOutKeys('alice', keysOf(aliceAccount)) ..handOutKeys('bob', mallorys); await aliceKeyPins.trustAnew('bob', mallorys); expect((await alice.share(space, 'k2')).sealedTo, contains('bob')); }); test('keys swapped before they were first seen open nothing, and the ' 'safety codes differ', () async { final mallory = await cipherOf(malloryAccount); server.handOutKeys('alice', keysOf(malloryAccount)); aliceCipher.newKey('k1'); // Alice's own device holds to its own keys, whatever the instance says. await expectLater(alice.share(space, 'k1'), throwsA(isA())); // Sealed by the true Alice, checked by Bob against Mallory's keys. server.addMember(space, 'alice', keys: keysOf(aliceAccount)); await alice.share(space, 'k1'); server.handOutKeys('alice', keysOf(malloryAccount)); expect(await bob.openMine(space), openedThese({}, refused: {'k1'})); expect(bobCipher.has('k1'), isFalse); expect( await bob.safetyCodeWith('alice'), isNot(await alice.safetyCodeWith('bob')), ); expect( await bob.safetyCodeWith('alice'), mallory.safetyCodeWith(keysOf(bobAccount)), ); }); test('a newcomer gets the keys a writer holds, and a Viewer seals ' 'nothing', () async { final carolAccount = await newAccountKeys(); final carolCipher = await cipherOf(carolAccount); final carol = SharedKeys( server.as('carol'), KeyPins(MemoryKeyPinStore()), carolCipher, me: 'carol', ); aliceCipher.newKey('k1'); await alice.share(space, 'k1'); server.addMember(space, 'carol', keys: keysOf(carolAccount), writes: false); expect((await carol.sealForNewcomers(space)).sealedTo, isEmpty); expect((await carol.openMine(space)).opened, isEmpty); await bob.openMine(space); expect((await bob.sealForNewcomers(space)).sealedTo, {'carol'}); expect((await carol.openMine(space)).opened, {'k1'}); expect((await alice.sealForNewcomers(space)).sealedTo, isEmpty); }); test('a member going makes the space due a new key, for writers alone, ' 'which the first key it never had ends (D22, D33, D34)', () async { aliceCipher.newKey('k1'); await alice.share(space, 'k1'); server.addMember(space, 'carol', writes: false); expect(await alice.rollDue(space), isFalse); server.removeMember(space, 'bob'); expect(await alice.rollDue(space), isTrue); expect( await SharedKeys( server.as('carol'), KeyPins(MemoryKeyPinStore()), FakeContentCipher(), me: 'carol', ).rollDue(space), isFalse, ); await alice.share(space, 'k1'); expect(await alice.rollDue(space), isTrue, reason: 'not a new key'); aliceCipher.newKey('k2'); await alice.share(space, 'k2'); expect(await alice.rollDue(space), isFalse); }); test("a key of a member's who left opens with the keys pinned for " 'them', () async { aliceCipher.newKey('k1'); await alice.share(space, 'k1'); await bob.openMine(space); final other = await cipherOf(bobAccount); final again = SharedKeys( server.as('bob'), KeyPins(bobPins), other, me: 'bob', ); server.removeMember(space, 'alice'); expect((await again.openMine(space)).opened, {'k1'}); }); test('a key sealed by someone never pinned, and no longer a member, ' "doesn't open", () async { aliceCipher.newKey('k1'); await alice.share(space, 'k1'); server.removeMember(space, 'alice'); expect(await bob.openMine(space), openedThese({}, refused: {'k1'})); }); test("a member's safety code is the same on both sides, and is marked " 'compared once the person says so (D32)', () async { final seen = (await alice.trustIn(space)).single; expect(seen.person, 'bob'); expect(seen.changed, isFalse); expect(seen.compared, isFalse); expect(seen.code, (await bob.trustIn(space)).single.code); await alice.markCompared('bob'); expect((await alice.trustIn(space)).single.compared, isTrue); }); test('keys that changed are flagged with the code of the new ones, and ' 'only the keys whose code was compared are trusted anew', () async { await alice.trustIn(space); await alice.markCompared('bob'); final mallorys = keysOf(malloryAccount); final eves = keysOf(await newAccountKeys()); server.handOutKeys('bob', mallorys); final changed = (await alice.trustIn(space)).single; expect(changed.changed, isTrue); expect(changed.compared, isFalse); expect(changed.code, aliceCipher.safetyCodeWith(mallorys)); // The instance hands out other keys again before the person trusts the // ones they compared: those compared are pinned, and these differ. server.handOutKeys('bob', eves); await alice.trustNewKeys('bob', changed.keys); final after = (await alice.trustIn(space)).single; expect(after.changed, isTrue); expect(after.code, aliceCipher.safetyCodeWith(eves)); server.handOutKeys('bob', mallorys); final trusted = (await alice.trustIn(space)).single; expect(trusted.changed, isFalse); expect(trusted.compared, isTrue); }); }