@blamejs/pki 0.2.24 → 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,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.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
+
7
16
  ## v0.2.24 — 2026-07-16
8
17
 
9
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.
package/README.md CHANGED
@@ -222,6 +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
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
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/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 --
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/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.24",
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:778bbc49-b521-4695-b1a1-915cda8b8012",
5
+ "serialNumber": "urn:uuid:40b168db-ab8a-4925-b224-648cd2ed3f10",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-16T09:47:36.237Z",
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.24",
22
+ "bom-ref": "@blamejs/pki@0.2.25",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.24",
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.24",
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.24",
57
+ "ref": "@blamejs/pki@0.2.25",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]