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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 13:22:30 +01:00
commit 5faf3fce58
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 336 additions and 68 deletions

View file

@ -125,4 +125,63 @@ void main() {
throwsStateError,
);
});
test("a shared key's content opens when a member it trusts signed it, "
"and only for that key (Halkyon's ADR 0044, D20)", () async {
final carol = (await crypto.newAccount('carol password')).keys;
PublicKeys keysOf(UnlockedAccountKeys account) => (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
Future<SodiumContentCipher> deviceOf(UnlockedAccountKeys account) async {
final cipher = SodiumContentCipher();
await cipher.unlock(account);
addTearDown(cipher.dispose);
return cipher;
}
final bobs = await deviceOf(bob);
final carols = await deviceOf(carol);
for (final keyId in ['shared', 'other']) {
device.newKey(keyId);
for (final (cipher, keys) in [(bobs, bob), (carols, carol)]) {
expect(
cipher.openSharedKey(
keyId,
device.sealKeyTo(keyId, keysOf(keys)),
sealedBy: alice.signingPublicKey,
),
isTrue,
);
}
}
final byBob = bobs.seal(plain, keyId: 'shared', context: context);
final byCarol = carols.seal(plain, keyId: 'shared', context: context);
final byAlice = device.seal(plain, keyId: 'shared', context: context);
// Only the account's own, until told whom to trust.
expect(device.unseal(byBob, context: context), isNull);
expect(device.unseal(byAlice, context: context), plain);
device.trustSigners('shared', [
alice.signingPublicKey,
bob.signingPublicKey,
]);
expect(device.unseal(byBob, context: context), plain);
expect(device.unseal(byAlice, context: context), plain);
expect(device.unseal(byCarol, context: context), isNull);
expect(
device.unseal(
bobs.seal(plain, keyId: 'other', context: context),
context: context,
),
isNull,
);
// What it trusts is replaced, not added to.
device.trustSigners('shared', [carol.signingPublicKey]);
expect(device.unseal(byCarol, context: context), plain);
expect(device.unseal(byBob, context: context), isNull);
expect(device.unseal(byAlice, context: context), isNull);
});
}

View file

@ -54,11 +54,52 @@ void main() {
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);
var book = await FileKeyPinStore(file).read();
expect(book.pins, isEmpty);
expect(book.spaces, isEmpty);
await file.writeAsString(
'{"bob": {"encryption": "%%", "signing": "", '
'"verified": false}, "carol": 3}',
'{"pins": {"bob": {"encryption": "%%", "signing": "", '
'"verified": false, "earlierSigning": []}, "carol": 3}, '
'"spaces": {"s": 4}}',
);
expect(await FileKeyPinStore(file).read(), isEmpty);
book = await FileKeyPinStore(file).read();
expect(book.pins, isEmpty);
expect(book.spaces, isEmpty);
});
test('signatures count in a space from everyone seen there, by every '
'signing key pinned for them, earlier ones too (D20)', () async {
final pins = KeyPins(FileKeyPinStore(file));
await pins.check('bob', keys(1));
await pins.check('carol', keys(5));
await pins.check('mallory', keys(9));
await pins.sawIn('space', ['bob', 'carol']);
await pins.trustAnew('bob', keys(3));
final later = KeyPins(FileKeyPinStore(file));
final signers = await later.signersIn('space');
expect(
signers,
unorderedEquals([keys(3).signing, keys(1).signing, keys(5).signing]),
);
expect(signers, isNot(contains(keys(9).signing)));
expect(await later.signersIn('elsewhere'), isEmpty);
});
test(
'someone gone from a space still counts for what they signed there',
() async {
final pins = KeyPins(FileKeyPinStore(file));
await pins.check('bob', keys(1));
await pins.sawIn('space', ['bob']);
await pins.sawIn('space', <String>[]);
expect(await pins.signersIn('space'), [keys(1).signing]);
},
);
test('a seen person without pinned keys counts for nothing', () async {
final pins = KeyPins(FileKeyPinStore(file));
await pins.sawIn('space', ['bob']);
expect(await pins.signersIn('space'), isEmpty);
});
}

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
@ -84,6 +86,35 @@ void main() {
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');