@dwk/http-signatures 0.1.0-beta.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.
Files changed (58) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +141 -0
  3. package/dist/algorithms.d.ts +31 -0
  4. package/dist/algorithms.d.ts.map +1 -0
  5. package/dist/algorithms.js +106 -0
  6. package/dist/algorithms.js.map +1 -0
  7. package/dist/base64.d.ts +8 -0
  8. package/dist/base64.d.ts.map +1 -0
  9. package/dist/base64.js +23 -0
  10. package/dist/base64.js.map +1 -0
  11. package/dist/cavage.d.ts +38 -0
  12. package/dist/cavage.d.ts.map +1 -0
  13. package/dist/cavage.js +249 -0
  14. package/dist/cavage.js.map +1 -0
  15. package/dist/components.d.ts +26 -0
  16. package/dist/components.d.ts.map +1 -0
  17. package/dist/components.js +65 -0
  18. package/dist/components.js.map +1 -0
  19. package/dist/digest.d.ts +45 -0
  20. package/dist/digest.d.ts.map +1 -0
  21. package/dist/digest.js +128 -0
  22. package/dist/digest.js.map +1 -0
  23. package/dist/index.d.ts +28 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +26 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/rfc9421.d.ts +49 -0
  28. package/dist/rfc9421.d.ts.map +1 -0
  29. package/dist/rfc9421.js +216 -0
  30. package/dist/rfc9421.js.map +1 -0
  31. package/dist/sf.d.ts +49 -0
  32. package/dist/sf.d.ts.map +1 -0
  33. package/dist/sf.js +234 -0
  34. package/dist/sf.js.map +1 -0
  35. package/dist/sign.d.ts +46 -0
  36. package/dist/sign.d.ts.map +1 -0
  37. package/dist/sign.js +39 -0
  38. package/dist/sign.js.map +1 -0
  39. package/dist/types.d.ts +62 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +9 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/verify.d.ts +37 -0
  44. package/dist/verify.d.ts.map +1 -0
  45. package/dist/verify.js +60 -0
  46. package/dist/verify.js.map +1 -0
  47. package/package.json +44 -0
  48. package/src/algorithms.ts +168 -0
  49. package/src/base64.ts +25 -0
  50. package/src/cavage.ts +292 -0
  51. package/src/components.ts +79 -0
  52. package/src/digest.ts +156 -0
  53. package/src/index.ts +45 -0
  54. package/src/rfc9421.ts +280 -0
  55. package/src/sf.ts +246 -0
  56. package/src/sign.ts +79 -0
  57. package/src/types.ts +94 -0
  58. package/src/verify.ts +102 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Algorithm allow-list and the Web Crypto primitives that back it.
3
+ *
4
+ * Mirrors the `@dwk/dpop` hardening posture: asymmetric algorithms only, an
5
+ * explicit allow-list (no `none`, no HMAC), and the resolved key is validated
6
+ * against the claimed algorithm — RSA moduli below 2048 bits and curve
7
+ * mismatches are rejected before any signature is checked.
8
+ */
9
+
10
+ import type { SignatureAlgorithm } from "./types";
11
+
12
+ // Structural shapes for Web Crypto algorithm parameters. We avoid the DOM lib
13
+ // names (RsaPssParams, EcdsaParams, …) because @cloudflare/workers-types does
14
+ // not declare them; these objects are accepted structurally by crypto.subtle.
15
+ interface ImportAlg {
16
+ name: string;
17
+ namedCurve?: string;
18
+ hash?: string;
19
+ }
20
+ interface SignVerifyAlg {
21
+ name: string;
22
+ hash?: string;
23
+ saltLength?: number;
24
+ }
25
+
26
+ interface AlgSpec {
27
+ /** Web Crypto key algorithm name the resolved key must report. */
28
+ keyName: string;
29
+ /** For EC algorithms, the curve the resolved key must use. */
30
+ expectedCurve?: string;
31
+ /** Whether the key type is RSA (so the 2048-bit floor applies). */
32
+ rsa: boolean;
33
+ importParams: ImportAlg;
34
+ signParams: SignVerifyAlg;
35
+ }
36
+
37
+ const ALGS: Record<SignatureAlgorithm, AlgSpec> = {
38
+ "rsa-pss-sha512": {
39
+ keyName: "RSA-PSS",
40
+ rsa: true,
41
+ importParams: { name: "RSA-PSS", hash: "SHA-512" },
42
+ signParams: { name: "RSA-PSS", saltLength: 64 },
43
+ },
44
+ "rsa-v1_5-sha256": {
45
+ keyName: "RSASSA-PKCS1-v1_5",
46
+ rsa: true,
47
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
48
+ signParams: { name: "RSASSA-PKCS1-v1_5" },
49
+ },
50
+ "ecdsa-p256-sha256": {
51
+ keyName: "ECDSA",
52
+ expectedCurve: "P-256",
53
+ rsa: false,
54
+ importParams: { name: "ECDSA", namedCurve: "P-256" },
55
+ signParams: { name: "ECDSA", hash: "SHA-256" },
56
+ },
57
+ "ecdsa-p384-sha384": {
58
+ keyName: "ECDSA",
59
+ expectedCurve: "P-384",
60
+ rsa: false,
61
+ importParams: { name: "ECDSA", namedCurve: "P-384" },
62
+ signParams: { name: "ECDSA", hash: "SHA-384" },
63
+ },
64
+ ed25519: {
65
+ keyName: "Ed25519",
66
+ rsa: false,
67
+ importParams: { name: "Ed25519" },
68
+ signParams: { name: "Ed25519" },
69
+ },
70
+ };
71
+
72
+ /** Minimum accepted RSA modulus size, in bits. */
73
+ const MIN_RSA_KEY_BITS = 2048;
74
+
75
+ /** Whether `alg` is in the allow-list. */
76
+ export function isSupportedAlgorithm(alg: string): alg is SignatureAlgorithm {
77
+ return Object.prototype.hasOwnProperty.call(ALGS, alg);
78
+ }
79
+
80
+ /** The reasons {@link validateKey} can reject a key, or `null` when it is sound. */
81
+ export type KeyRejection = "key_alg_mismatch" | "key_too_small";
82
+
83
+ // The subset of CryptoKey.algorithm members we inspect. Declared structurally
84
+ // for the same reason as the param shapes above.
85
+ interface KeyAlgorithm {
86
+ name: string;
87
+ modulusLength?: number;
88
+ namedCurve?: string;
89
+ }
90
+
91
+ /**
92
+ * Validate that a resolved {@link CryptoKey} matches the claimed algorithm:
93
+ * the key's algorithm name and EC curve must line up, and RSA moduli must meet
94
+ * the {@link MIN_RSA_KEY_BITS} floor. Returns `null` when the key is acceptable.
95
+ */
96
+ export function validateKey(
97
+ key: CryptoKey,
98
+ alg: SignatureAlgorithm,
99
+ ): KeyRejection | null {
100
+ const spec = ALGS[alg];
101
+ const keyAlg = key.algorithm as KeyAlgorithm;
102
+ if (keyAlg.name !== spec.keyName) {
103
+ return "key_alg_mismatch";
104
+ }
105
+ if (spec.expectedCurve && keyAlg.namedCurve !== spec.expectedCurve) {
106
+ return "key_alg_mismatch";
107
+ }
108
+ if (
109
+ spec.rsa &&
110
+ typeof keyAlg.modulusLength === "number" &&
111
+ keyAlg.modulusLength < MIN_RSA_KEY_BITS
112
+ ) {
113
+ return "key_too_small";
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Derive the signature algorithm implied by a resolved key's type. Used when a
120
+ * signature omits its algorithm (RFC 9421 `alg` is OPTIONAL; the legacy
121
+ * `hs2019` token is intentionally key-derived). Returns `null` for a key type
122
+ * outside the allow-list.
123
+ */
124
+ export function deriveAlgFromKey(key: CryptoKey): SignatureAlgorithm | null {
125
+ const a = key.algorithm as KeyAlgorithm;
126
+ switch (a.name) {
127
+ case "RSA-PSS":
128
+ return "rsa-pss-sha512";
129
+ case "RSASSA-PKCS1-v1_5":
130
+ return "rsa-v1_5-sha256";
131
+ case "ECDSA":
132
+ return a.namedCurve === "P-384"
133
+ ? "ecdsa-p384-sha384"
134
+ : "ecdsa-p256-sha256";
135
+ case "Ed25519":
136
+ return "ed25519";
137
+ default:
138
+ return null;
139
+ }
140
+ }
141
+
142
+ /** Sign `data` with `key` under `alg`. */
143
+ export function signBytes(
144
+ key: CryptoKey,
145
+ alg: SignatureAlgorithm,
146
+ data: Uint8Array,
147
+ ): Promise<ArrayBuffer> {
148
+ return crypto.subtle.sign(ALGS[alg].signParams, key, data);
149
+ }
150
+
151
+ /** Verify `signature` over `data` with `key` under `alg`. Never throws. */
152
+ export async function verifyBytes(
153
+ key: CryptoKey,
154
+ alg: SignatureAlgorithm,
155
+ signature: Uint8Array,
156
+ data: Uint8Array,
157
+ ): Promise<boolean> {
158
+ try {
159
+ return await crypto.subtle.verify(
160
+ ALGS[alg].signParams,
161
+ key,
162
+ signature,
163
+ data,
164
+ );
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
package/src/base64.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Base64 (standard, padded) <-> bytes, plus base64url helpers. */
2
+
3
+ /** Decode standard padded base64 to bytes. Throws on invalid input. */
4
+ export function base64ToBytes(b64: string): Uint8Array {
5
+ const binary = atob(b64);
6
+ const bytes = new Uint8Array(binary.length);
7
+ for (let i = 0; i < binary.length; i++) {
8
+ bytes[i] = binary.charCodeAt(i);
9
+ }
10
+ return bytes;
11
+ }
12
+
13
+ /** Encode bytes as standard padded base64. */
14
+ export function bytesToBase64(bytes: Uint8Array): string {
15
+ let binary = "";
16
+ for (const byte of bytes) {
17
+ binary += String.fromCharCode(byte);
18
+ }
19
+ return btoa(binary);
20
+ }
21
+
22
+ /** Encode a UTF-8 string to bytes. */
23
+ export function utf8(input: string): Uint8Array {
24
+ return new TextEncoder().encode(input);
25
+ }
package/src/cavage.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The legacy `draft-cavage-http-signatures` "Signature" profile that much of
3
+ * the fediverse still emits. A single `Signature` header carries the `keyId`,
4
+ * `algorithm`, the space-separated `headers` list, and the base64 `signature`;
5
+ * the signing string is the `headers` components joined by newlines, where
6
+ * `(request-target)`, `(created)`, and `(expires)` are pseudo-headers.
7
+ */
8
+
9
+ import {
10
+ deriveAlgFromKey,
11
+ isSupportedAlgorithm,
12
+ signBytes,
13
+ validateKey,
14
+ verifyBytes,
15
+ } from "./algorithms";
16
+ import { base64ToBytes, bytesToBase64, utf8 } from "./base64";
17
+ import { derivationContext, type DerivationContext } from "./components";
18
+ import type { KeyResolver } from "./rfc9421";
19
+ import type {
20
+ HttpMessage,
21
+ SignatureAlgorithm,
22
+ SignatureFailureReason,
23
+ VerifyResult,
24
+ } from "./types";
25
+
26
+ /** Map our algorithm identifiers to a `draft-cavage` `algorithm` token. */
27
+ const CAVAGE_TOKEN: Record<SignatureAlgorithm, string> = {
28
+ "rsa-v1_5-sha256": "rsa-sha256",
29
+ "rsa-pss-sha512": "hs2019",
30
+ "ecdsa-p256-sha256": "ecdsa-sha256",
31
+ "ecdsa-p384-sha384": "ecdsa-sha384",
32
+ ed25519: "ed25519",
33
+ };
34
+
35
+ /** Map a `draft-cavage` `algorithm` token to our identifier, or `null` for `hs2019`. */
36
+ function tokenToAlg(token: string): SignatureAlgorithm | "derive" | null {
37
+ switch (token.toLowerCase()) {
38
+ case "rsa-sha256":
39
+ return "rsa-v1_5-sha256";
40
+ case "ecdsa-sha256":
41
+ return "ecdsa-p256-sha256";
42
+ case "ecdsa-sha384":
43
+ return "ecdsa-p384-sha384";
44
+ case "ed25519":
45
+ return "ed25519";
46
+ case "hs2019":
47
+ return "derive";
48
+ default:
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** Build the `draft-cavage` signing string, or report the first missing component. */
54
+ function buildSigningString(
55
+ ctx: DerivationContext,
56
+ components: string[],
57
+ times: { created?: number; expires?: number },
58
+ ): { string: string } | { missing: string } {
59
+ const lines: string[] = [];
60
+ for (const raw of components) {
61
+ const name = raw.toLowerCase();
62
+ let value: string | null;
63
+ if (name === "(request-target)") {
64
+ value = `${ctx.message.method.toLowerCase()} ${ctx.url.pathname}${ctx.url.search}`;
65
+ } else if (name === "(created)") {
66
+ value = times.created === undefined ? null : String(times.created);
67
+ } else if (name === "(expires)") {
68
+ value = times.expires === undefined ? null : String(times.expires);
69
+ } else {
70
+ value = ctx.headers.get(name) ?? null;
71
+ }
72
+ if (value === null) return { missing: name };
73
+ lines.push(`${name}: ${value}`);
74
+ }
75
+ return { string: lines.join("\n") };
76
+ }
77
+
78
+ /** Resolved signing inputs for the `draft-cavage` profile. */
79
+ export interface CavageSignParams {
80
+ key: CryptoKey;
81
+ keyId: string;
82
+ alg: SignatureAlgorithm;
83
+ /** Cavage-style component names, e.g. `["(request-target)", "host", "date", "digest"]`. */
84
+ components: string[];
85
+ created?: number;
86
+ expires?: number;
87
+ }
88
+
89
+ /**
90
+ * Sign `message` under the `draft-cavage` profile. Returns the single
91
+ * `Signature` header to merge into the request. Throws if a covered component
92
+ * is absent — a signer bug.
93
+ */
94
+ export async function signCavage(
95
+ message: HttpMessage,
96
+ params: CavageSignParams,
97
+ ): Promise<Record<string, string>> {
98
+ const ctx = derivationContext(message);
99
+ if (ctx === null)
100
+ throw new Error("http-signatures: message.url is not a valid URL");
101
+ const built = buildSigningString(ctx, params.components, {
102
+ created: params.created,
103
+ expires: params.expires,
104
+ });
105
+ if ("missing" in built) {
106
+ throw new Error(
107
+ `http-signatures: covered component "${built.missing}" is missing from the message`,
108
+ );
109
+ }
110
+ const signature = await signBytes(params.key, params.alg, utf8(built.string));
111
+ const parts = [
112
+ `keyId="${params.keyId}"`,
113
+ `algorithm="${CAVAGE_TOKEN[params.alg]}"`,
114
+ `headers="${params.components.map((c) => c.toLowerCase()).join(" ")}"`,
115
+ `signature="${bytesToBase64(new Uint8Array(signature))}"`,
116
+ ];
117
+ if (params.created !== undefined) parts.push(`created=${params.created}`);
118
+ if (params.expires !== undefined) parts.push(`expires=${params.expires}`);
119
+ return { Signature: parts.join(",") };
120
+ }
121
+
122
+ /** Parse a `draft-cavage` `Signature` header into its named members. */
123
+ function parseCavageHeader(value: string): Map<string, string> {
124
+ const out = new Map<string, string>();
125
+ let i = 0;
126
+ while (i < value.length) {
127
+ while (
128
+ i < value.length &&
129
+ (value[i] === " " || value[i] === "," || value[i] === "\t")
130
+ )
131
+ i++;
132
+ const eq = value.indexOf("=", i);
133
+ if (eq < 0) break;
134
+ const key = value.slice(i, eq).trim();
135
+ i = eq + 1;
136
+ let raw: string;
137
+ if (value[i] === '"') {
138
+ i++;
139
+ let s = "";
140
+ while (i < value.length && value[i] !== '"') {
141
+ if (value[i] === "\\" && i + 1 < value.length) i++;
142
+ s += value[i];
143
+ i++;
144
+ }
145
+ i++; // closing quote
146
+ raw = s;
147
+ } else {
148
+ let j = i;
149
+ while (j < value.length && value[j] !== ",") j++;
150
+ raw = value.slice(i, j).trim();
151
+ i = j;
152
+ }
153
+ if (key) out.set(key.toLowerCase(), raw);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /** Parameters for verifying a `draft-cavage` signature. */
159
+ export interface CavageVerifyParams {
160
+ resolveKey: KeyResolver;
161
+ requiredComponents?: string[];
162
+ now?: number;
163
+ toleranceSeconds?: number;
164
+ }
165
+
166
+ const DEFAULT_TOLERANCE_SECONDS = 300;
167
+
168
+ function getHeader(message: HttpMessage, name: string): string | null {
169
+ const lower = name.toLowerCase();
170
+ for (const [k, v] of Object.entries(message.headers)) {
171
+ if (k.toLowerCase() === lower) return Array.isArray(v) ? v.join(", ") : v;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ function fail(reason: SignatureFailureReason): VerifyResult {
177
+ return { valid: false, profile: "cavage", reason };
178
+ }
179
+
180
+ function parseIntegerParam(raw: string | undefined): number | null | undefined {
181
+ if (raw === undefined) return undefined;
182
+ const n = Number(raw);
183
+ return Number.isInteger(n) ? n : null;
184
+ }
185
+
186
+ /**
187
+ * Verify a `draft-cavage` signature on `message`. Never throws — failures are
188
+ * returned as `{ valid: false, reason }`.
189
+ */
190
+ export async function verifyCavage(
191
+ message: HttpMessage,
192
+ params: CavageVerifyParams,
193
+ ): Promise<VerifyResult> {
194
+ const header = getHeader(message, "signature");
195
+ if (header === null) return fail("signature_missing");
196
+
197
+ const fields = parseCavageHeader(header);
198
+ const sigB64 = fields.get("signature");
199
+ if (sigB64 === undefined) return fail("signature_malformed");
200
+ let signature: Uint8Array;
201
+ try {
202
+ signature = base64ToBytes(sigB64);
203
+ } catch {
204
+ return fail("signature_malformed");
205
+ }
206
+
207
+ const keyId = fields.get("keyid") ?? null;
208
+ if (keyId === null) return fail("keyid_missing");
209
+
210
+ const token = fields.get("algorithm");
211
+ const createdRaw = parseIntegerParam(fields.get("created"));
212
+ if (createdRaw === null) return fail("created_invalid");
213
+ const expiresRaw = parseIntegerParam(fields.get("expires"));
214
+ if (expiresRaw === null) return fail("expires_invalid");
215
+
216
+ // Default covered-component list when no explicit `headers` is sent.
217
+ // draft-cavage-12 §2.1.6 specifies the default is `(created)` unconditionally.
218
+ // We deliberately diverge: when `created` is absent we fall back to `date`,
219
+ // matching the older "Signing HTTP Messages" rule that essentially every
220
+ // fediverse peer implements. In practice senders always send an explicit
221
+ // `headers`, so this branch is rarely reached; the divergence only affects
222
+ // the (non-conforming) case of a signature with neither `headers` nor
223
+ // `created`, where interop with real-world peers beats spec-literalism.
224
+ const headersList = fields.get("headers");
225
+ const components = headersList
226
+ ? headersList.split(/\s+/).filter(Boolean)
227
+ : createdRaw !== undefined
228
+ ? ["(created)"]
229
+ : ["date"];
230
+
231
+ // Resolve the algorithm. `hs2019` (and a missing token) defer to the key type.
232
+ let alg: SignatureAlgorithm | null = null;
233
+ if (token !== undefined) {
234
+ const mapped = tokenToAlg(token);
235
+ if (mapped === null) return fail("alg_unsupported");
236
+ if (mapped !== "derive") alg = mapped;
237
+ }
238
+
239
+ const key = await params.resolveKey({ keyId, alg });
240
+ if (key === null) return fail("key_unresolved");
241
+ if (alg === null) {
242
+ alg = deriveAlgFromKey(key);
243
+ if (alg === null || !isSupportedAlgorithm(alg))
244
+ return fail("alg_unsupported");
245
+ }
246
+ const rejection = validateKey(key, alg);
247
+ if (rejection !== null) return fail(rejection);
248
+
249
+ // created / expires window.
250
+ const now = params.now ?? Math.floor(Date.now() / 1000);
251
+ const tolerance = params.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
252
+ if (createdRaw !== undefined && createdRaw > now + tolerance)
253
+ return fail("signature_future");
254
+ if (expiresRaw !== undefined && now > expiresRaw + tolerance)
255
+ return fail("signature_expired");
256
+ // RFC 9421 §2.3 / draft-cavage: `expires` MUST NOT precede `created`.
257
+ if (
258
+ createdRaw !== undefined &&
259
+ expiresRaw !== undefined &&
260
+ expiresRaw < createdRaw
261
+ )
262
+ return fail("expires_invalid");
263
+
264
+ // Required-component policy.
265
+ if (params.requiredComponents) {
266
+ const covered = new Set(components.map((c) => c.toLowerCase()));
267
+ for (const required of params.requiredComponents) {
268
+ if (!covered.has(required.toLowerCase()))
269
+ return fail("required_component_missing");
270
+ }
271
+ }
272
+
273
+ const ctx = derivationContext(message);
274
+ if (ctx === null) return fail("covered_component_missing");
275
+ const built = buildSigningString(ctx, components, {
276
+ created: createdRaw,
277
+ expires: expiresRaw,
278
+ });
279
+ if ("missing" in built) return fail("covered_component_missing");
280
+
281
+ const ok = await verifyBytes(key, alg, signature, utf8(built.string));
282
+ if (!ok) return fail("signature_invalid");
283
+
284
+ return {
285
+ valid: true,
286
+ profile: "cavage",
287
+ keyId,
288
+ coveredComponents: components,
289
+ created: createdRaw,
290
+ expires: expiresRaw,
291
+ };
292
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Covered-component value derivation (RFC 9421 §2.1–2.2), shared by the
3
+ * signature-base builder and the verifier.
4
+ *
5
+ * Supports the derived components needed for request signing — `@method`,
6
+ * `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`,
7
+ * `@query` — and any HTTP header field (matched case-insensitively, with
8
+ * multiple field lines folded into one `", "`-joined value per §2.1).
9
+ * Component parameters (`;sf`, `;key`, `;bs`, `@query-param`, …) are not
10
+ * supported and are reported by the caller as malformed.
11
+ */
12
+
13
+ import type { HttpMessage } from "./types";
14
+
15
+ /** Build a case-insensitive view of the message's header values, OWS-trimmed. */
16
+ function headerMap(headers: HttpMessage["headers"]): Map<string, string> {
17
+ const map = new Map<string, string>();
18
+ for (const [name, value] of Object.entries(headers)) {
19
+ // RFC 9421 §2.1: strip leading/trailing OWS from each field value and join
20
+ // multiple values with ", ". Internal whitespace is preserved verbatim —
21
+ // collapsing it would change the signed value and break verification.
22
+ const joined = Array.isArray(value)
23
+ ? value.map((v) => v.trim()).join(", ")
24
+ : value.trim();
25
+ map.set(name.toLowerCase(), joined);
26
+ }
27
+ return map;
28
+ }
29
+
30
+ export interface DerivationContext {
31
+ message: HttpMessage;
32
+ url: URL;
33
+ headers: Map<string, string>;
34
+ }
35
+
36
+ /** Parse the message URL once and capture a normalized header view. */
37
+ export function derivationContext(
38
+ message: HttpMessage,
39
+ ): DerivationContext | null {
40
+ let url: URL;
41
+ try {
42
+ url = new URL(message.url);
43
+ } catch {
44
+ return null;
45
+ }
46
+ return { message, url, headers: headerMap(message.headers) };
47
+ }
48
+
49
+ /**
50
+ * Derive the value of a single covered component. Returns `null` when the
51
+ * component is unsupported (a `@`-derived name we do not implement) or absent
52
+ * (a header the message does not carry).
53
+ */
54
+ export function deriveComponentValue(
55
+ ctx: DerivationContext,
56
+ name: string,
57
+ ): string | null {
58
+ if (name.startsWith("@")) {
59
+ switch (name) {
60
+ case "@method":
61
+ return ctx.message.method.toUpperCase();
62
+ case "@target-uri":
63
+ return ctx.url.href;
64
+ case "@authority":
65
+ return ctx.url.host.toLowerCase();
66
+ case "@scheme":
67
+ return ctx.url.protocol.replace(/:$/, "").toLowerCase();
68
+ case "@request-target":
69
+ return `${ctx.url.pathname}${ctx.url.search}`;
70
+ case "@path":
71
+ return ctx.url.pathname;
72
+ case "@query":
73
+ return ctx.url.search === "" ? "?" : ctx.url.search;
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+ return ctx.headers.get(name.toLowerCase()) ?? null;
79
+ }