@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.
- package/CHANGES.md +17 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +1 -1
- package/SPONSORS.md +1 -1
- package/esm/deno.js +3 -1
- package/esm/deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.js +55 -0
- package/esm/federation/middleware.js +26 -1
- package/esm/federation/send.js +6 -7
- package/esm/runtime/docloader.js +4 -16
- package/esm/sig/http.js +528 -9
- package/esm/sig/key.js +4 -1
- package/esm/testing/fixtures/remote.domain/users/bob +20 -0
- package/esm/vocab/vocab.js +308 -176
- package/package.json +2 -1
- package/types/deno.d.ts +2 -0
- package/types/deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.d.ts +34 -0
- package/types/deps/jsr.io/@std/crypto/1.0.4/timing_safe_equal.d.ts.map +1 -0
- package/types/federation/middleware.d.ts +21 -3
- package/types/federation/middleware.d.ts.map +1 -1
- package/types/federation/send.d.ts +6 -0
- package/types/federation/send.d.ts.map +1 -1
- package/types/runtime/docloader.d.ts +17 -1
- package/types/runtime/docloader.d.ts.map +1 -1
- package/types/sig/http.d.ts +128 -0
- package/types/sig/http.d.ts.map +1 -1
- package/types/sig/key.d.ts.map +1 -1
- package/types/sig/mod.d.ts +1 -1
- package/types/sig/mod.d.ts.map +1 -1
- package/types/vocab/vocab.d.ts.map +1 -1
- package/esm/deps/jsr.io/@std/bytes/1.0.5/copy.js +0 -50
- package/esm/deps/jsr.io/@std/bytes/1.0.5/ends_with.js +0 -36
- package/esm/deps/jsr.io/@std/bytes/1.0.5/equals.js +0 -82
- package/esm/deps/jsr.io/@std/bytes/1.0.5/includes_needle.js +0 -42
- package/esm/deps/jsr.io/@std/bytes/1.0.5/index_of_needle.js +0 -68
- package/esm/deps/jsr.io/@std/bytes/1.0.5/last_index_of_needle.js +0 -65
- package/esm/deps/jsr.io/@std/bytes/1.0.5/mod.js +0 -34
- package/esm/deps/jsr.io/@std/bytes/1.0.5/repeat.js +0 -43
- package/esm/deps/jsr.io/@std/bytes/1.0.5/starts_with.js +0 -34
- package/types/deps/jsr.io/@std/bytes/1.0.5/copy.d.ts +0 -41
- package/types/deps/jsr.io/@std/bytes/1.0.5/copy.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/ends_with.d.ts +0 -24
- package/types/deps/jsr.io/@std/bytes/1.0.5/ends_with.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/equals.d.ts +0 -22
- package/types/deps/jsr.io/@std/bytes/1.0.5/equals.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/includes_needle.d.ts +0 -38
- package/types/deps/jsr.io/@std/bytes/1.0.5/includes_needle.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/index_of_needle.d.ts +0 -45
- package/types/deps/jsr.io/@std/bytes/1.0.5/index_of_needle.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/last_index_of_needle.d.ts +0 -42
- package/types/deps/jsr.io/@std/bytes/1.0.5/last_index_of_needle.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/mod.d.ts +0 -33
- package/types/deps/jsr.io/@std/bytes/1.0.5/mod.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/repeat.d.ts +0 -33
- package/types/deps/jsr.io/@std/bytes/1.0.5/repeat.d.ts.map +0 -1
- package/types/deps/jsr.io/@std/bytes/1.0.5/starts_with.d.ts +0 -24
- 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 {
|
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
|
-
|
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
|
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",
|
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
|
-
|
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
|
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 (!
|
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
|
-
|
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
|
+
}
|