@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/CHANGELOG.md +30 -0
- package/README.md +7 -6
- package/index.js +11 -4
- package/lib/acme.js +511 -1
- package/lib/constants.js +8 -0
- package/lib/est.js +327 -75
- package/lib/framework-error.js +10 -0
- package/lib/http-retry-after.js +101 -0
- package/lib/http-transport.js +310 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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,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
|
|
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)
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
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,
|
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,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 };
|