@blamejs/pki 0.2.24 → 0.2.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -0
- package/index.js +2 -0
- package/lib/cms-verify.js +8 -0
- package/lib/constants.js +2 -0
- package/lib/mime.js +191 -0
- package/lib/smime.js +375 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.26 — 2026-07-16
|
|
8
|
+
|
|
9
|
+
S/MIME encryption arrives as pki.smime.encrypt / pki.smime.decrypt: envelope and open RFC 8551 encrypted messages over the CMS layer, with AES-GCM authenticated enveloping as the default and bidirectional OpenSSL interoperability.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.smime.encrypt(content, recipients, opts) envelopes a MIME entity as an encrypted RFC 8551 S/MIME message (opaque application/pkcs7-mime). The default AES-256-GCM content encryption yields an authEnveloped-data message (confidentiality and integrity, RFC 8551 sec. 3.4); opts.contentEncryptionAlgorithm can select AES-CBC for an enveloped-data message (confidentiality only, RFC 8551 sec. 3.3). recipients is the pki.cms.encrypt recipient array (RSA-OAEP key transport, ECDH / X25519 / X448 key agreement, AES key wrap, PBKDF2 password, and post-quantum ML-KEM), and a single descriptor is accepted and normalized to a one-element array. content is wrapped as a text/plain entity by default or taken verbatim with opts.entity. Fail-closed with typed SmimeError.
|
|
14
|
+
- pki.smime.decrypt(message, keyMaterial, opts) opens an encrypted S/MIME message, returning the recovered inner MIME entity plus smimeType, authenticated (true only for an AuthEnvelopedData -- a CBC enveloped-data message reports false, the RFC 8551 sec. 3.3 no-integrity caveat), recipientType, recipientIndex, and contentEncryptionAlgorithm. The smime-type is derived from the CMS body, not the header; opts.strictSmimeType additionally rejects a header smime-type that disagrees with the body. keyMaterial is the pki.cms.decrypt key material ({ key, cert }, { password }, or { kek, kekId }). Decryption is fail-closed and oracle-free. OpenSSL's legacy application/x-pkcs7-mime and a missing smime-type are both accepted. A recovered content that is itself a signed S/MIME message is returned for the caller to feed back to pki.smime.verify. Bidirectionally interoperable with openssl cms -encrypt / -decrypt.
|
|
15
|
+
|
|
16
|
+
## v0.2.25 — 2026-07-16
|
|
17
|
+
|
|
18
|
+
S/MIME signed-message assembly and verification arrive as pki.smime: sign and verify RFC 8551 multipart/signed and application/pkcs7-mime messages over the CMS layer, bidirectionally interoperable with OpenSSL.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
7
25
|
## v0.2.24 — 2026-07-16
|
|
8
26
|
|
|
9
27
|
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, and encryption over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one — `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat); the `smime-type` is derived from the CMS body, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt` |
|
|
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,375 @@
|
|
|
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 + encryption over the shipped CMS
|
|
13
|
+
* layer. `sign` wraps a MIME entity as a signed S/MIME message and `verify`
|
|
14
|
+
* unwraps and verifies one, in both forms: `multipart/signed` (clear-signed --
|
|
15
|
+
* the content stays readable, a detached CMS signature rides alongside) and
|
|
16
|
+
* `application/pkcs7-mime` (opaque -- the whole entity is a base64 CMS
|
|
17
|
+
* SignedData). `encrypt` envelopes a MIME entity as an opaque
|
|
18
|
+
* `application/pkcs7-mime` message and `decrypt` opens one -- `authEnveloped-data`
|
|
19
|
+
* (AES-GCM, confidentiality AND integrity, the default) or `enveloped-data`
|
|
20
|
+
* (AES-CBC, confidentiality only, so `decrypt` surfaces `authenticated:false`,
|
|
21
|
+
* the EFAIL / RFC 8551 sec. 3.3 no-integrity caveat). The crypto is entirely
|
|
22
|
+
* `pki.cms.sign` / `verify` / `encrypt` / `decrypt`; the new work is the MIME
|
|
23
|
+
* frame and the RFC 8551 sec. 3.1.1 canonicalization signer and verifier share
|
|
24
|
+
* one canonicalizer (`lib/mime.js`) so their digests cannot diverge. Like
|
|
25
|
+
* `cms.verify`, `verify` returns the per-signer cryptographic verdict; chaining
|
|
26
|
+
* a signer to a trust anchor is the caller's `pki.path.validate` step.
|
|
27
|
+
*
|
|
28
|
+
* @card
|
|
29
|
+
* Assemble, verify, and encrypt RFC 8551 S/MIME messages (signed:
|
|
30
|
+
* multipart/signed + application/pkcs7-mime; encrypted: enveloped-data +
|
|
31
|
+
* authEnveloped-data) over any CMS signer / recipient -- fail-closed,
|
|
32
|
+
* algorithm-agnostic.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
var frameworkError = require("./framework-error.js");
|
|
36
|
+
var mime = require("./mime.js");
|
|
37
|
+
var cms = require("./cms-verify.js");
|
|
38
|
+
var schemaCms = require("./schema-cms.js");
|
|
39
|
+
var guard = require("./guard-all.js");
|
|
40
|
+
var C = require("./constants.js");
|
|
41
|
+
var nodeCrypto = require("crypto");
|
|
42
|
+
|
|
43
|
+
var SmimeError = frameworkError.SmimeError;
|
|
44
|
+
|
|
45
|
+
function _err(code, msg, cause) { return new SmimeError(code, msg, cause); }
|
|
46
|
+
|
|
47
|
+
// RFC 8551 uses application/pkcs7-<kind>; OpenSSL's legacy `smime` command emits the PKCS#7
|
|
48
|
+
// application/x-pkcs7-<kind>. Accept both on the RECEIVE side (we always EMIT the RFC 8551 form).
|
|
49
|
+
function _isPkcs7(type, kind) {
|
|
50
|
+
var t = (type || "").toLowerCase();
|
|
51
|
+
return t === "application/pkcs7-" + kind || t === "application/x-pkcs7-" + kind;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The RFC 8551 sec. 3.4.3.2 micalg name for a CMS digest (schema-cms surfaces "sha256" etc.).
|
|
55
|
+
// RFC 8551 sec. 3.4.3.2 micalg names, extended with the SHAKE names FIPS 204/205 CMS signers digest
|
|
56
|
+
// with (RFC 8702). An unknown digest passes through verbatim -- never regex-mangled (a blind
|
|
57
|
+
// `sha`->`sha-` would corrupt "shake256" into "sha-ke256").
|
|
58
|
+
var MICALG = { sha1: "sha-1", sha224: "sha-224", sha256: "sha-256", sha384: "sha-384", sha512: "sha-512", md5: "md5", shake128: "shake128", shake256: "shake256" };
|
|
59
|
+
|
|
60
|
+
// The exact bytes to sign: the caller's content wrapped as a MIME entity (text/plain by default) in
|
|
61
|
+
// its canonical form, OR -- when opts.entity is set -- the caller's own complete MIME entity, canonical.
|
|
62
|
+
function _entityBytes(content, opts) {
|
|
63
|
+
var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
|
|
64
|
+
if (opts.entity) return mime.canonicalize(raw, SmimeError, "smime/bad-mime");
|
|
65
|
+
var ct = opts.contentType || "text/plain; charset=utf-8";
|
|
66
|
+
// Declare the honest Content-Transfer-Encoding: "7bit" requires every byte <= 127 (RFC 2045); a body
|
|
67
|
+
// with any 8-bit byte is "8bit". Declaring 7bit for 8-bit content is a false claim a transport could act on.
|
|
68
|
+
var cte = _is7bit(raw) ? "7bit" : "8bit";
|
|
69
|
+
var head = Buffer.from("Content-Type: " + ct + "\r\nContent-Transfer-Encoding: " + cte + "\r\n\r\n", "latin1");
|
|
70
|
+
return mime.canonicalize(Buffer.concat([head, raw]), SmimeError, "smime/bad-mime");
|
|
71
|
+
}
|
|
72
|
+
function _is7bit(buf) { for (var i = 0; i < buf.length; i++) if (buf[i] > 0x7f) return false; return true; }
|
|
73
|
+
|
|
74
|
+
// Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic -- it forwards any signer).
|
|
75
|
+
function _cmsSignOpts(opts, detached) {
|
|
76
|
+
var o = { detached: detached };
|
|
77
|
+
if (opts.signingTime !== undefined) o.signingTime = opts.signingTime;
|
|
78
|
+
if (opts.sid) o.sid = opts.sid;
|
|
79
|
+
if (opts.signedAttributes !== undefined) o.signedAttributes = opts.signedAttributes;
|
|
80
|
+
if (opts.additionalSignedAttributes) o.additionalSignedAttributes = opts.additionalSignedAttributes;
|
|
81
|
+
return o;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Coverage residual: the defensive `|| default` fallbacks in the helpers below (a null media type, a
|
|
85
|
+
// non-standard SignerInfo digest name -> a derived micalg, an absent micalg -> "sha-256"/null, a
|
|
86
|
+
// non-Buffer cms.sign result) and the two catch arms (a CMS body that passed cms.verify yet fails a
|
|
87
|
+
// second schema-cms.parse) are belt-and-braces around the primary path the round-trips exercise; the
|
|
88
|
+
// producer never emits any of them.
|
|
89
|
+
function _micName(name) { return name ? (MICALG[name] || name) : null; }
|
|
90
|
+
// Normalize a micalg header value into the same sorted-distinct comma-joined form _micalgOf emits.
|
|
91
|
+
function _micalgSet(value) {
|
|
92
|
+
var seen = [];
|
|
93
|
+
String(value).split(",").forEach(function (m) { var t = m.trim().toLowerCase(); if (t && seen.indexOf(t) < 0) seen.push(t); });
|
|
94
|
+
return seen.sort().join(",");
|
|
95
|
+
}
|
|
96
|
+
// The micalg for the SignedData -- RFC 8551 sec. 3.4.3.2: EVERY signer's message-digest algorithm,
|
|
97
|
+
// distinct, sorted, comma-separated (a multi-signer message with mixed digests lists them all).
|
|
98
|
+
function _micalgOf(p7Der) {
|
|
99
|
+
var parsed;
|
|
100
|
+
try { parsed = schemaCms.parse(guard.bytes.view(p7Der, SmimeError, "smime/bad-mime", "the CMS body")); }
|
|
101
|
+
catch (e) { throw _err("smime/bad-mime", "the CMS SignedData body could not be parsed", e); }
|
|
102
|
+
var out = [];
|
|
103
|
+
(parsed.signerInfos || []).forEach(function (si) {
|
|
104
|
+
var mic = _micName(si.digestAlgorithm && si.digestAlgorithm.name);
|
|
105
|
+
if (mic && out.indexOf(mic) < 0) out.push(mic);
|
|
106
|
+
});
|
|
107
|
+
return out.length ? out.sort().join(",") : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A fresh multipart boundary that cannot collide with the content (random, dashed, unique per call).
|
|
111
|
+
function _boundary() { return "----=_pki_smime_" + nodeCrypto.randomBytes(18).toString("hex"); }
|
|
112
|
+
|
|
113
|
+
function _base64Body(der) {
|
|
114
|
+
return Buffer.from(der.toString("base64").replace(/(.{64})/g, "$1\r\n").replace(/\r\n$/, ""), "latin1");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @primitive pki.smime.sign
|
|
119
|
+
* @signature pki.smime.sign(content, signers, opts?) -> Promise<Buffer>
|
|
120
|
+
* @since 0.2.25
|
|
121
|
+
* @status experimental
|
|
122
|
+
* @spec RFC 8551, RFC 5652
|
|
123
|
+
* @related pki.smime.verify, pki.cms.sign
|
|
124
|
+
*
|
|
125
|
+
* Assemble a signed S/MIME message (RFC 8551). `content` is the payload -- a raw body wrapped as a
|
|
126
|
+
* `text/plain` entity by default, or the caller's own complete MIME entity when `opts.entity` is set;
|
|
127
|
+
* `signers` is the `pki.cms.sign` signer array (any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA
|
|
128
|
+
* signer -- the S/MIME layer is algorithm-agnostic). Two forms via `opts.form`:
|
|
129
|
+
* - `"multipart"` (default, clear-signed): a `multipart/signed` message carrying the canonical entity
|
|
130
|
+
* verbatim in the first part and a DETACHED CMS SignedData (`application/pkcs7-signature`) over its
|
|
131
|
+
* canonical form in the second, with `protocol="application/pkcs7-signature"` + a matching `micalg`.
|
|
132
|
+
* - `"pkcs7-mime"` (opaque): one `application/pkcs7-mime; smime-type=signed-data` entity whose base64
|
|
133
|
+
* body is an ATTACHED CMS SignedData over the canonical entity.
|
|
134
|
+
* The signed bytes are the entity's RFC 8551 sec. 3.1.1 canonical form (CRLF line endings); the SAME
|
|
135
|
+
* canonicalizer runs on verify. Returns the assembled message bytes. Fail-closed with `SmimeError`.
|
|
136
|
+
*
|
|
137
|
+
* @opts form `"multipart"` (default) or `"pkcs7-mime"`.
|
|
138
|
+
* @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
|
|
139
|
+
* @opts contentType the wrapped entity's Content-Type (default `text/plain; charset=utf-8`).
|
|
140
|
+
* @opts signingTime a `Date` for the CMS signing-time attribute, or false to omit it.
|
|
141
|
+
* @example
|
|
142
|
+
* var msg = await pki.smime.sign(Buffer.from("hello"), [{ cert: signerCertDer, key: signerKeyPkcs8 }]);
|
|
143
|
+
*/
|
|
144
|
+
async function sign(content, signers, opts) {
|
|
145
|
+
opts = opts || {};
|
|
146
|
+
var entity = _entityBytes(content, opts);
|
|
147
|
+
var form = opts.form || "multipart";
|
|
148
|
+
if (form === "pkcs7-mime") {
|
|
149
|
+
var p7m = await cms.sign(entity, signers, _cmsSignOpts(opts, false));
|
|
150
|
+
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");
|
|
151
|
+
return _capped(Buffer.concat([head, _base64Body(_toBuf(p7m)), mime.CRLF]));
|
|
152
|
+
}
|
|
153
|
+
if (form !== "multipart") throw _err("smime/bad-input", "form must be \"multipart\" or \"pkcs7-mime\"");
|
|
154
|
+
var p7s = _toBuf(await cms.sign(entity, signers, _cmsSignOpts(opts, true)));
|
|
155
|
+
var micalg = _micalgOf(p7s) || "sha-256";
|
|
156
|
+
var boundary = _boundary();
|
|
157
|
+
var head2 = Buffer.from("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n\r\n", "latin1");
|
|
158
|
+
var sigPart = Buffer.concat([
|
|
159
|
+
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"),
|
|
160
|
+
_base64Body(p7s),
|
|
161
|
+
]);
|
|
162
|
+
var dl = Buffer.from("--" + boundary + "\r\n", "latin1"), close = Buffer.from("--" + boundary + "--\r\n", "latin1");
|
|
163
|
+
return _capped(Buffer.concat([head2, dl, entity, mime.CRLF, dl, sigPart, mime.CRLF, close]));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// A produced message MUST stay within the same size ceiling verify enforces on receive, so sign never
|
|
167
|
+
// emits an S/MIME message our own verify would reject as too large (producer/consumer symmetry).
|
|
168
|
+
// Coverage residual: the throw only fires on a >16 MiB assembled message -- a real cap, exercised by no
|
|
169
|
+
// unit vector (driving it would sign 16 MiB).
|
|
170
|
+
function _capped(msg) {
|
|
171
|
+
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");
|
|
172
|
+
return msg;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @primitive pki.smime.verify
|
|
177
|
+
* @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg }>
|
|
178
|
+
* @since 0.2.25
|
|
179
|
+
* @status experimental
|
|
180
|
+
* @spec RFC 8551, RFC 5652
|
|
181
|
+
* @related pki.smime.sign, pki.cms.verify, pki.path.validate
|
|
182
|
+
*
|
|
183
|
+
* Unwrap and verify a signed S/MIME message (RFC 8551), both `multipart/signed` and
|
|
184
|
+
* `application/pkcs7-mime; smime-type=signed-data`. For `multipart/signed` the detached CMS signature
|
|
185
|
+
* is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the SAME canonicalizer the
|
|
186
|
+
* signer used); for `application/pkcs7-mime` the base64 body is the attached CMS SignedData. Returns
|
|
187
|
+
* `pki.cms.verify`'s `{ valid, signers }` verdict PLUS `form`, the recovered `content` (the signed MIME
|
|
188
|
+
* entity bytes), and the `micalg`. Like `cms.verify`, this returns the cryptographic verdict only --
|
|
189
|
+
* chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
|
|
190
|
+
* that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
|
|
191
|
+
*
|
|
192
|
+
* @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
|
|
193
|
+
* @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
|
|
194
|
+
* @example
|
|
195
|
+
* var res = await pki.smime.verify(smimeMessageBytes);
|
|
196
|
+
* if (res.valid) { res.content; res.signers[0].sid; }
|
|
197
|
+
*/
|
|
198
|
+
async function verify(message, opts) {
|
|
199
|
+
opts = opts || {};
|
|
200
|
+
var ent = mime.parse(message, SmimeError, "smime/bad-mime");
|
|
201
|
+
var ct = ent.contentType;
|
|
202
|
+
var vOpts = {};
|
|
203
|
+
if (opts.certs) vOpts.certs = opts.certs;
|
|
204
|
+
if (_isPkcs7(ct.type, "mime")) {
|
|
205
|
+
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)");
|
|
206
|
+
var p7m = _decodeCms(ent);
|
|
207
|
+
var res = await cms.verify(p7m, vOpts);
|
|
208
|
+
var inner;
|
|
209
|
+
try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
|
|
210
|
+
catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
|
|
211
|
+
return { valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null };
|
|
212
|
+
}
|
|
213
|
+
if (ct.type === "multipart/signed") {
|
|
214
|
+
if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
|
|
215
|
+
var parts = mime.splitMultipart(ent.body, ct.params.boundary, SmimeError, "smime/bad-multipart");
|
|
216
|
+
if (parts.length !== 2) throw _err("smime/bad-multipart", "multipart/signed must have exactly two body parts, got " + parts.length);
|
|
217
|
+
var sigEnt = mime.parse(parts[1], SmimeError, "smime/bad-mime");
|
|
218
|
+
if (!_isPkcs7(sigEnt.contentType.type, "signature")) throw _err("smime/bad-multipart", "the second part must be application/pkcs7-signature");
|
|
219
|
+
var p7s = _decodeCms(sigEnt);
|
|
220
|
+
// RFC 8551 sec. 3.4 / RFC 1847: the multipart/signed signature is DETACHED -- the SignedData carries
|
|
221
|
+
// NO encapsulated content, the first MIME part IS the signed unit. Reject an ATTACHED SignedData:
|
|
222
|
+
// otherwise cms.verify would verify over the embedded eContent and IGNORE the first part, so an
|
|
223
|
+
// attacker could pair any validly-signed attached blob with an arbitrary first part (a forgery).
|
|
224
|
+
var sd;
|
|
225
|
+
try { sd = schemaCms.parse(p7s); }
|
|
226
|
+
catch (e) { throw _err("smime/bad-mime", "the pkcs7-signature part is not a CMS SignedData", e); }
|
|
227
|
+
if (sd.encapContentInfo && sd.encapContentInfo.eContent != null) {
|
|
228
|
+
throw _err("smime/bad-multipart", "the pkcs7-signature part must carry a DETACHED SignedData (no encapsulated content) (RFC 8551 sec. 3.4)");
|
|
229
|
+
}
|
|
230
|
+
var canon = mime.canonicalize(parts[0], SmimeError, "smime/bad-mime");
|
|
231
|
+
var res2 = await cms.verify(p7s, Object.assign({}, vOpts, { content: canon }));
|
|
232
|
+
var micalg = ct.params.micalg || null;
|
|
233
|
+
// Compare micalg as an ORDER-INDEPENDENT set of digest names (RFC 8551 sec. 3.4.3.2 lists them
|
|
234
|
+
// comma-separated, in no required order, possibly with whitespace).
|
|
235
|
+
if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
|
|
236
|
+
throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
|
|
237
|
+
}
|
|
238
|
+
return { valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg };
|
|
239
|
+
}
|
|
240
|
+
throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The application/pkcs7-mime header for an enveloped S/MIME message (RFC 8551 sec. 3.2 / sec. 3.2.2).
|
|
244
|
+
function _pkcs7MimeHead(smimeType) {
|
|
245
|
+
return Buffer.from("Content-Type: application/pkcs7-mime; smime-type=" + smimeType + "; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "latin1");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The smime-type for a CMS enveloped body, DERIVED from its OUTER content type (RFC 8551 sec. 3.2.2) --
|
|
249
|
+
// the single source of truth, never an opts guess: AuthEnvelopedData -> "authEnveloped-data" (AEAD,
|
|
250
|
+
// integrity), EnvelopedData -> "enveloped-data" (no integrity). Anything else is not an S/MIME enveloped
|
|
251
|
+
// message. Returns { smimeType, name } (name is the parsed contentTypeName). Throws smime/unsupported-type
|
|
252
|
+
// for a non-enveloped body (EncryptedData / SignedData carries no smime-type mapping here).
|
|
253
|
+
function _envelopedTypeOf(der, badParseCode) {
|
|
254
|
+
var name;
|
|
255
|
+
try { name = schemaCms.parse(guard.bytes.view(der, SmimeError, "smime/bad-mime", "the CMS body")).contentTypeName; }
|
|
256
|
+
catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime body is not a parseable CMS structure", e); }
|
|
257
|
+
if (name === "authEnvelopedData") return "authEnveloped-data";
|
|
258
|
+
if (name === "envelopedData") return "enveloped-data";
|
|
259
|
+
throw _err(badParseCode, "the pkcs7-mime body is a " + name + ", not an EnvelopedData / AuthEnvelopedData");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Map smime opts to cms.encrypt opts. The S/MIME layer is recipient-agnostic: it forwards the content
|
|
263
|
+
// -encryption choice + every recipient-shaping opt unchanged, and never emits PEM (the CMS body is base64
|
|
264
|
+
// inside a MIME frame). opts.contentType is the MIME media type of the INNER entity (handled by
|
|
265
|
+
// _entityBytes), NOT the CMS encapsulated content type -- it is deliberately not forwarded here.
|
|
266
|
+
function _cmsEncryptOpts(opts) {
|
|
267
|
+
var o = {};
|
|
268
|
+
if (opts.contentEncryptionAlgorithm !== undefined) o.contentEncryptionAlgorithm = opts.contentEncryptionAlgorithm;
|
|
269
|
+
if (opts.oaepHash !== undefined) o.oaepHash = opts.oaepHash;
|
|
270
|
+
if (opts.keyIdentifier !== undefined) o.keyIdentifier = opts.keyIdentifier;
|
|
271
|
+
if (opts.ukm !== undefined) o.ukm = opts.ukm;
|
|
272
|
+
return o;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @primitive pki.smime.encrypt
|
|
277
|
+
* @signature pki.smime.encrypt(content, recipients, opts?) -> Promise<Buffer>
|
|
278
|
+
* @since 0.2.26
|
|
279
|
+
* @status experimental
|
|
280
|
+
* @spec RFC 8551, RFC 5652, RFC 5083
|
|
281
|
+
* @related pki.smime.decrypt, pki.cms.encrypt
|
|
282
|
+
*
|
|
283
|
+
* Envelope a MIME entity as an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4). `content` is the
|
|
284
|
+
* payload -- a raw body wrapped as a `text/plain` entity by default, or the caller's own complete MIME
|
|
285
|
+
* entity when `opts.entity` is set; `recipients` is the `pki.cms.encrypt` recipient array (any RSA-OAEP
|
|
286
|
+
* ktri / EC or X25519/X448 kari / ML-KEM ori-KEM / password pwri / kek kekri -- the S/MIME layer is
|
|
287
|
+
* recipient-agnostic; a single descriptor is accepted and normalized to a one-element array). Enveloping
|
|
288
|
+
* has ONE form -- opaque `application/pkcs7-mime` with the whole entity base64-encoded. The `smime-type`
|
|
289
|
+
* is derived from the produced CMS: AES-GCM (the default) yields an AuthEnvelopedData with
|
|
290
|
+
* `smime-type=authEnveloped-data` (confidentiality AND integrity); a CBC choice yields an EnvelopedData
|
|
291
|
+
* with `smime-type=enveloped-data` (confidentiality only -- no integrity, RFC 8551 sec. 3.3). Returns the
|
|
292
|
+
* assembled message bytes. Fail-closed with `SmimeError`.
|
|
293
|
+
*
|
|
294
|
+
* @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
|
|
295
|
+
* @opts contentType the wrapped entity's MIME Content-Type (default `text/plain; charset=utf-8`).
|
|
296
|
+
* @opts contentEncryptionAlgorithm forwarded to cms.encrypt: `"aes-256-gcm"` (default) / `"aes-128-gcm"` / `"aes-256-cbc"` / `"aes-128-cbc"`.
|
|
297
|
+
* @opts oaepHash forwarded: the RSAES-OAEP hash for ktri recipients.
|
|
298
|
+
* @opts keyIdentifier forwarded: `"issuerAndSerial"` (default) or `"subjectKeyIdentifier"`.
|
|
299
|
+
* @opts ukm forwarded: user keying material for kari / kemri recipients.
|
|
300
|
+
* @example
|
|
301
|
+
* var enc = await pki.smime.encrypt(Buffer.from("secret"), [{ cert: recipientCertDer }]);
|
|
302
|
+
*/
|
|
303
|
+
async function encrypt(content, recipients, opts) {
|
|
304
|
+
opts = opts || {};
|
|
305
|
+
var entity = _entityBytes(content, opts);
|
|
306
|
+
// Normalize a single descriptor to an array so cms.encrypt always takes the ENVELOPED path (an array of
|
|
307
|
+
// RecipientInfos), never bare EncryptedData -- which is not an S/MIME construct (no smime-type maps to it).
|
|
308
|
+
var recips = Array.isArray(recipients) ? recipients : [recipients];
|
|
309
|
+
var der = _toBuf(await cms.encrypt(entity, recips, _cmsEncryptOpts(opts)));
|
|
310
|
+
var head = _pkcs7MimeHead(_envelopedTypeOf(der, "smime/bad-mime"));
|
|
311
|
+
return _capped(Buffer.concat([head, _base64Body(der), mime.CRLF]));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @primitive pki.smime.decrypt
|
|
316
|
+
* @signature pki.smime.decrypt(message, keyMaterial, opts?) -> Promise<{ content, smimeType, authenticated, recipientType, recipientIndex, contentEncryptionAlgorithm }>
|
|
317
|
+
* @since 0.2.26
|
|
318
|
+
* @status experimental
|
|
319
|
+
* @spec RFC 8551, RFC 5652, RFC 5083
|
|
320
|
+
* @related pki.smime.encrypt, pki.cms.decrypt, pki.smime.verify
|
|
321
|
+
*
|
|
322
|
+
* Open an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4) -- an `application/pkcs7-mime` entity
|
|
323
|
+
* whose base64 body is a CMS EnvelopedData or AuthEnvelopedData. `keyMaterial` is the `pki.cms.decrypt`
|
|
324
|
+
* key material (`{ key, cert }`, `{ password }`, or `{ kek, kekId? }`). Returns the recovered inner MIME
|
|
325
|
+
* entity as `content`, the `smimeType`, `authenticated` (true only for AuthEnvelopedData -- a CBC
|
|
326
|
+
* `enveloped-data` message reports `false`, the RFC 8551 sec. 3.3 / EFAIL no-integrity caveat), and the
|
|
327
|
+
* `recipientType` / `recipientIndex` / `contentEncryptionAlgorithm` from the CMS layer. Fail-closed and
|
|
328
|
+
* oracle-free: every secret-dependent failure collapses to the uniform `cms/decrypt-failed` the CMS layer
|
|
329
|
+
* emits (this layer only propagates it). A recovered `content` that is itself a signed S/MIME message is
|
|
330
|
+
* returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
|
|
331
|
+
* legacy `application/x-pkcs7-mime` and a missing `smime-type`.
|
|
332
|
+
*
|
|
333
|
+
* @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
|
|
334
|
+
* @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
|
|
335
|
+
* @opts strictSmimeType reject a header `smime-type` that disagrees with the CMS body (`smime/smime-type-mismatch`).
|
|
336
|
+
* @example
|
|
337
|
+
* var res = await pki.smime.decrypt(smimeMessageBytes, { key: recipientKeyPkcs8, cert: recipientCertDer });
|
|
338
|
+
* res.content; // the recovered inner MIME entity
|
|
339
|
+
* res.authenticated; // false for an enveloped-only (CBC) message -- no integrity
|
|
340
|
+
*/
|
|
341
|
+
async function decrypt(message, keyMaterial, opts) {
|
|
342
|
+
opts = opts || {};
|
|
343
|
+
var ent = mime.parse(message, SmimeError, "smime/bad-mime");
|
|
344
|
+
var ct = ent.contentType;
|
|
345
|
+
if (!_isPkcs7(ct.type, "mime")) throw _err("smime/unsupported-type", "not an encrypted S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
|
|
346
|
+
// The smime-type header is advisory (the CMS body is authoritative), so compare it case-insensitively --
|
|
347
|
+
// liberal on accept, strict on emit -- rather than rejecting a legitimate message over its parameter casing.
|
|
348
|
+
var st = ct.params["smime-type"];
|
|
349
|
+
var stLower = st ? st.toLowerCase() : st;
|
|
350
|
+
if (st && stLower !== "enveloped-data" && stLower !== "authenveloped-data") throw _err("smime/unsupported-type", "unsupported smime-type " + JSON.stringify(st) + " (only enveloped-data / authEnveloped-data)");
|
|
351
|
+
var der = _decodeCms(ent);
|
|
352
|
+
var smimeType = _envelopedTypeOf(der, "smime/unsupported-type");
|
|
353
|
+
if (opts.strictSmimeType && st && stLower !== smimeType.toLowerCase()) throw _err("smime/smime-type-mismatch", "the header smime-type " + JSON.stringify(st) + " disagrees with the CMS body (" + smimeType + ")");
|
|
354
|
+
var cmsOpts = {};
|
|
355
|
+
if (opts.recipientIndex !== undefined) cmsOpts.recipientIndex = opts.recipientIndex;
|
|
356
|
+
if (opts.maxIterations !== undefined) cmsOpts.maxIterations = opts.maxIterations;
|
|
357
|
+
var res = await cms.decrypt(der, keyMaterial, cmsOpts);
|
|
358
|
+
return {
|
|
359
|
+
content: res.content, smimeType: smimeType, authenticated: res.authenticated,
|
|
360
|
+
recipientType: res.recipientType, recipientIndex: res.recipientIndex,
|
|
361
|
+
contentEncryptionAlgorithm: res.contentEncryptionAlgorithm,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Decode a CMS body per its Content-Transfer-Encoding (base64 is the S/MIME norm; 7bit/8bit/binary pass through).
|
|
366
|
+
function _decodeCms(ent) {
|
|
367
|
+
// guard.encoding.base64 is strict (no whitespace) and rejects via a (code,msg) FACTORY -- strip the
|
|
368
|
+
// MIME line-wrapping whitespace first, and pass _err (not the SmimeError class).
|
|
369
|
+
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");
|
|
370
|
+
return ent.body;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _toBuf(v) { return Buffer.isBuffer(v) ? v : Buffer.from(v); }
|
|
374
|
+
|
|
375
|
+
module.exports = { sign: sign, verify: verify, encrypt: encrypt, decrypt: decrypt };
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:f346b9f1-fcfc-449a-bd4f-4e1dfeb3209c",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-16T12:38:31.773Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.2.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.26",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.26",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.2.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.2.26",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.2.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.26",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|