feat(app): a shared space's content taken from its members' signatures
ADR 0044, D20: a row in a shared space counts as genuine when any
member this device pinned signed it, current or former, never by the
author column the row names.
- `ContentCipher.trustSigners`: content sealed with a key is checked
against the signing keys given for it, in place of the account's
alone; `FakeContentCipher` tells authors apart likewise.
- `KeyPins` keeps who it saw in each space (`sawIn`) and the signing
keys pinned for someone before a reset (`earlierSigning`), so what
they wrote stays readable; `signersIn` gives a space's signers.
`key_pins.json` holds both (`pins`, `spaces`); a file written by
step 3, on no released app, counts as none.
- `SharedKeys` pins the person's own keys too, records the members it
checks, and trusts the space's signers for each key it shares or
opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 13:22:30 +01:00
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
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>
2026-11-11 11:28:13 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
feat(app): a shared space's content taken from its members' signatures
ADR 0044, D20: a row in a shared space counts as genuine when any
member this device pinned signed it, current or former, never by the
author column the row names.
- `ContentCipher.trustSigners`: content sealed with a key is checked
against the signing keys given for it, in place of the account's
alone; `FakeContentCipher` tells authors apart likewise.
- `KeyPins` keeps who it saw in each space (`sawIn`) and the signing
keys pinned for someone before a reset (`earlierSigning`), so what
they wrote stays readable; `signersIn` gives a space's signers.
`key_pins.json` holds both (`pins`, `spaces`); a file written by
step 3, on no released app, counts as none.
- `SharedKeys` pins the person's own keys too, records the members it
checks, and trusts the space's signers for each key it shares or
opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 13:22:30 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-11-11 11:28:13 +01:00
|
|
|
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'}));
|
|
|
|
|
});
|
|
|
|
|
}
|