@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/NOTICE +1 -1
  3. package/README.md +5 -5
  4. package/lib/agent-audit.js +27 -2
  5. package/lib/ai-adverse-decision.js +18 -2
  6. package/lib/audit-sign.js +24 -5
  7. package/lib/auth/passkey.js +4 -1
  8. package/lib/codepoint-class.js +72 -0
  9. package/lib/cookies.js +7 -10
  10. package/lib/credential-hash.js +8 -1
  11. package/lib/crypto.js +7 -5
  12. package/lib/db-file-lifecycle.js +14 -3
  13. package/lib/db.js +505 -49
  14. package/lib/guard-auth.js +34 -11
  15. package/lib/guard-filename.js +41 -33
  16. package/lib/guard-html.js +10 -2
  17. package/lib/guard-list-unsubscribe.js +6 -1
  18. package/lib/guard-managesieve-command.js +73 -12
  19. package/lib/guard-regex.js +3 -5
  20. package/lib/guard-smtp-command.js +20 -4
  21. package/lib/guard-svg.js +6 -1
  22. package/lib/guard-yaml.js +60 -15
  23. package/lib/http-client.js +17 -3
  24. package/lib/mail-agent.js +29 -13
  25. package/lib/mail-arc-sign.js +40 -7
  26. package/lib/mail-auth.js +134 -22
  27. package/lib/mail-crypto-pgp.js +1 -1
  28. package/lib/mail-dkim.js +80 -11
  29. package/lib/mail-helo.js +10 -0
  30. package/lib/mail-rbl.js +10 -3
  31. package/lib/mail-send-deliver.js +151 -32
  32. package/lib/mail-server-imap.js +186 -89
  33. package/lib/mail-server-jmap.js +31 -4
  34. package/lib/mail-server-managesieve.js +198 -42
  35. package/lib/mail-server-mx.js +191 -38
  36. package/lib/mail-server-net.js +281 -1
  37. package/lib/mail-server-pop3.js +89 -41
  38. package/lib/mail-server-rate-limit.js +104 -6
  39. package/lib/mail-server-submission.js +183 -35
  40. package/lib/mail-server-tls.js +48 -3
  41. package/lib/mail-store.js +33 -11
  42. package/lib/mail.js +355 -17
  43. package/lib/mcp.js +11 -3
  44. package/lib/middleware/bearer-auth.js +6 -1
  45. package/lib/middleware/fetch-metadata.js +5 -1
  46. package/lib/middleware/headers.js +7 -10
  47. package/lib/middleware/require-mtls.js +8 -1
  48. package/lib/network-dns-resolver.js +71 -8
  49. package/lib/network-dns.js +26 -0
  50. package/lib/network-smtp-policy.js +42 -10
  51. package/lib/network-tls.js +18 -0
  52. package/lib/redact.js +13 -3
  53. package/lib/retention.js +22 -2
  54. package/lib/safe-mount-info.js +39 -6
  55. package/lib/safe-smtp.js +96 -1
  56. package/lib/safe-url.js +8 -2
  57. package/lib/self-update.js +4 -1
  58. package/lib/vendor/MANIFEST.json +12 -12
  59. package/lib/vendor/blamejs-pki.cjs +672 -75
  60. package/lib/watcher.js +31 -6
  61. package/lib/ws-client.js +17 -2
  62. package/lib/yaml-lex.js +55 -1
  63. package/package.json +1 -1
  64. package/sbom.cdx.json +6 -6
@@ -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
- if (!Array.isArray(allowedHosts) || allowedHosts.length === 0) return true;
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
- var pin = (Array.isArray(allowedHosts) && allowedHosts.length > 0) ? allowedHosts : null;
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
- if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
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
- // active posture. For expunge semantics, the floor IS the minimum
544
- // TTL — messages younger than the floor MUST NOT be hard-deleted,
545
- // even on operator request. Distinct from `b.retention.
546
- // complianceFloor(posture, candidateTtl)` which composes the
547
- // candidate TTL into a max that primitive's "candidate must be
548
- // positive" contract doesn't apply here because expunge means TTL=0.
549
- // Read the floor table directly.
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
- floorMs = retentionModule.COMPLIANCE_RETENTION_FLOOR_MS[posture] || 0;
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
- function _actorShape(actor) {
779
- if (!actor || typeof actor !== "object") return { id: "<unknown>" };
780
- return { id: actor.id, roles: actor.roles || [] };
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,
@@ -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
- return nodeCrypto.createHash(hashAlgo).update(canonical).digest("base64");
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
- validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
186
- MailAuthError, "arc-sign/bad-input");
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
- var sealedRfc822 = prependedHeaders + opts.rfc822;
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, Buffer.from(canonInput, "utf8"), keyObject)
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(canonInput);
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") anyFail = true;
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
- reasonOut = "signature-verification-failed";
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
- var rv = await dkim.verify(synthetic, verifyOpts);
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
- verified = nodeCrypto.verify(nodeAlgo, Buffer.from(signedString, "utf8"), keyObj, sigBuf);
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(rfc822, opts);
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
- // DKIM canonicalization re-encodes the string form as UTF-8
2838
- // (lib/mail-dkim.js hashes Buffer.from(canonicalized, "utf8")), so
2839
- // the byte→string decode must be utf8 for valid-UTF-8 content to
2840
- // round-trip exactly. Non-UTF-8 8-bit content cannot survive any
2841
- // decode + utf8 re-encode; such messages verify as DKIM fail and
2842
- // DMARC falls back to the SPF identity (RFC 9989 §4 one
2843
- // aligned authenticator is sufficient to pass).
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
- var dkimResults = await dkim.verify(message, dkimVerifyOpts);
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
- var from = _extractFromHeaders(_splitHeaderBlock(message).headers);
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, authResults: authResults };
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) ----
@@ -81,7 +81,7 @@
81
81
  * Surface:
82
82
  * var sigBundle = b.mail.crypto.pgp.sign({
83
83
  * message: "rfc822 body bytes",
84
- * privateKeyPem: "-----BEGIN PRIVATE KEY----- ...",
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(canonicalized);
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(strToSign).sign(privateKey).toString("base64");
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, Buffer.from(strToSign, "utf8"), privateKey)
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
- if (typeof rfc822 !== "string" || rfc822.length === 0) {
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
- return dkimHeaderLine + rfc822;
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, "utf8")) {
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
- Buffer.from(canonicalizedHeaders, "utf8"), keyObj, sigBuf);
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