Files
indiekit-endpoint-activitypub/lib/direct-follow.js
Ricardo c71fd691a3 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
2026-03-22 19:40:12 +01:00

164 lines
4.6 KiB
JavaScript

/**
* 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 };
}
}