@blamejs/pki 0.2.23 → 0.2.25

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,24 @@ 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.25 — 2026-07-16
8
+
9
+ 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.
10
+
11
+ ### Added
12
+
13
+ - pki.smime.sign(content, signers, opts) assembles a signed RFC 8551 S/MIME message. opts.form selects multipart/signed (default, clear-signed: the canonical entity in the first part and a detached CMS SignedData over its canonical form as an application/pkcs7-signature second part, with protocol="application/pkcs7-signature" and a matching micalg) or pkcs7-mime (opaque: one application/pkcs7-mime; smime-type=signed-data entity whose base64 body is an attached CMS SignedData). content is wrapped as a text/plain entity by default or taken verbatim with opts.entity. Any pki.cms.sign signer (RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA) carries through. Fail-closed with typed SmimeError.
14
+ - pki.smime.verify(message, opts) unwraps and verifies a signed S/MIME message in both forms, recomputing the detached signature over the first part's RFC 8551 sec. 3.1.1 canonical form (the same canonicalizer the signer used, so a line-ending-mangling transport still verifies and a tampered part fails). It returns pki.cms.verify's { valid, signers } verdict plus the form, the recovered content, and the micalg; a micalg disagreeing with the actual digest is advisory unless opts.strictMicalg. Chaining a signer to a trust anchor is the caller's pki.path.validate step. Bidirectionally interoperable with openssl smime and openssl cms.
15
+
16
+ ## v0.2.24 — 2026-07-16
17
+
18
+ SCT-list encoding and log signing join pki.ct: encodeSctList builds an RFC 6962 SignedCertificateTimestampList byte-for-byte, and signSct performs a Certificate Transparency log's signing step, completing the parse/verify/encode/sign symmetry.
19
+
20
+ ### Added
21
+
22
+ - pki.ct.encodeSctList(scts) builds an RFC 6962 SCT-list extension value from an array of SCTs -- the exact inverse of pki.ct.parseSctList (byte-identical round-trip). A decoded v1 SCT is rebuilt from its fields in the RFC 6962 sec. 3.2 order; an opaque non-v1 entry re-emits its rawSct verbatim (forward compatibility). The list must be non-empty and stays within the parser's SCT_MAX_COUNT / SCT_MAX_BYTES caps. Fail-closed with a typed CtError.
23
+ - pki.ct.signSct(entry, logKey, opts) performs a Certificate Transparency log's signing step (RFC 6962 sec. 3.2): it rebuilds the digitally-signed preimage via the same reconstructSignedData builder the verifier hashes, signs it with the log's private key (ECDSA NIST P-256 or RSA >= 2048, SHA-256 per sec. 2.1.4), and returns a fully-formed v1 SCT that pki.ct.verifySct accepts. The LogID is derived as SHA-256 of the log's SubjectPublicKeyInfo (sec. 3.4); a supplied opts.logId must match. Composes with encodeSctList to assemble a signed SCT-list extension.
24
+
7
25
  ## v0.2.23 — 2026-07-16
8
26
 
9
27
  CMS content encryption arrives as pki.cms.encrypt and pki.cms.decrypt: EnvelopedData, AuthEnvelopedData, and EncryptedData with every RFC 5652 recipient type -- RSA-OAEP, ephemeral-static ECDH, X25519/X448, symmetric key-wrap, password, and post-quantum ML-KEM (RFC 9629/9936) -- and a single, oracle-free decryption verdict.
package/README.md CHANGED
@@ -222,9 +222,10 @@ 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
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` |
226
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` |
227
- | `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. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct` |
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` |
228
229
  | `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` |
229
230
  | `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` |
230
231
  | `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/index.js CHANGED
@@ -37,6 +37,7 @@ var schema = require("./lib/schema-all");
37
37
  var path = require("./lib/path-validate");
38
38
  var ct = require("./lib/ct");
39
39
  var cms = require("./lib/cms-verify");
40
+ var smime = require("./lib/smime");
40
41
  var tsp = require("./lib/tsp-sign");
41
42
  var ocsp = require("./lib/ocsp");
42
43
  var merkle = require("./lib/merkle");
@@ -77,6 +78,7 @@ module.exports = {
77
78
  // surfaced raw for external verification (pki.ct.reconstructSignedData).
78
79
  ct: ct,
79
80
  cms: cms,
81
+ smime: smime,
80
82
  tsp: tsp,
81
83
  ocsp: ocsp,
82
84
  // `merkle` is the RFC 6962 / RFC 9162 Merkle-tree proof-verification core --
@@ -0,0 +1,75 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @internal
6
+ * lib/byte-writer.js -- a growable big-endian byte sink: the ENCODING-layer twin of
7
+ * lib/byte-reader.js, the shared producer under the toolkit's non-ASN.1 length-prefixed
8
+ * wire formats (the TLS-vector SCT list pki.ct emits; any future TLS-presentation
9
+ * producer). Every fixed-width integer is range-checked against its field width and
10
+ * every length-prefixed vector against its declared bound BEFORE a byte is committed,
11
+ * so an out-of-range value or an over-long body faults through the caller's typed
12
+ * ErrorClass rather than silently truncating mod 2^(8*width). It carries the CALLER's
13
+ * ErrorClass exactly as ByteReader / the guard family do, so every wire format keeps
14
+ * its own `domain/reason` fault code.
15
+ *
16
+ * This is an engine primitive, not a format: a new fixed-width or length-prefixed wire
17
+ * field is a method here (with its own range check), never a hand-rolled Buffer write
18
+ * in a format module.
19
+ */
20
+
21
+ // A growable big-endian sink, faulting through the caller's ErrorClass `E`
22
+ // (constructed `new E(code, message)`). `defaultCode` is the fault code used when a
23
+ // write is not given its own.
24
+ function ByteWriter(E, defaultCode) {
25
+ this.parts = [];
26
+ this.len = 0;
27
+ this.E = E;
28
+ this.defaultCode = defaultCode || "byte-writer/bad-value";
29
+ }
30
+ ByteWriter.prototype._push = function (buf) { this.parts.push(buf); this.len += buf.length; };
31
+ // A non-negative integer that fills exactly `width` big-endian octets; the width MUST be 1..4 (a wider
32
+ // or malformed width would break the max computation via 32-bit shift wrap-around and mis-size the
33
+ // field), and a value outside [0, 2^(8*width)-1] (or a non-integer) faults rather than silently wrapping.
34
+ ByteWriter.prototype._uint = function (v, width, code) {
35
+ if (!Number.isInteger(width) || width < 1 || width > 4) throw new this.E(code || this.defaultCode, "an integer width must be 1..4, got " + width);
36
+ var max = width === 4 ? 0xffffffff : (1 << (8 * width)) - 1;
37
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0 || v > max) {
38
+ throw new this.E(code || this.defaultCode, "a uint" + (8 * width) + " must be an integer in 0.." + max + ", got " + v);
39
+ }
40
+ var b = Buffer.alloc(width); b.writeUIntBE(v, 0, width); this._push(b);
41
+ };
42
+ ByteWriter.prototype.u8 = function (v, code) { this._uint(v, 1, code); return this; };
43
+ ByteWriter.prototype.u16 = function (v, code) { this._uint(v, 2, code); return this; };
44
+ ByteWriter.prototype.u24 = function (v, code) { this._uint(v, 3, code); return this; };
45
+ ByteWriter.prototype.u32 = function (v, code) { this._uint(v, 4, code); return this; };
46
+ // A uint64 from a BigInt or a safe-integer Number; refused if out of [0, 2^64-1].
47
+ ByteWriter.prototype.u64 = function (v, code) {
48
+ var big;
49
+ if (typeof v === "bigint") big = v;
50
+ else if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0) big = BigInt(v);
51
+ else throw new this.E(code || this.defaultCode, "a uint64 must be a non-negative integer or BigInt, got " + v);
52
+ if (big < 0n || big > 0xffffffffffffffffn) throw new this.E(code || this.defaultCode, "a uint64 must be in 0..2^64-1");
53
+ var b = Buffer.alloc(8); b.writeBigUInt64BE(big); this._push(b);
54
+ return this;
55
+ };
56
+ // Append a raw opaque slice verbatim (a fixed-width field).
57
+ ByteWriter.prototype.bytes = function (buf, code) {
58
+ if (!Buffer.isBuffer(buf)) throw new this.E(code || this.defaultCode, "bytes() requires a Buffer");
59
+ this._push(buf); return this;
60
+ };
61
+ // opaque<min..max> -- a `lenWidth`-byte big-endian length prefix (1..4) then `body`.
62
+ // The body length is bounded [min, max] BEFORE the prefix is written, so an over-long
63
+ // body cannot emit a prefix that disagrees with what follows.
64
+ ByteWriter.prototype.vector = function (lenWidth, min, max, body, code) {
65
+ if (!Buffer.isBuffer(body)) throw new this.E(code || this.defaultCode, "vector() body must be a Buffer");
66
+ if (body.length < min) throw new this.E(code || this.defaultCode, "vector body " + body.length + " below minimum " + min);
67
+ if (max != null && body.length > max) throw new this.E(code || this.defaultCode, "vector body " + body.length + " above maximum " + max);
68
+ this._uint(body.length, lenWidth, code);
69
+ this._push(body);
70
+ return this;
71
+ };
72
+ ByteWriter.prototype.length = function () { return this.len; };
73
+ ByteWriter.prototype.build = function () { return Buffer.concat(this.parts, this.len); };
74
+
75
+ module.exports = ByteWriter;
package/lib/cms-verify.js CHANGED
@@ -516,6 +516,14 @@ function verify(input, opts) {
516
516
  if (content == null) {
517
517
  if (opts.content == null) throw _err("cms/detached-content-required", "this SignedData is detached; opts.content (the external content) is required");
518
518
  content = _toBuf(opts.content, "opts.content");
519
+ } else if (opts.content != null) {
520
+ // A caller supplied external content AND the SignedData is attached. A redundant COPY that equals
521
+ // the encapsulated content is fine; content that DIFFERS is a substitution trap -- silently
522
+ // preferring the embedded copy would let the caller believe it verified opts.content when the
523
+ // signature covers different embedded bytes. Fail closed on a mismatch.
524
+ if (Buffer.compare(_toBuf(opts.content, "opts.content"), Buffer.from(content)) !== 0) {
525
+ throw _err("cms/content-conflict", "opts.content disagrees with this SignedData's own encapsulated content (a detached signature must omit eContent)");
526
+ }
519
527
  }
520
528
  // Pre-parse every candidate certificate (embedded + caller-supplied) once.
521
529
  var parsedCerts = [];
package/lib/constants.js CHANGED
@@ -233,6 +233,8 @@ var LIMITS = {
233
233
  // 10M clears the authoring default (600k) with generous headroom for high-security policies.
234
234
  PBKDF2_MAX_ITERATIONS: 10000000,
235
235
  PBKDF2_MAX_SALT: 1024,
236
+ // A MIME entity / S/MIME message size ceiling -- bounds the header + body decode before a copy.
237
+ MIME_MAX_BYTES: BYTES.mib(16),
236
238
  // ACME challenge token entropy floor (RFC 8555 sec. 8, errata 6950): >= 128
237
239
  // bits of base64url is >= 22 characters. A shorter token is refused before use.
238
240
  ACME_TOKEN_MIN_CHARS: 22,
package/lib/ct.js CHANGED
@@ -38,11 +38,13 @@
38
38
  * bounded TLS-struct decode, fail-closed.
39
39
  */
40
40
 
41
+ var nodeCrypto = require("crypto");
41
42
  var asn1 = require("./asn1-der.js");
42
43
  var constants = require("./constants.js");
43
44
  var frameworkError = require("./framework-error.js");
44
45
  var guard = require("./guard-all.js");
45
46
  var ByteReader = require("./byte-reader.js");
47
+ var ByteWriter = require("./byte-writer.js");
46
48
  var oid = require("./oid.js");
47
49
  var webcrypto = require("./webcrypto.js");
48
50
  var validator = require("./validator-all.js");
@@ -136,15 +138,15 @@ function _parseSct(r, sctLen) {
136
138
 
137
139
  /**
138
140
  * @primitive pki.ct.parseSctList
139
- * @signature pki.ct.parseSctList(extValue) -> { scts, unknownScts }
141
+ * @signature pki.ct.parseSctList(extValue) -> { scts, unknownScts, all }
140
142
  * @since 0.1.20
141
143
  * @status experimental
142
144
  * @spec RFC 6962, RFC 5246, RFC 8446
143
- * @related pki.ct.reconstructSignedData, pki.schema.x509.parse
145
+ * @related pki.ct.reconstructSignedData, pki.ct.encodeSctList, pki.schema.x509.parse
144
146
  *
145
147
  * Parse the value of an RFC 6962 SCT-list extension (the raw `extnValue`
146
148
  * content an `x509.parse` / OCSP extension already surfaces) into
147
- * `{ scts, unknownScts }`. Each entry of `scts` is a fully decoded v1 SCT:
149
+ * `{ scts, unknownScts, all }`. Each entry of `scts` is a fully decoded v1 SCT:
148
150
  * `version` (0), `logId` (32-byte Buffer) + `logIdHex`, `timestamp` (BigInt,
149
151
  * exact) + `timestampMs` (Number or `null` above 2^53) + `timestampDate`,
150
152
  * `extensions` (raw Buffer), `hashAlg` / `sigAlg` (1-byte code points) + a named
@@ -152,7 +154,10 @@ function _parseSct(r, sctLen) {
152
154
  * SerializedSCT body). A SerializedSCT whose version this parser does not define
153
155
  * is preserved OPAQUE in `unknownScts` as `{ version, rawSct }` rather than
154
156
  * failing the list -- RFC 6962 sec. 3.3 frames each SerializedSCT with its own length
155
- * so unknown versions are skippable (forward compatibility).
157
+ * so unknown versions are skippable (forward compatibility). `all` lists every
158
+ * SerializedSCT (known and unknown) in the exact wire order, so
159
+ * `encodeSctList(all)` reproduces the list byte-identically even when the two
160
+ * kinds are interleaved.
156
161
  *
157
162
  * The extension value is a DER `OCTET STRING` wrapping the TLS-encoded list
158
163
  * (RFC 6962 sec. 3.3 double wrap); everything below that peel is TLS presentation
@@ -186,7 +191,7 @@ function parseSctList(extValue) {
186
191
  if (listLen < 1) {
187
192
  throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 sec. 3.3)");
188
193
  }
189
- var scts = [], unknownScts = [];
194
+ var scts = [], unknownScts = [], all = [];
190
195
  // The shared item counter bounds the TOTAL element count (known + preserved-
191
196
  // unknown) before it can drive unbounded per-element work (RFC 6962 sec. 3.3).
192
197
  var sctCount = guard.limits.counter(C.LIMITS.SCT_MAX_COUNT, _ctErr, "ct/too-many-scts", "SCT");
@@ -203,10 +208,12 @@ function parseSctList(extValue) {
203
208
  }
204
209
  sctCount.tick();
205
210
  var one = _parseSct(outer.subReader(sctLen, "ct/list-trailing-bytes"), sctLen);
206
- if (one.unknown) unknownScts.push({ version: one.version, rawSct: one.rawSct });
207
- else scts.push(one);
211
+ if (one.unknown) { var u = { version: one.version, rawSct: one.rawSct }; unknownScts.push(u); all.push(u); }
212
+ else { scts.push(one); all.push(one); }
208
213
  }
209
- return { scts: scts, unknownScts: unknownScts };
214
+ // `all` preserves the exact wire order across known + unknown entries, so encodeSctList(all)
215
+ // reproduces the list byte-identically even when the two kinds were interleaved.
216
+ return { scts: scts, unknownScts: unknownScts, all: all };
210
217
  }
211
218
 
212
219
  function _u24Bytes(n) {
@@ -409,10 +416,188 @@ async function verifySct(entry, sct, logPublicKey) {
409
416
  }
410
417
  }
411
418
 
419
+ // The TLS-vector WRITER bound to the ct/* fault domain -- the encode twin of TlsReader,
420
+ // over the shared ByteWriter engine primitive (see lib/byte-writer.js).
421
+ function TlsWriter() { return new ByteWriter(CtError, "ct/bad-input"); }
422
+
423
+ // Encode one SerializedSCT body. A v1 SCT is rebuilt from its fields in the exact _parseSct
424
+ // order (so it round-trips byte-identically); an opaque non-v1 entry re-emits its rawSct
425
+ // verbatim (RFC 6962 sec. 3.3 forward-compat symmetry -- encode preserves what parse preserved).
426
+ function _encodeSctBody(sct) {
427
+ if (!sct || typeof sct !== "object") throw new CtError("ct/bad-input", "each SCT must be an object");
428
+ if (sct.version !== 0) {
429
+ // The SerializedSCT version is a single byte (RFC 6962 sec. 3.2), so a declared version must be a
430
+ // 0..255 integer -- reject a non-byte value rather than masking it (a masked 263 would spoof v7).
431
+ if (typeof sct.version !== "number" || !Number.isInteger(sct.version) || sct.version < 0 || sct.version > 255) {
432
+ throw new CtError("ct/bad-input", "an SCT version must be a byte in 0..255 (RFC 6962 sec. 3.2)");
433
+ }
434
+ // An opaque entry re-emits its rawSct verbatim, but the SerializedSCT's on-wire version is
435
+ // rawSct[0] -- refuse a rawSct whose leading byte disagrees with the declared version, so encode
436
+ // never emits a list whose framing our own parser would reject or re-classify (RFC 6962 sec. 3.3).
437
+ var raw = _toBuffer(sct.rawSct, "sct.rawSct");
438
+ if (raw.length < 1 || raw[0] !== sct.version) {
439
+ throw new CtError("ct/bad-input", "an opaque SCT's rawSct[0] must equal its declared version (RFC 6962 sec. 3.3)");
440
+ }
441
+ return raw;
442
+ }
443
+ var w = new TlsWriter();
444
+ w.u8(0, "ct/bad-input"); // Version -- v1(0)
445
+ var logId = _toBuffer(sct.logId, "sct.logId");
446
+ if (logId.length !== LOGID_BYTES) throw new CtError("ct/bad-input", "an SCT logId must be exactly " + LOGID_BYTES + " bytes (RFC 6962 sec. 3.2)");
447
+ w.bytes(logId);
448
+ w.u64(guard.range.uint64(sct.timestamp, _ctErr, "ct/bad-input", "sct.timestamp"), "ct/bad-input"); // uint64 timestamp
449
+ w.vector(2, 0, 0xffff, _toBuffer(sct.extensions, "sct.extensions"), "ct/bad-input"); // CtExtensions<0..2^16-1>
450
+ w.u8(sct.hashAlg, "ct/bad-input"); // digitally-signed hash
451
+ w.u8(sct.sigAlg, "ct/bad-input"); // digitally-signed signature
452
+ w.vector(2, 0, 0xffff, _toBuffer(sct.signature, "sct.signature"), "ct/bad-input"); // signature<0..2^16-1>
453
+ return w.build();
454
+ }
455
+
456
+ /**
457
+ * @primitive pki.ct.encodeSctList
458
+ * @signature pki.ct.encodeSctList(scts) -> Buffer
459
+ * @since 0.2.24
460
+ * @status experimental
461
+ * @spec RFC 6962, RFC 5246
462
+ * @related pki.ct.parseSctList, pki.ct.signSct
463
+ *
464
+ * Build the value of an RFC 6962 SCT-list extension from an array of SCTs -- the exact
465
+ * inverse of `parseSctList`, such that `parseSctList(encodeSctList(list.all))` round-trips to
466
+ * identical bytes. Each element is either a decoded v1 SCT (the shape `parseSctList().scts[]`
467
+ * or `signSct` returns: `version` 0, 32-byte `logId`, `timestamp` BigInt, raw `extensions`,
468
+ * `hashAlg` / `sigAlg` code points, raw `signature`) -- rebuilt from its fields in the RFC
469
+ * 6962 sec. 3.2 field order -- or an opaque non-v1 entry (`{ version, rawSct }`) whose
470
+ * `rawSct` is re-emitted verbatim (forward compatibility, sec. 3.3). Pass `parseSctList().all`
471
+ * (not `.scts`) to preserve the exact wire order and every unknown-version entry.
472
+ *
473
+ * Returns the DER `OCTET STRING`-wrapped TLS `SignedCertificateTimestampList` (the same
474
+ * `extnValue` content `parseSctList` consumes). The list must be non-empty and stays within
475
+ * the parser's `SCT_MAX_COUNT` element cap and the RFC 6962 sec. 3.3 65535-byte list-body cap so
476
+ * encode cannot emit what parse would reject. Throws a typed `CtError` (`ct/empty-list`,
477
+ * `ct/bad-input`, `ct/too-large`, `ct/too-many-scts`) on malformed input.
478
+ *
479
+ * @example
480
+ * var list = pki.ct.parseSctList(sctExtValue);
481
+ * var reEncoded = pki.ct.encodeSctList(list.all); // byte-identical to sctExtValue
482
+ */
483
+ function encodeSctList(scts) {
484
+ if (!Array.isArray(scts)) throw new CtError("ct/bad-input", "encodeSctList expects an array of SCTs");
485
+ if (scts.length < 1) throw new CtError("ct/empty-list", "an SCT list must contain at least one SCT (RFC 6962 sec. 3.3)");
486
+ var sctCount = guard.limits.counter(C.LIMITS.SCT_MAX_COUNT, _ctErr, "ct/too-many-scts", "SCT");
487
+ var elements = [], total = 0;
488
+ for (var i = 0; i < scts.length; i++) {
489
+ sctCount.tick();
490
+ var ew = new TlsWriter();
491
+ ew.vector(2, 1, 0xffff, _encodeSctBody(scts[i]), "ct/bad-input"); // SerializedSCT<1..2^16-1>
492
+ var el = ew.build();
493
+ total += el.length;
494
+ // Enforce the sct_list<1..2^16-1> body cap INCREMENTALLY, so an over-long list fails at the first
495
+ // element that crosses the bound instead of accumulating the whole (potentially large) set first.
496
+ if (total > 0xffff) throw new CtError("ct/too-large", "the SCT list body exceeds the 65535-byte maximum (RFC 6962 sec. 3.3)");
497
+ elements.push(el);
498
+ }
499
+ var lw = new TlsWriter();
500
+ lw.vector(2, 1, 0xffff, Buffer.concat(elements, total), "ct/too-large"); // sct_list<1..2^16-1>
501
+ return asn1.build.octetString(lw.build()); // RFC 6962 sec. 3.3 DER OCTET STRING wrap
502
+ }
503
+
504
+ // A CT log private key -> its PKCS#8 DER (for WebCrypto sign) + the SPKI DER (for the LogID +
505
+ // the sec. 2.1.4 algorithm profile). Accepts a PKCS#8 DER Buffer, a PEM string, a node
506
+ // KeyObject, or a { key, format, type } input.
507
+ function _logKeyMaterial(logKey) {
508
+ // Every key operation runs inside the guard: a duck-typed impostor (an object carrying an
509
+ // asymmetricKeyType but not a real KeyObject) fails at export/createPublicKey and surfaces the
510
+ // typed ct/bad-input, never a raw TypeError. The private-key check keeps its own CtError.
511
+ try {
512
+ var keyObj;
513
+ if (logKey && typeof logKey === "object" && logKey.asymmetricKeyType) keyObj = logKey;
514
+ else if (Buffer.isBuffer(logKey)) keyObj = nodeCrypto.createPrivateKey({ key: logKey, format: "der", type: "pkcs8" });
515
+ else keyObj = nodeCrypto.createPrivateKey(logKey);
516
+ if (keyObj.type !== "private") throw new CtError("ct/bad-input", "signSct requires the CT log PRIVATE key");
517
+ return { pkcs8: keyObj.export({ type: "pkcs8", format: "der" }), spki: nodeCrypto.createPublicKey(keyObj).export({ type: "spki", format: "der" }) };
518
+ } catch (e) {
519
+ if (e instanceof CtError) throw e;
520
+ throw new CtError("ct/bad-input", "the CT log private key could not be loaded", e);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * @primitive pki.ct.signSct
526
+ * @signature pki.ct.signSct(entry, logKey, opts?) -> Promise<sct>
527
+ * @since 0.2.24
528
+ * @status experimental
529
+ * @spec RFC 6962
530
+ * @related pki.ct.verifySct, pki.ct.reconstructSignedData, pki.ct.encodeSctList
531
+ *
532
+ * Perform a Certificate Transparency log's signing step (RFC 6962 sec. 3.2): rebuild the exact
533
+ * `digitally-signed` preimage over `entry` (via `reconstructSignedData`, the SAME builder the
534
+ * verifier hashes), sign it with the log's private key, and return a fully-formed v1 SCT that
535
+ * `verifySct` accepts against the log's public key. `entry` is the log entry the SCT covers
536
+ * (`{ entryType: 0, leafCert }` or `{ entryType: 1, tbsCertificate, issuerKeyHash }`, as for
537
+ * `reconstructSignedData`); `logKey` is the log's private key (PKCS#8 DER `Buffer`, PEM string,
538
+ * or a node `KeyObject`).
539
+ *
540
+ * The log-key profile is RFC 6962 sec. 2.1.4: ECDSA NIST P-256 (`sigAlg` 3) or RSA >= 2048
541
+ * (`sigAlg` 1), SHA-256 only -- an unsupported key fails closed `ct/unsupported-algorithm`. The
542
+ * `logId` is derived as SHA-256 of the log SPKI (sec. 3.4); a supplied `opts.logId` must match.
543
+ * The returned SCT is the parseSctList/verifySct shape and composes with `encodeSctList`.
544
+ *
545
+ * @opts timestamp ms since the epoch (finite non-negative integer/BigInt). Default `Date.now()`.
546
+ * @opts extensions raw `CtExtensions` bytes (opaque<0..2^16-1>). Default empty.
547
+ * @opts logId assert the derived LogID equals this 32-byte value (fail closed on mismatch).
548
+ * @example
549
+ * var sct = await pki.ct.signSct({ entryType: 0, leafCert: der }, signerKeyPkcs8);
550
+ * var ext = pki.ct.encodeSctList([sct]);
551
+ */
552
+ async function signSct(entry, logKey, opts) {
553
+ opts = opts || {};
554
+ var mat = _logKeyMaterial(logKey);
555
+ var alg = _spkiAlg(mat.spki);
556
+ var hashAlg = 4, sigAlg, imp, sign, ecdsaDer = false, coordLen;
557
+ if (alg.algOid === oid.byName("ecPublicKey")) {
558
+ var ec = CT_EC_CURVE[alg.curveOid];
559
+ if (!ec) throw new CtError("ct/unsupported-algorithm", "unsupported SCT log EC curve (RFC 6962 sec. 2.1.4 mandates NIST P-256)");
560
+ sigAlg = 3; imp = { name: "ECDSA", namedCurve: ec.curve }; sign = { name: "ECDSA", hash: "SHA-256" }; ecdsaDer = true; coordLen = ec.coordLen;
561
+ } else if (alg.algOid === oid.byName("rsaEncryption")) {
562
+ if (!(alg.rsaBits >= 2048)) throw new CtError("ct/unsupported-algorithm", "the SCT log RSA key is below the RFC 6962 sec. 2.1.4 minimum of 2048 bits");
563
+ sigAlg = 1; imp = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; sign = { name: "RSASSA-PKCS1-v1_5" };
564
+ } else {
565
+ throw new CtError("ct/unsupported-algorithm", "unsupported SCT log key algorithm (RFC 6962 sec. 2.1.4 supports ecdsa P-256 / rsa)");
566
+ }
567
+ var timestamp;
568
+ if (opts.timestamp == null) timestamp = BigInt(Date.now());
569
+ else if (typeof opts.timestamp === "bigint") timestamp = opts.timestamp;
570
+ else if (typeof opts.timestamp === "number" && Number.isSafeInteger(opts.timestamp) && opts.timestamp >= 0) timestamp = BigInt(opts.timestamp);
571
+ else throw new CtError("ct/bad-input", "timestamp must be a finite non-negative integer or BigInt (RFC 6962 sec. 3.2)");
572
+ var extensions = opts.extensions == null ? Buffer.alloc(0) : _toBuffer(opts.extensions, "opts.extensions");
573
+ var logId = Buffer.from(await subtle.digest("SHA-256", mat.spki));
574
+ if (opts.logId != null && !_toBuffer(opts.logId, "opts.logId").equals(logId)) {
575
+ throw new CtError("ct/bad-input", "opts.logId does not match SHA-256 of the log key (RFC 6962 sec. 3.4)");
576
+ }
577
+ // Reuse the ONE preimage builder the verifier uses -- sign and verify cannot diverge.
578
+ var preimage = reconstructSignedData(entry, { version: 0, timestamp: timestamp, extensions: extensions });
579
+ var priv = await subtle.importKey("pkcs8", mat.pkcs8, imp, false, ["sign"]);
580
+ var sigRaw = Buffer.from(await subtle.sign(sign, priv, preimage));
581
+ var signature = ecdsaDer ? validator.sig.rawToEcdsaDer(sigRaw, coordLen) : sigRaw;
582
+ return {
583
+ version: 0,
584
+ logId: logId, logIdHex: logId.toString("hex"),
585
+ timestamp: timestamp,
586
+ extensions: extensions,
587
+ hashAlg: hashAlg, sigAlg: sigAlg,
588
+ // The `|| null` fallbacks mirror parseSctList's shape; unreachable here since signSct only ever
589
+ // emits the in-map sha256(4) + ecdsa(3)/rsa(1) code points (coverage residual).
590
+ signatureAlgorithm: { hash: hashAlg, hashName: HASH_ALGORITHMS[hashAlg] || null, signature: sigAlg, signatureName: SIGNATURE_ALGORITHMS[sigAlg] || null },
591
+ signature: signature,
592
+ };
593
+ }
594
+
412
595
  module.exports = {
413
596
  parseSctList: parseSctList,
414
597
  reconstructSignedData: reconstructSignedData,
415
598
  verifySct: verifySct,
599
+ encodeSctList: encodeSctList,
600
+ signSct: signSct,
416
601
  HASH_ALGORITHMS: HASH_ALGORITHMS,
417
602
  SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
418
603
  };
package/lib/mime.js ADDED
@@ -0,0 +1,191 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @internal
6
+ * lib/mime.js -- a minimal MIME entity framer + canonicalizer, the message-layer engine primitive
7
+ * under pki.smime (RFC 8551 S/MIME) and any future MIME-carrying feature. It parses an entity into
8
+ * its headers + body, canonicalizes a text entity to the RFC 8551 sec. 3.1.1 form (CRLF line endings),
9
+ * splits a multipart body on its boundary, and builds an entity/multipart back. There is NO crypto
10
+ * here -- the CMS layer signs/verifies the canonical bytes this module produces.
11
+ *
12
+ * The LOAD-BEARING rule is canonicalization: the detached signature over a multipart/signed first part
13
+ * is computed over that part's canonical MIME form, so the signer and verifier MUST share ONE
14
+ * canonicalizer -- this module. It carries the caller's typed ErrorClass `E` (constructed
15
+ * `new E(code, message)`), exactly as the byte-reader / guard family do, so every consumer keeps its
16
+ * own `domain/reason` fault code.
17
+ */
18
+
19
+ var C = require("./constants.js");
20
+ var guard = require("./guard-all.js");
21
+
22
+ var CRLF = Buffer.from("\r\n");
23
+
24
+ // A byte source -> a Buffer view, capped before any copy (CWE-770/400).
25
+ function _buf(v, E, code, label) { return guard.bytes.view(v, E, code, label); }
26
+
27
+ // Locate the header/body separator: the first empty line (a bare LF-LF or CRLF-CRLF, or a mix).
28
+ // Returns { headerEnd, bodyStart } offsets, or null when there is no blank-line separator.
29
+ function _splitPoint(bytes) {
30
+ for (var i = 0; i < bytes.length; i++) {
31
+ if (bytes[i] === 0x0a) { // LF
32
+ if (i + 1 < bytes.length && bytes[i + 1] === 0x0a) return { headerEnd: i, bodyStart: i + 2 }; // LF LF
33
+ if (i + 2 < bytes.length && bytes[i + 1] === 0x0d && bytes[i + 2] === 0x0a) return { headerEnd: i, bodyStart: i + 3 }; // LF CRLF
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Split a header block into unfolded logical lines (RFC 5322 folding: a continuation line begins with
40
+ // SP/HTAB and joins the previous). Returns the raw header strings (each "Name: value").
41
+ function _unfoldHeaders(headerText) {
42
+ var lines = headerText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
43
+ var out = [];
44
+ for (var i = 0; i < lines.length; i++) {
45
+ var ln = lines[i];
46
+ if (ln === "") continue;
47
+ if ((ln[0] === " " || ln[0] === "\t") && out.length) out[out.length - 1] += " " + ln.replace(/^[ \t]+/, "");
48
+ else out.push(ln);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // Parse a Content-Type / any structured header value into { value, type, params } -- the media type
54
+ // (lowercased) plus its parameters (names lowercased, quotes stripped). Tolerant of extra whitespace.
55
+ function _parseStructured(headerValue) {
56
+ var parts = _splitSemicolons(headerValue); // always >= 1 element
57
+ var type = parts[0].trim().toLowerCase();
58
+ var params = {};
59
+ for (var i = 1; i < parts.length; i++) {
60
+ var eq = parts[i].indexOf("=");
61
+ if (eq < 0) continue;
62
+ var name = parts[i].slice(0, eq).trim().toLowerCase();
63
+ var val = parts[i].slice(eq + 1).trim();
64
+ if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') val = val.slice(1, -1);
65
+ params[name] = val;
66
+ }
67
+ return { value: headerValue.trim(), type: type, params: params };
68
+ }
69
+
70
+ // Split on ";" while respecting double-quoted spans (a boundary/protocol value may be quoted).
71
+ function _splitSemicolons(s) {
72
+ var out = [], cur = "", inQ = false;
73
+ for (var i = 0; i < s.length; i++) {
74
+ var ch = s[i];
75
+ if (ch === '"') { inQ = !inQ; cur += ch; }
76
+ else if (ch === ";" && !inQ) { out.push(cur); cur = ""; }
77
+ else cur += ch;
78
+ }
79
+ out.push(cur);
80
+ return out;
81
+ }
82
+
83
+ // Parse a MIME entity (headers + body). `contentType` / `cte` are decoded; `header(name)` looks up a
84
+ // header case-insensitively; `body` is the raw body Buffer. `headerBytes` / `bodyBytes` are the exact
85
+ // slices (a signature over a first part must reference exact bytes, never a re-serialization).
86
+ function parse(input, E, code) {
87
+ var bytes = _buf(input, E, code, "the MIME entity");
88
+ // Coverage residual: the DoS ceiling only fires on a >16 MiB message (a real cap, exercised by no
89
+ // unit vector -- driving it would allocate 16 MiB).
90
+ if (bytes.length > C.LIMITS.MIME_MAX_BYTES) throw new E(code, "MIME entity exceeds the size cap");
91
+ var sp = _splitPoint(bytes);
92
+ var headerBytes = sp ? bytes.subarray(0, sp.headerEnd) : bytes;
93
+ var bodyBytes = sp ? bytes.subarray(sp.bodyStart) : Buffer.alloc(0);
94
+ var headerText = guard.text.decode(headerBytes, C.LIMITS.MIME_MAX_BYTES, E, { charset: "latin1", tooLarge: code, badInput: code, label: "the MIME headers" });
95
+ var rawHeaders = _unfoldHeaders(headerText);
96
+ var headers = [];
97
+ for (var i = 0; i < rawHeaders.length; i++) {
98
+ var colon = rawHeaders[i].indexOf(":");
99
+ if (colon < 0) throw new E(code, "a MIME header line has no colon: " + JSON.stringify(rawHeaders[i].slice(0, 40)));
100
+ headers.push({ name: rawHeaders[i].slice(0, colon).trim(), lname: rawHeaders[i].slice(0, colon).trim().toLowerCase(), value: rawHeaders[i].slice(colon + 1).trim() });
101
+ }
102
+ function header(name) { var l = name.toLowerCase(); for (var j = 0; j < headers.length; j++) if (headers[j].lname === l) return headers[j].value; return null; }
103
+ var ctv = header("content-type");
104
+ var cte = (header("content-transfer-encoding") || "").trim().toLowerCase() || "7bit";
105
+ return {
106
+ headers: headers, header: header,
107
+ contentType: ctv != null ? _parseStructured(ctv) : { value: "text/plain", type: "text/plain", params: {} },
108
+ cte: cte,
109
+ headerBytes: headerBytes, bodyBytes: bodyBytes, body: bodyBytes, bytes: bytes,
110
+ };
111
+ }
112
+
113
+ // Normalize a text body's line endings to the RFC 8551 sec. 3.1.1 canonical CRLF pair (any bare CR or
114
+ // LF becomes CRLF; an existing CRLF is preserved).
115
+ function canonicalizeText(bodyBytes) {
116
+ var s = bodyBytes.toString("latin1").replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n/g, "\r\n");
117
+ return Buffer.from(s, "latin1");
118
+ }
119
+
120
+ // The canonical MIME form of a part for signing (RFC 8551 sec. 3.1.1): line endings normalized to the
121
+ // CRLF pair over the EXACT bytes -- never a header re-serialization, which would diverge from the
122
+ // signer's digest (and would break a header-less clear-signed part, as OpenSSL's `smime -sign` emits).
123
+ // The signer and verifier both call THIS, so their digests cannot diverge; a transport that mangles
124
+ // CR/LF is repaired identically on both sides. (A binary clear-signed part must carry a base64
125
+ // Content-Transfer-Encoding first -- line-ending canonicalization is defined for text, sec. 3.1.1.)
126
+ function canonicalize(input, E, code) {
127
+ return canonicalizeText(_buf(input, E, code, "the MIME part"));
128
+ }
129
+
130
+ // Split a multipart body on its boundary into the exact bytes of each part (RFC 2046 sec. 5.1.1). A
131
+ // part runs from after the "--boundary CRLF" delimiter to just before the "CRLF--boundary" that ends
132
+ // it; the CRLF preceding a boundary is part of the delimiter, not the part (the S/MIME signing rule).
133
+ function splitMultipart(bodyBytes, boundary, E, code) {
134
+ if (!boundary) throw new E(code, "a multipart entity is missing its boundary parameter");
135
+ var delim = Buffer.from("--" + boundary);
136
+ var body = bodyBytes;
137
+ var idx = _findDelim(body, delim, 0);
138
+ if (idx < 0) throw new E(code, "no boundary delimiter found in the multipart body");
139
+ // Advance past the opening delimiter line.
140
+ var pos = _afterLine(body, idx + delim.length, E, code);
141
+ var parts = [];
142
+ while (true) {
143
+ var next = _findDelim(body, delim, pos);
144
+ if (next < 0) throw new E(code, "an unterminated multipart body part (no closing boundary)");
145
+ // The part ends at the CRLF (or LF) that immediately precedes this boundary delimiter.
146
+ var partEnd = next;
147
+ if (partEnd >= 2 && body[partEnd - 2] === 0x0d && body[partEnd - 1] === 0x0a) partEnd -= 2;
148
+ else if (partEnd >= 1 && body[partEnd - 1] === 0x0a) partEnd -= 1;
149
+ parts.push(body.subarray(pos, partEnd));
150
+ // A closing delimiter is "--boundary--"; otherwise advance past this delimiter line to the next part.
151
+ var afterDelim = next + delim.length;
152
+ if (body[afterDelim] === 0x2d && body[afterDelim + 1] === 0x2d) break; // "--"
153
+ pos = _afterLine(body, afterDelim, E, code);
154
+ }
155
+ return parts;
156
+ }
157
+
158
+ // The next LINE-ANCHORED "--boundary" delimiter at or after `from` (RFC 2046 sec. 5.1.1): it MUST begin
159
+ // a line (at the body start, or immediately after an LF) AND its line MUST be a complete delimiter --
160
+ // the token, then an optional "--" (close), then only transport-padding whitespace, then CRLF / LF /
161
+ // end-of-buffer. So a "--boundary" substring mid-content, a "--boundaryextra" (a different token), a
162
+ // "--boundary garbage" line, or one not at a line start is NOT matched -- an attacker cannot inject a
163
+ // fake delimiter inside a part's content.
164
+ function _findDelim(body, delim, from) {
165
+ for (var at = body.indexOf(delim, from); at >= 0; at = body.indexOf(delim, at + delim.length)) {
166
+ if ((at === 0 || body[at - 1] === 0x0a) && _delimLineOk(body, at + delim.length)) return at;
167
+ }
168
+ return -1;
169
+ }
170
+ // The suffix after the "--boundary" token is a valid delimiter line: an optional closing "--", then
171
+ // zero or more LWSP (space / HTAB transport padding), then CRLF / LF / end-of-buffer. Anything else
172
+ // (non-whitespace before the line end) means this is content, not a delimiter.
173
+ function _delimLineOk(body, i) {
174
+ if (body[i] === 0x2d && body[i + 1] === 0x2d) i += 2;
175
+ while (body[i] === 0x20 || body[i] === 0x09) i++;
176
+ return i >= body.length || body[i] === 0x0d || body[i] === 0x0a;
177
+ }
178
+
179
+ // The offset just past the end of the current line (skipping to after the next LF).
180
+ function _afterLine(buf, from, E, code) {
181
+ for (var i = from; i < buf.length; i++) if (buf[i] === 0x0a) return i + 1;
182
+ throw new E(code, "a multipart boundary line is not terminated");
183
+ }
184
+
185
+ module.exports = {
186
+ parse: parse,
187
+ canonicalize: canonicalize,
188
+ canonicalizeText: canonicalizeText,
189
+ splitMultipart: splitMultipart,
190
+ CRLF: CRLF,
191
+ };
package/lib/smime.js ADDED
@@ -0,0 +1,248 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.smime
6
+ * @nav Messaging
7
+ * @title S/MIME
8
+ * @order 175
9
+ * @slug smime
10
+ *
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
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
21
+ * `cms.verify`, `verify` returns the per-signer cryptographic verdict; chaining
22
+ * a signer to a trust anchor is the caller's `pki.path.validate` step.
23
+ *
24
+ * @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.
28
+ */
29
+
30
+ var frameworkError = require("./framework-error.js");
31
+ var mime = require("./mime.js");
32
+ var cms = require("./cms-verify.js");
33
+ var schemaCms = require("./schema-cms.js");
34
+ var guard = require("./guard-all.js");
35
+ var C = require("./constants.js");
36
+ var nodeCrypto = require("crypto");
37
+
38
+ var SmimeError = frameworkError.SmimeError;
39
+
40
+ function _err(code, msg, cause) { return new SmimeError(code, msg, cause); }
41
+
42
+ // RFC 8551 uses application/pkcs7-<kind>; OpenSSL's legacy `smime` command emits the PKCS#7
43
+ // application/x-pkcs7-<kind>. Accept both on the RECEIVE side (we always EMIT the RFC 8551 form).
44
+ function _isPkcs7(type, kind) {
45
+ var t = (type || "").toLowerCase();
46
+ return t === "application/pkcs7-" + kind || t === "application/x-pkcs7-" + kind;
47
+ }
48
+
49
+ // The RFC 8551 sec. 3.4.3.2 micalg name for a CMS digest (schema-cms surfaces "sha256" etc.).
50
+ // RFC 8551 sec. 3.4.3.2 micalg names, extended with the SHAKE names FIPS 204/205 CMS signers digest
51
+ // with (RFC 8702). An unknown digest passes through verbatim -- never regex-mangled (a blind
52
+ // `sha`->`sha-` would corrupt "shake256" into "sha-ke256").
53
+ var MICALG = { sha1: "sha-1", sha224: "sha-224", sha256: "sha-256", sha384: "sha-384", sha512: "sha-512", md5: "md5", shake128: "shake128", shake256: "shake256" };
54
+
55
+ // The exact bytes to sign: the caller's content wrapped as a MIME entity (text/plain by default) in
56
+ // its canonical form, OR -- when opts.entity is set -- the caller's own complete MIME entity, canonical.
57
+ function _entityBytes(content, opts) {
58
+ var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
59
+ if (opts.entity) return mime.canonicalize(raw, SmimeError, "smime/bad-mime");
60
+ var ct = opts.contentType || "text/plain; charset=utf-8";
61
+ // Declare the honest Content-Transfer-Encoding: "7bit" requires every byte <= 127 (RFC 2045); a body
62
+ // with any 8-bit byte is "8bit". Declaring 7bit for 8-bit content is a false claim a transport could act on.
63
+ var cte = _is7bit(raw) ? "7bit" : "8bit";
64
+ var head = Buffer.from("Content-Type: " + ct + "\r\nContent-Transfer-Encoding: " + cte + "\r\n\r\n", "latin1");
65
+ return mime.canonicalize(Buffer.concat([head, raw]), SmimeError, "smime/bad-mime");
66
+ }
67
+ function _is7bit(buf) { for (var i = 0; i < buf.length; i++) if (buf[i] > 0x7f) return false; return true; }
68
+
69
+ // Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic -- it forwards any signer).
70
+ function _cmsSignOpts(opts, detached) {
71
+ var o = { detached: detached };
72
+ if (opts.signingTime !== undefined) o.signingTime = opts.signingTime;
73
+ if (opts.sid) o.sid = opts.sid;
74
+ if (opts.signedAttributes !== undefined) o.signedAttributes = opts.signedAttributes;
75
+ if (opts.additionalSignedAttributes) o.additionalSignedAttributes = opts.additionalSignedAttributes;
76
+ return o;
77
+ }
78
+
79
+ // Coverage residual: the defensive `|| default` fallbacks in the helpers below (a null media type, a
80
+ // non-standard SignerInfo digest name -> a derived micalg, an absent micalg -> "sha-256"/null, a
81
+ // non-Buffer cms.sign result) and the two catch arms (a CMS body that passed cms.verify yet fails a
82
+ // second schema-cms.parse) are belt-and-braces around the primary path the round-trips exercise; the
83
+ // producer never emits any of them.
84
+ function _micName(name) { return name ? (MICALG[name] || name) : null; }
85
+ // Normalize a micalg header value into the same sorted-distinct comma-joined form _micalgOf emits.
86
+ function _micalgSet(value) {
87
+ var seen = [];
88
+ String(value).split(",").forEach(function (m) { var t = m.trim().toLowerCase(); if (t && seen.indexOf(t) < 0) seen.push(t); });
89
+ return seen.sort().join(",");
90
+ }
91
+ // The micalg for the SignedData -- RFC 8551 sec. 3.4.3.2: EVERY signer's message-digest algorithm,
92
+ // distinct, sorted, comma-separated (a multi-signer message with mixed digests lists them all).
93
+ function _micalgOf(p7Der) {
94
+ var parsed;
95
+ try { parsed = schemaCms.parse(guard.bytes.view(p7Der, SmimeError, "smime/bad-mime", "the CMS body")); }
96
+ catch (e) { throw _err("smime/bad-mime", "the CMS SignedData body could not be parsed", e); }
97
+ var out = [];
98
+ (parsed.signerInfos || []).forEach(function (si) {
99
+ var mic = _micName(si.digestAlgorithm && si.digestAlgorithm.name);
100
+ if (mic && out.indexOf(mic) < 0) out.push(mic);
101
+ });
102
+ return out.length ? out.sort().join(",") : null;
103
+ }
104
+
105
+ // A fresh multipart boundary that cannot collide with the content (random, dashed, unique per call).
106
+ function _boundary() { return "----=_pki_smime_" + nodeCrypto.randomBytes(18).toString("hex"); }
107
+
108
+ function _base64Body(der) {
109
+ return Buffer.from(der.toString("base64").replace(/(.{64})/g, "$1\r\n").replace(/\r\n$/, ""), "latin1");
110
+ }
111
+
112
+ /**
113
+ * @primitive pki.smime.sign
114
+ * @signature pki.smime.sign(content, signers, opts?) -> Promise<Buffer>
115
+ * @since 0.2.25
116
+ * @status experimental
117
+ * @spec RFC 8551, RFC 5652
118
+ * @related pki.smime.verify, pki.cms.sign
119
+ *
120
+ * Assemble a signed S/MIME message (RFC 8551). `content` is the payload -- a raw body wrapped as a
121
+ * `text/plain` entity by default, or the caller's own complete MIME entity when `opts.entity` is set;
122
+ * `signers` is the `pki.cms.sign` signer array (any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA
123
+ * signer -- the S/MIME layer is algorithm-agnostic). Two forms via `opts.form`:
124
+ * - `"multipart"` (default, clear-signed): a `multipart/signed` message carrying the canonical entity
125
+ * verbatim in the first part and a DETACHED CMS SignedData (`application/pkcs7-signature`) over its
126
+ * canonical form in the second, with `protocol="application/pkcs7-signature"` + a matching `micalg`.
127
+ * - `"pkcs7-mime"` (opaque): one `application/pkcs7-mime; smime-type=signed-data` entity whose base64
128
+ * body is an ATTACHED CMS SignedData over the canonical entity.
129
+ * The signed bytes are the entity's RFC 8551 sec. 3.1.1 canonical form (CRLF line endings); the SAME
130
+ * canonicalizer runs on verify. Returns the assembled message bytes. Fail-closed with `SmimeError`.
131
+ *
132
+ * @opts form `"multipart"` (default) or `"pkcs7-mime"`.
133
+ * @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
134
+ * @opts contentType the wrapped entity's Content-Type (default `text/plain; charset=utf-8`).
135
+ * @opts signingTime a `Date` for the CMS signing-time attribute, or false to omit it.
136
+ * @example
137
+ * var msg = await pki.smime.sign(Buffer.from("hello"), [{ cert: signerCertDer, key: signerKeyPkcs8 }]);
138
+ */
139
+ async function sign(content, signers, opts) {
140
+ opts = opts || {};
141
+ var entity = _entityBytes(content, opts);
142
+ var form = opts.form || "multipart";
143
+ if (form === "pkcs7-mime") {
144
+ var p7m = await cms.sign(entity, signers, _cmsSignOpts(opts, false));
145
+ var head = Buffer.from("Content-Type: application/pkcs7-mime; smime-type=signed-data; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "latin1");
146
+ return _capped(Buffer.concat([head, _base64Body(_toBuf(p7m)), mime.CRLF]));
147
+ }
148
+ if (form !== "multipart") throw _err("smime/bad-input", "form must be \"multipart\" or \"pkcs7-mime\"");
149
+ var p7s = _toBuf(await cms.sign(entity, signers, _cmsSignOpts(opts, true)));
150
+ var micalg = _micalgOf(p7s) || "sha-256";
151
+ var boundary = _boundary();
152
+ var head2 = Buffer.from("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n\r\n", "latin1");
153
+ var sigPart = Buffer.concat([
154
+ Buffer.from("Content-Type: application/pkcs7-signature; name=smime.p7s\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7s\r\n\r\n", "latin1"),
155
+ _base64Body(p7s),
156
+ ]);
157
+ var dl = Buffer.from("--" + boundary + "\r\n", "latin1"), close = Buffer.from("--" + boundary + "--\r\n", "latin1");
158
+ return _capped(Buffer.concat([head2, dl, entity, mime.CRLF, dl, sigPart, mime.CRLF, close]));
159
+ }
160
+
161
+ // A produced message MUST stay within the same size ceiling verify enforces on receive, so sign never
162
+ // emits an S/MIME message our own verify would reject as too large (producer/consumer symmetry).
163
+ // Coverage residual: the throw only fires on a >16 MiB assembled message -- a real cap, exercised by no
164
+ // unit vector (driving it would sign 16 MiB).
165
+ function _capped(msg) {
166
+ if (msg.length > C.LIMITS.MIME_MAX_BYTES) throw _err("smime/too-large", "the assembled S/MIME message (" + msg.length + " bytes) exceeds the " + C.LIMITS.MIME_MAX_BYTES + "-byte cap that verify enforces");
167
+ return msg;
168
+ }
169
+
170
+ /**
171
+ * @primitive pki.smime.verify
172
+ * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg }>
173
+ * @since 0.2.25
174
+ * @status experimental
175
+ * @spec RFC 8551, RFC 5652
176
+ * @related pki.smime.sign, pki.cms.verify, pki.path.validate
177
+ *
178
+ * Unwrap and verify a signed S/MIME message (RFC 8551), both `multipart/signed` and
179
+ * `application/pkcs7-mime; smime-type=signed-data`. For `multipart/signed` the detached CMS signature
180
+ * is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the SAME canonicalizer the
181
+ * signer used); for `application/pkcs7-mime` the base64 body is the attached CMS SignedData. Returns
182
+ * `pki.cms.verify`'s `{ valid, signers }` verdict PLUS `form`, the recovered `content` (the signed MIME
183
+ * entity bytes), and the `micalg`. Like `cms.verify`, this returns the cryptographic verdict only --
184
+ * chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
185
+ * that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
186
+ *
187
+ * @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
188
+ * @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
189
+ * @example
190
+ * var res = await pki.smime.verify(smimeMessageBytes);
191
+ * if (res.valid) { res.content; res.signers[0].sid; }
192
+ */
193
+ async function verify(message, opts) {
194
+ opts = opts || {};
195
+ var ent = mime.parse(message, SmimeError, "smime/bad-mime");
196
+ var ct = ent.contentType;
197
+ var vOpts = {};
198
+ if (opts.certs) vOpts.certs = opts.certs;
199
+ if (_isPkcs7(ct.type, "mime")) {
200
+ if (ct.params["smime-type"] && ct.params["smime-type"] !== "signed-data") throw _err("smime/unsupported-type", "unsupported smime-type " + JSON.stringify(ct.params["smime-type"]) + " (only signed-data)");
201
+ var p7m = _decodeCms(ent);
202
+ var res = await cms.verify(p7m, vOpts);
203
+ var inner;
204
+ try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
205
+ catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
206
+ return { valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null };
207
+ }
208
+ if (ct.type === "multipart/signed") {
209
+ if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
210
+ var parts = mime.splitMultipart(ent.body, ct.params.boundary, SmimeError, "smime/bad-multipart");
211
+ if (parts.length !== 2) throw _err("smime/bad-multipart", "multipart/signed must have exactly two body parts, got " + parts.length);
212
+ var sigEnt = mime.parse(parts[1], SmimeError, "smime/bad-mime");
213
+ if (!_isPkcs7(sigEnt.contentType.type, "signature")) throw _err("smime/bad-multipart", "the second part must be application/pkcs7-signature");
214
+ var p7s = _decodeCms(sigEnt);
215
+ // RFC 8551 sec. 3.4 / RFC 1847: the multipart/signed signature is DETACHED -- the SignedData carries
216
+ // NO encapsulated content, the first MIME part IS the signed unit. Reject an ATTACHED SignedData:
217
+ // otherwise cms.verify would verify over the embedded eContent and IGNORE the first part, so an
218
+ // attacker could pair any validly-signed attached blob with an arbitrary first part (a forgery).
219
+ var sd;
220
+ try { sd = schemaCms.parse(p7s); }
221
+ catch (e) { throw _err("smime/bad-mime", "the pkcs7-signature part is not a CMS SignedData", e); }
222
+ if (sd.encapContentInfo && sd.encapContentInfo.eContent != null) {
223
+ throw _err("smime/bad-multipart", "the pkcs7-signature part must carry a DETACHED SignedData (no encapsulated content) (RFC 8551 sec. 3.4)");
224
+ }
225
+ var canon = mime.canonicalize(parts[0], SmimeError, "smime/bad-mime");
226
+ var res2 = await cms.verify(p7s, Object.assign({}, vOpts, { content: canon }));
227
+ var micalg = ct.params.micalg || null;
228
+ // Compare micalg as an ORDER-INDEPENDENT set of digest names (RFC 8551 sec. 3.4.3.2 lists them
229
+ // comma-separated, in no required order, possibly with whitespace).
230
+ if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
231
+ throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
232
+ }
233
+ return { valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg };
234
+ }
235
+ throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
236
+ }
237
+
238
+ // Decode a CMS body per its Content-Transfer-Encoding (base64 is the S/MIME norm; 7bit/8bit/binary pass through).
239
+ function _decodeCms(ent) {
240
+ // guard.encoding.base64 is strict (no whitespace) and rejects via a (code,msg) FACTORY -- strip the
241
+ // MIME line-wrapping whitespace first, and pass _err (not the SmimeError class).
242
+ if (ent.cte === "base64") return guard.encoding.base64(ent.body.toString("latin1").replace(/[\r\n\t ]/g, ""), C.LIMITS.MIME_MAX_BYTES, _err, "smime/bad-mime", "the CMS body");
243
+ return ent.body;
244
+ }
245
+
246
+ function _toBuf(v) { return Buffer.isBuffer(v) ? v : Buffer.from(v); }
247
+
248
+ module.exports = { sign: sign, verify: verify };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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:218fb396-9c81-482d-be9e-f08c7add5683",
5
+ "serialNumber": "urn:uuid:40b168db-ab8a-4925-b224-648cd2ed3f10",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T08:33:14.109Z",
8
+ "timestamp": "2026-07-16T11:50:45.040Z",
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.23",
22
+ "bom-ref": "@blamejs/pki@0.2.25",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.23",
25
+ "version": "0.2.25",
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.23",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.25",
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.23",
57
+ "ref": "@blamejs/pki@0.2.25",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]