@blamejs/core 0.18.53 → 0.18.55
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 +228 -0
- package/NOTICE +1 -1
- package/README.md +5 -5
- package/lib/agent-audit.js +27 -2
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +41 -33
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +73 -12
- package/lib/guard-regex.js +3 -5
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/guard-yaml.js +60 -15
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +29 -13
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +134 -22
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +186 -89
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +198 -42
- package/lib/mail-server-mx.js +191 -38
- package/lib/mail-server-net.js +281 -1
- package/lib/mail-server-pop3.js +89 -41
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +183 -35
- package/lib/mail-server-tls.js +48 -3
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/mcp.js +11 -3
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/network-tls.js +18 -0
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +672 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/http-client.js
CHANGED
|
@@ -621,7 +621,13 @@ function _fromH2Headers(h2Headers) {
|
|
|
621
621
|
// { host: "api.x.com", methods: ["GET","HEAD"] }
|
|
622
622
|
// method-restricted; methods omitted = any method
|
|
623
623
|
function hostAllowed(host, allowedHosts, method) {
|
|
624
|
-
|
|
624
|
+
// A NON-array means no pin was supplied, so nothing is constrained. An EMPTY
|
|
625
|
+
// array is a pin that permits nothing, and falls through to the `false` at
|
|
626
|
+
// the end rather than short-circuiting to `true` here — an allowlist that
|
|
627
|
+
// disappears when empty is a firewall rule set that opens when the last rule
|
|
628
|
+
// is deleted. Every caller enforcing this pin routes through here, so the
|
|
629
|
+
// distinction has to live at this function and not at each of them.
|
|
630
|
+
if (!Array.isArray(allowedHosts)) return true;
|
|
625
631
|
var wanted = String(host || "").toLowerCase();
|
|
626
632
|
var verb = String(method || "GET").toUpperCase();
|
|
627
633
|
for (var ai = 0; ai < allowedHosts.length; ai++) {
|
|
@@ -720,7 +726,9 @@ function pinnedClient(client, allowedHosts) {
|
|
|
720
726
|
// redirect the wrapper never sees is a hop none of that applied to. Returning
|
|
721
727
|
// the client untouched when no pin was named would leave a 307 from https to
|
|
722
728
|
// http free to resend the credentials that were checked onto the first hop.
|
|
723
|
-
|
|
729
|
+
// An EMPTY allowlist is a real pin meaning "reach nothing", not the absence
|
|
730
|
+
// of one — same reasoning as the request path above.
|
|
731
|
+
var pin = Array.isArray(allowedHosts) ? allowedHosts : null;
|
|
724
732
|
return {
|
|
725
733
|
request: function (opts) {
|
|
726
734
|
var target = opts && opts.url;
|
|
@@ -1746,7 +1754,13 @@ function _requestSingle(opts) {
|
|
|
1746
1754
|
// A disallowed call rejects with HOST_DISALLOWED AND emits an
|
|
1747
1755
|
// audit event when opts.audit is wired (operator gets a structured
|
|
1748
1756
|
// signal that the application tried to reach somewhere it shouldn't).
|
|
1749
|
-
|
|
1757
|
+
// Gated on "was an allowlist supplied", NOT on "is it non-empty". Omitting
|
|
1758
|
+
// the option is how "no egress pin" is spelled; an operator who builds the
|
|
1759
|
+
// list from config and gets an empty one is saying this process may reach
|
|
1760
|
+
// nothing, and reading that as "no pin" inverts the request exactly. An
|
|
1761
|
+
// allowlist that disappears when empty is a firewall rule set that opens when
|
|
1762
|
+
// the last rule is deleted.
|
|
1763
|
+
if (Array.isArray(opts.allowedHosts)) {
|
|
1750
1764
|
var host = u.hostname.toLowerCase();
|
|
1751
1765
|
var method = (opts.method || "GET").toUpperCase();
|
|
1752
1766
|
var ok = hostAllowed(host, opts.allowedHosts, method);
|
package/lib/mail-agent.js
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
var lazyRequire = require("./lazy-require");
|
|
78
78
|
var validateOpts = require("./validate-opts");
|
|
79
79
|
var C = require("./constants");
|
|
80
|
+
var agentAudit = require("./agent-audit");
|
|
80
81
|
var { defineClass } = require("./framework-error");
|
|
81
82
|
var guardMailQuery = require("./guard-mail-query");
|
|
82
83
|
var guardMailCompose = require("./guard-mail-compose");
|
|
@@ -539,19 +540,33 @@ async function _expunge(ctx, args) {
|
|
|
539
540
|
"agent.expunge: { folder, objectIds, [candidateTtlMs] } required");
|
|
540
541
|
}
|
|
541
542
|
|
|
542
|
-
// Look up the regulator-mandated retention floor for the operator's
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
// complianceFloor
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
543
|
+
// Look up the regulator-mandated retention floor for the operator's active
|
|
544
|
+
// posture. For expunge semantics the floor IS the minimum TTL — messages
|
|
545
|
+
// younger than it MUST NOT be hard-deleted, even on operator request.
|
|
546
|
+
//
|
|
547
|
+
// Through b.retention.complianceFloor, not the floor table it wraps. The
|
|
548
|
+
// direct read fell back to zero for anything the table did not contain, so a
|
|
549
|
+
// misspelled posture, a capitalised one ("HIPAA"), or one the table simply
|
|
550
|
+
// does not carry all permitted an unbounded hard delete with no error — a
|
|
551
|
+
// typo refused where retention windows are computed and accepted at the one
|
|
552
|
+
// call that destroys mail permanently. It also read through the prototype:
|
|
553
|
+
// posture "constructor" returned a function, which is truthy, so the `|| 0`
|
|
554
|
+
// fallback kept it and every age comparison against it was nonsense.
|
|
555
|
+
//
|
|
556
|
+
// No posture at all is a legitimate configuration and keeps the zero floor.
|
|
557
|
+
// A posture that was supplied and is not understood now throws.
|
|
550
558
|
var retentionModule = require("./retention"); // allow:inline-require — lazy-load until first expunge call
|
|
551
559
|
var posture = (ctx && ctx.posture) || (args && args.posture) || null;
|
|
552
560
|
var floorMs = 0;
|
|
553
561
|
if (typeof posture === "string" && posture.length > 0) {
|
|
554
|
-
|
|
562
|
+
try {
|
|
563
|
+
floorMs = retentionModule.complianceFloor(posture);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
throw new MailAgentError("mail-agent/unknown-posture",
|
|
566
|
+
"expunge: posture '" + posture + "' is not a posture the framework knows, " +
|
|
567
|
+
"so no retention floor can be established and the hard delete is refused " +
|
|
568
|
+
"rather than run unbounded: " + ((e && e.message) || String(e)));
|
|
569
|
+
}
|
|
555
570
|
}
|
|
556
571
|
|
|
557
572
|
// Read message metadata BEFORE invoking hardExpunge so the per-id
|
|
@@ -775,10 +790,11 @@ function _auditEmitter(auditOverride) {
|
|
|
775
790
|
};
|
|
776
791
|
}
|
|
777
792
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
793
|
+
// Through the shared shaper rather than a local copy. This module had its own,
|
|
794
|
+
// and the two drifted in exactly the way that matters: both emitted `{ id,
|
|
795
|
+
// roles }` while `b.audit.record` reads `actor.userId`, so every row either
|
|
796
|
+
// produced landed unattributable. One shaper means one place to be right.
|
|
797
|
+
var _actorShape = agentAudit.actorShape;
|
|
782
798
|
|
|
783
799
|
module.exports = {
|
|
784
800
|
create: create,
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -128,7 +128,12 @@ function _bodyHashB64(body, algorithm) {
|
|
|
128
128
|
var canonical = _canonRelaxedBody(body);
|
|
129
129
|
// RFC 6376 §3.4.4 — empty body canon is `\r\n` (one CRLF). Hash
|
|
130
130
|
// includes that CRLF.
|
|
131
|
-
|
|
131
|
+
//
|
|
132
|
+
// latin1, for the reason DKIM uses it: ARC seals a message the same way DKIM
|
|
133
|
+
// signs one, over the OCTETS on the wire. `update(string)` defaults to UTF-8,
|
|
134
|
+
// which re-encodes every high byte and seals a body that is not the one the
|
|
135
|
+
// message carries.
|
|
136
|
+
return nodeCrypto.createHash(hashAlgo).update(canonical, "latin1").digest("base64");
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
// RFC 8617 §5 — ARC chains MUST NOT exceed 50 hops. The verifier
|
|
@@ -182,8 +187,27 @@ function sign(opts) {
|
|
|
182
187
|
"headersToSign", "timestamp", "audit", "excludeAarFromAms",
|
|
183
188
|
], "mail.arc.sign");
|
|
184
189
|
|
|
185
|
-
|
|
186
|
-
|
|
190
|
+
// A seal covers the OCTETS on the wire, so a caller holding them seals them
|
|
191
|
+
// rather than a UTF-8 decode of them. One code unit per octet from here down.
|
|
192
|
+
//
|
|
193
|
+
// Through `dkim._toWire`, which is the SAME function the DKIM signer uses.
|
|
194
|
+
// Converting the Buffer here by hand and leaving a string untouched was the
|
|
195
|
+
// first version, and it left every non-ASCII string message signed over the
|
|
196
|
+
// low byte of each code unit: the hashing and signing paths below read a
|
|
197
|
+
// string as latin1 octets, so a message that is not already ASCII has to be
|
|
198
|
+
// put into that form before it reaches them, not after. Two functions
|
|
199
|
+
// answering "which bytes is this message" is how they come to disagree.
|
|
200
|
+
var gaveBuffer = Buffer.isBuffer(opts.rfc822);
|
|
201
|
+
if (gaveBuffer) {
|
|
202
|
+
if (opts.rfc822.length === 0) {
|
|
203
|
+
throw new MailAuthError("arc-sign/bad-input",
|
|
204
|
+
"sign: rfc822 must be a non-empty Buffer or string");
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
|
|
208
|
+
MailAuthError, "arc-sign/bad-input");
|
|
209
|
+
}
|
|
210
|
+
opts = Object.assign({}, opts, { rfc822: dkim._toWire(opts.rfc822) });
|
|
187
211
|
if (typeof opts.instance !== "number" || !isFinite(opts.instance) ||
|
|
188
212
|
opts.instance < 1 || opts.instance > 50 || // RFC 8617 §5 chain bound
|
|
189
213
|
Math.floor(opts.instance) !== opts.instance) {
|
|
@@ -387,7 +411,14 @@ function sign(opts) {
|
|
|
387
411
|
"ARC-Seal: " + asValue + "\r\n" +
|
|
388
412
|
"ARC-Message-Signature: " + amsValue + "\r\n" +
|
|
389
413
|
"ARC-Authentication-Results: " + aarValue + "\r\n";
|
|
390
|
-
|
|
414
|
+
// Returned in the shape it arrived in, so a caller relaying the sealed
|
|
415
|
+
// message relays the octets they handed over. See the same note in
|
|
416
|
+
// lib/mail-dkim.js sign(): a string caller gets their own string back, which
|
|
417
|
+
// is the UTF-8 decode of the wire form rather than the wire form itself.
|
|
418
|
+
var sealedWire = prependedHeaders + opts.rfc822;
|
|
419
|
+
var sealedRfc822 = gaveBuffer
|
|
420
|
+
? Buffer.from(sealedWire, "latin1")
|
|
421
|
+
: Buffer.from(sealedWire, "latin1").toString("utf8");
|
|
391
422
|
|
|
392
423
|
if (auditOn) {
|
|
393
424
|
try {
|
|
@@ -418,15 +449,17 @@ function sign(opts) {
|
|
|
418
449
|
}
|
|
419
450
|
|
|
420
451
|
function _signOne(canonInput, keyObject, algorithm) {
|
|
452
|
+
// The canonicalized header octets, one code unit per octet — same boundary as
|
|
453
|
+
// the body hash above and as DKIM's.
|
|
454
|
+
var toSign = Buffer.from(canonInput, "latin1");
|
|
421
455
|
if (algorithm === "ed25519-sha256") {
|
|
422
456
|
// Ed25519 prehash variant — Node's `crypto.sign(null, msg, key)`
|
|
423
457
|
// accepts the message directly.
|
|
424
|
-
return nodeCrypto.sign(null,
|
|
425
|
-
.toString("base64");
|
|
458
|
+
return nodeCrypto.sign(null, toSign, keyObject).toString("base64");
|
|
426
459
|
}
|
|
427
460
|
// RSA-SHA256 / default — createSign + update + sign.
|
|
428
461
|
var signer = nodeCrypto.createSign("RSA-SHA256");
|
|
429
|
-
signer.update(
|
|
462
|
+
signer.update(toSign);
|
|
430
463
|
return signer.sign(keyObject).toString("base64");
|
|
431
464
|
}
|
|
432
465
|
|
package/lib/mail-auth.js
CHANGED
|
@@ -1926,10 +1926,15 @@ function _arcInstanceOf(value) {
|
|
|
1926
1926
|
}
|
|
1927
1927
|
|
|
1928
1928
|
async function arcVerify(rfc822, opts) {
|
|
1929
|
-
if (typeof rfc822 !== "string" || rfc822.length === 0) {
|
|
1929
|
+
if ((!Buffer.isBuffer(rfc822) && typeof rfc822 !== "string") || rfc822.length === 0) {
|
|
1930
1930
|
throw new MailAuthError("mail-auth/arc-bad-input",
|
|
1931
|
-
"arc.verify: rfc822 must be a non-empty string");
|
|
1931
|
+
"arc.verify: rfc822 must be a non-empty Buffer or string");
|
|
1932
1932
|
}
|
|
1933
|
+
// A seal covers the octets on the wire, so a caller holding them verifies
|
|
1934
|
+
// against them. Same boundary as DKIM, and the SAME function, so a string
|
|
1935
|
+
// cannot come to mean one thing here and another there: a Buffer is octets,
|
|
1936
|
+
// a string is text and its octets are its UTF-8 encoding.
|
|
1937
|
+
rfc822 = dkim._toWire(rfc822);
|
|
1933
1938
|
opts = opts || {};
|
|
1934
1939
|
// RFC 8617 §5.1.1 / RFC 6376 §3.4 — ARC-Message-Signature verification
|
|
1935
1940
|
// reuses the DKIM verifier, whose header/body split REQUIRES CRLF CRLF and
|
|
@@ -2083,6 +2088,15 @@ async function arcVerify(rfc822, opts) {
|
|
|
2083
2088
|
// 3. Per-hop AMS + AS verification.
|
|
2084
2089
|
var perHop = [];
|
|
2085
2090
|
var anyFail = false;
|
|
2091
|
+
// A hop that failed for a reason OTHER than the key being unreachable. RFC
|
|
2092
|
+
// 8601 §2.7 keeps `temperror` apart from `fail` for exactly this reason: one
|
|
2093
|
+
// is a statement about the message, the other about the resolver. Collapsing
|
|
2094
|
+
// them tells a consumer a seal was forged when a DNS query timed out.
|
|
2095
|
+
//
|
|
2096
|
+
// Tracked as the hard case rather than the transient one, because `anyFail`
|
|
2097
|
+
// without `anyHardFail` already means every non-passing hop was a temperror,
|
|
2098
|
+
// and a second flag could disagree with the first.
|
|
2099
|
+
var anyHardFail = false;
|
|
2086
2100
|
// RFC 8617 §5.2 — operator-tunable clock skew on t= (signing
|
|
2087
2101
|
// timestamp) and x= (expiration) tags. Default 5 min.
|
|
2088
2102
|
// A present clockSkewMs must be a non-negative finite integer; an Infinity /
|
|
@@ -2142,7 +2156,16 @@ async function arcVerify(rfc822, opts) {
|
|
|
2142
2156
|
amsErrors: amsResult.errors,
|
|
2143
2157
|
asErrors: asResult.errors,
|
|
2144
2158
|
});
|
|
2145
|
-
if (amsResult.result !== "pass" || asResult.result !== "pass")
|
|
2159
|
+
if (amsResult.result !== "pass" || asResult.result !== "pass") {
|
|
2160
|
+
anyFail = true;
|
|
2161
|
+
// Accumulated across ALL hops, not decided per hop: a temperror on one
|
|
2162
|
+
// hop and a genuine failure on another is a genuine failure, whichever
|
|
2163
|
+
// order they arrive in.
|
|
2164
|
+
if ((amsResult.result !== "pass" && amsResult.result !== "temperror") ||
|
|
2165
|
+
(asResult.result !== "pass" && asResult.result !== "temperror")) {
|
|
2166
|
+
anyHardFail = true;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2146
2169
|
}
|
|
2147
2170
|
|
|
2148
2171
|
// 4. Chain Validation per RFC 8617 §5.2.
|
|
@@ -2185,12 +2208,37 @@ async function arcVerify(rfc822, opts) {
|
|
|
2185
2208
|
var lastCv = perHopCv[perHopCv.length - 1];
|
|
2186
2209
|
var chainStatus;
|
|
2187
2210
|
var reasonOut = null;
|
|
2211
|
+
// Whether this verdict is worth retrying. A consumer that lets the chain
|
|
2212
|
+
// influence a disposition must not read a resolver outage as a forgery.
|
|
2213
|
+
var transientOut = false;
|
|
2188
2214
|
if (hopRuleViolation) {
|
|
2189
2215
|
chainStatus = "fail";
|
|
2190
2216
|
reasonOut = hopRuleViolation;
|
|
2191
2217
|
} else if (anyFail) {
|
|
2192
2218
|
chainStatus = "fail";
|
|
2193
|
-
|
|
2219
|
+
// The chain does not validate either way, so the wire token stays "fail" —
|
|
2220
|
+
// RFC 8617 §5.2 gives the chain none / pass / fail and nothing else, and
|
|
2221
|
+
// putting a word on the wire that receivers have no rule for would be a
|
|
2222
|
+
// worse answer than a coarse one. The distinction a consumer needs lives in
|
|
2223
|
+
// the structured verdict beside it.
|
|
2224
|
+
// A cv=fail recorded by a hop is TERMINAL — RFC 8617 §5.2 makes the chain
|
|
2225
|
+
// unrecoverable from that point, so it is reported as the reason even when
|
|
2226
|
+
// an upstream lookup also failed, and it is never transient. Retrying DNS
|
|
2227
|
+
// cannot turn it into a pass, and calling it transient tells a consumer to
|
|
2228
|
+
// defer and re-deliver mail that will never validate.
|
|
2229
|
+
//
|
|
2230
|
+
// Believing that claim requires the hop making it to have proved it made
|
|
2231
|
+
// it. cv= is a token in a header the sender wrote; only the ARC-Seal
|
|
2232
|
+
// covering that header says the named hop is who wrote it. So terminality
|
|
2233
|
+
// is gated on the LAST hop's seal verifying: when its own key lookup
|
|
2234
|
+
// temperrored, the token is unauthenticated text, and honouring it would
|
|
2235
|
+
// let anyone who can stall one DNS query stamp cv=fail on a chain and turn
|
|
2236
|
+
// a retryable outage into a permanent rejection.
|
|
2237
|
+
var lastSeal = perHop.length ? perHop[perHop.length - 1].asResult : null;
|
|
2238
|
+
var terminalCv = lastCv === "fail" && lastSeal === "pass";
|
|
2239
|
+
reasonOut = terminalCv ? "last-as-cv=fail"
|
|
2240
|
+
: (anyHardFail ? "signature-verification-failed" : "key-lookup-unavailable");
|
|
2241
|
+
transientOut = !terminalCv && !anyHardFail;
|
|
2194
2242
|
} else if (lastCv === "fail") {
|
|
2195
2243
|
chainStatus = "fail";
|
|
2196
2244
|
reasonOut = "last-as-cv=fail";
|
|
@@ -2211,6 +2259,7 @@ async function arcVerify(rfc822, opts) {
|
|
|
2211
2259
|
hops: perHop,
|
|
2212
2260
|
};
|
|
2213
2261
|
if (reasonOut) out.reason = reasonOut;
|
|
2262
|
+
if (transientOut) out.transient = true;
|
|
2214
2263
|
return out;
|
|
2215
2264
|
}
|
|
2216
2265
|
|
|
@@ -2333,7 +2382,10 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
|
|
|
2333
2382
|
// unreachable from b.mail.dkim.verify.
|
|
2334
2383
|
var verifyOpts = { dnsLookup: dnsLookup };
|
|
2335
2384
|
verifyOpts[ARC_AMS_REUSE] = true;
|
|
2336
|
-
|
|
2385
|
+
// As OCTETS. `synthetic` is already the wire form, one code unit per octet,
|
|
2386
|
+
// and handing it over as a string would have the verifier read it as text and
|
|
2387
|
+
// re-encode it as UTF-8 — hashing bytes this message does not contain.
|
|
2388
|
+
var rv = await dkim.verify(dkim._wireBytes(synthetic), verifyOpts);
|
|
2337
2389
|
if (!Array.isArray(rv) || rv.length === 0) {
|
|
2338
2390
|
return { result: "permerror", errors: ["ams: dkim verifier returned no results"] };
|
|
2339
2391
|
}
|
|
@@ -2401,7 +2453,11 @@ function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
|
|
|
2401
2453
|
var sigBuf = Buffer.from(sigB64, "base64");
|
|
2402
2454
|
var verified;
|
|
2403
2455
|
try {
|
|
2404
|
-
|
|
2456
|
+
// latin1: the signer sealed the canonicalized OCTETS (lib/mail-arc-sign.js),
|
|
2457
|
+
// and the message reaches here one code unit per octet. Re-encoding as UTF-8
|
|
2458
|
+
// would verify against bytes the signer never saw, for any message carrying
|
|
2459
|
+
// 8-bit content.
|
|
2460
|
+
verified = nodeCrypto.verify(nodeAlgo, Buffer.from(signedString, "latin1"), keyObj, sigBuf);
|
|
2405
2461
|
} catch (e) {
|
|
2406
2462
|
return { result: "permerror",
|
|
2407
2463
|
errors: [label + ": verify threw: " + ((e && e.message) || String(e))] };
|
|
@@ -2429,10 +2485,19 @@ void C; // C is imported for future TIME constants in policy fetchers.
|
|
|
2429
2485
|
// // trustedDomain: "mailgun.net" }
|
|
2430
2486
|
|
|
2431
2487
|
async function arcEvaluate(rfc822, opts) {
|
|
2432
|
-
if (typeof rfc822 !== "string" || rfc822.length === 0) {
|
|
2488
|
+
if ((!Buffer.isBuffer(rfc822) && typeof rfc822 !== "string") || rfc822.length === 0) {
|
|
2433
2489
|
throw new MailAuthError("mail-auth/arc-bad-input",
|
|
2434
|
-
"arc.evaluate: rfc822 must be a non-empty string");
|
|
2435
|
-
}
|
|
2490
|
+
"arc.evaluate: rfc822 must be a non-empty Buffer or string");
|
|
2491
|
+
}
|
|
2492
|
+
// Two views, and the distinction matters. The header re-scan below reads the
|
|
2493
|
+
// message as a wire STRING; `arcVerify` resolves its own argument, and
|
|
2494
|
+
// `_toWire` is idempotent on a Buffer but NOT on a string — applied twice it
|
|
2495
|
+
// reads the latin1 wire form as text and re-encodes it as UTF-8, so the
|
|
2496
|
+
// octets checked stop being the octets signed. Passing the wire string on
|
|
2497
|
+
// made a Unicode message pass `arc.verify` and fail `arc.evaluate`, which are
|
|
2498
|
+
// supposed to answer the same question.
|
|
2499
|
+
rfc822 = dkim._toWire(rfc822);
|
|
2500
|
+
var rfc822Octets = dkim._wireBytes(rfc822);
|
|
2436
2501
|
opts = opts || {};
|
|
2437
2502
|
if (!Array.isArray(opts.trustedSealers)) {
|
|
2438
2503
|
throw new MailAuthError("mail-auth/arc-bad-trusted-sealers",
|
|
@@ -2448,7 +2513,7 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
2448
2513
|
trusted[d.toLowerCase()] = true;
|
|
2449
2514
|
}
|
|
2450
2515
|
|
|
2451
|
-
var verdict = await arcVerify(
|
|
2516
|
+
var verdict = await arcVerify(rfc822Octets, opts);
|
|
2452
2517
|
var out = {
|
|
2453
2518
|
chainStatus: verdict.chainStatus,
|
|
2454
2519
|
hopCount: verdict.hopCount,
|
|
@@ -2469,6 +2534,9 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
2469
2534
|
breakAt: null,
|
|
2470
2535
|
};
|
|
2471
2536
|
if (verdict.reason) out.reason = verdict.reason;
|
|
2537
|
+
// Carried for the same reason `reason` is: a caller deciding what to do about
|
|
2538
|
+
// a chain that did not validate needs to know whether the answer is stable.
|
|
2539
|
+
if (verdict.transient) out.transient = true;
|
|
2472
2540
|
|
|
2473
2541
|
// Re-extract per-hop d= (signing domain on AS) AND the AAR text from
|
|
2474
2542
|
// the original headers — the verify-result shape doesn't carry
|
|
@@ -2695,9 +2763,19 @@ function authResultsEmit(opts) {
|
|
|
2695
2763
|
// message: rfc5322Bytes, // string or Buffer
|
|
2696
2764
|
// authservId: "mx.example.com",
|
|
2697
2765
|
// });
|
|
2698
|
-
// // → { spf, dkim, from, dmarc, authResults }
|
|
2766
|
+
// // → { spf, dkim, from, dmarc, arc, authResults }
|
|
2699
2767
|
// if (v.dmarc.recommendedAction === "reject") { /* refuse 550 5.7.1 */ }
|
|
2700
2768
|
//
|
|
2769
|
+
// `arc.chainStatus` is "none" / "pass" / "fail" — the vocabulary RFC 8617 §5.2
|
|
2770
|
+
// gives the chain, and the token that goes on the wire in
|
|
2771
|
+
// Authentication-Results. A "fail" carries `arc.reason`, and `arc.transient` is
|
|
2772
|
+
// true when the chain did not validate because a key could not be looked up
|
|
2773
|
+
// rather than because a seal did not verify. Those want opposite responses: one
|
|
2774
|
+
// is a statement about the sender, the other about a resolver, and a consumer
|
|
2775
|
+
// that lets the chain influence a disposition must not read an outage as a
|
|
2776
|
+
// forgery. The header cannot carry the distinction, which is why the structured
|
|
2777
|
+
// verdict does.
|
|
2778
|
+
//
|
|
2701
2779
|
// From-header discipline (RFC 9989 §5.3.1, RFC 7489 §6.6.1 before it):
|
|
2702
2780
|
// DMARC evaluates exactly one author domain. A message with zero From fields, several From fields,
|
|
2703
2781
|
// or several author addresses in one field is the header-duplication
|
|
@@ -2834,14 +2912,13 @@ async function inboundVerify(opts) {
|
|
|
2834
2912
|
}
|
|
2835
2913
|
var message = opts.message;
|
|
2836
2914
|
if (Buffer.isBuffer(message)) {
|
|
2837
|
-
//
|
|
2838
|
-
//
|
|
2839
|
-
//
|
|
2840
|
-
//
|
|
2841
|
-
//
|
|
2842
|
-
//
|
|
2843
|
-
|
|
2844
|
-
message = message.toString("utf8");
|
|
2915
|
+
// One code unit per octet, which is what the DKIM verifier now hashes.
|
|
2916
|
+
// This used to decode as UTF-8, on the reasoning that the verifier
|
|
2917
|
+
// re-encoded as UTF-8 and so only valid-UTF-8 content could round-trip.
|
|
2918
|
+
// That was accurate and it meant a message carrying ordinary 8-bit content
|
|
2919
|
+
// verified as a DKIM fail, leaving DMARC to fall back to the SPF identity
|
|
2920
|
+
// — and nothing to fall back to once the message had been forwarded.
|
|
2921
|
+
message = message.toString("latin1");
|
|
2845
2922
|
}
|
|
2846
2923
|
if (typeof message !== "string" || message.length === 0) {
|
|
2847
2924
|
throw new MailAuthError("mail-auth/inbound-bad-message",
|
|
@@ -2893,10 +2970,40 @@ async function inboundVerify(opts) {
|
|
|
2893
2970
|
if (opts.clockSkewMs !== undefined) dkimVerifyOpts.clockSkewMs = opts.clockSkewMs;
|
|
2894
2971
|
if (opts.maxSignatures !== undefined) dkimVerifyOpts.maxSignatures = opts.maxSignatures;
|
|
2895
2972
|
if (opts.minRsaBits !== undefined) dkimVerifyOpts.minRsaBits = opts.minRsaBits;
|
|
2896
|
-
|
|
2973
|
+
// As OCTETS. `message` is the wire form here, one code unit per octet, and
|
|
2974
|
+
// both verifiers read a string as text — handing one over would re-encode it
|
|
2975
|
+
// as UTF-8 and verify against bytes the sender never signed.
|
|
2976
|
+
var messageOctets = dkim._wireBytes(message);
|
|
2977
|
+
var dkimResults = await dkim.verify(messageOctets, dkimVerifyOpts);
|
|
2978
|
+
|
|
2979
|
+
// ARC (RFC 8617) — the chain a forwarder or list left behind, carrying the
|
|
2980
|
+
// authentication verdict from before it rewrote the message. A receiver
|
|
2981
|
+
// without it sees forwarded mail fail DMARC on the forwarder's identity with
|
|
2982
|
+
// no way to recover the original result, which is the case ARC exists for.
|
|
2983
|
+
//
|
|
2984
|
+
// The chain is EVIDENCE, not a gate: a malformed one is reported as a fail
|
|
2985
|
+
// verdict and the pipeline continues, because refusing mail over a defect in
|
|
2986
|
+
// some intermediary's headers would be the receiver punishing the wrong
|
|
2987
|
+
// party. Deciding what a passing chain buys a sender is local policy, which
|
|
2988
|
+
// is what b.mail.arc.evaluate + trustedSealers is for.
|
|
2989
|
+
var arcResult;
|
|
2990
|
+
try {
|
|
2991
|
+
arcResult = await arcVerify(messageOctets, { dnsLookup: opts.dnsLookup });
|
|
2992
|
+
} catch (e) {
|
|
2993
|
+
arcResult = { chainStatus: "fail", hopCount: 0, hops: [],
|
|
2994
|
+
reason: "arc-verify-error: " + ((e && e.message) || String(e)) };
|
|
2995
|
+
}
|
|
2897
2996
|
|
|
2898
2997
|
// From header + DMARC policy/alignment.
|
|
2899
|
-
|
|
2998
|
+
// The From header is read as TEXT, while DKIM and ARC above read the same
|
|
2999
|
+
// message as OCTETS. Both are needed and they are not the same view: a
|
|
3000
|
+
// signature covers the bytes on the wire, and an address is characters —
|
|
3001
|
+
// an RFC 6531 local part written in UTF-8 aligns against a DMARC record
|
|
3002
|
+
// only once it is decoded as UTF-8, and reading it octet-per-code-unit
|
|
3003
|
+
// would compare mojibake. Only the header block is decoded, so the body's
|
|
3004
|
+
// size does not pay for it.
|
|
3005
|
+
var headerOctets = _splitHeaderBlock(message).headers;
|
|
3006
|
+
var from = _extractFromHeaders(Buffer.from(headerOctets, "latin1").toString("utf8"));
|
|
2900
3007
|
var dmarc;
|
|
2901
3008
|
if (from.count === 1 && from.address && from.domain) {
|
|
2902
3009
|
dmarc = await dmarcEvaluate({
|
|
@@ -2956,10 +3063,15 @@ async function inboundVerify(opts) {
|
|
|
2956
3063
|
var dmarcEntry = { method: "dmarc", result: dmarc.result };
|
|
2957
3064
|
if (from.address) dmarcEntry.from = from.address;
|
|
2958
3065
|
arResults.push(dmarcEntry);
|
|
3066
|
+
// RFC 8601 §2.7.6 — the arc method. "none" is reported like any other
|
|
3067
|
+
// verdict: a receiver reading the header can tell a chain that was checked
|
|
3068
|
+
// and found absent from a receiver that does not check.
|
|
3069
|
+
arResults.push({ method: "arc", result: arcResult.chainStatus });
|
|
2959
3070
|
authResults = authResultsEmit({ authservId: opts.authservId, results: arResults });
|
|
2960
3071
|
}
|
|
2961
3072
|
|
|
2962
|
-
return { spf: spf, dkim: dkimResults, from: from, dmarc: dmarc,
|
|
3073
|
+
return { spf: spf, dkim: dkimResults, from: from, dmarc: dmarc,
|
|
3074
|
+
arc: arcResult, authResults: authResults };
|
|
2963
3075
|
}
|
|
2964
3076
|
|
|
2965
3077
|
// ---- DMARC aggregate (RUA) report parser (RFC 9990 §3 / draft-ietf-dmarc-aggregate-reporting) ----
|
package/lib/mail-crypto-pgp.js
CHANGED
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
* Surface:
|
|
82
82
|
* var sigBundle = b.mail.crypto.pgp.sign({
|
|
83
83
|
* message: "rfc822 body bytes",
|
|
84
|
-
* privateKeyPem: "
|
|
84
|
+
* privateKeyPem: "<PEM-encoded PKCS#8 private key>",
|
|
85
85
|
* passphrase: undefined | "...", // optional
|
|
86
86
|
* audit: opts.audit, // optional b.audit handle
|
|
87
87
|
* });
|
package/lib/mail-dkim.js
CHANGED
|
@@ -179,6 +179,51 @@ function _parseHeaders(rawHeaders) {
|
|
|
179
179
|
return out;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
// ---- The octet boundary ----
|
|
183
|
+
//
|
|
184
|
+
// RFC 6376 §3.4 signs a canonicalized OCTET stream, and a JavaScript string
|
|
185
|
+
// cannot hold one: a message decoded as UTF-8 loses every sequence that is not
|
|
186
|
+
// valid UTF-8 to U+FFFD, and no string re-encodes back to those bytes. Signing
|
|
187
|
+
// hashed `utf8(decode_utf8(original))`, equal to the original only when the
|
|
188
|
+
// original was already valid UTF-8 — 128 of the 256 single-octet values do not
|
|
189
|
+
// survive it.
|
|
190
|
+
//
|
|
191
|
+
// So the message is carried as latin1 from here down. latin1 is a bijection
|
|
192
|
+
// between bytes and code units 0x00-0xFF, which makes every string operation
|
|
193
|
+
// below an operation on the octets themselves. That is not an approximation of a
|
|
194
|
+
// byte implementation, it IS one: §3.4 canonicalization touches only SP, HTAB,
|
|
195
|
+
// CR and LF, none of which can appear inside a multi-byte UTF-8 sequence, so a
|
|
196
|
+
// byte scan and this character scan agree on every input.
|
|
197
|
+
//
|
|
198
|
+
// A string argument keeps its present meaning — the octets are that string's
|
|
199
|
+
// UTF-8 encoding — so it is encoded once here and rejoins the same path. One
|
|
200
|
+
// path, and the caller's existing signatures are unchanged.
|
|
201
|
+
// NOT IDEMPOTENT ON A STRING. Applied twice to a string, the second pass reads
|
|
202
|
+
// the latin1 wire form as text and re-encodes it as UTF-8, so the octets are no
|
|
203
|
+
// longer the message's. It IS idempotent on a Buffer. So a function that
|
|
204
|
+
// resolves a message and then hands it to another function that resolves it
|
|
205
|
+
// again must pass OCTETS — `_wireBytes(_toWire(x))` — not the wire string.
|
|
206
|
+
// `arc.evaluate` passed the string to `arc.verify` and the two then disagreed
|
|
207
|
+
// about whether the same Unicode message's chain passed.
|
|
208
|
+
//
|
|
209
|
+
// Refuses anything else rather than coercing it. `String(x)` on an object
|
|
210
|
+
// yields "[object Object]", which is a message this would go on to sign — and
|
|
211
|
+
// the caller gets a valid signature over eleven characters instead of an error.
|
|
212
|
+
function _toWire(rfc822) {
|
|
213
|
+
if (Buffer.isBuffer(rfc822)) return rfc822.toString("latin1");
|
|
214
|
+
if (typeof rfc822 !== "string") {
|
|
215
|
+
throw new DkimError("dkim/bad-input",
|
|
216
|
+
"message must be a Buffer or a string — got " +
|
|
217
|
+
(rfc822 === null ? "null" : typeof rfc822));
|
|
218
|
+
}
|
|
219
|
+
return Buffer.from(rfc822, "utf8").toString("latin1");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The octets of a string that is already in the latin1 wire form above.
|
|
223
|
+
function _wireBytes(wire) {
|
|
224
|
+
return Buffer.from(wire, "latin1");
|
|
225
|
+
}
|
|
226
|
+
|
|
182
227
|
// ---- Hashing + signing ----
|
|
183
228
|
|
|
184
229
|
function _bodyHashB64(body, algorithm, canonBody, lcap) {
|
|
@@ -192,25 +237,29 @@ function _bodyHashB64(body, algorithm, canonBody, lcap) {
|
|
|
192
237
|
// octet stream to lcap (slicing the raw body before canonicalizing diverges
|
|
193
238
|
// whenever relaxed canon changes the byte count within the first lcap
|
|
194
239
|
// octets — WSP-run collapse, trailing-WSP strip, CRLF normalization).
|
|
240
|
+
var buf = _wireBytes(canonicalized);
|
|
195
241
|
if (typeof lcap === "number" && isFinite(lcap) && lcap >= 0) {
|
|
196
|
-
var buf = Buffer.from(canonicalized, "utf8");
|
|
197
242
|
hash.update(lcap < buf.length ? buf.subarray(0, lcap) : buf);
|
|
198
243
|
} else {
|
|
199
|
-
hash.update(
|
|
244
|
+
hash.update(buf);
|
|
200
245
|
}
|
|
201
246
|
return hash.digest("base64");
|
|
202
247
|
}
|
|
203
248
|
|
|
204
249
|
function _signString(strToSign, privateKey, algorithm) {
|
|
250
|
+
// §3.7's data hash is assembled from canonicalized header octets, so it takes
|
|
251
|
+
// the same treatment as the body: a header field carrying unencoded 8-bit
|
|
252
|
+
// content is signed as the octets it holds.
|
|
253
|
+
var toSign = _wireBytes(strToSign);
|
|
205
254
|
if (algorithm === "rsa-sha256") {
|
|
206
255
|
return nodeCrypto.createSign("RSA-SHA256")
|
|
207
|
-
.update(
|
|
256
|
+
.update(toSign).sign(privateKey).toString("base64");
|
|
208
257
|
}
|
|
209
258
|
if (algorithm === "ed25519-sha256") {
|
|
210
259
|
// Ed25519 in node:crypto signs the raw message (it hashes
|
|
211
260
|
// internally as part of EdDSA). Per RFC 8463 the verifier still
|
|
212
261
|
// sees `a=ed25519-sha256` because the body hash is sha256.
|
|
213
|
-
return nodeCrypto.sign(null,
|
|
262
|
+
return nodeCrypto.sign(null, toSign, privateKey)
|
|
214
263
|
.toString("base64");
|
|
215
264
|
}
|
|
216
265
|
throw new DkimError("dkim/bad-algorithm",
|
|
@@ -335,11 +384,15 @@ function create(opts) {
|
|
|
335
384
|
}
|
|
336
385
|
|
|
337
386
|
function sign(rfc822) {
|
|
338
|
-
|
|
387
|
+
var gaveBuffer = Buffer.isBuffer(rfc822);
|
|
388
|
+
if ((!gaveBuffer && typeof rfc822 !== "string") || rfc822.length === 0) {
|
|
339
389
|
throw new DkimError("dkim/bad-input",
|
|
340
|
-
"sign() requires the rfc822 wire format as a non-empty string");
|
|
390
|
+
"sign() requires the rfc822 wire format as a non-empty Buffer or string");
|
|
341
391
|
}
|
|
342
392
|
var t0 = Date.now();
|
|
393
|
+
// A Buffer's own octets, or a string's UTF-8 encoding, one code unit per
|
|
394
|
+
// octet from here down.
|
|
395
|
+
rfc822 = _toWire(rfc822);
|
|
343
396
|
var split = _splitHeadersBody(rfc822);
|
|
344
397
|
var parsedHeaders = _parseHeaders(split.headers);
|
|
345
398
|
|
|
@@ -455,7 +508,17 @@ function create(opts) {
|
|
|
455
508
|
durationMs: Date.now() - t0,
|
|
456
509
|
});
|
|
457
510
|
|
|
458
|
-
|
|
511
|
+
// Returned in the shape it arrived in. A caller who handed over octets is
|
|
512
|
+
// relaying what comes back, and handing them a string would put the
|
|
513
|
+
// corruption this signs around straight back into the message.
|
|
514
|
+
var out = dkimHeaderLine + rfc822;
|
|
515
|
+
// A string caller gets their own string back, not the latin1 wire form of
|
|
516
|
+
// it: the octets are that string's UTF-8 encoding, so decoding them as
|
|
517
|
+
// UTF-8 reproduces exactly what they passed, with the ASCII signature
|
|
518
|
+
// header in front. Returning the wire form instead handed back a mojibake
|
|
519
|
+
// string that no longer re-encoded to the bytes just signed, so the
|
|
520
|
+
// message failed its own verify.
|
|
521
|
+
return gaveBuffer ? _wireBytes(out) : _wireBytes(out).toString("utf8");
|
|
459
522
|
}
|
|
460
523
|
|
|
461
524
|
return {
|
|
@@ -808,7 +871,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
|
|
|
808
871
|
// accept legacy l= senders opts in via verify({ acceptBodyLengthLimit: true }).
|
|
809
872
|
if (lcap !== undefined && !verifyOpts.acceptBodyLengthLimit) {
|
|
810
873
|
var fullCanon = canonBody === "simple" ? _canonBodySimple(body) : _canonBodyRelaxed(body);
|
|
811
|
-
if (lcap < Buffer.byteLength(fullCanon, "
|
|
874
|
+
if (lcap < Buffer.byteLength(fullCanon, "latin1")) {
|
|
812
875
|
return { result: "fail",
|
|
813
876
|
errors: ["DKIM-Signature l= leaves appended body content unsigned " +
|
|
814
877
|
"(RFC 6376 §8.2 append-after-signature)"] };
|
|
@@ -924,7 +987,7 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
|
|
|
924
987
|
var verified;
|
|
925
988
|
try {
|
|
926
989
|
verified = nodeCrypto.verify(nodeAlgo,
|
|
927
|
-
|
|
990
|
+
_wireBytes(canonicalizedHeaders), keyObj, sigBuf);
|
|
928
991
|
} catch (e) {
|
|
929
992
|
return { result: "permerror",
|
|
930
993
|
errors: ["DKIM verify threw: " + ((e && e.message) || String(e))] };
|
|
@@ -943,10 +1006,14 @@ var DKIM_CLOCK_SKEW_MS_MAX = C.TIME.hours(24);
|
|
|
943
1006
|
var DKIM_CLOCK_SKEW_MS_DEFAULT = C.TIME.minutes(5);
|
|
944
1007
|
|
|
945
1008
|
async function verify(rfc822, opts) {
|
|
946
|
-
if (typeof rfc822 !== "string" || rfc822.length === 0) {
|
|
1009
|
+
if ((!Buffer.isBuffer(rfc822) && typeof rfc822 !== "string") || rfc822.length === 0) {
|
|
947
1010
|
throw new DkimError("dkim/bad-input",
|
|
948
|
-
"verify(): rfc822 must be a non-empty string");
|
|
1011
|
+
"verify(): rfc822 must be a non-empty Buffer or string");
|
|
949
1012
|
}
|
|
1013
|
+
// The same octet boundary as sign(): a receiver holding wire bytes verifies
|
|
1014
|
+
// against those bytes rather than against a UTF-8 decode of them, which is
|
|
1015
|
+
// what a sender's signature actually covers.
|
|
1016
|
+
rfc822 = _toWire(rfc822);
|
|
950
1017
|
opts = opts || {};
|
|
951
1018
|
validateOpts(opts, ["dnsLookup", "audit", "clockSkewMs", "maxSignatures",
|
|
952
1019
|
"minRsaBits", "acceptBodyLengthLimit"], "mail.dkim.verify");
|
|
@@ -1392,6 +1459,8 @@ module.exports = {
|
|
|
1392
1459
|
_canonBodyRelaxedForTest: _canonBodyRelaxed,
|
|
1393
1460
|
_canonBodySimpleForTest: _canonBodySimple,
|
|
1394
1461
|
_stripBTagValue: _stripBTagValue, // RFC 6376 §3.5 — tag-aware b= zeroing; shared by the ARC seal verifier (internal cross-module helper)
|
|
1462
|
+
_toWire: _toWire, // Buffer|string → the message one code unit per octet; shared with the ARC verifier so both modules answer "which bytes is this message" the same way (internal cross-module helper)
|
|
1463
|
+
_wireBytes: _wireBytes, // the octets of a string already in that wire form (internal cross-module helper)
|
|
1395
1464
|
_stripBTagValueForTest: _stripBTagValue,
|
|
1396
1465
|
// The header-block parser that produces the { name, value } pairs fed to the
|
|
1397
1466
|
// canonicalizers. Exposed so a golden-vector test can pin its byte-exact
|