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:
parent
016f9b7e26
commit
b4290f71c8
25 changed files with 118206 additions and 13301 deletions
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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}$');
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(…)';
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue