# smolboid audit A security and implementation audit of smolboid as of 2026-10-05, covering the `core/` feed format, the `daemon/` binary, the three end-to-end suites (integration rig, stress, pentest) and a dependency vulnerability scan. 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` | 20 core + 31 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) | | Dependency scan | `devbox run audit-deps` | Clean: 80 crate dependencies, zero RustSec advisories (cargo-audit 0.22.2, 2026-10-05) | ## 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 any control character, TAB, LF and CR included (post rule 3, `rules.rs`), so a record can never introduce a second line; a text posing as `# smol:mail` or `# smol:repost` publishes only as timestamped post text. Proven by the pentest's injection and poser probes. - **The feed path cannot escape its directory.** A slug is validated before any filesystem access (`feedstore::valid_slug`), and the join is then checked to have stayed inside `feeds/` — so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outside it. Proven by nine pentest path probes, a reject table and an HTTP property test. The grammar widened when remote authors got host-namespaced slugs (`alice@other.org`), and that changed the guard's character rather than just its range: a path could not previously contain a dot, which is *why* traversal was unreachable. Dots are legal now, so `..`, adjacent separators, uppercase, `%`, `/` and empty DNS labels are each refused explicitly. The nick rule (`valid_username`, 1-30 bytes of `[a-z0-9-]`) is deliberately a separate function and was left untouched, because a nick reaches `# nick =` metadata that other twtxt clients parse; a test asserts the two have not been conflated. - **A record sealed to another instance cannot be laundered here.** The payload signature covers `to`; the feed's `smol:host_key` is the only `to` accepted. Proven by the vectors' `invalid_foreign_host` case. - **An unknown sender is answered only after proving they hold their address.** Self-service registration deliberately replaced the older property that requests-tier mail was structurally invisible — the daemon now reads that folder. What replaces it is a chain of gates, each proven by a pentest probe: no signed `Reply-To` draws nothing; a `Reply-To` naming an address the sender does not hold draws nothing and pins nothing; an address nobody holds draws nothing; a blocked author stays blocked, checked before any network call; and `registration = silent`, the default, drops unknown senders exactly as before. The resolve is what carries this: a signed `Reply-To` proves only that the sender signed that URI, so answering one without resolving the address would let anyone take a feed in someone else's name — and replying to the claimed `smol://` URI would pin the sender's key as verified under that address, permanently blocking the real owner. Outbound volume is bounded by two global hourly caps, a per-sender reply budget, a per-cycle dial budget, and `register_hosts` when the operator does not want the daemon dialling hosts a stranger chose. - **Reply subjects cannot be truncated by their own copy.** A rejection reason is interpolated into the reply's `Subject`, and a frontmatter value carrying a line break makes the whole block malformed, so smolmail fails closed toward display (SPEC.md §5.5) and the reply arrives with no subject at all. Found in the rig after five catalogue entries were wrapped for readability; every reason is single-line now, enforced by a test over the whole enum. - **Forged records are detectable, not trusted.** `smolboid check` exits 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. - **Connection floods are shed, not absorbed.** At most 64 concurrent connections; beyond that the listener answers 503 without spawning a thread, and recovers as soon as the flood closes. Proven by the pentest's 64-connection flood probe and a unit test that asserts the shed and the recovery. - **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: 1. **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. 2. **Reposts published as ordinary posts (fixed).** `apply_record` built a `Record::Post` regardless of kind; now a `Record::Repost`, with a unit test and a rig assertion. 3. **"A post mail has no frontmatter" was unimplementable (fixed by design change).** Every real smolmail client attaches a signed `Reply-To` block, so nothing would ever have published. Posts now accept two canonical body forms (bare, or a block whose only field is `Reply-To`), the verifier derives the block from `smol:author` plus the per-record sender key, and bodies with other fields are rejected at receipt rather than published as unverifiable. PLAN.md updated. 4. **Admission required a welcome mail (fixed).** Without the daemon's Accept token an author's mail stays in requests tier forever; `smolboid admit` now sends a welcome mail carrying it. 5. **A `smol:reply_to` metadata binding would break on rotation (fixed by removal).** The author's Reply-To changes with each rotation; per-record derivation replaced the binding entirely. 6. **A stale base32 length in the plan (fixed).** A 64-byte signature is 103 unpadded base32 characters, not 104. 7. **Unbounded thread-per-connection (fixed).** The listener now caps concurrent connections at 64, answering 503 beyond that; an atomic counter with an RAII guard keeps slots leak-free across early returns. 8. **HEAD returned 405 (fixed).** The listener now answers HEAD with exactly the headers GET would send, Content-Length included, minus the body — what pollers expect. 9. **A CR-run round-trip bug in the feed parser (fixed, found by the new property test).** `str::lines` strips only one CR per line, so a line ending in a CR run kept a CR as content that vanished on the next parse — a load/save cycle could silently change bytes. Trailing CRs are now stripped per line at parse, making render a fixpoint on arbitrary input. ## Open findings, accepted for v1 - **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 admit` with 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:author` is 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). - The fuzzing gap is narrowed but not closed: deterministic property tests now hammer both parsers with thousands of hostile inputs (no panics, render is a fixpoint, every accepted username is path-safe), which already caught the CR-run bug above; what remains is coverage-guided fuzzing and non-UTF-8 input, which would need a nightly toolchain. - 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, property 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 devbox run audit-deps # cargo audit against the RustSec advisory database ``` All suites are self-contained: each starts its own bunshin instance, fumi identities and daemon on local ports and cleans up after itself.