@blamejs/pki 0.5.7 → 0.5.8
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 +405 -386
- package/MIGRATING.md +43 -0
- package/README.md +12 -12
- package/lib/acme.js +31 -31
- package/lib/asn1-der.js +10 -10
- package/lib/attrcert-sign.js +19 -19
- package/lib/byte-reader.js +6 -6
- package/lib/byte-writer.js +5 -5
- package/lib/cbor-det.js +27 -24
- package/lib/cmc-build.js +97 -92
- package/lib/cmc-verify.js +106 -30
- package/lib/cmp-build.js +25 -25
- package/lib/cmp-session.js +70 -70
- package/lib/cmp-verify.js +71 -57
- package/lib/cms-compress.js +7 -7
- package/lib/cms-decrypt.js +90 -72
- package/lib/cms-encrypt.js +32 -32
- package/lib/cms-sign.js +74 -55
- package/lib/cms-verify.js +97 -75
- package/lib/composite-sig.js +13 -13
- package/lib/constants.js +4 -4
- package/lib/crl-sign.js +22 -22
- package/lib/crl-verify.js +7 -6
- package/lib/crmf-sign.js +14 -14
- package/lib/csr-sign.js +8 -8
- package/lib/ct.js +37 -37
- package/lib/edwards-point.js +7 -7
- package/lib/est.js +98 -55
- package/lib/framework-error.js +5 -5
- package/lib/guard-all.js +3 -3
- package/lib/guard-async.js +4 -4
- package/lib/guard-bytes.js +79 -79
- package/lib/guard-compress.js +17 -17
- package/lib/guard-crypto.js +1 -1
- package/lib/guard-encoding.js +15 -15
- package/lib/guard-header.js +3 -3
- package/lib/guard-identifier.js +16 -16
- package/lib/guard-json.js +15 -15
- package/lib/guard-limits.js +7 -7
- package/lib/guard-name.js +81 -16
- package/lib/guard-parsed.js +80 -80
- package/lib/guard-range.js +19 -19
- package/lib/guard-secret.js +11 -10
- package/lib/guard-text.js +6 -6
- package/lib/guard-time.js +10 -10
- package/lib/hpke.js +18 -17
- package/lib/http-digest.js +35 -35
- package/lib/http-retry-after.js +13 -13
- package/lib/http-transport.js +20 -19
- package/lib/inspect.js +53 -53
- package/lib/ip-utils.js +2 -2
- package/lib/jose.js +13 -13
- package/lib/key.js +16 -16
- package/lib/lint.js +51 -51
- package/lib/merkle.js +51 -36
- package/lib/mime.js +18 -18
- package/lib/ocsp-verify.js +10 -10
- package/lib/ocsp.js +13 -13
- package/lib/oid.js +29 -29
- package/lib/path-validate.js +114 -113
- package/lib/pbes2.js +16 -16
- package/lib/pkcs12-build.js +53 -53
- package/lib/pki-build.js +21 -19
- package/lib/rc2.js +1 -1
- package/lib/rfc3339.js +5 -5
- package/lib/schema-all.js +31 -31
- package/lib/schema-attrcert.js +12 -12
- package/lib/schema-c509.js +144 -142
- package/lib/schema-cmc.js +58 -58
- package/lib/schema-cmp.js +43 -43
- package/lib/schema-cms.js +45 -45
- package/lib/schema-crl.js +7 -7
- package/lib/schema-crmf.js +28 -28
- package/lib/schema-csr.js +12 -12
- package/lib/schema-csrattrs.js +16 -16
- package/lib/schema-engine.js +18 -18
- package/lib/schema-ocsp.js +15 -15
- package/lib/schema-pkcs12.js +20 -20
- package/lib/schema-pkcs8.js +2 -2
- package/lib/schema-pkix.js +131 -126
- package/lib/schema-smime.js +19 -19
- package/lib/schema-tsp.js +12 -12
- package/lib/schema-x509.js +3 -3
- package/lib/shbs.js +18 -18
- package/lib/sign-scheme.js +13 -13
- package/lib/sigstore.js +10 -11
- package/lib/sleep.js +1 -1
- package/lib/smime.js +308 -96
- package/lib/tls-cert-compress.js +18 -18
- package/lib/trust.js +27 -27
- package/lib/tsp-sign.js +17 -17
- package/lib/validator-all.js +1 -1
- package/lib/validator-attcert.js +1 -1
- package/lib/validator-cose.js +43 -44
- package/lib/validator-keydesc.js +3 -3
- package/lib/validator-sig.js +13 -13
- package/lib/validator-tls.js +11 -11
- package/lib/validator-tpm.js +20 -19
- package/lib/webauthn-mds.js +66 -66
- package/lib/webauthn.js +33 -33
- package/lib/webcrypto.js +15 -15
- package/lib/x509-sign.js +13 -13
- package/package.json +3 -2
- package/sbom.cdx.json +6 -6
package/lib/smime.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* layer. `sign` wraps a MIME entity as a signed S/MIME message and `verify`
|
|
14
14
|
* unwraps and verifies one, in both forms: `multipart/signed` (clear-signed --
|
|
15
15
|
* the content stays readable, a detached CMS signature rides alongside) and
|
|
16
|
-
* `application/pkcs7-mime` (opaque
|
|
16
|
+
* `application/pkcs7-mime` (opaque, where the whole entity is a base64 CMS
|
|
17
17
|
* SignedData). `encrypt` envelopes a MIME entity as an opaque
|
|
18
18
|
* `application/pkcs7-mime` message and `decrypt` opens one -- `authEnveloped-data`
|
|
19
19
|
* (AES-GCM, confidentiality AND integrity, the default) or `enveloped-data`
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* @card
|
|
29
29
|
* Assemble, verify, and encrypt RFC 8551 S/MIME messages (signed:
|
|
30
30
|
* multipart/signed + application/pkcs7-mime; encrypted: enveloped-data +
|
|
31
|
-
* authEnveloped-data) over any CMS signer / recipient
|
|
31
|
+
* authEnveloped-data) over any CMS signer / recipient, fail-closed,
|
|
32
32
|
* algorithm-agnostic.
|
|
33
33
|
*/
|
|
34
34
|
|
|
@@ -36,6 +36,9 @@ var frameworkError = require("./framework-error.js");
|
|
|
36
36
|
var mime = require("./mime.js");
|
|
37
37
|
var cms = require("./cms-verify.js");
|
|
38
38
|
var schemaCms = require("./schema-cms.js");
|
|
39
|
+
var schemaX509 = require("./schema-x509.js");
|
|
40
|
+
var pkix = require("./schema-pkix.js");
|
|
41
|
+
var oid = require("./oid.js");
|
|
39
42
|
var guard = require("./guard-all.js");
|
|
40
43
|
var C = require("./constants.js");
|
|
41
44
|
var nodeCrypto = require("crypto");
|
|
@@ -46,14 +49,14 @@ function _err(code, msg, cause) { return new SmimeError(code, msg, cause); }
|
|
|
46
49
|
|
|
47
50
|
// ---- the option surface each verb accepts -----------------------------------
|
|
48
51
|
//
|
|
49
|
-
// A misspelled option is the one input that reads as an omission
|
|
52
|
+
// A misspelled option is the one input that reads as an omission, never as a value: nothing is
|
|
50
53
|
// out of range, nothing fails to parse, and the caller who asked for something stricter silently
|
|
51
54
|
// gets the looser default. `protectHeaders` misspelled sends the headers a caller meant to protect
|
|
52
55
|
// as ordinary display copies; `strictMicalg` misspelled accepts the mismatch it was set to reject;
|
|
53
56
|
// `entity` misspelled wraps a caller's complete MIME entity inside another one.
|
|
54
57
|
//
|
|
55
|
-
// The tables are per
|
|
56
|
-
// something on sign and nothing on encrypt
|
|
58
|
+
// The tables are per verb, not per module, because the surfaces genuinely differ (`form` means
|
|
59
|
+
// something on sign and nothing on encrypt) and a merged table would accept each verb's options at
|
|
57
60
|
// every other one, which is the same silence in a wider form. Each is the keys that verb's body and
|
|
58
61
|
// the helpers it hands `opts` to actually read: the value goes through _entityBytes, _cmsSignOpts or
|
|
59
62
|
// _cmsEncryptOpts as readily as it is read here, so a table built from the verb's own lines alone
|
|
@@ -64,7 +67,7 @@ var SIGN_OPTS = {
|
|
|
64
67
|
};
|
|
65
68
|
var VERIFY_OPTS = {
|
|
66
69
|
certs: 1, trustAnchors: 1, time: 1, requiredEku: 1, checkPurpose: 1, strictMicalg: 1,
|
|
67
|
-
legacyHeaderProtection: 1,
|
|
70
|
+
legacyHeaderProtection: 1, expectedSender: 1,
|
|
68
71
|
};
|
|
69
72
|
var ENCRYPT_OPTS = {
|
|
70
73
|
entity: 1, contentType: 1, protectHeaders: 1, headers: 1, hcp: 1,
|
|
@@ -90,12 +93,12 @@ function _isPkcs7(type, kind) {
|
|
|
90
93
|
|
|
91
94
|
// The RFC 8551 sec. 3.4.3.2 micalg name for a CMS digest (schema-cms surfaces "sha256" etc.).
|
|
92
95
|
// RFC 8551 sec. 3.4.3.2 micalg names, extended with the SHAKE names FIPS 204/205 CMS signers digest
|
|
93
|
-
// with (RFC 8702). An unknown digest passes through verbatim
|
|
96
|
+
// with (RFC 8702). An unknown digest passes through verbatim and is never regex-mangled (a blind
|
|
94
97
|
// `sha`->`sha-` would corrupt "shake256" into "sha-ke256").
|
|
95
98
|
var MICALG = { sha1: "sha-1", sha224: "sha-224", sha256: "sha-256", sha384: "sha-384", sha512: "sha-512", md5: "md5", shake128: "shake128", shake256: "shake256" };
|
|
96
99
|
|
|
97
100
|
// The exact bytes to sign: the caller's content wrapped as a MIME entity (text/plain by default) in
|
|
98
|
-
// its canonical form,
|
|
101
|
+
// its canonical form, or, when opts.entity is set, the caller's own complete MIME entity, canonical.
|
|
99
102
|
function _entityBytes(content, opts) {
|
|
100
103
|
var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
|
|
101
104
|
if (opts.entity) return mime.canonicalize(raw, SmimeError, "smime/bad-mime");
|
|
@@ -117,7 +120,7 @@ function _is7bit(buf) { for (var i = 0; i < buf.length; i++) if (buf[i] > 0x7f)
|
|
|
117
120
|
// -Disposition / -ID / -Description / -Language / -Location / ...). These describe the specific MIME part --
|
|
118
121
|
// the library sets them -- so they are never protected as Non-Structural fields (rejected at the producer) nor
|
|
119
122
|
// surfaced in the authenticated protectedHeaders set on verify. The prefix rule is the RFC definition itself,
|
|
120
|
-
// so it matches
|
|
123
|
+
// so it matches every Content-* field (not just an enumerated subset) and is not a prototype-key lookup (a
|
|
121
124
|
// field named "constructor" / "toString" is simply not Structural).
|
|
122
125
|
function _isStructural(lname) {
|
|
123
126
|
return lname === "mime-version" || lname.indexOf("content-") === 0;
|
|
@@ -131,7 +134,7 @@ function _hpHeaderList(headers) {
|
|
|
131
134
|
if (Array.isArray(headers)) {
|
|
132
135
|
headers.forEach(function (h) {
|
|
133
136
|
if (!h || typeof h !== "object") throw _err("smime/bad-input", "each opts.headers entry must be { name, value }");
|
|
134
|
-
// Read name + value
|
|
137
|
+
// Read name + value once each: an accessor / Proxy could return a different value on a second read, so
|
|
135
138
|
// the snapshot (not the live property) is what both the signed inner header and the outer display copy use.
|
|
136
139
|
var name = h.name, v = h.value;
|
|
137
140
|
if (typeof name !== "string") throw _err("smime/bad-input", "each opts.headers entry must be { name, value }");
|
|
@@ -146,7 +149,7 @@ function _hpHeaderList(headers) {
|
|
|
146
149
|
// describes the MIME structure -- the library sets it -- so a caller passing one would duplicate it on the
|
|
147
150
|
// payload root. And a REPEATED field name is rejected here at the PRODUCER: a repeated protected field is an
|
|
148
151
|
// unsupported shape (verify fails closed on the ambiguity), so the library never emits a message it cannot
|
|
149
|
-
// re-consume. Reject both
|
|
152
|
+
// re-consume. Reject both; never emit a malformed / self-inconsistent entity.
|
|
150
153
|
var seen = Object.create(null);
|
|
151
154
|
out.forEach(function (h) {
|
|
152
155
|
var ln = h.name.toLowerCase();
|
|
@@ -163,7 +166,7 @@ function _hpHeaderList(headers) {
|
|
|
163
166
|
// The Header Confidentiality Policy for an OUTER header of an encrypted-HP message. hcp_baseline (RFC 9788
|
|
164
167
|
// sec. 3.2.1, the default) obscures Subject to "[...]" and REMOVES Comments / Keywords (null);
|
|
165
168
|
// hcp_no_confidentiality leaves every field visible (opt-in only). This is the sec. 3.2.1 pseudocode
|
|
166
|
-
//
|
|
169
|
+
// Verbatim: Bcc is intentionally not stripped. RFC 9788 sec. 11.4 explains that removing Bcc during
|
|
167
170
|
// encryption can break deliverability to a Bcc'd recipient, so the choice is left to the caller (omit Bcc
|
|
168
171
|
// from opts.headers to keep it out of the plaintext outer headers).
|
|
169
172
|
function _applyHcp(name, value, hcp) {
|
|
@@ -175,7 +178,7 @@ function _applyHcp(name, value, hcp) {
|
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
// The outer display header list: for "clear" (signed) the fields are copied verbatim; for "cipher"
|
|
178
|
-
// (encrypted) each passes through the HCP
|
|
181
|
+
// (encrypted) each passes through the HCP, where a null result removes the field (it lives only in the ciphertext).
|
|
179
182
|
function _outerHeaderList(list, mode, hcp) {
|
|
180
183
|
var policy = hcp == null ? "hcp_baseline" : hcp;
|
|
181
184
|
if (policy !== "hcp_baseline" && policy !== "hcp_no_confidentiality") throw _err("smime/bad-input", "unknown opts.hcp policy " + JSON.stringify(hcp) + " (only \"hcp_baseline\" or \"hcp_no_confidentiality\")");
|
|
@@ -193,13 +196,13 @@ function _outerHeaderList(list, mode, hcp) {
|
|
|
193
196
|
|
|
194
197
|
// Build the inner Cryptographic Payload for a header-protected message: the caller's body wrapped with its
|
|
195
198
|
// Content-Type (gaining ; hp="<mode>") + Content-Transfer-Encoding + the protected fields inlined, canonical.
|
|
196
|
-
// hpList is the
|
|
199
|
+
// hpList is the single normalized snapshot of opts.headers (see the caller), and that same list feeds the signed/
|
|
197
200
|
// encrypted inner header set here and the outer display copy, so an accessor / Proxy cannot make them diverge.
|
|
198
201
|
// outerList is the HCP-processed outer set (_outerHeaderList) -- for a cipher payload it also drives the
|
|
199
202
|
// HP-Outer records embedded here.
|
|
200
203
|
function _protectedInnerEntity(content, opts, mode, hpList, outerList) {
|
|
201
204
|
// Header protection inlines the protected fields on the payload ROOT it builds, so a caller-supplied
|
|
202
|
-
// complete entity (opts.entity) is an unsupported combination
|
|
205
|
+
// complete entity (opts.entity) is an unsupported combination: reject it, and never silently bury the
|
|
203
206
|
// caller's entity (its own Content-Type + headers) beneath a new default text/plain wrapper.
|
|
204
207
|
if (opts.entity) throw _err("smime/bad-input", "opts.entity is not supported with protectHeaders -- pass raw content + opts.contentType");
|
|
205
208
|
var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
|
|
@@ -212,7 +215,7 @@ function _protectedInnerEntity(content, opts, mode, hpList, outerList) {
|
|
|
212
215
|
hpList.forEach(function (h) { fields.push(h); });
|
|
213
216
|
// RFC 9788 sec. 2.2 / sec. 5.2.1 step 5.v: an ENCRYPTED header-protected payload documents inside the
|
|
214
217
|
// ciphertext, as HP-Outer records, every Non-Structural field it deliberately left visible in the outer
|
|
215
|
-
// (unprotected) header section
|
|
218
|
+
// (unprotected) header section, as "HP-Outer: <name>: <outer-value>". A field the HCP removed has no HP-Outer
|
|
216
219
|
// record; its absence authenticates that it was made confidential by removal. Signed-only (clear) payloads
|
|
217
220
|
// carry no HP-Outer (sec. 2.2: "not relevant for signed-only messages").
|
|
218
221
|
if (mode === "cipher") {
|
|
@@ -223,7 +226,7 @@ function _protectedInnerEntity(content, opts, mode, hpList, outerList) {
|
|
|
223
226
|
|
|
224
227
|
// The outer header prefix (display copies + MIME-Version) that precedes the Cryptographic Envelope's
|
|
225
228
|
// Content-Type in a header-protected message. Each field is guard-validated (no CR/LF/NUL injection). list is
|
|
226
|
-
// the
|
|
229
|
+
// the same HCP-processed outer set the inner entity's HP-Outer records were built from.
|
|
227
230
|
function _outerPrefix(list) {
|
|
228
231
|
var s = "";
|
|
229
232
|
for (var i = 0; i < list.length; i++) {
|
|
@@ -249,15 +252,15 @@ function _declaresHp(content) {
|
|
|
249
252
|
var s = content.toString("latin1");
|
|
250
253
|
var m = s.match(/\r\n\r\n/);
|
|
251
254
|
var head = (m ? s.slice(0, m.index) : s).replace(/\r\n[ \t]+/g, " ");
|
|
252
|
-
// Detect hp by composing mime.paramCount
|
|
255
|
+
// Detect hp by composing mime.paramCount, the same comment- + quoted-string-aware tokenizer the parser
|
|
253
256
|
// uses -- over each Content-Type field, so the probe never diverges from mime.parse: a leading-whitespace
|
|
254
257
|
// or before-colon field name is matched (the parser trims it), while an hp= inside a MIME comment or a
|
|
255
|
-
// quoted value is
|
|
258
|
+
// quoted value is not a parameter (so an ordinary Content-Type cannot opt a message into HP processing). A
|
|
256
259
|
// real hp on ANY Content-Type line is detected; a duplicate Content-Type is rejected downstream.
|
|
257
260
|
var cts = head.match(/^[ \t]*content-type[ \t]*:[^\r\n]*/gim);
|
|
258
261
|
if (!cts) return false;
|
|
259
262
|
for (var i = 0; i < cts.length; i++) {
|
|
260
|
-
// hasParam (not paramCount): a payload that names the hp attribute
|
|
263
|
+
// hasParam (not paramCount): a payload that names the hp attribute without a value ("...; hp") still
|
|
261
264
|
// CLAIMS header protection -- detect it so _hpSurface fails closed on the malformed declaration rather
|
|
262
265
|
// than silently downgrading it to unprotected.
|
|
263
266
|
if (mime.hasParam(cts[i].replace(/^[ \t]*content-type[ \t]*:/i, ""), "hp")) return true;
|
|
@@ -267,17 +270,20 @@ function _declaresHp(content) {
|
|
|
267
270
|
|
|
268
271
|
// A fresh not-protected surface (returned when no header protection is present or detection fails soft). A NEW
|
|
269
272
|
// object each call -- the caller Object.assign()s it onto the verify/decrypt result, so it must not be shared.
|
|
270
|
-
// `present` + `protectedHeaders` describe
|
|
271
|
-
// unless an opt-in legacy RFC8551HP inference succeeded (then it carries its own headers/mode/etc
|
|
272
|
-
|
|
273
|
+
// `present` + `protectedHeaders` describe only cryptographically-declared (hp=) protection; `legacy` is null
|
|
274
|
+
// unless an opt-in legacy RFC8551HP inference succeeded (then it carries its own headers/mode/etc.; see below).
|
|
275
|
+
// No header protection means no protected From to compare the outer one against, so the
|
|
276
|
+
// mismatch question is unanswered rather than answered "no" -- null, matching every other
|
|
277
|
+
// field here that reports an absent property.
|
|
278
|
+
function _noneSurface() { return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: null, confidential: [], legacy: null } }; }
|
|
273
279
|
|
|
274
280
|
// Count the Content-Type fields in a parsed header list. A structure with more than one is AMBIGUOUS: mime.parse
|
|
275
|
-
// surfaces only the
|
|
276
|
-
// field would be invisible to the classification
|
|
281
|
+
// surfaces only the first via contentType, so an hp= parameter or a Cryptographic-Layer media type on a later
|
|
282
|
+
// field would be invisible to the classification, and both the standard HP path and the legacy path fail closed on it.
|
|
277
283
|
function _contentTypeCount(headers) { var n = 0; headers.forEach(function (h) { if (h.lname === "content-type") n++; }); return n; }
|
|
278
284
|
|
|
279
285
|
// Header fields that must not sensibly repeat: the RFC 5322 sec. 3.6 max-1 originator/destination/identification
|
|
280
|
-
// fields, plus Return-Path (the envelope sender
|
|
286
|
+
// fields, plus Return-Path (the envelope sender; RFC 5322 sec. 3.6 groups it under trace, but RFC 5321 sec. 4.4
|
|
281
287
|
// restricts a delivered message to a single one). A duplicate of one of these is a malformed / ambiguous set (a
|
|
282
288
|
// consumer could pick a different sender / subject); a duplicate of any OTHER field (Received and the other
|
|
283
289
|
// trace fields, Resent-*, Comments, Keywords, and optional / X-* fields) is legitimately repeatable in a real
|
|
@@ -287,14 +293,14 @@ var RFC5322_SINGLETON = new Set(["date", "from", "sender", "reply-to", "to", "cc
|
|
|
287
293
|
|
|
288
294
|
// Extract the Non-Structural protected fields from a recovered inner header list. Shared by the standard HP path
|
|
289
295
|
// (over the Cryptographic Payload root) and the legacy path (over the message/rfc822 inner message, sec. 4.10).
|
|
290
|
-
// Returns { protectedHeaders (UTF-8 map, last-wins
|
|
291
|
-
// path), entries (an
|
|
296
|
+
// Returns { protectedHeaders (UTF-8 map, last-wins, the ergonomic single-value view surfaced by the standard
|
|
297
|
+
// path), entries (an ordered array of every { name, value, raw } occurrence, since a real message repeats trace
|
|
292
298
|
// fields, and a last-wins collapse would drop all but one), innerFrom, refouter (HP-Outer records), dup (any
|
|
293
299
|
// duplicate field name), dupSingleton (a duplicate of an RFC 5322 singleton field) }.
|
|
294
300
|
function _extractProtected(headerList) {
|
|
295
301
|
// mime.parse decodes the header block as latin1 (byte-preserving), so a protected value emitted as UTF-8
|
|
296
302
|
// (RFC 6532, the guard permits it) is re-decoded latin1->UTF-8 here to round-trip intact. protectedHeaders
|
|
297
|
-
// surfaces the exact authenticated field body (rawValue: leading/trailing whitespace preserved),
|
|
303
|
+
// surfaces the exact authenticated field body (rawValue: leading/trailing whitespace preserved), and not a
|
|
298
304
|
// trimmed value that would diverge from the signed octets.
|
|
299
305
|
var protectedHeaders = Object.create(null), innerFrom = null, seen = Object.create(null), dup = null, dupSingleton = null;
|
|
300
306
|
var entries = []; // every Non-Structural { name, value } occurrence, in order -- retains legally-repeated fields
|
|
@@ -302,11 +308,11 @@ function _extractProtected(headerList) {
|
|
|
302
308
|
headerList.forEach(function (h) {
|
|
303
309
|
if (_isStructural(h.lname)) return;
|
|
304
310
|
if (h.lname === "hp-outer") {
|
|
305
|
-
// RFC 9788 sec. 4.2.1 step 4.i: split the HP-Outer value on the
|
|
311
|
+
// RFC 9788 sec. 4.2.1 step 4.i: split the HP-Outer value on the first colon into (name, outer-value)
|
|
306
312
|
// -> refouter. The value is kept BYTE-PRESERVING (latin1), never UTF-8-decoded, because the sec. 4.3.1
|
|
307
313
|
// confidentiality comparison must be octet-exact (a lossy decode maps distinct invalid octets 0x80/0x81
|
|
308
314
|
// to one replacement char, which would mis-classify an obscured field as exposed and drop it from the
|
|
309
|
-
// confidential set). HP-Outer is
|
|
315
|
+
// confidential set). HP-Outer is never surfaced as a protected header nor counted toward the duplicate
|
|
310
316
|
// check (sec. 2.2: it "can appear multiple times"). A valueless HP-Outer (no inner colon) is ignored.
|
|
311
317
|
var ci = h.rawValue.indexOf(":");
|
|
312
318
|
if (ci >= 0) refouter.push({ name: h.rawValue.slice(0, ci).trim().toLowerCase(), value: h.rawValue.slice(ci + 1).trim() });
|
|
@@ -326,17 +332,17 @@ function _extractProtected(headerList) {
|
|
|
326
332
|
}
|
|
327
333
|
|
|
328
334
|
// RFC 9788 sec. 4.3.1: a protected field occurrence is end-to-end confidential (encrypted-only) unless a
|
|
329
|
-
// `refouter` record carries its EXACT name + value
|
|
335
|
+
// `refouter` record carries its EXACT name + value, meaning that value was copied verbatim to the visible outer
|
|
330
336
|
// section. The match is per-OCCURRENCE and MULTISET (each outer record accounts for at most one inside
|
|
331
337
|
// occurrence), so a legally-repeated field with one obscured value and one exposed value (Keywords: secret then
|
|
332
|
-
// Keywords: public, outer exposing only "public") still reports the name confidential
|
|
338
|
+
// Keywords: public, outer exposing only "public") still reports the name confidential, because a last-wins collapse
|
|
333
339
|
// would drop the hidden "secret" and let a caller leak it. A field name is confidential if ANY of its
|
|
334
340
|
// occurrences is unexposed. The comparison is octet-exact (latin1 raw), never lossy-decoded. For the standard
|
|
335
341
|
// path `refouter` is the HP-Outer records; for the legacy path it is the actual outer Header Section (part A).
|
|
336
342
|
function _computeConfidential(entries, refouter) {
|
|
337
343
|
// Index the outer occurrences by (normalized name -> value -> available count) so the multiset comparison is
|
|
338
344
|
// O(N + M), not a per-occurrence rescan of the outer array: matching N identical exposed occurrences would
|
|
339
|
-
// otherwise be N(N+1)/2 comparisons
|
|
345
|
+
// otherwise be N(N+1)/2 comparisons: a DoS an attacker could drive toward the 16 MiB section cap. A two-level
|
|
340
346
|
// Map (never a plain object) so an attacker-chosen name/value like "__proto__" cannot confuse the lookup.
|
|
341
347
|
var byName = new Map();
|
|
342
348
|
refouter.forEach(function (r) {
|
|
@@ -352,18 +358,24 @@ function _computeConfidential(entries, refouter) {
|
|
|
352
358
|
return confidential;
|
|
353
359
|
}
|
|
354
360
|
|
|
355
|
-
// Inspect
|
|
361
|
+
// Inspect every outer From (outerEnt.header returns only the first; an attacker may append a second, forged From
|
|
356
362
|
// that a MUA displays). A mismatch is ANYTHING but exactly one outer From equal to the protected one: a removed
|
|
357
363
|
// outer From, a duplicate, or a differing value all flag tampering of the displayed sender.
|
|
364
|
+
// null when there was nothing to compare, true/false only when a comparison ran. A bare
|
|
365
|
+
// false cannot distinguish "the outer From agrees with the protected one" from "no inner
|
|
366
|
+
// From was protected, so no comparison happened" -- and the second is the common case,
|
|
367
|
+
// which is how `!fromMismatch` came to read as a passed check on a message where nothing
|
|
368
|
+
// was checked at all.
|
|
358
369
|
function _computeFromMismatch(innerFrom, outerEnt) {
|
|
370
|
+
if (innerFrom == null) return null;
|
|
359
371
|
var outerFroms = [];
|
|
360
372
|
outerEnt.headers.forEach(function (h) { if (h.lname === "from") outerFroms.push(h.value.trim()); }); // byte-preserving (latin1)
|
|
361
|
-
return
|
|
373
|
+
return outerFroms.length !== 1 || outerFroms[0] !== innerFrom.trim();
|
|
362
374
|
}
|
|
363
375
|
|
|
364
376
|
// Is this (lowercased) media type a Cryptographic Layer (RFC 9788 sec. 4.10.1)? Conservative: any
|
|
365
377
|
// application/pkcs7-mime (incl. an OpenSSL x-pkcs7-mime, or a compressed-data layer) plus multipart/signed and
|
|
366
|
-
// multipart/encrypted. Mis-classifying part D as a layer only degrades legacy detection to none
|
|
378
|
+
// multipart/encrypted. Mis-classifying part D as a layer only degrades legacy detection to none, the safe direction.
|
|
367
379
|
function _isCryptoLayer(type) {
|
|
368
380
|
// `type` is the already-lowercased media type from mime.parse's _parseStructured; _isPkcs7 guards the
|
|
369
381
|
// null/undefined case internally, so no local `|| ""` fallback is needed (and no dead branch is introduced).
|
|
@@ -378,19 +390,19 @@ function _outerRefouter(outerEnt) {
|
|
|
378
390
|
return r;
|
|
379
391
|
}
|
|
380
392
|
|
|
381
|
-
// RFC 9788 sec. 4.10 backward compatibility: identify + surface a
|
|
393
|
+
// RFC 9788 sec. 4.10 backward compatibility: identify + surface a legacy RFC8551HP message, a Cryptographic
|
|
382
394
|
// Envelope whose payload is a bare message/rfc822 object carrying the real headers, with NO hp= parameter. This
|
|
383
|
-
// is
|
|
384
|
-
// (`enabled`). Identification (sec. 4.10.1) is the four conjunctive conditions C1-C4; every failure
|
|
395
|
+
// is detect-only (sec. 4.10: "An MUA MUST NOT generate an RFC8551HP message ... MAY try to render") and opt-in
|
|
396
|
+
// (`enabled`). Identification (sec. 4.10.1) is the four conjunctive conditions C1-C4; every failure (an
|
|
385
397
|
// unparseable inner message, a non-message/rfc822 payload, a nested Cryptographic Layer, an hp= on part D, or an
|
|
386
398
|
// ambiguous/empty protected set -- fails SOFT to none (never throws), because the message is not PRECISELY
|
|
387
399
|
// identified. On identification (sec. 4.10.2) the inner message's (part D's) Non-Structural fields are surfaced,
|
|
388
400
|
// the mode is inferred from the envelope (clear for signed, cipher for encrypted), and the confidential set is
|
|
389
401
|
// derived from the actual outer section (part A). CRUCIAL: a legacy RFC8551HP message is structurally
|
|
390
402
|
// INDISTINGUISHABLE from an ordinary signed/forwarded message/rfc822 (sec. 4.10.2: the inference is "not based
|
|
391
|
-
// on any strong end-to-end guarantees"), so the inferred set is
|
|
392
|
-
// sets `present:true
|
|
393
|
-
// inference is surfaced
|
|
403
|
+
// on any strong end-to-end guarantees"), so the inferred set is never placed in `protectedHeaders` and never
|
|
404
|
+
// sets `present:true`; those describe only cryptographically-declared hp= protection a caller may trust. The
|
|
405
|
+
// inference is surfaced only under `headerProtection.legacy` (its own { headers, mode, fromMismatch, confidential }
|
|
394
406
|
// object), so a caller keying trust off `present` / `protectedHeaders` can never mistake an opted-in heuristic --
|
|
395
407
|
// possibly a forwarded attachment -- for this message's authenticated headers; consuming it is an explicit choice.
|
|
396
408
|
function _legacyHpSurface(canon, outerEnt, mode, enabled) {
|
|
@@ -403,73 +415,250 @@ function _legacyHpSurface(canon, outerEnt, mode, enabled) {
|
|
|
403
415
|
if (partC.contentType.type !== "message/rfc822") return _noneSurface();
|
|
404
416
|
partD = mime.parse(partC.body, SmimeError, "smime/bad-header-protection");
|
|
405
417
|
} catch (_e) { return _noneSurface(); } // an unparseable legacy candidate is not precisely identified -- fail soft
|
|
406
|
-
// The C2-C4 classification reads only the
|
|
407
|
-
// duplicate Content-Type on part C or part D is ambiguous
|
|
408
|
-
// Layer media type the checks below would miss
|
|
418
|
+
// The C2-C4 classification reads only the first Content-Type of each part (mime.parse surfaces that one). A
|
|
419
|
+
// duplicate Content-Type on part C or part D is ambiguous, since a later field could carry hp= or a
|
|
420
|
+
// Cryptographic Layer media type the checks below would miss. Fail soft there, matching the standard path's
|
|
421
|
+
// duplicate reject.
|
|
409
422
|
if (_contentTypeCount(partC.headers) > 1 || _contentTypeCount(partD.headers) > 1) return _noneSurface();
|
|
410
|
-
// C4: an hp= on part D means the message is
|
|
423
|
+
// C4: an hp= on part D means the message is not legacy (sec. 4.1: a consumer MUST ignore hp outside the payload
|
|
411
424
|
// root). C3: part D must not itself be a Cryptographic Layer (a genuinely nested signed/encrypted message).
|
|
412
425
|
if (mime.hasParam(partD.contentType.value, "hp")) return _noneSurface();
|
|
413
426
|
if (_isCryptoLayer(partD.contentType.type)) return _noneSurface();
|
|
414
427
|
var ex = _extractProtected(partD.headers);
|
|
415
|
-
// Only a duplicate SINGLETON field (RFC 5322 sec. 3.6) is the ambiguous case
|
|
428
|
+
// Only a duplicate SINGLETON field (RFC 5322 sec. 3.6) is the ambiguous case. Part D is an ordinary received
|
|
416
429
|
// message whose trace fields (Received, ...) and other repeatable fields legitimately recur, so a repeatable
|
|
417
|
-
// duplicate must
|
|
430
|
+
// duplicate must not reject the inference (else legacy detection fails on essentially all delivered mail).
|
|
418
431
|
if (ex.dupSingleton) return _noneSurface();
|
|
419
432
|
if (!ex.entries.length) return _noneSurface(); // no Non-Structural fields -- nothing to surface
|
|
420
433
|
var confidential = mode === "cipher" ? _computeConfidential(ex.entries, _outerRefouter(outerEnt)) : [];
|
|
421
|
-
// present stays false + protectedHeaders stays null: the inferred set is surfaced
|
|
434
|
+
// present stays false + protectedHeaders stays null: the inferred set is surfaced only under `legacy` so a
|
|
422
435
|
// caller cannot mistake an opt-in heuristic (indistinguishable from a forwarded message/rfc822) for authenticated
|
|
423
|
-
// headers. `headers` is the ORDERED [{ name, value }] list (every occurrence
|
|
436
|
+
// headers. `headers` is the ORDERED [{ name, value }] list (every occurrence; a real message repeats trace
|
|
424
437
|
// fields); the internal `raw` per occurrence is dropped from the public shape.
|
|
425
438
|
var headers = ex.entries.map(function (e) { return { name: e.name, value: e.value }; });
|
|
426
|
-
|
|
439
|
+
// The OUTER fromMismatch stays unanswered here: a legacy wrap is an inference, never
|
|
440
|
+
// declared protection, so the top-level field must not report a comparison the message
|
|
441
|
+
// did not authenticate. The legacy block carries its own, clearly opt-in.
|
|
442
|
+
return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: null, confidential: [], legacy: { headers: headers, mode: mode, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential } } };
|
|
427
443
|
}
|
|
428
444
|
|
|
429
445
|
// Detect + surface RFC 9788 header protection on a recovered inner entity, FAIL-CLOSED. A payload that does
|
|
430
|
-
// not declare hp surfaces protectedHeaders:null
|
|
446
|
+
// not declare hp surfaces protectedHeaders:null, unless opts.legacyHeaderProtection is set and it is a legacy
|
|
431
447
|
// RFC8551HP message/rfc822 wrap (sec. 4.10), which _legacyHpSurface then surfaces (opt-in, fail-soft). A payload
|
|
432
448
|
// that DECLARES hp is validated: a malformed block, an invalid hp value, or an hp mode that CONTRADICTS the
|
|
433
449
|
// cryptographic envelope (expectedMode "clear" for a signed message, "cipher" for a decrypted one) throws
|
|
434
450
|
// smime/bad-header-protection -- never a silent downgrade. Otherwise the inline Non-Structural fields ARE the
|
|
435
451
|
// authenticated set; a From that differs from the untrusted OUTER From is flagged fromMismatch.
|
|
452
|
+
// The email identities a signer certificate actually asserts. RFC 8550 sec. 4.4.3 makes
|
|
453
|
+
// subjectAltName the carrier: an ASCII local-part rides the rfc822Name CHOICE, and a
|
|
454
|
+
// certificate MAY assert several addresses, so this collects every one rather than the
|
|
455
|
+
// first. A non-ASCII local-part rides otherName / SmtpUTF8Mailbox (RFC 8398 sec. 3); those
|
|
456
|
+
// are counted as PRESENT but not returned as comparable strings, so a certificate that
|
|
457
|
+
// carries only internationalized identities reports "there is an identity here that this
|
|
458
|
+
// comparison cannot read" instead of the indistinguishable "no identities".
|
|
459
|
+
var SAN_OID = oid.byName("subjectAltName");
|
|
460
|
+
var SMTP_UTF8_MAILBOX = oid.byName("smtpUtf8Mailbox");
|
|
461
|
+
var EMAIL_ADDRESS_ATTR = oid.byName("emailAddress");
|
|
462
|
+
var _extNs = pkix.makeNS("smime", SmimeError, oid);
|
|
463
|
+
var _extDecoders = pkix.certExtensionDecoders(_extNs);
|
|
464
|
+
var _extCtx = { E: function (c, m, cause) { return new SmimeError(c, m, cause); }, oid: oid };
|
|
465
|
+
function _signerEmails(certDer) {
|
|
466
|
+
var out = { addresses: [], unreadable: 0 };
|
|
467
|
+
var parsed = null;
|
|
468
|
+
// A failure here is COUNTED, never absorbed: it raises `unreadable`, which drives the
|
|
469
|
+
// sender verdict to null (undecidable) rather than to a clean "no identities". Returning
|
|
470
|
+
// early on the error would make an unreadable certificate indistinguishable from one
|
|
471
|
+
// that genuinely asserts no address, and the caller would read "nothing to match" as a
|
|
472
|
+
// finished comparison instead of one that never happened.
|
|
473
|
+
try { parsed = schemaX509.parse(certDer); }
|
|
474
|
+
catch (parseFailed) { out.unreadable++; out.reason = parseFailed.code || "unparseable"; }
|
|
475
|
+
if (parsed === null) return out;
|
|
476
|
+
(parsed.extensions || []).forEach(function (e) {
|
|
477
|
+
if (e.oid !== SAN_OID) return;
|
|
478
|
+
var dec = null;
|
|
479
|
+
try { dec = _extDecoders.byOid[SAN_OID](e.value, _extCtx); }
|
|
480
|
+
catch (sanFailed) { out.unreadable++; out.reason = sanFailed.code || "bad-san"; }
|
|
481
|
+
if (dec === null) return;
|
|
482
|
+
(dec.names || []).forEach(function (n) {
|
|
483
|
+
if (n.tagNumber === 1) { out.addresses.push(String(n.value)); return; } // rfc822Name
|
|
484
|
+
// An otherName only bears on the EMAIL question when it is an SmtpUTF8Mailbox
|
|
485
|
+
// (RFC 8398 sec. 3). A certificate may legitimately carry unrelated otherNames -- a
|
|
486
|
+
// Microsoft UPN is routine in enterprise issuance -- and counting those as an
|
|
487
|
+
// unreadable identity would drive a certificate whose rfc822Name matches perfectly
|
|
488
|
+
// to an undecidable verdict, making expectedSender unusable for that whole class.
|
|
489
|
+
if (n.tagNumber !== 0) return;
|
|
490
|
+
// The decoder builds otherName as value: { typeId, valueBytes } (schema-pkix's
|
|
491
|
+
// GeneralName rule), so the type-id lives one level in. Reading it off the node
|
|
492
|
+
// itself silently matched nothing, which made every SmtpUTF8Mailbox invisible and
|
|
493
|
+
// let a legacy subject value speak for a certificate whose SAN was authoritative.
|
|
494
|
+
// An otherName whose type-id cannot be read at all counts as unreadable: unknown
|
|
495
|
+
// shape is a reason to withhold a verdict, not to ignore the entry.
|
|
496
|
+
var typeId = n.value && typeof n.value === "object" ? n.value.typeId : undefined;
|
|
497
|
+
if (typeId === undefined) { out.unreadable++; return; }
|
|
498
|
+
if (String(typeId) === SMTP_UTF8_MAILBOX) out.unreadable++;
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
// RFC 8550 sec. 3: the address SHOULD live in subjectAltName, but a receiving agent
|
|
502
|
+
// "MUST recognize email addresses in the distinguished name field in the PKCS #9
|
|
503
|
+
// emailAddress attribute". Reading only the extension would leave every legacy
|
|
504
|
+
// certificate of that shape permanently undecidable, so the subject DN is consulted too.
|
|
505
|
+
// It is a fallback, not a peer: a SAN address is the conformant carrier.
|
|
506
|
+
// A fallback, not a peer: consulted ONLY when subjectAltName yielded no address. RFC 8550
|
|
507
|
+
// sec. 3 says the address SHOULD be in the extension and SHOULD NOT be in the subject, so
|
|
508
|
+
// where both exist the extension is authoritative. Merging them would let a stale or
|
|
509
|
+
// conflicting subject value satisfy expectedSender while the SAN names a different
|
|
510
|
+
// mailbox -- a positive binding drawn from the weaker of two disagreeing identities.
|
|
511
|
+
// "No SAN email identity" means none of EITHER kind. An SmtpUTF8Mailbox raises
|
|
512
|
+
// `unreadable` without adding a comparable address, so testing `addresses` alone would
|
|
513
|
+
// fall through to the subject for exactly the certificates whose SAN does carry an
|
|
514
|
+
// authoritative identity -- letting a stale subject value override an internationalized
|
|
515
|
+
// SAN this toolkit simply cannot read.
|
|
516
|
+
var sanCarriedIdentity = out.addresses.length > 0 || out.unreadable > 0;
|
|
517
|
+
var rdns = !sanCarriedIdentity && parsed.subject ? parsed.subject.rdns : null;
|
|
518
|
+
(rdns || []).forEach(function (rdn) {
|
|
519
|
+
(rdn || []).forEach(function (attr) {
|
|
520
|
+
if (!attr || attr.type !== EMAIL_ADDRESS_ATTR || typeof attr.value !== "string") return;
|
|
521
|
+
if (out.addresses.indexOf(attr.value) === -1) out.addresses.push(attr.value);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// What the signer's certificate says about who sent this, and whether anyone asked.
|
|
528
|
+
//
|
|
529
|
+
// `match` is THREE-valued and never collapses: true only when an identity in the signer's
|
|
530
|
+
// certificate matches under RFC 5280 sec. 7.5; false when every identity was comparable and
|
|
531
|
+
// none matched; null when the question was not asked, or was asked and could not be decided
|
|
532
|
+
// (no readable identity, or an address needing an IDNA transform this toolkit will not do).
|
|
533
|
+
// A boolean here would make "nobody checked" indistinguishable from "checked and agreed",
|
|
534
|
+
// which is the defect this field exists to remove -- so a caller enforcing sender binding
|
|
535
|
+
// tests `match === true`, and null fails that test exactly as false does.
|
|
536
|
+
//
|
|
537
|
+
// `expectedSender` is the authoritative input. The outer From is attacker-controlled on an
|
|
538
|
+
// unprotected message, so comparing against it is reported as advisory (`source: "from"`)
|
|
539
|
+
// and never presented as a verified binding.
|
|
540
|
+
function _senderSurface(signers, outerEnt, expectedSender) {
|
|
541
|
+
var expected = null, source = null;
|
|
542
|
+
// Entry-tier: a mistyped option is a caller bug and throws, never a coercion. String()
|
|
543
|
+
// would accept any object with a toString, so `{ toString: () => "alice@example.com" }`
|
|
544
|
+
// would drive a sender binding the caller never actually stated -- the authoritative
|
|
545
|
+
// input to this comparison must be the string it claims to be.
|
|
546
|
+
if (expectedSender != null) {
|
|
547
|
+
if (typeof expectedSender !== "string") {
|
|
548
|
+
throw _err("smime/bad-input", "expectedSender must be a string email address, got " + typeof expectedSender);
|
|
549
|
+
}
|
|
550
|
+
expected = expectedSender; source = "expectedSender";
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
var froms = [];
|
|
554
|
+
outerEnt.headers.forEach(function (h) { if (h.lname === "from") froms.push(h.value.trim()); });
|
|
555
|
+
// Exactly one From, or there is no unambiguous address to compare against.
|
|
556
|
+
if (froms.length === 1) { expected = _addrSpec(froms[0]); source = expected === null ? null : "from"; }
|
|
557
|
+
}
|
|
558
|
+
var identities = [], undecidable = false, sawIdentity = false;
|
|
559
|
+
(signers || []).forEach(function (s) {
|
|
560
|
+
// Only a signer whose signature actually verified may contribute an identity.
|
|
561
|
+
// cms.verify reports every matched signer, including ones that failed, as ok:false --
|
|
562
|
+
// harvesting their subjectAltName would let a tampered message report valid:false
|
|
563
|
+
// alongside sender.match:true, and this verb's own guidance is to enforce
|
|
564
|
+
// sender.match === true. A failed signer makes the answer undecidable, never a clean
|
|
565
|
+
// no-match.
|
|
566
|
+
// Only a signer whose signature actually verified may contribute an identity.
|
|
567
|
+
// cms.verify reports every matched signer, including ones that failed, as ok:false --
|
|
568
|
+
// harvesting their subjectAltName would let a tampered message report valid:false
|
|
569
|
+
// alongside sender.match:true, and this verb's own guidance is to enforce
|
|
570
|
+
// sender.match === true. A failed signer makes the answer undecidable, never a clean
|
|
571
|
+
// no-match.
|
|
572
|
+
// A verified signature is not enough: anyone can self-sign a certificate carrying the
|
|
573
|
+
// victim's address, and cms.verify reports that signer ok:true / trusted:false. Reading
|
|
574
|
+
// its subjectAltName would let an attacker-minted certificate produce a positive
|
|
575
|
+
// binding, which is the same defect one layer up from the ok:false case. Name your
|
|
576
|
+
// roots in opts.trustAnchors; with none supplied nothing is trusted, so the binding
|
|
577
|
+
// question stays unanswered rather than answered from an unvetted certificate.
|
|
578
|
+
if (!s || s.ok !== true || s.trusted !== true) { undecidable = true; return; }
|
|
579
|
+
var e = s.cert ? _signerEmails(s.cert) : null;
|
|
580
|
+
if (e === null) { undecidable = true; return; } // no certificate to read at all
|
|
581
|
+
if (e.unreadable > 0) { undecidable = true; sawIdentity = true; }
|
|
582
|
+
e.addresses.forEach(function (a) { sawIdentity = true; if (identities.indexOf(a) === -1) identities.push(a); });
|
|
583
|
+
});
|
|
584
|
+
if (expected === null) return { checked: false, expected: null, source: null, identities: identities, match: null };
|
|
585
|
+
if (!sawIdentity) return { checked: true, expected: expected, source: source, identities: identities, match: null };
|
|
586
|
+
// No early return on the first match. A match is only the answer once nothing else has
|
|
587
|
+
// made the result undecidable: a failed co-signer, an unreadable certificate or an
|
|
588
|
+
// address this toolkit cannot canonicalize all mean the verdict is unsettled, and
|
|
589
|
+
// reporting true from inside the loop would let that be read as a clean binding.
|
|
590
|
+
// Undecidable always wins over a positive.
|
|
591
|
+
var sawMatch = false, sawNotComparable = false;
|
|
592
|
+
for (var i = 0; i < identities.length; i++) {
|
|
593
|
+
var v = guard.name.emailEqual(identities[i], expected);
|
|
594
|
+
if (v === "match") sawMatch = true;
|
|
595
|
+
else if (v === "not-comparable") sawNotComparable = true;
|
|
596
|
+
}
|
|
597
|
+
// The question is existential: does a verified signer's certificate assert this address?
|
|
598
|
+
// An exact match answers it, and a second identity that could not be compared does not
|
|
599
|
+
// un-answer it -- a certificate legitimately carrying several addresses would otherwise
|
|
600
|
+
// fail the binding because of one it was never asked about. Undecidability only decides
|
|
601
|
+
// the verdict when nothing matched.
|
|
602
|
+
var verdict;
|
|
603
|
+
if (sawMatch) verdict = true;
|
|
604
|
+
else if (undecidable || sawNotComparable) verdict = null;
|
|
605
|
+
else verdict = false;
|
|
606
|
+
return { checked: true, expected: expected, source: source, identities: identities, match: verdict };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// The addr-spec inside an RFC 5322 From. A display-name form ("Bob <bob@x>") yields the
|
|
610
|
+
// angle-addr; a bare address yields itself. Anything else (a group, a second address, an
|
|
611
|
+
// unclosed angle bracket) returns null so the caller reports "not compared" rather than
|
|
612
|
+
// comparing against a fragment.
|
|
613
|
+
function _addrSpec(v) {
|
|
614
|
+
var lt = v.indexOf("<"), gt = v.lastIndexOf(">");
|
|
615
|
+
if (lt === -1 && gt === -1) return v.indexOf(",") === -1 ? v.trim() : null;
|
|
616
|
+
if (lt === -1 || gt < lt) return null;
|
|
617
|
+
// Nothing but whitespace may follow the angle address. `Alice <alice@x>, mallory@y` is two
|
|
618
|
+
// mailboxes, and returning the first would report a single compared sender over a field
|
|
619
|
+
// that named more than one -- the ambiguity this function exists to refuse.
|
|
620
|
+
if (v.slice(gt + 1).trim().length) return null;
|
|
621
|
+
var inner = v.slice(lt + 1, gt).trim();
|
|
622
|
+
return inner.length && inner.indexOf(",") === -1 && inner.indexOf("<") === -1 ? inner : null;
|
|
623
|
+
}
|
|
624
|
+
|
|
436
625
|
function _hpSurface(content, outerEnt, expectedMode, authenticated, legacyEnabled) {
|
|
437
|
-
// Header protection is an AUTHENTICATED property: surface the inner headers as protected
|
|
626
|
+
// Header protection is an AUTHENTICATED property: surface the inner headers as protected only when the
|
|
438
627
|
// cryptographic verdict succeeded (a valid signature, or an authenticated-encryption decrypt). An invalid
|
|
439
|
-
// signature or an unauthenticated (AES-CBC, no integrity) decrypt yields attacker-influenced bytes
|
|
628
|
+
// signature or an unauthenticated (AES-CBC, no integrity) decrypt yields attacker-influenced bytes, and those
|
|
440
629
|
// are never marked or exposed as protected (a caller must not treat protectedHeaders as a trust signal
|
|
441
630
|
// unless integrity held).
|
|
442
631
|
if (!authenticated) return _noneSurface();
|
|
443
|
-
// Detect + parse the CANONICAL entity
|
|
632
|
+
// Detect + parse the CANONICAL entity: the exact bytes the signature covers. A transport may rewrite a
|
|
444
633
|
// CRLF fold OR the header/body separator to bare CR/LF; canonicalize repairs them (so verification still
|
|
445
|
-
// succeeds), and
|
|
634
|
+
// succeeds), and both the hp detection and the parse must run on the repaired bytes, or they diverge from
|
|
446
635
|
// the signed content -- stripping the signal, or false-rejecting a valid message as an unparseable block.
|
|
447
636
|
// The returned `content` stays the raw recovered bytes; only this HP inspection uses the canonical copy.
|
|
448
637
|
var canon = mime.canonicalizeText(content);
|
|
449
638
|
// A payload that does not declare hp is either non-protected (protectedHeaders:null) or a legacy RFC8551HP
|
|
450
639
|
// message/rfc822 wrap the caller opted into detecting (sec. 4.10). Both are mutually exclusive with the
|
|
451
|
-
// standard path below by construction: it runs
|
|
640
|
+
// standard path below by construction: it runs only when a real hp= is present on the payload root.
|
|
452
641
|
if (!_declaresHp(canon)) return _legacyHpSurface(canon, outerEnt, expectedMode, legacyEnabled);
|
|
453
642
|
var inner = mime.parse(canon, SmimeError, "smime/bad-header-protection"); // a malformed HP block fails closed
|
|
454
|
-
// A duplicate Content-Type makes the hp declaration ambiguous (a parser reads params from the
|
|
455
|
-
// but an hp= may sit on a later one)
|
|
643
|
+
// A duplicate Content-Type makes the hp declaration ambiguous (a parser reads params from the first field,
|
|
644
|
+
// but an hp= may sit on a later one), so the wrap is malformed and fails closed, never a silent downgrade.
|
|
456
645
|
var ctCount = _contentTypeCount(inner.headers);
|
|
457
646
|
if (ctCount > 1) throw _err("smime/bad-header-protection", "a header-protected payload must carry exactly one Content-Type field (found " + ctCount + ")");
|
|
458
|
-
// A duplicate hp attribute is ambiguous (mime.parse keeps the
|
|
459
|
-
// first would see a different mode)
|
|
647
|
+
// A duplicate hp attribute is ambiguous (mime.parse keeps the last value, but a recipient honoring the
|
|
648
|
+
// first would see a different mode); fail closed, like the duplicate Content-Type. Counted by attribute
|
|
460
649
|
// name (bare OR valued) so a bare+valued pair ("hp; hp=x") is caught too, and quote-/comment-aware (an hp=
|
|
461
650
|
// inside a quoted value or comment is not the hp parameter, so a message we emit is never self-rejected).
|
|
462
651
|
if (mime.paramNameCount(inner.contentType.value, "hp") > 1) throw _err("smime/bad-header-protection", "a header-protected payload declares the hp parameter more than once");
|
|
463
|
-
// _declaresHp composes the
|
|
652
|
+
// _declaresHp composes the same comment/quoted-string-aware tokenizer (mime.paramCount) mime.parse uses, so
|
|
464
653
|
// reaching here means the single Content-Type carries a real hp parameter (a duplicate is rejected above) --
|
|
465
654
|
// hp is defined. A malformed value fails CLOSED at the mode checks below, never a silent downgrade.
|
|
466
|
-
// The hp keyword values clear / cipher are an enumerated Content-Type parameter
|
|
655
|
+
// The hp keyword values clear / cipher are an enumerated Content-Type parameter. Per RFC 2045 sec. 5.1 a
|
|
467
656
|
// value of this kind is compared case-insensitively for its intended use, so a peer that emits hp="Clear"
|
|
468
657
|
// or hp=CIPHER is still recognized. (We always EMIT lowercase; we ACCEPT any case. The value is inside the
|
|
469
|
-
// signed/encrypted payload, so normalizing it enables no attack
|
|
658
|
+
// signed/encrypted payload, so normalizing it enables no attack; the mode-contradiction check still runs.)
|
|
470
659
|
// _declaresHp detected the hp attribute (hasParam); a bare "hp" with no value is a malformed HP declaration
|
|
471
|
-
//
|
|
472
|
-
//
|
|
660
|
+
// and fails closed (mime.parse skips a valueless parameter, so params.hp is undefined here only for that
|
|
661
|
+
// malformed case).
|
|
473
662
|
var raw = inner.contentType.params.hp;
|
|
474
663
|
if (raw === undefined) throw _err("smime/bad-header-protection", "a header-protected payload declares a bare hp parameter with no value (expected hp=\"clear\" or hp=\"cipher\")");
|
|
475
664
|
var hp = raw.toLowerCase();
|
|
@@ -477,7 +666,7 @@ function _hpSurface(content, outerEnt, expectedMode, authenticated, legacyEnable
|
|
|
477
666
|
if (hp !== expectedMode) throw _err("smime/bad-header-protection", "the payload hp=" + JSON.stringify(hp) + " contradicts the cryptographic envelope (a " + (expectedMode === "cipher" ? "decrypted" : "signed") + " message requires hp=" + JSON.stringify(expectedMode) + ")");
|
|
478
667
|
var ex = _extractProtected(inner.headers);
|
|
479
668
|
// A duplicate protected field is ambiguous (the last-wins overwrite hides an earlier value a different
|
|
480
|
-
// parser might select)
|
|
669
|
+
// parser might select), so the payload fails closed.
|
|
481
670
|
if (ex.dup) throw _err("smime/bad-header-protection", "a header-protected payload has a duplicate protected header field " + JSON.stringify(ex.dup));
|
|
482
671
|
// RFC 9788 sec. 4.3.1: for an ENCRYPTED payload (hp="cipher") the confidential set is the fields not copied
|
|
483
672
|
// verbatim to the outer section via an HP-Outer record. Signed-only (clear) payloads carry no HP-Outer.
|
|
@@ -485,7 +674,7 @@ function _hpSurface(content, outerEnt, expectedMode, authenticated, legacyEnable
|
|
|
485
674
|
return { protectedHeaders: ex.protectedHeaders, headerProtection: { present: true, mode: hp, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential, legacy: null } };
|
|
486
675
|
}
|
|
487
676
|
|
|
488
|
-
// Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic
|
|
677
|
+
// Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic and forwards any signer).
|
|
489
678
|
function _cmsSignOpts(opts, detached) {
|
|
490
679
|
var o = { detached: detached };
|
|
491
680
|
if (opts.signingTime !== undefined) o.signingTime = opts.signingTime;
|
|
@@ -536,7 +725,7 @@ function _base64Body(der) {
|
|
|
536
725
|
* @spec RFC 8551, RFC 5652
|
|
537
726
|
* @related pki.smime.verify, pki.cms.sign
|
|
538
727
|
*
|
|
539
|
-
* Assemble a signed S/MIME message (RFC 8551). `content` is the payload
|
|
728
|
+
* Assemble a signed S/MIME message (RFC 8551). `content` is the payload: a raw body wrapped as a
|
|
540
729
|
* `text/plain` entity by default, or the caller's own complete MIME entity when `opts.entity` is set;
|
|
541
730
|
* `signers` is the `pki.cms.sign` signer array (any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA
|
|
542
731
|
* signer -- the S/MIME layer is algorithm-agnostic). Two forms via `opts.form`:
|
|
@@ -545,17 +734,17 @@ function _base64Body(der) {
|
|
|
545
734
|
* canonical form in the second, with `protocol="application/pkcs7-signature"` + a matching `micalg`.
|
|
546
735
|
* - `"pkcs7-mime"` (opaque): one `application/pkcs7-mime; smime-type=signed-data` entity whose base64
|
|
547
736
|
* body is an ATTACHED CMS SignedData over the canonical entity.
|
|
548
|
-
* The signed bytes are the entity's RFC 8551 sec. 3.1.1 canonical form (CRLF line endings); the
|
|
737
|
+
* The signed bytes are the entity's RFC 8551 sec. 3.1.1 canonical form (CRLF line endings); the same
|
|
549
738
|
* canonicalizer runs on verify. With `opts.protectHeaders`, the message is header-protected (RFC 9788): the
|
|
550
|
-
* caller's `opts.headers` are inlined on the Cryptographic Payload root
|
|
551
|
-
*
|
|
739
|
+
* caller's `opts.headers` are inlined on the Cryptographic Payload root (its Content-Type gains `hp="clear"`),
|
|
740
|
+
* so the signature covers them, and copied to the outer display headers; `verify` surfaces the
|
|
552
741
|
* authenticated inner set. Returns the assembled message bytes. Fail-closed with `SmimeError`.
|
|
553
742
|
*
|
|
554
743
|
* @opts form `"multipart"` (default) or `"pkcs7-mime"`.
|
|
555
744
|
* @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
|
|
556
745
|
* @opts contentType the wrapped entity's Content-Type (default `text/plain; charset=utf-8`).
|
|
557
746
|
* @opts signingTime a `Date` for the CMS signing-time attribute, or false to omit it.
|
|
558
|
-
* @opts protectHeaders enable RFC 9788 header protection (`hp="clear"`)
|
|
747
|
+
* @opts protectHeaders enable RFC 9788 header protection (`hp="clear"`), inlining `opts.headers` on the signed payload + the outer display headers.
|
|
559
748
|
* @opts headers the Non-Structural fields to protect + display: an object `{ Name: value }` or an array `[{ name, value }]` (Subject / From / To / Date / ...); used with `protectHeaders`.
|
|
560
749
|
* @opts hcp the Header Confidentiality Policy applied to the OUTER display copies: `"hcp_baseline"` (default) or `"hcp_no_confidentiality"`. A signed message's payload is not encrypted, so this governs presentation, not secrecy; used with `protectHeaders`.
|
|
561
750
|
* @opts sid forwarded to cms.sign: the SignerIdentifier form, `"issuerAndSerial"` (default) or `"subjectKeyIdentifier"`.
|
|
@@ -609,7 +798,7 @@ function _capped(msg) {
|
|
|
609
798
|
|
|
610
799
|
/**
|
|
611
800
|
* @primitive pki.smime.verify
|
|
612
|
-
* @signature pki.smime.verify(message, opts?) -> Promise<{ valid, trusted, signers, form, content, micalg, protectedHeaders, headerProtection }>
|
|
801
|
+
* @signature pki.smime.verify(message, opts?) -> Promise<{ valid, trusted, signers, form, content, micalg, sender, protectedHeaders, headerProtection }>
|
|
613
802
|
* @since 0.2.25
|
|
614
803
|
* @status stable
|
|
615
804
|
* @spec RFC 8551, RFC 5652, RFC 9788
|
|
@@ -617,7 +806,7 @@ function _capped(msg) {
|
|
|
617
806
|
*
|
|
618
807
|
* Unwrap and verify a signed S/MIME message (RFC 8551), both `multipart/signed` and
|
|
619
808
|
* `application/pkcs7-mime; smime-type=signed-data`. For `multipart/signed` the detached CMS signature
|
|
620
|
-
* is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the
|
|
809
|
+
* is recomputed over the first part's RFC 8551 sec. 3.1.1 canonical form (the same canonicalizer the
|
|
621
810
|
* signer used); for `application/pkcs7-mime` the base64 body is the attached CMS SignedData. Returns
|
|
622
811
|
* `pki.cms.verify`'s `{ valid, trusted, signers }` verdict PLUS `form`, the recovered `content` (the
|
|
623
812
|
* signed MIME entity bytes), and the `micalg`.
|
|
@@ -630,31 +819,54 @@ function _capped(msg) {
|
|
|
630
819
|
* chains to its root perfectly well and is still the wrong key to have signed a message; and the anchor's
|
|
631
820
|
* own trust metadata must permit that purpose, because a root distributed with NSS trust bits can be
|
|
632
821
|
* marked untrusted for email while remaining a good TLS root. Override either with `opts.requiredEku`
|
|
633
|
-
* and `opts.checkPurpose`. Supply no anchors and `trusted` is `false
|
|
822
|
+
* and `opts.checkPurpose`. Supply no anchors and `trusted` is `false`, since there was nothing to chain to.
|
|
634
823
|
* A `micalg`
|
|
635
824
|
* that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
|
|
636
825
|
* If the message is header-protected (RFC 9788), `protectedHeaders` is the AUTHENTICATED inner header set (a
|
|
637
826
|
* tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`
|
|
638
|
-
* -- `present` is `true` only for cryptographically-DECLARED (`hp=`) protection you may trust; `fromMismatch`
|
|
639
|
-
*
|
|
827
|
+
* -- `present` is `true` only for cryptographically-DECLARED (`hp=`) protection you may trust; `fromMismatch` is
|
|
828
|
+
* `true` when the outer From differs from the protected one, `false` when they agree, and `null` when there was
|
|
829
|
+
* no protected From to compare against, which is every message without header protection. Test it against
|
|
830
|
+
* `false` rather than for falsiness: `!fromMismatch` treats the unanswered case as a passed check, and that is
|
|
831
|
+
* the common case. For a binding that does not depend on the composer having protected headers, use `sender`
|
|
832
|
+
* with `opts.expectedSender`; `confidential` lists the protected fields the composer kept
|
|
640
833
|
* end-to-end confidential (per the authenticated HP-Outer records, RFC 9788 sec. 4.3; only for an encrypted
|
|
641
834
|
* `hp="cipher"` payload). A non-protected message reports `protectedHeaders: null`, `present: false`. A payload
|
|
642
835
|
* whose declared `hp` is malformed, invalid, or contradicts the envelope fails closed (`smime/bad-header-protection`),
|
|
643
836
|
* never a silent downgrade. `legacy` is `null` unless `opts.legacyHeaderProtection` detected a legacy RFC 8551
|
|
644
837
|
* `message/rfc822` wrap (see that option), in which case it is its own `{ headers, mode, fromMismatch, confidential }`
|
|
645
838
|
* object (`headers` an ordered `[{ name, value }]` array that retains legally-repeated fields like `Received`) -- a
|
|
646
|
-
* legacy inference is
|
|
839
|
+
* legacy inference is never placed in `protectedHeaders` and never sets `present: true`, because it is
|
|
647
840
|
* indistinguishable from an ordinary forwarded `message/rfc822`, so a caller keying trust off `present` /
|
|
648
841
|
* `protectedHeaders` cannot mistake the opt-in heuristic for authenticated headers.
|
|
649
842
|
*
|
|
843
|
+
* `sender` answers who the message is from, which `valid` does not: a signature proves a key signed,
|
|
844
|
+
* never that the message came from the mailbox the reader sees. Name the address you expect in
|
|
845
|
+
* `opts.expectedSender` and `sender.match` is `true` only when the signer certificate asserts it,
|
|
846
|
+
* compared under RFC 5280 sec. 7.5: the local-part exactly, the host-part case-insensitively. The
|
|
847
|
+
* address is read from the `subjectAltName` `rfc822Name` entries (RFC 8550 sec. 4.4.3) and, where the
|
|
848
|
+
* extension carries none, from the subject DN's PKCS #9 `emailAddress` attribute, which RFC 8550 sec. 3
|
|
849
|
+
* requires a receiving agent to recognise. Where both are present the extension wins. Only a signer
|
|
850
|
+
* whose signature verified contributes an identity, so a tampered message cannot report a binding. `match` is THREE-valued and a caller enforcing sender binding tests
|
|
851
|
+
* `match === true` -- `false` means every identity was comparable and none matched, and `null` means the
|
|
852
|
+
* question was not answered (no `expectedSender` and no single outer `From`, a signer certificate
|
|
853
|
+
* asserting no readable email identity, or an internationalized address needing an IDNA transform this
|
|
854
|
+
* toolkit does not perform). `null` is not a pass. `sender.identities` lists what the certificate
|
|
855
|
+
* actually asserts, `sender.checked` whether a comparison ran, and `sender.source` which input it ran
|
|
856
|
+
* against -- `"from"` is ADVISORY, because on a message without header protection the outer From is
|
|
857
|
+
* attacker-controlled.
|
|
858
|
+
*
|
|
650
859
|
* @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
|
|
860
|
+
* @opts expectedSender the email address the signer's certificate must assert for `sender.match` to be
|
|
861
|
+
* `true`. Without it the outer `From` is used when there is exactly one, and reported
|
|
862
|
+
* as `source: "from"` -- advisory, never a verified binding.
|
|
651
863
|
* @opts trustAnchors the roots you accept, forwarded to `cms.verify`; supplying them is what makes
|
|
652
864
|
* `trusted` answerable. Certificate DER or anchor tuples.
|
|
653
865
|
* @opts time the instant the signer's chain is judged at (default now). Only read with `trustAnchors`.
|
|
654
866
|
* @opts requiredEku key purposes the SIGNER certificate must carry. Defaults to `["emailProtection"]`.
|
|
655
867
|
* @opts checkPurpose the purpose the ANCHOR's own trust metadata must permit. Defaults to `"emailProtection"`.
|
|
656
868
|
* @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
|
|
657
|
-
* @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): a Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }
|
|
869
|
+
* @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): a Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner message's headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array (retaining legally-repeated fields such as `Received`) and the mode is inferred from the envelope (`clear` here). They are not placed under `protectedHeaders`, and `present` stays `false`. Consuming `headerProtection.legacy.headers` is an explicit choice: a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, so this is a heuristic (RFC 9788 sec. 4.10.2: "not based on any strong end-to-end guarantees"); cross-check `legacy.fromMismatch`. Anything not precisely identified (a nested crypto layer, an `hp=` on the inner message, a non-`message/rfc822` payload, a duplicate of a singleton field, or a duplicate Content-Type) reports `legacy: null`. Off by default. The signed-and-encrypted form (RFC 9788 Appendix C.3.17) is a documented gap (`legacy: null` at `decrypt`; surfaces as `clear` only via the caller's re-`verify` step), because the non-recursive layered API exposes no single seam holding both the inner signature verdict and the outer header section.
|
|
658
870
|
* @example
|
|
659
871
|
* var pair = await pki.key.generate("Ed25519");
|
|
660
872
|
* var key = await pki.key.export(pair.privateKey);
|
|
@@ -698,7 +910,7 @@ async function verify(message, opts) {
|
|
|
698
910
|
var inner;
|
|
699
911
|
try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
|
|
700
912
|
catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
|
|
701
|
-
return Object.assign({ valid: res.valid, trusted: res.trusted, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
|
|
913
|
+
return Object.assign({ valid: res.valid, trusted: res.trusted, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null, sender: _senderSurface(res.signers, ent, opts.expectedSender) }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
|
|
702
914
|
}
|
|
703
915
|
if (ct.type === "multipart/signed") {
|
|
704
916
|
if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
|
|
@@ -725,7 +937,7 @@ async function verify(message, opts) {
|
|
|
725
937
|
if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
|
|
726
938
|
throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
|
|
727
939
|
}
|
|
728
|
-
return Object.assign({ valid: res2.valid, trusted: res2.trusted, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
|
|
940
|
+
return Object.assign({ valid: res2.valid, trusted: res2.trusted, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg, sender: _senderSurface(res2.signers, ent, opts.expectedSender) }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
|
|
729
941
|
}
|
|
730
942
|
throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
|
|
731
943
|
}
|
|
@@ -774,13 +986,13 @@ function _cmsEncryptOpts(opts) {
|
|
|
774
986
|
* Envelope a MIME entity as an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4). `content` is the
|
|
775
987
|
* payload -- a raw body wrapped as a `text/plain` entity by default, or the caller's own complete MIME
|
|
776
988
|
* entity when `opts.entity` is set; `recipients` is the `pki.cms.encrypt` recipient array (any RSA-OAEP
|
|
777
|
-
* ktri / EC or X25519/X448 kari / ML-KEM ori-KEM / password pwri / kek kekri
|
|
989
|
+
* ktri / EC or X25519/X448 kari / ML-KEM ori-KEM / password pwri / kek kekri: the S/MIME layer is
|
|
778
990
|
* recipient-agnostic; a single descriptor is accepted and normalized to a one-element array). Enveloping
|
|
779
|
-
* has
|
|
991
|
+
* has a single form, opaque `application/pkcs7-mime` with the whole entity base64-encoded. The `smime-type`
|
|
780
992
|
* is derived from the produced CMS: AES-GCM (the default) yields an AuthEnvelopedData with
|
|
781
993
|
* `smime-type=authEnveloped-data` (confidentiality AND integrity); a CBC choice yields an EnvelopedData
|
|
782
|
-
* with `smime-type=enveloped-data` (confidentiality only
|
|
783
|
-
* `opts.protectHeaders`, the message is header-protected (RFC 9788): the
|
|
994
|
+
* with `smime-type=enveloped-data` (confidentiality only, no integrity, RFC 8551 sec. 3.3). With
|
|
995
|
+
* `opts.protectHeaders`, the message is header-protected (RFC 9788): the real `opts.headers` are inlined
|
|
784
996
|
* inside the ciphertext (the payload Content-Type gains `hp="cipher"`), and only the Header-Confidentiality-
|
|
785
997
|
* Policy-processed display copies appear outside -- the default `hcp_baseline` obscures Subject to `[...]`
|
|
786
998
|
* and removes Comments / Keywords, so those values live only in the ciphertext; `decrypt` recovers the real
|
|
@@ -788,9 +1000,9 @@ function _cmsEncryptOpts(opts) {
|
|
|
788
1000
|
*
|
|
789
1001
|
* @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
|
|
790
1002
|
* @opts contentType the wrapped entity's MIME Content-Type (default `text/plain; charset=utf-8`).
|
|
791
|
-
* @opts protectHeaders enable RFC 9788 header protection (`hp="cipher"`)
|
|
1003
|
+
* @opts protectHeaders enable RFC 9788 header protection (`hp="cipher"`): inline `opts.headers` inside the ciphertext, emit HCP-processed outer copies, and embed the authenticated HP-Outer records (RFC 9788 sec. 2.2) documenting which fields were left visible outside.
|
|
792
1004
|
* @opts headers the Non-Structural fields to protect (object `{ Name: value }` or array `[{ name, value }]`); the real values, hidden by the HCP.
|
|
793
|
-
* @opts hcp the Header Confidentiality Policy: `"hcp_baseline"` (default
|
|
1005
|
+
* @opts hcp the Header Confidentiality Policy: `"hcp_baseline"` (the default, obscuring Subject and removing Comments/Keywords) or `"hcp_no_confidentiality"` (leave all outer values visible). Per RFC 9788 sec. 3.2.1 / sec. 11.4, `hcp_baseline` deliberately does not strip `Bcc` (removing it can break deliverability to a Bcc'd recipient); to keep a blind recipient out of the plaintext outer headers, omit `Bcc` from `opts.headers`.
|
|
794
1006
|
* @opts contentEncryptionAlgorithm forwarded to cms.encrypt: `"aes-256-gcm"` (default) / `"aes-128-gcm"` / `"aes-256-cbc"` / `"aes-128-cbc"`.
|
|
795
1007
|
* @opts oaepHash forwarded: the RSAES-OAEP hash for ktri recipients.
|
|
796
1008
|
* @opts keyIdentifier forwarded: `"issuerAndSerial"` (default) or `"subjectKeyIdentifier"`.
|
|
@@ -835,26 +1047,26 @@ async function encrypt(content, recipients, opts) {
|
|
|
835
1047
|
* Open an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4) -- an `application/pkcs7-mime` entity
|
|
836
1048
|
* whose base64 body is a CMS EnvelopedData or AuthEnvelopedData. `keyMaterial` is the `pki.cms.decrypt`
|
|
837
1049
|
* key material (`{ key, cert }`, `{ password }`, or `{ kek, kekId? }`). Returns the recovered inner MIME
|
|
838
|
-
* entity as `content`, the `smimeType`, `authenticated` (true only for AuthEnvelopedData
|
|
1050
|
+
* entity as `content`, the `smimeType`, `authenticated` (true only for AuthEnvelopedData; a CBC
|
|
839
1051
|
* `enveloped-data` message reports `false`, the RFC 8551 sec. 3.3 / EFAIL no-integrity caveat), and the
|
|
840
1052
|
* `recipientType` / `recipientIndex` / `contentEncryptionAlgorithm` from the CMS layer. Fail-closed and
|
|
841
1053
|
* oracle-free: every secret-dependent failure collapses to the uniform `cms/decrypt-failed` the CMS layer
|
|
842
1054
|
* emits (this layer only propagates it). A recovered `content` that is itself a signed S/MIME message is
|
|
843
1055
|
* returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
|
|
844
1056
|
* legacy `application/x-pkcs7-mime` and a missing `smime-type`. If the decrypted payload is header-protected
|
|
845
|
-
* (RFC 9788, `hp="cipher"`), `protectedHeaders` is the recovered
|
|
1057
|
+
* (RFC 9788, `hp="cipher"`), `protectedHeaders` is the recovered real inner header set (the values the outer
|
|
846
1058
|
* Header Confidentiality Policy hid) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`,
|
|
847
1059
|
* where `present` is `true` only for a declared `hp=` payload, and `confidential` names the fields the composer
|
|
848
1060
|
* kept end-to-end confidential (via the authenticated HP-Outer records, RFC 9788 sec. 4.3) -- so a caller can
|
|
849
1061
|
* reply/forward without leaking them (sec. 6.1); a payload whose `hp` is malformed or contradicts the envelope
|
|
850
1062
|
* fails closed (`smime/bad-header-protection`). `legacy` is `null` unless `opts.legacyHeaderProtection` detected a
|
|
851
|
-
* legacy RFC 8551 `message/rfc822` wrap (its own `{ headers, mode, fromMismatch, confidential }` object
|
|
852
|
-
* an ordered `[{ name, value }]` array
|
|
1063
|
+
* legacy RFC 8551 `message/rfc822` wrap (its own `{ headers, mode, fromMismatch, confidential }` object, where `headers`
|
|
1064
|
+
* is an ordered `[{ name, value }]` array; it is never merged into `protectedHeaders` / `present`).
|
|
853
1065
|
*
|
|
854
1066
|
* @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
|
|
855
1067
|
* @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
|
|
856
1068
|
* @opts strictSmimeType reject a header `smime-type` that disagrees with the CMS body (`smime/smime-type-mismatch`).
|
|
857
|
-
* @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): an encrypted Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner headers under `headerProtection.legacy = { headers, mode: "cipher", fromMismatch, confidential }
|
|
1069
|
+
* @opts legacyHeaderProtection opt in to detecting a LEGACY RFC 8551 header-protected message (RFC 9788 sec. 4.10): an encrypted Cryptographic Payload that is a bare `message/rfc822` wrap with no `hp=` parameter. When set, a precisely-identified legacy message surfaces the inner headers under `headerProtection.legacy = { headers, mode: "cipher", fromMismatch, confidential }`, with `headers` an ordered `[{ name, value }]` array (retaining repeated fields) and the `confidential` set derived from the actual visible outer Header Section. They are not placed under `protectedHeaders`, and `present` stays `false`, since a legacy message is structurally indistinguishable from a forwarded `message/rfc822` (a heuristic; cross-check `legacy.fromMismatch`). Anything not precisely identified reports `legacy: null`. Off by default.
|
|
858
1070
|
* @example
|
|
859
1071
|
* var rsa = { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" };
|
|
860
1072
|
* var pair = await pki.key.generate(rsa);
|
|
@@ -904,7 +1116,7 @@ async function decrypt(message, keyMaterial, opts) {
|
|
|
904
1116
|
* entity when `opts.entity` is set. The entity is canonicalized (RFC 8551 sec. 3.1) and ZLIB-compressed
|
|
905
1117
|
* into a CMS `CompressedData` (`pki.cms.compress`), carried opaque in one `application/pkcs7-mime;
|
|
906
1118
|
* smime-type=compressed-data; name=smime.p7z` entity (base64). Compression is a size transform with NO
|
|
907
|
-
* integrity, confidentiality, or authentication (RFC 8551 sec. 2.4.5)
|
|
1119
|
+
* integrity, confidentiality, or authentication (RFC 8551 sec. 2.4.5). Sign or encrypt the result if
|
|
908
1120
|
* you need protection. Returns the assembled message bytes; fail-closed with `SmimeError`.
|
|
909
1121
|
*
|
|
910
1122
|
* @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
|
|
@@ -936,7 +1148,7 @@ async function compress(content, opts) {
|
|
|
936
1148
|
* base64 body is a CMS `CompressedData`. Returns the recovered inner MIME entity as `content` plus the
|
|
937
1149
|
* inner `contentType` / `contentTypeName` and the `compressionAlgorithm`. The inflate is BOUNDED (a
|
|
938
1150
|
* decompression-bomb defense, `cms/decompress-too-large`; `opts.maxOutputBytes` tightens it downward).
|
|
939
|
-
* The verdict carries NO `authenticated` / `valid` field
|
|
1151
|
+
* The verdict carries NO `authenticated` / `valid` field, because CompressedData is not a security assertion
|
|
940
1152
|
* (RFC 8551 sec. 2.4.5). A recovered content that is itself a signed or enveloped S/MIME message is
|
|
941
1153
|
* returned as-is for the caller to feed back to `pki.smime.verify` / `pki.smime.decrypt` (no
|
|
942
1154
|
* auto-recursion). Accepts OpenSSL's legacy `application/x-pkcs7-mime` and a missing `smime-type`.
|