smolboid/README.md
randogoth 4dab1540ac docs: describe self-service registration and restate what replaced the old guard
README gains an Admission section covering both routes, the admin commands,
and the six new config keys. The author's side leads with Twt-Register and
says plainly not to accept this mailbox: doing so makes a client attach an
accept token to every later mail, and a record is verified by rebuilding the
exact signed bytes, which cannot include one — so every post would be refused.

audit.md's "requests-tier mail is structurally invisible" was a stated
hardening property, and this change deliberately reverses it. Rewritten as the
chain of gates that replaces it, each named with the probe that proves it, and
the feed-path property restated: the grammar widened for host-namespaced
slugs, so traversal stopped being structurally impossible and became a rule
that has to be right. Added the subject-truncation property the rig found.

PLAN.md was the design document and is kept as the record of intent, with its
status corrected — it still claimed nothing in src/ existed — and a "since
implemented" note wherever the shipped behaviour diverges, rather than quietly
editing the original intent away.
2026-10-14 21:01:21 +03:00

13 KiB

smolboid: signed twtxt over smolmail

smolboid is a small daemon that serves twtxt feeds for many users and lets them publish and manage their feed by sending ordinary 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 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
registration = silent          # open | closed | silent — see Admission
registrations_per_hour = 10    # global cap on attempts that reach the network
info_replies_per_hour = 30     # global cap on replies to unknown senders
admins =                       # comma-separated addresses allowed admin commands
admin_notify = on              # mail every admin when a feed is created
register_hosts =               # host allowlist; empty means any host
smolboid serve --config smolboid.toml

Admission

Posting needs an accept token (§5.8), and a feed exists only once its owner's address has been verified. There are two ways to get there.

By mail, if registration is open. An author writes to the mailbox; the daemon replies telling them how to register; they send Twt-Register and get a feed, a token and a confirmation. No operator action at all. See The author's side.

Before the daemon answers an unknown sender at all, it resolves the address their signed Reply-To claims, on that address's own server, and requires the resolved key to equal the key that signed the mail. The claim alone proves only that the sender signed that URI — not that they hold the mailbox it names — so without this check a signed Reply-To naming someone else's address would be enough to take their feed. Replying to the claimed smol:// URI would be worse still: it would pin the sender's key as verified under that address, and the real owner could never register afterwards.

registration is silent by default, which drops unknown senders without a reply exactly as every version before self-service did. closed tells them registration is shut. An admin can change it by mail, so the mode lives in state.json and the config key is only its initial value.

By the operator, whatever the mode:

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

admit passes no expected key, because the target has not written in — it prints the resolved key and fingerprint for an out-of-band check instead. A mail-driven registration always passes one.

Admin commands

Listed addresses in admins may run these by mail, from their own address, so an instance can be operated without shell access:

Field Value Effect
Twt-Admit an address Verify, accept and create that author's feed
Twt-Block an address Withdraw their token; the feed keeps being served
Twt-Registration open | closed | silent Change who may register

An admin's own mail reaches the main tier only once the daemon holds a token for them, so admit your admin identity once with the CLI; until then the acks arrive in your requests tier. An admin with no feed still has to prove they hold their address, by the same resolve as any stranger: a signed Reply-To naming an admin proves nothing on its own.

A non-admin sending one of these gets the ordinary answer for their situation and is never told that admin commands exist.

Deploying with Nix

The flake exposes packages.default, packages.static (a static musl binary, for deploy targets that should not compile Rust) and nixosModules.default:

{
  inputs.smolboid.url = "git+https://code.randogoth.com/randogoth/smolboid.git";

  # in your nixosSystem modules:
  imports = [ inputs.smolboid.nixosModules.default ];

  services.smolboid = {
    enable = true;
    listen = "127.0.0.1:8098";
    baseUrl = "https://smol.place";
    smolmailHost = "smol.place";
    masterFile = "/run/smolboid/master.key";   # 32 raw bytes, provisioned out of band
  };
}

The module runs the daemon as a real smolboid user rather than under DynamicUser, because admit, block and the fumi bootstrap are operator commands against the same master key and store and need a stable uid to sudo -u into. It does not generate or manage the master.

nix run .#release-static builds the static binary and publishes it as a Forgejo release on this repo, tagged from daemon/Cargo.toml's version; it refuses unless HEAD carries that tag. Needs FORGEJO_TOKEN with write:repository.

fumi-core is a path dependency on a sibling ../fumi checkout, which escapes this repo and so cannot be reached from a flake src. The flake vendors fumi as its own input and re-lays it beside the source tree in the store, the way bunshin vendors microReticulum. One consequence: nix build compiles against the locked fumi rev while devbox run build uses the sibling worktree, so the two can disagree — the lock is the one that ships.

The author's side

Ask for a feed, if the instance is open:

fumi send twtxt@smol.place --header "Twt-Register: yournick"

Then fetch the reply — it carries the accept token that moves your posts out of the requests tier — and post by sending the twt as the mail body, from any smolmail client:

fumi fetch
fumi send twtxt@smol.place --body "hello smolverse"

Do not accept the daemon's address in your client. Replies arrive in your requests tier (fumi list --requests), which is where to look for them. If you accept the address, your client attaches an accept token to everything you send afterwards — and a published record is verified by rebuilding the exact bytes you signed, which cannot include a token without publishing it. Every post you sent would be refused, with a reply saying so.

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)
Twt-Register empty, or a nick Ask for a feed. Only for a sender who has none
Twt-Help empty Mail back this list and your feed's address

One command per mail. Two of them together, or one mixed with another, is refused rather than half-applied.

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. An unknown sender publishes nothing and gets no token; with registration = open they draw at most one reply telling them how to register, bounded by info_replies_per_hour globally and the per-sender hourly budget. With the default silent they draw nothing at all.