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
8 changed files with 336 additions and 68 deletions

View file

@ -81,9 +81,16 @@ abstract interface class ContentCipher() {
/// What [sealed] holds, for the [context] it is stored in; null where it
/// doesn't open: its key isn't open here, its signature isn't the
/// account's, it was changed, or it belongs to another row.
/// account's (or, for a key with signers trusted, none of theirs), it was
/// changed, or it belongs to another row.
Uint8List? unseal(SealedPayload sealed, {required List<int> context});
/// Takes content sealed with the key [keyId] as genuine when any of
/// [signers] (signing public keys) signed it, in place of the account
/// alone (Halkyon's ADR 0044, D20): a shared space's key, with which
/// every member writes. Replaces what was trusted for it before.
void trustSigners(String keyId, List<Uint8List> signers);
/// The account's signature (64 bytes) over [message]: a readable row's,
/// which tells a row a device of the account wrote from one the instance
/// made up (ADR 0019, the reading). Throws a [StateError] before
@ -112,6 +119,9 @@ class SodiumContentCipher() implements ContentCipher {
AccountKeys? _account;
final _keys = <String, DataKey>{};
/// The signers trusted for each shared key, by its ID ([trustSigners]).
final _signers = <String, List<Uint8List>>{};
@override
Future<void> unlock(UnlockedAccountKeys account) async {
final sodium = _sodium ??= await SodiumSumoInit.init();
@ -251,24 +261,32 @@ class SodiumContentCipher() implements ContentCipher {
if (sealed.payload.length < signatureBytes) {
return null;
}
try {
return openContent(
sodium,
SealedContent(
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: Uint8List.sublistView(sealed.payload, signatureBytes),
signature: Uint8List.sublistView(sealed.payload, 0, signatureBytes),
),
dataKey: key,
authorSigningPublicKey: account.signingPublicKey,
context: context,
);
} on KeyRefused {
return null;
final content = SealedContent(
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: Uint8List.sublistView(sealed.payload, signatureBytes),
signature: Uint8List.sublistView(sealed.payload, 0, signatureBytes),
);
for (final signer in _signers[sealed.keyId] ?? [account.signingPublicKey]) {
try {
return openContent(
sodium,
content,
dataKey: key,
authorSigningPublicKey: signer,
context: context,
);
} on KeyRefused {
// Another signer may have signed it.
}
}
return null;
}
@override
void trustSigners(String keyId, List<Uint8List> signers) => _signers[keyId] =
[for (final signer in signers) Uint8List.fromList(signer)];
@override
Uint8List sign(List<int> message) {
final (sodium, account) = _unlocked();
@ -308,6 +326,7 @@ class SodiumContentCipher() implements ContentCipher {
key.dispose();
}
_keys.clear();
_signers.clear();
_account?.dispose();
_account = null;
}

View file

@ -4,11 +4,16 @@ import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// The public keys a device first saw for a person, and whether their
/// safety code was compared (Halkyon's ADR 0044, D18).
/// The public keys a device first saw for a person, whether their safety
/// code was compared, and the signing keys pinned for them before (Halkyon's
/// ADR 0044, D18, D20).
final class const PinnedKeys({
required final PublicKeys keys,
final bool verified = false,
/// The signing keys pinned for the person before [keys], each trusted
/// still for what it signed then: a reset leaves what they wrote readable.
final List<Uint8List> earlierSigning = const [],
}) {
/// From what [toJson] wrote; null for anything else.
static PinnedKeys? fromJson(Object? json) => switch (json) {
@ -16,8 +21,9 @@ final class const PinnedKeys({
'encryption': final String encryption,
'signing': final String signing,
'verified': final bool verified,
'earlierSigning': final List<Object?> earlier,
} =>
_decoded(encryption, signing, verified),
_decoded(encryption, signing, verified, earlier),
_ => null,
};
@ -25,6 +31,7 @@ final class const PinnedKeys({
String encryption,
String signing,
bool verified,
List<Object?> earlier,
) {
try {
return PinnedKeys(
@ -33,6 +40,13 @@ final class const PinnedKeys({
signing: base64Decode(signing),
),
verified: verified,
earlierSigning: [
for (final key in earlier)
if (key is String)
base64Decode(key)
else
throw const FormatException(),
],
);
} on FormatException {
return null;
@ -43,46 +57,72 @@ final class const PinnedKeys({
'encryption': base64Encode(keys.encryption),
'signing': base64Encode(keys.signing),
'verified': verified,
'earlierSigning': [for (final key in earlierSigning) base64Encode(key)],
};
}
/// Where a device keeps the keys it pinned, by person (an account's ID).
abstract interface class KeyPinStore() {
Future<Map<String, PinnedKeys>> read();
/// What a device keeps of other people's keys: the keys pinned for each
/// person (an account's ID), and who it saw in each space (its ID).
typedef PinBook = ({
Map<String, PinnedKeys> pins,
Map<String, Set<String>> spaces,
});
Future<void> write(Map<String, PinnedKeys> pins);
/// Where a device keeps its [PinBook].
abstract interface class KeyPinStore() {
Future<PinBook> read();
Future<void> write(PinBook book);
}
/// [KeyPinStore] in a JSON file in the person's folder (`key_pins.json`).
/// [KeyPinStore] in a JSON file in the person's folder (`key_pins.json`); a
/// file that doesn't hold one counts as none.
class FileKeyPinStore(final File _file) implements KeyPinStore {
@override
Future<Map<String, PinnedKeys>> read() async {
Future<PinBook> read() async {
try {
final json = jsonDecode(await _file.readAsString());
return {
if (json case final Map<String, Object?> pins)
for (final MapEntry(:key, :value) in pins.entries)
key: ?PinnedKeys.fromJson(value),
};
return (
pins: <String, PinnedKeys>{
if (json case {'pins': final Map<String, Object?> pins})
for (final MapEntry(:key, :value) in pins.entries)
key: ?PinnedKeys.fromJson(value),
},
spaces: <String, Set<String>>{
if (json case {'spaces': final Map<String, Object?> spaces})
for (final MapEntry(:key, :value) in spaces.entries)
if (value case final List<Object?> people)
key: {...people.whereType<String>()},
},
);
} on PathNotFoundException {
return {};
return _empty();
} on FormatException {
return {};
return _empty();
}
}
@override
Future<void> write(Map<String, PinnedKeys> pins) async {
Future<void> write(PinBook book) async {
await _file.parent.create(recursive: true);
final temporary = File('${_file.path}.tmp');
await temporary.writeAsString(
jsonEncode({
for (final MapEntry(:key, :value) in pins.entries) key: value.toJson(),
'pins': {
for (final MapEntry(:key, :value) in book.pins.entries)
key: value.toJson(),
},
'spaces': {
for (final MapEntry(:key, :value) in book.spaces.entries)
key: value.toList()..sort(),
},
}),
flush: true,
);
await temporary.rename(_file.path);
}
static PinBook _empty() => (pins: {}, spaces: {});
}
/// How a person's keys compare with those pinned for them.
@ -101,43 +141,80 @@ enum PinCheck() {
/// The keys a device pinned for each person (ADR 0044, D18), as Signal and
/// SSH pin them: the first seen are kept, a change refused until trusted
/// anew.
/// anew; and who it saw in each space, whose signatures it takes there
/// (D20).
class KeyPins(final KeyPinStore _store) {
Map<String, PinnedKeys>? _pins;
PinBook? _book;
Future<Map<String, PinnedKeys>> _all() async => _pins ??= await _store.read();
Future<PinBook> _read() async => _book ??= await _store.read();
/// How [keys] compare with those pinned for [person], pinning them when
/// none are.
Future<PinCheck> check(String person, PublicKeys keys) async {
final pins = await _all();
final pinned = pins[person];
final book = await _read();
final pinned = book.pins[person];
if (pinned == null) {
pins[person] = PinnedKeys(keys: keys);
await _store.write(pins);
book.pins[person] = PinnedKeys(keys: keys);
await _store.write(book);
return PinCheck.firstSeen;
}
return sameKeys(pinned.keys, keys) ? PinCheck.same : PinCheck.changed;
}
/// The keys pinned for [person]; null for none.
Future<PinnedKeys?> of(String person) async => (await _all())[person];
Future<PinnedKeys?> of(String person) async => (await _read()).pins[person];
/// Marks [person]'s pinned keys compared, by their safety code.
Future<void> markVerified(String person) async {
final pins = await _all();
if (pins[person] case final pinned?) {
pins[person] = PinnedKeys(keys: pinned.keys, verified: true);
await _store.write(pins);
final book = await _read();
if (book.pins[person] case final pinned?) {
book.pins[person] = PinnedKeys(
keys: pinned.keys,
verified: true,
earlierSigning: pinned.earlierSigning,
);
await _store.write(book);
}
}
/// Pins [keys] for [person] in place of those before, not compared: the
/// person said their keys changed for a reason of their own.
/// person said their keys changed for a reason of their own. The signing
/// key before stays trusted for what it signed.
Future<void> trustAnew(String person, PublicKeys keys) async {
final pins = await _all();
pins[person] = PinnedKeys(keys: keys);
await _store.write(pins);
final book = await _read();
final before = book.pins[person];
book.pins[person] = PinnedKeys(
keys: keys,
earlierSigning: [
...?before?.earlierSigning,
if (before != null && !_bytesEqual(before.keys.signing, keys.signing))
before.keys.signing,
],
);
await _store.write(book);
}
/// Keeps that [people] were seen as members of [space].
Future<void> sawIn(String space, Iterable<String> people) async {
final book = await _read();
final seen = book.spaces[space] ??= {};
if (!seen.containsAll(people)) {
seen.addAll(people);
await _store.write(book);
}
}
/// The signing keys whose signatures count in [space] (D20): every key
/// pinned, now or before, for anyone ever seen there.
Future<List<Uint8List>> signersIn(String space) async {
final book = await _read();
return [
for (final person in book.spaces[space] ?? const <String>{})
if (book.pins[person] case final pinned?) ...[
pinned.keys.signing,
...pinned.earlierSigning,
],
];
}
}

View file

@ -57,6 +57,7 @@ class SharedKeys(
(keyId: keyId, person: person, sealed: _cipher.sealKeyTo(keyId, keys)),
];
await _gateway.seal(space, sealed);
_cipher.trustSigners(keyId, await _pins.signersIn(space));
return (
sealedTo: {for (final key in sealed) key.person},
changed: members.changed,
@ -96,7 +97,9 @@ class SharedKeys(
/// Opens the keys of [space] sealed to the person, each checked against
/// its sealer's pinned keys; one a former member sealed is checked
/// against the keys pinned for them, if any.
/// against the keys pinned for them, if any. Content sealed with them is
/// taken from anyone ever seen in the space, by the keys pinned for them
/// (D20).
Future<OpenOutcome> openMine(String space) async {
final mine = await _gateway.mine(space);
if (mine.isEmpty) {
@ -123,6 +126,14 @@ class SharedKeys(
);
(opens ? opened : refused).add(key.keyId);
}
// What is sealed with the space's keys is taken from anyone seen in it
// (D20), the person's own key among them.
final signers = await _pins.signersIn(space);
for (final key in mine) {
if (_cipher.has(key.keyId)) {
_cipher.trustSigners(key.keyId, signers);
}
}
return (opened: opened, refused: refused, changed: changed);
}
@ -145,6 +156,12 @@ class SharedKeys(
if (!sameKeys(member.keys, _cipher.ownKeys)) {
throw OwnKeysDiffer();
}
// The device's own keys are the person's, so they are pinned as
// they are, an earlier key of theirs trusted still for what it
// signed.
if (await _pins.check(me, member.keys) == PinCheck.changed) {
await _pins.trustAnew(me, member.keys);
}
trusted[me] = member.keys;
} else if (await _pins.check(member.person, member.keys) ==
PinCheck.changed) {
@ -153,6 +170,7 @@ class SharedKeys(
trusted[member.person] = member.keys;
}
}
await _pins.sawIn(space, [...trusted.keys, ...changed]);
return (trusted: trusted, changed: changed);
}
}

View file

@ -16,6 +16,10 @@ class FakeContentCipher() implements ContentCipher {
/// The keys open here, by their ID, with the account they belong to.
final Map<String, String> opened = {};
/// The signers trusted for each shared key, by its ID ([trustSigners]),
/// as their signing keys in base64.
final _signers = <String, Set<String>>{};
@override
Future<void> unlock(UnlockedAccountKeys account) async {
_account = base64Encode(account.signingPublicKey);
@ -91,7 +95,7 @@ class FakeContentCipher() implements ContentCipher {
if (!has(keyId)) {
throw StateError('The data key $keyId is not open');
}
final tag = _tag(keyId, context);
final tag = _tag(keyId, _unlocked(), context);
return SealedPayload(
keyId: keyId,
nonce: Uint8List(24),
@ -111,10 +115,15 @@ class FakeContentCipher() implements ContentCipher {
return null;
}
final length = payload[0] << 8 | payload[1];
final tag = _tag(sealed.keyId, context);
if (length != tag.length ||
payload.length < 2 + length ||
!_same(Uint8List.sublistView(payload, 2, 2 + length), tag)) {
if (payload.length < 2 + length) {
return null;
}
final found = Uint8List.sublistView(payload, 2, 2 + length);
final signed = (_signers[sealed.keyId] ?? {_unlocked()}).any((signer) {
final tag = _tag(sealed.keyId, signer, context);
return length == tag.length && _same(found, tag);
});
if (!signed) {
return null;
}
return Uint8List.fromList([
@ -122,6 +131,10 @@ class FakeContentCipher() implements ContentCipher {
]);
}
@override
void trustSigners(String keyId, List<Uint8List> signers) =>
_signers[keyId] = {for (final signer in signers) base64Encode(signer)};
@override
Uint8List sign(List<int> message) {
final account = _unlocked();
@ -160,13 +173,17 @@ class FakeContentCipher() implements ContentCipher {
}
@override
void dispose() => opened.clear();
void dispose() {
opened.clear();
_signers.clear();
}
String _unlocked() => _account ?? (throw StateError('Not unlocked'));
/// What binds a payload to its key and context.
Uint8List _tag(String keyId, List<int> context) =>
utf8.encode('$keyId|${base64Encode(context)}');
/// What binds a payload to its key, the account that sealed it (its
/// signing key in base64) and its context.
Uint8List _tag(String keyId, String author, List<int> context) =>
utf8.encode('$keyId|$author|${base64Encode(context)}');
static bool _same(Uint8List a, Uint8List b) {
for (var i = 0; i < a.length; i++) {

View file

@ -1,13 +1,19 @@
import 'package:halkyon_app/src/shared_space/key_pins.dart';
/// [KeyPinStore] in memory; [pins] is what a file would hold.
/// [KeyPinStore] in memory; [book] is what a file would hold.
class MemoryKeyPinStore() implements KeyPinStore {
Map<String, PinnedKeys> pins = {};
PinBook book = (pins: {}, spaces: {});
@override
Future<Map<String, PinnedKeys>> read() async => {...pins};
Future<PinBook> read() async => _copy(book);
@override
Future<void> write(Map<String, PinnedKeys> pins) async =>
this.pins = {...pins};
Future<void> write(PinBook book) async => this.book = _copy(book);
static PinBook _copy(PinBook book) => (
pins: {...book.pins},
spaces: {
for (final MapEntry(:key, :value) in book.spaces.entries) key: {...value},
},
);
}

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