feat(privacy): a private studyset's files are sealed with its key (ADR 0019, D5)

- `AssetRepository` seals a file of a private studyset with its tree's key
  for its asset row before hashing it (`AssetSealing`, which `Privacy`
  does): the row's hash and size and the file uploaded are the sealed
  file's, the upload says it is encrypted, and the row's payload, sealed
  too, carries the file name and the hash of what was sealed (asset
  schema 2). A download is checked against the row's hash, opened for its
  row and checked against what was sealed. This device keeps the sealed
  file to upload and the opened one, by its own hash, for what reads files
  by path.
- The same file is a row per key: in a private and a readable studyset, or
  two private trees, it is two.
- Every place adding a file names its studyset: the editors' pictures and
  sounds (`StudysetFiles`, which every screen of a studyset and the PDF
  reader set), PDFs and screenshots, Anki imports, copies and their updates
  (a copy's top studyset is made first, so its files take its key), and
  accepted suggestions (by their item's tree).
- Making a tree private or not, or moving a topic into another tree, moves
  its files: each under another key is added anew under the tree's, the
  content names the new row (whole `asset:` values and a PDF's file, never
  text), and the old row is blanked and deleted once nothing else names it.
- Tests: a private studyset's file never uploads readable; a sealed file
  moved to another row, or under a key not open here, doesn't open; a
  changed file is refused by its hash; one row per key.

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 22:57:08 +02:00
commit e368bba2dd
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 1402 additions and 228 deletions

View file

@ -2,8 +2,19 @@ import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
/// An asset row as tests write one: readable, unless
/// `FakeAssetGateway.receiveRemote` is told its key.
typedef FakeAssetRow = ({
String id,
String sha256,
String mimeType,
int byteSize,
ByteData payload,
});
/// An [AssetGateway] in memory: ids are `asset-001`, `asset-002`, … in the
/// order of creation.
/// order of creation. Payloads are kept as given: sealing them is the
/// envelope's, which its own tests cover.
class FakeAssetGateway() implements AssetGateway {
final _rows = <String, AssetRow>{};
var _next = 1;
@ -15,8 +26,18 @@ class FakeAssetGateway() implements AssetGateway {
final deleted = <String>[];
/// Stores [row] as if another device had written it and a sync had
/// brought it here.
void receiveRemote(AssetRow row) => _rows[row.id] = row;
/// brought it here, sealed with [keyId] if given, [locked] where that key
/// doesn't open here.
void receiveRemote(FakeAssetRow row, {String? keyId, bool locked = false}) =>
_rows[row.id] = (
id: row.id,
sha256: row.sha256,
mimeType: row.mimeType,
byteSize: row.byteSize,
payload: row.payload,
keyId: keyId,
locked: locked,
);
@override
Future<AssetRow?> byId(String id) async => _rows[id];
@ -25,20 +46,29 @@ class FakeAssetGateway() implements AssetGateway {
Future<AssetRow?> bySha256(String sha256) async =>
_rows.values.where((row) => row.sha256 == sha256).firstOrNull;
@override
Future<List<AssetRow>> all() async => [..._rows.values];
@override
String newId() => 'asset-${'${_next++}'.padLeft(3, '0')}';
@override
Future<String> insert({
required String id,
required String sha256,
required String mimeType,
required int byteSize,
required ByteData payload,
String? keyId,
}) async {
final id = 'asset-${'${_next++}'.padLeft(3, '0')}';
_rows[id] = (
id: id,
sha256: sha256,
mimeType: mimeType,
byteSize: byteSize,
payload: payload,
keyId: keyId,
locked: false,
);
return id;
}

View file

@ -0,0 +1,75 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
/// [AssetSealing] for tests: the studysets in [keys] are private, sealed
/// with the key named; the others readable. Files are sealed with
/// [FakeContentCipher], bound to their row as the real ones are, behind a
/// header naming their key.
class FakeAssetSealing({
/// Each private studyset's key, by its id.
final Map<String, String> keys = const {},
/// Each private item's key, by its id.
final Map<String, String> itemKeys = const {},
}) implements AssetSealing {
/// The cipher the keys are open in; [keys]' keys are opened on first use.
final cipher = FakeContentCipher();
var _unlocked = false;
void _unlock() {
if (_unlocked) {
return;
}
_unlocked = true;
unawaited(cipher.unlock(FakeAccountCrypto().keysNumbered(1)));
<String>{...keys.values, ...itemKeys.values}.forEach(cipher.newKey);
}
@override
Future<String?> keyOfStudyset(String studysetId) async => keys[studysetId];
@override
Future<String?> keyOfItem(ItemKind kind, String id) async => itemKeys[id];
@override
Uint8List sealFile(
Uint8List plain, {
required String keyId,
required String assetId,
}) {
_unlock();
final sealed = cipher.seal(
plain,
keyId: keyId,
context: utf8.encode('asset|$assetId|file'),
);
final id = utf8.encode(keyId);
return Uint8List.fromList([id.length, ...id, ...sealed.payload]);
}
@override
Uint8List? openFile(Uint8List sealed, {required String assetId}) {
_unlock();
if (sealed.isEmpty || sealed.length < 1 + sealed[0]) {
return null;
}
final keyId = utf8.decode(
Uint8List.sublistView(sealed, 1, 1 + sealed[0]),
allowMalformed: true,
);
return cipher.unseal(
SealedPayload(
keyId: keyId,
nonce: Uint8List(24),
payload: Uint8List.sublistView(sealed, 1 + sealed[0]),
),
context: utf8.encode('asset|$assetId|file'),
);
}
}

View file

@ -22,6 +22,9 @@ class FakeAssetServerGateway() implements AssetServerGateway {
/// The ids downloads were asked for, in order.
final downloads = <String>[];
/// The hashes of the uploads said to be encrypted (ADR 0019).
final encrypted = <String>{};
/// When set, every call fails with it, as without a connection.
Exception? failCalls;
@ -38,9 +41,16 @@ class FakeAssetServerGateway() implements AssetServerGateway {
files.values.fold(0, (sum, bytes) => sum + bytes.length);
@override
Future<String?> startUpload(String sha256, int byteSize) async {
Future<String?> startUpload(
String sha256,
int byteSize, {
bool encrypted = false,
}) async {
_check();
started.add(sha256);
if (encrypted) {
this.encrypted.add(sha256);
}
if (files.containsKey(sha256)) {
return null;
}
@ -62,7 +72,7 @@ class FakeAssetServerGateway() implements AssetServerGateway {
}
@override
Future<bool> finishUpload(String sha256) async {
Future<bool> finishUpload(String sha256, {bool encrypted = false}) async {
_check();
final bytes = _uploaded.remove(sha256);
if (bytes == null || sha256Of(bytes) != sha256) {

View file

@ -79,6 +79,7 @@ import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'fake_asset_gateway.dart';
import 'fake_asset_sealing.dart';
import 'fake_asset_server_gateway.dart';
import 'fake_assistant_gateway.dart';
import 'fake_card_gateway.dart';
@ -273,6 +274,7 @@ Dependencies testDependencies({
assetGateway ?? FakeAssetGateway(),
assetServer ?? FakeAssetServerGateway(),
assetFiles ?? MemoryAssetFileCache(),
FakeAssetSealing(),
);
final pdfSources = PdfSourceRepository(
pdfGateway ?? FakePdfSourceGateway(),