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:
parent
dcdc9c1542
commit
e368bba2dd
4 changed files with 1402 additions and 228 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue