halkyon/packages/halkyon_app/test/shared_space/shared_keys_test.dart
Tizian 「ティツィアーン」 02bb24428c
feat(app,learn): shared spaces' members on the device
Step 4 (e) of ADR 0044, its first part, for every app's screens.

- halkyon_app: `SharedSpaceGateway` on the module's `sharedSpace`
  endpoint (the spaces, their members and roles, inviting, handing on,
  leaving), refusals as `SpaceRefused`; `SharedKeys.trustIn` gives
  each member's safety code and whether it was compared or their keys
  changed, `markCompared`, and `trustNewKeys`, which pins the keys
  whose code was compared and never those the instance hands next.
  `FakeSharedSpaceServer` keeps the module's rules for tests, in step
  with `FakeSharedKeysServer`, which now knows members' handles.
- Learn's sharing makes its space through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 15:53:09 +01:00

261 lines
9.1 KiB
Dart

import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.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';
final crypto = SodiumAccountCrypto();
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<SodiumContentCipher> cipherOf(UnlockedAccountKeys account) async {
final cipher = SodiumContentCipher();
await cipher.unlock(account);
addTearDown(cipher.dispose);
return cipher;
}
Matcher sealedTo(Set<String> people, {Set<String> changed = const {}}) =>
isA<SealOutcome>()
.having((o) => o.sealedTo, 'sealedTo', people)
.having((o) => o.changed, 'changed', changed);
Matcher openedThese(
Set<String> keys, {
Set<String> refused = const {},
Set<String> changed = const {},
}) => isA<OpenOutcome>()
.having((o) => o.opened, 'opened', keys)
.having((o) => o.refused, 'refused', refused)
.having((o) => o.changed, 'changed', changed);
setUpAll(() async {
aliceAccount = (await crypto.newAccount('alice password')).keys;
bobAccount = (await crypto.newAccount('bob password')).keys;
malloryAccount = (await crypto.newAccount('mallory password')).keys;
});
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<OwnKeysDiffer>()));
// 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 crypto.newAccount('carol password')).keys;
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 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 crypto.newAccount('eve password')).keys);
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);
});
}