@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 +27 -1
- package/README.md +3 -2
- package/index.js +11 -4
- package/lib/constants.js +8 -0
- package/lib/est.js +316 -8
- package/lib/framework-error.js +10 -0
- package/lib/http-transport.js +310 -0
- package/lib/pkcs12-build.js +72 -31
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
|
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,
|
package/lib/framework-error.js
CHANGED
|
@@ -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 };
|
package/lib/pkcs12-build.js
CHANGED
|
@@ -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
|
|
26
|
-
//
|
|
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
|
|
330
|
-
* form `{ key, cert, ca?, friendlyName?, localKeyId? }` (one PBES2-encrypted cert
|
|
331
|
-
* safe) or the full form `{ safeContents: [...] }`, where each element is a
|
|
332
|
-
* `SafeContents` of key / shroudedKey / cert / crl / secret / nested
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
-
|
|
349
|
-
|
|
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
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
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
|
|
449
|
-
* integrity store is
|
|
450
|
-
*
|
|
451
|
-
*
|
|
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
|
-
|
|
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
|
|
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
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:d422e0bf-7707-4f70-8e04-0d467ade9599",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.17",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.17",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|