feat(base,app,keys): keys of shared spaces, sealed to each member
Step 3 of ADR 0044 (D3, D18, D19). A shared space's data keys are sealed on a device to each member's public keys; the instance keeps what devices sealed and never opens it. - halkyon_keys: the safety code of two accounts' public keys (`safetyCode`), 60 digits, the same on both sides. - halkyon_base: the register hands out accounts' public keys (`public-keys-of`, local and remote); the table `shared_space_key` and the endpoint `sharedSpaceKeys` (memberKeys, keysNeeded, seal, mine). Only the Owner and Editors seal, the first key sealed to a member stays, and a member's keys go with their membership. Every host's migration creates the table. - halkyon_app: `ContentCipher` seals a key to another account and opens one sealed by another; `SharedKeys` shares a space's key with its members, seals keys to newcomers and opens the person's own, holding each member to the public keys `KeyPins` pinned on first sight (`key_pins.json` in the person's folder), a change refused until trusted anew. Learn takes them into its `Privacy` in step 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d08d8ad545
commit
b72cea1fe0
1 changed files with 105813 additions and 5 deletions
191
packages/halkyon_app/test/shared_space/shared_keys_test.dart
Normal file
191
packages/halkyon_app/test/shared_space/shared_keys_test.dart
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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('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'}));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue