feat(privacy): every synced payload sealed and opened through one envelope (ADR 0019)
- `ContentKeys` opens the person's data keys (`content_key`) with their account's keys on the device, an anonymous person's device keys made as their data first opens; it makes a private studyset's key and the personal key. Without the account's keys nothing opens and nothing new is private (D8). - `PayloadEnvelope` is the one place payloads are sealed and opened, each for its table, row and column, so a payload moved to another row never opens; one that doesn't open is `Locked`, never half-read. - `Privacy` gives each gateway the key of the row's tree (its top-level studyset's), and rewrites a tree under another key, or readable, in place (`rekeyTree`). - The studyset, card, note, learning path, path answer and PDF gateways seal with their tree's key and open what they read: a new top-level studyset is private where the device holds the keys, a new one inside a tree takes its privacy, and one moved into another tree is rewritten under that tree's key. Tags and settings are sealed with the personal key; a tag or settings row that doesn't open here is left out, so nothing folds or deletes it. A studyset whose key doesn't open shows as locked (`Studyset.isLocked`); other rows that don't open read as blank. - Every payload codec round-trips sealed, and refuses another row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
905bb9abf2
commit
64fdfa091d
24 changed files with 1693 additions and 151 deletions
|
|
@ -14,6 +14,9 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
|
|||
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.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/privacy.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';
|
||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||
|
|
@ -233,10 +236,12 @@ class Dependencies({
|
|||
appName: 'Halkyon Learn',
|
||||
);
|
||||
final addresses = FileSignInAddresses(device.storage.peopleOf(instance));
|
||||
final accountCrypto = SodiumAccountCrypto();
|
||||
final accountKeys = SecureAccountKeyStore(device.storage.keysOf(instance));
|
||||
final passwords = PasswordRepository(
|
||||
ServerpodPasswordGateway(client, base: client.modules.base),
|
||||
SodiumAccountCrypto(),
|
||||
SecureAccountKeyStore(device.storage.keysOf(instance)),
|
||||
accountCrypto,
|
||||
accountKeys,
|
||||
authGateway,
|
||||
addresses,
|
||||
);
|
||||
|
|
@ -298,6 +303,17 @@ class Dependencies({
|
|||
compatibility,
|
||||
person,
|
||||
await folders.openOnly(person.authUserId),
|
||||
accountKeys: () async {
|
||||
final kept = await accountKeys.read(person.authUserId);
|
||||
if (kept != null || person.isIdentified) {
|
||||
return kept;
|
||||
}
|
||||
// An anonymous person's device makes the keys their private
|
||||
// studysets are encrypted with (ADR 0017, 9; ADR 0019).
|
||||
final made = await accountCrypto.newDeviceKeys();
|
||||
await accountKeys.keep(person.authUserId, made);
|
||||
return made;
|
||||
},
|
||||
onClosed: closed.complete,
|
||||
);
|
||||
} on Object {
|
||||
|
|
@ -388,6 +404,7 @@ Future<PersonDependencies> _openPersonData(
|
|||
AppCompatibility compatibility,
|
||||
SignedIn person,
|
||||
Directory folder, {
|
||||
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
||||
required void Function() onClosed,
|
||||
}) async {
|
||||
final db = await client.createSyncSession(
|
||||
|
|
@ -410,24 +427,37 @@ Future<PersonDependencies> _openPersonData(
|
|||
pendingChanges: kept,
|
||||
syncedBefore: (await kept.read()).synced,
|
||||
)..start();
|
||||
final studysets = StudysetRepository(SyncStudysetGateway(db, sync));
|
||||
// The person's keys, with which private studysets, their tags and their
|
||||
// settings are sealed and opened (ADR 0019).
|
||||
final contentKeys = ContentKeys(
|
||||
SyncContentKeyGateway(db, sync),
|
||||
SodiumContentCipher(),
|
||||
accountKeys,
|
||||
);
|
||||
final privacy = Privacy(db, contentKeys);
|
||||
await contentKeys.refresh();
|
||||
final studysets = StudysetRepository(
|
||||
SyncStudysetGateway(db, sync, privacy),
|
||||
);
|
||||
final cards = CardRepository(
|
||||
SyncCardGateway(db, sync, person.authUserId),
|
||||
SyncCardGateway(db, sync, person.authUserId, privacy),
|
||||
);
|
||||
final notes = NoteRepository(
|
||||
SyncNoteGateway(db, sync, person.authUserId),
|
||||
SyncNoteGateway(db, sync, person.authUserId, privacy),
|
||||
);
|
||||
final paths = LearningPathRepository(
|
||||
SyncLearningPathGateway(db, sync, person.authUserId),
|
||||
SyncLearningPathGateway(db, sync, person.authUserId, privacy),
|
||||
);
|
||||
final pathProgress = PathProgressRepository(
|
||||
SyncPathAnswerGateway(db, sync, privacy),
|
||||
);
|
||||
final pathProgress = PathProgressRepository(SyncPathAnswerGateway(db, sync));
|
||||
final tags = TagRepository(
|
||||
SyncTagGateway(db, sync),
|
||||
SyncTagGateway(db, sync, privacy),
|
||||
cardChanges: cards.changes,
|
||||
noteChanges: notes.changes,
|
||||
pathChanges: paths.changes,
|
||||
);
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
|
||||
final settings = SettingsRepository(SyncSettingsGateway(db, sync, privacy));
|
||||
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
|
||||
final assets = AssetRepository(
|
||||
SyncAssetGateway(db, sync),
|
||||
|
|
@ -436,7 +466,7 @@ Future<PersonDependencies> _openPersonData(
|
|||
uploadTriggers: sync.synced,
|
||||
);
|
||||
final pdfSources = PdfSourceRepository(
|
||||
SyncPdfSourceGateway(db, sync),
|
||||
SyncPdfSourceGateway(db, sync, privacy),
|
||||
assets,
|
||||
);
|
||||
final deletions = StudysetDeletions(
|
||||
|
|
@ -537,6 +567,8 @@ Future<PersonDependencies> _openPersonData(
|
|||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
// The keys opened here go once nothing can write any more.
|
||||
contentKeys.dispose();
|
||||
onClosed();
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// What kind of data key a row of `content_key` holds (ADR 0019).
|
||||
enum ContentKeyKind() {
|
||||
/// A private studyset's: everything in its tree.
|
||||
studyset,
|
||||
|
||||
/// The person's own: their tags and settings.
|
||||
personal,
|
||||
}
|
||||
|
||||
/// A data key as its row keeps it: sealed to the person's account.
|
||||
typedef ContentKeyRow = ({
|
||||
String id,
|
||||
String dataKeyId,
|
||||
ContentKeyKind kind,
|
||||
String? studysetId,
|
||||
Uint8List sealedKey,
|
||||
DateTime createdAt,
|
||||
});
|
||||
|
||||
/// The person's data keys (ADR 0019), synced in their space.
|
||||
abstract interface class ContentKeyGateway() {
|
||||
/// Every key kept, of every kind; rows of a kind this app doesn't know are
|
||||
/// left out.
|
||||
Future<List<ContentKeyRow>> all();
|
||||
|
||||
/// Keeps a new key.
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required ContentKeyKind kind,
|
||||
required String? studysetId,
|
||||
required Uint8List sealedKey,
|
||||
required DateTime createdAt,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
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';
|
||||
|
||||
/// The person's data keys on this device (ADR 0019): each private
|
||||
/// studyset's and their personal key, sealed to their account and synced,
|
||||
/// opened here with the account's keys (an anonymous person's device keys,
|
||||
/// 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.
|
||||
class ContentKeys(
|
||||
final ContentKeyGateway _gateway,
|
||||
|
||||
/// Seals and opens with the keys; [ContentKeys] owns it.
|
||||
final ContentCipher cipher,
|
||||
|
||||
/// The account's keys on this device; null where none are.
|
||||
final Future<UnlockedAccountKeys?> Function() _accountKeys,
|
||||
) {
|
||||
var _unlocked = false;
|
||||
var _rows = const <ContentKeyRow>[];
|
||||
|
||||
/// The keys that wouldn't open, never tried again.
|
||||
final _refused = <String>{};
|
||||
|
||||
late final _refreshing = CoalescingRunner(
|
||||
_refresh,
|
||||
onError: (error, st) =>
|
||||
log('Opening data keys failed', name: 'Privacy', error: error),
|
||||
);
|
||||
|
||||
/// Whether this device holds the account's keys, so new content can be
|
||||
/// made private; known once [refresh] ran.
|
||||
bool get canEncrypt => _unlocked;
|
||||
|
||||
/// 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();
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (!_unlocked) {
|
||||
final account = await _accountKeys();
|
||||
if (account == null) {
|
||||
return;
|
||||
}
|
||||
await cipher.unlock(account);
|
||||
_unlocked = true;
|
||||
}
|
||||
_rows = await _gateway.all();
|
||||
for (final row in _rows) {
|
||||
if (!cipher.has(row.dataKeyId) && !_refused.contains(row.dataKeyId)) {
|
||||
if (!cipher.openKey(row.dataKeyId, row.sealedKey)) {
|
||||
_refused.add(row.dataKeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
Future<String> _newKey(ContentKeyKind kind, String? studysetId) 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);
|
||||
await _gateway.insert(
|
||||
dataKeyId: keyId,
|
||||
kind: kind,
|
||||
studysetId: studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
_rows = [
|
||||
..._rows,
|
||||
(
|
||||
id: keyId,
|
||||
dataKeyId: keyId,
|
||||
kind: kind,
|
||||
studysetId: studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
),
|
||||
];
|
||||
return keyId;
|
||||
}
|
||||
|
||||
/// 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('=', '');
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// A synced row's envelope (ADR 0002): its key's ID and nonce, null while
|
||||
/// readable, and its payload.
|
||||
typedef Envelope = ({String? keyId, ByteData? nonce, ByteData payload});
|
||||
|
||||
/// What a row's payload is once opened (ADR 0019).
|
||||
sealed class const Opened() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@override
|
||||
List<Object?> get props => [Uint8List.sublistView(payload)];
|
||||
}
|
||||
|
||||
/// A payload whose key isn't open on this device, or that doesn't open
|
||||
/// with it (changed, or moved from another row): never read, never shown
|
||||
/// half-read.
|
||||
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) {
|
||||
/// [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.
|
||||
Envelope seal(
|
||||
ByteData plain, {
|
||||
required String? keyId,
|
||||
required String table,
|
||||
required String id,
|
||||
String column = 'payload',
|
||||
}) {
|
||||
if (keyId == null || plain.lengthInBytes == 0) {
|
||||
return (keyId: keyId, nonce: null, payload: plain);
|
||||
}
|
||||
final sealed = _cipher.seal(
|
||||
Uint8List.sublistView(plain),
|
||||
keyId: keyId,
|
||||
context: _context(table, id, column),
|
||||
);
|
||||
return (
|
||||
keyId: keyId,
|
||||
nonce: ByteData.sublistView(sealed.nonce),
|
||||
payload: ByteData.sublistView(sealed.payload),
|
||||
);
|
||||
}
|
||||
|
||||
/// [envelope]'s payload opened: as it is while readable or blank, opened
|
||||
/// with its key, or [Locked].
|
||||
Opened open(
|
||||
Envelope envelope, {
|
||||
required String table,
|
||||
required String id,
|
||||
String column = 'payload',
|
||||
}) {
|
||||
final (:keyId, :nonce, :payload) = envelope;
|
||||
if (keyId == null || payload.lengthInBytes == 0) {
|
||||
return Readable(payload);
|
||||
}
|
||||
if (nonce == null) {
|
||||
return const Locked();
|
||||
}
|
||||
final plain = _cipher.unseal(
|
||||
SealedPayload(
|
||||
keyId: keyId,
|
||||
nonce: Uint8List.sublistView(nonce),
|
||||
payload: Uint8List.sublistView(payload),
|
||||
),
|
||||
context: _context(table, id, column),
|
||||
);
|
||||
return plain == null
|
||||
? const Locked()
|
||||
: Readable(ByteData.sublistView(plain));
|
||||
}
|
||||
|
||||
/// What binds a payload to where it is stored.
|
||||
static List<int> _context(String table, String id, String column) =>
|
||||
utf8.encode('$table|$id|$column');
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// What the synced gateways seal and open with (ADR 0019): the person's
|
||||
/// keys, the one envelope, and which key each row's tree is encrypted with.
|
||||
/// A private studyset's tree is encrypted with the key its top-level
|
||||
/// studyset's own row names; tags and settings with the person's key.
|
||||
class Privacy(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
|
||||
/// The person's keys on this device.
|
||||
final ContentKeys keys,
|
||||
) {
|
||||
/// Seals and opens every synced payload.
|
||||
late final envelope = PayloadEnvelope(keys.cipher);
|
||||
|
||||
/// Each studyset's tree's key, null for a readable tree; read once, until
|
||||
/// studysets change.
|
||||
Map<String, String?>? _treeKeys;
|
||||
|
||||
/// Forgets which key each tree has: studysets changed here or came in by
|
||||
/// a sync.
|
||||
void forgetTrees() => _treeKeys = null;
|
||||
|
||||
/// The key [studysetId]'s tree is encrypted with: its top-level
|
||||
/// studyset's; null for a readable tree, or a studyset that isn't there.
|
||||
Future<String?> keyOfStudyset(String studysetId) async =>
|
||||
(_treeKeys ??= await _readTreeKeys())[studysetId];
|
||||
|
||||
/// The key of the tree the card [cardId] is in.
|
||||
Future<String?> keyOfCard(String cardId) async {
|
||||
final card = await protocol.Card.db.findById(_db, _uuid(cardId));
|
||||
return card == null ? null : await keyOfStudyset(card.studysetId.uuid);
|
||||
}
|
||||
|
||||
/// The key of the tree the note [noteId] is in.
|
||||
Future<String?> keyOfNote(String noteId) async {
|
||||
final note = await protocol.Note.db.findById(_db, _uuid(noteId));
|
||||
return note == null ? null : await keyOfStudyset(note.studysetId.uuid);
|
||||
}
|
||||
|
||||
/// The key of the tree the learning path [pathId] is in.
|
||||
Future<String?> keyOfPath(String pathId) async {
|
||||
final path = await protocol.LearningPath.db.findById(_db, _uuid(pathId));
|
||||
return path == null ? null : await keyOfStudyset(path.studysetId.uuid);
|
||||
}
|
||||
|
||||
/// The key of the tree the step [stepId] is in.
|
||||
Future<String?> keyOfStep(String stepId) async {
|
||||
final step = await protocol.LearningPathStep.db.findById(
|
||||
_db,
|
||||
_uuid(stepId),
|
||||
);
|
||||
return step == null ? null : await keyOfPath(step.pathId.uuid);
|
||||
}
|
||||
|
||||
/// The key the person's tags and settings are written with; null where
|
||||
/// this device can't encrypt (ADR 0019, D8).
|
||||
Future<String?> personalKey() => keys.personalKey();
|
||||
|
||||
/// [envelope]'s payload opened for its [table], row [id] and [column].
|
||||
Opened open(
|
||||
Envelope envelope, {
|
||||
required String table,
|
||||
required String id,
|
||||
String column = 'payload',
|
||||
}) => this.envelope.open(envelope, table: table, id: id, column: column);
|
||||
|
||||
/// [plain] sealed with [keyId] for its [table], row [id] and [column].
|
||||
Envelope seal(
|
||||
ByteData plain, {
|
||||
required String? keyId,
|
||||
required String table,
|
||||
required String id,
|
||||
String column = 'payload',
|
||||
}) => envelope.seal(
|
||||
plain,
|
||||
keyId: keyId,
|
||||
table: table,
|
||||
id: id,
|
||||
column: column,
|
||||
);
|
||||
|
||||
/// The ids of [studysetId] and every studyset inside it.
|
||||
Future<Set<String>> treeOf(String studysetId) async {
|
||||
final children = <String, List<String>>{};
|
||||
for (final studyset in await protocol.Studyset.db.find(_db)) {
|
||||
if (studyset.parentId case final parent?) {
|
||||
(children[parent.uuid] ??= []).add(studyset.id!.uuid);
|
||||
}
|
||||
}
|
||||
final tree = <String>{};
|
||||
final queue = [studysetId];
|
||||
while (queue.isNotEmpty) {
|
||||
final id = queue.removeLast();
|
||||
if (tree.add(id)) {
|
||||
queue.addAll(children[id] ?? const []);
|
||||
}
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
/// Seals everything in [studysetId]'s tree anew with [keyId], or makes it
|
||||
/// readable with null (ADR 0019): the studysets, their cards', notes' and
|
||||
/// steps' revisions, their paths, the answers given in them and their
|
||||
/// PDFs, each opened with the key it has and written back in place (the
|
||||
/// payload's columns only, as blanking is). A row that doesn't open here
|
||||
/// stays as it is. Returns how many rows it couldn't open.
|
||||
Future<int> rekeyTree(String studysetId, String? keyId) async {
|
||||
final tree = await treeOf(studysetId);
|
||||
final ids = {for (final id in tree) _uuid(id)};
|
||||
var locked = 0;
|
||||
|
||||
/// [row]'s payload in [table] under [keyId], or null to leave it.
|
||||
Envelope? rekeyed(
|
||||
String table,
|
||||
String id,
|
||||
Envelope current, {
|
||||
String column = 'payload',
|
||||
}) {
|
||||
if (current.keyId == keyId) {
|
||||
return null;
|
||||
}
|
||||
switch (open(current, table: table, id: id, column: column)) {
|
||||
case Readable(:final payload):
|
||||
return seal(
|
||||
payload,
|
||||
keyId: keyId,
|
||||
table: table,
|
||||
id: id,
|
||||
column: column,
|
||||
);
|
||||
case Locked():
|
||||
locked++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Each row on its own: the sync engine's session writes one at a time,
|
||||
// and a row rewritten is whole either way.
|
||||
for (final row in await protocol.Studyset.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet(ids),
|
||||
)) {
|
||||
if (rekeyed('studyset', row.id!.uuid, (
|
||||
keyId: row.keyId,
|
||||
nonce: row.nonce,
|
||||
payload: row.payload,
|
||||
))
|
||||
case final e?) {
|
||||
await protocol.Studyset.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
final cards = {
|
||||
for (final card in await protocol.Card.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(ids),
|
||||
))
|
||||
card.id!,
|
||||
};
|
||||
for (final row in await protocol.CardRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.cardId.inSet(cards),
|
||||
)) {
|
||||
if (rekeyed('card_revision', row.id!.uuid, (
|
||||
keyId: row.keyId,
|
||||
nonce: row.nonce,
|
||||
payload: row.payload,
|
||||
))
|
||||
case final e?) {
|
||||
await protocol.CardRevision.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
final notes = {
|
||||
for (final note in await protocol.Note.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(ids),
|
||||
))
|
||||
note.id!,
|
||||
};
|
||||
for (final row in await protocol.NoteRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.noteId.inSet(notes),
|
||||
)) {
|
||||
if (rekeyed('note_revision', row.id!.uuid, (
|
||||
keyId: row.keyId,
|
||||
nonce: row.nonce,
|
||||
payload: row.payload,
|
||||
))
|
||||
case final e?) {
|
||||
await protocol.NoteRevision.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
final paths = await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(ids),
|
||||
);
|
||||
for (final row in paths) {
|
||||
if (rekeyed('learning_path', row.id!.uuid, (
|
||||
keyId: row.keyId,
|
||||
nonce: row.nonce,
|
||||
payload: row.payload,
|
||||
))
|
||||
case final e?) {
|
||||
await protocol.LearningPath.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
final steps = {
|
||||
for (final step in await protocol.LearningPathStep.db.find(
|
||||
_db,
|
||||
where: (t) => t.pathId.inSet({for (final path in paths) path.id!}),
|
||||
))
|
||||
step.id!,
|
||||
};
|
||||
for (final row in await protocol.LearningPathStepRevision.db.find(
|
||||
_db,
|
||||
where: (t) => t.stepId.inSet(steps),
|
||||
)) {
|
||||
if (rekeyed(
|
||||
'learning_path_step_revision',
|
||||
row.id!.uuid,
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
)
|
||||
case final e?) {
|
||||
await protocol.LearningPathStepRevision.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final row in await protocol.LearningPathAnswer.db.find(
|
||||
_db,
|
||||
where: (t) => t.stepId.inSet(steps),
|
||||
)) {
|
||||
final answer = row.answer;
|
||||
if (answer == null) {
|
||||
continue;
|
||||
}
|
||||
final current = answerEnvelope(row.keyId, row.nonce, answer);
|
||||
final e = current == null
|
||||
? null
|
||||
: rekeyed(
|
||||
'learning_path_answer',
|
||||
row.id!.uuid,
|
||||
current,
|
||||
column: 'answer',
|
||||
);
|
||||
if (e != null) {
|
||||
await protocol.LearningPathAnswer.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(
|
||||
keyId: e.keyId,
|
||||
nonce: e.nonce,
|
||||
answer: answerText(e),
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.answer],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final row in await protocol.PdfSource.db.find(
|
||||
_db,
|
||||
where: (t) => t.studysetId.inSet(ids),
|
||||
)) {
|
||||
if (rekeyed('pdf_source', row.id!.uuid, (
|
||||
keyId: row.keyId,
|
||||
nonce: row.nonce,
|
||||
payload: row.payload,
|
||||
))
|
||||
case final e?) {
|
||||
await protocol.PdfSource.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(keyId: e.keyId, nonce: e.nonce, payload: e.payload),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
forgetTrees();
|
||||
return locked;
|
||||
}
|
||||
|
||||
/// A path answer's text (its `answer` column) as an envelope: sealed, it
|
||||
/// holds the sealed payload in base64; null for one that isn't.
|
||||
static Envelope? answerEnvelope(
|
||||
String? keyId,
|
||||
ByteData? nonce,
|
||||
String answer,
|
||||
) {
|
||||
if (keyId == null) {
|
||||
return (
|
||||
keyId: null,
|
||||
nonce: null,
|
||||
payload: ByteData.sublistView(utf8.encode(answer)),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return (
|
||||
keyId: keyId,
|
||||
nonce: nonce,
|
||||
payload: ByteData.sublistView(base64Decode(answer)),
|
||||
);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [envelope] as a path answer's `answer` column keeps it: readable as it
|
||||
/// is, sealed in base64.
|
||||
static String answerText(Envelope envelope) => envelope.keyId == null
|
||||
? utf8.decode(Uint8List.sublistView(envelope.payload))
|
||||
: base64Encode(Uint8List.sublistView(envelope.payload));
|
||||
|
||||
/// A new row's id, made here so its payload is sealed for it before it is
|
||||
/// written: a UUID v7, as the database would make (ADR 0001, rule 7).
|
||||
static protocol.UuidValue newId() => const protocol.Uuid().v7obj();
|
||||
|
||||
Future<Map<String, String?>> _readTreeKeys() async {
|
||||
final rows = {
|
||||
for (final studyset in await protocol.Studyset.db.find(_db))
|
||||
studyset.id!.uuid: (
|
||||
parent: studyset.parentId?.uuid,
|
||||
keyId: studyset.keyId,
|
||||
),
|
||||
};
|
||||
String? topKey(String id) {
|
||||
final seen = <String>{};
|
||||
var current = id;
|
||||
while (true) {
|
||||
final row = rows[current];
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
final parent = row.parent;
|
||||
// A studyset without its parent, or in a cycle, is at the top
|
||||
// level, as the library shows it.
|
||||
if (parent == null || !rows.containsKey(parent) || !seen.add(current)) {
|
||||
return row.keyId;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
return {for (final id in rows.keys) id: topKey(id)};
|
||||
}
|
||||
|
||||
static protocol.UuidValue _uuid(String id) =>
|
||||
protocol.UuidValue.withValidation(id);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [ContentKeyGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
class SyncContentKeyGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements ContentKeyGateway {
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => [
|
||||
for (final key in await protocol.ContentKey.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
if (ContentKeyKind.values.asNameMap()[key.kind] case final kind?)
|
||||
(
|
||||
id: key.id!.uuid,
|
||||
dataKeyId: key.dataKeyId,
|
||||
kind: kind,
|
||||
studysetId: key.studysetId?.uuid,
|
||||
sealedKey: Uint8List.sublistView(key.sealedKey),
|
||||
createdAt: key.createdAt,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required ContentKeyKind kind,
|
||||
required String? studysetId,
|
||||
required Uint8List sealedKey,
|
||||
required DateTime createdAt,
|
||||
}) async {
|
||||
await protocol.ContentKey.db.insertRow(
|
||||
_db,
|
||||
protocol.ContentKey(
|
||||
dataKeyId: dataKeyId,
|
||||
kind: kind.name,
|
||||
studysetId: studysetId == null
|
||||
? null
|
||||
: protocol.UuidValue.withValidation(studysetId),
|
||||
sealedKey: ByteData.sublistView(sealedKey),
|
||||
createdAt: createdAt.toUtc(),
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,17 +3,21 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [CardGateway] on the person's local database, which the sync engine keeps
|
||||
/// in step with their other devices. Revisions record [_authorId] as their
|
||||
/// author.
|
||||
/// author, and are sealed with their tree's key in a private studyset
|
||||
/// ([Privacy], ADR 0019); one that doesn't open here reads as blank.
|
||||
class SyncCardGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final String _authorId,
|
||||
final Privacy _privacy,
|
||||
) implements CardGateway {
|
||||
@override
|
||||
Future<List<CardRow>> cardsOf(String studysetId) async => [
|
||||
|
|
@ -44,6 +48,7 @@ class SyncCardGateway(
|
|||
if (cardIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final revision in await protocol.CardRevision.db.find(
|
||||
_db,
|
||||
|
|
@ -56,18 +61,55 @@ class SyncCardGateway(
|
|||
}
|
||||
|
||||
@override
|
||||
Future<List<CardRevisionRow>> allRevisions() async => [
|
||||
Future<List<CardRevisionRow>> allRevisions() async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final revision in await protocol.CardRevision.db.find(_db))
|
||||
_revisionRow(revision),
|
||||
];
|
||||
}
|
||||
|
||||
CardRevisionRow _revisionRow(protocol.CardRevision revision) => (
|
||||
id: revision.id!.uuid,
|
||||
cardId: revision.cardId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: revision.payload,
|
||||
payload: switch (_privacy.open(
|
||||
(keyId: revision.keyId, nonce: revision.nonce, payload: revision.payload),
|
||||
table: 'card_revision',
|
||||
id: revision.id!.uuid,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
},
|
||||
);
|
||||
|
||||
/// A new revision of [cardId] with [payload], sealed with [keyId].
|
||||
protocol.CardRevision _newRevision(
|
||||
protocol.UuidValue cardId,
|
||||
Iterable<String> parents,
|
||||
ByteData payload,
|
||||
String? keyId,
|
||||
) {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: keyId,
|
||||
table: 'card_revision',
|
||||
id: id.uuid,
|
||||
);
|
||||
return protocol.CardRevision(
|
||||
id: id,
|
||||
cardId: cardId,
|
||||
parents: [
|
||||
for (final parent in parents) protocol.UuidValue.withValidation(parent),
|
||||
],
|
||||
authorId: protocol.UuidValue.withValidation(_authorId),
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insertCard({
|
||||
required String studysetId,
|
||||
|
|
@ -107,14 +149,10 @@ class SyncCardGateway(
|
|||
// The batch's positions are distinct, so they tell the stored rows apart
|
||||
// whatever order they come back in.
|
||||
final idOf = {for (final row in rows) row.position: row.id!};
|
||||
final keyId = await _privacy.keyOfStudyset(studysetId);
|
||||
await protocol.CardRevision.db.insert(_db, [
|
||||
for (final card in cards)
|
||||
protocol.CardRevision(
|
||||
cardId: idOf[card.position]!,
|
||||
parents: const [],
|
||||
authorId: protocol.UuidValue.withValidation(_authorId),
|
||||
payload: card.payload,
|
||||
),
|
||||
_newRevision(idOf[card.position]!, const [], card.payload, keyId),
|
||||
]);
|
||||
_sync.requestSync();
|
||||
return [for (final card in cards) idOf[card.position]!.uuid];
|
||||
|
|
@ -128,14 +166,11 @@ class SyncCardGateway(
|
|||
}) async {
|
||||
final revision = await protocol.CardRevision.db.insertRow(
|
||||
_db,
|
||||
protocol.CardRevision(
|
||||
cardId: protocol.UuidValue.withValidation(cardId),
|
||||
parents: [
|
||||
for (final parent in parents)
|
||||
protocol.UuidValue.withValidation(parent),
|
||||
],
|
||||
authorId: protocol.UuidValue.withValidation(_authorId),
|
||||
payload: payload,
|
||||
_newRevision(
|
||||
protocol.UuidValue.withValidation(cardId),
|
||||
parents,
|
||||
payload,
|
||||
await _privacy.keyOfCard(cardId),
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
|
|
|
|||
|
|
@ -3,19 +3,26 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [LearningPathGateway] on the person's local database, which the sync
|
||||
/// engine keeps in step with their other devices. Step revisions record
|
||||
/// [_authorId] as their author.
|
||||
/// [_authorId] as their author. In a private studyset, paths and step
|
||||
/// revisions are sealed with their tree's key ([Privacy], ADR 0019); one
|
||||
/// that doesn't open here reads as blank.
|
||||
class SyncLearningPathGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final String _authorId,
|
||||
final Privacy _privacy,
|
||||
) implements LearningPathGateway {
|
||||
@override
|
||||
Future<List<PathRow>> paths({String? studysetId}) async => [
|
||||
Future<List<PathRow>> paths({String? studysetId}) async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final path in await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
where: studysetId == null
|
||||
|
|
@ -26,9 +33,21 @@ class SyncLearningPathGateway(
|
|||
id: path.id!.uuid,
|
||||
studysetId: path.studysetId.uuid,
|
||||
position: path.position,
|
||||
payload: path.payload,
|
||||
payload: _opened(
|
||||
'learning_path',
|
||||
path.id!.uuid,
|
||||
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// [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,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<String> insertPath({
|
||||
|
|
@ -36,12 +55,22 @@ class SyncLearningPathGateway(
|
|||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||
table: 'learning_path',
|
||||
id: id.uuid,
|
||||
);
|
||||
final path = await protocol.LearningPath.db.insertRow(
|
||||
_db,
|
||||
protocol.LearningPath(
|
||||
id: id,
|
||||
studysetId: _uuid(studysetId),
|
||||
position: position,
|
||||
payload: payload,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
|
|
@ -52,11 +81,21 @@ class SyncLearningPathGateway(
|
|||
Future<void> updatePathPayload(String id, ByteData payload) async {
|
||||
if (await protocol.LearningPath.db.findById(_db, _uuid(id))
|
||||
case final path?) {
|
||||
// Only the named column changes (ADR 0001, rule 1).
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(path.studysetId.uuid),
|
||||
table: 'learning_path',
|
||||
id: id,
|
||||
);
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.LearningPath.db.updateRow(
|
||||
_db,
|
||||
path.copyWith(payload: payload),
|
||||
columns: (t) => [t.payload],
|
||||
path.copyWith(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
|
@ -112,6 +151,7 @@ class SyncLearningPathGateway(
|
|||
if (stepIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final revision in await protocol.LearningPathStepRevision.db.find(
|
||||
_db,
|
||||
|
|
@ -121,7 +161,11 @@ class SyncLearningPathGateway(
|
|||
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,
|
||||
)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
@ -168,14 +212,10 @@ class SyncLearningPathGateway(
|
|||
// The batch's positions are distinct, so they tell the stored rows apart
|
||||
// whatever order they come back in.
|
||||
final idOf = {for (final row in rows) row.position: row.id!};
|
||||
final keyId = await _privacy.keyOfPath(pathId);
|
||||
await protocol.LearningPathStepRevision.db.insert(_db, [
|
||||
for (final step in steps)
|
||||
protocol.LearningPathStepRevision(
|
||||
stepId: idOf[step.position]!,
|
||||
parents: const [],
|
||||
authorId: _uuid(_authorId),
|
||||
payload: step.payload,
|
||||
),
|
||||
_newRevision(idOf[step.position]!, const [], step.payload, keyId),
|
||||
]);
|
||||
_sync.requestSync();
|
||||
return [for (final step in steps) idOf[step.position]!.uuid];
|
||||
|
|
@ -189,17 +229,42 @@ class SyncLearningPathGateway(
|
|||
}) async {
|
||||
final revision = await protocol.LearningPathStepRevision.db.insertRow(
|
||||
_db,
|
||||
protocol.LearningPathStepRevision(
|
||||
stepId: _uuid(stepId),
|
||||
parents: [for (final parent in parents) _uuid(parent)],
|
||||
authorId: _uuid(_authorId),
|
||||
payload: payload,
|
||||
_newRevision(
|
||||
_uuid(stepId),
|
||||
parents,
|
||||
payload,
|
||||
await _privacy.keyOfStep(stepId),
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return revision.id!.uuid;
|
||||
}
|
||||
|
||||
/// A new revision of [stepId] with [payload], sealed with [keyId].
|
||||
protocol.LearningPathStepRevision _newRevision(
|
||||
protocol.UuidValue stepId,
|
||||
Iterable<String> parents,
|
||||
ByteData payload,
|
||||
String? keyId,
|
||||
) {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: keyId,
|
||||
table: 'learning_path_step_revision',
|
||||
id: id.uuid,
|
||||
);
|
||||
return protocol.LearningPathStepRevision(
|
||||
id: id,
|
||||
stepId: stepId,
|
||||
parents: [for (final parent in parents) _uuid(parent)],
|
||||
authorId: _uuid(_authorId),
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStepPosition(String id, String position) async {
|
||||
if (await protocol.LearningPathStep.db.findById(_db, _uuid(id))
|
||||
|
|
|
|||
|
|
@ -1,19 +1,28 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_answer_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [PathAnswerGateway] on the person's local database, which the sync
|
||||
/// engine keeps in step with their other devices.
|
||||
/// engine keeps in step with their other devices. In a private studyset the
|
||||
/// answer itself is sealed with its tree's key ([Privacy], ADR 0019), its
|
||||
/// outcome and time readable; one that doesn't open here has no answer.
|
||||
class SyncPathAnswerGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final Privacy _privacy,
|
||||
) implements PathAnswerGateway {
|
||||
@override
|
||||
Future<List<AnswerRow>> answers({Set<String>? stepIds}) async {
|
||||
if (stepIds != null && stepIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final answer in await protocol.LearningPathAnswer.db.find(
|
||||
_db,
|
||||
|
|
@ -28,26 +37,71 @@ class SyncPathAnswerGateway(
|
|||
stepId: answer.stepId.uuid,
|
||||
answeredAt: answer.answeredAt,
|
||||
outcome: answer.outcome,
|
||||
answer: answer.answer,
|
||||
answer: _opened(answer),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// [row]'s answer as written, null where it has none or it doesn't open.
|
||||
String? _opened(protocol.LearningPathAnswer row) {
|
||||
final answer = row.answer;
|
||||
final envelope = answer == null
|
||||
? null
|
||||
: Privacy.answerEnvelope(row.keyId, row.nonce, answer);
|
||||
if (envelope == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (_privacy.open(
|
||||
envelope,
|
||||
table: 'learning_path_answer',
|
||||
id: row.id!.uuid,
|
||||
column: 'answer',
|
||||
)) {
|
||||
Readable(:final payload) => utf8.decode(
|
||||
Uint8List.sublistView(payload),
|
||||
allowMalformed: true,
|
||||
),
|
||||
Locked() => null,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> insert(List<LoggedAnswer> answers) async {
|
||||
if (answers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final keys = <String, String?>{
|
||||
for (final stepId in {for (final answer in answers) answer.stepId})
|
||||
stepId: await _privacy.keyOfStep(stepId),
|
||||
};
|
||||
await protocol.LearningPathAnswer.db.insert(_db, [
|
||||
for (final answer in answers)
|
||||
protocol.LearningPathAnswer(
|
||||
for (final answer in answers) _newAnswer(answer, keys[answer.stepId]),
|
||||
]);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
/// [answer]'s row, its answer sealed with [keyId].
|
||||
protocol.LearningPathAnswer _newAnswer(LoggedAnswer answer, String? keyId) {
|
||||
final id = Privacy.newId();
|
||||
final text = answer.answer;
|
||||
final sealed = text == null
|
||||
? null
|
||||
: _privacy.seal(
|
||||
ByteData.sublistView(utf8.encode(text)),
|
||||
keyId: keyId,
|
||||
table: 'learning_path_answer',
|
||||
id: id.uuid,
|
||||
column: 'answer',
|
||||
);
|
||||
return protocol.LearningPathAnswer(
|
||||
id: id,
|
||||
stepId: protocol.UuidValue.withValidation(answer.stepId),
|
||||
answeredAt: answer.answeredAt,
|
||||
outcome: answer.outcome,
|
||||
answer: answer.answer,
|
||||
),
|
||||
]);
|
||||
_sync.requestSync();
|
||||
answer: sealed == null ? null : Privacy.answerText(sealed),
|
||||
keyId: sealed?.keyId,
|
||||
nonce: sealed?.nonce,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -3,17 +3,21 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [NoteGateway] on the person's local database, which the sync engine keeps
|
||||
/// in step with their other devices. Revisions record [_authorId] as their
|
||||
/// author.
|
||||
/// author, and are sealed with their tree's key in a private studyset
|
||||
/// ([Privacy], ADR 0019); one that doesn't open here reads as blank.
|
||||
class SyncNoteGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final String _authorId,
|
||||
final Privacy _privacy,
|
||||
) implements NoteGateway {
|
||||
@override
|
||||
Future<List<NoteRow>> notesOf(String studysetId) async => [
|
||||
|
|
@ -36,6 +40,7 @@ class SyncNoteGateway(
|
|||
if (noteIds.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final revision in await protocol.NoteRevision.db.find(
|
||||
_db,
|
||||
|
|
@ -47,7 +52,18 @@ class SyncNoteGateway(
|
|||
id: revision.id!.uuid,
|
||||
noteId: revision.noteId.uuid,
|
||||
parents: {for (final parent in revision.parents) parent.uuid},
|
||||
payload: switch (_privacy.open(
|
||||
(
|
||||
keyId: revision.keyId,
|
||||
nonce: revision.nonce,
|
||||
payload: revision.payload,
|
||||
),
|
||||
table: 'note_revision',
|
||||
id: revision.id!.uuid,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
},
|
||||
writtenAt: uuidV7Time(revision.id!.uuid),
|
||||
),
|
||||
];
|
||||
|
|
@ -80,16 +96,26 @@ class SyncNoteGateway(
|
|||
required Set<String> parents,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfNote(noteId),
|
||||
table: 'note_revision',
|
||||
id: id.uuid,
|
||||
);
|
||||
final revision = await protocol.NoteRevision.db.insertRow(
|
||||
_db,
|
||||
protocol.NoteRevision(
|
||||
id: id,
|
||||
noteId: protocol.UuidValue.withValidation(noteId),
|
||||
parents: [
|
||||
for (final parent in parents)
|
||||
protocol.UuidValue.withValidation(parent),
|
||||
],
|
||||
authorId: protocol.UuidValue.withValidation(_authorId),
|
||||
payload: payload,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
|
|
|
|||
|
|
@ -2,17 +2,24 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [PdfSourceGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
/// keeps in step with their other devices. In a private studyset a PDF's
|
||||
/// payload is sealed with its tree's key ([Privacy], ADR 0019).
|
||||
class SyncPdfSourceGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final Privacy _privacy,
|
||||
) implements PdfSourceGateway {
|
||||
@override
|
||||
Future<List<PdfSourceRow>> sources({String? studysetId}) async => [
|
||||
Future<List<PdfSourceRow>> sources({String? studysetId}) async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final source in await protocol.PdfSource.db.find(
|
||||
_db,
|
||||
where: studysetId == null
|
||||
|
|
@ -21,6 +28,7 @@ class SyncPdfSourceGateway(
|
|||
))
|
||||
_row(source),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PdfSourceRow?> byId(String id) async {
|
||||
|
|
@ -44,12 +52,22 @@ class SyncPdfSourceGateway(
|
|||
required String position,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(studysetId),
|
||||
table: 'pdf_source',
|
||||
id: id.uuid,
|
||||
);
|
||||
final source = await protocol.PdfSource.db.insertRow(
|
||||
_db,
|
||||
protocol.PdfSource(
|
||||
id: id,
|
||||
studysetId: _uuid(studysetId),
|
||||
position: position,
|
||||
payload: payload,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
|
|
@ -60,11 +78,21 @@ class SyncPdfSourceGateway(
|
|||
Future<void> updatePayload(String id, ByteData payload) async {
|
||||
if (await protocol.PdfSource.db.findById(_db, _uuid(id))
|
||||
case final source?) {
|
||||
// Only the named column changes (ADR 0001, rule 1).
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(source.studysetId.uuid),
|
||||
table: 'pdf_source',
|
||||
id: id,
|
||||
);
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.PdfSource.db.updateRow(
|
||||
_db,
|
||||
source.copyWith(payload: payload),
|
||||
columns: (t) => [t.payload],
|
||||
source.copyWith(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
|
@ -162,7 +190,16 @@ class SyncPdfSourceGateway(
|
|||
id: source.id!.uuid,
|
||||
studysetId: source.studysetId.uuid,
|
||||
position: source.position,
|
||||
payload: source.payload,
|
||||
// A PDF in a private studyset whose key doesn't open here reads as
|
||||
// blank (ADR 0019).
|
||||
payload: switch (_privacy.open(
|
||||
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
|
||||
table: 'pdf_source',
|
||||
id: source.id!.uuid,
|
||||
)) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => redactedPayload,
|
||||
},
|
||||
);
|
||||
|
||||
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
|
||||
|
|
|
|||
|
|
@ -2,26 +2,52 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [SettingsGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
/// keeps in step with their other devices. The settings are sealed with
|
||||
/// the person's key where this device holds it ([Privacy], ADR 0019, D2); a
|
||||
/// row that doesn't open here is left out, so nothing here folds it away.
|
||||
class SyncSettingsGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final Privacy _privacy,
|
||||
) implements SettingsGateway {
|
||||
@override
|
||||
Future<List<SettingsRow>> rows() async => [
|
||||
Future<List<SettingsRow>> rows() async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final row in await protocol.PersonSettings.db.find(_db))
|
||||
(id: row.id!.uuid, payload: row.payload),
|
||||
if (_privacy.open(
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
table: 'person_settings',
|
||||
id: row.id!.uuid,
|
||||
)
|
||||
case Readable(:final payload))
|
||||
(id: row.id!.uuid, payload: payload),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insert(ByteData payload) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.personalKey(),
|
||||
table: 'person_settings',
|
||||
id: id.uuid,
|
||||
);
|
||||
final row = await protocol.PersonSettings.db.insertRow(
|
||||
_db,
|
||||
protocol.PersonSettings(payload: payload),
|
||||
protocol.PersonSettings(
|
||||
id: id,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return row.id!.uuid;
|
||||
|
|
@ -33,11 +59,21 @@ class SyncSettingsGateway(
|
|||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
// Only the named column changes (ADR 0001, rule 1).
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.personalKey(),
|
||||
table: 'person_settings',
|
||||
id: id,
|
||||
);
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.PersonSettings.db.updateRow(
|
||||
_db,
|
||||
row.copyWith(payload: payload),
|
||||
columns: (t) => [t.payload],
|
||||
row.copyWith(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,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).
|
||||
/// 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.
|
||||
typedef StudysetRow = ({
|
||||
String id,
|
||||
ByteData payload,
|
||||
|
|
@ -11,6 +13,8 @@ typedef StudysetRow = ({
|
|||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
bool isPrivate,
|
||||
bool locked,
|
||||
});
|
||||
|
||||
/// The boundary to the studysets in this person's local, synced database.
|
||||
|
|
@ -25,7 +29,9 @@ abstract interface class StudysetGateway() {
|
|||
|
||||
/// Stores a new studyset, made at [updatedAt], inside [parentId] (or at
|
||||
/// the top level) at [position] among its siblings, and returns its id, a
|
||||
/// UUID v7 made on the device.
|
||||
/// UUID v7 made on the device. Inside a private tree it is private too; at
|
||||
/// the top level it is private where this device holds the account's
|
||||
/// keys (ADR 0019).
|
||||
Future<String> insert(
|
||||
ByteData payload,
|
||||
DateTime updatedAt, {
|
||||
|
|
@ -33,6 +39,15 @@ abstract interface class StudysetGateway() {
|
|||
String position = 'a0',
|
||||
});
|
||||
|
||||
/// Makes the top-level studyset [id] private, with everything inside it,
|
||||
/// sealed with a new key, or readable again (ADR 0019); how many of its
|
||||
/// rows couldn't be opened to do so, which stay as they were.
|
||||
Future<int> setPrivate(String id, {required bool private});
|
||||
|
||||
/// Whether this device can make a studyset private: it holds the
|
||||
/// account's keys (ADR 0019, D8).
|
||||
bool get canMakePrivate;
|
||||
|
||||
/// Replaces a studyset's payload, as of [updatedAt].
|
||||
Future<void> updatePayload(String id, ByteData payload, DateTime updatedAt);
|
||||
|
||||
|
|
|
|||
|
|
@ -212,18 +212,23 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
|||
});
|
||||
|
||||
/// The studyset in [row], or null for a row this app can't read: one
|
||||
/// written by a newer app version, or one being deleted.
|
||||
/// written by a newer app version, or one being deleted. A private one
|
||||
/// whose key doesn't open here is there, locked, with no content.
|
||||
Studyset? _decode(StudysetRow row) {
|
||||
try {
|
||||
return Studyset(
|
||||
id: row.id,
|
||||
content: decodeStudysetPayload(row.payload),
|
||||
content: row.locked
|
||||
? StudysetContent.titled('')
|
||||
: decodeStudysetPayload(row.payload),
|
||||
parentId: row.parentId,
|
||||
position: row.position,
|
||||
archivedAt: row.archivedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
starredAt: row.starredAt,
|
||||
updatedAt: row.updatedAt,
|
||||
isPrivate: row.isPrivate,
|
||||
isLocked: row.locked,
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
|
||||
|
|
|
|||
|
|
@ -2,34 +2,59 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [StudysetGateway] on the person's local database, which the sync engine
|
||||
/// keeps in step with their other devices.
|
||||
/// keeps in step with their other devices. A private studyset's payload is
|
||||
/// sealed with its tree's key ([Privacy], ADR 0019).
|
||||
class SyncStudysetGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final Privacy _privacy,
|
||||
) implements StudysetGateway {
|
||||
@override
|
||||
Future<List<StudysetRow>> all() async => [
|
||||
// UUID v7 ids are time-ordered: this is the order of creation (ADR 0001,
|
||||
// rule 7).
|
||||
Future<List<StudysetRow>> all() async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
// UUID v7 ids are time-ordered: this is the order of creation (ADR
|
||||
// 0001, rule 7).
|
||||
for (final studyset in await protocol.Studyset.db.find(
|
||||
_db,
|
||||
orderBy: (t) => t.id,
|
||||
))
|
||||
_row(studyset),
|
||||
];
|
||||
}
|
||||
|
||||
StudysetRow _row(protocol.Studyset studyset) {
|
||||
final opened = _privacy.open(
|
||||
(
|
||||
id: studyset.id!.uuid,
|
||||
keyId: studyset.keyId,
|
||||
nonce: studyset.nonce,
|
||||
payload: studyset.payload,
|
||||
),
|
||||
table: 'studyset',
|
||||
id: studyset.id!.uuid,
|
||||
);
|
||||
return (
|
||||
id: studyset.id!.uuid,
|
||||
payload: switch (opened) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => ByteData(0),
|
||||
},
|
||||
parentId: studyset.parentId?.uuid,
|
||||
position: studyset.position,
|
||||
archivedAt: studyset.archivedAt,
|
||||
deletedAt: studyset.deletedAt,
|
||||
starredAt: studyset.starredAt,
|
||||
updatedAt: studyset.updatedAt,
|
||||
),
|
||||
];
|
||||
isPrivate: studyset.keyId != null,
|
||||
locked: opened is Locked,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> insert(
|
||||
|
|
@ -38,17 +63,35 @@ class SyncStudysetGateway(
|
|||
String? parentId,
|
||||
String position = 'a0',
|
||||
}) async {
|
||||
final studyset = await protocol.Studyset.db.insertRow(
|
||||
final id = Privacy.newId();
|
||||
// Inside a tree, its key; at the top level, a key of its own where this
|
||||
// device can encrypt: new studysets are private (ADR 0017, D5).
|
||||
final keyId = parentId != null
|
||||
? await _privacy.keyOfStudyset(parentId)
|
||||
: _privacy.keys.canEncrypt
|
||||
? await _privacy.keys.newStudysetKey(id.uuid)
|
||||
: null;
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: keyId,
|
||||
table: 'studyset',
|
||||
id: id.uuid,
|
||||
);
|
||||
await protocol.Studyset.db.insertRow(
|
||||
_db,
|
||||
protocol.Studyset(
|
||||
payload: payload,
|
||||
id: id,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
updatedAt: updatedAt,
|
||||
parentId: _uuid(parentId),
|
||||
position: position,
|
||||
),
|
||||
);
|
||||
_privacy.forgetTrees();
|
||||
_sync.requestSync();
|
||||
return studyset.id!.uuid;
|
||||
return id.uuid;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -58,10 +101,21 @@ class SyncStudysetGateway(
|
|||
DateTime updatedAt,
|
||||
) async {
|
||||
if (await _find(id) case final studyset?) {
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.keyOfStudyset(id),
|
||||
table: 'studyset',
|
||||
id: id,
|
||||
);
|
||||
await protocol.Studyset.db.updateRow(
|
||||
_db,
|
||||
studyset.copyWith(payload: payload, updatedAt: updatedAt),
|
||||
columns: (t) => [t.payload, t.updatedAt],
|
||||
studyset.copyWith(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
updatedAt: updatedAt,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload, t.updatedAt],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
|
@ -100,10 +154,34 @@ class SyncStudysetGateway(
|
|||
),
|
||||
columns: (t) => [t.parentId, t.position, t.updatedAt],
|
||||
);
|
||||
// Moved into another tree, it takes on that tree's privacy (ADR
|
||||
// 0019); moved to the top level, it keeps its own.
|
||||
_privacy.forgetTrees();
|
||||
if (parentId != null) {
|
||||
final keyId = await _privacy.keyOfStudyset(parentId);
|
||||
if (keyId != studyset.keyId) {
|
||||
await _privacy.rekeyTree(id, keyId);
|
||||
}
|
||||
}
|
||||
_sync.requestSync();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> setPrivate(String id, {required bool private}) async {
|
||||
final studyset = await _find(id);
|
||||
if (studyset == null || (studyset.keyId != null) == private) {
|
||||
return 0;
|
||||
}
|
||||
final keyId = private ? await _privacy.keys.newStudysetKey(id) : null;
|
||||
final locked = await _privacy.rekeyTree(id, keyId);
|
||||
_sync.requestSync();
|
||||
return locked;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get canMakePrivate => _privacy.keys.canEncrypt;
|
||||
|
||||
@override
|
||||
Future<void> updatePosition(
|
||||
String id,
|
||||
|
|
@ -158,11 +236,15 @@ class SyncStudysetGateway(
|
|||
_db,
|
||||
where: (t) => t.id.equals(protocol.UuidValue.withValidation(id)),
|
||||
);
|
||||
_privacy.forgetTrees();
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _sync.merges;
|
||||
Stream<void> get remoteChanges => _sync.merges.map((_) {
|
||||
// Studysets may have moved or changed their privacy elsewhere.
|
||||
_privacy.forgetTrees();
|
||||
});
|
||||
|
||||
/// The id [id] as the database stores it; null stays null.
|
||||
protocol.UuidValue? _uuid(String? id) =>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@ final class const Studyset({
|
|||
/// When it last changed itself (its content, place, star or archive), in
|
||||
/// UTC; null for a studyset from before that was recorded.
|
||||
final DateTime? updatedAt,
|
||||
|
||||
/// Whether it is private (ADR 0019): end-to-end encrypted, with
|
||||
/// everything in its tree, which the instance can't read.
|
||||
final bool isPrivate = false,
|
||||
|
||||
/// Whether it is private and its key doesn't open on this device (lost
|
||||
/// in an account reset without the recovery phrase): nothing of it can be
|
||||
/// read, only deleted.
|
||||
final bool isLocked = false,
|
||||
}) with Equatable {
|
||||
/// Whether it was archived itself; a studyset inside an archived one rests
|
||||
/// in the archive too.
|
||||
|
|
@ -57,6 +66,8 @@ final class const Studyset({
|
|||
deletedAt,
|
||||
starredAt,
|
||||
updatedAt,
|
||||
isPrivate,
|
||||
isLocked,
|
||||
];
|
||||
|
||||
/// This studyset with [content] instead of its own.
|
||||
|
|
@ -69,5 +80,7 @@ final class const Studyset({
|
|||
deletedAt: deletedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: isPrivate,
|
||||
isLocked: isLocked,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,34 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [TagGateway] on the person's local database, which the sync engine keeps
|
||||
/// in step with their other devices.
|
||||
/// in step with their other devices. Tags are sealed with the person's key
|
||||
/// where this device holds it ([Privacy], ADR 0019, D2); one that doesn't
|
||||
/// open here is left out, so nothing here folds or deletes it.
|
||||
class SyncTagGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
final Privacy _privacy,
|
||||
) implements TagGateway {
|
||||
@override
|
||||
Future<List<TagRow>> tags() async => [
|
||||
Future<List<TagRow>> tags() async {
|
||||
await _privacy.keys.refresh();
|
||||
return [
|
||||
for (final tag in await protocol.Tag.db.find(_db))
|
||||
(id: tag.id!.uuid, payload: tag.payload),
|
||||
if (_privacy.open(
|
||||
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
|
||||
table: 'tag',
|
||||
id: tag.id!.uuid,
|
||||
)
|
||||
case Readable(:final payload))
|
||||
(id: tag.id!.uuid, payload: payload),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CardTagRow>> cardTags() async => [
|
||||
|
|
@ -49,9 +63,21 @@ class SyncTagGateway(
|
|||
|
||||
@override
|
||||
Future<String> insertTag(ByteData payload) async {
|
||||
final id = Privacy.newId();
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.personalKey(),
|
||||
table: 'tag',
|
||||
id: id.uuid,
|
||||
);
|
||||
final tag = await protocol.Tag.db.insertRow(
|
||||
_db,
|
||||
protocol.Tag(payload: payload),
|
||||
protocol.Tag(
|
||||
id: id,
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return tag.id!.uuid;
|
||||
|
|
@ -63,11 +89,21 @@ class SyncTagGateway(
|
|||
if (tag == null) {
|
||||
return;
|
||||
}
|
||||
// Only the named column changes (ADR 0001, rule 1).
|
||||
final sealed = _privacy.seal(
|
||||
payload,
|
||||
keyId: await _privacy.personalKey(),
|
||||
table: 'tag',
|
||||
id: id,
|
||||
);
|
||||
// Only the named columns change (ADR 0001, rule 1).
|
||||
await protocol.Tag.db.updateRow(
|
||||
_db,
|
||||
tag.copyWith(payload: payload),
|
||||
columns: (t) => [t.payload],
|
||||
tag.copyWith(
|
||||
keyId: sealed.keyId,
|
||||
nonce: sealed.nonce,
|
||||
payload: sealed.payload,
|
||||
),
|
||||
columns: (t) => [t.keyId, t.nonce, t.payload],
|
||||
);
|
||||
_sync.requestSync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
|
||||
import '../../support/fake_content_key_gateway.dart';
|
||||
|
||||
void main() {
|
||||
final crypto = FakeAccountCrypto();
|
||||
final alice = crypto.keysNumbered(1);
|
||||
final bob = crypto.keysNumbered(2);
|
||||
late FakeContentKeyGateway synced;
|
||||
|
||||
setUp(() => synced = FakeContentKeyGateway());
|
||||
|
||||
/// A device of the person whose keys are [account] (none on it with
|
||||
/// null), on the keys [synced] holds.
|
||||
Future<ContentKeys> device(UnlockedAccountKeys? account) async {
|
||||
final keys = ContentKeys(
|
||||
synced,
|
||||
FakeContentCipher(),
|
||||
() async => account,
|
||||
);
|
||||
addTearDown(keys.dispose);
|
||||
await keys.refresh();
|
||||
return keys;
|
||||
}
|
||||
|
||||
ByteData bytes(String text) => ByteData.sublistView(utf8.encode(text));
|
||||
|
||||
test("a studyset's key made on one device opens on the person's others, "
|
||||
'and what it seals opens there for its row alone', () async {
|
||||
final laptop = await device(alice);
|
||||
final keyId = await laptop.newStudysetKey('studyset-1');
|
||||
final sealed = PayloadEnvelope(laptop.cipher).seal(
|
||||
bytes('{"title":"Diary"}'),
|
||||
keyId: keyId,
|
||||
table: 'studyset',
|
||||
id: 'studyset-1',
|
||||
);
|
||||
|
||||
final phone = await device(alice);
|
||||
final envelope = PayloadEnvelope(phone.cipher);
|
||||
|
||||
expect(synced.rows.single.kind, ContentKeyKind.studyset);
|
||||
expect(synced.rows.single.studysetId, 'studyset-1');
|
||||
expect(phone.canOpen(keyId), isTrue);
|
||||
expect(sealed.keyId, keyId);
|
||||
expect(sealed.nonce, isNotNull);
|
||||
expect(
|
||||
envelope.open(sealed, table: 'studyset', id: 'studyset-1'),
|
||||
Readable(bytes('{"title":"Diary"}')),
|
||||
);
|
||||
expect(
|
||||
envelope.open(sealed, table: 'studyset', id: 'studyset-2'),
|
||||
const Locked(),
|
||||
);
|
||||
expect(
|
||||
envelope.open(sealed, table: 'card_revision', id: 'studyset-1'),
|
||||
const Locked(),
|
||||
);
|
||||
});
|
||||
|
||||
test("another account's device opens nothing of it", () async {
|
||||
final laptop = await device(alice);
|
||||
final keyId = await laptop.newStudysetKey('studyset-1');
|
||||
final sealed = PayloadEnvelope(laptop.cipher).seal(
|
||||
bytes('secret'),
|
||||
keyId: keyId,
|
||||
table: 'studyset',
|
||||
id: 'studyset-1',
|
||||
);
|
||||
|
||||
final bobs = await device(bob);
|
||||
|
||||
expect(bobs.canOpen(keyId), isFalse);
|
||||
expect(
|
||||
PayloadEnvelope(bobs.cipher).open(
|
||||
sealed,
|
||||
table: 'studyset',
|
||||
id: 'studyset-1',
|
||||
),
|
||||
const Locked(),
|
||||
);
|
||||
});
|
||||
|
||||
test('a device without the account keys makes nothing private and opens '
|
||||
'nothing', () async {
|
||||
final laptop = await device(alice);
|
||||
final keyId = await laptop.newStudysetKey('studyset-1');
|
||||
final signedInElsewhere = await device(null);
|
||||
|
||||
expect(signedInElsewhere.canEncrypt, isFalse);
|
||||
expect(signedInElsewhere.canOpen(keyId), isFalse);
|
||||
expect(await signedInElsewhere.personalKey(), isNull);
|
||||
await expectLater(
|
||||
signedInElsewhere.newStudysetKey('studyset-2'),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
|
||||
test('the personal key is made once, and of two made at once on two '
|
||||
'devices the oldest is written with', () async {
|
||||
final laptop = await device(alice);
|
||||
final phone = await device(alice);
|
||||
final first = await laptop.personalKey();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 2));
|
||||
final second = await phone.personalKey();
|
||||
|
||||
expect(await laptop.personalKey(), first);
|
||||
expect(synced.rows, hasLength(2));
|
||||
await phone.refresh();
|
||||
expect(await phone.personalKey(), first);
|
||||
expect(second, isNot(first));
|
||||
});
|
||||
|
||||
test('readable and blank payloads pass as they are; a key not open here '
|
||||
'leaves the row locked', () async {
|
||||
final keys = await device(alice);
|
||||
final envelope = PayloadEnvelope(keys.cipher);
|
||||
|
||||
final readable = envelope.seal(
|
||||
bytes('plain'),
|
||||
keyId: null,
|
||||
table: 'tag',
|
||||
id: 't',
|
||||
);
|
||||
expect(readable.keyId, isNull);
|
||||
expect(readable.nonce, isNull);
|
||||
expect(utf8.decode(Uint8List.sublistView(readable.payload)), 'plain');
|
||||
expect(
|
||||
envelope.open(
|
||||
(keyId: 'some-key', nonce: null, payload: ByteData(0)),
|
||||
table: 'tag',
|
||||
id: 't',
|
||||
),
|
||||
Readable(ByteData(0)),
|
||||
);
|
||||
expect(
|
||||
envelope.open(
|
||||
(keyId: 'unknown', nonce: ByteData(24), payload: bytes('xyz')),
|
||||
table: 'tag',
|
||||
id: 't',
|
||||
),
|
||||
const Locked(),
|
||||
);
|
||||
expect(
|
||||
() => envelope.seal(bytes('x'), keyId: 'unknown', table: 'tag', id: 't'),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/payload_envelope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
|
||||
import '../../support/fake_content_key_gateway.dart';
|
||||
|
||||
/// Every payload codec's content, sealed with a key as a private studyset's
|
||||
/// rows are (ADR 0019), reads back as it was written; for another row it
|
||||
/// doesn't open at all.
|
||||
void main() {
|
||||
late PayloadEnvelope envelope;
|
||||
late String keyId;
|
||||
|
||||
setUp(() async {
|
||||
final keys = ContentKeys(
|
||||
FakeContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
addTearDown(keys.dispose);
|
||||
await keys.refresh();
|
||||
keyId = await keys.newStudysetKey('studyset-1');
|
||||
envelope = PayloadEnvelope(keys.cipher);
|
||||
});
|
||||
|
||||
/// [payload] sealed for [table] and row `row-1`, then opened there.
|
||||
ByteData roundTrip(String table, ByteData payload) {
|
||||
final sealed = envelope.seal(
|
||||
payload,
|
||||
keyId: keyId,
|
||||
table: table,
|
||||
id: 'row-1',
|
||||
);
|
||||
expect(
|
||||
Uint8List.sublistView(sealed.payload),
|
||||
isNot(Uint8List.sublistView(payload)),
|
||||
);
|
||||
expect(
|
||||
envelope.open(sealed, table: table, id: 'row-2'),
|
||||
const Locked(),
|
||||
reason: 'a payload moved to another row never opens',
|
||||
);
|
||||
return switch (envelope.open(sealed, table: table, id: 'row-1')) {
|
||||
Readable(:final payload) => payload,
|
||||
Locked() => fail('$table did not open'),
|
||||
};
|
||||
}
|
||||
|
||||
test('a studyset', () {
|
||||
final content = StudysetContent.titled('Diary');
|
||||
expect(
|
||||
decodeStudysetPayload(
|
||||
roundTrip('studyset', encodeStudysetPayload(content)),
|
||||
),
|
||||
content,
|
||||
);
|
||||
});
|
||||
|
||||
test('a card', () {
|
||||
final content = CardContent.empty();
|
||||
expect(
|
||||
decodeCardPayload(roundTrip('card_revision', encodeCardPayload(content))),
|
||||
content,
|
||||
);
|
||||
});
|
||||
|
||||
test('a note', () {
|
||||
final content = NoteContent(title: 'Secret', body: RichContent.empty());
|
||||
expect(
|
||||
decodeNotePayload(roundTrip('note_revision', encodeNotePayload(content))),
|
||||
content,
|
||||
);
|
||||
});
|
||||
|
||||
test('a learning path and a step', () {
|
||||
final path = LearningPathContent(
|
||||
title: 'Hiragana',
|
||||
description: RichContent.empty(),
|
||||
);
|
||||
final step = StepContent(
|
||||
body: RichContent.empty(),
|
||||
rightFeedback: RichContent.empty(),
|
||||
wrongFeedback: RichContent.empty(),
|
||||
);
|
||||
expect(
|
||||
decodeLearningPathPayload(
|
||||
roundTrip('learning_path', encodeLearningPathPayload(path)),
|
||||
),
|
||||
path,
|
||||
);
|
||||
expect(
|
||||
decodeStepPayload(
|
||||
roundTrip('learning_path_step_revision', encodeStepPayload(step)),
|
||||
StepKind.info,
|
||||
),
|
||||
step,
|
||||
);
|
||||
});
|
||||
|
||||
test('a PDF', () {
|
||||
const content = PdfSourceContent(
|
||||
title: 'A book',
|
||||
fileName: 'book.pdf',
|
||||
assetId: 'asset-1',
|
||||
);
|
||||
expect(
|
||||
decodePdfSourcePayload(
|
||||
roundTrip('pdf_source', encodePdfSourcePayload(content)),
|
||||
),
|
||||
content,
|
||||
);
|
||||
});
|
||||
|
||||
test('a tag and the settings, with the personal key', () {
|
||||
const TagContent tag = (name: 'あかね噺 S1 E1', colorName: 'blue');
|
||||
const settings = PersonSettings();
|
||||
expect(
|
||||
decodeTagPayload(roundTrip('tag', encodeTagPayload(tag))),
|
||||
tag,
|
||||
);
|
||||
expect(
|
||||
decodeSettingsPayload(
|
||||
roundTrip('person_settings', encodeSettingsPayload(settings)),
|
||||
),
|
||||
settings,
|
||||
);
|
||||
});
|
||||
|
||||
test("a file's name", () {
|
||||
expect(
|
||||
decodeAssetPayload(
|
||||
roundTrip('asset', encodeAssetPayload(fileName: 'photo.png')),
|
||||
),
|
||||
'photo.png',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_gateway.dart';
|
||||
|
||||
/// [ContentKeyGateway] in memory; several devices of one person share one,
|
||||
/// as their sync would.
|
||||
class FakeContentKeyGateway() implements ContentKeyGateway {
|
||||
/// The keys kept.
|
||||
final List<ContentKeyRow> rows = [];
|
||||
|
||||
@override
|
||||
Future<List<ContentKeyRow>> all() async => [...rows];
|
||||
|
||||
@override
|
||||
Future<void> insert({
|
||||
required String dataKeyId,
|
||||
required ContentKeyKind kind,
|
||||
required String? studysetId,
|
||||
required Uint8List sealedKey,
|
||||
required DateTime createdAt,
|
||||
}) async => rows.add((
|
||||
id: 'key-${rows.length + 1}',
|
||||
dataKeyId: dataKeyId,
|
||||
kind: kind,
|
||||
studysetId: studysetId,
|
||||
sealedKey: sealedKey,
|
||||
createdAt: createdAt,
|
||||
));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue