@blamejs/pki 0.2.25 → 0.2.26

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 CHANGED
@@ -4,6 +4,15 @@ 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.2.26 — 2026-07-16
8
+
9
+ S/MIME encryption arrives as pki.smime.encrypt / pki.smime.decrypt: envelope and open RFC 8551 encrypted messages over the CMS layer, with AES-GCM authenticated enveloping as the default and bidirectional OpenSSL interoperability.
10
+
11
+ ### Added
12
+
13
+ - pki.smime.encrypt(content, recipients, opts) envelopes a MIME entity as an encrypted RFC 8551 S/MIME message (opaque application/pkcs7-mime). The default AES-256-GCM content encryption yields an authEnveloped-data message (confidentiality and integrity, RFC 8551 sec. 3.4); opts.contentEncryptionAlgorithm can select AES-CBC for an enveloped-data message (confidentiality only, RFC 8551 sec. 3.3). recipients is the pki.cms.encrypt recipient array (RSA-OAEP key transport, ECDH / X25519 / X448 key agreement, AES key wrap, PBKDF2 password, and post-quantum ML-KEM), and a single descriptor is accepted and normalized to a one-element array. content is wrapped as a text/plain entity by default or taken verbatim with opts.entity. Fail-closed with typed SmimeError.
14
+ - pki.smime.decrypt(message, keyMaterial, opts) opens an encrypted S/MIME message, returning the recovered inner MIME entity plus smimeType, authenticated (true only for an AuthEnvelopedData -- a CBC enveloped-data message reports false, the RFC 8551 sec. 3.3 no-integrity caveat), recipientType, recipientIndex, and contentEncryptionAlgorithm. The smime-type is derived from the CMS body, not the header; opts.strictSmimeType additionally rejects a header smime-type that disagrees with the body. keyMaterial is the pki.cms.decrypt key material ({ key, cert }, { password }, or { kek, kekId }). Decryption is fail-closed and oracle-free. OpenSSL's legacy application/x-pkcs7-mime and a missing smime-type are both accepted. A recovered content that is itself a signed S/MIME message is returned for the caller to feed back to pki.smime.verify. Bidirectionally interoperable with openssl cms -encrypt / -decrypt.
15
+
7
16
  ## v0.2.25 — 2026-07-16
8
17
 
9
18
  S/MIME signed-message assembly and verification arrive as pki.smime: sign and verify RFC 8551 multipart/signed and application/pkcs7-mime messages over the CMS layer, bidirectionally interoperable with OpenSSL.
package/README.md CHANGED
@@ -222,7 +222,7 @@ is callable today; nothing below is a stub.
222
222
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
223
223
  | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
224
  | `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`. **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. Fail-closed with typed `cms/*` errors — `sign`, `verify`, `encrypt`, `decrypt` |
225
- | `pki.smime` | RFC 8551 S/MIME message assembly + verification 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. The crypto is entirely `pki.cms.sign` / `pki.cms.verify` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer carries through (algorithm-agnostic). Like `cms.verify`, it 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. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify` |
225
+ | `pki.smime` | RFC 8551 S/MIME message assembly, verification, and encryption 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. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt` |
226
226
  | `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` |
227
227
  | `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` |
228
228
  | `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). Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct` |
package/lib/smime.js CHANGED
@@ -9,22 +9,27 @@
9
9
  * @slug smime
10
10
  *
11
11
  * @intro
12
- * RFC 8551 S/MIME assembly + verification over the shipped CMS layer.
13
- * `sign` wraps a MIME entity as a signed S/MIME message and `verify` unwraps
14
- * and verifies one, in both forms: `multipart/signed` (clear-signed -- the
15
- * content stays readable, a detached CMS signature rides alongside) and
12
+ * RFC 8551 S/MIME assembly + verification + encryption over the shipped CMS
13
+ * layer. `sign` wraps a MIME entity as a signed S/MIME message and `verify`
14
+ * unwraps and verifies one, in both forms: `multipart/signed` (clear-signed --
15
+ * the content stays readable, a detached CMS signature rides alongside) and
16
16
  * `application/pkcs7-mime` (opaque -- the whole entity is a base64 CMS
17
- * SignedData). The crypto is entirely `pki.cms.sign` / `pki.cms.verify`; the
18
- * new work is the MIME frame and the RFC 8551 sec. 3.1.1 canonicalization the
19
- * detached signature is computed over -- signer and verifier share one
20
- * canonicalizer (`lib/mime.js`) so their digests cannot diverge. Like
17
+ * SignedData). `encrypt` envelopes a MIME entity as an opaque
18
+ * `application/pkcs7-mime` message and `decrypt` opens one -- `authEnveloped-data`
19
+ * (AES-GCM, confidentiality AND integrity, the default) or `enveloped-data`
20
+ * (AES-CBC, confidentiality only, so `decrypt` surfaces `authenticated:false`,
21
+ * the EFAIL / RFC 8551 sec. 3.3 no-integrity caveat). The crypto is entirely
22
+ * `pki.cms.sign` / `verify` / `encrypt` / `decrypt`; the new work is the MIME
23
+ * frame and the RFC 8551 sec. 3.1.1 canonicalization signer and verifier share
24
+ * one canonicalizer (`lib/mime.js`) so their digests cannot diverge. Like
21
25
  * `cms.verify`, `verify` returns the per-signer cryptographic verdict; chaining
22
26
  * a signer to a trust anchor is the caller's `pki.path.validate` step.
23
27
  *
24
28
  * @card
25
- * Assemble + verify RFC 8551 S/MIME signed messages (multipart/signed +
26
- * application/pkcs7-mime) over any CMS signer -- shared canonicalization,
27
- * fail-closed, algorithm-agnostic.
29
+ * Assemble, verify, and encrypt RFC 8551 S/MIME messages (signed:
30
+ * multipart/signed + application/pkcs7-mime; encrypted: enveloped-data +
31
+ * authEnveloped-data) over any CMS signer / recipient -- fail-closed,
32
+ * algorithm-agnostic.
28
33
  */
29
34
 
30
35
  var frameworkError = require("./framework-error.js");
@@ -235,6 +240,128 @@ async function verify(message, opts) {
235
240
  throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
236
241
  }
237
242
 
243
+ // The application/pkcs7-mime header for an enveloped S/MIME message (RFC 8551 sec. 3.2 / sec. 3.2.2).
244
+ function _pkcs7MimeHead(smimeType) {
245
+ return Buffer.from("Content-Type: application/pkcs7-mime; smime-type=" + smimeType + "; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "latin1");
246
+ }
247
+
248
+ // The smime-type for a CMS enveloped body, DERIVED from its OUTER content type (RFC 8551 sec. 3.2.2) --
249
+ // the single source of truth, never an opts guess: AuthEnvelopedData -> "authEnveloped-data" (AEAD,
250
+ // integrity), EnvelopedData -> "enveloped-data" (no integrity). Anything else is not an S/MIME enveloped
251
+ // message. Returns { smimeType, name } (name is the parsed contentTypeName). Throws smime/unsupported-type
252
+ // for a non-enveloped body (EncryptedData / SignedData carries no smime-type mapping here).
253
+ function _envelopedTypeOf(der, badParseCode) {
254
+ var name;
255
+ try { name = schemaCms.parse(guard.bytes.view(der, SmimeError, "smime/bad-mime", "the CMS body")).contentTypeName; }
256
+ catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime body is not a parseable CMS structure", e); }
257
+ if (name === "authEnvelopedData") return "authEnveloped-data";
258
+ if (name === "envelopedData") return "enveloped-data";
259
+ throw _err(badParseCode, "the pkcs7-mime body is a " + name + ", not an EnvelopedData / AuthEnvelopedData");
260
+ }
261
+
262
+ // Map smime opts to cms.encrypt opts. The S/MIME layer is recipient-agnostic: it forwards the content
263
+ // -encryption choice + every recipient-shaping opt unchanged, and never emits PEM (the CMS body is base64
264
+ // inside a MIME frame). opts.contentType is the MIME media type of the INNER entity (handled by
265
+ // _entityBytes), NOT the CMS encapsulated content type -- it is deliberately not forwarded here.
266
+ function _cmsEncryptOpts(opts) {
267
+ var o = {};
268
+ if (opts.contentEncryptionAlgorithm !== undefined) o.contentEncryptionAlgorithm = opts.contentEncryptionAlgorithm;
269
+ if (opts.oaepHash !== undefined) o.oaepHash = opts.oaepHash;
270
+ if (opts.keyIdentifier !== undefined) o.keyIdentifier = opts.keyIdentifier;
271
+ if (opts.ukm !== undefined) o.ukm = opts.ukm;
272
+ return o;
273
+ }
274
+
275
+ /**
276
+ * @primitive pki.smime.encrypt
277
+ * @signature pki.smime.encrypt(content, recipients, opts?) -> Promise<Buffer>
278
+ * @since 0.2.26
279
+ * @status experimental
280
+ * @spec RFC 8551, RFC 5652, RFC 5083
281
+ * @related pki.smime.decrypt, pki.cms.encrypt
282
+ *
283
+ * Envelope a MIME entity as an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4). `content` is the
284
+ * payload -- a raw body wrapped as a `text/plain` entity by default, or the caller's own complete MIME
285
+ * entity when `opts.entity` is set; `recipients` is the `pki.cms.encrypt` recipient array (any RSA-OAEP
286
+ * ktri / EC or X25519/X448 kari / ML-KEM ori-KEM / password pwri / kek kekri -- the S/MIME layer is
287
+ * recipient-agnostic; a single descriptor is accepted and normalized to a one-element array). Enveloping
288
+ * has ONE form -- opaque `application/pkcs7-mime` with the whole entity base64-encoded. The `smime-type`
289
+ * is derived from the produced CMS: AES-GCM (the default) yields an AuthEnvelopedData with
290
+ * `smime-type=authEnveloped-data` (confidentiality AND integrity); a CBC choice yields an EnvelopedData
291
+ * with `smime-type=enveloped-data` (confidentiality only -- no integrity, RFC 8551 sec. 3.3). Returns the
292
+ * assembled message bytes. Fail-closed with `SmimeError`.
293
+ *
294
+ * @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
295
+ * @opts contentType the wrapped entity's MIME Content-Type (default `text/plain; charset=utf-8`).
296
+ * @opts contentEncryptionAlgorithm forwarded to cms.encrypt: `"aes-256-gcm"` (default) / `"aes-128-gcm"` / `"aes-256-cbc"` / `"aes-128-cbc"`.
297
+ * @opts oaepHash forwarded: the RSAES-OAEP hash for ktri recipients.
298
+ * @opts keyIdentifier forwarded: `"issuerAndSerial"` (default) or `"subjectKeyIdentifier"`.
299
+ * @opts ukm forwarded: user keying material for kari / kemri recipients.
300
+ * @example
301
+ * var enc = await pki.smime.encrypt(Buffer.from("secret"), [{ cert: recipientCertDer }]);
302
+ */
303
+ async function encrypt(content, recipients, opts) {
304
+ opts = opts || {};
305
+ var entity = _entityBytes(content, opts);
306
+ // Normalize a single descriptor to an array so cms.encrypt always takes the ENVELOPED path (an array of
307
+ // RecipientInfos), never bare EncryptedData -- which is not an S/MIME construct (no smime-type maps to it).
308
+ var recips = Array.isArray(recipients) ? recipients : [recipients];
309
+ var der = _toBuf(await cms.encrypt(entity, recips, _cmsEncryptOpts(opts)));
310
+ var head = _pkcs7MimeHead(_envelopedTypeOf(der, "smime/bad-mime"));
311
+ return _capped(Buffer.concat([head, _base64Body(der), mime.CRLF]));
312
+ }
313
+
314
+ /**
315
+ * @primitive pki.smime.decrypt
316
+ * @signature pki.smime.decrypt(message, keyMaterial, opts?) -> Promise<{ content, smimeType, authenticated, recipientType, recipientIndex, contentEncryptionAlgorithm }>
317
+ * @since 0.2.26
318
+ * @status experimental
319
+ * @spec RFC 8551, RFC 5652, RFC 5083
320
+ * @related pki.smime.encrypt, pki.cms.decrypt, pki.smime.verify
321
+ *
322
+ * Open an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4) -- an `application/pkcs7-mime` entity
323
+ * whose base64 body is a CMS EnvelopedData or AuthEnvelopedData. `keyMaterial` is the `pki.cms.decrypt`
324
+ * key material (`{ key, cert }`, `{ password }`, or `{ kek, kekId? }`). Returns the recovered inner MIME
325
+ * entity as `content`, the `smimeType`, `authenticated` (true only for AuthEnvelopedData -- a CBC
326
+ * `enveloped-data` message reports `false`, the RFC 8551 sec. 3.3 / EFAIL no-integrity caveat), and the
327
+ * `recipientType` / `recipientIndex` / `contentEncryptionAlgorithm` from the CMS layer. Fail-closed and
328
+ * oracle-free: every secret-dependent failure collapses to the uniform `cms/decrypt-failed` the CMS layer
329
+ * emits (this layer only propagates it). A recovered `content` that is itself a signed S/MIME message is
330
+ * returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
331
+ * legacy `application/x-pkcs7-mime` and a missing `smime-type`.
332
+ *
333
+ * @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
334
+ * @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
335
+ * @opts strictSmimeType reject a header `smime-type` that disagrees with the CMS body (`smime/smime-type-mismatch`).
336
+ * @example
337
+ * var res = await pki.smime.decrypt(smimeMessageBytes, { key: recipientKeyPkcs8, cert: recipientCertDer });
338
+ * res.content; // the recovered inner MIME entity
339
+ * res.authenticated; // false for an enveloped-only (CBC) message -- no integrity
340
+ */
341
+ async function decrypt(message, keyMaterial, opts) {
342
+ opts = opts || {};
343
+ var ent = mime.parse(message, SmimeError, "smime/bad-mime");
344
+ var ct = ent.contentType;
345
+ if (!_isPkcs7(ct.type, "mime")) throw _err("smime/unsupported-type", "not an encrypted S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
346
+ // The smime-type header is advisory (the CMS body is authoritative), so compare it case-insensitively --
347
+ // liberal on accept, strict on emit -- rather than rejecting a legitimate message over its parameter casing.
348
+ var st = ct.params["smime-type"];
349
+ var stLower = st ? st.toLowerCase() : st;
350
+ if (st && stLower !== "enveloped-data" && stLower !== "authenveloped-data") throw _err("smime/unsupported-type", "unsupported smime-type " + JSON.stringify(st) + " (only enveloped-data / authEnveloped-data)");
351
+ var der = _decodeCms(ent);
352
+ var smimeType = _envelopedTypeOf(der, "smime/unsupported-type");
353
+ if (opts.strictSmimeType && st && stLower !== smimeType.toLowerCase()) throw _err("smime/smime-type-mismatch", "the header smime-type " + JSON.stringify(st) + " disagrees with the CMS body (" + smimeType + ")");
354
+ var cmsOpts = {};
355
+ if (opts.recipientIndex !== undefined) cmsOpts.recipientIndex = opts.recipientIndex;
356
+ if (opts.maxIterations !== undefined) cmsOpts.maxIterations = opts.maxIterations;
357
+ var res = await cms.decrypt(der, keyMaterial, cmsOpts);
358
+ return {
359
+ content: res.content, smimeType: smimeType, authenticated: res.authenticated,
360
+ recipientType: res.recipientType, recipientIndex: res.recipientIndex,
361
+ contentEncryptionAlgorithm: res.contentEncryptionAlgorithm,
362
+ };
363
+ }
364
+
238
365
  // Decode a CMS body per its Content-Transfer-Encoding (base64 is the S/MIME norm; 7bit/8bit/binary pass through).
239
366
  function _decodeCms(ent) {
240
367
  // guard.encoding.base64 is strict (no whitespace) and rejects via a (code,msg) FACTORY -- strip the
@@ -245,4 +372,4 @@ function _decodeCms(ent) {
245
372
 
246
373
  function _toBuf(v) { return Buffer.isBuffer(v) ? v : Buffer.from(v); }
247
374
 
248
- module.exports = { sign: sign, verify: verify };
375
+ module.exports = { sign: sign, verify: verify, encrypt: encrypt, decrypt: decrypt };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.25",
3
+ "version": "0.2.26",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:40b168db-ab8a-4925-b224-648cd2ed3f10",
5
+ "serialNumber": "urn:uuid:f346b9f1-fcfc-449a-bd4f-4e1dfeb3209c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T11:50:45.040Z",
8
+ "timestamp": "2026-07-16T12:38:31.773Z",
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.2.25",
22
+ "bom-ref": "@blamejs/pki@0.2.26",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.25",
25
+ "version": "0.2.26",
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.2.25",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.26",
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.2.25",
57
+ "ref": "@blamejs/pki@0.2.26",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]