@rmdes/indiekit-endpoint-activitypub 3.8.2 → 3.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -5,6 +5,11 @@ import { createMastodonRouter } from "./lib/mastodon/router.js";
5
5
  import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
6
6
  import { initRedisCache } from "./lib/redis-cache.js";
7
7
  import { lookupWithSecurity } from "./lib/lookup-helpers.js";
8
+ import {
9
+ needsDirectFollow,
10
+ sendDirectFollow,
11
+ sendDirectUnfollow,
12
+ } from "./lib/direct-follow.js";
8
13
  import {
9
14
  createFedifyMiddleware,
10
15
  } from "./lib/federation-bridge.js";
@@ -231,13 +236,6 @@ export default class ActivityPubEndpoint {
231
236
  // authenticated `routes` getter, not the federation layer.
232
237
  if (req.path.startsWith("/admin")) return next();
233
238
 
234
- // Diagnostic: log inbox POSTs to detect federation stalls
235
- if (req.method === "POST" && req.path.includes("inbox")) {
236
- const ua = req.get("user-agent") || "unknown";
237
- const bodyParsed = req.body !== undefined && Object.keys(req.body || {}).length > 0;
238
- console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`);
239
- }
240
-
241
239
  // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD
242
240
  // (it only returns true for explicit application/activity+json etc.).
243
241
  // Remote servers fetching actor URLs for HTTP Signature verification
@@ -729,6 +727,32 @@ export default class ActivityPubEndpoint {
729
727
  * @param {string} [actorInfo.photo] - Actor avatar URL
730
728
  * @returns {Promise<{ok: boolean, error?: string}>}
731
729
  */
730
+ /**
731
+ * Load the RSA private key from ap_keys for direct HTTP Signature signing.
732
+ * @returns {Promise<CryptoKey|null>}
733
+ */
734
+ async _loadRsaPrivateKey() {
735
+ try {
736
+ const keyDoc = await this._collections.ap_keys.findOne({
737
+ privateKeyPem: { $exists: true },
738
+ });
739
+ if (!keyDoc?.privateKeyPem) return null;
740
+ const pemBody = keyDoc.privateKeyPem
741
+ .replace(/-----[^-]+-----/g, "")
742
+ .replace(/\s/g, "");
743
+ return await crypto.subtle.importKey(
744
+ "pkcs8",
745
+ Buffer.from(pemBody, "base64"),
746
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
747
+ false,
748
+ ["sign"],
749
+ );
750
+ } catch (error) {
751
+ console.error("[ActivityPub] Failed to load RSA key:", error.message);
752
+ return null;
753
+ }
754
+ }
755
+
732
756
  async followActor(actorUrl, actorInfo = {}) {
733
757
  if (!this._federation) {
734
758
  return { ok: false, error: "Federation not initialized" };
@@ -755,14 +779,33 @@ export default class ActivityPubEndpoint {
755
779
  }
756
780
 
757
781
  // Send Follow activity
758
- const follow = new Follow({
759
- actor: ctx.getActorUri(handle),
760
- object: new URL(actorUrl),
761
- });
762
-
763
- await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
764
- orderingKey: actorUrl,
765
- });
782
+ if (needsDirectFollow(actorUrl)) {
783
+ // tags.pub rejects Fedify's LD Signature context (identity/v1).
784
+ // Send a minimal signed Follow directly, bypassing the outbox pipeline.
785
+ // See: https://github.com/social-web-foundation/tags.pub/issues/10
786
+ const rsaKey = await this._loadRsaPrivateKey();
787
+ if (!rsaKey) {
788
+ return { ok: false, error: "No RSA key available for direct follow" };
789
+ }
790
+ const result = await sendDirectFollow({
791
+ actorUri: ctx.getActorUri(handle).href,
792
+ targetActorUrl: actorUrl,
793
+ inboxUrl: remoteActor.inboxId?.href,
794
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
795
+ privateKey: rsaKey,
796
+ });
797
+ if (!result.ok) {
798
+ return { ok: false, error: result.error };
799
+ }
800
+ } else {
801
+ const follow = new Follow({
802
+ actor: ctx.getActorUri(handle),
803
+ object: new URL(actorUrl),
804
+ });
805
+ await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
806
+ orderingKey: actorUrl,
807
+ });
808
+ }
766
809
 
767
810
  // Store in ap_following
768
811
  const name =
@@ -866,19 +909,35 @@ export default class ActivityPubEndpoint {
866
909
  return { ok: true };
867
910
  }
868
911
 
869
- const follow = new Follow({
870
- actor: ctx.getActorUri(handle),
871
- object: new URL(actorUrl),
872
- });
873
-
874
- const undo = new Undo({
875
- actor: ctx.getActorUri(handle),
876
- object: follow,
877
- });
878
-
879
- await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
880
- orderingKey: actorUrl,
881
- });
912
+ if (needsDirectFollow(actorUrl)) {
913
+ // tags.pub rejects Fedify's LD Signature context (identity/v1).
914
+ // See: https://github.com/social-web-foundation/tags.pub/issues/10
915
+ const rsaKey = await this._loadRsaPrivateKey();
916
+ if (rsaKey) {
917
+ const result = await sendDirectUnfollow({
918
+ actorUri: ctx.getActorUri(handle).href,
919
+ targetActorUrl: actorUrl,
920
+ inboxUrl: remoteActor.inboxId?.href,
921
+ keyId: `${ctx.getActorUri(handle).href}#main-key`,
922
+ privateKey: rsaKey,
923
+ });
924
+ if (!result.ok) {
925
+ console.warn(`[ActivityPub] Direct unfollow failed for ${actorUrl}: ${result.error}`);
926
+ }
927
+ }
928
+ } else {
929
+ const follow = new Follow({
930
+ actor: ctx.getActorUri(handle),
931
+ object: new URL(actorUrl),
932
+ });
933
+ const undo = new Undo({
934
+ actor: ctx.getActorUri(handle),
935
+ object: follow,
936
+ });
937
+ await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
938
+ orderingKey: actorUrl,
939
+ });
940
+ }
882
941
  await this._collections.ap_following.deleteOne({ actorUrl });
883
942
 
884
943
  console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Direct Follow/Undo(Follow) for servers that reject Fedify's LD Signatures.
3
+ *
4
+ * tags.pub's activitypub-bot uses the `activitystrea.ms` AS2 parser, which
5
+ * rejects the `https://w3id.org/identity/v1` JSON-LD context that Fedify 2.0
6
+ * adds for RsaSignature2017. This module sends Follow/Undo(Follow) activities
7
+ * with a minimal body (no LD Sig, no Data Integrity Proof) signed with
8
+ * draft-cavage HTTP Signatures.
9
+ *
10
+ * Upstream issue: https://github.com/social-web-foundation/tags.pub/issues/10
11
+ *
12
+ * @module direct-follow
13
+ */
14
+
15
+ import crypto from "node:crypto";
16
+
17
+ /** Hostnames that need direct follow (bypass Fedify outbox pipeline) */
18
+ const DIRECT_FOLLOW_HOSTS = new Set(["tags.pub"]);
19
+
20
+ /**
21
+ * Check if an actor URL requires direct follow delivery.
22
+ * @param {string} actorUrl
23
+ * @returns {boolean}
24
+ */
25
+ export function needsDirectFollow(actorUrl) {
26
+ try {
27
+ return DIRECT_FOLLOW_HOSTS.has(new URL(actorUrl).hostname);
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Send a Follow activity directly with draft-cavage HTTP Signatures.
35
+ * @param {object} options
36
+ * @param {string} options.actorUri - Our actor URI
37
+ * @param {string} options.targetActorUrl - Remote actor URL to follow
38
+ * @param {string} options.inboxUrl - Remote actor's inbox URL
39
+ * @param {string} options.keyId - Our key ID (e.g. ...#main-key)
40
+ * @param {CryptoKey} options.privateKey - RSA private key for signing
41
+ * @returns {Promise<{ok: boolean, status?: number, error?: string}>}
42
+ */
43
+ export async function sendDirectFollow({
44
+ actorUri,
45
+ targetActorUrl,
46
+ inboxUrl,
47
+ keyId,
48
+ privateKey,
49
+ }) {
50
+ const body = JSON.stringify({
51
+ "@context": "https://www.w3.org/ns/activitystreams",
52
+ type: "Follow",
53
+ actor: actorUri,
54
+ object: targetActorUrl,
55
+ id: `${actorUri.replace(/\/$/, "")}/#Follow/${crypto.randomUUID()}`,
56
+ });
57
+
58
+ return _signAndSend(inboxUrl, body, keyId, privateKey);
59
+ }
60
+
61
+ /**
62
+ * Send an Undo(Follow) activity directly with draft-cavage HTTP Signatures.
63
+ * @param {object} options
64
+ * @param {string} options.actorUri - Our actor URI
65
+ * @param {string} options.targetActorUrl - Remote actor URL to unfollow
66
+ * @param {string} options.inboxUrl - Remote actor's inbox URL
67
+ * @param {string} options.keyId - Our key ID (e.g. ...#main-key)
68
+ * @param {CryptoKey} options.privateKey - RSA private key for signing
69
+ * @returns {Promise<{ok: boolean, status?: number, error?: string}>}
70
+ */
71
+ export async function sendDirectUnfollow({
72
+ actorUri,
73
+ targetActorUrl,
74
+ inboxUrl,
75
+ keyId,
76
+ privateKey,
77
+ }) {
78
+ const body = JSON.stringify({
79
+ "@context": "https://www.w3.org/ns/activitystreams",
80
+ type: "Undo",
81
+ actor: actorUri,
82
+ object: {
83
+ type: "Follow",
84
+ actor: actorUri,
85
+ object: targetActorUrl,
86
+ },
87
+ id: `${actorUri.replace(/\/$/, "")}/#Undo/${crypto.randomUUID()}`,
88
+ });
89
+
90
+ return _signAndSend(inboxUrl, body, keyId, privateKey);
91
+ }
92
+
93
+ /**
94
+ * Sign a POST request with draft-cavage HTTP Signatures and send it.
95
+ * @private
96
+ */
97
+ async function _signAndSend(inboxUrl, body, keyId, privateKey) {
98
+ const url = new URL(inboxUrl);
99
+ const date = new Date().toUTCString();
100
+
101
+ // Compute SHA-256 digest of the body
102
+ const digestRaw = await crypto.subtle.digest(
103
+ "SHA-256",
104
+ new TextEncoder().encode(body),
105
+ );
106
+ const digest = "SHA-256=" + Buffer.from(digestRaw).toString("base64");
107
+
108
+ // Build draft-cavage signing string
109
+ const signingString = [
110
+ `(request-target): post ${url.pathname}`,
111
+ `host: ${url.host}`,
112
+ `date: ${date}`,
113
+ `digest: ${digest}`,
114
+ ].join("\n");
115
+
116
+ // Sign with RSA-SHA256
117
+ const signature = await crypto.subtle.sign(
118
+ "RSASSA-PKCS1-v1_5",
119
+ privateKey,
120
+ new TextEncoder().encode(signingString),
121
+ );
122
+ const signatureB64 = Buffer.from(signature).toString("base64");
123
+
124
+ const signatureHeader = [
125
+ `keyId="${keyId}"`,
126
+ `algorithm="rsa-sha256"`,
127
+ `headers="(request-target) host date digest"`,
128
+ `signature="${signatureB64}"`,
129
+ ].join(",");
130
+
131
+ try {
132
+ const response = await fetch(inboxUrl, {
133
+ method: "POST",
134
+ headers: {
135
+ "Content-Type": "application/activity+json",
136
+ Date: date,
137
+ Digest: digest,
138
+ Host: url.host,
139
+ Signature: signatureHeader,
140
+ },
141
+ body,
142
+ });
143
+
144
+ if (response.ok) {
145
+ return { ok: true, status: response.status };
146
+ }
147
+
148
+ const errorBody = await response.text().catch(() => "");
149
+ let detail = errorBody;
150
+ try {
151
+ detail = JSON.parse(errorBody).detail || errorBody;
152
+ } catch {
153
+ // not JSON
154
+ }
155
+ return {
156
+ ok: false,
157
+ status: response.status,
158
+ error: `${response.status} ${response.statusText}: ${detail}`,
159
+ };
160
+ } catch (error) {
161
+ return { ok: false, error: error.message };
162
+ }
163
+ }
@@ -154,7 +154,16 @@ export function setupFederation(options) {
154
154
  };
155
155
  if (keyPairs.length > 0) {
156
156
  appOptions.publicKey = keyPairs[0].cryptographicKey;
157
- appOptions.assertionMethods = keyPairs.map((k) => k.multikey);
157
+ // Only include Ed25519 keys in assertionMethod (Object Integrity Proofs).
158
+ // RSA keys belong only in publicKey (HTTP Signatures). Putting the RSA
159
+ // Multikey in assertionMethod with the same #main-key id as the
160
+ // CryptographicKey in publicKey causes id collisions — servers that
161
+ // traverse JSON-LD properties alphabetically (assertionMethod before
162
+ // publicKey) find the Multikey first, which has no publicKeyPem,
163
+ // and fail signature verification.
164
+ appOptions.assertionMethods = keyPairs
165
+ .filter((k) => k.privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5")
166
+ .map((k) => k.multikey);
158
167
  }
159
168
  return new Application(appOptions);
160
169
  }
@@ -753,7 +762,12 @@ export async function buildPersonActor(
753
762
 
754
763
  if (keyPairs.length > 0) {
755
764
  personOptions.publicKey = keyPairs[0].cryptographicKey;
756
- personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
765
+ // Only include Ed25519 keys in assertionMethod (Object Integrity Proofs).
766
+ // RSA keys belong only in publicKey (HTTP Signatures). See instance actor
767
+ // above for the full explanation of why this filter is necessary.
768
+ personOptions.assertionMethods = keyPairs
769
+ .filter((k) => k.privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5")
770
+ .map((k) => k.multikey);
757
771
  }
758
772
 
759
773
  // Build profile field attachments (PropertyValue).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.8.2",
3
+ "version": "3.8.4",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",