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