feat(learn): shared studysets sealed with their space's latest key

Step 4 (c) of ADR 0044 (D20 to D22).

- `SharedSpaceKeys`: after each sync, opens the keys members' devices
  sealed to the person and seals those this device holds to newcomers,
  space by space, one failing holding back none of the others; keeps
  each space's latest key, counting only keys whose sealed ID names
  that space; makes a space's new key and shares it with its members.
- `Privacy`: a studyset in a shared space takes the space's latest key,
  after its own signed record and the key it was last seen sealed with,
  so a private studyset the instance claims is shared keeps its own
  key. An item last seen under a space's key is written with that
  space's latest (D22: earlier rows keep theirs). Writing is refused
  while no key of the space is open (`spaceKeyMissing`), and a shared
  studyset is never made readable nor given a key of the person's own
  (`shared`, D21). Sweeps keep to studysets with a signed record, which
  a shared one never has.

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:42:32 +01:00
commit 0594c4f3fe
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 571 additions and 12 deletions

View file

@ -0,0 +1,118 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart';
/// The keys of the shared spaces a person is a member of, on their device
/// (Halkyon's ADR 0044, D3, D20, D22): opened after each sync, sealed to
/// newcomers, the latest of each space the one new writes take.
void main() {
const space = 'space-1';
const other = 'space-2';
final crypto = FakeAccountCrypto();
late FakeSharedKeysServer server;
PublicKeys keysOf(UnlockedAccountKeys account) => (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
/// [person]'s device, a member of [spaces] by what its database says.
Future<({SharedSpaceKeys keys, FakeContentCipher cipher})> device(
String person,
UnlockedAccountKeys account, {
Set<String> spaces = const {space},
}) async {
final cipher = FakeContentCipher();
await cipher.unlock(account);
return (
keys: SharedSpaceKeys(
SharedKeys(
server.as(person),
KeyPins(MemoryKeyPinStore()),
cipher,
me: person,
),
cipher,
() async => spaces,
),
cipher: cipher,
);
}
final aliceAccount = crypto.keysNumbered(1);
final bobAccount = crypto.keysNumbered(2);
final carolAccount = crypto.keysNumbered(3);
setUp(() {
server = FakeSharedKeysServer()
..addMember(space, 'alice', keys: keysOf(aliceAccount))
..addMember(space, 'bob', keys: keysOf(bobAccount))
..addMember(other, 'alice', keys: keysOf(aliceAccount))
..addMember(other, 'bob', keys: keysOf(bobAccount));
});
test("a space's new key is sealed to its members, opens on their "
'devices, and is the latest there', () async {
final alice = await device('alice', aliceAccount);
final bob = await device('bob', bobAccount);
expect(alice.keys.latestOf(space), isNull);
final first = await alice.keys.newKey(space);
expect(first.space, space);
expect(alice.keys.latestOf(space), first);
expect(alice.cipher.has(first.value), isTrue);
await bob.keys.upkeep();
expect(bob.cipher.has(first.value), isTrue);
expect(bob.keys.latestOf(space), first);
// The key rolled (D22): new writes take the newer one.
final second = await bob.keys.newKey(space);
expect(second.madeAt, greaterThan(first.madeAt));
await alice.keys.upkeep();
expect(alice.keys.latestOf(space), second);
expect(alice.cipher.has(first.value), isTrue);
});
test("a key named for another space is never this space's latest, as "
'the instance may hand one out as another', () async {
final alice = await device('alice', aliceAccount);
final bob = await device('bob', bobAccount, spaces: {space, other});
final others = await alice.keys.newKey(other);
// Sealed in [space] too, as a hostile instance would hand it on.
await SharedKeys(
server.as('alice'),
KeyPins(MemoryKeyPinStore()),
alice.cipher,
me: 'alice',
).share(space, others.value);
await bob.keys.upkeep();
expect(bob.keys.latestOf(other), others);
expect(bob.keys.latestOf(space), isNull);
});
test("a newcomer gets the space's keys at a member's next upkeep", () async {
final alice = await device('alice', aliceAccount);
final key = await alice.keys.newKey(space);
server.addMember(space, 'carol', keys: keysOf(carolAccount));
final carol = await device('carol', carolAccount);
await carol.keys.upkeep();
expect(carol.keys.latestOf(space), isNull);
await alice.keys.upkeep();
await carol.keys.upkeep();
expect(carol.keys.latestOf(space), key);
});
test('a space failing holds back none of the others', () async {
final alice = await device('alice', aliceAccount);
final key = await alice.keys.newKey(space);
final bob = await device('bob', bobAccount, spaces: {'gone', space});
await bob.keys.upkeep();
expect(bob.keys.latestOf(space), key);
});
}

View file

@ -0,0 +1,236 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/shared_space_keys.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Studysets in a shared space (Halkyon's ADR 0044, D21, D22): written with
/// the space's latest key, refused while none is open, never made readable
/// or given a key of the person's own, and a private studyset the instance
/// claims is shared keeps its own key. On the real gateways, over a local
/// database without a server.
void main() {
const space = 'space-1';
final crypto = FakeAccountCrypto();
final alice = crypto.keysNumbered(1);
late OfflineSyncDatabaseSession db;
late SyncService sync;
late FakeSharedKeysServer server;
late _Lookup lookup;
late Directory folder;
setUp(() async {
folder = await Directory.systemTemp.createTemp('shared_trees');
addTearDown(() => folder.delete(recursive: true));
final client = protocol.Client('http://127.0.0.1:9/');
db = await client.createSyncSession(
'${folder.path}/sync.sqlite',
persistentUserId: const protocol.Uuid().v7obj(),
);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
addTearDown(sync.dispose);
server = FakeSharedKeysServer()
..addMember(
space,
'alice',
keys: (
encryption: alice.encryptionPublicKey,
signing: alice.signingPublicKey,
),
);
lookup = _Lookup();
});
/// Alice's device, a member of [space].
Future<
({
Privacy privacy,
SharedSpaceKeys spaceKeys,
SyncStudysetGateway studysets,
SyncCardGateway cards,
})
>
device() async {
final ledger = PrivacyLedger(MemoryPrivacyLedgerStore());
await ledger.open();
final keys = ContentKeys(
SyncContentKeyGateway(db, sync),
FakeContentCipher(),
() async => alice,
seen: ledger,
);
await keys.refresh();
final spaceKeys = SharedSpaceKeys(
SharedKeys(
server.as('alice'),
KeyPins(MemoryKeyPinStore()),
keys.cipher,
me: 'alice',
),
keys.cipher,
() async => {space},
);
final privacy = Privacy(db, keys)
..spaces = SpaceRouter(lookup)
..spaceKeys = spaceKeys;
addTearDown(() async {
await privacy.dispose();
await spaceKeys.dispose();
await keys.dispose();
});
return (
privacy: privacy,
spaceKeys: spaceKeys,
studysets: SyncStudysetGateway(db, sync, privacy),
cards: SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy),
);
}
ByteData json(Map<String, Object?> value) =>
ByteData.sublistView(utf8.encode(jsonEncode(value)));
ByteData cardPayload(String front) => json({
'schemaVersion': 1,
'type': 'basic',
'front': front,
'back': 'back',
});
/// A studyset a member shared, as a sync brings it.
Future<String> sharedStudyset() async {
final id = Privacy.newId();
await protocol.Studyset.db.insertRow(
db,
protocol.Studyset(
id: id,
payload: json({'schemaVersion': 1, 'title': 'Shared'}),
updatedAt: DateTime.utc(2026, 11, 11),
position: 'a0',
),
);
lookup.shared[space] = {id.uuid};
return id.uuid;
}
Future<List<String?>> revisionKeys(String cardId) async => [
for (final revision in await protocol.CardRevision.db.find(
db,
where: (t) => t.cardId.equals(protocol.UuidValue.withValidation(cardId)),
orderBy: (t) => t.id,
))
revision.keyId,
];
test('a card written into a shared studyset is refused while no key of '
"the space is open, then sealed with the space's latest key, an "
'earlier revision keeping the key it had (D22)', () async {
final laptop = await device();
final studyset = await sharedStudyset();
await expectLater(
laptop.cards.insertCard(
studysetId: studyset,
position: 'a0',
payload: cardPayload('first'),
),
throwsA(
isA<PrivacyRefused>().having(
(r) => r.reason,
'reason',
PrivacyRefusal.spaceKeyMissing,
),
),
);
final first = await laptop.spaceKeys.newKey(space);
final card = await laptop.cards.insertCard(
studysetId: studyset,
position: 'a0',
payload: cardPayload('first'),
);
expect(await revisionKeys(card), [first.value]);
final second = await laptop.spaceKeys.newKey(space);
await laptop.cards.insertRevision(
cardId: card,
parents: const {},
payload: cardPayload('second'),
);
expect(await revisionKeys(card), [first.value, second.value]);
});
test('a shared studyset is never made readable, nor given a key of the '
"person's own (D21)", () async {
final laptop = await device();
final studyset = await sharedStudyset();
await laptop.spaceKeys.newKey(space);
for (final private in [false, true]) {
await expectLater(
laptop.privacy.setPrivate(studyset, private: private),
throwsA(
isA<PrivacyRefused>().having(
(r) => r.reason,
'reason',
PrivacyRefusal.shared,
),
),
);
}
});
test('a private studyset the instance claims is shared keeps its own key: '
'nothing of it is written for the members to read', () async {
final laptop = await device();
final own = await laptop.studysets.insert(
json({'schemaVersion': 1, 'title': 'Diary'}),
DateTime.utc(2026, 11, 11),
);
final ownKey = await laptop.privacy.keyOfStudyset(own);
await laptop.spaceKeys.newKey(space);
lookup.shared[space] = {own};
laptop.privacy.forgetTrees();
final card = await laptop.cards.insertCard(
studysetId: own,
position: 'a0',
payload: cardPayload('secret'),
);
expect(await revisionKeys(card), [ownKey]);
});
}
/// The studysets in each shared space, as the sync engine's grants say.
class _Lookup() implements SpaceLookup {
final shared = <String, Set<String>>{};
@override
Future<Map<String, Set<String>>> sharedStudysets() async => shared;
@override
Future<String?> studysetOfCard(String id) async => null;
@override
Future<String?> studysetOfNote(String id) async => null;
@override
Future<String?> studysetOfPath(String id) async => null;
@override
Future<String?> pathOfStep(String id) async => null;
}