diff --git a/CLAUDE.md b/CLAUDE.md index 978e761..d02762d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -594,6 +594,31 @@ curl -s "https://rmendes.net/nodeinfo/2.1" | jq . # Search from Mastodon for @rick@rmendes.net ``` +### 36. WORKAROUND: Direct Follow for tags.pub (v3.8.4+) + +**File:** `lib/direct-follow.js` +**Upstream issue:** [tags.pub#10](https://github.com/social-web-foundation/tags.pub/issues/10) — OPEN +**Remove when:** tags.pub registers `https://w3id.org/identity/v1` as a known context in `activitypub-bot`'s `lib/activitystreams.js`, OR switches to a JSON-LD parser that handles unknown contexts gracefully. + +**Problem:** Fedify 2.0 adds Linked Data Signatures (`RsaSignature2017`) to all outbound activities. The signature object embeds `"@context": "https://w3id.org/identity/v1"`, which gets hoisted into the top-level `@context` array. tags.pub's `activitypub-bot` uses the `activitystrea.ms` AS2 parser, which rejects any activity containing this context with `400 Invalid request body`. This affects ALL Fedify 2.0 servers, not just us. + +**Workaround:** `lib/direct-follow.js` sends Follow/Undo(Follow) activities with a minimal JSON body (standard AS2 context only, no LD Signature, no Data Integrity Proof) signed with draft-cavage HTTP Signatures. The `DIRECT_FOLLOW_HOSTS` set controls which hostnames use this path (currently only `tags.pub`). + +**Integration:** `followActor()` and `unfollowActor()` in `index.js` check `needsDirectFollow(actorUrl)` before sending. For matching hosts, they load the RSA private key from `ap_keys` via `_loadRsaPrivateKey()` and use `sendDirectFollow()`/`sendDirectUnfollow()` instead of Fedify's `ctx.sendActivity()`. All other servers use the normal Fedify pipeline unchanged. + +**How to revert:** When the upstream fix lands: +1. Remove the `needsDirectFollow()` checks from `followActor()` and `unfollowActor()` in `index.js` +2. Remove the `_loadRsaPrivateKey()` method from the plugin class +3. Remove the `import` of `direct-follow.js` from `index.js` +4. Delete `lib/direct-follow.js` +5. Remove `tags.pub` from any test/documentation references to the workaround +6. Verify by following a tags.pub hashtag actor and confirming the normal Fedify path succeeds + +**Additional tags.pub issues (not fixable on our side):** +- tags.pub does not send `Accept(Follow)` activities back to our inbox +- `@_followback@tags.pub` does not send Follow activities back despite accepting ours +- Both suggest tags.pub's outbound delivery is broken — zero inbound requests from `activitypub-bot` user-agent have been observed + ## CSS Conventions The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support: diff --git a/index.js b/index.js index db3b8e1..d1eb53a 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,11 @@ import { createMastodonRouter } from "./lib/mastodon/router.js"; import { setLocalIdentity } from "./lib/mastodon/entities/status.js"; import { initRedisCache } from "./lib/redis-cache.js"; import { lookupWithSecurity } from "./lib/lookup-helpers.js"; +import { + needsDirectFollow, + sendDirectFollow, + sendDirectUnfollow, +} from "./lib/direct-follow.js"; import { createFedifyMiddleware, } from "./lib/federation-bridge.js"; @@ -232,13 +237,6 @@ export default class ActivityPubEndpoint { // authenticated `routes` getter, not the federation layer. if (req.path.startsWith("/admin")) return next(); - // Diagnostic: log inbox POSTs to detect federation stalls - if (req.method === "POST" && req.path.includes("inbox")) { - const ua = req.get("user-agent") || "unknown"; - const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0; - console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`); - } - // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD // (it only returns true for explicit application/activity+json etc.). // Remote servers fetching actor URLs for HTTP Signature verification @@ -841,6 +839,32 @@ export default class ActivityPubEndpoint { * @param {string} [actorInfo.photo] - Actor avatar URL * @returns {Promise<{ok: boolean, error?: string}>} */ + /** + * Load the RSA private key from ap_keys for direct HTTP Signature signing. + * @returns {Promise} + */ + async _loadRsaPrivateKey() { + try { + const keyDoc = await this._collections.ap_keys.findOne({ + privateKeyPem: { $exists: true }, + }); + if (!keyDoc?.privateKeyPem) return null; + const pemBody = keyDoc.privateKeyPem + .replace(/-----[^-]+-----/g, "") + .replace(/\s/g, ""); + return await crypto.subtle.importKey( + "pkcs8", + Buffer.from(pemBody, "base64"), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ); + } catch (error) { + console.error("[ActivityPub] Failed to load RSA key:", error.message); + return null; + } + } + async followActor(actorUrl, actorInfo = {}) { if (!this._federation) { return { ok: false, error: "Federation not initialized" }; @@ -867,14 +891,33 @@ export default class ActivityPubEndpoint { } // Send Follow activity - const follow = new Follow({ - actor: ctx.getActorUri(handle), - object: new URL(actorUrl), - }); - - await ctx.sendActivity({ identifier: handle }, remoteActor, follow, { - orderingKey: actorUrl, - }); + if (needsDirectFollow(actorUrl)) { + // tags.pub rejects Fedify's LD Signature context (identity/v1). + // Send a minimal signed Follow directly, bypassing the outbox pipeline. + // See: https://github.com/social-web-foundation/tags.pub/issues/10 + const rsaKey = await this._loadRsaPrivateKey(); + if (!rsaKey) { + return { ok: false, error: "No RSA key available for direct follow" }; + } + const result = await sendDirectFollow({ + actorUri: ctx.getActorUri(handle).href, + targetActorUrl: actorUrl, + inboxUrl: remoteActor.inboxId?.href, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + privateKey: rsaKey, + }); + if (!result.ok) { + return { ok: false, error: result.error }; + } + } else { + const follow = new Follow({ + actor: ctx.getActorUri(handle), + object: new URL(actorUrl), + }); + await ctx.sendActivity({ identifier: handle }, remoteActor, follow, { + orderingKey: actorUrl, + }); + } // Store in ap_following const name = @@ -978,19 +1021,35 @@ export default class ActivityPubEndpoint { return { ok: true }; } - const follow = new Follow({ - actor: ctx.getActorUri(handle), - object: new URL(actorUrl), - }); - - const undo = new Undo({ - actor: ctx.getActorUri(handle), - object: follow, - }); - - await ctx.sendActivity({ identifier: handle }, remoteActor, undo, { - orderingKey: actorUrl, - }); + if (needsDirectFollow(actorUrl)) { + // tags.pub rejects Fedify's LD Signature context (identity/v1). + // See: https://github.com/social-web-foundation/tags.pub/issues/10 + const rsaKey = await this._loadRsaPrivateKey(); + if (rsaKey) { + const result = await sendDirectUnfollow({ + actorUri: ctx.getActorUri(handle).href, + targetActorUrl: actorUrl, + inboxUrl: remoteActor.inboxId?.href, + keyId: `${ctx.getActorUri(handle).href}#main-key`, + privateKey: rsaKey, + }); + if (!result.ok) { + console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`); + } + } + } else { + const follow = new Follow({ + actor: ctx.getActorUri(handle), + object: new URL(actorUrl), + }); + const undo = new Undo({ + actor: ctx.getActorUri(handle), + object: follow, + }); + await ctx.sendActivity({ identifier: handle }, remoteActor, undo, { + orderingKey: actorUrl, + }); + } await this._collections.ap_following.deleteOne({ actorUrl }); console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`); diff --git a/lib/direct-follow.js b/lib/direct-follow.js new file mode 100644 index 0000000..7883679 --- /dev/null +++ b/lib/direct-follow.js @@ -0,0 +1,163 @@ +/** + * Direct Follow/Undo(Follow) for servers that reject Fedify's LD Signatures. + * + * tags.pub's activitypub-bot uses the `activitystrea.ms` AS2 parser, which + * rejects the `https://w3id.org/identity/v1` JSON-LD context that Fedify 2.0 + * adds for RsaSignature2017. This module sends Follow/Undo(Follow) activities + * with a minimal body (no LD Sig, no Data Integrity Proof) signed with + * draft-cavage HTTP Signatures. + * + * Upstream issue: https://github.com/social-web-foundation/tags.pub/issues/10 + * + * @module direct-follow + */ + +import crypto from "node:crypto"; + +/** Hostnames that need direct follow (bypass Fedify outbox pipeline) */ +const DIRECT_FOLLOW_HOSTS = new Set(["tags.pub"]); + +/** + * Check if an actor URL requires direct follow delivery. + * @param {string} actorUrl + * @returns {boolean} + */ +export function needsDirectFollow(actorUrl) { + try { + return DIRECT_FOLLOW_HOSTS.has(new URL(actorUrl).hostname); + } catch { + return false; + } +} + +/** + * Send a Follow activity directly with draft-cavage HTTP Signatures. + * @param {object} options + * @param {string} options.actorUri - Our actor URI + * @param {string} options.targetActorUrl - Remote actor URL to follow + * @param {string} options.inboxUrl - Remote actor's inbox URL + * @param {string} options.keyId - Our key ID (e.g. ...#main-key) + * @param {CryptoKey} options.privateKey - RSA private key for signing + * @returns {Promise<{ok: boolean, status?: number, error?: string}>} + */ +export async function sendDirectFollow({ + actorUri, + targetActorUrl, + inboxUrl, + keyId, + privateKey, +}) { + const body = JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Follow", + actor: actorUri, + object: targetActorUrl, + id: `${actorUri.replace(/\/$/, "")}/#Follow/${crypto.randomUUID()}`, + }); + + return _signAndSend(inboxUrl, body, keyId, privateKey); +} + +/** + * Send an Undo(Follow) activity directly with draft-cavage HTTP Signatures. + * @param {object} options + * @param {string} options.actorUri - Our actor URI + * @param {string} options.targetActorUrl - Remote actor URL to unfollow + * @param {string} options.inboxUrl - Remote actor's inbox URL + * @param {string} options.keyId - Our key ID (e.g. ...#main-key) + * @param {CryptoKey} options.privateKey - RSA private key for signing + * @returns {Promise<{ok: boolean, status?: number, error?: string}>} + */ +export async function sendDirectUnfollow({ + actorUri, + targetActorUrl, + inboxUrl, + keyId, + privateKey, +}) { + const body = JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Undo", + actor: actorUri, + object: { + type: "Follow", + actor: actorUri, + object: targetActorUrl, + }, + id: `${actorUri.replace(/\/$/, "")}/#Undo/${crypto.randomUUID()}`, + }); + + return _signAndSend(inboxUrl, body, keyId, privateKey); +} + +/** + * Sign a POST request with draft-cavage HTTP Signatures and send it. + * @private + */ +async function _signAndSend(inboxUrl, body, keyId, privateKey) { + const url = new URL(inboxUrl); + const date = new Date().toUTCString(); + + // Compute SHA-256 digest of the body + const digestRaw = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(body), + ); + const digest = "SHA-256=" + Buffer.from(digestRaw).toString("base64"); + + // Build draft-cavage signing string + const signingString = [ + `(request-target): post ${url.pathname}`, + `host: ${url.host}`, + `date: ${date}`, + `digest: ${digest}`, + ].join("\n"); + + // Sign with RSA-SHA256 + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + new TextEncoder().encode(signingString), + ); + const signatureB64 = Buffer.from(signature).toString("base64"); + + const signatureHeader = [ + `keyId="${keyId}"`, + `algorithm="rsa-sha256"`, + `headers="(request-target) host date digest"`, + `signature="${signatureB64}"`, + ].join(","); + + try { + const response = await fetch(inboxUrl, { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + Date: date, + Digest: digest, + Host: url.host, + Signature: signatureHeader, + }, + body, + }); + + if (response.ok) { + return { ok: true, status: response.status }; + } + + const errorBody = await response.text().catch(() => ""); + let detail = errorBody; + try { + detail = JSON.parse(errorBody).detail || errorBody; + } catch { + // not JSON + } + return { + ok: false, + status: response.status, + error: `${response.status} ${response.statusText}: ${detail}`, + }; + } catch (error) { + return { ok: false, error: error.message }; + } +} diff --git a/package.json b/package.json index 9603821..224bed5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.8.3", + "version": "3.8.4", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",