From 37df976fde1a6c597548e423d17fee50292d29da Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 28 Sep 2026 16:03:53 +0300 Subject: [PATCH] feat: implement Phase 1 TCP client --- .gitignore | 8 + AGENTS.md | 51 +++ Cargo.lock | 1027 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 33 ++ PLAN.md | 408 ++++++++++++++++++ README.md | 435 +++----------------- flake.lock | 61 +++ flake.nix | 37 ++ src/account.rs | 307 ++++++++++++++ src/address.rs | 276 +++++++++++++ src/client.rs | 823 +++++++++++++++++++++++++++++++++++++ src/crypto.rs | 201 +++++++++ src/error.rs | 77 ++++ src/lib.rs | 15 + src/main.rs | 766 ++++++++++++++++++++++++++++++++++ src/message.rs | 443 ++++++++++++++++++++ src/store.rs | 653 +++++++++++++++++++++++++++++ src/tcp.rs | 177 ++++++++ src/transport.rs | 210 ++++++++++ 19 files changed, 5631 insertions(+), 377 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PLAN.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/account.rs create mode 100644 src/address.rs create mode 100644 src/client.rs create mode 100644 src/crypto.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/message.rs create mode 100644 src/store.rs create mode 100644 src/tcp.rs create mode 100644 src/transport.rs 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, SmolError> + where + T: rusqlite::types::FromSql, + { + Ok( + match self.db.query_row(sql, params, |row| row.get::<_, T>(0)) { + Ok(value) => Some(value), + Err(rusqlite::Error::QueryReturnedNoRows) => None, + Err(e) => return Err(e.into()), + }, + ) + } + + /// The home address, when this identity has been registered or restored. + pub fn account(&self) -> Result, SmolError> { + let row = self.db.query_row( + "SELECT username, host, port, scheme FROM state WHERE id = 1", + [], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ); + let (username, host, port, scheme) = match row { + Ok(tuple) => tuple, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let (Some(username), Some(host)) = (username, host) else { + return Ok(None); + }; + let scheme = scheme.unwrap_or_else(|| "tcp".to_string()); + let port = port.unwrap_or(crate::address::DEFAULT_PORT as i64) as u16; + let text = if scheme == "rns" { + format!("smol+rns://{username}@{host}") + } else { + format!("{username}@{host}") + }; + let mut addr = Address::parse(&text) + .map_err(|e| SmolError(format!("stored account is not parseable: {e}")))?; + if addr.scheme == crate::address::Scheme::Tcp { + addr.port = port; + } + Ok(Some(addr)) + } + + pub fn set_account(&self, addr: &Address) -> Result<(), SmolError> { + let scheme = match addr.scheme { + crate::address::Scheme::Tcp => "tcp", + crate::address::Scheme::Rns => "rns", + }; + self.db + .execute( + "UPDATE state SET username = ?1, host = ?2, port = ?3, scheme = ?4 WHERE id = 1", + params![addr.user, addr.host, addr.port, scheme], + ) + .map(|_| ())?; + Ok(()) + } + + pub fn rotations(&self) -> Result { + Ok(self + .one::("SELECT rotations FROM state WHERE id = 1", &[])? + .unwrap_or(0) as u32) + } + + pub fn set_rotations(&self, index: u32) -> Result<(), SmolError> { + self.db + .execute( + "UPDATE state SET rotations = ?1 WHERE id = 1", + params![index], + ) + .map(|_| ())?; + Ok(()) + } + + pub fn cursor(&self) -> Result<(i64, [u8; ID_LEN]), SmolError> { + let (after_time, after_id): (i64, Vec) = self + .db + .query_row( + "SELECT after_time, after_id FROM state WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(SmolError::from)?; + let after_id: [u8; ID_LEN] = if after_id.len() == ID_LEN { + after_id.try_into().unwrap() + } else { + [0u8; ID_LEN] + }; + Ok((after_time, after_id)) + } + + pub fn set_cursor(&self, after_time: i64, after_id: &[u8; ID_LEN]) -> Result<(), SmolError> { + self.db + .execute( + "UPDATE state SET after_time = ?1, after_id = ?2 WHERE id = 1", + params![after_time, after_id], + ) + .map(|_| ())?; + Ok(()) + } + + /// Whether the local accept-token set may replace the server's: a client + /// restored from the master alone must not erase it (sec 4). + pub fn sync_ok(&self) -> Result { + Ok(self + .one::("SELECT sync_ok FROM state WHERE id = 1", &[])? + .unwrap_or(1) + != 0) + } + + pub fn set_sync_ok(&self, ok: bool) -> Result<(), SmolError> { + self.db + .execute( + "UPDATE state SET sync_ok = ?1 WHERE id = 1", + params![ok as i64], + ) + .map(|_| ())?; + Ok(()) + } + + pub fn server_pin(&self, host: &str) -> Result, SmolError> { + Ok(self + .one::>>("SELECT static FROM servers WHERE host = ?1", &[&host])? + .flatten() + .and_then(|k| k.try_into().ok())) + } + + pub fn pin_server(&self, host: &str, key: &[u8; KEY_LEN]) -> Result<(), SmolError> { + self.db + .execute( + "INSERT INTO servers (host, static, pinned_at) VALUES (?1, ?2, ?3) \ + ON CONFLICT (host) DO UPDATE SET static = ?2, pinned_at = ?3", + params![host, key, now()], + ) + .map(|_| ())?; + Ok(()) + } + + pub fn contact(&self, address: &str) -> Result, SmolError> { + let row = self.db.query_row( + "SELECT identity, verified FROM contacts WHERE address = ?1", + [&address], + |row| Ok((row.get::<_, Vec>(0)?, row.get::<_, i64>(1)?)), + ); + match row { + Ok((identity, verified)) => Ok(Some(( + identity + .try_into() + .map_err(|_| SmolError::new("stored contact identity is not 32 bytes"))?, + verified != 0, + ))), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn save_contact( + &self, + address: &str, + identity: &[u8; KEY_LEN], + verified: bool, + ) -> Result<(), SmolError> { + self.db + .execute( + "INSERT INTO contacts (address, identity, verified, seen_at) VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT (address) DO UPDATE SET identity = ?2, verified = ?3, seen_at = ?4", + params![address, identity, verified as i64, now()], + ) + .map(|_| ())?; + Ok(()) + } + + /// Every contact with its acceptance state, for `fumi contacts`. + pub fn contact_rows(&self) -> Result, SmolError> { + let mut stmt = self.db.prepare( + "SELECT c.address, c.identity, c.verified, a.active FROM contacts c \ + LEFT JOIN accepted a ON a.address = c.address ORDER BY c.address", + )?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Option>(3)?, + )) + })? + .collect::, _>>()?; + Ok(rows + .into_iter() + .filter_map(|(address, identity, verified, active)| { + Some((address, identity.try_into().ok()?, verified != 0, active)) + }) + .collect()) + } + + /// The accept tokens to push with AUTH, and whether to push at all (sec 4). + pub fn token_set(&self, account: &Account) -> Result<(u8, Vec<[u8; 32]>), SmolError> { + if !self.sync_ok()? { + return Ok((0, Vec::new())); + } + let mut stmt = self + .db + .prepare("SELECT identity FROM accepted WHERE active = 1 ORDER BY added_at")?; + let tokens = stmt + .query_map([], |row| row.get::<_, Vec>(0))? + .collect::, _>>()?; + Ok(( + 1, + tokens + .iter() + .filter_map(|identity| { + let identity: [u8; KEY_LEN] = identity.as_slice().try_into().ok()?; + Some(account.token_for(&identity)) + }) + .collect(), + )) + } + + /// The address we know a signer by: a contact, or the Reply-To it signed + /// for itself. Naming a mailbox is not trusting a key, so nothing is + /// pinned here (sec 5.7, sec 8). + pub fn address_of( + &self, + sender: &[u8], + reply_to: Option<&str>, + ) -> Result, SmolError> { + let mut stmt = self.db.prepare("SELECT address, identity FROM contacts")?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)) + })? + .collect::, _>>()?; + for (address, identity) in rows { + if ct_eq(&identity, sender) { + return Ok(Some(address)); + } + } + if let Some(uri) = reply_to { + if let Ok(claimed) = Address::parse(uri) { + if claimed.identity.is_some_and(|key| ct_eq(&key, sender)) { + return Ok(Some(claimed.short())); + } + } + } + Ok(None) + } + + /// Admits a correspondent to the main tier, freezing the identity the + /// token is derived from (sec 5.8): on conflict the identity is left + /// alone, so the token stays the one they already hold. + pub fn accept(&self, address: &str, identity: &[u8; KEY_LEN]) -> Result<(), SmolError> { + self.db + .execute( + "INSERT INTO accepted (address, identity, active, added_at) VALUES (?1, ?2, 1, ?3) \ + ON CONFLICT (address) DO UPDATE SET active = 1", + params![address, identity, now()], + ) + .map(|_| ())?; + self.set_sync_ok(true)?; + Ok(()) + } + + pub fn block(&self, address: &str) -> Result { + let changed = self.db.execute( + "UPDATE accepted SET active = 0 WHERE address = ?1", + params![address], + )?; + Ok(changed > 0) + } + + /// The identity frozen at acceptance, if this address is currently + /// accepted; the token for our own mailbox travels in the next message. + pub fn accepted_identity(&self, address: &str) -> Result, SmolError> { + let identity: Option> = self.one( + "SELECT identity FROM accepted WHERE address = ?1 AND active = 1", + &[&address], + )?; + Ok(identity.and_then(|identity| identity.try_into().ok())) + } + + /// A token a correspondent issued us, filed under their address (sec 5.8). + pub fn token_of(&self, address: &str) -> Result, SmolError> { + let token: Option> = + self.one("SELECT token FROM tokens WHERE address = ?1", &[&address])?; + Ok(token.and_then(|token| token.try_into().ok())) + } + + pub fn save_token(&self, address: &str, token: &[u8; 32]) -> Result<(), SmolError> { + self.db + .execute( + "INSERT INTO tokens (address, token, seen_at) VALUES (?1, ?2, ?3) \ + ON CONFLICT (address) DO UPDATE SET token = ?2, seen_at = ?3", + params![address, token, now()], + ) + .map(|_| ())?; + Ok(()) + } + + pub fn seen(&self, id: &[u8; ID_LEN]) -> Result { + Ok(self + .one::("SELECT 1 FROM seen WHERE id = ?1", &[id])? + .is_some()) + } + + pub fn store_inbox( + &self, + id: &[u8; ID_LEN], + envelope: &[u8], + received_at: i64, + requests_tier: bool, + ) -> Result<(), SmolError> { + let tier = if requests_tier { + TIER_REQUESTS + } else { + TIER_MAIN + }; + self.db + .execute( + "INSERT OR IGNORE INTO inbox (id, envelope, received_at, tier) VALUES (?1, ?2, ?3, ?4)", + params![id, envelope, received_at, tier], + ) + .map(|_| ())?; + self.db + .execute( + "INSERT OR IGNORE INTO seen (id, at) VALUES (?1, ?2)", + params![id, received_at], + ) + .map(|_| ())?; + Ok(()) + } + + pub fn store_sent( + &self, + id: &[u8; ID_LEN], + recipient: &str, + envelope: &[u8], + sent_at: i64, + ) -> Result<(), SmolError> { + self.db + .execute( + "INSERT OR IGNORE INTO sent (id, recipient, envelope, sent_at) VALUES (?1, ?2, ?3, ?4)", + params![id, recipient, envelope, sent_at], + ) + .map(|_| ())?; + Ok(()) + } + + /// One folder, ordered by arrival or sending time. `folder` is "inbox", + /// "requests", "sent" or "all". + pub fn mail(&self, folder: &str) -> Result, SmolError> { + let (sql, tier): (&str, Option) = match folder { + "sent" => ( + "SELECT id, envelope, sent_at, recipient FROM sent ORDER BY sent_at, id", + None, + ), + "all" => ( + "SELECT id, envelope, received_at, NULL FROM inbox ORDER BY received_at, id", + None, + ), + "requests" => ( + "SELECT id, envelope, received_at, NULL FROM inbox \ + WHERE tier = ?1 ORDER BY received_at, id", + Some(TIER_REQUESTS as i8), + ), + _ => ( + "SELECT id, envelope, received_at, NULL FROM inbox \ + WHERE tier = ?1 ORDER BY received_at, id", + Some(TIER_MAIN as i8), + ), + }; + let mut stmt = self.db.prepare(sql)?; + let rows = match tier { + Some(t) => stmt + .query_map(params![t], row_stored)? + .collect::, _>>()?, + None => stmt + .query_map([], row_stored)? + .collect::, _>>()?, + }; + Ok(rows) + } +} + +fn row_stored(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Stored { + id: row.get::<_, Vec>(0)?.try_into().unwrap(), + envelope: row.get(1)?, + at: row.get(2)?, + recipient: row.get(3)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::account::{identity_seed, Account}; + + fn master() -> [u8; 32] { + core::array::from_fn(|i| i as u8) + } + + fn temp_store(tag: &str) -> Store { + let path = std::env::temp_dir().join(format!( + "fumi-store-{tag}-{}-{}.db", + std::process::id(), + now() + )); + Store::open(&path).expect("open store") + } + + #[test] + fn account_state_round_trip() { + let store = temp_store("account"); + assert!(store.account().unwrap().is_none()); + let addr = Address::parse("alice@example.org:1962").unwrap(); + store.set_account(&addr).unwrap(); + let back = store.account().unwrap().unwrap(); + assert_eq!(back.short(), "alice@example.org:1962"); + assert_eq!(back.port, 1962); + + let rns = Address::parse("smol+rns://alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a").unwrap(); + store.set_account(&rns).unwrap(); + let back = store.account().unwrap().unwrap(); + assert_eq!(back.scheme, crate::address::Scheme::Rns); + assert_eq!(back.short(), "alice@8f2c1d0a7b6e5f4c3d2a1b0e9f8c7d6a"); + } + + #[test] + fn rotation_index_and_cursor_persist() { + let store = temp_store("state"); + assert_eq!(store.rotations().unwrap(), 0); + store.set_rotations(3).unwrap(); + assert_eq!(store.rotations().unwrap(), 3); + assert_eq!(store.cursor().unwrap(), (0, [0u8; 32])); + store.set_cursor(17, &[9u8; 32]).unwrap(); + assert_eq!(store.cursor().unwrap(), (17, [9u8; 32])); + // A short cursor blob (a fresh store's x'') reads as the start. + } + + #[test] + fn sync_ok_gates_the_token_set() { + let store = temp_store("sync"); + let account = Account::new(master(), 0).unwrap(); + let key = Account::new(master(), 1).unwrap().me().pk(); + store.accept("bob@example.org", &key).unwrap(); + // Accepting turns sync on and pushes exactly one derived token. + assert!(store.sync_ok().unwrap()); + let (sync, tokens) = store.token_set(&account).unwrap(); + assert_eq!(sync, 1); + assert_eq!(tokens, vec![account.token_for(&key)]); + + // A restored client must not erase the server's set (sec 4). + store.set_sync_ok(false).unwrap(); + let (sync, tokens) = store.token_set(&account).unwrap(); + assert_eq!(sync, 0); + assert!(tokens.is_empty()); + } + + #[test] + fn accept_freezes_identity_across_conflicts() { + let store = temp_store("accept"); + let old = Account::new(master(), 1).unwrap().me().pk(); + let new = Account::new(master(), 2).unwrap().me().pk(); + store.accept("bob@example.org", &old).unwrap(); + store.accept("bob@example.org", &new).unwrap(); + assert_eq!( + store.accepted_identity("bob@example.org").unwrap(), + Some(old) + ); + assert!(store.block("bob@example.org").unwrap()); + assert_eq!(store.accepted_identity("bob@example.org").unwrap(), None); + // Blocking again is a harmless no-op; only a never-accepted address + // is an error, which the caller reports. + assert!(store.block("bob@example.org").unwrap()); + assert!(!store.block("nobody@example.org").unwrap()); + } + + #[test] + fn address_of_matches_contacts_then_reply_to() { + let store = temp_store("address-of"); + let bob = Account::new(master(), 1).unwrap().me().pk(); + store.save_contact("bob@example.org", &bob, true).unwrap(); + assert_eq!( + store.address_of(&bob, None).unwrap().as_deref(), + Some("bob@example.org") + ); + // A Reply-To only names a signer whose key it carries (sec 5.7). + let carol = Account::new(master(), 2).unwrap().me().pk(); + let uri = format!("smol://carol@example.org/{}", crate::crypto::b32(&carol)); + assert_eq!( + store.address_of(&carol, Some(&uri)).unwrap().as_deref(), + Some("carol@example.org") + ); + // Key mismatch: ordinary text, no address learned. + assert_eq!( + store + .address_of( + &carol, + Some(&format!( + "smol://mallory@example.org/{}", + crate::crypto::b32(&bob) + )) + ) + .unwrap(), + None + ); + } + + #[test] + fn inbox_and_sent_store_sealed_and_deduplicate() { + let store = temp_store("mail"); + let id = [3u8; 32]; + store.store_inbox(&id, b"envelope", 100, false).unwrap(); + // A replayed id is not re-stored (sec 10). + store.store_inbox(&id, b"envelope2", 100, true).unwrap(); + assert!(store.seen(&id).unwrap()); + let inbox = store.mail("inbox").unwrap(); + assert_eq!(inbox.len(), 1); + assert_eq!(inbox[0].envelope, b"envelope"); + assert_eq!(inbox[0].at, 100); + + store + .store_sent(&id, "bob@example.org", b"sent-copy", 200) + .unwrap(); + let sent = store.mail("sent").unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].recipient.as_deref(), Some("bob@example.org")); + } + + #[test] + fn pins_and_contacts_round_trip() { + let store = temp_store("pins"); + assert!(store.server_pin("example.org").unwrap().is_none()); + store.pin_server("example.org", &[5u8; 32]).unwrap(); + assert_eq!(store.server_pin("example.org").unwrap(), Some([5u8; 32])); + store.pin_server("example.org", &[6u8; 32]).unwrap(); + assert_eq!(store.server_pin("example.org").unwrap(), Some([6u8; 32])); + + let key = Account::new(master(), 1).unwrap().me().pk(); + assert!(store.contact("bob@example.org").unwrap().is_none()); + store.save_contact("bob@example.org", &key, false).unwrap(); + assert_eq!( + store.contact("bob@example.org").unwrap(), + Some((key, false)) + ); + store.save_contact("bob@example.org", &key, true).unwrap(); + assert_eq!(store.contact("bob@example.org").unwrap(), Some((key, true))); + } + + #[test] + fn identity_seed_is_derived_not_stored() { + // A client must be able to re-derive every superseded key (sec 7). + let account = Account::new(master(), 4).unwrap(); + assert_eq!(account.keys().len(), 5); + for (n, key) in account.keys().iter().enumerate() { + assert_eq!( + key.pk(), + Account::new(master(), n as u32).unwrap().me().pk() + ); + let _ = identity_seed(&master(), n as u32); + } + } +} diff --git a/src/tcp.rs b/src/tcp.rs new file mode 100644 index 0000000..54c305e --- /dev/null +++ b/src/tcp.rs @@ -0,0 +1,177 @@ +//! The TCP carrier: a Noise_NX initiator session with server-key pinning +//! layered on top (SPEC.md sec 4), and the framed transport over it. + +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +use snow::{Builder, TransportState}; + +use crate::crypto::{b32, ct_eq, KEY_LEN}; +use crate::error::SmolError; +use crate::transport::{ + Response, Transport, TransportBindValues, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE, +}; + +/// A pinned-key mismatch is a hard abort: the discrepancy is surfaced, never +/// clicked through, because the pin is the entire trust model. +pub struct TcpTransport { + stream: TcpStream, + noise: TransportState, + buf: Vec, + bind: TransportBindValues, + pinned: bool, + host: String, +} + +impl TcpTransport { + /// Runs the Noise_NX handshake as the initiator. The NX pattern has the + /// server transmit its static key during the handshake, so pinning is a + /// single code path: an existing pin must match, an absent one is + /// accepted but reported as unpinned by `pinned()`. + pub fn connect( + host: &str, + port: u16, + pinned: Option<[u8; KEY_LEN]>, + timeout: u64, + ) -> anyhow::Result { + let addr = (host, port); + let stream = TcpStream::connect(addr) + .map_err(|e| anyhow::anyhow!("cannot reach {host}:{port}: {e}"))?; + stream.set_read_timeout(Some(Duration::from_secs(timeout)))?; + stream.set_write_timeout(Some(Duration::from_secs(timeout)))?; + let mut stream = stream; + + let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?; + let mut noise = Builder::new(params).prologue(PROLOGUE).build_initiator()?; + + let mut buf = [0u8; 65535]; + let len = noise.write_message(&[], &mut buf)?; + write_u16_len(&mut stream, &buf[..len])?; + + let len = read_u16_len(&mut stream)?; + let mut message = vec![0u8; len]; + stream.read_exact(&mut message)?; + noise.read_message(&message, &mut buf)?; + if !noise.is_handshake_finished() { + anyhow::bail!("handshake did not complete"); + } + + let server_static: [u8; KEY_LEN] = noise + .get_remote_static() + .ok_or_else(|| anyhow::anyhow!("server sent no static key"))? + .try_into()?; + let handshake_hash = noise.get_handshake_hash().to_vec(); + let bind = TransportBindValues::tcp(&handshake_hash, &server_static)?; + let transport = noise.into_transport_mode()?; + + if let Some(pinned) = pinned { + if !ct_eq(&pinned, &server_static) { + return Err(anyhow::anyhow!( + "{host} presented a different key than the one pinned\n pinned: {}\n \ + presented: {}", + b32(&pinned), + b32(&server_static) + )); + } + } + + Ok(TcpTransport { + stream, + noise: transport, + buf: Vec::new(), + bind, + pinned: pinned.is_some(), + host: host.to_string(), + }) + } + + /// The server's Noise static key, what REGISTER binds its proof of + /// possession to and what `trust` pins. + pub fn server_static(&self) -> &[u8; KEY_LEN] { + &self.bind.server_static + } + + pub fn host(&self) -> &str { + &self.host + } + + fn read_noise(&mut self) -> Result, SmolError> { + let len = read_u16_len(&mut self.stream)?; + let mut ciphertext = vec![0u8; len]; + self.stream.read_exact(&mut ciphertext)?; + let mut plaintext = vec![0u8; len]; + let n = self.noise.read_message(&ciphertext, &mut plaintext)?; + plaintext.truncate(n); + Ok(plaintext) + } + + fn write_noise(&mut self, payload: &[u8]) -> Result<(), SmolError> { + let mut packet = vec![0u8; payload.len() + 16]; + let n = self.noise.write_message(payload, &mut packet)?; + packet.truncate(n); + write_u16_len(&mut self.stream, &packet)?; + Ok(()) + } +} + +impl Transport for TcpTransport { + /// Sends one application frame (u32 length || op || body, split across + /// as many Noise messages as it needs) and reads the response frame, + /// returning its status byte and body (sec 4, sec 6.1). + fn request(&mut self, op: u8, body: &[u8]) -> Result { + let length = 1 + body.len(); + if length > MAX_FRAME { + return Err(SmolError::new("request exceeds the maximum frame size")); + } + let mut frame = Vec::with_capacity(4 + length); + frame.extend_from_slice(&(length as u32).to_be_bytes()); + frame.push(op); + frame.extend_from_slice(body); + for chunk in frame.chunks(NOISE_PAYLOAD) { + self.write_noise(chunk)?; + } + + while self.buf.len() < 5 { + let chunk = self.read_noise()?; + self.buf.extend_from_slice(&chunk); + } + let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize; + if length == 0 || length > MAX_FRAME { + return Err(SmolError::new(format!( + "server sent a frame of length {length}" + ))); + } + while self.buf.len() < 4 + length { + let chunk = self.read_noise()?; + self.buf.extend_from_slice(&chunk); + } + // Frame layout: u32 length || op (echoed) || status || payload. + let status = self.buf[5]; + let body: Vec = self.buf.drain(..4 + length).skip(6).collect(); + Ok(Response { status, body }) + } + + fn bind(&self) -> &TransportBindValues { + &self.bind + } + + fn pinned(&self) -> bool { + self.pinned + } + + fn close(&mut self) { + let _ = self.stream.shutdown(std::net::Shutdown::Both); + } +} + +fn read_u16_len(stream: &mut TcpStream) -> std::io::Result { + let mut len_buf = [0u8; 2]; + stream.read_exact(&mut len_buf)?; + Ok(u16::from_be_bytes(len_buf) as usize) +} + +fn write_u16_len(stream: &mut TcpStream, packet: &[u8]) -> std::io::Result<()> { + stream.write_all(&(packet.len() as u16).to_be_bytes())?; + stream.write_all(packet) +} diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..850b45c --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,210 @@ +//! The transport boundary: operation and status constants, the bind values +//! AUTH and REGISTER signatures depend on, and the trait both carriers +//! implement. Only the bind values and the framing differ between them; every +//! operation body is byte-identical (RNS.md sec 13.5, sec 15). + +// Used only by the RNS bind values and their tests. +#[cfg(any(test, feature = "rns"))] +use crate::crypto::sha256; +use crate::error::SmolError; + +pub const NOISE_PARAMS: &str = "Noise_NX_25519_ChaChaPoly_SHA256"; +pub const PROLOGUE: &[u8] = b"smolmail/1"; + +pub const LABEL_AUTH: &[u8] = b"smolmail/1 auth"; +pub const LABEL_ID: &[u8] = b"smolmail/1 id"; +pub const LABEL_SEAL: &[u8] = b"smolmail/1 seal"; +pub const LABEL_MSG: &[u8] = b"smolmail/1 msg"; +pub const LABEL_MAC: &[u8] = b"smolmail/1 mac"; +pub const LABEL_ROTATE: &[u8] = b"smolmail/1 rotate"; +pub const LABEL_REGISTER: &[u8] = b"smolmail/1 register"; +pub const LABEL_IDENTITY: &[u8] = b"smolmail/1 identity"; +pub const LABEL_ACCEPT: &[u8] = b"smolmail/1 accept"; +/// RNS.md sec 14: added in 1.2 for the link binding of sec 13.6. +#[cfg(any(test, feature = "rns"))] +pub const LABEL_BIND: &[u8] = b"smolmail/1 bind"; + +pub const OP_AUTH: u8 = 0x00; +pub const OP_RESOLVE: u8 = 0x01; +pub const OP_SEND: u8 = 0x02; +pub const OP_FETCH: u8 = 0x03; +pub const OP_DELETE: u8 = 0x04; +pub const OP_REGISTER: u8 = 0x05; + +pub const ENVELOPE_MAGIC: &[u8; 4] = b"SMOL"; +pub const ENVELOPE_VERSION: u8 = 1; +pub const ENVELOPE_HEADER: usize = 69; // magic 4 + version 1 + to 32 + epk 32 +pub const ENVELOPE_MIN: usize = ENVELOPE_HEADER + 16; // + Poly1305 tag +pub const PAYLOAD_HEADER: usize = 45; // version 1 + sender 32 + time 8 + body_len 4 +pub const KEY_LEN: usize = 32; +pub const ID_LEN: usize = 32; +pub const SIG_LEN: usize = 64; +pub const CERT_LEN: usize = 200; // old_pub 32 + new_pub 32 + time 8 + sig_old 64 + sig_new 64 +pub const TOKEN_LEN: usize = 32; +pub const MAX_CHAIN: usize = 16; + +pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling (sec 4) +pub const NOISE_PAYLOAD: usize = 65535 - 16; // Noise message ceiling minus the tag + +/// Tiers a fetched message can land in (sec 5.8). +pub const TIER_MAIN: u8 = 0; +pub const TIER_REQUESTS: u8 = 1; +/// flags bit 0: the message arrived without a matching accept token. +pub const FLAG_REQUESTS: u8 = 0x01; + +/// A parsed response frame: the status byte and everything after it. +pub struct Response { + pub status: u8, + pub body: Vec, +} + +/// Fail-closed reader over a response body. Every parse path errors rather +/// than reading past the end, so a truncated response can never be mistaken +/// for a short but valid one. +pub struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Reader { buf, pos: 0 } + } + + pub fn take(&mut self, n: usize) -> Result<&'a [u8], SmolError> { + if self.pos + n > self.buf.len() { + return Err(SmolError::new("truncated response from server")); + } + let out = &self.buf[self.pos..self.pos + n]; + self.pos += n; + Ok(out) + } + + pub fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + pub fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_be_bytes([b[0], b[1]])) + } + + pub fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_be_bytes(b.try_into().unwrap())) + } + + pub fn i64(&mut self) -> Result { + let b = self.take(8)?; + Ok(i64::from_be_bytes(b.try_into().unwrap())) + } + + pub fn rest(&mut self) -> &'a [u8] { + let out = &self.buf[self.pos..]; + self.pos = self.buf.len(); + out + } + + pub fn done(&self) -> Result<(), SmolError> { + if self.pos != self.buf.len() { + return Err(SmolError::new("trailing bytes in response")); + } + Ok(()) + } +} + +/// Transport-supplied values that AUTH and REGISTER signatures bind to. Both +/// carriers prove the same thing — that the peer holds the identity key and +/// is talking to this server, not a replayed capture of another — but the +/// inputs differ (RNS.md sec 13.6), so the session layer consumes this +/// struct instead of a Noise handshake hash. +pub struct TransportBindValues { + /// What AUTH signs: the Noise handshake hash, or its RNS substitute. + pub h: [u8; 32], + /// What REGISTER signs: the server's static key, or its RNS substitute. + pub server_static: [u8; 32], +} + +impl TransportBindValues { + /// Noise binds to the handshake hash and the server's real static key. + pub fn tcp(handshake_hash: &[u8], server_static: &[u8; KEY_LEN]) -> anyhow::Result { + Ok(Self { + h: handshake_hash + .try_into() + .map_err(|_| anyhow::anyhow!("handshake hash not 32 bytes"))?, + server_static: *server_static, + }) + } + + /// RNS has no static key of ours on the wire, so both values are derived + /// from the destination; link_id keeps one link's AUTH from replaying on + /// another (RNS.md sec 13.6). Neither input is length-prefixed, and both + /// are fixed-width, so concatenation stays unambiguous. + #[cfg(any(test, feature = "rns"))] + pub fn rns(destination: &[u8; 16], link_id: &[u8; 16]) -> Self { + Self { + h: sha256(&[LABEL_BIND, &destination[..], &link_id[..]]), + server_static: sha256(&[LABEL_BIND, &destination[..]]), + } + } +} + +/// One request/response exchange per carrier. `request` sends one application +/// frame and returns the peer's answer; `bind` supplies the values the +/// operation layer signs over; `pinned` reports whether this session's +/// server identity came from a trusted channel, which decides whether +/// RESOLVE results may be marked verified (sec 4, RNS.md sec 13.4). +pub trait Transport { + fn request(&mut self, op: u8, body: &[u8]) -> Result; + fn bind(&self) -> &TransportBindValues; + fn pinned(&self) -> bool; + fn close(&mut self); +} + +#[cfg(test)] +mod tests { + use super::*; + + // Vectors computed independently over the RNS.md sec 13.6 formula + // SHA-256("smolmail/1 bind" || destination || link_id); shared with + // bunshin, whose server verifies what this client produces. + const DEST: [u8; 16] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, + ]; + const LINK: [u8; 16] = [ + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, + 0xaf, + ]; + const H_HEX: &str = "06d6437dc63250ff51d609e12b488ff22b4fa02620f35000b3b2ed1c8789d45c"; + const SERVER_STATIC_HEX: &str = + "da6fe109dc4da2878fa4810ffa0e08cef6b68a8b524126aa05bb14181b20d25a"; + + #[test] + fn rns_matches_reference_vectors() { + let bind = TransportBindValues::rns(&DEST, &LINK); + assert_eq!(data_encoding::HEXLOWER.encode(&bind.h), H_HEX); + assert_eq!( + data_encoding::HEXLOWER.encode(&bind.server_static), + SERVER_STATIC_HEX + ); + } + + #[test] + fn rns_h_differs_per_link_but_server_static_does_not() { + let other = [0xc0u8; 16]; + let a = TransportBindValues::rns(&DEST, &LINK); + let b = TransportBindValues::rns(&DEST, &other); + assert_ne!(a.h, b.h); + assert_eq!(a.server_static, b.server_static); + } + + #[test] + fn tcp_rejects_short_handshake_hash() { + let static_key = [7u8; KEY_LEN]; + assert!(TransportBindValues::tcp(&[1u8; 31], &static_key).is_err()); + let bind = TransportBindValues::tcp(&[2u8; 32], &static_key).unwrap(); + assert_eq!(bind.h, [2u8; 32]); + assert_eq!(bind.server_static, static_key); + } +}