445 lines
16 KiB
Dart
445 lines
16 KiB
Dart
// The app-level client: the same flows the screens have always called —
|
|
// connect with pinning, fetch with verification and acknowledgment, send
|
|
// with sent copies, contacts and rotation — now as one thin layer over
|
|
// fumi-core through the native binding. Every network operation runs on an
|
|
// isolate; the reads the UI makes per frame stay synchronous.
|
|
|
|
import "dart:io";
|
|
import "dart:math";
|
|
import "dart:typed_data";
|
|
|
|
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/store.dart";
|
|
|
|
class RefreshOutcome {
|
|
final String message;
|
|
final bool warn;
|
|
|
|
const RefreshOutcome(this.message, this.warn);
|
|
}
|
|
|
|
class FetchSummary {
|
|
final int stored;
|
|
final List<String> rejected;
|
|
|
|
const FetchSummary(this.stored, this.rejected);
|
|
}
|
|
|
|
class OpenedRecord {
|
|
final String id;
|
|
final String? sender; // base32
|
|
final int? time;
|
|
final Map<String, String> fields;
|
|
final String body;
|
|
final String? error;
|
|
|
|
const OpenedRecord(this.id,
|
|
{this.sender, this.time, this.fields = const {}, this.body = "", this.error});
|
|
|
|
String get subject => error == null ? (fields["subject"] ?? "") : "";
|
|
}
|
|
|
|
class SmolClient {
|
|
final SmolStore store;
|
|
|
|
/// Trust warnings (§4/§8) the UI must not let the user miss; the app wires
|
|
/// this to a persistent banner in app_widget.dart.
|
|
void Function(String message)? onWarning;
|
|
|
|
SmolClient(this.store);
|
|
|
|
void _warn(String message) => onWarning?.call(message);
|
|
|
|
FumiNative get _native => store.native;
|
|
|
|
SmolFfi get _ffi => SmolFfi.open();
|
|
|
|
/// Resolves the host the way that works everywhere this app runs: the
|
|
/// platform resolver. On Android the native getaddrinfo that fumi-core's
|
|
/// connect would use can be dead for app processes while this path works,
|
|
/// so the facade resolves here and hands the IP across as a dial hint —
|
|
/// the hostname keeps every identity role. IP literals dial themselves.
|
|
Future<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>{};
|
|
|
|
SmolIdentity? get identity => store.identity();
|
|
|
|
Uint8List? get master => store.master();
|
|
|
|
SmolAddress? accountAddress() {
|
|
final text = _ffi.accountAddress(_native.store!);
|
|
if (text == null) return null;
|
|
return parseAddress(text);
|
|
}
|
|
|
|
// --- identity setup ------------------------------------------------------------
|
|
|
|
/// A fresh master secret at rotation index 0. Only this local step;
|
|
/// nothing is sent until [registerAccount].
|
|
Future<Uint8List> createIdentity() async {
|
|
final fresh = _randomMaster();
|
|
store.setMaster(fresh);
|
|
return fresh;
|
|
}
|
|
|
|
Uint8List _randomMaster() {
|
|
// The platform CSPRNG: 32 random bytes are the whole identity.
|
|
final rng = Random.secure();
|
|
return Uint8List.fromList(
|
|
List.generate(32, (_) => rng.nextInt(256)));
|
|
}
|
|
|
|
/// §2: a master alone does not say which rotation index a server has bound,
|
|
/// so restoring resolves the address and walks indices until one derives
|
|
/// the key RESOLVE returned, then binds the account locally.
|
|
Future<SmolAddress> restoreAndRecall(String masterHex, String addressText) async {
|
|
Uint8List master;
|
|
try {
|
|
master = _unhex(masterHex.trim());
|
|
} on Exception {
|
|
throw const SmolError("master must be 64 hex characters");
|
|
}
|
|
if (master.length != 32) {
|
|
throw SmolError("master is ${master.length} bytes, expected 32");
|
|
}
|
|
final addr = parseAddress(addressText);
|
|
store.restoreMaster(master, 0);
|
|
await _restore(addr);
|
|
master.fillRange(0, master.length, 0);
|
|
return addr;
|
|
}
|
|
|
|
Future<void> _restore(SmolAddress addr) async {
|
|
// fumi's restore would tolerate an unpinned server (sec 8 trust on
|
|
// first use), but this app's onboarding teaches the pin up front:
|
|
// registration and fetching demand it anyway (sec 4), so restoring is
|
|
// stopped at the pin step rather than letting the account bind to a
|
|
// server whose key nobody verified.
|
|
if (store.serverPin(addr.host, addr.port) == 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);
|
|
}
|
|
}
|
|
|
|
void pinServer(String host, String keyB32, int port) =>
|
|
store.pinServer(host, keyB32, port);
|
|
|
|
Future<void> registerAccount(String addressText, {String token = ""}) async {
|
|
final addr = parseAddress(addressText);
|
|
try {
|
|
await _native.register(addr.short,
|
|
invite: token.isEmpty ? null : token, dial: await _dial(addr.host));
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
}
|
|
|
|
/// Recall binds the identity to its registered address without
|
|
/// re-REGISTER — the same resolve-and-walk a restore does (§2).
|
|
Future<SmolAddress> recallAccount(String addressText) async {
|
|
final addr = parseAddress(addressText);
|
|
await _restore(addr);
|
|
return addr;
|
|
}
|
|
|
|
// --- fetch -----------------------------------------------------------------
|
|
|
|
Future<FetchSummary> fetch({bool reset = false}) async {
|
|
if (master == null) throw const SmolError("no identity yet");
|
|
if (accountAddress() == null) {
|
|
throw const SmolError("not registered; register an address first");
|
|
}
|
|
final Map<String, dynamic> summary;
|
|
try {
|
|
summary = await _native.fetch(
|
|
keep: store.leaveOnServer(), reset: reset, dial: await _accountDial());
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
_openedCache.clear();
|
|
if (summary["cancelled"] == true) {
|
|
_warn("fetch cancelled; partial results kept");
|
|
}
|
|
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 ------------------------------------------------------------------
|
|
|
|
/// Deletes a message locally, and from the server too if it might still be
|
|
/// sitting there (only possible when "leave mail on server" was on when it
|
|
/// was fetched — §10). Sent copies are local-only (§5.6).
|
|
Future<void> deleteMessage(String folder, MailRecord record) async {
|
|
if (folder != "sent" && record.keptOnServer) {
|
|
if (accountAddress() == null) {
|
|
throw const SmolError(
|
|
"not registered; cannot reach the server to delete this message");
|
|
}
|
|
try {
|
|
await _native.delete([record.id], dial: await _accountDial());
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
}
|
|
await store.deleteMessage(folder, record.id);
|
|
_openedCache.remove(record.id);
|
|
}
|
|
|
|
// --- accept tokens (§5.8) --------------------------------------------------------
|
|
|
|
/// Admit a contact to the main tier; their token travels in our next
|
|
/// message to them. Pushes the changed set to the server right away.
|
|
Future<int> acceptContact(String address) async {
|
|
try {
|
|
return await _native.accept(address, dial: await _accountDial());
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
}
|
|
|
|
/// Withdraw a contact's accept token; their mail lands in requests from
|
|
/// their next message on.
|
|
Future<int> blockContact(String address) async {
|
|
try {
|
|
await _native.block(address, dial: await _accountDial());
|
|
return 0;
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
}
|
|
|
|
// --- compose and send -----------------------------------------------------------
|
|
|
|
/// Sends one message (§5, §6.1): recipient selection prefers a key we
|
|
/// already trust, then RESOLVE with trust on first use; an accepted
|
|
/// correspondent gets our token and a §5.6 sent copy is kept.
|
|
Future<String> send(String toText, String subject, String body,
|
|
{String? replyTo, bool anonymous = false}) async {
|
|
if (master == null) throw const SmolError("no identity yet");
|
|
final addr = parseAddress(toText);
|
|
final Map<String, dynamic> sent;
|
|
try {
|
|
sent = await _native.send(addr.short, body,
|
|
subject: subject.isEmpty ? null : subject,
|
|
replyTo: replyTo,
|
|
dial: await _dial(addr.host));
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
if (sent["warning"] != null) {
|
|
_warn(sent["warning"] as String);
|
|
}
|
|
return addr.short;
|
|
}
|
|
|
|
// --- reading -----------------------------------------------------------------
|
|
|
|
/// Opens one sealed message: the described view the reader shows. Sync —
|
|
/// one unseal is microseconds native, and the old Dart core did the same
|
|
/// work at fifty times the cost.
|
|
OpenedRecord describe(MailRecord row) {
|
|
var entry = _openedCache[row.id];
|
|
if (entry == null) {
|
|
if (_native.account == null) {
|
|
// No account handle: no identity to unseal with.
|
|
entry = OpenedRecord(row.id, error: "no identity yet");
|
|
_openedCache[row.id] = entry;
|
|
return entry;
|
|
}
|
|
try {
|
|
final described =
|
|
_ffi.describe(_native.store!, _native.account!, row.id);
|
|
entry = OpenedRecord(
|
|
row.id,
|
|
sender: described["sender"] as String,
|
|
time: described["time"] as int,
|
|
fields: (described["fields"] as Map).cast<String, String>(),
|
|
body: described["text"] as String,
|
|
);
|
|
} on NativeSmolException catch (err) {
|
|
entry = OpenedRecord(row.id, error: err.message);
|
|
}
|
|
_openedCache[row.id] = entry;
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/// The Reply-To address carried inside the message, but only when it is a
|
|
/// full smol:// URI whose key matches the signer (§5.7); anything else is
|
|
/// ordinary text.
|
|
SmolAddress? replyAddress(OpenedRecord opened) {
|
|
final claim = opened.fields["reply-to"];
|
|
if (claim == null || opened.sender == null) return null;
|
|
try {
|
|
final parsed = parseAddress(claim);
|
|
if (parsed.identity != null && parsed.identity == opened.sender) {
|
|
return parsed;
|
|
}
|
|
} on SmolError {
|
|
// malformed claim: display, never bind
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Bind a user-supplied address to the key that signed a message. A smol://
|
|
/// address carries its own key (verified); a short address is resolved and
|
|
/// the result kept on first use. Anything that binds a different key is
|
|
/// refused.
|
|
Future<void> nameSender(String text, String senderKey) async {
|
|
final addr = parseAddress(text.trim());
|
|
if (addr.identity == null) {
|
|
await refreshContact(addr.short);
|
|
final known = store.contact(addr.short);
|
|
if (known == null) {
|
|
throw const SmolError("could not resolve that address");
|
|
}
|
|
if (known.key != senderKey) {
|
|
throw const SmolError(
|
|
"that address carries a different key than this message's sender");
|
|
}
|
|
return;
|
|
}
|
|
if (addr.identity != senderKey) {
|
|
throw const SmolError(
|
|
"that address carries a different key than this message's sender");
|
|
}
|
|
await store.saveContact(addr.short, senderKey, verified: true);
|
|
}
|
|
|
|
/// A signed Reply-To is the sender's own claim, so it saves as verified —
|
|
/// but never over an address already pinned to a different key (§8).
|
|
Future<void> saveReplyAddress(SmolAddress addr, String senderKey) async {
|
|
final existing = store.contact(addr.short);
|
|
if (existing != null && existing.key != senderKey) {
|
|
throw SmolError("${addr.short} is already known with a different key — "
|
|
"verify out of band before replying");
|
|
}
|
|
await store.saveContact(addr.short, senderKey, verified: true);
|
|
}
|
|
|
|
// Re-resolve a contact and apply §8: a valid rotation chain is accepted and
|
|
// surfaced; anything else requires out-of-band verification.
|
|
Future<RefreshOutcome> refreshContact(String address) async {
|
|
final addr = parseAddress(address);
|
|
if (addr.identity != null) {
|
|
throw const SmolError(
|
|
"that address already carries a key; use import instead");
|
|
}
|
|
final known = store.contact(addr.short);
|
|
final Map<String, dynamic> resolved;
|
|
try {
|
|
resolved = await _native.resolve(addr.short, dial: await _dial(addr.host));
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
final change = resolved["change"] as String;
|
|
if (known == null) {
|
|
return RefreshOutcome(
|
|
"${addr.short} pinned (trust on first use)", change == "newUnverified");
|
|
}
|
|
switch (change) {
|
|
case "none":
|
|
return RefreshOutcome("${addr.short}: key unchanged", false);
|
|
case "rotated":
|
|
_warn("${addr.short} rotated its key; a signed chain confirms it.\n"
|
|
"now ${resolved["key"]}");
|
|
return RefreshOutcome(
|
|
"${addr.short} rotated its key; a signed chain confirms it.\n"
|
|
"now ${resolved["key"]}",
|
|
true);
|
|
default:
|
|
return RefreshOutcome(
|
|
"${addr.short} presents a different key with no valid rotation chain.\n"
|
|
"Verify out of band, then import the new smol:// address.",
|
|
true);
|
|
}
|
|
}
|
|
|
|
// Bind a smol:// address to the key it carries (§8's strong path).
|
|
Future<void> importContact(String text) async {
|
|
final addr = parseAddress(text.trim());
|
|
if (addr.identity == null) {
|
|
throw const SmolError("import needs a smol:// address carrying a key");
|
|
}
|
|
await store.saveContact(addr.short, addr.identity!, verified: true);
|
|
}
|
|
|
|
// --- rotation -----------------------------------------------------------------
|
|
|
|
/// §7: rotate to the next index's derived key and rebind the account with
|
|
/// a signed certificate. The superseded key stays derivable from the
|
|
/// master, since mail sealed to it stays readable with nothing else.
|
|
Future<SmolIdentity> rotateIdentity() async {
|
|
if (identity == null || master == null || accountAddress() == null) {
|
|
throw const SmolError("rotate needs a registered account");
|
|
}
|
|
try {
|
|
await _native.rotate(dial: await _accountDial());
|
|
} on NativeSmolException catch (err) {
|
|
throw SmolError(err.message);
|
|
}
|
|
_openedCache.clear();
|
|
return identity!;
|
|
}
|
|
|
|
// A full wipe: every secret and every stored envelope. The UI must confirm.
|
|
Future<void> wipe() async {
|
|
await store.wipe();
|
|
_openedCache.clear();
|
|
}
|
|
|
|
Uint8List _unhex(String text) {
|
|
if (text.length % 2 != 0) {
|
|
throw const SmolError("odd-length hex string");
|
|
}
|
|
return Uint8List.fromList([
|
|
for (var i = 0; i < text.length; i += 2)
|
|
int.parse(text.substring(i, i + 2), radix: 16),
|
|
]);
|
|
}
|
|
}
|