@blamejs/pki 0.3.15 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
- * RFC 9908) -- the transport-agnostic codecs, validators, and request builders
14
- * an EST client composes over the shipped CMS / CSR / PKCS#8 / X.509 parsers.
15
- * No socket is opened here: `transferDecode` / `transferEncode` are the RFC 8951
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 codecs -- base64 transfer, multipart
40
- * splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
41
- * builders, and the HTTP response classifier. Transport-agnostic, fail-closed.
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,8 @@ 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");
65
+ var retryAfter = require("./http-retry-after");
54
66
 
55
67
  var EstError = frameworkError.EstError;
56
68
  function E(code, message, cause) { return new EstError(code, message, cause); }
@@ -65,50 +77,6 @@ var OID_TEMPLATE = oid.byName("certificationRequestInfoTemplate");
65
77
 
66
78
  var OPERATIONS = ["cacerts", "simpleenroll", "simplereenroll", "fullcmc", "serverkeygen", "csrattrs"];
67
79
 
68
- // The largest Retry-After delay this classifier will surface as a number: a
69
- // generous one-year ceiling that keeps the value a safe integer and rejects an
70
- // overflowing / nonsensical delay-seconds fail-closed rather than returning it.
71
- var MAX_RETRY_AFTER_SECONDS = constants.TIME.days(365) / constants.TIME.seconds(1);
72
-
73
- // The three HTTP-date forms (RFC 7231 sec. 7.1.1.1): IMF-fixdate (the required
74
- // form), and the obsolete rfc850-date / asctime-date a recipient must still
75
- // accept. Gating Date.parse on this grammar keeps its permissiveness (an ISO
76
- // string like "2026-07-10") from passing as a valid Retry-After header.
77
- var _MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
78
- var _MONTH = "(?:" + _MONTHS.join("|") + ")";
79
- var HTTP_DATE_FORMS = [
80
- new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \\d{2} " + _MONTH + " \\d{4} \\d{2}:\\d{2}:\\d{2} GMT$"),
81
- new RegExp("^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \\d{2}-" + _MONTH + "-\\d{2} \\d{2}:\\d{2}:\\d{2} GMT$"),
82
- new RegExp("^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) " + _MONTH + " [ \\d]\\d \\d{2}:\\d{2}:\\d{2} \\d{4}$"),
83
- ];
84
-
85
- // Parse an HTTP-date to epoch ms, or NaN when its grammar, calendar, or time is
86
- // invalid. Built from the extracted fields via Date.UTC (never Date.parse): every
87
- // HTTP-date is UTC -- the asctime form carries no GMT token, so delegating would
88
- // parse it in LOCAL time -- and the round-trip check rejects an impossible date /
89
- // time (Date.UTC normalizes Feb 31 -> Mar 2 just as Date.parse would).
90
- function _httpDateMs(s) {
91
- if (!HTTP_DATE_FORMS.some(function (re) { return re.test(s); })) return NaN;
92
- var day, monName, year;
93
- var asc = /^\w{3} (\w{3}) ([ \d]\d) /.exec(s); // asctime: month then day
94
- if (asc) { monName = asc[1]; day = parseInt(asc[2], 10); year = parseInt(/(\d{4})$/.exec(s)[1], 10); }
95
- else {
96
- var dmy = /(\d{2})[ -](\w{3})[ -](\d{2,4})/.exec(s); // IMF / rfc850: day month year
97
- if (!dmy) return NaN;
98
- day = parseInt(dmy[1], 10); monName = dmy[2]; year = parseInt(dmy[3], 10);
99
- if (year < 100) year += (year < 70 ? 2000 : 1900); // rfc850 two-digit year
100
- }
101
- var t = /(\d{2}):(\d{2}):(\d{2})/.exec(s);
102
- var mon = _MONTHS.indexOf(monName);
103
- if (!t || mon < 0) return NaN;
104
- var hh = parseInt(t[1], 10), mi = parseInt(t[2], 10), ss = parseInt(t[3], 10);
105
- var when = Date.UTC(year, mon, day, hh, mi, ss);
106
- var d = new Date(when);
107
- if (d.getUTCDate() !== day || d.getUTCMonth() !== mon || d.getUTCFullYear() !== year ||
108
- d.getUTCHours() !== hh || d.getUTCMinutes() !== mi || d.getUTCSeconds() !== ss) return NaN;
109
- return when;
110
- }
111
-
112
80
  // ---- the RFC 8951 sec. 3/3.1 transfer codec (CTE-header-blind) -----------
113
81
 
114
82
  /**
@@ -498,31 +466,13 @@ function classifyResponse(status, headers, body, opts) {
498
466
  }
499
467
  if (status === 202) {
500
468
  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 8951 sec. 3.3)");
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)");
502
470
  var raStr = String(ra).trim();
503
- // Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3). A
504
- // delay-seconds becomes retryAfterSeconds directly; an HTTP-date is surfaced
505
- // as an absolute retryAfterDate (epoch ms) -- and, when the caller passes its
506
- // receipt time as opts.now (epoch ms), also as a bounded retryAfterSeconds.
507
- // Neither form -> fail closed rather than a retry verdict with no delay.
508
- var out202 = { status: "retry", retryAfter: raStr, retryAfterSeconds: null, retryAfterDate: null };
509
- if (/^\d+$/.test(raStr)) {
510
- var n = parseInt(raStr, 10);
511
- 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)");
512
- out202.retryAfterSeconds = n;
513
- } else {
514
- var when = _httpDateMs(raStr);
515
- 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));
516
- out202.retryAfterDate = when;
517
- if (typeof opts.now === "number" && isFinite(opts.now)) {
518
- var d = Math.max(0, Math.round((when - opts.now) / constants.TIME.seconds(1)));
519
- // The same one-year ceiling the delay-seconds form enforces (a date far in
520
- // the future would otherwise surface a huge numeric delay).
521
- 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)");
522
- out202.retryAfterSeconds = d;
523
- }
524
- }
525
- 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 };
526
476
  }
527
477
  if (status === 204 || status === 404) {
528
478
  if (op === "csrattrs") return { status: "none-available" };
@@ -708,7 +658,309 @@ function reenrollGuard(oldCertDer, newCsrDer) {
708
658
  return { subjectDn: oldSubjectDn, subjectAltName: oldSan };
709
659
  }
710
660
 
661
+ // ---- the thin RFC 7030 client: network verbs over the shared pki.transport ----
662
+
663
+ var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
664
+ var MAX_TIMEOUT = constants.TIME.seconds(600);
665
+
666
+ // The DER of a caller-supplied CSR: a DER Buffer as-is, or a PEM "CERTIFICATE REQUEST"
667
+ // decoded. Any other input is a config-time est/bad-input.
668
+ function _csrDer(input) {
669
+ if (Buffer.isBuffer(input)) return input;
670
+ if (typeof input === "string") return csr.pemDecode(input);
671
+ throw E("est/bad-input", "a CSR must be a DER Buffer or a PEM CERTIFICATE REQUEST string");
672
+ }
673
+
674
+ // Parse + validate the request URL BEFORE any transport is called: a URL that will not
675
+ // parse is est/bad-url, a non-https URL is est/insecure-url (RFC 7030 sec. 3.3). Runs
676
+ // even when a transport is injected, so an insecure URL never reaches the wire.
677
+ function _parseUrl(urlStr) {
678
+ var url;
679
+ try { url = new URL(String(urlStr)); }
680
+ catch (e) { throw E("est/bad-url", "the EST server URL did not parse: " + String(urlStr), e); }
681
+ if (url.protocol !== "https:") throw E("est/insecure-url", "EST requires https (RFC 7030 sec. 3.3), got " + url.protocol + " for " + urlStr);
682
+ return url;
683
+ }
684
+
685
+ // Map opts.tls (operator-facing) to the transport request.tls shape (anchors -> ca).
686
+ // rejectUnauthorized is NOT here -- the transport forces it on unconditionally.
687
+ function _tlsForRequest(opts) {
688
+ var t = opts.tls || {};
689
+ return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
690
+ }
691
+
692
+ // Resolve a redirect target: reject a scheme downgrade (est/insecure-redirect) or a
693
+ // cross-origin redirect on a non-GET/HEAD method without opt-in
694
+ // (est/cross-origin-redirect); a Location that will not parse is est/bad-url. Same-origin
695
+ // (and cross-origin GET/HEAD) are followed -- the transport re-runs every TLS check on the
696
+ // new connection (RFC 7030 sec. 3.2.1).
697
+ function _redirectTarget(current, location, method, opts) {
698
+ if (location == null || String(location).trim() === "") throw E("est/http-error", "a redirect response carried no Location header");
699
+ var resolved;
700
+ try { resolved = new URL(String(location), current.href); }
701
+ catch (e) { throw E("est/bad-url", "a redirect Location did not parse: " + location, e); }
702
+ 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);
703
+ var safeMethod = method === "GET" || method === "HEAD";
704
+ if (resolved.origin !== current.origin && !safeMethod && !opts.allowCrossOriginRedirect) {
705
+ throw E("est/cross-origin-redirect", "a cross-origin redirect on a " + method + " needs opts.allowCrossOriginRedirect (RFC 7030 sec. 3.2.1)");
706
+ }
707
+ return resolved;
708
+ }
709
+
710
+ // Does WWW-Authenticate advertise a Basic challenge? The quoted-string contents are blanked first
711
+ // (a quoted auth-param may contain a comma + "Basic", e.g. `Digest realm="x, Basic required"`, which
712
+ // must NOT be read as a Basic challenge), then Basic is matched as an auth-scheme TOKEN (at the start
713
+ // or after a comma-separated challenge, followed by whitespace / a comma / end) -- never a substring,
714
+ // so `Digest realm="basic"` / `Bearer error="basic required"` are not taken as Basic either.
715
+ function _hasBasicChallenge(www) {
716
+ var s = String(www || "").replace(/"(?:[^"\\]|\\.)*"/g, "\"\"");
717
+ return /(?:^|,)\s*Basic(?=[\s,]|$)/i.test(s);
718
+ }
719
+
720
+ // The redirect-follow + HTTP-auth-retry loop. Returns the terminal transport response
721
+ // ({status, headers, body}) -- a 2xx, a 202, or a non-401 4xx/5xx. A 3xx is followed
722
+ // (bounded by maxRedirects); a 401 is answered ONCE with Basic credentials, and ONLY when
723
+ // the caller supplied them and the challenge is Basic (RFC 7030 sec. 3.2.3) -- the server
724
+ // was authenticated by the transport's rejectUnauthorized TLS before any credential is
725
+ // transmitted (no credential ever precedes server authorization).
726
+ function _drive(method, url, body, headers, opts, transport, budgets) {
727
+ var redirects = 0;
728
+ var authTried = false;
729
+ var initialOrigin = url.origin; // the origin the caller intended to authenticate to
730
+ var tls = budgets.tls; // narrowed (mTLS client identity stripped) on a cross-origin hop
731
+ function step() {
732
+ return transport({ method: method, url: url.href, headers: headers, body: body, tls: tls, timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
733
+ res = res || {};
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");
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)");
739
+ // The transport contract returns lowercased headers, but the injectable seam only promises
740
+ // { status, headers, body }; normalize here so a redirect Location / WWW-Authenticate from an
741
+ // injected transport using ordinary HTTP casing is read correctly (never missed as absent).
742
+ var h = {};
743
+ Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
744
+ var status = res.status;
745
+ if (status >= 300 && status < 400) {
746
+ if (redirects >= budgets.maxRedirects) throw E("est/too-many-redirects", "the redirect chain exceeded maxRedirects=" + budgets.maxRedirects + " (RFC 7030 sec. 3.2.1)");
747
+ // 303 See Other directs a non-GET/HEAD request to retrieve the target with GET and no body
748
+ // (RFC 7231 sec. 6.4.4); drop the request body + its entity content-type before following, so
749
+ // the CSR is not re-POSTed to the redirect target as a duplicate enrollment.
750
+ if (status === 303 && method !== "GET" && method !== "HEAD") {
751
+ method = "GET";
752
+ body = null;
753
+ if (headers["content-type"]) { headers = Object.assign({}, headers); delete headers["content-type"]; }
754
+ }
755
+ var prevOrigin = url.origin;
756
+ url = _redirectTarget(url, h.location, method, opts);
757
+ // Credentials MUST NOT cross an origin boundary. A redirect to a different web origin (even a
758
+ // permitted cross-origin GET) drops BOTH the HTTP Basic Authorization header AND the mTLS
759
+ // client identity (cert/key), so a prior 401's password and the caller's certificate + proof
760
+ // of private-key possession are never presented to a different server.
761
+ if (url.origin !== prevOrigin) {
762
+ if (headers.authorization) { headers = Object.assign({}, headers); delete headers.authorization; }
763
+ if (tls && (tls.cert || tls.key)) { tls = Object.assign({}, tls); delete tls.cert; delete tls.key; }
764
+ }
765
+ redirects += 1;
766
+ return step();
767
+ }
768
+ if (status === 401) {
769
+ if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
770
+ // Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a
771
+ // cross-origin redirect is a different server, and the client MUST NOT send its credentials
772
+ // there (RFC 7030 sec. 3.6 -- credentials go only to an authorized server the caller chose).
773
+ if (url.origin !== initialOrigin) throw E("est/auth-required", "refusing to send HTTP credentials to a redirected origin (RFC 7030 sec. 3.6)");
774
+ var www = String(h["www-authenticate"] || "");
775
+ if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
776
+ 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)");
777
+ headers = Object.assign({}, headers, { authorization: "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64") });
778
+ authTried = true;
779
+ return step();
780
+ }
781
+ return res;
782
+ });
783
+ }
784
+ return step();
785
+ }
786
+
787
+ // The shared client entry: build the op URL, validate it + the trust config + the budgets
788
+ // (ALL before any transport call so a config fault proves the transport was never reached),
789
+ // then drive the request through the redirect/auth loop.
790
+ function _client(op, method, baseUrl, body, headers, opts) {
791
+ // paths() concatenates onto the base string, so a query / fragment on the base URL would capture
792
+ // the operation path (sending the request -- possibly an enrollment CSR -- to the wrong resource).
793
+ var base;
794
+ try { base = new URL(String(baseUrl)); }
795
+ catch (e) { throw E("est/bad-url", "the EST base URL did not parse: " + String(baseUrl), e); }
796
+ 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)");
797
+ var url = _parseUrl(paths(baseUrl, { label: opts.label })[op]);
798
+ var transport = opts.transport;
799
+ if (!transport) {
800
+ var t = opts.tls || {};
801
+ var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
802
+ 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)");
803
+ transport = httpTransport.https({ E: E, errPrefix: "est" });
804
+ }
805
+ var budgets = {
806
+ tls: _tlsForRequest(opts),
807
+ timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: E, code: "est/bad-input", min: 1, max: MAX_TIMEOUT }),
808
+ 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 }),
809
+ maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "est/bad-input", min: 0, max: 32 }),
810
+ };
811
+ return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
812
+ }
813
+
814
+ // Turn a terminal response into the verb result: classifyResponse gates status +
815
+ // content-type (202 -> a surfaced retry, never slept; 4xx/5xx -> est/http-error), then an
816
+ // empty 200 is rejected and the certs-only body is decoded + validated. For an enroll, the
817
+ // issued certificate is the public-key match (findIssuedCert), never a positional guess;
818
+ // the remaining certificates are the chain.
819
+ function _certsResult(op, res, opts, csrSpki) {
820
+ var verdict = classifyResponse(res.status, res.headers, res.body, { op: op, now: opts.now });
821
+ if (verdict.status === "retry") {
822
+ // 202 Retry-After is an ENROLLMENT response (RFC 7030 sec. 4.2.3); a successful /cacerts is 200
823
+ // only (sec. 4.1.3), so a 202 there is a nonconforming server -> fail closed rather than "retry".
824
+ if (op === "cacerts") throw E("est/http-error", "a /cacerts response must be HTTP 200, not 202 (RFC 7030 sec. 4.1.3)");
825
+ return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
826
+ }
827
+ // Only the RFC-defined success statuses are accepted: a non-standard 2xx (201, 206, ...) that the
828
+ // classifier reports as "unexpected" must NOT have its body decoded and accepted as certificates.
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);
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");
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)");
834
+ var parsed = parseCertsOnly(transferDecode(res.body));
835
+ if (op === "cacerts") return { certificates: parsed.certificates, crls: parsed.crls };
836
+ var issued = findIssuedCert(parsed.certificates, csrSpki);
837
+ if (!issued) throw E("est/issued-cert-not-found", "no returned certificate matched the submitted CSR public key (RFC 5272 sec. 4.1)");
838
+ // The chain is every OTHER response certificate (by reference, so a byte-identical duplicate is
839
+ // NOT silently collapsed into the issued cert). Exactly one certificate may match the submitted
840
+ // public key -- a second match (a renewed cert sharing the key, or a duplicate entry) makes the
841
+ // issued certificate ambiguous, since the certs-only set has no issuance ordering (RFC 5272 sec. 4.1).
842
+ var chain = parsed.certificates.filter(function (c) { return c !== issued; });
843
+ 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)");
844
+ 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");
845
+ return { certificate: issued, chain: chain, certificates: parsed.certificates };
846
+ }
847
+
848
+ function _enroll(op, baseUrl, csrInput, opts) {
849
+ var csrDer = _csrDer(csrInput);
850
+ var spki = csr.parse(csrDer).subjectPublicKeyInfo;
851
+ var body = transferEncode(csrDer);
852
+ return _client(op, "POST", baseUrl, body, { accept: "application/pkcs7-mime", "content-type": "application/pkcs10" }, opts)
853
+ .then(function (res) { return _certsResult(op, res, opts, spki); });
854
+ }
855
+
856
+ /**
857
+ * @primitive pki.est.cacerts
858
+ * @signature pki.est.cacerts(baseUrl, opts?) -> Promise<{ certificates, crls } | { retry, retryAfterSeconds }>
859
+ * @since 0.3.16
860
+ * @status experimental
861
+ * @spec RFC 7030, RFC 8951
862
+ * @related pki.est.simpleenroll, pki.transport.https
863
+ *
864
+ * Fetch a CA's certificates over the wire: GET `<baseUrl>/.well-known/est/cacerts` through
865
+ * the shared `pki.transport` (inject `opts.transport`, else a fail-closed
866
+ * `pki.transport.https`). Returns the raw, unordered certs-only set
867
+ * (`{ certificates, crls }`), or `{ retry: true, retryAfterSeconds }` on a 202 (surfaced,
868
+ * never slept). https-only (`est/insecure-url`); an explicit `opts.tls.anchors` (or an
869
+ * `opts.tls.useSystemStore` opt-in) is required (`est/no-trust-anchors`); the returned CA
870
+ * certificate is NOT auto-trusted -- the caller path-validates it and supplies the accepted
871
+ * anchor on the next call.
872
+ *
873
+ * @opts
874
+ * - `transport` -- an injected transport(request) -> {status, headers, body}; default pki.transport.https.
875
+ * - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }.
876
+ * - `label` -- an OPTIONAL CA label path segment; `timeout` / `maxResponseBytes` / `maxRedirects` -- budgets.
877
+ * - `now` -- receipt time (epoch ms) to render a 202 Retry-After HTTP-date as seconds.
878
+ * @example
879
+ * // a live CA uses the default pki.transport.https; here an injected transport returns a canned bag
880
+ * var r = await pki.est.cacerts("https://ca.example",
881
+ * { transport: function () { return Promise.resolve({ status: 200, headers: { "content-type": "application/pkcs7-mime" }, body: caCertsDer.toString("base64") }); } });
882
+ * r.certificates; // -> [Buffer, ...] raw, unordered
883
+ */
884
+ function cacerts(baseUrl, opts) {
885
+ opts = opts || {};
886
+ return Promise.resolve().then(function () {
887
+ return _client("cacerts", "GET", baseUrl, null, { accept: "application/pkcs7-mime" }, opts);
888
+ }).then(function (res) { return _certsResult("cacerts", res, opts, null); });
889
+ }
890
+
891
+ /**
892
+ * @primitive pki.est.simpleenroll
893
+ * @signature pki.est.simpleenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
894
+ * @since 0.3.16
895
+ * @status experimental
896
+ * @spec RFC 7030, RFC 5272
897
+ * @related pki.csr.sign, pki.est.simplereenroll, pki.est.cacerts
898
+ *
899
+ * Enroll for a certificate: POST a PKCS#10 `csr` (a DER Buffer or a PEM CERTIFICATE REQUEST,
900
+ * e.g. from `pki.csr.sign`) to `<baseUrl>/.well-known/est/simpleenroll` as
901
+ * `application/pkcs10`, over the shared `pki.transport`. Returns the issued certificate
902
+ * chosen by public-key match against the submitted CSR (`certificate`), the remaining
903
+ * certificates (`chain`), and the raw set (`certificates`); or `{ retry: true,
904
+ * retryAfterSeconds }` on a 202. No returned certificate matching the CSR key fails closed
905
+ * (`est/issued-cert-not-found`); `opts.strict` requires exactly the issued certificate. A
906
+ * 401 is answered once with HTTP Basic ONLY when `opts.username`/`password` are supplied and
907
+ * the transport already authenticated the server.
908
+ *
909
+ * @opts
910
+ * - `transport` / `tls` / `label` / `timeout` / `maxResponseBytes` / `maxRedirects` / `now` -- as pki.est.cacerts.
911
+ * - `strict` -- reject an enroll response that carries more than the single issued certificate.
912
+ * - `username` / `password` -- HTTP Basic credentials, answered only after server authorization (empty username allowed).
913
+ * - `allowCrossOriginRedirect` -- opt in to following a cross-origin redirect on this POST.
914
+ * @example
915
+ * var req = await pki.csr.sign({ subject: "device.example", subjectPublicKey: signerSpki }, { key: signerKeyPkcs8 });
916
+ * // a 202 means the CA queued the request -- the verb surfaces the delay, never sleeps
917
+ * var r = await pki.est.simpleenroll("https://ca.example", req,
918
+ * { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
919
+ * r.retry && r.retryAfterSeconds; // 60
920
+ */
921
+ function simpleenroll(baseUrl, csrInput, opts) {
922
+ opts = opts || {};
923
+ return Promise.resolve().then(function () { return _enroll("simpleenroll", baseUrl, csrInput, opts); });
924
+ }
925
+
926
+ /**
927
+ * @primitive pki.est.simplereenroll
928
+ * @signature pki.est.simplereenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
929
+ * @since 0.3.16
930
+ * @status experimental
931
+ * @spec RFC 7030
932
+ * @related pki.est.simpleenroll, pki.est.reenrollGuard
933
+ *
934
+ * Renew / rekey a certificate: identical to `pki.est.simpleenroll` but POSTs to
935
+ * `/.well-known/est/simplereenroll` and REQUIRES `opts.oldCert` (the certificate being
936
+ * renewed). Before anything crosses the wire, `reenrollGuard` enforces that the CSR's
937
+ * Subject and SubjectAltName (names and criticality) are byte-identical to `opts.oldCert`
938
+ * (RFC 7030 sec. 4.2.2) -- a mismatch fails closed (`est/reenroll-subject-mismatch` /
939
+ * `est/reenroll-san-mismatch`) and the transport is never called. A missing `opts.oldCert`
940
+ * is `est/bad-input`.
941
+ *
942
+ * @opts
943
+ * - `oldCert` -- REQUIRED, the DER certificate being renewed (the re-enroll identity check).
944
+ * - every option of pki.est.simpleenroll (transport, tls, label, budgets, strict, credentials).
945
+ * @example
946
+ * // reenrollGuard enforces the RFC 7030 sec. 4.2.2 identity check before anything is sent
947
+ * var r = await pki.est.simplereenroll("https://ca.example", renewCsr,
948
+ * { oldCert: signerCertDer, transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
949
+ * r.retry; // true
950
+ */
951
+ function simplereenroll(baseUrl, csrInput, opts) {
952
+ opts = opts || {};
953
+ return Promise.resolve().then(function () {
954
+ if (!opts.oldCert) throw E("est/bad-input", "simplereenroll requires opts.oldCert (the certificate being renewed, RFC 7030 sec. 4.2.2)");
955
+ reenrollGuard(opts.oldCert, _csrDer(csrInput)); // est/reenroll-* on mismatch, BEFORE the POST
956
+ return _enroll("simplereenroll", baseUrl, csrInput, opts);
957
+ });
958
+ }
959
+
711
960
  module.exports = {
961
+ cacerts: cacerts,
962
+ simpleenroll: simpleenroll,
963
+ simplereenroll: simplereenroll,
712
964
  transferDecode: transferDecode,
713
965
  transferEncode: transferEncode,
714
966
  splitMultipartMixed: splitMultipartMixed,
@@ -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,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 };