@blamejs/pki 0.3.28 → 0.3.29
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 +15 -0
- package/README.md +1 -1
- package/lib/acme.js +674 -42
- package/lib/http-retry-after.js +13 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ 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.29 — 2026-08-02
|
|
8
|
+
|
|
9
|
+
The pki.acme client rounds out its RFC 8555 / RFC 9773 surface -- pre-authorize an identifier with client.newAuthz, choose among alternate issuance chains in client.downloadCertificate, and schedule renewal from the CA's ARI window with client.renewalWindow.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- client.newAuthz(identifier) pre-authorizes a single identifier ahead of placing an order (RFC 8555 sec. 7.4.1): it POSTs the identifier to the directory's newAuthz resource (kid-signed) and returns { authorization, url } for the created authorization -- normally pending, or an already-valid one when the CA has out-of-band authorization for the identifier. A wildcard identifier is refused before any request (pre-authorization of a wildcard is not defined), an unadvertised newAuthz resource fails closed, a 201 without a Location fails closed, and the returned authorization is validated to name exactly the identifier requested and to be a non-wildcard authorization the flow can proceed with -- one that names a different identifier, is marked as a broader wildcard grant, or is in a terminal failed state is rejected rather than acted on.
|
|
14
|
+
- client.downloadCertificate(url, opts?) gains selectChain + maxAlternates to choose among the alternate issuance chains a CA offers (RFC 8555 sec. 7.4.2, RFC 8288 Link). Without a selector it returns the primary chain and now also alternates -- the resolved URLs of every Link rel="alternate" the certificate response advertised. With selectChain, it evaluates the primary chain first, then each alternate in header order, and resolves to the first chain the predicate accepts (selectChain receives { certificate, chain, certificates }); none accepted fails closed. The alternate Link header is untrusted: it is parsed strictly against RFC 8288 (rel matched as a whole token, case-insensitively; a malformed header or a non-https target fails closed), the extra signed fetches are bounded by maxAlternates (default 8, over-budget fails closed), duplicate resolved URLs are de-duplicated, and an alternate whose end-entity certificate differs from the primary's is rejected rather than substituted. Each alternate is fetched by the same POST-as-GET path, inheriting the media-type, size, and strict-chain-parse gates.
|
|
15
|
+
- client.renewalWindow(certDer, opts?) turns the CA's ARI renewal window into a scheduling decision (RFC 9773 sec. 4.2 / 4.3). It composes the unauthenticated renewalInfo GET, selects a uniform-random instant within the CA's suggested window -- bounded by the certificate's own expiry, so the chosen time is never after notAfter -- (an injectable random and clock make the decision deterministic and let a caller ask 'renew as of time T?') so many clients do not renew at the same edge, and returns { suggestedWindow, selectedTime, renewNow, retryAfterSeconds, explanationURL } -- renewNow is set when the selected instant is already in the past. It refuses before any request for a certificate already past its notAfter (nothing to renew) or one the caller marks with replaced: true (already superseded), and retryAfterSeconds always carries a poll delay: the CA's Retry-After clamped to [60s, 24h], or a sensible default when the CA omits it. Pass a prior result back as opts.previous to REUSE its selectedTime while the CA's window is unchanged (RFC 9773 sec. 4.2), so a client that refreshes ARI on each poll keeps one stable renewal instant instead of re-randomizing it. The helper returns the decision as data; it never sleeps or schedules on a background timer.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- client.renewalInfo(certDer) now refuses an already-expired certificate before issuing the unauthenticated RenewalInfo GET (RFC 9773 sec. 4.3: a client MUST NOT check a certificate's RenewalInfo after it has expired), throwing acme/certificate-expired -- the same pre-fetch expiry gate renewalWindow applies.
|
|
20
|
+
- pki.acme.client now rejects a server-provided URL whose host is an IPv4-address form (hex, octal, decimal, or shorthand) that the WHATWG URL parser would coerce to a different, often loopback or internal, address -- the account-signed JWS url (RFC 8555 sec. 6.4) must name the exact authority the request connects to, so such a URL can no longer steer an authenticated request to an unintended host (SSRF hardening).
|
|
21
|
+
|
|
7
22
|
## v0.3.28 — 2026-08-01
|
|
8
23
|
|
|
9
24
|
pki.est gains its remaining RFC 7030 network verbs -- request a server-generated key pair with pki.est.serverkeygen, fetch the CA's CSR-attributes policy with pki.est.csrattrs, and authenticate with HTTP Digest as an alternative to HTTP Basic.
|
package/README.md
CHANGED
|
@@ -219,7 +219,7 @@ is callable today; nothing below is a stub.
|
|
|
219
219
|
| `pki.est` | RFC 7030 / 8951 / 9908 / 7616 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` / `serverkeygen` / `csrattrs` 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 or Digest (RFC 7616; SHA-256 / SHA-512-256, MD5 and no-qop refused by default) answered only after the server is authenticated, and the issued certificate chosen by public-key match. `serverkeygen` requests a server-generated key (cleartext or an opaque CMS EnvelopedData, encryption bound to the CSR's key-identifier attribute, over a confidentiality-bearing cipher); `csrattrs` fetches the CA's RFC 9908 attributes policy. 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`, `serverkeygen`, `csrattrs`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
220
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, ACME, and CMP clients — `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 — `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`,
|
|
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` / `newAuthz` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow — `newAuthz` pre-authorizes a single identifier (§7.4.1) and `downloadCertificate` picks among alternate issuance chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`) — and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, `renewalInfo` (ARI), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) 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`. **Opt-in AIA `caIssuers` fetching** (`opts.fetchAia: true`) discovers a *missing* intermediate from a certificate's Authority Information Access URL (RFC 5280 §4.2.2.1) over `pki.transport`, triggered only on a pool miss — SSRF/amplification-bounded (https-only, a total fetch budget that silently caps fetching rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size + certificate-count cap, no redirect following; every fault a silent skip), with the TLS trust (`opts.tls`) kept distinct from the PKI `trustAnchors` and every fetched certificate remaining untrusted pool material that still flows through `validate` (never a trust anchor). Off by default — the default build is byte-identical offline. Pure and re-entrant, fail-closed — `validate`, `build`, `crlChecker`, `ocspChecker` |
|
package/lib/acme.js
CHANGED
|
@@ -42,7 +42,8 @@ var csr = require("./schema-csr");
|
|
|
42
42
|
var pkix = require("./schema-pkix");
|
|
43
43
|
var constants = require("./constants");
|
|
44
44
|
var rfc3339 = require("./rfc3339");
|
|
45
|
-
var
|
|
45
|
+
var webcrypto = require("./webcrypto").webcrypto;
|
|
46
|
+
var subtle = webcrypto.subtle;
|
|
46
47
|
var frameworkError = require("./framework-error");
|
|
47
48
|
var httpTransport = require("./http-transport");
|
|
48
49
|
var retryAfter = require("./http-retry-after");
|
|
@@ -60,6 +61,18 @@ function _isString(v) { return typeof v === "string"; }
|
|
|
60
61
|
// downstream expiry / renewal-window comparison never runs on an impossible instant (month 13, a :60
|
|
61
62
|
// leap second, a rolled-over value). pki.ct's log-list window parse composes the same primitive.
|
|
62
63
|
function _isRfc3339(v) { return rfc3339.isValid(v); }
|
|
64
|
+
// A uniform random draw in [0, 1) from the platform CSPRNG (48 bits of entropy). Used ONLY to spread the
|
|
65
|
+
// ARI renewal instant across the CA's suggested window (RFC 9773 sec. 4.2) so many clients do not stampede
|
|
66
|
+
// the same edge -- a load-distribution measure, not a secret. Injectable via renewalWindow opts.random.
|
|
67
|
+
var _RANDOM_DENOM = Math.pow(2, 48); // 6 bytes -> a uniform fraction in [0, 1)
|
|
68
|
+
function _defaultRandom() { return Buffer.from(webcrypto.getRandomValues(new Uint8Array(6))).readUIntBE(0, 6) / _RANDOM_DENOM; }
|
|
69
|
+
// RFC 9773 sec. 4.3.2: the ARI Retry-After bounds the re-poll cadence -- clamp it to [60s, 24h] so a
|
|
70
|
+
// hostile (or absent) value can neither hammer the CA nor defer the next check indefinitely. When the CA
|
|
71
|
+
// omits Retry-After (permitted), the decision helper still owes the caller a poll interval, so it returns a
|
|
72
|
+
// reasonable default in-range (sec. 4.3.2 "the client SHOULD use a reasonable default") rather than null.
|
|
73
|
+
var RENEWAL_RETRY_MIN_SECONDS = 60;
|
|
74
|
+
var RENEWAL_RETRY_MAX_SECONDS = constants.TIME.days(1) / constants.TIME.seconds(1);
|
|
75
|
+
var RENEWAL_RETRY_DEFAULT_SECONDS = constants.TIME.hours(6) / constants.TIME.seconds(1);
|
|
63
76
|
// A URL string: an absolute http(s) URI with a real host (RFC 3986). ACME URLs are
|
|
64
77
|
// server-provided endpoints downstream transport will trust, so they are PARSED
|
|
65
78
|
// (not prefix-matched) -- a malformed value like "https://[" or a hostless
|
|
@@ -138,7 +151,10 @@ var SPECS = {
|
|
|
138
151
|
{ name: "identifier", type: "identifier", required: true },
|
|
139
152
|
{ name: "status", type: "string", required: true, enum: STATUS.authorization },
|
|
140
153
|
{ name: "expires", type: "rfc3339", requiredWhen: function (o) { return o.status === "valid"; } },
|
|
141
|
-
|
|
154
|
+
// challenges is required (the key is present) but MAY be empty for an already-"valid" authorization the CA
|
|
155
|
+
// granted out of band (RFC 8555 sec. 7.1.4 / 7.4.1 -- no challenge was validated); a pending/other authz still
|
|
156
|
+
// needs at least the one challenge the client fulfills.
|
|
157
|
+
{ name: "challenges", type: "array", required: true, minItems: function (o) { return o.status === "valid" ? 0 : 1; }, elemType: "challenge" },
|
|
142
158
|
{ name: "wildcard", type: "boolean" },
|
|
143
159
|
],
|
|
144
160
|
challenge: [
|
|
@@ -155,7 +171,7 @@ var SPECS = {
|
|
|
155
171
|
],
|
|
156
172
|
};
|
|
157
173
|
|
|
158
|
-
function _checkType(kind, field, value) {
|
|
174
|
+
function _checkType(kind, field, value, obj) {
|
|
159
175
|
switch (field.type) {
|
|
160
176
|
case "string": if (!_isString(value)) return "must be a string"; break;
|
|
161
177
|
case "url": if (!_isUrl(value)) return "must be a URL string"; break;
|
|
@@ -166,7 +182,8 @@ function _checkType(kind, field, value) {
|
|
|
166
182
|
case "identifier": _validateIdentifier(value); break;
|
|
167
183
|
case "array":
|
|
168
184
|
if (!Array.isArray(value)) return "must be an array";
|
|
169
|
-
|
|
185
|
+
var minItems = typeof field.minItems === "function" ? field.minItems(obj) : field.minItems; // conditional (sec. 7.1.4)
|
|
186
|
+
if (minItems && value.length < minItems) return "must have at least " + minItems + " element(s)";
|
|
170
187
|
for (var i = 0; i < value.length; i++) {
|
|
171
188
|
if (field.elemType === "url" && !_isUrl(value[i])) return "element " + i + " must be a URL string";
|
|
172
189
|
if (field.elemType === "contact" && !_isUriString(value[i])) return "element " + i + " must be a URI string";
|
|
@@ -202,7 +219,7 @@ function _validate(kind, obj) {
|
|
|
202
219
|
if (field.enum && field.enum.indexOf(obj[field.name]) === -1) {
|
|
203
220
|
throw E("acme/bad-status", "the " + kind + " " + field.name + " " + JSON.stringify(obj[field.name]) + " is not a recognized value");
|
|
204
221
|
}
|
|
205
|
-
var err = _checkType(kind, field, obj[field.name]);
|
|
222
|
+
var err = _checkType(kind, field, obj[field.name], obj);
|
|
206
223
|
if (err) throw E("acme/bad-" + _codeSlug(kind), "the " + kind + " field " + JSON.stringify(field.name) + " " + err);
|
|
207
224
|
}
|
|
208
225
|
return obj;
|
|
@@ -216,15 +233,19 @@ function _validate(kind, obj) {
|
|
|
216
233
|
// round-trip. `_validateIdentifier` rejects a value beginning `*.` (that is only
|
|
217
234
|
// legal in an order identifier, checked separately).
|
|
218
235
|
function _validateIdentifier(id) {
|
|
219
|
-
if (!_isObject(id)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
236
|
+
if (!_isObject(id)) throw E("acme/bad-identifier", "an identifier must be { type, value } strings");
|
|
237
|
+
var type = id.type, value = id.value; // read each field ONCE (a getter-backed / inherited prop is captured here)
|
|
238
|
+
if (!_isString(type) || !_isString(value)) throw E("acme/bad-identifier", "an identifier must be { type, value } strings");
|
|
239
|
+
if (type === "dns") {
|
|
240
|
+
if (value.indexOf("*.") === 0) throw E("acme/bad-identifier", "a wildcard *. value is not permitted in an authorization identifier (RFC 8555 sec. 7.1.4)");
|
|
241
|
+
_assertDnsName(value);
|
|
242
|
+
} else if (type === "ip") {
|
|
243
|
+
_assertIpAddress(value);
|
|
225
244
|
}
|
|
226
|
-
//
|
|
227
|
-
|
|
245
|
+
// Return a CANONICAL { type, value } built from the single read above -- an unrecognized type is surfaced raw
|
|
246
|
+
// (a server may add types). Callers serialize THIS, not the caller's object, so an inherited/getter-backed field
|
|
247
|
+
// is not dropped by JSON.stringify and an enumerable extra property is not sent to the CA.
|
|
248
|
+
return { type: type, value: value };
|
|
228
249
|
}
|
|
229
250
|
|
|
230
251
|
// A DNS name (each label lowercase letters/digits/hyphen, not leading/trailing
|
|
@@ -758,9 +779,11 @@ async function externalAccountBinding(o) {
|
|
|
758
779
|
// only (sec. 7.1.3). An `ip` identifier has no wildcard form. Everything else is
|
|
759
780
|
// the shared _validateIdentifier syntax on the base name.
|
|
760
781
|
function _validateOrderIdentifier(id) {
|
|
761
|
-
if (!_isObject(id)
|
|
762
|
-
|
|
763
|
-
|
|
782
|
+
if (!_isObject(id)) throw E("acme/bad-identifier", "an order identifier must be { type, value } strings");
|
|
783
|
+
var type = id.type, value = id.value; // read each field ONCE (a getter-backed / inherited prop is captured here)
|
|
784
|
+
if (!_isString(type) || !_isString(value)) throw E("acme/bad-identifier", "an order identifier must be { type, value } strings");
|
|
785
|
+
if (type === "dns") {
|
|
786
|
+
var v = value;
|
|
764
787
|
if (v.indexOf("*.") === 0) {
|
|
765
788
|
v = v.slice(2);
|
|
766
789
|
if (v.indexOf("*") !== -1) throw E("acme/bad-identifier", "a wildcard order identifier permits exactly one leading *. label (RFC 8555 sec. 7.1.3)");
|
|
@@ -768,11 +791,13 @@ function _validateOrderIdentifier(id) {
|
|
|
768
791
|
throw E("acme/bad-identifier", "a wildcard must be a single leading *. label (RFC 8555 sec. 7.1.3)");
|
|
769
792
|
}
|
|
770
793
|
_assertDnsName(v);
|
|
771
|
-
} else if (
|
|
772
|
-
if (
|
|
773
|
-
_assertIpAddress(
|
|
794
|
+
} else if (type === "ip") {
|
|
795
|
+
if (value.indexOf("*") !== -1) throw E("acme/bad-identifier", "an ip identifier has no wildcard form (RFC 8738)");
|
|
796
|
+
_assertIpAddress(value);
|
|
774
797
|
}
|
|
775
|
-
|
|
798
|
+
// Canonical { type, value } from the single read (the ORIGINAL value, wildcard `*.` included) -- see
|
|
799
|
+
// _validateIdentifier: callers serialize THIS, so the wire payload cannot diverge from what was validated.
|
|
800
|
+
return { type: type, value: value };
|
|
776
801
|
}
|
|
777
802
|
|
|
778
803
|
/**
|
|
@@ -794,14 +819,39 @@ function _validateOrderIdentifier(id) {
|
|
|
794
819
|
*/
|
|
795
820
|
function newOrder(o) {
|
|
796
821
|
if (!_isObject(o) || !Array.isArray(o.identifiers) || o.identifiers.length === 0) throw E("acme/bad-order", "newOrder requires a non-empty identifiers array (RFC 8555 sec. 7.4)");
|
|
797
|
-
|
|
798
|
-
|
|
822
|
+
// Serialize the CANONICAL { type, value } each validator returns, never the caller's objects (which may carry
|
|
823
|
+
// getter-backed / inherited fields JSON.stringify would drop, or extra enumerable fields it would send).
|
|
824
|
+
var payload = { identifiers: o.identifiers.map(_validateOrderIdentifier) };
|
|
799
825
|
if (o.notBefore !== undefined) { if (!_isRfc3339(o.notBefore)) throw E("acme/bad-order", "notBefore must be an RFC 3339 date-time"); payload.notBefore = o.notBefore; }
|
|
800
826
|
if (o.notAfter !== undefined) { if (!_isRfc3339(o.notAfter)) throw E("acme/bad-order", "notAfter must be an RFC 3339 date-time"); payload.notAfter = o.notAfter; }
|
|
801
827
|
if (o.replaces !== undefined) { if (!_isString(o.replaces)) throw E("acme/bad-order", "replaces must be an ARI certID string (RFC 9773 sec. 5)"); payload.replaces = o.replaces; }
|
|
802
828
|
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
|
|
803
829
|
}
|
|
804
830
|
|
|
831
|
+
/**
|
|
832
|
+
* @primitive pki.acme.newAuthz
|
|
833
|
+
* @signature pki.acme.newAuthz(opts) -> flattened JWS
|
|
834
|
+
* @since 0.3.29
|
|
835
|
+
* @status experimental
|
|
836
|
+
* @spec RFC 8555, RFC 8555 sec. 7.4.1
|
|
837
|
+
* @related pki.acme.newOrder, pki.acme.client
|
|
838
|
+
*
|
|
839
|
+
* Build a kid-signed pre-authorization request (RFC 8555 sec. 7.4.1): a Flattened JWS over EXACTLY
|
|
840
|
+
* `{ identifier: { type, value } }` -- a SINGLE identifier object, not an array. The identifier is validated as
|
|
841
|
+
* an AUTHORIZATION identifier, which REJECTS a wildcard `*.` value (pre-authorization cannot authorize a
|
|
842
|
+
* wildcard name); a bad type / value is `acme/bad-identifier`. `opts` = `{ key, alg, nonce, url, kid, identifier }`.
|
|
843
|
+
* The `client.newAuthz(identifier)` verb composes this, POSTs it to the directory `newAuthz` resource, and
|
|
844
|
+
* returns the validated authorization bound to the requested identifier.
|
|
845
|
+
*
|
|
846
|
+
* @example
|
|
847
|
+
* await pki.acme.newAuthz({ key, alg: "ES256", nonce, url, kid, identifier: { type: "dns", value: "example.org" } });
|
|
848
|
+
*/
|
|
849
|
+
function newAuthz(o) {
|
|
850
|
+
if (!_isObject(o) || !_isObject(o.identifier)) throw E("acme/bad-identifier", "newAuthz requires a single identifier object (RFC 8555 sec. 7.4.1)");
|
|
851
|
+
// Serialize the canonical { type, value } the validator returns, never the caller's object (see _validateIdentifier).
|
|
852
|
+
return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, { identifier: _validateIdentifier(o.identifier) });
|
|
853
|
+
}
|
|
854
|
+
|
|
805
855
|
// The account key's SubjectPublicKeyInfo DER, imported from its public JWK, so a
|
|
806
856
|
// finalize CSR carrying that same key is caught (sec. 11.1). The DER is canonical
|
|
807
857
|
// (node emits canonical SPKI), matched byte-for-byte against the CSR's strict-DER SPKI.
|
|
@@ -1144,10 +1194,35 @@ function _clientUrl(urlStr) {
|
|
|
1144
1194
|
try { url = new URL(s); }
|
|
1145
1195
|
catch (e) { throw E("acme/bad-url", "the ACME URL did not parse: " + s, e); }
|
|
1146
1196
|
if (url.protocol !== "https:") throw E("acme/insecure-url", "ACME requires https (RFC 8555 sec. 6.1), got " + url.protocol + " for " + s);
|
|
1197
|
+
// An ACME URL never carries userinfo. The transport connects to the host and does NOT put userinfo in the
|
|
1198
|
+
// request target, so a signed JWS `url` with userinfo would not match the URL the request is directed to
|
|
1199
|
+
// (RFC 8555 sec. 6.4); refuse it rather than sign a credential-bearing URL that steers nothing. Test the RAW
|
|
1200
|
+
// authority for a literal "@", not url.username/password: an EMPTY userinfo ("https://@host", "https://:@host")
|
|
1201
|
+
// leaves both empty while the verbatim URL still carries the "@" the transport drops.
|
|
1202
|
+
if (((/^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(s) || [])[1] || "").indexOf("@") !== -1) throw E("acme/bad-url", "an ACME URL must not contain userinfo: " + JSON.stringify(s));
|
|
1147
1203
|
// Reject a spelling the transport would REPAIR into a different path -- whitespace (WHATWG percent-
|
|
1148
1204
|
// encodes or trims it) or a backslash (rewritten to `/`). The transport re-parses the URL to the
|
|
1149
1205
|
// normalized path while the JWS `url` keeps the original, so the signed and requested URLs would differ.
|
|
1150
1206
|
if (/[\s\\]/.test(s)) throw E("acme/bad-url", "an ACME URL must not contain whitespace or a backslash: " + JSON.stringify(s));
|
|
1207
|
+
// The path/query round-trip checks below cover only pathname+search; apply the shared structural authority
|
|
1208
|
+
// guard too, so a repaired AUTHORITY (a bracket in userinfo -- "a[b@host" -> "a%5Bb@host", a double "@") is
|
|
1209
|
+
// rejected on THIS direct path (a directory / Location / caller URL) exactly as the Link-alternate path
|
|
1210
|
+
// already rejects it. Otherwise the verbatim URL would be signed (sec. 6.4) while the transport connects to a
|
|
1211
|
+
// different authority. (An uppercase host / explicit :443 is NOT a repair here -- it is normalized, not
|
|
1212
|
+
// rewritten to a different authority -- and is deliberately honored below, so it is not flagged.)
|
|
1213
|
+
if (_uriStructurallyInvalid(s)) throw E("acme/bad-url", "an ACME URL authority is not canonical (the transport would repair it): " + JSON.stringify(s));
|
|
1214
|
+
// Reject a HOST the transport would rewrite to a DIFFERENT authority. WHATWG's special-scheme parser coerces an
|
|
1215
|
+
// IPv4-address form -- hex ("0x7f.1"), octal ("0177.0.0.1"), decimal ("2130706433"), shorthand ("127.1") -- into
|
|
1216
|
+
// a dotted-quad ("127.0.0.1"), so the JWS `url` would be signed over the raw host while the transport connects to
|
|
1217
|
+
// a different (often loopback / internal, an SSRF-adjacent) address. Compare the raw host (ASCII-lowercased, the
|
|
1218
|
+
// only tolerated normalization) to the parsed hostname: uppercase-host and a default :443 still match, an IPv4
|
|
1219
|
+
// coercion or a non-canonical IPv6 literal does not. (path/query/fragment are checked separately below.)
|
|
1220
|
+
var _auth = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(s);
|
|
1221
|
+
if (_auth) {
|
|
1222
|
+
var _hp = _auth[1].slice(_auth[1].lastIndexOf("@") + 1); // strip any userinfo (does not route the request)
|
|
1223
|
+
var _rawHost = _hp.charAt(0) === "[" ? _hp.slice(0, _hp.indexOf("]") + 1) : _hp.split(":")[0]; // strip the port
|
|
1224
|
+
if (_rawHost.toLowerCase() !== url.hostname) throw E("acme/bad-url", "an ACME URL host is not canonical (the transport would rewrite it): " + JSON.stringify(s));
|
|
1225
|
+
}
|
|
1151
1226
|
// Reject any path the transport would NORMALIZE differently -- literal OR percent-encoded dot-segments
|
|
1152
1227
|
// (`/..`, `/%2e%2e/`), or an authority-only URL with NO path (`https://ca.example`, where the transport
|
|
1153
1228
|
// inserts a `/`) -- by comparing the raw path in `s` to the WHATWG-parsed pathname the transport sends as
|
|
@@ -1158,9 +1233,19 @@ function _clientUrl(urlStr) {
|
|
|
1158
1233
|
// like `%41` or a dot inside a segment name round-trips unchanged.)
|
|
1159
1234
|
var rawPath = s.replace(/^[a-z]+:\/\/[^/?#]*/i, "").split(/[?#]/)[0];
|
|
1160
1235
|
if (rawPath !== url.pathname) throw E("acme/bad-url", "an ACME URL path is not canonical (the transport would normalize it): " + JSON.stringify(s));
|
|
1236
|
+
// The query round-trips too (the transport sends pathname+search): an EMPTY query ("...?") -- which WHATWG
|
|
1237
|
+
// drops (url.search === "") while the verbatim URL keeps the "?" -- or any query the parser would rewrite
|
|
1238
|
+
// signs a JWS `url` differing from the request target. A non-empty canonical query (`?a=b`) round-trips and
|
|
1239
|
+
// is honored. (The fragment is rejected below, so the first raw "?" is the query delimiter.)
|
|
1240
|
+
var qi = s.indexOf("?");
|
|
1241
|
+
var rawQuery = qi === -1 ? "" : s.slice(qi).split("#")[0];
|
|
1242
|
+
if (rawQuery !== url.search) throw E("acme/bad-url", "an ACME URL query is not canonical (the transport would normalize it): " + JSON.stringify(s));
|
|
1161
1243
|
// 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
|
-
|
|
1244
|
+
// would sign a JWS `url` (sec. 6.4) that differs from the URL actually requested, failing at the CA. Test
|
|
1245
|
+
// the RAW `#` delimiter, not `url.hash`: an EMPTY fragment ("...alt#") leaves url.hash === "" (falsy) while
|
|
1246
|
+
// the verbatim URL still carries the `#`. A literal `#` is always the fragment delimiter (a `#` in a path is
|
|
1247
|
+
// %23), so its mere presence is the fragment.
|
|
1248
|
+
if (s.indexOf("#") !== -1) throw E("acme/bad-url", "an ACME URL must not contain a fragment: " + JSON.stringify(s));
|
|
1164
1249
|
// Return the value VERBATIM (the server's exact spelling for the sec. 6.4/7.3 JWS match) -- a conforming
|
|
1165
1250
|
// CA may emit a form WHATWG would normalize (a default :443 port, an uppercase host); the transport
|
|
1166
1251
|
// re-parses it for the connection, so such a URL is honored rather than rejected as non-canonical.
|
|
@@ -1177,10 +1262,23 @@ function _resolveLocation(loc, base) {
|
|
|
1177
1262
|
try { new URL(loc); }
|
|
1178
1263
|
catch (_e) { isAbsolute = false; }
|
|
1179
1264
|
if (isAbsolute) return _clientUrl(loc);
|
|
1180
|
-
var
|
|
1181
|
-
try {
|
|
1265
|
+
var u;
|
|
1266
|
+
try { u = new URL(loc, base); }
|
|
1182
1267
|
catch (e) { throw E("acme/bad-url", "the Location header did not resolve to a valid URL: " + JSON.stringify(loc), e); }
|
|
1183
|
-
|
|
1268
|
+
// A relative reference whose QUERY WHATWG re-encoded on resolution (an RFC 3986 reserved char the special-scheme
|
|
1269
|
+
// query set percent-encodes -- an apostrophe -> %27) is non-canonical: the signed+requested URL would differ from
|
|
1270
|
+
// the raw advertised query (RFC 8555 sec. 6.4). _clientUrl makes this check for an absolute URL, which a relative
|
|
1271
|
+
// reference would otherwise bypass.
|
|
1272
|
+
if (_queryRepaired(loc, u)) throw E("acme/bad-url", "a relative Link/Location query is not canonical (resolution re-encoded it): " + JSON.stringify(loc));
|
|
1273
|
+
return _clientUrl(u.href);
|
|
1274
|
+
}
|
|
1275
|
+
// Did resolving `rawRef` re-encode its query (a reserved char like "'" -> %27)? RFC 3986 sec. 6.2.2.2 does NOT make
|
|
1276
|
+
// a reserved char equivalent to its percent-escape, so a raw query that differs from the resolved search is a
|
|
1277
|
+
// non-canonical reference -- used to reject a repaired target AND to skip a repaired anchor whose context match
|
|
1278
|
+
// would otherwise be SPOOFED (a "?x='" anchor byte-matching a base carrying "?x=%27").
|
|
1279
|
+
function _queryRepaired(rawRef, resolvedUrl) {
|
|
1280
|
+
var qi = rawRef.indexOf("?");
|
|
1281
|
+
return qi !== -1 && rawRef.slice(qi).split("#")[0] !== resolvedUrl.search;
|
|
1184
1282
|
}
|
|
1185
1283
|
// The default poll sleeper is the shared bounded sleeper (lib/sleep.js): it chunks a delay above Node's
|
|
1186
1284
|
// 32-bit setTimeout ceiling so a large (parser-bounded, up to a year) Retry-After is honored rather than
|
|
@@ -1213,6 +1311,355 @@ function _splitPemChain(text) {
|
|
|
1213
1311
|
return out;
|
|
1214
1312
|
}
|
|
1215
1313
|
|
|
1314
|
+
// The default and hard ceilings on the alternate-chain fetch (RFC 8555 sec. 7.4.2). A Link header is an
|
|
1315
|
+
// UNTRUSTED server field: cap its length before the walk, and cap how many extra signed POSTs it can drive
|
|
1316
|
+
// (CWE-770 fetch amplification), the same posture as maxRedirects.
|
|
1317
|
+
var LINK_HEADER_MAX_BYTES = constants.BYTES.kib(8);
|
|
1318
|
+
var DEFAULT_MAX_ALTERNATES = 8;
|
|
1319
|
+
// An RFC 7230 token (a Link parameter name, or an unquoted parameter value): 1*tchar, no whitespace/specials.
|
|
1320
|
+
var LINK_TOKEN = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
1321
|
+
// Trim only HTTP OWS -- SP (0x20) and HTAB (0x09) -- NOT arbitrary Unicode whitespace. String.prototype.trim
|
|
1322
|
+
// would strip an NBSP (obs-text) and other Unicode spaces, letting them masquerade as field whitespace.
|
|
1323
|
+
function _trimOWS(s) { return s.replace(/^[ \t]+/, "").replace(/[ \t]+$/, ""); }
|
|
1324
|
+
// Is `t` a valid RFC 8288 sec. 3.3 relation-type -- a reg-rel-type (a letter then LDH, compared case-
|
|
1325
|
+
// insensitively) OR an ext-rel-type (an absolute URI: a scheme then ':', AND a canonical RFC 3986 reference so
|
|
1326
|
+
// a scheme prefix alone is not enough, e.g. "http:%ZZ" is rejected for its malformed pct-escape)? "@" is neither.
|
|
1327
|
+
function _validRelType(t) {
|
|
1328
|
+
if (/^[A-Za-z][A-Za-z0-9.-]*$/.test(t)) return true; // reg-rel-type
|
|
1329
|
+
// ext-rel-type: a full absolute URI. A relation type is an identifier, never resolved, so a percent-encoded dot
|
|
1330
|
+
// (%2e) is a legal char (not a dot-segment); other than that its structure must be a valid absolute URI.
|
|
1331
|
+
if (LINK_URI_BADCHAR.test(t)) return false;
|
|
1332
|
+
return _validAbsoluteUri(t);
|
|
1333
|
+
}
|
|
1334
|
+
// Is `t` a valid RFC 3986 ABSOLUTE URI (a relation-type identifier, never connected to)? A scheme, valid pct-
|
|
1335
|
+
// escapes, and a structurally valid authority/path. The characters are already validated (LINK_URI_BADCHAR above);
|
|
1336
|
+
// _uriStructurallyInvalid enforces the rest by RFC 3986 GRAMMAR -- brackets only in an authority IP-literal whose
|
|
1337
|
+
// content is a valid IPv6/IPvFuture, a non-empty authority, at most one "@", at most one "#", a numeric port. NO
|
|
1338
|
+
// URL.canParse on the whole URI: its special-scheme quirks wrongly reject valid identifiers (an IPv4 coercion of a
|
|
1339
|
+
// numeric reg-name "1.2.3.4.5", a 16-bit port cap, IPvFuture, an empty reg-name "foo://user@/"); WHATWG is used
|
|
1340
|
+
// only inside _badIpLiteral to validate an IPv6 literal, the one place it is correct.
|
|
1341
|
+
function _validAbsoluteUri(t) {
|
|
1342
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(t) && !LINK_URI_BADPCT.test(t) && !_uriStructurallyInvalid(t);
|
|
1343
|
+
}
|
|
1344
|
+
// A URI carries at most ONE "#" (the fragment delimiter); a fragment cannot itself contain "#" (RFC 3986 sec.
|
|
1345
|
+
// 3.5), so a second literal "#" is invalid, which WHATWG ACCEPTS by folding the rest into the fragment. A pct-
|
|
1346
|
+
// encoded "%23" is a literal char, not counted.
|
|
1347
|
+
function _multiFragment(uri) { return uri.split("#").length > 2; }
|
|
1348
|
+
// The authority (after "//", optionally after a scheme, up to the next "/?#") carries at most one "@" (userinfo
|
|
1349
|
+
// "@" host); a second literal "@" is invalid RFC 3986, which WHATWG ACCEPTS while rewriting the first "@".
|
|
1350
|
+
function _authorityMultiAt(uri) {
|
|
1351
|
+
var m = /^(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/([^/?#]*)/.exec(uri);
|
|
1352
|
+
return !!m && m[1].split("@").length > 2;
|
|
1353
|
+
}
|
|
1354
|
+
// A URI whose scheme REQUIRES an authority (or a scheme-relative reference, which inherits one -- always https
|
|
1355
|
+
// here) must have a NON-EMPTY authority. A "//" immediately followed by "/", "?", "#", or end is an empty
|
|
1356
|
+
// authority WHATWG silently REPAIRS by promoting the next path segment to the host ("///cert/alt" -> host "cert";
|
|
1357
|
+
// "////acme.example/p" -> host "acme.example"; "http:///relations" -> host "relations"). A scheme that legitimately
|
|
1358
|
+
// allows an empty authority (file:///path, or any non-authority scheme) is NOT flagged.
|
|
1359
|
+
function _hasEmptyAuthority(uri) {
|
|
1360
|
+
var m = /^([A-Za-z][A-Za-z0-9+.-]*:)?\/\/([^/?#]*)/.exec(uri);
|
|
1361
|
+
if (!m) return false;
|
|
1362
|
+
var scheme = m[1] ? m[1].slice(0, -1).toLowerCase() : ""; // "" == scheme-relative (inherits the https base)
|
|
1363
|
+
if (scheme !== "" && scheme !== "http" && scheme !== "https" && scheme !== "ws" && scheme !== "wss" && scheme !== "ftp") return false;
|
|
1364
|
+
// The HOST itself (the authority with any userinfo and port stripped) must be non-empty -- not only the char
|
|
1365
|
+
// right after "//": "https://user@/x" and "https://:443/x" have an empty host after the userinfo / before the
|
|
1366
|
+
// port, which WHATWG rejects (or repairs) for a special scheme.
|
|
1367
|
+
var host = m[2].slice(m[2].lastIndexOf("@") + 1);
|
|
1368
|
+
if (host.charAt(0) !== "[") { var ci = host.indexOf(":"); if (ci !== -1) host = host.slice(0, ci); }
|
|
1369
|
+
return host === "";
|
|
1370
|
+
}
|
|
1371
|
+
// Are any brackets in `uri` confined to a single authority IP-literal (`scheme://[...]`, scheme optional for a
|
|
1372
|
+
// network-path reference, RFC 3986 sec. 4.2)? RFC 3986 permits `[`/`]` ONLY there; anywhere else -- a path
|
|
1373
|
+
// ("/cert/[alt]") or an opaque scheme part ("urn:[") -- they are invalid, yet WHATWG's lenient parse accepts them.
|
|
1374
|
+
function _bracketsOnlyInAuthority(uri) {
|
|
1375
|
+
if (uri.indexOf("[") === -1 && uri.indexOf("]") === -1) return true;
|
|
1376
|
+
// Optional userinfo may precede the IP-literal host (RFC 3986: authority = [ userinfo "@" ] host [ ":" port ]).
|
|
1377
|
+
// A raw "[" / "]" is NOT permitted in userinfo (RFC 3986 sec. 3.2.1/3.2.2 -- the IP-literal host is the ONLY place
|
|
1378
|
+
// square brackets are allowed), so the userinfo class excludes them; otherwise "a[b@[::1]" (which WHATWG repairs
|
|
1379
|
+
// to "a%5Bb@[::1]") would anchor on the real host and slip.
|
|
1380
|
+
var m = /^(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/(?:[^/?#@[\]]*@)?\[[^[\]]*\]/.exec(uri);
|
|
1381
|
+
if (!m) return false;
|
|
1382
|
+
var rest = uri.slice(m[0].length);
|
|
1383
|
+
if (rest.indexOf("[") !== -1 || rest.indexOf("]") !== -1) return false; // no bracket past the authority
|
|
1384
|
+
// After the IP-literal HOST "]", the rest of the authority (up to the next "/?#") may be ONLY an optional numeric
|
|
1385
|
+
// ":port". Anything else -- an "@" ("[::1]:80@host" / "[::1]@host", where "[::1]" is a bracketed userinfo, invalid
|
|
1386
|
+
// RFC 3986), a non-numeric port, or trailing host text -- means the literal sits in an invalid position, which
|
|
1387
|
+
// WHATWG would re-parse to a DIFFERENT host.
|
|
1388
|
+
var afterHost = rest.split(/[/?#]/)[0];
|
|
1389
|
+
return afterHost === "" || /^:[0-9]*$/.test(afterHost);
|
|
1390
|
+
}
|
|
1391
|
+
// A relative-PATH reference (no scheme, no "//" authority) whose FIRST path segment contains a ":" is a
|
|
1392
|
+
// path-noscheme violation (RFC 3986 sec. 3.3 / 4.2: the first segment "cannot contain a colon (':') character",
|
|
1393
|
+
// as it would be mistaken for a scheme). ":foo" or "1x:y" is such a case, which WHATWG resolves against the base
|
|
1394
|
+
// (same-origin) rather than rejecting. A real scheme ("foo:bar") is handled by the scheme/https gates, not here.
|
|
1395
|
+
function _relFirstSegHasColon(uri) {
|
|
1396
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(uri) || uri.indexOf("//") === 0) return false;
|
|
1397
|
+
return uri.split(/[/?#]/)[0].indexOf(":") !== -1;
|
|
1398
|
+
}
|
|
1399
|
+
// The authority port (":" after the host, before the next "/?#") must be *DIGIT (RFC 3986 sec. 3.2.3). A
|
|
1400
|
+
// non-numeric port ("host:bad") is malformed -- the character + structural checks otherwise miss it, and WHATWG
|
|
1401
|
+
// rejects it only on connect (so a fetched target would be skipped rather than failing closed). An empty port
|
|
1402
|
+
// (":") and a missing port are fine.
|
|
1403
|
+
function _badPort(uri) {
|
|
1404
|
+
var m = /^(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/(?:[^/?#@]*@)?(?:\[[^\]]*\]|[^/?#:]*)(:[^/?#]*)?/.exec(uri);
|
|
1405
|
+
return !!(m && m[1] && !/^:[0-9]*$/.test(m[1]));
|
|
1406
|
+
}
|
|
1407
|
+
// An IP-literal host "[...]" must CONTAIN a valid IPv6 address or IPvFuture (RFC 3986 sec. 3.2.2) -- "[not-ip]",
|
|
1408
|
+
// "[]", "[garbage]" are malformed, which the bracket-placement check alone accepts (it only confirms the brackets
|
|
1409
|
+
// are in the authority) and WHATWG rejects only on connect (so a fetched target would be skipped rather than
|
|
1410
|
+
// failing closed). IPvFuture is checked by grammar; an IPv6 by WHATWG (which parses it correctly). `URL.canParse`
|
|
1411
|
+
// never throws, so no swallow.
|
|
1412
|
+
function _badIpLiteral(uri) {
|
|
1413
|
+
var m = /^(?:[A-Za-z][A-Za-z0-9+.-]*:)?\/\/(?:[^/?#@]*@)?\[([^\]]*)\]/.exec(uri);
|
|
1414
|
+
if (!m) return false;
|
|
1415
|
+
if (/^[vV][0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+$/.test(m[1])) return false; // valid IPvFuture
|
|
1416
|
+
return !URL.canParse("http://[" + m[1] + "]/");
|
|
1417
|
+
}
|
|
1418
|
+
// The RFC 3986 STRUCTURAL rules a URI-reference must satisfy that WHATWG would otherwise silently REPAIR (accept
|
|
1419
|
+
// while rewriting) or reject only on connect: brackets only in an authority IP-literal, a non-empty authority, at
|
|
1420
|
+
// most one "@" in the authority, at most one "#", a numeric port, no colon in a relative-path first segment.
|
|
1421
|
+
// SHARED by the fetched-target/anchor check (_linkUriInvalid) AND the ext-rel-type check (_validAbsoluteUri) so the
|
|
1422
|
+
// two can NEVER diverge -- a new structural rule added here binds both at once.
|
|
1423
|
+
function _uriStructurallyInvalid(uri) {
|
|
1424
|
+
return !_bracketsOnlyInAuthority(uri) || _hasEmptyAuthority(uri) || _authorityMultiAt(uri) || _multiFragment(uri) || _relFirstSegHasColon(uri) || _badPort(uri) || _badIpLiteral(uri);
|
|
1425
|
+
}
|
|
1426
|
+
// RFC 3986 sec. 6.2.2 syntax-based normalization for an alternate DEDUP key (the verbatim URL is still what is
|
|
1427
|
+
// fetched + signed, sec. 6.4). The WHATWG-normalized href already lowercases scheme+host, drops a default port,
|
|
1428
|
+
// and removes dot-segments; on top of that, DECODE a percent-escape of an unreserved char (sec. 6.2.2.2 --
|
|
1429
|
+
// "%61" == "a") and UPPERCASE the hex of any other escape (sec. 6.2.2.1), so equivalent spellings collapse to one
|
|
1430
|
+
// signed fetch (CWE-770). fromCharCode keeps the source pure-ASCII while matching a runtime byte.
|
|
1431
|
+
function _dedupKey(href) {
|
|
1432
|
+
return href.replace(/%[0-9A-Fa-f]{2}/g, function (m) {
|
|
1433
|
+
var c = String.fromCharCode(parseInt(m.slice(1), 16));
|
|
1434
|
+
return /[A-Za-z0-9._~-]/.test(c) ? c : "%" + m.slice(1).toUpperCase();
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
// The INTERIOR of a well-formed RFC 7230 quoted-string (between the DQUOTEs): a sequence of qdtext (any char
|
|
1438
|
+
// but `"`/`\`) and quoted-pair (`\` + a char). Rejects an unescaped interior quote (a""b) or a dangling `\`,
|
|
1439
|
+
// which first/last-char-is-a-quote alone would miss. Disjoint alternation -> linear, no ReDoS.
|
|
1440
|
+
var LINK_QUOTED_INTERIOR = /^(?:[^"\\]|\\.)*$/;
|
|
1441
|
+
// A character NOT permitted raw in an RFC 3986 URI-Reference (unreserved / reserved / pct-encode marker). A
|
|
1442
|
+
// Link target containing one -- a space, brace, backslash, control char -- is one WHATWG URL parsing would
|
|
1443
|
+
// silently repair (trim / percent-encode) on resolution, masking a malformed or off-path value; reject it.
|
|
1444
|
+
var LINK_URI_BADCHAR = /[^A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]/;
|
|
1445
|
+
// A `%` that does NOT introduce a valid RFC 3986 pct-encoded triplet (%HEXDIG HEXDIG): the char allowlist
|
|
1446
|
+
// permits `%` for percent-encoding, but `%ZZ` / a trailing `%` is malformed and URL parsing would repair it.
|
|
1447
|
+
var LINK_URI_BADPCT = /%(?![0-9A-Fa-f]{2})/;
|
|
1448
|
+
// A path SEGMENT that decodes to "." or ".." via a percent-encoded dot (`%2e`): URL resolution decodes `%2e`
|
|
1449
|
+
// to `.` for dot-segment removal, so an encoded `..` silently becomes a path traversal that changes the
|
|
1450
|
+
// resolved target. Reject only such an encoded dot-SEGMENT -- an encoded dot elsewhere (a filename `0%2ex` ->
|
|
1451
|
+
// `0.x`) is a valid target, and a LITERAL `.`/`..` segment resolves transparently. Path only (before ?/#).
|
|
1452
|
+
function _hasEncodedDotSegment(uri) {
|
|
1453
|
+
var segs = uri.split(/[?#]/)[0].split("/");
|
|
1454
|
+
for (var _si = 0; _si < segs.length; _si++) {
|
|
1455
|
+
if (!/%2e/i.test(segs[_si])) continue;
|
|
1456
|
+
var dec = segs[_si].replace(/%2e/ig, ".");
|
|
1457
|
+
if (dec === "." || dec === "..") return true;
|
|
1458
|
+
}
|
|
1459
|
+
return false;
|
|
1460
|
+
}
|
|
1461
|
+
// Is a Link URI-Reference (a target OR an anchor) NOT a canonical RFC 3986 reference -- i.e. one URL parsing
|
|
1462
|
+
// would repair (a disallowed char, a malformed pct-escape, an encoded dot-segment) on resolution?
|
|
1463
|
+
function _linkUriInvalid(uri) {
|
|
1464
|
+
// A bracket in a resolved target/anchor is valid only in an authority IP-literal (a same-origin IPv6/IPvFuture
|
|
1465
|
+
// cert URL is legal); in a path it is invalid and WHATWG would repair it by percent-encoding.
|
|
1466
|
+
return LINK_URI_BADCHAR.test(uri) || LINK_URI_BADPCT.test(uri) || _hasEncodedDotSegment(uri) || _uriStructurallyInvalid(uri);
|
|
1467
|
+
}
|
|
1468
|
+
// A control octet forbidden in an HTTP field-value (RFC 9110): any C0 control except HTAB, plus DEL. Checked
|
|
1469
|
+
// by code point (not a control-char regex) to keep the source pure ASCII and clear of eslint no-control-regex.
|
|
1470
|
+
function _hasCtlOctet(s) {
|
|
1471
|
+
for (var _ci = 0; _ci < s.length; _ci++) { var _cc = s.charCodeAt(_ci); if (_cc === 0x7F || (_cc < 0x20 && _cc !== 0x09)) return true; }
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// Split an RFC 8288 Link field into its comma-separated link-values, respecting <...> and "..." context so
|
|
1476
|
+
// a comma inside a URI or a quoted parameter value is not a separator. An unterminated quote fails closed.
|
|
1477
|
+
function _splitLinkValues(s) {
|
|
1478
|
+
var out = [], start = 0, inAngle = false, inQuote = false;
|
|
1479
|
+
for (var i = 0; i < s.length; i++) {
|
|
1480
|
+
var ch = s.charAt(i);
|
|
1481
|
+
if (inQuote) { if (ch === "\\") { i++; continue; } if (ch === "\"") inQuote = false; continue; }
|
|
1482
|
+
if (ch === "\"") { inQuote = true; continue; }
|
|
1483
|
+
if (ch === "<") inAngle = true;
|
|
1484
|
+
else if (ch === ">") inAngle = false;
|
|
1485
|
+
else if (ch === "," && !inAngle) { out.push(s.slice(start, i)); start = i + 1; }
|
|
1486
|
+
}
|
|
1487
|
+
if (inQuote || inAngle) throw E("acme/bad-link", "the Link header has an unterminated quote or angle bracket (RFC 8288)");
|
|
1488
|
+
out.push(s.slice(start));
|
|
1489
|
+
return out;
|
|
1490
|
+
}
|
|
1491
|
+
// Split a link-value's parameter tail (everything after `<URI>`) into `;`-separated name=value params,
|
|
1492
|
+
// quote-aware; a quoted value is unescaped. Returns [{ name (lower-cased), value }].
|
|
1493
|
+
function _splitLinkParams(rest) {
|
|
1494
|
+
var parts = [], start = 0, inQuote = false;
|
|
1495
|
+
for (var i = 0; i < rest.length; i++) {
|
|
1496
|
+
var ch = rest.charAt(i);
|
|
1497
|
+
if (inQuote) { if (ch === "\\") { i++; continue; } if (ch === "\"") inQuote = false; continue; }
|
|
1498
|
+
if (ch === "\"") inQuote = true;
|
|
1499
|
+
else if (ch === ";") { parts.push(rest.slice(start, i)); start = i + 1; }
|
|
1500
|
+
}
|
|
1501
|
+
parts.push(rest.slice(start));
|
|
1502
|
+
var out = [];
|
|
1503
|
+
for (var j = 0; j < parts.length; j++) {
|
|
1504
|
+
var p = _trimOWS(parts[j]);
|
|
1505
|
+
// `rest` starts with the first `;`, so parts[0] is an expected empty slot before the first parameter. Any
|
|
1506
|
+
// OTHER empty part is a `;` with no parameter after it (`;;` or a trailing `;`) -- malformed (RFC 8288).
|
|
1507
|
+
if (p === "") { if (j === 0) continue; throw E("acme/bad-link", "an empty Link parameter (a ';' with no parameter) is malformed (RFC 8288): " + JSON.stringify(rest)); }
|
|
1508
|
+
var eq = p.indexOf("=");
|
|
1509
|
+
var name = (eq === -1 ? p : _trimOWS(p.slice(0, eq))).toLowerCase();
|
|
1510
|
+
// A parameter NAME is a token (RFC 8288 / RFC 7230): reject whitespace / specials rather than accept a
|
|
1511
|
+
// malformed "bad name=x" as a silently-ignored parameter that masks the value grammar below.
|
|
1512
|
+
if (!LINK_TOKEN.test(name)) throw E("acme/bad-link", "a Link parameter name must be a token (RFC 8288 / RFC 7230): " + JSON.stringify(p));
|
|
1513
|
+
// A valueless token parameter is VALID: RFC 8288 link-param = token BWS [ "=" BWS ( token / quoted-string ) ]
|
|
1514
|
+
// -- the value is OPTIONAL. Keep it as an empty-valued extension param (it is never `rel`, so it is ignored).
|
|
1515
|
+
// (An EMPTY part -- a `;` with no token at all, e.g. `;;` -- was already rejected above.)
|
|
1516
|
+
if (eq === -1) { out.push({ name: name, value: "", hasValue: false }); continue; }
|
|
1517
|
+
var v = _trimOWS(p.slice(eq + 1));
|
|
1518
|
+
if (v.length >= 2 && v.charAt(0) === "\"" && v.charAt(v.length - 1) === "\"") {
|
|
1519
|
+
var inner = v.slice(1, -1); // a quoted-string may hold a space-separated list (RFC 8288)
|
|
1520
|
+
if (!LINK_QUOTED_INTERIOR.test(inner)) throw E("acme/bad-link", "a quoted Link parameter value is malformed (an unescaped quote or dangling backslash, RFC 7230): " + JSON.stringify(p));
|
|
1521
|
+
v = inner.replace(/\\(.)/g, "$1");
|
|
1522
|
+
} else if (!LINK_TOKEN.test(v)) {
|
|
1523
|
+
// An UNQUOTED value is a single NON-EMPTY RFC 7230 token -- no whitespace/specials, and an empty value
|
|
1524
|
+
// (`foo=`) is not a token (an empty value must be quoted, `foo=""`). Reject a malformed one rather than
|
|
1525
|
+
// split-and-match it as if it were a quoted relation list.
|
|
1526
|
+
throw E("acme/bad-link", "an unquoted Link parameter value must be a non-empty token (RFC 8288 / RFC 7230): " + JSON.stringify(p));
|
|
1527
|
+
}
|
|
1528
|
+
out.push({ name: name, value: v, hasValue: true });
|
|
1529
|
+
}
|
|
1530
|
+
return out;
|
|
1531
|
+
}
|
|
1532
|
+
// Does an RFC 8288 rel value (a space-separated relation-type list) contain "alternate" as a WHOLE token,
|
|
1533
|
+
// case-insensitively (RFC 8288 sec. 3.3)? Never a substring -- "alternateX" / "xalternate" do not match.
|
|
1534
|
+
function _relHasAlternate(rel) {
|
|
1535
|
+
// The rel value is a SPACE-separated relation-type list (RFC 8288 sec. 3.3) -- split on SP only, NOT a tab
|
|
1536
|
+
// or other whitespace, so "alternate<TAB>index" is a single (non-matching) relation-type, not a false match.
|
|
1537
|
+
var toks = String(rel).split(" ");
|
|
1538
|
+
for (var i = 0; i < toks.length; i++) { if (toks[i].toLowerCase() === "alternate") return true; }
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
// Parse one link-value `"<" URI-Reference ">" *( ";" link-param )` -> { uri, rel } (rel "" if absent);
|
|
1542
|
+
// a blank segment (a trailing comma) -> null (skip); anything not starting with <...> -> acme/bad-link.
|
|
1543
|
+
function _parseLinkValue(raw) {
|
|
1544
|
+
var s = _trimOWS(raw);
|
|
1545
|
+
if (s === "") return null;
|
|
1546
|
+
var gt = s.indexOf(">");
|
|
1547
|
+
if (s.charAt(0) !== "<" || gt === -1) throw E("acme/bad-link", "a Link value must be <URI-Reference> with parameters (RFC 8288): " + JSON.stringify(raw));
|
|
1548
|
+
// After the "<URI>" the remainder is *( OWS ";" OWS link-param ) -- so, past any leading whitespace, it is
|
|
1549
|
+
// empty or begins with ';'. Any other trailing text (a param not introduced by ';', e.g. "<uri>rel=...") is
|
|
1550
|
+
// malformed and MUST NOT be leniently read as a parameter.
|
|
1551
|
+
var rest = s.slice(gt + 1).replace(/^[ \t]+/, "");
|
|
1552
|
+
if (rest !== "" && rest.charAt(0) !== ";") throw E("acme/bad-link", "a Link value's parameters must be introduced by ';' (RFC 8288): " + JSON.stringify(raw));
|
|
1553
|
+
var uri = s.slice(1, gt);
|
|
1554
|
+
// A URI-Reference carries only RFC 3986 characters; WHATWG URL parsing would silently REPAIR anything else
|
|
1555
|
+
// (trim whitespace, percent-encode a brace/control char, rewrite a backslash) when the target is later
|
|
1556
|
+
// resolved -- masking a malformed / off-path value. Reject it up front, the same canonicality posture the
|
|
1557
|
+
// client applies to every server-provided URL (a relative target skips _clientUrl's raw-vs-parsed check).
|
|
1558
|
+
if (_linkUriInvalid(uri)) throw E("acme/bad-link", "a Link URI-Reference contains a character, percent-escape, or encoded dot-segment not permitted by RFC 3986: " + JSON.stringify(raw));
|
|
1559
|
+
var params = _splitLinkParams(rest), rel = "", relSeen = false, anchor = null, anchorSeen = false;
|
|
1560
|
+
for (var i = 0; i < params.length; i++) {
|
|
1561
|
+
// The FIRST occurrence of rel wins; later ones are ignored (RFC 8288 sec. 3.3, a singleton) -- take the first
|
|
1562
|
+
// even when its value is empty, so `rel="";rel=alternate` keeps the empty first value (the caller then fails
|
|
1563
|
+
// closed on the empty rel, rather than falling through to the later alternate).
|
|
1564
|
+
if (params[i].name === "rel" && !relSeen) { rel = params[i].value; relSeen = true; }
|
|
1565
|
+
else if (params[i].name === "anchor") {
|
|
1566
|
+
// anchor is NOT in the RFC 8288 sec. 3.3 singleton set (rel/media/title/title*/type), so it has no "ignore
|
|
1567
|
+
// after the first" rule. A DUPLICATE anchor is ambiguous, and anchor is security-relevant (it overrides the
|
|
1568
|
+
// link CONTEXT), so it fails closed rather than silently taking one. A VALUELESS anchor (no `=`) is malformed;
|
|
1569
|
+
// an explicit value -- even an empty quoted one (`anchor=""`, resolving to the context) -- is valid.
|
|
1570
|
+
if (anchorSeen) throw E("acme/bad-link", "a Link value must not carry more than one anchor parameter (RFC 8288 sec. 3.2, ambiguous context): " + JSON.stringify(raw));
|
|
1571
|
+
anchorSeen = true;
|
|
1572
|
+
if (!params[i].hasValue) throw E("acme/bad-link", "a Link anchor parameter requires a URI value (RFC 8288 sec. 3.2): " + JSON.stringify(raw));
|
|
1573
|
+
anchor = params[i].value;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
return { uri: uri, rel: rel, anchor: anchor, relSeen: relSeen };
|
|
1577
|
+
}
|
|
1578
|
+
// Parse an RFC 8288 Link response header for rel="alternate" certificate-chain targets (RFC 8555 sec.
|
|
1579
|
+
// 7.4.2): read the (case-insensitive, node-lower-cased) `link` field -- a combined string OR an array --
|
|
1580
|
+
// bound its length, split into link-values, keep the alternate-rel ones, resolve each against the download
|
|
1581
|
+
// URL, https-gate, and dedupe by resolved URL. An UNTRUSTED header: every malformed shape or non-https /
|
|
1582
|
+
// unresolvable target fails closed as acme/bad-link. Scoped to the alternate relation the download consumes.
|
|
1583
|
+
function _parseLinkAlternates(headers, base) {
|
|
1584
|
+
var raw = null;
|
|
1585
|
+
for (var k in headers) { if (Object.prototype.hasOwnProperty.call(headers, k) && k.toLowerCase() === "link") { raw = headers[k]; break; } }
|
|
1586
|
+
if (raw == null) return [];
|
|
1587
|
+
var baseUrl = new URL(base), baseOrigin = baseUrl.origin, baseHref = baseUrl.href; // the download URL
|
|
1588
|
+
var fields = Array.isArray(raw) ? raw : [raw], out = [], seen = Object.create(null), totalBytes = 0;
|
|
1589
|
+
// Seed the dedup set with the PRIMARY download URL: a CA that advertises the certificate URL itself as a
|
|
1590
|
+
// rel="alternate" (it is not an alternate of itself, RFC 8555 sec. 7.4.2) must not cause a redundant re-fetch.
|
|
1591
|
+
seen[_dedupKey(baseHref)] = true;
|
|
1592
|
+
for (var fi = 0; fi < fields.length; fi++) {
|
|
1593
|
+
var field = String(fields[fi]);
|
|
1594
|
+
// Cap the AGGREGATE across every Link field (CWE-770): an injected transport / duplicate-Link array can
|
|
1595
|
+
// hand back many fields, each under a per-field size, that together are unbounded -- bound the sum so the
|
|
1596
|
+
// parse work (and the collected alternate set) cannot amplify regardless of the header's shape. Charge a
|
|
1597
|
+
// per-field overhead so a flood of EMPTY fields (each 0 bytes) still counts toward the cap.
|
|
1598
|
+
totalBytes += field.length + 1;
|
|
1599
|
+
if (totalBytes > LINK_HEADER_MAX_BYTES) throw E("acme/bad-link", "the Link header(s) exceed the " + LINK_HEADER_MAX_BYTES + "-byte aggregate cap (RFC 8288, CWE-770)");
|
|
1600
|
+
if (_hasCtlOctet(field)) throw E("acme/bad-link", "a Link header must not contain control octets (RFC 9110 field-value)");
|
|
1601
|
+
var values = _splitLinkValues(field);
|
|
1602
|
+
for (var vi = 0; vi < values.length; vi++) {
|
|
1603
|
+
var lv = _parseLinkValue(values[vi]);
|
|
1604
|
+
if (lv === null) continue;
|
|
1605
|
+
// A rel that is PRESENT but empty (`rel=""`) is a zero-length relation-type list, which is malformed (RFC
|
|
1606
|
+
// 8288 sec. 3.3 requires >=1 type). Fail closed rather than silently skip it -- otherwise a malformed value
|
|
1607
|
+
// could shadow a later valid one. (A link-value with NO rel at all is simply not one of ours -> skipped.)
|
|
1608
|
+
if (lv.relSeen && lv.rel === "") throw E("acme/bad-link", "a Link rel parameter must name at least one relation-type (RFC 8288 sec. 3.3)");
|
|
1609
|
+
// The rel value is a space-separated relation-type list with 1*SP separators (RFC 8288 sec. 3.3): multiple
|
|
1610
|
+
// spaces BETWEEN types are allowed, but a LEADING or TRAILING space is malformed (so "alternate " is not
|
|
1611
|
+
// split-and-matched as containing "alternate", while "alternate index" is a valid two-type list).
|
|
1612
|
+
if (lv.rel !== "" && (lv.rel.charAt(0) === " " || lv.rel.charAt(lv.rel.length - 1) === " ")) throw E("acme/bad-link", "a Link rel value has a leading or trailing space (RFC 8288 sec. 3.3): " + JSON.stringify(lv.rel));
|
|
1613
|
+
// EVERY relation-type in the list must be well-formed (RFC 8288 sec. 3.3): a bad token (e.g. "@") makes the
|
|
1614
|
+
// list malformed, even if another token is "alternate". (Empty tokens are internal 1*SP -- skip them.)
|
|
1615
|
+
var relToks = lv.rel === "" ? [] : lv.rel.split(" ");
|
|
1616
|
+
for (var ri = 0; ri < relToks.length; ri++) { if (relToks[ri] !== "" && !_validRelType(relToks[ri])) throw E("acme/bad-link", "a Link rel value contains a token that is not a valid relation-type (RFC 8288 sec. 3.3): " + JSON.stringify(lv.rel)); }
|
|
1617
|
+
if (!_relHasAlternate(lv.rel)) continue;
|
|
1618
|
+
// RFC 8288 sec. 3.2: an `anchor` overrides the link's CONTEXT. A certificate alternate's context is the
|
|
1619
|
+
// certificate itself (the download URL); a link anchored to another resource is an alternate of THAT
|
|
1620
|
+
// resource, not this certificate -- skip it. No anchor, or one resolving to the download URL, is ours.
|
|
1621
|
+
if (lv.anchor != null) {
|
|
1622
|
+
// Validate the RAW anchor with the same RFC 3986 rules as a target BEFORE resolving: otherwise a
|
|
1623
|
+
// repairable anchor (an encoded dot-segment) could traverse to the certificate URL and SPOOF a context
|
|
1624
|
+
// match. A non-canonical anchor is not a reliable context -> skip.
|
|
1625
|
+
if (_linkUriInvalid(lv.anchor)) continue;
|
|
1626
|
+
var au;
|
|
1627
|
+
try { au = new URL(lv.anchor, base); } catch (_ae) { continue; }
|
|
1628
|
+
// A repaired anchor query ("?x='" re-encoded to "?x=%27") must not byte-match a base carrying "?x=%27":
|
|
1629
|
+
// "'" is a reserved sub-delim, not equivalent to its escape (RFC 3986 sec. 6.2.2.2), so a resolution that
|
|
1630
|
+
// re-encoded it is a non-canonical anchor -> unreliable context, skip.
|
|
1631
|
+
if (_queryRepaired(lv.anchor, au)) continue;
|
|
1632
|
+
if (au.href !== baseHref) continue;
|
|
1633
|
+
}
|
|
1634
|
+
var resolved;
|
|
1635
|
+
try { resolved = _resolveLocation(lv.uri, base); } // resolves a relative ref + enforces the https gate
|
|
1636
|
+
catch (e) {
|
|
1637
|
+
// A non-https alternate is a security-relevant anomaly -> fail the whole header closed. A target that is a
|
|
1638
|
+
// valid RFC 3986 reference but not a canonical ACME request URL (literal dot-segments, or a sub-delim
|
|
1639
|
+
// WHATWG would re-encode in a special-scheme query) cannot be signed AND requested byte-identically
|
|
1640
|
+
// (RFC 8555 sec. 6.4), so it is merely UNUSABLE -> skip THIS alternate and keep the others (sec. 7.4.2
|
|
1641
|
+
// permits several), rather than discarding every valid alternate over one non-canonical spelling.
|
|
1642
|
+
if (e && e.code === "acme/insecure-url") throw E("acme/bad-link", "a rel=\"alternate\" Link target is not an https URL: " + JSON.stringify(lv.uri), e);
|
|
1643
|
+
continue;
|
|
1644
|
+
}
|
|
1645
|
+
// Origin-gate: an alternate is fetched with the ACCOUNT-KEY-signed POST-as-GET, so an untrusted (TLS-
|
|
1646
|
+
// delivered but unsigned) Link header MUST NOT steer that authenticated request to a different origin
|
|
1647
|
+
// (SSRF). Confine alternates to the certificate download's own origin -- the same "possibly compromised
|
|
1648
|
+
// directory" threat the cross-origin mTLS-credential strip already defends (RFC 8555 sec. 7.4.2
|
|
1649
|
+
// alternates are the CA's own chains, served alongside the certificate).
|
|
1650
|
+
var parsed = new URL(resolved);
|
|
1651
|
+
if (parsed.origin !== baseOrigin) throw E("acme/bad-link", "a rel=\"alternate\" Link target is not on the certificate download's origin (SSRF guard): " + JSON.stringify(lv.uri));
|
|
1652
|
+
// Dedup by the RFC 3986 sec. 6.2.2-normalized href (lowercased scheme+host, no default :443, no dot-segments,
|
|
1653
|
+
// unreserved pct-escapes decoded), so equivalent spellings ("acme.example" vs "ACME.EXAMPLE:443", "/alt" vs
|
|
1654
|
+
// "/%61lt") collapse to one fetch (CWE-770 amplification) -- while the VERBATIM resolved string is what is
|
|
1655
|
+
// fetched + signed (RFC 8555 sec. 6.4 exact-URL match).
|
|
1656
|
+
var key = _dedupKey(parsed.href);
|
|
1657
|
+
if (!seen[key]) { seen[key] = true; out.push(resolved); }
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
return out;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1216
1663
|
/**
|
|
1217
1664
|
* @primitive pki.acme.client
|
|
1218
1665
|
* @signature pki.acme.client(directoryUrl, opts) -> client
|
|
@@ -1230,9 +1677,9 @@ function _splitPemChain(text) {
|
|
|
1230
1677
|
* request is https-only (`acme/insecure-url`); reads are POST-as-GET; a problem+json response is a
|
|
1231
1678
|
* typed `acme/server-problem`; a poll sleeps on a bounded Retry-After via an injectable sleeper and is
|
|
1232
1679
|
* capped by a poll count and a total-wait budget. Returns a client
|
|
1233
|
-
* object: `directory`, `newAccount`, `newOrder`, `getOrder` / `getAuthorization` / `getChallenge`,
|
|
1680
|
+
* object: `directory`, `newAccount`, `newOrder`, `newAuthz`, `getOrder` / `getAuthorization` / `getChallenge`,
|
|
1234
1681
|
* `respondToChallenge`, `finalize`, `pollOrder` / `pollAuthorization`, `downloadCertificate`,
|
|
1235
|
-
* `revokeCert`, `deactivateAccount` / `deactivateAuthorization`, `keyChange`, `renewalInfo`.
|
|
1682
|
+
* `revokeCert`, `deactivateAccount` / `deactivateAuthorization`, `keyChange`, `renewalInfo`, `renewalWindow`.
|
|
1236
1683
|
*
|
|
1237
1684
|
* @opts
|
|
1238
1685
|
* - `accountKey` / `accountJwk` / `alg` -- REQUIRED: the account private key, its public JWK, and the JWS alg.
|
|
@@ -1240,6 +1687,9 @@ function _splitPemChain(text) {
|
|
|
1240
1687
|
* - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity } for the default transport.
|
|
1241
1688
|
* - `timeout` / `maxResponseBytes` / `maxRedirects` -- transport budgets; `maxNonceRetries` -- badNonce retry cap (default 1).
|
|
1242
1689
|
* - `maxPolls` / `maxTotalWait` / `sleep` -- poll-loop budgets + an injectable sleeper; `clock` -- an injectable receipt clock (default Date.now) for a Retry-After HTTP-date.
|
|
1690
|
+
* - `newAuthz(identifier)` -- pre-authorize a single identifier (RFC 8555 sec. 7.4.1) -> { authorization, url }.
|
|
1691
|
+
* - `downloadCertificate(url, { selectChain, maxAlternates })` -- pick among RFC 8555 sec. 7.4.2 alternate chains: `selectChain({certificate, chain, certificates})` returns the first truthy candidate (primary first, then bounded `Link rel="alternate"` chains, confined to the download's own origin); the result adds `alternates` (the resolved alternate URLs).
|
|
1692
|
+
* - `renewalWindow(certDer, { random, clock, replaced, previous })` -- the RFC 9773 ARI renewal decision: composes `renewalInfo`, selects a uniform-random instant in the suggested window -> { suggestedWindow, selectedTime, renewNow, retryAfterSeconds, explanationURL }. Pass a prior result back as `previous` to REUSE its selectedTime while the CA's window is unchanged (RFC 9773 sec. 4.2), so repeated refreshes keep one stable renewal instant.
|
|
1243
1693
|
* @example
|
|
1244
1694
|
* var acme = pki.acme.client("https://acme.example/directory", { accountKey, accountJwk, alg: "ES256", transport });
|
|
1245
1695
|
* var acct = await acme.newAccount({ termsOfServiceAgreed: true });
|
|
@@ -1463,6 +1913,36 @@ function client(directoryUrl, opts) {
|
|
|
1463
1913
|
});
|
|
1464
1914
|
});
|
|
1465
1915
|
}
|
|
1916
|
+
function _newAuthz(identifier) {
|
|
1917
|
+
return Promise.resolve().then(function () {
|
|
1918
|
+
// Reject a wildcard / bad AUTHORIZATION identifier BEFORE any network (sec. 7.4.1); the builder re-checks. Bind
|
|
1919
|
+
// to the CANONICAL { type, value } (a single validated read), so the wire body, the sent identifier, and the
|
|
1920
|
+
// returned-authz comparison below all use the same values -- never a getter that could re-read differently.
|
|
1921
|
+
var canon = _validateIdentifier(identifier);
|
|
1922
|
+
return _resource("newAuthz").then(function (url) {
|
|
1923
|
+
// 201 (Created) is the norm (RFC 8555 sec. 7.4.1), but a CA that returns an already-existing authorization
|
|
1924
|
+
// (the out-of-band valid case) MAY answer 200 (OK) -- the authz was not newly created -- just as newAccount
|
|
1925
|
+
// accepts 200 for an existing account. Accept both; the Location + object are validated below regardless.
|
|
1926
|
+
return _post(url, newAuthz, { identifier: canon }, "kid", undefined, [200, 201]).then(function (res) {
|
|
1927
|
+
var loc = res.headers["location"];
|
|
1928
|
+
if (!_isString(loc)) throw E("acme/no-authorization-url", "newAuthz did not return an authorization URL in a Location header (RFC 8555 sec. 7.4.1)");
|
|
1929
|
+
var authz = validate("authorization", _json(res));
|
|
1930
|
+
// Bind the returned authorization to the SUBMITTED identifier (sec. 7.4.1): a server returning an authz
|
|
1931
|
+
// for a different identifier is not silently accepted. The submitted identifier is always non-wildcard
|
|
1932
|
+
// (_validateIdentifier rejects a leading *.), so an authz the CA marks wildcard:true (sec. 7.1.4 -- it
|
|
1933
|
+
// authorizes *.<value>, a BROADER grant than requested) is a mismatch, not the authorization asked for.
|
|
1934
|
+
if (!authz.identifier || authz.identifier.type !== canon.type || authz.identifier.value !== canon.value || authz.wildcard === true) throw E("acme/identifier-mismatch", "the returned authorization does not match the requested identifier (RFC 8555 sec. 7.4.1)");
|
|
1935
|
+
// A pre-authorization's status "MUST be 'pending' unless the server has out-of-band information about the
|
|
1936
|
+
// client's authorization status" (sec. 7.4.1): the normal case is a fresh "pending" authz the caller then
|
|
1937
|
+
// fulfils, but the CA MAY instead return an already-"valid" authz (the identifier is already authorized for
|
|
1938
|
+
// this account -- no challenge to answer). Both are usable; only a terminal failed state ("invalid"/
|
|
1939
|
+
// "deactivated"/"expired"/"revoked") is not the authorization the pre-auth flow can proceed with.
|
|
1940
|
+
if (authz.status !== "pending" && authz.status !== "valid") throw E("acme/unexpected-authorization-status", "newAuthz returned an authorization that is neither pending nor already valid (status " + JSON.stringify(authz.status) + "); RFC 8555 sec. 7.4.1");
|
|
1941
|
+
return { authorization: authz, url: _resolveLocation(loc, url) };
|
|
1942
|
+
});
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1466
1946
|
function _getOrder(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("order", _json(res)); }); }
|
|
1467
1947
|
function _getAuthorization(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("authorization", _json(res)); }); }
|
|
1468
1948
|
function _getChallenge(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("challenge", _json(res)); }); }
|
|
@@ -1519,17 +1999,63 @@ function client(directoryUrl, opts) {
|
|
|
1519
1999
|
function _pollOrder(url, budget) { return _poll("order", url, budget); }
|
|
1520
2000
|
function _pollAuthorization(url, budget) { return _poll("authorization", url, budget); }
|
|
1521
2001
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
2002
|
+
// Read + strictly validate a downloaded chain response into { certificate, chain, certificates }. The caller
|
|
2003
|
+
// routes through _post (okStatuses [200]), so a non-200 has already thrown _serverProblem before this runs.
|
|
2004
|
+
function _readCertChain(res) {
|
|
2005
|
+
// The certificate representation is bound to application/pem-certificate-chain (RFC 8555 sec. 7.4.2):
|
|
2006
|
+
// a 200 with a wrong media type (a proxy error, a misrouted response) fails closed even if its body
|
|
2007
|
+
// happens to contain parseable PEM. Match the media-type TOKEN exactly (before any ;-parameters), so
|
|
2008
|
+
// a lookalike like application/pem-certificate-chain-evil does not slip through a substring test.
|
|
2009
|
+
var ctToken = String(res.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
2010
|
+
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)");
|
|
2011
|
+
var chain = _splitPemChain(_bodyText(res));
|
|
2012
|
+
return { certificate: chain[0], chain: chain.slice(1), certificates: chain };
|
|
2013
|
+
}
|
|
2014
|
+
function _fetchCertChain(url) { return _post(url, postAsGet, null, "kid", "application/pem-certificate-chain").then(_readCertChain); }
|
|
2015
|
+
|
|
2016
|
+
function _downloadCertificate(url, opts) {
|
|
2017
|
+
opts = opts || {};
|
|
2018
|
+
// Prelude in a resolved-promise .then so a bad opt / URL is a REJECTION, uniform for a .catch caller.
|
|
2019
|
+
return Promise.resolve().then(function () {
|
|
2020
|
+
var select = opts.selectChain;
|
|
2021
|
+
if (select !== undefined && typeof select !== "function") throw E("acme/bad-input", "downloadCertificate opts.selectChain must be a function");
|
|
2022
|
+
var maxAlt = guard.limits.cap(opts.maxAlternates, "maxAlternates", DEFAULT_MAX_ALTERNATES, { E: E, code: "acme/bad-input", min: 0, max: 64 });
|
|
2023
|
+
var dlUrl = _clientUrl(url);
|
|
2024
|
+
return _post(dlUrl, postAsGet, null, "kid", "application/pem-certificate-chain").then(function (res) {
|
|
2025
|
+
var primary = _readCertChain(res);
|
|
2026
|
+
// The Link header is UNTRUSTED. Parse it now but DEFER any malformed-header error: a malformed Link is
|
|
2027
|
+
// fatal only if the alternates are actually needed (the primary itself is rejected below). So a broken
|
|
2028
|
+
// or hostile Link can never deny a valid primary download -- whether the caller is selecting or not.
|
|
2029
|
+
var alternates = [], linkError = null;
|
|
2030
|
+
try { alternates = _parseLinkAlternates(res.headers, dlUrl); }
|
|
2031
|
+
catch (e) { linkError = e; }
|
|
2032
|
+
function result(cand) { return { certificate: cand.certificate, chain: cand.chain, certificates: cand.certificates, alternates: alternates }; }
|
|
2033
|
+
if (!select) return result(primary);
|
|
2034
|
+
// Selection: consider the PRIMARY first, then each alternate in header order, bounded by maxAlt
|
|
2035
|
+
// (CWE-770 fetch amplification). selectChain MAY be async -- await its result (Promise.resolve wraps a
|
|
2036
|
+
// sync return too) so a returned Promise is never treated as always-truthy. The first truthy wins. An
|
|
2037
|
+
// alternate whose end-entity certificate differs from the primary's violates sec. 7.4.2 ("starting with
|
|
2038
|
+
// the same end-entity certificate") -- fail closed rather than accept a substituted leaf. A predicate
|
|
2039
|
+
// throw / rejection propagates unswallowed. Exhausting the budget with alternates left unfetched is
|
|
2040
|
+
// acme/too-many-alternates; fetching every alternate with no match is acme/no-matching-chain.
|
|
2041
|
+
return Promise.resolve(select(primary)).then(function (primaryMatch) {
|
|
2042
|
+
if (primaryMatch) return result(primary);
|
|
2043
|
+
// The primary was rejected, so the alternates ARE needed now -- a malformed Link finally fails closed.
|
|
2044
|
+
if (linkError) throw linkError;
|
|
2045
|
+
var leaf = primary.certificate, toFetch = alternates.slice(0, maxAlt), overBudget = alternates.length > maxAlt, idx = 0;
|
|
2046
|
+
function next() {
|
|
2047
|
+
if (idx >= toFetch.length) {
|
|
2048
|
+
if (overBudget) throw E("acme/too-many-alternates", "no acceptable chain within the maxAlternates=" + maxAlt + " fetch budget (RFC 8555 sec. 7.4.2, CWE-770)");
|
|
2049
|
+
throw E("acme/no-matching-chain", "no downloaded chain satisfied selectChain (RFC 8555 sec. 7.4.2)");
|
|
2050
|
+
}
|
|
2051
|
+
return _fetchCertChain(toFetch[idx++]).then(function (cand) {
|
|
2052
|
+
if (!cand.certificate.equals(leaf)) throw E("acme/bad-alternate", "an alternate chain did not start with the same end-entity certificate (RFC 8555 sec. 7.4.2)");
|
|
2053
|
+
return Promise.resolve(select(cand)).then(function (m) { return m ? result(cand) : next(); });
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
return next();
|
|
2057
|
+
});
|
|
2058
|
+
});
|
|
1533
2059
|
});
|
|
1534
2060
|
}
|
|
1535
2061
|
|
|
@@ -1582,10 +2108,23 @@ function client(directoryUrl, opts) {
|
|
|
1582
2108
|
}
|
|
1583
2109
|
|
|
1584
2110
|
// ARI RenewalInfo (RFC 9773 sec. 4.1) -- the SOLE UNAUTHENTICATED GET (no JWS, no nonce).
|
|
1585
|
-
function _renewalInfo(certDer) {
|
|
2111
|
+
function _renewalInfo(certDer, clockFn, retryAfterCapSeconds) {
|
|
1586
2112
|
if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "renewalInfo requires a DER certificate Buffer");
|
|
2113
|
+
var _rawClk = typeof clockFn === "function" ? clockFn : clock; // renewalWindow may pass a per-call clock
|
|
2114
|
+
// The clock MUST return finite epoch ms: a NaN / Infinity would make the expiry comparison below silently
|
|
2115
|
+
// false and bypass the RFC 9773 sec. 4.3 gate, so the validating wrapper fails closed on a non-finite value.
|
|
2116
|
+
function clk() { var t = _rawClk(); if (typeof t !== "number" || !isFinite(t)) throw E("acme/bad-input", "the renewalInfo clock returned a non-finite value"); return t; }
|
|
2117
|
+
// RFC 9773 sec. 4.3: a client MUST NOT check a certificate's RenewalInfo after it has expired. Gate BEFORE the
|
|
2118
|
+
// unauthenticated GET -- the same expiry test renewalWindow applies -- so the raw verb cannot query a dead cert.
|
|
2119
|
+
var notAfterMs = x509.parse(certDer).validity.notAfter.getTime();
|
|
2120
|
+
// allow:nan-date-comparison-unguarded -- notAfter is a codec-parsed cert date (asn1 readTime rejects a NaN instant).
|
|
2121
|
+
if (clk() > notAfterMs) throw E("acme/certificate-expired", "the certificate is already past its notAfter; a client MUST NOT check RenewalInfo after it has expired (RFC 9773 sec. 4.3)");
|
|
1587
2122
|
var certId = ariCertId(certDer);
|
|
1588
2123
|
return _resource("renewalInfo").then(function (base) {
|
|
2124
|
+
// Re-check expiry immediately BEFORE the GET: the directory fetch above (uncached) can take long enough that a
|
|
2125
|
+
// certificate close to notAfter crosses it in between, and RFC 9773 sec. 4.3 forbids the query after expiry.
|
|
2126
|
+
// allow:nan-date-comparison-unguarded -- notAfterMs is a codec-parsed cert date; clk() is validated finite.
|
|
2127
|
+
if (clk() > notAfterMs) throw E("acme/certificate-expired", "the certificate expired before the RenewalInfo request could be issued; a client MUST NOT check RenewalInfo after expiry (RFC 9773 sec. 4.3)");
|
|
1589
2128
|
// Append the certID to the PATH, before any query string (RFC 9773): a directory renewalInfo URL may
|
|
1590
2129
|
// carry a query, so string concatenation would push the certID into the query rather than the path.
|
|
1591
2130
|
var u = new URL(base);
|
|
@@ -1596,16 +2135,107 @@ function client(directoryUrl, opts) {
|
|
|
1596
2135
|
var obj = validateRenewalInfo(_json(res));
|
|
1597
2136
|
var ra = res.headers["retry-after"];
|
|
1598
2137
|
var retryAfterSeconds = null;
|
|
1599
|
-
if (typeof ra === "string" && ra.trim() !== "")
|
|
2138
|
+
if (typeof ra === "string" && ra.trim() !== "") {
|
|
2139
|
+
// renewalWindow passes a cap: the Retry-After is ADVISORY there and is clamped anyway, so a huge or
|
|
2140
|
+
// unparseable value must clamp / drop to null (renewalWindow then defaults) rather than discard the
|
|
2141
|
+
// validated window. The raw renewalInfo verb (no cap) stays strict -- a bad Retry-After fails closed.
|
|
2142
|
+
var raOpts = { now: clk(), E: E, code: "acme/bad-retry-after" };
|
|
2143
|
+
if (typeof retryAfterCapSeconds === "number") { raOpts.cap = retryAfterCapSeconds; raOpts.lenient = true; }
|
|
2144
|
+
retryAfterSeconds = retryAfter.parse(ra, raOpts).retryAfterSeconds;
|
|
2145
|
+
}
|
|
1600
2146
|
return { renewalInfo: obj, retryAfterSeconds: retryAfterSeconds };
|
|
1601
2147
|
});
|
|
1602
2148
|
});
|
|
1603
2149
|
}
|
|
1604
2150
|
|
|
2151
|
+
// ARI renewal-window decision (RFC 9773 sec. 4.2 / 4.3) -- a pure, timer-less helper that composes the
|
|
2152
|
+
// unauthenticated renewalInfo GET, picks a uniform-random instant in the CA's suggested window, and
|
|
2153
|
+
// returns the decision as DATA. It never sleeps or schedules: an auto-renewing daemon is an explicit
|
|
2154
|
+
// future opt-in, not a hidden timer inside the thin client.
|
|
2155
|
+
function _renewalWindow(certDer, o) {
|
|
2156
|
+
o = o || {};
|
|
2157
|
+
return Promise.resolve().then(function () {
|
|
2158
|
+
if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "renewalWindow requires a DER certificate Buffer");
|
|
2159
|
+
if (o.random !== undefined && typeof o.random !== "function") throw E("acme/bad-input", "renewalWindow opts.random must be a function returning a number in [0, 1]");
|
|
2160
|
+
if (o.clock !== undefined && typeof o.clock !== "function") throw E("acme/bad-input", "renewalWindow opts.clock must be a function returning epoch milliseconds");
|
|
2161
|
+
// replaced is the sec. 4.3 "MUST NOT act on a replaced certificate" signal: require a real boolean so a
|
|
2162
|
+
// truthy non-boolean (a caller storing replacement state as a timestamp/object) cannot fail OPEN past
|
|
2163
|
+
// the gate below (=== true would let it through) and issue the RenewalInfo GET the RFC forbids.
|
|
2164
|
+
if (o.replaced !== undefined && typeof o.replaced !== "boolean") throw E("acme/bad-input", "renewalWindow opts.replaced must be a boolean");
|
|
2165
|
+
// opts.previous is a prior renewalWindow RESULT the caller stored: RFC 9773 sec. 4.2 says the client SHOULD
|
|
2166
|
+
// store the selected time and reuse it, so refreshing ARI (a later call after retryAfterSeconds) keeps the
|
|
2167
|
+
// SAME renewal instant while the CA's window is unchanged rather than re-randomizing and jittering it.
|
|
2168
|
+
if (o.previous !== undefined && !_isObject(o.previous)) throw E("acme/bad-input", "renewalWindow opts.previous must be a prior renewalWindow result object");
|
|
2169
|
+
// A per-call clock (documented opt) overrides the client's receipt clock for the WHOLE decision -- the
|
|
2170
|
+
// expiry gate, the renew-now comparison, and the RenewalInfo Retry-After parse -- so a caller can ask
|
|
2171
|
+
// "should I renew as of time T?" deterministically without reconfiguring the client. Every READING must
|
|
2172
|
+
// be a finite epoch ms: a NaN / Infinity would make each relational comparison silently false and bypass
|
|
2173
|
+
// the expiry gate, so the validating wrapper fails closed on a non-finite value.
|
|
2174
|
+
var _clk = typeof o.clock === "function" ? o.clock : clock;
|
|
2175
|
+
function clk() { var t = _clk(); if (typeof t !== "number" || !isFinite(t)) throw E("acme/bad-input", "the renewalWindow clock returned a non-finite value"); return t; }
|
|
2176
|
+
// RFC 9773 sec. 4.3: a client MUST NOT act on renewal info for a certificate it can no longer use --
|
|
2177
|
+
// one already past its notAfter (nothing to renew) or one the caller knows has been replaced. Gate
|
|
2178
|
+
// BEFORE the fetch so a decommissioned certificate never even reaches the CA.
|
|
2179
|
+
var notAfter = x509.parse(certDer).validity.notAfter;
|
|
2180
|
+
var now = clk();
|
|
2181
|
+
// Exclusive of notAfter: X.509 validity is inclusive of notAfter (a cert is still valid AT it), matching
|
|
2182
|
+
// path-validate's `t > notAfter` expiry test -- only a cert strictly past notAfter has nothing to renew.
|
|
2183
|
+
// allow:nan-date-comparison-unguarded -- notAfter is a codec-parsed cert date (asn1 readTime rejects a NaN
|
|
2184
|
+
// instant) and the window start/end below are rfc3339-validated by validateRenewalInfo; neither is NaN.
|
|
2185
|
+
if (now > notAfter.getTime()) throw E("acme/certificate-expired", "the certificate is already past its notAfter; there is nothing to renew (RFC 9773 sec. 4.3)");
|
|
2186
|
+
if (o.replaced === true) throw E("acme/certificate-replaced", "the caller asserts this certificate has already been replaced (RFC 9773 sec. 4.3)");
|
|
2187
|
+
return _renewalInfo(certDer, clk, RENEWAL_RETRY_MAX_SECONDS).then(function (ri) {
|
|
2188
|
+
var w = ri.renewalInfo.suggestedWindow;
|
|
2189
|
+
// start/end are grammar+calendar-validated RFC 3339 by validateRenewalInfo (an inverted window has
|
|
2190
|
+
// already thrown acme/bad-renewal-window), so neither Date.parse is NaN and end > start here.
|
|
2191
|
+
var startMs = Date.parse(w.start), endMs = Date.parse(w.end);
|
|
2192
|
+
// Bound the window by the certificate's own expiry: a renewal instant AFTER notAfter is useless (you
|
|
2193
|
+
// must renew BEFORE the cert dies), so a CA suggestedWindow that extends past notAfter is clamped to it
|
|
2194
|
+
// -- selection stays uniform within the still-valid sub-window. If the whole window is past notAfter,
|
|
2195
|
+
// effStart collapses to effEnd (renew at the last valid instant).
|
|
2196
|
+
var notAfterMs = notAfter.getTime();
|
|
2197
|
+
var effEnd = Math.min(endMs, notAfterMs), effStart = Math.min(startMs, effEnd);
|
|
2198
|
+
// RFC 9773 sec. 4.2: if the caller passed a prior result (opts.previous) whose suggestedWindow is UNCHANGED,
|
|
2199
|
+
// REUSE its selectedTime (clamped to the still-valid, notAfter-bounded window) instead of drawing again, so
|
|
2200
|
+
// repeated ARI refreshes converge on one stable renewal instant. A changed window (or no prior) draws fresh.
|
|
2201
|
+
var pw = o.previous && _isObject(o.previous.suggestedWindow) ? o.previous.suggestedWindow : null;
|
|
2202
|
+
var selectedMs;
|
|
2203
|
+
if (pw && pw.start === w.start && pw.end === w.end && _isString(o.previous.selectedTime)) {
|
|
2204
|
+
var prevMs = Date.parse(o.previous.selectedTime);
|
|
2205
|
+
if (!isFinite(prevMs)) throw E("acme/bad-input", "renewalWindow opts.previous.selectedTime must be an RFC 3339 date-time");
|
|
2206
|
+
selectedMs = Math.max(effStart, Math.min(effEnd, prevMs));
|
|
2207
|
+
} else {
|
|
2208
|
+
// Validate the draw's TYPE, never coerce: Number(null)/Number(false) -> 0, Number("0.5") -> 0.5 would
|
|
2209
|
+
// silently accept a non-number callback return. Require an actual number in [0, 1].
|
|
2210
|
+
var draw = o.random ? o.random() : _defaultRandom();
|
|
2211
|
+
if (typeof draw !== "number" || !(draw >= 0 && draw <= 1)) throw E("acme/bad-input", "renewalWindow opts.random must return a number in [0, 1]");
|
|
2212
|
+
// select a uniform-random instant across the (validity-bounded) window to spread renewals; if it is
|
|
2213
|
+
// already in the past, the caller should renew immediately.
|
|
2214
|
+
selectedMs = Math.round(effStart + draw * (effEnd - effStart));
|
|
2215
|
+
}
|
|
2216
|
+
var retryAfterSeconds = ri.retryAfterSeconds == null ? RENEWAL_RETRY_DEFAULT_SECONDS :
|
|
2217
|
+
Math.max(RENEWAL_RETRY_MIN_SECONDS, Math.min(RENEWAL_RETRY_MAX_SECONDS, ri.retryAfterSeconds));
|
|
2218
|
+
return {
|
|
2219
|
+
suggestedWindow: w,
|
|
2220
|
+
selectedTime: new Date(selectedMs).toISOString(),
|
|
2221
|
+
// Decide renew-now against a FRESH clock read: the RenewalInfo GET may itself span the selected
|
|
2222
|
+
// instant, so the pre-fetch `now` (used above only for the expiry gate) could be stale here. Also
|
|
2223
|
+
// renew now when the SELECTED instant lands at or past notAfter -- whether the whole window opened
|
|
2224
|
+
// after expiry or a straddling window's draw hit the clamp endpoint, there is no margin left, so
|
|
2225
|
+
// waiting for the (clamped) time would leave the cert to expire unrenewed.
|
|
2226
|
+
renewNow: selectedMs <= clk() || selectedMs >= notAfterMs,
|
|
2227
|
+
retryAfterSeconds: retryAfterSeconds,
|
|
2228
|
+
explanationURL: _isString(ri.renewalInfo.explanationURL) ? ri.renewalInfo.explanationURL : null,
|
|
2229
|
+
};
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
|
|
1605
2234
|
return {
|
|
1606
2235
|
directory: _directory,
|
|
1607
2236
|
newAccount: _newAccount,
|
|
1608
2237
|
newOrder: _newOrder,
|
|
2238
|
+
newAuthz: _newAuthz,
|
|
1609
2239
|
getOrder: _getOrder,
|
|
1610
2240
|
getAuthorization: _getAuthorization,
|
|
1611
2241
|
getChallenge: _getChallenge,
|
|
@@ -1619,6 +2249,7 @@ function client(directoryUrl, opts) {
|
|
|
1619
2249
|
deactivateAuthorization: _deactivateAuthorization,
|
|
1620
2250
|
keyChange: _keyChange,
|
|
1621
2251
|
renewalInfo: _renewalInfo,
|
|
2252
|
+
renewalWindow: _renewalWindow,
|
|
1622
2253
|
};
|
|
1623
2254
|
}
|
|
1624
2255
|
|
|
@@ -1639,6 +2270,7 @@ module.exports = {
|
|
|
1639
2270
|
newAccount: newAccount,
|
|
1640
2271
|
externalAccountBinding: externalAccountBinding,
|
|
1641
2272
|
newOrder: newOrder,
|
|
2273
|
+
newAuthz: newAuthz,
|
|
1642
2274
|
finalize: finalize,
|
|
1643
2275
|
challengeResponse: challengeResponse,
|
|
1644
2276
|
deactivate: deactivate,
|
package/lib/http-retry-after.js
CHANGED
|
@@ -70,25 +70,34 @@ function httpDateMs(s, refMs) {
|
|
|
70
70
|
// gates absence with its own code) is a delay-seconds integer -> retryAfterSeconds, or an HTTP-date ->
|
|
71
71
|
// retryAfterDate (epoch ms) plus, when opts.now (epoch ms) is given, a bounded retryAfterSeconds. Either
|
|
72
72
|
// form beyond the one-year ceiling, or a value that is neither, fails closed via opts.E(opts.code, ...).
|
|
73
|
-
//
|
|
73
|
+
// opts.cap (seconds) clamps a value above the cap to the cap instead of rejecting it; opts.lenient surfaces
|
|
74
|
+
// an otherwise-rejected value as a null retryAfterSeconds -- both for a caller (e.g. an ARI poll cadence)
|
|
75
|
+
// for whom the value is advisory and must not discard the response. Never slept on here -- the value is
|
|
76
|
+
// SURFACED for the caller to decide.
|
|
74
77
|
function parse(value, opts) {
|
|
75
78
|
opts = opts || {};
|
|
76
79
|
var raStr = String(value).trim();
|
|
77
80
|
var out = { retryAfterSeconds: null, retryAfterDate: null };
|
|
78
81
|
if (/^\d+$/.test(raStr)) {
|
|
79
82
|
var n = parseInt(raStr, 10);
|
|
80
|
-
|
|
83
|
+
// opts.cap (seconds): a caller that will clamp anyway (e.g. an ARI poll cadence) wants a delay ABOVE its
|
|
84
|
+
// ceiling reduced to the ceiling, not rejected -- so a valid-but-huge value never discards the response.
|
|
85
|
+
if (typeof opts.cap === "number" && n > opts.cap) { out.retryAfterSeconds = opts.cap; return out; }
|
|
86
|
+
// opts.lenient: a caller for whom the value is purely advisory wants an UNPARSEABLE one surfaced as
|
|
87
|
+
// null (retryAfterSeconds stays null) rather than a hard reject that would discard the whole response.
|
|
88
|
+
if (!Number.isSafeInteger(n) || n > MAX_RETRY_AFTER_SECONDS) { if (opts.lenient) return out; throw _fail(opts, "the Retry-After delay is out of the supported range (0.." + MAX_RETRY_AFTER_SECONDS + " seconds)"); }
|
|
81
89
|
out.retryAfterSeconds = n;
|
|
82
90
|
return out;
|
|
83
91
|
}
|
|
84
92
|
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));
|
|
93
|
+
if (isNaN(when)) { if (opts.lenient) return out; 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
94
|
out.retryAfterDate = when;
|
|
87
95
|
if (typeof opts.now === "number" && isFinite(opts.now)) {
|
|
88
96
|
// Round the remaining whole-second delay UP (a sub-second date must not retry before the requested
|
|
89
97
|
// time), clamping a past date to 0.
|
|
90
98
|
var d = Math.max(0, Math.ceil((when - opts.now) / constants.TIME.seconds(1)));
|
|
91
|
-
if (
|
|
99
|
+
if (typeof opts.cap === "number" && d > opts.cap) { out.retryAfterSeconds = opts.cap; return out; }
|
|
100
|
+
if (d > MAX_RETRY_AFTER_SECONDS) { if (opts.lenient) return out; throw _fail(opts, "the Retry-After date is beyond the supported horizon (" + MAX_RETRY_AFTER_SECONDS + " seconds)"); }
|
|
92
101
|
out.retryAfterSeconds = d;
|
|
93
102
|
}
|
|
94
103
|
return out;
|
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:3a0c2231-58bb-4cd0-ae0c-88a58b06b1bf",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-02T13:10:19.880Z",
|
|
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.29",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.29",
|
|
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.29",
|
|
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.29",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|