@blamejs/pki 0.3.27 → 0.3.28

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,16 @@ 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.28 — 2026-08-01
8
+
9
+ 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.
10
+
11
+ ### Added
12
+
13
+ - pki.est.serverkeygen(baseUrl, csr, opts?) requests a server-generated key pair + certificate (RFC 7030 sec. 4.4): it POSTs the CSR (application/pkcs10, identical encoding to simpleenroll) to /.well-known/est/serverkeygen and returns { certificates, privateKey } for a cleartext PKCS#8 PrivateKeyInfo key part, { certificates, encryptedKey } for a CMS EnvelopedData key part (surfaced structurally, never decrypted), or { retry, retryAfterSeconds, retryAfterDate } on a 202. The certificates are returned raw (no leaf is selected -- the CA generated the key, so the issued certificate carries the generated public key, not the throwaway CSR key). A cleartext key is bound to its certificate before it is surfaced: the delivered private key's public half must match exactly one returned certificate, so a key mis-associated with the returned certificate set is refused rather than handed back unusable. Whether the key part must be encrypted, and to which recipient, is derived from the CSR's DecryptKeyIdentifier (a symmetric key-encryption key) or AsymmetricDecryptKeyIdentifier (an asymmetric key) attribute; the recipient MECHANISM is preserved and required to match the RecipientInfo arm, so a symmetric KEK recipient never satisfies an advertised asymmetric key (or vice versa) merely because the identifier bytes coincide. An opts value that contradicts the CSR is refused, and a cleartext key delivered where the CSR requested encryption is refused, as is an encrypted key delivered where the CSR advertised no decryption key to open it (an unusable, unsolicited credential). The delivered key's channel must negotiate a confidentiality-bearing cipher (a NULL / anonymous / EXPORT suite is refused). https-only, explicit-anchor, and the full redirect / auth / budget machinery of the enrollment verbs apply.
14
+ - pki.est.csrattrs(baseUrl, opts?) fetches the CA's CSR-attributes policy (RFC 7030 sec. 4.5, RFC 9908): a 200 application/csrattrs body is parsed and returned as { available: true, attrs, plan }, where plan is the enroll-attribute plan the caller builds its next CSR from; a 204 or 404 is { available: false } (a valid "CSR Attributes Response not available"); an empty CsrAttrs is a complete empty policy. The verb never applies attributes to a CSR itself. Server authentication is not required for this policy GET, but a 401 is honored so the auth path stays available.
15
+ - HTTP Digest access authentication (RFC 7616) is available on every EST verb as an alternative to HTTP Basic via opts.auth = { scheme: "digest", username, password }. SHA-256 and SHA-512-256 are supported; MD5 and MD5-sess are refused unless opts.auth.allowMD5 is set, and a legacy no-qop (RFC 2069) challenge is refused unless opts.auth.allowLegacyQop is set. Among multiple offered challenges the most secure USABLE one is chosen -- a challenge this client cannot answer under its policy (an unsupported or disallowed algorithm, or a qop mode it does not accept) never shadows a lower-ranked usable one, so the client authenticates on a usable offer instead of picking the strongest and failing; when no offer is usable it fails closed with the specific reason rather than downgrading to a weaker scheme. A server stale=true re-challenge is answered under a bounded opts.auth.maxStaleRetries budget. The response is scoped to the challenge's protection space: within an origin it is preemptively reused only for the URIs the challenge's domain directive covers (a quoted directive -- a malformed unquoted one is refused, not silently widened; an absolute-URI entry is matched by its authority as well as its path, so an entry naming a different host never widens the scope to this one; the query is kept in the comparison, so an entry scoped to one query does not cover a different one), and a same-origin resource outside that scope is authenticated afresh (bounded) -- classified by whether the request actually carried a credential, so a resource sent unauthenticated is never mistaken for a rejected one -- rather than sent the first realm's response or blocked outright. A non-ASCII username under a charset=UTF-8 challenge is carried in the RFC 5987 username* extended form (percent-encoded UTF-8) rather than the legacy quoted field a server would read as ISO-8859-1, and a non-ASCII realm's incoming header octets are hashed unchanged, so a UTF-8 realm contributes the same octets the server used and the response matches an RFC 7616 server. As with Basic, the credential is answered only on the origin the caller authenticated to -- never sent to a redirected origin, and never as a silent Basic downgrade of a Digest challenge.
16
+
7
17
  ## v0.3.27 — 2026-07-31
8
18
 
9
19
  pki.cmp.session drives a full CMP certificate enrollment end to end -- build, transfer, and verify every leg of an ir/cr/kur/p10cr exchange, with every response protection-checked before its body is read.
package/README.md CHANGED
@@ -216,7 +216,7 @@ is callable today; nothing below is a stub.
216
216
  | `pki.schema.pkcs12` | Parse DER / BER / PEM RFC 7292 PKCS#12 (PFX) stores — key bags via the PKCS#8 parser, shrouded keys (algorithm surfaced, ciphertext opaque), cert / CRL / secret bags raw and byte-exact, encrypted and enveloped safes structurally via CMS, `friendlyName` / `localKeyId` decoded, and the exact MAC byte range (`macedBytes`) plus RFC 9579 PBMAC1 recognition for external verification; BER accepted exactly where §4.1 requires it, fail-closed — `parse`, `pemDecode`, `pemEncode` |
217
217
  | `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
218
218
  | `pki.schema.csrattrs` | Parse DER EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC/RSA key-type conventions, the certification-request-info template). Unknown types tolerated (surfaced raw), fail-closed on structure and the RFC 9908 semantic MUSTs — `parse` |
219
- | `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic answered only after the server is authenticated, and the issued certificate chosen by public-key match. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `cacerts`, `simpleenroll`, `simplereenroll`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
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
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` |
@@ -272,9 +272,9 @@ certificate and writes a DER (or `--pem`) SignedData to `--out` or stdout.
272
272
 
273
273
  ### What's coming
274
274
 
275
- The remaining EST network verbs (`/serverkeygen`, `/csrattrs`, `/fullcmc`) and
276
- HTTP Digest auth, SCEP and CMC enrollment, and additional NIST-on-ramp PQC
277
- signatures as the OID registry admits them are on the roadmap and ride this same
275
+ The remaining EST network verb (`/fullcmc`), SCEP and CMC enrollment, and
276
+ additional NIST-on-ramp PQC signatures as the OID registry admits them are on
277
+ the roadmap and ride this same
278
278
  core. See
279
279
  [ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
280
280
  [CHANGELOG.md](CHANGELOG.md) for what has landed.
package/lib/constants.js CHANGED
@@ -161,6 +161,10 @@ var LIMITS = {
161
161
  // document plus multipart/header overhead (16 MiB * 4/3 ~= 21.3 MiB); 24 MiB
162
162
  // clears it. An operator may only tighten it DOWNWARD via opts.maxResponseBytes.
163
163
  HTTP_MAX_RESPONSE_BYTES: BYTES.mib(24),
164
+ // A single untrusted WWW-Authenticate challenge header, bounded BEFORE the string
165
+ // is copied/tokenized so a hostile server cannot drive unbounded work parsing it
166
+ // (CWE-770). 8 KiB clears any realistic multi-scheme Digest challenge.
167
+ HTTP_AUTH_HEADER_MAX_BYTES: BYTES.kib(8),
164
168
  // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
165
169
  // a whole-document cap refused before the walk, a nesting cap, and a per-value
166
170
  // bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
package/lib/est.js CHANGED
@@ -57,10 +57,13 @@ var constants = require("./constants");
57
57
  var cms = require("./schema-cms");
58
58
  var x509 = require("./schema-x509");
59
59
  var pkcs8 = require("./schema-pkcs8");
60
+ var key = require("./key");
60
61
  var csr = require("./schema-csr");
62
+ var csrattrsFmt = require("./schema-csrattrs"); // aliased: the `csrattrs` name is the verb + the public export
61
63
  var frameworkError = require("./framework-error");
62
64
  var guard = require("./guard-all");
63
65
  var httpTransport = require("./http-transport");
66
+ var httpDigest = require("./http-digest");
64
67
  var retryAfter = require("./http-retry-after");
65
68
 
66
69
  var EstError = frameworkError.EstError;
@@ -175,7 +178,10 @@ function splitMultipartMixed(body, contentType) {
175
178
  var rawHeaders = seg.slice(0, sep);
176
179
  var partBody = seg.slice(sep).replace(/^(\r?\n){2}/, "").replace(/\r?\n$/, "");
177
180
  var headers = {};
178
- rawHeaders.split(/\r?\n/).forEach(function (line) {
181
+ // Unfold MIME part headers (RFC 5322 sec. 2.2.3 / RFC 2046): a CRLF immediately followed by whitespace is a
182
+ // FOLD, not a header break, so it is removed before the split -- a Content-Type whose smime-type parameter
183
+ // continues on the next line must be read whole, not truncated.
184
+ rawHeaders.replace(/\r?\n(?=[ \t])/g, "").split(/\r?\n/).forEach(function (line) {
179
185
  var col = line.indexOf(":");
180
186
  if (col > 0) headers[line.slice(0, col).trim().toLowerCase()] = line.slice(col + 1).trim();
181
187
  });
@@ -235,14 +241,24 @@ function findIssuedCert(certs, target) {
235
241
  // Collect every key-identifier (subjectKeyIdentifier / KEKIdentifier) a set of
236
242
  // RecipientInfos names, across the ktri / kari / kekri / kemri arms -- the
237
243
  // byte identifiers a client's advertised decryptKeyID would match.
238
- function _recipientKeyIds(recipientInfos) {
244
+ // The key-identifier bytes each RecipientInfo arm names, filtered by MECHANISM (RFC 7030 sec. 4.4.1): an
245
+ // ASYMMETRIC decryption key (AsymmetricDecryptKeyIdentifier) is named by a subjectKeyIdentifier on a
246
+ // KeyTrans / KeyAgree / KEM arm, a SYMMETRIC key (DecryptKeyIdentifier) by the keyIdentifier of a KEK arm.
247
+ // Filtering keeps a symmetric KEK arm from satisfying an advertised asymmetric key (and vice-versa) merely
248
+ // because the identifier bytes coincide -- the returned key would be unusable with the mechanism requested.
249
+ // kind: "asymmetric" | "symmetric" | undefined (any, for a direct caller that did not state the mechanism).
250
+ function _recipientKeyIds(recipientInfos, kind) {
239
251
  var ids = [];
240
252
  function push(v) { if (Buffer.isBuffer(v)) ids.push(v); }
241
253
  (recipientInfos || []).forEach(function (r) {
242
- if (r.rid) push(r.rid.subjectKeyIdentifier);
243
- if (r.kemri && r.kemri.rid) push(r.kemri.rid.subjectKeyIdentifier); // KEMRecipientInfo (RFC 9629, under the ori arm)
244
- if (r.kekid) push(r.kekid.keyIdentifier);
245
- (r.recipientEncryptedKeys || []).forEach(function (rek) { if (rek.rid) push(rek.rid.subjectKeyIdentifier); });
254
+ if (kind !== "symmetric") {
255
+ if (r.rid) push(r.rid.subjectKeyIdentifier);
256
+ if (r.kemri && r.kemri.rid) push(r.kemri.rid.subjectKeyIdentifier); // KEMRecipientInfo (RFC 9629, under the ori arm)
257
+ (r.recipientEncryptedKeys || []).forEach(function (rek) { if (rek.rid) push(rek.rid.subjectKeyIdentifier); });
258
+ }
259
+ if (kind !== "asymmetric") {
260
+ if (r.kekid) push(r.kekid.keyIdentifier);
261
+ }
246
262
  });
247
263
  return ids;
248
264
  }
@@ -266,11 +282,23 @@ function _recipientIssuerSerials(recipientInfos) {
266
282
  // form the server may have used to name the same requested key.
267
283
  function _recipientMatches(recipientInfos, opts) {
268
284
  if (Buffer.isBuffer(opts.expectedRecipientKeyId) &&
269
- _recipientKeyIds(recipientInfos).some(function (id) { return id.equals(opts.expectedRecipientKeyId); })) return true;
285
+ _recipientKeyIds(recipientInfos, opts.expectedRecipientKind).some(function (id) { return id.equals(opts.expectedRecipientKeyId); })) return true;
270
286
  var ias = opts.expectedRecipientIssuerSerial;
271
287
  if (ias && Buffer.isBuffer(ias.issuer) && ias.serialNumber != null) {
272
- var want = typeof ias.serialNumber === "bigint" ? ias.serialNumber : BigInt(ias.serialNumber);
273
- if (_recipientIssuerSerials(recipientInfos).some(function (r) { return r.issuer.equals(ias.issuer) && r.serialNumber === want; })) return true;
288
+ // Validate the caller-supplied serial BEFORE BigInt() so a bad value is a typed est/bad-input, never a
289
+ // raw SyntaxError / RangeError leaking from the codec (the single choke point for both the serverkeygen
290
+ // verb and the public parseServerKeygenResponse).
291
+ var sn = ias.serialNumber, want;
292
+ if (typeof sn === "bigint" && sn >= 0n) want = sn;
293
+ else if (typeof sn === "number" && Number.isSafeInteger(sn) && sn >= 0) want = BigInt(sn);
294
+ else if (typeof sn === "string" && /^[0-9]+$/.test(sn)) want = BigInt(sn);
295
+ else throw E("est/bad-input", "expectedRecipientIssuerSerial.serialNumber must be a NON-NEGATIVE bigint, a safe non-negative integer, or a decimal digit string (a certificate serial is non-negative, RFC 5280 sec. 4.1.2.2)");
296
+ // An issuerAndSerialNumber names an ASYMMETRIC recipient -- a certificate identity the server derived from
297
+ // an AsymmetricDecryptKeyIdentifier (RFC 7030 sec. 4.4.2); a symmetric KEK recipient has none. So a SYMMETRIC
298
+ // request must NOT be satisfied by an asymmetric arm's issuer+serial, or the client would accept ciphertext
299
+ // undecryptable with the symmetric key it requested. The key-id branch applies the same mechanism filter.
300
+ if (opts.expectedRecipientKind !== "symmetric" &&
301
+ _recipientIssuerSerials(recipientInfos).some(function (r) { return r.issuer.equals(ias.issuer) && r.serialNumber === want; })) return true;
274
302
  }
275
303
  return false;
276
304
  }
@@ -337,6 +365,11 @@ function parseServerKeygenResponse(body, contentType, opts) {
337
365
  }
338
366
  if (!keyPart || !certPart) throw E("est/bad-multipart", "a serverkeygen response needs one key part and one certificate part");
339
367
  if (opts.requestedEncryption && !encrypted) throw E("est/expected-encrypted-key", "encryption was requested but the private-key part is cleartext (RFC 7030 sec. 4.4.2)");
368
+ // The reverse is also a mismatch: the CSR advertised NO key-encryption key (requestedEncryption explicitly
369
+ // false), yet the server returned an EnvelopedData. The client holds nothing to decrypt it, so an unsolicited
370
+ // encrypted key is an unusable credential, not a success (RFC 7030 sec. 4.4.2). (An undefined requestedEncryption
371
+ // -- a low-level caller not stating the mode -- stays permissive.)
372
+ if (opts.requestedEncryption === false && encrypted) throw E("est/unexpected-encrypted-key", "the server returned an encrypted key but the CSR advertised no DecryptKeyIdentifier / AsymmetricDecryptKeyIdentifier to decrypt it (RFC 7030 sec. 4.4.2)");
340
373
  var out = { certificates: parseCertsOnly(transferDecode(certPart.body)).certificates };
341
374
  if (encrypted) {
342
375
  // The server-generated key MUST be a CMS EnvelopedData (RFC 7030 sec. 4.4.2);
@@ -360,7 +393,13 @@ function parseServerKeygenResponse(body, contentType, opts) {
360
393
  }
361
394
  out.encryptedKey = parsedKey;
362
395
  } else {
363
- out.privateKey = pkcs8.parse(transferDecode(keyPart.body));
396
+ var keyDer = transferDecode(keyPart.body);
397
+ out.privateKey = pkcs8.parse(keyDer);
398
+ // The raw cleartext PrivateKeyInfo DER is surfaced so a semantic layer can bind the key to its
399
+ // certificate (the public-key coherence check the verb runs). Structural parsing itself never
400
+ // does the crypto derivation -- mirroring parseCertsOnly, which splits certs while the enroll
401
+ // verb picks the issued one by public-key match.
402
+ out.privateKeyDer = keyDer;
364
403
  }
365
404
  return out;
366
405
  }
@@ -677,15 +716,35 @@ function _redirectTarget(current, location, method, opts) {
677
716
  return resolved;
678
717
  }
679
718
 
680
- // Does WWW-Authenticate advertise a Basic challenge? The quoted-string contents are blanked first
681
- // (a quoted auth-param may contain a comma + "Basic", e.g. `Digest realm="x, Basic required"`, which
682
- // must NOT be read as a Basic challenge), then Basic is matched as an auth-scheme TOKEN (at the start
683
- // or after a comma-separated challenge, followed by whitespace / a comma / end) -- never a substring,
684
- // so `Digest realm="basic"` / `Bearer error="basic required"` are not taken as Basic either.
685
- function _hasBasicChallenge(www) {
719
+ // Does WWW-Authenticate advertise the given auth-scheme as a TOKEN? The quoted-string contents are blanked
720
+ // first (a quoted auth-param may contain a comma + the scheme name, e.g. `Digest realm="x, Basic required"`,
721
+ // which must NOT be read as a Basic challenge), then the scheme is matched at the start or after a
722
+ // comma-separated challenge, followed by whitespace / a comma / end -- never a substring, so `Digest
723
+ // realm="basic"` is not taken as Basic. `scheme` is always our own literal ("Basic" / "Digest").
724
+ function _offersScheme(www, scheme) {
686
725
  var s = String(www || "").replace(/"(?:[^"\\]|\\.)*"/g, "\"\"");
687
- return /(?:^|,)\s*Basic(?=[\s,]|$)/i.test(s);
726
+ return new RegExp("(?:^|,)\\s*" + scheme + "(?=[\\s,]|$)", "i").test(s);
727
+ }
728
+ // The chosen HTTP auth scheme: an explicit opts.auth.scheme wins; a legacy opts.username / opts.password
729
+ // WITHOUT opts.auth means Basic (backwards compatible); otherwise none. There is NO scheme:"auto" -- a
730
+ // Basic<->Digest downgrade is never silently chosen (a MITM stripping Digest to Basic must not succeed).
731
+ function _authScheme(opts) {
732
+ if (opts.auth && opts.auth.scheme != null) {
733
+ var sc = String(opts.auth.scheme).toLowerCase();
734
+ if (sc !== "basic" && sc !== "digest") throw E("est/bad-input", "opts.auth.scheme must be \"basic\" or \"digest\"");
735
+ return sc;
736
+ }
737
+ if (opts.username !== undefined || opts.password !== undefined) return "basic";
738
+ return null;
688
739
  }
740
+ function _authUser(opts) { return opts.auth && opts.auth.username !== undefined ? opts.auth.username : opts.username; }
741
+ function _authPass(opts) { return opts.auth && opts.auth.password !== undefined ? opts.auth.password : opts.password; }
742
+ // Credentials present -- an EMPTY username is allowed (RFC 7030 sec. 3.2.3), so "defined" (not "truthy").
743
+ function _hasCreds(opts) { return _authUser(opts) !== undefined || _authPass(opts) !== undefined; }
744
+ var DIGEST_CODES = { unsupportedAlgorithm: "est/digest-unsupported-algorithm", weakAlgorithm: "est/digest-weak-algorithm", noQop: "est/digest-no-qop", badChallenge: "est/digest-bad-challenge" };
745
+ // The Digest answer policy, from opts.auth. The SAME object drives challenge SELECTION (parseChallenge) and
746
+ // the ANSWER (answer), so the client never selects a challenge under one policy then answers under another.
747
+ function _digestPolicy(opts) { return { allowMD5: !!(opts.auth && opts.auth.allowMD5), allowLegacyQop: !!(opts.auth && opts.auth.allowLegacyQop), codes: DIGEST_CODES }; }
689
748
 
690
749
  // The redirect-follow + HTTP-auth-retry loop. Returns the terminal transport response
691
750
  // ({status, headers, body}) -- a 2xx, a 202, or a non-401 4xx/5xx. A 3xx is followed
@@ -696,6 +755,9 @@ function _hasBasicChallenge(www) {
696
755
  function _drive(method, url, body, headers, opts, transport, budgets) {
697
756
  var redirects = 0;
698
757
  var authTried = false;
758
+ var staleRetries = 0; // bounded re-answers to a Digest stale=true re-challenge (a fresh nonce), never an open loop
759
+ var authSpaces = 0; // distinct Digest protection spaces (realms) entered beyond the first -- bounded so a server cannot loop with an endless stream of new realms
760
+ var lastDigestNonce = null; // the nonce just answered -- a stale=true retry MUST carry a DIFFERENT (fresh) nonce
699
761
  var initialOrigin = url.origin; // the origin the caller intended to authenticate to
700
762
  // The per-hop TLS: the origin-specific identity (mTLS cert/key + pinned SNI) is sent ONLY to the caller's
701
763
  // configured origin; a cross-origin hop gets a narrowed copy with those stripped (checkServerIdentity kept).
@@ -709,14 +771,30 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
709
771
  }
710
772
  return t;
711
773
  }
712
- // The per-hop Authorization: the HTTP Basic credential (established after a 401 on the caller's origin) is
713
- // sent ONLY to that origin. A cross-origin hop travels unauthenticated, but a redirect back to the origin
714
- // restores it -- so an authenticated flow that bounces off-origin and returns still completes (mirrors _tlsFor).
774
+ // The per-hop Authorization, sent ONLY to the caller's origin (a cross-origin hop travels unauthenticated; a
775
+ // redirect back to the origin restores it -- mirrors _tlsFor). A Basic credential is target-INDEPENDENT, so it
776
+ // is cached whole in authValue. A Digest response is bound to the request method + target, so the CHALLENGE is
777
+ // cached and the answer is (re)computed for the CURRENT method / uri on every hop -- a same-origin redirect (or
778
+ // a 303 POST->GET) then gets a target-correct answer instead of a stale one the server would reject.
715
779
  var authValue = null;
780
+ var digestChallenge = null;
781
+ var digestNcByNonce = Object.create(null); // nonce string -> count of requests answered under THAT nonce, tracked PER NONCE so a nonce reissued after others were used never repeats an nc (RFC 7616 sec. 3.4); bounded by the total answered 401s; a null-proto map so an attacker-chosen nonce (e.g. "__proto__") is an ordinary key
782
+ var digestSent = false; // whether the LAST request actually carried a Digest Authorization -- the DIRECT signal that a following 401 rejects a credential, vs. is a fresh challenge for a request sent unauthenticated
716
783
  function _headersFor(u) {
717
- if (!authValue || u.origin !== initialOrigin) return headers;
784
+ digestSent = false;
785
+ if (u.origin !== initialOrigin || (!authValue && !digestChallenge)) return headers;
718
786
  var hh = Object.assign({}, headers);
719
- hh.authorization = authValue;
787
+ if (digestChallenge) {
788
+ // Preemptively reuse the cached answer only WITHIN the challenge's protection space (RFC 7616 sec. 3.5):
789
+ // a same-origin redirect to a URI outside the challenge's `domain` is sent unauthenticated, so the target
790
+ // resource's own challenge drives a fresh authentication instead of receiving an Authorization computed
791
+ // for the wrong protection space.
792
+ if (!httpDigest.inProtectionSpace(digestChallenge, u.origin, u.pathname + u.search)) return headers;
793
+ var nkey = digestChallenge.nonce;
794
+ digestNcByNonce[nkey] = (digestNcByNonce[nkey] || 0) + 1;
795
+ hh.authorization = httpDigest.answer(digestChallenge, { method: method, uri: u.pathname + u.search, username: _authUser(opts), password: _authPass(opts), body: body, nc: digestNcByNonce[nkey], policy: _digestPolicy(opts) }, E);
796
+ digestSent = true;
797
+ } else { hh.authorization = authValue; }
720
798
  return hh;
721
799
  }
722
800
  function step() {
@@ -751,17 +829,65 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
751
829
  return step();
752
830
  }
753
831
  if (status === 401) {
754
- if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
755
- // Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a
756
- // cross-origin redirect is a different server, and the client MUST NOT send its credentials
757
- // there (RFC 7030 sec. 3.6 -- credentials go only to an authorized server the caller chose).
832
+ // Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a cross-origin
833
+ // redirect is a different server, and the client MUST NOT send its credentials there (RFC 7030 sec. 3.6).
758
834
  if (url.origin !== initialOrigin) throw E("est/auth-required", "refusing to send HTTP credentials to a redirected origin (RFC 7030 sec. 3.6)");
759
835
  var www = String(h["www-authenticate"] || "");
760
- if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
761
- if (opts.username === undefined && opts.password === undefined) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
836
+ // Bound the attacker-controlled challenge header BEFORE any scheme scan / copy (_offersScheme, the parser):
837
+ // an injected transport without its own header limit must not cause unbounded allocation or work despite
838
+ // the parser's own cap being applied later (CWE-400). The cap matches the Digest parser's.
839
+ if (www.length > constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES) throw E("est/auth-required", "the WWW-Authenticate header exceeds the " + constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES + "-byte cap (RFC 7030 sec. 3.2.3)");
840
+ var scheme = _authScheme(opts);
841
+ if (scheme === null) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
842
+ if (scheme === "digest") {
843
+ if (!_offersScheme(www, "Digest")) throw E("est/auth-required", "opts.auth.scheme is \"digest\" but the server offered no Digest challenge: " + www);
844
+ if (!_hasCreds(opts)) throw E("est/auth-required", "Digest authentication requires opts.auth.username / password (RFC 7030 sec. 3.2.3)");
845
+ // In a credential-rejection context (this request carried a Digest answer), bias selection toward a
846
+ // RETRYABLE stale=true offer so a stronger stale=false offer does not shadow one that could re-answer.
847
+ var selPol = _digestPolicy(opts);
848
+ selPol.preferStale = digestSent;
849
+ selPol.priorNonce = lastDigestNonce; // a retryable stale offer must carry a nonce DIFFERENT from the one just rejected
850
+ selPol.priorRealm = digestChallenge && digestChallenge.realm; // ...and be for the SAME realm that was rejected (a different realm is a new space, not a retry)
851
+ selPol.requestOrigin = url.origin; // prefer an offer whose protection space actually covers THIS request --
852
+ selPol.requestTarget = url.pathname + url.search; // one scoped elsewhere cannot authenticate it and must not shadow one that can
853
+ var ch = httpDigest.parseChallenge(www, E, "est/digest-bad-challenge", selPol);
854
+ if (!ch) throw E("est/auth-required", "the server offered no usable Digest challenge: " + www);
855
+ // Whether this 401 REJECTS a credential turns on the DIRECT signal (RFC 7616 sec. 3.3 / 3.5): did the
856
+ // request that received it actually CARRY a Digest answer for THIS protection space? A request sent
857
+ // unauthenticated -- the first attempt, or one whose target lay outside the cached challenge's domain --
858
+ // has not authenticated, so its 401 (even in the same realm) is a FRESH challenge to answer, never a
859
+ // rejection. A credentialed request refused in the SAME realm IS a rejection of that credential: only a
860
+ // stale=true re-challenge carrying a FRESH nonce may retry (a repeated nonce with stale=true is a
861
+ // self-contradictory / hostile server -> terminate). Any fresh authentication -- the first, a newly
862
+ // entered protection space, or an unauthenticated resource -- is bounded (maxRedirects) against a loop.
863
+ if (digestSent && digestChallenge && ch.realm === digestChallenge.realm) {
864
+ if (!(ch.stale && ch.nonce !== lastDigestNonce && staleRetries < budgets.maxStaleRetries)) throw E("est/auth-required", "the server rejected the credentialed Digest request (RFC 7030 sec. 3.2.3)");
865
+ staleRetries += 1;
866
+ } else {
867
+ if (authTried && authSpaces >= budgets.maxRedirects) throw E("est/auth-required", "the server demanded Digest authentication for too many distinct protection spaces (RFC 7616 sec. 3.3)");
868
+ if (authTried) authSpaces += 1;
869
+ authTried = true;
870
+ staleRetries = 0; // a fresh authentication (new space / unauthenticated resource) restarts the stale budget
871
+ }
872
+ lastDigestNonce = ch.nonce;
873
+ // Cache the CHALLENGE, not a fixed answer: _headersFor computes the response for the CURRENT method /
874
+ // request-target on each hop, so a same-origin redirect (or a 303 POST->GET) gets a target-correct answer
875
+ // rather than a stale one. A policy fault (MD5 / no-qop / unsupported) surfaces from _headersFor on the
876
+ // immediate re-send (the transport is not reached, so a rejected leg still proves calls did not advance).
877
+ // The nonce-count is tracked PER NONCE (digestNcByNonce), so a nonce reissued after other nonces were
878
+ // used in between resumes its own count -- a (nonce, nc) pair is never replayed, without a reset here.
879
+ digestChallenge = ch;
880
+ return step();
881
+ }
882
+ // Basic (default / explicit): the one-shot semantics unchanged.
883
+ if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
884
+ if (!_offersScheme(www, "Basic")) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic and Digest are supported): " + www);
885
+ // An explicit auth.scheme:"basic" with no credentials must fail closed too (an empty username stays legal),
886
+ // never transmit a "Basic Og==" (base64 of ":") the operator never supplied.
887
+ if (!_hasCreds(opts)) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
762
888
  // Establish the credential as origin-scoped state (_headersFor attaches it only on the initial origin),
763
889
  // never a mutation of the shared headers that would leak across a cross-origin redirect.
764
- authValue = "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64");
890
+ authValue = "Basic " + Buffer.from((_authUser(opts) || "") + ":" + (_authPass(opts) || ""), "utf8").toString("base64");
765
891
  authTried = true;
766
892
  return step();
767
893
  }
@@ -794,7 +920,9 @@ function _client(op, method, baseUrl, body, headers, opts) {
794
920
  timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: E, code: "est/bad-input", min: 1, max: MAX_TIMEOUT }),
795
921
  maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: E, code: "est/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
796
922
  maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "est/bad-input", min: 0, max: 32 }),
923
+ maxStaleRetries: guard.limits.cap(opts.auth && opts.auth.maxStaleRetries, "maxStaleRetries", 1, { E: E, code: "est/bad-input", min: 0, max: 8 }),
797
924
  };
925
+ _authScheme(opts); // config-time: a bad opts.auth.scheme throws est/bad-input here, never deferred to a 401
798
926
  return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
799
927
  }
800
928
 
@@ -944,10 +1072,205 @@ function simplereenroll(baseUrl, csrInput, opts) {
944
1072
  });
945
1073
  }
946
1074
 
1075
+ // ---- /serverkeygen (RFC 7030 sec. 4.4) ----------------------------------
1076
+
1077
+ // Derive the server-generated-key ENCRYPTION coherence from the CSR the caller actually POSTs (RFC 7030
1078
+ // sec. 4.4.1): a DecryptKeyIdentifier / AsymmetricDecryptKeyIdentifier attribute names the key-encryption
1079
+ // key the server must encrypt the generated private key to. Binding the expected recipient to the CSR (not a
1080
+ // free-floating opts boolean) closes the drift where a cleartext key rides past a caller who forgot the flag.
1081
+ function _serverkeygenEncryptionFromCsr(csrDer) {
1082
+ var attrs = csr.parse(csrDer).attributes || [];
1083
+ var keyId = null, kind = null;
1084
+ for (var i = 0; i < attrs.length; i++) {
1085
+ if (attrs[i].type !== OID_DECRYPT_KEY_ID && attrs[i].type !== OID_ASYMM_DECRYPT_KEY_ID) continue;
1086
+ // DecryptKeyIdentifier names a SYMMETRIC key-encryption key; AsymmetricDecryptKeyIdentifier an ASYMMETRIC
1087
+ // one (RFC 7030 sec. 4.4.1). The mechanism is preserved so the returned key part is matched to a compatible
1088
+ // RecipientInfo arm, not merely one whose identifier bytes coincide.
1089
+ var thisKind = attrs[i].type === OID_ASYMM_DECRYPT_KEY_ID ? "asymmetric" : "symmetric";
1090
+ var vals = attrs[i].values || [];
1091
+ if (vals.length !== 1) throw E("est/bad-input", "a serverkeygen key-identifier attribute must carry exactly one value (RFC 7030 sec. 4.4.1)");
1092
+ var id;
1093
+ try { id = asn1.read.octetString(asn1.decode(vals[0])); }
1094
+ catch (e) { throw E("est/bad-input", "a serverkeygen key-identifier attribute value is not a valid OCTET STRING", e); }
1095
+ if (keyId !== null && !keyId.equals(id)) throw E("est/bad-input", "the CSR advertised two different serverkeygen key identifiers (RFC 7030 sec. 4.4.1)");
1096
+ if (kind !== null && kind !== thisKind) throw E("est/bad-input", "the CSR advertised both a symmetric (DecryptKeyIdentifier) and an asymmetric (AsymmetricDecryptKeyIdentifier) serverkeygen key -- the recipient mechanism is ambiguous (RFC 7030 sec. 4.4.1)");
1097
+ keyId = id; kind = thisKind;
1098
+ }
1099
+ return { requestedEncryption: keyId !== null, expectedRecipientKeyId: keyId, expectedRecipientKind: kind };
1100
+ }
1101
+
1102
+ // The delivered private key rides TLS confidentiality alone: a NULL / anonymous / EXPORT suite MUST NOT be
1103
+ // used to carry it (RFC 7030 sec. 4.4, sec. 6). Assert (never reconfigure) the negotiated cipher when the
1104
+ // transport surfaces it; an injected transport that reports no cipher is trusted (the test/loopback channel).
1105
+ function _assertConfidentialCipher(res) {
1106
+ if (!res || !res.tls || !res.tls.cipher) return;
1107
+ var c = res.tls.cipher;
1108
+ var name = (String(c.name || "") + " " + String(c.standardName || "")).toUpperCase();
1109
+ // EXPORT covers the RFC/IANA name; the OpenSSL short names use an EXP prefix followed by a separator OR a
1110
+ // digit run (EXP-RC4-MD5, EXP1024-RC4-SHA), so match EXP before either -- a bare `\bEXP\b` misses EXP1024.
1111
+ if (/NULL|ANON|EXPORT|\bEXP[-_0-9]|\bA(EC)?DH\b/.test(name)) throw E("est/weak-cipher", "the serverkeygen channel negotiated a NULL / anonymous / EXPORT cipher (" + (c.standardName || c.name) + "), which cannot protect the delivered private key (RFC 7030 sec. 4.4)");
1112
+ }
1113
+
1114
+ // A case-insensitive header lookup: the injectable transport seam only promises { status, headers, body }, so a
1115
+ // hostile / non-Node transport may deliver "Content-Type" in any casing -- read it case-insensitively (classifyResponse
1116
+ // normalizes internally, but the multipart boundary is read from the header directly here).
1117
+ function _ciHeader(headers, name) {
1118
+ headers = headers || {};
1119
+ if (headers[name] !== undefined) return headers[name];
1120
+ var lname = name.toLowerCase(), keys = Object.keys(headers);
1121
+ for (var i = 0; i < keys.length; i++) { if (keys[i].toLowerCase() === lname) return headers[keys[i]]; }
1122
+ return null;
1123
+ }
1124
+
1125
+ async function _serverkeygenResult(res, opts, derived) {
1126
+ var verdict = classifyResponse(res.status, res.headers, res.body, { op: "serverkeygen", now: opts.now });
1127
+ // A 202 is an enrollment posture (RFC 7030 sec. 4.4 treats /serverkeygen as an enroll): surfaced, never slept.
1128
+ if (verdict.status === "retry") return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
1129
+ if (verdict.status !== "ok") throw E("est/http-error", "an EST serverkeygen response must be HTTP 200 or 202 (RFC 7030 sec. 4.4.2), got " + res.status);
1130
+ var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
1131
+ if (bodyLen === 0) throw E("est/empty-body", "a 200 serverkeygen response carried an empty body (RFC 7030 sec. 4.4.2)");
1132
+ _assertConfidentialCipher(res);
1133
+ // parseServerKeygenResponse does the per-part transfer-decode; the recipient coherence is bound to the CSR.
1134
+ var out = parseServerKeygenResponse(res.body, _ciHeader(res.headers, "content-type"), {
1135
+ requestedEncryption: derived.requestedEncryption,
1136
+ expectedRecipientKeyId: derived.expectedRecipientKeyId,
1137
+ expectedRecipientKind: derived.expectedRecipientKind,
1138
+ expectedRecipientIssuerSerial: opts.expectedRecipientIssuerSerial,
1139
+ });
1140
+ // Bind a CLEARTEXT server-generated key to its certificate (RFC 7030 sec. 4.4.2): the CA generated
1141
+ // this pair and issued a certificate over its PUBLIC half, so the delivered PrivateKeyInfo's public key
1142
+ // MUST equal EXACTLY ONE returned certificate's SubjectPublicKeyInfo -- a key unrelated to every
1143
+ // certificate is an unusable / mis-associated credential, and a key matching more than one leaves the
1144
+ // issued certificate ambiguous. Mirrors simpleenroll's findIssuedCert public-key match; the encrypted
1145
+ // key stays opaque, so its recipient coherence is bound in the parser instead. The public half is
1146
+ // derived through the key engine (never re-serialized here); a key whose public half cannot be derived
1147
+ // is not bindable and fails closed.
1148
+ if (out.privateKeyDer) {
1149
+ var spki;
1150
+ try { spki = await key.publicFromPrivate(out.privateKeyDer); }
1151
+ catch (e) { throw E("est/key-cert-mismatch", "the cleartext server-generated private key's public half could not be derived to bind it to a returned certificate (RFC 7030 sec. 4.4.2)", e); }
1152
+ var bound = findIssuedCert(out.certificates, spki);
1153
+ if (!bound) throw E("est/key-cert-mismatch", "the cleartext server-generated private key matches no returned certificate's public key (RFC 7030 sec. 4.4.2)");
1154
+ if (findIssuedCert(out.certificates.filter(function (c) { return c !== bound; }), spki)) throw E("est/ambiguous-issued-cert", "more than one returned certificate carries the server-generated key; the issued certificate is ambiguous (RFC 7030 sec. 4.4.2)");
1155
+ delete out.privateKeyDer;
1156
+ }
1157
+ return out;
1158
+ }
1159
+
1160
+ /**
1161
+ * @primitive pki.est.serverkeygen
1162
+ * @signature pki.est.serverkeygen(baseUrl, csr, opts?) -> Promise<{ certificates, privateKey } | { certificates, encryptedKey } | { retry, retryAfterSeconds, retryAfterDate }>
1163
+ * @since 0.3.28
1164
+ * @status experimental
1165
+ * @spec RFC 7030, RFC 8951
1166
+ * @related pki.est.simpleenroll, pki.est.parseServerKeygenResponse
1167
+ *
1168
+ * Request a SERVER-GENERATED key pair + certificate: POST the CSR (base64 DER, `Content-Type:
1169
+ * application/pkcs10`, identical request encoding to `simpleenroll`) to
1170
+ * `<baseUrl>/.well-known/est/serverkeygen`. The two-part `multipart/mixed` response is surfaced as
1171
+ * `{ certificates, privateKey }` (a cleartext PKCS#8 `PrivateKeyInfo`) or `{ certificates,
1172
+ * encryptedKey }` (the CMS `EnvelopedData` the caller decrypts out-of-band with its key-encryption key --
1173
+ * the verb NEVER decrypts, so it is not a decryption oracle), or `{ retry, retryAfterSeconds }` on a 202.
1174
+ * The certificates are RAW/unordered -- unlike `simpleenroll` no leaf is picked, because the CA generated
1175
+ * the key so the issued certificate's public key is the generated one, not the throwaway CSR key. A CLEARTEXT
1176
+ * key is bound to its certificate before it resolves: the delivered private key's public half MUST match
1177
+ * EXACTLY ONE returned certificate (`est/key-cert-mismatch` on none, `est/ambiguous-issued-cert` on more than
1178
+ * one), so a mis-associated key is refused rather than handed back unusable.
1179
+ * The encryption requirement + expected recipient are DERIVED from the CSR's own DecryptKeyIdentifier /
1180
+ * AsymmetricDecryptKeyIdentifier attribute; an `opts` value that contradicts the CSR is `est/bad-input`
1181
+ * (a cleartext-key downgrade cannot slip past). The delivered key's channel is asserted confidentiality-
1182
+ * bearing (a NULL / anonymous / EXPORT cipher is `est/weak-cipher`). https-only, explicit-anchor, and the
1183
+ * whole redirect / auth / budget machinery of `simpleenroll` apply.
1184
+ *
1185
+ * @opts
1186
+ * - `requestedEncryption` / `expectedRecipientKeyId` / `expectedRecipientIssuerSerial` -- OPTIONAL
1187
+ * overrides of the CSR-derived recipient coherence; a value that contradicts the CSR is `est/bad-input`.
1188
+ * - every option of pki.est.simpleenroll (transport, tls, label, budgets, credentials incl. `auth`).
1189
+ * @example
1190
+ * var r = await pki.est.serverkeygen("https://ca.example", csrDer,
1191
+ * { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
1192
+ * r.retry; // true -- a 202 is surfaced, never slept
1193
+ */
1194
+ function serverkeygen(baseUrl, csrInput, opts) {
1195
+ opts = opts || {};
1196
+ return Promise.resolve().then(function () {
1197
+ var csrDer = _csrDer(csrInput);
1198
+ var derived = _serverkeygenEncryptionFromCsr(csrDer);
1199
+ if (opts.requestedEncryption !== undefined && !!opts.requestedEncryption !== derived.requestedEncryption) throw E("est/bad-input", "opts.requestedEncryption (" + !!opts.requestedEncryption + ") contradicts the CSR's advertised key-encryption attribute (" + derived.requestedEncryption + ") (RFC 7030 sec. 4.4.1)");
1200
+ if (opts.expectedRecipientKeyId !== undefined) {
1201
+ if (!Buffer.isBuffer(opts.expectedRecipientKeyId)) throw E("est/bad-input", "opts.expectedRecipientKeyId must be a Buffer");
1202
+ if (derived.expectedRecipientKeyId && !opts.expectedRecipientKeyId.equals(derived.expectedRecipientKeyId)) throw E("est/bad-input", "opts.expectedRecipientKeyId contradicts the key identifier the CSR advertised (RFC 7030 sec. 4.4.1)");
1203
+ }
1204
+ if (opts.expectedRecipientIssuerSerial != null) {
1205
+ var eis = opts.expectedRecipientIssuerSerial;
1206
+ if (typeof eis !== "object" || Buffer.isBuffer(eis) || !Buffer.isBuffer(eis.issuer)) throw E("est/bad-input", "opts.expectedRecipientIssuerSerial must be { issuer: Buffer, serialNumber }");
1207
+ var s = eis.serialNumber;
1208
+ if (!((typeof s === "bigint" && s >= 0n) || (typeof s === "number" && Number.isSafeInteger(s) && s >= 0) || (typeof s === "string" && /^[0-9]+$/.test(s)))) throw E("est/bad-input", "opts.expectedRecipientIssuerSerial.serialNumber must be a NON-NEGATIVE bigint, a safe non-negative integer, or a decimal digit string (a certificate serial is non-negative, RFC 5280 sec. 4.1.2.2)");
1209
+ }
1210
+ // A recipient expectation implies the key part MUST be encrypted to that recipient; if the CSR advertised no
1211
+ // key-encryption attribute the coherence check below never runs, so a compromised CA could deliver the key
1212
+ // to a recipient IT controls (or cleartext) while the caller believes its pin was enforced. Refuse the
1213
+ // contradiction at config time (RFC 7030 sec. 4.4.1) -- the caller must advertise the KEK in the CSR.
1214
+ if ((opts.expectedRecipientKeyId !== undefined || opts.expectedRecipientIssuerSerial != null) && !derived.requestedEncryption) {
1215
+ throw E("est/bad-input", "a recipient expectation (expectedRecipientKeyId / expectedRecipientIssuerSerial) implies an encrypted key, but the CSR advertised no DecryptKeyIdentifier / AsymmetricDecryptKeyIdentifier attribute (RFC 7030 sec. 4.4.1)");
1216
+ }
1217
+ return _client("serverkeygen", "POST", baseUrl, transferEncode(csrDer), { accept: "multipart/mixed", "content-type": "application/pkcs10" }, opts)
1218
+ .then(function (res) { return _serverkeygenResult(res, opts, derived); });
1219
+ });
1220
+ }
1221
+
1222
+ // ---- /csrattrs GET (RFC 7030 sec. 4.5, RFC 9908) ------------------------
1223
+
1224
+ function _csrattrsResult(res, opts) {
1225
+ var verdict = classifyResponse(res.status, res.headers, res.body, { op: "csrattrs", now: opts.now });
1226
+ // 204 / 404 = "CSR Attributes Response not available" -- a valid NONE outcome, not an error (RFC 7030 sec. 4.5.2).
1227
+ if (verdict.status === "none-available") return { available: false, attrs: null };
1228
+ // A 202 is nonconforming for a policy GET (sec. 4.5.2 lists only 200 / 204 / 404).
1229
+ if (verdict.status === "retry") throw E("est/http-error", "a /csrattrs response must be HTTP 200, 204, or 404, not 202 (RFC 7030 sec. 4.5.2)");
1230
+ if (verdict.status !== "ok") throw E("est/http-error", "an EST csrattrs response must be HTTP 200 / 204 / 404 (RFC 7030 sec. 4.5.2), got " + res.status);
1231
+ var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
1232
+ // An empty HTTP body is distinct from a valid EMPTY CsrAttrs (`30 00` / base64 `MAA=`), which parses as {items:[]}.
1233
+ if (bodyLen === 0) throw E("est/empty-body", "a 200 csrattrs response carried an empty body (RFC 7030 sec. 4.5.2)");
1234
+ var attrs = csrattrsFmt.parse(transferDecode(res.body));
1235
+ return { available: true, attrs: attrs, plan: buildEnrollAttributes(attrs) };
1236
+ }
1237
+
1238
+ /**
1239
+ * @primitive pki.est.csrattrs
1240
+ * @signature pki.est.csrattrs(baseUrl, opts?) -> Promise<{ available: true, attrs, plan } | { available: false, attrs: null }>
1241
+ * @since 0.3.28
1242
+ * @status experimental
1243
+ * @spec RFC 7030, RFC 8951, RFC 9908
1244
+ * @related pki.est.simpleenroll, pki.est.buildEnrollAttributes
1245
+ *
1246
+ * Fetch the CA's CSR-attributes policy: GET `<baseUrl>/.well-known/est/csrattrs` (`Accept:
1247
+ * application/csrattrs`). A 200 body is base64-decoded, parsed as an RFC 9908 `CsrAttrs`, and returned
1248
+ * with a `plan` (`buildEnrollAttributes`) the caller applies to its NEXT CSR -- the verb NEVER auto-applies
1249
+ * attributes to a CSR (single responsibility). A 204 or 404 is `{ available: false }` (a valid "no specific
1250
+ * attributes"), NOT an error; an empty SEQUENCE (`30 00`) is a COMPLETE empty policy (`attrs.items` empty),
1251
+ * distinct from an empty HTTP body (`est/empty-body`). Server auth is NOT required for this policy GET but a
1252
+ * 401 is tolerated (the shared auth path stays live). https-only + explicit-anchor as elsewhere.
1253
+ *
1254
+ * @opts
1255
+ * - every transport / tls / label / budget / credential option of the other verbs.
1256
+ * @example
1257
+ * var r = await pki.est.csrattrs("https://ca.example",
1258
+ * { transport: function () { return Promise.resolve({ status: 404, headers: {}, body: "" }); } });
1259
+ * r.available; // false -- a 404 is "no CSR-attributes policy available"
1260
+ */
1261
+ function csrattrs(baseUrl, opts) {
1262
+ opts = opts || {};
1263
+ return Promise.resolve().then(function () {
1264
+ return _client("csrattrs", "GET", baseUrl, null, { accept: "application/csrattrs" }, opts);
1265
+ }).then(function (res) { return _csrattrsResult(res, opts); });
1266
+ }
1267
+
947
1268
  module.exports = {
948
1269
  cacerts: cacerts,
949
1270
  simpleenroll: simpleenroll,
950
1271
  simplereenroll: simplereenroll,
1272
+ serverkeygen: serverkeygen,
1273
+ csrattrs: csrattrs,
951
1274
  transferDecode: transferDecode,
952
1275
  transferEncode: transferEncode,
953
1276
  splitMultipartMixed: splitMultipartMixed,
@@ -0,0 +1,379 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- NOT on pki.*. HTTP Digest access authentication (RFC 7616): parse an UNTRUSTED
6
+ // WWW-Authenticate challenge (fail-closed, quoted-string-honoring) and compute the Authorization
7
+ // response header (byte-exact A1/A2/KD). Prefix-agnostic: every thrown code comes from the caller's
8
+ // E(code, msg) FACTORY (never a defineClass class -- feedback_guard_error_factory_not_class) and its
9
+ // policy.codes map, so pki.est composes it now and pki.acme / pki.cmp can reuse it as config, not a fork.
10
+ //
11
+ // The challenge is attacker-shaped: a comma inside a quoted value is NOT a delimiter (the _hasBasicChallenge
12
+ // substring-scan bug class), a missing realm / nonce is REJECTED not defaulted, and an unsupported / weak
13
+ // algorithm or a no-qop challenge fails closed rather than downgrading (feedback_guards_fail_closed_not_guess).
14
+
15
+ var crypto = require("crypto");
16
+ var constants = require("./constants");
17
+
18
+ // Algorithm registry (a data row, not a switch -- Hard rule #2): rank orders the RFC 7616 sec. 3.7
19
+ // "most secure the client can use" selection (SHA-512-256 > SHA-256 > MD5); an unknown algorithm ranks 0.
20
+ var ALGS = {
21
+ "SHA-512-256": { hash: "sha512-256", rank: 3, sess: false }, "SHA-512-256-SESS": { hash: "sha512-256", rank: 3, sess: true },
22
+ "SHA-256": { hash: "sha256", rank: 2, sess: false }, "SHA-256-SESS": { hash: "sha256", rank: 2, sess: true },
23
+ "MD5": { hash: "md5", rank: 1, sess: false }, "MD5-SESS": { hash: "md5", rank: 1, sess: true },
24
+ };
25
+ var DEFAULT_CODES = {
26
+ unsupportedAlgorithm: "digest/unsupported-algorithm", weakAlgorithm: "digest/weak-algorithm",
27
+ noQop: "digest/no-qop", badChallenge: "digest/bad-challenge",
28
+ };
29
+
30
+ // H = lowercase-hex digest; the octet string is fed as latin1 so a UTF-8-encoded credential (charset=UTF-8)
31
+ // is hashed over its exact bytes. KD(secret, data) = H(secret ":" data).
32
+ function H(hash, s) { return crypto.createHash(hash).update(s, "latin1").digest("hex"); }
33
+ function KD(hash, secret, data) { return H(hash, secret + ":" + data); }
34
+ function _qstr(v) { return "\"" + String(v).replace(/(["\\])/g, "\\$1") + "\""; }
35
+
36
+ // Split a header at commas that are OUTSIDE a quoted-string (a quoted comma is a literal, not a delimiter).
37
+ function _commaSplitOutsideQuotes(s) {
38
+ var out = [], buf = "", inQ = false, esc = false;
39
+ for (var i = 0; i < s.length; i++) {
40
+ var c = s.charAt(i);
41
+ if (esc) { buf += c; esc = false; continue; }
42
+ if (inQ && c === "\\") { buf += c; esc = true; continue; }
43
+ if (c === "\"") { inQ = !inQ; buf += c; continue; }
44
+ if (c === "," && !inQ) { out.push(buf); buf = ""; continue; }
45
+ buf += c;
46
+ }
47
+ out.push(buf);
48
+ return out;
49
+ }
50
+ // Index of the first '=' OUTSIDE a quoted-string, or -1.
51
+ function _firstEqOutsideQuotes(s) {
52
+ var inQ = false, esc = false;
53
+ for (var i = 0; i < s.length; i++) {
54
+ var c = s.charAt(i);
55
+ if (esc) { esc = false; continue; }
56
+ if (inQ && c === "\\") { esc = true; continue; }
57
+ if (c === "\"") { inQ = !inQ; continue; }
58
+ if (c === "=" && !inQ) return i;
59
+ }
60
+ return -1;
61
+ }
62
+ function _unq(s) { return s.slice(1, -1).replace(/\\(.)/g, "$1"); } // only ever called on a well-formed quoted-string (see _closedQuote)
63
+ // A well-formed quoted-string: opens with ", closes with an UNESCAPED " that is the LAST character (nothing trails
64
+ // it). An unterminated / trailing-garbage quoted-string is malformed (RFC 7230 sec. 3.2.6) and rejected.
65
+ function _closedQuote(s) {
66
+ if (s.length < 2 || s.charAt(0) !== "\"") return false;
67
+ var esc = false;
68
+ for (var i = 1; i < s.length; i++) {
69
+ var c = s.charAt(i);
70
+ if (esc) { esc = false; continue; }
71
+ if (c === "\\") { esc = true; continue; }
72
+ if (c === "\"") return i === s.length - 1;
73
+ }
74
+ return false;
75
+ }
76
+ // A control octet (RFC 7230 sec. 3.2.6 quoted-string / qdtext forbids CTL except HTAB). Scanned with charCodeAt
77
+ // rather than a control-char regex literal (which eslint no-control-regex correctly refuses).
78
+ function _hasCtl(s) {
79
+ for (var i = 0; i < s.length; i++) { var c = s.charCodeAt(i); if ((c < 0x20 && c !== 0x09) || c === 0x7f) return true; }
80
+ return false;
81
+ }
82
+
83
+ // Split a WWW-Authenticate header into [{ scheme, paramText }]. A comma-separated segment whose pre-'='
84
+ // text is "token WS token" (a scheme name before the first auth-param key), or a bare token with no '=',
85
+ // STARTS a new challenge; a "key=value" segment is a param continuation of the current challenge.
86
+ function _splitChallenges(s) {
87
+ var segs = _commaSplitOutsideQuotes(s);
88
+ var out = [], cur = null;
89
+ for (var i = 0; i < segs.length; i++) {
90
+ var seg = segs[i].replace(/^\s+|\s+$/g, "");
91
+ if (seg === "") continue;
92
+ var eq = _firstEqOutsideQuotes(seg);
93
+ var pre = (eq < 0 ? seg : seg.slice(0, eq)).replace(/^\s+|\s+$/g, "");
94
+ var ws = /^(\S+)\s+(\S[\s\S]*)$/.exec(pre);
95
+ if (eq < 0) { cur = { scheme: seg, paramText: "" }; out.push(cur); } // a bare scheme token (e.g. "Basic")
96
+ else if (ws) { cur = { scheme: ws[1], paramText: seg.replace(/^\s*\S+\s+/, "") }; out.push(cur); } // "scheme firstKey=..."
97
+ else if (cur) { cur.paramText = cur.paramText ? cur.paramText + "," + seg : seg; } // "key=value" continuation
98
+ }
99
+ return out;
100
+ }
101
+ // Parse a challenge's params into { key(lower): { value, quoted } }; a repeated key is malformed (fail closed).
102
+ function _parseParams(paramText, E, code) {
103
+ var segs = _commaSplitOutsideQuotes(paramText), map = Object.create(null);
104
+ for (var i = 0; i < segs.length; i++) {
105
+ var seg = segs[i].replace(/^\s+|\s+$/g, "");
106
+ if (seg === "") continue;
107
+ var eq = _firstEqOutsideQuotes(seg);
108
+ if (eq < 0) throw E(code, "malformed Digest auth-param (no '='): " + JSON.stringify(seg));
109
+ var key = seg.slice(0, eq).replace(/^\s+|\s+$/g, "").toLowerCase();
110
+ var rawVal = seg.slice(eq + 1).replace(/^\s+|\s+$/g, "");
111
+ var quoted = rawVal.charAt(0) === "\"";
112
+ if (quoted && !_closedQuote(rawVal)) throw E(code, "an unterminated or trailing-garbage Digest quoted-string (RFC 7230 sec. 3.2.6)");
113
+ if (Object.prototype.hasOwnProperty.call(map, key)) throw E(code, "repeated Digest auth-param " + JSON.stringify(key));
114
+ var value = quoted ? _unq(rawVal) : rawVal;
115
+ // Reject a control octet in any value (CR / LF / NUL / ...): it is a MUST-reject per RFC 7230 qdtext AND a
116
+ // header-injection sink -- an unescaped CR/LF reflected into the outgoing Authorization would split the header.
117
+ if (_hasCtl(value)) throw E(code, "a Digest auth-param value contains a control character (RFC 7230 sec. 3.2.6)");
118
+ map[key] = { value: value, quoted: quoted };
119
+ }
120
+ return map;
121
+ }
122
+ // Structural validation of ONE Digest challenge's params (RFC 7616 sec. 3.3). Throws on any violation.
123
+ function _validateDigest(paramText, E, code) {
124
+ var p = _parseParams(paramText, E, code);
125
+ if (!p.realm || !p.realm.quoted || p.realm.value === "") throw E(code, "a Digest challenge requires a non-empty quoted realm (RFC 7616 sec. 3.3)");
126
+ if (!p.nonce || !p.nonce.quoted || p.nonce.value === "") throw E(code, "a Digest challenge requires a non-empty quoted nonce (RFC 7616 sec. 3.3)");
127
+ if (p.algorithm && p.algorithm.quoted) throw E(code, "the Digest algorithm must be a token, not a quoted-string (RFC 7616 sec. 3.3)");
128
+ var qop = [];
129
+ if (p.qop) {
130
+ if (!p.qop.quoted) throw E(code, "the Digest qop must be a quoted list (RFC 7616 sec. 3.3)");
131
+ qop = p.qop.value.split(",").map(function (x) { return x.replace(/^\s+|\s+$/g, "").toLowerCase(); }).filter(Boolean);
132
+ // A qop directive that is PRESENT but lists no value (qop="" / qop=", ,") is malformed -- it is NOT the
133
+ // absent-qop (RFC 2069) case. Reject it rather than silently collapsing to a no-qop response the server
134
+ // cannot accept (RFC 7616 sec. 3.3). Only an OMITTED qop directive selects the legacy no-qop path.
135
+ if (qop.length === 0) throw E(code, "a present Digest qop directive must list at least one value (RFC 7616 sec. 3.3)");
136
+ }
137
+ // domain (RFC 7616 sec. 3.3): a QUOTED, space-separated list of URIs defining the protection space. An
138
+ // UNQUOTED domain is malformed and rejected (fail closed like realm / nonce / qop) rather than silently
139
+ // widened -- widening a mis-encoded scope to "the whole server" would send credentials MORE broadly than the
140
+ // server intended. Surfaced as an array of the listed URIs, or null when omitted / quoted-empty -- which per
141
+ // the RFC means "all URIs on the responding server" (the answer may be reused for any same-origin URI, sec. 3.5).
142
+ if (p.domain && !p.domain.quoted) throw E(code, "the Digest domain must be a quoted-string (RFC 7616 sec. 3.3)");
143
+ var domain = (p.domain && p.domain.value.replace(/^\s+|\s+$/g, "") !== "")
144
+ ? p.domain.value.replace(/^\s+|\s+$/g, "").split(/\s+/) : null;
145
+ // charset (RFC 7616 sec. 3.3): the ONLY permitted value is the UNQUOTED token "UTF-8". A quoted charset, or any
146
+ // other value, is malformed -- answering it would hash the credentials in the wrong encoding -- so it is
147
+ // rejected (and thus skipped during multi-offer selection in favour of a conforming offer).
148
+ if (p.charset && (p.charset.quoted || String(p.charset.value).toUpperCase() !== "UTF-8")) throw E(code, "the Digest charset must be the unquoted token UTF-8 (RFC 7616 sec. 3.3)");
149
+ // stale (RFC 7616 sec. 3.3) is the unquoted token "true" or "false". A quoted or otherwise invalid value
150
+ // (stale=maybe) is malformed and rejected (thus skipped in selection) rather than parsed as a false flag that
151
+ // could shadow a conforming offer.
152
+ if (p.stale) {
153
+ var sv = String(p.stale.value).toLowerCase();
154
+ if (p.stale.quoted || (sv !== "true" && sv !== "false")) throw E(code, "the Digest stale directive must be the unquoted token true or false (RFC 7616 sec. 3.3)");
155
+ }
156
+ // userhash (RFC 7616 sec. 3.3) is likewise the unquoted token "true" or "false". A quoted or otherwise invalid
157
+ // value is malformed and rejected (skipped in selection) rather than silently changing how the username is sent.
158
+ if (p.userhash) {
159
+ var uhv = String(p.userhash.value).toLowerCase();
160
+ if (p.userhash.quoted || (uhv !== "true" && uhv !== "false")) throw E(code, "the Digest userhash directive must be the unquoted token true or false (RFC 7616 sec. 3.3)");
161
+ }
162
+ // opaque (RFC 7616 sec. 3.3) is a quoted-string echoed back verbatim. An UNQUOTED opaque is malformed and
163
+ // rejected (skipped in selection) rather than accepted, where it could shadow a conforming offer.
164
+ if (p.opaque && !p.opaque.quoted) throw E(code, "the Digest opaque directive must be a quoted-string (RFC 7616 sec. 3.3)");
165
+ return {
166
+ scheme: "Digest", realm: p.realm.value, nonce: p.nonce.value, qop: qop, domain: domain,
167
+ algorithm: p.algorithm ? p.algorithm.value.toUpperCase() : "MD5",
168
+ opaque: p.opaque ? p.opaque.value : null,
169
+ stale: !!(p.stale && String(p.stale.value).toLowerCase() === "true"),
170
+ userhash: !!(p.userhash && String(p.userhash.value).toLowerCase() === "true"),
171
+ charset: p.charset ? p.charset.value : null,
172
+ };
173
+ }
174
+
175
+ // parseChallenge(www, E, code, policy?) -> the most-secure USABLE structurally-valid Digest challenge,
176
+ // OR null (no Digest challenge present), OR throw E(code) (a Digest challenge present but none valid).
177
+ // Selection is policy-aware: a challenge answer() would refuse under the active policy (an unsupported /
178
+ // MD5-when-disallowed algorithm, or a qop this client cannot satisfy) is ranked BELOW every usable one, so a
179
+ // higher-algorithm-rank but unusable offer (e.g. a SHA-512-256 no-qop challenge under the default policy)
180
+ // never shadows a lower-ranked usable one (e.g. SHA-256 with qop="auth") -- RFC 7616 sec. 3.3. When NO
181
+ // challenge is usable the highest-algorithm-rank one is still returned, so answer() reports the specific
182
+ // policy reason (which opt to set) rather than a generic "no challenge".
183
+ function parseChallenge(www, E, code, policy) {
184
+ var pol = policy || {};
185
+ var codes = pol.codes || DEFAULT_CODES;
186
+ // preferStale marks a CREDENTIAL-REJECTION context (a 401 answering a credentialed request in the same space):
187
+ // there, a stale=true offer is RETRYABLE with a fresh nonce while a stale=false offer is a terminal rejection,
188
+ // so a retryable offer must outrank a stronger non-retryable one (RFC 7616 sec. 3.3). In the initial context
189
+ // it is not set and stale plays no part.
190
+ var preferStale = !!pol.preferStale;
191
+ var raw = String(www == null ? "" : www);
192
+ if (raw.length > constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES) throw E(code, "the WWW-Authenticate header exceeds the " + constants.LIMITS.HTTP_AUTH_HEADER_MAX_BYTES + "-byte cap");
193
+ var challenges = _splitChallenges(raw);
194
+ var best = null, bestUsable = false, bestApplicable = false, bestStale = false, bestRank = -1, sawDigest = false;
195
+ for (var i = 0; i < challenges.length; i++) {
196
+ if (challenges[i].scheme.toLowerCase() !== "digest") continue;
197
+ sawDigest = true;
198
+ var parsed;
199
+ // A MALFORMED offer is skipped, not fatal: parseChallenge's contract is to throw only when NO valid Digest
200
+ // offer exists (RFC 7616 sec. 3.3), so a bad offer alongside a good one still authenticates on the good one.
201
+ try { parsed = _validateDigest(challenges[i].paramText, E, code); }
202
+ catch (_e) {
203
+ continue;
204
+ }
205
+ var alg = ALGS[parsed.algorithm];
206
+ var rank = alg ? alg.rank : 0;
207
+ var usable = _rejection(parsed, pol, codes) === null;
208
+ // APPLICABLE: the offer's own protection space covers the CURRENT request target (RFC 7616 sec. 3.5). An
209
+ // offer whose `domain` excludes this request cannot authenticate it (the caller would omit the credential),
210
+ // so it must not shadow a weaker offer that does apply. Only checked when the caller supplies the request
211
+ // context; otherwise every offer is treated as applicable.
212
+ var applicable = pol.requestTarget === undefined ? true : inProtectionSpace(parsed, pol.requestOrigin, pol.requestTarget);
213
+ // A RETRYABLE offer (only meaningful in the rejection context) is stale=true, carries a FRESH nonce, AND is
214
+ // for the SAME realm whose credential was just rejected -- the conditions under which the caller re-answers.
215
+ // A stale offer for a DIFFERENT realm is a new protection space (not a retry), and a stale offer repeating
216
+ // the prior nonce cannot re-answer, so neither must out-rank a weaker genuinely retryable offer (RFC 7616 sec. 3.3).
217
+ var retryable = preferStale && !!parsed.stale && parsed.nonce !== pol.priorNonce && parsed.realm === pol.priorRealm;
218
+ // Usability dominates; then an offer that APPLIES to this request; then (in a rejection) a retryable offer;
219
+ // then the stronger algorithm.
220
+ if (best === null ||
221
+ (usable && !bestUsable) ||
222
+ (usable === bestUsable && applicable && !bestApplicable) ||
223
+ (usable === bestUsable && applicable === bestApplicable && retryable && !bestStale) ||
224
+ (usable === bestUsable && applicable === bestApplicable && retryable === bestStale && rank > bestRank)) {
225
+ best = parsed; bestUsable = usable; bestApplicable = applicable; bestStale = retryable; bestRank = rank;
226
+ }
227
+ }
228
+ if (best) return best;
229
+ if (sawDigest) throw E(code, "no valid Digest challenge: every Digest offer was malformed (missing realm / nonce or a bad directive, RFC 7616 sec. 3.3)");
230
+ return null;
231
+ }
232
+
233
+ // Credential octets for the given charset (RFC 7616 sec. 4): UTF-8 -> the exact UTF-8 bytes; else the
234
+ // string as-is (ASCII / latin1). H() hashes over latin1, so a UTF-8 credential is fed as its octet string.
235
+ function _octets(s, charset) {
236
+ var v = s == null ? "" : String(s);
237
+ return (charset && String(charset).toUpperCase() === "UTF-8") ? Buffer.from(v, "utf8").toString("latin1") : v;
238
+ }
239
+
240
+ // Does the string carry any non-ASCII code unit (> U+007F)? Such a UTF-8 username is sent via `username*`.
241
+ function _hasNonAscii(s) {
242
+ s = String(s == null ? "" : s);
243
+ for (var i = 0; i < s.length; i++) { if (s.charCodeAt(i) > 0x7F) return true; }
244
+ return false;
245
+ }
246
+
247
+ // RFC 5987 / RFC 8187 percent-encoding of a string's UTF-8 octets: attr-char (ALPHA / DIGIT / "!#$&+-.^_`|~")
248
+ // is kept literal, every other octet is %XX (upper-hex). Used for the Digest `username*` extended value.
249
+ function _pctEncodeUtf8(s) {
250
+ var bytes = Buffer.from(String(s == null ? "" : s), "utf8");
251
+ var out = "";
252
+ for (var i = 0; i < bytes.length; i++) {
253
+ var b = bytes[i];
254
+ if ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) ||
255
+ b === 0x21 || b === 0x23 || b === 0x24 || b === 0x26 || b === 0x2B || b === 0x2D ||
256
+ b === 0x2E || b === 0x5E || b === 0x5F || b === 0x60 || b === 0x7C || b === 0x7E) {
257
+ out += String.fromCharCode(b);
258
+ } else {
259
+ out += "%" + (b < 0x10 ? "0" : "") + b.toString(16).toUpperCase();
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+
265
+ // The policy gates a parsed challenge must clear to be answerable (RFC 7616 sec. 3.4): a supported
266
+ // algorithm, MD5 only when allowed, and a qop this client can satisfy (auth / auth-int, or no-qop only when
267
+ // allowLegacyQop). Returns a { code, msg } rejection or null. This is the SINGLE definition both answer()
268
+ // and parseChallenge() consult, so challenge SELECTION can never rank a challenge as usable that answer()
269
+ // would then refuse (or vice-versa) -- the two cannot drift.
270
+ function _rejection(challenge, pol, codes) {
271
+ var alg = ALGS[challenge.algorithm];
272
+ if (!alg) return { code: codes.unsupportedAlgorithm, msg: "unsupported Digest algorithm " + challenge.algorithm + " (supported: SHA-512-256, SHA-256, MD5)" };
273
+ if (alg.hash === "md5" && !pol.allowMD5) return { code: codes.weakAlgorithm, msg: "MD5 Digest refused by default (RFC 7616 sec. 3.4 discourages it); set opts.auth.allowMD5 for legacy interop" };
274
+ if (challenge.qop.length === 0) {
275
+ if (!pol.allowLegacyQop) return { code: codes.noQop, msg: "a no-qop (RFC 2069) Digest challenge is refused by default; set opts.auth.allowLegacyQop for legacy interop" };
276
+ } else if (challenge.qop.indexOf("auth") === -1 && challenge.qop.indexOf("auth-int") === -1) {
277
+ return { code: codes.badChallenge, msg: "the Digest qop offered no member this client supports (auth / auth-int)" };
278
+ }
279
+ return null;
280
+ }
281
+
282
+ // answer(challenge, { method, uri, username, password, body, policy, rng }, E) -> the Authorization header value.
283
+ function answer(challenge, params, E) {
284
+ var pol = params.policy || {};
285
+ var codes = pol.codes || DEFAULT_CODES;
286
+ var rej = _rejection(challenge, pol, codes);
287
+ if (rej) throw E(rej.code, rej.msg);
288
+ var alg = ALGS[challenge.algorithm];
289
+ var useQop = challenge.qop.indexOf("auth") !== -1 ? "auth" : (challenge.qop.indexOf("auth-int") !== -1 ? "auth-int" : null);
290
+ var cnonce = String((params.rng || function () { return crypto.randomBytes(18).toString("base64"); })());
291
+ // The realm is used with its incoming header OCTETS unchanged (RFC 7616 sec. 4). The realm arrives from the
292
+ // wire, so a transport that exposes header bytes as Latin-1 (the Node default) already presents the server's
293
+ // UTF-8 octets one-per-char; re-encoding them as UTF-8 (as user / pass, which come from the caller as Unicode
294
+ // strings, correctly do) would DOUBLE-encode and make the digest disagree with the server. H() hashes over
295
+ // Latin-1, so the raw realm string contributes exactly the octets the server used.
296
+ var realm = challenge.realm, nonce = challenge.nonce;
297
+ // Normalize UTF-8 credentials to NFC before hashing (RFC 7616 sec. 4): a server hashes the normalized form,
298
+ // so a canonically-decomposed username / password (e + combining accent vs the precomposed char) must be
299
+ // composed first or HA1 disagrees. The SAME normalized username feeds A1, userhash, and username*.
300
+ var isUtf8 = String(challenge.charset || "").toUpperCase() === "UTF-8";
301
+ var pUser = params.username == null ? "" : String(params.username);
302
+ var pPass = params.password == null ? "" : String(params.password);
303
+ if (isUtf8) { pUser = pUser.normalize("NFC"); pPass = pPass.normalize("NFC"); }
304
+ var user = _octets(pUser, challenge.charset);
305
+ var pass = _octets(pPass, challenge.charset);
306
+ var HA1 = H(alg.hash, user + ":" + realm + ":" + pass);
307
+ if (alg.sess) HA1 = H(alg.hash, HA1 + ":" + nonce + ":" + cnonce);
308
+ var A2 = (useQop === "auth-int")
309
+ ? params.method + ":" + params.uri + ":" + H(alg.hash, params.body == null ? "" : params.body)
310
+ : params.method + ":" + params.uri;
311
+ var HA2 = H(alg.hash, A2);
312
+ // The nonce-count: a (nonce, nc) pair MUST NOT be reused (RFC 7616 sec. 3.4), so a request that REUSES a nonce
313
+ // (a same-origin redirect, an auth-int retry) increments nc; a fresh nonce restarts at 1. The caller supplies
314
+ // the count for this nonce (default 1); formatted as 8 lowercase hex.
315
+ var ncNum = (typeof params.nc === "number" && params.nc >= 1) ? Math.floor(params.nc) : 1;
316
+ var nc = ("0000000" + ncNum.toString(16)).slice(-8);
317
+ var response = useQop
318
+ ? KD(alg.hash, HA1, nonce + ":" + nc + ":" + cnonce + ":" + useQop + ":" + HA2)
319
+ : KD(alg.hash, HA1, nonce + ":" + HA2);
320
+ // The username field (RFC 7616 sec. 3.4). userhash=true sends H(username:realm) for privacy (sec. 3.4.4) --
321
+ // an ASCII hex value; A1 above still used the REAL username. Otherwise a charset=UTF-8 username containing
322
+ // non-ASCII characters MUST be carried in the extended `username*` form (RFC 5987 / RFC 8187 percent-encoded
323
+ // UTF-8), because a server reads the legacy quoted `username` as ISO-8859-1 and could not resolve the account;
324
+ // `username` and `username*` MUST NOT both appear. An ASCII (or userhash) username uses the legacy quoted form.
325
+ var parts;
326
+ if (!challenge.userhash && isUtf8 && _hasNonAscii(pUser)) {
327
+ parts = ["username*=UTF-8''" + _pctEncodeUtf8(pUser)];
328
+ } else {
329
+ var sentUser = challenge.userhash ? H(alg.hash, _octets(pUser, challenge.charset) + ":" + realm) : _octets(pUser, challenge.charset);
330
+ parts = ["username=" + _qstr(sentUser)];
331
+ }
332
+ parts.push("realm=" + _qstr(realm), "nonce=" + _qstr(nonce), "uri=" + _qstr(params.uri), "algorithm=" + challenge.algorithm);
333
+ if (useQop) { parts.push("qop=" + useQop); parts.push("nc=" + nc); }
334
+ // The cnonce directive is emitted whenever the server needs it to recompute the response: under a qop, and
335
+ // also under a -sess algorithm whose A1 folds in the cnonce even with no qop (RFC 7616 sec. 3.4.2 / 3.9.1).
336
+ if (useQop || alg.sess) { parts.push("cnonce=" + _qstr(cnonce)); }
337
+ parts.push("response=" + _qstr(response));
338
+ if (challenge.opaque != null) parts.push("opaque=" + _qstr(challenge.opaque));
339
+ if (challenge.userhash) parts.push("userhash=true");
340
+ return "Digest " + parts.join(", ");
341
+ }
342
+
343
+ // Parse one domain URI entry (RFC 7616 sec. 3.3) into { origin, full }: an abs_path ("/a", "/a?x=1") is
344
+ // relative to the responding server -> origin null (any same-origin request); an ABSOLUTE URI carries its own
345
+ // authority -> origin "scheme://authority" (lowercased, default port normalized) and matches ONLY a request to
346
+ // that SAME origin, since an entry to a different host names a protection space on a different server. `full` is
347
+ // the pathname + query the request URI is prefix-compared against. A relative / unparseable absolute entry -> null.
348
+ function _domainEntry(d) {
349
+ d = String(d == null ? "" : d);
350
+ if (d.charAt(0) === "/") return { origin: null, full: d };
351
+ // An absolute entry is parsed through the URL constructor so its origin is NORMALIZED the same way the
352
+ // request's URL.origin is -- an explicit default port (https :443 / http :80) is dropped, so a domain naming
353
+ // "https://h:443/x" still matches a request whose origin normalizes to "https://h". A relative / unparseable
354
+ // entry throws and never matches (fail closed).
355
+ try { var u = new URL(d); return { origin: u.origin.toLowerCase(), full: (u.pathname || "/") + (u.search || "") }; }
356
+ catch (_e) { return null; }
357
+ }
358
+
359
+ // inProtectionSpace(challenge, requestOrigin, requestPathAndSearch) -> is the request within the challenge's
360
+ // protection space? An absent / empty domain means "all URIs on the responding server" (RFC 7616 sec. 3.3), so
361
+ // any same-origin request matches; otherwise membership is by LITERAL URI PREFIX (RFC 7616 sec. 3.5 / RFC 2617
362
+ // sec. 1.2): the request's pathname + query must have a listed domain URI as a prefix, and for an absolute entry
363
+ // the same origin. The query is part of the prefix, so /enroll?tenant=a does not cover /enroll?tenant=b while a
364
+ // query-less /enroll covers its whole subtree (including /enroll?...). Used to scope where a cached answer is reused.
365
+ function inProtectionSpace(challenge, requestOrigin, requestPathAndSearch) {
366
+ var domain = challenge && challenge.domain;
367
+ if (!domain || !domain.length) return true;
368
+ var origin = String(requestOrigin == null ? "" : requestOrigin).toLowerCase();
369
+ var full = String(requestPathAndSearch == null ? "" : requestPathAndSearch);
370
+ for (var i = 0; i < domain.length; i++) {
371
+ var e = _domainEntry(domain[i]);
372
+ if (e === null) continue;
373
+ if (e.origin !== null && e.origin !== origin) continue; // an absolute entry to a DIFFERENT origin is a different protection space
374
+ if (full.indexOf(e.full) === 0) return true;
375
+ }
376
+ return false;
377
+ }
378
+
379
+ module.exports = { parseChallenge: parseChallenge, answer: answer, inProtectionSpace: inProtectionSpace };
@@ -353,9 +353,11 @@ function httpsTransport(defaults) {
353
353
  timer = setTimeout(function () { fail(C("timeout"), "the request timed out after " + prep.timeout + "ms"); }, prep.timeout);
354
354
  try {
355
355
  req = nodeHttps.request(prep.options, function (res) {
356
- // Capture the TLS session facts while the socket is live -- getProtocol() /
357
- // getPeerCertificate() return null once the socket detaches at stream end.
356
+ // Capture the TLS session facts while the socket is live -- getProtocol() / getCipher() /
357
+ // getPeerCertificate() return null once the socket detaches at stream end. The negotiated cipher lets a
358
+ // caller refuse a NULL / anonymous / EXPORT suite before it trusts confidentiality (RFC 7030 sec. 4.4).
358
359
  var proto = res.socket && res.socket.getProtocol ? res.socket.getProtocol() : null;
360
+ var cipher = res.socket && res.socket.getCipher ? res.socket.getCipher() : null;
359
361
  var peer = res.socket && res.socket.getPeerCertificate ? res.socket.getPeerCertificate() : null;
360
362
  // Pre-check a declared content-length so an oversized body is refused before it streams.
361
363
  var declared = parseInt((res.headers || {})["content-length"], 10);
@@ -389,7 +391,7 @@ function httpsTransport(defaults) {
389
391
  status: res.statusCode,
390
392
  headers: lower,
391
393
  body: Buffer.from(buf.subarray(0, len)),
392
- tls: { protocol: proto, peerCertificate: peer && peer.raw ? peer.raw : null },
394
+ tls: { protocol: proto, cipher: cipher, peerCertificate: peer && peer.raw ? peer.raw : null },
393
395
  });
394
396
  });
395
397
  res.on("error", function (e) { fail(C("transport-error"), "the response stream failed", e); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.27",
3
+ "version": "0.3.28",
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:98a7ec9f-9442-4c90-b486-f206cdde5325",
5
+ "serialNumber": "urn:uuid:ea40d20f-ad92-403e-8de1-cfd14bead6ba",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-31T20:40:18.144Z",
8
+ "timestamp": "2026-08-01T18:47:21.356Z",
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.27",
22
+ "bom-ref": "@blamejs/pki@0.3.28",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.27",
25
+ "version": "0.3.28",
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.27",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.28",
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.27",
57
+ "ref": "@blamejs/pki@0.3.28",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]