fix(privacy): privacy is a signed fact the instance can't take back (ADR 0019)
The reading of 22 Oct, against an instance that alters data. A data key's ID now says what it is for (a studyset's key naming it, the person's key, or a record that a studyset was made readable), and the ID is sealed with the key and signed by the account, so the instance can't relabel a key or make one up; the row's own columns are no longer trusted. A studyset is private while the latest signed record about it says so, and the device keeps the latest it saw in a ledger in the person's folder (privacy.json), so a record withheld or a row replayed changes nothing; a tree's key is the nearest studyset's with such a record, itself or above. An item keeps the key it was last seen sealed with unless this device moves it or a signed record makes its tree readable. A readable row a device holding the keys writes carries the account's signature in place of a nonce; one nobody signed is locked wherever privacy holds (in private trees, for items once sealed, and for tags and settings once sealed), so the instance can't plant content, confirm a guessed tag name by folding, or switch Count me on. Signed readable rows from a device that hadn't heard are shown and sealed anew. Rewrites are kept as under way until done, write the top studyset last, hold writes into their tree meanwhile, and are finished as the data opens and after each merge; a sweep seals what a stale device wrote. Cards moved into a private tree are sealed with their files first; cards of a private tree are never moved into a readable one. A private topic moved out of its tree keeps its privacy with a key of its own. Settings carry a counter inside their sealed payload, and a lower one than seen is refused. The person's readable tags and settings are sealed once. A device without the keys of an account that has them writes nothing that would be private and reports nothing for the learner count; an anonymous person's keys kept but unreadable are never replaced. Publishing sends only the names of the tags the release carries. Key refusals are remembered per row. Tests on the real gateways over a local database: a replayed readable top row, a withheld key record, a moved parent, a planted revision, a signed stale revision, card and topic moves, an interrupted rewrite, a settings rollback, a device without keys, planted tags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8ea2f04384
commit
88e79155bc
31 changed files with 2622 additions and 444 deletions
|
|
@ -215,6 +215,9 @@ class AssetRepository(
|
|||
@override
|
||||
Future<String?> keyIdOf(String id) async => (await _rows.byId(id))?.keyId;
|
||||
|
||||
@override
|
||||
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
||||
|
||||
@override
|
||||
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
||||
String id,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ class SyncAssetGateway(
|
|||
keyId: keyId,
|
||||
table: 'asset',
|
||||
id: id,
|
||||
item: 'asset:$id',
|
||||
);
|
||||
final asset = await protocol.Asset.db.insertRow(
|
||||
_db,
|
||||
|
|
@ -104,10 +105,15 @@ class SyncAssetGateway(
|
|||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
final opened = _privacy.open(
|
||||
// A file this device saw sealed never counts as readable again (the
|
||||
// reading of 22 Oct): a readable row in its place nobody signed is
|
||||
// locked.
|
||||
final opened = _privacy.openItem(
|
||||
(keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
|
||||
table: 'asset',
|
||||
id: asset.id!.uuid,
|
||||
item: 'asset:${asset.id!.uuid}',
|
||||
treeKey: null,
|
||||
);
|
||||
return (
|
||||
id: asset.id!.uuid,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_sett
|
|||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.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/sync_content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
||||
|
|
@ -83,6 +84,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_progres
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
|
@ -308,7 +310,11 @@ class Dependencies({
|
|||
await folders.openOnly(person.authUserId),
|
||||
accountKeys: () async {
|
||||
final kept = await accountKeys.read(person.authUserId);
|
||||
if (kept != null || person.isIdentified) {
|
||||
if (kept != null ||
|
||||
person.isIdentified ||
|
||||
// Kept but unreadable now: never replaced, or their
|
||||
// private studysets would be locked for good.
|
||||
await accountKeys.holds(person.authUserId)) {
|
||||
return kept;
|
||||
}
|
||||
// An anonymous person's device makes the keys their private
|
||||
|
|
@ -432,12 +438,19 @@ Future<PersonDependencies> _openPersonData(
|
|||
)..start();
|
||||
// The person's keys, with which private studysets, their tags and their
|
||||
// settings are sealed and opened (ADR 0019).
|
||||
// What this device saw of their privacy, which the instance can't take
|
||||
// back (the reading of 22 Oct).
|
||||
final privacyLedger = PrivacyLedger(
|
||||
FilePrivacyLedgerStore(File(p.join(folder.path, 'privacy.json'))),
|
||||
);
|
||||
await privacyLedger.open();
|
||||
final contentKeys = ContentKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
accountKeys,
|
||||
seen: privacyLedger,
|
||||
);
|
||||
final privacy = Privacy(db, contentKeys);
|
||||
final privacy = Privacy(db, contentKeys)..requestSync = sync.requestSync;
|
||||
await contentKeys.refresh();
|
||||
final studysets = StudysetRepository(
|
||||
SyncStudysetGateway(db, sync, privacy),
|
||||
|
|
@ -469,6 +482,7 @@ Future<PersonDependencies> _openPersonData(
|
|||
ServerpodLearningCountGateway(client),
|
||||
settings,
|
||||
studysets,
|
||||
canCount: () => contentKeys.canEncrypt || !contentKeys.accountHasKeys,
|
||||
)..start(sync.synced))
|
||||
: null;
|
||||
final assets = AssetRepository(
|
||||
|
|
@ -478,8 +492,12 @@ Future<PersonDependencies> _openPersonData(
|
|||
privacy,
|
||||
uploadTriggers: sync.synced,
|
||||
);
|
||||
// A tree made private or not, or moved, takes its files along (ADR 0019).
|
||||
privacy.files = assets;
|
||||
// A tree made private or not, or moved, takes its files along (ADR 0019);
|
||||
// what a run left, or a device that hadn't heard wrote, is sealed as the
|
||||
// data opens and after every sync that merged.
|
||||
privacy
|
||||
..files = assets
|
||||
..maintainOn(sync.merges);
|
||||
final pdfSources = PdfSourceRepository(
|
||||
SyncPdfSourceGateway(db, sync, privacy),
|
||||
assets,
|
||||
|
|
@ -508,8 +526,23 @@ Future<PersonDependencies> _openPersonData(
|
|||
final publishing = PublishRepository(
|
||||
ServerpodPublishGateway(client),
|
||||
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
||||
tagNames: () async => {
|
||||
for (final tag in (await tags.read()).all) tag.id: tag.name,
|
||||
tagNames: (studysetId) async {
|
||||
final tree = subtreeOf(studysetId, await studysets.withDeleted());
|
||||
final tagging = await tags.read();
|
||||
final carried = <String>{
|
||||
for (final card in await cards.cardsIn(tree))
|
||||
for (final tag in tagging.of(card.id)) tag.id,
|
||||
for (final id in tree) ...[
|
||||
for (final note in await notes.notesOf(id))
|
||||
for (final tag in tagging.ofNote(note.id)) tag.id,
|
||||
for (final path in await paths.pathsOf(id))
|
||||
for (final tag in tagging.ofPath(path.id)) tag.id,
|
||||
],
|
||||
};
|
||||
return {
|
||||
for (final tag in tagging.all)
|
||||
if (carried.contains(tag.id)) tag.id: tag.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
final studysetTabs = StudysetTabs(
|
||||
|
|
@ -586,11 +619,13 @@ Future<PersonDependencies> _openPersonData(
|
|||
await pdfSources.dispose();
|
||||
await assets.dispose();
|
||||
await publishing.dispose();
|
||||
await privacy.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
// The keys opened here go once nothing can write any more.
|
||||
contentKeys.dispose();
|
||||
// The keys opened here go once nothing can write any more, and what
|
||||
// the device saw of privacy is written.
|
||||
await contentKeys.dispose();
|
||||
onClosed();
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ enum ContentKeyKind() {
|
|||
|
||||
/// The person's own: their tags and settings.
|
||||
personal,
|
||||
|
||||
/// No key to write with: a signed record that a studyset was made
|
||||
/// readable (ADR 0019, the reading of 22 Oct).
|
||||
readable,
|
||||
}
|
||||
|
||||
/// A data key as its row keeps it: sealed to the person's account.
|
||||
/// A data key as its row keeps it: sealed to the person's account. Only its
|
||||
/// sealed ID is trusted, which says what the key is for (`ContentKeyId`);
|
||||
/// its kind, studyset and time are the instance's to change.
|
||||
typedef ContentKeyRow = ({
|
||||
String id,
|
||||
String dataKeyId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// What a data key is for, as its ID says (ADR 0019, the reading of 22 Oct).
|
||||
enum ContentKeyScope() {
|
||||
/// A private studyset's key, for everything in its tree.
|
||||
studyset,
|
||||
|
||||
/// The person's own key, for their tags and settings.
|
||||
personal,
|
||||
|
||||
/// No key to write with, but a signed record that the studyset it names
|
||||
/// was made readable then, which ends its earlier keys.
|
||||
readable,
|
||||
}
|
||||
|
||||
/// A data key's ID, which says what the key is for: its scope, the
|
||||
/// studyset it names, and when it was made. The ID is sealed with the key
|
||||
/// and signed by the account (halkyon_keys' `sealDataKey` opens a key only
|
||||
/// under the ID it was sealed with), so the instance can relabel no key:
|
||||
/// the `content_key` row's own columns are never trusted.
|
||||
final class const ContentKeyId({
|
||||
/// What the key is for.
|
||||
required final ContentKeyScope scope,
|
||||
|
||||
/// The studyset a studyset's key or a readable record names.
|
||||
required final String? studysetId,
|
||||
|
||||
/// When it was made, in milliseconds since the epoch (UTC), on the device
|
||||
/// that made it: what orders a studyset's records.
|
||||
required final int madeAt,
|
||||
|
||||
/// The ID as it is stored.
|
||||
required final String value,
|
||||
}) with Equatable {
|
||||
/// A new ID for [scope], naming [studysetId] for a studyset's key or
|
||||
/// readable record, made now.
|
||||
factory next(ContentKeyScope scope, {String? studysetId}) {
|
||||
final random = Random.secure();
|
||||
final nonce = base64Url
|
||||
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
|
||||
.replaceAll('=', '');
|
||||
final ms = clock.now().toUtc().millisecondsSinceEpoch;
|
||||
final value = switch (scope) {
|
||||
ContentKeyScope.personal => 'p1.$ms.$nonce',
|
||||
ContentKeyScope.studyset => 's1.${studysetId!}.$ms.$nonce',
|
||||
ContentKeyScope.readable => 'r1.${studysetId!}.$ms.$nonce',
|
||||
};
|
||||
return ContentKeyId(
|
||||
scope: scope,
|
||||
studysetId: studysetId,
|
||||
madeAt: ms,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
|
||||
/// [value] read as an ID; null for one of another form (a key made
|
||||
/// before IDs said what they are for, or one this app doesn't know),
|
||||
/// which never counts as a fact about privacy.
|
||||
static ContentKeyId? parse(String value) {
|
||||
final parts = value.split('.');
|
||||
switch (parts) {
|
||||
case ['p1', final ms, final _]:
|
||||
final madeAt = int.tryParse(ms);
|
||||
return madeAt == null
|
||||
? null
|
||||
: ContentKeyId(
|
||||
scope: ContentKeyScope.personal,
|
||||
studysetId: null,
|
||||
madeAt: madeAt,
|
||||
value: value,
|
||||
);
|
||||
case [final kind, final studyset, final ms, final _]
|
||||
when (kind == 's1' || kind == 'r1') && studyset.isNotEmpty:
|
||||
final madeAt = int.tryParse(ms);
|
||||
return madeAt == null
|
||||
? null
|
||||
: ContentKeyId(
|
||||
scope: kind == 's1'
|
||||
? ContentKeyScope.studyset
|
||||
: ContentKeyScope.readable,
|
||||
studysetId: studyset,
|
||||
madeAt: madeAt,
|
||||
value: value,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [scope, studysetId, madeAt, value];
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' show Random;
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_core/halkyon_core.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_id.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
||||
|
||||
/// The person's data keys on this device (ADR 0019): each private
|
||||
/// studyset's and their personal key, sealed to their account and synced,
|
||||
|
|
@ -12,6 +12,12 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_gateway.
|
|||
/// ADR 0017, 9). Without those keys on the device (an account signed in
|
||||
/// without its password here, or one that has none yet, D8) nothing opens
|
||||
/// and nothing new is made private.
|
||||
///
|
||||
/// What a key is for is its sealed ID's to say ([ContentKeyId]), never its
|
||||
/// row's: so the keys that open are also the signed facts about which
|
||||
/// studysets are private ([ledger] keeps the latest seen of each), and the
|
||||
/// instance can neither relabel a key nor make one up (the reading of 22
|
||||
/// Oct).
|
||||
class ContentKeys(
|
||||
final ContentKeyGateway _gateway,
|
||||
|
||||
|
|
@ -19,13 +25,26 @@ class ContentKeys(
|
|||
final ContentCipher cipher,
|
||||
|
||||
/// The account's keys on this device; null where none are.
|
||||
final Future<UnlockedAccountKeys?> Function() _accountKeys,
|
||||
) {
|
||||
final Future<UnlockedAccountKeys?> Function() _accountKeys, {
|
||||
|
||||
/// What this device has seen of the person's privacy; one in memory
|
||||
/// unless given.
|
||||
PrivacyLedger? seen,
|
||||
}) {
|
||||
/// What this device has seen of the person's privacy, kept in their
|
||||
/// folder.
|
||||
final PrivacyLedger ledger =
|
||||
seen ?? PrivacyLedger(MemoryPrivacyLedgerStore());
|
||||
|
||||
var _unlocked = false;
|
||||
var _rows = const <ContentKeyRow>[];
|
||||
|
||||
/// The keys that wouldn't open, never tried again.
|
||||
final _refused = <String>{};
|
||||
/// The rows whose key wouldn't open, by row: a forged row never shadows
|
||||
/// the real key of the same ID, and keys not open yet are tried again.
|
||||
final _refusedRows = <String>{};
|
||||
|
||||
/// The personal keys open here, by their IDs.
|
||||
final _personal = <ContentKeyId>[];
|
||||
|
||||
late final _refreshing = CoalescingRunner(
|
||||
_refresh,
|
||||
|
|
@ -37,6 +56,12 @@ class ContentKeys(
|
|||
/// made private; known once [refresh] ran.
|
||||
bool get canEncrypt => _unlocked;
|
||||
|
||||
/// Whether the person has keys at all: some are kept, or this device saw
|
||||
/// some. A device without the account's keys on such an account writes
|
||||
/// nothing that would be private (the reading of 22 Oct).
|
||||
bool get accountHasKeys =>
|
||||
_rows.isNotEmpty || ledger.studysetsWithFacts.isNotEmpty;
|
||||
|
||||
/// Opens the keys kept that aren't open yet: as the person's data opens,
|
||||
/// and once a sync may have brought more.
|
||||
Future<void> refresh() => _refreshing.run();
|
||||
|
|
@ -44,83 +69,129 @@ class ContentKeys(
|
|||
Future<void> _refresh() async {
|
||||
if (!_unlocked) {
|
||||
final account = await _accountKeys();
|
||||
if (account == null) {
|
||||
return;
|
||||
if (account != null) {
|
||||
await cipher.unlock(account);
|
||||
_unlocked = true;
|
||||
}
|
||||
await cipher.unlock(account);
|
||||
_unlocked = true;
|
||||
}
|
||||
_rows = await _gateway.all();
|
||||
if (!_unlocked) {
|
||||
return;
|
||||
}
|
||||
for (final row in _rows) {
|
||||
if (!cipher.has(row.dataKeyId) && !_refused.contains(row.dataKeyId)) {
|
||||
if (_refusedRows.contains(row.id)) {
|
||||
continue;
|
||||
}
|
||||
final id = ContentKeyId.parse(row.dataKeyId);
|
||||
if (!cipher.has(row.dataKeyId)) {
|
||||
if (!cipher.openKey(row.dataKeyId, row.sealedKey)) {
|
||||
_refused.add(row.dataKeyId);
|
||||
_refusedRows.add(row.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (id != null) {
|
||||
_learn(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps what the open key [id] says: a studyset made private or
|
||||
/// readable, or the person's key.
|
||||
void _learn(ContentKeyId id) {
|
||||
switch (id.scope) {
|
||||
case ContentKeyScope.studyset:
|
||||
ledger.learnFact(id.studysetId!, (at: id.madeAt, keyId: id.value));
|
||||
case ContentKeyScope.readable:
|
||||
ledger.learnFact(id.studysetId!, (at: id.madeAt, keyId: null));
|
||||
case ContentKeyScope.personal:
|
||||
if (!_personal.contains(id)) {
|
||||
_personal.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the key [keyId] is open here.
|
||||
bool canOpen(String keyId) => cipher.has(keyId);
|
||||
|
||||
/// A new key for the private studyset [studysetId], kept and synced; its
|
||||
/// ID. Throws a [StateError] where this device can't encrypt.
|
||||
Future<String> newStudysetKey(String studysetId) =>
|
||||
_newKey(ContentKeyKind.studyset, studysetId);
|
||||
/// A new key for the private studyset [studysetId], kept and synced, and
|
||||
/// the fact that it is private from now on; its ID. Throws a
|
||||
/// [StateError] where this device can't encrypt.
|
||||
Future<String> newStudysetKey(String studysetId) => _newKey(
|
||||
ContentKeyId.next(ContentKeyScope.studyset, studysetId: studysetId),
|
||||
);
|
||||
|
||||
/// Keeps and syncs the signed record that [studysetId] is readable from
|
||||
/// now on, which ends its earlier keys. Throws a [StateError] where this
|
||||
/// device can't encrypt.
|
||||
Future<void> recordReadable(String studysetId) async {
|
||||
await _newKey(
|
||||
ContentKeyId.next(ContentKeyScope.readable, studysetId: studysetId),
|
||||
);
|
||||
}
|
||||
|
||||
/// The person's key for their tags and settings, made the first time;
|
||||
/// null where this device can't encrypt. Two devices making one at once
|
||||
/// each keep theirs, every device opening both; the oldest is written
|
||||
/// with.
|
||||
/// with, by when its sealed ID says it was made.
|
||||
Future<String?> personalKey() async {
|
||||
if (!_unlocked) {
|
||||
return null;
|
||||
}
|
||||
final personal = [
|
||||
for (final row in _rows)
|
||||
if (row.kind == ContentKeyKind.personal && canOpen(row.dataKeyId)) row,
|
||||
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
return personal.firstOrNull?.dataKeyId ??
|
||||
await _newKey(ContentKeyKind.personal, null);
|
||||
if (_personal.isEmpty) {
|
||||
return await _newKey(ContentKeyId.next(ContentKeyScope.personal));
|
||||
}
|
||||
final oldest = [..._personal]
|
||||
..sort(
|
||||
(a, b) => a.madeAt != b.madeAt
|
||||
? a.madeAt.compareTo(b.madeAt)
|
||||
: a.value.compareTo(b.value),
|
||||
);
|
||||
return oldest.first.value;
|
||||
}
|
||||
|
||||
Future<String> _newKey(ContentKeyKind kind, String? studysetId) async {
|
||||
/// Whether a personal key is open here, or this device saw one: from
|
||||
/// then on, a readable tag or settings row is no longer trusted.
|
||||
bool get hasPersonalKey => _personal.isNotEmpty || ledger.personalSealed;
|
||||
|
||||
Future<String> _newKey(ContentKeyId id) async {
|
||||
if (!_unlocked) {
|
||||
throw StateError('The account keys are not on this device');
|
||||
}
|
||||
final keyId = _newKeyId();
|
||||
final createdAt = clock.now().toUtc();
|
||||
final sealedKey = cipher.newKey(keyId);
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
id.madeAt,
|
||||
isUtc: true,
|
||||
);
|
||||
final sealedKey = cipher.newKey(id.value);
|
||||
final kind = switch (id.scope) {
|
||||
ContentKeyScope.studyset => ContentKeyKind.studyset,
|
||||
ContentKeyScope.personal => ContentKeyKind.personal,
|
||||
ContentKeyScope.readable => ContentKeyKind.readable,
|
||||
};
|
||||
await _gateway.insert(
|
||||
dataKeyId: keyId,
|
||||
dataKeyId: id.value,
|
||||
kind: kind,
|
||||
studysetId: studysetId,
|
||||
studysetId: id.studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
_rows = [
|
||||
..._rows,
|
||||
(
|
||||
id: keyId,
|
||||
dataKeyId: keyId,
|
||||
id: id.value,
|
||||
dataKeyId: id.value,
|
||||
kind: kind,
|
||||
studysetId: studysetId,
|
||||
studysetId: id.studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
];
|
||||
return keyId;
|
||||
_learn(id);
|
||||
return id.value;
|
||||
}
|
||||
|
||||
/// Wipes the keys kept open here.
|
||||
void dispose() => cipher.dispose();
|
||||
}
|
||||
|
||||
/// A key's ID: 128 random bits, never derived from anything guessable, as
|
||||
/// ids of synced rows aren't (AGENTS.md).
|
||||
String _newKeyId() {
|
||||
final random = Random.secure();
|
||||
return base64Url
|
||||
.encode([for (var i = 0; i < 16; i++) random.nextInt(256)])
|
||||
.replaceAll('=', '');
|
||||
/// Wipes the keys kept open here, and writes what the device saw.
|
||||
Future<void> dispose() async {
|
||||
cipher.dispose();
|
||||
await ledger.flush();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,15 @@ sealed class const Opened() with Equatable {
|
|||
|
||||
/// The payload as the codecs read it: readable, or opened with its key. A
|
||||
/// blank one is a row being deleted (ADR 0002, rule 2), whatever its key.
|
||||
final class const Readable(final ByteData payload) extends Opened {
|
||||
/// [trusted] unless it is readable without the account's signature: a row
|
||||
/// the instance could have made up, or one a device without the account's
|
||||
/// keys wrote (ADR 0019, the reading of 22 Oct).
|
||||
final class const Readable(
|
||||
final ByteData payload, {
|
||||
final bool trusted = true,
|
||||
}) extends Opened {
|
||||
@override
|
||||
List<Object?> get props => [Uint8List.sublistView(payload)];
|
||||
List<Object?> get props => [Uint8List.sublistView(payload), trusted];
|
||||
}
|
||||
|
||||
/// A payload whose key isn't open on this device, or that doesn't open
|
||||
|
|
@ -29,10 +35,20 @@ final class const Locked() extends Opened;
|
|||
/// Seals and opens every synced payload (ADR 0019), the one place the
|
||||
/// gateways go through: a payload is sealed for its table, row and column,
|
||||
/// so the instance can't move it to another row unnoticed.
|
||||
class PayloadEnvelope(final ContentCipher _cipher) {
|
||||
///
|
||||
/// A readable payload a device holding the account's keys writes carries the
|
||||
/// account's signature (64 bytes) in place of a nonce, for its table, row
|
||||
/// and column: a device can tell it from one the instance made up, and
|
||||
/// trusts the latter nowhere privacy holds (the reading of 22 Oct).
|
||||
class PayloadEnvelope(
|
||||
final ContentCipher _cipher, {
|
||||
|
||||
/// Whether this device can sign: it holds the account's keys.
|
||||
final bool Function()? canSign,
|
||||
}) {
|
||||
/// [plain] as the row keeps it: sealed with the key [keyId], readable
|
||||
/// without one. A blank payload stays blank. Throws a [StateError] for a
|
||||
/// key not open on this device.
|
||||
/// without one, and then signed where this device can. A blank payload
|
||||
/// stays blank. Throws a [StateError] for a key not open on this device.
|
||||
Envelope seal(
|
||||
ByteData plain, {
|
||||
required String? keyId,
|
||||
|
|
@ -40,9 +56,21 @@ class PayloadEnvelope(final ContentCipher _cipher) {
|
|||
required String id,
|
||||
String column = 'payload',
|
||||
}) {
|
||||
if (keyId == null || plain.lengthInBytes == 0) {
|
||||
if (plain.lengthInBytes == 0) {
|
||||
return (keyId: keyId, nonce: null, payload: plain);
|
||||
}
|
||||
if (keyId == null) {
|
||||
final signs = canSign?.call() ?? false;
|
||||
return (
|
||||
keyId: null,
|
||||
nonce: signs
|
||||
? ByteData.sublistView(
|
||||
_cipher.sign(_readableMessage(table, id, column, plain)),
|
||||
)
|
||||
: null,
|
||||
payload: plain,
|
||||
);
|
||||
}
|
||||
final sealed = _cipher.seal(
|
||||
Uint8List.sublistView(plain),
|
||||
keyId: keyId,
|
||||
|
|
@ -64,9 +92,20 @@ class PayloadEnvelope(final ContentCipher _cipher) {
|
|||
String column = 'payload',
|
||||
}) {
|
||||
final (:keyId, :nonce, :payload) = envelope;
|
||||
if (keyId == null || payload.lengthInBytes == 0) {
|
||||
if (payload.lengthInBytes == 0) {
|
||||
return Readable(payload);
|
||||
}
|
||||
if (keyId == null) {
|
||||
return Readable(
|
||||
payload,
|
||||
trusted:
|
||||
nonce != null &&
|
||||
_cipher.verify(
|
||||
_readableMessage(table, id, column, payload),
|
||||
Uint8List.sublistView(nonce),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (nonce == null) {
|
||||
return const Locked();
|
||||
}
|
||||
|
|
@ -86,4 +125,16 @@ class PayloadEnvelope(final ContentCipher _cipher) {
|
|||
/// What binds a payload to where it is stored.
|
||||
static List<int> _context(String table, String id, String column) =>
|
||||
utf8.encode('$table|$id|$column');
|
||||
|
||||
/// What a readable payload's signature is over: that it is readable, where
|
||||
/// it is stored, and what it says.
|
||||
static List<int> _readableMessage(
|
||||
String table,
|
||||
String id,
|
||||
String column,
|
||||
ByteData payload,
|
||||
) => [
|
||||
...utf8.encode('readable|$table|$id|$column|'),
|
||||
...Uint8List.sublistView(payload),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,259 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_core/halkyon_core.dart' show SerialQueue;
|
||||
|
||||
/// The latest signed fact this device has seen about a studyset's privacy
|
||||
/// (ADR 0019, the reading of 22 Oct): private with `keyId` since `at`, or
|
||||
/// made readable at `at` (`keyId` null).
|
||||
typedef TreeFact = ({int at, String? keyId});
|
||||
|
||||
/// A rewrite of a tree to another key that hasn't finished on this device:
|
||||
/// the studyset at its top and the key it goes to (null: readable).
|
||||
typedef PendingRewrite = ({String studysetId, String? keyId});
|
||||
|
||||
/// Where the device keeps what it has seen of the person's privacy: in their
|
||||
/// folder, never synced, so the instance can't take it back by withholding
|
||||
/// or replaying rows. A fake in tests.
|
||||
abstract interface class PrivacyLedgerStore() {
|
||||
/// What was kept; null when nothing is, or it can't be read.
|
||||
Future<Map<String, Object?>?> read();
|
||||
|
||||
/// Keeps [ledger] in place of what was kept.
|
||||
Future<void> write(Map<String, Object?> ledger);
|
||||
}
|
||||
|
||||
/// [PrivacyLedgerStore] in a JSON file, written whole through a temporary
|
||||
/// file, so a crash leaves the old one.
|
||||
class FilePrivacyLedgerStore(final File _file) implements PrivacyLedgerStore {
|
||||
final _queue = SerialQueue();
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>?> read() async {
|
||||
try {
|
||||
return jsonDecode(await _file.readAsString()) as Map<String, Object?>;
|
||||
} on FileSystemException {
|
||||
return null;
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'What the device saw of privacy is unreadable',
|
||||
name: 'Privacy',
|
||||
error: error.runtimeType,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, Object?> ledger) => _queue.run(() async {
|
||||
await _file.parent.create(recursive: true);
|
||||
final temporary = File('${_file.path}.new');
|
||||
await temporary.writeAsString(jsonEncode(ledger), flush: true);
|
||||
await temporary.rename(_file.path);
|
||||
});
|
||||
}
|
||||
|
||||
/// [PrivacyLedgerStore] in memory, for tests.
|
||||
class MemoryPrivacyLedgerStore() implements PrivacyLedgerStore {
|
||||
/// What is kept.
|
||||
Map<String, Object?>? kept;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>?> read() async => kept;
|
||||
|
||||
@override
|
||||
Future<void> write(Map<String, Object?> ledger) async =>
|
||||
kept = jsonDecode(jsonEncode(ledger)) as Map<String, Object?>;
|
||||
}
|
||||
|
||||
/// What this device has seen of the person's privacy (ADR 0019, the reading
|
||||
/// of 22 Oct), which the instance can't take back: the latest signed fact
|
||||
/// about each studyset's privacy, the key each item was last seen sealed
|
||||
/// with, whether its tags and settings were sealed once, the highest
|
||||
/// settings counter seen, and the rewrites still to finish. Only ever moves
|
||||
/// forward, but by this device's own changes.
|
||||
class PrivacyLedger(final PrivacyLedgerStore _store) {
|
||||
final _facts = <String, TreeFact>{};
|
||||
final _items = <String, String>{};
|
||||
final _pending = <PendingRewrite>[];
|
||||
var _personalSealed = false;
|
||||
final _settingsCounters = <String, int>{};
|
||||
|
||||
Future<void>? _saving;
|
||||
var _dirty = false;
|
||||
|
||||
/// Reads what was kept; once, before anything else.
|
||||
Future<void> open() async {
|
||||
final kept = await _store.read();
|
||||
if (kept == null) {
|
||||
return;
|
||||
}
|
||||
if (kept['facts'] case final Map<String, Object?> facts) {
|
||||
for (final MapEntry(:key, :value) in facts.entries) {
|
||||
if (value case {'at': final int at, 'key': final String? keyId}) {
|
||||
_facts[key] = (at: at, keyId: keyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kept['items'] case final Map<String, Object?> items) {
|
||||
for (final MapEntry(:key, :value) in items.entries) {
|
||||
if (value case final String keyId) {
|
||||
_items[key] = keyId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kept['pending'] case final List<Object?> pending) {
|
||||
for (final item in pending) {
|
||||
if (item case {'studyset': final String id, 'key': final String? key}) {
|
||||
_pending.add((studysetId: id, keyId: key));
|
||||
}
|
||||
}
|
||||
}
|
||||
_personalSealed = kept['personalSealed'] == true;
|
||||
if (kept['settingsCounters'] case final Map<String, Object?> counters) {
|
||||
for (final MapEntry(:key, :value) in counters.entries) {
|
||||
if (value case final int counter) {
|
||||
_settingsCounters[key] = counter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest signed fact seen about [studysetId]'s privacy.
|
||||
TreeFact? factOf(String studysetId) => _facts[studysetId];
|
||||
|
||||
/// Keeps [fact] about [studysetId] if it is later than the one kept;
|
||||
/// whether it was.
|
||||
bool learnFact(String studysetId, TreeFact fact) {
|
||||
final known = _facts[studysetId];
|
||||
if (known != null && known.at >= fact.at) {
|
||||
return false;
|
||||
}
|
||||
_facts[studysetId] = fact;
|
||||
_changed();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Every studyset a fact is kept about.
|
||||
Iterable<String> get studysetsWithFacts => _facts.keys;
|
||||
|
||||
/// The key [item] (`card:<id>`, `studyset:<id>`, …) was last seen sealed
|
||||
/// with; null for one never seen sealed, or released.
|
||||
String? keyOfItem(String item) => _items[item];
|
||||
|
||||
/// Keeps that [item] was seen sealed with [keyId].
|
||||
void sealedWith(String item, String keyId) {
|
||||
if (_items[item] != keyId) {
|
||||
_items[item] = keyId;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets [item]'s key: this device made it readable, or a signed
|
||||
/// record ended its key.
|
||||
void release(String item) {
|
||||
if (_items.remove(item) != null) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets every item kept under one of [keyIds]: a signed record made
|
||||
/// their tree readable.
|
||||
void releaseKeys(Set<String> keyIds) {
|
||||
final before = _items.length;
|
||||
_items.removeWhere((_, keyId) => keyIds.contains(keyId));
|
||||
if (_items.length != before) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this device sealed the person's readable tags and settings
|
||||
/// once their personal key existed, after which a readable one is no
|
||||
/// longer trusted.
|
||||
bool get personalSealed => _personalSealed;
|
||||
|
||||
set personalSealed(bool sealed) {
|
||||
if (_personalSealed != sealed) {
|
||||
_personalSealed = sealed;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest counter seen in the settings row [rowId]: a lower one is a
|
||||
/// rollback, and refused; 0 for one never seen.
|
||||
int settingsCounterOf(String rowId) => _settingsCounters[rowId] ?? 0;
|
||||
|
||||
/// Keeps [counter] for the settings row [rowId] if it is higher than the
|
||||
/// one kept.
|
||||
void sawSettingsCounter(String rowId, int counter) {
|
||||
if (counter > settingsCounterOf(rowId)) {
|
||||
_settingsCounters[rowId] = counter;
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The rewrites still to finish here, oldest first.
|
||||
List<PendingRewrite> get pending => List.unmodifiable(_pending);
|
||||
|
||||
/// Keeps that [rewrite] is under way, in place of any for its studyset.
|
||||
void begin(PendingRewrite rewrite) {
|
||||
_pending
|
||||
..removeWhere((p) => p.studysetId == rewrite.studysetId)
|
||||
..add(rewrite);
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Forgets [rewrite]: it finished.
|
||||
void finish(PendingRewrite rewrite) {
|
||||
if (_pending.remove(rewrite)) {
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes what changed, if anything did; done after each change anyway,
|
||||
/// one write at a time.
|
||||
Future<void> flush() async {
|
||||
while (_saving != null) {
|
||||
await _saving;
|
||||
}
|
||||
if (_dirty) {
|
||||
await _save();
|
||||
}
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
_dirty = true;
|
||||
_saving ??= _save().whenComplete(() => _saving = null);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
// Changes made while writing are written next.
|
||||
while (_dirty) {
|
||||
_dirty = false;
|
||||
try {
|
||||
await _store.write({
|
||||
'facts': {
|
||||
for (final MapEntry(:key, :value) in _facts.entries)
|
||||
key: {'at': value.at, 'key': value.keyId},
|
||||
},
|
||||
'items': _items,
|
||||
'pending': [
|
||||
for (final p in _pending)
|
||||
{'studyset': p.studysetId, 'key': p.keyId},
|
||||
],
|
||||
'personalSealed': _personalSealed,
|
||||
'settingsCounters': _settingsCounters,
|
||||
});
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'What the device saw of privacy was not kept',
|
||||
name: 'Privacy',
|
||||
error: error.runtimeType,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@ abstract interface class TreeFiles() {
|
|||
/// The key the asset row [id] is sealed with; null for a readable one.
|
||||
Future<String?> keyIdOf(String id);
|
||||
|
||||
/// Whether the asset row [id] is there: content may name one that isn't,
|
||||
/// which has nothing to move.
|
||||
Future<bool> exists(String id);
|
||||
|
||||
/// Adds [bytes] as a file sealed with [keyId] (readable without), and
|
||||
/// returns its asset row's id: one of that key holding the same file, if
|
||||
/// there is one.
|
||||
|
|
|
|||
|
|
@ -49,34 +49,59 @@ class SyncCardGateway(
|
|||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
final ids = {
|
||||
for (final id in cardIds) protocol.UuidValue.withValidation(id),
|
||||
};
|
||||
final treeOf = await _treeKeysOf(
|
||||
await protocol.Card.db.find(_db, where: (t) => t.id.inSet(ids)),
|
||||
);
|
||||
return [
|
||||
for (final revision in await protocol.CardRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.cardId.inSet({
|
||||
for (final id in cardIds) protocol.UuidValue.withValidation(id),
|
||||
}),
|
||||
where: (t) => t.cardId.inSet(ids),
|
||||
))
|
||||
_revisionRow(revision),
|
||||
_revisionRow(revision, treeOf(revision.cardId.uuid)),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CardRevisionRow>> allRevisions() async {
|
||||
await _privacy.keys.refresh();
|
||||
final treeOf = await _treeKeysOf(await protocol.Card.db.find(_db));
|
||||
return [
|
||||
for (final revision in await protocol.CardRevision.db.find(_db))
|
||||
_revisionRow(revision),
|
||||
_revisionRow(revision, treeOf(revision.cardId.uuid)),
|
||||
];
|
||||
}
|
||||
|
||||
CardRevisionRow _revisionRow(protocol.CardRevision revision) => (
|
||||
/// Each of [cards]' tree keys, by card.
|
||||
Future<String? Function(String cardId)> _treeKeysOf(
|
||||
List<protocol.Card> cards,
|
||||
) async {
|
||||
final treeKey = await _privacy.treeKeys();
|
||||
final studysetOf = {
|
||||
for (final card in cards) card.id!.uuid: card.studysetId.uuid,
|
||||
};
|
||||
String? of(String cardId) => switch (studysetOf[cardId]) {
|
||||
final studyset? => treeKey(studyset),
|
||||
null => null,
|
||||
};
|
||||
return of;
|
||||
}
|
||||
|
||||
CardRevisionRow _revisionRow(
|
||||
protocol.CardRevision revision,
|
||||
String? treeKey,
|
||||
) => (
|
||||
id: revision.id!.uuid,
|
||||
cardId: revision.cardId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: switch (_privacy.open(
|
||||
payload: switch (_privacy.openItem(
|
||||
(keyId: revision.keyId, nonce: revision.nonce, payload: revision.payload),
|
||||
table: 'card_revision',
|
||||
id: revision.id!.uuid,
|
||||
item: 'card:${revision.cardId.uuid}',
|
||||
treeKey: treeKey,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
|
|
@ -96,6 +121,7 @@ class SyncCardGateway(
|
|||
keyId: keyId,
|
||||
table: 'card_revision',
|
||||
id: id.uuid,
|
||||
item: 'card:${cardId.uuid}',
|
||||
);
|
||||
return protocol.CardRevision(
|
||||
id: id,
|
||||
|
|
@ -208,6 +234,21 @@ class SyncCardGateway(
|
|||
if (card == null) {
|
||||
return;
|
||||
}
|
||||
// Its revisions and files take the key of the tree it moves into
|
||||
// before it moves, so a move cut short leaves nothing readable; never
|
||||
// out of a private tree into a readable one, which the person is asked
|
||||
// to make private first (the reading of 22 Oct).
|
||||
final from = await _privacy.keyOfCard(cardId);
|
||||
final into = await _privacy.keyOfStudyset(studysetId);
|
||||
if (from != null && into == null) {
|
||||
throw const PrivacyRefused(PrivacyRefusal.movedIntoReadable);
|
||||
}
|
||||
if (into != null && into != from) {
|
||||
if (!_privacy.keys.canOpen(into)) {
|
||||
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
|
||||
}
|
||||
await _privacy.rekeyCards({cardId}, into, adopt: from == null);
|
||||
}
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Card.db.updateRow(
|
||||
_db,
|
||||
|
|
|
|||
|
|
@ -13,11 +13,21 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_reposit
|
|||
/// The choice stays in the person's settings, which follow them to every
|
||||
/// device; this mirrors it after each sync, and reports the copies only
|
||||
/// while it is on.
|
||||
///
|
||||
/// A device without the account's keys, on an account that has them, says
|
||||
/// nothing: it can't read the choice, and would turn it off (the reading of
|
||||
/// 22 Oct). The same report isn't sent twice in a row.
|
||||
class LearningCounting(
|
||||
final LearningCountGateway _gateway,
|
||||
final SettingsRepository _settings,
|
||||
final StudysetRepository _studysets,
|
||||
) {
|
||||
final StudysetRepository _studysets, {
|
||||
|
||||
/// Whether this device can read the person's choice.
|
||||
final bool Function()? canCount,
|
||||
}) {
|
||||
/// The copies last reported, so the same report isn't sent again.
|
||||
Map<String, Set<String>>? _reported;
|
||||
|
||||
late final _runner = CoalescingRunner(
|
||||
_report,
|
||||
onError: (error, st) =>
|
||||
|
|
@ -35,8 +45,12 @@ class LearningCounting(
|
|||
Future<void> report() => _runner.run();
|
||||
|
||||
Future<void> _report() async {
|
||||
if (!(canCount?.call() ?? true)) {
|
||||
return;
|
||||
}
|
||||
final counted = (await _settings.read()).countLearning;
|
||||
if (!counted) {
|
||||
_reported = null;
|
||||
if (await _gateway.counted()) {
|
||||
await _gateway.setCounted(counted: false);
|
||||
}
|
||||
|
|
@ -51,9 +65,25 @@ class LearningCounting(
|
|||
(copies[publication] ??= {}).add(studyset.id);
|
||||
}
|
||||
}
|
||||
if (_sameAs(_reported, copies)) {
|
||||
return;
|
||||
}
|
||||
await _gateway.reportCopies(copies);
|
||||
_reported = copies;
|
||||
}
|
||||
|
||||
static bool _sameAs(
|
||||
Map<String, Set<String>>? before,
|
||||
Map<String, Set<String>> now,
|
||||
) =>
|
||||
before != null &&
|
||||
before.length == now.length &&
|
||||
now.entries.every(
|
||||
(entry) =>
|
||||
before[entry.key]?.length == entry.value.length &&
|
||||
before[entry.key]!.containsAll(entry.value),
|
||||
);
|
||||
|
||||
/// Stops following the syncs.
|
||||
Future<void> dispose() async {
|
||||
await _subscription?.cancel();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class SyncLearningPathGateway(
|
|||
@override
|
||||
Future<List<PathRow>> paths({String? studysetId}) async {
|
||||
await _privacy.keys.refresh();
|
||||
final treeKey = await _privacy.treeKeys();
|
||||
return [
|
||||
for (final path in await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
|
|
@ -37,17 +38,31 @@ class SyncLearningPathGateway(
|
|||
'learning_path',
|
||||
path.id!.uuid,
|
||||
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
|
||||
item: 'path:${path.id!.uuid}',
|
||||
treeKey: treeKey(path.studysetId.uuid),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// [envelope]'s payload opened, blank where it doesn't open here.
|
||||
ByteData _opened(String table, String id, Envelope envelope) =>
|
||||
switch (_privacy.open(envelope, table: table, id: id)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
};
|
||||
/// [envelope]'s payload opened as [item]'s in a tree with [treeKey],
|
||||
/// blank where it doesn't open here, or isn't trusted there.
|
||||
ByteData _opened(
|
||||
String table,
|
||||
String id,
|
||||
Envelope envelope, {
|
||||
required String item,
|
||||
required String? treeKey,
|
||||
}) => switch (_privacy.openItem(
|
||||
envelope,
|
||||
table: table,
|
||||
id: id,
|
||||
item: item,
|
||||
treeKey: treeKey,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<String> insertPath({
|
||||
|
|
@ -61,6 +76,7 @@ class SyncLearningPathGateway(
|
|||
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||
table: 'learning_path',
|
||||
id: id.uuid,
|
||||
item: 'path:${id.uuid}',
|
||||
);
|
||||
final path = await protocol.LearningPath.db.insertRow(
|
||||
_db,
|
||||
|
|
@ -83,9 +99,10 @@ class SyncLearningPathGateway(
|
|||
case final path?) {
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(path.studysetId.uuid),
|
||||
keyId: await _privacy.keyOfPath(id),
|
||||
table: 'learning_path',
|
||||
id: id,
|
||||
item: 'path:$id',
|
||||
);
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.LearningPath.db.updateRow(
|
||||
|
|
@ -152,20 +169,49 @@ class SyncLearningPathGateway(
|
|||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
final ids = {for (final id in stepIds) _uuid(id)};
|
||||
final treeKey = await _privacy.treeKeys();
|
||||
final pathOf = {
|
||||
for (final step in await protocol.LearningPathStep.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(ids),
|
||||
))
|
||||
step.id!.uuid: step.pathId,
|
||||
};
|
||||
final studysetOf = pathOf.isEmpty
|
||||
? const <String, String>{}
|
||||
: {
|
||||
for (final path in await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet({...pathOf.values}),
|
||||
))
|
||||
path.id!.uuid: path.studysetId.uuid,
|
||||
};
|
||||
String? treeKeyOf(String stepId) =>
|
||||
switch (studysetOf[pathOf[stepId]?.uuid]) {
|
||||
final studyset? => treeKey(studyset),
|
||||
null => null,
|
||||
};
|
||||
return [
|
||||
for (final revision in await protocol.LearningPathStepRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.stepId.inSet({for (final id in stepIds) _uuid(id)}),
|
||||
where: (t) => t.stepId.inSet(ids),
|
||||
))
|
||||
(
|
||||
id: revision.id!.uuid,
|
||||
stepId: revision.stepId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: _opened('learning_path_step_revision', revision.id!.uuid, (
|
||||
keyId: revision.keyId,
|
||||
nonce: revision.nonce,
|
||||
payload: revision.payload,
|
||||
)),
|
||||
payload: _opened(
|
||||
'learning_path_step_revision',
|
||||
revision.id!.uuid,
|
||||
(
|
||||
keyId: revision.keyId,
|
||||
nonce: revision.nonce,
|
||||
payload: revision.payload,
|
||||
),
|
||||
item: 'step:${revision.stepId.uuid}',
|
||||
treeKey: treeKeyOf(revision.stepId.uuid),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -253,6 +299,7 @@ class SyncLearningPathGateway(
|
|||
keyId: keyId,
|
||||
table: 'learning_path_step_revision',
|
||||
id: id.uuid,
|
||||
item: 'step:${stepId.uuid}',
|
||||
);
|
||||
return protocol.LearningPathStepRevision(
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class SyncPathAnswerGateway(
|
|||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
final treeKey = await _privacy.stepTreeKeys(stepIds);
|
||||
return [
|
||||
for (final answer in await protocol.LearningPathAnswer.db.find(
|
||||
_db,
|
||||
|
|
@ -37,13 +38,14 @@ class SyncPathAnswerGateway(
|
|||
stepId: answer.stepId.uuid,
|
||||
answeredAt: answer.answeredAt,
|
||||
outcome: answer.outcome,
|
||||
answer: _opened(answer),
|
||||
answer: _opened(answer, treeKey(answer.stepId.uuid)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// [row]'s answer as written, null where it has none or it doesn't open.
|
||||
String? _opened(protocol.LearningPathAnswer row) {
|
||||
/// [row]'s answer as written, null where it has none, it doesn't open,
|
||||
/// or it isn't trusted in a tree with [treeKey].
|
||||
String? _opened(protocol.LearningPathAnswer row, String? treeKey) {
|
||||
final answer = row.answer;
|
||||
final envelope = answer == null
|
||||
? null
|
||||
|
|
@ -51,11 +53,13 @@ class SyncPathAnswerGateway(
|
|||
if (envelope == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_privacy.open(
|
||||
return switch (_privacy.openItem(
|
||||
envelope,
|
||||
table: 'learning_path_answer',
|
||||
id: row.id!.uuid,
|
||||
column: 'answer',
|
||||
item: 'answers:${row.stepId.uuid}',
|
||||
treeKey: treeKey,
|
||||
)) {
|
||||
Readable(:final payload) => utf8.decode(
|
||||
Uint8List.sublistView(payload),
|
||||
|
|
@ -72,7 +76,7 @@ class SyncPathAnswerGateway(
|
|||
}
|
||||
final keys = <String, String?>{
|
||||
for (final stepId in {for (final answer in answers) answer.stepId})
|
||||
stepId: await _privacy.keyOfStep(stepId),
|
||||
stepId: await _privacy.keyOfAnswer(stepId),
|
||||
};
|
||||
await protocol.LearningPathAnswer.db.insert(_db, [
|
||||
for (final answer in answers) _newAnswer(answer, keys[answer.stepId]),
|
||||
|
|
@ -92,6 +96,7 @@ class SyncPathAnswerGateway(
|
|||
table: 'learning_path_answer',
|
||||
id: id.uuid,
|
||||
column: 'answer',
|
||||
item: 'answers:${answer.stepId}',
|
||||
);
|
||||
return protocol.LearningPathAnswer(
|
||||
id: id,
|
||||
|
|
|
|||
|
|
@ -41,18 +41,27 @@ class SyncNoteGateway(
|
|||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
final ids = {
|
||||
for (final id in noteIds) protocol.UuidValue.withValidation(id),
|
||||
};
|
||||
final treeKey = await _privacy.treeKeys();
|
||||
final studysetOf = {
|
||||
for (final note in await protocol.Note.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(ids),
|
||||
))
|
||||
note.id!.uuid: note.studysetId.uuid,
|
||||
};
|
||||
return [
|
||||
for (final revision in await protocol.NoteRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.noteId.inSet({
|
||||
for (final id in noteIds) protocol.UuidValue.withValidation(id),
|
||||
}),
|
||||
where: (t) => t.noteId.inSet(ids),
|
||||
))
|
||||
(
|
||||
id: revision.id!.uuid,
|
||||
noteId: revision.noteId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: switch (_privacy.open(
|
||||
payload: switch (_privacy.openItem(
|
||||
(
|
||||
keyId: revision.keyId,
|
||||
nonce: revision.nonce,
|
||||
|
|
@ -60,6 +69,11 @@ class SyncNoteGateway(
|
|||
),
|
||||
table: 'note_revision',
|
||||
id: revision.id!.uuid,
|
||||
item: 'note:${revision.noteId.uuid}',
|
||||
treeKey: switch (studysetOf[revision.noteId.uuid]) {
|
||||
final studyset? => treeKey(studyset),
|
||||
null => null,
|
||||
},
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
|
|
@ -102,6 +116,7 @@ class SyncNoteGateway(
|
|||
keyId: await _privacy.keyOfNote(noteId),
|
||||
table: 'note_revision',
|
||||
id: id.uuid,
|
||||
item: 'note:$noteId',
|
||||
);
|
||||
final revision = await protocol.NoteRevision.db.insertRow(
|
||||
_db,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class SyncPdfSourceGateway(
|
|||
@override
|
||||
Future<List<PdfSourceRow>> sources({String? studysetId}) async {
|
||||
await _privacy.keys.refresh();
|
||||
final treeKey = await _privacy.treeKeys();
|
||||
return [
|
||||
for (final source in await protocol.PdfSource.db.find(
|
||||
_db,
|
||||
|
|
@ -26,7 +27,7 @@ class SyncPdfSourceGateway(
|
|||
? null
|
||||
: (t) => t.studysetId.equals(_uuid(studysetId)),
|
||||
))
|
||||
_row(source),
|
||||
_row(source, treeKey(source.studysetId.uuid)),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +42,10 @@ class SyncPdfSourceGateway(
|
|||
return null;
|
||||
}
|
||||
return switch (await protocol.PdfSource.db.findById(_db, uuid)) {
|
||||
final source? => _row(source),
|
||||
final source? => _row(
|
||||
source,
|
||||
await _privacy.keyOfStudyset(source.studysetId.uuid),
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
|
@ -58,6 +62,7 @@ class SyncPdfSourceGateway(
|
|||
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||
table: 'pdf_source',
|
||||
id: id.uuid,
|
||||
item: 'pdf:${id.uuid}',
|
||||
);
|
||||
final source = await protocol.PdfSource.db.insertRow(
|
||||
_db,
|
||||
|
|
@ -80,9 +85,10 @@ class SyncPdfSourceGateway(
|
|||
case final source?) {
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(source.studysetId.uuid),
|
||||
keyId: await _privacy.keyOfPdf(id, source.studysetId.uuid),
|
||||
table: 'pdf_source',
|
||||
id: id,
|
||||
item: 'pdf:$id',
|
||||
);
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.PdfSource.db.updateRow(
|
||||
|
|
@ -186,16 +192,19 @@ class SyncPdfSourceGateway(
|
|||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
|
||||
PdfSourceRow _row(protocol.PdfSource source) => (
|
||||
PdfSourceRow _row(protocol.PdfSource source, String? treeKey) => (
|
||||
id: source.id!.uuid,
|
||||
studysetId: source.studysetId.uuid,
|
||||
position: source.position,
|
||||
// A PDF in a private studyset whose key doesn't open here reads as
|
||||
// blank (ADR 0019).
|
||||
payload: switch (_privacy.open(
|
||||
// A PDF in a private studyset whose key doesn't open here, or that is
|
||||
// readable there without the account's signature, reads as blank (ADR
|
||||
// 0019).
|
||||
payload: switch (_privacy.openItem(
|
||||
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
|
||||
table: 'pdf_source',
|
||||
id: source.id!.uuid,
|
||||
item: 'pdf:${source.id!.uuid}',
|
||||
treeKey: treeKey,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ class PublishRepository(
|
|||
final PublishGateway _gateway,
|
||||
final PublishedStore _store, {
|
||||
|
||||
/// The person's tags' names by their ids, as this device reads them: the
|
||||
/// instance can't read them (ADR 0019, D2), so a release's tags are named
|
||||
/// with them; none without.
|
||||
final Future<Map<String, String>> Function()? _tagNames,
|
||||
/// The names of the tags the items of the studyset with the id given,
|
||||
/// and everything inside it, carry, by their ids, as this device reads
|
||||
/// them: the instance can't read them (ADR 0019, D2), so a release's tags
|
||||
/// are named with them, and no other tag's name is sent (the reading of
|
||||
/// 22 Oct); none without.
|
||||
final Future<Map<String, String>> Function(String studysetId)? _tagNames,
|
||||
}) {
|
||||
final _changes = StreamController<Set<String>>.broadcast();
|
||||
|
||||
|
|
@ -66,7 +68,7 @@ class PublishRepository(
|
|||
slug: slug.trim(),
|
||||
notes: notes.trim(),
|
||||
description: description,
|
||||
tagNames: await _tagNames?.call(),
|
||||
tagNames: await _tagNames?.call(studysetId),
|
||||
);
|
||||
await _rememberOne(studysetId, out: true);
|
||||
return publication;
|
||||
|
|
|
|||
|
|
@ -21,22 +21,34 @@ class SyncSettingsGateway(
|
|||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final row in await protocol.PersonSettings.db.find(_db))
|
||||
if (_privacy.open(
|
||||
if (_privacy.openItem(
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
table: 'person_settings',
|
||||
id: row.id!.uuid,
|
||||
item: 'settings:${row.id!.uuid}',
|
||||
treeKey: null,
|
||||
protected: _privacy.personalProtected,
|
||||
)
|
||||
case Readable(:final payload))
|
||||
(id: row.id!.uuid, payload: payload),
|
||||
if (_counted(row.id!.uuid, sealed: row.keyId != null, payload)
|
||||
case final payload?)
|
||||
(id: row.id!.uuid, payload: payload),
|
||||
];
|
||||
}
|
||||
|
||||
ByteData? _counted(String id, ByteData payload, {required bool sealed}) =>
|
||||
sealed ? _privacy.settingsWithoutCounter(id, payload) : payload;
|
||||
|
||||
ByteData _withCounter(String id, ByteData payload, String? keyId) =>
|
||||
keyId == null ? payload : _privacy.settingsWithCounter(id, payload);
|
||||
|
||||
@override
|
||||
Future<String> insert(ByteData payload) async {
|
||||
final id = Privacy.newId();
|
||||
final keyId = await _privacy.personalKey();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.personalKey(),
|
||||
_withCounter(id.uuid, payload, keyId),
|
||||
keyId: keyId,
|
||||
table: 'person_settings',
|
||||
id: id.uuid,
|
||||
);
|
||||
|
|
@ -59,9 +71,10 @@ class SyncSettingsGateway(
|
|||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
final keyId = await _privacy.personalKey();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.personalKey(),
|
||||
_withCounter(id, payload, keyId),
|
||||
keyId: keyId,
|
||||
table: 'person_settings',
|
||||
id: id,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import 'dart:typed_data';
|
|||
/// A studyset's row as the device's database stores it: the payload, and the
|
||||
/// columns that change on their own (ADR 0004). Whether it is private (ADR
|
||||
/// 0019): its payload is opened with its tree's key, and `locked` where
|
||||
/// that key doesn't open on this device, its payload then blank.
|
||||
/// that key doesn't open on this device, its payload then blank;
|
||||
/// `fullyPrivate` while nothing in its tree was left readable or unsealed;
|
||||
/// `keysHere` where this device holds the account's keys.
|
||||
typedef StudysetRow = ({
|
||||
String id,
|
||||
ByteData payload,
|
||||
|
|
@ -14,7 +16,9 @@ typedef StudysetRow = ({
|
|||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
bool isPrivate,
|
||||
bool fullyPrivate,
|
||||
bool locked,
|
||||
bool keysHere,
|
||||
});
|
||||
|
||||
/// The boundary to the studysets in this person's local, synced database.
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
updatedAt: row.updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
isLocked: row.locked,
|
||||
isPartlyReadable: row.isPrivate && !row.fullyPrivate,
|
||||
keysOnDevice: row.keysHere,
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue