Fumi 文 - A Rust client for Smol Mail: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. http://smol.place
Find a file
randogoth 16dafcd388 fix: deliver an accept token once, not on every message
send attached the token to every message addressed to an accepted
correspondent, so a secret rode in the body of ordinary mail indefinitely.
sec 5.8 says the owner delivers each token to its correspondent; it does not
say every message carries one.

Harmless between people, and fatal for any recipient that republishes what it
receives: a published record is verified by rebuilding the exact bytes the
author signed, and those cannot include a token without disclosing it. An
author who accepted such a service could never post to it again — and
accepting a correspondent is the behaviour the rest of the system teaches.

Schema 3 adds `delivered`, set only once the recipient's server has taken the
message, so a failed send leaves the token still owing. Not cleared by block:
the token is a pure function of the master and the identity frozen at
acceptance, so they keep the value either way and re-accepting hands back the
same one.

Existing rows migrate as still owing rather than delivered. One more message
carries the token, which is what every message did before, so nothing that
works today can break; assuming delivery would instead strand anyone accepted
but never written to.
2026-10-14 22:32:21 +03:00
cache fix: join the RNS loop thread before exit and keep the response slot per-request 2026-09-29 10:20:41 +03:00
cli feat: SRV discovery (_smolmail._tcp) and port-scoped trust syntax in the CLI 2026-09-30 13:58:37 +03:00
core fix: deliver an accept token once, not on every message 2026-10-14 22:32:21 +03:00
hashlist_store fix: join the RNS loop thread before exit and keep the response slot per-request 2026-09-29 10:20:41 +03:00
known_store fix: join the RNS loop thread before exit and keep the response slot per-request 2026-09-29 10:20:41 +03:00
path_store fix: join the RNS loop thread before exit and keep the response slot per-request 2026-09-29 10:20:41 +03:00
.gitignore
AGENTS.md
AI-DECLARATION.md docs: add AI-DECLARATION.md and update README badge to copilot 2026-09-29 20:29:37 +03:00
Cargo.lock feat: SRV discovery (_smolmail._tcp) and port-scoped trust syntax in the CLI 2026-09-30 13:58:37 +03:00
Cargo.toml refactor: split the library into fumi-core and make it embeddable 2026-09-28 23:21:08 +03:00
flake.lock
flake.nix
LICENSE
PLAN.md
README.md feat: SRV discovery (_smolmail._tcp) and port-scoped trust syntax in the CLI 2026-09-30 13:58:37 +03:00
THIRD_PARTY_NOTICES.md refactor: split the library into fumi-core and make it embeddable 2026-09-28 23:21:08 +03:00
transport_identity fix: join the RNS loop thread before exit and keep the response slot per-request 2026-09-29 10:20:41 +03:00

文 fumi

License: Apache-2.0 AI-DECLARATION: copilot Nix Flake

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

When a TCP address carries no port, the CLI discovers one by DNS SRV before dialing: _smolmail._tcp.<host> states the target and port serving that domain's mail (lowest priority wins, weight ignored), the discovered target is only a dial hint, and the domain keeps every identity role. No record, a failing lookup, or an explicit port means the default 1961 — the feature is purely additive, and old deployments never notice it existed. Discovery runs when an address is given; a stored account is dialed at the port it was registered with.

Server pins are scoped by port: trust example.org pins the default port, and trust example.org:1962 pins any other — one host may serve several domains, each with its own key. The fallback is asymmetric by design: the default port inherits the bare-host pin existing stores already hold, and a non-default port never falls back to it — the first dial at a new port is trust on first use, like any first contact, rather than a mismatch against its sibling's key.

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[:port]> <key-b32> [--force] pin a server's Noise static key, scoped by port
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 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 on the default port and by host:port on any other — the same label the trust errors name
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