@blamejs/pki 0.6.5 → 0.6.7

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,7 +4,30 @@ 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.5 — 2026-08-31
7
+ ## v0.6.7 — 2026-08-31
8
+
9
+ pki.scep gains the SCEP HTTP client verbs (RFC 8894): getCACaps, getCACert, enroll, and renew drive a certificate enrollment against a SCEP CA over the shipped pkiMessage codec.
10
+
11
+ ### Added
12
+
13
+ - pki.scep.getCACaps(baseUrl, opts) queries a SCEP CA's advertised capabilities (RFC 8894 sec. 3.5) and returns the parsed keyword set; pki.scep.parseCapabilities(text) exposes the parser directly. The response is unauthenticated (sec. 7.5), so the verb reports what the CA claims without downgrading on it; pass expectSCEPStandard or requireStrongProfile to fail closed when the strong profile is absent.
14
+ - pki.scep.getCACert(baseUrl, opts) retrieves the CA certificate, either a single DER certificate or a certs-only chain (RFC 8894 sec. 4.2). Pass an out-of-band expectedFingerprint (a hex string or Buffer, SHA-256 by default) and a returned certificate must match it or the response is refused (RFC 8894 sec. 2.2); omitting it returns the certificates for the caller to authenticate.
15
+ - pki.scep.enroll(baseUrl, opts) and pki.scep.renew(baseUrl, opts) POST a PKCSReq or RenewalReq to ?operation=PKIOperation and read the CertRep. The response signature is authenticated against the CA certificate and its recipientNonce must echo the request's senderNonce; on SUCCESS the issued certificate is selected out of the response by SubjectPublicKeyInfo match, a FAILURE throws scep/enrollment-failed carrying the CA's failInfo, and a PENDING returns { status: "PENDING", transactionId } to retry. The content key is transported under RSAES-OAEP, so the CA certificate must be an RSA keyEncipherment certificate.
16
+
17
+ ### Fixed
18
+
19
+ - The EST, SCEP, and ACME client verbs override the mTLS client certificate, key, and pinned servername on a cross-origin redirect rather than omitting them. A transport constructed with a default client credential (for example pki.transport.https({ tls: { cert, key } })) no longer presents that credential to the redirected origin.
20
+
21
+ ## v0.6.6 — 2026-08-31
22
+
23
+ pki.kem establishes a shared secret with composite ML-KEM (draft-ietf-lamps-pq-composite-kem): a post-quantum ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448, so the secret holds if either component is later broken.
24
+
25
+ ### Added
26
+
27
+ - pki.kem.encapsulate(publicKey) establishes a 256-bit shared secret for a recipient's composite ML-KEM SubjectPublicKeyInfo, returning the secret and a ciphertext to send to the recipient (draft-ietf-lamps-pq-composite-kem).
28
+ - pki.kem.decapsulate(privateKey, ciphertext) recovers the shared secret from a composite ML-KEM ciphertext and the composite PKCS#8 private key. The twelve algorithms pair ML-KEM-768 and ML-KEM-1024 with RSA-OAEP 2048/3072/4096, ECDH over P-256/P-384/P-521 and brainpoolP256r1/P384r1, X25519, and X448.
29
+
30
+ ## v0.6.5 — 2026-08-30
8
31
 
9
32
  pki.cmp.build assembles a CMP key-recovery request (krr) body: a CertReqMessages under PKIBody tag [9], the key-recovery counterpart of an initialization request (RFC 9810 sec. 5.3.7).
10
33
 
package/README.md CHANGED
@@ -222,7 +222,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
222
222
  | `pki.schema.cmp` | CMP messages (RFC 9810): the header (version, sender and recipient including 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` and `bodyBytes` slices an external verifier reconstructs the protected part from. The CMP-before-OCSP dispatch order is enforced Fail-closed — `parse`, `pemDecode`, `pemEncode` |
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. `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
- | `pki.scep` | SCEP pkiMessage codec (RFC 8894), the client side of Simple Certificate Enrollment. `build(spec)` assembles a `PKCSReq` or `RenewalReq` request: it verifies the PKCS#10 certification request's own proof-of-possession, encrypts it 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`. `parse(bytes, opts?)` reads a `PKCSReq`, `RenewalReq`, or `CertRep` back (the `CertPoll`, `GetCert`, and `GetCRL` client queries are 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. Issuing a CertRep is not yet built — `build`, `parse` |
225
+ | `pki.scep` | SCEP pkiMessage codec (RFC 8894), the client side of Simple Certificate Enrollment. `build(spec)` assembles a `PKCSReq` or `RenewalReq` request: it verifies the PKCS#10 certification request's own proof-of-possession, encrypts it 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`. `parse(bytes, opts?)` reads a `PKCSReq`, `RenewalReq`, or `CertRep` back (the `CertPoll`, `GetCert`, and `GetCRL` client queries are 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?)`, `enroll(baseUrl, opts)`, and `renew(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, 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 return a PENDING to retry. Issuing a CertRep, `GetNextCACert`, and automatic polling are not yet built — `build`, `parse`, `getCACaps`, `getCACert`, `enroll`, `renew` |
226
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`, and raw `peerCertificate`. 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. 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), and `renewalWindow` (the RFC 9773 §4.2/4.3 renewal decision) 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, 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` |
@@ -248,6 +248,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
248
248
  | `pki.trust` | Mozilla and CCADB trust-store ingestion. `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export, both into one constraint-carrying anchor shape: the per-purpose trust bits, where only `CKT_NSS_TRUSTED_DELEGATOR` grants, and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer and serial rather than adjacency and are cross-checked against the parsed DER, so metadata cannot attach to the wrong root. `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
249
249
  | `pki.shbs` | Stateful hash-based signature verification: HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and in CMS by RFC 9708, profiled by NIST SP 800-208 for CNSA 2.0 firmware signing. `verify` checks an HSS signature, where every level must pass, and `verifyLms` a single-tree LMS, over the raw public-key and signature blobs the parsers already surface. Pure public-input SHA-256 and SHAKE256 hashing, a data-driven typecode registry, and bounds-before-slice reads; a malformed blob throws a typed `ShbsError` while a well-formed but wrong signature returns `false`. Verification only by design, since stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
250
250
  | `pki.hpke` | Hybrid Public Key Encryption (RFC 9180), the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS` and `setupR` establish a sender or recipient context (KEM encapsulation plus the HKDF key schedule); the context's `seal` and `open` AEAD-encrypt with a sequence-counter nonce, and `export` derives further secrets; the module-level `seal` and `open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) by HKDF-SHA256/SHA512 by AES-GCM / ChaCha20Poly1305 / export-only, across all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM and X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
251
+ | `pki.kem` | Composite ML-KEM key establishment (draft-ietf-lamps-pq-composite-kem), a post-quantum ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448 so the established secret holds if either component is later broken. `encapsulate` turns a recipient's composite `SubjectPublicKeyInfo` into a 256-bit shared secret and a ciphertext; `decapsulate` recovers the same secret from the ciphertext and the composite PKCS#8 private key. Each component KEM runs independently and the two secrets are mixed through a SHA3-256 combiner that binds the traditional ciphertext, the traditional public key, and a per-algorithm label. The twelve algorithms pair ML-KEM-768 and ML-KEM-1024 with RSA-OAEP 2048/3072/4096, ECDH over P-256/P-384/P-521 and brainpoolP256r1/P384r1, X25519, and X448, each verified against the draft Appendix G known-answer vectors; a malformed key or ciphertext, an unsupported algorithm, or a component decapsulation failure throws a typed `KemError` — `encapsulate`, `decapsulate` |
251
252
  | `pki.sigstore` | Offline verifier for a Sigstore bundle, the artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots and Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key, the ephemeral Fulcio certificate chain validated as of the Rekor log time, the RFC 9162 inclusion proof folded to a Rekor-signed tree root, the log entry bound to this exact signature, and the in-toto SLSA subject digest the caller confirms against the published artifact. It reuses the X.509 parser, the RFC 5280 path validator, and the Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `verified: true` says the artifact was signed and logged, not that a party you trust signed it — Fulcio issues to anyone who completes an OIDC flow, so who signed is decided only by `opts.identity`, and `identityChecked` reports which of its fields were compared. An identity policy naming no field, or a field name that is not one of the three, is refused rather than satisfied — either would accept every signer while reading as a policy in force — `pae`, `parseBundle`, `verifyBundle` |
252
253
  | `pki.inspect` | Human-readable inspection, the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders an OpenSSL-style report: version, serial, signature algorithm, issuer and subject distinguished names, validity, public-key details (curve or modulus size plus the raw point or modulus), every decoded extension with its critical flag, and the signature. `crl`, `csr`, and `cms` render the other formats the same way — a CRL like `openssl crl -text`, a CSR like `openssl req -text`, and a CMS message like `openssl cms -cmsout -print`, with a stable summary for a non-SignedData ContentInfo — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry with one set of field renderers, it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency, and the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version. A certificate policy's user notice renders as text, both its explicit text and a notice reference with the notice numbers that identify it, rather than hex, and a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
253
254
  | `pki.webauthn` | WebAuthn and passkey verification, both halves: offline trust evaluation of a W3C WebAuthn (Level 3) registration, and signature verification of the assertion every login returns. `parseAttestationObject(bytes)` decodes the CBOR attestation object, authenticatorData, and COSE credential key over the strict `pki.cbor` codec; `parseAuthenticatorData(bytes)` reads the bare form an assertion carries through the same parser; `parseClientData(bytes, opts)` decodes the `clientDataJSON` no signature check looks inside, through the shared JSON guard since these are attacker-chosen bytes, returning the challenge decoded so a caller compares bytes rather than spellings, and checking the ceremony type, challenge, and origin when the relying party supplies what it issued. `verifyAssertion(input)` verifies an assertion signature over `authenticatorData \|\| SHA-256(clientDataJSON)` — raw bytes, no COSE_Sign1, an ES256 signature in ASN.1 DER — and applies the §7.2 step 21 counter rule when a stored `previousSignCount` is given, so a counter that fails to advance is refused as a cloned authenticator. `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for packed, tpm, android-key, apple, fido-u2f, and none: the x5c leaf key, the apple nonce, the tpm `certInfo` Name and `extraData` over the `pubArea`, the android `KeyDescription`, and the fido-u2f `verificationData`. It binds the credential public key to each attestation, through the signed authenticatorData for packed and fido-u2f or a cert or `pubArea`-key equality check for android-key, apple, and tpm, and enforces each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256/384/512, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers ESP256/384/512, Ed25519, and Ed448 — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally encoded DER ECDSA signature. The verdict field is `attestationVerified`, and `signatureVerified` for an assertion, rather than a bare `verified`, because a sound statement is a different claim from an acceptable ceremony: an attestation naming another origin's RP ID with user presence clear is perfectly sound and must not be registered. Pass `expectedRpId`, `requireUserPresence`, `requireUserVerification`, or `allowedAlgorithms` and those are checked, with `bindingChecked` reporting which ran, so a check that passed can be told from one that never happened. Pass `opts.clientDataJSON` instead of the digest and the client data is read here too: the ceremony type is checked unconditionally, since a login response replayed into a registration is what that rule stops, and the challenge, origin, and top-level origin are checked against what the relying party issued, with `clientData.checked` reporting which comparisons ran. A registration verdict also carries the `credentialId`, `credentialPublicKey`, and initial `signCount` a later login needs. A credential key declaring COSE algorithm `-65535` (RSASSA-PKCS1-v1_5 with SHA-1) is refused unless `allowedAlgorithms` names it, since every signature that credential ever makes would use SHA-1. Anchoring the trust path has two routes: `opts.metadata` resolves the roots the authenticator's own model registered, and `opts.rootCertificates` pins roots directly, which is what anchors the formats FIDO MDS does not cover, Apple's authenticators and the Google hardware-attestation roots among them. `metadata` governs when both are given, and `anchoredTo` names every route that anchored the path, joined with `+` when more than one did: `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is `null` only when nothing anchored the path. `verifyMetadataBlob(blob, opts)` reads a FIDO Metadata Service (MDS v3) BLOB, the signed catalogue of registered authenticator models, verifying its JWS and chaining its signer to an operator-supplied FIDO root before the payload is parsed, with sequence-number rollback and `nextUpdate` freshness checks. Passing the result as `opts.metadata` to `verify` resolves the authenticator's registered attestation roots from its identifier and requires the trust path to fully validate to one of them, refusing an unlisted or revoked model. `metadataAnchors(entry, opts)` applies the same status gate for a caller anchoring the path themselves: a model the catalogue has disqualified registers no anchors. Both of the catalogue's key spaces are covered: an aaguid, and the attestation-certificate key identifiers a U2F authenticator is listed under instead. No FIDO root is bundled, there is no trust-on-first-use, and retrieving the BLOB is out of scope. Fail-closed with typed `webauthn/*` errors — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
package/index.js CHANGED
@@ -53,6 +53,7 @@ var pkcs12 = require("./lib/pkcs12-build");
53
53
  var merkle = require("./lib/merkle");
54
54
  var shbs = require("./lib/shbs");
55
55
  var hpke = require("./lib/hpke");
56
+ var compositeKem = require("./lib/composite-kem");
56
57
  var sigstore = require("./lib/sigstore");
57
58
  var est = require("./lib/est");
58
59
  var scep = require("./lib/scep");
@@ -156,6 +157,10 @@ module.exports = {
156
157
  // schedule + AEAD context construction behind TLS ECH / MLS / OHTTP. Pure
157
158
  // composition over node:crypto; the classical DHKEM suites, all four modes.
158
159
  hpke: hpke,
160
+ // `kem` is composite ML-KEM key establishment (draft-ietf-lamps-pq-composite-kem):
161
+ // pki.kem.encapsulate / decapsulate over a post-quantum ML-KEM hybridized with a
162
+ // traditional RSA-OAEP / ECDH / X25519 / X448, mixed through the SHA3-256 combiner.
163
+ kem: compositeKem,
159
164
  // `sigstore` verifies a Sigstore bundle (the npm --provenance artifact): a
160
165
  // keyless Fulcio signature over a DSSE-wrapped in-toto SLSA attestation with a
161
166
  // Rekor inclusion proof -- offline, zero-dep, against caller-pinned trust.
@@ -166,7 +171,7 @@ module.exports = {
166
171
  // splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
167
172
  // builders, the HTTP response classifier). est opens no socket; fail-closed.
168
173
  est: est,
169
- scep: { build: scep.build, parse: scep.parse },
174
+ scep: { build: scep.build, parse: scep.parse, getCACaps: scep.getCACaps, getCACert: scep.getCACert, enroll: scep.enroll, renew: scep.renew, parseCapabilities: scep.parseCapabilities },
170
175
  // `transport` is the shared fail-closed node:https transport the enrollment clients
171
176
  // drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
172
177
  // headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
package/lib/acme.js CHANGED
@@ -2346,9 +2346,13 @@ function client(directoryUrl, opts) {
2346
2346
  // pin, or a stricter host check) that node re-evaluates against the actual host, so a pin keeps applying
2347
2347
  // and a host-pinned check fails the wrong host closed; dropping it would accept the cross-origin host
2348
2348
  // under only the default hostname validation, bypassing the pin.
2349
- if (new URL(url).origin !== trustedOrigin && (t.cert != null || t.key != null || t.servername != null)) {
2349
+ if (new URL(url).origin !== trustedOrigin) {
2350
+ // Every cross-origin hop overrides the client identity with an explicit null, unconditionally: an
2351
+ // omitted field falls back to the transport's own configured defaults (http-transport resolves
2352
+ // reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert), so the override must fire even when the
2353
+ // request-level tls carries no credential, or a transport default would still reach the new origin.
2350
2354
  t = Object.assign({}, t);
2351
- delete t.cert; delete t.key; delete t.servername;
2355
+ t.cert = null; t.key = null; t.servername = null;
2352
2356
  }
2353
2357
  return t;
2354
2358
  }
@@ -0,0 +1,413 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.kem
6
+ * @nav Protocols
7
+ * @title Composite ML-KEM
8
+ * @fullname Composite ML-KEM (draft-ietf-lamps-pq-composite-kem)
9
+ * @intro Composite ML-KEM key establishment (draft-ietf-lamps-pq-composite-kem): a post-quantum
10
+ * ML-KEM hybridized with a traditional RSA-OAEP, ECDH, X25519, or X448, so the established shared
11
+ * secret stays secure as long as EITHER component is unbroken. pki.kem.encapsulate turns a
12
+ * recipient's composite public key into a 256-bit shared secret and a ciphertext; pki.kem.decapsulate
13
+ * recovers the same secret from that ciphertext and the composite private key. Each component KEM
14
+ * runs independently, and the two secrets are mixed through the SHA3-256 combiner that also binds the
15
+ * traditional ciphertext, the traditional public key, and a per-algorithm label. All twelve registered
16
+ * algorithms -- ML-KEM-768 and ML-KEM-1024 paired with RSA-OAEP 2048/3072/4096, ECDH over
17
+ * P-256/P-384/P-521 and brainpool, X25519, and X448 -- are verified against the draft Appendix G
18
+ * known-answer vectors.
19
+ * @spec draft-ietf-lamps-pq-composite-kem
20
+ * @card Establish a shared secret with a hybrid post-quantum plus traditional KEM.
21
+ */
22
+ //
23
+ // ML-KEM runs on the toolkit's hardened WebCrypto surface (the FIPS 203 ciphertext-length check +
24
+ // shared-secret zeroization ride along); the traditional component uses node:crypto directly, as the
25
+ // sibling KEM module lib/hpke.js does, promoting RSA-OAEP into a KEM (a fresh random secret encrypted,
26
+ // draft sec. 2.1) and ECDH / X25519 / X448 into a KEM (an ephemeral public key as the ciphertext,
27
+ // draft sec. 2.2). The public key is mlkemEK || tradPK (sec. 4.1) in the SPKI BIT STRING; the private
28
+ // key is mlkemSeed || tradSK (sec. 4.2, a 64-byte ML-KEM seed) in the PKCS#8 privateKey OCTET STRING;
29
+ // the ciphertext is mlkemCT || tradCT (sec. 4.3). The ML-KEM component is the fixed-length first half,
30
+ // and the split point is its parameter-set length.
31
+
32
+ var asn1 = require("./asn1-der");
33
+ var oid = require("./oid");
34
+ var webcrypto = require("./webcrypto");
35
+ var nodeCrypto = require("node:crypto");
36
+ var frameworkError = require("./framework-error");
37
+ var guard = require("./guard-all");
38
+ var pkix = require("./schema-pkix");
39
+ var schemaPkcs8 = require("./schema-pkcs8");
40
+ var subtle = webcrypto.webcrypto.subtle;
41
+ var _b = asn1.build;
42
+ var KemError = frameworkError.KemError;
43
+ var PemError = frameworkError.PemError;
44
+ function _err(code, message, cause) { return new KemError(code, message, cause); }
45
+
46
+ // Accept a composite key as DER (any BufferSource, snapshotted at the door) or as a PEM block with the
47
+ // given label. A PEM decode fault is re-typed into the module's own KemError so the verb's documented
48
+ // "throws a typed KemError" contract holds for every input shape. PEM is detected with the shared armor
49
+ // check (so leading text before "-----BEGIN" is tolerated as elsewhere in the toolkit), and the byte
50
+ // source is snapshot-COPIED first: the async verbs read the key across await points, so it must not alias
51
+ // a caller buffer that can change underneath them (guard.bytes.source, which coerceToDer uses, aliases).
52
+ function _toDer(input, pemLabel, what) {
53
+ if (typeof input === "string") return _pemToDer(input, pemLabel);
54
+ var buf = guard.bytes.snapshotSource(input, KemError, "kem/bad-input", what);
55
+ if (pkix.isPemArmor(buf)) return _pemToDer(buf, pemLabel);
56
+ return buf;
57
+ }
58
+ function _pemToDer(text, label) {
59
+ try { return pkix.pemDecode(text, label, PemError); }
60
+ catch (e) { throw _err("kem/bad-key", "the PEM input is not a valid " + label + " block", e); }
61
+ }
62
+
63
+ // ---- registry (draft sec. 6) -------------------------------------------------
64
+ //
65
+ // One row per composite algorithm, keyed by the composite OID. `trad` is exactly one component shape,
66
+ // each carrying `ctLen`, the exact byte length of its traditional ciphertext component (RSA-OAEP block,
67
+ // EC uncompressed point, or raw OKP ephemeral key), so the composite ciphertext length is fixed and an
68
+ // oversized attacker-controlled component is refused before any key is built:
69
+ // { kind: "rsa", bits, ctLen } RSA-OAEP-KEM, OAEP SHA-256 / MGF1-SHA256 / empty label (sec. 6.1)
70
+ // { kind: "ec", node, curve, ctLen } ECDH DHKEM, `node` the node namedCurve, `curve` the curve OID name
71
+ // { kind: "okp", curve, ctLen } X25519 / X448 DHKEM, `curve` the registered OID name
72
+ // `label` is stored as the AUTHORITATIVE per-row bytes (draft sec. 6): most are ASCII, but the
73
+ // id-MLKEM768-X25519 label is the raw six bytes 5c2e2f2f5e5c and MUST NOT be derived from the name.
74
+ var COMPOSITE_KEM = {};
75
+ function _comp(name, mlkemBits, label, trad) {
76
+ var mlOidName = "id-ml-kem-" + mlkemBits;
77
+ var kp = oid.kemParams(mlOidName);
78
+ COMPOSITE_KEM[oid.byName(name)] = {
79
+ name: name, mlkemWc: "ML-KEM-" + mlkemBits, mlkemOidName: mlOidName,
80
+ mlkemCt: kp.ct, mlkemEk: kp.ek, label: label, trad: trad,
81
+ };
82
+ }
83
+ _comp("id-MLKEM768-RSA2048-SHA3-256", 768, Buffer.from("MLKEM768-RSAOAEP2048", "ascii"), { kind: "rsa", bits: 2048, ctLen: 256 });
84
+ _comp("id-MLKEM768-RSA3072-SHA3-256", 768, Buffer.from("MLKEM768-RSAOAEP3072", "ascii"), { kind: "rsa", bits: 3072, ctLen: 384 });
85
+ _comp("id-MLKEM768-RSA4096-SHA3-256", 768, Buffer.from("MLKEM768-RSAOAEP4096", "ascii"), { kind: "rsa", bits: 4096, ctLen: 512 });
86
+ _comp("id-MLKEM768-X25519-SHA3-256", 768, Buffer.from("5c2e2f2f5e5c", "hex"), { kind: "okp", curve: "X25519", ctLen: 32 });
87
+ _comp("id-MLKEM768-ECDH-P256-SHA3-256", 768, Buffer.from("MLKEM768-P256", "ascii"), { kind: "ec", node: "prime256v1", curve: "prime256v1", ctLen: 65 });
88
+ _comp("id-MLKEM768-ECDH-P384-SHA3-256", 768, Buffer.from("MLKEM768-P384", "ascii"), { kind: "ec", node: "secp384r1", curve: "secp384r1", ctLen: 97 });
89
+ _comp("id-MLKEM768-ECDH-brainpoolP256r1-SHA3-256", 768, Buffer.from("MLKEM768-BP256", "ascii"), { kind: "ec", node: "brainpoolP256r1", curve: "brainpoolP256r1", ctLen: 65 });
90
+ _comp("id-MLKEM1024-RSA3072-SHA3-256", 1024, Buffer.from("MLKEM1024-RSAOAEP3072", "ascii"), { kind: "rsa", bits: 3072, ctLen: 384 });
91
+ _comp("id-MLKEM1024-ECDH-P384-SHA3-256", 1024, Buffer.from("MLKEM1024-P384", "ascii"), { kind: "ec", node: "secp384r1", curve: "secp384r1", ctLen: 97 });
92
+ _comp("id-MLKEM1024-ECDH-brainpoolP384r1-SHA3-256", 1024, Buffer.from("MLKEM1024-BP384", "ascii"), { kind: "ec", node: "brainpoolP384r1", curve: "brainpoolP384r1", ctLen: 97 });
93
+ _comp("id-MLKEM1024-X448-SHA3-256", 1024, Buffer.from("MLKEM1024-X448", "ascii"), { kind: "okp", curve: "X448", ctLen: 56 });
94
+ _comp("id-MLKEM1024-ECDH-P521-SHA3-256", 1024, Buffer.from("MLKEM1024-P521", "ascii"), { kind: "ec", node: "secp521r1", curve: "secp521r1", ctLen: 133 });
95
+
96
+ var MLKEM_SEED = 64; // d || z, both 32 bytes (draft sec. 4.2)
97
+ var RSA_SS = 32; // ss_len for RSA-OAEP-KEM (draft sec. 6.1)
98
+
99
+ // ---- ML-KEM component (toolkit WebCrypto) ------------------------------------
100
+ //
101
+ // The private key is the 64-byte seed; node reconstructs the decapsulation key from a PKCS#8 whose
102
+ // privateKey OCTET STRING wraps the seed as a context-[0] primitive (the OpenSSL / draft seed form),
103
+ // not from node's own { seed } keygen option, whose seed convention differs.
104
+ function _mlkemSeedPkcs8(d, seed) {
105
+ return _b.sequence([
106
+ _b.integer(0n),
107
+ _b.sequence([_b.oid(oid.byName(d.mlkemOidName))]),
108
+ _b.octetString(_b.implicit(0, _b.octetString(seed))),
109
+ ]);
110
+ }
111
+ function _mlkemSpki(d, ek) {
112
+ return _b.sequence([_b.sequence([_b.oid(oid.byName(d.mlkemOidName))]), _b.bitString(ek, 0)]);
113
+ }
114
+ function _mlkemEncaps(d, mlkemEK) {
115
+ return subtle.importKey("spki", _mlkemSpki(d, mlkemEK), { name: d.mlkemWc }, false, ["encapsulateBits"])
116
+ .then(function (k) { return subtle.encapsulateBits({ name: d.mlkemWc }, k); })
117
+ .then(function (r) { return { ss: Buffer.from(r.sharedKey), ct: Buffer.from(r.ciphertext) }; })
118
+ // a malformed ML-KEM public-key half surfaces from the engine as a WebCryptoError; re-type it into
119
+ // the module's own KemError so the verb's typed-error contract holds. The chain runs only the
120
+ // WebCrypto component operations, which never raise a KemError, so the re-type is unconditional.
121
+ .catch(function (e) { throw _err("kem/bad-key", "the ML-KEM component public key is invalid", e); });
122
+ }
123
+ function _mlkemDecaps(d, seed, mlkemCT) {
124
+ return subtle.importKey("pkcs8", _mlkemSeedPkcs8(d, seed), { name: d.mlkemWc }, false, ["decapsulateBits"])
125
+ .then(function (k) { return subtle.decapsulateBits({ name: d.mlkemWc }, k, mlkemCT); })
126
+ .then(function (ss) { return Buffer.from(ss); })
127
+ // Defensive re-type mirroring _mlkemEncaps. Verified-unreachable from the shipped decapsulate path:
128
+ // the caller-checked split always supplies a valid 64-byte ML-KEM seed (any 64 bytes are a valid
129
+ // key) and an exact-parameter-set-length ML-KEM ciphertext, and ML-KEM decapsulation is implicitly
130
+ // rejecting, so neither the import nor the decapsulation of the ML-KEM half can fail here. Kept for
131
+ // node's experimental ML-KEM surface and for symmetry with the reachable encapsulate catch.
132
+ .catch(function (e) { throw _err("kem/bad-key", "the ML-KEM component private key is invalid", e); });
133
+ }
134
+
135
+ // ---- traditional component (node:crypto, like lib/hpke.js) -------------------
136
+ //
137
+ // The traditional public key encodings mixed into the combiner (draft sec. 4.1 / Appendix B): RSA is
138
+ // the RSAPublicKey DER, EC is the uncompressed X9.62 point, X25519 / X448 is the raw curve point.
139
+ function _ecPubFromPoint(d, point) {
140
+ // draft Appendix B: the EC component is the uncompressed X9.62 point (0x04 || X || Y). node would
141
+ // silently decompress a 0x02/0x03 point, so a compressed or hybrid form is refused here as non-canonical;
142
+ // createPublicKey then validates the length and that the point is on the curve.
143
+ if (point.length < 1 || point[0] !== 0x04) throw _err("kem/bad-key", "the EC component point is not in uncompressed form");
144
+ // SubjectPublicKeyInfo { AlgorithmIdentifier { ecPublicKey, namedCurve }, BIT STRING point }.
145
+ var spki = _b.sequence([_b.sequence([_b.oid(oid.byName("ecPublicKey")), _b.oid(oid.byName(d.trad.curve))]), _b.bitString(point, 0)]);
146
+ return nodeCrypto.createPublicKey({ key: spki, format: "der", type: "spki" });
147
+ }
148
+ function _okpPubFromRaw(d, raw) {
149
+ var spki = _b.sequence([_b.sequence([_b.oid(oid.byName(d.trad.curve))]), _b.bitString(raw, 0)]);
150
+ return nodeCrypto.createPublicKey({ key: spki, format: "der", type: "spki" });
151
+ }
152
+ function _okpPrivFromRaw(d, raw) {
153
+ var p8 = _b.sequence([_b.integer(0n), _b.sequence([_b.oid(oid.byName(d.trad.curve))]), _b.octetString(_b.octetString(raw))]);
154
+ return nodeCrypto.createPrivateKey({ key: p8, format: "der", type: "pkcs8" });
155
+ }
156
+ // The subjectPublicKey octets of a public KeyObject (the uncompressed EC point / raw OKP key), read
157
+ // off the SPKI export rather than a JWK export, which node cannot produce for the brainpool curves.
158
+ function _rawPubOf(keyObject) {
159
+ var spki = keyObject.export({ format: "der", type: "spki" });
160
+ return Buffer.from(asn1.read.bitString(asn1.decode(spki).children[1]).bytes);
161
+ }
162
+
163
+ function _tradEncaps(d, tradPK) {
164
+ var trad = d.trad;
165
+ if (trad.kind === "rsa") {
166
+ var pub;
167
+ try {
168
+ // node's createPublicKey ignores trailing bytes after the RSAPublicKey; decode strictly first so a
169
+ // composite key carrying extra bytes after the RSA component is refused (draft sec. 4.1: exact encoding).
170
+ asn1.decode(tradPK);
171
+ pub = nodeCrypto.createPublicKey({ key: tradPK, format: "der", type: "pkcs1" });
172
+ } catch (e) { return Promise.reject(_err("kem/bad-key", "the RSA component public key is not a valid RSAPublicKey", e)); }
173
+ if (pub.asymmetricKeyDetails.modulusLength !== trad.bits) return Promise.reject(_err("kem/bad-key", "the RSA component modulus is not " + trad.bits + " bits (draft sec. 6)"));
174
+ var ss = Buffer.from(nodeCrypto.randomBytes(RSA_SS));
175
+ var ct;
176
+ try { ct = Buffer.from(nodeCrypto.publicEncrypt({ key: pub, padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, ss)); }
177
+ catch (e) {
178
+ // the random secret is already allocated; wipe it before rejecting if the RSA public operation fails
179
+ // (draft sec. 3.5). On success it is returned and wiped by the combiner.
180
+ guard.secret.zeroize(ss, KemError, "kem/bad-input", "the RSA-OAEP component secret");
181
+ return Promise.reject(_err("kem/bad-key", "the RSA-OAEP component encryption failed", e));
182
+ }
183
+ return Promise.resolve({ ss: ss, ct: ct });
184
+ }
185
+ // DHKEM: an ephemeral keypair, the shared secret is DH(ephemeral, recipient), the ciphertext is the
186
+ // ephemeral public key in the component's own encoding (draft sec. 2.2). The Diffie-Hellman is inside
187
+ // the try: a structurally valid but low-order recipient point (e.g. an all-zero X25519 key) imports
188
+ // cleanly but makes node's diffieHellman throw, which must surface as a typed fault rather than a raw
189
+ // OpenSSL error escaping the encapsulation.
190
+ var dh, dhCt;
191
+ try {
192
+ var recip = trad.kind === "ec" ? _ecPubFromPoint(d, tradPK) : _okpPubFromRaw(d, tradPK);
193
+ var eph = trad.kind === "ec" ? nodeCrypto.generateKeyPairSync("ec", { namedCurve: trad.node }) : nodeCrypto.generateKeyPairSync(trad.curve.toLowerCase());
194
+ dh = Buffer.from(nodeCrypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recip }));
195
+ dhCt = _rawPubOf(eph.publicKey);
196
+ } catch (e) { return Promise.reject(_err("kem/bad-key", "the " + trad.curve + " public key is invalid", e)); }
197
+ return Promise.resolve({ ss: dh, ct: dhCt });
198
+ }
199
+
200
+ function _tradDecaps(d, tradSK, tradCT) {
201
+ var trad = d.trad;
202
+ if (trad.kind === "rsa") {
203
+ var priv;
204
+ try {
205
+ // node's createPrivateKey ignores trailing bytes after the RSAPrivateKey; decode strictly first so a
206
+ // composite key carrying extra bytes after the RSA component is refused (draft sec. 4.2: exact encoding).
207
+ asn1.decode(tradSK);
208
+ priv = nodeCrypto.createPrivateKey({ key: tradSK, format: "der", type: "pkcs1" });
209
+ } catch (e) { return Promise.reject(_err("kem/bad-key", "the RSA component private key is not a valid RSAPrivateKey", e)); }
210
+ if (priv.asymmetricKeyDetails.modulusLength !== trad.bits) return Promise.reject(_err("kem/bad-key", "the RSA component modulus is not " + trad.bits + " bits (draft sec. 6)"));
211
+ var ss;
212
+ // OAEP-invalid ciphertext and valid-padding-wrong-length plaintext MUST fail identically: a distinct
213
+ // verdict for either is an RSA-OAEP padding-validity oracle (Manger). Both reject with the same code
214
+ // and message AND no cause (the OpenSSL error is dropped, since it too would tell the paths apart), so
215
+ // the caller cannot tell the failure reason apart. draft sec. 2.1 fixes the transported secret at 32
216
+ // bytes; a plaintext of any other length is wiped and rejected here with that same verdict (sec. 3.5).
217
+ try { ss = Buffer.from(nodeCrypto.privateDecrypt({ key: priv, padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, tradCT)); }
218
+ catch (_e) { return Promise.reject(_err("kem/decapsulation-failed", "the RSA-OAEP component decryption failed")); }
219
+ if (ss.length !== RSA_SS) {
220
+ guard.secret.zeroize(ss, KemError, "kem/bad-input", "the RSA-OAEP component secret");
221
+ return Promise.reject(_err("kem/decapsulation-failed", "the RSA-OAEP component decryption failed"));
222
+ }
223
+ // The combiner input tradPK is the RSAPublicKey DER (n, e), draft sec. 4.1, taken from the key object.
224
+ return Promise.resolve({ ss: ss, tradPK: Buffer.from(nodeCrypto.createPublicKey(priv).export({ format: "der", type: "pkcs1" })) });
225
+ }
226
+ var priv2, eph;
227
+ try {
228
+ if (trad.kind === "ec") {
229
+ // node's SEC1 reader ignores trailing bytes after the ECPrivateKey; decode strictly first so a
230
+ // composite key carrying extra bytes after the EC component is refused (draft sec. 4.2, exact encoding).
231
+ asn1.decode(tradSK);
232
+ priv2 = nodeCrypto.createPrivateKey({ key: tradSK, format: "der", type: "sec1" });
233
+ } else {
234
+ // the OKP private key is a fixed-length raw scalar; node rejects any wrong length, trailing included.
235
+ priv2 = _okpPrivFromRaw(d, tradSK);
236
+ }
237
+ eph = trad.kind === "ec" ? _ecPubFromPoint(d, tradCT) : _okpPubFromRaw(d, tradCT);
238
+ } catch (e) { return Promise.reject(_err("kem/bad-key", "the " + trad.curve + " key material is invalid", e)); }
239
+ var dh;
240
+ try { dh = Buffer.from(nodeCrypto.diffieHellman({ privateKey: priv2, publicKey: eph })); }
241
+ catch (e) { return Promise.reject(_err("kem/decapsulation-failed", "the " + trad.curve + " Diffie-Hellman failed", e)); }
242
+ return Promise.resolve({ ss: dh, tradPK: _rawPubOf(nodeCrypto.createPublicKey(priv2)) });
243
+ }
244
+
245
+ // ---- combiner (draft sec. 3.4) -----------------------------------------------
246
+ function _combine(d, mlkemSS, tradSS, tradCT, tradPK) {
247
+ var preimage = Buffer.concat([mlkemSS, tradSS, tradCT, tradPK, d.label]);
248
+ return subtle.digest("SHA3-256", preimage).then(function (h) {
249
+ return Buffer.from(h);
250
+ }).finally(function () {
251
+ // draft sec. 3.5: clear the intermediate key material on every path, whether the digest resolves or
252
+ // fails. Only the returned composite secret survives. tradCT / tradPK are public, but the preimage
253
+ // holds copies of both component shared secrets, and mlkemSS / tradSS are the component secrets.
254
+ guard.secret.zeroizeAll([preimage, mlkemSS, tradSS], KemError, "kem/bad-input", "a KEM combiner secret");
255
+ });
256
+ }
257
+
258
+ // Run the two component operations; if EITHER rejects, wipe the shared secret the OTHER already produced
259
+ // before re-throwing, so a component failure leaves no key material readable (draft sec. 3.5 -- clear all
260
+ // buffers and forward the error). On success the combiner wipes both component secrets. `secA` / `secB`
261
+ // extract the secret Buffer from each component's result.
262
+ async function _settleOrWipe(pA, pB, secA, secB) {
263
+ var s = await Promise.allSettled([pA, pB]);
264
+ if (s[0].status === "rejected" || s[1].status === "rejected") {
265
+ if (s[0].status === "fulfilled") guard.secret.zeroize(secA(s[0].value), KemError, "kem/bad-input", "an ML-KEM shared secret");
266
+ if (s[1].status === "fulfilled") guard.secret.zeroize(secB(s[1].value), KemError, "kem/bad-input", "a traditional shared secret");
267
+ throw s[0].status === "rejected" ? s[0].reason : s[1].reason;
268
+ }
269
+ return [s[0].value, s[1].value];
270
+ }
271
+
272
+ // ---- descriptor resolution ---------------------------------------------------
273
+ function _descriptor(algOid) {
274
+ var d = COMPOSITE_KEM[algOid];
275
+ if (!d) throw _err("kem/unsupported-algorithm", "not a composite ML-KEM algorithm: " + (oid.name(algOid) || algOid));
276
+ return d;
277
+ }
278
+ // Resolve the composite descriptor from an algorithm OID, enforcing the draft sec. 5.3 parameters-absent
279
+ // rule. Both verbs feed the same (oid, hasParams) here so the dispatch and the parameters rule live once,
280
+ // whether the AlgorithmIdentifier was read from a hand-decoded SPKI or a schema-parsed OneAsymmetricKey.
281
+ function _descriptorForAlg(algOid, hasParams) {
282
+ if (hasParams) throw _err("kem/bad-algorithm", "a composite ML-KEM AlgorithmIdentifier MUST have absent parameters (draft sec. 5.3)");
283
+ return _descriptor(algOid);
284
+ }
285
+ // A DER node is a universal SEQUENCE (SubjectPublicKeyInfo and AlgorithmIdentifier are both SEQUENCE, not
286
+ // SET or any other constructed tag): a SET carrying the same children is not a canonical encoding.
287
+ function _isSequence(node) {
288
+ return !!node && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.SEQUENCE && !!node.children;
289
+ }
290
+ // Resolve the composite descriptor from a raw AlgorithmIdentifier node (the SPKI encapsulation path).
291
+ function _fromAlgId(algNode) {
292
+ if (!_isSequence(algNode) || algNode.children.length < 1) throw _err("kem/bad-algorithm", "the AlgorithmIdentifier is malformed");
293
+ var algOid;
294
+ try { algOid = asn1.read.oid(algNode.children[0]); }
295
+ catch (e) { throw _err("kem/bad-algorithm", "the AlgorithmIdentifier algorithm is not an OBJECT IDENTIFIER", e); }
296
+ return _descriptorForAlg(algOid, algNode.children.length > 1);
297
+ }
298
+
299
+ // ---- public verbs ------------------------------------------------------------
300
+
301
+ /**
302
+ * @primitive pki.kem.decapsulate
303
+ * @signature pki.kem.decapsulate(privateKey, ciphertext) -> Promise<Buffer>
304
+ * @since 0.6.6
305
+ * @status stable
306
+ * @spec draft-ietf-lamps-pq-composite-kem
307
+ * @defends harvest-now-decrypt-later (CWE-327)
308
+ * @related pki.kem.encapsulate
309
+ *
310
+ * Recover the 256-bit composite shared secret from a ciphertext. `privateKey` is the composite PKCS#8
311
+ * (a `OneAsymmetricKey` DER `Buffer` or `PRIVATE KEY` PEM whose `privateKeyAlgorithm` is a composite
312
+ * ML-KEM OID and whose `privateKey` octets are the raw `mlkemSeed || tradSK`, draft sec. 4.2);
313
+ * `ciphertext` is the composite ciphertext (`mlkemCT || tradCT`, sec. 4.3). The ML-KEM half and the
314
+ * traditional half are decapsulated independently and combined with SHA3-256, so the secret is
315
+ * recovered only when both components agree. A malformed key or ciphertext, an unsupported algorithm,
316
+ * or a component decapsulation failure throws a typed `KemError`.
317
+ *
318
+ * @example
319
+ * // requires: privatePkcs8Der -- the composite ML-KEM PKCS#8 private key (DER)
320
+ * // requires: ciphertext -- the composite KEM ciphertext received from the sender
321
+ * var secret = await pki.kem.decapsulate(privatePkcs8Der, ciphertext); // 32-byte Buffer
322
+ */
323
+ // async so any synchronous fault (an argument that is not a BufferSource, a malformed DER structure)
324
+ // leaves as a rejected promise rather than a synchronous throw, keeping the Promise-returning contract.
325
+ async function _decapsulate(privateKey, ciphertext) {
326
+ var pkcs8 = _toDer(privateKey, "PRIVATE KEY", "the composite ML-KEM private key");
327
+ var ct = guard.bytes.snapshotSource(ciphertext, KemError, "kem/bad-input", "the composite ML-KEM ciphertext");
328
+ var d, sk;
329
+ try {
330
+ try {
331
+ // Compose the shared strict OneAsymmetricKey parser so the version, the algorithm, and the optional
332
+ // [0]/[1] trailing fields are all validated once, rather than reading fixed positions and trusting
333
+ // the rest. The privateKey octets are the raw composite mlkemSeed || tradSK (draft sec. 4.2).
334
+ var info = schemaPkcs8.parse(pkcs8);
335
+ var alg = info.privateKeyAlgorithm;
336
+ d = _descriptorForAlg(alg.oid, alg.parameters !== null);
337
+ sk = Buffer.from(info.privateKey);
338
+ } catch (e) {
339
+ if (e instanceof KemError) throw e;
340
+ // the shared parser rejects a present `parameters` at the envelope; re-map it to the module's own
341
+ // parameters-absent verdict so both verbs report draft sec. 5.3 the same way.
342
+ if (e && e.code === "pkcs8/bad-algorithm-parameters") throw _err("kem/bad-algorithm", "a composite ML-KEM AlgorithmIdentifier MUST have absent parameters (draft sec. 5.3)", e);
343
+ throw _err("kem/bad-key", "the composite ML-KEM private key is not a well-formed OneAsymmetricKey", e);
344
+ }
345
+ try {
346
+ if (sk.length <= MLKEM_SEED) throw _err("kem/bad-key", "the composite private key is shorter than the ML-KEM seed");
347
+ // the composite ciphertext is a fixed length (the ML-KEM ciphertext plus the exact traditional
348
+ // component); reject any other length before building a key, so an attacker-controlled oversized
349
+ // traditional component cannot force an unbounded allocation (CWE-770).
350
+ if (ct.length !== d.mlkemCt + d.trad.ctLen) throw _err("kem/bad-ciphertext", "the composite ciphertext is not the expected " + (d.mlkemCt + d.trad.ctLen) + " bytes for " + d.name);
351
+ var seed = sk.subarray(0, MLKEM_SEED), tradSK = sk.subarray(MLKEM_SEED);
352
+ var mlkemCT = ct.subarray(0, d.mlkemCt), tradCT = ct.subarray(d.mlkemCt);
353
+ var r = await _settleOrWipe(_mlkemDecaps(d, seed, mlkemCT), _tradDecaps(d, tradSK, tradCT),
354
+ function (v) { return v; }, function (v) { return v.ss; });
355
+ return await _combine(d, r[0], r[1].ss, tradCT, r[1].tradPK);
356
+ } finally {
357
+ // the reconstructed private-key copy (ML-KEM seed || traditional private key) is wiped on every path,
358
+ // including the length-validation exits above.
359
+ guard.secret.zeroize(sk, KemError, "kem/bad-input", "the composite private key material");
360
+ }
361
+ } finally {
362
+ // the snapshotted PKCS#8 copy still holds the private-key octets; wipe it on every path (draft sec. 3.5).
363
+ guard.secret.zeroize(pkcs8, KemError, "kem/bad-input", "the composite private key DER");
364
+ }
365
+ }
366
+
367
+ /**
368
+ * @primitive pki.kem.encapsulate
369
+ * @signature pki.kem.encapsulate(publicKey) -> Promise<{ sharedSecret: Buffer, ciphertext: Buffer }>
370
+ * @since 0.6.6
371
+ * @status stable
372
+ * @spec draft-ietf-lamps-pq-composite-kem
373
+ * @defends harvest-now-decrypt-later (CWE-327)
374
+ * @related pki.kem.decapsulate
375
+ *
376
+ * Establish a 256-bit shared secret for a recipient's composite ML-KEM public key. `publicKey` is the
377
+ * composite `SubjectPublicKeyInfo` (a DER `Buffer` or `PUBLIC KEY` PEM whose algorithm is a composite
378
+ * ML-KEM OID and whose subjectPublicKey octets are the raw `mlkemEK || tradPK`, draft sec. 4.1).
379
+ * Returns the `sharedSecret` (a 32-byte `Buffer`) and the `ciphertext` (`mlkemCT || tradCT`) to send to
380
+ * the recipient, who recovers the same secret with `pki.kem.decapsulate`. The ML-KEM and traditional
381
+ * component encapsulations run independently and are mixed through SHA3-256. A malformed or unsupported
382
+ * public key throws a typed `KemError`.
383
+ *
384
+ * @example
385
+ * // requires: recipientSpkiDer -- the recipient's composite ML-KEM SubjectPublicKeyInfo (DER)
386
+ * var out = await pki.kem.encapsulate(recipientSpkiDer);
387
+ * out.sharedSecret.length === 32; // send out.ciphertext to the recipient
388
+ */
389
+ async function _encapsulate(publicKey) {
390
+ var spki = _toDer(publicKey, "PUBLIC KEY", "the composite ML-KEM public key");
391
+ var d, ek;
392
+ try {
393
+ var node = asn1.decode(spki);
394
+ if (!_isSequence(node) || node.children.length !== 2) throw _err("kem/bad-key", "the public key is not a well-formed SubjectPublicKeyInfo");
395
+ d = _fromAlgId(node.children[0]);
396
+ var bs = asn1.read.bitString(node.children[1]);
397
+ if (bs.unusedBits !== 0) throw _err("kem/bad-key", "the composite subjectPublicKey has unused bits");
398
+ ek = Buffer.from(bs.bytes);
399
+ } catch (e) { throw e instanceof KemError ? e : _err("kem/bad-key", "the composite ML-KEM public key is not valid DER", e); }
400
+ if (ek.length <= d.mlkemEk) throw _err("kem/bad-key", "the composite public key is shorter than the ML-KEM component");
401
+ var mlkemEK = ek.subarray(0, d.mlkemEk), tradPK = ek.subarray(d.mlkemEk);
402
+ var r = await _settleOrWipe(_mlkemEncaps(d, mlkemEK), _tradEncaps(d, tradPK),
403
+ function (v) { return v.ss; }, function (v) { return v.ss; });
404
+ var ss = await _combine(d, r[0].ss, r[1].ss, r[1].ct, tradPK);
405
+ return { sharedSecret: ss, ciphertext: Buffer.concat([r[0].ct, r[1].ct]) };
406
+ }
407
+
408
+ // Only the two verbs are public: this module IS the pki.kem namespace, so the internal registry stays
409
+ // unexported to keep it off the operator surface and immutable from outside.
410
+ module.exports = {
411
+ encapsulate: _encapsulate,
412
+ decapsulate: _decapsulate,
413
+ };
package/lib/est.js CHANGED
@@ -1100,9 +1100,13 @@ function _drive(method, url, body, headers, opts, transport, budgets) {
1100
1100
  // restores its identity rather than permanently losing the SNI at the first cross-origin boundary.
1101
1101
  function _tlsFor(u) {
1102
1102
  var t = budgets.tls;
1103
- if (t && u.origin !== initialOrigin && (t.cert != null || t.key != null || t.servername != null)) {
1103
+ if (t && u.origin !== initialOrigin) {
1104
+ // Every cross-origin hop overrides the client identity with an explicit null, unconditionally: an
1105
+ // omitted field falls back to the transport's own configured defaults (http-transport resolves
1106
+ // reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert), so the override must fire even when the
1107
+ // request-level tls carries no credential, or a transport default would still reach the new origin.
1104
1108
  t = Object.assign({}, t);
1105
- delete t.cert; delete t.key; delete t.servername;
1109
+ t.cert = null; t.key = null; t.servername = null;
1106
1110
  }
1107
1111
  return t;
1108
1112
  }
@@ -342,6 +342,9 @@ var AcmeError = defineClass("AcmeError", { withCause: true });
342
342
  var TrustError = defineClass("TrustError", { withCause: true });
343
343
  var ShbsError = defineClass("ShbsError", { withCause: true });
344
344
  var HpkeError = defineClass("HpkeError", { withCause: true });
345
+ // KemError -- a malformed composite ML-KEM key or ciphertext, or an unsupported
346
+ // composite algorithm (draft-ietf-lamps-pq-composite-kem). Raised by pki.kem.
347
+ var KemError = defineClass("KemError", { withCause: true });
345
348
  // SigstoreError -- a Sigstore bundle (npm --provenance artifact) that is not a
346
349
  // well-formed / verifiable keyless signature: a malformed or oversize JSON
347
350
  // bundle, an unknown media type or content arm, a DSSE envelope missing a
@@ -406,6 +409,7 @@ module.exports = {
406
409
  C509Error: C509Error,
407
410
  ShbsError: ShbsError,
408
411
  HpkeError: HpkeError,
412
+ KemError: KemError,
409
413
  SigstoreError: SigstoreError,
410
414
  InspectError: InspectError,
411
415
  WebauthnError: WebauthnError,
@@ -351,7 +351,11 @@ function httpsTransport(defaults) {
351
351
  // SNI (and RFC 6125 identity) uses a hostname; node forbids a servername that is an IP
352
352
  // literal, so it is omitted for an IP host (node then matches the IP against the cert's
353
353
  // IP SANs). A caller connecting by hostname gets SNI + name verification by default.
354
- var sni = reqTls.servername || tlsDefaults.servername || host;
354
+ // The per-request servername falls back to the factory default only when it is ABSENT
355
+ // (as cert/key do); an explicit value, INCLUDING null, overrides the default, so a caller
356
+ // that sets servername to null on a cross-origin hop suppresses a configured default SNI
357
+ // rather than falsy-coalescing back to it.
358
+ var sni = (reqTls.servername !== undefined ? reqTls.servername : tlsDefaults.servername) || host;
355
359
  if (sni && !nodeNet.isIP(sni)) options.servername = sni;
356
360
  // Build node's `ca`: the explicit anchors (DER wrapped as PEM) and, when useSystemStore is opted
357
361
  // in, the OS system store + bundled roots. Node's `ca` REPLACES the default set, so both sources
package/lib/oid.js CHANGED
@@ -394,7 +394,16 @@ var FAMILIES = {
394
394
  "id-MLDSA65-ECDSA-brainpoolP256r1-SHA512": 47, "id-MLDSA65-Ed25519-SHA512": 48,
395
395
  "id-MLDSA87-ECDSA-P384-SHA512": 49, "id-MLDSA87-ECDSA-brainpoolP384r1-SHA512": 50,
396
396
  "id-MLDSA87-Ed448-SHAKE256": 51, "id-MLDSA87-RSA3072-PSS-SHA512": 52,
397
- "id-MLDSA87-RSA4096-PSS-SHA512": 53, "id-MLDSA87-ECDSA-P521-SHA512": 54 } },
397
+ "id-MLDSA87-RSA4096-PSS-SHA512": 53, "id-MLDSA87-ECDSA-P521-SHA512": 54,
398
+ // Composite ML-KEM key-establishment algorithms (draft-ietf-lamps-pq-composite-kem
399
+ // sec. 6): a PQ ML-KEM paired with a traditional RSA-OAEP / ECDH / X25519 / X448 so the
400
+ // established secret stays safe if EITHER primitive is later broken.
401
+ "id-MLKEM768-RSA2048-SHA3-256": 55, "id-MLKEM768-RSA3072-SHA3-256": 56,
402
+ "id-MLKEM768-RSA4096-SHA3-256": 57, "id-MLKEM768-X25519-SHA3-256": 58,
403
+ "id-MLKEM768-ECDH-P256-SHA3-256": 59, "id-MLKEM768-ECDH-P384-SHA3-256": 60,
404
+ "id-MLKEM768-ECDH-brainpoolP256r1-SHA3-256": 61, "id-MLKEM1024-RSA3072-SHA3-256": 62,
405
+ "id-MLKEM1024-ECDH-P384-SHA3-256": 63, "id-MLKEM1024-ECDH-brainpoolP384r1-SHA3-256": 64,
406
+ "id-MLKEM1024-X448-SHA3-256": 65, "id-MLKEM1024-ECDH-P521-SHA3-256": 66 } },
398
407
 
399
408
  // RSA-KEM key-transport algorithm (RFC 9690, obsoletes RFC 5990) on the ISO
400
409
  // 18033-2 arc -- the kem OID an RSA KEMRecipientInfo carries (distinct from the
@@ -433,6 +442,9 @@ var FAMILIES = {
433
442
  // SECG named curves.
434
443
  secg: { base: [1, 3, 132, 0], of: { secp384r1: 34, secp521r1: 35 } },
435
444
 
445
+ // Brainpool named curves (RFC 5639).
446
+ brainpool: { base: [1, 3, 36, 3, 3, 2, 8, 1, 1], of: { brainpoolP256r1: 7, brainpoolP384r1: 11 } },
447
+
436
448
  // Edwards / Montgomery curves (RFC 8410).
437
449
  edwards: { base: [1, 3, 101], of: { X25519: 110, X448: 111, Ed25519: 112, Ed448: 113 } },
438
450
 
@@ -833,6 +845,14 @@ var _PARAMS_ABSENT = new Set();
833
845
  "id-MLDSA87-ECDSA-P384-SHA512", "id-MLDSA87-ECDSA-brainpoolP384r1-SHA512",
834
846
  "id-MLDSA87-Ed448-SHAKE256", "id-MLDSA87-RSA3072-PSS-SHA512",
835
847
  "id-MLDSA87-RSA4096-PSS-SHA512", "id-MLDSA87-ECDSA-P521-SHA512",
848
+ // Composite ML-KEM (draft-ietf-lamps-pq-composite-kem sec. 5.3: the parameters
849
+ // field MUST be absent for every composite KEM AlgorithmIdentifier).
850
+ "id-MLKEM768-RSA2048-SHA3-256", "id-MLKEM768-RSA3072-SHA3-256",
851
+ "id-MLKEM768-RSA4096-SHA3-256", "id-MLKEM768-X25519-SHA3-256",
852
+ "id-MLKEM768-ECDH-P256-SHA3-256", "id-MLKEM768-ECDH-P384-SHA3-256",
853
+ "id-MLKEM768-ECDH-brainpoolP256r1-SHA3-256", "id-MLKEM1024-RSA3072-SHA3-256",
854
+ "id-MLKEM1024-ECDH-P384-SHA3-256", "id-MLKEM1024-ECDH-brainpoolP384r1-SHA3-256",
855
+ "id-MLKEM1024-X448-SHA3-256", "id-MLKEM1024-ECDH-P521-SHA3-256",
836
856
  ].forEach(function (nm) {
837
857
  var d = byName(nm);
838
858
  // A seed-list typo must fail at module load -- admitting undefined would
@@ -851,10 +871,12 @@ var _PARAMS_ABSENT = new Set();
851
871
  * @related pki.oid.name, pki.oid.byName
852
872
  *
853
873
  * True when an AlgorithmIdentifier bearing this OID MUST encode its `parameters`
854
- * field as ABSENT (not an explicit NULL, not any bytes): the FIPS 204 ML-DSA and
855
- * FIPS 205 SLH-DSA signature families and the RFC 8410 Edwards / Montgomery
856
- * curves. The shared AlgorithmIdentifier decoder consults this and fails closed
857
- * on a present-parameters violation, so every format inherits the rule.
874
+ * field as ABSENT (not an explicit NULL, not any bytes). The post-quantum ML-DSA,
875
+ * SLH-DSA, and ML-KEM families, the composite ML-DSA and composite ML-KEM
876
+ * algorithms, HSS/LMS and XMSS, the HKDF key-derivation identifiers, and the
877
+ * RFC 8410 Edwards / Montgomery curves all carry this MUST. The shared
878
+ * AlgorithmIdentifier decoder consults this and fails closed on a
879
+ * present-parameters violation, so every format inherits the rule.
858
880
  *
859
881
  * @example
860
882
  * pki.oid.paramsMustBeAbsent(pki.oid.byName("id-slh-dsa-sha2-128s")); // -> true
package/lib/scep.js CHANGED
@@ -52,6 +52,7 @@ var schemaCrl = require("./schema-crl");
52
52
  var pkix = require("./schema-pkix");
53
53
  var guard = require("./guard-all");
54
54
  var C = require("./constants");
55
+ var httpTransport = require("./http-transport"); // the injectable https transport for the client verbs (sec. 4)
55
56
  var nodeCrypto = require("node:crypto");
56
57
 
57
58
  function _err(code, msg, cause) { return new ScepError(code, msg, cause); }
@@ -553,9 +554,511 @@ async function parse(bytes, opts) {
553
554
  }
554
555
  var _PARSE_KEYS = { recipientKey: 1, expectedSenderNonce: 1, signerCert: 1 };
555
556
 
557
+ // ---- HTTP client (RFC 8894 sec. 4) -------------------------------------------
558
+ //
559
+ // The client verbs compose the shipped pkiMessage codec (build / parse) over an injectable transport
560
+ // (pki.transport.https by default; tests inject a fake). SCEP's message security is the CMS layer, not
561
+ // the transport (RFC 8894 sec. 2.8), so https is not forced at the URL level; a caller with a plain-HTTP
562
+ // CA injects its own transport. This release ships the mandatory-to-implement set (GetCACaps, GetCACert,
563
+ // PKCSReq via HTTP POST, AES-128-CBC + SHA-256, sec. 2.9) plus RenewalReq. Deferred, each re-opening
564
+ // under its named condition: GetNextCACert (the rollover-certificate authentication model, sec. 4.7);
565
+ // auto-poll / GetCert / GetCRL (their CertPoll / IssuerAndSerialNumber messageData builders are not in
566
+ // this codec); GET-carried PKIOperation (POST is mandatory, sec. 4.3); and HTTP auth (a CA behind an
567
+ // authenticating proxy).
568
+ var DEFAULT_TIMEOUT = C.TIME.seconds(30);
569
+ var MAX_TIMEOUT = C.TIME.seconds(600);
570
+
571
+ // The SCEP media types (RFC 8894 sec. 4.2 / 4.6). A 200 response MUST carry the operation's media type;
572
+ // a proxy error page or a wrong type is refused (fail-closed).
573
+ var CT_CA_CERT = "application/x-x509-ca-cert"; // GetCACert, a single DER certificate
574
+ var CT_CA_RA_CERT = "application/x-x509-ca-ra-cert"; // GetCACert, a certs-only CMS chain
575
+ var CT_PKI_MESSAGE = "application/x-pki-message"; // PKIOperation, a pkiMessage
576
+ var CT_PLAIN = "text/plain"; // GetCACaps, a newline-separated capability list
577
+
578
+ function _isWs(c) { return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d || c === 0x0b || c === 0x0c; }
579
+ function _foldLower(s) { var o = "", i, n = s.length; for (i = 0; i < n; i++) { var c = s.charCodeAt(i); o += String.fromCharCode(c >= 65 && c <= 90 ? c + 32 : c); } return o; }
580
+ function _foldUpper(s) { var o = "", i, n = s.length; for (i = 0; i < n; i++) { var c = s.charCodeAt(i); o += String.fromCharCode(c >= 97 && c <= 122 ? c - 32 : c); } return o; }
581
+ function _trim(s) { var a = 0, e = s.length; while (a < e && _isWs(s.charCodeAt(a))) a += 1; while (e > a && _isWs(s.charCodeAt(e - 1))) e -= 1; return s.slice(a, e); }
582
+
583
+ // A case-insensitive single-header read. A duplicated header is refused: an injected transport promises
584
+ // only { status, headers, body }, and a smuggled duplicate must never be silently coalesced.
585
+ function _header(headers, name) {
586
+ if (!headers || typeof headers !== "object") return null;
587
+ var found = null, seen = 0, k;
588
+ for (k in headers) {
589
+ if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
590
+ if (_foldLower(String(k)) === name) { found = headers[k]; seen += 1; }
591
+ }
592
+ if (seen > 1) throw _err("scep/bad-response", "the response carried more than one " + name + " header");
593
+ return found == null ? null : String(found);
594
+ }
595
+ // The bare media type of a Content-Type (strip parameters, lower-case), by character scan (no regex).
596
+ function _mediaType(ct) {
597
+ if (ct == null) return "";
598
+ var s = String(ct), semi = s.indexOf(";");
599
+ return _foldLower(_trim(semi === -1 ? s : s.slice(0, semi)));
600
+ }
601
+ function _bodyBuf(body) {
602
+ if (Buffer.isBuffer(body)) return body;
603
+ if (body == null) return Buffer.alloc(0);
604
+ if (typeof body === "string") return Buffer.from(body, "utf8");
605
+ // A BufferSource (Uint8Array, ArrayBuffer, DataView) from an injected transport: normalize its raw bytes
606
+ // rather than String()-coercing it, which would turn a Uint8Array into its "48,1,..." toString.
607
+ return guard.bytes.snapshotSource(body, ScepError, "scep/bad-response", "the response body");
608
+ }
609
+ // The byte length of a transport response body, read without a full copy so the size cap is enforced before
610
+ // the body is materialized (cap-before-copy): a string is measured as UTF-8, a Buffer or other BufferSource
611
+ // through the byte guard, which reads the length via a captured intrinsic rather than a shadowable accessor.
612
+ function _responseLen(body) {
613
+ if (body == null) return 0;
614
+ if (typeof body === "string") return Buffer.byteLength(body, "utf8");
615
+ if (Buffer.isBuffer(body)) return guard.bytes.lengthOf(body);
616
+ return guard.bytes.lengthOf(guard.bytes.view(body, ScepError, "scep/bad-response", "the response body"));
617
+ }
618
+ // Map opts.tls to the transport request.tls shape. rejectUnauthorized is NOT set -- the transport forces
619
+ // it on for an https connection.
620
+ function _tlsForRequest(opts) {
621
+ var t = opts.tls || {};
622
+ return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
623
+ }
624
+ // Resolve a redirect target: refuse an https->http downgrade (a stripping MITM) and a cross-origin
625
+ // redirect on a non-idempotent method without opt-in; an unparseable Location is scep/bad-url.
626
+ function _redirectTarget(current, location, method, allowCrossOrigin) {
627
+ if (location == null || _trim(String(location)) === "") throw _err("scep/bad-response", "a redirect response carried no Location header");
628
+ var resolved;
629
+ try { resolved = new URL(String(location), current.href); }
630
+ catch (e) { throw _err("scep/bad-url", "a redirect Location did not parse: " + location, e); }
631
+ // SCEP is an HTTP operation (RFC 8894 sec. 4): a redirect target on any other scheme (ftp:, file:, ...)
632
+ // is refused before it reaches the transport, so a CA cannot steer an injected transport off HTTP.
633
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw _err("scep/bad-url", "a redirect to a non-HTTP scheme is refused: " + resolved.protocol);
634
+ if (current.protocol === "https:" && resolved.protocol !== "https:") throw _err("scep/insecure-redirect", "a redirect from https to " + resolved.protocol + " is refused");
635
+ var safe = method === "GET" || method === "HEAD";
636
+ if (resolved.origin !== current.origin && !safe && !allowCrossOrigin) throw _err("scep/cross-origin-redirect", "a cross-origin redirect on a " + method + " needs opts.allowCrossOriginRedirect");
637
+ return resolved;
638
+ }
639
+ // Build a SCEP operation URL: the caller's base URL with ?operation=<op> appended (RFC 8894 sec. 4.1;
640
+ // SCEPPATH is whatever the base carries, the CA typically ignores it).
641
+ function _scepUrl(baseUrl, operation) {
642
+ var url;
643
+ try { url = new URL(String(baseUrl)); }
644
+ catch (e) { throw _err("scep/bad-url", "the SCEP base URL did not parse: " + String(baseUrl), e); }
645
+ // SCEP is an HTTP operation (RFC 8894 sec. 4): any other scheme (ftp:, file:, ...) is refused before the
646
+ // URL reaches the transport, so an injected transport cannot be steered off HTTP.
647
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw _err("scep/bad-url", "the SCEP base URL must be http or https, got " + url.protocol);
648
+ if (url.hash) throw _err("scep/bad-url", "the SCEP base URL must not carry a fragment");
649
+ url.searchParams.set("operation", operation);
650
+ return url;
651
+ }
652
+ // The transport request / redirect loop. Returns the terminal transport response ({ status, headers,
653
+ // body }). Digest / Basic auth is not answered (deferred); a 401 is returned to the verb, which throws.
654
+ function _drive(method, url, body, headers, opts, transport, budgets) {
655
+ var redirects = 0, curMethod = method, curBody = body, initialOrigin = url.origin;
656
+ // Snapshot the caller's TLS fields and redirect policy once, at the start, before the first transport call:
657
+ // the redirect loop reruns per hop after each transport promise settles, so a hop must use the identity and
658
+ // policy the initial hop did even if opts (or opts.tls) is replaced while a request is in flight.
659
+ var tlsBase = _tlsForRequest(opts);
660
+ var allowCrossOrigin = !!opts.allowCrossOriginRedirect;
661
+ // The mTLS client identity (cert / key / pinned SNI) is sent ONLY to the origin the caller intended; a
662
+ // cross-origin redirect gets a copy with those stripped, so a CA that redirects to another origin cannot
663
+ // make the client authenticate to it with the caller's client credential. Derived fresh each hop, so a
664
+ // redirect back to the original origin restores it.
665
+ function _hopTls(current) {
666
+ var t = tlsBase;
667
+ if (current.origin !== initialOrigin) {
668
+ // Every cross-origin hop overrides the client identity with an explicit null, unconditionally: an
669
+ // omitted field falls back to the transport's own configured defaults (http-transport resolves
670
+ // reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert), so the override must fire even when the
671
+ // request-level tls carries no credential, or a transport default would still reach the new origin.
672
+ t = Object.assign({}, t);
673
+ t.cert = null; t.key = null; t.servername = null;
674
+ }
675
+ return t;
676
+ }
677
+ function step(current) {
678
+ var req = { method: curMethod, url: current.href, headers: headers, tls: _hopTls(current), timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes };
679
+ if (curBody != null) req.body = curBody;
680
+ return transport(req).then(function (res) {
681
+ res = res || {};
682
+ var blen = _responseLen(res.body);
683
+ if (blen > budgets.maxResponseBytes) throw _err("scep/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap");
684
+ var status = res.status;
685
+ if (status >= 300 && status < 400) {
686
+ if (redirects >= budgets.maxRedirects) throw _err("scep/too-many-redirects", "the SCEP request exceeded the " + budgets.maxRedirects + "-redirect cap");
687
+ redirects += 1;
688
+ var target = _redirectTarget(current, _header(res.headers, "location"), curMethod, allowCrossOrigin);
689
+ if (status === 303 && curMethod !== "GET" && curMethod !== "HEAD") { curMethod = "GET"; curBody = null; }
690
+ return step(target);
691
+ }
692
+ return res;
693
+ });
694
+ }
695
+ return step(url);
696
+ }
697
+ function _client(method, url, body, headers, opts) {
698
+ var transport = opts.transport || httpTransport.https({ E: _err, errPrefix: "scep" });
699
+ var budgets = {
700
+ timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: _err, code: "scep/bad-input", min: 1, max: MAX_TIMEOUT }),
701
+ maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: _err, code: "scep/bad-input", min: 1, max: C.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
702
+ maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: _err, code: "scep/bad-input", min: 0, max: 32 }),
703
+ };
704
+ return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
705
+ }
706
+ // Require a 200 with one of the expected media types; otherwise a typed error. Returns the body Buffer.
707
+ function _requireOk(res, expected, what) {
708
+ var status = res.status;
709
+ if (status !== 200) {
710
+ var text = Buffer.isBuffer(res.body) ? res.body.toString("utf8", 0, 256) : String(res.body == null ? "" : res.body).slice(0, 256);
711
+ throw _err("scep/http-error", "the SCEP " + what + " returned HTTP " + status + (text ? ": " + text : ""));
712
+ }
713
+ if (expected) {
714
+ var ct = _mediaType(_header(res.headers, "content-type"));
715
+ if (expected.indexOf(ct) === -1) throw _err("scep/bad-content-type", "a 200 " + what + " response must carry content-type " + expected.join(" or ") + ", got " + JSON.stringify(ct));
716
+ }
717
+ return _bodyBuf(res.body);
718
+ }
719
+
720
+ // The GetCACaps keyword set (RFC 8894 sec. 3.5.2, Table 7). Comparison is case-folded; the canonical
721
+ // spelling is stored. An unknown keyword is ignored (sec. 3.5.2). SCEPStandard implies the sec. 2.9 set.
722
+ var CAP_CANON = { AES: "AES", DES3: "DES3", GETNEXTCACERT: "GetNextCACert", POSTPKIOPERATION: "POSTPKIOperation",
723
+ RENEWAL: "Renewal", "SHA-1": "SHA-1", "SHA-256": "SHA-256", "SHA-512": "SHA-512", SCEPSTANDARD: "SCEPStandard" };
724
+ function _splitLines(s) {
725
+ var out = [], start = 0, i, n = s.length;
726
+ for (i = 0; i < n; i++) if (s.charCodeAt(i) === 0x0a) { out.push(s.slice(start, i)); start = i + 1; }
727
+ out.push(s.slice(start));
728
+ return out;
729
+ }
730
+
731
+ /**
732
+ * @primitive pki.scep.parseCapabilities
733
+ * @signature pki.scep.parseCapabilities(text) -> { AES?, "SHA-256"?, POSTPKIOperation?, ... }
734
+ * @since 0.6.7
735
+ * @status stable
736
+ * @spec RFC 8894
737
+ * @related pki.scep.getCACaps
738
+ *
739
+ * Parse a GetCACaps response body (RFC 8894 sec. 3.5.2): one capability keyword per line, CR/LF or LF
740
+ * delimited. The returned object has a `true` property for each recognized keyword (`AES`, `DES3`,
741
+ * `GetNextCACert`, `POSTPKIOperation`, `Renewal`, `SHA-1`, `SHA-256`, `SHA-512`, `SCEPStandard`);
742
+ * comparison is case-insensitive and an unknown keyword is ignored (sec. 3.5.2). `SCEPStandard` implies
743
+ * `AES`, `POSTPKIOperation`, and `SHA-256` (the sec. 2.9 mandatory profile), which are set on the result.
744
+ * An empty body yields an empty set ("none supported", sec. 3.5.1).
745
+ *
746
+ * @example
747
+ * var caps = pki.scep.parseCapabilities("AES\r\nSHA-256\r\nPOSTPKIOperation\r\n");
748
+ * caps.POSTPKIOperation === true;
749
+ */
750
+ function parseCapabilities(text) {
751
+ var raw = Buffer.isBuffer(text) ? text.toString("latin1") : String(text == null ? "" : text);
752
+ var set = {}, lines = _splitLines(raw), i;
753
+ for (i = 0; i < lines.length; i++) {
754
+ var kw = _foldUpper(_trim(lines[i]));
755
+ if (kw !== "" && CAP_CANON[kw]) set[CAP_CANON[kw]] = true;
756
+ }
757
+ if (set.SCEPStandard) { set.AES = true; set.POSTPKIOperation = true; set["SHA-256"] = true; }
758
+ return set;
759
+ }
760
+ function _assertStrongProfile(caps, profile) {
761
+ if (profile.expectSCEPStandard && !caps.SCEPStandard) throw _err("scep/weak-profile", "the CA did not advertise SCEPStandard (RFC 8894 sec. 3.5.2); the GetCACaps response is unauthenticated, so its absence is a downgrade signal (sec. 7.5)");
762
+ if (profile.requireStrongProfile && (!caps.AES || !caps["SHA-256"] || !caps.POSTPKIOperation)) throw _err("scep/weak-profile", "the CA did not advertise the strong profile (AES + SHA-256 + POSTPKIOperation)");
763
+ }
764
+
765
+ /**
766
+ * @primitive pki.scep.getCACaps
767
+ * @signature pki.scep.getCACaps(baseUrl, opts?) -> Promise<capabilities>
768
+ * @since 0.6.7
769
+ * @status stable
770
+ * @spec RFC 8894
771
+ * @related pki.scep.parseCapabilities, pki.scep.enroll
772
+ *
773
+ * Query a SCEP CA's advertised capabilities (RFC 8894 sec. 3.5.1): a GET `?operation=GetCACaps` whose
774
+ * `text/plain` body is parsed by `parseCapabilities`. The GetCACaps response is UNAUTHENTICATED (sec.
775
+ * 7.5), so a client MUST NOT downgrade its algorithm choice based on it; this verb only reports what the
776
+ * CA claims. Pass `expectSCEPStandard: true` (or `requireStrongProfile: true`) to fail closed when the
777
+ * strong profile is absent, treating the absence as the downgrade signal it is.
778
+ *
779
+ * @opts
780
+ * - `transport` -- an injected transport (defaults to `pki.transport.https`); the request is a GET with no body.
781
+ * - `expectSCEPStandard` -- refuse (`scep/weak-profile`) if the response does not advertise `SCEPStandard`.
782
+ * - `requireStrongProfile` -- refuse unless AES + SHA-256 + POSTPKIOperation are all advertised.
783
+ * - `timeout` / `maxResponseBytes` / `maxRedirects` / `tls` / `allowCrossOriginRedirect` -- transport bounds.
784
+ * @example
785
+ * // requires: scepTransport -- an injected transport (omit to use pki.transport.https)
786
+ * var caps = await pki.scep.getCACaps("http://ca.example/scep", { transport: scepTransport });
787
+ * caps.AES === true;
788
+ */
789
+ async function getCACaps(baseUrl, opts) {
790
+ opts = guard.identifier.optionsObject(opts, _err, "scep/bad-input", "pki.scep.getCACaps options");
791
+ guard.identifier.assertKnownKeys(opts, _CACAPS_KEYS, _err, "scep/bad-input", "pki.scep.getCACaps has an unknown option: ");
792
+ // Capture the strong-profile policy at the door: the downgrade check runs only after the GET await, so a
793
+ // caller must not be able to relax its own guard by mutating opts while the request is in flight.
794
+ var profile = { expectSCEPStandard: !!opts.expectSCEPStandard, requireStrongProfile: !!opts.requireStrongProfile };
795
+ var res = await _client("GET", _scepUrl(baseUrl, "GetCACaps"), null, {}, opts);
796
+ // An HTTP error or empty body means "none supported" (RFC 8894 sec. 3.5.1); a non-200 that is not a
797
+ // clean error is still surfaced so a caller is not silently told "no capabilities" on a broken CA.
798
+ if (res.status === 200) {
799
+ // A conforming GetCACaps response carries content-type text/plain (RFC 8894 sec. 3.5.1). Enforcing it
800
+ // stops a proxy's 200 HTML error page from being read as an empty (no capabilities) capability set.
801
+ var caps = parseCapabilities(_requireOk(res, [CT_PLAIN], "GetCACaps"));
802
+ _assertStrongProfile(caps, profile);
803
+ return caps;
804
+ }
805
+ // A CA that does not implement GetCACaps signals it with an HTTP error, which RFC 8894 sec. 3.5.1 reads
806
+ // as "no capabilities advertised": the codes that mean the operation is unsupported or unrecognized are
807
+ // 400 (an old CA that does not parse the query), 404 (no such operation), 405 (GET not allowed on it), and
808
+ // 501 Not Implemented. A transient server error (500, 502, 503) is surfaced instead of masked.
809
+ if (res.status === 204 || res.status === 400 || res.status === 404 || res.status === 405 || res.status === 501) { _assertStrongProfile({}, profile); return {}; }
810
+ throw _err("scep/http-error", "the SCEP GetCACaps returned HTTP " + res.status);
811
+ }
812
+
813
+ // The exact SPKI byte-match that identifies an issued certificate (RFC 5272 sec. 4.1); positional
814
+ // guessing is forbidden. `certs` is an array of raw cert Buffers, `wantSpki` the SPKI Buffer to match.
815
+ function findIssuedCert(certs, wantSpki) {
816
+ if (!Buffer.isBuffer(wantSpki)) return null;
817
+ for (var i = 0; i < certs.length; i++) {
818
+ var spki;
819
+ try { spki = schemaX509.parse(certs[i]).subjectPublicKeyInfo; }
820
+ catch (_e) { continue; }
821
+ if (spki && Buffer.isBuffer(spki.bytes) && spki.bytes.equals(wantSpki)) return certs[i];
822
+ }
823
+ return null;
824
+ }
825
+ // Normalize an expected fingerprint (hex string, optionally colon/space separated, or a Buffer) to
826
+ // { algorithm, value }. Character-scan the hex (Hard rule #11).
827
+ function _normalizeFingerprint(fp, algName) {
828
+ var algorithm = _foldLower(String(algName == null ? "sha256" : algName));
829
+ // Validate the digest name at the entry point so a misspelled or build-unavailable fingerprintAlgorithm
830
+ // is the typed scep/bad-input the client API documents, not a native createHash exception downstream.
831
+ try { nodeCrypto.createHash(algorithm); }
832
+ catch (_e) { throw _err("scep/bad-input", "fingerprintAlgorithm " + JSON.stringify(algorithm) + " is not an available hash"); }
833
+ var value;
834
+ if (Buffer.isBuffer(fp)) value = Buffer.from(fp);
835
+ else if (typeof fp === "string") {
836
+ var hex = "", i, n = fp.length;
837
+ for (i = 0; i < n; i++) {
838
+ var c = fp.charCodeAt(i);
839
+ if ((c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70)) hex += fp.charAt(i);
840
+ else if (c === 0x3a || c === 0x20) continue;
841
+ else throw _err("scep/bad-input", "expectedFingerprint must be a hex string or a Buffer");
842
+ }
843
+ if (hex.length === 0 || hex.length % 2 !== 0) throw _err("scep/bad-input", "expectedFingerprint hex has an odd or zero length");
844
+ value = Buffer.from(hex, "hex");
845
+ } else throw _err("scep/bad-input", "expectedFingerprint must be a hex string or a Buffer");
846
+ return { algorithm: algorithm, value: value };
847
+ }
848
+
849
+ /**
850
+ * @primitive pki.scep.getCACert
851
+ * @signature pki.scep.getCACert(baseUrl, opts?) -> Promise<{ caCertificate, certificates }>
852
+ * @since 0.6.7
853
+ * @status stable
854
+ * @spec RFC 8894
855
+ * @related pki.scep.enroll, pki.path.validate
856
+ *
857
+ * Retrieve a SCEP CA's certificate (RFC 8894 sec. 4.2): a GET `?operation=GetCACert` returning either a
858
+ * single DER certificate (`application/x-x509-ca-cert`) or a certs-only CMS chain
859
+ * (`application/x-x509-ca-ra-cert`). GetCACert is served in the clear; the ONLY authentication is an
860
+ * out-of-band fingerprint (RFC 8894 sec. 2.2). Pass `expectedFingerprint` (a hex string or Buffer, with
861
+ * `fingerprintAlgorithm` defaulting to SHA-256) and this refuses (`scep/fingerprint-mismatch`) unless a
862
+ * returned certificate matches it, returning that certificate as `caCertificate`. A single-certificate
863
+ * response is unambiguously the CA, so `caCertificate` holds it. A ca-ra chain lists the CA, any
864
+ * intermediates, and RA certificate(s) in a CMS order that does not identify the CA role, so without a
865
+ * fingerprint `caCertificate` is null and the caller reads `certificates` to identify and authenticate
866
+ * the CA itself.
867
+ *
868
+ * @opts
869
+ * - `expectedFingerprint` -- a hex string or Buffer; a returned certificate must hash to it, or the response is refused.
870
+ * - `fingerprintAlgorithm` -- the hash for the fingerprint (default "sha256").
871
+ * - `transport` / `timeout` / `maxResponseBytes` / `maxRedirects` / `tls` / `allowCrossOriginRedirect` -- transport bounds.
872
+ * @example
873
+ * // requires: scepCaCertTransport -- an injected transport returning a single CA certificate
874
+ * var ca = await pki.scep.getCACert("http://ca.example/scep", { transport: scepCaCertTransport });
875
+ * ca.caCertificate; // the CA certificate DER (a single-certificate response identifies it)
876
+ */
877
+ async function getCACert(baseUrl, opts) {
878
+ opts = guard.identifier.optionsObject(opts, _err, "scep/bad-input", "pki.scep.getCACert options");
879
+ guard.identifier.assertKnownKeys(opts, _CACERT_KEYS, _err, "scep/bad-input", "pki.scep.getCACert has an unknown option: ");
880
+ // Normalize the expected fingerprint at the synchronous entry point: fail fast on a bad hash before the
881
+ // network call, and pin against the value the caller supplied at the call rather than one mutated while
882
+ // the GET is in flight.
883
+ var fp = opts.expectedFingerprint != null ? _normalizeFingerprint(opts.expectedFingerprint, opts.fingerprintAlgorithm) : null;
884
+ var res = await _client("GET", _scepUrl(baseUrl, "GetCACert"), null, {}, opts);
885
+ var body = _requireOk(res, [CT_CA_CERT, CT_CA_RA_CERT], "GetCACert");
886
+ var ct = _mediaType(_header(res.headers, "content-type")), certs, single = false;
887
+ if (ct === CT_CA_CERT) {
888
+ try { schemaX509.parse(body); } catch (e) { throw _err("scep/bad-response", "the GetCACert response is not a valid X.509 certificate", e); }
889
+ certs = [Buffer.from(body)];
890
+ single = true;
891
+ } else {
892
+ // parseCertsOnly fails closed with scep/no-certificates on an empty CertificateSet, so certs is non-empty here.
893
+ certs = schemaCms.parseCertsOnly(body, _err, "scep").certificates;
894
+ }
895
+ var caCertificate = null;
896
+ if (fp != null) {
897
+ var matched = null, j;
898
+ for (j = 0; j < certs.length; j++) {
899
+ var got = nodeCrypto.createHash(fp.algorithm).update(certs[j]).digest();
900
+ if (got.length === fp.value.length && got.equals(fp.value)) { matched = certs[j]; break; }
901
+ }
902
+ if (!matched) throw _err("scep/fingerprint-mismatch", "no GetCACert certificate matched the expected " + fp.algorithm + " fingerprint (RFC 8894 sec. 2.2)");
903
+ caCertificate = matched;
904
+ } else if (single) {
905
+ // A single-certificate response (application/x-x509-ca-cert) is unambiguously the CA certificate. A
906
+ // ca-ra chain carries the CA, any intermediates, and RA certificate(s) in a CMS CertificateSet whose
907
+ // DER order does not identify the CA role, so caCertificate stays null there unless a fingerprint pins it.
908
+ caCertificate = certs[0];
909
+ }
910
+ return { caCertificate: caCertificate, certificates: certs };
911
+ }
912
+
913
+ // The default transaction ID: 16 random bytes, hex-encoded. RFC 8894 sec. 3.2.1.1 requires the ID to be
914
+ // unique per operation, so a per-key value would collide across separate enrollments of the same key (a
915
+ // renewal could not be told from the original request). A caller wanting a stable per-device ID passes an
916
+ // explicit transactionId.
917
+ function _defaultTransactionId() {
918
+ return nodeCrypto.randomBytes(16).toString("hex");
919
+ }
920
+ async function _pkiOperation(messageType, baseUrl, opts, verbName) {
921
+ opts = guard.identifier.optionsObject(opts, _err, "scep/bad-input", "pki.scep." + verbName + " options");
922
+ guard.identifier.assertKnownKeys(opts, _ENROLL_KEYS, _err, "scep/bad-input", "pki.scep." + verbName + " has an unknown option: ");
923
+ if (opts.csr == null) throw _err("scep/bad-input", "a csr (PKCS#10 DER) is required");
924
+ if (opts.caCert == null) throw _err("scep/bad-input", "a caCert (the SCEP CA certificate DER) is required");
925
+ if (opts.signer == null || typeof opts.signer !== "object") throw _err("scep/bad-input", "a signer { cert, key } is required");
926
+ if (opts.recipientKey == null || typeof opts.recipientKey !== "object") throw _err("scep/bad-input", "a recipientKey { cert, key } is required to decrypt the CA response");
927
+ // Snapshot the response-authentication inputs at the synchronous entry point: the response is
928
+ // authenticated (signerCert) and decrypted (recipientKey) only after the build and POST awaits, so a
929
+ // caller mutating opts mid-flight cannot switch the trusted signer, or the CA the request was encrypted
930
+ // to, out from under the exchange. Certificate bytes are copied; the recipient private key and the
931
+ // request signer are held by reference.
932
+ var caCert = guard.bytes.snapshotSource(opts.caCert, ScepError, "scep/bad-input", "the caCert");
933
+ var signerCert = opts.responderCert != null ? guard.bytes.snapshotSource(opts.responderCert, ScepError, "scep/bad-input", "the responderCert") : caCert;
934
+ // Capture the recipient-key FIELDS at the door (snapshotSource copies the cert bytes; the private key is
935
+ // held by reference), not the opts.recipientKey object: a caller replacing opts.recipientKey.cert or .key
936
+ // while the POST is in flight must not switch the credential the response is decrypted with.
937
+ var recipientKey = _captureRecipientKey(opts.recipientKey);
938
+ var signer = opts.signer;
939
+ // The transport options are read by _client only after the build await, so snapshot them at the door too:
940
+ // a caller replacing opts.transport (rerouting the completed request) or opts.tls.cert/.key (presenting the
941
+ // next operation's mTLS identity) while the POST is in flight must not take effect. tls is shallow-copied
942
+ // so a replaced field does not reach the transport; the transport function and budgets are held by value.
943
+ var clientOpts = {
944
+ transport: opts.transport,
945
+ tls: opts.tls != null ? Object.assign({}, opts.tls) : opts.tls,
946
+ maxRedirects: opts.maxRedirects,
947
+ allowCrossOriginRedirect: opts.allowCrossOriginRedirect,
948
+ timeout: opts.timeout,
949
+ maxResponseBytes: opts.maxResponseBytes,
950
+ };
951
+ // Resolve the request URL at the door: _scepUrl reads String(baseUrl) only when the POST is sent (after the
952
+ // build await), so a baseUrl whose toString() the caller changes mid-flight could otherwise redirect it.
953
+ var postUrl = _scepUrl(baseUrl, "PKIOperation");
954
+ var csrView = guard.bytes.snapshotSource(opts.csr, ScepError, "scep/bad-input", "the csr");
955
+ var spki;
956
+ try { spki = schemaCsr.parse(csrView).subjectPublicKeyInfo; }
957
+ catch (e) { throw _err("scep/bad-input", "the csr is not a valid PKCS#10 CertificationRequest", e); }
958
+ var senderNonce = nodeCrypto.randomBytes(16);
959
+ var transactionId = opts.transactionId != null ? opts.transactionId : _defaultTransactionId();
960
+ var reqMsg = await build({ messageType: messageType, messageData: csrView, recipient: caCert, signer: signer, transactionId: transactionId, senderNonce: senderNonce });
961
+ var res = await _client("POST", postUrl, reqMsg, { "content-type": CT_PKI_MESSAGE }, clientOpts);
962
+ var body = _requireOk(res, [CT_PKI_MESSAGE], "PKIOperation");
963
+ var rep = await parse(body, { signerCert: signerCert, expectedSenderNonce: senderNonce, recipientKey: recipientKey });
964
+ if (rep.messageType !== "CertRep") throw _err("scep/unexpected-message-type", "the SCEP response is a " + rep.messageType + ", not a CertRep");
965
+ // Every message in one SCEP transaction carries the same transactionID (RFC 8894 sec. 3.2.1.1); a
966
+ // response whose transactionID does not echo the request is for a different exchange and is refused,
967
+ // even if its nonce and signer check out.
968
+ if (rep.transactionId !== transactionId) throw _err("scep/transaction-id-mismatch", "the CertRep transactionID does not match the request (RFC 8894 sec. 3.2.1.1)");
969
+ if (rep.pkiStatus === "FAILURE") {
970
+ // Surface the CA's reason as structured fields, not only interpolated text, so a caller can branch on
971
+ // the failInfo enumerant (RFC 8894 sec. 3.3.2.1) without parsing the human-readable message.
972
+ var failErr = _err("scep/enrollment-failed", "the CA refused the request with failInfo " + JSON.stringify(rep.failInfo) + (rep.failInfoText ? " (" + rep.failInfoText + ")" : ""));
973
+ failErr.failInfo = rep.failInfo;
974
+ failErr.failInfoText = rep.failInfoText;
975
+ throw failErr;
976
+ }
977
+ if (rep.pkiStatus === "PENDING") return { status: "PENDING", transactionId: rep.transactionId, senderNonce: senderNonce };
978
+ var repCerts = rep.certificates || [];
979
+ var issued = findIssuedCert(repCerts, spki.bytes);
980
+ if (issued == null) throw _err("scep/no-issued-cert", "the SUCCESS CertRep did not carry a certificate matching the request public key");
981
+ // A CMS CertificateSet states no issuance order, so a second certificate carrying the request public key
982
+ // (a same-key renewal returning old and new, or a duplicate) leaves the issued certificate ambiguous; it
983
+ // is refused rather than resolved by position.
984
+ if (findIssuedCert(repCerts.filter(function (c) { return c !== issued; }), spki.bytes) != null) {
985
+ throw _err("scep/ambiguous-issued-cert", "the SUCCESS CertRep carried more than one certificate matching the request public key; the issued certificate is ambiguous (RFC 8894 sec. 3.3)");
986
+ }
987
+ return { status: "SUCCESS", certificate: issued, certificates: repCerts, transactionId: rep.transactionId };
988
+ }
989
+
990
+ /**
991
+ * @primitive pki.scep.enroll
992
+ * @signature pki.scep.enroll(baseUrl, opts) -> Promise<{ status, certificate?, certificates?, transactionId }>
993
+ * @since 0.6.7
994
+ * @status stable
995
+ * @spec RFC 8894
996
+ * @related pki.scep.build, pki.scep.getCACert, pki.csr.sign
997
+ *
998
+ * Enrol for a certificate over SCEP (RFC 8894 sec. 4.3): build a PKCSReq wrapping the caller's PKCS#10 in
999
+ * a pkcsPKIEnvelope encrypted to the CA certificate, HTTP POST it to `?operation=PKIOperation`, and read
1000
+ * the CertRep. The CA response is authenticated against the CA certificate and its `recipientNonce` must
1001
+ * echo the request's fresh `senderNonce`. On SUCCESS the issued certificate is selected out of the
1002
+ * response by SubjectPublicKeyInfo byte-match against the request; a FAILURE throws
1003
+ * `scep/enrollment-failed` carrying the CA's failInfo; a PENDING returns `{ status: "PENDING",
1004
+ * transactionId }` for the caller to retry. The content key is transported under RSAES-OAEP, so the CA
1005
+ * certificate must be an RSA `keyEncipherment` certificate that accepts OAEP; the shared-secret
1006
+ * authenticator (RFC 8894 sec. 2.4) is the `challengePassword` the caller placed in the PKCS#10.
1007
+ *
1008
+ * @opts
1009
+ * - `csr` -- the PKCS#10 CertificationRequest DER to enrol (place a `challengePassword` in it for shared-secret auth).
1010
+ * - `caCert` -- the SCEP CA certificate DER the request is encrypted to.
1011
+ * - `signer` -- `{ cert, key }`, the outer SignedData signer (for a PKCSReq, a self-signed certificate over the request key).
1012
+ * - `recipientKey` -- `{ cert, key }`, the RSA key the CA encrypts the response to, to decrypt the CertRep.
1013
+ * - `responderCert` -- the certificate to authenticate the CA's response signature against (defaults to `caCert`).
1014
+ * - `transactionId` -- a caller-unique PrintableString (defaults to 16 random bytes, hex-encoded, unique per call).
1015
+ * - `transport` / `timeout` / `maxResponseBytes` / `maxRedirects` / `tls` / `allowCrossOriginRedirect` -- transport bounds.
1016
+ * @example
1017
+ * // requires: scepCsr, caCertDer, clientCertDer, clientKeyPkcs8, scepTransport
1018
+ * var out = await pki.scep.enroll("http://ca.example/scep", { csr: scepCsr, caCert: caCertDer,
1019
+ * signer: { cert: clientCertDer, key: clientKeyPkcs8 },
1020
+ * recipientKey: { cert: clientCertDer, key: clientKeyPkcs8 }, transport: scepTransport });
1021
+ * out.status; // "SUCCESS", or "PENDING" to retry
1022
+ */
1023
+ function enroll(baseUrl, opts) { return _pkiOperation("PKCSReq", baseUrl, opts, "enroll"); }
1024
+
1025
+ /**
1026
+ * @primitive pki.scep.renew
1027
+ * @signature pki.scep.renew(baseUrl, opts) -> Promise<{ status, certificate?, certificates?, transactionId }>
1028
+ * @since 0.6.7
1029
+ * @status stable
1030
+ * @spec RFC 8894
1031
+ * @related pki.scep.enroll
1032
+ *
1033
+ * Renew a certificate over SCEP (RFC 8894 sec. 4.3): identical to `enroll` but building a RenewalReq,
1034
+ * whose outer SignedData is signed with the caller's existing CA-issued certificate rather than a
1035
+ * self-signed one. Because the request is signed with the existing key and not the new PKCS#10 key, a
1036
+ * RenewalReq does not itself prove possession of the new key (RFC 8894 sec. 7.6); the proof-of-possession
1037
+ * over the PKCS#10 is still verified. Options are the same as `enroll`, with `signer` being the existing
1038
+ * certificate and key.
1039
+ *
1040
+ * @example
1041
+ * // requires: scepCsr, caCertDer, clientCertDer, clientKeyPkcs8, scepTransport
1042
+ * var out = await pki.scep.renew("http://ca.example/scep", { csr: scepCsr, caCert: caCertDer,
1043
+ * signer: { cert: clientCertDer, key: clientKeyPkcs8 },
1044
+ * recipientKey: { cert: clientCertDer, key: clientKeyPkcs8 }, transport: scepTransport });
1045
+ */
1046
+ function renew(baseUrl, opts) { return _pkiOperation("RenewalReq", baseUrl, opts, "renew"); }
1047
+
1048
+ var _TRANSPORT_KEYS = { transport: 1, timeout: 1, maxResponseBytes: 1, maxRedirects: 1, tls: 1, allowCrossOriginRedirect: 1 };
1049
+ function _withKeys(extra) { var o = {}, k; for (k in _TRANSPORT_KEYS) o[k] = 1; for (k in extra) o[k] = 1; return o; }
1050
+ var _CACAPS_KEYS = _withKeys({ expectSCEPStandard: 1, requireStrongProfile: 1 });
1051
+ var _CACERT_KEYS = _withKeys({ expectedFingerprint: 1, fingerprintAlgorithm: 1 });
1052
+ var _ENROLL_KEYS = _withKeys({ csr: 1, caCert: 1, signer: 1, recipientKey: 1, responderCert: 1, transactionId: 1 });
1053
+
556
1054
  module.exports = {
557
1055
  build: build,
558
1056
  parse: parse,
1057
+ getCACaps: getCACaps,
1058
+ getCACert: getCACert,
1059
+ enroll: enroll,
1060
+ renew: renew,
1061
+ parseCapabilities: parseCapabilities,
559
1062
  MESSAGE_TYPES: MESSAGE_TYPE,
560
1063
  PKI_STATUS: PKI_STATUS,
561
1064
  FAIL_INFO: FAIL_INFO,
@@ -1846,6 +1846,7 @@ module.exports = {
1846
1846
  charTable: _charTable, // build a code-point membership table from a string of allowed characters
1847
1847
  allCharsIn: _allCharsIn, // is a NON-EMPTY string composed only of table members (the /^[chars]+$/ family)
1848
1848
  coerceToDer: coerceToDer,
1849
+ isPemArmor: _isPemArmor, // does a byte source carry a PEM "-----BEGIN" armor (RFC 7468 leading text allowed)
1849
1850
  decodeRoot: decodeRoot,
1850
1851
  runParse: runParse,
1851
1852
  makeNS: makeNS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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:8cbb4140-2f2a-4d8e-abac-1df0dc0526bf",
5
+ "serialNumber": "urn:uuid:2201f01a-1b89-4433-a2aa-df6cc354e171",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-31T03:30:39.294Z",
8
+ "timestamp": "2026-08-31T11:43:15.117Z",
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.5",
22
+ "bom-ref": "@blamejs/pki@0.6.7",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.6.5",
25
+ "version": "0.6.7",
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.5",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.6.7",
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.5",
57
+ "ref": "@blamejs/pki@0.6.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]