@blamejs/pki 0.5.7 → 0.5.9

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +418 -386
  2. package/MIGRATING.md +43 -0
  3. package/README.md +12 -12
  4. package/lib/acme.js +31 -31
  5. package/lib/asn1-der.js +10 -10
  6. package/lib/attrcert-sign.js +19 -19
  7. package/lib/byte-reader.js +6 -6
  8. package/lib/byte-writer.js +5 -5
  9. package/lib/cbor-det.js +27 -24
  10. package/lib/cmc-build.js +97 -92
  11. package/lib/cmc-verify.js +106 -30
  12. package/lib/cmp-build.js +25 -25
  13. package/lib/cmp-session.js +70 -70
  14. package/lib/cmp-verify.js +71 -57
  15. package/lib/cms-compress.js +7 -7
  16. package/lib/cms-decrypt.js +90 -72
  17. package/lib/cms-encrypt.js +32 -32
  18. package/lib/cms-sign.js +74 -55
  19. package/lib/cms-verify.js +97 -75
  20. package/lib/composite-sig.js +13 -13
  21. package/lib/constants.js +4 -4
  22. package/lib/crl-sign.js +22 -22
  23. package/lib/crl-verify.js +7 -6
  24. package/lib/crmf-sign.js +14 -14
  25. package/lib/csr-sign.js +8 -8
  26. package/lib/ct.js +37 -37
  27. package/lib/edwards-point.js +7 -7
  28. package/lib/est.js +98 -55
  29. package/lib/framework-error.js +5 -5
  30. package/lib/guard-all.js +5 -3
  31. package/lib/guard-async.js +4 -4
  32. package/lib/guard-bytes.js +79 -79
  33. package/lib/guard-compress.js +17 -17
  34. package/lib/guard-crypto.js +1 -1
  35. package/lib/guard-der.js +152 -0
  36. package/lib/guard-encoding.js +15 -15
  37. package/lib/guard-header.js +3 -3
  38. package/lib/guard-identifier.js +16 -16
  39. package/lib/guard-json.js +15 -15
  40. package/lib/guard-limits.js +7 -7
  41. package/lib/guard-name.js +81 -16
  42. package/lib/guard-parsed.js +80 -80
  43. package/lib/guard-range.js +19 -19
  44. package/lib/guard-secret.js +11 -10
  45. package/lib/guard-text.js +6 -6
  46. package/lib/guard-time.js +10 -10
  47. package/lib/hpke.js +18 -17
  48. package/lib/http-digest.js +35 -35
  49. package/lib/http-retry-after.js +13 -13
  50. package/lib/http-transport.js +20 -19
  51. package/lib/inspect.js +53 -53
  52. package/lib/ip-utils.js +2 -2
  53. package/lib/jose.js +13 -13
  54. package/lib/key.js +16 -16
  55. package/lib/lint.js +51 -51
  56. package/lib/merkle.js +51 -36
  57. package/lib/mime.js +18 -18
  58. package/lib/ocsp-verify.js +10 -10
  59. package/lib/ocsp.js +13 -13
  60. package/lib/oid.js +29 -29
  61. package/lib/path-validate.js +114 -113
  62. package/lib/pbes2.js +16 -16
  63. package/lib/pkcs12-build.js +53 -53
  64. package/lib/pki-build.js +48 -20
  65. package/lib/rc2.js +1 -1
  66. package/lib/rfc3339.js +5 -5
  67. package/lib/schema-all.js +31 -31
  68. package/lib/schema-attrcert.js +12 -12
  69. package/lib/schema-c509.js +149 -197
  70. package/lib/schema-cmc.js +58 -58
  71. package/lib/schema-cmp.js +43 -43
  72. package/lib/schema-cms.js +45 -45
  73. package/lib/schema-crl.js +7 -7
  74. package/lib/schema-crmf.js +28 -28
  75. package/lib/schema-csr.js +12 -12
  76. package/lib/schema-csrattrs.js +16 -16
  77. package/lib/schema-engine.js +18 -18
  78. package/lib/schema-ocsp.js +15 -15
  79. package/lib/schema-pkcs12.js +20 -20
  80. package/lib/schema-pkcs8.js +2 -2
  81. package/lib/schema-pkix.js +131 -126
  82. package/lib/schema-smime.js +19 -19
  83. package/lib/schema-tsp.js +12 -12
  84. package/lib/schema-x509.js +3 -3
  85. package/lib/shbs.js +18 -18
  86. package/lib/sign-scheme.js +13 -13
  87. package/lib/sigstore.js +10 -11
  88. package/lib/sleep.js +1 -1
  89. package/lib/smime.js +308 -96
  90. package/lib/tls-cert-compress.js +18 -18
  91. package/lib/trust.js +27 -27
  92. package/lib/tsp-sign.js +17 -17
  93. package/lib/validator-all.js +1 -1
  94. package/lib/validator-attcert.js +1 -1
  95. package/lib/validator-cose.js +43 -44
  96. package/lib/validator-keydesc.js +3 -3
  97. package/lib/validator-sig.js +13 -13
  98. package/lib/validator-tls.js +11 -11
  99. package/lib/validator-tpm.js +20 -19
  100. package/lib/webauthn-mds.js +66 -66
  101. package/lib/webauthn.js +33 -33
  102. package/lib/webcrypto.js +15 -15
  103. package/lib/x509-sign.js +13 -13
  104. package/package.json +3 -2
  105. 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 -- the whole entity is a base64 CMS
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 -- fail-closed,
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 rather than as a value: nothing is
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 VERB, not per module, because the surfaces genuinely differ -- `form` means
56
- // something on sign and nothing on encrypt -- and a merged table would accept each verb's options at
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 -- never regex-mangled (a blind
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, OR -- when opts.entity is set -- the caller's own complete MIME entity, canonical.
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 EVERY Content-* field (not just an enumerated subset) and is not a prototype-key lookup (a
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 ONCE each -- an accessor / Proxy could return a different value on a second read, so
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 rather than emit a malformed / self-inconsistent entity.
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
- // VERBATIM: Bcc is intentionally NOT stripped -- RFC 9788 sec. 11.4 explains that removing Bcc during
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 -- a null result removes the field (it lives only in the ciphertext).
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 ONE normalized snapshot of opts.headers (see the caller) -- the SAME list feeds the signed/
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 -- reject it rather than silently bury the
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 -- "HP-Outer: <name>: <outer-value>". A field the HCP removed has NO HP-Outer
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 SAME HCP-processed outer set the inner entity's HP-Outer records were built from.
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 -- the SAME comment- + quoted-string-aware tokenizer the parser
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 NOT a parameter (so an ordinary Content-Type cannot opt a message into HP processing). A
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 WITHOUT a value ("...; hp") still
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 ONLY cryptographically-declared (hp=) protection; `legacy` is null
271
- // unless an opt-in legacy RFC8551HP inference succeeded (then it carries its own headers/mode/etc. -- see below).
272
- function _noneSurface() { return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [], legacy: null } }; }
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 FIRST via contentType, so an hp= parameter or a Cryptographic-Layer media type on a LATER
276
- // field would be invisible to the classification -- both the standard HP path and the legacy path fail closed on it.
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 -- RFC 5322 sec. 3.6 groups it under trace, but RFC 5321 sec. 4.4
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 -- the ergonomic single-value view surfaced by the standard
291
- // path), entries (an ORDERED array of every { name, value, raw } occurrence -- a real message repeats trace
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), NOT a
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 FIRST colon into (name, outer-value)
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 NEVER surfaced as a protected header nor counted toward the duplicate
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 -- i.e. that value was copied verbatim to the visible outer
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 -- a last-wins collapse
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 -- a DoS an attacker could drive toward the 16 MiB section cap. A two-level
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 EVERY outer From (outerEnt.header returns only the FIRST; an attacker may append a second, forged From
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 innerFrom != null && (outerFroms.length !== 1 || outerFroms[0] !== innerFrom.trim());
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 -- the safe direction.
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 LEGACY RFC8551HP message -- a Cryptographic
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 DETECT-ONLY (sec. 4.10: "An MUA MUST NOT generate an RFC8551HP message ... MAY try to render") and OPT-IN
384
- // (`enabled`). Identification (sec. 4.10.1) is the four conjunctive conditions C1-C4; every failure -- an
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 NEVER placed in `protectedHeaders` and NEVER
392
- // sets `present:true` -- those describe only cryptographically-declared hp= protection a caller may trust. The
393
- // inference is surfaced ONLY under `headerProtection.legacy` (its own { headers, mode, fromMismatch, confidential }
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 FIRST Content-Type of each part (mime.parse surfaces that one). A
407
- // duplicate Content-Type on part C or part D is ambiguous -- a later field could carry hp= or a Cryptographic
408
- // Layer media type the checks below would miss -- so fail soft, matching the standard path's duplicate reject.
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 NOT legacy (sec. 4.1: a consumer MUST ignore hp outside the payload
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 -- part D is an ordinary received
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 NOT reject the inference (else legacy detection fails on essentially all delivered mail).
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 ONLY under `legacy` so a
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 -- a real message repeats trace
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
- return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [], legacy: { headers: headers, mode: mode, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential } } };
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 -- UNLESS opts.legacyHeaderProtection is set and it is a legacy
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 ONLY when the
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 -- they
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 -- the exact bytes the signature covers. A transport may rewrite a
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 BOTH the hp detection and the parse must run on the repaired bytes, or they diverge from
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 ONLY when a real hp= is present on the payload root.
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 FIRST field,
455
- // but an hp= may sit on a later one) -- a malformed wrap that fails closed, never a silent downgrade.
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 LAST value, but a recipient honoring the
459
- // first would see a different mode) -- fail closed, like the duplicate Content-Type. Counted by attribute
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 SAME comment/quoted-string-aware tokenizer (mime.paramCount) mime.parse uses, so
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 -- per RFC 2045 sec. 5.1 a
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 -- the mode-contradiction check still runs.)
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
- // -- fail closed rather than silently downgrade (mime.parse skips a valueless parameter, so params.hp is
472
- // undefined here only for that malformed case).
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) -- fail closed rather than surface an ambiguous authenticated set.
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 -- it forwards any signer).
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 -- a raw body wrapped as a
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 SAME
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 -- its Content-Type gains `hp="clear"`
551
- * -- so the signature covers them, and copied to the outer display headers; `verify` surfaces the
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"`) -- inline `opts.headers` on the signed payload + the outer display headers.
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 SAME canonicalizer 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` -- there was nothing to chain to.
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` flags
639
- * an outer From differing from the protected one; `confidential` lists the protected fields the composer kept
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 NEVER placed in `protectedHeaders` and never sets `present: true`, because it 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 }` -- `headers` an ordered `[{ name, value }]` array (retaining legally-repeated fields such as `Received`), the mode inferred from the envelope (`clear` here) -- NOT 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) -- the non-recursive layered API exposes no single seam holding both the inner signature verdict and the outer header section.
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 -- the S/MIME layer is
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 ONE form -- opaque `application/pkcs7-mime` with the whole entity base64-encoded. The `smime-type`
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 -- no integrity, RFC 8551 sec. 3.3). With
783
- * `opts.protectHeaders`, the message is header-protected (RFC 9788): the REAL `opts.headers` are inlined
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"`) -- 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.
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 -- obscure Subject, remove 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`.
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 -- a CBC
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 REAL inner header set (the values the outer
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 -- `headers`
852
- * an ordered `[{ name, value }]` array -- never merged into `protectedHeaders` / `present`).
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 }` -- `headers` an ordered `[{ name, value }]` array (retaining repeated fields), the `confidential` set derived from the actual visible outer Header Section -- NOT 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.
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) -- sign or encrypt the result if
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 -- CompressedData is not a security assertion
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`.