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
4 changed files with 571 additions and 12 deletions

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/core/grammar/grammar_gateway.dart';
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/core/sync/sync_space_lookup.dart';
@ -469,7 +470,8 @@ Future<PersonDependencies> _openPersonData(
);
// A new row of a studyset shared goes in its space, the person's own rows
// in theirs (Halkyon's ADR 0044, D24).
final spaces = SpaceRouter(SyncSpaceLookup(db, person.authUserId));
final spaceLookup = SyncSpaceLookup(db, person.authUserId);
final spaces = SpaceRouter(spaceLookup);
db.db.newRowSpace = (row) async => switch (await spaces.spaceOf(row)) {
final space? => UuidValue.withValidation(space),
null => null,
@ -514,8 +516,26 @@ Future<PersonDependencies> _openPersonData(
registerHasKeys: () async =>
(await client.modules.base.account.signInWays()).password,
);
final privacy = Privacy(db, contentKeys)..requestSync = sync.requestSync;
// The keys of the studysets shared with the person, opened as members'
// devices seal them, sealed to newcomers in turn, each member held to the
// public keys first seen for them (Halkyon's ADR 0044, D3, D18, D20).
final spaceKeys = SharedSpaceKeys(
SharedKeys(
ServerpodSharedKeysGateway(client.modules.base),
KeyPins(FileKeyPinStore(File(p.join(folder.path, 'key_pins.json')))),
contentKeys.cipher,
me: person.authUserId,
),
contentKeys.cipher,
() async => (await spaceLookup.sharedStudysets()).keys.toSet(),
);
final privacy = Privacy(db, contentKeys)
..requestSync = sync.requestSync
..spaces = spaces
..spaceKeys = spaceKeys;
await contentKeys.refresh();
// Once the person's own keys are open, with which the space's are.
spaceKeys.keepUpOn(sync.synced);
final studysets = StudysetRepository(
SyncStudysetGateway(db, sync, privacy),
);
@ -704,6 +724,7 @@ Future<PersonDependencies> _openPersonData(
await assets.dispose();
await publishing.dispose();
await privacy.dispose();
await spaceKeys.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -11,8 +11,11 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_id.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.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/space_key_id.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// A write this device refuses, as it would leave what is private readable
@ -25,6 +28,15 @@ enum PrivacyRefusal() {
/// Cards of a private studyset moved into a readable one would be
/// readable there.
movedIntoReadable,
/// A studyset shared in a space none of whose keys is open on this device
/// yet (Halkyon's ADR 0044, D21): nothing is written into it until a
/// member's device seals one to the person.
spaceKeyMissing,
/// A shared studyset is always sealed with its space's key (D21): it is
/// neither made readable nor given a key of the person's own.
shared,
}
/// A write refused for [reason].
@ -63,7 +75,11 @@ typedef _Row = ({
/// written with a weaker key than it had;
/// - a readable row without the account's signature is locked wherever
/// privacy holds, as the instance could have made it up; a signed one (a
/// device that hadn't heard yet) is shown and sealed anew ([maintain]).
/// device that hadn't heard yet) is shown and sealed anew ([maintain]);
/// - a studyset in a shared space (Halkyon's ADR 0044) takes the latest key
/// of that space, after its own record and the key it was last seen
/// sealed with: a studyset the instance claims is shared keeps the key
/// it had, so nothing of it is written for others to read.
class Privacy(
final OfflineSyncDatabaseSession _db,
@ -77,6 +93,16 @@ class Privacy(
/// Asks for a sync once a rewrite wrote rows.
void Function()? requestSync;
/// Which shared space each studyset is in (ADR 0044, D24); none shared
/// without.
SpaceRouter? spaces;
/// The keys of the shared spaces the person is a member of.
SharedSpaceKeys? spaceKeys;
/// The shared space of each studyset in one, read with [_parents].
Map<String, String> _shared = const {};
StreamSubscription<void>? _merges;
/// Seals and opens every synced payload; a readable one is signed where
@ -145,11 +171,19 @@ class Privacy(
}
}
/// Each studyset's parent, by id.
Future<Map<String, String?>> _structure() async => _parents ??= {
for (final studyset in await protocol.Studyset.db.find(_db))
studyset.id!.uuid: studyset.parentId?.uuid,
};
/// Each studyset's parent, by id, and which are shared ([_shared]).
Future<Map<String, String?>> _structure() async {
if (_parents case final parents?) {
return parents;
}
final shared = await spaces?.studysetSpaces() ?? const {};
final parents = {
for (final studyset in await protocol.Studyset.db.find(_db))
studyset.id!.uuid: studyset.parentId?.uuid,
};
_shared = shared;
return _parents = parents;
}
/// The key [studysetId] and its tree's rows are written with: the
/// nearest private studyset's, itself or above, by the signed records;
@ -176,13 +210,13 @@ class Privacy(
current = parents[current]
) {
if (_decided(current) case (:final keyId)) {
return keyId;
return _usable(keyId);
}
if (locked.contains(current)) {
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
}
}
return treeKeyIn(parents, studysetId);
return _usable(treeKeyIn(parents, studysetId));
}
/// Learns the keys of [studysetId] and the studysets above it from their
@ -333,16 +367,58 @@ class Privacy(
/// sealed with; null where neither does, and its tree's decides. A
/// studyset made readable by its own record stays readable wherever the
/// instance moves it (the final reading of 22 Oct).
///
/// A studyset in a shared space takes the space's latest key, after both
/// (ADR 0044, D21, D22): only one copied there under new IDs, or brought
/// by its members, has neither, so the instance moving a private
/// studyset's rows into a space changes nothing.
({String? keyId})? _decided(String studysetId) {
if (ledger.factOf(studysetId) case final fact?) {
return (keyId: fact.keyId);
}
if (_itemKey('studyset:$studysetId') case final keyId?) {
return (keyId: keyId);
return (keyId: _latestFor(keyId));
}
if (_shared[studysetId] case final space?) {
return (keyId: _spaceKey(space));
}
return null;
}
/// [keyId], or for a shared space's key, the latest of that space: new
/// and changed rows take it, earlier ones keep theirs (D22).
String _latestFor(String keyId) => switch (SpaceKeyId.parse(keyId)) {
final id? => _spaceKey(id.space),
null => keyId,
};
/// The latest key of the shared [space]; while none is open here, an ID
/// naming the space that no key has, so reading locks what is readable
/// and writing is refused ([_usable]).
String _spaceKey(String space) =>
spaceKeys?.latestOf(space)?.value ?? 'h1.$space.0.none';
/// [keyId] for writing: refused where it is a shared space's and not
/// open here.
String? _usable(String? keyId) {
if (keyId != null &&
SpaceKeyId.parse(keyId) != null &&
!keys.canOpen(keyId)) {
throw const PrivacyRefused(PrivacyRefusal.spaceKeyMissing);
}
return keyId;
}
/// Refuses to give [studysetId] a privacy of its own where it is shared
/// (D21).
Future<void> _refuseShared(String studysetId) async {
forgetTrees();
await _structure();
if (_shared.containsKey(studysetId)) {
throw const PrivacyRefused(PrivacyRefusal.shared);
}
}
/// The key [item] was last seen sealed with, unless a later signed record
/// made the tree of that key readable, which releases it.
String? _itemKey(String item) {
@ -390,7 +466,8 @@ class Privacy(
/// without the keys of an account that has them.
String? _writeKey(String item, String? treeKey) {
_refuseKeyless();
return _itemKey(item) ?? treeKey;
final kept = _itemKey(item);
return _usable(kept == null ? treeKey : _latestFor(kept));
}
@override
@ -1185,6 +1262,7 @@ class Privacy(
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
}
await _idle(studysetId);
await _refuseShared(studysetId);
// Readable rows nobody signed are its content only while it is
// readable; in a private tree they could be the instance's.
final adopt = await keyOfStudyset(studysetId) == null;

View file

@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_core/halkyon_core.dart' show CoalescingRunner;
import 'package:halkyon_learn_flutter/src/core/privacy/data/space_key_id.dart';
/// The keys of the shared spaces the person is a member of, on this device
/// (Halkyon's ADR 0044, D3, D20, D22): opened as the members' devices seal
/// them, sealed in turn to newcomers, and the latest of each space, which
/// new writes there take.
class SharedSpaceKeys(
final SharedKeys _keys,
final ContentCipher _cipher,
/// The shared spaces the person is a member of, by the sync engine's
/// grants.
final Future<Set<String>> Function() _spaces,
) {
/// The latest key known of each space, by its ID's time.
final _latest = <String, SpaceKeyId>{};
StreamSubscription<void>? _syncs;
late final _upkeep = CoalescingRunner(
_keepUp,
onError: (error, st) => log(
"Keeping shared spaces' keys failed",
name: 'SharedSpaceKeys',
error: error,
stackTrace: st,
),
);
/// The key new writes in [space] take; null while none is open here.
SpaceKeyId? latestOf(String space) => _latest[space];
/// Opens the keys sealed to the person in each of their shared spaces and
/// seals those this device holds to members who lack them: at the data's
/// opening and after each sync.
Future<void> upkeep() => _upkeep.run();
Future<void> _keepUp() async {
for (final space in await _spaces()) {
// One space failing (the instance refusing it, keys that changed)
// holds back none of the others.
try {
final opened = await _keys.openMine(space);
for (final keyId in opened.opened) {
_learn(space, keyId);
}
await _keys.sealForNewcomers(space);
} on Object catch (error, st) {
log(
"Keeping a shared space's keys failed",
name: 'SharedSpaceKeys',
error: error,
stackTrace: st,
);
}
}
}
/// A new key for [space], later than any known of it, sealed to its
/// members and the latest from now on: the space's first, or the one a
/// roll makes (D22). Throws what [SharedKeys.share] throws.
Future<SpaceKeyId> newKey(String space) async {
final id = SpaceKeyId.next(space, after: _latest[space]?.madeAt);
_cipher.newKey(id.value);
await _keys.share(space, id.value);
_learn(space, id.value);
return id;
}
/// Keeps [keyId] as [space]'s latest where it is later. Only a key whose
/// ID names [space] counts: the ID is sealed with the key, so the
/// instance can't hand one space's key out as another's.
void _learn(String space, String keyId) {
final id = SpaceKeyId.parse(keyId);
if (id == null || id.space != space) {
return;
}
final latest = _latest[space];
if (latest == null ||
id.madeAt > latest.madeAt ||
(id.madeAt == latest.madeAt && id.value.compareTo(latest.value) > 0)) {
_latest[space] = id;
}
}
/// Keeps the keys up now and after each of [syncs].
void keepUpOn(Stream<void> syncs) {
unawaited(upkeep());
_syncs = syncs.listen((_) => unawaited(upkeep()));
}
Future<void> dispose() async {
await _syncs?.cancel();
await _upkeep.idle;
}
}

View file

@ -95,6 +95,11 @@ class SpaceRouter(final SpaceLookup _lookup) {
Future<String?> _ofStudyset(String? studysetId) async =>
studysetId == null ? null : (await _studysetSpaces())[studysetId];
/// The shared space of each studyset in one, by its id.
Future<Map<String, String>> studysetSpaces() async => {
...await _studysetSpaces(),
};
Future<Map<String, String>> _studysetSpaces() async => _spaces ??= {
for (final MapEntry(key: space, value: studysets)
in (await _lookup.sharedStudysets()).entries)