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

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