@blamejs/pki 0.3.21 → 0.3.23

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/lib/rc2.js ADDED
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. RC2 is exposed only through the legacy-PBE bag decryption of
6
+ // pki.pkcs12.open (RFC 7292 App. C), never as a general cipher; RC2 is a broken 64-bit block cipher (RC2-40
7
+ // is export-crippled) kept solely to READ old `openssl pkcs12 -legacy` / NSS stores.
8
+ //
9
+ // rc2 -- a from-scratch RFC 2268 RC2-CBC. OpenSSL 3.x moved RC2 to the legacy provider (not loaded by Node),
10
+ // so node:crypto cannot decrypt these bags; this in-tree primitive fills that single gap (Hard rule #1: own
11
+ // code, no npm hop -- MANIFEST stays empty; a lib primitive like webcrypto.js, not a lib/vendor/ bundle). The
12
+ // RFC 2268 sec. 5 known-answer vectors pin the cipher; the `openssl -legacy` store is the end-to-end KAT.
13
+
14
+ // RFC 2268 sec. 2: PITABLE, a fixed 256-byte permutation of the digits of pi used by the key schedule.
15
+ var PITABLE = [
16
+ 0xd9, 0x78, 0xf9, 0xc4, 0x19, 0xdd, 0xb5, 0xed, 0x28, 0xe9, 0xfd, 0x79, 0x4a, 0xa0, 0xd8, 0x9d,
17
+ 0xc6, 0x7e, 0x37, 0x83, 0x2b, 0x76, 0x53, 0x8e, 0x62, 0x4c, 0x64, 0x88, 0x44, 0x8b, 0xfb, 0xa2,
18
+ 0x17, 0x9a, 0x59, 0xf5, 0x87, 0xb3, 0x4f, 0x13, 0x61, 0x45, 0x6d, 0x8d, 0x09, 0x81, 0x7d, 0x32,
19
+ 0xbd, 0x8f, 0x40, 0xeb, 0x86, 0xb7, 0x7b, 0x0b, 0xf0, 0x95, 0x21, 0x22, 0x5c, 0x6b, 0x4e, 0x82,
20
+ 0x54, 0xd6, 0x65, 0x93, 0xce, 0x60, 0xb2, 0x1c, 0x73, 0x56, 0xc0, 0x14, 0xa7, 0x8c, 0xf1, 0xdc,
21
+ 0x12, 0x75, 0xca, 0x1f, 0x3b, 0xbe, 0xe4, 0xd1, 0x42, 0x3d, 0xd4, 0x30, 0xa3, 0x3c, 0xb6, 0x26,
22
+ 0x6f, 0xbf, 0x0e, 0xda, 0x46, 0x69, 0x07, 0x57, 0x27, 0xf2, 0x1d, 0x9b, 0xbc, 0x94, 0x43, 0x03,
23
+ 0xf8, 0x11, 0xc7, 0xf6, 0x90, 0xef, 0x3e, 0xe7, 0x06, 0xc3, 0xd5, 0x2f, 0xc8, 0x66, 0x1e, 0xd7,
24
+ 0x08, 0xe8, 0xea, 0xde, 0x80, 0x52, 0xee, 0xf7, 0x84, 0xaa, 0x72, 0xac, 0x35, 0x4d, 0x6a, 0x2a,
25
+ 0x96, 0x1a, 0xd2, 0x71, 0x5a, 0x15, 0x49, 0x74, 0x4b, 0x9f, 0xd0, 0x5e, 0x04, 0x18, 0xa4, 0xec,
26
+ 0xc2, 0xe0, 0x41, 0x6e, 0x0f, 0x51, 0xcb, 0xcc, 0x24, 0x91, 0xaf, 0x50, 0xa1, 0xf4, 0x70, 0x39,
27
+ 0x99, 0x7c, 0x3a, 0x85, 0x23, 0xb8, 0xb4, 0x7a, 0xfc, 0x02, 0x36, 0x5b, 0x25, 0x55, 0x97, 0x31,
28
+ 0x2d, 0x5d, 0xfa, 0x98, 0xe3, 0x8a, 0x92, 0xae, 0x05, 0xdf, 0x29, 0x10, 0x67, 0x6c, 0xba, 0xc9,
29
+ 0xd3, 0x00, 0xe6, 0xcf, 0xe1, 0x9e, 0xa8, 0x2c, 0x63, 0x16, 0x01, 0x3f, 0x58, 0xe2, 0x89, 0xa9,
30
+ 0x0d, 0x38, 0x34, 0x1b, 0xab, 0x33, 0xff, 0xb0, 0xbb, 0x48, 0x0c, 0x5f, 0xb9, 0xb1, 0xcd, 0x2e,
31
+ 0xc5, 0xf3, 0xdb, 0x47, 0xe5, 0xa5, 0x9c, 0x77, 0x0a, 0xa6, 0x20, 0x68, 0xfe, 0x7f, 0xc1, 0xad,
32
+ ];
33
+
34
+ // RFC 2268 sec. 2: expand `key` (1..128 bytes) to the 64 16-bit words K[0..63], reducing to `effectiveBits`.
35
+ function _expandKey(key, effectiveBits) {
36
+ var T = key.length, L = new Array(128);
37
+ for (var i = 0; i < T; i++) L[i] = key[i];
38
+ for (i = T; i < 128; i++) L[i] = PITABLE[(L[i - 1] + L[i - T]) & 0xff];
39
+ var T8 = (effectiveBits + 7) >>> 3;
40
+ var TM = 0xff >>> ((8 - (effectiveBits & 7)) & 7); // 255 mod 2^(8 + effectiveBits - 8*T8)
41
+ L[128 - T8] = PITABLE[L[128 - T8] & TM];
42
+ for (i = 127 - T8; i >= 0; i--) L[i] = PITABLE[L[i + 1] ^ L[i + T8]];
43
+ var K = new Array(64);
44
+ for (i = 0; i < 64; i++) K[i] = L[2 * i] + (L[2 * i + 1] << 8);
45
+ return K;
46
+ }
47
+
48
+ function _rotl16(x, n) { return ((x << n) | (x >>> (16 - n))) & 0xffff; }
49
+ function _rotr16(x, n) { return ((x >>> n) | (x << (16 - n))) & 0xffff; }
50
+ var SHIFT = [1, 2, 3, 5];
51
+
52
+ // RFC 2268 sec. 3: encrypt one 8-byte block (little-endian 16-bit words) in place-ish, returning 8 bytes.
53
+ function _encBlock(K, b, off) {
54
+ var R = [b[off] | (b[off + 1] << 8), b[off + 2] | (b[off + 3] << 8), b[off + 4] | (b[off + 5] << 8), b[off + 6] | (b[off + 7] << 8)];
55
+ var j = 0, i;
56
+ function mix() {
57
+ for (var k = 0; k < 4; k++) {
58
+ R[k] = (R[k] + K[j] + (R[(k + 3) & 3] & R[(k + 2) & 3]) + (~R[(k + 3) & 3] & R[(k + 1) & 3])) & 0xffff;
59
+ j++;
60
+ R[k] = _rotl16(R[k], SHIFT[k]);
61
+ }
62
+ }
63
+ function mash() { for (var k = 0; k < 4; k++) R[k] = (R[k] + K[R[(k + 3) & 3] & 63]) & 0xffff; }
64
+ for (i = 0; i < 5; i++) mix();
65
+ mash();
66
+ for (i = 0; i < 6; i++) mix();
67
+ mash();
68
+ for (i = 0; i < 5; i++) mix();
69
+ return R;
70
+ }
71
+
72
+ // RFC 2268 sec. 3 inverse: decrypt one 8-byte block.
73
+ function _decBlock(K, b, off) {
74
+ var R = [b[off] | (b[off + 1] << 8), b[off + 2] | (b[off + 3] << 8), b[off + 4] | (b[off + 5] << 8), b[off + 6] | (b[off + 7] << 8)];
75
+ var j = 63, i;
76
+ function rMix() {
77
+ for (var k = 3; k >= 0; k--) {
78
+ R[k] = _rotr16(R[k], SHIFT[k]);
79
+ R[k] = (R[k] - K[j] - (R[(k + 3) & 3] & R[(k + 2) & 3]) - (~R[(k + 3) & 3] & R[(k + 1) & 3])) & 0xffff;
80
+ j--;
81
+ }
82
+ }
83
+ function rMash() { for (var k = 3; k >= 0; k--) R[k] = (R[k] - K[R[(k + 3) & 3] & 63]) & 0xffff; }
84
+ for (i = 0; i < 5; i++) rMix();
85
+ rMash();
86
+ for (i = 0; i < 6; i++) rMix();
87
+ rMash();
88
+ for (i = 0; i < 5; i++) rMix();
89
+ return R;
90
+ }
91
+
92
+ function _wordsToBytes(R) {
93
+ return Buffer.from([R[0] & 0xff, (R[0] >>> 8) & 0xff, R[1] & 0xff, (R[1] >>> 8) & 0xff, R[2] & 0xff, (R[2] >>> 8) & 0xff, R[3] & 0xff, (R[3] >>> 8) & 0xff]);
94
+ }
95
+
96
+ // ECB block encrypt/decrypt (used by the KAT; CBC wraps them).
97
+ function encryptBlock(key, effectiveBits, block) { return _wordsToBytes(_encBlock(_expandKey(key, effectiveBits), block, 0)); }
98
+ function decryptBlock(key, effectiveBits, block) { return _wordsToBytes(_decBlock(_expandKey(key, effectiveBits), block, 0)); }
99
+
100
+ // RC2-CBC decrypt with PKCS#7 unpadding. `E`/`code` are the caller's typed-error factory + code; a structural
101
+ // fault (bad length, invalid pad) throws E(code) so the caller can collapse it to its uniform decrypt verdict.
102
+ function cbcDecrypt(key, effectiveBits, iv, ct, E, code) {
103
+ if (ct.length === 0 || ct.length % 8 !== 0) throw E(code, "RC2-CBC ciphertext length must be a non-zero multiple of 8");
104
+ var K = _expandKey(key, effectiveBits), out = Buffer.alloc(ct.length), prev = iv;
105
+ for (var off = 0; off < ct.length; off += 8) {
106
+ var dec = _wordsToBytes(_decBlock(K, ct, off));
107
+ for (var i = 0; i < 8; i++) out[off + i] = dec[i] ^ prev[i];
108
+ prev = ct.subarray(off, off + 8);
109
+ }
110
+ var pad = out[out.length - 1];
111
+ if (pad < 1 || pad > 8 || pad > out.length) throw E(code, "RC2-CBC invalid PKCS#7 padding");
112
+ for (i = 0; i < pad; i++) if (out[out.length - 1 - i] !== pad) throw E(code, "RC2-CBC invalid PKCS#7 padding");
113
+ return out.subarray(0, out.length - pad);
114
+ }
115
+
116
+ // RC2-CBC encrypt with PKCS#7 padding (for the round-trip KAT; pkcs12.open only decrypts).
117
+ function cbcEncrypt(key, effectiveBits, iv, pt) {
118
+ var padLen = 8 - (pt.length % 8), padded = Buffer.concat([pt, Buffer.alloc(padLen, padLen)]);
119
+ var K = _expandKey(key, effectiveBits), out = Buffer.alloc(padded.length), prev = iv;
120
+ for (var off = 0; off < padded.length; off += 8) {
121
+ var x = Buffer.alloc(8);
122
+ for (var i = 0; i < 8; i++) x[i] = padded[off + i] ^ prev[i];
123
+ var enc = _wordsToBytes(_encBlock(K, x, 0));
124
+ enc.copy(out, off);
125
+ prev = enc;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ module.exports = { encryptBlock: encryptBlock, decryptBlock: decryptBlock, cbcDecrypt: cbcDecrypt, cbcEncrypt: cbcEncrypt };
package/lib/smime.js CHANGED
@@ -62,15 +62,273 @@ var MICALG = { sha1: "sha-1", sha224: "sha-224", sha256: "sha-256", sha384: "sha
62
62
  function _entityBytes(content, opts) {
63
63
  var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
64
64
  if (opts.entity) return mime.canonicalize(raw, SmimeError, "smime/bad-mime");
65
- var ct = opts.contentType || "text/plain; charset=utf-8";
66
65
  // Declare the honest Content-Transfer-Encoding: "7bit" requires every byte <= 127 (RFC 2045); a body
67
66
  // with any 8-bit byte is "8bit". Declaring 7bit for 8-bit content is a false claim a transport could act on.
68
67
  var cte = _is7bit(raw) ? "7bit" : "8bit";
69
- var head = Buffer.from("Content-Type: " + ct + "\r\nContent-Transfer-Encoding: " + cte + "\r\n\r\n", "latin1");
70
- return mime.canonicalize(Buffer.concat([head, raw]), SmimeError, "smime/bad-mime");
68
+ // Build through the guarded serializer so a CRLF / NUL in opts.contentType cannot inject a header field.
69
+ return mime.buildEntity([{ name: "Content-Type", value: opts.contentType || "text/plain; charset=utf-8" }, { name: "Content-Transfer-Encoding", value: cte }], raw, SmimeError, "smime/bad-header");
71
70
  }
72
71
  function _is7bit(buf) { for (var i = 0; i < buf.length; i++) if (buf[i] > 0x7f) return false; return true; }
73
72
 
73
+ // ---- RFC 9788 header protection (an opts.protectHeaders-gated MIME transform over the shipped CMS path) ----
74
+ // The protected header fields are inlined on the Cryptographic Payload root (its Content-Type gains an `hp`
75
+ // parameter: "clear" for signed-only, "cipher" for encrypted); the outer display headers are copied as-is
76
+ // (signed) or passed through a Header Confidentiality Policy (encrypted). The CMS crypto is UNCHANGED.
77
+
78
+ // Is this (lowercased) header field name Structural? RFC 9787 sec. 1.1.1 defines a Structural Header Field as
79
+ // MIME-Version OR any field whose name begins with "Content-" (Content-Type / -Transfer-Encoding /
80
+ // -Disposition / -ID / -Description / -Language / -Location / ...). These describe the specific MIME part --
81
+ // the library sets them -- so they are never protected as Non-Structural fields (rejected at the producer) nor
82
+ // surfaced in the authenticated protectedHeaders set on verify. The prefix rule is the RFC definition itself,
83
+ // so it matches EVERY Content-* field (not just an enumerated subset) and is not a prototype-key lookup (a
84
+ // field named "constructor" / "toString" is simply not Structural).
85
+ function _isStructural(lname) {
86
+ return lname === "mime-version" || lname.indexOf("content-") === 0;
87
+ }
88
+
89
+ // Normalize opts.headers (an object { Name: value } or an array [{ name, value }]) to an ordered list. A
90
+ // malformed shape is a config-time smime/bad-input (the value's byte integrity is guarded at emission).
91
+ function _hpHeaderList(headers) {
92
+ if (headers == null) return [];
93
+ var out = [];
94
+ if (Array.isArray(headers)) {
95
+ headers.forEach(function (h) {
96
+ if (!h || typeof h !== "object") throw _err("smime/bad-input", "each opts.headers entry must be { name, value }");
97
+ // Read name + value ONCE each -- an accessor / Proxy could return a different value on a second read, so
98
+ // the snapshot (not the live property) is what both the signed inner header and the outer display copy use.
99
+ var name = h.name, v = h.value;
100
+ if (typeof name !== "string") throw _err("smime/bad-input", "each opts.headers entry must be { name, value }");
101
+ out.push({ name: name, value: v == null ? "" : String(v) });
102
+ });
103
+ } else if (typeof headers === "object") {
104
+ Object.keys(headers).forEach(function (k) { var v = headers[k]; out.push({ name: k, value: v == null ? "" : String(v) }); });
105
+ } else {
106
+ throw _err("smime/bad-input", "opts.headers must be an object or an array of { name, value }");
107
+ }
108
+ // Only Non-Structural fields are protected (RFC 9787): a Structural header (Content-Type, MIME-Version, ...)
109
+ // describes the MIME structure -- the library sets it -- so a caller passing one would duplicate it on the
110
+ // payload root. And a REPEATED field name is rejected here at the PRODUCER: a repeated protected field is an
111
+ // unsupported shape (verify fails closed on the ambiguity), so the library never emits a message it cannot
112
+ // re-consume. Reject both rather than emit a malformed / self-inconsistent entity.
113
+ var seen = Object.create(null);
114
+ out.forEach(function (h) {
115
+ var ln = h.name.toLowerCase();
116
+ if (_isStructural(ln)) throw _err("smime/bad-input", "opts.headers must not include the Structural header " + JSON.stringify(h.name) + " (RFC 9787); only Non-Structural fields (Subject / From / To / ...) are protected");
117
+ // HP-Outer is a reserved field the library emits itself (RFC 9788 sec. 2.2) to document outer-header
118
+ // confidentiality; a caller-supplied one would be mistaken for that machinery, so reject it.
119
+ if (ln === "hp-outer") throw _err("smime/bad-input", "opts.headers must not include the reserved HP-Outer field (RFC 9788 sec. 2.2); the library emits it");
120
+ if (seen[ln]) throw _err("smime/bad-input", "opts.headers must not repeat a protected field name (" + JSON.stringify(h.name) + "); a repeated protected field is not supported");
121
+ seen[ln] = 1;
122
+ });
123
+ return out;
124
+ }
125
+
126
+ // The Header Confidentiality Policy for an OUTER header of an encrypted-HP message. hcp_baseline (RFC 9788
127
+ // sec. 3.2.1, the default) obscures Subject to "[...]" and REMOVES Comments / Keywords (null);
128
+ // hcp_no_confidentiality leaves every field visible (opt-in only). This is the sec. 3.2.1 pseudocode
129
+ // VERBATIM: Bcc is intentionally NOT stripped -- RFC 9788 sec. 11.4 explains that removing Bcc during
130
+ // encryption can break deliverability to a Bcc'd recipient, so the choice is left to the caller (omit Bcc
131
+ // from opts.headers to keep it out of the plaintext outer headers).
132
+ function _applyHcp(name, value, hcp) {
133
+ if (hcp === "hcp_no_confidentiality") return value;
134
+ var ln = name.toLowerCase();
135
+ if (ln === "subject") return "[...]";
136
+ if (ln === "comments" || ln === "keywords") return null;
137
+ return value;
138
+ }
139
+
140
+ // The outer display header list: for "clear" (signed) the fields are copied verbatim; for "cipher"
141
+ // (encrypted) each passes through the HCP -- a null result removes the field (it lives only in the ciphertext).
142
+ function _outerHeaderList(list, mode, hcp) {
143
+ var policy = hcp == null ? "hcp_baseline" : hcp;
144
+ 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\")");
145
+ var out = [];
146
+ list.forEach(function (h) {
147
+ if (mode === "cipher") {
148
+ var v = _applyHcp(h.name, h.value, policy);
149
+ if (v !== null) out.push({ name: h.name, value: v });
150
+ } else {
151
+ out.push({ name: h.name, value: h.value });
152
+ }
153
+ });
154
+ return out;
155
+ }
156
+
157
+ // Build the inner Cryptographic Payload for a header-protected message: the caller's body wrapped with its
158
+ // Content-Type (gaining ; hp="<mode>") + Content-Transfer-Encoding + the protected fields inlined, canonical.
159
+ // hpList is the ONE normalized snapshot of opts.headers (see the caller) -- the SAME list feeds the signed/
160
+ // encrypted inner header set here and the outer display copy, so an accessor / Proxy cannot make them diverge.
161
+ // outerList is the HCP-processed outer set (_outerHeaderList) -- for a cipher payload it also drives the
162
+ // HP-Outer records embedded here.
163
+ function _protectedInnerEntity(content, opts, mode, hpList, outerList) {
164
+ // Header protection inlines the protected fields on the payload ROOT it builds, so a caller-supplied
165
+ // complete entity (opts.entity) is an unsupported combination -- reject it rather than silently bury the
166
+ // caller's entity (its own Content-Type + headers) beneath a new default text/plain wrapper.
167
+ if (opts.entity) throw _err("smime/bad-input", "opts.entity is not supported with protectHeaders -- pass raw content + opts.contentType");
168
+ var raw = guard.bytes.view(content, SmimeError, "smime/bad-input", "content");
169
+ // Header protection sets the hp parameter itself; a caller hp in opts.contentType (with OR without a value)
170
+ // would emit two hp attributes (Content-Type: ...; hp; hp="clear"), an unconsumable non-interoperable
171
+ // message -- reject it here (hasParam catches a bare "hp" that paramCount would miss).
172
+ if (opts.contentType != null && mime.hasParam(String(opts.contentType), "hp")) throw _err("smime/bad-input", "opts.contentType must not set the hp parameter -- header protection sets it (hp=" + JSON.stringify(mode) + ")");
173
+ var ct = (opts.contentType || "text/plain; charset=utf-8") + "; hp=\"" + mode + "\"";
174
+ var fields = [{ name: "Content-Type", value: ct }, { name: "Content-Transfer-Encoding", value: _is7bit(raw) ? "7bit" : "8bit" }];
175
+ hpList.forEach(function (h) { fields.push(h); });
176
+ // RFC 9788 sec. 2.2 / sec. 5.2.1 step 5.v: an ENCRYPTED header-protected payload documents inside the
177
+ // ciphertext, as HP-Outer records, every Non-Structural field it deliberately left visible in the outer
178
+ // (unprotected) header section -- "HP-Outer: <name>: <outer-value>". A field the HCP removed has NO HP-Outer
179
+ // record; its absence authenticates that it was made confidential by removal. Signed-only (clear) payloads
180
+ // carry no HP-Outer (sec. 2.2: "not relevant for signed-only messages").
181
+ if (mode === "cipher") {
182
+ outerList.forEach(function (h) { fields.push({ name: "HP-Outer", value: h.name + ": " + h.value }); });
183
+ }
184
+ return mime.buildEntity(fields, raw, SmimeError, "smime/bad-header");
185
+ }
186
+
187
+ // The outer header prefix (display copies + MIME-Version) that precedes the Cryptographic Envelope's
188
+ // Content-Type in a header-protected message. Each field is guard-validated (no CR/LF/NUL injection). list is
189
+ // the SAME HCP-processed outer set the inner entity's HP-Outer records were built from.
190
+ function _outerPrefix(list) {
191
+ var s = "";
192
+ for (var i = 0; i < list.length; i++) {
193
+ guard.header.assertField(list[i].name, list[i].value, SmimeError, "smime/bad-header");
194
+ s += list[i].name + ": " + list[i].value + "\r\n";
195
+ }
196
+ return s + "MIME-Version: 1.0\r\n";
197
+ }
198
+
199
+ // Does the payload header region declare an hp Content-Type parameter? A cheap raw scan (unfold the header
200
+ // block, then look for a Content-Type line carrying an hp= parameter) so a NON-HP payload is never parsed --
201
+ // no swallow -- and only a payload that CLAIMS hp is validated (a malformed such payload then fails closed,
202
+ // never a silent downgrade to unprotected).
203
+ // Re-decode a header value that mime.parse latin1-decoded (byte-preserving) as UTF-8 (RFC 6532), so a
204
+ // non-ASCII protected value round-trips intact -- a latin1 string maps 1:1 to bytes, recovering the emitted UTF-8.
205
+ function _utf8Header(s) { return Buffer.from(s, "latin1").toString("utf8"); }
206
+
207
+ function _declaresHp(content) {
208
+ // content is the CANONICAL entity bytes -- the caller (_hpSurface) runs mime.canonicalizeText ONCE so
209
+ // detection here and the subsequent mime.parse see the identical normalized form (bare CR / LF -> CRLF).
210
+ // Detecting hp on the canonical form -- the bytes the signature covers -- is what stops a fold/line-ending
211
+ // transport rewrite from stripping the hp signal from a still-valid signed message.
212
+ var s = content.toString("latin1");
213
+ var m = s.match(/\r\n\r\n/);
214
+ var head = (m ? s.slice(0, m.index) : s).replace(/\r\n[ \t]+/g, " ");
215
+ // Detect hp by composing mime.paramCount -- the SAME comment- + quoted-string-aware tokenizer the parser
216
+ // uses -- over each Content-Type field, so the probe never diverges from mime.parse: a leading-whitespace
217
+ // or before-colon field name is matched (the parser trims it), while an hp= inside a MIME comment or a
218
+ // quoted value is NOT a parameter (so an ordinary Content-Type cannot opt a message into HP processing). A
219
+ // real hp on ANY Content-Type line is detected; a duplicate Content-Type is rejected downstream.
220
+ var cts = head.match(/^[ \t]*content-type[ \t]*:[^\r\n]*/gim);
221
+ if (!cts) return false;
222
+ for (var i = 0; i < cts.length; i++) {
223
+ // hasParam (not paramCount): a payload that names the hp attribute WITHOUT a value ("...; hp") still
224
+ // CLAIMS header protection -- detect it so _hpSurface fails closed on the malformed declaration rather
225
+ // than silently downgrading it to unprotected.
226
+ if (mime.hasParam(cts[i].replace(/^[ \t]*content-type[ \t]*:/i, ""), "hp")) return true;
227
+ }
228
+ return false;
229
+ }
230
+
231
+ // Detect + surface RFC 9788 header protection on a recovered inner entity, FAIL-CLOSED. A payload that does
232
+ // not declare hp surfaces protectedHeaders:null. A payload that DECLARES hp is validated: a malformed block,
233
+ // an invalid hp value, or an hp mode that CONTRADICTS the cryptographic envelope (expectedMode "clear" for a
234
+ // signed message, "cipher" for a decrypted one) throws smime/bad-header-protection -- never a silent
235
+ // downgrade. Otherwise the inline Non-Structural fields ARE the authenticated set; a From that differs from
236
+ // the untrusted OUTER From is flagged fromMismatch.
237
+ function _hpSurface(content, outerEnt, expectedMode, authenticated) {
238
+ var none = { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [] } };
239
+ // Header protection is an AUTHENTICATED property: surface the inner headers as protected ONLY when the
240
+ // cryptographic verdict succeeded (a valid signature, or an authenticated-encryption decrypt). An invalid
241
+ // signature or an unauthenticated (AES-CBC, no integrity) decrypt yields attacker-influenced bytes -- they
242
+ // are never marked or exposed as protected (a caller must not treat protectedHeaders as a trust signal
243
+ // unless integrity held).
244
+ if (!authenticated) return none;
245
+ // Detect + parse the CANONICAL entity -- the exact bytes the signature covers. A transport may rewrite a
246
+ // CRLF fold OR the header/body separator to bare CR/LF; canonicalize repairs them (so verification still
247
+ // succeeds), and BOTH the hp detection and the parse must run on the repaired bytes, or they diverge from
248
+ // the signed content -- stripping the signal, or false-rejecting a valid message as an unparseable block.
249
+ // The returned `content` stays the raw recovered bytes; only this HP inspection uses the canonical copy.
250
+ var canon = mime.canonicalizeText(content);
251
+ if (!_declaresHp(canon)) return none;
252
+ var inner = mime.parse(canon, SmimeError, "smime/bad-header-protection"); // a malformed HP block fails closed
253
+ // A duplicate Content-Type makes the hp declaration ambiguous (a parser reads params from the FIRST field,
254
+ // but an hp= may sit on a later one) -- a malformed wrap that fails closed, never a silent downgrade.
255
+ var ctCount = 0;
256
+ inner.headers.forEach(function (h) { if (h.lname === "content-type") ctCount++; });
257
+ if (ctCount > 1) throw _err("smime/bad-header-protection", "a header-protected payload must carry exactly one Content-Type field (found " + ctCount + ")");
258
+ // A duplicate hp attribute is ambiguous (mime.parse keeps the LAST value, but a recipient honoring the
259
+ // first would see a different mode) -- fail closed, like the duplicate Content-Type. Counted by attribute
260
+ // name (bare OR valued) so a bare+valued pair ("hp; hp=x") is caught too, and quote-/comment-aware (an hp=
261
+ // inside a quoted value or comment is not the hp parameter, so a message we emit is never self-rejected).
262
+ 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");
263
+ // _declaresHp composes the SAME comment/quoted-string-aware tokenizer (mime.paramCount) mime.parse uses, so
264
+ // reaching here means the single Content-Type carries a real hp parameter (a duplicate is rejected above) --
265
+ // hp is defined. A malformed value fails CLOSED at the mode checks below, never a silent downgrade.
266
+ // The hp keyword values clear / cipher are an enumerated Content-Type parameter -- per RFC 2045 sec. 5.1 a
267
+ // value of this kind is compared case-insensitively for its intended use, so a peer that emits hp="Clear"
268
+ // or hp=CIPHER is still recognized. (We always EMIT lowercase; we ACCEPT any case. The value is inside the
269
+ // signed/encrypted payload, so normalizing it enables no attack -- the mode-contradiction check still runs.)
270
+ // _declaresHp detected the hp attribute (hasParam); a bare "hp" with no value is a malformed HP declaration
271
+ // -- fail closed rather than silently downgrade (mime.parse skips a valueless parameter, so params.hp is
272
+ // undefined here only for that malformed case).
273
+ var raw = inner.contentType.params.hp;
274
+ 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\")");
275
+ var hp = raw.toLowerCase();
276
+ if (hp !== "clear" && hp !== "cipher") throw _err("smime/bad-header-protection", "the header-protected payload declares an invalid hp value " + JSON.stringify(raw) + " (only clear / cipher)");
277
+ 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) + ")");
278
+ // mime.parse decodes the header block as latin1 (byte-preserving), so a protected value emitted as UTF-8
279
+ // (RFC 6532, the guard permits it) is re-decoded latin1->UTF-8 here to round-trip intact. protectedHeaders
280
+ // surfaces the exact authenticated field body (rawValue: leading/trailing whitespace preserved), NOT a
281
+ // trimmed value that would diverge from the signed octets.
282
+ var protectedHeaders = Object.create(null), protectedRaw = Object.create(null), innerFrom = null, seen = Object.create(null), dup = null;
283
+ var refouter = []; // RFC 9788 sec. 4.2.1: the HP-Outer records (name + the value the field had in the outer section)
284
+ inner.headers.forEach(function (h) {
285
+ if (_isStructural(h.lname)) return;
286
+ if (h.lname === "hp-outer") {
287
+ // RFC 9788 sec. 4.2.1 step 4.i: split the HP-Outer value on the FIRST colon into (name, outer-value)
288
+ // -> refouter. The value is kept BYTE-PRESERVING (latin1), never UTF-8-decoded, because the sec. 4.3.1
289
+ // confidentiality comparison must be octet-exact (a lossy decode maps distinct invalid octets 0x80/0x81
290
+ // to one replacement char, which would mis-classify an obscured field as exposed and drop it from the
291
+ // confidential set). HP-Outer is NEVER surfaced as a protected header nor counted toward the duplicate
292
+ // check (sec. 2.2: it "can appear multiple times"). A valueless HP-Outer (no inner colon) is ignored.
293
+ var ci = h.rawValue.indexOf(":");
294
+ if (ci >= 0) refouter.push({ name: h.rawValue.slice(0, ci).trim().toLowerCase(), value: h.rawValue.slice(ci + 1).trim() });
295
+ return;
296
+ }
297
+ if (seen[h.lname]) dup = h.name;
298
+ seen[h.lname] = 1;
299
+ protectedHeaders[h.name] = _utf8Header(h.rawValue); // SURFACE the exact authenticated field body (RFC 6532 UTF-8)
300
+ protectedRaw[h.name] = h.rawValue; // ...but retain the BYTE-PRESERVING (latin1) body for the octet-exact confidentiality comparison
301
+ // ...and for the mismatch comparison: a lossy UTF-8 decode maps distinct invalid 8-bit sequences (0x80 vs
302
+ // 0x81) to the same replacement char, which would let an attacker alter the displayed From without
303
+ // tripping fromMismatch. Compare the raw octets instead.
304
+ if (h.lname === "from") innerFrom = h.value;
305
+ });
306
+ // A duplicate protected field is ambiguous (the last-wins overwrite hides an earlier value a different
307
+ // parser might select) -- fail closed rather than surface an ambiguous authenticated set.
308
+ if (dup) throw _err("smime/bad-header-protection", "a header-protected payload has a duplicate protected header field " + JSON.stringify(dup));
309
+ // RFC 9788 sec. 4.3.1: for an ENCRYPTED payload (hp="cipher"), a protected field is end-to-end confidential
310
+ // (encrypted-only) unless an HP-Outer record carries its EXACT name + value -- i.e. it was copied verbatim
311
+ // to the visible outer section. A field with no matching HP-Outer (removed, or obscured to a different outer
312
+ // value) was made confidential. This authenticated set lets a caller reply/forward without leaking it
313
+ // (sec. 6.1). Signed-only (clear) payloads carry no HP-Outer, so nothing is confidential.
314
+ var confidential = [];
315
+ if (hp === "cipher") {
316
+ Object.keys(protectedHeaders).forEach(function (name) {
317
+ var ln = name.toLowerCase(), val = protectedRaw[name].trim(), exposed = false; // octet-exact (latin1), not lossy-decoded
318
+ for (var i = 0; i < refouter.length; i++) { if (refouter[i].name === ln && refouter[i].value === val) { exposed = true; break; } }
319
+ if (!exposed) confidential.push(name);
320
+ });
321
+ }
322
+ // Inspect EVERY outer From (outerEnt.header returns only the FIRST; an attacker may append a second, forged
323
+ // From that a MUA displays) -- a duplicate outer From, or any that disagrees with the protected From, is flagged.
324
+ var outerFroms = [];
325
+ outerEnt.headers.forEach(function (h) { if (h.lname === "from") outerFroms.push(h.value.trim()); }); // byte-preserving (latin1), compared octet-for-octet against the protected From
326
+ // A mismatch is ANYTHING but exactly one outer From equal to the protected one: a removed outer From (a
327
+ // transport/attacker strips it), a duplicate, or a differing value all flag tampering of the displayed sender.
328
+ var fromMismatch = innerFrom != null && (outerFroms.length !== 1 || outerFroms[0] !== innerFrom.trim());
329
+ return { protectedHeaders: protectedHeaders, headerProtection: { present: true, mode: hp, fromMismatch: fromMismatch, confidential: confidential } };
330
+ }
331
+
74
332
  // Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic -- it forwards any signer).
75
333
  function _cmsSignOpts(opts, detached) {
76
334
  var o = { detached: detached };
@@ -132,29 +390,41 @@ function _base64Body(der) {
132
390
  * - `"pkcs7-mime"` (opaque): one `application/pkcs7-mime; smime-type=signed-data` entity whose base64
133
391
  * body is an ATTACHED CMS SignedData over the canonical entity.
134
392
  * The signed bytes are the entity's RFC 8551 sec. 3.1.1 canonical form (CRLF line endings); the SAME
135
- * canonicalizer runs on verify. Returns the assembled message bytes. Fail-closed with `SmimeError`.
393
+ * canonicalizer runs on verify. With `opts.protectHeaders`, the message is header-protected (RFC 9788): the
394
+ * caller's `opts.headers` are inlined on the Cryptographic Payload root -- its Content-Type gains `hp="clear"`
395
+ * -- so the signature covers them, and copied to the outer display headers; `verify` surfaces the
396
+ * authenticated inner set. Returns the assembled message bytes. Fail-closed with `SmimeError`.
136
397
  *
137
398
  * @opts form `"multipart"` (default) or `"pkcs7-mime"`.
138
399
  * @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
139
400
  * @opts contentType the wrapped entity's Content-Type (default `text/plain; charset=utf-8`).
140
401
  * @opts signingTime a `Date` for the CMS signing-time attribute, or false to omit it.
402
+ * @opts protectHeaders enable RFC 9788 header protection (`hp="clear"`) -- inline `opts.headers` on the signed payload + the outer display headers.
403
+ * @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`.
141
404
  * @example
142
405
  * var msg = await pki.smime.sign(Buffer.from("hello"), [{ cert: signerCertDer, key: signerKeyPkcs8 }]);
143
406
  */
144
407
  async function sign(content, signers, opts) {
145
408
  opts = opts || {};
146
- var entity = _entityBytes(content, opts);
409
+ // RFC 9788 header protection (opts.protectHeaders): the inner Cryptographic Payload gains hp="clear" +
410
+ // the inlined protected fields, and the outer frame carries the display copies. Off by default => the
411
+ // shipped path, byte-for-byte.
412
+ var hp = opts.protectHeaders === true;
413
+ var hpList = hp ? _hpHeaderList(opts.headers) : null; // ONE snapshot for both the signed inner + outer display copies
414
+ var outerList = hp ? _outerHeaderList(hpList, "clear", opts.hcp) : null;
415
+ var entity = hp ? _protectedInnerEntity(content, opts, "clear", hpList, outerList) : _entityBytes(content, opts);
416
+ var outer = hp ? _outerPrefix(outerList) : "";
147
417
  var form = opts.form || "multipart";
148
418
  if (form === "pkcs7-mime") {
149
419
  var p7m = await cms.sign(entity, signers, _cmsSignOpts(opts, false));
150
- var head = Buffer.from("Content-Type: application/pkcs7-mime; smime-type=signed-data; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "latin1");
420
+ var head = Buffer.from(outer + "Content-Type: application/pkcs7-mime; smime-type=signed-data; name=smime.p7m\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7m\r\n\r\n", "utf8");
151
421
  return _capped(Buffer.concat([head, _base64Body(_toBuf(p7m)), mime.CRLF]));
152
422
  }
153
423
  if (form !== "multipart") throw _err("smime/bad-input", "form must be \"multipart\" or \"pkcs7-mime\"");
154
424
  var p7s = _toBuf(await cms.sign(entity, signers, _cmsSignOpts(opts, true)));
155
425
  var micalg = _micalgOf(p7s) || "sha-256";
156
426
  var boundary = _boundary();
157
- var head2 = Buffer.from("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n\r\n", "latin1");
427
+ var head2 = Buffer.from(outer + "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=" + micalg + "; boundary=\"" + boundary + "\"\r\n\r\n", "utf8");
158
428
  var sigPart = Buffer.concat([
159
429
  Buffer.from("Content-Type: application/pkcs7-signature; name=smime.p7s\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename=smime.p7s\r\n\r\n", "latin1"),
160
430
  _base64Body(p7s),
@@ -174,10 +444,10 @@ function _capped(msg) {
174
444
 
175
445
  /**
176
446
  * @primitive pki.smime.verify
177
- * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg }>
447
+ * @signature pki.smime.verify(message, opts?) -> Promise<{ valid, signers, form, content, micalg, protectedHeaders, headerProtection }>
178
448
  * @since 0.2.25
179
449
  * @status experimental
180
- * @spec RFC 8551, RFC 5652
450
+ * @spec RFC 8551, RFC 5652, RFC 9788
181
451
  * @related pki.smime.sign, pki.cms.verify, pki.path.validate
182
452
  *
183
453
  * Unwrap and verify a signed S/MIME message (RFC 8551), both `multipart/signed` and
@@ -188,6 +458,13 @@ function _capped(msg) {
188
458
  * entity bytes), and the `micalg`. Like `cms.verify`, this returns the cryptographic verdict only --
189
459
  * chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
190
460
  * that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
461
+ * If the message is header-protected (RFC 9788), `protectedHeaders` is the AUTHENTICATED inner header set (a
462
+ * tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential }`
463
+ * -- `fromMismatch` flags an outer From differing from the protected one; `confidential` lists the protected
464
+ * fields the composer kept end-to-end confidential (per the authenticated HP-Outer records, RFC 9788 sec. 4.3;
465
+ * only populated for an encrypted `hp="cipher"` payload). A non-protected message reports `protectedHeaders: null`.
466
+ * A payload whose declared `hp` is malformed, invalid, or contradicts the envelope fails closed
467
+ * (`smime/bad-header-protection`), never a silent downgrade.
191
468
  *
192
469
  * @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
193
470
  * @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
@@ -208,7 +485,7 @@ async function verify(message, opts) {
208
485
  var inner;
209
486
  try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
210
487
  catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
211
- return { valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null };
488
+ return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid));
212
489
  }
213
490
  if (ct.type === "multipart/signed") {
214
491
  if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
@@ -235,7 +512,7 @@ async function verify(message, opts) {
235
512
  if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
236
513
  throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
237
514
  }
238
- return { valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg };
515
+ return Object.assign({ valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid));
239
516
  }
240
517
  throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
241
518
  }
@@ -289,11 +566,18 @@ function _cmsEncryptOpts(opts) {
289
566
  * has ONE form -- opaque `application/pkcs7-mime` with the whole entity base64-encoded. The `smime-type`
290
567
  * is derived from the produced CMS: AES-GCM (the default) yields an AuthEnvelopedData with
291
568
  * `smime-type=authEnveloped-data` (confidentiality AND integrity); a CBC choice yields an EnvelopedData
292
- * with `smime-type=enveloped-data` (confidentiality only -- no integrity, RFC 8551 sec. 3.3). Returns the
293
- * assembled message bytes. Fail-closed with `SmimeError`.
569
+ * with `smime-type=enveloped-data` (confidentiality only -- no integrity, RFC 8551 sec. 3.3). With
570
+ * `opts.protectHeaders`, the message is header-protected (RFC 9788): the REAL `opts.headers` are inlined
571
+ * inside the ciphertext (the payload Content-Type gains `hp="cipher"`), and only the Header-Confidentiality-
572
+ * Policy-processed display copies appear outside -- the default `hcp_baseline` obscures Subject to `[...]`
573
+ * and removes Comments / Keywords, so those values live only in the ciphertext; `decrypt` recovers the real
574
+ * inner set. Returns the assembled message bytes. Fail-closed with `SmimeError`.
294
575
  *
295
576
  * @opts entity treat `content` as a complete MIME entity (default: wrap it as text/plain).
296
577
  * @opts contentType the wrapped entity's MIME Content-Type (default `text/plain; charset=utf-8`).
578
+ * @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.
579
+ * @opts headers the Non-Structural fields to protect (object `{ Name: value }` or array `[{ name, value }]`); the real values, hidden by the HCP.
580
+ * @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`.
297
581
  * @opts contentEncryptionAlgorithm forwarded to cms.encrypt: `"aes-256-gcm"` (default) / `"aes-128-gcm"` / `"aes-256-cbc"` / `"aes-128-cbc"`.
298
582
  * @opts oaepHash forwarded: the RSAES-OAEP hash for ktri recipients.
299
583
  * @opts keyIdentifier forwarded: `"issuerAndSerial"` (default) or `"subjectKeyIdentifier"`.
@@ -303,21 +587,30 @@ function _cmsEncryptOpts(opts) {
303
587
  */
304
588
  async function encrypt(content, recipients, opts) {
305
589
  opts = opts || {};
306
- var entity = _entityBytes(content, opts);
590
+ // RFC 9788 header protection (opts.protectHeaders): the inner payload carries hp="cipher" + the REAL
591
+ // headers (inside the ciphertext); the outer frame carries only the Header-Confidentiality-Policy-processed
592
+ // display copies (hcp_baseline obscures Subject to [...], removes Comments/Keywords). Off => shipped path.
593
+ var hp = opts.protectHeaders === true;
594
+ var hpList = hp ? _hpHeaderList(opts.headers) : null; // ONE snapshot for both the encrypted inner + outer display copies
595
+ var outerList = hp ? _outerHeaderList(hpList, "cipher", opts.hcp) : null;
596
+ var entity = hp ? _protectedInnerEntity(content, opts, "cipher", hpList, outerList) : _entityBytes(content, opts);
597
+ // Build the outer prefix (opts.headers + opts.hcp were validated when outerList was computed) BEFORE the
598
+ // expensive cms.encrypt, so a bad policy / header fails closed without doing the crypto.
599
+ var outer = hp ? Buffer.from(_outerPrefix(outerList), "utf8") : Buffer.alloc(0);
307
600
  // Normalize a single descriptor to an array so cms.encrypt always takes the ENVELOPED path (an array of
308
601
  // RecipientInfos), never bare EncryptedData -- which is not an S/MIME construct (no smime-type maps to it).
309
602
  var recips = Array.isArray(recipients) ? recipients : [recipients];
310
603
  var der = _toBuf(await cms.encrypt(entity, recips, _cmsEncryptOpts(opts)));
311
604
  var head = _pkcs7MimeHead(_envelopedTypeOf(der, "smime/bad-mime"), "smime.p7m");
312
- return _capped(Buffer.concat([head, _base64Body(der), mime.CRLF]));
605
+ return _capped(Buffer.concat([outer, head, _base64Body(der), mime.CRLF]));
313
606
  }
314
607
 
315
608
  /**
316
609
  * @primitive pki.smime.decrypt
317
- * @signature pki.smime.decrypt(message, keyMaterial, opts?) -> Promise<{ content, smimeType, authenticated, recipientType, recipientIndex, contentEncryptionAlgorithm }>
610
+ * @signature pki.smime.decrypt(message, keyMaterial, opts?) -> Promise<{ content, smimeType, authenticated, recipientType, recipientIndex, contentEncryptionAlgorithm, protectedHeaders, headerProtection }>
318
611
  * @since 0.2.26
319
612
  * @status experimental
320
- * @spec RFC 8551, RFC 5652, RFC 5083
613
+ * @spec RFC 8551, RFC 5652, RFC 5083, RFC 9788
321
614
  * @related pki.smime.encrypt, pki.cms.decrypt, pki.smime.verify
322
615
  *
323
616
  * Open an encrypted S/MIME message (RFC 8551 sec. 3.3 / sec. 3.4) -- an `application/pkcs7-mime` entity
@@ -329,7 +622,12 @@ async function encrypt(content, recipients, opts) {
329
622
  * oracle-free: every secret-dependent failure collapses to the uniform `cms/decrypt-failed` the CMS layer
330
623
  * emits (this layer only propagates it). A recovered `content` that is itself a signed S/MIME message is
331
624
  * returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
332
- * legacy `application/x-pkcs7-mime` and a missing `smime-type`.
625
+ * legacy `application/x-pkcs7-mime` and a missing `smime-type`. If the decrypted payload is header-protected
626
+ * (RFC 9788, `hp="cipher"`), `protectedHeaders` is the recovered REAL inner header set (the values the outer
627
+ * Header Confidentiality Policy hid) and `headerProtection` is `{ present, mode, fromMismatch, confidential }`,
628
+ * where `confidential` names the fields the composer kept end-to-end confidential (via the authenticated
629
+ * HP-Outer records, RFC 9788 sec. 4.3) -- so a caller can reply/forward without leaking them (sec. 6.1); a
630
+ * payload whose `hp` is malformed or contradicts the envelope fails closed (`smime/bad-header-protection`).
333
631
  *
334
632
  * @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
335
633
  * @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
@@ -356,11 +654,11 @@ async function decrypt(message, keyMaterial, opts) {
356
654
  if (opts.recipientIndex !== undefined) cmsOpts.recipientIndex = opts.recipientIndex;
357
655
  if (opts.maxIterations !== undefined) cmsOpts.maxIterations = opts.maxIterations;
358
656
  var res = await cms.decrypt(der, keyMaterial, cmsOpts);
359
- return {
657
+ return Object.assign({
360
658
  content: res.content, smimeType: smimeType, authenticated: res.authenticated,
361
659
  recipientType: res.recipientType, recipientIndex: res.recipientIndex,
362
660
  contentEncryptionAlgorithm: res.contentEncryptionAlgorithm,
363
- };
661
+ }, _hpSurface(res.content, ent, "cipher", res.authenticated));
364
662
  }
365
663
 
366
664
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:b01f035a-7844-490a-a236-5c901a15cfa3",
5
+ "serialNumber": "urn:uuid:e6deeec3-0517-4316-9e40-caa107241321",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-26T07:13:35.044Z",
8
+ "timestamp": "2026-07-26T18:13:45.078Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.3.21",
22
+ "bom-ref": "@blamejs/pki@0.3.23",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.3.21",
25
+ "version": "0.3.23",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.3.21",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.3.23",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.3.21",
57
+ "ref": "@blamejs/pki@0.3.23",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]