@blamejs/pki 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ 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.8 — 2026-08-31
8
+
9
+ pki.cmp.build assembles the ccr cross-certification request body (RFC 9810 sec. 5.3.11), and the ACME client accepts a response body an injected transport returns as any BufferSource, not only a Node Buffer.
10
+
11
+ ### Added
12
+
13
+ - pki.cmp.build assembles the ccr cross-certification request body (RFC 9810 sec. 5.3.11): a CertReqMessages under PKIBody [13], the request one certification authority sends to have another certify its existing public key. It carries the same certificate template and proof-of-possession as an ir or cr, and refuses the private-key-transport encryptedKey proof-of-possession, since a ccr does not send the requesting CA's private key. It is a CertReqMessages at the sec. 5.3.11 normative floor, so it requires no Appendix D.6 profile field and does not enforce the optional D.6 single-request cardinality: a multi-request ccr builds and round-trips, and a producer that follows App. D.6 sends one cross-certificate per message by its own policy.
14
+
15
+ ### Changed
16
+
17
+ - pki.schema.cmp.parse and pki.cmp.build no longer enforce the App. D.6 single-CertResponse cardinality on a ccp cross-certification response. A CertRepMessage is SIZE (1..MAX) and App. D.6 is optional, so a multi-response ccp is parsed and built at the RFC 9810 sec. 5.3.12 normative floor, matching the new ccr treatment. The sec. 5.3.12 no-private-key restriction is unchanged.
18
+
19
+ ### Fixed
20
+
21
+ - The ACME and SCEP clients read a response body an injected transport returns as any BufferSource (a Uint8Array, a raw ArrayBuffer, a DataView), not only a Node Buffer or a string. Both the response-size check and the body decode take its raw bytes through the byte guard, so a BufferSource response is measured and parsed from its bytes rather than from a comma-joined string, and the size check accepts the same forms the decode does.
22
+
23
+ ## v0.6.7 — 2026-08-31
24
+
25
+ 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.
26
+
27
+ ### Added
28
+
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+
33
+ ### Fixed
34
+
35
+ - 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.
36
+
7
37
  ## v0.6.6 — 2026-08-31
8
38
 
9
39
  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.
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` |
@@ -235,7 +235,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
235
235
  | `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985). `sign(spec, key, opts)` builds and signs a `CertificationRequest` from a `spec` of subject (which may be empty), the public key being certified, an optional `extensionRequest` carrying the requested v3 extensions a CA copies into the issued certificate (subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned, which is what `openssl req -verify` checks. The signature algorithm is resolved from the subject key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. `verify(request)` asks the same question of a request someone else produced, over its exact signed `certificationRequestInfo` bytes under the `subjectPKInfo` inside them, through the one path-validation signature engine. It answers `{ verified, subject, subjectPublicKeyInfo, attributes, certificationRequestInfoBytes }`, all re-derived from the signed bytes, so a CA issues from the fields the signature covers rather than from the object it passed in. A `verified: true` says the producer held that private key and that the subject and requested extensions are the ones they signed; who they are is the enrollment protocol's question, not this one. Parsing stays at `pki.schema.csr.parse` — `sign`, `verify` |
236
236
  | `pki.attrcert` | Attribute-certificate issuance (RFC 5755). `sign(spec, issuer, opts)` builds and signs an `AttributeCertificate` as an Attribute Authority: a `spec` of `holder` (exactly one of an entity name, a `baseCertificateID` reference, a `fromCertificate` binding, or an object digest), the validity window as GeneralizedTime, an optional serial (positive, at most 20 octets, randomly generated when omitted), the `attributes` (role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo, or pre-encoded Attribute DER), and optional `extensions` (auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, authorityKeyIdentifier, or pre-encoded Extension DER) each with its RFC 5755 criticality. An attribute certificate is never self-signed, so the `issuer` is the signing AA, supplied as `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the AA key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, and the signature is verified under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE CERTIFICATE` with `opts.pem`; malformed input throws a typed `AttrCertError`. `verify(ac, issuer, opts)` is the receiving side, performing the RFC 5755 sec. 5 checks an attribute certificate and a named issuer can settle between them: the signature over the exact `AttributeCertificateInfo` bytes through the one path-validation signature engine, the AC naming the issuer this verifier trusts (compared as a distinguished name), the evaluation instant lying within the validity with equality at either bound succeeding as the section states, the sec. 4.3.2 targeting rule, and rejection of any critical extension this verb does not process (section 5 defines support as parsing the value AND rejecting where the value would reject, so `aaControls` and `acProxying`, whose constraints it does not evaluate, are refused when critical). It answers `{ verified, signatureValid, validityChecked, targetingChecked, holderBindingChecked, issuerPathChecked, holder, issuer, attributes, extensions, notBefore, notAfter, serialNumberHex, reason }` with the fields re-derived from the signed bytes; the holder's own certificate chain and the AC issuer's chain need certificates the verb is not given, so those slots report `false` rather than letting their absence read as a pass. Parsing stays at `pki.schema.attrcert.parse` — `sign`, `verify` |
237
237
  | `pki.crmf` | Certificate-request-message issuance (RFC 4211). `build(spec, key, opts)` assembles a `CertReqMessages` from a `spec` of `certReqId` (default 0, with the RFC 9483 `-1` sentinel allowed), a `certTemplate` of the requested fields (`subject`, `publicKey` as the SPKI DER of the key being certified, `validity`, requested `extensions`, an optional `version` 2), optional `controls` and `regInfo` (regToken, authenticator, utf8Pairs, oldCertID, protocolEncrKey, or pre-encoded `AttributeTypeAndValue` DER), and an optional `pop` selector. `key` (or `{ key }`) is the requester's private key: the message carries a `POPOSigningKey` proof of possession signed with the private half of `certTemplate.publicKey` and verified before the message is returned, exactly as a PKCS#10 CSR proves possession. A complete template signs the `CertRequest`; an incomplete one signs a `POPOSigningKeyInput`. The signature algorithm is resolved from the requested public key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. `key` is optional for a `raVerified` proof. A key that cannot sign, which is where an ML-KEM enrollment sits, takes a `POPOPrivKey` proof instead (RFC 4211 sec. 4.2, sec. 4.3): `pop.type` of `keyEncipherment` or `keyAgreement` with `pop.method: 'subsequentMessage'` declares whether an encrypted certificate (`encrCert`) or a challenge (`challengeResp`) completes the proof over the enrolling protocol's next exchange, and `pop.method: 'encryptedKey'` encloses the requester's private key for the CA in a CMS `EnvelopedData` under `id-ct-encKeyWithID`, requiring `pop.privateKey`, `pop.recipients`, the `pop.identifier` sec. 4.2.1 makes mandatory, and `pop.archive: true` because RFC 9810 sec. 5.2.8.3.1 permits the method only where archival is intended. The enclosed key must be the private half of `certTemplate.publicKey`, or the proof demonstrates possession of a key the request never asked to have certified. The `thisMessage` and `dhMAC` alternatives the specification deprecates are refused with their successors named, and `agreeMAC` is not built, since it needs the RFC 2875 static Diffie-Hellman secret against a CA certificate the requester already holds. Pass an array of specs for a batch; the CA-assigned template fields are never emitted. Returns DER, or a PEM block with `opts.pem`; malformed input throws a typed `CrmfError`. `verifyPop(messages)` is the receiving side: for each `CertReqMsg` it verifies the `POPOSigningKey` signature over the bytes RFC 4211 sec. 4.1 names — the DER of `poposkInput` when present, of `certReq` when not — through the one path-validation signature engine, and answers `{ verified, messages: [{ verified, method, cryptographicallyVerified, certReqId, subject, subjectBound, publicKey, reason }] }` with the fields re-derived from the message's own bytes. Each verdict reports only what its preimage covers: `publicKey` is the key possession was proven for, and `subject` is the requested name when the `certReq` was signed. A `poposkInput` preimage covers the key and the sender alone, so any subject beside it is unsigned and is withheld with `subjectBound: false`. A `raVerified` proof is an RA's out-of-band assertion and a `keyEncipherment` or `keyAgreement` proof completes over a later exchange, so each is reported unverified with its `method` named instead of guessed. Parsing stays at `pki.schema.crmf.parse` — `build`, `verifyPop` |
238
- | `pki.cmp` | CMP message building, transfer, and verification (RFC 9810). `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` and `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata; `message.body` is a single-key object naming the arm — request-side `ir`, `cr`, `kur`, `krr` (key recovery, §5.3.7), `p10cr`, `certConf`, `pollReq`, `genm`, `rr`, responder-side `ip`, `cp`, `kup`, `ccp`, `rp`, `genp`, `error`, `pollRep`, `krp`, `pkiconf`, and the registration-authority `nested` wrapper of complete PKIMessages (RFC 9810 §5.1.3.5). Protection is exactly one of `opts.{ key, cert }`, a signature under the sender key with the algorithm resolved from the signer certificate so RSA (PKCS#1 v1.5 / PSS), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, or `opts.mac`, a PBMAC1 shared-secret HMAC (RFC 9481 / 9579, PBKDF2-derived). Protection covers the exact DER of the virtual `ProtectedPart` and is self-verified before the message is returned, and `protectionAlg` is derived rather than caller-set, so the message the parser accepts is coherent by construction. A `p10cr` request's PKCS#10 proof-of-possession is verified before the message is protected, so one whose self-signature does not verify under its subject public key is refused rather than sent. `transfer(url, message, opts)` carries a built message to a CMP endpoint over `pki.transport` (RFC 9811): one POST of the DER PKIMessage, with the response classified fail-closed — 200 only for success, a non-200 2xx or an un-followed 3xx refused, a 4xx or 5xx carrying a CMP error PKIMessage forwarded as the integrity-protected verdict — and protection surfaced rather than verified. `wellKnownUrl(base, opts)` builds the §3.4 `/.well-known/cmp` request-URIs. `verify(message, opts)` checks the protection on an incoming message, either a signature through the same certification-path engine `pki.crl.verify` and `pki.ocsp.verify` use, with the EdDSA low-order-point and algorithm-confusion gates, or a PBMAC1 MAC recomputed from `opts.sharedSecret` and the message's own PBKDF2 parameters and constant-time compared, over the exact `ProtectedPart` reconstructed from the parser's raw slices. It is fail-closed on an unprotected message, a legacy or KEM MAC algorithm, an omitted keyLength, or a SHA-1 PRF, and returns a `{ valid, trusted, protectionType, signer, ... }` verdict. With `opts.trustAnchors` the signer certificate is fully path-validated (RFC 5280 §6.1 plus the RFC 9483 §3.2 `keyUsage.digitalSignature` gate) at a trusted current time, or an explicit `opts.time` for historical verification and never the message's self-asserted `messageTime`, before it is reported trusted; without one the verdict is crypto-only and the signer certificate is surfaced to anchor. `session(opts)` returns a stateful enrollment session whose `enroll(request)` drives a full `ir` / `cr` / `kur` / `p10cr` transaction over the shared transport, composing `build`, `transfer`, and `verify`. Every response is protection-verified, signer-trusted, and bound to the exchange (a stable `transactionID`, a fresh-`senderNonce` and echoed-`recipNonce` chain) before its body is read, with a bounded `pollReq` / `pollRep` loop for a `waiting` status and a `certConf` / `pkiConf` (or implicit) confirmation carrying an explicit `hashAlg` for a signature algorithm that does not convey its hash. It returns a terminal `{ outcome, certificate, chain, status, trusted, confirmed, implicitConfirm, transactionID, polls, transcript }`. The same session also drives the other two RFC 9483 operations under that shell: `revoke(request)` sends an `rr` naming a certificate as `{ certificate }` or `{ certDetails }` with an optional CRLReason `reason`, and returns `revoked` / `rejected` / `poll-timeout`; `info(request)` sends a `genm` for one of the four support messages — `{ caCerts }`, `{ rootCaCert }`, `{ certReqTemplate }`, `{ crlUpdate }` — and returns `answered` with the decoded `value` and a `present` flag, since an absent response value is how each says nothing is available. A session revokes its own certificate: the signature over the request is the proof of authorization (§4.2), so the named certificate must be `opts.cert` and a PBMAC1 session is refused. The signature flavor requires `opts.trustAnchors` to authenticate the CA; a verified rejection or error and an exhausted poll budget are terminal verdicts, while a tampered, untrusted, or desynchronized response is a typed throw. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build`, `transfer`, `wellKnownUrl`, `verify`, `session` |
238
+ | `pki.cmp` | CMP message building, transfer, and verification (RFC 9810). `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` and `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata; `message.body` is a single-key object naming the arm — request-side `ir`, `cr`, `kur`, `krr` (key recovery, §5.3.7), `ccr` (cross-certification, §5.3.11: a `CertReqMessages` at the normative floor, where the requesting CA keeps its private key so a private-key-transport encryptedKey proof-of-possession is refused, and the optional App. D.6 single-request cardinality is not enforced), `p10cr`, `certConf`, `pollReq`, `genm`, `rr`, responder-side `ip`, `cp`, `kup`, `ccp`, `rp`, `genp`, `error`, `pollRep`, `krp`, `pkiconf`, and the registration-authority `nested` wrapper of complete PKIMessages (RFC 9810 §5.1.3.5). Protection is exactly one of `opts.{ key, cert }`, a signature under the sender key with the algorithm resolved from the signer certificate so RSA (PKCS#1 v1.5 / PSS), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, or `opts.mac`, a PBMAC1 shared-secret HMAC (RFC 9481 / 9579, PBKDF2-derived). Protection covers the exact DER of the virtual `ProtectedPart` and is self-verified before the message is returned, and `protectionAlg` is derived rather than caller-set, so the message the parser accepts is coherent by construction. A `p10cr` request's PKCS#10 proof-of-possession is verified before the message is protected, so one whose self-signature does not verify under its subject public key is refused rather than sent. `transfer(url, message, opts)` carries a built message to a CMP endpoint over `pki.transport` (RFC 9811): one POST of the DER PKIMessage, with the response classified fail-closed — 200 only for success, a non-200 2xx or an un-followed 3xx refused, a 4xx or 5xx carrying a CMP error PKIMessage forwarded as the integrity-protected verdict — and protection surfaced rather than verified. `wellKnownUrl(base, opts)` builds the §3.4 `/.well-known/cmp` request-URIs. `verify(message, opts)` checks the protection on an incoming message, either a signature through the same certification-path engine `pki.crl.verify` and `pki.ocsp.verify` use, with the EdDSA low-order-point and algorithm-confusion gates, or a PBMAC1 MAC recomputed from `opts.sharedSecret` and the message's own PBKDF2 parameters and constant-time compared, over the exact `ProtectedPart` reconstructed from the parser's raw slices. It is fail-closed on an unprotected message, a legacy or KEM MAC algorithm, an omitted keyLength, or a SHA-1 PRF, and returns a `{ valid, trusted, protectionType, signer, ... }` verdict. With `opts.trustAnchors` the signer certificate is fully path-validated (RFC 5280 §6.1 plus the RFC 9483 §3.2 `keyUsage.digitalSignature` gate) at a trusted current time, or an explicit `opts.time` for historical verification and never the message's self-asserted `messageTime`, before it is reported trusted; without one the verdict is crypto-only and the signer certificate is surfaced to anchor. `session(opts)` returns a stateful enrollment session whose `enroll(request)` drives a full `ir` / `cr` / `kur` / `p10cr` transaction over the shared transport, composing `build`, `transfer`, and `verify`. Every response is protection-verified, signer-trusted, and bound to the exchange (a stable `transactionID`, a fresh-`senderNonce` and echoed-`recipNonce` chain) before its body is read, with a bounded `pollReq` / `pollRep` loop for a `waiting` status and a `certConf` / `pkiConf` (or implicit) confirmation carrying an explicit `hashAlg` for a signature algorithm that does not convey its hash. It returns a terminal `{ outcome, certificate, chain, status, trusted, confirmed, implicitConfirm, transactionID, polls, transcript }`. The same session also drives the other two RFC 9483 operations under that shell: `revoke(request)` sends an `rr` naming a certificate as `{ certificate }` or `{ certDetails }` with an optional CRLReason `reason`, and returns `revoked` / `rejected` / `poll-timeout`; `info(request)` sends a `genm` for one of the four support messages — `{ caCerts }`, `{ rootCaCert }`, `{ certReqTemplate }`, `{ crlUpdate }` — and returns `answered` with the decoded `value` and a `present` flag, since an absent response value is how each says nothing is available. A session revokes its own certificate: the signature over the request is the proof of authorization (§4.2), so the named certificate must be `opts.cert` and a PBMAC1 session is refused. The signature flavor requires `opts.trustAnchors` to authenticate the CA; a verified rejection or error and an exhausted poll budget are terminal verdicts, while a tampered, untrusted, or desynchronized response is a typed throw. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build`, `transfer`, `wellKnownUrl`, `verify`, `session` |
239
239
  | `pki.crl` | CRL issuance and verification (RFC 5280 §5). `sign(spec, issuer, opts)` builds and signs a `CertificateList` from a `spec` of `thisUpdate` and `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` and `revocationDate` with an optional `reason` or `invalidityDate`), and an optional `extensions` object (authority key identifier, issuing distribution point, delta-CRL indicator, freshest CRL, authority information access) or an array of pre-encoded Extension DER, with an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 or PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. The version is derived from the extension set (v2 when any CRL or entry extension is present, else v1), the outer `signatureAlgorithm` matches `tbsCertList.signature`, an empty revocation list omits the field rather than emitting an empty SEQUENCE, `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime, the RFC fixes per-extension criticality, and the produced signature is verified under the issuer key before return. `verify(crl, issuer)` checks a CRL signature through the one path-validation signature engine, algorithm-confusion and EdDSA low-order gates included; handed a certificate rather than a bare key it also asks what only a certificate can answer — that the certificate is the issuer the CRL names, and that its `keyUsage`, when present, asserts `cRLSign` — so a CRL minted under an end-entity certificate of the same CA does not verify as that CA's. `isRevoked(crl, serialNumber, opts)` looks a serial up, and first checks that the CRL is one a serial can be looked up in at all — it is handed a serial and nothing else, so a CRL that speaks for part of its issuer's certificates is refused rather than answered from. A delta CRL lists changes since a base, so an entry recording a release reads as a revocation when read alone; an indirect CRL carries other issuers' entries, whose serials are unrelated to yours; and any other `issuingDistributionPoint` narrows the CRL to one distribution point, one kind of certificate, or a subset of revocation reasons, none of which a serial can be matched against. A CRL speaks for a span as well as a set, so `opts.time` states the instant the question is asked at and a list that has stopped speaking for it — `nextUpdate` passed, `thisUpdate` later, or no `nextUpdate` stated at all — is refused rather than read as a clean bill of health; without `opts.time` the lookup is structural and an absent serial means only that it is not on that list. `pki.path.crlChecker` is the verb for all of them: it is handed the certificate, merges a delta with its base, performs the §6.3.3 correspondence, and decides currency against the material it fetched. Returns DER, or a PEM `X509 CRL` with `opts.pem`; malformed input throws a typed `CrlError`. Parsing stays at `pki.schema.crl.parse` — `sign`, `verify`, `isRevoked` |
240
240
  | `pki.key` | Key-material lifecycle (RFC 5958 / RFC 8018). `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 with AES-CBC-Pad), where `opts` selects the `cipher` (`aes-256-cbc` default, `aes-192-cbc`, `aes-128-cbc`), the `prf` (`hmacWithSHA256` default, SHA-384/512, SHA-1), the `iterations` (default 600000), and the `salt`. The plaintext is validated as PKCS#8 before encryption, a default `prf` and `keyLength` are omitted so the parameters are byte-exact with OpenSSL, and the output is re-parsed before return. `decrypt(encrypted, password, opts)` recovers the inner `PrivateKeyInfo`, re-validated through `pki.schema.pkcs8.parse`: only PBES2 / PBKDF2 / AES-CBC is accepted (PBES1, PBMAC1, and scrypt are refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), and a malformed parameter set or wrong-length IV is a distinct typed error. Because a MAC-less PBES2-CBC decrypt must not become a padding oracle (RFC 8018 §8), a wrong password and a valid-pad-but-not-a-key both surface the one uniform `key/decrypt-failed`. `export(key, opts)` and `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo. The key may come from the platform's WebCrypto or from a separately installed copy of this toolkit, and is exported through whichever holds its material; a non-extractable key, or one whose implementation keeps its material out of reach, is refused with that as the reason. Encoding is delegated to WebCrypto, so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters, and an ambiguous RSA or EC import requires `opts.algorithm`. `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards and Montgomery curves, and the FIPS post-quantum ML-DSA and ML-KEM; `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM, with a typed `KeyError` on failure. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt`, `decrypt`, `export`, `import`, `generate`, `publicFromPrivate` |
241
241
  | `pki.pkcs12` | PKCS#12 (.p12/.pfx) issuance and reading (RFC 7292 / RFC 9579). `build(spec, opts)` assembles a store from the OpenSSL-style `{ key, cert, ca?, friendlyName?, localKeyId? }` or the full `{ safeContents: [...] }`, where each element is a plaintext or PBES2-encrypted `SafeContents` of key, shroudedKey, cert, crl, secret, or nested `safeContents` bags. Keys and certs are validated before wrapping, and `friendlyName` (BMPString) and `localKeyId` are single-value. Integrity is a classic Appendix B HMAC (the default, for maximum interoperability) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`) over SHA-256/384/512, with shrouded keys and cert safes encrypted under RFC 8018 PBES2 (AES-128/192/256-CBC). Every password is encoded the PKCS#12 way — BMPString+NULL for the classic MAC, UTF-8 for the PBES2 bags and PBMAC1 — which is what OpenSSL and NSS consume, so a file it emits opens in both, cross-checked bidirectionally. The MAC covers the exact AuthenticatedSafe byte range, a DEFAULT-1 `MacData.iterations` is rejected up front, and the store is re-parsed before return. `verifyMac(pfx, password, opts)` recomputes a classic or PBMAC1 MAC over `macedBytes` and constant-time-compares it, throwing on a MAC-less or public-key-integrity store. Public-key integrity (`opts.integrity.mode: "public-key"`) wraps the AuthenticatedSafe in a CMS SignedData instead of a MAC, signed by any `pki.cms.sign` signer and carrying no MacData (§4); privacy stays independent, so `password` still PBES2-encrypts the bags. Public-key privacy wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData`, never GCM) encrypted to recipient public keys through the `pki.cms.encrypt` recipient model, via per-safe `recipients` or the `opts.recipientCerts` convenience, restricted to certificate recipients (RSA-OAEP, ECDH, X25519, X448, ML-KEM) since a password or KEK recipient could not be reopened by `open`. All four integrity-by-privacy combinations are permitted (§3.1). `open(pfx, password, opts)` reads a store back: it verifies the MAC first, so a wrong password is the MAC verdict rather than a decrypt error, then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` — keys as re-validated PKCS#8 DER, certs, CRLs and secrets as raw DER, all with `friendlyName` and `localKeyId`, nested safes recursively. A MAC-less store is refused unless `opts.allowUnauthenticated`. A public-key-integrity store is verified through its CMS SignedData signature first (`pkcs12/signature-invalid` on failure), with the signer surfaced in `signers` but never trust-chained, which remains the caller's `pki.path.validate` step. A legacy-PBE store's Appendix C 3DES and RC2 bags are decrypted, RC2 through an in-tree RFC 2268 cipher, so an `openssl pkcs12 -legacy` or NSS store opens; the legacy RC4 schemes are refused. An `id-envelopedData` safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` when absent), every recipient-side fault and every post-integrity decrypt failure collapsing to the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`. It reads what OpenSSL and NSS produce. Returns DER or a PEM `PKCS12`, with a typed `Pkcs12Error` on failure. Parsing stays at `pki.schema.pkcs12.parse` — `build`, `verifyMac`, `open` |
package/index.js CHANGED
@@ -171,7 +171,7 @@ module.exports = {
171
171
  // splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
172
172
  // builders, the HTTP response classifier). est opens no socket; fail-closed.
173
173
  est: est,
174
- 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 },
175
175
  // `transport` is the shared fail-closed node:https transport the enrollment clients
176
176
  // drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
177
177
  // headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
package/lib/acme.js CHANGED
@@ -2346,18 +2346,38 @@ 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
  }
2355
2359
 
2356
- function _bodyText(res) { return Buffer.isBuffer(res.body) ? res.body.toString("utf8") : String(res.body || ""); }
2360
+ function _bodyText(res) {
2361
+ var b = res.body;
2362
+ if (Buffer.isBuffer(b)) return b.toString("utf8");
2363
+ if (b == null) return "";
2364
+ if (typeof b === "string") return b;
2365
+ // A BufferSource (Uint8Array, ArrayBuffer) from an injected transport: read its raw bytes rather than
2366
+ // String()-coercing it into its "123,45,..." toString.
2367
+ return guard.bytes.snapshotSource(b, E, "acme/bad-response", "the response body").toString("utf8");
2368
+ }
2357
2369
  // The RAW bytes for jose.parseJson, so its FATAL UTF-8 validation runs on the wire body -- _bodyText's
2358
2370
  // lossy utf8 decode (replacement chars) would let a malformed RFC 8259 response slip through. A string
2359
2371
  // body (an injected test transport) carries no malformed UTF-8, so encoding it is lossless.
2360
- function _jsonInput(res) { return Buffer.isBuffer(res.body) ? res.body : Buffer.from(String(res.body == null ? "" : res.body), "utf8"); }
2372
+ function _jsonInput(res) {
2373
+ var b = res.body;
2374
+ if (Buffer.isBuffer(b)) return b;
2375
+ if (b == null) return Buffer.alloc(0);
2376
+ if (typeof b === "string") return Buffer.from(b, "utf8");
2377
+ // A BufferSource (Uint8Array, ArrayBuffer) from an injected transport: normalize its raw bytes so
2378
+ // jose.parseJson runs its fatal UTF-8 validation on the wire body, not on its "123,45,..." toString.
2379
+ return guard.bytes.snapshotSource(b, E, "acme/bad-response", "the response body");
2380
+ }
2361
2381
  function _requireKid() { if (!kid) throw E("acme/no-account", "no account is set -- call newAccount before an authenticated request (RFC 8555 sec. 6.2)"); return kid; }
2362
2382
  function _harvestNonce(v) {
2363
2383
  if (typeof v !== "string" || v === "") return;
@@ -2398,7 +2418,8 @@ function client(directoryUrl, opts) {
2398
2418
  // Measure an injected string body as UTF-8 -- the exact bytes _jsonInput re-encodes it to before the
2399
2419
  // decoder sees it -- so a non-ASCII body (e.g. multi-byte emoji, ~half the byte count under latin1)
2400
2420
  // cannot slip past the cap and reach the JSON parser.
2401
- var blen = Buffer.isBuffer(res.body) ? guard.bytes.lengthOf(res.body) : Buffer.byteLength(String(res.body == null ? "" : res.body), "utf8");
2421
+ var blen = res.body == null ? 0 : (typeof res.body === "string" ? Buffer.byteLength(res.body, "utf8")
2422
+ : (Buffer.isBuffer(res.body) ? guard.bytes.lengthOf(res.body) : guard.bytes.lengthOf(guard.bytes.source(res.body, E, "acme/bad-response", "the response body"))));
2402
2423
  if (blen > budgets.maxResponseBytes) throw E("acme/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap");
2403
2424
  if ((method === "GET" || method === "HEAD") && res.status >= 300 && res.status < 400) {
2404
2425
  if (redirects >= budgets.maxRedirects) throw E("acme/too-many-redirects", "the redirect budget of " + budgets.maxRedirects + " was exceeded");
package/lib/cmp-build.js CHANGED
@@ -92,17 +92,18 @@ var KNOWN_HEADER_KEYS = {
92
92
  transactionID: 1, senderNonce: 1, recipNonce: 1, freeText: 1, generalInfo: 1,
93
93
  };
94
94
  var KNOWN_BODY_KEYS = {
95
- ir: 1, cr: 1, kur: 1, krr: 1, p10cr: 1, certConf: 1, pollReq: 1, genm: 1, rr: 1, // request-side
95
+ ir: 1, cr: 1, kur: 1, krr: 1, ccr: 1, p10cr: 1, certConf: 1, pollReq: 1, genm: 1, rr: 1, // request-side
96
96
  ip: 1, cp: 1, kup: 1, ccp: 1, krp: 1, rp: 1, genp: 1, error: 1, pollRep: 1, pkiconf: 1, // CA/responder-side
97
97
  nested: 1, // an RA wrapper of complete, independently-protected PKIMessages (RFC 9810 sec. 5.1.3.5)
98
98
  };
99
99
  // The PKIBody arm -> its EXPLICIT context tag number (schema-cmp.js BODY_ARMS). rr is [11], not [15].
100
100
  var BODY_TAG = {
101
- ir: 0, cr: 2, p10cr: 4, kur: 7, krr: 9, rr: 11, genm: 21, certConf: 24, pollReq: 25,
101
+ ir: 0, cr: 2, p10cr: 4, kur: 7, krr: 9, ccr: 13, rr: 11, genm: 21, certConf: 24, pollReq: 25,
102
102
  ip: 1, cp: 3, kup: 8, krp: 10, rp: 12, ccp: 14, pkiconf: 19, genp: 22, error: 23, pollRep: 26,
103
103
  nested: 20, // NestedMessageContent (schema-cmp.js BODY_ARMS [20])
104
104
  };
105
- var CRMF_BODY = { ir: 1, cr: 1, kur: 1, krr: 1 }; // arms whose content is a CertReqMessages via pki.crmf.build (RFC 9810 sec. 5.3.7 krr)
105
+ // arms whose content is a CertReqMessages via pki.crmf.build (RFC 9810 sec. 5.3.7 krr, sec. 5.3.11 ccr).
106
+ var CRMF_BODY = { ir: 1, cr: 1, kur: 1, krr: 1, ccr: 1 };
106
107
  var CERT_REP_ARM = { ip: 1, cp: 1, kup: 1, ccp: 1 }; // arms carrying a CertRepMessage
107
108
  // Protection has two mutually exclusive forms, and the signature parameters are read only by the
108
109
  // signature one: under PBMAC1 a requested pss or digestAlgorithm selects nothing and the message
@@ -517,7 +518,6 @@ function _encodeCertResponse(cr, state) {
517
518
  function _encodeCertRepMessage(spec, state, arm) {
518
519
  if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("cmp/bad-cert-rep", arm + " must be an object { caPubs?, response }");
519
520
  if (!Array.isArray(spec.response)) throw _err("cmp/bad-cert-rep", arm + ".response must be an array of CertResponse");
520
- if (arm === "ccp" && spec.response.length !== 1) throw _err("cmp/bad-cert-rep", "a ccp CertRepMessage carries exactly one CertResponse (RFC 9810 App. D)");
521
521
  var children = [];
522
522
  if (spec.caPubs != null) { // caPubs [1] PRECEDES response (the optional-first x509-version shape)
523
523
  if (!Array.isArray(spec.caPubs) || !spec.caPubs.length) throw _err("cmp/bad-cert-rep", arm + ".caPubs must be a non-empty array of certificate DERs");
@@ -897,7 +897,11 @@ function _collectExtraCerts(opts, protCertDer) {
897
897
  * `{ header, body }`: `header` carries the `sender` / `recipient` GeneralNames plus optional transaction
898
898
  * metadata (`transactionID`, `senderNonce`, `messageTime`, `freeText`, `generalInfo`, ...); `body` is a
899
899
  * single-key object naming the arm. Request-side: `{ ir }` / `{ cr }` / `{ kur }` / `{ krr }` (key recovery,
900
- * RFC 9810 sec. 5.3.7; a `CertReqMessages` spec
900
+ * RFC 9810 sec. 5.3.7) / `{ ccr }` (cross-certification, RFC 9810 sec. 5.3.11: the requesting CA keeps its
901
+ * private key, so a ccr carrying the private-key-transport encryptedKey proof-of-possession is refused; a
902
+ * ccr is a CertReqMessages at the normative floor and the optional App. D.6 single-request cardinality is
903
+ * not enforced); each
904
+ * a `CertReqMessages` spec
901
905
  * delegated to `pki.crmf.build`; the proof-of-possession key is `key` on the arm spec), `{ p10cr }` (a PKCS#10
902
906
  * CertificationRequest DER), `{ certConf }`, `{ pollReq }`, `{ genm }`, `{ rr }`. CA/responder-side: `{ ip }` /
903
907
  * `{ cp }` / `{ kup }` / `{ ccp }` (a `CertRepMessage`: `caPubs` plus `response` of `CertResponse` each with a
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
  }
@@ -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/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.source(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,
package/lib/schema-cmp.js CHANGED
@@ -599,13 +599,14 @@ var BODY = schema.decode(function (n, ctx) {
599
599
  if (CRMF_ARMS[n.tagNumber]) {
600
600
  // ccr [13] cross-certification: RFC 9810 Appendix D.6 profiles its template
601
601
  // with signingAlg present and version "v1 or v3" (both overriding RFC 4211
602
- // sec. 5 for this one arm), and allows exactly one CertReqMsg (multiple
603
- // cross-certificates go in separate PKIMessages).
602
+ // sec. 5 for this one arm). Those relaxations are allowed. The D.6 profile
603
+ // also expects exactly one CertReqMsg, but sec. 5.3.11 defines a ccr as a
604
+ // CertReqMessages (SIZE (1..MAX)) and App. D is OPTIONAL, so the base parser
605
+ // does not enforce the single-request cardinality (an App. D.6 producer keeps
606
+ // to one by its own policy, sending multiple cross-certificates in separate
607
+ // messages); only the no-private-key restriction below is normative.
604
608
  var isCcr = n.tagNumber === 13;
605
609
  out.decoded = crmf.walkCertReqMessages(inner, { allowSigningAlg: isCcr, allowV1Version: isCcr });
606
- if (isCcr && out.decoded.messages.length !== 1) {
607
- throw ctx.E("cmp/bad-body", "a cross-certification request (ccr) must contain exactly one CertReqMsg (RFC 9810 Appendix D.6)");
608
- }
609
610
  } else if (DECODED_ARMS[n.tagNumber]) {
610
611
  var match = schema.walk(DECODED_ARMS[n.tagNumber], inner, ctx);
611
612
  out.decoded = match.result;
@@ -704,12 +705,10 @@ var PKI_MESSAGE = schema.seq([
704
705
  // meaning. Reject a ccp that carries one; never surface the key
705
706
  // material. The field stays legal in the enrollment responses (ip/cp/kup).
706
707
  if (body.arm === "ccp" && body.decoded && body.decoded.response) {
707
- // RFC 9810 Appendix D.6: a one-way cross-certification response carries
708
- // exactly one CertResponse (multiple cross-certificates go in separate
709
- // PKIMessages), the mirror of the ccr one-CertReqMsg rule.
710
- if (body.decoded.response.length !== 1) {
711
- throw ctx.E("cmp/bad-body", "a cross-certification response (ccp) must contain exactly one CertResponse (RFC 9810 Appendix D.6)");
712
- }
708
+ // RFC 9810 Appendix D.6 profiles a one-way ccp as exactly one CertResponse (the mirror of the ccr
709
+ // one-CertReqMsg profile), but sec. 5.3.12 defines a ccp as a CertRepMessage (SIZE (1..MAX)) and App. D
710
+ // is OPTIONAL, so the base parser does not enforce the single-response cardinality; only the
711
+ // no-private-key restriction is normative.
713
712
  for (var c = 0; c < body.decoded.response.length; c++) {
714
713
  var ckpCcp = body.decoded.response[c].certifiedKeyPair;
715
714
  if (ckpCcp && ckpCcp.privateKey !== null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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:a919550f-e9fd-4370-816d-2d179d45f774",
5
+ "serialNumber": "urn:uuid:ce70e235-3eb4-4497-bf76-5f97f81cffee",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-31T07:36:11.594Z",
8
+ "timestamp": "2026-08-31T13:12:39.009Z",
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.6",
22
+ "bom-ref": "@blamejs/pki@0.6.8",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.6.6",
25
+ "version": "0.6.8",
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.6",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.6.8",
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.6",
57
+ "ref": "@blamejs/pki@0.6.8",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]