@blamejs/pki 0.3.15 → 0.3.17
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 +17 -0
- package/README.md +2 -1
- package/index.js +11 -4
- package/lib/constants.js +8 -0
- package/lib/est.js +316 -8
- package/lib/framework-error.js +10 -0
- package/lib/http-transport.js +310 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ 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.3.17 — 2026-07-25
|
|
8
|
+
|
|
9
|
+
Refresh two development-only tooling dependencies to clear newly-disclosed advisories.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
|
|
13
|
+
- Refresh the development/fuzzing-only brace-expansion (5.0.7 -> 5.0.8, GHSA-mh99-v99m-4gvg) and tar (7.5.19 -> 7.5.22, GHSA-r292-9mhp-454m) tooling dependencies to versions clear of two newly-disclosed advisories. Both are used only by the development and fuzzing harnesses and are never part of the published package (the toolkit has zero runtime dependencies), so installed contents are unchanged.
|
|
14
|
+
|
|
15
|
+
## v0.3.16 — 2026-07-24
|
|
16
|
+
|
|
17
|
+
The EST enrollment client ships -- pki.est fetches CA certificates and enrolls certificates over the wire (RFC 7030), on a new shared node:https transport.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- pki.est.cacerts(baseUrl, opts?), pki.est.simpleenroll(baseUrl, csr, opts?), and pki.est.simplereenroll(baseUrl, csr, opts?) -- the thin RFC 7030 client verbs over pki.transport. cacerts returns the raw, unordered CA certificate set; simpleenroll submits a PKCS#10 CSR (a DER Buffer or PEM) and returns the issued certificate (chosen by public-key match) with its chain; simplereenroll additionally requires opts.oldCert and enforces the byte-identical Subject + SubjectAltName re-enroll check before the request. A 202 is surfaced as { retry, retryAfterSeconds } and never slept on. Trust is fail-closed: an https URL and an explicit opts.tls.anchors (or opts.tls.useSystemStore) are required, HTTP Basic credentials are answered only after the server is authenticated and are dropped on a cross-origin redirect, and a downgrade, redirect loop, oversized response, or unmatched issued certificate each fails closed with a typed est/* error.
|
|
22
|
+
- pki.transport -- the shared, fail-closed node:https transport the enrollment clients drive. pki.transport.https(defaults) returns a transport(request) -> Promise<{ status, headers, body }>. It is the toolkit's only socket: rejectUnauthorized is always on with no disable path, an explicit trust anchor or a system-store opt-in is required, TLS is floored at 1.2, the response body is bounded while it streams (LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward), and connect/read is bounded by a timeout. It carries no HTTP or protocol semantics -- status, content-type, redirect, and authentication decisions live in the message layer -- so it is reused verbatim across enrollment protocols. errors.TransportError is its default fault type; a protocol client may parameterize it to surface domain codes.
|
|
23
|
+
|
|
7
24
|
## v0.3.15 — 2026-07-24
|
|
8
25
|
|
|
9
26
|
PKCS#12 public-key integrity is produced and verified (RFC 7292 sec. 4).
|
package/README.md
CHANGED
|
@@ -216,7 +216,8 @@ is callable today; nothing below is a stub.
|
|
|
216
216
|
| `pki.schema.pkcs12` | Parse DER / BER / PEM RFC 7292 PKCS#12 (PFX) stores — key bags via the PKCS#8 parser, shrouded keys (algorithm surfaced, ciphertext opaque), cert / CRL / secret bags raw and byte-exact, encrypted and enveloped safes structurally via CMS, `friendlyName` / `localKeyId` decoded, and the exact MAC byte range (`macedBytes`) plus RFC 9579 PBMAC1 recognition for external verification; BER accepted exactly where §4.1 requires it, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
217
217
|
| `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
218
218
|
| `pki.schema.csrattrs` | Parse DER EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC/RSA key-type conventions, the certification-request-info template). Unknown types tolerated (surfaced raw), fail-closed on structure and the RFC 9908 semantic MUSTs — `parse` |
|
|
219
|
-
| `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the transport-
|
|
219
|
+
| `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic answered only after the server is authenticated, and the issued certificate chosen by public-key match. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
220
|
+
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive — `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body }`. The toolkit's sole socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. Reused verbatim by the EST client today (ACME / CMP next) — `https` |
|
|
220
221
|
| `pki.jose` | RFC 7515 Flattened JWS + RFC 7638 JWK thumbprints — `sign` / `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required/forbidden header rules as data; `base64url` is the strict RFC 4648 §5 codec (padding, non-alphabet, and non-canonical trailing bits rejected); `parseJson` is a bounded reader that refuses duplicate members at any depth; `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), so `alg:none`, an RS256→HS256 key confusion, and an all-zero ECDSA signature have no code path; `assertPublicJwk` refuses a JWK carrying private material so an exported private key is never published — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
221
222
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
222
223
|
| `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
package/index.js
CHANGED
|
@@ -53,6 +53,7 @@ var shbs = require("./lib/shbs");
|
|
|
53
53
|
var hpke = require("./lib/hpke");
|
|
54
54
|
var sigstore = require("./lib/sigstore");
|
|
55
55
|
var est = require("./lib/est");
|
|
56
|
+
var transport = require("./lib/http-transport");
|
|
56
57
|
var jose = require("./lib/jose");
|
|
57
58
|
var acme = require("./lib/acme");
|
|
58
59
|
var trust = require("./lib/trust");
|
|
@@ -143,11 +144,17 @@ module.exports = {
|
|
|
143
144
|
// keyless Fulcio signature over a DSSE-wrapped in-toto SLSA attestation with a
|
|
144
145
|
// Rekor inclusion proof -- offline, zero-dep, against caller-pinned trust.
|
|
145
146
|
sigstore: sigstore,
|
|
146
|
-
// `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
147
|
+
// `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the thin
|
|
148
|
+
// client verbs (pki.est.cacerts / simpleenroll / simplereenroll) over pki.transport,
|
|
149
|
+
// plus the transport-agnostic codecs they compose (base64 transfer, multipart
|
|
150
|
+
// splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
|
|
151
|
+
// builders, the HTTP response classifier). est opens no socket; fail-closed.
|
|
150
152
|
est: est,
|
|
153
|
+
// `transport` is the shared fail-closed node:https transport the enrollment clients
|
|
154
|
+
// drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
|
|
155
|
+
// headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
|
|
156
|
+
// rejectUnauthorized always on, a TLS floor, a streaming response cap, and a timeout.
|
|
157
|
+
transport: transport,
|
|
151
158
|
// `jose` is the RFC 7515 Flattened JWS + RFC 7638 JWK-thumbprint layer: a strict
|
|
152
159
|
// base64url codec, a bounded duplicate-key-rejecting JSON reader, profiled
|
|
153
160
|
// sign/verify (ACME-outer / EAB-inner / keyChange-inner), and an alg registry
|
package/lib/constants.js
CHANGED
|
@@ -153,6 +153,14 @@ var LIMITS = {
|
|
|
153
153
|
PEM_MAX_BYTES: BYTES.mib(16),
|
|
154
154
|
DER_MAX_INTEGER_BYTES: BYTES.kib(16),
|
|
155
155
|
OID_MAX_SUBIDENTIFIER_BYTES: 32,
|
|
156
|
+
// Network transport response ceiling (pki.transport / the EST client). A remote
|
|
157
|
+
// server's HTTP response body is bounded WHILE it streams -- the accumulator
|
|
158
|
+
// aborts the socket the instant the running total crosses this cap, before the
|
|
159
|
+
// body reaches a decoder, so a malicious length field cannot drive unbounded
|
|
160
|
+
// allocation (RFC 7030 sec. 6). Sized above a base64-inflated DER_MAX_BYTES
|
|
161
|
+
// document plus multipart/header overhead (16 MiB * 4/3 ~= 21.3 MiB); 24 MiB
|
|
162
|
+
// clears it. An operator may only tighten it DOWNWARD via opts.maxResponseBytes.
|
|
163
|
+
HTTP_MAX_RESPONSE_BYTES: BYTES.mib(24),
|
|
156
164
|
// Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
|
|
157
165
|
// a whole-document cap refused before the walk, a nesting cap, and a per-value
|
|
158
166
|
// bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
|
package/lib/est.js
CHANGED
|
@@ -9,10 +9,19 @@
|
|
|
9
9
|
* @slug est
|
|
10
10
|
*
|
|
11
11
|
* @intro
|
|
12
|
-
* Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and RFC 9908).
|
|
13
|
+
* The network verbs -- `cacerts`, `simpleenroll`, `simplereenroll` -- run the thin
|
|
14
|
+
* RFC 7030 client: they compose the codecs below over the shared `pki.transport`
|
|
15
|
+
* (a caller MAY inject `opts.transport`; the default is a fail-closed
|
|
16
|
+
* `pki.transport.https`). This module opens no socket itself -- the sole socket
|
|
17
|
+
* choke point is `pki.transport` -- so the verbs stay a thin, fail-closed shell:
|
|
18
|
+
* https-only (`est/insecure-url`), an explicit trust anchor required
|
|
19
|
+
* (`est/no-trust-anchors`), same-origin redirects followed but a downgrade / loop
|
|
20
|
+
* refused, a 202 Retry-After SURFACED and never slept, HTTP Basic answered only
|
|
21
|
+
* after the transport authenticated the server, and the issued certificate chosen
|
|
22
|
+
* by public-key match. Under them sit the transport-agnostic codecs, validators, and
|
|
23
|
+
* request builders over the shipped CMS / CSR / PKCS#8 / X.509 parsers:
|
|
24
|
+
* `transferDecode` / `transferEncode` are the RFC 8951
|
|
16
25
|
* sec. 3 base64 transfer codec (RFC 4648, and DELIBERATELY blind to any
|
|
17
26
|
* Content-Transfer-Encoding header -- errata 5904/5107); `splitMultipartMixed`
|
|
18
27
|
* is the /serverkeygen `multipart/mixed` splitter; `parseCertsOnly` validates a
|
|
@@ -36,9 +45,10 @@
|
|
|
36
45
|
* module). DER-only where DER, fail-closed everywhere.
|
|
37
46
|
*
|
|
38
47
|
* @card
|
|
39
|
-
* EST (RFC 7030 / 8951 / 9908) client
|
|
40
|
-
*
|
|
41
|
-
*
|
|
48
|
+
* EST (RFC 7030 / 8951 / 9908) client -- the cacerts / simpleenroll / simplereenroll
|
|
49
|
+
* verbs over the shared pki.transport, plus the codecs they compose: base64 transfer,
|
|
50
|
+
* multipart splitter, certs-only + serverkeygen validators over CMS, the
|
|
51
|
+
* enroll-attribute builders, and the HTTP response classifier. Fail-closed.
|
|
42
52
|
*/
|
|
43
53
|
|
|
44
54
|
var asn1 = require("./asn1-der");
|
|
@@ -51,6 +61,7 @@ var pkcs8 = require("./schema-pkcs8");
|
|
|
51
61
|
var csr = require("./schema-csr");
|
|
52
62
|
var frameworkError = require("./framework-error");
|
|
53
63
|
var guard = require("./guard-all");
|
|
64
|
+
var httpTransport = require("./http-transport");
|
|
54
65
|
|
|
55
66
|
var EstError = frameworkError.EstError;
|
|
56
67
|
function E(code, message, cause) { return new EstError(code, message, cause); }
|
|
@@ -498,7 +509,7 @@ function classifyResponse(status, headers, body, opts) {
|
|
|
498
509
|
}
|
|
499
510
|
if (status === 202) {
|
|
500
511
|
var ra = h["retry-after"];
|
|
501
|
-
if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC
|
|
512
|
+
if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC 7030 sec. 4.2.3)");
|
|
502
513
|
var raStr = String(ra).trim();
|
|
503
514
|
// Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3). A
|
|
504
515
|
// delay-seconds becomes retryAfterSeconds directly; an HTTP-date is surfaced
|
|
@@ -708,7 +719,304 @@ function reenrollGuard(oldCertDer, newCsrDer) {
|
|
|
708
719
|
return { subjectDn: oldSubjectDn, subjectAltName: oldSan };
|
|
709
720
|
}
|
|
710
721
|
|
|
722
|
+
// ---- the thin RFC 7030 client: network verbs over the shared pki.transport ----
|
|
723
|
+
|
|
724
|
+
var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
|
|
725
|
+
var MAX_TIMEOUT = constants.TIME.seconds(600);
|
|
726
|
+
|
|
727
|
+
// The DER of a caller-supplied CSR: a DER Buffer as-is, or a PEM "CERTIFICATE REQUEST"
|
|
728
|
+
// decoded. Any other input is a config-time est/bad-input.
|
|
729
|
+
function _csrDer(input) {
|
|
730
|
+
if (Buffer.isBuffer(input)) return input;
|
|
731
|
+
if (typeof input === "string") return csr.pemDecode(input);
|
|
732
|
+
throw E("est/bad-input", "a CSR must be a DER Buffer or a PEM CERTIFICATE REQUEST string");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Parse + validate the request URL BEFORE any transport is called: a URL that will not
|
|
736
|
+
// parse is est/bad-url, a non-https URL is est/insecure-url (RFC 7030 sec. 3.3). Runs
|
|
737
|
+
// even when a transport is injected, so an insecure URL never reaches the wire.
|
|
738
|
+
function _parseUrl(urlStr) {
|
|
739
|
+
var url;
|
|
740
|
+
try { url = new URL(String(urlStr)); }
|
|
741
|
+
catch (e) { throw E("est/bad-url", "the EST server URL did not parse: " + String(urlStr), e); }
|
|
742
|
+
if (url.protocol !== "https:") throw E("est/insecure-url", "EST requires https (RFC 7030 sec. 3.3), got " + url.protocol + " for " + urlStr);
|
|
743
|
+
return url;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Map opts.tls (operator-facing) to the transport request.tls shape (anchors -> ca).
|
|
747
|
+
// rejectUnauthorized is NOT here -- the transport forces it on unconditionally.
|
|
748
|
+
function _tlsForRequest(opts) {
|
|
749
|
+
var t = opts.tls || {};
|
|
750
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Resolve a redirect target: reject a scheme downgrade (est/insecure-redirect) or a
|
|
754
|
+
// cross-origin redirect on a non-GET/HEAD method without opt-in
|
|
755
|
+
// (est/cross-origin-redirect); a Location that will not parse is est/bad-url. Same-origin
|
|
756
|
+
// (and cross-origin GET/HEAD) are followed -- the transport re-runs every TLS check on the
|
|
757
|
+
// new connection (RFC 7030 sec. 3.2.1).
|
|
758
|
+
function _redirectTarget(current, location, method, opts) {
|
|
759
|
+
if (location == null || String(location).trim() === "") throw E("est/http-error", "a redirect response carried no Location header");
|
|
760
|
+
var resolved;
|
|
761
|
+
try { resolved = new URL(String(location), current.href); }
|
|
762
|
+
catch (e) { throw E("est/bad-url", "a redirect Location did not parse: " + location, e); }
|
|
763
|
+
if (resolved.protocol !== "https:") throw E("est/insecure-redirect", "a redirect to a non-https URL is refused (RFC 7030 sec. 3.2.1): " + resolved.protocol);
|
|
764
|
+
var safeMethod = method === "GET" || method === "HEAD";
|
|
765
|
+
if (resolved.origin !== current.origin && !safeMethod && !opts.allowCrossOriginRedirect) {
|
|
766
|
+
throw E("est/cross-origin-redirect", "a cross-origin redirect on a " + method + " needs opts.allowCrossOriginRedirect (RFC 7030 sec. 3.2.1)");
|
|
767
|
+
}
|
|
768
|
+
return resolved;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Does WWW-Authenticate advertise a Basic challenge? The quoted-string contents are blanked first
|
|
772
|
+
// (a quoted auth-param may contain a comma + "Basic", e.g. `Digest realm="x, Basic required"`, which
|
|
773
|
+
// must NOT be read as a Basic challenge), then Basic is matched as an auth-scheme TOKEN (at the start
|
|
774
|
+
// or after a comma-separated challenge, followed by whitespace / a comma / end) -- never a substring,
|
|
775
|
+
// so `Digest realm="basic"` / `Bearer error="basic required"` are not taken as Basic either.
|
|
776
|
+
function _hasBasicChallenge(www) {
|
|
777
|
+
var s = String(www || "").replace(/"(?:[^"\\]|\\.)*"/g, "\"\"");
|
|
778
|
+
return /(?:^|,)\s*Basic(?=[\s,]|$)/i.test(s);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// The redirect-follow + HTTP-auth-retry loop. Returns the terminal transport response
|
|
782
|
+
// ({status, headers, body}) -- a 2xx, a 202, or a non-401 4xx/5xx. A 3xx is followed
|
|
783
|
+
// (bounded by maxRedirects); a 401 is answered ONCE with Basic credentials, and ONLY when
|
|
784
|
+
// the caller supplied them and the challenge is Basic (RFC 7030 sec. 3.2.3) -- the server
|
|
785
|
+
// was authenticated by the transport's rejectUnauthorized TLS before any credential is
|
|
786
|
+
// transmitted (no credential ever precedes server authorization).
|
|
787
|
+
function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
788
|
+
var redirects = 0;
|
|
789
|
+
var authTried = false;
|
|
790
|
+
var initialOrigin = url.origin; // the origin the caller intended to authenticate to
|
|
791
|
+
var tls = budgets.tls; // narrowed (mTLS client identity stripped) on a cross-origin hop
|
|
792
|
+
function step() {
|
|
793
|
+
return transport({ method: method, url: url.href, headers: headers, body: body, tls: tls, timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
|
|
794
|
+
res = res || {};
|
|
795
|
+
var blen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "latin1");
|
|
796
|
+
if (blen > budgets.maxResponseBytes) throw E("est/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap (RFC 7030 sec. 6)");
|
|
797
|
+
// The transport contract returns lowercased headers, but the injectable seam only promises
|
|
798
|
+
// { status, headers, body }; normalize here so a redirect Location / WWW-Authenticate from an
|
|
799
|
+
// injected transport using ordinary HTTP casing is read correctly (never missed as absent).
|
|
800
|
+
var h = {};
|
|
801
|
+
Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
|
|
802
|
+
var status = res.status;
|
|
803
|
+
if (status >= 300 && status < 400) {
|
|
804
|
+
if (redirects >= budgets.maxRedirects) throw E("est/too-many-redirects", "the redirect chain exceeded maxRedirects=" + budgets.maxRedirects + " (RFC 7030 sec. 3.2.1)");
|
|
805
|
+
// 303 See Other directs a non-GET/HEAD request to retrieve the target with GET and no body
|
|
806
|
+
// (RFC 7231 sec. 6.4.4); drop the request body + its entity content-type before following, so
|
|
807
|
+
// the CSR is not re-POSTed to the redirect target as a duplicate enrollment.
|
|
808
|
+
if (status === 303 && method !== "GET" && method !== "HEAD") {
|
|
809
|
+
method = "GET";
|
|
810
|
+
body = null;
|
|
811
|
+
if (headers["content-type"]) { headers = Object.assign({}, headers); delete headers["content-type"]; }
|
|
812
|
+
}
|
|
813
|
+
var prevOrigin = url.origin;
|
|
814
|
+
url = _redirectTarget(url, h.location, method, opts);
|
|
815
|
+
// Credentials MUST NOT cross an origin boundary. A redirect to a different web origin (even a
|
|
816
|
+
// permitted cross-origin GET) drops BOTH the HTTP Basic Authorization header AND the mTLS
|
|
817
|
+
// client identity (cert/key), so a prior 401's password and the caller's certificate + proof
|
|
818
|
+
// of private-key possession are never presented to a different server.
|
|
819
|
+
if (url.origin !== prevOrigin) {
|
|
820
|
+
if (headers.authorization) { headers = Object.assign({}, headers); delete headers.authorization; }
|
|
821
|
+
if (tls && (tls.cert || tls.key)) { tls = Object.assign({}, tls); delete tls.cert; delete tls.key; }
|
|
822
|
+
}
|
|
823
|
+
redirects += 1;
|
|
824
|
+
return step();
|
|
825
|
+
}
|
|
826
|
+
if (status === 401) {
|
|
827
|
+
if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
|
|
828
|
+
// Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a
|
|
829
|
+
// cross-origin redirect is a different server, and the client MUST NOT send its credentials
|
|
830
|
+
// there (RFC 7030 sec. 3.6 -- credentials go only to an authorized server the caller chose).
|
|
831
|
+
if (url.origin !== initialOrigin) throw E("est/auth-required", "refusing to send HTTP credentials to a redirected origin (RFC 7030 sec. 3.6)");
|
|
832
|
+
var www = String(h["www-authenticate"] || "");
|
|
833
|
+
if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
|
|
834
|
+
if (opts.username === undefined && opts.password === undefined) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
|
|
835
|
+
headers = Object.assign({}, headers, { authorization: "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64") });
|
|
836
|
+
authTried = true;
|
|
837
|
+
return step();
|
|
838
|
+
}
|
|
839
|
+
return res;
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
return step();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// The shared client entry: build the op URL, validate it + the trust config + the budgets
|
|
846
|
+
// (ALL before any transport call so a config fault proves the transport was never reached),
|
|
847
|
+
// then drive the request through the redirect/auth loop.
|
|
848
|
+
function _client(op, method, baseUrl, body, headers, opts) {
|
|
849
|
+
// paths() concatenates onto the base string, so a query / fragment on the base URL would capture
|
|
850
|
+
// the operation path (sending the request -- possibly an enrollment CSR -- to the wrong resource).
|
|
851
|
+
var base;
|
|
852
|
+
try { base = new URL(String(baseUrl)); }
|
|
853
|
+
catch (e) { throw E("est/bad-url", "the EST base URL did not parse: " + String(baseUrl), e); }
|
|
854
|
+
if (base.search || base.hash) throw E("est/bad-url", "the EST base URL must not carry a query or fragment component (RFC 7030 sec. 3.2.2)");
|
|
855
|
+
var url = _parseUrl(paths(baseUrl, { label: opts.label })[op]);
|
|
856
|
+
var transport = opts.transport;
|
|
857
|
+
if (!transport) {
|
|
858
|
+
var t = opts.tls || {};
|
|
859
|
+
var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
|
|
860
|
+
if (!hasAnchors && t.useSystemStore !== true) throw E("est/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned server (RFC 7030 sec. 3.6)");
|
|
861
|
+
transport = httpTransport.https({ E: E, errPrefix: "est" });
|
|
862
|
+
}
|
|
863
|
+
var budgets = {
|
|
864
|
+
tls: _tlsForRequest(opts),
|
|
865
|
+
timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: E, code: "est/bad-input", min: 1, max: MAX_TIMEOUT }),
|
|
866
|
+
maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: E, code: "est/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
|
|
867
|
+
maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "est/bad-input", min: 0, max: 32 }),
|
|
868
|
+
};
|
|
869
|
+
return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Turn a terminal response into the verb result: classifyResponse gates status +
|
|
873
|
+
// content-type (202 -> a surfaced retry, never slept; 4xx/5xx -> est/http-error), then an
|
|
874
|
+
// empty 200 is rejected and the certs-only body is decoded + validated. For an enroll, the
|
|
875
|
+
// issued certificate is the public-key match (findIssuedCert), never a positional guess;
|
|
876
|
+
// the remaining certificates are the chain.
|
|
877
|
+
function _certsResult(op, res, opts, csrSpki) {
|
|
878
|
+
var verdict = classifyResponse(res.status, res.headers, res.body, { op: op, now: opts.now });
|
|
879
|
+
if (verdict.status === "retry") {
|
|
880
|
+
// 202 Retry-After is an ENROLLMENT response (RFC 7030 sec. 4.2.3); a successful /cacerts is 200
|
|
881
|
+
// only (sec. 4.1.3), so a 202 there is a nonconforming server -> fail closed rather than "retry".
|
|
882
|
+
if (op === "cacerts") throw E("est/http-error", "a /cacerts response must be HTTP 200, not 202 (RFC 7030 sec. 4.1.3)");
|
|
883
|
+
return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
|
|
884
|
+
}
|
|
885
|
+
// Only the RFC-defined success statuses are accepted: a non-standard 2xx (201, 206, ...) that the
|
|
886
|
+
// classifier reports as "unexpected" must NOT have its body decoded and accepted as certificates.
|
|
887
|
+
if (verdict.status !== "ok") throw E("est/http-error", "an EST " + op + " response must be HTTP 200 or 202 (RFC 7030 sec. 4.1.3 / 4.2.3), got " + res.status);
|
|
888
|
+
var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "latin1");
|
|
889
|
+
if (bodyLen === 0) throw E("est/empty-body", "a 200 " + op + " response carried an empty body (RFC 7030 sec. 4.1.3 / 4.2.3)");
|
|
890
|
+
var parsed = parseCertsOnly(transferDecode(res.body));
|
|
891
|
+
if (op === "cacerts") return { certificates: parsed.certificates, crls: parsed.crls };
|
|
892
|
+
var issued = findIssuedCert(parsed.certificates, csrSpki);
|
|
893
|
+
if (!issued) throw E("est/issued-cert-not-found", "no returned certificate matched the submitted CSR public key (RFC 5272 sec. 4.1)");
|
|
894
|
+
// The chain is every OTHER response certificate (by reference, so a byte-identical duplicate is
|
|
895
|
+
// NOT silently collapsed into the issued cert). Exactly one certificate may match the submitted
|
|
896
|
+
// public key -- a second match (a renewed cert sharing the key, or a duplicate entry) makes the
|
|
897
|
+
// issued certificate ambiguous, since the certs-only set has no issuance ordering (RFC 5272 sec. 4.1).
|
|
898
|
+
var chain = parsed.certificates.filter(function (c) { return c !== issued; });
|
|
899
|
+
if (findIssuedCert(chain, csrSpki)) throw E("est/ambiguous-issued-cert", "the enroll response carried more than one certificate matching the submitted CSR public key; the issued certificate is ambiguous (RFC 5272 sec. 4.1)");
|
|
900
|
+
if (opts.strict && chain.length > 0) throw E("est/unexpected-certs", "strict: the enroll response carried " + parsed.certificates.length + " certificates, expected exactly the issued one");
|
|
901
|
+
return { certificate: issued, chain: chain, certificates: parsed.certificates };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function _enroll(op, baseUrl, csrInput, opts) {
|
|
905
|
+
var csrDer = _csrDer(csrInput);
|
|
906
|
+
var spki = csr.parse(csrDer).subjectPublicKeyInfo;
|
|
907
|
+
var body = transferEncode(csrDer);
|
|
908
|
+
return _client(op, "POST", baseUrl, body, { accept: "application/pkcs7-mime", "content-type": "application/pkcs10" }, opts)
|
|
909
|
+
.then(function (res) { return _certsResult(op, res, opts, spki); });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* @primitive pki.est.cacerts
|
|
914
|
+
* @signature pki.est.cacerts(baseUrl, opts?) -> Promise<{ certificates, crls } | { retry, retryAfterSeconds }>
|
|
915
|
+
* @since 0.3.16
|
|
916
|
+
* @status experimental
|
|
917
|
+
* @spec RFC 7030, RFC 8951
|
|
918
|
+
* @related pki.est.simpleenroll, pki.transport.https
|
|
919
|
+
*
|
|
920
|
+
* Fetch a CA's certificates over the wire: GET `<baseUrl>/.well-known/est/cacerts` through
|
|
921
|
+
* the shared `pki.transport` (inject `opts.transport`, else a fail-closed
|
|
922
|
+
* `pki.transport.https`). Returns the raw, unordered certs-only set
|
|
923
|
+
* (`{ certificates, crls }`), or `{ retry: true, retryAfterSeconds }` on a 202 (surfaced,
|
|
924
|
+
* never slept). https-only (`est/insecure-url`); an explicit `opts.tls.anchors` (or an
|
|
925
|
+
* `opts.tls.useSystemStore` opt-in) is required (`est/no-trust-anchors`); the returned CA
|
|
926
|
+
* certificate is NOT auto-trusted -- the caller path-validates it and supplies the accepted
|
|
927
|
+
* anchor on the next call.
|
|
928
|
+
*
|
|
929
|
+
* @opts
|
|
930
|
+
* - `transport` -- an injected transport(request) -> {status, headers, body}; default pki.transport.https.
|
|
931
|
+
* - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }.
|
|
932
|
+
* - `label` -- an OPTIONAL CA label path segment; `timeout` / `maxResponseBytes` / `maxRedirects` -- budgets.
|
|
933
|
+
* - `now` -- receipt time (epoch ms) to render a 202 Retry-After HTTP-date as seconds.
|
|
934
|
+
* @example
|
|
935
|
+
* // a live CA uses the default pki.transport.https; here an injected transport returns a canned bag
|
|
936
|
+
* var r = await pki.est.cacerts("https://ca.example",
|
|
937
|
+
* { transport: function () { return Promise.resolve({ status: 200, headers: { "content-type": "application/pkcs7-mime" }, body: caCertsDer.toString("base64") }); } });
|
|
938
|
+
* r.certificates; // -> [Buffer, ...] raw, unordered
|
|
939
|
+
*/
|
|
940
|
+
function cacerts(baseUrl, opts) {
|
|
941
|
+
opts = opts || {};
|
|
942
|
+
return Promise.resolve().then(function () {
|
|
943
|
+
return _client("cacerts", "GET", baseUrl, null, { accept: "application/pkcs7-mime" }, opts);
|
|
944
|
+
}).then(function (res) { return _certsResult("cacerts", res, opts, null); });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* @primitive pki.est.simpleenroll
|
|
949
|
+
* @signature pki.est.simpleenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
|
|
950
|
+
* @since 0.3.16
|
|
951
|
+
* @status experimental
|
|
952
|
+
* @spec RFC 7030, RFC 5272
|
|
953
|
+
* @related pki.csr.sign, pki.est.simplereenroll, pki.est.cacerts
|
|
954
|
+
*
|
|
955
|
+
* Enroll for a certificate: POST a PKCS#10 `csr` (a DER Buffer or a PEM CERTIFICATE REQUEST,
|
|
956
|
+
* e.g. from `pki.csr.sign`) to `<baseUrl>/.well-known/est/simpleenroll` as
|
|
957
|
+
* `application/pkcs10`, over the shared `pki.transport`. Returns the issued certificate
|
|
958
|
+
* chosen by public-key match against the submitted CSR (`certificate`), the remaining
|
|
959
|
+
* certificates (`chain`), and the raw set (`certificates`); or `{ retry: true,
|
|
960
|
+
* retryAfterSeconds }` on a 202. No returned certificate matching the CSR key fails closed
|
|
961
|
+
* (`est/issued-cert-not-found`); `opts.strict` requires exactly the issued certificate. A
|
|
962
|
+
* 401 is answered once with HTTP Basic ONLY when `opts.username`/`password` are supplied and
|
|
963
|
+
* the transport already authenticated the server.
|
|
964
|
+
*
|
|
965
|
+
* @opts
|
|
966
|
+
* - `transport` / `tls` / `label` / `timeout` / `maxResponseBytes` / `maxRedirects` / `now` -- as pki.est.cacerts.
|
|
967
|
+
* - `strict` -- reject an enroll response that carries more than the single issued certificate.
|
|
968
|
+
* - `username` / `password` -- HTTP Basic credentials, answered only after server authorization (empty username allowed).
|
|
969
|
+
* - `allowCrossOriginRedirect` -- opt in to following a cross-origin redirect on this POST.
|
|
970
|
+
* @example
|
|
971
|
+
* var req = await pki.csr.sign({ subject: "device.example", subjectPublicKey: signerSpki }, { key: signerKeyPkcs8 });
|
|
972
|
+
* // a 202 means the CA queued the request -- the verb surfaces the delay, never sleeps
|
|
973
|
+
* var r = await pki.est.simpleenroll("https://ca.example", req,
|
|
974
|
+
* { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
|
|
975
|
+
* r.retry && r.retryAfterSeconds; // 60
|
|
976
|
+
*/
|
|
977
|
+
function simpleenroll(baseUrl, csrInput, opts) {
|
|
978
|
+
opts = opts || {};
|
|
979
|
+
return Promise.resolve().then(function () { return _enroll("simpleenroll", baseUrl, csrInput, opts); });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* @primitive pki.est.simplereenroll
|
|
984
|
+
* @signature pki.est.simplereenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
|
|
985
|
+
* @since 0.3.16
|
|
986
|
+
* @status experimental
|
|
987
|
+
* @spec RFC 7030
|
|
988
|
+
* @related pki.est.simpleenroll, pki.est.reenrollGuard
|
|
989
|
+
*
|
|
990
|
+
* Renew / rekey a certificate: identical to `pki.est.simpleenroll` but POSTs to
|
|
991
|
+
* `/.well-known/est/simplereenroll` and REQUIRES `opts.oldCert` (the certificate being
|
|
992
|
+
* renewed). Before anything crosses the wire, `reenrollGuard` enforces that the CSR's
|
|
993
|
+
* Subject and SubjectAltName (names and criticality) are byte-identical to `opts.oldCert`
|
|
994
|
+
* (RFC 7030 sec. 4.2.2) -- a mismatch fails closed (`est/reenroll-subject-mismatch` /
|
|
995
|
+
* `est/reenroll-san-mismatch`) and the transport is never called. A missing `opts.oldCert`
|
|
996
|
+
* is `est/bad-input`.
|
|
997
|
+
*
|
|
998
|
+
* @opts
|
|
999
|
+
* - `oldCert` -- REQUIRED, the DER certificate being renewed (the re-enroll identity check).
|
|
1000
|
+
* - every option of pki.est.simpleenroll (transport, tls, label, budgets, strict, credentials).
|
|
1001
|
+
* @example
|
|
1002
|
+
* // reenrollGuard enforces the RFC 7030 sec. 4.2.2 identity check before anything is sent
|
|
1003
|
+
* var r = await pki.est.simplereenroll("https://ca.example", renewCsr,
|
|
1004
|
+
* { oldCert: signerCertDer, transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
|
|
1005
|
+
* r.retry; // true
|
|
1006
|
+
*/
|
|
1007
|
+
function simplereenroll(baseUrl, csrInput, opts) {
|
|
1008
|
+
opts = opts || {};
|
|
1009
|
+
return Promise.resolve().then(function () {
|
|
1010
|
+
if (!opts.oldCert) throw E("est/bad-input", "simplereenroll requires opts.oldCert (the certificate being renewed, RFC 7030 sec. 4.2.2)");
|
|
1011
|
+
reenrollGuard(opts.oldCert, _csrDer(csrInput)); // est/reenroll-* on mismatch, BEFORE the POST
|
|
1012
|
+
return _enroll("simplereenroll", baseUrl, csrInput, opts);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
711
1016
|
module.exports = {
|
|
1017
|
+
cacerts: cacerts,
|
|
1018
|
+
simpleenroll: simpleenroll,
|
|
1019
|
+
simplereenroll: simplereenroll,
|
|
712
1020
|
transferDecode: transferDecode,
|
|
713
1021
|
transferEncode: transferEncode,
|
|
714
1022
|
splitMultipartMixed: splitMultipartMixed,
|
package/lib/framework-error.js
CHANGED
|
@@ -264,6 +264,15 @@ var CsrattrsError = defineClass("CsrattrsError", { withCause: true });
|
|
|
264
264
|
// `.cause`.
|
|
265
265
|
var EstError = defineClass("EstError", { withCause: true });
|
|
266
266
|
|
|
267
|
+
// TransportError -- a fault from the shared node:https transport (pki.transport):
|
|
268
|
+
// a non-https / unparseable request URL, a missing trust anchor, a TLS handshake /
|
|
269
|
+
// server-authentication failure, a negotiated protocol below the floor, a response
|
|
270
|
+
// over the size cap, or a connect / read timeout. A protocol client (est / acme /
|
|
271
|
+
// cmp) may parameterize the transport with its OWN error factory + code prefix so
|
|
272
|
+
// the same choke point surfaces domain codes; this is the default identity when the
|
|
273
|
+
// transport is used directly. Carries the underlying socket / TLS fault as `.cause`.
|
|
274
|
+
var TransportError = defineClass("TransportError", { withCause: true });
|
|
275
|
+
|
|
267
276
|
// JoseError -- a JOSE (RFC 7515 JWS / RFC 7518 JWA / RFC 7638 thumbprint)
|
|
268
277
|
// fault: a non-canonical base64url field, a duplicate JSON member, a
|
|
269
278
|
// bounds violation, a header that fails its profile (alg/nonce/url/jwk-kid),
|
|
@@ -361,6 +370,7 @@ module.exports = {
|
|
|
361
370
|
SmimeError: SmimeError,
|
|
362
371
|
CsrattrsError: CsrattrsError,
|
|
363
372
|
EstError: EstError,
|
|
373
|
+
TransportError: TransportError,
|
|
364
374
|
JoseError: JoseError,
|
|
365
375
|
AcmeError: AcmeError,
|
|
366
376
|
TrustError: TrustError,
|
|
@@ -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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:d422e0bf-7707-4f70-8e04-0d467ade9599",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-25T04:02:51.828Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.17",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.17",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.3.17",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.17",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|