@blamejs/pki 0.7.40 → 0.7.42
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 +21 -0
- package/README.md +1 -1
- package/lib/crmf-sign.js +34 -1
- package/lib/est.js +15 -1
- package/lib/key.js +59 -17
- package/lib/smime.js +133 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ 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.7.42 — 2026-09-12
|
|
8
|
+
|
|
9
|
+
An S/MIME message is signed only under a certificate a receiving agent accepts a mail signature from.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- pki.smime.sign refuses, with smime/bad-signer-certificate, a signer certificate whose keyUsage extension asserts neither digitalSignature nor nonRepudiation (RFC 8550 sec. 4.4.2) or whose extendedKeyUsage extension carries neither emailProtection nor anyExtendedKeyUsage (sec. 4.4.4), naming the signer by position. A certificate that carries neither extension signs, as a reader presumes both key usage bits and any purpose for it. The rule is about the certificate the message carries: a key-only signer, which RFC 8550 sec. 3 permits where correspondents hold each other's certificates by other means, still signs, and the reader holds the certificate it supplies to the same rule. The signer descriptors are read once, so a certificate accessor answers the same certificate to this check and to the signing, and a sparse signer list is refused before it is traversed.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- The emailProtection requirement pki.smime.verify applies to a trusted signer is cited to RFC 8550 sec. 4.4.4, the certificate-handling specification, in the README and the reference; it was cited to RFC 8551.
|
|
18
|
+
|
|
19
|
+
## v0.7.41 — 2026-09-12
|
|
20
|
+
|
|
21
|
+
A delivered or archived private key is proven the private half of its certificate with the key itself.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- pki.est.serverkeygen proves a cleartext server-generated private key is the private half of the returned certificate it binds it to (a signature, an agreement, or an encapsulation the key completes), and refuses with est/key-cert-mismatch a key whose stated public half matches the certificate while its private material cannot use it. The selection of the certificate by public-key match and the ambiguity refusal are unchanged, and a finite-field Diffie-Hellman key, whose private structure carries the exponent alone, is still bound by the value it derives, at any width the runtime reads.
|
|
26
|
+
- pki.crmf.build proves the key enclosed in an encryptedKey proof of possession is the private half of certTemplate.publicKey with the key itself, and refuses with crmf/bad-popo an RSA, EC, EdDSA, ML-DSA, ML-KEM or composite key that only states the template's public components; a key the toolkit cannot read stays crmf/bad-input. A finite-field Diffie-Hellman key, whose private structure carries the exponent alone, keeps the derived comparison at any width, so the archival path still accepts a key wider than any group the toolkit agrees over.
|
|
27
|
+
|
|
7
28
|
## v0.7.40 — 2026-09-12
|
|
8
29
|
|
|
9
30
|
A PKCS#12 store links a certificate only to its own key.
|
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
240
240
|
| `pki.key` | Key-material lifecycle (RFC 5958 / RFC 8018). `encrypt(privateKey, password, opts)` wraps a PKCS#8 private key (DER, PEM, or an extractable `CryptoKey`) into an `EncryptedPrivateKeyInfo` under PBES2 (PBKDF2 with AES-CBC-Pad), where `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, and scrypt are refused), the salt and iteration count are bounded before any derivation (`opts.maxIterations` lowers the cap), and a malformed parameter set or wrong-length IV is a distinct typed error. Because a MAC-less PBES2-CBC decrypt must not become 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)` and `import(input, opts)` move a private key as PKCS#8 or a public key as SubjectPublicKeyInfo. The key may come from the platform's WebCrypto or from a separately installed copy of this toolkit, and is exported through whichever holds its material; a non-extractable key, or one whose implementation keeps its material out of reach, is refused with that as the reason. Encoding is delegated to WebCrypto, so RSA carries an explicit NULL, EC a named curve, and Ed25519/Ed448/X25519/X448 omit parameters, and an ambiguous RSA or EC import requires `opts.algorithm`. `generate(algorithm, opts)` produces a key pair over RSA, ECDSA/ECDH, the Edwards and Montgomery curves, and the FIPS post-quantum ML-DSA and ML-KEM; `publicFromPrivate(privateKey)` derives the public key. Returns DER or PEM, with a typed `KeyError` on failure. Parsing stays at `pki.schema.pkcs8.parse` — `encrypt`, `decrypt`, `export`, `import`, `generate`, `publicFromPrivate` |
|
|
241
241
|
| `pki.pkcs12` | PKCS#12 (.p12/.pfx) issuance and reading (RFC 7292 / RFC 9579). `build(spec, opts)` assembles a store from 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, or nested `safeContents` bags. Keys and certs are validated before wrapping, a key paired with a certificate (the `{ key, cert }` form, or bags sharing a `localKeyId`) must be the private half of that certificate's public key, and `friendlyName` (BMPString) and `localKeyId` are single-value. Integrity is a classic Appendix B HMAC (the default, for maximum interoperability) or an RFC 9579 PBMAC1 (`opts.mac.algorithm`) over SHA-256/384/512, with 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 — which is what OpenSSL and NSS consume, so a file it emits opens in both, cross-checked bidirectionally. The MAC covers 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 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, signed by any `pki.cms.sign` signer and carrying no MacData (§4); privacy stays independent, so `password` still PBES2-encrypts the bags. Public-key privacy wraps a SafeContents as a CMS EnvelopedData (AES-CBC, `id-envelopedData`, never GCM) encrypted to recipient public keys through the `pki.cms.encrypt` recipient model, via per-safe `recipients` or the `opts.recipientCerts` convenience, restricted to certificate recipients (RSA-OAEP, ECDH, X25519, X448, ML-KEM) since a password or KEK recipient could not be reopened by `open`. All four integrity-by-privacy combinations are permitted (§3.1). `open(pfx, password, opts)` reads a store back: it verifies the MAC first, so a wrong password is the MAC verdict rather than 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 and secrets as raw DER, all with `friendlyName` and `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), with the signer surfaced in `signers` but never trust-chained, which remains the caller's `pki.path.validate` step. A legacy-PBE store's Appendix C 3DES and RC2 bags are decrypted, RC2 through an in-tree RFC 2268 cipher, so an `openssl pkcs12 -legacy` or NSS store opens; the legacy RC4 schemes are refused. An `id-envelopedData` safe is decrypted with `opts.recipientKey` after the integrity gate (`pkcs12/no-recipient-key` when absent), every recipient-side fault and every post-integrity decrypt failure collapsing to the uniform `pkcs12/decrypt-failed`, and `opts.keys: 'crypto'` imports each key to a `CryptoKey`. It reads what OpenSSL and NSS produce. Returns DER or a PEM `PKCS12`, with a typed `Pkcs12Error` on failure. Parsing stays at `pki.schema.pkcs12.parse` — `build`, `verifyMac`, `open` |
|
|
242
242
|
| `pki.cms` | CMS signing, verification, encryption, and compression (RFC 5652). `sign(content, signers, opts)` produces a SignedData (§5), attached or detached, with one or many signers over 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, or EdDSA key (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. The content may also be an async iterable of byte chunks, which signs a large detached payload without holding it in memory: the payload is hashed incrementally under every signer's digest algorithm in one pass, and `verify` accepts the same streamed form as its `content` option. A signer may also be key-only — `{ key, spki, keyIdentifier }` with no certificate — which RFC 5272 §3.2 requires when a Full PKI Request's signer is the key of a certification request that request carries: the signer identifier takes the subjectKeyIdentifier form carrying the identifier the request declares, the signature scheme resolves from the request's own public key, and no certificate is embedded. `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: with signed attributes 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), and otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate. `valid` and `trusted` are separate claims and neither implies the other: a SignedData carries its own certificates, so `valid` says the signature is sound under one of them and nothing about who signed, while `trusted` says every signer chained to a root named in `opts.trustAnchors`, validated through the same RFC 5280 path engine `pki.path.validate` uses. Without anchors there is nothing to chain to and `trusted` is `false`; anchors that cannot be read throw, rather than reading as untrusted. An unrecognized option is refused rather than ignored. `countersign(cms, signers, opts)` adds a countersignature (§11.4) — a `SignerInfo` over the countersigned SignerInfo's signature value, any signer algorithm, nestable, with 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 — including an RFC 3161 timestamp token, attachable via `sign`'s `unsignedAttributes` — under `signers[i].unsignedAttrs`, surfaced unauthenticated. `encrypt(content, recipients, opts)` produces an EnvelopedData, AuthEnvelopedData (AES-GCM, the authenticated default), or EncryptedData, with recipients auto-dispatched off the certificate key to key transport (RSAES-OAEP; v1.5 is 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 with RFC 3211 PWRI-KEK), or the post-quantum ML-KEM (and composite ML-KEM, draft-lamps-pq-composite-kem) KEMRecipientInfo (RFC 9629/9936), wrapping one fresh content key for every recipient. The content may also be an async iterable of byte chunks, streamed through the AES-GCM or AES-CBC content cipher so a large plaintext is encrypted without being buffered whole; the ciphertext is assembled before the definite-length encryptedContent is emitted, byte-for-byte the buffered form. `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, and password-oracle freedom), and PKCS#1 v1.5 is decrypt-only under the RFC 3218 implicit-rejection countermeasure. With `opts.stream: true` the recovered `content` is an async iterable of plaintext chunks instead of a single Buffer, for a payload too large to hold whole; the authenticated modes (AuthEnvelopedData, AuthenticatedData, AES-GCM) verify integrity before the iterable yields, so a forged message throws before any plaintext is exposed, while a CBC EnvelopedData or EncryptedData yields the plaintext as the cipher produces it. Every key-establishment secret the toolkit allocates is wiped once used, on the failing path as well as the succeeding one: the KEM shared secret and its derived key-encryption key, the raw ECDH / X25519 / X448 agreement secret, a password-derived key-encryption key, and the content-encryption key itself, cleared once the message is complete since all recipients share it. Caller-supplied key material is never written to (best-effort; NIST SP 800-227 §4.2, RFC 9629 §7). `authenticate(content, recipients, opts)` produces an `id-ct-authData` (§9): cleartext content plus an HMAC-SHA-256/384/512 MAC, authenticated but not encrypted, with the fresh MAC key wrapped for every recipient through the same RecipientInfo model. The MAC covers the authenticated attributes (content-type and 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`. `compress(content, opts)` and `decompress(input, opts)` produce and consume a CompressedData (RFC 3274; 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`. Compression is a size transform with no integrity or confidentiality (RFC 8551 §2.4.5). `certsOnly(certs, opts?)` builds a certs-only certificate-management message (RFC 8551 §3.8): a degenerate SignedData with an empty signerInfos and no eContent that conveys certificates and CRLs without signing anything, `parseCertsOnly(input, opts?)` reads one back to its raw certificate and CRL DER (a CRL-only message is valid here, unlike the cert-required RFC 5272 form that `pki.est.parseCertsOnly` reads), and `isCertsOnly(input)` recognizes one structurally. Fail-closed with typed `cms/*` errors — `sign`, `verify`, `countersign`, `encrypt`, `authenticate`, `decrypt`, `compress`, `decompress`, `certsOnly`, `parseCertsOnly`, `isCertsOnly` |
|
|
243
|
-
| `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while 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, as `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 rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: 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. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC
|
|
243
|
+
| `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while 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, as `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 rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: 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. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC 8550 §4.4.4) and the anchor's own trust metadata must permit that purpose, since a root can be distrusted for email while remaining a good TLS root. `sign` refuses a signer certificate a conforming reader would reject: a `keyUsage` extension without `digitalSignature` or `nonRepudiation` (RFC 8550 §4.4.2), or an `extendedKeyUsage` extension without `emailProtection` or `anyExtendedKeyUsage` (§4.4.4); a certificate carrying neither extension signs. Override either with `requiredEku` / `checkPurpose`. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), 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` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `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 and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` and `openssl cms`. `buildCertsOnly(certs, opts?)` wraps a `pki.cms.certsOnly` certificate-management message in one `application/pkcs7-mime; smime-type=certs-only; name=smime.p7c` entity, read back by OpenSSL's `pkcs7 -print_certs`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress`, `buildCertsOnly` |
|
|
244
244
|
| `pki.tsp` | Time-Stamp Protocol (RFC 3161). `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, and ordering, plus the §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). The TSA certificate is held to §2.3 before signing (one critical extendedKeyUsage naming timeStamping alone) with the codes `verify` answers, `genTime` keeps its milliseconds as the §2.4.2 fraction of a second, and an accuracy naming no field is refused. `request` and `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq); `response` and `parseResponse` handle the TimeStampResp a TSA returns, either a granted status wrapping a token or a rejection with PKIStatus and failure info, with the §2.4.2 status-to-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` — judged under the `timeStamping` purpose, so an anchor's own per-purpose trust metadata reaches the decision rather than sitting inert. It returns `{ valid, trusted, genTime, serialNumber, tstInfo, ... }`, where `trusted` is the separate claim that the authority chained to an anchor you named: without one there is nothing to chain to and it is `false`, which is the distinction a timestamp re-read years later turns on — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
|
|
245
245
|
| `pki.ocsp` | Online Certificate Status Protocol (RFC 6960), both the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs, with the CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile or under SHA-2, plus an optional RFC 9654 nonce (32 to 128 octets, the requester floor that section sets) and an optional requestor signature, under which the signer certificate's subject is the requestorName unless one is stated (RFC 6960 §4.1.2). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER, from the issuing CA directly or a delegated responder, under any `pki.cms.sign` key including the post-quantum ML-DSA and SLH-DSA sets, with `good`, `revoked` (reason and time), or `unknown` per-certificate status, and per-response `singleExtensions` naming the §4.4.4 archive cutoff and the §4.4.2 CRL reference as fields, each read back by name by the parser. The signer refuses what its own lint would grade an error: a pre-encoded singleExtension that RFC 6960 places in requests or in responseExtensions, a CRL entry extension carried at the wrong criticality, and CRL references or entry extensions on a non-issued response, which itself places the §4.4.8 extended revoked definition in responseExtensions without being asked. `buildErrorResponse(status)` produces the unsigned §2.3 error (`tryLater`, `unauthorized`, and the rest). `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 and passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency against `thisUpdate` and `nextUpdate`, and the request-nonce echo. It returns `{ status: "good" / "revoked" / "unknown", ... }` and never silently accepts. `verifyRequest(request, opts)` is the responder-side inverse of `buildRequest`: it verifies a client's signed request (RFC 6960 §4.1.1) under the requestor certificate the request carries, trying each embedded certificate since the field is unordered, and reports `signed`, `signatureValid`, every certificate whose key verified the signature and whose subject is the `requestorName` the request states (`signerCerts`, so a key carried under an expired certificate beside its renewal is not hidden behind ordering, while a same-key certificate under another name is not the requestor) with the first one's decoded subject, the full embedded certificate bag (`certs`, including intermediates for path building), and the request's CertIDs; the name check is reported as `requestorNamed` (RFC 6960 §4.1.2) and `valid` requires it, a request no verifying certificate names falls back to listing the verifying ones with `valid: false`, and an unsigned request is reported with `signed: false` rather than refused. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify`, `verifyRequest` |
|
|
246
246
|
| `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, 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, and `verifySct` verifies an SCT signature against a log's public key, 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. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `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) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and 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 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so 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` and `timestamp`. Above the per-SCT layer, `verifySctList` renders a certificate-level verdict over every SCT a certificate carries (§3.3), reporting how many distinct trusted logs verified an SCT (so a duplicated SCT cannot inflate the count) and from how many distinct trusted operators against a caller's CT policy (`minScts` and `minOperators`, both defaulting to the RFC floor of one), where a policy shortfall is a verdict and a per-SCT failure is a recorded row rather than a thrown call; `x509CertEntry` reconstructs the precertificate log entry from a final certificate by removing the SCT-list extension from its TBSCertificate as byte surgery, so its embedded SCTs verify end to end (§3.2). The toolkit bakes in no vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifySctList`, `x509CertEntry`, `verifyLogListSignature`, `fetchLogList` |
|
package/lib/crmf-sign.js
CHANGED
|
@@ -836,7 +836,38 @@ function _buildEncryptedKeyPop(pop, outerTag, template) {
|
|
|
836
836
|
}
|
|
837
837
|
}
|
|
838
838
|
|
|
839
|
+
async function _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki) {
|
|
840
|
+
var pair;
|
|
841
|
+
try {
|
|
842
|
+
pair = await key.correspondsTo(privateKeyDer, templateSpki);
|
|
843
|
+
} catch (e) {
|
|
844
|
+
if (e instanceof CrmfError) throw e;
|
|
845
|
+
/** @internal A key the key module cannot read at all is bad input; one it reads and cannot
|
|
846
|
+
* exercise together with the template's key proves possession of nothing, which is the proof
|
|
847
|
+
* failing, not the input. */
|
|
848
|
+
if (e && e.isPkiError === true && e.code === "key/bad-input") {
|
|
849
|
+
throw _err("crmf/bad-input", "pop.privateKey could not be read as a private key to check against " +
|
|
850
|
+
"certTemplate.publicKey: " + (e.message || e), e);
|
|
851
|
+
}
|
|
852
|
+
throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession could not be " +
|
|
853
|
+
"exercised with certTemplate.publicKey, so it cannot be shown to be that key's private half (RFC 4211 sec. 4.2)", e);
|
|
854
|
+
}
|
|
855
|
+
if (pair !== true) {
|
|
856
|
+
throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession is not the " +
|
|
857
|
+
"private half of certTemplate.publicKey, so it proves possession of a key this request does not ask to " +
|
|
858
|
+
"have certified (RFC 4211 sec. 4.2)");
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
839
862
|
function _assertEnclosedKeyMatches(privateKeyDer, templateSpki) {
|
|
863
|
+
/** @internal Which proof the enclosed key takes depends on what its structure can carry. A
|
|
864
|
+
* finite-field PrivateKeyInfo carries the exponent alone, so the public value derived from it IS
|
|
865
|
+
* the proof, and this arm keeps that derivation at any width, with the cheap tests below. Every
|
|
866
|
+
* other family's private structure carries public components of its own (RFC 5958 sec. 2,
|
|
867
|
+
* RFC 5915, PKCS #1), which a caller could set to the template's beside private material that
|
|
868
|
+
* cannot use it, so those are proven by USING the key against the template. */
|
|
869
|
+
var enclosedAlg = key.algorithmOidAt(privateKeyDer, 1);
|
|
870
|
+
if (enclosedAlg !== null && !key.isDhOid(enclosedAlg)) return _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki);
|
|
840
871
|
return Promise.resolve()
|
|
841
872
|
.then(function () { return key.publicFromPrivate(privateKeyDer); })
|
|
842
873
|
.then(function (derivedSpki) {
|
|
@@ -991,7 +1022,9 @@ function _buildProofOfPossession(pop, certReqDer, template, signingKey, opts) {
|
|
|
991
1022
|
* intended, so it takes `pop.archive: true`; `pop.privateKey` is the PKCS#8 DER, `pop.recipients` the CMS
|
|
992
1023
|
* recipients to envelope it to, and `pop.identifier` (a string or GeneralName) is required, because
|
|
993
1024
|
* sec. 4.2.1 makes it mandatory whenever the purpose is proving possession: without it a decrypting
|
|
994
|
-
* agent cannot tell whose key it holds.
|
|
1025
|
+
* agent cannot tell whose key it holds. The enclosed key is proven the private half of
|
|
1026
|
+
* `certTemplate.publicKey` with the key itself (`crmf/bad-popo` when it is not), not by the public
|
|
1027
|
+
* components its structure states. `pop.contentEncryptionAlgorithm` defaults to `aes-256-cbc`; an
|
|
995
1028
|
* AEAD algorithm is refused, since it would produce an `AuthEnvelopedData` this field cannot carry.
|
|
996
1029
|
* - `agreeMAC`, which agrees a secret with the authority and MACs the request under a key derived from it,
|
|
997
1030
|
* so nothing is signed (RFC 4211 sec. 4.3, RFC 2875 sec. 3). `pop.key` is the requester's finite-field
|
package/lib/est.js
CHANGED
|
@@ -1742,6 +1742,18 @@ async function _serverkeygenResult(res, opts, derived) {
|
|
|
1742
1742
|
var bound = findIssuedCert(out.certificates, spki);
|
|
1743
1743
|
if (!bound) throw E("est/key-cert-mismatch", "the cleartext server-generated private key matches no returned certificate's public key (RFC 7030 sec. 4.4.2)");
|
|
1744
1744
|
if (findIssuedCert(out.certificates.filter(function (c) { return c !== bound; }), spki)) throw E("est/ambiguous-issued-cert", "more than one returned certificate carries the server-generated key; the issued certificate is ambiguous (RFC 7030 sec. 4.4.2)");
|
|
1745
|
+
/** @internal The public half the key states selected the certificate; the pair is then proven
|
|
1746
|
+
* with the PRIVATE half, since a private structure states public components of its own that a
|
|
1747
|
+
* server could set to the certificate's beside private material that cannot use it. A
|
|
1748
|
+
* finite-field Diffie-Hellman PrivateKeyInfo carries the exponent alone, so the value derived
|
|
1749
|
+
* from it above IS the proof, at any width the runtime reads. */
|
|
1750
|
+
var keyAlg = key.algorithmOidAt(out.privateKeyDer, 1);
|
|
1751
|
+
if (keyAlg === null || !key.isDhOid(keyAlg)) {
|
|
1752
|
+
var pair;
|
|
1753
|
+
try { pair = await key.correspondsTo(out.privateKeyDer, spki); }
|
|
1754
|
+
catch (e2) { throw E("est/key-cert-mismatch", "the cleartext server-generated private key could not be exercised with the returned certificate's public key, so the two cannot be shown to be a pair (RFC 7030 sec. 4.4.2)", e2); }
|
|
1755
|
+
if (pair !== true) throw E("est/key-cert-mismatch", "the cleartext server-generated private key is not the private half of the returned certificate's public key (RFC 7030 sec. 4.4.2)");
|
|
1756
|
+
}
|
|
1745
1757
|
delete out.privateKeyDer;
|
|
1746
1758
|
}
|
|
1747
1759
|
return out;
|
|
@@ -1765,7 +1777,9 @@ async function _serverkeygenResult(res, opts, derived) {
|
|
|
1765
1777
|
* the key so the issued certificate's public key is the generated one, not the throwaway CSR key. A cleartext
|
|
1766
1778
|
* key is bound to its certificate before it resolves: the delivered private key's public half MUST match
|
|
1767
1779
|
* exactly one returned certificate (`est/key-cert-mismatch` on none, `est/ambiguous-issued-cert` on more than
|
|
1768
|
-
* one),
|
|
1780
|
+
* one), and the key is then proven the private half of that certificate's public key with the key itself
|
|
1781
|
+
* (`est/key-cert-mismatch` when it is not; a finite-field DH key, whose structure carries the exponent alone,
|
|
1782
|
+
* is proven by the value it derives), so a mis-associated or unusable key is refused, never handed back.
|
|
1769
1783
|
* The encryption requirement + expected recipient are derived from the CSR's own DecryptKeyIdentifier /
|
|
1770
1784
|
* AsymmetricDecryptKeyIdentifier attribute; an `opts` value that contradicts the CSR is `est/bad-input`
|
|
1771
1785
|
* (a cleartext-key downgrade cannot slip past). The delivered key's channel is asserted confidentiality-
|
package/lib/key.js
CHANGED
|
@@ -680,7 +680,22 @@ function _dhAsPkcs3(der, at, what) {
|
|
|
680
680
|
if (!algNode.children || algNode.children.length !== 2) {
|
|
681
681
|
throw _err("key/bad-input", what + " X9.42 AlgorithmIdentifier does not carry DomainParameters");
|
|
682
682
|
}
|
|
683
|
-
|
|
683
|
+
/** @internal A field the codec refuses to read as the type the structure states is a malformed
|
|
684
|
+
* key, reported as one rather than as the codec's own complaint. */
|
|
685
|
+
var dom, y = null;
|
|
686
|
+
try {
|
|
687
|
+
dom = x942Domain(algNode.children[1], KeyError, "key/bad-input", what);
|
|
688
|
+
if (at === 0) {
|
|
689
|
+
var pub = asn1.read.bitString(node.children[1]);
|
|
690
|
+
if (!guard.crypto.isOctetAligned(pub)) {
|
|
691
|
+
throw _err("key/bad-input", what + " Diffie-Hellman public key BIT STRING must be octet-aligned (0 unused bits)");
|
|
692
|
+
}
|
|
693
|
+
y = asn1.read.integer(asn1.decode(pub.bytes));
|
|
694
|
+
}
|
|
695
|
+
} catch (e) {
|
|
696
|
+
if (e instanceof KeyError) throw e;
|
|
697
|
+
throw _err("key/bad-input", what + " X9.42 Diffie-Hellman key could not be read", e);
|
|
698
|
+
}
|
|
684
699
|
assertDhOperandsWithin([dom.p, dom.g, dom.q, dom.j], KeyError, "key/bad-input", what);
|
|
685
700
|
assertDhDomainCoherent(dom.p, dom.g, dom.q, dom.j, KeyError, "key/bad-input", what);
|
|
686
701
|
assertDhGeneratorOrder(dom.p, dom.g, dom.q, KeyError, "key/bad-input", what);
|
|
@@ -689,11 +704,6 @@ function _dhAsPkcs3(der, at, what) {
|
|
|
689
704
|
/** @internal The one field the X9.42 form states that the PKCS #3 form cannot be held to is the
|
|
690
705
|
* subgroup, so a public value is held to it here, where q is still known. */
|
|
691
706
|
if (at === 0) {
|
|
692
|
-
var pub = asn1.read.bitString(node.children[1]);
|
|
693
|
-
if (!guard.crypto.isOctetAligned(pub)) {
|
|
694
|
-
throw _err("key/bad-input", what + " Diffie-Hellman public key BIT STRING must be octet-aligned (0 unused bits)");
|
|
695
|
-
}
|
|
696
|
-
var y = asn1.read.integer(asn1.decode(pub.bytes));
|
|
697
707
|
if (y <= 1n || y >= dom.p - 1n) {
|
|
698
708
|
throw _err("key/bad-input", what + " Diffie-Hellman public value is outside the group (RFC 2875 sec. 3)");
|
|
699
709
|
}
|
|
@@ -711,19 +721,45 @@ function _dhAsPkcs3(der, at, what) {
|
|
|
711
721
|
* algorithm; every secret the probe makes is wiped on every way out. A public key of another
|
|
712
722
|
* algorithm, composite or not, is another family and not a pair. */
|
|
713
723
|
async function _compositeCorrespondsTo(privDer, spkiDer, privateOid) {
|
|
724
|
+
/** @internal The private half is read first, as on the classical route: a composite key the KEM
|
|
725
|
+
* cannot read is bad input whatever the public half names, decided before the family shortcut
|
|
726
|
+
* answers that the two are not a pair. The derivation's result is not what is compared; the
|
|
727
|
+
* read is what it answers for. */
|
|
728
|
+
try { await compositeKem.publicFromPrivate(privDer); }
|
|
729
|
+
catch (e0) {
|
|
730
|
+
if (_isKemStructureFault(e0)) throw _err("key/bad-input", "the private key could not be read", e0);
|
|
731
|
+
throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e0);
|
|
732
|
+
}
|
|
714
733
|
if (_algorithmOidAt(spkiDer, 0) !== privateOid) return false;
|
|
715
734
|
var sealed = null, opened = null;
|
|
716
735
|
try {
|
|
717
|
-
|
|
718
|
-
|
|
736
|
+
/** @internal A structure the KEM cannot read is bad input, the verdict an unreadable classical
|
|
737
|
+
* key gets from the runtime import above; a decapsulation that runs and fails is the pair not
|
|
738
|
+
* deciding. The KEM tells the two apart by its own codes, and so does this. */
|
|
739
|
+
try { sealed = await compositeKem.encapsulate(spkiDer); }
|
|
740
|
+
catch (e) {
|
|
741
|
+
if (_isKemStructureFault(e)) throw _err("key/bad-input", "the public key could not be read", e);
|
|
742
|
+
throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e);
|
|
743
|
+
}
|
|
744
|
+
try { opened = await compositeKem.decapsulate(privDer, sealed.ciphertext); }
|
|
745
|
+
catch (e2) {
|
|
746
|
+
if (_isKemStructureFault(e2)) throw _err("key/bad-input", "the private key could not be read", e2);
|
|
747
|
+
throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e2);
|
|
748
|
+
}
|
|
719
749
|
return guard.crypto.constantTimeEqual(opened, sealed.sharedSecret);
|
|
720
|
-
} catch (e) {
|
|
721
|
-
throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e);
|
|
722
750
|
} finally {
|
|
723
751
|
guard.secret.zeroizeAll([sealed && sealed.sharedSecret, opened], KeyError, "key/bad-input", "the correspondence probe secrets");
|
|
724
752
|
}
|
|
725
753
|
}
|
|
726
754
|
|
|
755
|
+
var _KEM_STRUCTURE_FAULTS = _assign(_create(null), { "kem/bad-key": 1, "kem/bad-input": 1, "kem/bad-algorithm": 1, "kem/bad-ciphertext": 1 });
|
|
756
|
+
|
|
757
|
+
/** @internal Whether a KEM error says the key or ciphertext could not be READ, as opposed to a
|
|
758
|
+
* decapsulation that ran and failed. */
|
|
759
|
+
function _isKemStructureFault(e) {
|
|
760
|
+
return !!(e && e.isPkiError === true && _KEM_STRUCTURE_FAULTS[guard.text.keyOf(e.code)] === 1);
|
|
761
|
+
}
|
|
762
|
+
|
|
727
763
|
async function publicFromPrivate(privateKey, opts) {
|
|
728
764
|
opts = guard.identifier.optionsObject(opts, _err, "key/bad-input", "pki.key.publicFromPrivate options");
|
|
729
765
|
guard.identifier.assertKnownKeys(opts, _PUBLIC_FROM_PRIVATE_OPTS, _err, "key/bad-input",
|
|
@@ -860,13 +896,15 @@ function _pssSalt(a, b) {
|
|
|
860
896
|
|
|
861
897
|
async function correspondsTo(privateKey, spkiDer) {
|
|
862
898
|
var privDer = await _toPrivateKeyDer(privateKey);
|
|
899
|
+
var spki = _bufferFrom(spkiDer);
|
|
863
900
|
var compositeOid = _compositeKemOidOf(privDer);
|
|
864
|
-
if (compositeOid !== null) return _compositeCorrespondsTo(privDer,
|
|
865
|
-
/** @internal
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
|
|
901
|
+
if (compositeOid !== null) return _compositeCorrespondsTo(privDer, spki, compositeOid);
|
|
902
|
+
/** @internal The private half is read FIRST, whatever the public half names: a private key that
|
|
903
|
+
* cannot be read is bad input, and a caller tells that apart from a pair that is not one. Only
|
|
904
|
+
* then is the family question asked, from the two identifiers, before the public structure is
|
|
905
|
+
* validated: a composite or a finite-field half against a half of another family is not a pair
|
|
906
|
+
* whatever it states, so the runtime is never asked to read a structure it cannot, and a DH
|
|
907
|
+
* structure's validation, which proves its modulus prime, is never run to say so. */
|
|
870
908
|
var priv, pub;
|
|
871
909
|
/** @internal The PKCS #3 rewrite of an X9.42 private key is this function's own copy of the
|
|
872
910
|
* exponent, wiped once the runtime holds the key. */
|
|
@@ -876,7 +914,10 @@ async function correspondsTo(privateKey, spkiDer) {
|
|
|
876
914
|
try { priv = nodeCrypto.createPrivateKey({ key: privAsPkcs3, format: "der", type: "pkcs8" }); }
|
|
877
915
|
catch (e) { throw _err("key/bad-input", "the private key could not be read", e); }
|
|
878
916
|
} finally { if (privAsPkcs3 !== privDer) guard.secret.zeroize(privAsPkcs3, KeyError, "key/bad-input", "the rewritten private key"); }
|
|
879
|
-
var
|
|
917
|
+
var publicOid = _algorithmOidAt(spki, 0);
|
|
918
|
+
if (publicOid !== null && compositeKem.isCompositeKem(publicOid)) return false;
|
|
919
|
+
if (_isDhOid(_algorithmOidAt(privDer, 1)) !== _isDhOid(publicOid)) return false;
|
|
920
|
+
var pubAsPkcs3 = _dhAsPkcs3(spki, 0, "the public key's");
|
|
880
921
|
_assertDhWithin(pubAsPkcs3, 0, "the public key's");
|
|
881
922
|
try { pub = nodeCrypto.createPublicKey({ key: pubAsPkcs3, format: "der", type: "spki" }); }
|
|
882
923
|
catch (e) { throw _err("key/bad-input", "the public key could not be read", e); }
|
|
@@ -1000,6 +1041,7 @@ module.exports = {
|
|
|
1000
1041
|
x942Domain: x942Domain, // @internal
|
|
1001
1042
|
pkcs3Params: pkcs3Params, // @internal
|
|
1002
1043
|
isDhOid: _isDhOid, // @internal
|
|
1044
|
+
algorithmOidAt: _algorithmOidAt, // @internal
|
|
1003
1045
|
assertDhOperandsWithin: assertDhOperandsWithin, // @internal
|
|
1004
1046
|
assertDhDomainCoherent: assertDhDomainCoherent, // @internal
|
|
1005
1047
|
assertDhGeneratorOrder: assertDhGeneratorOrder, // @internal
|
package/lib/smime.js
CHANGED
|
@@ -41,7 +41,9 @@ var schemaX509 = require("./schema-x509.js");
|
|
|
41
41
|
var pkix = require("./schema-pkix.js");
|
|
42
42
|
var oid = require("./oid.js");
|
|
43
43
|
var guard = require("./guard-all.js");
|
|
44
|
+
var pkiBuild = require("./pki-build.js");
|
|
44
45
|
var C = require("./constants.js");
|
|
46
|
+
var nodeUtil = require("util");
|
|
45
47
|
var nodeCrypto = require("crypto");
|
|
46
48
|
|
|
47
49
|
var SmimeError = frameworkError.SmimeError;
|
|
@@ -322,9 +324,130 @@ function _legacyHpSurface(canon, outerEnt, mode, enabled) {
|
|
|
322
324
|
var SAN_OID = oid.byName("subjectAltName");
|
|
323
325
|
var SMTP_UTF8_MAILBOX = oid.byName("smtpUtf8Mailbox");
|
|
324
326
|
var EMAIL_ADDRESS_ATTR = oid.byName("emailAddress");
|
|
327
|
+
var EMAIL_PROTECTION_OID = oid.byName("emailProtection");
|
|
328
|
+
var ANY_EKU_OID = oid.byName("anyExtendedKeyUsage");
|
|
329
|
+
var DESCRIPTOR_MAX_PROTOTYPES = 32;
|
|
330
|
+
var DESCRIPTOR_MAX_FIELDS = 64;
|
|
325
331
|
var _extNs = pkix.makeNS("smime", SmimeError, oid);
|
|
326
332
|
var _extDecoders = pkix.certExtensionDecoders(_extNs);
|
|
327
333
|
var _extCtx = Object.assign(Object.create(null), { E: function (c, m, cause) { return new SmimeError(c, m, cause); }, oid: oid });
|
|
334
|
+
|
|
335
|
+
/** @internal A signer certificate fit to sign mail. RFC 8550 sec. 4.4.2: a receiving agent "MUST
|
|
336
|
+
* NOT accept the signature of a message if it was verified using a certificate that contains a key
|
|
337
|
+
* usage extension without at least one of the digitalSignature or nonRepudiation bits set", and
|
|
338
|
+
* sec. 4.4.4: if the extended key usage extension is present, a receiving agent "MUST check that it
|
|
339
|
+
* contains either the id-kp-emailProtection OID or the anyExtendedKeyUsage OID". A message signed
|
|
340
|
+
* under such a certificate is refused by every conforming reader, so it is not produced. An absent
|
|
341
|
+
* extension permits signing: sec. 4.4.2 has a reader presume both bits, and sec. 4.4.4 lets a
|
|
342
|
+
* certificate with no extended key usage sign. */
|
|
343
|
+
function _assertSignerFitForMail(certDer, position) {
|
|
344
|
+
var parsed;
|
|
345
|
+
try { parsed = schemaX509.parse(certDer); }
|
|
346
|
+
catch (e) { throw _err("smime/bad-input", "signer " + position + "'s certificate is not a well-formed X.509 certificate", e); }
|
|
347
|
+
var label = "signer " + position + "'s certificate";
|
|
348
|
+
var ku = pkix.keyUsageOf(_extNs, parsed, _err, "smime/bad-signer-certificate", label);
|
|
349
|
+
if (ku !== null && !(ku.digitalSignature === true || ku.nonRepudiation === true)) {
|
|
350
|
+
throw _err("smime/bad-signer-certificate", label + " carries a keyUsage extension with neither digitalSignature nor nonRepudiation, and a receiving agent must not accept a signature under it (RFC 8550 sec. 4.4.2)");
|
|
351
|
+
}
|
|
352
|
+
var purposes = pkix.extKeyUsageOf(_extNs, parsed, _err, "smime/bad-signer-certificate", label);
|
|
353
|
+
if (purposes !== null && !guard.list.contains(purposes, EMAIL_PROTECTION_OID) && !guard.list.contains(purposes, ANY_EKU_OID)) {
|
|
354
|
+
throw _err("smime/bad-signer-certificate", label + " carries an extendedKeyUsage extension with neither emailProtection nor anyExtendedKeyUsage, and a receiving agent must check for one of them (RFC 8550 sec. 4.4.4)");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @internal The signer descriptors as this verb read them, held to the rule above before anything
|
|
359
|
+
* is signed, and handed on to pki.cms.sign as the SAME values: every field a descriptor carries, own
|
|
360
|
+
* or inherited, is read once with the descriptor as its receiver into a record, and the certificate
|
|
361
|
+
* is the DER it coerced to, so a `cert` accessor cannot answer one certificate to this check and
|
|
362
|
+
* another to the signing beneath, and an accessor over private state answers to the object it was
|
|
363
|
+
* defined for. A single descriptor is the list of one pki.cms.sign accepts. Anything that is not a
|
|
364
|
+
* descriptor travels as given, for pki.cms.sign to refuse in its own words. */
|
|
365
|
+
function _snapshotSignersFitForMail(signers) {
|
|
366
|
+
/** @internal A proxy, as the list or as a descriptor, is refused before anything trusts what its
|
|
367
|
+
* traps answer, as the toolkit's guarded snapshots refuse one; nothing is read through it. A
|
|
368
|
+
* sparse list is refused at its first missing slot, as pki.cms.sign refuses it, before any slot
|
|
369
|
+
* is visited; a list whose own faults surface is this verb's error too. */
|
|
370
|
+
if (nodeUtil.types.isProxy(signers)) throw _err("smime/bad-input", "the signer list must not be a proxy");
|
|
371
|
+
var list;
|
|
372
|
+
try { list = Array.isArray(signers) ? pkiBuild.reqDenseArray(signers, "the signer list", _err, "smime/bad-input") : [signers]; }
|
|
373
|
+
catch (e) {
|
|
374
|
+
if (e instanceof SmimeError) throw e;
|
|
375
|
+
throw _err("smime/bad-input", "the signer list could not be read", e);
|
|
376
|
+
}
|
|
377
|
+
var out = [];
|
|
378
|
+
for (var i = 0; i < list.length; i++) {
|
|
379
|
+
var s = list[i];
|
|
380
|
+
/** @internal An object or a function is a descriptor pki.cms.sign reads. An absent one travels
|
|
381
|
+
* as given for pki.cms.sign to refuse in its own words; a primitive is refused here, since the
|
|
382
|
+
* only fields it could answer with are ones a built-in prototype was made to carry. */
|
|
383
|
+
if (s === null || s === undefined) { guard.list.append(out, s); continue; }
|
|
384
|
+
if (typeof s !== "object" && typeof s !== "function") throw _err("smime/bad-input", "signer " + (i + 1) + " is not a signer descriptor (an object carrying cert and key)");
|
|
385
|
+
if (nodeUtil.types.isProxy(s)) throw _err("smime/bad-input", "signer " + (i + 1) + " must not be a proxy");
|
|
386
|
+
var copy = _readDescriptor(s, i + 1);
|
|
387
|
+
/** @internal The one read of `cert`, whatever it answered: an absent certificate stays absent,
|
|
388
|
+
* so an accessor cannot answer nothing here and a certificate to the signing beneath. A present
|
|
389
|
+
* one is an owned copy of the bytes, not a view, since a later descriptor's accessor runs after
|
|
390
|
+
* this check and could rewrite a buffer the caller still holds. */
|
|
391
|
+
if (copy.cert != null) {
|
|
392
|
+
/** @internal A certificate that cannot be read, in either encoding, is this verb's bad
|
|
393
|
+
* input, never the PEM codec's own code. */
|
|
394
|
+
try { copy.cert = guard.bytes.snapshot(pkix.coerceToDer(copy.cert, { pemLabel: "CERTIFICATE", PemError: SmimeError, ErrorClass: SmimeError, prefix: "smime" }), SmimeError, "smime/bad-input", "signer " + (i + 1) + "'s certificate"); }
|
|
395
|
+
catch (e) { throw _err("smime/bad-input", "signer " + (i + 1) + "'s certificate could not be read as DER or PEM", e); }
|
|
396
|
+
_assertSignerFitForMail(copy.cert, i + 1);
|
|
397
|
+
}
|
|
398
|
+
guard.list.append(out, copy);
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** @internal Whether a prototype-level property is a method: a data property holding a function. */
|
|
404
|
+
function _isMethod(proto, name) {
|
|
405
|
+
var d = Object.getOwnPropertyDescriptor(proto, name);
|
|
406
|
+
return !!d && !d.get && !d.set && typeof d.value === "function";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @internal Every named field of a descriptor, own or inherited up to the built-in roots (the
|
|
410
|
+
* object and function prototypes carry no descriptor field, and the function one carries accessors
|
|
411
|
+
* that throw on being read), read once with the descriptor as receiver into a record with no
|
|
412
|
+
* prototype. An accessor that throws is a caller fault reported in this verb's class. */
|
|
413
|
+
function _readDescriptor(s, position) {
|
|
414
|
+
var copy = Object.create(null), seen = Object.create(null), fields = 0;
|
|
415
|
+
try {
|
|
416
|
+
/** @internal The descriptor's own level carries every key, Symbol-keyed and `constructor`
|
|
417
|
+
* included, as the caller set them; a prototype level's `constructor` is the class, not a
|
|
418
|
+
* field. A descriptor carries a handful of fields, so one answering more than the cap is
|
|
419
|
+
* refused before they are read. */
|
|
420
|
+
var symbols = Object.getOwnPropertySymbols(s);
|
|
421
|
+
for (var y = 0; y < symbols.length; y++) {
|
|
422
|
+
if (++fields > DESCRIPTOR_MAX_FIELDS) throw _err("smime/bad-input", "signer " + position + " carries more than " + DESCRIPTOR_MAX_FIELDS + " fields");
|
|
423
|
+
copy[symbols[y]] = s[symbols[y]];
|
|
424
|
+
}
|
|
425
|
+
/** @internal The walk is bounded: a proxy may answer itself, or another visited object, as its
|
|
426
|
+
* prototype, and a chain has a depth no descriptor reaches. A prototype level's methods are
|
|
427
|
+
* the class's, not signer fields: neither copied nor counted. */
|
|
428
|
+
var visited = [], depth = 0;
|
|
429
|
+
for (var proto = s; proto !== null && proto !== Object.prototype && proto !== Function.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
430
|
+
if (visited.indexOf(proto) !== -1 || ++depth > DESCRIPTOR_MAX_PROTOTYPES) {
|
|
431
|
+
throw _err("smime/bad-input", "signer " + position + "'s prototype chain is cyclic or deeper than " + DESCRIPTOR_MAX_PROTOTYPES);
|
|
432
|
+
}
|
|
433
|
+
if (nodeUtil.types.isProxy(proto)) throw _err("smime/bad-input", "signer " + position + " must not inherit from a proxy");
|
|
434
|
+
guard.list.append(visited, proto);
|
|
435
|
+
var names = Object.getOwnPropertyNames(proto);
|
|
436
|
+
for (var n = 0; n < names.length; n++) {
|
|
437
|
+
var name = names[n];
|
|
438
|
+
if ((name === "constructor" && proto !== s) || seen[name] === 1) continue;
|
|
439
|
+
if (proto !== s && _isMethod(proto, name)) continue;
|
|
440
|
+
seen[name] = 1;
|
|
441
|
+
if (++fields > DESCRIPTOR_MAX_FIELDS) throw _err("smime/bad-input", "signer " + position + " carries more than " + DESCRIPTOR_MAX_FIELDS + " fields");
|
|
442
|
+
copy[name] = s[name];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
} catch (e) {
|
|
446
|
+
if (e instanceof SmimeError) throw e;
|
|
447
|
+
throw _err("smime/bad-input", "signer " + position + " could not be read as a signer descriptor", e);
|
|
448
|
+
}
|
|
449
|
+
return copy;
|
|
450
|
+
}
|
|
328
451
|
function _signerEmails(certDer) {
|
|
329
452
|
var out = { addresses: [], unreadable: 0 };
|
|
330
453
|
var parsed = null;
|
|
@@ -473,7 +596,14 @@ function _base64Body(der) {
|
|
|
473
596
|
* Assemble a signed S/MIME message (RFC 8551). `content` is the payload: a raw body wrapped as a
|
|
474
597
|
* `text/plain` entity by default, or the caller's own complete MIME entity when `opts.entity` is set;
|
|
475
598
|
* `signers` is the `pki.cms.sign` signer array (any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA
|
|
476
|
-
* signer: the S/MIME layer is algorithm-agnostic).
|
|
599
|
+
* signer: the S/MIME layer is algorithm-agnostic). Each signer's certificate must be one a conforming
|
|
600
|
+
* receiving agent accepts a mail signature under: a `keyUsage` extension it carries must assert
|
|
601
|
+
* `digitalSignature` or `nonRepudiation` (RFC 8550 sec. 4.4.2) and an `extendedKeyUsage` extension it
|
|
602
|
+
* carries must include `emailProtection` or `anyExtendedKeyUsage` (sec. 4.4.4); a certificate with neither
|
|
603
|
+
* extension signs, and one failing either rule is refused with `smime/bad-signer-certificate`. The rule is
|
|
604
|
+
* about the certificate the message carries: a key-only signer (`{ key, spki, keyIdentifier }`, the form
|
|
605
|
+
* RFC 8550 sec. 3 permits where correspondents hold each other's certificates by other means) carries none,
|
|
606
|
+
* and the reader holds the certificate it supplies to the same rule. Two forms via `opts.form`:
|
|
477
607
|
* - `"multipart"` (default, clear-signed): a `multipart/signed` message carrying the canonical entity
|
|
478
608
|
* verbatim in the first part and a DETACHED CMS SignedData (`application/pkcs7-signature`) over its
|
|
479
609
|
* canonical form in the second, with `protocol="application/pkcs7-signature"` + a matching `micalg`.
|
|
@@ -505,6 +635,7 @@ function _base64Body(der) {
|
|
|
505
635
|
async function sign(content, signers, opts) {
|
|
506
636
|
opts = opts || {};
|
|
507
637
|
opts = _knownOpts(opts, SIGN_OPTS, "sign");
|
|
638
|
+
signers = _snapshotSignersFitForMail(signers);
|
|
508
639
|
var hp = opts.protectHeaders === true;
|
|
509
640
|
var hpList = hp ? _hpHeaderList(opts.headers) : null;
|
|
510
641
|
var outerList = hp ? _outerHeaderList(hpList, "clear", opts.hcp) : null;
|
|
@@ -553,7 +684,7 @@ function _capped(msg) {
|
|
|
553
684
|
* certificates, so `valid` says the signature is sound under one of them and nothing about who signed.
|
|
554
685
|
* Name the roots you accept in `opts.trustAnchors` and `trusted` says every signer chained to one,
|
|
555
686
|
* validated for EMAIL, at both ends of the chain. The signer certificate must carry the
|
|
556
|
-
* `emailProtection` key purpose (RFC
|
|
687
|
+
* `emailProtection` key purpose (RFC 8550 sec. 4.4.4), because a certificate restricted to `serverAuth`
|
|
557
688
|
* chains to its root perfectly well and is still the wrong key to have signed a message; and the anchor's
|
|
558
689
|
* own trust metadata must permit that purpose, because a root distributed with NSS trust bits can be
|
|
559
690
|
* marked untrusted for email while remaining a good TLS root. Override either with `opts.requiredEku`
|
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:bc84167a-c3b5-4455-9b3b-6a818f51a616",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-12T22:42:58.983Z",
|
|
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.7.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.7.42",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.42",
|
|
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.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.7.42",
|
|
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.7.
|
|
57
|
+
"ref": "@blamejs/pki@0.7.42",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|