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:
Tizian 「ティツィアーン」 2026-10-21 21:21:31 +02:00
commit 64fdfa091d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
24 changed files with 1693 additions and 151 deletions

View file

@ -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();
}
},

View file

@ -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,
});
}

View file

@ -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('=', '');
}

View file

@ -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');
}

View file

@ -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);
}

View file

@ -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();
}
}

View file

@ -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();

View file

@ -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))

View file

@ -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

View file

@ -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();

View file

@ -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);

View file

@ -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();
}

View file

@ -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);

View file

@ -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');

View file

@ -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) =>

View file

@ -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,
);
}

View file

@ -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();
}

View file

@ -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,
);
});
}

View file

@ -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',
);
});
}

View file

@ -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