@blamejs/pki 0.2.31 → 0.2.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,18 @@ 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.2.32 — 2026-07-16
8
+
9
+ X.509 certificates now decode the Microsoft Active Directory Certificate Services enrollment extensions, and pki.lint's critical-extension check is aligned with certification-path validation.
10
+
11
+ ### Added
12
+
13
+ - pki.schema.x509.parse decodes the Microsoft Active Directory Certificate Services enrollment extensions ([MS-WCCE] / [MS-CRTD]): the v2 certificate template (szOID-CERTIFICATE_TEMPLATE -- template OID plus major/minor version), the legacy v1 template name (szOID-ENROLL_CERTTYPE_EXTENSION), the CA version (szOID-CERTSRV_CA_VERSION, surfaced as the raw value and as the CA key index / certificate index split), the previous-CA-certificate hash (szOID-CERTSRV_PREVIOUS_CERT_HASH), and the application policies (szOID-APPLICATION_CERT_POLICIES, decoded as RFC 5280 certificate policies). Each is rendered by pki.inspect and fails closed with a typed error on a malformed shape. The version fields accept the full 32-bit range Active Directory uses, not a signed subset.
14
+
15
+ ### Changed
16
+
17
+ - pki.lint's unrecognized-critical-extension check now mirrors certification-path validation: it reports a critical extension whose semantics the path validator does not process -- an authority/subject key identifier, a freshest-CRL pointer, an SCT list, a qualified-certificate statement, or a Microsoft enterprise-CA extension -- rather than treating any extension it can merely decode as processed. precertificatePoison, which RFC 6962 requires to be critical, is not reported. A conforming relying party must reject a critical extension it cannot process, so a certificate whose critical enterprise or qualified-certificate constraints this toolkit does not enforce is now surfaced by the linter.
18
+
7
19
  ## v0.2.31 — 2026-07-16
8
20
 
9
21
  X.509 certificates now decode the RFC 3739 / ETSI EN 319 412-5 qualified-certificate qcStatements extension, and the sigstore verifier gains a low-order EdDSA public-key gate.
package/README.md CHANGED
@@ -203,7 +203,7 @@ is callable today; nothing below is a stub.
203
203
  | `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
204
204
  | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation and certificate/PKCS#8 import — the RFC 9935 seed / expandedKey / both private-key CHOICE is validated fail-closed, so an OpenSSL-legacy bare-seed or an internally inconsistent key is rejected with a typed error (KEM encapsulation lands with CMS KEM-decrypt). Zero-dependency, OpenSSL-interoperable |
205
205
  | `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
206
- | `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields, with named + partly-decoded extensions — including the RFC 3739 / ETSI EN 319 412-5 qualified-certificate `qcStatements` (EU-qualified declaration, reliance limit, QSCD flag, certificate type, retention, PDS URLs, country of qualification; unknown statements preserved opaque), fail-closed — `parse`, `pemDecode`, `pemEncode` |
206
+ | `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields, with named + partly-decoded extensions — including the RFC 3739 / ETSI EN 319 412-5 qualified-certificate `qcStatements` (EU-qualified declaration, reliance limit, QSCD flag, certificate type, retention, PDS URLs, country of qualification; unknown statements preserved opaque) and the Microsoft Active Directory Certificate Services enrollment extensions (certificate template, CA version, previous-CA-certificate hash, application policies), fail-closed — `parse`, `pemDecode`, `pemEncode` |
207
207
  | `pki.schema.c509` | Parse C509 CBOR-encoded certificates (draft-ietf-cose-cbor-encoded-cert) — the compact CBOR profile of X.509, decoded fail-closed under deterministic CBOR; an explicit `parse` call (CBOR, not DER, so not auto-routed) |
208
208
  | `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode`, `pemEncode` |
209
209
  | `pki.schema.csr` | Parse DER / PEM PKCS#10 certification requests per RFC 2986 — subject DN, public key, requested attributes, signature, fail-closed — `parse`, `pemDecode`, `pemEncode` |
package/lib/inspect.js CHANGED
@@ -335,6 +335,19 @@ var EXT_RENDERERS = {
335
335
  return label;
336
336
  }).join("\n");
337
337
  },
338
+ msCertificateTemplate: function (decoded, inner) {
339
+ var v = decoded.templateMajorVersion === null ? "" : " v" + decoded.templateMajorVersion + "." + (decoded.templateMinorVersion === null ? 0 : decoded.templateMinorVersion);
340
+ return inner + "Template: " + (decoded.name || decoded.templateID) + v;
341
+ },
342
+ msEnrollCertType: function (decoded, inner) {
343
+ return inner + "Cert Type: " + _clean(String(decoded));
344
+ },
345
+ msCaVersion: function (decoded, inner) {
346
+ return inner + "CA Version: V" + decoded.caKeyIndex + "." + decoded.certIndex;
347
+ },
348
+ msPreviousCertHash: function (decoded, inner) {
349
+ return inner + _hexColon(Buffer.isBuffer(decoded) ? decoded : Buffer.alloc(0), { upper: true });
350
+ },
338
351
  subjectAltName: _renderAltName,
339
352
  issuerAltName: _renderAltName,
340
353
  certificatePolicies: function (decoded, inner) {
@@ -422,6 +435,8 @@ var EXT_RENDERERS = {
422
435
  return akiLines.length ? akiLines.join("\n") : inner + "keyid:(none)";
423
436
  },
424
437
  };
438
+ // [MS-WCCE] szOID-APPLICATION_CERT_POLICIES decodes as certificatePolicies, so it renders identically.
439
+ EXT_RENDERERS.msApplicationPolicies = EXT_RENDERERS.certificatePolicies;
425
440
 
426
441
  // Coverage residual -- the EXT_RENDERERS entry fallbacks are unreachable because each shared
427
442
  // decoder (and asn1.read.oid) already narrows the shape before the renderer runs:
package/lib/lint.js CHANGED
@@ -29,6 +29,7 @@ var guard = require("./guard-all");
29
29
  var oid = require("./oid");
30
30
  var x509 = require("./schema-x509");
31
31
  var pkix = require("./schema-pkix");
32
+ var path = require("./path-validate");
32
33
  var C = require("./constants");
33
34
  var ipUtils = require("./ip-utils");
34
35
 
@@ -237,24 +238,32 @@ function _subjectCNs(cert) {
237
238
  return out;
238
239
  }
239
240
 
240
- // An extension is "recognized" (RFC 5280 4.2: a consumer MUST reject a critical extension
241
- // it does NOT recognize / cannot process) when the toolkit has a DECODER for it -- the
242
- // shared certExtensionDecoders table IS the set of extensions this toolkit processes. This
243
- // is registry-driven (no curated list to omit a legitimately-critical extension such as
244
- // policyMappings, which the table decodes) and correctly scoped to EXTENSION OIDs: an
245
- // algorithm / EKU-purpose OID the name registry happens to resolve is NOT in the decoder
246
- // table, so a critical extension carrying such an OID is still flagged.
247
- //
248
- // The one exception: an extension the table DECODES for display but whose CRITICAL semantics
249
- // the toolkit does not enforce. qcStatements decodes (for pki.inspect / this linter) yet a
250
- // critical instance asserts qualified-certificate constraints (a reliance limit, a certificate
251
- // purpose) no consumer here processes -- path-validate rejects a critical qcStatements as
252
- // unrecognized-critical for exactly that reason. Structural decodability is not validation
253
- // processing, so a critical qcStatements is still flagged here, keeping lint consistent with
254
- // certification-path validation.
255
- var _DECODE_ONLY_UNPROCESSED_CRITICAL = {};
256
- _DECODE_ONLY_UNPROCESSED_CRITICAL[oid.byName("qcStatements")] = true;
257
- function _isUnknownExtension(extOid) { return !EXT_DECODERS[extOid] || _DECODE_ONLY_UNPROCESSED_CRITICAL[extOid] === true; }
241
+ // An extension's CRITICALITY is honoured (RFC 5280 4.2: a consumer MUST reject a critical extension
242
+ // it cannot PROCESS) only when this toolkit actually processes its semantics. That authority is
243
+ // path-validate's PROCESSED_EXTENSIONS (the RFC 5280 sec. 6.1 path-processing set), NOT the decoder
244
+ // table: certExtensionDecoders also DECODES many extensions purely for display (qcStatements, the MS
245
+ // enterprise-CA extensions, the SCT list, the *KeyIdentifiers, freshestCRL, issuerAltName) whose
246
+ // critical semantics are not enforced -- path-validate rejects a critical instance of any of those
247
+ // as unrecognized-critical, so the linter flags them too (structural decodability is not validation
248
+ // processing). The one decode-only extension that is LEGITIMATELY critical is precertificatePoison
249
+ // (RFC 6962 sec. 3.1 REQUIRES it critical), so it is allow-listed. Everything else -- a processed
250
+ // extension, or an unregistered OID (algorithm / EKU-purpose OID the name registry resolves but that
251
+ // is no extension) -- falls out correctly: processed -> recognized, unregistered -> unknown. Driving
252
+ // this off the validator's set (not the decoder table) means a newly-decoded extension is
253
+ // flagged-when-critical automatically until its semantics are actually processed.
254
+ var _CRITICAL_LEGIT = {};
255
+ _CRITICAL_LEGIT[oid.byName("precertificatePoison")] = true;
256
+ // Mirrors path-validate's unrecognizedCriticalExtension. An extension not in PROCESSED_EXTENSIONS is
257
+ // unknown everywhere (precertificatePoison is the one legitimately-critical exception). An extension
258
+ // that IS processed for an intermediate but is unprocessed on the TARGET (policyMappings) is also
259
+ // flagged: path-validate decides target status purely by path position (i === n), so ANY certificate
260
+ // -- a CA included -- can be validated as the final/target certificate, where a critical instance is
261
+ // rejected. A linter has no path context and cannot rule that out, so it flags these regardless of
262
+ // cA-ness (policyMappings is SHOULD-be-non-critical everywhere, so this never mis-flags conformant input).
263
+ function _isUnknownExtension(extOid) {
264
+ if (path.PROCESSED_EXTENSIONS[extOid] !== true) return _CRITICAL_LEGIT[extOid] !== true;
265
+ return path.TARGET_UNPROCESSED_IF_CRITICAL[extOid] === true;
266
+ }
258
267
 
259
268
  // RSA-modulus / EC-curve helpers for the weak-key lint. Each returns null when the key
260
269
  // material cannot be read as expected (a malformed RSA modulus, or EC parameters that are
package/lib/oid.js CHANGED
@@ -95,6 +95,13 @@ var FAMILIES = {
95
95
  // ETSI EN 319 412-5 QcType member OIDs (the certificate-purpose types) on the id-etsi-qct arc.
96
96
  etsiQcType: { base: [0, 4, 0, 1862, 1, 6], of: { qctEsign: 1, qctEseal: 2, qctWeb: 3 } },
97
97
 
98
+ // [MS-WCCE] AD CS enrollment extensions on the id-microsoft-21 arc (szOID-CERTSRV / szOID-CERTIFICATE_TEMPLATE / szOID-APPLICATION_CERT_POLICIES).
99
+ msEnrollment: { base: [1, 3, 6, 1, 4, 1, 311, 21], of: {
100
+ msCaVersion: 1, msPreviousCertHash: 2, msCertificateTemplate: 7, msApplicationPolicies: 10 } },
101
+
102
+ // [MS-CRTD] legacy v1 enroll cert-type name on the id-microsoft-20 arc (szOID-ENROLL_CERTTYPE_EXTENSION).
103
+ msEnrollmentLegacy: { base: [1, 3, 6, 1, 4, 1, 311, 20], of: { msEnrollCertType: 2 } },
104
+
98
105
  // id-aca -- RFC 5755 attribute-certificate attribute types on the id-pkix 10 arc:
99
106
  // authenticationInfo sec. 4.4.1 .. group sec. 4.4.4, plus encAttrs sec. 7.1 (the encrypted-
100
107
  // attribute wrapper, syntax ContentInfo). { id-aca 5 } is reserved.
@@ -101,6 +101,11 @@ var PROCESSED_EXTENSIONS = {};
101
101
  OID.policyMappings, OID.policyConstraints, OID.inhibitAnyPolicy, OID.subjectAltName,
102
102
  OID.extKeyUsage, OID.cRLDistributionPoints].
103
103
  forEach(function (o) { PROCESSED_EXTENSIONS[o] = true; });
104
+ // Frozen after seeding: this exact object is both consulted by the critical-extension check here and
105
+ // exported (for pki.lint to stay consistent). A caller must not be able to add an OID -- doing so
106
+ // would make an attacker's critical, decoder-less extension pass as "processed" and skip both the
107
+ // unrecognized-critical check and structural validation. Freezing makes any such write a no-op.
108
+ Object.freeze(PROCESSED_EXTENSIONS);
104
109
 
105
110
  // ---- signature verify bridge (NEW 6) ---------------------------------------
106
111
 
@@ -1061,6 +1066,7 @@ function applyPolicyMappings(state, mappings, i) {
1061
1066
  // a terminal CA cert is conforming and is NOT treated as unprocessed here.)
1062
1067
  var TARGET_UNPROCESSED_IF_CRITICAL = {};
1063
1068
  TARGET_UNPROCESSED_IF_CRITICAL[OID.policyMappings] = true;
1069
+ Object.freeze(TARGET_UNPROCESSED_IF_CRITICAL);
1064
1070
 
1065
1071
  function unrecognizedCriticalExtension(cert, isTarget) {
1066
1072
  for (var i = 0; i < cert.extensions.length; i++) {
@@ -2023,4 +2029,13 @@ module.exports = {
2023
2029
  crlChecker: crlChecker,
2024
2030
  ocspChecker: ocspChecker,
2025
2031
  verifyOcspResponse: verifyOcspResponse,
2032
+ // The set of extension OIDs whose CRITICAL semantics this validator processes (RFC 5280 sec. 6.1).
2033
+ // Exposed so a linter can distinguish "processed" from "merely decoded" and stay consistent with
2034
+ // the path-validation verdict on a critical extension -- a decoder in certExtensionDecoders is NOT
2035
+ // by itself proof the criticality is honoured. Both sets are frozen so a caller cannot mutate them.
2036
+ PROCESSED_EXTENSIONS: PROCESSED_EXTENSIONS,
2037
+ // Extensions that ARE processed for an intermediate CA but are unprocessed on the target/leaf, so a
2038
+ // critical instance on the target fails closed (RFC 5280 sec. 6.1.5(f)) -- policyMappings is
2039
+ // prepare-next-only and SHOULD-be-non-critical. A linter mirrors this for a leaf certificate.
2040
+ TARGET_UNPROCESSED_IF_CRITICAL: TARGET_UNPROCESSED_IF_CRITICAL,
2026
2041
  };
@@ -1031,6 +1031,57 @@ function certExtensionDecoders(ns) {
1031
1031
  return out;
1032
1032
  }
1033
1033
 
1034
+ // [MS-WCCE] szOID-CERTIFICATE_TEMPLATE -- CertificateTemplateOID ::= SEQUENCE {
1035
+ // templateID OID, templateMajorVersion INTEGER OPTIONAL, templateMinorVersion INTEGER OPTIONAL }.
1036
+ // The two versions are DWORDs (0..2^32-1), NOT uint31 -- a CA key/template revision with bit 31
1037
+ // set is a legal DWORD, so bound to the whole safe-integer range, never guard.range.uint31.
1038
+ function msCertificateTemplate(buf) {
1039
+ var C = ns.prefix + "/bad-ms-certificate-template";
1040
+ var kids = seqChildren(buf, C, "CertificateTemplate");
1041
+ if (!kids.length || kids.length > 3) throw ns.E(C, "CertificateTemplateOID must be a SEQUENCE of a templateID and up to two version INTEGERs");
1042
+ if (kids[0].tagClass !== "universal" || kids[0].tagNumber !== _T.OBJECT_IDENTIFIER) throw ns.E(C, "CertificateTemplateOID templateID must be an OBJECT IDENTIFIER");
1043
+ var id;
1044
+ try { id = asn1.read.oid(kids[0]); } catch (e) { throw ns.E(C, "CertificateTemplateOID templateID must be an OBJECT IDENTIFIER", e); }
1045
+ function ver(i, what) {
1046
+ if (!kids[i]) return null;
1047
+ if (kids[i].tagClass !== "universal" || kids[i].tagNumber !== _T.INTEGER) throw ns.E(C, what + " must be an INTEGER");
1048
+ return guard.range.int(readInt(kids[i], C, what), 0n, 4294967295n, ns.E, C, what);
1049
+ }
1050
+ return { templateID: id, name: ns.oid.name(id) || null, templateMajorVersion: ver(1, "templateMajorVersion"), templateMinorVersion: ver(2, "templateMinorVersion") };
1051
+ }
1052
+
1053
+ // [MS-CRTD] szOID-ENROLL_CERTTYPE_EXTENSION -- the legacy v1 template name as a BARE BMPString.
1054
+ // asn1.read.string accepts any universal string tag, so the explicit BMP_STRING assert is the guard.
1055
+ function msEnrollCertType(buf) {
1056
+ var C = ns.prefix + "/bad-ms-enroll-cert-type";
1057
+ var n = decodeTop(buf, C, "EnrollCertType");
1058
+ if (n.tagClass !== "universal" || n.tagNumber !== _T.BMP_STRING) throw ns.E(C, "the enroll cert-type name must be a BMPString");
1059
+ try { return asn1.read.string(n); } catch (e) { throw ns.E(C, "the enroll cert-type name must be a well-formed BMPString", e); }
1060
+ }
1061
+
1062
+ // [MS-WCCE] szOID-CERTSRV_CA_VERSION -- INTEGER (0..2^32-1) = (caKeyIndex << 16) | certIndex.
1063
+ function msCaVersion(buf) {
1064
+ var C = ns.prefix + "/bad-ms-ca-version";
1065
+ var n = decodeTop(buf, C, "CACertVersion");
1066
+ if (n.tagClass !== "universal" || n.tagNumber !== _T.INTEGER) throw ns.E(C, "CACertVersion must be an INTEGER");
1067
+ var v = guard.range.int(readInt(n, C, "CACertVersion"), 0n, 4294967295n, ns.E, C, "CACertVersion (DWORD)");
1068
+ return { caVersion: v, caKeyIndex: v >>> 16, certIndex: v & 0xffff };
1069
+ }
1070
+
1071
+ // [MS-WCCE] szOID-CERTSRV_PREVIOUS_CERT_HASH -- the SHA-1 thumbprint of the prior CA certificate.
1072
+ // AD CS stamps the Windows certificate THUMBPRINT here, which Windows computes with SHA-1 regardless
1073
+ // of the certificate's signature algorithm, so the OCTET STRING is exactly 20 octets. An empty or
1074
+ // other-length value is malformed renewal metadata and fails closed.
1075
+ function msPreviousCertHash(buf) {
1076
+ var C = ns.prefix + "/bad-ms-previous-cert-hash";
1077
+ var n = decodeTop(buf, C, "CAPrevCertHash");
1078
+ var hash;
1079
+ try { hash = Buffer.concat([asn1.read.octetString(n)]); }
1080
+ catch (e) { throw ns.E(C, "CAPrevCertHash must be an OCTET STRING", e); }
1081
+ if (hash.length !== 20) throw ns.E(C, "CAPrevCertHash must be a 20-octet SHA-1 certificate thumbprint");
1082
+ return hash;
1083
+ }
1084
+
1034
1085
  var byOid = {};
1035
1086
  byOid[O("qcStatements")] = qcStatements;
1036
1087
  byOid[O("basicConstraints")] = basicConstraints;
@@ -1049,6 +1100,11 @@ function certExtensionDecoders(ns) {
1049
1100
  byOid[O("precertificatePoison")] = precertPoison;
1050
1101
  byOid[O("cRLDistributionPoints")] = crlDistributionPoints;
1051
1102
  byOid[O("freshestCRL")] = crlDistributionPoints;
1103
+ byOid[O("msCertificateTemplate")] = msCertificateTemplate;
1104
+ byOid[O("msEnrollCertType")] = msEnrollCertType;
1105
+ byOid[O("msCaVersion")] = msCaVersion;
1106
+ byOid[O("msPreviousCertHash")] = msPreviousCertHash;
1107
+ byOid[O("msApplicationPolicies")] = certificatePolicies; // [MS-WCCE] = RFC 5280 certificatePolicies, reused not re-implemented
1052
1108
  return { byOid: byOid };
1053
1109
  }
1054
1110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.31",
3
+ "version": "0.2.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",
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:c89087a8-9dba-4496-9704-cd639f3c4038",
5
+ "serialNumber": "urn:uuid:c1caa29b-a81a-443d-9296-76988e159993",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T21:10:43.618Z",
8
+ "timestamp": "2026-07-16T23:57:14.646Z",
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.2.31",
22
+ "bom-ref": "@blamejs/pki@0.2.32",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.31",
25
+ "version": "0.2.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.2.31",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.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.2.31",
57
+ "ref": "@blamejs/pki@0.2.32",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]