smolboid/audit.md

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: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. 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 outside feeds/. Proven by nine pentest path probes.
  • 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.
  • 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 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.
  • 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.

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 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).
  • 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.