@blamejs/pki 0.1.23 → 0.1.25
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/CHANGELOG.md +28 -1
- package/README.md +5 -1
- package/index.js +18 -0
- package/lib/acme.js +1159 -0
- package/lib/constants.js +10 -0
- package/lib/est.js +717 -0
- package/lib/framework-error.js +35 -0
- package/lib/jose.js +616 -0
- package/lib/oid.js +8 -2
- package/lib/schema-all.js +16 -0
- package/lib/schema-csr.js +17 -3
- package/lib/schema-csrattrs.js +371 -0
- package/lib/schema-pkix.js +45 -5
- package/lib/webcrypto.js +5 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/framework-error.js
CHANGED
|
@@ -219,6 +219,37 @@ var SmimeError = defineClass("SmimeError", { withCause: true });
|
|
|
219
219
|
// inner `asn1/*` decode error) as `.cause`.
|
|
220
220
|
var CtError = defineClass("CtError", { withCause: true });
|
|
221
221
|
|
|
222
|
+
// CsrattrsError -- a byte sequence that is not a well-formed RFC 8951 sec. 3.5
|
|
223
|
+
// CsrAttrs (SEQUENCE OF AttrOrOID), or one violating an RFC 9908 semantic rule
|
|
224
|
+
// (a repeated id-ExtensionReq, an extension-request values SET that is not
|
|
225
|
+
// exactly one Extensions, a template version other than v1(0), a mixed
|
|
226
|
+
// extension-request template). Carries the underlying leaf fault as `.cause`.
|
|
227
|
+
var CsrattrsError = defineClass("CsrattrsError", { withCause: true });
|
|
228
|
+
|
|
229
|
+
// EstError -- an RFC 7030 / 8951 / 9908 Enrollment-over-Secure-Transport
|
|
230
|
+
// protocol fault: a payload that fails a per-operation validator (a non-certs-
|
|
231
|
+
// only /cacerts response, a serverkeygen recipient-arm mismatch), a transfer /
|
|
232
|
+
// multipart framing violation, or an HTTP response the classifier rejects.
|
|
233
|
+
// Carries the underlying leaf / delegated fault (e.g. a cms/* or asn1/*) as
|
|
234
|
+
// `.cause`.
|
|
235
|
+
var EstError = defineClass("EstError", { withCause: true });
|
|
236
|
+
|
|
237
|
+
// JoseError -- a JOSE (RFC 7515 JWS / RFC 7518 JWA / RFC 7638 thumbprint)
|
|
238
|
+
// fault: a non-canonical base64url field, a duplicate JSON member, a
|
|
239
|
+
// bounds violation, a header that fails its profile (alg/nonce/url/jwk-kid),
|
|
240
|
+
// an unknown or MAC algorithm on the outer profile, a signature of the wrong
|
|
241
|
+
// length for its alg, or a verify failure. Carries the underlying leaf fault
|
|
242
|
+
// (a webcrypto / asn1 error) as `.cause`.
|
|
243
|
+
var JoseError = defineClass("JoseError", { withCause: true });
|
|
244
|
+
|
|
245
|
+
// AcmeError -- an RFC 8555 / 8737 / 8738 / 9773 ACME message-layer fault: a
|
|
246
|
+
// resource object that fails its spec (bad status enum, a missing
|
|
247
|
+
// conditionally-required field, an immutable field mutated), an illegal state
|
|
248
|
+
// transition, an identifier/token/nonce that fails validation, a CSR whose
|
|
249
|
+
// identifiers or key do not match the order, or a renewal window inversion.
|
|
250
|
+
// Carries the delegated jose/* or a DER leaf fault as `.cause`.
|
|
251
|
+
var AcmeError = defineClass("AcmeError", { withCause: true });
|
|
252
|
+
|
|
222
253
|
module.exports = {
|
|
223
254
|
PkiError: PkiError,
|
|
224
255
|
defineClass: defineClass,
|
|
@@ -241,4 +272,8 @@ module.exports = {
|
|
|
241
272
|
PathError: PathError,
|
|
242
273
|
CtError: CtError,
|
|
243
274
|
SmimeError: SmimeError,
|
|
275
|
+
CsrattrsError: CsrattrsError,
|
|
276
|
+
EstError: EstError,
|
|
277
|
+
JoseError: JoseError,
|
|
278
|
+
AcmeError: AcmeError,
|
|
244
279
|
};
|
package/lib/jose.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.jose
|
|
6
|
+
* @nav Protocols
|
|
7
|
+
* @title JOSE (JWS / JWK)
|
|
8
|
+
* @order 10
|
|
9
|
+
* @slug jose
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* The JOSE message envelope (RFC 7515 JWS, RFC 7518 JWA, RFC 7638 JWK
|
|
13
|
+
* thumbprint), profiled for RFC 8555 ACME but usable on its own. A JWS here
|
|
14
|
+
* is the Flattened JSON Serialization only -- `{ protected, payload,
|
|
15
|
+
* signature }` -- with the multi-signature `signatures` member, the
|
|
16
|
+
* unprotected `header` member, and the RFC 7797 unencoded-payload `b64`
|
|
17
|
+
* option all structurally forbidden. Every base64url field is decoded by a
|
|
18
|
+
* STRICT codec (Node's `Buffer.from(s, "base64url")` accepts padding,
|
|
19
|
+
* whitespace, and non-canonical trailing bits -- all of which are rejected
|
|
20
|
+
* here), and every JSON document is read by a bounded reader that rejects a
|
|
21
|
+
* duplicate member at any nesting depth (the parser-differential smuggling
|
|
22
|
+
* class `JSON.parse` silently allows).
|
|
23
|
+
*
|
|
24
|
+
* Algorithms resolve through an `alg`-keyed registry (ES256/384/512,
|
|
25
|
+
* RS256/384/512, PS256/384/512, EdDSA, and the RFC 9964 ML-DSA-44/65/87 PQC
|
|
26
|
+
* rows), never a switch: the registry binds `alg` to a key type and pins the
|
|
27
|
+
* exact signature byte length BEFORE any crypto call, so `alg:"none"`, a MAC
|
|
28
|
+
* algorithm on the outer profile, an ES256/RSA-key confusion, and a DER-vs-raw
|
|
29
|
+
* ECDSA signature all fail closed. `sign` and `verify` are driven by ONE
|
|
30
|
+
* declarative profile table each -- the same data drives both directions.
|
|
31
|
+
*
|
|
32
|
+
* @card
|
|
33
|
+
* RFC 7515 Flattened JWS sign/verify + RFC 7638 JWK thumbprints, ACME-profiled:
|
|
34
|
+
* strict base64url + duplicate-rejecting JSON, an alg registry (ES/RS/PS/EdDSA
|
|
35
|
+
* + ML-DSA), signature-length pinning, fail-closed everywhere.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
var constants = require("./constants");
|
|
39
|
+
var webcrypto = require("./webcrypto").webcrypto;
|
|
40
|
+
var frameworkError = require("./framework-error");
|
|
41
|
+
|
|
42
|
+
var JoseError = frameworkError.JoseError;
|
|
43
|
+
function E(code, message, cause) { return new JoseError(code, message, cause); }
|
|
44
|
+
|
|
45
|
+
var LIMITS = constants.LIMITS;
|
|
46
|
+
|
|
47
|
+
// ---- strict base64url codec (RFC 7515 sec. 2 / RFC 4648 sec. 5) ----------
|
|
48
|
+
|
|
49
|
+
var B64U_ALPHABET = /^[A-Za-z0-9_-]*$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @primitive pki.jose.base64url.encode
|
|
53
|
+
* @signature pki.jose.base64url.encode(bytes) -> string
|
|
54
|
+
* @since 0.1.25
|
|
55
|
+
* @status experimental
|
|
56
|
+
* @spec RFC 7515, RFC 4648
|
|
57
|
+
* @related pki.jose.base64url.decode
|
|
58
|
+
*
|
|
59
|
+
* Encode a `Buffer` as unpadded base64url (RFC 4648 sec. 5): the `+`/`/`
|
|
60
|
+
* characters become `-`/`_` and the trailing `=` padding is omitted.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* pki.jose.base64url.encode(Buffer.from([1, 2, 3])); // -> "AQID"
|
|
64
|
+
*/
|
|
65
|
+
function b64uEncode(bytes) {
|
|
66
|
+
if (!Buffer.isBuffer(bytes)) throw E("jose/bad-input", "base64url.encode requires a Buffer");
|
|
67
|
+
return bytes.toString("base64url");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @primitive pki.jose.base64url.decode
|
|
72
|
+
* @signature pki.jose.base64url.decode(text) -> Buffer
|
|
73
|
+
* @since 0.1.25
|
|
74
|
+
* @status experimental
|
|
75
|
+
* @spec RFC 7515, RFC 4648, RFC 8555
|
|
76
|
+
* @related pki.jose.base64url.encode
|
|
77
|
+
*
|
|
78
|
+
* Decode base64url to a `Buffer`, STRICTLY (RFC 8555 sec. 6.1): trailing `=`
|
|
79
|
+
* padding, any non-alphabet character (`+`, `/`, whitespace), and a
|
|
80
|
+
* non-canonical final character (one whose discarded low bits are non-zero)
|
|
81
|
+
* each throw `jose/bad-base64url`. The canonical check is a re-encode round-trip.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* pki.jose.base64url.decode("AQID"); // -> <Buffer 01 02 03>
|
|
85
|
+
*/
|
|
86
|
+
function b64uDecode(text) {
|
|
87
|
+
if (typeof text !== "string") throw E("jose/bad-base64url", "base64url.decode requires a string");
|
|
88
|
+
if (!B64U_ALPHABET.test(text)) throw E("jose/bad-base64url", "value is not base64url (padding or a non-alphabet character)");
|
|
89
|
+
// length % 4 === 1 is not a possible base64 encoding length.
|
|
90
|
+
if (text.length % 4 === 1) throw E("jose/bad-base64url", "base64url value has an impossible length");
|
|
91
|
+
var buf = Buffer.from(text, "base64url");
|
|
92
|
+
// Re-encode: a non-canonical final character (non-zero discarded bits) yields
|
|
93
|
+
// a different canonical form, so the round-trip fails closed.
|
|
94
|
+
if (buf.toString("base64url") !== text) throw E("jose/bad-base64url", "base64url value is not canonical");
|
|
95
|
+
return buf;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- strict bounded JSON reader ------------------------------------------
|
|
99
|
+
|
|
100
|
+
// A single hand-written recursive-descent reader (never JSON.parse, which
|
|
101
|
+
// silently takes the LAST of a duplicate member -- the smuggling class). It
|
|
102
|
+
// enforces the byte-size cap, the depth cap, and duplicate-member rejection at
|
|
103
|
+
// EVERY nesting level, and reads UTF-8 strictly. Returns the parsed value.
|
|
104
|
+
function _jsonReader(str) {
|
|
105
|
+
var i = 0, n = str.length;
|
|
106
|
+
function ws() { while (i < n) { var c = str.charCodeAt(i); if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++; else break; } }
|
|
107
|
+
function fail(msg) { throw E("jose/bad-json", "invalid JSON at offset " + i + ": " + msg); }
|
|
108
|
+
function value(depth) {
|
|
109
|
+
if (depth > LIMITS.JSON_MAX_DEPTH) throw E("jose/too-deep", "JSON nesting exceeds the depth cap");
|
|
110
|
+
ws();
|
|
111
|
+
if (i >= n) fail("unexpected end of input");
|
|
112
|
+
var c = str[i];
|
|
113
|
+
if (c === "{") return object(depth);
|
|
114
|
+
if (c === "[") return array(depth);
|
|
115
|
+
if (c === "\"") return string();
|
|
116
|
+
if (c === "-" || (c >= "0" && c <= "9")) return number();
|
|
117
|
+
if (str.substr(i, 4) === "true") { i += 4; return true; }
|
|
118
|
+
if (str.substr(i, 5) === "false") { i += 5; return false; }
|
|
119
|
+
if (str.substr(i, 4) === "null") { i += 4; return null; }
|
|
120
|
+
fail("unexpected token");
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
function object(depth) {
|
|
124
|
+
i++; // {
|
|
125
|
+
var out = {};
|
|
126
|
+
ws();
|
|
127
|
+
if (str[i] === "}") { i++; return out; }
|
|
128
|
+
for (;;) {
|
|
129
|
+
ws();
|
|
130
|
+
if (str[i] !== "\"") fail("expected a string key");
|
|
131
|
+
var key = string();
|
|
132
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) throw E("jose/duplicate-member", "duplicate JSON member " + JSON.stringify(key));
|
|
133
|
+
ws();
|
|
134
|
+
if (str[i] !== ":") fail("expected ':'");
|
|
135
|
+
i++;
|
|
136
|
+
// Assign as an OWN data property via defineProperty (not out[key] = ...): a
|
|
137
|
+
// "__proto__" key must become a normal own member, not mutate the object's
|
|
138
|
+
// prototype. A bare `out["__proto__"] = v` would pollute the returned object
|
|
139
|
+
// AND (for a primitive v) create no own property, silently defeating the
|
|
140
|
+
// duplicate-member gate above on a repeated __proto__.
|
|
141
|
+
Object.defineProperty(out, key, { value: value(depth + 1), writable: true, enumerable: true, configurable: true });
|
|
142
|
+
ws();
|
|
143
|
+
if (str[i] === ",") { i++; continue; }
|
|
144
|
+
if (str[i] === "}") { i++; return out; }
|
|
145
|
+
fail("expected ',' or '}'");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function array(depth) {
|
|
149
|
+
i++; // [
|
|
150
|
+
var out = [];
|
|
151
|
+
ws();
|
|
152
|
+
if (str[i] === "]") { i++; return out; }
|
|
153
|
+
for (;;) {
|
|
154
|
+
out.push(value(depth + 1));
|
|
155
|
+
ws();
|
|
156
|
+
if (str[i] === ",") { i++; continue; }
|
|
157
|
+
if (str[i] === "]") { i++; return out; }
|
|
158
|
+
fail("expected ',' or ']'");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function string() {
|
|
162
|
+
i++; // opening quote
|
|
163
|
+
var s = "";
|
|
164
|
+
for (;;) {
|
|
165
|
+
if (i >= n) fail("unterminated string");
|
|
166
|
+
var c = str[i++];
|
|
167
|
+
if (c === "\"") return s;
|
|
168
|
+
if (c === "\\") {
|
|
169
|
+
if (i >= n) fail("unterminated escape");
|
|
170
|
+
var e = str[i++];
|
|
171
|
+
if (e === "\"") s += "\"";
|
|
172
|
+
else if (e === "\\") s += "\\";
|
|
173
|
+
else if (e === "/") s += "/";
|
|
174
|
+
else if (e === "b") s += "\b";
|
|
175
|
+
else if (e === "f") s += "\f";
|
|
176
|
+
else if (e === "n") s += "\n";
|
|
177
|
+
else if (e === "r") s += "\r";
|
|
178
|
+
else if (e === "t") s += "\t";
|
|
179
|
+
else if (e === "u") {
|
|
180
|
+
var hex = str.substr(i, 4);
|
|
181
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail("bad \\u escape");
|
|
182
|
+
s += String.fromCharCode(parseInt(hex, 16));
|
|
183
|
+
i += 4;
|
|
184
|
+
} else fail("bad escape");
|
|
185
|
+
} else if (c.charCodeAt(0) < 0x20) {
|
|
186
|
+
fail("control character in string");
|
|
187
|
+
} else s += c;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function number() {
|
|
191
|
+
var start = i;
|
|
192
|
+
if (str[i] === "-") i++;
|
|
193
|
+
while (i < n && str[i] >= "0" && str[i] <= "9") i++;
|
|
194
|
+
if (str[i] === ".") { i++; while (i < n && str[i] >= "0" && str[i] <= "9") i++; }
|
|
195
|
+
if (str[i] === "e" || str[i] === "E") { i++; if (str[i] === "+" || str[i] === "-") i++; while (i < n && str[i] >= "0" && str[i] <= "9") i++; }
|
|
196
|
+
var tok = str.slice(start, i);
|
|
197
|
+
// Enforce the RFC 8259 number grammar the greedy scan does not: no leading zero
|
|
198
|
+
// ("01"), a fraction and an exponent each need at least one digit ("1.", "1e",
|
|
199
|
+
// "1.e1"), and a bare "-" is not a number. A lenient Number() would accept these
|
|
200
|
+
// and reintroduce a parser differential against a strict JSON reader.
|
|
201
|
+
if (!/^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/.test(tok)) fail("malformed number");
|
|
202
|
+
var v = Number(tok);
|
|
203
|
+
if (!isFinite(v)) fail("bad number");
|
|
204
|
+
return v;
|
|
205
|
+
}
|
|
206
|
+
var result = value(0);
|
|
207
|
+
ws();
|
|
208
|
+
if (i !== n) fail("trailing content after JSON value");
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @primitive pki.jose.parseJson
|
|
214
|
+
* @signature pki.jose.parseJson(input) -> value
|
|
215
|
+
* @since 0.1.25
|
|
216
|
+
* @status experimental
|
|
217
|
+
* @spec RFC 7515, RFC 8259
|
|
218
|
+
* @related pki.jose.base64url.decode
|
|
219
|
+
*
|
|
220
|
+
* Parse a JSON document (a `Buffer` or a string) with a bounded, strict reader:
|
|
221
|
+
* the byte size is capped (`jose/too-large`), nesting is capped
|
|
222
|
+
* (`jose/too-deep`), a duplicate member at ANY depth is rejected
|
|
223
|
+
* (`jose/duplicate-member`), and a `Buffer` is decoded as strict UTF-8 (invalid
|
|
224
|
+
* bytes throw). Unlike `JSON.parse`, a duplicate key never silently resolves to
|
|
225
|
+
* the last value.
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* pki.jose.parseJson('{"a":1}'); // -> { a: 1 }
|
|
229
|
+
*/
|
|
230
|
+
function parseJson(input) {
|
|
231
|
+
var str;
|
|
232
|
+
if (Buffer.isBuffer(input)) {
|
|
233
|
+
if (input.length > LIMITS.JSON_MAX_BYTES) throw E("jose/too-large", "the JSON document exceeds the size cap");
|
|
234
|
+
try { str = new TextDecoder("utf-8", { fatal: true }).decode(input); }
|
|
235
|
+
catch (e) { throw E("jose/bad-json", "the JSON document is not valid UTF-8", e); }
|
|
236
|
+
} else if (typeof input === "string") {
|
|
237
|
+
if (Buffer.byteLength(input, "utf8") > LIMITS.JSON_MAX_BYTES) throw E("jose/too-large", "the JSON document exceeds the size cap");
|
|
238
|
+
str = input;
|
|
239
|
+
} else {
|
|
240
|
+
throw E("jose/bad-input", "parseJson requires a Buffer or string");
|
|
241
|
+
}
|
|
242
|
+
return _jsonReader(str);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- JOSE algorithm registry (RFC 7518 / 8037 / 9964) --------------------
|
|
246
|
+
|
|
247
|
+
// One data row per JWS `alg`, keyed for O(1) resolution -- never a switch. Each
|
|
248
|
+
// row binds the alg to its JWK key type (and, for EC/OKP, the exact curve), the
|
|
249
|
+
// hash, and the WebCrypto import/verify parameters, plus the exact signature
|
|
250
|
+
// byte length (fixed for EC / EdDSA / ML-DSA; the RSA modulus length for RS*/PS*,
|
|
251
|
+
// resolved from the key). Binding alg->kty in DATA is what kills the RS256->HS256
|
|
252
|
+
// key-confusion class: an ES256 header with an RSA key is rejected here, before
|
|
253
|
+
// any crypto call. `HS*`/`none` are absent from this table by construction.
|
|
254
|
+
var SIG_ALGS = {
|
|
255
|
+
ES256: { kty: "EC", crv: "P-256", hash: "SHA-256", subtle: "ECDSA", sigBytes: 64 },
|
|
256
|
+
ES384: { kty: "EC", crv: "P-384", hash: "SHA-384", subtle: "ECDSA", sigBytes: 96 },
|
|
257
|
+
ES512: { kty: "EC", crv: "P-521", hash: "SHA-512", subtle: "ECDSA", sigBytes: 132 },
|
|
258
|
+
RS256: { kty: "RSA", hash: "SHA-256", subtle: "RSASSA-PKCS1-V1_5" },
|
|
259
|
+
RS384: { kty: "RSA", hash: "SHA-384", subtle: "RSASSA-PKCS1-V1_5" },
|
|
260
|
+
RS512: { kty: "RSA", hash: "SHA-512", subtle: "RSASSA-PKCS1-V1_5" },
|
|
261
|
+
PS256: { kty: "RSA", hash: "SHA-256", subtle: "RSA-PSS", saltLength: 32 },
|
|
262
|
+
PS384: { kty: "RSA", hash: "SHA-384", subtle: "RSA-PSS", saltLength: 48 },
|
|
263
|
+
PS512: { kty: "RSA", hash: "SHA-512", subtle: "RSA-PSS", saltLength: 64 },
|
|
264
|
+
EdDSA: { kty: "OKP", subtle: "EdDSA", sigBytesByCrv: { Ed25519: 64, Ed448: 114 } },
|
|
265
|
+
"ML-DSA-44": { kty: "AKP", subtle: "ML-DSA-44", sigBytes: 2420 },
|
|
266
|
+
"ML-DSA-65": { kty: "AKP", subtle: "ML-DSA-65", sigBytes: 3309 },
|
|
267
|
+
"ML-DSA-87": { kty: "AKP", subtle: "ML-DSA-87", sigBytes: 4627 },
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// The MAC table exists ONLY for the RFC 8555 sec. 7.3.4 externalAccountBinding
|
|
271
|
+
// inner JWS -- HS* appears nowhere in the outer/signature profiles, so an
|
|
272
|
+
// `alg:"HS256"` on an ACME request rejects (the RS256->HS256 downgrade class).
|
|
273
|
+
var MAC_ALGS = {
|
|
274
|
+
HS256: { kty: "oct", hash: "SHA-256", sigBytes: 32 },
|
|
275
|
+
HS384: { kty: "oct", hash: "SHA-384", sigBytes: 48 },
|
|
276
|
+
HS512: { kty: "oct", hash: "SHA-512", sigBytes: 64 },
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// The header parameter names this implementation understands. A `crit` naming
|
|
280
|
+
// anything outside this set is unprocessable -> the JWS is invalid (RFC 7515
|
|
281
|
+
// sec. 4.1.11); ACME defines no extension params, so any `crit` entry rejects.
|
|
282
|
+
var UNDERSTOOD_HEADER = { alg: 1, nonce: 1, url: 1, jwk: 1, kid: 1, crit: 1 };
|
|
283
|
+
|
|
284
|
+
// ---- JWS profiles (declarative -- one table drives sign AND verify) ------
|
|
285
|
+
|
|
286
|
+
// Each profile is DATA: the algorithm table it permits, the required params,
|
|
287
|
+
// whether `nonce` is required/forbidden, and the key-identification rule
|
|
288
|
+
// (exactly-one-of jwk/kid, or one specifically). The profile carries the rule,
|
|
289
|
+
// not the code path -- so the EAB-inner table ACCEPTS HS256 where the ACME-outer
|
|
290
|
+
// table REJECTS it, from the same walker.
|
|
291
|
+
var PROFILES = {
|
|
292
|
+
"acme-outer": { algs: SIG_ALGS, nonce: "required", keyId: "one-of", requireUrl: true },
|
|
293
|
+
"eab-inner": { algs: MAC_ALGS, nonce: "forbidden", keyId: "kid", requireUrl: true },
|
|
294
|
+
"keychange-inner": { algs: SIG_ALGS, nonce: "forbidden", keyId: "jwk", requireUrl: true },
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Validate a decoded protected header against a profile. Returns the resolved
|
|
298
|
+
// { alg, algRow, header }; throws a typed jose/* fault on any violation.
|
|
299
|
+
function _checkHeader(header, profileName) {
|
|
300
|
+
var profile = PROFILES[profileName];
|
|
301
|
+
if (!profile) throw E("jose/bad-input", "unknown JWS profile " + JSON.stringify(profileName));
|
|
302
|
+
if (!header || typeof header !== "object" || Array.isArray(header)) throw E("jose/bad-header", "the protected header must be a JSON object");
|
|
303
|
+
// Every field is read as an OWN property. JSON.stringify (used by sign) omits
|
|
304
|
+
// inherited members, so an alg/nonce/url living on the prototype (a polluted or
|
|
305
|
+
// Object.create(...) header) would pass here yet serialize to a header MISSING
|
|
306
|
+
// them -- so require own-ness, matching what the serialized JWS will actually carry.
|
|
307
|
+
var own = function (k) { return Object.prototype.hasOwnProperty.call(header, k); };
|
|
308
|
+
// b64 (RFC 7797) MUST NOT be present at all -- the unencoded-payload downgrade.
|
|
309
|
+
if (own("b64")) throw E("jose/bad-jws", "the RFC 7797 b64 header parameter is forbidden");
|
|
310
|
+
var alg = header.alg;
|
|
311
|
+
if (!own("alg") || typeof alg !== "string") throw E("jose/bad-header", "the protected header must carry a string alg");
|
|
312
|
+
var algRow = profile.algs[alg];
|
|
313
|
+
if (!algRow) throw E("jose/bad-alg", "unsupported or forbidden algorithm " + JSON.stringify(alg) + " for this JWS profile");
|
|
314
|
+
if (profile.requireUrl && (!own("url") || typeof header.url !== "string" || header.url.length === 0)) throw E("jose/bad-header", "the protected header must carry a non-empty string url");
|
|
315
|
+
// nonce discipline.
|
|
316
|
+
var hasNonce = own("nonce");
|
|
317
|
+
if (profile.nonce === "required") {
|
|
318
|
+
// A Replay-Nonce is 1*base64url -- NON-empty (RFC 8555 sec. 6.5.2); "" decodes as
|
|
319
|
+
// canonical base64url but no server issues it, so reject it locally.
|
|
320
|
+
if (!hasNonce || typeof header.nonce !== "string" || header.nonce.length === 0) throw E("jose/bad-header", "the protected header must carry a non-empty nonce");
|
|
321
|
+
// a nonce that is not valid base64url is malformed (RFC 8555 sec. 6.5.2).
|
|
322
|
+
try { b64uDecode(header.nonce); } catch (e) { throw E("jose/bad-header", "the nonce is not valid base64url", e); }
|
|
323
|
+
} else if (profile.nonce === "forbidden" && hasNonce) {
|
|
324
|
+
throw E("jose/bad-header", "a nonce is forbidden in this inner JWS");
|
|
325
|
+
}
|
|
326
|
+
// key identification.
|
|
327
|
+
var hasJwk = own("jwk");
|
|
328
|
+
var hasKid = own("kid");
|
|
329
|
+
// A kid is a non-empty account URL string.
|
|
330
|
+
if (hasKid && (typeof header.kid !== "string" || header.kid.length === 0)) throw E("jose/bad-header", "kid must be a non-empty string");
|
|
331
|
+
if (profile.keyId === "one-of") {
|
|
332
|
+
if (hasJwk === hasKid) throw E("jose/bad-header", "the protected header must carry EXACTLY ONE of jwk / kid");
|
|
333
|
+
} else if (profile.keyId === "kid") {
|
|
334
|
+
if (!hasKid || hasJwk) throw E("jose/bad-header", "this JWS must identify its key by kid, not jwk");
|
|
335
|
+
} else if (profile.keyId === "jwk") {
|
|
336
|
+
if (!hasJwk || hasKid) throw E("jose/bad-header", "this JWS must carry an embedded jwk, not kid");
|
|
337
|
+
}
|
|
338
|
+
if (hasJwk && (!header.jwk || typeof header.jwk !== "object" || Array.isArray(header.jwk))) throw E("jose/bad-header", "jwk must be a JSON object");
|
|
339
|
+
// crit: protected-only, non-empty, no standard names, no duplicates, all understood.
|
|
340
|
+
if (Object.prototype.hasOwnProperty.call(header, "crit")) {
|
|
341
|
+
var crit = header.crit;
|
|
342
|
+
if (!Array.isArray(crit) || crit.length === 0) throw E("jose/bad-crit", "crit must be a non-empty array");
|
|
343
|
+
var seen = {};
|
|
344
|
+
for (var c = 0; c < crit.length; c++) {
|
|
345
|
+
var name = crit[c];
|
|
346
|
+
if (typeof name !== "string") throw E("jose/bad-crit", "crit entries must be strings");
|
|
347
|
+
if (Object.prototype.hasOwnProperty.call(seen, name)) throw E("jose/bad-crit", "duplicate crit entry " + JSON.stringify(name));
|
|
348
|
+
seen[name] = 1;
|
|
349
|
+
if (UNDERSTOOD_HEADER[name]) throw E("jose/bad-crit", "crit must not name a standard header parameter " + JSON.stringify(name));
|
|
350
|
+
// Anything else is an extension parameter this implementation does not process.
|
|
351
|
+
throw E("jose/bad-crit", "unprocessed critical header parameter " + JSON.stringify(name));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { alg: alg, algRow: algRow, header: header };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- flattened-JWS verify (RFC 7515 sec. 5.2) ----------------------------
|
|
358
|
+
|
|
359
|
+
// Resolve the WebCrypto import parameters for a JWK under an alg row.
|
|
360
|
+
function _importParams(algRow, jwk) {
|
|
361
|
+
if (algRow.kty === "EC") return { name: "ECDSA", namedCurve: algRow.crv };
|
|
362
|
+
if (algRow.kty === "RSA") return { name: algRow.subtle, hash: algRow.hash };
|
|
363
|
+
if (algRow.kty === "OKP") return { name: jwk.crv }; // Ed25519 / Ed448
|
|
364
|
+
if (algRow.kty === "AKP") return { name: algRow.subtle }; // ML-DSA-44/65/87
|
|
365
|
+
if (algRow.kty === "oct") return { name: "HMAC", hash: algRow.hash }; // EAB inner (HS*)
|
|
366
|
+
throw E("jose/bad-alg", "unsupported key type");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The WebCrypto verify/sign algorithm for an alg row + JWK.
|
|
370
|
+
function _cryptoAlg(algRow, jwk, key) {
|
|
371
|
+
if (algRow.kty === "EC") return { name: "ECDSA", hash: algRow.hash };
|
|
372
|
+
if (algRow.kty === "RSA") return algRow.subtle === "RSA-PSS" ? { name: "RSA-PSS", saltLength: algRow.saltLength } : { name: "RSASSA-PKCS1-V1_5" };
|
|
373
|
+
// EdDSA carries the curve (Ed25519 / Ed448) as the WebCrypto algorithm name. In
|
|
374
|
+
// a kid-signed request there is no header/opts jwk to read it from, so fall back
|
|
375
|
+
// to the signing key's own algorithm -- an Ed25519 account key must stay usable
|
|
376
|
+
// after account creation, not only in the embedded-jwk (newAccount) case.
|
|
377
|
+
if (algRow.kty === "OKP") return { name: (jwk && jwk.crv) || (key && key.algorithm && key.algorithm.name) };
|
|
378
|
+
if (algRow.kty === "AKP") return { name: algRow.subtle };
|
|
379
|
+
if (algRow.kty === "oct") return { name: "HMAC" };
|
|
380
|
+
throw E("jose/bad-alg", "unsupported key type");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// The key type the JWK MUST have for this alg (fail closed on confusion).
|
|
384
|
+
function _assertKeyType(algRow, jwk) {
|
|
385
|
+
if (!jwk || typeof jwk !== "object" || typeof jwk.kty !== "string") throw E("jose/bad-key", "a JWK object with a kty is required");
|
|
386
|
+
if (jwk.kty !== algRow.kty) throw E("jose/bad-alg", "the key type " + JSON.stringify(jwk.kty) + " does not match the algorithm");
|
|
387
|
+
if (algRow.kty === "EC" && jwk.crv !== algRow.crv) throw E("jose/bad-alg", "the EC curve does not match the algorithm");
|
|
388
|
+
if (algRow.kty === "OKP" && jwk.crv !== "Ed25519" && jwk.crv !== "Ed448") throw E("jose/bad-alg", "unsupported OKP curve " + JSON.stringify(jwk.crv));
|
|
389
|
+
// An AKP (ML-DSA) JWK carries its parameter set in `alg` (RFC 9964); it MUST match
|
|
390
|
+
// the algorithm's set, or an ML-DSA-44 key could embed an ML-DSA-65 public JWK.
|
|
391
|
+
if (algRow.kty === "AKP" && jwk.alg !== algRow.subtle) throw E("jose/bad-alg", "the AKP parameter set " + JSON.stringify(jwk.alg) + " does not match the algorithm " + algRow.subtle);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// The exact signature byte length this alg + key must produce.
|
|
395
|
+
function _expectedSigBytes(algRow, jwk, key) {
|
|
396
|
+
if (algRow.sigBytes != null) return algRow.sigBytes;
|
|
397
|
+
// EdDSA: the curve fixes the length. On the verify side it is read from the JWK;
|
|
398
|
+
// on the sign side (kid mode, no JWK) it comes from the signing key's algorithm.
|
|
399
|
+
if (algRow.sigBytesByCrv) return algRow.sigBytesByCrv[(jwk && jwk.crv) || (key && key.algorithm && key.algorithm.name)] || null;
|
|
400
|
+
if (algRow.kty === "RSA") {
|
|
401
|
+
if (jwk && typeof jwk.n === "string") return b64uDecode(jwk.n).length; // verify: modulus from the JWK
|
|
402
|
+
if (key && key.algorithm && key.algorithm.modulusLength) return key.algorithm.modulusLength / 8; // sign: from the key
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The JWK members that carry PRIVATE key material across every key type -- EC/OKP
|
|
409
|
+
// `d`, the RSA CRT set, the symmetric `k`, and the AKP `priv`.
|
|
410
|
+
var PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "k", "priv"];
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* @primitive pki.jose.assertPublicJwk
|
|
414
|
+
* @signature pki.jose.assertPublicJwk(jwk) -> jwk
|
|
415
|
+
* @since 0.1.25
|
|
416
|
+
* @status experimental
|
|
417
|
+
* @spec RFC 7517, RFC 7518
|
|
418
|
+
* @related pki.jose.sign, pki.jose.thumbprint
|
|
419
|
+
*
|
|
420
|
+
* Assert that a JWK is PUBLIC-ONLY before it is published (embedded in a JWS
|
|
421
|
+
* protected header or an ACME External Account Binding payload). A JWK carrying any
|
|
422
|
+
* private member (`d`, the RSA CRT parameters `p`/`q`/`dp`/`dq`/`qi`, the symmetric
|
|
423
|
+
* `k`, or the AKP `priv`) throws `jose/private-key-material` -- so an accidentally
|
|
424
|
+
* exported private JWK is never sent to a server. Returns the JWK.
|
|
425
|
+
*
|
|
426
|
+
* @example
|
|
427
|
+
* pki.jose.assertPublicJwk({ kty: "EC", crv: "P-256", x: "...", y: "..." });
|
|
428
|
+
*/
|
|
429
|
+
function assertPublicJwk(jwk) {
|
|
430
|
+
if (!jwk || typeof jwk !== "object" || Array.isArray(jwk)) throw E("jose/bad-key", "a JWK object is required");
|
|
431
|
+
for (var i = 0; i < PRIVATE_JWK_MEMBERS.length; i++) {
|
|
432
|
+
if (Object.prototype.hasOwnProperty.call(jwk, PRIVATE_JWK_MEMBERS[i])) {
|
|
433
|
+
throw E("jose/private-key-material", "a published JWK must be public-only; it carries the private member " + JSON.stringify(PRIVATE_JWK_MEMBERS[i]));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return jwk;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* @primitive pki.jose.verify
|
|
441
|
+
* @signature pki.jose.verify(jws, opts) -> Promise<{ header, payload }>
|
|
442
|
+
* @since 0.1.25
|
|
443
|
+
* @status experimental
|
|
444
|
+
* @spec RFC 7515, RFC 7518, RFC 8555
|
|
445
|
+
* @related pki.jose.sign, pki.jose.parseJson
|
|
446
|
+
*
|
|
447
|
+
* Verify a Flattened JSON JWS against a profile (`opts.profile`, default
|
|
448
|
+
* `"acme-outer"`). Structural rules fail closed BEFORE any crypto: the
|
|
449
|
+
* `signatures`/`header` members and a detached payload are rejected, the
|
|
450
|
+
* protected header is validated against the profile (alg registry, nonce, url,
|
|
451
|
+
* exactly-one-of jwk/kid, crit), the signature byte length is pinned per alg, and
|
|
452
|
+
* the verification key is the header `jwk` (only where the profile permits it) or
|
|
453
|
+
* `opts.key` (a JWK). Returns the decoded `{ header, payload }` (payload a raw
|
|
454
|
+
* `Buffer`); a failed signature throws `jose/verify-failed`.
|
|
455
|
+
*
|
|
456
|
+
* @opts
|
|
457
|
+
* profile: string // "acme-outer" | "eab-inner" | "keychange-inner"
|
|
458
|
+
* key: object // a public JWK, required unless the profile embeds jwk
|
|
459
|
+
*
|
|
460
|
+
* @example
|
|
461
|
+
* var v = await pki.jose.verify(jws, { profile: "acme-outer", key: accountJwk });
|
|
462
|
+
* v.header.alg; // -> "ES256"
|
|
463
|
+
*/
|
|
464
|
+
async function verify(jws, opts) {
|
|
465
|
+
opts = opts || {};
|
|
466
|
+
if (!jws || typeof jws !== "object" || Array.isArray(jws)) throw E("jose/bad-jws", "a flattened JWS object is required");
|
|
467
|
+
if (Object.prototype.hasOwnProperty.call(jws, "signatures")) throw E("jose/bad-jws", "the multi-signature signatures member is forbidden");
|
|
468
|
+
if (Object.prototype.hasOwnProperty.call(jws, "header")) throw E("jose/bad-jws", "the unprotected header member is forbidden");
|
|
469
|
+
if (typeof jws.protected !== "string" || typeof jws.signature !== "string") throw E("jose/bad-jws", "protected and signature must be base64url strings");
|
|
470
|
+
if (typeof jws.payload !== "string") throw E("jose/bad-jws", "payload must be a base64url string (never detached)");
|
|
471
|
+
var header = parseJson(b64uDecode(jws.protected));
|
|
472
|
+
var profileName = opts.profile || "acme-outer";
|
|
473
|
+
var checked = _checkHeader(header, profileName);
|
|
474
|
+
var jwk = header.jwk || opts.key;
|
|
475
|
+
if (!jwk) throw E("jose/bad-key", "a verification key is required (opts.key) when the profile does not embed a jwk");
|
|
476
|
+
_assertKeyType(checked.algRow, jwk);
|
|
477
|
+
var sig = b64uDecode(jws.signature);
|
|
478
|
+
var want = _expectedSigBytes(checked.algRow, jwk);
|
|
479
|
+
if (want != null && sig.length !== want) throw E("jose/bad-signature", "the signature length " + sig.length + " is not the " + want + " bytes " + checked.alg + " requires");
|
|
480
|
+
// Signing input from the RECEIVED base64url strings verbatim (never a re-serialize).
|
|
481
|
+
var signingInput = Buffer.from(jws.protected + "." + jws.payload, "ascii");
|
|
482
|
+
var key;
|
|
483
|
+
try { key = await webcrypto.subtle.importKey("jwk", jwk, _importParams(checked.algRow, jwk), false, ["verify"]); }
|
|
484
|
+
catch (e) { throw E("jose/bad-key", "the JWK could not be imported for verification", e); }
|
|
485
|
+
var ok = await webcrypto.subtle.verify(_cryptoAlg(checked.algRow, jwk), key, sig, signingInput);
|
|
486
|
+
if (!ok) throw E("jose/verify-failed", "the JWS signature did not verify");
|
|
487
|
+
return { header: header, payload: b64uDecode(jws.payload) };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- flattened-JWS sign (RFC 7515 sec. 5.1) ------------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* @primitive pki.jose.sign
|
|
494
|
+
* @signature pki.jose.sign(opts) -> Promise<{ protected, payload, signature }>
|
|
495
|
+
* @since 0.1.25
|
|
496
|
+
* @status experimental
|
|
497
|
+
* @spec RFC 7515, RFC 7518, RFC 8555
|
|
498
|
+
* @related pki.jose.verify
|
|
499
|
+
*
|
|
500
|
+
* Produce a Flattened JSON JWS. `opts.protected` is the protected-header object
|
|
501
|
+
* (validated against `opts.profile`), `opts.payload` the raw payload octets (a
|
|
502
|
+
* `Buffer`; the empty `Buffer` yields the POST-as-GET `payload:""`), and
|
|
503
|
+
* `opts.key` a private `CryptoKey`. The signing input is built from the encoded
|
|
504
|
+
* header and payload and signed with the alg the header names.
|
|
505
|
+
*
|
|
506
|
+
* @opts
|
|
507
|
+
* protected: object // the protected header (alg, nonce, url, jwk|kid)
|
|
508
|
+
* payload: Buffer // the raw payload octets ("" for POST-as-GET)
|
|
509
|
+
* key: CryptoKey // the private signing key
|
|
510
|
+
* profile: string // default "acme-outer"
|
|
511
|
+
* jwk: object // the public JWK, when the header embeds jwk
|
|
512
|
+
*
|
|
513
|
+
* @example
|
|
514
|
+
* var jws = await pki.jose.sign({ protected: hdr, payload: Buffer.from("{}"), key: priv });
|
|
515
|
+
*/
|
|
516
|
+
async function sign(opts) {
|
|
517
|
+
opts = opts || {};
|
|
518
|
+
var header = opts.protected;
|
|
519
|
+
var payload = opts.payload;
|
|
520
|
+
if (!Buffer.isBuffer(payload)) throw E("jose/bad-input", "payload must be a Buffer (empty Buffer for POST-as-GET)");
|
|
521
|
+
// A non-CryptoKey (a raw Buffer, a string, a JWK object) would reach subtle.sign
|
|
522
|
+
// and throw a bare TypeError; require a CryptoKey (it carries an `algorithm`) so
|
|
523
|
+
// every caller -- and every acme builder that composes sign -- fails closed.
|
|
524
|
+
if (!opts.key || typeof opts.key !== "object" || typeof opts.key.algorithm !== "object") throw E("jose/bad-input", "a private or secret CryptoKey (opts.key) is required");
|
|
525
|
+
var checked = _checkHeader(header, opts.profile || "acme-outer");
|
|
526
|
+
// An RSA / HMAC key binds its hash at import; the signature-length pin below cannot
|
|
527
|
+
// see a hash mismatch (the modulus / MAC size is unchanged), so a SHA-512 key under
|
|
528
|
+
// RS256 would sign here yet fail verification. Reject the hash disagreement up front.
|
|
529
|
+
if ((checked.algRow.kty === "RSA" || checked.algRow.kty === "oct") &&
|
|
530
|
+
(!opts.key.algorithm.hash || opts.key.algorithm.hash.name !== checked.algRow.hash)) {
|
|
531
|
+
throw E("jose/bad-key", "the signing key's hash does not match the algorithm " + checked.alg);
|
|
532
|
+
}
|
|
533
|
+
// An embedded jwk (jwk mode / keychange-inner) is the public key a verifier will
|
|
534
|
+
// use, so it MUST match the alg exactly as verify enforces -- otherwise the header
|
|
535
|
+
// advertises a key that cannot verify this signature. (No jwk in kid mode.) It is
|
|
536
|
+
// also PUBLISHED to the server, so it must be public-only: an accidentally exported
|
|
537
|
+
// private JWK must never be serialized into the protected header.
|
|
538
|
+
if (header.jwk) { _assertKeyType(checked.algRow, header.jwk); assertPublicJwk(header.jwk); }
|
|
539
|
+
var protectedB64 = b64uEncode(Buffer.from(JSON.stringify(header), "utf8"));
|
|
540
|
+
var payloadB64 = payload.length === 0 ? "" : b64uEncode(payload);
|
|
541
|
+
var signingInput = Buffer.from(protectedB64 + "." + payloadB64, "ascii");
|
|
542
|
+
var jwk = header.jwk || opts.jwk || {};
|
|
543
|
+
var sigBuf = Buffer.from(await webcrypto.subtle.sign(_cryptoAlg(checked.algRow, jwk, opts.key), opts.key, signingInput));
|
|
544
|
+
// Pin the produced signature length the same way verify does. A key whose curve /
|
|
545
|
+
// hash / modulus does not match the header alg (a P-384 key under ES256, an
|
|
546
|
+
// HS512 key under HS256) still signs, but emits the wrong length -- caught here
|
|
547
|
+
// so the builder fails locally instead of shipping a JWS the server will reject.
|
|
548
|
+
var want = _expectedSigBytes(checked.algRow, jwk, opts.key);
|
|
549
|
+
if (want != null && sigBuf.length !== want) throw E("jose/bad-key", "the signing key produced a " + sigBuf.length + "-byte signature but " + checked.alg + " requires " + want + " (the key does not match the algorithm)");
|
|
550
|
+
// In jwk mode the EMBEDDED public jwk is what verifiers use. Confirm it actually
|
|
551
|
+
// verifies this signature -- i.e. it is the signing key's public half -- so a
|
|
552
|
+
// key-pair mix-up (a jwk from a different key of the same type) fails locally
|
|
553
|
+
// instead of producing a JWS the CA rejects. Same-type/curve is not enough.
|
|
554
|
+
if (header.jwk) {
|
|
555
|
+
var pubKey;
|
|
556
|
+
try { pubKey = await webcrypto.subtle.importKey("jwk", header.jwk, _importParams(checked.algRow, header.jwk), false, ["verify"]); }
|
|
557
|
+
catch (e) { throw E("jose/bad-key", "the embedded jwk could not be imported to confirm it matches the signing key", e); }
|
|
558
|
+
if (!(await webcrypto.subtle.verify(_cryptoAlg(checked.algRow, header.jwk), pubKey, sigBuf, signingInput))) {
|
|
559
|
+
throw E("jose/bad-key", "the embedded jwk does not match the signing key (it cannot verify the produced signature)");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return { protected: protectedB64, payload: payloadB64, signature: b64uEncode(sigBuf) };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ---- JWK thumbprint (RFC 7638 + RFC 8037 + RFC 9964) ---------------------
|
|
566
|
+
|
|
567
|
+
// The required-member templates per key type (RFC 7638 sec. 3.2 / RFC 8037 sec. 2
|
|
568
|
+
// / RFC 9964). ONLY these members, in lexicographic order, no whitespace, feed
|
|
569
|
+
// the SHA-256; optional members (alg/use/kid) never enter the hash.
|
|
570
|
+
var THUMBPRINT_MEMBERS = {
|
|
571
|
+
EC: ["crv", "kty", "x", "y"],
|
|
572
|
+
RSA: ["e", "kty", "n"],
|
|
573
|
+
oct: ["k", "kty"],
|
|
574
|
+
OKP: ["crv", "kty", "x"],
|
|
575
|
+
AKP: ["alg", "kty", "pub"], // RFC 9964 sec. 6: alg, kty, pub in lexicographic order
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* @primitive pki.jose.thumbprint
|
|
580
|
+
* @signature pki.jose.thumbprint(jwk) -> Promise<string>
|
|
581
|
+
* @since 0.1.25
|
|
582
|
+
* @status experimental
|
|
583
|
+
* @spec RFC 7638, RFC 8037, RFC 9964
|
|
584
|
+
* @related pki.jose.verify
|
|
585
|
+
*
|
|
586
|
+
* The RFC 7638 JWK SHA-256 thumbprint as base64url: the canonical JSON of ONLY
|
|
587
|
+
* the key type's required members, lexicographically ordered, no whitespace,
|
|
588
|
+
* hashed. Optional members (`alg`, `use`, `kid`) are excluded, so the same key
|
|
589
|
+
* always yields the same thumbprint (the ACME key-authorization anchor).
|
|
590
|
+
*
|
|
591
|
+
* @example
|
|
592
|
+
* await pki.jose.thumbprint(accountJwk); // -> "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
|
|
593
|
+
*/
|
|
594
|
+
async function thumbprint(jwk) {
|
|
595
|
+
if (!jwk || typeof jwk !== "object" || typeof jwk.kty !== "string") throw E("jose/bad-key", "a JWK object with a kty is required");
|
|
596
|
+
var members = THUMBPRINT_MEMBERS[jwk.kty];
|
|
597
|
+
if (!members) throw E("jose/bad-key", "unsupported JWK key type " + JSON.stringify(jwk.kty));
|
|
598
|
+
var parts = [];
|
|
599
|
+
for (var i = 0; i < members.length; i++) {
|
|
600
|
+
var m = members[i];
|
|
601
|
+
if (typeof jwk[m] !== "string") throw E("jose/bad-key", "the JWK is missing the required member " + JSON.stringify(m));
|
|
602
|
+
parts.push(JSON.stringify(m) + ":" + JSON.stringify(jwk[m]));
|
|
603
|
+
}
|
|
604
|
+
var canonical = "{" + parts.join(",") + "}";
|
|
605
|
+
var digest = Buffer.from(await webcrypto.subtle.digest("SHA-256", Buffer.from(canonical, "utf8")));
|
|
606
|
+
return b64uEncode(digest);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
module.exports = {
|
|
610
|
+
base64url: { encode: b64uEncode, decode: b64uDecode },
|
|
611
|
+
parseJson: parseJson,
|
|
612
|
+
verify: verify,
|
|
613
|
+
sign: sign,
|
|
614
|
+
thumbprint: thumbprint,
|
|
615
|
+
assertPublicJwk: assertPublicJwk,
|
|
616
|
+
};
|