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>
261 lines
9.1 KiB
Dart
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);
|
|
});
|
|
}
|