fix: read back sent copies after the sec 5.6 upstream fix, pinned at 2d65d66
This commit is contained in:
parent
52947e153d
commit
d6f4c434fa
36 changed files with 4223 additions and 3118 deletions
90
android/gradlew.bat
vendored
Executable file
90
android/gradlew.bat
vendored
Executable file
|
|
@ -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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
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/client.dart";
|
||||||
import "package:smol_mail/smol/proto.dart";
|
|
||||||
import "package:smol_mail/smol/store.dart";
|
import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
/// Overridden in main() with the Hive-backed store opened at boot.
|
/// Overridden in main() with the Hive-backed store opened at boot.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:hive_flutter/hive_flutter.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.
|
// one that's actually sandboxed per-app on every platform.
|
||||||
final dataDir = await getApplicationSupportDirectory();
|
final dataDir = await getApplicationSupportDirectory();
|
||||||
Hive.init(dataDir.path);
|
Hive.init(dataDir.path);
|
||||||
final store = await SmolStore.open();
|
final store = await SmolStore.open(dbPath: "${dataDir.path}/smol-mail.db");
|
||||||
applyPresetServer(store);
|
applyPresetServer(store);
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
|
|
|
||||||
211
lib/native/client.dart
Normal file
211
lib/native/client.dart
Normal file
|
|
@ -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<T> _io<T>(T Function(SmolFfi ffi) op) => Isolate.run(() {
|
||||||
|
final ffi = SmolFfi.open();
|
||||||
|
return op(ffi);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<String> accountPk() => _io((ffi) => ffi.accountPk(_a));
|
||||||
|
|
||||||
|
Future<String?> accountAddress() => _io((ffi) => ffi.accountAddress(_s));
|
||||||
|
|
||||||
|
Future<int> rotations() => _io((ffi) => ffi.rotations(_s));
|
||||||
|
|
||||||
|
Future<void> pinServer(String host, String keyB32) =>
|
||||||
|
_io((ffi) => ffi.pinServer(_s, host, keyB32));
|
||||||
|
|
||||||
|
Future<String?> serverPin(String host) =>
|
||||||
|
_io((ffi) => ffi.serverPin(_s, host));
|
||||||
|
|
||||||
|
Future<void> unpinServer(String host) =>
|
||||||
|
_io((ffi) => ffi.unpinServer(_s, host));
|
||||||
|
|
||||||
|
Future<List<dynamic>> pins() => _io((ffi) => ffi.pins(_s));
|
||||||
|
|
||||||
|
Future<bool> syncOk() => _io((ffi) => ffi.syncOk(_s));
|
||||||
|
|
||||||
|
Future<void> saveContact(String address, String keyB32,
|
||||||
|
{required bool verified}) =>
|
||||||
|
_io((ffi) => ffi.saveContact(_s, address, keyB32, verified: verified));
|
||||||
|
|
||||||
|
// --- account lifecycle ------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> 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<void> restore(String address, {String? dial, int timeout = 30}) =>
|
||||||
|
_io((ffi) =>
|
||||||
|
ffi.restore(_s, _master!, address, dial: dial, timeout: timeout))
|
||||||
|
.then((_) => _rebuildAccount());
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> delete(List<String> 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<void> deleteLocal(String folder, List<String> idsHex) =>
|
||||||
|
_io((ffi) => ffi.deleteLocal(_s, folder, idsHex));
|
||||||
|
|
||||||
|
Future<List<dynamic>> mail(String folder) =>
|
||||||
|
_io((ffi) => ffi.mail(_s, folder));
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> describe(String idHex) =>
|
||||||
|
_io((ffi) => ffi.describe(_s, _a, idHex));
|
||||||
|
|
||||||
|
// --- contacts ---------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<List<dynamic>> contacts() => _io((ffi) => ffi.contacts(_s));
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> contact(String address) =>
|
||||||
|
_io((ffi) => ffi.contact(_s, address));
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> resolve(String address,
|
||||||
|
{String? dial, int timeout = 30}) =>
|
||||||
|
_io((ffi) => ffi.resolve(_s, address, dial: dial, timeout: timeout));
|
||||||
|
|
||||||
|
Future<void> importContact(String uri) =>
|
||||||
|
_io((ffi) => ffi.importContact(_s, uri));
|
||||||
|
|
||||||
|
Future<int> accept(String address, {String? dial, int timeout = 30}) =>
|
||||||
|
_io((ffi) => ffi.accept(_s, _a, address, dial: dial, timeout: timeout));
|
||||||
|
|
||||||
|
Future<void> block(String address, {String? dial, int timeout = 30}) =>
|
||||||
|
_io((ffi) => ffi.block(_s, _a, address, dial: dial, timeout: timeout));
|
||||||
|
|
||||||
|
// --- backups ----------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<String> exportBackup() => _io((ffi) => ffi.exportBackup(_s, _master!));
|
||||||
|
|
||||||
|
Future<String> importBackup(String text) =>
|
||||||
|
_io((ffi) => ffi.importBackup(_s, _master!, text));
|
||||||
|
}
|
||||||
518
lib/native/ffi.dart
Normal file
518
lib/native/ffi.dart
Normal file
|
|
@ -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<String, dynamic> 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<Void> _h(int address) => Pointer<Void>.fromAddress(address);
|
||||||
|
|
||||||
|
late final Pointer<Utf8> Function() _lastError = _lib
|
||||||
|
.lookupFunction<Pointer<Utf8> Function(), Pointer<Utf8> Function()>(
|
||||||
|
"smol_last_error");
|
||||||
|
late final void Function(Pointer<Utf8>) _freeString = _lib.lookupFunction<
|
||||||
|
Void Function(Pointer<Utf8>),
|
||||||
|
void Function(Pointer<Utf8>)>("smol_free_string");
|
||||||
|
|
||||||
|
late final Pointer<Void> Function(Pointer<Utf8>) _storeOpen = _lib
|
||||||
|
.lookupFunction<Pointer<Void> Function(Pointer<Utf8>),
|
||||||
|
Pointer<Void> Function(Pointer<Utf8>)>("smol_store_open");
|
||||||
|
late final Pointer<Void> Function() _storeMemory = _lib.lookupFunction<
|
||||||
|
Pointer<Void> Function(),
|
||||||
|
Pointer<Void> Function()>("smol_store_memory");
|
||||||
|
late final void Function(Pointer<Void>) _storeFree = _lib.lookupFunction<
|
||||||
|
Void Function(Pointer<Void>),
|
||||||
|
void Function(Pointer<Void>)>("smol_store_free");
|
||||||
|
late final Pointer<Void> Function(Pointer<Uint8>, int) _accountNew = _lib
|
||||||
|
.lookupFunction<
|
||||||
|
Pointer<Void> Function(Pointer<Uint8>, Uint32),
|
||||||
|
Pointer<Void> Function(
|
||||||
|
Pointer<Uint8>, int)>("smol_account_new");
|
||||||
|
late final void Function(Pointer<Void>) _accountFree = _lib.lookupFunction<
|
||||||
|
Void Function(Pointer<Void>),
|
||||||
|
void Function(Pointer<Void>)>("smol_account_free");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>) _accountPk = _lib
|
||||||
|
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>)>("smol_account_pk");
|
||||||
|
late final Pointer<Void> Function() _flagNew = _lib.lookupFunction<
|
||||||
|
Pointer<Void> Function(),
|
||||||
|
Pointer<Void> Function()>("smol_flag_new");
|
||||||
|
late final void Function(Pointer<Void>, int) _flagSet = _lib.lookupFunction<
|
||||||
|
Void Function(Pointer<Void>, Int32),
|
||||||
|
void Function(Pointer<Void>, int)>("smol_flag_set");
|
||||||
|
late final void Function(Pointer<Void>) _flagFree = _lib.lookupFunction<
|
||||||
|
Void Function(Pointer<Void>),
|
||||||
|
void Function(Pointer<Void>)>("smol_flag_free");
|
||||||
|
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)
|
||||||
|
_pinServer = _lib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>),
|
||||||
|
int Function(
|
||||||
|
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)>("smol_pin_server");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)
|
||||||
|
_serverPin = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)>(
|
||||||
|
"smol_server_pin");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>) _storeAccount = _lib
|
||||||
|
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>)>("smol_store_account");
|
||||||
|
late final int Function(Pointer<Void>) _rotations = _lib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Void>),
|
||||||
|
int Function(Pointer<Void>)>("smol_rotations");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, Pointer<Utf8>, int) _register = _lib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, Uint64),
|
||||||
|
int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int)
|
||||||
|
>("smol_register");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int) _restore = _lib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, Uint64),
|
||||||
|
int Function(Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int)>("smol_restore");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>,
|
||||||
|
Pointer<Utf8>, int) _rotate = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Uint64),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
int)>("smol_rotate");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, int, int,
|
||||||
|
Pointer<Utf8>, int, Pointer<Void>) _fetch = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Int32, Int32,
|
||||||
|
Pointer<Utf8>, Uint64, Pointer<Void>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, int, int,
|
||||||
|
Pointer<Utf8>, int, Pointer<Void>)>("smol_fetch");
|
||||||
|
late final Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>,
|
||||||
|
Pointer<Void>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
int,
|
||||||
|
int,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
int) _send = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>,
|
||||||
|
Pointer<Void>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Int32,
|
||||||
|
Int32,
|
||||||
|
Pointer<Utf8>,
|
||||||
|
Uint64),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>, int, int,
|
||||||
|
Pointer<Utf8>, int)>("smol_send");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int) _delete = _lib.lookupFunction<
|
||||||
|
Int32 Function(
|
||||||
|
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||||
|
int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int)>("smol_delete");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>) _mail = _lib
|
||||||
|
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>),
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Utf8>)>("smol_mail");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>,
|
||||||
|
Pointer<Utf8>) _describe = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>),
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Void>, Pointer<Utf8>)>("smol_describe");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>) _contacts = _lib
|
||||||
|
.lookupFunction<Pointer<Utf8> Function(Pointer<Void>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>)>("smol_contacts");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>) _contact =
|
||||||
|
_lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>)>(
|
||||||
|
"smol_contact");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>,
|
||||||
|
Pointer<Utf8>, Pointer<Utf8>, int) _accept = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int)>("smol_accept");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int) _block = _lib.lookupFunction<
|
||||||
|
Int32 Function(
|
||||||
|
Pointer<Void>, Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||||
|
int Function(Pointer<Void>, Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int)>("smol_block");
|
||||||
|
late final Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int) _resolve = _lib
|
||||||
|
.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Uint64),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Utf8>,
|
||||||
|
Pointer<Utf8>, int)>("smol_resolve");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Utf8>) _accountSet = _lib
|
||||||
|
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>),
|
||||||
|
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_account_set");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, int)
|
||||||
|
_contactSave = _lib.lookupFunction<
|
||||||
|
Int32 Function(
|
||||||
|
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>, Int32),
|
||||||
|
int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>,
|
||||||
|
int)>("smol_contact_save");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Utf8>) _unpinServer = _lib
|
||||||
|
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>),
|
||||||
|
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_unpin_server");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>) _pins = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>)>("smol_pins");
|
||||||
|
late final int Function(Pointer<Void>) _syncOk = _lib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Void>),
|
||||||
|
int Function(Pointer<Void>)>("smol_sync_ok");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)
|
||||||
|
_deleteLocal = _lib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>),
|
||||||
|
int Function(
|
||||||
|
Pointer<Void>, Pointer<Utf8>, Pointer<Utf8>)>("smol_delete_local");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Utf8>) _parseAddress = _lib
|
||||||
|
.lookupFunction<Pointer<Utf8> Function(Pointer<Utf8>),
|
||||||
|
Pointer<Utf8> Function(Pointer<Utf8>)>("smol_parse_address");
|
||||||
|
late final int Function(Pointer<Void>, Pointer<Utf8>) _importContact = _lib
|
||||||
|
.lookupFunction<Int32 Function(Pointer<Void>, Pointer<Utf8>),
|
||||||
|
int Function(Pointer<Void>, Pointer<Utf8>)>("smol_import_contact");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Uint8>)
|
||||||
|
_exportBackup = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(Pointer<Void>, Pointer<Uint8>),
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Uint8>)>("smol_export_backup");
|
||||||
|
late final Pointer<Utf8> Function(Pointer<Void>, Pointer<Uint8>,
|
||||||
|
Pointer<Utf8>) _importBackup = _lib.lookupFunction<
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>),
|
||||||
|
Pointer<Utf8> Function(
|
||||||
|
Pointer<Void>, Pointer<Uint8>, Pointer<Utf8>)>(
|
||||||
|
"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<String, dynamic> details;
|
||||||
|
try {
|
||||||
|
details = jsonDecode(text) as Map<String, dynamic>;
|
||||||
|
} 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<Utf8> ptr) {
|
||||||
|
if (ptr == nullptr) _fail();
|
||||||
|
final text = ptr.toDartString();
|
||||||
|
_freeString(ptr);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
Pointer<Utf8> _text(String text) => text.toNativeUtf8();
|
||||||
|
|
||||||
|
Pointer<Uint8> _bytes(Uint8List bytes) {
|
||||||
|
final buf = malloc<Uint8>(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<dynamic> pins(int store) =>
|
||||||
|
jsonDecode(take(_pins(_h(store)))) as List<dynamic>;
|
||||||
|
|
||||||
|
/// 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<String, dynamic> 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<String, dynamic>;
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> ids,
|
||||||
|
{String? dial, int timeout = 30}) {
|
||||||
|
if (_delete(_h(store), _h(account), _text(jsonEncode(ids)),
|
||||||
|
dial == null ? nullptr : _text(dial), timeout) !=
|
||||||
|
smolOk) {
|
||||||
|
_fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> mail(int store, String folder) =>
|
||||||
|
jsonDecode(take(_mail(_h(store), _text(folder)))) as List<dynamic>;
|
||||||
|
|
||||||
|
Map<String, dynamic> describe(int store, int account, String idHex) =>
|
||||||
|
jsonDecode(take(_describe(_h(store), _h(account), _text(idHex))))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
List<dynamic> contacts(int store) =>
|
||||||
|
jsonDecode(take(_contacts(_h(store)))) as List<dynamic>;
|
||||||
|
|
||||||
|
Map<String, dynamic> contact(int store, String address) =>
|
||||||
|
jsonDecode(take(_contact(_h(store), _text(address))))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
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<String, dynamic>)["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<String, dynamic> 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<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String> 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<String, dynamic> parseAddress(String text) {
|
||||||
|
return jsonDecode(take(_parseAddress(_text(text))))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
lib/native/library.dart
Normal file
13
lib/native/library.dart
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,9 @@ import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||||
import "package:smol_mail/data/providers/providers.dart";
|
import "package:smol_mail/data/providers/providers.dart";
|
||||||
import "package:smol_mail/shared/utils/format.dart";
|
import "package:smol_mail/shared/utils/format.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.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/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 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
|
/// the row can only afford a fingerprint, and §8 turns on the user being able
|
||||||
|
|
@ -125,7 +126,7 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_field(context, "fingerprint", fingerprint(contact.key)),
|
_field(context, "fingerprint", fingerprint(contact.key)),
|
||||||
_field(context, "public key", b32encode(contact.key)),
|
_field(context, "public key", contact.key),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -162,7 +163,7 @@ class _ContactDetailScreenState extends ConsumerState<ContactDetailScreen> {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(fingerprint(entry.key)),
|
Text(fingerprint(entry.key)),
|
||||||
Text(b32encode(entry.key),
|
Text(entry.key,
|
||||||
style: Theme.of(context).textTheme.labelSmall),
|
style: Theme.of(context).textTheme.labelSmall),
|
||||||
Text(
|
Text(
|
||||||
"replaced ${formatTime(entry.until ~/ 1000)}",
|
"replaced ${formatTime(entry.until ~/ 1000)}",
|
||||||
|
|
|
||||||
|
|
@ -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/presentation/theme/app_colors.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/errors.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
|
/// Contacts: the addresses bound to keys, with the trust badge and the number
|
||||||
/// of keys each has replaced — the §8 surface for noticing rotations.
|
/// of keys each has replaced — the §8 surface for noticing rotations.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import "dart:typed_data";
|
|
||||||
|
|
||||||
import "package:auto_route/auto_route.dart";
|
import "package:auto_route/auto_route.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
|
|
@ -40,7 +39,7 @@ class MessageDetailScreen extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _nameSender(BuildContext context, WidgetRef ref,
|
Future<void> _nameSender(BuildContext context, WidgetRef ref,
|
||||||
Uint8List senderKey) async {
|
String senderKey) async {
|
||||||
final client = ref.read(clientProvider);
|
final client = ref.read(clientProvider);
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
final address = await showDialog<String>(
|
final address = await showDialog<String>(
|
||||||
|
|
|
||||||
|
|
@ -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/primary_button.dart";
|
||||||
import "package:smol_mail/presentation/widgets/secondary_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/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/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/presentation/theme/app_colors.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||||
|
|
||||||
|
|
@ -49,6 +49,10 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
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();
|
seedController.dispose();
|
||||||
restoreAddressController.dispose();
|
restoreAddressController.dispose();
|
||||||
addressController.dispose();
|
addressController.dispose();
|
||||||
|
|
@ -81,11 +85,12 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _createIdentity() {
|
Future<void> _createIdentity() async {
|
||||||
_clearAbandonedIdentity();
|
_clearAbandonedIdentity();
|
||||||
final client = ref.read(clientProvider);
|
final client = ref.read(clientProvider);
|
||||||
try {
|
try {
|
||||||
final master = client.createIdentity();
|
final master = await client.createIdentity();
|
||||||
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
createdMaster = master;
|
createdMaster = master;
|
||||||
step = _Step.backup;
|
step = _Step.backup;
|
||||||
|
|
|
||||||
|
|
@ -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/routes/app_router.gr.dart";
|
||||||
import "package:smol_mail/presentation/theme/app_colors.dart";
|
import "package:smol_mail/presentation/theme/app_colors.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.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/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
|
/// Identity card, server pins, rotation and the wipe. The seed is the only
|
||||||
/// secret; revealing it is always an explicit action.
|
/// secret; revealing it is always an explicit action.
|
||||||
|
|
@ -69,10 +68,11 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
// pinned servers into one shareable file; never the seed.
|
// pinned servers into one shareable file; never the seed.
|
||||||
Future<void> _export() async {
|
Future<void> _export() async {
|
||||||
final store = ref.read(storeProvider);
|
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 stamp = DateTime.now().toIso8601String().substring(0, 10);
|
||||||
final fileName = "kirakira-backup-$stamp.json";
|
final fileName = "kirakira-backup-$stamp.json";
|
||||||
final bytes = Uint8List.fromList(utf8.encode(jsonEncode(data)));
|
|
||||||
try {
|
try {
|
||||||
// share_plus has no file-sharing implementation on desktop platforms —
|
// share_plus has no file-sharing implementation on desktop platforms —
|
||||||
// it throws UnimplementedError for Linux/Windows/macOS — so those save
|
// it throws UnimplementedError for Linux/Windows/macOS — so those save
|
||||||
|
|
@ -108,8 +108,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
final path = files.isEmpty ? null : files.single.path;
|
final path = files.isEmpty ? null : files.single.path;
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(File(path).readAsStringSync()) as Map;
|
final summary =
|
||||||
final summary = await ref.read(storeProvider).importData(data);
|
await ref.read(storeProvider).importData(File(path).readAsStringSync());
|
||||||
ref.read(revisionProvider.notifier).bump();
|
ref.read(revisionProvider.notifier).bump();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -165,7 +165,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
ref.read(revisionProvider.notifier).bump();
|
ref.read(revisionProvider.notifier).bump();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text("rotated; new key ${b32encode(fresh.publicKey)}")),
|
SnackBar(content: Text("rotated; new key ${fresh.publicKey}")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -313,7 +313,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(host),
|
title: Text(host),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
b32encode(key),
|
key,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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/presentation/theme/app_colors.dart";
|
||||||
import "package:smol_mail/shared/utils/avatar_color.dart";
|
import "package:smol_mail/shared/utils/avatar_color.dart";
|
||||||
import "package:smol_mail/shared/utils/format.dart";
|
import "package:smol_mail/shared/utils/format.dart";
|
||||||
import "package:smol_mail/smol/proto.dart";
|
|
||||||
import "package:smol_mail/smol/store.dart";
|
import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
class MessageTile extends ConsumerWidget {
|
class MessageTile extends ConsumerWidget {
|
||||||
|
|
@ -27,7 +26,7 @@ class MessageTile extends ConsumerWidget {
|
||||||
who = record.recipient ?? "";
|
who = record.recipient ?? "";
|
||||||
} else if (opened.sender != null) {
|
} else if (opened.sender != null) {
|
||||||
final known = store.addressForKey(opened.sender!);
|
final known = store.addressForKey(opened.sender!);
|
||||||
who = known ?? "unknown · ${b32encode(opened.sender!).substring(0, 4)}";
|
who = known ?? "unknown · ${opened.sender!.substring(0, 4)}";
|
||||||
} else {
|
} else {
|
||||||
who = "?";
|
who = "?";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import "dart:typed_data";
|
|
||||||
|
|
||||||
import "package:auto_route/auto_route.dart";
|
import "package:auto_route/auto_route.dart";
|
||||||
import "package:flutter/material.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/format.dart";
|
||||||
import "package:smol_mail/shared/utils/snackbar.dart";
|
import "package:smol_mail/shared/utils/snackbar.dart";
|
||||||
import "package:smol_mail/smol/client.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/store.dart";
|
||||||
|
import "package:smol_mail/smol/ui.dart";
|
||||||
|
|
||||||
/// The opened message: trust row (fingerprint and how the sender's key is
|
/// 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.
|
/// known), frontmatter fields, and the plain body — smol mail has no HTML.
|
||||||
|
|
@ -18,7 +17,7 @@ class MessageView extends ConsumerWidget {
|
||||||
final String folder;
|
final String folder;
|
||||||
final MailRecord record;
|
final MailRecord record;
|
||||||
final OpenedRecord opened;
|
final OpenedRecord opened;
|
||||||
final void Function(Uint8List senderKey)? onNameSender;
|
final void Function(String senderKey)? onNameSender;
|
||||||
|
|
||||||
const MessageView({
|
const MessageView({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -78,7 +77,7 @@ class MessageView extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
if (!isSent && senderKey != null) ...[
|
if (!isSent && senderKey != null) ...[
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_keyBlock(context, "${fingerprint(senderKey)}\n${b32encode(senderKey)}"),
|
_keyBlock(context, "${fingerprint(senderKey)}\n$senderKey"),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_field(context, isSent ? "To" : "From",
|
_field(context, isSent ? "To" : "From",
|
||||||
|
|
|
||||||
48
lib/smol/address.dart
Normal file
48
lib/smol/address.dart
Normal file
|
|
@ -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<String, dynamic> 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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
// App-level client: the flows of gsmol's app.js — connect with pinning, fetch
|
// The app-level client: the same flows the screens have always called —
|
||||||
// with verification and acknowledgment, send with sent copies, contacts and
|
// connect with pinning, fetch with verification and acknowledgment, send
|
||||||
// rotation — on top of the pure protocol modules.
|
// 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 "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/errors.dart";
|
||||||
import "package:smol_mail/smol/proto.dart";
|
|
||||||
import "package:smol_mail/smol/store.dart";
|
import "package:smol_mail/smol/store.dart";
|
||||||
import "package:smol_mail/smol/transport.dart";
|
|
||||||
|
|
||||||
class RefreshOutcome {
|
class RefreshOutcome {
|
||||||
final String message;
|
final String message;
|
||||||
|
|
@ -27,7 +31,7 @@ class FetchSummary {
|
||||||
|
|
||||||
class OpenedRecord {
|
class OpenedRecord {
|
||||||
final String id;
|
final String id;
|
||||||
final Uint8List? sender;
|
final String? sender; // base32
|
||||||
final int? time;
|
final int? time;
|
||||||
final Map<String, String> fields;
|
final Map<String, String> fields;
|
||||||
final String body;
|
final String body;
|
||||||
|
|
@ -50,10 +54,41 @@ class SmolClient {
|
||||||
|
|
||||||
void _warn(String message) => onWarning?.call(message);
|
void _warn(String message) => onWarning?.call(message);
|
||||||
|
|
||||||
// Opening an envelope costs an X25519 agreement and an Ed25519
|
FumiNative get _native => store.native;
|
||||||
// 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
|
SmolFfi get _ffi => SmolFfi.open();
|
||||||
// every render. Rotation clears it, since the key set grew.
|
|
||||||
|
/// 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<String> _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<String> _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 = <String, OpenedRecord>{};
|
final _openedCache = <String, OpenedRecord>{};
|
||||||
|
|
||||||
SmolIdentity? get identity => store.identity();
|
SmolIdentity? get identity => store.identity();
|
||||||
|
|
@ -61,397 +96,211 @@ class SmolClient {
|
||||||
Uint8List? get master => store.master();
|
Uint8List? get master => store.master();
|
||||||
|
|
||||||
SmolAddress? accountAddress() {
|
SmolAddress? accountAddress() {
|
||||||
final account = store.account();
|
final text = _ffi.accountAddress(_native.store!);
|
||||||
if (account == null) return null;
|
if (text == null) return null;
|
||||||
final suffix = account.port == defaultPort ? "" : ":${account.port}";
|
return parseAddress(text);
|
||||||
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<OpenedSession> 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- identity setup ------------------------------------------------------------
|
// --- identity setup ------------------------------------------------------------
|
||||||
|
|
||||||
/// A fresh master secret at rotation index 0. Only this local step; nothing
|
/// A fresh master secret at rotation index 0. Only this local step;
|
||||||
/// is sent until [registerAccount].
|
/// nothing is sent until [registerAccount].
|
||||||
Uint8List createIdentity() {
|
Future<Uint8List> createIdentity() async {
|
||||||
final fresh = randomBytes(keyLen);
|
final fresh = _randomMaster();
|
||||||
store.setMaster(fresh);
|
store.setMaster(fresh);
|
||||||
return 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,
|
/// §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
|
/// so restoring resolves the address and walks indices until one derives
|
||||||
/// one derives the key RESOLVE returned. Also binds "account" locally, like
|
/// the key RESOLVE returned, then binds the account locally.
|
||||||
/// [recallAccount] — restoring on a new device knows the identity but not
|
|
||||||
/// the address it was registered under.
|
|
||||||
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
||||||
Uint8List master;
|
Uint8List master;
|
||||||
try {
|
try {
|
||||||
master = unhex(masterHex.trim());
|
master = _unhex(masterHex.trim());
|
||||||
} on Exception {
|
} on Exception {
|
||||||
throw const SmolError("master must be 64 hex characters");
|
throw const SmolError("master must be 64 hex characters");
|
||||||
}
|
}
|
||||||
if (master.length != keyLen) {
|
if (master.length != 32) {
|
||||||
throw SmolError("master is ${master.length} bytes, expected $keyLen");
|
throw SmolError("master is ${master.length} bytes, expected 32");
|
||||||
}
|
}
|
||||||
final addr = parseAddress(addressText);
|
final addr = parseAddress(addressText);
|
||||||
final opened = await connect(addr, requirePin: true);
|
store.restoreMaster(master, 0);
|
||||||
Uint8List current;
|
await _restore(addr);
|
||||||
try {
|
master.fillRange(0, master.length, 0);
|
||||||
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);
|
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
void pinServer(String host, String keyB32) {
|
Future<void> _restore(SmolAddress addr) async {
|
||||||
final key = b32decode(keyB32);
|
// fumi's restore would tolerate an unpinned server (sec 8 trust on
|
||||||
if (key.length != keyLen) {
|
// first use), but this app's onboarding teaches the pin up front:
|
||||||
throw SmolError("server key is ${key.length} bytes, expected $keyLen");
|
// 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<void> registerAccount(String addressText, {String token = ""}) async {
|
Future<void> registerAccount(String addressText, {String token = ""}) async {
|
||||||
final me = identity;
|
|
||||||
if (me == null) throw const SmolError("no identity yet");
|
|
||||||
final addr = parseAddress(addressText);
|
final addr = parseAddress(addressText);
|
||||||
final opened = await connect(addr, requirePin: true);
|
|
||||||
try {
|
try {
|
||||||
await registerOp(opened.session, opened.serverStatic, addr.user, me,
|
await _native.register(addr.short,
|
||||||
RegisterOptions(token: token));
|
invite: token.isEmpty ? null : token, dial: await _dial(addr.host));
|
||||||
} finally {
|
} on NativeSmolException catch (err) {
|
||||||
opened.session.wire.close();
|
throw SmolError(err.message);
|
||||||
}
|
}
|
||||||
store.setAccount(addr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Restoring a master brings back the identity, not the memory of what
|
/// Recall binds the identity to its registered address without
|
||||||
/// address a *different device* registered it under — "account" is
|
/// re-REGISTER — the same resolve-and-walk a restore does (§2).
|
||||||
/// 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.
|
|
||||||
Future<SmolAddress> recallAccount(String addressText) async {
|
Future<SmolAddress> recallAccount(String addressText) async {
|
||||||
final me = identity;
|
|
||||||
if (me == null) throw const SmolError("no identity yet");
|
|
||||||
final addr = parseAddress(addressText);
|
final addr = parseAddress(addressText);
|
||||||
final opened = await connect(addr, requirePin: true);
|
await _restore(addr);
|
||||||
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);
|
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- fetch ----------------------------------------------------------------------
|
// --- fetch -----------------------------------------------------------------
|
||||||
|
|
||||||
Future<FetchSummary> fetch() async {
|
Future<FetchSummary> fetch({bool reset = false}) async {
|
||||||
final me = identity;
|
if (master == null) throw const SmolError("no identity yet");
|
||||||
final master = this.master;
|
if (accountAddress() == null) {
|
||||||
final addr = accountAddress();
|
|
||||||
if (me == null || master == null) throw const SmolError("no identity yet");
|
|
||||||
if (addr == null) {
|
|
||||||
throw const SmolError("not registered; register an address first");
|
throw const SmolError("not registered; register an address first");
|
||||||
}
|
}
|
||||||
var stored = 0;
|
final Map<String, dynamic> summary;
|
||||||
final rejected = <String>[];
|
|
||||||
// §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);
|
|
||||||
try {
|
try {
|
||||||
final (sync, tokens) = store.tokenSet(master);
|
summary = await _native.fetch(
|
||||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
|
keep: store.leaveOnServer(), reset: reset, dial: await _accountDial());
|
||||||
sync: sync, tokens: tokens);
|
} on NativeSmolException catch (err) {
|
||||||
while (true) {
|
throw SmolError(err.message);
|
||||||
final records = await fetchOp(opened.session, afterTime, afterId);
|
|
||||||
if (records.isEmpty) break;
|
|
||||||
final acked = <Uint8List>[];
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
if (!leaveOnServer) {
|
_openedCache.clear();
|
||||||
// Everything acknowledged is deleted, so the next fetch starts fresh; a
|
if (summary["cancelled"] == true) {
|
||||||
// record left on the server (rejected above) simply resurfaces then.
|
_warn("fetch cancelled; partial results kept");
|
||||||
store.setCursor(0, Uint8List(idLen));
|
|
||||||
}
|
}
|
||||||
return FetchSummary(stored, rejected);
|
return FetchSummary(
|
||||||
|
summary["stored"] as int,
|
||||||
|
[
|
||||||
|
for (final r in (summary["rejected"] as List).cast<List<dynamic>>())
|
||||||
|
"${r[0]}: ${r[1]}",
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raises the cancellation flag; a running fetch stops between envelopes
|
||||||
|
/// and returns a partial summary.
|
||||||
|
void cancelFetch() => _native.cancelFetch();
|
||||||
|
|
||||||
// --- delete ------------------------------------------------------------------
|
// --- delete ------------------------------------------------------------------
|
||||||
|
|
||||||
/// Deletes a message locally, and from the server too if it might still be
|
/// 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
|
/// sitting there (only possible when "leave mail on server" was on when it
|
||||||
/// was fetched — §10). Sent copies are local-only; there is nothing
|
/// was fetched — §10). Sent copies are local-only (§5.6).
|
||||||
/// 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.
|
|
||||||
Future<void> deleteMessage(String folder, MailRecord record) async {
|
Future<void> deleteMessage(String folder, MailRecord record) async {
|
||||||
if (folder != "sent" && record.keptOnServer) {
|
if (folder != "sent" && record.keptOnServer) {
|
||||||
final me = identity;
|
if (accountAddress() == null) {
|
||||||
final addr = accountAddress();
|
|
||||||
if (me == null || addr == null) {
|
|
||||||
throw const SmolError(
|
throw const SmolError(
|
||||||
"not registered; cannot reach the server to delete this message");
|
"not registered; cannot reach the server to delete this message");
|
||||||
}
|
}
|
||||||
final opened = await connect(addr, requirePin: true);
|
|
||||||
try {
|
try {
|
||||||
await authenticate(opened.session, opened.handshakeHash, addr.user, me,
|
await _native.delete([record.id], dial: await _accountDial());
|
||||||
sync: 0, tokens: const []);
|
} on NativeSmolException catch (err) {
|
||||||
await deleteOp(opened.session, [unhex(record.id)]);
|
throw SmolError(err.message);
|
||||||
} finally {
|
|
||||||
opened.session.wire.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await store.deleteMessage(folder, record.id);
|
await store.deleteMessage(folder, record.id);
|
||||||
}
|
_openedCache.remove(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- accept tokens (§5.8) --------------------------------------------------------
|
// --- accept tokens (§5.8) --------------------------------------------------------
|
||||||
|
|
||||||
/// Admit a contact to the main tier; their token travels in our next
|
/// 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
|
/// message to them. Pushes the changed set to the server right away.
|
||||||
/// accept or a block only takes effect once it holds the changed set.
|
|
||||||
Future<int> acceptContact(String address) async {
|
Future<int> acceptContact(String address) async {
|
||||||
final key = store.contact(address)?.key;
|
try {
|
||||||
if (key == null) throw SmolError("no key for $address yet");
|
return await _native.accept(address, dial: await _accountDial());
|
||||||
store.accept(address, key);
|
} on NativeSmolException catch (err) {
|
||||||
store.setSyncOk(true);
|
throw SmolError(err.message);
|
||||||
return _pushTokens();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Withdraw a contact's accept token; their mail lands in requests from
|
/// Withdraw a contact's accept token; their mail lands in requests from
|
||||||
/// their next message on.
|
/// their next message on.
|
||||||
Future<int> blockContact(String address) async {
|
Future<int> blockContact(String address) async {
|
||||||
store.block(address);
|
|
||||||
return _pushTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> _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 {
|
try {
|
||||||
final (sync, tokens) = store.tokenSet(master);
|
await _native.block(address, dial: await _accountDial());
|
||||||
return await authenticate(opened.session, opened.handshakeHash, addr.user, me,
|
return 0;
|
||||||
sync: sync, tokens: tokens);
|
} on NativeSmolException catch (err) {
|
||||||
} finally {
|
throw SmolError(err.message);
|
||||||
opened.session.wire.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- compose and send -----------------------------------------------------------
|
// --- compose and send -----------------------------------------------------------
|
||||||
|
|
||||||
// Prefer a key we already trust; fall back to RESOLVE with trust on first
|
/// Sends one message (§5, §6.1): recipient selection prefers a key we
|
||||||
// use.
|
/// already trust, then RESOLVE with trust on first use; an accepted
|
||||||
Future<Uint8List> resolveRecipient(SmolAddress addr) async {
|
/// correspondent gets our token and a §5.6 sent copy is kept.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> send(String toText, String subject, String body,
|
Future<String> send(String toText, String subject, String body,
|
||||||
{String? replyTo, bool anonymous = false}) async {
|
{String? replyTo, bool anonymous = false}) async {
|
||||||
final me = identity;
|
if (master == null) throw const SmolError("no identity yet");
|
||||||
final master = this.master;
|
|
||||||
if (me == null || master == null) throw const SmolError("no identity yet");
|
|
||||||
final addr = parseAddress(toText);
|
final addr = parseAddress(toText);
|
||||||
final recipient = await resolveRecipient(addr);
|
final Map<String, dynamic> sent;
|
||||||
final account = accountAddress();
|
|
||||||
final fields = <String, String>{"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);
|
|
||||||
try {
|
try {
|
||||||
await sendOp(opened.session, envelope, mac: mac);
|
sent = await _native.send(addr.short, body,
|
||||||
} finally {
|
subject: subject.isEmpty ? null : subject,
|
||||||
opened.session.wire.close();
|
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;
|
return addr.short;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- reading -----------------------------------------------------------------
|
// --- reading -----------------------------------------------------------------
|
||||||
|
|
||||||
// What is known about a sender changes as the user binds addresses to keys,
|
/// Opens one sealed message: the described view the reader shows. Sync —
|
||||||
// so this layer sits over the cached envelope and is recomputed per call —
|
/// one unseal is microseconds native, and the old Dart core did the same
|
||||||
// it is a map lookup, not crypto.
|
/// work at fifty times the cost.
|
||||||
OpenedRecord describe(MailRecord row) {
|
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];
|
var entry = _openedCache[row.id];
|
||||||
if (entry == null) {
|
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 {
|
try {
|
||||||
final opened = unseal(store.identities(), row.envelope);
|
final described =
|
||||||
final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true));
|
_ffi.describe(_native.store!, _native.account!, row.id);
|
||||||
entry = OpenedRecord(row.id,
|
entry = OpenedRecord(
|
||||||
sender: opened.sender,
|
row.id,
|
||||||
time: opened.time,
|
sender: described["sender"] as String,
|
||||||
fields: parsed.fields,
|
time: described["time"] as int,
|
||||||
body: parsed.body);
|
fields: (described["fields"] as Map).cast<String, String>(),
|
||||||
} on SmolError catch (err) {
|
body: described["text"] as String,
|
||||||
|
);
|
||||||
|
} on NativeSmolException catch (err) {
|
||||||
entry = OpenedRecord(row.id, error: err.message);
|
entry = OpenedRecord(row.id, error: err.message);
|
||||||
}
|
}
|
||||||
_openedCache[row.id] = entry;
|
_openedCache[row.id] = entry;
|
||||||
|
|
@ -467,8 +316,7 @@ class SmolClient {
|
||||||
if (claim == null || opened.sender == null) return null;
|
if (claim == null || opened.sender == null) return null;
|
||||||
try {
|
try {
|
||||||
final parsed = parseAddress(claim);
|
final parsed = parseAddress(claim);
|
||||||
if (parsed.identity != null &&
|
if (parsed.identity != null && parsed.identity == opened.sender) {
|
||||||
timingSafeEqual(parsed.identity!, opened.sender!)) {
|
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
} on SmolError {
|
} on SmolError {
|
||||||
|
|
@ -481,38 +329,36 @@ class SmolClient {
|
||||||
/// address carries its own key (verified); a short address is resolved and
|
/// 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
|
/// the result kept on first use. Anything that binds a different key is
|
||||||
/// refused.
|
/// refused.
|
||||||
Future<void> nameSender(String text, Uint8List senderKey) async {
|
Future<void> nameSender(String text, String senderKey) async {
|
||||||
final addr = parseAddress(text.trim());
|
final addr = parseAddress(text.trim());
|
||||||
Uint8List key;
|
|
||||||
var verified = true;
|
|
||||||
if (addr.identity == null) {
|
if (addr.identity == null) {
|
||||||
final opened = await connect(addr, requirePin: false);
|
await refreshContact(addr.short);
|
||||||
try {
|
final known = store.contact(addr.short);
|
||||||
key = (await resolveOp(opened.session, addr.user)).identity;
|
if (known == null) {
|
||||||
} finally {
|
throw const SmolError("could not resolve that address");
|
||||||
opened.session.wire.close();
|
|
||||||
}
|
}
|
||||||
verified = false; // trust on first use, as with any RESOLVE
|
if (known.key != senderKey) {
|
||||||
} else {
|
throw const SmolError(
|
||||||
key = addr.identity!;
|
"that address carries a different key than this message's sender");
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (!timingSafeEqual(key, senderKey)) {
|
if (addr.identity != senderKey) {
|
||||||
throw const SmolError(
|
throw const SmolError(
|
||||||
"that address carries a different key than this message's sender");
|
"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 —
|
/// 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
|
/// but never over an address already pinned to a different key (§8).
|
||||||
/// change without a rotation chain needs out-of-band confirmation).
|
Future<void> saveReplyAddress(SmolAddress addr, String senderKey) async {
|
||||||
Future<void> saveReplyAddress(SmolAddress addr, Uint8List senderKey) async {
|
|
||||||
final existing = store.contact(addr.short);
|
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 — "
|
throw SmolError("${addr.short} is already known with a different key — "
|
||||||
"verify out of band before replying");
|
"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
|
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
|
||||||
|
|
@ -520,74 +366,64 @@ class SmolClient {
|
||||||
Future<RefreshOutcome> refreshContact(String address) async {
|
Future<RefreshOutcome> refreshContact(String address) async {
|
||||||
final addr = parseAddress(address);
|
final addr = parseAddress(address);
|
||||||
if (addr.identity != null) {
|
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 known = store.contact(addr.short);
|
||||||
final opened = await connect(addr, requirePin: false);
|
final Map<String, dynamic> resolved;
|
||||||
Resolved resolved;
|
|
||||||
try {
|
try {
|
||||||
resolved = await resolveOp(opened.session, addr.user);
|
resolved = await _native.resolve(addr.short, dial: await _dial(addr.host));
|
||||||
} finally {
|
} on NativeSmolException catch (err) {
|
||||||
opened.session.wire.close();
|
throw SmolError(err.message);
|
||||||
}
|
}
|
||||||
|
final change = resolved["change"] as String;
|
||||||
if (known == null) {
|
if (known == null) {
|
||||||
store.saveContact(addr.short, resolved.identity, false);
|
|
||||||
return RefreshOutcome(
|
return RefreshOutcome(
|
||||||
"${addr.short} pinned (trust on first use"
|
"${addr.short} pinned (trust on first use)", change == "newUnverified");
|
||||||
"${opened.pinned ? "" : ", UNVERIFIED server"})",
|
|
||||||
!opened.pinned);
|
|
||||||
}
|
}
|
||||||
if (timingSafeEqual(known.key, resolved.identity)) {
|
switch (change) {
|
||||||
return RefreshOutcome("${addr.short}: key unchanged", false);
|
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
|
// Bind a smol:// address to the key it carries (§8's strong path).
|
||||||
// displaced key, if any, lands in the contact's history.
|
Future<void> importContact(String text) async {
|
||||||
void importContact(String text) {
|
|
||||||
final addr = parseAddress(text.trim());
|
final addr = parseAddress(text.trim());
|
||||||
if (addr.identity == null) {
|
if (addr.identity == null) {
|
||||||
throw const SmolError("import needs a smol:// address carrying a key");
|
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 -----------------------------------------------------------------
|
// --- rotation -----------------------------------------------------------------
|
||||||
|
|
||||||
// §7: rotate to the next index's derived key and rebind the account with a
|
/// §7: rotate to the next index's derived key and rebind the account with
|
||||||
// signed certificate. The superseded key stays derivable from the master,
|
/// a signed certificate. The superseded key stays derivable from the
|
||||||
// since mail sealed to it stays readable with nothing else.
|
/// master, since mail sealed to it stays readable with nothing else.
|
||||||
Future<SmolIdentity> rotateIdentity() async {
|
Future<SmolIdentity> rotateIdentity() async {
|
||||||
final me = identity;
|
if (identity == null || master == null || accountAddress() == null) {
|
||||||
final master = this.master;
|
|
||||||
final addr = accountAddress();
|
|
||||||
if (me == null || master == null || addr == null) {
|
|
||||||
throw const SmolError("rotate needs a registered account");
|
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 {
|
try {
|
||||||
await registerOp(opened.session, opened.serverStatic, addr.user, fresh,
|
await _native.rotate(dial: await _accountDial());
|
||||||
RegisterOptions(cert: cert));
|
} on NativeSmolException catch (err) {
|
||||||
} finally {
|
throw SmolError(err.message);
|
||||||
opened.session.wire.close();
|
|
||||||
}
|
}
|
||||||
store.advanceRotation();
|
|
||||||
_openedCache.clear();
|
_openedCache.clear();
|
||||||
return fresh;
|
return identity!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A full wipe: every secret and every stored envelope. The UI must confirm.
|
// A full wipe: every secret and every stored envelope. The UI must confirm.
|
||||||
|
|
@ -595,4 +431,14 @@ class SmolClient {
|
||||||
await store.wipe();
|
await store.wipe();
|
||||||
_openedCache.clear();
|
_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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
// Deploy-time configuration, empty by default (mirrors gsmol's config.js).
|
||||||
// A release may bake in a server key with:
|
// A release may bake in a server key with:
|
||||||
// flutter build apk --dart-define=SMOL_PRESET_SERVER=example.org \
|
// 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,
|
// 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
|
// removable in settings like any other, and never overwrites a host the user
|
||||||
// (or a previous install) already pinned.
|
// (or a previous install) already pinned.
|
||||||
|
|
||||||
|
import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER");
|
const _presetHost = String.fromEnvironment("SMOL_PRESET_SERVER");
|
||||||
const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY");
|
const _presetKey = String.fromEnvironment("SMOL_PRESET_SERVER_KEY");
|
||||||
|
|
||||||
void applyPresetServer(SmolStore store) {
|
void applyPresetServer(SmolStore store) {
|
||||||
if (_presetHost.isEmpty || _presetKey.isEmpty) return;
|
if (_presetHost.isEmpty || _presetKey.isEmpty) return;
|
||||||
if (store.serverPin(_presetHost) != null) return;
|
if (store.serverPin(_presetHost) != null) return;
|
||||||
try {
|
// A malformed preset leaves the host unpinned rather than blocking boot.
|
||||||
store.pinServer(_presetHost, b32decode(_presetKey));
|
store.pinServer(_presetHost, _presetKey);
|
||||||
} on SmolError {
|
|
||||||
// malformed preset: leave unpinned rather than block boot
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<List<int>> 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<int> 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<int> a, List<int> 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<int> message) =>
|
|
||||||
Uint8List.fromList(hashes.sha256.convert(message).bytes);
|
|
||||||
|
|
||||||
Uint8List sha512(List<int> message) =>
|
|
||||||
Uint8List.fromList(hashes.sha512.convert(message).bytes);
|
|
||||||
|
|
||||||
Uint8List hmacSha256(List<int> key, List<int> message) =>
|
|
||||||
Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes);
|
|
||||||
|
|
||||||
Uint8List hkdfSha256(List<int> ikm, List<int> salt, List<int> info,
|
|
||||||
[int length = 32]) {
|
|
||||||
final prk = hmacSha256(salt, ikm);
|
|
||||||
var out = <int>[];
|
|
||||||
var block = <int>[];
|
|
||||||
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<int> 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<int> 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<int> 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));
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<int> 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 = <int>[];
|
|
||||||
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"^(?<user>[a-z0-9._-]{1,63})@(?<host>[^/:]+)(?::(?<port>\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<int> 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<SmolIdentity> 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<String, String> 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 = <String, String>{};
|
|
||||||
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<String, String> 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<Uint8List> 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<Uint8List> 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<Uint8List> _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<Response> 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 = <int>[];
|
|
||||||
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<OpenedSession> 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<int> authenticate(Session session, Uint8List handshakeHash, String username,
|
|
||||||
SmolIdentity identity, {required int sync, List<Uint8List> 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<Uint8List> chain;
|
|
||||||
|
|
||||||
const Resolved(this.identity, this.chain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RESOLVE, returning the current key and its rotation chain (§6.1).
|
|
||||||
Future<Resolved> 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<Uint8List> 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<List<FetchedRecord>> 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<int> deleteOp(Session session, List<Uint8List> 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<void> 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");
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +1,46 @@
|
||||||
// Device state: identity, pins, contacts and read markers in one JSON blob;
|
// Local state, split by owner: fumi's SQLite store owns everything the
|
||||||
// sealed envelopes in a second Hive box, opened only on demand, so nothing at
|
// protocol defines (mail, contacts, pins, accepted, tokens, seen ids), and
|
||||||
// rest is plaintext (the master secret excepted — the device's app storage is
|
// this Hive layer owns only what the app owns — the master secret, read
|
||||||
// the trust boundary, like gsmol's browser profile).
|
// 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:convert";
|
||||||
|
import "dart:io";
|
||||||
import "dart:typed_data";
|
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/errors.dart";
|
||||||
import "package:smol_mail/smol/proto.dart";
|
import "package:smol_mail/smol/ui.dart";
|
||||||
|
|
||||||
const _stateBox = "smol";
|
const tierMain = 0, tierRequests = 1;
|
||||||
const _mailBox = "mail";
|
|
||||||
const _stateKey = "state";
|
|
||||||
|
|
||||||
class StoredAccount {
|
/// A displaced key a contact no longer uses, with when it stopped being
|
||||||
final String user;
|
/// current (epoch ms) — the record §8 turns on the user being able to see.
|
||||||
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.
|
|
||||||
class ContactHistoryEntry {
|
class ContactHistoryEntry {
|
||||||
final Uint8List key;
|
final String key;
|
||||||
final int until; // epoch ms of the displacement
|
final int until;
|
||||||
|
|
||||||
const ContactHistoryEntry(this.key, this.until);
|
const ContactHistoryEntry(this.key, this.until);
|
||||||
}
|
}
|
||||||
|
|
||||||
class StoredContact {
|
class StoredContact {
|
||||||
final Uint8List key;
|
final String key;
|
||||||
final bool verified;
|
final bool verified;
|
||||||
final List<ContactHistoryEntry> history;
|
final List<ContactHistoryEntry> history;
|
||||||
|
|
||||||
const StoredContact(this.key, this.verified, [this.history = const []]);
|
const StoredContact(this.key, this.verified, this.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MailRecord {
|
/// A correspondent admitted to the mailbox's main tier (§5.8). The identity
|
||||||
final String id; // hex of the 32-byte message id
|
/// is frozen at acceptance because the token is derived from it.
|
||||||
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.
|
|
||||||
class AcceptedContact {
|
class AcceptedContact {
|
||||||
final Uint8List identity;
|
final String identity;
|
||||||
final bool active;
|
final bool active;
|
||||||
|
|
||||||
const AcceptedContact(this.identity, this.active);
|
const AcceptedContact(this.identity, this.active);
|
||||||
|
|
@ -84,584 +56,303 @@ class ImportSummary {
|
||||||
"$pinsAdded server keys ($pinsConflicted conflicted), $malformed malformed";
|
"$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 {
|
class SmolStore {
|
||||||
final Box _state;
|
final Box _meta;
|
||||||
final Box _mail;
|
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
|
/// The SQLite file fumi's store owns. One store per process.
|
||||||
/// in one process; production always uses the defaults.
|
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<SmolStore> open(
|
static Future<SmolStore> open(
|
||||||
{String stateBox = _stateBox, String mailBox = _mailBox}) async {
|
{required String dbPath,
|
||||||
final state = await Hive.openBox(stateBox);
|
String stateBox = "smol-state",
|
||||||
final mail = await Hive.openBox(mailBox);
|
String readBox = "smol-read"}) async {
|
||||||
return SmolStore(state, mail);
|
final native = FumiNative(dbPath);
|
||||||
}
|
await native.open();
|
||||||
|
final store = SmolStore._(
|
||||||
Map _load() {
|
await Hive.openBox(stateBox), await Hive.openBox(readBox), native);
|
||||||
final blob = _state.get(_stateKey);
|
final master = store.master();
|
||||||
return blob is Map ? blob : <String, dynamic>{};
|
if (master != null) {
|
||||||
}
|
// The rotation index sits in the native store; read it through the
|
||||||
|
// synchronous ABI, not the async wrapper.
|
||||||
void _update(Map Function(Map state) fn) {
|
native.setMaster(master,
|
||||||
final next = fn(_load());
|
rotations: SmolFfi.open().rotations(native.store!));
|
||||||
_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");
|
|
||||||
}
|
}
|
||||||
_update((state) => state..["rotations"] = current + 1);
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// §7: every key rotated away from is re-derivable from the master, since
|
/// The native binding this store fronts; the client drives its network
|
||||||
/// mail sealed to a superseded key is readable with nothing else.
|
/// operations, the store its reads.
|
||||||
List<SmolIdentity> identities() {
|
FumiNative get native => _native;
|
||||||
final m = master();
|
|
||||||
if (m == null) return const [];
|
SmolFfi get _ffi => SmolFfi.open();
|
||||||
return [for (var n = rotations(); n >= 0; n--) identityFromSeed(identitySeed(m, n))];
|
|
||||||
|
// --- identity ---------------------------------------------------------------
|
||||||
|
|
||||||
|
SmolIdentity? identity() {
|
||||||
|
if (master() == null) return null;
|
||||||
|
return SmolIdentity(_ffi.accountPk(_native.account!));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- account and server pins -------------------------------------------------
|
/// The master, as the one mutable buffer that owns it — wipe overwrites
|
||||||
|
/// these bytes rather than leaving them to the garbage collector, which
|
||||||
StoredAccount? account() {
|
/// is the honest version of zeroization Dart allows. Hive keeps the
|
||||||
final a = _load()["account"];
|
/// durable copy as bytes too; a hex string could never be scrubbed.
|
||||||
if (a is! Map) return null;
|
Uint8List? master() {
|
||||||
return StoredAccount(
|
final stored = _meta.get("master");
|
||||||
a["user"] as String, a["host"] as String, a["port"] as int);
|
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) {
|
/// A fresh identity: the master at rotation index 0. Only this local step;
|
||||||
_update((state) => state
|
/// nothing is sent until registration.
|
||||||
..["account"] = {
|
void setMaster(Uint8List fresh) {
|
||||||
"user": address.user,
|
// Hive's in-memory state updates synchronously and persists in the
|
||||||
"host": address.host,
|
// background, so the store is consistent without awaiting the write.
|
||||||
"port": address.port,
|
unawaited(_meta.put("master", fresh));
|
||||||
});
|
_native.setMaster(fresh, rotations: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List? serverPin(String host) {
|
/// The master restored from a backup, already at the rotation index the
|
||||||
final raw = ((_load()["servers"] as Map?) ?? {})[host];
|
/// server bound.
|
||||||
return raw == null ? null : b32decode(raw as String);
|
void restoreMaster(Uint8List master, int index) {
|
||||||
|
unawaited(_meta.put("master", master));
|
||||||
|
_native.setMaster(master, rotations: index);
|
||||||
}
|
}
|
||||||
|
|
||||||
void pinServer(String host, Uint8List key) {
|
int rotations() => _ffi.rotations(_native.store!);
|
||||||
_update((state) {
|
|
||||||
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
// --- settings ---------------------------------------------------------------
|
||||||
servers[host] = b32encode(key);
|
|
||||||
state["servers"] = servers;
|
bool leaveOnServer() => _meta.get("leaveOnServer") == true;
|
||||||
return state;
|
|
||||||
});
|
Future<void> setLeaveOnServer(bool value) async =>
|
||||||
|
_meta.put("leaveOnServer", value);
|
||||||
|
|
||||||
|
bool syncOk() => _ffi.syncOk(_native.store!);
|
||||||
|
|
||||||
|
// --- mail -------------------------------------------------------------------
|
||||||
|
|
||||||
|
List<MailRecord> listMessages(String folder) {
|
||||||
|
final rows = _ffi.mail(_native.store!, folder);
|
||||||
|
return [
|
||||||
|
for (final row in rows.cast<Map<String, dynamic>>())
|
||||||
|
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) {
|
/// One message by folder and id, for the reader screen's deep link.
|
||||||
_update((state) {
|
MailRecord? getMessage(String folder, String id) {
|
||||||
(state["servers"] as Map?)?.remove(host);
|
for (final row in listMessages(folder)) {
|
||||||
return state;
|
if (row.id == id) return row;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
List<(String, Uint8List)> allPins() {
|
|
||||||
final servers = ((_load()["servers"] as Map?) ?? {}).cast<String, String>();
|
|
||||||
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>()
|
|
||||||
.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<String, Map>();
|
|
||||||
final wanted = b32encode(key);
|
|
||||||
final previous = contacts[address];
|
|
||||||
final history = ((previous?["history"] as List?) ?? const [])
|
|
||||||
.whereType<Map>()
|
|
||||||
.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<String, Map>();
|
|
||||||
final wanted = b32encode(key);
|
|
||||||
for (final entry in contacts.entries) {
|
|
||||||
if (entry.value["key"] == wanted) return entry.key;
|
|
||||||
}
|
}
|
||||||
return null;
|
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<Map<String, dynamic>>()
|
||||||
|
.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<void> deleteMessage(String folder, String id) async =>
|
||||||
|
_native.deleteLocal(folder, [id]);
|
||||||
|
|
||||||
|
// --- contacts ---------------------------------------------------------------
|
||||||
|
|
||||||
List<(String, StoredContact)> allContacts() {
|
List<(String, StoredContact)> allContacts() {
|
||||||
final contacts = ((_load()["contacts"] as Map?) ?? {}).cast<String, Map>();
|
final rows = _ffi.contacts(_native.store!);
|
||||||
return [for (final entry in contacts.entries) (entry.key, contact(entry.key)!)];
|
return [
|
||||||
|
for (final row in rows.cast<Map<String, dynamic>>())
|
||||||
|
(
|
||||||
|
row["address"] as String,
|
||||||
|
StoredContact(
|
||||||
|
row["key"] as String,
|
||||||
|
row["verified"] as bool,
|
||||||
|
[
|
||||||
|
for (final entry in (row["history"] as List).cast<Map<String, dynamic>>())
|
||||||
|
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<Map<String, dynamic>>())
|
||||||
|
ContactHistoryEntry(entry["key"] as String, entry["until"] as int),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The acceptance state: main tier, blocked, or never accepted.
|
||||||
AcceptedContact? accepted(String address) {
|
AcceptedContact? accepted(String address) {
|
||||||
final a = ((_load()["accepted"] as Map?) ?? {})[address];
|
final row = _ffi.contact(_native.store!, address);
|
||||||
if (a is! Map) return null;
|
final active = row["active"] as bool?;
|
||||||
return AcceptedContact(b32decode(a["identity"] as String), a["active"] as bool);
|
final acceptedKey = row["acceptedKey"] as String?;
|
||||||
}
|
if (active == null || acceptedKey == null) return null;
|
||||||
|
return AcceptedContact(acceptedKey, active);
|
||||||
/// 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<String, Map>();
|
|
||||||
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<String, Map>();
|
|
||||||
if (!accepted.containsKey(address)) {
|
|
||||||
throw SmolError("$address was never accepted");
|
|
||||||
}
|
|
||||||
_update((state) {
|
|
||||||
final accepted = (state["accepted"] as Map? ?? {}).cast<String, Map>();
|
|
||||||
accepted[address] = {...accepted[address]!, "active": false};
|
|
||||||
state["accepted"] = accepted;
|
|
||||||
return state;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<(String, AcceptedContact)> allAccepted() {
|
List<(String, AcceptedContact)> allAccepted() {
|
||||||
final accepted = ((_load()["accepted"] as Map?) ?? {}).cast<String, Map>();
|
final rows = _ffi.contacts(_native.store!);
|
||||||
return [for (final e in accepted.entries) (e.key, this.accepted(e.key)!)];
|
return [
|
||||||
|
for (final row in rows.cast<Map<String, dynamic>>())
|
||||||
|
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
|
/// Binds an address to a key. A different key displaces the old one into
|
||||||
/// that cannot vouch for its own set — one restored from the master alone —
|
/// the contact's history (§8).
|
||||||
/// must not replace the server's with an incomplete one.
|
Future<void> saveContact(String address, String keyB32,
|
||||||
(int, List<Uint8List>) tokenSet(Uint8List master) {
|
{required bool verified}) async =>
|
||||||
if (!syncOk()) return (0, const []);
|
_native.saveContact(address, keyB32, verified: verified);
|
||||||
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)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A token received from a correspondent, filed under the address that
|
/// The address a key is known by, if any — naming a mailbox is not
|
||||||
/// issued it: an address outlives the keys behind it, so the token keeps
|
/// trusting a key, so nothing is bound here.
|
||||||
/// working across the issuer's rotations (§5.8).
|
String? addressForKey(String keyB32) {
|
||||||
Uint8List? tokenFrom(String address) {
|
for (final (address, contact) in allContacts()) {
|
||||||
final raw = ((_load()["tokens"] as Map?) ?? {})[address];
|
if (contact.key == keyB32) return 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<String, Map>();
|
|
||||||
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<String, bool>();
|
|
||||||
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<void> 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<MailRecord?> storeIfNew(String folder, MailRecord record) async {
|
|
||||||
if (_mail.containsKey(mailKey(folder, record.id))) return null;
|
|
||||||
await storeMessage(folder, record);
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MailRecord> listMessages(String folder) {
|
|
||||||
final physical = _physicalFolder(folder);
|
|
||||||
final prefix = "$physical/";
|
|
||||||
final wantTier = folder == "requests" ? tierRequests : tierMain;
|
|
||||||
final rows = <MailRecord>[];
|
|
||||||
for (final key in _mail.keys.cast<String>()) {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
rows.sort((a, b) =>
|
return null;
|
||||||
(b.receivedAt ?? b.sentAt ?? 0).compareTo(a.receivedAt ?? a.sentAt ?? 0));
|
|
||||||
return rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MailRecord? getMessage(String folder, String id) {
|
// --- pins -------------------------------------------------------------------
|
||||||
final row = _mail.get(mailKey(_physicalFolder(folder), id));
|
|
||||||
return row is Map ? _mapToRecord(row) : null;
|
/// 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<Map<String, dynamic>>())
|
||||||
|
(row["host"] as String, row["key"] as String),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteMessage(String folder, String id) =>
|
Future<void> unpinServer(String host) async => _native.unpinServer(host);
|
||||||
_mail.delete(mailKey(_physicalFolder(folder), id));
|
|
||||||
|
|
||||||
// --- export / import: mail, contacts, pins — never the seed --------------------
|
// --- backups ----------------------------------------------------------------
|
||||||
|
|
||||||
/// Label kept as gsmol wrote it originally; the export format version
|
/// The gsmol backup container: sealed mail, contacts and pins — never the
|
||||||
/// (gsmolExport) is what actually changed between v1 and v2.
|
/// master — as one JSON file body.
|
||||||
static final _exportLabel = utf8Bytes("gsmol/1 export");
|
Future<String> exportData() => _native.exportBackup();
|
||||||
Uint8List _exportKey(Uint8List master) =>
|
|
||||||
hkdfSha256(master, Uint8List(0), _exportLabel, 32);
|
|
||||||
|
|
||||||
/// v2 matches gsmol's own current export: the whole payload — mail,
|
Future<ImportSummary> importData(String text) async {
|
||||||
/// contacts, pins — is sealed to a key derived from the identity's master,
|
final summary = await _native.importBackup(text);
|
||||||
/// so a backup file is only readable by whoever holds that master.
|
final Map<String, dynamic> parsed;
|
||||||
/// Deliberately excludes the master itself: it has its own reveal-and-copy
|
try {
|
||||||
/// flow in settings, meant for a password manager, not a shareable file.
|
parsed = _decodeSummary(summary);
|
||||||
Map<String, dynamic> exportData() {
|
} on Exception {
|
||||||
final master = this.master();
|
|
||||||
if (master == null) throw const SmolError("no identity yet");
|
|
||||||
final state = _load();
|
|
||||||
final contacts = ((state["contacts"] as Map?) ?? {}).cast<String, Map>();
|
|
||||||
final payload = {
|
|
||||||
"servers": ((state["servers"] as Map?) ?? {}).cast<String, String>(),
|
|
||||||
"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<ImportSummary> 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 {
|
|
||||||
throw const SmolError("not a gsmol export file");
|
throw const SmolError("not a gsmol export file");
|
||||||
}
|
}
|
||||||
final summary = ImportSummary();
|
final out = ImportSummary();
|
||||||
|
out.pinsAdded = parsed["pinsAdded"] as int;
|
||||||
_update((state) {
|
out.pinsConflicted = parsed["pinsConflicted"] as int;
|
||||||
final servers = (state["servers"] as Map? ?? {}).cast<String, String>();
|
out.contactsAdded = parsed["contactsAdded"] as int;
|
||||||
final incomingPins = payload["servers"] is Map ? payload["servers"] as Map : null;
|
out.contactsConflicted = parsed["contactsConflicted"] as int;
|
||||||
if (payload["servers"] != null && incomingPins == null) summary.malformed++;
|
out.mailAdded = parsed["mailAdded"] as int;
|
||||||
for (final entry in (incomingPins ?? const {}).entries) {
|
out.malformed = parsed["malformed"] as int;
|
||||||
final host = entry.key, key = entry.value;
|
return out;
|
||||||
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<String, Map>();
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pin key must decode to exactly 32 bytes of base32.
|
Map<String, dynamic> _decodeSummary(String text) =>
|
||||||
bool _pinKeyOk(String key) {
|
jsonDecode(text) as Map<String, dynamic>;
|
||||||
try {
|
|
||||||
return b32decode(key).length == keyLen;
|
|
||||||
} on SmolError {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<void> wipe() async {
|
Future<void> wipe() async {
|
||||||
await _state.delete(_stateKey);
|
master()?.fillRange(0, 32, 0);
|
||||||
await _mail.clear();
|
_native.clearMaster();
|
||||||
}
|
await _meta.delete("master");
|
||||||
|
await _read.clear();
|
||||||
int unreadCount() {
|
await _meta.delete("master");
|
||||||
var count = 0;
|
await _native.close();
|
||||||
for (final row in listMessages("inbox")) {
|
try {
|
||||||
if (!isRead(row.id)) count++;
|
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;
|
await _native.open();
|
||||||
}
|
|
||||||
|
|
||||||
int requestsUnreadCount() {
|
|
||||||
var count = 0;
|
|
||||||
for (final row in listMessages("requests")) {
|
|
||||||
if (!isRead(row.id)) count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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";
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 = <Uint8List>[];
|
|
||||||
final _waiters = <_ReadRequest>[];
|
|
||||||
int _buffered = 0;
|
|
||||||
Object? _closed;
|
|
||||||
late final StreamSubscription<Uint8List> _subscription;
|
|
||||||
|
|
||||||
TcpWire(this._socket) {
|
|
||||||
_subscription = _socket.listen(_onData,
|
|
||||||
onError: (Object error) => _fail(error),
|
|
||||||
onDone: () => _fail(const SmolError("server closed the connection")));
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<TcpWire> 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<Uint8List> 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<void>();
|
|
||||||
|
|
||||||
_ReadRequest(this.need);
|
|
||||||
}
|
|
||||||
24
lib/smol/ui.dart
Normal file
24
lib/smol/ui.dart
Normal file
|
|
@ -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<int> 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)]);
|
||||||
|
}
|
||||||
738
native/Cargo.lock
generated
Normal file
738
native/Cargo.lock
generated
Normal file
|
|
@ -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"
|
||||||
14
native/Cargo.toml
Normal file
14
native/Cargo.toml
Normal file
|
|
@ -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"
|
||||||
17
native/src/bin/dnsprobe.rs
Normal file
17
native/src/bin/dnsprobe.rs
Normal file
|
|
@ -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<String> = addrs.map(|a| a.to_string()).collect();
|
||||||
|
println!("resolved {host}: {ips:?}");
|
||||||
|
}
|
||||||
|
Err(err) => println!("FAILED {host}: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
1205
native/src/ffi.rs
Normal file
1205
native/src/ffi.rs
Normal file
File diff suppressed because it is too large
Load diff
450
native/src/lib.rs
Normal file
450
native/src/lib.rs
Normal file
|
|
@ -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<Vec<u8>> = (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<Response, Error> {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,32 @@
|
||||||
// End-to-end against a live reference server: register an address on a
|
// End-to-end against a live server: register an address, send sealed mail to
|
||||||
// locally running smolmaild, send sealed messages to ourselves, fetch them
|
// ourselves, fetch it back, exercise the accept-token round trip (§5.8)
|
||||||
// back, exercise the accept-token round trip (§5.8) between the requests and
|
// between the requests and main tiers, restore onto a second store, and
|
||||||
// main tiers, and check the server is drained afterwards. Skips when nothing
|
// check the server is drained afterwards. Skips when nothing listens on
|
||||||
// listens on 127.0.0.1:1961, so `devbox run test` does not depend on a server.
|
// 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 &&
|
// The server key is pinned directly: on this machine the bunshin.service
|
||||||
// uv run smolmaild.py serve --key server.key --db mail.db)
|
// static key is a documented, operator-supplied value — exactly the trusted
|
||||||
// then `devbox run test`.
|
// channel SPEC.md §4 asks a pin to come from.
|
||||||
|
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
import "dart:math";
|
||||||
import "dart:typed_data";
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "package:flutter_test/flutter_test.dart";
|
import "package:flutter_test/flutter_test.dart";
|
||||||
import "package:hive_flutter/hive_flutter.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/client.dart";
|
||||||
import "package:smol_mail/smol/crypto.dart";
|
|
||||||
import "package:smol_mail/smol/errors.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/store.dart";
|
||||||
|
import "package:smol_mail/smol/ui.dart";
|
||||||
|
|
||||||
const host = "127.0.0.1";
|
const host = "127.0.0.1";
|
||||||
const port = 1961;
|
const port = 1961;
|
||||||
|
const serverKey = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
|
||||||
|
|
||||||
|
Uint8List randomBytes(int n) =>
|
||||||
|
Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256)));
|
||||||
|
|
||||||
Future<bool> serverUp() async {
|
Future<bool> serverUp() async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -33,35 +39,31 @@ Future<bool> serverUp() async {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<SmolStore> freshStore(String tag, String dir) => SmolStore.open(
|
||||||
|
dbPath: "$dir/$tag.db", stateBox: "$tag-state", readBox: "$tag-read");
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test("register, send to self, fetch, unseal, drain", () async {
|
test("register, send to self, fetch, unseal, drain", () async {
|
||||||
if (!await serverUp()) {
|
if (!await serverUp()) {
|
||||||
markTestSkipped("no smolmaild on $host:$port");
|
markTestSkipped("no server on $host:$port");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final dir = await Directory.systemTemp.createTemp("smol-e2e");
|
final dir = await Directory.systemTemp.createTemp("smol-e2e");
|
||||||
Hive.init(dir.path);
|
Hive.init(dir.path);
|
||||||
final store = await SmolStore.open();
|
final store = await freshStore("main", dir.path);
|
||||||
final client = SmolClient(store);
|
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 = <String>[];
|
final warnings = <String>[];
|
||||||
client.onWarning = warnings.add;
|
client.onWarning = warnings.add;
|
||||||
|
|
||||||
final master = client.createIdentity();
|
await client.createIdentity();
|
||||||
final me = client.identity!;
|
final me = client.identity!;
|
||||||
final user = "e2e${hex(randomBytes(4))}";
|
final user = "e2e${hex(randomBytes(4))}";
|
||||||
final address = parseAddress("$user@$host");
|
final address = parseAddress("$user@$host");
|
||||||
final learned = await client.connect(address, requirePin: false);
|
store.pinServer(host, serverKey);
|
||||||
// §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();
|
|
||||||
|
|
||||||
await client.registerAccount(address.short);
|
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, "hello e2e", "sealed and signed");
|
||||||
await client.send(address.short, "second", "another sealed envelope");
|
await client.send(address.short, "second", "another sealed envelope");
|
||||||
|
|
@ -75,33 +77,32 @@ void main() {
|
||||||
expect(store.listMessages("inbox"), isEmpty);
|
expect(store.listMessages("inbox"), isEmpty);
|
||||||
final requests = store.listMessages("requests");
|
final requests = store.listMessages("requests");
|
||||||
expect(requests.length, 2);
|
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();
|
final subjects = requests.map((m) => client.describe(m).subject).toSet();
|
||||||
expect(subjects, {"hello e2e", "second"});
|
expect(subjects, {"hello e2e", "second"});
|
||||||
final hello = requests.firstWhere(
|
final hello = requests
|
||||||
(m) => client.describe(m).subject == "hello e2e");
|
.firstWhere((m) => client.describe(m).subject == "hello e2e");
|
||||||
final opened = client.describe(hello);
|
final opened = client.describe(hello);
|
||||||
expect(opened.error, isNull);
|
expect(opened.error, isNull);
|
||||||
expect(opened.body, "sealed and signed\n");
|
// fumi's describe splits the trailing newline into the frontmatter
|
||||||
expect(hex(opened.sender!), hex(me.publicKey));
|
// 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.
|
// The server must be drained: everything that verified was acknowledged.
|
||||||
final again = await client.fetch();
|
final again = await client.fetch();
|
||||||
expect(again.stored, 0);
|
expect(again.stored, 0);
|
||||||
|
|
||||||
// Accept ourselves as a correspondent (§5.8): the change is pushed to the
|
// Accept ourselves as a correspondent (§5.8): the change is pushed to
|
||||||
// server right away. This next message carries our own Accept field, but
|
// the server right away. This next message carries our own Accept field,
|
||||||
// no MAC yet — we cannot know our own token before receiving and parsing
|
// but no MAC yet, so it still lands in requests.
|
||||||
// a message that carries it — so it still lands in requests.
|
|
||||||
await client.acceptContact(address.short);
|
await client.acceptContact(address.short);
|
||||||
await client.send(address.short, "third", "still unsolicited");
|
await client.send(address.short, "third", "still unsolicited");
|
||||||
expect((await client.fetch()).stored, 1);
|
expect((await client.fetch()).stored, 1);
|
||||||
expect(store.listMessages("requests").length, 3);
|
expect(store.listMessages("requests").length, 3);
|
||||||
expect(store.listMessages("inbox"), isEmpty);
|
expect(store.listMessages("inbox"), isEmpty);
|
||||||
|
|
||||||
// Having now learned our own token from that message's Accept field, the
|
// Having now learned our own token, the next one carries a matching MAC
|
||||||
// next one carries a matching MAC and reaches the main tier.
|
// and reaches the main tier.
|
||||||
await client.send(address.short, "fourth", "now accepted");
|
await client.send(address.short, "fourth", "now accepted");
|
||||||
expect((await client.fetch()).stored, 1);
|
expect((await client.fetch()).stored, 1);
|
||||||
final mainTier = store.listMessages("inbox");
|
final mainTier = store.listMessages("inbox");
|
||||||
|
|
@ -123,18 +124,17 @@ void main() {
|
||||||
// Restore on a second device: same master, fresh store, no pin. Unpinned
|
// Restore on a second device: same master, fresh store, no pin. Unpinned
|
||||||
// recall is refused; re-registering a taken name is refused; recall with
|
// recall is refused; re-registering a taken name is refused; recall with
|
||||||
// the operator-supplied key then binds the account without REGISTER.
|
// the operator-supplied key then binds the account without REGISTER.
|
||||||
final restored = await SmolStore.open(
|
final secondStore = await freshStore("second", dir.path);
|
||||||
stateBox: "e2e-restore-state", mailBox: "e2e-restore-mail");
|
final secondDevice = SmolClient(secondStore);
|
||||||
final secondDevice = SmolClient(restored);
|
secondStore.restoreMaster(store.master()!, 0);
|
||||||
restored.setMaster(master);
|
await expectLater(
|
||||||
expect(
|
|
||||||
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
|
secondDevice.recallAccount(address.short), throwsA(isA<SmolError>()));
|
||||||
restored.pinServer(host, learned.serverStatic);
|
secondStore.pinServer(host, serverKey);
|
||||||
await expectLater(
|
await expectLater(
|
||||||
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
|
secondDevice.registerAccount(address.short), throwsA(isA<SmolError>()));
|
||||||
final bound = await secondDevice.recallAccount(address.short);
|
final bound = await secondDevice.recallAccount(address.short);
|
||||||
expect(bound.short, 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.
|
// Re-resolving our own address finds the same key and says so quietly.
|
||||||
final outcome = await client.refreshContact(address.short);
|
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",
|
test("leave mail on server keeps mail until deleted, with dedupe on refetch",
|
||||||
() async {
|
() async {
|
||||||
if (!await serverUp()) {
|
if (!await serverUp()) {
|
||||||
markTestSkipped("no smolmaild on $host:$port");
|
markTestSkipped("no server on $host:$port");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final dir = await Directory.systemTemp.createTemp("smol-e2e-keep");
|
final dir = await Directory.systemTemp.createTemp("smol-e2e-keep");
|
||||||
Hive.init(dir.path);
|
Hive.init(dir.path);
|
||||||
final store =
|
final store = await freshStore("keep", dir.path);
|
||||||
await SmolStore.open(stateBox: "e2e-keep-state", mailBox: "e2e-keep-mail");
|
|
||||||
final client = SmolClient(store);
|
final client = SmolClient(store);
|
||||||
|
|
||||||
client.createIdentity();
|
await client.createIdentity();
|
||||||
final user = "e2ekeep${hex(randomBytes(4))}";
|
final user = "e2ekeep${hex(randomBytes(4))}";
|
||||||
final address = parseAddress("$user@$host");
|
final address = parseAddress("$user@$host");
|
||||||
final learned = await client.connect(address, requirePin: false);
|
store.pinServer(host, serverKey);
|
||||||
store.pinServer(host, learned.serverStatic);
|
|
||||||
learned.session.wire.close();
|
|
||||||
await client.registerAccount(address.short);
|
await client.registerAccount(address.short);
|
||||||
|
|
||||||
store.setLeaveOnServer(true);
|
await store.setLeaveOnServer(true);
|
||||||
await client.send(address.short, "kept", "stays on the server until deleted");
|
await client.send(address.short, "kept", "stays on the server until deleted");
|
||||||
|
|
||||||
final first = await client.fetch();
|
final first = await client.fetch();
|
||||||
|
|
@ -172,19 +169,17 @@ void main() {
|
||||||
expect(record.keptOnServer, isTrue);
|
expect(record.keptOnServer, isTrue);
|
||||||
expect(client.describe(record).subject, "kept");
|
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).
|
// dedupe), even though the server still has it (nothing was deleted).
|
||||||
store.setCursor(0, Uint8List(idLen));
|
final second = await client.fetch(reset: true);
|
||||||
final second = await client.fetch();
|
|
||||||
expect(second.stored, 0);
|
expect(second.stored, 0);
|
||||||
expect(store.listMessages("requests").length, 1);
|
expect(store.listMessages("requests").length, 1);
|
||||||
|
|
||||||
// Deleting removes it from the server too: a further full re-page after
|
// Deleting removes it locally and from the server too: a further full
|
||||||
// deletion must come back empty rather than resurrecting it.
|
// re-page after deletion comes back empty rather than resurrecting it.
|
||||||
await client.deleteMessage("requests", record);
|
await client.deleteMessage("requests", record);
|
||||||
expect(store.listMessages("requests"), isEmpty);
|
expect(store.listMessages("requests"), isEmpty);
|
||||||
store.setCursor(0, Uint8List(idLen));
|
final third = await client.fetch(reset: true);
|
||||||
final third = await client.fetch();
|
|
||||||
expect(third.stored, 0);
|
expect(third.stored, 0);
|
||||||
expect(store.listMessages("requests"), isEmpty);
|
expect(store.listMessages("requests"), isEmpty);
|
||||||
}, timeout: const Timeout(Duration(minutes: 2)));
|
}, timeout: const Timeout(Duration(minutes: 2)));
|
||||||
|
|
|
||||||
19
test/ffi_smoke_test.dart
Normal file
19
test/ffi_smoke_test.dart
Normal file
|
|
@ -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<Int32 Function(), int Function()>("smol_smoke");
|
||||||
|
expect(smolSmoke(), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
77
test/interop_fumi_test.dart
Normal file
77
test/interop_fumi_test.dart
Normal file
|
|
@ -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<ProcessResult> fumi(List<String> 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
127
test/native_binding_test.dart
Normal file
127
test/native_binding_test.dart
Normal file
|
|
@ -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<bool> 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<String, dynamic>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
// router flow itself.
|
// router flow itself.
|
||||||
|
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
import "dart:math";
|
||||||
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_riverpod/flutter_riverpod.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/data/providers/providers.dart";
|
||||||
import "package:smol_mail/presentation/app_widget.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/client.dart";
|
||||||
import "package:smol_mail/smol/crypto.dart";
|
|
||||||
import "package:smol_mail/smol/errors.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/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
|
/// A [SmolClient] whose network operations complete instantly, so the test
|
||||||
/// exercises the flow rather than the network. [restoreAndRecall] still
|
/// 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}");
|
throw SmolError("no pinned key for ${addr.host}");
|
||||||
}
|
}
|
||||||
store.restoreMaster(unhex(masterHex.trim()), 0);
|
store.restoreMaster(unhex(masterHex.trim()), 0);
|
||||||
store.setAccount(addr);
|
store.native.setAccount(addr.short);
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SmolAddress> recallAccount(String addressText) async {
|
Future<SmolAddress> recallAccount(String addressText) async {
|
||||||
final addr = parseAddress(addressText);
|
final addr = parseAddress(addressText);
|
||||||
store.setAccount(addr);
|
store.native.setAccount(addr.short);
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,11 +61,11 @@ void main() {
|
||||||
final dir = await Directory.systemTemp.createTemp("smol-recall-flow");
|
final dir = await Directory.systemTemp.createTemp("smol-recall-flow");
|
||||||
Hive.init(dir.path);
|
Hive.init(dir.path);
|
||||||
storeA = await SmolStore.open(
|
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(
|
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(
|
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(
|
Widget app(SmolStore store) => ProviderScope(
|
||||||
|
|
@ -99,13 +107,14 @@ void main() {
|
||||||
expect(find.text("Register a new address instead"), findsNothing);
|
expect(find.text("Register a new address instead"), findsNothing);
|
||||||
fields = find.byType(TextField);
|
fields = find.byType(TextField);
|
||||||
expect(fields, findsNWidgets(2)); // address (carried over), server key
|
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.tap(find.text("Pin and Recall"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text("Inbox"), findsOneWidget);
|
expect(find.text("Inbox"), findsOneWidget);
|
||||||
expect(store.master(), master);
|
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",
|
testWidgets("restore requires an address before it will submit",
|
||||||
|
|
@ -141,7 +150,7 @@ void main() {
|
||||||
await tester.tap(find.text("Create Identity"));
|
await tester.tap(find.text("Create Identity"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(store.master(), isNotNull);
|
expect(store.master(), isNotNull);
|
||||||
expect(store.account(), isNull);
|
expect(SmolClient(store).accountAddress(), isNull);
|
||||||
|
|
||||||
// Simulate returning to onboarding later (e.g. a cold restart). Pumping
|
// Simulate returning to onboarding later (e.g. a cold restart). Pumping
|
||||||
// app(store) directly would just rebuild the existing OnboardingScreen
|
// 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.
|
// Lands on the recall-framed register step (no pin yet); pin it and finish.
|
||||||
expect(find.byType(TextField), findsNWidgets(2));
|
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.tap(find.text("Pin and Recall"));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<String, dynamic>;
|
|
||||||
|
|
||||||
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<SmolError>()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("X25519 matches and rejects low-order points", () {
|
|
||||||
final byName = <String, Map>{};
|
|
||||||
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<SmolError>()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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<SmolError>()));
|
|
||||||
// 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<Map>();
|
|
||||||
final responderFrames = (v["responder_frames"] as List).cast<Map>();
|
|
||||||
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<Map>().first["pub"]));
|
|
||||||
final parsed = parseAddress(parseAddress("bob@h").uri(key));
|
|
||||||
expect(parsed.identity, key);
|
|
||||||
expect(parsed.user, "bob");
|
|
||||||
expect(() => parseAddress("-bob@h"), throwsA(isA<SmolError>()));
|
|
||||||
// §3: never two separators in a row
|
|
||||||
expect(() => parseAddress("a..b@h"), throwsA(isA<SmolError>()));
|
|
||||||
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<Uint8List>.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<void> 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<Uint8List> 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
// Store-level behavior: contact key history (§8), import binding, and the
|
// Store-level behavior against the real native store: master lifecycle, read
|
||||||
// export/import backup file.
|
// marks, pins and the settings the app owns in Hive.
|
||||||
|
|
||||||
import "dart:convert";
|
|
||||||
import "dart:io";
|
import "dart:io";
|
||||||
|
import "dart:math";
|
||||||
|
import "dart:typed_data";
|
||||||
|
|
||||||
import "package:flutter_test/flutter_test.dart";
|
import "package:flutter_test/flutter_test.dart";
|
||||||
import "package:hive_flutter/hive_flutter.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";
|
import "package:smol_mail/smol/store.dart";
|
||||||
|
|
||||||
// Each store gets its own boxes; Hive is a per-process singleton, so without
|
Uint8List randomBytes(int n) =>
|
||||||
// this the "exporting" and "importing" stores would be the same store.
|
Uint8List.fromList(List.generate(n, (_) => Random.secure().nextInt(256)));
|
||||||
Future<SmolStore> freshStore(String tag) =>
|
|
||||||
SmolStore.open(stateBox: "test-$tag-state", mailBox: "test-$tag-mail");
|
Future<SmolStore> freshStore(String tag) => SmolStore.open(
|
||||||
|
dbPath: "${Directory.systemTemp.createTempSync("smol-store-$tag").path}/store.db",
|
||||||
|
stateBox: "$tag-state",
|
||||||
|
readBox: "$tag-read");
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
setUpAll(() async {
|
setUpAll(() async {
|
||||||
|
|
@ -25,182 +25,69 @@ void main() {
|
||||||
Hive.init(dir.path);
|
Hive.init(dir.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("contact history keeps displaced keys, not re-saves", () async {
|
test("master set, restore and rotations", () async {
|
||||||
final store = await freshStore("history");
|
final store = await freshStore("master");
|
||||||
final a = randomBytes(32), b = randomBytes(32), c = randomBytes(32);
|
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<SmolError>()));
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
final master = randomBytes(32);
|
||||||
store.setMaster(master);
|
store.setMaster(master);
|
||||||
final alice = randomBytes(32);
|
expect(store.master(), master);
|
||||||
store.saveContact("alice@example.org", alice, true);
|
// 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.
|
final other = randomBytes(32);
|
||||||
var (sync, tokens) = store.tokenSet(master);
|
store.restoreMaster(other, 3);
|
||||||
expect(sync, 1);
|
expect(store.master(), other);
|
||||||
expect(tokens, isEmpty);
|
// The account handle was rebuilt at the restored rotation index.
|
||||||
|
expect(store.identity()!.publicKey.length, 52);
|
||||||
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<SmolError>()));
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("v1 legacy export still imports without an identity", () async {
|
test("leave-on-server is a setting, not protocol state", () async {
|
||||||
final store = await freshStore("import-legacy-v1");
|
final store = await freshStore("leave");
|
||||||
final summary = await store.importData({
|
expect(store.leaveOnServer(), isFalse);
|
||||||
"gsmolExport": 1,
|
await store.setLeaveOnServer(true);
|
||||||
"servers": {"example.org": b32encode(randomBytes(32))},
|
expect(store.leaveOnServer(), isTrue);
|
||||||
});
|
await store.setLeaveOnServer(false);
|
||||||
expect(summary.pinsAdded, 1);
|
expect(store.leaveOnServer(), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("import never overwrites a differing trust binding", () async {
|
test("read marks drive the unread counts", () async {
|
||||||
final store = await freshStore("conflict");
|
final store = await freshStore("read");
|
||||||
final mine = randomBytes(32), other = randomBytes(32);
|
expect(store.unreadCount(), 0);
|
||||||
store.pinServer("example.org", mine);
|
// Nothing is stored yet, so marking an id is harmless bookkeeping.
|
||||||
store.saveContact("alice@example.org", mine, true);
|
store.markRead("ab" * 32);
|
||||||
final summary = await store.importData({
|
expect(store.isRead("ab" * 32), isTrue);
|
||||||
"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("import skips malformed entries and rejects wrong files", () async {
|
test("pins save, list and unpin", () async {
|
||||||
final store = await freshStore("malformed");
|
final store = await freshStore("pins");
|
||||||
final summary = await store.importData({
|
expect(store.allPins(), isEmpty);
|
||||||
"gsmolExport": 1,
|
// A syntactically valid key: the system server's, a real 52-char form.
|
||||||
"servers": {"bad": "notbase32!", "short": b32encode(randomBytes(8))},
|
const key = "lm2gqd7e5q67xq3isc5hx6jfj2q7a7xvq4l7trctxipudujovmgq";
|
||||||
"contacts": {
|
store.pinServer("example.org", key);
|
||||||
"x": {"key": "nope"},
|
expect(store.serverPin("example.org"), key);
|
||||||
"y": "not a record",
|
expect(store.allPins().length, 1);
|
||||||
},
|
await store.unpinServer("example.org");
|
||||||
"inbox": [
|
expect(store.serverPin("example.org"), isNull);
|
||||||
{"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);
|
|
||||||
|
|
||||||
expect(() => store.importData({"nope": 1}), throwsA(isA<SmolError>()));
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,7 @@ void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
final dir = await Directory.systemTemp.createTemp("smol-widget-test");
|
final dir = await Directory.systemTemp.createTemp("smol-widget-test");
|
||||||
Hive.init(dir.path);
|
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",
|
testWidgets("onboarding invites to create or restore an identity",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue