@blamejs/core 0.7.24 → 0.7.39

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/mail-dkim.js CHANGED
@@ -63,6 +63,12 @@ var ALLOWED_CANON = [
63
63
  ];
64
64
  var DEFAULT_HEADERS = ["from", "to", "subject", "date", "message-id"];
65
65
 
66
+ // RSA modulus bit-size thresholds per RFC 8301 §3.1 + M³AAWG hardening
67
+ // guidance. Anything below MIN must be considered failure; below WEAK
68
+ // emits a warning so operators can quarantine while transitioning.
69
+ var RSA_MIN_BITS = 1024; // allow:raw-byte-literal — RFC 8301 RSA bit floor
70
+ var RSA_WEAK_BITS = 2048; // allow:raw-byte-literal — RFC 8301 RSA bit weak threshold
71
+
66
72
  // ---- Canonicalization (RFC 6376 §3.4) ----
67
73
 
68
74
  function _canonHeaderRelaxed(name, value) {
@@ -184,11 +190,13 @@ function _foldSignatureHeader(unfolded) {
184
190
  var name = "DKIM-Signature: ";
185
191
  var rest = unfolded;
186
192
  if ((name + rest).length <= maxLine) return name + rest;
187
- // Fold on tag boundaries (`; tag=value`). Keep the first chunk on
188
- // the header line, subsequent chunks on continuation lines starting
189
- // with TAB.
193
+ // Fold on tag boundaries (`; tag=value`). Each non-last chunk keeps
194
+ // its trailing `;` (RFC 6376 §3.2 `;` is the tag-list separator,
195
+ // not a tag-end terminator; receivers' parsers expect it on the
196
+ // PRIOR line at fold points). Earlier shape ("v=1\r\n\ta=...;")
197
+ // missed the first separator and tripped strict parsers.
190
198
  var parts = rest.split("; ");
191
- var lines = [name + parts[0]];
199
+ var lines = [name + parts[0] + (parts.length > 1 ? ";" : "")];
192
200
  for (var i = 1; i < parts.length; i++) {
193
201
  lines.push("\t" + parts[i] + (i < parts.length - 1 ? ";" : ""));
194
202
  }
@@ -379,6 +387,301 @@ function create(opts) {
379
387
  // Both signers are constructed eagerly at create-time (configuration
380
388
  // errors surface at boot, not at first send). The combined sign()
381
389
  // applies the RSA signer first, then the Ed25519 signer on top.
390
+ // ---- DKIM-Signature verification (RFC 6376 §6) ----
391
+ //
392
+ // Counterpart to the signer. Walks every DKIM-Signature header in
393
+ // the message, parses the tag list, fetches the signing public key
394
+ // from DNS TXT at <selector>._domainkey.<domain>, canonicalizes the
395
+ // body + headers per the c= tag, and runs nodeCrypto.verify.
396
+ //
397
+ // Surface:
398
+ //
399
+ // var rv = await b.mail.dkim.verify(rfc822, {
400
+ // dnsLookup: async function (qname, type) { return [["v=DKIM1; k=rsa; p=BASE64..."]]; },
401
+ // });
402
+ // // → [{ d, s, alg, result, errors }, ...]
403
+ //
404
+ // One result per DKIM-Signature header (operators expect multiple
405
+ // when senders dual-sign with RSA + Ed25519). Each result's `result`
406
+ // is one of: "pass" / "fail" / "permerror" / "temperror" / "neutral".
407
+ //
408
+ // dnsLookup is operator-supplied so verify() composes with the
409
+ // framework's b.network.dns (DoH / DoT / system) without taking a
410
+ // hard dependency on it. When omitted, falls back to node:dns.
411
+
412
+ function _parseDkimTagList(value) {
413
+ // RFC 6376 §3.2 — tags are `key=value` separated by `;`. Whitespace
414
+ // around `=` and `;` is allowed and stripped. The signer folds the
415
+ // DKIM-Signature header across CRLF + WSP; unfold first so tag
416
+ // boundaries land in the right place.
417
+ var unfolded = String(value).replace(/\r?\n[ \t]+/g, " ");
418
+ var tags = {};
419
+ var parts = unfolded.split(";");
420
+ for (var i = 0; i < parts.length; i += 1) {
421
+ var p = parts[i].trim();
422
+ if (p.length === 0) continue;
423
+ var eq = p.indexOf("=");
424
+ if (eq === -1) continue;
425
+ var key = p.slice(0, eq).trim().toLowerCase();
426
+ var val = p.slice(eq + 1).trim();
427
+ // Whitespace inside values is unfolded — RFC 6376 §3.2 says FWS
428
+ // (folding whitespace) is ignored within a tag value. Strip
429
+ // newlines + tabs while preserving the meaningful tokens.
430
+ val = val.replace(/\s+/g, "");
431
+ tags[key] = val;
432
+ }
433
+ return tags;
434
+ }
435
+
436
+ function _selectorTxtToKeyTags(txtRecords) {
437
+ // DKIM key record is a TXT record at <selector>._domainkey.<domain>.
438
+ // Format: "v=DKIM1; k=rsa; p=<base64>" (chunks may be split across
439
+ // multi-string TXT). Returns { v, k, p, t, h } or throws.
440
+ var joined = "";
441
+ if (Array.isArray(txtRecords)) {
442
+ for (var i = 0; i < txtRecords.length; i += 1) {
443
+ var rec = txtRecords[i];
444
+ joined = Array.isArray(rec) ? rec.join("") : String(rec);
445
+ if (joined.indexOf("v=DKIM1") === 0 || joined.indexOf("p=") !== -1) break;
446
+ }
447
+ } else {
448
+ joined = String(txtRecords || "");
449
+ }
450
+ if (joined.length === 0) {
451
+ throw new DkimError("dkim/key-not-found", "DKIM key record is empty");
452
+ }
453
+ return _parseDkimTagList(joined);
454
+ }
455
+
456
+ // Process-local cache for fetched DKIM key TXT records. Mailing-list
457
+ // fan-out and bulk-replay scenarios frequently re-fetch the same
458
+ // selector; without a cache the verifier hammers DNS. TTL bounded so
459
+ // rotated keys propagate within minutes, not hours.
460
+ var DKIM_KEY_CACHE = new Map();
461
+ var DKIM_KEY_CACHE_TTL_MS = 5 * 60 * 1000; // allow:raw-time-literal — TTL ms expression
462
+ var DKIM_KEY_CACHE_MAX_ENTRIES = 1024;
463
+
464
+ function _cacheGet(qname) {
465
+ var ent = DKIM_KEY_CACHE.get(qname);
466
+ if (!ent) return null;
467
+ if (ent.expires <= Date.now()) {
468
+ DKIM_KEY_CACHE.delete(qname);
469
+ return null;
470
+ }
471
+ return ent.tags;
472
+ }
473
+
474
+ function _cachePut(qname, tags) {
475
+ if (DKIM_KEY_CACHE.size >= DKIM_KEY_CACHE_MAX_ENTRIES) {
476
+ // Drop oldest (Map preserves insertion order). Cheap LRU-ish.
477
+ var oldest = DKIM_KEY_CACHE.keys().next().value;
478
+ if (oldest !== undefined) DKIM_KEY_CACHE.delete(oldest);
479
+ }
480
+ DKIM_KEY_CACHE.set(qname, { tags: tags, expires: Date.now() + DKIM_KEY_CACHE_TTL_MS });
481
+ }
482
+
483
+ function _resetDkimKeyCacheForTest() { DKIM_KEY_CACHE.clear(); }
484
+
485
+ function _pemFromB64KeyMaterial(b64) {
486
+ // RSA: SubjectPublicKeyInfo DER in base64. Ed25519: raw 32-byte key
487
+ // OR SPKI DER. Wrap in PEM markers so node:crypto.createPublicKey
488
+ // accepts it.
489
+ var pem = "-----BEGIN PUBLIC KEY-----\n";
490
+ // 64-char wrap (PEM convention).
491
+ for (var i = 0; i < b64.length; i += 64) { // allow:raw-byte-literal — PEM wrap width
492
+ pem += b64.slice(i, i + 64) + "\n"; // allow:raw-byte-literal — PEM wrap width
493
+ }
494
+ pem += "-----END PUBLIC KEY-----\n";
495
+ return pem;
496
+ }
497
+
498
+ async function _fetchDkimKey(domain, selector, dnsLookup) {
499
+ var qname = selector + "._domainkey." + domain;
500
+ var cached = _cacheGet(qname);
501
+ if (cached) return cached;
502
+ var records;
503
+ try {
504
+ if (dnsLookup) {
505
+ records = await dnsLookup(qname, "TXT");
506
+ } else {
507
+ var dnsModule = require("node:dns/promises");
508
+ records = await dnsModule.resolveTxt(qname);
509
+ }
510
+ } catch (e) {
511
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) {
512
+ throw new DkimError("dkim/key-not-found",
513
+ "no DKIM TXT record at " + qname);
514
+ }
515
+ throw new DkimError("dkim/key-lookup-temperror",
516
+ "DKIM TXT lookup for " + qname + " failed: " +
517
+ ((e && e.message) || String(e)));
518
+ }
519
+ var tags = _selectorTxtToKeyTags(records);
520
+ _cachePut(qname, tags);
521
+ return tags;
522
+ }
523
+
524
+ function _findDkimSignatureHeaders(parsedHeaders) {
525
+ var out = [];
526
+ for (var i = 0; i < parsedHeaders.length; i += 1) {
527
+ if (parsedHeaders[i].name.toLowerCase() === "dkim-signature") {
528
+ out.push({ index: i, name: parsedHeaders[i].name, value: parsedHeaders[i].value });
529
+ }
530
+ }
531
+ return out;
532
+ }
533
+
534
+ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTags) {
535
+ // Reconstruct what the signer canonicalized, per RFC 6376 §3.7.
536
+ var canonicalization = sigTags.c || "simple/simple";
537
+ var canonHeader = canonicalization.split("/")[0];
538
+ var canonBody = canonicalization.split("/")[1];
539
+ var algorithm = sigTags.a;
540
+
541
+ var split = _splitHeadersBody(rfc822);
542
+ var body = split.body;
543
+ if (sigTags.l !== undefined) {
544
+ // The framework refuses l= at SIGN-time per the M3AAWG / Gmail /
545
+ // Microsoft 365 guidance (v0.7.18). On VERIFY, an `l=` tag on an
546
+ // inbound signature signals append-after-signature exposure —
547
+ // operators decide acceptance. Honor the cap for the body hash so
548
+ // the signature still validates against legitimate senders that
549
+ // use l=, but flag in the result.
550
+ var lcap = parseInt(sigTags.l, 10);
551
+ if (isFinite(lcap) && lcap >= 0) body = body.slice(0, lcap);
552
+ }
553
+
554
+ // 1. Body-hash check.
555
+ var expectedBh = sigTags.bh;
556
+ if (typeof expectedBh !== "string") {
557
+ return { result: "permerror", errors: ["DKIM-Signature missing bh="] };
558
+ }
559
+ var actualBh = _bodyHashB64(body, algorithm, canonBody);
560
+ if (actualBh !== expectedBh) {
561
+ return { result: "fail", errors: ["body hash mismatch"] };
562
+ }
563
+
564
+ // 2. Canonicalize the headers in h= order, then the DKIM-Signature
565
+ // header itself with the b= value emptied (per §3.7).
566
+ var headerNames = (sigTags.h || "").split(":").map(function (s) {
567
+ return s.trim().toLowerCase();
568
+ });
569
+ var lcNames = parsedHeaders.map(function (h) { return h.name.toLowerCase(); });
570
+ var canonicalizedHeaders = "";
571
+ for (var j = 0; j < headerNames.length; j += 1) {
572
+ var want = headerNames[j];
573
+ if (want.length === 0) continue;
574
+ var idx = lcNames.lastIndexOf(want); // last-occurrence per the DKIM spec
575
+ if (idx === -1) continue;
576
+ var h = parsedHeaders[idx];
577
+ canonicalizedHeaders += canonHeader === "simple"
578
+ ? _canonHeaderSimple(h.name, h.value)
579
+ : _canonHeaderRelaxed(h.name, h.value);
580
+ }
581
+ // Strip the b= value from the DKIM-Signature header for the canonical
582
+ // form per §3.7.
583
+ var unsignedSigValue = sigHeader.value.replace(/(\bb=)[^;]*/i, "$1");
584
+ canonicalizedHeaders += canonHeader === "simple"
585
+ ? _canonHeaderSimple("DKIM-Signature", " " + unsignedSigValue).replace(/\r\n$/, "")
586
+ : _canonHeaderRelaxed("DKIM-Signature", unsignedSigValue).replace(/\r\n$/, "");
587
+
588
+ // 3. Verify the signature.
589
+ var sigB64 = sigTags.b;
590
+ if (typeof sigB64 !== "string") {
591
+ return { result: "permerror", errors: ["DKIM-Signature missing b="] };
592
+ }
593
+ var sigBuf = Buffer.from(sigB64, "base64");
594
+ var pem = _pemFromB64KeyMaterial(keyTags.p);
595
+ var keyObj;
596
+ try { keyObj = nodeCrypto.createPublicKey(pem); }
597
+ catch (e) {
598
+ return { result: "permerror",
599
+ errors: ["DKIM key parse failed: " + ((e && e.message) || String(e))] };
600
+ }
601
+
602
+ var nodeAlgo = algorithm === "rsa-sha256" ? "sha256" :
603
+ algorithm === "ed25519-sha256" ? null : null;
604
+ if (algorithm !== "rsa-sha256" && algorithm !== "ed25519-sha256") {
605
+ return { result: "permerror",
606
+ errors: ["unsupported DKIM algorithm '" + algorithm + "'"] };
607
+ }
608
+
609
+ // Key-size enforcement (RFC 8301 §3.1, M³AAWG hardening guidance):
610
+ // RSA keys < 1024 bits MUST be considered failure; < 2048 is weak.
611
+ // The framework rejects < 1024 as a security baseline; < 2048 emits
612
+ // a warning in the result so operators can quarantine.
613
+ var warnings = [];
614
+ if (algorithm === "rsa-sha256" && keyObj.asymmetricKeyType === "rsa") {
615
+ var modBits = (keyObj.asymmetricKeyDetails && keyObj.asymmetricKeyDetails.modulusLength) || 0;
616
+ if (modBits > 0 && modBits < RSA_MIN_BITS) {
617
+ return { result: "fail",
618
+ errors: ["RSA key too small: " + modBits + " bits (RFC 8301 §3.1 minimum " + RSA_MIN_BITS + ")"] };
619
+ }
620
+ if (modBits > 0 && modBits < RSA_WEAK_BITS) {
621
+ warnings.push("rsa-key-weak: " + modBits + " bits (< " + RSA_WEAK_BITS + ")");
622
+ }
623
+ }
624
+ if (sigTags.l !== undefined) {
625
+ warnings.push("l-tag-present: append-after-signature exposure (RFC 6376 §8.2)");
626
+ }
627
+
628
+ var verified;
629
+ try {
630
+ verified = nodeCrypto.verify(nodeAlgo,
631
+ Buffer.from(canonicalizedHeaders, "utf8"), keyObj, sigBuf);
632
+ } catch (e) {
633
+ return { result: "permerror",
634
+ errors: ["DKIM verify threw: " + ((e && e.message) || String(e))] };
635
+ }
636
+ return verified
637
+ ? { result: "pass", errors: [], warnings: warnings }
638
+ : { result: "fail", errors: ["signature verification failed"], warnings: warnings };
639
+ }
640
+
641
+ async function verify(rfc822, opts) {
642
+ if (typeof rfc822 !== "string" || rfc822.length === 0) {
643
+ throw new DkimError("dkim/bad-input",
644
+ "verify(): rfc822 must be a non-empty string");
645
+ }
646
+ opts = opts || {};
647
+ validateOpts(opts, ["dnsLookup", "audit"], "mail.dkim.verify");
648
+
649
+ var split = _splitHeadersBody(rfc822);
650
+ var parsedHeaders = _parseHeaders(split.headers);
651
+ var sigHeaders = _findDkimSignatureHeaders(parsedHeaders);
652
+ if (sigHeaders.length === 0) {
653
+ return [{ result: "none", errors: ["no DKIM-Signature headers"] }];
654
+ }
655
+
656
+ var results = [];
657
+ for (var i = 0; i < sigHeaders.length; i += 1) {
658
+ var sigTags = _parseDkimTagList(sigHeaders[i].value);
659
+ var d = sigTags.d;
660
+ var s = sigTags.s;
661
+ var alg = sigTags.a;
662
+ if (!d || !s) {
663
+ results.push({ d: d || null, s: s || null, alg: alg || null,
664
+ result: "permerror", errors: ["DKIM-Signature missing d= or s="] });
665
+ continue;
666
+ }
667
+ var keyTags;
668
+ try { keyTags = await _fetchDkimKey(d, s, opts.dnsLookup); }
669
+ catch (e) {
670
+ var verdict = e.code === "dkim/key-lookup-temperror" ? "temperror" : "permerror";
671
+ results.push({ d: d, s: s, alg: alg, result: verdict, errors: [e.message] });
672
+ continue;
673
+ }
674
+ if (!keyTags.p) {
675
+ results.push({ d: d, s: s, alg: alg, result: "permerror",
676
+ errors: ["DKIM key record missing p="] });
677
+ continue;
678
+ }
679
+ var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags);
680
+ results.push(Object.assign({ d: d, s: s, alg: alg }, rv));
681
+ }
682
+ return results;
683
+ }
684
+
382
685
  function dualSigner(opts) {
383
686
  if (!opts || !opts.rsa || !opts.eddsa) {
384
687
  throw new DkimError("dkim/dual-signer-missing",
@@ -412,6 +715,8 @@ function dualSigner(opts) {
412
715
  // directly without going through a full sign() round.
413
716
  module.exports = {
414
717
  create: create,
718
+ verify: verify,
719
+ _resetDkimKeyCacheForTest: _resetDkimKeyCacheForTest,
415
720
  dualSigner: dualSigner,
416
721
  DkimError: DkimError,
417
722
  _canonHeaderRelaxedForTest: _canonHeaderRelaxed,
package/lib/mail.js CHANGED
@@ -70,6 +70,7 @@ var audit = lazyRequire(function () { return require("./audit"); });
70
70
  var httpClient = lazyRequire(function () { return require("./http-client"); });
71
71
  var guardEmail = lazyRequire(function () { return require("./guard-email"); });
72
72
  var mailDkim = require("./mail-dkim");
73
+ var mailAuth = require("./mail-auth");
73
74
  var net = lazyRequire(function () { return require("net"); });
74
75
  var tls = lazyRequire(function () { return require("tls"); });
75
76
  var safeJson = require("./safe-json");
@@ -968,6 +969,13 @@ module.exports = {
968
969
  // default, ed25519-sha256 opt-in). Wire it into the smtp transport
969
970
  // via opts.dkimSigner. See lib/mail-dkim.js for the full surface.
970
971
  dkim: mailDkim,
972
+ // Inbound mail authentication-results verification: SPF (RFC 7208),
973
+ // DMARC (RFC 7489), ARC (RFC 8617). Outbound DKIM signing lives in
974
+ // .dkim above; per-hop DKIM verification is deferred (composes with
975
+ // the existing canonicalization helpers in lib/mail-dkim.js).
976
+ spf: mailAuth.spf,
977
+ dmarc: mailAuth.dmarc,
978
+ arc: mailAuth.arc,
971
979
  // Test-only export: lets unit tests inspect the wire format without
972
980
  // standing up a TLS-capable SMTP fixture. Operators don't call this.
973
981
  _buildRfc822ForTest: _buildRfc822,