From 71ad7e04c0c8c7726b5746bcb545173620541d32 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 26 Sep 2026 11:52:22 +0300 Subject: [PATCH] feat: implement Smol Mail server in Rust with nix flake deployment --- .gitignore | 7 + Cargo.lock | 1004 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 22 + README.md | 67 ++++ flake.lock | 61 +++ flake.nix | 180 +++++++++ src/channel.rs | 122 ++++++ src/crypto.rs | 88 ++++ src/main.rs | 123 ++++++ src/proto.rs | 166 ++++++++ src/ratelimit.rs | 82 ++++ src/server.rs | 163 ++++++++ src/session.rs | 264 ++++++++++++ src/store.rs | 243 +++++++++++ 14 files changed, 2592 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/channel.rs create mode 100644 src/crypto.rs create mode 100644 src/main.rs create mode 100644 src/proto.rs create mode 100644 src/ratelimit.rs create mode 100644 src/server.rs create mode 100644 src/session.rs create mode 100644 src/store.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..049603f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +*.db +*.db-wal +*.db-shm +server.key +result +result-* diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..38547e7 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1004 @@ +# 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 = "bunshin" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "data-encoding", + "ed25519-dalek", + "env_logger", + "log", + "rand_core", + "rusqlite", + "sha2", + "snow", + "x25519-dalek", +] + +[[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", + "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 = "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 = "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..7c3416a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bunshin" +version = "0.1.0" +edition = "2021" +description = "Smol Mail server" + +[[bin]] +name = "bunshin" +path = "src/main.rs" + +[dependencies] +snow = "0.9" +rusqlite = { version = "0.32", features = ["bundled"] } +ed25519-dalek = "2" +x25519-dalek = { version = "2", features = ["static_secrets"] } +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +data-encoding = "2" +clap = { version = "4", features = ["derive"] } +log = "0.4" +env_logger = "0.11" +anyhow = "1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..46432e5 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# 分身 bunshin + +A Rust implementation of the [Smol Mail](https://code.randogoth.com/randogoth/smolmail) server: a minimalist, end-to-end encrypted mail protocol over a Noise-secured TCP connection. `bunshin` implements the server side only — receiving, storing and serving sealed mail — not the client. + +The server never sees plaintext, sender identities or any private key. It learns only which mailbox an envelope is for, its size, and when it arrived. + +The flake's main purpose is turnkey deployment on a NixOS host: import `nixosModules.default`, point it at a key, and `nixos-rebuild switch`. + +## Deploying on NixOS + +Add bunshin as a flake input and import the module: + +```nix +{ + inputs.bunshin.url = "https://code.randogoth.com/randogoth/bunshin"; + + outputs = { self, nixpkgs, bunshin, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + bunshin.nixosModules.default + { + services.bunshin = { + enable = true; + keyFile = "/var/lib/bunshin/server.key"; # provisioned out of band, see below + openFirewall = true; + inviteTokenFile = "/run/secrets/bunshin-invite"; # or inviteToken directly + }; + } + ]; + }; + }; +} +``` + +`services.bunshin` also takes `host`, `port`, `dataDir`, `maxEnvelope`, `quota`, `retentionDays`, `rateConnections` and `rateSends`; see `flake.nix` for defaults. The module renders a `systemd` unit that runs `bunshin serve` under `DynamicUser`; it does not generate a key. + +Before the first deploy, generate the server's static key once (from a dev shell or `nix run`) and place it at the configured `keyFile`: + +``` +nix run "https://code.randogoth.com/randogoth/bunshin" -- keygen --key server.key +``` + +`keygen` writes the server's static X25519 key (used for the Noise handshake, distinct from any user's Ed25519 identity) and prints its public key in base32. Publish that public key through a trusted channel — clients pin it, and a mismatch aborts the handshake. + +## Building + +Requires a Rust toolchain (stable, edition 2021) and a C compiler — `rusqlite`'s `bundled` feature compiles SQLite from source rather than linking a system copy, so no separate SQLite install is needed. + +``` +cargo build --release +cargo test +``` + +The binary lands at `target/release/bunshin`. With Nix, skip the toolchain setup entirely: `nix build` produces the same binary at `./result/bin/bunshin`. + +Running directly, outside the NixOS module: + +``` +bunshin keygen --key server.key +bunshin serve --key server.key --db mail.db --host 0.0.0.0 --port 1961 +``` + +`serve` accepts `--max-envelope`, `--quota`, `--retention-days`, `--invite-token`, `--rate-connections` and `--rate-sends` to control size limits, per-mailbox quota, message retention, registration gating and abuse control. Run `bunshin serve --help` for defaults. + +## Status + +Implements SPEC.md version 1 in full: `AUTH`, `RESOLVE`, `SEND`, `FETCH`, `DELETE` and `REGISTER` (including invite tokens and key rotation chains). Verified end-to-end against the Python reference client over a live Noise connection, plus a raw-protocol test suite covering the rejection paths (auth failures, unknown users, malformed and oversized envelopes, rate limiting). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c4536ba --- /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": 1790323409, + "narHash": "sha256-VVTPf+Hyd5ebpjBMHmrLMSBIeW6ls48Bqtosj7CNKLA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e94cb152ed51bd6e24eb4a41f1460252beb52cd2", + "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..c4873a7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,180 @@ +{ + description = "bunshin - Smol Mail server (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; }; + + bunshin = pkgs.rustPlatform.buildRustPackage { + pname = "bunshin"; + version = "0.1.0"; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + nativeBuildInputs = [ pkgs.pkg-config ]; + buildInputs = [ pkgs.sqlite ]; + }; + in + { + packages.default = bunshin; + + apps.default = flake-utils.lib.mkApp { + drv = bunshin; + name = "bunshin"; + }; + + devShells.default = pkgs.mkShell { + packages = [ pkgs.cargo pkgs.rustc pkgs.rustfmt pkgs.clippy pkgs.pkg-config pkgs.gcc pkgs.sqlite ]; + }; + }) // { + nixosModules.default = { config, lib, pkgs, ... }: + let + cfg = config.services.bunshin; + inherit (lib) mkEnableOption mkOption mkIf types; + in + { + options.services.bunshin = { + enable = mkEnableOption "the bunshin Smol Mail server"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.system}.default; + description = "bunshin package to run."; + }; + + host = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "Address to listen on."; + }; + + port = mkOption { + type = types.port; + default = 1961; + description = "TCP port to listen on."; + }; + + keyFile = mkOption { + type = types.path; + description = '' + Path to the server's static Noise X25519 private key + (32 raw bytes, generated with `bunshin keygen`). Provisioned + out of band; this module does not generate it. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/bunshin"; + description = "Directory holding mail.db."; + }; + + maxEnvelope = mkOption { + type = types.ints.positive; + default = 1048576; + description = "Maximum accepted envelope size, in bytes."; + }; + + quota = mkOption { + type = types.ints.positive; + default = 67108864; + description = "Per-mailbox storage quota, in bytes."; + }; + + retentionDays = mkOption { + type = types.ints.positive; + default = 30; + description = "Days a message is retained before being purged."; + }; + + rateConnections = mkOption { + type = types.ints.positive; + default = 120; + description = "Max accepted connections per minute, per source IP."; + }; + + rateSends = mkOption { + type = types.ints.positive; + default = 60; + description = "Max SEND operations per minute, per source IP."; + }; + + inviteToken = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Registration invite token. Null means open registration. + Prefer `inviteTokenFile` to avoid storing the token in the + world-readable Nix store. + ''; + }; + + inviteTokenFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file (readable by the service via LoadCredential) + containing the registration invite token. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the configured TCP port in the firewall."; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(cfg.inviteToken != null && cfg.inviteTokenFile != null); + message = "services.bunshin: set only one of inviteToken or inviteTokenFile."; + } + ]; + + systemd.services.bunshin = { + description = "bunshin Smol Mail server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + ExecStart = pkgs.writeShellScript "bunshin-serve" '' + set -euo pipefail + args=( + serve + --key ${cfg.keyFile} + --db ${cfg.dataDir}/mail.db + --host ${cfg.host} + --port ${toString cfg.port} + --max-envelope ${toString cfg.maxEnvelope} + --quota ${toString cfg.quota} + --retention-days ${toString cfg.retentionDays} + --rate-connections ${toString cfg.rateConnections} + --rate-sends ${toString cfg.rateSends} + ) + ${lib.optionalString (cfg.inviteToken != null) + ''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''} + ${lib.optionalString (cfg.inviteTokenFile != null) + ''args+=(--invite-token "$(cat "$CREDENTIALS_DIRECTORY/invite-token")")''} + exec ${cfg.package}/bin/bunshin "''${args[@]}" + ''; + DynamicUser = true; + StateDirectory = "bunshin"; + StateDirectoryMode = "0700"; + Restart = "on-failure"; + } // lib.optionalAttrs (cfg.inviteTokenFile != null) { + LoadCredential = "invite-token:${cfg.inviteTokenFile}"; + }; + }; + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + }; + }; + }; +} diff --git a/src/channel.rs b/src/channel.rs new file mode 100644 index 0000000..fd67c93 --- /dev/null +++ b/src/channel.rs @@ -0,0 +1,122 @@ +//! Noise_NX handshake and the framed transport on top of it. +//! +//! Two independent layers: Noise messages under a u16 length prefix, and +//! application frames (u32 length || u8 op || body) split across as many +//! Noise messages as they need and reassembled from them. + +use std::io::{self, Read, Write}; +use std::net::TcpStream; + +use snow::{Builder, TransportState}; + +use crate::proto::{ProtocolError, MAX_FRAME, NOISE_PARAMS, NOISE_PAYLOAD, PROLOGUE}; + +/// Runs the Noise_NX responder handshake. The initiator stays anonymous; +/// only we hold a static key. Returns the transport state and the +/// handshake hash (needed later to verify AUTH frames). +pub fn handshake( + stream: &mut TcpStream, + static_key: &[u8], +) -> anyhow::Result<(TransportState, Vec)> { + let params: snow::params::NoiseParams = NOISE_PARAMS.parse()?; + let mut noise = Builder::new(params) + .local_private_key(static_key) + .prologue(PROLOGUE) + .build_responder()?; + + let mut buf = [0u8; 65535]; + let mut msg = [0u8; 65535]; + + let len = read_u16_len(stream)?; + read_exact_into(stream, &mut buf[..len])?; + noise.read_message(&buf[..len], &mut msg)?; + + let len = noise.write_message(&[], &mut buf)?; + write_u16_len(stream, &buf[..len])?; + + anyhow::ensure!(noise.is_handshake_finished(), "handshake did not complete"); + let hash = noise.get_handshake_hash().to_vec(); + let transport = noise.into_transport_mode()?; + Ok((transport, hash)) +} + +fn read_exact_into(stream: &mut TcpStream, buf: &mut [u8]) -> io::Result<()> { + stream.read_exact(buf) +} + +fn read_u16_len(stream: &mut TcpStream) -> 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]) -> io::Result<()> { + stream.write_all(&(packet.len() as u16).to_be_bytes())?; + stream.write_all(packet)?; + Ok(()) +} + +pub struct Channel { + stream: TcpStream, + transport: TransportState, + buf: Vec, +} + +impl Channel { + pub fn new(stream: TcpStream, transport: TransportState) -> Self { + Channel { + stream, + transport, + buf: Vec::new(), + } + } + + fn read_noise(&mut self) -> anyhow::Result> { + let len = read_u16_len(&mut self.stream)?; + let mut ciphertext = vec![0u8; len]; + read_exact_into(&mut self.stream, &mut ciphertext)?; + let mut plaintext = vec![0u8; len]; + let n = self.transport.read_message(&ciphertext, &mut plaintext)?; + plaintext.truncate(n); + Ok(plaintext) + } + + fn write_noise(&mut self, payload: &[u8]) -> anyhow::Result<()> { + let mut packet = vec![0u8; payload.len() + 16]; + let n = self.transport.write_message(payload, &mut packet)?; + packet.truncate(n); + write_u16_len(&mut self.stream, &packet)?; + Ok(()) + } + + /// Reads one application frame, blocking until a full frame is available. + pub fn read_frame(&mut self) -> anyhow::Result<(u8, Vec)> { + 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 < 1 || length > MAX_FRAME { + return Err(ProtocolError::new(format!("frame length {length} out of range")).into()); + } + while self.buf.len() < 4 + length { + let chunk = self.read_noise()?; + self.buf.extend_from_slice(&chunk); + } + let frame: Vec = self.buf[4..4 + length].to_vec(); + self.buf.drain(..4 + length); + Ok((frame[0], frame[1..].to_vec())) + } + + pub fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()> { + let mut frame = Vec::with_capacity(5 + body.len()); + frame.extend_from_slice(&((1 + body.len()) as u32).to_be_bytes()); + frame.push(op); + frame.extend_from_slice(body); + for chunk in frame.chunks(NOISE_PAYLOAD) { + self.write_noise(chunk)?; + } + Ok(()) + } + +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..a54f1e6 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,88 @@ +//! Encoding and cryptographic verification helpers. + +use data_encoding::{Encoding, Specification}; +use ed25519_dalek::{Signature, VerifyingKey}; +use rand_core::OsRng; +use sha2::{Digest, Sha256}; +use std::sync::LazyLock; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::proto::{LABEL_ID, ID_LEN}; + +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. +pub fn b32(raw: &[u8]) -> String { + BASE32_LOWER_UNPADDED.encode(raw) +} + +/// Derived from the envelope, so a sender cannot choose it. +pub fn message_id(envelope: &[u8]) -> [u8; ID_LEN] { + let mut hasher = Sha256::new(); + hasher.update(LABEL_ID); + hasher.update(envelope); + let digest = hasher.finalize(); + let mut out = [0u8; ID_LEN]; + out.copy_from_slice(&digest[..ID_LEN]); + out +} + +/// Generates the server's static X25519 keypair (transport identity, distinct +/// from any user's Ed25519 identity). Returns (private, public) raw bytes. +pub fn generate_static_key() -> ([u8; 32], [u8; 32]) { + let secret = StaticSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + (secret.to_bytes(), public.to_bytes()) +} + +/// Derives the X25519 public key for a raw static private key. +pub fn derive_public(private: &[u8; 32]) -> [u8; 32] { + let secret = StaticSecret::from(*private); + PublicKey::from(&secret).to_bytes() +} + +/// Verifies an Ed25519 signature; malformed keys/signatures are simply not valid. +pub fn verify(pubkey: &[u8], signature: &[u8], message: &[u8]) -> bool { + let Ok(pubkey): Result<[u8; 32], _> = pubkey.try_into() else { + return false; + }; + let Ok(signature): Result<[u8; 64], _> = signature.try_into() else { + return false; + }; + let Ok(verifying_key) = VerifyingKey::from_bytes(&pubkey) else { + return false; + }; + let signature = Signature::from_bytes(&signature); + verifying_key.verify_strict(message, &signature).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base32_matches_rfc4648_lowercase_unpadded() { + // "hello" -> base32 "NBSWY3DP" per RFC 4648, lowercased and unpadded. + assert_eq!(b32(b"hello"), "nbswy3dp"); + } + + #[test] + fn message_id_is_16_bytes_and_deterministic() { + let a = message_id(b"envelope-bytes"); + let b = message_id(b"envelope-bytes"); + let c = message_id(b"other-bytes"); + assert_eq!(a.len(), ID_LEN); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn verify_rejects_garbage() { + assert!(!verify(&[0u8; 32], &[0u8; 64], b"msg")); + assert!(!verify(&[0u8; 5], &[0u8; 64], b"msg")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e802ca8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,123 @@ +//! Smol Mail server. + +mod channel; +mod crypto; +mod proto; +mod ratelimit; +mod server; +mod session; +mod store; + +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +use clap::{Parser, Subcommand}; + +use crate::proto::DEFAULT_PORT; + +#[derive(Parser)] +#[command(about = "Smol Mail server")] +struct Cli { + #[arg(short, long, global = true)] + verbose: bool, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Generate the server's static X25519 key + Keygen { + #[arg(long, default_value = "server.key")] + key: String, + #[arg(long)] + force: bool, + }, + /// Run the mailbox server + Serve { + #[arg(long, default_value = "server.key")] + key: String, + #[arg(long, default_value = "mail.db")] + db: String, + #[arg(long, default_value = "127.0.0.1")] + host: String, + #[arg(long, default_value_t = DEFAULT_PORT)] + port: u16, + #[arg(long = "max-envelope", default_value_t = 1 << 20)] + max_envelope: usize, + #[arg(long, default_value_t = 64 << 20)] + quota: i64, + #[arg(long = "retention-days", default_value_t = 30)] + retention_days: i64, + #[arg(long = "invite-token")] + invite_token: Option, + #[arg(long = "rate-connections", default_value_t = 120)] + rate_connections: u32, + #[arg(long = "rate-sends", default_value_t = 60)] + rate_sends: u32, + }, +} + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + env_logger::Builder::new() + .filter_level(if cli.verbose { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }) + .format_timestamp_secs() + .init(); + + match cli.command { + Command::Keygen { key, force } => cmd_keygen(&key, force), + Command::Serve { + key, + db, + host, + port, + max_envelope, + quota, + retention_days, + invite_token, + rate_connections, + rate_sends, + } => server::run(server::ServeArgs { + key_path: key, + db_path: db, + host, + port, + max_envelope, + quota, + retention_days, + invite_token, + rate_connections, + rate_sends, + }), + } +} + +fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> { + if std::path::Path::new(key_path).exists() && !force { + anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)"); + } + + let (private, public) = crypto::generate_static_key(); + + // Written 0600 before any bytes land, so the key is never briefly readable. + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + opts.mode(0o600); + let mut file = opts.open(key_path)?; + file.write_all(&private)?; + + println!("private key: {key_path}"); + println!("public key: {}", crypto::b32(&public)); + println!(); + println!("Publish the public key through a trusted channel; clients pin it (SPEC.md sec 4)."); + Ok(()) +} diff --git a/src/proto.rs b/src/proto.rs new file mode 100644 index 0000000..2c71666 --- /dev/null +++ b/src/proto.rs @@ -0,0 +1,166 @@ +//! Wire constants and body parsing. + +use std::fmt; + +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_ROTATE: &[u8] = b"smolmail/1 rotate"; + +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 OK: u8 = 0; +pub const MALFORMED: u8 = 1; +pub const BAD_VERSION: u8 = 2; +pub const UNKNOWN_USER: u8 = 3; +pub const AUTH_REQUIRED: u8 = 4; +pub const AUTH_FAILED: u8 = 5; +pub const QUOTA_EXCEEDED: u8 = 6; +pub const TOO_LARGE: u8 = 7; +pub const RATE_LIMITED: u8 = 8; +pub const NOT_PERMITTED: u8 = 9; +pub const INTERNAL_ERROR: u8 = 10; + +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 ID_LEN: usize = 16; +pub const KEY_LEN: usize = 32; +pub const CERT_LEN: usize = 136; // old_pub 32 + new_pub 32 + time 8 + signature 64 +pub const MAX_CHAIN: usize = 16; + +pub const DEFAULT_PORT: u16 = 1961; +pub const MAX_FRAME: usize = 1 << 20; // application frame ceiling +pub const NOISE_MAX: usize = 65535; // Noise message ceiling +pub const NOISE_PAYLOAD: usize = NOISE_MAX - 16; // minus the AEAD tag +pub const FETCH_BUDGET: usize = 512 * 1024; // must stay under MAX_FRAME +pub const IDLE_TIMEOUT_SECS: u64 = 120; +pub const PURGE_INTERVAL_SECS: u64 = 60; + +/// A peer sent something unparseable. Always answered with MALFORMED. +#[derive(Debug)] +pub struct ProtocolError(pub String); + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for ProtocolError {} + +impl ProtocolError { + pub fn new(msg: impl Into) -> Self { + ProtocolError(msg.into()) + } +} + +/// Fail-closed reader over a frame body. +/// +/// Every parse path errors rather than reading past the end, so a truncated +/// frame 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], ProtocolError> { + if self.pos + n > self.buf.len() { + return Err(ProtocolError::new(format!( + "short read: want {}, have {}", + n, + self.buf.len() - self.pos + ))); + } + 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 rest(&mut self) -> &'a [u8] { + let out = &self.buf[self.pos..]; + self.pos = self.buf.len(); + out + } + + pub fn done(&self) -> Result<(), ProtocolError> { + if self.pos != self.buf.len() { + return Err(ProtocolError::new(format!( + "{} trailing bytes", + self.buf.len() - self.pos + ))); + } + Ok(()) + } +} + +/// 1-63 bytes of [a-z0-9._-], not starting or ending with a separator. +pub fn valid_username(name: &str) -> bool { + let bytes = name.as_bytes(); + if bytes.is_empty() || bytes.len() > 63 { + return false; + } + if !bytes + .iter() + .all(|&c| c.is_ascii_digit() || c.is_ascii_lowercase() || matches!(c, b'.' | b'_' | b'-')) + { + return false; + } + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + !matches!(first, b'.' | b'_' | b'-') && !matches!(last, b'.' | b'_' | b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reader_bounds() { + let mut r = Reader::new(&[1, 2, 3]); + assert_eq!(r.u8().unwrap(), 1); + assert!(r.take(3).is_err()); + assert_eq!(r.take(2).unwrap(), &[2, 3]); + assert!(r.done().is_ok()); + } + + #[test] + fn reader_trailing_bytes_rejected() { + let mut r = Reader::new(&[1, 2, 3]); + let _ = r.u8().unwrap(); + assert!(r.done().is_err()); + } + + #[test] + fn username_validation() { + assert!(valid_username("alice")); + assert!(valid_username("a.b_c-9")); + assert!(!valid_username("")); + assert!(!valid_username(&"a".repeat(64))); + assert!(!valid_username(".alice")); + assert!(!valid_username("alice.")); + assert!(!valid_username("Alice")); + assert!(!valid_username("al ice")); + } +} diff --git a/src/ratelimit.rs b/src/ratelimit.rs new file mode 100644 index 0000000..2117149 --- /dev/null +++ b/src/ratelimit.rs @@ -0,0 +1,82 @@ +//! Fixed-window per-IP counter, the whole of the server's abuse control. +//! +//! A server cannot see senders, so quotas, size caps and this are all it has. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +struct Window { + start: Instant, + count: u32, +} + +pub struct RateLimiter { + limit: u32, + window: Duration, + hits: Mutex>, +} + +impl RateLimiter { + pub fn new(limit: u32) -> Self { + RateLimiter { + limit, + window: Duration::from_secs(60), + hits: Mutex::new(HashMap::new()), + } + } + + pub fn allow(&self, ip: &str) -> bool { + if self.limit == 0 { + return true; + } + let now = Instant::now(); + let mut hits = self.hits.lock().unwrap(); + let entry = hits.entry(ip.to_string()).or_insert(Window { + start: now, + count: 0, + }); + if now.duration_since(entry.start) >= self.window { + entry.start = now; + entry.count = 0; + } + if entry.count >= self.limit { + return false; + } + entry.count += 1; + if hits.len() > 4096 { + let window = self.window; + hits.retain(|_, w| now.duration_since(w.start) < window); + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_up_to_limit_then_blocks() { + let rl = RateLimiter::new(2); + assert!(rl.allow("1.2.3.4")); + assert!(rl.allow("1.2.3.4")); + assert!(!rl.allow("1.2.3.4")); + } + + #[test] + fn zero_limit_means_unlimited() { + let rl = RateLimiter::new(0); + for _ in 0..100 { + assert!(rl.allow("1.2.3.4")); + } + } + + #[test] + fn separate_ips_have_separate_windows() { + let rl = RateLimiter::new(1); + assert!(rl.allow("1.1.1.1")); + assert!(rl.allow("2.2.2.2")); + assert!(!rl.allow("1.1.1.1")); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..4e9dcc6 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,163 @@ +//! TCP accept loop, per-connection handling, and the background purge loop. + +use std::net::TcpStream; +use std::sync::Arc; +use std::time::Duration; + +use crate::channel::{handshake, Channel}; +use crate::crypto::{b32, derive_public}; +use crate::proto::{IDLE_TIMEOUT_SECS, KEY_LEN, MALFORMED, PURGE_INTERVAL_SECS}; +use crate::ratelimit::RateLimiter; +use crate::session::{ServerConfig, Session}; +use crate::store::Store; + +pub struct ServeArgs { + pub key_path: String, + pub db_path: String, + pub host: String, + pub port: u16, + pub max_envelope: usize, + pub quota: i64, + pub retention_days: i64, + pub invite_token: Option, + pub rate_connections: u32, + pub rate_sends: u32, +} + +pub fn run(args: ServeArgs) -> anyhow::Result<()> { + let static_key = + std::fs::read(&args.key_path).map_err(|e| anyhow::anyhow!("cannot read server key: {e}"))?; + anyhow::ensure!( + static_key.len() == KEY_LEN, + "server key must be {KEY_LEN} raw bytes, got {}", + static_key.len() + ); + + let config = Arc::new(ServerConfig { + max_envelope: args.max_envelope, + quota: args.quota, + invite_token: args.invite_token.map(String::into_bytes), + conn_limiter: RateLimiter::new(args.rate_connections), + send_limiter: RateLimiter::new(args.rate_sends), + }); + + let retention_secs = args.retention_days * 86400; + let purge_db_path = args.db_path.clone(); + std::thread::spawn(move || purge_loop(purge_db_path, retention_secs)); + + let listener = std::net::TcpListener::bind((args.host.as_str(), args.port))?; + log::info!("listening on {}:{}", args.host, args.port); + + let key_array: [u8; KEY_LEN] = static_key.clone().try_into().unwrap(); + let public = derive_public(&key_array); + log::info!("server public key: {}", b32(&public)); + + for incoming in listener.incoming() { + let stream = match incoming { + Ok(s) => s, + Err(e) => { + log::warn!("accept error: {e}"); + continue; + } + }; + let peer_ip = stream + .peer_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + if !config.conn_limiter.allow(&peer_ip) { + log::warn!("rate limited {peer_ip}"); + continue; + } + + let config = Arc::clone(&config); + let static_key = static_key.clone(); + let db_path = args.db_path.clone(); + std::thread::spawn(move || { + if let Err(e) = handle_connection(stream, &config, &static_key, &db_path, &peer_ip) { + log::info!("connection error from {peer_ip}: {e}"); + } + }); + } + Ok(()) +} + +fn handle_connection( + mut stream: TcpStream, + config: &ServerConfig, + static_key: &[u8], + db_path: &str, + peer_ip: &str, +) -> anyhow::Result<()> { + stream.set_read_timeout(Some(Duration::from_secs(IDLE_TIMEOUT_SECS)))?; + + let (transport, handshake_hash) = match handshake(&mut stream, static_key) { + Ok(v) => v, + Err(e) => { + log::info!("handshake failed from {peer_ip}: {e}"); + return Ok(()); + } + }; + + let store = Store::open(db_path)?; + let mut session = Session::new(config, store, peer_ip.to_string(), handshake_hash); + let mut channel = Channel::new(stream, transport); + + loop { + let (op, body) = match channel.read_frame() { + Ok(v) => v, + Err(e) => { + if is_eof_like(&e) { + return Ok(()); + } + log::info!("bad frame from {peer_ip}: {e}"); + let _ = channel.write_frame(0, &[MALFORMED]); + return Ok(()); + } + }; + + let (status, payload) = session.dispatch(op, &body); + let mut response = Vec::with_capacity(1 + payload.len()); + response.push(status); + response.extend_from_slice(&payload); + if channel.write_frame(op, &response).is_err() { + return Ok(()); + } + } +} + +fn is_eof_like(e: &anyhow::Error) -> bool { + if let Some(io_err) = e.downcast_ref::() { + return matches!( + io_err.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::WouldBlock + ); + } + false +} + +fn purge_loop(db_path: String, retention_secs: i64) { + let store = match Store::open(&db_path) { + Ok(s) => s, + Err(e) => { + log::error!("purge thread failed to open store: {e}"); + return; + } + }; + loop { + std::thread::sleep(Duration::from_secs(PURGE_INTERVAL_SECS)); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + match store.purge(now - retention_secs) { + Ok(0) => {} + Ok(n) => log::info!("expired {n} message(s)"), + Err(e) => log::error!("purge failed: {e}"), + } + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..5d72b2d --- /dev/null +++ b/src/session.rs @@ -0,0 +1,264 @@ +//! Per-connection dispatch and the six wire operations. + +use crate::crypto::{message_id, verify}; +use crate::proto::{ + valid_username, ProtocolError, Reader, AUTH_FAILED, AUTH_REQUIRED, BAD_VERSION, CERT_LEN, + ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, FETCH_BUDGET, ID_LEN, KEY_LEN, LABEL_AUTH, + LABEL_ROTATE, MALFORMED, MAX_CHAIN, NOT_PERMITTED, OK, OP_AUTH, OP_DELETE, OP_FETCH, + OP_REGISTER, OP_RESOLVE, OP_SEND, QUOTA_EXCEEDED, RATE_LIMITED, TOO_LARGE, UNKNOWN_USER, +}; +use crate::ratelimit::RateLimiter; +use crate::store::Store; + +pub struct ServerConfig { + pub max_envelope: usize, + pub quota: i64, + pub invite_token: Option>, + pub conn_limiter: RateLimiter, + pub send_limiter: RateLimiter, +} + +/// A parse failure (-> MALFORMED) or a storage failure (-> INTERNAL_ERROR). +pub enum HandlerError { + Protocol(ProtocolError), + Store(rusqlite::Error), +} + +impl From for HandlerError { + fn from(e: ProtocolError) -> Self { + HandlerError::Protocol(e) + } +} + +impl From for HandlerError { + fn from(e: rusqlite::Error) -> Self { + HandlerError::Store(e) + } +} + +type OpResult = Result<(u8, Vec), HandlerError>; + +pub struct Session<'a> { + config: &'a ServerConfig, + store: Store, + peer_ip: String, + handshake_hash: Vec, + username: Option, +} + +impl<'a> Session<'a> { + pub fn new(config: &'a ServerConfig, store: Store, peer_ip: String, handshake_hash: Vec) -> Self { + Session { + config, + store, + peer_ip, + handshake_hash, + username: None, + } + } + + /// Dispatches one frame, always producing a status to send back, never + /// panicking or propagating errors to the caller: a bad frame or a + /// storage error both become a response, and the caller decides + /// separately whether to keep the connection open. + pub fn dispatch(&mut self, op: u8, body: &[u8]) -> (u8, Vec) { + if matches!(op, OP_FETCH | OP_DELETE) && self.username.is_none() { + return (AUTH_REQUIRED, Vec::new()); + } + let mut r = Reader::new(body); + let result = match op { + OP_AUTH => self.op_auth(&mut r), + OP_RESOLVE => self.op_resolve(&mut r), + OP_SEND => self.op_send(&mut r), + OP_FETCH => self.op_fetch(&mut r), + OP_DELETE => self.op_delete(&mut r), + OP_REGISTER => self.op_register(&mut r), + _ => return (MALFORMED, Vec::new()), + }; + match result { + Ok(response) => response, + Err(HandlerError::Protocol(e)) => { + log::info!("bad body from {}: {}", self.peer_ip, e); + (MALFORMED, Vec::new()) + } + Err(HandlerError::Store(e)) => { + log::error!("storage error from {}: {}", self.peer_ip, e); + (crate::proto::INTERNAL_ERROR, Vec::new()) + } + } + } + + fn read_str(r: &mut Reader) -> Result { + let len = r.u8()? as usize; + let bytes = r.take(len)?; + std::str::from_utf8(bytes) + .map(str::to_string) + .map_err(|_| ProtocolError::new("invalid utf-8")) + } + + fn op_auth(&mut self, r: &mut Reader) -> OpResult { + let username = Self::read_str(r)?; + let identity = r.take(KEY_LEN)?.to_vec(); + let signature = r.take(64)?.to_vec(); + r.done()?; + + let bound = self.store.identity_of(&username)?; + // A wrong username and a wrong signature are both AUTH_FAILED: telling + // them apart would turn this into an account-existence oracle. + if bound.as_deref() != Some(identity.as_slice()) { + return Ok((AUTH_FAILED, Vec::new())); + } + let mut msg = LABEL_AUTH.to_vec(); + msg.extend_from_slice(&self.handshake_hash); + if !verify(&identity, &signature, &msg) { + return Ok((AUTH_FAILED, Vec::new())); + } + self.username = Some(username); + Ok((OK, Vec::new())) + } + + fn op_resolve(&mut self, r: &mut Reader) -> OpResult { + let username = Self::read_str(r)?; + r.done()?; + let identity = match self.store.identity_of(&username)? { + Some(id) => id, + None => return Ok((UNKNOWN_USER, Vec::new())), + }; + let chain = self.store.chain(&username)?; + let mut out = identity; + out.push(chain.len() as u8); + for cert in chain { + out.extend_from_slice(&cert); + } + Ok((OK, out)) + } + + fn op_send(&mut self, r: &mut Reader) -> OpResult { + let envelope = r.rest().to_vec(); + if !self.config.send_limiter.allow(&self.peer_ip) { + return Ok((RATE_LIMITED, Vec::new())); + } + if envelope.len() > self.config.max_envelope { + return Ok((TOO_LARGE, Vec::new())); + } + if envelope.len() < ENVELOPE_MIN || &envelope[..4] != ENVELOPE_MAGIC { + return Ok((MALFORMED, Vec::new())); + } + if envelope[4] != ENVELOPE_VERSION { + return Ok((BAD_VERSION, Vec::new())); + } + let recipient = &envelope[5..37]; + let username = match self.store.username_for_key(recipient)? { + Some(u) => u, + None => return Ok((UNKNOWN_USER, Vec::new())), + }; + let keys = self.store.keys_of(&username)?; + let used = self.store.mailbox_bytes(&keys)?; + if used + envelope.len() as i64 > self.config.quota { + return Ok((QUOTA_EXCEEDED, Vec::new())); + } + // The ciphertext is never inspected; the server cannot read it. + let mid = message_id(&envelope); + self.store.store_message(&mid, recipient, &envelope)?; + Ok((OK, mid.to_vec())) + } + + fn op_fetch(&mut self, r: &mut Reader) -> OpResult { + r.done()?; + let username = self.username.as_ref().expect("AUTH_REQUIRED gate above"); + let keys = self.store.keys_of(username)?; + let records = self.store.pending(&keys, FETCH_BUDGET)?; + + let mut out = Vec::new(); + out.extend_from_slice(&(records.len() as u16).to_be_bytes()); + for (mid, received_at, envelope) in records { + out.extend_from_slice(&mid); + out.extend_from_slice(&received_at.to_be_bytes()); + out.extend_from_slice(&(envelope.len() as u32).to_be_bytes()); + out.extend_from_slice(&envelope); + } + Ok((OK, out)) + } + + fn op_delete(&mut self, r: &mut Reader) -> OpResult { + let count = r.u16()? as usize; + let mut ids = Vec::with_capacity(count); + for _ in 0..count { + ids.push(r.take(ID_LEN)?.to_vec()); + } + r.done()?; + let username = self.username.as_ref().expect("AUTH_REQUIRED gate above"); + + if ids.is_empty() { + return Ok((OK, 0u16.to_be_bytes().to_vec())); + } + // Scoped to the caller's own keys, so ids cannot be used to probe or + // delete another mailbox. + let keys = self.store.keys_of(username)?; + let removed = self.store.delete(&keys, &ids)?; + Ok((OK, (removed as u16).to_be_bytes().to_vec())) + } + + fn op_register(&mut self, r: &mut Reader) -> OpResult { + let username = Self::read_str(r)?; + let identity = r.take(KEY_LEN)?.to_vec(); + let token_len = r.u8()? as usize; + let token = r.take(token_len)?.to_vec(); + let cert_len = r.u8()? as usize; + let cert = r.take(cert_len)?.to_vec(); + r.done()?; + + if !valid_username(&username) { + return Ok((MALFORMED, Vec::new())); + } + // identity is exactly KEY_LEN bytes by construction (Reader::take + // enforces it); no separate curve-point validity check is needed. + + if let Some(expected) = &self.config.invite_token { + if &token != expected { + return Ok((NOT_PERMITTED, Vec::new())); + } + } + + if cert.is_empty() { + if !self.store.register(&username, &identity)? { + return Ok((NOT_PERMITTED, Vec::new())); + } + return Ok((OK, Vec::new())); + } + + if cert.len() != CERT_LEN { + return Ok((MALFORMED, Vec::new())); + } + let old_pub = &cert[..32]; + let new_pub = &cert[32..64]; + let when = &cert[64..72]; + let signature = &cert[72..]; + if new_pub != identity.as_slice() { + return Ok((MALFORMED, Vec::new())); + } + let bound = match self.store.identity_of(&username)? { + Some(b) => b, + None => return Ok((UNKNOWN_USER, Vec::new())), + }; + // Only the currently bound key may hand the username on. + if bound != old_pub { + return Ok((NOT_PERMITTED, Vec::new())); + } + let mut msg = LABEL_ROTATE.to_vec(); + msg.extend_from_slice(old_pub); + msg.extend_from_slice(new_pub); + msg.extend_from_slice(when); + if !verify(old_pub, signature, &msg) { + return Ok((AUTH_FAILED, Vec::new())); + } + let chain = self.store.chain(&username)?; + if chain.len() >= MAX_CHAIN { + return Ok((NOT_PERMITTED, Vec::new())); + } + if !self.store.rotate(&username, new_pub, &cert, chain.len())? { + return Ok((NOT_PERMITTED, Vec::new())); + } + Ok((OK, Vec::new())) + } +} diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..c204bf0 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,243 @@ +//! SQLite-backed mailbox storage. +//! +//! Each connection thread opens its own `Store` (own `rusqlite::Connection`), +//! since SQLite connections aren't meant to be shared across threads. + +use std::time::Duration; + +use rusqlite::{params_from_iter, Connection, OptionalExtension}; + +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + identity BLOB NOT NULL +); +-- Every key ever bound to a username, so a superseded key stays addressable +-- across a rotation. +CREATE TABLE IF NOT EXISTS keys ( + identity BLOB PRIMARY KEY, + username TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS rotations ( + username TEXT NOT NULL, + seq INTEGER NOT NULL, + cert BLOB NOT NULL, + PRIMARY KEY (username, seq) +); +CREATE TABLE IF NOT EXISTS messages ( + id BLOB PRIMARY KEY, + recipient BLOB NOT NULL, + received_at INTEGER NOT NULL, + envelope BLOB NOT NULL +); +CREATE INDEX IF NOT EXISTS messages_by_recipient + ON messages (recipient, received_at); +"; + +pub struct Store { + conn: Connection, +} + +fn placeholders(n: usize) -> String { + vec!["?"; n].join(",") +} + +impl Store { + pub fn open(path: &str) -> rusqlite::Result { + let conn = Connection::open(path)?; + conn.busy_timeout(Duration::from_secs(10))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.execute_batch(SCHEMA)?; + Ok(Store { conn }) + } + + pub fn identity_of(&self, username: &str) -> rusqlite::Result>> { + self.conn + .query_row( + "SELECT identity FROM users WHERE username = ?1", + [username], + |row| row.get(0), + ) + .optional() + } + + pub fn chain(&self, username: &str) -> rusqlite::Result>> { + let mut stmt = self + .conn + .prepare("SELECT cert FROM rotations WHERE username = ?1 ORDER BY seq")?; + let rows = stmt.query_map([username], |row| row.get(0))?; + rows.collect() + } + + pub fn keys_of(&self, username: &str) -> rusqlite::Result>> { + let mut stmt = self + .conn + .prepare("SELECT identity FROM keys WHERE username = ?1")?; + let rows = stmt.query_map([username], |row| row.get(0))?; + rows.collect() + } + + pub fn username_for_key(&self, identity: &[u8]) -> rusqlite::Result> { + self.conn + .query_row( + "SELECT username FROM keys WHERE identity = ?1", + [identity], + |row| row.get(0), + ) + .optional() + } + + /// False on conflict: username taken, or this key is already bound elsewhere. + pub fn register(&self, username: &str, identity: &[u8]) -> rusqlite::Result { + let tx = self.conn.unchecked_transaction()?; + let result = (|| -> rusqlite::Result<()> { + tx.execute( + "INSERT INTO users (username, identity) VALUES (?1, ?2)", + (username, identity), + )?; + tx.execute( + "INSERT INTO keys (identity, username) VALUES (?1, ?2)", + (identity, username), + )?; + Ok(()) + })(); + match result { + Ok(()) => { + tx.commit()?; + Ok(true) + } + Err(rusqlite::Error::SqliteFailure(e, _)) + if e.code == rusqlite::ErrorCode::ConstraintViolation => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + pub fn rotate( + &self, + username: &str, + new_key: &[u8], + cert: &[u8], + seq: usize, + ) -> rusqlite::Result { + let tx = self.conn.unchecked_transaction()?; + let result = (|| -> rusqlite::Result<()> { + tx.execute( + "UPDATE users SET identity = ?1 WHERE username = ?2", + (new_key, username), + )?; + tx.execute( + "INSERT INTO keys (identity, username) VALUES (?1, ?2)", + (new_key, username), + )?; + tx.execute( + "INSERT INTO rotations (username, seq, cert) VALUES (?1, ?2, ?3)", + (username, seq as i64, cert), + )?; + Ok(()) + })(); + match result { + Ok(()) => { + tx.commit()?; + Ok(true) + } + Err(rusqlite::Error::SqliteFailure(e, _)) + if e.code == rusqlite::ErrorCode::ConstraintViolation => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + pub fn mailbox_bytes(&self, keys: &[Vec]) -> rusqlite::Result { + if keys.is_empty() { + return Ok(0); + } + let sql = format!( + "SELECT COALESCE(SUM(LENGTH(envelope)), 0) FROM messages WHERE recipient IN ({})", + placeholders(keys.len()) + ); + self.conn + .query_row(&sql, params_from_iter(keys.iter()), |row| row.get(0)) + } + + pub fn store_message( + &self, + mid: &[u8], + recipient: &[u8], + envelope: &[u8], + ) -> rusqlite::Result<()> { + self.conn.execute( + "INSERT OR IGNORE INTO messages (id, recipient, received_at, envelope) \ + VALUES (?1, ?2, ?3, ?4)", + ( + mid, + recipient, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64, + envelope, + ), + )?; + Ok(()) + } + + /// Always returns at least one message, even if it alone exceeds `budget`, + /// so an oversized envelope cannot wedge a mailbox shut. + pub fn pending( + &self, + keys: &[Vec], + budget: usize, + ) -> rusqlite::Result, i64, Vec)>> { + if keys.is_empty() { + return Ok(Vec::new()); + } + let sql = format!( + "SELECT id, received_at, envelope FROM messages \ + WHERE recipient IN ({}) ORDER BY received_at, id", + placeholders(keys.len()) + ); + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map(params_from_iter(keys.iter()), |row| { + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, + )) + })?; + + let mut out = Vec::new(); + let mut used = 0usize; + for row in rows { + let (mid, received_at, envelope) = row?; + if !out.is_empty() && used + envelope.len() > budget { + break; + } + used += envelope.len(); + out.push((mid, received_at, envelope)); + } + Ok(out) + } + + pub fn delete(&self, keys: &[Vec], ids: &[Vec]) -> rusqlite::Result { + if keys.is_empty() || ids.is_empty() { + return Ok(0); + } + let sql = format!( + "DELETE FROM messages WHERE id IN ({}) AND recipient IN ({})", + placeholders(ids.len()), + placeholders(keys.len()) + ); + let params: Vec<&Vec> = ids.iter().chain(keys.iter()).collect(); + self.conn.execute(&sql, params_from_iter(params)) + } + + pub fn purge(&self, older_than: i64) -> rusqlite::Result { + self.conn + .execute("DELETE FROM messages WHERE received_at < ?1", [older_than]) + } +}