@blamejs/core 0.9.49 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
package/lib/mail-dkim.js CHANGED
@@ -43,6 +43,8 @@ var audit = lazyRequire(function () { return require("./audit"); });
43
43
  var nodeCrypto = require("node:crypto");
44
44
  var safeBuffer = require("./safe-buffer");
45
45
  var validateOpts = require("./validate-opts");
46
+ var C = require("./constants");
47
+ var networkDnsResolver = lazyRequire(function () { return require("./network-dns-resolver"); });
46
48
  var { FrameworkError } = require("./framework-error");
47
49
 
48
50
  class DkimError extends FrameworkError {
@@ -63,11 +65,20 @@ var ALLOWED_CANON = [
63
65
  ];
64
66
  var DEFAULT_HEADERS = ["from", "to", "subject", "date", "message-id"];
65
67
 
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
+ // RSA modulus bit-size thresholds per RFC 8301bis (draft-ietf-dmarc-rfc8301bis)
69
+ // + Google + Yahoo February 2024 bulk-sender policy + M³AAWG hardening.
70
+ // RFC 8301 §3.1 historic floor was 1024; bulk-sender enforcement at the
71
+ // two largest mailbox providers raised the operational floor to 2048
72
+ // (messages signed with <2048-bit keys are rejected or quarantined).
73
+ // Anything below MIN must be considered failure on verify; below WEAK
68
74
  // 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
75
+ // Operators stuck with legacy 1024-bit signers (deprecated; remediate
76
+ // before bulk-sending) opt down via verify({ minRsaBits: 1024 }) per-call
77
+ // — the historical floor stays available for migration but the
78
+ // framework default refuses sub-2048 inbound.
79
+ var RSA_MIN_BITS = 2048; // allow:raw-byte-literal — RFC 8301bis + 2024 bulk-sender floor
80
+ var RSA_WEAK_BITS = 2048; // allow:raw-byte-literal — RFC 8301bis weak threshold (same as floor)
81
+ var RSA_LEGACY_MIN_BITS = 1024; // allow:raw-byte-literal — RFC 8301 historical floor, opt-in only
71
82
 
72
83
  // ---- Canonicalization (RFC 6376 §3.4) ----
73
84
 
@@ -329,7 +340,19 @@ function create(opts) {
329
340
  // order, picking the LAST occurrence per RFC 6376 §5.4.2), then
330
341
  // the DKIM-Signature header itself with empty b=. The result is
331
342
  // what gets signed.
343
+ //
344
+ // Missing-header policy (RFC 6376 §3.4.2 + §5.4): the signer is
345
+ // permitted to list a header in h= that isn't present in the
346
+ // message — the verifier will compute the canonicalized form as
347
+ // empty and the signature still validates IF both sides agree the
348
+ // header is absent. The risk is silent drift: an operator
349
+ // configures `headersToSign: [..., "List-Unsubscribe-Post", ...]`
350
+ // for a campaign mailer, the per-message builder forgets to add
351
+ // the header, and the signature ships without binding to the
352
+ // intended commitment. Emit an audit event so operators see the
353
+ // skip rather than only noticing when a recipient rejects.
332
354
  var headerNamesLc = parsedHeaders.map(function (h) { return h.name.toLowerCase(); });
355
+ var missingHeaders = [];
333
356
  var canonicalizedHeaders = "";
334
357
  for (var j = 0; j < headersToSign.length; j++) {
335
358
  var wantLc = headersToSign[j].toLowerCase();
@@ -337,12 +360,33 @@ function create(opts) {
337
360
  for (var k = 0; k < headerNamesLc.length; k++) {
338
361
  if (headerNamesLc[k] === wantLc) idx = k;
339
362
  }
340
- if (idx === -1) continue; // missing headers are skipped (signer's choice)
363
+ if (idx === -1) {
364
+ // Operator configured h= entry that isn't in the message —
365
+ // surface via audit; sign continues per RFC 6376 §3.4.2.
366
+ missingHeaders.push(headersToSign[j]);
367
+ continue;
368
+ }
341
369
  var h = parsedHeaders[idx];
342
370
  canonicalizedHeaders += canonHeader === "simple"
343
371
  ? _canonHeaderSimple(h.name, h.value)
344
372
  : _canonHeaderRelaxed(h.name, h.value);
345
373
  }
374
+ if (missingHeaders.length > 0 && auditOn) {
375
+ try {
376
+ audit().safeEmit({
377
+ action: "dkim.sign.headers_missing",
378
+ outcome: "success",
379
+ actor: null,
380
+ metadata: {
381
+ domain: opts.domain,
382
+ selector: opts.selector,
383
+ missingHeaders: missingHeaders,
384
+ headersConfigured: headersToSign.length,
385
+ severity: "warning",
386
+ },
387
+ });
388
+ } catch (_e) { /* drop-silent */ }
389
+ }
346
390
  // Append the unsigned DKIM-Signature header without trailing CRLF
347
391
  // per RFC 6376 §3.7.
348
392
  var dkimHeaderForSigning = canonHeader === "simple"
@@ -463,10 +507,25 @@ function _selectorTxtToKeyTags(txtRecords) {
463
507
  // fan-out and bulk-replay scenarios frequently re-fetch the same
464
508
  // selector; without a cache the verifier hammers DNS. TTL bounded so
465
509
  // rotated keys propagate within minutes, not hours.
510
+ //
511
+ // Eviction is LRU (not FIFO): on hit, the entry is removed and
512
+ // re-inserted so the Map's insertion-order ordering tracks recency.
513
+ // FIFO would evict the most-recently-fetched key of an
514
+ // active-domain mix under cache pressure — exactly the wrong shape
515
+ // for repeated-sender workloads.
466
516
  var DKIM_KEY_CACHE = new Map();
467
- var DKIM_KEY_CACHE_TTL_MS = 5 * 60 * 1000; // allow:raw-time-literal — TTL ms expression
517
+ var DKIM_KEY_CACHE_TTL_MS = C.TIME.minutes(5);
468
518
  var DKIM_KEY_CACHE_MAX_ENTRIES = 1024;
469
519
 
520
+ // Per-message signature-count cap (DoS bound). A single message with
521
+ // many DKIM-Signature headers forces the verifier to fetch a key and
522
+ // run cryptographic verify for each — without a cap, an attacker
523
+ // inflates verifier work linearly in header count. RFC 6376 §6.1
524
+ // permits multiple signatures but doesn't bound them; mainstream
525
+ // receivers cap at 5–8. Operators that legitimately accept more
526
+ // override via verify({ maxSignatures }).
527
+ var DKIM_MAX_SIGNATURES_PER_MESSAGE = 8; // allow:raw-byte-literal — receiver-fan-out DoS bound
528
+
470
529
  function _cacheGet(qname) {
471
530
  var ent = DKIM_KEY_CACHE.get(qname);
472
531
  if (!ent) return null;
@@ -474,18 +533,59 @@ function _cacheGet(qname) {
474
533
  DKIM_KEY_CACHE.delete(qname);
475
534
  return null;
476
535
  }
536
+ // LRU: remove + re-insert so this entry becomes the most-recent in
537
+ // Map insertion order. Evictions below pop the oldest via keys().
538
+ DKIM_KEY_CACHE.delete(qname);
539
+ DKIM_KEY_CACHE.set(qname, ent);
477
540
  return ent.tags;
478
541
  }
479
542
 
480
543
  function _cachePut(qname, tags) {
481
544
  if (DKIM_KEY_CACHE.size >= DKIM_KEY_CACHE_MAX_ENTRIES) {
482
- // Drop oldest (Map preserves insertion order). Cheap LRU-ish.
545
+ // Drop oldest by insertion-order (LRU since _cacheGet rotates).
483
546
  var oldest = DKIM_KEY_CACHE.keys().next().value;
484
547
  if (oldest !== undefined) DKIM_KEY_CACHE.delete(oldest);
485
548
  }
486
549
  DKIM_KEY_CACHE.set(qname, { tags: tags, expires: Date.now() + DKIM_KEY_CACHE_TTL_MS });
487
550
  }
488
551
 
552
+ // Shared safe-DNS TXT lookup. Operator-supplied `dnsLookup` (legacy
553
+ // `[[strings]]` shape) takes precedence; otherwise routes through
554
+ // `b.network.dns.resolver` which uses DoH by default (per v0.7.23),
555
+ // so the framework default never falls back to plaintext node:dns
556
+ // resolution against an operator-untrusted upstream. CVE-2008-1447
557
+ // (Kaminsky) + CVE-2022-3204 (NRDelegationAttack) class — the
558
+ // transport-encrypted DoH path plus `b.safeDns` parse caps defend
559
+ // both transport and parse-side. Operators that need plaintext
560
+ // upstream wire it explicitly via `dnsLookup`.
561
+ var _defaultResolver = null;
562
+ function _getDefaultResolver() {
563
+ if (_defaultResolver) return _defaultResolver;
564
+ _defaultResolver = networkDnsResolver().create();
565
+ return _defaultResolver;
566
+ }
567
+
568
+ async function _safeResolveTxt(qname, operatorLookup) {
569
+ if (operatorLookup) return operatorLookup(qname, "TXT");
570
+ var r = await _getDefaultResolver().queryTxt(qname);
571
+ // Resolver returns parsed RRs; reshape to the legacy
572
+ // `[[chunk1, chunk2], ...]` shape so callers downstream don't care
573
+ // which path produced the bytes.
574
+ var out = [];
575
+ for (var i = 0; i < r.rrs.length; i += 1) {
576
+ var rr = r.rrs[i];
577
+ if (rr && rr.type === 16) { // allow:raw-byte-literal — IANA DNS qtype TXT
578
+ out.push(Array.isArray(rr.decoded) ? rr.decoded : [String(rr.decoded)]);
579
+ }
580
+ }
581
+ if (out.length === 0) {
582
+ var err = new Error("no TXT records for " + qname);
583
+ err.code = "ENODATA";
584
+ throw err;
585
+ }
586
+ return out;
587
+ }
588
+
489
589
  function _resetDkimKeyCacheForTest() { DKIM_KEY_CACHE.clear(); }
490
590
 
491
591
  function _pemFromB64KeyMaterial(b64) {
@@ -507,12 +607,7 @@ async function _fetchDkimKey(domain, selector, dnsLookup) {
507
607
  if (cached) return cached;
508
608
  var records;
509
609
  try {
510
- if (dnsLookup) {
511
- records = await dnsLookup(qname, "TXT");
512
- } else {
513
- var dnsModule = require("node:dns/promises");
514
- records = await dnsModule.resolveTxt(qname);
515
- }
610
+ records = await _safeResolveTxt(qname, dnsLookup);
516
611
  } catch (e) {
517
612
  if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) {
518
613
  throw new DkimError("dkim/key-not-found",
@@ -537,13 +632,68 @@ function _findDkimSignatureHeaders(parsedHeaders) {
537
632
  return out;
538
633
  }
539
634
 
540
- function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTags) {
635
+ // Strip the value of the `b=` tag from a DKIM-Signature tag list per
636
+ // RFC 6376 §3.7. Walks tag-spec boundaries (`;` separator) and only
637
+ // matches the exact `b` tag name — not any tag whose name happens
638
+ // to end in `b`. Returns the value with the b= tag's content removed
639
+ // (leaving `b=` in place).
640
+ function _stripBTagValue(value) {
641
+ var parts = String(value).split(";");
642
+ var out = [];
643
+ for (var i = 0; i < parts.length; i += 1) {
644
+ var p = parts[i];
645
+ var m = /^(\s*)([A-Za-z][A-Za-z0-9_-]*)(\s*=)/.exec(p);
646
+ if (m && m[2].toLowerCase() === "b") {
647
+ out.push(m[1] + m[2] + m[3]);
648
+ continue;
649
+ }
650
+ out.push(p);
651
+ }
652
+ return out.join(";");
653
+ }
654
+
655
+ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTags, verifyOpts) {
656
+ verifyOpts = verifyOpts || {};
541
657
  // Reconstruct what the signer canonicalized, per RFC 6376 §3.7.
542
658
  var canonicalization = sigTags.c || "simple/simple";
543
659
  var canonHeader = canonicalization.split("/")[0];
544
660
  var canonBody = canonicalization.split("/")[1];
545
661
  var algorithm = sigTags.a;
546
662
 
663
+ // RFC 6376 §3.5 — the optional i= tag (Agent or User Identifier),
664
+ // when present, MUST have a domain part identical to or a subdomain
665
+ // of d=. A signature whose i= claims `@evil.example.com` while d=
666
+ // is `example.org` is malformed and binds the signer's claim to a
667
+ // domain the verifier wouldn't otherwise associate. Refuse.
668
+ if (typeof sigTags.i === "string" && sigTags.i.length > 0) {
669
+ var iDomain = sigTags.i.indexOf("@") === -1
670
+ ? sigTags.i
671
+ : sigTags.i.slice(sigTags.i.indexOf("@") + 1);
672
+ var d = String(sigTags.d || "").toLowerCase();
673
+ var iDl = iDomain.toLowerCase();
674
+ if (d.length === 0 || (iDl !== d && iDl.slice(-d.length - 1) !== "." + d)) {
675
+ return { result: "permerror",
676
+ errors: ["DKIM-Signature i=" + sigTags.i + " is not d= or a subdomain of d=" + sigTags.d + " (RFC 6376 §3.5)"] };
677
+ }
678
+ }
679
+
680
+ // RFC 6376 §3.6.1 — the key record's optional h= tag declares the
681
+ // hash algorithms the key MAY be used with (`sha256` is canonical).
682
+ // The signature's a= names the hash via its suffix (`rsa-sha256`,
683
+ // `ed25519-sha256`). If h= is present on the key, the signature's
684
+ // hash MUST appear in the colon-separated list; otherwise the key
685
+ // owner intends the key for a different hash family and the
686
+ // signature is unauthorized.
687
+ if (typeof keyTags.h === "string" && keyTags.h.length > 0) {
688
+ var sigHash = String(algorithm || "").toLowerCase().split("-").slice(-1)[0];
689
+ var allowedHashes = keyTags.h.toLowerCase().split(":").map(function (s) { return s.trim(); });
690
+ if (sigHash.length === 0 || allowedHashes.indexOf(sigHash) === -1) {
691
+ return { result: "permerror",
692
+ errors: ["DKIM-Signature a=" + algorithm + " hash '" + sigHash +
693
+ "' not in key h=" + keyTags.h + " (RFC 6376 §3.6.1)"] };
694
+ }
695
+ }
696
+
547
697
  var split = _splitHeadersBody(rfc822);
548
698
  var body = split.body;
549
699
  if (sigTags.l !== undefined) {
@@ -593,8 +743,15 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
593
743
  : _canonHeaderRelaxed(h.name, h.value);
594
744
  }
595
745
  // Strip the b= value from the DKIM-Signature header for the canonical
596
- // form per §3.7.
597
- var unsignedSigValue = sigHeader.value.replace(/(\bb=)[^;]*/i, "$1");
746
+ // form per RFC 6376 §3.7. The strip must locate the `b=` tag within
747
+ // the tag-list grammar (`tag-spec *( ";" tag-spec )` per §3.2) and
748
+ // zero its value through the next `;` OR end-of-string. The earlier
749
+ // shape `/(\bb=)[^;]*/i` matched on the first `b=` substring anywhere
750
+ // in the value — fine for current DKIM tag vocabulary (no tag-name
751
+ // ends in `b`) but brittle against any hypothetical future tag whose
752
+ // name ends in `b` (`ab=`, `pub=`, `cb=` …). Anchor on the tag-list
753
+ // structure instead.
754
+ var unsignedSigValue = _stripBTagValue(sigHeader.value);
598
755
  canonicalizedHeaders += canonHeader === "simple"
599
756
  ? _canonHeaderSimple("DKIM-Signature", " " + unsignedSigValue).replace(/\r\n$/, "")
600
757
  : _canonHeaderRelaxed("DKIM-Signature", unsignedSigValue).replace(/\r\n$/, "");
@@ -620,16 +777,27 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
620
777
  errors: ["unsupported DKIM algorithm '" + algorithm + "'"] };
621
778
  }
622
779
 
623
- // Key-size enforcement (RFC 8301 §3.1, M³AAWG hardening guidance):
624
- // RSA keys < 1024 bits MUST be considered failure; < 2048 is weak.
625
- // The framework rejects < 1024 as a security baseline; < 2048 emits
626
- // a warning in the result so operators can quarantine.
780
+ // Key-size enforcement (RFC 8301bis §3.1 + Google + Yahoo Feb 2024
781
+ // bulk-sender + M³AAWG hardening):
782
+ // - Default floor: 2048 bits (bulk-sender enforced floor).
783
+ // - Operator opt-down: verify({ minRsaBits: 1024 }) re-enables the
784
+ // historical RFC 8301 floor for legacy migration windows. Sub-
785
+ // 1024 is refused regardless of opt-down — no operator policy
786
+ // can accept genuinely-too-small RSA per §3.1.
787
+ // - Below opt-down-honored floor → fail; below WEAK threshold →
788
+ // warning so operators can quarantine while transitioning.
789
+ var operatorMinBits = (typeof verifyOpts.minRsaBits === "number" &&
790
+ isFinite(verifyOpts.minRsaBits) &&
791
+ verifyOpts.minRsaBits >= RSA_LEGACY_MIN_BITS)
792
+ ? Math.floor(verifyOpts.minRsaBits)
793
+ : RSA_MIN_BITS;
627
794
  var warnings = [];
628
795
  if (algorithm === "rsa-sha256" && keyObj.asymmetricKeyType === "rsa") {
629
796
  var modBits = (keyObj.asymmetricKeyDetails && keyObj.asymmetricKeyDetails.modulusLength) || 0;
630
- if (modBits > 0 && modBits < RSA_MIN_BITS) {
797
+ if (modBits > 0 && modBits < operatorMinBits) {
631
798
  return { result: "fail",
632
- errors: ["RSA key too small: " + modBits + " bits (RFC 8301 §3.1 minimum " + RSA_MIN_BITS + ")"] };
799
+ errors: ["RSA key too small: " + modBits + " bits (minimum " + operatorMinBits +
800
+ " — RFC 8301bis + 2024 bulk-sender)"] };
633
801
  }
634
802
  if (modBits > 0 && modBits < RSA_WEAK_BITS) {
635
803
  warnings.push("rsa-key-weak: " + modBits + " bits (< " + RSA_WEAK_BITS + ")");
@@ -652,13 +820,46 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
652
820
  : { result: "fail", errors: ["signature verification failed"], warnings: warnings };
653
821
  }
654
822
 
823
+ // RFC 6376 §3.5 — `t=` / `x=` clock-skew bound. Operator-tunable, but
824
+ // must be a finite non-negative number and must NOT exceed the
825
+ // FRAMEWORK absolute ceiling. An unbounded skew lets an attacker
826
+ // re-play a long-expired signed message indefinitely; the ceiling
827
+ // bounds the maximum back-dating tolerance.
828
+ var DKIM_CLOCK_SKEW_MS_MAX = C.TIME.hours(24);
829
+ var DKIM_CLOCK_SKEW_MS_DEFAULT = C.TIME.minutes(5);
830
+
655
831
  async function verify(rfc822, opts) {
656
832
  if (typeof rfc822 !== "string" || rfc822.length === 0) {
657
833
  throw new DkimError("dkim/bad-input",
658
834
  "verify(): rfc822 must be a non-empty string");
659
835
  }
660
836
  opts = opts || {};
661
- validateOpts(opts, ["dnsLookup", "audit"], "mail.dkim.verify");
837
+ validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
838
+ "minRsaBits"], "mail.dkim.verify");
839
+
840
+ // Bounded clock skew: refuse non-numeric / negative / infinite /
841
+ // beyond-ceiling. Throwing on bad config-time input per the
842
+ // framework's three-tier validation policy.
843
+ var clockSkewMs;
844
+ if (opts.clockSkewMs === undefined || opts.clockSkewMs === null) {
845
+ clockSkewMs = DKIM_CLOCK_SKEW_MS_DEFAULT;
846
+ } else if (typeof opts.clockSkewMs !== "number" || !isFinite(opts.clockSkewMs) ||
847
+ opts.clockSkewMs < 0) {
848
+ throw new DkimError("dkim/bad-clock-skew",
849
+ "verify(): clockSkewMs must be a finite non-negative number");
850
+ } else if (opts.clockSkewMs > DKIM_CLOCK_SKEW_MS_MAX) {
851
+ throw new DkimError("dkim/bad-clock-skew",
852
+ "verify(): clockSkewMs " + opts.clockSkewMs + " exceeds framework ceiling " +
853
+ DKIM_CLOCK_SKEW_MS_MAX + " (RFC 6376 §3.5 — back-dating replay defense)");
854
+ } else {
855
+ clockSkewMs = Math.floor(opts.clockSkewMs);
856
+ }
857
+
858
+ var maxSignatures = (typeof opts.maxSignatures === "number" &&
859
+ isFinite(opts.maxSignatures) && opts.maxSignatures >= 1)
860
+ ? Math.floor(opts.maxSignatures)
861
+ : DKIM_MAX_SIGNATURES_PER_MESSAGE;
862
+ var verifyOpts = { minRsaBits: opts.minRsaBits };
662
863
 
663
864
  var split = _splitHeadersBody(rfc822);
664
865
  var parsedHeaders = _parseHeaders(split.headers);
@@ -666,6 +867,13 @@ async function verify(rfc822, opts) {
666
867
  if (sigHeaders.length === 0) {
667
868
  return [{ result: "none", errors: ["no DKIM-Signature headers"] }];
668
869
  }
870
+ // RFC 6376 §6.1 — verifier MUST handle multiple signatures but the
871
+ // RFC sets no count cap. An unbounded count is a CPU-DoS surface
872
+ // (each sig forces a DNS fetch + cryptographic verify). Cap and
873
+ // surface the truncation in the result for operator visibility.
874
+ if (sigHeaders.length > maxSignatures) {
875
+ sigHeaders = sigHeaders.slice(0, maxSignatures);
876
+ }
669
877
 
670
878
  var results = [];
671
879
  for (var i = 0; i < sigHeaders.length; i += 1) {
@@ -685,8 +893,8 @@ async function verify(rfc822, opts) {
685
893
  // refuse if more than 24h in the future (clock drift between
686
894
  // signer + verifier of more than a day is a near-certain bug or
687
895
  // attack). Both are in seconds-since-epoch per ABNF.
688
- var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — Unix-epoch seconds divisor
689
- var clockSkewSec = Math.floor((opts.clockSkewMs || (5 * 60 * 1000)) / 1000); // allow:raw-time-literal — default 5-minute skew
896
+ var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
897
+ var clockSkewSec = Math.floor(clockSkewMs / C.TIME.seconds(1));
690
898
  if (sigTags.x !== undefined) {
691
899
  var expSec = parseInt(sigTags.x, 10);
692
900
  if (isFinite(expSec) && expSec + clockSkewSec < nowSec) {
@@ -755,7 +963,7 @@ async function verify(rfc822, opts) {
755
963
  continue;
756
964
  }
757
965
  }
758
- var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags);
966
+ var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags, verifyOpts);
759
967
  results.push(Object.assign({ d: d, s: s, alg: alg }, rv));
760
968
  }
761
969
  return results;
@@ -792,13 +1000,210 @@ function dualSigner(opts) {
792
1000
 
793
1001
  // Test-only exports for unit testing the canonicalization primitives
794
1002
  // directly without going through a full sign() round.
1003
+ /**
1004
+ * @primitive b.mail.dkim.bootstrap
1005
+ * @signature b.mail.dkim.bootstrap(opts)
1006
+ * @since 0.9.48
1007
+ * @status stable
1008
+ * @related b.vault.sealPemFile
1009
+ *
1010
+ * Bootstrap a DKIM keypair + DNS TXT record + ready-to-use signer.
1011
+ * Operators deploying outbound mail (b.mail.send, b.mail.server.submission)
1012
+ * need three things in place: (1) a private signing key, (2) the matching
1013
+ * public key published as a DNS TXT record under
1014
+ * `<selector>._domainkey.<domain>`, (3) a `b.mail.dkim.create(...)` handle
1015
+ * wired into the outbound agent. Pre-this-primitive every consumer
1016
+ * reinvented the keypair-mint + DNS-record-serialize plumbing; this
1017
+ * primitive owns it.
1018
+ *
1019
+ * Default algorithm is `ed25519-sha256` (RFC 8463): smaller DNS record,
1020
+ * faster signing, modern crypto. Operators with receivers that don't yet
1021
+ * support Ed25519 pass `algorithm: "rsa-sha256"` for RFC 6376 (defaults
1022
+ * to 2048-bit RSA per RFC 8301 §3.1 guidance — opt up with `rsaBits`).
1023
+ * Passing `algorithm: "dual"` mints BOTH keypairs and returns a
1024
+ * `b.mail.dkim.dualSigner`-shaped signer that emits two DKIM-Signature
1025
+ * headers (one per alg) for max receiver compat per RFC 8463 §3 dual-
1026
+ * signing pattern.
1027
+ *
1028
+ * @opts
1029
+ * domain: string, // required — RFC 5321 domain
1030
+ * selector: string, // required — RFC 6376 §3.1 selector (the `s1` in s1._domainkey.example.com)
1031
+ * algorithm: "ed25519-sha256" | "rsa-sha256" | "dual",
1032
+ * // default: "ed25519-sha256"
1033
+ * rsaBits: number, // RSA-only; default 2048; refused below 1024 (RFC 8301 §3.1)
1034
+ * rsaSelector: string, // dual-only; selector for the RSA key (defaults to selector + "-rsa")
1035
+ *
1036
+ * @example
1037
+ * var dkim = b.mail.dkim.bootstrap({ domain: "example.com", selector: "s1" });
1038
+ * // → {
1039
+ * // algorithm: "ed25519-sha256",
1040
+ * // domain: "example.com",
1041
+ * // selector: "s1",
1042
+ * // privateKeyPem,
1043
+ * // publicKeyPem,
1044
+ * // dnsName: "s1._domainkey.example.com",
1045
+ * // dnsTxtValue: "v=DKIM1; k=ed25519; p=MCowBQYDK2Vw...",
1046
+ * // dnsRecord: 's1._domainkey.example.com. IN TXT ("v=DKIM1; k=ed25519; p=MCo...")',
1047
+ * // signer: fn(headersToSign?, canonicalization?) → signer,
1048
+ * // }
1049
+ *
1050
+ * // Operator seals the private key via the vault then wires the signer:
1051
+ * var sealedPath = b.vault.sealPemFile({ source: "/var/lib/blamejs/dkim.key", destination: "/var/lib/blamejs/dkim.key.sealed" });
1052
+ * var signer = dkim.signer(); // uses dkim.privateKeyPem in-memory
1053
+ *
1054
+ * // Dual signing — RSA + Ed25519 for max receiver compatibility:
1055
+ * var dkim2 = b.mail.dkim.bootstrap({ domain: "example.com", selector: "s1", algorithm: "dual" });
1056
+ * // dkim2.signer() returns a dualSigner emitting both DKIM-Signature headers.
1057
+ */
1058
+ function bootstrap(opts) {
1059
+ validateOpts.requireObject(opts, "b.mail.dkim.bootstrap", DkimError, "dkim/bad-opts");
1060
+ validateOpts.requireNonEmptyString(opts.domain, "b.mail.dkim.bootstrap: opts.domain",
1061
+ DkimError, "dkim/bad-domain");
1062
+ validateOpts.requireNonEmptyString(opts.selector, "b.mail.dkim.bootstrap: opts.selector",
1063
+ DkimError, "dkim/bad-selector");
1064
+ var alg = opts.algorithm || "ed25519-sha256";
1065
+ if (alg !== "ed25519-sha256" && alg !== "rsa-sha256" && alg !== "dual") {
1066
+ throw new DkimError("dkim/bad-algorithm",
1067
+ "b.mail.dkim.bootstrap: opts.algorithm must be 'ed25519-sha256' | 'rsa-sha256' | 'dual'");
1068
+ }
1069
+ // DKIM selector + domain shape: RFC 6376 §3.1 — selector is a
1070
+ // sub-domain label (no leading/trailing dot; no whitespace; no
1071
+ // wildcards). domain is a normal DNS hostname.
1072
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/.test(opts.selector)) { // allow:regex-no-length-cap — anchored + bounded repeat
1073
+ throw new DkimError("dkim/bad-selector",
1074
+ "b.mail.dkim.bootstrap: opts.selector must match RFC 6376 §3.1 selector shape");
1075
+ }
1076
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,253}[A-Za-z0-9])?$/.test(opts.domain)) { // allow:regex-no-length-cap — anchored + bounded repeat
1077
+ throw new DkimError("dkim/bad-domain",
1078
+ "b.mail.dkim.bootstrap: opts.domain must be a DNS-hostname-shaped string");
1079
+ }
1080
+
1081
+ if (alg === "ed25519-sha256") {
1082
+ return _bootstrapSingle("ed25519-sha256", opts.domain, opts.selector);
1083
+ }
1084
+ if (alg === "rsa-sha256") {
1085
+ var bits = opts.rsaBits === undefined ? RSA_MIN_BITS : opts.rsaBits;
1086
+ if (typeof bits !== "number" || !isFinite(bits) || bits < RSA_LEGACY_MIN_BITS || (bits % 1) !== 0) {
1087
+ throw new DkimError("dkim/bad-rsa-bits",
1088
+ "b.mail.dkim.bootstrap: opts.rsaBits must be an integer >= " + RSA_LEGACY_MIN_BITS +
1089
+ " (RFC 8301 §3.1 floor; default " + RSA_MIN_BITS +
1090
+ " per RFC 8301bis + 2024 bulk-sender)");
1091
+ }
1092
+ return _bootstrapSingle("rsa-sha256", opts.domain, opts.selector, bits);
1093
+ }
1094
+ // dual
1095
+ var rsaSelector = opts.rsaSelector || (opts.selector + "-rsa");
1096
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/.test(rsaSelector)) { // allow:regex-no-length-cap — anchored + bounded repeat
1097
+ throw new DkimError("dkim/bad-selector",
1098
+ "b.mail.dkim.bootstrap: opts.rsaSelector must match RFC 6376 §3.1 selector shape");
1099
+ }
1100
+ var rsaBits = opts.rsaBits === undefined ? RSA_MIN_BITS : opts.rsaBits;
1101
+ if (typeof rsaBits !== "number" || !isFinite(rsaBits) || rsaBits < RSA_LEGACY_MIN_BITS || (rsaBits % 1) !== 0) {
1102
+ throw new DkimError("dkim/bad-rsa-bits",
1103
+ "b.mail.dkim.bootstrap: opts.rsaBits must be an integer >= " + RSA_LEGACY_MIN_BITS);
1104
+ }
1105
+ var ed = _bootstrapSingle("ed25519-sha256", opts.domain, opts.selector);
1106
+ var rsa = _bootstrapSingle("rsa-sha256", opts.domain, rsaSelector, rsaBits);
1107
+ return {
1108
+ algorithm: "dual",
1109
+ domain: opts.domain,
1110
+ ed25519: ed,
1111
+ rsa: rsa,
1112
+ signer: function (signOpts) {
1113
+ signOpts = signOpts || {};
1114
+ return dualSigner({
1115
+ domain: opts.domain,
1116
+ headersToSign: signOpts.headersToSign,
1117
+ canonicalization: signOpts.canonicalization,
1118
+ eddsa: {
1119
+ selector: opts.selector,
1120
+ privateKey: ed.privateKeyPem,
1121
+ },
1122
+ rsa: {
1123
+ selector: rsaSelector,
1124
+ privateKey: rsa.privateKeyPem,
1125
+ },
1126
+ });
1127
+ },
1128
+ };
1129
+ }
1130
+
1131
+ function _bootstrapSingle(algorithm, domain, selector, rsaBits) {
1132
+ var keyPair;
1133
+ var k; // DNS TXT `k=` tag value
1134
+ if (algorithm === "ed25519-sha256") {
1135
+ keyPair = nodeCrypto.generateKeyPairSync("ed25519", {
1136
+ publicKeyEncoding: { type: "spki", format: "der" },
1137
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
1138
+ });
1139
+ k = "ed25519";
1140
+ } else {
1141
+ keyPair = nodeCrypto.generateKeyPairSync("rsa", {
1142
+ modulusLength: rsaBits,
1143
+ publicKeyEncoding: { type: "spki", format: "der" },
1144
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
1145
+ });
1146
+ k = "rsa";
1147
+ }
1148
+ var publicKeyPemObj = nodeCrypto.createPublicKey({ key: keyPair.publicKey, type: "spki", format: "der" });
1149
+ var publicKeyPem = publicKeyPemObj.export({ type: "spki", format: "pem" });
1150
+ var pBase64 = Buffer.from(keyPair.publicKey).toString("base64");
1151
+ var dnsName = selector + "._domainkey." + domain;
1152
+ // RFC 6376 §3.6.1 record syntax: v=DKIM1; k=<alg>; p=<base64>
1153
+ // The optional t/s/g/n/h/k tags omitted (operator can re-edit
1154
+ // the dnsTxtValue before publishing if they need policy flags).
1155
+ var dnsTxtValue = "v=DKIM1; k=" + k + "; p=" + pBase64;
1156
+ // BIND/Unbound zone-file shape: name TTL? IN TXT ("...").
1157
+ // TXT values > 255 octets must be split into multiple quoted
1158
+ // strings per RFC 1035 §3.3.14 — long RSA records will trip this.
1159
+ var dnsRecord = dnsName + ". IN TXT (" + _wrapDnsTxt(dnsTxtValue) + ")";
1160
+
1161
+ return {
1162
+ algorithm: algorithm,
1163
+ domain: domain,
1164
+ selector: selector,
1165
+ privateKeyPem: keyPair.privateKey,
1166
+ publicKeyPem: publicKeyPem,
1167
+ dnsName: dnsName,
1168
+ dnsTxtValue: dnsTxtValue,
1169
+ dnsRecord: dnsRecord,
1170
+ signer: function (signOpts) {
1171
+ signOpts = signOpts || {};
1172
+ return create({
1173
+ domain: domain,
1174
+ selector: selector,
1175
+ privateKey: keyPair.privateKey,
1176
+ algorithm: algorithm,
1177
+ headersToSign: signOpts.headersToSign,
1178
+ canonicalization: signOpts.canonicalization,
1179
+ });
1180
+ },
1181
+ };
1182
+ }
1183
+
1184
+ // RFC 1035 §3.3.14 — TXT records carry one or more <character-string>s
1185
+ // each capped at 255 octets. Long RSA p= values are split into multiple
1186
+ // quoted strings so the zone file is valid.
1187
+ function _wrapDnsTxt(value) {
1188
+ if (value.length <= 255) return '"' + value + '"'; // allow:raw-byte-literal — RFC 1035 character-string cap
1189
+ var parts = [];
1190
+ for (var i = 0; i < value.length; i += 255) parts.push('"' + value.slice(i, i + 255) + '"'); // allow:raw-byte-literal — RFC 1035 character-string cap
1191
+ return parts.join(" ");
1192
+ }
1193
+
795
1194
  module.exports = {
796
1195
  create: create,
1196
+ bootstrap: bootstrap,
797
1197
  verify: verify,
798
1198
  _resetDkimKeyCacheForTest: _resetDkimKeyCacheForTest,
799
1199
  dualSigner: dualSigner,
800
1200
  DkimError: DkimError,
1201
+ RSA_MIN_BITS: RSA_MIN_BITS,
1202
+ RSA_LEGACY_MIN_BITS: RSA_LEGACY_MIN_BITS,
1203
+ DKIM_MAX_SIGNATURES_PER_MESSAGE: DKIM_MAX_SIGNATURES_PER_MESSAGE,
1204
+ DKIM_CLOCK_SKEW_MS_MAX: DKIM_CLOCK_SKEW_MS_MAX,
801
1205
  _canonHeaderRelaxedForTest: _canonHeaderRelaxed,
802
1206
  _canonBodyRelaxedForTest: _canonBodyRelaxed,
803
1207
  _canonBodySimpleForTest: _canonBodySimple,
1208
+ _stripBTagValueForTest: _stripBTagValue,
804
1209
  };