ActivityPub federation
A Kiln site can be a fediverse actor: followable from Mastodon, with published content arriving in followers' timelines as it is published (#491).
This is phase 1 — a read-only actor. A site publishes, followers receive. Replies, likes and boosts are accepted and ignored; inbound moderation is a later phase and deliberately intersects the visitor-comments non-goal.
Turning it on
Federation is off unless an operator says so twice.
1. The deployment. KILN_FEDERATION_ENABLED=true, then restart. Off means
every federation route 404s regardless of what any tenant admin has done.
This switch exists because federation is an egress decision, not an editorial one: it makes the server sign and POST to hosts chosen by strangers who followed the site, indefinitely, with nobody watching. An operator whose egress policy forbids that must be able to say so once, centrally.
2. The site.
mix kiln.federation enablewhich prints the handle to announce:
Federation enabled for this site.
Handle: @[email protected]
Actor: https://acme.example.com/actormix kiln.federation status shows both halves of the gate and the follower
count; mix kiln.federation disable turns the site half off. There is no admin
screen yet — the settings UI and follower management are phase 2.
The identity is permanent
enable captures the site's origin once and never recomputes it. An actor
id is its permanent name in the fediverse: remote servers store it, deduplicate
on it, and address deliveries to it.
So the origin is not derived from the request or from the site's current base
URL. If it were, adding a custom_domain later would silently rename the actor
and leave every follower on every remote instance holding an id that 404s, with
no mechanism to learn the new one.
Two consequences worth knowing before you enable:
- Enable under the hostname you intend to keep. Moving a federating site to a new domain is a migration with a redirect story, not a settings edit.
- Disabling keeps the identity. Re-enabling restores the same handle and the same key, so existing followers keep working.
What federates
| Publish | Create |
| Edit a published record | Update |
| Unpublish | Delete (a Tombstone — the body is not re-sent) |
Only content that is published, :public audience, in the default
locale, and of a type that already syndicates a feed.
Each of those is a deliberate narrowing, not an oversight:
- an audience-gated record is published and paywalled, and an outbox is the most public surface there is;
- a record in three languages is three rows — federating all three would notify every follower three times for one article;
- an operator who chose not to put a type in the site's feed did not choose to broadcast it to the fediverse either.
Workflow transitions (in-review, returned-to-draft) federate nothing. A follower has no business learning that a draft moved between editorial columns.
Endpoints
| URL | |
|---|---|
GET /.well-known/webfinger?resource=acct:<user>@<host> | Resolves the handle to the actor |
GET /actor | The actor document, including the public key |
GET /actor/outbox | Recent published content as Create activities |
GET /actor/followers | A count — never the list |
GET /ap/object/:id | The AS2 object for one published document |
POST /actor/inbox | Follow and Undo{Follow} |
Every activity this site delivers carries an object id under /ap/object/, and
that id dereferences — Mastodon re-resolves objects on refresh, on thread
expansion, and to confirm a Delete. Ids are built from the record id rather
than the slug, so a rename does not re-announce an article as a new one.
A single fixed /actor rather than Mastodon's /users/:name: a Kiln site is one
publication, not a user directory, and a fixed path cannot be shadowed by a
content type whose plural happens to collide.
The actor is typed Service, not Person. It is a publication that posts
automatically, Mastodon renders Service actors with a bot marker, and saying
so is more honest than the extra reach Person would buy — mislabelling
automated accounts is what instance moderators block for.
The followers collection publishes a count and no items. Who follows a publication is not the publication's information to publish.
Security
Every inbound activity must carry a valid HTTP Signature. A Follow is a
request to subscribe somebody else's server to this site's firehose; accepting
an unsigned one would let anyone sign up any instance for traffic it never asked
for, with this site's name on it.
Verification checks, in order:
- the signature covers
(request-target),host,dateanddigest. Requiring the set is the whole property: withoutdigestin it, a signature is not bound to a body at all, so a captured request could be replayed with a substituted activity and a recomputedDigest; - the date is within five minutes, and the signature has not been seen
before (#967). A verified signature's SHA-256 is inserted into
federation_seen_signatures— the insert is the check, the hash is the primary key, so a byte-identical resend fails the unique constraint and is answered 401 whatever itsDatesays. Postgres, not a node-local cache, so a replay landing on another node is refused there too. Rows are held for twice the date window and swept hourly (KilnCMS.Federation.SeenSignatureSweeper,KILN_FEDERATION_NONCE_SWEEP_CRON). A store that cannot be written — an outage, or a refused write — fails closed (#1659): the delivery is answered503withRetry-After: 60, so an honest server retries it once the store is back, and nothing is recorded meanwhile. It used to log and accept on the date window alone, which let every replay through exactly when the store could not tell; hostis checked against the site's pinned origin, not against the request.conn.hostis the caller's ownHostheader, so verifying against it would bind a signature to nothing — the same signed request would replay against any other Kiln deployment;- the
Digestheader matches the raw body bytes. A signature over a digest of different bytes than the ones acted on is the classic way this is implemented wrong; - the signing key belongs to the actor named in the activity, and that actor's
document was served from the URL it claims as its
id(actor fetches follow no redirects, so there is exactly one URL to bind to). Without that, an attacker serves a document claiming a popular account's id, signs with their own key, and the follower upsert overwrites the real account's row — redirecting every future delivery to them.
A Follow is additionally refused when the actor's inbox lives on a different
host than the actor (otherwise one actor could name a victim's server as its
inbox and turn the site's publishing schedule into a signed flood aimed at a
third party), and when the site is at its follower ceiling (50,000 by default —
one_per_actor dedups an exact URI, so one attacker domain serving many actor
URLs would otherwise become many permanent delivery targets). A follower count
that fails, or that the policy refuses, counts as "at the ceiling" and is
logged (#1659) — a refused read would otherwise count 0 and switch the ceiling
off.
An Undo is honoured only when it names a Follow addressed to this site.
Undo{Like} and Undo{Announce} are far more common and carry object as a
bare URI, so a looser check would silently delete a follower for un-liking a
post.
The Accept sent back is rebuilt from four known fields rather than echoing the
inbound activity, so a caller cannot choose the size of what this server stores
for 30 days and POSTs back.
The fetch comes after every check that needs no network
The checks above are not run in the order listed. Everything that can be decided without the sender's key is decided before the key is fetched (#1665), because that fetch is the one step an unauthenticated caller can make cost this server a request:
- the activity must be one whose outcome the actor document could change (see below) — otherwise it is accepted and dropped;
- the
Signatureheader must parse, name an accepted algorithm, and cover(request-target) host date digest; theDatemust be inside the window; theDigestmust match the raw body (HttpSignature.precheck/2); - the
keyIdmust belong to the activity's ownactor— compared as strings, the same test later applied to the fetched document, so it refuses nothing a genuine request would pass; - only then is the actor document fetched (or read from the cache below), the signature verified against its key, and the verified signature recorded in the replay store.
A request that fails steps 2 or 3 is answered 401 without a byte leaving the
server. What is left cannot be removed: a well-formed request naming an actor
URL of the caller's choosing still costs one fetch, since telling a forged
signature from a real one needs the key. That fetch is bounded by the cache,
SafeFetch, and the inbox route's per-IP :api rate bucket.
Nothing is fetched for an activity that changes nothing
Verifying a signature needs the sender's key, and that key lives in the sender's actor document — so authenticating anything costs an outbound HTTPS GET to a host the unauthenticated caller named. A ~200-byte POST would otherwise buy a fetch of up to 128 KB, aimed wherever the caller likes, holding a web-tier process open for the length of the fetch.
So the fetch happens only for an activity whose outcome the document could
change: a Follow or an Undo{Follow} addressed to this site's actor.
Every Like, every Announce, every misdelivered Follow is accepted with a
202 and dropped without a byte leaving the server. The trade is that which
activity types this software acts on becomes observable before authentication —
a property of the release, documented here, not of any site.
Actor documents that are fetched are cached for ten minutes, capped, and evicted least-recently-written, so a burst from one actor costs one fetch and a flood of invented actor URLs costs a fixed table. Failures are never cached. The cost of the cache is that an actor rotating its signing key is unrecognised until the entry expires — its activities 401 and its server retries, which is the mild direction for this to fail in.
Outbound requests — actor fetches and inbox deliveries — go through
KilnCMS.SafeFetch, which pins the resolved address and refuses private ranges.
The URLs come from inbound requests written by strangers, which is exactly the
shape SSRF protection exists for: without it, sending a Follow with a crafted
keyId would make this server fetch its own cloud metadata endpoint.
Activities are signed against the request's real host, which is also what
SafeFetch restores after connecting to the pinned IP.
Delivery
One ledger row per activity per follower, with attempts, status and error recorded — the same shape outbound webhooks use, with two differences the fediverse forces:
- more retries (12, with backoff to six hours). A webhook receiver that is down is usually a bug someone is fixing; a fediverse instance that is down is often just restarting. Mastodon retries for days.
- dead followers are dropped, not disabled. A webhook endpoint belongs to the site's own admin, who can re-enable it. A follower belongs to a stranger on another server; there is nobody here to notice a disabled row, so after 12 consecutive failures it is deleted — and that instance is free to follow again if it comes back.
Ledger rows are pruned after 30 days.
Managing it: /editor/federation (#967)
Phase 2 of #491. The admin page shows the two halves of the gate (the
deployment's KILN_FEDERATION_ENABLED, which only an operator sets, and the
site's own switch), the handle and actor id, the editable profile
(display_name / summary), every follower with when it followed, when it
was last delivered to and how many deliveries in a row have failed (a row at
the drop ceiling is marked, and the header says how many followers a publish
will actually reach — Follower's :deliverable read, the same set
AnnounceWorker fans out to), the recent delivery ledger with each failure's
status and error, and the block list.
Blocks are the durable answer to an abusive follower — a bare delete was
not, because the next Follow upserted the row back. A block names an
actor (an exact URI) or an instance (a host, matched against the host
of the actor's URI, so a domain minting a fresh actor per follow is one row).
Blocking removes the followers it covers, so deliveries stop with the block,
and the inbox refuses a Follow from a blocked actor or instance before
writing anything (KilnCMS.Federation.Inbox, check_not_blocked/2) — with a
202, like every other ignored activity, so the remote server does not retry.
Enabling from the page mints the identity exactly as mix kiln.federation enable does; the mix task remains for a shell.
The site's own last_delivered_at (SiteFederation.record_delivery) is now
written by DeliveryWorker on every successful delivery and shown on the page.
Key rotation (#967, #1487)
There is no automatic rotation, and that is a decision rather than a gap.
Peers cache publicKeyPem from the actor document at follow time and refetch
it on their own schedule (Mastodon: on a signature failure, at most every few
hours), so a KilnCMS.Provenance.KeyRegistry-style retired-key set — where a
verifier consults the registry — buys nothing here: the verifier is someone
else's server.
Re-keying on purpose is mix kiln.federation rekey or Re-key on
/editor/federation, both admin-only (SiteFederation's :rekey action,
#1487). It replaces both halves of the keypair and keeps the identity: the
handle, the actor id and the keyId (#main-key) are permanent, so a
rotation is a new PEM behind an unchanged URL, never a new actor. In the same
transaction it queues KilnCMS.Federation.ActorUpdateWorker, which sends every
deliverable follower an actor Update carrying the new publicKeyPem. Because
the job only becomes visible at commit, the Update cannot leave before
/actor serves the key that signs it. Expect the delivery ledger to show a
short burst of failures from peers that verify against their cached key first.
Mastodon then re-fetches the actor and accepts the retry. A peer that neither
processes the Update nor re-fetches keeps failing, and the button's
confirmation says so.
Re-key when the key has leaked or has been lost, not as routine hygiene. A
SECRET_KEY_BASE rotation does not need a re-key: the vault re-encryption
task carries the key across unchanged (see
secrets-rotation.md). If the key leaked,
do not assume re-keying contains it: until each peer has the new key, the old
one still signs traffic those peers accept.
A key the vault cannot open (a SECRET_KEY_BASE rotated out of order) is
reported as such. The federation page and mix kiln.federation status show the
signing key as unreadable, and each delivery fails with "this site's signing
key is unreadable" instead of "federation is not enabled".
What phase 1 does not do
- No admin UI (phase 2). Done in #967 — see above.
- No key rotation. Re-keying is an explicit admin action (#1487), and not automated, by decision — see above.
- No inbound replies, likes or boosts — accepted with a 202 and dropped. A 4xx would make the sending server retry for days over something not built yet, which is a way to get an instance blocked.
- No
sharedInboxon our side. We use a remote server's when it publishes one, so one POST reaches every follower there.