@blamejs/pki 0.1.30 → 0.1.32

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 CHANGED
@@ -4,6 +4,23 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.1.32 — 2026-07-11
8
+
9
+ OCSP-backed revocation checking joins certification-path validation.
10
+
11
+ ### Added
12
+
13
+ - pki.path.ocspChecker(responses) -> RevocationChecker builds an OCSP-backed revocation checker for pki.path.validate's revocationChecker option from a set of pre-fetched OCSP responses (DER Buffer, PEM string, or already-parsed). It matches the full CertID triple against the certificate under whichever hash algorithm the CertID declares, authorizes the responder (issuing CA or a CA-issued id-kp-OCSPSigning delegate), verifies the response signature over tbsResponseData, and enforces thisUpdate/nextUpdate currency, reporting good, revoked (with revocationReason), or unknown. A wrong-issuer CertID, an unauthorized responder, a stale, not-yet-valid, or nextUpdate-less response, a non-successful responseStatus, or any verification failure yields unknown, which the validator fails closed unless softFail is set. RFC 6960.
14
+
15
+ ## v0.1.31 — 2026-07-11
16
+
17
+ The DER format cohort and the JOSE surface graduate to stable.
18
+
19
+ ### Changed
20
+
21
+ - pki.schema.pkcs12 / attrcert / crmf / cmp / csrattrs (parse, pemDecode, pemEncode), pki.schema.all and pki.schema.parse, and the pki.jose signing / verification / thumbprint / base64url / JSON surface graduate from experimental to stable.
22
+ - The LTS-CALENDAR graduation criterion now states that a settled, well-tested format no mainstream tool implements graduates on the toolkit's own conformance-vector round-trip plus coverage-guided fuzzing, rather than on a harness oracle that does not exist.
23
+
7
24
  ## v0.1.30 — 2026-07-11
8
25
 
9
26
  Fail-closed hardening of the byte-input and text-decode boundaries.
package/README.md CHANGED
@@ -220,7 +220,7 @@ is callable today; nothing below is a stub.
220
220
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
221
221
  | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
222
222
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
223
- | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes; `crlChecker` supplies CRL-based revocation. Pure and re-entrant, fail-closed — `validate`, `crlChecker` |
223
+ | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes; `crlChecker` supplies CRL-based revocation and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
224
  | `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
225
225
  | `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
226
226
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
package/lib/guard-all.js CHANGED
@@ -18,6 +18,9 @@
18
18
  // (recursion / allocation DoS defence)
19
19
  // guard.crypto.constantTimeEqual -- length-checked constant-time compare
20
20
  // (timing side-channel defence)
21
+ // guard.range.int / .uint31 / .positiveInt31
22
+ // -- bound a decoded integer before narrowing
23
+ // to Number (silent-narrowing defence)
21
24
  //
22
25
  // Each shape is enforced by a codebase-patterns detector: the characteristic
23
26
  // token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
@@ -29,10 +32,14 @@ var bytes = require("./guard-bytes");
29
32
  var text = require("./guard-text");
30
33
  var limits = require("./guard-limits");
31
34
  var crypto = require("./guard-crypto");
35
+ var range = require("./guard-range");
36
+ var name = require("./guard-name");
32
37
 
33
38
  module.exports = {
34
39
  bytes: bytes,
35
40
  text: text,
36
41
  limits: limits,
37
42
  crypto: crypto,
43
+ range: range,
44
+ name: name,
38
45
  };
@@ -27,6 +27,8 @@
27
27
  // Accepts a Buffer / Uint8Array -- the DER / CBOR / CT / Merkle input contract.
28
28
  // ErrorClass MUST be a withCause PkiError subclass (the raw detach failure is
29
29
  // threaded as the cause).
30
+ // @enforced-by guard-shape-reinlined
31
+ // @guard-shape Buffer\.from\(\s*([A-Za-z_$][\w$]*)\.buffer\s*,\s*\1\.byteOffset
30
32
  function view(input, ErrorClass, code, label) {
31
33
  if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
32
34
  try {
@@ -42,6 +44,7 @@ function view(input, ErrorClass, code, label) {
42
44
  // Accepts the full W3C BufferSource (Buffer / TypedArray view / raw ArrayBuffer)
43
45
  // -- the WebCrypto input contract. A raw ArrayBuffer is copied via Buffer.from,
44
46
  // which also throws on a detached backing store.
47
+ // @enforced-by guard-shape-reinlined (the re-view shape is declared on view above)
45
48
  function source(input, ErrorClass, code, label) {
46
49
  var isAb = input instanceof ArrayBuffer;
47
50
  if (isAb || ArrayBuffer.isView(input)) {
@@ -25,6 +25,8 @@ var nodeCrypto = require("node:crypto");
25
25
 
26
26
  // constantTimeEqual(a, b) -> boolean. Length-checked, then constant-time over
27
27
  // equal lengths. a and b are Buffers.
28
+ // @enforced-by guard-shape-reinlined
29
+ // @guard-shape \.timingSafeEqual\s*\(
28
30
  function constantTimeEqual(a, b) {
29
31
  return a.length === b.length && nodeCrypto.timingSafeEqual(a, b);
30
32
  }
@@ -27,6 +27,8 @@ var constants = require("./constants");
27
27
  // cap(value, key, dflt) -> a validated non-negative integer, or dflt when value
28
28
  // is undefined. A non-integer / non-finite / negative value is a config-time
29
29
  // TypeError (Number.isInteger already rejects non-numbers, NaN, and Infinity).
30
+ // @enforced-by behavioral -- a config-time cap validator has no rename-proof code
31
+ // shape; the decode-rejects-a-bad-cap RED vectors (asn1/cbor) are the guard.
30
32
  function cap(value, key, dflt) {
31
33
  if (value === undefined) return dflt;
32
34
  if (!Number.isInteger(value) || value < 0) {
@@ -38,6 +40,11 @@ function cap(value, key, dflt) {
38
40
  // depthCap(value, key, dflt) -> a validated recursion-depth cap: cap() plus the
39
41
  // stack-safe ceiling, so a raised maxDepth cannot drive recursive descent into a
40
42
  // raw RangeError past the native frame limit.
43
+ // @enforced-by guard-shape-reinlined
44
+ // @guard-scope file
45
+ // @guard-shape \bopts\.maxDepth\b
46
+ // @guard-shape \bdepth\s*\+\s*1\b
47
+ // @guard-via \.depthCap\s*\(
41
48
  function depthCap(value, key, dflt) {
42
49
  var n = cap(value, key, dflt);
43
50
  if (n > constants.LIMITS.MAX_DECODE_DEPTH_CEILING) {
@@ -0,0 +1,111 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the
6
+ // consumers whose name-string integrity composes this guard (pki.path.validate
7
+ // DN chaining, pki.schema.x509 GeneralName / SAN decode).
8
+ //
9
+ // guard-name -- fail-closed name integrity: reject an embedded control byte in a
10
+ // distinguished-name / SAN string, and compare two distinguished names by their
11
+ // RFC 5280 sec. 7.1 canonical identity rather than their raw bytes.
12
+ //
13
+ // Defends the name-truncation / display-confusion class (CVE-2009-2408): a NUL
14
+ // or control byte embedded in a decoded name lets an attacker make two different
15
+ // names compare equal (or a UI truncate at the NUL), so a cert issued for
16
+ // "good.example.com\0.evil.com" is treated as "good.example.com". CWE-158
17
+ // (improper neutralization of null byte) / CWE-20. The reject is at decode, so a
18
+ // truncation name never reaches a comparison or a display.
19
+ //
20
+ // Defends the DN identity-vs-bytes class (CWE-706): a distinguished name has many
21
+ // RFC 5280 sec. 7.1-equal DER encodings (case, whitespace, PrintableString vs
22
+ // UTF8String). Binding identity to raw bytes -- a byte compare, or hashing
23
+ // name.bytes as a lookup key -- silently treats two equal names as different, so
24
+ // certificate chaining breaks, a revocation issuer / OCSP responder fails to
25
+ // match, or (the mirror risk) a name constraint is escaped. Every DN identity
26
+ // decision routes through the one canonical comparison here.
27
+
28
+ // assertNoControlBytes(str, E, code, label) -> str | throws E(code, ...)
29
+ // DirectoryString policy (a DN attribute value): reject NUL and C0 control bytes
30
+ // in a DECODED name string. TAB (0x09) is exempt; printable non-ASCII (a
31
+ // UTF8String CN carries accented / CJK characters) is allowed. `str` is assumed a
32
+ // string (the caller guards typeof). E is the (code, message) typed-error factory.
33
+ // @enforced-by behavioral -- the control-byte reject has no rename-proof code
34
+ // shape distinct from the ASN.1 charset readers; the CVE-2009-2408 RED vectors
35
+ // (a DN string with an embedded NUL / control byte rejects) are the guard.
36
+ function assertNoControlBytes(str, E, code, label) {
37
+ for (var i = 0; i < str.length; i++) {
38
+ var c = str.charCodeAt(i);
39
+ if (c === 0 || (c < 0x20 && c !== 0x09)) {
40
+ throw E(code, label + " contains an embedded control byte (CVE-2009-2408)");
41
+ }
42
+ }
43
+ return str;
44
+ }
45
+
46
+ // assertPrintableIa5(buf, E, code, label) -> buf | throws E(code, ...)
47
+ // IA5String policy (a dNSName / rfc822Name / URI GeneralName): every byte must be
48
+ // printable 7-bit ASCII [0x20, 0x7e] -- an embedded NUL / control byte enables the
49
+ // same name-truncation bypass downstream. `buf` is the raw GeneralName content.
50
+ // @enforced-by behavioral -- the printable-IA5 byte-range reject has no rename-proof
51
+ // code shape distinct from the ASN.1 IA5 reader; the CVE-2009-2408 RED vectors
52
+ // (a SAN with a control byte rejects) are the guard.
53
+ function assertPrintableIa5(buf, E, code, label) {
54
+ for (var i = 0; i < buf.length; i++) {
55
+ if (buf[i] < 0x20 || buf[i] > 0x7e) {
56
+ throw E(code, label + " must be a printable IA5String (no control bytes)");
57
+ }
58
+ }
59
+ return buf;
60
+ }
61
+
62
+ // Canonical form of a single DN attribute value (RFC 5280 sec. 7.1): reject an
63
+ // embedded control byte (CVE-2009-2408) then case-fold and collapse internal
64
+ // whitespace. Every standard X.520 attribute uses caseIgnoreMatch, and this form
65
+ // matches OpenSSL's X509_NAME_cmp, so a chain OpenSSL accepts is not rejected. This
66
+ // canonicalization is the shape the guard-shape-reinlined detector keys on
67
+ // (declared on dnEqual): a boundary hand-rolling it is re-implementing DN identity.
68
+ function _canonAttrValue(v, E, code, label) {
69
+ if (typeof v !== "string") return v;
70
+ assertNoControlBytes(v, E, code, label);
71
+ return v.trim().replace(/\s+/g, " ").toLowerCase();
72
+ }
73
+ // rdnEqual(a, b, E, code, label) -> boolean. Canonical comparison of a single
74
+ // RelativeDistinguishedName (an unordered SET of type/value pairs, compared as a
75
+ // multiset). The RDN-level primitive a name-constraint directoryName prefix match
76
+ // composes; dnEqual composes it over the RDN sequence. Comparing an RDN by raw DER
77
+ // would treat two RFC 5280-equal names as different (or let a truncation name
78
+ // compare equal).
79
+ // @enforced-by guard-shape-reinlined (shares the canonicalization shape declared on dnEqual)
80
+ function rdnEqual(a, b, E, code, label) {
81
+ if (a.length !== b.length) return false;
82
+ var used = [];
83
+ for (var i = 0; i < a.length; i++) {
84
+ var found = false;
85
+ for (var j = 0; j < b.length; j++) {
86
+ if (used[j]) continue;
87
+ if (a[i].type === b[j].type && _canonAttrValue(a[i].value, E, code, label) === _canonAttrValue(b[j].value, E, code, label)) {
88
+ used[j] = true; found = true; break;
89
+ }
90
+ }
91
+ if (!found) return false;
92
+ }
93
+ return true;
94
+ }
95
+ // dnEqual(rdnsA, rdnsB, E, code, label) -> boolean. RFC 5280 sec. 7.1 canonical
96
+ // distinguished-name comparison over the RDN sequence -- the ONE place a DN identity
97
+ // is decided, so no caller binds identity to raw DER (a byte compare, or hashing
98
+ // name.bytes, would treat two RFC 5280-equal names as different, breaking a chain /
99
+ // a revocation match, or -- the mirror risk -- escaping a name constraint). The
100
+ // per-RDN canonicalization + control-byte reject is in rdnEqual / _canonAttrValue.
101
+ // @enforced-by guard-shape-reinlined
102
+ // @guard-shape replace\(/\\s\+/g,
103
+ function dnEqual(rdnsA, rdnsB, E, code, label) {
104
+ if (rdnsA.length !== rdnsB.length) return false;
105
+ for (var i = 0; i < rdnsA.length; i++) {
106
+ if (!rdnEqual(rdnsA[i], rdnsB[i], E, code, label)) return false;
107
+ }
108
+ return true;
109
+ }
110
+
111
+ module.exports = { assertNoControlBytes: assertNoControlBytes, assertPrintableIa5: assertPrintableIa5, dnEqual: dnEqual, rdnEqual: rdnEqual };
@@ -0,0 +1,69 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the
6
+ // parsers whose decoded-integer bounds compose this guard (pki.schema.x509 /
7
+ // crl / pkcs12 / cmp, pki.path.validate).
8
+ //
9
+ // guard-range -- fail-closed bound of an untrusted DECODED integer before it is
10
+ // narrowed to a JS Number. Parse-time (Tier-2): the value is malformed content,
11
+ // so it throws the caller's typed PkiError -- distinct from guard-limits, which
12
+ // validates an operator-supplied option at config time (Tier-1, TypeError).
13
+ //
14
+ // Defends the silent-narrowing class (a decoded ASN.1 INTEGER past 2^53 rounds
15
+ // when coerced to a Number, so a caller acting on the result -- a pathLen /
16
+ // skipCerts / saltLength / iteration counter -- acts on the WRONG number;
17
+ // CWE-190 integer overflow / CWE-681 incorrect conversion). The bound and the
18
+ // narrow are ATOMIC here: a caller cannot obtain the Number without the range
19
+ // having been enforced, so the value can never round silently and be acted on.
20
+
21
+ // 2^31 - 1 -- the shared skip-cert / path-length / salt / iteration ceiling.
22
+ // This literal lives ONLY here (a re-inline anywhere else is flagged: the
23
+ // guard-shape walk anchors on the literal). 2^53 - 1 is the safe-narrow ceiling.
24
+ var UINT31_MAX = 2147483647n;
25
+ var SAFE_MAX = 9007199254740991n;
26
+ var SAFE_MIN = -9007199254740991n;
27
+
28
+ // int(value, min, max, E, code, label) -> a Number in [min, max].
29
+ // value : the BigInt result of an ASN.1 INTEGER / ENUMERATED read.
30
+ // min, max : inclusive BigInt bounds.
31
+ // E : the (code, message, cause) typed-error factory in scope at the call
32
+ // site (ns.E / ctx.E / the module-local E) -- guard-range injects the
33
+ // caller's typed error through it, the tier-appropriate currency (a
34
+ // parse boundary carries a factory, not a bare ErrorClass).
35
+ // code : the frozen domain/reason code this field rejects under.
36
+ // label : field phrase (+ optional RFC cite) for the message.
37
+ // @enforced-by guard-shape-reinlined
38
+ // @guard-shape 2147483647n
39
+ function int(value, min, max, E, code, label) {
40
+ // Config-time authoring guard (Tier-1): narrowing to Number is lossless only
41
+ // when the WHOLE range sits inside the safe-integer band [-(2^53-1), 2^53-1].
42
+ // Both ends bind -- a min below the floor rounds a decoded value near it just
43
+ // as a max above the ceiling does. A wider range needs a BigInt-preserving
44
+ // guard, not this one (this is why the uint64 Merkle-coordinate domain must
45
+ // NOT route here).
46
+ if (max > SAFE_MAX) {
47
+ throw new TypeError("guard.range.int: max " + max + " exceeds the safe-integer ceiling; use a BigInt-preserving guard");
48
+ }
49
+ if (min < SAFE_MIN) {
50
+ throw new TypeError("guard.range.int: min " + min + " is below the safe-integer floor; use a BigInt-preserving guard");
51
+ }
52
+ // Parse-time reject (Tier-2): malformed / out-of-range decoded content.
53
+ if (typeof value !== "bigint" || value < min || value > max) {
54
+ throw E(code, label + " must be an integer within " + min + ".." + max);
55
+ }
56
+ return Number(value);
57
+ }
58
+
59
+ // uint31(value, E, code, label) -> Number in [0, 2^31-1]. The DER-INTEGER
60
+ // non-negative skip/path/salt shape (RFC 5280 / 4211 / 9810).
61
+ // @enforced-by guard-shape-reinlined (shares the 2147483647n shape declared on int)
62
+ function uint31(value, E, code, label) { return int(value, 0n, UINT31_MAX, E, code, label); }
63
+
64
+ // positiveInt31(value, E, code, label) -> Number in [1, 2^31-1]. The PKCS#12
65
+ // PBKDF2 / MAC iteration-count shape (a count of at least one).
66
+ // @enforced-by guard-shape-reinlined (shares the 2147483647n shape declared on int)
67
+ function positiveInt31(value, E, code, label) { return int(value, 1n, UINT31_MAX, E, code, label); }
68
+
69
+ module.exports = { int: int, uint31: uint31, positiveInt31: positiveInt31 };
package/lib/guard-text.js CHANGED
@@ -35,6 +35,9 @@ var LATIN1 = "latin1";
35
35
  // (required only when fatal is set),
36
36
  // badInput: code for a non-Buffer/non-string reject,
37
37
  // label: human phrase for the message }
38
+ // @enforced-by behavioral -- cap-before-copy has no rename-proof code shape; the
39
+ // over-cap RED vectors + the detached re-view through guard.bytes.view (itself
40
+ // enforced) are the guard.
38
41
  function decode(input, maxBytes, ErrorClass, spec) {
39
42
  var charset = spec.charset || LATIN1;
40
43
  if (Buffer.isBuffer(input)) {
package/lib/jose.js CHANGED
@@ -53,7 +53,7 @@ var B64U_ALPHABET = /^[A-Za-z0-9_-]*$/;
53
53
  * @primitive pki.jose.base64url.encode
54
54
  * @signature pki.jose.base64url.encode(bytes) -> string
55
55
  * @since 0.1.25
56
- * @status experimental
56
+ * @status stable
57
57
  * @spec RFC 7515, RFC 4648
58
58
  * @related pki.jose.base64url.decode
59
59
  *
@@ -72,7 +72,7 @@ function b64uEncode(bytes) {
72
72
  * @primitive pki.jose.base64url.decode
73
73
  * @signature pki.jose.base64url.decode(text) -> Buffer
74
74
  * @since 0.1.25
75
- * @status experimental
75
+ * @status stable
76
76
  * @spec RFC 7515, RFC 4648, RFC 8555
77
77
  * @related pki.jose.base64url.encode
78
78
  *
@@ -214,7 +214,7 @@ function _jsonReader(str) {
214
214
  * @primitive pki.jose.parseJson
215
215
  * @signature pki.jose.parseJson(input) -> value
216
216
  * @since 0.1.25
217
- * @status experimental
217
+ * @status stable
218
218
  * @spec RFC 7515, RFC 8259
219
219
  * @related pki.jose.base64url.decode
220
220
  *
@@ -408,7 +408,7 @@ var PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "k", "priv"];
408
408
  * @primitive pki.jose.assertPublicJwk
409
409
  * @signature pki.jose.assertPublicJwk(jwk) -> jwk
410
410
  * @since 0.1.25
411
- * @status experimental
411
+ * @status stable
412
412
  * @spec RFC 7517, RFC 7518
413
413
  * @related pki.jose.sign, pki.jose.thumbprint
414
414
  *
@@ -435,7 +435,7 @@ function assertPublicJwk(jwk) {
435
435
  * @primitive pki.jose.verify
436
436
  * @signature pki.jose.verify(jws, opts) -> Promise<{ header, payload }>
437
437
  * @since 0.1.25
438
- * @status experimental
438
+ * @status stable
439
439
  * @spec RFC 7515, RFC 7518, RFC 8555
440
440
  * @related pki.jose.sign, pki.jose.parseJson
441
441
  *
@@ -488,7 +488,7 @@ async function verify(jws, opts) {
488
488
  * @primitive pki.jose.sign
489
489
  * @signature pki.jose.sign(opts) -> Promise<{ protected, payload, signature }>
490
490
  * @since 0.1.25
491
- * @status experimental
491
+ * @status stable
492
492
  * @spec RFC 7515, RFC 7518, RFC 8555
493
493
  * @related pki.jose.verify
494
494
  *
@@ -574,7 +574,7 @@ var THUMBPRINT_MEMBERS = {
574
574
  * @primitive pki.jose.thumbprint
575
575
  * @signature pki.jose.thumbprint(jwk) -> Promise<string>
576
576
  * @since 0.1.25
577
- * @status experimental
577
+ * @status stable
578
578
  * @spec RFC 7638, RFC 8037, RFC 9964
579
579
  * @related pki.jose.verify
580
580
  *
@@ -37,6 +37,8 @@ var asn1 = require("./asn1-der");
37
37
  var schema = require("./schema-engine");
38
38
  var x509 = require("./schema-x509");
39
39
  var crl = require("./schema-crl");
40
+ var ocsp = require("./schema-ocsp");
41
+ var guard = require("./guard-all");
40
42
  var constants = require("./constants");
41
43
 
42
44
  var PathError = errors.PathError;
@@ -226,14 +228,11 @@ function resolveRsaPss(paramsBytes) {
226
228
  // verifier binds to the salt length the certificate states, so a value
227
229
  // that would round is not verifiable material (no real salt exceeds the
228
230
  // modulus size, let alone this).
229
- if (sl < 0n || sl > 2147483647n) throw E("path/unsupported-algorithm", "RSASSA-PSS saltLength must be a non-negative integer within the salt-length range");
230
- saltLength = Number(sl);
231
+ saltLength = guard.range.uint31(sl, E, "path/unsupported-algorithm", "RSASSA-PSS saltLength");
231
232
  } else if (f.tagNumber === 3) {
232
233
  // Compared for equality with 1 below -- bound before conversion so an
233
234
  // oversized value cannot round on its way to the comparison.
234
- var tf = asn1.read.integer(f.children[0]);
235
- if (tf < 0n || tf > 2147483647n) throw E("path/unsupported-algorithm", "unsupported RSASSA-PSS trailerField " + tf.toString());
236
- trailer = Number(tf);
235
+ trailer = guard.range.uint31(asn1.read.integer(f.children[0]), E, "path/unsupported-algorithm", "RSASSA-PSS trailerField");
237
236
  }
238
237
  });
239
238
  if (hash === null) throw E("path/unsupported-algorithm", "RSASSA-PSS hashAlgorithm must be stated explicitly (the SHA-1 default is rejected)");
@@ -338,45 +337,17 @@ function builtinVerify(state, cert) {
338
337
 
339
338
  // ---- 7.1 name comparison ---------------------------------------------------
340
339
 
341
- function normalizeAttrValue(v) {
342
- // RFC 5280 7.1 canonical DN comparison: case-fold + collapse internal
343
- // whitespace. Every standard X.520 DN attribute (CN/O/OU/L/ST/C/serialNumber/
344
- // dnQualifier) uses caseIgnoreMatch, and this canonical form matches OpenSSL's
345
- // X509_NAME_cmp -- applying it uniformly is the interoperable behavior, not a
346
- // per-attribute caseExact rule that would reject a chain OpenSSL accepts.
347
- // Reject embedded NUL / control bytes so a truncation attack (CVE-2009-2408)
348
- // cannot make two different names compare equal.
349
- if (typeof v !== "string") return v;
350
- for (var i = 0; i < v.length; i++) {
351
- var c = v.charCodeAt(i);
352
- if (c === 0 || (c < 0x20 && c !== 0x09)) throw E("path/name-chaining", "distinguished name contains an embedded control byte (CVE-2009-2408)");
353
- }
354
- return v.trim().replace(/\s+/g, " ").toLowerCase();
340
+ // RFC 5280 sec. 7.1 canonical DN / RDN comparison, via the shared name guard: the
341
+ // canonical form (case-fold + internal-whitespace collapse) and the embedded
342
+ // control-byte reject (CVE-2009-2408 -> path/name-chaining) live once in
343
+ // guard-name, so no path-validation caller can reintroduce a raw-byte DN
344
+ // comparison that treats two RFC 5280-equal names as different.
345
+ function dnEqual(rdnsA, rdnsB) {
346
+ return guard.name.dnEqual(rdnsA, rdnsB, E, "path/name-chaining", "distinguished name");
355
347
  }
356
348
 
357
349
  function rdnEqual(a, b) {
358
- if (a.length !== b.length) return false;
359
- // An RDN is an unordered SET of type/value pairs; compare as multisets.
360
- var used = [];
361
- for (var i = 0; i < a.length; i++) {
362
- var found = false;
363
- for (var j = 0; j < b.length; j++) {
364
- if (used[j]) continue;
365
- if (a[i].type === b[j].type && normalizeAttrValue(a[i].value) === normalizeAttrValue(b[j].value)) {
366
- used[j] = true; found = true; break;
367
- }
368
- }
369
- if (!found) return false;
370
- }
371
- return true;
372
- }
373
-
374
- function dnEqual(rdnsA, rdnsB) {
375
- if (rdnsA.length !== rdnsB.length) return false;
376
- for (var i = 0; i < rdnsA.length; i++) {
377
- if (!rdnEqual(rdnsA[i], rdnsB[i])) return false;
378
- }
379
- return true;
350
+ return guard.name.rdnEqual(a, b, E, "path/name-chaining", "distinguished name");
380
351
  }
381
352
 
382
353
  // ---- extension access ------------------------------------------------------
@@ -1588,23 +1559,295 @@ function crlEntryReason(entry) {
1588
1559
  return null;
1589
1560
  }
1590
1561
 
1591
- function verifyCrlSignature(theCrl, issuer) {
1562
+ // Verify a raw signature over tbsBytes with an SPKI public key -- the shared core
1563
+ // of every certificate / CRL / OCSP signature check. Resolve the algorithm
1564
+ // descriptor, enforce the key-OID <-> sig-OID binding (the algorithm-confusion
1565
+ // guard, RFC 9814 sec. 4), import the SPKI, bridge an ECDSA DER signature to
1566
+ // P1363, and verify. Any fault -- an unresolvable/forbidden algorithm, a
1567
+ // key/sig mismatch, an import or verify failure -- resolves false: a signature
1568
+ // check never throws out of this path, it fails closed. `rawSig` is the raw
1569
+ // signature octets (the caller has already unwrapped any BIT STRING and rejected
1570
+ // a non-octet-aligned one).
1571
+ function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
1592
1572
  var d;
1593
1573
  try {
1594
- d = resolveDescriptor(theCrl.signatureAlgorithm);
1595
- assertKeyMatchesSigAlg(issuer.workingPublicKey, theCrl.signatureAlgorithm.oid, d);
1574
+ d = resolveDescriptor(sigAlg);
1575
+ assertKeyMatchesSigAlg(spkiBytes, sigAlg.oid, d);
1596
1576
  } catch (_e) { return Promise.resolve(false); }
1597
- if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
1598
- var key;
1599
- return subtle.importKey("spki", issuer.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
1600
- key = k;
1601
- var sig = theCrl.signatureValue.bytes;
1577
+ return subtle.importKey("spki", spkiBytes, d.imp, false, ["verify"]).then(function (key) {
1578
+ var sig = rawSig;
1602
1579
  if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
1603
- return subtle.verify(d.verify, key, sig, theCrl.tbsBytes);
1580
+ return subtle.verify(d.verify, key, sig, tbsBytes);
1604
1581
  }).then(function (ok) { return ok === true; }, function () { return false; });
1605
1582
  }
1606
1583
 
1584
+ function verifyCrlSignature(theCrl, issuer) {
1585
+ if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
1586
+ return _verifyWithSpki(theCrl.signatureAlgorithm, theCrl.signatureValue.bytes, issuer.workingPublicKey, theCrl.tbsBytes);
1587
+ }
1588
+
1589
+ // ---- the OCSP revocation checker (RFC 6960) -------------------------------
1590
+
1591
+ // CertID identity-hash algorithms (RFC 6960 sec. 4.1.1). Kept SEPARATE from the
1592
+ // signature SIG_ALGS/HASH_BY_OID set (which omits SHA-1 -- a SHA-1 *signature* is
1593
+ // refused, SHAttered): a CertID hash is an identity binding of an already-known
1594
+ // issuer, not a signature, so collision resistance is irrelevant to the lookup
1595
+ // and RFC 6960's default SHA-1 CertID MUST interoperate. A hash OID outside this
1596
+ // set cannot be reproduced -> no CertID match (fail closed), never an assumption.
1597
+ var OCSP_CERTID_HASHES = {};
1598
+ OCSP_CERTID_HASHES[oid.byName("sha1")] = "SHA-1";
1599
+ OCSP_CERTID_HASHES[oid.byName("sha256")] = "SHA-256";
1600
+ OCSP_CERTID_HASHES[oid.byName("sha384")] = "SHA-384";
1601
+ OCSP_CERTID_HASHES[oid.byName("sha512")] = "SHA-512";
1602
+ var OID_OCSP_SIGNING = oid.byName("ocspSigning");
1603
+ var OID_OCSP_NOCHECK = oid.byName("ocspNoCheck");
1604
+
1605
+ // The subjectPublicKey BIT STRING VALUE (excluding the unused-bits octet) of an
1606
+ // SPKI DER -- the exact bytes an OCSP CertID issuerKeyHash / byKey KeyHash hash
1607
+ // over (RFC 6960 sec. 4.1.1). Throws on a malformed SPKI; the caller fails closed.
1608
+ function ocspKeyValue(spkiDer) {
1609
+ return asn1.read.bitString(asn1.decode(spkiDer).children[1]).bytes;
1610
+ }
1611
+
1612
+ // The delegate responder's importable SPKI. If its key omits algorithm parameters
1613
+ // and inherits them from the issuing CA (same key algorithm -- an EC public key
1614
+ // whose SPKI omits the namedCurve, RFC 5280 sec. 4.1.2.7), splice the issuer's
1615
+ // parameters in so importKey("spki", ...) has a complete key; otherwise a valid
1616
+ // response would verify as unknown. Mirrors updateWorkingKey's inheritance.
1617
+ function ocspResponderSpki(rc, issuer) {
1618
+ var keyAlg = rc.subjectPublicKeyInfo.algorithm;
1619
+ if (!isNullOrAbsentParams(keyAlg.parameters)) return rc.subjectPublicKeyInfo.bytes;
1620
+ var issuerOid, issuerParams;
1621
+ try {
1622
+ var alg = asn1.decode(issuer.workingPublicKey).children[0];
1623
+ issuerOid = asn1.read.oid(alg.children[0]);
1624
+ issuerParams = alg.children[1] ? alg.children[1].bytes : null;
1625
+ } catch (_e) { return rc.subjectPublicKeyInfo.bytes; }
1626
+ if (issuerOid === keyAlg.oid && !isNullOrAbsentParams(issuerParams)) {
1627
+ return spliceSpkiParameters(rc.subjectPublicKeyInfo, keyAlg.oid, issuerParams);
1628
+ }
1629
+ return rc.subjectPublicKeyInfo.bytes;
1630
+ }
1631
+ function ocspDigest(alg, buf) { return subtle.digest(alg, buf).then(function (h) { return Buffer.from(h); }); }
1632
+
1633
+ // The checker implements NO OCSP response-extension semantics (the nonce is the
1634
+ // live client's, RFC 6960 sec. 4.4.1; CRL References / Archive Cutoff / Service
1635
+ // Locator are not consulted), so a CRITICAL responseExtension / singleExtension
1636
+ // may change a response's meaning in a way this code cannot honor -- treat it as
1637
+ // unusable (RFC 6960 sec. 4.4 / the RFC 5280 critical-extension contract, the OCSP
1638
+ // analogue of the crlChecker unhandled-critical skip). Returns true if `extList`
1639
+ // carries any critical extension.
1640
+ function ocspHasCriticalExtension(extList) {
1641
+ if (!extList) return false;
1642
+ for (var i = 0; i < extList.length; i++) if (extList[i].critical) return true;
1643
+ return false;
1644
+ }
1645
+
1646
+ // A SingleResponse's CertID names the target cert IFF its serial equals the
1647
+ // target's AND issuerNameHash + issuerKeyHash, recomputed under the CertID's OWN
1648
+ // hashAlgorithm, match the issuer (RFC 6960 sec. 4.1.1). A serial-only match would
1649
+ // let a "good" for issuer A serial N answer a query for issuer B serial N
1650
+ // (cross-CA substitution), so BOTH issuer hashes must match under the responder's
1651
+ // chosen algorithm -- never an assumed SHA-1.
1652
+ //
1653
+ // `issuerNameCandidates` is every byte encoding of the (name-chaining-validated)
1654
+ // issuer DN to try: the checked cert's issuer field (RFC 6960 sec. 4.1.1) plus,
1655
+ // when available, the issuer certificate's own subject encoding. These are RFC
1656
+ // 5280 sec. 7.1-equal (they chained) but MAY differ in DER -- case, whitespace, a
1657
+ // PrintableString-vs-UTF8String DirectoryString choice -- and a responder MAY have
1658
+ // hashed any of them; matching the CertID against any is still the SAME validated
1659
+ // issuer, because the issuerKeyHash + serial + authorized signature carry the
1660
+ // binding. The key (raw octets, no encoding variance) is matched once.
1661
+ async function ocspCertIdMatches(certID, cert, issuerNameCandidates, issuerKeyBits) {
1662
+ if (certID.serialNumberHex !== cert.serialNumberHex) return false;
1663
+ var hashName = OCSP_CERTID_HASHES[certID.hashAlgorithm.oid];
1664
+ if (!hashName) return false; // an unreproducible hash -> the match cannot be confirmed
1665
+ var keyHash = await ocspDigest(hashName, issuerKeyBits);
1666
+ if (!certID.issuerKeyHash.equals(keyHash)) return false;
1667
+ for (var i = 0; i < issuerNameCandidates.length; i++) {
1668
+ if (certID.issuerNameHash.equals(await ocspDigest(hashName, issuerNameCandidates[i]))) return true;
1669
+ }
1670
+ return false;
1671
+ }
1672
+
1673
+ // Resolve the signer of a BasicOCSPResponse to an AUTHORIZED responder's SPKI DER,
1674
+ // or null when none is authorized (RFC 6960 sec. 4.2.2.2). Two models: the issuing
1675
+ // CA signing directly (responderID identifies the issuer), or a CA-delegated
1676
+ // responder -- a certificate in `certs` issued by the SAME CA, valid at `time`,
1677
+ // bearing id-kp-OCSPSigning in its extendedKeyUsage. anyExtendedKeyUsage and an
1678
+ // ABSENT EKU do NOT authorize an OCSP delegate (the opposite of the path-validation
1679
+ // EKU default), so an ordinary leaf the CA issued cannot forge revocation status.
1680
+ // Fails closed at every branch (a malformed delegate, a control-byte DN, an
1681
+ // unreadable EKU -> skip that candidate).
1682
+ async function ocspAuthorizeResponder(basicResponse, cert, issuer, issuerKeyBits, time) {
1683
+ var rid = basicResponse.responderID;
1684
+ var matchesIssuer = false;
1685
+ try {
1686
+ if (rid.byName) matchesIssuer = dnEqual(rid.byName.rdns, cert.issuer.rdns);
1687
+ else if (rid.byKey) matchesIssuer = rid.byKey.equals(await ocspDigest("SHA-1", issuerKeyBits));
1688
+ } catch (_e) { matchesIssuer = false; }
1689
+ if (matchesIssuer) return issuer.workingPublicKey;
1690
+
1691
+ for (var i = 0; i < basicResponse.certs.length; i++) {
1692
+ var rc;
1693
+ try { rc = x509.parse(basicResponse.certs[i]); }
1694
+ catch (_e) { continue; }
1695
+ var identifies = false;
1696
+ try {
1697
+ if (rid.byName) identifies = dnEqual(rid.byName.rdns, rc.subject.rdns);
1698
+ else if (rid.byKey) identifies = rid.byKey.equals(await ocspDigest("SHA-1", ocspKeyValue(rc.subjectPublicKeyInfo.bytes)));
1699
+ } catch (_e) { identifies = false; }
1700
+ if (!identifies) continue;
1701
+ // The delegate MUST be issued directly by the CA that issued the target.
1702
+ var issuedByCa;
1703
+ try { issuedByCa = dnEqual(rc.issuer.rdns, cert.issuer.rdns); }
1704
+ catch (_e) { continue; }
1705
+ if (!issuedByCa) continue;
1706
+ if (rc.signatureValue.unusedBits !== 0) continue;
1707
+ if (!(await _verifyWithSpki(rc.signatureAlgorithm, rc.signatureValue.bytes, issuer.workingPublicKey, rc.tbsBytes))) continue;
1708
+ // The delegate certificate MUST itself be valid at the validation instant.
1709
+ if (time < rc.validity.notBefore || time > rc.validity.notAfter) continue;
1710
+ // The delegate MUST assert id-kp-OCSPSigning; anyEKU / an absent EKU do not.
1711
+ var eku;
1712
+ try { eku = decodeExt(rc, OID.extKeyUsage); }
1713
+ catch (_e) { continue; }
1714
+ if (!eku || eku.value.indexOf(OID_OCSP_SIGNING) === -1) continue;
1715
+ // A delegate asserting keyUsage MUST permit digitalSignature -- signing an OCSP
1716
+ // response is a digitalSignature operation (RFC 5280 sec. 4.2.1.3); an absent
1717
+ // keyUsage is unrestricted, an unreadable one is not authoritative.
1718
+ var ku;
1719
+ try { ku = decodeExt(rc, OID.keyUsage); }
1720
+ catch (_e) { continue; }
1721
+ if (ku && ku.value.digitalSignature !== true) continue;
1722
+ // A delegate carrying a critical extension this code does not process is
1723
+ // unusable, the same fail-closed rule the path validator applies to any cert
1724
+ // (RFC 5280 sec. 6.1.4(o)) -- an unknown critical constraint must not be ignored
1725
+ // while its key is trusted to authenticate revocation status. A RECOGNIZED
1726
+ // critical extension whose value is malformed is likewise rejected via the
1727
+ // shared structure validation, exactly as the path validator rejects it.
1728
+ if (unrecognizedCriticalExtension(rc, false)) continue;
1729
+ if (validateCriticalExtensionStructure(rc)) continue;
1730
+ // The delegate MUST carry id-pkix-ocsp-nocheck (RFC 6960 sec. 4.2.2.2.1): a
1731
+ // transport-free checker cannot otherwise confirm the responder certificate has
1732
+ // not itself been revoked, and a revoked OCSP-signing certificate would keep
1733
+ // signing "good" until its notAfter. A deployment that instead supplies the
1734
+ // responder's own status opts in through a future checker; absent nocheck, fail
1735
+ // closed (unknown) rather than trust an unvalidated responder.
1736
+ if (!findExt(rc, OID_OCSP_NOCHECK)) continue;
1737
+ return ocspResponderSpki(rc, issuer);
1738
+ }
1739
+ return null;
1740
+ }
1741
+
1742
+ /**
1743
+ * @primitive pki.path.ocspChecker
1744
+ * @signature pki.path.ocspChecker(responses) -> RevocationChecker
1745
+ * @since 0.1.32
1746
+ * @status experimental
1747
+ * @spec RFC 6960
1748
+ * @related pki.path.validate, pki.schema.ocsp.parseResponse, pki.path.crlChecker
1749
+ *
1750
+ * Build an OCSP-backed `RevocationChecker` for `pki.path.validate`'s
1751
+ * `revocationChecker` option from a set of pre-fetched OCSP responses (DER/PEM
1752
+ * or already-parsed). For each certificate it locates a SingleResponse whose
1753
+ * CertID binds this cert's serial to its issuer -- recomputing `issuerNameHash`
1754
+ * and `issuerKeyHash` under the CertID's own hashAlgorithm (SHA-1 or SHA-2), so
1755
+ * a response using either matches -- confirms the responder is authorized (the
1756
+ * issuing CA directly, or a valid CA-issued delegate bearing both id-kp-OCSPSigning
1757
+ * and id-pkix-ocsp-nocheck), verifies the response signature over
1758
+ * `tbsResponseDataBytes`, checks currency
1759
+ * (`thisUpdate`/`nextUpdate`), and reports `{ status: "good"|"revoked"|
1760
+ * "unknown" }`. A wrong-issuer CertID, an unauthorized responder, a stale,
1761
+ * not-yet-valid, nextUpdate-less, non-successful, or unverifiable response
1762
+ * yields `unknown`, which the validator fails closed unless `softFail` is set;
1763
+ * a `revoked` status surfaces its `revocationReason`. It is transport-free: the
1764
+ * caller supplies bytes it collected (an OCSP fetch or a stapled response), so
1765
+ * nonce anti-replay is the live client's responsibility and the residual replay
1766
+ * defense is the `thisUpdate`/`nextUpdate` currency window.
1767
+ *
1768
+ * @example
1769
+ * var checker = pki.path.ocspChecker([]); // no responses -> every cert is "unknown"
1770
+ * typeof checker.check; // "function"
1771
+ */
1772
+ function ocspChecker(responses) {
1773
+ var parsed = (responses || []).map(function (r) { return (r && r.responseStatus) ? r : ocsp.parseResponse(r); });
1774
+ return {
1775
+ check: async function (cert, issuer, ctx) {
1776
+ var time = ctx.time;
1777
+ var historical = ctx.historicalMode === true;
1778
+ // Issuer DN candidates to match the CertID against (RFC 6960 sec. 4.1.1 names
1779
+ // the checked cert's issuer field; a response MAY instead carry the issuer
1780
+ // certificate's own subject encoding -- sec. 7.1-equal but not byte-identical).
1781
+ var issuerNameCandidates = [cert.issuer.bytes];
1782
+ function addNameCandidate(nm) {
1783
+ if (nm && nm.bytes && !issuerNameCandidates.some(function (e) { return e.equals(nm.bytes); })) issuerNameCandidates.push(nm.bytes);
1784
+ }
1785
+ if (issuer.issuerCert) addNameCandidate(issuer.issuerCert.subject);
1786
+ addNameCandidate(issuer.workingIssuerName);
1787
+ var issuerKeyBits;
1788
+ try { issuerKeyBits = ocspKeyValue(issuer.workingPublicKey); }
1789
+ catch (_e) { return { status: "unknown", reason: "the issuer public key could not be read to recompute the OCSP CertID" }; }
1790
+
1791
+ // A serial is revoked if ANY authoritative, verified, current response says
1792
+ // so -- a clean response must never shadow a revoking one (the crlChecker
1793
+ // fail-closed law). "good" needs at least one authoritative match; every
1794
+ // other outcome is undetermined.
1795
+ var revokedResult = null;
1796
+ var sawGood = false;
1797
+ var sawUnknownStatus = false;
1798
+
1799
+ for (var k = 0; k < parsed.length; k++) {
1800
+ var resp = parsed[k];
1801
+ if (resp.responseStatus.code !== 0) continue; // non-successful -> conveys no status
1802
+ var br = resp.basicResponse;
1803
+ if (!br) continue;
1804
+ var signerSpki = await ocspAuthorizeResponder(br, cert, issuer, issuerKeyBits, time);
1805
+ if (!signerSpki) continue; // unauthorized responder
1806
+ if (!(await _verifyWithSpki(br.signatureAlgorithm, br.signature, signerSpki, br.tbsResponseDataBytes))) continue;
1807
+ // A critical responseExtension changes the meaning of the WHOLE response
1808
+ // and this code processes none -> the response is unusable.
1809
+ if (ocspHasCriticalExtension(br.responseExtensions)) continue;
1810
+ for (var s = 0; s < br.responses.length; s++) {
1811
+ var sr = br.responses[s];
1812
+ if (!(await ocspCertIdMatches(sr.certID, cert, issuerNameCandidates, issuerKeyBits))) continue; // not about this cert
1813
+ if (ocspHasCriticalExtension(sr.singleExtensions)) continue; // a critical per-response extension this code cannot process -> skip
1814
+ if (sr.thisUpdate > time) continue; // not yet valid
1815
+ if (!sr.nextUpdate || sr.nextUpdate < time) continue; // no bounded validity / stale -> fail closed
1816
+ var st = sr.certStatus;
1817
+ if (st.type === "revoked") {
1818
+ // A revocation is effective as of its revocationTime. Present-time
1819
+ // validation revokes regardless (a future revocationTime is
1820
+ // post-dating/skew, never "good"); only an EXPLICIT historical
1821
+ // validation defers a strictly-future revocation.
1822
+ if (historical && st.revocationTime instanceof Date && st.revocationTime.getTime() > time.getTime()) { sawGood = true; }
1823
+ else {
1824
+ revokedResult = {
1825
+ status: "revoked",
1826
+ revocationReason: st.revocationReason || null,
1827
+ reason: "certificate reported revoked by an authorized OCSP responder" + (st.revocationReason ? " (" + st.revocationReason + ")" : ""),
1828
+ };
1829
+ }
1830
+ } else if (st.type === "good") {
1831
+ sawGood = true;
1832
+ } else {
1833
+ sawUnknownStatus = true; // an explicit responder "unknown"
1834
+ }
1835
+ }
1836
+ }
1837
+ if (revokedResult) return revokedResult;
1838
+ if (sawGood) return { status: "good" };
1839
+ return {
1840
+ status: "unknown",
1841
+ reason: sawUnknownStatus
1842
+ ? "the OCSP responder reported certStatus unknown for this certificate"
1843
+ : "no authoritative, current, in-scope OCSP response covers this certificate",
1844
+ };
1845
+ },
1846
+ };
1847
+ }
1848
+
1607
1849
  module.exports = {
1608
1850
  validate: validate,
1609
1851
  crlChecker: crlChecker,
1852
+ ocspChecker: ocspChecker,
1610
1853
  };
package/lib/schema-all.js CHANGED
@@ -249,7 +249,7 @@ var FORMATS = [
249
249
  * @primitive pki.schema.all
250
250
  * @signature pki.schema.all() -> string[]
251
251
  * @since 0.1.7
252
- * @status experimental
252
+ * @status stable
253
253
  * @spec RFC 5280
254
254
  * @related pki.schema.parse
255
255
  *
@@ -264,7 +264,7 @@ function all() { return FORMATS.map(function (f) { return f.name; }); }
264
264
  * @primitive pki.schema.parse
265
265
  * @signature pki.schema.parse(input) -> parsed
266
266
  * @since 0.1.7
267
- * @status experimental
267
+ * @status stable
268
268
  * @spec RFC 5280
269
269
  * @related pki.schema.x509, pki.schema.all
270
270
  *
@@ -299,7 +299,7 @@ var ATTRIBUTE_CERTIFICATE = pkix.signedEnvelope(NS, ACINFO, {
299
299
  * @primitive pki.schema.attrcert.parse
300
300
  * @signature pki.schema.attrcert.parse(input) -> attributeCertificate
301
301
  * @since 0.1.14
302
- * @status experimental
302
+ * @status stable
303
303
  * @spec RFC 5755, X.509
304
304
  * @related pki.schema.parse, pki.schema.x509.parse
305
305
  *
@@ -348,7 +348,7 @@ function parseV1(input) {
348
348
  * @primitive pki.schema.attrcert.pemDecode
349
349
  * @signature pki.schema.attrcert.pemDecode(text, label?) -> Buffer
350
350
  * @since 0.1.14
351
- * @status experimental
351
+ * @status stable
352
352
  * @spec RFC 7468, RFC 5755
353
353
  * @related pki.schema.attrcert.parse
354
354
  *
@@ -367,7 +367,7 @@ function pemDecode(text, label) { return pkix.pemDecode(text, label === null ? n
367
367
  * @primitive pki.schema.attrcert.pemEncode
368
368
  * @signature pki.schema.attrcert.pemEncode(der, label?) -> string
369
369
  * @since 0.1.23
370
- * @status experimental
370
+ * @status stable
371
371
  * @spec RFC 7468, RFC 5755
372
372
  * @related pki.schema.attrcert.pemDecode
373
373
  *
package/lib/schema-cmp.js CHANGED
@@ -42,6 +42,7 @@ var pkix = require("./schema-pkix.js");
42
42
  var cms = require("./schema-cms.js");
43
43
  var crmf = require("./schema-crmf.js");
44
44
  var frameworkError = require("./framework-error.js");
45
+ var guard = require("./guard-all.js");
45
46
 
46
47
  var CmpError = frameworkError.CmpError;
47
48
  var PemError = frameworkError.PemError;
@@ -99,9 +100,7 @@ var PKI_FAILURE_INFO = schema.decode(function (n, ctx) {
99
100
  // PollRep checkAfter is a delay in seconds -- negative poisons scheduling, and
100
101
  // the value surfaces as an exact number or not at all.
101
102
  var CHECK_AFTER = schema.decode(function (n, ctx) {
102
- var v = asn1.read.integer(n);
103
- if (v < 0n || v > 2147483647n) throw ctx.E("cmp/bad-poll-rep", "checkAfter must be a non-negative delay in seconds (RFC 9810 sec. 5.3.22)");
104
- return Number(v);
103
+ return guard.range.uint31(asn1.read.integer(n), ctx.E, "cmp/bad-poll-rep", "checkAfter delay in seconds (RFC 9810 sec. 5.3.22)");
105
104
  });
106
105
 
107
106
  // A raw certificate / CRL / publication-info element -- surfaced byte-exact for
@@ -841,7 +840,7 @@ var PKI_MESSAGE = schema.seq([
841
840
  * @primitive pki.schema.cmp.parse
842
841
  * @signature pki.schema.cmp.parse(input) -> pkiMessage
843
842
  * @since 0.1.19
844
- * @status experimental
843
+ * @status stable
845
844
  * @spec RFC 9810, RFC 9483, RFC 9481
846
845
  * @related pki.schema.parse, pki.schema.crmf.parse, pki.schema.cms.parse
847
846
  *
@@ -895,7 +894,7 @@ var parse = pkix.makeParser({
895
894
  * @primitive pki.schema.cmp.pemDecode
896
895
  * @signature pki.schema.cmp.pemDecode(text, label?) -> Buffer
897
896
  * @since 0.1.19
898
- * @status experimental
897
+ * @status stable
899
898
  * @spec RFC 7468, RFC 9810
900
899
  * @related pki.schema.cmp.parse
901
900
  *
@@ -912,7 +911,7 @@ function pemDecode(text, label) { return pkix.pemDecode(text, label || "CMP", Pe
912
911
  * @primitive pki.schema.cmp.pemEncode
913
912
  * @signature pki.schema.cmp.pemEncode(der, label?) -> string
914
913
  * @since 0.1.19
915
- * @status experimental
914
+ * @status stable
916
915
  * @spec RFC 7468
917
916
  * @related pki.schema.cmp.pemDecode
918
917
  *
@@ -455,7 +455,7 @@ var CERT_REQ_MESSAGES = schema.seqOf(CERT_REQ_MSG, {
455
455
  * @primitive pki.schema.crmf.parse
456
456
  * @signature pki.schema.crmf.parse(input) -> certReqMessages
457
457
  * @since 0.1.17
458
- * @status experimental
458
+ * @status stable
459
459
  * @spec RFC 4211
460
460
  * @related pki.schema.parse, pki.schema.csr.parse
461
461
  *
@@ -496,7 +496,7 @@ var parse = pkix.makeParser({
496
496
  * @primitive pki.schema.crmf.pemDecode
497
497
  * @signature pki.schema.crmf.pemDecode(text, label?) -> Buffer
498
498
  * @since 0.1.17
499
- * @status experimental
499
+ * @status stable
500
500
  * @spec RFC 7468, RFC 4211
501
501
  * @related pki.schema.crmf.parse
502
502
  *
@@ -513,7 +513,7 @@ function pemDecode(text, label) { return pkix.pemDecode(text, label || null, Pem
513
513
  * @primitive pki.schema.crmf.pemEncode
514
514
  * @signature pki.schema.crmf.pemEncode(der, label) -> string
515
515
  * @since 0.1.23
516
- * @status experimental
516
+ * @status stable
517
517
  * @spec RFC 7468, RFC 4211
518
518
  * @related pki.schema.crmf.pemDecode
519
519
  *
@@ -320,7 +320,7 @@ var CSR_ATTRS = schema.seqOf(ATTR_OR_OID, {
320
320
  * @primitive pki.schema.csrattrs.parse
321
321
  * @signature pki.schema.csrattrs.parse(der) -> { items }
322
322
  * @since 0.1.24
323
- * @status experimental
323
+ * @status stable
324
324
  * @spec RFC 8951, RFC 9908, RFC 7030
325
325
  * @related pki.schema.parse, pki.est.buildEnrollAttributes
326
326
  *
@@ -46,6 +46,7 @@ var pkix = require("./schema-pkix.js");
46
46
  var cms = require("./schema-cms.js");
47
47
  var pkcs8 = require("./schema-pkcs8.js");
48
48
  var frameworkError = require("./framework-error.js");
49
+ var guard = require("./guard-all.js");
49
50
 
50
51
  var Pkcs12Error = frameworkError.Pkcs12Error;
51
52
  var PemError = frameworkError.PemError;
@@ -102,16 +103,10 @@ var PBKDF2_PARAMS = schema.seq([
102
103
  if (!m.fields.keyLength.present) {
103
104
  throw ctx.E("pkcs12/bad-mac-data", "PBMAC1 PBKDF2-params must carry keyLength (RFC 9579 sec. 5)");
104
105
  }
105
- // Both counters surface as exact JS numbers -- a value past the bound
106
- // would round silently on conversion and hand a verifier wrong inputs.
107
- var iterations = m.fields.iterationCount.value;
108
- if (iterations < 1n || iterations > 2147483647n) {
109
- throw ctx.E("pkcs12/bad-mac-data", "PBKDF2 iterationCount must be a positive integer within the iteration-count range");
110
- }
111
- var keyLength = m.fields.keyLength.value;
112
- if (keyLength < 1n || keyLength > 2147483647n) {
113
- throw ctx.E("pkcs12/bad-mac-data", "PBKDF2 keyLength must be a positive integer within the key-length range");
114
- }
106
+ // guard.range.positiveInt31 bounds + narrows each counter atomically -- a
107
+ // value past the bound would round silently and hand a verifier wrong inputs.
108
+ var iterationCount = guard.range.positiveInt31(m.fields.iterationCount.value, ctx.E, "pkcs12/bad-mac-data", "PBKDF2 iterationCount");
109
+ var keyLength = guard.range.positiveInt31(m.fields.keyLength.value, ctx.E, "pkcs12/bad-mac-data", "PBKDF2 keyLength");
115
110
  var prf = m.fields.prf.present ? m.fields.prf.value.result : null;
116
111
  // X.690 sec. 11.5 -- a DEFAULT-valued component must be omitted from a DER
117
112
  // encoding, and the prf DEFAULT is algid-hmacWithSHA1: hmacWithSHA1 with
@@ -124,8 +119,8 @@ var PBKDF2_PARAMS = schema.seq([
124
119
  }
125
120
  return {
126
121
  salt: m.fields.salt.value,
127
- iterationCount: Number(iterations),
128
- keyLength: Number(keyLength),
122
+ iterationCount: iterationCount,
123
+ keyLength: keyLength,
129
124
  prfOid: prf ? prf.oid : OID_HMAC_SHA1,
130
125
  prfName: prf ? prf.name : "hmacWithSHA1",
131
126
  };
@@ -172,8 +167,7 @@ var MAC_DATA = schema.seq([
172
167
  if (it.present) {
173
168
  var v = it.value;
174
169
  if (v === 1n) throw ctx.E("pkcs12/bad-mac-iterations", "iterations equal to its DEFAULT 1 must be omitted (X.690 sec. 11.5)");
175
- if (v < 1n || v > 2147483647n) throw ctx.E("pkcs12/bad-mac-iterations", "iterations must be a positive integer within the iteration-count range");
176
- iterations = Number(v);
170
+ iterations = guard.range.positiveInt31(v, ctx.E, "pkcs12/bad-mac-iterations", "MacData iterations");
177
171
  }
178
172
  var di = m.fields.mac.value.result;
179
173
  var pbmac1 = null;
@@ -529,7 +523,7 @@ function _buildBag(rec, state, ctx) {
529
523
  * @primitive pki.schema.pkcs12.parse
530
524
  * @signature pki.schema.pkcs12.parse(input) -> pfx
531
525
  * @since 0.1.18
532
- * @status experimental
526
+ * @status stable
533
527
  * @spec RFC 7292, RFC 9579
534
528
  * @defends ASN.1-parser-DoS (CWE-400)
535
529
  * @related pki.schema.parse, pki.schema.pkcs8.parse, pki.schema.cms.parse
@@ -577,7 +571,7 @@ var parse = pkix.makeParser({
577
571
  * @primitive pki.schema.pkcs12.pemDecode
578
572
  * @signature pki.schema.pkcs12.pemDecode(text, label?) -> Buffer
579
573
  * @since 0.1.18
580
- * @status experimental
574
+ * @status stable
581
575
  * @spec RFC 7468, RFC 7292
582
576
  * @related pki.schema.pkcs12.parse
583
577
  *
@@ -594,7 +588,7 @@ function pemDecode(text, label) { return pkix.pemDecode(text, label || "PKCS12",
594
588
  * @primitive pki.schema.pkcs12.pemEncode
595
589
  * @signature pki.schema.pkcs12.pemEncode(der, label?) -> string
596
590
  * @since 0.1.18
597
- * @status experimental
591
+ * @status stable
598
592
  * @spec RFC 7468
599
593
  * @related pki.schema.pkcs12.pemDecode
600
594
  *
@@ -500,12 +500,9 @@ function generalName(ns, opts) {
500
500
  if (constructed) throw ctx.E(code, "GeneralName [" + t + "] must be primitive (X.690 sec. 10.2)");
501
501
  if (GN_IA5[t]) {
502
502
  if (n.content.length === 0) throw ctx.E(code, "GeneralName [" + t + "] must be a non-empty IA5String");
503
- for (var i = 0; i < n.content.length; i++) {
504
- // 7-bit IA5, and no C0/DEL control byte -- an embedded NUL/control in a
505
- // dNSName/rfc822Name/URI enables a name-truncation/confusion bypass
506
- // (CVE-2009-2408 class) downstream, so reject it at decode.
507
- if (n.content[i] < 0x20 || n.content[i] > 0x7e) throw ctx.E(code, "GeneralName [" + t + "] must be a printable IA5String (no control bytes)");
508
- }
503
+ // 7-bit IA5, no C0/DEL control byte -- an embedded NUL/control in a
504
+ // dNSName/rfc822Name/URI enables a name-truncation bypass (CVE-2009-2408).
505
+ guard.name.assertPrintableIa5(n.content, ctx.E, code, "GeneralName [" + t + "]");
509
506
  if (decodeValue) value = n.content.toString("latin1");
510
507
  } else if (t === 7) {
511
508
  if (subtreeBase) {
@@ -619,11 +616,10 @@ function certExtensionDecoders(ns) {
619
616
  if (kids[i] && kids[i].tagClass === "universal" && kids[i].tagNumber === _T.INTEGER) {
620
617
  if (!cA) throw ns.E(C, "BasicConstraints pathLenConstraint is only permitted when cA is TRUE (RFC 5280 sec. 4.2.1.9)");
621
618
  var pl = readInt(kids[i], C, "pathLenConstraint");
622
- // Bounded before Number() narrowing -- a value past the safe-integer range
623
- // would round silently and be compared as a path-length ceiling, so it is
624
- // exact-or-rejected (the same rule the RSASSA-PSS/PKCS#12 counters follow).
625
- if (pl < 0n || pl > 2147483647n) throw ns.E(C, "BasicConstraints pathLenConstraint must be a non-negative integer within range");
626
- pathLen = Number(pl); i++;
619
+ // guard.range.uint31 bounds the decoded BigInt and narrows atomically -- a
620
+ // value past the safe-integer range would otherwise round silently and be
621
+ // compared as a path-length ceiling.
622
+ pathLen = guard.range.uint31(pl, ns.E, C, "BasicConstraints pathLenConstraint (RFC 5280 sec. 4.2.1.9)"); i++;
627
623
  }
628
624
  if (i !== kids.length) throw ns.E(C, "BasicConstraints has unexpected trailing fields");
629
625
  return { cA: cA, pathLenConstraint: pathLen };
@@ -746,11 +742,11 @@ function certExtensionDecoders(ns) {
746
742
  pcLastTag = k.tagNumber;
747
743
  var v;
748
744
  try { v = asn1.read.integerImplicit(k, k.tagNumber); } catch (e) { throw ns.E(C, "PolicyConstraints field must be an INTEGER", e); }
749
- // Bounded before Number() narrowing (a skip count past the safe-integer
750
- // range would round silently) -- exact-or-rejected.
751
- if (v < 0n || v > 2147483647n) throw ns.E(C, "PolicyConstraints skip count must be a non-negative integer within range");
752
- if (k.tagNumber === 0) rep = Number(v);
753
- else if (k.tagNumber === 1) ipm = Number(v);
745
+ // guard.range.uint31 bounds + narrows atomically (a skip count past the
746
+ // safe-integer range would round silently).
747
+ var skip = guard.range.uint31(v, ns.E, C, "PolicyConstraints skip count (RFC 5280 sec. 4.2.1.11)");
748
+ if (k.tagNumber === 0) rep = skip;
749
+ else if (k.tagNumber === 1) ipm = skip;
754
750
  else throw ns.E(C, "PolicyConstraints has an unexpected field [" + k.tagNumber + "]");
755
751
  });
756
752
  return { requireExplicitPolicy: rep, inhibitPolicyMapping: ipm };
@@ -762,10 +758,9 @@ function certExtensionDecoders(ns) {
762
758
  var n = decodeTop(buf, C, "InhibitAnyPolicy");
763
759
  if (n.tagClass !== "universal" || n.tagNumber !== _T.INTEGER) throw ns.E(C, "InhibitAnyPolicy must be an INTEGER (RFC 5280 sec. 4.2.1.14)");
764
760
  var v = readInt(n, C, "InhibitAnyPolicy");
765
- // Bounded before Number() narrowing (a skip count past the safe-integer
766
- // range would round silently) -- exact-or-rejected.
767
- if (v < 0n || v > 2147483647n) throw ns.E(C, "InhibitAnyPolicy skip count must be a non-negative integer within range");
768
- return Number(v);
761
+ // guard.range.uint31 bounds + narrows atomically (a skip count past the
762
+ // safe-integer range would round silently).
763
+ return guard.range.uint31(v, ns.E, C, "InhibitAnyPolicy skip count (RFC 5280 sec. 4.2.1.14)");
769
764
  }
770
765
 
771
766
  // subjectAltName / issuerAltName ::= GeneralNames -- decoded values surfaced.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -72,6 +72,6 @@
72
72
  "devDependencies": {
73
73
  "c8": "11.0.0",
74
74
  "esbuild": "0.28.1",
75
- "eslint": "10.3.0"
75
+ "eslint": "10.7.0"
76
76
  }
77
77
  }
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:9b3c1a14-9eb9-492b-a652-b6fa68d6b4b4",
5
+ "serialNumber": "urn:uuid:87ae1f84-9b42-4b45-820b-1323915f3fbe",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-11T07:38:46.451Z",
8
+ "timestamp": "2026-07-11T18:49:24.325Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.1.30",
22
+ "bom-ref": "@blamejs/pki@0.1.32",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.30",
25
+ "version": "0.1.32",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.1.30",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.32",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.1.30",
57
+ "ref": "@blamejs/pki@0.1.32",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]