mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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
164 lines
4.6 KiB
JavaScript
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 };
|
|
}
|
|
}
|