8.5 KiB
smolboid audit
A security and implementation audit of smolboid as of 2026-10-05, covering the core/ feed format, the daemon/ binary, and the three end-to-end suites (integration rig, stress, pentest). The audit was conducted against the smolmail protocol (SPEC.md 1.2), PLAN.md, and the actual fumi-core and bunshin codebases it interoperates with. Everything below reflects executed and observed behavior, not intent.
Verification results
| Suite | Command | Result |
|---|---|---|
| Unit and format tests | devbox run test |
19 core + 28 daemon + 3 vector pins, all pass |
| Reference vectors | devbox run vectors regenerates core/vectors.json |
15 cases pin every verification state; byte-identical regeneration |
| Integration rig | devbox run rig |
Passes: post, nick, repost, delete, timestamp collision, hard limit, FIFO prune, 90% warning, author rotation — all against real bunshin and fumi binaries |
| Stress | devbox run stress |
Passes: 40-send burst with integrity held, exact 25-record quota, reply cap exact at 10, 200 parallel HTTP requests in 271ms (~738 req/s lower bound), all-304 conditional load, zero failed reads during concurrent writes |
| Pentest | devbox run pentest |
Passes: every probe repelled (details below) |
Trust model, as implemented
The reader of a feed verifies the author's own §5.3 payload signature, rebuilt from public data alone: the record's sender key, the line timestamp, the canonical body form, and the feed's smol:host_key metadata. smolboid never signs content and holds no author private key; its role at receipt is admission (feed exists, sender is an owner key, accepted_state is true), not attestation. What the host controls is what gets published at all — it can withhold or prune records, but cannot forge one: a forged or tampered record reconstructs to an invalid state, verified by the pentest's tamper probe and the vectors.
Security properties, each with its enforcement point and proof
- Feed-line injection is impossible. Post and repost text is rejected on TAB, LF, CR or U+2028 (post rule 4,
rules.rs), so a record can never introduce a second line; a text posing as# smol:mailor# smol:repostpublishes only as timestamped post text. Proven by the pentest's injection and poser probes. - The feed path cannot escape its directory. Usernames are validated as 1-30 bytes of
[a-z0-9-]before any filesystem access (feedstore.rs), so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outsidefeeds/. Proven by nine pentest path probes. - A record sealed to another instance cannot be laundered here. The payload signature covers
to; the feed'ssmol:host_keyis the onlytoaccepted. Proven by the vectors'invalid_foreign_hostcase. - Requests-tier mail is structurally invisible. fumi's store splits requests-tier rows into a folder the daemon never lists; unadmitted senders publish nothing, get no feed, and draw no reply. Proven by the pentest's mallory probe.
- Forged records are detectable, not trusted.
smolboid checkexits nonzero on any invalid record; the signature check is the load-bearing mechanism, not the host's say-so. Proven by the tamper probe. - Reply floods are bounded. At most 10 error replies per sender per hour, and never twice for the same rejected payload (state ledger plus dedupe ring). Proven by both suites: 15 and ~39 rejections drew at most 10 answers.
- Quotas and retention hold under rapid fire. The daily cap is never exceeded even by a 40-send burst, and paced sends land exactly on it; FIFO prunes only what the owner directed; hard-limit mode never silently deletes. Proven by the stress suite.
- Slow clients cannot wedge the listener. Socket read/write timeouts of 10s close half-open connections; the listener stayed healthy during and after a slowloris hold. Proven by the pentest.
- Reads never see torn writes. Feeds and state are replaced atomically (temp file plus rename); 60 reads during a concurrent delete all succeeded and the feed verified. Proven by the stress suite.
- Unparseable or hostile input fails closed. Oversized requests are capped at 8 KiB, malformed bodies become
Ignore, blocks with unreconstructible fields are rejected with an explanatory reply rather than published. Proven by pentest parsing probes and dispatch unit tests.
Findings fixed during this audit
Each of these was discovered by a suite or by design review against real clients, and is fixed with a regression test or a vector:
- No read timeout on HTTP connections (fixed). A slowloris client held a thread forever; the listener is thread-per-connection. Socket timeouts added in
http.rs; the pentest now asserts closure at 10s. - Reposts published as ordinary posts (fixed).
apply_recordbuilt aRecord::Postregardless of kind; now aRecord::Repost, with a unit test and a rig assertion. - "A post mail has no frontmatter" was unimplementable (fixed by design change). Every real smolmail client attaches a signed
Reply-Toblock, so nothing would ever have published. Posts now accept two canonical body forms (bare, or a block whose only field isReply-To), the verifier derives the block fromsmol:authorplus the per-record sender key, and bodies with other fields are rejected at receipt rather than published as unverifiable. PLAN.md updated. - Admission required a welcome mail (fixed). Without the daemon's Accept token an author's mail stays in requests tier forever;
smolboid admitnow sends a welcome mail carrying it. - A
smol:reply_tometadata binding would break on rotation (fixed by removal). The author's Reply-To changes with each rotation; per-record derivation replaced the binding entirely. - A stale base32 length in the plan (fixed). A 64-byte signature is 103 unpadded base32 characters, not 104.
Open findings, accepted for v1
- Thread-per-connection is unbounded. Bounded per connection by the 10s timeouts, and the port is operator-exposed, but a connection flood can still spawn many short-lived threads. A concurrent-connection cap is a small future hardening step.
- HEAD returns 405. Legitimate pollers may use HEAD; serving it (same headers, no body) is a small compatibility improvement.
- The HTTP listener is plain HTTP. The plan's
https://origin assumes TLS termination upstream (a reverse proxy); a direct deployment serves feeds in cleartext. This is a deployment requirement to document in the README, not a code defect. - Bootstrap trust is operator-mediated. The daemon's own server pin, registration, and each author's first resolution arrive via the fumi CLI and
smolboid admitwith trust-on-first-use semantics; a compromised or lying DNS/SRV path fails closed at the Noise pin mismatch, but the first pin is taken on faith by the operator. smol:authoris adopted from the first publishing mail. The first Reply-To a feed's owner sends rewrites the metadata's address spelling (empty feeds only). A malicious owner can only rename their own feed's address form, not another user's; cross-feed impact is none, but the rule is worth knowing.- state.json holds no secrets (owner keys are public, counters and ledgers are operational), so it is written world-readable-safe plaintext; the only secret on disk is the daemon master key, 0600 by
keygen, whose protection is the operator's.
Not covered by this audit
- fumi-core and bunshin themselves; they are separate projects with their own test suites, and smolboid depends on their correctness (envelope verification, Noise pin enforcement, tier separation).
- No
cargo audit-style dependency vulnerability scan was run; the dependency tree is small (fumi-core, chacha20poly1305, serde, serde_json) but this check is recommended before any public deployment. - No fuzzing of the feed parser or HTTP request parser; both are small and hand-tested against hostile inputs, but fuzz targets would raise confidence.
- Timing side channels are untested; the crypto layer is all constant-time primitives from fumi-core, but no analysis was performed at this layer.
Reproducing
devbox run test # unit, format and vector tests
devbox run vectors # regenerate and pin core/vectors.json
devbox run rig # end-to-end against a local bunshin
devbox run stress # flood, quota, reply budget, HTTP load
devbox run pentest # adversarial probes, raw sockets and real mail
All suites are self-contained: each starts its own bunshin instance, fumi identities and daemon on local ports and cleans up after itself.