@johnhenry/http-fields-signatures 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,174 @@
1
- # Temporary Holding Version
1
+ # @johnhenry/http-fields-signatures
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Previously published as the unscoped `http-fields-signatures` (0.1.0). This scoped package continues that line at 0.2.0 and depends on `@johnhenry/http-fields`.
4
+
5
+ [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421.html) **HTTP Message
6
+ Signatures** for JavaScript — sign and verify HTTP requests and responses.
7
+
8
+ Built on [`http-fields`](https://github.com/johnhenry/http-fields) (RFC
9
+ 8941/9651 Structured Field Values) for all header parsing/serialization, and
10
+ on WebCrypto for the cryptography. Runtime-agnostic: Node.js ≥ 20, browsers,
11
+ and edge runtimes with `globalThis.crypto.subtle`.
12
+
13
+ Validated against the complete RFC 9421 Appendix B test suite: all six
14
+ signature bases are reproduced byte-exactly, the RFC's RSA-PSS and ECDSA
15
+ example signatures verify, and HMAC/Ed25519 signing reproduces the RFC's
16
+ exact signature bytes.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @johnhenry/http-fields-signatures
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ### Signing a request
27
+
28
+ ```javascript
29
+ import { signMessage } from "@johnhenry/http-fields-signatures";
30
+
31
+ const request = {
32
+ method: "POST",
33
+ url: "https://example.com/foo?param=Value&Pet=dog",
34
+ headers: {
35
+ Date: "Tue, 20 Apr 2021 02:07:55 GMT",
36
+ "Content-Type": "application/json",
37
+ },
38
+ };
39
+
40
+ const { signatureInput, signature } = await signMessage(request, {
41
+ alg: "ed25519",
42
+ key: privateJwk, // JWK, PEM (PKCS#8), or CryptoKey
43
+ keyid: "my-key",
44
+ components: ["@method", "@path", "@authority", "content-type"],
45
+ });
46
+
47
+ request.headers["Signature-Input"] = signatureInput;
48
+ request.headers["Signature"] = signature;
49
+ // Signature-Input: sig1=("@method" "@path" "@authority" "content-type");created=...;keyid="my-key"
50
+ // Signature: sig1=:...base64...:
51
+ ```
52
+
53
+ ### Verifying
54
+
55
+ ```javascript
56
+ import { verifyMessage } from "@johnhenry/http-fields-signatures";
57
+
58
+ const results = await verifyMessage(request, {
59
+ getKey: ({ keyid }) => ({ alg: "ed25519", key: publicKeys[keyid] }),
60
+ });
61
+ // [{ label: "sig1", verified: true, params: {...}, components: [...] }]
62
+ ```
63
+
64
+ Structural problems (unparseable headers) throw; cryptographic mismatches and
65
+ policy failures (expired signatures, unknown keys) report as
66
+ `{verified: false, reason}` so one bad signature doesn't mask others.
67
+
68
+ ### Signing a response (with request binding)
69
+
70
+ ```javascript
71
+ const response = { status: 200, headers: {...}, request };
72
+
73
+ await signMessage(response, {
74
+ alg: "ecdsa-p256-sha256",
75
+ key: privateJwk,
76
+ keyid: "my-key",
77
+ components: [
78
+ "@status",
79
+ "content-type",
80
+ { id: "@method", params: { req: true } }, // bind to the request's method
81
+ ],
82
+ });
83
+ ```
84
+
85
+ ## Supported algorithms (RFC 9421 §3.3)
86
+
87
+ | Name | WebCrypto |
88
+ | --- | --- |
89
+ | `rsa-pss-sha512` | RSA-PSS, SHA-512, salt 64 |
90
+ | `rsa-v1_5-sha256` | RSASSA-PKCS1-v1_5, SHA-256 |
91
+ | `ecdsa-p256-sha256` | ECDSA P-256, SHA-256 |
92
+ | `ecdsa-p384-sha384` | ECDSA P-384, SHA-384 |
93
+ | `ed25519` | Ed25519 |
94
+ | `hmac-sha256` | HMAC, SHA-256 |
95
+
96
+ Keys are accepted as **CryptoKey**, **JWK** objects, **PEM** strings (PKCS#8
97
+ or SEC1 private / SPKI public), or raw **Uint8Array** bytes (HMAC).
98
+
99
+ WebCrypto itself cannot import SEC1 `EC PRIVATE KEY` PEMs (the format RFC
100
+ 9421 uses for its P-256 example key), so `importKey` transparently re-wraps
101
+ them: PKCS#8 is just an ASN.1 envelope around the SEC1 payload, so the
102
+ conversion is pure DER re-packaging with no cryptography involved. The
103
+ transcoder is also exported directly as `sec1ToPkcs8(sec1Bytes, namedCurve?)`.
104
+
105
+ **Encrypted private keys are not supported.** Password-protected PEMs —
106
+ PKCS#8 `ENCRYPTED PRIVATE KEY` (PBES2) or legacy `Proc-Type: 4,ENCRYPTED`
107
+ blocks — require key-derivation cryptography, not byte re-packaging, and
108
+ `importKey` rejects them with a clear error. Decrypt once outside the
109
+ library instead:
110
+
111
+ ```bash
112
+ openssl pkcs8 -topk8 -nocrypt -in encrypted-key.pem -out key.pem
113
+ ```
114
+
115
+ ## Covered components
116
+
117
+ Components are strings or `{id, params}` objects:
118
+
119
+ - **Derived** (from the message itself): `@method`, `@target-uri`,
120
+ `@authority`, `@scheme`, `@request-target`, `@path`, `@query`,
121
+ `@query-param` (`{id: "@query-param", params: {name: "Pet"}}`), `@status`
122
+ - **Fields**: any header name (lowercased), with optional parameters:
123
+ - `key`: select one dictionary member (`{id: "example-dict", params: {key: "b"}}`)
124
+ - `sf`: strict structured-field re-serialization (requires the field's type
125
+ via `options.fieldTypes`)
126
+ - `bs`: wrap raw value(s) as byte sequences
127
+ - `req`: take the component from the related request when signing a response
128
+
129
+ ## Lower-level API
130
+
131
+ ```javascript
132
+ import {
133
+ createSignatureBase, // build the §2.5 signature base string
134
+ parseSignatureHeaders, // parse Signature-Input / Signature dictionaries
135
+ signBase, verifyBase, // raw crypto over a base string
136
+ importKey, ALGORITHMS,
137
+ } from "@johnhenry/http-fields-signatures";
138
+
139
+ const { base } = createSignatureBase(request, ["@method", "content-type"], {
140
+ created: 1618884473,
141
+ keyid: "my-key",
142
+ });
143
+ ```
144
+
145
+ ## Messages
146
+
147
+ Messages are plain objects, so any client/server framework can adapt to them:
148
+
149
+ ```javascript
150
+ // Request: { method, url, headers }
151
+ // Response: { status, headers, request? }
152
+ ```
153
+
154
+ `headers` may be a plain object (values `string` or `string[]`), an array of
155
+ `[name, value]` entries, or a Fetch-style `Headers` instance.
156
+
157
+ ## Testing
158
+
159
+ ```bash
160
+ npm test
161
+ ```
162
+
163
+ The suite includes the full RFC 9421 Appendix B vectors (B.2.1–B.2.6) plus
164
+ round-trip, tampering, and expiry tests.
165
+
166
+ ## Alternatives
167
+
168
+ See [COMPARISON.md](COMPARISON.md) for a side-by-side comparison with
169
+ [`http-message-signatures`](https://github.com/dhensby/node-http-message-signatures),
170
+ including how to do the same signing/verifying tasks in each.
171
+
172
+ ## License
173
+
174
+ MIT
package/base.mjs ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * RFC 9421 signature base construction (§2).
3
+ *
4
+ * A "message" is a plain, runtime-agnostic object:
5
+ * Request: { method, url, headers }
6
+ * Response: { status, headers, request? } // request enables ;req params
7
+ *
8
+ * `headers` may be a plain object (values: string or string[]), an iterable
9
+ * of [name, value] entries, or a Fetch-style Headers instance.
10
+ */
11
+
12
+ "use strict";
13
+
14
+ import { parse, serialize } from "@johnhenry/http-fields";
15
+
16
+ // --- header access ---------------------------------------------------------
17
+
18
+ const headerEntries = (headers) => {
19
+ if (!headers) return [];
20
+ if (typeof headers.entries === "function" && typeof headers.get === "function") {
21
+ return [...headers.entries()];
22
+ }
23
+ if (Array.isArray(headers)) return headers;
24
+ return Object.entries(headers);
25
+ };
26
+
27
+ /** All values for a header name (case-insensitive), in order. */
28
+ export const getHeaderValues = (headers, name) => {
29
+ const lower = name.toLowerCase();
30
+ const values = [];
31
+ for (const [key, value] of headerEntries(headers)) {
32
+ if (String(key).toLowerCase() === lower) {
33
+ if (Array.isArray(value)) values.push(...value.map(String));
34
+ else values.push(String(value));
35
+ }
36
+ }
37
+ return values;
38
+ };
39
+
40
+ // --- component identifiers -------------------------------------------------
41
+
42
+ /**
43
+ * Normalize a covered-component spec to {id, params}. Accepts a bare string
44
+ * ("@method", "content-type") or {id, params} for parameterized components
45
+ * ({id: "@query-param", params: {name: "Pet"}}).
46
+ */
47
+ export const normalizeComponent = (component) => {
48
+ if (typeof component === "string") return { id: component, params: {} };
49
+ if (component && typeof component.id === "string") {
50
+ return { id: component.id, params: { ...(component.params || {}) } };
51
+ }
52
+ throw new Error("Component must be a string or {id, params} object");
53
+ };
54
+
55
+ /** Serialize a component identifier as an SFV string item with parameters. */
56
+ export const serializeComponentIdentifier = ({ id, params }) => {
57
+ return serialize({ value: id.toLowerCase(), parameters: params }, "item");
58
+ };
59
+
60
+ // --- derived component values (§2.2) ---------------------------------------
61
+
62
+ const strictEncode = (s) =>
63
+ encodeURIComponent(s).replace(
64
+ /[!'()*]/g,
65
+ (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase()
66
+ );
67
+
68
+ const requireUrl = (message) => {
69
+ if (!message || typeof message.url !== "string") {
70
+ throw new Error("Message must have an absolute 'url' for this component");
71
+ }
72
+ return new URL(message.url);
73
+ };
74
+
75
+ const deriveValues = (id, params, message) => {
76
+ const url = () => requireUrl(message);
77
+ switch (id) {
78
+ case "@method": {
79
+ if (typeof message.method !== "string") {
80
+ throw new Error("Message must have a 'method'");
81
+ }
82
+ return [message.method.toUpperCase()];
83
+ }
84
+ case "@target-uri":
85
+ return [url().href];
86
+ case "@authority": {
87
+ const u = url();
88
+ return [u.host.toLowerCase()];
89
+ }
90
+ case "@scheme":
91
+ return [url().protocol.replace(/:$/, "").toLowerCase()];
92
+ case "@request-target": {
93
+ const u = url();
94
+ return [u.pathname + u.search];
95
+ }
96
+ case "@path":
97
+ return [url().pathname];
98
+ case "@query": {
99
+ const u = url();
100
+ return [u.search === "" ? "?" : u.search];
101
+ }
102
+ case "@query-param": {
103
+ if (typeof params.name !== "string") {
104
+ throw new Error('@query-param requires a "name" parameter');
105
+ }
106
+ const u = url();
107
+ const lines = [];
108
+ for (const [k, v] of new URLSearchParams(u.search)) {
109
+ if (strictEncode(k) === params.name) lines.push(strictEncode(v));
110
+ }
111
+ if (lines.length === 0) {
112
+ throw new Error(`Query parameter "${params.name}" not found`);
113
+ }
114
+ return lines;
115
+ }
116
+ case "@status": {
117
+ if (!Number.isInteger(message.status)) {
118
+ throw new Error("Message must have an integer 'status'");
119
+ }
120
+ return [String(message.status)];
121
+ }
122
+ default:
123
+ throw new Error(`Unknown derived component: ${id}`);
124
+ }
125
+ };
126
+
127
+ // --- field component values (§2.1) -----------------------------------------
128
+
129
+ const b64FromBytes = (bytes) => {
130
+ let bin = "";
131
+ for (const byte of bytes) bin += String.fromCharCode(byte);
132
+ return btoa(bin);
133
+ };
134
+
135
+ const fieldValue = (id, params, message, options) => {
136
+ const values = getHeaderValues(message.headers, id).map((v) =>
137
+ v.trim().replace(/[ \t]*\r?\n[ \t]*/g, " ")
138
+ );
139
+ if (values.length === 0) {
140
+ throw new Error(`Header field "${id}" not present in message`);
141
+ }
142
+
143
+ if (params.bs === true) {
144
+ const encoder = new TextEncoder();
145
+ return values.map((v) => `:${b64FromBytes(encoder.encode(v))}:`).join(", ");
146
+ }
147
+
148
+ if (params.key !== undefined) {
149
+ if (typeof params.key !== "string") {
150
+ throw new Error('"key" parameter must be a string');
151
+ }
152
+ const dict = parse(values.join(", "), "dictionary");
153
+ const member = dict[params.key];
154
+ if (member === undefined) {
155
+ throw new Error(`Dictionary key "${params.key}" not found in "${id}"`);
156
+ }
157
+ if (Array.isArray(member.value)) {
158
+ return serialize([member], "list");
159
+ }
160
+ return serialize({ value: member.value, parameters: member.parameters }, "item");
161
+ }
162
+
163
+ if (params.sf === true) {
164
+ const type = options?.fieldTypes?.[id];
165
+ if (!type) {
166
+ throw new Error(
167
+ `"sf" parameter requires the field type of "${id}" via options.fieldTypes`
168
+ );
169
+ }
170
+ return serialize(parse(values.join(", "), type), type);
171
+ }
172
+
173
+ if (params.tr === true) {
174
+ throw new Error("Trailer components (;tr) are not supported");
175
+ }
176
+
177
+ return values.join(", ");
178
+ };
179
+
180
+ // --- signature base (§2.5) -------------------------------------------------
181
+
182
+ /**
183
+ * Build the RFC 9421 signature base.
184
+ *
185
+ * @param {object} message - request or response message (see module docs)
186
+ * @param {Array<string|{id: string, params?: object}>} components
187
+ * @param {object} signatureParams - created/expires/nonce/alg/keyid/tag, in
188
+ * the order they should appear on the wire
189
+ * @param {{fieldTypes?: Record<string, "list"|"dictionary"|"item">}} [options]
190
+ * @returns {{base: string, signatureParamsValue: string}} the base string and
191
+ * the serialized inner list used in both the base and Signature-Input
192
+ */
193
+ export const createSignatureBase = (message, components, signatureParams, options) => {
194
+ const normalized = components.map(normalizeComponent);
195
+ const lines = [];
196
+ const seen = new Set();
197
+
198
+ for (const component of normalized) {
199
+ const { params } = component;
200
+ const id = component.id.toLowerCase();
201
+
202
+ let target = message;
203
+ if (params.req === true) {
204
+ if (!message.request) {
205
+ throw new Error('";req" component requires message.request');
206
+ }
207
+ target = message.request;
208
+ }
209
+
210
+ const identifier = serializeComponentIdentifier({ id, params });
211
+ if (seen.has(identifier)) {
212
+ throw new Error(`Duplicate covered component: ${identifier}`);
213
+ }
214
+ seen.add(identifier);
215
+
216
+ const values = id.startsWith("@")
217
+ ? deriveValues(id, params, target)
218
+ : [fieldValue(id, params, target, options)];
219
+
220
+ for (const value of values) {
221
+ lines.push(`${identifier}: ${value}`);
222
+ }
223
+ }
224
+
225
+ const innerList = normalized.map(({ id, params }) => ({
226
+ value: id.toLowerCase(),
227
+ parameters: params,
228
+ }));
229
+ const signatureParamsValue = serialize(
230
+ [{ value: innerList, parameters: signatureParams || {} }],
231
+ "list"
232
+ );
233
+ lines.push(`"@signature-params": ${signatureParamsValue}`);
234
+
235
+ return { base: lines.join("\n"), signatureParamsValue };
236
+ };
package/crypto.mjs ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * RFC 9421 signature algorithms (§3.3) over WebCrypto.
3
+ *
4
+ * Keys are accepted as: a CryptoKey, a JWK object, a PEM string (PKCS#8
5
+ * private or SPKI public), or raw bytes (Uint8Array, HMAC only).
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const subtle = globalThis.crypto?.subtle;
11
+ if (!subtle) {
12
+ throw new Error("WebCrypto (globalThis.crypto.subtle) is required");
13
+ }
14
+
15
+ export const ALGORITHMS = {
16
+ "rsa-pss-sha512": {
17
+ importParams: { name: "RSA-PSS", hash: "SHA-512" },
18
+ signParams: { name: "RSA-PSS", saltLength: 64 },
19
+ },
20
+ "rsa-v1_5-sha256": {
21
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
22
+ signParams: { name: "RSASSA-PKCS1-v1_5" },
23
+ },
24
+ "ecdsa-p256-sha256": {
25
+ importParams: { name: "ECDSA", namedCurve: "P-256" },
26
+ signParams: { name: "ECDSA", hash: "SHA-256" },
27
+ },
28
+ "ecdsa-p384-sha384": {
29
+ importParams: { name: "ECDSA", namedCurve: "P-384" },
30
+ signParams: { name: "ECDSA", hash: "SHA-384" },
31
+ },
32
+ ed25519: {
33
+ importParams: { name: "Ed25519" },
34
+ signParams: { name: "Ed25519" },
35
+ },
36
+ "hmac-sha256": {
37
+ importParams: { name: "HMAC", hash: "SHA-256" },
38
+ signParams: { name: "HMAC" },
39
+ },
40
+ };
41
+
42
+ const bytesFromB64 = (b64) => {
43
+ const bin = atob(b64);
44
+ const bytes = new Uint8Array(bin.length);
45
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
46
+ return bytes;
47
+ };
48
+
49
+ const pemToBytes = (pem, label) => {
50
+ const match = pem.match(
51
+ new RegExp(`-----BEGIN ${label}-----([A-Za-z0-9+/=\\s]+)-----END ${label}-----`)
52
+ );
53
+ if (!match) return null;
54
+ return bytesFromB64(match[1].replace(/\s+/g, ""));
55
+ };
56
+
57
+ // --- SEC1 → PKCS#8 transcoding ---------------------------------------------
58
+ // WebCrypto cannot import SEC1 "EC PRIVATE KEY" DER (RFC 5915) directly, but
59
+ // PKCS#8 is just an ASN.1 envelope around it:
60
+ // SEQUENCE { INTEGER 0, SEQUENCE { id-ecPublicKey, curveOID }, OCTET STRING <SEC1> }
61
+ // No cryptography involved — pure DER re-packaging.
62
+
63
+ const OID_EC_PUBLIC_KEY = [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
64
+ const CURVE_OIDS = {
65
+ "P-256": [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07],
66
+ "P-384": [0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22],
67
+ "P-521": [0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x23],
68
+ };
69
+
70
+ const derTlv = (tag, contents) => {
71
+ const len = contents.length;
72
+ let header;
73
+ if (len < 0x80) header = [tag, len];
74
+ else if (len < 0x100) header = [tag, 0x81, len];
75
+ else header = [tag, 0x82, len >> 8, len & 0xff];
76
+ return Uint8Array.from([...header, ...contents]);
77
+ };
78
+
79
+ // Minimal DER walk over the SEC1 ECPrivateKey to find the [0] curve
80
+ // parameters (an OID), when present.
81
+ const sec1CurveOid = (sec1) => {
82
+ if (sec1[0] !== 0x30) throw new Error("SEC1 key must start with a SEQUENCE");
83
+ let offset = 2;
84
+ if (sec1[1] & 0x80) offset = 2 + (sec1[1] & 0x7f);
85
+ while (offset < sec1.length) {
86
+ const tag = sec1[offset];
87
+ let len = sec1[offset + 1];
88
+ let headerLen = 2;
89
+ if (len & 0x80) {
90
+ const lenBytes = len & 0x7f;
91
+ len = 0;
92
+ for (let i = 0; i < lenBytes; i++) len = (len << 8) | sec1[offset + 2 + i];
93
+ headerLen = 2 + lenBytes;
94
+ }
95
+ if (tag === 0xa0) {
96
+ // [0] explicit: contents are the curve OID TLV
97
+ return [...sec1.slice(offset + headerLen, offset + headerLen + len)];
98
+ }
99
+ offset += headerLen + len;
100
+ }
101
+ return null;
102
+ };
103
+
104
+ /**
105
+ * Wrap SEC1 "EC PRIVATE KEY" DER bytes in a PKCS#8 envelope so WebCrypto can
106
+ * import them. The curve is read from the key's [0] parameters when present,
107
+ * else taken from `namedCurve` ("P-256" | "P-384" | "P-521").
108
+ * @param {Uint8Array} sec1 - DER bytes of the ECPrivateKey
109
+ * @param {string} [namedCurve] - fallback curve name
110
+ * @returns {Uint8Array} PKCS#8 DER bytes
111
+ */
112
+ export const sec1ToPkcs8 = (sec1, namedCurve) => {
113
+ const curveOid = sec1CurveOid(sec1) ?? CURVE_OIDS[namedCurve];
114
+ if (!curveOid) {
115
+ throw new Error(
116
+ "SEC1 key has no curve parameters; a namedCurve is required"
117
+ );
118
+ }
119
+ const algorithmId = derTlv(0x30, [...OID_EC_PUBLIC_KEY, ...curveOid]);
120
+ const version = Uint8Array.from([0x02, 0x01, 0x00]);
121
+ const privateKey = derTlv(0x04, sec1);
122
+ return derTlv(0x30, [...version, ...algorithmId, ...privateKey]);
123
+ };
124
+
125
+ /**
126
+ * Import key material for an algorithm.
127
+ * @param {string} alg - RFC 9421 algorithm name (e.g. "ed25519")
128
+ * @param {CryptoKey|object|string|Uint8Array} material - CryptoKey, JWK,
129
+ * PEM (PKCS#8/SPKI), or raw bytes (HMAC)
130
+ * @param {"sign"|"verify"} usage
131
+ * @returns {Promise<CryptoKey>}
132
+ */
133
+ export const importKey = async (alg, material, usage) => {
134
+ const spec = ALGORITHMS[alg];
135
+ if (!spec) throw new Error(`Unsupported algorithm: ${alg}`);
136
+
137
+ if (material && typeof material === "object" && material.type && material.algorithm) {
138
+ return material; // already a CryptoKey
139
+ }
140
+
141
+ if (material instanceof Uint8Array) {
142
+ if (alg !== "hmac-sha256") {
143
+ throw new Error("Raw byte keys are only supported for hmac-sha256");
144
+ }
145
+ return subtle.importKey("raw", material, spec.importParams, false, [
146
+ "sign",
147
+ "verify",
148
+ ]);
149
+ }
150
+
151
+ if (typeof material === "object") {
152
+ // JWK: private keys carry "d"
153
+ const usages =
154
+ alg === "hmac-sha256" ? ["sign", "verify"] : [material.d ? "sign" : "verify"];
155
+ return subtle.importKey("jwk", material, spec.importParams, false, usages);
156
+ }
157
+
158
+ if (typeof material === "string") {
159
+ // Encrypted keys are out of scope: decrypting them means PBES2/PBKDF2 key
160
+ // derivation, not DER re-packaging. Decrypt once with
161
+ // `openssl pkcs8 -topk8 -nocrypt` and pass the result instead.
162
+ if (
163
+ material.includes("ENCRYPTED PRIVATE KEY") ||
164
+ /Proc-Type:\s*4\s*,\s*ENCRYPTED/.test(material)
165
+ ) {
166
+ throw new Error(
167
+ "Encrypted private keys are not supported; decrypt first, e.g. " +
168
+ "`openssl pkcs8 -topk8 -nocrypt -in key.pem`"
169
+ );
170
+ }
171
+ const pkcs8 = pemToBytes(material, "PRIVATE KEY");
172
+ if (pkcs8) {
173
+ return subtle.importKey("pkcs8", pkcs8, spec.importParams, false, ["sign"]);
174
+ }
175
+ const sec1 = pemToBytes(material, "EC PRIVATE KEY");
176
+ if (sec1) {
177
+ const wrapped = sec1ToPkcs8(sec1, spec.importParams.namedCurve);
178
+ return subtle.importKey("pkcs8", wrapped, spec.importParams, false, ["sign"]);
179
+ }
180
+ const spki = pemToBytes(material, "PUBLIC KEY");
181
+ if (spki) {
182
+ return subtle.importKey("spki", spki, spec.importParams, false, ["verify"]);
183
+ }
184
+ throw new Error(
185
+ "PEM must contain a PKCS#8/SEC1 private key or SPKI PUBLIC KEY block"
186
+ );
187
+ }
188
+
189
+ throw new Error(`Unsupported key material for ${usage}`);
190
+ };
191
+
192
+ /**
193
+ * Sign a signature base with the given algorithm and key.
194
+ * @returns {Promise<Uint8Array>} raw signature bytes
195
+ */
196
+ export const signBase = async (alg, key, base) => {
197
+ const spec = ALGORITHMS[alg];
198
+ if (!spec) throw new Error(`Unsupported algorithm: ${alg}`);
199
+ const cryptoKey = await importKey(alg, key, "sign");
200
+ const data = new TextEncoder().encode(base);
201
+ const sig = await subtle.sign(spec.signParams, cryptoKey, data);
202
+ return new Uint8Array(sig);
203
+ };
204
+
205
+ /**
206
+ * Verify raw signature bytes over a signature base.
207
+ * @returns {Promise<boolean>}
208
+ */
209
+ export const verifyBase = async (alg, key, signature, base) => {
210
+ const spec = ALGORITHMS[alg];
211
+ if (!spec) throw new Error(`Unsupported algorithm: ${alg}`);
212
+ const cryptoKey = await importKey(alg, key, "verify");
213
+ const data = new TextEncoder().encode(base);
214
+ return subtle.verify(spec.signParams, cryptoKey, signature, data);
215
+ };
package/index.mjs ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @johnhenry/http-fields-signatures — RFC 9421 HTTP Message Signatures.
3
+ *
4
+ * Built on `@johnhenry/http-fields` (RFC 8941/9651 Structured Field Values) for all
5
+ * header parsing/serialization, and WebCrypto for the cryptography.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ import { parse, serialize, binary } from "@johnhenry/http-fields";
11
+ import {
12
+ createSignatureBase,
13
+ getHeaderValues,
14
+ normalizeComponent,
15
+ } from "./base.mjs";
16
+ import { ALGORITHMS, importKey, signBase, verifyBase } from "./crypto.mjs";
17
+
18
+ export { createSignatureBase, getHeaderValues } from "./base.mjs";
19
+ export {
20
+ ALGORITHMS,
21
+ importKey,
22
+ signBase,
23
+ verifyBase,
24
+ sec1ToPkcs8,
25
+ } from "./crypto.mjs";
26
+
27
+ const bytesToB64 = (bytes) => {
28
+ let bin = "";
29
+ for (const byte of bytes) bin += String.fromCharCode(byte);
30
+ return btoa(bin);
31
+ };
32
+
33
+ const b64ToBytes = (b64) => {
34
+ const bin = atob(b64);
35
+ const bytes = new Uint8Array(bin.length);
36
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
37
+ return bytes;
38
+ };
39
+
40
+ /**
41
+ * Parse `Signature-Input` and `Signature` header values into per-label
42
+ * entries: {label: {components: [{id, params}], params, signature?}}.
43
+ * Either argument may be undefined.
44
+ */
45
+ export const parseSignatureHeaders = (signatureInput, signature) => {
46
+ const entries = {};
47
+ if (signatureInput !== undefined) {
48
+ const dict = parse(signatureInput, "dictionary");
49
+ for (const [label, member] of Object.entries(dict)) {
50
+ if (!Array.isArray(member.value)) {
51
+ throw new Error(`Signature-Input member "${label}" must be an inner list`);
52
+ }
53
+ const components = member.value.map((item) => {
54
+ if (typeof item.value !== "string") {
55
+ throw new Error("Covered components must be strings");
56
+ }
57
+ return { id: item.value, params: item.parameters };
58
+ });
59
+ entries[label] = { components, params: member.parameters };
60
+ }
61
+ }
62
+ if (signature !== undefined) {
63
+ const dict = parse(signature, "dictionary");
64
+ for (const [label, member] of Object.entries(dict)) {
65
+ const v = member.value;
66
+ if (!v || v.type !== "binary") {
67
+ throw new Error(`Signature member "${label}" must be a byte sequence`);
68
+ }
69
+ entries[label] = entries[label] || {};
70
+ entries[label].signature = b64ToBytes(v.value);
71
+ }
72
+ }
73
+ return entries;
74
+ };
75
+
76
+ /**
77
+ * Sign an HTTP message (RFC 9421).
78
+ *
79
+ * @param {object} message - {method, url, headers} or {status, headers, request?}
80
+ * @param {object} options
81
+ * @param {string} options.alg - algorithm name (see ALGORITHMS)
82
+ * @param {CryptoKey|object|string|Uint8Array} options.key - signing key
83
+ * @param {Array<string|{id, params}>} [options.components=[]] - covered components
84
+ * @param {string} [options.label="sig1"] - signature label
85
+ * @param {string} [options.keyid]
86
+ * @param {number} [options.created=now] - unix seconds; pass null to omit
87
+ * @param {number} [options.expires]
88
+ * @param {string} [options.nonce]
89
+ * @param {string} [options.tag]
90
+ * @param {boolean} [options.includeAlg=false] - include the alg parameter
91
+ * @param {object} [options.fieldTypes] - field name → SFV type, for ;sf
92
+ * @returns {Promise<{signatureInput: string, signature: string, base: string,
93
+ * label: string}>} header values to add to the message
94
+ */
95
+ export const signMessage = async (message, options) => {
96
+ const {
97
+ alg,
98
+ key,
99
+ components = [],
100
+ label = "sig1",
101
+ keyid,
102
+ created = Math.floor(Date.now() / 1000),
103
+ expires,
104
+ nonce,
105
+ tag,
106
+ includeAlg = false,
107
+ fieldTypes,
108
+ } = options;
109
+
110
+ if (!ALGORITHMS[alg]) throw new Error(`Unsupported algorithm: ${alg}`);
111
+
112
+ // Parameter order follows the RFC 9421 examples: created, expires, alg,
113
+ // keyid, then nonce/tag
114
+ const params = {};
115
+ if (created !== null && created !== undefined) params.created = created;
116
+ if (expires !== undefined) params.expires = expires;
117
+ if (includeAlg) params.alg = alg;
118
+ if (keyid !== undefined) params.keyid = keyid;
119
+ if (nonce !== undefined) params.nonce = nonce;
120
+ if (tag !== undefined) params.tag = tag;
121
+
122
+ const { base } = createSignatureBase(message, components, params, { fieldTypes });
123
+ const signatureBytes = await signBase(alg, key, base);
124
+
125
+ const inner = components.map(normalizeComponent).map(({ id, params: p }) => ({
126
+ value: id.toLowerCase(),
127
+ parameters: p,
128
+ }));
129
+ const signatureInput = serialize(
130
+ { [label]: { value: inner, parameters: params } },
131
+ "dictionary"
132
+ );
133
+ const signature = serialize(
134
+ { [label]: { value: binary(bytesToB64(signatureBytes)), parameters: {} } },
135
+ "dictionary"
136
+ );
137
+
138
+ return { signatureInput, signature, base, label };
139
+ };
140
+
141
+ /**
142
+ * Verify the signatures on an HTTP message (RFC 9421).
143
+ *
144
+ * Reads `Signature-Input`/`Signature` from message.headers unless provided
145
+ * via options. Structural problems throw; cryptographic mismatch and policy
146
+ * failures report as verified:false with a reason.
147
+ *
148
+ * @param {object} message
149
+ * @param {object} options
150
+ * @param {(context: {label: string, keyid?: string, alg?: string}) =>
151
+ * (object|Promise<object>)} options.getKey - resolves {alg, key} (or just a
152
+ * key when the signature's alg parameter should be trusted) for a signature
153
+ * @param {string} [options.label] - verify only this label
154
+ * @param {string} [options.signatureInput] - override header lookup
155
+ * @param {string} [options.signature] - override header lookup
156
+ * @param {number} [options.now=now] - unix seconds for expires checking
157
+ * @param {object} [options.fieldTypes]
158
+ * @returns {Promise<Array<{label: string, verified: boolean, reason?: string,
159
+ * params: object, components: Array<{id, params}>}>>}
160
+ */
161
+ export const verifyMessage = async (message, options) => {
162
+ const {
163
+ getKey,
164
+ label: onlyLabel,
165
+ now = Math.floor(Date.now() / 1000),
166
+ fieldTypes,
167
+ } = options;
168
+
169
+ const signatureInput =
170
+ options.signatureInput ??
171
+ getHeaderValues(message.headers, "signature-input").join(", ");
172
+ const signature =
173
+ options.signature ?? getHeaderValues(message.headers, "signature").join(", ");
174
+ if (!signatureInput) throw new Error("No Signature-Input to verify");
175
+
176
+ const entries = parseSignatureHeaders(signatureInput, signature || undefined);
177
+ const results = [];
178
+
179
+ for (const [label, entry] of Object.entries(entries)) {
180
+ if (onlyLabel && label !== onlyLabel) continue;
181
+ if (!entry.components) {
182
+ results.push({
183
+ label,
184
+ verified: false,
185
+ reason: "No Signature-Input for this label",
186
+ params: {},
187
+ components: [],
188
+ });
189
+ continue;
190
+ }
191
+ const { components, params } = entry;
192
+ const result = { label, verified: false, params, components };
193
+ results.push(result);
194
+
195
+ if (!entry.signature) {
196
+ result.reason = "No Signature value for this label";
197
+ continue;
198
+ }
199
+ if (Number.isInteger(params.expires) && params.expires < now) {
200
+ result.reason = "Signature expired";
201
+ continue;
202
+ }
203
+
204
+ const resolved = await getKey({
205
+ label,
206
+ keyid: typeof params.keyid === "string" ? params.keyid : undefined,
207
+ alg: typeof params.alg === "string" ? params.alg : undefined,
208
+ });
209
+ if (!resolved) {
210
+ result.reason = "Key not found";
211
+ continue;
212
+ }
213
+ const alg = resolved.alg ?? params.alg;
214
+ const key = resolved.key ?? resolved;
215
+ if (!ALGORITHMS[alg]) {
216
+ result.reason = `Unsupported algorithm: ${alg}`;
217
+ continue;
218
+ }
219
+
220
+ const { base } = createSignatureBase(message, components, params, {
221
+ fieldTypes,
222
+ });
223
+ const ok = await verifyBase(alg, key, entry.signature, base);
224
+ result.verified = ok;
225
+ if (!ok) result.reason = "Signature mismatch";
226
+ }
227
+
228
+ if (onlyLabel && results.length === 0) {
229
+ throw new Error(`No signature labeled "${onlyLabel}" found`);
230
+ }
231
+ return results;
232
+ };
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@johnhenry/http-fields-signatures",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "RFC 9421 HTTP Message Signatures — sign and verify HTTP requests and responses, built on http-fields (RFC 8941/9651 Structured Field Values) and WebCrypto",
5
+ "main": "index.mjs",
6
+ "type": "module",
7
+ "types": "./types.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./types.d.ts",
11
+ "import": "./index.mjs"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "node --test test/*.mjs"
16
+ },
17
+ "keywords": [
18
+ "http",
19
+ "signatures",
20
+ "message-signatures",
21
+ "rfc9421",
22
+ "@johnhenry/http-fields",
23
+ "structured-fields",
24
+ "rfc8941",
25
+ "webcrypto",
26
+ "signing",
27
+ "verification",
28
+ "zero-dependencies"
29
+ ],
30
+ "author": "John Henry <john@iamjohnhenry.com>",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/johnhenry/http-fields-signatures.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/johnhenry/http-fields-signatures/issues"
38
+ },
39
+ "homepage": "https://github.com/johnhenry/http-fields-signatures#readme",
40
+ "files": [
41
+ "index.mjs",
42
+ "base.mjs",
43
+ "crypto.mjs",
44
+ "types.d.ts",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "engines": {
49
+ "node": ">=26.0.0"
50
+ },
51
+ "dependencies": {
52
+ "@johnhenry/http-fields": "^0.1.0"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ }
57
+ }
package/types.d.ts ADDED
@@ -0,0 +1,174 @@
1
+ // Type definitions for @johnhenry/http-fields-signatures
2
+ // Project: https://github.com/johnhenry/http-fields-signatures
3
+
4
+ /** HTTP request message (runtime-agnostic). */
5
+ export interface RequestMessage {
6
+ method: string;
7
+ /** Absolute URL. */
8
+ url: string;
9
+ headers: HeadersLike;
10
+ }
11
+
12
+ /** HTTP response message. `request` enables `;req`-flagged components. */
13
+ export interface ResponseMessage {
14
+ status: number;
15
+ headers: HeadersLike;
16
+ request?: RequestMessage;
17
+ }
18
+
19
+ export type Message = RequestMessage | ResponseMessage;
20
+
21
+ /** Plain object, entries array, or Fetch-style Headers. */
22
+ export type HeadersLike =
23
+ | Record<string, string | string[]>
24
+ | Array<[string, string]>
25
+ | { entries(): IterableIterator<[string, string]>; get(name: string): string | null };
26
+
27
+ /** Covered component: a bare id or an id with identifier parameters. */
28
+ export type ComponentSpec =
29
+ | string
30
+ | {
31
+ id: string;
32
+ params?: {
33
+ /** @query-param: the (re-encoded) parameter name. */
34
+ name?: string;
35
+ /** Field: select a dictionary member. */
36
+ key?: string;
37
+ /** Field: strict structured-field re-serialization. */
38
+ sf?: boolean;
39
+ /** Field: wrap raw value(s) as byte sequences. */
40
+ bs?: boolean;
41
+ /** Response signing: take the component from the related request. */
42
+ req?: boolean;
43
+ };
44
+ };
45
+
46
+ export interface Component {
47
+ id: string;
48
+ params: Record<string, unknown>;
49
+ }
50
+
51
+ /** created/expires/nonce/alg/keyid/tag, serialized in insertion order. */
52
+ export type SignatureParams = Record<string, unknown>;
53
+
54
+ export type FieldTypes = Record<string, "list" | "dictionary" | "item">;
55
+
56
+ /** JWK object, PEM string (PKCS#8/SPKI), raw bytes (HMAC), or a CryptoKey. */
57
+ export type KeyMaterial = CryptoKey | object | string | Uint8Array;
58
+
59
+ export type AlgorithmName =
60
+ | "rsa-pss-sha512"
61
+ | "rsa-v1_5-sha256"
62
+ | "ecdsa-p256-sha256"
63
+ | "ecdsa-p384-sha384"
64
+ | "ed25519"
65
+ | "hmac-sha256";
66
+
67
+ export declare const ALGORITHMS: Record<
68
+ AlgorithmName,
69
+ { importParams: object; signParams: object }
70
+ >;
71
+
72
+ export declare function createSignatureBase(
73
+ message: Message,
74
+ components: ComponentSpec[],
75
+ signatureParams: SignatureParams,
76
+ options?: { fieldTypes?: FieldTypes }
77
+ ): { base: string; signatureParamsValue: string };
78
+
79
+ export declare function getHeaderValues(
80
+ headers: HeadersLike,
81
+ name: string
82
+ ): string[];
83
+
84
+ /**
85
+ * Wrap SEC1 "EC PRIVATE KEY" DER bytes in a PKCS#8 envelope so WebCrypto can
86
+ * import them. Pure ASN.1 re-packaging; no cryptography involved.
87
+ */
88
+ export declare function sec1ToPkcs8(
89
+ sec1: Uint8Array,
90
+ namedCurve?: "P-256" | "P-384" | "P-521"
91
+ ): Uint8Array;
92
+
93
+ export declare function importKey(
94
+ alg: AlgorithmName,
95
+ material: KeyMaterial,
96
+ usage: "sign" | "verify"
97
+ ): Promise<CryptoKey>;
98
+
99
+ export declare function signBase(
100
+ alg: AlgorithmName,
101
+ key: KeyMaterial,
102
+ base: string
103
+ ): Promise<Uint8Array>;
104
+
105
+ export declare function verifyBase(
106
+ alg: AlgorithmName,
107
+ key: KeyMaterial,
108
+ signature: Uint8Array,
109
+ base: string
110
+ ): Promise<boolean>;
111
+
112
+ export declare function parseSignatureHeaders(
113
+ signatureInput?: string,
114
+ signature?: string
115
+ ): Record<
116
+ string,
117
+ { components?: Component[]; params?: SignatureParams; signature?: Uint8Array }
118
+ >;
119
+
120
+ export interface SignOptions {
121
+ alg: AlgorithmName;
122
+ key: KeyMaterial;
123
+ components?: ComponentSpec[];
124
+ label?: string;
125
+ keyid?: string;
126
+ /** Unix seconds; defaults to now. Pass null to omit. */
127
+ created?: number | null;
128
+ expires?: number;
129
+ nonce?: string;
130
+ tag?: string;
131
+ /** Include the alg parameter in Signature-Input (default false). */
132
+ includeAlg?: boolean;
133
+ fieldTypes?: FieldTypes;
134
+ }
135
+
136
+ export declare function signMessage(
137
+ message: Message,
138
+ options: SignOptions
139
+ ): Promise<{
140
+ signatureInput: string;
141
+ signature: string;
142
+ base: string;
143
+ label: string;
144
+ }>;
145
+
146
+ export interface VerifyResult {
147
+ label: string;
148
+ verified: boolean;
149
+ reason?: string;
150
+ params: SignatureParams;
151
+ components: Component[];
152
+ }
153
+
154
+ export interface VerifyOptions {
155
+ getKey: (context: {
156
+ label: string;
157
+ keyid?: string;
158
+ alg?: string;
159
+ }) =>
160
+ | KeyMaterial
161
+ | { alg?: AlgorithmName; key: KeyMaterial }
162
+ | Promise<KeyMaterial | { alg?: AlgorithmName; key: KeyMaterial }>;
163
+ label?: string;
164
+ signatureInput?: string;
165
+ signature?: string;
166
+ /** Unix seconds used for expires checking; defaults to now. */
167
+ now?: number;
168
+ fieldTypes?: FieldTypes;
169
+ }
170
+
171
+ export declare function verifyMessage(
172
+ message: Message,
173
+ options: VerifyOptions
174
+ ): Promise<VerifyResult[]>;