@daloyjs/core 0.36.0 → 0.38.0
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/LICENSE +21 -0
- package/README.md +34 -3
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +57 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +39 -5
- package/dist/index.js +19 -2
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +61 -7
- package/dist/security.js +75 -8
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +79 -3
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Message Signatures (RFC 9421).
|
|
3
|
+
*
|
|
4
|
+
* First-party, dependency-free sign + verify for server-to-server request
|
|
5
|
+
* authentication. Where {@link "./security.js" | verifyWebhookSignature} binds
|
|
6
|
+
* an HMAC to a request *body* and {@link "./mtls.js" | clientCertAuth}
|
|
7
|
+
* authenticates the TLS *peer*, HTTP Message Signatures bind a signature to a
|
|
8
|
+
* caller-chosen set of **HTTP message components** (method, path, authority,
|
|
9
|
+
* selected headers, …) carried in the standard `Signature` /
|
|
10
|
+
* `Signature-Input` headers — the IETF-standard answer to "prove this internal
|
|
11
|
+
* call came from a trusted peer."
|
|
12
|
+
*
|
|
13
|
+
* The implementation is runtime-portable (WebCrypto only — no `node:` imports)
|
|
14
|
+
* and secure-by-default on the verify path:
|
|
15
|
+
*
|
|
16
|
+
* - The verifier requires an explicit {@link VerifyMessageOptions.algorithms}
|
|
17
|
+
* allowlist; there is no implicit "accept any algorithm" mode, and a
|
|
18
|
+
* resolved key may pin its own algorithm to defeat algorithm-confusion.
|
|
19
|
+
* - `created` is required by default and the signature is rejected once it is
|
|
20
|
+
* older than {@link DEFAULT_MAX_SIGNATURE_AGE_SECONDS}, or if `created` is in
|
|
21
|
+
* the future / `expires` has passed (outside a small clock-skew tolerance).
|
|
22
|
+
* - A configurable {@link VerifyMessageOptions.requiredComponents} set must be
|
|
23
|
+
* covered, so a peer cannot sign an empty/irrelevant component set.
|
|
24
|
+
* - Raw HMAC keys must be at least 32 bytes (RFC 7518 §3.2 floor); SHA-1 and
|
|
25
|
+
* `alg: "none"`-style escapes do not exist.
|
|
26
|
+
*
|
|
27
|
+
* Supported algorithms map 1:1 onto the RFC 9421 HTTP Signature Algorithms
|
|
28
|
+
* registry: `hmac-sha256`, `ed25519`, `ecdsa-p256-sha256`, `ecdsa-p384-sha384`,
|
|
29
|
+
* `rsa-pss-sha512`, and `rsa-v1_5-sha256`.
|
|
30
|
+
*
|
|
31
|
+
* @module
|
|
32
|
+
* @since 0.37.0
|
|
33
|
+
*/
|
|
34
|
+
import { UnauthorizedError } from "./errors.js";
|
|
35
|
+
/** Default signature label used when the caller does not supply one. */
|
|
36
|
+
export const DEFAULT_SIGNATURE_LABEL = "sig1";
|
|
37
|
+
/**
|
|
38
|
+
* Default maximum age (seconds) a signature's `created` timestamp may have
|
|
39
|
+
* before the verifier rejects it as stale. Mirrors the webhook-HMAC and
|
|
40
|
+
* Standard-Webhooks five-minute convention.
|
|
41
|
+
*
|
|
42
|
+
* @since 0.37.0
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_MAX_SIGNATURE_AGE_SECONDS = 300;
|
|
45
|
+
/** Default clock-skew tolerance (seconds) for future `created` / `expires`. */
|
|
46
|
+
export const DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS = 60;
|
|
47
|
+
/** Hard cap on the length of a parsed `Signature` / `Signature-Input` header. */
|
|
48
|
+
const MAX_HEADER_LENGTH = 8192;
|
|
49
|
+
/** Minimum byte length for a raw HMAC secret (RFC 7518 §3.2). */
|
|
50
|
+
const MIN_HMAC_KEY_BYTES = 32;
|
|
51
|
+
const ENC = new TextEncoder();
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// WebCrypto + encoding helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
function getCrypto() {
|
|
56
|
+
const c = globalThis
|
|
57
|
+
.crypto;
|
|
58
|
+
if (!c?.subtle) {
|
|
59
|
+
throw new Error("http-signatures: WebCrypto SubtleCrypto API is unavailable on this runtime.");
|
|
60
|
+
}
|
|
61
|
+
return c;
|
|
62
|
+
}
|
|
63
|
+
function bytesToBase64(bytes) {
|
|
64
|
+
let bin = "";
|
|
65
|
+
for (let i = 0; i < bytes.length; i++)
|
|
66
|
+
bin += String.fromCharCode(bytes[i]);
|
|
67
|
+
return btoa(bin);
|
|
68
|
+
}
|
|
69
|
+
function base64ToBytes(b64) {
|
|
70
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(b64) || b64.length % 4 !== 0)
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
const bin = atob(b64);
|
|
74
|
+
const out = new Uint8Array(bin.length);
|
|
75
|
+
for (let i = 0; i < bin.length; i++)
|
|
76
|
+
out[i] = bin.charCodeAt(i);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isJsonWebKey(v) {
|
|
84
|
+
return (typeof v === "object" &&
|
|
85
|
+
v !== null &&
|
|
86
|
+
!Array.isArray(v) &&
|
|
87
|
+
typeof v.kty === "string");
|
|
88
|
+
}
|
|
89
|
+
function isCryptoKey(v) {
|
|
90
|
+
return (typeof v === "object" &&
|
|
91
|
+
v !== null &&
|
|
92
|
+
typeof v.type === "string" &&
|
|
93
|
+
typeof v.algorithm === "object");
|
|
94
|
+
}
|
|
95
|
+
function algSpec(alg) {
|
|
96
|
+
switch (alg) {
|
|
97
|
+
case "hmac-sha256":
|
|
98
|
+
return {
|
|
99
|
+
importParams: { name: "HMAC", hash: "SHA-256" },
|
|
100
|
+
signParams: { name: "HMAC" },
|
|
101
|
+
symmetric: true,
|
|
102
|
+
};
|
|
103
|
+
case "ed25519":
|
|
104
|
+
return {
|
|
105
|
+
importParams: { name: "Ed25519" },
|
|
106
|
+
signParams: { name: "Ed25519" },
|
|
107
|
+
symmetric: false,
|
|
108
|
+
};
|
|
109
|
+
case "ecdsa-p256-sha256":
|
|
110
|
+
return {
|
|
111
|
+
importParams: { name: "ECDSA", namedCurve: "P-256" },
|
|
112
|
+
signParams: { name: "ECDSA", hash: "SHA-256" },
|
|
113
|
+
symmetric: false,
|
|
114
|
+
};
|
|
115
|
+
case "ecdsa-p384-sha384":
|
|
116
|
+
return {
|
|
117
|
+
importParams: { name: "ECDSA", namedCurve: "P-384" },
|
|
118
|
+
signParams: { name: "ECDSA", hash: "SHA-384" },
|
|
119
|
+
symmetric: false,
|
|
120
|
+
};
|
|
121
|
+
case "rsa-pss-sha512":
|
|
122
|
+
return {
|
|
123
|
+
importParams: { name: "RSA-PSS", hash: "SHA-512" },
|
|
124
|
+
signParams: { name: "RSA-PSS", saltLength: 64 },
|
|
125
|
+
symmetric: false,
|
|
126
|
+
};
|
|
127
|
+
case "rsa-v1_5-sha256":
|
|
128
|
+
return {
|
|
129
|
+
importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
130
|
+
signParams: { name: "RSASSA-PKCS1-v1_5" },
|
|
131
|
+
symmetric: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function importKey(alg, material, usage) {
|
|
136
|
+
const spec = algSpec(alg);
|
|
137
|
+
const c = getCrypto();
|
|
138
|
+
if (isCryptoKey(material))
|
|
139
|
+
return material;
|
|
140
|
+
if (material instanceof Uint8Array) {
|
|
141
|
+
if (!spec.symmetric) {
|
|
142
|
+
throw new TypeError(`http-signatures: raw byte keys are only supported for hmac-sha256; got ${alg}.`);
|
|
143
|
+
}
|
|
144
|
+
if (material.byteLength < MIN_HMAC_KEY_BYTES) {
|
|
145
|
+
throw new TypeError(`http-signatures: hmac-sha256 secret must be at least ${MIN_HMAC_KEY_BYTES} bytes (RFC 7518 §3.2); got ${material.byteLength}.`);
|
|
146
|
+
}
|
|
147
|
+
return c.subtle.importKey("raw", material, spec.importParams, false, [usage]);
|
|
148
|
+
}
|
|
149
|
+
if (isJsonWebKey(material)) {
|
|
150
|
+
return c.subtle.importKey("jwk", material, spec.importParams, false, [
|
|
151
|
+
usage,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
throw new TypeError("http-signatures: unsupported key material.");
|
|
155
|
+
}
|
|
156
|
+
function serializeSfString(s) {
|
|
157
|
+
for (let i = 0; i < s.length; i++) {
|
|
158
|
+
const code = s.charCodeAt(i);
|
|
159
|
+
if (code < 0x20 || code > 0x7e) {
|
|
160
|
+
throw new TypeError(`http-signatures: value contains a non-printable-ASCII character and cannot be serialized as a structured-field string: ${JSON.stringify(s)}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return `"${s.replace(/([\\"])/g, "\\$1")}"`;
|
|
164
|
+
}
|
|
165
|
+
function serializeComponentId(c) {
|
|
166
|
+
let out = serializeSfString(c.name);
|
|
167
|
+
if (c.paramName !== undefined)
|
|
168
|
+
out += `;name=${serializeSfString(c.paramName)}`;
|
|
169
|
+
if (c.req)
|
|
170
|
+
out += ";req";
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function serializeSignatureInput(components, params) {
|
|
174
|
+
let out = `(${components.map(serializeComponentId).join(" ")})`;
|
|
175
|
+
if (params.created !== undefined)
|
|
176
|
+
out += `;created=${params.created}`;
|
|
177
|
+
if (params.expires !== undefined)
|
|
178
|
+
out += `;expires=${params.expires}`;
|
|
179
|
+
if (params.keyid !== undefined)
|
|
180
|
+
out += `;keyid=${serializeSfString(params.keyid)}`;
|
|
181
|
+
if (params.alg !== undefined)
|
|
182
|
+
out += `;alg=${serializeSfString(params.alg)}`;
|
|
183
|
+
if (params.nonce !== undefined)
|
|
184
|
+
out += `;nonce=${serializeSfString(params.nonce)}`;
|
|
185
|
+
if (params.tag !== undefined)
|
|
186
|
+
out += `;tag=${serializeSfString(params.tag)}`;
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Structured-field parsing (RFC 8941 subset)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
class SfParseError extends Error {
|
|
193
|
+
}
|
|
194
|
+
function readSfString(src, start) {
|
|
195
|
+
if (src[start] !== '"')
|
|
196
|
+
throw new SfParseError("expected string");
|
|
197
|
+
let i = start + 1;
|
|
198
|
+
let out = "";
|
|
199
|
+
while (i < src.length) {
|
|
200
|
+
const ch = src[i];
|
|
201
|
+
if (ch === "\\") {
|
|
202
|
+
const next = src[i + 1];
|
|
203
|
+
if (next !== '"' && next !== "\\")
|
|
204
|
+
throw new SfParseError("bad escape");
|
|
205
|
+
out += next;
|
|
206
|
+
i += 2;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (ch === '"')
|
|
210
|
+
return [out, i + 1];
|
|
211
|
+
const code = ch.charCodeAt(0);
|
|
212
|
+
if (code < 0x20 || code > 0x7e)
|
|
213
|
+
throw new SfParseError("bad string char");
|
|
214
|
+
out += ch;
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
throw new SfParseError("unterminated string");
|
|
218
|
+
}
|
|
219
|
+
function readKey(src, start) {
|
|
220
|
+
let i = start;
|
|
221
|
+
if (!/[a-z*]/.test(src[i] ?? ""))
|
|
222
|
+
throw new SfParseError("bad key start");
|
|
223
|
+
let out = "";
|
|
224
|
+
while (i < src.length && /[a-z0-9_\-.*]/.test(src[i])) {
|
|
225
|
+
out += src[i];
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
return [out, i];
|
|
229
|
+
}
|
|
230
|
+
/** Parse `;key=value` / `;key` params starting at `;`. */
|
|
231
|
+
function readParams(src, start) {
|
|
232
|
+
const params = Object.create(null);
|
|
233
|
+
let i = start;
|
|
234
|
+
while (i < src.length && src[i] === ";") {
|
|
235
|
+
i++;
|
|
236
|
+
while (src[i] === " ")
|
|
237
|
+
i++;
|
|
238
|
+
const [key, ni] = readKey(src, i);
|
|
239
|
+
i = ni;
|
|
240
|
+
if (src[i] === "=") {
|
|
241
|
+
i++;
|
|
242
|
+
if (src[i] === '"') {
|
|
243
|
+
const [val, nj] = readSfString(src, i);
|
|
244
|
+
params[key] = val;
|
|
245
|
+
i = nj;
|
|
246
|
+
}
|
|
247
|
+
else if (src[i] === "?") {
|
|
248
|
+
if (src[i + 1] === "1")
|
|
249
|
+
params[key] = true;
|
|
250
|
+
else if (src[i + 1] === "0")
|
|
251
|
+
params[key] = false;
|
|
252
|
+
else
|
|
253
|
+
throw new SfParseError("bad boolean");
|
|
254
|
+
i += 2;
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
const m = /^-?\d+/.exec(src.slice(i));
|
|
258
|
+
if (!m)
|
|
259
|
+
throw new SfParseError("bad param value");
|
|
260
|
+
params[key] = Number(m[0]);
|
|
261
|
+
i += m[0].length;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
params[key] = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return [params, i];
|
|
269
|
+
}
|
|
270
|
+
function toSignatureParams(raw) {
|
|
271
|
+
const out = {};
|
|
272
|
+
if (typeof raw.created === "number")
|
|
273
|
+
out.created = raw.created;
|
|
274
|
+
if (typeof raw.expires === "number")
|
|
275
|
+
out.expires = raw.expires;
|
|
276
|
+
if (typeof raw.keyid === "string")
|
|
277
|
+
out.keyid = raw.keyid;
|
|
278
|
+
if (typeof raw.alg === "string")
|
|
279
|
+
out.alg = raw.alg;
|
|
280
|
+
if (typeof raw.nonce === "string")
|
|
281
|
+
out.nonce = raw.nonce;
|
|
282
|
+
if (typeof raw.tag === "string")
|
|
283
|
+
out.tag = raw.tag;
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
/** Parse one `(...)<params>` inner list starting at `(`. */
|
|
287
|
+
function parseInnerList(src, start) {
|
|
288
|
+
if (src[start] !== "(")
|
|
289
|
+
throw new SfParseError("expected inner list");
|
|
290
|
+
let i = start + 1;
|
|
291
|
+
const components = [];
|
|
292
|
+
while (i < src.length && src[i] !== ")") {
|
|
293
|
+
while (src[i] === " ")
|
|
294
|
+
i++;
|
|
295
|
+
if (src[i] === ")")
|
|
296
|
+
break;
|
|
297
|
+
const [name, ni] = readSfString(src, i);
|
|
298
|
+
i = ni;
|
|
299
|
+
const [cParams, nj] = readParams(src, i);
|
|
300
|
+
i = nj;
|
|
301
|
+
const comp = { name };
|
|
302
|
+
if (typeof cParams.name === "string")
|
|
303
|
+
comp.paramName = cParams.name;
|
|
304
|
+
if (cParams.req === true)
|
|
305
|
+
comp.req = true;
|
|
306
|
+
components.push(comp);
|
|
307
|
+
while (src[i] === " ")
|
|
308
|
+
i++;
|
|
309
|
+
}
|
|
310
|
+
if (src[i] !== ")")
|
|
311
|
+
throw new SfParseError("unterminated inner list");
|
|
312
|
+
i++;
|
|
313
|
+
const [rawParams, nk] = readParams(src, i);
|
|
314
|
+
i = nk;
|
|
315
|
+
const raw = src.slice(start, i).trim();
|
|
316
|
+
return [{ components, params: toSignatureParams(rawParams), raw }, i];
|
|
317
|
+
}
|
|
318
|
+
/** Parse the `Signature-Input` dictionary into a label → inner-list map. */
|
|
319
|
+
function parseSignatureInput(headerValue) {
|
|
320
|
+
const out = new Map();
|
|
321
|
+
const src = headerValue;
|
|
322
|
+
let i = 0;
|
|
323
|
+
while (i < src.length) {
|
|
324
|
+
while (src[i] === " " || src[i] === "\t" || src[i] === ",")
|
|
325
|
+
i++;
|
|
326
|
+
if (i >= src.length)
|
|
327
|
+
break;
|
|
328
|
+
const [label, ni] = readKey(src, i);
|
|
329
|
+
i = ni;
|
|
330
|
+
if (src[i] !== "=")
|
|
331
|
+
throw new SfParseError("expected = after label");
|
|
332
|
+
i++;
|
|
333
|
+
const [inner, nj] = parseInnerList(src, i);
|
|
334
|
+
i = nj;
|
|
335
|
+
out.set(label, inner);
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
/** Parse the `Signature` dictionary into a label → raw bytes map. */
|
|
340
|
+
function parseSignature(headerValue) {
|
|
341
|
+
const out = new Map();
|
|
342
|
+
const src = headerValue;
|
|
343
|
+
let i = 0;
|
|
344
|
+
while (i < src.length) {
|
|
345
|
+
while (src[i] === " " || src[i] === "\t" || src[i] === ",")
|
|
346
|
+
i++;
|
|
347
|
+
if (i >= src.length)
|
|
348
|
+
break;
|
|
349
|
+
const [label, ni] = readKey(src, i);
|
|
350
|
+
i = ni;
|
|
351
|
+
if (src[i] !== "=")
|
|
352
|
+
throw new SfParseError("expected = after label");
|
|
353
|
+
i++;
|
|
354
|
+
if (src[i] !== ":")
|
|
355
|
+
throw new SfParseError("expected byte sequence");
|
|
356
|
+
const end = src.indexOf(":", i + 1);
|
|
357
|
+
if (end === -1)
|
|
358
|
+
throw new SfParseError("unterminated byte sequence");
|
|
359
|
+
const b64 = src.slice(i + 1, end);
|
|
360
|
+
const bytes = base64ToBytes(b64);
|
|
361
|
+
if (!bytes)
|
|
362
|
+
throw new SfParseError("bad base64");
|
|
363
|
+
out.set(label, bytes);
|
|
364
|
+
i = end + 1;
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
class ComponentError extends Error {
|
|
369
|
+
}
|
|
370
|
+
function resolveComponentValue(c, msg) {
|
|
371
|
+
const name = c.name;
|
|
372
|
+
if (name === "@signature-params") {
|
|
373
|
+
throw new ComponentError("@signature-params cannot be a covered component");
|
|
374
|
+
}
|
|
375
|
+
if (name.startsWith("@")) {
|
|
376
|
+
if (c.req) {
|
|
377
|
+
throw new ComponentError(`the ;req parameter is not supported on ${name} in this context`);
|
|
378
|
+
}
|
|
379
|
+
switch (name) {
|
|
380
|
+
case "@method":
|
|
381
|
+
return msg.method.toUpperCase();
|
|
382
|
+
case "@target-uri":
|
|
383
|
+
return msg.url.href;
|
|
384
|
+
case "@authority":
|
|
385
|
+
return msg.url.host.toLowerCase();
|
|
386
|
+
case "@scheme":
|
|
387
|
+
return msg.url.protocol.replace(/:$/, "").toLowerCase();
|
|
388
|
+
case "@request-target":
|
|
389
|
+
return `${msg.url.pathname}${msg.url.search}`;
|
|
390
|
+
case "@path":
|
|
391
|
+
return msg.url.pathname;
|
|
392
|
+
case "@query":
|
|
393
|
+
return msg.url.search === "" ? "?" : msg.url.search;
|
|
394
|
+
case "@query-param": {
|
|
395
|
+
if (c.paramName === undefined) {
|
|
396
|
+
throw new ComponentError("@query-param requires a ;name parameter");
|
|
397
|
+
}
|
|
398
|
+
const values = msg.url.searchParams.getAll(c.paramName);
|
|
399
|
+
if (values.length === 0) {
|
|
400
|
+
throw new ComponentError(`@query-param;name="${c.paramName}" is not present in the query`);
|
|
401
|
+
}
|
|
402
|
+
return values[0];
|
|
403
|
+
}
|
|
404
|
+
case "@status":
|
|
405
|
+
if (msg.status === undefined) {
|
|
406
|
+
throw new ComponentError("@status is only valid for responses");
|
|
407
|
+
}
|
|
408
|
+
return String(msg.status);
|
|
409
|
+
default:
|
|
410
|
+
throw new ComponentError(`unknown derived component ${name}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// HTTP field component.
|
|
414
|
+
if (name !== name.toLowerCase()) {
|
|
415
|
+
throw new ComponentError(`field component name must be lowercase: ${name}`);
|
|
416
|
+
}
|
|
417
|
+
if (c.req) {
|
|
418
|
+
throw new ComponentError("the ;req parameter is not supported in this context");
|
|
419
|
+
}
|
|
420
|
+
const value = msg.headers.get(name);
|
|
421
|
+
if (value === null) {
|
|
422
|
+
throw new ComponentError(`covered header "${name}" is not present`);
|
|
423
|
+
}
|
|
424
|
+
return value.trim().replace(/[ \t]*\r?\n[ \t]*/g, " ");
|
|
425
|
+
}
|
|
426
|
+
function buildSignatureBase(components, signatureParamsValue, msg) {
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
let base = "";
|
|
429
|
+
for (const c of components) {
|
|
430
|
+
const id = serializeComponentId(c);
|
|
431
|
+
if (seen.has(id)) {
|
|
432
|
+
throw new ComponentError(`duplicate covered component ${id}`);
|
|
433
|
+
}
|
|
434
|
+
seen.add(id);
|
|
435
|
+
base += `${id}: ${resolveComponentValue(c, msg)}\n`;
|
|
436
|
+
}
|
|
437
|
+
base += `"@signature-params": ${signatureParamsValue}`;
|
|
438
|
+
return base;
|
|
439
|
+
}
|
|
440
|
+
function toHeaders(init) {
|
|
441
|
+
return init instanceof Headers ? init : new Headers(init ?? {});
|
|
442
|
+
}
|
|
443
|
+
function parseComponentSpec(spec) {
|
|
444
|
+
// Accept the canonical serialized form ("@query-param";name="x") or the
|
|
445
|
+
// bare convenience form `@query-param;name=x`.
|
|
446
|
+
if (spec.startsWith('"')) {
|
|
447
|
+
const [name, idx] = readSfString(spec, 0);
|
|
448
|
+
const [params] = readParams(spec, idx);
|
|
449
|
+
const comp = { name };
|
|
450
|
+
if (typeof params.name === "string")
|
|
451
|
+
comp.paramName = params.name;
|
|
452
|
+
if (params.req === true)
|
|
453
|
+
comp.req = true;
|
|
454
|
+
return comp;
|
|
455
|
+
}
|
|
456
|
+
const semi = spec.indexOf(";");
|
|
457
|
+
if (semi === -1)
|
|
458
|
+
return { name: spec };
|
|
459
|
+
const name = spec.slice(0, semi);
|
|
460
|
+
const comp = { name };
|
|
461
|
+
for (const part of spec.slice(semi + 1).split(";")) {
|
|
462
|
+
if (part === "req")
|
|
463
|
+
comp.req = true;
|
|
464
|
+
else if (part.startsWith("name=")) {
|
|
465
|
+
comp.paramName = part.slice(5).replace(/^"|"$/g, "");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return comp;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Compute HTTP Message Signature header values (RFC 9421) over the described
|
|
472
|
+
* message.
|
|
473
|
+
*
|
|
474
|
+
* @throws {TypeError} for unsupported algorithms, weak HMAC keys, or
|
|
475
|
+
* unserializable parameter values.
|
|
476
|
+
* @throws {Error} when a covered component cannot be resolved (e.g. a covered
|
|
477
|
+
* header is missing) or WebCrypto is unavailable.
|
|
478
|
+
* @since 0.37.0
|
|
479
|
+
*/
|
|
480
|
+
export async function signMessage(opts) {
|
|
481
|
+
const label = opts.label ?? DEFAULT_SIGNATURE_LABEL;
|
|
482
|
+
const url = opts.url instanceof URL ? opts.url : new URL(opts.url);
|
|
483
|
+
const components = (opts.components ?? ["@method", "@target-uri"]).map(parseComponentSpec);
|
|
484
|
+
const nowSeconds = Math.floor((opts.now ?? Date.now)() / 1000);
|
|
485
|
+
const params = {
|
|
486
|
+
created: opts.created ?? nowSeconds,
|
|
487
|
+
...(opts.expires !== undefined ? { expires: opts.expires } : {}),
|
|
488
|
+
...(opts.keyid !== undefined ? { keyid: opts.keyid } : {}),
|
|
489
|
+
...(opts.includeAlg === false ? {} : { alg: opts.alg }),
|
|
490
|
+
...(opts.nonce !== undefined ? { nonce: opts.nonce } : {}),
|
|
491
|
+
...(opts.tag !== undefined ? { tag: opts.tag } : {}),
|
|
492
|
+
};
|
|
493
|
+
const signatureParamsValue = serializeSignatureInput(components, params);
|
|
494
|
+
const msg = {
|
|
495
|
+
method: opts.method,
|
|
496
|
+
url,
|
|
497
|
+
headers: toHeaders(opts.headers),
|
|
498
|
+
...(opts.status !== undefined ? { status: opts.status } : {}),
|
|
499
|
+
};
|
|
500
|
+
const base = buildSignatureBase(components, signatureParamsValue, msg);
|
|
501
|
+
const key = await importKey(opts.alg, opts.key, "sign");
|
|
502
|
+
const spec = algSpec(opts.alg);
|
|
503
|
+
const sig = new Uint8Array(await getCrypto().subtle.sign(spec.signParams, key, ENC.encode(base)));
|
|
504
|
+
return {
|
|
505
|
+
signatureInput: `${label}=${signatureParamsValue}`,
|
|
506
|
+
signature: `${label}=:${bytesToBase64(sig)}:`,
|
|
507
|
+
signatureBase: base,
|
|
508
|
+
label,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Sign an outbound {@link Request} and return a new `Request` with the
|
|
513
|
+
* `Signature` and `Signature-Input` headers attached. The original request is
|
|
514
|
+
* not mutated.
|
|
515
|
+
*
|
|
516
|
+
* @since 0.37.0
|
|
517
|
+
*/
|
|
518
|
+
export async function signRequest(request, opts) {
|
|
519
|
+
const sig = await signMessage({
|
|
520
|
+
...opts,
|
|
521
|
+
method: request.method,
|
|
522
|
+
url: request.url,
|
|
523
|
+
headers: request.headers,
|
|
524
|
+
});
|
|
525
|
+
const headers = new Headers(request.headers);
|
|
526
|
+
headers.set("signature-input", sig.signatureInput);
|
|
527
|
+
headers.set("signature", sig.signature);
|
|
528
|
+
return new Request(request, { headers });
|
|
529
|
+
}
|
|
530
|
+
function fail(reason) {
|
|
531
|
+
return { valid: false, reason };
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Verify an HTTP Message Signature (RFC 9421) on a received message. Returns a
|
|
535
|
+
* structured result and never throws on a bad/forged signature — only on a
|
|
536
|
+
* programming error (e.g. WebCrypto unavailable).
|
|
537
|
+
*
|
|
538
|
+
* @since 0.37.0
|
|
539
|
+
*/
|
|
540
|
+
export async function verifyMessage(opts) {
|
|
541
|
+
if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
|
|
542
|
+
throw new TypeError("verifyMessage(): an explicit, non-empty `algorithms` allowlist is required.");
|
|
543
|
+
}
|
|
544
|
+
const headers = toHeaders(opts.headers);
|
|
545
|
+
const sigInputRaw = headers.get("signature-input");
|
|
546
|
+
const sigRaw = headers.get("signature");
|
|
547
|
+
if (!sigInputRaw)
|
|
548
|
+
return fail("missing_signature_input");
|
|
549
|
+
if (!sigRaw)
|
|
550
|
+
return fail("missing_signature");
|
|
551
|
+
if (sigInputRaw.length > MAX_HEADER_LENGTH || sigRaw.length > MAX_HEADER_LENGTH) {
|
|
552
|
+
return fail("header_too_large");
|
|
553
|
+
}
|
|
554
|
+
let inputs;
|
|
555
|
+
let signatures;
|
|
556
|
+
try {
|
|
557
|
+
inputs = parseSignatureInput(sigInputRaw);
|
|
558
|
+
signatures = parseSignature(sigRaw);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return fail("malformed_signature_headers");
|
|
562
|
+
}
|
|
563
|
+
let label = opts.label;
|
|
564
|
+
if (label === undefined) {
|
|
565
|
+
if (inputs.size !== 1)
|
|
566
|
+
return fail("ambiguous_label");
|
|
567
|
+
label = inputs.keys().next().value;
|
|
568
|
+
}
|
|
569
|
+
const input = inputs.get(label);
|
|
570
|
+
const provided = signatures.get(label);
|
|
571
|
+
if (!input || !provided)
|
|
572
|
+
return fail("label_not_found");
|
|
573
|
+
const params = input.params;
|
|
574
|
+
const declaredAlg = params.alg;
|
|
575
|
+
const info = {
|
|
576
|
+
label,
|
|
577
|
+
...(params.keyid !== undefined ? { keyid: params.keyid } : {}),
|
|
578
|
+
...(declaredAlg !== undefined ? { alg: declaredAlg } : {}),
|
|
579
|
+
...(params.tag !== undefined ? { tag: params.tag } : {}),
|
|
580
|
+
};
|
|
581
|
+
// Required components.
|
|
582
|
+
const requiredComponents = opts.requiredComponents ?? ["@method", "@path"];
|
|
583
|
+
const coveredIds = input.components.map(serializeComponentId);
|
|
584
|
+
for (const req of requiredComponents) {
|
|
585
|
+
const wanted = serializeComponentId(parseComponentSpec(req));
|
|
586
|
+
if (!coveredIds.includes(wanted))
|
|
587
|
+
return fail("missing_required_component");
|
|
588
|
+
}
|
|
589
|
+
// Tag check.
|
|
590
|
+
if (opts.requiredTag !== undefined && params.tag !== opts.requiredTag) {
|
|
591
|
+
return fail("tag_mismatch");
|
|
592
|
+
}
|
|
593
|
+
// Time checks.
|
|
594
|
+
const nowSeconds = Math.floor((opts.now ?? Date.now)() / 1000);
|
|
595
|
+
const tolerance = opts.toleranceSeconds ?? DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS;
|
|
596
|
+
const requireCreated = opts.requireCreated !== false;
|
|
597
|
+
if (params.created === undefined) {
|
|
598
|
+
if (requireCreated)
|
|
599
|
+
return fail("missing_created");
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
if (params.created - tolerance > nowSeconds)
|
|
603
|
+
return fail("created_in_future");
|
|
604
|
+
const maxAge = opts.maxAgeSeconds ?? DEFAULT_MAX_SIGNATURE_AGE_SECONDS;
|
|
605
|
+
if (Number.isFinite(maxAge) && nowSeconds - params.created > maxAge) {
|
|
606
|
+
return fail("signature_stale");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
if (params.expires !== undefined && nowSeconds - tolerance > params.expires) {
|
|
610
|
+
return fail("signature_expired");
|
|
611
|
+
}
|
|
612
|
+
// Replay check.
|
|
613
|
+
if (opts.isReplay) {
|
|
614
|
+
if (params.nonce === undefined)
|
|
615
|
+
return fail("missing_nonce");
|
|
616
|
+
if (await opts.isReplay(params.nonce, info))
|
|
617
|
+
return fail("replay_detected");
|
|
618
|
+
}
|
|
619
|
+
// Resolve the key + effective algorithm (defeating algorithm-confusion).
|
|
620
|
+
const resolved = await opts.resolveKey(info);
|
|
621
|
+
if (resolved === undefined)
|
|
622
|
+
return fail("key_not_found");
|
|
623
|
+
let keyMaterial;
|
|
624
|
+
let pinnedAlg;
|
|
625
|
+
if (resolved instanceof Uint8Array ||
|
|
626
|
+
isCryptoKey(resolved) ||
|
|
627
|
+
isJsonWebKey(resolved)) {
|
|
628
|
+
keyMaterial = resolved;
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
keyMaterial = resolved.key;
|
|
632
|
+
pinnedAlg = resolved.alg;
|
|
633
|
+
}
|
|
634
|
+
const effectiveAlg = pinnedAlg ?? declaredAlg;
|
|
635
|
+
if (effectiveAlg === undefined)
|
|
636
|
+
return fail("unspecified_alg");
|
|
637
|
+
if (pinnedAlg !== undefined && declaredAlg !== undefined && pinnedAlg !== declaredAlg) {
|
|
638
|
+
return fail("alg_mismatch");
|
|
639
|
+
}
|
|
640
|
+
if (!opts.algorithms.includes(effectiveAlg))
|
|
641
|
+
return fail("alg_not_allowed");
|
|
642
|
+
// Rebuild the signature base (verbatim @signature-params value).
|
|
643
|
+
const url = opts.url instanceof URL ? opts.url : new URL(opts.url);
|
|
644
|
+
const msg = {
|
|
645
|
+
method: opts.method,
|
|
646
|
+
url,
|
|
647
|
+
headers,
|
|
648
|
+
...(opts.status !== undefined ? { status: opts.status } : {}),
|
|
649
|
+
};
|
|
650
|
+
let base;
|
|
651
|
+
try {
|
|
652
|
+
base = buildSignatureBase(input.components, input.raw, msg);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
return fail("component_resolution_failed");
|
|
656
|
+
}
|
|
657
|
+
let key;
|
|
658
|
+
try {
|
|
659
|
+
key = await importKey(effectiveAlg, keyMaterial, "verify");
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return fail("invalid_key");
|
|
663
|
+
}
|
|
664
|
+
const spec = algSpec(effectiveAlg);
|
|
665
|
+
let ok;
|
|
666
|
+
try {
|
|
667
|
+
ok = await getCrypto().subtle.verify(spec.signParams, key, provided, ENC.encode(base));
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
return fail("verify_threw");
|
|
671
|
+
}
|
|
672
|
+
if (!ok)
|
|
673
|
+
return fail("invalid_signature");
|
|
674
|
+
return {
|
|
675
|
+
valid: true,
|
|
676
|
+
label,
|
|
677
|
+
alg: effectiveAlg,
|
|
678
|
+
components: coveredIds,
|
|
679
|
+
...(params.keyid !== undefined ? { keyid: params.keyid } : {}),
|
|
680
|
+
...(params.created !== undefined ? { created: params.created } : {}),
|
|
681
|
+
...(params.expires !== undefined ? { expires: params.expires } : {}),
|
|
682
|
+
...(params.nonce !== undefined ? { nonce: params.nonce } : {}),
|
|
683
|
+
...(params.tag !== undefined ? { tag: params.tag } : {}),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Verify an HTTP Message Signature on an inbound {@link Request}. Thin wrapper
|
|
688
|
+
* over {@link verifyMessage} that pulls the method, URL, and headers from the
|
|
689
|
+
* request.
|
|
690
|
+
*
|
|
691
|
+
* @since 0.37.0
|
|
692
|
+
*/
|
|
693
|
+
export function verifyRequest(request, opts) {
|
|
694
|
+
return verifyMessage({
|
|
695
|
+
...opts,
|
|
696
|
+
method: request.method,
|
|
697
|
+
url: request.url,
|
|
698
|
+
headers: request.headers,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Middleware that enforces a valid RFC 9421 HTTP Message Signature on inbound
|
|
703
|
+
* requests. On success the {@link VerifySuccess} is stamped on `ctx.state`; on
|
|
704
|
+
* a missing (unless `optional`) or invalid signature it throws
|
|
705
|
+
* {@link UnauthorizedError} (`401` + `Cache-Control: no-store`).
|
|
706
|
+
*
|
|
707
|
+
* @since 0.37.0
|
|
708
|
+
*/
|
|
709
|
+
export function httpSignatureAuth(opts) {
|
|
710
|
+
const stateKey = opts.stateKey ?? "httpSignature";
|
|
711
|
+
const message = opts.message ?? "Valid HTTP message signature required";
|
|
712
|
+
return {
|
|
713
|
+
async beforeHandle(ctx) {
|
|
714
|
+
const headers = ctx.request.headers;
|
|
715
|
+
if (opts.optional && !headers.has("signature"))
|
|
716
|
+
return undefined;
|
|
717
|
+
const result = await verifyRequest(ctx.request, opts);
|
|
718
|
+
if (!result.valid) {
|
|
719
|
+
throw new UnauthorizedError(`${message} (${result.reason})`);
|
|
720
|
+
}
|
|
721
|
+
ctx.state[stateKey] = result;
|
|
722
|
+
return undefined;
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const CONTENT_DIGEST_HASH = {
|
|
727
|
+
"sha-256": "SHA-256",
|
|
728
|
+
"sha-512": "SHA-512",
|
|
729
|
+
};
|
|
730
|
+
function toBytes(body) {
|
|
731
|
+
return typeof body === "string" ? ENC.encode(body) : body;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Compute an RFC 9530 `Content-Digest` header value over `body`, e.g.
|
|
735
|
+
* `sha-256=:<base64>:`. Pair it with a `content-digest` covered component to
|
|
736
|
+
* bind the request body into the signature, then re-check it against the
|
|
737
|
+
* received body with {@link verifyContentDigest}.
|
|
738
|
+
*
|
|
739
|
+
* @since 0.37.0
|
|
740
|
+
*/
|
|
741
|
+
export async function contentDigest(body, opts = {}) {
|
|
742
|
+
const algorithm = opts.algorithm ?? "sha-256";
|
|
743
|
+
const hash = CONTENT_DIGEST_HASH[algorithm];
|
|
744
|
+
if (!hash) {
|
|
745
|
+
throw new TypeError(`contentDigest(): unsupported algorithm ${algorithm}`);
|
|
746
|
+
}
|
|
747
|
+
const digest = new Uint8Array(await getCrypto().subtle.digest(hash, toBytes(body)));
|
|
748
|
+
return `${algorithm}=:${bytesToBase64(digest)}:`;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Verify that an RFC 9530 `Content-Digest` header value matches `body`.
|
|
752
|
+
* Returns `false` for any malformed header or mismatch (never throws on bad
|
|
753
|
+
* input). Only `sha-256` / `sha-512` members are considered.
|
|
754
|
+
*
|
|
755
|
+
* @since 0.37.0
|
|
756
|
+
*/
|
|
757
|
+
export async function verifyContentDigest(header, body) {
|
|
758
|
+
if (typeof header !== "string" || header.length > MAX_HEADER_LENGTH) {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
const bodyBytes = toBytes(body);
|
|
762
|
+
let matchedAny = false;
|
|
763
|
+
for (const member of header.split(",")) {
|
|
764
|
+
const m = /^\s*(sha-256|sha-512)=:([A-Za-z0-9+/]*={0,2}):\s*$/.exec(member);
|
|
765
|
+
if (!m)
|
|
766
|
+
continue;
|
|
767
|
+
const algorithm = m[1];
|
|
768
|
+
const expected = base64ToBytes(m[2]);
|
|
769
|
+
if (!expected)
|
|
770
|
+
return false;
|
|
771
|
+
const digest = new Uint8Array(await getCrypto().subtle.digest(CONTENT_DIGEST_HASH[algorithm], bodyBytes));
|
|
772
|
+
if (digest.length !== expected.length)
|
|
773
|
+
return false;
|
|
774
|
+
let diff = 0;
|
|
775
|
+
for (let i = 0; i < digest.length; i++)
|
|
776
|
+
diff |= digest[i] ^ expected[i];
|
|
777
|
+
if (diff !== 0)
|
|
778
|
+
return false;
|
|
779
|
+
matchedAny = true;
|
|
780
|
+
}
|
|
781
|
+
return matchedAny;
|
|
782
|
+
}
|