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); + } +}