@blamejs/pki 0.1.24 → 0.1.26

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/lib/constants.js CHANGED
@@ -173,6 +173,16 @@ var LIMITS = {
173
173
  // A single attribute's value SET -- no deployed attribute carries more than
174
174
  // a handful of values; a list at this scale is amplification.
175
175
  ATTRIBUTE_MAX_VALUES: 256,
176
+ // JSON message bounds for the JOSE / ACME layer (RFC 8555). Directories and
177
+ // resource objects are small (certificates travel as PEM, not JSON); 1 MiB is
178
+ // generous headroom. Depth 32 clears the deepest real nesting (a problem
179
+ // document's subproblems nest one level) with margin below any stack risk;
180
+ // enforced BEFORE parsing so a hostile document is refused up front.
181
+ JSON_MAX_BYTES: BYTES.mib(1),
182
+ JSON_MAX_DEPTH: 32,
183
+ // ACME challenge token entropy floor (RFC 8555 sec. 8, errata 6950): >= 128
184
+ // bits of base64url is >= 22 characters. A shorter token is refused before use.
185
+ ACME_TOKEN_MIN_CHARS: 22,
176
186
  };
177
187
 
178
188
  // Single-sourced from the package manifest so the reported version can
@@ -234,6 +234,22 @@ var CsrattrsError = defineClass("CsrattrsError", { withCause: true });
234
234
  // `.cause`.
235
235
  var EstError = defineClass("EstError", { withCause: true });
236
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
+
237
253
  module.exports = {
238
254
  PkiError: PkiError,
239
255
  defineClass: defineClass,
@@ -258,4 +274,6 @@ module.exports = {
258
274
  SmimeError: SmimeError,
259
275
  CsrattrsError: CsrattrsError,
260
276
  EstError: EstError,
277
+ JoseError: JoseError,
278
+ AcmeError: AcmeError,
261
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
+ };