From fd3f9bcf24b74af2f998319996ac105bbb8d7725 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 4 Oct 2026 20:33:08 +0300 Subject: [PATCH] feat: add smolboid-core feed format with payload-signature verification --- Cargo.lock | 745 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 4 + core/Cargo.toml | 13 + core/src/envelope.rs | 161 +++++++++ core/src/feed.rs | 427 ++++++++++++++++++++++++ core/src/lib.rs | 18 + core/src/rules.rs | 95 ++++++ core/src/time.rs | 146 +++++++++ core/src/verify.rs | 156 +++++++++ core/tests/vectors.rs | 418 ++++++++++++++++++++++++ core/vectors.json | 138 ++++++++ devbox.json | 32 ++ devbox.lock | 178 ++++++++++ 13 files changed, 2531 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 core/Cargo.toml create mode 100644 core/src/envelope.rs create mode 100644 core/src/feed.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/rules.rs create mode 100644 core/src/time.rs create mode 100644 core/src/verify.rs create mode 100644 core/tests/vectors.rs create mode 100644 core/vectors.json create mode 100644 devbox.json create mode 100644 devbox.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ae1d061 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,745 @@ +# 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 = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +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 = "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 = "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 = "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-core" +version = "0.1.0" +dependencies = [ + "chacha20poly1305", + "data-encoding", + "ed25519-dalek", + "hkdf", + "hmac", + "rand_core", + "rusqlite", + "serde", + "serde_json", + "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 = "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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[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 = "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 = "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 = "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 = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "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 = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "smolboid" +version = "0.1.0" +dependencies = [ + "fumi-core", + "serde", + "serde_json", + "smolboid-core", +] + +[[package]] +name = "smolboid-core" +version = "0.1.0" +dependencies = [ + "chacha20poly1305", + "fumi-core", + "serde_json", +] + +[[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 = "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 = "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 = "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 = "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", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2e68a98 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = ["core", "daemon"] +default-members = ["core", "daemon"] +resolver = "2" diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..3341912 --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "smolboid-core" +version = "0.1.0" +edition = "2021" +description = "smolboid feed format: parsing, post rules and smolmail payload-signature verification" +license = "Apache-2.0" + +[dependencies] +fumi-core = { path = "../../fumi/core", default-features = false } +chacha20poly1305 = "0.10" + +[dev-dependencies] +serde_json = "1" diff --git a/core/src/envelope.rs b/core/src/envelope.rs new file mode 100644 index 0000000..f4a0b55 --- /dev/null +++ b/core/src/envelope.rs @@ -0,0 +1,161 @@ +//! Opening sealed smolmail envelopes the way a publisher needs: fumi's +//! `unseal` verifies and discards, while a `# smol:mail` record must carry the +//! ephemeral key and the payload signature, so this mirrors `message::unseal` +//! (SPEC.md §5.1-5.3) and returns every field the record publishes. + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use fumi_core::account::{verify_sig, Identity}; +use fumi_core::crypto::{agree, b32, ct_eq, hkdf_sha256}; +use fumi_core::error::Error; +use fumi_core::transport::{ + ENVELOPE_HEADER, ENVELOPE_MAGIC, ENVELOPE_MIN, ENVELOPE_VERSION, LABEL_SEAL, PAYLOAD_HEADER, + SIG_LEN, +}; + +/// §5.3: a payload dated further ahead than this is rejected. +pub const MAX_SKEW: i64 = 86400; +pub const KEY_LEN: usize = 32; + +/// An opened, verified payload plus the fields a record publishes. +pub struct Opened { + pub sender: [u8; KEY_LEN], + /// The envelope's cleartext ephemeral key (§5.1); the signature covers it. + pub epk: [u8; KEY_LEN], + /// The payload's Ed25519 signature over the §5.3 signed bytes. + pub sig: [u8; SIG_LEN], + pub time: i64, + pub body: Vec, +} + +/// Opens an envelope addressed to one of `identities`, performing every check +/// a receiver MUST make (§5.3): addressed to us, signature verifies against +/// the carried sender, payload not dated too far ahead. +pub fn open(identities: &[Identity], envelope: &[u8], now: i64) -> Result { + if envelope.len() < ENVELOPE_MIN { + return Err(Error::Other("envelope too short".into())); + } + if &envelope[..4] != ENVELOPE_MAGIC { + return Err(Error::Other("not a Smol Mail envelope".into())); + } + if envelope[4] != ENVELOPE_VERSION { + return Err(Error::Other(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(|| { + Error::Other(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(|_| Error::Other("decryption failed: wrong key or corrupt envelope".into()))?; + + if plaintext.len() < PAYLOAD_HEADER + SIG_LEN || plaintext[0] != ENVELOPE_VERSION { + return Err(Error::Other("unsupported payload version".into())); + } + 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(Error::Other( + "payload body length exceeds the payload".into(), + )); + } + 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 = plaintext[PAYLOAD_HEADER..PAYLOAD_HEADER + body_len].to_vec(); + let sig: [u8; SIG_LEN] = plaintext[PAYLOAD_HEADER + body_len..PAYLOAD_HEADER + body_len + SIG_LEN] + .try_into() + .unwrap(); + + let mut signed = Vec::with_capacity( + LABEL_SEAL.len() + 2 * KEY_LEN + PAYLOAD_HEADER + body.len(), + ); + signed.extend_from_slice(fumi_core::transport::LABEL_MSG); + signed.extend_from_slice(&to); + signed.extend_from_slice(&epk); + signed.extend_from_slice(&plaintext[..PAYLOAD_HEADER]); + signed.extend_from_slice(&body); + if !verify_sig(&sender, &sig, &signed) { + return Err(Error::Other("signature does not verify".into())); + } + if when > now + MAX_SKEW { + return Err(Error::Other("payload is dated in the future".into())); + } + Ok(Opened { + sender, + epk, + sig, + time: when, + body, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use fumi_core::account::{identity_seed, Identity}; + use fumi_core::message::{seal, unseal}; + + fn identity(master: u8, index: u32) -> Identity { + Identity::from_seed(identity_seed(&[master; 32], index)) + } + + #[test] + fn agrees_with_fumi_unseal_and_exposes_the_record_fields() { + let recipient = identity(0x11, 0); + let sender = identity(0x22, 0); + let body = b"hello smolverse".to_vec(); + let envelope = seal(&sender, &recipient.pk(), &body, 1_760_000_000, false).unwrap(); + + let mine = open(std::slice::from_ref(&recipient), &envelope, 1_760_000_000).unwrap(); + assert_eq!(mine.sender, sender.pk()); + assert_eq!(mine.time, 1_760_000_000); + assert_eq!(mine.body, body); + + let theirs = unseal(&[recipient.clone()], &envelope, 1_760_000_000).unwrap(); + assert_eq!(theirs.sender, mine.sender); + assert_eq!(theirs.time, mine.time); + assert_eq!(theirs.body, mine.body); + + // The exposed signature covers the exact §5.3 signed bytes. + let signed = crate::verify::signed_bytes( + &recipient.pk(), + &mine.epk, + &mine.sender, + mine.time, + &mine.body, + ); + assert!(verify_sig(&mine.sender, &mine.sig, &signed)); + } + + #[test] + fn rejects_envelopes_addressed_elsewhere() { + let recipient = identity(0x11, 0); + let other = identity(0x33, 0); + let envelope = seal(&identity(0x22, 0), &other.pk(), b"x", 0, false).unwrap(); + assert!(open(&[recipient], &envelope, 0).is_err()); + } +} diff --git a/core/src/feed.rs b/core/src/feed.rs new file mode 100644 index 0000000..9f5ee2e --- /dev/null +++ b/core/src/feed.rs @@ -0,0 +1,427 @@ +//! The feed file: parse, render and manipulate a `twtxt.txt` without losing +//! bytes. Unknown lines are preserved verbatim, so store operations (delete, +//! prune, append) never corrupt content this format does not know about. + +use fumi_core::crypto::{b32, unb32}; + +use crate::rules::{validate_text, validate_timestamp, RuleError}; + +pub const KEY_LEN: usize = 32; +pub const SIG_LEN: usize = 64; + +/// A decoded `# smol:mail` line: the three values a verifier needs, in the +/// unpadded lowercase base32 the smolmail ecosystem uses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MailLine { + pub sender: [u8; KEY_LEN], + pub epk: [u8; KEY_LEN], + pub sig: [u8; SIG_LEN], +} + +impl MailLine { + pub fn render(&self) -> String { + format!( + "# smol:mail {} {} {}", + b32(&self.sender), + b32(&self.epk), + b32(&self.sig) + ) + } +} + +/// A publishable record: a post line or a `# smol:repost` line. Timestamp and +/// text are kept exactly as written — the payload signature covers both. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Record { + Post { timestamp: String, text: String }, + Repost { timestamp: String, args: String }, +} + +impl Record { + pub fn timestamp(&self) -> &str { + match self { + Record::Post { timestamp, .. } | Record::Repost { timestamp, .. } => timestamp, + } + } + + /// The text as it appears in the line: after the tab for a post, the + /// repost args after the timestamp for a repost. + pub fn text(&self) -> &str { + match self { + Record::Post { text, .. } => text, + Record::Repost { args, .. } => args, + } + } + + pub fn render(&self) -> String { + match self { + Record::Post { timestamp, text } => format!("{timestamp}\t{text}"), + Record::Repost { timestamp, args } => format!("# smol:repost {timestamp} {args}"), + } + } + + /// Post rules 1, 3 and 4. + pub fn validate(&self) -> Result<(), RuleError> { + validate_timestamp(self.timestamp())?; + validate_text(self.text()) + } +} + +/// One parsed record plus the `# smol:mail` line directly below it, if any. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + pub record: Record, + pub mail: Option, +} + +/// Every line kind the parser distinguishes. Raw strings are kept so render +/// is byte-faithful for lines this format did not generate. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Item { + Meta(String, String), + Post(String, String), + Repost(String, String), + Mail(MailLine), + Other(String), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Feed { + items: Vec, +} + +impl Feed { + pub fn new() -> Feed { + Feed::default() + } + + pub fn parse(text: &str) -> Feed { + Feed { + items: text.lines().map(parse_line).collect(), + } + } + + /// Always newline-terminates every line, including the last. + pub fn render(&self) -> String { + let mut out = String::new(); + for item in &self.items { + match item { + Item::Meta(key, value) => { + out.push_str("# "); + out.push_str(key); + out.push_str(" = "); + out.push_str(value); + } + Item::Post(timestamp, text) => { + out.push_str(timestamp); + out.push('\t'); + out.push_str(text); + } + Item::Repost(timestamp, args) => { + out.push_str("# smol:repost "); + out.push_str(timestamp); + out.push(' '); + out.push_str(args); + } + Item::Mail(mail) => out.push_str(&mail.render()), + Item::Other(line) => out.push_str(line), + } + out.push('\n'); + } + out + } + + /// Records in file order, each bound to the `# smol:mail` line directly + /// below it. A `# smol:mail` line with no adjacent record is ignored. + pub fn entries(&self) -> Vec { + let mut entries = Vec::new(); + let mut i = 0; + while i < self.items.len() { + let record = match &self.items[i] { + Item::Post(timestamp, text) => Some(Record::Post { + timestamp: timestamp.clone(), + text: text.clone(), + }), + Item::Repost(timestamp, args) => Some(Record::Repost { + timestamp: timestamp.clone(), + args: args.clone(), + }), + _ => None, + }; + match record { + Some(record) => { + let mail = match self.items.get(i + 1) { + Some(Item::Mail(mail)) => Some(mail.clone()), + _ => None, + }; + i += 1 + usize::from(mail.is_some()); + entries.push(Entry { record, mail }); + } + None => i += 1, + } + } + entries + } + + pub fn meta(&self, key: &str) -> Option<&str> { + self.items + .iter() + .find_map(|item| match item { + Item::Meta(k, v) if k == key => Some(v.as_str()), + _ => None, + }) + } + + pub fn author(&self) -> Option<&str> { + self.meta("smol:author") + } + + /// Every `smol:host_key` in order, oldest first; undecodable values are + /// skipped, as a verifier cannot use them. + pub fn host_keys(&self) -> Vec<[u8; KEY_LEN]> { + self.items + .iter() + .filter_map(|item| match item { + Item::Meta(k, v) if k == "smol:host_key" => key(v), + _ => None, + }) + .collect() + } + + /// A feed claiming no author or carrying no host key is plain twtxt, not + /// a broken smol feed (PLAN.md, Verification step 1). + pub fn is_legacy(&self) -> bool { + self.author().is_none() || self.host_keys().is_empty() + } + + /// Replaces the value of an existing metadata key, or inserts a new line + /// at the top of the file among the metadata block. + pub fn set_meta(&mut self, key: &str, value: &str) { + for item in &mut self.items { + if let Item::Meta(k, v) = item { + if k == key { + v.replace_range(.., value); + return; + } + } + } + let insert_at = self + .items + .iter() + .take_while(|item| matches!(item, Item::Meta(_, _))) + .count(); + self.items + .insert(insert_at, Item::Meta(key.to_owned(), value.to_owned())); + } + + /// Post rule 2: timestamps are unique across posts and reposts. + pub fn has_timestamp(&self, timestamp: &str) -> bool { + self.items.iter().any(|item| match item { + Item::Post(ts, _) | Item::Repost(ts, _) => ts == timestamp, + _ => false, + }) + } + + /// Appends a record and its signature line, post rule 5's append-only + /// half. The caller has validated the record and checked uniqueness. + pub fn append(&mut self, record: Record, mail: Option) { + let mail_item = mail.map(Item::Mail); + self.items.push(match record { + Record::Post { timestamp, text } => Item::Post(timestamp, text), + Record::Repost { timestamp, args } => Item::Repost(timestamp, args), + }); + if let Some(mail_item) = mail_item { + self.items.push(mail_item); + } + } + + /// Validates the record and rejects a timestamp collision, then appends. + /// The signature cannot be bumped, so a collision is an error the caller + /// answers with an error reply (PLAN.md, post rule 2). + pub fn append_checked( + &mut self, + record: Record, + mail: Option, + ) -> Result<(), RuleError> { + record.validate()?; + if self.has_timestamp(record.timestamp()) { + return Err(RuleError::DuplicateTimestamp); + } + self.append(record, mail); + Ok(()) + } + + /// Removes the record with this timestamp and its `# smol:mail` line. + /// Returns whether anything was removed. + pub fn delete(&mut self, timestamp: &str) -> bool { + let Some(index) = self.items.iter().position(|item| match item { + Item::Post(ts, _) | Item::Repost(ts, _) => ts == timestamp, + _ => false, + }) else { + return false; + }; + let mut remove = 1; + if matches!(self.items.get(index + 1), Some(Item::Mail(_))) { + remove += 1; + } + self.items.drain(index..index + remove); + true + } +} + +fn key(value: &str) -> Option<[u8; KEY_LEN]> { + let raw = unb32(value).ok()?; + raw.try_into().ok() +} + +fn parse_line(line: &str) -> Item { + if let Some(rest) = line.strip_prefix("# smol:mail ") { + return match mail(rest) { + Some(mail) => Item::Mail(mail), + None => Item::Other(line.to_owned()), + }; + } + if let Some(rest) = line.strip_prefix("# smol:repost ") { + return match rest.split_once(' ') { + Some((timestamp, args)) if !args.is_empty() => { + Item::Repost(timestamp.to_owned(), args.to_owned()) + } + _ => Item::Other(line.to_owned()), + }; + } + if let Some(rest) = line.strip_prefix("# ") { + return match rest.split_once(" = ") { + Some((key, value)) if !key.is_empty() => Item::Meta(key.to_owned(), value.to_owned()), + _ => Item::Other(line.to_owned()), + }; + } + if let Some((timestamp, text)) = line.split_once('\t') { + return Item::Post(timestamp.to_owned(), text.to_owned()); + } + Item::Other(line.to_owned()) +} + +fn mail(args: &str) -> Option { + let (sender, rest) = args.split_once(' ')?; + let (epk, sig) = rest.split_once(' ')?; + Some(MailLine { + sender: key(sender)?, + epk: key(epk)?, + sig: unb32(sig).ok()?.try_into().ok()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A 32-byte key is 52 base32 chars, a 64-byte signature 103 (the plan's + // earlier 104 was a miscount: 104 nopad chars decode to 65 bytes). + fn sample_feed() -> String { + let key = "a".repeat(52); + let sig = "a".repeat(103); + format!( + "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = {key}\n\n2026-10-01T12:47:00Z\thello smolverse\n# smol:mail {key} {key} {sig}\nplain stray line\n" + ) + } + + #[test] + fn round_trips_byte_faithfully() { + let feed = sample_feed(); + assert_eq!(Feed::parse(&feed).render(), feed); + } + + #[test] + fn reads_metadata() { + let feed = Feed::parse(&sample_feed()); + assert_eq!(feed.nick_of(), Some("tobias")); + assert_eq!(feed.author(), Some("tobias@smol.place")); + assert_eq!(feed.host_keys().len(), 1); + assert!(!feed.is_legacy()); + } + + #[test] + fn is_legacy_without_author_or_host_key() { + for text in [ + "# nick = tobias\n2026-10-01T12:47:00Z\thello\n", + "# smol:author = tobias@smol.place\n2026-10-01T12:47:00Z\thello\n", + ] { + assert!(Feed::parse(text).is_legacy(), "{text:?}"); + } + } + + #[test] + fn binds_only_the_mail_line_directly_below() { + let feed = Feed::parse(&sample_feed()); + let entries = feed.entries(); + assert_eq!(entries.len(), 1); + assert!(entries[0].mail.is_some()); + } + + #[test] + fn ignores_orphan_and_malformed_mail_lines() { + let key = "a".repeat(52); + let sig = "a".repeat(103); + let feed = Feed::parse(&format!( + "# smol:author = a@b\n# smol:host_key = {key}\n2026-10-01T12:47:00Z\ttext\n# smol:mail {key} {key} {sig}\n2026-10-01T12:48:00Z\tsecond\n# smol:mail {key} {key} too-short\n" + )); + let entries = feed.entries(); + assert_eq!(entries.len(), 2); + assert!(entries[0].mail.is_some(), "adjacent line binds"); + assert!(entries[1].mail.is_none(), "malformed line is not a mail line"); + // The malformed line survives rendering for byte fidelity. + assert!(feed.render().contains("too-short")); + } + + #[test] + fn set_meta_replaces_and_inserts() { + let mut feed = Feed::parse("# nick = tobias\n# url = u\n\n2026-10-01T12:47:00Z\thi\n"); + feed.set_meta("nick", "tobias2"); + assert_eq!(feed.meta("nick"), Some("tobias2")); + feed.set_meta("smol:author", "tobias@smol.place"); + assert_eq!( + feed.render(), + "# nick = tobias2\n# url = u\n# smol:author = tobias@smol.place\n\n2026-10-01T12:47:00Z\thi\n" + ); + } + + #[test] + fn append_and_delete_keep_the_mail_line_together() { + let mut feed = Feed::new(); + let record = Record::Post { + timestamp: "2026-10-01T12:47:00Z".into(), + text: "hello".into(), + }; + assert!(feed.append_checked(record.clone(), None).is_ok()); + assert!(feed.has_timestamp("2026-10-01T12:47:00Z")); + assert!(feed.delete("2026-10-01T12:47:00Z")); + assert!(!feed.has_timestamp("2026-10-01T12:47:00Z")); + assert!(!feed.delete("2026-10-01T12:47:00Z")); + } + + #[test] + fn append_rejects_rule_violations_and_collisions() { + let mut feed = Feed::new(); + let ok = Record::Post { + timestamp: "2026-10-01T12:47:00Z".into(), + text: "hello".into(), + }; + assert!(feed.append_checked(ok, None).is_ok()); + let dup = Record::Post { + timestamp: "2026-10-01T12:47:00Z".into(), + text: "again".into(), + }; + assert!(feed.append_checked(dup, None).is_err()); + let bad_text = Record::Post { + timestamp: "2026-10-01T12:48:00Z".into(), + text: "a\tb".into(), + }; + assert!(feed.append_checked(bad_text, None).is_err()); + } + + impl Feed { + fn nick_of(&self) -> Option<&str> { + self.meta("nick") + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..b2e9928 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,18 @@ +//! `smolboid-core` — the smolboid feed format (PLAN.md): parsing and +//! rendering `twtxt.txt`, the post rules, canonical reconstruction of post +//! and repost bodies, verification of `# smol:mail` records against the +//! author's smolmail payload signature (SPEC.md §5.3), and opening sealed +//! envelopes to expose the fields a record publishes. No I/O, so clients and +//! the daemon link this crate directly. + +pub mod envelope; +pub mod feed; +pub mod rules; +pub mod time; +pub mod verify; + +pub use envelope::{open, Opened}; +pub use feed::{Entry, Feed, MailLine, Record}; +pub use rules::{validate_text, validate_timestamp, RuleError, MAX_BYTES, MAX_CODEPOINTS}; +pub use time::{parse as parse_timestamp, render as render_timestamp}; +pub use verify::{reply_to_body, repost_body, signed_bytes, verify, Report, State}; diff --git a/core/src/rules.rs b/core/src/rules.rs new file mode 100644 index 0000000..c37a251 --- /dev/null +++ b/core/src/rules.rs @@ -0,0 +1,95 @@ +//! Post rules 1, 3 and 4 from PLAN.md, enforced at receipt by the daemon and +//! usable by any author-side tool. + +pub const MAX_CODEPOINTS: usize = 160; +pub const MAX_BYTES: usize = 640; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleError { + TooManyCodepoints, + TooManyBytes, + ForbiddenChar, + BadTimestamp, + DuplicateTimestamp, +} + +impl std::fmt::Display for RuleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let msg = match self { + RuleError::TooManyCodepoints => "more than 160 Unicode code points", + RuleError::TooManyBytes => "more than 640 bytes", + RuleError::ForbiddenChar => "contains TAB, LF, CR or U+2028", + RuleError::BadTimestamp => "not an RFC 3339 UTC timestamp at second precision", + RuleError::DuplicateTimestamp => "timestamp already exists in this feed", + }; + f.write_str(msg) + } +} + +impl std::error::Error for RuleError {} + +/// Rules 3 and 4: at most 160 code points and 640 bytes, and no TAB, LF, CR +/// or U+2028 — any of those would break the one-record-per-line invariant. +/// The byte cap is mathematically implied by the codepoint cap over UTF-8 but +/// is kept as an explicit guard. +pub fn validate_text(text: &str) -> Result<(), RuleError> { + if text.chars().count() > MAX_CODEPOINTS { + return Err(RuleError::TooManyCodepoints); + } + if text.len() > MAX_BYTES { + return Err(RuleError::TooManyBytes); + } + if text.contains(['\t', '\n', '\r', '\u{2028}']) { + return Err(RuleError::ForbiddenChar); + } + Ok(()) +} + +/// Rule 1: RFC 3339 at second precision, rendered in UTC with `Z`. +pub fn validate_timestamp(timestamp: &str) -> Result<(), RuleError> { + if crate::time::parse(timestamp).is_none() { + return Err(RuleError::BadTimestamp); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_a_short_post() { + assert_eq!(validate_text("hello smolverse"), Ok(())); + assert_eq!(validate_timestamp("2026-10-01T12:47:00Z"), Ok(())); + } + + #[test] + fn rejects_over_length_text() { + let chars: String = std::iter::repeat('a').take(161).collect(); + assert_eq!(validate_text(&chars), Err(RuleError::TooManyCodepoints)); + + // 160 four-byte code points sit exactly on both caps. + let four_byte: String = "🙂".repeat(160); + assert_eq!(validate_text(&four_byte), Ok(())); + let over: String = "🙂".repeat(161); + assert_eq!( + validate_text(&over), + Err(RuleError::TooManyCodepoints), + "codepoints are checked before bytes" + ); + } + + #[test] + fn rejects_record_breaking_characters() { + for bad in ["a\tb", "a\nb", "a\rb", "a\u{2028}b"] { + assert_eq!(validate_text(bad), Err(RuleError::ForbiddenChar)); + } + } + + #[test] + fn rejects_non_z_timestamps() { + for bad in ["2026-10-01T12:47:00+03:00", "not a date", ""] { + assert_eq!(validate_timestamp(bad), Err(RuleError::BadTimestamp)); + } + } +} diff --git a/core/src/time.rs b/core/src/time.rs new file mode 100644 index 0000000..5728c51 --- /dev/null +++ b/core/src/time.rs @@ -0,0 +1,146 @@ +//! RFC 3339 UTC rendering at second precision — the only timestamp form a +//! smol feed carries, so a line timestamp round-trips to the sealed payload's +//! Unix seconds and back without loss. + +/// Renders Unix seconds as `YYYY-MM-DDTHH:MM:SSZ`, post rule 1. +pub fn render(unix: i64) -> String { + let days = unix.div_euclid(86_400); + let secs = unix.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + secs / 3600, + (secs % 3600) / 60, + secs % 60 + ) +} + +/// Parses the exact form `render` produces; anything else is rejected, +/// because the payload signature covers the timestamp as written. +pub fn parse(timestamp: &str) -> Option { + let b = timestamp.as_bytes(); + if b.len() != 20 + || b[4] != b'-' + || b[7] != b'-' + || b[10] != b'T' + || b[13] != b':' + || b[16] != b':' + || b[19] != b'Z' + { + return None; + } + let num = |range: std::ops::Range| -> Option { + let digits = timestamp.get(range)?; + if !digits.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + Some(digits.parse().ok()?) + }; + let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?); + let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?); + if !(1..=12).contains(&mo) || h > 23 || mi > 59 || s > 59 { + return None; + } + let days = days_from_civil(y, mo as u32, d as u32); + // A calendar round-trip rejects impossible dates such as 2026-02-31. + if civil_from_days(days) != (y, mo as u32, d as u32) { + return None; + } + Some(days * 86_400 + h * 3600 + mi * 60 + s) +} + +/// UTC calendar parts of a Unix timestamp: (year, month, day, hour, minute, +/// second). Callers that need a textual form of HTTP date reuse this rather +/// than a calendar of their own. +pub fn utc_parts(unix: i64) -> (i64, u32, u32, u32, u32, u32) { + let days = unix.div_euclid(86_400); + let secs = unix.rem_euclid(86_400); + let (y, mo, d) = civil_from_days(days); + ( + y, + mo, + d, + (secs / 3600) as u32, + ((secs % 3600) / 60) as u32, + (secs % 60) as u32, + ) +} + +/// Inverse of `utc_parts` with the same strictness as `parse`: impossible +/// dates do not exist. +pub fn from_parts(y: i64, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> Option { + if !(1..=12).contains(&mo) || h > 23 || mi > 59 || s > 59 { + return None; + } + let days = days_from_civil(y, mo, d); + if civil_from_days(days) != (y, mo, d) { + return None; + } + Some(days * 86_400 + i64::from(h) * 3600 + i64::from(mi) * 60 + i64::from(s)) +} + +/// Howard Hinnant's civil-from-days: the standard dependency-free conversion +/// from a day count since 1970-01-01 to a (year, month, day) triple. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 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) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = y - i64::from(m <= 2); + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if m > 2 { i64::from(m) - 3 } else { i64::from(m) + 9 }; + let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(unix: i64) { + assert_eq!(parse(&render(unix)), Some(unix), "round trip {unix}"); + } + + #[test] + fn renders_the_plan_form() { + assert_eq!(render(1_760_000_000), "2025-10-09T08:53:20Z"); + assert_eq!(render(0), "1970-01-01T00:00:00Z"); + assert_eq!(render(-1), "1969-12-31T23:59:59Z"); + } + + #[test] + fn round_trips_across_decades() { + for unix in [-1, 0, 1, 951_782_400, 1_760_000_000, 4_102_444_800] { + round_trip(unix); + } + } + + #[test] + fn accepts_leap_days_only_when_real() { + assert!(parse("2024-02-29T00:00:00Z").is_some()); + assert!(parse("2026-02-29T00:00:00Z").is_none()); + assert!(parse("2026-02-30T00:00:00Z").is_none()); + assert!(parse("2026-04-31T00:00:00Z").is_none()); + } + + #[test] + fn rejects_other_rfc3339_forms() { + assert_eq!(parse("2026-10-01T12:47:00+03:00"), None, "offset form"); + assert_eq!(parse("2026-10-01T12:47:00.5Z"), None, "sub-second"); + assert_eq!(parse("2026-10-01 12:47:00Z"), None, "space separator"); + assert_eq!(parse("2026-10-01T12:47:00"), None, "missing Z"); + assert_eq!(parse("2026-10-01T24:00:00Z"), None, "hour 24"); + assert_eq!(parse(""), None); + } +} diff --git a/core/src/verify.rs b/core/src/verify.rs new file mode 100644 index 0000000..50570b2 --- /dev/null +++ b/core/src/verify.rs @@ -0,0 +1,156 @@ +//! Verification of `# smol:mail` records: rebuild the sealed-payload signed +//! bytes (SPEC.md §5.3) and check the author's signature against the record's +//! own sender key, trying each `smol:host_key` as the recipient. + +use fumi_core::account::verify_sig; +use fumi_core::crypto::b32; +use fumi_core::transport::{ENVELOPE_VERSION, LABEL_MSG}; + +use crate::feed::{Feed, Record}; +use crate::time; + +/// The canonical Reply-To block a smolmail client emits on an ordinary send +/// (§5.7): the only field a post or repost body may carry and still verify. +pub fn reply_to_body(reply_to: &str, text: &str) -> String { + format!("---\nReply-To: {reply_to}\n---\n{text}") +} + +/// The canonical repost body (PLAN.md): a frontmatter block whose only +/// fields are the repost target and, optionally, the same Reply-To an +/// ordinary send carries, in the client's emission order. +pub fn repost_body(args: &str, reply_to: Option<&str>) -> String { + let mut body = format!("---\nTwt-Repost: {args}\n"); + if let Some(reply_to) = reply_to { + body.push_str(&format!("Reply-To: {reply_to}\n")); + } + body.push_str("---\n"); + body +} + +/// The bytes a `# smol:mail` signature covers (SPEC.md §5.3): +/// `label || to || epk || version || sender || time || body_len || body`, +/// with integers big-endian. +pub fn signed_bytes( + to: &[u8; 32], + epk: &[u8; 32], + sender: &[u8; 32], + time: i64, + body: &[u8], +) -> Vec { + let mut signed = Vec::with_capacity(LABEL_MSG.len() + 32 * 3 + 1 + 8 + 4 + body.len()); + signed.extend_from_slice(LABEL_MSG); + signed.extend_from_slice(to); + signed.extend_from_slice(epk); + signed.push(ENVELOPE_VERSION); + signed.extend_from_slice(sender); + signed.extend_from_slice(&time.to_be_bytes()); + signed.extend_from_slice(&(body.len() as u32).to_be_bytes()); + signed.extend_from_slice(body); + signed +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + /// Signature present and valid. + Verified, + /// No `# smol:mail` line below the record. + Unsigned, + /// Signature present but does not verify. + Invalid, +} + +impl State { + pub fn as_str(&self) -> &'static str { + match self { + State::Verified => "verified", + State::Unsigned => "unsigned", + State::Invalid => "invalid", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Report { + /// The feed claims no `smol:author` or carries no `smol:host_key`: it is + /// plain twtxt and its records are not judged (PLAN.md, Verification). + pub legacy: bool, + /// One state per entry, in file order. + pub states: Vec, +} + +/// The Reply-To an ordinary send carries, derived per record: the author +/// address from the metadata, the sender key from the record itself, so a +/// rotation changes nothing about reconstruction. +fn derived_reply_to(author: &str, sender: &[u8; 32]) -> String { + format!("smol://{}/{}", author, b32(sender)) +} + +/// Verifies every entry of a feed. Each record's signature is tried against +/// every `smol:host_key` and every canonical body form, so records survive a +/// daemon rotation and whichever send form the author used. +pub fn verify(feed: &Feed) -> Report { + let host_keys = feed.host_keys(); + let legacy = feed.is_legacy(); + let author = feed.author().unwrap_or_default(); + let states = feed + .entries() + .iter() + .map(|entry| match &entry.mail { + None => State::Unsigned, + Some(mail) => { + if legacy { + // Not a smol feed: nothing is forged, everything is legacy. + return State::Unsigned; + } + let reply_to = derived_reply_to(&author, &mail.sender); + let bodies: Vec> = match &entry.record { + // A post is bare text or a Reply-To block plus the text. + Record::Post { text, .. } => vec![ + text.clone().into_bytes(), + reply_to_body(&reply_to, text).into_bytes(), + ], + // A repost block may carry the Reply-To of an ordinary send. + Record::Repost { args, .. } => vec![ + repost_body(args, None).into_bytes(), + repost_body(args, Some(&reply_to)).into_bytes(), + ], + }; + let Some(unix) = time::parse(entry.record.timestamp()) else { + return State::Invalid; + }; + host_keys + .iter() + .any(|to| { + bodies.iter().any(|body| { + let signed = signed_bytes(to, &mail.epk, &mail.sender, unix, body); + verify_sig(&mail.sender, &mail.sig, &signed) + }) + }) + .then_some(State::Verified) + .unwrap_or(State::Invalid) + } + }) + .collect(); + Report { legacy, states } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_body_forms() { + assert_eq!( + reply_to_body("smol://a@b/k", "hi"), + "---\nReply-To: smol://a@b/k\n---\nhi" + ); + assert_eq!( + repost_body("@ #abc", None), + "---\nTwt-Repost: @ #abc\n---\n" + ); + assert_eq!( + repost_body("@ #abc", Some("smol://a@b/k")), + "---\nTwt-Repost: @ #abc\nReply-To: smol://a@b/k\n---\n" + ); + } +} diff --git a/core/tests/vectors.rs b/core/tests/vectors.rs new file mode 100644 index 0000000..839afb5 --- /dev/null +++ b/core/tests/vectors.rs @@ -0,0 +1,418 @@ +//! Reference vectors pinning every verification state (PLAN.md step 3). +//! +//! Cases are built from fixed seeds; Ed25519 signing is deterministic, so a +//! rebuilt case is byte-identical to `vectors.json`. The regular tests pin +//! both directions — the JSON must verify to its recorded states, and the +//! code must regenerate exactly the JSON's feeds. `write_vectors` +//! regenerates the file after an intentional format change: +//! `cargo test -- --ignored write_vectors`. + +use fumi_core::account::{identity_seed, Identity}; +use fumi_core::crypto::b32; +use smolboid_core::feed::{Feed, MailLine}; +use smolboid_core::verify::{reply_to_body, repost_body, signed_bytes, verify, State}; +use smolboid_core::time; + +/// Distinct fixed masters: 0xA0/0xA1 are the daemon's identity keys across a +/// rotation, 0xB0 a foreign instance, 0x5B the author. +const HOST0: u8 = 0xA0; +const HOST1: u8 = 0xA1; +const FOREIGN: u8 = 0xB0; +const AUTHOR: u8 = 0x5B; +const EPK: [u8; 32] = [0xE7; 32]; + +fn identity(master: u8, index: u32) -> Identity { + Identity::from_seed(identity_seed(&[master; 32], index)) +} + +/// Signs a record the way smolmail seals it, so the feed carries the very +/// signature that sealed the mail (PLAN.md, Signing). +fn sign(sender: &Identity, to: [u8; 32], unix: i64, body: &str) -> MailLine { + let sender_pk = sender.pk(); + MailLine { + sender: sender_pk, + epk: EPK, + sig: sender.sign(&signed_bytes(&to, &EPK, &sender_pk, unix, body.as_bytes())), + } +} + +fn post(sender: &Identity, to: [u8; 32], unix: i64, text: &str) -> String { + let mail = sign(sender, to, unix, text); + // The bare form of a post. + format!( + "{}\t{}\n{}\n", + time::render(unix), + text, + mail.render() + ) +} + +fn post_with_reply_to(sender: &Identity, to: [u8; 32], unix: i64, reply_to: &str, text: &str) -> String { + let mail = sign(sender, to, unix, &reply_to_body(reply_to, text)); + format!( + "{}\t{}\n{}\n", + time::render(unix), + text, + mail.render() + ) +} + +fn repost(sender: &Identity, to: [u8; 32], unix: i64, args: &str) -> String { + let mail = sign(sender, to, unix, &repost_body(args, None)); + format!( + "# smol:repost {} {}\n{}\n", + time::render(unix), + args, + mail.render() + ) +} + +fn repost_with_reply_to(sender: &Identity, to: [u8; 32], unix: i64, reply_to: &str, args: &str) -> String { + let mail = sign(sender, to, unix, &repost_body(args, Some(reply_to))); + format!( + "# smol:repost {} {}\n{}\n", + time::render(unix), + args, + mail.render() + ) +} + +struct Case { + name: &'static str, + feed: String, + legacy: bool, + states: Vec, +} + +fn metadata(host_keys: &[[u8; 32]]) -> String { + let mut meta = String::from( + "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n", + ); + for key in host_keys { + meta.push_str(&format!("# smol:host_key = {}\n", b32(key))); + } + meta +} + + + +fn cases() -> Vec { + let host0 = identity(HOST0, 0).pk(); + let host1 = identity(HOST1, 0).pk(); + let foreign = identity(FOREIGN, 0).pk(); + let author = identity(AUTHOR, 0); + let rotated = identity(AUTHOR, 1); + let t1: i64 = 1_760_000_000; + let t2 = t1 + 3600; + let t3 = t2 + 3600; + + let meta1 = metadata(&[host0]); + + vec![ + Case { + name: "verified_post", + feed: format!("{}{}", meta1, post(&author, host0, t1, "hello smolverse")), + legacy: false, + states: vec![State::Verified], + }, + Case { + name: "unsigned_post", + feed: format!( + "{}{}\t{}\n", + meta1, + time::render(t1), + "no signature line" + ), + legacy: false, + states: vec![State::Unsigned], + }, + Case { + name: "invalid_tampered_text", + // Signed over "hello smolverse", published as "hello sm0lverse". + feed: format!( + "{}{}\t{}\n{}\n", + meta1, + time::render(t1), + "hello sm0lverse", + sign(&author, host0, t1, "hello smolverse").render() + ), + legacy: false, + states: vec![State::Invalid], + }, + Case { + name: "invalid_foreign_host", + // Sealed to another instance: no host_key matches, so the record + // cannot be laundered into this feed. + feed: format!( + "{}{}", + meta1, + post(&author, foreign, t1, "sealed elsewhere") + ), + legacy: false, + states: vec![State::Invalid], + }, + Case { + name: "invalid_bad_timestamp", + feed: format!( + "{}{}\t{}\n{}\n", + meta1, + "2026-13-40T99:99:99Z", + "unparseable time", + sign(&author, host0, t1, "unparseable time").render() + ), + legacy: false, + states: vec![State::Invalid], + }, + Case { + name: "legacy_feed", + // No smol:author and no host_key: plain twtxt, records not judged. + feed: format!( + "# nick = tobias\n{}\t{}\n", + time::render(t1), + "just a legacy twt" + ), + legacy: true, + states: vec![State::Unsigned], + }, + Case { + name: "orphan_mail_line", + feed: format!( + "{}{}{}", + meta1, + post(&author, host0, t1, "with a signature"), + format!( + "# smol:mail {} {} {}\n", + b32(&author.pk()), + b32(&EPK), + b32(&author.sign(&signed_bytes( + &host0, + &EPK, + &author.pk(), + t2, + "orphaned".as_bytes() + ))) + ) + ), + legacy: false, + // The second mail line belongs to no record and is ignored. + states: vec![State::Verified], + }, + Case { + name: "host_rotation", + // Both eras verify because each host_key is tried in turn. + feed: format!( + "{}{}{}", + metadata(&[host0, host1]), + post(&author, host0, t1, "before the rotation"), + post(&author, host1, t2, "after the rotation") + ), + legacy: false, + states: vec![State::Verified, State::Verified], + }, + Case { + name: "author_rotation", + // Per-record sender keys: old posts stay valid under the old key. + feed: format!( + "{}{}{}", + meta1, + post(&author, host0, t1, "identity index 0"), + post(&rotated, host0, t2, "identity index 1") + ), + legacy: false, + states: vec![State::Verified, State::Verified], + }, + Case { + name: "verified_repost", + feed: format!( + "{}{}", + meta1, + repost( + &author, + host0, + t1, + "@ #abc1234" + ) + ), + legacy: false, + states: vec![State::Verified], + }, + Case { + name: "invalid_repost_tampered", + feed: format!( + "{}{}", + meta1, + repost( + &author, + host0, + t1, + "@ #abc1234" + ) + .replace("#abc1234", "#fedcba9") // signed over #abc1234 + ), + legacy: false, + states: vec![State::Invalid], + }, + Case { + name: "dashed_post", + // Text beginning with `---` is sent raw: a single line can never + // open a §5.5 frontmatter block, so nothing is escaped. + feed: format!( + "{}{}", + meta1, + post(&author, host0, t1, "--- dashed opening") + ), + legacy: false, + states: vec![State::Verified], + }, + Case { + name: "reply_to_post", + // An ordinary send carries a signed Reply-To block; the verifier + // derives the same value from the author address and sender key. + feed: format!( + "{}{}", + meta1, + post_with_reply_to( + &author, + host0, + t1, + &format!("smol://tobias@smol.place/{}", b32(&author.pk())), + "hello from an ordinary send" + ) + ), + legacy: false, + states: vec![State::Verified], + }, + Case { + name: "reply_to_key_mismatch_is_forged", + // The signature covers a Reply-To naming a different key than + // the record's sender, so no derived form reconstructs. + feed: format!( + "{}{}", + meta1, + post_with_reply_to( + &author, + host0, + t1, + &format!("smol://tobias@smol.place/{}", b32(&[0xEE; 32])), + "signed with a foreign reply-to" + ) + ), + legacy: false, + states: vec![State::Invalid], + }, + Case { + name: "repost_with_reply_to", + feed: format!( + "{}{}", + meta1, + repost_with_reply_to( + &author, + host0, + t1, + &format!("smol://tobias@smol.place/{}", b32(&author.pk())), + "@ #abc1234" + ) + ), + legacy: false, + states: vec![State::Verified], + }, + Case { + name: "mixed_feed", + feed: format!( + "{}{}\t{}\n{}{}{}", + meta1, + time::render(t1), + "unsigned legacy line", + post(&author, host0, t2, "signed after it"), + repost(&author, host0, t3, "@ #hash123"), + post(&author, foreign, t1 + 7200, "signed to a foreign host") + ), + legacy: false, + states: vec![ + State::Unsigned, + State::Verified, + State::Verified, + State::Invalid, + ], + }, + ] +} + +#[test] +fn cases_verify_to_their_expected_states() { + for case in cases() { + let report = verify(&Feed::parse(&case.feed)); + assert_eq!(report.legacy, case.legacy, "{}: legacy flag", case.name); + assert_eq!(report.states, case.states, "{}: states", case.name); + } +} + +#[test] +fn parse_render_round_trips_every_case() { + for case in cases() { + assert_eq!(Feed::parse(&case.feed).render(), case.feed, "{}", case.name); + } +} + +#[test] +fn vectors_json_pins_the_generated_cases() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors.json"); + let pinned: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(path).expect("vectors.json present")) + .expect("vectors.json parses"); + let pinned_cases = pinned["cases"].as_array().expect("cases array"); + let generated = cases(); + assert_eq!( + pinned_cases.len(), + generated.len(), + "vectors.json and the code disagree on the case set" + ); + + for (pinned, case) in pinned_cases.iter().zip(&generated) { + assert_eq!(pinned["name"], case.name, "case order"); + assert_eq!(pinned["feed"], case.feed, "{}: feed bytes", case.name); + assert_eq!(pinned["legacy"], case.legacy, "{}: legacy", case.name); + let states: Vec<&str> = pinned["states"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap()) + .collect(); + let expected: Vec<&str> = case.states.iter().map(State::as_str).collect(); + assert_eq!(states, expected, "{}: states", case.name); + + // The pinned feed must verify exactly as recorded. + let report = verify(&Feed::parse(case.feed.as_str())); + assert_eq!(report.legacy, case.legacy); + assert_eq!( + report + .states + .iter() + .map(State::as_str) + .collect::>(), + states + ); + } +} + +#[test] +#[ignore = "regenerates core/vectors.json after an intentional format change"] +fn write_vectors() { + let cases: Vec = cases() + .iter() + .map(|case| { + serde_json::json!({ + "name": case.name, + "feed": case.feed, + "legacy": case.legacy, + "states": case.states.iter().map(State::as_str).collect::>(), + }) + }) + .collect(); + let doc = serde_json::json!({ + "comment": "smolboid-core verification vectors; regenerate with: cargo test -- --ignored write_vectors", + "cases": cases, + }); + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/vectors.json"); + std::fs::write(path, serde_json::to_string_pretty(&doc).unwrap() + "\n").unwrap(); +} diff --git a/core/vectors.json b/core/vectors.json new file mode 100644 index 0000000..e0022e7 --- /dev/null +++ b/core/vectors.json @@ -0,0 +1,138 @@ +{ + "cases": [ + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\thello smolverse\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq ia6di7h2xao6qwr6ry2ikcwwv2wzf7lrqytr6w7iwglq43ltky4q5r36tnbjwu6nunjtc3nu3evonherkc6cawbii3tb3s6ciul7obi\n", + "legacy": false, + "name": "verified_post", + "states": [ + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tno signature line\n", + "legacy": false, + "name": "unsigned_post", + "states": [ + "unsigned" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\thello sm0lverse\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq ia6di7h2xao6qwr6ry2ikcwwv2wzf7lrqytr6w7iwglq43ltky4q5r36tnbjwu6nunjtc3nu3evonherkc6cawbii3tb3s6ciul7obi\n", + "legacy": false, + "name": "invalid_tampered_text", + "states": [ + "invalid" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tsealed elsewhere\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq n24wqnx5zek6ijlntuzdfzoht7x3smwvbkwbar7mvmulqziozexkbhojwmpqmrmxamxhonhlmuguv45zwidf2p3rq53fgpqbsahwsby\n", + "legacy": false, + "name": "invalid_foreign_host", + "states": [ + "invalid" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2026-13-40T99:99:99Z\tunparseable time\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq konvdbrlvpoilkuljesxdqevmeszow2o7uoq4han4iciiieqs7wsegdhyuhl4ykw6b3lz4zpocwdspel26vjvp6jndeug2ng2i4h2ca\n", + "legacy": false, + "name": "invalid_bad_timestamp", + "states": [ + "invalid" + ] + }, + { + "feed": "# nick = tobias\n2025-10-09T08:53:20Z\tjust a legacy twt\n", + "legacy": true, + "name": "legacy_feed", + "states": [ + "unsigned" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\twith a signature\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq 4wygpwalnweej76ge746tzof52b33hbuewkoock44bvpjzqtbe2my3hq6vccvytbyzydhbtxcbti6x6nwiux57tgdozv3pamcpmueca\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq 45sk6einnju7xcphanrgm3mwtfw2xh32moholp46vgrt6lvp7r2ekyvxh7ka5pqfqfdbsunaw4i4jxvzmpkhhrvagdfwmktgnpceuby\n", + "legacy": false, + "name": "orphan_mail_line", + "states": [ + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:host_key = erufyonapvqh5r5kr7rkgi4lnqaigh33tz6cnnaledofy7feel7a\n2025-10-09T08:53:20Z\tbefore the rotation\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq mdlh7eb4uqim3stgtwywzgx4y45jc6kfbuzsxnab62ggsngijod6utgad366q6mm4gxic4qaedwmkr7g3gcv6n4ki4k7tuwxsq7r2dq\n2025-10-09T09:53:20Z\tafter the rotation\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq vrsqckvlsftuurdwtbneigdtnle5lkpcjthyzqhkantnjkjtsrguwqw367cnlrx4udmuntvjbj2jvcyyahp5uvtjb4q7idoiqjbveay\n", + "legacy": false, + "name": "host_rotation", + "states": [ + "verified", + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tidentity index 0\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq l7mhywrghkgqtyfb5t4e4xgfmcpteqdurjagm3w55qomncirgdjf3h6dkr5lpw23tgoxag6vk4cmfrvz7k3chopenuvaexnosnpjeby\n2025-10-09T09:53:20Z\tidentity index 1\n# smol:mail ceitvjvvappgrm3hmogulwfkw3s43qvm5vcgk7c3pg7daonzreea 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq xzsg3owvd6fecwfq5tfscrrk7a7jhzimghbbgtv2ssxnzed2cakmekvzeawehto55uzcds7iqjfgl6nulylkohubywplamv6rrmzwbq\n", + "legacy": false, + "name": "author_rotation", + "states": [ + "verified", + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:repost 2025-10-09T08:53:20Z @ #abc1234\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq t7zwas3yeane6ezyzg4cqnglyv5ccdaklqu6pk4ucrgyqjj2y6ghyzx57blg3dav42rkwrmmbgnas3pch4wkj3vkqbvo22exdreewby\n", + "legacy": false, + "name": "verified_repost", + "states": [ + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:repost 2025-10-09T08:53:20Z @ #fedcba9\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq t7zwas3yeane6ezyzg4cqnglyv5ccdaklqu6pk4ucrgyqjj2y6ghyzx57blg3dav42rkwrmmbgnas3pch4wkj3vkqbvo22exdreewby\n", + "legacy": false, + "name": "invalid_repost_tampered", + "states": [ + "invalid" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\t--- dashed opening\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq 6udoaeejb2ydygw5ld75bayflszlnevmr5kx5z3pv52dajmzj46owv3ejw6b22gs5bddqcvugyece354h5byl4vohfze6lspd2ihgbi\n", + "legacy": false, + "name": "dashed_post", + "states": [ + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\thello from an ordinary send\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq p6luqrmt62nvzqrhpchmtm3mcfydp5jn6fh4hioyueqmd66e6hmdgxne3iaspmo6rknvd5hv5xfaddrczjuu6h7dqkcdnkamnawviaa\n", + "legacy": false, + "name": "reply_to_post", + "states": [ + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tsigned with a foreign reply-to\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq ny4xpgadtesuz64z6c6dr7zmzknbd3gg5m7r276wrk7lk3znfah7nnrt4i3m5oryrklrpxhsknhouu2grffpvndr7cakorjb6io4yaq\n", + "legacy": false, + "name": "reply_to_key_mismatch_is_forged", + "states": [ + "invalid" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n# smol:repost 2025-10-09T08:53:20Z @ #abc1234\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq sumdf5lz5rk4falzxw7njfugqign32z6vvceprmlfreelaagcq66ctvkkooiqfipaayxzpjyzwsfjoy6bvmc2vvl4s5elj6kh67zaby\n", + "legacy": false, + "name": "repost_with_reply_to", + "states": [ + "verified" + ] + }, + { + "feed": "# nick = tobias\n# url = https://smol.place/twtxt/tobias.txt\n# smol:author = tobias@smol.place\n# smol:host_key = guluji3jitwof5nrilv7qyfpwbx2qsohz2sq76sisnmxxdayfbda\n2025-10-09T08:53:20Z\tunsigned legacy line\n2025-10-09T09:53:20Z\tsigned after it\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq iossrjv4a3z7pa7pcfw6epv2sjhnnreoh4bvzs3txq5rnb3767giuqogbpeqkyxggju2fewfyvne7smr6gsumtz6z2f4r3fr6gm7uba\n# smol:repost 2025-10-09T10:53:20Z @ #hash123\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq jca43z6yttue5epkuplkbtimozmsk6zvgy44xbv65xjbvspm7pykriadwnhad7ay2rdnmiiq5vnwerijivaoambrlium223vremqkci\n2025-10-09T10:53:20Z\tsigned to a foreign host\n# smol:mail bnelnksnti3gs4u6dfblgtw4rbwqefh43fu2efeops54jm574dpq 47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47t6pz7h47tq fxbfyyjtv653e4fny25q2mwihwpxkdfs6zfpxtozto57ggidqpcxsaohrkxpe2pohj5cehjdr5qw2pbqmx5t4nesagpqjkhit7aj4bq\n", + "legacy": false, + "name": "mixed_feed", + "states": [ + "unsigned", + "verified", + "verified", + "invalid" + ] + } + ], + "comment": "smolboid-core verification vectors; regenerate with: cargo test -- --ignored write_vectors" +} diff --git a/devbox.json b/devbox.json new file mode 100644 index 0000000..bdcb994 --- /dev/null +++ b/devbox.json @@ -0,0 +1,32 @@ +{ + "packages": [ + "rustc@latest", + "cargo@latest", + "gcc@latest" + ], + "shell": { + "scripts": { + "build": [ + "cargo build --workspace" + ], + "test": [ + "cargo test --workspace" + ], + "check": [ + "cargo check --workspace" + ], + "vectors": [ + "cargo test -p smolboid-core --test vectors -- --ignored write_vectors" + ], + "rig": [ + "bash tests/rig.sh" + ], + "stress": [ + "bash tests/stress.sh" + ], + "pentest": [ + "bash tests/pentest.sh" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 0000000..46dfd53 --- /dev/null +++ b/devbox.lock @@ -0,0 +1,178 @@ +{ + "lockfile_version": "1", + "packages": { + "cargo@latest": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo", + "source": "devbox-search", + "version": "1.97.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/cvd2pzmgq4pr7kkzx2zmya47fqd70sqa-cargo-1.97.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/z64fp7fjfy9zr3xn7prdidi4gww213d5-cargo-1.97.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1", + "default": true + } + ], + "store_path": "/nix/store/dv55qwamlq68hdiprg1v104ymwh1c27r-cargo-1.97.1" + } + } + }, + "gcc@latest": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#gcc16", + "source": "devbox-search", + "version": "16.2.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/z3zd0lklg2b8705zmx11cq35crddbpj4-gcc-wrapper-16.2.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/pbj8r9p89ilvfklxrj3fyvqdadac0861-gcc-wrapper-16.2.0-man", + "default": true + }, + { + "name": "info", + "path": "/nix/store/g210v8iyk80zy476vp9d7nkgvv5s3i1r-gcc-wrapper-16.2.0-info" + } + ], + "store_path": "/nix/store/z3zd0lklg2b8705zmx11cq35crddbpj4-gcc-wrapper-16.2.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d0a9bdlmsac38hi0kscqcnh2pn4439ss-gcc-wrapper-16.2.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/pyn8mm9fribahydk1bxby0c56r3qmfmb-gcc-wrapper-16.2.0-man", + "default": true + }, + { + "name": "info", + "path": "/nix/store/7vfckbslv452rdwwcqa5r5i4rq47m3rj-gcc-wrapper-16.2.0-info" + } + ], + "store_path": "/nix/store/d0a9bdlmsac38hi0kscqcnh2pn4439ss-gcc-wrapper-16.2.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/8pfs1i59j9zxy2c7h0wmb8vcda75pjxv-gcc-wrapper-16.2.0", + "default": true + }, + { + "name": "man", + "path": "/nix/store/pmnqpcv27gdk4nf3py76fcr2g42f2ybb-gcc-wrapper-16.2.0-man", + "default": true + }, + { + "name": "info", + "path": "/nix/store/nh1dnj05p8lygbd8hkvj07r1h5vvbbqf-gcc-wrapper-16.2.0-info" + } + ], + "store_path": "/nix/store/8pfs1i59j9zxy2c7h0wmb8vcda75pjxv-gcc-wrapper-16.2.0" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-09-29T03:29:29Z", + "resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D" + }, + "rustc@latest": { + "last_modified": "2026-08-27T07:16:00Z", + "plugin_version": "0.0.1", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#rustc", + "source": "devbox-search", + "version": "1.97.1", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/grw7ifdixkdr4ya8bw5x0psjyfr5azva-rustc-wrapper-1.97.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/8qkha38sic70l51g4l7dmjrxnzqjrr6w-rustc-wrapper-1.97.1-doc" + } + ], + "store_path": "/nix/store/swrk83krgf2iaqd7a1bs0pg1sinlz5ds-rustc-wrapper-1.97.1" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/hn340n5pi9p30j4bcxgzqgq8lmclg1by-rustc-wrapper-1.97.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/avbwjkw896rb3nl28kh8qv6kxyq5b9v2-rustc-wrapper-1.97.1-doc" + } + ], + "store_path": "/nix/store/d6bnbpk6jr95frzmmksda4r7l3v8d3m6-rustc-wrapper-1.97.1" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1", + "default": true + }, + { + "name": "man", + "path": "/nix/store/an2hvf5i7mq52mqdad9rad8mxkpksmq2-rustc-wrapper-1.97.1-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/8svfp79q0gx10gcrsyxf1pw04q8jf0lp-rustc-wrapper-1.97.1-doc" + } + ], + "store_path": "/nix/store/yaa1sq5g4jar7rvlspy7kk2wd98nj6qh-rustc-wrapper-1.97.1" + } + } + } + } +}