@blamejs/pki 0.3.14 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,33 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## v0.3.14 — 2026-07-24
7
+ ## v0.3.17 — 2026-07-25
8
+
9
+ Refresh two development-only tooling dependencies to clear newly-disclosed advisories.
10
+
11
+ ### Security
12
+
13
+ - Refresh the development/fuzzing-only brace-expansion (5.0.7 -> 5.0.8, GHSA-mh99-v99m-4gvg) and tar (7.5.19 -> 7.5.22, GHSA-r292-9mhp-454m) tooling dependencies to versions clear of two newly-disclosed advisories. Both are used only by the development and fuzzing harnesses and are never part of the published package (the toolkit has zero runtime dependencies), so installed contents are unchanged.
14
+
15
+ ## v0.3.16 — 2026-07-24
16
+
17
+ The EST enrollment client ships -- pki.est fetches CA certificates and enrolls certificates over the wire (RFC 7030), on a new shared node:https transport.
18
+
19
+ ### Added
20
+
21
+ - pki.est.cacerts(baseUrl, opts?), pki.est.simpleenroll(baseUrl, csr, opts?), and pki.est.simplereenroll(baseUrl, csr, opts?) -- the thin RFC 7030 client verbs over pki.transport. cacerts returns the raw, unordered CA certificate set; simpleenroll submits a PKCS#10 CSR (a DER Buffer or PEM) and returns the issued certificate (chosen by public-key match) with its chain; simplereenroll additionally requires opts.oldCert and enforces the byte-identical Subject + SubjectAltName re-enroll check before the request. A 202 is surfaced as { retry, retryAfterSeconds } and never slept on. Trust is fail-closed: an https URL and an explicit opts.tls.anchors (or opts.tls.useSystemStore) are required, HTTP Basic credentials are answered only after the server is authenticated and are dropped on a cross-origin redirect, and a downgrade, redirect loop, oversized response, or unmatched issued certificate each fails closed with a typed est/* error.
22
+ - pki.transport -- the shared, fail-closed node:https transport the enrollment clients drive. pki.transport.https(defaults) returns a transport(request) -> Promise<{ status, headers, body }>. It is the toolkit's only socket: rejectUnauthorized is always on with no disable path, an explicit trust anchor or a system-store opt-in is required, TLS is floored at 1.2, the response body is bounded while it streams (LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward), and connect/read is bounded by a timeout. It carries no HTTP or protocol semantics -- status, content-type, redirect, and authentication decisions live in the message layer -- so it is reused verbatim across enrollment protocols. errors.TransportError is its default fault type; a protocol client may parameterize it to surface domain codes.
23
+
24
+ ## v0.3.15 — 2026-07-24
25
+
26
+ PKCS#12 public-key integrity is produced and verified (RFC 7292 sec. 4).
27
+
28
+ ### Added
29
+
30
+ - pki.pkcs12.build produces a public-key-integrity PKCS#12 store (RFC 7292 sec. 4). With opts.integrity { mode: 'public-key', signer: { cert, key, digestAlgorithm?, pss? } | signers: [ ... ], sid?, signingTime?, certificates? } it wraps the AuthenticatedSafe in a CMS SignedData whose id-data eContent is the byte-exact AuthenticatedSafe, signed by any pki.cms.sign signer algorithm (RSA PKCS#1 v1.5 / RSASSA-PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, composite ML-DSA), with no MacData. Combining opts.mac with public-key integrity, or building with no signer, is a config-time pkcs12/bad-integrity-mode or pkcs12/bad-input. Privacy is unchanged: opts.password still PBES2-encrypts the bags.
31
+ - pki.pkcs12.open verifies a public-key-integrity store. It runs pki.cms.verify over the store's CMS SignedData authSafe FIRST -- the integrity gate, exactly as the MAC is for password mode -- and returns nothing on a failure (pkcs12/signature-invalid), before decrypting any bag. The result bundle gains signers, the per-signer verdict [{ ok, sid, cert }] (null in password / MAC-less mode); the signer certificate is surfaced but never chained to a trust anchor -- anchoring it is the caller's pki.path.validate step. opts.signerCerts supplies the signer certificate for a store built with certificates: false. The bags then decrypt under the caller password exactly as in password mode (privacy is independent of integrity); a wrong bag password is the uniform pkcs12/decrypt-failed.
32
+
33
+ ## v0.3.14 — 2026-07-23
8
34
 
9
35
  CMS AuthenticatedData is produced and verified (RFC 5652 sec. 9).
10
36
 
package/README.md CHANGED
@@ -216,7 +216,8 @@ is callable today; nothing below is a stub.
216
216
  | `pki.schema.pkcs12` | Parse DER / BER / PEM RFC 7292 PKCS#12 (PFX) stores — key bags via the PKCS#8 parser, shrouded keys (algorithm surfaced, ciphertext opaque), cert / CRL / secret bags raw and byte-exact, encrypted and enveloped safes structurally via CMS, `friendlyName` / `localKeyId` decoded, and the exact MAC byte range (`macedBytes`) plus RFC 9579 PBMAC1 recognition for external verification; BER accepted exactly where §4.1 requires it, fail-closed — `parse`, `pemDecode`, `pemEncode` |
217
217
  | `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
218
218
  | `pki.schema.csrattrs` | Parse DER EST CSR Attributes (`CsrAttrs`, RFC 8951 §3.5 / RFC 9908) — the `AttrOrOID` items a server sends to shape an enrollment: bare OIDs, attributes with raw values, and decoded views of the RFC 9908 meaningful types (extension requests, EC/RSA key-type conventions, the certification-request-info template). Unknown types tolerated (surfaced raw), fail-closed on structure and the RFC 9908 semantic MUSTs — `parse` |
219
- | `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the transport-agnostic client codecs (the RFC 8951 base64 transfer codec, blind to Content-Transfer-Encoding; the `multipart/mixed` splitter), the certs-only + serverkeygen response validators layered over CMS, the enroll-attribute builders (channel-binding challengePassword, out-of-band key identifiers, SMIMECapabilities, the template-priority enroll plan), and the HTTP response classifier (202 Retry-After surfaced, never slept on; 204/404 on csrattrs a "none available" verdict). No socket, fail-closed — `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `parseServerKeygenResponse`, `classifyResponse`, `paths`, and the builders |
219
+ | `pki.est` | RFC 7030 / 8951 / 9908 Enrollment over Secure Transport — the thin client verbs `cacerts` / `simpleenroll` / `simplereenroll` drive the RFC 7030 flow over `pki.transport` (inject your own, or the fail-closed default): https-only, an explicit trust anchor required, same-origin redirects followed but a downgrade / loop refused, a 202 Retry-After surfaced (never slept), HTTP Basic answered only after the server is authenticated, and the issued certificate chosen by public-key match. Under them, the transport-agnostic codecs they compose — the RFC 8951 base64 transfer codec (blind to Content-Transfer-Encoding), the `multipart/mixed` splitter, the certs-only + serverkeygen response validators over CMS, the enroll-attribute builders, and the HTTP response classifier `cacerts`, `simpleenroll`, `simplereenroll`, `transferDecode`/`transferEncode`, `parseCertsOnly`, `findIssuedCert`, `classifyResponse`, `paths`, and the builders |
220
+ | `pki.transport` | The shared, fail-closed `node:https` transport the enrollment clients drive — `pki.transport.https(defaults)` returns a `transport(request) → { status, headers, body }`. The toolkit's sole socket choke point: an explicit trust anchor (or an opt-in to the system store) is required, `rejectUnauthorized` is always on, TLS is floored at 1.2, the response body is capped while it streams, and a stalled socket times out. Reused verbatim by the EST client today (ACME / CMP next) — `https` |
220
221
  | `pki.jose` | RFC 7515 Flattened JWS + RFC 7638 JWK thumbprints — `sign` / `verify` run a Flattened JWS against declarative profiles (ACME outer, EAB inner, keyChange inner) that carry the required/forbidden header rules as data; `base64url` is the strict RFC 4648 §5 codec (padding, non-alphabet, and non-canonical trailing bits rejected); `parseJson` is a bounded reader that refuses duplicate members at any depth; `thumbprint` is the RFC 7638 / 8037 / 9964 canonical digest. The algorithm registry binds each `alg` to its key type (ES/RS/PS/EdDSA/ML-DSA), so `alg:none`, an RS256→HS256 key confusion, and an all-zero ECDSA signature have no code path; `assertPublicJwk` refuses a JWK carrying private material so an exported private key is never published — `sign`, `verify`, `base64url`, `parseJson`, `thumbprint`, `assertPublicJwk` |
221
222
  | `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
222
223
  | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
@@ -229,7 +230,7 @@ is callable today; nothing below is a stub.
229
230
  | `pki.cmp` | RFC 9810 Certificate Management Protocol message building — `build(message, opts)` assembles a protected `PKIMessage`. `message.header` carries the `sender` / `recipient` GeneralNames (including the anonymous NULL-DN) plus optional transaction metadata (`transactionID`, `senderNonce` / `recipNonce`, `messageTime` as a GeneralizedTime, `senderKID` / `recipKID`, `freeText`, `generalInfo`); `message.body` is a single-key object naming the arm. Request-side: `ir` / `cr` / `kur` (a `CertReqMessages` spec delegated to `pki.crmf.build`), `p10cr` (a PKCS#10 `CertificationRequest`), `certConf`, `pollReq`, `genm`, `rr`. CA/responder-side: `ip` / `cp` / `kup` / `ccp` (a `CertRepMessage` — `caPubs` and `response` entries carrying a `PKIStatusInfo` and, under a granting status, a `certifiedKeyPair`), `rp` (revocation response), `genp`, `error`, `pollRep`, `krp` (key-recovery response), `pkiconf`. Protection is exactly one of `opts.{ key, cert }` — a signature over the message under the sender key, 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). The protection covers the exact DER of the virtual `ProtectedPart` (the header and body) and is self-verified before the message is returned; the `protectionAlg` is derived, never caller-set, so the message the parser accepts is coherent by construction. Returns DER, or a PEM `CMP` block with `opts.pem`; malformed input throws a typed `CmpError`. Parsing stays at `pki.schema.cmp.parse` — `build` |
230
231
  | `pki.crl` | RFC 5280 §5 certificate revocation list issuance — `sign(spec, issuer, opts)` builds and signs a `CertificateList`: a `spec` of `thisUpdate` / `nextUpdate`, an optional `crlNumber`, a `revoked` array (each entry a `serialNumber` + `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; an `issuer` of `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the issuer key, so RSA (PKCS#1 v1.5 / 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, per-extension criticality is fixed by the RFC, 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), and `isRevoked(crl, serialNumber)` looks a serial up in the revocation list. 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` |
231
232
  | `pki.key` | RFC 5958 / RFC 8018 key-material lifecycle — `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 + AES-CBC-Pad): `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, scrypt refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), a malformed parameter set or wrong-length IV is a distinct typed error, and — because a MAC-less PBES2-CBC decrypt must not be 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)` / `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo, delegating the encoding to WebCrypto so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters (an ambiguous RSA/EC import requires `opts.algorithm`). `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards/Montgomery curves, and the FIPS post-quantum ML-DSA / ML-KEM, and `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM; fail-closed with typed `KeyError`. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt` / `decrypt` / `export` / `import` / `generate` / `publicFromPrivate` |
232
- | `pki.pkcs12` | RFC 7292 / RFC 9579 PKCS#12 (.p12/.pfx) issuance — `build(spec, opts)` assembles a password-integrity store. `spec` is 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 / nested `safeContents` bags. Keys and certs are validated before wrapping; `friendlyName` (BMPString) and `localKeyId` attributes are single-value. The store is protected by a classic Appendix B HMAC (default, max interop) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`), over SHA-256/384/512, with the 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 (what OpenSSL and NSS consume) — so a file it emits opens in OpenSSL and NSS, cross-checked bidirectionally. The MAC is computed over 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 store's classic or PBMAC1 MAC over `macedBytes` and constant-time-compares it, throwing on a MAC-less or public-key-integrity store. Public-key integrity and legacy-PBE (PKCS#12 Appendix C) bag decryption are not yet built. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not a decrypt error), then PBES2-decrypts every privacy safe and shrouded key bag and returns `{ integrityMode, macVerified, keys, certs, crls, secrets }` — keys as re-validated PKCS#8 DER, certs/CRLs/secrets as raw DER, all with `friendlyName`/`localKeyId`, nested safes recursively. A MAC-less store is refused unless `opts.allowUnauthenticated`, a public-key-integrity or legacy-PBE store is refused, a post-MAC decrypt failure is the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`; it reads what OpenSSL and NSS produce. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` / `open` |
233
+ | `pki.pkcs12` | RFC 7292 / RFC 9579 PKCS#12 (.p12/.pfx) issuance — `build(spec, opts)` assembles a password-integrity store. `spec` is 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 / nested `safeContents` bags. Keys and certs are validated before wrapping; `friendlyName` (BMPString) and `localKeyId` attributes are single-value. The store is protected by a classic Appendix B HMAC (default, max interop) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`), over SHA-256/384/512, with the 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 (what OpenSSL and NSS consume) — so a file it emits opens in OpenSSL and NSS, cross-checked bidirectionally. The MAC is computed over 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 store's 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 — a signature from any `pki.cms.sign` signer (RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite), no MacData (RFC 7292 §4); privacy stays independent, so the `password` still PBES2-encrypts the bags. Legacy-PBE (PKCS#12 Appendix C) bag decryption is not yet built. Returns DER or a PEM `PKCS12`; fail-closed with typed `Pkcs12Error`. `open(pfx, password, opts)` reads a store back: it verifies the MAC **first** (a wrong password is the MAC verdict, not 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/secrets as raw DER, all with `friendlyName`/`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, the signer surfaced in `signers` but never trust-chained — the caller's `pki.path.validate` step); a legacy-PBE store is refused; a post-integrity decrypt failure is the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`; it reads what OpenSSL and NSS produce. Parsing stays at `pki.schema.pkcs12.parse` — `build` / `verifyMac` / `open` |
233
234
  | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, RSA / RSASSA-PSS / ECDSA / EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA (pairing ML-DSA with a traditional RSA / ECDSA / EdDSA — accepted only when **both** components verify — draft-ietf-lamps-cms-composite-sigs)); it builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: when signed attributes are present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. **Countersignatures** (RFC 5652 §11.4): `countersign(cms, signers, opts)` adds a countersignature — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, the primary bytes preserved so it still verifies — attached as the id-countersignature unsigned attribute; `verify` returns each countersignature's verdict under `signers[i].countersignatures` and every unsigned attribute (an RFC 3161 timestamp token attachable via `sign`'s `unsignedAttributes`) under `signers[i].unsignedAttrs`, surfaced unauthenticated. **Content encryption** (RFC 5652/5083/5084/9629): `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData — recipients auto-dispatch off the certificate key to key-transport (RSAES-OAEP; v1.5 never emitted), key-agreement (ephemeral-static ECDH over P-256/384/521 with the X9.63 KDF, and X25519/X448 with HKDF), symmetric key-wrap, password (PBKDF2 + RFC 3211 PWRI-KEK), or the post-quantum ML-KEM KEMRecipientInfo (RFC 9629/9936) — one fresh content key wrapped for every recipient. `decrypt(input, keyMaterial, opts)` recovers the content through the matching arm and returns it with an `authenticated` flag; every secret-dependent failure collapses to one uniform `cms/decrypt-failed` verdict (Bleichenbacher / EFAIL / password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. **AuthenticatedData** (RFC 5652 §9): `authenticate(content, recipients, opts)` produces an `id-ct-authData` — cleartext content plus an HMAC-SHA-256/384/512 MAC (authenticated but not encrypted), the fresh MAC key wrapped for every recipient through the same RecipientInfo model as `encrypt`; the MAC covers the authenticated attributes (content-type + message-digest) re-tagged to the EXPLICIT SET OF (§9.2), or the content octets directly. `decrypt` recovers the MAC key, recomputes the MAC and independently the message-digest (§9.3), and releases the content only after both pass, with every secret-dependent failure collapsing to the uniform `cms/decrypt-failed`. **Compression** (RFC 3274): `compress(content, opts)` / `decompress(input, opts)` produce and consume a CompressedData (ZLIB, version 0, id-alg-zlibCompress); decompress bounds the uncompressed output at 16 MiB and stops before it is materialized, so a decompression bomb fails closed as `cms/decompress-too-large` — a size transform with no integrity/confidentiality (RFC 8551 §2.4.5). Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress` |
234
235
  | `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one — `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat); the `smime-type` is derived from the CMS body, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
235
236
  | `pki.tsp` | RFC 3161 Time-Stamp Protocol — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and — with a trust anchor supplied — full certification-path validation of the TSA certificate at the token's `genTime`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
package/index.js CHANGED
@@ -53,6 +53,7 @@ var shbs = require("./lib/shbs");
53
53
  var hpke = require("./lib/hpke");
54
54
  var sigstore = require("./lib/sigstore");
55
55
  var est = require("./lib/est");
56
+ var transport = require("./lib/http-transport");
56
57
  var jose = require("./lib/jose");
57
58
  var acme = require("./lib/acme");
58
59
  var trust = require("./lib/trust");
@@ -143,11 +144,17 @@ module.exports = {
143
144
  // keyless Fulcio signature over a DSSE-wrapped in-toto SLSA attestation with a
144
145
  // Rekor inclusion proof -- offline, zero-dep, against caller-pinned trust.
145
146
  sigstore: sigstore,
146
- // `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the
147
- // transport-agnostic client codecs (base64 transfer, multipart splitter),
148
- // certs-only + serverkeygen validators over CMS, the enroll-attribute builders,
149
- // and the HTTP response classifier. No socket; fail-closed.
147
+ // `est` is RFC 7030 / 8951 / 9908 Enrollment over Secure Transport -- the thin
148
+ // client verbs (pki.est.cacerts / simpleenroll / simplereenroll) over pki.transport,
149
+ // plus the transport-agnostic codecs they compose (base64 transfer, multipart
150
+ // splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
151
+ // builders, the HTTP response classifier). est opens no socket; fail-closed.
150
152
  est: est,
153
+ // `transport` is the shared fail-closed node:https transport the enrollment clients
154
+ // drive -- pki.transport.https(defaults) returns a transport(request) -> {status,
155
+ // headers, body}. The toolkit's sole socket choke point: explicit trust anchors,
156
+ // rejectUnauthorized always on, a TLS floor, a streaming response cap, and a timeout.
157
+ transport: transport,
151
158
  // `jose` is the RFC 7515 Flattened JWS + RFC 7638 JWK-thumbprint layer: a strict
152
159
  // base64url codec, a bounded duplicate-key-rejecting JSON reader, profiled
153
160
  // sign/verify (ACME-outer / EAB-inner / keyChange-inner), and an alg registry
package/lib/constants.js CHANGED
@@ -153,6 +153,14 @@ var LIMITS = {
153
153
  PEM_MAX_BYTES: BYTES.mib(16),
154
154
  DER_MAX_INTEGER_BYTES: BYTES.kib(16),
155
155
  OID_MAX_SUBIDENTIFIER_BYTES: 32,
156
+ // Network transport response ceiling (pki.transport / the EST client). A remote
157
+ // server's HTTP response body is bounded WHILE it streams -- the accumulator
158
+ // aborts the socket the instant the running total crosses this cap, before the
159
+ // body reaches a decoder, so a malicious length field cannot drive unbounded
160
+ // allocation (RFC 7030 sec. 6). Sized above a base64-inflated DER_MAX_BYTES
161
+ // document plus multipart/header overhead (16 MiB * 4/3 ~= 21.3 MiB); 24 MiB
162
+ // clears it. An operator may only tighten it DOWNWARD via opts.maxResponseBytes.
163
+ HTTP_MAX_RESPONSE_BYTES: BYTES.mib(24),
156
164
  // Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbours' siblings:
157
165
  // a whole-document cap refused before the walk, a nesting cap, and a per-value
158
166
  // bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
package/lib/est.js CHANGED
@@ -9,10 +9,19 @@
9
9
  * @slug est
10
10
  *
11
11
  * @intro
12
- * Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and
13
- * RFC 9908) -- the transport-agnostic codecs, validators, and request builders
14
- * an EST client composes over the shipped CMS / CSR / PKCS#8 / X.509 parsers.
15
- * No socket is opened here: `transferDecode` / `transferEncode` are the RFC 8951
12
+ * Enrollment over Secure Transport (RFC 7030, updated by RFC 8951 and RFC 9908).
13
+ * The network verbs -- `cacerts`, `simpleenroll`, `simplereenroll` -- run the thin
14
+ * RFC 7030 client: they compose the codecs below over the shared `pki.transport`
15
+ * (a caller MAY inject `opts.transport`; the default is a fail-closed
16
+ * `pki.transport.https`). This module opens no socket itself -- the sole socket
17
+ * choke point is `pki.transport` -- so the verbs stay a thin, fail-closed shell:
18
+ * https-only (`est/insecure-url`), an explicit trust anchor required
19
+ * (`est/no-trust-anchors`), same-origin redirects followed but a downgrade / loop
20
+ * refused, a 202 Retry-After SURFACED and never slept, HTTP Basic answered only
21
+ * after the transport authenticated the server, and the issued certificate chosen
22
+ * by public-key match. Under them sit the transport-agnostic codecs, validators, and
23
+ * request builders over the shipped CMS / CSR / PKCS#8 / X.509 parsers:
24
+ * `transferDecode` / `transferEncode` are the RFC 8951
16
25
  * sec. 3 base64 transfer codec (RFC 4648, and DELIBERATELY blind to any
17
26
  * Content-Transfer-Encoding header -- errata 5904/5107); `splitMultipartMixed`
18
27
  * is the /serverkeygen `multipart/mixed` splitter; `parseCertsOnly` validates a
@@ -36,9 +45,10 @@
36
45
  * module). DER-only where DER, fail-closed everywhere.
37
46
  *
38
47
  * @card
39
- * EST (RFC 7030 / 8951 / 9908) client codecs -- base64 transfer, multipart
40
- * splitter, certs-only + serverkeygen validators over CMS, the enroll-attribute
41
- * builders, and the HTTP response classifier. Transport-agnostic, fail-closed.
48
+ * EST (RFC 7030 / 8951 / 9908) client -- the cacerts / simpleenroll / simplereenroll
49
+ * verbs over the shared pki.transport, plus the codecs they compose: base64 transfer,
50
+ * multipart splitter, certs-only + serverkeygen validators over CMS, the
51
+ * enroll-attribute builders, and the HTTP response classifier. Fail-closed.
42
52
  */
43
53
 
44
54
  var asn1 = require("./asn1-der");
@@ -51,6 +61,7 @@ var pkcs8 = require("./schema-pkcs8");
51
61
  var csr = require("./schema-csr");
52
62
  var frameworkError = require("./framework-error");
53
63
  var guard = require("./guard-all");
64
+ var httpTransport = require("./http-transport");
54
65
 
55
66
  var EstError = frameworkError.EstError;
56
67
  function E(code, message, cause) { return new EstError(code, message, cause); }
@@ -498,7 +509,7 @@ function classifyResponse(status, headers, body, opts) {
498
509
  }
499
510
  if (status === 202) {
500
511
  var ra = h["retry-after"];
501
- if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC 8951 sec. 3.3)");
512
+ if (ra === undefined || ra === null || String(ra).trim() === "") throw E("est/missing-retry-after", "an HTTP 202 EST response must include Retry-After (RFC 7030 sec. 4.2.3)");
502
513
  var raStr = String(ra).trim();
503
514
  // Retry-After is delay-seconds OR an HTTP-date (RFC 7231 sec. 7.1.3). A
504
515
  // delay-seconds becomes retryAfterSeconds directly; an HTTP-date is surfaced
@@ -708,7 +719,304 @@ function reenrollGuard(oldCertDer, newCsrDer) {
708
719
  return { subjectDn: oldSubjectDn, subjectAltName: oldSan };
709
720
  }
710
721
 
722
+ // ---- the thin RFC 7030 client: network verbs over the shared pki.transport ----
723
+
724
+ var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
725
+ var MAX_TIMEOUT = constants.TIME.seconds(600);
726
+
727
+ // The DER of a caller-supplied CSR: a DER Buffer as-is, or a PEM "CERTIFICATE REQUEST"
728
+ // decoded. Any other input is a config-time est/bad-input.
729
+ function _csrDer(input) {
730
+ if (Buffer.isBuffer(input)) return input;
731
+ if (typeof input === "string") return csr.pemDecode(input);
732
+ throw E("est/bad-input", "a CSR must be a DER Buffer or a PEM CERTIFICATE REQUEST string");
733
+ }
734
+
735
+ // Parse + validate the request URL BEFORE any transport is called: a URL that will not
736
+ // parse is est/bad-url, a non-https URL is est/insecure-url (RFC 7030 sec. 3.3). Runs
737
+ // even when a transport is injected, so an insecure URL never reaches the wire.
738
+ function _parseUrl(urlStr) {
739
+ var url;
740
+ try { url = new URL(String(urlStr)); }
741
+ catch (e) { throw E("est/bad-url", "the EST server URL did not parse: " + String(urlStr), e); }
742
+ if (url.protocol !== "https:") throw E("est/insecure-url", "EST requires https (RFC 7030 sec. 3.3), got " + url.protocol + " for " + urlStr);
743
+ return url;
744
+ }
745
+
746
+ // Map opts.tls (operator-facing) to the transport request.tls shape (anchors -> ca).
747
+ // rejectUnauthorized is NOT here -- the transport forces it on unconditionally.
748
+ function _tlsForRequest(opts) {
749
+ var t = opts.tls || {};
750
+ return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
751
+ }
752
+
753
+ // Resolve a redirect target: reject a scheme downgrade (est/insecure-redirect) or a
754
+ // cross-origin redirect on a non-GET/HEAD method without opt-in
755
+ // (est/cross-origin-redirect); a Location that will not parse is est/bad-url. Same-origin
756
+ // (and cross-origin GET/HEAD) are followed -- the transport re-runs every TLS check on the
757
+ // new connection (RFC 7030 sec. 3.2.1).
758
+ function _redirectTarget(current, location, method, opts) {
759
+ if (location == null || String(location).trim() === "") throw E("est/http-error", "a redirect response carried no Location header");
760
+ var resolved;
761
+ try { resolved = new URL(String(location), current.href); }
762
+ catch (e) { throw E("est/bad-url", "a redirect Location did not parse: " + location, e); }
763
+ if (resolved.protocol !== "https:") throw E("est/insecure-redirect", "a redirect to a non-https URL is refused (RFC 7030 sec. 3.2.1): " + resolved.protocol);
764
+ var safeMethod = method === "GET" || method === "HEAD";
765
+ if (resolved.origin !== current.origin && !safeMethod && !opts.allowCrossOriginRedirect) {
766
+ throw E("est/cross-origin-redirect", "a cross-origin redirect on a " + method + " needs opts.allowCrossOriginRedirect (RFC 7030 sec. 3.2.1)");
767
+ }
768
+ return resolved;
769
+ }
770
+
771
+ // Does WWW-Authenticate advertise a Basic challenge? The quoted-string contents are blanked first
772
+ // (a quoted auth-param may contain a comma + "Basic", e.g. `Digest realm="x, Basic required"`, which
773
+ // must NOT be read as a Basic challenge), then Basic is matched as an auth-scheme TOKEN (at the start
774
+ // or after a comma-separated challenge, followed by whitespace / a comma / end) -- never a substring,
775
+ // so `Digest realm="basic"` / `Bearer error="basic required"` are not taken as Basic either.
776
+ function _hasBasicChallenge(www) {
777
+ var s = String(www || "").replace(/"(?:[^"\\]|\\.)*"/g, "\"\"");
778
+ return /(?:^|,)\s*Basic(?=[\s,]|$)/i.test(s);
779
+ }
780
+
781
+ // The redirect-follow + HTTP-auth-retry loop. Returns the terminal transport response
782
+ // ({status, headers, body}) -- a 2xx, a 202, or a non-401 4xx/5xx. A 3xx is followed
783
+ // (bounded by maxRedirects); a 401 is answered ONCE with Basic credentials, and ONLY when
784
+ // the caller supplied them and the challenge is Basic (RFC 7030 sec. 3.2.3) -- the server
785
+ // was authenticated by the transport's rejectUnauthorized TLS before any credential is
786
+ // transmitted (no credential ever precedes server authorization).
787
+ function _drive(method, url, body, headers, opts, transport, budgets) {
788
+ var redirects = 0;
789
+ var authTried = false;
790
+ var initialOrigin = url.origin; // the origin the caller intended to authenticate to
791
+ var tls = budgets.tls; // narrowed (mTLS client identity stripped) on a cross-origin hop
792
+ function step() {
793
+ return transport({ method: method, url: url.href, headers: headers, body: body, tls: tls, timeout: budgets.timeout, maxResponseBytes: budgets.maxResponseBytes }).then(function (res) {
794
+ res = res || {};
795
+ var blen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "latin1");
796
+ if (blen > budgets.maxResponseBytes) throw E("est/response-too-large", "the response body (" + blen + " bytes) exceeds the " + budgets.maxResponseBytes + "-byte cap (RFC 7030 sec. 6)");
797
+ // The transport contract returns lowercased headers, but the injectable seam only promises
798
+ // { status, headers, body }; normalize here so a redirect Location / WWW-Authenticate from an
799
+ // injected transport using ordinary HTTP casing is read correctly (never missed as absent).
800
+ var h = {};
801
+ Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
802
+ var status = res.status;
803
+ if (status >= 300 && status < 400) {
804
+ if (redirects >= budgets.maxRedirects) throw E("est/too-many-redirects", "the redirect chain exceeded maxRedirects=" + budgets.maxRedirects + " (RFC 7030 sec. 3.2.1)");
805
+ // 303 See Other directs a non-GET/HEAD request to retrieve the target with GET and no body
806
+ // (RFC 7231 sec. 6.4.4); drop the request body + its entity content-type before following, so
807
+ // the CSR is not re-POSTed to the redirect target as a duplicate enrollment.
808
+ if (status === 303 && method !== "GET" && method !== "HEAD") {
809
+ method = "GET";
810
+ body = null;
811
+ if (headers["content-type"]) { headers = Object.assign({}, headers); delete headers["content-type"]; }
812
+ }
813
+ var prevOrigin = url.origin;
814
+ url = _redirectTarget(url, h.location, method, opts);
815
+ // Credentials MUST NOT cross an origin boundary. A redirect to a different web origin (even a
816
+ // permitted cross-origin GET) drops BOTH the HTTP Basic Authorization header AND the mTLS
817
+ // client identity (cert/key), so a prior 401's password and the caller's certificate + proof
818
+ // of private-key possession are never presented to a different server.
819
+ if (url.origin !== prevOrigin) {
820
+ if (headers.authorization) { headers = Object.assign({}, headers); delete headers.authorization; }
821
+ if (tls && (tls.cert || tls.key)) { tls = Object.assign({}, tls); delete tls.cert; delete tls.key; }
822
+ }
823
+ redirects += 1;
824
+ return step();
825
+ }
826
+ if (status === 401) {
827
+ if (authTried) throw E("est/auth-required", "the server rejected the credentialed request (RFC 7030 sec. 3.2.3)");
828
+ // Answer a challenge ONLY on the origin the caller targeted: a 401 arriving after a
829
+ // cross-origin redirect is a different server, and the client MUST NOT send its credentials
830
+ // there (RFC 7030 sec. 3.6 -- credentials go only to an authorized server the caller chose).
831
+ if (url.origin !== initialOrigin) throw E("est/auth-required", "refusing to send HTTP credentials to a redirected origin (RFC 7030 sec. 3.6)");
832
+ var www = String(h["www-authenticate"] || "");
833
+ if (!_hasBasicChallenge(www)) throw E("est/auth-required", "the server requires an unsupported HTTP authentication scheme (only Basic is supported): " + www);
834
+ if (opts.username === undefined && opts.password === undefined) throw E("est/auth-required", "the server requires HTTP authentication but no credentials were supplied (RFC 7030 sec. 3.2.3)");
835
+ headers = Object.assign({}, headers, { authorization: "Basic " + Buffer.from((opts.username || "") + ":" + (opts.password || ""), "utf8").toString("base64") });
836
+ authTried = true;
837
+ return step();
838
+ }
839
+ return res;
840
+ });
841
+ }
842
+ return step();
843
+ }
844
+
845
+ // The shared client entry: build the op URL, validate it + the trust config + the budgets
846
+ // (ALL before any transport call so a config fault proves the transport was never reached),
847
+ // then drive the request through the redirect/auth loop.
848
+ function _client(op, method, baseUrl, body, headers, opts) {
849
+ // paths() concatenates onto the base string, so a query / fragment on the base URL would capture
850
+ // the operation path (sending the request -- possibly an enrollment CSR -- to the wrong resource).
851
+ var base;
852
+ try { base = new URL(String(baseUrl)); }
853
+ catch (e) { throw E("est/bad-url", "the EST base URL did not parse: " + String(baseUrl), e); }
854
+ if (base.search || base.hash) throw E("est/bad-url", "the EST base URL must not carry a query or fragment component (RFC 7030 sec. 3.2.2)");
855
+ var url = _parseUrl(paths(baseUrl, { label: opts.label })[op]);
856
+ var transport = opts.transport;
857
+ if (!transport) {
858
+ var t = opts.tls || {};
859
+ var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
860
+ if (!hasAnchors && t.useSystemStore !== true) throw E("est/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned server (RFC 7030 sec. 3.6)");
861
+ transport = httpTransport.https({ E: E, errPrefix: "est" });
862
+ }
863
+ var budgets = {
864
+ tls: _tlsForRequest(opts),
865
+ timeout: guard.limits.cap(opts.timeout, "timeout", DEFAULT_TIMEOUT, { E: E, code: "est/bad-input", min: 1, max: MAX_TIMEOUT }),
866
+ maxResponseBytes: guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: E, code: "est/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES }),
867
+ maxRedirects: guard.limits.cap(opts.maxRedirects, "maxRedirects", 5, { E: E, code: "est/bad-input", min: 0, max: 32 }),
868
+ };
869
+ return _drive(method, url, body, Object.assign({}, headers), opts, transport, budgets);
870
+ }
871
+
872
+ // Turn a terminal response into the verb result: classifyResponse gates status +
873
+ // content-type (202 -> a surfaced retry, never slept; 4xx/5xx -> est/http-error), then an
874
+ // empty 200 is rejected and the certs-only body is decoded + validated. For an enroll, the
875
+ // issued certificate is the public-key match (findIssuedCert), never a positional guess;
876
+ // the remaining certificates are the chain.
877
+ function _certsResult(op, res, opts, csrSpki) {
878
+ var verdict = classifyResponse(res.status, res.headers, res.body, { op: op, now: opts.now });
879
+ if (verdict.status === "retry") {
880
+ // 202 Retry-After is an ENROLLMENT response (RFC 7030 sec. 4.2.3); a successful /cacerts is 200
881
+ // only (sec. 4.1.3), so a 202 there is a nonconforming server -> fail closed rather than "retry".
882
+ if (op === "cacerts") throw E("est/http-error", "a /cacerts response must be HTTP 200, not 202 (RFC 7030 sec. 4.1.3)");
883
+ return { retry: true, retryAfterSeconds: verdict.retryAfterSeconds, retryAfterDate: verdict.retryAfterDate };
884
+ }
885
+ // Only the RFC-defined success statuses are accepted: a non-standard 2xx (201, 206, ...) that the
886
+ // classifier reports as "unexpected" must NOT have its body decoded and accepted as certificates.
887
+ if (verdict.status !== "ok") throw E("est/http-error", "an EST " + op + " response must be HTTP 200 or 202 (RFC 7030 sec. 4.1.3 / 4.2.3), got " + res.status);
888
+ var bodyLen = Buffer.isBuffer(res.body) ? res.body.length : Buffer.byteLength(String(res.body == null ? "" : res.body), "latin1");
889
+ if (bodyLen === 0) throw E("est/empty-body", "a 200 " + op + " response carried an empty body (RFC 7030 sec. 4.1.3 / 4.2.3)");
890
+ var parsed = parseCertsOnly(transferDecode(res.body));
891
+ if (op === "cacerts") return { certificates: parsed.certificates, crls: parsed.crls };
892
+ var issued = findIssuedCert(parsed.certificates, csrSpki);
893
+ if (!issued) throw E("est/issued-cert-not-found", "no returned certificate matched the submitted CSR public key (RFC 5272 sec. 4.1)");
894
+ // The chain is every OTHER response certificate (by reference, so a byte-identical duplicate is
895
+ // NOT silently collapsed into the issued cert). Exactly one certificate may match the submitted
896
+ // public key -- a second match (a renewed cert sharing the key, or a duplicate entry) makes the
897
+ // issued certificate ambiguous, since the certs-only set has no issuance ordering (RFC 5272 sec. 4.1).
898
+ var chain = parsed.certificates.filter(function (c) { return c !== issued; });
899
+ if (findIssuedCert(chain, csrSpki)) throw E("est/ambiguous-issued-cert", "the enroll response carried more than one certificate matching the submitted CSR public key; the issued certificate is ambiguous (RFC 5272 sec. 4.1)");
900
+ if (opts.strict && chain.length > 0) throw E("est/unexpected-certs", "strict: the enroll response carried " + parsed.certificates.length + " certificates, expected exactly the issued one");
901
+ return { certificate: issued, chain: chain, certificates: parsed.certificates };
902
+ }
903
+
904
+ function _enroll(op, baseUrl, csrInput, opts) {
905
+ var csrDer = _csrDer(csrInput);
906
+ var spki = csr.parse(csrDer).subjectPublicKeyInfo;
907
+ var body = transferEncode(csrDer);
908
+ return _client(op, "POST", baseUrl, body, { accept: "application/pkcs7-mime", "content-type": "application/pkcs10" }, opts)
909
+ .then(function (res) { return _certsResult(op, res, opts, spki); });
910
+ }
911
+
912
+ /**
913
+ * @primitive pki.est.cacerts
914
+ * @signature pki.est.cacerts(baseUrl, opts?) -> Promise<{ certificates, crls } | { retry, retryAfterSeconds }>
915
+ * @since 0.3.16
916
+ * @status experimental
917
+ * @spec RFC 7030, RFC 8951
918
+ * @related pki.est.simpleenroll, pki.transport.https
919
+ *
920
+ * Fetch a CA's certificates over the wire: GET `<baseUrl>/.well-known/est/cacerts` through
921
+ * the shared `pki.transport` (inject `opts.transport`, else a fail-closed
922
+ * `pki.transport.https`). Returns the raw, unordered certs-only set
923
+ * (`{ certificates, crls }`), or `{ retry: true, retryAfterSeconds }` on a 202 (surfaced,
924
+ * never slept). https-only (`est/insecure-url`); an explicit `opts.tls.anchors` (or an
925
+ * `opts.tls.useSystemStore` opt-in) is required (`est/no-trust-anchors`); the returned CA
926
+ * certificate is NOT auto-trusted -- the caller path-validates it and supplies the accepted
927
+ * anchor on the next call.
928
+ *
929
+ * @opts
930
+ * - `transport` -- an injected transport(request) -> {status, headers, body}; default pki.transport.https.
931
+ * - `tls` -- { anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }.
932
+ * - `label` -- an OPTIONAL CA label path segment; `timeout` / `maxResponseBytes` / `maxRedirects` -- budgets.
933
+ * - `now` -- receipt time (epoch ms) to render a 202 Retry-After HTTP-date as seconds.
934
+ * @example
935
+ * // a live CA uses the default pki.transport.https; here an injected transport returns a canned bag
936
+ * var r = await pki.est.cacerts("https://ca.example",
937
+ * { transport: function () { return Promise.resolve({ status: 200, headers: { "content-type": "application/pkcs7-mime" }, body: caCertsDer.toString("base64") }); } });
938
+ * r.certificates; // -> [Buffer, ...] raw, unordered
939
+ */
940
+ function cacerts(baseUrl, opts) {
941
+ opts = opts || {};
942
+ return Promise.resolve().then(function () {
943
+ return _client("cacerts", "GET", baseUrl, null, { accept: "application/pkcs7-mime" }, opts);
944
+ }).then(function (res) { return _certsResult("cacerts", res, opts, null); });
945
+ }
946
+
947
+ /**
948
+ * @primitive pki.est.simpleenroll
949
+ * @signature pki.est.simpleenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
950
+ * @since 0.3.16
951
+ * @status experimental
952
+ * @spec RFC 7030, RFC 5272
953
+ * @related pki.csr.sign, pki.est.simplereenroll, pki.est.cacerts
954
+ *
955
+ * Enroll for a certificate: POST a PKCS#10 `csr` (a DER Buffer or a PEM CERTIFICATE REQUEST,
956
+ * e.g. from `pki.csr.sign`) to `<baseUrl>/.well-known/est/simpleenroll` as
957
+ * `application/pkcs10`, over the shared `pki.transport`. Returns the issued certificate
958
+ * chosen by public-key match against the submitted CSR (`certificate`), the remaining
959
+ * certificates (`chain`), and the raw set (`certificates`); or `{ retry: true,
960
+ * retryAfterSeconds }` on a 202. No returned certificate matching the CSR key fails closed
961
+ * (`est/issued-cert-not-found`); `opts.strict` requires exactly the issued certificate. A
962
+ * 401 is answered once with HTTP Basic ONLY when `opts.username`/`password` are supplied and
963
+ * the transport already authenticated the server.
964
+ *
965
+ * @opts
966
+ * - `transport` / `tls` / `label` / `timeout` / `maxResponseBytes` / `maxRedirects` / `now` -- as pki.est.cacerts.
967
+ * - `strict` -- reject an enroll response that carries more than the single issued certificate.
968
+ * - `username` / `password` -- HTTP Basic credentials, answered only after server authorization (empty username allowed).
969
+ * - `allowCrossOriginRedirect` -- opt in to following a cross-origin redirect on this POST.
970
+ * @example
971
+ * var req = await pki.csr.sign({ subject: "device.example", subjectPublicKey: signerSpki }, { key: signerKeyPkcs8 });
972
+ * // a 202 means the CA queued the request -- the verb surfaces the delay, never sleeps
973
+ * var r = await pki.est.simpleenroll("https://ca.example", req,
974
+ * { transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
975
+ * r.retry && r.retryAfterSeconds; // 60
976
+ */
977
+ function simpleenroll(baseUrl, csrInput, opts) {
978
+ opts = opts || {};
979
+ return Promise.resolve().then(function () { return _enroll("simpleenroll", baseUrl, csrInput, opts); });
980
+ }
981
+
982
+ /**
983
+ * @primitive pki.est.simplereenroll
984
+ * @signature pki.est.simplereenroll(baseUrl, csr, opts?) -> Promise<{ certificate, chain, certificates } | { retry, retryAfterSeconds }>
985
+ * @since 0.3.16
986
+ * @status experimental
987
+ * @spec RFC 7030
988
+ * @related pki.est.simpleenroll, pki.est.reenrollGuard
989
+ *
990
+ * Renew / rekey a certificate: identical to `pki.est.simpleenroll` but POSTs to
991
+ * `/.well-known/est/simplereenroll` and REQUIRES `opts.oldCert` (the certificate being
992
+ * renewed). Before anything crosses the wire, `reenrollGuard` enforces that the CSR's
993
+ * Subject and SubjectAltName (names and criticality) are byte-identical to `opts.oldCert`
994
+ * (RFC 7030 sec. 4.2.2) -- a mismatch fails closed (`est/reenroll-subject-mismatch` /
995
+ * `est/reenroll-san-mismatch`) and the transport is never called. A missing `opts.oldCert`
996
+ * is `est/bad-input`.
997
+ *
998
+ * @opts
999
+ * - `oldCert` -- REQUIRED, the DER certificate being renewed (the re-enroll identity check).
1000
+ * - every option of pki.est.simpleenroll (transport, tls, label, budgets, strict, credentials).
1001
+ * @example
1002
+ * // reenrollGuard enforces the RFC 7030 sec. 4.2.2 identity check before anything is sent
1003
+ * var r = await pki.est.simplereenroll("https://ca.example", renewCsr,
1004
+ * { oldCert: signerCertDer, transport: function () { return Promise.resolve({ status: 202, headers: { "retry-after": "60" }, body: "" }); } });
1005
+ * r.retry; // true
1006
+ */
1007
+ function simplereenroll(baseUrl, csrInput, opts) {
1008
+ opts = opts || {};
1009
+ return Promise.resolve().then(function () {
1010
+ if (!opts.oldCert) throw E("est/bad-input", "simplereenroll requires opts.oldCert (the certificate being renewed, RFC 7030 sec. 4.2.2)");
1011
+ reenrollGuard(opts.oldCert, _csrDer(csrInput)); // est/reenroll-* on mismatch, BEFORE the POST
1012
+ return _enroll("simplereenroll", baseUrl, csrInput, opts);
1013
+ });
1014
+ }
1015
+
711
1016
  module.exports = {
1017
+ cacerts: cacerts,
1018
+ simpleenroll: simpleenroll,
1019
+ simplereenroll: simplereenroll,
712
1020
  transferDecode: transferDecode,
713
1021
  transferEncode: transferEncode,
714
1022
  splitMultipartMixed: splitMultipartMixed,
@@ -264,6 +264,15 @@ var CsrattrsError = defineClass("CsrattrsError", { withCause: true });
264
264
  // `.cause`.
265
265
  var EstError = defineClass("EstError", { withCause: true });
266
266
 
267
+ // TransportError -- a fault from the shared node:https transport (pki.transport):
268
+ // a non-https / unparseable request URL, a missing trust anchor, a TLS handshake /
269
+ // server-authentication failure, a negotiated protocol below the floor, a response
270
+ // over the size cap, or a connect / read timeout. A protocol client (est / acme /
271
+ // cmp) may parameterize the transport with its OWN error factory + code prefix so
272
+ // the same choke point surfaces domain codes; this is the default identity when the
273
+ // transport is used directly. Carries the underlying socket / TLS fault as `.cause`.
274
+ var TransportError = defineClass("TransportError", { withCause: true });
275
+
267
276
  // JoseError -- a JOSE (RFC 7515 JWS / RFC 7518 JWA / RFC 7638 thumbprint)
268
277
  // fault: a non-canonical base64url field, a duplicate JSON member, a
269
278
  // bounds violation, a header that fails its profile (alg/nonce/url/jwk-kid),
@@ -361,6 +370,7 @@ module.exports = {
361
370
  SmimeError: SmimeError,
362
371
  CsrattrsError: CsrattrsError,
363
372
  EstError: EstError,
373
+ TransportError: TransportError,
364
374
  JoseError: JoseError,
365
375
  AcmeError: AcmeError,
366
376
  TrustError: TrustError,
@@ -0,0 +1,310 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.transport
6
+ * @nav Protocols
7
+ * @title Transport
8
+ * @order 195
9
+ * @slug transport
10
+ *
11
+ * @intro
12
+ * The shared, fail-closed `node:https` transport the enrollment protocol clients
13
+ * drive -- `pki.est` now, `pki.acme` and `pki.cmp` next. This is the ONLY module in
14
+ * the toolkit that opens a socket; every protocol layer stays transport-agnostic and
15
+ * composes it (or an injected substitute) through one contract:
16
+ * `transport(request) -> Promise<{ status, headers, body }>`. The response triple is
17
+ * exactly what a message layer's classifier consumes, so no protocol semantics leak
18
+ * into the socket layer -- the transport owns socket lifecycle, the TLS trust policy,
19
+ * the streaming size cap, and the timeout budget; the caller owns HTTP status,
20
+ * content-type, redirect, and authentication decisions.
21
+ *
22
+ * `pki.transport.https(defaults?)` binds TLS + budget defaults and returns a
23
+ * transport. Trust is EXPLICIT and fail-closed: a request is refused unless it
24
+ * carries an https URL and either a `tls.anchors` set (an Explicit trust-anchor
25
+ * database, mapped to the node `ca` option) or an explicit `tls.useSystemStore`
26
+ * opt-in to node's bundled roots. `rejectUnauthorized` is ALWAYS on -- there is no
27
+ * code path that disables server-certificate verification. The response body is
28
+ * bounded WHILE it streams: the accumulator aborts the socket the instant the running
29
+ * total crosses `maxResponseBytes`, before a byte reaches a decoder. A protocol
30
+ * client MAY parameterize the transport with its own `(code, message, cause)` error
31
+ * factory + code prefix, so the same choke point surfaces domain-specific codes.
32
+ *
33
+ * @card
34
+ * The shared fail-closed node:https transport (est / acme / cmp): explicit trust
35
+ * anchors, rejectUnauthorized always on, a TLS floor, a streaming response-size cap,
36
+ * and a timeout -- behind one `transport(request) -> {status, headers, body}` seam.
37
+ */
38
+
39
+ var nodeHttps = require("node:https");
40
+ var nodeNet = require("node:net");
41
+ var nodeTls = require("node:tls");
42
+ var constants = require("./constants");
43
+ var guard = require("./guard-all");
44
+ var frameworkError = require("./framework-error");
45
+
46
+ var TransportError = frameworkError.TransportError;
47
+ function defaultE(code, message, cause) { return new TransportError(code, message, cause); }
48
+
49
+ var DEFAULT_TIMEOUT = constants.TIME.seconds(30);
50
+ var MAX_TIMEOUT = constants.TIME.seconds(600);
51
+ var DEFAULT_MIN_VERSION = "TLSv1.2";
52
+
53
+ // A config-time budget: cap() with the caller's typed factory + a >= 1 floor and an
54
+ // upper bound (maxResponseBytes may only be tightened DOWNWARD from the default). A
55
+ // NaN / negative / over-max value is a typed reject, never a silently-disabled bound.
56
+ function _budget(value, key, dflt, max, E, code) {
57
+ return guard.limits.cap(value, key, dflt, { E: E, code: code, min: 1, max: max, label: key });
58
+ }
59
+
60
+ // Node's `ca` option expects PEM; a raw DER certificate Buffer -- the toolkit's native trust-anchor
61
+ // form (e.g. what pki.est.cacerts / parseCertsOnly return) -- is silently ignored there, so the
62
+ // connection would fail server authentication despite a valid anchor. Wrap a DER anchor as PEM
63
+ // (a Buffer already carrying PEM text, or a PEM string, passes through unchanged).
64
+ function _pemifyAnchor(a) {
65
+ if (typeof a === "string") return a;
66
+ if (Buffer.isBuffer(a)) {
67
+ // Detect PEM by its armor at the START (after optional whitespace) only: a DER certificate begins
68
+ // with the SEQUENCE tag 0x30 and may itself contain the bytes "-----BEGIN" in a subject / extension
69
+ // value, so an anywhere-search would leave such a DER anchor unwrapped and unusable.
70
+ if (/^\s*-----BEGIN /.test(a.toString("latin1", 0, 64))) return a;
71
+ var b64 = a.toString("base64").replace(/.{1,64}/g, "$&\n");
72
+ return "-----BEGIN CERTIFICATE-----\n" + b64 + "-----END CERTIFICATE-----\n";
73
+ }
74
+ return a;
75
+ }
76
+ function _pemifyAnchors(anchors) {
77
+ var list = Array.isArray(anchors) ? anchors.map(_pemifyAnchor) : [_pemifyAnchor(anchors)];
78
+ return list;
79
+ }
80
+
81
+ // The OS system certificate store plus node's bundled roots (cached; the read is not free), for a
82
+ // tls.useSystemStore opt-in. Node's default (no `ca`) is ONLY the bundled Mozilla set, so a server
83
+ // chaining to an OS-installed enterprise CA would fail without this. On a node too old for
84
+ // tls.getCACertificates the cache stays empty and the caller falls back to node's default store.
85
+ var _systemCaCache = null;
86
+ function _systemCa() {
87
+ if (_systemCaCache !== null) return _systemCaCache;
88
+ var out = [];
89
+ if (typeof nodeTls.getCACertificates === "function") {
90
+ ["system", "bundled"].forEach(function (t) {
91
+ // allow:swallow-unverified a store TYPE unsupported on this node is skipped; the other type
92
+ // (and, if both fail, node's default-store fallback) still applies -- a best-effort store load.
93
+ try { var c = nodeTls.getCACertificates(t); if (Array.isArray(c)) out = out.concat(c); } catch (_e) { /* unsupported type */ }
94
+ });
95
+ }
96
+ _systemCaCache = out;
97
+ return out;
98
+ }
99
+
100
+ // Classify a node request/TLS error into the transport's fail-closed verdict: a
101
+ // protocol-version mismatch is the TLS floor; a certificate / identity / handshake
102
+ // failure is a server-authentication failure; anything else is a generic transport
103
+ // error. Every arm threads the raw fault as `.cause`, so the diagnostic survives.
104
+ function _classifyError(e, C) {
105
+ var s = String((e && e.code) || "") + " " + String((e && e.message) || "");
106
+ if (/PROTOCOL_VERSION|UNSUPPORTED_PROTOCOL|VERSION_TOO_LOW|WRONG_VERSION|NO_PROTOCOLS_AVAILABLE|INAPPROPRIATE_FALLBACK/i.test(s)) return C("tls-floor");
107
+ if (/CERT|SELF.?SIGNED|VERIFY|ALTNAME|HOSTNAME|DEPTH_ZERO|LOCAL_ISSUER|HANDSHAKE|\bSSL\b|\bTLS\b/i.test(s)) return C("server-auth-failed");
108
+ return C("transport-error");
109
+ }
110
+
111
+ /**
112
+ * @primitive pki.transport.https
113
+ * @signature pki.transport.https(defaults?) -> transport
114
+ * @since 0.3.16
115
+ * @status experimental
116
+ * @spec RFC 7030, RFC 8996
117
+ * @defends tls-downgrade (CWE-757), server-impersonation (CWE-297), response-flooding (CWE-770)
118
+ * @related pki.est.cacerts, pki.est.simpleenroll
119
+ *
120
+ * Build a fail-closed `node:https` transport: `transport(request) -> Promise<{ status,
121
+ * headers, body }>`. `defaults` binds a `tls` policy (`anchors` -> the node `ca`;
122
+ * `useSystemStore` to opt into the bundled roots; `cert`/`key` for mutual TLS;
123
+ * `minVersion` 'TLSv1.2' (default) or 'TLSv1.3'; `servername`; a `checkServerIdentity`
124
+ * that may only tighten) plus `timeout` and `maxResponseBytes` budgets. Each `request`
125
+ * ({ method, url, headers, body, tls, timeout, maxResponseBytes }) may override them.
126
+ * A non-https URL (`transport/insecure-url`), a request with neither an explicit
127
+ * anchor nor `useSystemStore` (`transport/no-trust-anchors`), a body over the streaming
128
+ * cap (`transport/response-too-large`), a stalled socket (`transport/timeout`), a below
129
+ * -floor negotiation (`transport/tls-floor`), or a failed server authentication
130
+ * (`transport/server-auth-failed`) all fail closed; `rejectUnauthorized` is always on.
131
+ * A protocol client passes its own error factory (`defaults.E`) + `defaults.errPrefix`
132
+ * to surface domain codes (`est/...`). The transport owns no HTTP/redirect/auth
133
+ * semantics -- those live in the message layer that consumes the response triple.
134
+ *
135
+ * @opts
136
+ * - `tls.anchors` -- Explicit trust anchor(s): a DER/PEM Buffer, an array, or PEM string(s) (node `ca`).
137
+ * - `tls.useSystemStore` -- boolean; the ONLY opt-in to node's bundled CA store (default false).
138
+ * - `tls.cert` / `tls.key` -- client certificate + key for mutual-TLS re-enrollment.
139
+ * - `tls.minVersion` -- 'TLSv1.2' (default) or 'TLSv1.3'; never below the floor.
140
+ * - `tls.servername` / `tls.checkServerIdentity` -- SNI + RFC 6125 identity; may tighten, never disable.
141
+ * - `timeout` -- ms (default C.TIME.seconds(30)); `maxResponseBytes` -- default LIMITS.HTTP_MAX_RESPONSE_BYTES, tightenable downward only.
142
+ * @example
143
+ * var t = pki.transport.https({ tls: { anchors: [caPem] } });
144
+ * var res = await t({ method: "GET", url: "https://ca.example/.well-known/est/cacerts" });
145
+ * res.status; // 200
146
+ */
147
+ function httpsTransport(defaults) {
148
+ defaults = defaults || {};
149
+ var E = typeof defaults.E === "function" ? defaults.E : defaultE;
150
+ var prefix = defaults.errPrefix || "transport";
151
+ function C(name) { return prefix + "/" + name; }
152
+ var tlsDefaults = defaults.tls || {};
153
+
154
+ // Synchronous config validation + node-options build. Throws the typed verdict on any
155
+ // fault (a non-https URL, no trust anchor, a bad budget, a sub-floor minVersion) so the
156
+ // async executor below never has to `return reject(...)`. rejectUnauthorized is forced on.
157
+ function _prepare(request) {
158
+ var url;
159
+ try { url = new URL(String(request.url)); }
160
+ catch (e) { throw E(C("bad-url"), "the request URL did not parse: " + String(request.url), e); }
161
+ if (url.protocol !== "https:") throw E(C("insecure-url"), "transport requires https (RFC 7030 sec. 3.3), got " + url.protocol);
162
+
163
+ var reqTls = request.tls || {};
164
+ var anchors = reqTls.anchors !== undefined ? reqTls.anchors : tlsDefaults.anchors;
165
+ // System-store trust requires a STRICT boolean true; a non-boolean (a "false" / "true" string from
166
+ // JSON/env, a truthy object) is treated as absent, so a malformed config fails closed rather than
167
+ // silently trusting node's bundled roots.
168
+ var useSystem = (reqTls.useSystemStore !== undefined ? reqTls.useSystemStore : tlsDefaults.useSystemStore) === true;
169
+ var hasAnchors = anchors !== undefined && anchors !== null && !(Array.isArray(anchors) && anchors.length === 0);
170
+ if (!hasAnchors && !useSystem) throw E(C("no-trust-anchors"), "no explicit trust anchor and useSystemStore not set -- refusing an unpinned server (RFC 7030 sec. 3.6)");
171
+
172
+ var timeout = _budget(request.timeout !== undefined ? request.timeout : defaults.timeout, "timeout", DEFAULT_TIMEOUT, MAX_TIMEOUT, E, C("bad-input"));
173
+ var maxBytes = _budget(request.maxResponseBytes !== undefined ? request.maxResponseBytes : defaults.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, E, C("bad-input"));
174
+
175
+ var minVersion = reqTls.minVersion || tlsDefaults.minVersion || DEFAULT_MIN_VERSION;
176
+ if (minVersion !== "TLSv1.2" && minVersion !== "TLSv1.3") throw E(C("bad-input"), "tls.minVersion must be 'TLSv1.2' or 'TLSv1.3' (never below the floor), got " + minVersion);
177
+
178
+ var body = request.body;
179
+ if (Buffer.isBuffer(body)) body = guard.bytes.view(body, E, C("bad-input"), "the request body");
180
+
181
+ // URL.hostname keeps the square brackets on an IPv6 literal ([2001:db8::1]); node's `hostname`
182
+ // option and its IP-detection expect the bare address, so strip the brackets once here.
183
+ var host = url.hostname.replace(/^\[(.*)\]$/, "$1");
184
+ var options = {
185
+ method: request.method || "GET",
186
+ hostname: host,
187
+ port: url.port || 443,
188
+ path: url.pathname + url.search,
189
+ headers: request.headers || {},
190
+ minVersion: minVersion,
191
+ rejectUnauthorized: true, // ALWAYS on -- there is no code path that disables server verification
192
+ // Opt out of connection pooling: node's keep-alive agent keys the socket pool on host + TLS
193
+ // options but NOT on checkServerIdentity, so a reused socket would skip a per-request identity /
194
+ // pinning callback. A fresh connection per request guarantees the identity check always runs.
195
+ agent: false,
196
+ };
197
+ // Every TLS field falls back to the factory defaults (as anchors / minVersion already do), so a
198
+ // reusable transport built with pki.transport.https({ tls: { cert, key, servername } }) applies
199
+ // them to a request that carries no per-request tls (mTLS enrollment, a configured SNI).
200
+ var cert = reqTls.cert !== undefined ? reqTls.cert : tlsDefaults.cert;
201
+ var key = reqTls.key !== undefined ? reqTls.key : tlsDefaults.key;
202
+ var callerCsi = typeof reqTls.checkServerIdentity === "function" ? reqTls.checkServerIdentity
203
+ : (typeof tlsDefaults.checkServerIdentity === "function" ? tlsDefaults.checkServerIdentity : null);
204
+ // SNI (and RFC 6125 identity) uses a hostname; node forbids a servername that is an IP
205
+ // literal, so it is omitted for an IP host (node then matches the IP against the cert's
206
+ // IP SANs). A caller connecting by hostname gets SNI + name verification by default.
207
+ var sni = reqTls.servername || tlsDefaults.servername || host;
208
+ if (sni && !nodeNet.isIP(sni)) options.servername = sni;
209
+ // Build node's `ca`: the explicit anchors (DER wrapped as PEM) and, when useSystemStore is opted
210
+ // in, the OS system store + bundled roots. Node's `ca` REPLACES the default set, so both sources
211
+ // are merged here. Left unset only when no anchors and (on an older node) the system store is
212
+ // empty -- then node's default bundled store applies (the documented fallback).
213
+ var caList = [];
214
+ if (hasAnchors) caList = caList.concat(_pemifyAnchors(anchors));
215
+ if (useSystem) caList = caList.concat(_systemCa());
216
+ if (caList.length) options.ca = caList;
217
+ if (cert) options.cert = cert;
218
+ if (key) options.key = key;
219
+ // A caller checkServerIdentity may only TIGHTEN, never REPLACE, name verification: node uses a
220
+ // supplied callback in place of its default RFC 6125 check, so a hook that returns undefined
221
+ // (pinning/metrics only) would silently disable hostname verification. Run node's default first
222
+ // and reject on its verdict; the caller's hook adds checks only after the default has passed.
223
+ if (callerCsi) {
224
+ options.checkServerIdentity = function (host, cert2) {
225
+ var baseErr = nodeTls.checkServerIdentity(host, cert2);
226
+ if (baseErr) return baseErr;
227
+ return callerCsi(host, cert2);
228
+ };
229
+ }
230
+
231
+ return { options: options, timeout: timeout, maxBytes: maxBytes, body: body };
232
+ }
233
+
234
+ return function transport(request) {
235
+ request = request || {};
236
+ var prep;
237
+ try { prep = _prepare(request); }
238
+ catch (e) { return Promise.reject(e); }
239
+ return new Promise(function (resolve, reject) {
240
+ var settled = false;
241
+ var req;
242
+ var timer = null;
243
+ function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } }
244
+ function fail(code, msg, cause) {
245
+ if (settled) return;
246
+ settled = true;
247
+ clearTimer();
248
+ // allow:swallow-unverified req.destroy() is idempotent and does not throw in practice; a
249
+ // raw throw from this best-effort abort would mask the real fail-closed verdict below.
250
+ try { if (req) req.destroy(); } catch (_e) { /* best-effort abort */ }
251
+ reject(E(code, msg, cause));
252
+ }
253
+ // An independent wall-clock deadline bounds DNS resolution and connection setup as well as
254
+ // socket inactivity: node's request/socket timeout does not run during a DNS lookup, so a
255
+ // stalled resolver would otherwise leave this promise pending forever despite a finite timeout.
256
+ timer = setTimeout(function () { fail(C("timeout"), "the request timed out after " + prep.timeout + "ms"); }, prep.timeout);
257
+ try {
258
+ req = nodeHttps.request(prep.options, function (res) {
259
+ // Capture the TLS session facts while the socket is live -- getProtocol() /
260
+ // getPeerCertificate() return null once the socket detaches at stream end.
261
+ var proto = res.socket && res.socket.getProtocol ? res.socket.getProtocol() : null;
262
+ var peer = res.socket && res.socket.getPeerCertificate ? res.socket.getPeerCertificate() : null;
263
+ // Pre-check a declared content-length so an oversized body is refused before it streams.
264
+ var declared = parseInt((res.headers || {})["content-length"], 10);
265
+ if (Number.isFinite(declared) && declared > prep.maxBytes) { fail(C("response-too-large"), "the declared content-length " + declared + " exceeds the " + prep.maxBytes + "-byte cap (RFC 7030 sec. 6)"); return; }
266
+ // Accumulate into a single doubling buffer rather than an array of chunks: a body sent as
267
+ // many tiny chunks (one-byte TLS records) would otherwise retain millions of Buffer objects
268
+ // under the byte cap and exhaust the heap. One live buffer bounds the metadata as well as the
269
+ // bytes; only the written prefix [0, len) is ever surfaced (the unwritten tail is never read).
270
+ var buf = Buffer.allocUnsafe(0);
271
+ var len = 0;
272
+ var total = 0;
273
+ res.on("data", function (chunk) {
274
+ total += chunk.length;
275
+ if (total > prep.maxBytes) { fail(C("response-too-large"), "the response exceeded the " + prep.maxBytes + "-byte cap (RFC 7030 sec. 6)"); return; }
276
+ if (len + chunk.length > buf.length) {
277
+ var want = Math.min(prep.maxBytes, Math.max(buf.length ? buf.length * 2 : 8192, len + chunk.length));
278
+ var grown = Buffer.allocUnsafe(want);
279
+ buf.copy(grown, 0, 0, len);
280
+ buf = grown;
281
+ }
282
+ chunk.copy(buf, len);
283
+ len += chunk.length;
284
+ });
285
+ res.on("end", function () {
286
+ if (settled) return;
287
+ settled = true;
288
+ clearTimer();
289
+ var lower = {};
290
+ Object.keys(res.headers || {}).forEach(function (k) { lower[k.toLowerCase()] = res.headers[k]; });
291
+ resolve({
292
+ status: res.statusCode,
293
+ headers: lower,
294
+ body: Buffer.from(buf.subarray(0, len)),
295
+ tls: { protocol: proto, peerCertificate: peer && peer.raw ? peer.raw : null },
296
+ });
297
+ });
298
+ res.on("error", function (e) { fail(C("transport-error"), "the response stream failed", e); });
299
+ });
300
+ req.on("error", function (e) { fail(_classifyError(e, C), "the request failed: " + ((e && e.message) || String(e)), e); });
301
+ // Write a provided body for ANY method (POST / PUT / PATCH / ...), not only POST, so a body
302
+ // is never silently dropped on a body-bearing request through this generic transport seam.
303
+ if (prep.body != null && prep.body !== "") req.write(prep.body);
304
+ req.end();
305
+ } catch (e) { fail(C("transport-error"), "the request could not be initiated: " + ((e && e.message) || String(e)), e); }
306
+ });
307
+ };
308
+ }
309
+
310
+ module.exports = { https: httpsTransport };
@@ -22,8 +22,12 @@
22
22
  // integrity purpose) is bespoke PKCS#12 crypto with no in-tree equivalent, built here as one primitive and
23
23
  // cross-checked against OpenSSL. The MAC is computed over the CONTENT octets of the id-data authSafe OCTET
24
24
  // STRING (excluding its TLV header) -- exactly the parser's `macedBytes`, the canonical off-by-the-header
25
- // trap designed out. Public-key integrity (an id-signedData authSafe) and bag/store DECRYPTION are deferred:
26
- // build produces password-integrity stores, and a `pki.pkcs12.open` reader is the re-open condition.
25
+ // trap designed out. Public-key integrity (an id-signedData authSafe -- a CMS SignedData over the exact
26
+ // AuthenticatedSafe, no MacData) is produced by composing pki.cms.sign and verified on open by pki.cms.verify
27
+ // as the integrity gate before any bag is trusted; the signer is surfaced, never trust-chained (the caller's
28
+ // pki.path.validate step). Public-key PRIVACY (id-envelopedData bags opened with a recipient key) remains the
29
+ // re-open condition -- privacy is independent of integrity, so a public-key-integrity store's bags still
30
+ // decrypt under the PBES2 password.
27
31
 
28
32
  var nodeCrypto = require("crypto");
29
33
  var asn1 = require("./asn1-der");
@@ -33,6 +37,7 @@ var pkcs8 = require("./schema-pkcs8");
33
37
  var x509 = require("./schema-x509");
34
38
  var schemaCrl = require("./schema-crl");
35
39
  var key = require("./key");
40
+ var cms = require("./cms-verify"); // pki.cms.sign (build the id-signedData authSafe) + pki.cms.verify (the open integrity gate)
36
41
  var schemaPkcs12 = require("./schema-pkcs12");
37
42
  var pkix = require("./schema-pkix");
38
43
  var guard = require("./guard-all");
@@ -326,16 +331,20 @@ function _normalizeSpec(spec, opts) {
326
331
  * @spec RFC 7292, RFC 9579, RFC 8018
327
332
  * @related pki.schema.pkcs12.parse, pki.pkcs12.verifyMac
328
333
  *
329
- * Build a password-integrity PKCS#12 (.p12 / .pfx) store. `spec` is either the OpenSSL-style convenience
330
- * form `{ key, cert, ca?, friendlyName?, localKeyId? }` (one PBES2-encrypted cert safe plus one shrouded-key
331
- * safe) or the full form `{ safeContents: [...] }`, where each element is a plaintext or PBES2-encrypted
332
- * `SafeContents` of key / shroudedKey / cert / crl / secret / nested safeContents bags. Keys and certs are
333
- * validated before wrapping; every password is BMPString+NULL encoded (RFC 7292 App. B.1). The store is
334
- * MACed with a classic Appendix B HMAC (default) or an RFC 9579 PBMAC1, and re-parsed before return.
334
+ * Build a PKCS#12 (.p12 / .pfx) store with password OR public-key integrity. `spec` is either the
335
+ * OpenSSL-style convenience form `{ key, cert, ca?, friendlyName?, localKeyId? }` (one PBES2-encrypted cert
336
+ * safe plus one shrouded-key safe) or the full form `{ safeContents: [...] }`, where each element is a
337
+ * plaintext or PBES2-encrypted `SafeContents` of key / shroudedKey / cert / crl / secret / nested
338
+ * safeContents bags. Keys and certs are validated before wrapping. Password integrity (the default) MACs the
339
+ * AuthenticatedSafe with a classic Appendix B HMAC or an RFC 9579 PBMAC1. Public-key integrity
340
+ * (`opts.integrity.mode: "public-key"`) instead wraps the AuthenticatedSafe in a CMS SignedData -- a
341
+ * signature from a keypair, no MacData (RFC 7292 sec. 4). Privacy (PBES2 bag encryption via `password`) is
342
+ * independent of the integrity mode. The store is re-parsed before return.
335
343
  *
336
344
  * @opts
337
345
  * - `password` -- the shared privacy + integrity password (string / Buffer / Uint8Array).
338
346
  * - `mac` -- `false` for a MAC-less store, or `{ algorithm: 'hmac'(default)|'pbmac1', hash: 'sha256'(default)|'sha1'|'sha384'|'sha512', salt?, iterations?, keyLength? }`.
347
+ * - `integrity` -- `{ mode: 'public-key', signer: { cert, key, digestAlgorithm?, pss? } | signers: [ ... ], sid?, signingTime?, certificates? }` for public-key integrity (a CMS SignedData authSafe over any `pki.cms.sign` signer algorithm, no MacData). Combining it with a truthy `mac` is rejected.
339
348
  * - `pem` (boolean) -- return a PEM `PKCS12` string instead of DER.
340
349
  * @example
341
350
  * var p12 = await pki.pkcs12.build({ safeContents: [{ bags: [
@@ -345,21 +354,35 @@ function _normalizeSpec(spec, opts) {
345
354
  */
346
355
  async function build(spec, opts) {
347
356
  opts = opts || {};
348
- if (opts.integrity && opts.integrity.mode === "public-key") {
349
- throw _err("pkcs12/unsupported-algorithm", "public-key integrity (an id-signedData authSafe) is not yet supported -- use password integrity");
350
- }
357
+ var pubKey = opts.integrity != null && opts.integrity.mode === "public-key";
358
+ // RFC 7292 sec. 4: public-key integrity OMITS MacData entirely -- a caller combining opts.mac with it is a
359
+ // config-time reject (the self-check re-parse would otherwise fail the coherence rule anyway).
360
+ if (pubKey && opts.mac != null && opts.mac !== false) throw _err("pkcs12/bad-integrity-mode", "public-key integrity has no MacData -- do not combine opts.mac with opts.integrity.mode 'public-key' (RFC 7292 sec. 4)");
351
361
  var safeContentsSpecs = _normalizeSpec(spec, opts);
352
362
  if (!Array.isArray(safeContentsSpecs) || !safeContentsSpecs.length) throw _err("pkcs12/bad-input", "the store has no safe contents");
353
363
  var elements = [];
354
364
  for (var i = 0; i < safeContentsSpecs.length; i++) elements.push(_buildAuthSafeElement(safeContentsSpecs[i], opts));
355
- var authSafeDer = b.sequence(elements); // AuthenticatedSafe ::= SEQUENCE OF ContentInfo
356
-
357
- var pfxChildren = [b.integer(3n), b.sequence([b.oid(O("data")), b.explicit(0, b.octetString(authSafeDer))])];
358
- if (opts.mac != null && opts.mac !== false && typeof opts.mac !== "object") throw _err("pkcs12/bad-input", "opts.mac must be false or a { algorithm, hash, salt, iterations, keyLength } object");
359
- if (opts.mac !== false) pfxChildren.push(await _buildMacData(opts.mac || {}, opts.password, authSafeDer));
360
- var pfx = b.sequence(pfxChildren);
365
+ var authSafeDer = b.sequence(elements); // AuthenticatedSafe ::= SEQUENCE OF ContentInfo -- shared by both integrity modes
366
+
367
+ var pfx;
368
+ if (pubKey) {
369
+ // Public-key integrity (RFC 7292 sec. 4 / sec. 5.1 step 5A): the authSafe is a CMS SignedData whose
370
+ // id-data eContent IS the byte-exact AuthenticatedSafe DER; the signature (from a keypair, not a
371
+ // password) provides the integrity, and there is NO MacData. Compose pki.cms.sign -- so every cms.sign
372
+ // signer algorithm (RSA / ECDSA / EdDSA / ML-DSA / SLH-DSA / composite) ships for free.
373
+ var ig = opts.integrity;
374
+ var signers = ig.signers != null ? ig.signers : (ig.signer != null ? [ig.signer] : null);
375
+ if (!Array.isArray(signers) || !signers.length) throw _err("pkcs12/bad-input", "public-key integrity requires opts.integrity.signer or opts.integrity.signers (a cms.sign signer descriptor)");
376
+ var authSafeCi = await cms.sign(authSafeDer, signers, { eContentType: "data", detached: false, certificates: ig.certificates !== false, sid: ig.sid, signingTime: ig.signingTime });
377
+ pfx = b.sequence([b.integer(3n), b.raw(authSafeCi)]); // PFX ::= SEQUENCE { version v3, authSafe (id-signedData) } -- NO MacData
378
+ } else {
379
+ var pfxChildren = [b.integer(3n), b.sequence([b.oid(O("data")), b.explicit(0, b.octetString(authSafeDer))])];
380
+ if (opts.mac != null && opts.mac !== false && typeof opts.mac !== "object") throw _err("pkcs12/bad-input", "opts.mac must be false or a { algorithm, hash, salt, iterations, keyLength } object");
381
+ if (opts.mac !== false) pfxChildren.push(await _buildMacData(opts.mac || {}, opts.password, authSafeDer));
382
+ pfx = b.sequence(pfxChildren);
383
+ }
361
384
 
362
- // Self-check: the emitted store round-trips through the strict parser (structure + MAC coherence).
385
+ // Self-check: the emitted store round-trips through the strict parser (structure + MAC/signedData coherence).
363
386
  try { schemaPkcs12.parse(pfx); } catch (e) { throw _err("pkcs12/bad-input", "the produced PKCS#12 store did not re-parse (build bug)", e); }
364
387
  return opts.pem ? schemaPkcs12.pemEncode(pfx, "PKCS12") : pfx;
365
388
  }
@@ -438,20 +461,26 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
438
461
  * @defends pkcs12-unauthenticated-decrypt (CWE-347), pbes2-padding-oracle (CWE-208)
439
462
  * @related pki.pkcs12.build, pki.pkcs12.verifyMac, pki.schema.pkcs12.parse
440
463
  *
441
- * Read a password-integrity PKCS#12 store: verify its MAC FIRST (RFC 7292 sec. 5.1 -- never trust a bag from a
442
- * store whose password MAC fails), then decrypt every PBES2 privacy safe and pkcs8ShroudedKeyBag with the same
443
- * password, returning a structured bundle `{ integrityMode, macVerified, keys, certs, crls, secrets }` -- each
444
- * private key as PKCS#8 `PrivateKeyInfo` DER (re-validated), each certificate / CRL / secret as raw DER, all
445
- * carrying their `friendlyName` / `localKeyId` for pairing. `pfx` is a DER `Buffer`, PEM string, or a
464
+ * Read a PKCS#12 store: verify its integrity FIRST -- a password store's MAC, or a public-key store's CMS
465
+ * SignedData signature (RFC 7292 sec. 4 / 5.1 -- never trust a bag from a store whose integrity check fails) --
466
+ * then decrypt every PBES2 privacy safe and pkcs8ShroudedKeyBag with the password, returning a structured
467
+ * bundle `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` -- each private key as PKCS#8
468
+ * `PrivateKeyInfo` DER (re-validated), each certificate / CRL / secret as raw DER, all carrying their
469
+ * `friendlyName` / `localKeyId` for pairing. `pfx` is a DER `Buffer`, PEM string, or a
446
470
  * `pki.schema.pkcs12.parse` result.
447
471
  *
448
- * A MAC-less store is refused (`pkcs12/no-integrity`) unless `opts.allowUnauthenticated` is set; a public-key
449
- * integrity store is refused. Only PBES2 (AES-CBC) bags are decrypted -- a legacy PBE scheme is named and
450
- * refused. The password is BMPString+NULL for the MAC and UTF-8 for the PBES2 bags; a wrong password fails at
451
- * the MAC gate (`pkcs12/mac-mismatch`), and a post-MAC decrypt failure is the uniform `pkcs12/decrypt-failed`.
472
+ * A MAC-less store is refused (`pkcs12/no-integrity`) unless `opts.allowUnauthenticated` is set. A public-key
473
+ * integrity store is verified through `pki.cms.verify` before any bag is trusted; a signature failure is
474
+ * `pkcs12/signature-invalid`, and `signers` carries the per-signer verdict `[{ ok, sid, cert }]` (`null` in
475
+ * password / MAC-less mode). The signer is surfaced, NEVER trust-chained -- anchoring `signers[i].cert` to a
476
+ * trust root is the caller's `pki.path.validate` step (the out-of-path signer contract). Privacy is
477
+ * independent of integrity, so the bag `password` still decrypts a public-key store's bags; a wrong bag
478
+ * password there is the uniform `pkcs12/decrypt-failed` (no MAC to catch it first). Only PBES2 (AES-CBC) bags
479
+ * are decrypted -- a legacy PBE scheme is named and refused.
452
480
  *
453
481
  * @opts
454
482
  * - `allowUnauthenticated` (boolean) -- open a MAC-less store anyway (result carries `macVerified: false`).
483
+ * - `signerCerts` (array of cert DER) -- signer certificate(s) for a public-key store built with `certificates: false` (forwarded to `pki.cms.verify`).
455
484
  * - `maxIterations` (number) -- lower the PBKDF2 / MAC iteration cap for this call (downward-only).
456
485
  * - `keys` (string) -- `der` (default) or `crypto` (also `pki.key.import` each private key to a CryptoKey).
457
486
  * - `importAlgorithm` -- forwarded to `pki.key.import` for the ambiguous RSA / EC arms when `keys: crypto`.
@@ -466,17 +495,29 @@ async function open(pfx, password, opts) {
466
495
  throw _err("pkcs12/bad-input", "maxIterations must be a positive integer");
467
496
  }
468
497
  var m = (pfx && pfx.integrityMode !== undefined && pfx.mac !== undefined) ? pfx : schemaPkcs12.parse(_coerceDer(pfx, "pfx"));
469
- if (m.integrityMode === "public-key") throw _err("pkcs12/bad-integrity-mode", "public-key-integrity (id-signedData) stores are not supported by open");
470
498
  var macVerified = false;
471
- if (m.integrityMode === "password") {
499
+ var signers = null;
500
+ if (m.integrityMode === "public-key") {
501
+ // RFC 7292 sec. 4 / sec. 5.1 step 5B -- INTEGRITY BEFORE USE: verify the CMS SignedData signature over
502
+ // the AuthenticatedSafe BEFORE decrypting or returning any bag (the signature is the integrity gate,
503
+ // exactly as the MAC is for password mode). cms.verify hashes the exact wire eContent the parser
504
+ // dispatched the bags from (m.authSafeSigned's attached content) -- no re-serialize. The signer is
505
+ // SURFACED as a per-signer verdict, NEVER trust-chained: the caller anchors signers[i].cert via
506
+ // pki.path.validate (the out-of-path signer contract). A cert-less store supplies opts.signerCerts.
507
+ var res = await cms.verify(m.authSafeSigned, { certs: opts.signerCerts });
508
+ if (!res.valid) throw _err("pkcs12/signature-invalid", "the PKCS#12 SignedData signature did not verify (an untrusted or tampered store)");
509
+ signers = res.signers;
510
+ } else if (m.integrityMode === "password") {
472
511
  macVerified = await verifyMac(m, password, opts);
473
512
  if (!macVerified) throw _err("pkcs12/mac-mismatch", "the PKCS#12 MAC did not verify (wrong password or a tampered store)");
474
513
  } else if (!opts.allowUnauthenticated) {
475
514
  throw _err("pkcs12/no-integrity", "the store carries no integrity MAC (integrityMode " + m.integrityMode + "); set opts.allowUnauthenticated to open it anyway");
476
515
  }
477
- // The PBES2 bag/safe password is UTF-8 (the pinned interop convention), distinct from the classic MAC BMPString.
516
+ // The PBES2 bag/safe password is UTF-8 (the pinned interop convention), distinct from the classic MAC
517
+ // BMPString -- and INDEPENDENT of the public-key integrity keypair; a wrong bag password fails at the first
518
+ // encrypted bag as the uniform pkcs12/decrypt-failed (there is no MAC to catch it first in public-key mode).
478
519
  var pwBytes = _pbePassword(password);
479
- var out = { integrityMode: m.integrityMode, macVerified: macVerified, keys: [], certs: [], crls: [], secrets: [] };
520
+ var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
480
521
  var i;
481
522
  for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], pwBytes, opts, out, 0);
482
523
  for (i = 0; i < m.encryptedSafes.length; i++) _openEncryptedSafe(m.encryptedSafes[i], pwBytes, opts, out, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.14",
3
+ "version": "0.3.17",
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:7db025af-2d42-4d80-9d99-9e6ec17cd13f",
5
+ "serialNumber": "urn:uuid:d422e0bf-7707-4f70-8e04-0d467ade9599",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-24T06:23:40.600Z",
8
+ "timestamp": "2026-07-25T04:02:51.828Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.3.14",
22
+ "bom-ref": "@blamejs/pki@0.3.17",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.14",
25
+ "version": "0.3.17",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.3.14",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.17",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.3.14",
57
+ "ref": "@blamejs/pki@0.3.17",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]