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
2 changed files with 1693 additions and 151 deletions

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

View file

@ -26,6 +26,10 @@ class FakeStudysetGateway() implements StudysetGateway {
/// How often the studysets were read.
int reads = 0;
/// Whether a new top-level studyset is private, as on a device holding
/// the account's keys (ADR 0019).
bool privateByDefault = false;
/// Stores [payload] as if another device had written it and a sync had
/// brought it here, with the other columns given.
void receiveRemoteChange(
@ -37,6 +41,8 @@ class FakeStudysetGateway() implements StudysetGateway {
DateTime? deletedAt,
DateTime? starredAt,
DateTime? updatedAt,
bool isPrivate = false,
bool locked = false,
}) {
_rows[id] = (
id: id,
@ -47,6 +53,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: deletedAt,
starredAt: starredAt,
updatedAt: updatedAt,
isPrivate: isPrivate,
locked: locked,
);
_remoteChanges.add(null);
}
@ -58,6 +66,43 @@ class FakeStudysetGateway() implements StudysetGateway {
_remoteChanges.add(null);
}
/// Every change of privacy, in order, as (id, private).
final privacyChanges = <(String, bool)>[];
@override
bool canMakePrivate = true;
@override
Future<int> setPrivate(String id, {required bool private}) async {
final tree = {id};
var grew = true;
while (grew) {
grew = false;
for (final row in _rows.values) {
if (tree.contains(row.parentId) && tree.add(row.id)) {
grew = true;
}
}
}
for (final member in tree) {
final row = _rows[member]!;
_rows[member] = (
id: row.id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: row.updatedAt,
isPrivate: private,
locked: row.locked,
);
}
privacyChanges.add((id, private));
return 0;
}
@override
Future<List<StudysetRow>> all() async {
reads++;
@ -84,6 +129,13 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: null,
starredAt: null,
updatedAt: updatedAt,
// New studysets are private where the device can encrypt (ADR 0019):
// the parent's tree's privacy, or [privateByDefault] at the top.
isPrivate: switch (parentId) {
final parent? => _rows[parent]?.isPrivate ?? false,
null => privateByDefault,
},
locked: false,
);
writes.add((id, payload));
return id;
@ -105,6 +157,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
);
writes.add((id, payload));
}
@ -126,6 +180,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: row.deletedAt,
starredAt: starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
);
}
}
@ -147,6 +203,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
);
moves.add((id, parentId, position));
}
@ -168,6 +226,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
);
moves.add((id, row.parentId, position));
}
@ -189,6 +249,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
);
}
}
@ -209,6 +271,8 @@ class FakeStudysetGateway() implements StudysetGateway {
deletedAt: deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
);
}
}