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:
parent
ff91f62303
commit
3486932cbe
16 changed files with 221 additions and 14 deletions
|
|
@ -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_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/asset/data/asset_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.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:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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_key_id.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.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/privacy_ledger.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.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';
|
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/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/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_gateway.dart';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/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/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/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/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_gateway.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_answer_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_answer_gateway.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/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/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/note/data/note_gateway.dart';
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import 'dart:typed_data';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/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/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_gateway.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.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';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
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/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
||||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_app/testing.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_key_gateway.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.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';
|
import '../../support/fake_content_key_gateway.dart';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_app/testing.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/asset/data/asset_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/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/data/card_payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ export 'src/password/widget/password_widgets.dart';
|
||||||
export 'src/password/widget/secret_screen.dart';
|
export 'src/password/widget/secret_screen.dart';
|
||||||
export 'src/person/person_folders.dart';
|
export 'src/person/person_folders.dart';
|
||||||
export 'src/privacy/content_cipher.dart';
|
export 'src/privacy/content_cipher.dart';
|
||||||
|
export 'src/privacy/payload_envelope.dart';
|
||||||
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
|
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
|
||||||
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
|
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
|
||||||
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';
|
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';
|
||||||
|
|
|
||||||
157
packages/halkyon_app/lib/src/privacy/payload_envelope.dart
Normal file
157
packages/halkyon_app/lib/src/privacy/payload_envelope.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:halkyon_app/src/privacy/content_cipher.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),
|
||||||
|
];
|
||||||
|
}
|
||||||
218
packages/halkyon_app/test/privacy/payload_envelope_test.dart
Normal file
218
packages/halkyon_app/test/privacy/payload_envelope_test.dart
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
|
||||||
|
/// A synced row's envelope (ADR 0019): sealed for its table, row, column and
|
||||||
|
/// the row it belongs to, readable payloads signed, and nothing an instance
|
||||||
|
/// moves, changes or makes up read as the person's.
|
||||||
|
void main() {
|
||||||
|
final crypto = SodiumAccountCrypto();
|
||||||
|
late UnlockedAccountKeys alice;
|
||||||
|
late UnlockedAccountKeys bob;
|
||||||
|
late SodiumContentCipher device;
|
||||||
|
late PayloadEnvelope envelope;
|
||||||
|
|
||||||
|
final plain = ByteData.sublistView(
|
||||||
|
Uint8List.fromList(utf8.encode('{"schemaVersion":1,"name":"Run"}')),
|
||||||
|
);
|
||||||
|
|
||||||
|
Opened openAt(
|
||||||
|
Envelope sealed, {
|
||||||
|
String table = 'workout',
|
||||||
|
String id = 'w1',
|
||||||
|
String column = 'payload',
|
||||||
|
String bound = '',
|
||||||
|
}) => envelope.open(
|
||||||
|
sealed,
|
||||||
|
table: table,
|
||||||
|
id: id,
|
||||||
|
column: column,
|
||||||
|
bound: bound,
|
||||||
|
);
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
alice = (await crypto.newAccount('alice password')).keys;
|
||||||
|
bob = (await crypto.newAccount('bob password')).keys;
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
device = SodiumContentCipher();
|
||||||
|
await device.unlock(alice);
|
||||||
|
device.newKey('person');
|
||||||
|
envelope = PayloadEnvelope(device, canSign: () => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => device.dispose());
|
||||||
|
|
||||||
|
test('a sealed payload opens where it was sealed for', () {
|
||||||
|
final sealed = envelope.seal(
|
||||||
|
plain,
|
||||||
|
keyId: 'person',
|
||||||
|
table: 'workout',
|
||||||
|
id: 'w1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sealed.keyId, 'person');
|
||||||
|
expect(sealed.nonce, isNotNull);
|
||||||
|
expect(
|
||||||
|
Uint8List.sublistView(sealed.payload),
|
||||||
|
isNot(containsAllInOrder(Uint8List.sublistView(plain))),
|
||||||
|
);
|
||||||
|
expect(openAt(sealed), Readable(plain));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sealed payload moved to another table, row, column or item '
|
||||||
|
"doesn't open", () {
|
||||||
|
final sealed = envelope.seal(
|
||||||
|
plain,
|
||||||
|
keyId: 'person',
|
||||||
|
table: 'workout',
|
||||||
|
id: 'w1',
|
||||||
|
bound: 'workout:w0',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(openAt(sealed, bound: 'workout:w0'), Readable(plain));
|
||||||
|
expect(
|
||||||
|
openAt(sealed, table: 'workout_series', bound: 'workout:w0'),
|
||||||
|
const Locked(),
|
||||||
|
);
|
||||||
|
expect(openAt(sealed, id: 'w2', bound: 'workout:w0'), const Locked());
|
||||||
|
expect(openAt(sealed, column: 'name', bound: 'workout:w0'), const Locked());
|
||||||
|
expect(openAt(sealed, bound: 'workout:w9'), const Locked());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a sealed payload changed, or without its nonce, doesn't open", () {
|
||||||
|
final sealed = envelope.seal(
|
||||||
|
plain,
|
||||||
|
keyId: 'person',
|
||||||
|
table: 'workout',
|
||||||
|
id: 'w1',
|
||||||
|
);
|
||||||
|
final changed = Uint8List.fromList(Uint8List.sublistView(sealed.payload));
|
||||||
|
changed[changed.length - 1] ^= 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
openAt((
|
||||||
|
keyId: 'person',
|
||||||
|
nonce: sealed.nonce,
|
||||||
|
payload: ByteData.sublistView(changed),
|
||||||
|
)),
|
||||||
|
const Locked(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
openAt((keyId: 'person', nonce: null, payload: sealed.payload)),
|
||||||
|
const Locked(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a payload under a key not open here doesn't open, and none is "
|
||||||
|
'sealed with it', () async {
|
||||||
|
final sealed = envelope.seal(
|
||||||
|
plain,
|
||||||
|
keyId: 'person',
|
||||||
|
table: 'workout',
|
||||||
|
id: 'w1',
|
||||||
|
);
|
||||||
|
final other = SodiumContentCipher();
|
||||||
|
await other.unlock(alice);
|
||||||
|
addTearDown(other.dispose);
|
||||||
|
final elsewhere = PayloadEnvelope(other);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
elsewhere.open(sealed, table: 'workout', id: 'w1'),
|
||||||
|
const Locked(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => elsewhere.seal(plain, keyId: 'person', table: 'workout', id: 'w1'),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"another account's device can't open a key sealed to this one",
|
||||||
|
() async {
|
||||||
|
final sealedKey = device.newKey('person-2');
|
||||||
|
final sealed = envelope.seal(
|
||||||
|
plain,
|
||||||
|
keyId: 'person-2',
|
||||||
|
table: 'workout',
|
||||||
|
id: 'w1',
|
||||||
|
);
|
||||||
|
final bobs = SodiumContentCipher();
|
||||||
|
await bobs.unlock(bob);
|
||||||
|
addTearDown(bobs.dispose);
|
||||||
|
|
||||||
|
expect(bobs.openKey('person-2', sealedKey), isFalse);
|
||||||
|
expect(
|
||||||
|
PayloadEnvelope(bobs).open(sealed, table: 'workout', id: 'w1'),
|
||||||
|
const Locked(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a readable payload is signed where the device can sign, and trusted '
|
||||||
|
'only where it stays', () {
|
||||||
|
final readable = envelope.seal(
|
||||||
|
plain,
|
||||||
|
keyId: null,
|
||||||
|
table: 'published_workout',
|
||||||
|
id: 'p1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readable.nonce, isNotNull);
|
||||||
|
expect(
|
||||||
|
openAt(readable, table: 'published_workout', id: 'p1'),
|
||||||
|
Readable(plain),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
openAt(readable, table: 'published_workout', id: 'p2'),
|
||||||
|
Readable(plain, trusted: false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a readable payload nobody signed, or signed by another account, '
|
||||||
|
'is not trusted', () async {
|
||||||
|
final unsigned = PayloadEnvelope(device).seal(
|
||||||
|
plain,
|
||||||
|
keyId: null,
|
||||||
|
table: 'published_workout',
|
||||||
|
id: 'p1',
|
||||||
|
);
|
||||||
|
expect(unsigned.nonce, isNull);
|
||||||
|
expect(
|
||||||
|
openAt(unsigned, table: 'published_workout', id: 'p1'),
|
||||||
|
Readable(plain, trusted: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
final bobs = SodiumContentCipher();
|
||||||
|
await bobs.unlock(bob);
|
||||||
|
addTearDown(bobs.dispose);
|
||||||
|
final bobsSigned = PayloadEnvelope(bobs, canSign: () => true).seal(
|
||||||
|
plain,
|
||||||
|
keyId: null,
|
||||||
|
table: 'published_workout',
|
||||||
|
id: 'p1',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
openAt(bobsSigned, table: 'published_workout', id: 'p1'),
|
||||||
|
Readable(plain, trusted: false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank payload, a row being deleted, stays blank and reads as '
|
||||||
|
'blank', () {
|
||||||
|
final blank = ByteData(0);
|
||||||
|
final sealed = envelope.seal(
|
||||||
|
blank,
|
||||||
|
keyId: 'person',
|
||||||
|
table: 'workout',
|
||||||
|
id: 'w1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sealed.payload.lengthInBytes, 0);
|
||||||
|
expect(sealed.nonce, isNull);
|
||||||
|
expect(openAt(sealed), Readable(blank));
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue