@blamejs/pki 0.3.17 → 0.3.19
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 +28 -1
- package/README.md +8 -7
- package/lib/acme.js +511 -1
- package/lib/cmp-build.js +242 -1
- package/lib/est.js +13 -69
- package/lib/http-retry-after.js +101 -0
- package/lib/http-transport.js +14 -1
- package/lib/ip-utils.js +11 -1
- package/lib/pkcs12-build.js +10 -3
- package/lib/pki-build.js +35 -9
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/cmp-build.js
CHANGED
|
@@ -41,6 +41,7 @@ var webcrypto = require("./webcrypto");
|
|
|
41
41
|
var pbes2 = require("./pbes2");
|
|
42
42
|
var constants = require("./constants");
|
|
43
43
|
var guard = require("./guard-all");
|
|
44
|
+
var httpTransport = require("./http-transport");
|
|
44
45
|
var frameworkError = require("./framework-error");
|
|
45
46
|
|
|
46
47
|
var CmpError = frameworkError.CmpError;
|
|
@@ -632,4 +633,244 @@ function _collectExtraCerts(opts, protCertDer) {
|
|
|
632
633
|
* { key: signerKeyPkcs8, cert: signerCertDer });
|
|
633
634
|
* pki.schema.cmp.parse(der).body.arm; // "p10cr"
|
|
634
635
|
*/
|
|
635
|
-
|
|
636
|
+
|
|
637
|
+
// ---- the thin RFC 9811 HTTP transfer verb: POST a DER PKIMessage over the shared pki.transport ----
|
|
638
|
+
//
|
|
639
|
+
// RFC 9811 defines exactly ONE client operation -- POST a DER PKIMessage, receive a DER PKIMessage -- so
|
|
640
|
+
// every CMP exchange (ir / p10cr / certConf / pollReq / rr / genm ...) rides this single stateless verb;
|
|
641
|
+
// the caller builds+protects the message upstream with pki.cmp.build and hands the finished bytes here,
|
|
642
|
+
// which are POSTed VERBATIM (the protection covers them). No socket is opened by this codec -- the verb
|
|
643
|
+
// composes pki.schema.cmp.parse over pki.transport, exactly as pki.est / pki.acme wire their message layers.
|
|
644
|
+
|
|
645
|
+
var DEFAULT_TRANSFER_TIMEOUT = constants.TIME.seconds(30);
|
|
646
|
+
var MAX_TRANSFER_TIMEOUT = constants.TIME.seconds(600);
|
|
647
|
+
var PKIXCMP = "application/pkixcmp";
|
|
648
|
+
var PKIXCMP_POLL = "application/pkixcmp-poll"; // the legacy type handled "like application/pkixcmp" (RFC 9811 sec. 4)
|
|
649
|
+
var KNOWN_TRANSFER_OPTS = { transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1 };
|
|
650
|
+
|
|
651
|
+
// The DER of a caller-supplied PKIMessage: a DER Buffer / Uint8Array re-viewed through guard.bytes.view
|
|
652
|
+
// (sent VERBATIM -- protection covers these exact bytes), a PEM "CMP" string decoded, else a config-time
|
|
653
|
+
// cmp/bad-input before the wire. The re-view fails a DETACHED backing buffer (a transferred / structuredClone'd
|
|
654
|
+
// view reads as zero-length) as cmp/bad-input rather than silently POSTing an empty body in its place.
|
|
655
|
+
function _cmpMessageDer(input) {
|
|
656
|
+
var der;
|
|
657
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) der = guard.bytes.view(input, CmpError, "cmp/bad-input", "the CMP message DER");
|
|
658
|
+
else if (typeof input === "string") {
|
|
659
|
+
// Normalize a PEM decode failure (no block, wrong armor label, malformed base64) to the stable
|
|
660
|
+
// cmp/bad-input at this input boundary, rather than leaking the PEM layer's PemError to the caller.
|
|
661
|
+
try { der = cmp.pemDecode(input); }
|
|
662
|
+
catch (e) { throw _err("cmp/bad-input", "the CMP message is not a valid PEM CMP block", e); }
|
|
663
|
+
} else throw _err("cmp/bad-input", "a CMP message must be a DER Buffer/Uint8Array or a PEM CMP string");
|
|
664
|
+
// Validate the bytes ARE a PKIMessage before the wire (transfer accepts a DER PKIMessage), so malformed
|
|
665
|
+
// input fails locally as cmp/bad-input rather than crossing the network seam. parse validates only -- the
|
|
666
|
+
// ORIGINAL der is returned for the verbatim POST (protection covers the exact bytes; nothing re-serialized).
|
|
667
|
+
try { cmp.parse(der); }
|
|
668
|
+
catch (e) { throw _err("cmp/bad-input", "the CMP message is not a valid PKIMessage", e); }
|
|
669
|
+
return der;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Map opts.tls (operator-facing) to the transport request.tls shape. rejectUnauthorized is NOT here --
|
|
673
|
+
// the transport forces it on unconditionally; the verb never disables server verification.
|
|
674
|
+
function _tlsForTransfer(opts) {
|
|
675
|
+
var t = opts.tls || {};
|
|
676
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// The response content-type token (before any ;-parameters), lowercased; is it a CMP media type?
|
|
680
|
+
function _ctypeToken(headers) { return String((headers || {})["content-type"] || "").split(";")[0].trim().toLowerCase(); }
|
|
681
|
+
function _isPkixcmp(headers) { var t = _ctypeToken(headers); return t === PKIXCMP || t === PKIXCMP_POLL; }
|
|
682
|
+
|
|
683
|
+
// Classify the CMP HTTP response (RFC 9811 sec. 3.1/3.2/4). 200 + a pkixcmp body -> parse + resolve;
|
|
684
|
+
// another 2xx -> reject (a request response MUST be 200, sec. 3.1); 3xx -> not followed (sec. 3.1/5);
|
|
685
|
+
// 4xx/5xx WITH a well-formed CMP body -> FORWARD the integrity-protected verdict (sec. 1.2/3.1), else
|
|
686
|
+
// cmp/http-error. The HTTP status is surfaced as DATA, never used to set the transaction verdict (sec. 5).
|
|
687
|
+
// `body` is always a Buffer (the verb normalizes it before the size cap).
|
|
688
|
+
function _classifyCmpResponse(status, headers, body, tls) {
|
|
689
|
+
var ctype = _ctypeToken(headers);
|
|
690
|
+
if (status === 200) {
|
|
691
|
+
if (!_isPkixcmp(headers)) throw _err("cmp/bad-content-type", "a 200 CMP response must be application/pkixcmp (RFC 9811 sec. 3.2), got " + JSON.stringify(ctype || null));
|
|
692
|
+
if (body.length === 0) throw _err("cmp/empty-response", "a 200 CMP response carried an empty body (RFC 9811 sec. 3.3)");
|
|
693
|
+
return { response: cmp.parse(body), responseBytes: body, status: 200, contentType: ctype, tls: tls }; // a malformed body surfaces the parser's fail-closed cmp/* verdict
|
|
694
|
+
}
|
|
695
|
+
// Below 200, every branch requires a valid HTTP status INTEGER -- a missing / non-numeric / out-of-range
|
|
696
|
+
// status from an injected transport must not fall through to the forward branch as if it were a 4xx/5xx.
|
|
697
|
+
var httpStatus = (typeof status === "number" && Number.isSafeInteger(status) && status >= 100 && status <= 599) ? status : null;
|
|
698
|
+
if (httpStatus !== null && httpStatus >= 300 && httpStatus < 400) throw _err("cmp/redirect-not-followed", "a CMP " + httpStatus + " redirect is not followed (RFC 9811 sec. 3.1, sec. 5); reconfigure the endpoint URL");
|
|
699
|
+
if (httpStatus !== null && httpStatus >= 400 && httpStatus <= 599) {
|
|
700
|
+
if (_isPkixcmp(headers) && body.length > 0) {
|
|
701
|
+
var forwarded;
|
|
702
|
+
try { forwarded = cmp.parse(body); }
|
|
703
|
+
catch (e) { throw _err("cmp/http-error", "the CMP server returned HTTP " + httpStatus + " with an undecodable body", e); }
|
|
704
|
+
// Only a CMP `error` message is a coherent verdict to forward on an HTTP failure. A 4xx/5xx carrying a
|
|
705
|
+
// non-error arm (a granting `ip`, a `pkiconf`) is contradictory -- the HTTP layer says failure while the
|
|
706
|
+
// CMP body says success -- so it is NOT accepted as a usable response; the HTTP failure stands.
|
|
707
|
+
if (forwarded.body.arm !== "error") throw _err("cmp/http-error", "the CMP server returned HTTP " + httpStatus + " with a non-error '" + forwarded.body.arm + "' body; an HTTP failure forwards only a CMP error message (RFC 9811 sec. 1.2/3.1)");
|
|
708
|
+
return { response: forwarded, responseBytes: body, status: httpStatus, contentType: ctype, tls: tls };
|
|
709
|
+
}
|
|
710
|
+
throw _err("cmp/http-error", "the CMP server returned HTTP " + httpStatus + " with no forwardable CMP error body");
|
|
711
|
+
}
|
|
712
|
+
// Any other status -- a non-200 2xx, a 1xx informational, or a non-numeric / out-of-range value.
|
|
713
|
+
throw _err("cmp/unexpected-status", "a CMP response must be HTTP 200 (or a 4xx/5xx carrying a CMP error), not " + JSON.stringify(status) + " (RFC 9811 sec. 3.1)");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function transfer(url, message, opts) {
|
|
717
|
+
return Promise.resolve().then(function () { return _transfer(url, message, opts); });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function _transfer(url, message, opts) {
|
|
721
|
+
opts = opts || {};
|
|
722
|
+
Object.keys(opts).forEach(function (k) { if (!KNOWN_TRANSFER_OPTS[k]) throw _err("cmp/bad-input", "unknown opts field " + JSON.stringify(k)); });
|
|
723
|
+
var der = _cmpMessageDer(message); // config gate: before any transport call
|
|
724
|
+
var parsedUrl;
|
|
725
|
+
try { parsedUrl = new URL(String(url)); } // parse only -- NO client scheme gate (the transport owns socket security)
|
|
726
|
+
catch (e) { throw _err("cmp/bad-url", "the CMP URL did not parse: " + String(url), e); }
|
|
727
|
+
var transport = opts.transport;
|
|
728
|
+
if (!transport) {
|
|
729
|
+
var t = opts.tls || {};
|
|
730
|
+
var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
|
|
731
|
+
if (!hasAnchors && t.useSystemStore !== true) throw _err("cmp/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned server (RFC 9811 sec. 5)");
|
|
732
|
+
transport = httpTransport.https({ E: _err, errPrefix: "cmp" });
|
|
733
|
+
}
|
|
734
|
+
var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_TRANSFER_TIMEOUT, { E: _err, code: "cmp/bad-input", min: 1, max: MAX_TRANSFER_TIMEOUT });
|
|
735
|
+
var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: _err, code: "cmp/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES });
|
|
736
|
+
// Build the request headers: forward custom opts.headers but STRIP the request-framing headers
|
|
737
|
+
// (content-length, transfer-encoding) and ANY content-type case variant -- the verb sets the content-type
|
|
738
|
+
// and the transport computes Content-Length from the exact body, so a caller cannot desync the request
|
|
739
|
+
// framing (HTTP request smuggling via a short/long content-length) or override the media type through
|
|
740
|
+
// header-name casing (a "Content-Type" that Object.assign would keep alongside the lowercase one).
|
|
741
|
+
var headers = {};
|
|
742
|
+
Object.keys(opts.headers || {}).forEach(function (k) {
|
|
743
|
+
var lk = k.toLowerCase();
|
|
744
|
+
if (lk !== "content-length" && lk !== "transfer-encoding" && lk !== "content-type") headers[k] = opts.headers[k];
|
|
745
|
+
});
|
|
746
|
+
headers["content-type"] = PKIXCMP;
|
|
747
|
+
var tls = _tlsForTransfer(opts);
|
|
748
|
+
return Promise.resolve(transport({ method: "POST", url: parsedUrl.href, headers: headers, body: der, tls: tls, timeout: timeout, maxResponseBytes: maxResponseBytes })).then(function (res) {
|
|
749
|
+
res = res || {};
|
|
750
|
+
var h = {};
|
|
751
|
+
Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
|
|
752
|
+
// Normalize the response body to a Buffer preserving its exact bytes: a binary view (Buffer / Uint8Array
|
|
753
|
+
// from an injected transport) is re-viewed through the byte guard, as the request path does; only a
|
|
754
|
+
// genuine string body falls back to a UTF-8 encode (the width a real socket already counts) so the size
|
|
755
|
+
// cap and the parser see the same bytes -- a typed array is not stringified to "48,130,..." garbage.
|
|
756
|
+
var body = (Buffer.isBuffer(res.body) || res.body instanceof Uint8Array)
|
|
757
|
+
? guard.bytes.view(res.body, CmpError, "cmp/bad-response", "the CMP response body")
|
|
758
|
+
: Buffer.from(String(res.body == null ? "" : res.body), "utf8");
|
|
759
|
+
if (body.length > maxResponseBytes) throw _err("cmp/response-too-large", "the response body (" + body.length + " bytes) exceeds the " + maxResponseBytes + "-byte cap");
|
|
760
|
+
return _classifyCmpResponse(res.status, h, body, res.tls || null);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// A single safe path segment for a well-known URI: no separator, dot-segment, or reserved/space char that
|
|
765
|
+
// would retarget the resource; percent-encoded on the way out. Fails closed (guards never guess a value).
|
|
766
|
+
function _wellKnownSeg(v, name) {
|
|
767
|
+
var s = String(v);
|
|
768
|
+
if (s === "" || s === "." || s === ".." || /[/?#\s\\]/.test(s)) throw _err("cmp/bad-url", "the CMP " + name + " must be a single safe path segment (no '/', dot-segment, or reserved char): " + JSON.stringify(s));
|
|
769
|
+
// encodeURIComponent throws a raw URIError (no .code) on an unpaired UTF-16 surrogate; normalize it to the
|
|
770
|
+
// helper's cmp/bad-url contract so a malformed-config segment never escapes as an untyped error.
|
|
771
|
+
try { return encodeURIComponent(s); }
|
|
772
|
+
catch (e) { throw _err("cmp/bad-url", "the CMP " + name + " is not encodable (e.g. an unpaired surrogate): " + JSON.stringify(s), e); }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
var KNOWN_WELLKNOWN_OPTS = { label: 1, operation: 1 };
|
|
776
|
+
|
|
777
|
+
function wellKnownUrl(base, opts) {
|
|
778
|
+
opts = opts || {};
|
|
779
|
+
// Reject an unknown option key (a typo like { lable } / { operaton }), as build/transfer do -- a silently
|
|
780
|
+
// ignored option would build the DEFAULT endpoint and send the request to a different CA/profile.
|
|
781
|
+
Object.keys(opts).forEach(function (k) { if (!KNOWN_WELLKNOWN_OPTS[k]) throw _err("cmp/bad-input", "unknown wellKnownUrl option " + JSON.stringify(k) + " (expected label / operation)"); });
|
|
782
|
+
var u;
|
|
783
|
+
try { u = new URL(String(base)); }
|
|
784
|
+
catch (e) { throw _err("cmp/bad-url", "the CMP base URL did not parse: " + String(base), e); }
|
|
785
|
+
// Require an http(s) authority: a non-HTTP scheme (file:, data:, a custom scheme) has an opaque origin
|
|
786
|
+
// ("null"), which would build a nonsensical "null/.well-known/cmp". A well-known URI is an HTTP concept
|
|
787
|
+
// (RFC 8615); https vs http is left to the transport (Build decision 6), but the scheme must be one of them.
|
|
788
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") throw _err("cmp/bad-url", "the CMP base URL must be http or https (a well-known URI is authority-rooted over HTTP), got " + u.protocol + ": " + JSON.stringify(String(base)));
|
|
789
|
+
if (u.search || u.hash) throw _err("cmp/bad-url", "the CMP base URL must not carry a query or fragment component (RFC 9811 sec. 3.4)");
|
|
790
|
+
// A backslash has no place in an http(s) authority: WHATWG rewrites it to a path separator (so
|
|
791
|
+
// `https://ca.example\tenant` is really the path `/tenant`), but the raw-path regex below excludes only
|
|
792
|
+
// `/?#` and would swallow it as authority, slipping a supplied path past the check. Reject it outright.
|
|
793
|
+
if (/\\/.test(String(base))) throw _err("cmp/bad-url", "the CMP base URL must not contain a backslash (WHATWG rewrites it to a path separator): " + JSON.stringify(String(base)));
|
|
794
|
+
// An RFC 8615 well-known URI is AUTHORITY-ROOTED (/.well-known/...), so a base carrying ANY path is
|
|
795
|
+
// rejected fail-closed -- an operator whose CMP endpoint sits under a path passes that full operation URL
|
|
796
|
+
// to transfer() directly. Inspect the RAW path in the source string, NOT url.pathname: WHATWG normalizes
|
|
797
|
+
// dot-segments (`/tenant/..`, `/%2e`) to "/" before the parse, which would slip a supplied path past a
|
|
798
|
+
// pathname check and silently target the authority-root profile instead of reporting the misconfiguration.
|
|
799
|
+
var rawBasePath = String(base).replace(/^[a-z]+:\/\/[^/?#]*/i, "").split(/[?#]/)[0];
|
|
800
|
+
if (rawBasePath !== "" && rawBasePath !== "/") throw _err("cmp/bad-url", "an RFC 8615 well-known URI is authority-rooted; the CMP base URL must have no path component (pass a full operation URL to transfer instead): " + JSON.stringify(String(base)));
|
|
801
|
+
var segs = [".well-known", "cmp"];
|
|
802
|
+
if (opts.label != null) { segs.push("p"); segs.push(_wellKnownSeg(opts.label, "label")); }
|
|
803
|
+
if (opts.operation != null) segs.push(_wellKnownSeg(opts.operation, "operation"));
|
|
804
|
+
return u.origin + "/" + segs.join("/");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* @primitive pki.cmp.transfer
|
|
809
|
+
* @signature pki.cmp.transfer(url, message, opts?) -> Promise<{ response, responseBytes, status, contentType, tls }>
|
|
810
|
+
* @since 0.3.19
|
|
811
|
+
* @status experimental
|
|
812
|
+
* @spec RFC 9811, RFC 9810
|
|
813
|
+
* @related pki.cmp.build, pki.cmp.wellKnownUrl, pki.schema.cmp.parse, pki.transport.https
|
|
814
|
+
*
|
|
815
|
+
* POST a DER `PKIMessage` to a CMP endpoint and return the parsed response `PKIMessage`, over the shared
|
|
816
|
+
* `pki.transport`. RFC 9811 transfers every CMP exchange identically -- one HTTP POST of a DER PKIMessage,
|
|
817
|
+
* one response PKIMessage -- so a single stateless verb carries `ir` / `cr` / `kur` / `p10cr` / `certConf`
|
|
818
|
+
* / `pollReq` / `rr` / `genm` and their responses; the caller builds and protects the message upstream with
|
|
819
|
+
* `pki.cmp.build` and hands the finished bytes here. `message` is a DER `Buffer`/`Uint8Array` or a PEM `CMP`
|
|
820
|
+
* string, sent VERBATIM (the message-layer protection covers these exact bytes -- they are never re-encoded).
|
|
821
|
+
* The response is classified fail-closed: HTTP 200 carrying an `application/pkixcmp` body is parsed and
|
|
822
|
+
* resolved; another 2xx is `cmp/unexpected-status` (RFC 9811 requires 200); a 3xx is `cmp/redirect-not-followed`
|
|
823
|
+
* (never auto-followed, sec. 3.1/5); a 4xx/5xx carrying a well-formed CMP `error` PKIMessage FORWARDS that
|
|
824
|
+
* integrity-protected verdict (sec. 1.2/3.1) with the HTTP status surfaced as data, while a 4xx/5xx that is
|
|
825
|
+
* not a CMP `error` message (no body, an undecodable body, or a non-error arm) is `cmp/http-error`.
|
|
826
|
+
* Protection is SURFACED, not verified -- the client confers no trust; the
|
|
827
|
+
* caller (or a future `pki.cmp.verify`) checks the response protection using the raw `headerBytes`/`bodyBytes`.
|
|
828
|
+
* By default the transport is https-only and requires an explicit trust anchor; there is no client scheme
|
|
829
|
+
* gate, so an operator who injects an http-capable transport reaches the RFC-9811-permitted plain-HTTP path.
|
|
830
|
+
*
|
|
831
|
+
* @opts
|
|
832
|
+
* - `transport` -- an injectable `transport(request) -> Promise<{status,headers,body}>` (default
|
|
833
|
+
* `pki.transport.https`, which fail-closes on a non-https URL and an unpinned server).
|
|
834
|
+
* - `tls` -- `{ anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }` for the
|
|
835
|
+
* default transport (mutual-TLS `cert`/`key` is common for CMP in addition to message protection).
|
|
836
|
+
* - `headers` -- extra request headers. The `content-type: application/pkixcmp` is always set and not
|
|
837
|
+
* overridable (any casing), and the request-framing headers (`content-length`, `transfer-encoding`) are
|
|
838
|
+
* stripped -- the transport computes Content-Length from the exact body, so a caller cannot desync framing.
|
|
839
|
+
* - `timeout` -- ms (default 30s); `maxResponseBytes` -- streaming cap, tightenable downward only.
|
|
840
|
+
* @example
|
|
841
|
+
* var reqDer = await pki.cmp.build(
|
|
842
|
+
* { header: { sender: { directoryName: "CN=client" }, recipient: { directoryName: "CN=CA" } },
|
|
843
|
+
* body: { p10cr: csrDer } },
|
|
844
|
+
* { key: signerKeyPkcs8, cert: signerCertDer });
|
|
845
|
+
* var url = pki.cmp.wellKnownUrl("https://ca.example", { operation: "p10cr" });
|
|
846
|
+
* var res = await pki.cmp.transfer(url, reqDer, { transport: transport });
|
|
847
|
+
* res.response.body.arm; // "ip" | "cp" | "error"
|
|
848
|
+
*/
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* @primitive pki.cmp.wellKnownUrl
|
|
852
|
+
* @signature pki.cmp.wellKnownUrl(base, opts?) -> string
|
|
853
|
+
* @since 0.3.19
|
|
854
|
+
* @status experimental
|
|
855
|
+
* @spec RFC 9811, RFC 8615
|
|
856
|
+
* @related pki.cmp.transfer
|
|
857
|
+
*
|
|
858
|
+
* Build an RFC 9811 sec. 3.4 CMP request-URI under the `/.well-known/cmp` prefix (RFC 8615). The four forms:
|
|
859
|
+
* `<base>/.well-known/cmp`, `.../<operation>`, `.../p/<label>`, and `.../p/<label>/<operation>`. A pure string
|
|
860
|
+
* builder over an authority-rooted well-known path (RFC 8615) -- https vs http is left to the transport (both
|
|
861
|
+
* forms are accepted, RFC 9811 sec. 3.4), but the `base` MUST be an http(s) URL. It rejects (`cmp/bad-url`) an
|
|
862
|
+
* unparseable `base`, a non-http(s) scheme (`file:`/`data:`/a custom scheme has an opaque origin), a `base`
|
|
863
|
+
* carrying a path / query / fragment (a well-known URI is authority-rooted; a path would capture it), and a
|
|
864
|
+
* `label`/`operation` that is empty, a dot-segment, or contains a `/` or other reserved char (never silently
|
|
865
|
+
* retarget the resource). The `operation` label is a caller-supplied string, not validated against a profile
|
|
866
|
+
* vocabulary (RFC 9483 is out of scope).
|
|
867
|
+
*
|
|
868
|
+
* @opts
|
|
869
|
+
* - `label` -- an optional CA/RA name inserted as `.../p/<label>` (a single percent-encoded path segment).
|
|
870
|
+
* - `operation` -- an optional operation label appended as the final path segment.
|
|
871
|
+
* @example
|
|
872
|
+
* pki.cmp.wellKnownUrl("https://ca.example"); // "https://ca.example/.well-known/cmp"
|
|
873
|
+
* pki.cmp.wellKnownUrl("https://ca.example", { label: "myca", operation: "cr" });
|
|
874
|
+
* // // ".../.well-known/cmp/p/myca/cr"
|
|
875
|
+
*/
|
|
876
|
+
module.exports = { build: build, transfer: transfer, wellKnownUrl: wellKnownUrl };
|
package/lib/est.js
CHANGED
|
@@ -62,6 +62,7 @@ var csr = require("./schema-csr");
|
|
|
62
62
|
var frameworkError = require("./framework-error");
|
|
63
63
|
var guard = require("./guard-all");
|
|
64
64
|
var httpTransport = require("./http-transport");
|
|
65
|
+
var retryAfter = require("./http-retry-after");
|
|
65
66
|
|
|
66
67
|
var EstError = frameworkError.EstError;
|
|
67
68
|
function E(code, message, cause) { return new EstError(code, message, cause); }
|
|
@@ -76,50 +77,6 @@ var OID_TEMPLATE = oid.byName("certificationRequestInfoTemplate");
|
|
|
76
77
|
|
|
77
78
|
var OPERATIONS = ["cacerts", "simpleenroll", "simplereenroll", "fullcmc", "serverkeygen", "csrattrs"];
|
|
78
79
|
|
|
79
|
-
// The largest Retry-After delay this classifier will surface as a number: a
|
|
80
|
-
// generous one-year ceiling that keeps the value a safe integer and rejects an
|
|
81
|
-
// overflowing / nonsensical delay-seconds fail-closed rather than returning it.
|
|
82
|
-
var MAX_RETRY_AFTER_SECONDS = constants.TIME.days(365) / constants.TIME.seconds(1);
|
|
83
|
-
|
|
84
|
-
// The three HTTP-date forms (RFC 7231 sec. 7.1.1.1): IMF-fixdate (the required
|
|
85
|
-
// form), and the obsolete rfc850-date / asctime-date a recipient must still
|
|
86
|
-
// accept. Gating Date.parse on this grammar keeps its permissiveness (an ISO
|
|
87
|
-
// string like "2026-07-10") from passing as a valid Retry-After header.
|
|
88
|
-
var _MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
|
|
89
|
-
var _MONTH = "(?:" + _MONTHS.join("|") + ")";
|
|
90
|
-
var HTTP_DATE_FORMS = [
|
|
91
|
-
new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \\d{2} " + _MONTH + " \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$"),
|
|
92
|
-
new RegExp("^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \\d{2}-" + _MONTH + "-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$"),
|
|
93
|
-
new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) " + _MONTH + " [ \\d]\\d \\d{2}:\\d{2}:\\d{2} \\d{4}$"),
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
// Parse an HTTP-date to epoch ms, or NaN when its grammar, calendar, or time is
|
|
97
|
-
// invalid. Built from the extracted fields via Date.UTC (never Date.parse): every
|
|
98
|
-
// HTTP-date is UTC -- the asctime form carries no GMT token, so delegating would
|
|
99
|
-
// parse it in LOCAL time -- and the round-trip check rejects an impossible date /
|
|
100
|
-
// time (Date.UTC normalizes Feb 31 -> Mar 2 just as Date.parse would).
|
|
101
|
-
function _httpDateMs(s) {
|
|
102
|
-
if (!HTTP_DATE_FORMS.some(function (re) { return re.test(s); })) return NaN;
|
|
103
|
-
var day, monName, year;
|
|
104
|
-
var asc = /^\w{3} (\w{3}) ([ \d]\d) /.exec(s); // asctime: month then day
|
|
105
|
-
if (asc) { monName = asc[1]; day = parseInt(asc[2], 10); year = parseInt(/(\d{4})$/.exec(s)[1], 10); }
|
|
106
|
-
else {
|
|
107
|
-
var dmy = /(\d{2})[ -](\w{3})[ -](\d{2,4})/.exec(s); // IMF / rfc850: day month year
|
|
108
|
-
if (!dmy) return NaN;
|
|
109
|
-
day = parseInt(dmy[1], 10); monName = dmy[2]; year = parseInt(dmy[3], 10);
|
|
110
|
-
if (year < 100) year += (year < 70 ? 2000 : 1900); // rfc850 two-digit year
|
|
111
|
-
}
|
|
112
|
-
var t = /(\d{2}):(\d{2}):(\d{2})/.exec(s);
|
|
113
|
-
var mon = _MONTHS.indexOf(monName);
|
|
114
|
-
if (!t || mon < 0) return NaN;
|
|
115
|
-
var hh = parseInt(t[1], 10), mi = parseInt(t[2], 10), ss = parseInt(t[3], 10);
|
|
116
|
-
var when = Date.UTC(year, mon, day, hh, mi, ss);
|
|
117
|
-
var d = new Date(when);
|
|
118
|
-
if (d.getUTCDate() !== day || d.getUTCMonth() !== mon || d.getUTCFullYear() !== year ||
|
|
119
|
-
d.getUTCHours() !== hh || d.getUTCMinutes() !== mi || d.getUTCSeconds() !== ss) return NaN;
|
|
120
|
-
return when;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
80
|
// ---- the RFC 8951 sec. 3/3.1 transfer codec (CTE-header-blind) -----------
|
|
124
81
|
|
|
125
82
|
/**
|
|
@@ -511,29 +468,11 @@ function classifyResponse(status, headers, body, opts) {
|
|
|
511
468
|
var ra = h["retry-after"];
|
|
512
469
|
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)");
|
|
513
470
|
var raStr = String(ra).trim();
|
|
514
|
-
// Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3)
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
var out202 = { status: "retry", retryAfter: raStr, retryAfterSeconds: null, retryAfterDate: null };
|
|
520
|
-
if (/^\d+$/.test(raStr)) {
|
|
521
|
-
var n = parseInt(raStr, 10);
|
|
522
|
-
if (!Number.isSafeInteger(n) || n > MAX_RETRY_AFTER_SECONDS) throw E("est/bad-retry-after", "the 202 Retry-After delay is out of the supported range (0.." + MAX_RETRY_AFTER_SECONDS + " seconds)");
|
|
523
|
-
out202.retryAfterSeconds = n;
|
|
524
|
-
} else {
|
|
525
|
-
var when = _httpDateMs(raStr);
|
|
526
|
-
if (isNaN(when)) throw E("est/bad-retry-after", "a 202 Retry-After must be delay-seconds or a valid HTTP-date (RFC 7231 sec. 7.1.1.1/7.1.3), got " + JSON.stringify(raStr));
|
|
527
|
-
out202.retryAfterDate = when;
|
|
528
|
-
if (typeof opts.now === "number" && isFinite(opts.now)) {
|
|
529
|
-
var d = Math.max(0, Math.round((when - opts.now) / constants.TIME.seconds(1)));
|
|
530
|
-
// The same one-year ceiling the delay-seconds form enforces (a date far in
|
|
531
|
-
// the future would otherwise surface a huge numeric delay).
|
|
532
|
-
if (d > MAX_RETRY_AFTER_SECONDS) throw E("est/bad-retry-after", "the 202 Retry-After date is beyond the supported horizon (" + MAX_RETRY_AFTER_SECONDS + " seconds)");
|
|
533
|
-
out202.retryAfterSeconds = d;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
return out202;
|
|
471
|
+
// Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3), surfaced (never slept on)
|
|
472
|
+
// as a bounded retryAfterSeconds and/or an absolute retryAfterDate via the shared parser; neither
|
|
473
|
+
// form -> fail closed rather than a retry verdict with no delay.
|
|
474
|
+
var parsed = retryAfter.parse(raStr, { now: opts.now, E: E, code: "est/bad-retry-after" });
|
|
475
|
+
return { status: "retry", retryAfter: raStr, retryAfterSeconds: parsed.retryAfterSeconds, retryAfterDate: parsed.retryAfterDate };
|
|
537
476
|
}
|
|
538
477
|
if (status === 204 || status === 404) {
|
|
539
478
|
if (op === "csrattrs") return { status: "none-available" };
|
|
@@ -792,7 +731,10 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
|
|
|
792
731
|
function step() {
|
|
793
732
|
return transport({ method: method, url: url.href, headers: headers, body: body, tls: tls, timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
|
|
794
733
|
res = res || {};
|
|
795
|
-
|
|
734
|
+
// Measure an injected string body as UTF-8 -- the byte width the body is decoded/transfer-decoded at,
|
|
735
|
+
// and what the real socket transport counts -- so a non-ASCII body (multi-byte chars are ~half the
|
|
736
|
+
// count under latin1) cannot undercount past the DoS cap and reach body handling.
|
|
737
|
+
var blen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
|
|
796
738
|
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
739
|
// The transport contract returns lowercased headers, but the injectable seam only promises
|
|
798
740
|
// { status, headers, body }; normalize here so a redirect Location / WWW-Authenticate from an
|
|
@@ -885,7 +827,9 @@ function _certsResult(op, res, opts, csrSpki) {
|
|
|
885
827
|
// Only the RFC-defined success statuses are accepted: a non-standard 2xx (201, 206, ...) that the
|
|
886
828
|
// classifier reports as "unexpected" must NOT have its body decoded and accepted as certificates.
|
|
887
829
|
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
|
-
|
|
830
|
+
// Measure the body as UTF-8 for parity with the transport cap above (this check only distinguishes empty
|
|
831
|
+
// from non-empty, where the encoding is immaterial, but the shape is kept uniform so no site undercounts).
|
|
832
|
+
var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
|
|
889
833
|
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
834
|
var parsed = parseCertsOnly(transferDecode(res.body));
|
|
891
835
|
if (op === "cacerts") return { certificates: parsed.certificates, crls: parsed.crls };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- the shared HTTP Retry-After parser (RFC 7231 sec. 7.1.3): a delay-seconds count OR an
|
|
6
|
+
// HTTP-date (sec. 7.1.1.1). The enrollment protocol clients (pki.est now, pki.acme next) all surface a
|
|
7
|
+
// server's Retry-After as a bounded delay -- never sleeping on it in a single-step call -- so the
|
|
8
|
+
// delay-seconds | HTTP-date grammar and the one-year ceiling live in ONE place both compose, error-
|
|
9
|
+
// factory-parameterized so each domain keeps its own typed verdict. A malformed / overflowing value
|
|
10
|
+
// fails closed (the caller's E(code, ...)); the value is never returned unparsed.
|
|
11
|
+
|
|
12
|
+
var constants = require("./constants");
|
|
13
|
+
|
|
14
|
+
// The largest delay this parser surfaces as a number: a generous one-year ceiling that keeps the
|
|
15
|
+
// value a safe integer and rejects an overflowing / nonsensical delay-seconds or far-future date.
|
|
16
|
+
var MAX_RETRY_AFTER_SECONDS = constants.TIME.days(365) / constants.TIME.seconds(1);
|
|
17
|
+
|
|
18
|
+
// The three HTTP-date forms (RFC 7231 sec. 7.1.1.1): IMF-fixdate (the required form), and the obsolete
|
|
19
|
+
// rfc850-date / asctime-date a recipient must still accept. Gating Date.parse on this grammar keeps its
|
|
20
|
+
// permissiveness (an ISO string like "2026-07-10") from passing as a valid Retry-After header.
|
|
21
|
+
var MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
|
|
22
|
+
var MONTH = "(?:" + MONTHS.join("|") + ")";
|
|
23
|
+
var HTTP_DATE_FORMS = [
|
|
24
|
+
new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \\d{2} " + MONTH + " \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$"),
|
|
25
|
+
new RegExp("^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \\d{2}-" + MONTH + "-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$"),
|
|
26
|
+
new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) " + MONTH + " [ \\d]\\d \\d{2}:\\d{2}:\\d{2} \\d{4}$"),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Expand an obsolete RFC 850 two-digit year: HTTP interprets it RELATIVE TO the receipt year -- the full
|
|
30
|
+
// year with these two digits within [refYear-50, refYear+49] (moved to the past only when it would
|
|
31
|
+
// otherwise be more than 50 years ahead). A fixed cutoff would misdate a year near the century boundary.
|
|
32
|
+
// With no receipt time, fall back to the RFC 6265 fixed rule (< 70 -> 2000s, else 1900s).
|
|
33
|
+
function _rfc850Year(yy, refMs) {
|
|
34
|
+
if (typeof refMs !== "number" || !isFinite(refMs)) return yy + (yy < 70 ? 2000 : 1900);
|
|
35
|
+
var refYear = new Date(refMs).getUTCFullYear();
|
|
36
|
+
var full = Math.floor(refYear / 100) * 100 + yy;
|
|
37
|
+
// Move to the PREVIOUS century ONLY when the same-century year would be more than 50 years ahead; never
|
|
38
|
+
// advance a past year to the future (an old two-digit year stays in the past).
|
|
39
|
+
if (full - refYear > 50) full -= 100;
|
|
40
|
+
return full;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse an HTTP-date to epoch ms, or NaN when its grammar, calendar, or time is invalid. Built from the
|
|
44
|
+
// extracted fields via Date.UTC (never Date.parse): every HTTP-date is UTC -- the asctime form carries
|
|
45
|
+
// no GMT token, so delegating would parse it in LOCAL time -- and the round-trip check rejects an
|
|
46
|
+
// impossible date / time (Date.UTC normalizes Feb 31 -> Mar 2 just as Date.parse would).
|
|
47
|
+
function httpDateMs(s, refMs) {
|
|
48
|
+
if (!HTTP_DATE_FORMS.some(function (re) { return re.test(s); })) return NaN;
|
|
49
|
+
var day, monName, year;
|
|
50
|
+
var asc = /^\w{3} (\w{3}) ([ \d]\d) /.exec(s); // asctime: month then day
|
|
51
|
+
if (asc) { monName = asc[1]; day = parseInt(asc[2], 10); year = parseInt(/(\d{4})$/.exec(s)[1], 10); }
|
|
52
|
+
else {
|
|
53
|
+
var dmy = /(\d{2})[ -](\w{3})[ -](\d{2,4})/.exec(s); // IMF / rfc850: day month year
|
|
54
|
+
if (!dmy) return NaN;
|
|
55
|
+
day = parseInt(dmy[1], 10); monName = dmy[2]; year = parseInt(dmy[3], 10);
|
|
56
|
+
if (year < 100) year = _rfc850Year(year, refMs); // rfc850 two-digit year
|
|
57
|
+
}
|
|
58
|
+
var t = /(\d{2}):(\d{2}):(\d{2})/.exec(s);
|
|
59
|
+
var mon = MONTHS.indexOf(monName);
|
|
60
|
+
if (!t || mon < 0) return NaN;
|
|
61
|
+
var hh = parseInt(t[1], 10), mi = parseInt(t[2], 10), ss = parseInt(t[3], 10);
|
|
62
|
+
var when = Date.UTC(year, mon, day, hh, mi, ss);
|
|
63
|
+
var d = new Date(when);
|
|
64
|
+
if (d.getUTCDate() !== day || d.getUTCMonth() !== mon || d.getUTCFullYear() !== year ||
|
|
65
|
+
d.getUTCHours() !== hh || d.getUTCMinutes() !== mi || d.getUTCSeconds() !== ss) return NaN;
|
|
66
|
+
return when;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// parse(value, opts) -> { retryAfterSeconds, retryAfterDate }. A PRESENT Retry-After value (the caller
|
|
70
|
+
// gates absence with its own code) is a delay-seconds integer -> retryAfterSeconds, or an HTTP-date ->
|
|
71
|
+
// retryAfterDate (epoch ms) plus, when opts.now (epoch ms) is given, a bounded retryAfterSeconds. Either
|
|
72
|
+
// form beyond the one-year ceiling, or a value that is neither, fails closed via opts.E(opts.code, ...).
|
|
73
|
+
// Never slept on here -- the value is SURFACED for the caller to decide.
|
|
74
|
+
function parse(value, opts) {
|
|
75
|
+
opts = opts || {};
|
|
76
|
+
var raStr = String(value).trim();
|
|
77
|
+
var out = { retryAfterSeconds: null, retryAfterDate: null };
|
|
78
|
+
if (/^\d+$/.test(raStr)) {
|
|
79
|
+
var n = parseInt(raStr, 10);
|
|
80
|
+
if (!Number.isSafeInteger(n) || n > MAX_RETRY_AFTER_SECONDS) throw _fail(opts, "the Retry-After delay is out of the supported range (0.." + MAX_RETRY_AFTER_SECONDS + " seconds)");
|
|
81
|
+
out.retryAfterSeconds = n;
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
var when = httpDateMs(raStr, opts.now);
|
|
85
|
+
if (isNaN(when)) throw _fail(opts, "a Retry-After must be delay-seconds or a valid HTTP-date (RFC 7231 sec. 7.1.1.1/7.1.3), got " + JSON.stringify(raStr));
|
|
86
|
+
out.retryAfterDate = when;
|
|
87
|
+
if (typeof opts.now === "number" && isFinite(opts.now)) {
|
|
88
|
+
// Round the remaining whole-second delay UP (a sub-second date must not retry before the requested
|
|
89
|
+
// time), clamping a past date to 0.
|
|
90
|
+
var d = Math.max(0, Math.ceil((when - opts.now) / constants.TIME.seconds(1)));
|
|
91
|
+
if (d > MAX_RETRY_AFTER_SECONDS) throw _fail(opts, "the Retry-After date is beyond the supported horizon (" + MAX_RETRY_AFTER_SECONDS + " seconds)");
|
|
92
|
+
out.retryAfterSeconds = d;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _fail(opts, message) {
|
|
98
|
+
return typeof opts.E === "function" ? opts.E(opts.code, message) : new TypeError(message);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { MAX_RETRY_AFTER_SECONDS: MAX_RETRY_AFTER_SECONDS, httpDateMs: httpDateMs, parse: parse };
|
package/lib/http-transport.js
CHANGED
|
@@ -178,6 +178,19 @@ function httpsTransport(defaults) {
|
|
|
178
178
|
var body = request.body;
|
|
179
179
|
if (Buffer.isBuffer(body)) body = guard.bytes.view(body, E, C("bad-input"), "the request body");
|
|
180
180
|
|
|
181
|
+
// Frame a body-bearing request with a fixed Content-Length computed from the EXACT body, so it is sent
|
|
182
|
+
// length-delimited rather than Transfer-Encoding: chunked (node's default when a body is written with no
|
|
183
|
+
// Content-Length -- which strict enrollment / CMP appliances reject). A caller-supplied content-length
|
|
184
|
+
// (any case) is dropped: the transport owns request framing and never trusts a possibly-mismatched value.
|
|
185
|
+
var reqHeaders = Object.assign({}, request.headers || {});
|
|
186
|
+
if (body != null && body !== "") {
|
|
187
|
+
// Drop BOTH caller-supplied framing headers before installing the fixed length: node rejects a request
|
|
188
|
+
// carrying Content-Length AND Transfer-Encoding together (HPE_INVALID_CONTENT_LENGTH), and the transport
|
|
189
|
+
// owns framing -- it never honors a caller's chunked directive or a mismatched length.
|
|
190
|
+
Object.keys(reqHeaders).forEach(function (k) { var lk = k.toLowerCase(); if (lk === "content-length" || lk === "transfer-encoding") delete reqHeaders[k]; });
|
|
191
|
+
reqHeaders["Content-Length"] = Buffer.byteLength(body);
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
// URL.hostname keeps the square brackets on an IPv6 literal ([2001:db8::1]); node's `hostname`
|
|
182
195
|
// option and its IP-detection expect the bare address, so strip the brackets once here.
|
|
183
196
|
var host = url.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
@@ -186,7 +199,7 @@ function httpsTransport(defaults) {
|
|
|
186
199
|
hostname: host,
|
|
187
200
|
port: url.port || 443,
|
|
188
201
|
path: url.pathname + url.search,
|
|
189
|
-
headers:
|
|
202
|
+
headers: reqHeaders,
|
|
190
203
|
minVersion: minVersion,
|
|
191
204
|
rejectUnauthorized: true, // ALWAYS on -- there is no code path that disables server verification
|
|
192
205
|
// Opt out of connection pooling: node's keep-alive agent keys the socket pool on host + TLS
|
package/lib/ip-utils.js
CHANGED
|
@@ -56,4 +56,14 @@ function expandIpv6Hex(ip) {
|
|
|
56
56
|
// Is `s` a syntactically valid IPv4 or IPv6 textual literal?
|
|
57
57
|
function isIpLiteral(s) { return isIPv4(s) || expandIpv6Hex(s) !== null; }
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// Pack an IPv4/IPv6 textual literal to its network-order octets -- 4 for IPv4, 16 for IPv6 --
|
|
60
|
+
// or null when `s` is not a valid literal. The binary inverse of the textual forms isIPv4 /
|
|
61
|
+
// expandIpv6Hex validate, so a SAN/GeneralName iPAddress (RFC 5280 sec. 4.2.1.6, always a bare
|
|
62
|
+
// host address of 4 or 16 octets) can be given as a string instead of a pre-packed Buffer.
|
|
63
|
+
function packIpLiteral(s) {
|
|
64
|
+
if (isIPv4(s)) return Buffer.from(s.split(".").map(Number)); // each octet already range-checked by IPV4_RE
|
|
65
|
+
var hex = expandIpv6Hex(s);
|
|
66
|
+
return hex === null ? null : Buffer.from(hex, "hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral, IPV4_RE: IPV4_RE };
|
package/lib/pkcs12-build.js
CHANGED
|
@@ -55,9 +55,14 @@ var DEFAULT_MAC_ITER = 2048;
|
|
|
55
55
|
var DEFAULT_PBMAC1_ITER = 2048;
|
|
56
56
|
var MAC_SALT_BYTES = 8;
|
|
57
57
|
var MAX_PBMAC1_KEYLEN = 1024; // an HMAC key beyond a hash block is pointless -- bound the derived length
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// Both MAC paths cap iterations to bound the SYNCHRONOUS derivation that blocks the event loop, but at
|
|
59
|
+
// different counts BECAUSE the per-iteration cost differs by ~5x -- the two ceilings target a comparable
|
|
60
|
+
// ~1-2 second wall-clock budget, not inconsistent DoS budgets. The classic Appendix B KDF is a hand-rolled
|
|
61
|
+
// JS hash loop (a createHash per iteration, ~1.1us each, so 1e6 ~= 1s); PBMAC1 derives via native
|
|
62
|
+
// crypto.pbkdf2Sync (one FFI call, ~0.2us per iteration, so the shared C.LIMITS.PBKDF2_MAX_ITERATIONS of 1e7
|
|
63
|
+
// ~= 2s). The 10x iteration-count gap is thus calibrated to wall clock; additionally the classic count is
|
|
64
|
+
// pkcs12-local (its KDF is bespoke here) while PBMAC1 reuses the toolkit-wide PBKDF2 ceiling. 1e6 is ~500x
|
|
65
|
+
// the OpenSSL default (2048) yet still bounds the loop to ~1 second.
|
|
61
66
|
var CLASSIC_MAC_MAX_ITERATIONS = 1000000;
|
|
62
67
|
|
|
63
68
|
// The classic App. B.2 KDF (u = hash output bytes, v = compression block bytes) per RFC 7292 App. B.2.
|
|
@@ -289,6 +294,8 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
|
|
|
289
294
|
if (algorithm === "pbmac1") {
|
|
290
295
|
var prf = PBMAC1_PRF[hash];
|
|
291
296
|
if (!prf) throw _err("pkcs12/unsupported-algorithm", "PBMAC1 requires a SHA-256/384/512 digest (RFC 9579 sec. 5/7 forbids a <= 160-bit digest, e.g. SHA-1)");
|
|
297
|
+
// Native PBKDF2 (~0.2us/iter) tolerates the toolkit-wide PBKDF2 ceiling where the classic JS-loop KDF
|
|
298
|
+
// caps ~10x lower; both target a comparable ~1-2s wall clock (see CLASSIC_MAC_MAX_ITERATIONS).
|
|
292
299
|
var iter2 = _assertMacIter(macOpts.iterations == null ? DEFAULT_PBMAC1_ITER : macOpts.iterations, C.LIMITS.PBKDF2_MAX_ITERATIONS);
|
|
293
300
|
var keyLen = macOpts.keyLength != null ? macOpts.keyLength : prf.keyLen;
|
|
294
301
|
if (typeof keyLen !== "number" || !Number.isInteger(keyLen) || keyLen < 20 || keyLen > MAX_PBMAC1_KEYLEN) throw _err("pkcs12/bad-input", "PBMAC1 keyLength must be an integer in [20, " + MAX_PBMAC1_KEYLEN + "] (RFC 9579 sec. 9)");
|