@blamejs/pki 0.9.7 → 0.9.9

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,38 @@ 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.9.9 — 2026-10-27
8
+
9
+ Refuse an IPv4 address spelled as a host name wherever a certificate identity is matched, whatever the Node version.
10
+
11
+ ### Changed
12
+
13
+ - A reference identity such as `999.999.999.999`, which is neither an address nor a domain name, is refused with `identity/bad-reference` instead of being compared against dNSName entries.
14
+
15
+ ### Security
16
+
17
+ - `pki.identity.match` refuses a reference identity whose last label is a number, or `0x` followed by hex digits, with `identity/bad-reference`, as a string and as the value of a `dns`, `srv` or `uri` record. Before, `0127.0.0.1`, `0177.0.0.1` and `0x7f.0.0.1` were matched by the dNSName `*.0.0.1`, `127.0.0.1.` by `127.0.0.1`, and `127.1` by `*.1`, and a `dns` record carrying the canonical `127.0.0.1` was matched by the dNSName `127.0.0.1`. An address is passed as the canonical dotted quad or as octets, which is compared only against `iPAddress` entries. A domain reference must also be made of letters, digits, hyphens, dots and underscores, so `127.0.0.%31` (which WHATWG URL decodes to 127.0.0.1), `::1` and `[::1]` are refused rather than compared against dNSName entries.
18
+ - `pki.path.validate` refuses such an `opts.identity` with `path/bad-input`. Before, a leaf whose only subject alternative name was the dNSName `*.0.0.1` validated for identity `0x7f.0.0.1` under an excluded `iPAddress` subtree of 127.0.0.0/8, because the subtree applies only to `iPAddress` entries.
19
+ - `pki.transport.https` and `pki.transport.peerChain`, and every client built on them, refuse a `tls.servername` that is such a value with `transport/bad-input`, and a `proxy.tls.servername` with `transport/bad-proxy`. A servername holding any character other than a letter, digit, hyphen, dot or underscore, including a fullwidth digit, a NUL, a percent escape or a bracketed IPv6 address, is refused the same way; a canonical address literal is still accepted. Node below 24.22.1 matched `0x7f.0.0.1` or `127` written in fullwidth digits against the dNSName `127.0.0.1`.
20
+ - The ACME verbs refuse such a value as a `dns` identifier with `acme/bad-identifier`, in an authorization, an order, the subject `commonName` of a finalize CSR and `pki.acme.verifyTlsAlpn01`, so a finalize CSR naming one in a dNSName matches no order identifier. An address takes the `ip` identifier type (RFC 8738 sec. 3); as a `dns` identifier, a validation certificate's dNSName would prove it.
21
+ - A URI name constraint, and the presented host of a uniformResourceIdentifier name, no longer read a host such as `0x7f.0.0.1` as a domain name. A constraint base of that form is refused as an address.
22
+
23
+ ## v0.9.8 — 2026-10-27
24
+
25
+ Require Node 24.22.1, which fixes the Brotli decoder memory corruption in CVE-2026-111497.
26
+
27
+ ### Changed
28
+
29
+ - `engines.node` is `>=24.22.1`. On a lower version npm reports the mismatch as EBADENGINE, and refuses the install where engine-strict is set. Node 24.22.1 ships OpenSSL 3.5.9.
30
+
31
+ ### Fixed
32
+
33
+ - A zstd-compressed input to `pki.tls.decompressCertificate`, or to any toolkit decompression, must be exactly one Zstandard frame, and anything else is refused with `tls/decompress-failed`. The zstd decoder in Node 24.22.1 and later decodes every concatenated frame and skips skippable frames (RFC 8878 sec. 3.1.2). An empty frame or a skippable frame of arbitrary bytes before or after the real one added no output and passed the declared-length check, so one certificate chain had unboundedly many compressed encodings. A skippable frame on its own decoded to empty output.
34
+
35
+ ### Security
36
+
37
+ - The supported Node floor moves to 24.22.1. Node 24.22.0 and earlier bundle a Brotli decoder in which malformed input can corrupt process memory, which can crash the process and may allow code execution (CVE-2026-111497). `pki.tls` passes a peer's Brotli-compressed certificate message (RFC 8879, algorithm 2) to that decoder, and the size cap the toolkit applies to the output does not protect against a fault inside the decoder. The fix is in the Node runtime, and updating an npm package does not replace it. Node 26 releases before 26.12.1 carry the same decoder and still satisfy `>=24.22.1`, so on Node 26 run 26.12.1 or later.
38
+
7
39
  ## v0.9.7 — 2026-10-21
8
40
 
9
41
  Refuse a key whose private key is public wherever it appears, with no option to turn the refusal off.
package/README.md CHANGED
@@ -64,7 +64,7 @@ Crypto API with its limits on streaming, opaque keys, and algorithm reach.
64
64
  npm i @blamejs/pki
65
65
  ```
66
66
 
67
- Requires Node.js 24.21+ and runs on the shipped runtime, with no build step and
67
+ Requires Node.js 24.22.1+ and runs on the shipped runtime, with no build step and
68
68
  no transpilation.
69
69
 
70
70
  ```js
package/lib/acme.js CHANGED
@@ -456,6 +456,13 @@ function _assertDnsName(name) {
456
456
  if (!_isLdhLabel(l)) throw E("acme/bad-identifier", "a dns label must be lowercase LDH ASCII (A-label): " + JSON.stringify(l));
457
457
  if (l.indexOf("xn--") === 0 && !_isXnLabel(l)) throw E("acme/bad-identifier", "a malformed xn-- A-label: " + JSON.stringify(l));
458
458
  }
459
+ /** @internal A resolver and WHATWG URL read a name whose last label is a number as IPv4, so
460
+ * `127.0.0.1`, `0x7f.0.0.1` and `127.1` are addresses rather than domain names. An address takes the
461
+ * `ip` identifier type (RFC 8738 sec. 3); as a `dns` identifier, a dNSName SAN would prove it. */
462
+ if (ipUtils.endsInANumber(name)) {
463
+ throw E("acme/bad-identifier", "a dns identifier value is read as an IPv4 address by a resolver and by WHATWG URL; " +
464
+ "an address uses the ip identifier type (RFC 8738 sec. 3): " + _stringify(name));
465
+ }
459
466
  }
460
467
 
461
468
  function _assertIpAddress(value) {
@@ -27,6 +27,37 @@ var _PROBE_COMPRESS = _intrinsic.assign(_intrinsic.create(null), {
27
27
  zstd: zlib.zstdCompressSync,
28
28
  });
29
29
 
30
+ /** @internal The byte length of the one Zstandard frame at the start of `s` (RFC 8878 sec. 3.1.1),
31
+ * or -1 when `s` does not open with a complete one. A decoder that follows RFC 8878 decodes every
32
+ * concatenated frame and skips skippable frames, so the consumed-length check cannot see an empty
33
+ * frame or a skippable frame next to the real one; this walk is what holds zstd to one frame. Only
34
+ * the frame magic 0xFD2FB528 starts a frame here, so a skippable frame anywhere is refused. */
35
+ var _ZSTD_DID_BYTES = [0, 1, 2, 4];
36
+ var _ZSTD_FCS_BYTES = [0, 2, 4, 8];
37
+ function _zstdFrameLength(s) {
38
+ var n = _sizeOf(s);
39
+ if (n < 6 || s[0] !== 0x28 || s[1] !== 0xb5 || s[2] !== 0x2f || s[3] !== 0xfd) return -1;
40
+ var fhd = s[4];
41
+ if ((fhd & 0x08) !== 0) return -1;
42
+ var single = (fhd & 0x20) !== 0;
43
+ var fcs = _ZSTD_FCS_BYTES[fhd >>> 6];
44
+ if (fcs === 0 && single) fcs = 1;
45
+ var p = 5 + (single ? 0 : 1) + _ZSTD_DID_BYTES[fhd & 0x03] + fcs;
46
+ for (;;) {
47
+ if (p + 3 > n) return -1;
48
+ var h = s[p] | (s[p + 1] << 8) | (s[p + 2] << 16);
49
+ var type = (h >>> 1) & 0x03;
50
+ if (type === 3) return -1;
51
+ p += 3 + (type === 1 ? 1 : (h >>> 3));
52
+ if (p > n) return -1;
53
+ if ((h & 0x01) !== 0) break;
54
+ }
55
+ if ((fhd & 0x04) !== 0) p += 4;
56
+ return p > n ? -1 : p;
57
+ }
58
+
59
+ var FRAME_LENGTH = _intrinsic.assign(_intrinsic.create(null), { zstd: _zstdFrameLength });
60
+
30
61
  function _reportsTruncation(name) {
31
62
  var compress = _PROBE_COMPRESS[name];
32
63
  var decompress = DECOMPRESS[name];
@@ -51,7 +82,7 @@ var SAFE = _intrinsic.create(null);
51
82
  _intrinsic.forEach(_objectKeys(DECOMPRESS), function (n) { if (_reportsTruncation(n)) SAFE[n] = true; });
52
83
 
53
84
  // @enforced-by guard-shape-reinlined
54
- // @guard-shape \bzlib\.(inflate|brotliDecompress|zstdDecompress)Sync\b
85
+ // @guard-shape \bzlib\.(?:inflate|inflateRaw|gunzip|unzip|brotliDecompress|zstdDecompress|createInflate|createInflateRaw|createGunzip|createUnzip|createBrotliDecompress|createZstdDecompress)(?:Sync)?\b
55
86
  // @guard-via \bguard\.compress\.bounded\s*\(
56
87
  function bounded(algorithm, stream, cap, E, codes, label) {
57
88
  var decompress = DECOMPRESS[algorithm];
@@ -65,6 +96,10 @@ function bounded(algorithm, stream, cap, E, codes, label) {
65
96
  throw new TypeError("guard.compress.bounded: cap must be a positive integer");
66
97
  }
67
98
  var what = label || "the compressed input";
99
+ var frameLength = FRAME_LENGTH[algorithm];
100
+ if (frameLength && frameLength(stream) !== _sizeOf(stream)) {
101
+ throw E(codes.failed, what + " is not exactly one " + algorithm + " frame");
102
+ }
68
103
  var res;
69
104
  try {
70
105
  res = decompress(stream, { maxOutputLength: cap, info: true });
@@ -101,7 +101,7 @@ function assertKnownKeys(obj, known, E, code, message) {
101
101
  * Which of the two a given name is depends on the function and the engine. A STRICT function, which
102
102
  * is every function in an ES module or a `"use strict"` file, owns neither `arguments` nor `caller`,
103
103
  * and `Function.prototype` carries both as the restricted accessors, so a chain walk finds them
104
- * there. On the Node 24.21.0 floor a non-strict function owns both outright. Measured before this
104
+ * there. On the Node 24.22.1 floor a non-strict function owns both outright. Measured before this
105
105
  * covered the inherited side: a plain strict callable descriptor was refused for `arguments` by
106
106
  * `pki.cms.sign` and `pki.cms.countersign`, while the minted floor shape passed, which is the shape
107
107
  * the vectors were written on.
@@ -372,7 +372,7 @@ function _kindMembers(v) {
372
372
  /** @internal The names a callable carries before a caller assigns a field, asked of the engine
373
373
  * rather than listed here: a function's own names, plus `Function.prototype`'s, which is where the
374
374
  * restricted `arguments` and `caller` accessors live. Which of them a given function makes OWN
375
- * depends on the engine. Measured: on Node 24.21.0, the version `engines.node` floors at, every
375
+ * depends on the engine. Measured: on Node 24.22.1, the version `engines.node` floors at, every
376
376
  * non-strict function carries own `arguments` and `caller`; on 26.9.0 none does, for a declaration
377
377
  * and for `new Function` alike. A list written against one of those refuses a legitimate callable
378
378
  * descriptor on the other, and a caller's non-strict CommonJS module is where the shape comes
@@ -418,7 +418,7 @@ function isOwnMachineryName(v, k) {
418
418
  if (v === null || v === undefined) return false;
419
419
  var d = _getOwnPropertyDescriptor(v, k);
420
420
  /** @internal A DATA property, because that is what the machinery is: measured on both Node
421
- * 24.21.0 and 26.9.0, every one of these names a function owns is a non-enumerable data
421
+ * 24.22.1 and 26.9.0, every one of these names a function owns is a non-enumerable data
422
422
  * property. An accessor a caller installed under one of those names is therefore theirs, and it
423
423
  * reaches the unknown-field refusal by its name rather than being exempted and ignored. Asking
424
424
  * the descriptor rather than reading the property leaves the getter uninvoked either way. */
@@ -267,7 +267,7 @@ var _strSliceOwn = uncurry(String.prototype.slice);
267
267
  * `String.prototype.split` is specified to look the separator's `Symbol.split` method up and call
268
268
  * it (ES2015 21.1.3.19 step 2), so the separator's prototype chain is part of the operation and a
269
269
  * capture of `split` does not close it. Hard rule 11 bans `split` in `lib/` for exactly that
270
- * reason. MEASURED on Node 24.21, the supported runtime: a captured split with a string separator
270
+ * reason. MEASURED on Node 24.22.1, the supported runtime: a captured split with a string separator
271
271
  * DOES dispatch to an installed `String.prototype[Symbol.split]`, and that made
272
272
  * `isBlockedIp("127.0.0.1")` answer false, so a request to loopback proceeded under
273
273
  * `blockPrivateAddresses`. Node 26.9 fast-paths a primitive-string separator past the lookup, which
package/lib/guard-name.js CHANGED
@@ -446,12 +446,6 @@ function _charTable(chars) {
446
446
  for (var i = 0; i < chars.length; i++) t[_charCodeAt(chars, i)] = true;
447
447
  return t;
448
448
  }
449
- var _DIGDOT_TABLE = _charTable("0123456789.");
450
- function _allCharsIn(s, table) {
451
- if (s.length === 0) return false;
452
- for (var i = 0; i < s.length; i++) if (!table[_charCodeAt(s, i)]) return false;
453
- return true;
454
- }
455
449
 
456
450
  /** @internal The one trailing dot a host name may be written with, removed before it is compared.
457
451
  *
@@ -485,7 +479,7 @@ function isFqdnHost(host) {
485
479
  if (typeof host !== "string") return false;
486
480
  var h = stripTrailingDot(host);
487
481
  if (!_isHostName(h)) return false;
488
- if (_allCharsIn(h, _DIGDOT_TABLE)) return false;
482
+ if (_ipUtils.endsInANumber(h)) return false;
489
483
  return true;
490
484
  }
491
485
 
@@ -527,6 +521,13 @@ function _isHostName(s) {
527
521
  // @enforced-by behavioral -- an LDH label walk has no rename-proof shape that separates it from any other per-character scan; the vectors refusing an empty label, a hyphen at a label edge and a name past 253 octets are the guard
528
522
  function isHostName(s) { return typeof s === "string" && _isHostName(s); }
529
523
 
524
+ /** @internal Whether a character code can appear in a host name a caller hands the toolkit: a letter,
525
+ * a digit, a hyphen, a dot or an underscore. A percent escape is decoded by WHATWG URL, so
526
+ * `127.0.0.%31` reaches 127.0.0.1, and a colon or a bracket spells an IPv6 address; neither is a
527
+ * host name. */
528
+ // @enforced-by behavioral -- a character-class test has no rename-proof shape; the vectors refusing a percent escape, a colon and a bracket in a reference identity and in a TLS servername are the guard
529
+ function isHostNameChar(c) { return _LDH_ALNUM[c] === true || c === 0x2d || c === 0x2e || c === 0x5f; }
530
+
530
531
  /** @internal The labels of `s`, in order, with no trailing-dot handling of its own: the caller
531
532
  * normalizes the dot through `stripTrailingDot` so both sides of a comparison are normalized by
532
533
  * one symbol. A name carrying an empty label yields an empty string at that position, which a
@@ -1019,7 +1020,7 @@ function constraintBaseRefusal(tag, base) {
1019
1020
  var uriBody = _toLowerCase(base);
1020
1021
  if (_charAt(uriBody, 0) === ".") uriBody = _strSlice(uriBody, 1);
1021
1022
  if (isFqdnHost(uriBody)) return null;
1022
- return _allCharsIn(stripTrailingDot(uriBody), _DIGDOT_TABLE)
1023
+ return _ipUtils.endsInANumber(uriBody)
1023
1024
  ? "must be a domain name rather than an address, RFC 5280 sec. 4.2.1.10 naming an address in a URI's authority as what a URI constraint cannot be compared against"
1024
1025
  : "must be a domain name, which RFC 5280 sec. 4.2.1.10 requires of a URI constraint, optionally written as a subtree with a leading dot";
1025
1026
  }
@@ -1148,7 +1149,7 @@ module.exports = intrinsic.freeze({
1148
1149
  isAttributeDescriptor: isAttributeDescriptor,
1149
1150
  lowerAscii: lowerAscii, uriSchemeIs: uriSchemeIs,
1150
1151
  stripTrailingDot: stripTrailingDot, isFqdnHost: isFqdnHost, splitMailbox: splitMailbox,
1151
- isHostName: isHostName, hostLabels: hostLabels, isXnLabel: isXnLabel, uriParts: uriParts,
1152
+ isHostName: isHostName, isHostNameChar: isHostNameChar, hostLabels: hostLabels, isXnLabel: isXnLabel, uriParts: uriParts,
1152
1153
  uriEqual: uriEqual, dnsNameEqual: dnsNameEqual, generalNameEqual: generalNameEqual,
1153
1154
  constraintBaseRefusal: constraintBaseRefusal,
1154
1155
  constraintBaseUnreadable: constraintBaseUnreadable,
@@ -88,6 +88,7 @@ var nodeUtil = require("node:util");
88
88
  var constants = require("./constants");
89
89
  var guard = require("./guard-all");
90
90
  var intrinsic = require("./guard-intrinsic");
91
+ var ipUtils = require("./ip-utils");
91
92
  /** @internal The certificate reader, and the two operations the distrust walk proves a link with,
92
93
  * taken at load. A replaced `verify` would answer whether each link of a server chain is signed by
93
94
  * the certificate above it. */
@@ -581,7 +582,7 @@ function _isBlockedIp(ip) {
581
582
  /** @internal The literal is scanned on the delimiter CHARACTER rather than split on a separator
582
583
  * string. `String.prototype.split` is specified to look the separator's `Symbol.split` method up
583
584
  * and call it, so the separator's prototype chain is part of the operation and capturing `split`
584
- * does not close it; hard rule 11 bans `split` in lib/ for that reason. MEASURED on Node 24.21,
585
+ * does not close it; hard rule 11 bans `split` in lib/ for that reason. MEASURED on Node 24.22.1,
585
586
  * the supported runtime: a hook answering with a public address's octets made this refusal clear
586
587
  * for `127.0.0.1` and a request to loopback proceeded. */
587
588
  var o = intrinsic.splitChar(ip, 46), a = +o[0], b = +o[1], c = +o[2];
@@ -697,6 +698,25 @@ var PROXY_KEYS = _keySet(["url", "auth", "tls"]);
697
698
  var PROXY_AUTH_KEYS = _keySet(["scheme", "username", "password", "allowMD5", "allowLegacyQop"]);
698
699
  var PROXY_DIGEST_KNOBS = ["allowMD5", "allowLegacyQop"];
699
700
  var PROXY_TLS_KEYS = _keySet(["anchors", "useSystemStore", "servername", "minVersion", "distrustedKeys"]);
701
+ /** @internal A servername is a DNS name (RFC 6066 sec. 3) of letters, digits, hyphens, dots and
702
+ * underscores, or a canonical address literal. A percent escape or a bracketed IPv6 address is
703
+ * neither. A value whose last label is a number but which is not a canonical address (`0x7f.0.0.1`,
704
+ * `0177.0.0.1`, `127.1`, `127.0.0.1.`, or `127.0.0.1` in fullwidth digits) is an address in another
705
+ * spelling. Node below 24.22.1 matched such a name against the certificate's dNSName entries
706
+ * (CVE-2026-106072, CVE-2026-105483), so `DNS:127.0.0.1` authenticated it. It is refused here
707
+ * whatever the runtime does. */
708
+ function _assertServername(s, E, code, what) {
709
+ if (_isIP(s)) return;
710
+ for (var i = 0; i < s.length; i++) {
711
+ if (!guard.name.isHostNameChar(_charCodeAt(s, i))) {
712
+ throw E(code, what + " carries a character at offset " + i + " that no host name holds; a servername is a DNS " +
713
+ "name of letters, digits, hyphens, dots and underscores (RFC 6066 sec. 3) or a canonical address literal");
714
+ }
715
+ }
716
+ if (ipUtils.endsInANumber(s)) {
717
+ throw E(code, what + " " + guard.text.showValue(s) + " is an IPv4 address in a non-canonical spelling; pass the canonical dotted quad or a DNS name");
718
+ }
719
+ }
700
720
  /** @internal The names a `tls` policy bag may carry. A name outside them is refused rather than
701
721
  * ignored: a misspelled `distrustedKeys` or `anchors` would otherwise leave the connection under a
702
722
  * policy the caller did not state. */
@@ -820,6 +840,7 @@ function _validateProxy(request, defaults, E, C, originHost, originStatedPort, o
820
840
  if (!_copyableRecord(ptls)) throw E(C("bad-proxy"), "opts.proxy.tls must be a plain object with only own enumerable data properties");
821
841
  guard.identifier.assertKnownKeys(ptls, PROXY_TLS_KEYS, E, C("bad-proxy"), "opts.proxy.tls has an unknown option ");
822
842
  if (ptls.servername !== undefined && ptls.servername !== null && typeof ptls.servername !== "string") throw E(C("bad-proxy"), "opts.proxy.tls.servername must be a string");
843
+ if (typeof ptls.servername === "string") _assertServername(ptls.servername, E, C("bad-proxy"), "opts.proxy.tls.servername");
823
844
  }
824
845
  /** @internal Taken here, so the value the tunnel is opened under is the one typed above rather
825
846
  * than a later read of the caller's record. The door refuses an accessor and refuses a Proxy, so
@@ -1107,6 +1128,7 @@ function httpsTransport(defaults) {
1107
1128
  * as `idHost` for the name the server certificate is matched against. A value answering a
1108
1129
  * different string per read sends one name and verifies another. */
1109
1130
  if (typeof sni !== "string") throw E(C("bad-input"), "tls.servername must be a string, got " + guard.text.showValue(sni));
1131
+ _assertServername(sni, E, C("bad-input"), "tls.servername");
1110
1132
  if (sni && !_isIP(sni)) options.servername = sni;
1111
1133
  var caList = [];
1112
1134
  if (hasAnchors) {
@@ -89,6 +89,27 @@ function _assertNoWildcard(s, what) {
89
89
  }
90
90
  }
91
91
 
92
+ /** @internal A domain is a host name and never an address. Its characters are letters, digits,
93
+ * hyphens, dots and underscores, so a percent escape (`127.0.0.%31`, which WHATWG URL decodes to
94
+ * 127.0.0.1) and an IPv6 spelling (`::1`, `[::1]`) are refused. RFC 9525 sec. 3 classifies an IP
95
+ * address as an IP-ID and sec. 6.4 never compares one as a DNS-ID. A resolver and WHATWG URL read any host whose last label
96
+ * is a number as IPv4 (`0x7f.0.0.1`, `0177.0.0.1`, `127.1`, `2130706433`, `127.0.0.1.`), and
97
+ * `0127.0.0.1` reaches 87.0.0.1 under URL but 127.0.0.1 under some resolvers. Compared as a domain,
98
+ * such a value was authenticated by a dNSName like `127.0.0.1` or `*.0.0.1`. */
99
+ function _assertNotAnAddress(v, what) {
100
+ for (var i = 0; i < v.length; i++) {
101
+ if (!guard.name.isHostNameChar(_charCodeAt(v, i))) {
102
+ throw E("identity/bad-reference", what + " carries " + guard.text.showValue(v[i]) + " at offset " + i +
103
+ ", which no host name holds. A host name is letters, digits, hyphens, dots and underscores; a percent " +
104
+ "escape is decoded by WHATWG URL and a colon or bracket spells an IPv6 address");
105
+ }
106
+ }
107
+ if (ip.endsInANumber(v)) {
108
+ throw E("identity/bad-reference", what + " is read as an IPv4 address by a resolver and by WHATWG URL, so it is " +
109
+ "not a domain name. Pass an address as the canonical dotted quad or as octets (RFC 9525 sec. 3)");
110
+ }
111
+ }
112
+
92
113
  /** @internal The domain of a reference identity, normalized the way both sides are normalized: one
93
114
  * trailing dot stripped, then held to the label rules so an empty label or a label past 63
94
115
  * characters is a caller mistake, not a name that can never match. */
@@ -96,6 +117,7 @@ function _referenceDomain(v, what) {
96
117
  if (typeof v !== "string" || v === "") throw E("identity/bad-reference", what + " must be a non-empty string");
97
118
  _assertAsciiReference(v, what);
98
119
  _assertNoWildcard(v, what);
120
+ _assertNotAnAddress(v, what);
99
121
  var d = _stripTrailingDot(v);
100
122
  if (d === "" || d.length > MAX_HOST) {
101
123
  throw E("identity/bad-reference", what + " must be a domain name of 1 to " + MAX_HOST + " characters");
@@ -487,6 +509,11 @@ function _search(refs, entries, wildcardsAllowed) {
487
509
  * its service type and its domain together, so no comparison can pair the service of one
488
510
  * reference with the domain of another (sec. 6.5).
489
511
  *
512
+ * A domain whose last label is a number, such as `0x7f.0.0.1`, `0177.0.0.1`, `127.1`,
513
+ * `2130706433` or `127.0.0.1.`, is an IPv4 address to a resolver and to WHATWG URL, so it is
514
+ * refused with `identity/bad-reference` in every form rather than compared against dNSName
515
+ * entries. An address is passed as the canonical dotted quad or as octets.
516
+ *
490
517
  * The subject distinguished name is never a source of identity. The `commonName` is not read
491
518
  * under any option or fallback, including when the certificate carries no subjectAltName at
492
519
  * all, and neither is any other RDN (sec. 2, RFC 9110 sec. 4.3.4). A certificate presenting
package/lib/ip-utils.js CHANGED
@@ -42,6 +42,29 @@ function isIPv4(s) {
42
42
  return true;
43
43
  }
44
44
 
45
+ /** @internal Whether a host is one WHATWG URL parses as IPv4: the "ends in a number" check of the
46
+ * URL Standard's host parser. With one trailing dot dropped, the last label is all ASCII digits, or
47
+ * `0x` / `0X` followed by hex digits or nothing. A resolver (inet_aton) reads the same strings as
48
+ * an address, so `0x7f.0.0.1`, `0177.0.0.1`, `127.1`, `2130706433` and `127.0.0.1.` all name
49
+ * 127.0.0.1. A host this answers true for and `isIPv4` answers false for is an address in a
50
+ * non-canonical spelling, never a domain name. */
51
+ function endsInANumber(s) {
52
+ if (typeof s !== "string") return false;
53
+ var parts = _splitOn(s, ".");
54
+ if (parts[parts.length - 1] === "") {
55
+ if (parts.length === 1) return false;
56
+ parts.length -= 1;
57
+ }
58
+ var last = parts[parts.length - 1], n = last.length, i;
59
+ if (n === 0) return false;
60
+ var digits = true;
61
+ for (i = 0; i < n; i += 1) { if (!_isDigitCode(_charCodeAt(last, i))) { digits = false; break; } }
62
+ if (digits) return true;
63
+ if (n < 2 || _charCodeAt(last, 0) !== 48 || (_charCodeAt(last, 1) | 0x20) !== 0x78) return false;
64
+ for (i = 2; i < n; i += 1) { if (!_isHexCode(_charCodeAt(last, i))) return false; }
65
+ return true;
66
+ }
67
+
45
68
  function _isHexGroup(g) {
46
69
  var n = g.length, i;
47
70
  if (n < 1 || n > 4) return false;
@@ -151,6 +174,6 @@ function canonicalizeIpv6(value) {
151
174
  }
152
175
 
153
176
  module.exports = {
154
- isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral,
177
+ isIPv4: isIPv4, endsInANumber: endsInANumber, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral,
155
178
  textFromOctets: textFromOctets, canonicalizeIpv6: canonicalizeIpv6,
156
179
  };
@@ -40,7 +40,7 @@
40
40
  * all rather than faulting is dropped, because a runtime that accepts a cut
41
41
  * frame lets a peer strip a frame's tail and have the receiver read a prefix as
42
42
  * the whole message. A dropped algorithm is then neither compressed to nor
43
- * accepted. At the Node 24.21 engine floor all three qualify; on a runtime
43
+ * accepted. At the Node 24.22.1 engine floor all three qualify; on a runtime
44
44
  * where one does not, it is dropped.
45
45
  *
46
46
  * @card
@@ -1,4 +1,4 @@
1
1
  {
2
- "_comment": "Vendored dependencies: none currently. @blamejs/pki's cryptography runs entirely on Node's built-in node:crypto: the classical algorithm set (RSA, ECDSA, EdDSA, ECDH, AES, HMAC, HKDF, PBKDF2, the SHA family) AND the FIPS 203/204/205 post-quantum algorithms (ML-KEM, ML-DSA, SLH-DSA) via the platform OpenSSL 3.5 that the Node engine floor (>=24.21) ships. A built-in ships zero bytes and is OpenSSL-interoperable by construction, so no crypto bundle is vendored. A package is added here ONLY when a specific operation is confirmed missing from the engine floor; see lib/vendor/README.md for the policy.",
2
+ "_comment": "Vendored dependencies: none currently. @blamejs/pki's cryptography runs entirely on Node's built-in node:crypto: the classical algorithm set (RSA, ECDSA, EdDSA, ECDH, AES, HMAC, HKDF, PBKDF2, the SHA family) AND the FIPS 203/204/205 post-quantum algorithms (ML-KEM, ML-DSA, SLH-DSA) via the platform OpenSSL 3.5 that the Node engine floor (>=24.22.1) ships. A built-in ships zero bytes and is OpenSSL-interoperable by construction, so no crypto bundle is vendored. A package is added here ONLY when a specific operation is confirmed missing from the engine floor; see lib/vendor/README.md for the policy.",
3
3
  "packages": {}
4
4
  }
@@ -6,7 +6,7 @@ nothing. This directory holds only the manifest.
6
6
  ## Native-first crypto
7
7
 
8
8
  The toolkit's cryptography runs entirely on Node's built-in `node:crypto`. The
9
- engine floor (Node `>=24.21`) links OpenSSL 3.5, which provides:
9
+ engine floor (Node `>=24.22.1`) links OpenSSL 3.5, which provides:
10
10
 
11
11
  - the full classical set: RSA (PKCS#1 v1.5, PSS, OAEP), ECDSA, EdDSA
12
12
  (Ed25519/Ed448), ECDH including X25519 and X448, AES (GCM/CBC/CTR/KW), HMAC,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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",
@@ -51,7 +51,7 @@
51
51
  "owns-its-stack"
52
52
  ],
53
53
  "engines": {
54
- "node": ">=24.21.0"
54
+ "node": ">=24.22.1"
55
55
  },
56
56
  "files": [
57
57
  "index.js",
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:1f2b6ec8-3dcd-4f4f-abc6-92aeb5ba8795",
5
+ "serialNumber": "urn:uuid:8b9305d6-9fdd-4cc8-9197-8b59f0469f8f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-10-21T20:18:37.023Z",
8
+ "timestamp": "2026-10-27T18:43:36.397Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -15,18 +15,18 @@
15
15
  {
16
16
  "vendor": "npm",
17
17
  "name": "cli",
18
- "version": "11.19.0"
18
+ "version": "11.19.1"
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.9.7",
22
+ "bom-ref": "@blamejs/pki@0.9.9",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.9.7",
25
+ "version": "0.9.9",
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.9.7",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.9.9",
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.9.7",
57
+ "ref": "@blamejs/pki@0.9.9",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]