@blamejs/pki 0.3.21 → 0.3.23
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 +23 -0
- package/README.md +2 -2
- package/lib/constants.js +4 -0
- package/lib/guard-all.js +5 -0
- package/lib/guard-header.js +52 -0
- package/lib/mime.js +99 -8
- package/lib/pkcs12-build.js +134 -33
- package/lib/rc2.js +130 -0
- package/lib/smime.js +318 -20
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@ 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.23 — 2026-07-26
|
|
8
|
+
|
|
9
|
+
pki.pkcs12.open now reads legacy PKCS#12 stores -- decrypt the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` (and NSS) store uses, so an older .p12/.pfx opens instead of being refused.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.pkcs12.open decrypts the RFC 7292 Appendix C legacy PBE bags: pbeWithSHAAnd3-KeyTripleDES-CBC, pbeWithSHAAnd2-KeyTripleDES-CBC, pbeWithSHAAnd128BitRC2-CBC, and pbeWithSHAAnd40BitRC2-CBC. This lets an `openssl pkcs12 -legacy` or NSS store -- whose default key bag is 3DES and default cert bag is 40-bit RC2 -- open, where before it was refused. The cipher key and IV are derived with the PKCS#12 Appendix B method over the BMPString+NULL password; a wrong password is caught by the MAC gate (or, for a MAC-less store, is the uniform pkcs12/decrypt-failed). The iteration count is DoS-capped before the key derivation runs. RFC 7292, RFC 2268, RFC 8018.
|
|
14
|
+
- An in-tree RFC 2268 RC2-CBC (lib/rc2.js) fills the gap left by OpenSSL 3.x moving RC2 to its legacy provider (Node's crypto can no longer decrypt it). It is own code with no new runtime dependency, pinned to the RFC 2268 Section 5 known-answer vectors and cross-checked against real OpenSSL -legacy RC2-40 and RC2-128 stores.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The legacy RC4 PBE schemes (pbeWithSHAAnd128BitRC4 / pbeWithSHAAnd40BitRC4) remain refused, now with a message that names the scheme and the remediation (re-export the store under AES-256-CBC or PBE-SHA1-3DES).
|
|
19
|
+
|
|
20
|
+
## v0.3.22 — 2026-07-26
|
|
21
|
+
|
|
22
|
+
S/MIME header protection ships -- cover the message headers (Subject, From, To, ...) under the CMS signature or encryption with pki.smime, RFC 9788.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- pki.smime.sign / pki.smime.encrypt gain opts.protectHeaders (RFC 9788 header protection) with opts.headers -- an object { Name: value } or an array [{ name, value }] of the Non-Structural fields to protect (Subject / From / To / Date / ...). Signed protection marks the payload hp="clear" and copies the fields to the outer display headers; encrypted protection marks it hp="cipher", inlines the REAL values inside the ciphertext, emits the Header-Confidentiality-Policy-processed outer copies, and embeds the authenticated HP-Outer records that document which fields were left visible outside -- opts.hcp selects "hcp_baseline" (default: obscure Subject to [...], remove Comments/Keywords) or "hcp_no_confidentiality". The CMS crypto is unchanged, so any signer / recipient algorithm carries through. RFC 9788.
|
|
27
|
+
- pki.smime.verify / pki.smime.decrypt return protectedHeaders (the authenticated inner header set, or null when the message is not header-protected) and headerProtection { present, mode, fromMismatch, confidential }. The inner protected headers are surfaced distinctly from the untrusted outer headers, fromMismatch flags an outer From that disagrees with the protected one, and confidential lists the fields the composer kept end-to-end confidential -- computed from the authenticated HP-Outer records so a caller can reply or forward without leaking them. A payload declaring hp that is malformed, carries an invalid value, or contradicts the cryptographic envelope (a signed message claiming hp="cipher") fails closed with smime/bad-header-protection -- never a silent downgrade. RFC 9788.
|
|
28
|
+
- Every MIME header field pki.smime emits routes through a fail-closed header-field guard: a CR / LF / NUL in a field value, or a field name outside RFC 5322 ftext, is rejected with smime/bad-header, so a caller-supplied Subject can never inject a Bcc header or split the message (CWE-93).
|
|
29
|
+
|
|
7
30
|
## v0.3.21 — 2026-07-26
|
|
8
31
|
|
|
9
32
|
A Certificate Transparency log-list live-fetch client ships -- pki.ct.fetchLogList fetches and verifies the CT log list over HTTPS before trusting a single log.
|
package/README.md
CHANGED
|
@@ -230,9 +230,9 @@ is callable today; nothing below is a stub.
|
|
|
230
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. **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
|
|
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 read** — `open` decrypts the RFC 7292 Appendix C 3DES and RC2 bags an `openssl pkcs12 -legacy` / NSS store uses (RC2 via an in-tree RFC 2268 cipher), so an older store opens; the legacy RC4 schemes are refused. 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 (App. C) store's 3DES / RC2 bags are decrypted (RC4 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
|
-
| `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one — `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat); the `smime-type` is derived from the CMS body, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
|
|
235
|
+
| `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. **Header protection** (RFC 9788): `sign` / `encrypt` gain `opts.protectHeaders` — the caller's `opts.headers` are inlined on the Cryptographic Payload root (its Content-Type gains `hp="clear"` signed / `hp="cipher"` encrypted) so the CMS signature/encryption covers them, defeating a transport that rewrites or reads Subject/From/… `verify` / `decrypt` surface the AUTHENTICATED inner set as `protectedHeaders` + `headerProtection { present, mode, fromMismatch }` (a tampered outer header cannot alter it; `fromMismatch` flags an outer From that disagrees). Encryption applies a Header Confidentiality Policy — the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments/Keywords, so the real values live only in the ciphertext; `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard (a CR/LF/NUL value or a non-ftext name is rejected), and a malformed/contradictory `hp` wrap fails closed (`smime/bad-header-protection`), never a silent downgrade; the CMS crypto is unchanged. 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` |
|
|
237
237
|
| `pki.ocsp` | RFC 6960 Online Certificate Status Protocol — the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs (CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile, or SHA-2; optional RFC 9654 nonce, optional requestor signature). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER — the issuing CA directly or a delegated responder, any `pki.cms.sign` key including the post-quantum ML-DSA / SLH-DSA sets, with `good` / `revoked` (reason + time) / `unknown` per-certificate status, and `buildErrorResponse(status)` the unsigned §2.3 error (`tryLater` / `unauthorized` / …). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA or a CA-issued delegate bearing id-kp-OCSPSigning **and** id-pkix-ocsp-nocheck, passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency (`thisUpdate`/`nextUpdate`), and the request-nonce echo — returning `{ status: "good" / "revoked" / "unknown", … }`, never a silent accept. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
|
|
238
238
|
| `pki.ct` | RFC 6962 Certificate Transparency SCTs — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine — resolving true or false, and throwing a typed error on a structural fault. The producing side: `encodeSctList` builds the extension value byte-for-byte (the exact inverse of `parseSctList`) and `signSct` performs a log's signing step (rebuilding the same signed-data preimage the verifier hashes and signing it with the log's ECDSA-P-256 / RSA key). The trust surface: `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs — recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2), decoding the state + temporal-interval constraints — and `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable/qualified/readonly trusted; retired only before retirement; pending/rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256, EC P-256 arm; forgeable-key defenses fail closed) — cross-checked against `openssl dgst`, completing the offline log-list trust chain. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over the shared `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key **before** parsing (verify-before-parse — an unverified document is never parsed, read, cached, or surfaced), then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version`/`timestamp`. No baked-in vendor URL or key (both caller-pinned); explicit TLS trust with `rejectUnauthorized` always on; each response is size-capped before the trust chain; the transport is dependency-injectable so it is fully testable offline. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature`, `fetchLogList` |
|
package/lib/constants.js
CHANGED
|
@@ -257,6 +257,10 @@ var LIMITS = {
|
|
|
257
257
|
PBKDF2_MAX_SALT: 1024,
|
|
258
258
|
// A MIME entity / S/MIME message size ceiling -- bounds the header + body decode before a copy.
|
|
259
259
|
MIME_MAX_BYTES: BYTES.mib(16),
|
|
260
|
+
// RFC 5322 sec. 2.1.1: a header line MUST be <= 998 octets excluding the CRLF. A composer that emits a
|
|
261
|
+
// longer unfolded field line risks a relay re-folding or rejecting the message -- for multipart/signed
|
|
262
|
+
// that re-fold changes the signed bytes and breaks verification -- so an emitted field line is capped here.
|
|
263
|
+
HEADER_LINE_MAX_OCTETS: 998,
|
|
260
264
|
// The decompressed-output ceiling for CMS CompressedData (RFC 3274) -- bounds the inflate BEFORE the
|
|
261
265
|
// output is materialized (a decompression-bomb / resource-exhaustion defense, CWE-409); a caller may
|
|
262
266
|
// tighten it DOWNWARD via opts.maxOutputBytes, never loosen it.
|
package/lib/guard-all.js
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
// guard.identifier.assertCanonicalOid
|
|
35
35
|
// -- canonical dotted-decimal OID string form
|
|
36
36
|
// (canonicalization-divergence defence)
|
|
37
|
+
// guard.header.assertField -- emitted MIME/RFC 5322 header field name +
|
|
38
|
+
// value integrity (CR/LF/NUL header-injection
|
|
39
|
+
// defence, CWE-93)
|
|
37
40
|
//
|
|
38
41
|
// Each shape is enforced by a codebase-patterns detector: the characteristic
|
|
39
42
|
// token of a guard (the Buffer.from(x.buffer, byteOffset) re-view, the
|
|
@@ -51,6 +54,7 @@ var name = require("./guard-name");
|
|
|
51
54
|
var encoding = require("./guard-encoding");
|
|
52
55
|
var json = require("./guard-json");
|
|
53
56
|
var identifier = require("./guard-identifier");
|
|
57
|
+
var header = require("./guard-header");
|
|
54
58
|
|
|
55
59
|
module.exports = {
|
|
56
60
|
bytes: bytes,
|
|
@@ -63,4 +67,5 @@ module.exports = {
|
|
|
63
67
|
encoding: encoding,
|
|
64
68
|
json: json,
|
|
65
69
|
identifier: identifier,
|
|
70
|
+
header: header,
|
|
66
71
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- no operator-facing namespace. The documented surface is the
|
|
6
|
+
// consumer whose emitted-header integrity composes this guard (pki.smime header
|
|
7
|
+
// protection, RFC 9788).
|
|
8
|
+
//
|
|
9
|
+
// guard-header -- fail-closed header-field integrity for a MIME / RFC 5322 header
|
|
10
|
+
// a composer EMITS. Defends the HEADER INJECTION class (CWE-93): a caller-supplied
|
|
11
|
+
// header field name or value carrying a CR (0x0d) / LF (0x0a) / NUL (0x00) could
|
|
12
|
+
// inject an extra header (a Bcc, an HP-Outer record), split the message, or forge
|
|
13
|
+
// a multipart boundary; a field name outside RFC 5322 ftext could break the
|
|
14
|
+
// "Name: value" header grammar. It also caps the serialized line length (RFC 5322
|
|
15
|
+
// sec. 2.1.1, 998 octets) so an over-long emitted field cannot be re-folded by a
|
|
16
|
+
// relay into different signed bytes. Every field a composer inlines routes through
|
|
17
|
+
// this one guard, so an emission site cannot re-inline a weaker check and
|
|
18
|
+
// reintroduce the injection.
|
|
19
|
+
|
|
20
|
+
var C = require("./constants");
|
|
21
|
+
|
|
22
|
+
// assertField(name, value, E, code) -> value | throws new E(code, ...)
|
|
23
|
+
// name: RFC 5322 ftext -- printable ASCII [0x21, 0x7e] except ':' (0x3a), non-empty
|
|
24
|
+
// (a space, control byte, or ':' in a field name breaks the "Name: value" grammar).
|
|
25
|
+
// value: reject CR (0x0d) / LF (0x0a) / NUL (0x00) -- the injection bytes; TAB
|
|
26
|
+
// (0x09, folding whitespace) and printable non-ASCII (a UTF-8 header value, RFC
|
|
27
|
+
// 6532) pass. E is the caller's typed-error CLASS, thrown `new E(code, message)`
|
|
28
|
+
// -- the same convention lib/mime.js (this guard's caller) and guard.bytes/text use.
|
|
29
|
+
// @enforced-by behavioral -- the CR/LF/NUL value reject + the ftext name check have
|
|
30
|
+
// no rename-proof code shape distinct from the guard-name control-byte loop / the
|
|
31
|
+
// ASN.1 charset readers; the header-injection RED vectors (a CR / LF / NUL value
|
|
32
|
+
// or a non-ftext field name rejects smime/bad-header) are the guard.
|
|
33
|
+
function assertField(name, value, E, code) {
|
|
34
|
+
if (typeof name !== "string" || name.length === 0) throw new E(code, "a header field name must be a non-empty string");
|
|
35
|
+
for (var i = 0; i < name.length; i++) {
|
|
36
|
+
var nc = name.charCodeAt(i);
|
|
37
|
+
if (nc < 0x21 || nc > 0x7e || nc === 0x3a) throw new E(code, "a header field name must be RFC 5322 ftext (printable ASCII, no space / ':' / control): " + JSON.stringify(name));
|
|
38
|
+
}
|
|
39
|
+
var v = typeof value === "string" ? value : String(value);
|
|
40
|
+
for (var j = 0; j < v.length; j++) {
|
|
41
|
+
var vc = v.charCodeAt(j);
|
|
42
|
+
if (vc === 0x00 || vc === 0x0d || vc === 0x0a) throw new E(code, "a header field value must not contain CR / LF / NUL (header injection) in " + JSON.stringify(name));
|
|
43
|
+
}
|
|
44
|
+
// RFC 5322 sec. 2.1.1: the serialized "Name: value" line (UTF-8 octets, excluding CRLF) must not exceed
|
|
45
|
+
// the line limit, or a relay may re-fold it -- silently changing the signed bytes of a protected header.
|
|
46
|
+
if (Buffer.byteLength(name, "utf8") + 2 + Buffer.byteLength(v, "utf8") > C.LIMITS.HEADER_LINE_MAX_OCTETS) {
|
|
47
|
+
throw new E(code, "a header field line exceeds RFC 5322's " + C.LIMITS.HEADER_LINE_MAX_OCTETS + "-octet limit: " + JSON.stringify(name));
|
|
48
|
+
}
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { assertField: assertField };
|
package/lib/mime.js
CHANGED
|
@@ -54,26 +54,53 @@ function _unfoldHeaders(headerText) {
|
|
|
54
54
|
// (lowercased) plus its parameters (names lowercased, quotes stripped). Tolerant of extra whitespace.
|
|
55
55
|
function _parseStructured(headerValue) {
|
|
56
56
|
var parts = _splitSemicolons(headerValue); // always >= 1 element
|
|
57
|
-
var type = parts[0].trim().toLowerCase();
|
|
57
|
+
var type = _stripComments(parts[0]).trim().toLowerCase();
|
|
58
58
|
var params = {};
|
|
59
59
|
for (var i = 1; i < parts.length; i++) {
|
|
60
|
-
|
|
60
|
+
// RFC 5322 CFWS: a comment around the name/value is folding whitespace, not part of either -- strip it
|
|
61
|
+
// so "(note) hp" resolves to the "hp" parameter and a trailing comment does not leak into the value.
|
|
62
|
+
var p = _stripComments(parts[i]);
|
|
63
|
+
var eq = p.indexOf("=");
|
|
61
64
|
if (eq < 0) continue;
|
|
62
|
-
var name =
|
|
63
|
-
var val =
|
|
65
|
+
var name = p.slice(0, eq).trim().toLowerCase();
|
|
66
|
+
var val = p.slice(eq + 1).trim();
|
|
64
67
|
if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') val = val.slice(1, -1);
|
|
65
68
|
params[name] = val;
|
|
66
69
|
}
|
|
67
70
|
return { value: headerValue.trim(), type: type, params: params };
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
// Remove RFC 5322 comments -- a "(...)" span (nesting; a backslash quoted-pair escapes the next byte) OUTSIDE
|
|
74
|
+
// a double-quoted string. A "(" inside a quoted string is literal qtext, not a comment. Used to drop CFWS
|
|
75
|
+
// from a structured-header token before the name/value is read, so a comment cannot forge or hide a parameter.
|
|
76
|
+
function _stripComments(s) {
|
|
77
|
+
var out = "", inQ = false, depth = 0;
|
|
78
|
+
for (var i = 0; i < s.length; i++) {
|
|
79
|
+
var ch = s[i];
|
|
80
|
+
if (inQ && ch === "\\" && i + 1 < s.length) { out += ch + s[i + 1]; i++; } // quoted-pair inside a quoted string -> keep both
|
|
81
|
+
else if (depth > 0 && ch === "\\" && i + 1 < s.length) { i++; } // quoted-pair inside a comment -> drop both
|
|
82
|
+
else if (ch === '"' && depth === 0) { inQ = !inQ; out += ch; }
|
|
83
|
+
else if (ch === "(" && !inQ) { depth++; }
|
|
84
|
+
else if (ch === ")" && !inQ && depth > 0) { depth--; }
|
|
85
|
+
else if (depth === 0) { out += ch; }
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
// Split on ";" while respecting double-quoted spans (a boundary/protocol value may be quoted).
|
|
71
91
|
function _splitSemicolons(s) {
|
|
72
|
-
var out = [], cur = "", inQ = false;
|
|
92
|
+
var out = [], cur = "", inQ = false, depth = 0;
|
|
73
93
|
for (var i = 0; i < s.length; i++) {
|
|
74
94
|
var ch = s[i];
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
// RFC 5322 quoted-pair: inside a quoted-string OR a comment a backslash escapes the next char (so an
|
|
96
|
+
// escaped `"` / `)` does not end the span, and a `;` in an escaped value does not split) -- keep both bytes.
|
|
97
|
+
if ((inQ || depth > 0) && ch === "\\" && i + 1 < s.length) { cur += ch + s[i + 1]; i++; }
|
|
98
|
+
else if (ch === '"' && depth === 0) { inQ = !inQ; cur += ch; }
|
|
99
|
+
// RFC 5322 comment: parentheses (outside a quoted-string) delimit a nesting comment -- a `;` or `=` inside
|
|
100
|
+
// one is CFWS, not structural, so a Content-Type comment like `charset=x (note; hp=y)` cannot forge a parameter.
|
|
101
|
+
else if (ch === "(" && !inQ) { depth++; cur += ch; }
|
|
102
|
+
else if (ch === ")" && !inQ && depth > 0) { depth--; cur += ch; }
|
|
103
|
+
else if (ch === ";" && !inQ && depth === 0) { out.push(cur); cur = ""; }
|
|
77
104
|
else cur += ch;
|
|
78
105
|
}
|
|
79
106
|
out.push(cur);
|
|
@@ -97,7 +124,13 @@ function parse(input, E, code) {
|
|
|
97
124
|
for (var i = 0; i < rawHeaders.length; i++) {
|
|
98
125
|
var colon = rawHeaders[i].indexOf(":");
|
|
99
126
|
if (colon < 0) throw new E(code, "a MIME header line has no colon: " + JSON.stringify(rawHeaders[i].slice(0, 40)));
|
|
100
|
-
|
|
127
|
+
var nm = rawHeaders[i].slice(0, colon).trim();
|
|
128
|
+
var body = rawHeaders[i].slice(colon + 1);
|
|
129
|
+
// value: the trimmed field body (structured-header parsing wants it trimmed). rawValue: the field body
|
|
130
|
+
// with only the single conventional ": " delimiter space removed -- leading/trailing whitespace INSIDE
|
|
131
|
+
// the body is preserved, so a consumer that must reproduce the exact authenticated field body (S/MIME
|
|
132
|
+
// header protection) is not handed a lossily-trimmed value.
|
|
133
|
+
headers.push({ name: nm, lname: nm.toLowerCase(), value: body.trim(), rawValue: body.replace(/^ /, "") });
|
|
101
134
|
}
|
|
102
135
|
function header(name) { var l = name.toLowerCase(); for (var j = 0; j < headers.length; j++) if (headers[j].lname === l) return headers[j].value; return null; }
|
|
103
136
|
var ctv = header("content-type");
|
|
@@ -182,10 +215,68 @@ function _afterLine(buf, from, E, code) {
|
|
|
182
215
|
throw new E(code, "a multipart boundary line is not terminated");
|
|
183
216
|
}
|
|
184
217
|
|
|
218
|
+
// Serialize an ordered list of header fields + a body into a canonical MIME entity: each field as
|
|
219
|
+
// "Name: value\r\n" (routed through the header-injection guard so a CR/LF/NUL in a value can never inject a
|
|
220
|
+
// header or split the message), then the blank-line separator, then the body -- all CRLF-canonicalized so
|
|
221
|
+
// the bytes match a CMS signer's/verifier's digest. `fields` is [{ name, value }]; `body` is a byte source
|
|
222
|
+
// (or null/empty). This is the single header/entity emitter -- an inline "Content-Type: ...\r\n" string
|
|
223
|
+
// concatenation is the shape this replaces, so a composer cannot re-inline an unguarded header.
|
|
224
|
+
function buildEntity(fields, body, E, code) {
|
|
225
|
+
var head = "";
|
|
226
|
+
for (var i = 0; i < fields.length; i++) {
|
|
227
|
+
var f = fields[i];
|
|
228
|
+
// Serialize the VALUE the guard validated + returned -- never a second coercion of f.value (a stateful
|
|
229
|
+
// toString() could return CRLF on the second call and inject a header past the check).
|
|
230
|
+
var v = guard.header.assertField(f.name, f.value, E, code);
|
|
231
|
+
head += f.name + ": " + v + "\r\n";
|
|
232
|
+
}
|
|
233
|
+
var bodyBuf = (body == null) ? Buffer.alloc(0) : _buf(body, E, code, "the MIME body");
|
|
234
|
+
// Buffer the head as UTF-8 (an RFC 6532 internationalized header value survives); canonicalizeText is
|
|
235
|
+
// byte-preserving except CR/LF normalization (latin1 round-trip), so those UTF-8 bytes pass through.
|
|
236
|
+
return canonicalizeText(Buffer.concat([Buffer.from(head + "\r\n", "utf8"), bodyBuf]));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Count how many times a parameter named `name` (case-insensitive) appears in a structured header value,
|
|
240
|
+
// QUOTE-AWARE (a `name=` inside a double-quoted value is not a parameter). _parseStructured collapses a
|
|
241
|
+
// duplicate parameter to its last value, so a consumer that must fail closed on a duplicate counts it here.
|
|
242
|
+
function paramCount(headerValue, name) {
|
|
243
|
+
var ln = name.toLowerCase(), n = 0;
|
|
244
|
+
_splitSemicolons(headerValue).forEach(function (part) {
|
|
245
|
+
var p = _stripComments(part); // CFWS around the name (e.g. "(note) hp") is not part of the parameter name
|
|
246
|
+
var eq = p.indexOf("=");
|
|
247
|
+
if (eq >= 0 && p.slice(0, eq).trim().toLowerCase() === ln) n++;
|
|
248
|
+
});
|
|
249
|
+
return n;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Count parameters with this attribute name -- WITH OR WITHOUT a value. paramCount only counts "name=value"
|
|
253
|
+
// parts, so a bare/valueless attribute (e.g. "Content-Type: text/plain; hp") is invisible to it;
|
|
254
|
+
// paramNameCount still sees the attribute name, so a consumer can fail closed on a malformed bare parameter --
|
|
255
|
+
// or on a bare+valued duplicate ("hp; hp=x") that paramCount undercounts -- rather than silently ignore it.
|
|
256
|
+
// Comment- + quoted-string-aware, like paramCount; part 0 is the media type (not a parameter), so attributes
|
|
257
|
+
// start at index 1.
|
|
258
|
+
function paramNameCount(headerValue, name) {
|
|
259
|
+
var ln = name.toLowerCase(), n = 0;
|
|
260
|
+
var parts = _splitSemicolons(headerValue);
|
|
261
|
+
for (var i = 1; i < parts.length; i++) {
|
|
262
|
+
var p = _stripComments(parts[i]);
|
|
263
|
+
var eq = p.indexOf("=");
|
|
264
|
+
if ((eq >= 0 ? p.slice(0, eq) : p).trim().toLowerCase() === ln) n++;
|
|
265
|
+
}
|
|
266
|
+
return n;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Does the structured header value name this parameter attribute at all (with or without a value)?
|
|
270
|
+
function hasParam(headerValue, name) { return paramNameCount(headerValue, name) > 0; }
|
|
271
|
+
|
|
185
272
|
module.exports = {
|
|
186
273
|
parse: parse,
|
|
187
274
|
canonicalize: canonicalize,
|
|
188
275
|
canonicalizeText: canonicalizeText,
|
|
189
276
|
splitMultipart: splitMultipart,
|
|
277
|
+
buildEntity: buildEntity,
|
|
278
|
+
paramCount: paramCount,
|
|
279
|
+
hasParam: hasParam,
|
|
280
|
+
paramNameCount: paramNameCount,
|
|
190
281
|
CRLF: CRLF,
|
|
191
282
|
};
|
package/lib/pkcs12-build.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
var nodeCrypto = require("crypto");
|
|
36
36
|
var asn1 = require("./asn1-der");
|
|
37
|
+
var rc2 = require("./rc2");
|
|
37
38
|
var oid = require("./oid");
|
|
38
39
|
var pbes2 = require("./pbes2");
|
|
39
40
|
var pkcs8 = require("./schema-pkcs8");
|
|
@@ -68,6 +69,12 @@ var MAX_PBMAC1_KEYLEN = 1024; // an HMAC key beyond a hash block is pointless
|
|
|
68
69
|
// pkcs12-local (its KDF is bespoke here) while PBMAC1 reuses the toolkit-wide PBKDF2 ceiling. 1e6 is ~500x
|
|
69
70
|
// the OpenSSL default (2048) yet still bounds the loop to ~1 second.
|
|
70
71
|
var CLASSIC_MAC_MAX_ITERATIONS = 1000000;
|
|
72
|
+
// The AGGREGATE cap on synchronous legacy-PBE App. B KDF work across a single open() -- SHA-1 rounds summed
|
|
73
|
+
// over every legacy bag (its KDF block count x iterations). The per-bag iteration cap resets per bag, so a
|
|
74
|
+
// hostile store that duplicates a costly legacy bag up to the parser's 1024-bag limit would otherwise block
|
|
75
|
+
// the event loop for minutes; this bounds the total to ~the classic MAC ceiling. A conforming store runs only
|
|
76
|
+
// a few thousand rounds (its handful of bags at ~2048 iterations).
|
|
77
|
+
var LEGACY_KDF_MAX_ROUNDS = CLASSIC_MAC_MAX_ITERATIONS;
|
|
71
78
|
|
|
72
79
|
// The classic App. B.2 KDF (u = hash output bytes, v = compression block bytes) per RFC 7292 App. B.2.
|
|
73
80
|
var P12_KDF_UV = {
|
|
@@ -131,20 +138,40 @@ function _blockFill(src, blockSize) {
|
|
|
131
138
|
return out;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
// RFC 7292 Appendix B.2 KDF: derive `nBytes` of key material for purpose `id` (
|
|
135
|
-
// App. B.1 password bytes + salt over `iterations` rounds of `hashName`. NOT PBKDF2. The
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
+
// RFC 7292 Appendix B.2 KDF: derive `nBytes` of key material for purpose `id` (1 = cipher key, 2 = IV,
|
|
142
|
+
// 3 = MAC) from the App. B.1 password bytes + salt over `iterations` rounds of `hashName`. NOT PBKDF2. The
|
|
143
|
+
// block count c = ceil(nBytes/u): the MAC always requests exactly the hash output u (c = 1, one diversified,
|
|
144
|
+
// salted, iterated hash), while a legacy-PBE cipher key wider than u (e.g. 24-byte 3-key-3DES from SHA-1's
|
|
145
|
+
// 20-byte output, c = 2) exercises the multi-block feedback (step 6C: I = (I + B + 1) mod 2^(8v) between
|
|
146
|
+
// blocks). The single-block path is byte-identical to before (proven bidirectionally against OpenSSL); the
|
|
147
|
+
// multi-block path is proven against an `openssl pkcs12 -legacy` store (its own known-answer test).
|
|
141
148
|
function _p12Kdf(hashName, id, pwBytes, salt, iterations, nBytes) {
|
|
142
|
-
var uv = P12_KDF_UV[hashName];
|
|
143
|
-
|
|
144
|
-
var
|
|
145
|
-
var
|
|
146
|
-
|
|
147
|
-
|
|
149
|
+
var uv = P12_KDF_UV[hashName], u = uv.u, v = uv.v;
|
|
150
|
+
var D = Buffer.alloc(v, id); // step 1: v copies of the ID diversifier
|
|
151
|
+
var I = Buffer.concat([_blockFill(salt, v), _blockFill(pwBytes, v)]); // steps 2-4: I = S || P
|
|
152
|
+
var c = Math.ceil(nBytes / u); // step 5
|
|
153
|
+
var out = Buffer.alloc(0);
|
|
154
|
+
for (var i = 0; i < c; i++) { // step 6
|
|
155
|
+
var A = Buffer.concat([D, I]);
|
|
156
|
+
for (var r = 0; r < iterations; r++) A = nodeCrypto.createHash(hashName).update(A).digest(); // 6A: A = H^r(D||I)
|
|
157
|
+
out = Buffer.concat([out, A]); // step 7 (accumulate A_1..A_c)
|
|
158
|
+
if (i < c - 1) _kdfStepC(I, _blockFill(A, v), v); // 6B+6C: modify I for the next block
|
|
159
|
+
}
|
|
160
|
+
return out.subarray(0, nBytes); // step 8: first nBytes of A
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// RFC 7292 App. B.2 step 6C: treat I as consecutive v-byte blocks and set each I_j = (I_j + B + 1) mod 2^(8v)
|
|
164
|
+
// via a big-endian byte-carry add (the +1 is the initial carry; carry out of the top byte is discarded, the
|
|
165
|
+
// mod). In place on I.
|
|
166
|
+
function _kdfStepC(I, B, v) {
|
|
167
|
+
for (var j = 0; j < I.length; j += v) {
|
|
168
|
+
var carry = 1;
|
|
169
|
+
for (var k = v - 1; k >= 0; k--) {
|
|
170
|
+
var sum = I[j + k] + B[k] + carry;
|
|
171
|
+
I[j + k] = sum & 0xff;
|
|
172
|
+
carry = sum >>> 8;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
148
175
|
}
|
|
149
176
|
|
|
150
177
|
// ---- input coercion --------------------------------------------------------
|
|
@@ -526,8 +553,10 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
|
|
|
526
553
|
* password / MAC-less mode). The signer is surfaced, NEVER trust-chained -- anchoring `signers[i].cert` to a
|
|
527
554
|
* trust root is the caller's `pki.path.validate` step (the out-of-path signer contract). Privacy is
|
|
528
555
|
* independent of integrity, so the bag `password` still decrypts a public-key store's bags; a wrong bag
|
|
529
|
-
* password there is the uniform `pkcs12/decrypt-failed` (no MAC to catch it first).
|
|
530
|
-
*
|
|
556
|
+
* password there is the uniform `pkcs12/decrypt-failed` (no MAC to catch it first). Bags encrypted under
|
|
557
|
+
* PBES2 (AES-CBC), or the RFC 7292 App. C legacy schemes an `openssl pkcs12 -legacy` store uses -- 3-key /
|
|
558
|
+
* 2-key Triple-DES-CBC and 40-/128-bit RC2-CBC (the App. B KDF over the BMPString password, RC2 via an in-tree
|
|
559
|
+
* RFC 2268 cipher) -- are decrypted; the legacy RC4 schemes are named and refused.
|
|
531
560
|
*
|
|
532
561
|
* @opts
|
|
533
562
|
* - `allowUnauthenticated` (boolean) -- open a MAC-less store anyway (result carries `macVerified: false`).
|
|
@@ -565,21 +594,22 @@ async function open(pfx, password, opts) {
|
|
|
565
594
|
} else if (!opts.allowUnauthenticated) {
|
|
566
595
|
throw _err("pkcs12/no-integrity", "the store carries no integrity MAC (integrityMode " + m.integrityMode + "); set opts.allowUnauthenticated to open it anyway");
|
|
567
596
|
}
|
|
568
|
-
// The
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
597
|
+
// The raw password is threaded to each bag/safe decrypt site, which picks the encoding its scheme needs:
|
|
598
|
+
// PBES2 uses UTF-8 (the pinned interop convention), a legacy App. C scheme uses the BMPString+NULL form (the
|
|
599
|
+
// App. B.1 encoding the classic MAC also uses) -- both distinct from the public-key integrity keypair. A
|
|
600
|
+
// wrong bag password fails at the first encrypted bag as the uniform pkcs12/decrypt-failed.
|
|
572
601
|
var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
|
|
573
602
|
var i;
|
|
574
|
-
|
|
575
|
-
for (i = 0; i < m.
|
|
603
|
+
var kdfBudget = { rounds: LEGACY_KDF_MAX_ROUNDS }; // aggregate legacy-PBE KDF work budget for this whole open()
|
|
604
|
+
for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], password, opts, out, 0, kdfBudget);
|
|
605
|
+
for (i = 0; i < m.encryptedSafes.length; i++) await _openEncryptedSafe(m.encryptedSafes[i], password, opts, out, 0, kdfBudget);
|
|
576
606
|
if (opts.keys === "crypto") {
|
|
577
607
|
for (i = 0; i < out.keys.length; i++) out.keys[i].key = await key.import(out.keys[i].pkcs8, { algorithm: opts.importAlgorithm });
|
|
578
608
|
}
|
|
579
609
|
return out;
|
|
580
610
|
}
|
|
581
611
|
|
|
582
|
-
function _openBag(bag,
|
|
612
|
+
function _openBag(bag, password, opts, out, depth, budget) {
|
|
583
613
|
switch (bag.type) {
|
|
584
614
|
case "keyBag":
|
|
585
615
|
// The open contract surfaces keys[].pkcs8 as canonical PrivateKeyInfo DER (key.import re-decodes strict
|
|
@@ -590,7 +620,7 @@ function _openBag(bag, pwBytes, opts, out, depth) {
|
|
|
590
620
|
out.keys.push({ pkcs8: bag.keyDer, encrypted: false, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
|
|
591
621
|
break;
|
|
592
622
|
case "pkcs8ShroudedKeyBag":
|
|
593
|
-
out.keys.push({ pkcs8: _decryptShroudedKey(bag.encrypted,
|
|
623
|
+
out.keys.push({ pkcs8: _decryptShroudedKey(bag.encrypted, password, opts, budget), encrypted: true, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
|
|
594
624
|
break;
|
|
595
625
|
case "certBag":
|
|
596
626
|
out.certs.push({ cert: bag.certValue, certType: bag.certType, friendlyName: bag.friendlyName, localKeyId: bag.localKeyId });
|
|
@@ -603,18 +633,90 @@ function _openBag(bag, pwBytes, opts, out, depth) {
|
|
|
603
633
|
break;
|
|
604
634
|
case "safeContentsBag":
|
|
605
635
|
if (depth + 1 > C.LIMITS.PKCS12_MAX_BAG_DEPTH) throw _err("pkcs12/too-deep", "safeContentsBag nesting exceeds the depth cap " + C.LIMITS.PKCS12_MAX_BAG_DEPTH);
|
|
606
|
-
for (var n = 0; n < (bag.nested || []).length; n++) _openBag(bag.nested[n],
|
|
636
|
+
for (var n = 0; n < (bag.nested || []).length; n++) _openBag(bag.nested[n], password, opts, out, depth + 1, budget);
|
|
607
637
|
break;
|
|
608
638
|
default:
|
|
609
639
|
throw _err("pkcs12/bad-input", "unexpected bag type " + bag.type);
|
|
610
640
|
}
|
|
611
641
|
}
|
|
612
642
|
|
|
613
|
-
//
|
|
643
|
+
// The RFC 7292 Appendix C / RFC 8018 PBES1 legacy PKCS#12 PBE schemes: the App. B.2 KDF (BMPString password)
|
|
644
|
+
// derives a cipher key (id=1) + IV (id=2), then a legacy CBC cipher decrypts the bag. An `openssl pkcs12
|
|
645
|
+
// -legacy` store uses pbeWithSHAAnd3-KeyTripleDES-CBC for keys and 40-bit RC2 for certs by default. RC2 and
|
|
646
|
+
// RC4 were moved to OpenSSL 3.x's legacy provider (not loaded by node), so they are recognized but refused
|
|
647
|
+
// with an actionable message rather than silently failing; the 3DES schemes decrypt via node.
|
|
648
|
+
// Keyed by the IMMUTABLE dotted OID (resolved once at module load via oid.byName), NOT the display name --
|
|
649
|
+
// so `pki.oid.register` renaming a built-in OID, or aliasing a legacy name onto an unrelated private OID,
|
|
650
|
+
// cannot alter which cipher a bag decrypts under (the PBES2 branch dispatches on the OID the same way).
|
|
651
|
+
var LEGACY_PBE = Object.create(null);
|
|
652
|
+
LEGACY_PBE[O("pbeWithSHAAnd3-KeyTripleDES-CBC")] = { cipher: "des-ede3-cbc", keyLen: 24, ivLen: 8, hash: "sha1" };
|
|
653
|
+
LEGACY_PBE[O("pbeWithSHAAnd2-KeyTripleDES-CBC")] = { cipher: "des-ede-cbc", keyLen: 16, ivLen: 8, hash: "sha1" };
|
|
654
|
+
LEGACY_PBE[O("pbeWithSHAAnd128BitRC2-CBC")] = { rc2: 128, keyLen: 16, ivLen: 8, hash: "sha1" };
|
|
655
|
+
LEGACY_PBE[O("pbeWithSHAAnd40BitRC2-CBC")] = { rc2: 40, keyLen: 5, ivLen: 8, hash: "sha1" };
|
|
656
|
+
LEGACY_PBE[O("pbeWithSHAAnd128BitRC4")] = { rc4: true };
|
|
657
|
+
LEGACY_PBE[O("pbeWithSHAAnd40BitRC4")] = { rc4: true };
|
|
658
|
+
|
|
659
|
+
// Decrypt a legacy-PBE bag. pkcs-12PbeParams ::= SEQUENCE { salt OCTET STRING, iterations INTEGER }. Every
|
|
660
|
+
// secret-dependent fault (a bad key -> a CBC unpad failure) collapses to the uniform pkcs12/decrypt-failed
|
|
661
|
+
// (no padding oracle, RFC 8018 sec. 8). The iteration count is DoS-capped BEFORE the sync KDF runs.
|
|
662
|
+
function _decryptLegacyPbe(ea, ct, password, opts, budget) {
|
|
663
|
+
var scheme = LEGACY_PBE[ea.oid];
|
|
664
|
+
if (!scheme) throw _err("pkcs12/unsupported-algorithm", "the bag uses " + (ea.name || ea.oid) + " (only RFC 8018 PBES2 and the RFC 7292 App. C 3DES / RC2 schemes are decrypted; re-export with -keypbe/-certpbe AES-256-CBC)");
|
|
665
|
+
if (scheme.rc4) throw _err("pkcs12/unsupported-algorithm", "the bag uses " + ea.name + " -- RC4 legacy PBE is not supported; re-export with -keypbe/-certpbe AES-256-CBC or PBE-SHA1-3DES");
|
|
666
|
+
// Decode + read pkcs-12PbeParams, wrapping EVERY fault in a typed pkcs12 error so a malformed legacy store is
|
|
667
|
+
// classified like a malformed PBES2 one (pkcs12/bad-algorithm-parameters), never a leaked asn1/* code: omitted
|
|
668
|
+
// parameters make asn1.decode throw asn1/not-buffer, a wrong-type salt/iterations makes asn1.read.* throw.
|
|
669
|
+
// Content regions are normatively BER (RFC 7292 sec. 4.1) and the parser accepts BER anywhere, so a conforming
|
|
670
|
+
// store may use an indefinite-length SEQUENCE or a constructed OCTET STRING salt -- decode in BER mode.
|
|
671
|
+
var salt, iterations;
|
|
672
|
+
try {
|
|
673
|
+
var params = asn1.decode(ea.parameters, { ber: true });
|
|
674
|
+
// Coverage residual -- the tagNumber!==SEQUENCE arm is exercised (a non-SEQUENCE params vector); the
|
|
675
|
+
// !children and length<2 arms are defensive-unreachable via a conforming decode (asn1.decode of a
|
|
676
|
+
// constructed node always sets a children array, and every real pkcs-12PbeParams carries salt+iterations).
|
|
677
|
+
if (params.tagNumber !== asn1.TAGS.SEQUENCE || !params.children || params.children.length < 2) throw _err("pkcs12/bad-algorithm-parameters", "malformed pkcs-12PbeParams (RFC 7292 App. C)");
|
|
678
|
+
salt = Buffer.from(asn1.read.octetString(params.children[0]));
|
|
679
|
+
iterations = guard.range.positiveInt31(asn1.read.integer(params.children[1]), _err, "pkcs12/bad-algorithm-parameters", "legacy PBE iterations");
|
|
680
|
+
} catch (e) {
|
|
681
|
+
if (e && e.isPkcs12Error) throw e; // an already-typed pkcs12 fault (the checks above) passes through
|
|
682
|
+
throw _err("pkcs12/bad-algorithm-parameters", "malformed pkcs-12PbeParams (RFC 7292 App. C)", e);
|
|
683
|
+
}
|
|
684
|
+
var cap = opts.maxIterations != null ? Math.min(opts.maxIterations, CLASSIC_MAC_MAX_ITERATIONS) : CLASSIC_MAC_MAX_ITERATIONS;
|
|
685
|
+
if (iterations > cap) throw _err("pkcs12/iteration-limit", "the legacy PBE iteration count " + iterations + " exceeds the cap " + cap);
|
|
686
|
+
// Coverage residual -- the salt cap is the shared C.LIMITS.PBKDF2_MAX_SALT the classic MAC / PBES2 paths
|
|
687
|
+
// already exercise; reaching this legacy-path duplicate needs a >1024-octet salt (a length-cascade-crafted
|
|
688
|
+
// store no conforming producer emits), so the over-cap arm is documented rather than fixture-forced.
|
|
689
|
+
if (salt.length > C.LIMITS.PBKDF2_MAX_SALT) throw _err("pkcs12/bad-input", "the legacy PBE salt exceeds the " + C.LIMITS.PBKDF2_MAX_SALT + "-octet cap");
|
|
690
|
+
// Charge the aggregate KDF-work budget BEFORE deriving: the App. B KDF hashes `iterations` times per output
|
|
691
|
+
// block, and this bag derives ceil(keyLen/u) key blocks + ceil(ivLen/u) IV blocks. A hostile many-bag store
|
|
692
|
+
// exhausts the shared budget instead of resetting the per-bag cap each time.
|
|
693
|
+
var u = P12_KDF_UV[scheme.hash].u;
|
|
694
|
+
budget.rounds -= (Math.ceil(scheme.keyLen / u) + Math.ceil(scheme.ivLen / u)) * iterations;
|
|
695
|
+
if (budget.rounds < 0) throw _err("pkcs12/iteration-limit", "the store's aggregate legacy PBE key-derivation work exceeds the budget (a hostile many-bag store)");
|
|
696
|
+
var p12pw = _p12Password(password); // App. B.1 BMPString + NULL (NOT the PBES2 UTF-8 encoding)
|
|
697
|
+
var keyM = _p12Kdf(scheme.hash, 1, p12pw, salt, iterations, scheme.keyLen);
|
|
698
|
+
var iv = _p12Kdf(scheme.hash, 2, p12pw, salt, iterations, scheme.ivLen);
|
|
699
|
+
// BOTH ciphers funnel through ONE uniform failure (same code AND message), so a bad-pad (secret-dependent)
|
|
700
|
+
// and a valid-pad-wrong-content outcome are indistinguishable -- no CBC padding oracle (RFC 8018 sec. 8). RC2
|
|
701
|
+
// is unavailable in node; the in-tree RFC 2268 primitive fills the gap and its typed reject is normalized here.
|
|
702
|
+
try {
|
|
703
|
+
if (scheme.rc2) return rc2.cbcDecrypt(keyM, scheme.rc2, iv, ct, _err, "pkcs12/decrypt-failed");
|
|
704
|
+
var d = nodeCrypto.createDecipheriv(scheme.cipher, keyM, iv);
|
|
705
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
706
|
+
} catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Decrypt a PBES2 (RFC 8018) or legacy-PBE (RFC 7292 App. C) bag/safe -- dispatch on the encryptionAlgorithm
|
|
710
|
+
// OID. PBES2 uses the UTF-8 password (the pinned interop convention); legacy PBE uses the App. B.1 BMPString.
|
|
711
|
+
function _decryptBag(ea, ct, password, opts, budget) {
|
|
712
|
+
if (ea.oid === O("pbes2")) return pbes2.pbes2Decrypt(_pbePassword(password), ea.parameters, ct, opts, _err, "pkcs12");
|
|
713
|
+
return _decryptLegacyPbe(ea, ct, password, opts, budget);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// A pkcs8ShroudedKeyBag is a bare EncryptedPrivateKeyInfo (RFC 5958 sec. 3): decrypt to a PrivateKeyInfo,
|
|
614
717
|
// re-validated (the re-parse is the MAC-less integrity check for the bag ciphertext).
|
|
615
|
-
function _decryptShroudedKey(encrypted,
|
|
616
|
-
|
|
617
|
-
var der = pbes2.pbes2Decrypt(pwBytes, encrypted.encryptionAlgorithm.parameters, encrypted.encryptedData, opts, _err, "pkcs12");
|
|
718
|
+
function _decryptShroudedKey(encrypted, password, opts, budget) {
|
|
719
|
+
var der = _decryptBag(encrypted.encryptionAlgorithm, encrypted.encryptedData, password, opts, budget);
|
|
618
720
|
try { pkcs8.parse(der); } catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
|
|
619
721
|
return der;
|
|
620
722
|
}
|
|
@@ -632,7 +734,7 @@ function _mapDecryptError(e) {
|
|
|
632
734
|
// A privacy safe: id-encryptedData (PBES2 password) OR id-envelopedData (public-key, RFC 7292 sec. 3.1) --
|
|
633
735
|
// decrypt the SafeContents, then re-walk it through the strict parser (same PKCS12_MAX_* caps) and open each
|
|
634
736
|
// recovered bag. Async because the envelope path awaits pki.cms.decrypt.
|
|
635
|
-
async function _openEncryptedSafe(encSafe,
|
|
737
|
+
async function _openEncryptedSafe(encSafe, password, opts, out, depth, budget) {
|
|
636
738
|
if (encSafe.type === "envelopedData") {
|
|
637
739
|
// INTEGRITY-BEFORE-USE: open()'s pre-loop MAC / SignedData gate already ran over the WHOLE AuthenticatedSafe
|
|
638
740
|
// (which covers this enveloped element), so the ciphertext reaching cms.decrypt is integrity-checked. The
|
|
@@ -649,14 +751,13 @@ async function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
|
|
|
649
751
|
// bad pad -- a distinguishable structural code here would be a padding oracle. The strict walk's caps fire.
|
|
650
752
|
try { envBags = schemaPkcs12.walkSafeContents(res.content); }
|
|
651
753
|
catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
|
|
652
|
-
for (var k = 0; k < envBags.length; k++) _openBag(envBags[k],
|
|
754
|
+
for (var k = 0; k < envBags.length; k++) _openBag(envBags[k], password, opts, out, depth, budget);
|
|
653
755
|
return;
|
|
654
756
|
}
|
|
655
757
|
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)");
|
|
656
758
|
var eci = encSafe.content.encryptedContentInfo;
|
|
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)");
|
|
658
759
|
if (eci.encryptedContent == null) throw _err("pkcs12/bad-input", "the encrypted privacy safe has no content");
|
|
659
|
-
var safeContentsDer =
|
|
760
|
+
var safeContentsDer = _decryptBag(eci.contentEncryptionAlgorithm, eci.encryptedContent, password, opts, budget);
|
|
660
761
|
// The bag/safe password MAY differ from the (already-verified) MAC password, so a decrypt under the MAC
|
|
661
762
|
// password can succeed with a valid pad yet yield bytes that are not a SafeContents. Collapse THAT re-parse
|
|
662
763
|
// failure into the same uniform pkcs12/decrypt-failed as a bad pad -- a distinguishable structural code here
|
|
@@ -664,7 +765,7 @@ async function _openEncryptedSafe(encSafe, pwBytes, opts, out, depth) {
|
|
|
664
765
|
var bags;
|
|
665
766
|
try { bags = schemaPkcs12.walkSafeContents(safeContentsDer); }
|
|
666
767
|
catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
|
|
667
|
-
for (var i = 0; i < bags.length; i++) _openBag(bags[i],
|
|
768
|
+
for (var i = 0; i < bags.length; i++) _openBag(bags[i], password, opts, out, depth, budget);
|
|
668
769
|
}
|
|
669
770
|
|
|
670
771
|
module.exports = {
|