@blamejs/pki 0.8.56 → 0.8.58

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,52 @@ 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.58 — 2026-10-07
8
+
9
+ Connect under the trust anchors, the address and the identity check the request named, not the ones a second read answers with.
10
+
11
+ ### Fixed
12
+
13
+ - `pki.transport.https` applies the private-address refusal the request asked for. `blockPrivateAddresses` was read once to test for presence and again for the value, so a value that answered `true` and then `false` left the refusal unarmed: measured, a request to `127.0.0.1` completed with the block in force. The same split applied to `tls.servername`, which is the name the server's certificate is verified against, to `tls.checkServerIdentity`, where the caller's hook was found to be a function and then not installed, to `tls` and `tls.anchors` themselves, to the transport's own `defaults.tls`, to `proxy`, and to `timeout` and `maxResponseBytes`. Each is now read once.
14
+ - `pki.transport.https` connects under the trust anchors the request named. The list is assembled by appending to an empty array and the appending operation was read off the live prototype, so one answering with an empty array left the connection with no trust list of its own and the platform's certificate store decided instead: a caller that passed `tls.anchors` to pin one issuer was pinned to none of them. Measured against a loopback endpoint whose certificate only the named anchor trusts, the request then failed on the platform store's verdict rather than succeeding on the caller's. The proxy CONNECT path assembled its own trust list the same way.
15
+ - The address a private-address check cleared is the address the connection is made to. A resolver that returns a list had its own entries forwarded to the socket layer, so an entry whose address answered a public value to the check and a loopback one afterwards reached loopback with `blockPrivateAddresses` set. Each address is now read once, that read is what is classified, and the snapshot is what is handed on.
16
+ - A sparse proxy trust-anchor list is refused rather than read. The eligibility check walks the array's own property descriptors, which cannot see a gap, so a list whose gap was answered from the array prototype was validated as holding one anchor and converted as holding another: reading an array by index is specified to consult the prototype at a hole. A list with a gap is now refused as `transport/bad-proxy`.
17
+ - The platform trust store is read through the provider bound when the module loads, so a replacement installed afterwards cannot answer with its own certificate list for a `useSystemStore` connection.
18
+ - Every connection is opened through the function bound when the module loaded, not a property read off `node:https`, `node:http` or `node:tls` at the moment of the call: the request itself, the proxy CONNECT, the TLS connection made over a tunnel, and the one `pki.transport.peerChain` opens. A replacement installed afterwards sent a request prepared for one destination to another and handed the caller that peer's answer.
19
+ - The socket itself is opened by this module rather than by the agent node builds, on the request and on the proxy CONNECT alike. `agent: false` does not mean no agent, which is what it had been relied on for: node builds one from the default agent's constructor, and that agent's own connection builder opened the socket, so replacing it returned a connection to another destination and the request went out over it. Measured, an `https:` request with `blockPrivateAddresses` set and certificate verification on reached a local plaintext listener that presented no certificate, and its body reached the caller. On the CONNECT path the same replacement could answer with a plain socket in place of the proxy's TLS connection, which is the request that carries `Proxy-Authorization`. The request options node computed for the hop are forwarded unchanged, so every TLS setting still applies, and there is still no connection pooling.
20
+ - A per-request `tls.checkServerIdentity` keeps the transport's default from being read at all. The default was read whether or not the request overrode it, so a default supplied as an accessor ran even for a request that replaced it, and one that throws refused the request before it connected.
21
+ - A response is as long as the bytes that arrived, and holds those bytes. It is allocated at the counted length and written through operations bound at load, because three earlier spellings were each steerable: a copy read off the prototype, a slice whose result is built through the species constructor, and a conversion that reads the received view's own `valueOf` and `length`. The last of those decided the length as well as the content: measured, an eight-byte response came back as 64 bytes with a `maxResponseBytes` of 8, so the size cap was reported as enforced on a body that exceeded it.
22
+ - Every remaining operation `pki.transport.https` reaches a socket through is bound when the module loads: the PEM armor and IPv6 bracket scanners, the peer-chain walk, the anchor conversion, the TLS error classifier that decides whether a failure is reported as a trust failure or a transport one, the header names a proxy credential is stripped from, and the CONNECT tunnel's own options. The trust lists are assembled by copying and appending index by index, because appending with the built-in consults a spreading hook each argument can inherit, which replaced a validated anchor with another one.
23
+ - `guard.limits.cap` compares a value against the bound it validated. `min` and `max` were each read more than once, with the integer check reading one value and the comparison another, so a bound answering a small integer to the check and a large one to the comparison configured a cap that admitted a value above its own limit. Each bound is now read once. No shipped verb could reach this, because every caller in the toolkit passes a fixed bound; the guard is what has to hold for the next one.
24
+
25
+ ## v0.8.57 — 2026-10-07
26
+
27
+ Read the OCSP response a CMS message carries in its crls field, instead of accepting it unread.
28
+
29
+ ### Added
30
+
31
+ - A `crls` entry in the `other [1]` arm is read as RFC 5652 sec. 10.2.1 `OtherRevocationInfoFormat` and carries `otherRevInfoFormat` (dotted), `otherRevInfoFormatName` (or `null` for a format no registry names) and `otherRevInfoBytes`, the raw ANY TLV exactly as it arrived. A CertificateList entry is unchanged. An operator can now tell an RFC 5940 OCSP entry from an SCVP one, and either from an unregistered format, which nothing in this build could do before.
32
+ - For `id-ri-ocsp-response`, the embedded OCSP response is surfaced in `ocspResponse` as a parsed record, with the byte ranges a verifier hashes left raw. `pki.path.ocspChecker`, `pki.path.verifyOcspResponse`, `pki.ocsp.verify` and `pki.lint.ocsp` all accept it as it stands: harvest the entries with `crls.map(e => e.ocspResponse).filter(Boolean)` and hand them over. The same verbs take the record and the caller re-encodes nothing; a rebuilt copy of it is refused exactly as a rebuilt parse result is.
33
+ - A carried response whose `responseType` names a body this build does not decode keeps its envelope and leaves the body alone: `ocspResponse.basicResponse` is `null` and `ocspResponse.responseBytes.response` holds the bytes as they arrived. RFC 6960 sec. 4.2.1 dispatches the response body on that OID and RFC 5940 sec. 3 asks for a successful status rather than a body a reader can decode, so the message parses and a caller who only needs its signature verified is not stopped by a response type nothing in the path consults. The verbs above are handed a record with no response body and determine nothing from it, so a certificate's status stays undetermined rather than reading as good. `pki.schema.ocsp.parseResponse` still refuses the same bytes with `ocsp/unsupported-response-type`: a client that asked a responder for an answer needs to be told it cannot be read.
34
+ - The same reading applies to `originatorInfo.crls`, so EnvelopedData (RFC 5652 sec. 6.1), AuthenticatedData (sec. 9.1) and AuthEnvelopedData (RFC 5083 sec. 2.1) carry it too, not SignedData alone. RFC 5940 sec. 1 names all four.
35
+ - `pki.oid` resolves the two id-ri formats a reader can act on: `id-ri-ocsp-response` (1.3.6.1.5.5.7.16.2) and `id-ri-scvp` (1.3.6.1.5.5.7.16.4).
36
+ - `pki.lint.cms` reports a notice when a `crls` entry carries revocation information this build does not read, naming the format, the response type where there is one, and where the entry sat. An `id-ri-ocsp-response` entry whose response was read draws nothing; one whose `responseType` named a body this build does not decode draws the notice, because the revocation information it carries went unconsulted either way.
37
+
38
+ ### Changed
39
+
40
+ - `pki.schema.cms.parse` reads these entries by default. An entry that was accepted unread before may now be refused if it is malformed under RFC 5652 sec. 10.2.1 or RFC 5940 sec. 3; an unrecognized format is still accepted and surfaced opaquely, because sec. 10.2.1's ANY DEFINED BY is an extension point and refusing an unknown format would refuse a future registered one. The reading is not optional, because an opt-in would leave the entry unread for every caller who does not know to ask for it.
41
+
42
+ ### Fixed
43
+
44
+ - An `other [1]` crls entry is held to its stated shape. `OtherRevocationInfoFormat` is exactly an OBJECT IDENTIFIER and an ANY, so an empty arm, a one-element arm carrying a format and no revocation information, and an arm with a third element are each refused as `cms/bad-other-rev-info`. The one-element form is what OpenSSL both accepts and can emit, because its template marks the value optional.
45
+ - An OCSP response carried in that arm must state a status. A `tryLater` or `malformed` response is a well-formed OCSPResponse that conveys no revocation information, so it is refused here as `cms/bad-ocsp-rev-info` (RFC 5940 sec. 3). `pki.schema.ocsp.parseResponse` still accepts one, because a client reading a live responder's answer needs to see it.
46
+ - A BasicOCSPResponse where an OCSPResponse belongs is refused. Both are SEQUENCEs, so the confusion RFC 5126 sec. 6.3.4 invites by carrying `SEQUENCE OF BasicOCSPResponse` in a signed attribute passes any tag check and fails only on the first field, where a status is read and a ResponseData arrives.
47
+ - An OCSP response placed in the CertificateList position is refused as `cms/misplaced-ocsp-response`. RFC 5940 sec. 2 states that such a response is carried in the `other` arm, and a universal SEQUENCE in `crls` was previously surfaced raw whatever it held.
48
+ - A caller's decode caps reach the embedded response. The walk runs under the parse's own budget rather than starting a fresh one, so a tightened cap refuses the message with the caller's typed `cms/too-large` instead of letting the nested work run at defaults.
49
+ - `pki.cms.parseCertsOnly`'s `maxCerts` bound no longer decides which messages it accepts. The bound caps the certificates and CRLs parsed and returned, but it was applied by shortening the lists before they were checked, so a cap leaving no room for the CRLs dropped a tagged entry silently: a capped reader accepted a message an uncapped reader refuses and handed back an empty CRL list where revocation information had been. The structural check now runs over every entry, and only the certificate and CRL parsing stays bounded, which is the work the bound exists for.
50
+ - `pki.ocsp.verify` takes the record a `crls` entry surfaces, which it did not. Every verb that accepts a parse result re-reads it from the bytes it was parsed from, so that an edited copy cannot be verified in place of what was parsed; a record produced by walking a structure a message carries was not attributed to those bytes, so the re-reading produced an object that the next verb in the chain read as rebuilt and refused with `path/bad-input`. A record is now attributed the same way whether it was parsed from its own bytes or walked inside a message that carried it, and the refusal of a genuinely rebuilt record is unchanged.
51
+ - The three readers that require a plain CertificateList still refuse a tagged entry now that a well-formed one parses: `pki.cms.parseCertsOnly` (`cms/bad-crl`), `pki.est.parseCertsOnly` (`est/bad-crl`) and a SCEP SUCCESS CertRep (`scep/bad-crl`). The SCEP refusal had no test before this change.
52
+
7
53
  ## v0.8.56 — 2026-10-06
8
54
 
9
55
  Decide a name, an address, a revocation and a trust bit through operations and values no caller can replace.
package/README.md CHANGED
@@ -249,7 +249,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
249
249
  | `pki.schema.csr` | PKCS#10 certification requests (RFC 2986): subject DN, public key, requested attributes, signature. `decodeExtensions` is what a CA reads to see what a request asked for, returning the rows of its one `extensionRequest` attribute; a request carrying more than one is refused, and `groups: true` reads each separately Fail-closed — `parse`, `pemDecode`, `pemEncode`, `decodeExtensions`, `decodeExtension` |
250
250
  | `pki.schema.trustanchor` | RFC 5914 trust anchor lists: a trust anchor as a structure rather than as a certificate, carrying the constraints a root program otherwise states out of band. `parse` reads a `TrustAnchorList` into `{ anchors }`, each naming which of the three `TrustAnchorChoice` arms it came from: a `certificate`, a bare `tbsCert` (an anchor has no signature to check), or a `taInfo`. A `taInfo` carries the public key, the key identifier, an optional title, and a `certPath` stating the name the anchor is known by plus the policies it may assert, the namespace it is trusted for, and how far below it a path may run. Every rule RFC 5914 states is enforced: an empty `taName`, a policy set carrying qualifiers, a negative path length, and a `requireExplicitPolicy` with no policy set are each refused, and where the anchor carries a certificate its subject, public key and `subjectKeyIdentifier` must match the anchor's own. The four extension types the RFC excludes from `exts` are reported and left out rather than refused, as it says to ignore them. `parseInfo` reads a bare `TrustAnchorInfo`. `pki.trust.parseTrustAnchorList` turns a list into anchors `pki.path.validate` consumes, and `pki.trustanchor.build` writes one Fail-closed — `parse`, `parseInfo`, `pemDecode`, `pemEncode` |
251
251
  | `pki.schema.pkcs8` | PKCS#8 private keys (RFC 5208 / 5958): algorithm, raw key bytes, attributes, optional public key. Encrypted keys are recognized but not decrypted. Fail-closed — `parse`, `parseEncrypted`, `pemDecode`, `pemEncode` |
252
- | `pki.schema.cms` | CMS (RFC 5652 / 5083 / 9629): SignedData (§5, signer infos plus raw signed-attribute bytes for external verification), EnvelopedData (§6, all five RecipientInfo kinds including RFC 5753 key agreement and RFC 9629 KEM recipients with ML-KEM validation), EncryptedData (§8), AuthenticatedData (§9, MAC surface plus raw `authAttrsBytes`), and AuthEnvelopedData (RFC 5083, with RFC 5084 AES-GCM/CCM parameter validation). §11 attribute placement is enforced, countersignatures validate recursively, certificates and CRLs are validated against the closed CHOICE sets and kept raw, and every result is tagged `contentTypeName` Fail-closed — `parse`, `pemDecode`, `pemEncode` |
252
+ | `pki.schema.cms` | CMS (RFC 5652 / 5083 / 9629): SignedData (§5, signer infos plus raw signed-attribute bytes for external verification), EnvelopedData (§6, all five RecipientInfo kinds including RFC 5753 key agreement and RFC 9629 KEM recipients with ML-KEM validation), EncryptedData (§8), AuthenticatedData (§9, MAC surface plus raw `authAttrsBytes`), and AuthEnvelopedData (RFC 5083, with RFC 5084 AES-GCM/CCM parameter validation). §11 attribute placement is enforced, countersignatures validate recursively, certificates and CRLs are validated against the closed CHOICE sets and kept raw, a `crls` entry in the `other [1]` arm is read as RFC 5652 §10.2.1 `OtherRevocationInfoFormat` with an RFC 5940 OCSP response surfaced as a record the revocation verbs take directly, and every result is tagged `contentTypeName` Fail-closed — `parse`, `pemDecode`, `pemEncode` |
253
253
  | `pki.schema.ocsp` | OCSP requests and responses (RFC 6960): per-certificate status (good, revoked, unknown), responder identity, raw tbs bytes for external verification, certificates kept raw. A response's nonce, and a single response's archive cutoff (§4.4.4) and CRL reference (§4.4.2), are decoded onto their extension entries by name; a malformed one is refused. Non-basic response types are recognized but not decoded. Fail-closed — `parseRequest`, `parseResponse`, `pemDecode`, `pemEncode` |
254
254
  | `pki.schema.tsp` | RFC 3161 timestamp requests, responses, and tokens: the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), the TSTInfo payload (imprint, genTime with sub-second precision, serial, nonce, accuracy), the status-to-token coupling, and the token wrapper composed over CMS with the single-signer rule. Fail-closed — `parse`, `parseRequest`, `parseResponse`, `parseTstInfo`, `parseToken`, `pemDecode`, `pemEncode` |
255
255
  | `pki.schema.attrcert` | Attribute certificates (RFC 5755): holder and issuer identities as validated GeneralNames, the validity window as real `Date`s, and the privilege attributes and extensions decoded to structured values (role, clearance, service and access identity, group, charging identity; audit identity, target and proxy information, no-rev-avail, AA controls), with the raw signed region for a verifier. Unknown types stay opaque, and the obsolete v1 form is recognized and deferred. `decodeExtensions` returns the same row shape the other formats return, reporting the criticality RFC 5755 fixes for each extension it profiles Fail-closed — `parse`, `pemDecode`, `pemEncode`, `decodeExtensions`, `decodeExtension` |
@@ -16,13 +16,19 @@ function _monotonicNow() { return _Number(_hrtimeBigint() / 1000000n); }
16
16
  function cap(value, key, dflt, opts) {
17
17
  if (value === undefined) return dflt;
18
18
  opts = opts || {};
19
- var min = opts.min === undefined ? 0 : opts.min;
20
- if (!_isInteger(min) || (opts.max !== undefined && !_isInteger(opts.max))) {
19
+ /** @internal Each bound is read ONCE, into the value the integer check and the comparison share.
20
+ * Read twice, the check saw one bound and the comparison another, so an `opts` answering a valid
21
+ * small integer to the check and a large one to the comparison configured a cap that admitted a
22
+ * value above the bound it had just been validated against. */
23
+ var rawMin = opts.min, rawMax = opts.max;
24
+ var min = rawMin === undefined ? 0 : rawMin;
25
+ var hasMax = rawMax !== undefined;
26
+ if (!_isInteger(min) || (hasMax && !_isInteger(rawMax))) {
21
27
  throw new TypeError("guard.limits.cap: the min/max bounds for " + key + " must be integers");
22
28
  }
23
- if (!_isInteger(value) || value < min || (opts.max !== undefined && value > opts.max)) {
24
- var want = "an integer >= " + min + (opts.max !== undefined ? " and <= " + opts.max : "");
25
- if (min === 0 && opts.max === undefined) want = "a non-negative integer";
29
+ if (!_isInteger(value) || value < min || (hasMax && value > rawMax)) {
30
+ var want = "an integer >= " + min + (hasMax ? " and <= " + rawMax : "");
31
+ if (min === 0 && !hasMax) want = "a non-negative integer";
26
32
  if (opts.E) throw opts.E(opts.code, (opts.label || key) + " must be " + want);
27
33
  throw new TypeError("decode: " + key + " must be " + want);
28
34
  }
@@ -136,7 +142,8 @@ function budget(limits) {
136
142
  // @enforced-by behavioral -- an elapsed-time comparison has no rename-proof shape that separates a deadline from any other subtraction; the vectors driving a checker to its bound are the guard
137
143
  function deadline(totalMs, opts) {
138
144
  opts = opts || {};
139
- var now = typeof opts.now === "function" ? opts.now : _monotonicNow;
145
+ var rawNow = opts.now;
146
+ var now = typeof rawNow === "function" ? rawNow : _monotonicNow;
140
147
  if (!_isInteger(totalMs) || totalMs < 0) {
141
148
  throw new TypeError("guard.limits.deadline: the total must be a non-negative integer of milliseconds");
142
149
  }
@@ -157,18 +157,28 @@ function recordingParser(kind, parse, ErrorClass, code, label) {
157
157
  }
158
158
 
159
159
  // @enforced-by guard-shape-reinlined -- shares recordingParser's shape below: the PROVENANCE.set
160
+ /** @internal `derive` records what it produces, so a re-derivation is the same kind of record the
161
+ * first walk was. Verbs stack: a door answers a record by re-deriving it from the bytes it
162
+ * recorded, and hands that to the next door, which is then the one deciding whether it is a parse
163
+ * result. Deriving through the bare walk left the second door with an object carrying the kind's
164
+ * fields and no provenance, which is the shape it refuses as rebuilt. */
160
165
  function recordingWalker(kind, walkNode, decodeBytes) {
161
- return function (node, ctx) {
162
- var out = walkNode(node, ctx);
163
- if (out && typeof out === "object" && node && _isBytes(node.bytes)) {
166
+ function remember(out, source) {
167
+ if (out && typeof out === "object" && _isBytes(source)) {
164
168
  _weakSet(PROVENANCE, out, {
165
169
  kind: kind,
166
- source: _bufferFrom(node.bytes),
170
+ source: _bufferFrom(source),
167
171
  shape: _shapeOf(out),
168
- derive: function (src) { return walkNode(decodeBytes(src)); },
172
+ derive: derive,
169
173
  });
170
174
  }
171
175
  return out;
176
+ }
177
+ function derive(src) { return remember(walkNode(decodeBytes(src)), src); }
178
+ return function (node, ctx) {
179
+ var out = walkNode(node, ctx);
180
+ if (!out || typeof out !== "object" || !node) return out;
181
+ return remember(out, node.bytes);
172
182
  };
173
183
  }
174
184
 
@@ -208,9 +218,15 @@ function isRecordedAsProduced(obj) {
208
218
  */
209
219
  function sourceOf(obj) {
210
220
  if (!isRecordedAsProduced(obj)) return null;
211
- return _bufferFrom(_weakGet(PROVENANCE, obj).source);
221
+ return _sourceCopy(_weakGet(PROVENANCE, obj).source);
212
222
  }
213
223
 
224
+ /** @internal The recorded bytes are the one account of what was parsed, so what leaves this module
225
+ * is a copy: a parse that keeps its argument would otherwise be holding them, and a write through
226
+ * that reference changes what the NEXT re-derivation of the same record reads. A PEM source is a
227
+ * string and cannot be written through. */
228
+ function _sourceCopy(source) { return _isBytes(source) ? _bufferFrom(source) : source; }
229
+
214
230
  function _shapeOf(obj) {
215
231
  var shape = _create(null);
216
232
  var keys = _allNames(obj);
@@ -238,7 +254,7 @@ function _allNames(obj) {
238
254
  function fromTrustedSource(input, kind, claimFields, parse, E, code, why) {
239
255
  if (input && typeof input === "object" && !_isBytes(input)) {
240
256
  var rec = _recordOf(input, kind);
241
- if (rec !== undefined) return (rec.derive || parse)(rec.source);
257
+ if (rec !== undefined) return (rec.derive || parse)(_sourceCopy(rec.source));
242
258
  for (var i = 0; i < claimFields.length; i++) {
243
259
  var claims;
244
260
  try { claims = input[claimFields[i]] !== undefined; }