@fedify/fedify 1.6.0-dev.778 → 1.6.0-dev.790

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 (56) hide show
  1. package/CHANGES.md +17 -0
  2. package/CONTRIBUTING.md +7 -0
  3. package/README.md +1 -1
  4. package/SPONSORS.md +1 -1
  5. package/esm/deno.js +3 -1
  6. package/esm/deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.js +55 -0
  7. package/esm/federation/middleware.js +26 -1
  8. package/esm/federation/send.js +6 -7
  9. package/esm/runtime/docloader.js +4 -16
  10. package/esm/sig/http.js +528 -9
  11. package/esm/sig/key.js +4 -1
  12. package/esm/testing/fixtures/remote.domain/users/bob +20 -0
  13. package/esm/vocab/vocab.js +308 -176
  14. package/package.json +2 -1
  15. package/types/deno.d.ts +2 -0
  16. package/types/deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.d.ts +34 -0
  17. package/types/deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.d.ts.map +1 -0
  18. package/types/federation/middleware.d.ts +21 -3
  19. package/types/federation/middleware.d.ts.map +1 -1
  20. package/types/federation/send.d.ts +6 -0
  21. package/types/federation/send.d.ts.map +1 -1
  22. package/types/runtime/docloader.d.ts +17 -1
  23. package/types/runtime/docloader.d.ts.map +1 -1
  24. package/types/sig/http.d.ts +128 -0
  25. package/types/sig/http.d.ts.map +1 -1
  26. package/types/sig/key.d.ts.map +1 -1
  27. package/types/sig/mod.d.ts +1 -1
  28. package/types/sig/mod.d.ts.map +1 -1
  29. package/types/vocab/vocab.d.ts.map +1 -1
  30. package/esm/deps/jsr.io/@std/bytes/1.0.5/copy.js +0 -50
  31. package/esm/deps/jsr.io/@std/bytes/1.0.5/ends_with.js +0 -36
  32. package/esm/deps/jsr.io/@std/bytes/1.0.5/equals.js +0 -82
  33. package/esm/deps/jsr.io/@std/bytes/1.0.5/includes_needle.js +0 -42
  34. package/esm/deps/jsr.io/@std/bytes/1.0.5/index_of_needle.js +0 -68
  35. package/esm/deps/jsr.io/@std/bytes/1.0.5/last_index_of_needle.js +0 -65
  36. package/esm/deps/jsr.io/@std/bytes/1.0.5/mod.js +0 -34
  37. package/esm/deps/jsr.io/@std/bytes/1.0.5/repeat.js +0 -43
  38. package/esm/deps/jsr.io/@std/bytes/1.0.5/starts_with.js +0 -34
  39. package/types/deps/jsr.io/@std/bytes/1.0.5/copy.d.ts +0 -41
  40. package/types/deps/jsr.io/@std/bytes/1.0.5/copy.d.ts.map +0 -1
  41. package/types/deps/jsr.io/@std/bytes/1.0.5/ends_with.d.ts +0 -24
  42. package/types/deps/jsr.io/@std/bytes/1.0.5/ends_with.d.ts.map +0 -1
  43. package/types/deps/jsr.io/@std/bytes/1.0.5/equals.d.ts +0 -22
  44. package/types/deps/jsr.io/@std/bytes/1.0.5/equals.d.ts.map +0 -1
  45. package/types/deps/jsr.io/@std/bytes/1.0.5/includes_needle.d.ts +0 -38
  46. package/types/deps/jsr.io/@std/bytes/1.0.5/includes_needle.d.ts.map +0 -1
  47. package/types/deps/jsr.io/@std/bytes/1.0.5/index_of_needle.d.ts +0 -45
  48. package/types/deps/jsr.io/@std/bytes/1.0.5/index_of_needle.d.ts.map +0 -1
  49. package/types/deps/jsr.io/@std/bytes/1.0.5/last_index_of_needle.d.ts +0 -42
  50. package/types/deps/jsr.io/@std/bytes/1.0.5/last_index_of_needle.d.ts.map +0 -1
  51. package/types/deps/jsr.io/@std/bytes/1.0.5/mod.d.ts +0 -33
  52. package/types/deps/jsr.io/@std/bytes/1.0.5/mod.d.ts.map +0 -1
  53. package/types/deps/jsr.io/@std/bytes/1.0.5/repeat.d.ts +0 -33
  54. package/types/deps/jsr.io/@std/bytes/1.0.5/repeat.d.ts.map +0 -1
  55. package/types/deps/jsr.io/@std/bytes/1.0.5/starts_with.d.ts +0 -24
  56. package/types/deps/jsr.io/@std/bytes/1.0.5/starts_with.d.ts.map +0 -1
package/esm/sig/http.js CHANGED
@@ -2,9 +2,10 @@ import * as dntShim from "../_dnt.shims.js";
2
2
  import { getLogger } from "@logtape/logtape";
3
3
  import { SpanStatusCode, trace, } from "@opentelemetry/api";
4
4
  import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_URL_FULL, } from "@opentelemetry/semantic-conventions";
5
- import { equals } from "../deps/jsr.io/@std/bytes/1.0.5/mod.js";
5
+ import { timingSafeEqual } from "../deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.js";
6
6
  import { decodeBase64, encodeBase64 } from "../deps/jsr.io/@std/encoding/1.0.7/base64.js";
7
7
  import { encodeHex } from "../deps/jsr.io/@std/encoding/1.0.7/hex.js";
8
+ import { decodeDict, encodeItem, Item, } from "structured-field-values";
8
9
  import metadata from "../deno.js";
9
10
  import { CryptographicKey } from "../vocab/vocab.js";
10
11
  import { fetchKey, validateCryptoKey } from "./key.js";
@@ -18,11 +19,22 @@ import { fetchKey, validateCryptoKey } from "./key.js";
18
19
  * @throws {TypeError} If the private key is invalid or unsupported.
19
20
  */
20
21
  export async function signRequest(request, privateKey, keyId, options = {}) {
22
+ validateCryptoKey(privateKey, "private");
21
23
  const tracerProvider = options.tracerProvider ?? trace.getTracerProvider();
22
24
  const tracer = tracerProvider.getTracer(metadata.name, metadata.version);
23
25
  return await tracer.startActiveSpan("http_signatures.sign", async (span) => {
24
26
  try {
25
- const signed = await signRequestInternal(request, privateKey, keyId, span);
27
+ // Choose implementation based on spec option
28
+ const spec = options.spec ?? "draft-cavage-http-signatures-12";
29
+ let signed;
30
+ if (spec === "rfc9421") {
31
+ // Pass through test options if provided
32
+ signed = await signRequestRfc9421(request, privateKey, keyId, span, options.currentTime);
33
+ }
34
+ else {
35
+ // Default to draft-cavage
36
+ signed = await signRequestDraft(request, privateKey, keyId, span, options.currentTime);
37
+ }
26
38
  if (span.isRecording()) {
27
39
  span.setAttribute(ATTR_HTTP_REQUEST_METHOD, signed.method);
28
40
  span.setAttribute(ATTR_URL_FULL, signed.url);
@@ -45,14 +57,13 @@ export async function signRequest(request, privateKey, keyId, options = {}) {
45
57
  }
46
58
  });
47
59
  }
48
- async function signRequestInternal(request, privateKey, keyId, span) {
49
- validateCryptoKey(privateKey, "private");
60
+ async function signRequestDraft(request, privateKey, keyId, span, currentTime) {
50
61
  if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") {
51
62
  throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
52
63
  }
53
64
  const url = new URL(request.url);
54
65
  const body = request.method !== "GET" && request.method !== "HEAD"
55
- ? await request.arrayBuffer()
66
+ ? await request.clone().arrayBuffer()
56
67
  : null;
57
68
  const headers = new Headers(request.headers);
58
69
  if (!headers.has("Host")) {
@@ -66,7 +77,10 @@ async function signRequestInternal(request, privateKey, keyId, span) {
66
77
  }
67
78
  }
68
79
  if (!headers.has("Date")) {
69
- headers.set("Date", new Date().toUTCString());
80
+ headers.set("Date", currentTime == null
81
+ ? new Date().toUTCString()
82
+ // FIXME: Do we have any better way to format Temporal.Instant to RFC 9421?
83
+ : new Date(currentTime.toString()).toUTCString());
70
84
  }
71
85
  const serialized = [
72
86
  ["(request-target)", `${request.method.toLowerCase()} ${url.pathname}`],
@@ -88,6 +102,191 @@ async function signRequestInternal(request, privateKey, keyId, span) {
88
102
  body,
89
103
  });
90
104
  }
105
+ export function formatRfc9421SignatureParameters(params) {
106
+ return `alg="${params.algorithm}";keyid="${params.keyId.href}";created=${params.created}`;
107
+ }
108
+ /**
109
+ * Creates a signature base for a request according to RFC 9421.
110
+ * @param request The request to create a signature base for.
111
+ * @param components The components to include in the signature base.
112
+ * @param parameters The signature parameters to include in the signature base.
113
+ * @returns The signature base as a string.
114
+ */
115
+ export function createRfc9421SignatureBase(request, components, parameters) {
116
+ const url = new URL(request.url);
117
+ // Build the base string
118
+ const baseComponents = [];
119
+ for (const component of components) {
120
+ let value;
121
+ // Process special derived components
122
+ if (component === "@method") {
123
+ value = request.method.toUpperCase();
124
+ }
125
+ else if (component === "@target-uri") {
126
+ value = request.url;
127
+ }
128
+ else if (component === "@authority") {
129
+ value = url.host;
130
+ }
131
+ else if (component === "@scheme") {
132
+ value = url.protocol.slice(0, -1); // Remove the trailing ':'
133
+ }
134
+ else if (component === "@request-target") {
135
+ value = `${request.method.toLowerCase()} ${url.pathname}${url.search}`;
136
+ }
137
+ else if (component === "@path") {
138
+ value = url.pathname;
139
+ }
140
+ else if (component === "@query") {
141
+ value = url.search.startsWith("?") ? url.search.slice(1) : url.search;
142
+ }
143
+ else if (component === "@query-param") {
144
+ throw new Error("@query-param requires a parameter name");
145
+ }
146
+ else if (component === "@status") {
147
+ throw new Error("@status is only valid for responses");
148
+ }
149
+ else if (component.startsWith("@")) {
150
+ throw new Error(`Unsupported derived component: ${component}`);
151
+ }
152
+ else {
153
+ // Regular header
154
+ value = request.headers.get(component) || "";
155
+ }
156
+ // Format the component as per RFC 9421 Section 2.1
157
+ baseComponents.push(`"${component}": ${value}`);
158
+ }
159
+ // Add the signature parameters component at the end
160
+ const sigComponents = components.map((c) => `"${c}"`).join(" ");
161
+ baseComponents.push(`"@signature-params": (${sigComponents});${parameters}`);
162
+ return baseComponents.join("\n");
163
+ }
164
+ /**
165
+ * Formats a signature using rfc9421 format.
166
+ * @param signature The raw signature bytes.
167
+ * @param components The components that were signed.
168
+ * @param parameters The signature parameters.
169
+ * @returns The formatted signature string.
170
+ */
171
+ export function formatRfc9421Signature(signature, components, parameters) {
172
+ const signatureInputValue = `sig1=("${components.join('" "')}");${parameters}`;
173
+ const signatureValue = `sig1=:${encodeBase64(signature)}:`;
174
+ return [signatureInputValue, signatureValue];
175
+ }
176
+ /**
177
+ * Parse RFC 9421 Signature-Input header.
178
+ * @param signatureInput The Signature-Input header value.
179
+ * @returns Parsed signature input parameters.
180
+ */
181
+ export function parseRfc9421SignatureInput(signatureInput) {
182
+ let dict;
183
+ try {
184
+ dict = decodeDict(signatureInput);
185
+ }
186
+ catch (error) {
187
+ getLogger(["fedify", "sig", "http"]).debug("Failed to parse Signature-Input header: {signatureInput}", { signatureInput, error });
188
+ return {};
189
+ }
190
+ const result = {};
191
+ for (const [label, item] of Object.entries(dict)) {
192
+ if (!Array.isArray(item.value) ||
193
+ typeof item.params.keyid !== "string" ||
194
+ typeof item.params.created !== "number")
195
+ continue;
196
+ const components = item.value
197
+ .map((subitem) => subitem.value)
198
+ .filter((v) => typeof v === "string");
199
+ const params = encodeItem(new Item(0, item.params));
200
+ result[label] = {
201
+ keyId: item.params.keyid,
202
+ alg: item.params.alg,
203
+ created: item.params.created,
204
+ components,
205
+ parameters: params.slice(params.indexOf(";") + 1),
206
+ };
207
+ }
208
+ return result;
209
+ }
210
+ /**
211
+ * Parse RFC 9421 Signature header.
212
+ * @param signature The Signature header value.
213
+ * @returns Parsed signature values.
214
+ */
215
+ export function parseRfc9421Signature(signature) {
216
+ let dict;
217
+ try {
218
+ dict = decodeDict(signature);
219
+ }
220
+ catch (error) {
221
+ getLogger(["fedify", "sig", "http"]).debug("Failed to parse Signature header: {signature}", { signature, error });
222
+ return {};
223
+ }
224
+ const result = {};
225
+ for (const [key, value] of Object.entries(dict)) {
226
+ if (value.value instanceof Uint8Array) {
227
+ result[key] = value.value;
228
+ }
229
+ }
230
+ return result;
231
+ }
232
+ async function signRequestRfc9421(request, privateKey, keyId, span, currentTime) {
233
+ if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") {
234
+ throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
235
+ }
236
+ const url = new URL(request.url);
237
+ const body = request.method !== "GET" && request.method !== "HEAD"
238
+ ? await request.clone().arrayBuffer()
239
+ : null;
240
+ const headers = new Headers(request.headers);
241
+ if (!headers.has("Host")) {
242
+ headers.set("Host", url.host);
243
+ }
244
+ if (!headers.has("Content-Digest") && body != null) {
245
+ // RFC 9421 uses Content-Digest instead of Digest
246
+ const digest = await dntShim.crypto.subtle.digest("SHA-256", body);
247
+ headers.set("Content-Digest", `sha-256=:${encodeBase64(digest)}:`);
248
+ if (span.isRecording()) {
249
+ span.setAttribute("http_signatures.digest.sha-256", encodeHex(digest));
250
+ }
251
+ }
252
+ // Use provided timestamp or current time
253
+ const created = (currentTime ?? dntShim.Temporal.Now.instant()).epochMilliseconds /
254
+ 1000;
255
+ // Define components to include in the signature
256
+ const components = [
257
+ "@method",
258
+ "@target-uri",
259
+ "@authority",
260
+ "host",
261
+ "date",
262
+ ];
263
+ if (body != null) {
264
+ components.push("content-digest");
265
+ }
266
+ // Generate the signature base using the headers
267
+ const signatureParams = formatRfc9421SignatureParameters({
268
+ algorithm: "rsa-v1_5-sha256",
269
+ keyId,
270
+ created,
271
+ });
272
+ const signatureBase = createRfc9421SignatureBase(new Request(request.url, {
273
+ method: request.method,
274
+ headers,
275
+ }), components, signatureParams);
276
+ // Sign the signature base
277
+ const signatureBytes = await dntShim.crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, new TextEncoder().encode(signatureBase));
278
+ // Format the signature according to RFC 9421
279
+ const [signatureInput, signature] = formatRfc9421Signature(signatureBytes, components, signatureParams);
280
+ // Add the signature headers
281
+ headers.set("Signature-Input", signatureInput);
282
+ headers.set("Signature", signature);
283
+ if (span.isRecording()) {
284
+ span.setAttribute("http_signatures.algorithm", "rsa-v1_5-sha256");
285
+ span.setAttribute("http_signatures.signature", encodeHex(signatureBytes));
286
+ span.setAttribute("http_signatures.created", created.toString());
287
+ }
288
+ return new Request(request, { headers, body });
289
+ }
91
290
  const supportedHashAlgorithms = {
92
291
  "sha": "SHA-1",
93
292
  "sha-256": "SHA-256",
@@ -118,7 +317,20 @@ export async function verifyRequest(request, options = {}) {
118
317
  }
119
318
  }
120
319
  try {
121
- const key = await verifyRequestInternal(request, span, options);
320
+ // Choose implementation based on spec option
321
+ let spec = options.spec;
322
+ if (spec == null) {
323
+ spec = request.headers.has("Signature-Input")
324
+ ? "rfc9421"
325
+ : "draft-cavage-http-signatures-12";
326
+ }
327
+ let key;
328
+ if (spec === "rfc9421") {
329
+ key = await verifyRequestRfc9421(request, span, options);
330
+ }
331
+ else {
332
+ key = await verifyRequestDraft(request, span, options);
333
+ }
122
334
  if (key == null)
123
335
  span.setStatus({ code: SpanStatusCode.ERROR });
124
336
  return key;
@@ -135,7 +347,7 @@ export async function verifyRequest(request, options = {}) {
135
347
  }
136
348
  });
137
349
  }
138
- async function verifyRequestInternal(request, span, { documentLoader, contextLoader, timeWindow, currentTime, keyCache, tracerProvider, } = {}) {
350
+ async function verifyRequestDraft(request, span, { documentLoader, contextLoader, timeWindow, currentTime, keyCache, tracerProvider, } = {}) {
139
351
  const logger = getLogger(["fedify", "sig", "http"]);
140
352
  if (request.bodyUsed) {
141
353
  logger.error("Failed to verify; the request body is already consumed.", { url: request.url });
@@ -187,7 +399,7 @@ async function verifyRequestInternal(request, span, { documentLoader, contextLoa
187
399
  span.setAttribute(`http_signatures.digest.${algo}`, encodeHex(digest));
188
400
  }
189
401
  const expectedDigest = await dntShim.crypto.subtle.digest(supportedHashAlgorithms[algo], body);
190
- if (!equals(digest, new Uint8Array(expectedDigest))) {
402
+ if (!timingSafeEqual(digest, new Uint8Array(expectedDigest))) {
191
403
  logger.debug("Failed to verify; digest mismatch ({algorithm}): " +
192
404
  "{digest} != {expectedDigest}.", {
193
405
  algorithm: algo,
@@ -290,3 +502,310 @@ async function verifyRequestInternal(request, span, { documentLoader, contextLoa
290
502
  }
291
503
  return key;
292
504
  }
505
+ /**
506
+ * RFC 9421 map of algorithm identifiers to WebCrypto algorithms
507
+ */
508
+ const rfc9421AlgorithmMap = {
509
+ "rsa-v1_5-sha256": { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
510
+ "rsa-v1_5-sha512": { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" },
511
+ "rsa-pss-sha512": { name: "RSA-PSS", hash: "SHA-512" },
512
+ "ecdsa-p256-sha256": { name: "ECDSA", hash: "SHA-256" },
513
+ "ecdsa-p384-sha384": { name: "ECDSA", hash: "SHA-384" },
514
+ "ed25519": { name: "Ed25519" },
515
+ };
516
+ /**
517
+ * Verifies a Content-Digest header according to RFC 9421.
518
+ * @param digestHeader The Content-Digest header value.
519
+ * @param body The message body to verify against.
520
+ * @returns Whether the digest is valid.
521
+ */
522
+ async function verifyRfc9421ContentDigest(digestHeader, body) {
523
+ const digests = digestHeader.split(",").map((pair) => {
524
+ pair = pair.trim();
525
+ const pos = pair.indexOf("=");
526
+ const algo = pos < 0 ? pair : pair.slice(0, pos);
527
+ const value = pos < 0 ? "" : pair.slice(pos + 1);
528
+ return { algo: algo.trim().toLowerCase(), value: value.trim() };
529
+ });
530
+ for (const { algo, value } of digests) {
531
+ // Extract hash algorithm
532
+ let hashAlgo;
533
+ if (algo === "sha-256") {
534
+ hashAlgo = "SHA-256";
535
+ }
536
+ else if (algo === "sha-512") {
537
+ hashAlgo = "SHA-512";
538
+ }
539
+ else {
540
+ // Unsupported algorithm
541
+ continue;
542
+ }
543
+ // Process RFC 9421 format: sha-256=:base64value:
544
+ const base64Match = value.match(/^:([^:]+):$/);
545
+ if (!base64Match)
546
+ continue;
547
+ let digest;
548
+ try {
549
+ digest = decodeBase64(base64Match[1]);
550
+ }
551
+ catch {
552
+ // Invalid base64 encoding
553
+ continue;
554
+ }
555
+ // Calculate and compare digests
556
+ const calculatedDigest = await dntShim.crypto.subtle.digest(hashAlgo, body);
557
+ if (timingSafeEqual(digest, new Uint8Array(calculatedDigest))) {
558
+ return true;
559
+ }
560
+ }
561
+ return false;
562
+ }
563
+ async function verifyRequestRfc9421(request, span, { documentLoader, contextLoader, timeWindow, currentTime, keyCache, tracerProvider, } = {}) {
564
+ const logger = getLogger(["fedify", "sig", "http"]);
565
+ if (request.bodyUsed) {
566
+ logger.error("Failed to verify; the request body is already consumed.", { url: request.url });
567
+ return null;
568
+ }
569
+ else if (request.body?.locked) {
570
+ logger.error("Failed to verify; the request body is locked.", { url: request.url });
571
+ return null;
572
+ }
573
+ const originalRequest = request;
574
+ request = request.clone();
575
+ // Check for required headers
576
+ const signatureInputHeader = request.headers.get("Signature-Input");
577
+ if (!signatureInputHeader) {
578
+ logger.debug("Failed to verify; no Signature-Input header found.", { headers: Object.fromEntries(request.headers.entries()) });
579
+ return null;
580
+ }
581
+ const signatureHeader = request.headers.get("Signature");
582
+ if (!signatureHeader) {
583
+ logger.debug("Failed to verify; no Signature header found.", { headers: Object.fromEntries(request.headers.entries()) });
584
+ return null;
585
+ }
586
+ // Parse the Signature-Input and Signature headers
587
+ const signatureInputs = parseRfc9421SignatureInput(signatureInputHeader);
588
+ logger.debug("Parsed Signature-Input header: {signatureInputs}", { signatureInputs });
589
+ const signatures = parseRfc9421Signature(signatureHeader);
590
+ // Check if we have at least one signature to verify
591
+ const signatureNames = Object.keys(signatureInputs);
592
+ if (signatureNames.length === 0) {
593
+ logger.debug("Failed to verify; no valid signatures found in Signature-Input header.", { header: signatureInputHeader });
594
+ return null;
595
+ }
596
+ // Verify the first signature we can find
597
+ // In practice, we could implement signature selection logic here
598
+ let validKey = null;
599
+ for (const sigName of signatureNames) {
600
+ // Skip if we don't have the signature bytes
601
+ if (!signatures[sigName]) {
602
+ continue;
603
+ }
604
+ const sigInput = signatureInputs[sigName];
605
+ const sigBytes = signatures[sigName];
606
+ // Validate signature input parameters
607
+ if (!sigInput.keyId) {
608
+ logger.debug("Failed to verify; missing keyId in signature {signatureName}.", { signatureName: sigName, signatureInput: signatureInputHeader });
609
+ continue;
610
+ }
611
+ if (!sigInput.created) {
612
+ logger.debug("Failed to verify; missing created timestamp in signature {signatureName}.", { signatureName: sigName, signatureInput: signatureInputHeader });
613
+ continue;
614
+ }
615
+ // Check timestamp validity
616
+ const signatureCreated = dntShim.Temporal.Instant.fromEpochMilliseconds(sigInput.created * 1000);
617
+ const now = currentTime ?? dntShim.Temporal.Now.instant();
618
+ if (timeWindow !== false) {
619
+ const tw = timeWindow ??
620
+ { hours: 1 };
621
+ if (dntShim.Temporal.Instant.compare(signatureCreated, now.add(tw)) > 0) {
622
+ logger.debug("Failed to verify; signature created time is too far in the future.", { created: signatureCreated.toString(), now: now.toString() });
623
+ continue;
624
+ }
625
+ else if (dntShim.Temporal.Instant.compare(signatureCreated, now.subtract(tw)) < 0) {
626
+ logger.debug("Failed to verify; signature created time is too far in the past.", { created: signatureCreated.toString(), now: now.toString() });
627
+ continue;
628
+ }
629
+ }
630
+ // Verify Content-Digest if present and required
631
+ if (request.method !== "GET" &&
632
+ request.method !== "HEAD" &&
633
+ sigInput.components.includes("content-digest")) {
634
+ const contentDigestHeader = request.headers.get("Content-Digest");
635
+ if (!contentDigestHeader) {
636
+ logger.debug("Failed to verify; Content-Digest header required but not found.", { components: sigInput.components });
637
+ continue;
638
+ }
639
+ const body = await request.arrayBuffer();
640
+ const digestValid = await verifyRfc9421ContentDigest(contentDigestHeader, body);
641
+ if (!digestValid) {
642
+ logger.debug("Failed to verify; Content-Digest verification failed.", { contentDigest: contentDigestHeader });
643
+ continue;
644
+ }
645
+ }
646
+ // Fetch the public key
647
+ span?.setAttribute("http_signatures.key_id", sigInput.keyId);
648
+ span?.setAttribute("http_signatures.created", sigInput.created.toString());
649
+ const { key, cached } = await fetchKey(new URL(sigInput.keyId), CryptographicKey, {
650
+ documentLoader,
651
+ contextLoader,
652
+ keyCache,
653
+ tracerProvider,
654
+ });
655
+ if (!key) {
656
+ logger.debug("Failed to fetch key: {keyId}", { keyId: sigInput.keyId });
657
+ continue;
658
+ }
659
+ // Map algorithm name to WebCrypto algorithm
660
+ let alg = sigInput.alg?.toLowerCase();
661
+ if (alg == null) {
662
+ if (key.publicKey.algorithm.name === "RSASSA-PKCS1-v1_5") {
663
+ alg = "hash" in key.publicKey.algorithm
664
+ ? (key.publicKey.algorithm.hash === "SHA-512"
665
+ ? "rsa-v1_5-sha512"
666
+ : "rsa-v1_5-sha256")
667
+ : "rsa-v1_5-sha256";
668
+ }
669
+ else if (key.publicKey.algorithm.name === "RSA-PSS") {
670
+ alg = "rsa-pss-sha512";
671
+ }
672
+ else if (key.publicKey.algorithm.name === "ECDSA") {
673
+ alg = "namedCurve" in key.publicKey.algorithm &&
674
+ key.publicKey.algorithm.namedCurve === "P-256"
675
+ ? "ecdsa-p256-sha256"
676
+ : "ecdsa-p384-sha384";
677
+ }
678
+ else if (key.publicKey.algorithm.name === "Ed25519") {
679
+ alg = "ed25519";
680
+ }
681
+ }
682
+ if (alg)
683
+ span?.setAttribute("http_signatures.algorithm", alg);
684
+ const algorithm = alg && rfc9421AlgorithmMap[alg];
685
+ if (!algorithm) {
686
+ logger.debug("Failed to verify; unsupported algorithm: {algorithm}", {
687
+ algorithm: sigInput.alg,
688
+ supported: Object.keys(rfc9421AlgorithmMap),
689
+ });
690
+ continue;
691
+ }
692
+ // Rebuild the signature base for verification
693
+ const signatureBase = createRfc9421SignatureBase(request, sigInput.components, sigInput.parameters);
694
+ const signatureBaseBytes = new TextEncoder().encode(signatureBase);
695
+ // Verify the signature
696
+ span?.setAttribute("http_signatures.signature", encodeHex(sigBytes));
697
+ try {
698
+ const verified = await dntShim.crypto.subtle.verify(algorithm, key.publicKey, sigBytes, signatureBaseBytes);
699
+ if (verified) {
700
+ validKey = key;
701
+ break;
702
+ }
703
+ else if (cached) {
704
+ // If we used a cached key and verification failed, try fetching fresh key
705
+ logger.debug("Failed to verify with cached key {keyId}; retrying with fresh key...", { keyId: sigInput.keyId });
706
+ return await verifyRequest(originalRequest, {
707
+ documentLoader,
708
+ contextLoader,
709
+ timeWindow,
710
+ currentTime,
711
+ keyCache: {
712
+ get: () => Promise.resolve(undefined),
713
+ set: async (keyId, key) => await keyCache?.set(keyId, key),
714
+ },
715
+ spec: "rfc9421",
716
+ });
717
+ }
718
+ else {
719
+ logger.debug("Failed to verify signature with fetched key {keyId}; signature invalid.", { keyId: sigInput.keyId, signatureBase });
720
+ }
721
+ }
722
+ catch (error) {
723
+ logger.debug("Error during signature verification: {error}", { error, keyId: sigInput.keyId, algorithm: sigInput.alg });
724
+ }
725
+ }
726
+ return validKey;
727
+ }
728
+ /**
729
+ * Performs a double-knock request to the given URL. For the details of
730
+ * double-knocking, see
731
+ * <https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions>.
732
+ * @param request The request to send.
733
+ * @param identity The identity to use for signing the request.
734
+ * @param options The options for double-knock requests.
735
+ * @returns The response to the request.
736
+ * @since 1.6.0
737
+ */
738
+ export async function doubleKnock(request, identity, options = {}) {
739
+ const { specDeterminer, log, tracerProvider } = options;
740
+ const origin = new URL(request.url).origin;
741
+ const firstTrySpec = specDeterminer == null
742
+ ? "rfc9421"
743
+ : await specDeterminer.determineSpec(origin);
744
+ let signedRequest = await signRequest(request, identity.privateKey, identity.keyId, { spec: firstTrySpec, tracerProvider });
745
+ log?.(signedRequest);
746
+ let response = await fetch(signedRequest, {
747
+ // Since Bun has a bug that ignores the `Request.redirect` option,
748
+ // to work around it we specify `redirect: "manual"` here too:
749
+ // https://github.com/oven-sh/bun/issues/10754
750
+ redirect: "manual",
751
+ });
752
+ // Follow redirects manually to get the final URL:
753
+ if (response.status >= 300 && response.status < 400 &&
754
+ response.headers.has("Location")) {
755
+ const location = response.headers.get("Location");
756
+ const body = request.method !== "GET" && request.method !== "HEAD"
757
+ ? await request.clone().arrayBuffer()
758
+ : undefined;
759
+ return doubleKnock(new Request(location, {
760
+ method: request.method,
761
+ headers: request.headers,
762
+ body,
763
+ redirect: "manual",
764
+ signal: request.signal,
765
+ mode: request.mode,
766
+ credentials: request.credentials,
767
+ referrer: request.referrer,
768
+ referrerPolicy: request.referrerPolicy,
769
+ integrity: request.integrity,
770
+ keepalive: request.keepalive,
771
+ }), identity, options);
772
+ }
773
+ else if (response.status === 400 || response.status === 401) {
774
+ // verification failed; retry with the other spec of HTTP Signatures
775
+ // (double-knocking; see https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions)
776
+ const spec = firstTrySpec === "draft-cavage-http-signatures-12"
777
+ ? "rfc9421"
778
+ : "draft-cavage-http-signatures-12";
779
+ getLogger(["fedify", "sig", "http"]).debug("Failed to verify with the spec {spec} ({status} {statusText}); retrying with spec {secondSpec}... (double-knocking)", {
780
+ spec: firstTrySpec,
781
+ secondSpec: spec,
782
+ status: response.status,
783
+ statusText: response.statusText,
784
+ });
785
+ signedRequest = await signRequest(request, identity.privateKey, identity.keyId, { spec, tracerProvider });
786
+ log?.(signedRequest);
787
+ response = await fetch(signedRequest, {
788
+ // Since Bun has a bug that ignores the `Request.redirect` option,
789
+ // to work around it we specify `redirect: "manual"` here too:
790
+ // https://github.com/oven-sh/bun/issues/10754
791
+ redirect: "manual",
792
+ });
793
+ // Follow redirects manually to get the final URL:
794
+ if (response.status >= 300 && response.status < 400 &&
795
+ response.headers.has("Location")) {
796
+ const location = response.headers.get("Location");
797
+ const body = request.method !== "GET" && request.method !== "HEAD"
798
+ ? request.clone().body
799
+ : null;
800
+ return doubleKnock(new Request(location, { ...request, body }), identity, options);
801
+ }
802
+ else if (response.status !== 400 && response.status !== 401) {
803
+ await specDeterminer?.rememberSpec(origin, spec);
804
+ }
805
+ }
806
+ else {
807
+ await specDeterminer?.rememberSpec(origin, firstTrySpec);
808
+ }
809
+ return response;
810
+ }
811
+ // cSpell: ignore keyid
package/esm/sig/key.js CHANGED
@@ -70,7 +70,10 @@ export function generateCryptoKeyPair(algorithm) {
70
70
  */
71
71
  export async function exportJwk(key) {
72
72
  validateCryptoKey(key);
73
- return await dntShim.crypto.subtle.exportKey("jwk", key);
73
+ const jwk = await dntShim.crypto.subtle.exportKey("jwk", key);
74
+ if (jwk.crv === "Ed25519")
75
+ jwk.alg = "Ed25519";
76
+ return jwk;
74
77
  }
75
78
  /**
76
79
  * Imports a key from JWK format.
@@ -0,0 +1,20 @@
1
+ {
2
+ "@context": [
3
+ "https://www.w3.org/ns/activitystreams",
4
+ "https://w3id.org/security/v1",
5
+ "https://w3id.org/security/multikey/v1",
6
+ "https://w3id.org/security/data-integrity/v1",
7
+ "https://www.w3.org/ns/did/v1"
8
+ ],
9
+ "id": "https://remote.domain/users/bob",
10
+ "type": "Person",
11
+ "name": "Bob",
12
+ "publicKey": [
13
+ {
14
+ "id": "https://remote.domain/users/bob#main-key",
15
+ "type": "CryptographicKey",
16
+ "owner": "https://remote.domain/users/bob",
17
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqIAYvNFGbZ5g4iiK6feS\ndXD4bDStFM58A7tHycYXaYtzZQpIeHXAmaXuZzXIwtrP4N0gIk8JNwZvXj2UPS+S\n07t0V9wNK94he01LV5EMz/GN4eNnFmDL64HIEuKLvV8TvgjbUPRD6Y5X0UpKi2ZI\nFLSb96Q5w0Z/k7ntpVKV52y8kz5Fjr/O/0JuHryZe0yItzJh8kzFfeMf0EXzfSna\nKvT7P9jhgC6uTre+jXyvVZjiHDrnqvvucdI3I7DRfXo1OqARBrLjy+TdseUAjNYJ\n+OuPRI1URIWQI01DCHqcohVu9+Ar+BiCjFp3ua+XMuJvrvbD61d1Fvig/9nbBRR+\n8QIDAQAB\n-----END PUBLIC KEY-----\n"
18
+ }
19
+ ]
20
+ }