@blamejs/pki 0.3.17 → 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 +14 -1
- package/README.md +6 -6
- package/lib/acme.js +511 -1
- package/lib/est.js +13 -69
- package/lib/http-retry-after.js +101 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,20 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## v0.3.
|
|
7
|
+
## v0.3.18 — 2026-07-25
|
|
8
|
+
|
|
9
|
+
The ACME client ships -- pki.acme drives the full RFC 8555 certificate-issuance flow over the shared node:https transport.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.acme.client(directoryUrl, opts) -- a stateful RFC 8555 ACME client over the shared pki.transport. newAccount / newOrder / getOrder / getAuthorization / getChallenge / respondToChallenge / finalize / pollOrder / pollAuthorization / downloadCertificate drive the issuance flow; revokeCert (RFC 8555 sec. 7.6, account-key or certificate-key signed), keyChange (sec. 7.3.5 account key rotation), deactivateAccount / deactivateAuthorization, and renewalInfo (ARI, RFC 9773) round out the account and certificate lifecycle. Signs every request with the account key (opts.accountKey / accountJwk / alg); reads are POST-as-GET; a problem+json response surfaces as a typed acme/server-problem.
|
|
14
|
+
- Fail-closed transport defaults for the client: HTTPS is required for the directory URL and every server-returned URL (acme/insecure-url), the default transport rejects a connection with no explicit trust anchor unless tls.useSystemStore is set (acme/no-trust-anchors), each JWS carries a fresh single-use nonce with a bounded badNonce retry, the poll loop is bounded by maxPolls and a total-wait budget and sleeps on a Retry-After via an injectable sleeper, and every response body is capped (acme/response-too-large).
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- The EST enrollment client now measures a string response body as UTF-8 -- the width it is decoded at -- so a non-ASCII body cannot undercount its byte length and slip past the response-size cap. The built-in node:https transport was unaffected (it returns raw bytes); this hardens a custom injected transport that returns string bodies.
|
|
19
|
+
|
|
20
|
+
## v0.3.17 — 2026-07-24
|
|
8
21
|
|
|
9
22
|
Refresh two development-only tooling dependencies to clear newly-disclosed advisories.
|
|
10
23
|
|
package/README.md
CHANGED
|
@@ -217,9 +217,9 @@ is callable today; nothing below is a stub.
|
|
|
217
217
|
| `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
|
|
218
218
|
| `pki.schema.csrattrs` | Parse DER EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC/RSA key-type conventions, the certification-request-info template). Unknown types tolerated (surfaced raw), fail-closed on structure and the RFC 9908 semantic MUSTs — `parse` |
|
|
219
219
|
| `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic answered only after the server is authenticated, and the issued certificate chosen by public-key match. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
220
|
-
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive — `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body }`. The toolkit's sole socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. Reused verbatim by the EST
|
|
220
|
+
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive — `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body }`. The toolkit's sole socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. Reused verbatim by the EST and ACME clients today (CMP next) — `https` |
|
|
221
221
|
| `pki.jose` | RFC 7515 Flattened JWS + RFC 7638 JWK thumbprints — `sign` / `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required/forbidden header rules as data; `base64url` is the strict RFC 4648 §5 codec (padding, non-alphabet, and non-canonical trailing bits rejected); `parseJson` is a bounded reader that refuses duplicate members at any depth; `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), so `alg:none`, an RS256→HS256 key confusion, and an all-zero ECDSA signature have no code path; `assertPublicJwk` refuses a JWK carrying private material so an exported private key is never published — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
222
|
-
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME
|
|
222
|
+
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow, and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, and `renewalInfo` (ARI) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. Over the message layer it composes: resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
223
223
|
| `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
224
224
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
225
225
|
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. `build(leaf, opts)` is the discovering complement (RFC 4158): from a leaf, an untrusted pool of candidate CA certificates, and a trust store, it finds the ordered leaf→anchor path `validate` accepts — name chaining plus the RFC 4158 §3.5 sort hints (AKI/SKI match, anchor-adjacent issuer, CA + keyCertSign, validity), a depth-first search with backtracking so the first path `validate` accepts wins, and a bounded search (chain-length cap, candidate-expansion cap, identity-tuple visited-set) so a cross-certificate cycle or Bridge-CA fan-out terminates deterministically; every accept flows through `validate` and its verdict is cross-checked against `openssl verify`. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
|
|
@@ -272,10 +272,10 @@ certificate and writes a DER (or `--pem`) SignedData to `--out` or stdout.
|
|
|
272
272
|
|
|
273
273
|
### What's coming
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
`
|
|
277
|
-
|
|
278
|
-
and ride this same core. See
|
|
275
|
+
The CMP HTTP transport (RFC 9811), the remaining EST network verbs
|
|
276
|
+
(`/serverkeygen`, `/csrattrs`, `/fullcmc`) and HTTP Digest auth, SCEP and CMC
|
|
277
|
+
enrollment, and additional NIST-on-ramp PQC signatures as the OID registry
|
|
278
|
+
admits them are on the roadmap and ride this same core. See
|
|
279
279
|
[ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
|
|
280
280
|
[CHANGELOG.md](CHANGELOG.md) for what has landed.
|
|
281
281
|
|
package/lib/acme.js
CHANGED
|
@@ -44,6 +44,10 @@ var constants = require("./constants");
|
|
|
44
44
|
var rfc3339 = require("./rfc3339");
|
|
45
45
|
var subtle = require("./webcrypto").webcrypto.subtle;
|
|
46
46
|
var frameworkError = require("./framework-error");
|
|
47
|
+
var httpTransport = require("./http-transport");
|
|
48
|
+
var retryAfter = require("./http-retry-after");
|
|
49
|
+
var guard = require("./guard-all");
|
|
50
|
+
var pkgVersion = require("../package.json").version;
|
|
47
51
|
|
|
48
52
|
var AcmeError = frameworkError.AcmeError;
|
|
49
53
|
function E(code, message, cause) { return new AcmeError(code, message, cause); }
|
|
@@ -302,7 +306,7 @@ function _canonicalizeIpv6(value) {
|
|
|
302
306
|
// illegal server transition the client fails closed on (acme/bad-transition).
|
|
303
307
|
var TRANSITIONS = {
|
|
304
308
|
challenge: { pending: ["processing", "valid", "invalid"], processing: ["processing", "valid", "invalid"] },
|
|
305
|
-
authorization: { pending: ["valid", "invalid"], valid: ["expired", "deactivated", "revoked"] },
|
|
309
|
+
authorization: { pending: ["valid", "invalid", "deactivated"], valid: ["expired", "deactivated", "revoked"] },
|
|
306
310
|
order: { pending: ["ready", "invalid"], ready: ["processing", "valid", "invalid"], processing: ["valid", "invalid"] },
|
|
307
311
|
};
|
|
308
312
|
|
|
@@ -1118,7 +1122,513 @@ function validateRenewalInfo(obj) {
|
|
|
1118
1122
|
return obj;
|
|
1119
1123
|
}
|
|
1120
1124
|
|
|
1125
|
+
// ---- the thin RFC 8555 client: a stateful session over the shared pki.transport ----
|
|
1126
|
+
|
|
1127
|
+
var CLIENT_DEFAULT_TIMEOUT = constants.TIME.seconds(30);
|
|
1128
|
+
var CLIENT_MAX_TIMEOUT = constants.TIME.seconds(600);
|
|
1129
|
+
// The RFC 8555 sec. 6.1 REQUIRED User-Agent, sent on every request (caller-overridable per request).
|
|
1130
|
+
var CLIENT_USER_AGENT = "blamejs-pki/" + pkgVersion + " node/" + process.versions.node;
|
|
1131
|
+
// A hard cap on the single-use nonce pool so a server that attaches a Replay-Nonce to every response
|
|
1132
|
+
// (permitted, RFC 8555 sec. 6.5) -- including the unauthenticated, poll-scheduled renewalInfo GET --
|
|
1133
|
+
// cannot grow it without bound; the oldest (least fresh) nonce is evicted first (CWE-770).
|
|
1134
|
+
var CLIENT_MAX_NONCE_POOL = 32;
|
|
1135
|
+
|
|
1136
|
+
// Parse + validate a URL BEFORE any request: not-parseable -> acme/bad-url, non-https -> acme/insecure-url
|
|
1137
|
+
// (RFC 8555 sec. 6.1 -- HTTPS is REQUIRED; the message-layer _isUrl accepts http, the client does not).
|
|
1138
|
+
// The validated string is returned VERBATIM (never url.href): the JWS protected `url` and the account
|
|
1139
|
+
// `kid` must be the exact server-provided value (RFC 8555 sec. 6.4/7.3), which URL normalization -- a
|
|
1140
|
+
// dropped :443, an added trailing slash -- would otherwise change, breaking the server's match.
|
|
1141
|
+
function _clientUrl(urlStr) {
|
|
1142
|
+
var s = String(urlStr);
|
|
1143
|
+
var url;
|
|
1144
|
+
try { url = new URL(s); }
|
|
1145
|
+
catch (e) { throw E("acme/bad-url", "the ACME URL did not parse: " + s, e); }
|
|
1146
|
+
if (url.protocol !== "https:") throw E("acme/insecure-url", "ACME requires https (RFC 8555 sec. 6.1), got " + url.protocol + " for " + s);
|
|
1147
|
+
// Reject a spelling the transport would REPAIR into a different path -- whitespace (WHATWG percent-
|
|
1148
|
+
// encodes or trims it) or a backslash (rewritten to `/`). The transport re-parses the URL to the
|
|
1149
|
+
// normalized path while the JWS `url` keeps the original, so the signed and requested URLs would differ.
|
|
1150
|
+
if (/[\s\\]/.test(s)) throw E("acme/bad-url", "an ACME URL must not contain whitespace or a backslash: " + JSON.stringify(s));
|
|
1151
|
+
// Reject any path the transport would NORMALIZE differently -- literal OR percent-encoded dot-segments
|
|
1152
|
+
// (`/..`, `/%2e%2e/`), or an authority-only URL with NO path (`https://ca.example`, where the transport
|
|
1153
|
+
// inserts a `/`) -- by comparing the raw path in `s` to the WHATWG-parsed pathname the transport sends as
|
|
1154
|
+
// the request target; a mismatch means the signed JWS `url` would keep a path the request never used. The
|
|
1155
|
+
// empty raw path is deliberately NOT coerced to `/`: that coercion would mask the authority-only case,
|
|
1156
|
+
// whose verbatim `s` (no slash) differs from the `/` the transport requests. (The authority may still
|
|
1157
|
+
// normalize -- a default :443 port, an uppercase host -- as that does not change the path; an encoded char
|
|
1158
|
+
// like `%41` or a dot inside a segment name round-trips unchanged.)
|
|
1159
|
+
var rawPath = s.replace(/^[a-z]+:\/\/[^/?#]*/i, "").split(/[?#]/)[0];
|
|
1160
|
+
if (rawPath !== url.pathname) throw E("acme/bad-url", "an ACME URL path is not canonical (the transport would normalize it): " + JSON.stringify(s));
|
|
1161
|
+
// Reject a fragment: the transport builds the request target from pathname+search only, so a `#frag`
|
|
1162
|
+
// would sign a JWS `url` (sec. 6.4) that differs from the URL actually requested, failing at the CA.
|
|
1163
|
+
if (url.hash) throw E("acme/bad-url", "an ACME URL must not contain a fragment: " + JSON.stringify(s));
|
|
1164
|
+
// Return the value VERBATIM (the server's exact spelling for the sec. 6.4/7.3 JWS match) -- a conforming
|
|
1165
|
+
// CA may emit a form WHATWG would normalize (a default :443 port, an uppercase host); the transport
|
|
1166
|
+
// re-parses it for the connection, so such a URL is honored rather than rejected as non-canonical.
|
|
1167
|
+
return s;
|
|
1168
|
+
}
|
|
1169
|
+
// Turn a server `Location` (which MAY be a relative URI-reference -- valid HTTP, common behind a proxy)
|
|
1170
|
+
// into the account/order URL: an absolute Location is used verbatim, a relative one is resolved against
|
|
1171
|
+
// the request URL, and the result is https-gated. A caller URL uses _clientUrl directly.
|
|
1172
|
+
function _resolveLocation(loc, base) {
|
|
1173
|
+
// An ABSOLUTE Location is used VERBATIM (its exact spelling -- an uppercase host, an explicit :443 --
|
|
1174
|
+
// for the sec. 6.4/7.3 JWS match, via _clientUrl). Only a RELATIVE reference is resolved against the
|
|
1175
|
+
// request URL first (the resolved href is then https-gated).
|
|
1176
|
+
var isAbsolute = true;
|
|
1177
|
+
try { new URL(loc); }
|
|
1178
|
+
catch (_e) { isAbsolute = false; }
|
|
1179
|
+
if (isAbsolute) return _clientUrl(loc);
|
|
1180
|
+
var abs;
|
|
1181
|
+
try { abs = new URL(loc, base).href; }
|
|
1182
|
+
catch (e) { throw E("acme/bad-url", "the Location header did not resolve to a valid URL: " + JSON.stringify(loc), e); }
|
|
1183
|
+
return _clientUrl(abs);
|
|
1184
|
+
}
|
|
1185
|
+
// The default poll sleeper: SPLITS a delay above Node's 32-bit setTimeout ceiling into chained chunks, so
|
|
1186
|
+
// a large (but parser-bounded, up to a year) Retry-After is actually honored -- a single oversized
|
|
1187
|
+
// setTimeout is silently clamped to 1 ms (TimeoutOverflowWarning), which would rapidly re-poll instead.
|
|
1188
|
+
var SETTIMEOUT_MAX_MS = 2147483647;
|
|
1189
|
+
function _defaultSleep(ms) {
|
|
1190
|
+
return new Promise(function (resolve) {
|
|
1191
|
+
(function step(remaining) {
|
|
1192
|
+
if (remaining <= SETTIMEOUT_MAX_MS) { setTimeout(resolve, remaining); return; }
|
|
1193
|
+
setTimeout(function () { step(remaining - SETTIMEOUT_MAX_MS); }, SETTIMEOUT_MAX_MS);
|
|
1194
|
+
})(ms);
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
function _clientTls(o) {
|
|
1198
|
+
var t = o.tls || {};
|
|
1199
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
1200
|
+
}
|
|
1201
|
+
// Split a PEM certificate chain (RFC 8555 sec. 7.4.2 application/pem-certificate-chain) into its DER certs.
|
|
1202
|
+
function _splitPemChain(text) {
|
|
1203
|
+
var out = [];
|
|
1204
|
+
var re = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
|
1205
|
+
var m;
|
|
1206
|
+
var lastEnd = 0;
|
|
1207
|
+
while ((m = re.exec(text)) !== null) {
|
|
1208
|
+
// application/pem-certificate-chain is ONLY PEM blocks separated by whitespace (RFC 8555 sec. 7.4.2) --
|
|
1209
|
+
// no explanatory text before, between, or after; reject any non-whitespace outside a block.
|
|
1210
|
+
if (text.slice(lastEnd, m.index).trim() !== "") throw E("acme/bad-certificate-chain", "the certificate chain contained non-whitespace text outside a PEM block (RFC 8555 sec. 7.4.2)");
|
|
1211
|
+
var der;
|
|
1212
|
+
// Decode the armor AND structurally parse it: valid PEM armor can wrap arbitrary base64, so without
|
|
1213
|
+
// the strict X.509 parse the download could return non-certificate bytes as the issued certificate.
|
|
1214
|
+
try { der = x509.pemDecode(m[0], "CERTIFICATE"); x509.parse(der); }
|
|
1215
|
+
catch (e) { throw E("acme/bad-certificate-chain", "a downloaded certificate did not decode as a valid X.509 certificate (RFC 8555 sec. 7.4.2)", e); }
|
|
1216
|
+
out.push(der);
|
|
1217
|
+
lastEnd = re.lastIndex;
|
|
1218
|
+
}
|
|
1219
|
+
if (text.slice(lastEnd).trim() !== "") throw E("acme/bad-certificate-chain", "the certificate chain contained trailing non-whitespace text outside a PEM block (RFC 8555 sec. 7.4.2)");
|
|
1220
|
+
if (!out.length) throw E("acme/bad-certificate-chain", "the certificate download carried no PEM certificate (RFC 8555 sec. 7.4.2)");
|
|
1221
|
+
return out;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* @primitive pki.acme.client
|
|
1226
|
+
* @signature pki.acme.client(directoryUrl, opts) -> client
|
|
1227
|
+
* @since 0.3.18
|
|
1228
|
+
* @status experimental
|
|
1229
|
+
* @spec RFC 8555, RFC 8737, RFC 8738, RFC 9773
|
|
1230
|
+
* @related pki.acme.newOrder, pki.transport.https
|
|
1231
|
+
*
|
|
1232
|
+
* A stateful RFC 8555 ACME client that drives the live directory flow over the shared `pki.transport`
|
|
1233
|
+
* (inject `opts.transport`, else a fail-closed `pki.transport.https`). It composes the shipped message
|
|
1234
|
+
* layer (the JWS builders + object validators + state machines) and owns only session state -- the
|
|
1235
|
+
* fetched directory, the single-use nonce pool (a fresh anti-replay nonce per JWS, badNonce bounded-
|
|
1236
|
+
* retried with the error's Replay-Nonce), and the account URL captured as the `kid`. `opts.accountKey`
|
|
1237
|
+
* (a private CryptoKey) + `opts.accountJwk` (its public JWK) + `opts.alg` sign every request. Every
|
|
1238
|
+
* request is https-only (`acme/insecure-url`); reads are POST-as-GET; a problem+json response is a
|
|
1239
|
+
* typed `acme/server-problem`; a poll sleeps on a bounded Retry-After via an injectable sleeper and is
|
|
1240
|
+
* capped by a poll count and a total-wait budget. Returns a client
|
|
1241
|
+
* object: `directory`, `newAccount`, `newOrder`, `getOrder` / `getAuthorization` / `getChallenge`,
|
|
1242
|
+
* `respondToChallenge`, `finalize`, `pollOrder` / `pollAuthorization`, `downloadCertificate`,
|
|
1243
|
+
* `revokeCert`, `deactivateAccount` / `deactivateAuthorization`, `keyChange`, `renewalInfo`.
|
|
1244
|
+
*
|
|
1245
|
+
* @opts
|
|
1246
|
+
* - `accountKey` / `accountJwk` / `alg` -- REQUIRED: the account private key, its public JWK, and the JWS alg.
|
|
1247
|
+
* - `transport` -- injectable transport(request) -> {status, headers, body}; default pki.transport.https.
|
|
1248
|
+
* - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity } for the default transport.
|
|
1249
|
+
* - `timeout` / `maxResponseBytes` / `maxRedirects` -- transport budgets; `maxNonceRetries` -- badNonce retry cap (default 1).
|
|
1250
|
+
* - `maxPolls` / `maxTotalWait` / `sleep` -- poll-loop budgets + an injectable sleeper; `clock` -- an injectable receipt clock (default Date.now) for a Retry-After HTTP-date.
|
|
1251
|
+
* @example
|
|
1252
|
+
* var acme = pki.acme.client("https://acme.example/directory", { accountKey, accountJwk, alg: "ES256", transport });
|
|
1253
|
+
* var acct = await acme.newAccount({ termsOfServiceAgreed: true });
|
|
1254
|
+
* var ord = await acme.newOrder({ identifiers: [{ type: "dns", value: "example.org" }] });
|
|
1255
|
+
*/
|
|
1256
|
+
function client(directoryUrl, opts) {
|
|
1257
|
+
opts = opts || {};
|
|
1258
|
+
if (!opts.accountKey) throw E("acme/bad-input", "client requires opts.accountKey (the account private key)");
|
|
1259
|
+
if (!_isObject(opts.accountJwk)) throw E("acme/bad-input", "client requires opts.accountJwk (the account public JWK)");
|
|
1260
|
+
if (!_isString(opts.alg)) throw E("acme/bad-input", "client requires opts.alg (the account-key JWS alg)");
|
|
1261
|
+
jose.assertPublicJwk(opts.accountJwk);
|
|
1262
|
+
var dirUrl = _clientUrl(directoryUrl);
|
|
1263
|
+
var accountKey = opts.accountKey, accountJwk = opts.accountJwk, alg = opts.alg;
|
|
1264
|
+
|
|
1265
|
+
var transport = opts.transport;
|
|
1266
|
+
if (!transport) {
|
|
1267
|
+
var t0 = opts.tls || {};
|
|
1268
|
+
var hasAnchors = t0.anchors !== undefined && t0.anchors !== null && !(Array.isArray(t0.anchors) && t0.anchors.length === 0);
|
|
1269
|
+
if (!hasAnchors && t0.useSystemStore !== true) throw E("acme/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true (RFC 8555 sec. 6.1)");
|
|
1270
|
+
transport = httpTransport.https({ E: E, errPrefix: "acme" });
|
|
1271
|
+
}
|
|
1272
|
+
var budgets = {
|
|
1273
|
+
tls: _clientTls(opts),
|
|
1274
|
+
timeout: guard.limits.cap(opts.timeout, "timeout", CLIENT_DEFAULT_TIMEOUT, { E: E, code: "acme/bad-input", min: 1, max: CLIENT_MAX_TIMEOUT }),
|
|
1275
|
+
maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: E, code: "acme/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
|
|
1276
|
+
maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "acme/bad-input", min: 0, max: 32 }),
|
|
1277
|
+
};
|
|
1278
|
+
var maxNonceRetries = guard.limits.cap(opts.maxNonceRetries, "maxNonceRetries", 1, { E: E, code: "acme/bad-input", min: 0, max: 8 });
|
|
1279
|
+
var maxPolls = guard.limits.cap(opts.maxPolls, "maxPolls", 20, { E: E, code: "acme/bad-input", min: 1, max: 1000 });
|
|
1280
|
+
var maxTotalWait = guard.limits.cap(opts.maxTotalWait, "maxTotalWait", retryAfter.MAX_RETRY_AFTER_SECONDS, { E: E, code: "acme/bad-input", min: 0, max: retryAfter.MAX_RETRY_AFTER_SECONDS });
|
|
1281
|
+
var sleep = typeof opts.sleep === "function" ? opts.sleep : _defaultSleep;
|
|
1282
|
+
// The receipt clock for a Retry-After HTTP-date: read FRESH per response (a fixed value would go stale
|
|
1283
|
+
// across polls, and a missing one would collapse every HTTP-date to a 1s delay). Injectable for tests.
|
|
1284
|
+
var clock = typeof opts.clock === "function" ? opts.clock : function () { return Date.now(); };
|
|
1285
|
+
|
|
1286
|
+
// session state
|
|
1287
|
+
var dirCache = null;
|
|
1288
|
+
var nonces = [];
|
|
1289
|
+
var kid = null;
|
|
1290
|
+
// The mTLS client credential is bound to the CONFIGURED directory origin: it is presented ONLY to that
|
|
1291
|
+
// origin, never to a different one reached via a redirect or advertised by a (possibly compromised)
|
|
1292
|
+
// directory. A CA that legitimately spans origins uses JWS auth, not a transport client certificate.
|
|
1293
|
+
var trustedOrigin = new URL(dirUrl).origin;
|
|
1294
|
+
function _tlsFor(url) {
|
|
1295
|
+
var t = budgets.tls;
|
|
1296
|
+
// Cross-origin, strip the ORIGIN-SPECIFIC identity: the client certificate/key (credential leak) AND
|
|
1297
|
+
// the pinned `servername` / `checkServerIdentity` (they drive SNI + RFC 6125 verification for the
|
|
1298
|
+
// trusted host, so applying them to another origin authenticates against the wrong identity).
|
|
1299
|
+
if (new URL(url).origin !== trustedOrigin && (t.cert != null || t.key != null || t.servername != null || t.checkServerIdentity != null)) {
|
|
1300
|
+
t = Object.assign({}, t);
|
|
1301
|
+
delete t.cert; delete t.key; delete t.servername; delete t.checkServerIdentity;
|
|
1302
|
+
}
|
|
1303
|
+
return t;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function _bodyText(res) { return Buffer.isBuffer(res.body) ? res.body.toString("utf8") : String(res.body || ""); }
|
|
1307
|
+
// The RAW bytes for jose.parseJson, so its FATAL UTF-8 validation runs on the wire body -- _bodyText's
|
|
1308
|
+
// lossy utf8 decode (replacement chars) would let a malformed RFC 8259 response slip through. A string
|
|
1309
|
+
// body (an injected test transport) carries no malformed UTF-8, so encoding it is lossless.
|
|
1310
|
+
function _jsonInput(res) { return Buffer.isBuffer(res.body) ? res.body : Buffer.from(String(res.body == null ? "" : res.body), "utf8"); }
|
|
1311
|
+
function _requireKid() { if (!kid) throw E("acme/no-account", "no account is set -- call newAccount before an authenticated request (RFC 8555 sec. 6.2)"); return kid; }
|
|
1312
|
+
function _harvestNonce(v) {
|
|
1313
|
+
if (typeof v !== "string" || v === "") return;
|
|
1314
|
+
try { jose.base64url.decode(v); }
|
|
1315
|
+
catch (_e) { return; } // ignore an invalid Replay-Nonce (RFC 8555 sec. 6.5.1)
|
|
1316
|
+
while (nonces.length >= CLIENT_MAX_NONCE_POOL) nonces.shift(); // evict the oldest, bound the pool (CWE-770)
|
|
1317
|
+
nonces.push(v);
|
|
1318
|
+
}
|
|
1319
|
+
function _isProblem(res) { return /application\/problem\+json/i.test(String(res.headers["content-type"] || "")); }
|
|
1320
|
+
function _serverProblem(res) {
|
|
1321
|
+
var prob = null;
|
|
1322
|
+
try { prob = jose.parseJson(_jsonInput(res)); if (_isObject(prob)) validateProblem(prob); }
|
|
1323
|
+
catch (_e) { /* allow:swallow-unverified an unparseable / schema-invalid problem doc; the typed acme/server-problem is surfaced from the HTTP status regardless */ }
|
|
1324
|
+
var type = (prob && prob.type) || "", detail = (prob && prob.detail) || "";
|
|
1325
|
+
var err = E("acme/server-problem", "the ACME server returned a problem (HTTP " + res.status + ")" + (type ? " " + type : "") + (detail ? ": " + detail : ""));
|
|
1326
|
+
err.problem = prob; err.httpStatus = res.status;
|
|
1327
|
+
return err;
|
|
1328
|
+
}
|
|
1329
|
+
function _json(res) {
|
|
1330
|
+
try { return jose.parseJson(_jsonInput(res)); }
|
|
1331
|
+
catch (e) { throw E("acme/bad-response", "the response body was not valid JSON: " + ((e && e.message) || String(e)), e); }
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// One request over the transport; lowercases headers, caps the body, harvests a Replay-Nonce off every
|
|
1335
|
+
// FINAL response (validated base64url, RFC 8555 sec. 6.5.1). A redirect on a SAFE method (GET/HEAD --
|
|
1336
|
+
// the unauthenticated directory / newNonce / renewalInfo reads) is followed to its https Location, up to
|
|
1337
|
+
// the maxRedirects budget; an authenticated POST is bound to its JWS `url` (sec. 6.4), so its redirect
|
|
1338
|
+
// is surfaced unchanged for the verb to reject. Every request carries the sec. 6.1 REQUIRED User-Agent.
|
|
1339
|
+
function _send(method, url, headers, body) { return _sendFollowing(method, url, headers, body, 0); }
|
|
1340
|
+
function _sendFollowing(method, url, headers, body, redirects) {
|
|
1341
|
+
var reqHeaders = Object.assign({ "user-agent": CLIENT_USER_AGENT }, headers || {});
|
|
1342
|
+
// _tlsFor binds the mTLS credential to the trusted origin, so EVERY request (this one, a redirect
|
|
1343
|
+
// target, or a directory-advertised URL on another host) presents the client certificate only there.
|
|
1344
|
+
return transport({ method: method, url: url, headers: reqHeaders, body: body, tls: _tlsFor(url), timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
|
|
1345
|
+
res = res || {};
|
|
1346
|
+
var h = {};
|
|
1347
|
+
Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
|
|
1348
|
+
// Measure an injected string body as UTF-8 -- the exact bytes _jsonInput re-encodes it to before the
|
|
1349
|
+
// decoder sees it -- so a non-ASCII body (e.g. multi-byte emoji, ~half the byte count under latin1)
|
|
1350
|
+
// cannot slip past the cap and reach the JSON parser.
|
|
1351
|
+
var blen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
|
|
1352
|
+
if (blen > budgets.maxResponseBytes) throw E("acme/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap");
|
|
1353
|
+
if ((method === "GET" || method === "HEAD") && res.status >= 300 && res.status < 400) {
|
|
1354
|
+
if (redirects >= budgets.maxRedirects) throw E("acme/too-many-redirects", "the redirect budget of " + budgets.maxRedirects + " was exceeded");
|
|
1355
|
+
if (!_isString(h["location"])) throw E("acme/bad-redirect", "a " + res.status + " redirect carried no Location header (RFC 7231 sec. 6.4)");
|
|
1356
|
+
// A Location may be relative (common behind a reverse proxy) -- resolve it against the request URL
|
|
1357
|
+
// before the https gate, so `/v2/directory` follows rather than failing the URL parse.
|
|
1358
|
+
var next;
|
|
1359
|
+
try { next = new URL(h["location"], url).href; }
|
|
1360
|
+
catch (e) { throw E("acme/bad-redirect", "the redirect Location did not resolve to a valid URL", e); }
|
|
1361
|
+
return _sendFollowing(method, _clientUrl(next), headers, body, redirects + 1);
|
|
1362
|
+
}
|
|
1363
|
+
_harvestNonce(h["replay-nonce"]);
|
|
1364
|
+
return { status: res.status, headers: h, body: res.body == null ? "" : res.body };
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function _getDirectory() {
|
|
1369
|
+
if (dirCache) return Promise.resolve(dirCache);
|
|
1370
|
+
return _send("GET", dirUrl, { accept: "application/json" }, null).then(function (res) {
|
|
1371
|
+
if (res.status !== 200) throw E("acme/bad-directory", "the ACME directory request returned HTTP " + res.status);
|
|
1372
|
+
var obj = _json(res);
|
|
1373
|
+
validate("directory", obj);
|
|
1374
|
+
dirCache = obj;
|
|
1375
|
+
return obj;
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
function _resource(name) {
|
|
1379
|
+
return _getDirectory().then(function (dir) {
|
|
1380
|
+
if (!_isString(dir[name])) throw E("acme/resource-unavailable", "the directory does not advertise the '" + name + "' resource (RFC 8555 sec. 7.1.1)");
|
|
1381
|
+
return _clientUrl(dir[name]); // the client is the sole https gate -- a directory advertising an http resource fails closed (RFC 8555 sec. 6.1)
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
// Take the FRESHEST pooled nonce (LIFO): every response's Replay-Nonce is appended, so on a badNonce
|
|
1385
|
+
// the error response's fresh nonce is the last harvested and is the one the retry consumes (RFC 8555
|
|
1386
|
+
// sec. 6.5) -- never a staler pooled nonce (e.g. one an unauthenticated read left behind).
|
|
1387
|
+
function _takeNonce() {
|
|
1388
|
+
if (nonces.length) return Promise.resolve(nonces.pop());
|
|
1389
|
+
var before = nonces.length;
|
|
1390
|
+
return _resource("newNonce").then(function (nn) { return _send("HEAD", nn, {}, null); }).then(function (res) {
|
|
1391
|
+
// newNonce must succeed (RFC 8555 sec. 7.2: 200/204). An ERROR response (a 4xx/5xx rate limit) also
|
|
1392
|
+
// carries a Replay-Nonce, but using it would silently proceed past the server's error -- discard the
|
|
1393
|
+
// harvested nonce and surface the problem instead.
|
|
1394
|
+
if (res.status !== 200 && res.status !== 204) { nonces.length = before; throw _serverProblem(res); }
|
|
1395
|
+
if (!nonces.length) throw E("acme/no-nonce", "the server did not provide a usable Replay-Nonce (RFC 8555 sec. 7.2)");
|
|
1396
|
+
return nonces.pop();
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// POST a JWS built by makeJws(nonce) as application/jose+json, and on a badNonce error retry (bounded)
|
|
1401
|
+
// with the error response's fresh nonce (RFC 8555 sec. 6.5 -- _takeNonce's LIFO pool surfaces it).
|
|
1402
|
+
// `accept` is the media type the request advertises (default application/json). This is the single
|
|
1403
|
+
// home of the badNonce retry, so every signed POST -- account-key or certificate-key -- inherits it.
|
|
1404
|
+
function _postJws(url, makeJws, accept, okStatuses) {
|
|
1405
|
+
var ok = okStatuses || [200];
|
|
1406
|
+
var attempt = 0;
|
|
1407
|
+
function once() {
|
|
1408
|
+
return _takeNonce().then(makeJws).then(function (jws) {
|
|
1409
|
+
return _send("POST", url, { "content-type": "application/jose+json", "accept": accept || "application/json" }, JSON.stringify(jws));
|
|
1410
|
+
}).then(function (res) {
|
|
1411
|
+
// An application/problem+json body is an ERROR regardless of the HTTP status -- a CA / proxy
|
|
1412
|
+
// returning a problem with an erroneous 2xx must NOT be accepted as success (RFC 7807 / RFC 8555).
|
|
1413
|
+
if (res.status >= 300 || _isProblem(res)) {
|
|
1414
|
+
if (_isProblem(res)) {
|
|
1415
|
+
var prob = null;
|
|
1416
|
+
try { prob = jose.parseJson(_jsonInput(res)); }
|
|
1417
|
+
catch (_e) { /* allow:swallow-unverified a non-JSON problem body is not a badNonce; prob stays null, falling through to the typed acme/server-problem throw */ }
|
|
1418
|
+
if (prob && typeof prob.type === "string" && /:badNonce$/.test(prob.type) && attempt < maxNonceRetries) { attempt++; return once(); }
|
|
1419
|
+
}
|
|
1420
|
+
throw _serverProblem(res);
|
|
1421
|
+
}
|
|
1422
|
+
// Enforce the PER-OPERATION success status (RFC 8555): only newAccount/newOrder may return 201
|
|
1423
|
+
// (created); every other verb requires 200, so a verb never acts on an incomplete result -- e.g.
|
|
1424
|
+
// keyChange rotating the session key or revokeCert reporting success on a 201/202.
|
|
1425
|
+
if (ok.indexOf(res.status) === -1) throw E("acme/unexpected-status", "an ACME POST returned an unexpected status " + res.status + " (expected " + ok.join("/") + ", RFC 8555)");
|
|
1426
|
+
return res;
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return once();
|
|
1430
|
+
}
|
|
1431
|
+
// Build an account-key JWS (kid or jwk mode) with a fresh nonce and POST it through the retrying _postJws.
|
|
1432
|
+
function _post(url, builder, extra, mode, accept, okStatuses) {
|
|
1433
|
+
return _postJws(url, function (nonce) {
|
|
1434
|
+
// The client-owned JWS session fields (signing key, alg, single-use nonce, protected url) are applied
|
|
1435
|
+
// AFTER the caller's payload options, so a payload carrying key/alg/nonce/url cannot override them and
|
|
1436
|
+
// desynchronize the protected header from the actual request (or reuse a nonce).
|
|
1437
|
+
var base = Object.assign({}, extra || {}, { key: accountKey, alg: alg, nonce: nonce, url: url });
|
|
1438
|
+
if (mode === "jwk") base.jwk = accountJwk; else base.kid = _requireKid();
|
|
1439
|
+
return builder(base);
|
|
1440
|
+
}, accept, okStatuses);
|
|
1441
|
+
}
|
|
1442
|
+
function _postAsGet(url) { return _post(url, postAsGet, null, "kid"); }
|
|
1443
|
+
|
|
1444
|
+
// ---- verbs (internal _-prefixed to avoid shadowing the module builders) ----
|
|
1445
|
+
function _directory() { return _getDirectory(); }
|
|
1446
|
+
|
|
1447
|
+
function _newAccount(payloadOpts) {
|
|
1448
|
+
return _resource("newAccount").then(function (url) {
|
|
1449
|
+
// newAccount is 201 (created) for a new account or 200 for an existing one (RFC 8555 sec. 7.3).
|
|
1450
|
+
return _post(url, newAccount, payloadOpts || {}, "jwk", undefined, [200, 201]).then(function (res) {
|
|
1451
|
+
var loc = res.headers["location"];
|
|
1452
|
+
if (!_isString(loc)) throw E("acme/no-account-url", "newAccount did not return an account URL in a Location header (RFC 8555 sec. 7.3)");
|
|
1453
|
+
// Validate the account object BEFORE committing the kid, so a malformed account (no status,
|
|
1454
|
+
// an unknown status) fails closed rather than enabling authenticated operations under a bad kid.
|
|
1455
|
+
var account = validate("account", _json(res));
|
|
1456
|
+
kid = _resolveLocation(loc, url);
|
|
1457
|
+
return { account: account, url: kid };
|
|
1458
|
+
});
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
function _newOrder(payloadOpts) {
|
|
1462
|
+
return _resource("newOrder").then(function (url) {
|
|
1463
|
+
// newOrder returns 201 Created ONLY (RFC 8555 sec. 7.4) -- a 200 is a non-conforming server / proxy.
|
|
1464
|
+
return _post(url, newOrder, payloadOpts || {}, "kid", undefined, [201]).then(function (res) {
|
|
1465
|
+
var loc = res.headers["location"];
|
|
1466
|
+
if (!_isString(loc)) throw E("acme/no-order-url", "newOrder did not return an order URL in a Location header (RFC 8555 sec. 7.4)");
|
|
1467
|
+
return { order: validate("order", _json(res)), url: _resolveLocation(loc, url) };
|
|
1468
|
+
});
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
function _getOrder(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("order", _json(res)); }); }
|
|
1472
|
+
function _getAuthorization(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("authorization", _json(res)); }); }
|
|
1473
|
+
function _getChallenge(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("challenge", _json(res)); }); }
|
|
1474
|
+
function _respondToChallenge(url) { return _post(_clientUrl(url), challengeResponse, null, "kid").then(function (res) { return validate("challenge", _json(res)); }); }
|
|
1475
|
+
|
|
1476
|
+
function _finalize(order, o) {
|
|
1477
|
+
o = o || {};
|
|
1478
|
+
if (!_isObject(order) || !_isString(order.finalize)) throw E("acme/bad-input", "finalize requires the order object with its finalize URL");
|
|
1479
|
+
if (!Array.isArray(order.identifiers)) throw E("acme/bad-input", "finalize requires the order's identifiers to enforce the RFC 8555 sec. 7.4 CSR-set match");
|
|
1480
|
+
// The sec. 7.4 CSR-vs-order check is ALWAYS bound to the order's OWN authoritative identifiers (the
|
|
1481
|
+
// client holds the validated order) -- never a caller-supplied set, which could otherwise loosen the
|
|
1482
|
+
// check so a CSR matching the replacement passes locally only to be rejected by the CA.
|
|
1483
|
+
return _post(_clientUrl(order.finalize), finalize, { csr: o.csr, identifiers: order.identifiers, accountJwk: accountJwk }, "kid").then(function (res) { return validate("order", _json(res)); });
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Poll a resource by POST-as-GET until it reaches a terminal state, honoring Retry-After (surfaced +
|
|
1487
|
+
// slept via the injectable sleep), bounded by maxPolls + maxTotalWait; each observed pair runs
|
|
1488
|
+
// assertTransition. A terminal `invalid` surfaces the object (the caller inspects .status).
|
|
1489
|
+
function _poll(kind, url, budget) {
|
|
1490
|
+
budget = budget || {};
|
|
1491
|
+
// A per-call poll override is validated through the SAME bounds as the constructor budget, so an
|
|
1492
|
+
// Infinity / NaN / over-ceiling value cannot turn a non-terminal resource into an unbounded loop.
|
|
1493
|
+
// Surface a bad override (or url) as a REJECTION, not a sync throw, so this async verb is uniform.
|
|
1494
|
+
var capPolls, capWait;
|
|
1495
|
+
try {
|
|
1496
|
+
capPolls = budget.maxPolls != null ? guard.limits.cap(budget.maxPolls, "maxPolls", maxPolls, { E: E, code: "acme/bad-input", min: 1, max: 1000 }) : maxPolls;
|
|
1497
|
+
capWait = budget.maxTotalWait != null ? guard.limits.cap(budget.maxTotalWait, "maxTotalWait", maxTotalWait, { E: E, code: "acme/bad-input", min: 0, max: retryAfter.MAX_RETRY_AFTER_SECONDS }) : maxTotalWait;
|
|
1498
|
+
url = _clientUrl(url);
|
|
1499
|
+
} catch (e) { return Promise.reject(e); }
|
|
1500
|
+
// An order poll terminates on every STABLE state (RFC 8555 sec. 7.1.6): `ready` (all authorizations
|
|
1501
|
+
// met -- the signal to finalize) and `valid` / `invalid`, not just valid/invalid -- otherwise a poll
|
|
1502
|
+
// run before finalize spins on the stable `ready` until the budget is exhausted.
|
|
1503
|
+
var terminal = kind === "order" ? { ready: true, valid: true, invalid: true } : { valid: true, invalid: true, deactivated: true, expired: true, revoked: true };
|
|
1504
|
+
var last = null, count = 0, waited = 0;
|
|
1505
|
+
function step() {
|
|
1506
|
+
count++;
|
|
1507
|
+
return _postAsGet(url).then(function (res) {
|
|
1508
|
+
var obj = validate(kind, _json(res));
|
|
1509
|
+
if (last && last !== obj.status) assertTransition(kind, last, obj.status);
|
|
1510
|
+
last = obj.status;
|
|
1511
|
+
if (terminal[obj.status]) return obj;
|
|
1512
|
+
if (count >= capPolls) throw E("acme/poll-exhausted", "the " + kind + " did not reach a terminal state within " + capPolls + " polls (RFC 8555 sec. 7.5.1)");
|
|
1513
|
+
var ra = res.headers["retry-after"];
|
|
1514
|
+
var delaySec = 1;
|
|
1515
|
+
if (typeof ra === "string" && ra.trim() !== "") { delaySec = retryAfter.parse(ra, { now: clock(), E: E, code: "acme/bad-retry-after" }).retryAfterSeconds; if (delaySec == null) delaySec = 1; }
|
|
1516
|
+
waited += delaySec;
|
|
1517
|
+
if (waited > capWait) throw E("acme/poll-exhausted", "the " + kind + " poll exceeded the total wait budget of " + capWait + " seconds");
|
|
1518
|
+
if (typeof budget.onRetryAfter === "function") budget.onRetryAfter(delaySec);
|
|
1519
|
+
return Promise.resolve(sleep(delaySec * constants.TIME.seconds(1))).then(step);
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return step();
|
|
1523
|
+
}
|
|
1524
|
+
function _pollOrder(url, budget) { return _poll("order", url, budget); }
|
|
1525
|
+
function _pollAuthorization(url, budget) { return _poll("authorization", url, budget); }
|
|
1526
|
+
|
|
1527
|
+
function _downloadCertificate(url) {
|
|
1528
|
+
return _post(_clientUrl(url), postAsGet, null, "kid", "application/pem-certificate-chain").then(function (res) {
|
|
1529
|
+
if (res.status !== 200) throw _serverProblem(res);
|
|
1530
|
+
// The certificate representation is bound to application/pem-certificate-chain (RFC 8555 sec. 7.4.2):
|
|
1531
|
+
// a 200 with a wrong media type (a proxy error, a misrouted response) fails closed even if its body
|
|
1532
|
+
// happens to contain parseable PEM. Match the media-type TOKEN exactly (before any ;-parameters), so
|
|
1533
|
+
// a lookalike like application/pem-certificate-chain-evil does not slip through a substring test.
|
|
1534
|
+
var ctToken = String(res.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
1535
|
+
if (ctToken !== "application/pem-certificate-chain") throw E("acme/bad-certificate-chain", "the certificate download returned an unexpected media type " + JSON.stringify(ctToken) + " (expected application/pem-certificate-chain, RFC 8555 sec. 7.4.2)");
|
|
1536
|
+
var chain = _splitPemChain(_bodyText(res));
|
|
1537
|
+
return { certificate: chain[0], chain: chain.slice(1), certificates: chain, alternates: [] };
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function _revokeCert(o) {
|
|
1542
|
+
o = o || {};
|
|
1543
|
+
// Surface bad input as a REJECTION (not a sync throw) so this async verb is uniform for a .catch caller.
|
|
1544
|
+
if (!Buffer.isBuffer(o.certificate)) return Promise.reject(E("acme/bad-input", "revokeCert requires a DER certificate Buffer (opts.certificate)"));
|
|
1545
|
+
// Certificate-key mode (RFC 8555 sec. 7.6) needs the COMPLETE triple certKey + certJwk + certAlg: an
|
|
1546
|
+
// incomplete set must NOT fall through to the account-key path (wrong credential), and certAlg must be
|
|
1547
|
+
// explicit -- inheriting the account alg would sign a different-family certificate key under the wrong
|
|
1548
|
+
// JWS algorithm (an ES256 account revoking with an RSA certificate key), which JOSE then rejects.
|
|
1549
|
+
var certKeyMode = o.certKey != null || _isObject(o.certJwk) || o.certAlg != null;
|
|
1550
|
+
if (certKeyMode && !(o.certKey != null && _isObject(o.certJwk) && _isString(o.certAlg))) return Promise.reject(E("acme/bad-input", "revokeCert certificate-key mode requires certKey, certJwk, AND certAlg together, or none (RFC 8555 sec. 7.6)"));
|
|
1551
|
+
var extra = { certificate: o.certificate };
|
|
1552
|
+
if (o.reason !== undefined) extra.reason = o.reason;
|
|
1553
|
+
return _resource("revokeCert").then(function (url) {
|
|
1554
|
+
if (certKeyMode) {
|
|
1555
|
+
// sign with the certificate key (jwk mode) rather than the account (RFC 8555 sec. 7.6), through
|
|
1556
|
+
// the same _postJws so it inherits the badNonce bounded retry the kid path has.
|
|
1557
|
+
return _postJws(url, function (nonce) {
|
|
1558
|
+
return revokeCert(Object.assign({}, extra, { key: o.certKey, alg: o.certAlg, nonce: nonce, url: url, jwk: o.certJwk }));
|
|
1559
|
+
}).then(function () { return true; });
|
|
1560
|
+
}
|
|
1561
|
+
return _post(url, revokeCert, extra, "kid").then(function () { return true; });
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function _deactivateAccount() { return _post(_requireKid(), deactivate, null, "kid").then(function (res) { return validate("account", _json(res)); }); }
|
|
1566
|
+
function _deactivateAuthorization(url) { return _post(_clientUrl(url), deactivate, null, "kid").then(function (res) { return validate("authorization", _json(res)); }); }
|
|
1567
|
+
|
|
1568
|
+
function _keyChange(o) {
|
|
1569
|
+
o = o || {};
|
|
1570
|
+
if (!o.newKey || !_isObject(o.newJwk) || !_isString(o.newAlg)) throw E("acme/bad-input", "keyChange requires newKey, newJwk, and newAlg");
|
|
1571
|
+
var account = _requireKid();
|
|
1572
|
+
return _resource("keyChange").then(function (url) {
|
|
1573
|
+
return _post(url, keyChange, { account: account, oldKey: accountJwk, newKey: o.newKey, newJwk: o.newJwk, newAlg: o.newAlg }, "kid").then(function (res) {
|
|
1574
|
+
// RFC 8555 sec. 7.3.5: a 200 means the server has COMMITTED the rollover (the status is gated to 200
|
|
1575
|
+
// by _post; a problem+json is already rejected). Rotate the session key FIRST so the client stays in
|
|
1576
|
+
// sync -- a bodyless or malformed account body must NOT leave the client on the old key while the
|
|
1577
|
+
// server holds the new one. The account object is optional + informational: parse it best-effort.
|
|
1578
|
+
accountKey = o.newKey; accountJwk = o.newJwk; alg = o.newAlg;
|
|
1579
|
+
var acct = null;
|
|
1580
|
+
if (_bodyText(res).trim() !== "") {
|
|
1581
|
+
try { acct = validate("account", _json(res)); }
|
|
1582
|
+
catch (_e) { acct = null; }
|
|
1583
|
+
}
|
|
1584
|
+
return { account: acct, url: account };
|
|
1585
|
+
});
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// ARI RenewalInfo (RFC 9773 sec. 4.1) -- the SOLE UNAUTHENTICATED GET (no JWS, no nonce).
|
|
1590
|
+
function _renewalInfo(certDer) {
|
|
1591
|
+
if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "renewalInfo requires a DER certificate Buffer");
|
|
1592
|
+
var certId = ariCertId(certDer);
|
|
1593
|
+
return _resource("renewalInfo").then(function (base) {
|
|
1594
|
+
// Append the certID to the PATH, before any query string (RFC 9773): a directory renewalInfo URL may
|
|
1595
|
+
// carry a query, so string concatenation would push the certID into the query rather than the path.
|
|
1596
|
+
var u = new URL(base);
|
|
1597
|
+
u.pathname = u.pathname.replace(/\/+$/, "") + "/" + certId;
|
|
1598
|
+
var url = _clientUrl(u.href);
|
|
1599
|
+
return _send("GET", url, { accept: "application/json" }, null).then(function (res) {
|
|
1600
|
+
if (res.status !== 200) throw _serverProblem(res);
|
|
1601
|
+
var obj = validateRenewalInfo(_json(res));
|
|
1602
|
+
var ra = res.headers["retry-after"];
|
|
1603
|
+
var retryAfterSeconds = null;
|
|
1604
|
+
if (typeof ra === "string" && ra.trim() !== "") retryAfterSeconds = retryAfter.parse(ra, { now: clock(), E: E, code: "acme/bad-retry-after" }).retryAfterSeconds;
|
|
1605
|
+
return { renewalInfo: obj, retryAfterSeconds: retryAfterSeconds };
|
|
1606
|
+
});
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
return {
|
|
1611
|
+
directory: _directory,
|
|
1612
|
+
newAccount: _newAccount,
|
|
1613
|
+
newOrder: _newOrder,
|
|
1614
|
+
getOrder: _getOrder,
|
|
1615
|
+
getAuthorization: _getAuthorization,
|
|
1616
|
+
getChallenge: _getChallenge,
|
|
1617
|
+
respondToChallenge: _respondToChallenge,
|
|
1618
|
+
finalize: _finalize,
|
|
1619
|
+
pollOrder: _pollOrder,
|
|
1620
|
+
pollAuthorization: _pollAuthorization,
|
|
1621
|
+
downloadCertificate: _downloadCertificate,
|
|
1622
|
+
revokeCert: _revokeCert,
|
|
1623
|
+
deactivateAccount: _deactivateAccount,
|
|
1624
|
+
deactivateAuthorization: _deactivateAuthorization,
|
|
1625
|
+
keyChange: _keyChange,
|
|
1626
|
+
renewalInfo: _renewalInfo,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1121
1630
|
module.exports = {
|
|
1631
|
+
client: client,
|
|
1122
1632
|
validate: validate,
|
|
1123
1633
|
validateProblem: validateProblem,
|
|
1124
1634
|
validateRenewalInfo: validateRenewalInfo,
|
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/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:38b1d773-a2de-428b-848c-aece31c991f9",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-25T19:26:20.442Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.3.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.18",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.18",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.3.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.3.18",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.3.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.18",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|