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
|
@override
|
||||||
Future<String?> keyIdOf(String id) async => (await _rows.byId(id))?.keyId;
|
Future<String?> keyIdOf(String id) async => (await _rows.byId(id))?.keyId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> exists(String id) async => await _rows.byId(id) != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
Future<({Uint8List bytes, String fileName, String mimeType})?> plainOf(
|
||||||
String id,
|
String id,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ class SyncAssetGateway(
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'asset',
|
table: 'asset',
|
||||||
id: id,
|
id: id,
|
||||||
|
item: 'asset:$id',
|
||||||
);
|
);
|
||||||
final asset = await protocol.Asset.db.insertRow(
|
final asset = await protocol.Asset.db.insertRow(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -104,10 +105,15 @@ class SyncAssetGateway(
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return 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),
|
(keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
|
||||||
table: 'asset',
|
table: 'asset',
|
||||||
id: asset.id!.uuid,
|
id: asset.id!.uuid,
|
||||||
|
item: 'asset:${asset.id!.uuid}',
|
||||||
|
treeKey: null,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
id: asset.id!.uuid,
|
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/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/content_keys.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.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/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/version/app_version.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/media_players.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_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.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/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/sync_tag_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
@ -308,7 +310,11 @@ class Dependencies({
|
||||||
await folders.openOnly(person.authUserId),
|
await folders.openOnly(person.authUserId),
|
||||||
accountKeys: () async {
|
accountKeys: () async {
|
||||||
final kept = await accountKeys.read(person.authUserId);
|
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;
|
return kept;
|
||||||
}
|
}
|
||||||
// An anonymous person's device makes the keys their private
|
// An anonymous person's device makes the keys their private
|
||||||
|
|
@ -432,12 +438,19 @@ Future<PersonDependencies> _openPersonData(
|
||||||
)..start();
|
)..start();
|
||||||
// The person's keys, with which private studysets, their tags and their
|
// The person's keys, with which private studysets, their tags and their
|
||||||
// settings are sealed and opened (ADR 0019).
|
// 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(
|
final contentKeys = ContentKeys(
|
||||||
SyncContentKeyGateway(db, sync),
|
SyncContentKeyGateway(db, sync),
|
||||||
SodiumContentCipher(),
|
SodiumContentCipher(),
|
||||||
accountKeys,
|
accountKeys,
|
||||||
|
seen: privacyLedger,
|
||||||
);
|
);
|
||||||
final privacy = Privacy(db, contentKeys);
|
final privacy = Privacy(db, contentKeys)..requestSync = sync.requestSync;
|
||||||
await contentKeys.refresh();
|
await contentKeys.refresh();
|
||||||
final studysets = StudysetRepository(
|
final studysets = StudysetRepository(
|
||||||
SyncStudysetGateway(db, sync, privacy),
|
SyncStudysetGateway(db, sync, privacy),
|
||||||
|
|
@ -469,6 +482,7 @@ Future<PersonDependencies> _openPersonData(
|
||||||
ServerpodLearningCountGateway(client),
|
ServerpodLearningCountGateway(client),
|
||||||
settings,
|
settings,
|
||||||
studysets,
|
studysets,
|
||||||
|
canCount: () => contentKeys.canEncrypt || !contentKeys.accountHasKeys,
|
||||||
)..start(sync.synced))
|
)..start(sync.synced))
|
||||||
: null;
|
: null;
|
||||||
final assets = AssetRepository(
|
final assets = AssetRepository(
|
||||||
|
|
@ -478,8 +492,12 @@ Future<PersonDependencies> _openPersonData(
|
||||||
privacy,
|
privacy,
|
||||||
uploadTriggers: sync.synced,
|
uploadTriggers: sync.synced,
|
||||||
);
|
);
|
||||||
// A tree made private or not, or moved, takes its files along (ADR 0019).
|
// A tree made private or not, or moved, takes its files along (ADR 0019);
|
||||||
privacy.files = assets;
|
// 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(
|
final pdfSources = PdfSourceRepository(
|
||||||
SyncPdfSourceGateway(db, sync, privacy),
|
SyncPdfSourceGateway(db, sync, privacy),
|
||||||
assets,
|
assets,
|
||||||
|
|
@ -508,8 +526,23 @@ Future<PersonDependencies> _openPersonData(
|
||||||
final publishing = PublishRepository(
|
final publishing = PublishRepository(
|
||||||
ServerpodPublishGateway(client),
|
ServerpodPublishGateway(client),
|
||||||
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
|
||||||
tagNames: () async => {
|
tagNames: (studysetId) async {
|
||||||
for (final tag in (await tags.read()).all) tag.id: tag.name,
|
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(
|
final studysetTabs = StudysetTabs(
|
||||||
|
|
@ -586,11 +619,13 @@ Future<PersonDependencies> _openPersonData(
|
||||||
await pdfSources.dispose();
|
await pdfSources.dispose();
|
||||||
await assets.dispose();
|
await assets.dispose();
|
||||||
await publishing.dispose();
|
await publishing.dispose();
|
||||||
|
await privacy.dispose();
|
||||||
await sync.dispose();
|
await sync.dispose();
|
||||||
await db.close();
|
await db.close();
|
||||||
} finally {
|
} finally {
|
||||||
// The keys opened here go once nothing can write any more.
|
// The keys opened here go once nothing can write any more, and what
|
||||||
contentKeys.dispose();
|
// the device saw of privacy is written.
|
||||||
|
await contentKeys.dispose();
|
||||||
onClosed();
|
onClosed();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,15 @@ enum ContentKeyKind() {
|
||||||
|
|
||||||
/// The person's own: their tags and settings.
|
/// The person's own: their tags and settings.
|
||||||
personal,
|
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 = ({
|
typedef ContentKeyRow = ({
|
||||||
String id,
|
String id,
|
||||||
String dataKeyId,
|
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:developer';
|
||||||
import 'dart:math' show Random;
|
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_core/halkyon_core.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_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
|
/// The person's data keys on this device (ADR 0019): each private
|
||||||
/// studyset's and their personal key, sealed to their account and synced,
|
/// 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
|
/// 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
|
/// without its password here, or one that has none yet, D8) nothing opens
|
||||||
/// and nothing new is made private.
|
/// 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(
|
class ContentKeys(
|
||||||
final ContentKeyGateway _gateway,
|
final ContentKeyGateway _gateway,
|
||||||
|
|
||||||
|
|
@ -19,13 +25,26 @@ class ContentKeys(
|
||||||
final ContentCipher cipher,
|
final ContentCipher cipher,
|
||||||
|
|
||||||
/// The account's keys on this device; null where none are.
|
/// 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 _unlocked = false;
|
||||||
var _rows = const <ContentKeyRow>[];
|
var _rows = const <ContentKeyRow>[];
|
||||||
|
|
||||||
/// The keys that wouldn't open, never tried again.
|
/// The rows whose key wouldn't open, by row: a forged row never shadows
|
||||||
final _refused = <String>{};
|
/// 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(
|
late final _refreshing = CoalescingRunner(
|
||||||
_refresh,
|
_refresh,
|
||||||
|
|
@ -37,6 +56,12 @@ class ContentKeys(
|
||||||
/// made private; known once [refresh] ran.
|
/// made private; known once [refresh] ran.
|
||||||
bool get canEncrypt => _unlocked;
|
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,
|
/// Opens the keys kept that aren't open yet: as the person's data opens,
|
||||||
/// and once a sync may have brought more.
|
/// and once a sync may have brought more.
|
||||||
Future<void> refresh() => _refreshing.run();
|
Future<void> refresh() => _refreshing.run();
|
||||||
|
|
@ -44,83 +69,129 @@ class ContentKeys(
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
if (!_unlocked) {
|
if (!_unlocked) {
|
||||||
final account = await _accountKeys();
|
final account = await _accountKeys();
|
||||||
if (account == null) {
|
if (account != null) {
|
||||||
return;
|
await cipher.unlock(account);
|
||||||
|
_unlocked = true;
|
||||||
}
|
}
|
||||||
await cipher.unlock(account);
|
|
||||||
_unlocked = true;
|
|
||||||
}
|
}
|
||||||
_rows = await _gateway.all();
|
_rows = await _gateway.all();
|
||||||
|
if (!_unlocked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (final row in _rows) {
|
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)) {
|
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.
|
/// Whether the key [keyId] is open here.
|
||||||
bool canOpen(String keyId) => cipher.has(keyId);
|
bool canOpen(String keyId) => cipher.has(keyId);
|
||||||
|
|
||||||
/// A new key for the private studyset [studysetId], kept and synced; its
|
/// A new key for the private studyset [studysetId], kept and synced, and
|
||||||
/// ID. Throws a [StateError] where this device can't encrypt.
|
/// the fact that it is private from now on; its ID. Throws a
|
||||||
Future<String> newStudysetKey(String studysetId) =>
|
/// [StateError] where this device can't encrypt.
|
||||||
_newKey(ContentKeyKind.studyset, studysetId);
|
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;
|
/// 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
|
/// null where this device can't encrypt. Two devices making one at once
|
||||||
/// each keep theirs, every device opening both; the oldest is written
|
/// 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 {
|
Future<String?> personalKey() async {
|
||||||
if (!_unlocked) {
|
if (!_unlocked) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final personal = [
|
if (_personal.isEmpty) {
|
||||||
for (final row in _rows)
|
return await _newKey(ContentKeyId.next(ContentKeyScope.personal));
|
||||||
if (row.kind == ContentKeyKind.personal && canOpen(row.dataKeyId)) row,
|
}
|
||||||
]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
final oldest = [..._personal]
|
||||||
return personal.firstOrNull?.dataKeyId ??
|
..sort(
|
||||||
await _newKey(ContentKeyKind.personal, null);
|
(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) {
|
if (!_unlocked) {
|
||||||
throw StateError('The account keys are not on this device');
|
throw StateError('The account keys are not on this device');
|
||||||
}
|
}
|
||||||
final keyId = _newKeyId();
|
final createdAt = DateTime.fromMillisecondsSinceEpoch(
|
||||||
final createdAt = clock.now().toUtc();
|
id.madeAt,
|
||||||
final sealedKey = cipher.newKey(keyId);
|
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(
|
await _gateway.insert(
|
||||||
dataKeyId: keyId,
|
dataKeyId: id.value,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
studysetId: studysetId,
|
studysetId: id.studysetId,
|
||||||
sealedKey: sealedKey,
|
sealedKey: sealedKey,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
);
|
);
|
||||||
_rows = [
|
_rows = [
|
||||||
..._rows,
|
..._rows,
|
||||||
(
|
(
|
||||||
id: keyId,
|
id: id.value,
|
||||||
dataKeyId: keyId,
|
dataKeyId: id.value,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
studysetId: studysetId,
|
studysetId: id.studysetId,
|
||||||
sealedKey: sealedKey,
|
sealedKey: sealedKey,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
return keyId;
|
_learn(id);
|
||||||
|
return id.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wipes the keys kept open here.
|
/// Wipes the keys kept open here, and writes what the device saw.
|
||||||
void dispose() => cipher.dispose();
|
Future<void> dispose() async {
|
||||||
}
|
cipher.dispose();
|
||||||
|
await ledger.flush();
|
||||||
/// 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('=', '');
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,15 @@ sealed class const Opened() with Equatable {
|
||||||
|
|
||||||
/// The payload as the codecs read it: readable, or opened with its key. A
|
/// 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.
|
/// 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
|
@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
|
/// 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
|
/// 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,
|
/// gateways go through: a payload is sealed for its table, row and column,
|
||||||
/// so the instance can't move it to another row unnoticed.
|
/// 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
|
/// [plain] as the row keeps it: sealed with the key [keyId], readable
|
||||||
/// without one. A blank payload stays blank. Throws a [StateError] for a
|
/// without one, and then signed where this device can. A blank payload
|
||||||
/// key not open on this device.
|
/// stays blank. Throws a [StateError] for a key not open on this device.
|
||||||
Envelope seal(
|
Envelope seal(
|
||||||
ByteData plain, {
|
ByteData plain, {
|
||||||
required String? keyId,
|
required String? keyId,
|
||||||
|
|
@ -40,9 +56,21 @@ class PayloadEnvelope(final ContentCipher _cipher) {
|
||||||
required String id,
|
required String id,
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
}) {
|
}) {
|
||||||
if (keyId == null || plain.lengthInBytes == 0) {
|
if (plain.lengthInBytes == 0) {
|
||||||
return (keyId: keyId, nonce: null, payload: plain);
|
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(
|
final sealed = _cipher.seal(
|
||||||
Uint8List.sublistView(plain),
|
Uint8List.sublistView(plain),
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
|
|
@ -64,9 +92,20 @@ class PayloadEnvelope(final ContentCipher _cipher) {
|
||||||
String column = 'payload',
|
String column = 'payload',
|
||||||
}) {
|
}) {
|
||||||
final (:keyId, :nonce, :payload) = envelope;
|
final (:keyId, :nonce, :payload) = envelope;
|
||||||
if (keyId == null || payload.lengthInBytes == 0) {
|
if (payload.lengthInBytes == 0) {
|
||||||
return Readable(payload);
|
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) {
|
if (nonce == null) {
|
||||||
return const Locked();
|
return const Locked();
|
||||||
}
|
}
|
||||||
|
|
@ -86,4 +125,16 @@ class PayloadEnvelope(final ContentCipher _cipher) {
|
||||||
/// What binds a payload to where it is stored.
|
/// What binds a payload to where it is stored.
|
||||||
static List<int> _context(String table, String id, String column) =>
|
static List<int> _context(String table, String id, String column) =>
|
||||||
utf8.encode('$table|$id|$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.
|
/// The key the asset row [id] is sealed with; null for a readable one.
|
||||||
Future<String?> keyIdOf(String id);
|
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
|
/// 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
|
/// returns its asset row's id: one of that key holding the same file, if
|
||||||
/// there is one.
|
/// there is one.
|
||||||
|
|
|
||||||
|
|
@ -49,34 +49,59 @@ class SyncCardGateway(
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
await _privacy.keys.refresh();
|
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 [
|
return [
|
||||||
for (final revision in await protocol.CardRevision.db.find(
|
for (final revision in await protocol.CardRevision.db.find(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.cardId.inSet({
|
where: (t) => t.cardId.inSet(ids),
|
||||||
for (final id in cardIds) protocol.UuidValue.withValidation(id),
|
|
||||||
}),
|
|
||||||
))
|
))
|
||||||
_revisionRow(revision),
|
_revisionRow(revision, treeOf(revision.cardId.uuid)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CardRevisionRow>> allRevisions() async {
|
Future<List<CardRevisionRow>> allRevisions() async {
|
||||||
await _privacy.keys.refresh();
|
await _privacy.keys.refresh();
|
||||||
|
final treeOf = await _treeKeysOf(await protocol.Card.db.find(_db));
|
||||||
return [
|
return [
|
||||||
for (final revision in await protocol.CardRevision.db.find(_db))
|
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,
|
id: revision.id!.uuid,
|
||||||
cardId: revision.cardId.uuid,
|
cardId: revision.cardId.uuid,
|
||||||
parents: {for (final parent in revision.parents) parent.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),
|
(keyId: revision.keyId, nonce: revision.nonce, payload: revision.payload),
|
||||||
table: 'card_revision',
|
table: 'card_revision',
|
||||||
id: revision.id!.uuid,
|
id: revision.id!.uuid,
|
||||||
|
item: 'card:${revision.cardId.uuid}',
|
||||||
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
Readable(:final payload) => payload,
|
Readable(:final payload) => payload,
|
||||||
Locked() => redactedPayload,
|
Locked() => redactedPayload,
|
||||||
|
|
@ -96,6 +121,7 @@ class SyncCardGateway(
|
||||||
keyId: keyId,
|
keyId: keyId,
|
||||||
table: 'card_revision',
|
table: 'card_revision',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
item: 'card:${cardId.uuid}',
|
||||||
);
|
);
|
||||||
return protocol.CardRevision(
|
return protocol.CardRevision(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -208,6 +234,21 @@ class SyncCardGateway(
|
||||||
if (card == null) {
|
if (card == null) {
|
||||||
return;
|
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).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
await protocol.Card.db.updateRow(
|
await protocol.Card.db.updateRow(
|
||||||
_db,
|
_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
|
/// 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
|
/// device; this mirrors it after each sync, and reports the copies only
|
||||||
/// while it is on.
|
/// 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(
|
class LearningCounting(
|
||||||
final LearningCountGateway _gateway,
|
final LearningCountGateway _gateway,
|
||||||
final SettingsRepository _settings,
|
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(
|
late final _runner = CoalescingRunner(
|
||||||
_report,
|
_report,
|
||||||
onError: (error, st) =>
|
onError: (error, st) =>
|
||||||
|
|
@ -35,8 +45,12 @@ class LearningCounting(
|
||||||
Future<void> report() => _runner.run();
|
Future<void> report() => _runner.run();
|
||||||
|
|
||||||
Future<void> _report() async {
|
Future<void> _report() async {
|
||||||
|
if (!(canCount?.call() ?? true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final counted = (await _settings.read()).countLearning;
|
final counted = (await _settings.read()).countLearning;
|
||||||
if (!counted) {
|
if (!counted) {
|
||||||
|
_reported = null;
|
||||||
if (await _gateway.counted()) {
|
if (await _gateway.counted()) {
|
||||||
await _gateway.setCounted(counted: false);
|
await _gateway.setCounted(counted: false);
|
||||||
}
|
}
|
||||||
|
|
@ -51,9 +65,25 @@ class LearningCounting(
|
||||||
(copies[publication] ??= {}).add(studyset.id);
|
(copies[publication] ??= {}).add(studyset.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (_sameAs(_reported, copies)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await _gateway.reportCopies(copies);
|
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.
|
/// Stops following the syncs.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ class SyncLearningPathGateway(
|
||||||
@override
|
@override
|
||||||
Future<List<PathRow>> paths({String? studysetId}) async {
|
Future<List<PathRow>> paths({String? studysetId}) async {
|
||||||
await _privacy.keys.refresh();
|
await _privacy.keys.refresh();
|
||||||
|
final treeKey = await _privacy.treeKeys();
|
||||||
return [
|
return [
|
||||||
for (final path in await protocol.LearningPath.db.find(
|
for (final path in await protocol.LearningPath.db.find(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -37,17 +38,31 @@ class SyncLearningPathGateway(
|
||||||
'learning_path',
|
'learning_path',
|
||||||
path.id!.uuid,
|
path.id!.uuid,
|
||||||
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
|
(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.
|
/// [envelope]'s payload opened as [item]'s in a tree with [treeKey],
|
||||||
ByteData _opened(String table, String id, Envelope envelope) =>
|
/// blank where it doesn't open here, or isn't trusted there.
|
||||||
switch (_privacy.open(envelope, table: table, id: id)) {
|
ByteData _opened(
|
||||||
Readable(:final payload) => payload,
|
String table,
|
||||||
Locked() => redactedPayload,
|
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
|
@override
|
||||||
Future<String> insertPath({
|
Future<String> insertPath({
|
||||||
|
|
@ -61,6 +76,7 @@ class SyncLearningPathGateway(
|
||||||
keyId: await _privacy.keyOfStudyset(studysetId),
|
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||||
table: 'learning_path',
|
table: 'learning_path',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
item: 'path:${id.uuid}',
|
||||||
);
|
);
|
||||||
final path = await protocol.LearningPath.db.insertRow(
|
final path = await protocol.LearningPath.db.insertRow(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -83,9 +99,10 @@ class SyncLearningPathGateway(
|
||||||
case final path?) {
|
case final path?) {
|
||||||
final sealed = _privacy.seal(
|
final sealed = _privacy.seal(
|
||||||
payload,
|
payload,
|
||||||
keyId: await _privacy.keyOfStudyset(path.studysetId.uuid),
|
keyId: await _privacy.keyOfPath(id),
|
||||||
table: 'learning_path',
|
table: 'learning_path',
|
||||||
id: id,
|
id: id,
|
||||||
|
item: 'path:$id',
|
||||||
);
|
);
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
await protocol.LearningPath.db.updateRow(
|
await protocol.LearningPath.db.updateRow(
|
||||||
|
|
@ -152,20 +169,49 @@ class SyncLearningPathGateway(
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
await _privacy.keys.refresh();
|
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 [
|
return [
|
||||||
for (final revision in await protocol.LearningPathStepRevision.db.find(
|
for (final revision in await protocol.LearningPathStepRevision.db.find(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.stepId.inSet({for (final id in stepIds) _uuid(id)}),
|
where: (t) => t.stepId.inSet(ids),
|
||||||
))
|
))
|
||||||
(
|
(
|
||||||
id: revision.id!.uuid,
|
id: revision.id!.uuid,
|
||||||
stepId: revision.stepId.uuid,
|
stepId: revision.stepId.uuid,
|
||||||
parents: {for (final parent in revision.parents) parent.uuid},
|
parents: {for (final parent in revision.parents) parent.uuid},
|
||||||
payload: _opened('learning_path_step_revision', revision.id!.uuid, (
|
payload: _opened(
|
||||||
keyId: revision.keyId,
|
'learning_path_step_revision',
|
||||||
nonce: revision.nonce,
|
revision.id!.uuid,
|
||||||
payload: revision.payload,
|
(
|
||||||
)),
|
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,
|
keyId: keyId,
|
||||||
table: 'learning_path_step_revision',
|
table: 'learning_path_step_revision',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
item: 'step:${stepId.uuid}',
|
||||||
);
|
);
|
||||||
return protocol.LearningPathStepRevision(
|
return protocol.LearningPathStepRevision(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class SyncPathAnswerGateway(
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
await _privacy.keys.refresh();
|
await _privacy.keys.refresh();
|
||||||
|
final treeKey = await _privacy.stepTreeKeys(stepIds);
|
||||||
return [
|
return [
|
||||||
for (final answer in await protocol.LearningPathAnswer.db.find(
|
for (final answer in await protocol.LearningPathAnswer.db.find(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -37,13 +38,14 @@ class SyncPathAnswerGateway(
|
||||||
stepId: answer.stepId.uuid,
|
stepId: answer.stepId.uuid,
|
||||||
answeredAt: answer.answeredAt,
|
answeredAt: answer.answeredAt,
|
||||||
outcome: answer.outcome,
|
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.
|
/// [row]'s answer as written, null where it has none, it doesn't open,
|
||||||
String? _opened(protocol.LearningPathAnswer row) {
|
/// or it isn't trusted in a tree with [treeKey].
|
||||||
|
String? _opened(protocol.LearningPathAnswer row, String? treeKey) {
|
||||||
final answer = row.answer;
|
final answer = row.answer;
|
||||||
final envelope = answer == null
|
final envelope = answer == null
|
||||||
? null
|
? null
|
||||||
|
|
@ -51,11 +53,13 @@ class SyncPathAnswerGateway(
|
||||||
if (envelope == null) {
|
if (envelope == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return switch (_privacy.open(
|
return switch (_privacy.openItem(
|
||||||
envelope,
|
envelope,
|
||||||
table: 'learning_path_answer',
|
table: 'learning_path_answer',
|
||||||
id: row.id!.uuid,
|
id: row.id!.uuid,
|
||||||
column: 'answer',
|
column: 'answer',
|
||||||
|
item: 'answers:${row.stepId.uuid}',
|
||||||
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
Readable(:final payload) => utf8.decode(
|
Readable(:final payload) => utf8.decode(
|
||||||
Uint8List.sublistView(payload),
|
Uint8List.sublistView(payload),
|
||||||
|
|
@ -72,7 +76,7 @@ class SyncPathAnswerGateway(
|
||||||
}
|
}
|
||||||
final keys = <String, String?>{
|
final keys = <String, String?>{
|
||||||
for (final stepId in {for (final answer in answers) answer.stepId})
|
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, [
|
await protocol.LearningPathAnswer.db.insert(_db, [
|
||||||
for (final answer in answers) _newAnswer(answer, keys[answer.stepId]),
|
for (final answer in answers) _newAnswer(answer, keys[answer.stepId]),
|
||||||
|
|
@ -92,6 +96,7 @@ class SyncPathAnswerGateway(
|
||||||
table: 'learning_path_answer',
|
table: 'learning_path_answer',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
column: 'answer',
|
column: 'answer',
|
||||||
|
item: 'answers:${answer.stepId}',
|
||||||
);
|
);
|
||||||
return protocol.LearningPathAnswer(
|
return protocol.LearningPathAnswer(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
|
||||||
|
|
@ -41,18 +41,27 @@ class SyncNoteGateway(
|
||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
await _privacy.keys.refresh();
|
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 [
|
return [
|
||||||
for (final revision in await protocol.NoteRevision.db.find(
|
for (final revision in await protocol.NoteRevision.db.find(
|
||||||
_db,
|
_db,
|
||||||
where: (t) => t.noteId.inSet({
|
where: (t) => t.noteId.inSet(ids),
|
||||||
for (final id in noteIds) protocol.UuidValue.withValidation(id),
|
|
||||||
}),
|
|
||||||
))
|
))
|
||||||
(
|
(
|
||||||
id: revision.id!.uuid,
|
id: revision.id!.uuid,
|
||||||
noteId: revision.noteId.uuid,
|
noteId: revision.noteId.uuid,
|
||||||
parents: {for (final parent in revision.parents) parent.uuid},
|
parents: {for (final parent in revision.parents) parent.uuid},
|
||||||
payload: switch (_privacy.open(
|
payload: switch (_privacy.openItem(
|
||||||
(
|
(
|
||||||
keyId: revision.keyId,
|
keyId: revision.keyId,
|
||||||
nonce: revision.nonce,
|
nonce: revision.nonce,
|
||||||
|
|
@ -60,6 +69,11 @@ class SyncNoteGateway(
|
||||||
),
|
),
|
||||||
table: 'note_revision',
|
table: 'note_revision',
|
||||||
id: revision.id!.uuid,
|
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,
|
Readable(:final payload) => payload,
|
||||||
Locked() => redactedPayload,
|
Locked() => redactedPayload,
|
||||||
|
|
@ -102,6 +116,7 @@ class SyncNoteGateway(
|
||||||
keyId: await _privacy.keyOfNote(noteId),
|
keyId: await _privacy.keyOfNote(noteId),
|
||||||
table: 'note_revision',
|
table: 'note_revision',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
item: 'note:$noteId',
|
||||||
);
|
);
|
||||||
final revision = await protocol.NoteRevision.db.insertRow(
|
final revision = await protocol.NoteRevision.db.insertRow(
|
||||||
_db,
|
_db,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class SyncPdfSourceGateway(
|
||||||
@override
|
@override
|
||||||
Future<List<PdfSourceRow>> sources({String? studysetId}) async {
|
Future<List<PdfSourceRow>> sources({String? studysetId}) async {
|
||||||
await _privacy.keys.refresh();
|
await _privacy.keys.refresh();
|
||||||
|
final treeKey = await _privacy.treeKeys();
|
||||||
return [
|
return [
|
||||||
for (final source in await protocol.PdfSource.db.find(
|
for (final source in await protocol.PdfSource.db.find(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -26,7 +27,7 @@ class SyncPdfSourceGateway(
|
||||||
? null
|
? null
|
||||||
: (t) => t.studysetId.equals(_uuid(studysetId)),
|
: (t) => t.studysetId.equals(_uuid(studysetId)),
|
||||||
))
|
))
|
||||||
_row(source),
|
_row(source, treeKey(source.studysetId.uuid)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,7 +42,10 @@ class SyncPdfSourceGateway(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return switch (await protocol.PdfSource.db.findById(_db, uuid)) {
|
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,
|
null => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -58,6 +62,7 @@ class SyncPdfSourceGateway(
|
||||||
keyId: await _privacy.keyOfStudyset(studysetId),
|
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||||
table: 'pdf_source',
|
table: 'pdf_source',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
|
item: 'pdf:${id.uuid}',
|
||||||
);
|
);
|
||||||
final source = await protocol.PdfSource.db.insertRow(
|
final source = await protocol.PdfSource.db.insertRow(
|
||||||
_db,
|
_db,
|
||||||
|
|
@ -80,9 +85,10 @@ class SyncPdfSourceGateway(
|
||||||
case final source?) {
|
case final source?) {
|
||||||
final sealed = _privacy.seal(
|
final sealed = _privacy.seal(
|
||||||
payload,
|
payload,
|
||||||
keyId: await _privacy.keyOfStudyset(source.studysetId.uuid),
|
keyId: await _privacy.keyOfPdf(id, source.studysetId.uuid),
|
||||||
table: 'pdf_source',
|
table: 'pdf_source',
|
||||||
id: id,
|
id: id,
|
||||||
|
item: 'pdf:$id',
|
||||||
);
|
);
|
||||||
// Only the named columns change (ADR 0001, rule 1).
|
// Only the named columns change (ADR 0001, rule 1).
|
||||||
await protocol.PdfSource.db.updateRow(
|
await protocol.PdfSource.db.updateRow(
|
||||||
|
|
@ -186,16 +192,19 @@ class SyncPdfSourceGateway(
|
||||||
@override
|
@override
|
||||||
Stream<void> get remoteChanges => _sync.merges;
|
Stream<void> get remoteChanges => _sync.merges;
|
||||||
|
|
||||||
PdfSourceRow _row(protocol.PdfSource source) => (
|
PdfSourceRow _row(protocol.PdfSource source, String? treeKey) => (
|
||||||
id: source.id!.uuid,
|
id: source.id!.uuid,
|
||||||
studysetId: source.studysetId.uuid,
|
studysetId: source.studysetId.uuid,
|
||||||
position: source.position,
|
position: source.position,
|
||||||
// A PDF in a private studyset whose key doesn't open here reads as
|
// A PDF in a private studyset whose key doesn't open here, or that is
|
||||||
// blank (ADR 0019).
|
// readable there without the account's signature, reads as blank (ADR
|
||||||
payload: switch (_privacy.open(
|
// 0019).
|
||||||
|
payload: switch (_privacy.openItem(
|
||||||
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
|
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
|
||||||
table: 'pdf_source',
|
table: 'pdf_source',
|
||||||
id: source.id!.uuid,
|
id: source.id!.uuid,
|
||||||
|
item: 'pdf:${source.id!.uuid}',
|
||||||
|
treeKey: treeKey,
|
||||||
)) {
|
)) {
|
||||||
Readable(:final payload) => payload,
|
Readable(:final payload) => payload,
|
||||||
Locked() => redactedPayload,
|
Locked() => redactedPayload,
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ class PublishRepository(
|
||||||
final PublishGateway _gateway,
|
final PublishGateway _gateway,
|
||||||
final PublishedStore _store, {
|
final PublishedStore _store, {
|
||||||
|
|
||||||
/// The person's tags' names by their ids, as this device reads them: the
|
/// The names of the tags the items of the studyset with the id given,
|
||||||
/// instance can't read them (ADR 0019, D2), so a release's tags are named
|
/// and everything inside it, carry, by their ids, as this device reads
|
||||||
/// with them; none without.
|
/// them: the instance can't read them (ADR 0019, D2), so a release's tags
|
||||||
final Future<Map<String, String>> Function()? _tagNames,
|
/// 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();
|
final _changes = StreamController<Set<String>>.broadcast();
|
||||||
|
|
||||||
|
|
@ -66,7 +68,7 @@ class PublishRepository(
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
description: description,
|
description: description,
|
||||||
tagNames: await _tagNames?.call(),
|
tagNames: await _tagNames?.call(studysetId),
|
||||||
);
|
);
|
||||||
await _rememberOne(studysetId, out: true);
|
await _rememberOne(studysetId, out: true);
|
||||||
return publication;
|
return publication;
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,34 @@ class SyncSettingsGateway(
|
||||||
await _privacy.keys.refresh();
|
await _privacy.keys.refresh();
|
||||||
return [
|
return [
|
||||||
for (final row in await protocol.PersonSettings.db.find(_db))
|
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),
|
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
id: row.id!.uuid,
|
id: row.id!.uuid,
|
||||||
|
item: 'settings:${row.id!.uuid}',
|
||||||
|
treeKey: null,
|
||||||
|
protected: _privacy.personalProtected,
|
||||||
)
|
)
|
||||||
case Readable(:final payload))
|
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
|
@override
|
||||||
Future<String> insert(ByteData payload) async {
|
Future<String> insert(ByteData payload) async {
|
||||||
final id = Privacy.newId();
|
final id = Privacy.newId();
|
||||||
|
final keyId = await _privacy.personalKey();
|
||||||
final sealed = _privacy.seal(
|
final sealed = _privacy.seal(
|
||||||
payload,
|
_withCounter(id.uuid, payload, keyId),
|
||||||
keyId: await _privacy.personalKey(),
|
keyId: keyId,
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
id: id.uuid,
|
id: id.uuid,
|
||||||
);
|
);
|
||||||
|
|
@ -59,9 +71,10 @@ class SyncSettingsGateway(
|
||||||
if (row == null) {
|
if (row == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final keyId = await _privacy.personalKey();
|
||||||
final sealed = _privacy.seal(
|
final sealed = _privacy.seal(
|
||||||
payload,
|
_withCounter(id, payload, keyId),
|
||||||
keyId: await _privacy.personalKey(),
|
keyId: keyId,
|
||||||
table: 'person_settings',
|
table: 'person_settings',
|
||||||
id: id,
|
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
|
/// 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
|
/// 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
|
/// 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 = ({
|
typedef StudysetRow = ({
|
||||||
String id,
|
String id,
|
||||||
ByteData payload,
|
ByteData payload,
|
||||||
|
|
@ -14,7 +16,9 @@ typedef StudysetRow = ({
|
||||||
DateTime? starredAt,
|
DateTime? starredAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
bool isPrivate,
|
bool isPrivate,
|
||||||
|
bool fullyPrivate,
|
||||||
bool locked,
|
bool locked,
|
||||||
|
bool keysHere,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The boundary to the studysets in this person's local, synced database.
|
/// The boundary to the studysets in this person's local, synced database.
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,8 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
isPrivate: row.isPrivate,
|
isPrivate: row.isPrivate,
|
||||||
isLocked: row.locked,
|
isLocked: row.locked,
|
||||||
|
isPartlyReadable: row.isPrivate && !row.fullyPrivate,
|
||||||
|
keysOnDevice: row.keysHere,
|
||||||
);
|
);
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
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