| cli | ||
| core | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| PLAN.md | ||
| README.md | ||
| THIRD_PARTY_NOTICES.md | ||
文 fumi
A Rust client for Smol Mail: 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, 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] <command>
# identity
fumi keygen [--force] create a master secret
fumi whoami address, public key, fingerprint, rotation index
fumi restore <address> recover local state from the master alone
fumi rotate advance the rotation index, sign and push the certificate
# servers and contacts
fumi trust <host> <key-b32> [--force] pin a server's Noise static key
fumi register <address> [--invite TOKEN] bind this identity to a username
fumi resolve <address> look up a contact's key, walk the chain, pin it
fumi import <smol-uri> add a contact from a self-certifying address
fumi contacts known keys and how each was learned
# accept tokens
fumi accept <address> issue a token, admit to the main tier, sync the set
fumi block <address> withdraw it, sync the set
# mail
fumi send <address> [--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 <id>... 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 <FILE> restore one; merges, never overwrites a trust binding
fumi read <id> [--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 <host:port> --rns-udp-forward <host:port> [--rns-storage DIR] <command>
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 andStore::open(path)takes only mail and state;identity.keynext tofumi.dbis 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 = falsedrops rusqlite entirely: identities, addresses, seal/open and the raw transport operations remain, for hosts with their own database.storeandbundled-sqlitecompose with it as separate features. - One shareable store handle.
StoreisSend + Sync: every operation locks an inner mutex, and the database runs in WAL mode, so a host holds oneStorebehind 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::Erroris an enum:NotPinned,PinMismatch,KeyChanged { address, known, offered },NotRegistered,AuthFailed,RateLimited,QuotaExceeded,SchemaVersion, ... — the distinctions a UI routes on.Displaystill produces the message the CLI prints. - Long operations can stop.
fetch_withtakes aFetchOptionswith 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.
restoreremains the one-call convenience, but its pieces are public:connect+resolve,account::find_rotation_index, then theStoresetters — a wizard can hold the master and the address before it has connectivity, and show each step. Trust outcomes arrive as values:TrustChangefromtrust_keyandsend,Session::unpinned_staticfor 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.jsonholds 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.