refactor(content,core,app,editor,learn): Learn's content in pure Dart, for its agent
ADR 0044's D49 (amended in building it): what reads and seals Learn's content moves out of the Flutter packages, so Learn's agent, which runs without Flutter, shares it rather than repeating the privacy rules. packages/halkyon_content (new, pure Dart) takes from halkyon_app the envelope and its cipher, SealedFile, UnlockedAccountKeys, shared spaces' gateway with SharedKeys and KeyPins, FileCache and FileServerGateway, with their fakes and tests; halkyon_app exports it, so no app changes. Its rich_text.dart takes RichContent from halkyon_editor, which exports it. newAccountKeys makes an agent's key pair. halkyon_core takes DelayedDeletions and gains SyncRequests, what a synced gateway asks of the sync, which SyncService implements. apps/learn/halkyon_learn_core (new, pure Dart) takes Learn's privacy, sync, payloads and the models, codecs, gateways and repositories of studysets, cards, notes, tags and feedback, with what they reach of files, learning paths, citations and study options; the app imports them from there. A subject's icon, colour and name stay in the app (SubjectLook); what only the app shows (progress, exam coverage, the card browser, deletions' waits) stays too. Nothing changes for people using the apps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
82c777b1a5
commit
d09625d43e
40 changed files with 3517 additions and 2007 deletions
|
|
@ -51,8 +51,15 @@
|
|||
/// `HalkyonAppLocalizations.delegate` beside its own.
|
||||
library;
|
||||
|
||||
// A member's public keys, as `SharedKeys` and `KeyPins` take them.
|
||||
export 'package:halkyon_keys/halkyon_keys.dart' show PublicKeys;
|
||||
// The envelope every synced row is sealed in, the account's unlocked keys
|
||||
// and the keys of shared spaces (Halkyon's ADR 0044, D49): pure Dart, so an
|
||||
// agent without Flutter seals and opens them as an app does; with them a
|
||||
// member's public keys (`PublicKeys`).
|
||||
export 'package:halkyon_content/halkyon_content.dart';
|
||||
// Deletions waiting for their Undo, and what a synced gateway asks of the
|
||||
// sync (`SyncService` is one), pure Dart for the agent too.
|
||||
export 'package:halkyon_core/halkyon_core.dart'
|
||||
show DelayedDeletions, SyncRequests;
|
||||
|
||||
export 'l10n/halkyon_app_localizations.dart';
|
||||
export 'src/agent/bloc/agents_cubit.dart';
|
||||
|
|
@ -93,7 +100,6 @@ export 'src/assistant/widget/assistant_provider_view.dart';
|
|||
export 'src/assistant/widget/assistant_use_view.dart';
|
||||
export 'src/assistant/widget/assistant_words.dart';
|
||||
export 'src/assistant/widget/assistants_card.dart';
|
||||
export 'src/async/delayed_deletions.dart';
|
||||
export 'src/auth/bloc/session_bloc.dart';
|
||||
export 'src/auth/bloc/sign_in_ways_cubit.dart';
|
||||
export 'src/auth/bloc/wait_cubit.dart';
|
||||
|
|
@ -112,12 +118,9 @@ export 'src/auth/widget/session_gate.dart';
|
|||
export 'src/auth/widget/start_screen.dart';
|
||||
export 'src/auth/widget/wait_screen.dart';
|
||||
export 'src/base/base_client.dart';
|
||||
export 'src/files/file_cache.dart';
|
||||
export 'src/files/file_opener.dart';
|
||||
export 'src/files/file_picker.dart';
|
||||
export 'src/files/file_saver.dart';
|
||||
export 'src/files/file_server_gateway.dart';
|
||||
export 'src/files/serverpod_file_server_gateway.dart';
|
||||
export 'src/handle/bloc/handle_check_cubit.dart';
|
||||
export 'src/handle/model/handle_problem.dart';
|
||||
export 'src/handle/widget/handle_field.dart';
|
||||
|
|
@ -168,7 +171,6 @@ export 'src/password/model/account_security.dart';
|
|||
export 'src/password/model/mail_code_purpose.dart';
|
||||
export 'src/password/model/password_failed.dart';
|
||||
export 'src/password/model/phrase_check.dart';
|
||||
export 'src/password/model/unlocked_account_keys.dart';
|
||||
export 'src/password/widget/account_recovery_sheet.dart';
|
||||
export 'src/password/widget/account_security_section.dart';
|
||||
export 'src/password/widget/account_setup_sheet.dart';
|
||||
|
|
@ -178,9 +180,6 @@ export 'src/password/widget/password_widgets.dart';
|
|||
export 'src/password/widget/secret_clipboard.dart';
|
||||
export 'src/password/widget/secret_screen.dart';
|
||||
export 'src/person/person_folders.dart';
|
||||
export 'src/privacy/content_cipher.dart';
|
||||
export 'src/privacy/payload_envelope.dart';
|
||||
export 'src/privacy/sealed_file.dart';
|
||||
export 'src/profile/bloc/public_profile_bloc.dart';
|
||||
export 'src/profile/data/avatar_image.dart';
|
||||
export 'src/profile/data/base_public_profile_gateway.dart';
|
||||
|
|
@ -190,12 +189,6 @@ export 'src/profile/model/public_profile.dart';
|
|||
export 'src/profile/widget/halkyon_profile.dart';
|
||||
export 'src/profile/widget/profile_sheet.dart';
|
||||
export 'src/profile/widget/public_profile_section.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';
|
||||
export 'src/simplelogin/bloc/simplelogin_cubit.dart';
|
||||
export 'src/simplelogin/bloc/simplelogin_standing_cubit.dart';
|
||||
export 'src/simplelogin/data/serverpod_simplelogin_gateway.dart';
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
/// Deletions that can be undone for a moment (decided 2026-10-01): what is
|
||||
/// deleted leaves every list at once ([pending]), and only once the time to
|
||||
/// undo is over (for a deletion held, counted from when [delete] lets it go)
|
||||
/// does [_deleteNow] run, since blanking can't be undone (ADR 0002, rule 2).
|
||||
///
|
||||
/// It belongs to the person's data, so a deletion outlives the screen it
|
||||
/// started on, and it closes with the person's data: a deletion still
|
||||
/// waiting then is dropped, and what it would have deleted stays.
|
||||
class DelayedDeletions(
|
||||
/// Deletes an item, given its id, for good.
|
||||
final Future<void> Function(String id) _deleteNow, {
|
||||
|
||||
/// Names the log a failed deletion goes to.
|
||||
final String _logName = 'Deletions',
|
||||
|
||||
/// How long a deletion can be undone: the three seconds the message
|
||||
/// offering it shows, and a moment for a late tap.
|
||||
final Duration _undoWindow = const Duration(seconds: 4),
|
||||
}) {
|
||||
/// The deletions that can still be undone, by the item's id: with the
|
||||
/// timer that starts it, or none while it is held.
|
||||
final _waiting = <String, Timer?>{};
|
||||
|
||||
/// The deletions under way, too late to undo.
|
||||
final _deleting = <String, Future<void>>{};
|
||||
|
||||
final _changes = StreamController<Set<String>>.broadcast();
|
||||
|
||||
/// The items whose deletion waits or is under way: lists leave them out.
|
||||
Set<String> get pending => {..._waiting.keys, ..._deleting.keys};
|
||||
|
||||
/// [pending], now and after every change.
|
||||
Stream<Set<String>> watchPending() => Stream.multi((listener) {
|
||||
listener.add(pending);
|
||||
final subscription = _changes.stream.listen(listener.add);
|
||||
listener.onCancel = subscription.cancel;
|
||||
});
|
||||
|
||||
/// Deletes the item [id] once the time to undo that is over.
|
||||
void delete(String id) {
|
||||
if (_deleting.containsKey(id)) {
|
||||
return;
|
||||
}
|
||||
_waiting[id]?.cancel();
|
||||
_waiting[id] = Timer(_undoWindow, () => _start(id));
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Takes the item [id] out of every list as [delete] does, but deletes it
|
||||
/// only once [delete] is called for it, however long that takes: e.g. at
|
||||
/// the end of a study session, which can undo it until then. Closing drops
|
||||
/// it as any deletion still waiting, and the item stays.
|
||||
void hold(String id) {
|
||||
if (_deleting.containsKey(id)) {
|
||||
return;
|
||||
}
|
||||
_waiting[id]?.cancel();
|
||||
_waiting[id] = null;
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Keeps the item [id] after all, while its deletion still waits.
|
||||
void undo(String id) {
|
||||
if (_waiting.containsKey(id)) {
|
||||
_waiting.remove(id)?.cancel();
|
||||
_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the deletions still waiting, and lets those under way finish.
|
||||
Future<void> dispose() async {
|
||||
for (final timer in _waiting.values) {
|
||||
timer?.cancel();
|
||||
}
|
||||
_waiting.clear();
|
||||
await Future.wait(_deleting.values);
|
||||
await _changes.close();
|
||||
}
|
||||
|
||||
void _start(String id) {
|
||||
_waiting.remove(id);
|
||||
_deleting[id] = _guarded(id).whenComplete(() {
|
||||
_deleting.remove(id);
|
||||
_changed();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _guarded(String id) async {
|
||||
try {
|
||||
await _deleteNow(id);
|
||||
} on Object catch (error) {
|
||||
// What is left shows again; deleting it once more retries.
|
||||
log('Deleting $id failed', name: _logName, error: error);
|
||||
}
|
||||
}
|
||||
|
||||
void _changed() {
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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_app/src/files/file_server_gateway.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
|
||||
/// [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,8 +1,8 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
|
||||
/// What a password unlocks on the device (ADR 0017), for one account's salt
|
||||
/// and costs: SRP's private value and the key that wraps the account's
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'dart:io';
|
|||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
|
||||
/// The system's keyring as the account's keys are kept in it. On iOS and
|
||||
/// macOS they open only while the device is unlocked and stay on this
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import 'package:halkyon_app/src/password/model/account_authenticator.dart';
|
|||
import 'package:halkyon_app/src/password/model/account_security.dart';
|
||||
import 'package:halkyon_app/src/password/model/mail_code_purpose.dart';
|
||||
import 'package:halkyon_app/src/password/model/password_failed.dart';
|
||||
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
|
||||
/// A sign-in under way: the address typed, and what the password unlocked
|
||||
/// for the account there (or for a decoy, should nobody have it: the
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_app/src/password/data/account_crypto.dart';
|
||||
import 'package:halkyon_app/src/password/data/proof_digests.dart';
|
||||
import 'package:halkyon_app/src/password/model/password_failed.dart';
|
||||
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_keys/halkyon_keys.dart';
|
||||
import 'package:sodium/sodium_sumo.dart';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,338 +0,0 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_app/src/password/model/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,157 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_app/src/privacy/content_cipher.dart';
|
||||
|
||||
/// A synced row's envelope (ADR 0002): its key's ID and nonce, null while
|
||||
/// readable, and its payload.
|
||||
typedef Envelope = ({String? keyId, ByteData? nonce, ByteData payload});
|
||||
|
||||
/// What a row's payload is once opened (ADR 0019).
|
||||
sealed class const Opened() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The payload as the codecs read it: readable, or opened with its key. A
|
||||
/// blank one is a row being deleted (ADR 0002, rule 2), whatever its key.
|
||||
/// [trusted] unless it is readable without the account's signature: a row
|
||||
/// the instance could have made up, or one a device without the account's
|
||||
/// keys wrote (ADR 0019, the reading of 22 Oct).
|
||||
final class const Readable(
|
||||
final ByteData payload, {
|
||||
final bool trusted = true,
|
||||
}) extends Opened {
|
||||
@override
|
||||
List<Object?> get props => [Uint8List.sublistView(payload), trusted];
|
||||
}
|
||||
|
||||
/// A payload whose key isn't open on this device, or that doesn't open
|
||||
/// with it (changed, or moved from another row): never read, never shown
|
||||
/// half-read.
|
||||
final class const Locked() extends Opened;
|
||||
|
||||
/// Seals and opens every synced payload (ADR 0019), the one place the
|
||||
/// gateways go through: a payload is sealed for its table, row and column,
|
||||
/// and for the row it belongs to where that decides its key (`bound`: a
|
||||
/// revision's card, note or step, an answer's step, a path's or PDF's
|
||||
/// studyset), so the instance can neither move it to another row nor point
|
||||
/// it at another item unnoticed (the final reading of 22 Oct).
|
||||
///
|
||||
/// A readable payload a device holding the account's keys writes carries the
|
||||
/// account's signature (64 bytes) in place of a nonce, over the same: a
|
||||
/// device can tell it from one the instance made up or re-pointed, and
|
||||
/// trusts the latter nowhere privacy holds (the reading of 22 Oct).
|
||||
class PayloadEnvelope(
|
||||
final ContentCipher _cipher, {
|
||||
|
||||
/// Whether this device can sign: it holds the account's keys.
|
||||
final bool Function()? canSign,
|
||||
}) {
|
||||
/// [plain] as the row keeps it: sealed with the key [keyId], readable
|
||||
/// without one, and then signed where this device can; for its [table],
|
||||
/// row [id], [column] and the row it is [bound] to (empty for none). A
|
||||
/// blank payload stays blank. Throws a [StateError] for a key not open on
|
||||
/// this device.
|
||||
Envelope seal(
|
||||
ByteData plain, {
|
||||
required String? keyId,
|
||||
required String table,
|
||||
required String id,
|
||||
String column = 'payload',
|
||||
String bound = '',
|
||||
}) {
|
||||
if (plain.lengthInBytes == 0) {
|
||||
return (keyId: keyId, nonce: null, payload: plain);
|
||||
}
|
||||
if (keyId == null) {
|
||||
final signs = canSign?.call() ?? false;
|
||||
return (
|
||||
keyId: null,
|
||||
nonce: signs
|
||||
? ByteData.sublistView(
|
||||
_cipher.sign(
|
||||
_readableMessage(table, id, column, bound, plain),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
payload: plain,
|
||||
);
|
||||
}
|
||||
final sealed = _cipher.seal(
|
||||
Uint8List.sublistView(plain),
|
||||
keyId: keyId,
|
||||
context: _context(table, id, column, bound),
|
||||
);
|
||||
return (
|
||||
keyId: keyId,
|
||||
nonce: ByteData.sublistView(sealed.nonce),
|
||||
payload: ByteData.sublistView(sealed.payload),
|
||||
);
|
||||
}
|
||||
|
||||
/// [envelope]'s payload opened: as it is while readable or blank, opened
|
||||
/// with its key, or [Locked]; one sealed or signed for another row, or
|
||||
/// [bound] to another, doesn't open or isn't trusted.
|
||||
Opened open(
|
||||
Envelope envelope, {
|
||||
required String table,
|
||||
required String id,
|
||||
String column = 'payload',
|
||||
String bound = '',
|
||||
}) {
|
||||
final (:keyId, :nonce, :payload) = envelope;
|
||||
if (payload.lengthInBytes == 0) {
|
||||
return Readable(payload);
|
||||
}
|
||||
if (keyId == null) {
|
||||
return Readable(
|
||||
payload,
|
||||
trusted:
|
||||
nonce != null &&
|
||||
_cipher.verify(
|
||||
_readableMessage(table, id, column, bound, payload),
|
||||
Uint8List.sublistView(nonce),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (nonce == null) {
|
||||
return const Locked();
|
||||
}
|
||||
final plain = _cipher.unseal(
|
||||
SealedPayload(
|
||||
keyId: keyId,
|
||||
nonce: Uint8List.sublistView(nonce),
|
||||
payload: Uint8List.sublistView(payload),
|
||||
),
|
||||
context: _context(table, id, column, bound),
|
||||
);
|
||||
return plain == null
|
||||
? const Locked()
|
||||
: Readable(ByteData.sublistView(plain));
|
||||
}
|
||||
|
||||
/// What binds a payload to where it is stored and what it belongs to; its
|
||||
/// format's version first (2 since the final reading of 22 Oct, which
|
||||
/// added `bound`; nothing sealed before was released).
|
||||
static List<int> _context(
|
||||
String table,
|
||||
String id,
|
||||
String column,
|
||||
String bound,
|
||||
) => utf8.encode('v2|$table|$id|$column|$bound');
|
||||
|
||||
/// What a readable payload's signature is over: that it is readable, where
|
||||
/// it is stored, what it belongs to, and what it says.
|
||||
static List<int> _readableMessage(
|
||||
String table,
|
||||
String id,
|
||||
String column,
|
||||
String bound,
|
||||
ByteData payload,
|
||||
) => [
|
||||
...utf8.encode('readable|v2|$table|$id|$column|$bound|'),
|
||||
...Uint8List.sublistView(payload),
|
||||
];
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_app/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,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_app/src/shared_space/shared_keys_gateway.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
|
||||
/// [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_app/src/shared_space/shared_space_gateway.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
|
||||
/// [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_app/src/privacy/content_cipher.dart';
|
||||
import 'package:halkyon_app/src/shared_space/key_pins.dart';
|
||||
import 'package:halkyon_app/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);
|
||||
}
|
||||
|
|
@ -9,13 +9,13 @@ import 'package:halkyon_app/src/auth/model/session_grant.dart';
|
|||
import 'package:halkyon_app/src/handle/model/handle_problem.dart';
|
||||
import 'package:halkyon_app/src/password/data/account_crypto.dart';
|
||||
import 'package:halkyon_app/src/password/data/account_key_store.dart';
|
||||
import 'package:halkyon_app/src/password/model/unlocked_account_keys.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/data/simplelogin_gateway.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/model/simplelogin_arrival.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/model/simplelogin_failed.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/model/simplelogin_standing.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:halkyon_base_client/srp.dart' as srp;
|
||||
import 'package:halkyon_content/halkyon_content.dart';
|
||||
import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart';
|
||||
|
||||
/// Where SimpleLogin's forwarding page sends the browser back to, on the
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue