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:
Tizian 「ティツィアーン」 2026-11-11 11:28:13 +01:00
commit b72cea1fe0
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 105813 additions and 5 deletions

View file

@ -0,0 +1,64 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// The public keys a device pins for each person (Halkyon's ADR 0044, D18).
void main() {
late Directory folder;
late File file;
PublicKeys keys(int mark) => (
encryption: Uint8List.fromList(List.filled(32, mark)),
signing: Uint8List.fromList(List.filled(32, mark + 1)),
);
setUp(() async {
folder = await Directory.systemTemp.createTemp('key_pins');
file = File('${folder.path}/person/key_pins.json');
});
tearDown(() => folder.delete(recursive: true));
test('the first keys seen stay pinned, on the next start too; others '
'are a change until trusted anew', () async {
final pins = KeyPins(FileKeyPinStore(file));
expect(await pins.check('bob', keys(1)), PinCheck.firstSeen);
expect(await pins.check('bob', keys(1)), PinCheck.same);
expect(await pins.check('bob', keys(3)), PinCheck.changed);
final later = KeyPins(FileKeyPinStore(file));
expect(await later.check('bob', keys(3)), PinCheck.changed);
expect(await later.check('bob', keys(1)), PinCheck.same);
await later.trustAnew('bob', keys(3));
expect(
await KeyPins(FileKeyPinStore(file)).check('bob', keys(3)),
PinCheck.same,
);
});
test('compared keys are marked so, until they are trusted anew', () async {
final pins = KeyPins(FileKeyPinStore(file));
await pins.check('bob', keys(1));
expect((await pins.of('bob'))!.verified, isFalse);
await pins.markVerified('bob');
expect((await KeyPins(FileKeyPinStore(file)).of('bob'))!.verified, isTrue);
await pins.trustAnew('bob', keys(3));
expect((await pins.of('bob'))!.verified, isFalse);
});
test('a file not holding pins counts as none', () async {
await file.parent.create(recursive: true);
await file.writeAsString('{not json');
expect(await FileKeyPinStore(file).read(), isEmpty);
await file.writeAsString(
'{"bob": {"encryption": "%%", "signing": "", '
'"verified": false}, "carol": 3}',
);
expect(await FileKeyPinStore(file).read(), isEmpty);
});
}

View 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'}));
});
}