# 文 fumi A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) client: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection, with an optional Reticulum (RNS) mesh carrier behind the `rns` build feature. `fumi` implements the client side only — deriving identities, sealing and opening mail, and talking to a mailbox — and is the counterpart to [bunshin](https://code.randogoth.com/randogoth/bunshin), the server. **Status: plan only.** Nothing in `src/` exists yet. Everything below is the intended design; the coverage tables are targets, not claims. ## Scope | | v1.1 (TCP) | v1.2 (RNS) | |---|---|---| | `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE`, `REGISTER` | planned | planned, `rns` feature | | Envelope sealing and opening (§5.1–5.4) | planned | identical, transport does not touch it | | Frontmatter (§5.5), sent copies (§5.6), `Reply-To` (§5.7) | planned | identical | | Accept tokens (§5.8) | planned | identical | | Key rotation and chain walking (§7) | planned | identical | | Server pinning | Noise static key | none — the destination hash is the pin (§13.4) | Section numbers are [SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md) throughout; §13 onwards is [RNS.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md). ## Addressing ``` alice@example.org[:1961] short form, resolved via the server smol://alice@example.org/mfrggzdfzt... self-certifying, 52-char base32 identity smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a short form over Reticulum smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a/mfrggzdfzt... self-certifying over Reticulum ``` The path component is the 32-byte identity key in unpadded lowercase base32 — **52 characters** (§3), not 32. The RNS authority is the server's 16-byte destination hash as exactly 32 lowercase hex characters (§13.2), compared in full, with no port and no bare `user@host` form. The scheme selects the transport; there is nothing else to configure and nothing to negotiate. --- ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ main.rs — CLI │ │ keygen whoami restore rotate trust register resolve │ │ import contacts accept block send fetch delete list read │ └──────────────────────────────┬──────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────┐ │ client.rs — the six operations, AUTH session setup, chain │ │ walking, seal/open pipelines, token sync, outbound queue │ └───┬──────────────┬──────────────┬──────────────┬────────────┘ ▼ ▼ ▼ ▼ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ account.rs │ │ message.rs │ │ store.rs │ │ address.rs │ │ master │ │ envelope │ │ SQLite │ │ the four forms │ │ rotation │ │ payload │ │ contacts │ │ usernames │ │ tokens │ │ frontmatter│ │ tokens │ │ URIs │ │ certs │ │ message id │ │ seen, mail │ │ fingerprints │ └──────┬─────┘ └─────┬──────┘ └────────────┘ └────────────────┘ ▼ ▼ ┌───────────────────────────────┐ │ crypto.rs │ │ hkdf, hmac, sha256, base32 │ │ Ed25519/X25519 map, §2 checks │ └───────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ transport.rs — trait Transport { request, bind, close }, │ │ TransportBindValues, operation and status constants │ └───────────────────▲─────────────────────▲───────────────────┘ implements │ │ implements ┌────────┴────────┐ ┌─────────┴──────────────┐ │ tcp.rs │ │ rns/ (feature "rns") │ │ Noise_NX │ │ shim FFI, links │ │ u32 framing │ │ path discovery │ │ static-key pin │ │ │ └─────────────────┘ └────────────────────────┘ ``` `client.rs` depends on the `Transport` trait; the two carriers implement it. Only `transport.rs`'s bind values and the framing differ between them — every operation body, every signature and the whole envelope format are byte-identical (§13.5, §15). ## Modules | File | Contents | |---|---| | `crypto.rs` | base32 (unpadded lowercase, as `bunshin/src/crypto.rs`), HKDF-SHA256, HMAC-SHA256, SHA-256, the Ed25519→X25519 map with §2's checks | | `address.rs` | `Address` parsing for all four forms, username validation (§3), self-certifying URI rendering, fingerprints | | `account.rs` | master secret, `seed_n` derivation, the current rotation index, accept-key and per-correspondent token derivation, rotation certificates | | `message.rs` | envelope seal/open (§5.1–5.3), message id (§5.4), frontmatter parse/build (§5.5) | | `transport.rs` | `Transport` trait, `TransportBindValues`, operation and status constants | | `tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning | | `rns/` | `mod.rs`, `ffi.rs`, `transport.rs` — behind `#[cfg(feature = "rns")]` | | `client.rs` | the six operations, AUTH session setup, chain walking, token sync, send/fetch pipelines | | `store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent | | `error.rs` | `SmolError` and the status-code mapping | | `lib.rs`, `main.rs` | library surface and CLI | ## Dependencies ```toml [package] name = "fumi" version = "0.1.0" edition = "2021" description = "Smol Mail client" [features] # RNS carrier over microReticulum (§13); needs MICRORETICULUM_SOURCE_DIR at # build time, provided by the flake. Same shape as bunshin's. rns = ["dep:cc"] [dependencies] snow = "0.9" # Noise_NX, TCP carrier only chacha20poly1305 = "0.10" # envelope AEAD (§5.1) — snow does not expose one ed25519-dalek = { version = "2", features = ["rand_core"] } x25519-dalek = { version = "2", features = ["static_secrets"] } sha2 = "0.10" hmac = "0.12" # accept tokens and their MACs (§5.8) hkdf = "0.12" rand_core = { version = "0.6", features = ["getrandom"] } rusqlite = { version = "0.32", features = ["bundled"] } data-encoding = "2" # base32 and HEXLOWER; no separate hex crate clap = { version = "4", features = ["derive"] } log = "0.4" env_logger = "0.11" anyhow = "1" [build-dependencies] cc = { version = "1", optional = true } # compiles the RNS shim, see Phase 2 ``` `chacha20poly1305` is the dependency `bunshin` does not have and `fumi` cannot do without: the server stores envelopes without opening them, the client opens them. No `regex` — §3's username grammar is a dozen lines of `matches!`, the way `bunshin/src/proto.rs` does it. No `hex` — `data_encoding::HEXLOWER` already covers RNS destination hashes. The Ed25519→X25519 map (§2) uses `ed25519_dalek::VerifyingKey::to_montgomery()` for the public half and `SigningKey::to_scalar_bytes()` fed to `x25519_dalek::StaticSecret` for the private half, which is exactly what libsodium's `crypto_sign_ed25519_{pk,sk}_to_curve25519` produce. §2 requires rejecting an all-zero agreement output and a low-order received ephemeral; `x25519-dalek` signals neither, so `crypto.rs` checks the output for all-zero and refuses, which covers both. --- ## Cryptographic detail The one place a client cannot be approximately right. Taken from §5.2–§5.4; `smolmail.py`'s `seal`/`unseal` are the executable reference. ### Sealing ``` esk, epk = X25519_keygen() shared = X25519(esk, to_x25519(recipient_identity)) # reject all-zero key = HKDF-SHA256(shared, salt = epk || recipient_identity, info = "smolmail/1 seal", len = 32) # one key, not two wipe(esk) header = version u8 || sender 32 || time i64 || body_len u32 signature = Ed25519(sender, "smolmail/1 msg" || to || epk || header || body) plaintext = header || body || signature || padding # padding to 1024, MAY, default on aad = "SMOL" || version u8 || to 32 || epk 32 # 69 bytes, the envelope header envelope = aad || ChaCha20-Poly1305(key, nonce = 0^12, aad, plaintext) id = SHA-256("smolmail/1 id" || envelope) ``` - `"smolmail/1 seal"` is the HKDF info; `"smolmail/1 msg"` is the signature label. They are different labels for different jobs (§11). - The salt is `epk || recipient_identity`, not empty. It binds the key to this exact sealing. - **The sender is inside the ciphertext.** Nothing outside the AEAD names a sender — that is the whole of §5.2's unlinkability and §9's privacy claim. Frontmatter lives in `body`, so it is encrypted too. - The nonce is all-zero and that is correct: `key` is used exactly once because `esk` is fresh per message. - `body_len` makes the padding unambiguous and the tag protects it. ### Opening Mirror image, plus the checks a receiver **MUST** perform (§5.3): the envelope's `to` is one of our own keys (including superseded ones, §7), the signature verifies against the `sender` it carries, `time` is not more than 86400 s ahead of our clock, and trailing bytes past `body_len + 64` are ignored as padding. A larger backwards skew MAY be surfaced. ### Accept tokens (§5.8) ``` accept = HKDF-SHA256(master, salt = "", info = "smolmail/1 accept", len = 32) t = HMAC-SHA256(accept, correspondent_identity) mac = HMAC-SHA256(t, "smolmail/1 mac" || id) ``` `accept` does not depend on the rotation index, so tokens survive our rotation; `t` is frozen at the identity the correspondent had when accepted, so it survives theirs. The correspondent's copy of `t` travels as a base32 `Accept` frontmatter field inside a sealed, signed payload, and is filed under the address that signed it — never under the key, which rotates. --- ## Client obligations The requirements that have no natural home in an operation handler and are therefore the ones an implementation forgets. Each is owned by exactly one module. | Requirement | Section | Owner | |---|---|---| | Reject a username that is not 1–63 of `[a-z0-9._-]`, does not start and end alphanumeric, or has adjacent separators; normalise to lowercase | §3 | `address.rs` | | Abort on a pinned-key mismatch and surface it; mark `RESOLVE` from an unpinned session unverified | §4 | `tcp.rs`, `client.rs` | | Refuse `REGISTER`, `FETCH`, `DELETE` against a server whose key did not come from a trusted channel | §4 | `client.rs` | | `AUTH` with `sync = 0` unless the local token set is known complete — a master-restored client must not erase the server's set with an empty one | §4 | `account.rs`, `store.rs` (`sync_ok` flag) | | Reject all-zero X25519 output and low-order ephemerals | §2 | `crypto.rs` | | Verify the payload signature, check `to` is ours, enforce the 86400 s forward skew | §5.3 | `message.rs` | | Keep a sent copy sealed to our own key | §5.6 | `client.rs` | | `Reply-To`: act on it only when the URI's key equals `sender`, never let it replace a bound key | §5.7 | `client.rs` | | Frontmatter: 4 KiB, 64 keys, first occurrence wins, case-insensitive keys, malformed block falls back to plain text, no YAML parser | §5.5 | `message.rs` | | Walk the rotation chain from the pinned key, verify both signatures per link, max 16 links; a broken, absent or over-long chain needs user confirmation; surface every rotation | §7 | `client.rs` | | Be able to re-derive every superseded private key and accept mail addressed to it | §7 | `account.rs` | | Keep a seen-id set so a replayed envelope is not re-stored after deletion | §10 | `store.rs` | | Page `FETCH` forward by `(received_at, id)` until a response is empty; deletion is a separate decision | §6.1 | `client.rs` | | Treat an unknown status code as failure, never retry automatically, surface the number, never parse the reason string | §12 | `error.rs` | | Own the outbound queue and retry; resending is safe because ids are derived | §6, §13.10 | `store.rs`, `client.rs` | ## Local store One SQLite file, shared by both carriers (§15). Following the reference client's schema: ``` state one row: username, host, port, scheme, rotations, cursor (after_time, after_id), sync_ok servers host -> pinned Noise static key, pinned_at (TCP only; RNS has nothing to pin) contacts address -> identity, verified, seen_at (verified = key came from a smol:// URI) accepted address -> identity frozen at acceptance, active (our tokens, issued to others) tokens address -> token (their tokens, issued to us) seen id -> at (§10 replay guard) inbox id -> envelope, received_at, tier (sealed at rest, opened on demand) sent id -> recipient, envelope, sent_at (§5.6 copies) ``` Envelopes are stored sealed; no plaintext at rest. --- ## CLI One master per store, selected by the global `--key` and `--db` flags — a second identity is a second pair of files. This drops the multi-account juggling an earlier draft had and with it the ambiguity of which account a bare `fetch
` meant: the home address lives in `state`, so only the commands that address someone else take an address. ``` fumi [--key identity.key] [--db fumi.db] [--timeout 30]