diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ad258a --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# smolboid: signed twtxt over smolmail + +smolboid is a small daemon that serves [twtxt](https://twtxt.readthedocs.io/) feeds for many users and lets them publish and manage their feed by sending ordinary [smolmail](https://code.randogoth.com/randogoth/smolmail). A post is a mail body; management rides custom frontmatter fields. Every published record carries the author's own smolmail payload signature, so any reader can verify authorship offline against the author's key alone — the host can withhold records but cannot forge one. No extra client tool is needed: any conformant smolmail client can post, and legacy twtxt and Yarn clients read the feeds unchanged. + +The workspace is `core/`, the feed format and verification library with no I/O, and `daemon/`, the server binary. [audit.md](audit.md) records the security audit, its findings and the test suites that prove them. + +## Build and test + +devbox manages the toolchain; every script runs in it. + +``` +devbox run build # compile the workspace +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, the fumi CLI as author +devbox run stress # mail flood, quotas, reply budget, HTTP load +devbox run pentest # adversarial probes, raw sockets and real mail +devbox run audit-deps # cargo audit over the dependency tree +``` + +## Running an instance + +The daemon holds one smolmail identity — a 32-byte master key — and one mailbox, e.g. `twtxt@smol.place`. Bootstrap its mailbox once with the fumi CLI against the same key and store, because the first server pin and the registration are operator actions: + +``` +smolboid keygen /srv/smolboid/master.key +fumi --key /srv/smolboid/master.key --db /srv/smolboid/data/mail.db trust smol.place +fumi --key /srv/smolboid/master.key --db /srv/smolboid/data/mail.db register twtxt@smol.place +``` + +Then configure and serve. The config file is one flat `key = value` list; anything unset keeps its default: + +``` +# smolboid.toml +listen = 127.0.0.1:8080 # HTTP listener for the feeds +base_url = https://smol.place # the origin the feeds' # url metadata carries +smolmail_host = smol.place # the host serving the daemon's mailbox +mailbox = twtxt # local part of the mailbox, for display +master = /srv/smolboid/master.key +data = /srv/smolboid/data # state.json, mail.db and feeds/ +poll_secs = 60 # fetch interval +timeout = 30 # smolmail network timeout, seconds +posts_per_day = 50 # per-feed daily quota +reposts_per_day = 50 +cap_bytes = 1048576 # per-feed size cap, default 1 MiB +``` + +``` +smolboid serve --config smolboid.toml +``` + +Admission is by accept token: an author's mail is only processed once the operator admits them, which also creates their feed at the fixed path and sends a welcome mail carrying the token. + +``` +smolboid admit --config smolboid.toml tobias@smol.place # verify, accept, create the feed +smolboid block --config smolboid.toml tobias@smol.place # withdraw the token +smolboid check /srv/smolboid/data/feeds/tobias.txt # verify a feed's records, nonzero on forged ones +``` + +## The author's side + +Post by sending the twt as the mail body, from any smolmail client. Fetch the daemon's welcome mail first — it carries the accept token that moves your posts out of the requests tier. + +``` +fumi send twtxt@smol.place --body "hello smolverse" +``` + +Management travels as frontmatter fields, which fumi emits with `--header`: + +| Field | Value | Effect | +| --- | --- | --- | +| `Twt-Nick` | 1-30 bytes of `[a-z0-9-]` | Set the display nick; the feed path never changes | +| `Twt-Delete` | a record timestamp | Remove that post or repost | +| `Twt-Repost` | ` <#hash>` | Publish a repost | +| `Twt-Prune` | `fifo` or `off` | Choose what a full feed does: prune its oldest records, or reject new ones (the default) | + +``` +fumi send twtxt@smol.place --header Twt-Delete:2026-10-01T12:47:00Z --body "" +``` + +The rules the daemon enforces: text is at most 160 Unicode code points and 640 bytes and may not contain TAB, LF, CR or U+2028; the timestamp is the mail's own time, pinned by the signature, so a same-second resend is rejected with a reply — wait a second and resend; at 90% of the size cap the daemon mails a warning naming the cap and both prune modes. Error replies are subject to the address's rules: at most ten per sender per hour, never twice for the same rejected payload. + +## What a feed looks like + +A standard twtxt file plus `# smol:` comment lines. Legacy clients ignore them; smol-aware clients verify. + +``` +# nick = tobias +# url = https://smol.place/twtxt/tobias.txt +# smol:author = tobias@smol.place +# smol:host_key = <52-char base32> +2026-10-01T12:47:00Z hello smolverse +# smol:mail +``` + +Each record's signature covers the smolmail payload bytes: the author's key, the record's timestamp, and the text in one of the two canonical forms a real client emits (bare, or with the ordinary send's `Reply-To` block). The verifier rebuilds those bytes from the record itself plus the feed's metadata, tries each `smol:host_key` as the recipient, and marks the record verified, unsigned or invalid. `smolboid check` reports exactly that. + +Feeds are served at `GET /twtxt/.txt` (`HEAD` answers with the same headers, no body) with a strong SHA-256 ETag, `Last-Modified`, `Cache-Control: max-age=300` and `Access-Control-Allow-Origin: *`; `If-None-Match` takes precedence over `If-Modified-Since`, and both answer 304. Unknown and missing feeds are 404; the listener sheds concurrent-connection floods with 503 and closes stalled connections after ten seconds. + +## The deployment trust contract + +Read this before running an instance on the public internet. + +**TLS is your job.** The listener is plain HTTP. The `base_url` above assumes TLS termination at a reverse proxy in front of the daemon; pointed at the internet directly, it serves feeds — public data — in cleartext, and a network attacker could serve stale or substituted feeds to readers. Terminate TLS upstream, or front the daemon with whatever serves the rest of your domain. + +**The first pin is on you.** Bootstrap is trust-on-first-use: `fumi trust` pins your smolmail server's Noise static key and `smolboid admit` resolves each author on their own server with the same semantics. A lying DNS path fails closed at the pin mismatch, but the *first* pin you enter is taken on faith. Verify the server key and your authors' keys out of band before admitting them; `smolboid admit` prints each resolved key. + +**The master key is the whole identity.** The 32-byte master at 0600 is the only secret on disk — everything else under `data/` is public keys, counters and ledgers. Back it up, because a lost master means a new identity and a new `smol:host_key` in every feed; a stolen one means an attacker can register the daemon's mailbox elsewhere and post as it, though they cannot forge the authors' records already published. + +**One domain per instance.** The feed path is fixed to the author's smolmail username, and discovery resolves `user@domain` to `https://domain/twtxt/.txt`, so one instance serves one domain; multiple domains run multiple daemons. + +**Authors own their words, not your storage.** An admitted author can fill their quota daily and switch to FIFO pruning; `cap_bytes` is the bound. Unadmitted senders publish nothing and draw no reply, and their mail never leaves the requests tier. diff --git a/audit.md b/audit.md index 08eb789..b1945d7 100644 --- a/audit.md +++ b/audit.md @@ -1,16 +1,17 @@ # 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. +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` | 19 core + 28 daemon + 3 vector pins, all pass | +| 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 @@ -26,6 +27,7 @@ The reader of a feed verifies the author's own §5.3 payload signature, rebuilt - **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. @@ -39,11 +41,12 @@ Each of these was discovered by a suite or by design review against real clients 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 -- **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. @@ -52,18 +55,18 @@ Each of these was discovered by a suite or by design review against real clients ## 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. +- 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, format and vector tests +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. diff --git a/devbox.json b/devbox.json index bdcb994..7bda5d7 100644 --- a/devbox.json +++ b/devbox.json @@ -2,7 +2,8 @@ "packages": [ "rustc@latest", "cargo@latest", - "gcc@latest" + "gcc@latest", + "cargo-audit@latest" ], "shell": { "scripts": { @@ -26,6 +27,9 @@ ], "pentest": [ "bash tests/pentest.sh" + ], + "audit-deps": [ + "cargo audit" ] } } diff --git a/devbox.lock b/devbox.lock index 46dfd53..f8597b2 100644 --- a/devbox.lock +++ b/devbox.lock @@ -1,6 +1,54 @@ { "lockfile_version": "1", "packages": { + "cargo-audit@latest": { + "last_modified": "2026-08-27T07:16:00Z", + "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo-audit", + "source": "devbox-search", + "version": "0.22.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/5g18ii2i6wv13cdbhsfjrnlzp0156hn1-cargo-audit-0.22.2", + "default": true + } + ], + "store_path": "/nix/store/5g18ii2i6wv13cdbhsfjrnlzp0156hn1-cargo-audit-0.22.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fc6nahfb75vwi55kal8r91y89dxjqhvj-cargo-audit-0.22.2", + "default": true + } + ], + "store_path": "/nix/store/fc6nahfb75vwi55kal8r91y89dxjqhvj-cargo-audit-0.22.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/alqirjvsifw7qmz7l0fdp8c2jfznkv1h-cargo-audit-0.22.2", + "default": true + } + ], + "store_path": "/nix/store/alqirjvsifw7qmz7l0fdp8c2jfznkv1h-cargo-audit-0.22.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/3mkwfy7wnzdngx7nj8nvs60jj2r4gmmc-cargo-audit-0.22.2", + "default": true + } + ], + "store_path": "/nix/store/3mkwfy7wnzdngx7nj8nvs60jj2r4gmmc-cargo-audit-0.22.2" + } + } + }, "cargo@latest": { "last_modified": "2026-08-27T07:16:00Z", "resolved": "github:NixOS/nixpkgs/c27cdad491a991b11ed731760aa2ef8db0cb0410#cargo",