ADR 0044's D49 (amended in building it): what reads and seals Learn's content moves out of the Flutter packages, so Learn's agent, which runs without Flutter, shares it rather than repeating the privacy rules. packages/halkyon_content (new, pure Dart) takes from halkyon_app the envelope and its cipher, SealedFile, UnlockedAccountKeys, shared spaces' gateway with SharedKeys and KeyPins, FileCache and FileServerGateway, with their fakes and tests; halkyon_app exports it, so no app changes. Its rich_text.dart takes RichContent from halkyon_editor, which exports it. newAccountKeys makes an agent's key pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a synced gateway asks of the sync, which SyncService implements. apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy, sync, payloads and the models, codecs, gateways and repositories of studysets, cards, notes, tags and feedback, with what they reach of files, learning paths, citations and study options; the app imports them from there. A subject's icon, colour and name stay in the app (SubjectLook); what only the app shows (progress, exam coverage, the card browser, deletions' waits) stays too. Nothing changes for people using the apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
286 lines
9.8 KiB
Dart
286 lines
9.8 KiB
Dart
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<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 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<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 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);
|
|
});
|
|
}
|