smolboid/audit.md
2026-10-05 11:14:23 +03:00

72 lines
9.4 KiB
Markdown

# 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 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.
- **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.