@blamejs/pki 0.3.19 → 0.3.21
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 -1
- package/README.md +2 -2
- package/lib/cms-decrypt.js +14 -1
- package/lib/ct.js +186 -8
- package/lib/pkcs12-build.js +91 -16
- 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,7 +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.
|
|
7
|
+
## v0.3.21 — 2026-07-26
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.ct.fetchLogList(opts) -- fetch the Certificate Transparency log list live and return the trusted-log set only after the detached signature verifies against the caller-pinned distributor key. It GETs opts.url (the log_list.json) and the detached opts.sigUrl (the log_list.sig, by default opts.url with a .json path suffix rewritten to .sig) over the shared pki.transport (or an injected opts.transport), verifies pki.ct.verifyLogListSignature over the raw JSON bytes against opts.signerKey, and only on a valid signature ingests the same bytes through pki.ct.parseLogList -- returning { logs, byLogId, version, timestamp, raw, status, contentType, tls }. No baked-in vendor URL and no baked-in key (both are caller-pinned); explicit TLS trust (an anchor set or an opts.tls.useSystemStore opt-in, rejectUnauthorized always on); each GET is size-capped before verify/parse; every fetch / verify / parse failure is a typed CtError. RFC 6962.
|
|
14
|
+
- pki.ct.parseLogList now also returns the document's version (a string or null) and timestamp (the parsed log_list_timestamp as a Date, or null when absent/unparseable) -- the freshness surface a caller polices, read leniently from the same document. Existing callers of the { logs, byLogId } shape are unaffected.
|
|
15
|
+
|
|
16
|
+
## v0.3.20 — 2026-07-25
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
29
|
+
## v0.3.19 — 2026-07-25
|
|
8
30
|
|
|
9
31
|
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).
|
|
10
32
|
|
package/README.md
CHANGED
|
@@ -230,12 +230,12 @@ 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. 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` |
|
|
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
|
-
| `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. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature` |
|
|
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` |
|
|
239
239
|
| `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
|
240
240
|
| `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
241
241
|
| `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
|
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/ct.js
CHANGED
|
@@ -49,14 +49,17 @@ var oid = require("./oid.js");
|
|
|
49
49
|
var webcrypto = require("./webcrypto.js");
|
|
50
50
|
var validator = require("./validator-all.js");
|
|
51
51
|
var rfc3339 = require("./rfc3339.js");
|
|
52
|
+
var httpTransport = require("./http-transport.js");
|
|
52
53
|
var subtle = webcrypto.webcrypto.subtle;
|
|
53
54
|
|
|
54
55
|
var CtError = frameworkError.CtError;
|
|
56
|
+
var PkiError = frameworkError.PkiError;
|
|
55
57
|
var C = constants;
|
|
56
58
|
|
|
57
|
-
// (code, message) -> CtError, the factory the composed guards
|
|
58
|
-
// malformed value keeps the ct/* typed verdict
|
|
59
|
-
|
|
59
|
+
// (code, message[, cause]) -> CtError, the factory the composed guards + the shared transport throw
|
|
60
|
+
// through so a malformed value keeps the ct/* typed verdict (the optional cause carries the underlying
|
|
61
|
+
// transport / evaluation error; existing 2-arg callers pass undefined -- behavior-preserving).
|
|
62
|
+
function _ctErr(c, m, cause) { return new CtError(c, m, cause); }
|
|
60
63
|
|
|
61
64
|
// RFC 5246 sec. 7.4.1.4.1 code points -- 1-byte, NOT OIDs. Surfaced named; an
|
|
62
65
|
// unknown code surfaces as its numeric byte with a null name (never rejected --
|
|
@@ -664,7 +667,7 @@ function _logsAgree(a, b) {
|
|
|
664
667
|
|
|
665
668
|
/**
|
|
666
669
|
* @primitive pki.ct.parseLogList
|
|
667
|
-
* @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId }
|
|
670
|
+
* @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId, version, timestamp }
|
|
668
671
|
* @since 0.2.28
|
|
669
672
|
* @status experimental
|
|
670
673
|
* @spec RFC 6962
|
|
@@ -678,9 +681,11 @@ function _logsAgree(a, b) {
|
|
|
678
681
|
* `SHA-256(SPKI)` and fail-closed **requires** it equal the stated `log_id` (RFC 6962 sec. 3.2 -- a log
|
|
679
682
|
* whose stated id disagrees with its key is refused as `ct/log-id-mismatch`), and decodes the `state`
|
|
680
683
|
* (exactly one of pending/qualified/usable/readonly/retired/rejected) and `temporal_interval`. Returns
|
|
681
|
-
* `{ logs, byLogId }` where each log is `{ logId, logIdHex, key, description, url, mmd,
|
|
682
|
-
* { name, since, trusted, conditional }, temporalInterval, trusted }
|
|
683
|
-
* `{ logIdHex: log }` map
|
|
684
|
+
* `{ logs, byLogId, version, timestamp }` where each log is `{ logId, logIdHex, key, description, url, mmd,
|
|
685
|
+
* operator, state: { name, since, trusted, conditional }, temporalInterval, trusted }`, `byLogId` is a
|
|
686
|
+
* null-proto `{ logIdHex: log }` map, `version` is the document's version string (or null), and `timestamp`
|
|
687
|
+
* is the parsed `log_list_timestamp` `Date` (or null when absent/unparseable -- the staleness surface, read
|
|
688
|
+
* leniently, never a throw). Every malformed / oversized / mis-bound input is a typed `CtError`.
|
|
684
689
|
*
|
|
685
690
|
* @example
|
|
686
691
|
* var logList = pki.ct.parseLogList(logListJsonBytes);
|
|
@@ -716,7 +721,14 @@ function parseLogList(json, opts) {
|
|
|
716
721
|
}
|
|
717
722
|
}
|
|
718
723
|
}
|
|
719
|
-
|
|
724
|
+
// Surface the document's own version + timestamp additively (the staleness surface the live-fetch client
|
|
725
|
+
// returns for the caller to police freshness). Read LENIENTLY -- a missing / non-string / unparseable
|
|
726
|
+
// scalar yields null, never a new throw, so every existing caller of the { logs, byLogId } shape is
|
|
727
|
+
// behavior-preserving.
|
|
728
|
+
var version = typeof doc.version === "string" ? doc.version : null;
|
|
729
|
+
var timestamp = (typeof doc.log_list_timestamp === "string" && rfc3339.isValid(doc.log_list_timestamp))
|
|
730
|
+
? rfc3339.parse(doc.log_list_timestamp, _ctErr, "ct/bad-date", "log_list_timestamp") : null;
|
|
731
|
+
return { logs: logs, byLogId: byLogId, version: version, timestamp: timestamp };
|
|
720
732
|
}
|
|
721
733
|
|
|
722
734
|
// The covered certificate's notAfter for the temporal gate: an explicit opts.certNotAfter, else derived
|
|
@@ -843,6 +855,171 @@ async function verifyLogListSignature(json, signature, publicKey) {
|
|
|
843
855
|
}
|
|
844
856
|
}
|
|
845
857
|
|
|
858
|
+
// ---- CT log-list live-fetch client (RFC 6962 sec. 3.2; the Chrome/Apple detached log_list.sig model) ----
|
|
859
|
+
|
|
860
|
+
var DEFAULT_FETCH_TIMEOUT = C.TIME.seconds(30);
|
|
861
|
+
var MAX_FETCH_TIMEOUT = C.TIME.seconds(600);
|
|
862
|
+
var KNOWN_FETCH_OPTS = { url: 1, signerKey: 1, sigUrl: 1, transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1, requireJsonContentType: 1 };
|
|
863
|
+
|
|
864
|
+
// Map opts.tls (operator-facing) to the transport request.tls shape. rejectUnauthorized is NOT here -- the
|
|
865
|
+
// transport forces it on unconditionally; the verb never disables server verification.
|
|
866
|
+
function _tlsForFetch(opts) {
|
|
867
|
+
var t = opts.tls || {};
|
|
868
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Do the two URLs share an origin (scheme + host + port)? Both args are already-validated absolute URL
|
|
872
|
+
// strings (each came from `new URL(...).href` after the https gate), so the parse cannot fail here.
|
|
873
|
+
function _sameOrigin(a, b) { return new URL(a).origin === new URL(b).origin; }
|
|
874
|
+
|
|
875
|
+
// The response content-type token (before any ;-parameters), lowercased.
|
|
876
|
+
function _fetchCtypeToken(headers) { return String((headers || {})["content-type"] || "").split(";")[0].trim().toLowerCase(); }
|
|
877
|
+
|
|
878
|
+
// Parse a URL and require https BEFORE any request -- runs even when a transport is INJECTED, so an insecure
|
|
879
|
+
// URL never reaches the wire (the CT log list is fetched over TLS; the detached signature is the authenticity
|
|
880
|
+
// gate, but the fetch stays confined to https, consistent with the est/acme clients). A parse failure is
|
|
881
|
+
// ct/bad-url; a non-https scheme is ct/insecure-url.
|
|
882
|
+
function _parseHttpsUrl(u, label) {
|
|
883
|
+
var parsed;
|
|
884
|
+
try { parsed = new URL(String(u)); }
|
|
885
|
+
catch (e) { throw _ctErr("ct/bad-url", "the CT log-list " + label + " did not parse: " + String(u), e); }
|
|
886
|
+
if (parsed.protocol !== "https:") throw _ctErr("ct/insecure-url", "the CT log-list " + label + " must be https, got " + parsed.protocol + " for " + String(u));
|
|
887
|
+
return parsed;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Derive the detached-signature URL from the (already parsed, https) log_list.json URL by the Chrome/Apple
|
|
891
|
+
// convention: the final ".json" path suffix rewritten to ".sig". A path that does NOT end in ".json" cannot
|
|
892
|
+
// yield an unambiguous sig URL, so the caller must pass opts.sigUrl explicitly (guards never guess a value).
|
|
893
|
+
// The derived URL inherits the json URL's https origin.
|
|
894
|
+
function _sigUrlFor(parsed) {
|
|
895
|
+
if (!/\.json$/.test(parsed.pathname)) throw _ctErr("ct/bad-input", "cannot derive the detached-signature URL from a non-.json path (" + JSON.stringify(parsed.pathname) + "); pass opts.sigUrl explicitly");
|
|
896
|
+
var sig = new URL(parsed.href);
|
|
897
|
+
sig.pathname = parsed.pathname.replace(/\.json$/, ".sig");
|
|
898
|
+
return sig.href;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// One bounded GET returning { body: Buffer, status, contentType, tls }. The body is re-viewed through the
|
|
902
|
+
// byte guard (a detached backing buffer fails as ct/bad-input, never a silent empty body); a string body
|
|
903
|
+
// falls back to a UTF-8 encode -- the width a real socket already counts -- so the size cap and verify/parse
|
|
904
|
+
// see the same bytes, never a typed array stringified to "48,130,..." garbage. An oversized body ->
|
|
905
|
+
// ct/response-too-large (the post-hoc twin of the transport's streaming abort); a non-200 -> ct/http-error
|
|
906
|
+
// carrying the status; an empty 200 body -> ct/empty-response. Both GETs must succeed before verify.
|
|
907
|
+
function _fetchBody(transport, url, req, label) {
|
|
908
|
+
// Wrap the transport call in a Promise.resolve().then so a SYNCHRONOUS throw from an injected transport
|
|
909
|
+
// becomes a rejection the catch below can type (not an uncaught throw crossing the API boundary).
|
|
910
|
+
return Promise.resolve().then(function () {
|
|
911
|
+
return transport({ method: "GET", url: url, headers: req.headers, tls: req.tls, timeout: req.timeout, maxResponseBytes: req.maxResponseBytes });
|
|
912
|
+
}).then(function (res) {
|
|
913
|
+
res = res || {};
|
|
914
|
+
var h = {};
|
|
915
|
+
Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
|
|
916
|
+
var body = (Buffer.isBuffer(res.body) || res.body instanceof Uint8Array)
|
|
917
|
+
? guard.bytes.view(res.body, CtError, "ct/bad-input", label)
|
|
918
|
+
: Buffer.from(String(res.body == null ? "" : res.body), "utf8");
|
|
919
|
+
if (body.length > req.maxResponseBytes) throw _ctErr("ct/response-too-large", "the " + label + " (" + body.length + " bytes) exceeds the " + req.maxResponseBytes + "-byte cap");
|
|
920
|
+
if (res.status !== 200) throw _ctErr("ct/http-error", "the CT server returned HTTP " + JSON.stringify(res.status) + " for the " + label);
|
|
921
|
+
if (body.length === 0) throw _ctErr("ct/empty-response", "the CT server returned a 200 with an empty " + label);
|
|
922
|
+
return { body: body, status: res.status, contentType: _fetchCtypeToken(h), tls: res.tls || null };
|
|
923
|
+
}).catch(function (e) {
|
|
924
|
+
// An already-typed PkiError (the default transport's ct/*, this function's own ct/* verdicts, or a
|
|
925
|
+
// caller-thrown typed error) propagates UNCHANGED; any other throw from an injected transport -- a raw
|
|
926
|
+
// network Error, a synchronous throw -- is wrapped as a typed ct/transport-error so a fetch failure never
|
|
927
|
+
// crosses the API boundary untyped (the documented typed-error contract).
|
|
928
|
+
if (e instanceof PkiError) throw e;
|
|
929
|
+
throw _ctErr("ct/transport-error", "the CT " + label + " request failed in the transport", e);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* @primitive pki.ct.fetchLogList
|
|
935
|
+
* @signature pki.ct.fetchLogList(opts) -> Promise<{ logs, byLogId, version, timestamp, raw, status, contentType, tls }>
|
|
936
|
+
* @since 0.3.21
|
|
937
|
+
* @status experimental
|
|
938
|
+
* @spec RFC 6962
|
|
939
|
+
* @related pki.ct.parseLogList, pki.ct.verifyLogListSignature, pki.ct.verifySctWithLogList
|
|
940
|
+
*
|
|
941
|
+
* Fetch the Certificate Transparency log list live and return the trusted-log set ONLY after the detached
|
|
942
|
+
* signature verifies against the caller-pinned distributor key. It GETs `opts.url` (the `log_list.json`)
|
|
943
|
+
* and the detached `opts.sigUrl` (the `log_list.sig`, by default `opts.url` with a `.json` path suffix
|
|
944
|
+
* rewritten to `.sig`) over the shared, fail-closed `pki.transport` (or an injected `opts.transport`), then
|
|
945
|
+
* verifies the detached signature over the RAW fetched JSON bytes against `opts.signerKey` and only on a
|
|
946
|
+
* strict `true` verdict ingests the SAME bytes through `parseLogList` -- so the client never parses, reads,
|
|
947
|
+
* caches, or surfaces any field of an unverified document (verify-before-parse). No baked-in vendor URL and
|
|
948
|
+
* no baked-in key: the caller pins both out-of-band. Trust is EXPLICIT -- an `opts.tls.anchors` set or an
|
|
949
|
+
* `opts.tls.useSystemStore` opt-in, `rejectUnauthorized` always on. The returned `timestamp` is surfaced
|
|
950
|
+
* (never policed) so the caller enforces its own freshness policy; chaining a resolved log to an SCT is the
|
|
951
|
+
* caller's `verifySctWithLogList` step. Every fetch / verify / parse failure is a typed `CtError`.
|
|
952
|
+
*
|
|
953
|
+
* @opts url REQUIRED -- the `log_list.json` URL; must be https (no baked-in vendor URL).
|
|
954
|
+
* @opts signerKey REQUIRED, PINNED -- the distributor SubjectPublicKeyInfo as a DER Buffer; no baked-in key.
|
|
955
|
+
* @opts sigUrl OPTIONAL -- the detached `log_list.sig` URL (https, must share the log-list URL's origin); default `url` with `.json` -> `.sig` (a non-.json url requires an explicit sigUrl).
|
|
956
|
+
* @opts transport OPTIONAL injectable `transport(request) -> Promise<{status,headers,body}>` (default `pki.transport.https`); the test seam.
|
|
957
|
+
* @opts tls OPTIONAL `{ anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }` threaded to the default transport (ignored when a transport is injected); `rejectUnauthorized` is always on.
|
|
958
|
+
* @opts headers OPTIONAL extra request headers (the request-framing headers are stripped; the verb owns the GET method).
|
|
959
|
+
* @opts timeout OPTIONAL ms budget, default 30s (cap-validated).
|
|
960
|
+
* @opts maxResponseBytes OPTIONAL per-GET size cap, default 4 MiB, tightenable DOWNWARD only.
|
|
961
|
+
* @opts requireJsonContentType OPTIONAL boolean, default false -- opt in to a strict `ct/bad-content-type` gate on the JSON GET.
|
|
962
|
+
* @example
|
|
963
|
+
* // a live distributor uses the default pki.transport.https; here an injected transport returns the pair
|
|
964
|
+
* var r = await pki.ct.fetchLogList({ url: "https://ct.example/log_list.json", signerKey: googleSignerSpki,
|
|
965
|
+
* transport: function (req) {
|
|
966
|
+
* var isSig = /\.sig$/.test(req.url);
|
|
967
|
+
* return Promise.resolve({ status: 200, headers: { "content-type": isSig ? "application/octet-stream" : "application/json" }, body: isSig ? logListSig : logListJsonBytes });
|
|
968
|
+
* } });
|
|
969
|
+
* r.logs[0] && r.logs[0].trusted; // the verified, trusted-log set (the detached signature checked first)
|
|
970
|
+
*/
|
|
971
|
+
async function fetchLogList(opts) {
|
|
972
|
+
opts = opts || {};
|
|
973
|
+
Object.keys(opts).forEach(function (k) { if (!KNOWN_FETCH_OPTS[k]) throw _ctErr("ct/bad-input", "unknown opts field " + JSON.stringify(k)); });
|
|
974
|
+
if (opts.signerKey == null) throw _ctErr("ct/bad-input", "opts.signerKey is required -- the caller-pinned CT log-list distributor SPKI (there is no baked-in key)");
|
|
975
|
+
if (opts.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log_list.json URL (there is no baked-in vendor URL)");
|
|
976
|
+
// Parse + https-gate both URLs BEFORE any transport call -- the gate runs across the injectable seam so an
|
|
977
|
+
// http URL never reaches even an injected transport (the documented HTTPS guarantee holds either way).
|
|
978
|
+
var jsonParsed = _parseHttpsUrl(opts.url, "URL");
|
|
979
|
+
var jsonUrl = jsonParsed.href;
|
|
980
|
+
var sigUrl = opts.sigUrl != null ? _parseHttpsUrl(opts.sigUrl, "signature URL").href : _sigUrlFor(jsonParsed);
|
|
981
|
+
// The detached signature MUST share the log-list URL's origin. The log-list trust rests on the pinned-key
|
|
982
|
+
// signature, not on where the signature is hosted; requiring one origin means both GETs carry the same
|
|
983
|
+
// per-origin credentials + TLS identity with NO cross-origin leak (an Authorization/Cookie header or an
|
|
984
|
+
// mTLS client certificate configured for the log-list host can never reach a different signature host). A
|
|
985
|
+
// caller with a genuinely separate signature host fetches it itself and composes verifyLogListSignature +
|
|
986
|
+
// parseLogList directly.
|
|
987
|
+
if (!_sameOrigin(jsonUrl, sigUrl)) throw _ctErr("ct/bad-input", "opts.sigUrl must share the log-list URL's origin (" + jsonParsed.origin + "); a cross-origin detached-signature host is not supported");
|
|
988
|
+
var transport = opts.transport;
|
|
989
|
+
if (!transport) {
|
|
990
|
+
var t = opts.tls || {};
|
|
991
|
+
var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
|
|
992
|
+
if (!hasAnchors && t.useSystemStore !== true) throw _ctErr("ct/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned CT server (RFC 6962 sec. 3.2)");
|
|
993
|
+
transport = httpTransport.https({ E: _ctErr, errPrefix: "ct" });
|
|
994
|
+
}
|
|
995
|
+
var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_FETCH_TIMEOUT, { E: _ctErr, code: "ct/bad-input", min: 1, max: MAX_FETCH_TIMEOUT });
|
|
996
|
+
var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.CT_LOG_LIST_MAX_BYTES, { E: _ctErr, code: "ct/bad-input", min: 1, max: C.LIMITS.CT_LOG_LIST_MAX_BYTES });
|
|
997
|
+
// Forward custom opts.headers but STRIP the request-framing headers so a caller cannot desync the request
|
|
998
|
+
// framing; the verb owns the GET method (no body).
|
|
999
|
+
var headers = {};
|
|
1000
|
+
Object.keys(opts.headers || {}).forEach(function (k) {
|
|
1001
|
+
var lk = k.toLowerCase();
|
|
1002
|
+
if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = opts.headers[k];
|
|
1003
|
+
});
|
|
1004
|
+
var req = { headers: headers, tls: _tlsForFetch(opts), timeout: timeout, maxResponseBytes: maxResponseBytes };
|
|
1005
|
+
// Two bounded GETs; BOTH must succeed before verify. The JSON is fetched first (its content-type is the one
|
|
1006
|
+
// opts.requireJsonContentType polices). Both requests carry the same per-origin credentials + TLS identity,
|
|
1007
|
+
// which is sound because the signature shares the log-list origin (gated above).
|
|
1008
|
+
var jsonRes = await _fetchBody(transport, jsonUrl, req, "CT log-list JSON");
|
|
1009
|
+
if (opts.requireJsonContentType === true && jsonRes.contentType !== "application/json") throw _ctErr("ct/bad-content-type", "the CT log-list JSON GET returned content-type " + JSON.stringify(jsonRes.contentType || null) + " (opts.requireJsonContentType is set)");
|
|
1010
|
+
var sigRes = await _fetchBody(transport, sigUrl, req, "CT log-list signature");
|
|
1011
|
+
// VERIFY BEFORE PARSE: the fetched JSON is untrusted until the detached signature verifies over the RAW
|
|
1012
|
+
// bytes against the caller-pinned key. parseLogList runs ONLY on a strict === true verdict, over the SAME
|
|
1013
|
+
// Buffer -- nothing re-serialized between verify and parse.
|
|
1014
|
+
var ok = await verifyLogListSignature(jsonRes.body, sigRes.body, opts.signerKey);
|
|
1015
|
+
if (ok !== true) throw _ctErr("ct/log-list-untrusted", "the CT log-list detached signature did not verify against the pinned distributor key -- the fetched list is untrusted and was not parsed");
|
|
1016
|
+
var parsed = parseLogList(jsonRes.body);
|
|
1017
|
+
return {
|
|
1018
|
+
logs: parsed.logs, byLogId: parsed.byLogId, version: parsed.version, timestamp: parsed.timestamp,
|
|
1019
|
+
raw: { json: jsonRes.body, sig: sigRes.body }, status: jsonRes.status, contentType: jsonRes.contentType, tls: jsonRes.tls,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
846
1023
|
module.exports = {
|
|
847
1024
|
parseSctList: parseSctList,
|
|
848
1025
|
reconstructSignedData: reconstructSignedData,
|
|
@@ -852,6 +1029,7 @@ module.exports = {
|
|
|
852
1029
|
parseLogList: parseLogList,
|
|
853
1030
|
verifySctWithLogList: verifySctWithLogList,
|
|
854
1031
|
verifyLogListSignature: verifyLogListSignature,
|
|
1032
|
+
fetchLogList: fetchLogList,
|
|
855
1033
|
HASH_ALGORITHMS: HASH_ALGORITHMS,
|
|
856
1034
|
SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
|
|
857
1035
|
};
|
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");
|
|
@@ -255,14 +259,41 @@ function _buildSafeContents(bags, opts, depth) {
|
|
|
255
259
|
return b.sequence(bags.map(function (bag) { return _buildBag(bag, opts, depth); }));
|
|
256
260
|
}
|
|
257
261
|
|
|
258
|
-
// One AuthenticatedSafe element: a plaintext id-data ContentInfo,
|
|
259
|
-
// EncryptedData is the PBES2 encryption of the SafeContents (RFC 7292 sec. 5.1 step 2A/2B)
|
|
260
|
-
|
|
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) {
|
|
261
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)");
|
|
262
275
|
var safeContentsDer = _buildSafeContents(sc.bags || [], opts, 0);
|
|
263
|
-
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) {
|
|
264
294
|
return b.sequence([b.oid(O("data")), b.explicit(0, b.octetString(safeContentsDer))]);
|
|
265
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");
|
|
266
297
|
var pw = _pbePassword(sc.encrypt.password != null ? sc.encrypt.password : opts.password);
|
|
267
298
|
var r = pbes2.pbes2Encrypt(pw, safeContentsDer, _pbeOpts(sc.encrypt), _err, "pkcs12");
|
|
268
299
|
var eci = b.sequence([b.oid(O("data")), r.algId, b.contextPrimitive(0, r.ct)]); // EncryptedContentInfo, [0] IMPLICIT ct
|
|
@@ -316,11 +347,22 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
|
|
|
316
347
|
function _normalizeSpec(spec, opts) {
|
|
317
348
|
if (spec && Array.isArray(spec.safeContents)) return spec.safeContents;
|
|
318
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");
|
|
319
352
|
var certBags = [];
|
|
320
353
|
if (spec.cert != null) certBags.push({ type: "cert", cert: spec.cert, friendlyName: spec.friendlyName, localKeyId: spec.localKeyId });
|
|
321
354
|
if (spec.ca != null && !Array.isArray(spec.ca)) throw _err("pkcs12/bad-input", "spec.ca must be an array of certificates");
|
|
322
355
|
(spec.ca || []).forEach(function (ca) { certBags.push({ type: "cert", cert: ca }); });
|
|
323
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
|
+
}
|
|
324
366
|
if (certBags.length) sc.push({ encrypt: { password: opts.password }, bags: certBags });
|
|
325
367
|
if (spec.key != null) sc.push({ bags: [{ type: "shroudedKey", key: spec.key, encrypt: { password: opts.password }, friendlyName: spec.friendlyName, localKeyId: spec.localKeyId }] });
|
|
326
368
|
return sc;
|
|
@@ -352,6 +394,7 @@ function _normalizeSpec(spec, opts) {
|
|
|
352
394
|
* - `password` -- the shared privacy + integrity password (string / Buffer / Uint8Array).
|
|
353
395
|
* - `mac` -- `false` for a MAC-less store, or `{ algorithm: 'hmac'(default)|'pbmac1', hash: 'sha256'(default)|'sha1'|'sha384'|'sha512', salt?, iterations?, keyLength? }`.
|
|
354
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.
|
|
355
398
|
* - `pem` (boolean) -- return a PEM `PKCS12` string instead of DER.
|
|
356
399
|
* @example
|
|
357
400
|
* var p12 = await pki.pkcs12.build({ safeContents: [{ bags: [
|
|
@@ -368,7 +411,7 @@ async function build(spec, opts) {
|
|
|
368
411
|
var safeContentsSpecs = _normalizeSpec(spec, opts);
|
|
369
412
|
if (!Array.isArray(safeContentsSpecs) || !safeContentsSpecs.length) throw _err("pkcs12/bad-input", "the store has no safe contents");
|
|
370
413
|
var elements = [];
|
|
371
|
-
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));
|
|
372
415
|
var authSafeDer = b.sequence(elements); // AuthenticatedSafe ::= SEQUENCE OF ContentInfo -- shared by both integrity modes
|
|
373
416
|
|
|
374
417
|
var pfx;
|
|
@@ -470,8 +513,9 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
|
|
|
470
513
|
*
|
|
471
514
|
* Read a PKCS#12 store: verify its integrity FIRST -- a password store's MAC, or a public-key store's CMS
|
|
472
515
|
* SignedData signature (RFC 7292 sec. 4 / 5.1 -- never trust a bag from a store whose integrity check fails) --
|
|
473
|
-
* then decrypt every PBES2 privacy safe and pkcs8ShroudedKeyBag with the password
|
|
474
|
-
*
|
|
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
|
|
475
519
|
* `PrivateKeyInfo` DER (re-validated), each certificate / CRL / secret as raw DER, all carrying their
|
|
476
520
|
* `friendlyName` / `localKeyId` for pairing. `pfx` is a DER `Buffer`, PEM string, or a
|
|
477
521
|
* `pki.schema.pkcs12.parse` result.
|
|
@@ -488,6 +532,7 @@ function _capWork(iterations, salt, opts, keyLength, hardCap) {
|
|
|
488
532
|
* @opts
|
|
489
533
|
* - `allowUnauthenticated` (boolean) -- open a MAC-less store anyway (result carries `macVerified: false`).
|
|
490
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`.
|
|
491
536
|
* - `maxIterations` (number) -- lower the PBKDF2 / MAC iteration cap for this call (downward-only).
|
|
492
537
|
* - `keys` (string) -- `der` (default) or `crypto` (also `pki.key.import` each private key to a CryptoKey).
|
|
493
538
|
* - `importAlgorithm` -- forwarded to `pki.key.import` for the ambiguous RSA / EC arms when `keys: crypto`.
|
|
@@ -527,7 +572,7 @@ async function open(pfx, password, opts) {
|
|
|
527
572
|
var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
|
|
528
573
|
var i;
|
|
529
574
|
for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], pwBytes, opts, out, 0);
|
|
530
|
-
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);
|
|
531
576
|
if (opts.keys === "crypto") {
|
|
532
577
|
for (i = 0; i < out.keys.length; i++) out.keys[i].key = await key.import(out.keys[i].pkcs8, { algorithm: opts.importAlgorithm });
|
|
533
578
|
}
|
|
@@ -574,10 +619,40 @@ function _decryptShroudedKey(encrypted, pwBytes, opts) {
|
|
|
574
619
|
return der;
|
|
575
620
|
}
|
|
576
621
|
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
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)");
|
|
581
656
|
var eci = encSafe.content.encryptedContentInfo;
|
|
582
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)");
|
|
583
658
|
if (eci.encryptedContent == null) throw _err("pkcs12/bad-input", "the encrypted privacy safe has no content");
|
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:b01f035a-7844-490a-a236-5c901a15cfa3",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-26T07:13:35.044Z",
|
|
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.21",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.21",
|
|
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.21",
|
|
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.21",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|