kirakira/lib/smol/client.dart

445 lines
16 KiB
Dart
Raw Normal View History

// 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) == 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) => store.pinServer(host, keyB32);
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),
]);
}
}