@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 +21 -0
- package/README.md +173 -2
- package/base.mjs +236 -0
- package/crypto.mjs +215 -0
- package/index.mjs +232 -0
- package/package.json +55 -4
- package/types.d.ts +174 -0
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
|
-
#
|
|
1
|
+
# @johnhenry/http-fields-signatures
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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[]>;
|