docs: add README and refresh audit
This commit is contained in:
parent
267625b411
commit
21c7cbe4ed
4 changed files with 175 additions and 8 deletions
112
README.md
Normal file
112
README.md
Normal 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.
|
||||
17
audit.md
17
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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
devbox.lock
48
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue