@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 +9 -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 +248 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
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:40b168db-ab8a-4925-b224-648cd2ed3f10",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.25",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.25",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|