Skip to main content

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 enable

which prints the handle to announce:

Federation enabled for this site.
Handle: @[email protected]
Actor:  https://acme.example.com/actor

mix 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

PublishCreate
Edit a published recordUpdate
UnpublishDelete (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 /actorThe actor document, including the public key
GET /actor/outboxRecent published content as Create activities
GET /actor/followersA count — never the list
GET /ap/object/:idThe AS2 object for one published document
POST /actor/inboxFollow 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, date and digest. Requiring the set is the whole property: without digest in it, a signature is not bound to a body at all, so a captured request could be replayed with a substituted activity and a recomputed Digest;
  • 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 its Date says. 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 answered 503 with Retry-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;
  • host is checked against the site's pinned origin, not against the request. conn.host is the caller's own Host header, so verifying against it would bind a signature to nothing — the same signed request would replay against any other Kiln deployment;
  • the Digest header 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:

  1. the activity must be one whose outcome the actor document could change (see below) — otherwise it is accepted and dropped;
  2. the Signature header must parse, name an accepted algorithm, and cover (request-target) host date digest; the Date must be inside the window; the Digest must match the raw body (HttpSignature.precheck/2);
  3. the keyId must belong to the activity's own actor — compared as strings, the same test later applied to the fetched document, so it refuses nothing a genuine request would pass;
  4. 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 sharedInbox on our side. We use a remote server's when it publishes one, so one POST reaches every follower there.