fumi/PLAN.md
2026-09-28 16:37:22 +03:00

27 KiB
Raw Permalink Blame History

文 fumi

A Rust implementation of the Smol Mail 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, 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 throughout; §13 onwards is 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

[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 <address> 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] <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 (TCP only)
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]
fumi read <id> [--sent]

# RNS carrier (feature "rns"); addresses select it by scheme
fumi --rns-udp <host:port> --rns-udp-forward <host:port> [--rns-storage DIR] <command>

trust takes a host rather than a full address because a pin is per server, not per user. register <address> binds the username the address already carries, so there is no separate --username. Without the rns feature, a smol+rns:// address fails with a message naming the feature rather than a parse error.

Key differences from bunshin

Aspect bunshin (server) fumi (client)
Noise role responder, holds the static key initiator, has no static key (NX)
Session accepts connections initiates, one per operation batch
Trust publishes its static key pins it and aborts on mismatch
Signatures verifies AUTH and REGISTER produces them over the bind values
Envelopes stores them sealed, never opens one seals and opens; needs the AEAD directly
Rate limits enforces respects, and never retries automatically
RNS role IN/SINGLE destination, announces, handles requests path discovery, OUT/SINGLE, opens links, sends requests

Phase 1: TCP client

# Module Notes
1 crypto.rs base32, HKDF, HMAC, the X25519 map and §2's checks
2 address.rs all four address forms, username grammar
3 account.rs master, seed_n, accept key, tokens, rotation certificates
4 message.rs seal, open, message id, frontmatter
5 transport.rs trait, TransportBindValues::tcp, constants
6 tcp.rs Noise_NX initiator, framing, pinning
7 store.rs schema above
8 client.rs six operations, chain walking, token sync, fetch pipeline
9 main.rs CLI

Modules 1–4 are pure and fully unit-testable against the reference client's vectors before a socket is opened.

Phase 2: RNS carrier

Built the way bunshin's RNS carrier was, and for the same reasons: microReticulum (C++17, Apache-2.0, on upstream's community-implementations list) behind a thin C ABI shim, pinned at the commit bunshin uses — 40fa628, 2026-07-20. The Python-sidecar and native-Rust options are not chosen: the first doubles the session rules across two implementations, the second has no listed-upstream candidate. Revisit only if a Rust stack gets listed.

No libffi: it builds calls at runtime, which is not what linking a C++ library needs. build.rs compiles the shim with cc and the library with cmake, exactly as bunshin does.

# Work Notes
1 shim/ the C ABI below, plus udp_interface.{h,cpp} from bunshin
2 build.rs cmake over the vendored checkout, then cc for the shim
3 rns/ffi.rs extern "C" declarations and the safe wrapper
4 rns/transport.rs impl Transport, TransportBindValues::rns, timeouts, teardown
5 address.rs, main.rs smol+rns:// dispatch and the --rns-* flags
6 flake.nix pin microReticulum and every FetchContent dependency
7 tests bind vectors, then end-to-end against smolmaild_rns.py

transport.rs itself does not change: the trait already carries the bind values, which is the only thing the two carriers disagree about.

Client-side surface

microReticulum's client API is present and mirrors the Python reference's flow:

Need API Header
Path discovery Transport::has_path, Transport::request_path Transport.h
Server identity Identity::recall(destination_hash) Identity.h
Destination Destination(identity, OUT, SINGLE, "smolmail", "server") Destination.h
Link Link(destination, established_cb, closed_cb), Link::link_id(), teardown() Link.h
Request Link::request(path, data, response_cb, failed_cb, progress_cb, timeout) Link.h:194
Response RequestReceipt::get_response(), get_status() Link.h:96

Every callback is a bare function pointer with no userdata, the same constraint bunshin hit. A CLI has one request in flight at a time, so the shim keeps a single response slot plus a condvar rather than a map.

Shim ABI

/* All calls block the caller; the shim owns the Reticulum loop thread and
   every callback fires there, as in bunshin's shim. */
int smolmail_rns_start(const char *storage_dir,
                       const char *udp_listen_host, uint16_t udp_listen_port,
                       const char *udp_forward_host, uint16_t udp_forward_port);

/* Requests a path if none is known and waits for it, recalls the identity,
   builds the OUT/SINGLE destination, opens the link and waits for ACTIVE.
   Returns the 16-byte link id, which the bind values are derived from. */
int smolmail_rns_connect(const uint8_t *destination_hash, uint32_t timeout_ms,
                         uint8_t *link_id_out);

/* request is `op u8 || body`, response is `status u8 || payload`. */
int smolmail_rns_request(const uint8_t *request, size_t request_len,
                         uint32_t timeout_ms, uint8_t *out, size_t cap, size_t *out_len);

void smolmail_rns_close(void);

No client Reticulum identity is created or loaded, and the shim never calls Link::identify: a server MUST NOT require identification (§13.8), and a durable client handle is exactly what the wire format is built to withhold.

Client requirements specific to this carrier

  • Request a path and wait for it before constructing the link. Creating one first makes RNS assume the maximum hop count and fail after minutes rather than promptly (§13.3). Identity::recall can still return nothing immediately after a path appears; that is a retry, not an error.
  • Set an explicit request timeout. Reticulum's default is derived from the round-trip time and covers a packet, not a FETCH page (§13.5). Default 30 s, as the reference client.
  • One fresh link per session, never reused across authentications — link_id is what stops an AUTH replaying on another link (§13.6).
  • No path, a dead link, a rejected resource and a timeout are local errors. They MUST NOT be reported as status codes (§13.5).
  • Tear the link down when done rather than leave it on keepalives (§13.10).
  • Remember a size cap learned from status 6 or 7 instead of discovering it twice (§13.7). Servers run ~32 KiB envelope and fetch budgets on mesh.
  • Retrying a SEND of unknown outcome is safe and expected here: ids are derived and a resend returns the same id with no new message (§10, §13.10).

Bind values

TransportBindValues is lifted from bunshin/src/bind.rs unchanged, including its test vectors — the client produces the signatures the server verifies, so sharing the construction is the point.

h             = SHA-256("smolmail/1 bind" || destination || link_id)   # replaces the Noise handshake hash
server_static = SHA-256("smolmail/1 bind" || destination)              # replaces the Noise static key

Build and interfaces

build.rs copies the microReticulum checkout from MICRORETICULUM_SOURCE_DIR into OUT_DIR, applies the one-line #include <cstdint> patch upstream master needs under current libstdc++, configures cmake with every RNS_<DEP>_SOURCE_DIR override the flake supplies, then compiles the shim with cc and links both archives. The flake vendors each FetchContent dependency as a pinned flake = false input so the sandboxed build never fetches.

microReticulum ships no interfaces, only examples, so shim/udp_interface.{h,cpp} comes over from bunshin verbatim (Apache-2.0). One asymmetry: bunshin can answer to the source of the last datagram it received, but a client speaks first, so --rns-udp-forward is effectively required and should point at an rnsd or at the server's UDP interface.

Two upstream divergences bunshin already found and that apply here too: Curve25519::eval does not clamp the scalar, so a destination hash must never be re-derived in Rust — but a client only ever consumes a hash from an address, so this stays a non-issue as long as nothing tries to verify one locally. And microReticulum splices request and response payloads into msgpack envelopes verbatim; bunshin's shim packs and unpacks on the server side, and the client shim should expect the same in both directions. Verify against smolmaild_rns.py before trusting the symmetry.


Testing

Unit

Address parsing and rejection (52-char base32, 32-char hex, port rejection on smol+rns://, bad usernames); identity derivation and rotation against vectors from smolmail.py; seal/open round trip; unseal of an envelope produced by the reference client, and the reverse; frontmatter parser against §5.5's rules including the malformed-block fallback; token and MAC derivation; TransportBindValues against bunshin's vectors; chain walking, including the broken, over-long and absent cases.

Integration

Against a local bunshin: register, resolve, send, fetch, delete, accept-token sync with both sync values, key rotation followed by a fetch of mail addressed to the superseded key, quota and rate-limit responses, and an unknown status code.

Interoperability

Both directions against smolmail.py/smolmaild.py on TCP and smolmail_rns.py/smolmaild_rns.py on RNS, plus the cross-transport case §15 promises: send over TCP, fetch over RNS, one identity and one store.


Project structure

fumi/
├── Cargo.toml
├── build.rs                      # rns feature only
├── flake.nix                     # vendors microReticulum and its deps
├── shim/
│   ├── smolmail_rns.{h,cpp}      # C ABI over microReticulum, client side
│   └── udp_interface.{h,cpp}     # from bunshin, Apache-2.0
├── src/
│   ├── main.rs  lib.rs  error.rs
│   ├── client.rs  account.rs  address.rs  message.rs  store.rs  crypto.rs
│   ├── transport.rs  tcp.rs
│   └── rns/  mod.rs  ffi.rs  transport.rs
├── tests/
└── README.md

References