@blamejs/pki 0.3.18 → 0.3.20
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 -0
- package/README.md +8 -7
- package/lib/cmp-build.js +242 -1
- package/lib/cms-decrypt.js +14 -1
- package/lib/http-transport.js +14 -1
- package/lib/ip-utils.js +11 -1
- package/lib/pkcs12-build.js +101 -19
- package/lib/pki-build.js +35 -9
- package/lib/schema-pkcs12.js +4 -1
- package/lib/sign-scheme.js +3 -1
- package/lib/webcrypto.js +13 -6
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +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.20 — 2026-07-26
|
|
8
|
+
|
|
9
|
+
PKCS#12 public-key privacy ships -- encrypt a store's contents to a recipient public key with pki.pkcs12.build/open, plus a webcrypto RSA algorithm-name fix.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- PKCS#12 public-key privacy (RFC 7292 sec. 3.1): pki.pkcs12.build encrypts a SafeContents to recipient public keys -- per-safeContents recipients: [{ cert }, ...] (certificate recipients only) with an optional contentEncryptionAlgorithm (aes-128|192|256-cbc, default 256; GCM/AEAD rejected), or the opts.recipientCerts convenience that envelopes the cert + key. It emits an id-envelopedData ContentInfo via pki.cms.encrypt, so every certificate recipient type (RSA-OAEP, ECDH P-256/384/521, X25519, X448, ML-KEM) and multiple recipients per safe carry through. Privacy is independent of the integrity mode; combining a password (encrypt) and recipients on one safe is rejected, as is a non-certificate (password/KEK) recipient.
|
|
14
|
+
- pki.pkcs12.open gains opts.recipientKey (+ opts.recipientCert or recipientIndex) to decrypt an id-envelopedData safe via pki.cms.decrypt, AFTER the MAC / SignedData integrity gate. The recipient key is a privacy credential only -- never a MAC key, signer, or bag password. A wrong key, a tampered envelope, or a decrypt that yields non-SafeContents bytes all collapse to a uniform pkcs12/decrypt-failed (oracle-free); an enveloped safe with no recipientKey is pkcs12/no-recipient-key.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- pki.webcrypto now emits the WebCrypto-registered casing on a CryptoKey's algorithm.name for RSASSA-PKCS1-v1_5 (lowercase v), matching the standard and the mixed-case Ed25519 / Ed448 it already emitted -- so the toolkit's own RSASSA-PKCS1-v1_5 CryptoKey can be passed as an x509 signer key. The x509 signer's algorithm-name match is now ASCII-case-folded as well (WebCrypto algorithm names are case-insensitive), so a CryptoKey from any source with equivalent casing is accepted.
|
|
19
|
+
|
|
20
|
+
## v0.3.19 — 2026-07-25
|
|
21
|
+
|
|
22
|
+
The CMP HTTP transfer client ships -- pki.cmp.transfer carries a protected PKIMessage to a CMP endpoint over the shared node:https transport (RFC 9811).
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- pki.cmp.transfer(url, message, opts) -- the RFC 9811 HTTP transfer verb: POST a DER (or PEM) PKIMessage over the shared pki.transport and return the parsed response { response, responseBytes, status, contentType, tls }. The message is sent verbatim so its message-layer protection is preserved, and the response is classified fail-closed: HTTP 200 with an application/pkixcmp body is parsed; another 2xx is rejected (RFC 9811 requires 200); a 3xx is not followed; a 4xx/5xx carrying a CMP error PKIMessage forwards that integrity-protected verdict with the HTTP status surfaced as data, while a 4xx/5xx with no CMP body is an error. Protection is surfaced, not verified -- the caller checks it. Composes pki.cmp.build + pki.schema.cmp.parse over pki.transport; the default transport is https-only and refuses an unpinned server.
|
|
27
|
+
- pki.cmp.wellKnownUrl(base, opts) -- build an RFC 9811 sec. 3.4 /.well-known/cmp request-URI, optionally with a { label, operation } path. Each label/operation is a single safe path segment; a base carrying a query or fragment, or a segment containing a separator or dot-segment, is refused so the resource cannot be silently retargeted.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- pki.x509.sign now accepts a subjectAltName iPAddress entry as a dotted-quad IPv4 or colon-hex IPv6 string -- packed to its 4- or 16-octet network form internally -- in addition to a pre-packed Buffer, matching the string ergonomics of dNSName and uniformResourceIdentifier. This applies to every GeneralName consumer (certificates, CRLs, CMP, attribute certificates).
|
|
32
|
+
- extendedKeyUsage and certificatePolicies now accept a raw dotted-decimal OID string directly, in addition to a registered purpose/policy name -- so an unregistered KeyPurposeId or private policy OID (a BIMI VMC purpose, a document-signing EKU, a vendor-specific purpose) can be supplied inline without first calling pki.oid.register or hand-encoding the extension. A token that is neither a registered name nor a well-formed dotted OID still fails closed.
|
|
33
|
+
|
|
7
34
|
## v0.3.18 — 2026-07-25
|
|
8
35
|
|
|
9
36
|
The ACME client ships -- pki.acme drives the full RFC 8555 certificate-issuance flow over the shared node:https transport.
|
package/README.md
CHANGED
|
@@ -217,7 +217,7 @@ is callable today; nothing below is a stub.
|
|
|
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
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
|
|
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, ACME, and CMP clients — `https` |
|
|
221
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` |
|
|
222
222
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME — `client(directoryUrl, opts)` is a stateful client that drives a live CA directory over `pki.transport` (inject your own, or the fail-closed default): `newAccount` / `newOrder` / `getOrder` / `getAuthorization` / `getChallenge` / `respondToChallenge` / `finalize` / `pollOrder` / `pollAuthorization` / `downloadCertificate` walk the issuance flow, and `revokeCert` (account-key or certificate-key signed), `keyChange` (account key rotation), `deactivateAccount` / `deactivateAuthorization`, and `renewalInfo` (ARI) round out the lifecycle — https-only for every URL, an explicit trust anchor required, a fresh single-use nonce per request with a bounded badNonce retry, POST-as-GET reads, bounded polling that sleeps on a Retry-After via an injectable sleeper (capped by a poll count and a total-wait budget), and every response body size-capped. Over the message layer it composes: resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, 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), fail-closed — `client`, `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
223
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` |
|
|
@@ -227,10 +227,10 @@ is callable today; nothing below is a stub.
|
|
|
227
227
|
| `pki.csr` | PKCS#10 certification-request issuance (RFC 2986 / RFC 2985) — `sign(spec, key, opts)` builds and signs a `CertificationRequest`: a `spec` of subject (a common-name string, an array of RDNs, or raw Name DER; may be empty), the public key being certified, an optional `extensionRequest` (requested v3 extensions — subject alternative names, key usage, extended key usage, basic constraints, certificate policies, subject key identifier, or an array of pre-encoded Extension DER — that a CA copies into the issued certificate), and an optional `challengePassword`. `key` (or `{ key }`) is the subject's own private key: the request is self-signed to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the request is returned (what `openssl req -verify` checks). The signature algorithm is resolved from the subject 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. Returns DER, or a PEM `CERTIFICATE REQUEST` with `opts.pem`; malformed input throws a typed `CsrError`. Parsing stays at `pki.schema.csr.parse` — `sign` |
|
|
228
228
|
| `pki.attrcert` | RFC 5755 attribute-certificate issuance — `sign(spec, issuer, opts)` builds and signs an `AttributeCertificate` as an Attribute Authority: a `spec` of `holder` (exactly one of an entity name, a `baseCertificateID` public-key-certificate reference, a `fromCertificate` binding derived from a certificate, or an object digest), the validity window (GeneralizedTime), an optional serial (positive, ≤ 20 octets; randomly generated when omitted), the `attributes` (the privilege syntaxes — role, clearance, group, chargingIdentity, accessIdentity, authenticationInfo — or pre-encoded Attribute DER), and optional `extensions` (auditIdentity, targetInformation, noRevAvail, aaControls, acProxying, authorityKeyIdentifier, or pre-encoded Extension DER, each with its RFC 5755 criticality). An attribute certificate is never self-signed — the `issuer` is the signing AA, supplied as `{ cert, key }` or `{ name, publicKey, key }`. The signature algorithm is resolved from the AA key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch, and the signature is verified under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE CERTIFICATE` with `opts.pem`; malformed input throws a typed `AttrCertError`. Parsing stays at `pki.schema.attrcert.parse` — `sign` |
|
|
229
229
|
| `pki.crmf` | RFC 4211 certificate-request-message issuance — `build(spec, key, opts)` assembles a `CertReqMessages`: a `spec` of `certReqId` (default 0; the RFC 9483 `-1` sentinel allowed), a `certTemplate` of the requested certificate fields (`subject`, `publicKey` — the SPKI DER of the key being certified — `validity`, requested `extensions`, an optional `version` 2), optional `controls` and `regInfo` (regToken / authenticator / utf8Pairs / oldCertID / protocolEncrKey, or pre-encoded `AttributeTypeAndValue` DER), and an optional `pop` selector. `key` (or `{ key }`) is the requester's private key — the message carries a `POPOSigningKey` proof of possession signed with the private half of `certTemplate.publicKey` (verified before the message is returned), exactly as a PKCS#10 CSR proves possession; a complete template signs the `CertRequest`, an incomplete one signs a `POPOSigningKeyInput`. The signature algorithm is resolved from the requested public key, so RSA (PKCS#1 v1.5 / PSS via `opts.pss`), ECDSA, EdDSA, ML-DSA, SLH-DSA, and the composite arms all sign without a per-algorithm branch. `key` is optional for a `raVerified` proof. Pass an array of specs for a batch; the CA-assigned template fields are never emitted. Returns DER, or a PEM block with `opts.pem`; malformed input throws a typed `CrmfError`. Parsing stays at `pki.schema.crmf.parse` — `build` |
|
|
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
|
+
| `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`. `transfer(url, message, opts)` carries a built message to a CMP endpoint over the shared `pki.transport` (RFC 9811 HTTP transfer) — one POST of the DER PKIMessage, the response classified fail-closed (200-only success, a non-200 2xx or an un-followed 3xx refused, a 4xx/5xx carrying a CMP error PKIMessage forwarded as the integrity-protected verdict) with protection surfaced not verified; `wellKnownUrl(base, opts)` builds the RFC 9811 §3.4 `/.well-known/cmp` request-URIs. Parsing stays at `pki.schema.cmp.parse` — `build`, `transfer`, `wellKnownUrl` |
|
|
231
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` |
|
|
232
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` |
|
|
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
|
+
| `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. **Public-key privacy** — per-safe `recipients` (or the `opts.recipientCerts` convenience) wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData` — never GCM) encrypting it to recipient public keys through the shipped `pki.cms.encrypt` recipient model, restricted to certificate recipients (RSA-OAEP / ECDH / X25519 / X448 / ML-KEM — a password or KEK recipient, which `open` could not reopen, is rejected); all four integrity × privacy combinations are permitted (RFC 7292 §3.1). 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; an `id-envelopedData` (public-key privacy) safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` if absent, every recipient-side fault the uniform `pkcs12/decrypt-failed`); 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` |
|
|
234
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` |
|
|
235
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` |
|
|
236
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` |
|
|
@@ -272,10 +272,11 @@ certificate and writes a DER (or `--pem`) SignedData to `--out` or stdout.
|
|
|
272
272
|
|
|
273
273
|
### What's coming
|
|
274
274
|
|
|
275
|
-
|
|
276
|
-
(`/serverkeygen`, `/csrattrs`, `/fullcmc`) and
|
|
277
|
-
enrollment, and additional NIST-on-ramp PQC
|
|
278
|
-
admits them are on the roadmap and ride this same
|
|
275
|
+
A stateful CMP transaction orchestrator (the `certConf` / `pollReq` round-trips),
|
|
276
|
+
the remaining EST network verbs (`/serverkeygen`, `/csrattrs`, `/fullcmc`) and
|
|
277
|
+
HTTP Digest auth, SCEP and CMC enrollment, and additional NIST-on-ramp PQC
|
|
278
|
+
signatures as the OID registry admits them are on the roadmap and ride this same
|
|
279
|
+
core. See
|
|
279
280
|
[ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
|
|
280
281
|
[CHANGELOG.md](CHANGELOG.md) for what has landed.
|
|
281
282
|
|
package/lib/cmp-build.js
CHANGED
|
@@ -41,6 +41,7 @@ var webcrypto = require("./webcrypto");
|
|
|
41
41
|
var pbes2 = require("./pbes2");
|
|
42
42
|
var constants = require("./constants");
|
|
43
43
|
var guard = require("./guard-all");
|
|
44
|
+
var httpTransport = require("./http-transport");
|
|
44
45
|
var frameworkError = require("./framework-error");
|
|
45
46
|
|
|
46
47
|
var CmpError = frameworkError.CmpError;
|
|
@@ -632,4 +633,244 @@ function _collectExtraCerts(opts, protCertDer) {
|
|
|
632
633
|
* { key: signerKeyPkcs8, cert: signerCertDer });
|
|
633
634
|
* pki.schema.cmp.parse(der).body.arm; // "p10cr"
|
|
634
635
|
*/
|
|
635
|
-
|
|
636
|
+
|
|
637
|
+
// ---- the thin RFC 9811 HTTP transfer verb: POST a DER PKIMessage over the shared pki.transport ----
|
|
638
|
+
//
|
|
639
|
+
// RFC 9811 defines exactly ONE client operation -- POST a DER PKIMessage, receive a DER PKIMessage -- so
|
|
640
|
+
// every CMP exchange (ir / p10cr / certConf / pollReq / rr / genm ...) rides this single stateless verb;
|
|
641
|
+
// the caller builds+protects the message upstream with pki.cmp.build and hands the finished bytes here,
|
|
642
|
+
// which are POSTed VERBATIM (the protection covers them). No socket is opened by this codec -- the verb
|
|
643
|
+
// composes pki.schema.cmp.parse over pki.transport, exactly as pki.est / pki.acme wire their message layers.
|
|
644
|
+
|
|
645
|
+
var DEFAULT_TRANSFER_TIMEOUT = constants.TIME.seconds(30);
|
|
646
|
+
var MAX_TRANSFER_TIMEOUT = constants.TIME.seconds(600);
|
|
647
|
+
var PKIXCMP = "application/pkixcmp";
|
|
648
|
+
var PKIXCMP_POLL = "application/pkixcmp-poll"; // the legacy type handled "like application/pkixcmp" (RFC 9811 sec. 4)
|
|
649
|
+
var KNOWN_TRANSFER_OPTS = { transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1 };
|
|
650
|
+
|
|
651
|
+
// The DER of a caller-supplied PKIMessage: a DER Buffer / Uint8Array re-viewed through guard.bytes.view
|
|
652
|
+
// (sent VERBATIM -- protection covers these exact bytes), a PEM "CMP" string decoded, else a config-time
|
|
653
|
+
// cmp/bad-input before the wire. The re-view fails a DETACHED backing buffer (a transferred / structuredClone'd
|
|
654
|
+
// view reads as zero-length) as cmp/bad-input rather than silently POSTing an empty body in its place.
|
|
655
|
+
function _cmpMessageDer(input) {
|
|
656
|
+
var der;
|
|
657
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) der = guard.bytes.view(input, CmpError, "cmp/bad-input", "the CMP message DER");
|
|
658
|
+
else if (typeof input === "string") {
|
|
659
|
+
// Normalize a PEM decode failure (no block, wrong armor label, malformed base64) to the stable
|
|
660
|
+
// cmp/bad-input at this input boundary, rather than leaking the PEM layer's PemError to the caller.
|
|
661
|
+
try { der = cmp.pemDecode(input); }
|
|
662
|
+
catch (e) { throw _err("cmp/bad-input", "the CMP message is not a valid PEM CMP block", e); }
|
|
663
|
+
} else throw _err("cmp/bad-input", "a CMP message must be a DER Buffer/Uint8Array or a PEM CMP string");
|
|
664
|
+
// Validate the bytes ARE a PKIMessage before the wire (transfer accepts a DER PKIMessage), so malformed
|
|
665
|
+
// input fails locally as cmp/bad-input rather than crossing the network seam. parse validates only -- the
|
|
666
|
+
// ORIGINAL der is returned for the verbatim POST (protection covers the exact bytes; nothing re-serialized).
|
|
667
|
+
try { cmp.parse(der); }
|
|
668
|
+
catch (e) { throw _err("cmp/bad-input", "the CMP message is not a valid PKIMessage", e); }
|
|
669
|
+
return der;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Map opts.tls (operator-facing) to the transport request.tls shape. rejectUnauthorized is NOT here --
|
|
673
|
+
// the transport forces it on unconditionally; the verb never disables server verification.
|
|
674
|
+
function _tlsForTransfer(opts) {
|
|
675
|
+
var t = opts.tls || {};
|
|
676
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// The response content-type token (before any ;-parameters), lowercased; is it a CMP media type?
|
|
680
|
+
function _ctypeToken(headers) { return String((headers || {})["content-type"] || "").split(";")[0].trim().toLowerCase(); }
|
|
681
|
+
function _isPkixcmp(headers) { var t = _ctypeToken(headers); return t === PKIXCMP || t === PKIXCMP_POLL; }
|
|
682
|
+
|
|
683
|
+
// Classify the CMP HTTP response (RFC 9811 sec. 3.1/3.2/4). 200 + a pkixcmp body -> parse + resolve;
|
|
684
|
+
// another 2xx -> reject (a request response MUST be 200, sec. 3.1); 3xx -> not followed (sec. 3.1/5);
|
|
685
|
+
// 4xx/5xx WITH a well-formed CMP body -> FORWARD the integrity-protected verdict (sec. 1.2/3.1), else
|
|
686
|
+
// cmp/http-error. The HTTP status is surfaced as DATA, never used to set the transaction verdict (sec. 5).
|
|
687
|
+
// `body` is always a Buffer (the verb normalizes it before the size cap).
|
|
688
|
+
function _classifyCmpResponse(status, headers, body, tls) {
|
|
689
|
+
var ctype = _ctypeToken(headers);
|
|
690
|
+
if (status === 200) {
|
|
691
|
+
if (!_isPkixcmp(headers)) throw _err("cmp/bad-content-type", "a 200 CMP response must be application/pkixcmp (RFC 9811 sec. 3.2), got " + JSON.stringify(ctype || null));
|
|
692
|
+
if (body.length === 0) throw _err("cmp/empty-response", "a 200 CMP response carried an empty body (RFC 9811 sec. 3.3)");
|
|
693
|
+
return { response: cmp.parse(body), responseBytes: body, status: 200, contentType: ctype, tls: tls }; // a malformed body surfaces the parser's fail-closed cmp/* verdict
|
|
694
|
+
}
|
|
695
|
+
// Below 200, every branch requires a valid HTTP status INTEGER -- a missing / non-numeric / out-of-range
|
|
696
|
+
// status from an injected transport must not fall through to the forward branch as if it were a 4xx/5xx.
|
|
697
|
+
var httpStatus = (typeof status === "number" && Number.isSafeInteger(status) && status >= 100 && status <= 599) ? status : null;
|
|
698
|
+
if (httpStatus !== null && httpStatus >= 300 && httpStatus < 400) throw _err("cmp/redirect-not-followed", "a CMP " + httpStatus + " redirect is not followed (RFC 9811 sec. 3.1, sec. 5); reconfigure the endpoint URL");
|
|
699
|
+
if (httpStatus !== null && httpStatus >= 400 && httpStatus <= 599) {
|
|
700
|
+
if (_isPkixcmp(headers) && body.length > 0) {
|
|
701
|
+
var forwarded;
|
|
702
|
+
try { forwarded = cmp.parse(body); }
|
|
703
|
+
catch (e) { throw _err("cmp/http-error", "the CMP server returned HTTP " + httpStatus + " with an undecodable body", e); }
|
|
704
|
+
// Only a CMP `error` message is a coherent verdict to forward on an HTTP failure. A 4xx/5xx carrying a
|
|
705
|
+
// non-error arm (a granting `ip`, a `pkiconf`) is contradictory -- the HTTP layer says failure while the
|
|
706
|
+
// CMP body says success -- so it is NOT accepted as a usable response; the HTTP failure stands.
|
|
707
|
+
if (forwarded.body.arm !== "error") throw _err("cmp/http-error", "the CMP server returned HTTP " + httpStatus + " with a non-error '" + forwarded.body.arm + "' body; an HTTP failure forwards only a CMP error message (RFC 9811 sec. 1.2/3.1)");
|
|
708
|
+
return { response: forwarded, responseBytes: body, status: httpStatus, contentType: ctype, tls: tls };
|
|
709
|
+
}
|
|
710
|
+
throw _err("cmp/http-error", "the CMP server returned HTTP " + httpStatus + " with no forwardable CMP error body");
|
|
711
|
+
}
|
|
712
|
+
// Any other status -- a non-200 2xx, a 1xx informational, or a non-numeric / out-of-range value.
|
|
713
|
+
throw _err("cmp/unexpected-status", "a CMP response must be HTTP 200 (or a 4xx/5xx carrying a CMP error), not " + JSON.stringify(status) + " (RFC 9811 sec. 3.1)");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function transfer(url, message, opts) {
|
|
717
|
+
return Promise.resolve().then(function () { return _transfer(url, message, opts); });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function _transfer(url, message, opts) {
|
|
721
|
+
opts = opts || {};
|
|
722
|
+
Object.keys(opts).forEach(function (k) { if (!KNOWN_TRANSFER_OPTS[k]) throw _err("cmp/bad-input", "unknown opts field " + JSON.stringify(k)); });
|
|
723
|
+
var der = _cmpMessageDer(message); // config gate: before any transport call
|
|
724
|
+
var parsedUrl;
|
|
725
|
+
try { parsedUrl = new URL(String(url)); } // parse only -- NO client scheme gate (the transport owns socket security)
|
|
726
|
+
catch (e) { throw _err("cmp/bad-url", "the CMP URL did not parse: " + String(url), e); }
|
|
727
|
+
var transport = opts.transport;
|
|
728
|
+
if (!transport) {
|
|
729
|
+
var t = opts.tls || {};
|
|
730
|
+
var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
|
|
731
|
+
if (!hasAnchors && t.useSystemStore !== true) throw _err("cmp/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned server (RFC 9811 sec. 5)");
|
|
732
|
+
transport = httpTransport.https({ E: _err, errPrefix: "cmp" });
|
|
733
|
+
}
|
|
734
|
+
var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_TRANSFER_TIMEOUT, { E: _err, code: "cmp/bad-input", min: 1, max: MAX_TRANSFER_TIMEOUT });
|
|
735
|
+
var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", constants.LIMITS.HTTP_MAX_RESPONSE_BYTES, { E: _err, code: "cmp/bad-input", min: 1, max: constants.LIMITS.HTTP_MAX_RESPONSE_BYTES });
|
|
736
|
+
// Build the request headers: forward custom opts.headers but STRIP the request-framing headers
|
|
737
|
+
// (content-length, transfer-encoding) and ANY content-type case variant -- the verb sets the content-type
|
|
738
|
+
// and the transport computes Content-Length from the exact body, so a caller cannot desync the request
|
|
739
|
+
// framing (HTTP request smuggling via a short/long content-length) or override the media type through
|
|
740
|
+
// header-name casing (a "Content-Type" that Object.assign would keep alongside the lowercase one).
|
|
741
|
+
var headers = {};
|
|
742
|
+
Object.keys(opts.headers || {}).forEach(function (k) {
|
|
743
|
+
var lk = k.toLowerCase();
|
|
744
|
+
if (lk !== "content-length" && lk !== "transfer-encoding" && lk !== "content-type") headers[k] = opts.headers[k];
|
|
745
|
+
});
|
|
746
|
+
headers["content-type"] = PKIXCMP;
|
|
747
|
+
var tls = _tlsForTransfer(opts);
|
|
748
|
+
return Promise.resolve(transport({ method: "POST", url: parsedUrl.href, headers: headers, body: der, tls: tls, timeout: timeout, maxResponseBytes: maxResponseBytes })).then(function (res) {
|
|
749
|
+
res = res || {};
|
|
750
|
+
var h = {};
|
|
751
|
+
Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
|
|
752
|
+
// Normalize the response body to a Buffer preserving its exact bytes: a binary view (Buffer / Uint8Array
|
|
753
|
+
// from an injected transport) is re-viewed through the byte guard, as the request path does; only a
|
|
754
|
+
// genuine string body falls back to a UTF-8 encode (the width a real socket already counts) so the size
|
|
755
|
+
// cap and the parser see the same bytes -- a typed array is not stringified to "48,130,..." garbage.
|
|
756
|
+
var body = (Buffer.isBuffer(res.body) || res.body instanceof Uint8Array)
|
|
757
|
+
? guard.bytes.view(res.body, CmpError, "cmp/bad-response", "the CMP response body")
|
|
758
|
+
: Buffer.from(String(res.body == null ? "" : res.body), "utf8");
|
|
759
|
+
if (body.length > maxResponseBytes) throw _err("cmp/response-too-large", "the response body (" + body.length + " bytes) exceeds the " + maxResponseBytes + "-byte cap");
|
|
760
|
+
return _classifyCmpResponse(res.status, h, body, res.tls || null);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// A single safe path segment for a well-known URI: no separator, dot-segment, or reserved/space char that
|
|
765
|
+
// would retarget the resource; percent-encoded on the way out. Fails closed (guards never guess a value).
|
|
766
|
+
function _wellKnownSeg(v, name) {
|
|
767
|
+
var s = String(v);
|
|
768
|
+
if (s === "" || s === "." || s === ".." || /[/?#\s\\]/.test(s)) throw _err("cmp/bad-url", "the CMP " + name + " must be a single safe path segment (no '/', dot-segment, or reserved char): " + JSON.stringify(s));
|
|
769
|
+
// encodeURIComponent throws a raw URIError (no .code) on an unpaired UTF-16 surrogate; normalize it to the
|
|
770
|
+
// helper's cmp/bad-url contract so a malformed-config segment never escapes as an untyped error.
|
|
771
|
+
try { return encodeURIComponent(s); }
|
|
772
|
+
catch (e) { throw _err("cmp/bad-url", "the CMP " + name + " is not encodable (e.g. an unpaired surrogate): " + JSON.stringify(s), e); }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
var KNOWN_WELLKNOWN_OPTS = { label: 1, operation: 1 };
|
|
776
|
+
|
|
777
|
+
function wellKnownUrl(base, opts) {
|
|
778
|
+
opts = opts || {};
|
|
779
|
+
// Reject an unknown option key (a typo like { lable } / { operaton }), as build/transfer do -- a silently
|
|
780
|
+
// ignored option would build the DEFAULT endpoint and send the request to a different CA/profile.
|
|
781
|
+
Object.keys(opts).forEach(function (k) { if (!KNOWN_WELLKNOWN_OPTS[k]) throw _err("cmp/bad-input", "unknown wellKnownUrl option " + JSON.stringify(k) + " (expected label / operation)"); });
|
|
782
|
+
var u;
|
|
783
|
+
try { u = new URL(String(base)); }
|
|
784
|
+
catch (e) { throw _err("cmp/bad-url", "the CMP base URL did not parse: " + String(base), e); }
|
|
785
|
+
// Require an http(s) authority: a non-HTTP scheme (file:, data:, a custom scheme) has an opaque origin
|
|
786
|
+
// ("null"), which would build a nonsensical "null/.well-known/cmp". A well-known URI is an HTTP concept
|
|
787
|
+
// (RFC 8615); https vs http is left to the transport (Build decision 6), but the scheme must be one of them.
|
|
788
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") throw _err("cmp/bad-url", "the CMP base URL must be http or https (a well-known URI is authority-rooted over HTTP), got " + u.protocol + ": " + JSON.stringify(String(base)));
|
|
789
|
+
if (u.search || u.hash) throw _err("cmp/bad-url", "the CMP base URL must not carry a query or fragment component (RFC 9811 sec. 3.4)");
|
|
790
|
+
// A backslash has no place in an http(s) authority: WHATWG rewrites it to a path separator (so
|
|
791
|
+
// `https://ca.example\tenant` is really the path `/tenant`), but the raw-path regex below excludes only
|
|
792
|
+
// `/?#` and would swallow it as authority, slipping a supplied path past the check. Reject it outright.
|
|
793
|
+
if (/\\/.test(String(base))) throw _err("cmp/bad-url", "the CMP base URL must not contain a backslash (WHATWG rewrites it to a path separator): " + JSON.stringify(String(base)));
|
|
794
|
+
// An RFC 8615 well-known URI is AUTHORITY-ROOTED (/.well-known/...), so a base carrying ANY path is
|
|
795
|
+
// rejected fail-closed -- an operator whose CMP endpoint sits under a path passes that full operation URL
|
|
796
|
+
// to transfer() directly. Inspect the RAW path in the source string, NOT url.pathname: WHATWG normalizes
|
|
797
|
+
// dot-segments (`/tenant/..`, `/%2e`) to "/" before the parse, which would slip a supplied path past a
|
|
798
|
+
// pathname check and silently target the authority-root profile instead of reporting the misconfiguration.
|
|
799
|
+
var rawBasePath = String(base).replace(/^[a-z]+:\/\/[^/?#]*/i, "").split(/[?#]/)[0];
|
|
800
|
+
if (rawBasePath !== "" && rawBasePath !== "/") throw _err("cmp/bad-url", "an RFC 8615 well-known URI is authority-rooted; the CMP base URL must have no path component (pass a full operation URL to transfer instead): " + JSON.stringify(String(base)));
|
|
801
|
+
var segs = [".well-known", "cmp"];
|
|
802
|
+
if (opts.label != null) { segs.push("p"); segs.push(_wellKnownSeg(opts.label, "label")); }
|
|
803
|
+
if (opts.operation != null) segs.push(_wellKnownSeg(opts.operation, "operation"));
|
|
804
|
+
return u.origin + "/" + segs.join("/");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* @primitive pki.cmp.transfer
|
|
809
|
+
* @signature pki.cmp.transfer(url, message, opts?) -> Promise<{ response, responseBytes, status, contentType, tls }>
|
|
810
|
+
* @since 0.3.19
|
|
811
|
+
* @status experimental
|
|
812
|
+
* @spec RFC 9811, RFC 9810
|
|
813
|
+
* @related pki.cmp.build, pki.cmp.wellKnownUrl, pki.schema.cmp.parse, pki.transport.https
|
|
814
|
+
*
|
|
815
|
+
* POST a DER `PKIMessage` to a CMP endpoint and return the parsed response `PKIMessage`, over the shared
|
|
816
|
+
* `pki.transport`. RFC 9811 transfers every CMP exchange identically -- one HTTP POST of a DER PKIMessage,
|
|
817
|
+
* one response PKIMessage -- so a single stateless verb carries `ir` / `cr` / `kur` / `p10cr` / `certConf`
|
|
818
|
+
* / `pollReq` / `rr` / `genm` and their responses; the caller builds and protects the message upstream with
|
|
819
|
+
* `pki.cmp.build` and hands the finished bytes here. `message` is a DER `Buffer`/`Uint8Array` or a PEM `CMP`
|
|
820
|
+
* string, sent VERBATIM (the message-layer protection covers these exact bytes -- they are never re-encoded).
|
|
821
|
+
* The response is classified fail-closed: HTTP 200 carrying an `application/pkixcmp` body is parsed and
|
|
822
|
+
* resolved; another 2xx is `cmp/unexpected-status` (RFC 9811 requires 200); a 3xx is `cmp/redirect-not-followed`
|
|
823
|
+
* (never auto-followed, sec. 3.1/5); a 4xx/5xx carrying a well-formed CMP `error` PKIMessage FORWARDS that
|
|
824
|
+
* integrity-protected verdict (sec. 1.2/3.1) with the HTTP status surfaced as data, while a 4xx/5xx that is
|
|
825
|
+
* not a CMP `error` message (no body, an undecodable body, or a non-error arm) is `cmp/http-error`.
|
|
826
|
+
* Protection is SURFACED, not verified -- the client confers no trust; the
|
|
827
|
+
* caller (or a future `pki.cmp.verify`) checks the response protection using the raw `headerBytes`/`bodyBytes`.
|
|
828
|
+
* By default the transport is https-only and requires an explicit trust anchor; there is no client scheme
|
|
829
|
+
* gate, so an operator who injects an http-capable transport reaches the RFC-9811-permitted plain-HTTP path.
|
|
830
|
+
*
|
|
831
|
+
* @opts
|
|
832
|
+
* - `transport` -- an injectable `transport(request) -> Promise<{status,headers,body}>` (default
|
|
833
|
+
* `pki.transport.https`, which fail-closes on a non-https URL and an unpinned server).
|
|
834
|
+
* - `tls` -- `{ anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }` for the
|
|
835
|
+
* default transport (mutual-TLS `cert`/`key` is common for CMP in addition to message protection).
|
|
836
|
+
* - `headers` -- extra request headers. The `content-type: application/pkixcmp` is always set and not
|
|
837
|
+
* overridable (any casing), and the request-framing headers (`content-length`, `transfer-encoding`) are
|
|
838
|
+
* stripped -- the transport computes Content-Length from the exact body, so a caller cannot desync framing.
|
|
839
|
+
* - `timeout` -- ms (default 30s); `maxResponseBytes` -- streaming cap, tightenable downward only.
|
|
840
|
+
* @example
|
|
841
|
+
* var reqDer = await pki.cmp.build(
|
|
842
|
+
* { header: { sender: { directoryName: "CN=client" }, recipient: { directoryName: "CN=CA" } },
|
|
843
|
+
* body: { p10cr: csrDer } },
|
|
844
|
+
* { key: signerKeyPkcs8, cert: signerCertDer });
|
|
845
|
+
* var url = pki.cmp.wellKnownUrl("https://ca.example", { operation: "p10cr" });
|
|
846
|
+
* var res = await pki.cmp.transfer(url, reqDer, { transport: transport });
|
|
847
|
+
* res.response.body.arm; // "ip" | "cp" | "error"
|
|
848
|
+
*/
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* @primitive pki.cmp.wellKnownUrl
|
|
852
|
+
* @signature pki.cmp.wellKnownUrl(base, opts?) -> string
|
|
853
|
+
* @since 0.3.19
|
|
854
|
+
* @status experimental
|
|
855
|
+
* @spec RFC 9811, RFC 8615
|
|
856
|
+
* @related pki.cmp.transfer
|
|
857
|
+
*
|
|
858
|
+
* Build an RFC 9811 sec. 3.4 CMP request-URI under the `/.well-known/cmp` prefix (RFC 8615). The four forms:
|
|
859
|
+
* `<base>/.well-known/cmp`, `.../<operation>`, `.../p/<label>`, and `.../p/<label>/<operation>`. A pure string
|
|
860
|
+
* builder over an authority-rooted well-known path (RFC 8615) -- https vs http is left to the transport (both
|
|
861
|
+
* forms are accepted, RFC 9811 sec. 3.4), but the `base` MUST be an http(s) URL. It rejects (`cmp/bad-url`) an
|
|
862
|
+
* unparseable `base`, a non-http(s) scheme (`file:`/`data:`/a custom scheme has an opaque origin), a `base`
|
|
863
|
+
* carrying a path / query / fragment (a well-known URI is authority-rooted; a path would capture it), and a
|
|
864
|
+
* `label`/`operation` that is empty, a dot-segment, or contains a `/` or other reserved char (never silently
|
|
865
|
+
* retarget the resource). The `operation` label is a caller-supplied string, not validated against a profile
|
|
866
|
+
* vocabulary (RFC 9483 is out of scope).
|
|
867
|
+
*
|
|
868
|
+
* @opts
|
|
869
|
+
* - `label` -- an optional CA/RA name inserted as `.../p/<label>` (a single percent-encoded path segment).
|
|
870
|
+
* - `operation` -- an optional operation label appended as the final path segment.
|
|
871
|
+
* @example
|
|
872
|
+
* pki.cmp.wellKnownUrl("https://ca.example"); // "https://ca.example/.well-known/cmp"
|
|
873
|
+
* pki.cmp.wellKnownUrl("https://ca.example", { label: "myca", operation: "cr" });
|
|
874
|
+
* // // ".../.well-known/cmp/p/myca/cr"
|
|
875
|
+
*/
|
|
876
|
+
module.exports = { build: build, transfer: transfer, wellKnownUrl: wellKnownUrl };
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -46,7 +46,20 @@ async function decrypt(input, keyMaterial, opts) {
|
|
|
46
46
|
if (ct === "encryptedData") return _decryptEncryptedData(parsed, keyMaterial, opts);
|
|
47
47
|
if (ct === "authData") return _verifyAuthenticatedData(parsed, keyMaterial, opts);
|
|
48
48
|
if (ct !== "envelopedData" && ct !== "authEnvelopedData") throw _err("cms/bad-input", "input is not an EnvelopedData / AuthEnvelopedData / EncryptedData / AuthenticatedData (got " + ct + ")");
|
|
49
|
+
return decryptEnvelopedData(parsed, keyMaterial, opts, ct);
|
|
50
|
+
}
|
|
49
51
|
|
|
52
|
+
// The post-parse decrypt of an ALREADY-PARSED EnvelopedData / AuthEnvelopedData: select a recipient, acquire
|
|
53
|
+
// the CEK, open the content -- oracle-free (every secret-dependent failure collapses to cms/decrypt-failed).
|
|
54
|
+
// @internal -- NOT re-exported on pki.cms. Taking the parsed structure (rather than DER) is a DELIBERATE seam
|
|
55
|
+
// for a sibling composer that already holds a strictly-walked content node it must NOT re-serialize/re-parse:
|
|
56
|
+
// pki.pkcs12.open drives this off the pkcs12 parser's walkEnvelopedData result so an RFC 7292 privacy safe
|
|
57
|
+
// whose EnvelopedData is BER-encoded (indefinite lengths, some NSS output -- accepted by the pkcs12 parser)
|
|
58
|
+
// opens without hitting the public decrypt()'s strict-DER re-parse. Callers pass a TRUSTED parse result only.
|
|
59
|
+
async function decryptEnvelopedData(parsed, keyMaterial, opts, contentTypeName) {
|
|
60
|
+
opts = opts || {};
|
|
61
|
+
if (keyMaterial == null || typeof keyMaterial !== "object") throw _err("cms/bad-input", "decrypt requires a key-material object");
|
|
62
|
+
var ct = contentTypeName || "envelopedData";
|
|
50
63
|
var recips = parsed.recipientInfos || [];
|
|
51
64
|
var candidates = _selectCandidates(recips, keyMaterial, opts); // stage 1 (typed, distinct)
|
|
52
65
|
var eci = parsed.encryptedContentInfo;
|
|
@@ -583,4 +596,4 @@ function _normCertDer(cert) {
|
|
|
583
596
|
throw _err("cms/bad-input", "the recipient certificate must be a DER Buffer or PEM string");
|
|
584
597
|
}
|
|
585
598
|
|
|
586
|
-
module.exports = { decrypt: decrypt };
|
|
599
|
+
module.exports = { decrypt: decrypt, decryptEnvelopedData: decryptEnvelopedData };
|
package/lib/http-transport.js
CHANGED
|
@@ -178,6 +178,19 @@ function httpsTransport(defaults) {
|
|
|
178
178
|
var body = request.body;
|
|
179
179
|
if (Buffer.isBuffer(body)) body = guard.bytes.view(body, E, C("bad-input"), "the request body");
|
|
180
180
|
|
|
181
|
+
// Frame a body-bearing request with a fixed Content-Length computed from the EXACT body, so it is sent
|
|
182
|
+
// length-delimited rather than Transfer-Encoding: chunked (node's default when a body is written with no
|
|
183
|
+
// Content-Length -- which strict enrollment / CMP appliances reject). A caller-supplied content-length
|
|
184
|
+
// (any case) is dropped: the transport owns request framing and never trusts a possibly-mismatched value.
|
|
185
|
+
var reqHeaders = Object.assign({}, request.headers || {});
|
|
186
|
+
if (body != null && body !== "") {
|
|
187
|
+
// Drop BOTH caller-supplied framing headers before installing the fixed length: node rejects a request
|
|
188
|
+
// carrying Content-Length AND Transfer-Encoding together (HPE_INVALID_CONTENT_LENGTH), and the transport
|
|
189
|
+
// owns framing -- it never honors a caller's chunked directive or a mismatched length.
|
|
190
|
+
Object.keys(reqHeaders).forEach(function (k) { var lk = k.toLowerCase(); if (lk === "content-length" || lk === "transfer-encoding") delete reqHeaders[k]; });
|
|
191
|
+
reqHeaders["Content-Length"] = Buffer.byteLength(body);
|
|
192
|
+
}
|
|
193
|
+
|
|
181
194
|
// URL.hostname keeps the square brackets on an IPv6 literal ([2001:db8::1]); node's `hostname`
|
|
182
195
|
// option and its IP-detection expect the bare address, so strip the brackets once here.
|
|
183
196
|
var host = url.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
@@ -186,7 +199,7 @@ function httpsTransport(defaults) {
|
|
|
186
199
|
hostname: host,
|
|
187
200
|
port: url.port || 443,
|
|
188
201
|
path: url.pathname + url.search,
|
|
189
|
-
headers:
|
|
202
|
+
headers: reqHeaders,
|
|
190
203
|
minVersion: minVersion,
|
|
191
204
|
rejectUnauthorized: true, // ALWAYS on -- there is no code path that disables server verification
|
|
192
205
|
// Opt out of connection pooling: node's keep-alive agent keys the socket pool on host + TLS
|
package/lib/ip-utils.js
CHANGED
|
@@ -56,4 +56,14 @@ function expandIpv6Hex(ip) {
|
|
|
56
56
|
// Is `s` a syntactically valid IPv4 or IPv6 textual literal?
|
|
57
57
|
function isIpLiteral(s) { return isIPv4(s) || expandIpv6Hex(s) !== null; }
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// Pack an IPv4/IPv6 textual literal to its network-order octets -- 4 for IPv4, 16 for IPv6 --
|
|
60
|
+
// or null when `s` is not a valid literal. The binary inverse of the textual forms isIPv4 /
|
|
61
|
+
// expandIpv6Hex validate, so a SAN/GeneralName iPAddress (RFC 5280 sec. 4.2.1.6, always a bare
|
|
62
|
+
// host address of 4 or 16 octets) can be given as a string instead of a pre-packed Buffer.
|
|
63
|
+
function packIpLiteral(s) {
|
|
64
|
+
if (isIPv4(s)) return Buffer.from(s.split(".").map(Number)); // each octet already range-checked by IPV4_RE
|
|
65
|
+
var hex = expandIpv6Hex(s);
|
|
66
|
+
return hex === null ? null : Buffer.from(hex, "hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { isIPv4: isIPv4, expandIpv6Hex: expandIpv6Hex, isIpLiteral: isIpLiteral, packIpLiteral: packIpLiteral, IPV4_RE: IPV4_RE };
|
package/lib/pkcs12-build.js
CHANGED
|
@@ -25,9 +25,12 @@
|
|
|
25
25
|
// trap designed out. Public-key integrity (an id-signedData authSafe -- a CMS SignedData over the exact
|
|
26
26
|
// AuthenticatedSafe, no MacData) is produced by composing pki.cms.sign and verified on open by pki.cms.verify
|
|
27
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
|
|
29
|
-
//
|
|
30
|
-
// decrypt
|
|
28
|
+
// pki.path.validate step). Public-key PRIVACY (an id-envelopedData safe encrypting the SafeContents to a
|
|
29
|
+
// recipient public key, RFC 7292 sec. 3.1) is produced by composing pki.cms.encrypt (AES-CBC EnvelopedData,
|
|
30
|
+
// never GCM/AuthEnvelopedData) and opened by composing pki.cms.decrypt with the recipient key -- AFTER the
|
|
31
|
+
// integrity gate, with every recipient-side fault collapsed to the uniform pkcs12/decrypt-failed. Privacy is
|
|
32
|
+
// independent of integrity: all four combinations (password/public-key integrity x password/public-key privacy)
|
|
33
|
+
// are permitted, and the recipient key is a privacy credential only -- never a MAC key, signer, or PBES2 password.
|
|
31
34
|
|
|
32
35
|
var nodeCrypto = require("crypto");
|
|
33
36
|
var asn1 = require("./asn1-der");
|
|
@@ -37,7 +40,8 @@ var pkcs8 = require("./schema-pkcs8");
|
|
|
37
40
|
var x509 = require("./schema-x509");
|
|
38
41
|
var schemaCrl = require("./schema-crl");
|
|
39
42
|
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)
|
|
43
|
+
var cms = require("./cms-verify"); // pki.cms.sign (build the id-signedData authSafe) + pki.cms.verify (the open integrity gate) + cms.encrypt (the privacy envelope)
|
|
44
|
+
var cmsDecrypt = require("./cms-decrypt"); // the @internal decryptEnvelopedData seam -- opens a privacy safe off the pkcs12 parser's already-walked (BER-capable) EnvelopedData node
|
|
41
45
|
var schemaPkcs12 = require("./schema-pkcs12");
|
|
42
46
|
var pkix = require("./schema-pkix");
|
|
43
47
|
var guard = require("./guard-all");
|
|
@@ -55,9 +59,14 @@ var DEFAULT_MAC_ITER = 2048;
|
|
|
55
59
|
var DEFAULT_PBMAC1_ITER = 2048;
|
|
56
60
|
var MAC_SALT_BYTES = 8;
|
|
57
61
|
var MAX_PBMAC1_KEYLEN = 1024; // an HMAC key beyond a hash block is pointless -- bound the derived length
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
62
|
+
// Both MAC paths cap iterations to bound the SYNCHRONOUS derivation that blocks the event loop, but at
|
|
63
|
+
// different counts BECAUSE the per-iteration cost differs by ~5x -- the two ceilings target a comparable
|
|
64
|
+
// ~1-2 second wall-clock budget, not inconsistent DoS budgets. The classic Appendix B KDF is a hand-rolled
|
|
65
|
+
// JS hash loop (a createHash per iteration, ~1.1us each, so 1e6 ~= 1s); PBMAC1 derives via native
|
|
66
|
+
// crypto.pbkdf2Sync (one FFI call, ~0.2us per iteration, so the shared C.LIMITS.PBKDF2_MAX_ITERATIONS of 1e7
|
|
67
|
+
// ~= 2s). The 10x iteration-count gap is thus calibrated to wall clock; additionally the classic count is
|
|
68
|
+
// pkcs12-local (its KDF is bespoke here) while PBMAC1 reuses the toolkit-wide PBKDF2 ceiling. 1e6 is ~500x
|
|
69
|
+
// the OpenSSL default (2048) yet still bounds the loop to ~1 second.
|
|
61
70
|
var CLASSIC_MAC_MAX_ITERATIONS = 1000000;
|
|
62
71
|
|
|
63
72
|
// The classic App. B.2 KDF (u = hash output bytes, v = compression block bytes) per RFC 7292 App. B.2.
|
|
@@ -250,14 +259,41 @@ function _buildSafeContents(bags, opts, depth) {
|
|
|
250
259
|
return b.sequence(bags.map(function (bag) { return _buildBag(bag, opts, depth); }));
|
|
251
260
|
}
|
|
252
261
|
|
|
253
|
-
// One AuthenticatedSafe element: a plaintext id-data ContentInfo,
|
|
254
|
-
// EncryptedData is the PBES2 encryption of the SafeContents (RFC 7292 sec. 5.1 step 2A/2B)
|
|
255
|
-
|
|
262
|
+
// One AuthenticatedSafe element: a plaintext id-data ContentInfo, an id-encryptedData ContentInfo whose
|
|
263
|
+
// EncryptedData is the PBES2 encryption of the SafeContents (RFC 7292 sec. 5.1 step 2A/2B), or -- for
|
|
264
|
+
// PUBLIC-KEY privacy (sec. 3.1) -- an id-envelopedData ContentInfo whose CMS EnvelopedData encrypts the
|
|
265
|
+
// SafeContents to one or more recipient public keys. Async because the envelope path awaits pki.cms.encrypt.
|
|
266
|
+
async function _buildAuthSafeElement(sc, opts) {
|
|
256
267
|
if (!sc || typeof sc !== "object") throw _err("pkcs12/bad-input", "each safeContents entry must be an object");
|
|
268
|
+
// A privacy directive is honored whenever its field is PRESENT (even if falsy). A present-but-falsy `recipients`
|
|
269
|
+
// or `encrypt` (null / false / "") is a configuration error that intended privacy -- it must fail closed, NEVER
|
|
270
|
+
// silently fall through to a plaintext id-data safe that would emit the bags in cleartext (privacy downgraded to
|
|
271
|
+
// none while still producing a valid MAC-protected PFX). Omit the field entirely for a genuinely plaintext safe.
|
|
272
|
+
var hasRecipients = sc.recipients !== undefined;
|
|
273
|
+
var hasEncrypt = sc.encrypt !== undefined;
|
|
274
|
+
if (hasEncrypt && hasRecipients) throw _err("pkcs12/bad-input", "a safeContents cannot combine encrypt (password) and recipients (public-key) -- one ContentInfo is one privacy type (RFC 7292 sec. 4.1)");
|
|
257
275
|
var safeContentsDer = _buildSafeContents(sc.bags || [], opts, 0);
|
|
258
|
-
if (
|
|
276
|
+
if (hasRecipients) {
|
|
277
|
+
if (!Array.isArray(sc.recipients) || !sc.recipients.length) throw _err("pkcs12/bad-input", "safeContents.recipients must be a non-empty array of recipient descriptors (RFC 5652 sec. 6.1)");
|
|
278
|
+
// Public-key privacy admits only CERTIFICATE recipients (ktri / kari / kemri -- RSA / ECDH / X25519 / X448 /
|
|
279
|
+
// ML-KEM, dispatched off the cert key). A password (pwri) or KEK (kekri) recipient is NOT public-key privacy
|
|
280
|
+
// and pki.pkcs12.open (which forwards only { recipientKey, recipientCert } to cms.decrypt) cannot reopen it --
|
|
281
|
+
// so reject it here rather than emit a store the toolkit's own reader cannot process. (Re-open: pwri/kekri
|
|
282
|
+
// privacy when open forwards the full cms.decrypt key-material model.)
|
|
283
|
+
sc.recipients.forEach(function (r) {
|
|
284
|
+
if (!r || typeof r !== "object" || r.cert == null) throw _err("pkcs12/bad-input", "a public-key privacy recipient must be a certificate recipient { cert } (a password or KEK recipient is not public-key privacy and cannot be reopened by pkcs12.open)");
|
|
285
|
+
});
|
|
286
|
+
var alg = sc.contentEncryptionAlgorithm || "aes-256-cbc";
|
|
287
|
+
// RFC 7292 sec. 4.1 lists only id-envelopedData for a public-key privacy safe (never id-ct-authEnvelopedData);
|
|
288
|
+
// cms.encrypt DEFAULTS to aes-256-gcm (AEAD -> AuthEnvelopedData), so force a CBC content cipher and reject GCM.
|
|
289
|
+
if (!/^aes-(128|192|256)-cbc$/.test(alg)) throw _err("pkcs12/bad-input", "a public-key privacy safe requires an AES-CBC content cipher (aes-128|192|256-cbc), not " + JSON.stringify(alg) + " -- RFC 7292 sec. 4.1 admits only id-envelopedData, not GCM/AuthEnvelopedData");
|
|
290
|
+
var envCi = await cms.encrypt(safeContentsDer, sc.recipients, { contentEncryptionAlgorithm: alg, contentType: "data" });
|
|
291
|
+
return b.raw(envCi); // cms.encrypt returns the whole id-envelopedData ContentInfo DER -- splice it verbatim
|
|
292
|
+
}
|
|
293
|
+
if (!hasEncrypt) {
|
|
259
294
|
return b.sequence([b.oid(O("data")), b.explicit(0, b.octetString(safeContentsDer))]);
|
|
260
295
|
}
|
|
296
|
+
if (!sc.encrypt || typeof sc.encrypt !== "object") throw _err("pkcs12/bad-input", "safeContents.encrypt must be an object { password? } (RFC 7292 sec. 5.1) -- omit it entirely for a plaintext safe");
|
|
261
297
|
var pw = _pbePassword(sc.encrypt.password != null ? sc.encrypt.password : opts.password);
|
|
262
298
|
var r = pbes2.pbes2Encrypt(pw, safeContentsDer, _pbeOpts(sc.encrypt), _err, "pkcs12");
|
|
263
299
|
var eci = b.sequence([b.oid(O("data")), r.algId, b.contextPrimitive(0, r.ct)]); // EncryptedContentInfo, [0] IMPLICIT ct
|
|
@@ -289,6 +325,8 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
|
|
|
289
325
|
if (algorithm === "pbmac1") {
|
|
290
326
|
var prf = PBMAC1_PRF[hash];
|
|
291
327
|
if (!prf) throw _err("pkcs12/unsupported-algorithm", "PBMAC1 requires a SHA-256/384/512 digest (RFC 9579 sec. 5/7 forbids a <= 160-bit digest, e.g. SHA-1)");
|
|
328
|
+
// Native PBKDF2 (~0.2us/iter) tolerates the toolkit-wide PBKDF2 ceiling where the classic JS-loop KDF
|
|
329
|
+
// caps ~10x lower; both target a comparable ~1-2s wall clock (see CLASSIC_MAC_MAX_ITERATIONS).
|
|
292
330
|
var iter2 = _assertMacIter(macOpts.iterations == null ? DEFAULT_PBMAC1_ITER : macOpts.iterations, C.LIMITS.PBKDF2_MAX_ITERATIONS);
|
|
293
331
|
var keyLen = macOpts.keyLength != null ? macOpts.keyLength : prf.keyLen;
|
|
294
332
|
if (typeof keyLen !== "number" || !Number.isInteger(keyLen) || keyLen < 20 || keyLen > MAX_PBMAC1_KEYLEN) throw _err("pkcs12/bad-input", "PBMAC1 keyLength must be an integer in [20, " + MAX_PBMAC1_KEYLEN + "] (RFC 9579 sec. 9)");
|
|
@@ -309,11 +347,22 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
|
|
|
309
347
|
function _normalizeSpec(spec, opts) {
|
|
310
348
|
if (spec && Array.isArray(spec.safeContents)) return spec.safeContents;
|
|
311
349
|
if (spec && (spec.key != null || spec.cert != null)) {
|
|
350
|
+
var enveloped = opts.recipientCerts != null;
|
|
351
|
+
if (enveloped && (!Array.isArray(opts.recipientCerts) || !opts.recipientCerts.length)) throw _err("pkcs12/bad-input", "opts.recipientCerts must be a non-empty array of recipient certificates");
|
|
312
352
|
var certBags = [];
|
|
313
353
|
if (spec.cert != null) certBags.push({ type: "cert", cert: spec.cert, friendlyName: spec.friendlyName, localKeyId: spec.localKeyId });
|
|
314
354
|
if (spec.ca != null && !Array.isArray(spec.ca)) throw _err("pkcs12/bad-input", "spec.ca must be an array of certificates");
|
|
315
355
|
(spec.ca || []).forEach(function (ca) { certBags.push({ type: "cert", cert: ca }); });
|
|
316
356
|
var sc = [];
|
|
357
|
+
if (enveloped) {
|
|
358
|
+
// Public-key-privacy convenience: the cert bags + a PLAINTEXT keyBag in ONE recipient-enveloped safe (the
|
|
359
|
+
// envelope supplies confidentiality; there is no recipient-encrypted key-bag type, RFC 7292 sec. 4.2). The
|
|
360
|
+
// caller password, if any, remains the MAC (integrity) password -- privacy and integrity are independent.
|
|
361
|
+
var bags = certBags.slice();
|
|
362
|
+
if (spec.key != null) bags.push({ type: "key", key: spec.key, friendlyName: spec.friendlyName, localKeyId: spec.localKeyId });
|
|
363
|
+
sc.push({ recipients: opts.recipientCerts.map(function (c) { return { cert: c }; }), bags: bags });
|
|
364
|
+
return sc;
|
|
365
|
+
}
|
|
317
366
|
if (certBags.length) sc.push({ encrypt: { password: opts.password }, bags: certBags });
|
|
318
367
|
if (spec.key != null) sc.push({ bags: [{ type: "shroudedKey", key: spec.key, encrypt: { password: opts.password }, friendlyName: spec.friendlyName, localKeyId: spec.localKeyId }] });
|
|
319
368
|
return sc;
|
|
@@ -345,6 +394,7 @@ function _normalizeSpec(spec, opts) {
|
|
|
345
394
|
* - `password` -- the shared privacy + integrity password (string / Buffer / Uint8Array).
|
|
346
395
|
* - `mac` -- `false` for a MAC-less store, or `{ algorithm: 'hmac'(default)|'pbmac1', hash: 'sha256'(default)|'sha1'|'sha384'|'sha512', salt?, iterations?, keyLength? }`.
|
|
347
396
|
* - `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.
|
|
397
|
+
* - `recipientCerts` -- (public-key privacy convenience) `[certDer|PEM, ...]`; the convenience-form cert + key are placed in one recipient-enveloped safe (a plaintext keyBag). For the full form, set per-`safeContents` `recipients: [{ cert }, ...]` -- CERTIFICATE recipients only (RSA-OAEP / ECDH / X25519 / X448 / ML-KEM, dispatched off the cert key); a password or KEK recipient is not public-key privacy and is rejected -- with an optional `contentEncryptionAlgorithm` (`aes-128|192|256-cbc`, default 256; GCM/AEAD rejected). `encrypt` (password) and `recipients` (public-key) on the same safe is rejected; privacy is independent of integrity.
|
|
348
398
|
* - `pem` (boolean) -- return a PEM `PKCS12` string instead of DER.
|
|
349
399
|
* @example
|
|
350
400
|
* var p12 = await pki.pkcs12.build({ safeContents: [{ bags: [
|
|
@@ -361,7 +411,7 @@ async function build(spec, opts) {
|
|
|
361
411
|
var safeContentsSpecs = _normalizeSpec(spec, opts);
|
|
362
412
|
if (!Array.isArray(safeContentsSpecs) || !safeContentsSpecs.length) throw _err("pkcs12/bad-input", "the store has no safe contents");
|
|
363
413
|
var elements = [];
|
|
364
|
-
for (var i = 0; i < safeContentsSpecs.length; i++) elements.push(_buildAuthSafeElement(safeContentsSpecs[i], opts));
|
|
414
|
+
for (var i = 0; i < safeContentsSpecs.length; i++) elements.push(await _buildAuthSafeElement(safeContentsSpecs[i], opts));
|
|
365
415
|
var authSafeDer = b.sequence(elements); // AuthenticatedSafe ::= SEQUENCE OF ContentInfo -- shared by both integrity modes
|
|
366
416
|
|
|
367
417
|
var pfx;
|
|
@@ -463,8 +513,9 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
|
|
|
463
513
|
*
|
|
464
514
|
* Read a PKCS#12 store: verify its integrity FIRST -- a password store's MAC, or a public-key store's CMS
|
|
465
515
|
* 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
|
|
467
|
-
*
|
|
516
|
+
* then decrypt every PBES2 privacy safe and pkcs8ShroudedKeyBag with the password -- and every id-envelopedData
|
|
517
|
+
* (public-key privacy) safe with `opts.recipientKey` (RFC 7292 sec. 3.1, via `pki.cms.decrypt`) -- returning a
|
|
518
|
+
* structured bundle `{ integrityMode, macVerified, signers, keys, certs, crls, secrets }` -- each private key as PKCS#8
|
|
468
519
|
* `PrivateKeyInfo` DER (re-validated), each certificate / CRL / secret as raw DER, all carrying their
|
|
469
520
|
* `friendlyName` / `localKeyId` for pairing. `pfx` is a DER `Buffer`, PEM string, or a
|
|
470
521
|
* `pki.schema.pkcs12.parse` result.
|
|
@@ -481,6 +532,7 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
|
|
|
481
532
|
* @opts
|
|
482
533
|
* - `allowUnauthenticated` (boolean) -- open a MAC-less store anyway (result carries `macVerified: false`).
|
|
483
534
|
* - `signerCerts` (array of cert DER) -- signer certificate(s) for a public-key store built with `certificates: false` (forwarded to `pki.cms.verify`).
|
|
535
|
+
* - `recipientKey` (PKCS#8 DER|PEM) + `recipientCert` (cert DER|PEM, or `recipientIndex`) -- decrypt an id-envelopedData (public-key privacy) safe. The recipient key is a privacy credential only; a wrong key / tampered envelope is the uniform `pkcs12/decrypt-failed`, and an enveloped safe with no `recipientKey` is `pkcs12/no-recipient-key`.
|
|
484
536
|
* - `maxIterations` (number) -- lower the PBKDF2 / MAC iteration cap for this call (downward-only).
|
|
485
537
|
* - `keys` (string) -- `der` (default) or `crypto` (also `pki.key.import` each private key to a CryptoKey).
|
|
486
538
|
* - `importAlgorithm` -- forwarded to `pki.key.import` for the ambiguous RSA / EC arms when `keys: crypto`.
|
|
@@ -520,7 +572,7 @@ async function open(pfx, password, opts) {
|
|
|
520
572
|
var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
|
|
521
573
|
var i;
|
|
522
574
|
for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], pwBytes, opts, out, 0);
|
|
523
|
-
for (i = 0; i < m.encryptedSafes.length; i++) _openEncryptedSafe(m.encryptedSafes[i], pwBytes, opts, out, 0);
|
|
575
|
+
for (i = 0; i < m.encryptedSafes.length; i++) await _openEncryptedSafe(m.encryptedSafes[i], pwBytes, opts, out, 0);
|
|
524
576
|
if (opts.keys === "crypto") {
|
|
525
577
|
for (i = 0; i < out.keys.length; i++) out.keys[i].key = await key.import(out.keys[i].pkcs8, { algorithm: opts.importAlgorithm });
|
|
526
578
|
}
|
|
@@ -567,10 +619,40 @@ function _decryptShroudedKey(encrypted, pwBytes, opts) {
|
|
|
567
619
|
return der;
|
|
568
620
|
}
|
|
569
621
|
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
622
|
+
// A secret-dependent recipient-decrypt fault (wrong recipientKey / a tampered envelope / a CBC unpad failure)
|
|
623
|
+
// surfaces from cms.decrypt as the uniform cms/decrypt-failed -- wrap it to pkcs12/decrypt-failed so the public-
|
|
624
|
+
// key privacy path shares ONE opaque verdict with the PBES2 path (no padding / recipient / structure oracle,
|
|
625
|
+
// RFC 8018 sec. 8). A structural / selection fault (no-matching-recipient, unsupported-algorithm, iteration-
|
|
626
|
+
// limit) is NOT secret-dependent and propagates as the delegated cms/* code.
|
|
627
|
+
function _mapDecryptError(e) {
|
|
628
|
+
if (e && e.code === "cms/decrypt-failed") return _err("pkcs12/decrypt-failed", "decryption failed", e);
|
|
629
|
+
return e;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// A privacy safe: id-encryptedData (PBES2 password) OR id-envelopedData (public-key, RFC 7292 sec. 3.1) --
|
|
633
|
+
// decrypt the SafeContents, then re-walk it through the strict parser (same PKCS12_MAX_* caps) and open each
|
|
634
|
+
// recovered bag. Async because the envelope path awaits pki.cms.decrypt.
|
|
635
|
+
async function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
|
|
636
|
+
if (encSafe.type === "envelopedData") {
|
|
637
|
+
// INTEGRITY-BEFORE-USE: open()'s pre-loop MAC / SignedData gate already ran over the WHOLE AuthenticatedSafe
|
|
638
|
+
// (which covers this enveloped element), so the ciphertext reaching cms.decrypt is integrity-checked. The
|
|
639
|
+
// recipient private key is a PRIVACY credential only -- never a MAC key, a signature input, or a PBES2 password.
|
|
640
|
+
if (opts.recipientKey == null) throw _err("pkcs12/no-recipient-key", "the store has an id-envelopedData privacy safe -- supply opts.recipientKey (and opts.recipientCert) to decrypt it (RFC 7292 sec. 5.2)");
|
|
641
|
+
// Drive the recipient decrypt off the parser's ALREADY-WALKED EnvelopedData (encSafe.content) via the
|
|
642
|
+
// @internal cms seam -- so a BER-encoded envelope the pkcs12 parser accepts opens without a strict-DER
|
|
643
|
+
// re-parse of reconstructed bytes. The pkcs12 parser is the trusted source of this parsed structure.
|
|
644
|
+
var res;
|
|
645
|
+
try { res = await cmsDecrypt.decryptEnvelopedData(encSafe.content, { key: opts.recipientKey, cert: opts.recipientCert }, opts, "envelopedData"); }
|
|
646
|
+
catch (e) { throw _mapDecryptError(e); }
|
|
647
|
+
var envBags;
|
|
648
|
+
// A decrypt that unpads to non-SafeContents bytes collapses into the SAME uniform pkcs12/decrypt-failed as a
|
|
649
|
+
// bad pad -- a distinguishable structural code here would be a padding oracle. The strict walk's caps fire.
|
|
650
|
+
try { envBags = schemaPkcs12.walkSafeContents(res.content); }
|
|
651
|
+
catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
|
|
652
|
+
for (var k = 0; k < envBags.length; k++) _openBag(envBags[k], pwBytes, opts, out, depth);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (encSafe.type !== "encryptedData") throw _err("pkcs12/unsupported-algorithm", "an " + encSafe.type + " privacy safe is not supported (only id-encryptedData under PBES2 or id-envelopedData under a recipient key)");
|
|
574
656
|
var eci = encSafe.content.encryptedContentInfo;
|
|
575
657
|
if (eci.contentEncryptionAlgorithm.oid !== O("pbes2")) throw _err("pkcs12/unsupported-algorithm", "the privacy safe uses " + (eci.contentEncryptionAlgorithm.name || eci.contentEncryptionAlgorithm.oid) + " (only RFC 8018 PBES2 is decrypted; re-export with -certpbe AES-256-CBC)");
|
|
576
658
|
if (eci.encryptedContent == null) throw _err("pkcs12/bad-input", "the encrypted privacy safe has no content");
|
package/lib/pki-build.js
CHANGED
|
@@ -15,10 +15,16 @@ var schema = require("./schema-engine");
|
|
|
15
15
|
var compositeSig = require("./composite-sig");
|
|
16
16
|
var guard = require("./guard-all");
|
|
17
17
|
var oid = require("./oid");
|
|
18
|
+
var ipUtils = require("./ip-utils");
|
|
18
19
|
var nodeCrypto = require("crypto");
|
|
19
20
|
|
|
20
21
|
var b = asn1.build;
|
|
21
22
|
|
|
23
|
+
// A well-formed dotted-decimal OID: a first arc 0-2 followed by one or more non-leading-zero arcs. Used to
|
|
24
|
+
// accept a raw OID string (an unregistered KeyPurposeId / policy OID) where a registered name would go; the
|
|
25
|
+
// encoder (encodeOidContent) remains the authoritative validator of the arc-value bounds.
|
|
26
|
+
var DOTTED_OID_RE = /^[0-2](?:\.(?:0|[1-9]\d*))+$/;
|
|
27
|
+
|
|
22
28
|
// KeyUsage named-bit positions (RFC 5280 sec. 4.2.1.3); contentCommitment is the RFC 5280 rename of the
|
|
23
29
|
// X.509 nonRepudiation bit (1).
|
|
24
30
|
var KU_BIT = {
|
|
@@ -112,8 +118,16 @@ function makeBuilder(ctx) {
|
|
|
112
118
|
case "dNSName": return b.contextPrimitive(2, ia5Content(v));
|
|
113
119
|
case "uniformResourceIdentifier": case "uri": return b.contextPrimitive(6, ia5Content(v));
|
|
114
120
|
case "iPAddress":
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
// Accept a dotted-quad / colon-hex string (packed to its 4/16 network octets) as well as a
|
|
122
|
+
// pre-packed Buffer, so an iPAddress SAN reads like dNSName/URI rather than forcing the caller
|
|
123
|
+
// to pack octets by hand (RFC 5280 sec. 4.2.1.6 -- a GeneralName iPAddress is a bare host address).
|
|
124
|
+
var ipBuf = v;
|
|
125
|
+
if (typeof v === "string") {
|
|
126
|
+
ipBuf = ipUtils.packIpLiteral(v);
|
|
127
|
+
if (ipBuf === null) throw E("bad-input", "iPAddress string is not a valid IPv4 or IPv6 literal: " + JSON.stringify(v));
|
|
128
|
+
}
|
|
129
|
+
if (!Buffer.isBuffer(ipBuf) || (ipBuf.length !== 4 && ipBuf.length !== 16)) throw E("bad-input", "iPAddress must be a 4- or 16-octet Buffer or an IPv4/IPv6 string");
|
|
130
|
+
return b.contextPrimitive(7, ipBuf);
|
|
117
131
|
case "directoryName": return b.explicit(4, encodeName(v)); // Name is a CHOICE -> the context tag is EXPLICIT
|
|
118
132
|
default: throw E("bad-input", "unsupported GeneralName form " + JSON.stringify(k) + " (supported: rfc822Name, dNSName, uniformResourceIdentifier, iPAddress, directoryName)");
|
|
119
133
|
}
|
|
@@ -129,13 +143,26 @@ function makeBuilder(ctx) {
|
|
|
129
143
|
});
|
|
130
144
|
return b.namedBitString(positions);
|
|
131
145
|
}
|
|
146
|
+
// Resolve an OID token to a dotted-decimal OID: a registered name via the registry, OR a raw dotted-OID
|
|
147
|
+
// string accepted directly (an unregistered KeyPurposeId / policy OID -- BIMI, document-signing, vendor
|
|
148
|
+
// purposes -- is a common valid input). A token that is neither fails closed (never silently accept a
|
|
149
|
+
// typo'd name); a dotted string that passes the shape check is still arc-validated by b.oid's encoder.
|
|
150
|
+
function _resolveOid(n, label) {
|
|
151
|
+
var dotted = O(n);
|
|
152
|
+
if (dotted != null) return dotted;
|
|
153
|
+
if (typeof n === "string" && DOTTED_OID_RE.test(n)) {
|
|
154
|
+
// The regex fixes the general shape; b.oid's encoder is the authoritative X.660 arc-bounds check
|
|
155
|
+
// (a first arc 0/1 caps the second at 39; an arc must DER-encode). A failure there is THIS producer's
|
|
156
|
+
// bad-input, not a leaked oid/* code, so every shared-builder consumer keeps its own error contract.
|
|
157
|
+
try { b.oid(n); }
|
|
158
|
+
catch (e) { throw E("bad-input", "invalid " + label + " OID " + JSON.stringify(n) + " (violates the X.660 arc bounds)", e); }
|
|
159
|
+
return n;
|
|
160
|
+
}
|
|
161
|
+
throw E("bad-input", "unknown " + label + " " + JSON.stringify(n) + " (expected a registered name or a dotted-decimal OID)");
|
|
162
|
+
}
|
|
132
163
|
function extExtKeyUsage(names) {
|
|
133
164
|
if (!Array.isArray(names) || !names.length) throw E("bad-input", "extendedKeyUsage must list at least one KeyPurposeId");
|
|
134
|
-
return b.sequence(names.map(function (n) {
|
|
135
|
-
var purposeOid = O(n);
|
|
136
|
-
if (purposeOid == null) throw E("bad-input", "unknown extendedKeyUsage purpose " + JSON.stringify(n));
|
|
137
|
-
return b.oid(purposeOid);
|
|
138
|
-
}));
|
|
165
|
+
return b.sequence(names.map(function (n) { return b.oid(_resolveOid(n, "extendedKeyUsage purpose")); }));
|
|
139
166
|
}
|
|
140
167
|
function validateBcSpec(bc) {
|
|
141
168
|
if (bc.cA != null && typeof bc.cA !== "boolean") throw E("bad-input", "basicConstraints cA must be a boolean");
|
|
@@ -173,8 +200,7 @@ function makeBuilder(ctx) {
|
|
|
173
200
|
if (!Array.isArray(names) || !names.length) throw E("bad-input", "certificatePolicies must list at least one policy OID");
|
|
174
201
|
var seen = {};
|
|
175
202
|
return b.sequence(names.map(function (n) {
|
|
176
|
-
var pOid =
|
|
177
|
-
if (pOid == null) throw E("bad-input", "unknown certificate policy " + JSON.stringify(n));
|
|
203
|
+
var pOid = _resolveOid(n, "certificate policy");
|
|
178
204
|
if (seen[pOid]) throw E("bad-input", "duplicate certificate policy " + JSON.stringify(n) + " (RFC 5280 sec. 4.2.1.4)");
|
|
179
205
|
seen[pOid] = true;
|
|
180
206
|
return b.sequence([b.oid(pOid)]);
|
package/lib/schema-pkcs12.js
CHANGED
|
@@ -466,7 +466,10 @@ function _dispatchSafes(asMatch, state, ctx) {
|
|
|
466
466
|
} else if (el.contentType === OID_ENCRYPTED_DATA) {
|
|
467
467
|
encryptedSafes.push({ type: "encryptedData", content: _privacySafe(cms.walkEncryptedData(el.innerNode), ctx) });
|
|
468
468
|
} else if (el.contentType === OID_ENVELOPED_DATA) {
|
|
469
|
-
|
|
469
|
+
// Surface the raw EnvelopedData DER alongside the walked structure: public-key privacy opens by driving
|
|
470
|
+
// pki.cms.decrypt, which re-parses strict DER (it takes no pre-parsed object), so pkcs12.open reconstructs
|
|
471
|
+
// the ContentInfo around these bytes rather than re-serializing the recipientInfos the integrity check trusts.
|
|
472
|
+
encryptedSafes.push({ type: "envelopedData", content: _privacySafe(cms.walkEnvelopedData(el.innerNode), ctx), envelopedDataDer: el.innerNode.bytes });
|
|
470
473
|
} else {
|
|
471
474
|
throw ctx.E("pkcs12/bad-safe-contentinfo-type", "an AuthenticatedSafe element must be id-data, id-encryptedData, or id-envelopedData (RFC 7292 sec. 4.1), got " +
|
|
472
475
|
(ctx.oid.name(el.contentType) || el.contentType));
|
package/lib/sign-scheme.js
CHANGED
|
@@ -149,7 +149,9 @@ function resolveSignScheme(cert, so, noSignedAttrs, E) {
|
|
|
149
149
|
// A CryptoKey's algorithm must match the resolved scheme (name / hash / curve).
|
|
150
150
|
function _assertKeyMatchesScheme(key, imp, E) {
|
|
151
151
|
var ka = key.algorithm || {};
|
|
152
|
-
|
|
152
|
+
// WebCrypto algorithm names are ASCII-case-insensitive (RSASSA-PKCS1-v1_5 == RSASSA-PKCS1-V1_5 is the SAME
|
|
153
|
+
// algorithm), so compare case-folded -- a genuine mismatch (e.g. RSASSA-PKCS1-v1_5 vs ECDSA) still fails.
|
|
154
|
+
if (String(ka.name).toUpperCase() !== String(imp.name).toUpperCase()) throw E("bad-input", "the signer CryptoKey algorithm (" + ka.name + ") does not match the certificate's key algorithm (" + imp.name + ")");
|
|
153
155
|
if (imp.hash && (!ka.hash || ka.hash.name !== imp.hash)) throw E("bad-input", "the signer CryptoKey hash (" + (ka.hash && ka.hash.name) + ") does not match the signing digest (" + imp.hash + ")");
|
|
154
156
|
if (imp.namedCurve && ka.namedCurve !== imp.namedCurve) throw E("bad-input", "the signer CryptoKey curve (" + ka.namedCurve + ") does not match the certificate curve (" + imp.namedCurve + ")");
|
|
155
157
|
}
|
package/lib/webcrypto.js
CHANGED
|
@@ -79,6 +79,14 @@ function _normalizeAlg(algorithm, who) {
|
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// The counterpart to _normalizeAlg's upper-casing: _normalizeAlg upper-cases every algorithm name for
|
|
83
|
+
// case-insensitive INTERNAL matching, but the name a CryptoKey EMITS on `.algorithm.name` MUST carry the
|
|
84
|
+
// WebCrypto-registered casing, which for a few algorithms is not all-upper -- "RSASSA-PKCS1-v1_5" (lowercase
|
|
85
|
+
// v), "Ed25519" / "Ed448" (mixed). Every emitted algorithm.name passes through here so a consumer that
|
|
86
|
+
// compares against the standard name (e.g. a signer matching a CryptoKey to a certificate key) matches.
|
|
87
|
+
var _STD_ALG_NAME = { "RSASSA-PKCS1-V1_5": "RSASSA-PKCS1-v1_5", "ED25519": "Ed25519", "ED448": "Ed448" };
|
|
88
|
+
function _stdName(name) { return _STD_ALG_NAME[name] || name; }
|
|
89
|
+
|
|
82
90
|
// WebCrypto hash name -> node digest name. SHA-1 is retained for
|
|
83
91
|
// backwards compatibility with legacy certificates and signatures.
|
|
84
92
|
var HASH_NODE = {
|
|
@@ -252,7 +260,7 @@ function _generateKeyPair(alg) {
|
|
|
252
260
|
modulusLength: alg.modulusLength || 2048,
|
|
253
261
|
publicExponent: alg.publicExponent ? _bufToBigIntNum(alg.publicExponent) : 65537,
|
|
254
262
|
});
|
|
255
|
-
algorithm = { name: name, modulusLength: alg.modulusLength || 2048, publicExponent: alg.publicExponent, hash: _hashObj(alg.hash) };
|
|
263
|
+
algorithm = { name: _stdName(name), modulusLength: alg.modulusLength || 2048, publicExponent: alg.publicExponent, hash: _hashObj(alg.hash) };
|
|
256
264
|
} else if (name === "ECDSA" || name === "ECDH") {
|
|
257
265
|
var curve = alg.namedCurve;
|
|
258
266
|
if (!CURVE_NODE[curve]) throw new WebCryptoError("webcrypto/not-supported", name + ": unsupported curve " + JSON.stringify(curve));
|
|
@@ -260,7 +268,7 @@ function _generateKeyPair(alg) {
|
|
|
260
268
|
algorithm = { name: name, namedCurve: curve };
|
|
261
269
|
} else if (name === "ED25519" || name === "ED448" || name === "X25519" || name === "X448") {
|
|
262
270
|
kp = nodeCrypto.generateKeyPairSync(name.toLowerCase());
|
|
263
|
-
algorithm = { name: (name
|
|
271
|
+
algorithm = { name: _stdName(name) };
|
|
264
272
|
} else if (ML_DSA_NODE[name]) {
|
|
265
273
|
kp = nodeCrypto.generateKeyPairSync(ML_DSA_NODE[name]);
|
|
266
274
|
algorithm = { name: name };
|
|
@@ -859,7 +867,7 @@ function _importRawPublic(name, alg, raw, extractable, usages) {
|
|
|
859
867
|
var ko = nodeCrypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
860
868
|
// The canonical mixed-case EdDSA labels, matching _algFromImport -- a raw
|
|
861
869
|
// import must not label the same key differently than an spki/jwk one.
|
|
862
|
-
return new CryptoKey("public", true, { name: name
|
|
870
|
+
return new CryptoKey("public", true, { name: _stdName(name) }, usages, ko);
|
|
863
871
|
}
|
|
864
872
|
if (name === "ECDSA" || name === "ECDH") {
|
|
865
873
|
var fb = CURVE_FIELD_BYTES[alg.namedCurve];
|
|
@@ -910,9 +918,8 @@ function _algFromImport(name, alg, keyObject) {
|
|
|
910
918
|
}
|
|
911
919
|
return { name: name, namedCurve: actualCurve };
|
|
912
920
|
}
|
|
913
|
-
if (name === "RSASSA-PKCS1-V1_5" || name === "RSA-PSS" || name === "RSA-OAEP") return { name: name, hash: _hashObj(alg.hash) };
|
|
914
|
-
if (name === "ED25519") return { name:
|
|
915
|
-
if (name === "ED448") return { name: "Ed448" };
|
|
921
|
+
if (name === "RSASSA-PKCS1-V1_5" || name === "RSA-PSS" || name === "RSA-OAEP") return { name: _stdName(name), hash: _hashObj(alg.hash) };
|
|
922
|
+
if (name === "ED25519" || name === "ED448") return { name: _stdName(name) };
|
|
916
923
|
return { name: alg.name };
|
|
917
924
|
}
|
|
918
925
|
|
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:f49c13d7-73ab-43d3-87b6-44d1c4d4d9bf",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-26T05:42:56.294Z",
|
|
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.20",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.20",
|
|
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.20",
|
|
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.20",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|