Align ActivityPub key IDs with actor document #208

Closed
opened 2026-06-26 17:56:28 -05:00 by erik · 2 comments
Owner

Summary

Generated Slugkit ActivityPub signing and actor-key discovery must use a key URI that Mastodon can verify. A slugkit.com production experiment signed outbound Accept activities with a standalone key URL (https://slugkit.com/users/slug/main-key), but the actor document still advertised publicKey.id as https://slugkit.com/users/slug#main-key. Mastodon rejected the signed request with:

{"error":"publicKey id for https://slugkit.com/users/slug does not correspond to https://slugkit.com/users/slug/main-key"}

Implement this correctly in the Slugkit template so generated sites either keep using the fragment key ID consistently, or switch to a standalone key endpoint and advertise that exact key ID from the actor document.

Background from slugkit.com

This came from slugkit.com task task-becaf72b while debugging Mastodon follow acceptance for @slug@slugkit.com.

Important slugkit.com branch/commits:

  • Repo: /home/erik/Private/code/forgejo/slugkit.com
  • Branch: task-becaf72b-activitypub-follow
  • 7bfb5d6 Serve actor keys for generic fetches
    • Made GET https://slugkit.com/users/slug return ActivityPub JSON even without an ActivityPub Accept header.
    • This is relevant because Mastodon may fetch key IDs like ...#main-key by requesting the actor URL without preserving the fragment.
  • b0fd7a6 Sign follow accepts with standalone key URL
    • Tried adding a standalone GET /users/:username/main-key endpoint returning a CryptographicKey.
    • Tried signing immediate follow Accept delivery with https://slugkit.com/users/slug/main-key.
    • Files changed in slugkit.com:
      • src/federation/routes.ts
      • src/federation/follow.ts
      • src/federation/__tests__/routes.test.ts
    • Production image evcraddock/slugkit-com:0.1.6 reached Mastodon, but Mastodon rejected it because the actor document did not advertise the standalone key ID.
  • 833f450 Revert "Sign follow accepts with standalone key URL"
    • Reverted the standalone-key signing experiment in slugkit.com after the Mastodon mismatch was understood.

Current slugkit.com has been reverted to the previous #main-key signing behavior while retaining the generic actor fetch fix.

Mastodon behavior to account for

Mastodon ActivityPub::FetchRemoteKeyService validates standalone legacy key ownership like this:

  • fetch the key URI from the HTTP Signature keyId,
  • read the key object's owner,
  • fetch the owner actor,
  • require the owner actor's publicKey list/object to include the exact key object's id.

For a legacy CryptographicKey, Mastodon effectively requires:

{
  "id": "https://example.com/users/alice/main-key",
  "type": "CryptographicKey",
  "owner": "https://example.com/users/alice",
  "publicKeyPem": "..."
}

and the actor at https://example.com/users/alice must advertise that same exact key ID, for example:

{
  "id": "https://example.com/users/alice",
  "type": "Person",
  "publicKey": {
    "id": "https://example.com/users/alice/main-key",
    "owner": "https://example.com/users/alice",
    "publicKeyPem": "..."
  }
}

If the signer uses /main-key but the actor advertises #main-key, Mastodon rejects with publicKey id ... does not correspond ....

Mastodon actor refresh is not immediate: Account::STALE_THRESHOLD = 1.day; there is also a 5-minute stoplight cool-off for repeated fetch/connect failures. Do not depend on cache expiry for correctness.

Steps to reproduce

  1. Generate or run a Slugkit site with ActivityPub enabled and a configured local actor.
  2. Ensure the site signs outbound ActivityPub delivery with a key ID that differs from the actor document's publicKey.id.
  3. Follow the actor from Mastodon, causing the Slugkit site to send an Accept back to Mastodon.
  4. Inspect Mastodon response/logging for signature verification errors.

Expected behavior

  • The generated actor document advertises the exact key URI used in HTTP Signature keyId.
  • If the generated site exposes a standalone key endpoint such as /users/:username/main-key, that key object has owner set to the actor URI and the actor's publicKey.id matches the standalone key URI.
  • If the generated site keeps using fragment key IDs such as /users/:username#main-key, actor fetches without an ActivityPub Accept header still return the ActivityPub actor JSON so Mastodon can resolve the key.
  • Mastodon accepts outbound signed Accept delivery for follows, and the remote UI transitions from pending follow (Cancel request) to active following (Unfollow) after reload.

Actual behavior

  • slugkit.com standalone-key experiment produced a valid-looking key endpoint but left the actor document advertising #main-key.
  • Mastodon rejected outbound signed Accept delivery because the signature keyId did not correspond to the actor's advertised publicKey.id.

Implementation notes

  • Port the relevant slugkit.com investigation into the Slugkit template, not just slugkit.com.
  • Inspect template equivalents of:
    • src/federation/routes.ts
    • src/federation/follow.ts
    • src/federation/__tests__/routes.test.ts
  • Choose one consistent strategy:
    1. Keep #main-key everywhere and ensure generic actor fetch works for key resolution, or
    2. Use /main-key everywhere: actor document publicKey.id, key endpoint id, and HTTP Signature keyId.
  • Prefer tests that encode Mastodon's ownership rule: when key ID is /main-key, the actor document must include /main-key, not #main-key.
  • Include a regression test for generic actor fetch without an ActivityPub Accept header if the template relies on fragment key IDs.
  • Ensure generated-site docs mention the chosen key-ID convention and why mismatches break Mastodon follow acceptance.

Acceptance criteria

  • Generated ActivityPub actor documents advertise the exact key URI used for outbound HTTP signatures.
  • If a standalone /users/:username/main-key endpoint is generated, it returns a CryptographicKey whose id matches the actor document's publicKey.id and whose owner is the actor URI.
  • If fragment key IDs remain the template default, generic GET /users/:username key-resolution fetches return ActivityPub actor JSON without requiring a special Accept header.
  • Regression tests cover the key-ID/actor-document match for the chosen strategy.
  • Regression tests cover outbound follow Accept signing using the advertised key URI.
  • A generated site can accept a Mastodon follow without Mastodon reporting Public key not found or publicKey id ... does not correspond ....

Dependencies

  • Related completed task: task-677d2a8f (Start generated ActivityPub queue worker).
  • No blocking dependency.
## Summary Generated Slugkit ActivityPub signing and actor-key discovery must use a key URI that Mastodon can verify. A slugkit.com production experiment signed outbound `Accept` activities with a standalone key URL (`https://slugkit.com/users/slug/main-key`), but the actor document still advertised `publicKey.id` as `https://slugkit.com/users/slug#main-key`. Mastodon rejected the signed request with: ```json {"error":"publicKey id for https://slugkit.com/users/slug does not correspond to https://slugkit.com/users/slug/main-key"} ``` Implement this correctly in the Slugkit template so generated sites either keep using the fragment key ID consistently, or switch to a standalone key endpoint and advertise that exact key ID from the actor document. ## Background from slugkit.com This came from slugkit.com task `task-becaf72b` while debugging Mastodon follow acceptance for `@slug@slugkit.com`. Important slugkit.com branch/commits: - Repo: `/home/erik/Private/code/forgejo/slugkit.com` - Branch: `task-becaf72b-activitypub-follow` - `7bfb5d6 Serve actor keys for generic fetches` - Made `GET https://slugkit.com/users/slug` return ActivityPub JSON even without an ActivityPub `Accept` header. - This is relevant because Mastodon may fetch key IDs like `...#main-key` by requesting the actor URL without preserving the fragment. - `b0fd7a6 Sign follow accepts with standalone key URL` - Tried adding a standalone `GET /users/:username/main-key` endpoint returning a `CryptographicKey`. - Tried signing immediate follow `Accept` delivery with `https://slugkit.com/users/slug/main-key`. - Files changed in slugkit.com: - `src/federation/routes.ts` - `src/federation/follow.ts` - `src/federation/__tests__/routes.test.ts` - Production image `evcraddock/slugkit-com:0.1.6` reached Mastodon, but Mastodon rejected it because the actor document did not advertise the standalone key ID. - `833f450 Revert "Sign follow accepts with standalone key URL"` - Reverted the standalone-key signing experiment in slugkit.com after the Mastodon mismatch was understood. Current slugkit.com has been reverted to the previous `#main-key` signing behavior while retaining the generic actor fetch fix. ## Mastodon behavior to account for Mastodon `ActivityPub::FetchRemoteKeyService` validates standalone legacy key ownership like this: - fetch the key URI from the HTTP Signature `keyId`, - read the key object's `owner`, - fetch the owner actor, - require the owner actor's `publicKey` list/object to include the exact key object's `id`. For a legacy `CryptographicKey`, Mastodon effectively requires: ```json { "id": "https://example.com/users/alice/main-key", "type": "CryptographicKey", "owner": "https://example.com/users/alice", "publicKeyPem": "..." } ``` and the actor at `https://example.com/users/alice` must advertise that same exact key ID, for example: ```json { "id": "https://example.com/users/alice", "type": "Person", "publicKey": { "id": "https://example.com/users/alice/main-key", "owner": "https://example.com/users/alice", "publicKeyPem": "..." } } ``` If the signer uses `/main-key` but the actor advertises `#main-key`, Mastodon rejects with `publicKey id ... does not correspond ...`. Mastodon actor refresh is not immediate: `Account::STALE_THRESHOLD = 1.day`; there is also a 5-minute stoplight cool-off for repeated fetch/connect failures. Do not depend on cache expiry for correctness. ## Steps to reproduce 1. Generate or run a Slugkit site with ActivityPub enabled and a configured local actor. 2. Ensure the site signs outbound ActivityPub delivery with a key ID that differs from the actor document's `publicKey.id`. 3. Follow the actor from Mastodon, causing the Slugkit site to send an `Accept` back to Mastodon. 4. Inspect Mastodon response/logging for signature verification errors. ## Expected behavior - The generated actor document advertises the exact key URI used in HTTP Signature `keyId`. - If the generated site exposes a standalone key endpoint such as `/users/:username/main-key`, that key object has `owner` set to the actor URI and the actor's `publicKey.id` matches the standalone key URI. - If the generated site keeps using fragment key IDs such as `/users/:username#main-key`, actor fetches without an ActivityPub `Accept` header still return the ActivityPub actor JSON so Mastodon can resolve the key. - Mastodon accepts outbound signed `Accept` delivery for follows, and the remote UI transitions from pending follow (`Cancel request`) to active following (`Unfollow`) after reload. ## Actual behavior - slugkit.com standalone-key experiment produced a valid-looking key endpoint but left the actor document advertising `#main-key`. - Mastodon rejected outbound signed `Accept` delivery because the signature `keyId` did not correspond to the actor's advertised `publicKey.id`. ## Implementation notes - Port the relevant slugkit.com investigation into the Slugkit template, not just slugkit.com. - Inspect template equivalents of: - `src/federation/routes.ts` - `src/federation/follow.ts` - `src/federation/__tests__/routes.test.ts` - Choose one consistent strategy: 1. Keep `#main-key` everywhere and ensure generic actor fetch works for key resolution, or 2. Use `/main-key` everywhere: actor document `publicKey.id`, key endpoint `id`, and HTTP Signature `keyId`. - Prefer tests that encode Mastodon's ownership rule: when key ID is `/main-key`, the actor document must include `/main-key`, not `#main-key`. - Include a regression test for generic actor fetch without an ActivityPub `Accept` header if the template relies on fragment key IDs. - Ensure generated-site docs mention the chosen key-ID convention and why mismatches break Mastodon follow acceptance. ## Acceptance criteria - [ ] Generated ActivityPub actor documents advertise the exact key URI used for outbound HTTP signatures. - [ ] If a standalone `/users/:username/main-key` endpoint is generated, it returns a `CryptographicKey` whose `id` matches the actor document's `publicKey.id` and whose `owner` is the actor URI. - [ ] If fragment key IDs remain the template default, generic `GET /users/:username` key-resolution fetches return ActivityPub actor JSON without requiring a special `Accept` header. - [ ] Regression tests cover the key-ID/actor-document match for the chosen strategy. - [ ] Regression tests cover outbound follow `Accept` signing using the advertised key URI. - [ ] A generated site can accept a Mastodon follow without Mastodon reporting `Public key not found` or `publicKey id ... does not correspond ...`. ## Dependencies - Related completed task: `task-677d2a8f` (`Start generated ActivityPub queue worker`). - No blocking dependency.
Author
Owner

Synced from todu comment by @todu on 2026-06-27T21:55:36.661Z

Review update

  • PR: #217 is open with Forgejo CI passing.
  • Result: approved.
  • Follow-up: waiting for explicit human merge approval.

Acceptance criteria: 6/6 met. Blocking issues: 0. Warnings: 0.

_Synced from todu comment by @todu on 2026-06-27T21:55:36.661Z_ ### Review update - PR: https://forge.caradoc.com/erik/slugkit/pulls/217 is open with Forgejo CI passing. - Result: approved. - Follow-up: waiting for explicit human merge approval. Acceptance criteria: 6/6 met. Blocking issues: 0. Warnings: 0.
erik 2026-06-27 18:44:54 -05:00
Author
Owner

Synced from todu comment by @todu on 2026-06-27T23:42:11.537Z

Close gate

Readiness: READY

Evidence:

  • PR #217 was merged into main as merge commit 8c40de4, with implementation commit 9440874 (fix: align ActivityPub sender key IDs).
  • Implementation updated template/site/src/federation/routes.ts to return ActivityPub JSON for generic /users/* GET/HEAD key-resolution fetches.
  • Regression coverage in template/site/src/federation/__tests__/routes.test.ts verifies the actor advertises #multikey-1, generic actor fetch returns ActivityPub JSON including #main-key and #multikey-1, and outbound follow Accept signing uses the advertised key URI instead of #main-key.
  • Documentation in template/site/src/federation/README.md records the chosen key-ID convention and Mastodon mismatch behavior.

Acceptance criteria: 6/6 met.

_Synced from todu comment by @todu on 2026-06-27T23:42:11.537Z_ ### Close gate Readiness: READY Evidence: - PR #217 was merged into `main` as merge commit `8c40de4`, with implementation commit `9440874` (`fix: align ActivityPub sender key IDs`). - Implementation updated `template/site/src/federation/routes.ts` to return ActivityPub JSON for generic `/users/*` GET/HEAD key-resolution fetches. - Regression coverage in `template/site/src/federation/__tests__/routes.test.ts` verifies the actor advertises `#multikey-1`, generic actor fetch returns ActivityPub JSON including `#main-key` and `#multikey-1`, and outbound follow `Accept` signing uses the advertised key URI instead of `#main-key`. - Documentation in `template/site/src/federation/README.md` records the chosen key-ID convention and Mastodon mismatch behavior. Acceptance criteria: 6/6 met.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
erik/slugkit#208
No description provided.