fix: direct follow workaround for tags.pub identity/v1 context rejection

tags.pub's activitypub-bot (activitystrea.ms parser) rejects any activity
body containing the https://w3id.org/identity/v1 JSON-LD context with
400 Invalid request body. Fedify 2.0 adds this context via LD Signatures
(RsaSignature2017) on all outbound activities.

Workaround: lib/direct-follow.js sends Follow/Undo(Follow) with a minimal
body (no LD Sig, no proof) using draft-cavage HTTP Signatures, scoped only
to tags.pub via DIRECT_FOLLOW_HOSTS set.

Also removes [federation-diag] inbox POST logging (no longer needed).

Upstream: https://github.com/social-web-foundation/tags.pub/issues/10
This commit is contained in:
Ricardo
2026-03-22 19:40:12 +01:00
parent 4495667ed9
commit c71fd691a3
4 changed files with 276 additions and 29 deletions

View File

@@ -593,6 +593,31 @@ curl -s "https://rmendes.net/nodeinfo/2.1" | jq .
# Search from Mastodon for @rick@rmendes.net # 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 ## CSS Conventions
The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support: The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support:

115
index.js
View File

@@ -5,6 +5,11 @@ import { createMastodonRouter } from "./lib/mastodon/router.js";
import { setLocalIdentity } from "./lib/mastodon/entities/status.js"; import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
import { initRedisCache } from "./lib/redis-cache.js"; import { initRedisCache } from "./lib/redis-cache.js";
import { lookupWithSecurity } from "./lib/lookup-helpers.js"; import { lookupWithSecurity } from "./lib/lookup-helpers.js";
import {
needsDirectFollow,
sendDirectFollow,
sendDirectUnfollow,
} from "./lib/direct-follow.js";
import { import {
createFedifyMiddleware, createFedifyMiddleware,
} from "./lib/federation-bridge.js"; } from "./lib/federation-bridge.js";
@@ -231,13 +236,6 @@ export default class ActivityPubEndpoint {
// authenticated `routes` getter, not the federation layer. // authenticated `routes` getter, not the federation layer.
if (req.path.startsWith("/admin")) return next(); 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 // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD
// (it only returns true for explicit application/activity+json etc.). // (it only returns true for explicit application/activity+json etc.).
// Remote servers fetching actor URLs for HTTP Signature verification // Remote servers fetching actor URLs for HTTP Signature verification
@@ -729,6 +727,32 @@ export default class ActivityPubEndpoint {
* @param {string} [actorInfo.photo] - Actor avatar URL * @param {string} [actorInfo.photo] - Actor avatar URL
* @returns {Promise<{ok: boolean, error?: string}>} * @returns {Promise<{ok: boolean, error?: string}>}
*/ */
/**
* Load the RSA private key from ap_keys for direct HTTP Signature signing.
* @returns {Promise<CryptoKey|null>}
*/
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 = {}) { async followActor(actorUrl, actorInfo = {}) {
if (!this._federation) { if (!this._federation) {
return { ok: false, error: "Federation not initialized" }; return { ok: false, error: "Federation not initialized" };
@@ -755,14 +779,33 @@ export default class ActivityPubEndpoint {
} }
// Send Follow activity // Send Follow activity
const follow = new Follow({ if (needsDirectFollow(actorUrl)) {
actor: ctx.getActorUri(handle), // tags.pub rejects Fedify's LD Signature context (identity/v1).
object: new URL(actorUrl), // 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();
await ctx.sendActivity({ identifier: handle }, remoteActor, follow, { if (!rsaKey) {
orderingKey: actorUrl, 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 // Store in ap_following
const name = const name =
@@ -866,19 +909,35 @@ export default class ActivityPubEndpoint {
return { ok: true }; return { ok: true };
} }
const follow = new Follow({ if (needsDirectFollow(actorUrl)) {
actor: ctx.getActorUri(handle), // tags.pub rejects Fedify's LD Signature context (identity/v1).
object: new URL(actorUrl), // See: https://github.com/social-web-foundation/tags.pub/issues/10
}); const rsaKey = await this._loadRsaPrivateKey();
if (rsaKey) {
const undo = new Undo({ const result = await sendDirectUnfollow({
actor: ctx.getActorUri(handle), actorUri: ctx.getActorUri(handle).href,
object: follow, targetActorUrl: actorUrl,
}); inboxUrl: remoteActor.inboxId?.href,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
await ctx.sendActivity({ identifier: handle }, remoteActor, undo, { privateKey: rsaKey,
orderingKey: actorUrl, });
}); 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 }); await this._collections.ap_following.deleteOne({ actorUrl });
console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`); console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);

163
lib/direct-follow.js Normal file
View File

@@ -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 };
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "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.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",