@blamejs/core 0.9.46 → 0.10.1
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 +951 -893
- package/index.js +30 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-imap-command.js +335 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +1102 -0
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +164 -34
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +269 -0
- package/lib/mail-server-submission.js +1032 -0
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +130 -10
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +168 -17
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- 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
|
|
67
|
-
//
|
|
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
|
-
|
|
70
|
-
|
|
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)
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
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 <
|
|
797
|
+
if (modBits > 0 && modBits < operatorMinBits) {
|
|
631
798
|
return { result: "fail",
|
|
632
|
-
errors: ["RSA key too small: " + modBits + " 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"
|
|
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() /
|
|
689
|
-
var clockSkewSec = Math.floor(
|
|
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
|
};
|