// 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(); Uint8List? get master => store.master(); SmolAddress? accountAddress() { final account = store.account(); if (account == null) return null; final suffix = account.port == defaultPort ? "" : ":${account.port}"; return parseAddress("${account.user}@${account.host}$suffix"); } // §4: registration and fetching demand a pinned key; sending to a recipient // whose key we already hold tolerates an unpinned server. Future connect(SmolAddress addr, {required bool requirePin}) async { final pinned = store.serverPin(addr.host); if (requirePin && pinned == null) { throw SmolError("no pinned key for ${addr.host}. Obtain it from the " "operator through a trusted channel, then pin it in settings."); } final wire = await TcpWire.connect(addr.host, addr.port); try { final opened = await openSession(wire, addr.host, pinned: pinned); if (pinned == null) { _warn("${addr.host} is not pinned; its key is " "${b32encode(opened.serverStatic)}.\n" "RESOLVE results from this session are UNVERIFIED (SPEC.md §8)."); } return opened; } catch (_) { wire.close(); rethrow; } } // --- identity setup ------------------------------------------------------------ /// A fresh master secret at rotation index 0. Only this local step; nothing /// is sent until [registerAccount]. Uint8List createIdentity() { final fresh = randomBytes(keyLen); store.setMaster(fresh); return fresh; } /// §2: a master alone does not say which rotation index a server has bound, /// so restoring resolves the address and walks indices 0..[maxChain] until /// one derives the key RESOLVE returned. Also binds "account" locally, like /// [recallAccount] — restoring on a new device knows the identity but not /// the address it was registered under. Future 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 != keyLen) { throw SmolError("master is ${master.length} bytes, expected $keyLen"); } final addr = parseAddress(addressText); final opened = await connect(addr, requirePin: true); Uint8List current; try { current = (await resolveOp(opened.session, addr.user)).identity; } finally { opened.session.wire.close(); } int? found; for (var n = 0; n <= maxChain; n++) { if (timingSafeEqual(identityFromSeed(identitySeed(master, n)).publicKey, current)) { found = n; break; } } if (found == null) { throw SmolError("the key bound to ${addr.short} is not derived from " "this master within $maxChain rotations"); } store.restoreMaster(master, found); store.setAccount(addr); return addr; } void pinServer(String host, String keyB32) { final key = b32decode(keyB32); if (key.length != keyLen) { throw SmolError("server key is ${key.length} bytes, expected $keyLen"); } 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, opened.serverStatic, addr.user, me, RegisterOptions(token: token)); } finally { opened.session.wire.close(); } store.setAccount(addr); } /// Restoring a master brings back the identity, not the memory of what /// address a *different device* registered it under — "account" is /// local-only state, never asked of the server. This binds it without /// REGISTER: RESOLVE the address and require it name this exact key, so a /// typo or someone else's address cannot misfile fetch and Reply-To. 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 master = this.master; 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"); } var stored = 0; final rejected = []; // §10: acknowledging (deleting) is the default; "leave mail on server" // pages forward by cursor instead, so already-fetched mail is never // re-downloaded even though it isn't deleted (store.storeIfNew also // dedupes, as a second line of defense). final leaveOnServer = store.leaveOnServer(); var (afterTime, afterId) = store.cursor(); final opened = await connect(addr, requirePin: true); try { final (sync, tokens) = store.tokenSet(master); await authenticate(opened.session, opened.handshakeHash, addr.user, me, sync: sync, tokens: tokens); while (true) { final records = await fetchOp(opened.session, afterTime, afterId); if (records.isEmpty) break; final acked = []; for (final record in records) { afterTime = record.receivedAt; afterId = record.id; OpenedMessage msg; try { if (!timingSafeEqual(messageId(record.envelope), record.id)) { throw const SmolError("id does not match the envelope"); } msg = unseal(store.identities(), record.envelope); } on SmolError catch (err) { // Left on the server rather than destroyed, so a client-side bug // cannot lose mail. rejected.add("${hex(record.id)}: ${err.message}"); continue; } final fresh = await store.storeIfNew( "inbox", MailRecord(hex(record.id), record.envelope, receivedAt: record.receivedAt, tier: record.isRequest ? tierRequests : tierMain, keptOnServer: leaveOnServer)); if (fresh != null) { stored++; _learnToken(msg); } acked.add(record.id); } if (leaveOnServer) { // Persisted per batch, so an interrupted fetch resumes here rather // than re-paging from the start next time. store.setCursor(afterTime, afterId); } else if (acked.isNotEmpty) { await deleteOp(opened.session, acked); } } } finally { opened.session.wire.close(); } if (!leaveOnServer) { // Everything acknowledged is deleted, so the next fetch starts fresh; a // record left on the server (rejected above) simply resurfaces then. store.setCursor(0, Uint8List(idLen)); } return FetchSummary(stored, rejected); } // --- delete ------------------------------------------------------------------ /// Deletes a message locally, and from the server too if it might still be /// sitting there (only possible when "leave mail on server" was on when it /// was fetched — §10). Sent copies are local-only; there is nothing /// server-side to remove for them (§5.6). Throws, leaving the local copy in /// place, if a needed server-side delete fails — otherwise a message could /// look gone locally while silently persisting on the server. Future deleteMessage(String folder, MailRecord record) async { if (folder != "sent" && record.keptOnServer) { final me = identity; final addr = accountAddress(); if (me == null || addr == null) { throw const SmolError( "not registered; cannot reach the server to delete this message"); } final opened = await connect(addr, requirePin: true); try { await authenticate(opened.session, opened.handshakeHash, addr.user, me, sync: 0, tokens: const []); await deleteOp(opened.session, [unhex(record.id)]); } finally { opened.session.wire.close(); } } await store.deleteMessage(folder, record.id); } // §5.8: an Accept field is bound to the signer of the message that carried // it, which unseal() has already verified. void _learnToken(OpenedMessage msg) { final parsed = parseFrontmatter(utf8.decode(msg.body, allowMalformed: true)); final raw = parsed.fields["accept"]; if (raw == null) return; Uint8List token; try { token = b32decode(raw); } on SmolError { return; } if (token.length != tokenLen) return; final address = _addressOfSigner(msg.sender, parsed.fields["reply-to"]); if (address == null) return; // no address to send to, so no use for a token store.learnToken(address, token); } /// The address we know a signer by: a contact, or the Reply-To it signed /// for itself. Naming a mailbox is not trusting a key, so nothing is /// pinned here (§5.7, §8). String? _addressOfSigner(Uint8List sender, String? replyTo) { final known = store.addressForKey(sender); if (known != null) return known; if (replyTo == null) return null; try { final parsed = parseAddress(replyTo); if (parsed.identity != null && timingSafeEqual(parsed.identity!, sender)) { return parsed.short; } } on SmolError { // malformed claim: no address to learn a token under } return null; } // --- accept tokens (§5.8) -------------------------------------------------------- /// Admit a contact to the main tier; their token travels in our next /// message to them. Pushes the change to the server right away, since an /// accept or a block only takes effect once it holds the changed set. Future acceptContact(String address) async { final key = store.contact(address)?.key; if (key == null) throw SmolError("no key for $address yet"); store.accept(address, key); store.setSyncOk(true); return _pushTokens(); } /// Withdraw a contact's accept token; their mail lands in requests from /// their next message on. Future blockContact(String address) async { store.block(address); return _pushTokens(); } Future _pushTokens() async { final me = identity; final master = this.master; final addr = accountAddress(); if (me == null || master == null) throw const SmolError("no identity yet"); if (addr == null) { _warn("not registered; the set will be pushed with your first fetch"); return 0; } final opened = await connect(addr, requirePin: true); try { final (sync, tokens) = store.tokenSet(master); return await authenticate(opened.session, opened.handshakeHash, addr.user, me, sync: sync, tokens: tokens); } finally { opened.session.wire.close(); } } // --- 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; final master = this.master; if (me == null || master == null) throw const SmolError("no identity yet"); final addr = parseAddress(toText); final recipient = await resolveRecipient(addr); final account = accountAddress(); final fields = {"Subject": subject, "In-Reply-To": replyTo ?? ""}; // A signed Reply-To lets a first-time recipient name and answer us // (§5.5 allows unknown keys); "anonymous" omits it. if (account != null && !anonymous) { fields["Reply-To"] = account.uri(me.publicKey); } // §5.8: hand an accepted correspondent the token for our own mailbox, so // a first reply from them reaches our main tier. final accepted = store.accepted(addr.short); if (accepted != null && accepted.active) { fields["Accept"] = b32encode(tokenFor(master, accepted.identity)); } final bodyBytes = utf8Bytes(buildFrontmatter( fields, "${body.replaceFirst(RegExp(r"\s+$"), "")}\n")); final envelope = seal(me, recipient, bodyBytes); // §5.8: our token for their mailbox, if they have given us one. final held = store.tokenFrom(addr.short); final mac = held == null ? null : acceptMac(held, messageId(envelope)); final opened = await connect(addr, requirePin: false); try { await sendOp(opened.session, envelope, mac: mac); } 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(addr.user, known.key, resolved.identity, resolved.chain)) { store.saveContact(addr.short, resolved.identity, known.verified); return RefreshOutcome( "${addr.short} rotated its key; a signed chain confirms it.\n" "now ${b32encode(resolved.identity)}", true); } return RefreshOutcome( "${addr.short} presents a different key with no valid rotation chain.\n" "Verify out of band, then import the new smol:// address.", true); } // Bind a smol:// address to the key it carries (§8's strong path); the // displaced key, if any, lands in the contact's history. void importContact(String text) { 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 the next index's derived key and rebind the account with a // signed certificate. The superseded key stays derivable from the master, // since mail sealed to it stays readable with nothing else. Future rotateIdentity() async { final me = identity; final master = this.master; final addr = accountAddress(); if (me == null || master == null || addr == null) { throw const SmolError("rotate needs a registered account"); } final freshSeed = identitySeed(master, store.rotations() + 1); final fresh = identityFromSeed(freshSeed); final cert = makeCert(addr.user, me, freshSeed); final opened = await connect(addr, requirePin: true); try { await registerOp(opened.session, opened.serverStatic, addr.user, fresh, RegisterOptions(cert: cert)); } finally { opened.session.wire.close(); } store.advanceRotation(); _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(); } }