refactor(app): the payload envelope moves into halkyon_app (ADR 0030, D15)

Learn's PayloadEnvelope moves unchanged into halkyon_app, so that Train
seals its rows with the same envelope; Learn imports it from there. It
gets tests of its own: a payload sealed for its table, row, column and
item opens there alone, a changed one or one under a key not open never
does, and a readable one is trusted only signed by the account where it
was signed for.

Learn's app tests haven't run with the move (its build needs clang,
missing on this machine); analysis passes.

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-28 11:08:46 +01:00
commit 3486932cbe
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 221 additions and 14 deletions

View file

@ -4,7 +4,6 @@ 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/asset/data/asset_gateway.dart';
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:serverpod_offline_sync_client/offline_sync.dart';

View file

@ -1,157 +0,0 @@
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.
/// [trusted] unless it is readable without the account's signature: a row
/// the instance could have made up, or one a device without the account's
/// keys wrote (ADR 0019, the reading of 22 Oct).
final class const Readable(
final ByteData payload, {
final bool trusted = true,
}) extends Opened {
@override
List<Object?> get props => [Uint8List.sublistView(payload), trusted];
}
/// 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,
/// and for the row it belongs to where that decides its key (`bound`: a
/// revision's card, note or step, an answer's step, a path's or PDF's
/// studyset), so the instance can neither move it to another row nor point
/// it at another item unnoticed (the final reading of 22 Oct).
///
/// A readable payload a device holding the account's keys writes carries the
/// account's signature (64 bytes) in place of a nonce, over the same: a
/// device can tell it from one the instance made up or re-pointed, and
/// trusts the latter nowhere privacy holds (the reading of 22 Oct).
class PayloadEnvelope(
final ContentCipher _cipher, {
/// Whether this device can sign: it holds the account's keys.
final bool Function()? canSign,
}) {
/// [plain] as the row keeps it: sealed with the key [keyId], readable
/// without one, and then signed where this device can; for its [table],
/// row [id], [column] and the row it is [bound] to (empty for none). 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',
String bound = '',
}) {
if (plain.lengthInBytes == 0) {
return (keyId: keyId, nonce: null, payload: plain);
}
if (keyId == null) {
final signs = canSign?.call() ?? false;
return (
keyId: null,
nonce: signs
? ByteData.sublistView(
_cipher.sign(
_readableMessage(table, id, column, bound, plain),
),
)
: null,
payload: plain,
);
}
final sealed = _cipher.seal(
Uint8List.sublistView(plain),
keyId: keyId,
context: _context(table, id, column, bound),
);
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]; one sealed or signed for another row, or
/// [bound] to another, doesn't open or isn't trusted.
Opened open(
Envelope envelope, {
required String table,
required String id,
String column = 'payload',
String bound = '',
}) {
final (:keyId, :nonce, :payload) = envelope;
if (payload.lengthInBytes == 0) {
return Readable(payload);
}
if (keyId == null) {
return Readable(
payload,
trusted:
nonce != null &&
_cipher.verify(
_readableMessage(table, id, column, bound, payload),
Uint8List.sublistView(nonce),
),
);
}
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, bound),
);
return plain == null
? const Locked()
: Readable(ByteData.sublistView(plain));
}
/// What binds a payload to where it is stored and what it belongs to; its
/// format's version first (2 since the final reading of 22 Oct, which
/// added `bound`; nothing sealed before was released).
static List<int> _context(
String table,
String id,
String column,
String bound,
) => utf8.encode('v2|$table|$id|$column|$bound');
/// What a readable payload's signature is over: that it is readable, where
/// it is stored, what it belongs to, and what it says.
static List<int> _readableMessage(
String table,
String id,
String column,
String bound,
ByteData payload,
) => [
...utf8.encode('readable|v2|$table|$id|$column|$bound|'),
...Uint8List.sublistView(payload),
];
}

View file

@ -10,7 +10,6 @@ import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_sealing.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_id.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/core/privacy/data/privacy_ledger.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';

View file

@ -3,7 +3,6 @@ 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/assistant/data/conversation_gateway.dart';

View file

@ -3,7 +3,6 @@ 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';

View file

@ -3,7 +3,6 @@ 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';

View file

@ -3,7 +3,6 @@ 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';

View file

@ -3,7 +3,6 @@ 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';

View file

@ -3,7 +3,6 @@ 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';

View file

@ -2,7 +2,6 @@ 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';

View file

@ -2,7 +2,6 @@ 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/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';

View file

@ -2,7 +2,6 @@ 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';

View file

@ -6,7 +6,6 @@ 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';

View file

@ -1,11 +1,11 @@
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/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';