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
2 changed files with 1693 additions and 151 deletions
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue