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:
parent
5faf3fce58
commit
0594c4f3fe
6 changed files with 571 additions and 12 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue