feat: implement Phase 1 TCP client

This commit is contained in:
randogoth 2026-09-28 16:03:53 +03:00
parent 1690aed3dc
commit 37df976fde
19 changed files with 5631 additions and 377 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
/target
*.db
*.db-wal
*.db-shm
identity.key
*.key
result
result-*

51
AGENTS.md Normal file
View file

@ -0,0 +1,51 @@
# General Working Rules
## Coding
- **Simple and idiomatic.** Write readable, conventional code. Prefer composition, immutability, explicit dependencies, and a single source of truth.
- **Small and focused.** Keep functions and modules single-purpose. Separate concerns and avoid unnecessary nesting, abstraction, and global mutable state.
- **Reuse existing solutions.** Follow project conventions and prefer existing tools and dependencies. Introduce new ones only when justified.
- **Minimal changes.** Implement only what is requested. Prefer deletion over rewriting, and avoid unrelated refactoring, formatting, or speculative features.
## Comments
- **Self-contained.** Explain essential context inline without requiring external documents or conversations. References to nearby source code are acceptable.
- **Concise.** Usually one sentence. Put longer explanations in brief module headers or documentation.
- **Why, not what.** Comment only on non-obvious decisions, constraints, invariants, and surprising behavior. Do not restate obvious code.
- **No unnecessary history.** Include implementation history only when essential to understanding current behavior. Remove obsolete comments.
## Documentation
- Keep documentation concise, accurate, and synchronized with the implementation.
- Distinguish implemented behavior from proposals and historical plans. Remove outdated information and shorten resolved findings.
- Update specifications alongside changes to interfaces, formats, and contracts. Record important decisions where they are enforced.
- **Natural reflow.** Write paragraphs as continuous lines without hard wrapping. Use blank lines between paragraphs and explicit line breaks only where structurally necessary.
## Working Style
- **Inspect first.** Examine existing code, configuration, architecture, and conventions rather than assuming how things work.
- **Respect scope.** Follow the latest explicit instructions. Do not make unrelated changes or resume superseded tasks.
- **Work silently.** No progress narration or request recaps. Interrupt only for genuine blockers or discoveries that materially change the approach.
- **Resolve uncertainty.** Investigate missing information before asking. Never invent requirements or assume unverified behavior.
- **Respect user decisions.** Identify significant design problems and propose concrete alternatives with trade-offs, but follow the user's choice.
- **Respect authorization.** Observe approval requirements for changes, tests, commits, deployments, and destructive operations.
- **Commit messages.** Use concise, single-line, tag-prefixed messages (e.g., fix:, feat:, refactor:, docs:). Describe what changed using the imperative mood.
- **No credit notes.** Omit attribution, co-author trailers, AI-generated notices, and other credit statements from commits and generated documentation.
## Verification
- Test affected behavior using existing project tools. Add regression tests where appropriate.
- Preserve safeguards for security, external input, data integrity, and concurrency.
- Measure consequential assumptions before making architectural decisions.
- Perform repository-wide cleanup or extensive refactoring only when explicitly requested.
- Format and validate affected code without altering unrelated files.
- Report what passed, failed, or could not be verified. Never claim untested behavior works.
## Completion
Summarize changes, verification results, important decisions, and unresolved issues in a few sentences. Avoid lengthy reports unless requested.
## Project Preferences
- use `git` colocated `jj` for version management
- use `nix flake` to manage dependencies and scripting

1027
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

33
Cargo.toml Normal file
View file

@ -0,0 +1,33 @@
[package]
name = "fumi"
version = "0.1.0"
edition = "2021"
description = "Smol Mail client"
[[bin]]
name = "fumi"
path = "src/main.rs"
[features]
# RNS carrier over microReticulum (RNS.md); needs MICRORETICULUM_SOURCE_DIR
# at build time, provided by the flake.
rns = ["dep:cc"]
[dependencies]
snow = "0.9"
chacha20poly1305 = "0.10"
ed25519-dalek = { version = "2", features = ["rand_core"] }
x25519-dalek = { version = "2", features = ["static_secrets"] }
sha2 = "0.10"
hmac = "0.12"
hkdf = "0.12"
rand_core = { version = "0.6", features = ["getrandom"] }
rusqlite = { version = "0.32", features = ["bundled"] }
data-encoding = "2"
clap = { version = "4", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
anyhow = "1"
[build-dependencies]
cc = { version = "1", optional = true }

408
PLAN.md Normal file
View file

@ -0,0 +1,408 @@
# 文 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 <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](https://code.randogoth.com/randogoth/bunshin/src/branch/main/RNS.md) was, and for the same reasons: [microReticulum](https://github.com/attermann/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
```c
/* 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
- [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 addendum
- [smolmail.py](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail.py), [smolmail_rns.py](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail_rns.py) — reference clients
- [bunshin](https://code.randogoth.com/randogoth/bunshin) and its [RNS.md](https://code.randogoth.com/randogoth/bunshin/src/branch/main/RNS.md) — the server, and the carrier this plan follows
- [microReticulum](https://github.com/attermann/microReticulum) — C++ Reticulum, pinned at `40fa628`

435
README.md
View file

@ -1,399 +1,80 @@
# Fumi - Smol Mail Client (Rust)
# fumi
A Rust implementation of the Smol Mail client protocol, compatible with version 1.2 (including RNS transport). Fumi is the client counterpart to [bunshin](../bunshin), the Smol Mail server implementation.
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.
## Overview
## Status
Smol Mail is a minimalist, end-to-end encrypted mail protocol. This client implementation supports:
Phase 1, the TCP carrier (SPEC.md 1.1), is implemented and interoperates in both directions with the reference client `smolmail.py` and the reference server `smolmaild.py`, and with `bunshin`. The Reticulum carrier of RNS.md (1.2) is not built yet: `smol+rns://` addresses parse and fail with a message naming the `rns` feature, and the flake does not yet vendor microReticulum.
- **Full protocol v1.1**: All operations over TCP with Noise_NX_25519_ChaChaPoly_SHA256
- **Full protocol v1.2**: Reticulum Network Stack (RNS) transport for mesh networking
- **Shared codebase**: Single binary supports both transports (via feature flags)
- **Interoperability**: Works with reference Python implementations and bunshin server
## Protocol Compatibility
| Feature | v1.1 (TCP) | v1.2 (RNS) |
|---------|------------|------------|
| AUTH | ✅ | ✅ |
| RESOLVE | ✅ | ✅ |
| SEND | ✅ | ✅ |
| FETCH | ✅ | ✅ |
| DELETE | ✅ | ✅ |
| REGISTER | ✅ | ✅ |
| Accept Tokens | ✅ | ✅ |
| Key Rotation | ✅ | ✅ |
| Message Encryption | ✅ | ✅ |
## Address Formats
### TCP Transport (v1.1)
```
smol://alice@example.com[:1961]
smol://alice@example.com/abcdefghijklmnopqrstuvwxyz234567 # self-certifying
```
### RNS Transport (v1.2)
```
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a
smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a/abcdefghijklmnopqrstuvwxyz234567
```
The RNS address uses the server's 32-character hex destination hash as the host.
---
## Architecture
## Addressing
```
┌─────────────────────────────────────────────────────────────┐
│ fumi client │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ CLI (main.rs) │ │
│ │ keygen, import, trust, register, send, │ │
│ │ fetch, delete, resolve, show │ │
│ └──────────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼──────────────────────────┐ │
│ │ Client Core (client.rs) │ │
│ │ - Session management with servers │ │
│ │ - Message composition and parsing │ │
│ │ - Envelope sealing/unsealing │ │
│ └──────────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼──────────────────────────┐ │
│ │ Account (account.rs) │ │
│ │ - Master secret management │ │
│ │ - Identity derivation (rotation chains) │ │
│ │ - Token generation │ │
│ └──────────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │ │
│ │ TCP Transport │ │ RNS Transport │ │ │
│ │ (tcp_transport) │ │ (rns_transport + FFI) │ │ │
│ └────────┬────────┘ └──────────┬───────────────────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Transport Trait (transport.rs) │ │
│ │ - connect(address) : Establish session │ │
│ │ - request(op, body) : Send frame, get response │ │
│ │ - bind_values() : Get auth binding values │ │
│ │ - close() : Clean up connection │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Store (store.rs) │ │
│ │ - SQLite local mailbox │ │
│ │ - Accounts, identities, tokens, messages │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
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
## Implementation Plan
### Phase 1: Core Client (TCP Only)
#### Dependencies
```toml
[package]
name = "fumi"
version = "0.1.0"
edition = "2021"
description = "Smol Mail client"
[dependencies]
snow = "0.9" # Noise protocol
ed25519-dalek = "2" # Ed25519 signatures
x25519-dalek = "2" # X25519 key agreement
sha2 = "0.10" # SHA-256
rand_core = "0.6" # Randomness
hmac = "0.12" # HMAC-SHA256
clap = "4" # CLI parsing
log = "0.4" # Logging
env_logger = "0.11" # Logger setup
anyhow = "1" # Error handling
rusqlite = "0.32" # SQLite local store
hex = "0.4" # Hex encoding
data-encoding = "2" # Base32 encoding
```
#### Modules
1. **crypto.rs** - Cryptographic primitives
- Base32 encoding/decoding
- HKDF-SHA256
- HMAC-SHA256
- Identity derivation
- Accept token generation
- Message ID computation
2. **address.rs** - Address parsing
- Parse TCP addresses: `smol://user@host[:port][/identity]`
- Parse RNS addresses: `smol+rns://user@desthash[/identity]`
- Validate usernames
- Generate self-certifying URIs
3. **account.rs** - Account management
- Master secret storage
- Identity key derivation with rotation
- Current identity tracking
- Token generation for correspondents
4. **transport.rs** - Transport trait
- Define `Transport` trait with connect/request/close
- Define `TransportBindValues` for auth bindings
- Define operation and status constants
5. **tcp_transport.rs** - TCP transport
- Noise_NX handshake
- Frame reading/writing
- Server key pinning (optional)
6. **client.rs** - Core client logic
- Session management
- REGISTER operation
- AUTH operation
- RESOLVE operation
- SEND operation (envelope creation)
- FETCH operation
- DELETE operation
- Message sealing/unsealing
7. **store.rs** - Local SQLite store
- Account storage
- Trusted server keys
- Accept tokens
- Outbound message queue
- Inbound message storage
8. **main.rs** - CLI
- keygen: Generate new identity
- import: Import existing identity
- accounts: List accounts
- trust: Trust server key (TCP)
- register: Register username
- send: Send message
- fetch: Fetch messages
- delete: Delete messages
- resolve: Resolve username
- show: Display message info
---
### Phase 2: RNS Transport
Same approach as bunshin's RNS integration:
1. **FFI with microReticulum** (recommended for production)
- Use C++ microReticulum via FFI bindings
- Official implementation, full compliance
- Complex but most reliable
2. **Python IPC** (quick validation)
- Call `smolmail_rns.py` as subprocess
- Share data via files or pipes
- Fast to implement
3. **Native Rust** (future)
- Wait for mature Rust RNS crate
- Or implement minimal subset
---
## Key Differences from Server (bunshin)
| Aspect | bunshin (server) | fumi (client) |
|--------|------------------|---------------|
| Noise role | Responder (has static key) | Initiator (no static key) |
| Session | Accepts connections | Initiates connections |
| Trust | Server key pinned by client | Client pins server key |
| Auth | Verifies client signatures | Signs requests |
| Rate limiting | Enforces limits | Respects limits |
---
## CLI Commands
```bash
# Identity management
fumi keygen # Generate new identity
fumi keygen --name alice # Generate with name
fumi import --name alice -m ABCD... # Import master secret
fumi accounts # List accounts
# Server trust (TCP only)
fumi trust smol://alice@example.com ABCD... # Trust server key
# Account registration
fumi register smol://alice@example.com --username alice
fumi register smol://alice@example.com --username alice --invite TOK...
# Messaging
fumi send smol://bob@example.com --subject "Hello" --body "World"
fumi send smol://bob@example.com --file message.txt
fumi fetch smol://alice@example.com
fumi delete smol://alice@example.com --ids ABCD...
fumi resolve smol://alice@example.com --username bob
fumi show ABCD... # Show message details
```
---
## Project Structure
One master per store, selected by the global `--key` and `--db` flags; a second identity is a second pair of files.
```
fumi/
├── Cargo.toml # Project configuration
├── Cargo.lock
├── src/
│ ├── main.rs # CLI entry point
│ ├── lib.rs # Library exports
│ ├── client.rs # Core client logic
│ ├── account.rs # Account/identity management
│ ├── address.rs # Address parsing
│ ├── store.rs # Local SQLite store
│ ├── crypto.rs # Cryptographic primitives
│ ├── transport.rs # Transport trait
│ ├── tcp_transport.rs # TCP transport implementation
│ └── error.rs # Error types
├── tests/
│ └── integration/ # Integration tests
├── flake.nix # Nix flake (optional)
└── README.md # This file
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]
fumi read <id> [--sent]
```
---
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.
## Implementation Priority
## Build
### Phase 1: TCP Client (High Priority)
```
nix build # or: nix develop -c cargo build
nix develop -c cargo test
```
| # | Module | Description | Dependencies |
|---|--------|-------------|--------------|
| 1 | crypto.rs | Base32, HKDF, HMAC, identity derivation | sha2, hmac, data-encoding |
| 2 | address.rs | Address parsing and validation | regex, anyhow |
| 3 | account.rs | Account and identity management | ed25519-dalek, rand_core |
| 4 | transport.rs | Transport trait and types | None |
| 5 | tcp_transport.rs | TCP/Noise transport | snow, std::net |
| 6 | client.rs | Core operations (REGISTER, AUTH, etc.) | All above |
| 7 | store.rs | SQLite local storage | rusqlite |
| 8 | main.rs | CLI | clap, all above |
| 9 | Tests | Unit and integration tests | All above |
`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.
### Phase 2: RNS Support (Medium Priority)
## Modules
| # | Module | Description | Dependencies |
|---|--------|-------------|--------------|
| 1 | rns/ffi.rs | FFI bindings to microReticulum | libffi |
| 2 | rns/transport.rs | RNS transport implementation | ffi.rs |
| 3 | rns/mod.rs | RNS module exports | transport.rs |
| 4 | Update transport.rs | Add RNS feature flag | None |
| 5 | Update client.rs | Handle RNS-specific logic | rns module |
| 6 | Update main.rs | Add RNS CLI commands | rns module |
| 7 | Tests | RNS integration tests | All above |
| File | Contents |
|---|---|
| `src/crypto.rs` | base32, HKDF, HMAC, SHA-256, the Ed25519→X25519 map with SPEC.md §2's checks |
| `src/address.rs` | parsing for all four address forms, username validation, fingerprints |
| `src/account.rs` | master, `seed_n` derivation, accept-key and tokens, rotation certificates |
| `src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) |
| `src/transport.rs` | `Transport` trait, bind values, operation and status constants |
| `src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning |
| `src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline |
| `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent |
---
## Testing Strategy
### Unit Tests
- Address parsing (TCP and RNS)
- Identity generation and rotation
- Message sealing and unsealing
- Envelope format validation
- Token generation and verification
### Integration Tests
- Register with bunshin server (TCP)
- Send and receive messages (TCP)
- Key rotation interoperability
- Accept token usage
- Error response handling
### Interoperability Tests
- Against `smolmail.py` reference client
- Against `smolmaild.py` reference server
- Against `smolmail_rns.py` reference client
- Against `smolmaild_rns.py` reference server
---
## Compatibility Matrix
| Client \ Server | bunshin (TCP) | bunshin (RNS) | smolmaild.py | smolmaild_rns.py |
|------------------|---------------|---------------|---------------|------------------|
| fumi (TCP) | ✅ Full | ❌ No | ✅ Full | ❌ No |
| fumi (RNS) | ❌ No | ✅ Full | ❌ No | ✅ Full |
Both transports share the same account and store, so a user can use both transports with the same identity.
---
## Key Implementation Notes
### Message Sealing (SPEC.md S5)
1. Generate ephemeral X25519 keypair
2. Convert recipient Ed25519 public key to X25519 (per SPEC.md S2)
3. Perform X25519 key agreement
4. Derive two 32-byte keys using HKDF-SHA256:
- `k1 = HKDF(shared, "", "smolmail/1 msg", 64)[0:32]`
- `k2 = HKDF(shared, "", "smolmail/1 msg", 64)[32:64]`
5. Build payload with frontmatter (unencrypted): version, sender, timestamp
6. Build body with: version, timestamp, body_length, body (padded to 1024-byte boundary)
7. Encrypt body with ChaCha20-Poly1305 using k1
8. Build envelope: magic ("SMOL"), version, recipient, ephemeral_pub, payload
### Transport Differences
#### TCP (v1.1)
- Noise_NX handshake (client is initiator, server has static key)
- Frames: u32 length || u8 op || body (split across Noise messages)
- Binding values: Noise handshake hash and server static public key
- Server pinning: Client verifies server's Noise static key
#### RNS (v1.2)
- Reticulum link establishment
- Frames: u8 op || body (RNS delimits messages)
- Binding values: SHA-256("smolmail/1 bind" || dest_hash || link_id) and SHA-256("smolmail/1 bind" || dest_hash)
- Server pinning: Destination hash IS the server's identity (no separate pinning)
---
## Next Steps
1. **Implement Phase 1 (TCP Client)**
- Start with crypto, address, and account modules
- Then transport and tcp_transport
- Then client core operations
- Finally store and CLI
- Test against bunshin server
2. **Implement Phase 2 (RNS Support)**
- Research microReticulum C API
- Create FFI bindings
- Implement RNS transport
- Add tests with reference implementations
3. **Polish and Package**
- Documentation
- Nix flake integration (optional)
- Performance optimization
- Error handling improvements
---
Unit tests pin every derivation against vectors generated from `smolmail.py`, including a full envelope reproduced byte for byte.
## References
- [Smol Mail SPEC.md](https://code.randogoth.com/randogoth/smolmail/src/branch/main/SPEC.md)
- [Smol Mail RNS.md (v1.2)](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md)
- [smolmail.py - Reference TCP client](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail.py)
- [smolmail_rns.py - Reference RNS client](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail_rns.py)
- [bunshin - Rust server implementation](../bunshin)
- [microReticulum - C++ RNS implementation](https://github.com/attermann/microReticulum)
- [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

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1790578696,
"narHash": "sha256-ZoxIApko70jCdbH3l20HWXOBaT2HZd87orzd2yJ9dVE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7a0f122f5090cf4c2ade2a13a0e229d4e19ba71f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

37
flake.nix Normal file
View file

@ -0,0 +1,37 @@
{
description = "fumi - Smol Mail client (Rust)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
fumi = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
pname = "fumi";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
buildFeatures = pkgs.lib.optionals rns [ "rns" ];
# The rns feature vendors microReticulum behind the shim and needs
# MICRORETICULUM_SOURCE_DIR; it is added with the RNS carrier.
doCheck = !rns;
};
in
{
packages.default = fumi { };
packages.rns = fumi { rns = true; };
apps.default = flake-utils.lib.mkApp {
drv = fumi { };
name = "fumi";
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [ cargo rustc rustfmt clippy sqlite ];
};
});
}

307
src/account.rs Normal file
View file

@ -0,0 +1,307 @@
//! The master secret of SPEC.md sec 2 and everything derived from it: rotation
//! indices, accept tokens and rotation certificates.
use crate::crypto::{hkdf_sha256, hmac_sha256, KEY_LEN};
use crate::error::SmolError;
use crate::transport::{CERT_LEN, LABEL_ACCEPT, LABEL_IDENTITY, LABEL_ROTATE, MAX_CHAIN};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use x25519_dalek::StaticSecret;
/// An Ed25519 identity keypair plus the X25519 keypair derived from it (sec 2).
#[derive(Clone)]
pub struct Identity {
signing: SigningKey,
x: StaticSecret,
}
impl Identity {
pub fn from_seed(seed: [u8; KEY_LEN]) -> Identity {
let signing = SigningKey::from_bytes(&seed);
// SHA-512(seed)[0..32], clamped as libsodium's sk_to_curve25519
// does (SPEC.md sec 2).
let x = StaticSecret::from(crate::crypto::clamp_scalar(signing.to_scalar_bytes()));
Identity { signing, x }
}
pub fn pk(&self) -> [u8; KEY_LEN] {
*self.signing.verifying_key().as_bytes()
}
pub fn sign(&self, message: &[u8]) -> [u8; 64] {
self.signing.sign(message).to_bytes()
}
/// The X25519 half used for sealing agreement, never for signatures.
pub fn x_priv(&self) -> &StaticSecret {
&self.x
}
}
/// The Ed25519 seed at a rotation index (sec 2). HKDF is one-way, so a
/// compromised seed exposes neither the master nor any other index.
pub fn identity_seed(master: &[u8; KEY_LEN], index: u32) -> [u8; KEY_LEN] {
let info = [LABEL_IDENTITY, &index.to_be_bytes()[..]].concat();
hkdf_sha256(master, b"", &info, KEY_LEN).try_into().unwrap()
}
/// The accept key (sec 2), independent of the rotation index so accept tokens
/// survive every rotation.
pub fn accept_key(master: &[u8; KEY_LEN]) -> [u8; KEY_LEN] {
hkdf_sha256(master, b"", LABEL_ACCEPT, KEY_LEN)
.try_into()
.unwrap()
}
/// A master plus every identity up to the current rotation index. Superseded
/// signing keys stay derivable because mail addressed to them is readable
/// with nothing else (sec 7).
pub struct Account {
master: [u8; KEY_LEN],
index: u32,
keys: Vec<Identity>,
}
impl Account {
pub fn new(master: [u8; KEY_LEN], index: u32) -> Result<Account, SmolError> {
if index as usize > MAX_CHAIN {
return Err(SmolError::new(format!(
"rotation index {index} exceeds the chain limit of {MAX_CHAIN}"
)));
}
let keys = (0..=index)
.map(|n| Identity::from_seed(identity_seed(&master, n)))
.collect();
Ok(Account {
master,
index,
keys,
})
}
pub fn index(&self) -> u32 {
self.index
}
pub fn master(&self) -> &[u8; KEY_LEN] {
&self.master
}
/// The identity at the current rotation index.
pub fn me(&self) -> &Identity {
self.keys.last().expect("at least one key")
}
/// Every key this account has held, current first is not guaranteed; the
/// index order is 0..=index.
pub fn keys(&self) -> &[Identity] {
&self.keys
}
/// The accept token this account issues to one correspondent (sec 5.8).
pub fn token_for(&self, identity: &[u8; KEY_LEN]) -> [u8; 32] {
hmac_sha256(&accept_key(&self.master), identity)
}
}
/// A double-signed rotation certificate (sec 7): fixed-size at 200 bytes, with
/// the username covered but not carried, so it cannot be replayed against a
/// different username bound to the same key.
pub struct RotationCert {
pub old_pub: [u8; KEY_LEN],
pub new_pub: [u8; KEY_LEN],
pub when: [u8; 8],
sig_old: [u8; 64],
sig_new: [u8; 64],
}
impl RotationCert {
pub fn from_bytes(bytes: &[u8]) -> Result<RotationCert, SmolError> {
if bytes.len() != CERT_LEN {
return Err(SmolError::new(format!(
"rotation certificate is {} bytes, expected {CERT_LEN}",
bytes.len()
)));
}
Ok(RotationCert {
old_pub: bytes[..32].try_into().unwrap(),
new_pub: bytes[32..64].try_into().unwrap(),
when: bytes[64..72].try_into().unwrap(),
sig_old: bytes[72..136].try_into().unwrap(),
sig_new: bytes[136..200].try_into().unwrap(),
})
}
fn signed_message(&self, username: &str) -> Vec<u8> {
rotate_message(username, &self.old_pub, &self.new_pub, &self.when)
}
/// Both signatures must verify: the old key alone could otherwise hand a
/// username to a key nobody controls (sec 7).
pub fn verify(&self, username: &str) -> bool {
let message = self.signed_message(username);
verify_sig(&self.old_pub, &self.sig_old, &message)
&& verify_sig(&self.new_pub, &self.sig_new, &message)
}
/// Builds the certificate for a rotation from `old` to `new`.
pub fn build(old: &Identity, new: &Identity, username: &str, when: i64) -> [u8; CERT_LEN] {
let old_pub = old.pk();
let new_pub = new.pk();
let message = rotate_message(username, &old_pub, &new_pub, &when.to_be_bytes());
let mut cert = [0u8; CERT_LEN];
cert[..32].copy_from_slice(&old_pub);
cert[32..64].copy_from_slice(&new_pub);
cert[64..72].copy_from_slice(&when.to_be_bytes());
cert[72..136].copy_from_slice(&old.sign(&message));
cert[136..200].copy_from_slice(&new.sign(&message));
cert
}
}
/// The double-signed rotation message (sec 7): the username is covered but
/// not carried, keeping the certificate fixed-size and non-portable.
fn rotate_message(username: &str, old_pub: &[u8], new_pub: &[u8], when: &[u8; 8]) -> Vec<u8> {
let mut message = Vec::with_capacity(LABEL_ROTATE.len() + username.len() + 72);
message.extend_from_slice(LABEL_ROTATE);
message.extend_from_slice(username.as_bytes());
message.extend_from_slice(old_pub);
message.extend_from_slice(new_pub);
message.extend_from_slice(when);
message
}
/// Strict Ed25519 verification; malformed keys and signatures are simply not
/// valid.
pub fn verify_sig(identity: &[u8], signature: &[u8], message: &[u8]) -> bool {
let (Ok(identity), Ok(signature)) = (
<[u8; KEY_LEN]>::try_from(identity),
<[u8; 64]>::try_from(signature),
) else {
return false;
};
let Ok(verifying_key) = VerifyingKey::from_bytes(&identity) else {
return false;
};
verifying_key
.verify_strict(message, &Signature::from_bytes(&signature))
.is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{b32, unb32};
// Vectors generated from the reference client's own derivations over
// master = bytes(range(32)).
fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8)
}
fn seed_b32(n: u32) -> &'static str {
match n {
0 => "6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea",
1 => "ag6k4r74bnc3tt6y623my7wasslq4ulxcxm4anzvizdpg76yfxva",
2 => "r35p2a4e5ffhz6aq5urhv27ch22a34r6i6adnqfwwcnte5te5tua",
16 => "rf6tdr6doeaymope2uj66au542kcmvfnaqc3cmy5jkulacdbnvaq",
_ => unreachable!(),
}
}
fn pk_b32(n: u32) -> &'static str {
match n {
0 => "3jxsxqtdvxwobge7uxefpbordkulv6bc6ao3h4iusqlov4kgkkja",
1 => "zoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rta",
2 => "2ptpqdy2d4zklwr2xzizhiza7b7jsf6qr2rayzrr2mk2h73ifbrq",
16 => "r6yhcbo733vka7dlbknnlly7aqpoflej4lumeuxq7gxhzzm3juta",
_ => unreachable!(),
}
}
fn xpriv_b32(n: u32) -> &'static str {
match n {
0 => "ebppfk5yqmf7v5a4seh6f6rkm7z3pnoh7b6hiaa3vs7n5if6hj5a",
1 => "rbhdttkcycpfqfyfsy2mstbykw4t5pngd6dezskrytlore4j5jvq",
2 => "xb4jcmjyappxo7zibvbalaepmvy74jilom43fxvfaj7ev4bpxzqq",
16 => "ucdl3wcmwjlso6um6ipequ3qq6lkx47u4rnwwwy7jo76c2ktwjoq",
_ => unreachable!(),
}
}
#[test]
fn identity_derivation_matches_the_reference_client() {
for n in [0u32, 1, 2, 16] {
let seed = identity_seed(&master(), n);
assert_eq!(b32(&seed), seed_b32(n));
let identity = Identity::from_seed(seed);
assert_eq!(b32(&identity.pk()), pk_b32(n));
// The X25519 half must be libsodium's sk_to_curve25519 output.
assert_eq!(b32(identity.x_priv().as_bytes()), xpriv_b32(n));
}
}
#[test]
fn accept_key_and_token_match_the_reference_client() {
assert_eq!(
b32(&accept_key(&master())),
"fqmnb3vktmrth47m2qjzcey6ue7rbkrvsazytwdayjjgkvcqe2xa"
);
let account = Account::new(master(), 0).unwrap();
let pk1: [u8; 32] = unb32(pk_b32(1)).unwrap().try_into().unwrap();
assert_eq!(
b32(&account.token_for(&pk1)),
"bgrtewwtanbfmp5aoxq6rqckn4xcufr3tp4wa67mwsumyj7yjwia"
);
// Independent of the rotation index (sec 2).
let rotated = Account::new(master(), 3).unwrap();
assert_eq!(account.token_for(&pk1), rotated.token_for(&pk1));
}
#[test]
fn account_holds_every_superseded_key() {
let account = Account::new(master(), 2).unwrap();
assert_eq!(account.keys().len(), 3);
for (n, key) in account.keys().iter().enumerate() {
assert_eq!(b32(&key.pk()), pk_b32(n as u32));
}
assert_eq!(account.index(), 2);
}
#[test]
fn account_rejects_an_over_long_chain() {
assert!(Account::new(master(), 16).is_ok());
assert!(Account::new(master(), 17).is_err());
}
#[test]
fn rotation_certificates_match_the_reference_client() {
let old = Identity::from_seed(identity_seed(&master(), 0));
let new = Identity::from_seed(identity_seed(&master(), 1));
let cert = RotationCert::build(&old, &new, "alice", 1700000001);
// Vector produced by the reference client over the same inputs.
let expected = "da6f2bc263adece0989fa5c85785d11aa8baf822f01db3f1149416eaf1465292\
cb9046928dd2bede87ddc5ca42378fbd6f987e425b34a2801f74b768da39e466000000006553f101\
0c6171708ec40a9c68b456547a78fe0a512f8e45a0e4a9c094737ef4f84072a997a152e0f16e94bd\
bd93502521ba812de62fe4c6d19092f0c424bba69337390401b2995c670f1d5720188956b8a1b00d\
2ac9e5c1665e862e411c5ddeaea43c0721f450527b98f5dd3af29350a314514898a272bc3f78a0b9a\
db826b46733c605";
assert_eq!(data_encoding::HEXLOWER.encode(&cert), expected);
assert_eq!(cert.len(), CERT_LEN);
let parsed = RotationCert::from_bytes(&cert).unwrap();
assert!(parsed.verify("alice"));
// The username is covered (sec 7), so another username fails.
assert!(!parsed.verify("bob"));
// Both halves must sign: flipping a signature byte breaks it.
let mut broken = cert;
broken[72] ^= 1;
assert!(!RotationCert::from_bytes(&broken).unwrap().verify("alice"));
assert!(RotationCert::from_bytes(&cert[..199]).is_err());
}
#[test]
fn verify_sig_rejects_garbage() {
assert!(!verify_sig(&[0u8; 32], &[0u8; 64], b"msg"));
assert!(!verify_sig(&[0u8; 5], &[0u8; 64], b"msg"));
}
}

276
src/address.rs Normal file
View file

@ -0,0 +1,276 @@
//! The four address forms (SPEC.md sec 3, RNS.md sec 13.2) and the username
//! grammar they share.
use crate::crypto::{b32, unb32, KEY_LEN};
use crate::error::SmolError;
pub const DEFAULT_PORT: u16 = 1961;
/// A Reticulum destination hash, as printed by every RNS tool: 32 lowercase
/// hexadecimal characters (RNS.md sec 13.2).
pub const RNS_HASH_HEX: usize = 32;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Scheme {
Tcp,
Rns,
}
impl Scheme {
pub fn prefix(&self) -> &'static str {
match self {
Scheme::Tcp => "smol://",
Scheme::Rns => "smol+rns://",
}
}
}
/// `user@host[:port]` over TCP, or `user@<destination-hash>` over Reticulum,
/// either optionally self-certifying with a 52-character base32 identity key.
#[derive(Clone, Debug)]
pub struct Address {
pub user: String,
/// TCP hostname, or the destination hash as 32 lowercase hex characters.
pub host: String,
/// TCP only; there is no port on the RNS carrier, which stores 0 here.
pub port: u16,
pub scheme: Scheme,
pub identity: Option<[u8; KEY_LEN]>,
}
impl Address {
/// The form commands and the contact store are keyed by.
pub fn short(&self) -> String {
let port = match (self.scheme, self.port) {
(Scheme::Tcp, DEFAULT_PORT) | (Scheme::Rns, 0) => String::new(),
(Scheme::Tcp, p) => format!(":{p}"),
(Scheme::Rns, _) => unreachable!("no port is stored on the rns scheme"),
};
format!("{}@{}{}", self.user, self.host, port)
}
/// The self-certifying form used for QR codes, contact files and links.
pub fn uri(&self, identity: &[u8; KEY_LEN]) -> String {
format!("{}{}/{}", self.scheme.prefix(), self.short(), b32(identity))
}
/// The Reticulum destination hash, for the `rns` carrier only.
pub fn destination(&self) -> Result<[u8; 16], SmolError> {
if self.scheme != Scheme::Rns {
return Err(SmolError::new("not an smol+rns:// address"));
}
data_encoding::HEXLOWER
.decode(self.host.as_bytes())
.map_err(|_| SmolError::new("destination hash is not hexadecimal"))
.and_then(|bytes| {
bytes
.try_into()
.map_err(|_| SmolError::new("destination hash is not 16 bytes"))
})
}
pub fn parse(text: &str) -> Result<Address, SmolError> {
let text = text.trim();
let (scheme, rest) = if let Some(rest) = text.strip_prefix("smol+rns://") {
(Scheme::Rns, rest)
} else if let Some(rest) = text.strip_prefix("smol://") {
(Scheme::Tcp, rest)
} else {
(Scheme::Tcp, text)
};
// The path component is the 52-character base32 identity key. On TCP
// it is required; the RNS form also has a bare short form (RNS.md sec
// 13.2).
let (rest, identity) = match rest.rsplit_once('/') {
Some((head, key)) => (head, Some(decode_key(text, key)?)),
None if scheme == Scheme::Tcp && !text.starts_with("smol://") => (rest, None),
None if scheme == Scheme::Tcp => {
return Err(SmolError::new(format!(
"{text}: smol:// address carries no key"
)))
}
None => (rest, None),
};
let (user, host_part) = rest
.split_once('@')
.ok_or_else(|| SmolError::new(format!("{text:?} is not a valid address")))?;
if user.is_empty() || host_part.is_empty() {
return Err(SmolError::new(format!("{text:?} is not a valid address")));
}
valid_username(user)?;
Ok(match scheme {
Scheme::Tcp => {
let (host, port) = split_host_port(text, host_part)?;
Address {
user: user.to_string(),
host,
port,
scheme,
identity,
}
}
Scheme::Rns => {
// No port and no bare user@host form; the authority is the
// destination hash, compared in full (RNS.md sec 13.2).
if host_part.contains(':') {
return Err(SmolError::new(format!(
"{text}: the :port suffix must not appear on smol+rns://"
)));
}
if !is_destination_hash(host_part) {
return Err(SmolError::new(format!(
"{text}: host must be exactly {RNS_HASH_HEX} lowercase hexadecimal \
characters, a Reticulum destination hash"
)));
}
Address {
user: user.to_string(),
host: host_part.to_string(),
port: 0,
scheme,
identity,
}
}
})
}
}
fn decode_key(text: &str, key: &str) -> Result<[u8; KEY_LEN], SmolError> {
let identity =
unb32(key).map_err(|e| SmolError::new(format!("{text}: undecodable key: {e}")))?;
identity.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!(
"{text}: key is {} bytes, expected {KEY_LEN}",
v.len()
))
})
}
fn split_host_port(text: &str, host_part: &str) -> Result<(String, u16), SmolError> {
if let Some((host, port)) = host_part.rsplit_once(':') {
if !host.is_empty() && port.bytes().all(|c| c.is_ascii_digit()) && !port.is_empty() {
return port
.parse::<u16>()
.map(|p| (host.to_string(), p))
.map_err(|_| SmolError::new(format!("{text}: invalid port")));
}
}
if host_part.contains(':') || host_part.contains('/') {
return Err(SmolError::new(format!("{text:?} is not a valid address")));
}
Ok((host_part.to_string(), DEFAULT_PORT))
}
fn is_destination_hash(host: &str) -> bool {
host.len() == RNS_HASH_HEX
&& host
.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
/// SPEC.md sec 3: 1-63 bytes of `[a-z0-9._-]`, alphanumeric at both ends, and
/// never two separators in a row. Non-lowercase input is a parse error, as in
/// the reference client, which normalises before sending instead.
fn valid_username(name: &str) -> Result<(), SmolError> {
const SEPARATORS: &[u8] = b"._-";
let bytes = name.as_bytes();
let ok = !bytes.is_empty()
&& bytes.len() <= 63
&& bytes
.iter()
.all(|&c| c.is_ascii_digit() || c.is_ascii_lowercase() || SEPARATORS.contains(&c))
&& !SEPARATORS.contains(&bytes[0])
&& !SEPARATORS.contains(&bytes[bytes.len() - 1])
&& !bytes
.windows(2)
.any(|pair| SEPARATORS.contains(&pair[0]) && SEPARATORS.contains(&pair[1]));
if ok {
Ok(())
} else {
Err(SmolError::new(format!(
"{name:?} must be 1-63 bytes of [a-z0-9._-], begin and end with a letter or digit, \
and contain no two separators in a row"
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::b32;
#[test]
fn short_form_and_default_port() {
let a = Address::parse("alice@example.org").unwrap();
assert_eq!(a.user, "alice");
assert_eq!(a.host, "example.org");
assert_eq!(a.port, DEFAULT_PORT);
assert_eq!(a.short(), "alice@example.org");
assert!(a.identity.is_none());
let a = Address::parse("alice@example.org:1962").unwrap();
assert_eq!(a.port, 1962);
assert_eq!(a.short(), "alice@example.org:1962");
}
#[test]
fn self_certifying_form_carries_its_key() {
let key = [9u8; 32];
let a = Address::parse(&format!("smol://alice@example.org/{}", b32(&key))).unwrap();
assert_eq!(a.identity, Some(key));
assert_eq!(a.short(), "alice@example.org");
assert_eq!(
a.uri(&key),
format!("smol://alice@example.org/{}", b32(&key))
);
}
#[test]
fn rns_forms_and_port_rejection() {
let key = [9u8; 32];
let hash = "8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a";
let a = Address::parse(&format!("smol+rns://alice@{hash}")).unwrap();
assert_eq!(a.scheme, Scheme::Rns);
assert_eq!(a.destination().unwrap()[..4], [0x8f, 0x2c, 0x1d, 0x0a]);
assert_eq!(a.short(), format!("alice@{hash}"));
let b = Address::parse(&format!("smol+rns://alice@{hash}/{}", b32(&key))).unwrap();
assert_eq!(b.identity, Some(key));
assert_eq!(
b.uri(&key),
format!("smol+rns://alice@{hash}/{}", b32(&key))
);
// No port on this transport (RNS.md sec 13.2).
assert!(Address::parse(&format!("smol+rns://alice@{hash}:1961")).is_err());
// The authority must be exactly 32 lowercase hex characters.
assert!(Address::parse("smol+rns://alice@deadbeef").is_err());
assert!(Address::parse(&format!("smol+rns://alice@{}", hash.to_uppercase())).is_err());
}
#[test]
fn username_grammar() {
assert!(Address::parse("a.b_c-9@example.org").is_ok());
assert!(Address::parse("Alice@example.org").is_err());
assert!(Address::parse(".alice@example.org").is_err());
assert!(Address::parse("alice.@example.org").is_err());
assert!(Address::parse("al..ice@example.org").is_err());
assert!(Address::parse("a-_b@example.org").is_err());
assert!(Address::parse(&format!("{}@example.org", "a".repeat(64))).is_err());
assert!(Address::parse(&format!("{}@example.org", "a".repeat(63))).is_ok());
assert!(Address::parse("al ice@example.org").is_err());
assert!(Address::parse("@example.org").is_err());
assert!(Address::parse("alice@").is_err());
}
#[test]
fn bad_keys_are_rejected() {
assert!(Address::parse("smol://alice@example.org/notbase32!!").is_err());
assert!(Address::parse("smol://alice@example.org/nbswy3dp").is_err());
assert!(Address::parse("smol://example.org").is_err());
// A bare short form has no key and needs none.
assert!(Address::parse("alice@example.org").is_ok());
}
}

823
src/client.rs Normal file
View file

@ -0,0 +1,823 @@
//! The six operations, AUTH session setup, chain walking, token sync and the
//! send/fetch pipelines. Everything here is carrier-neutral: it speaks
//! through the `Transport` trait and the bodies are byte-identical on both
//! carriers (RNS.md sec 13.5, sec 15).
use std::collections::BTreeMap;
use crate::account::{Account, Identity, RotationCert};
use crate::address::{Address, Scheme};
use crate::crypto::{b32, ct_eq, hmac_sha256, KEY_LEN};
use crate::error::{expect_ok, SmolError};
use crate::message::{
accept_field, build_frontmatter, message_id, parse_frontmatter, seal, unseal, Opened,
};
use crate::store::{now, Store, Stored};
use crate::tcp::TcpTransport;
use crate::transport::{
Reader, Transport, CERT_LEN, FLAG_REQUESTS, ID_LEN, LABEL_AUTH, LABEL_MAC, LABEL_REGISTER,
MAX_CHAIN, OP_AUTH, OP_DELETE, OP_FETCH, OP_REGISTER, OP_RESOLVE, OP_SEND, TOKEN_LEN,
};
/// A connection plus what the caller needs to know about how it was made:
/// the server's static key when no pin was checked, for the sec 4 warning.
pub struct Session {
transport: Box<dyn Transport>,
pub unpinned_static: Option<[u8; KEY_LEN]>,
}
impl Session {
pub fn transport(&mut self) -> &mut dyn Transport {
self.transport.as_mut()
}
pub fn close(&mut self) {
self.transport.close();
}
}
/// Opens a session, enforcing sec 4's rule about unpinned servers. TCP pins
/// the server's Noise static key; on RNS the destination hash is the pin
/// (RNS.md sec 13.4).
pub fn connect(
store: &Store,
addr: &Address,
require_pin: bool,
timeout: u64,
) -> Result<Session, SmolError> {
match addr.scheme {
Scheme::Tcp => {
let pinned = store.server_pin(&addr.host)?;
if pinned.is_none() && require_pin {
return Err(SmolError::new(format!(
"no pinned key for {}.\nObtain it from the operator through a trusted \
channel, then:\n fumi trust {} <key>",
addr.host, addr.host
)));
}
let transport = TcpTransport::connect(&addr.host, addr.port, pinned, timeout)?;
let unpinned_static = if pinned.is_none() {
Some(*transport.server_static())
} else {
None
};
Ok(Session {
transport: Box::new(transport),
unpinned_static,
})
}
Scheme::Rns => Err(SmolError::new(
"smol+rns:// addresses need the rns feature; rebuild with --features rns",
)),
}
}
fn string_field(out: &mut Vec<u8>, text: &[u8]) -> Result<(), SmolError> {
if text.len() > 255 {
return Err(SmolError::new("field longer than one length byte"));
}
out.push(text.len() as u8);
out.extend_from_slice(text);
Ok(())
}
/// RESOLVE: the username's current key and its rotation chain (sec 6.1).
pub fn resolve(
transport: &mut dyn Transport,
username: &str,
) -> Result<([u8; KEY_LEN], Vec<[u8; CERT_LEN]>), SmolError> {
let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?;
let response = transport.request(OP_RESOLVE, &body)?;
expect_ok(response.status, &format!("resolving {username}"))?;
let mut r = Reader::new(&response.body);
let identity: [u8; KEY_LEN] = r.take(KEY_LEN)?.try_into().unwrap();
let chain_len = r.u8()? as usize;
let mut chain = Vec::with_capacity(chain_len);
for _ in 0..chain_len {
chain.push(r.take(CERT_LEN)?.try_into().unwrap());
}
r.done()?;
Ok((identity, chain))
}
/// Accepts a key change only when a signed chain leads from the key we hold
/// to the one the server now returns (sec 7). Both keys must sign each link;
/// a link predating the key we hold is skipped, a gap is not.
pub fn walk_chain(
username: &str,
pinned: &[u8; KEY_LEN],
current: &[u8; KEY_LEN],
chain: &[[u8; CERT_LEN]],
) -> bool {
if ct_eq(pinned, current) {
return true;
}
if chain.is_empty() || chain.len() > MAX_CHAIN {
return false;
}
let mut key: Vec<u8> = pinned.to_vec();
let mut started = false;
for cert_bytes in chain {
let cert = match RotationCert::from_bytes(cert_bytes) {
Ok(cert) => cert,
Err(_) => return false,
};
if !started {
if key != cert.old_pub {
continue; // a link predating the key we hold
}
started = true;
} else if key != cert.old_pub {
return false; // the chain is not continuous
}
if !cert.verify(username) {
return false;
}
key = cert.new_pub.to_vec();
}
started && ct_eq(&key, current)
}
/// How `trust_key` left the contact.
#[derive(PartialEq, Eq, Debug)]
pub enum TrustChange {
/// Trust on first use; `unverified` marks a session with no pin (sec 8).
New { unverified: bool },
/// A signed chain confirmed a key change (sec 7).
Rotated,
/// The key matches what we already hold.
None,
}
/// Resolves a contact and applies sec 8's trust rules. A key change without
/// a valid chain is an error here: the user confirms out of band and
/// imports, rather than clicking through.
pub fn trust_key(
store: &Store,
transport: &mut dyn Transport,
addr: &Address,
) -> Result<([u8; KEY_LEN], TrustChange), SmolError> {
let (identity, chain) = resolve(transport, &addr.user)?;
let known = store.contact(&addr.short())?;
match known {
None => {
store.save_contact(&addr.short(), &identity, false)?;
Ok((
identity,
TrustChange::New {
unverified: !transport.pinned(),
},
))
}
Some((old, _)) if ct_eq(&old, &identity) => Ok((identity, TrustChange::None)),
Some((old, verified)) => {
if walk_chain(&addr.user, &old, &identity, &chain) {
store.save_contact(&addr.short(), &identity, verified)?;
Ok((identity, TrustChange::Rotated))
} else {
Err(SmolError::new(format!(
"{} presents a different key with no valid rotation chain.\n known: {}\n \
offered: {}\nVerify out of band, then: fumi import {}",
addr.short(),
b32(&old),
b32(&identity),
addr.uri(&identity)
)))
}
}
}
}
/// AUTH (sec 4): sign the handshake hash and push the accept-token set. The
/// trailing block carries the mailbox's tokens (sec 5.8): `sync = 0` leaves
/// the stored set untouched, `sync = 1` replaces it with exactly what
/// follows.
pub fn authenticate(
transport: &mut dyn Transport,
username: &str,
account: &Account,
store: &Store,
) -> Result<u16, SmolError> {
let (sync, tokens) = store.token_set(account)?;
let mut body = Vec::new();
string_field(&mut body, username.as_bytes())?;
body.extend_from_slice(&account.me().pk());
body.extend_from_slice(
&account
.me()
.sign(&[LABEL_AUTH, &transport.bind().h[..]].concat()),
);
body.push(sync);
body.extend_from_slice(&(tokens.len() as u16).to_be_bytes());
for token in &tokens {
body.extend_from_slice(token);
}
let response = transport.request(OP_AUTH, &body)?;
expect_ok(response.status, "authentication")?;
let held = if response.body.len() >= 2 {
u16::from_be_bytes(response.body[..2].try_into().unwrap())
} else {
0
};
Ok(held)
}
/// What pushing the accept-token set achieved (sec 5.8).
pub enum Pushed {
/// Not registered yet; the set travels with the first fetch instead.
NotRegistered,
/// The server now holds this many tokens.
Held(u16),
}
/// An accept or a block only takes effect once the server holds the changed
/// set, so it is pushed now rather than at the next fetch.
pub fn push_tokens(store: &Store, account: &Account, timeout: u64) -> Result<Pushed, SmolError> {
let Some(addr) = store.account()? else {
return Ok(Pushed::NotRegistered);
};
let mut session = connect(store, &addr, true, timeout)?;
let held = authenticate(session.transport(), &addr.user, account, store)?;
session.close();
Ok(Pushed::Held(held))
}
/// REGISTER (sec 6.1): binds this identity to a username, with proof of
/// possession over the server's static key so the attestation cannot be
/// replayed to another server.
pub fn register(
store: &Store,
addr: &Address,
account: &Account,
invite: Option<&str>,
timeout: u64,
) -> Result<(), SmolError> {
let mut session = connect(store, addr, true, timeout)?;
let transport = session.transport();
let me = account.me();
let mut body = Vec::new();
string_field(&mut body, addr.user.as_bytes())?;
body.extend_from_slice(&me.pk());
body.extend_from_slice(
&me.sign(
&[
LABEL_REGISTER,
&transport.bind().server_static[..],
addr.user.as_bytes(),
&me.pk()[..],
]
.concat(),
),
);
let token = invite.unwrap_or("").as_bytes();
string_field(&mut body, token)?;
body.push(0); // no rotation certificate on a plain registration
let response = transport.request(OP_REGISTER, &body)?;
expect_ok(response.status, &format!("registering {}", addr.short()))?;
session.close();
store.set_account(addr)?;
Ok(())
}
/// A completed rotation: the new key and the certificate the server now
/// holds, ready to be pushed to contacts as an ordinary message (sec 7).
pub struct Rotated {
pub new_pk: [u8; KEY_LEN],
pub cert: [u8; CERT_LEN],
}
/// Advances the rotation index and pushes the certificate (sec 7). The master
/// is untouched; only the index moves, and the superseded key stays
/// derivable from it (sec 2).
pub fn rotate(store: &Store, account: &Account, timeout: u64) -> Result<Rotated, SmolError> {
let Some(addr) = store.account()? else {
return Err(SmolError::new(
"not registered; run: fumi register <user@host>",
));
};
if account.index() as usize >= MAX_CHAIN {
return Err(SmolError::new(format!(
"the rotation chain is full at {MAX_CHAIN} links"
)));
}
let old = account.me();
let new = Identity::from_seed(crate::account::identity_seed(
account.master(),
account.index() + 1,
));
let cert = RotationCert::build(old, &new, &addr.user, now());
let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport();
let mut body = Vec::new();
string_field(&mut body, addr.user.as_bytes())?;
body.extend_from_slice(&new.pk());
body.extend_from_slice(
&new.sign(
&[
LABEL_REGISTER,
&transport.bind().server_static[..],
addr.user.as_bytes(),
&new.pk()[..],
]
.concat(),
),
);
body.push(0); // no invite token on a rotation
body.push(CERT_LEN as u8);
body.extend_from_slice(&cert);
let response = transport.request(OP_REGISTER, &body)?;
expect_ok(response.status, "rotating")?;
session.close();
store.set_rotations(account.index() + 1)?;
Ok(Rotated {
new_pk: new.pk(),
cert,
})
}
/// Recovers the rotation index, and with it every superseded key, from the
/// master alone (sec 2). The accepted set is gone, so sync is disabled until
/// the user rebuilds it: an empty set must not replace the server's (sec 4).
pub fn restore(
store: &Store,
master: &[u8; KEY_LEN],
addr: &Address,
timeout: u64,
) -> Result<u32, SmolError> {
let mut session = connect(store, addr, false, timeout)?;
let (identity, _) = resolve(session.transport(), &addr.user)?;
session.close();
let mut found = None;
for index in 0..=(MAX_CHAIN as u32) {
if Identity::from_seed(crate::account::identity_seed(master, index)).pk() == identity {
found = Some(index);
break;
}
}
let Some(index) = found else {
return Err(SmolError::new(format!(
"the key bound to {} is not derived from this master within {MAX_CHAIN} rotations",
addr.short()
)));
};
store.set_account(addr)?;
store.set_cursor(0, &[0u8; ID_LEN])?;
store.set_rotations(index)?;
store.set_sync_ok(false)?;
Ok(index)
}
/// One message to send, as the CLI assembled it.
pub struct SendDraft<'a> {
pub address: &'a Address,
pub text: String,
pub subject: Option<&'a str>,
/// An id this message replies to, as 64 hex characters.
pub reply_to: Option<&'a str>,
/// Extra `Key: value` frontmatter fields.
pub headers: &'a [(&'a str, &'a str)],
/// Omit the Reply-To field carrying this address (sec 5.7).
pub anonymous: bool,
pub no_pad: bool,
}
/// What `send` did, for the caller to report.
pub struct Sent {
pub id: [u8; ID_LEN],
pub bytes: usize,
/// An accept token for their mailbox was attached (sec 5.8).
pub token_used: bool,
/// How the recipient key was obtained, if it was learned now.
pub change: TrustChange,
/// A non-fatal oddity worth showing the user.
pub warning: Option<String>,
}
/// Seals and delivers one message (sec 5, sec 6.1). Recipient selection
/// prefers a key we already trust: a self-certifying address, then a stored
/// contact, and only then RESOLVE with trust on first use.
pub fn send(
store: &Store,
account: &Account,
draft: &SendDraft<'_>,
timeout: u64,
) -> Result<Sent, SmolError> {
let addr = draft.address;
let me = account.me();
let (recipient, change) = if let Some(key) = addr.identity {
store.save_contact(&addr.short(), &key, true)?;
(key, TrustChange::None)
} else if let Some((known, _)) = store.contact(&addr.short())? {
(known, TrustChange::None)
} else {
let mut session = connect(store, addr, false, timeout)?;
let (key, change) = trust_key(store, session.transport(), addr)?;
session.close();
(key, change)
};
// Frontmatter, in the order the reference client emits it.
let mut fields: Vec<(String, String)> = Vec::new();
if let Some(subject) = draft.subject {
fields.push(("Subject".into(), subject.to_string()));
}
if let Some(reply_to) = draft.reply_to {
fields.push(("In-Reply-To".into(), reply_to.to_string()));
}
for (key, value) in draft.headers {
fields.push(((*key).to_string(), (*value).to_string()));
}
// sec 5.7: a signed reply address lets a first-time recipient answer us.
if let Some(home) = store.account()? {
if !draft.anonymous {
fields.push(("Reply-To".into(), home.uri(&me.pk())));
}
}
// sec 5.8: hand an accepted correspondent the token for our own mailbox.
if let Some(identity) = store.accepted_identity(&addr.short())? {
fields.push(("Accept".into(), b32(&account.token_for(&identity))));
}
let borrowed: Vec<(&str, &str)> = fields
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let body = build_frontmatter(&borrowed, &draft.text).into_bytes();
let envelope = seal(me, &recipient, &body, now(), !draft.no_pad)?;
let mid = message_id(&envelope);
// sec 5.8: our token for their mailbox, if they have given us one.
let mac = match store.token_of(&addr.short())? {
Some(token) => hmac_sha256(&token, &[LABEL_MAC, &mid[..]].concat()).to_vec(),
None => Vec::new(),
};
let mut session = connect(store, addr, false, timeout)?;
let mut wire = vec![mac.len() as u8];
wire.extend_from_slice(&mac);
wire.extend_from_slice(&envelope);
let response = session.transport().request(OP_SEND, &wire)?;
expect_ok(response.status, &format!("sending to {}", addr.short()))?;
session.close();
// The server echoes the id; it is derived, so ours is authoritative.
let warning = if !response.body.is_empty() && response.body != mid.to_vec() {
Some("server returned an id we did not derive; it is not authoritative".to_string())
} else {
None
};
// sec 5.6: the ephemeral is gone, so keep a copy sealed to ourselves.
store.store_sent(&mid, &addr.short(), &envelope, now())?;
Ok(Sent {
id: mid,
bytes: envelope.len(),
token_used: !mac.is_empty(),
change,
warning,
})
}
/// One rejected fetch record: the id and why it did not enter the inbox.
pub struct Rejected {
pub id: [u8; ID_LEN],
pub reason: String,
}
/// What `fetch` did, for the caller to report.
pub struct Fetched {
pub total: u32,
pub stored: u32,
pub rejected: Vec<Rejected>,
/// Messages were deleted from the server rather than kept behind a
/// cursor (the default).
pub acknowledged: bool,
}
/// Retrieves, verifies and stores mail (sec 6.1), paging forward by
/// `(received_at, id)` until a response is empty. Acknowledging deletes what
/// it takes, so it always pages from the start; `keep` instead remembers the
/// cursor. A message that fails verification is left on the server, so a
/// client-side bug cannot lose mail.
pub fn fetch(
store: &Store,
account: &Account,
keep: bool,
reset: bool,
timeout: u64,
) -> Result<Fetched, SmolError> {
let Some(addr) = store.account()? else {
return Err(SmolError::new(
"not registered; run: fumi register <user@host>",
));
};
if reset {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
let (mut after_time, mut after_id) = if keep {
store.cursor()?
} else {
(0, [0u8; ID_LEN])
};
let mut summary = Fetched {
total: 0,
stored: 0,
rejected: Vec::new(),
acknowledged: !keep,
};
let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport();
authenticate(transport, &addr.user, account, store)?;
loop {
let mut body = Vec::with_capacity(8 + ID_LEN);
body.extend_from_slice(&after_time.to_be_bytes());
body.extend_from_slice(&after_id);
let response = transport.request(OP_FETCH, &body)?;
expect_ok(response.status, "fetching")?;
let mut r = Reader::new(&response.body);
let count = r.u16()? as usize;
if count == 0 {
break;
}
let mut acked: Vec<[u8; ID_LEN]> = Vec::new();
for _ in 0..count {
let mid: [u8; ID_LEN] = r.take(ID_LEN)?.try_into().unwrap();
let received_at = r.i64()?;
let flags = r.u8()?;
let envelope_len = r.u32()? as usize;
let envelope = r.take(envelope_len)?.to_vec();
after_time = received_at;
after_id = mid;
summary.total += 1;
let opened = match verify_and_open(account, &mid, &envelope) {
Ok(opened) => opened,
Err(reason) => {
summary.rejected.push(Rejected {
id: mid,
reason: reason.to_string(),
});
continue;
}
};
// A message we have already had once is not stored again, even
// if we deleted it locally in the meantime (sec 10).
if !store.seen(&mid)? {
let requests_tier = flags & FLAG_REQUESTS != 0;
store.store_inbox(&mid, &envelope, received_at, requests_tier)?;
learn_token(store, &opened)?;
summary.stored += 1;
}
acked.push(mid);
}
r.done()?;
if keep {
store.set_cursor(after_time, &after_id)?;
} else if !acked.is_empty() {
delete_ids(transport, &acked)?;
}
}
session.close();
if !keep {
store.set_cursor(0, &[0u8; ID_LEN])?;
}
Ok(summary)
}
fn verify_and_open(
account: &Account,
mid: &[u8; ID_LEN],
envelope: &[u8],
) -> Result<Opened, SmolError> {
if message_id(envelope) != *mid {
return Err(SmolError::new("id does not match the envelope"));
}
unseal(account.keys(), envelope, now())
}
/// Files the accept token a verified payload carried, under the address that
/// signed it (sec 5.8). The signature has already been checked by `unseal`,
/// so the attribution is the signer's own claim.
fn learn_token(store: &Store, opened: &Opened) -> Result<(), SmolError> {
let text = String::from_utf8_lossy(&opened.body).into_owned();
let (fields, _) = parse_frontmatter(&text);
let Some(token) = accept_field(&fields) else {
return Ok(());
};
let reply_to = fields.get("reply-to").map(String::as_str);
match store.address_of(&opened.sender, reply_to)? {
// No address to send to, so no use for a token.
None => Ok(()),
Some(address) => store.save_token(&address, &token),
}
}
fn delete_ids(transport: &mut dyn Transport, ids: &[[u8; ID_LEN]]) -> Result<u16, SmolError> {
let mut body = Vec::with_capacity(2 + ids.len() * ID_LEN);
body.extend_from_slice(&(ids.len() as u16).to_be_bytes());
for id in ids {
body.extend_from_slice(id);
}
let response = transport.request(OP_DELETE, &body)?;
expect_ok(response.status, "acknowledging")?;
let removed = if response.body.len() >= 2 {
u16::from_be_bytes(response.body[..2].try_into().unwrap())
} else {
0
};
Ok(removed)
}
/// DELETE (sec 6.1) over an authenticated session: remove ids from the
/// server explicitly. Unknown ids are not an error.
pub fn delete(
store: &Store,
account: &Account,
ids: &[[u8; ID_LEN]],
timeout: u64,
) -> Result<u16, SmolError> {
let Some(addr) = store.account()? else {
return Err(SmolError::new(
"not registered; run: fumi register <user@host>",
));
};
let mut session = connect(store, &addr, true, timeout)?;
let transport = session.transport();
authenticate(transport, &addr.user, account, store)?;
let removed = delete_ids(transport, ids)?;
session.close();
Ok(removed)
}
/// An opened, described message for listing and reading.
pub struct Described {
pub sender: [u8; KEY_LEN],
pub time: i64,
pub fields: BTreeMap<String, String>,
pub text: String,
/// The contact address we know the signer by, or a fingerprint.
pub from: String,
pub subject: String,
}
impl Described {
/// Whether this payload carried its signer's accept token (sec 5.8):
/// machinery, not content.
pub fn carries_token(&self) -> bool {
self.fields.contains_key("accept")
}
}
/// Opens one sealed message and splits its body into frontmatter and text.
pub fn describe(store: &Store, account: &Account, stored: &Stored) -> Result<Described, SmolError> {
let opened = unseal(account.keys(), &stored.envelope, now())?;
let text = String::from_utf8_lossy(&opened.body).into_owned();
let (fields, body_text) = parse_frontmatter(&text);
let from = store
.address_of(&opened.sender, fields.get("reply-to").map(String::as_str))?
.unwrap_or_else(|| format!("<{}…>", &b32(&opened.sender)[..20]));
Ok(Described {
sender: opened.sender,
time: opened.time,
subject: fields.get("subject").cloned().unwrap_or_default(),
fields,
text: body_text,
from,
})
}
/// The accept MAC a sender attaches to SEND (sec 5.8), exposed for tests.
pub fn accept_mac(token: &[u8; TOKEN_LEN], mid: &[u8; ID_LEN]) -> [u8; 32] {
hmac_sha256(token, &[LABEL_MAC, mid].concat())
}
/// Proof of possession over a server's static key (sec 6.1), for tests.
#[cfg(test)]
fn register_signed(server_static: &[u8], username: &str, identity: &[u8]) -> Vec<u8> {
[LABEL_REGISTER, server_static, username.as_bytes(), identity].concat()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{identity_seed, verify_sig, Identity, RotationCert};
use crate::transport::{TransportBindValues, SIG_LEN};
fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8)
}
fn identity(n: u32) -> Identity {
Identity::from_seed(identity_seed(&master(), n))
}
#[test]
fn walk_chain_accepts_signed_progressions() {
let old = identity(0);
let new = identity(1);
let cert = RotationCert::build(&old, &new, "alice", 1700000001);
assert!(walk_chain("alice", &old.pk(), &old.pk(), &[]));
assert!(walk_chain("alice", &old.pk(), &new.pk(), &[cert]));
// The username is covered, so the same cert proves nothing for bob.
assert!(!walk_chain("bob", &old.pk(), &new.pk(), &[cert]));
// No chain, no acceptance.
assert!(!walk_chain("alice", &old.pk(), &new.pk(), &[]));
}
#[test]
fn walk_chain_requires_continuity_and_terminates_at_current() {
let keys: Vec<Identity> = (0..4).map(identity).collect();
let cert01 = RotationCert::build(&keys[0], &keys[1], "alice", 1);
let cert12 = RotationCert::build(&keys[1], &keys[2], "alice", 2);
let cert23 = RotationCert::build(&keys[2], &keys[3], "alice", 3);
// A full chain from 0 to 3.
assert!(walk_chain(
"alice",
&keys[0].pk(),
&keys[3].pk(),
&[cert01, cert12, cert23]
));
// Holding key 1: the earlier link is skipped, the rest must continue.
assert!(walk_chain(
"alice",
&keys[1].pk(),
&keys[3].pk(),
&[cert01, cert12, cert23]
));
// A gap between the held key and the chain never validates.
assert!(!walk_chain(
"alice",
&keys[0].pk(),
&keys[3].pk(),
&[cert12, cert23]
));
// The chain must end at the key RESOLVE returned, not merely agree
// partway.
assert!(!walk_chain(
"alice",
&keys[0].pk(),
&keys[2].pk(),
&[cert01, cert12, cert23]
));
}
#[test]
fn walk_chain_enforces_the_length_limit() {
let keys: Vec<Identity> = (0..=16).map(identity).collect();
let certs: Vec<[u8; CERT_LEN]> = (0..16)
.map(|n| RotationCert::build(&keys[n], &keys[n + 1], "alice", n as i64))
.collect();
// Sixteen links walk the whole chain from the first key.
assert!(walk_chain("alice", &keys[0].pk(), &keys[16].pk(), &certs));
// Seventeen links is over-long and needs user confirmation, whatever
// key we hold (sec 7).
let mut over = certs.clone();
let extra = identity(17);
over.push(RotationCert::build(&keys[16], &extra, "alice", 17));
assert!(!walk_chain("alice", &keys[0].pk(), &extra.pk(), &over));
assert!(!walk_chain("alice", &keys[1].pk(), &extra.pk(), &over));
}
#[test]
fn accept_mac_matches_the_reference_vector() {
// The reference client's own vector: token_for(pk_1) over the id of
// the reference envelope (see the message module's vectors).
let account = Account::new(master(), 0).unwrap();
let token = account.token_for(&identity(1).pk());
let mid: [u8; 32] =
crate::crypto::unb32("7tttsuaq4fqwbi5hvp6tigzwk5g73upgqpfxvk26aumwak2zefiq")
.unwrap()
.try_into()
.unwrap();
assert_eq!(
data_encoding::HEXLOWER.encode(&accept_mac(&token, &mid)),
"f709facbe5e032f4c2667c7899e5194397437ed001f2b2be33b82f1cc8d7c93e"
);
}
#[test]
fn bind_values_feed_the_auth_and_register_signatures() {
let bind = TransportBindValues::tcp(&[2u8; 32], &[7u8; 32]).unwrap();
let account = Account::new(master(), 0).unwrap();
let me = account.me();
let auth = me.sign(&[LABEL_AUTH, &bind.h[..]].concat());
let register = me.sign(&register_signed(&bind.server_static, "alice", &me.pk()));
assert_eq!(auth.len(), SIG_LEN);
assert!(verify_sig(
&me.pk(),
&auth,
&[LABEL_AUTH, &bind.h[..]].concat()
));
assert!(verify_sig(
&me.pk(),
&register,
&register_signed(&bind.server_static, "alice", &me.pk())
));
}
}

201
src/crypto.rs Normal file
View file

@ -0,0 +1,201 @@
//! Encoding and cryptographic helpers: base32, HKDF, HMAC, SHA-256 and the
//! Ed25519 -> X25519 key map with the checks SPEC.md sec 2 requires.
use data_encoding::{Encoding, Specification};
use ed25519_dalek::VerifyingKey;
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use std::sync::LazyLock;
use x25519_dalek::{PublicKey, StaticSecret};
use crate::error::SmolError;
pub const KEY_LEN: usize = 32;
/// X25519 scalar clamping (RFC 7748): libsodium's crypto_sign_ed25519_sk_to_
/// curve25519 output is the clamped scalar, and SPEC.md sec 2 spells the map
/// the same way, so the private half is clamped when constructed rather than
/// only inside key agreement.
pub fn clamp_scalar(mut bytes: [u8; KEY_LEN]) -> [u8; KEY_LEN] {
bytes[0] &= 248;
bytes[31] &= 127;
bytes[31] |= 64;
bytes
}
static BASE32_LOWER_UNPADDED: LazyLock<Encoding> = LazyLock::new(|| {
let mut spec = Specification::new();
spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567");
spec.encoding().unwrap()
});
/// RFC 4648 base32, lowercase and unpadded (SPEC.md sec 3).
pub fn b32(raw: &[u8]) -> String {
BASE32_LOWER_UNPADDED.encode(raw)
}
/// Inverse of `b32`; accepts uppercase and stray padding for pasted input.
pub fn unb32(text: &str) -> Result<Vec<u8>, SmolError> {
let lower = text.trim().to_ascii_lowercase();
let trimmed = lower.trim_end_matches('=');
BASE32_LOWER_UNPADDED
.decode(trimmed.as_bytes())
.map_err(|e| SmolError::new(format!("undecodable base32: {e}")))
}
/// First 20 characters of the base32 identity, in groups of four (SPEC.md
/// sec 3): a truncation of the identity, not a separate encoding.
pub fn fingerprint(identity: &[u8; KEY_LEN]) -> String {
let s: String = b32(identity).chars().take(20).collect();
s.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).unwrap())
.collect::<Vec<_>>()
.join(" ")
}
/// Multi-part SHA-256, the shape of every derived value in the protocol.
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = Sha256::new();
for part in parts {
hasher.update(part);
}
hasher.finalize().into()
}
/// HKDF-SHA256 (RFC 5869), as used by the identity derivations of sec 2 and
/// the sealing key of sec 5.2.
pub fn hkdf_sha256(ikm: &[u8], salt: &[u8], info: &[u8], len: usize) -> Vec<u8> {
let mut okm = vec![0u8; len];
let _ = Hkdf::<Sha256>::new(Some(salt), ikm).expand(info, &mut okm);
okm
}
/// HMAC-SHA256 (RFC 2104), used for accept tokens and their MACs (sec 5.8).
pub fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
mac.finalize().into_bytes().into()
}
/// Constant-time equality, so a pinned-key or MAC comparison cannot leak
/// timing information.
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// The X25519 public key of an Ed25519 identity: libsodium's
/// `crypto_sign_ed25519_pk_to_curve25519` (SPEC.md sec 2). Malformed points
/// are rejected rather than mapped, matching the reference client.
pub fn ed25519_to_x25519(identity: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], SmolError> {
VerifyingKey::from_bytes(identity)
.map(|key| *key.to_montgomery().as_bytes())
.map_err(|_| SmolError::new("not a valid Ed25519 public key"))
}
/// X25519 key agreement with sec 2's checks. An all-zero output covers a
/// low-order received ephemeral as well, so both are refused here.
pub fn agree(secret: &StaticSecret, peer: &[u8; KEY_LEN]) -> Result<[u8; KEY_LEN], SmolError> {
let shared = secret.diffie_hellman(&PublicKey::from(*peer));
if shared.as_bytes().iter().all(|&b| b == 0) {
return Err(SmolError::new("rejected all-zero key agreement output"));
}
Ok(*shared.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_matches_rfc4648_lowercase_unpadded() {
assert_eq!(b32(b"hello"), "nbswy3dp");
assert_eq!(
b32(&[0u8; 32]),
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
// 52 characters for a 32-byte key, the form the smol:// path uses.
assert_eq!(b32(&[1u8; 32]).len(), 52);
}
#[test]
fn unb32_round_trips_and_accepts_padded_uppercase() {
assert_eq!(unb32("nbswy3dp").unwrap(), b"hello");
assert_eq!(unb32("NBSWY3DP==").unwrap(), b"hello");
assert!(unb32("nbsw!3dp").is_err());
}
#[test]
fn fingerprint_is_20_chars_in_groups_of_four() {
let fp = fingerprint(&[0u8; 32]);
assert_eq!(fp, "aaaa aaaa aaaa aaaa aaaa");
}
#[test]
fn hkdf_matches_rfc5869_case_1() {
let ikm = [0x0bu8; 22];
let salt = [0u8; 13];
let info = [0xf0u8, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9];
let okm = hkdf_sha256(&ikm, &salt, &info, 42);
// First 32 bytes of RFC 5869 test case 1's 42-byte OKM.
let expected = "abbafb13f5c1bc489d4203135817956dd521b39e3bd61d1cc85cef884d1f8e2e";
assert_eq!(data_encoding::HEXLOWER.encode(&okm[..32]), expected);
}
#[test]
fn hmac_matches_rfc4231_case_1() {
let key = [0x0bu8; 20];
let data = b"Hi There";
let expected = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
assert_eq!(
data_encoding::HEXLOWER.encode(&hmac_sha256(&key, data)),
expected
);
}
#[test]
fn ed25519_to_x25519_rejects_garbage_and_maps_like_libsodium() {
// Vectors generated with libsodium's pk_to_curve25519 over the
// identities the reference client derives at seed_0 and seed_1.
let seed0: [u8; 32] = unb32("6l6wdqp3uwi2a3vn6jnlgjjmvimilnqv6np6t2vytw7wj3jdg6ea")
.unwrap()
.try_into()
.unwrap();
let pk0 = *ed25519_dalek::SigningKey::from_bytes(&seed0)
.verifying_key()
.as_bytes();
assert_eq!(
b32(&ed25519_to_x25519(&pk0).unwrap()),
"bqw73wfgphe4ubojbbsrhfvtnxknuhhwvq74lmadpppi2p7lkbvq"
);
// 32 zero bytes do decode to a curve point, but a low-order one;
// sec 2's rejection happens at agreement time, where the all-zero
// output surfaces.
let secret = StaticSecret::from([7u8; 32]);
assert!(agree(&secret, &ed25519_to_x25519(&[0u8; 32]).unwrap()).is_err());
}
#[test]
fn agree_rejects_all_zero_output() {
// A low-order base point produces the all-zero agreement output that
// sec 2 rejects.
let secret = StaticSecret::from([7u8; 32]);
let low_order = PublicKey::from([0u8; 32]);
assert!(agree(&secret, low_order.as_bytes()).is_err());
}
#[test]
fn ct_eq_compares_content() {
assert!(ct_eq(b"abc", b"abc"));
assert!(!ct_eq(b"abc", b"abd"));
assert!(!ct_eq(b"abc", b"ab"));
}
}

77
src/error.rs Normal file
View file

@ -0,0 +1,77 @@
//! The error the CLI surfaces as a message, and the status-code mapping.
use std::fmt;
/// Anything the user should see as a message rather than a traceback.
#[derive(Debug)]
pub struct SmolError(pub String);
impl SmolError {
pub fn new(msg: impl Into<String>) -> Self {
SmolError(msg.into())
}
}
impl fmt::Display for SmolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for SmolError {}
impl From<anyhow::Error> for SmolError {
fn from(e: anyhow::Error) -> Self {
SmolError(e.to_string())
}
}
impl From<rusqlite::Error> for SmolError {
fn from(e: rusqlite::Error) -> Self {
SmolError(format!("storage error: {e}"))
}
}
impl From<std::io::Error> for SmolError {
fn from(e: std::io::Error) -> Self {
SmolError(e.to_string())
}
}
impl From<snow::Error> for SmolError {
fn from(e: snow::Error) -> Self {
SmolError(format!("noise error: {e}"))
}
}
/// Status codes from SPEC.md sec 12. Unassigned numbers map to None, so a
/// caller cannot accidentally name one that does not exist.
pub fn status_name(status: u8) -> Option<&'static str> {
Some(match status {
0 => "ok",
1 => "malformed",
2 => "bad version",
3 => "unknown user",
4 => "auth required",
5 => "auth failed",
6 => "quota exceeded",
7 => "too large",
8 => "rate limited",
9 => "not permitted",
10 => "internal error",
_ => return None,
})
}
/// Turns a non-OK response into an error. The optional reason string in the
/// response body is never parsed (SPEC.md sec 12); an unassigned code is
/// surfaced by number and treated as a plain failure.
pub fn expect_ok(status: u8, what: &str) -> Result<(), SmolError> {
if status == 0 {
return Ok(());
}
let named = status_name(status)
.map(str::to_string)
.unwrap_or_else(|| format!("unknown status {status}"));
Err(SmolError::new(format!("{what} failed: {named} ({status})")))
}

15
src/lib.rs Normal file
View file

@ -0,0 +1,15 @@
//! `fumi` — the Smol Mail client (SPEC.md 1.2).
//!
//! Counterpart to bunshin, the server: deriving identities from one master
//! secret, sealing and opening mail, and talking to a mailbox over a Noise_NX
//! TCP connection (or the Reticulum carrier behind the `rns` feature).
pub mod account;
pub mod address;
pub mod client;
pub mod crypto;
pub mod error;
pub mod message;
pub mod store;
pub mod tcp;
pub mod transport;

766
src/main.rs Normal file
View file

@ -0,0 +1,766 @@
//! The command line: one master per store, selected by `--key` and `--db`.
//! A second identity is a second pair of files.
use std::io::{IsTerminal, Read, Write};
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use fumi::account::{identity_seed, Account};
use fumi::address::Address;
use fumi::client::{self, Pushed, SendDraft, TrustChange};
use fumi::crypto::{b32, fingerprint, unb32, KEY_LEN};
use fumi::error::SmolError;
use fumi::store::Store;
use fumi::transport::ID_LEN;
#[derive(Parser)]
#[command(about = "Smol Mail client")]
struct Cli {
/// Master secret file
#[arg(long, global = true, default_value = "identity.key")]
key: PathBuf,
/// Local state and mail
#[arg(long, global = true, default_value = "fumi.db")]
db: PathBuf,
/// Network timeout in seconds
#[arg(long, global = true, default_value_t = 30)]
timeout: u64,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Create a master secret
Keygen {
#[arg(long)]
force: bool,
},
/// Show this identity
Whoami,
/// Recover local state from the master alone
Restore { address: String },
/// Advance to the next identity, signing the change
Rotate,
/// Pin a server's Noise static key (TCP only)
Trust {
host: String,
key_b32: String,
#[arg(long)]
force: bool,
},
/// Bind this identity to a username
Register {
address: String,
/// Invite token, if the server requires one
#[arg(long)]
invite: Option<String>,
},
/// Look up a contact's key, walk the chain, pin it
Resolve { address: String },
/// Add a contact from a self-certifying address
Import { uri: String },
/// List known keys and how each was learned
Contacts,
/// Issue an accept token, admit to the main tier, sync the set
Accept { address: String },
/// Withdraw a contact's accept token, sync the set
Block { address: String },
/// Seal and deliver a message
Send {
address: String,
#[arg(long)]
subject: Option<String>,
/// Message text; stdin is read when neither --body nor --file is given
#[arg(long)]
body: Option<String>,
/// Read the message text from this file ("-" for stdin)
#[arg(long)]
file: Option<String>,
/// Extra `Key: value` frontmatter field; repeatable
#[arg(long = "header", value_name = "KEY:VALUE")]
headers: Vec<String>,
/// The id this message replies to, 64 hex characters
#[arg(long = "reply-to", value_name = "ID")]
reply_to: Option<String>,
/// Omit the Reply-To field carrying this address
#[arg(long)]
anonymous: bool,
/// Do not pad the payload to 1 KiB
#[arg(long = "no-pad")]
no_pad: bool,
},
/// Retrieve, verify, store and acknowledge mail
Fetch {
/// Do not delete from the server; remember the cursor instead
#[arg(long)]
keep: bool,
/// Forget the cursor and page again
#[arg(long)]
reset: bool,
},
/// Remove messages from the server explicitly
Delete {
/// Message ids, or unambiguous prefixes
ids: Vec<String>,
},
/// List stored mail
List {
#[arg(long)]
sent: bool,
/// Mail that arrived without an accept token
#[arg(long)]
requests: bool,
},
/// Show one message
Read {
id: String,
#[arg(long)]
sent: bool,
},
}
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("error: {e}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
let store = Store::open(&cli.db)?;
match &cli.command {
Command::Keygen { force } => keygen(&cli.key, *force),
Command::Whoami => whoami(&cli.key, &store),
Command::Restore { address } => restore(&cli.key, &store, address, cli.timeout),
Command::Rotate => rotate(&cli.key, &store, cli.timeout),
Command::Trust {
host,
key_b32,
force,
} => trust(&store, host, key_b32, *force),
Command::Register { address, invite } => {
register(&cli.key, &store, address, invite.as_deref(), cli.timeout)
}
Command::Resolve { address } => resolve(&store, address, cli.timeout),
Command::Import { uri } => import(&store, uri),
Command::Contacts => contacts(&store),
Command::Accept { address } => accept(&cli.key, &store, address, cli.timeout),
Command::Block { address } => block(&cli.key, &store, address, cli.timeout),
Command::Send { .. } => send(&cli, &store),
Command::Fetch { keep, reset } => fetch(&cli.key, &store, *keep, *reset, cli.timeout),
Command::Delete { ids } => delete(&cli.key, &store, ids, cli.timeout),
Command::List { sent, requests } => list(&cli.key, &store, *sent, *requests),
Command::Read { id, sent } => read(&cli.key, &store, id, *sent),
}
}
fn warn(message: &str) {
eprintln!("warning: {message}");
}
fn load_master(path: &PathBuf) -> Result<[u8; KEY_LEN]> {
let bytes = std::fs::read(path).map_err(|_| {
SmolError::new(format!(
"no identity at {}; run: fumi keygen",
path.display()
))
})?;
bytes.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!(
"master secret at {} is {} bytes, expected {KEY_LEN}",
path.display(),
v.len()
))
.into()
})
}
/// The master from disk plus the rotation index from local state.
fn load_account(path: &PathBuf, store: &Store) -> Result<Account> {
Ok(Account::new(load_master(path)?, store.rotations()?)?)
}
/// Writes a secret with mode 0600 before any bytes land, so it is never
/// briefly world-readable.
fn write_secret(path: &PathBuf, secret: &[u8]) -> Result<()> {
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
};
#[cfg(not(unix))]
let file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path);
let mut file = file.with_context(|| format!("cannot write {}", path.display()))?;
file.write_all(secret)?;
Ok(())
}
fn keygen(path: &PathBuf, force: bool) -> Result<()> {
if path.exists() && !force {
return Err(SmolError::new(format!(
"{} exists; refusing to overwrite (use --force)",
path.display()
))
.into());
}
let mut master = [0u8; KEY_LEN];
use rand_core::RngCore;
rand_core::OsRng.fill_bytes(&mut master);
write_secret(path, &master)?;
let account = Account::new(master, 0)?;
println!(
"master: {} (back this up; it is the only secret)",
path.display()
);
println!("public key: {}", b32(&account.me().pk()));
println!("fingerprint: {}", fingerprint(&account.me().pk()));
Ok(())
}
fn whoami(key: &PathBuf, store: &Store) -> Result<()> {
let account = load_account(key, store)?;
println!("public key: {}", b32(&account.me().pk()));
println!("fingerprint: {}", fingerprint(&account.me().pk()));
match store.account()? {
Some(addr) => {
println!("address: {}", addr.short());
println!("uri: {}", addr.uri(&account.me().pk()));
}
None => println!("address: (not registered)"),
}
if account.index() > 0 {
println!(
"rotations: {} (earlier keys derived on demand)",
account.index()
);
}
let live = store
.contact_rows()?
.iter()
.filter(|(_, _, _, active)| *active == Some(1))
.count();
let sync = store.sync_ok()?;
println!(
"accepted: {live} correspondent(s){}",
if sync {
String::new()
} else {
", not pushed to the server until rebuilt".to_string()
}
);
Ok(())
}
fn restore(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
let master = load_master(key)?;
let addr = Address::parse(address)?;
let index = client::restore(store, &master, &addr, timeout)?;
let identity = fumi::account::Identity::from_seed(identity_seed(&master, index));
println!("restored {} at rotation {index}", addr.short());
println!("public key: {}", b32(&identity.pk()));
println!(
"Your accepted correspondents are still live on the server; `accept` them again only \
when you are ready to replace that set."
);
Ok(())
}
fn rotate(key: &PathBuf, store: &Store, timeout: u64) -> Result<()> {
let account = load_account(key, store)?;
let rotated = client::rotate(store, &account, timeout)?;
let addr = store.account()?.expect("rotate checked for an account");
println!("rotated {}", addr.short());
println!("new key: {}", b32(&rotated.new_pk));
println!("fingerprint: {}", fingerprint(&rotated.new_pk));
println!("share: {}", addr.uri(&rotated.new_pk));
println!();
println!("Tell your contacts; they will accept the change from the signed chain.");
Ok(())
}
fn trust(store: &Store, host: &str, key_b32: &str, force: bool) -> Result<()> {
let key: [u8; KEY_LEN] = unb32(key_b32)?.try_into().map_err(|v: Vec<u8>| {
SmolError::new(format!(
"server key is {} bytes, expected {KEY_LEN}",
v.len()
))
})?;
// A pin is per server, and the reference client strips any :port the
// same way its address grammar does: at the first colon.
let host = host.split(':').next().unwrap_or(host);
match store.server_pin(host)? {
Some(old) if old != key && !force => {
return Err(SmolError::new(format!(
"{host} is already pinned to {}; use --force to replace",
b32(&old)
))
.into());
}
_ => {}
}
store.pin_server(host, &key)?;
println!("pinned {host} {}", b32(&key));
Ok(())
}
fn register(
key: &PathBuf,
store: &Store,
address: &str,
invite: Option<&str>,
timeout: u64,
) -> Result<()> {
let account = load_account(key, store)?;
let addr = Address::parse(address)?;
client::register(store, &addr, &account, invite, timeout)?;
println!("registered {}", addr.short());
println!("share: {}", addr.uri(&account.me().pk()));
Ok(())
}
fn resolve(store: &Store, address: &str, timeout: u64) -> Result<()> {
let addr = Address::parse(address)?;
if addr.identity.is_some() {
return Err(
SmolError::new("that address already carries a key; use `import` instead").into(),
);
}
let mut session = client::connect(store, &addr, false, timeout)?;
if let Some(key) = session.unpinned_static {
warn(&format!(
"{} is not pinned; its key is {}",
addr.host,
b32(&key)
));
}
let transport = session.transport();
let (identity, change) = client::trust_key(store, transport, &addr)?;
session.close();
match change {
TrustChange::New { unverified } => println!(
"{} {}\n pinned (trust on first use{})",
addr.short(),
b32(&identity),
if unverified {
", UNVERIFIED server"
} else {
""
}
),
TrustChange::Rotated => {
warn(&format!(
"{} rotated its key; a signed chain confirms it",
addr.short()
));
println!(" now {}", b32(&identity));
}
TrustChange::None => println!("{} {}", addr.short(), b32(&identity)),
}
Ok(())
}
fn import(store: &Store, uri: &str) -> Result<()> {
let addr = Address::parse(uri)?;
let key = addr
.identity
.ok_or_else(|| SmolError::new("import needs a self-certifying address carrying a key"))?;
store.save_contact(&addr.short(), &key, true)?;
println!("imported {} {} (verified)", addr.short(), b32(&key));
println!("fingerprint: {}", fingerprint(&key));
Ok(())
}
fn contacts(store: &Store) -> Result<()> {
let rows = store.contact_rows()?;
if rows.is_empty() {
println!("no contacts");
return Ok(());
}
let width = rows.iter().map(|(a, _, _, _)| a.len()).max().unwrap_or(0);
for (address, identity, verified, active) in rows {
let state = match active {
Some(1) => "accepted",
Some(_) => "blocked",
None => "",
};
println!(
"{address:<width$} {} {:<8} {state}",
b32(&identity),
if verified { "verified" } else { "tofu" }
);
}
Ok(())
}
/// An address plus the key we hold for it, for commands naming a contact.
fn target_contact(store: &Store, text: &str) -> Result<(String, [u8; KEY_LEN])> {
let addr = Address::parse(text)?;
if let Some(key) = addr.identity {
return Ok((addr.short(), key));
}
match store.contact(&addr.short())? {
Some((key, _)) => Ok((addr.short(), key)),
None => Err(SmolError::new(format!(
"no key for {}; run `resolve` or `import` first",
addr.short()
))
.into()),
}
}
fn accept(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
let account = load_account(key, store)?;
let (address, identity) = target_contact(store, address)?;
if !store.sync_ok()? {
warn(
"this client's accepted set was not restored; from now on it replaces the server's, \
so re-accept everyone you still correspond with",
);
}
store.accept(&address, &identity)?;
println!("accepted {address}; its token travels in your next message to them");
match client::push_tokens(store, &account, timeout)? {
Pushed::NotRegistered => {
warn("not registered; the set will be pushed with your first fetch")
}
Pushed::Held(held) => println!("server now holds {held} accept token(s)"),
}
Ok(())
}
fn block(key: &PathBuf, store: &Store, address: &str, timeout: u64) -> Result<()> {
let account = load_account(key, store)?;
let (address, _) = target_contact(store, address)?;
if !store.block(&address)? {
return Err(SmolError::new(format!("{address} was never accepted")).into());
}
println!("blocked {address}; their mail lands in requests from their next message on");
match client::push_tokens(store, &account, timeout)? {
Pushed::NotRegistered => {
warn("not registered; the set will be pushed with your first fetch")
}
Pushed::Held(held) => println!("server now holds {held} accept token(s)"),
}
Ok(())
}
fn send(cli: &Cli, store: &Store) -> Result<()> {
let Command::Send {
address,
subject,
body,
file,
headers,
reply_to,
anonymous,
no_pad,
} = &cli.command
else {
unreachable!("send dispatches only from the Send command")
};
let account = load_account(&cli.key, store)?;
let addr = Address::parse(address)?;
let text = match (body, file) {
(Some(text), _) => text.clone(),
(None, Some(path)) if path == "-" => read_stdin()?,
(None, Some(path)) => {
std::fs::read_to_string(path).with_context(|| format!("cannot read {path}"))?
}
(None, None) if !std::io::stdin().is_terminal() => read_stdin()?,
(None, None) => {
return Err(SmolError::new("no message body; pass --body or pipe it on stdin").into())
}
};
if let Some(reply) = reply_to {
if reply.len() != 64 || !reply.bytes().all(|c| c.is_ascii_hexdigit()) {
return Err(
SmolError::new("--reply-to must be a message id: 64 hex characters").into(),
);
}
}
// Extra headers must be valid `Key: value` fields (sec 5.5).
let mut extra: Vec<(String, String)> = Vec::new();
for raw in headers {
let Some((k, v)) = raw.split_once(':') else {
return Err(
SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(),
);
};
if !valid_frontmatter_key(k.trim()) {
return Err(
SmolError::new(format!("{raw:?} is not a valid `Key: value` header")).into(),
);
}
extra.push((k.trim().to_string(), v.trim().to_string()));
}
let borrowed: Vec<(&str, &str)> = extra
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let draft = SendDraft {
address: &addr,
text,
subject: subject.as_deref(),
reply_to: reply_to.as_deref(),
headers: &borrowed,
anonymous: *anonymous,
no_pad: *no_pad,
};
let sent = client::send(store, &account, &draft, cli.timeout)?;
if let Some(warning) = &sent.warning {
warn(warning);
}
match sent.change {
TrustChange::New { unverified } => warn(&format!(
"{} is new; its key was learned by trust on first use{}",
addr.short(),
if unverified {
" over an UNVERIFIED server"
} else {
""
}
)),
TrustChange::Rotated => warn(&format!(
"{} rotated its key; a signed chain confirms it",
addr.short()
)),
TrustChange::None => {}
}
println!(
"sent {} to {} ({} bytes{})",
&hex(&sent.id)[..16],
addr.short(),
sent.bytes,
if sent.token_used { ", accepted" } else { "" }
);
Ok(())
}
fn read_stdin() -> Result<String> {
let mut text = String::new();
std::io::stdin()
.read_to_string(&mut text)
.context("cannot read stdin")?;
Ok(text)
}
fn valid_frontmatter_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
}
fn fetch(key: &PathBuf, store: &Store, keep: bool, reset: bool, timeout: u64) -> Result<()> {
let account = load_account(key, store)?;
let summary = client::fetch(store, &account, keep, reset, timeout)?;
for rejected in &summary.rejected {
warn(&format!(
"{}: {}; left on server",
&hex(&rejected.id)[..16],
rejected.reason
));
}
let mut line = format!(
"{} message(s): {} new, {} rejected",
summary.total,
summary.stored,
summary.rejected.len()
);
if keep && summary.total > 0 {
line.push_str(" (left on the server)");
}
println!("{line}");
Ok(())
}
/// Resolves an unambiguous id prefix against stored mail and returns the full
/// ids, ready for the wire.
fn resolve_id_prefixes(store: &Store, ids: &[String]) -> Result<Vec<[u8; ID_LEN]>> {
if ids.is_empty() {
return Err(SmolError::new("no message ids given").into());
}
let stored = store
.mail("all")?
.into_iter()
.chain(store.mail("sent")?)
.map(|m| m.id)
.collect::<Vec<_>>();
let mut full = Vec::with_capacity(ids.len());
for id in ids {
let prefix = id.to_ascii_lowercase();
let matches: Vec<[u8; ID_LEN]> = stored
.iter()
.filter(|m| hex(m).starts_with(&prefix))
.copied()
.collect();
match matches.len() {
0 => return Err(SmolError::new(format!("no message matching {id:?}")).into()),
1 => full.push(matches[0]),
_ => {
return Err(SmolError::new(format!(
"{id:?} matches {} messages; be more specific",
matches.len()
))
.into())
}
}
}
Ok(full)
}
fn delete(key: &PathBuf, store: &Store, ids: &[String], timeout: u64) -> Result<()> {
let account = load_account(key, store)?;
let full = resolve_id_prefixes(store, ids)?;
let removed = client::delete(store, &account, &full, timeout)?;
println!("removed {removed} message(s) from the server");
Ok(())
}
fn list(key: &PathBuf, store: &Store, sent: bool, requests: bool) -> Result<()> {
let account = load_account(key, store)?;
let folder = if sent {
"sent"
} else if requests {
"requests"
} else {
"inbox"
};
let rows = store.mail(folder)?;
if rows.is_empty() {
println!("{folder} is empty");
return Ok(());
}
for row in rows {
let who = row.recipient.clone();
match client::describe(store, &account, &row) {
Ok(described) => {
let who = who.unwrap_or(described.from);
let subject = if described.subject.is_empty() {
"(no subject)".to_string()
} else {
described.subject
};
println!(
"{} {} {:<28.28} {subject}",
&hex(&row.id)[..8],
format_utc(row.at),
who
);
}
Err(e) => println!(
"{} {} {:<28.28} <unreadable: {e}>",
&hex(&row.id)[..8],
format_utc(row.at),
"?"
),
}
}
Ok(())
}
fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> {
let account = load_account(key, store)?;
let folder = if sent { "sent" } else { "all" };
let prefix = id.to_ascii_lowercase();
let matches: Vec<_> = store
.mail(folder)?
.into_iter()
.filter(|m| hex(&m.id).starts_with(&prefix))
.collect();
match matches.len() {
0 => Err(SmolError::new(format!(
"no {}message matching {id:?}",
if sent { "sent " } else { "" }
))
.into()),
1 => {
let row = &matches[0];
let described = client::describe(store, &account, row)?;
fn field(key: &str, value: &str) {
println!("{:<12} {value}", format!("{key}:"));
}
field("id", &hex(&row.id));
if let Some(recipient) = &row.recipient {
field("to", recipient);
}
field("from", &described.from);
field("key", &b32(&described.sender));
field("date", &format_utc(described.time));
for (k, v) in &described.fields {
if k != "accept" {
field(k, v);
}
}
println!("signature verified");
println!();
print!("{}", described.text);
if !described.text.ends_with('\n') {
println!();
}
Ok(())
}
_ => Err(SmolError::new(format!(
"{id:?} matches {} messages; be more specific",
matches.len()
))
.into()),
}
}
fn hex(id: &[u8; ID_LEN]) -> String {
data_encoding::HEXLOWER.encode(id)
}
/// UTC date and time, so no time-zone dependency is needed for a mail listing.
fn format_utc(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let time_of_day = secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}",
time_of_day / 3600,
time_of_day % 3600 / 60
)
}
/// Days since the Unix epoch to a civil date (Howard Hinnant's algorithm).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(if m <= 2 { y + 1 } else { y }, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utc_formatting_matches_known_dates() {
assert_eq!(format_utc(0), "1970-01-01 00:00");
assert_eq!(format_utc(1_700_000_000), "2023-11-14 22:13");
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00"); // leap day
assert_eq!(format_utc(-1), "1969-12-31 23:59");
}
}

443
src/message.rs Normal file
View file

@ -0,0 +1,443 @@
//! Envelopes (sec 5.1-5.4) and body frontmatter (sec 5.5).
use std::collections::BTreeMap;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand_core::{OsRng, RngCore};
use crate::account::{verify_sig, Identity};
use crate::crypto::{agree, b32, ct_eq, ed25519_to_x25519, hkdf_sha256, sha256, unb32, KEY_LEN};
use crate::error::SmolError;
use crate::transport::{
ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_ID, LABEL_MSG,
LABEL_SEAL, PAYLOAD_HEADER, SIG_LEN,
};
/// Senders MAY pad to a multiple of this to blur message length (sec 5.3).
pub const PAD_TO: usize = 1024;
/// A payload dated further ahead of our clock than this is rejected (sec 5.3).
pub const MAX_SKEW: i64 = 86400;
/// Frontmatter limits (sec 5.5).
pub const FRONTMATTER_MAX: usize = 4096;
pub const FRONTMATTER_KEYS: usize = 64;
/// An opened, verified payload. The message id is derived from the envelope,
/// not carried in it.
pub struct Opened {
pub sender: [u8; KEY_LEN],
pub time: i64,
pub body: Vec<u8>,
}
/// The derived message identifier (sec 5.4): a sender cannot choose it, and a
/// resend is a no-op rather than a new message.
pub fn message_id(envelope: &[u8]) -> [u8; KEY_LEN] {
sha256(&[LABEL_ID, envelope])
}
/// Seals `body` from `sender` to `recipient` (sec 5.2, 5.3). A fresh ephemeral
/// X25519 keypair per message makes the all-zero nonce correct: the derived
/// key is used exactly once, and the sender's long-term key never takes part
/// in key agreement.
pub fn seal(
sender: &Identity,
recipient: &[u8; KEY_LEN],
body: &[u8],
when: i64,
pad: bool,
) -> Result<Vec<u8>, SmolError> {
let mut esk = [0u8; KEY_LEN];
OsRng.fill_bytes(&mut esk);
// esk is dropped at the end of the frame; nothing more can be promised
// without a zeroizing type.
seal_with_esk(sender, recipient, body, when, pad, esk)
}
fn seal_with_esk(
sender: &Identity,
recipient: &[u8; KEY_LEN],
body: &[u8],
when: i64,
pad: bool,
esk: [u8; KEY_LEN],
) -> Result<Vec<u8>, SmolError> {
let x_recipient = ed25519_to_x25519(recipient)?;
let eph = x25519_dalek::StaticSecret::from(esk);
let epk = x25519_dalek::PublicKey::from(&eph);
// Ephemeral-static against the recipient's converted key (sec 5.2);
// converting also validates the recipient identity as a curve point.
let shared = agree(&eph, &x_recipient)?;
let mut salt = Vec::with_capacity(2 * KEY_LEN);
salt.extend_from_slice(epk.as_bytes());
salt.extend_from_slice(recipient);
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
let mut header = Vec::with_capacity(PAYLOAD_HEADER);
header.push(ENVELOPE_VERSION);
header.extend_from_slice(&sender.pk());
header.extend_from_slice(&when.to_be_bytes());
header.extend_from_slice(&(body.len() as u32).to_be_bytes());
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 2 * KEY_LEN + header.len() + body.len());
signed.extend_from_slice(LABEL_MSG);
signed.extend_from_slice(recipient);
signed.extend_from_slice(epk.as_bytes());
signed.extend_from_slice(&header);
signed.extend_from_slice(body);
let mut plaintext = header;
plaintext.extend_from_slice(body);
plaintext.extend_from_slice(&sender.sign(&signed));
if pad {
let pad = (PAD_TO - plaintext.len() % PAD_TO) % PAD_TO;
plaintext.resize(plaintext.len() + pad, 0);
}
let mut aad = Vec::with_capacity(ENVELOPE_HEADER);
aad.extend_from_slice(ENVELOPE_MAGIC);
aad.push(ENVELOPE_VERSION);
aad.extend_from_slice(recipient);
aad.extend_from_slice(epk.as_bytes());
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let sealed = cipher
.encrypt(
&Nonce::from([0u8; 12]),
Payload {
msg: &plaintext,
aad: &aad,
},
)
.map_err(|_| SmolError::new("encryption failed"))?;
let mut envelope = aad;
envelope.extend_from_slice(&sealed);
Ok(envelope)
}
/// Inverse of `seal`, performing every check a receiver MUST make (sec 5.3):
/// the envelope is addressed to one of our keys, the signature verifies
/// against the sender the payload carries, and the payload is not dated more
/// than `MAX_SKEW` ahead of `now`. Trailing bytes past `body_len + 64` are
/// ignored as padding.
pub fn unseal(identities: &[Identity], envelope: &[u8], now: i64) -> Result<Opened, SmolError> {
if envelope.len() < ENVELOPE_MIN {
return Err(SmolError::new("envelope too short"));
}
if &envelope[..4] != ENVELOPE_MAGIC {
return Err(SmolError::new("not a Smol Mail envelope"));
}
if envelope[4] != ENVELOPE_VERSION {
return Err(SmolError::new(format!(
"unsupported envelope version {}",
envelope[4]
)));
}
let to: [u8; KEY_LEN] = envelope[5..37].try_into().unwrap();
let epk: [u8; KEY_LEN] = envelope[37..69].try_into().unwrap();
let sealed = &envelope[ENVELOPE_HEADER..];
let me = identities
.iter()
.find(|i| ct_eq(&i.pk(), &to))
.ok_or_else(|| {
SmolError::new(format!(
"addressed to {}…, not one of our keys",
&b32(&to)[..16]
))
})?;
let shared = agree(me.x_priv(), &epk)?;
let mut salt = Vec::with_capacity(2 * KEY_LEN);
salt.extend_from_slice(&epk);
salt.extend_from_slice(&to);
let key = hkdf_sha256(&shared, &salt, LABEL_SEAL, 32);
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let plaintext = cipher
.decrypt(
&Nonce::from([0u8; 12]),
Payload {
msg: sealed,
aad: &envelope[..ENVELOPE_HEADER],
},
)
.map_err(|_| SmolError::new("decryption failed: wrong key or corrupt envelope"))?;
if plaintext.len() < PAYLOAD_HEADER + SIG_LEN || plaintext[0] != ENVELOPE_VERSION {
return Err(SmolError::new("unsupported payload version"));
}
let header = &plaintext[..PAYLOAD_HEADER];
let sender: [u8; KEY_LEN] = plaintext[1..33].try_into().unwrap();
let when = i64::from_be_bytes(plaintext[33..41].try_into().unwrap());
let body_len = u32::from_be_bytes(plaintext[41..45].try_into().unwrap()) as usize;
if PAYLOAD_HEADER + body_len + SIG_LEN > plaintext.len() {
return Err(SmolError::new("payload body length exceeds the payload"));
}
let body = &plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len];
let signature = &plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_LEN];
let mut signed = Vec::with_capacity(LABEL_MSG.len() + 2 * KEY_LEN + header.len() + body.len());
signed.extend_from_slice(LABEL_MSG);
signed.extend_from_slice(&to);
signed.extend_from_slice(&epk);
signed.extend_from_slice(header);
signed.extend_from_slice(body);
if !verify_sig(&sender, signature, &signed) {
return Err(SmolError::new("signature does not verify"));
}
if when > now + MAX_SKEW {
return Err(SmolError::new("payload is dated in the future"));
}
Ok(Opened {
sender,
time: when,
body: body.to_vec(),
})
}
/// Splits a body into its frontmatter fields and text (sec 5.5). Keys are
/// returned lowercased; a malformed block falls back to the whole body as
/// plain text, failing closed toward display.
pub fn parse_frontmatter(body: &str) -> (BTreeMap<String, String>, String) {
let whole = body.to_string();
if !body.starts_with("---\n") {
return (BTreeMap::new(), whole);
}
let lines: Vec<&str> = body.split('\n').collect();
// The block runs to the next line that is exactly `---`.
let Some(close) = lines
.iter()
.skip(1)
.position(|&l| l == "---")
.map(|p| p + 1)
else {
return (BTreeMap::new(), whole);
};
let block = &lines[1..close];
let rest = lines[close + 1..].join("\n");
if block.len() > FRONTMATTER_KEYS {
return (BTreeMap::new(), whole);
}
if block.iter().map(|l| l.len() + 1).sum::<usize>() > FRONTMATTER_MAX {
return (BTreeMap::new(), whole);
}
let mut fields = BTreeMap::new();
for line in block {
let Some((key, value)) = line.split_once(':') else {
return (BTreeMap::new(), whole);
};
if !valid_key(key) {
return (BTreeMap::new(), whole);
}
// First occurrence wins; keys are compared case-insensitively.
fields
.entry(key.to_ascii_lowercase())
.or_insert_with(|| value.trim().to_string());
}
(fields, rest)
}
/// Emits a block only when needed, including to escape a body that genuinely
/// begins with `---` (sec 5.5).
pub fn build_frontmatter(fields: &[(&str, &str)], body: &str) -> String {
if fields.is_empty() && !body.starts_with("---\n") {
return body.to_string();
}
let mut out = String::from("---\n");
for (key, value) in fields {
out.push_str(&format!("{key}: {value}\n"));
}
out.push_str("---\n");
out.push_str(body);
out
}
/// 1-64 bytes of [A-Za-z0-9-] (sec 5.5).
fn valid_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-')
}
/// The accept token carried in a reserved `Accept` field, if any. It is
/// meaningful only inside a sealed, signed payload, which the caller has
/// already verified (sec 5.8).
pub fn accept_field(fields: &BTreeMap<String, String>) -> Option<[u8; 32]> {
let raw = fields.get("accept")?;
unb32(raw).ok()?.try_into().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::identity_seed;
use crate::crypto::b32;
use std::collections::BTreeMap;
fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8)
}
// A full envelope produced by the reference client: fixed ephemeral
// [7;32], when = 1700000000, sender = seed_3, recipient = pk_1.
const REF_ENVELOPE_B32: &str = "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihmh5ba76e6ykfiymqcb5nguhws2cviykpwlkuwmxygtgqxpethdvvlhnurf6g4i2u3la2nd3rhgjlr4t227xerr652osqltqzpcjf4m2sidnhcvu5252nijsvxxmgi343ojiffqodjr4fjuglzdwovufeyirkcjywvjdlslcdmjsxg5wiadnvectykxlq3c6qxxqex66z5vjcklzoldtrh3h36l35vqtjtn5rf4yggz2wtbrrdteym2k46obuoxiolj2cnbmj7qihdabg6wdvohl2ctpoc57huengekrprkuyzvogidgwrnqbjhxjgz7mqq6tri24rkts3pqaza6jol6w72zltvxa5itkarkmu6e2vjytpsxbxulnursf52m4abd3w7vxyw6rlxogivj2wafqpylngomzgghlcyoto65ac2n3oavt53xbcdwmgstrdkhlzd2j2wihp2xscizr7pa3n7d3rzuocdc46cwishrvejl3cihfdfnzjhosij5jkr3driehzjdgrx7rl4q43gyjdlvl5zuvvihw2ka75do7vakdghfz4d2jmh7an2bkchnyv2bnwydwei6wsbfc55g7mthvqqwm5ojaza4f37ha4cps37zrbw4fg5q3xpxupfotnvts5ki7zcpvkpdwn4p4voqlpve6czprwfhv7pmmipevr76trg44lkxemhpweqqxtzr4bdsuwt5hroelg2pyv2e3qmq4ibanrqx6ygr4k7b4znwqmtx5urn3wfu2do45bivw6qd55vaaqzeu27shn5xj4mmpfy46iuj2a3vdkwuevlix26uz27ryuovrgnaurhkuowuyme2u2nxwr5z5xoclwy42dw5mg3tynplzz2espoq5ok5amuacjxcy3dm37d547mljiz3ist25pyv7zn33onzovucntivlvrqul23obeb44lqybqnu4m6nbbdpcuniyxyribqq3ukpq2zhvcesdzqqqzqfv2gcwtkzjuj6cbs25finxerdi726ac73hjlodjgwq7wdeqjohirl54rzmgerhqnlr6mummauqad6ehubeuw6sdj2e4sbi4v4obgp7xv7ci3yeqroowi4ygh674lwtlpweaat7xbtvhzltexcknt6bu3abgmmpabcvlrdszpp33w44pw6wogrhl5p5dozx3of3keefu4bxhypg3euh4mzawrxdnjflee3um6y5kmfw5iexaoscy5lu6jupc5sisvvgtxibnjzxuwaifui22b3ng3coacszqxyy7boll4k4qdaehombacj36mbqk3kjmhxpx5etgqr5krbac2z3xufosnwtfln2pauhe2i3kujty2a3r3yn7umiqvomkeg6zyvla4kltclot55c6vpmp4mlisvzngayds67f45hnz7hpgtygjcz2ozzhqk2tq";
const REF_MESSAGE_ID: &str = "fce7395010e16160a3a7abfd341b36574dfdd1e683cb7aab5e0519602b592151";
const REF_ENVELOPE_NOPAD_B32: &str = "kngu6tabzoieneun2k7n5b65yxfeen4pxvxzq7sclm2kfaa7os3wrwrz4rtbhpsp5lvpebgh7uzvr7e4abzbraoroqtyckbcp3dhj437p7uxw3lqwcki4wgjyisjpfr77awo2sz7p3uuke337wdlyubbzvdixnvauhf2q2nihc4cerlf62uyhdnvwnrajxxqo56hht5qwijh3uxbrons62te3xwwpm4yppbiab63nw57rj3yjarzwq2tfz57cphnxnacdbm5xdl66mdygni5js7pcftrmhitpcqjibjdmanyjtl75xtsjkm6dxzcwldqaxngqnfgg37yatihkho2c5e2w27hqbhphfhpeusvhu";
fn identity(n: u32) -> Identity {
Identity::from_seed(identity_seed(&master(), n))
}
#[test]
fn message_id_matches_the_reference_client() {
let envelope = unb32(REF_ENVELOPE_B32).unwrap();
assert_eq!(envelope.len(), 1109);
assert_eq!(
data_encoding::HEXLOWER.encode(&message_id(&envelope)),
REF_MESSAGE_ID
);
}
#[test]
fn unseals_an_envelope_from_the_reference_client() {
let envelope = unb32(REF_ENVELOPE_B32).unwrap();
let opened = unseal(
&[identity(0), identity(1), identity(2)],
&envelope,
1700000100,
)
.expect("reference envelope must open");
assert_eq!(
b32(&opened.sender),
"qn6h4zx62pnqxsteog6kcykfef33k2t5yrai7ei3eq33hres6wga"
);
assert_eq!(opened.time, 1700000000);
assert_eq!(opened.body, b"hello from the reference client\n");
}
#[test]
fn seal_matches_the_reference_client_byte_for_byte() {
let sender = identity(3);
let recipient = identity(1).pk();
let envelope = seal_with_esk(
&sender,
&recipient,
b"hello from the reference client\n",
1700000000,
true,
[7u8; 32],
)
.unwrap();
assert_eq!(envelope, unb32(REF_ENVELOPE_B32).unwrap());
// And unpadded, which is the other sender choice sec 5.3 allows.
let plain = seal_with_esk(
&sender,
&recipient,
b"hello from the reference client\n",
1700000000,
false,
[7u8; 32],
)
.unwrap();
assert_eq!(plain.len(), 226);
assert_eq!(plain, unb32(REF_ENVELOPE_NOPAD_B32).unwrap());
}
#[test]
fn seal_open_round_trip_and_rejections() {
let sender = identity(2);
let recipient = identity(0);
let envelope = seal(&sender, &recipient.pk(), b"body text", 1700000000, false).unwrap();
let opened = unseal(std::slice::from_ref(&recipient), &envelope, 1700000100).unwrap();
assert_eq!(opened.sender, sender.pk());
assert_eq!(opened.body, b"body text");
// Not addressed to us.
assert!(unseal(&[identity(1)], &envelope, 1700000100).is_err());
// Tampered ciphertext fails the tag.
let mut broken = envelope.clone();
broken[80] ^= 1;
assert!(unseal(std::slice::from_ref(&recipient), &broken, 1700000100).is_err());
// Superseded keys still open their mail (sec 7).
assert!(unseal(&[identity(1), recipient.clone()], &envelope, 1700000100).is_ok());
// A payload dated far in the future is rejected (sec 5.3).
let future = seal(
&sender,
&recipient.pk(),
b"x",
1700000000 + MAX_SKEW + 1,
false,
)
.unwrap();
assert!(unseal(std::slice::from_ref(&recipient), &future, 1700000000).is_err());
// Not an envelope at all.
assert!(unseal(&[recipient], b"SMOL", 0).is_err());
}
#[test]
fn padding_is_ignored_and_length_blurred() {
let sender = identity(2);
let recipient = identity(0);
let padded = seal(&sender, &recipient.pk(), b"smol", 1700000000, true).unwrap();
let opened = unseal(&[recipient], &padded, 1700000000).unwrap();
assert_eq!(opened.body, b"smol");
// 45-byte payload header + 4-byte body + 64-byte signature = 113
// padded to the next 1024 multiple, plus the 69-byte envelope header
// and the 16-byte tag.
assert_eq!(padded.len(), 69 + 1024 + 16);
}
#[test]
fn frontmatter_rules() {
let (fields, text) = parse_frontmatter(
"---\nSubject: hi there\nX-Mood: ok\nSubJect: second loses\n---\nBody here.\n",
);
let mut expected = BTreeMap::new();
expected.insert("subject".to_string(), "hi there".to_string());
expected.insert("x-mood".to_string(), "ok".to_string());
assert_eq!(fields, expected);
assert_eq!(text, "Body here.\n");
// A malformed line invalidates the whole block: display, not discard.
let (fields, text) = parse_frontmatter("---\nSubject: good\nnot a valid line\n---\ntext\n");
assert!(fields.is_empty());
assert!(text.starts_with("---\n"));
// No closing fence, oversized key: no block.
assert!(parse_frontmatter("---\nSubject: hi\n").0.is_empty());
assert!(
parse_frontmatter(&format!("---\n{}: hi\n---\nt\n", "x".repeat(65)))
.0
.is_empty()
);
assert!(parse_frontmatter("plain text").1 == "plain text");
// Values are trimmed; a value may itself contain a colon.
let (fields, _) = parse_frontmatter("---\nX-Url: http://a:b/c\n---\n");
assert_eq!(fields.get("x-url").unwrap(), "http://a:b/c");
}
#[test]
fn frontmatter_building_escapes_leading_dashes() {
let built = build_frontmatter(&[("Subject", "hi"), ("X-Mood", "ok")], "plain body\n");
assert_eq!(built, "---\nSubject: hi\nX-Mood: ok\n---\nplain body\n");
// A body genuinely beginning with --- is escaped by an empty block.
let escaped = build_frontmatter(&[], "---\nstarts with dashes\n");
assert_eq!(escaped, "---\n---\n---\nstarts with dashes\n");
// Nothing to say means no block at all.
assert_eq!(build_frontmatter(&[], "body\n"), "body\n");
// Round trip through the parser.
let (fields, text) = parse_frontmatter(&built);
assert_eq!(fields.get("subject").unwrap(), "hi");
assert_eq!(text, "plain body\n");
}
#[test]
fn accept_field_decodes_base32_or_is_absent() {
let mut fields = BTreeMap::new();
assert!(accept_field(&fields).is_none());
fields.insert("accept".to_string(), "nbswy3dp".to_string());
assert!(accept_field(&fields).is_none()); // 5 bytes, not 32
fields.insert("accept".to_string(), b32(&[1u8; 32]));
assert_eq!(accept_field(&fields), Some([1u8; 32]));
}
}

653
src/store.rs Normal file
View file

@ -0,0 +1,653 @@
//! Local state: one SQLite file, shared by both carriers (RNS.md sec 15).
//! Envelopes are stored sealed and opened on demand; no plaintext at rest.
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use rusqlite::{params, Connection};
use crate::account::Account;
use crate::address::Address;
use crate::crypto::ct_eq;
use crate::error::SmolError;
use crate::transport::{ID_LEN, KEY_LEN, TIER_MAIN, TIER_REQUESTS};
pub fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock before 1970")
.as_secs() as i64
}
pub const SCHEMA: &str = "
-- One row, always present, so every update is a plain UPDATE.
CREATE TABLE IF NOT EXISTS state (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT, host TEXT, port INTEGER, scheme TEXT,
rotations INTEGER NOT NULL DEFAULT 0, -- sec 2 rotation index
after_time INTEGER NOT NULL DEFAULT 0, -- sec 6.1 FETCH cursor
after_id BLOB NOT NULL DEFAULT x'',
sync_ok INTEGER NOT NULL DEFAULT 1); -- may we replace the server's set?
INSERT OR IGNORE INTO state (id) VALUES (1);
CREATE TABLE IF NOT EXISTS servers (
host TEXT PRIMARY KEY, static BLOB NOT NULL, pinned_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS contacts (
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
verified INTEGER NOT NULL, -- 1 when the key came from a self-certifying URI
seen_at INTEGER NOT NULL);
-- Correspondents admitted to this mailbox's main tier (sec 5.8). The
-- identity is frozen at acceptance because the token is derived from it: a
-- contact's later rotation must not change the token they already hold.
CREATE TABLE IF NOT EXISTS accepted (
address TEXT PRIMARY KEY, identity BLOB NOT NULL,
active INTEGER NOT NULL, added_at INTEGER NOT NULL);
-- Accept tokens received from correspondents, filed under the address that
-- issued them: an address outlives the keys behind it, so a token keeps
-- working across the issuer's rotations (sec 5.8).
CREATE TABLE IF NOT EXISTS tokens (
address TEXT PRIMARY KEY, token BLOB NOT NULL, seen_at INTEGER NOT NULL);
-- Every id ever fetched, so an envelope resent after we deleted it locally
-- is not stored again (sec 10).
CREATE TABLE IF NOT EXISTS seen (id BLOB PRIMARY KEY, at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS inbox (
id BLOB PRIMARY KEY, envelope BLOB NOT NULL,
received_at INTEGER NOT NULL, tier INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sent (
id BLOB PRIMARY KEY, recipient TEXT NOT NULL,
envelope BLOB NOT NULL, sent_at INTEGER NOT NULL);
";
/// One sealed message in a folder, still encrypted.
pub struct Stored {
pub id: [u8; ID_LEN],
pub envelope: Vec<u8>,
pub at: i64,
/// Only sent copies carry a recipient.
pub recipient: Option<String>,
}
pub struct Store {
db: Connection,
}
/// A contact row: address, identity, verified, and its acceptance state
/// (None when never accepted) for `fumi contacts`.
pub type ContactRow = (String, [u8; KEY_LEN], bool, Option<i64>);
impl Store {
pub fn open(path: &Path) -> Result<Store, SmolError> {
let db = Connection::open(path)?;
db.execute_batch(SCHEMA)?;
Ok(Store { db })
}
fn one<T>(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Option<T>, SmolError>
where
T: rusqlite::types::FromSql,
{
Ok(
match self.db.query_row(sql, params, |row| row.get::<_, T>(0)) {
Ok(value) => Some(value),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(e.into()),
},
)
}
/// The home address, when this identity has been registered or restored.
pub fn account(&self) -> Result<Option<Address>, SmolError> {
let row = self.db.query_row(
"SELECT username, host, port, scheme FROM state WHERE id = 1",
[],
|row| {
Ok((
row.get::<_, Option<String>>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, Option<i64>>(2)?,
row.get::<_, Option<String>>(3)?,
))
},
);
let (username, host, port, scheme) = match row {
Ok(tuple) => tuple,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(e.into()),
};
let (Some(username), Some(host)) = (username, host) else {
return Ok(None);
};
let scheme = scheme.unwrap_or_else(|| "tcp".to_string());
let port = port.unwrap_or(crate::address::DEFAULT_PORT as i64) as u16;
let text = if scheme == "rns" {
format!("smol+rns://{username}@{host}")
} else {
format!("{username}@{host}")
};
let mut addr = Address::parse(&text)
.map_err(|e| SmolError(format!("stored account is not parseable: {e}")))?;
if addr.scheme == crate::address::Scheme::Tcp {
addr.port = port;
}
Ok(Some(addr))
}
pub fn set_account(&self, addr: &Address) -> Result<(), SmolError> {
let scheme = match addr.scheme {
crate::address::Scheme::Tcp => "tcp",
crate::address::Scheme::Rns => "rns",
};
self.db
.execute(
"UPDATE state SET username = ?1, host = ?2, port = ?3, scheme = ?4 WHERE id = 1",
params![addr.user, addr.host, addr.port, scheme],
)
.map(|_| ())?;
Ok(())
}
pub fn rotations(&self) -> Result<u32, SmolError> {
Ok(self
.one::<i64>("SELECT rotations FROM state WHERE id = 1", &[])?
.unwrap_or(0) as u32)
}
pub fn set_rotations(&self, index: u32) -> Result<(), SmolError> {
self.db
.execute(
"UPDATE state SET rotations = ?1 WHERE id = 1",
params![index],
)
.map(|_| ())?;
Ok(())
}
pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), SmolError> {
let (after_time, after_id): (i64, Vec<u8>) = self
.db
.query_row(
"SELECT after_time, after_id FROM state WHERE id = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(SmolError::from)?;
let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN {
after_id.try_into().unwrap()
} else {
[0u8; ID_LEN]
};
Ok((after_time, after_id))
}
pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), SmolError> {
self.db
.execute(
"UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1",
params![after_time, after_id],
)
.map(|_| ())?;
Ok(())
}
/// Whether the local accept-token set may replace the server's: a client
/// restored from the master alone must not erase it (sec 4).
pub fn sync_ok(&self) -> Result<bool, SmolError> {
Ok(self
.one::<i64>("SELECT sync_ok FROM state WHERE id = 1", &[])?
.unwrap_or(1)
!= 0)
}
pub fn set_sync_ok(&self, ok: bool) -> Result<(), SmolError> {
self.db
.execute(
"UPDATE state SET sync_ok = ?1 WHERE id = 1",
params![ok as i64],
)
.map(|_| ())?;
Ok(())
}
pub fn server_pin(&self, host: &str) -> Result<Option<[u8; KEY_LEN]>, SmolError> {
Ok(self
.one::<Option<Vec<u8>>>("SELECT static FROM servers WHERE host = ?1", &[&host])?
.flatten()
.and_then(|k| k.try_into().ok()))
}
pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), SmolError> {
self.db
.execute(
"INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \
ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3",
params![host, key, now()],
)
.map(|_| ())?;
Ok(())
}
pub fn contact(&self, address: &str) -> Result<Option<([u8; KEY_LEN], bool)>, SmolError> {
let row = self.db.query_row(
"SELECT identity, verified FROM contacts WHERE address = ?1",
[&address],
|row| Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?)),
);
match row {
Ok((identity, verified)) => Ok(Some((
identity
.try_into()
.map_err(|_| SmolError::new("stored contact identity is not 32 bytes"))?,
verified != 0,
))),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save_contact(
&self,
address: &str,
identity: &[u8; KEY_LEN],
verified: bool,
) -> Result<(), SmolError> {
self.db
.execute(
"INSERT INTO contacts (address, identity, verified, seen_at) VALUES (?1, ?2, ?3, ?4) \
ON CONFLICT (address) DO UPDATE SET identity = ?2, verified = ?3, seen_at = ?4",
params![address, identity, verified as i64, now()],
)
.map(|_| ())?;
Ok(())
}
/// Every contact with its acceptance state, for `fumi contacts`.
pub fn contact_rows(&self) -> Result<Vec<ContactRow>, SmolError> {
let mut stmt = self.db.prepare(
"SELECT c.address, c.identity, c.verified, a.active FROM contacts c \
LEFT JOIN accepted a ON a.address = c.address ORDER BY c.address",
)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Vec<u8>>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, Option<i64>>(3)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows
.into_iter()
.filter_map(|(address, identity, verified, active)| {
Some((address, identity.try_into().ok()?, verified != 0, active))
})
.collect())
}
/// The accept tokens to push with AUTH, and whether to push at all (sec 4).
pub fn token_set(&self, account: &Account) -> Result<(u8, Vec<[u8; 32]>), SmolError> {
if !self.sync_ok()? {
return Ok((0, Vec::new()));
}
let mut stmt = self
.db
.prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?;
let tokens = stmt
.query_map([], |row| row.get::<_, Vec<u8>>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok((
1,
tokens
.iter()
.filter_map(|identity| {
let identity: [u8; KEY_LEN] = identity.as_slice().try_into().ok()?;
Some(account.token_for(&identity))
})
.collect(),
))
}
/// The address we know a signer by: a contact, or the Reply-To it signed
/// for itself. Naming a mailbox is not trusting a key, so nothing is
/// pinned here (sec 5.7, sec 8).
pub fn address_of(
&self,
sender: &[u8],
reply_to: Option<&str>,
) -> Result<Option<String>, SmolError> {
let mut stmt = self.db.prepare("SELECT address, identity FROM contacts")?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
for (address, identity) in rows {
if ct_eq(&identity, sender) {
return Ok(Some(address));
}
}
if let Some(uri) = reply_to {
if let Ok(claimed) = Address::parse(uri) {
if claimed.identity.is_some_and(|key| ct_eq(&key, sender)) {
return Ok(Some(claimed.short()));
}
}
}
Ok(None)
}
/// Admits a correspondent to the main tier, freezing the identity the
/// token is derived from (sec 5.8): on conflict the identity is left
/// alone, so the token stays the one they already hold.
pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), SmolError> {
self.db
.execute(
"INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \
ON CONFLICT (address) DO UPDATE SET active = 1",
params![address, identity, now()],
)
.map(|_| ())?;
self.set_sync_ok(true)?;
Ok(())
}
pub fn block(&self, address: &str) -> Result<bool, SmolError> {
let changed = self.db.execute(
"UPDATE accepted SET active = 0 WHERE address = ?1",
params![address],
)?;
Ok(changed > 0)
}
/// The identity frozen at acceptance, if this address is currently
/// accepted; the token for our own mailbox travels in the next message.
pub fn accepted_identity(&self, address: &str) -> Result<Option<[u8; KEY_LEN]>, SmolError> {
let identity: Option<Vec<u8>> = self.one(
"SELECT identity FROM accepted WHERE address = ?1 AND active = 1",
&[&address],
)?;
Ok(identity.and_then(|identity| identity.try_into().ok()))
}
/// A token a correspondent issued us, filed under their address (sec 5.8).
pub fn token_of(&self, address: &str) -> Result<Option<[u8; 32]>, SmolError> {
let token: Option<Vec<u8>> =
self.one("SELECT token FROM tokens WHERE address = ?1", &[&address])?;
Ok(token.and_then(|token| token.try_into().ok()))
}
pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), SmolError> {
self.db
.execute(
"INSERT INTO tokens (address, token, seen_at) VALUES (?1, ?2, ?3) \
ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3",
params![address, token, now()],
)
.map(|_| ())?;
Ok(())
}
pub fn seen(&self, id: &[u8; ID_LEN]) -> Result<bool, SmolError> {
Ok(self
.one::<i64>("SELECT 1 FROM seen WHERE id = ?1", &[id])?
.is_some())
}
pub fn store_inbox(
&self,
id: &[u8; ID_LEN],
envelope: &[u8],
received_at: i64,
requests_tier: bool,
) -> Result<(), SmolError> {
let tier = if requests_tier {
TIER_REQUESTS
} else {
TIER_MAIN
};
self.db
.execute(
"INSERT OR IGNORE INTO inbox (id, envelope, received_at, tier) VALUES (?1, ?2, ?3, ?4)",
params![id, envelope, received_at, tier],
)
.map(|_| ())?;
self.db
.execute(
"INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)",
params![id, received_at],
)
.map(|_| ())?;
Ok(())
}
pub fn store_sent(
&self,
id: &[u8; ID_LEN],
recipient: &str,
envelope: &[u8],
sent_at: i64,
) -> Result<(), SmolError> {
self.db
.execute(
"INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)",
params![id, recipient, envelope, sent_at],
)
.map(|_| ())?;
Ok(())
}
/// One folder, ordered by arrival or sending time. `folder` is "inbox",
/// "requests", "sent" or "all".
pub fn mail(&self, folder: &str) -> Result<Vec<Stored>, SmolError> {
let (sql, tier): (&str, Option<i8>) = match folder {
"sent" => (
"SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id",
None,
),
"all" => (
"SELECT id, envelope, received_at, NULL FROM inbox ORDER BY received_at, id",
None,
),
"requests" => (
"SELECT id, envelope, received_at, NULL FROM inbox \
WHERE tier = ?1 ORDER BY received_at, id",
Some(TIER_REQUESTS as i8),
),
_ => (
"SELECT id, envelope, received_at, NULL FROM inbox \
WHERE tier = ?1 ORDER BY received_at, id",
Some(TIER_MAIN as i8),
),
};
let mut stmt = self.db.prepare(sql)?;
let rows = match tier {
Some(t) => stmt
.query_map(params![t], row_stored)?
.collect::<Result<Vec<_>, _>>()?,
None => stmt
.query_map([], row_stored)?
.collect::<Result<Vec<_>, _>>()?,
};
Ok(rows)
}
}
fn row_stored(row: &rusqlite::Row<'_>) -> rusqlite::Result<Stored> {
Ok(Stored {
id: row.get::<_, Vec<u8>>(0)?.try_into().unwrap(),
envelope: row.get(1)?,
at: row.get(2)?,
recipient: row.get(3)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{identity_seed, Account};
fn master() -> [u8; 32] {
core::array::from_fn(|i| i as u8)
}
fn temp_store(tag: &str) -> Store {
let path = std::env::temp_dir().join(format!(
"fumi-store-{tag}-{}-{}.db",
std::process::id(),
now()
));
Store::open(&path).expect("open store")
}
#[test]
fn account_state_round_trip() {
let store = temp_store("account");
assert!(store.account().unwrap().is_none());
let addr = Address::parse("alice@example.org:1962").unwrap();
store.set_account(&addr).unwrap();
let back = store.account().unwrap().unwrap();
assert_eq!(back.short(), "alice@example.org:1962");
assert_eq!(back.port, 1962);
let rns = Address::parse("smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a").unwrap();
store.set_account(&rns).unwrap();
let back = store.account().unwrap().unwrap();
assert_eq!(back.scheme, crate::address::Scheme::Rns);
assert_eq!(back.short(), "alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a");
}
#[test]
fn rotation_index_and_cursor_persist() {
let store = temp_store("state");
assert_eq!(store.rotations().unwrap(), 0);
store.set_rotations(3).unwrap();
assert_eq!(store.rotations().unwrap(), 3);
assert_eq!(store.cursor().unwrap(), (0, [0u8; 32]));
store.set_cursor(17, &[9u8; 32]).unwrap();
assert_eq!(store.cursor().unwrap(), (17, [9u8; 32]));
// A short cursor blob (a fresh store's x'') reads as the start.
}
#[test]
fn sync_ok_gates_the_token_set() {
let store = temp_store("sync");
let account = Account::new(master(), 0).unwrap();
let key = Account::new(master(), 1).unwrap().me().pk();
store.accept("bob@example.org", &key).unwrap();
// Accepting turns sync on and pushes exactly one derived token.
assert!(store.sync_ok().unwrap());
let (sync, tokens) = store.token_set(&account).unwrap();
assert_eq!(sync, 1);
assert_eq!(tokens, vec![account.token_for(&key)]);
// A restored client must not erase the server's set (sec 4).
store.set_sync_ok(false).unwrap();
let (sync, tokens) = store.token_set(&account).unwrap();
assert_eq!(sync, 0);
assert!(tokens.is_empty());
}
#[test]
fn accept_freezes_identity_across_conflicts() {
let store = temp_store("accept");
let old = Account::new(master(), 1).unwrap().me().pk();
let new = Account::new(master(), 2).unwrap().me().pk();
store.accept("bob@example.org", &old).unwrap();
store.accept("bob@example.org", &new).unwrap();
assert_eq!(
store.accepted_identity("bob@example.org").unwrap(),
Some(old)
);
assert!(store.block("bob@example.org").unwrap());
assert_eq!(store.accepted_identity("bob@example.org").unwrap(), None);
// Blocking again is a harmless no-op; only a never-accepted address
// is an error, which the caller reports.
assert!(store.block("bob@example.org").unwrap());
assert!(!store.block("nobody@example.org").unwrap());
}
#[test]
fn address_of_matches_contacts_then_reply_to() {
let store = temp_store("address-of");
let bob = Account::new(master(), 1).unwrap().me().pk();
store.save_contact("bob@example.org", &bob, true).unwrap();
assert_eq!(
store.address_of(&bob, None).unwrap().as_deref(),
Some("bob@example.org")
);
// A Reply-To only names a signer whose key it carries (sec 5.7).
let carol = Account::new(master(), 2).unwrap().me().pk();
let uri = format!("smol://carol@example.org/{}", crate::crypto::b32(&carol));
assert_eq!(
store.address_of(&carol, Some(&uri)).unwrap().as_deref(),
Some("carol@example.org")
);
// Key mismatch: ordinary text, no address learned.
assert_eq!(
store
.address_of(
&carol,
Some(&format!(
"smol://mallory@example.org/{}",
crate::crypto::b32(&bob)
))
)
.unwrap(),
None
);
}
#[test]
fn inbox_and_sent_store_sealed_and_deduplicate() {
let store = temp_store("mail");
let id = [3u8; 32];
store.store_inbox(&id, b"envelope", 100, false).unwrap();
// A replayed id is not re-stored (sec 10).
store.store_inbox(&id, b"envelope2", 100, true).unwrap();
assert!(store.seen(&id).unwrap());
let inbox = store.mail("inbox").unwrap();
assert_eq!(inbox.len(), 1);
assert_eq!(inbox[0].envelope, b"envelope");
assert_eq!(inbox[0].at, 100);
store
.store_sent(&id, "bob@example.org", b"sent-copy", 200)
.unwrap();
let sent = store.mail("sent").unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].recipient.as_deref(), Some("bob@example.org"));
}
#[test]
fn pins_and_contacts_round_trip() {
let store = temp_store("pins");
assert!(store.server_pin("example.org").unwrap().is_none());
store.pin_server("example.org", &[5u8; 32]).unwrap();
assert_eq!(store.server_pin("example.org").unwrap(), Some([5u8; 32]));
store.pin_server("example.org", &[6u8; 32]).unwrap();
assert_eq!(store.server_pin("example.org").unwrap(), Some([6u8; 32]));
let key = Account::new(master(), 1).unwrap().me().pk();
assert!(store.contact("bob@example.org").unwrap().is_none());
store.save_contact("bob@example.org", &key, false).unwrap();
assert_eq!(
store.contact("bob@example.org").unwrap(),
Some((key, false))
);
store.save_contact("bob@example.org", &key, true).unwrap();
assert_eq!(store.contact("bob@example.org").unwrap(), Some((key, true)));
}
#[test]
fn identity_seed_is_derived_not_stored() {
// A client must be able to re-derive every superseded key (sec 7).
let account = Account::new(master(), 4).unwrap();
assert_eq!(account.keys().len(), 5);
for (n, key) in account.keys().iter().enumerate() {
assert_eq!(
key.pk(),
Account::new(master(), n as u32).unwrap().me().pk()
);
let _ = identity_seed(&master(), n as u32);
}
}
}

177
src/tcp.rs Normal file
View file

@ -0,0 +1,177 @@
//! The TCP carrier: a Noise_NX initiator session with server-key pinning
//! layered on top (SPEC.md sec 4), and the framed transport over it.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use snow::{Builder, TransportState};
use crate::crypto::{b32, ct_eq, KEY_LEN};
use crate::error::SmolError;
use crate::transport::{
Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE,
};
/// A pinned-key mismatch is a hard abort: the discrepancy is surfaced, never
/// clicked through, because the pin is the entire trust model.
pub struct TcpTransport {
stream: TcpStream,
noise: TransportState,
buf: Vec<u8>,
bind: TransportBindValues,
pinned: bool,
host: String,
}
impl TcpTransport {
/// Runs the Noise_NX handshake as the initiator. The NX pattern has the
/// server transmit its static key during the handshake, so pinning is a
/// single code path: an existing pin must match, an absent one is
/// accepted but reported as unpinned by `pinned()`.
pub fn connect(
host: &str,
port: u16,
pinned: Option<[u8; KEY_LEN]>,
timeout: u64,
) -> anyhow::Result<TcpTransport> {
let addr = (host, port);
let stream = TcpStream::connect(addr)
.map_err(|e| anyhow::anyhow!("cannot reach {host}:{port}: {e}"))?;
stream.set_read_timeout(Some(Duration::from_secs(timeout)))?;
stream.set_write_timeout(Some(Duration::from_secs(timeout)))?;
let mut stream = stream;
let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?;
let mut noise = Builder::new(params).prologue(PROLOGUE).build_initiator()?;
let mut buf = [0u8; 65535];
let len = noise.write_message(&[], &mut buf)?;
write_u16_len(&mut stream, &buf[..len])?;
let len = read_u16_len(&mut stream)?;
let mut message = vec![0u8; len];
stream.read_exact(&mut message)?;
noise.read_message(&message, &mut buf)?;
if !noise.is_handshake_finished() {
anyhow::bail!("handshake did not complete");
}
let server_static: [u8; KEY_LEN] = noise
.get_remote_static()
.ok_or_else(|| anyhow::anyhow!("server sent no static key"))?
.try_into()?;
let handshake_hash = noise.get_handshake_hash().to_vec();
let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?;
let transport = noise.into_transport_mode()?;
if let Some(pinned) = pinned {
if !ct_eq(&pinned, &server_static) {
return Err(anyhow::anyhow!(
"{host} presented a different key than the one pinned\n pinned: {}\n \
presented: {}",
b32(&pinned),
b32(&server_static)
));
}
}
Ok(TcpTransport {
stream,
noise: transport,
buf: Vec::new(),
bind,
pinned: pinned.is_some(),
host: host.to_string(),
})
}
/// The server's Noise static key, what REGISTER binds its proof of
/// possession to and what `trust` pins.
pub fn server_static(&self) -> &[u8; KEY_LEN] {
&self.bind.server_static
}
pub fn host(&self) -> &str {
&self.host
}
fn read_noise(&mut self) -> Result<Vec<u8>, SmolError> {
let len = read_u16_len(&mut self.stream)?;
let mut ciphertext = vec![0u8; len];
self.stream.read_exact(&mut ciphertext)?;
let mut plaintext = vec![0u8; len];
let n = self.noise.read_message(&ciphertext, &mut plaintext)?;
plaintext.truncate(n);
Ok(plaintext)
}
fn write_noise(&mut self, payload: &[u8]) -> Result<(), SmolError> {
let mut packet = vec![0u8; payload.len() + 16];
let n = self.noise.write_message(payload, &mut packet)?;
packet.truncate(n);
write_u16_len(&mut self.stream, &packet)?;
Ok(())
}
}
impl Transport for TcpTransport {
/// Sends one application frame (u32 length || op || body, split across
/// as many Noise messages as it needs) and reads the response frame,
/// returning its status byte and body (sec 4, sec 6.1).
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, SmolError> {
let length = 1 + body.len();
if length > MAX_FRAME {
return Err(SmolError::new("request exceeds the maximum frame size"));
}
let mut frame = Vec::with_capacity(4 + length);
frame.extend_from_slice(&(length as u32).to_be_bytes());
frame.push(op);
frame.extend_from_slice(body);
for chunk in frame.chunks(NOISE_PAYLOAD) {
self.write_noise(chunk)?;
}
while self.buf.len() < 5 {
let chunk = self.read_noise()?;
self.buf.extend_from_slice(&chunk);
}
let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
if length == 0 || length > MAX_FRAME {
return Err(SmolError::new(format!(
"server sent a frame of length {length}"
)));
}
while self.buf.len() < 4 + length {
let chunk = self.read_noise()?;
self.buf.extend_from_slice(&chunk);
}
// Frame layout: u32 length || op (echoed) || status || payload.
let status = self.buf[5];
let body: Vec<u8> = self.buf.drain(..4 + length).skip(6).collect();
Ok(Response { status, body })
}
fn bind(&self) -> &TransportBindValues {
&self.bind
}
fn pinned(&self) -> bool {
self.pinned
}
fn close(&mut self) {
let _ = self.stream.shutdown(std::net::Shutdown::Both);
}
}
fn read_u16_len(stream: &mut TcpStream) -> std::io::Result<usize> {
let mut len_buf = [0u8; 2];
stream.read_exact(&mut len_buf)?;
Ok(u16::from_be_bytes(len_buf) as usize)
}
fn write_u16_len(stream: &mut TcpStream, packet: &[u8]) -> std::io::Result<()> {
stream.write_all(&(packet.len() as u16).to_be_bytes())?;
stream.write_all(packet)
}

210
src/transport.rs Normal file
View file

@ -0,0 +1,210 @@
//! The transport boundary: operation and status constants, the bind values
//! AUTH and REGISTER signatures depend on, and the trait both carriers
//! implement. Only the bind values and the framing differ between them; every
//! operation body is byte-identical (RNS.md sec 13.5, sec 15).
// Used only by the RNS bind values and their tests.
#[cfg(any(test, feature = "rns"))]
use crate::crypto::sha256;
use crate::error::SmolError;
pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256";
pub const PROLOGUE: &[u8] = b"smolmail/1";
pub const LABEL_AUTH: &[u8] = b"smolmail/1 auth";
pub const LABEL_ID: &[u8] = b"smolmail/1 id";
pub const LABEL_SEAL: &[u8] = b"smolmail/1 seal";
pub const LABEL_MSG: &[u8] = b"smolmail/1 msg";
pub const LABEL_MAC: &[u8] = b"smolmail/1 mac";
pub const LABEL_ROTATE: &[u8] = b"smolmail/1 rotate";
pub const LABEL_REGISTER: &[u8] = b"smolmail/1 register";
pub const LABEL_IDENTITY: &[u8] = b"smolmail/1 identity";
pub const LABEL_ACCEPT: &[u8] = b"smolmail/1 accept";
/// RNS.md sec 14: added in 1.2 for the link binding of sec 13.6.
#[cfg(any(test, feature = "rns"))]
pub const LABEL_BIND: &[u8] = b"smolmail/1 bind";
pub const OP_AUTH: u8 = 0x00;
pub const OP_RESOLVE: u8 = 0x01;
pub const OP_SEND: u8 = 0x02;
pub const OP_FETCH: u8 = 0x03;
pub const OP_DELETE: u8 = 0x04;
pub const OP_REGISTER: u8 = 0x05;
pub const ENVELOPE_MAGIC: &[u8; 4] = b"SMOL";
pub const ENVELOPE_VERSION: u8 = 1;
pub const ENVELOPE_HEADER: usize = 69; // magic 4 + version 1 + to 32 + epk 32
pub const ENVELOPE_MIN: usize = ENVELOPE_HEADER + 16; // + Poly1305 tag
pub const PAYLOAD_HEADER: usize = 45; // version 1 + sender 32 + time 8 + body_len 4
pub const KEY_LEN: usize = 32;
pub const ID_LEN: usize = 32;
pub const SIG_LEN: usize = 64;
pub const CERT_LEN: usize = 200; // old_pub 32 + new_pub 32 + time 8 + sig_old 64 + sig_new 64
pub const TOKEN_LEN: usize = 32;
pub const MAX_CHAIN: usize = 16;
pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling (sec 4)
pub const NOISE_PAYLOAD: usize = 65535 - 16; // Noise message ceiling minus the tag
/// Tiers a fetched message can land in (sec 5.8).
pub const TIER_MAIN: u8 = 0;
pub const TIER_REQUESTS: u8 = 1;
/// flags bit 0: the message arrived without a matching accept token.
pub const FLAG_REQUESTS: u8 = 0x01;
/// A parsed response frame: the status byte and everything after it.
pub struct Response {
pub status: u8,
pub body: Vec<u8>,
}
/// Fail-closed reader over a response body. Every parse path errors rather
/// than reading past the end, so a truncated response can never be mistaken
/// for a short but valid one.
pub struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(buf: &'a [u8]) -> Self {
Reader { buf, pos: 0 }
}
pub fn take(&mut self, n: usize) -> Result<&'a [u8], SmolError> {
if self.pos + n > self.buf.len() {
return Err(SmolError::new("truncated response from server"));
}
let out = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(out)
}
pub fn u8(&mut self) -> Result<u8, SmolError> {
Ok(self.take(1)?[0])
}
pub fn u16(&mut self) -> Result<u16, SmolError> {
let b = self.take(2)?;
Ok(u16::from_be_bytes([b[0], b[1]]))
}
pub fn u32(&mut self) -> Result<u32, SmolError> {
let b = self.take(4)?;
Ok(u32::from_be_bytes(b.try_into().unwrap()))
}
pub fn i64(&mut self) -> Result<i64, SmolError> {
let b = self.take(8)?;
Ok(i64::from_be_bytes(b.try_into().unwrap()))
}
pub fn rest(&mut self) -> &'a [u8] {
let out = &self.buf[self.pos..];
self.pos = self.buf.len();
out
}
pub fn done(&self) -> Result<(), SmolError> {
if self.pos != self.buf.len() {
return Err(SmolError::new("trailing bytes in response"));
}
Ok(())
}
}
/// Transport-supplied values that AUTH and REGISTER signatures bind to. Both
/// carriers prove the same thing — that the peer holds the identity key and
/// is talking to this server, not a replayed capture of another — but the
/// inputs differ (RNS.md sec 13.6), so the session layer consumes this
/// struct instead of a Noise handshake hash.
pub struct TransportBindValues {
/// What AUTH signs: the Noise handshake hash, or its RNS substitute.
pub h: [u8; 32],
/// What REGISTER signs: the server's static key, or its RNS substitute.
pub server_static: [u8; 32],
}
impl TransportBindValues {
/// Noise binds to the handshake hash and the server's real static key.
pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> anyhow::Result<Self> {
Ok(Self {
h: handshake_hash
.try_into()
.map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?,
server_static: *server_static,
})
}
/// RNS has no static key of ours on the wire, so both values are derived
/// from the destination; link_id keeps one link's AUTH from replaying on
/// another (RNS.md sec 13.6). Neither input is length-prefixed, and both
/// are fixed-width, so concatenation stays unambiguous.
#[cfg(any(test, feature = "rns"))]
pub fn rns(destination: &[u8; 16], link_id: &[u8; 16]) -> Self {
Self {
h: sha256(&[LABEL_BIND, &destination[..], &link_id[..]]),
server_static: sha256(&[LABEL_BIND, &destination[..]]),
}
}
}
/// One request/response exchange per carrier. `request` sends one application
/// frame and returns the peer's answer; `bind` supplies the values the
/// operation layer signs over; `pinned` reports whether this session's
/// server identity came from a trusted channel, which decides whether
/// RESOLVE results may be marked verified (sec 4, RNS.md sec 13.4).
pub trait Transport {
fn request(&mut self, op: u8, body: &[u8]) -> Result<Response, SmolError>;
fn bind(&self) -> &TransportBindValues;
fn pinned(&self) -> bool;
fn close(&mut self);
}
#[cfg(test)]
mod tests {
use super::*;
// Vectors computed independently over the RNS.md sec 13.6 formula
// SHA-256("smolmail/1 bind" || destination || link_id); shared with
// bunshin, whose server verifies what this client produces.
const DEST: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f,
];
const LINK: [u8; 16] = [
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae,
0xaf,
];
const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c";
const SERVER_STATIC_HEX: &str =
"da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a";
#[test]
fn rns_matches_reference_vectors() {
let bind = TransportBindValues::rns(&DEST, &LINK);
assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX);
assert_eq!(
data_encoding::HEXLOWER.encode(&bind.server_static),
SERVER_STATIC_HEX
);
}
#[test]
fn rns_h_differs_per_link_but_server_static_does_not() {
let other = [0xc0u8; 16];
let a = TransportBindValues::rns(&DEST, &LINK);
let b = TransportBindValues::rns(&DEST, &other);
assert_ne!(a.h, b.h);
assert_eq!(a.server_static, b.server_static);
}
#[test]
fn tcp_rejects_short_handshake_hash() {
let static_key = [7u8; KEY_LEN];
assert!(TransportBindValues::tcp(&[1u8; 31], &static_key).is_err());
let bind = TransportBindValues::tcp(&[2u8; 32], &static_key).unwrap();
assert_eq!(bind.h, [2u8; 32]);
assert_eq!(bind.server_static, static_key);
}
}