@blamejs/pki 0.3.15 → 0.3.18

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.
@@ -0,0 +1,310 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.transport
6
+ * @nav Protocols
7
+ * @title Transport
8
+ * @order 195
9
+ * @slug transport
10
+ *
11
+ * @intro
12
+ * The shared, fail-closed `node:https` transport the enrollment protocol clients
13
+ * drive -- `pki.est` now, `pki.acme` and `pki.cmp` next. This is the ONLY module in
14
+ * the toolkit that opens a socket; every protocol layer stays transport-agnostic and
15
+ * composes it (or an injected substitute) through one contract:
16
+ * `transport(request) -> Promise<{ status, headers, body }>`. The response triple is
17
+ * exactly what a message layer's classifier consumes, so no protocol semantics leak
18
+ * into the socket layer -- the transport owns socket lifecycle, the TLS trust policy,
19
+ * the streaming size cap, and the timeout budget; the caller owns HTTP status,
20
+ * content-type, redirect, and authentication decisions.
21
+ *
22
+ * `pki.transport.https(defaults?)` binds TLS + budget defaults and returns a
23
+ * transport. Trust is EXPLICIT and fail-closed: a request is refused unless it
24
+ * carries an https URL and either a `tls.anchors` set (an Explicit trust-anchor
25
+ * database, mapped to the node `ca` option) or an explicit `tls.useSystemStore`
26
+ * opt-in to node's bundled roots. `rejectUnauthorized` is ALWAYS on -- there is no
27
+ * code path that disables server-certificate verification. The response body is
28
+ * bounded WHILE it streams: the accumulator aborts the socket the instant the running
29
+ * total crosses `maxResponseBytes`, before a byte reaches a decoder. A protocol
30
+ * client MAY parameterize the transport with its own `(code, message, cause)` error
31
+ * factory + code prefix, so the same choke point surfaces domain-specific codes.
32
+ *
33
+ * @card
34
+ * The shared fail-closed node:https transport (est / acme / cmp): explicit trust
35
+ * anchors, rejectUnauthorized always on, a TLS floor, a streaming response-size cap,
36
+ * and a timeout -- behind one `transport(request) -> {status, headers, body}` seam.
37
+ */
38
+
39
+ var nodeHttps = require("node:https");
40
+ var nodeNet = require("node:net");
41
+ var nodeTls = require("node:tls");
42
+ var constants = require("./constants");
43
+ var guard = require("./guard-all");
44
+ var frameworkError = require("./framework-error");
45
+
46
+ var TransportError = frameworkError.TransportError;
47
+ function defaultE(code, message, cause) { return new TransportError(code, message, cause); }
48
+
49
+ var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
50
+ var MAX_TIMEOUT = constants.TIME.seconds(600);
51
+ var DEFAULT_MIN_VERSION = "TLSv1.2";
52
+
53
+ // A config-time budget: cap() with the caller's typed factory + a >= 1 floor and an
54
+ // upper bound (maxResponseBytes may only be tightened DOWNWARD from the default). A
55
+ // NaN / negative / over-max value is a typed reject, never a silently-disabled bound.
56
+ function _budget(value, key, dflt, max, E, code) {
57
+ return guard.limits.cap(value, key, dflt, { E: E, code: code, min: 1, max: max, label: key });
58
+ }
59
+
60
+ // Node's `ca` option expects PEM; a raw DER certificate Buffer -- the toolkit's native trust-anchor
61
+ // form (e.g. what pki.est.cacerts / parseCertsOnly return) -- is silently ignored there, so the
62
+ // connection would fail server authentication despite a valid anchor. Wrap a DER anchor as PEM
63
+ // (a Buffer already carrying PEM text, or a PEM string, passes through unchanged).
64
+ function _pemifyAnchor(a) {
65
+ if (typeof a === "string") return a;
66
+ if (Buffer.isBuffer(a)) {
67
+ // Detect PEM by its armor at the START (after optional whitespace) only: a DER certificate begins
68
+ // with the SEQUENCE tag 0x30 and may itself contain the bytes "-----BEGIN" in a subject / extension
69
+ // value, so an anywhere-search would leave such a DER anchor unwrapped and unusable.
70
+ if (/^\s*-----BEGIN /.test(a.toString("latin1", 0, 64))) return a;
71
+ var b64 = a.toString("base64").replace(/.{1,64}/g, "$&\n");
72
+ return "-----BEGIN CERTIFICATE-----\n" + b64 + "-----END CERTIFICATE-----\n";
73
+ }
74
+ return a;
75
+ }
76
+ function _pemifyAnchors(anchors) {
77
+ var list = Array.isArray(anchors) ? anchors.map(_pemifyAnchor) : [_pemifyAnchor(anchors)];
78
+ return list;
79
+ }
80
+
81
+ // The OS system certificate store plus node's bundled roots (cached; the read is not free), for a
82
+ // tls.useSystemStore opt-in. Node's default (no `ca`) is ONLY the bundled Mozilla set, so a server
83
+ // chaining to an OS-installed enterprise CA would fail without this. On a node too old for
84
+ // tls.getCACertificates the cache stays empty and the caller falls back to node's default store.
85
+ var _systemCaCache = null;
86
+ function _systemCa() {
87
+ if (_systemCaCache !== null) return _systemCaCache;
88
+ var out = [];
89
+ if (typeof nodeTls.getCACertificates === "function") {
90
+ ["system", "bundled"].forEach(function (t) {
91
+ // allow:swallow-unverified a store TYPE unsupported on this node is skipped; the other type
92
+ // (and, if both fail, node's default-store fallback) still applies -- a best-effort store load.
93
+ try { var c = nodeTls.getCACertificates(t); if (Array.isArray(c)) out = out.concat(c); } catch (_e) { /* unsupported type */ }
94
+ });
95
+ }
96
+ _systemCaCache = out;
97
+ return out;
98
+ }
99
+
100
+ // 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.
104
+ function _classifyError(e, C) {
105
+ var s = String((e && e.code) || "") + " " + String((e && e.message) || "");
106
+ if (/PROTOCOL_VERSION|UNSUPPORTED_PROTOCOL|VERSION_TOO_LOW|WRONG_VERSION|NO_PROTOCOLS_AVAILABLE|INAPPROPRIATE_FALLBACK/i.test(s)) return C("tls-floor");
107
+ if (/CERT|SELF.?SIGNED|VERIFY|ALTNAME|HOSTNAME|DEPTH_ZERO|LOCAL_ISSUER|HANDSHAKE|\bSSL\b|\bTLS\b/i.test(s)) return C("server-auth-failed");
108
+ return C("transport-error");
109
+ }
110
+
111
+ /**
112
+ * @primitive pki.transport.https
113
+ * @signature pki.transport.https(defaults?) -> transport
114
+ * @since 0.3.16
115
+ * @status experimental
116
+ * @spec RFC 7030, RFC 8996
117
+ * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770)
118
+ * @related pki.est.cacerts, pki.est.simpleenroll
119
+ *
120
+ * Build a fail-closed `node:https` transport: `transport(request) -> Promise<{ status,
121
+ * headers, body }>`. `defaults` binds a `tls` policy (`anchors` -> the node `ca`;
122
+ * `useSystemStore` to opt into the bundled roots; `cert`/`key` for mutual TLS;
123
+ * `minVersion` 'TLSv1.2' (default) or 'TLSv1.3'; `servername`; a `checkServerIdentity`
124
+ * that may only tighten) plus `timeout` and `maxResponseBytes` budgets. Each `request`
125
+ * ({ method, url, headers, body, tls, timeout, maxResponseBytes }) may override them.
126
+ * A non-https URL (`transport/insecure-url`), a request with neither an explicit
127
+ * anchor nor `useSystemStore` (`transport/no-trust-anchors`), a body over the streaming
128
+ * cap (`transport/response-too-large`), a stalled socket (`transport/timeout`), a below
129
+ * -floor negotiation (`transport/tls-floor`), or a failed server authentication
130
+ * (`transport/server-auth-failed`) all fail closed; `rejectUnauthorized` is always on.
131
+ * A protocol client passes its own error factory (`defaults.E`) + `defaults.errPrefix`
132
+ * to surface domain codes (`est/...`). The transport owns no HTTP/redirect/auth
133
+ * semantics -- those live in the message layer that consumes the response triple.
134
+ *
135
+ * @opts
136
+ * - `tls.anchors` -- Explicit trust anchor(s): a DER/PEM Buffer, an array, or PEM string(s) (node `ca`).
137
+ * - `tls.useSystemStore` -- boolean; the ONLY opt-in to node's bundled CA store (default false).
138
+ * - `tls.cert` / `tls.key` -- client certificate + key for mutual-TLS re-enrollment.
139
+ * - `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
140
+ * - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
141
+ * - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
142
+ * @example
143
+ * var t = pki.transport.https({ tls: { anchors: [caPem] } });
144
+ * var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
145
+ * res.status; // 200
146
+ */
147
+ function httpsTransport(defaults) {
148
+ defaults = defaults || {};
149
+ var E = typeof defaults.E === "function" ? defaults.E : defaultE;
150
+ var prefix = defaults.errPrefix || "transport";
151
+ function C(name) { return prefix + "/" + name; }
152
+ var tlsDefaults = defaults.tls || {};
153
+
154
+ // Synchronous config validation + node-options build. Throws the typed verdict on any
155
+ // fault (a non-https URL, no trust anchor, a bad budget, a sub-floor minVersion) so the
156
+ // async executor below never has to `return reject(...)`. rejectUnauthorized is forced on.
157
+ function _prepare(request) {
158
+ var url;
159
+ try { url = new URL(String(request.url)); }
160
+ catch (e) { throw E(C("bad-url"), "the request URL did not parse: " + String(request.url), e); }
161
+ if (url.protocol !== "https:") throw E(C("insecure-url"), "transport requires https (RFC 7030 sec. 3.3), got " + url.protocol);
162
+
163
+ var reqTls = request.tls || {};
164
+ var anchors = reqTls.anchors !== undefined ? reqTls.anchors : tlsDefaults.anchors;
165
+ // System-store trust requires a STRICT boolean true; a non-boolean (a "false" / "true" string from
166
+ // JSON/env, a truthy object) is treated as absent, so a malformed config fails closed rather than
167
+ // silently trusting node's bundled roots.
168
+ var useSystem = (reqTls.useSystemStore !== undefined ? reqTls.useSystemStore : tlsDefaults.useSystemStore) === true;
169
+ var hasAnchors = anchors !== undefined && anchors !== null && !(Array.isArray(anchors) && anchors.length === 0);
170
+ if (!hasAnchors && !useSystem) throw E(C("no-trust-anchors"), "no explicit trust anchor and useSystemStore not set -- refusing an unpinned server (RFC 7030 sec. 3.6)");
171
+
172
+ var timeout = _budget(request.timeout !== undefined ? request.timeout : defaults.timeout, "timeout", DEFAULT_TIMEOUT, MAX_TIMEOUT, E, C("bad-input"));
173
+ var maxBytes = _budget(request.maxResponseBytes !== undefined ? request.maxResponseBytes : defaults.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, E, C("bad-input"));
174
+
175
+ var minVersion = reqTls.minVersion || tlsDefaults.minVersion || DEFAULT_MIN_VERSION;
176
+ if (minVersion !== "TLSv1.2" && minVersion !== "TLSv1.3") throw E(C("bad-input"), "tls.minVersion must be 'TLSv1.2' or 'TLSv1.3' (never below the floor), got " + minVersion);
177
+
178
+ var body = request.body;
179
+ if (Buffer.isBuffer(body)) body = guard.bytes.view(body, E, C("bad-input"), "the request body");
180
+
181
+ // URL.hostname keeps the square brackets on an IPv6 literal ([2001:db8::1]); node's `hostname`
182
+ // option and its IP-detection expect the bare address, so strip the brackets once here.
183
+ var host = url.hostname.replace(/^\[(.*)\]$/, "$1");
184
+ var options = {
185
+ method: request.method || "GET",
186
+ hostname: host,
187
+ port: url.port || 443,
188
+ path: url.pathname + url.search,
189
+ headers: request.headers || {},
190
+ minVersion: minVersion,
191
+ rejectUnauthorized: true, // ALWAYS on -- there is no code path that disables server verification
192
+ // Opt out of connection pooling: node's keep-alive agent keys the socket pool on host + TLS
193
+ // options but NOT on checkServerIdentity, so a reused socket would skip a per-request identity /
194
+ // pinning callback. A fresh connection per request guarantees the identity check always runs.
195
+ agent: false,
196
+ };
197
+ // Every TLS field falls back to the factory defaults (as anchors / minVersion already do), so a
198
+ // reusable transport built with pki.transport.https({ tls: { cert, key, servername } }) applies
199
+ // them to a request that carries no per-request tls (mTLS enrollment, a configured SNI).
200
+ var cert = reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert;
201
+ var key = reqTls.key !== undefined ? reqTls.key : tlsDefaults.key;
202
+ var callerCsi = typeof reqTls.checkServerIdentity === "function" ? reqTls.checkServerIdentity
203
+ : (typeof tlsDefaults.checkServerIdentity === "function" ? tlsDefaults.checkServerIdentity : null);
204
+ // SNI (and RFC 6125 identity) uses a hostname; node forbids a servername that is an IP
205
+ // literal, so it is omitted for an IP host (node then matches the IP against the cert's
206
+ // IP SANs). A caller connecting by hostname gets SNI + name verification by default.
207
+ var sni = reqTls.servername || tlsDefaults.servername || host;
208
+ if (sni && !nodeNet.isIP(sni)) options.servername = sni;
209
+ // Build node's `ca`: the explicit anchors (DER wrapped as PEM) and, when useSystemStore is opted
210
+ // in, the OS system store + bundled roots. Node's `ca` REPLACES the default set, so both sources
211
+ // are merged here. Left unset only when no anchors and (on an older node) the system store is
212
+ // empty -- then node's default bundled store applies (the documented fallback).
213
+ var caList = [];
214
+ if (hasAnchors) caList = caList.concat(_pemifyAnchors(anchors));
215
+ if (useSystem) caList = caList.concat(_systemCa());
216
+ if (caList.length) options.ca = caList;
217
+ if (cert) options.cert = cert;
218
+ if (key) options.key = key;
219
+ // A caller checkServerIdentity may only TIGHTEN, never REPLACE, name verification: node uses a
220
+ // supplied callback in place of its default RFC 6125 check, so a hook that returns undefined
221
+ // (pinning/metrics only) would silently disable hostname verification. Run node's default first
222
+ // and reject on its verdict; the caller's hook adds checks only after the default has passed.
223
+ if (callerCsi) {
224
+ options.checkServerIdentity = function (host, cert2) {
225
+ var baseErr = nodeTls.checkServerIdentity(host, cert2);
226
+ if (baseErr) return baseErr;
227
+ return callerCsi(host, cert2);
228
+ };
229
+ }
230
+
231
+ return { options: options, timeout: timeout, maxBytes: maxBytes, body: body };
232
+ }
233
+
234
+ return function transport(request) {
235
+ request = request || {};
236
+ var prep;
237
+ try { prep = _prepare(request); }
238
+ catch (e) { return Promise.reject(e); }
239
+ return new Promise(function (resolve, reject) {
240
+ var settled = false;
241
+ var req;
242
+ var timer = null;
243
+ function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } }
244
+ function fail(code, msg, cause) {
245
+ if (settled) return;
246
+ settled = true;
247
+ clearTimer();
248
+ // allow:swallow-unverified req.destroy() is idempotent and does not throw in practice; a
249
+ // raw throw from this best-effort abort would mask the real fail-closed verdict below.
250
+ try { if (req) req.destroy(); } catch (_e) { /* best-effort abort */ }
251
+ reject(E(code, msg, cause));
252
+ }
253
+ // An independent wall-clock deadline bounds DNS resolution and connection setup as well as
254
+ // socket inactivity: node's request/socket timeout does not run during a DNS lookup, so a
255
+ // stalled resolver would otherwise leave this promise pending forever despite a finite timeout.
256
+ timer = setTimeout(function () { fail(C("timeout"), "the request timed out after " + prep.timeout + "ms"); }, prep.timeout);
257
+ try {
258
+ req = nodeHttps.request(prep.options, function (res) {
259
+ // Capture the TLS session facts while the socket is live -- getProtocol() /
260
+ // getPeerCertificate() return null once the socket detaches at stream end.
261
+ var proto = res.socket && res.socket.getProtocol ? res.socket.getProtocol() : null;
262
+ var peer = res.socket && res.socket.getPeerCertificate ? res.socket.getPeerCertificate() : null;
263
+ // Pre-check a declared content-length so an oversized body is refused before it streams.
264
+ var declared = parseInt((res.headers || {})["content-length"], 10);
265
+ if (Number.isFinite(declared) && declared > prep.maxBytes) { fail(C("response-too-large"), "the declared content-length " + declared + " exceeds the " + prep.maxBytes + "-byte cap (RFC 7030 sec. 6)"); return; }
266
+ // Accumulate into a single doubling buffer rather than an array of chunks: a body sent as
267
+ // many tiny chunks (one-byte TLS records) would otherwise retain millions of Buffer objects
268
+ // under the byte cap and exhaust the heap. One live buffer bounds the metadata as well as the
269
+ // bytes; only the written prefix [0, len) is ever surfaced (the unwritten tail is never read).
270
+ var buf = Buffer.allocUnsafe(0);
271
+ var len = 0;
272
+ var total = 0;
273
+ res.on("data", function (chunk) {
274
+ total += chunk.length;
275
+ if (total > prep.maxBytes) { fail(C("response-too-large"), "the response exceeded the " + prep.maxBytes + "-byte cap (RFC 7030 sec. 6)"); return; }
276
+ if (len + chunk.length > buf.length) {
277
+ var want = Math.min(prep.maxBytes, Math.max(buf.length ? buf.length * 2 : 8192, len + chunk.length));
278
+ var grown = Buffer.allocUnsafe(want);
279
+ buf.copy(grown, 0, 0, len);
280
+ buf = grown;
281
+ }
282
+ chunk.copy(buf, len);
283
+ len += chunk.length;
284
+ });
285
+ res.on("end", function () {
286
+ if (settled) return;
287
+ settled = true;
288
+ clearTimer();
289
+ var lower = {};
290
+ Object.keys(res.headers || {}).forEach(function (k) { lower[k.toLowerCase()] = res.headers[k]; });
291
+ resolve({
292
+ status: res.statusCode,
293
+ headers: lower,
294
+ body: Buffer.from(buf.subarray(0, len)),
295
+ tls: { protocol: proto, peerCertificate: peer && peer.raw ? peer.raw : null },
296
+ });
297
+ });
298
+ res.on("error", function (e) { fail(C("transport-error"), "the response stream failed", e); });
299
+ });
300
+ req.on("error", function (e) { fail(_classifyError(e, C), "the request failed: " + ((e && e.message) || String(e)), e); });
301
+ // Write a provided body for ANY method (POST / PUT / PATCH / ...), not only POST, so a body
302
+ // is never silently dropped on a body-bearing request through this generic transport seam.
303
+ if (prep.body != null && prep.body !== "") req.write(prep.body);
304
+ req.end();
305
+ } catch (e) { fail(C("transport-error"), "the request could not be initiated: " + ((e && e.message) || String(e)), e); }
306
+ });
307
+ };
308
+ }
309
+
310
+ module.exports = { https: httpsTransport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.15",
3
+ "version": "0.3.18",
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:535d6fbb-3650-4575-9c45-a256806e58cf",
5
+ "serialNumber": "urn:uuid:38b1d773-a2de-428b-848c-aece31c991f9",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-24T11:36:26.019Z",
8
+ "timestamp": "2026-07-25T19:26:20.442Z",
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.3.15",
22
+ "bom-ref": "@blamejs/pki@0.3.18",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.15",
25
+ "version": "0.3.18",
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.3.15",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.18",
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.3.15",
57
+ "ref": "@blamejs/pki@0.3.18",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]