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
|
# 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
115
index.js
@@ -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";
|
||||||
@@ -232,13 +237,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
|
||||||
@@ -841,6 +839,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" };
|
||||||
@@ -867,14 +891,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 =
|
||||||
@@ -978,19 +1021,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
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",
|
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user