diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..22f28a7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/target
+*.db
+*.db-wal
+*.db-shm
+identity.key
+*.key
+result
+result-*
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9141aab
--- /dev/null
+++ b/AGENTS.md
@@ -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
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..4059476
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,1027 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cc"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "typenum",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "env_filter"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
+dependencies = [
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "env_logger"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "env_filter",
+ "jiff",
+ "log",
+]
+
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "fumi"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "cc",
+ "chacha20poly1305",
+ "clap",
+ "data-encoding",
+ "ed25519-dalek",
+ "env_logger",
+ "hkdf",
+ "hmac",
+ "log",
+ "rand_core",
+ "rusqlite",
+ "sha2",
+ "snow",
+ "x25519-dalek",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "jiff"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.13.2",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "snow"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85"
+dependencies = [
+ "aes-gcm",
+ "blake2",
+ "chacha20poly1305",
+ "curve25519-dalek",
+ "rand_core",
+ "rustc_version",
+ "sha2",
+ "subtle",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "x25519-dalek"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core",
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..2533bbb
--- /dev/null
+++ b/Cargo.toml
@@ -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 }
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..2308c6c
--- /dev/null
+++ b/PLAN.md
@@ -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
` 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]
+
+# identity
+fumi keygen [--force] create a master secret
+fumi whoami address, public key, fingerprint, rotation index
+fumi restore recover local state from the master alone
+fumi rotate advance the rotation index, sign and push the certificate
+
+# servers and contacts
+fumi trust [--force] pin a server's Noise static key (TCP only)
+fumi register [--invite TOKEN] bind this identity to a username
+fumi resolve look up a contact's key, walk the chain, pin it
+fumi import add a contact from a self-certifying address
+fumi contacts known keys and how each was learned
+
+# accept tokens
+fumi accept issue a token, admit to the main tier, sync the set
+fumi block withdraw it, sync the set
+
+# mail
+fumi send [--subject S] [--body TEXT | --file F | -] [--header K:V]
+ [--reply-to ID] [--anonymous] [--no-pad]
+fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
+fumi delete ... remove from the server explicitly
+fumi list [--sent] [--requests]
+fumi read [--sent]
+
+# RNS carrier (feature "rns"); addresses select it by scheme
+fumi --rns-udp --rns-udp-forward [--rns-storage DIR]
+```
+
+`trust` takes a host rather than a full address because a pin is per server, not per user. `register ` 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 ` patch upstream master needs under current libstdc++, configures cmake with every `RNS__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`
diff --git a/README.md b/README.md
index 0524410..3b0e551 100644
--- a/README.md
+++ b/README.md
@@ -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]
+
+# identity
+fumi keygen [--force] create a master secret
+fumi whoami address, public key, fingerprint, rotation index
+fumi restore recover local state from the master alone
+fumi rotate advance the rotation index, sign and push the certificate
+
+# servers and contacts
+fumi trust [--force] pin a server's Noise static key
+fumi register [--invite TOKEN] bind this identity to a username
+fumi resolve look up a contact's key, walk the chain, pin it
+fumi import add a contact from a self-certifying address
+fumi contacts known keys and how each was learned
+
+# accept tokens
+fumi accept issue a token, admit to the main tier, sync the set
+fumi block withdraw it, sync the set
+
+# mail
+fumi send [--subject S] [--body TEXT | --file F | -] [--header K:V] [--reply-to ID] [--anonymous] [--no-pad]
+fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
+fumi delete ... remove from the server explicitly
+fumi list [--sent] [--requests]
+fumi read [--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
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..ed130fd
--- /dev/null
+++ b/flake.lock
@@ -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
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..496112d
--- /dev/null
+++ b/flake.nix
@@ -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 ];
+ };
+ });
+}
diff --git a/src/account.rs b/src/account.rs
new file mode 100644
index 0000000..1882c9e
--- /dev/null
+++ b/src/account.rs
@@ -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,
+}
+
+impl Account {
+ pub fn new(master: [u8; KEY_LEN], index: u32) -> Result {
+ 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 {
+ 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 {
+ 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 {
+ 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"));
+ }
+}
diff --git a/src/address.rs b/src/address.rs
new file mode 100644
index 0000000..fbfb46f
--- /dev/null
+++ b/src/address.rs
@@ -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@` 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 {
+ 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| {
+ 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::()
+ .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());
+ }
+}
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..d71176e
--- /dev/null
+++ b/src/client.rs
@@ -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,
+ 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 {
+ 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 {} ",
+ 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, 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 = 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 {
+ 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 {
+ 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 {
+ let Some(addr) = store.account()? else {
+ return Err(SmolError::new(
+ "not registered; run: fumi register ",
+ ));
+ };
+ 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 {
+ 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,
+}
+
+/// 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 {
+ 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,
+ /// 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 {
+ let Some(addr) = store.account()? else {
+ return Err(SmolError::new(
+ "not registered; run: fumi register ",
+ ));
+ };
+ 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 {
+ 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 {
+ 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 {
+ let Some(addr) = store.account()? else {
+ return Err(SmolError::new(
+ "not registered; run: fumi register ",
+ ));
+ };
+ 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,
+ 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 {
+ 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 {
+ [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 = (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 = (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(®ister_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(),
+ ®ister,
+ ®ister_signed(&bind.server_static, "alice", &me.pk())
+ ));
+ }
+}
diff --git a/src/crypto.rs b/src/crypto.rs
new file mode 100644
index 0000000..7ef3a7a
--- /dev/null
+++ b/src/crypto.rs
@@ -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 = 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, 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::>()
+ .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 {
+ let mut okm = vec![0u8; len];
+ let _ = Hkdf::::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::::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"));
+ }
+}
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..b17554f
--- /dev/null
+++ b/src/error.rs
@@ -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) -> 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 for SmolError {
+ fn from(e: anyhow::Error) -> Self {
+ SmolError(e.to_string())
+ }
+}
+
+impl From for SmolError {
+ fn from(e: rusqlite::Error) -> Self {
+ SmolError(format!("storage error: {e}"))
+ }
+}
+
+impl From for SmolError {
+ fn from(e: std::io::Error) -> Self {
+ SmolError(e.to_string())
+ }
+}
+
+impl From 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})")))
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..deb239c
--- /dev/null
+++ b/src/lib.rs
@@ -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;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..7a2202f
--- /dev/null
+++ b/src/main.rs
@@ -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,
+ },
+ /// 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,
+ /// Message text; stdin is read when neither --body nor --file is given
+ #[arg(long)]
+ body: Option,
+ /// Read the message text from this file ("-" for stdin)
+ #[arg(long)]
+ file: Option,
+ /// Extra `Key: value` frontmatter field; repeatable
+ #[arg(long = "header", value_name = "KEY:VALUE")]
+ headers: Vec,
+ /// The id this message replies to, 64 hex characters
+ #[arg(long = "reply-to", value_name = "ID")]
+ reply_to: Option,
+ /// 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,
+ },
+ /// 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| {
+ 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 {
+ 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| {
+ 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: 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 {
+ 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> {
+ 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::>();
+ 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} ",
+ &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");
+ }
+}
diff --git a/src/message.rs b/src/message.rs
new file mode 100644
index 0000000..aa621d0
--- /dev/null
+++ b/src/message.rs
@@ -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,
+}
+
+/// 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, 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, 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 {
+ 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) {
+ 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::() > 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) -> 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]));
+ }
+}
diff --git a/src/store.rs b/src/store.rs
new file mode 100644
index 0000000..43d5f3f
--- /dev/null
+++ b/src/store.rs
@@ -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,
+ pub at: i64,
+ /// Only sent copies carry a recipient.
+ pub recipient: Option,
+}
+
+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);
+
+impl Store {
+ pub fn open(path: &Path) -> Result {
+ let db = Connection::open(path)?;
+ db.execute_batch(SCHEMA)?;
+ Ok(Store { db })
+ }
+
+ fn one(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result