# 文 fumi [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![AI-DECLARATION: auto](https://img.shields.io/badge/%E4%B7%BC%20AI--DECLARATION-auto-ede9fe?labelColor=ede9fe)](https://ai-declaration.md) A Rust client for [Smol Mail](https://code.randogoth.com/randogoth/smolmail): a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `fumi` derives an identity from one 32-byte master secret, seals and opens mail, and talks to a mailbox; it is the counterpart to [bunshin](https://code.randogoth.com/randogoth/bunshin), the server. ## Status Both carriers are implemented: the TCP carrier (SPEC.md 1.1) and the Reticulum carrier of RNS.md (1.2), the latter behind the `rns` cargo feature (`nix build .#rns`). `fumi` interoperates in both directions with the reference stack — `smolmail.py`/`smolmaild.py` over TCP and `smolmail_rns.py`/`smolmaild_rns.py` over Reticulum — and with `bunshin` over both carriers, including the cross-transport case upstream section 15 promises: send over TCP, fetch over the mesh, one mailbox. ## 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 (rns feature) smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a/mfrggzdfzt... self-certifying over Reticulum ``` ## Usage One master per store, selected by the global `--key` and `--db` flags; a second identity is a second pair of files. ``` fumi [--key identity.key] [--db fumi.db] [--timeout 30] # identity fumi keygen [--force] create a master secret fumi whoami address, public key, fingerprint, rotation index fumi restore
recover local state from the master alone fumi rotate advance the rotation index, sign and push the certificate # servers and contacts fumi trust [--force] pin a server's Noise static key fumi register
[--invite TOKEN] bind this identity to a username fumi resolve
look up a contact's key, walk the chain, pin it fumi import add a contact from a self-certifying address fumi contacts known keys and how each was learned # accept tokens fumi accept
issue a token, admit to the main tier, sync the set fumi block
withdraw it, sync the set # mail fumi send
[--subject S] [--body TEXT | --file F | -] [--header K:V] [--reply-to ID] [--anonymous] [--no-pad] fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge fumi delete ... remove from the server explicitly fumi list [--sent] [--requests] # backups fumi export [FILE] write a gsmol backup: sealed mail, contacts, pins — never the master fumi import-backup restore one; merges, never overwrites a trust binding fumi read [--sent] # RNS carrier (built with --features rns / nix build .#rns); addresses select # it by scheme, so these flags apply only to smol+rns:// dials: fumi --rns-udp --rns-udp-forward [--rns-storage DIR] ``` On the mesh the destination hash is the pin (RNS.md 13.4): there is no `trust`, no static key, and a client creates no Reticulum identity (13.8). The `--rns-udp-forward` target is effectively required for a client, which speaks first; it should point at an `rnsd` or at the server's UDP interface. Requests carry an explicit 30-second timeout (13.5), links are torn down when a session ends (13.10), and a resend is safe because ids are derived. Mail is stored sealed and opened on demand; there is no plaintext at rest. A first contact is learned by trust on first use and marked unverified when the server's key was not pinned; a key change is accepted only when a signed rotation chain leads from the key held to the one offered, and is surfaced rather than applied silently. ## Build ``` nix build # or: nix develop -c cargo build nix develop -c cargo test --workspace ``` The crate is a workspace: `core/` is the `fumi-core` library — everything but the command line — and `cli/` is the `fumi` binary. GUI hosts depend on `fumi-core` alone; it pulls no argument parsing, no logging globals, and no keyfile conventions. `store` (default) is the SQLite local-state module and the operations that take it; `bundled-sqlite` (default) vendors libsqlite3, so embedders that link their own SQLite, as Android's NDK does, build with `default-features = false` and that flag alone. With both defaults off the crate keeps identities, addresses, seal/open and the raw transport operations (`resolve`, `walk_chain`, `accept_mac`) — for hosts with their own database and thin FFI consumers that need only the crypto and envelope layer. `bunshin` and the reference clients make a complete test rig: register against a local server, exchange mail in both directions, then rotate and fetch the mail that the superseded key still receives. ## Embedding `fumi-core` is the protocol core of a GUI client, not just this CLI's engine. The contract an embedder can rely on: - **Secrets stay bytes in the API, files stay in the CLI.** `Account::new(master, index)` takes the master as bytes and `Store::open(path)` takes only mail and state; `identity.key` next to `fumi.db` is a CLI convention. A GUI keeps the master wherever its platform wants it (Android Keystore, OS keychain) and hands it over per operation. - **A storeless build.** `default-features = false` drops rusqlite entirely: identities, addresses, seal/open and the raw transport operations remain, for hosts with their own database. `store` and `bundled-sqlite` compose with it as separate features. - **One shareable store handle.** `Store` is `Send + Sync`: every operation locks an inner mutex, and the database runs in WAL mode, so a host holds one `Store` behind an executor and reads it while a fetch writes. Each envelope is committed as it is verified — a concurrent reader listing the inbox sees fetch progress without any callback. `Store::open_in_memory()` gives a scratch store with the schema and version already in place — for tests, foreign importers assembling rows in code, and FFI round-trips that never touch disk. - **Decisions, not prose.** `error::Error` is an enum: `NotPinned`, `PinMismatch`, `KeyChanged { address, known, offered }`, `NotRegistered`, `AuthFailed`, `RateLimited`, `QuotaExceeded`, `SchemaVersion`, ... — the distinctions a UI routes on. `Display` still produces the message the CLI prints. - **Long operations can stop.** `fetch_with` takes a `FetchOptions` with a cancellation token checked between pages and before each envelope; the cursor covers exactly what was processed, so a cancelled fetch resumes rather than repeats. - **Composable flows.** `restore` remains the one-call convenience, but its pieces are public: `connect` + `resolve`, `account::find_rotation_index`, then the `Store` setters — a wizard can hold the master and the address before it has connectivity, and show each step. Trust outcomes arrive as values: `TrustChange` from `trust_key` and `send`, `Session::unpinned_static` for the sec 4 warning. - **A versioned store.** The schema carries a `user_version`; a store written by a newer build is refused (`Error::SchemaVersion`) rather than misread, and within a version the schema is stable. The host, not the library, decides when to upgrade the binary. - **Binding from other languages.** The API is `Send`-friendly and free of CLI globals, so plain Rust bindings (flutter_rust_bridge, uniffi) work against it. `core/vectors.json` holds the reference vectors the unit tests pin — derivations, seals, a full envelope — so a port or FFI binding can verify itself end to end without the reference stack. The intended call graph: `Store::open` once, `Account::new(master, rotations)` per session, then `client::*` operations taking `&Store` and `&Account`. The store outlives accounts; nothing in the library caches between calls. ## The store schema The schema is a contract, not an implementation detail: `SCHEMA_VERSION` is 2, written to SQLite's `PRAGMA user_version` at creation and checked at open, so a foreign importer — a migration from another client, a sync tool — can build rows directly instead of reading `store.rs`. `Store::open_in_memory()` gives an importer a scratch database with schema and version in place. Within a version the layout below is stable; a version bump comes with a migration run automatically at open (version 1 stores gain the `kept` column and the `history` table in place), and only stores newer than the build are refused. Every mail column holds a sealed envelope, never plaintext. Timestamps are Unix seconds, except `history.until`, which is milliseconds; keys and ids are 32-byte blobs. | Table | Columns | Meaning and invariants | |---|---|---| | `state` | one row, `id = 1`, always present | `username`, `host`, `port`, `scheme` (`tcp` or `rns`) are the account; `rotations` is the sec 2 rotation index the account is at; `after_time` and `after_id` are the sec 6.1 fetch cursor; `sync_ok` (0/1) says whether the local accept-token set may replace the server's — a client restored from the master alone must not erase it (sec 4) | | `servers` | `host`, `static`, `pinned_at` | The sec 4 pins: a server's Noise static key, keyed by host without port | | `contacts` | `address`, `identity`, `verified`, `seen_at` | Every key known for an address; `verified` is 1 only when it came from a self-certifying URI | | `accepted` | `address`, `identity`, `active`, `added_at` | Main-tier admission (sec 5.8). `identity` is frozen at acceptance because the token the correspondent holds is derived from it; `active = 0` is a block and keeps the row | | `tokens` | `address`, `token`, `seen_at` | Accept tokens correspondents issued us, filed under their address, which outlives the keys behind it (sec 5.8) | | `seen` | `id`, `at` | Every message id ever fetched; a replay is not re-stored even after a local delete (sec 10) | | `inbox` | `id`, `envelope`, `received_at`, `tier`, `kept` | Sealed mail; `tier` is 0 main, 1 requests (arrived without a matching accept token); `kept` records whether a server copy existed when the message was stored, so a local delete can offer to remove it there too — it is written once at first store and does not track later server-side deletes | | `history` | `address`, `identity`, `until` | Keys a contact used before its current one, with when each stopped being current (epoch ms): the local record that a rotation happened, written when a signed chain displaces the key held (sec 7, sec 8) | | `sent` | `id`, `recipient`, `envelope`, `sent_at` | Sealed self-copies (sec 5.6), with the recipient for the listing | An importer should set `user_version` to 2 (a v1 store is migrated on open instead), keep the single `state` row, and respect the frozen `accepted.identity` and `sync_ok` semantics above — those are the two rows where a wrong guess loses data (an erased server-side token set, or a contact's token silently changing). ## Modules | File | Contents | |---|---| | `core/src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks | | `core/src/address.rs` | parsing for all four address forms, username validation, fingerprints | | `core/src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates, `find_rotation_index` | | `core/src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) | | `core/src/transport.rs` | `Transport` trait, bind values, operation and status constants | | `core/src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning | | `core/src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `core/shim/`, path discovery, one link per session | | `core/src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline with cancellation; store-taking items behind the `store` feature | | `core/src/store.rs` | SQLite behind the `store` feature: state, contacts, accepted, tokens, seen ids, inbox, sent; `Send + Sync`, WAL, versioned schema | | `core/src/export.rs` | the gsmol backup format behind the `store` feature: sealed payload of mail, contacts and pins, interchangeable with gsmol exports | | `core/src/error.rs` | the `Error` enum embedders match on, the status-code mapping | | `core/vectors.json` | the committed reference vectors, the tests' source of truth | | `cli/src/main.rs` | the command line: keyfile and `--db` conventions, hints, output | Unit tests pin every derivation against `vectors.json`, whose values were generated from `smolmail.py`, including a full envelope reproduced byte for byte. ## References - [SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md) — protocol 1.1 - [RNS.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md) — the 1.2 Reticulum addendum - [bunshin](https://code.randogoth.com/randogoth/bunshin) — the server