From d6f4c434fa3c67b6dba7df5526d3016c40c6e330 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 28 Sep 2026 23:49:39 +0300 Subject: [PATCH 1/6] fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66 --- android/gradlew.bat | 90 ++ lib/data/providers/providers.dart | 2 +- lib/main.dart | 3 +- lib/native/client.dart | 211 +++ lib/native/ffi.dart | 518 +++++++ lib/native/library.dart | 13 + .../screens/contact_detail_screen.dart | 7 +- lib/presentation/screens/contacts_screen.dart | 2 +- .../screens/message_detail_screen.dart | 3 +- .../screens/onboarding_screen.dart | 13 +- lib/presentation/screens/settings_screen.dart | 16 +- .../widgets/message/message_tile.dart | 3 +- .../widgets/message/message_view.dart | 7 +- lib/smol/address.dart | 48 + lib/smol/client.dart | 608 ++++----- lib/smol/config.dart | 14 +- lib/smol/crypto.dart | 435 ------ lib/smol/noise.dart | 118 -- lib/smol/proto.dart | 663 --------- lib/smol/store.dart | 857 ++++-------- lib/smol/transport.dart | 115 -- lib/smol/ui.dart | 24 + native/Cargo.lock | 738 ++++++++++ native/Cargo.toml | 14 + native/src/bin/dnsprobe.rs | 17 + native/src/ffi.rs | 1205 +++++++++++++++++ native/src/lib.rs | 450 ++++++ test/e2e_test.dart | 107 +- test/ffi_smoke_test.dart | 19 + test/interop_fumi_test.dart | 77 ++ test/native_binding_test.dart | 127 ++ test/recall_flow_test.dart | 31 +- test/smol_test.dart | 317 ----- test/store_test.dart | 241 +--- test/vectors.json | 226 ---- test/widget_test.dart | 2 +- 36 files changed, 4223 insertions(+), 3118 deletions(-) create mode 100755 android/gradlew.bat create mode 100644 lib/native/client.dart create mode 100644 lib/native/ffi.dart create mode 100644 lib/native/library.dart create mode 100644 lib/smol/address.dart delete mode 100644 lib/smol/crypto.dart delete mode 100644 lib/smol/noise.dart delete mode 100644 lib/smol/proto.dart delete mode 100644 lib/smol/transport.dart create mode 100644 lib/smol/ui.dart create mode 100644 native/Cargo.lock create mode 100644 native/Cargo.toml create mode 100644 native/src/bin/dnsprobe.rs create mode 100644 native/src/ffi.rs create mode 100644 native/src/lib.rs create mode 100644 test/ffi_smoke_test.dart create mode 100644 test/interop_fumi_test.dart create mode 100644 test/native_binding_test.dart delete mode 100644 test/smol_test.dart delete mode 100644 test/vectors.json diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100755 index 0000000..aec9973 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lib/data/providers/providers.dart b/lib/data/providers/providers.dart index 1f1bcaa..c222a18 100644 --- a/lib/data/providers/providers.dart +++ b/lib/data/providers/providers.dart @@ -1,7 +1,7 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; /// Overridden in main() with the Hive-backed store opened at boot. diff --git a/lib/main.dart b/lib/main.dart index f01ccdb..4c2beb1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ + import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:hive_flutter/hive_flutter.dart"; @@ -16,7 +17,7 @@ void main() async { // one that's actually sandboxed per-app on every platform. final dataDir = await getApplicationSupportDirectory(); Hive.init(dataDir.path); - final store = await SmolStore.open(); + final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); applyPresetServer(store); runApp( diff --git a/lib/native/client.dart b/lib/native/client.dart new file mode 100644 index 0000000..f445693 --- /dev/null +++ b/lib/native/client.dart @@ -0,0 +1,211 @@ +// The async surface over the raw ABI: every operation runs on a short-lived +// isolate via Isolate.run, because the Rust side blocks — a fetch can hold a +// thread for seconds, which must never be the UI thread. Handles are plain +// addresses (ints) and the master is bytes, so both cross isolates freely, +// and fumi-core's `Send + Sync` store is safe to call from any of them. +// +// Error codes cross in two ranges: 0-10 are wire statuses, 64 up are local +// failures (see ffi.dart) — recovery paths branch on the range. After a +// smolPanic the store's lock is poisoned and every later call panics; the +// UI treats that as "close and reopen the store", not an unsolvable error. + +import "dart:isolate"; +import "dart:typed_data"; + +import "package:smol_mail/native/ffi.dart"; + +class FumiNative { + FumiNative(this.dbPath); + + /// The SQLite file the store owns; one store per process. + final String dbPath; + + int? _store; + int? _account; + int? _flag; + + /// The 32-byte master, held by the app (Hive in kirakira), never by the + /// library. + Uint8List? _master; + + int? get store => _store; + + /// The account handle, when a master is set; readers need it for describe. + int? get account => _account; + + /// Binds the account locally, as register and restore do at their end. + void setAccount(String address) => + SmolFfi.open().setAccount(_store!, address); + + Future _io(T Function(SmolFfi ffi) op) => Isolate.run(() { + final ffi = SmolFfi.open(); + return op(ffi); + }); + + Future open() async { + if (_store != null) return; + _store = await _io((ffi) => ffi.openStore(dbPath)); + _flag = await _io((ffi) => ffi.newFlag()); + } + + /// Drops the identity from memory the honest way Dart allows: the + /// master's bytes are overwritten before the reference goes, and the + /// account handle is freed. What persists is the caller's business + /// (the store wipes its own copy). + void clearMaster() { + _master?.fillRange(0, _master!.length, 0); + _master = null; + final old = _account; + if (old != null) { + SmolFfi.open().freeAccount(old); + } + _account = null; + } + + /// Sets the master and rebuilds the account handle synchronously: the + /// rebuild is a few key derivations (microseconds native), not a network + /// operation, so it never needs an isolate — and widget tests can settle + /// it inside their fake-async zones. + void setMaster(Uint8List master, {int? rotations}) { + _master = master; + _rebuildAccount(rotations: rotations); + } + + /// Rebuilds the account handle from the master and the store's rotation + /// index — required after register, restore and rotate, which all change + /// the index the account stands at. + void _rebuildAccount({int? rotations}) { + final ffi = SmolFfi.open(); + final index = rotations ?? ffi.rotations(_store!); + final old = _account; + if (old != null) { + ffi.freeAccount(old); + } + _account = ffi.newAccount(_master!, index); + } + + int get _s => _store!; + int get _a => _account!; + + Future close() async { + final store = _store; + final account = _account; + final flag = _flag; + _store = null; + _account = null; + _flag = null; + if (account != null) await _io((ffi) => ffi.freeAccount(account)); + if (flag != null) await _io((ffi) => ffi.freeFlag(flag)); + if (store != null) await _io((ffi) => ffi.closeStore(store)); + } + + // --- identity and pins ------------------------------------------------------ + + Future accountPk() => _io((ffi) => ffi.accountPk(_a)); + + Future accountAddress() => _io((ffi) => ffi.accountAddress(_s)); + + Future rotations() => _io((ffi) => ffi.rotations(_s)); + + Future pinServer(String host, String keyB32) => + _io((ffi) => ffi.pinServer(_s, host, keyB32)); + + Future serverPin(String host) => + _io((ffi) => ffi.serverPin(_s, host)); + + Future unpinServer(String host) => + _io((ffi) => ffi.unpinServer(_s, host)); + + Future> pins() => _io((ffi) => ffi.pins(_s)); + + Future syncOk() => _io((ffi) => ffi.syncOk(_s)); + + Future saveContact(String address, String keyB32, + {required bool verified}) => + _io((ffi) => ffi.saveContact(_s, address, keyB32, verified: verified)); + + // --- account lifecycle ------------------------------------------------------ + + Future register(String address, + {String? invite, String? dial, int timeout = 30}) => + _io((ffi) { + ffi.register(_s, _a, address, + invite: invite, dial: dial, timeout: timeout); + }).then((_) => _rebuildAccount()); + + Future restore(String address, {String? dial, int timeout = 30}) => + _io((ffi) => + ffi.restore(_s, _master!, address, dial: dial, timeout: timeout)) + .then((_) => _rebuildAccount()); + + Future> rotate({String? dial, int timeout = 30}) => + _io((ffi) => ffi.rotate(_s, _a, dial: dial, timeout: timeout)) + .then((out) { + _rebuildAccount(); + return out; + }); + + // --- mail ------------------------------------------------------------------- + + /// Raises the cancellation flag; the running fetch stops between + /// envelopes and returns a partial summary. + void cancelFetch() => SmolFfi.open().setFlag(_flag ?? 0, true); + + Future> fetch( + {bool keep = false, + bool reset = false, + String? dial, + int timeout = 30}) => + _io((ffi) { + ffi.setFlag(_flag!, false); + return ffi.fetch(_s, _a, + keep: keep, reset: reset, dial: dial, timeout: timeout, + cancel: _flag); + }); + + Future> send(String to, String body, + {String? subject, String? replyTo, String? dial, int timeout = 30}) => + _io((ffi) => ffi.send(_s, _a, to, body, + subject: subject, replyTo: replyTo, dial: dial)); + + Future delete(List idsHex, {String? dial, int timeout = 30}) => + _io((ffi) => ffi.delete(_s, _a, idsHex, dial: dial, timeout: timeout)); + + /// The reader's delete: local removal plus seen-marking, no server round + /// trip. Use [delete] for the server-side DELETE. + Future deleteLocal(String folder, List idsHex) => + _io((ffi) => ffi.deleteLocal(_s, folder, idsHex)); + + Future> mail(String folder) => + _io((ffi) => ffi.mail(_s, folder)); + + Future> describe(String idHex) => + _io((ffi) => ffi.describe(_s, _a, idHex)); + + // --- contacts --------------------------------------------------------------- + + Future> contacts() => _io((ffi) => ffi.contacts(_s)); + + Future> contact(String address) => + _io((ffi) => ffi.contact(_s, address)); + + Future> resolve(String address, + {String? dial, int timeout = 30}) => + _io((ffi) => ffi.resolve(_s, address, dial: dial, timeout: timeout)); + + Future importContact(String uri) => + _io((ffi) => ffi.importContact(_s, uri)); + + Future accept(String address, {String? dial, int timeout = 30}) => + _io((ffi) => ffi.accept(_s, _a, address, dial: dial, timeout: timeout)); + + Future block(String address, {String? dial, int timeout = 30}) => + _io((ffi) => ffi.block(_s, _a, address, dial: dial, timeout: timeout)); + + // --- backups ---------------------------------------------------------------- + + Future exportBackup() => _io((ffi) => ffi.exportBackup(_s, _master!)); + + Future importBackup(String text) => + _io((ffi) => ffi.importBackup(_s, _master!, text)); +} diff --git a/lib/native/ffi.dart b/lib/native/ffi.dart new file mode 100644 index 0000000..32a7d69 --- /dev/null +++ b/lib/native/ffi.dart @@ -0,0 +1,518 @@ +// Raw FFI bindings for the C ABI in native/src/ffi.rs: one lookup per entry +// point, no logic. Handles are opaque pointers at this layer; the typed +// methods accept plain addresses (ints) so they cross isolates freely. +// Every string this ABI returns is freed with smolFreeString — Dart's GC +// runs no Rust destructor. + +import "dart:convert"; +import "dart:ffi"; +import "dart:typed_data"; + +import "package:ffi/ffi.dart"; + +import "package:smol_mail/native/library.dart"; + +// Stable error codes, mirrored from native/src/ffi.rs. The space is split +// like the wire's: 0-10 are protocol status codes, verbatim, and local +// failures live from 64 up, so a recovery path can be picked by range — +// RATE_LIMITED (8) retries on the live session it arrived on, while +// HANDSHAKE_REFUSED (71) means there is no session to retry on. +const smolOk = 0; +const smolMalformed = 1; +const smolBadVersion = 2; +const smolUnknownUser = 3; +const smolAuthRequired = 4; +const smolAuthFailed = 5; +const smolQuotaExceeded = 6; +const smolTooLarge = 7; +const smolRateLimited = 8; +const smolNotPermitted = 9; +const smolInternalError = 10; +/// Unassigned status byte; the raw byte is in the payload's "status". +const smolUnknownStatus = 11; + +const smolNotPinned = 64; +const smolPinMismatch = 65; +const smolKeyChanged = 66; +const smolNotRegistered = 67; +const smolChainLimit = 68; +const smolSchemaVersion = 69; +const smolUnreachable = 70; +const smolHandshakeRefused = 71; +const smolStorage = 72; +const smolNoise = 73; +const smolIo = 74; +const smolOther = 75; +const smolPanic = 76; + +/// What a failed native call reports: the decision code plus the structured +/// payload the ABI carries (host, known, offered — keys as base32). +class NativeSmolException implements Exception { + final int code; + final String message; + final Map details; + + const NativeSmolException(this.code, this.message, this.details); + + @override + String toString() => message; +} + +/// The raw ABI. Opened per isolate — handles are plain addresses, so the +/// same store can be called from any isolate, which is what the async +/// wrapper relies on. +class SmolFfi { + SmolFfi._(this._lib); + + final DynamicLibrary _lib; + + static SmolFfi? _cached; + + factory SmolFfi.open() => _cached ??= SmolFfi._(openSmolLibrary()); + + Pointer _h(int address) => Pointer.fromAddress(address); + + late final Pointer Function() _lastError = _lib + .lookupFunction Function(), Pointer Function()>( + "smol_last_error"); + late final void Function(Pointer) _freeString = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_free_string"); + + late final Pointer Function(Pointer) _storeOpen = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_store_open"); + late final Pointer Function() _storeMemory = _lib.lookupFunction< + Pointer Function(), + Pointer Function()>("smol_store_memory"); + late final void Function(Pointer) _storeFree = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_store_free"); + late final Pointer Function(Pointer, int) _accountNew = _lib + .lookupFunction< + Pointer Function(Pointer, Uint32), + Pointer Function( + Pointer, int)>("smol_account_new"); + late final void Function(Pointer) _accountFree = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_account_free"); + late final Pointer Function(Pointer) _accountPk = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_account_pk"); + late final Pointer Function() _flagNew = _lib.lookupFunction< + Pointer Function(), + Pointer Function()>("smol_flag_new"); + late final void Function(Pointer, int) _flagSet = _lib.lookupFunction< + Void Function(Pointer, Int32), + void Function(Pointer, int)>("smol_flag_set"); + late final void Function(Pointer) _flagFree = _lib.lookupFunction< + Void Function(Pointer), + void Function(Pointer)>("smol_flag_free"); + + late final int Function(Pointer, Pointer, Pointer) + _pinServer = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer), + int Function( + Pointer, Pointer, Pointer)>("smol_pin_server"); + late final Pointer Function(Pointer, Pointer) + _serverPin = _lib.lookupFunction< + Pointer Function(Pointer, Pointer), + Pointer Function(Pointer, Pointer)>( + "smol_server_pin"); + late final Pointer Function(Pointer) _storeAccount = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_store_account"); + late final int Function(Pointer) _rotations = _lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>("smol_rotations"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, Pointer, int) _register = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer, Pointer, + Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, Pointer, + Pointer, int) + >("smol_register"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, int) _restore = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer, + Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_restore"); + late final Pointer Function(Pointer, Pointer, + Pointer, int) _rotate = _lib.lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer, Uint64), + Pointer Function(Pointer, Pointer, Pointer, + int)>("smol_rotate"); + late final Pointer Function(Pointer, Pointer, int, int, + Pointer, int, Pointer) _fetch = _lib.lookupFunction< + Pointer Function(Pointer, Pointer, Int32, Int32, + Pointer, Uint64, Pointer), + Pointer Function(Pointer, Pointer, int, int, + Pointer, int, Pointer)>("smol_fetch"); + late final Pointer Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + int, + int, + Pointer, + int) _send = _lib.lookupFunction< + Pointer Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + Int32, + Int32, + Pointer, + Uint64), + Pointer Function(Pointer, Pointer, Pointer, + Pointer, Pointer, Pointer, int, int, + Pointer, int)>("smol_send"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, int) _delete = _lib.lookupFunction< + Int32 Function( + Pointer, Pointer, Pointer, Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_delete"); + late final Pointer Function(Pointer, Pointer) _mail = _lib + .lookupFunction Function(Pointer, Pointer), + Pointer Function( + Pointer, Pointer)>("smol_mail"); + late final Pointer Function(Pointer, Pointer, + Pointer) _describe = _lib.lookupFunction< + Pointer Function(Pointer, Pointer, Pointer), + Pointer Function( + Pointer, Pointer, Pointer)>("smol_describe"); + late final Pointer Function(Pointer) _contacts = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_contacts"); + late final Pointer Function(Pointer, Pointer) _contact = + _lib.lookupFunction< + Pointer Function(Pointer, Pointer), + Pointer Function(Pointer, Pointer)>( + "smol_contact"); + late final Pointer Function(Pointer, Pointer, + Pointer, Pointer, int) _accept = _lib.lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer, Pointer, Uint64), + Pointer Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_accept"); + late final int Function(Pointer, Pointer, Pointer, + Pointer, int) _block = _lib.lookupFunction< + Int32 Function( + Pointer, Pointer, Pointer, Pointer, Uint64), + int Function(Pointer, Pointer, Pointer, + Pointer, int)>("smol_block"); + late final Pointer Function( + Pointer, Pointer, Pointer, int) _resolve = _lib + .lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer, Uint64), + Pointer Function(Pointer, Pointer, + Pointer, int)>("smol_resolve"); + late final int Function(Pointer, Pointer) _accountSet = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_account_set"); + late final int Function(Pointer, Pointer, Pointer, int) + _contactSave = _lib.lookupFunction< + Int32 Function( + Pointer, Pointer, Pointer, Int32), + int Function(Pointer, Pointer, Pointer, + int)>("smol_contact_save"); + late final int Function(Pointer, Pointer) _unpinServer = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_unpin_server"); + late final Pointer Function(Pointer) _pins = _lib.lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer)>("smol_pins"); + late final int Function(Pointer) _syncOk = _lib.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>("smol_sync_ok"); + late final int Function(Pointer, Pointer, Pointer) + _deleteLocal = _lib.lookupFunction< + Int32 Function(Pointer, Pointer, Pointer), + int Function( + Pointer, Pointer, Pointer)>("smol_delete_local"); + late final Pointer Function(Pointer) _parseAddress = _lib + .lookupFunction Function(Pointer), + Pointer Function(Pointer)>("smol_parse_address"); + late final int Function(Pointer, Pointer) _importContact = _lib + .lookupFunction, Pointer), + int Function(Pointer, Pointer)>("smol_import_contact"); + late final Pointer Function(Pointer, Pointer) + _exportBackup = _lib.lookupFunction< + Pointer Function(Pointer, Pointer), + Pointer Function( + Pointer, Pointer)>("smol_export_backup"); + late final Pointer Function(Pointer, Pointer, + Pointer) _importBackup = _lib.lookupFunction< + Pointer Function( + Pointer, Pointer, Pointer), + Pointer Function( + Pointer, Pointer, Pointer)>( + "smol_import_backup"); + + // --- helpers over the raw surface ------------------------------------------ + + /// Throws when a status call returned non-zero, decoding the error slot. + /// The slot is thread-local: this only works because it runs inside the + /// same Isolate.run closure that made the failing call — after the + /// closure returns, the slot on that thread is gone. + Never _fail() { + final slot = _lastError(); + if (slot == nullptr) { + throw const NativeSmolException(smolOther, "the native call failed", {}); + } + final text = take(slot); + final Map details; + try { + details = jsonDecode(text) as Map; + } on FormatException { + throw NativeSmolException(smolOther, text, {}); + } + throw NativeSmolException( + details["code"] as int? ?? smolOther, + details["message"] as String? ?? "the native call failed", + details); + } + + /// Reads one returned buffer into a String and frees the buffer — the + /// ownership rule every caller owes a returned pointer. + String take(Pointer ptr) { + if (ptr == nullptr) _fail(); + final text = ptr.toDartString(); + _freeString(ptr); + return text; + } + + Pointer _text(String text) => text.toNativeUtf8(); + + Pointer _bytes(Uint8List bytes) { + final buf = malloc(32); + buf.asTypedList(32).setAll(0, bytes); + return buf; + } + + // --- typed surface: handles cross as ints ---------------------------------- + + int openStore(String path) { + final ptr = _storeOpen(_text(path)); + if (ptr == nullptr) _fail(); + return ptr.address; + } + + int openMemoryStore() { + final ptr = _storeMemory(); + if (ptr == nullptr) _fail(); + return ptr.address; + } + + void closeStore(int store) => _storeFree(_h(store)); + + int newAccount(Uint8List master, int index) { + final buf = _bytes(master); + final ptr = _accountNew(buf, index); + malloc.free(buf); + if (ptr == nullptr) _fail(); + return ptr.address; + } + + void freeAccount(int account) => _accountFree(_h(account)); + + String accountPk(int account) => take(_accountPk(_h(account))); + + int newFlag() => _flagNew().address; + + void setFlag(int flag, bool value) => _flagSet(_h(flag), value ? 1 : 0); + + void freeFlag(int flag) => _flagFree(_h(flag)); + + void pinServer(int store, String host, String keyB32) { + if (_pinServer(_h(store), _text(host), _text(keyB32)) != smolOk) _fail(); + } + + void unpinServer(int store, String host) { + if (_unpinServer(_h(store), _text(host)) != smolOk) _fail(); + } + + /// Every pin: [{host, key}] with keys as base32. + List pins(int store) => + jsonDecode(take(_pins(_h(store)))) as List; + + /// Whether the local accept-token set may replace the server's (sec 4). + bool syncOk(int store) => _syncOk(_h(store)) == 1; + + void saveContact(int store, String address, String keyB32, + {required bool verified}) => + _contactSave( + _h(store), _text(address), _text(keyB32), verified ? 1 : 0); + + /// Null when nothing is pinned. + String? serverPin(int store, String host) { + final text = take(_serverPin(_h(store), _text(host))); + return text.isEmpty ? null : text; + } + + /// Null when not registered. + String? accountAddress(int store) { + final text = take(_storeAccount(_h(store))); + return text.isEmpty ? null : text; + } + + int rotations(int store) => _rotations(_h(store)); + + /// Binds the account locally, as register and restore do at their end. + void setAccount(int store, String address) { + if (_accountSet(_h(store), _text(address)) != smolOk) _fail(); + } + + void register(int store, int account, String address, + {String? invite, String? dial, int timeout = 30}) { + if (_register( + _h(store), + _h(account), + _text(address), + invite == null ? nullptr : _text(invite), + dial == null ? nullptr : _text(dial), + timeout) != + smolOk) { + _fail(); + } + } + + void restore(int store, Uint8List master, String address, + {String? dial, int timeout = 30}) { + final buf = _bytes(master); + final code = _restore(_h(store), buf, _text(address), + dial == null ? nullptr : _text(dial), timeout); + malloc.free(buf); + if (code != smolOk) _fail(); + } + + Map rotate(int store, int account, + {String? dial, int timeout = 30}) => + jsonDecode(take(_rotate(_h(store), _h(account), + dial == null ? nullptr : _text(dial), timeout))) + as Map; + + Map fetch(int store, int account, + {bool keep = false, + bool reset = false, + String? dial, + int timeout = 30, + int? cancel}) { + final text = take(_fetch(_h(store), _h(account), keep ? 1 : 0, reset ? 1 : 0, + dial == null ? nullptr : _text(dial), timeout, + cancel == null ? nullptr : _h(cancel))); + return jsonDecode(text) as Map; + } + + Map send(int store, int account, String to, String body, + {String? subject, + String? replyTo, + bool anonymous = false, + bool noPad = false, + String? dial, + int timeout = 30}) { + final text = take(_send( + _h(store), + _h(account), + _text(to), + subject == null ? nullptr : _text(subject), + _text(body), + replyTo == null ? nullptr : _text(replyTo), + anonymous ? 1 : 0, + noPad ? 1 : 0, + dial == null ? nullptr : _text(dial), + timeout)); + return jsonDecode(text) as Map; + } + + /// Ids are 64 hex characters everywhere — the same shape `mail` and + /// `describe` return, so no caller hex-decodes solely to delete. + void delete(int store, int account, List ids, + {String? dial, int timeout = 30}) { + if (_delete(_h(store), _h(account), _text(jsonEncode(ids)), + dial == null ? nullptr : _text(dial), timeout) != + smolOk) { + _fail(); + } + } + + List mail(int store, String folder) => + jsonDecode(take(_mail(_h(store), _text(folder)))) as List; + + Map describe(int store, int account, String idHex) => + jsonDecode(take(_describe(_h(store), _h(account), _text(idHex)))) + as Map; + + List contacts(int store) => + jsonDecode(take(_contacts(_h(store)))) as List; + + Map contact(int store, String address) => + jsonDecode(take(_contact(_h(store), _text(address)))) + as Map; + + int accept(int store, int account, String address, + {String? dial, int timeout = 30}) => + (jsonDecode(take(_accept(_h(store), _h(account), _text(address), + dial == null ? nullptr : _text(dial), timeout))) + as Map)["held"] as int; + + void block(int store, int account, String address, + {String? dial, int timeout = 30}) { + if (_block(_h(store), _h(account), _text(address), + dial == null ? nullptr : _text(dial), timeout) != + smolOk) { + _fail(); + } + } + + Map resolve(int store, String address, + {String? dial, int timeout = 30}) { + final text = take(_resolve(_h(store), _text(address), + dial == null ? nullptr : _text(dial), timeout)); + return jsonDecode(text) as Map; + } + + /// The reader's delete: removes locally and marks seen, so a kept + /// server copy is not re-stored by the next fetch. + void deleteLocal(int store, String folder, List ids) { + if (_deleteLocal(_h(store), _text(folder), _text(jsonEncode(ids))) != + smolOk) { + _fail(); + } + } + + /// One address for the UI: {user, host, port, short, scheme}, or a + /// NativeSmolException when it does not parse. + Map parseAddress(String text) { + return jsonDecode(take(_parseAddress(_text(text)))) + as Map; + } + + void importContact(int store, String uri) { + if (_importContact(_h(store), _text(uri)) != smolOk) _fail(); + } + + String exportBackup(int store, Uint8List master) { + final buf = _bytes(master); + final text = take(_exportBackup(_h(store), buf)); + malloc.free(buf); + return text; + } + + String importBackup(int store, Uint8List master, String text) { + final buf = _bytes(master); + final summary = take(_importBackup(_h(store), buf, _text(text))); + malloc.free(buf); + return summary; + } +} diff --git a/lib/native/library.dart b/lib/native/library.dart new file mode 100644 index 0000000..30d26e3 --- /dev/null +++ b/lib/native/library.dart @@ -0,0 +1,13 @@ +// Opens the native library (lib/smol -> fumi-core) the app links against. +// Android bundles the cdylib under its plain soname; Linux loads the built +// artifact from the checkout in dev/test and the bundle dir in release. + +import "dart:ffi"; +import "dart:io"; + +DynamicLibrary openSmolLibrary() { + if (Platform.isAndroid) { + return DynamicLibrary.open("libsmol_mail_native.so"); + } + return DynamicLibrary.open("native/target/release/libsmol_mail_native.so"); +} diff --git a/lib/presentation/screens/contact_detail_screen.dart b/lib/presentation/screens/contact_detail_screen.dart index 02b961d..27dc40e 100644 --- a/lib/presentation/screens/contact_detail_screen.dart +++ b/lib/presentation/screens/contact_detail_screen.dart @@ -5,8 +5,9 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/shared/utils/format.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; /// The full key, the address to hand out, and any key this one replaced — /// the row can only afford a fingerprint, and §8 turns on the user being able @@ -125,7 +126,7 @@ class _ContactDetailScreenState extends ConsumerState { const Divider(height: 1), const SizedBox(height: 20), _field(context, "fingerprint", fingerprint(contact.key)), - _field(context, "public key", b32encode(contact.key)), + _field(context, "public key", contact.key), Row( children: [ Expanded( @@ -162,7 +163,7 @@ class _ContactDetailScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(fingerprint(entry.key)), - Text(b32encode(entry.key), + Text(entry.key, style: Theme.of(context).textTheme.labelSmall), Text( "replaced ${formatTime(entry.until ~/ 1000)}", diff --git a/lib/presentation/screens/contacts_screen.dart b/lib/presentation/screens/contacts_screen.dart index 46c189f..9a04b98 100644 --- a/lib/presentation/screens/contacts_screen.dart +++ b/lib/presentation/screens/contacts_screen.dart @@ -7,7 +7,7 @@ import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; /// Contacts: the addresses bound to keys, with the trust badge and the number /// of keys each has replaced — the §8 surface for noticing rotations. diff --git a/lib/presentation/screens/message_detail_screen.dart b/lib/presentation/screens/message_detail_screen.dart index 4467084..edc4dba 100644 --- a/lib/presentation/screens/message_detail_screen.dart +++ b/lib/presentation/screens/message_detail_screen.dart @@ -1,4 +1,3 @@ -import "dart:typed_data"; import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; @@ -40,7 +39,7 @@ class MessageDetailScreen extends ConsumerWidget { } Future _nameSender(BuildContext context, WidgetRef ref, - Uint8List senderKey) async { + String senderKey) async { final client = ref.read(clientProvider); final controller = TextEditingController(); final address = await showDialog( diff --git a/lib/presentation/screens/onboarding_screen.dart b/lib/presentation/screens/onboarding_screen.dart index 1c26d9b..75e62dc 100644 --- a/lib/presentation/screens/onboarding_screen.dart +++ b/lib/presentation/screens/onboarding_screen.dart @@ -8,9 +8,9 @@ import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/widgets/primary_button.dart"; import "package:smol_mail/presentation/widgets/secondary_button.dart"; import "package:smol_mail/presentation/widgets/small_loading_spinner.dart"; -import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; @@ -49,6 +49,10 @@ class _OnboardingScreenState extends ConsumerState { @override void dispose() { + // The backup step's copy of the master is overwritten, not left to the + // garbage collector — the honest zeroization Dart allows. + createdMaster?.fillRange(0, createdMaster!.length, 0); + createdMaster = null; seedController.dispose(); restoreAddressController.dispose(); addressController.dispose(); @@ -81,11 +85,12 @@ class _OnboardingScreenState extends ConsumerState { } } - void _createIdentity() { + Future _createIdentity() async { _clearAbandonedIdentity(); final client = ref.read(clientProvider); try { - final master = client.createIdentity(); + final master = await client.createIdentity(); + if (!mounted) return; setState(() { createdMaster = master; step = _Step.backup; diff --git a/lib/presentation/screens/settings_screen.dart b/lib/presentation/screens/settings_screen.dart index d9562d0..a9b5746 100644 --- a/lib/presentation/screens/settings_screen.dart +++ b/lib/presentation/screens/settings_screen.dart @@ -12,9 +12,8 @@ import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; /// Identity card, server pins, rotation and the wipe. The seed is the only /// secret; revealing it is always an explicit action. @@ -69,10 +68,11 @@ class _SettingsScreenState extends ConsumerState { // pinned servers into one shareable file; never the seed. Future _export() async { final store = ref.read(storeProvider); - final data = store.exportData(); + // exportData is the sealed gsmol container itself; the file body is its + // JSON, already sealed to the master-derived key. + final bytes = Uint8List.fromList(utf8.encode(await store.exportData())); final stamp = DateTime.now().toIso8601String().substring(0, 10); final fileName = "kirakira-backup-$stamp.json"; - final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data))); try { // share_plus has no file-sharing implementation on desktop platforms — // it throws UnimplementedError for Linux/Windows/macOS — so those save @@ -108,8 +108,8 @@ class _SettingsScreenState extends ConsumerState { final path = files.isEmpty ? null : files.single.path; if (path == null) return; try { - final data = jsonDecode(File(path).readAsStringSync()) as Map; - final summary = await ref.read(storeProvider).importData(data); + final summary = + await ref.read(storeProvider).importData(File(path).readAsStringSync()); ref.read(revisionProvider.notifier).bump(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -165,7 +165,7 @@ class _SettingsScreenState extends ConsumerState { ref.read(revisionProvider.notifier).bump(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")), + SnackBar(content: Text("rotated; new key ${fresh.publicKey}")), ); } } catch (err) { @@ -313,7 +313,7 @@ class _SettingsScreenState extends ConsumerState { contentPadding: EdgeInsets.zero, title: Text(host), subtitle: Text( - b32encode(key), + key, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall, ), diff --git a/lib/presentation/widgets/message/message_tile.dart b/lib/presentation/widgets/message/message_tile.dart index f2fb1a5..de73b4a 100644 --- a/lib/presentation/widgets/message/message_tile.dart +++ b/lib/presentation/widgets/message/message_tile.dart @@ -7,7 +7,6 @@ import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/presentation/theme/app_colors.dart"; import "package:smol_mail/shared/utils/avatar_color.dart"; import "package:smol_mail/shared/utils/format.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; class MessageTile extends ConsumerWidget { @@ -27,7 +26,7 @@ class MessageTile extends ConsumerWidget { who = record.recipient ?? ""; } else if (opened.sender != null) { final known = store.addressForKey(opened.sender!); - who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}"; + who = known ?? "unknown · ${opened.sender!.substring(0, 4)}"; } else { who = "?"; } diff --git a/lib/presentation/widgets/message/message_view.dart b/lib/presentation/widgets/message/message_view.dart index e52c2e9..4c1566b 100644 --- a/lib/presentation/widgets/message/message_view.dart +++ b/lib/presentation/widgets/message/message_view.dart @@ -1,4 +1,3 @@ -import "dart:typed_data"; import "package:auto_route/auto_route.dart"; import "package:flutter/material.dart"; @@ -9,8 +8,8 @@ import "package:smol_mail/presentation/routes/app_router.gr.dart"; import "package:smol_mail/shared/utils/format.dart"; import "package:smol_mail/shared/utils/snackbar.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/ui.dart"; /// The opened message: trust row (fingerprint and how the sender's key is /// known), frontmatter fields, and the plain body — smol mail has no HTML. @@ -18,7 +17,7 @@ class MessageView extends ConsumerWidget { final String folder; final MailRecord record; final OpenedRecord opened; - final void Function(Uint8List senderKey)? onNameSender; + final void Function(String senderKey)? onNameSender; const MessageView({ super.key, @@ -78,7 +77,7 @@ class MessageView extends ConsumerWidget { ], if (!isSent && senderKey != null) ...[ const SizedBox(height: 10), - _keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"), + _keyBlock(context, "${fingerprint(senderKey)}\n$senderKey"), ], const SizedBox(height: 18), _field(context, isSent ? "To" : "From", diff --git a/lib/smol/address.dart b/lib/smol/address.dart new file mode 100644 index 0000000..44be381 --- /dev/null +++ b/lib/smol/address.dart @@ -0,0 +1,48 @@ +// Address forms (SPEC.md §3): parsing goes through the native library, so +// the username rules are fumi's, not a reimplementation that can drift. + +import "package:smol_mail/native/ffi.dart"; +import "package:smol_mail/smol/errors.dart"; + +/// One parsed address. [identity] carries the self-certifying key a +/// smol:// URI binds; a short form has none until it is resolved. +class SmolAddress { + final String user; + final String host; + final int port; + final String? identity; + final bool rns; + + const SmolAddress(this.user, this.host, this.port, + {this.identity, this.rns = false}); + + /// The short form a contact list shows; the default port is elided. + String get short => rns + ? "smol+rns://$user@$host" + : "$user@$host${port == defaultPort ? "" : ":$port"}"; + + /// The self-certifying form: the key inside makes the address verifiable. + String uri(String publicKey) => rns + ? "smol+rns://$user@$host/$publicKey" + : "smol://$user@$host${port == defaultPort ? "" : ":$port"}/$publicKey"; +} + +const defaultPort = 1961; + +/// Parses any of the four address forms. Throws [SmolError] on anything +/// else — the onboarding and compose fields validate through this. +SmolAddress parseAddress(String text) { + final Map parsed; + try { + parsed = SmolFfi.open().parseAddress(text.trim()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); + } + return SmolAddress( + parsed["user"] as String, + parsed["host"] as String, + parsed["port"] as int, + identity: parsed["identity"] as String?, + rns: parsed["scheme"] == "rns", + ); +} diff --git a/lib/smol/client.dart b/lib/smol/client.dart index 9390108..1ea6a7a 100644 --- a/lib/smol/client.dart +++ b/lib/smol/client.dart @@ -1,15 +1,19 @@ -// App-level client: the flows of gsmol's app.js — connect with pinning, fetch -// with verification and acknowledgment, send with sent copies, contacts and -// rotation — on top of the pure protocol modules. +// The app-level client: the same flows the screens have always called — +// connect with pinning, fetch with verification and acknowledgment, send +// with sent copies, contacts and rotation — now as one thin layer over +// fumi-core through the native binding. Every network operation runs on an +// isolate; the reads the UI makes per frame stay synchronous. -import "dart:convert"; +import "dart:io"; +import "dart:math"; import "dart:typed_data"; -import "package:smol_mail/smol/crypto.dart"; +import "package:smol_mail/native/client.dart"; +import "package:smol_mail/native/ffi.dart"; + +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; -import "package:smol_mail/smol/transport.dart"; class RefreshOutcome { final String message; @@ -27,7 +31,7 @@ class FetchSummary { class OpenedRecord { final String id; - final Uint8List? sender; + final String? sender; // base32 final int? time; final Map fields; final String body; @@ -50,10 +54,41 @@ class SmolClient { void _warn(String message) => onWarning?.call(message); - // Opening an envelope costs an X25519 agreement and an Ed25519 - // verification, and an envelope's plaintext never changes — so the result - // is kept. Failures are cached too, so one bad message is not retried on - // every render. Rotation clears it, since the key set grew. + FumiNative get _native => store.native; + + SmolFfi get _ffi => SmolFfi.open(); + + /// Resolves the host the way that works everywhere this app runs: the + /// platform resolver. On Android the native getaddrinfo that fumi-core's + /// connect would use can be dead for app processes while this path works, + /// so the facade resolves here and hands the IP across as a dial hint — + /// the hostname keeps every identity role. IP literals dial themselves. + Future _dial(String host) async { + final parsed = InternetAddress.tryParse(host); + if (parsed != null) return parsed.address; + try { + final addresses = await InternetAddress.lookup(host); + if (addresses.isEmpty) { + throw SmolError("could not resolve $host"); + } + return addresses.first.address; + } on SocketException catch (err) { + throw SmolError("could not resolve $host: ${err.message}"); + } catch (err) { + if (err is SmolError) rethrow; + throw SmolError("could not resolve $host"); + } + } + + /// The account host's dial hint, for the ops that connect home. + Future _accountDial() async { + final addr = accountAddress(); + if (addr == null) return ""; + return _dial(addr.host); + } + + // Opening an envelope is microseconds native; the results never change, so + // they are kept like the Dart core kept them. Failures cache too. final _openedCache = {}; SmolIdentity? get identity => store.identity(); @@ -61,397 +96,211 @@ class SmolClient { Uint8List? get master => store.master(); SmolAddress? accountAddress() { - final account = store.account(); - if (account == null) return null; - final suffix = account.port == defaultPort ? "" : ":${account.port}"; - return parseAddress("${account.user}@${account.host}$suffix"); - } - - // §4: registration and fetching demand a pinned key; sending to a recipient - // whose key we already hold tolerates an unpinned server. - Future connect(SmolAddress addr, - {required bool requirePin}) async { - final pinned = store.serverPin(addr.host); - if (requirePin && pinned == null) { - throw SmolError("no pinned key for ${addr.host}. Obtain it from the " - "operator through a trusted channel, then pin it in settings."); - } - final wire = await TcpWire.connect(addr.host, addr.port); - try { - final opened = await openSession(wire, addr.host, pinned: pinned); - if (pinned == null) { - _warn("${addr.host} is not pinned; its key is " - "${b32encode(opened.serverStatic)}.\n" - "RESOLVE results from this session are UNVERIFIED (SPEC.md §8)."); - } - return opened; - } catch (_) { - wire.close(); - rethrow; - } + final text = _ffi.accountAddress(_native.store!); + if (text == null) return null; + return parseAddress(text); } // --- identity setup ------------------------------------------------------------ - /// A fresh master secret at rotation index 0. Only this local step; nothing - /// is sent until [registerAccount]. - Uint8List createIdentity() { - final fresh = randomBytes(keyLen); + /// A fresh master secret at rotation index 0. Only this local step; + /// nothing is sent until [registerAccount]. + Future createIdentity() async { + final fresh = _randomMaster(); store.setMaster(fresh); return fresh; } + Uint8List _randomMaster() { + // The platform CSPRNG: 32 random bytes are the whole identity. + final rng = Random.secure(); + return Uint8List.fromList( + List.generate(32, (_) => rng.nextInt(256))); + } + /// §2: a master alone does not say which rotation index a server has bound, - /// so restoring resolves the address and walks indices 0..[maxChain] until - /// one derives the key RESOLVE returned. Also binds "account" locally, like - /// [recallAccount] — restoring on a new device knows the identity but not - /// the address it was registered under. + /// so restoring resolves the address and walks indices until one derives + /// the key RESOLVE returned, then binds the account locally. Future restoreAndRecall(String masterHex, String addressText) async { Uint8List master; try { - master = unhex(masterHex.trim()); + master = _unhex(masterHex.trim()); } on Exception { throw const SmolError("master must be 64 hex characters"); } - if (master.length != keyLen) { - throw SmolError("master is ${master.length} bytes, expected $keyLen"); + if (master.length != 32) { + throw SmolError("master is ${master.length} bytes, expected 32"); } final addr = parseAddress(addressText); - final opened = await connect(addr, requirePin: true); - Uint8List current; - try { - current = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); - } - int? found; - for (var n = 0; n <= maxChain; n++) { - if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) { - found = n; - break; - } - } - if (found == null) { - throw SmolError("the key bound to ${addr.short} is not derived from " - "this master within $maxChain rotations"); - } - store.restoreMaster(master, found); - store.setAccount(addr); + store.restoreMaster(master, 0); + await _restore(addr); + master.fillRange(0, master.length, 0); return addr; } - void pinServer(String host, String keyB32) { - final key = b32decode(keyB32); - if (key.length != keyLen) { - throw SmolError("server key is ${key.length} bytes, expected $keyLen"); + Future _restore(SmolAddress addr) async { + // fumi's restore would tolerate an unpinned server (sec 8 trust on + // first use), but this app's onboarding teaches the pin up front: + // registration and fetching demand it anyway (sec 4), so restoring is + // stopped at the pin step rather than letting the account bind to a + // server whose key nobody verified. + if (store.serverPin(addr.host) == null) { + throw SmolError("no pinned key for ${addr.host}. Obtain it from the " + "operator through a trusted channel, then pin it in settings."); + } + try { + // restore resolves, walks the rotation indices and writes the account + // state; the account handle is rebuilt inside the binding. + await _native.restore(addr.short, dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - store.pinServer(host.trim().toLowerCase(), key); } + void pinServer(String host, String keyB32) => store.pinServer(host, keyB32); + Future registerAccount(String addressText, {String token = ""}) async { - final me = identity; - if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(addressText); - final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, opened.serverStatic, addr.user, me, - RegisterOptions(token: token)); - } finally { - opened.session.wire.close(); + await _native.register(addr.short, + invite: token.isEmpty ? null : token, dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - store.setAccount(addr); } - /// Restoring a master brings back the identity, not the memory of what - /// address a *different device* registered it under — "account" is - /// local-only state, never asked of the server. This binds it without - /// REGISTER: RESOLVE the address and require it name this exact key, so a - /// typo or someone else's address cannot misfile fetch and Reply-To. + /// Recall binds the identity to its registered address without + /// re-REGISTER — the same resolve-and-walk a restore does (§2). Future recallAccount(String addressText) async { - final me = identity; - if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(addressText); - final opened = await connect(addr, requirePin: true); - Uint8List current; - try { - current = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); - } - if (!timingSafeEqual(current, me.publicKey)) { - throw SmolError( - "${addr.short} resolves to a different key — not this identity"); - } - store.setAccount(addr); + await _restore(addr); return addr; } - // --- fetch ---------------------------------------------------------------------- + // --- fetch ----------------------------------------------------------------- - Future fetch() async { - final me = identity; - final master = this.master; - final addr = accountAddress(); - if (me == null || master == null) throw const SmolError("no identity yet"); - if (addr == null) { + Future fetch({bool reset = false}) async { + if (master == null) throw const SmolError("no identity yet"); + if (accountAddress() == null) { throw const SmolError("not registered; register an address first"); } - var stored = 0; - final rejected = []; - // §10: acknowledging (deleting) is the default; "leave mail on server" - // pages forward by cursor instead, so already-fetched mail is never - // re-downloaded even though it isn't deleted (store.storeIfNew also - // dedupes, as a second line of defense). - final leaveOnServer = store.leaveOnServer(); - var (afterTime, afterId) = store.cursor(); - final opened = await connect(addr, requirePin: true); + final Map summary; try { - final (sync, tokens) = store.tokenSet(master); - await authenticate(opened.session, opened.handshakeHash, addr.user, me, - sync: sync, tokens: tokens); - while (true) { - final records = await fetchOp(opened.session, afterTime, afterId); - if (records.isEmpty) break; - final acked = []; - for (final record in records) { - afterTime = record.receivedAt; - afterId = record.id; - OpenedMessage msg; - try { - if (!timingSafeEqual(messageId(record.envelope), record.id)) { - throw const SmolError("id does not match the envelope"); - } - msg = unseal(store.identities(), record.envelope); - } on SmolError catch (err) { - // Left on the server rather than destroyed, so a client-side bug - // cannot lose mail. - rejected.add("${hex(record.id)}: ${err.message}"); - continue; - } - final fresh = await store.storeIfNew( - "inbox", - MailRecord(hex(record.id), record.envelope, - receivedAt: record.receivedAt, - tier: record.isRequest ? tierRequests : tierMain, - keptOnServer: leaveOnServer)); - if (fresh != null) { - stored++; - _learnToken(msg); - } - acked.add(record.id); - } - if (leaveOnServer) { - // Persisted per batch, so an interrupted fetch resumes here rather - // than re-paging from the start next time. - store.setCursor(afterTime, afterId); - } else if (acked.isNotEmpty) { - await deleteOp(opened.session, acked); - } - } - } finally { - opened.session.wire.close(); + summary = await _native.fetch( + keep: store.leaveOnServer(), reset: reset, dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - if (!leaveOnServer) { - // Everything acknowledged is deleted, so the next fetch starts fresh; a - // record left on the server (rejected above) simply resurfaces then. - store.setCursor(0, Uint8List(idLen)); + _openedCache.clear(); + if (summary["cancelled"] == true) { + _warn("fetch cancelled; partial results kept"); } - return FetchSummary(stored, rejected); + return FetchSummary( + summary["stored"] as int, + [ + for (final r in (summary["rejected"] as List).cast>()) + "${r[0]}: ${r[1]}", + ], + ); } + /// Raises the cancellation flag; a running fetch stops between envelopes + /// and returns a partial summary. + void cancelFetch() => _native.cancelFetch(); + // --- delete ------------------------------------------------------------------ /// Deletes a message locally, and from the server too if it might still be /// sitting there (only possible when "leave mail on server" was on when it - /// was fetched — §10). Sent copies are local-only; there is nothing - /// server-side to remove for them (§5.6). Throws, leaving the local copy in - /// place, if a needed server-side delete fails — otherwise a message could - /// look gone locally while silently persisting on the server. + /// was fetched — §10). Sent copies are local-only (§5.6). Future deleteMessage(String folder, MailRecord record) async { if (folder != "sent" && record.keptOnServer) { - final me = identity; - final addr = accountAddress(); - if (me == null || addr == null) { + if (accountAddress() == null) { throw const SmolError( "not registered; cannot reach the server to delete this message"); } - final opened = await connect(addr, requirePin: true); try { - await authenticate(opened.session, opened.handshakeHash, addr.user, me, - sync: 0, tokens: const []); - await deleteOp(opened.session, [unhex(record.id)]); - } finally { - opened.session.wire.close(); + await _native.delete([record.id], dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } } await store.deleteMessage(folder, record.id); - } - - // §5.8: an Accept field is bound to the signer of the message that carried - // it, which unseal() has already verified. - void _learnToken(OpenedMessage msg) { - final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true)); - final raw = parsed.fields["accept"]; - if (raw == null) return; - Uint8List token; - try { - token = b32decode(raw); - } on SmolError { - return; - } - if (token.length != tokenLen) return; - final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]); - if (address == null) return; // no address to send to, so no use for a token - store.learnToken(address, token); - } - - /// The address we know a signer by: a contact, or the Reply-To it signed - /// for itself. Naming a mailbox is not trusting a key, so nothing is - /// pinned here (§5.7, §8). - String? _addressOfSigner(Uint8List sender, String? replyTo) { - final known = store.addressForKey(sender); - if (known != null) return known; - if (replyTo == null) return null; - try { - final parsed = parseAddress(replyTo); - if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) { - return parsed.short; - } - } on SmolError { - // malformed claim: no address to learn a token under - } - return null; + _openedCache.remove(record.id); } // --- accept tokens (§5.8) -------------------------------------------------------- /// Admit a contact to the main tier; their token travels in our next - /// message to them. Pushes the change to the server right away, since an - /// accept or a block only takes effect once it holds the changed set. + /// message to them. Pushes the changed set to the server right away. Future acceptContact(String address) async { - final key = store.contact(address)?.key; - if (key == null) throw SmolError("no key for $address yet"); - store.accept(address, key); - store.setSyncOk(true); - return _pushTokens(); + try { + return await _native.accept(address, dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); + } } /// Withdraw a contact's accept token; their mail lands in requests from /// their next message on. Future blockContact(String address) async { - store.block(address); - return _pushTokens(); - } - - Future _pushTokens() async { - final me = identity; - final master = this.master; - final addr = accountAddress(); - if (me == null || master == null) throw const SmolError("no identity yet"); - if (addr == null) { - _warn("not registered; the set will be pushed with your first fetch"); - return 0; - } - final opened = await connect(addr, requirePin: true); try { - final (sync, tokens) = store.tokenSet(master); - return await authenticate(opened.session, opened.handshakeHash, addr.user, me, - sync: sync, tokens: tokens); - } finally { - opened.session.wire.close(); + await _native.block(address, dial: await _accountDial()); + return 0; + } on NativeSmolException catch (err) { + throw SmolError(err.message); } } // --- compose and send ----------------------------------------------------------- - // Prefer a key we already trust; fall back to RESOLVE with trust on first - // use. - Future resolveRecipient(SmolAddress addr) async { - if (addr.identity != null) { - store.saveContact(addr.short, addr.identity!, true); - return addr.identity!; - } - final known = store.contact(addr.short); - if (known != null) return known.key; - final opened = await connect(addr, requirePin: false); - Uint8List current; - try { - current = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); - } - store.saveContact(addr.short, current, false); - return current; - } - + /// Sends one message (§5, §6.1): recipient selection prefers a key we + /// already trust, then RESOLVE with trust on first use; an accepted + /// correspondent gets our token and a §5.6 sent copy is kept. Future send(String toText, String subject, String body, {String? replyTo, bool anonymous = false}) async { - final me = identity; - final master = this.master; - if (me == null || master == null) throw const SmolError("no identity yet"); + if (master == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); - final recipient = await resolveRecipient(addr); - final account = accountAddress(); - final fields = {"Subject": subject, "In-Reply-To": replyTo ?? ""}; - // A signed Reply-To lets a first-time recipient name and answer us - // (§5.5 allows unknown keys); "anonymous" omits it. - if (account != null && !anonymous) { - fields["Reply-To"] = account.uri(me.publicKey); - } - // §5.8: hand an accepted correspondent the token for our own mailbox, so - // a first reply from them reaches our main tier. - final accepted = store.accepted(addr.short); - if (accepted != null && accepted.active) { - fields["Accept"] = b32encode(tokenFor(master, accepted.identity)); - } - final bodyBytes = utf8Bytes(buildFrontmatter( - fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); - final envelope = seal(me, recipient, bodyBytes); - // §5.8: our token for their mailbox, if they have given us one. - final held = store.tokenFrom(addr.short); - final mac = held == null ? null : acceptMac(held, messageId(envelope)); - final opened = await connect(addr, requirePin: false); + final Map sent; try { - await sendOp(opened.session, envelope, mac: mac); - } finally { - opened.session.wire.close(); + sent = await _native.send(addr.short, body, + subject: subject.isEmpty ? null : subject, + replyTo: replyTo, + dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); + } + if (sent["warning"] != null) { + _warn(sent["warning"] as String); } - // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. - await store.storeMessage("sent", MailRecord(hex(messageId(envelope)), - seal(me, me.publicKey, bodyBytes), - recipient: addr.short, sentAt: nowSeconds())); return addr.short; } // --- reading ----------------------------------------------------------------- - // What is known about a sender changes as the user binds addresses to keys, - // so this layer sits over the cached envelope and is recomputed per call — - // it is a map lookup, not crypto. + /// Opens one sealed message: the described view the reader shows. Sync — + /// one unseal is microseconds native, and the old Dart core did the same + /// work at fifty times the cost. OpenedRecord describe(MailRecord row) { - final opened = _openEnvelope(row); - if (opened.error != null) return opened; - return OpenedRecord( - row.id, - sender: opened.sender, - time: opened.time, - fields: opened.fields, - body: opened.body, - ); - } - - OpenedRecord _openEnvelope(MailRecord row) { var entry = _openedCache[row.id]; if (entry == null) { + if (_native.account == null) { + // No account handle: no identity to unseal with. + entry = OpenedRecord(row.id, error: "no identity yet"); + _openedCache[row.id] = entry; + return entry; + } try { - final opened = unseal(store.identities(), row.envelope); - final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true)); - entry = OpenedRecord(row.id, - sender: opened.sender, - time: opened.time, - fields: parsed.fields, - body: parsed.body); - } on SmolError catch (err) { + final described = + _ffi.describe(_native.store!, _native.account!, row.id); + entry = OpenedRecord( + row.id, + sender: described["sender"] as String, + time: described["time"] as int, + fields: (described["fields"] as Map).cast(), + body: described["text"] as String, + ); + } on NativeSmolException catch (err) { entry = OpenedRecord(row.id, error: err.message); } _openedCache[row.id] = entry; @@ -467,8 +316,7 @@ class SmolClient { if (claim == null || opened.sender == null) return null; try { final parsed = parseAddress(claim); - if (parsed.identity != null && - timingSafeEqual(parsed.identity!, opened.sender!)) { + if (parsed.identity != null && parsed.identity == opened.sender) { return parsed; } } on SmolError { @@ -481,38 +329,36 @@ class SmolClient { /// address carries its own key (verified); a short address is resolved and /// the result kept on first use. Anything that binds a different key is /// refused. - Future nameSender(String text, Uint8List senderKey) async { + Future nameSender(String text, String senderKey) async { final addr = parseAddress(text.trim()); - Uint8List key; - var verified = true; if (addr.identity == null) { - final opened = await connect(addr, requirePin: false); - try { - key = (await resolveOp(opened.session, addr.user)).identity; - } finally { - opened.session.wire.close(); + await refreshContact(addr.short); + final known = store.contact(addr.short); + if (known == null) { + throw const SmolError("could not resolve that address"); } - verified = false; // trust on first use, as with any RESOLVE - } else { - key = addr.identity!; + if (known.key != senderKey) { + throw const SmolError( + "that address carries a different key than this message's sender"); + } + return; } - if (!timingSafeEqual(key, senderKey)) { + if (addr.identity != senderKey) { throw const SmolError( "that address carries a different key than this message's sender"); } - store.saveContact(addr.short, senderKey, verified); + await store.saveContact(addr.short, senderKey, verified: true); } /// A signed Reply-To is the sender's own claim, so it saves as verified — - /// but never over an address already pinned to a different key (§8: a key - /// change without a rotation chain needs out-of-band confirmation). - Future saveReplyAddress(SmolAddress addr, Uint8List senderKey) async { + /// but never over an address already pinned to a different key (§8). + Future saveReplyAddress(SmolAddress addr, String senderKey) async { final existing = store.contact(addr.short); - if (existing != null && !timingSafeEqual(existing.key, senderKey)) { + if (existing != null && existing.key != senderKey) { throw SmolError("${addr.short} is already known with a different key — " "verify out of band before replying"); } - store.saveContact(addr.short, senderKey, true); + await store.saveContact(addr.short, senderKey, verified: true); } // Re-resolve a contact and apply §8: a valid rotation chain is accepted and @@ -520,74 +366,64 @@ class SmolClient { Future refreshContact(String address) async { final addr = parseAddress(address); if (addr.identity != null) { - throw const SmolError("that address already carries a key; use import instead"); + throw const SmolError( + "that address already carries a key; use import instead"); } final known = store.contact(addr.short); - final opened = await connect(addr, requirePin: false); - Resolved resolved; + final Map resolved; try { - resolved = await resolveOp(opened.session, addr.user); - } finally { - opened.session.wire.close(); + resolved = await _native.resolve(addr.short, dial: await _dial(addr.host)); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } + final change = resolved["change"] as String; if (known == null) { - store.saveContact(addr.short, resolved.identity, false); return RefreshOutcome( - "${addr.short} pinned (trust on first use" - "${opened.pinned ? "" : ", UNVERIFIED server"})", - !opened.pinned); + "${addr.short} pinned (trust on first use)", change == "newUnverified"); } - if (timingSafeEqual(known.key, resolved.identity)) { - return RefreshOutcome("${addr.short}: key unchanged", false); + switch (change) { + case "none": + return RefreshOutcome("${addr.short}: key unchanged", false); + case "rotated": + _warn("${addr.short} rotated its key; a signed chain confirms it.\n" + "now ${resolved["key"]}"); + return RefreshOutcome( + "${addr.short} rotated its key; a signed chain confirms it.\n" + "now ${resolved["key"]}", + true); + default: + return RefreshOutcome( + "${addr.short} presents a different key with no valid rotation chain.\n" + "Verify out of band, then import the new smol:// address.", + true); } - if (walkChain(addr.user, known.key, resolved.identity, resolved.chain)) { - store.saveContact(addr.short, resolved.identity, known.verified); - return RefreshOutcome( - "${addr.short} rotated its key; a signed chain confirms it.\n" - "now ${b32encode(resolved.identity)}", - true); - } - return RefreshOutcome( - "${addr.short} presents a different key with no valid rotation chain.\n" - "Verify out of band, then import the new smol:// address.", - true); } - // Bind a smol:// address to the key it carries (§8's strong path); the - // displaced key, if any, lands in the contact's history. - void importContact(String text) { + // Bind a smol:// address to the key it carries (§8's strong path). + Future importContact(String text) async { final addr = parseAddress(text.trim()); if (addr.identity == null) { throw const SmolError("import needs a smol:// address carrying a key"); } - store.saveContact(addr.short, addr.identity!, true); + await store.saveContact(addr.short, addr.identity!, verified: true); } // --- rotation ----------------------------------------------------------------- - // §7: rotate to the next index's derived key and rebind the account with a - // signed certificate. The superseded key stays derivable from the master, - // since mail sealed to it stays readable with nothing else. + /// §7: rotate to the next index's derived key and rebind the account with + /// a signed certificate. The superseded key stays derivable from the + /// master, since mail sealed to it stays readable with nothing else. Future rotateIdentity() async { - final me = identity; - final master = this.master; - final addr = accountAddress(); - if (me == null || master == null || addr == null) { + if (identity == null || master == null || accountAddress() == null) { throw const SmolError("rotate needs a registered account"); } - final freshSeed = identitySeed(master, store.rotations() + 1); - final fresh = identityFromSeed(freshSeed); - final cert = makeCert(addr.user, me, freshSeed); - final opened = await connect(addr, requirePin: true); try { - await registerOp(opened.session, opened.serverStatic, addr.user, fresh, - RegisterOptions(cert: cert)); - } finally { - opened.session.wire.close(); + await _native.rotate(dial: await _accountDial()); + } on NativeSmolException catch (err) { + throw SmolError(err.message); } - store.advanceRotation(); _openedCache.clear(); - return fresh; + return identity!; } // A full wipe: every secret and every stored envelope. The UI must confirm. @@ -595,4 +431,14 @@ class SmolClient { await store.wipe(); _openedCache.clear(); } + + Uint8List _unhex(String text) { + if (text.length % 2 != 0) { + throw const SmolError("odd-length hex string"); + } + return Uint8List.fromList([ + for (var i = 0; i < text.length; i += 2) + int.parse(text.substring(i, i + 2), radix: 16), + ]); + } } diff --git a/lib/smol/config.dart b/lib/smol/config.dart index 102c73d..aae3fee 100644 --- a/lib/smol/config.dart +++ b/lib/smol/config.dart @@ -1,7 +1,3 @@ -import "package:smol_mail/smol/proto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/store.dart"; - // Deploy-time configuration, empty by default (mirrors gsmol's config.js). // A release may bake in a server key with: // flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \ @@ -12,15 +8,15 @@ import "package:smol_mail/smol/store.dart"; // This only seeds the first run — once written it is an ordinary pin, // removable in settings like any other, and never overwrites a host the user // (or a previous install) already pinned. + +import "package:smol_mail/smol/store.dart"; + const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER"); const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY"); void applyPresetServer(SmolStore store) { if (_presetHost.isEmpty || _presetKey.isEmpty) return; if (store.serverPin(_presetHost) != null) return; - try { - store.pinServer(_presetHost, b32decode(_presetKey)); - } on SmolError { - // malformed preset: leave unpinned rather than block boot - } + // A malformed preset leaves the host unpinned rather than blocking boot. + store.pinServer(_presetHost, _presetKey); } diff --git a/lib/smol/crypto.dart b/lib/smol/crypto.dart deleted file mode 100644 index 345ce64..0000000 --- a/lib/smol/crypto.dart +++ /dev/null @@ -1,435 +0,0 @@ -// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305, -// X25519, Ed25519, and the §2 key conversions between the two curves. Pure -// Dart rather than PointyCastle so the byte-exact vectors from the reference -// client (test/vectors.json) can pin every operation. - -import "dart:convert"; -import "dart:math"; -import "dart:typed_data"; - -import "package:crypto/crypto.dart" as hashes; - -import "package:smol_mail/smol/errors.dart"; - -// --- bytes -------------------------------------------------------------------- - -Uint8List concat(List> parts) { - final out = Uint8List(parts.fold(0, (n, p) => n + p.length)); - var off = 0; - for (final p in parts) { - out.setRange(off, off + p.length, p); - off += p.length; - } - return out; -} - -Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text)); - -String hex(List bytes) => - bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join(); - -Uint8List unhex(String text) { - if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text"); - final out = Uint8List(text.length ~/ 2); - for (var i = 0; i < out.length; i++) { - out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16); - } - return out; -} - -BigInt leBytesToBigInt(Uint8List bytes) { - var n = BigInt.zero; - for (var i = bytes.length - 1; i >= 0; i--) { - n = (n << 8) | BigInt.from(bytes[i]); - } - return n; -} - -Uint8List bigIntToLeBytes(BigInt value, int length) { - final out = Uint8List(length); - var v = value; - for (var i = 0; i < length; i++) { - out[i] = (v & BigInt.from(0xff)).toInt(); - v >>= 8; - } - return out; -} - -Uint8List randomBytes(int n) { - final out = Uint8List(n); - final rng = Random.secure(); - for (var i = 0; i < n; i++) { - out[i] = rng.nextInt(256); - } - return out; -} - -bool timingSafeEqual(List a, List b) { - if (a.length != b.length) return false; - var diff = 0; - for (var i = 0; i < a.length; i++) { - diff |= a[i] ^ b[i]; - } - return diff == 0; -} - -// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) --------------- - -Uint8List sha256(List message) => - Uint8List.fromList(hashes.sha256.convert(message).bytes); - -Uint8List sha512(List message) => - Uint8List.fromList(hashes.sha512.convert(message).bytes); - -Uint8List hmacSha256(List key, List message) => - Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes); - -Uint8List hkdfSha256(List ikm, List salt, List info, - [int length = 32]) { - final prk = hmacSha256(salt, ikm); - var out = []; - var block = []; - var counter = 1; - while (out.length < length) { - block = hmacSha256(prk, concat([block, info, [counter]])); - out.addAll(block); - counter++; - } - return Uint8List.fromList(out.sublist(0, length)); -} - -// --- ChaCha20-Poly1305 AEAD (RFC 8439) ----------------------------------------- - -const _mask32 = 0xFFFFFFFF; - -int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32; - -Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) { - final state = Uint32List(16); - state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]); - final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length); - for (var i = 0; i < 8; i++) { - state[4 + i] = kview.getUint32(i * 4, Endian.little); - } - state[12] = counter & _mask32; - final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length); - for (var i = 0; i < 3; i++) { - state[13 + i] = nview.getUint32(i * 4, Endian.little); - } - final x = Uint32List.fromList(state); - void qr(int a, int b, int c, int d) { - x[a] = (x[a] + x[b]) & _mask32; - x[d] = _rotl32(x[d] ^ x[a], 16); - x[c] = (x[c] + x[d]) & _mask32; - x[b] = _rotl32(x[b] ^ x[c], 12); - x[a] = (x[a] + x[b]) & _mask32; - x[d] = _rotl32(x[d] ^ x[a], 8); - x[c] = (x[c] + x[d]) & _mask32; - x[b] = _rotl32(x[b] ^ x[c], 7); - } - - for (var i = 0; i < 10; i++) { - qr(0, 4, 8, 12); - qr(1, 5, 9, 13); - qr(2, 6, 10, 14); - qr(3, 7, 11, 15); - qr(0, 5, 10, 15); - qr(1, 6, 11, 12); - qr(2, 7, 8, 13); - qr(3, 4, 9, 14); - } - final out = Uint8List(64); - final view = ByteData.view(out.buffer); - for (var i = 0; i < 16; i++) { - view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little); - } - return out; -} - -Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) { - final out = Uint8List(data.length); - for (var off = 0; off < data.length; off += 64) { - final stream = _chachaBlock(key, counter + off ~/ 64, nonce); - final n = min(64, data.length - off); - for (var i = 0; i < n; i++) { - out[off + i] = data[off + i] ^ stream[i]; - } - } - return out; -} - -// Poly1305 over BigInt; correctness over speed, messages here stay small. -Uint8List _poly1305(Uint8List key, List message) { - final p = (BigInt.one << 130) - BigInt.from(5); - final r = leBytesToBigInt(key.sublist(0, 16)) & - BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff"); - final s = leBytesToBigInt(key.sublist(16, 32)); - var acc = BigInt.zero; - for (var off = 0; off < message.length; off += 16) { - final block = message.sublist(off, min(off + 16, message.length)); - acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) + - (BigInt.one << (8 * block.length))) * - r % - p; - } - return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16); -} - -Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16); - -Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8); - -Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext, - Uint8List aad) { - final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32); - final ciphertext = _chacha20Xor(key, 1, nonce, plaintext); - final mac = _poly1305(polyKey, - concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)])); - return concat([ciphertext, mac]); -} - -Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) { - if (sealed.length < 16) { - throw const SmolError("ciphertext shorter than the Poly1305 tag"); - } - final ciphertext = sealed.sublist(0, sealed.length - 16); - final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32); - final expect = _poly1305(polyKey, - concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)])); - if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) { - throw const SmolError("decryption failed: bad Poly1305 tag"); - } - return _chacha20Xor(key, 1, nonce, ciphertext); -} - -// --- X25519 (RFC 7748) --------------------------------------------------------- - -final BigInt _p = (BigInt.one << 255) - BigInt.from(19); -final BigInt _mask255 = (BigInt.one << 255) - BigInt.one; - -BigInt _mod(BigInt value, [BigInt? p]) { - final m = p ?? _p; - return ((value % m) + m) % m; -} - -BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) { - final m = p ?? _p; - var out = BigInt.one; - base = _mod(base, m); - while (exponent > BigInt.zero) { - if (exponent & BigInt.one == BigInt.one) out = out * base % m; - base = base * base % m; - exponent >>= 1; - } - return out; -} - -Uint8List clampScalar(Uint8List scalar) { - final k = Uint8List.fromList(scalar); - k[0] &= 248; - k[31] &= 127; - k[31] |= 64; - return k; -} - -BigInt _x25519Raw(Uint8List scalar, Uint8List u) { - final k = leBytesToBigInt(clampScalar(scalar)); - final x1 = leBytesToBigInt(u) & _mask255; - const a24 = 121665; - var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one; - var swap = BigInt.zero; - for (var t = 254; t >= 0; t--) { - final kt = (k >> t) & BigInt.one; - swap ^= kt; - if (swap == BigInt.one) { - var tmp = x2; - x2 = x3; - x3 = tmp; - tmp = z2; - z2 = z3; - z3 = tmp; - } - swap = kt; - final a = _mod(x2 + z2), aa = a * a % _p; - final b = _mod(x2 - z2), bb = b * b % _p; - final e = _mod(aa - bb); - final c = _mod(x3 + z3), d = _mod(x3 - z3); - final da = d * a % _p, cb = c * b % _p; - final sum = _mod(da + cb), diff = _mod(da - cb); - x3 = sum * sum % _p; - z3 = x1 * diff * diff % _p; - x2 = aa * bb % _p; - z2 = e * _mod(aa + BigInt.from(a24) * e) % _p; - } - if (swap == BigInt.one) { - var tmp = x2; - x2 = x3; - x3 = tmp; - tmp = z2; - z2 = z3; - z3 = tmp; - } - return x2 * _powMod(z2, _p - BigInt.two) % _p; -} - -// §2's low-order rejection: a clamped scalar is a multiple of 8, so any -// low-order peer point yields an all-zero shared secret — rejecting the zero -// output rejects all of them. -Uint8List x25519(Uint8List scalar, Uint8List peerPublic) { - final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32); - if (shared.every((b) => b == 0)) { - throw const SmolError("rejected low-order key agreement point"); - } - return shared; -} - -Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes( - _x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")), - 32); - -// --- Ed25519 (RFC 8032) -------------------------------------------------------- - -final BigInt _l = (BigInt.one << 252) + - BigInt.parse("27742317777372353535851937790883648493"); -final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two)); -final _Point _b = _Point.fromAffine( - BigInt.parse( - "15112221349535400772501151409588531511454012693041857206046113283949847762202"), - _mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two))); - -class _Point { - final BigInt x, y, z, t; - - const _Point(this.x, this.y, this.z, this.t); - - _Point.fromAffine(BigInt x, BigInt y) - : this(x, y, BigInt.one, _mod(x * y)); -} - -final _Point _identity = _Point( - BigInt.zero, BigInt.one, BigInt.one, BigInt.zero); - -_Point _pointAdd(_Point p, _Point q) { - final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p; - final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p; - final c = BigInt.two * p.t * q.t % _p * _d % _p; - final d = BigInt.two * p.z * q.z % _p; - final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c); - final h = b + a; - return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p); -} - -_Point _pointDouble(_Point p) { - final a = p.x * p.x % _p; - final b = p.y * p.y % _p; - final c = BigInt.two * p.z * p.z % _p; - final d = _p - a; // a = -1 on this curve, so d = -A - final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b); - final g = _mod(d + b); - final f = _mod(g - c); - final h = _mod(d - b); - return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p); -} - -_Point _scalarMult(BigInt scalar, _Point point) { - var result = _identity; - for (var t = 254; t >= 0; t--) { - result = _pointDouble(result); - if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point); - } - return result; -} - -Uint8List _encodePoint(_Point p) { - final zInv = _powMod(p.z, _p - BigInt.two); - final x = p.x * zInv % _p, y = p.y * zInv % _p; - final out = bigIntToLeBytes(y, 32); - out[31] |= (x & BigInt.one).toInt() << 7; - return out; -} - -_Point _decodePoint(Uint8List bytes) { - if (bytes.length != 32) { - throw const SmolError("Ed25519 public key must be 32 bytes"); - } - final sign = bytes[31] >> 7; - final y = leBytesToBigInt(bytes) & _mask255; - if (y >= _p) { - throw const SmolError("non-canonical Ed25519 public key"); - } - final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one); - final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p; - var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p; - if (_mod(v * x % _p * x) != u) { - if (_mod(v * x % _p * x) == _mod(-u)) { - x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p; - } else { - throw const SmolError("not a point on the Ed25519 curve"); - } - } - if (x == BigInt.zero && sign == 1) { - throw const SmolError("invalid sign bit on x = 0"); - } - if ((x & BigInt.one).toInt() != sign) x = _p - x; - return _Point.fromAffine(x, y); -} - -BigInt _seedToScalar(Uint8List seed) { - final h = sha512(seed); - return leBytesToBigInt(clampScalar(h.sublist(0, 32))); -} - -Uint8List ed25519PublicKey(Uint8List seed) { - if (seed.length != 32) { - throw const SmolError("identity seed must be 32 bytes"); - } - return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y))); -} - -Uint8List ed25519Sign(Uint8List seed, List message) { - final h = sha512(seed); - final a = leBytesToBigInt(clampScalar(h.sublist(0, 32))); - final publicKey = - _encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y))); - final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l; - final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y))); - final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l; - return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]); -} - -bool ed25519Verify(Uint8List publicKey, List message, Uint8List signature) { - if (signature.length != 64) return false; - try { - final decodedPk = _decodePoint(publicKey); - final decodedR = _decodePoint(signature.sublist(0, 32)); - final a = _Point.fromAffine(decodedPk.x, decodedPk.y); - final r = _Point.fromAffine(decodedR.x, decodedR.y); - final s = leBytesToBigInt(signature.sublist(32, 64)); - if (s >= _l) return false; - final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l; - final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y)); - final rhs = _pointAdd(_scalarMult(k, a), r); - return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p && - lhs.y * rhs.z % _p == rhs.y * lhs.z % _p; - } on Exception { - return false; - } -} - -// --- §2 conversions between the identity key and X25519 ------------------------- - -Uint8List ed25519ToX25519(Uint8List publicKey) { - final y = leBytesToBigInt(publicKey) & _mask255; - if (y >= _p) { - throw const SmolError("non-canonical Ed25519 public key"); - } - if (_mod(BigInt.one - y) == BigInt.zero) { - throw const SmolError("identity element has no X25519 image"); - } - return bigIntToLeBytes( - _mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32); -} - -Uint8List ed25519SeedToX25519(Uint8List seed) => - clampScalar(sha512(seed).sublist(0, 32)); - diff --git a/lib/smol/noise.dart b/lib/smol/noise.dart deleted file mode 100644 index fa5d46b..0000000 --- a/lib/smol/noise.dart +++ /dev/null @@ -1,118 +0,0 @@ -// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics. -// The initiator is anonymous; the responder's static key arrives encrypted in -// message two, which is what server pinning checks. - -import "dart:typed_data"; - -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; - -const String _protocol = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name - -Uint8List _prologue() => utf8Bytes("smolmail/1"); - -// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE. -Uint8List _nonce(int n) { - final out = Uint8List(12); - ByteData.view(out.buffer).setUint64(4, n, Endian.little); - return out; -} - -/// One direction of the post-handshake transport; tests substitute a -/// passthrough so framing guards can be exercised without crypto. -abstract class SessionCipher { - Uint8List encrypt(Uint8List plaintext); - - Uint8List decrypt(Uint8List sealed); -} - -// The key is unique per session, so the counter starting at zero is safe. -class CipherState implements SessionCipher { - final Uint8List key; - int counter = 0; - - CipherState(this.key); - - @override - Uint8List encrypt(Uint8List plaintext) { - final sealed = aeadEncrypt(key, _nonce(counter), plaintext, Uint8List(0)); - counter++; - return sealed; - } - - @override - Uint8List decrypt(Uint8List sealed) { - final plaintext = aeadDecrypt(key, _nonce(counter), sealed, Uint8List(0)); - counter++; - return plaintext; - } -} - -class NxResult { - final CipherState send, recv; - final Uint8List serverStatic; - final Uint8List handshakeHash; - - const NxResult(this.send, this.recv, this.serverStatic, this.handshakeHash); -} - -class NxInitiator { - late Uint8List h; - late Uint8List ck; - Uint8List? key; - late Uint8List esk; - late Uint8List epk; - - NxInitiator() { - h = utf8Bytes(_protocol); - ck = Uint8List.fromList(h); - mixHash(_prologue()); - } - - void mixHash(Uint8List data) { - h = sha256(concat([h, data])); - } - - void mixKey(Uint8List ikm) { - final okm = hkdfSha256(ikm, ck, Uint8List(0), 64); - ck = okm.sublist(0, 32); - key = okm.sublist(32); - } - - // Message one is just our ephemeral public key. No key is set yet, so the - // empty payload travels in the clear — and is still mixed into h. - Uint8List writeMessage1([Uint8List? esk]) { - this.esk = esk ?? randomBytes(32); - epk = x25519Base(this.esk); - mixHash(epk); - mixHash(Uint8List(0)); - return Uint8List.fromList(epk); - } - - // Message two: e (plaintext), ee, then the responder's static and the - // (empty) payload as AEAD ciphertexts chained through h. Each MixKey - // restarts the nonce at zero. - NxResult readMessage2(Uint8List message) { - if (message.length != 32 + 48 + 16) { - throw SmolError("unexpected NX message length ${message.length}"); - } - final re = message.sublist(0, 32); - mixHash(re); - mixKey(x25519(esk, re)); - final serverStatic = decryptAndHash(message.sublist(32, 80)); - mixKey(x25519(esk, serverStatic)); // es - final payload = decryptAndHash(message.sublist(80)); - if (payload.isNotEmpty) throw const SmolError("unexpected payload in handshake"); - final handshakeHash = h; - // Split(): two transport keys from the final chaining key, zero-length ikm - final okm = hkdfSha256(Uint8List(0), ck, Uint8List(0), 64); - return NxResult(CipherState(okm.sublist(0, 32)), CipherState(okm.sublist(32)), - serverStatic, handshakeHash); - } - - Uint8List decryptAndHash(Uint8List sealed) { - final plaintext = aeadDecrypt(key!, _nonce(0), sealed, h); - mixHash(sealed); - return plaintext; - } -} diff --git a/lib/smol/proto.dart b/lib/smol/proto.dart deleted file mode 100644 index c253723..0000000 --- a/lib/smol/proto.dart +++ /dev/null @@ -1,663 +0,0 @@ -// Smol Mail protocol, version 1.1 (../smolmail SPEC.md): addresses, sealed -// and signed envelopes, body frontmatter, key rotation, accept tokens, and -// the framed request and response bodies of the five operations. - -import "dart:math"; -import "dart:typed_data"; - -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/noise.dart"; - -const defaultPort = 1961; -const keyLen = 32, sigLen = 64, certLen = 200, idLen = 32, tokenLen = 32; -const maxFrame = 1 << 20, noisePayload = 65535 - 16, padTo = 1024; -const envelopeHeader = 69, payloadHeader = 45, maxChain = 16; -const maxSkew = 86400; // §5.3: how far ahead of our clock a payload may be dated -const flagRequests = 0x01; // §6.1: set when a FETCH record missed an accept token -const _frontmatterMax = 4096, _frontmatterKeys = 64; - -const opAuth = 0x00, opResolve = 0x01, opSend = 0x02, opFetch = 0x03, - opDelete = 0x04, opRegister = 0x05; - -const _statusNames = { - 0: "ok", 1: "malformed", 2: "bad version", 3: "unknown user", - 4: "auth required", 5: "auth failed", 6: "quota exceeded", 7: "too large", - 8: "rate limited", 9: "not permitted", 10: "internal error", -}; - -String statusName(int status) => _statusNames[status] ?? "$status"; - -final _label = ( - auth: utf8Bytes("smolmail/1 auth"), - seal: utf8Bytes("smolmail/1 seal"), - msg: utf8Bytes("smolmail/1 msg"), - id: utf8Bytes("smolmail/1 id"), - rotate: utf8Bytes("smolmail/1 rotate"), - identity: utf8Bytes("smolmail/1 identity"), - accept: utf8Bytes("smolmail/1 accept"), - mac: utf8Bytes("smolmail/1 mac"), - register: utf8Bytes("smolmail/1 register"), -); - -// --- encoding helpers --------------------------------------------------------- - -const _b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; - -String b32encode(List bytes) { - var out = ""; - var value = 0, bits = 0; - for (final b in bytes) { - value = (value << 8) | b; - bits += 8; - while (bits >= 5) { - bits -= 5; - out += _b32[(value >>> bits) & 31]; - } - } - if (bits > 0) out += _b32[(value << (5 - bits)) & 31]; - return out.toLowerCase(); -} - -Uint8List b32decode(String text) { - final out = []; - var value = 0, bits = 0; - final clean = text.trim().toUpperCase().replaceAll(RegExp(r"=+$"), ""); - for (final ch in clean.split("")) { - final idx = _b32.indexOf(ch); - if (idx < 0) throw SmolError("invalid base32 character '$ch'"); - value = (value << 5) | idx; - bits += 5; - if (bits >= 8) { - bits -= 8; - out.add((value >>> bits) & 0xff); - } - } - return Uint8List.fromList(out); -} - -// §3: the first 20 base32 characters of the identity, in groups of four. -String fingerprint(Uint8List identity) { - final s = b32encode(identity).substring(0, 20); - return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" "); -} - -Uint8List u16be(int n) => Uint8List.fromList([(n >> 8) & 0xff, n & 0xff]); - -Uint8List u32be(int n) => Uint8List.fromList( - [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); - -// Dart 3.2's ByteData has no setBigInt, so write big-endian manually. -Uint8List i64be(BigInt n) { - final out = Uint8List(8); - for (var i = 0; i < 8; i++) { - out[i] = ((n >> (8 * (7 - i))) & BigInt.from(0xff)).toInt(); - } - return out; -} - -int nowSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000; - -// Fail-closed reader; every parse raises rather than reading past the end. -class Reader { - final Uint8List buf; - int pos = 0; - - Reader(this.buf); - - Uint8List take(int n) { - if (n < 0 || pos + n > buf.length) throw const SmolError("truncated message"); - final out = buf.sublist(pos, pos + n); - pos += n; - return out; - } - - int u8() => take(1)[0]; - - int u16() { - final b = take(2); - return (b[0] << 8) | b[1]; - } - - int u32() => ByteData.view(take(4).buffer).getUint32(0); - - int i64() => ByteData.view(take(8).buffer).getInt64(0); - - int get left => buf.length - pos; -} - -// --- identity ----------------------------------------------------------------- - -// §2: an Ed25519 keypair with the X25519 agreement keys derived from it. -class SmolIdentity { - final Uint8List seed; - final Uint8List publicKey; - - const SmolIdentity(this.seed, this.publicKey); -} - -SmolIdentity identityFromSeed(Uint8List seed) { - if (seed.length != keyLen) { - throw const SmolError("identity seed must be $keyLen bytes"); - } - return SmolIdentity(seed, ed25519PublicKey(seed)); -} - -SmolIdentity newIdentity() => identityFromSeed(randomBytes(keyLen)); - -// §2: the only secret a user holds. Everything else — every rotation index's -// signing seed, and the accept key — is derived from it with HKDF. -Uint8List identitySeed(Uint8List master, int index) => - hkdfSha256(master, Uint8List(0), concat([_label.identity, u32be(index)])); - -Uint8List acceptKeyFor(Uint8List master) => - hkdfSha256(master, Uint8List(0), _label.accept); - -// §5.8: the token this account issues to one correspondent, independent of -// the rotation index so it survives the owner's key rotation. -Uint8List tokenFor(Uint8List master, Uint8List correspondentIdentity) => - hmacSha256(acceptKeyFor(master), correspondentIdentity); - -// §5.8: what a sender attaches to SEND to reach the recipient's main tier. -Uint8List acceptMac(Uint8List token, Uint8List id) => - hmacSha256(token, concat([_label.mac, id])); - -// --- addressing (§3) ----------------------------------------------------------- - -final _address = - RegExp(r"^(?[a-z0-9._-]{1,63})@(?[^/:]+)(?::(?\d+))?$"); - -const _separators = "._-"; - -// §3: alphanumeric at both ends, never two separators in a row. -bool validUsername(String name) { - if (name.isEmpty) return false; - if (_separators.contains(name[0]) || _separators.contains(name[name.length - 1])) { - return false; - } - for (var i = 0; i < name.length - 1; i++) { - if (_separators.contains(name[i]) && _separators.contains(name[i + 1])) { - return false; - } - } - return true; -} - -class SmolAddress { - final String user; - final String host; - final int port; - - /// The key carried by a `smol://` address; null for short addresses. - final Uint8List? identity; - - const SmolAddress(this.user, this.host, this.port, this.identity); - - String get short => - "$user@$host${port == defaultPort ? "" : ":$port"}"; - - String uri(Uint8List key) => - "smol://$user@$host${port == defaultPort ? "" : ":$port"}/${b32encode(key)}"; -} - -SmolAddress parseAddress(String text) { - text = text.trim(); - Uint8List? identity; - if (text.startsWith("smol://")) { - final rest = text.substring("smol://".length); - final slash = rest.lastIndexOf("/"); - if (slash < 0) throw SmolError("$text: smol:// address carries no key"); - identity = b32decode(rest.substring(slash + 1)); - if (identity.length != keyLen) { - throw SmolError( - "$text: key is ${identity.length} bytes, expected $keyLen"); - } - text = rest.substring(0, slash); - } - final m = _address.firstMatch(text.toLowerCase()); - if (m == null) throw SmolError("'$text' is not a valid address"); - final user = m.namedGroup("user")!; - final host = m.namedGroup("host")!; - if (!validUsername(user)) { - throw SmolError("$user must begin and end with a letter or digit " - "and may not contain two separators in a row"); - } - final portText = m.namedGroup("port"); - final port = portText != null ? int.parse(portText) : defaultPort; - return SmolAddress(user, host, port, identity); -} - -// --- message format (§5) ------------------------------------------------------- - -// §5.4: derived from the envelope so no sender can choose it; used whole, -// nothing truncates it. -Uint8List messageId(List envelope) => sha256(concat([_label.id, envelope])); - -class OpenedMessage { - final Uint8List sender; - final int time; - final Uint8List body; - final Uint8List id; - - const OpenedMessage(this.sender, this.time, this.body, this.id); -} - -/// Options for [seal]; [esk] and [pad] exist so tests can pin them, mirroring -/// the spec's fixed-ephemeral vectors. -class SealOptions { - final Uint8List? esk; - final bool pad; - - const SealOptions({this.esk, this.pad = true}); -} - -// §5.2 and §5.3. The ephemeral key is thrown away after sealing, so the sender -// cannot decrypt what they sent. -Uint8List seal(SmolIdentity identity, Uint8List recipient, Uint8List body, - [int? when, SealOptions opts = const SealOptions()]) { - final esk = opts.esk ?? randomBytes(keyLen); - final epk = x25519Base(esk); - final key = hkdfSha256(x25519(esk, ed25519ToX25519(recipient)), - concat([epk, recipient]), _label.seal); - final header = concat([ - Uint8List.fromList([1]), - identity.publicKey, - i64be(BigInt.from(when ?? nowSeconds())), - u32be(body.length), - ]); - var plaintext = concat([ - header, - body, - ed25519Sign(identity.seed, concat([_label.msg, recipient, epk, header, body])), - ]); - if (opts.pad) { - plaintext = concat( - [plaintext, Uint8List((padTo - plaintext.length % padTo) % padTo)]); - } - final aad = concat([utf8Bytes("SMOL"), Uint8List.fromList([1]), recipient, epk]); - return concat([aad, aeadEncrypt(key, Uint8List(12), plaintext, aad)]); -} - -// Inverse of seal(); throws unless the signature and the recipient both check -// out. [identities] may include retired keys, per §7. -OpenedMessage unseal(List identities, Uint8List envelope) { - if (envelope.length < envelopeHeader + 16) { - throw const SmolError("envelope too short"); - } - final magic = utf8Bytes("SMOL"); - for (var i = 0; i < 4; i++) { - if (magic[i] != envelope[i]) { - throw const SmolError("not a Smol Mail envelope"); - } - } - if (envelope[4] != 1) { - throw SmolError("unsupported envelope version ${envelope[4]}"); - } - final to = envelope.sublist(5, 37), epk = envelope.sublist(37, 69); - final sealed = envelope.sublist(69); - SmolIdentity? me; - for (final i in identities) { - if (timingSafeEqual(i.publicKey, to)) { - me = i; - break; - } - } - if (me == null) { - throw SmolError( - "addressed to ${b32encode(to).substring(0, 16)}…, not one of our keys"); - } - final key = hkdfSha256( - x25519(ed25519SeedToX25519(me.seed), epk), concat([epk, to]), _label.seal); - Uint8List plaintext; - try { - plaintext = aeadDecrypt(key, Uint8List(12), sealed, envelope.sublist(0, envelopeHeader)); - } on SmolError { - throw const SmolError("decryption failed: wrong key or corrupt envelope"); - } - final r = Reader(plaintext); - if (r.u8() != 1) throw const SmolError("unsupported payload version"); - final sender = r.take(keyLen); - final when = r.i64(); - final bodyLen = r.u32(); - if (bodyLen > r.left) { - throw const SmolError("payload body length exceeds the payload"); - } - final body = r.take(bodyLen); - final signature = r.take(sigLen); // trailing bytes are padding - if (!ed25519Verify(sender, - concat([_label.msg, to, epk, plaintext.sublist(0, payloadHeader), body]), - signature)) { - throw const SmolError("signature does not verify"); - } - if (when > nowSeconds() + maxSkew) { - throw const SmolError("payload is dated in the future"); - } - return OpenedMessage(sender, when, body, messageId(envelope)); -} - -// --- body frontmatter (§5.5) --------------------------------------------------- - -final _fmKey = RegExp(r"^[A-Za-z0-9-]{1,64}$"); - -class Frontmatter { - final Map fields; - final String body; - - const Frontmatter(this.fields, this.body); -} - -// A flat `Key: value` block, deliberately not YAML. Any malformed line -// invalidates the whole block, which is then returned as ordinary body text: -// frontmatter fails closed toward display, never toward silent discard. Keys -// are compared case-insensitively (§5.5), so they are kept lowercased. -Frontmatter parseFrontmatter(String text) { - if (!text.startsWith("---\n")) return Frontmatter(const {}, text); - final lines = text.split("\n"); - final close = lines.indexOf("---", 1); - if (close < 0) return Frontmatter(const {}, text); - final block = lines.sublist(1, close); - final rest = lines.sublist(close + 1).join("\n"); - var encoded = 0; - for (final line in block) { - encoded += utf8Bytes(line).length + 1; - } - if (block.length > _frontmatterKeys || encoded > _frontmatterMax) { - return Frontmatter(const {}, text); - } - final fields = {}; - for (final line in block) { - final colon = line.indexOf(":"); - final head = colon < 0 ? "" : line.substring(0, colon); - if (colon < 0 || !_fmKey.hasMatch(head)) { - return Frontmatter(const {}, text); - } - // first occurrence wins - fields.putIfAbsent(head.toLowerCase(), () => line.substring(colon + 1).trim()); - } - return Frontmatter(fields, rest); -} - -// Emit a block only when needed, including to escape a body that genuinely -// begins with `---` (§5.5). -String buildFrontmatter(Map fields, String body) { - final entries = fields.entries.where((e) => e.value.isNotEmpty).toList(); - if (entries.isEmpty && !body.startsWith("---\n")) return body; - final block = entries.map((e) => "${e.key}: ${e.value}\n").join(); - return "---\n$block---\n$body"; -} - -// --- key rotation (§7) --------------------------------------------------------- - -// §7: old_pub 32 || new_pub 32 || time 8 || sig_old 64 || sig_new 64. Both -// keys sign, so the old key alone cannot hand the username to a key nobody -// controls; the username is covered but not carried, so a verifier always -// supplies the one it is checking. -Uint8List makeCert( - String username, SmolIdentity oldIdentity, Uint8List newSeed, [int? when]) { - final newIdentity = identityFromSeed(newSeed); - final time = i64be(BigInt.from(when ?? nowSeconds())); - final signed = concat( - [_label.rotate, utf8Bytes(username), oldIdentity.publicKey, newIdentity.publicKey, time]); - return concat([ - oldIdentity.publicKey, - newIdentity.publicKey, - time, - ed25519Sign(oldIdentity.seed, signed), - ed25519Sign(newIdentity.seed, signed), - ]); -} - -// Accept a key change only when a signed chain leads from the key we hold to -// the one the server now returns, both keys signing each link (§7). -bool walkChain( - String username, Uint8List pinned, Uint8List current, List chain) { - if (timingSafeEqual(pinned, current)) return true; - if (chain.isEmpty || chain.length > maxChain) return false; - var key = pinned; - var started = false; - for (final cert in chain) { - final old = cert.sublist(0, 32), next = cert.sublist(32, 64); - final when = cert.sublist(64, 72); - final sigOld = cert.sublist(72, 136), sigNew = cert.sublist(136, 200); - if (!started) { - if (!timingSafeEqual(old, key)) continue; // a link predating the key we hold - started = true; - } else if (!timingSafeEqual(old, key)) { - return false; // the chain is not continuous - } - final signed = concat([_label.rotate, utf8Bytes(username), old, next, when]); - if (!ed25519Verify(old, signed, sigOld) || !ed25519Verify(next, signed, sigNew)) { - return false; - } - key = next; - } - return started && timingSafeEqual(key, current); -} - -// --- framing and operations (§4, §6) ------------------------------------------- - -/// An ordered byte pipe (TCP socket, or an in-memory queue in tests). -abstract class Wire { - void send(Uint8List bytes); - - void close(); - - Future readExact(int n); -} - -// One Noise session: application frames split across u16-prefixed Noise -// messages, requests and responses as in §6.1. -class Session { - final Wire wire; - final SessionCipher send, recv; - - Session(this.wire, this.send, this.recv); - - Future _readNoise() async { - final head = await wire.readExact(2); - final length = (head[0] << 8) | head[1]; - if (length < 16) throw SmolError("server sent a $length-byte Noise message"); - return recv.decrypt(await wire.readExact(length)); - } - - Future call(int op, [Uint8List? body]) async { - final payload = body ?? Uint8List(0); - final frame = concat([u32be(1 + payload.length), Uint8List.fromList([op]), payload]); - if (frame.length > maxFrame + 4) { - throw const SmolError("request exceeds the maximum frame size"); - } - for (var off = 0; off < frame.length; off += noisePayload) { - final packet = send.encrypt(frame.sublist(off, min(off + noisePayload, frame.length))); - wire.send(concat([u16be(packet.length), packet])); - } - var length = -1; - var have = []; - while (length < 0 || have.length < 4 + length) { - have.addAll(await _readNoise()); - if (length < 0 && have.length >= 4) { - length = (have[0] << 24) | (have[1] << 16) | (have[2] << 8) | have[3]; - // §6.1: the shortest response is a type byte and a status byte. - if (length < 2 || length > maxFrame) { - throw SmolError("server sent a frame of length $length"); - } - } - } - final payloadOut = Uint8List.fromList(have.sublist(4, 4 + length)); - // §6.1: a response reuses the request's type byte. A mismatch means the - // session desynchronised, which must not be mistaken for a status. - if (payloadOut[0] != op) { - throw SmolError( - "server answered op 0x${payloadOut[0].toRadixString(16)}, expected 0x${op.toRadixString(16)}"); - } - return Response(payloadOut[1], payloadOut.sublist(2)); - } -} - -class Response { - final int status; - final Uint8List body; - - const Response(this.status, this.body); -} - -class OpenedSession { - final Session session; - final Uint8List serverStatic; - final bool pinned; - final Uint8List handshakeHash; - - const OpenedSession(this.session, this.serverStatic, this.pinned, this.handshakeHash); -} - -// Handshake plus §4 pinning. Returns the session, the server's static key as -// revealed by the handshake, and whether that key was already pinned. -Future openSession(Wire wire, String host, - {Uint8List? pinned}) async { - final nx = NxInitiator(); - final m1 = nx.writeMessage1(); - wire.send(concat([u16be(m1.length), m1])); - final head = await wire.readExact(2); - final result = nx.readMessage2(await wire.readExact((head[0] << 8) | head[1])); - if (pinned != null && !timingSafeEqual(pinned, result.serverStatic)) { - throw SmolError("$host presented a different key than the one pinned\n" - " pinned: ${b32encode(pinned)}\n" - " presented: ${b32encode(result.serverStatic)}"); - } - return OpenedSession( - Session(wire, result.send, result.recv), - result.serverStatic, - pinned != null, - result.handshakeHash); -} - -void expectOk(int status, String what) { - if (status != 0) { - throw SmolError("$what failed: ${statusName(status)} ($status)"); - } -} - -// §4 session authentication: sign the handshake hash, which binds the -// signature to this session's server ephemeral and cannot be replayed, and -// push the accept token set (§5.8). `sync = 0` leaves the server's stored set -// untouched and `tokens` MUST then be empty; `sync = 1` replaces it exactly. -// Returns the number of accept tokens the server now holds. -Future authenticate(Session session, Uint8List handshakeHash, String username, - SmolIdentity identity, {required int sync, List tokens = const []}) async { - final name = utf8Bytes(username); - if (name.length > 255) throw const SmolError("username too long"); - if (tokens.length > 0xffff) throw const SmolError("too many accept tokens for one AUTH"); - final body = concat([ - Uint8List.fromList([name.length]), - name, - identity.publicKey, - ed25519Sign(identity.seed, concat([_label.auth, handshakeHash])), - Uint8List.fromList([sync]), - u16be(tokens.length), - ...tokens, - ]); - final response = await session.call(opAuth, body); - expectOk(response.status, "authentication"); - return Reader(response.body).u16(); -} - -class Resolved { - final Uint8List identity; - final List chain; - - const Resolved(this.identity, this.chain); -} - -// RESOLVE, returning the current key and its rotation chain (§6.1). -Future resolveOp(Session session, String user) async { - final name = utf8Bytes(user); - if (name.length > 255) throw const SmolError("username too long"); - final response = - await session.call(opResolve, concat([Uint8List.fromList([name.length]), name])); - expectOk(response.status, "resolving $user"); - final r = Reader(response.body); - return Resolved( - r.take(keyLen), - List.generate(r.u8(), (_) => r.take(certLen))); -} - -// §5.8: [mac] is the sender's proof of an accept token, 0 or 32 bytes. -Future sendOp(Session session, Uint8List envelope, {Uint8List? mac}) async { - final macBytes = mac ?? Uint8List(0); - if (macBytes.isNotEmpty && macBytes.length != tokenLen) { - throw const SmolError("accept MAC must be $tokenLen bytes"); - } - final body = concat([Uint8List.fromList([macBytes.length]), macBytes, envelope]); - final response = await session.call(opSend, body); - expectOk(response.status, "sending"); - return response.body.length == idLen - ? response.body - : messageId(envelope); -} - -class FetchedRecord { - final Uint8List id; - final int receivedAt; - final int flags; - final Uint8List envelope; - - const FetchedRecord(this.id, this.receivedAt, this.flags, this.envelope); - - // §6.1: bit 0 is set when the message arrived without a matching accept token. - bool get isRequest => flags & flagRequests != 0; -} - -// §6.1: pages forward from a cursor; an all-zero id starts at the beginning. -Future> fetchOp( - Session session, int afterReceivedAt, Uint8List afterId) async { - final body = concat([i64be(BigInt.from(afterReceivedAt)), afterId]); - final response = await session.call(opFetch, body); - expectOk(response.status, "fetching"); - final r = Reader(response.body); - return List.generate(r.u16(), (_) { - final id = r.take(idLen); - final receivedAt = r.i64(); - final flags = r.u8(); - return FetchedRecord(id, receivedAt, flags, r.take(r.u32())); - }); -} - -Future deleteOp(Session session, List ids) async { - if (ids.length > 0xffff) throw const SmolError("too many ids for one DELETE"); - final body = concat([u16be(ids.length), ...ids]); - final response = await session.call(opDelete, body); - expectOk(response.status, "acknowledging"); - return Reader(response.body).u16(); -} - -class RegisterOptions { - final String token; - final Uint8List? cert; - - const RegisterOptions({this.token = "", this.cert}); -} - -// §6.1: the signature is proof of possession, bound to the server that will -// store the binding so it cannot be replayed to another server. -Uint8List registerSigned(Uint8List serverStatic, String username, Uint8List identity) => - concat([_label.register, serverStatic, utf8Bytes(username), identity]); - -Future registerOp(Session session, Uint8List serverStatic, String username, - SmolIdentity identity, [RegisterOptions opts = const RegisterOptions()]) async { - final name = utf8Bytes(username); - final tokenBytes = utf8Bytes(opts.token); - final cert = opts.cert ?? Uint8List(0); - if (name.length > 255 || tokenBytes.length > 255 || cert.length > 255) { - throw const SmolError("REGISTER field too long"); - } - final body = concat([ - Uint8List.fromList([name.length]), - name, - identity.publicKey, - ed25519Sign(identity.seed, registerSigned(serverStatic, username, identity.publicKey)), - Uint8List.fromList([tokenBytes.length]), - tokenBytes, - Uint8List.fromList([cert.length]), - cert, - ]); - expectOk((await session.call(opRegister, body)).status, "registering $username"); -} diff --git a/lib/smol/store.dart b/lib/smol/store.dart index d80391e..9ace821 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -1,74 +1,46 @@ -// Device state: identity, pins, contacts and read markers in one JSON blob; -// sealed envelopes in a second Hive box, opened only on demand, so nothing at -// rest is plaintext (the master secret excepted — the device's app storage is -// the trust boundary, like gsmol's browser profile). +// Local state, split by owner: fumi's SQLite store owns everything the +// protocol defines (mail, contacts, pins, accepted, tokens, seen ids), and +// this Hive layer owns only what the app owns — the master secret, read +// marks and UI settings. Reads are synchronous FFI calls (a query plus one +// JSON parse, microseconds); network operations stay on the client, where +// they run through isolates. +import "dart:async"; import "dart:convert"; +import "dart:io"; import "dart:typed_data"; -import "package:hive_flutter/hive_flutter.dart"; +import "package:hive/hive.dart"; + +import "package:smol_mail/native/client.dart"; +import "package:smol_mail/native/ffi.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; +import "package:smol_mail/smol/ui.dart"; -const _stateBox = "smol"; -const _mailBox = "mail"; -const _stateKey = "state"; +const tierMain = 0, tierRequests = 1; -class StoredAccount { - final String user; - final String host; - final int port; - - const StoredAccount(this.user, this.host, this.port); -} - -/// A key this contact replaced, per §7/§8 — the only local record that a -/// rotation happened, kept so the user can notice such changes. +/// A displaced key a contact no longer uses, with when it stopped being +/// current (epoch ms) — the record §8 turns on the user being able to see. class ContactHistoryEntry { - final Uint8List key; - final int until; // epoch ms of the displacement + final String key; + final int until; const ContactHistoryEntry(this.key, this.until); } class StoredContact { - final Uint8List key; + final String key; final bool verified; final List history; - const StoredContact(this.key, this.verified, [this.history = const []]); + const StoredContact(this.key, this.verified, this.history); } -class MailRecord { - final String id; // hex of the 32-byte message id - final Uint8List envelope; - final int? receivedAt; - final String? recipient; // sent copies only - final int? sentAt; - final int tier; // §5.8: tierMain or tierRequests; meaningless for sent copies - - /// Whether "leave mail on server" was on when this was fetched, so a - /// manual delete still has a server-side copy to remove. Always false for - /// sent copies, which never had one (§5.6). - final bool keptOnServer; - - const MailRecord(this.id, this.envelope, - {this.receivedAt, - this.recipient, - this.sentAt, - this.tier = tierMain, - this.keptOnServer = false}); -} - -const tierMain = 0, tierRequests = 1; - -/// A correspondent admitted to this mailbox's main tier (§5.8). The identity -/// is frozen at acceptance because the token is derived from it: a contact's -/// later rotation must not change the token they already hold. +/// A correspondent admitted to the mailbox's main tier (§5.8). The identity +/// is frozen at acceptance because the token is derived from it. class AcceptedContact { - final Uint8List identity; + final String identity; final bool active; const AcceptedContact(this.identity, this.active); @@ -84,584 +56,303 @@ class ImportSummary { "$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed"; } +class MailRecord { + final String id; // hex of the 32-byte message id + final String envelope; // sealed, base64 — plaintext is never at rest + final int? receivedAt; + final String? recipient; // sent copies only + final int? sentAt; + final int tier; // §5.8: tierMain or tierRequests; meaningless for sent + final bool keptOnServer; + + const MailRecord(this.id, this.envelope, + {this.receivedAt, + this.recipient, + this.sentAt, + this.tier = tierMain, + this.keptOnServer = false}); +} + +/// The public half of the identity; the master never leaves the store +/// except through the reveal-and-copy flow in settings. +class SmolIdentity { + final String publicKey; // base32 + + const SmolIdentity(this.publicKey); +} + class SmolStore { - final Box _state; - final Box _mail; + final Box _meta; + final Box _read; + final FumiNative _native; - SmolStore(this._state, this._mail); + SmolStore._(this._meta, this._read, this._native); - /// [stateBox]/[mailBox] exist so tests can hold several isolated stores - /// in one process; production always uses the defaults. + /// The SQLite file fumi's store owns. One store per process. + late final String dbPath = _native.dbPath; + + /// Opens both layers. [dbPath] is the SQLite file fumi's store owns; the + /// box names exist so tests can hold several isolated stores in one + /// process. static Future open( - {String stateBox = _stateBox, String mailBox = _mailBox}) async { - final state = await Hive.openBox(stateBox); - final mail = await Hive.openBox(mailBox); - return SmolStore(state, mail); - } - - Map _load() { - final blob = _state.get(_stateKey); - return blob is Map ? blob : {}; - } - - void _update(Map Function(Map state) fn) { - final next = fn(_load()); - _state.put(_stateKey, next); - } - - // --- identity (§2) ----------------------------------------------------------- - - /// The 32-byte master secret, or null before the user creates or restores - /// one. Every signing key is derived from it plus the rotation index. - Uint8List? master() { - final raw = _load()["master"]; - return raw == null ? null : unhex(raw as String); - } - - /// The rotation index (§7) of the identity currently in use. - int rotations() => (_load()["rotations"] as int?) ?? 0; - - /// The active identity, or null before the user creates or restores one. - SmolIdentity? identity() { - final m = master(); - return m == null ? null : identityFromSeed(identitySeed(m, rotations())); - } - - /// Whether the accepted-correspondent set held here may replace the - /// server's on the next AUTH — false right after a restore from the master - /// alone, whose empty set must not erase the server's (§4). - bool syncOk() => (_load()["syncOk"] as bool?) ?? true; - - void setSyncOk(bool ok) => _update((state) => state..["syncOk"] = ok); - - void _bindMaster(Uint8List newMaster, int rotations, bool syncOk) { - if (master() != null) throw const SmolIdentityExistsException(); - _update((state) => state - ..["master"] = hex(newMaster) - ..["rotations"] = rotations - ..["syncOk"] = syncOk); - setCursor(0, Uint8List(idLen)); - } - - /// A fresh identity: rotation index 0, and an empty accepted set is - /// already complete, so it may sync. - void setMaster(Uint8List newMaster) => _bindMaster(newMaster, 0, true); - - /// §2: recovering a master alone does not recover which correspondents were - /// accepted, so that set must not overwrite the server's until rebuilt. - void restoreMaster(Uint8List newMaster, int rotationIndex) => - _bindMaster(newMaster, rotationIndex, false); - - // Rotation (§7): only the index advances; the superseded key stays - // derivable from the master, so nothing has to be archived. - void advanceRotation() { - final current = rotations(); - if (master() == null) throw const SmolNoIdentityException(); - if (current >= maxChain) { - throw SmolError("the rotation chain is full at $maxChain links"); + {required String dbPath, + String stateBox = "smol-state", + String readBox = "smol-read"}) async { + final native = FumiNative(dbPath); + await native.open(); + final store = SmolStore._( + await Hive.openBox(stateBox), await Hive.openBox(readBox), native); + final master = store.master(); + if (master != null) { + // The rotation index sits in the native store; read it through the + // synchronous ABI, not the async wrapper. + native.setMaster(master, + rotations: SmolFfi.open().rotations(native.store!)); } - _update((state) => state..["rotations"] = current + 1); + return store; } - /// §7: every key rotated away from is re-derivable from the master, since - /// mail sealed to a superseded key is readable with nothing else. - List identities() { - final m = master(); - if (m == null) return const []; - return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))]; + /// The native binding this store fronts; the client drives its network + /// operations, the store its reads. + FumiNative get native => _native; + + SmolFfi get _ffi => SmolFfi.open(); + + // --- identity --------------------------------------------------------------- + + SmolIdentity? identity() { + if (master() == null) return null; + return SmolIdentity(_ffi.accountPk(_native.account!)); } - // --- account and server pins ------------------------------------------------- - - StoredAccount? account() { - final a = _load()["account"]; - if (a is! Map) return null; - return StoredAccount( - a["user"] as String, a["host"] as String, a["port"] as int); + /// The master, as the one mutable buffer that owns it — wipe overwrites + /// these bytes rather than leaving them to the garbage collector, which + /// is the honest version of zeroization Dart allows. Hive keeps the + /// durable copy as bytes too; a hex string could never be scrubbed. + Uint8List? master() { + final stored = _meta.get("master"); + if (stored == null) return null; + if (stored is Uint8List) return stored; + // The pre-swap app stored hex; convert once. Two alpha testers, so this + // shim retires when their stores have moved. + final bytes = unhex(stored as String); + _meta.put("master", bytes); + return bytes; } - void setAccount(SmolAddress address) { - _update((state) => state - ..["account"] = { - "user": address.user, - "host": address.host, - "port": address.port, - }); + /// A fresh identity: the master at rotation index 0. Only this local step; + /// nothing is sent until registration. + void setMaster(Uint8List fresh) { + // Hive's in-memory state updates synchronously and persists in the + // background, so the store is consistent without awaiting the write. + unawaited(_meta.put("master", fresh)); + _native.setMaster(fresh, rotations: 0); } - Uint8List? serverPin(String host) { - final raw = ((_load()["servers"] as Map?) ?? {})[host]; - return raw == null ? null : b32decode(raw as String); + /// The master restored from a backup, already at the rotation index the + /// server bound. + void restoreMaster(Uint8List master, int index) { + unawaited(_meta.put("master", master)); + _native.setMaster(master, rotations: index); } - void pinServer(String host, Uint8List key) { - _update((state) { - final servers = (state["servers"] as Map? ?? {}).cast(); - servers[host] = b32encode(key); - state["servers"] = servers; - return state; - }); + int rotations() => _ffi.rotations(_native.store!); + + // --- settings --------------------------------------------------------------- + + bool leaveOnServer() => _meta.get("leaveOnServer") == true; + + Future setLeaveOnServer(bool value) async => + _meta.put("leaveOnServer", value); + + bool syncOk() => _ffi.syncOk(_native.store!); + + // --- mail ------------------------------------------------------------------- + + List listMessages(String folder) { + final rows = _ffi.mail(_native.store!, folder); + return [ + for (final row in rows.cast>()) + MailRecord( + row["id"] as String, + row["envelope"] as String, + receivedAt: folder == "sent" ? null : row["at"] as int, + recipient: row["recipient"] as String?, + sentAt: folder == "sent" ? row["at"] as int : null, + tier: (row["tier"] as int?) ?? tierMain, + keptOnServer: row["kept"] as bool? ?? false, + ), + ]..sort((a, b) => (b.receivedAt ?? b.sentAt ?? 0) + .compareTo(a.receivedAt ?? a.sentAt ?? 0)); } - void unpinServer(String host) { - _update((state) { - (state["servers"] as Map?)?.remove(host); - return state; - }); - } - - List<(String, Uint8List)> allPins() { - final servers = ((_load()["servers"] as Map?) ?? {}).cast(); - return [for (final e in servers.entries) (e.key, b32decode(e.value))]; - } - - // --- FETCH behavior -------------------------------------------------------- - - /// When true, FETCH does not acknowledge (delete) what it retrieves — - /// mail stays on the server until explicitly deleted. Defaults to the - /// original behavior: fetched mail is acknowledged immediately. - bool leaveOnServer() => (_load()["leaveOnServer"] as bool?) ?? false; - - void setLeaveOnServer(bool value) => - _update((state) => state..["leaveOnServer"] = value); - - // --- FETCH cursor (§6.1) ------------------------------------------------------- - - (int, Uint8List) cursor() { - final state = _load(); - final afterId = state["afterId"] as String?; - return ( - (state["afterTime"] as int?) ?? 0, - afterId == null ? Uint8List(idLen) : unhex(afterId), - ); - } - - void setCursor(int afterTime, Uint8List afterId) => _update((state) => state - ..["afterTime"] = afterTime - ..["afterId"] = hex(afterId)); - - // --- contacts ------------------------------------------------------------------ - - StoredContact? contact(String address) { - final c = ((_load()["contacts"] as Map?) ?? {})[address]; - if (c is! Map) return null; - final history = ((c["history"] as List?) ?? const []) - .whereType() - .map((e) => ContactHistoryEntry( - b32decode(e["key"] as String), e["until"] as int)) - .toList(); - return StoredContact(b32decode(c["key"] as String), - c["verified"] as bool, history); - } - - // A key that displaces another is kept in the history (§8): it is the - // only local record that the contact rotated. Re-saving the same key is - // not a rotation and must not add an entry. - void saveContact(String address, Uint8List key, bool verified) { - _update((state) { - final contacts = - (state["contacts"] as Map? ?? {}).cast(); - final wanted = b32encode(key); - final previous = contacts[address]; - final history = ((previous?["history"] as List?) ?? const []) - .whereType() - .toList(); - if (previous != null && previous["key"] != wanted) { - history.add({ - "key": previous["key"], - "until": DateTime.now().millisecondsSinceEpoch, - }); - } - contacts[address] = { - "key": wanted, - "verified": verified, - "seenAt": DateTime.now().millisecondsSinceEpoch, - if (history.isNotEmpty) "history": history, - }; - state["contacts"] = contacts; - return state; - }); - } - - String? addressForKey(Uint8List key) { - final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); - final wanted = b32encode(key); - for (final entry in contacts.entries) { - if (entry.value["key"] == wanted) return entry.key; + /// One message by folder and id, for the reader screen's deep link. + MailRecord? getMessage(String folder, String id) { + for (final row in listMessages(folder)) { + if (row.id == id) return row; } return null; } + bool isRead(String id) => _read.get(id) == true; + + void markRead(String id) => unawaited(_read.put(id, true)); + + int unreadCount() => _unread("inbox"); + + int requestsUnreadCount() => _unread("requests"); + + int _unread(String folder) { + final rows = _ffi.mail(_native.store!, folder); + return rows.cast>() + .where((row) => _read.get(row["id"] as String) != true) + .length; + } + + /// The reader's delete: local removal with the id marked seen (§10), so a + /// still-kept server copy is not re-stored by the next fetch. + Future deleteMessage(String folder, String id) async => + _native.deleteLocal(folder, [id]); + + // --- contacts --------------------------------------------------------------- + List<(String, StoredContact)> allContacts() { - final contacts = ((_load()["contacts"] as Map?) ?? {}).cast(); - return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)]; + final rows = _ffi.contacts(_native.store!); + return [ + for (final row in rows.cast>()) + ( + row["address"] as String, + StoredContact( + row["key"] as String, + row["verified"] as bool, + [ + for (final entry in (row["history"] as List).cast>()) + ContactHistoryEntry( + entry["key"] as String, entry["until"] as int), + ], + ) + ), + ]; } - // --- accept tokens (§5.8) -------------------------------------------------------- + StoredContact? contact(String address) { + final row = _ffi.contact(_native.store!, address); + if ((row["key"] as String).isEmpty) return null; + return StoredContact( + row["key"] as String, + row["verified"] as bool, + [ + for (final entry in (row["history"] as List).cast>()) + ContactHistoryEntry(entry["key"] as String, entry["until"] as int), + ], + ); + } + /// The acceptance state: main tier, blocked, or never accepted. AcceptedContact? accepted(String address) { - final a = ((_load()["accepted"] as Map?) ?? {})[address]; - if (a is! Map) return null; - return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool); - } - - /// Admit a contact to the main tier. The identity is frozen at acceptance — - /// re-accepting after a block must not change which key the token is - /// derived from (§5.8). - void accept(String address, Uint8List identity) { - _update((state) { - final accepted = (state["accepted"] as Map? ?? {}).cast(); - final previous = accepted[address]; - accepted[address] = { - "identity": previous?["identity"] ?? b32encode(identity), - "active": true, - "addedAt": previous?["addedAt"] ?? DateTime.now().millisecondsSinceEpoch, - }; - state["accepted"] = accepted; - return state; - }); - } - - /// Withdraw a contact's accept token; their mail lands in the requests tier - /// from their next message on. Throws if the contact was never accepted. - void block(String address) { - final accepted = (_load()["accepted"] as Map? ?? {}).cast(); - if (!accepted.containsKey(address)) { - throw SmolError("$address was never accepted"); - } - _update((state) { - final accepted = (state["accepted"] as Map? ?? {}).cast(); - accepted[address] = {...accepted[address]!, "active": false}; - state["accepted"] = accepted; - return state; - }); + final row = _ffi.contact(_native.store!, address); + final active = row["active"] as bool?; + final acceptedKey = row["acceptedKey"] as String?; + if (active == null || acceptedKey == null) return null; + return AcceptedContact(acceptedKey, active); } List<(String, AcceptedContact)> allAccepted() { - final accepted = ((_load()["accepted"] as Map?) ?? {}).cast(); - return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)]; + final rows = _ffi.contacts(_native.store!); + return [ + for (final row in rows.cast>()) + if (row["active"] != null && row["acceptedKey"] != null) + ( + row["address"] as String, + AcceptedContact( + row["acceptedKey"] as String, row["active"] as bool) + ), + ]; } - /// §4: the tokens to push with AUTH, and whether to push at all. A client - /// that cannot vouch for its own set — one restored from the master alone — - /// must not replace the server's with an incomplete one. - (int, List) tokenSet(Uint8List master) { - if (!syncOk()) return (0, const []); - final active = allAccepted().where((e) => e.$2.active).toList() - ..sort((a, b) => a.$1.compareTo(b.$1)); - return (1, [for (final e in active) tokenFor(master, e.$2.identity)]); - } + /// Binds an address to a key. A different key displaces the old one into + /// the contact's history (§8). + Future saveContact(String address, String keyB32, + {required bool verified}) async => + _native.saveContact(address, keyB32, verified: verified); - /// A token received from a correspondent, filed under the address that - /// issued it: an address outlives the keys behind it, so the token keeps - /// working across the issuer's rotations (§5.8). - Uint8List? tokenFrom(String address) { - final raw = ((_load()["tokens"] as Map?) ?? {})[address]; - if (raw is! Map) return null; - return b32decode(raw["token"] as String); - } - - void learnToken(String address, Uint8List token) { - _update((state) { - final tokens = (state["tokens"] as Map? ?? {}).cast(); - tokens[address] = { - "token": b32encode(token), - "seenAt": DateTime.now().millisecondsSinceEpoch, - }; - state["tokens"] = tokens; - return state; - }); - } - - // --- read markers --------------------------------------------------------------- - - void markRead(String idHex) { - _update((state) { - final read = (state["read"] as Map? ?? {}).cast(); - read[idHex] = true; - state["read"] = read; - return state; - }); - } - - bool isRead(String idHex) => - ((_load()["read"] as Map?) ?? {})[idHex] == true; - - // --- sealed mail ------------------------------------------------------------ - - Map _recordToMap(MailRecord record) => { - "id": record.id, - "envelope": record.envelope, - "receivedAt": record.receivedAt, - "recipient": record.recipient, - "sentAt": record.sentAt, - "tier": record.tier, - "keptOnServer": record.keptOnServer, - }; - - MailRecord _mapToRecord(Map map) => MailRecord( - map["id"] as String, - (map["envelope"] as Uint8List), - receivedAt: map["receivedAt"] as int?, - recipient: map["recipient"] as String?, - sentAt: map["sentAt"] as int?, - tier: (map["tier"] as int?) ?? tierMain, - keptOnServer: (map["keptOnServer"] as bool?) ?? false, - ); - - static String mailKey(String folder, String id) => "$folder/$id"; - - // "requests" is a view over the same physical "inbox" records, filtered by - // tier (§5.8) — not a separate folder, so a message keeps one identity - // regardless of which tier it arrived in. - static String _physicalFolder(String folder) => - folder == "requests" ? "inbox" : folder; - - Future storeMessage(String folder, MailRecord record) => - _mail.put(mailKey(folder, record.id), _recordToMap(record)); - - /// Returns null when the id already exists, so fetch can leave server - /// state alone. - Future storeIfNew(String folder, MailRecord record) async { - if (_mail.containsKey(mailKey(folder, record.id))) return null; - await storeMessage(folder, record); - return record; - } - - List listMessages(String folder) { - final physical = _physicalFolder(folder); - final prefix = "$physical/"; - final wantTier = folder == "requests" ? tierRequests : tierMain; - final rows = []; - for (final key in _mail.keys.cast()) { - if (!key.startsWith(prefix)) continue; - final row = _mail.get(key); - if (row is! Map) continue; - final record = _mapToRecord(row); - if (physical == "inbox" && record.tier != wantTier) continue; - rows.add(record); + /// The address a key is known by, if any — naming a mailbox is not + /// trusting a key, so nothing is bound here. + String? addressForKey(String keyB32) { + for (final (address, contact) in allContacts()) { + if (contact.key == keyB32) return address; } - rows.sort((a, b) => - (b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0)); - return rows; + return null; } - MailRecord? getMessage(String folder, String id) { - final row = _mail.get(mailKey(_physicalFolder(folder), id)); - return row is Map ? _mapToRecord(row) : null; + // --- pins ------------------------------------------------------------------- + + /// Pins a server's static key (§4): sync, because it is one local write. + void pinServer(String host, String keyB32) => + _ffi.pinServer(_native.store!, host, keyB32); + + String? serverPin(String host) => _ffi.serverPin(_native.store!, host); + + List<(String, String)> allPins() { + final rows = _ffi.pins(_native.store!); + return [ + for (final row in rows.cast>()) + (row["host"] as String, row["key"] as String), + ]; } - Future deleteMessage(String folder, String id) => - _mail.delete(mailKey(_physicalFolder(folder), id)); + Future unpinServer(String host) async => _native.unpinServer(host); -// --- export / import: mail, contacts, pins — never the seed -------------------- + // --- backups ---------------------------------------------------------------- - /// Label kept as gsmol wrote it originally; the export format version - /// (gsmolExport) is what actually changed between v1 and v2. - static final _exportLabel = utf8Bytes("gsmol/1 export"); - Uint8List _exportKey(Uint8List master) => - hkdfSha256(master, Uint8List(0), _exportLabel, 32); + /// The gsmol backup container: sealed mail, contacts and pins — never the + /// master — as one JSON file body. + Future exportData() => _native.exportBackup(); - /// v2 matches gsmol's own current export: the whole payload — mail, - /// contacts, pins — is sealed to a key derived from the identity's master, - /// so a backup file is only readable by whoever holds that master. - /// Deliberately excludes the master itself: it has its own reveal-and-copy - /// flow in settings, meant for a password manager, not a shareable file. - Map exportData() { - final master = this.master(); - if (master == null) throw const SmolError("no identity yet"); - final state = _load(); - final contacts = ((state["contacts"] as Map?) ?? {}).cast(); - final payload = { - "servers": ((state["servers"] as Map?) ?? {}).cast(), - "contacts": { - for (final entry in contacts.entries) - entry.key: { - "key": entry.value["key"], - "verified": entry.value["verified"], - if ((entry.value["history"] as List?)?.isNotEmpty == true) - "history": entry.value["history"], - } - }, - "inbox": [ - for (final row in [...listMessages("inbox"), ...listMessages("requests")]) - { - "id": row.id, - "receivedAt": row.receivedAt, - "envelope": base64Encode(row.envelope), - "tier": row.tier, - "keptOnServer": row.keptOnServer, - } - ], - "sent": [ - for (final row in listMessages("sent")) - { - "id": row.id, - "recipient": row.recipient, - "sentAt": row.sentAt, - "envelope": base64Encode(row.envelope), - } - ], - }; - final nonce = randomBytes(12); - final ciphertext = aeadEncrypt( - _exportKey(master), nonce, utf8Bytes(jsonEncode(payload)), Uint8List(0)); - return { - "gsmolExport": 2, - "exportedAt": DateTime.now().millisecondsSinceEpoch, - "nonce": base64Encode(nonce), - "ciphertext": base64Encode(ciphertext), - }; - } - - /// Never overwrites a trust binding that already differs locally — the same - /// rule refreshContact()/saveReplyAddress() apply elsewhere. A malformed - /// entry is skipped and counted, not fatal: one bad record cannot abort the - /// rest of the import. - Future importData(Map data) async { - Map payload; - if (data["gsmolExport"] == 2) { - final master = this.master(); - if (master == null) { - throw const SmolError("no identity yet — restore it before importing"); - } - try { - final plaintext = aeadDecrypt( - _exportKey(master), - base64Decode(data["nonce"] as String), - base64Decode(data["ciphertext"] as String), - Uint8List(0), - ); - payload = jsonDecode(utf8.decode(plaintext)) as Map; - } catch (_) { - throw const SmolError("couldn't decrypt — exported by a different " - "identity, or the file is corrupted"); - } - } else if (data["gsmolExport"] == 1) { - payload = data; // pre-encryption shape: fields already sit at the top level - } else { + Future importData(String text) async { + final summary = await _native.importBackup(text); + final Map parsed; + try { + parsed = _decodeSummary(summary); + } on Exception { throw const SmolError("not a gsmol export file"); } - final summary = ImportSummary(); - - _update((state) { - final servers = (state["servers"] as Map? ?? {}).cast(); - final incomingPins = payload["servers"] is Map ? payload["servers"] as Map : null; - if (payload["servers"] != null && incomingPins == null) summary.malformed++; - for (final entry in (incomingPins ?? const {}).entries) { - final host = entry.key, key = entry.value; - if (host is! String || key is! String || _pinKeyOk(key) != true) { - summary.malformed++; - continue; - } - if (!servers.containsKey(host)) { - servers[host] = key; - summary.pinsAdded++; - } else if (servers[host] != key) { - summary.pinsConflicted++; - } - } - state["servers"] = servers; - - final contacts = (state["contacts"] as Map? ?? {}).cast(); - final incoming = payload["contacts"] is Map ? payload["contacts"] as Map : null; - if (payload["contacts"] != null && incoming == null) summary.malformed++; - for (final entry in (incoming ?? const {}).entries) { - final address = entry.key, contact = entry.value; - if (address is! String || - contact is! Map || - contact["key"] is! String || - _pinKeyOk(contact["key"] as String) != true) { - summary.malformed++; - continue; - } - if (!contacts.containsKey(address)) { - contacts[address] = { - "key": contact["key"], - "verified": contact["verified"] == true, - "seenAt": DateTime.now().millisecondsSinceEpoch, - if (contact["history"] is List && (contact["history"] as List).isNotEmpty) - "history": contact["history"], - }; - summary.contactsAdded++; - } else if (contacts[address]!["key"] != contact["key"]) { - summary.contactsConflicted++; - } - } - state["contacts"] = contacts; - return state; - }); - - for (final folder in ["inbox", "sent"]) { - final rows = payload[folder] is List ? payload[folder] as List : null; - if (payload[folder] != null && rows == null) summary.malformed++; - for (final row in rows ?? const []) { - try { - final map = row as Map; - final record = MailRecord( - map["id"] as String, - base64Decode(map["envelope"] as String), - receivedAt: folder == "inbox" ? map["receivedAt"] as int? : null, - recipient: folder == "sent" ? map["recipient"] as String? : null, - sentAt: folder == "sent" ? map["sentAt"] as int? : null, - tier: (map["tier"] as int?) ?? tierMain, - keptOnServer: (map["keptOnServer"] as bool?) ?? false, - ); - if (await storeIfNew(folder, record) != null) summary.mailAdded++; - } on Exception { - summary.malformed++; - } on TypeError { - summary.malformed++; - } - } - } - return summary; + final out = ImportSummary(); + out.pinsAdded = parsed["pinsAdded"] as int; + out.pinsConflicted = parsed["pinsConflicted"] as int; + out.contactsAdded = parsed["contactsAdded"] as int; + out.contactsConflicted = parsed["contactsConflicted"] as int; + out.mailAdded = parsed["mailAdded"] as int; + out.malformed = parsed["malformed"] as int; + return out; } - // A pin key must decode to exactly 32 bytes of base32. - bool _pinKeyOk(String key) { - try { - return b32decode(key).length == keyLen; - } on SmolError { - return false; - } - } + Map _decodeSummary(String text) => + jsonDecode(text) as Map; - /// Remove every secret and every stored envelope; the UI must confirm first. + // --- teardown --------------------------------------------------------------- + + /// A full wipe: every secret, envelope and mark. The UI must confirm. + /// The master's bytes are overwritten before their references go — Dart + /// cannot promise zeroed immutable strings, so the master is only ever + /// held as this one mutable buffer. Future wipe() async { - await _state.delete(_stateKey); - await _mail.clear(); - } - - int unreadCount() { - var count = 0; - for (final row in listMessages("inbox")) { - if (!isRead(row.id)) count++; + master()?.fillRange(0, 32, 0); + _native.clearMaster(); + await _meta.delete("master"); + await _read.clear(); + await _meta.delete("master"); + await _native.close(); + try { + final db = File(dbPath); + if (await db.exists()) await db.delete(); + for (final suffix in ["-wal", "-shm"]) { + final side = File("$dbPath$suffix"); + if (await side.exists()) await side.delete(); + } + } on FileSystemException { + // A wipe must not fail on files the store never created. } - return count; - } - - int requestsUnreadCount() { - var count = 0; - for (final row in listMessages("requests")) { - if (!isRead(row.id)) count++; - } - return count; + await _native.open(); } } - -/// The store throws these typed errors so the UI can tell "no identity yet" -/// and "an identity already exists" apart without string matching. -class SmolIdentityExistsException implements Exception { - const SmolIdentityExistsException(); - - @override - String toString() => "an identity already exists; rotate it instead"; -} - -class SmolNoIdentityException implements Exception { - const SmolNoIdentityException(); - - @override - String toString() => "no identity to rotate"; -} diff --git a/lib/smol/transport.dart b/lib/smol/transport.dart deleted file mode 100644 index c768957..0000000 --- a/lib/smol/transport.dart +++ /dev/null @@ -1,115 +0,0 @@ -// The byte pipe to a smolmaild server: raw TCP (dart:io), framed elsewhere. -// Mobile is this app's only target platform, so dart:io is fine here. - -import "dart:async"; -import "dart:io"; -import "dart:typed_data"; - -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; - -/// Buffers the socket's stream and serves exact-length reads, so protocol -/// code never sees a partial frame. -class TcpWire implements Wire { - final Socket _socket; - final _chunks = []; - final _waiters = <_ReadRequest>[]; - int _buffered = 0; - Object? _closed; - late final StreamSubscription _subscription; - - TcpWire(this._socket) { - _subscription = _socket.listen(_onData, - onError: (Object error) => _fail(error), - onDone: () => _fail(const SmolError("server closed the connection"))); - } - - static Future connect(String host, int port) async { - try { - // Mobile networks routinely need longer than a LAN handshake; 30s keeps - // flaky handovers from surfacing as user-facing timeouts. - return TcpWire(await Socket.connect(host, port, - timeout: const Duration(seconds: 30))); - } on SocketException catch (error) { - throw SmolError("cannot reach $host:$port (${error.message})"); - } - } - - void _onData(Uint8List data) { - _chunks.add(data); - _buffered += data.length; - _wake(); - } - - void _wake() { - _waiters.removeWhere((w) { - if (_closed != null) { - w.completer.completeError(_closed!); - return true; - } - if (_buffered >= w.need) { - w.completer.complete(); - return true; - } - return false; - }); - } - - void _fail(Object error) { - _closed = error; - for (final w in _waiters) { - w.completer.completeError(error); - } - _waiters.clear(); - } - - @override - void send(Uint8List bytes) { - if (_closed != null) throw _closed!; - _socket.add(bytes); - } - - @override - void close() { - _subscription.cancel(); - _socket.destroy(); - _fail(const SmolError("connection closed")); - } - - @override - Future readExact(int n) async { - if (_closed != null) throw _closed!; - if (_buffered < n) { - final request = _ReadRequest(n); - _waiters.add(request); - try { - await request.completer.future; - } finally { - _waiters.remove(request); - } - if (_closed != null) throw _closed!; - } - final out = Uint8List(n); - var off = 0; - while (off < n) { - final chunk = _chunks.first; - final take = chunk.length < n - off ? chunk.length : n - off; - out.setRange(off, off + take, chunk); - if (take == chunk.length) { - _chunks.removeAt(0); - } else { - _chunks[0] = Uint8List.sublistView(chunk, take); - } - off += take; - _buffered -= take; - } - return out; - } -} - -class _ReadRequest { - final int need; - final completer = Completer(); - - _ReadRequest(this.need); -} diff --git a/lib/smol/ui.dart b/lib/smol/ui.dart new file mode 100644 index 0000000..dc19f1d --- /dev/null +++ b/lib/smol/ui.dart @@ -0,0 +1,24 @@ +// Display helpers for the keys and ids the native library hands back as +// base32 and hex — formatting only, no protocol meaning. + +import "dart:typed_data"; + +/// Lowercase hex, as the message ids cross the ABI. +String hex(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join(); + +/// The compact human check for a key: the first 20 base32 characters in +/// groups of four, the same shape every smol client shows. +String fingerprint(String keyB32) { + final s = keyB32.substring(0, 20); + return RegExp(".{4}").allMatches(s).map((m) => m[0]).join(" "); +} + +/// The inverse of [hex]; throws on anything that is not even-length hex. +Uint8List unhex(String text) { + if (text.length % 2 != 0) { + throw FormatException("odd-length hex string"); + } + return Uint8List.fromList( + [for (var i = 0; i < text.length; i += 2) int.parse(text.substring(i, i + 2), radix: 16)]); +} diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 0000000..209a64d --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,738 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fumi-core" +version = "0.1.0" +source = "git+ssh://git@code.randogoth.com:2222/randogoth/fumi.git?rev=2d65d66#2d65d66012fa40121e4dc3d8d15db9ffc486569a" +dependencies = [ + "chacha20poly1305", + "data-encoding", + "ed25519-dalek", + "hkdf", + "hmac", + "rand_core", + "rusqlite", + "serde", + "serde_json", + "sha2", + "snow", + "x25519-dalek", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smol_mail_native" +version = "0.1.0" +dependencies = [ + "data-encoding", + "fumi-core", + "rand_core", + "serde", + "serde_json", +] + +[[package]] +name = "snow" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "rand_core", + "rustc_version", + "sha2", + "subtle", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 0000000..adb0e95 --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "smol_mail_native" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +fumi-core = { git = "ssh://git@code.randogoth.com:2222/randogoth/fumi.git", rev = "2d65d66" } +rand_core = { version = "0.6", features = ["getrandom"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +data-encoding = "2" diff --git a/native/src/bin/dnsprobe.rs b/native/src/bin/dnsprobe.rs new file mode 100644 index 0000000..408e224 --- /dev/null +++ b/native/src/bin/dnsprobe.rs @@ -0,0 +1,17 @@ +// Device probe: reproduce the app's DNS failure in isolation. Resolves +// the given host exactly the way fumi-core's connect does — Rust std's +// getaddrinfo — and prints what came back. + +use std::net::ToSocketAddrs; + +fn main() { + let host = std::env::args().nth(1).unwrap_or_else(|| "example.com".into()); + let target = format!("{host}:1961"); + match target.to_socket_addrs() { + Ok(addrs) => { + let ips: Vec = addrs.map(|a| a.to_string()).collect(); + println!("resolved {host}: {ips:?}"); + } + Err(err) => println!("FAILED {host}: {err}"), + } +} diff --git a/native/src/ffi.rs b/native/src/ffi.rs new file mode 100644 index 0000000..04cb17e --- /dev/null +++ b/native/src/ffi.rs @@ -0,0 +1,1205 @@ +//! The C ABI over fumi-core: what the Dart binding links against. +//! +//! Boundary contract (agreed with the library's maintainer): +//! - every entry point catches panics and maps them to code 24, so a panic +//! can never unwind into Dart; +//! - `Error` is a decision enum, so it crosses as stable status codes plus +//! one JSON error slot (`smol_last_error`) carrying the structured +//! payload — the UI must never parse `Display` prose; +//! - ownership is explicit: `Store`, `Account` and cancel flags are opaque +//! handles with free functions, and every string the ABI hands back is +//! freed by `smol_free_string` — Dart's GC runs no Rust destructor. +//! +//! Compound results cross as JSON strings (camelCase), which suits the +//! poll-based UI: a list is one allocation, not a callback per row. +//! +//! Conventions every caller relies on: +//! - message ids are 64 hex characters everywhere, including the delete +//! batch, which takes one JSON array of them; +//! - `at` and `time` are protocol times in epoch seconds; `until` (contact +//! key history) is epoch milliseconds, the gsmol format's unit; `active` +//! is an accepted-state flag (1/0), not a timestamp; +//! - the error slot is thread-local, so it is only readable from the thread +//! that made the failing call — on the Dart side, inside the same +//! Isolate.run closure, never after it returns; +//! - after PANIC the store's lock is poisoned and every later call on the +//! handle returns PANIC; the UI treats that as "close and reopen the +//! store", not as an unsolvable error. + +use std::cell::RefCell; +use std::ffi::{c_char, CStr, CString}; +use std::sync::atomic::AtomicBool; + +use fumi_core::account::Account; +use fumi_core::address::Address; +use fumi_core::client::{self, FetchOptions, SendDraft, TrustChange}; +use fumi_core::crypto::{b32, unb32, KEY_LEN}; +use fumi_core::error::Error; +use fumi_core::store::Store; +use fumi_core::transport::{CERT_LEN, ID_LEN, TIER_MAIN, TIER_REQUESTS}; +use serde::Serialize; + +// --- stable error codes ------------------------------------------------------ +// +// The space is split the way the wire splits it (sec 12, RNS.md sec 13.5): +// 0-10 are the protocol's status codes, verbatim, and every purely local +// failure lives from 64 up — a server-issued RATE_LIMITED (8) arrives over a +// live session and is retried on it, while a local HANDSHAKE_REFUSED (71) +// means there is no session to retry on. The ranges must stay disjoint so +// the poll loop can pick a recovery path by code alone. + +pub const OK: i32 = 0; +pub const MALFORMED: i32 = 1; +pub const BAD_VERSION: i32 = 2; +pub const UNKNOWN_USER: i32 = 3; +pub const AUTH_REQUIRED: i32 = 4; +pub const AUTH_FAILED: i32 = 5; +pub const QUOTA_EXCEEDED: i32 = 6; +pub const TOO_LARGE: i32 = 7; +pub const RATE_LIMITED: i32 = 8; +pub const NOT_PERMITTED: i32 = 9; +pub const INTERNAL_ERROR: i32 = 10; +/// An unassigned status byte the server sent; the raw byte is in the payload. +pub const UNKNOWN_STATUS: i32 = 11; + +pub const NOT_PINNED: i32 = 64; +pub const PIN_MISMATCH: i32 = 65; +pub const KEY_CHANGED: i32 = 66; +pub const NOT_REGISTERED: i32 = 67; +pub const CHAIN_LIMIT: i32 = 68; +pub const SCHEMA_VERSION: i32 = 69; +pub const UNREACHABLE: i32 = 70; +pub const HANDSHAKE_REFUSED: i32 = 71; +pub const STORAGE: i32 = 72; +pub const NOISE: i32 = 73; +pub const IO: i32 = 74; +pub const OTHER: i32 = 75; +pub const PANIC: i32 = 76; + +fn code_of(error: &Error) -> i32 { + match error { + Error::Malformed(_) => MALFORMED, + Error::BadVersion(_) => BAD_VERSION, + Error::UnknownUser(_) => UNKNOWN_USER, + Error::AuthRequired(_) => AUTH_REQUIRED, + Error::AuthFailed(_) => AUTH_FAILED, + Error::QuotaExceeded(_) => QUOTA_EXCEEDED, + Error::TooLarge(_) => TOO_LARGE, + Error::RateLimited(_) => RATE_LIMITED, + Error::NotPermitted(_) => NOT_PERMITTED, + Error::InternalError(_) => INTERNAL_ERROR, + Error::UnknownStatus(_, _) => UNKNOWN_STATUS, + Error::NotPinned { .. } => NOT_PINNED, + Error::PinMismatch { .. } => PIN_MISMATCH, + Error::KeyChanged { .. } => KEY_CHANGED, + Error::NotRegistered => NOT_REGISTERED, + Error::ChainLimit { .. } => CHAIN_LIMIT, + Error::SchemaVersion { .. } => SCHEMA_VERSION, + Error::Unreachable { .. } => UNREACHABLE, + Error::HandshakeRefused { .. } => HANDSHAKE_REFUSED, + Error::Storage(_) => STORAGE, + Error::Noise(_) => NOISE, + Error::Io(_) => IO, + Error::Other(_) => OTHER, + } +} + +/// The structured payload of the last failure on this thread: code, message, +/// and the fields the UI decides on (keys as base32). +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorJson { + code: i32, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pinned: Option, + #[serde(skip_serializing_if = "Option::is_none")] + presented: Option, + #[serde(skip_serializing_if = "Option::is_none")] + address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + known: Option, + #[serde(skip_serializing_if = "Option::is_none")] + offered: Option, + /// The raw byte behind an UNKNOWN_STATUS (code 11). + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, +} + +impl ErrorJson { + fn of(error: &Error) -> ErrorJson { + let message = error.to_string(); + let (host, pinned, presented, address, known, offered, status) = match error { + Error::UnknownStatus(status, _) => { + (None, None, None, None, None, None, Some(*status)) + } + Error::NotPinned { host } => (Some(host.clone()), None, None, None, None, None, None), + Error::Unreachable { host, .. } => (Some(host.clone()), None, None, None, None, None, None), + Error::HandshakeRefused { host, .. } => { + (Some(host.clone()), None, None, None, None, None, None) + } + Error::PinMismatch { + host, + pinned, + presented, + } => ( + Some(host.clone()), + Some(b32(pinned)), + Some(b32(presented)), + None, + None, + None, + None, + ), + Error::KeyChanged { + address, + known, + offered, + } => ( + None, + None, + None, + Some(address.clone()), + Some(b32(known)), + Some(b32(offered)), + None, + ), + _ => (None, None, None, None, None, None, None), + }; + ErrorJson { + code: code_of(error), + message, + host, + pinned, + presented, + address, + known, + offered, + status, + } + } +} + +thread_local! { + static LAST_ERROR: RefCell> = const { RefCell::new(None) }; +} + +/// Runs `op` under the panic guard, recording the result. On success the +/// continuation builds the return value; on failure the error slot is filled +/// and the status code is returned for the Dart side to match on. +fn guarded(op: impl FnOnce() -> Result) -> Result { + LAST_ERROR.with(|slot| *slot.borrow_mut() = None); + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(op)) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => { + let code = code_of(&error); + let json = serde_json::to_string(&ErrorJson::of(&error)) + .unwrap_or_else(|_| format!("{{\"code\":{OTHER},\"message\":\"error\"}}")); + LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(json)); + Err(code) + } + Err(_) => { + LAST_ERROR.with(|slot| { + *slot.borrow_mut() = Some(format!( + "{{\"code\":{PANIC},\"message\":\"the native library panicked; the store is still consistent\"}}" + )) + }); + Err(PANIC) + } + } +} + +/// Every entry point returns an i32 status: 0 ok, otherwise the error code. +fn status(op: impl FnOnce() -> Result<(), Error>) -> i32 { + match guarded(op) { + Ok(()) => OK, + Err(code) => code, + } +} + +/// String-returning entry points: a returned pointer is success (freed by +/// `smol_free_string`), null is failure with the error slot set. +fn json_out(op: impl FnOnce() -> Result) -> *mut c_char { + match guarded(op) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +/// The ABI keeps every input string alive for the duration of the call, so +/// the borrowed lifetime is the call's. +fn text(ptr: *const c_char) -> Result<&'static str, Error> { + if ptr.is_null() { + return Ok(""); + } + unsafe { CStr::from_ptr(ptr) } + .to_str() + .map_err(|_| Error::Other("input is not UTF-8".into())) +} + +fn opt_text(ptr: *const c_char) -> Result, Error> { + if ptr.is_null() { + Ok(None) + } else { + text(ptr).map(Some) + } +} + +fn master_of(ptr: *const u8) -> Result<[u8; KEY_LEN], Error> { + if ptr.is_null() { + return Err(Error::Other("master pointer is null".into())); + } + let bytes = unsafe { std::slice::from_raw_parts(ptr, KEY_LEN) }; + bytes + .try_into() + .map_err(|_| Error::Other("master must be 32 bytes".into())) +} + + +fn ser(value: &T) -> Result { + serde_json::to_string(value) + .map_err(|e| Error::Other(format!("serialization failed: {e}"))) +} + +fn parse_address(raw: &str) -> Result { + Address::parse(raw) +} + +/// Parses an address and applies the caller's dial hint: the IP to dial when +/// the host resolved DNS itself, with the hostname keeping every identity +/// role — pins, proof-of-possession, display. +fn dial_addr(raw: *const c_char, dial: *const c_char) -> Result { + let addr = parse_address(text(raw)?)?; + match opt_text(dial)? { + Some(ip) => Ok(addr.with_dial(ip)), + None => Ok(addr), + } +} + +fn change_name(change: &TrustChange) -> &'static str { + match change { + TrustChange::New { unverified: false } => "new", + TrustChange::New { unverified: true } => "newUnverified", + TrustChange::Rotated => "rotated", + TrustChange::None => "none", + } +} + +// --- the ABI ----------------------------------------------------------------- + +/// The JSON error slot: what the last failed call on this thread reports. +/// Null when the last call succeeded. +#[no_mangle] +pub extern "C" fn smol_last_error() -> *mut c_char { + LAST_ERROR.with(|slot| { + match slot.borrow_mut().take() { + Some(json) => CString::new(json).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + None => std::ptr::null_mut(), + } + }) +} + +/// Frees any string this ABI returned. Strings not freed leak — Dart's GC +/// runs no Rust destructor. +#[no_mangle] +pub extern "C" fn smol_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { drop(CString::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_store_open(path: *const c_char) -> *mut Store { + match guarded(|| Store::open(std::path::Path::new(text(path)?))) { + Ok(store) => Box::into_raw(Box::new(store)), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_store_memory() -> *mut Store { + match guarded(|| Store::open_in_memory()) { + Ok(store) => Box::into_raw(Box::new(store)), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_store_free(ptr: *mut Store) { + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_account_new(master: *const u8, index: u32) -> *mut Account { + match guarded(|| Account::new(master_of(master)?, index)) { + Ok(account) => Box::into_raw(Box::new(account)), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_account_free(ptr: *mut Account) { + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_account_pk(ptr: *mut Account) -> *mut c_char { + match guarded(|| { + let account = unsafe { ptr.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + Ok(b32(&account.me().pk())) + }) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn smol_flag_new() -> *mut AtomicBool { + Box::into_raw(Box::new(AtomicBool::new(false))) +} + +#[no_mangle] +pub extern "C" fn smol_flag_set(ptr: *mut AtomicBool, value: i32) { + if let Some(flag) = unsafe { ptr.as_ref() } { + flag.store(value != 0, std::sync::atomic::Ordering::Relaxed); + } +} + +#[no_mangle] +pub extern "C" fn smol_flag_free(ptr: *mut AtomicBool) { + if !ptr.is_null() { + unsafe { drop(Box::from_raw(ptr)) }; + } +} + +#[no_mangle] +pub extern "C" fn smol_pin_server( + store: *mut Store, + host: *const c_char, + key_b32: *const c_char, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let key: [u8; KEY_LEN] = unb32(text(key_b32)?)? + .try_into() + .map_err(|_| Error::Other("server key must decode to 32 bytes".into()))?; + store.pin_server(text(host)?, &key) + }) +} + +/// The pinned key for a host, base32. Empty string: none pinned. Null: error. +#[no_mangle] +pub extern "C" fn smol_server_pin(store: *mut Store, host: *const c_char) -> *mut c_char { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store + .server_pin(text(host)?)? + .map(|key| b32(&key)) + .unwrap_or_default()) + }) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +/// Saves a contact binding: the settings and reader flows that name a key +/// for an address (import, name-sender, save-reply-address). +#[no_mangle] +pub extern "C" fn smol_contact_save( + store: *mut Store, + address: *const c_char, + key_b32: *const c_char, + verified: i32, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let key: [u8; KEY_LEN] = unb32(text(key_b32)?)? + .try_into() + .map_err(|_| Error::Other("contact key must decode to 32 bytes".into()))?; + store.save_contact(text(address)?, &key, verified != 0) + }) +} + +/// Removes a pin: the next session against that host is trust on first use. +#[no_mangle] +pub extern "C" fn smol_unpin_server(store: *mut Store, host: *const c_char) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + store.unpin_server(text(host)?) + }) +} + +/// Every pin as JSON: [{host, key}]. +#[no_mangle] +pub extern "C" fn smol_pins(store: *mut Store) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + #[derive(Serialize)] + struct Out { + host: String, + key: String, + } + let rows = store + .pins()? + .into_iter() + .map(|(host, key)| Out { host, key: b32(&key) }) + .collect::>(); + ser(&rows) + }) +} + +/// Whether the local accept-token set may replace the server's (sec 4): +/// 1 yes, 0 no, -1 error. +#[no_mangle] +pub extern "C" fn smol_sync_ok(store: *mut Store) -> i32 { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store.sync_ok()?) + }) { + Ok(ok) => ok as i32, + Err(_) => -1, + } +} + +/// The registered address, short form. Empty string: not registered. +#[no_mangle] +pub extern "C" fn smol_store_account(store: *mut Store) -> *mut c_char { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store.account()?.map(|addr| addr.short()).unwrap_or_default()) + }) { + Ok(text) => CString::new(text).map(CString::into_raw).unwrap_or(std::ptr::null_mut()), + Err(_) => std::ptr::null_mut(), + } +} + +/// Binds the account locally: what register and restore do at their end, +/// exposed for hosts and test harnesses that need to set the state directly. +#[no_mangle] +pub extern "C" fn smol_account_set(store: *mut Store, address: *const c_char) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + store.set_account(&parse_address(text(address)?)?) + }) +} + +/// The rotation index the account is at; -1 on error. +#[no_mangle] +pub extern "C" fn smol_rotations(store: *mut Store) -> i32 { + match guarded(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + Ok(store.rotations()? as i32) + }) { + Ok(index) => index, + Err(_) => -1, + } +} + +#[no_mangle] +pub extern "C" fn smol_register( + store: *mut Store, + account: *mut Account, + address: *const c_char, + invite: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = dial_addr(address, dial)?; + client::register(store, &addr, account, opt_text(invite)?, timeout) + }) +} + +/// Recovers local state from the master alone; the rotation index lands in +/// the store, so the account handle must be rebuilt afterwards. +#[no_mangle] +pub extern "C" fn smol_restore( + store: *mut Store, + master: *const u8, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let addr = dial_addr(address, dial)?; + client::restore(store, &master_of(master)?, &addr, timeout)?; + Ok(()) + }) +} + +#[no_mangle] +pub extern "C" fn smol_rotate( + store: *mut Store, + account: *mut Account, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let rotated = client::rotate(store, account, opt_text(dial)?, timeout)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out<'a> { + new_pk: String, + cert: &'a str, + } + Ok(ser(&Out { + new_pk: b32(&rotated.new_pk), + cert: &hex(&rotated.cert), + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_fetch( + store: *mut Store, + account: *mut Account, + keep: i32, + reset: i32, + dial: *const c_char, + timeout: u64, + cancel: *mut AtomicBool, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let cancel = unsafe { cancel.as_ref() }; + let summary = client::fetch_with( + store, + account, + FetchOptions { + keep: keep != 0, + reset: reset != 0, + dial: opt_text(dial)?, + timeout, + cancel, + }, + )?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + total: u32, + stored: u32, + rejected: Vec<(String, String)>, + acknowledged: bool, + cancelled: bool, + } + Ok(ser(&Out { + total: summary.total, + stored: summary.stored, + rejected: summary + .rejected + .iter() + .map(|r| (hex(&r.id), r.reason.clone())) + .collect(), + acknowledged: summary.acknowledged, + cancelled: summary.cancelled, + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_send( + store: *mut Store, + account: *mut Account, + to: *const c_char, + subject: *const c_char, + body: *const c_char, + reply_to: *const c_char, + anonymous: i32, + no_pad: i32, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = dial_addr(to, dial)?; + let draft = SendDraft { + address: &addr, + text: text(body)?.to_string(), + subject: opt_text(subject)?, + reply_to: opt_text(reply_to)?, + headers: &[], + anonymous: anonymous != 0, + no_pad: no_pad != 0, + }; + let sent = client::send(store, account, &draft, timeout)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + id: String, + bytes: usize, + token_used: bool, + change: &'static str, + warning: Option, + } + Ok(ser(&Out { + id: hex(&sent.id), + bytes: sent.bytes, + token_used: sent.token_used, + change: change_name(&sent.change), + warning: sent.warning, + })?) + }) +} + +/// Deletes on the server by message id: one JSON array of 64-hex-character +/// ids — the same shape `smol_mail` and `smol_describe` speak, so no caller +/// hex-decodes solely to delete. A missing or empty array is a caller bug +/// and surfaces as an error rather than silent success. +#[no_mangle] +pub extern "C" fn smol_delete( + store: *mut Store, + account: *mut Account, + ids_json: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let ids: Vec = serde_json::from_str(text(ids_json)?) + .map_err(|_| Error::Other("delete takes a JSON array of hex message ids".into()))?; + if ids.is_empty() { + return Err(Error::Other("delete was called with no message ids".into())); + } + let mut parsed = Vec::with_capacity(ids.len()); + for id in &ids { + parsed.push( + unhex(id).and_then(|bytes| <[u8; ID_LEN]>::try_from(bytes).ok()).ok_or_else( + || Error::Other("message ids must be 64 hex characters".into()), + )?, + ); + } + client::delete(store, account, &parsed, opt_text(dial)?, timeout)?; + Ok(()) + }) +} + +/// One folder as JSON: "inbox", "requests", "sent" or "all". +#[no_mangle] +pub extern "C" fn smol_mail(store: *mut Store, folder: *const c_char) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let folder = text(folder)?; + // Only the inbox folders have a tier; sent and all rows omit the + // field rather than fabricate one a badge could mislabel. + let tier = match folder { + "inbox" => Some(TIER_MAIN), + "requests" => Some(TIER_REQUESTS), + _ => None, + }; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Row { + id: String, + envelope: String, + at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + tier: Option, + kept: bool, + recipient: Option, + } + let rows: Vec = store + .mail(folder)? + .into_iter() + .map(|row| Row { + id: hex(&row.id), + envelope: data_encoding::BASE64.encode(&row.envelope), + at: row.at, + tier, + kept: row.kept, + recipient: row.recipient, + }) + .collect(); + Ok(ser(&rows)?) + }) +} + +/// Removes messages locally — the reader's delete, distinct from the +/// server delete above. Each id is also marked seen (sec 10), so a still-kept +/// server copy is not re-stored on the next fetch: deleting locally must not +/// resurrect the message. +#[no_mangle] +pub extern "C" fn smol_delete_local( + store: *mut Store, + folder: *const c_char, + ids_json: *const c_char, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let folder = text(folder)?; + let table = match folder { + "sent" => "sent", + "inbox" | "requests" | "all" => "inbox", + _ => return Err(Error::Other("folder must be inbox, requests, sent or all".into())), + }; + let ids: Vec = serde_json::from_str(text(ids_json)?) + .map_err(|_| Error::Other("delete takes a JSON array of hex message ids".into()))?; + if ids.is_empty() { + return Err(Error::Other("delete was called with no message ids".into())); + } + for id in &ids { + let id: [u8; ID_LEN] = unhex(id) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| Error::Other("message ids must be 64 hex characters".into()))?; + store.delete_local(table, &id)?; + } + Ok(()) + }) +} + +/// Parses one address for the UI: {user, host, port, short, scheme}. Null +/// plus the error slot when it does not parse — the onboarding's field +/// validation and the compose screen's recipient check both live here. +#[no_mangle] +pub extern "C" fn smol_parse_address(raw: *const c_char) -> *mut c_char { + json_out(|| { + let addr = parse_address(text(raw)?)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + user: String, + host: String, + port: u16, + short: String, + scheme: &'static str, + /// The self-certifying key a smol:// URI carries, when present. + identity: Option, + } + ser(&Out { + user: addr.user.clone(), + host: addr.host.clone(), + port: addr.port, + short: addr.short(), + scheme: match addr.scheme { + fumi_core::address::Scheme::Tcp => "tcp", + fumi_core::address::Scheme::Rns => "rns", + }, + identity: addr.identity.map(|key| b32(&key)), + }) + }) +} + +/// Opens one sealed message: the described view the reader shows. +#[no_mangle] +pub extern "C" fn smol_describe( + store: *mut Store, + account: *mut Account, + id_hex: *const c_char, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let id: [u8; ID_LEN] = unhex(text(id_hex)?) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| Error::Other("message id must be 64 hex characters".into()))?; + let stored = store + .mail("all")? + .into_iter() + .chain(store.mail("sent")?) + .find(|row| row.id == id) + .ok_or_else(|| Error::Other("no such message".into()))?; + let described = client::describe(store, account, &stored)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + sender: String, + time: i64, + subject: String, + fields: std::collections::BTreeMap, + text: String, + from: String, + } + Ok(ser(&Out { + sender: b32(&described.sender), + time: described.time, + subject: described.subject, + fields: described.fields, + text: described.text, + from: described.from, + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_contacts(store: *mut Store) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct History { + key: String, + until: i64, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + address: String, + key: String, + verified: bool, + active: Option, + /// The identity frozen at acceptance (sec 5.8), which the token + /// is derived from — it can differ from the current key after a + /// rotation. + accepted_key: Option, + history: Vec, + } + let mut rows = Vec::new(); + for (address, key, verified, active) in store.contact_rows()? { + let history = store + .history(&address)? + .into_iter() + .map(|(key, until)| History { key: b32(&key), until }) + .collect(); + let accepted_key = store + .accepted_identity(&address)? + .map(|identity| b32(&identity)); + rows.push(Out { + address, + key: b32(&key), + verified, + active: active.map(|a| a != 0), + accepted_key, + history, + }); + } + Ok(ser(&rows)?) + }) +} + +/// One contact by address: {key, verified, history}. Empty object: unknown. +#[no_mangle] +pub extern "C" fn smol_contact(store: *mut Store, address: *const c_char) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let address = text(address)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + key: String, + verified: bool, + active: Option, + accepted_key: Option, + history: Vec<(String, i64)>, + } + let Some((key, verified)) = store.contact(address)? else { + return Ok(ser(&Out { + key: String::new(), + verified: false, + active: None, + accepted_key: None, + history: Vec::new(), + })?); + }; + Ok(ser(&Out { + key: b32(&key), + verified, + active: store.accepted_state(address)?, + accepted_key: store + .accepted_identity(address)? + .map(|identity| b32(&identity)), + history: store + .history(address)? + .into_iter() + .map(|(key, until)| (b32(&key), until)) + .collect(), + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_accept( + store: *mut Store, + account: *mut Account, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = parse_address(text(address)?)?; + let identity = store + .contact(&addr.short())? + .ok_or_else(|| Error::Other("no key for that address; resolve first".into()))? + .0; + store.accept(&addr.short(), &identity)?; + match client::push_tokens(store, account, opt_text(dial)?, timeout)? { + client::Pushed::Held(held) => Ok(format!("{{\"held\":{held}}}")), + client::Pushed::NotRegistered => Ok("{\"held\":0}".to_string()), + } + }) +} + +#[no_mangle] +pub extern "C" fn smol_block( + store: *mut Store, + account: *mut Account, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let account = unsafe { account.as_ref().ok_or_else(|| Error::Other("account handle is null".into()))? }; + let addr = parse_address(text(address)?)?; + store.block(&addr.short())?; + client::push_tokens(store, account, opt_text(dial)?, timeout)?; + Ok(()) + }) +} + +/// Resolves a contact and applies the sec 8 trust rules; a key change +/// without a chain fails with KEY_CHANGED carrying known and offered. +#[no_mangle] +pub extern "C" fn smol_resolve( + store: *mut Store, + address: *const c_char, + dial: *const c_char, + timeout: u64, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let addr = dial_addr(address, dial)?; + let mut session = client::connect(store, &addr, true, timeout)?; + let (key, change) = { + let transport = session.transport(); + client::trust_key(store, transport, &addr)? + }; + session.close(); + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + key: String, + change: &'static str, + } + Ok(ser(&Out { + key: b32(&key), + change: change_name(&change), + })?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_import_contact(store: *mut Store, uri: *const c_char) -> i32 { + status(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let addr = parse_address(text(uri)?)?; + let key = addr + .identity + .ok_or_else(|| Error::Other("import needs a self-certifying address carrying a key".into()))?; + store.save_contact(&addr.short(), &key, true)?; + Ok(()) + }) +} + +#[no_mangle] +pub extern "C" fn smol_export_backup(store: *mut Store, master: *const u8) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + fumi_core::export::export(store, &master_of(master)?) + }) +} + +#[no_mangle] +pub extern "C" fn smol_import_backup( + store: *mut Store, + master: *const u8, + backup: *const c_char, +) -> *mut c_char { + json_out(|| { + let store = unsafe { store.as_ref().ok_or_else(|| Error::Other("store handle is null".into()))? }; + let summary = fumi_core::export::import(store, &master_of(master)?, text(backup)?)?; + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Out { + pins_added: usize, + pins_conflicted: usize, + contacts_added: usize, + contacts_conflicted: usize, + mail_added: usize, + malformed: usize, + } + ser(&Out { + pins_added: summary.pins_added, + pins_conflicted: summary.pins_conflicted, + contacts_added: summary.contacts_added, + contacts_conflicted: summary.contacts_conflicted, + mail_added: summary.mail_added, + malformed: summary.malformed, + }) + }) +} + +/// The smoke entry point from the spike: a full store round-trip through the +/// cdylib. 0 = round-trip held. +#[no_mangle] +pub extern "C" fn smol_smoke() -> i32 { + let addr = match Address::parse("alice@example.org") { + Ok(addr) => addr, + Err(_) => return 1, + }; + let store = match Store::open_in_memory() { + Ok(store) => store, + Err(_) => return 2, + }; + if store.set_account(&addr).is_err() { + return 3; + } + match store.account() { + Ok(Some(back)) if back.short() == addr.short() => 0, + Ok(Some(_)) => 4, + Ok(None) => 5, + Err(_) => 6, + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(text: &str) -> Option> { + if text.len() % 2 != 0 { + return None; + } + (0..text.len() / 2) + .map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).ok()) + .collect() +} + +// CERT_LEN is part of the rotation result's shape; keep the import honest. +const _: () = assert!(CERT_LEN == 200); + +#[cfg(test)] +mod tests { + use super::*; + + /// Reads one returned buffer into an owned String and frees the buffer — + /// the ownership rule every caller of this ABI owes a returned pointer. + fn take(ptr: *mut c_char) -> String { + assert!(!ptr.is_null(), "a null return means the call failed"); + let text = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap().to_string(); + smol_free_string(ptr); + text + } + + #[test] + fn smoke_through_the_abi() { + assert_eq!(smol_smoke(), 0); + } + + #[test] + fn local_delete_and_address_parse() { + let store = smol_store_memory(); + let s = unsafe { &*store }; + let id = [5u8; ID_LEN]; + s.store_inbox(&id, b"sealed", 9, false, true).unwrap(); + assert_eq!(s.mail("all").unwrap().len(), 1); + + // Local removal marks the id seen, so a refetch cannot re-store it. + let ids = CString::new(format!("[\"{}\"]", hex(&id))).unwrap(); + let code = smol_delete_local(store, c"inbox".as_ptr(), ids.as_ptr()); + assert_eq!(code, OK); + assert!(s.mail("all").unwrap().is_empty()); + assert!(s.seen(&id).unwrap()); + // Caller bugs surface, same as the server delete. + assert_eq!( + smol_delete_local(store, c"inbox".as_ptr(), c"[]".as_ptr()), + OTHER + ); + + // Address parsing for the UI: full shape, or an error with the slot. + let parsed = smol_parse_address(c"alice@example.org:1961".as_ptr()); + let parsed = take(parsed); + let json: serde_json::Value = serde_json::from_str(&parsed).unwrap(); + assert_eq!(json["user"], "alice"); + assert_eq!(json["host"], "example.org"); + assert_eq!(json["port"], 1961); + // The default port is elided in the short form, as everywhere else. + assert_eq!(json["short"], "alice@example.org"); + assert_eq!(json["scheme"], "tcp"); + assert!(smol_parse_address(c"not an address".as_ptr()).is_null()); + + smol_store_free(store); + } + + #[test] + fn delete_surfaces_caller_bugs() { + let store = smol_store_memory(); + let master = [7u8; KEY_LEN]; + let account = smol_account_new(master.as_ptr(), 0); + // An empty array, a non-JSON argument and a wrong-length id are all + // caller bugs: errors, never silent success. + assert_eq!(smol_delete(store, account, c"[]".as_ptr(), std::ptr::null(), 5), OTHER); + assert_eq!(smol_delete(store, account, c"not json".as_ptr(), std::ptr::null(), 5), OTHER); + assert_eq!( + smol_delete(store, account, c"[\"abcd\"]".as_ptr(), std::ptr::null(), 5), + OTHER + ); + smol_account_free(account); + smol_store_free(store); + } + + #[test] + fn handles_round_trip_and_errors_carry_codes() { + let store = smol_store_memory(); + assert!(!store.is_null()); + let master = [5u8; KEY_LEN]; + let account = smol_account_new(master.as_ptr(), 0); + assert!(!account.is_null()); + let pk = take(smol_account_pk(account)); + assert_eq!(pk.len(), 52); + + // No pin: NotPinned with the host in the slot, by code, not prose. + let code = smol_register(store, account, c"nobody@127.0.0.1:19619".as_ptr(), std::ptr::null(), std::ptr::null(), 5); + assert_eq!(code, NOT_PINNED); + let slot = take(smol_last_error()); + let json: serde_json::Value = serde_json::from_str(&slot).unwrap(); + assert_eq!(json["code"], NOT_PINNED); + assert_eq!(json["host"], "127.0.0.1"); + + // Strings returned on success must be freed without double-free. + assert_eq!(take(smol_store_account(store)), ""); + + let flag = smol_flag_new(); + smol_flag_set(flag, 1); + smol_flag_free(flag); + smol_account_free(account); + smol_store_free(store); + } + + #[test] + fn mail_and_contacts_render_as_json() { + let store = smol_store_memory(); + let s = unsafe { &*store }; + s.pin_server("example.org", &[1u8; KEY_LEN]).unwrap(); + s.save_contact("alice@example.org", &[2u8; KEY_LEN], true).unwrap(); + s.save_history("alice@example.org", &[9u8; KEY_LEN], 500).unwrap(); + s.store_inbox(&[3u8; ID_LEN], b"sealed", 7, false, true).unwrap(); + + let folder = take(smol_mail(store, c"all".as_ptr())); + let rows: serde_json::Value = serde_json::from_str(&folder).unwrap(); + assert_eq!(rows[0]["id"], hex(&[3u8; ID_LEN])); + assert_eq!(rows[0]["kept"], true); + + let contacts = take(smol_contacts(store)); + let list: serde_json::Value = serde_json::from_str(&contacts).unwrap(); + assert_eq!(list[0]["address"], "alice@example.org"); + assert_eq!(list[0]["verified"], true); + assert_eq!(list[0]["history"][0]["until"], 500); + + smol_store_free(store); + } +} diff --git a/native/src/lib.rs b/native/src/lib.rs new file mode 100644 index 0000000..faec893 --- /dev/null +++ b/native/src/lib.rs @@ -0,0 +1,450 @@ +// C ABI over fumi-core for the Dart FFI binding; the app links the cdylib. +// One smoke function for the spike; the real surface lives in `ffi`. + +pub mod ffi; + +#[cfg(test)] +mod tests { + use fumi_core::{account::Identity, message}; + use rand_core::{OsRng, RngCore}; + use std::time::Instant; + + #[test] + fn store_round_trip() { + assert_eq!(crate::ffi::smol_smoke(), 0); + } + + /// Spike benchmark: seal/unseal throughput with a 2 KiB body, to compare + /// against the Dart core's numbers (test/perf_smoke_test.dart). Print + /// only; the assertions pin correctness, not speed. + #[test] + fn envelope_perf() { + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let sender = Identity::from_seed(seed); + OsRng.fill_bytes(&mut seed); + let recipient = Identity::from_seed(seed); + let body = vec![b'x'; 2048]; + const N: usize = 100; + + let t0 = Instant::now(); + let envelopes: Vec> = (0..N) + .map(|i| message::seal(&sender, &recipient.pk(), &body, i as i64, true).unwrap()) + .collect(); + let seal_dt = t0.elapsed(); + + let t1 = Instant::now(); + for (i, envelope) in envelopes.iter().enumerate() { + let opened = message::unseal(&[recipient.clone()], envelope, 0).unwrap(); + assert_eq!(opened.body.len(), 2048); + assert_eq!(opened.sender, sender.pk()); + assert_eq!(opened.time, i as i64); + } + let open_dt = t1.elapsed(); + + println!( + "rust: seal {N} in {seal_dt:?} ({:.0} us/msg), unseal {N} in {open_dt:?} ({:.0} us/msg)", + seal_dt.as_micros() as f64 / N as f64, + open_dt.as_micros() as f64 / N as f64, + ); + } + + /// Spike round-trip against a live bunshin on 127.0.0.1:19619; ignored + /// because it needs the server (bunshin serve --key ... --port 19619). + /// Covers the embeddable path end to end: pin, REGISTER, SEND, FETCH. + #[test] + #[ignore] + fn bunshin_round_trip() { + use fumi_core::account::Account; + use fumi_core::address::Address; + use fumi_core::client::{fetch, register, send, SendDraft}; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + + let mut master = [0u8; 32]; + let mut identity = || { + OsRng.fill_bytes(&mut master); + (Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap()) + }; + let (alice_store, alice) = identity(); + let (bob_store, bob) = identity(); + + // Random usernames: the server persists registrations, so fixed ones + // would collide on the second run of this test. + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + let run = fumi_core::crypto::b32(&suffix); + let alice_addr = + Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); + let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); + alice_store.pin_server("127.0.0.1", &server).unwrap(); + bob_store.pin_server("127.0.0.1", &server).unwrap(); + + register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); + register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); + + let draft = SendDraft { + address: &bob_addr, + text: "spike: hello over fumi".into(), + subject: Some("spike"), + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + let sent = send(&alice_store, &alice, &draft, 5).unwrap(); + assert_eq!(sent.warning, None); + + let fetched = fetch(&bob_store, &bob, false, false, 5).unwrap(); + assert_eq!(fetched.stored, 1); + // First contact without an accept token lands in the requests tier + // (sec 5.8), not the inbox. + let stored = &bob_store.mail("requests").unwrap()[0]; + let opened = fumi_core::client::describe(&bob_store, &bob, stored).unwrap(); + assert_eq!(opened.text, "spike: hello over fumi"); + assert_eq!(opened.subject, "spike"); + assert_eq!(opened.sender, alice.keys()[0].pk()); + assert_eq!(opened.from, alice_addr.short()); + + // sec 5.6, the cross-recipient case self-sends mask: the sent copy + // is sealed to the sender's own key, so alice can read back what she + // sent to bob even though bob's envelope used a discarded ephemeral. + let sent = &alice_store.mail("sent").unwrap()[0]; + let reread = + fumi_core::client::describe(&alice_store, &alice, sent).unwrap(); + assert_eq!(reread.text, "spike: hello over fumi"); + assert_eq!(reread.subject, "spike"); + } + + /// Spike: cancellation and resume, against the same live bunshin. Four + /// ~400 KiB envelopes exceed the server's 512 KiB fetch budget, so each + /// page carries one message and the between-page cancel check is + /// reachable. Phase A is deterministic (flag pre-set); phase B cancels + /// from a watcher thread the moment the first message commits — which is + /// also the concurrent-reader check, since the store is read while the + /// fetch holds it. + #[test] + #[ignore] + fn bunshin_cancel_and_resume() { + use fumi_core::account::Account; + use fumi_core::address::Address; + use fumi_core::client::{fetch, fetch_with, register, send, FetchOptions, SendDraft}; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + + let mut master = [0u8; 32]; + let mut identity = || { + OsRng.fill_bytes(&mut master); + (Store::open_in_memory().unwrap(), Account::new(master, 0).unwrap()) + }; + let (alice_store, alice) = identity(); + let bob_store = Arc::new(Store::open_in_memory().unwrap()); + let mut bob_master = [0u8; 32]; + OsRng.fill_bytes(&mut bob_master); + let bob = Account::new(bob_master, 0).unwrap(); + + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + let run = fumi_core::crypto::b32(&suffix); + let alice_addr = Address::parse(&format!("a{run}@127.0.0.1:19619")).unwrap(); + let bob_addr = Address::parse(&format!("b{run}@127.0.0.1:19619")).unwrap(); + alice_store.pin_server("127.0.0.1", &server).unwrap(); + bob_store.pin_server("127.0.0.1", &server).unwrap(); + register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); + register(&bob_store, &bob_addr, &bob, None, 5).unwrap(); + + let big = "x".repeat(400 * 1024); + for i in 0..4 { + let draft = SendDraft { + address: &bob_addr, + text: format!("{i}\n{big}"), + subject: None, + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + send(&alice_store, &alice, &draft, 5).unwrap(); + } + + // Phase A: the flag is checked before the first page, so nothing is + // fetched and nothing is acknowledged. + let cancel = AtomicBool::new(true); + let a = fetch_with( + &bob_store, + &bob, + FetchOptions { + keep: false, + reset: false, + dial: None, + timeout: 5, + cancel: Some(&cancel), + }, + ) + .unwrap(); + assert!(a.cancelled); + assert_eq!(a.stored, 0); + + // Phase B: a reader thread watches the store fill and raises the + // flag after the first commit; the fetch stops between pages. + cancel.store(false, Ordering::Relaxed); + let flag = Arc::new(AtomicBool::new(false)); + let thread_flag = Arc::clone(&flag); + let watcher_store = Arc::clone(&bob_store); + let watcher = std::thread::spawn(move || { + while watcher_store.mail("all").unwrap().len() < 1 { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + thread_flag.store(true, Ordering::Relaxed); + }); + let b = fetch_with( + &bob_store, + &bob, + FetchOptions { + keep: false, + reset: false, + dial: None, + timeout: 5, + cancel: Some(&flag), + }, + ) + .unwrap(); + assert!(b.cancelled, "watcher should have raised the flag mid-fetch"); + assert!(b.stored >= 1 && b.stored < 4); + watcher.join().unwrap(); + + // Resume: an uncancelled fetch delivers the rest, and the seen-id + // set keeps the acknowledged pages from coming back as duplicates. + let c = fetch(&bob_store, &bob, false, false, 5).unwrap(); + assert_eq!(b.stored + c.stored, 4); + assert_eq!(bob_store.mail("all").unwrap().len(), 4); + } + + /// The Android regression: a hostname the native resolver cannot + /// resolve (the device's getaddrinfo is dead for app processes), pinned + /// by name, dialed by the IP the app resolved itself. Registration must + /// succeed and the account must keep the hostname identity. + #[test] + #[ignore] + fn bunshin_dial_hint_routes_by_ip() { + use fumi_core::client::register; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + let mut master = [0u8; 32]; + OsRng.fill_bytes(&mut master); + let store = Store::open_in_memory().unwrap(); + let account = fumi_core::account::Account::new(master, 0).unwrap(); + + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + let run = fumi_core::crypto::b32(&suffix); + let host = format!("unresolvable-{run}.invalid"); + let addr = + fumi_core::address::Address::parse(&format!("u{run}@{host}:19619")) + .unwrap() + .with_dial("127.0.0.1"); + store.pin_server(&host, &server).unwrap(); + register(&store, &addr, &account, None, 5).unwrap(); + assert_eq!(store.account().unwrap().unwrap().short(), + format!("u{run}@{host}:19619")); + } + + /// Spike: the error variants the onboarding routes on. No pin and a + /// wrong pin are distinct and matchable — the two branches that decide + /// whether the register step frames itself as "pin the key" (§4) or + /// aborts. Needs the live bunshin but changes no server state. + #[test] + #[ignore] + fn bunshin_pin_errors() { + use fumi_core::address::Address; + use fumi_core::client::connect; + use fumi_core::error::Error; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let addr = Address::parse("nobody@127.0.0.1:19619").unwrap(); + + let unpinned = Store::open_in_memory().unwrap(); + match connect(&unpinned, &addr, true, 5) { + Err(Error::NotPinned { host }) => assert_eq!(host, "127.0.0.1"), + Err(err) => panic!("expected NotPinned, got {err}"), + Ok(_) => panic!("expected NotPinned, connected"), + } + + let mut wrong = [0u8; KEY_LEN]; + OsRng.fill_bytes(&mut wrong); + let mismatched = Store::open_in_memory().unwrap(); + mismatched.pin_server("127.0.0.1", &wrong).unwrap(); + match connect(&mismatched, &addr, true, 5) { + Err(Error::PinMismatch { .. }) => {} + Err(err) => panic!("expected PinMismatch, got {err}"), + Ok(_) => panic!("expected PinMismatch, connected"), + } + } + + /// Spike, bright path: a real rotation validates through the chain and + /// surfaces as `TrustChange::Rotated` — a warning, not an error. Carol + /// registers, Alice learns her key by sending, Carol rotates, and + /// Alice's next resolve walks the chain the server returns. + #[test] + #[ignore] + fn bunshin_rotation_bright() { + use fumi_core::account::Account; + use fumi_core::address::Address; + use fumi_core::client::{connect, register, rotate, send, trust_key, SendDraft}; + use fumi_core::crypto::unb32; + use fumi_core::store::Store; + use fumi_core::transport::KEY_LEN; + use rand_core::{OsRng, RngCore}; + + let server: [u8; KEY_LEN] = unb32( + "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + ) + .unwrap() + .try_into() + .unwrap(); + + let mut carol_master = [0u8; 32]; + OsRng.fill_bytes(&mut carol_master); + let mut alice_master = [0u8; 32]; + OsRng.fill_bytes(&mut alice_master); + let carol_store = Store::open_in_memory().unwrap(); + let alice_store = Store::open_in_memory().unwrap(); + let carol = Account::new(carol_master, 0).unwrap(); + let alice = Account::new(alice_master, 0).unwrap(); + + let mut suffix = [0u8; 2]; + OsRng.fill_bytes(&mut suffix); + let run = fumi_core::crypto::b32(&suffix); + let carol_addr = Address::parse(&format!("c{run}@127.0.0.1:19619")).unwrap(); + let alice_addr = Address::parse(&format!("d{run}@127.0.0.1:19619")).unwrap(); + carol_store.pin_server("127.0.0.1", &server).unwrap(); + alice_store.pin_server("127.0.0.1", &server).unwrap(); + register(&carol_store, &carol_addr, &carol, None, 5).unwrap(); + register(&alice_store, &alice_addr, &alice, None, 5).unwrap(); + + let draft = SendDraft { + address: &carol_addr, + text: "before the rotation".into(), + subject: None, + reply_to: None, + headers: &[], + anonymous: false, + no_pad: false, + }; + let sent = send(&alice_store, &alice, &draft, 5).unwrap(); + assert!(matches!( + sent.change, + fumi_core::client::TrustChange::New { unverified: false } + )); + + rotate(&carol_store, &carol, None, 5).unwrap(); + let carol_next = Account::new(carol_master, 1).unwrap(); + + let mut session = connect(&alice_store, &carol_addr, true, 5).unwrap(); + let (pk, change) = { + let transport = session.transport(); + trust_key(&alice_store, transport, &carol_addr).unwrap() + }; + session.close(); + assert!(matches!(change, fumi_core::client::TrustChange::Rotated)); + assert_eq!(pk, carol_next.me().pk()); + } + + /// Spike, dark twin: RESOLVE returns a key with no chain to the one we + /// hold — the state a hijacked or re-registered username produces, and + /// the branch the onboarding must treat as terminal until the user + /// verifies out of band. A mock transport stands in for the server, so + /// this needs no live bunshin. + #[test] + fn keychanged_dark_twin() { + use fumi_core::address::Address; + use fumi_core::client::trust_key; + use fumi_core::error::Error; + use fumi_core::store::Store; + use fumi_core::transport::{ + Response, Transport, TransportBindValues, KEY_LEN, OP_RESOLVE, + }; + use rand_core::{OsRng, RngCore}; + + struct MockResolve { + offered: [u8; KEY_LEN], + bind: TransportBindValues, + } + impl Transport for MockResolve { + fn request(&mut self, op: u8, _body: &[u8]) -> Result { + assert_eq!(op, OP_RESOLVE); + let mut body = Vec::with_capacity(KEY_LEN + 1); + body.extend_from_slice(&self.offered); + body.push(0); // no chain at all + Ok(Response { status: 0, body }) + } + fn bind(&self) -> &TransportBindValues { + &self.bind + } + fn pinned(&self) -> bool { + true + } + fn close(&mut self) {} + } + + let addr = Address::parse("dark@127.0.0.1:19619").unwrap(); + let mut known = [0u8; KEY_LEN]; + OsRng.fill_bytes(&mut known); + let mut offered = [0u8; KEY_LEN]; + OsRng.fill_bytes(&mut offered); + + let store = Store::open_in_memory().unwrap(); + store.save_contact(&addr.short(), &known, false).unwrap(); + let mut mock = MockResolve { + offered, + bind: TransportBindValues { + h: [0u8; 32], + server_static: [0u8; 32], + }, + }; + match trust_key(&store, &mut mock, &addr) { + Err(Error::KeyChanged { + address, + known: k, + offered: o, + }) => { + assert_eq!(address, addr.short()); + assert_eq!(k, known); + assert_eq!(o, offered); + } + Err(err) => panic!("expected KeyChanged, got {err}"), + Ok(_) => panic!("expected KeyChanged, trusted the new key"), + } + } +} diff --git a/test/e2e_test.dart b/test/e2e_test.dart index f137bfe..2c67ea3 100644 --- a/test/e2e_test.dart +++ b/test/e2e_test.dart @@ -1,26 +1,32 @@ -// End-to-end against a live reference server: register an address on a -// locally running smolmaild, send sealed messages to ourselves, fetch them -// back, exercise the accept-token round trip (§5.8) between the requests and -// main tiers, and check the server is drained afterwards. Skips when nothing -// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server. +// End-to-end against a live server: register an address, send sealed mail to +// ourselves, fetch it back, exercise the accept-token round trip (§5.8) +// between the requests and main tiers, restore onto a second store, and +// check the server is drained afterwards. Skips when nothing listens on +// 127.0.0.1:1961, so `devbox run test` does not depend on a server. // -// To run it: (cd ../smolmail && uv run smolmaild.py keygen --key server.key && -// uv run smolmaild.py serve --key server.key --db mail.db) -// then `devbox run test`. +// The server key is pinned directly: on this machine the bunshin.service +// static key is a documented, operator-supplied value — exactly the trusted +// channel SPEC.md §4 asks a pin to come from. import "dart:io"; +import "dart:math"; import "dart:typed_data"; + import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/ui.dart"; const host = "127.0.0.1"; const port = 1961; +const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + +Uint8List randomBytes(int n) => + Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); Future serverUp() async { try { @@ -33,35 +39,31 @@ Future serverUp() async { } } +Future freshStore(String tag, String dir) => SmolStore.open( + dbPath: "$dir/$tag.db", stateBox: "$tag-state", readBox: "$tag-read"); + void main() { test("register, send to self, fetch, unseal, drain", () async { if (!await serverUp()) { - markTestSkipped("no smolmaild on $host:$port"); + markTestSkipped("no server on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e"); Hive.init(dir.path); - final store = await SmolStore.open(); + final store = await freshStore("main", dir.path); final client = SmolClient(store); - // First contact is trust-on-first-use: learn the key the handshake reveals, - // then pin it — the flow a user with an operator-supplied key skips. final warnings = []; client.onWarning = warnings.add; - final master = client.createIdentity(); + await client.createIdentity(); final me = client.identity!; final user = "e2e${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); - final learned = await client.connect(address, requirePin: false); - // §8: the first, unpinned session must be announced as unverified. - expect(warnings, isNotEmpty); - expect(warnings.single, contains("not pinned")); - store.pinServer(host, learned.serverStatic); - learned.session.wire.close(); + store.pinServer(host, serverKey); await client.registerAccount(address.short); - expect(store.account()?.user, user); + expect(client.accountAddress()!.short, address.short); await client.send(address.short, "hello e2e", "sealed and signed"); await client.send(address.short, "second", "another sealed envelope"); @@ -75,33 +77,32 @@ void main() { expect(store.listMessages("inbox"), isEmpty); final requests = store.listMessages("requests"); expect(requests.length, 2); - // receivedAt has second granularity, so the order of the two is not - // guaranteed; assert on the pair, then open the one we care about. final subjects = requests.map((m) => client.describe(m).subject).toSet(); expect(subjects, {"hello e2e", "second"}); - final hello = requests.firstWhere( - (m) => client.describe(m).subject == "hello e2e"); + final hello = requests + .firstWhere((m) => client.describe(m).subject == "hello e2e"); final opened = client.describe(hello); expect(opened.error, isNull); - expect(opened.body, "sealed and signed\n"); - expect(hex(opened.sender!), hex(me.publicKey)); + // fumi's describe splits the trailing newline into the frontmatter + // parse, so the body arrives trimmed. + expect(opened.body, "sealed and signed"); + expect(opened.sender, me.publicKey); // The server must be drained: everything that verified was acknowledged. final again = await client.fetch(); expect(again.stored, 0); - // Accept ourselves as a correspondent (§5.8): the change is pushed to the - // server right away. This next message carries our own Accept field, but - // no MAC yet — we cannot know our own token before receiving and parsing - // a message that carries it — so it still lands in requests. + // Accept ourselves as a correspondent (§5.8): the change is pushed to + // the server right away. This next message carries our own Accept field, + // but no MAC yet, so it still lands in requests. await client.acceptContact(address.short); await client.send(address.short, "third", "still unsolicited"); expect((await client.fetch()).stored, 1); expect(store.listMessages("requests").length, 3); expect(store.listMessages("inbox"), isEmpty); - // Having now learned our own token from that message's Accept field, the - // next one carries a matching MAC and reaches the main tier. + // Having now learned our own token, the next one carries a matching MAC + // and reaches the main tier. await client.send(address.short, "fourth", "now accepted"); expect((await client.fetch()).stored, 1); final mainTier = store.listMessages("inbox"); @@ -123,18 +124,17 @@ void main() { // Restore on a second device: same master, fresh store, no pin. Unpinned // recall is refused; re-registering a taken name is refused; recall with // the operator-supplied key then binds the account without REGISTER. - final restored = await SmolStore.open( - stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail"); - final secondDevice = SmolClient(restored); - restored.setMaster(master); - expect( + final secondStore = await freshStore("second", dir.path); + final secondDevice = SmolClient(secondStore); + secondStore.restoreMaster(store.master()!, 0); + await expectLater( secondDevice.recallAccount(address.short), throwsA(isA())); - restored.pinServer(host, learned.serverStatic); + secondStore.pinServer(host, serverKey); await expectLater( secondDevice.registerAccount(address.short), throwsA(isA())); final bound = await secondDevice.recallAccount(address.short); expect(bound.short, address.short); - expect(restored.account()!.user, user); + expect(secondDevice.accountAddress()!.user, user); // Re-resolving our own address finds the same key and says so quietly. final outcome = await client.refreshContact(address.short); @@ -146,24 +146,21 @@ void main() { test("leave mail on server keeps mail until deleted, with dedupe on refetch", () async { if (!await serverUp()) { - markTestSkipped("no smolmaild on $host:$port"); + markTestSkipped("no server on $host:$port"); return; } final dir = await Directory.systemTemp.createTemp("smol-e2e-keep"); Hive.init(dir.path); - final store = - await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail"); + final store = await freshStore("keep", dir.path); final client = SmolClient(store); - client.createIdentity(); + await client.createIdentity(); final user = "e2ekeep${hex(randomBytes(4))}"; final address = parseAddress("$user@$host"); - final learned = await client.connect(address, requirePin: false); - store.pinServer(host, learned.serverStatic); - learned.session.wire.close(); + store.pinServer(host, serverKey); await client.registerAccount(address.short); - store.setLeaveOnServer(true); + await store.setLeaveOnServer(true); await client.send(address.short, "kept", "stays on the server until deleted"); final first = await client.fetch(); @@ -172,19 +169,17 @@ void main() { expect(record.keptOnServer, isTrue); expect(client.describe(record).subject, "kept"); - // Re-fetching from scratch must not duplicate it locally (storeIfNew's + // Re-paging from scratch must not duplicate it locally (the seen-id // dedupe), even though the server still has it (nothing was deleted). - store.setCursor(0, Uint8List(idLen)); - final second = await client.fetch(); + final second = await client.fetch(reset: true); expect(second.stored, 0); expect(store.listMessages("requests").length, 1); - // Deleting removes it from the server too: a further full re-page after - // deletion must come back empty rather than resurrecting it. + // Deleting removes it locally and from the server too: a further full + // re-page after deletion comes back empty rather than resurrecting it. await client.deleteMessage("requests", record); expect(store.listMessages("requests"), isEmpty); - store.setCursor(0, Uint8List(idLen)); - final third = await client.fetch(); + final third = await client.fetch(reset: true); expect(third.stored, 0); expect(store.listMessages("requests"), isEmpty); }, timeout: const Timeout(Duration(minutes: 2))); diff --git a/test/ffi_smoke_test.dart b/test/ffi_smoke_test.dart new file mode 100644 index 0000000..63607c4 --- /dev/null +++ b/test/ffi_smoke_test.dart @@ -0,0 +1,19 @@ +// Spike: the first Dart-to-Rust link. Opens the built cdylib and calls the +// smoke entry point, which round-trips an address through fumi-core's +// in-memory store. Proves the dynamic-library bridge works; the real binding +// replaces this once the surface settles. + +import "dart:ffi"; +import "dart:io"; + +import "package:flutter_test/flutter_test.dart"; + +void main() { + test("native library links and round-trips", () { + final lib = DynamicLibrary.open( + "${Directory.current.path}/native/target/release/libsmol_mail_native.so"); + final smolSmoke = + lib.lookupFunction("smol_smoke"); + expect(smolSmoke(), 0); + }); +} diff --git a/test/interop_fumi_test.dart b/test/interop_fumi_test.dart new file mode 100644 index 0000000..c44f0f6 --- /dev/null +++ b/test/interop_fumi_test.dart @@ -0,0 +1,77 @@ +// Cross-client interop: kirakira's gsmol export imports into fumi, and +// fumi's export imports back into kirakira — the interchange the export +// patch upstream exists for. The kirakira side is driven through the real +// store API now: pins and contacts (with rotation history) land in the +// native store, and the sealed mail round-trip is covered by fumi-core's +// own export tests, so this proves the container compatibility both ways. +// Runs the fumi CLI as a subprocess, so the test skips when no built fumi +// sits in the sibling checkout. + +import "dart:io"; +import "dart:typed_data"; + +import "package:flutter_test/flutter_test.dart"; +import "package:hive_flutter/hive_flutter.dart"; + +import "package:smol_mail/smol/store.dart"; + +const fumiBinary = "../fumi/target/release/fumi"; + +void main() { + final fumiBuilt = File(fumiBinary).existsSync(); + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + final dir = await Directory.systemTemp.createTemp("smol-interop"); + Hive.init(dir.path); + }); + + test("exports cross-import in both directions", + skip: fumiBuilt ? false : "no built fumi in ../fumi", () async { + final dir = await Directory.systemTemp.createTemp("fumi-interop"); + final master = Uint8List.fromList(List.generate(32, (i) => i + 3)); + // Real, distinct, valid key forms: the system server's and fumi's. + const pinKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + + final mine = await SmolStore.open( + dbPath: "${dir.path}/kirakira.db", + stateBox: "interop-state", + readBox: "interop-read"); + mine.restoreMaster(master, 0); + mine.pinServer("example.org", pinKey); + // One contact, bound to the current key; rotation history is written by + // the trust engine on a validated rotation, not by a plain re-save. + await mine.saveContact("alice@example.org", pinKey, verified: true); + + final file = File("${dir.path}/kirakira.json"); + await file.writeAsString(await mine.exportData()); + + // fumi imports it. + final keyFile = File("${dir.path}/identity.key"); + await keyFile.writeAsBytes(master); + Future fumi(List args) => Process.run( + fumiBinary, + ["--key", keyFile.path, "--db", "${dir.path}/fumi.db", ...args]); + final into = await fumi(["import-backup", file.path]); + expect(into.exitCode, 0, reason: into.stderr.toString()); + expect(into.stdout.toString(), contains("1 contacts")); + + // fumi exports its store, and kirakira imports that back. + final fumiFile = File("${dir.path}/fumi.json"); + final out = await fumi(["export", fumiFile.path]); + expect(out.exitCode, 0, reason: out.stderr.toString()); + final restored = await SmolStore.open( + dbPath: "${dir.path}/restored.db", + stateBox: "interop2-state", + readBox: "interop2-read"); + restored.restoreMaster(master, 0); + final summary = await restored.importData(await fumiFile.readAsString()); + expect(summary.contactsAdded, 1); + expect(summary.pinsAdded, 1); + expect(summary.malformed, 0); + // The binding survived both directions of the round trip. + final contact = restored.contact("alice@example.org")!; + expect(contact.key, pinKey); + expect(restored.serverPin("example.org"), pinKey); + }); +} diff --git a/test/native_binding_test.dart b/test/native_binding_test.dart new file mode 100644 index 0000000..7d354a6 --- /dev/null +++ b/test/native_binding_test.dart @@ -0,0 +1,127 @@ +// The binding layer's proof: the Dart client drives the C ABI end to end — +// handles, error codes, async isolates, and the backup round-trip. The +// server-backed part runs only when the spike bunshin is up on 19619. + +import "dart:convert"; +import "dart:io"; +import "dart:typed_data"; + +import "package:flutter_test/flutter_test.dart"; + +import "package:smol_mail/native/client.dart"; +import "package:smol_mail/native/ffi.dart"; + +const spikeServerKey = "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq"; +const spikeServer = "127.0.0.1"; +const spikePort = 19619; + +Future spikeUp() async { + try { + final socket = await Socket.connect(spikeServer, spikePort, + timeout: const Duration(milliseconds: 300)); + socket.destroy(); + return true; + } on SocketException { + return false; + } +} + +void main() async { + test("handles, error codes and async isolates work end to end", () async { + final dir = await Directory.systemTemp.createTemp("native-binding"); + final client = FumiNative("${dir.path}/a.db"); + await client.open(); + client.setMaster(Uint8List.fromList(List.filled(32, 9))); + + // The account public key crosses as base32, driven from a worker isolate. + expect((await client.accountPk()).length, 52); + expect(await client.accountAddress(), isNull); + + // A pinned but dead host fails by code, never by prose: Unreachable (7). + await client.pinServer("127.0.0.1", spikeServerKey); + client.setMaster(Uint8List.fromList(List.filled(32, 9))); + try { + await client.register("nobody@127.0.0.1:19629"); + fail("register against a dead port must fail"); + } on NativeSmolException catch (err) { + expect(err.code, smolUnreachable); + expect(err.details["host"], "127.0.0.1"); + } + + // Empty folders and contact lookups render, not crash. + expect(await client.mail("inbox"), isEmpty); + expect((await client.contact("ghost@example.org"))["key"], ""); + + await client.close(); + }); + + test("the backup round-trip crosses the ABI in both directions", () async { + final dir = await Directory.systemTemp.createTemp("native-backup"); + final master = Uint8List.fromList(List.generate(32, (i) => i + 3)); + + final mine = FumiNative("${dir.path}/mine.db"); + await mine.open(); + mine.setMaster(master); + await mine.pinServer("example.org", spikeServerKey); + await mine.importContact( + "smol://alice@example.org/ayb6y3mwr3cfkcmcaqbn3cgfi6xsrsoqkalykw5s7oqmwqcpnmsq"); + + final file = await mine.exportBackup(); + + final restored = FumiNative("${dir.path}/restored.db"); + await restored.open(); + restored.setMaster(master); + final summary = jsonDecode(await restored.importBackup(file)) + as Map; + expect(summary["contactsAdded"], 1); + expect(summary["pinsAdded"], 1); + expect(await restored.serverPin("example.org"), spikeServerKey); + + await mine.close(); + await restored.close(); + }); + + test("register, send, fetch and describe against the spike bunshin", + skip: await spikeUp() + ? false + : "no bunshin on 127.0.0.1:19619", () async { + final dir = await Directory.systemTemp.createTemp("native-e2e"); + // Random masters: bunshin refuses a key already bound under another + // username, so identities must be fresh per run. + final aliceMaster = + Uint8List.fromList(List.generate(32, (i) => i * 7 + DateTime.now().microsecondsSinceEpoch % 251)); + final bobMaster = Uint8List.fromList(List.generate(32, (i) => i * 13 + 5)); + final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + + final alice = FumiNative("${dir.path}/alice.db"); + await alice.open(); + alice.setMaster(aliceMaster); + await alice.pinServer(spikeServer, spikeServerKey); + await alice.register("a$run@$spikeServer:$spikePort"); + expect(await alice.accountAddress(), "a$run@$spikeServer:$spikePort"); + + final bob = FumiNative("${dir.path}/bob.db"); + await bob.open(); + bob.setMaster(bobMaster); + await bob.pinServer(spikeServer, spikeServerKey); + await bob.register("b$run@$spikeServer:$spikePort"); + + final sent = await alice.send("b$run@$spikeServer:$spikePort", + "binding: hello over the ABI", + subject: "binding"); + expect(sent["change"], "new"); + + final summary = await bob.fetch(); + expect(summary["stored"], 1); + // First contact lands in the requests tier (sec 5.8). + final requests = await bob.mail("requests"); + expect(requests, hasLength(1)); + final described = + await bob.describe(requests[0]["id"] as String); + expect(described["text"], "binding: hello over the ABI"); + expect(described["subject"], "binding"); + + await alice.close(); + await bob.close(); + }); +} diff --git a/test/recall_flow_test.dart b/test/recall_flow_test.dart index 147ebbc..3ddfa97 100644 --- a/test/recall_flow_test.dart +++ b/test/recall_flow_test.dart @@ -4,6 +4,8 @@ // router flow itself. import "dart:io"; +import "dart:math"; +import "dart:typed_data"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -12,11 +14,17 @@ import "package:hive_flutter/hive_flutter.dart"; import "package:smol_mail/data/providers/providers.dart"; import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/smol/address.dart"; import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/crypto.dart"; import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; +import "package:smol_mail/smol/ui.dart"; + +Uint8List randomBytes(int n) => Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); + +// A syntactically valid server key for the stubbed pin step: the flow under +// test is the UI routing, not the handshake. +const fakePin = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; /// A [SmolClient] whose network operations complete instantly, so the test /// exercises the flow rather than the network. [restoreAndRecall] still @@ -32,14 +40,14 @@ class StubClient extends SmolClient { throw SmolError("no pinned key for ${addr.host}"); } store.restoreMaster(unhex(masterHex.trim()), 0); - store.setAccount(addr); + store.native.setAccount(addr.short); return addr; } @override Future recallAccount(String addressText) async { final addr = parseAddress(addressText); - store.setAccount(addr); + store.native.setAccount(addr.short); return addr; } } @@ -53,11 +61,11 @@ void main() { final dir = await Directory.systemTemp.createTemp("smol-recall-flow"); Hive.init(dir.path); storeA = await SmolStore.open( - stateBox: "recall-a-state", mailBox: "recall-a-mail"); + dbPath: "${dir.path}/a.db", stateBox: "recall-a-state", readBox: "recall-a-read"); storeB = await SmolStore.open( - stateBox: "recall-b-state", mailBox: "recall-b-mail"); + dbPath: "${dir.path}/b.db", stateBox: "recall-b-state", readBox: "recall-b-read"); storeC = await SmolStore.open( - stateBox: "recall-c-state", mailBox: "recall-c-mail"); + dbPath: "${dir.path}/c.db", stateBox: "recall-c-state", readBox: "recall-c-read"); }); Widget app(SmolStore store) => ProviderScope( @@ -99,13 +107,14 @@ void main() { expect(find.text("Register a new address instead"), findsNothing); fields = find.byType(TextField); expect(fields, findsNWidgets(2)); // address (carried over), server key - await tester.enterText(fields.at(1), b32encode(randomBytes(32))); + await tester.enterText(fields.at(1), fakePin); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); expect(find.text("Inbox"), findsOneWidget); expect(store.master(), master); - expect(store.account()!.user, "randogoth"); + expect(parseAddress(SmolClient(store).accountAddress()!.short).user, + "randogoth"); }); testWidgets("restore requires an address before it will submit", @@ -141,7 +150,7 @@ void main() { await tester.tap(find.text("Create Identity")); await tester.pumpAndSettle(); expect(store.master(), isNotNull); - expect(store.account(), isNull); + expect(SmolClient(store).accountAddress(), isNull); // Simulate returning to onboarding later (e.g. a cold restart). Pumping // app(store) directly would just rebuild the existing OnboardingScreen @@ -164,7 +173,7 @@ void main() { // Lands on the recall-framed register step (no pin yet); pin it and finish. expect(find.byType(TextField), findsNWidgets(2)); - await tester.enterText(find.byType(TextField).at(1), b32encode(randomBytes(32))); + await tester.enterText(find.byType(TextField).at(1), fakePin); await tester.tap(find.text("Pin and Recall")); await tester.pumpAndSettle(); diff --git a/test/smol_test.dart b/test/smol_test.dart deleted file mode 100644 index 12ab9e7..0000000 --- a/test/smol_test.dart +++ /dev/null @@ -1,317 +0,0 @@ -// Unit tests: lib/smol must reproduce test/vectors.json byte for byte (the -// vectors are generated from the reference stack — PyNaCl, noiseprotocol — by -// ../gsmol/test/gen_vectors.py), plus protocol-level checks for frontmatter, -// addresses, rotation chains and response framing. -// Run: devbox run test - -import "dart:convert"; -import "dart:io"; -import "dart:typed_data"; - -import "package:flutter_test/flutter_test.dart"; - -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/noise.dart"; -import "package:smol_mail/smol/proto.dart"; - -final vectors = - jsonDecode(File("test/vectors.json").readAsStringSync()) as Map; - -Uint8List vhex(String text) => unhex(text); -String vstring(dynamic value) => value as String; - -void main() { - test("hashes, HMAC/HKDF and AEAD match the reference vectors", () { - for (final v in vectors["sha256"] as List) { - final m = v as Map; - expect(hex(sha256(vhex(vstring(m["in"])))), vstring(m["out"])); - } - for (final v in vectors["sha512"] as List) { - final m = v as Map; - expect(hex(sha512(vhex(vstring(m["in"])))), vstring(m["out"])); - } - for (final v in vectors["hkdf"] as List) { - final m = v as Map; - expect( - hex(hkdfSha256(vhex(vstring(m["ikm"])), vhex(vstring(m["salt"])), - vhex(vstring(m["info"])), m["len"] as int)), - vstring(m["out"])); - } - for (final v in vectors["aead"] as List) { - final m = v as Map; - final key = vhex(vstring(m["key"])); - final nonce = vhex(vstring(m["nonce"])); - final aad = vhex(vstring(m["aad"])); - final sealed = aeadEncrypt( - key, nonce, vhex(vstring(m["plaintext"])), aad); - expect(hex(sealed), vstring(m["sealed"]), reason: "aead-seal ${m["name"]}"); - expect( - hex(aeadDecrypt(key, nonce, vhex(vstring(m["sealed"])), aad)), - vstring(m["plaintext"])); - expect( - () => aeadDecrypt( - key, nonce, Uint8List.fromList(vhex(vstring(m["sealed"])).sublist(0, vhex(vstring(m["sealed"])).length - 1)), aad), - throwsA(isA())); - } - }); - - test("X25519 matches and rejects low-order points", () { - final byName = {}; - for (final v in vectors["x25519"] as List) { - final m = v as Map; - byName[m["name"] as String] = m; - } - expect(hex(x25519Base(vhex(vstring(byName["alice"]!["priv"])))), byName["alice"]!["pub"]); - expect(hex(x25519Base(vhex(vstring(byName["bob"]!["priv"])))), byName["bob"]!["pub"]); - expect( - hex(x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(byName["bob"]!["pub"])))), - byName["agree"]!["shared"]); - for (final v in vectors["x25519"] as List) { - final m = v as Map; - if ((m["name"] as String).startsWith("low-order")) { - expect( - () => x25519(vhex(vstring(byName["alice"]!["priv"])), vhex(vstring(m["peer"]))), - throwsA(isA())); - } - } - }); - - test("Ed25519 signs and verifies like the reference stack", () { - for (final v in vectors["ed25519"] as List) { - final m = v as Map; - final seed = vhex(vstring(m["seed"])); - expect(hex(ed25519PublicKey(seed)), vstring(m["pub"]), reason: "ed25519-pub ${m["name"]}"); - final sig = ed25519Sign(seed, vhex(vstring(m["message"]))); - if (m["valid"] as bool) { - expect(hex(sig), vstring(m["signature"]), reason: "ed25519-sign ${m["name"]}"); - expect(ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), sig), isTrue); - expect( - ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), - vhex(vstring(m["signature"]))), - isTrue, - reason: "ed25519-verify-pynacl ${m["name"]}"); - } else { - expect( - ed25519Verify(vhex(vstring(m["pub"])), vhex(vstring(m["message"])), - vhex(vstring(m["signature"]))), - isFalse, - reason: "ed25519-reject ${m["name"]}"); - } - } - }); - - test("§2 conversions between the identity key and X25519", () { - for (final v in vectors["ed_to_x25519"] as List) { - final m = v as Map; - expect(hex(ed25519SeedToX25519(vhex(vstring(m["seed"])))), vstring(m["x_priv"])); - expect(hex(ed25519ToX25519(ed25519PublicKey(vhex(vstring(m["seed"]))))), vstring(m["x_pub"])); - } - }); - - test("§5 envelope seals, ids and unseals byte for byte", () { - final v = vectors["envelope"] as Map; - final sender = identityFromSeed(vhex(vstring(v["sender_seed"]))); - final recipient = identityFromSeed(vhex(vstring(v["recipient_seed"]))); - Uint8List sealWith(bool pad) => seal(sender, recipient.publicKey, - vhex(vstring(v["body"])), v["time"] as int, - SealOptions(esk: vhex(vstring(v["esk"])), pad: pad)); - - expect(hex(sealWith(false)), vstring(v["envelope"])); - expect(hex(messageId(vhex(vstring(v["envelope"])))), vstring(v["id"])); - final opened = unseal([recipient], vhex(vstring(v["envelope"]))); - expect(hex(opened.sender), hex(sender.publicKey)); - expect(opened.time, v["time"] as int); - expect(hex(opened.body), vstring(v["body"])); - expect( - () => unseal([identityFromSeed(vhex(vstring(v["esk"])))], - vhex(vstring(v["envelope"]))), - throwsA(isA())); - // padding round-trips and is ignored by the receiver (§5.3) - final padded = sealWith(true); - expect((padded.length - envelopeHeader - 16) % padTo, 0); - expect(padded.length > vstring(v["envelope"]).length ~/ 2, isTrue); - expect(hex(unseal([recipient], padded).body), vstring(v["body"])); - }); - - test("Noise NX transcript matches the reference", () { - final v = vectors["noise"] as Map; - final nx = NxInitiator(); - expect(hex(nx.writeMessage1(vhex(vstring(v["initiator_eph_priv"])))), vstring(v["message1"])); - final result = nx.readMessage2(vhex(vstring(v["message2"]))); - expect(hex(result.serverStatic), vstring(v["server_static_pub"])); - expect(hex(result.handshakeHash), vstring(v["handshake_hash"])); - final initiatorFrames = (v["initiator_frames"] as List).cast(); - final responderFrames = (v["responder_frames"] as List).cast(); - for (var i = 0; i < initiatorFrames.length; i++) { - expect(hex(result.send.encrypt(vhex(vstring(initiatorFrames[i]["plaintext"])))), - vstring(initiatorFrames[i]["sealed"]), - reason: "noise-frame-i$i"); - } - for (var i = 0; i < responderFrames.length; i++) { - expect(hex(result.recv.decrypt(vhex(vstring(responderFrames[i]["sealed"])))), - vstring(responderFrames[i]["plaintext"]), - reason: "noise-frame-r$i"); - } - // The responder direction must also produce identical ciphertexts (AEAD is - // deterministic), so the recv cipher can be checked in both directions. - final mirrored = NxInitiator(); - mirrored.writeMessage1(vhex(vstring(v["initiator_eph_priv"]))); - final mirror = mirrored.readMessage2(vhex(vstring(v["message2"]))); - expect(hex(mirror.recv.encrypt(vhex(vstring(responderFrames[0]["plaintext"])))), - vstring(responderFrames[0]["sealed"])); - }); - - test("frontmatter parses and fails closed (§5.5)", () { - const inReplyTo = - "4f2a1c9e8b7d6a5f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a1908f7e6d5"; - final spec = - "---\nSubject: Re: the thing\nIn-Reply-To: $inReplyTo\nX-Mood: cautiously optimistic\n---\nBody text starts here."; - final parsed = parseFrontmatter(spec); - expect(parsed.fields["subject"], "Re: the thing"); - expect(parsed.fields["in-reply-to"], inReplyTo); - expect(parsed.body, "Body text starts here."); - // a malformed line invalidates the whole block, which fails closed toward display - expect(parseFrontmatter("---\nno colon here\n---\nrest").body, - "---\nno colon here\n---\nrest"); - expect(parseFrontmatter("---\nSubject: x\nno end").body, - "---\nSubject: x\nno end"); - expect(parseFrontmatter("---\nA: 1\nA: 2\n---\ntext").fields["a"], "1"); - // keys compare case-insensitively; the first occurrence wins (§5.5) - expect(parseFrontmatter("---\nSubject: x\nsubject: y\n---\ntext").fields["subject"], "x"); - expect(buildFrontmatter(const {}, "---\nactual body"), - "---\n---\n---\nactual body"); - expect(buildFrontmatter({"Subject": "hi"}, "there"), "---\nSubject: hi\n---\nthere"); - expect(buildFrontmatter(const {}, "plain"), "plain"); - expect( - parseFrontmatter("---\n${"X: y\n" * 65}---\nbody").fields["subject"], - isNull); - }); - - test("addresses parse per §3 and round-trip through smol://", () { - final a = parseAddress("Alice@Example.ORG:1961"); - expect(a.user, "alice"); - expect(a.port, 1961); - expect(a.short, "alice@example.org"); // a default port is dropped - expect(parseAddress("bob@host").port, defaultPort); - final key = vhex(vstring((vectors["ed25519"] as List).cast().first["pub"])); - final parsed = parseAddress(parseAddress("bob@h").uri(key)); - expect(parsed.identity, key); - expect(parsed.user, "bob"); - expect(() => parseAddress("-bob@h"), throwsA(isA())); - // §3: never two separators in a row - expect(() => parseAddress("a..b@h"), throwsA(isA())); - expect(parseAddress("a.b_c@h").user, "a.b_c"); - // §3: fingerprints are the first 20 base32 characters in groups of four - final b32 = b32encode(key); - expect( - fingerprint(key), - [ - b32.substring(0, 4), b32.substring(4, 8), b32.substring(8, 12), - b32.substring(12, 16), b32.substring(16, 20) - ].join(" ")); - for (final n in [1, 2, 5, 32, 52, 64]) { - final raw = randomBytes(n); - expect(b32decode(b32encode(raw)), raw, reason: "b32[$n]"); - } - }); - - test("rotation chains validate, break and oversize per §7", () { - const username = "alice"; - final old = identityFromSeed(randomBytes(32)); - final mid = randomBytes(32); - final fresh = randomBytes(32); - final when = nowSeconds(); - final chain = [ - makeCert(username, old, mid, when), - makeCert(username, identityFromSeed(mid), fresh, when), - ]; - expect(walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain), isTrue); - expect(walkChain(username, old.publicKey, old.publicKey, []), isTrue); - expect( - walkChain(username, old.publicKey, ed25519PublicKey(fresh), chain.sublist(1)), - isFalse); - final forged = List.from(chain); - forged[1] = makeCert(username, identityFromSeed(mid), randomBytes(32), when); - expect( - walkChain(username, old.publicKey, ed25519PublicKey(fresh), forged), isFalse); - expect( - walkChain(username, old.publicKey, ed25519PublicKey(fresh), - List.filled(17, chain[0])), - isFalse); - // a chain signed for a different username must not validate (§7) - expect( - walkChain("bob", old.publicKey, ed25519PublicKey(fresh), chain), isFalse); - expect(chain[0].length, certLen); - }); - - test("response framing guards (§6.1)", () async { - Session scripted(Uint8List frame) { - // readNoise wants a u16 length prefix and at least 16 bytes of Noise - // message; the frame is padded up to that floor. - final message = Uint8List.fromList([ - ...frame, - ...List.filled(frame.length < 16 ? 16 - frame.length : 0, 0), - ]); - final session = Session(_ScriptedWire(concat([u16be(message.length), message])), - _PassthroughCipher(), _PassthroughCipher()); - return session; - } - - Future refuses(String name, Uint8List frame, int op) async { - try { - await scripted(frame).call(op); - fail("$name: call resolved instead of throwing"); - } on TestFailure { - rethrow; - } catch (_) { - // expected - } - } - - // The shortest legal response is a type byte and a status byte. - await refuses("frame-too-short", concat([u32be(1), Uint8List.fromList([opFetch])]), opFetch); - // A response reuses the request's type byte; a mismatch means the session - // desynchronised, which must not be read as a status. - await refuses("frame-op-mismatch", - concat([u32be(2), Uint8List.fromList([opResolve, 0])]), opFetch); - // The same frame with the right echo still passes, so the guard is not - // simply rejecting everything. - final okSession = - scripted(concat([u32be(2), Uint8List.fromList([opFetch, 0])])); - expect((await okSession.call(opFetch)).status, 0); - }); -} - -/// Serves a scripted response and ignores sends, so framing can be tested -/// without a server. -class _ScriptedWire implements Wire { - final Uint8List _queue; - int _pos = 0; - - _ScriptedWire(this._queue); - - @override - void send(Uint8List bytes) {} - - @override - void close() {} - - @override - Future readExact(int n) async { - if (_pos + n > _queue.length) { - throw const SmolError("script exhausted"); - } - final out = Uint8List.fromList(_queue.sublist(_pos, _pos + n)); - _pos += n; - return out; - } -} - -class _PassthroughCipher implements SessionCipher { - @override - Uint8List encrypt(Uint8List plaintext) => Uint8List.fromList(plaintext); - - @override - Uint8List decrypt(Uint8List sealed) => Uint8List.fromList(sealed); -} diff --git a/test/store_test.dart b/test/store_test.dart index 7d6abe8..1b783a6 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -1,22 +1,22 @@ -// Store-level behavior: contact key history (§8), import binding, and the -// export/import backup file. +// Store-level behavior against the real native store: master lifecycle, read +// marks, pins and the settings the app owns in Hive. -import "dart:convert"; import "dart:io"; +import "dart:math"; +import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; -import "package:smol_mail/smol/client.dart"; -import "package:smol_mail/smol/crypto.dart"; -import "package:smol_mail/smol/errors.dart"; -import "package:smol_mail/smol/proto.dart"; import "package:smol_mail/smol/store.dart"; -// Each store gets its own boxes; Hive is a per-process singleton, so without -// this the "exporting" and "importing" stores would be the same store. -Future freshStore(String tag) => - SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail"); +Uint8List randomBytes(int n) => + Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256))); + +Future freshStore(String tag) => SmolStore.open( + dbPath: "${Directory.systemTemp.createTempSync("smol-store-$tag").path}/store.db", + stateBox: "$tag-state", + readBox: "$tag-read"); void main() { setUpAll(() async { @@ -25,182 +25,69 @@ void main() { Hive.init(dir.path); }); - test("contact history keeps displaced keys, not re-saves", () async { - final store = await freshStore("history"); - final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32); + test("master set, restore and rotations", () async { + final store = await freshStore("master"); + expect(store.master(), isNull); + expect(store.identity(), isNull); - store.saveContact("alice@example.org", a, true); - expect(store.contact("alice@example.org")!.history, isEmpty); - - store.saveContact("alice@example.org", b, false); - final rotated = store.contact("alice@example.org")!; - expect(rotated.key, b); - expect(rotated.history.length, 1); - expect(rotated.history.first.key, a); - expect(rotated.history.first.until, greaterThan(0)); - - // Re-saving the same key is not a rotation and must not add an entry. - store.saveContact("alice@example.org", b, true); - expect(store.contact("alice@example.org")!.history.length, 1); - - // A second displacement appends, oldest first. - store.saveContact("alice@example.org", c, false); - final history = store.contact("alice@example.org")!.history; - expect(history.length, 2); - expect(history[0].key, a); - expect(history[1].key, b); - expect(store.allContacts().single.$2.history.length, 2); - }); - - test("importContact binds a smol:// address to the key it carries", () async { - final store = await freshStore("import-contact"); - final client = SmolClient(store); - final identity = identityFromSeed(randomBytes(32)); - client.importContact( - "smol://bob@example.org/${b32encode(identity.publicKey)}"); - final saved = store.contact("bob@example.org")!; - expect(saved.verified, isTrue); - expect(saved.key, identity.publicKey); - }); - - test("export never contains the master secret and round-trips through import", - () async { - final a = await freshStore("export"); - final b = await freshStore("round-trip"); - a.setMaster(randomBytes(32)); - a.pinServer("example.org", randomBytes(32)); - a.saveContact("alice@example.org", randomBytes(32), true); - a.saveContact("alice@example.org", randomBytes(32), false); // history grows - await a.storeMessage( - "inbox", MailRecord("aa", randomBytes(64), receivedAt: 5)); - await a.storeMessage("sent", - MailRecord("bb", randomBytes(64), recipient: "bob@example.org", sentAt: 6)); - - final data = a.exportData(); - expect(data["gsmolExport"], 2); // sealed to the identity's master, like gsmol - expect(jsonEncode(data).contains(hex(a.master()!)), isFalse); - - // v2 is sealed to the exporting identity's master — a restore-on-new-device - // scenario, not a transfer to someone else's identity (see the test below). - b.setMaster(a.master()!); - final summary = await b.importData(data); - expect(summary.mailAdded, 2); - expect(summary.pinsAdded, 1); - expect(summary.contactsAdded, 1); - expect(b.serverPin("example.org"), a.serverPin("example.org")); - expect(b.contact("alice@example.org")!.history.length, 1); - expect(b.getMessage("inbox", "aa"), isNotNull); - expect(b.getMessage("sent", "bb"), isNotNull); - }); - - test("v2 import refuses a different identity's export", () async { - final a = await freshStore("export-wrong-identity"); - final c = await freshStore("round-trip-wrong-identity"); - a.setMaster(randomBytes(32)); - a.pinServer("example.org", randomBytes(32)); - final data = a.exportData(); - - c.setMaster(randomBytes(32)); // a different master than a's - expect(() => c.importData(data), throwsA(isA())); - }); - - test("accept tokens: accept/block gate the sync set, tiers split the inbox view", - () async { - final store = await freshStore("accept-tokens"); final master = randomBytes(32); store.setMaster(master); - final alice = randomBytes(32); - store.saveContact("alice@example.org", alice, true); + expect(store.master(), master); + // A fresh identity's public key is the native-derived one. + expect(store.identity()!.publicKey.length, 52); + expect(store.rotations(), 0); - // No one accepted yet: an empty set is already complete, so it may sync. - var (sync, tokens) = store.tokenSet(master); - expect(sync, 1); - expect(tokens, isEmpty); - - store.accept("alice@example.org", alice); - (sync, tokens) = store.tokenSet(master); - expect(sync, 1); - expect(tokens, [tokenFor(master, alice)]); - expect(store.accepted("alice@example.org")!.active, isTrue); - - store.block("alice@example.org"); - expect(store.accepted("alice@example.org")!.active, isFalse); - expect(store.tokenSet(master).$2, isEmpty); - // Re-accepting keeps the identity frozen at the original acceptance, - // so the token a correspondent already holds keeps working. - store.accept("alice@example.org", randomBytes(32)); - expect(store.accepted("alice@example.org")!.identity, alice); - - expect(() => store.block("bob@example.org"), throwsA(isA())); - - // A restored master must not silently replace the server's set. - store.setSyncOk(false); - (sync, tokens) = store.tokenSet(master); - expect(sync, 0); - expect(tokens, isEmpty); - - await store.storeIfNew( - "inbox", MailRecord("aa", randomBytes(64), receivedAt: 1, tier: tierMain)); - await store.storeIfNew("inbox", - MailRecord("bb", randomBytes(64), receivedAt: 2, tier: tierRequests)); - expect(store.listMessages("inbox").map((r) => r.id), ["aa"]); - expect(store.listMessages("requests").map((r) => r.id), ["bb"]); - expect(store.getMessage("requests", "bb"), isNotNull); + final other = randomBytes(32); + store.restoreMaster(other, 3); + expect(store.master(), other); + // The account handle was rebuilt at the restored rotation index. + expect(store.identity()!.publicKey.length, 52); }); - test("v1 legacy export still imports without an identity", () async { - final store = await freshStore("import-legacy-v1"); - final summary = await store.importData({ - "gsmolExport": 1, - "servers": {"example.org": b32encode(randomBytes(32))}, - }); - expect(summary.pinsAdded, 1); + test("leave-on-server is a setting, not protocol state", () async { + final store = await freshStore("leave"); + expect(store.leaveOnServer(), isFalse); + await store.setLeaveOnServer(true); + expect(store.leaveOnServer(), isTrue); + await store.setLeaveOnServer(false); + expect(store.leaveOnServer(), isFalse); }); - test("import never overwrites a differing trust binding", () async { - final store = await freshStore("conflict"); - final mine = randomBytes(32), other = randomBytes(32); - store.pinServer("example.org", mine); - store.saveContact("alice@example.org", mine, true); - final summary = await store.importData({ - "gsmolExport": 1, - "servers": {"example.org": b32encode(other)}, - "contacts": { - "alice@example.org": {"key": b32encode(other), "verified": true}, - "bob@example.org": {"key": b32encode(randomBytes(32)), "verified": false}, - }, - }); - expect(summary.pinsConflicted, 1); - expect(summary.pinsAdded, 0); - expect(summary.contactsConflicted, 1); - expect(summary.contactsAdded, 1); - expect(store.serverPin("example.org"), mine); - expect(store.contact("alice@example.org")!.key, mine); - expect(store.contact("bob@example.org"), isNotNull); + test("read marks drive the unread counts", () async { + final store = await freshStore("read"); + expect(store.unreadCount(), 0); + // Nothing is stored yet, so marking an id is harmless bookkeeping. + store.markRead("ab" * 32); + expect(store.isRead("ab" * 32), isTrue); }); - test("import skips malformed entries and rejects wrong files", () async { - final store = await freshStore("malformed"); - final summary = await store.importData({ - "gsmolExport": 1, - "servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))}, - "contacts": { - "x": {"key": "nope"}, - "y": "not a record", - }, - "inbox": [ - {"id": "ok", "envelope": base64Encode(randomBytes(64)), "receivedAt": 1}, - {"id": "bad", "envelope": "!!!not base64!!!"}, - "not a record", - ], - "sent": "not a list", - }); - expect(summary.malformed, 7); // 2 pins, 2 contacts, 2 mail, 1 sent - expect(summary.pinsAdded, 0); - expect(summary.mailAdded, 1); - expect(store.getMessage("inbox", "ok"), isNotNull); - expect(store.getMessage("inbox", "bad"), isNull); + test("pins save, list and unpin", () async { + final store = await freshStore("pins"); + expect(store.allPins(), isEmpty); + // A syntactically valid key: the system server's, a real 52-char form. + const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + store.pinServer("example.org", key); + expect(store.serverPin("example.org"), key); + expect(store.allPins().length, 1); + await store.unpinServer("example.org"); + expect(store.serverPin("example.org"), isNull); + }); - expect(() => store.importData({"nope": 1}), throwsA(isA())); + test("wipe clears every secret and mark, overwriting the master", () async { + final store = await freshStore("wipe"); + final master = randomBytes(32); + store.setMaster(master); + store.pinServer("example.org", + "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"); + store.markRead("cd" * 32); + + await store.wipe(); + // The bytes the store owned are overwritten, not just dereferenced — + // the caller's reference sees the zeros too. + expect(master.every((b) => b == 0), isTrue); + expect(store.master(), isNull); + expect(store.identity(), isNull); + expect(store.serverPin("example.org"), isNull); + expect(store.isRead("cd" * 32), isFalse); }); } diff --git a/test/vectors.json b/test/vectors.json deleted file mode 100644 index 7acb656..0000000 --- a/test/vectors.json +++ /dev/null @@ -1,226 +0,0 @@ -{ - "sha256": [ - { - "in": "", - "out": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - { - "in": "616263", - "out": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - }, - { - "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "out": "fdeab9acf3710362bd2658cdc9a29e8f9c757fcf9811603a8c447cd1d9151108" - }, - { - "in": "736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c736d6f6c6d61696c", - "out": "58a0adce483c517ae1b37025dbe3b534880972be4d9d10b78959a13fb8b13462" - } - ], - "sha512": [ - { - "in": "", - "out": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" - }, - { - "in": "616263", - "out": "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f" - }, - { - "in": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "out": "ee4320ebaf3fdb4f2c832b137200c08e235e0fa7bbd0eb1740c7063ba8a0d151da77e003398e1714a955d475b05e3e950b639503b452ec185de4229bc4873949" - } - ], - "hkdf": [ - { - "name": "rfc5869-1", - "ikm": "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b", - "salt": "000102030405060708090a0b0c", - "info": "f0f1f2f3f4f5f6f7f8f9", - "len": 42, - "out": "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" - }, - { - "name": "seal-shaped", - "ikm": "61677265656d656e7420736861726564", - "salt": "65706b726563697069656e74", - "info": "736d6f6c6d61696c2f31207365616c", - "len": 32, - "out": "b9f729e45d7bab89598edbce35f3f5bb91d6f403a5ff85943a838defbfcd7a0c" - } - ], - "aead": [ - { - "name": "empty", - "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", - "nonce": "3edd69829394f0807df7b01d", - "aad": "", - "plaintext": "", - "sealed": "941b286ea0ee86bb66236fc3c16b2e48" - }, - { - "name": "short", - "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", - "nonce": "3edd69829394f0807df7b01d", - "aad": "50515253c0c1c2c3c4c5c6c7", - "plaintext": "68656c6c6f", - "sealed": "7dbede6dd11ffa046f102dedea535912be561e3c29" - }, - { - "name": "multiline", - "key": "831386140c054287c460b9f6092d43a5bb6eb2d2c549b00fc3fce0e7cca1a19d", - "nonce": "3edd69829394f0807df7b01d", - "aad": "50515253c0c1c2c3c4c5c6c7", - "plaintext": "4c616469657320616e642047656e746c656d656e206f662074686520636c617373206f66202739393a206966204920636f756c64206f6666657220796f75206f6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73637265656e20776f756c642062652069742e", - "sealed": "59bad668dbf00561dd86add05afe35b269f9997d57e46cee0e42fd69693c3df16b681f3905bbea4135cb379f4a0c730b5dbb3ba06d1231ce396660a16f8cadb8b422d745b932df3c1eed2df9636750551a0333b3d06877582f172f3ec2d437061143699bfc7258aa98e319f23a1f31f88fd15a24a97b46fd2e05c266d31ee96f5e24" - } - ], - "x25519": [ - { - "name": "alice", - "priv": "c63095403fea13cb2c43380599e669ebfb41ab184b04df28a143472e4283aa33", - "pub": "712e68cefc13d0e1778226b7f7aaeb59042225e7a4def3816344da256e031664" - }, - { - "name": "bob", - "priv": "33485a5b40b70730b3c3e468a8262543e5a4d9dc98de06399de66a4d579e02a7", - "pub": "b8540c30e8fb348aed0abc8189cf8025ce09a9c5d74e0681c4e50f8d281da252" - }, - { - "name": "agree", - "shared": "e6a3b1d2ae10c29b51519a71255af4bd20deee697c6ced1a44eadff428439e13" - }, - { - "name": "low-order-0", - "peer": "0000000000000000000000000000000000000000000000000000000000000000", - "peer_rejected": true, - "raised": true - }, - { - "name": "low-order-1", - "peer": "0100000000000000000000000000000000000000000000000000000000000000", - "peer_rejected": true, - "raised": true - } - ], - "ed25519": [ - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "", - "signature": "b20543ac2e0ba66c1b437de2afda7ca8ca6f9feb2af3e3e7ac3183e388d1786c9c027bfe549639140d0e45e7e850999b3fb992c7c003946c1c5aaeb09892cc0c", - "valid": true - }, - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", - "signature": "f7e74a0540e05843b52efb7dee4f3622ab8a88333142f6dd1d5386612f7f0f1410bd0b1f1ff09dea9419922b756920a4c1fb31a95eac3cc55a410d0d6f1dab03", - "valid": true - }, - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "e55b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", - "valid": true - }, - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "pub": "7ce749eb2966b9b747543236ef5f61b46328a82f92677b82470439db065154e0", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "e45b07b43bed9938bdf401d264226d22ea87f85867dc2f3a22edf129088e0a100dda4000c9c2075b946c12a645115911a041493bd3a6c6d6e3892233c7f3e209", - "valid": false - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "", - "signature": "2dfb4b1e65dfd4bf991ef50be88acddf2d59fe158daf2879bec5641ab80b1e0c542b9ea2bd9a6bc76f2020b76b14dc80ae9f68c62ea58dbd8f5b1990ff069e08", - "valid": true - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "736d6f6c6d61696c2f3120617574680000000000000000000000000000000000000000000000000000000000000000", - "signature": "f6d398cd25fec0ba882af13b85c6addcc2f546181fba84f8925e6e196b759897337a2291f4b73c40a062b0a2283ef096ded790154af58e9d4bd39c32b3db2f05", - "valid": true - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "24f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", - "valid": true - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "pub": "7068fc61a59adcb58e856393df939349389af09095fffdfb7374d07be183201e", - "message": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", - "signature": "25f8405160d7f8eca42d9bc5e703bc81e58082b34e5d0f28404e5a3518b500b46d48a9bd64c69f5dde38c978ae06c9ce5dfeabdfacd410753176ea11f171140e", - "valid": false - } - ], - "ed_to_x25519": [ - { - "name": "vector-seed-a", - "seed": "81712c4bef43282ffd12909479bece9da17d404bdbc629bec3a6fdf246f847fb", - "x_priv": "30dc8ba3a49892d4b8a626cd371fd43bff4e5651c2a45f1be16e89d84fa89e68", - "x_pub": "77bc3dae84c9b4085862725a21e0a366c09563d6da8f29c408ac2b6928937910" - }, - { - "name": "vector-seed-b", - "seed": "053a9de5a92ac01208edcd9f9d585ffa1462b60e895567d13a262ebbd5138b5d", - "x_priv": "b03deb6f9f4ab25d15471a355ff4ee23ea98f7d24695c500ed80b6af4b7b9963", - "x_pub": "253d4b5b14df341a5dde486ddd588e292444118ed8eed1fe0738823b82e4243d" - } - ], - "envelope": { - "sender_seed": "89c16df9e4352e706abe701928c230d8bd169cd31633bf4589c2d410cb3ae8cc", - "recipient_seed": "6f845ccc435252d39dd08e964d219258e92c3d6c54af0376fa45d5e55eec0aaf", - "esk": "c31fee505964c44b711cf354b431f9716c644a3dbf8c1d29c5e3cedf884d0a48", - "body": "2d2d2d0a5375626a6563743a20766563746f720a2d2d2d0a68656c6c6f20626f620a", - "time": 1730000000, - "envelope": "534d4f4c01e048814b56d9b82e54fd367d3c980661313cc6a3d81a80315561fc0ac87f81184e49921528d72321669ae1b275229800d8786c1ecdd7d9331bcb2cc64dc27c0399b106b5061e9105d8adf82f6b7464930fa31cb08ba85dba4764ce14cf3ddc32599f43fea73ced76ffa3a3b768e5a127034f5e82d667687369c19f060e8eafb09da0e212683290f4e1a73ce072b94f053c9088652109d3639f7b9aa0d48619626ecefe33855aade6ab8bf9de14ebb7cf07eec0ef9c193ae4537c370183e0c8f7cd7230471b9d8e7389ac654e1b09dc9b0160c875270fdad218f0c9da735bab", - "id": "b05d15a2ab5293164eda564de02a69019aa97895ff988f3d9fa2be5126516553", - "unpadded_plaintext_len": 143 - }, - "noise": { - "initiator_eph_priv": "af5f15993914cfd4e308856810d483ab25a383bfb2d708a0e15f80275f1fe6c2", - "responder_eph_priv": "1c21927bb3e579efb69c937a2bf2e299ca1da9c83a62fb7f8ea1a375eb6f1c80", - "server_static_priv": "c7b206a746c9b167da412a6b1a235869e316e9f08dbbc8478430b2998130f79e", - "server_static_pub": "fa111eca8e853320f8e076674143fd4d1cd181bddeda7d9950a65922b9eafc32", - "message1": "b8b73e6f132dab5659270e6496d2c575ae7daf45a0961ae8e19405a12ed1cc2e", - "message2": "0b03ec78fd19cf7b8480881f33c6b4ca1094fac03c87860095c87c6737349c28256e498747bcf2018420d145c378e6605e63b217f92925ae5771dbe387b94cf9c995f7a3a8314fe2e671ca8fa1463e0642c1d7974f326737cadf630b8aac8ed9", - "handshake_hash": "a039471479680a596a8a61b8827afb01853274b03de2870095edb9b7dd4e2c72", - "assert_hash_equal": true, - "initiator_frames": [ - { - "plaintext": "70696e67", - "sealed": "f1885096d9b9383b0bc38b08dff77c005d69f0f0" - }, - { - "plaintext": "7365636f6e64206672616d6520746f20746573742074686520636f756e746572", - "sealed": "4a6481a2f7d0c909d9b321bc097e09a1929aeaf8647751f64d65b5e1f92abe960796dbf1c619bef48845aea257f2c73c" - } - ], - "responder_frames": [ - { - "plaintext": "706f6e67", - "sealed": "a7dda7fe3ef32435b3e72fda49714e9977318f0b" - }, - { - "plaintext": "787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878", - "sealed": "9aa3af13c00a8a0cd81167b48a5443541e0c862297638e4b7e5c71196657bc565aad46c2d147b23f752ac48c29b69699209e252d3e840c13fc466dd0445515dec2d289902c7177a237f9256afe9508a45b64ea12cdb597a8c38b6ce0c43891ec772c21ca360980094268686642eae8e01ccf89650a427297dbab052aabfeb08adbb2087ae303dd168ceb3beffa63c8d3ccd1c5b2687c2a9c0d43eaab237fde648bf8b0f347b4952ff6da2212360b4a2a5b1316e9e776d82961c498e51f35a58c999f080ac1c12d7ac08f6ddd7addb70c37ea6bef42ed2c498362f4fd75a222068035a7f372c4f57e613427193ffd8ed40a2d0765ba62cbfd6cb5103165dc15be7ad2db723a4edc73cefe9f7fe5ff78a8ea06ecbc7c5135e5d810a1bd4e47f0cd4a1b6e8dd88b85236dd4b41296e00b7054139ee4fd4faa5876e01d62" - } - ] - } -} diff --git a/test/widget_test.dart b/test/widget_test.dart index 640041c..e3689ba 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -17,7 +17,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); final dir = await Directory.systemTemp.createTemp("smol-widget-test"); Hive.init(dir.path); - store = await SmolStore.open(); + store = await SmolStore.open(dbPath: "${dir.path}/widget.db"); }); testWidgets("onboarding invites to create or restore an identity", -- 2.51.2 From 6f6fa20b583000d8076091f3b860b0a084a83a76 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 28 Sep 2026 23:49:39 +0300 Subject: [PATCH 2/6] feat: patrol on-device integration smoke test --- .gitignore | 11 ++ android/.gitignore | 5 +- android/app/build.gradle.kts | 26 +++ .../integration/MainActivityTest.java | 38 ++++ android/gradlew | 171 ++++++++++++++++++ devbox.json | 13 +- devbox.lock | 8 + integration_test/smoke_test.dart | 97 ++++++++++ pubspec.lock | 89 ++++++++- pubspec.yaml | 14 ++ 10 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java create mode 100755 android/gradlew create mode 100644 integration_test/smoke_test.dart diff --git a/.gitignore b/.gitignore index 24476c5..a305876 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,14 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/build + +# Rust wrapper crate (native/) +native/target/ + +# Rust cdylibs built into the APK by devbox run build-native-android +android/app/src/main/jniLibs/ + +# Patrol generates the bundle from the integration tests at run time +**/test_bundle.dart +.patrol.env diff --git a/android/.gitignore b/android/.gitignore index 6f56801..1e367f8 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -1,8 +1,9 @@ gradle-wrapper.jar /.gradle /captures/ -/gradlew -/gradlew.bat +# gradlew stays tracked: it carries the nix project-cache redirect that every +# non-flutter gradle invocation (patrol_cli, manual builds) needs, so the +# wrapper can't be re-injected from the template. /local.properties GeneratedPluginRegistrant.java diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index db7cfc9..22ada6c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -25,6 +25,25 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + // The Android Test Orchestrator (with clearPackageData) wipes the + // integration flavor's app data between test runs, and the flavor + // installs it beside — never over — the real app, so every patrol run + // starts at onboarding with a fresh identity. + testInstrumentationRunner = "pl.leancode.patrol.PatrolJUnitRunner" + testInstrumentationRunnerArguments["clearPackageData"] = "true" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + flavorDimensions += "test" + productFlavors { + create("integration") { + dimension = "test" + applicationIdSuffix = ".integration" + versionNameSuffix = "-integration" + } } buildTypes { @@ -38,3 +57,10 @@ android { flutter { source = "../.." } + +dependencies { + // The orchestrator referenced by testOptions.execution above. The patrol + // native library itself comes from the patrol pub package's android + // module (a plugin subproject), not from a Maven artifact. + androidTestUtil("androidx.test:orchestrator:1.5.1") +} diff --git a/android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java b/android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java new file mode 100644 index 0000000..cf41662 --- /dev/null +++ b/android/app/src/androidTest/java/com/app/smol_mail/integration/MainActivityTest.java @@ -0,0 +1,38 @@ +package com.app.smol_mail.integration; + +import androidx.test.platform.app.InstrumentationRegistry; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import pl.leancode.patrol.PatrolJUnitRunner; + +// The Patrol host class: each Dart test in the integration_test directory is +// executed through this JUnit suite. Its package is the flavored applicationId +// (com.app.smol_mail + .integration suffix); the namespace differs, so +// MainActivity needs the explicit import below. +@RunWith(Parameterized.class) +public class MainActivityTest { + + @Parameters(name = "{0}") + public static Object[] testCases() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.setUp(com.example.smol_mail.MainActivity.class); + instrumentation.waitForPatrolAppService(); + return instrumentation.listDartTests(); + } + + public MainActivityTest(String dartTestName) { + this.dartTestName = dartTestName; + } + + private final String dartTestName; + + @Test + public void runDartTest() { + PatrolJUnitRunner instrumentation = + (PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation(); + instrumentation.runDartTest(dartTestName); + } +} diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..a32d0e0 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,171 @@ +#!/nix/store/gik3rh1vz2jlgnifb9dh6vc6sxwwz9jj-bash-5.3p9/bin/bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +# Nix's flutter keeps the flutter_tools/gradle composite build on a read-only +# store path, so every gradle invocation needs its project and Kotlin caches +# redirected off it — the same two arguments the flutter tool itself passes. +# Without them, any gradlew run other than through the flutter tool (patrol_cli, +# manual builds) fails with a silent exit 1. +_nix_cache="${XDG_CACHE_HOME:-$HOME/.cache}/flutter/nix-flutter-tools-gradle/gradle-project-cache" +case " $* " in + *" --project-cache-dir"*) ;; + *) set -- "$@" "--project-cache-dir=$_nix_cache" "-Pkotlin.project.persistent.dir=$_nix_cache/kotlin" ;; +esac + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/devbox.json b/devbox.json index 2c74757..d8a38b7 100644 --- a/devbox.json +++ b/devbox.json @@ -10,7 +10,9 @@ "nixpkgs#ninja", "nixpkgs#pkg-config", "nixpkgs#gtk3", - "nixpkgs#libsysprof-capture" + "nixpkgs#libsysprof-capture", + "nixpkgs#rustup", + "nixpkgs#cargo-ndk" ], "shell": { "init_hook": [ @@ -22,11 +24,16 @@ "test": "flutter test", "run-linux": "flutter run -d linux", "build-linux": "flutter build linux", - "link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi" + "link-android-tools": "if [ -n \"$ANDROID_HOME\" ] && [ ! -d \"$ANDROID_HOME/cmdline-tools/latest\" ] && [ -d \"$ANDROID_HOME/cmdline-tools/8.0\" ]; then ln -s \"$ANDROID_HOME/cmdline-tools/8.0\" \"$ANDROID_HOME/cmdline-tools/latest\"; fi", + "build-native": "cd native && cargo build --release", + "build-native-android": "rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android && cd native && cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o ../android/app/src/main/jniLibs build --release", + "test-native": "cd native && cargo test --release && cargo test --release -- --ignored", + "activate-patrol": "dart pub global activate patrol_cli", + "test-integration": "export PATH=\"$HOME/.pub-cache/bin:$PATH\" && patrol test --dart-define=SMOL_TEST_HOST=$(hostname -I | tr ' ' '\\n' | grep -E '^(192\\.168|10\\.|172\\.)' | head -1)" } }, "env": { "ANDROID_SDK_ROOT": "$DEVBOX_PACKAGES_DIR/share/android-sdk", "ANDROID_HOME": "$DEVBOX_PACKAGES_DIR/share/android-sdk" } -} \ No newline at end of file +} diff --git a/devbox.lock b/devbox.lock index 6ffe55e..5db83f3 100644 --- a/devbox.lock +++ b/devbox.lock @@ -57,6 +57,10 @@ "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#android-tools" }, + "nixpkgs#cargo-ndk": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cargo-ndk" + }, "nixpkgs#clang": { "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#clang" @@ -84,6 +88,10 @@ "nixpkgs#pkg-config": { "last_modified": "1970-01-01T00:00:00Z", "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#pkg-config" + }, + "nixpkgs#rustup": { + "last_modified": "1970-01-01T00:00:00Z", + "resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#rustup" } } } diff --git a/integration_test/smoke_test.dart b/integration_test/smoke_test.dart new file mode 100644 index 0000000..7a6c75a --- /dev/null +++ b/integration_test/smoke_test.dart @@ -0,0 +1,97 @@ +// Device integration tests over the real UI, driven by Patrol against the +// system bunshin on the test host. The integration flavor installs beside +// the real app and the instrumentation runner clears its data, so every run +// starts at onboarding with a fresh identity — the app's own flows are the +// subject under test, including the network path. +// +// Run: devbox run test-integration +// (the script resolves the host's LAN IP into SMOL_TEST_HOST, because the +// device reaches the server over Wi-Fi, not loopback.) + +import "package:flutter/material.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:hive_flutter/hive_flutter.dart"; +import "package:path_provider/path_provider.dart"; + +import "package:patrol/patrol.dart"; + +import "package:smol_mail/data/providers/providers.dart"; +import "package:smol_mail/presentation/app_widget.dart"; +import "package:smol_mail/smol/config.dart"; +import "package:smol_mail/smol/store.dart"; + +const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2.2"); + +// The system bunshin's static key: a documented, operator-supplied value on +// this machine — the trusted channel SPEC.md §4 asks a pin to come from. +const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; + +Future bootApp() async { + // main()'s boot, minus runApp: the test pumps the same tree. + final dataDir = await getApplicationSupportDirectory(); + Hive.init(dataDir.path); + final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); + applyPresetServer(store); + return ProviderScope( + overrides: [storeProvider.overrideWithValue(store)], + child: const AppWidget(), + ); +} + +void main() { + patrolTest("smoke: onboarding, register, self-send, fetch, read in requests", + ($) async { + await $.pumpWidgetAndSettle(await bootApp()); + + // Onboarding: a fresh identity, backed up, registered under a name no + // server has bound yet — registration must succeed against the real + // server over Wi-Fi, through the Dart-resolved dial path. + await $("Create Identity").tap(); + await $.pumpAndSettle(); + await $("I have backed it up").waitUntilVisible(); + await $("I have backed it up").tap(); + await $.pumpAndSettle(); + + final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; + // Enter text by TextField, not by its labelText: the decoration label is + // a RichText that is not hit-testable, so a text finder times out. + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), serverKey); + await $("Pin and Register").tap(); + + // The register round trip is real network: the inbox tabs only exist + // behind HomeGuard, so reaching them proves the account bound. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + await $("Requests").waitUntilVisible(); + + // Compose the first self-addressed mail. + await $(Icons.edit).tap(); + await $.pumpAndSettle(); + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), "smoke subject"); + await $.enterText($(TextField).at(2), "smoke body from patrol"); + await $("Send").tap(); + // Compose pops back to the inbox once the send round trip completes. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + await $.pumpAndSettle(); + + // Fetch over the air, then read what arrived. + await $(Icons.cloud_download).tap(); + await $.pumpAndSettle(duration: const Duration(seconds: 5)); + await $("Requests (1)").tap(); + await $.pumpAndSettle(); + + // First contact is unsolicited, so it landed in requests (sec 5.8) — + // and the §5.6 sent copy must read back as plain text, not . + await $("smoke subject").tap(); + await $.pumpAndSettle(); + await $("smoke body from patrol").waitUntilVisible(); + await $.native.pressBack(); // ignore: deprecated_member_use + await $.pumpAndSettle(); + await $("Sent").tap(); + await $.pumpAndSettle(); + await $("smoke subject").tap(); + await $.pumpAndSettle(); + await $("smoke body from patrol").waitUntilVisible(); + }); +} diff --git a/pubspec.lock b/pubspec.lock index a96a269..b7c28cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.0" + dispose_scope: + dependency: transitive + description: + name: dispose_scope + sha256: "48ec38ca2631c53c4f8fa96b294c801e55c335db5e3fb9f82cede150cfe5a2af" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -258,7 +274,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" @@ -334,6 +350,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -384,6 +405,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -440,6 +466,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.7" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -464,6 +498,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.10.1" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -672,6 +711,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + patrol: + dependency: "direct dev" + description: + name: patrol + sha256: "662c50d517b2f159fedbc359a7fb8d0d30cbefea6d5c2a08392a0593079d64d1" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + patrol_finders: + dependency: transitive + description: + name: patrol_finders + sha256: "8608cdacaab90a3b00ecd7b09df11f13a62b01dea13c211b9f13fd86854e103a" + url: "https://pub.dev" + source: hosted + version: "3.6.0" + patrol_log: + dependency: transitive + description: + name: patrol_log + sha256: "3d91c93e8d0ed19cb12d4b27aa24eca7294e0a48d9d8445505c9775feb5fde2d" + url: "https://pub.dev" + source: hosted + version: "0.10.1" petitparser: dependency: transitive description: @@ -712,6 +775,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -869,6 +940,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -1005,6 +1084,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 52af655..9738da7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter crypto: ^3.0.3 + ffi: ^2.1.0 hive: ^2.2.3 hive_flutter: ^1.1.0 auto_route: ">=11.1.0 <11.2.0" @@ -24,12 +25,25 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter + patrol: ^4.10.0 build_runner: ^2.4.6 flutter_lints: ^6.0.0 auto_route_generator: ^10.5.0 flutter_native_splash: ^2.3.4 flutter_launcher_icons: ^0.14.4 +# patrol test reads this block; android.package_name is the flavored +# applicationId (the integration flavor adds the .integration suffix), and +# patrol_cli refuses to run without it. +patrol: + app_name: kirakira + flavor: integration + test_directory: integration_test + android: + package_name: com.app.smol_mail.integration + flutter_launcher_icons: android: "launcher_icon" ios: false -- 2.51.2 From aa8c7ec71a80b71fd0c53afce6cf5339e64af012 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:15 +0300 Subject: [PATCH 3/6] feat: add AI-DECLARATION.md and MIT license badge Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- AI-DECLARATION.md | 17 +++++++++++++++++ README.md | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 AI-DECLARATION.md diff --git a/AI-DECLARATION.md b/AI-DECLARATION.md new file mode 100644 index 0000000..e028c4a --- /dev/null +++ b/AI-DECLARATION.md @@ -0,0 +1,17 @@ +--- +version: "0.1.2" +level: copilot +processes: + design: assist + implementation: pair + testing: pair + documentation: copilot + review: copilot + deployment: assist +--- + +This format is based on [AI-DECLARATION.md](https://ai-declaration.md/en/0.1.2). + +## Notes + +- diff --git a/README.md b/README.md index 92549c2..5d80e14 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # kirakira +[![AI-DECLARATION: copilot](https://img.shields.io/badge/䷼%20AI--DECLARATION-copilot-fee2e2?labelColor=fee2e2)](https://ai-declaration.md) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + A mobile client for [Smol Mail](https://smol.place), a minimalist end-to-end encrypted mail protocol: one Ed25519 identity, five operations, sealed and signed messages over a Noise_NX transport. Sibling of the reference CLI client (`https://smol.place`) and the browser client (`https://code.randogoth.com/randogoth/gsmol`). kirakira began as [FlashMail](https://github.com/sarthakkimtani/flash-mail), a Flutter UI template for a disposable-email app built on mail.tm. Its networking layer was replaced end to end with a from-scratch Smol Mail implementation (`lib/smol/`: crypto, Noise handshake, framing, client flows), and the UI was redesigned around a Material 3 theme — dark navy and mint green by default, with a matching light theme — built from a single `ColorScheme` and `AppColors` extension rather than hardcoded colors per screen. -- 2.51.2 From d7179694a6b151b47d5772e8280600ec7e6645e4 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:16 +0300 Subject: [PATCH 4/6] fix: serialize contact history entries as objects across the FFI --- native/src/ffi.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/native/src/ffi.rs b/native/src/ffi.rs index 04cb17e..f16f3c8 100644 --- a/native/src/ffi.rs +++ b/native/src/ffi.rs @@ -887,12 +887,21 @@ pub extern "C" fn smol_contact(store: *mut Store, address: *const c_char) -> *mu let address = text(address)?; #[derive(Serialize)] #[serde(rename_all = "camelCase")] + // History entries serialize as objects, matching smol_contacts and + // the facade's parser — a bare tuple would cross the FFI as a JSON + // array and break the first contact that ever gains rotation history. + struct History { + key: String, + until: i64, + } + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] struct Out { key: String, verified: bool, active: Option, accepted_key: Option, - history: Vec<(String, i64)>, + history: Vec, } let Some((key, verified)) = store.contact(address)? else { return Ok(ser(&Out { @@ -913,7 +922,7 @@ pub extern "C" fn smol_contact(store: *mut Store, address: *const c_char) -> *mu history: store .history(address)? .into_iter() - .map(|(key, until)| (b32(&key), until)) + .map(|(key, until)| History { key: b32(&key), until }) .collect(), })?) }) -- 2.51.2 From f7452832eabb495550a0d35c0d013c61bb5db83e Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:16 +0300 Subject: [PATCH 5/6] fix: copy the master at each layer so zeroization stays local --- lib/native/client.dart | 7 ++++++- lib/smol/store.dart | 7 +++++-- test/store_test.dart | 10 ++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/native/client.dart b/lib/native/client.dart index f445693..6685b74 100644 --- a/lib/native/client.dart +++ b/lib/native/client.dart @@ -67,7 +67,12 @@ class FumiNative { /// operation, so it never needs an isolate — and widget tests can settle /// it inside their fake-async zones. void setMaster(Uint8List master, {int? rotations}) { - _master = master; + // Own copy: the caller keeps its buffer (the onboarding screen zeroes + // its reference on dispose, and wipe zeroes Hive's), and the account + // rebuilds after register/restore/rotate must derive from untouched + // bytes — a shared buffer zeroized elsewhere would rebuild an account + // that matches none of our keys. + _master = Uint8List.fromList(master); _rebuildAccount(rotations: rotations); } diff --git a/lib/smol/store.dart b/lib/smol/store.dart index 9ace821..ef4a414 100644 --- a/lib/smol/store.dart +++ b/lib/smol/store.dart @@ -145,14 +145,17 @@ class SmolStore { void setMaster(Uint8List fresh) { // Hive's in-memory state updates synchronously and persists in the // background, so the store is consistent without awaiting the write. - unawaited(_meta.put("master", fresh)); + // The put takes its own copy: the caller's buffer is the caller's to + // zeroize (the onboarding screen does, on dispose), and a shared object + // would scrub the store's view with it. + unawaited(_meta.put("master", Uint8List.fromList(fresh))); _native.setMaster(fresh, rotations: 0); } /// The master restored from a backup, already at the rotation index the /// server bound. void restoreMaster(Uint8List master, int index) { - unawaited(_meta.put("master", master)); + unawaited(_meta.put("master", Uint8List.fromList(master))); _native.setMaster(master, rotations: index); } diff --git a/test/store_test.dart b/test/store_test.dart index 1b783a6..5e296af 100644 --- a/test/store_test.dart +++ b/test/store_test.dart @@ -77,14 +77,20 @@ void main() { final store = await freshStore("wipe"); final master = randomBytes(32); store.setMaster(master); + // The store's own copy, read back before the wipe. + final stored = store.master()!; store.pinServer("example.org", "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"); store.markRead("cd" * 32); await store.wipe(); // The bytes the store owned are overwritten, not just dereferenced — - // the caller's reference sees the zeros too. - expect(master.every((b) => b == 0), isTrue); + // the reference read back through the store sees the zeros. The + // caller's own buffer is the caller's to zeroize: the store holds a + // copy precisely so nobody else's zeroization can reach it, and vice + // versa a wipe never reaches a buffer the store handed out. + expect(stored.every((b) => b == 0), isTrue); + expect(master.every((b) => b == 0), isFalse); expect(store.master(), isNull); expect(store.identity(), isNull); expect(store.serverPin("example.org"), isNull); -- 2.51.2 From fa0809f021fe9351dc08f1cbe845740baa630925 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 20:35:16 +0300 Subject: [PATCH 6/6] test: patrol flows for fetch cancel, rotation and unchanged-key refresh --- integration_test/smoke_test.dart | 206 ++++++++++++++++++++++++++----- native/src/lib.rs | 8 +- pubspec.yaml | 3 + test/native_binding_test.dart | 57 ++++++++- 4 files changed, 240 insertions(+), 34 deletions(-) diff --git a/integration_test/smoke_test.dart b/integration_test/smoke_test.dart index 7a6c75a..4989479 100644 --- a/integration_test/smoke_test.dart +++ b/integration_test/smoke_test.dart @@ -10,6 +10,7 @@ import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_test/flutter_test.dart"; import "package:hive_flutter/hive_flutter.dart"; import "package:path_provider/path_provider.dart"; @@ -26,43 +27,68 @@ const testHost = String.fromEnvironment("SMOL_TEST_HOST", defaultValue: "10.0.2. // this machine — the trusted channel SPEC.md §4 asks a pin to come from. const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq"; -Future bootApp() async { - // main()'s boot, minus runApp: the test pumps the same tree. +// main()'s boot, minus runApp: the test pumps the same tree. The container is +// the one the UI reads from, so the tests drive the same SmolClient the +// screens do — the fetch cancellation test needs exactly that. +Future boot(PatrolIntegrationTester $) async { final dataDir = await getApplicationSupportDirectory(); Hive.init(dataDir.path); final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db"); applyPresetServer(store); - return ProviderScope( - overrides: [storeProvider.overrideWithValue(store)], + final container = + ProviderContainer(overrides: [storeProvider.overrideWithValue(store)]); + await $.pumpWidgetAndSettle(UncontrolledProviderScope( + container: container, child: const AppWidget(), - ); + )); + return container; +} + +// Onboarding into a registered account: a fresh identity, backed up, then +// registered under a name no server has bound yet. Returns the username. +Future onboard(PatrolIntegrationTester $, ProviderContainer container) async { + await $("Create Identity").tap(); + await $.pumpAndSettle(); + await $("I have backed it up").waitUntilVisible(); + await $("I have backed it up").tap(); + await $.pumpAndSettle(); + + final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; + // Enter text by TextField, not by its labelText: the decoration label is + // a RichText that is not hit-testable, so a text finder times out. + await $.enterText($(TextField).at(0), "$user@$testHost:1961"); + await $.enterText($(TextField).at(1), serverKey); + await $("Pin and Register").tap(); + + // The register round trip is real network: the inbox tabs only exist + // behind HomeGuard, so reaching them proves the account bound. + await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); + return user; +} + +// Imports the account's own smol:// address as a contact — the §8 verified +// path — so later steps have an entry to re-resolve. Leaves the app on the +// Contacts screen. +Future importSelfContact(PatrolIntegrationTester $, ProviderContainer container) async { + final client = container.read(clientProvider); + final uri = client.accountAddress()!.uri(client.identity!.publicKey); + await $("Contacts").tap(); + await $.pumpAndSettle(); + await $(Icons.person_add).tap(); + await $.pumpAndSettle(); + await $.enterText($(TextField).at(0), uri); + await $("Import").tap(); + await $("contact imported (verified key)").waitUntilVisible(); + // The snackbar floats over the bottom navigation bar; let it expire + // before the next navigation tap. + await $.pump(const Duration(seconds: 5)); } void main() { patrolTest("smoke: onboarding, register, self-send, fetch, read in requests", ($) async { - await $.pumpWidgetAndSettle(await bootApp()); - - // Onboarding: a fresh identity, backed up, registered under a name no - // server has bound yet — registration must succeed against the real - // server over Wi-Fi, through the Dart-resolved dial path. - await $("Create Identity").tap(); - await $.pumpAndSettle(); - await $("I have backed it up").waitUntilVisible(); - await $("I have backed it up").tap(); - await $.pumpAndSettle(); - - final user = "it${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}"; - // Enter text by TextField, not by its labelText: the decoration label is - // a RichText that is not hit-testable, so a text finder times out. - await $.enterText($(TextField).at(0), "$user@$testHost:1961"); - await $.enterText($(TextField).at(1), serverKey); - await $("Pin and Register").tap(); - - // The register round trip is real network: the inbox tabs only exist - // behind HomeGuard, so reaching them proves the account bound. - await $("Inbox").waitUntilVisible(timeout: const Duration(seconds: 30)); - await $("Requests").waitUntilVisible(); + final container = await boot($); + final user = await onboard($, container); // Compose the first self-addressed mail. await $(Icons.edit).tap(); @@ -82,7 +108,7 @@ void main() { await $.pumpAndSettle(); // First contact is unsolicited, so it landed in requests (sec 5.8) — - // and the §5.6 sent copy must read back as plain text, not . + // and the sec 5.6 sent copy must read back as plain text, not . await $("smoke subject").tap(); await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); @@ -94,4 +120,128 @@ void main() { await $.pumpAndSettle(); await $("smoke body from patrol").waitUntilVisible(); }); + + // Checklist item 11: a fetch interrupted mid-run keeps everything that + // already arrived, and fetching again completes the set without re-storing + // what the first pass took. There is no cancel control in the UI yet, so + // the test raises the facade's cancellation flag — the same call a cancel + // button would make; when one exists, drive it from the UI instead. + patrolTest( + "smoke: cancelled fetch keeps what arrived and resume completes it", + ($) async { + final container = await boot($); + final user = await onboard($, container); + final client = container.read(clientProvider); + final address = "$user@$testHost:1961"; + + // 20 letters with 40 KiB bodies: the fetch has real pages to chew + // through, so a cancel 600 ms in lands mid-run, not after it. + for (var i = 0; i < 20; i++) { + await client.send(address, "cancel $i", "cancel body $i\n${"x" * 40960}"); + } + + final fetch = client.fetch(); + await Future.delayed(const Duration(milliseconds: 600)); + client.cancelFetch(); + final summary = await fetch; + container.read(revisionProvider.notifier).bump(); + await $.pumpAndSettle(); + + // If the cancel landed mid-run, part of the page arrived and the + // warning banner says so; if the fetch had already finished, nothing + // was lost either way and the resume below is a no-op. + if (summary.stored < 20) { + await $("fetch cancelled; partial results kept").waitUntilVisible(); + } + + // The UI's fetch resumes and completes the set. The requests badge + // counts every unread message, so a re-stored duplicate would push + // the count past 20. + await $(Icons.cloud_download).tap(); + await $.pumpAndSettle(duration: const Duration(seconds: 5)); + await $("Requests (20)") + .waitUntilVisible(timeout: const Duration(seconds: 30)); + }); + + // Checklist item 13: after rotating, a correspondent re-resolving the + // address sees the signed-chain rotation banner — a warning, not the + // terminal mismatch — and mail sealed to the superseded key still reads. + patrolTest( + "smoke: rotation re-resolves through the chain; old mail stays readable", + ($) async { + final container = await boot($); + final user = await onboard($, container); + final client = container.read(clientProvider); + final address = "$user@$testHost:1961"; + + // A letter sealed to the pre-rotation key, waiting in requests. + await client.send(address, "rotation subject", + "rotation body before the key change"); + await client.fetch(); + container.read(revisionProvider.notifier).bump(); + await $.pumpAndSettle(); + + // The correspondent entry, bound to the current key, must exist before + // the rotation so re-resolving has a known key to move away from. + await importSelfContact($, container); + + // Rotate: the dialog pushes the next index's key with a certificate. + await $("Settings").tap(); + await $.pumpAndSettle(); + // The rotate row sits below the settings list's fold, and a SliverList + // builds lazily — off-screen rows do not exist as widgets until + // scrolled to, so bring it into the tree before tapping. + await $.scrollUntilVisible(finder: $("Rotate identity key")); + await $("Rotate identity key").tap(); + await $.pumpAndSettle(); + await $("Rotate").tap(); + await $(RegExp("rotated; new key")).waitUntilVisible(); + await $.pump(const Duration(seconds: 5)); + + // Re-resolve: the chain validates, so the outcome is the rotation + // banner, and the superseded key moves to the §8 history section. + await $("Contacts").tap(); + await $.pumpAndSettle(); + await $(client.accountAddress()!.short).tap(); + await $.pumpAndSettle(); + await $("Re-resolve").tap(); + await $(RegExp("rotated its key; a signed chain confirms it")) + .waitUntilVisible(timeout: const Duration(seconds: 30)); + await $("previous keys (1)").waitUntilVisible(); + await $("Dismiss").tap(); + + // Mail sealed to the old key: the master still derives it, so the + // letter reads exactly as before the rotation. The AppBar's back arrow + // pops the detail route; the native back press is avoided here because + // it killed the patrol connection at this point in an earlier run. + await $(Icons.arrow_back).tap(); + await $.pumpAndSettle(); + await $("Mail").tap(); + await $.pumpAndSettle(); + await $("Requests (1)").tap(); + await $.pumpAndSettle(); + await $("rotation subject").tap(); + await $.pumpAndSettle(); + await $("rotation body before the key change").waitUntilVisible(); + }); + + // Checklist item 14: re-resolving a contact whose key did not change is a + // quiet confirmation — no banner, no history section. + patrolTest("smoke: re-resolving an unchanged contact is quiet", ($) async { + final container = await boot($); + await onboard($, container); + final client = container.read(clientProvider); + final short = client.accountAddress()!.short; + + await importSelfContact($, container); + + await $(short).tap(); + await $.pumpAndSettle(); + await $("Re-resolve").tap(); + await $("$short: key unchanged") + .waitUntilVisible(timeout: const Duration(seconds: 30)); + // A banner would have carried the rotation warning instead; history + // records a rotation, and there was none. + expect($("previous keys (1)"), findsNothing); + }); } diff --git a/native/src/lib.rs b/native/src/lib.rs index faec893..b04be2b 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -64,7 +64,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -146,7 +146,7 @@ mod tests { use std::sync::Arc; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -254,7 +254,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() @@ -328,7 +328,7 @@ mod tests { use rand_core::{OsRng, RngCore}; let server: [u8; KEY_LEN] = unb32( - "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq", + "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga", ) .unwrap() .try_into() diff --git a/pubspec.yaml b/pubspec.yaml index 9738da7..1c448de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,9 @@ patrol: app_name: kirakira flavor: integration test_directory: integration_test + # A failing device test leaves the phone in an unknown state; the screenshot + # is the only way to see it from the host. + screenshot_on_failure: true android: package_name: com.app.smol_mail.integration diff --git a/test/native_binding_test.dart b/test/native_binding_test.dart index 7d354a6..3a36ebc 100644 --- a/test/native_binding_test.dart +++ b/test/native_binding_test.dart @@ -4,6 +4,7 @@ import "dart:convert"; import "dart:io"; +import "dart:math"; import "dart:typed_data"; import "package:flutter_test/flutter_test.dart"; @@ -11,7 +12,7 @@ import "package:flutter_test/flutter_test.dart"; import "package:smol_mail/native/client.dart"; import "package:smol_mail/native/ffi.dart"; -const spikeServerKey = "g5ttsssqjq6j37vso3h7otn33zrk6o6r7c6jjm7guxbbijoa5nqq"; +const spikeServerKey = "wukbhdiwboarbg4pujxstbqi3caveunrruaopbwxr226ga3xv5ga"; const spikeServer = "127.0.0.1"; const spikePort = 19619; @@ -90,7 +91,8 @@ void main() async { // username, so identities must be fresh per run. final aliceMaster = Uint8List.fromList(List.generate(32, (i) => i * 7 + DateTime.now().microsecondsSinceEpoch % 251)); - final bobMaster = Uint8List.fromList(List.generate(32, (i) => i * 13 + 5)); + final bobMaster = + Uint8List.fromList(List.generate(32, (_) => Random.secure().nextInt(256))); final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); final alice = FumiNative("${dir.path}/alice.db"); @@ -124,4 +126,55 @@ void main() async { await alice.close(); await bob.close(); }); + + // sec 7's readability invariant: mail sealed to the pre-rotation key must + // still open after a rotation, because the account derives every superseded + // key from the master. + test("old mail stays readable across a rotation", + skip: await spikeUp() + ? false + : "no bunshin on 127.0.0.1:19619", () async { + final dir = await Directory.systemTemp.createTemp("native-rotate"); + // Random masters: bunshin refuses a key already bound under another + // username, so identities must be fresh per run. + final rng = Random.secure(); + final aliceMaster = + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final bobMaster = + Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final run = DateTime.now().millisecondsSinceEpoch.toRadixString(36); + + final alice = FumiNative("${dir.path}/alice.db"); + await alice.open(); + alice.setMaster(aliceMaster); + await alice.pinServer(spikeServer, spikeServerKey); + await alice.register("a$run@$spikeServer:$spikePort"); + + final bob = FumiNative("${dir.path}/bob.db"); + await bob.open(); + bob.setMaster(bobMaster); + // The onboarding screen zeroes its own master reference on dispose; + // whatever holds the master after that must not share that buffer. + bobMaster.fillRange(0, 32, 0); + await bob.pinServer(spikeServer, spikeServerKey); + await bob.register("b$run@$spikeServer:$spikePort"); + + await alice.send("b$run@$spikeServer:$spikePort", + "before the rotation", + subject: "pre-rotation"); + final summary = await bob.fetch(); + expect(summary["stored"], 1); + final requests = await bob.mail("requests"); + final preRotationId = requests[0]["id"] as String; + final before = await bob.describe(preRotationId); + expect(before["subject"], "pre-rotation"); + + await bob.rotate(); + final after = await bob.describe(preRotationId); + expect(after["subject"], "pre-rotation", + reason: "mail sealed to the superseded key must stay readable"); + + await alice.close(); + await bob.close(); + }); } -- 2.51.2