feat: add smolboid-core feed format with payload-signature verification

This commit is contained in:
randogoth 2026-10-04 20:33:08 +03:00
commit fd3f9bcf24
13 changed files with 2531 additions and 0 deletions

745
Cargo.lock generated Normal file
View file

@ -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"

4
Cargo.toml Normal file
View file

@ -0,0 +1,4 @@
[workspace]
members = ["core", "daemon"]
default-members = ["core", "daemon"]
resolver = "2"

13
core/Cargo.toml Normal file
View file

@ -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"

161
core/src/envelope.rs Normal file
View file

@ -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<u8>,
}
/// 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<Opened, Error> {
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());
}
}

427
core/src/feed.rs Normal file
View file

@ -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<MailLine>,
}
/// 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<Item>,
}
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<Entry> {
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<MailLine>) {
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<MailLine>,
) -> 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<MailLine> {
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")
}
}
}

18
core/src/lib.rs Normal file
View file

@ -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};

95
core/src/rules.rs Normal file
View file

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

146
core/src/time.rs Normal file
View file

@ -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<i64> {
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<usize>| -> Option<i64> {
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<i64> {
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);
}
}

156
core/src/verify.rs Normal file
View file

@ -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<u8> {
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<State>,
}
/// 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<Vec<u8>> = 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("@<d u> #abc", None),
"---\nTwt-Repost: @<d u> #abc\n---\n"
);
assert_eq!(
repost_body("@<d u> #abc", Some("smol://a@b/k")),
"---\nTwt-Repost: @<d u> #abc\nReply-To: smol://a@b/k\n---\n"
);
}
}

418
core/tests/vectors.rs Normal file
View file

@ -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<State>,
}
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<Case> {
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,
"@<dana https://smol.place/twtxt/dana.txt> #abc1234"
)
),
legacy: false,
states: vec![State::Verified],
},
Case {
name: "invalid_repost_tampered",
feed: format!(
"{}{}",
meta1,
repost(
&author,
host0,
t1,
"@<dana https://smol.place/twtxt/dana.txt> #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())),
"@<dana https://smol.place/twtxt/dana.txt> #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, "@<nick url> #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::<Vec<_>>(),
states
);
}
}
#[test]
#[ignore = "regenerates core/vectors.json after an intentional format change"]
fn write_vectors() {
let cases: Vec<serde_json::Value> = 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::<Vec<_>>(),
})
})
.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();
}

138
core/vectors.json Normal file
View file

@ -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 @<dana https://smol.place/twtxt/dana.txt> #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 @<dana https://smol.place/twtxt/dana.txt> #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 @<dana https://smol.place/twtxt/dana.txt> #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 @<nick url> #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"
}

32
devbox.json Normal file
View file

@ -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"
]
}
}
}

178
devbox.lock Normal file
View file

@ -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"
}
}
}
}
}