@fedify/fedify 2.3.0-dev.1110 → 2.3.0-dev.1114

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.
Files changed (95) hide show
  1. package/dist/{assert_rejects-B-qJtC9Z.mjs → assert_rejects-DQP-q39h.mjs} +27 -2
  2. package/dist/{builder-B-Y6fwSu.mjs → builder-YlEusQth.mjs} +3 -3
  3. package/dist/compat/mod.d.cts +1 -1
  4. package/dist/compat/mod.d.ts +1 -1
  5. package/dist/compat/outgoing-jsonld.test.mjs +1 -1
  6. package/dist/compat/public-audience.test.mjs +1 -1
  7. package/dist/compat/transformers.test.mjs +2 -2
  8. package/dist/{context-C0C_sRha.d.cts → context-Ch-ZLyTQ.d.cts} +1 -1
  9. package/dist/{context-Dqgt8saU.d.ts → context-cSUMk2da.d.ts} +1 -1
  10. package/dist/{deno-hqC7tKJn.mjs → deno-CF3jMgip.mjs} +1 -1
  11. package/dist/{docloader-BOEuuXkX.mjs → docloader-BENj6vQ4.mjs} +2 -2
  12. package/dist/federation/builder.test.mjs +3 -3
  13. package/dist/federation/collection.test.mjs +2 -2
  14. package/dist/federation/handler.test.mjs +8 -7
  15. package/dist/federation/idempotency.test.mjs +5 -5
  16. package/dist/federation/inbox.test.mjs +1 -1
  17. package/dist/federation/keycache.test.mjs +1 -1
  18. package/dist/federation/kv.test.mjs +2 -2
  19. package/dist/federation/middleware.test.mjs +10 -10
  20. package/dist/federation/mod.cjs +1 -1
  21. package/dist/federation/mod.d.cts +2 -2
  22. package/dist/federation/mod.d.ts +2 -2
  23. package/dist/federation/mod.js +1 -1
  24. package/dist/federation/mq.test.mjs +2 -2
  25. package/dist/federation/negotiation.test.mjs +2 -2
  26. package/dist/federation/router.test.mjs +2 -2
  27. package/dist/federation/send.test.mjs +11 -11
  28. package/dist/federation/webfinger.test.mjs +3 -3
  29. package/dist/{getMachineId-bsd-etIyxDet.mjs → getMachineId-bsd-BY01PL1n.mjs} +1 -1
  30. package/dist/{getMachineId-darwin-D23zTf4g.mjs → getMachineId-darwin-Dr1gkBkp.mjs} +1 -1
  31. package/dist/{getMachineId-win-Dpap6v5i.mjs → getMachineId-win-QEYwcJiy.mjs} +1 -1
  32. package/dist/{http-BLopFpvC.mjs → http-BmOZYc-8.mjs} +86 -37
  33. package/dist/{http-DV0il3vk.cjs → http-CKCgOPkX.cjs} +427 -35
  34. package/dist/{http-O8MYWwk8.js → http-CpzZ9zsb.js} +393 -37
  35. package/dist/{http-BDZeS5om.d.ts → http-D6LP89UO.d.ts} +7 -1
  36. package/dist/{http-C87EWkO0.d.cts → http-D6aw3j2U.d.cts} +7 -1
  37. package/dist/{key-DW1EVmtP.mjs → key-B4I8H5Lc.mjs} +1 -1
  38. package/dist/{kv-cache-Dya-TWMe.cjs → kv-cache-DY-XWOqM.cjs} +1 -1
  39. package/dist/{kv-cache-C3NWWiTg.js → kv-cache-Wc5ezcVW.js} +1 -1
  40. package/dist/{ld-BNkk2Yal.mjs → ld-B5D5THhl.mjs} +60 -9
  41. package/dist/{send-hokVCPu6.mjs → metrics-ek3ilf6c.mjs} +53 -221
  42. package/dist/{middleware-CjzI3aYo.js → middleware-CuZbBw-N.js} +16 -269
  43. package/dist/{middleware-DA2WTBr4.mjs → middleware-DlcecZMq.mjs} +29 -23
  44. package/dist/{middleware-D6FbOjuK.mjs → middleware-EI7OU6BR.mjs} +1 -1
  45. package/dist/{middleware-DUWeXjZR.cjs → middleware-EqTYPG4F.cjs} +45 -298
  46. package/dist/{mod-DXY9JF28.d.cts → mod-B-Lin9Sy.d.ts} +25 -2
  47. package/dist/{mod-DHO9lk3D.d.ts → mod-BDhgfjP7.d.cts} +25 -2
  48. package/dist/{mod-B0rWmfW5.d.cts → mod-BR_BB0bh.d.cts} +1 -1
  49. package/dist/{mod-Dx3-hqyo.d.ts → mod-C6E8rkcz.d.ts} +1 -1
  50. package/dist/{mod-BhU_H1I_.d.ts → mod-DLrRb0dx.d.ts} +1 -1
  51. package/dist/{mod-CLPnQPsv.d.cts → mod-P9tE2WmM.d.cts} +1 -1
  52. package/dist/mod.cjs +4 -4
  53. package/dist/mod.d.cts +5 -5
  54. package/dist/mod.d.ts +5 -5
  55. package/dist/mod.js +4 -4
  56. package/dist/nodeinfo/client.test.mjs +2 -2
  57. package/dist/nodeinfo/handler.test.mjs +3 -3
  58. package/dist/nodeinfo/types.test.mjs +2 -2
  59. package/dist/otel/exporter.test.mjs +2 -2
  60. package/dist/{outgoing-jsonld-BgFLCJQ_.mjs → outgoing-jsonld-BNL8AC14.mjs} +1 -1
  61. package/dist/{owner-jvJAtR5O.mjs → owner-DO810N24.mjs} +2 -2
  62. package/dist/{proof-mfmHH9j0.mjs → proof-BgfyWv7b.mjs} +25 -7
  63. package/dist/{proof-BD92WeqV.cjs → proof-DIoqrKnX.cjs} +78 -11
  64. package/dist/{proof-5kT7OUPV.js → proof-Vd8-1EWh.js} +78 -11
  65. package/dist/send-CAYXdUTk.mjs +225 -0
  66. package/dist/sig/accept.test.mjs +1 -1
  67. package/dist/sig/http.test.mjs +212 -6
  68. package/dist/sig/key.test.mjs +4 -4
  69. package/dist/sig/ld.test.mjs +138 -5
  70. package/dist/sig/mod.cjs +2 -2
  71. package/dist/sig/mod.d.cts +2 -2
  72. package/dist/sig/mod.d.ts +2 -2
  73. package/dist/sig/mod.js +2 -2
  74. package/dist/sig/owner.test.mjs +4 -4
  75. package/dist/sig/proof.test.mjs +167 -6
  76. package/dist/{std__assert-CRDpx_HF.mjs → std__assert-BTEgfoJo.mjs} +2 -27
  77. package/dist/utils/docloader.test.mjs +5 -5
  78. package/dist/utils/kv-cache.test.mjs +1 -1
  79. package/dist/utils/mod.cjs +1 -1
  80. package/dist/utils/mod.d.cts +1 -1
  81. package/dist/utils/mod.d.ts +1 -1
  82. package/dist/utils/mod.js +1 -1
  83. package/package.json +5 -5
  84. /package/dist/{accept-CceiKpCy.mjs → accept-CgDcxvjV.mjs} +0 -0
  85. /package/dist/{activity-listener-tztVvlNb.mjs → activity-listener-BeTGV3wc.mjs} +0 -0
  86. /package/dist/{client-B_A6mfn3.mjs → client-Bneh_DYR.mjs} +0 -0
  87. /package/dist/{collection-CA3V5zyK.mjs → collection-Cc3DVAhE.mjs} +0 -0
  88. /package/dist/{execAsync-DCBrgFiV.mjs → execAsync-Dxb7rNf3.mjs} +0 -0
  89. /package/dist/{getMachineId-linux-ObI47Hql.mjs → getMachineId-linux-Bbhofx-s.mjs} +0 -0
  90. /package/dist/{getMachineId-unsupported-Ddu-PFeh.mjs → getMachineId-unsupported-dIOte2Ct.mjs} +0 -0
  91. /package/dist/{keys-C3kae-6B.mjs → keys-CSYsOMFG.mjs} +0 -0
  92. /package/dist/{kv-x2IvBUyq.mjs → kv-QHE0oeM3.mjs} +0 -0
  93. /package/dist/{kv-cache-CiiNwT6W.mjs → kv-cache-DihufyAQ.mjs} +0 -0
  94. /package/dist/{public-audience-N3pyOx2p.mjs → public-audience-c9zmYKgA.mjs} +0 -0
  95. /package/dist/{types-BFowWFTT.mjs → types-D09GN0uZ.mjs} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  import { URLPattern } from "urlpattern-polyfill";
3
- import { _ as version, d as validateCryptoKey, g as name, s as fetchKey } from "./http-O8MYWwk8.js";
3
+ import { C as version, S as name, d as validateCryptoKey, f as getDurationMs, g as measureSignatureKeyFetch, p as getFederationMetrics, s as fetchKey } from "./http-CpzZ9zsb.js";
4
4
  import { getLogger } from "@logtape/logtape";
5
5
  import { Activity, CryptographicKey, DataIntegrityProof, Multikey, Object as Object$1, PUBLIC_COLLECTION, getTypeId, isActor } from "@fedify/vocab";
6
6
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
@@ -145,6 +145,17 @@ function detachSignature(jsonLd) {
145
145
  }
146
146
  /**
147
147
  * Verifies Linked Data Signatures of the given JSON-LD document.
148
+ *
149
+ * This is a low-level utility that only checks the cryptographic signature
150
+ * and (optionally) the cached key. It does not run the JSON-LD parsing,
151
+ * attribution, and owner checks that a complete inbound LD verification
152
+ * needs. For incoming activities, prefer {@link verifyJsonLd}, which is
153
+ * the public verification entry point and the one that emits the
154
+ * `activitypub.signature.verification.duration` metric for the LD path.
155
+ * `verifySignature` itself only emits
156
+ * `activitypub.signature.key_fetch.duration`, since the rest of the work
157
+ * that the verification-duration metric is meant to cover happens in
158
+ * `verifyJsonLd`.
148
159
  * @param jsonLd The JSON-LD document to verify.
149
160
  * @param options Options for verifying the signature.
150
161
  * @returns The public key that signed the document or `null` if the signature
@@ -164,7 +175,7 @@ async function verifySignature(jsonLd, options = {}) {
164
175
  });
165
176
  return null;
166
177
  }
167
- const { key, cached } = await fetchKey(new URL(sig.creator), CryptographicKey, options);
178
+ const { key, cached } = await measureSignatureKeyFetch(options.meterProvider, "linked_data", () => fetchKey(new URL(sig.creator), CryptographicKey, options));
168
179
  if (key == null) return null;
169
180
  const sigOpts = {
170
181
  ...sig,
@@ -204,13 +215,13 @@ async function verifySignature(jsonLd, options = {}) {
204
215
  keyId: sig.creator,
205
216
  ...sig
206
217
  });
207
- const { key } = await fetchKey(new URL(sig.creator), CryptographicKey, {
218
+ const { key } = await measureSignatureKeyFetch(options.meterProvider, "linked_data", () => fetchKey(new URL(sig.creator), CryptographicKey, {
208
219
  ...options,
209
220
  keyCache: {
210
221
  get: () => Promise.resolve(void 0),
211
222
  set: async (keyId, key) => await options.keyCache?.set(keyId, key)
212
223
  }
213
- });
224
+ }));
214
225
  if (key == null) return null;
215
226
  return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key.publicKey, signature.slice(), messageBytes) ? key : null;
216
227
  }
@@ -222,6 +233,33 @@ async function verifySignature(jsonLd, options = {}) {
222
233
  return null;
223
234
  }
224
235
  /**
236
+ * Known Linked Data Signature `type` values, used to keep
237
+ * `ld_signatures.type` on a bounded set of spec-defined string values.
238
+ * Fedify only signs and verifies `RsaSignature2017`; other values come in
239
+ * only from external documents and are dropped from the metric attribute to
240
+ * avoid attacker-controlled cardinality.
241
+ */
242
+ const LD_KNOWN_SIGNATURE_TYPES = new Set(["RsaSignature2017"]);
243
+ /**
244
+ * Reports only whether a `signature` key is present on the document, with
245
+ * no shape check on its value. This is intentionally looser than
246
+ * {@link hasSignature} (which validates a full `RsaSignature2017` shape)
247
+ * and {@link hasSignatureLike} (which structurally accepts several known
248
+ * suites): `verifyJsonLd` needs to tell a document with a malformed or
249
+ * unsupported signature payload (classified as `rejected`) apart from a
250
+ * truly unsigned document (classified as `missing`), and only this
251
+ * presence-only check captures both cases.
252
+ */
253
+ function hasLdSignatureProperty(jsonLd) {
254
+ return typeof jsonLd === "object" && jsonLd != null && "signature" in jsonLd;
255
+ }
256
+ function getLdSignatureObject(jsonLd) {
257
+ if (!hasLdSignatureProperty(jsonLd)) return void 0;
258
+ const { signature } = jsonLd;
259
+ if (typeof signature !== "object" || signature == null || Array.isArray(signature)) return;
260
+ return signature;
261
+ }
262
+ /**
225
263
  * Verify the authenticity of the given JSON-LD document using Linked Data
226
264
  * Signatures. If the document is signed, this function verifies the signature
227
265
  * and checks if the document is attributed to the owner of the public key.
@@ -232,14 +270,22 @@ async function verifySignature(jsonLd, options = {}) {
232
270
  */
233
271
  async function verifyJsonLd(jsonLd, options = {}) {
234
272
  return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("ld_signatures.verify", async (span) => {
273
+ const start = performance.now();
274
+ let verified = false;
275
+ let threw = false;
276
+ let signatureType;
235
277
  try {
236
278
  const object = await Object$1.fromJsonLd(jsonLd, options);
237
279
  if (object.id != null) span.setAttribute("activitypub.object.id", object.id.href);
238
280
  span.setAttribute("activitypub.object.type", getTypeId(object).href);
239
- if (typeof jsonLd === "object" && jsonLd != null && "signature" in jsonLd && typeof jsonLd.signature === "object" && jsonLd.signature != null) {
240
- if ("creator" in jsonLd.signature && typeof jsonLd.signature.creator === "string") span.setAttribute("ld_signatures.key_id", jsonLd.signature.creator);
241
- if ("signatureValue" in jsonLd.signature && typeof jsonLd.signature.signatureValue === "string") span.setAttribute("ld_signatures.signature", jsonLd.signature.signatureValue);
242
- if ("type" in jsonLd.signature && typeof jsonLd.signature.type === "string") span.setAttribute("ld_signatures.type", jsonLd.signature.type);
281
+ const sig = getLdSignatureObject(jsonLd);
282
+ if (sig != null) {
283
+ if (typeof sig.creator === "string") span.setAttribute("ld_signatures.key_id", sig.creator);
284
+ if (typeof sig.signatureValue === "string") span.setAttribute("ld_signatures.signature", sig.signatureValue);
285
+ if (typeof sig.type === "string") {
286
+ span.setAttribute("ld_signatures.type", sig.type);
287
+ if (LD_KNOWN_SIGNATURE_TYPES.has(sig.type)) signatureType = sig.type;
288
+ }
243
289
  }
244
290
  const attributions = new Set(object.attributionIds.map((uri) => uri.href));
245
291
  if (object instanceof Activity) for (const uri of object.actorIds) attributions.add(uri.href);
@@ -254,14 +300,18 @@ async function verifyJsonLd(jsonLd, options = {}) {
254
300
  logger$3.debug("Some attributions are not authenticated by the Linked Data Signatures: {attributions}.", { attributions: [...attributions] });
255
301
  return false;
256
302
  }
303
+ verified = true;
257
304
  return true;
258
305
  } catch (error) {
306
+ threw = true;
259
307
  span.setStatus({
260
308
  code: SpanStatusCode.ERROR,
261
309
  message: String(error)
262
310
  });
263
311
  throw error;
264
312
  } finally {
313
+ const classified = threw ? "error" : verified ? "verified" : hasLdSignatureProperty(jsonLd) ? "rejected" : "missing";
314
+ getFederationMetrics(options.meterProvider).recordSignatureVerificationDuration(getDurationMs(start), "linked_data", classified, { ldType: signatureType });
265
315
  span.end();
266
316
  }
267
317
  });
@@ -766,6 +816,15 @@ async function normalizeOutgoingActivityJsonLd(jsonLd, contextLoader) {
766
816
  }
767
817
  //#endregion
768
818
  //#region src/sig/proof.ts
819
+ /**
820
+ * Known Object Integrity Proof `cryptosuite` values, used to keep
821
+ * `object_integrity_proofs.cryptosuite` on a bounded set of spec-defined
822
+ * string values. Fedify currently signs and verifies only
823
+ * `eddsa-jcs-2022`; other values come in only from external proofs and are
824
+ * dropped from the metric attribute to avoid attacker-controlled
825
+ * cardinality.
826
+ */
827
+ const OIP_KNOWN_CRYPTOSUITES = new Set(["eddsa-jcs-2022"]);
769
828
  const logger = getLogger([
770
829
  "fedify",
771
830
  "sig",
@@ -892,6 +951,10 @@ async function signObject(object, privateKey, keyId, options = {}) {
892
951
  */
893
952
  async function verifyProof(jsonLd, proof, options = {}) {
894
953
  return await (options.tracerProvider ?? trace.getTracerProvider()).getTracer(name, version).startActiveSpan("object_integrity_proofs.verify", async (span) => {
954
+ const start = performance.now();
955
+ let verified = false;
956
+ let threw = false;
957
+ const cryptosuite = proof.cryptosuite != null && OIP_KNOWN_CRYPTOSUITES.has(proof.cryptosuite) ? proof.cryptosuite : void 0;
895
958
  if (span.isRecording()) {
896
959
  if (proof.cryptosuite != null) span.setAttribute("object_integrity_proofs.cryptosuite", proof.cryptosuite);
897
960
  if (proof.verificationMethodId != null) span.setAttribute("object_integrity_proofs.key_id", proof.verificationMethodId.href);
@@ -900,21 +963,25 @@ async function verifyProof(jsonLd, proof, options = {}) {
900
963
  try {
901
964
  const key = await verifyProofInternal(jsonLd, proof, options);
902
965
  if (key == null) span.setStatus({ code: SpanStatusCode.ERROR });
966
+ else verified = true;
903
967
  return key;
904
968
  } catch (error) {
969
+ threw = true;
905
970
  span.setStatus({
906
971
  code: SpanStatusCode.ERROR,
907
972
  message: String(error)
908
973
  });
909
974
  throw error;
910
975
  } finally {
976
+ const classified = threw ? "error" : verified ? "verified" : "rejected";
977
+ getFederationMetrics(options.meterProvider).recordSignatureVerificationDuration(getDurationMs(start), "object_integrity", classified, { cryptosuite });
911
978
  span.end();
912
979
  }
913
980
  });
914
981
  }
915
982
  async function verifyProofInternal(jsonLd, proof, options) {
916
983
  if (typeof jsonLd !== "object" || jsonLd == null || Array.isArray(jsonLd) || proof.cryptosuite !== "eddsa-jcs-2022" || proof.verificationMethodId == null || proof.proofPurpose !== "assertionMethod" || proof.proofValue == null || proof.created == null) return null;
917
- const publicKeyPromise = fetchKey(proof.verificationMethodId, Multikey, options);
984
+ const publicKeyPromise = measureSignatureKeyFetch(options.meterProvider, "object_integrity", () => fetchKey(proof.verificationMethodId, Multikey, options));
918
985
  const proofConfig = {
919
986
  "@context": jsonLd["@context"],
920
987
  type: "DataIntegrityProof",
@@ -954,7 +1021,7 @@ async function verifyProofInternal(jsonLd, proof, options) {
954
1021
  proof,
955
1022
  keyId: proof.verificationMethodId.href
956
1023
  });
957
- return await verifyProof(jsonLd, proof, {
1024
+ return await verifyProofInternal(jsonLd, proof, {
958
1025
  ...options,
959
1026
  keyCache: {
960
1027
  get: () => Promise.resolve(void 0),
@@ -985,7 +1052,7 @@ async function verifyProofInternal(jsonLd, proof, options) {
985
1052
  keyId: proof.verificationMethodId.href,
986
1053
  proof
987
1054
  });
988
- return await verifyProof(jsonLd, proof, {
1055
+ return await verifyProofInternal(jsonLd, proof, {
989
1056
  ...options,
990
1057
  keyCache: {
991
1058
  get: () => Promise.resolve(void 0),
@@ -0,0 +1,225 @@
1
+ import "@js-temporal/polyfill";
2
+ import "urlpattern-polyfill";
3
+ globalThis.addEventListener = () => {};
4
+ import { n as version, t as name } from "./deno-CF3jMgip.mjs";
5
+ import { n as getFederationMetrics, t as getDurationMs } from "./metrics-ek3ilf6c.mjs";
6
+ import { n as doubleKnock } from "./http-BmOZYc-8.mjs";
7
+ import { getLogger } from "@logtape/logtape";
8
+ import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
9
+ //#region src/federation/send.ts
10
+ /**
11
+ * Extracts the inbox URLs from recipients.
12
+ * @param parameters The parameters to extract the inboxes.
13
+ * See also {@link ExtractInboxesParameters}.
14
+ * @returns The inboxes as a map of inbox URL to actor URIs.
15
+ */
16
+ function extractInboxes({ recipients, preferSharedInbox, excludeBaseUris }) {
17
+ const inboxes = {};
18
+ for (const recipient of recipients) {
19
+ let inbox;
20
+ let sharedInbox = false;
21
+ if (preferSharedInbox && recipient.endpoints?.sharedInbox != null) {
22
+ inbox = recipient.endpoints.sharedInbox;
23
+ sharedInbox = true;
24
+ } else inbox = recipient.inboxId;
25
+ if (inbox != null && recipient.id != null) {
26
+ if (excludeBaseUris != null && excludeBaseUris.some((u) => u.origin === inbox?.origin)) continue;
27
+ inboxes[inbox.href] ??= {
28
+ actorIds: /* @__PURE__ */ new Set(),
29
+ sharedInbox
30
+ };
31
+ inboxes[inbox.href].actorIds.add(recipient.id.href);
32
+ }
33
+ }
34
+ return inboxes;
35
+ }
36
+ /**
37
+ * Sends an {@link Activity} to an inbox.
38
+ *
39
+ * @param parameters The parameters for sending the activity.
40
+ * See also {@link SendActivityParameters}.
41
+ * @throws {Error} If the activity fails to send.
42
+ */
43
+ function sendActivity(options) {
44
+ const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
45
+ return tracerProvider.getTracer(name, version).startActiveSpan("activitypub.send_activity", {
46
+ kind: SpanKind.CLIENT,
47
+ attributes: { "activitypub.shared_inbox": options.sharedInbox ?? false }
48
+ }, async (span) => {
49
+ if (options.activityId != null) span.setAttribute("activitypub.activity.id", options.activityId);
50
+ if (options.activityType != null) span.setAttribute("activitypub.activity.type", options.activityType);
51
+ try {
52
+ await sendActivityInternal({
53
+ ...options,
54
+ tracerProvider
55
+ }, span);
56
+ } catch (e) {
57
+ span.setStatus({
58
+ code: SpanStatusCode.ERROR,
59
+ message: String(e)
60
+ });
61
+ throw e;
62
+ } finally {
63
+ span.end();
64
+ }
65
+ });
66
+ }
67
+ const MAX_ERROR_RESPONSE_BODY_BYTES = 1024;
68
+ function getActivityActorId(activity) {
69
+ if (!isRecord(activity)) return void 0;
70
+ return getIdValue(activity.actor);
71
+ }
72
+ function getIdValue(value) {
73
+ if (typeof value === "string" && value !== "") return value;
74
+ if (value instanceof URL) return value.href;
75
+ if (Array.isArray(value)) {
76
+ for (const item of value) {
77
+ const id = getIdValue(item);
78
+ if (id != null) return id;
79
+ }
80
+ return;
81
+ }
82
+ if (isRecord(value)) return getIdValue(value.id);
83
+ }
84
+ function isRecord(value) {
85
+ return typeof value === "object" && value != null;
86
+ }
87
+ async function readLimitedResponseBody(response, maxBytes) {
88
+ if (response.body == null) return "";
89
+ const reader = response.body.getReader();
90
+ const decoder = new TextDecoder();
91
+ const chunks = [];
92
+ let totalBytes = 0;
93
+ let truncated = false;
94
+ try {
95
+ while (true) {
96
+ const { done, value } = await reader.read();
97
+ if (done) break;
98
+ if (totalBytes + value.length > maxBytes) {
99
+ const remaining = maxBytes - totalBytes;
100
+ if (remaining > 0) chunks.push(decoder.decode(value.slice(0, remaining), { stream: true }));
101
+ truncated = true;
102
+ break;
103
+ }
104
+ chunks.push(decoder.decode(value, { stream: true }));
105
+ totalBytes += value.length;
106
+ }
107
+ } finally {
108
+ reader.releaseLock();
109
+ }
110
+ let result = chunks.join("");
111
+ if (truncated) result += "… (truncated)";
112
+ return result;
113
+ }
114
+ async function sendActivityInternal({ activity, activityId, activityType, keys, inbox, headers, specDeterminer, meterProvider, tracerProvider }, span) {
115
+ const logger = getLogger([
116
+ "fedify",
117
+ "federation",
118
+ "outbox"
119
+ ]);
120
+ const federationMetrics = getFederationMetrics(meterProvider);
121
+ const started = performance.now();
122
+ let deliverySuccess = false;
123
+ headers = new Headers(headers);
124
+ headers.set("Content-Type", "application/activity+json");
125
+ const request = new Request(inbox, {
126
+ method: "POST",
127
+ headers,
128
+ body: JSON.stringify(activity)
129
+ });
130
+ let rsaKey = null;
131
+ for (const key of keys) if (key.privateKey.algorithm.name === "RSASSA-PKCS1-v1_5") {
132
+ rsaKey = key;
133
+ break;
134
+ }
135
+ if (rsaKey == null) logger.warn("No supported key found to sign the request to {inbox}. The request will be sent without a signature. In order to sign the request, at least one RSASSA-PKCS1-v1_5 key must be provided.", {
136
+ inbox: inbox.href,
137
+ keys: keys.map((pair) => ({
138
+ keyId: pair.keyId.href,
139
+ privateKey: pair.privateKey
140
+ }))
141
+ });
142
+ let response;
143
+ try {
144
+ response = rsaKey == null ? await fetch(request) : await doubleKnock(request, rsaKey, {
145
+ tracerProvider,
146
+ specDeterminer
147
+ });
148
+ } catch (error) {
149
+ logger.error("Failed to send activity {activityId} to {inbox}:\n{error}", {
150
+ activityId,
151
+ inbox: inbox.href,
152
+ error
153
+ });
154
+ federationMetrics.recordDelivery(inbox, getDurationMs(started), false, activityType);
155
+ throw error;
156
+ }
157
+ try {
158
+ if (!response.ok) {
159
+ let error;
160
+ try {
161
+ error = await readLimitedResponseBody(response, MAX_ERROR_RESPONSE_BODY_BYTES);
162
+ } catch (_) {
163
+ error = "";
164
+ }
165
+ logger.error("Failed to send activity {activityId} to {inbox} ({status} {statusText}):\n{error}", {
166
+ activityId,
167
+ inbox: inbox.href,
168
+ status: response.status,
169
+ statusText: response.statusText,
170
+ error
171
+ });
172
+ throw new SendActivityError(inbox, response.status, `Failed to send activity ${activityId} to ${inbox.href} (${response.status} ${response.statusText}):\n${error}`, error);
173
+ }
174
+ deliverySuccess = true;
175
+ const eventAttributes = {
176
+ "activitypub.inbox.url": inbox.href,
177
+ "activitypub.activity.id": activityId ?? ""
178
+ };
179
+ if (activityType != null) eventAttributes["activitypub.activity.type"] = activityType;
180
+ const actorId = getActivityActorId(activity);
181
+ if (actorId != null) eventAttributes["activitypub.actor.id"] = actorId;
182
+ span.addEvent("activitypub.activity.sent", eventAttributes);
183
+ } finally {
184
+ federationMetrics.recordDelivery(inbox, getDurationMs(started), deliverySuccess, activityType);
185
+ }
186
+ }
187
+ /**
188
+ * An error that is thrown when an activity fails to send to a remote inbox.
189
+ * It contains structured information about the failure, including the HTTP
190
+ * status code, the inbox URL, and the response body.
191
+ * @since 2.0.0
192
+ */
193
+ var SendActivityError = class extends Error {
194
+ /**
195
+ * The inbox URL that the activity was being sent to.
196
+ */
197
+ inbox;
198
+ /**
199
+ * The HTTP status code returned by the inbox.
200
+ */
201
+ statusCode;
202
+ /**
203
+ * The response body from the inbox, if any. Note that this may be
204
+ * truncated to a maximum of 1 KiB to prevent excessive memory consumption
205
+ * when remote servers return large error pages (e.g., Cloudflare error pages).
206
+ * If truncated, the string will end with `"… (truncated)"`.
207
+ */
208
+ responseBody;
209
+ /**
210
+ * Creates a new {@link SendActivityError}.
211
+ * @param inbox The inbox URL.
212
+ * @param statusCode The HTTP status code.
213
+ * @param message The error message.
214
+ * @param responseBody The response body.
215
+ */
216
+ constructor(inbox, statusCode, message, responseBody) {
217
+ super(message);
218
+ this.name = "SendActivityError";
219
+ this.inbox = inbox;
220
+ this.statusCode = statusCode;
221
+ this.responseBody = responseBody;
222
+ }
223
+ };
224
+ //#endregion
225
+ export { extractInboxes as n, sendActivity as r, SendActivityError as t };
@@ -1,7 +1,7 @@
1
1
  import "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
- import { i as validateAcceptSignature, n as fulfillAcceptSignature, r as parseAcceptSignature, t as formatAcceptSignature } from "../accept-CceiKpCy.mjs";
4
+ import { i as validateAcceptSignature, n as fulfillAcceptSignature, r as parseAcceptSignature, t as formatAcceptSignature } from "../accept-CgDcxvjV.mjs";
5
5
  import { test } from "@fedify/fixture";
6
6
  import { deepStrictEqual, strictEqual } from "node:assert/strict";
7
7
  //#region src/sig/accept.test.ts
@@ -2,15 +2,15 @@ import { Temporal } from "@js-temporal/polyfill";
2
2
  import "urlpattern-polyfill";
3
3
  globalThis.addEventListener = () => {};
4
4
  import { t as assertEquals } from "../assert_equals-Ew3jOFa3.mjs";
5
- import { i as assertExists, t as assertStringIncludes } from "../std__assert-CRDpx_HF.mjs";
6
- import { n as assertFalse, t as assertRejects } from "../assert_rejects-B-qJtC9Z.mjs";
5
+ import { r as assertExists, t as assertStringIncludes } from "../std__assert-BTEgfoJo.mjs";
6
+ import { n as assertGreaterOrEqual, r as assertFalse, t as assertRejects } from "../assert_rejects-DQP-q39h.mjs";
7
7
  import { t as assertThrows } from "../assert_throws-4NwKEy2q.mjs";
8
8
  import { t as assert } from "../assert-DikXweDx.mjs";
9
9
  import { t as esm_default } from "../esm-sdtqOUPu.mjs";
10
- import { t as exportJwk } from "../key-DW1EVmtP.mjs";
11
- import { a as parseRfc9421Signature, c as timingSafeEqual, i as formatRfc9421SignatureParameters, l as verifyRequest, n as doubleKnock, o as parseRfc9421SignatureInput, r as formatRfc9421Signature, s as signRequest, t as createRfc9421SignatureBase, u as verifyRequestDetailed } from "../http-BLopFpvC.mjs";
12
- import { i as rsaPrivateKey2, l as rsaPublicKey5, o as rsaPublicKey1, s as rsaPublicKey2 } from "../keys-C3kae-6B.mjs";
13
- import { createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
10
+ import { t as exportJwk } from "../key-B4I8H5Lc.mjs";
11
+ import { a as parseRfc9421Signature, c as timingSafeEqual, i as formatRfc9421SignatureParameters, l as verifyRequest, n as doubleKnock, o as parseRfc9421SignatureInput, r as formatRfc9421Signature, s as signRequest, t as createRfc9421SignatureBase, u as verifyRequestDetailed } from "../http-BmOZYc-8.mjs";
12
+ import { i as rsaPrivateKey2, l as rsaPublicKey5, o as rsaPublicKey1, s as rsaPublicKey2 } from "../keys-CSYsOMFG.mjs";
13
+ import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
14
14
  import { FetchError, exportSpki } from "@fedify/vocab-runtime";
15
15
  import { encodeBase64 } from "byte-encodings/base64";
16
16
  //#region src/sig/http.test.ts
@@ -230,6 +230,212 @@ test("verifyRequestDetailed() records failure details on span", async () => {
230
230
  assertEquals(span.attributes["http_signatures.key_id"], keyId.href);
231
231
  assertEquals(span.attributes["http_signatures.key_fetch_status"], 410);
232
232
  });
233
+ test("verifyRequestDetailed() records verification duration metric", async (t) => {
234
+ const buildSignedRequest = () => signRequest(new Request("https://example.com/inbox", {
235
+ method: "POST",
236
+ headers: {
237
+ "Content-Type": "application/activity+json",
238
+ accept: "application/ld+json"
239
+ },
240
+ body: JSON.stringify({
241
+ "@context": "https://www.w3.org/ns/activitystreams",
242
+ type: "Create",
243
+ actor: "https://example.com/key2"
244
+ })
245
+ }), rsaPrivateKey2, new URL("https://example.com/key2"));
246
+ await t.step("verified path emits one measurement", async () => {
247
+ const [meterProvider, recorder] = createTestMeterProvider();
248
+ assert((await verifyRequestDetailed(await buildSignedRequest(), {
249
+ contextLoader: mockDocumentLoader,
250
+ documentLoader: mockDocumentLoader,
251
+ meterProvider
252
+ })).verified);
253
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
254
+ assertEquals(measurements.length, 1);
255
+ const measurement = measurements[0];
256
+ assertEquals(measurement.type, "histogram");
257
+ assertGreaterOrEqual(measurement.value, 0);
258
+ assertEquals(measurement.attributes["activitypub.signature.kind"], "http");
259
+ assertEquals(measurement.attributes["activitypub.signature.result"], "verified");
260
+ assertEquals(measurement.attributes["http_signatures.algorithm"], "rsa-sha256");
261
+ assertFalse("http_signatures.failure_reason" in measurement.attributes);
262
+ });
263
+ await t.step("missing signature is recorded as result=missing", async () => {
264
+ const [meterProvider, recorder] = createTestMeterProvider();
265
+ const result = await verifyRequestDetailed(new Request("https://example.com/inbox", {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/activity+json" },
268
+ body: "{}"
269
+ }), {
270
+ contextLoader: mockDocumentLoader,
271
+ documentLoader: mockDocumentLoader,
272
+ meterProvider
273
+ });
274
+ assertFalse(result.verified);
275
+ assertEquals(result.reason.type, "noSignature");
276
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
277
+ assertEquals(measurements.length, 1);
278
+ assertEquals(measurements[0].attributes["activitypub.signature.kind"], "http");
279
+ assertEquals(measurements[0].attributes["activitypub.signature.result"], "missing");
280
+ assertFalse("http_signatures.failure_reason" in measurements[0].attributes);
281
+ });
282
+ await t.step("invalid signature is recorded as result=rejected with failure_reason", async () => {
283
+ const [meterProvider, recorder] = createTestMeterProvider();
284
+ const result = await verifyRequestDetailed(new Request("https://example.com/", {
285
+ method: "POST",
286
+ headers: {
287
+ Date: "Tue, 05 Mar 2024 07:49:44 GMT",
288
+ Digest: "sha-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
289
+ Signature: "keyId=\"https://example.com/key2\",headers=\"(request-target) date digest\",signature=\"AAAA\""
290
+ },
291
+ body: ""
292
+ }), {
293
+ documentLoader: mockDocumentLoader,
294
+ contextLoader: mockDocumentLoader,
295
+ meterProvider
296
+ });
297
+ assertFalse(result.verified);
298
+ assertEquals(result.reason.type, "invalidSignature");
299
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
300
+ assertEquals(measurements.length, 1);
301
+ assertEquals(measurements[0].attributes["activitypub.signature.kind"], "http");
302
+ assertEquals(measurements[0].attributes["activitypub.signature.result"], "rejected");
303
+ assertEquals(measurements[0].attributes["http_signatures.failure_reason"], "invalidSignature");
304
+ });
305
+ await t.step("key fetch failure is recorded as result=rejected with failure_reason=keyFetchError", async () => {
306
+ const [meterProvider, recorder] = createTestMeterProvider();
307
+ const keyId = new URL("https://gone.example/actors/alice#main-key");
308
+ const result = await verifyRequestDetailed(await signRequest(new Request("https://example.com/inbox", {
309
+ method: "POST",
310
+ headers: {
311
+ "Content-Type": "application/activity+json",
312
+ accept: "application/ld+json"
313
+ },
314
+ body: JSON.stringify({
315
+ "@context": "https://www.w3.org/ns/activitystreams",
316
+ type: "Create",
317
+ actor: "https://gone.example/actors/alice"
318
+ })
319
+ }), rsaPrivateKey2, keyId), {
320
+ contextLoader: mockDocumentLoader,
321
+ documentLoader(url) {
322
+ if (url === keyId.href) throw new FetchError(keyId, `HTTP 410: ${keyId.href}`, new Response(null, { status: 410 }));
323
+ return mockDocumentLoader(url);
324
+ },
325
+ meterProvider
326
+ });
327
+ assertFalse(result.verified);
328
+ assertEquals(result.reason.type, "keyFetchError");
329
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
330
+ assertEquals(measurements.length, 1);
331
+ assertEquals(measurements[0].attributes["activitypub.signature.result"], "rejected");
332
+ assertEquals(measurements[0].attributes["http_signatures.failure_reason"], "keyFetchError");
333
+ });
334
+ await t.step("verifyRequest() wrapper emits exactly one measurement, not two", async () => {
335
+ const [meterProvider, recorder] = createTestMeterProvider();
336
+ assertExists(await verifyRequest(await buildSignedRequest(), {
337
+ contextLoader: mockDocumentLoader,
338
+ documentLoader: mockDocumentLoader,
339
+ meterProvider
340
+ }));
341
+ assertEquals(recorder.getMeasurements("activitypub.signature.verification.duration").length, 1);
342
+ });
343
+ await t.step("cached-key retry emits one measurement, not two", async () => {
344
+ const [meterProvider, recorder] = createTestMeterProvider();
345
+ const cache = { "https://example.com/key2": rsaPublicKey1 };
346
+ assertExists(await verifyRequest(await buildSignedRequest(), {
347
+ contextLoader: mockDocumentLoader,
348
+ documentLoader: mockDocumentLoader,
349
+ meterProvider,
350
+ keyCache: {
351
+ get(keyId) {
352
+ return Promise.resolve(cache[keyId.href]);
353
+ },
354
+ set(keyId, k) {
355
+ cache[keyId.href] = k;
356
+ return Promise.resolve();
357
+ }
358
+ }
359
+ }));
360
+ assertEquals(recorder.getMeasurements("activitypub.signature.verification.duration").length, 1);
361
+ });
362
+ await t.step("key fetch records result=fetched on a cold cache", async () => {
363
+ const [meterProvider, recorder] = createTestMeterProvider();
364
+ assertExists(await verifyRequest(await buildSignedRequest(), {
365
+ contextLoader: mockDocumentLoader,
366
+ documentLoader: mockDocumentLoader,
367
+ meterProvider
368
+ }));
369
+ const measurements = recorder.getMeasurements("activitypub.signature.key_fetch.duration");
370
+ assertEquals(measurements.length, 1);
371
+ assertEquals(measurements[0].type, "histogram");
372
+ assertGreaterOrEqual(measurements[0].value, 0);
373
+ assertEquals(measurements[0].attributes["activitypub.signature.kind"], "http");
374
+ assertEquals(measurements[0].attributes["activitypub.signature.key_fetch.result"], "fetched");
375
+ });
376
+ await t.step("key fetch records result=hit when served from the key cache", async () => {
377
+ const [meterProvider, recorder] = createTestMeterProvider();
378
+ const cache = { "https://example.com/key2": rsaPublicKey2 };
379
+ assertExists(await verifyRequest(await buildSignedRequest(), {
380
+ contextLoader: mockDocumentLoader,
381
+ documentLoader: mockDocumentLoader,
382
+ meterProvider,
383
+ keyCache: {
384
+ get(keyId) {
385
+ return Promise.resolve(cache[keyId.href]);
386
+ },
387
+ set(keyId, k) {
388
+ cache[keyId.href] = k;
389
+ return Promise.resolve();
390
+ }
391
+ }
392
+ }));
393
+ const measurements = recorder.getMeasurements("activitypub.signature.key_fetch.duration");
394
+ assertEquals(measurements.length, 1);
395
+ assertEquals(measurements[0].attributes["activitypub.signature.key_fetch.result"], "hit");
396
+ });
397
+ await t.step("key fetch records result=error when the remote key returns HTTP 410", async () => {
398
+ const [meterProvider, recorder] = createTestMeterProvider();
399
+ const keyId = new URL("https://gone.example/actors/alice#main-key");
400
+ assertFalse((await verifyRequestDetailed(await signRequest(new Request("https://example.com/inbox", {
401
+ method: "POST",
402
+ headers: {
403
+ "Content-Type": "application/activity+json",
404
+ accept: "application/ld+json"
405
+ },
406
+ body: "{}"
407
+ }), rsaPrivateKey2, keyId), {
408
+ contextLoader: mockDocumentLoader,
409
+ documentLoader(url) {
410
+ if (url === keyId.href) throw new FetchError(keyId, `HTTP 410: ${keyId.href}`, new Response(null, { status: 410 }));
411
+ return mockDocumentLoader(url);
412
+ },
413
+ meterProvider
414
+ })).verified);
415
+ const measurements = recorder.getMeasurements("activitypub.signature.key_fetch.duration");
416
+ assertEquals(measurements.length, 1);
417
+ assertEquals(measurements[0].attributes["activitypub.signature.key_fetch.result"], "error");
418
+ });
419
+ await t.step("draft-cavage with unknown algorithm omits the algorithm metric attribute", async () => {
420
+ const [meterProvider, recorder] = createTestMeterProvider();
421
+ assertFalse((await verifyRequestDetailed(new Request("https://example.com/", {
422
+ method: "POST",
423
+ headers: {
424
+ Date: "Tue, 05 Mar 2024 07:49:44 GMT",
425
+ Digest: "sha-256=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=",
426
+ Signature: "keyId=\"https://example.com/key2\",algorithm=\"x-attacker-supplied\",headers=\"(request-target) date digest\",signature=\"AAAA\""
427
+ },
428
+ body: ""
429
+ }), {
430
+ documentLoader: mockDocumentLoader,
431
+ contextLoader: mockDocumentLoader,
432
+ meterProvider
433
+ })).verified);
434
+ const measurements = recorder.getMeasurements("activitypub.signature.verification.duration");
435
+ assertEquals(measurements.length, 1);
436
+ assertFalse("http_signatures.algorithm" in measurements[0].attributes);
437
+ });
438
+ });
233
439
  test("signRequest() and verifyRequest() [rfc9421] implementation", async () => {
234
440
  const currentTimestamp = 1709626184;
235
441
  const currentTime = Temporal.Instant.from("2024-03-05T08:09:44Z");