@blamejs/pki 0.3.24 → 0.3.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
@@ -223,6 +223,21 @@ var LIMITS = {
223
223
  // overridable (opts.maxDepth / opts.maxCandidatesConsidered).
224
224
  PATH_BUILD_MAX_DEPTH: 20,
225
225
  PATH_BUILD_MAX_CANDIDATES: 1000,
226
+ // AIA caIssuers network-fetch bounds (pki.path.build with opts.fetchAia). Fetching an issuer over the
227
+ // network from an untrusted certificate's authorityInfoAccess opens an SSRF / amplification surface (a
228
+ // hostile mesh where each fetched cert advertises a fresh caIssuers URL). PATH_AIA_MAX_FETCHES is the
229
+ // TOTAL network GET budget across a whole build() call (a breach throws path/aia-fetch-limit);
230
+ // PATH_AIA_MAX_PER_CERT caps how many caIssuers URIs are tried for a single certificate (an AIA MAY carry
231
+ // many). Both are small by default and operator-overridable (opts.maxAiaFetches / opts.maxAiaPerCert).
232
+ PATH_AIA_MAX_FETCHES: 10,
233
+ PATH_AIA_MAX_PER_CERT: 3,
234
+ // A caIssuers response is one certificate or a short chain -- never a 24 MiB bundle. Bounding the AIA
235
+ // fetch below the general HTTP ceiling (and capping the certificate COUNT a single response contributes)
236
+ // stops a hostile-but-TLS-trusted AIA endpoint from forcing tens of thousands of certificate parses per
237
+ // fetch (parse work bounded by COUNT, not only by bytes). Both operator-overridable (opts.maxResponseBytes
238
+ // tightens downward only; the count cap is a fixed defense).
239
+ PATH_AIA_MAX_RESPONSE_BYTES: BYTES.mib(1),
240
+ PATH_AIA_MAX_CERTS_PER_RESPONSE: 16,
226
241
  // PKCS#12 container ceilings. A PFX carries lists at three altitudes
227
242
  // (ContentInfos per AuthenticatedSafe, SafeBags per SafeContents,
228
243
  // attributes per bag) and can chain fresh DER blobs inside OCTET STRINGs,
package/lib/est.js CHANGED
@@ -56,7 +56,6 @@ var oid = require("./oid");
56
56
  var constants = require("./constants");
57
57
  var cms = require("./schema-cms");
58
58
  var x509 = require("./schema-x509");
59
- var crl = require("./schema-crl");
60
59
  var pkcs8 = require("./schema-pkcs8");
61
60
  var csr = require("./schema-csr");
62
61
  var frameworkError = require("./framework-error");
@@ -66,8 +65,6 @@ var retryAfter = require("./http-retry-after");
66
65
 
67
66
  var EstError = frameworkError.EstError;
68
67
  function E(code, message, cause) { return new EstError(code, message, cause); }
69
-
70
- var ID_DATA = oid.byName("data");
71
68
  var ID_SIGNED_DATA = oid.byName("signedData");
72
69
  var OID_CHALLENGE_PASSWORD = oid.byName("challengePassword");
73
70
  var OID_DECRYPT_KEY_ID = oid.byName("decryptKeyID");
@@ -212,36 +209,9 @@ function splitMultipartMixed(body, contentType) {
212
209
  * r.certificates; // -> [Buffer, ...] raw, unordered
213
210
  */
214
211
  function parseCertsOnly(der) {
215
- var r;
216
- try { r = cms.parse(der); }
217
- catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-response", "the EST response did not decode as CMS: " + ((e && e.message) || String(e)), e); }
218
- if (r.contentTypeName !== "signedData") throw E("est/not-certs-only", "an EST certs-only response must be a CMS SignedData (RFC 5272 sec. 4.1)");
219
- if (r.encapContentInfo.eContentType !== ID_DATA || r.encapContentInfo.eContent !== null) {
220
- throw E("est/not-certs-only", "a certs-only Simple PKI Response must carry id-data with no eContent (RFC 5272 sec. 4.1)");
221
- }
222
- if (r.signerInfos.length !== 0) throw E("est/not-certs-only", "a certs-only Simple PKI Response must have empty signerInfos (RFC 5272 sec. 4.1)");
223
- if (!r.certificates || r.certificates.length === 0) throw E("est/no-certificates", "an EST certs-only response must contain at least one certificate (RFC 7030 sec. 4.1.3)");
224
- for (var i = 0; i < r.certificates.length; i++) {
225
- if (r.certificates[i].tagClass !== "universal") throw E("est/bad-certificate-choice", "EST exchanges plain X.509 certificates; a tagged CertificateChoices alternative is not permitted (RFC 7030)");
226
- // A universal-SEQUENCE CertificateChoice must be a well-formed X.509
227
- // Certificate, not merely any SEQUENCE. Parse it structurally (still
228
- // returning the raw bytes below) so a malformed response fails closed.
229
- try { x509.parse(r.certificates[i].bytes); }
230
- catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-certificate", "a certs-only response carried a non-certificate in its certificates field (RFC 5272 sec. 4.1)", e); }
231
- }
232
- var crls = r.crls || [];
233
- for (var j = 0; j < crls.length; j++) {
234
- // A RevocationInfoChoice is a plain X.509 CertificateList or a [1] otherRevInfo;
235
- // EST surfaces CRLs, so reject the tagged alternative and structurally validate
236
- // each universal entry as a CertificateList (mirrors the certificate path).
237
- if (crls[j].tagClass !== "universal") throw E("est/bad-crl", "an EST response CRL must be a plain X.509 CertificateList, not a tagged otherRevInfo alternative (RFC 5652 sec. 10.2.1)");
238
- try { crl.parse(crls[j].bytes); }
239
- catch (e) { if (e instanceof EstError) throw e; throw E("est/bad-crl", "a response carried a non-CRL in its crls field", e); }
240
- }
241
- return {
242
- certificates: r.certificates.map(function (c) { return c.bytes; }),
243
- crls: crls.map(function (c) { return c.bytes; }),
244
- };
212
+ // The certs-only Simple PKI Response shape is a CMS concern shared with AIA path building; the reader lives
213
+ // in schema-cms. The "est" prefix keeps the exact est/* codes (est/not-certs-only, est/no-certificates, ...).
214
+ return cms.parseCertsOnly(der, E, "est");
245
215
  }
246
216
 
247
217
  // Pick the issued certificate from a certs-only response by matching its public
@@ -727,9 +697,30 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
727
697
  var redirects = 0;
728
698
  var authTried = false;
729
699
  var initialOrigin = url.origin; // the origin the caller intended to authenticate to
730
- var tls = budgets.tls; // narrowed (mTLS client identity stripped) on a cross-origin hop
700
+ // The per-hop TLS: the origin-specific identity (mTLS cert/key + pinned SNI) is sent ONLY to the caller's
701
+ // configured origin; a cross-origin hop gets a narrowed copy with those stripped (checkServerIdentity kept).
702
+ // Derived FRESH from budgets.tls each hop (the ACME _tlsFor model) so a redirect back to the original origin
703
+ // restores its identity rather than permanently losing the SNI at the first cross-origin boundary.
704
+ function _tlsFor(u) {
705
+ var t = budgets.tls;
706
+ if (t && u.origin !== initialOrigin && (t.cert != null || t.key != null || t.servername != null)) {
707
+ t = Object.assign({}, t);
708
+ delete t.cert; delete t.key; delete t.servername;
709
+ }
710
+ return t;
711
+ }
712
+ // The per-hop Authorization: the HTTP Basic credential (established after a 401 on the caller's origin) is
713
+ // sent ONLY to that origin. A cross-origin hop travels unauthenticated, but a redirect back to the origin
714
+ // restores it -- so an authenticated flow that bounces off-origin and returns still completes (mirrors _tlsFor).
715
+ var authValue = null;
716
+ function _headersFor(u) {
717
+ if (!authValue || u.origin !== initialOrigin) return headers;
718
+ var hh = Object.assign({}, headers);
719
+ hh.authorization = authValue;
720
+ return hh;
721
+ }
731
722
  function step() {
732
- return transport({ method: method, url: url.href, headers: headers, body: body, tls: tls, timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
723
+ return transport({ method: method, url: url.href, headers: _headersFor(url), body: body, tls: _tlsFor(url), timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
733
724
  res = res || {};
734
725
  // Measure an injected string body as UTF-8 -- the byte width the body is decoded/transfer-decoded at,
735
726
  // and what the real socket transport counts -- so a non-ASCII body (multi-byte chars are ~half the
@@ -752,16 +743,10 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
752
743
  body = null;
753
744
  if (headers["content-type"]) { headers = Object.assign({}, headers); delete headers["content-type"]; }
754
745
  }
755
- var prevOrigin = url.origin;
756
746
  url = _redirectTarget(url, h.location, method, opts);
757
- // Credentials MUST NOT cross an origin boundary. A redirect to a different web origin (even a
758
- // permitted cross-origin GET) drops BOTH the HTTP Basic Authorization header AND the mTLS
759
- // client identity (cert/key), so a prior 401's password and the caller's certificate + proof
760
- // of private-key possession are never presented to a different server.
761
- if (url.origin !== prevOrigin) {
762
- if (headers.authorization) { headers = Object.assign({}, headers); delete headers.authorization; }
763
- if (tls && (tls.cert || tls.key)) { tls = Object.assign({}, tls); delete tls.cert; delete tls.key; }
764
- }
747
+ // Credentials MUST NOT cross an origin boundary: the HTTP Basic Authorization header and the
748
+ // ORIGIN-SPECIFIC TLS identity (mTLS cert/key + pinned SNI) are BOTH scoped per hop (_headersFor /
749
+ // _tlsFor), so each is absent off-origin but restored on a hop back to the caller's configured origin.
765
750
  redirects += 1;
766
751
  return step();
767
752
  }
@@ -774,7 +759,9 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
774
759
  var www = String(h["www-authenticate"] || "");
775
760
  if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
776
761
  if (opts.username === undefined && opts.password === undefined) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
777
- headers = Object.assign({}, headers, { authorization: "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64") });
762
+ // Establish the credential as origin-scoped state (_headersFor attaches it only on the initial origin),
763
+ // never a mutation of the shared headers that would leak across a cross-origin redirect.
764
+ authValue = "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64");
778
765
  authTried = true;
779
766
  return step();
780
767
  }
@@ -39,6 +39,7 @@
39
39
  var nodeHttps = require("node:https");
40
40
  var nodeNet = require("node:net");
41
41
  var nodeTls = require("node:tls");
42
+ var nodeDns = require("node:dns");
42
43
  var constants = require("./constants");
43
44
  var guard = require("./guard-all");
44
45
  var frameworkError = require("./framework-error");
@@ -97,11 +98,81 @@ function _systemCa() {
97
98
  return out;
98
99
  }
99
100
 
101
+ // Classify a bare IP string (no brackets) as a private / loopback / link-local / reserved destination an
102
+ // untrusted URL must not reach: RFC 1918 + loopback + this-network + multicast/reserved + 169.254 (cloud
103
+ // metadata) + CGNAT for IPv4, and loopback / unspecified / IPv4-mapped / ULA (fc00::/7) + link-local (fe80::/10)
104
+ // for IPv6. This is the SAME range set the AIA literal pre-check applies (pki.path.build reuses it), enforced
105
+ // here at DNS-RESOLUTION time so a hostname pointing AT an internal address is caught too. A malformed IP fails
106
+ // CLOSED (net.isIP === 0 -> not a v4/v6 arm -> the caller treats a non-IP as un-judgeable, never as public).
107
+ function _isBlockedIp(ip) {
108
+ var fam = nodeNet.isIP(ip);
109
+ if (fam === 4) {
110
+ // net.isIP === 4 guarantees exactly four octets 0..255. Block the COMPLETE IANA special-purpose /
111
+ // non-global set (RFC 6890) so an untrusted destination can reach ONLY globally-routable public space.
112
+ var o = ip.split("."), a = +o[0], b = +o[1], c = +o[2];
113
+ return a === 0 || a === 10 || a === 127 || a >= 224 || // this-network / RFC1918 10/8 / loopback / multicast 224/4 + reserved 240/4 + broadcast
114
+ (a === 100 && b >= 64 && b <= 127) || // 100.64/10 CGNAT
115
+ (a === 169 && b === 254) || // 169.254/16 link-local (cloud metadata)
116
+ (a === 172 && b >= 16 && b <= 31) || // 172.16/12
117
+ (a === 192 && b === 168) || // 192.168/16
118
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || // 192.0.0/24 IETF protocol + 192.0.2/24 TEST-NET-1
119
+ (a === 192 && b === 88 && c === 99) || // 192.88.99/24 6to4 relay anycast (deprecated)
120
+ (a === 198 && (b === 18 || b === 19)) || // 198.18/15 benchmarking
121
+ (a === 198 && b === 51 && c === 100) || // 198.51.100/24 TEST-NET-2
122
+ (a === 203 && b === 0 && c === 113); // 203.0.113/24 TEST-NET-3
123
+ }
124
+ if (fam === 6) {
125
+ var l = ip.toLowerCase();
126
+ if (l.indexOf("::ffff:") === 0) return true; // IPv4-mapped -- may embed a private v4; block all (fail-closed)
127
+ var parts = l.split(":");
128
+ var h = parseInt(parts[0], 16); // first hextet ("" for a leading "::" -> NaN -> not in 2000::/3 -> blocked)
129
+ if (!(h >= 0x2000 && h <= 0x3fff)) return true; // outside global unicast 2000::/3: loopback/ULA/link-local/site-local/multicast(ff00::/8)/unspecified/unallocated
130
+ // Within 2000::/3, carve out the non-globally-routable special-purpose sub-ranges (IANA IPv6 Special-Purpose
131
+ // Address Registry) an attacker could route to an internal service: a first-hextet allow of the whole block
132
+ // would admit them. The second hextet is 0 when "::" compresses it (parts[1] empty).
133
+ var h2 = parts[1] ? parseInt(parts[1], 16) : 0;
134
+ if (h === 0x2002) return true; // 2002::/16 6to4 (embeds an IPv4 that may be private)
135
+ if (h === 0x2001 && h2 < 0x0200) return true; // 2001::/23 IETF protocol assignments (Teredo / benchmarking / ORCHID / AMT / ...)
136
+ if (h === 0x2001 && h2 === 0x0db8) return true; // 2001:db8::/32 documentation (RFC 3849)
137
+ if (h === 0x3fff && h2 < 0x1000) return true; // 3fff::/20 documentation (RFC 9637)
138
+ return false;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ // A DNS-rebinding-safe SSRF filter, installed as node's `lookup` ONLY when a request opts into
144
+ // blockPrivateAddresses (the AIA fetch of an untrusted-cert URL). node connects to EXACTLY the address this
145
+ // returns -- no second resolution -- so checking the resolved address here PINS it, closing the resolve/connect
146
+ // TOCTOU. Any private / loopback / link-local result fails the lookup, which surfaces as transport/blocked-address.
147
+ function _blockedAddrErr(hostname, address) {
148
+ var e = new Error("refusing to connect to " + hostname + " -> " + address + " (private / loopback / link-local address blocked)");
149
+ e.pkiBlockedAddress = true;
150
+ return e;
151
+ }
152
+ // Built over an injectable resolver (defaults to nodeDns.lookup) so every branch -- a resolve error, the
153
+ // options.all array shape, a blocked result, a permitted result -- is unit-testable without a live DNS.
154
+ function _makeGuardedLookup(lookupFn) {
155
+ return function guardedLookup(hostname, options, callback) {
156
+ lookupFn(hostname, options || {}, function (err, address, family) {
157
+ if (err) return callback(err);
158
+ if (Array.isArray(address)) { // options.all -> [{ address, family }, ...]; reject if ANY resolved address is blocked
159
+ for (var i = 0; i < address.length; i++) if (_isBlockedIp(address[i].address)) return callback(_blockedAddrErr(hostname, address[i].address));
160
+ return callback(null, address);
161
+ }
162
+ if (_isBlockedIp(address)) return callback(_blockedAddrErr(hostname, address));
163
+ return callback(null, address, family);
164
+ });
165
+ };
166
+ }
167
+ var _guardedLookup = _makeGuardedLookup(nodeDns.lookup);
168
+
100
169
  // Classify a node request/TLS error into the transport's fail-closed verdict: a
101
- // protocol-version mismatch is the TLS floor; a certificate / identity / handshake
102
- // failure is a server-authentication failure; anything else is a generic transport
103
- // error. Every arm threads the raw fault as `.cause`, so the diagnostic survives.
170
+ // blocked-address lookup rejection; a protocol-version mismatch is the TLS floor; a
171
+ // certificate / identity / handshake failure is a server-authentication failure;
172
+ // anything else is a generic transport error. Every arm threads the raw fault as
173
+ // `.cause`, so the diagnostic survives.
104
174
  function _classifyError(e, C) {
175
+ if (e && e.pkiBlockedAddress) return C("blocked-address");
105
176
  var s = String((e && e.code) || "") + " " + String((e && e.message) || "");
106
177
  if (/PROTOCOL_VERSION|UNSUPPORTED_PROTOCOL|VERSION_TOO_LOW|WRONG_VERSION|NO_PROTOCOLS_AVAILABLE|INAPPROPRIATE_FALLBACK/i.test(s)) return C("tls-floor");
107
178
  if (/CERT|SELF.?SIGNED|VERIFY|ALTNAME|HOSTNAME|DEPTH_ZERO|LOCAL_ISSUER|HANDSHAKE|\bSSL\b|\bTLS\b/i.test(s)) return C("server-auth-failed");
@@ -114,7 +185,7 @@ function _classifyError(e, C) {
114
185
  * @since 0.3.16
115
186
  * @status experimental
116
187
  * @spec RFC 7030, RFC 8996
117
- * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770)
188
+ * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770), ssrf (CWE-918)
118
189
  * @related pki.est.cacerts, pki.est.simpleenroll
119
190
  *
120
191
  * Build a fail-closed `node:https` transport: `transport(request) -> Promise<{ status,
@@ -139,6 +210,7 @@ function _classifyError(e, C) {
139
210
  * - `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
140
211
  * - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
141
212
  * - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
213
+ * - `blockPrivateAddresses` -- boolean; when true, an IP-literal host OR a hostname resolving to a private / loopback / link-local address is refused (`transport/blocked-address`), and a resolved address is pinned for the connection. For fetching an untrusted-certificate URL (AIA caIssuers); default false.
142
214
  * @example
143
215
  * var t = pki.transport.https({ tls: { anchors: [caPem] } });
144
216
  * var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
@@ -240,11 +312,23 @@ function httpsTransport(defaults) {
240
312
  return callerCsi(host, cert2);
241
313
  };
242
314
  }
315
+ // SSRF at resolution time: when a request opts in (the AIA fetch of an untrusted-cert URL), install a
316
+ // lookup that refuses -- and pins -- a private / loopback / link-local resolved address, so a hostname
317
+ // pointing at an internal service is blocked even though the literal-address check saw only a DNS name.
318
+ // A STRICT boolean true (a truthy string/object is treated as absent) keeps a malformed config fail-open-free.
319
+ var blockPrivate = (request.blockPrivateAddresses !== undefined ? request.blockPrivateAddresses : defaults.blockPrivateAddresses) === true;
320
+ if (blockPrivate) {
321
+ // Node does NOT invoke a custom `lookup` for an IP-LITERAL host (there is nothing to resolve), so the
322
+ // resolver alone would let a literal private / loopback / link-local destination through. Reject a blocked
323
+ // literal here, before installing the resolver -- so the option blocks a literal AND a resolved hostname.
324
+ if (_isBlockedIp(host)) throw E(C("blocked-address"), "refusing to connect to the private / loopback / link-local address literal " + host);
325
+ options.lookup = _guardedLookup;
326
+ }
243
327
 
244
328
  return { options: options, timeout: timeout, maxBytes: maxBytes, body: body };
245
329
  }
246
330
 
247
- return function transport(request) {
331
+ var _transportFn = function transport(request) {
248
332
  request = request || {};
249
333
  var prep;
250
334
  try { prep = _prepare(request); }
@@ -318,6 +402,11 @@ function httpsTransport(defaults) {
318
402
  } catch (e) { fail(C("transport-error"), "the request could not be initiated: " + ((e && e.message) || String(e)), e); }
319
403
  });
320
404
  };
405
+ // Advertise that this transport HONORS the blockPrivateAddresses request flag (it filters and pins a resolved
406
+ // address). A consumer of an UNTRUSTED URL (pki.path.build's AIA fetch) checks this marker before relying on the
407
+ // flag for SSRF protection -- an injected transport that does not set it is treated as unguarded (fail-closed).
408
+ _transportFn.blocksPrivateAddresses = true;
409
+ return _transportFn;
321
410
  }
322
411
 
323
- module.exports = { https: httpsTransport };
412
+ module.exports = { https: httpsTransport, isBlockedIp: _isBlockedIp, _makeGuardedLookup: _makeGuardedLookup, MAX_TIMEOUT: MAX_TIMEOUT };
package/lib/inspect.js CHANGED
@@ -381,6 +381,26 @@ var EXT_RENDERERS = {
381
381
  },
382
382
  cRLDistributionPoints: _renderCrlDp,
383
383
  freshestCRL: _renderCrlDp,
384
+ authorityInfoAccess: function (decoded, inner) {
385
+ // AccessDescription list: <accessMethod> - <accessLocation>. The method resolves to its name (caIssuers /
386
+ // ocsp); the accessLocation is a GeneralName (a URI in the common case). An unregistered method / an
387
+ // uncommon accessLocation tag falls back to the raw OID / bracketed tag rather than dropping the entry.
388
+ var LABEL = { caIssuers: "CA Issuers", ocsp: "OCSP" };
389
+ return (decoded || []).map(function (ad) {
390
+ var m = null;
391
+ try { m = oid.name(ad.accessMethod); } catch (_e) { /* allow:swallow-unverified display best-effort: an unregistered accessMethod OID falls back to the raw dotted OID below (inspection is best-effort, never a verdict) */ }
392
+ var loc = ad.accessLocation || {}, lv;
393
+ // The string choices (URI/DNS/email) are IA5String values already control-byte-rejected at decode by the
394
+ // CVE-2009-2408 guard, so they are safe to emit directly. The iPAddress choice is a RAW 4/16-byte Buffer --
395
+ // render it through _ipString (never raw), so a byte such as 0x0a cannot inject a line and spoof a field.
396
+ if (loc.tag === 6) lv = "URI:" + loc.value;
397
+ else if (loc.tag === 2) lv = "DNS:" + loc.value;
398
+ else if (loc.tag === 1) lv = "email:" + loc.value;
399
+ else if (loc.tag === 7) lv = "IP:" + _ipString(loc.value);
400
+ else lv = typeof loc.value === "string" ? loc.value : "[" + loc.tag + "]";
401
+ return inner + (LABEL[m] || m || ad.accessMethod) + " - " + lv;
402
+ }).join("\n");
403
+ },
384
404
  nameConstraints: function (decoded, inner) {
385
405
  var ncLines = [];
386
406
  ["permittedSubtrees:Permitted", "excludedSubtrees:Excluded"].forEach(function (pair) {
package/lib/lint.js CHANGED
@@ -191,32 +191,9 @@ function _serialOctets(cert) {
191
191
  return buf.length;
192
192
  }
193
193
 
194
- // dNSName syntax (a representative CABF check): no whitespace, no leading/trailing dot,
195
- // no empty label, and no underscore (forbidden in a dNSName). IDN must already be an
196
- // A-label (we do not transcode). Returns a reason string, or null when well-formed.
197
- function _dnsNameProblem(s) {
198
- if (typeof s !== "string" || !s.length) return "empty";
199
- if (s.length > 253) return "exceeds 253 octets";
200
- if (/\s/.test(s)) return "whitespace";
201
- if (s.charAt(0) === "." || s.charAt(s.length - 1) === ".") return "leading/trailing dot";
202
- if (s.indexOf("_") !== -1) return "underscore forbidden in dNSName";
203
- var labels = s.split(".");
204
- for (var i = 0; i < labels.length; i++) {
205
- var label = labels[i];
206
- if (label.length === 0) return "empty label";
207
- if (label.length > 63) return "label exceeds 63 octets";
208
- // A leftmost "*" wildcard label is permitted only when at least one more label follows
209
- // (a bare "*" is not a domain name).
210
- if (i === 0 && label === "*") {
211
- if (labels.length < 2) return "bare wildcard";
212
- continue;
213
- }
214
- // RFC 1034 preferred name syntax: an LDH label that neither begins nor ends with a
215
- // hyphen. Rejects "-bad" / "bad-" and any non-letter/digit/hyphen character.
216
- if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(label)) return "invalid label syntax";
217
- }
218
- return null;
219
- }
194
+ // dNSName syntax check -- the shared RFC 5280 sec. 4.2.1.6 / RFC 1034 preferred-name validator (pkix), reused
195
+ // so the linter and the identity comparators agree on what a well-formed dNSName is. Returns a reason or null.
196
+ function _dnsNameProblem(s) { return pkix.dnsNameProblem(s); }
220
197
 
221
198
  // A genuine IPv4 or IPv6 literal -- a CN validated against an iPAddress SAN rather than a
222
199
  // dNSName. Routed through the shared strict validator (no node:net, so the toolkit needs no