@blamejs/pki 0.3.23 → 0.3.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -2
- package/index.js +3 -1
- package/lib/constants.js +15 -0
- package/lib/est.js +3 -33
- package/lib/http-transport.js +95 -6
- package/lib/inspect.js +20 -0
- package/lib/path-validate.js +286 -19
- package/lib/schema-cms.js +45 -0
- package/lib/schema-pkix.js +25 -0
- package/lib/smime.js +204 -74
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/smime.js
CHANGED
|
@@ -228,32 +228,195 @@ function _declaresHp(content) {
|
|
|
228
228
|
return false;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// A fresh not-protected surface (returned when no header protection is present or detection fails soft). A NEW
|
|
232
|
+
// object each call -- the caller Object.assign()s it onto the verify/decrypt result, so it must not be shared.
|
|
233
|
+
// `present` + `protectedHeaders` describe ONLY cryptographically-declared (hp=) protection; `legacy` is null
|
|
234
|
+
// unless an opt-in legacy RFC8551HP inference succeeded (then it carries its own headers/mode/etc. -- see below).
|
|
235
|
+
function _noneSurface() { return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [], legacy: null } }; }
|
|
236
|
+
|
|
237
|
+
// Count the Content-Type fields in a parsed header list. A structure with more than one is AMBIGUOUS: mime.parse
|
|
238
|
+
// surfaces only the FIRST via contentType, so an hp= parameter or a Cryptographic-Layer media type on a LATER
|
|
239
|
+
// field would be invisible to the classification -- both the standard HP path and the legacy path fail closed on it.
|
|
240
|
+
function _contentTypeCount(headers) { var n = 0; headers.forEach(function (h) { if (h.lname === "content-type") n++; }); return n; }
|
|
241
|
+
|
|
242
|
+
// Header fields that must not sensibly repeat: the RFC 5322 sec. 3.6 max-1 originator/destination/identification
|
|
243
|
+
// fields, plus Return-Path (the envelope sender -- RFC 5322 sec. 3.6 groups it under trace, but RFC 5321 sec. 4.4
|
|
244
|
+
// restricts a delivered message to a single one). A duplicate of one of these is a malformed / ambiguous set (a
|
|
245
|
+
// consumer could pick a different sender / subject); a duplicate of any OTHER field (Received and the other
|
|
246
|
+
// trace fields, Resent-*, Comments, Keywords, and optional / X-* fields) is legitimately repeatable in a real
|
|
247
|
+
// message. A Set (not a plain object) so an attacker-chosen field name like "constructor" / "__proto__" cannot
|
|
248
|
+
// match an inherited Object.prototype member and false-trip the singleton reject.
|
|
249
|
+
var RFC5322_SINGLETON = new Set(["date", "from", "sender", "reply-to", "to", "cc", "bcc", "message-id", "in-reply-to", "references", "subject", "return-path"]);
|
|
250
|
+
|
|
251
|
+
// Extract the Non-Structural protected fields from a recovered inner header list. Shared by the standard HP path
|
|
252
|
+
// (over the Cryptographic Payload root) and the legacy path (over the message/rfc822 inner message, sec. 4.10).
|
|
253
|
+
// Returns { protectedHeaders (UTF-8 map, last-wins -- the ergonomic single-value view surfaced by the standard
|
|
254
|
+
// path), entries (an ORDERED array of every { name, value, raw } occurrence -- a real message repeats trace
|
|
255
|
+
// fields, and a last-wins collapse would drop all but one), innerFrom, refouter (HP-Outer records), dup (any
|
|
256
|
+
// duplicate field name), dupSingleton (a duplicate of an RFC 5322 singleton field) }.
|
|
257
|
+
function _extractProtected(headerList) {
|
|
258
|
+
// mime.parse decodes the header block as latin1 (byte-preserving), so a protected value emitted as UTF-8
|
|
259
|
+
// (RFC 6532, the guard permits it) is re-decoded latin1->UTF-8 here to round-trip intact. protectedHeaders
|
|
260
|
+
// surfaces the exact authenticated field body (rawValue: leading/trailing whitespace preserved), NOT a
|
|
261
|
+
// trimmed value that would diverge from the signed octets.
|
|
262
|
+
var protectedHeaders = Object.create(null), innerFrom = null, seen = Object.create(null), dup = null, dupSingleton = null;
|
|
263
|
+
var entries = []; // every Non-Structural { name, value } occurrence, in order -- retains legally-repeated fields
|
|
264
|
+
var refouter = []; // RFC 9788 sec. 4.2.1: the HP-Outer records (name + the value the field had in the outer section)
|
|
265
|
+
headerList.forEach(function (h) {
|
|
266
|
+
if (_isStructural(h.lname)) return;
|
|
267
|
+
if (h.lname === "hp-outer") {
|
|
268
|
+
// RFC 9788 sec. 4.2.1 step 4.i: split the HP-Outer value on the FIRST colon into (name, outer-value)
|
|
269
|
+
// -> refouter. The value is kept BYTE-PRESERVING (latin1), never UTF-8-decoded, because the sec. 4.3.1
|
|
270
|
+
// confidentiality comparison must be octet-exact (a lossy decode maps distinct invalid octets 0x80/0x81
|
|
271
|
+
// to one replacement char, which would mis-classify an obscured field as exposed and drop it from the
|
|
272
|
+
// confidential set). HP-Outer is NEVER surfaced as a protected header nor counted toward the duplicate
|
|
273
|
+
// check (sec. 2.2: it "can appear multiple times"). A valueless HP-Outer (no inner colon) is ignored.
|
|
274
|
+
var ci = h.rawValue.indexOf(":");
|
|
275
|
+
if (ci >= 0) refouter.push({ name: h.rawValue.slice(0, ci).trim().toLowerCase(), value: h.rawValue.slice(ci + 1).trim() });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
var val = _utf8Header(h.rawValue); // the exact authenticated field body (RFC 6532 UTF-8)
|
|
279
|
+
entries.push({ name: h.name, value: val, raw: h.rawValue }); // EVERY occurrence: `value` (UTF-8) for surfacing, `raw` (latin1) for the octet-exact confidentiality match
|
|
280
|
+
if (seen[h.lname]) { dup = h.name; if (RFC5322_SINGLETON.has(h.lname)) dupSingleton = h.name; }
|
|
281
|
+
seen[h.lname] = 1;
|
|
282
|
+
protectedHeaders[h.name] = val; // SURFACE (map, last-wins -- the ergonomic single-value view; `entries` keeps every occurrence + the octet-exact `raw`)
|
|
283
|
+
// innerFrom uses the byte-preserving value: a lossy UTF-8 decode maps distinct invalid 8-bit sequences (0x80
|
|
284
|
+
// vs 0x81) to the same replacement char, which would let an attacker alter the displayed From without
|
|
285
|
+
// tripping fromMismatch. Compare the raw octets instead.
|
|
286
|
+
if (h.lname === "from") innerFrom = h.value;
|
|
287
|
+
});
|
|
288
|
+
return { protectedHeaders: protectedHeaders, entries: entries, innerFrom: innerFrom, refouter: refouter, dup: dup, dupSingleton: dupSingleton };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// RFC 9788 sec. 4.3.1: a protected field occurrence is end-to-end confidential (encrypted-only) unless a
|
|
292
|
+
// `refouter` record carries its EXACT name + value -- i.e. that value was copied verbatim to the visible outer
|
|
293
|
+
// section. The match is per-OCCURRENCE and MULTISET (each outer record accounts for at most one inside
|
|
294
|
+
// occurrence), so a legally-repeated field with one obscured value and one exposed value (Keywords: secret then
|
|
295
|
+
// Keywords: public, outer exposing only "public") still reports the name confidential -- a last-wins collapse
|
|
296
|
+
// would drop the hidden "secret" and let a caller leak it. A field name is confidential if ANY of its
|
|
297
|
+
// occurrences is unexposed. The comparison is octet-exact (latin1 raw), never lossy-decoded. For the standard
|
|
298
|
+
// path `refouter` is the HP-Outer records; for the legacy path it is the actual outer Header Section (part A).
|
|
299
|
+
function _computeConfidential(entries, refouter) {
|
|
300
|
+
// Index the outer occurrences by (normalized name -> value -> available count) so the multiset comparison is
|
|
301
|
+
// O(N + M), not a per-occurrence rescan of the outer array: matching N identical exposed occurrences would
|
|
302
|
+
// otherwise be N(N+1)/2 comparisons -- a DoS an attacker could drive toward the 16 MiB section cap. A two-level
|
|
303
|
+
// Map (never a plain object) so an attacker-chosen name/value like "__proto__" cannot confuse the lookup.
|
|
304
|
+
var byName = new Map();
|
|
305
|
+
refouter.forEach(function (r) {
|
|
306
|
+
var m = byName.get(r.name); if (!m) { m = new Map(); byName.set(r.name, m); }
|
|
307
|
+
m.set(r.value, (m.get(r.value) || 0) + 1);
|
|
308
|
+
});
|
|
309
|
+
var confidential = [], seen = Object.create(null);
|
|
310
|
+
entries.forEach(function (e) {
|
|
311
|
+
var m = byName.get(e.name.toLowerCase()), val = e.raw.trim(), n = (m && m.get(val)) || 0;
|
|
312
|
+
if (n > 0) { m.set(val, n - 1); return; } // an outer occurrence accounts for (exposes) this one
|
|
313
|
+
if (!seen[e.name]) { seen[e.name] = 1; confidential.push(e.name); }
|
|
314
|
+
});
|
|
315
|
+
return confidential;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Inspect EVERY outer From (outerEnt.header returns only the FIRST; an attacker may append a second, forged From
|
|
319
|
+
// that a MUA displays). A mismatch is ANYTHING but exactly one outer From equal to the protected one: a removed
|
|
320
|
+
// outer From, a duplicate, or a differing value all flag tampering of the displayed sender.
|
|
321
|
+
function _computeFromMismatch(innerFrom, outerEnt) {
|
|
322
|
+
var outerFroms = [];
|
|
323
|
+
outerEnt.headers.forEach(function (h) { if (h.lname === "from") outerFroms.push(h.value.trim()); }); // byte-preserving (latin1)
|
|
324
|
+
return innerFrom != null && (outerFroms.length !== 1 || outerFroms[0] !== innerFrom.trim());
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Is this (lowercased) media type a Cryptographic Layer (RFC 9788 sec. 4.10.1)? Conservative: any
|
|
328
|
+
// application/pkcs7-mime (incl. an OpenSSL x-pkcs7-mime, or a compressed-data layer) plus multipart/signed and
|
|
329
|
+
// multipart/encrypted. Mis-classifying part D as a layer only degrades legacy detection to none -- the safe direction.
|
|
330
|
+
function _isCryptoLayer(type) {
|
|
331
|
+
// `type` is the already-lowercased media type from mime.parse's _parseStructured; _isPkcs7 guards the
|
|
332
|
+
// null/undefined case internally, so no local `|| ""` fallback is needed (and no dead branch is introduced).
|
|
333
|
+
return _isPkcs7(type, "mime") || type === "multipart/signed" || type === "multipart/encrypted";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The legacy `refouter`: the actual visible outer Header Section (part A) as name+value records, so the sec.
|
|
337
|
+
// 4.3.1 confidentiality comparison runs against the real outer values (a legacy message carries no HP-Outer).
|
|
338
|
+
function _outerRefouter(outerEnt) {
|
|
339
|
+
var r = [];
|
|
340
|
+
outerEnt.headers.forEach(function (h) { if (!_isStructural(h.lname) && h.lname !== "hp-outer") r.push({ name: h.lname, value: h.rawValue.trim() }); });
|
|
341
|
+
return r;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// RFC 9788 sec. 4.10 backward compatibility: identify + surface a LEGACY RFC8551HP message -- a Cryptographic
|
|
345
|
+
// Envelope whose payload is a bare message/rfc822 object carrying the real headers, with NO hp= parameter. This
|
|
346
|
+
// is DETECT-ONLY (sec. 4.10: "An MUA MUST NOT generate an RFC8551HP message ... MAY try to render") and OPT-IN
|
|
347
|
+
// (`enabled`). Identification (sec. 4.10.1) is the four conjunctive conditions C1-C4; every failure -- an
|
|
348
|
+
// unparseable inner message, a non-message/rfc822 payload, a nested Cryptographic Layer, an hp= on part D, or an
|
|
349
|
+
// ambiguous/empty protected set -- fails SOFT to none (never throws), because the message is not PRECISELY
|
|
350
|
+
// identified. On identification (sec. 4.10.2) the inner message's (part D's) Non-Structural fields are surfaced,
|
|
351
|
+
// the mode is inferred from the envelope (clear for signed, cipher for encrypted), and the confidential set is
|
|
352
|
+
// derived from the actual outer section (part A). CRUCIAL: a legacy RFC8551HP message is structurally
|
|
353
|
+
// INDISTINGUISHABLE from an ordinary signed/forwarded message/rfc822 (sec. 4.10.2: the inference is "not based
|
|
354
|
+
// on any strong end-to-end guarantees"), so the inferred set is NEVER placed in `protectedHeaders` and NEVER
|
|
355
|
+
// sets `present:true` -- those describe only cryptographically-declared hp= protection a caller may trust. The
|
|
356
|
+
// inference is surfaced ONLY under `headerProtection.legacy` (its own { headers, mode, fromMismatch, confidential }
|
|
357
|
+
// object), so a caller keying trust off `present` / `protectedHeaders` can never mistake an opted-in heuristic --
|
|
358
|
+
// possibly a forwarded attachment -- for this message's authenticated headers; consuming it is an explicit choice.
|
|
359
|
+
function _legacyHpSurface(canon, outerEnt, mode, enabled) {
|
|
360
|
+
if (!enabled) return _noneSurface();
|
|
361
|
+
var partC, partD;
|
|
362
|
+
try {
|
|
363
|
+
// C1 (outermost object is a Cryptographic Layer) holds by construction: _hpSurface only reaches here after a
|
|
364
|
+
// successful, AUTHENTICATED verify/decrypt. C2: the Cryptographic Payload must be a SINGLE message/rfc822 object.
|
|
365
|
+
partC = mime.parse(canon, SmimeError, "smime/bad-header-protection");
|
|
366
|
+
if (partC.contentType.type !== "message/rfc822") return _noneSurface();
|
|
367
|
+
partD = mime.parse(partC.body, SmimeError, "smime/bad-header-protection");
|
|
368
|
+
} catch (_e) { return _noneSurface(); } // an unparseable legacy candidate is not precisely identified -- fail soft
|
|
369
|
+
// The C2-C4 classification reads only the FIRST Content-Type of each part (mime.parse surfaces that one). A
|
|
370
|
+
// duplicate Content-Type on part C or part D is ambiguous -- a later field could carry hp= or a Cryptographic
|
|
371
|
+
// Layer media type the checks below would miss -- so fail soft, matching the standard path's duplicate reject.
|
|
372
|
+
if (_contentTypeCount(partC.headers) > 1 || _contentTypeCount(partD.headers) > 1) return _noneSurface();
|
|
373
|
+
// C4: an hp= on part D means the message is NOT legacy (sec. 4.1: a consumer MUST ignore hp outside the payload
|
|
374
|
+
// root). C3: part D must not itself be a Cryptographic Layer (a genuinely nested signed/encrypted message).
|
|
375
|
+
if (mime.hasParam(partD.contentType.value, "hp")) return _noneSurface();
|
|
376
|
+
if (_isCryptoLayer(partD.contentType.type)) return _noneSurface();
|
|
377
|
+
var ex = _extractProtected(partD.headers);
|
|
378
|
+
// Only a duplicate SINGLETON field (RFC 5322 sec. 3.6) is the ambiguous case -- part D is an ordinary received
|
|
379
|
+
// message whose trace fields (Received, ...) and other repeatable fields legitimately recur, so a repeatable
|
|
380
|
+
// duplicate must NOT reject the inference (else legacy detection fails on essentially all delivered mail).
|
|
381
|
+
if (ex.dupSingleton) return _noneSurface();
|
|
382
|
+
if (!ex.entries.length) return _noneSurface(); // no Non-Structural fields -- nothing to surface
|
|
383
|
+
var confidential = mode === "cipher" ? _computeConfidential(ex.entries, _outerRefouter(outerEnt)) : [];
|
|
384
|
+
// present stays false + protectedHeaders stays null: the inferred set is surfaced ONLY under `legacy` so a
|
|
385
|
+
// caller cannot mistake an opt-in heuristic (indistinguishable from a forwarded message/rfc822) for authenticated
|
|
386
|
+
// headers. `headers` is the ORDERED [{ name, value }] list (every occurrence -- a real message repeats trace
|
|
387
|
+
// fields); the internal `raw` per occurrence is dropped from the public shape.
|
|
388
|
+
var headers = ex.entries.map(function (e) { return { name: e.name, value: e.value }; });
|
|
389
|
+
return { protectedHeaders: null, headerProtection: { present: false, mode: null, fromMismatch: false, confidential: [], legacy: { headers: headers, mode: mode, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential } } };
|
|
390
|
+
}
|
|
391
|
+
|
|
231
392
|
// Detect + surface RFC 9788 header protection on a recovered inner entity, FAIL-CLOSED. A payload that does
|
|
232
|
-
// not declare hp surfaces protectedHeaders:null.
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// the
|
|
237
|
-
|
|
238
|
-
|
|
393
|
+
// not declare hp surfaces protectedHeaders:null -- UNLESS opts.legacyHeaderProtection is set and it is a legacy
|
|
394
|
+
// RFC8551HP message/rfc822 wrap (sec. 4.10), which _legacyHpSurface then surfaces (opt-in, fail-soft). A payload
|
|
395
|
+
// that DECLARES hp is validated: a malformed block, an invalid hp value, or an hp mode that CONTRADICTS the
|
|
396
|
+
// cryptographic envelope (expectedMode "clear" for a signed message, "cipher" for a decrypted one) throws
|
|
397
|
+
// smime/bad-header-protection -- never a silent downgrade. Otherwise the inline Non-Structural fields ARE the
|
|
398
|
+
// authenticated set; a From that differs from the untrusted OUTER From is flagged fromMismatch.
|
|
399
|
+
function _hpSurface(content, outerEnt, expectedMode, authenticated, legacyEnabled) {
|
|
239
400
|
// Header protection is an AUTHENTICATED property: surface the inner headers as protected ONLY when the
|
|
240
401
|
// cryptographic verdict succeeded (a valid signature, or an authenticated-encryption decrypt). An invalid
|
|
241
402
|
// signature or an unauthenticated (AES-CBC, no integrity) decrypt yields attacker-influenced bytes -- they
|
|
242
403
|
// are never marked or exposed as protected (a caller must not treat protectedHeaders as a trust signal
|
|
243
404
|
// unless integrity held).
|
|
244
|
-
if (!authenticated) return
|
|
405
|
+
if (!authenticated) return _noneSurface();
|
|
245
406
|
// Detect + parse the CANONICAL entity -- the exact bytes the signature covers. A transport may rewrite a
|
|
246
407
|
// CRLF fold OR the header/body separator to bare CR/LF; canonicalize repairs them (so verification still
|
|
247
408
|
// succeeds), and BOTH the hp detection and the parse must run on the repaired bytes, or they diverge from
|
|
248
409
|
// the signed content -- stripping the signal, or false-rejecting a valid message as an unparseable block.
|
|
249
410
|
// The returned `content` stays the raw recovered bytes; only this HP inspection uses the canonical copy.
|
|
250
411
|
var canon = mime.canonicalizeText(content);
|
|
251
|
-
|
|
412
|
+
// A payload that does not declare hp is either non-protected (protectedHeaders:null) or a legacy RFC8551HP
|
|
413
|
+
// message/rfc822 wrap the caller opted into detecting (sec. 4.10). Both are mutually exclusive with the
|
|
414
|
+
// standard path below by construction: it runs ONLY when a real hp= is present on the payload root.
|
|
415
|
+
if (!_declaresHp(canon)) return _legacyHpSurface(canon, outerEnt, expectedMode, legacyEnabled);
|
|
252
416
|
var inner = mime.parse(canon, SmimeError, "smime/bad-header-protection"); // a malformed HP block fails closed
|
|
253
417
|
// A duplicate Content-Type makes the hp declaration ambiguous (a parser reads params from the FIRST field,
|
|
254
418
|
// but an hp= may sit on a later one) -- a malformed wrap that fails closed, never a silent downgrade.
|
|
255
|
-
var ctCount =
|
|
256
|
-
inner.headers.forEach(function (h) { if (h.lname === "content-type") ctCount++; });
|
|
419
|
+
var ctCount = _contentTypeCount(inner.headers);
|
|
257
420
|
if (ctCount > 1) throw _err("smime/bad-header-protection", "a header-protected payload must carry exactly one Content-Type field (found " + ctCount + ")");
|
|
258
421
|
// A duplicate hp attribute is ambiguous (mime.parse keeps the LAST value, but a recipient honoring the
|
|
259
422
|
// first would see a different mode) -- fail closed, like the duplicate Content-Type. Counted by attribute
|
|
@@ -275,58 +438,14 @@ function _hpSurface(content, outerEnt, expectedMode, authenticated) {
|
|
|
275
438
|
var hp = raw.toLowerCase();
|
|
276
439
|
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
440
|
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
|
-
|
|
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
|
-
});
|
|
441
|
+
var ex = _extractProtected(inner.headers);
|
|
306
442
|
// A duplicate protected field is ambiguous (the last-wins overwrite hides an earlier value a different
|
|
307
443
|
// 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")
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
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 } };
|
|
444
|
+
if (ex.dup) throw _err("smime/bad-header-protection", "a header-protected payload has a duplicate protected header field " + JSON.stringify(ex.dup));
|
|
445
|
+
// RFC 9788 sec. 4.3.1: for an ENCRYPTED payload (hp="cipher") the confidential set is the fields not copied
|
|
446
|
+
// verbatim to the outer section via an HP-Outer record. Signed-only (clear) payloads carry no HP-Outer.
|
|
447
|
+
var confidential = hp === "cipher" ? _computeConfidential(ex.entries, ex.refouter) : [];
|
|
448
|
+
return { protectedHeaders: ex.protectedHeaders, headerProtection: { present: true, mode: hp, fromMismatch: _computeFromMismatch(ex.innerFrom, outerEnt), confidential: confidential, legacy: null } };
|
|
330
449
|
}
|
|
331
450
|
|
|
332
451
|
// Map smime opts to cms.sign opts (the S/MIME layer is algorithm-agnostic -- it forwards any signer).
|
|
@@ -459,15 +578,22 @@ function _capped(msg) {
|
|
|
459
578
|
* chaining a signer certificate to a trust anchor is the caller's `pki.path.validate` step. A `micalg`
|
|
460
579
|
* that disagrees with the actual digest is advisory unless `opts.strictMicalg` (then `smime/micalg-mismatch`).
|
|
461
580
|
* 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
|
-
* -- `
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* (`smime/bad-header-protection`),
|
|
581
|
+
* tampered outer header cannot alter it) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`
|
|
582
|
+
* -- `present` is `true` only for cryptographically-DECLARED (`hp=`) protection you may trust; `fromMismatch` flags
|
|
583
|
+
* an outer From differing from the protected one; `confidential` lists the protected fields the composer kept
|
|
584
|
+
* end-to-end confidential (per the authenticated HP-Outer records, RFC 9788 sec. 4.3; only for an encrypted
|
|
585
|
+
* `hp="cipher"` payload). A non-protected message reports `protectedHeaders: null`, `present: false`. A payload
|
|
586
|
+
* whose declared `hp` is malformed, invalid, or contradicts the envelope fails closed (`smime/bad-header-protection`),
|
|
587
|
+
* never a silent downgrade. `legacy` is `null` unless `opts.legacyHeaderProtection` detected a legacy RFC 8551
|
|
588
|
+
* `message/rfc822` wrap (see that option), in which case it is its own `{ headers, mode, fromMismatch, confidential }`
|
|
589
|
+
* object (`headers` an ordered `[{ name, value }]` array that retains legally-repeated fields like `Received`) -- a
|
|
590
|
+
* legacy inference is NEVER placed in `protectedHeaders` and never sets `present: true`, because it is
|
|
591
|
+
* indistinguishable from an ordinary forwarded `message/rfc822`, so a caller keying trust off `present` /
|
|
592
|
+
* `protectedHeaders` cannot mistake the opt-in heuristic for authenticated headers.
|
|
468
593
|
*
|
|
469
594
|
* @opts certs extra signer certificates (DER `Buffer`s) to match, forwarded to `cms.verify`.
|
|
470
595
|
* @opts strictMicalg reject a `multipart/signed` whose `micalg` disagrees with the SignerInfo digest.
|
|
596
|
+
* @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.
|
|
471
597
|
* @example
|
|
472
598
|
* var res = await pki.smime.verify(smimeMessageBytes);
|
|
473
599
|
* if (res.valid) { res.content; res.signers[0].sid; }
|
|
@@ -485,7 +611,7 @@ async function verify(message, opts) {
|
|
|
485
611
|
var inner;
|
|
486
612
|
try { inner = _toBuf(schemaCms.parse(p7m).encapContentInfo.eContent); }
|
|
487
613
|
catch (e) { throw _err("smime/bad-mime", "the pkcs7-mime SignedData has no encapsulated content", e); }
|
|
488
|
-
return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid));
|
|
614
|
+
return Object.assign({ valid: res.valid, signers: res.signers, form: "pkcs7-mime", content: inner, micalg: null }, _hpSurface(inner, ent, "clear", res.valid, opts.legacyHeaderProtection === true));
|
|
489
615
|
}
|
|
490
616
|
if (ct.type === "multipart/signed") {
|
|
491
617
|
if (ct.params.protocol && !_isPkcs7(ct.params.protocol, "signature")) throw _err("smime/bad-multipart", "multipart/signed protocol must be application/pkcs7-signature");
|
|
@@ -512,7 +638,7 @@ async function verify(message, opts) {
|
|
|
512
638
|
if (opts.strictMicalg && micalg && _micalgSet(micalg) !== (_micalgOf(p7s) || "")) {
|
|
513
639
|
throw _err("smime/micalg-mismatch", "the multipart/signed micalg " + JSON.stringify(micalg) + " disagrees with the SignerInfo digests");
|
|
514
640
|
}
|
|
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));
|
|
641
|
+
return Object.assign({ valid: res2.valid, signers: res2.signers, form: "multipart/signed", content: parts[0], micalg: micalg }, _hpSurface(parts[0], ent, "clear", res2.valid, opts.legacyHeaderProtection === true));
|
|
516
642
|
}
|
|
517
643
|
throw _err("smime/unsupported-type", "not a signed S/MIME message (Content-Type " + JSON.stringify(ct.type) + ")");
|
|
518
644
|
}
|
|
@@ -624,14 +750,18 @@ async function encrypt(content, recipients, opts) {
|
|
|
624
750
|
* returned as-is for the caller to feed back to `pki.smime.verify` (no auto-recursion). Accepts OpenSSL's
|
|
625
751
|
* legacy `application/x-pkcs7-mime` and a missing `smime-type`. If the decrypted payload is header-protected
|
|
626
752
|
* (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 `
|
|
629
|
-
* HP-Outer records, RFC 9788 sec. 4.3) -- so a caller can
|
|
630
|
-
* payload whose `hp` is malformed or contradicts the envelope
|
|
753
|
+
* Header Confidentiality Policy hid) and `headerProtection` is `{ present, mode, fromMismatch, confidential, legacy }`,
|
|
754
|
+
* where `present` is `true` only for a declared `hp=` payload, and `confidential` names the fields the composer
|
|
755
|
+
* kept end-to-end confidential (via the authenticated HP-Outer records, RFC 9788 sec. 4.3) -- so a caller can
|
|
756
|
+
* reply/forward without leaking them (sec. 6.1); a payload whose `hp` is malformed or contradicts the envelope
|
|
757
|
+
* fails closed (`smime/bad-header-protection`). `legacy` is `null` unless `opts.legacyHeaderProtection` detected a
|
|
758
|
+
* legacy RFC 8551 `message/rfc822` wrap (its own `{ headers, mode, fromMismatch, confidential }` object -- `headers`
|
|
759
|
+
* an ordered `[{ name, value }]` array -- never merged into `protectedHeaders` / `present`).
|
|
631
760
|
*
|
|
632
761
|
* @opts recipientIndex forwarded to cms.decrypt: explicitly select the recipient by index.
|
|
633
762
|
* @opts maxIterations forwarded to cms.decrypt: lower the PBKDF2 iteration cap (downward only).
|
|
634
763
|
* @opts strictSmimeType reject a header `smime-type` that disagrees with the CMS body (`smime/smime-type-mismatch`).
|
|
764
|
+
* @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.
|
|
635
765
|
* @example
|
|
636
766
|
* var res = await pki.smime.decrypt(smimeMessageBytes, { key: recipientKeyPkcs8, cert: recipientCertDer });
|
|
637
767
|
* res.content; // the recovered inner MIME entity
|
|
@@ -658,7 +788,7 @@ async function decrypt(message, keyMaterial, opts) {
|
|
|
658
788
|
content: res.content, smimeType: smimeType, authenticated: res.authenticated,
|
|
659
789
|
recipientType: res.recipientType, recipientIndex: res.recipientIndex,
|
|
660
790
|
contentEncryptionAlgorithm: res.contentEncryptionAlgorithm,
|
|
661
|
-
}, _hpSurface(res.content, ent, "cipher", res.authenticated));
|
|
791
|
+
}, _hpSurface(res.content, ent, "cipher", res.authenticated, opts.legacyHeaderProtection === true));
|
|
662
792
|
}
|
|
663
793
|
|
|
664
794
|
/**
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:5e29cae0-edec-47f9-be9b-305be03dd73c",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-27T02:30:05.287Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.25",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.25",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.3.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.3.25",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.3.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.25",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|