@blamejs/pki 0.6.42 → 0.6.44
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 +20 -0
- package/README.md +2 -2
- package/lib/http-transport.js +89 -37
- package/lib/path-validate.js +94 -7
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ 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.6.44 — 2026-09-06
|
|
8
|
+
|
|
9
|
+
An enrollment client reaches a certification authority through a proxy that demands Digest authentication.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- opts.proxy.auth.scheme accepts "digest" alongside "basic", so a client reaches a certification authority through a forward proxy that authenticates with RFC 7616 Digest. The first CONNECT carries no credential; the proxy's 407 carries its Proxy-Authenticate challenge, and the client answers it once, hashing the method CONNECT over the authority-form target (RFC 9112 section 3.2.3) that the proxy checks it against. The challenge is read by the same parser the origin verbs use and held to the same policy, so MD5 and a challenge carrying no qop are refused unless opts.proxy.auth.allowMD5 or allowLegacyQop says otherwise, surfacing transport/proxy-digest-weak-algorithm, transport/proxy-digest-unsupported-algorithm, transport/proxy-digest-no-qop, and transport/proxy-digest-bad-challenge (each under the calling client's own prefix, so a SCEP call reports scep/...). A proxy that offers no Digest challenge while digest is configured is transport/proxy-auth-required naming what it offered, and a rejected credential is transport/proxy-auth-failed after that single answer. Digest requires an https proxy on the same terms as Basic: credentials ride the authenticated channel to the proxy, and a plaintext http proxy carrying auth stays refused with transport/proxy-auth-requires-tls.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- transport/proxy-unsupported-scheme is no longer raised. It reported that Digest proxy authentication was unavailable, and Digest is now supported; an unknown scheme is transport/bad-proxy, as it was before.
|
|
18
|
+
|
|
19
|
+
## v0.6.43 — 2026-09-06
|
|
20
|
+
|
|
21
|
+
Path building finds a path through a mesh of cross-certified domains without walking the foreign branches first.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- pki.path.build orders candidates in a mesh of cross-certified domains by the two RFC 4158 section 3.5 name heuristics it had not applied. A candidate is ranked by how many leading relative distinguished names it shares with the name the search is heading for: its issuer against a trust anchor's name when building forward (section 3.5.16), and its subject against the leaf's issuer when building in reverse (the section 3.5.16 and section 3.5.19 reverse methods). Names are compared under the RFC 5280 section 7.1 rule, so a certificate in the target's own domain is tried before one from a cross-certified domain. Forward building additionally sorts a candidate under every candidate that can complete the path when its basicConstraints path length constraint cannot cover the certificates already below it in the chain (section 3.5.7), counting the non-self-issued ones the way RFC 5280 section 6.1.4(m) does, so a key-rollover certificate below the candidate spends none of its budget. That ordering outranks the other hints rather than competing with them, so a key identifier match or an anchor-adjacent issuer does not raise a candidate whose constraint the path already exceeds. All of it is ordering alone: no candidate is removed, every assembled path is handed to pki.path.validate, and an unbounded search still reaches every path the pool holds. A build over a mesh reaches a path within a smaller opts.maxCandidatesConsidered budget and reports a lower candidatesConsidered. A bounded search spends that budget on whichever branches the order steers it to, so a build that reports path/build-limit where the earlier order succeeded wants a larger opts.maxCandidatesConsidered, and the same holds for the opts.maxAiaFetches allowance.
|
|
26
|
+
|
|
7
27
|
## v0.6.42 — 2026-09-06
|
|
8
28
|
|
|
9
29
|
A trust anchor carries the namespace a root program trusts a root for, so a certificate outside it is refused even where the root certificate states no such limit.
|
package/README.md
CHANGED
|
@@ -223,14 +223,14 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
223
223
|
| `pki.schema.csrattrs` | 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 and RSA key-type conventions, the certification-request-info template). Unknown types are surfaced raw; structure and the RFC 9908 semantic MUSTs are fail-closed — `parse` |
|
|
224
224
|
| `pki.est` | Enrollment over Secure Transport (RFC 7030 / 8951 / 9908 / 7616). The client verbs `cacerts`, `simpleenroll`, `simplereenroll`, `serverkeygen`, `csrattrs`, and `fullcmc` drive the RFC 7030 flow over `pki.transport` (inject your own via `opts.transport`, or take the fail-closed default): HTTPS only, an explicit trust anchor required, same-origin redirects followed while a downgrade or loop is refused, a 202 Retry-After surfaced but 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, with encryption bound to the CSR's key-identifier attribute over a confidentiality-bearing cipher. `csrattrs` fetches the CA's RFC 9908 attributes policy. For §3.5 channel binding, `simpleenroll` and `simplereenroll` accept a CSR builder `(tls) -> csr` instead of a CSR: it runs after the handshake with that connection's `tlsUnique`, so the request is signed over the session it is sent on, and it re-runs on any new connection a redirect or auth retry opens, recreating the CSR for that session (§3.2.1). A builder alongside Digest authentication is refused, since that header is computed over the body before the handshake. `fullcmc` (§4.3) carries a CMC Full PKI Request and reduces the CA's answer through `pki.cmc.verify` to one terminal outcome, refusing a response that fails to echo the transaction and nonce the request carried, or that covers a key the request never asked for; a certs-only reply to a bound request is refused rather than read as an issuance. Under the verbs sit the transport-agnostic codecs they compose: the RFC 8951 base64 transfer codec (which ignores Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only and serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier — `transferDecode`/`transferEncode`, `parseCertsOnly`, `splitMultipartMixed`, `parseServerKeygenResponse`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
|
|
225
225
|
| `pki.scep` | SCEP pkiMessage codec (RFC 8894), the client side of Simple Certificate Enrollment. `build(spec)` assembles a request: a `PKCSReq` or `RenewalReq` wraps a PKCS#10 certification request, whose own proof-of-possession it verifies first; a `CertPoll` wraps an IssuerAndSubject naming the issuing CA and the request subject; and a `GetCert` or `GetCRL` wraps an IssuerAndSerialNumber naming a certificate by its issuer and a positive serial of at most 20 octets. It encrypts the messageData to the CA as the inner EnvelopedData (`pkcsPKIEnvelope`, AES-128-CBC), and signs that under the transaction attributes RFC 8894 §3.2 defines, a messageType, a caller-unique transactionId, and a 16-byte senderNonce. The recipient CA certificate must assert `keyEncipherment`. `build` also issues the CA-side `CertRep` response (§3.3.2): a SUCCESS envelopes a certs-only SignedData of the issued certificate (or the CRL for a GetCRL) to the requester and signs it under the CA key, while a FAILURE (carrying a `failInfo`) or a PENDING omits the pkcsPKIEnvelope and is signed detached, each echoing the request's senderNonce as `recipientNonce`. `parse(bytes, opts?)` reads a `PKCSReq`, `RenewalReq`, `CertPoll`, `GetCert`, `GetCRL`, or `CertRep` back (a messageType outside the RFC 8894 registry is refused): it verifies the outer signature first and refuses a message whose signature does not check, so the transaction attributes it returns come only from a verified signer, never alongside a false verdict. Because a valid signature only proves self-consistency with the embedded certificate, pass `opts.signerCert` to authenticate a CA response against the CA certificate you hold (a public-key mismatch is refused, `signerAuthenticated` reports the result); a caller that omits it gets a crypto-only verdict and must authenticate the surfaced `signerCert` itself. It maps the messageType, pkiStatus, failInfo, and the nonces from their RFC 8894 enumerants and refuses an unknown one; given `recipientKey` it decrypts the `pkcsPKIEnvelope` to the messageData, and given `expectedSenderNonce` it refuses a response whose recipientNonce does not echo it. It reads a CA's CertRep in full. `getCACaps(baseUrl, opts?)`, `getCACert(baseUrl, opts?)`, `getNextCACert(baseUrl, opts)`, `enroll(baseUrl, opts)`, `renew(baseUrl, opts)`, `getCert(baseUrl, opts)`, and `getCrl(baseUrl, opts)` carry that codec to a live CA over HTTP (RFC 8894 §4) through an injectable transport (`pki.transport.https` by default): `getCACaps` reports the CA's advertised capabilities and never lowers the client's algorithm choice on the unauthenticated response, `getCACert` pins the CA certificate to an out-of-band `expectedFingerprint` a returned certificate must match, `getNextCACert` fetches the CA's next (rollover) certificate and verifies the SignedData response against the current CA certificate (`caCertificate`) before returning it, refusing a response signed by any other key, and `enroll` and `renew` POST a PKCSReq or RenewalReq, authenticate the CertRep against the CA certificate and its nonce echo, select the issued certificate by public-key match, throw on a FAILURE with the CA's failInfo, and poll a PENDING response with CertPoll requests until the CA answers or a bounded poll-count and total-wait budget is spent. `getCert` and `getCrl` retrieve an already-issued certificate or a CA CRL by issuer and serial number, authenticated the same way; RFC 8894 §2.6, §2.7, and §3.3.4 recommend these queries only where HTTP certificate-store access (RFC 4387), LDAP, or a CRL distribution point is unavailable, so prefer those where a CA offers them. A PKIOperation goes over HTTP POST by default; `httpMethod: "GET"` carries it base64-encoded in the URL query for a legacy CA that accepts only GET (RFC 8894 §4.1). Every verb reaches an https CA through a forward proxy with `opts.proxy = { url, auth?, tls? }`: a `CONNECT` tunnel is opened to the proxy and the origin TLS is negotiated inside it, so the proxy carries the enrollment without being able to read it or substitute the origin certificate. Basic proxy authentication (RFC 7617) is sent only over an `https://` proxy, where it rides the authenticated TLS-to-proxy channel; an `http://` proxy is tunnel-only, and credentials over a plaintext proxy are refused rather than exposed (see `pki.transport`) — `build`, `parse`, `parseCapabilities`, `getCACaps`, `getCACert`, `getNextCACert`, `enroll`, `renew`, `getCert`, `getCrl` |
|
|
226
|
-
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive. `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body, tls }`, where `tls` carries the negotiated `protocol`, `cipher`, raw `peerCertificate`, and one channel binding per TLS version: on TLS 1.2 the RFC 5929 `tlsUnique` that `pki.est.challengePasswordFromTlsUnique` links to an EST enrollment (RFC 7030 §3.5), and on TLS 1.3 the RFC 9266 `tlsExporter` (32 bytes under the label `EXPORTER-Channel-Binding`, zero-length context). Each is `null` on the version that does not define it, and `tlsExporter` is withheld below TLS 1.3 because RFC 9266 §2 also requires the RFC 7627 extended master secret, which Node gives no way to confirm. Since `tlsUnique` on the response arrives after the body was sent, a request `body` may instead be a function `(tls) -> bytes` the transport invokes after the handshake and before it writes the body, so the caller builds a channel-bound CSR from `tlsUnique` on the same connection it posts over. It may return a promise, so the CSR can be signed with `pki.csr.sign` while the connection is held open; a callback that throws, rejects, or returns anything other than bytes or a string fails the request closed rather than posting an empty enrollment. This is the toolkit's only 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. The EST, ACME, and CMP clients reuse it verbatim. A `request.proxy = { url, auth?, tls? }` reaches the origin through a forward proxy: a `CONNECT` tunnel is opened to the proxy and the origin TLS is negotiated inside it under the identical origin trust policy, so the proxy cannot read the origin's encrypted session or substitute an authenticated origin certificate.
|
|
226
|
+
| `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive. `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body, tls }`, where `tls` carries the negotiated `protocol`, `cipher`, raw `peerCertificate`, and one channel binding per TLS version: on TLS 1.2 the RFC 5929 `tlsUnique` that `pki.est.challengePasswordFromTlsUnique` links to an EST enrollment (RFC 7030 §3.5), and on TLS 1.3 the RFC 9266 `tlsExporter` (32 bytes under the label `EXPORTER-Channel-Binding`, zero-length context). Each is `null` on the version that does not define it, and `tlsExporter` is withheld below TLS 1.3 because RFC 9266 §2 also requires the RFC 7627 extended master secret, which Node gives no way to confirm. Since `tlsUnique` on the response arrives after the body was sent, a request `body` may instead be a function `(tls) -> bytes` the transport invokes after the handshake and before it writes the body, so the caller builds a channel-bound CSR from `tlsUnique` on the same connection it posts over. It may return a promise, so the CSR can be signed with `pki.csr.sign` while the connection is held open; a callback that throws, rejects, or returns anything other than bytes or a string fails the request closed rather than posting an empty enrollment. This is the toolkit's only 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. The EST, ACME, and CMP clients reuse it verbatim. A `request.proxy = { url, auth?, tls? }` reaches the origin through a forward proxy: a `CONNECT` tunnel is opened to the proxy and the origin TLS is negotiated inside it under the identical origin trust policy, so the proxy cannot read the origin's encrypted session or substitute an authenticated origin certificate. Proxy credentials are sent only over an `https://` proxy, whose own certificate is verified against `proxy.tls`, so they ride an authenticated channel; a plaintext `http://` proxy is tunnel-only and carrying `auth` on one is refused (`proxy-auth-requires-tls`) rather than exposing the credentials to the proxy hop. `auth.scheme` is `"basic"` (RFC 7617), which sends its credential on the first CONNECT, or `"digest"` (RFC 7616), which answers the proxy's `Proxy-Authenticate` challenge on a `407` by hashing the method `CONNECT` over the authority-form target, and answers exactly once, so a proxy repeating its challenge has rejected the credential rather than obtained another attempt. Digest runs the policy the origin verbs apply, refusing MD5 and a challenge carrying no `qop` unless `auth.allowMD5` / `auth.allowLegacyQop` says otherwise. A malformed option, a failed CONNECT, an unverified proxy certificate, or an unaccepted credential fails closed. The SCEP, EST, ACME, and CMP clients each thread it through as `opts.proxy`. If you inject your own transport, return `tls` too: `pki.est.serverkeygen` asserts the negotiated cipher can protect the delivered private key, and a transport that reports no cipher is trusted rather than refused, so omitting the field silently skips that check — `https` |
|
|
227
227
|
| `pki.jose` | Flattened JWS (RFC 7515) and JWK thumbprints (RFC 7638). `sign` and `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required and forbidden header rules as data. `base64url` is the strict RFC 4648 §5 codec, rejecting padding, non-alphabet characters, and non-canonical trailing bits. `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), leaving no code path for `alg:none`, an RS256→HS256 key confusion, or an all-zero ECDSA signature; `assertPublicJwk` refuses a JWK carrying private material, so an exported private key is never published. `opts.key` names the key a message must be signed under and governs: where the profile also permits an embedded header `jwk`, the two must be the same key — compared as RFC 7638 thumbprints, so member order cannot make equal keys differ — and a disagreement is refused rather than resolved in the message's favor. `keySource` reports which key answered, since a signature checked against a key you named is a different claim from one checked against the key the message carried — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
|
|
228
228
|
| `pki.acme` | ACME (RFC 8555 / 8737 / 8738 / 9773). `client(directoryUrl, opts)` is a stateful client driving a live CA directory over `pki.transport`: `newAccount`, `newOrder`, `newAuthz`, `getOrder`, `getAuthorization`, `getChallenge`, `respondToChallenge`, `finalize`, `pollOrder`, `pollAuthorization`, and `downloadCertificate` walk the issuance flow, with `newAuthz` pre-authorizing a single identifier (§7.4.1) and `downloadCertificate` binding the issued certificate to the order — its key, its identifiers, or both; at least one is required by default and the result reports `boundToKey` / `boundToIdentifiers` — before choosing among alternate chains (`Link rel="alternate"`, §7.4.2, via a `selectChain` predicate bounded by `maxAlternates`). `revokeCert` (account-key or certificate-key signed), `keyChange`, `updateAccount` (change the account contacts, §7.3.2), `listOrders` (the account's orders list, following the §7.1.2.1 `Link rel="next"` pagination bounded by a page cap and confined to the orders origin), `deactivateAccount`, `deactivateAuthorization`, `renewalInfo` (ARI), `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision), and `scheduleRenewal` (the §4.1 auto-sleeping renewal loop that sleeps until the selected instant and refetches until it is time to renew, stopping at the certificate's notAfter) complete the lifecycle. Every URL is HTTPS only, an explicit trust anchor is required, each request carries a fresh single-use nonce with a bounded badNonce retry (and, when `opts.resignKeys` supplies caller-approved alternative account-key algorithms, a bounded RFC 8555 §6.2 badSignatureAlgorithm re-sign that the CA's advertised list can only filter, never widen, so it cannot force a weaker algorithm), reads are POST-as-GET, polling is bounded and sleeps on a Retry-After via an injectable sleeper capped by a poll count and a total-wait budget, and every response body is size-capped. The transport is injectable via `opts.transport`. 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, the request builders (newAccount with External Account Binding, newOrder with `replaces`, finalize with a 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 and ip identifier validators, and the ARI certID with serial sign-padding preserved — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
229
229
|
| `pki.schema.smime` | S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551). `parseSigningCertificate` and `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` and serial); `parseSmimeCapabilities` decodes the ordered capability list; `decodeAttribute` dispatches a CMS attribute by OID, enforcing the single-value rule and deferring on unknown types. A companion decoder for CMS signed attributes rather than an auto-routed format — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
230
230
|
| `pki.cmc` | Build and interpret CMC messages (RFC 5272). `build(spec, signer)` assembles a Full PKI Request across all three request arms (PKCS#10, CRMF, other) and signs it through `pki.cms.sign` under `id-cct-PKIData`. A PKCS#10 (`tcr`) request is verified against its own proof-of-possession before signing, so a request whose self-signature does not verify under its subject public key is refused rather than signed into a message a CA would reject. Body-part identifiers are unique across the whole message and never the reserved 0; a caller's clash is refused rather than renumbered, since a control may already reference it. An Identity Proof V2 witness is computed over the `reqSequence` bytes exactly as emitted (§6.2.1 step 1) rather than a re-serialization, a POP Link Witness is emitted only alongside the POP Link Random control §6.3.1.1 requires beside it, and a renewal carries neither Identification nor Identity Proof in either version. `verify(response, sent)` takes what the CA returned plus the state the client retained and reduces it to one terminal outcome: `issued`, `pending`, `confirm-required`, `pop-required`, or `rejected`. It binds the exchange first — Transaction Identifier, the Sender and Recipient Nonce echo compared in constant time and by full value so a truncation cannot match, and the Data Return echo — with each check applying only if the client sent that half, and, once sent, an absent or differing echo being a refusal, which is the replay defense. `bodyPartIDs` extends the same rule to what the response is about: a status reporting on a body part the request never sent is refused, which the transaction and nonce cannot catch, because a server can echo both correctly while answering about a different message. Several status controls are permitted and the worst governs, so a failure cannot hide behind an earlier success; the absence of any status control is success, per §6.1.2. The carrier's signature must verify (§3.2.1.3.4): a conforming SignedData carries its own signer certificate, so the ordinary build-then-verify flow needs nothing extra and the verdict reports `signatureVerified: true`. Where the signer is found nowhere the posture is fail-closed with a named opt-out — supply `certs` with the responder's certificate, or `allowUnverified: true`, in which case the verdict reports `signatureVerified: false`. Doing neither is refused, the opt-out never excuses a signature that is present and wrong, and a carrier with no signer at all is refused outright. The response's own `cmsSequence` and `otherMsgs` come back raw, since a request whose only arm was the other-message form has no certificate to return and §4.1 puts its answer there. Nothing is trusted: issued certificates are read from the CMS certificate bag (§4.2) and surfaced raw for `pki.path.validate`, and a Publish Trust Anchors control is surfaced with `trusted: false` rather than added to a store — `build`, `verify` |
|
|
231
231
|
| `pki.schema.cmc` | Decode CMC messages (RFC 5272 as updated by RFC 6402): a Full PKI Request (`PKIData`) or Full PKI Response (`PKIResponse`) riding inside a CMS SignedData, reached by the encapsulated content type (`id-cct-PKIData` / `id-cct-PKIResponse`). Controls are surfaced in wire order with their values raw, so an unrecognized one is data rather than a fault. Tagged requests decode across all three arms. The status verdicts (`CMCStatusInfo` v1 and `CMCStatusInfoV2`) are collected as an ordered list, and the RFC 6402 two-module `OtherStatusInfo` ambiguity — `pendInfo` and `extendedFailInfo` are both untagged SEQUENCEs in the 1988 module, told apart only by their first element — is resolved by inspection and refused when it cannot be told apart. Body-part identity is unique across the whole message rather than per sequence, 0 is reserved as the reference to the enclosing PKIData, and the `reqSequence` bytes are surfaced exactly as they appeared so an Identity Proof witness is computed over the wire bytes. A companion decoder for CMS content rather than an auto-routed format — `parse`, `parsePkiData`, `parsePkiResponse` |
|
|
232
232
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk`, `encode`, `embeddedDer`, and the schema combinators |
|
|
233
|
-
| `pki.path` | Certification-path validation (RFC 5280 §6). `validate` runs the §6.1 state machine over an ordered path and a trust anchor: signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA (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, and the certificate-policy tree. It returns a structured verdict with per-check reason codes and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes through `checkPurpose` — an anchor that carries either one with no purpose named to select by is refused as a configuration fault rather than validated as though it carried none. The verdict says what was established. `revocationChecked` takes the weakest outcome on the path: `false` with no checker supplied, `"determined"` when every certificate got an explicit good or revoked answer, `"waived"` when `softFail` turned an undetermined one into a pass, and `"undetermined"` when one could not be answered at all — which includes a checker that throws, a fault `softFail` does not waive because `softFail` opts into an undetermined answer, not into a broken checker. `anchorConstraints` names the purpose the anchor was judged under and whether each of its two constraints applied. `crlChecker` supplies CRL-based revocation, covering partitioned and sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets corresponding shards accumulate reason coverage until all eight revocation reasons are covered, and delta CRLs, merged onto the complete CRL they may be combined with (§5.2.4) so a held certificate its delta releases reads good, while a delta that merges with nothing still reports what it lists and still withholds good. `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-to-anchor path `validate` accepts, using name chaining plus the RFC 4158 §3.5 sort hints (an AKI/SKI match, an anchor-adjacent issuer, CA plus keyCertSign, validity at the check time — ordering hints, never filters), a depth-first search with backtracking so the first accepted path 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 (§4.2.2.1) over `pki.transport`, triggered only on a pool miss and bounded against SSRF and amplification: HTTPS only, a total fetch budget that caps fetching silently rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size and certificate-count cap, no redirect following, and every fault a silent skip. The TLS trust (`opts.tls`) stays distinct from the PKI `trustAnchors`, and every fetched certificate remains untrusted pool material that still flows through `validate` (never a trust anchor). It is off by default, so the default build is byte-identical offline. An `opts.direction` of `"reverse"` searches from a trust anchor toward the leaf (RFC 4158 §3.1), narrower when the anchor set is small and the leaf's issuer options fan out, and `"auto"` picks the direction by first-hop fan-out; reverse building reuses the same pool, visited set, caps, and `validate` gate, so the direction changes only search order, and the anchor is excluded from the returned path. Reverse building is pool-only and does not combine with `fetchAia`. When `opts.userInitialPolicySet` names acceptable certificate-policy OIDs and more than one path validates, build returns the accepted path whose user-constrained policy set best satisfies that set (RFC 4158 §4) rather than the first accepted path, the ranking bounded by the same candidate cap and a malformed set refused with `path/bad-input`; an `anyPolicy` entry is unconstrained and returns the first accepted path. Pure and re-entrant — `validate`, `build`, `crlChecker`, `ocspChecker` |
|
|
233
|
+
| `pki.path` | Certification-path validation (RFC 5280 §6). `validate` runs the §6.1 state machine over an ordered path and a trust anchor: signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA (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, and the certificate-policy tree. It returns a structured verdict with per-check reason codes and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes through `checkPurpose` — an anchor that carries either one with no purpose named to select by is refused as a configuration fault rather than validated as though it carried none. The verdict says what was established. `revocationChecked` takes the weakest outcome on the path: `false` with no checker supplied, `"determined"` when every certificate got an explicit good or revoked answer, `"waived"` when `softFail` turned an undetermined one into a pass, and `"undetermined"` when one could not be answered at all — which includes a checker that throws, a fault `softFail` does not waive because `softFail` opts into an undetermined answer, not into a broken checker. `anchorConstraints` names the purpose the anchor was judged under and whether each of its two constraints applied. `crlChecker` supplies CRL-based revocation, covering partitioned and sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets corresponding shards accumulate reason coverage until all eight revocation reasons are covered, and delta CRLs, merged onto the complete CRL they may be combined with (§5.2.4) so a held certificate its delta releases reads good, while a delta that merges with nothing still reports what it lists and still withholds good. `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-to-anchor path `validate` accepts, using name chaining plus the RFC 4158 §3.5 sort hints (an AKI/SKI match, an anchor-adjacent issuer, CA plus keyCertSign, validity at the check time — ordering hints, never filters), a depth-first search with backtracking so the first accepted path 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 (§4.2.2.1) over `pki.transport`, triggered only on a pool miss and bounded against SSRF and amplification: HTTPS only, a total fetch budget that caps fetching silently rather than throwing, a per-cert URL cap, a build-wide URL dedupe, a response-size and certificate-count cap, no redirect following, and every fault a silent skip. The TLS trust (`opts.tls`) stays distinct from the PKI `trustAnchors`, and every fetched certificate remains untrusted pool material that still flows through `validate` (never a trust anchor). It is off by default, so the default build is byte-identical offline. An `opts.direction` of `"reverse"` searches from a trust anchor toward the leaf (RFC 4158 §3.1), narrower when the anchor set is small and the leaf's issuer options fan out, and `"auto"` picks the direction by first-hop fan-out; reverse building reuses the same pool, visited set, caps, and `validate` gate, so the direction changes only search order, and the anchor is excluded from the returned path. Reverse building is pool-only and does not combine with `fetchAia`. When `opts.userInitialPolicySet` names acceptable certificate-policy OIDs and more than one path validates, build returns the accepted path whose user-constrained policy set best satisfies that set (RFC 4158 §4) rather than the first accepted path, the ranking bounded by the same candidate cap and a malformed set refused with `path/bad-input`; an `anyPolicy` entry is unconstrained and returns the first accepted path. In a mesh of cross-certified domains, where many candidates chain by name to the same place, a candidate is ranked by how many leading relative distinguished names it shares with the name the search is heading for (§3.5.16 and the §3.5.19 reverse method), so a certificate in the target's own domain is tried before one from a cross-certified domain, and forward building sorts a candidate under every candidate that can complete the path when its path length constraint cannot cover the certificates already below it (§3.5.7), an ordering that outranks the other hints rather than competing with them. No candidate is removed and an unbounded search still reaches every path the pool holds; a bounded one spends `maxCandidatesConsidered` on whichever branches the order steers it to. Pure and re-entrant — `validate`, `build`, `crlChecker`, `ocspChecker` |
|
|
234
234
|
| `pki.x509` | Certificate issuance (RFC 5280 §4). `sign(spec, issuer, opts)` builds and signs a certificate from a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER), the public key being certified, the validity window, an optional serial, and an optional `extensions` object. The `issuer` is a key alone (self-signed: issuer equals subject, signed with that key), a name plus public key plus key, or an issuing certificate plus key. The signature algorithm is resolved from the signing key through the shared registry, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA P-256/384/521, Ed25519, Ed448, ML-DSA-44/65/87, the twelve SLH-DSA sets, and the composite arms all issue without a per-algorithm branch. It encodes basic constraints, key usage, extended key usage, subject and authority key identifiers (the SKI derived by SHA-1 of the subject key), subject alternative names, and certificate policies from the spec, taking any other extension as pre-encoded DER. It derives the version from the field set and enforces the serial bounds, the UTCTime/GeneralizedTime cutover, the DER default omissions, and the CA cross-field rules; a violation throws a typed `CertificateError`. Returns DER, or a PEM `CERTIFICATE` with `opts.pem`. Every arm is independently verified by OpenSSL. Parsing stays at `pki.schema.x509.parse` — `sign` |
|
|
235
235
|
| `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985). `sign(spec, key, opts)` builds and signs a `CertificationRequest` from a `spec` of subject (which may be empty), the public key being certified, an optional `extensionRequest` carrying the requested v3 extensions a CA copies into the issued certificate (subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned, which is what `openssl req -verify` checks. The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. `verify(request)` asks the same question of a request someone else produced, over its exact signed `certificationRequestInfo` bytes under the `subjectPKInfo` inside them, through the one path-validation signature engine. It answers `{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }`, all re-derived from the signed bytes, so a CA issues from the fields the signature covers rather than from the object it passed in. A `verified: true` says the producer held that private key and that the subject and requested extensions are the ones they signed; who they are is the enrollment protocol's question, not this one. Parsing stays at `pki.schema.csr.parse` — `sign`, `verify` |
|
|
236
236
|
| `pki.attrcert` | Attribute-certificate issuance (RFC 5755). `sign(spec, issuer, opts)` builds and signs an `AttributeCertificate` as an Attribute Authority: a `spec` of `holder` (exactly one of an entity name, a `baseCertificateID` reference, a `fromCertificate` binding, or an object digest), the validity window as GeneralizedTime, an optional serial (positive, at most 20 octets, randomly generated when omitted), the `attributes` (role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo, or pre-encoded Attribute DER), and optional `extensions` (auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, authorityKeyIdentifier, or pre-encoded Extension DER) each with its RFC 5755 criticality. An attribute certificate is never self-signed, so the `issuer` is the signing AA, supplied as `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the AA key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, and the signature is verified under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE CERTIFICATE` with `opts.pem`; malformed input throws a typed `AttrCertError`. `verify(ac, issuer, opts)` is the receiving side, performing the RFC 5755 sec. 5 checks an attribute certificate and a named issuer can settle between them: the signature over the exact `AttributeCertificateInfo` bytes through the one path-validation signature engine, the AC naming the issuer this verifier trusts (compared as a distinguished name), the evaluation instant lying within the validity with equality at either bound succeeding as the section states, the sec. 4.3.2 targeting rule, and rejection of any critical extension this verb does not process (section 5 defines support as parsing the value AND rejecting where the value would reject, so `aaControls` and `acProxying`, whose constraints it does not evaluate, are refused when critical). It answers `{ verified, signatureValid, validityChecked, targetingChecked, holderBindingChecked, issuerPathChecked, holder, issuer, attributes, extensions, notBefore, notAfter, serialNumberHex, reason }` with the fields re-derived from the signed bytes; the holder's own certificate chain and the AC issuer's chain need certificates the verb is not given, so those slots report `false` rather than letting their absence read as a pass. Parsing stays at `pki.schema.attrcert.parse` — `sign`, `verify` |
|
package/lib/http-transport.js
CHANGED
|
@@ -239,7 +239,8 @@ function _classifyError(e, C) {
|
|
|
239
239
|
|
|
240
240
|
function _keySet(names) { var m = Object.create(null); for (var i = 0; i < names.length; i++) m[names[i]] = true; return m; }
|
|
241
241
|
var PROXY_KEYS = _keySet(["url", "auth", "tls"]);
|
|
242
|
-
var PROXY_AUTH_KEYS = _keySet(["scheme", "username", "password"]);
|
|
242
|
+
var PROXY_AUTH_KEYS = _keySet(["scheme", "username", "password", "allowMD5", "allowLegacyQop"]);
|
|
243
|
+
var PROXY_DIGEST_KNOBS = ["allowMD5", "allowLegacyQop"];
|
|
243
244
|
var PROXY_TLS_KEYS = _keySet(["anchors", "useSystemStore", "servername", "minVersion"]);
|
|
244
245
|
|
|
245
246
|
function _copyAnchor(a) { return (a !== null && typeof a === "object" && nodeUtil.types.isProxy(a)) ? a : (Buffer.isBuffer(a) ? Buffer.from(a) : a); }
|
|
@@ -319,11 +320,17 @@ function _validateProxy(request, defaults, E, C, url) {
|
|
|
319
320
|
if (!_copyableRecord(proxy.auth)) throw E(C("bad-proxy"), "opts.proxy.auth must be a plain object with only own enumerable data properties");
|
|
320
321
|
guard.identifier.assertKnownKeys(proxy.auth, PROXY_AUTH_KEYS, E, C("bad-proxy"), "opts.proxy.auth has an unknown option ");
|
|
321
322
|
var scheme = proxy.auth.scheme;
|
|
322
|
-
if (scheme
|
|
323
|
-
if (scheme !== "basic") throw E(C("bad-proxy"), "opts.proxy.auth.scheme must be 'basic', got " + guard.text.showValue(scheme));
|
|
323
|
+
if (scheme !== "basic" && scheme !== "digest") throw E(C("bad-proxy"), "opts.proxy.auth.scheme must be 'basic' or 'digest', got " + guard.text.showValue(scheme));
|
|
324
324
|
if (typeof proxy.auth.username !== "string" || typeof proxy.auth.password !== "string") throw E(C("bad-proxy"), "opts.proxy.auth requires a string username and password");
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
for (var ki = 0; ki < PROXY_DIGEST_KNOBS.length; ki++) {
|
|
326
|
+
var kn = PROXY_DIGEST_KNOBS[ki], kv = proxy.auth[kn];
|
|
327
|
+
if (kv === undefined) continue;
|
|
328
|
+
if (scheme !== "digest") throw E(C("bad-proxy"), "opts.proxy.auth." + kn + " applies only to Digest authentication");
|
|
329
|
+
if (typeof kv !== "boolean") throw E(C("bad-proxy"), "opts.proxy.auth." + kn + " must be a boolean, got " + guard.text.showValue(kv));
|
|
330
|
+
}
|
|
331
|
+
if (scheme === "basic" && proxy.auth.username.indexOf(":") !== -1) throw E(C("bad-proxy"), "a Basic proxy user-id must not contain a colon (RFC 7617 sec. 2)");
|
|
332
|
+
auth = { scheme: scheme, username: proxy.auth.username, password: proxy.auth.password,
|
|
333
|
+
allowMD5: proxy.auth.allowMD5 === true, allowLegacyQop: proxy.auth.allowLegacyQop === true };
|
|
327
334
|
}
|
|
328
335
|
|
|
329
336
|
var proxyTls = null;
|
|
@@ -365,6 +372,30 @@ function _validateProxy(request, defaults, E, C, url) {
|
|
|
365
372
|
function _basicProxyHeader(auth) {
|
|
366
373
|
return "Basic " + Buffer.from(auth.username + ":" + auth.password, "utf8").toString("base64");
|
|
367
374
|
}
|
|
375
|
+
|
|
376
|
+
/** @internal The `Proxy-Authorization` value answering a proxy's `Proxy-Authenticate: Digest` challenge
|
|
377
|
+
* (RFC 7616 sec. 3.3, RFC 9110 sec. 11.7.1). The challenge grammar is the one the origin uses, so the
|
|
378
|
+
* shipped parser reads it unchanged; a CONNECT hashes the method `CONNECT` over the authority-form target
|
|
379
|
+
* the request line carries (RFC 9112 sec. 3.2.3), which is the pair the proxy checks it against. */
|
|
380
|
+
function _digestProxyCredential(headers, pinfo, C, E) {
|
|
381
|
+
var httpDigest = require("./http-digest"); // allow:inline-require -- circular load: http-digest -> schema-pkix -> ct -> this module
|
|
382
|
+
var offered = String((headers && headers["proxy-authenticate"]) || "");
|
|
383
|
+
var policy = {
|
|
384
|
+
allowMD5: pinfo.auth.allowMD5, allowLegacyQop: pinfo.auth.allowLegacyQop,
|
|
385
|
+
codes: {
|
|
386
|
+
unsupportedAlgorithm: C("proxy-digest-unsupported-algorithm"), weakAlgorithm: C("proxy-digest-weak-algorithm"),
|
|
387
|
+
noQop: C("proxy-digest-no-qop"), badChallenge: C("proxy-digest-bad-challenge"),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
var challenge = httpDigest.parseChallenge(offered, E, C("proxy-digest-bad-challenge"), policy);
|
|
391
|
+
if (!challenge) {
|
|
392
|
+
throw E(C("proxy-auth-required"), "opts.proxy.auth.scheme is \"digest\" but the proxy offered no usable Digest challenge: " + guard.text.showValue(offered));
|
|
393
|
+
}
|
|
394
|
+
return httpDigest.answer(challenge, {
|
|
395
|
+
method: "CONNECT", uri: pinfo.originAuthority,
|
|
396
|
+
username: pinfo.auth.username, password: pinfo.auth.password, nc: 1, policy: policy,
|
|
397
|
+
}, E);
|
|
398
|
+
}
|
|
368
399
|
function _proxyErrorClass(e, C) {
|
|
369
400
|
var code = String((e && e.code) || "");
|
|
370
401
|
if (code.indexOf("ERR_TLS") === 0 || code.indexOf("ERR_SSL") === 0 || code.indexOf("SSL") !== -1 ||
|
|
@@ -405,7 +436,7 @@ function _proxyErrorClass(e, C) {
|
|
|
405
436
|
* - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
|
|
406
437
|
* - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
|
|
407
438
|
* - `blockPrivateAddresses` -- boolean; when true, an IP-literal host OR a hostname resolving to a private / loopback / link-local address is refused (`transport/blocked-address`), and a resolved address is pinned for the connection. For fetching an untrusted-certificate URL (AIA caIssuers); default false. It cannot be combined with `proxy`: a forward proxy resolves the origin itself, so the transport cannot enforce the block on it, and the combination is refused (`transport/bad-proxy`).
|
|
408
|
-
* - `proxy` -- reach the https origin through a forward HTTP proxy: `{ url: "http://proxy:3128" | "https://proxy:3128", auth?: { scheme: "basic", username, password }, tls?: { anchors, useSystemStore, servername, minVersion } }`. A CONNECT tunnel is opened to the proxy and the origin's TLS is negotiated inside it under the identical origin trust policy, so the proxy cannot read the origin's encrypted session or substitute the origin certificate. `auth` requires an `https://` proxy:
|
|
439
|
+
* - `proxy` -- reach the https origin through a forward HTTP proxy: `{ url: "http://proxy:3128" | "https://proxy:3128", auth?: { scheme: "basic" | "digest", username, password, allowMD5?, allowLegacyQop? }, tls?: { anchors, useSystemStore, servername, minVersion } }`. A CONNECT tunnel is opened to the proxy and the origin's TLS is negotiated inside it under the identical origin trust policy, so the proxy cannot read the origin's encrypted session or substitute the origin certificate. `auth` requires an `https://` proxy: the credentials ride the authenticated TLS-to-proxy channel (verified against `proxy.tls`, which is required for an https proxy), so a plaintext `http://` proxy carrying `auth` is refused (`transport/proxy-auth-requires-tls`) rather than exposing them to the proxy hop. An `http://` proxy is tunnel-only. Basic (RFC 7617) sends its credential on the first CONNECT. Digest (RFC 7616) answers the proxy's `Proxy-Authenticate` challenge on a `407`, hashing the method `CONNECT` over the authority-form target, and answers exactly once: a proxy that repeats its challenge has rejected the credential. The Digest policy is the one the origin verbs use, so MD5 and a challenge carrying no `qop` are refused unless `allowMD5` / `allowLegacyQop` says otherwise (`transport/proxy-digest-weak-algorithm`, `transport/proxy-digest-unsupported-algorithm`, `transport/proxy-digest-no-qop`, `transport/proxy-digest-bad-challenge`). A `407` is `transport/proxy-auth-failed` (credentials rejected) or `transport/proxy-auth-required` (none supplied, or none of the offered schemes is the configured one); a proxy certificate that does not verify is `transport/proxy-tls-failed`; a non-2xx CONNECT is `transport/proxy-connect-failed`; a malformed option is `transport/bad-proxy`. Off by default (a direct connection).
|
|
409
440
|
* @example
|
|
410
441
|
* var t = pki.transport.https({ tls: { anchors: [caPem] } });
|
|
411
442
|
* var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
|
|
@@ -485,7 +516,7 @@ function httpsTransport(defaults) {
|
|
|
485
516
|
var typedProxyError = false;
|
|
486
517
|
try {
|
|
487
518
|
var ec = (e instanceof Error) && !nodeUtil.types.isProxy(e) ? e.code : undefined;
|
|
488
|
-
typedProxyError = ec === C("bad-proxy") || ec === C("proxy-auth-requires-tls")
|
|
519
|
+
typedProxyError = ec === C("bad-proxy") || ec === C("proxy-auth-requires-tls");
|
|
489
520
|
} catch (_ie) { /* allow:swallow-unverified reading .code on a hostile error object can throw; treat any such failure as untyped */ }
|
|
490
521
|
if (typedProxyError) throw e;
|
|
491
522
|
throw E(C("bad-proxy"), "opts.proxy could not be validated", e);
|
|
@@ -595,25 +626,43 @@ function httpsTransport(defaults) {
|
|
|
595
626
|
if (!prep.proxy) { issue(prep.options); return; }
|
|
596
627
|
|
|
597
628
|
var pinfo = prep.proxy;
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
var
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
629
|
+
/** @internal The client counts its own attempts. A 407 is answered once, from the challenge the proxy
|
|
630
|
+
* sent; a proxy that answers the credentialed CONNECT with another 407 has rejected the credential,
|
|
631
|
+
* and repeating its challenge cannot obtain a further attempt. */
|
|
632
|
+
var digestAnswered = false;
|
|
633
|
+
var cr, done;
|
|
634
|
+
|
|
635
|
+
function issueConnect(credential) {
|
|
636
|
+
var pheaders = Object.assign(Object.create(null), { Host: pinfo.originAuthority, Connection: "keep-alive" });
|
|
637
|
+
if (credential) pheaders["Proxy-Authorization"] = credential;
|
|
638
|
+
var connectOpts = { host: pinfo.host, port: pinfo.port, method: "CONNECT", path: pinfo.originAuthority, headers: pheaders, agent: false };
|
|
639
|
+
try {
|
|
640
|
+
if (pinfo.secure) {
|
|
641
|
+
connectOpts.ca = pinfo.proxyTls.ca;
|
|
642
|
+
connectOpts.rejectUnauthorized = true;
|
|
643
|
+
connectOpts.minVersion = pinfo.proxyTls.minVersion;
|
|
644
|
+
var pIdentity = pinfo.proxyTls.servername || pinfo.host;
|
|
645
|
+
connectOpts.servername = (pIdentity && !nodeNet.isIP(pIdentity)) ? pIdentity : "";
|
|
646
|
+
connectOpts.checkServerIdentity = function (host, cert) { return nodeTls.checkServerIdentity(pIdentity, cert); };
|
|
647
|
+
cr = nodeHttps.request(connectOpts);
|
|
648
|
+
} else {
|
|
649
|
+
cr = nodeHttp.request(connectOpts);
|
|
650
|
+
}
|
|
651
|
+
} catch (e) { fail(C("proxy-connect-failed"), "the CONNECT request could not be initiated: " + ((e && e.message) || String(e)), e); return; }
|
|
652
|
+
creq = cr;
|
|
653
|
+
done = false;
|
|
654
|
+
cr.on("connect", function (res, socket) { onProxyResponse(res, socket); });
|
|
655
|
+
cr.on("response", function (res) {
|
|
656
|
+
// allow:swallow-unverified draining the proxy error body is best-effort; the verdict is the status
|
|
657
|
+
res.on("data", function () { });
|
|
658
|
+
res.on("error", function () { });
|
|
659
|
+
res.on("end", function () { });
|
|
660
|
+
onProxyResponse(res, null);
|
|
661
|
+
});
|
|
662
|
+
cr.on("error", function (e) { fail(pinfo.secure ? _proxyErrorClass(e, C) : C("proxy-connect-failed"), "the CONNECT request failed: " + ((e && e.message) || String(e)), e); });
|
|
663
|
+
cr.end();
|
|
664
|
+
}
|
|
665
|
+
|
|
617
666
|
function onProxyResponse(res, socket) {
|
|
618
667
|
if (done) { if (socket) _quietDestroy(socket); return; }
|
|
619
668
|
done = true;
|
|
@@ -646,19 +695,22 @@ function httpsTransport(defaults) {
|
|
|
646
695
|
return;
|
|
647
696
|
}
|
|
648
697
|
if (socket) _quietDestroy(socket);
|
|
649
|
-
if (sc === 407) {
|
|
698
|
+
if (sc === 407) {
|
|
699
|
+
if (pinfo.auth && pinfo.auth.scheme === "digest" && !digestAnswered) {
|
|
700
|
+
digestAnswered = true;
|
|
701
|
+
var credential;
|
|
702
|
+
try { credential = _digestProxyCredential(res.headers, pinfo, C, E); }
|
|
703
|
+
catch (e) { fail(e.code || C("proxy-auth-failed"), e.message, e); return; }
|
|
704
|
+
issueConnect(credential);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
fail(C(pinfo.auth ? "proxy-auth-failed" : "proxy-auth-required"), pinfo.auth ? "the proxy rejected the supplied credentials (HTTP 407)" : "the proxy requires authentication; supply proxy.auth over an https proxy (HTTP 407)");
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
650
710
|
fail(C("proxy-connect-failed"), "the proxy answered CONNECT with HTTP " + sc);
|
|
651
711
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
// allow:swallow-unverified draining the proxy error body is best-effort; the verdict is the status
|
|
655
|
-
res.on("data", function () { });
|
|
656
|
-
res.on("error", function () { });
|
|
657
|
-
res.on("end", function () { });
|
|
658
|
-
onProxyResponse(res, null);
|
|
659
|
-
});
|
|
660
|
-
cr.on("error", function (e) { fail(pinfo.secure ? _proxyErrorClass(e, C) : C("proxy-connect-failed"), "the CONNECT request failed: " + ((e && e.message) || String(e)), e); });
|
|
661
|
-
cr.end();
|
|
712
|
+
|
|
713
|
+
issueConnect(pinfo.auth && pinfo.auth.scheme === "basic" ? _basicProxyHeader(pinfo.auth) : null);
|
|
662
714
|
});
|
|
663
715
|
};
|
|
664
716
|
_transportFn.blocksPrivateAddresses = true;
|
package/lib/path-validate.js
CHANGED
|
@@ -2283,16 +2283,76 @@ function _candBaseScore(cand, time) {
|
|
|
2283
2283
|
return score;
|
|
2284
2284
|
}
|
|
2285
2285
|
|
|
2286
|
+
/** @internal A relative name that cannot be compared ends the shared prefix below; the count is a sort hint
|
|
2287
|
+
* and every candidate is still tried, so the comparison fault decides nothing. */
|
|
2288
|
+
function _rdnEqualSoft(a, b) {
|
|
2289
|
+
try { return rdnEqual(a, b); }
|
|
2290
|
+
catch (_e) { return false; } // allow:swallow-unverified a name the comparison refuses ends the shared prefix
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/** @internal How many leading relative names two distinguished names share, under the RFC 5280 sec. 7.1
|
|
2294
|
+
* comparison. Names in a public-key infrastructure are built as a tree, so in a mesh of cross-certified domains
|
|
2295
|
+
* a certificate in the target's own domain shares a prefix with it and one from a foreign domain shares little
|
|
2296
|
+
* or none (RFC 4158 sec. 3.5.16, sec. 3.5.19). */
|
|
2297
|
+
function rdnPrefixLength(a, b) {
|
|
2298
|
+
if (!intrinsic.isArray(a) || !intrinsic.isArray(b)) return 0;
|
|
2299
|
+
var n = a.length < b.length ? a.length : b.length;
|
|
2300
|
+
for (var i = 0; i < n; i++) {
|
|
2301
|
+
if (!_rdnEqualSoft(a[i], b[i])) return i;
|
|
2302
|
+
}
|
|
2303
|
+
return n;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
var RDN_PREFIX_WEIGHT = 5;
|
|
2307
|
+
/** @internal Within the name term, a shared prefix is the weaker signal and stays under an exact match,
|
|
2308
|
+
* however many relative names it shares. The terms are summed the way the rest of the RFC 4158 sec. 3.5
|
|
2309
|
+
* hints are, so this bounds the name term rather than the whole score. */
|
|
2310
|
+
var RDN_PREFIX_MAX = 95;
|
|
2311
|
+
var EXACT_NAME_BONUS = 100;
|
|
2312
|
+
function _prefixScore(a, b) {
|
|
2313
|
+
var s = rdnPrefixLength(a, b) * RDN_PREFIX_WEIGHT;
|
|
2314
|
+
return s > RDN_PREFIX_MAX ? RDN_PREFIX_MAX : s;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/** @internal RFC 4158 sec. 3.5.7: a certificate whose path length constraint cannot cover the certificates
|
|
2318
|
+
* already below it in the path cannot complete that path. The constraint counts the non-self-issued
|
|
2319
|
+
* certificates that follow (RFC 5280 sec. 6.1.4(m)), which forward building knows from the chain it holds, so
|
|
2320
|
+
* a key-rollover certificate below the candidate spends none of the budget. */
|
|
2321
|
+
function _forwardPathLenIneligible(cand, chain) {
|
|
2322
|
+
/** @internal A forward chain is built by prepending, so the leaf is its last entry and every entry before
|
|
2323
|
+
* it is an intermediate the candidate would sit above. */
|
|
2324
|
+
var below = 0;
|
|
2325
|
+
for (var i = 0; i < chain.length - 1; i++) {
|
|
2326
|
+
if (!selfIssued(chain[i])) below += 1;
|
|
2327
|
+
}
|
|
2328
|
+
if (below === 0) return false;
|
|
2329
|
+
var bc = softDecode(cand, OID.basicConstraints);
|
|
2330
|
+
var lim = bc && bc.value ? bc.value.pathLenConstraint : undefined;
|
|
2331
|
+
if (typeof lim !== "number") return false;
|
|
2332
|
+
return lim < below;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/** @internal Reverse building holds no such count: it knows what lies between a candidate and the anchor, and
|
|
2336
|
+
* the constraint governs what lies between the candidate and the target, which is the part not yet built. Every
|
|
2337
|
+
* candidate at one frame would take the same penalty, which orders nothing, so the rule is forward-only here. */
|
|
2338
|
+
|
|
2339
|
+
/** @internal RFC 4158 sec. 3.5.19, a candidate whose subject shares more relative names with the target's
|
|
2340
|
+
* issuer, orders nothing in this direction: forward building selects a candidate BY its subject matching the
|
|
2341
|
+
* current certificate's issuer, so every candidate at one decision point carries that same name. Reverse
|
|
2342
|
+
* building selects by issuer instead, and there the rule is the ordering below. */
|
|
2286
2343
|
function scoreCandidate(cand, childAki, anchors, time) {
|
|
2287
2344
|
var score = _candBaseScore(cand, time);
|
|
2288
2345
|
if (childAki) {
|
|
2289
2346
|
var d = softDecode(cand, OID.subjectKeyIdentifier);
|
|
2290
2347
|
if (d && Buffer.isBuffer(d.value) && d.value.equals(childAki)) score += 1000;
|
|
2291
2348
|
}
|
|
2349
|
+
var best = 0;
|
|
2292
2350
|
for (var i = 0; i < anchors.length; i++) {
|
|
2293
|
-
if (nameMatchSoft(cand.issuer.rdns, anchors[i].name.rdns)) {
|
|
2351
|
+
if (nameMatchSoft(cand.issuer.rdns, anchors[i].name.rdns)) { best = EXACT_NAME_BONUS; break; }
|
|
2352
|
+
var p = _prefixScore(cand.issuer.rdns, anchors[i].name.rdns);
|
|
2353
|
+
if (p > best) best = p;
|
|
2294
2354
|
}
|
|
2295
|
-
return score;
|
|
2355
|
+
return score + best;
|
|
2296
2356
|
}
|
|
2297
2357
|
|
|
2298
2358
|
function scoreCandidateReverse(cand, parentCert, leafCert, time) {
|
|
@@ -2304,7 +2364,8 @@ function scoreCandidateReverse(cand, parentCert, leafCert, time) {
|
|
|
2304
2364
|
if (d && intrinsic.isBuffer(d.value) && intrinsic.bufferEquals(d.value, candAki)) score += 1000;
|
|
2305
2365
|
}
|
|
2306
2366
|
}
|
|
2307
|
-
if (nameMatchSoft(leafCert.issuer.rdns, cand.subject.rdns)) score +=
|
|
2367
|
+
if (nameMatchSoft(leafCert.issuer.rdns, cand.subject.rdns)) score += EXACT_NAME_BONUS;
|
|
2368
|
+
else score += _prefixScore(cand.subject.rdns, leafCert.issuer.rdns);
|
|
2308
2369
|
return score;
|
|
2309
2370
|
}
|
|
2310
2371
|
|
|
@@ -2351,8 +2412,14 @@ function _assertUserInitialPolicySet(uips, prefix) {
|
|
|
2351
2412
|
}
|
|
2352
2413
|
}
|
|
2353
2414
|
|
|
2415
|
+
/** @internal Sorted so the candidate tried FIRST is last in the array, since the caller pushes onto a stack.
|
|
2416
|
+
* A candidate whose path length constraint cannot cover the certificates below it sorts under every candidate
|
|
2417
|
+
* that can, whatever the heuristics score it, and the score orders within each of the two groups. */
|
|
2354
2418
|
function _pushCandidates(frame, scored, stack, counter, down) {
|
|
2355
|
-
scored.sort(function (a, b) {
|
|
2419
|
+
scored.sort(function (a, b) {
|
|
2420
|
+
if (!a.ineligible !== !b.ineligible) return a.ineligible ? -1 : 1;
|
|
2421
|
+
return a.score - b.score;
|
|
2422
|
+
});
|
|
2356
2423
|
var n = 0;
|
|
2357
2424
|
for (var ci = 0; ci < scored.length; ci++) {
|
|
2358
2425
|
counter.tick();
|
|
@@ -2465,7 +2532,25 @@ async function _fetchAiaIssuers(current, aia) {
|
|
|
2465
2532
|
* Candidate issuers are matched by RFC 5280 7.1 name chaining, prioritized by the RFC 4158 3.5
|
|
2466
2533
|
* heuristics (a subjectKeyIdentifier/authorityKeyIdentifier match, an anchor-adjacent issuer,
|
|
2467
2534
|
* CA + keyCertSign, validity at the check time; each orders the search without excluding a
|
|
2468
|
-
* candidate)
|
|
2535
|
+
* candidate).
|
|
2536
|
+
*
|
|
2537
|
+
* Where cross-certified domains meet, several candidates chain by name and the order they are
|
|
2538
|
+
* tried in decides how much of the mesh is walked. Two more RFC 4158 3.5 orderings apply there.
|
|
2539
|
+
* A candidate is ranked by how many leading relative names it shares with the name the search is
|
|
2540
|
+
* heading for, its issuer against a trust anchor's name going forward (3.5.16) and its subject
|
|
2541
|
+
* against the leaf's issuer going in reverse (3.5.16 and 3.5.19 reverse methods), so a
|
|
2542
|
+
* certificate in the target's own domain is tried before one from a cross-certified domain.
|
|
2543
|
+
* Forward building also sorts a candidate under every candidate that can complete the path when its
|
|
2544
|
+
* `basicConstraints` path length constraint cannot cover the certificates already below it (3.5.7,
|
|
2545
|
+
* counting the non-self-issued ones the way 5280 6.1.4(m) does). That one outranks the other hints
|
|
2546
|
+
* rather than competing with them, so a key identifier or an anchor-adjacent issuer does not raise
|
|
2547
|
+
* a candidate whose constraint the path already exceeds. All of it is ordering alone: no candidate
|
|
2548
|
+
* is removed, every assembled path is handed to `validate`, and an unbounded search still reaches
|
|
2549
|
+
* every path the pool holds. A bounded one spends its budget on whichever branches it is steered
|
|
2550
|
+
* to, so a build that reports `path/build-limit` where an earlier order succeeded wants a larger
|
|
2551
|
+
* `maxCandidatesConsidered`, and the same holds for the `maxAiaFetches` allowance.
|
|
2552
|
+
*
|
|
2553
|
+
* The search runs depth-first with backtracking: the first ordered path that
|
|
2469
2554
|
* `pki.path.validate` accepts wins. A name or key-identifier match is only an ordering hint;
|
|
2470
2555
|
* every accept flows through `validate`, so `build` never weakens or duplicates a 6.1 check. The
|
|
2471
2556
|
* search over the untrusted pool is bounded: a depth cap on chain length, a total-work cap on
|
|
@@ -2702,7 +2787,8 @@ async function build(leaf, opts) {
|
|
|
2702
2787
|
var fbScored = [];
|
|
2703
2788
|
for (var pj = fb.poolMark; pj < pool.length; pj++) {
|
|
2704
2789
|
if (nameMatchSoft(pool[pj].subject.rdns, fbCur.issuer.rdns)) {
|
|
2705
|
-
guard.list.append(fbScored, { cand: pool[pj], score: scoreCandidate(pool[pj], fbAki, anchors, opts.time)
|
|
2790
|
+
guard.list.append(fbScored, { cand: pool[pj], score: scoreCandidate(pool[pj], fbAki, anchors, opts.time),
|
|
2791
|
+
ineligible: _forwardPathLenIneligible(pool[pj], fb.chain) });
|
|
2706
2792
|
}
|
|
2707
2793
|
}
|
|
2708
2794
|
fb.poolMark = pool.length;
|
|
@@ -2730,7 +2816,8 @@ async function build(leaf, opts) {
|
|
|
2730
2816
|
var scored = [];
|
|
2731
2817
|
for (var pi = 0; pi < pool.length; pi++) {
|
|
2732
2818
|
if (nameMatchSoft(pool[pi].subject.rdns, current.issuer.rdns)) {
|
|
2733
|
-
guard.list.append(scored, { cand: pool[pi], score: scoreCandidate(pool[pi], childAki, anchors, opts.time)
|
|
2819
|
+
guard.list.append(scored, { cand: pool[pi], score: scoreCandidate(pool[pi], childAki, anchors, opts.time),
|
|
2820
|
+
ineligible: _forwardPathLenIneligible(pool[pi], frame.chain) });
|
|
2734
2821
|
}
|
|
2735
2822
|
}
|
|
2736
2823
|
if (aiaCtx && frame.hop < maxDepth && aiaCtx.fetches < aiaCtx.maxFetches) {
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:3a1167b9-bd57-4112-8a1e-58236cee3f31",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-06T07:45:11.202Z",
|
|
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.6.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.6.44",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.6.
|
|
25
|
+
"version": "0.6.44",
|
|
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.6.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.6.44",
|
|
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.6.
|
|
57
|
+
"ref": "@blamejs/pki@0.6.44",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|