mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
25
CLAUDE.md
25
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:
|
||||
|
||||
115
index.js
115
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<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 = {}) {
|
||||
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}`);
|
||||
|
||||
163
lib/direct-follow.js
Normal file
163
lib/direct-follow.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user