docs: add README and refresh audit

This commit is contained in:
randogoth 2026-10-05 11:12:23 +03:00
parent 267625b411
commit 21c7cbe4ed
4 changed files with 175 additions and 8 deletions

112
README.md Normal file
View file

@ -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 <server-key-b32>
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` | `<mention> <#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 <sender b32> <epk b32> <signature b32>
```
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/<username>.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/<username>.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.

View file

@ -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.

View file

@ -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"
]
}
}

View file

@ -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",