refactor!: packages, paths and app IDs under Halkyon Days' names (ADR 0045, step 3)

Learn's folder is apps/halkyon, Todo's apps/myrmex; every package of
ours takes the prefix halkyone_ (halkyone_halkyon_*, halkyone_myrmex_*,
halkyone_train_*, halkyone_meditate_*, halkyone_app, halkyone_base_* and
the other shared ones; halkyon_oidc_* keeps its name, D11). Halkyon and
Myrmex take the app IDs one.halky.halkyon and one.halky.myrmex (D10);
Train and Meditate keep theirs, while every app's Android namespace,
Kotlin package and method channels move to one.halky. The snaps are
halkyon and myrmex (D17), each built in a clone with its recipe in
place of snap/, as Train's and Todo's were.

Serverpod finds a database's migrations by its module's name, which
changed with the packages':
- each server's new, empty migration ("rename") records itself under
  the new names and deletes the rows under the old ones;
- deploy/redeploy.sh copies each database's record to the new name
  before the migrations (carry_module), so the renamed servers find
  their databases migrated; a copy, never a move, so a deploy failing
  before the migration commits leaves the old rows in place;
- Train and Meditate keep their devices' databases: halkyone_app's
  carryMigrationRecord copies their record before the sync engine
  opens them (openPersonDatabase);
- the client migrations' generated moduleName getters, one line each,
  say the new names: Serverpod's client asserts in debug builds that
  every migration names the module, and create-migration never writes
  a migration's files again.

Train's WorkManager work is enqueued under new names, the former ones
cancelled: the work kept names the workers' former classes.

The release tool: an app's tags of its earlier names count as its
history (legacyTagPrefixes), the shared packages' former paths count
for its version, and every snap is built in a clone (atRoot gone).

The databases, Docker images and Compose project keep their names
until step 5.

BREAKING CHANGE: Halkyon (Learn) and Myrmex (Todo) are new apps to
Android and the Snap Store: installs of app.halkyon.learn and
app.halkyon.todo don't update to them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-12 23:21:45 +01:00
commit b4290f71c8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
25 changed files with 118206 additions and 13301 deletions

View file

@ -1,37 +0,0 @@
/// What seals and opens a person's synced content, in pure Dart (Halkyon's
/// ADR 0044, D49):
///
/// - **The envelope** every synced row is sealed in (`PayloadEnvelope`, on a
/// `ContentCipher`, ADR 0019): sealed for its table, row and what it belongs
/// to, readable rows signed; a file sealed alike (`SealedFile`, HLKF).
/// - **People's files** on the device and the module's store (`FileCache`,
/// `FileServerGateway`).
/// - **The account's keys**, unlocked (`UnlockedAccountKeys`), and new ones
/// for an account without a password (`newAccountKeys`, an agent's).
/// - **Shared spaces** (ADR 0044): their members and invitations through the
/// module (`SharedSpaceGateway`), and their keys, sealed to each member and
/// held to the public keys first seen (`SharedKeys`, `KeyPins`).
///
/// Rich text (`RichContent`, the editor's document JSON, which
/// halkyon_editor edits and exports) is a library of its own here,
/// `rich_text.dart`. An app takes the rest through halkyon_app, which
/// exports it; Learn's agent takes it directly.
library;
// A member's public keys, as `SharedKeys` and `KeyPins` take them.
export 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
export 'src/files/file_cache.dart';
export 'src/files/file_server_gateway.dart';
export 'src/files/serverpod_file_server_gateway.dart';
export 'src/privacy/content_cipher.dart';
export 'src/privacy/new_account_keys.dart';
export 'src/privacy/payload_envelope.dart';
export 'src/privacy/sealed_file.dart';
export 'src/privacy/unlocked_account_keys.dart';
export 'src/shared_space/key_pins.dart';
export 'src/shared_space/serverpod_shared_keys_gateway.dart';
export 'src/shared_space/serverpod_shared_space_gateway.dart';
export 'src/shared_space/shared_keys.dart';
export 'src/shared_space/shared_keys_gateway.dart';
export 'src/shared_space/shared_space_gateway.dart';

View file

@ -1,7 +0,0 @@
/// Rich text as Halkyon's apps keep it (`RichContent`, the editor's
/// document JSON, and the names of its blocks), in pure Dart: what
/// halkyon_editor edits and shows, and what Learn's models and agent read.
library;
export 'src/rich_text/content_blocks.dart';
export 'src/rich_text/rich_content.dart';

View file

@ -1,100 +0,0 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
/// The files on this device, by their SHA-256, and which of them wait to be
/// uploaded.
abstract interface class FileCache() {
/// The file with [sha256]; null if this device hasn't got it.
Future<Uint8List?> read(String sha256);
/// Keeps [bytes], whose SHA-256 is [sha256].
Future<void> write(String sha256, Uint8List bytes);
/// Where the file with [sha256] is, or would be.
String pathOf(String sha256);
/// The files that wait to be uploaded.
Future<Set<String>> pendingUploads();
/// Notes that the file with [sha256] waits to be uploaded.
Future<void> markPending(String sha256);
/// Notes that the file with [sha256] doesn't wait any more.
Future<void> clearPending(String sha256);
}
/// [FileCache] in a folder of the person's: each file named by its hash,
/// and an empty file of the same name in `uploads/` for each that waits.
///
/// A hash is part of a path here, and rows synced from elsewhere carry them,
/// so anything but 64 lowercase hex digits is refused.
class DirectoryFileCache(final Directory _folder) implements FileCache {
Directory get _uploads => Directory(p.join(_folder.path, 'uploads'));
@override
Future<Uint8List?> read(String sha256) async {
try {
return await File(pathOf(sha256)).readAsBytes();
} on PathNotFoundException {
return null;
}
}
@override
Future<void> write(String sha256, Uint8List bytes) async {
final file = File(pathOf(sha256));
if (file.existsSync()) {
return;
}
await _folder.create(recursive: true);
// Written aside and renamed, so no half-written file ever has the name.
final partial = File('${file.path}.partial');
await partial.writeAsBytes(bytes, flush: true);
await partial.rename(file.path);
}
@override
String pathOf(String sha256) => p.join(_folder.path, checkedSha256(sha256));
@override
Future<Set<String>> pendingUploads() async {
if (!_uploads.existsSync()) {
return {};
}
return {
await for (final entry in _uploads.list())
if (entry is File && isSha256(p.basename(entry.path)))
p.basename(entry.path),
};
}
@override
Future<void> markPending(String sha256) async {
await _uploads.create(recursive: true);
await File(
p.join(_uploads.path, checkedSha256(sha256)),
).writeAsBytes(const []);
}
@override
Future<void> clearPending(String sha256) async {
try {
await File(p.join(_uploads.path, checkedSha256(sha256))).delete();
} on PathNotFoundException {
// Not waiting.
}
}
}
/// Whether [value] is a SHA-256 as files are named by: 64 lowercase hex
/// digits.
bool isSha256(String value) => _sha256.hasMatch(value);
/// [value], if it [isSha256]; throws an [ArgumentError] otherwise.
String checkedSha256(String value) => isSha256(value)
? value
: throw ArgumentError.value(value, 'sha256', 'is no SHA-256 in hex');
final _sha256 = RegExp(r'^[0-9a-f]{64}$');

View file

@ -1,47 +0,0 @@
import 'dart:typed_data';
/// How much room the person's files take on the server, and may take, in
/// bytes.
typedef FileRoom = ({int usedBytes, int limitBytes});
/// The server keeps no more of the person's files: they would take more
/// room than the person may use, or the instance has left ([instanceFull]).
class const FileLimitReached({
/// Whether the instance is full, rather than the person's own room.
required final bool instanceFull,
/// What the person may keep, in bytes.
required final int limitBytes,
}) implements Exception {
@override
String toString() =>
'FileLimitReached(instanceFull: $instanceFull, limit: $limitBytes)';
}
/// The server's copy of the person's files (Halkyon's ADR 0043), which
/// halkyon_base keeps for the app.
abstract interface class FileServerGateway() {
/// How to upload the file with [sha256] of [byteSize] bytes; null when the
/// server has it already. Throws [FileLimitReached] when it doesn't fit.
Future<String?> startUpload(
String sha256,
int byteSize, {
bool encrypted = false,
});
/// Uploads [bytes] as [description] says; whether that worked.
Future<bool> upload(String description, Uint8List bytes);
/// Completes the upload of the file with [sha256]; whether the server keeps
/// it, which it does only if its content has that hash. Throws
/// [FileLimitReached] when it no longer fits.
Future<bool> finishUpload(String sha256, {bool encrypted = false});
/// How much room the person's files take, and may take.
Future<FileRoom> room();
/// The file the app's row [id] names (Learn's asset, Todo's attachment);
/// null when the server has none for this person (not uploaded yet, or
/// not theirs).
Future<Uint8List?> download(String id);
}

View file

@ -1,69 +0,0 @@
import 'dart:typed_data';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_content/src/files/file_server_gateway.dart';
/// [FileServerGateway] on halkyon_base's `files` endpoint (Halkyon's ADR
/// 0043), uploading through Serverpod's own upload route: every app's files
/// (Learn's assets, Todo's attachments). Learn's own `asset` endpoint stays
/// for its released apps alone (D7).
class ServerpodFileServerGateway(final protocol.Caller _base)
implements FileServerGateway {
@override
Future<String?> startUpload(
String sha256,
int byteSize, {
bool encrypted = false,
}) => _refusing(
() => _base.files.startUpload(
sha256,
byteSize,
encrypted: encrypted,
),
);
@override
Future<bool> upload(String description, Uint8List bytes) =>
protocol.FileUploader(description)
.uploadByteData(ByteData.sublistView(bytes));
@override
Future<bool> finishUpload(String sha256, {bool encrypted = false}) =>
_refusing(
() => _base.files.finishUpload(
sha256,
encrypted: encrypted,
),
);
@override
Future<FileRoom> room() async {
final usage = await _base.files.usage();
return (usedBytes: usage.usedBytes, limitBytes: usage.limitBytes);
}
/// [call], its refusal for want of room as a [FileLimitReached].
Future<T> _refusing<T>(Future<T> Function() call) async {
try {
return await call();
} on protocol.FileLimitExceededException catch (error) {
throw FileLimitReached(
instanceFull: error.instanceFull,
limitBytes: error.limitBytes,
);
}
}
@override
Future<Uint8List?> download(String id) async {
try {
return Uint8List.sublistView(
await _base.files.download(
protocol.UuidValue.withValidation(id),
),
);
} on protocol.StoredFileNotFoundException {
return null;
}
}
}

View file

@ -1,338 +0,0 @@
import 'dart:developer';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:halkyon_content/src/privacy/unlocked_account_keys.dart';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
/// A row's content sealed with a data key (ADR 0019): what goes into ADR
/// 0002's envelope, `keyId`, `nonce` and `payload`, the payload holding the
/// author's signature (64 bytes), then the ciphertext.
final class const SealedPayload({
/// The data key's ID.
required final String keyId,
/// XChaCha20-Poly1305's nonce, random for every sealing.
required final Uint8List nonce,
/// The signature, then the ciphertext.
required final Uint8List payload,
}) with Equatable {
@override
List<Object?> get props => [keyId, nonce, payload];
}
/// Seals and opens content with the person's data keys (ADR 0019), each
/// sealed to their account and kept open here, in libsodium's guarded
/// memory, while their data is open. Every key is the account's own, so
/// content is signed by the account and checked against it. A fake in
/// tests.
abstract interface class ContentCipher() {
/// Gets ready to work for the account whose keys are [account]; once,
/// before anything else.
Future<void> unlock(UnlockedAccountKeys account);
/// A new data key named [keyId], kept open here, and sealed to the
/// account (signed by it), as its row keeps it.
Uint8List newKey(String keyId);
/// Opens [sealed] as the data key [keyId], kept open here from then on;
/// false where it doesn't open (another account's, sealed under another
/// ID, changed). Throws a [StateError] before [unlock].
bool openKey(String keyId, Uint8List sealed);
/// Whether the key [keyId] is open here.
bool has(String keyId);
/// The account's own public keys. Throws a [StateError] before [unlock].
PublicKeys get ownKeys;
/// The open key [keyId] sealed to another account, whose public keys are
/// [to], and signed by this one (Halkyon's ADR 0044, D3): for a member of
/// a space shared with them. Throws a [StateError] for a key not open
/// here.
Uint8List sealKeyTo(String keyId, PublicKeys to);
/// Opens [sealed] as the data key [keyId] another account sealed to this
/// one, checked against that account's signing key [sealedBy], kept open
/// here from then on; false where it doesn't open (not sealed to this
/// account, sealed or signed by another, changed). Throws a [StateError]
/// before [unlock].
bool openSharedKey(
String keyId,
Uint8List sealed, {
required Uint8List sealedBy,
});
/// The safety code of this account and the one whose public keys are
/// [other] (halkyon_keys' `safetyCode`, ADR 0044, D18), the same on both
/// sides. Throws a [StateError] before [unlock].
String safetyCodeWith(PublicKeys other);
/// [plain] sealed with the key [keyId] for the [context] it is stored in
/// (its table, row and column), so it opens nowhere else. Throws a
/// [StateError] for a key not open here.
SealedPayload seal(
Uint8List plain, {
required String keyId,
required List<int> context,
});
/// What [sealed] holds, for the [context] it is stored in; null where it
/// doesn't open: its key isn't open here, its signature isn't the
/// account's (or, for a key with signers trusted, none of theirs), it was
/// changed, or it belongs to another row.
Uint8List? unseal(SealedPayload sealed, {required List<int> context});
/// Takes content sealed with the key [keyId] as genuine when any of
/// [signers] (signing public keys) signed it, in place of the account
/// alone (Halkyon's ADR 0044, D20): a shared space's key, with which
/// every member writes. Replaces what was trusted for it before.
void trustSigners(String keyId, List<Uint8List> signers);
/// The account's signature (64 bytes) over [message]: a readable row's,
/// which tells a row a device of the account wrote from one the instance
/// made up (ADR 0019, the reading). Throws a [StateError] before
/// [unlock].
Uint8List sign(List<int> message);
/// Whether [signature] is the account's over [message]; false before
/// [unlock].
bool verify(List<int> message, List<int> signature);
/// The ID of the row named [name], keyed by the account (halkyon_keys'
/// `keyedRowId`, Halkyon Train's ADR 0030, D57): the same on each of its
/// devices, and nothing of [name] read from it by the instance. Throws a
/// [StateError] before [unlock].
String rowId(String name);
/// Wipes every key kept open here.
void dispose();
}
/// [ContentCipher] with libsodium (halkyon_keys), on the isolate it is used
/// on: rows are sealed and opened one by one as they are read and written,
/// too many for an isolate each.
class SodiumContentCipher() implements ContentCipher {
SodiumSumo? _sodium;
AccountKeys? _account;
final _keys = <String, DataKey>{};
/// The signers trusted for each shared key, by its ID ([trustSigners]).
final _signers = <String, List<Uint8List>>{};
@override
Future<void> unlock(UnlockedAccountKeys account) async {
final sodium = _sodium ??= await SodiumSumoInit.init();
_account?.dispose();
_account = AccountKeys.fromSecretKeys(
sodium,
encryptionSecretKey: account.encryptionSecretKey,
signingSecretKey: account.signingSecretKey,
);
}
@override
Uint8List newKey(String keyId) {
final (sodium, account) = _unlocked();
final key = DataKey.generate(sodium, keyId);
_keys.remove(keyId)?.dispose();
_keys[keyId] = key;
return sealDataKey(
sodium,
key,
to: account.encryptionPublicKey,
sealer: account,
);
}
@override
bool openKey(String keyId, Uint8List sealed) {
final (sodium, account) = _unlocked();
try {
final key = openDataKey(
sodium,
sealed,
keyId: keyId,
keys: account,
sealedBy: account.signingPublicKey,
);
_keys.remove(keyId)?.dispose();
_keys[keyId] = key;
return true;
} on KeyRefused catch (refused) {
log('A data key not opened: ${refused.reason.name}', name: 'Privacy');
return false;
}
}
@override
bool has(String keyId) => _keys.containsKey(keyId);
@override
PublicKeys get ownKeys {
final (_, account) = _unlocked();
return (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
}
@override
Uint8List sealKeyTo(String keyId, PublicKeys to) {
final (sodium, account) = _unlocked();
final key = _keys[keyId];
if (key == null) {
throw StateError('The data key $keyId is not open');
}
return sealDataKey(sodium, key, to: to.encryption, sealer: account);
}
@override
bool openSharedKey(
String keyId,
Uint8List sealed, {
required Uint8List sealedBy,
}) {
final (sodium, account) = _unlocked();
try {
final key = openDataKey(
sodium,
sealed,
keyId: keyId,
keys: account,
sealedBy: sealedBy,
);
_keys.remove(keyId)?.dispose();
_keys[keyId] = key;
return true;
} on KeyRefused catch (refused) {
log(
'A shared data key not opened: ${refused.reason.name}',
name: 'Privacy',
);
return false;
}
}
@override
String safetyCodeWith(PublicKeys other) {
final (sodium, _) = _unlocked();
return safetyCode(sodium, ownKeys, other);
}
@override
SealedPayload seal(
Uint8List plain, {
required String keyId,
required List<int> context,
}) {
final (sodium, account) = _unlocked();
final key = _keys[keyId];
if (key == null) {
throw StateError('The data key $keyId is not open');
}
final sealed = sealContent(
sodium,
plainText: plain,
dataKey: key,
author: account,
context: context,
);
return SealedPayload(
keyId: keyId,
nonce: Uint8List.fromList(sealed.nonce),
payload: Uint8List(sealed.signature.length + sealed.payload.length)
..setAll(0, sealed.signature)
..setAll(sealed.signature.length, sealed.payload),
);
}
@override
Uint8List? unseal(SealedPayload sealed, {required List<int> context}) {
final sodium = _sodium;
final account = _account;
final key = _keys[sealed.keyId];
if (sodium == null || account == null || key == null) {
return null;
}
final signatureBytes = sodium.crypto.sign.bytes;
if (sealed.payload.length < signatureBytes) {
return null;
}
final content = SealedContent(
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: Uint8List.sublistView(sealed.payload, signatureBytes),
signature: Uint8List.sublistView(sealed.payload, 0, signatureBytes),
);
for (final signer in _signers[sealed.keyId] ?? [account.signingPublicKey]) {
try {
return openContent(
sodium,
content,
dataKey: key,
authorSigningPublicKey: signer,
context: context,
);
} on KeyRefused {
// Another signer may have signed it.
}
}
return null;
}
@override
void trustSigners(String keyId, List<Uint8List> signers) => _signers[keyId] =
[for (final signer in signers) Uint8List.fromList(signer)];
@override
Uint8List sign(List<int> message) {
final (sodium, account) = _unlocked();
return Uint8List.fromList(
sodium.crypto.sign.detached(
message: Uint8List.fromList(message),
secretKey: account.signing.secretKey,
),
);
}
@override
bool verify(List<int> message, List<int> signature) {
final sodium = _sodium;
final account = _account;
if (sodium == null ||
account == null ||
signature.length != sodium.crypto.sign.bytes) {
return false;
}
return sodium.crypto.sign.verifyDetached(
message: Uint8List.fromList(message),
signature: Uint8List.fromList(signature),
publicKey: account.signingPublicKey,
);
}
@override
String rowId(String name) {
final (sodium, account) = _unlocked();
return keyedRowId(sodium, account, name);
}
@override
void dispose() {
for (final key in _keys.values) {
key.dispose();
}
_keys.clear();
_signers.clear();
_account?.dispose();
_account = null;
}
(SodiumSumo, AccountKeys) _unlocked() => switch ((_sodium, _account)) {
(final sodium?, final account?) => (sodium, account),
_ => throw StateError('Not unlocked'),
};
}

View file

@ -1,24 +0,0 @@
import 'dart:typed_data';
import 'package:halkyon_content/src/privacy/unlocked_account_keys.dart';
import 'package:halkyon_keys/halkyon_keys.dart';
import 'package:sodium/sodium_sumo.dart';
/// New key pairs for an account without a password to wrap them under:
/// an agent's (Halkyon's ADR 0044, D6), kept where it runs, its public
/// halves set on the register once.
Future<UnlockedAccountKeys> newAccountKeys() async {
final sodium = await SodiumSumoInit.init();
final keys = AccountKeys.generate(sodium);
try {
return UnlockedAccountKeys(
encryptionPublicKey: Uint8List.fromList(keys.encryptionPublicKey),
// extractBytes copies already.
encryptionSecretKey: keys.encryption.secretKey.extractBytes(),
signingPublicKey: Uint8List.fromList(keys.signingPublicKey),
signingSecretKey: keys.signing.secretKey.extractBytes(),
);
} finally {
keys.dispose();
}
}

View file

@ -1,157 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:halkyon_content/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),
];
}

View file

@ -1,86 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/src/privacy/content_cipher.dart';
/// A file sealed as every app seals one (Halkyon Learn's ADR 0019, D5;
/// Halkyon's ADR 0043): `HLKF`, the format's version (1), the length of the
/// key's ID and the ID itself, the nonce (24 bytes), then the sealed
/// payload (the signature and the ciphertext). The server checks the first
/// five bytes alone (halkyon_base's `finishFileUpload`).
abstract final class SealedFile() {
/// What a sealed file starts with.
static final magic = utf8.encode('HLKF');
/// The format's version.
static const version = 1;
static const _nonceLength = 24;
/// [plain] sealed with [cipher]'s key [keyId] for the [context] it belongs
/// to (its row, so it opens for no other). Throws a [StateError] for a key
/// not open there.
static Uint8List seal(
ContentCipher cipher,
Uint8List plain, {
required String keyId,
required List<int> context,
}) {
final sealed = cipher.seal(plain, keyId: keyId, context: context);
final id = utf8.encode(keyId);
return Uint8List.fromList([
...magic,
version,
id.length,
...id,
...sealed.nonce,
...sealed.payload,
]);
}
/// What [sealed] holds, for the [context] it belongs to; null where it
/// isn't a sealed file or doesn't open with [cipher] (its key isn't open,
/// it was changed, or it belongs elsewhere).
static Uint8List? open(
ContentCipher cipher,
Uint8List sealed, {
required List<int> context,
}) {
final header = magic.length + 2;
if (sealed.length < header ||
!_startsWith(sealed, magic) ||
sealed[magic.length] != version) {
return null;
}
final idLength = sealed[magic.length + 1];
if (sealed.length < header + idLength + _nonceLength) {
return null;
}
final String keyId;
try {
keyId = utf8.decode(
Uint8List.sublistView(sealed, header, header + idLength),
);
} on FormatException {
return null;
}
final nonceAt = header + idLength;
return cipher.unseal(
SealedPayload(
keyId: keyId,
nonce: Uint8List.sublistView(sealed, nonceAt, nonceAt + _nonceLength),
payload: Uint8List.sublistView(sealed, nonceAt + _nonceLength),
),
context: context,
);
}
static bool _startsWith(Uint8List bytes, List<int> prefix) {
for (var i = 0; i < prefix.length; i++) {
if (bytes[i] != prefix[i]) {
return false;
}
}
return true;
}
}

View file

@ -1,59 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
/// An account's key pairs, their private halves unlocked (ADR 0017): what
/// the device keeps in the system's keyring while the person is signed in,
/// and what wraps them anew under a new password or recovery phrase.
final class const UnlockedAccountKeys({
/// X25519's public key, which data keys are sealed to.
required final Uint8List encryptionPublicKey,
/// X25519's secret key (32 bytes).
required final Uint8List encryptionSecretKey,
/// Ed25519's public key, which the account's signatures check against.
required final Uint8List signingPublicKey,
/// Ed25519's secret key, libsodium's 64 bytes (the seed and the public
/// key).
required final Uint8List signingSecretKey,
}) with Equatable {
/// As [toJson] wrote them; a [FormatException] otherwise.
factory fromJson(Map<String, Object?> json) => switch (json) {
{
'encryptionPublicKey': final String encryptionPublicKey,
'encryptionSecretKey': final String encryptionSecretKey,
'signingPublicKey': final String signingPublicKey,
'signingSecretKey': final String signingSecretKey,
} =>
UnlockedAccountKeys(
encryptionPublicKey: base64Decode(encryptionPublicKey),
encryptionSecretKey: base64Decode(encryptionSecretKey),
signingPublicKey: base64Decode(signingPublicKey),
signingSecretKey: base64Decode(signingSecretKey),
),
_ => throw const FormatException('Not unlocked account keys'),
};
/// As the keyring keeps them.
Map<String, String> toJson() => {
'encryptionPublicKey': base64Encode(encryptionPublicKey),
'encryptionSecretKey': base64Encode(encryptionSecretKey),
'signingPublicKey': base64Encode(signingPublicKey),
'signingSecretKey': base64Encode(signingSecretKey),
};
@override
List<Object?> get props => [
encryptionPublicKey,
encryptionSecretKey,
signingPublicKey,
signingSecretKey,
];
/// Never their secret halves.
@override
String toString() => 'UnlockedAccountKeys(…)';
}

View file

@ -1,91 +0,0 @@
// The blocks of ours in rich text, beyond the editor's own: their types and
// the attributes they keep, in the editor's document JSON. Pure Dart, so
// `RichContent` and the server read them as the editor writes them.
//
// Every block keeps its type for good: content with a type an app doesn't
// know stays as it is there, shown as needing a newer app.
/// A sound: the person's file at `url` (`assetUrl`), played on a tap.
const audioBlockType = 'audio';
/// On text within a line: its furigana (ruby), the reading of the text it
/// is on (`かんじ` on `漢字`), shown above it.
const rubyKey = 'ruby';
/// On the editor's own `image` block: the id of the PDF a screenshot was
/// taken from; missing for any other image.
const imagePdfKey = 'pdf';
/// On the editor's own `image` block: the page of that PDF, from 1.
const imagePageKey = 'page';
/// A boxed aside of a kind (a note, a tip, a warning…), holding blocks of
/// its own as children.
const calloutBlockType = 'callout';
/// The callout's kind, one of [calloutKinds]; any other reads as `note`.
const calloutKindKey = 'kind';
/// The callout's title; empty or missing for its kind's name.
const calloutTitleKey = 'title';
/// Whether the callout starts folded, its blocks hidden until opened.
const calloutFoldedKey = 'folded';
/// The kinds of callouts, in the order they are offered.
const calloutKinds = [
'note',
'info',
'tip',
'success',
'question',
'warning',
'danger',
'quote',
];
/// Text hidden until it is revealed, in the block's own `delta`.
const spoilerBlockType = 'spoiler';
/// A formula set on its own line.
const mathBlockType = 'math';
/// The formula in LaTeX, of a [mathBlockType] block.
const mathFormulaKey = 'formula';
/// The text mark of a formula within a line: its text is the LaTeX.
const inlineMathKey = 'math';
/// A YouTube video, played only once someone asks for it.
const youtubeBlockType = 'youtube';
/// The video's id: the 11 characters YouTube names it by.
const youtubeVideoKey = 'video';
/// Where the video starts, in whole seconds; missing for its beginning.
const youtubeStartKey = 'start';
/// The caption shown in place of the video; optional.
const youtubeCaptionKey = 'caption';
/// A button starting a study session of a studyset's cards.
const studyButtonBlockType = 'study_button';
/// The id of the studyset studied.
const studyButtonStudysetKey = 'studyset';
/// The id of the tag whose cards alone are studied; missing for all.
const studyButtonTagKey = 'tag';
/// The name of that tag, to find it by once its id is gone (a tag folded
/// into another of its name).
const studyButtonTagNameKey = 'tagName';
/// The button's label; empty or missing for a plain "Study".
const studyButtonLabelKey = 'label';
/// Whether the studyset's topics are studied too; true when missing.
const studyButtonTopicsKey = 'topics';
/// Whether front/back and vocabulary cards ask their back first.
const studyButtonBackFirstKey = 'backFirst';

View file

@ -1,275 +0,0 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_content/src/rich_text/content_blocks.dart';
/// Rich text as the editor stores it: the editor's document JSON plus the
/// version of that format, so stored content can be migrated when it changes.
///
/// Pure Dart on purpose: models hold it, the data layer encodes it into the
/// content payload, and the server can read it without Flutter. The editor
/// converts it to and from its own document type in `rich_content_document.dart`.
///
/// Made from the JSON the editor writes for a document, which it keeps as a
/// deeply unmodifiable copy.
final class RichContent(
Map<String, Object?> editorJson, {
/// The version of the format [editorJson] is in.
final int formatVersion = currentFormatVersion,
}) with Equatable {
/// A document without any blocks.
factory empty() => RichContent(const {
'document': {'type': 'page'},
});
/// [text] as a document, a paragraph per line.
factory plain(String text) => RichContent({
'document': {
'type': 'page',
'children': [
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
});
/// Reads the JSON written by [toJson].
///
/// Throws a [FormatException] for anything else, including content in a
/// newer format than this app knows.
factory fromJson(Map<String, Object?> json) {
final formatVersion = json['formatVersion'];
final editorJson = json['editor'];
if (formatVersion is! int || editorJson is! Map<String, Object?>) {
throw FormatException('Not rich content.', json);
}
if (formatVersion > currentFormatVersion) {
throw FormatException(
'Rich content in format $formatVersion, but this app only knows '
'formats up to $currentFormatVersion.',
json,
);
}
return RichContent(editorJson, formatVersion: formatVersion);
}
/// The format this app writes.
static const currentFormatVersion = 1;
/// The editor's document JSON, deeply unmodifiable.
final Map<String, Object?> editorJson = _unmodifiableMap(editorJson);
@override
List<Object?> get props => [formatVersion, editorJson];
/// The text without its formatting, one line per block; for previews.
/// A block's text kept beside its blocks counts too: a formula, a
/// callout's title, a video's caption, a study button's label.
String get plainText {
final blocks = <String>[];
void visit(Object? node) {
if (node case {'data': final Map<String, Object?> data}) {
if (data[_textKeys[node['type']]] case final String text) {
blocks.add(text);
}
if (data case {'delta': final List<Object?> delta}) {
blocks.add(
[
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join(),
);
}
}
if (node case {'children': final List<Object?> children}) {
children.forEach(visit);
}
}
visit(editorJson['document']);
return blocks.where((block) => block.isNotEmpty).join('\n');
}
/// Whether it holds nothing: no text, and no block that shows something
/// without text (an image, a sound, a video, a study button).
bool get isBlank =>
plainText.trim().isEmpty &&
!_nodes.any((node) => _somethingWithoutText.contains(node['type']));
/// Whether it shows an image, the person's or another.
bool get hasImages => _hasBlock('image');
/// Whether it plays a sound.
bool get hasSounds => _hasBlock(audioBlockType);
/// Whether it plays a video.
bool get hasVideos => _hasBlock(youtubeBlockType);
/// The person's files it shows or plays, by their asset rows' ids.
Set<String> get assetIds => {
for (final node in _nodes)
if (node case {'data': {'url': final url}}) ?assetIdOf(url),
};
/// The person's images it shows, by their asset rows' ids, in order and
/// each once: what an assistant that reads images may be sent of it (ADR
/// 0021, D12).
List<String> get imageIds => {
for (final node in _nodes)
if (node case {'type': 'image', 'data': {'url': final url}})
?assetIdOf(url),
}.toList();
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
/// This content without its first block when that is a heading reading
/// [text], whatever its case and spaces (decided 2026-10-16): a heading
/// that repeats the title shown above it, as imported lessons often
/// open. Unchanged otherwise.
RichContent withoutHeading(String text) {
String tidy(String text) =>
text.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
if (editorJson['document']
case {
'children': [
{
'type': 'heading',
'data': {'delta': final List<Object?> delta},
},
...final rest,
],
} &&
final Map<String, Object?> document
when tidy(text).isNotEmpty) {
final heading = [
for (final operation in delta)
if (operation case {'insert': final String text}) text,
].join();
if (tidy(heading) == tidy(text)) {
return RichContent({
...editorJson,
'document': {...document, 'children': rest},
}, formatVersion: formatVersion);
}
}
return this;
}
/// Every block, depth first.
Iterable<Map<String, Object?>> get _nodes sync* {
Iterable<Map<String, Object?>> visit(Object? node) sync* {
if (node is Map<String, Object?>) {
yield node;
if (node case {'children': final List<Object?> children}) {
for (final child in children) {
yield* visit(child);
}
}
}
}
yield* visit(editorJson['document']);
}
/// This content with [text] after it, a paragraph per line: as text
/// taken from a PDF joins what is written. An empty last line takes the
/// text rather than staying above it.
RichContent withParagraphs(String text) {
final document =
editorJson['document'] as Map<String, Object?>? ??
const {'type': 'page'};
final children = [...?document['children'] as List<Object?>?];
if (children.lastOrNull
case {
'type': 'paragraph',
'data': {'delta': final List<Object?> delta},
}
when delta.isEmpty) {
children.removeLast();
}
return RichContent({
'document': {
...document,
'children': [
...children,
for (final line in text.split('\n'))
{
'type': 'paragraph',
'data': {
'delta': [
if (line.isNotEmpty) {'insert': line},
],
},
},
],
},
}, formatVersion: formatVersion);
}
/// The JSON stored in the content payload.
Map<String, Object?> toJson() => {
'formatVersion': formatVersion,
'editor': editorJson,
};
}
/// The URL naming the person's file [assetId] in content: an image's or a
/// sound's (ADR 0004, rule 5).
String assetUrl(String assetId) => '$_assetScheme$assetId';
/// The asset row [url] names, if it names one.
String? assetIdOf(Object? url) =>
url is String &&
url.startsWith(_assetScheme) &&
url.length > _assetScheme.length
? url.substring(_assetScheme.length)
: null;
const _assetScheme = 'asset:';
/// The person's files [json] (a payload's JSON, or a part of it) names: the
/// `url` of every block that shows or plays one, as [RichContent.assetIds]
/// finds them, at any depth.
Set<String> assetIdsIn(Object? json) => switch (json) {
final Map<String, Object?> map => {
?assetIdOf(map['url']),
for (final value in map.values) ...assetIdsIn(value),
},
final List<Object?> list => {for (final value in list) ...assetIdsIn(value)},
_ => const {},
};
/// The attribute holding the text of a block that keeps it beside its
/// blocks, by the block's type.
const _textKeys = {
mathBlockType: mathFormulaKey,
calloutBlockType: calloutTitleKey,
youtubeBlockType: youtubeCaptionKey,
studyButtonBlockType: studyButtonLabelKey,
};
/// The blocks that show something without any text.
const _somethingWithoutText = {
'image',
audioBlockType,
youtubeBlockType,
studyButtonBlockType,
};
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
Map.unmodifiable({
for (final MapEntry(:key, :value) in map.entries)
key: _unmodifiableValue(value),
});
Object? _unmodifiableValue(Object? value) => switch (value) {
Map<String, Object?>() => _unmodifiableMap(value),
List<Object?>() => List<Object?>.unmodifiable(value.map(_unmodifiableValue)),
_ => value,
};

View file

@ -1,235 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// The public keys a device first saw for a person, whether their safety
/// code was compared, and the signing keys pinned for them before (Halkyon's
/// ADR 0044, D18, D20).
final class const PinnedKeys({
required final PublicKeys keys,
final bool verified = false,
/// The signing keys pinned for the person before [keys], each trusted
/// still for what it signed then: a reset leaves what they wrote readable.
final List<Uint8List> earlierSigning = const [],
}) {
/// From what [toJson] wrote; null for anything else.
static PinnedKeys? fromJson(Object? json) => switch (json) {
{
'encryption': final String encryption,
'signing': final String signing,
'verified': final bool verified,
'earlierSigning': final List<Object?> earlier,
} =>
_decoded(encryption, signing, verified, earlier),
_ => null,
};
static PinnedKeys? _decoded(
String encryption,
String signing,
bool verified,
List<Object?> earlier,
) {
try {
return PinnedKeys(
keys: (
encryption: base64Decode(encryption),
signing: base64Decode(signing),
),
verified: verified,
earlierSigning: [
for (final key in earlier)
if (key is String)
base64Decode(key)
else
throw const FormatException(),
],
);
} on FormatException {
return null;
}
}
Map<String, Object?> toJson() => {
'encryption': base64Encode(keys.encryption),
'signing': base64Encode(keys.signing),
'verified': verified,
'earlierSigning': [for (final key in earlierSigning) base64Encode(key)],
};
}
/// What a device keeps of other people's keys: the keys pinned for each
/// person (an account's ID), and who it saw in each space (its ID).
typedef PinBook = ({
Map<String, PinnedKeys> pins,
Map<String, Set<String>> spaces,
});
/// Where a device keeps its [PinBook].
abstract interface class KeyPinStore() {
Future<PinBook> read();
Future<void> write(PinBook book);
}
/// [KeyPinStore] in a JSON file in the person's folder (`key_pins.json`); a
/// file that doesn't hold one counts as none.
class FileKeyPinStore(final File _file) implements KeyPinStore {
@override
Future<PinBook> read() async {
try {
final json = jsonDecode(await _file.readAsString());
return (
pins: <String, PinnedKeys>{
if (json case {'pins': final Map<String, Object?> pins})
for (final MapEntry(:key, :value) in pins.entries)
key: ?PinnedKeys.fromJson(value),
},
spaces: <String, Set<String>>{
if (json case {'spaces': final Map<String, Object?> spaces})
for (final MapEntry(:key, :value) in spaces.entries)
if (value case final List<Object?> people)
key: {...people.whereType<String>()},
},
);
} on PathNotFoundException {
return _empty();
} on FormatException {
return _empty();
}
}
@override
Future<void> write(PinBook book) async {
await _file.parent.create(recursive: true);
final temporary = File('${_file.path}.tmp');
await temporary.writeAsString(
jsonEncode({
'pins': {
for (final MapEntry(:key, :value) in book.pins.entries)
key: value.toJson(),
},
'spaces': {
for (final MapEntry(:key, :value) in book.spaces.entries)
key: value.toList()..sort(),
},
}),
flush: true,
);
await temporary.rename(_file.path);
}
static PinBook _empty() => (pins: {}, spaces: {});
}
/// How a person's keys compare with those pinned for them.
enum PinCheck() {
/// Seen for the first time, and pinned now.
firstSeen,
/// The same as pinned.
same,
/// Other than pinned: the instance swapped them, or the person's keys
/// were reset. Nothing is sealed to them or taken from them until the
/// person trusts the new keys ([KeyPins.trustAnew]).
changed,
}
/// The keys a device pinned for each person (ADR 0044, D18), as Signal and
/// SSH pin them: the first seen are kept, a change refused until trusted
/// anew; and who it saw in each space, whose signatures it takes there
/// (D20).
class KeyPins(final KeyPinStore _store) {
PinBook? _book;
Future<PinBook> _read() async => _book ??= await _store.read();
/// How [keys] compare with those pinned for [person], pinning them when
/// none are.
Future<PinCheck> check(String person, PublicKeys keys) async {
final book = await _read();
final pinned = book.pins[person];
if (pinned == null) {
book.pins[person] = PinnedKeys(keys: keys);
await _store.write(book);
return PinCheck.firstSeen;
}
return sameKeys(pinned.keys, keys) ? PinCheck.same : PinCheck.changed;
}
/// The keys pinned for [person]; null for none.
Future<PinnedKeys?> of(String person) async => (await _read()).pins[person];
/// Marks [person]'s pinned keys compared, by their safety code.
Future<void> markVerified(String person) async {
final book = await _read();
if (book.pins[person] case final pinned?) {
book.pins[person] = PinnedKeys(
keys: pinned.keys,
verified: true,
earlierSigning: pinned.earlierSigning,
);
await _store.write(book);
}
}
/// Pins [keys] for [person] in place of those before, not compared: the
/// person said their keys changed for a reason of their own. The signing
/// key before stays trusted for what it signed.
Future<void> trustAnew(String person, PublicKeys keys) async {
final book = await _read();
final before = book.pins[person];
book.pins[person] = PinnedKeys(
keys: keys,
earlierSigning: [
...?before?.earlierSigning,
if (before != null && !_bytesEqual(before.keys.signing, keys.signing))
before.keys.signing,
],
);
await _store.write(book);
}
/// Keeps that [people] were seen as members of [space].
Future<void> sawIn(String space, Iterable<String> people) async {
final book = await _read();
final seen = book.spaces[space] ??= {};
if (!seen.containsAll(people)) {
seen.addAll(people);
await _store.write(book);
}
}
/// The signing keys whose signatures count in [space] (D20): every key
/// pinned, now or before, for anyone ever seen there.
Future<List<Uint8List>> signersIn(String space) async {
final book = await _read();
return [
for (final person in book.spaces[space] ?? const <String>{})
if (book.pins[person] case final pinned?) ...[
pinned.keys.signing,
...pinned.earlierSigning,
],
];
}
}
/// Whether [a] and [b] are the same keys, compared in constant time.
bool sameKeys(PublicKeys a, PublicKeys b) =>
_bytesEqual(a.encryption, b.encryption) &&
_bytesEqual(a.signing, b.signing);
bool _bytesEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) {
return false;
}
var difference = 0;
for (var i = 0; i < a.length; i++) {
difference |= a[i] ^ b[i];
}
return difference == 0;
}

View file

@ -1,54 +0,0 @@
import 'dart:typed_data';
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_content/src/shared_space/shared_keys_gateway.dart';
/// [SharedKeysGateway] on halkyon_base's `sharedSpaceKeys` endpoint.
class ServerpodSharedKeysGateway(final protocol.Caller _base)
implements SharedKeysGateway {
@override
Future<List<MemberKeys>> memberKeys(String space) async => [
for (final member in await _base.sharedSpaceKeys.memberKeys(_id(space)))
(
person: member.person.uuid,
handle: member.handle,
keys: (
encryption: Uint8List.sublistView(member.encryption),
signing: Uint8List.sublistView(member.signing),
),
),
];
@override
Future<List<KeyNeed>> keysNeeded(String space) async => [
for (final need in await _base.sharedSpaceKeys.keysNeeded(_id(space)))
(keyId: need.keyId, person: need.person.uuid),
];
@override
Future<void> seal(String space, List<SealedKey> keys) =>
_base.sharedSpaceKeys.seal(_id(space), [
for (final key in keys)
protocol.SharedSpaceSealedKey(
keyId: key.keyId,
person: _id(key.person),
sealedKey: ByteData.sublistView(key.sealed),
),
]);
@override
Future<List<SealedKey>> mine(String space) async => [
for (final key in await _base.sharedSpaceKeys.mine(_id(space)))
(
keyId: key.keyId,
person: key.person.uuid,
sealed: Uint8List.sublistView(key.sealedKey),
),
];
@override
Future<bool> rollDue(String space) =>
_base.sharedSpaceKeys.rollDue(_id(space));
protocol.UuidValue _id(String id) => protocol.UuidValue.withValidation(id);
}

View file

@ -1,197 +0,0 @@
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
import 'package:halkyon_content/src/shared_space/shared_space_gateway.dart';
/// [SharedSpaceGateway] on halkyon_base's `sharedSpace` endpoint.
class ServerpodSharedSpaceGateway(final protocol.Caller _base)
implements SharedSpaceGateway {
@override
Future<String> create(String kind) =>
_refusing(() async => (await _base.sharedSpace.create(kind)).id.uuid);
@override
Future<List<SpaceInfo>> mine() => _refusing(
() async => [
for (final space in await _base.sharedSpace.mine()) _info(space),
],
);
@override
Future<List<SpaceMember>> members(String space) => _refusing(
() async => [
for (final member in await _base.sharedSpace.members(_id(space)))
_member(member),
],
);
@override
Future<SpaceMember> invite(String space, String handle, SpaceRole role) =>
_refusing(
() async => _member(
await _base.sharedSpace.invite(_id(space), handle, _theirs(role)),
),
);
@override
Future<SpaceMember> inviteByEmail(
String space,
String address,
SpaceRole role,
String language,
) => _refusing(
() async => _member(
await _base.sharedSpace.inviteByEmail(
_id(space),
address,
_theirs(role),
language,
),
),
);
@override
Future<SpaceJoinPreview> preview(String token) => _refusing(() async {
final preview = await _base.sharedSpace.preview(token);
return SpaceJoinPreview(
kind: preview.kind,
role: _role(preview.role),
byEmail: preview.byEmail,
inviterHandle: preview.inviterHandle,
inviterName: preview.inviterName,
lapsesAt: preview.lapsesAt,
member: preview.member,
);
});
@override
Future<String> sendJoinCode(String address, {required String language}) =>
_refusing(
() async => (await _base.emailCode.sendCode(
address: address,
language: language,
purpose: protocol.EmailCodePurpose.joinSpace,
)).uuid,
);
@override
Future<SpaceInfo> acceptEmailInvitation(
String token, {
required String request,
required String code,
}) => _refusing(
() async => _info(
await _base.sharedSpace.acceptEmailInvitation(token, _id(request), code),
),
);
@override
Future<String?> joinLink(String space) =>
_refusing(() => _base.sharedSpace.joinLink(_id(space)));
@override
Future<String?> setJoinLink(String space, {required bool on}) =>
_refusing(() => _base.sharedSpace.setJoinLink(_id(space), on));
@override
Future<SpaceInfo> joinByLink(String token) => _refusing(
() async => _info(await _base.sharedSpace.joinByLink(token)),
);
@override
Future<void> markSeen(String space) =>
_refusing(() => _base.sharedSpace.markSeen(_id(space)));
@override
Future<void> setRole(String space, String member, SpaceRole role) =>
_refusing(
() => _base.sharedSpace.setRole(_id(space), _id(member), _theirs(role)),
);
@override
Future<void> remove(String space, String member) =>
_refusing(() => _base.sharedSpace.remove(_id(space), _id(member)));
@override
Future<void> handOn(String space, String member) =>
_refusing(() => _base.sharedSpace.handOn(_id(space), _id(member)));
@override
Future<void> leave(String space, {String? nextOwner}) => _refusing(
() => _base.sharedSpace.leave(
_id(space),
nextOwner: nextOwner == null ? null : _id(nextOwner),
),
);
@override
Future<void> delete(String space) =>
_refusing(() => _base.sharedSpace.delete(_id(space)));
/// [call], its refusals as [SpaceRefused].
static Future<T> _refusing<T>(Future<T> Function() call) async {
try {
return await call();
} on protocol.SharedSpaceRefusedException catch (refused) {
throw SpaceRefused(switch (refused.reason) {
protocol.SharedSpaceRefusal.unavailable => SpaceRefusal.unavailable,
protocol.SharedSpaceRefusal.notAllowed => SpaceRefusal.notAllowed,
protocol.SharedSpaceRefusal.noSuchHandle => SpaceRefusal.noSuchHandle,
protocol.SharedSpaceRefusal.alreadyMember => SpaceRefusal.alreadyMember,
protocol.SharedSpaceRefusal.ownerRole => SpaceRefusal.ownerRole,
protocol.SharedSpaceRefusal.self => SpaceRefusal.self,
protocol.SharedSpaceRefusal.nextOwnerNeeded =>
SpaceRefusal.nextOwnerNeeded,
protocol.SharedSpaceRefusal.lastMember => SpaceRefusal.lastMember,
protocol.SharedSpaceRefusal.handleNeeded => SpaceRefusal.handleNeeded,
protocol.SharedSpaceRefusal.otherAddress => SpaceRefusal.otherAddress,
protocol.SharedSpaceRefusal.agentViewer => SpaceRefusal.agentViewer,
});
} on protocol.SharedSpaceNotFoundException {
throw const SpaceRefused(SpaceRefusal.notFound);
} on protocol.EmailSignInRefusedException catch (refused) {
throw SpaceRefused(switch (refused.reason) {
protocol.EmailSignInRefusal.unavailable => SpaceRefusal.unavailable,
protocol.EmailSignInRefusal.notAnAddress => SpaceRefusal.notAnAddress,
protocol.EmailSignInRefusal.tooManyCodes => SpaceRefusal.tooManyMails,
protocol.EmailSignInRefusal.mailFailed => SpaceRefusal.mailFailed,
protocol.EmailSignInRefusal.wrongCode => SpaceRefusal.wrongCode,
_ => SpaceRefusal.codeExpired,
});
}
}
static SpaceInfo _info(protocol.SharedSpaceInfo space) => (
id: space.id.uuid,
kind: space.kind,
role: _role(space.role),
isNew: space.isNew,
sharedBy: space.sharedBy,
);
static SpaceMember _member(protocol.SharedSpaceMemberInfo member) =>
SpaceMember(
id: member.id.uuid,
handle: member.handle,
displayName: member.displayName,
role: _role(member.role),
joinedAt: member.joinedAt,
invited: member.invited,
you: member.you,
byEmail: member.byEmail,
agent: member.agent,
);
static SpaceRole _role(protocol.SharedSpaceRole role) => switch (role) {
protocol.SharedSpaceRole.owner => SpaceRole.owner,
protocol.SharedSpaceRole.editor => SpaceRole.editor,
protocol.SharedSpaceRole.viewer => SpaceRole.viewer,
};
static protocol.SharedSpaceRole _theirs(SpaceRole role) => switch (role) {
SpaceRole.owner => protocol.SharedSpaceRole.owner,
SpaceRole.editor => protocol.SharedSpaceRole.editor,
SpaceRole.viewer => protocol.SharedSpaceRole.viewer,
};
static protocol.UuidValue _id(String id) =>
protocol.UuidValue.withValidation(id);
}

View file

@ -1,238 +0,0 @@
import 'package:halkyon_content/src/privacy/content_cipher.dart';
import 'package:halkyon_content/src/shared_space/key_pins.dart';
import 'package:halkyon_content/src/shared_space/shared_keys_gateway.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// The instance hands other public keys for the person than their own on
/// this device: nothing is sealed to keys it chose.
final class OwnKeysDiffer() implements Exception {
@override
String toString() =>
"OwnKeysDiffer: the instance's public keys for this account aren't "
"the device's";
}
/// What sealing a space's keys to its members did.
typedef SealOutcome = ({
/// The people a key was sealed to now.
Set<String> sealedTo,
/// Members whose keys changed since this device pinned them, left out
/// until trusted anew ([KeyPins.trustAnew]).
Set<String> changed,
});
/// What opening the keys sealed to the person did.
typedef OpenOutcome = ({
/// The keys opened, kept open in the cipher.
Set<String> opened,
/// Keys that didn't open: sealed or signed by keys other than those
/// pinned for their sealer, changed, or from a sealer unknown here.
Set<String> refused,
/// Sealers whose keys changed since this device pinned them; their keys
/// stay unopened until trusted anew.
Set<String> changed,
});
/// How this device sees another member's keys (ADR 0044, D18, D32).
typedef MemberTrust = ({
/// Their account's ID.
String person,
/// Their handle; null without a public profile.
String? handle,
/// The keys the instance hands out for them now.
PublicKeys keys,
/// The safety code of the person and them over [keys]: those pinned, or
/// where they changed, the new ones, which [SharedKeys.trustNewKeys]
/// takes once the two compared it.
String code,
/// Whether the person compared it, for the keys pinned.
bool compared,
/// Whether their keys changed since this device pinned them: nothing is
/// sealed to them or taken from them until trusted anew.
bool changed,
});
/// A shared space's data keys on the device (Halkyon's ADR 0044, D3, D18):
/// each sealed to every member's public keys, which this device pins on
/// first sight and holds to from then on, as Signal and SSH do; the
/// instance only carries what devices sealed.
class SharedKeys(
final SharedKeysGateway _gateway,
final KeyPins _pins,
final ContentCipher _cipher, {
required final String me,
}) {
/// Seals the open key [keyId] to every member of [space] who has keys,
/// the person among them: for a space's first key, or the one replacing
/// it after someone left. Throws [OwnKeysDiffer] where the instance's
/// keys for the person aren't the device's.
Future<SealOutcome> share(String space, String keyId) async {
final members = await _checkedMembers(space);
final sealed = [
for (final MapEntry(key: person, value: keys) in members.trusted.entries)
(keyId: keyId, person: person, sealed: _cipher.sealKeyTo(keyId, keys)),
];
await _gateway.seal(space, sealed);
_cipher.trustSigners(keyId, await _pins.signersIn(space));
return (
sealedTo: {for (final key in sealed) key.person},
changed: members.changed,
);
}
/// Whether [space] is due a new key, as a member went since its latest
/// (ADR 0044, D22, D33): a writer's device then makes one and [share]s
/// it. Never for a Viewer.
Future<bool> rollDue(String space) => _gateway.rollDue(space);
/// Seals the keys of [space] that this device holds to the members who
/// lack them: newcomers, and those who have keys only now. Nothing for a
/// Viewer, whom the instance asks for none.
Future<SealOutcome> sealForNewcomers(String space) async {
final needed = await _gateway.keysNeeded(space);
if (needed.isEmpty) {
return (sealedTo: <String>{}, changed: <String>{});
}
final members = await _checkedMembers(space);
final sealed = [
for (final need in needed)
if (_cipher.has(need.keyId))
if (members.trusted[need.person] case final keys?)
(
keyId: need.keyId,
person: need.person,
sealed: _cipher.sealKeyTo(need.keyId, keys),
),
];
if (sealed.isNotEmpty) {
await _gateway.seal(space, sealed);
}
return (
sealedTo: {for (final key in sealed) key.person},
changed: {
for (final need in needed)
if (members.changed.contains(need.person)) need.person,
},
);
}
/// Opens the keys of [space] sealed to the person, each checked against
/// its sealer's pinned keys; one a former member sealed is checked
/// against the keys pinned for them, if any. Content sealed with them is
/// taken from anyone ever seen in the space, by the keys pinned for them
/// (D20).
Future<OpenOutcome> openMine(String space) async {
final mine = await _gateway.mine(space);
if (mine.isEmpty) {
return (opened: <String>{}, refused: <String>{}, changed: <String>{});
}
final members = await _checkedMembers(space);
final opened = <String>{};
final refused = <String>{};
final changed = <String>{};
for (final key in mine) {
if (members.changed.contains(key.person)) {
changed.add(key.person);
refused.add(key.keyId);
continue;
}
final sealer =
members.trusted[key.person] ?? (await _pins.of(key.person))?.keys;
final opens =
sealer != null &&
_cipher.openSharedKey(
key.keyId,
key.sealed,
sealedBy: sealer.signing,
);
(opens ? opened : refused).add(key.keyId);
}
// What is sealed with the space's keys is taken from anyone seen in it
// (D20), the person's own key among them.
final signers = await _pins.signersIn(space);
for (final key in mine) {
if (_cipher.has(key.keyId)) {
_cipher.trustSigners(key.keyId, signers);
}
}
return (opened: opened, refused: refused, changed: changed);
}
/// The other members of [space] with keys, as this device sees them,
/// pinning those seen for the first time.
Future<List<MemberTrust>> trustIn(String space) async {
final members = await _checkedMembers(space);
return [
for (final member in await _gateway.memberKeys(space))
if (member.person != me &&
(members.trusted.containsKey(member.person) ||
members.changed.contains(member.person)))
(
person: member.person,
handle: member.handle,
keys: member.keys,
code: _cipher.safetyCodeWith(member.keys),
compared:
!members.changed.contains(member.person) &&
((await _pins.of(member.person))?.verified ?? false),
changed: members.changed.contains(member.person),
),
];
}
/// Marks [person]'s pinned keys compared, by their safety code.
Future<void> markCompared(String person) => _pins.markVerified(person);
/// Pins [keys] for [person], compared, in place of those before: the
/// person compared their safety code over them (`MemberTrust.code`). The
/// keys the code was shown for, never those the instance hands next.
Future<void> trustNewKeys(String person, PublicKeys keys) async {
await _pins.trustAnew(person, keys);
await _pins.markVerified(person);
}
/// The safety code of the person and [person], over the keys pinned for
/// them; null where none are.
Future<String?> safetyCodeWith(String person) async {
final pinned = await _pins.of(person);
return pinned == null ? null : _cipher.safetyCodeWith(pinned.keys);
}
/// [space]'s members with keys, checked against their pins: those whose
/// keys are pinned (or now first seen) and those whose keys changed. The
/// person's own must be the cipher's.
Future<({Map<String, PublicKeys> trusted, Set<String> changed})>
_checkedMembers(String space) async {
final trusted = <String, PublicKeys>{};
final changed = <String>{};
for (final member in await _gateway.memberKeys(space)) {
if (member.person == me) {
if (!sameKeys(member.keys, _cipher.ownKeys)) {
throw OwnKeysDiffer();
}
// The device's own keys are the person's, so they are pinned as
// they are, an earlier key of theirs trusted still for what it
// signed.
if (await _pins.check(me, member.keys) == PinCheck.changed) {
await _pins.trustAnew(me, member.keys);
}
trusted[me] = member.keys;
} else if (await _pins.check(member.person, member.keys) ==
PinCheck.changed) {
changed.add(member.person);
} else {
trusted[member.person] = member.keys;
}
}
await _pins.sawIn(space, [...trusted.keys, ...changed]);
return (trusted: trusted, changed: changed);
}
}

View file

@ -1,34 +0,0 @@
import 'dart:typed_data';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// A member of a shared space with their public keys, as the instance hands
/// them.
typedef MemberKeys = ({String person, String? handle, PublicKeys keys});
/// A key the device holds that `person` lacks.
typedef KeyNeed = ({String keyId, String person});
/// A key sealed to one person: to `person` on the way to the instance,
/// from `person` (its sealer) on the way back.
typedef SealedKey = ({String keyId, String person, Uint8List sealed});
/// The boundary to the keys of spaces shared on the instance (halkyon_base's
/// `sharedSpaceKeys`, Halkyon's ADR 0044, D3). Tests replace it with a fake.
abstract interface class SharedKeysGateway() {
/// The public keys of [space]'s members who have keys.
Future<List<MemberKeys>> memberKeys(String space);
/// The keys of [space] the person holds that members lack.
Future<List<KeyNeed>> keysNeeded(String space);
/// Keeps [keys], each sealed to the member it names.
Future<void> seal(String space, List<SealedKey> keys);
/// The keys of [space] sealed to the person, each naming its sealer.
Future<List<SealedKey>> mine(String space);
/// Whether [space] is due a new key, a member having gone since its
/// latest (ADR 0044, D22); never for a Viewer.
Future<bool> rollDue(String space);
}

View file

@ -1,231 +0,0 @@
/// What a member may do in a shared space (Halkyon's ADR 0044, D2).
enum SpaceRole() {
/// Everything: manages the members, hands the space on, deletes it.
owner,
/// Changes its content.
editor,
/// Reads and studies it.
viewer;
/// Whether the role changes content: the Owner's and an Editor's.
bool get writes => this != viewer;
}
/// A shared space the person is a member of: the sync engine's space, what
/// it holds in the app's words, and their role in it; whether it is new to
/// them, someone else having made them a member they haven't seen since,
/// and that one's handle while it is (ADR 0044, D40).
typedef SpaceInfo = ({
String id,
String kind,
SpaceRole role,
bool isNew,
String? sharedBy,
});
/// What a link to join a shared space says before anyone joins (ADR 0044,
/// D39): what it holds and as whom, who invites, never its content's names.
final class const SpaceJoinPreview({
/// What the space holds, in the app's words.
required final String kind,
/// The role joining gives.
required final SpaceRole role,
/// Whether it is an invitation mailed to an address, which joining
/// proves with a mailed code (D37); else the space's own link.
required final bool byEmail,
/// The handle of who invites; null without a public profile.
required final String? inviterHandle,
/// Their display name; empty without one.
required final String inviterName,
/// When a mailed invitation lapses; null for a link.
required final DateTime? lapsesAt,
/// Whether the person is a member already.
required final bool member,
});
/// The token a link to join names, from what the person pasted: the link
/// (`https://learn.halkyon.eu/join/<token>`, with anything after the
/// token left off) or the token alone; null for nothing.
String? joinTokenOf(String pasted) {
final text = pasted.trim();
final at = text.indexOf('/join/');
final token = (at < 0 ? text : text.substring(at + '/join/'.length))
.split(RegExp(r'[/?#\s]'))
.first;
return token.isEmpty ? null : token;
}
/// A member of a shared space, or someone invited who hasn't joined yet, as
/// the members see them: by their public profile, never by account ID.
final class const SpaceMember({
/// What names them in calls about the space: their membership, or the
/// invitation while it waits.
required final String id,
/// Their handle; null without a public profile.
required final String? handle,
/// Their display name; empty without one.
required final String displayName,
required final SpaceRole role,
/// When they joined; null while invited.
required final DateTime? joinedAt,
/// Whether the invitation waits for them to use the app (ADR 0044, D13).
required final bool invited,
/// Whether this is the person.
required final bool you,
/// Whether this is an invitation mailed to an address (D38), which
/// members never see: neither handle nor name.
final bool byEmail = false,
/// Whether this is an agent's account (D44), a Viewer always.
final bool agent = false,
});
/// Why a call about a shared space was refused; nothing changed.
enum SpaceRefusal() {
/// The instance keeps no shared spaces for this app.
unavailable,
/// Only the Owner does that (or a member who may invite, inviting).
notAllowed,
/// No account has that handle.
noSuchHandle,
/// They are a member or invited already.
alreadyMember,
/// The Owner's role passes only by handing the space on.
ownerRole,
/// Not on oneself: the Owner hands on or leaves instead.
self,
/// The Owner leaves only naming who takes the space on.
nextOwnerNeeded,
/// The only member doesn't leave: they delete the space.
lastMember,
/// Inviting by mail names who invites by their handle: the person has
/// none (ADR 0044, D37).
handleNeeded,
/// The code proved another address than the one invited (D37).
otherAddress,
/// What was typed is no email address.
notAnAddress,
/// Too many mails for that address, or from this network, this hour.
tooManyMails,
/// The mail couldn't be sent.
mailFailed,
/// The mailed code isn't right.
wrongCode,
/// The mailed code is no longer valid: used, too many tries, too old.
codeExpired,
/// An agent is a Viewer, never more (ADR 0044, D44).
agentViewer,
/// No such space the person is in, or no such member in it; for a link,
/// one that holds no more.
notFound,
}
/// A call about a shared space refused for [reason].
final class const SpaceRefused(final SpaceRefusal reason) implements Exception {
@override
String toString() => 'SpaceRefused(${reason.name})';
}
/// The shared spaces the instance keeps (halkyon_base's `sharedSpace`
/// endpoint, Halkyon's ADR 0044, D1, D2, D11, D13). Each call throws
/// [SpaceRefused] where the instance refuses it. Tests replace it with a
/// fake.
abstract interface class SharedSpaceGateway() {
/// Makes a space holding [kind] (the app's word), with the person its
/// Owner; returns its id, the sync engine's space.
Future<String> create(String kind);
/// The spaces the person is a member of, with their role in each.
Future<List<SpaceInfo>> mine();
/// The members of [space] and those invited, the Owner first, then as
/// they joined.
Future<List<SpaceMember>> members(String space);
/// Invites the person with [handle] to [space] as [role]: a member at once
/// where they use the app on this instance, else invited until they do.
Future<SpaceMember> invite(String space, String handle, SpaceRole role);
/// Mails an invitation to [space] as [role] to [address], worded in
/// [language] (D37, D38): its link admits whoever proves the address.
Future<SpaceMember> inviteByEmail(
String space,
String address,
SpaceRole role,
String language,
);
/// What the link of [token] invites to (D39).
Future<SpaceJoinPreview> preview(String token);
/// Mails a code to [address], worded in [language], to accept an
/// invitation sent there; the request it answers.
Future<String> sendJoinCode(String address, {required String language});
/// Joins the space the invitation mailed with [token] is to, once [code],
/// mailed for [request], proves its address (D37); the space.
Future<SpaceInfo> acceptEmailInvitation(
String token, {
required String request,
required String code,
});
/// The link of [space] while someone may join by it; null while not.
Future<String?> joinLink(String space);
/// Switches [space]'s link on, a new one, or off for good; the Owner's.
/// The link while on.
Future<String?> setJoinLink(String space, {required bool on});
/// Joins the space whose link [token] is, as a Viewer (D5, D39).
Future<SpaceInfo> joinByLink(String token);
/// Marks [space] seen (D40): no longer new to the person.
Future<void> markSeen(String space);
/// Gives the member [member] of [space] the role [role]; the Owner's.
Future<void> setRole(String space, String member, SpaceRole role);
/// Removes the member or invitation [member] from [space]; the Owner's.
Future<void> remove(String space, String member);
/// Makes [member] the Owner of [space], and the person, its Owner, an
/// Editor.
Future<void> handOn(String space, String member);
/// Leaves [space]; its Owner names who takes it on ([nextOwner]).
Future<void> leave(String space, {String? nextOwner});
/// Deletes [space] with everything in it; its Owner's.
Future<void> delete(String space);
}

View file

@ -1,196 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/src/privacy/content_cipher.dart';
import 'package:halkyon_content/src/privacy/unlocked_account_keys.dart';
import 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
/// [ContentCipher] without libsodium, for tests: a key opens only on a
/// device unlocked for the account it was made for, and content only under
/// its key and for its context, as the real one; but nothing here keeps it
/// secret (each byte is flipped, so the plain text doesn't show).
class FakeContentCipher() implements ContentCipher {
String? _account;
PublicKeys? _ownKeys;
/// The keys open here, by their ID, with the account they belong to.
final Map<String, String> opened = {};
/// The signers trusted for each shared key, by its ID ([trustSigners]),
/// as their signing keys in base64.
final _signers = <String, Set<String>>{};
@override
Future<void> unlock(UnlockedAccountKeys account) async {
_account = base64Encode(account.signingPublicKey);
_ownKeys = (
encryption: account.encryptionPublicKey,
signing: account.signingPublicKey,
);
}
@override
Uint8List newKey(String keyId) {
final account = _unlocked();
opened[keyId] = account;
return Uint8List.fromList(utf8.encode('key|$account|$keyId'));
}
@override
bool openKey(String keyId, Uint8List sealed) {
final account = _unlocked();
if (utf8.decode(sealed, allowMalformed: true) != 'key|$account|$keyId') {
return false;
}
opened[keyId] = account;
return true;
}
@override
bool has(String keyId) => opened.containsKey(keyId);
@override
PublicKeys get ownKeys => _ownKeys ?? (throw StateError('Not unlocked'));
@override
Uint8List sealKeyTo(String keyId, PublicKeys to) {
final account = _unlocked();
if (!has(keyId)) {
throw StateError('The data key $keyId is not open');
}
return Uint8List.fromList(
utf8.encode('shared|${base64Encode(to.encryption)}|$keyId|$account'),
);
}
@override
bool openSharedKey(
String keyId,
Uint8List sealed, {
required Uint8List sealedBy,
}) {
if (utf8.decode(sealed, allowMalformed: true) !=
'shared|${base64Encode(ownKeys.encryption)}|$keyId|'
'${base64Encode(sealedBy)}') {
return false;
}
opened[keyId] = _unlocked();
return true;
}
/// Both accounts' signing keys, in one order: alike on both sides, but
/// not the real code's digits.
@override
String safetyCodeWith(PublicKeys other) => ([
base64Encode(ownKeys.signing),
base64Encode(other.signing),
]..sort()).join(' ');
@override
SealedPayload seal(
Uint8List plain, {
required String keyId,
required List<int> context,
}) {
if (!has(keyId)) {
throw StateError('The data key $keyId is not open');
}
final tag = _tag(keyId, _unlocked(), context);
return SealedPayload(
keyId: keyId,
nonce: Uint8List(24),
payload: Uint8List.fromList([
tag.length >> 8,
tag.length & 0xff,
...tag,
for (final byte in plain) byte ^ 0xff,
]),
);
}
@override
Uint8List? unseal(SealedPayload sealed, {required List<int> context}) {
final payload = sealed.payload;
if (!has(sealed.keyId) || payload.length < 2) {
return null;
}
final length = payload[0] << 8 | payload[1];
if (payload.length < 2 + length) {
return null;
}
final found = Uint8List.sublistView(payload, 2, 2 + length);
final signed = (_signers[sealed.keyId] ?? {_unlocked()}).any((signer) {
final tag = _tag(sealed.keyId, signer, context);
return length == tag.length && _same(found, tag);
});
if (!signed) {
return null;
}
return Uint8List.fromList([
for (final byte in payload.skip(2 + length)) byte ^ 0xff,
]);
}
@override
void trustSigners(String keyId, List<Uint8List> signers) =>
_signers[keyId] = {for (final signer in signers) base64Encode(signer)};
@override
Uint8List sign(List<int> message) {
final account = _unlocked();
final tag = utf8.encode('sig|$account|${base64Encode(message)}');
// 64 bytes, as a real signature: a digest of what it signs, folded.
final signature = Uint8List(64);
for (var i = 0; i < tag.length; i++) {
signature[i % 64] = (signature[i % 64] * 31 + tag[i]) & 0xff;
}
return signature;
}
@override
bool verify(List<int> message, List<int> signature) =>
_account != null &&
signature.length == 64 &&
_same(Uint8List.fromList(signature), sign(message));
/// A UUID of version 8 made from the account and [name], as the real
/// one's form; but nothing here keys it.
@override
String rowId(String name) {
final account = _unlocked();
final bytes = Uint8List(16);
final tag = utf8.encode('row|$account|$name');
for (var i = 0; i < tag.length; i++) {
bytes[i % 16] = (bytes[i % 16] * 31 + tag[i]) & 0xff;
}
bytes[6] = bytes[6] & 0x0f | 0x80;
bytes[8] = bytes[8] & 0x3f | 0x80;
final hex = [
for (final byte in bytes) byte.toRadixString(16).padLeft(2, '0'),
].join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}
@override
void dispose() {
opened.clear();
_signers.clear();
}
String _unlocked() => _account ?? (throw StateError('Not unlocked'));
/// What binds a payload to its key, the account that sealed it (its
/// signing key in base64) and its context.
Uint8List _tag(String keyId, String author, List<int> context) =>
utf8.encode('$keyId|$author|${base64Encode(context)}');
static bool _same(Uint8List a, Uint8List b) {
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}
}

View file

@ -1,106 +0,0 @@
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_content/src/files/file_server_gateway.dart';
/// A [FileServerGateway] in memory, holding files by hash as the server
/// does, and the rows' files by id for downloads.
class FakeFileServerGateway() implements FileServerGateway {
/// The files the server keeps, by hash.
final files = <String, Uint8List>{};
/// The hash of each row naming a file the server knows, by id: what a download
/// serves.
final rows = <String, String>{};
/// What was uploaded and not finished yet, by hash.
final _uploaded = <String, Uint8List>{};
/// The hashes uploads were started for, in order.
final started = <String>[];
/// 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;
/// When true, uploads don't arrive.
bool failUploads = false;
/// When set, uploads arrive with this content instead of what was sent.
Uint8List? corruptUploads;
/// What the person may keep, in bytes; past it, uploads are refused.
int limitBytes = 1 << 30;
int get _usedBytes =>
files.values.fold(0, (sum, bytes) => sum + bytes.length);
@override
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;
}
if (_usedBytes + byteSize > limitBytes) {
throw FileLimitReached(instanceFull: false, limitBytes: limitBytes);
}
return 'upload:$sha256';
}
@override
Future<bool> upload(String description, Uint8List bytes) async {
_check();
if (failUploads) {
return false;
}
_uploaded[description.substring('upload:'.length)] =
corruptUploads ?? bytes;
return true;
}
@override
Future<bool> finishUpload(String sha256, {bool encrypted = false}) async {
_check();
final bytes = _uploaded.remove(sha256);
if (bytes == null || sha256Of(bytes) != sha256) {
return false;
}
files[sha256] = bytes;
return true;
}
@override
Future<FileRoom> room() async {
_check();
return (usedBytes: _usedBytes, limitBytes: limitBytes);
}
@override
Future<Uint8List?> download(String id) async {
_check();
downloads.add(id);
return files[rows[id]];
}
void _check() {
if (failCalls case final error?) {
throw error;
}
}
}
/// The SHA-256 of [bytes] in lowercase hex.
String sha256Of(List<int> bytes) => sha256.convert(bytes).toString();

Some files were not shown because too many files have changed in this diff Show more