// App-level client: the flows of gsmol's app.js — connect with pinning, fetch // with verification and acknowledgment, send with sent copies, contacts and // rotation — on top of the pure protocol modules. import "dart:convert"; import "dart:typed_data"; 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/transport.dart"; class RefreshOutcome { final String message; final bool warn; const RefreshOutcome(this.message, this.warn); } class FetchSummary { final int stored; final List rejected; const FetchSummary(this.stored, this.rejected); } class OpenedRecord { final String id; final Uint8List? sender; final int? time; final Map 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); // Opening an envelope costs an X25519 agreement and an Ed25519 // verification, and an envelope's plaintext never changes — so the result // is kept. Failures are cached too, so one bad message is not retried on // every render. Rotation clears it, since the key set grew. final _openedCache = {}; SmolIdentity? get identity => store.identity(); SmolAddress? accountAddress() { final account = store.account(); if (account == null) return null; final suffix = account.port == defaultPort ? "" : ":${account.port}"; return parseAddress("${account.user}@${account.host}$suffix"); } // §4: registration and fetching demand a pinned key; sending to a recipient // whose key we already hold tolerates an unpinned server. Future connect(SmolAddress addr, {required bool requirePin}) async { final pinned = store.serverPin(addr.host); if (requirePin && pinned == null) { throw SmolError("no pinned key for ${addr.host}. Obtain it from the " "operator through a trusted channel, then pin it in settings."); } final wire = await TcpWire.connect(addr.host, addr.port); try { final opened = await openSession(wire, addr.host, pinned: pinned); if (pinned == null) { _warn("${addr.host} is not pinned; its key is " "${b32encode(opened.serverStatic)}.\n" "RESOLVE results from this session are UNVERIFIED (SPEC.md §8)."); } return opened; } catch (_) { wire.close(); rethrow; } } // --- identity setup ------------------------------------------------------------ SmolIdentity createIdentity() { final fresh = newIdentity(); store.setIdentity(fresh.seed); return fresh; } SmolIdentity restoreIdentity(String seedHex) { Uint8List seed; try { seed = unhex(seedHex.trim()); } on Exception { throw const SmolError("seed must be 64 hex characters"); } if (seed.length != keyLen) { throw SmolError("seed is ${seed.length} bytes, expected $keyLen"); } final restored = identityFromSeed(seed); store.setIdentity(seed); return restored; } void pinServer(String host, String keyB32) { final key = b32decode(keyB32); if (key.length != keyLen) { throw SmolError("server key is ${key.length} bytes, expected $keyLen"); } store.pinServer(host.trim().toLowerCase(), key); } Future registerAccount(String addressText, {String token = ""}) async { final me = identity; if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(addressText); final opened = await connect(addr, requirePin: true); try { await registerOp(opened.session, addr.user, me, RegisterOptions(token: token)); } finally { opened.session.wire.close(); } store.setAccount(addr); } /// Restoring a seed brings back the identity, not the memory of what /// address a *different device* registered it under — "account" is /// local-only state, never asked of the server. This binds it without /// REGISTER: RESOLVE the address and require it name this exact key, so a /// typo or someone else's address cannot misfile fetch and Reply-To. Future recallAccount(String addressText) async { final me = identity; if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(addressText); final opened = await connect(addr, requirePin: true); Uint8List current; try { current = (await resolveOp(opened.session, addr.user)).identity; } finally { opened.session.wire.close(); } if (!timingSafeEqual(current, me.publicKey)) { throw SmolError( "${addr.short} resolves to a different key — not this identity"); } store.setAccount(addr); return addr; } // --- fetch ---------------------------------------------------------------------- Future fetch() async { final me = identity; final addr = accountAddress(); if (me == null) throw const SmolError("no identity yet"); if (addr == null) { throw const SmolError("not registered; register an address first"); } var stored = 0; final rejected = []; final opened = await connect(addr, requirePin: true); try { await authenticate(opened.session, opened.handshakeHash, addr.user, me); while (true) { final records = await fetchOp(opened.session); if (records.isEmpty) break; final acked = []; for (final record in records) { try { if (!timingSafeEqual(messageId(record.envelope), record.id)) { throw const SmolError("id does not match the envelope"); } 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)); if (fresh != null) stored++; acked.add(record.id); } if (acked.isEmpty) break; await deleteOp(opened.session, acked); } } finally { opened.session.wire.close(); } return FetchSummary(stored, rejected); } // --- compose and send ----------------------------------------------------------- // Prefer a key we already trust; fall back to RESOLVE with trust on first // use. Future resolveRecipient(SmolAddress addr) async { if (addr.identity != null) { store.saveContact(addr.short, addr.identity!, true); return addr.identity!; } final known = store.contact(addr.short); if (known != null) return known.key; final opened = await connect(addr, requirePin: false); Uint8List current; try { current = (await resolveOp(opened.session, addr.user)).identity; } finally { opened.session.wire.close(); } store.saveContact(addr.short, current, false); return current; } Future send(String toText, String subject, String body, {String? replyTo, bool anonymous = false}) async { final me = identity; if (me == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); final recipient = await resolveRecipient(addr); final account = accountAddress(); final fields = {"Subject": subject, "In-Reply-To": replyTo ?? ""}; // A signed Reply-To lets a first-time recipient name and answer us // (§5.5 allows unknown keys); "anonymous" omits it. if (account != null && !anonymous) { fields["Reply-To"] = account.uri(me.publicKey); } final bodyBytes = utf8Bytes(buildFrontmatter( fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); final envelope = seal(me, recipient, bodyBytes); final opened = await connect(addr, requirePin: false); try { await sendOp(opened.session, envelope); } finally { opened.session.wire.close(); } // §5.6: the ephemeral is gone, so keep a copy sealed to ourselves. await store.storeMessage("sent", MailRecord(hex(messageId(envelope)), seal(me, me.publicKey, bodyBytes), recipient: addr.short, sentAt: nowSeconds())); return addr.short; } // --- reading ----------------------------------------------------------------- // What is known about a sender changes as the user binds addresses to keys, // so this layer sits over the cached envelope and is recomputed per call — // it is a map lookup, not crypto. OpenedRecord describe(MailRecord row) { final opened = _openEnvelope(row); if (opened.error != null) return opened; return OpenedRecord( row.id, sender: opened.sender, time: opened.time, fields: opened.fields, body: opened.body, ); } OpenedRecord _openEnvelope(MailRecord row) { var entry = _openedCache[row.id]; if (entry == null) { try { final opened = unseal(store.identities(), row.envelope); final parsed = parseFrontmatter(utf8.decode(opened.body, allowMalformed: true)); entry = OpenedRecord(row.id, sender: opened.sender, time: opened.time, fields: parsed.fields, body: parsed.body); } on SmolError catch (err) { 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 && timingSafeEqual(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 nameSender(String text, Uint8List senderKey) async { final addr = parseAddress(text.trim()); Uint8List key; var verified = true; if (addr.identity == null) { final opened = await connect(addr, requirePin: false); try { key = (await resolveOp(opened.session, addr.user)).identity; } finally { opened.session.wire.close(); } verified = false; // trust on first use, as with any RESOLVE } else { key = addr.identity!; } if (!timingSafeEqual(key, senderKey)) { throw const SmolError( "that address carries a different key than this message's sender"); } store.saveContact(addr.short, senderKey, 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 /// change without a rotation chain needs out-of-band confirmation). Future saveReplyAddress(SmolAddress addr, Uint8List senderKey) async { final existing = store.contact(addr.short); if (existing != null && !timingSafeEqual(existing.key, senderKey)) { throw SmolError("${addr.short} is already known with a different key — " "verify out of band before replying"); } store.saveContact(addr.short, senderKey, true); } // Re-resolve a contact and apply §8: a valid rotation chain is accepted and // surfaced; anything else requires out-of-band verification. Future refreshContact(String address) async { final addr = parseAddress(address); if (addr.identity != null) { throw const SmolError("that address already carries a key; use import instead"); } final known = store.contact(addr.short); final opened = await connect(addr, requirePin: false); Resolved resolved; try { resolved = await resolveOp(opened.session, addr.user); } finally { opened.session.wire.close(); } if (known == null) { store.saveContact(addr.short, resolved.identity, false); return RefreshOutcome( "${addr.short} pinned (trust on first use" "${opened.pinned ? "" : ", UNVERIFIED server"})", !opened.pinned); } if (timingSafeEqual(known.key, resolved.identity)) { return RefreshOutcome("${addr.short}: key unchanged", false); } if (walkChain(known.key, resolved.identity, resolved.chain)) { store.saveContact(addr.short, resolved.identity, known.verified); return RefreshOutcome( "${addr.short} rotated its key; a signed chain confirms it.\n" "now ${b32encode(resolved.identity)}", true); } return RefreshOutcome( "${addr.short} presents a different key with no valid rotation chain.\n" "Verify out of band, then import the new smol:// address.", true); } // Bind a smol:// address to the key it carries (§8's strong path); the // displaced key, if any, lands in the contact's history. void importContact(String text) { final addr = parseAddress(text.trim()); if (addr.identity == null) { throw const SmolError("import needs a smol:// address carrying a key"); } store.saveContact(addr.short, addr.identity!, true); } // --- rotation ----------------------------------------------------------------- // §7: rotate to a fresh seed and rebind the account with a signed // certificate. The old seed is kept by the store, since mail sealed to it // stays readable with nothing else. Future rotateIdentity() async { final me = identity; final addr = accountAddress(); if (me == null || addr == null) { throw const SmolError("rotate needs a registered account"); } final fresh = newIdentity(); final cert = makeCert(me, fresh.seed); final opened = await connect(addr, requirePin: true); try { await registerOp(opened.session, addr.user, fresh, RegisterOptions(cert: cert)); } finally { opened.session.wire.close(); } store.rotateIdentity(fresh.seed); _openedCache.clear(); return fresh; } // A full wipe: every secret and every stored envelope. The UI must confirm. Future wipe() async { await store.wipe(); _openedCache.clear(); } }