@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 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`, and `renewalInfo` (ARI) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. Over the message layer it composes: resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
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 subtle = require("./webcrypto").webcrypto.subtle;
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
- { name: "challenges", type: "array", required: true, minItems: 1, elemType: "challenge" },
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
- if (field.minItems && value.length < field.minItems) return "must have at least " + field.minItems + " element(s)";
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) || !_isString(id.type) || !_isString(id.value)) throw E("acme/bad-identifier", "an identifier must be { type, value } strings");
220
- if (id.type === "dns") {
221
- if (id.value.indexOf("*.") === 0) throw E("acme/bad-identifier", "a wildcard *. value is not permitted in an authorization identifier (RFC 8555 sec. 7.1.4)");
222
- _assertDnsName(id.value);
223
- } else if (id.type === "ip") {
224
- _assertIpAddress(id.value);
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
- // An unrecognized identifier type is surfaced raw (a server may add types).
227
- return id;
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) || !_isString(id.type) || !_isString(id.value)) throw E("acme/bad-identifier", "an order identifier must be { type, value } strings");
762
- if (id.type === "dns") {
763
- var v = id.value;
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 (id.type === "ip") {
772
- if (id.value.indexOf("*") !== -1) throw E("acme/bad-identifier", "an ip identifier has no wildcard form (RFC 8738)");
773
- _assertIpAddress(id.value);
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
- return id;
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
- o.identifiers.forEach(_validateOrderIdentifier);
798
- var payload = { identifiers: o.identifiers };
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
- if (url.hash) throw E("acme/bad-url", "an ACME URL must not contain a fragment: " + JSON.stringify(s));
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 abs;
1181
- try { abs = new URL(loc, base).href; }
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
- return _clientUrl(abs);
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
- function _downloadCertificate(url) {
1523
- return _post(_clientUrl(url), postAsGet, null, "kid", "application/pem-certificate-chain").then(function (res) {
1524
- if (res.status !== 200) throw _serverProblem(res);
1525
- // The certificate representation is bound to application/pem-certificate-chain (RFC 8555 sec. 7.4.2):
1526
- // a 200 with a wrong media type (a proxy error, a misrouted response) fails closed even if its body
1527
- // happens to contain parseable PEM. Match the media-type TOKEN exactly (before any ;-parameters), so
1528
- // a lookalike like application/pem-certificate-chain-evil does not slip through a substring test.
1529
- var ctToken = String(res.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
1530
- 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)");
1531
- var chain = _splitPemChain(_bodyText(res));
1532
- return { certificate: chain[0], chain: chain.slice(1), certificates: chain, alternates: [] };
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() !== "") retryAfterSeconds = retryAfter.parse(ra, { now: clock(), E: E, code: "acme/bad-retry-after" }).retryAfterSeconds;
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,
@@ -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
- // Never slept on here -- the value is SURFACED for the caller to decide.
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
- if (!Number.isSafeInteger(n) || n > MAX_RETRY_AFTER_SECONDS) throw _fail(opts, "the Retry-After delay is out of the supported range (0.." + MAX_RETRY_AFTER_SECONDS + " seconds)");
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 (d > MAX_RETRY_AFTER_SECONDS) throw _fail(opts, "the Retry-After date is beyond the supported horizon (" + MAX_RETRY_AFTER_SECONDS + " seconds)");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.28",
3
+ "version": "0.3.29",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:ea40d20f-ad92-403e-8de1-cfd14bead6ba",
5
+ "serialNumber": "urn:uuid:3a0c2231-58bb-4cd0-ae0c-88a58b06b1bf",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-01T18:47:21.356Z",
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.28",
22
+ "bom-ref": "@blamejs/pki@0.3.29",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.28",
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.28",
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.28",
57
+ "ref": "@blamejs/pki@0.3.29",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]