fix: raw signed fetch fallback for author resolution

Servers like wafrn return AP JSON without @context, causing Fedify's
JSON-LD processor to reject the document. Strategy 1b in resolveAuthor
does a direct signed GET, extracts attributedTo/actor from plain JSON,
then resolves the actor via lookupWithSecurity.

Also: _loadRsaPrivateKey now imports with extractable=true (required
by Fedify's signRequest), and loadRsaKey is wired through to all
Mastodon API interaction helpers.
This commit is contained in:
Ricardo
2026-03-23 07:56:34 +01:00
parent c71fd691a3
commit c2920cafd8
7 changed files with 83 additions and 8 deletions

View File

@@ -744,7 +744,7 @@ export default class ActivityPubEndpoint {
"pkcs8", "pkcs8",
Buffer.from(pemBody, "base64"), Buffer.from(pemBody, "base64"),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false, true,
["sign"], ["sign"],
); );
} catch (error) { } catch (error) {
@@ -1589,6 +1589,7 @@ export default class ActivityPubEndpoint {
federation: this._federation, federation: this._federation,
followActor: (url, info) => pluginRef.followActor(url, info), followActor: (url, info) => pluginRef.followActor(url, info),
unfollowActor: (url) => pluginRef.unfollowActor(url), unfollowActor: (url) => pluginRef.unfollowActor(url),
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
}, },
}); });
Indiekit.addEndpoint({ Indiekit.addEndpoint({

View File

@@ -72,11 +72,16 @@ export function boostController(mountPath, plugin) {
identifier: handle, identifier: handle,
}); });
const { application } = request.app.locals; const { application } = request.app.locals;
const rsaKey = await plugin._loadRsaPrivateKey();
const recipient = await resolveAuthor( const recipient = await resolveAuthor(
url, url,
ctx, ctx,
documentLoader, documentLoader,
application?.collections, application?.collections,
{
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
},
); );
if (recipient) { if (recipient) {

View File

@@ -49,11 +49,16 @@ export function likeController(mountPath, plugin) {
}); });
const { application } = request.app.locals; const { application } = request.app.locals;
const rsaKey = await plugin._loadRsaPrivateKey();
const recipient = await resolveAuthor( const recipient = await resolveAuthor(
url, url,
ctx, ctx,
documentLoader, documentLoader,
application?.collections, application?.collections,
{
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
},
); );
if (!recipient) { if (!recipient) {
@@ -170,11 +175,16 @@ export function unlikeController(mountPath, plugin) {
identifier: handle, identifier: handle,
}); });
const rsaKey2 = await plugin._loadRsaPrivateKey();
const recipient = await resolveAuthor( const recipient = await resolveAuthor(
url, url,
ctx, ctx,
documentLoader, documentLoader,
application?.collections, application?.collections,
{
privateKey: rsaKey2,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
},
); );
if (!recipient) { if (!recipient) {

View File

@@ -22,7 +22,7 @@ import { resolveAuthor } from "../../resolve-author.js";
* @param {object} params.interactions - ap_interactions collection * @param {object} params.interactions - ap_interactions collection
* @returns {Promise<{ activityId: string }>} * @returns {Promise<{ activityId: string }>}
*/ */
export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
const { Like } = await import("@fedify/fedify/vocab"); const { Like } = await import("@fedify/fedify/vocab");
const ctx = federation.createContext( const ctx = federation.createContext(
new URL(publicationUrl), new URL(publicationUrl),
@@ -30,7 +30,11 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
); );
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); const rsaKey = loadRsaKey ? await loadRsaKey() : null;
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
});
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
const baseUrl = publicationUrl.replace(/\/$/, ""); const baseUrl = publicationUrl.replace(/\/$/, "");
@@ -79,7 +83,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
* @param {object} params.interactions - ap_interactions collection * @param {object} params.interactions - ap_interactions collection
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
const existing = interactions const existing = interactions
? await interactions.findOne({ objectUrl: targetUrl, type: "like" }) ? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
: null; : null;
@@ -95,7 +99,11 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
); );
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); const rsaKey = loadRsaKey ? await loadRsaKey() : null;
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
});
if (recipient) { if (recipient) {
const like = new Like({ const like = new Like({
@@ -131,7 +139,7 @@ export async function unlikePost({ targetUrl, federation, handle, publicationUrl
* @param {object} params.interactions - ap_interactions collection * @param {object} params.interactions - ap_interactions collection
* @returns {Promise<{ activityId: string }>} * @returns {Promise<{ activityId: string }>}
*/ */
export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions, loadRsaKey }) {
const { Announce } = await import("@fedify/fedify/vocab"); const { Announce } = await import("@fedify/fedify/vocab");
const ctx = federation.createContext( const ctx = federation.createContext(
new URL(publicationUrl), new URL(publicationUrl),
@@ -162,7 +170,11 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
// Also send directly to the original post author // Also send directly to the original post author
const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); const rsaKey = loadRsaKey ? await loadRsaKey() : null;
const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections, {
privateKey: rsaKey,
keyId: `${ctx.getActorUri(handle).href}#main-key`,
});
if (recipient) { if (recipient) {
try { try {
await ctx.sendActivity({ identifier: handle }, recipient, announce, { await ctx.sendActivity({ identifier: handle }, recipient, announce, {

View File

@@ -614,6 +614,7 @@ function getFederationOpts(req) {
handle: pluginOptions.handle || "user", handle: pluginOptions.handle || "user",
publicationUrl: pluginOptions.publicationUrl, publicationUrl: pluginOptions.publicationUrl,
collections: req.app.locals.mastodonCollections, collections: req.app.locals.mastodonCollections,
loadRsaKey: pluginOptions.loadRsaKey,
}; };
} }

View File

@@ -6,6 +6,8 @@
* *
* Strategies (tried in order): * Strategies (tried in order):
* 1. lookupObject on post URL → getAttributedTo * 1. lookupObject on post URL → getAttributedTo
* 1b. Raw signed fetch fallback (for servers like wafrn that return
* non-standard JSON-LD that Fedify can't parse)
* 2. Timeline/notification DB lookup → lookupObject on stored author URL * 2. Timeline/notification DB lookup → lookupObject on stored author URL
* 3. Extract author URL from post URL pattern → lookupObject * 3. Extract author URL from post URL pattern → lookupObject
*/ */
@@ -60,6 +62,9 @@ export function extractAuthorUrl(postUrl) {
* @param {object} ctx - Fedify context * @param {object} ctx - Fedify context
* @param {object} documentLoader - Authenticated document loader * @param {object} documentLoader - Authenticated document loader
* @param {object} [collections] - Optional MongoDB collections map (application.collections) * @param {object} [collections] - Optional MongoDB collections map (application.collections)
* @param {object} [options] - Additional options
* @param {CryptoKey} [options.privateKey] - RSA private key for raw signed fetch fallback
* @param {string} [options.keyId] - Key ID for HTTP Signature (e.g. "...#main-key")
* @returns {Promise<object|null>} - Fedify Actor object or null * @returns {Promise<object|null>} - Fedify Actor object or null
*/ */
export async function resolveAuthor( export async function resolveAuthor(
@@ -67,6 +72,7 @@ export async function resolveAuthor(
ctx, ctx,
documentLoader, documentLoader,
collections, collections,
options = {},
) { ) {
// Strategy 1: Look up remote post via Fedify (signed request) // Strategy 1: Look up remote post via Fedify (signed request)
try { try {
@@ -90,6 +96,46 @@ export async function resolveAuthor(
); );
} }
// Strategy 1b: Raw signed fetch fallback
// Some servers (e.g. wafrn) return AP JSON without @context, which Fedify's
// JSON-LD processor rejects. A raw fetch can still extract attributedTo/actor.
if (options.privateKey && options.keyId) {
try {
const { signRequest } = await import("@fedify/fedify/sig");
const request = new Request(postUrl, {
method: "GET",
headers: { Accept: "application/activity+json" },
});
const signed = await signRequest(request, options.privateKey, new URL(options.keyId), {
spec: "draft-cavage-http-signatures-12",
});
const res = await fetch(signed, { redirect: "follow" });
if (res.ok) {
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("json")) {
const json = await res.json();
const authorUrl = json.attributedTo || json.actor;
if (authorUrl && typeof authorUrl === "string") {
const actor = await lookupWithSecurity(ctx, new URL(authorUrl), {
documentLoader,
});
if (actor) {
console.info(
`[ActivityPub] Resolved author via raw fetch for ${postUrl}${authorUrl}`,
);
return actor;
}
}
}
}
} catch (error) {
console.warn(
`[ActivityPub] Raw fetch fallback failed for ${postUrl}:`,
error.message,
);
}
}
// Strategy 2: Use author URL from timeline or notifications // Strategy 2: Use author URL from timeline or notifications
if (collections) { if (collections) {
const ap_timeline = collections.get("ap_timeline"); const ap_timeline = collections.get("ap_timeline");

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.8.4", "version": "3.8.5",
"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",