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.
This commit is contained in:
randogoth 2026-10-14 21:01:21 +03:00
parent 1171d2157d
commit 4dab1540ac
2 changed files with 53 additions and 5 deletions

View file

@ -43,13 +43,29 @@ timeout = 30 # smolmail network timeout, seconds
posts_per_day = 50 # per-feed daily quota posts_per_day = 50 # per-feed daily quota
reposts_per_day = 50 reposts_per_day = 50
cap_bytes = 1048576 # per-feed size cap, default 1 MiB 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 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. ## 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](#the-authors-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 admit --config smolboid.toml tobias@smol.place # verify, accept, create the feed
@ -57,6 +73,22 @@ 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 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 ## Deploying with Nix
The flake exposes `packages.default`, `packages.static` (a static musl binary, The flake exposes `packages.default`, `packages.static` (a static musl binary,
@ -98,12 +130,21 @@ the two can disagree — the lock is the one that ships.
## The author's side ## 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. 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" 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`: Management travels as frontmatter fields, which fumi emits with `--header`:
| Field | Value | Effect | | Field | Value | Effect |
@ -112,6 +153,10 @@ Management travels as frontmatter fields, which fumi emits with `--header`:
| `Twt-Delete` | a record timestamp | Remove that post or repost | | `Twt-Delete` | a record timestamp | Remove that post or repost |
| `Twt-Repost` | `<mention> <#hash>` | Publish a 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-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 "" fumi send twtxt@smol.place --header Twt-Delete:2026-10-01T12:47:00Z --body ""
@ -148,4 +193,4 @@ Read this before running an instance on the public internet.
**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. **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. **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.

View file

@ -20,9 +20,12 @@ The reader of a feed verifies the author's own §5.3 payload signature, rebuilt
## Security properties, each with its enforcement point and proof ## 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. - **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. - **The feed path cannot escape its directory.** A slug is validated before any filesystem access (`feedstore::valid_slug`), and the join is then checked to have stayed inside `feeds/` — so traversal, encoding, null-byte, unicode and dotfile forms all 404 without touching a file outside it. Proven by nine pentest path probes, a reject table and an HTTP property test.
The grammar widened when remote authors got host-namespaced slugs (`alice@other.org`), and that changed the guard's character rather than just its range: a path could not previously contain a dot, which is *why* traversal was unreachable. Dots are legal now, so `..`, adjacent separators, uppercase, `%`, `/` and empty DNS labels are each refused explicitly. The nick rule (`valid_username`, 1-30 bytes of `[a-z0-9-]`) is deliberately a separate function and was left untouched, because a nick reaches `# nick =` metadata that other twtxt clients parse; a test asserts the two have not been conflated.
- **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. - **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. - **An unknown sender is answered only after proving they hold their address.** Self-service registration deliberately replaced the older property that requests-tier mail was structurally invisible — the daemon now reads that folder. What replaces it is a chain of gates, each proven by a pentest probe: no signed `Reply-To` draws nothing; a `Reply-To` naming an address the sender does not hold draws nothing and pins nothing; an address nobody holds draws nothing; a blocked author stays blocked, checked before any network call; and `registration = silent`, the default, drops unknown senders exactly as before. The resolve is what carries this: a signed `Reply-To` proves only that the sender signed that URI, so answering one without resolving the address would let anyone take a feed in someone else's name — and replying to the claimed `smol://` URI would pin the sender's key as verified under that address, permanently blocking the real owner. Outbound volume is bounded by two global hourly caps, a per-sender reply budget, a per-cycle dial budget, and `register_hosts` when the operator does not want the daemon dialling hosts a stranger chose.
- **Reply subjects cannot be truncated by their own copy.** A rejection reason is interpolated into the reply's `Subject`, and a frontmatter value carrying a line break makes the whole block malformed, so smolmail fails closed toward display (SPEC.md §5.5) and the reply arrives with no subject at all. Found in the rig after five catalogue entries were wrapped for readability; every reason is single-line now, enforced by a test over the whole enum.
- **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. - **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. - **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. - **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.