@blamejs/pki 0.8.48 → 0.8.49

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,14 @@ 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.8.49 — 2026-10-05
8
+
9
+ Contact the certificate-transparency log the caller named, and refuse an options object that answers a second read differently.
10
+
11
+ ### Fixed
12
+
13
+ - `pki.ct.getSth`, `pki.ct.getProofByHash`, `pki.ct.getSthConsistency`, `pki.ct.getEntries`, `pki.ct.getRoots` and `pki.ct.addChain` read `opts.url` once to check it was present and again to build the endpoint. An options object whose `url` is an accessor therefore answered the two separately: the check saw the log the caller named and the request went wherever the second read pointed, which for a measured case was a different host entirely. `opts.tls` was read the same way, once for the trust-anchor check that refuses an unpinned log and once more for the connection, so the anchors that satisfied the check need not have been the anchors the socket used. Each verb now takes its options through one door at its own entry, and that door refuses an options object carrying an accessor, which is the rule `pki.ct.signSct` in the same module already applied. It also refuses an object that changes which options it carries while they are being read, since no set of checks can answer for one. A caller passing a plain object sees no change. The refusal is `ct/bad-input` and it happens before any request is sent, so a rejected options object cannot reach a log. Refusing an accessor on the options object is not the whole rule, because the door never inspects the members of a value, and a getter on a header name runs while the request headers are being copied. Three things it could reach from there are now taken before it runs. The URL is taken and converted to text at the same point, since holding the value alone is not enough when the value is an object whose text form names the host: a getter that rewrote an object-valued URL's `href` moved the request just as a second read would have. The TLS settings are flattened at that point too, and the anchor list is copied rather than shared, because a getter that emptied the pinned list after the trust-anchor check had approved it left the connection pinned to nothing. A caller that supplies options through a defaults object on the prototype chain is unaffected, including a function-valued `transport`. `pki.ct.fetchLogList` checked which option names it had been given and never entered that door at all, so its `url` was read once to parse and once to fetch, its `sigUrl` twice, and its TLS configuration once for the anchor check and once for the connection; it enters the same door now, over its own option set. Note that this is stricter than before: an options object with an accessor property was previously accepted, and the toolkit answered from whichever read it happened to make. Pass plain values.
14
+
7
15
  ## v0.8.48 — 2026-10-05
8
16
 
9
17
  Assemble a verification verdict out of component results through the operation the package captured at load, and refuse the reserved end-of-contents encoding where an element belongs.
package/lib/ct.js CHANGED
@@ -1171,9 +1171,17 @@ var DEFAULT_FETCH_TIMEOUT = C.TIME.seconds(30);
1171
1171
  var MAX_FETCH_TIMEOUT = C.TIME.seconds(600);
1172
1172
  var KNOWN_FETCH_OPTS = intrinsic.assign(intrinsic.create(null), { url: 1, signerKey: 1, sigUrl: 1, transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1, requireJsonContentType: 1 });
1173
1173
 
1174
+ /** @internal The TLS settings flattened into a record of plain values, with the anchor LIST copied.
1175
+ * `opts.tls` is the caller's object and its members are read later than the trust-anchor check that
1176
+ * approves them, so leaving either shared let a getter elsewhere in the bag empty the pinned anchors
1177
+ * and rewrite the servername after the check had passed: the anchors checked were then not the anchors
1178
+ * the connection used. Copying the list is what closes that, since emptying an array the caller still
1179
+ * holds is the lever. Flatten once, check that record, and connect under the same one. */
1174
1180
  function _tlsForFetch(opts) {
1175
1181
  var t = opts.tls || {};
1176
- return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
1182
+ var anchors = t.anchors;
1183
+ if (intrinsic.isArray(anchors)) anchors = intrinsic.arraySlice(anchors);
1184
+ return { anchors: anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
1177
1185
  }
1178
1186
 
1179
1187
  function _sameOrigin(a, b) { return new URL(a).origin === new URL(b).origin; }
@@ -1252,38 +1260,81 @@ function _endpoint(url, message) {
1252
1260
  return out;
1253
1261
  }
1254
1262
 
1263
+ /** @internal The door every sec. 4 message enters by: the declared options refused if one is not
1264
+ * recognized, then taken ONCE into a plain record. A caller's bag is the caller's, so an option
1265
+ * reached through an accessor answers each read separately, and this code read several of them twice:
1266
+ * `url` for a presence check and again to build the endpoint, which let the check approve one host
1267
+ * while the request went to another; `tls` for the anchor check and again for the connection, which
1268
+ * would be a pin approved on one object and a socket opened under another. Reading from the record
1269
+ * afterwards means the value that was checked is the value that is used. Every verb calls this at its
1270
+ * own entry and passes the record down, rather than relying on a check further in. */
1271
+ function _clientDoor(opts) {
1272
+ /** @internal `optionsObject` is the door `signSct` in this module already enters by, and it REFUSES
1273
+ * an options bag carrying an accessor rather than tolerating one: a bag whose properties are not
1274
+ * plain values is a shape no set of checks can answer for, and it also refuses one that changes
1275
+ * which options it carries while they are read. Refusing beats copying, because a copy leaves the
1276
+ * caller's shape acceptable and every future read in this module has to remember to use the copy.
1277
+ * It also leaves ABSENCE intact, which materializing the declared names would not: an absent
1278
+ * `transport` means "open the connection yourself, under the pinned anchors", while one present
1279
+ * holding `undefined` is a transport that is not callable. */
1280
+ var bag = guard.identifier.optionsObject(opts, _ctErr, "ct/bad-input", "pki.ct client options");
1281
+ guard.identifier.assertKnownKeys(bag, KNOWN_CLIENT_OPTS, _ctErr, "ct/bad-input", "unknown opts field ");
1282
+ /** @internal The bag is returned as it came, not copied. A copy cannot reproduce what reading it
1283
+ * does: an option supplied through a caller's defaults object is reached along the prototype chain,
1284
+ * and the enumeration that would drive a copy deliberately omits an inherited FUNCTION member,
1285
+ * because that is how `Object.prototype.toString` is kept from counting as an option. Copying
1286
+ * therefore dropped an inherited `transport`, and dropping a transport is not a smaller result: it
1287
+ * falls through to opening a real connection where the caller passed a fake one. What makes the
1288
+ * repeated reads safe instead is that this door refuses a bag whose properties are not plain values,
1289
+ * and that each option below is taken into a local ONCE, before anything that could run caller code. */
1290
+ return bag;
1291
+ }
1292
+
1255
1293
  /** @internal The shared request shape every message uses: the caller's transport or a pinned-anchor
1256
1294
  * one, the caps, and the header filter. Factored so a verb adds a path and a body and nothing else,
1257
- * and so a rule about reaching a log is stated once. */
1258
- function _clientRequest(opts, label) {
1259
- guard.identifier.assertKnownKeys(opts, KNOWN_CLIENT_OPTS, _ctErr, "ct/bad-input", "unknown opts field ");
1260
- var transport = _assertTransport(opts);
1261
- if (opts.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log's base URL (there is no baked-in log)");
1295
+ * and so a rule about reaching a log is stated once. `o` has already been through `_clientDoor`, so
1296
+ * every read here is of a plain value. */
1297
+ function _clientRequest(o, label) {
1298
+ var transport = _assertTransport(o);
1299
+ /** @internal ONE read, and the presence check and the endpoint both decide from it. Read again after
1300
+ * the header copy below, which runs whatever getters the caller's header object carries, it was
1301
+ * whatever that code had since made it: a getter on a header name rewrote `url` and moved the request
1302
+ * to another host after this check had approved the original.
1303
+ * AND CONVERTED HERE, not at the endpoint. Holding the value alone is not enough when it is an
1304
+ * object, because the text form is what names the host and `String()` asks the object for it: a
1305
+ * header getter that rewrote an object-valued url's `href` moved the request just the same, the
1306
+ * conversion being the last reader. The endpoint is built from this string. */
1307
+ if (o.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log's base URL (there is no baked-in log)");
1308
+ var url = _stringOf(o.url);
1309
+ /** @internal Flattened BEFORE the header copy below, which invokes whatever getters the caller's
1310
+ * header object carries. One of those emptied the pinned anchors after this check had approved them,
1311
+ * so the check and the connection have to read the same flat record and it has to exist first. */
1312
+ var tls = _tlsForFetch(o);
1262
1313
  if (!transport) {
1263
- var t = opts.tls || {};
1264
- var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(intrinsic.isArray(t.anchors) && t.anchors.length === 0);
1265
- if (!hasAnchors && t.useSystemStore !== true) {
1314
+ var hasAnchors = tls.anchors !== undefined && tls.anchors !== null && !(intrinsic.isArray(tls.anchors) && tls.anchors.length === 0);
1315
+ if (!hasAnchors && tls.useSystemStore !== true) {
1266
1316
  throw _ctErr("ct/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned CT log server (RFC 6962 sec. 3.2)");
1267
1317
  }
1268
1318
  transport = httpTransport.https({ E: _ctErr, errPrefix: "ct" });
1269
1319
  }
1270
- var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_FETCH_TIMEOUT, { E: _ctErr, code: "ct/bad-input", min: 1, max: MAX_FETCH_TIMEOUT });
1271
- var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.CT_LOG_LIST_MAX_BYTES, { E: _ctErr, code: "ct/bad-input", min: 1, max: C.LIMITS.CT_LOG_LIST_MAX_BYTES });
1320
+ var timeout = guard.limits.cap(o.timeout, "timeout", DEFAULT_FETCH_TIMEOUT, { E: _ctErr, code: "ct/bad-input", min: 1, max: MAX_FETCH_TIMEOUT });
1321
+ var maxResponseBytes = guard.limits.cap(o.maxResponseBytes, "maxResponseBytes", C.LIMITS.CT_LOG_LIST_MAX_BYTES, { E: _ctErr, code: "ct/bad-input", min: 1, max: C.LIMITS.CT_LOG_LIST_MAX_BYTES });
1272
1322
  var headers = intrinsic.create(null);
1273
- guard.identifier.optionNames(opts.headers).forEach(function (k) {
1323
+ var supplied = o.headers;
1324
+ intrinsic.forEach(guard.identifier.optionNames(supplied), function (k) {
1274
1325
  var lk = _toLowerCase(k);
1275
- if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = opts.headers[k];
1326
+ if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = supplied[k];
1276
1327
  });
1277
- return { transport: transport, req: { headers: headers, tls: _tlsForFetch(opts), timeout: timeout,
1328
+ return { transport: transport, url: url, req: { headers: headers, tls: tls, timeout: timeout,
1278
1329
  maxResponseBytes: maxResponseBytes }, label: label };
1279
1330
  }
1280
1331
 
1281
1332
  /** @internal A sec. 4 message, fetched and read as JSON. The specification returns every error as a
1282
1333
  * 4xx or 5xx with a human-readable message, which `_fetchBody` already turns into a typed refusal,
1283
1334
  * so a non-200 never reaches the reader. */
1284
- async function _clientJson(opts, message, label, query, body) {
1285
- var ctx = _clientRequest(opts, label);
1286
- var url = _endpoint(opts.url, message);
1335
+ async function _clientJson(o, message, label, query, body) {
1336
+ var ctx = _clientRequest(o, label);
1337
+ var url = _endpoint(ctx.url, message);
1287
1338
  if (query) intrinsic.keys(query).forEach(function (k) { _searchSet(url.searchParams, k, query[k]); });
1288
1339
  var req = ctx.req;
1289
1340
  if (body !== undefined) {
@@ -1466,7 +1517,7 @@ async function verifySth(sth, logKey) {
1466
1517
  * sth.treeSize; // -> 3n
1467
1518
  */
1468
1519
  async function getSth(opts) {
1469
- opts = opts || {};
1520
+ opts = _clientDoor(opts || {});
1470
1521
  /** @internal ONE read of the option, and the presence check and the copy both decide from it. Reading
1471
1522
  * it twice let an accessor answer the two separately: a non-null value for the check and an
1472
1523
  * attacker's SPKI for the copy, and the tree head was then verified against a key the caller never
@@ -1524,7 +1575,7 @@ async function getSth(opts) {
1524
1575
  * p.verified; // -> true
1525
1576
  */
1526
1577
  async function getProofByHash(opts) {
1527
- opts = opts || {};
1578
+ opts = _clientDoor(opts || {});
1528
1579
  var sth = _assertSth(opts.sth, "opts.sth");
1529
1580
  /** @internal Copied for the reason the tree head is: it is sent on the wire and then folded against the
1530
1581
  * head after the fetch returns, so a view would let the leaf being proved differ from the leaf asked
@@ -1579,7 +1630,7 @@ async function getProofByHash(opts) {
1579
1630
  * c.verified; // -> true
1580
1631
  */
1581
1632
  async function getSthConsistency(opts) {
1582
- opts = opts || {};
1633
+ opts = _clientDoor(opts || {});
1583
1634
  var oldSth = _assertSth(opts.oldSth, "opts.oldSth");
1584
1635
  var newSth = _assertSth(opts.newSth, "opts.newSth");
1585
1636
  if (oldSth.treeSize > newSth.treeSize) {
@@ -1636,7 +1687,7 @@ async function getSthConsistency(opts) {
1636
1687
  * e.entries[0].leafInput; // -> <Buffer ...>
1637
1688
  */
1638
1689
  async function getEntries(opts) {
1639
- opts = opts || {};
1690
+ opts = _clientDoor(opts || {});
1640
1691
  var start = guard.range.uint64(opts.start, _ctErr, "ct/bad-input", "opts.start");
1641
1692
  var end = guard.range.uint64(opts.end, _ctErr, "ct/bad-input", "opts.end");
1642
1693
  if (end < start) {
@@ -1692,7 +1743,7 @@ async function getEntries(opts) {
1692
1743
  * r.certificates.length; // -> 2
1693
1744
  */
1694
1745
  async function getRoots(opts) {
1695
- opts = opts || {};
1746
+ opts = _clientDoor(opts || {});
1696
1747
  var doc = await _clientJson(opts, "get-roots", "roots");
1697
1748
  return guard.verdict.of({ certificates: _b64Array(doc, "certificates", "ct/bad-roots", "roots", undefined, C.LIMITS.CT_MAX_ROOTS) });
1698
1749
  }
@@ -1775,7 +1826,7 @@ async function addPreChain(opts) {
1775
1826
  * The chain encoding, the response shape, and the refusal when the receipt does not verify are one
1776
1827
  * rule stated once. */
1777
1828
  async function _addChain(opts, message, entryType) {
1778
- opts = opts || {};
1829
+ opts = _clientDoor(opts || {});
1779
1830
  /** @internal ONE read of the option, used by the presence check here and by the copy below. Reading it
1780
1831
  * twice let an accessor answer the two separately, presenting a non-null value to the check and an
1781
1832
  * attacker's SPKI to the copy, and the returned receipt was then verified against a key the caller
@@ -2000,7 +2051,10 @@ function _b64Array(obj, name, code, label, exactLen, maxCount) {
2000
2051
  * r.logs[0] && r.logs[0].trusted; // the verified, trusted-log set (the detached signature checked first)
2001
2052
  */
2002
2053
  async function fetchLogList(opts) {
2003
- opts = opts || {};
2054
+ /** @internal The same door the sec. 4 verbs enter by, over this verb's own option set. Checking the
2055
+ * NAMES alone left every value still to be read from the caller's object, and `url` was read twice,
2056
+ * once to parse and once to fetch, so an accessor sent the request somewhere the parse never saw. */
2057
+ opts = guard.identifier.optionsObject(opts, _ctErr, "ct/bad-input", "pki.ct.fetchLogList options");
2004
2058
  guard.identifier.assertKnownKeys(opts, KNOWN_FETCH_OPTS, _ctErr, "ct/bad-input", "unknown opts field ");
2005
2059
  /** @internal Read once, at the door, and carried from here. Asking again would let an option that
2006
2060
  * answers differently on each read hand back something other than the value that was checked. */
@@ -2017,22 +2071,27 @@ async function fetchLogList(opts) {
2017
2071
  if (opts.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log_list.json URL (there is no baked-in vendor URL)");
2018
2072
  var jsonParsed = _parseHttpsUrl(opts.url, "URL");
2019
2073
  var jsonUrl = jsonParsed.href;
2020
- var sigUrl = opts.sigUrl != null ? _parseHttpsUrl(opts.sigUrl, "signature URL").href : _sigUrlFor(jsonParsed);
2074
+ /** @internal ONE read: the presence test and the parse decide from the same value. */
2075
+ var suppliedSigUrl = opts.sigUrl;
2076
+ var sigUrl = suppliedSigUrl != null ? _parseHttpsUrl(suppliedSigUrl, "signature URL").href : _sigUrlFor(jsonParsed);
2021
2077
  if (!_sameOrigin(jsonUrl, sigUrl)) throw _ctErr("ct/bad-input", "opts.sigUrl must share the log-list URL's origin (" + jsonParsed.origin + "); a cross-origin detached-signature host is not supported");
2078
+ /** @internal Flattened once, for the reason `_clientRequest` does it: the header copy below runs the
2079
+ * caller's getters, and the check and the connection have to read the same flat record. */
2080
+ var fetchTls = _tlsForFetch(opts);
2022
2081
  if (!transport) {
2023
- var t = opts.tls || {};
2024
- var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
2025
- if (!hasAnchors && t.useSystemStore !== true) throw _ctErr("ct/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned CT server (RFC 6962 sec. 3.2)");
2082
+ var hasAnchors = fetchTls.anchors !== undefined && fetchTls.anchors !== null && !(intrinsic.isArray(fetchTls.anchors) && fetchTls.anchors.length === 0);
2083
+ if (!hasAnchors && fetchTls.useSystemStore !== true) throw _ctErr("ct/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned CT server (RFC 6962 sec. 3.2)");
2026
2084
  transport = httpTransport.https({ E: _ctErr, errPrefix: "ct" });
2027
2085
  }
2028
2086
  var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_FETCH_TIMEOUT, { E: _ctErr, code: "ct/bad-input", min: 1, max: MAX_FETCH_TIMEOUT });
2029
2087
  var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.CT_LOG_LIST_MAX_BYTES, { E: _ctErr, code: "ct/bad-input", min: 1, max: C.LIMITS.CT_LOG_LIST_MAX_BYTES });
2030
2088
  var headers = intrinsic.create(null);
2031
- guard.identifier.optionNames(opts.headers).forEach(function (k) {
2032
- var lk = k.toLowerCase();
2033
- if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = opts.headers[k];
2089
+ var suppliedHeaders = opts.headers;
2090
+ intrinsic.forEach(guard.identifier.optionNames(suppliedHeaders), function (k) {
2091
+ var lk = _toLowerCase(k);
2092
+ if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = suppliedHeaders[k];
2034
2093
  });
2035
- var req = { headers: headers, tls: _tlsForFetch(opts), timeout: timeout, maxResponseBytes: maxResponseBytes };
2094
+ var req = { headers: headers, tls: fetchTls, timeout: timeout, maxResponseBytes: maxResponseBytes };
2036
2095
  /** @internal Read before the request goes out, so the option that decides the check is the one the call
2037
2096
  * was made with rather than whatever it says once the response is back. */
2038
2097
  var requireJsonContentType = opts.requireJsonContentType === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.8.48",
3
+ "version": "0.8.49",
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:0f0eb9a3-9ce3-42bb-ba73-3ad3528eccf7",
5
+ "serialNumber": "urn:uuid:850d86e8-9f55-4854-9b3a-89ed4e16dfd5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-10-05T06:06:36.873Z",
8
+ "timestamp": "2026-10-05T08:10:11.062Z",
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.8.48",
22
+ "bom-ref": "@blamejs/pki@0.8.49",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.8.48",
25
+ "version": "0.8.49",
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.8.48",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.8.49",
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.8.48",
57
+ "ref": "@blamejs/pki@0.8.49",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]