@blamejs/core 0.7.73 → 0.7.74

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.74** (2026-05-06) — Email receive-side parity: DMARC aggregate (RUA) report parser + ARC trust evaluation + Authentication-Results header builder + TLS-RPT receive-side report parser. Closes the "framework can send mail compliantly but can't receive compliantly" gap. **`b.mail.dmarc.parseAggregateReport(xmlBytes, { contentType? })`** parses RFC 7489 §7.2 aggregate XML reports through the framework's existing `lib/parsers/safe-xml.js` (the existing security-focused XML parser handles XXE / DOCTYPE / entity-expansion defenses by default). Auto-detects gzip via magic bytes (`0x1f 0x8b`) or `Content-Type: application/gzip`. Returns `{ reportMetadata, policyPublished, records, totals }` with per-record source-IP / count / policy-evaluated dispositions / identifiers / DKIM + SPF auth results, plus aggregated `messages` / `aligned` / `notAligned` totals operators want for dashboards. Caps report size at 8 MiB and records-per-report at 10 000. **`b.mail.arc.evaluate(rfc822, { trustedSealers })`** wraps the existing `arc.verify` cryptographic chain check with the operator-side trust decision: given a passing chain, did any hop in the chain belong to a sealer the operator trusts? Returns `{ chainStatus, trusted, trustedHop, trustedDomain }` walking hops most-recent-first so the deepest trusted sealer wins. **`b.mail.authResults.emit({ authservId, results, fold? })`** builds the RFC 8601 Authentication-Results header value — operators consume per-method results from `b.mail.spf.verify` / `b.mail.dmarc.evaluate` / `b.mail.arc.verify`, hand them to `.emit`, and the framework formats the conformant header string with method-specific properties (`smtp.mailfrom`, `header.d`, `header.from`, `policy.iprev`, `policy.ip`, `policy.tls`). Refuses unknown methods / results at config-mistake time. **`b.network.smtp.tlsRpt.parseReport(body, { contentType? })`** is the receive-side counterpart to `tlsRpt.recordShape` / `tlsRpt.submit` — accepts a Buffer or string, auto-detects gzip, parses JSON, validates the RFC 8460 §4.4 required-fields shape (`organization-name`, `date-range`, `report-id`, `policies`), aggregates `total-successful-session-count` / `total-failure-session-count` across policies. Caps report size at 8 MiB and policies-per-report at 1024.
12
+
11
13
  - **0.7.73** (2026-05-06) — `b.auth.aal` + `b.middleware.requireAal({ minimum })` — NIST SP 800-63-4 Authentication Assurance Level bands. **`b.auth.aal.fromMethods({ password, totp, webauthn, ... })`** combines a set of operator-asserted authenticator methods into the resulting band: `AAL1` (single factor — memorized secret OR single-factor cryptographic), `AAL2` (multi-factor — memorized secret + OTP/SMS/hardware/mTLS), `AAL3` (phishing-resistant multi-factor — WebAuthn / passkey / hardware-+-PIN). Recognized methods: `password`, `pin`, `totp`, `sms`, `webauthn`, `passkey`, `hardware`, `mtls`. **`b.middleware.requireAal({ minimum, getAal?, audit?, realm? })`** gates routes by the request's AAL band — reads `req.user.aal` by default (or operator-supplied `getAal(req)`), compares against the minimum, returns 401 with `WWW-Authenticate: AAL-StepUp realm="...", required="AAL2"` on insufficient assurance. The bespoke scheme name signals to the operator's frontend that a step-up flow should be triggered (re-prompt for TOTP / passkey) without reusing the generic `Bearer` challenge namespace. Audit emits `auth.aal.granted` / `auth.aal.denied` (drop-silent on observability sink failure). The framework leaves AMR/ACR claim emission to the operator's IdP; the new `b.auth.aal.AMR` constants object provides consistent OIDC-conformant strings for operators emitting access tokens with AAL info. Also exposes `b.auth.aal.meets(actual, required)` for ad-hoc band comparisons outside the middleware. **CI vendor-manifest gate fix**: vendor files now have an explicit `.gitattributes` `lib/vendor/** -text binary` declaration so git never rewrites line endings between Windows and Linux checkouts. The `lib/vendor/noble-ciphers.cjs` file was renormalized to LF on disk and re-hashed in `lib/vendor/MANIFEST.json`. Closes the v0.7.65–0.7.72 npm-publish.yml smoke-test failures (`vendor manifest: @noble/ciphers :: server hash matches`).
12
14
 
13
15
  - **0.7.72** (2026-05-06) — `b.mail` gains EAI / SMTPUTF8 / Punycode-IDN support (RFC 6531 / 6532 / 6533 / 3492). **Internationalized email addresses** (`müller@münchen.example`) now validate through `b.mail.send()` — `_isValidEmail` detects non-ASCII content, converts the IDN domain to Punycode via Node's `url.domainToASCII`, and re-tests the assembled `local@ascii-domain` against the framework's pragmatic email regex. Local parts may legitimately contain Unicode under RFC 6531 §3.3 (the regex check substitutes a placeholder local for the format gate; the original local-part is still refused if it contains CRLF/NUL header-injection bytes). **`b.mail.toAscii(domain)`** and **`b.mail.toUnicode(domain)`** are the operator-facing wrappers around `node:url`'s IDN helpers — one obvious place to reach for Punycode encoding when handling addresses outside `send()`. **SMTP transport** now captures EHLO extension lines (250-X continuations) and detects `SMTPUTF8`. Messages whose from / to / cc / bcc / subject contain non-ASCII octets compute `requiresSmtpUtf8 = true`; when the peer advertises `SMTPUTF8` the transport appends ` SMTPUTF8` to `MAIL FROM:<...>` per RFC 6531 §3.4. When the peer does NOT advertise `SMTPUTF8` AND the message requires it, the transport refuses with `mail/smtp-failed: eai-required-not-supported` rather than emit a mangled wire (some peers would silently corrupt headers downstream). Pure-ASCII messages continue without the keyword (some legacy mailboxes reject `SMTPUTF8` outright on transactions that don't need it).
package/lib/mail-auth.js CHANGED
@@ -34,10 +34,12 @@
34
34
 
35
35
  var dns = require("node:dns");
36
36
  var dnsPromises = dns.promises;
37
+ var zlib = require("node:zlib");
37
38
  var lazyRequire = require("./lazy-require");
38
39
  var validateOpts = require("./validate-opts");
39
40
  var C = require("./constants");
40
41
  var dkim = require("./mail-dkim");
42
+ var safeXml = require("./parsers/safe-xml");
41
43
  var { MailAuthError } = require("./framework-error");
42
44
 
43
45
  var observability = lazyRequire(function () { return require("./observability"); });
@@ -744,17 +746,369 @@ function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
744
746
 
745
747
  void C; // C is imported for future TIME constants in policy fetchers.
746
748
 
749
+ // ---- ARC receiver-side trust evaluation (RFC 8617 §6) ----
750
+ //
751
+ // arc.verify confirms the cryptographic chain validates; arc.evaluate
752
+ // is the operator-side trust decision: given a passing chain, did any
753
+ // hop in the chain belong to a sealer the operator trusts? The trust
754
+ // list is operator policy — typically the operator's own domain plus
755
+ // upstream relays the operator has agreed to honor (mailing list
756
+ // operators, MX-vendor middleware).
757
+ //
758
+ // var rv = await b.mail.arc.evaluate(rfc822, {
759
+ // trustedSealers: ["example.com", "mailgun.net"],
760
+ // });
761
+ // // → { chainStatus: "pass", trusted: true, trustedHop: 2,
762
+ // // trustedDomain: "mailgun.net" }
763
+
764
+ async function arcEvaluate(rfc822, opts) {
765
+ if (typeof rfc822 !== "string" || rfc822.length === 0) {
766
+ throw new MailAuthError("mail-auth/arc-bad-input",
767
+ "arc.evaluate: rfc822 must be a non-empty string");
768
+ }
769
+ opts = opts || {};
770
+ if (!Array.isArray(opts.trustedSealers)) {
771
+ throw new MailAuthError("mail-auth/arc-bad-trusted-sealers",
772
+ "arc.evaluate: opts.trustedSealers must be an array of domain strings");
773
+ }
774
+ var trusted = {};
775
+ for (var ti = 0; ti < opts.trustedSealers.length; ti += 1) {
776
+ var d = opts.trustedSealers[ti];
777
+ if (typeof d === "string" && d.length > 0) trusted[d.toLowerCase()] = true;
778
+ }
779
+
780
+ var verdict = await arcVerify(rfc822, opts);
781
+ var out = {
782
+ chainStatus: verdict.chainStatus,
783
+ hopCount: verdict.hopCount,
784
+ trusted: false,
785
+ trustedHop: null,
786
+ trustedDomain: null,
787
+ };
788
+ if (verdict.reason) out.reason = verdict.reason;
789
+
790
+ if (verdict.chainStatus !== "pass" || !Array.isArray(verdict.hops)) return out;
791
+
792
+ // Re-extract the d= of each hop's AS from the original headers — the
793
+ // verify-result shape doesn't carry it. Walk hops most-recent-first
794
+ // so we attribute the trust decision to the deepest trusted sealer.
795
+ var headers = _parseHeaderLines(_splitHeaders(rfc822));
796
+ var hopDomains = {};
797
+ for (var hi = 0; hi < headers.length; hi += 1) {
798
+ var line = headers[hi];
799
+ var colonAt = line.indexOf(":");
800
+ if (colonAt === -1) continue;
801
+ var name = line.slice(0, colonAt).trim().toLowerCase();
802
+ if (name !== "arc-seal") continue;
803
+ var value = line.slice(colonAt + 1).trim();
804
+ var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
805
+ var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
806
+ if (iMatch && dMatch) hopDomains[parseInt(iMatch[1], 10)] = dMatch[1].toLowerCase();
807
+ }
808
+
809
+ for (var ri2 = verdict.hops.length - 1; ri2 >= 0; ri2 -= 1) {
810
+ var hop = verdict.hops[ri2];
811
+ if (!hop || hop.amsResult !== "pass" || hop.asResult !== "pass") continue;
812
+ var domain = hopDomains[hop.instance];
813
+ if (domain && trusted[domain]) {
814
+ out.trusted = true;
815
+ out.trustedHop = hop.instance;
816
+ out.trustedDomain = domain;
817
+ break;
818
+ }
819
+ }
820
+ return out;
821
+ }
822
+
823
+ // ---- Authentication-Results header (RFC 8601) builder ----
824
+ //
825
+ // Build the A-R header value the receiving MTA prepends to the message
826
+ // before delivery. Operators consume per-method results from
827
+ // b.mail.spf.verify / b.mail.dmarc.evaluate / b.mail.arc.verify (or
828
+ // .evaluate) and pass them to .emit; the framework formats the RFC
829
+ // 8601-conformant header string.
830
+ //
831
+ // var hdr = b.mail.authResults.emit({
832
+ // authservId: "mx.example.com",
833
+ // results: [
834
+ // { method: "spf", result: "pass", smtpMailfrom: "user@sender.example" },
835
+ // { method: "dkim", result: "pass", domain: "sender.example" },
836
+ // { method: "dmarc", result: "pass", from: "user@sender.example" },
837
+ // { method: "arc", result: "pass" },
838
+ // ],
839
+ // });
840
+ // // → "Authentication-Results: mx.example.com;\r\n spf=pass smtp.mailfrom=user@sender.example;\r\n dkim=pass header.d=sender.example;\r\n dmarc=pass header.from=user@sender.example;\r\n arc=pass"
841
+
842
+ var AR_VALID_RESULTS = {
843
+ pass: 1, fail: 1, neutral: 1, none: 1, softfail: 1, policy: 1,
844
+ permerror: 1, temperror: 1, hardfail: 1, bestguesspass: 1,
845
+ };
846
+ var AR_VALID_METHODS = {
847
+ auth: 1, dkim: 1, "dkim-adsp": 1, dmarc: 1, "domainkeys": 1,
848
+ "iprev": 1, "sender-id": 1, spf: 1, arc: 1, "smime": 1, dane: 1,
849
+ "vbr": 1, "dnswl": 1, "x-original-authentication-results": 1,
850
+ };
851
+
852
+ function authResultsEmit(opts) {
853
+ validateOpts.requireObject(opts, "authResults.emit", MailAuthError, "mail-auth/ar-bad-input");
854
+ validateOpts(opts, ["authservId", "results", "version", "fold"], "authResults.emit");
855
+ validateOpts.requireNonEmptyString(opts.authservId,
856
+ "authResults.emit: authservId", MailAuthError, "mail-auth/ar-bad-authserv-id");
857
+ if (/[\r\n\0]/.test(opts.authservId)) {
858
+ throw new MailAuthError("mail-auth/ar-bad-authserv-id",
859
+ "authResults.emit: authservId contains forbidden control characters");
860
+ }
861
+ if (!Array.isArray(opts.results)) {
862
+ throw new MailAuthError("mail-auth/ar-bad-results",
863
+ "authResults.emit: results must be an array");
864
+ }
865
+
866
+ var version = (opts.version === undefined || opts.version === null)
867
+ ? "1" : String(opts.version);
868
+ var head = opts.authservId + (version === "1" ? "" : " " + version);
869
+
870
+ if (opts.results.length === 0) {
871
+ // RFC 8601 §2.2 — when no methods evaluated, emit `none`.
872
+ return "Authentication-Results: " + head + "; none";
873
+ }
874
+
875
+ var clauses = [];
876
+ for (var i = 0; i < opts.results.length; i += 1) {
877
+ var r = opts.results[i];
878
+ if (!r || typeof r !== "object") {
879
+ throw new MailAuthError("mail-auth/ar-bad-result-entry",
880
+ "authResults.emit: results[" + i + "] must be an object");
881
+ }
882
+ var method = String(r.method || "").toLowerCase();
883
+ var result = String(r.result || "").toLowerCase();
884
+ if (!AR_VALID_METHODS[method]) {
885
+ throw new MailAuthError("mail-auth/ar-bad-method",
886
+ "authResults.emit: unknown method '" + r.method + "'");
887
+ }
888
+ if (!AR_VALID_RESULTS[result]) {
889
+ throw new MailAuthError("mail-auth/ar-bad-result",
890
+ "authResults.emit: unknown result '" + r.result + "' for method '" + method + "'");
891
+ }
892
+ var clause = method + "=" + result;
893
+ if (r.reason && typeof r.reason === "string" && !/[\r\n\0;]/.test(r.reason)) {
894
+ clause += ' reason="' + r.reason.replace(/"/g, "'") + '"';
895
+ }
896
+ // Method-specific properties (ptype.property=value triples per
897
+ // RFC 8601 §2.3). Operators pass them as flat object keys.
898
+ var props = {
899
+ smtpMailfrom: "smtp.mailfrom",
900
+ smtpHelo: "smtp.helo",
901
+ domain: "header.d",
902
+ selector: "header.s",
903
+ from: "header.from",
904
+ iprev: "policy.iprev",
905
+ ip: "policy.ip",
906
+ tls: "policy.tls",
907
+ };
908
+ var propKeys = Object.keys(props);
909
+ for (var pk = 0; pk < propKeys.length; pk += 1) {
910
+ var k = propKeys[pk];
911
+ if (typeof r[k] === "string" && r[k].length > 0 && !/[\r\n\0;]/.test(r[k])) {
912
+ clause += " " + props[k] + "=" + r[k];
913
+ }
914
+ }
915
+ clauses.push(clause);
916
+ }
917
+
918
+ var fold = opts.fold !== false;
919
+ var sep = fold ? ";\r\n " : "; ";
920
+ return "Authentication-Results: " + head + ";\r\n " + clauses.join(sep);
921
+ }
922
+
923
+ // ---- DMARC aggregate (RUA) report parser (RFC 7489 §7.2 / draft-ietf-dmarc-aggregate-reporting) ----
924
+ //
925
+ // MTAs that publish a DMARC `rua=` policy receive aggregate reports
926
+ // from peers — XML attached to a multipart/report mail body, often
927
+ // gzip-compressed. This primitive accepts the report bytes (raw XML,
928
+ // gzipped XML, or a parsed object) and returns a structured shape
929
+ // with the metadata, published policy, and per-record evaluation
930
+ // results.
931
+ //
932
+ // var rv = b.mail.dmarc.parseAggregateReport(xmlBytes);
933
+ // // → {
934
+ // // reportMetadata: { orgName, email, reportId, dateRange },
935
+ // // policyPublished: { domain, adkim, aspf, p, sp, pct, ... },
936
+ // // records: [{ sourceIp, count, dispositions, identifiers, authResults }]
937
+ // // }
938
+
939
+ var DMARC_RUA_MAX_REPORT_BYTES = C.BYTES.mib(8);
940
+ var DMARC_RUA_MAX_RECORDS_PER_REPORT = 10000; // allow:raw-byte-literal allow:raw-time-literal — record cap, not seconds
941
+
942
+ function _arrayOf(value) {
943
+ if (value === undefined || value === null) return [];
944
+ return Array.isArray(value) ? value : [value];
945
+ }
946
+
947
+ function dmarcParseAggregateReport(input, opts) {
948
+ opts = opts || {};
949
+ var bytes;
950
+ if (Buffer.isBuffer(input)) bytes = input;
951
+ else if (typeof input === "string") bytes = Buffer.from(input, "utf8");
952
+ else if (input && typeof input === "object" && input.feedback) {
953
+ // operator already pre-parsed via safeXml; skip the parse step.
954
+ return _shapeAggregateReport(input);
955
+ }
956
+ else {
957
+ throw new MailAuthError("mail-auth/dmarc-rua-bad-input",
958
+ "dmarc.parseAggregateReport: input must be a Buffer, string, or pre-parsed object");
959
+ }
960
+ if (bytes.length > DMARC_RUA_MAX_REPORT_BYTES) {
961
+ throw new MailAuthError("mail-auth/dmarc-rua-too-large",
962
+ "dmarc.parseAggregateReport: report exceeds " + DMARC_RUA_MAX_REPORT_BYTES + " bytes");
963
+ }
964
+
965
+ // Auto-detect gzip via magic 0x1f 0x8b (RFC 1952). DMARC RUA reports
966
+ // are commonly zip- or gzip-compressed; the gzip magic check covers
967
+ // the bulk of real-world reports. ZIP archives need operator-side
968
+ // unzip first (the framework doesn't ship a ZIP primitive yet).
969
+ var contentType = (opts.contentType || "").toLowerCase();
970
+ var looksGzip = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
971
+ if (contentType.indexOf("gzip") !== -1 || looksGzip) {
972
+ try { bytes = zlib.gunzipSync(bytes, { maxOutputLength: DMARC_RUA_MAX_REPORT_BYTES }); }
973
+ catch (e) {
974
+ throw new MailAuthError("mail-auth/dmarc-rua-gunzip-failed",
975
+ "dmarc.parseAggregateReport: gunzip failed: " + ((e && e.message) || String(e)));
976
+ }
977
+ }
978
+
979
+ var parsed;
980
+ try { parsed = safeXml.parse(bytes.toString("utf8"), { maxBytes: DMARC_RUA_MAX_REPORT_BYTES }); }
981
+ catch (e) {
982
+ throw new MailAuthError("mail-auth/dmarc-rua-bad-xml",
983
+ "dmarc.parseAggregateReport: XML parse failed: " + ((e && e.message) || String(e)));
984
+ }
985
+ return _shapeAggregateReport(parsed);
986
+ }
987
+
988
+ function _shapeAggregateReport(parsed) {
989
+ if (!parsed || typeof parsed !== "object" || !parsed.feedback) {
990
+ throw new MailAuthError("mail-auth/dmarc-rua-no-feedback",
991
+ "dmarc.parseAggregateReport: report root must be <feedback>");
992
+ }
993
+ var feedback = parsed.feedback;
994
+ var rmRaw = feedback.report_metadata || {};
995
+ var ppRaw = feedback.policy_published || {};
996
+ var records = _arrayOf(feedback.record);
997
+ if (records.length > DMARC_RUA_MAX_RECORDS_PER_REPORT) {
998
+ throw new MailAuthError("mail-auth/dmarc-rua-too-many-records",
999
+ "dmarc.parseAggregateReport: report has " + records.length +
1000
+ " records (cap " + DMARC_RUA_MAX_RECORDS_PER_REPORT + ")");
1001
+ }
1002
+
1003
+ var dateRange = rmRaw.date_range || {};
1004
+ var beginSec = parseInt(dateRange.begin, 10);
1005
+ var endSec = parseInt(dateRange.end, 10);
1006
+
1007
+ var shaped = {
1008
+ reportMetadata: {
1009
+ orgName: rmRaw.org_name || null,
1010
+ email: rmRaw.email || null,
1011
+ reportId: rmRaw.report_id || null,
1012
+ extraContact: rmRaw.extra_contact_info || null,
1013
+ dateRange: {
1014
+ begin: isFinite(beginSec) ? beginSec : null,
1015
+ end: isFinite(endSec) ? endSec : null,
1016
+ },
1017
+ },
1018
+ policyPublished: {
1019
+ domain: ppRaw.domain || null,
1020
+ adkim: ppRaw.adkim || null,
1021
+ aspf: ppRaw.aspf || null,
1022
+ p: ppRaw.p || null,
1023
+ sp: ppRaw.sp || null,
1024
+ pct: ppRaw.pct === undefined ? null : parseInt(ppRaw.pct, 10),
1025
+ fo: ppRaw.fo || null,
1026
+ },
1027
+ records: records.map(function (rec) {
1028
+ var row = rec.row || {};
1029
+ var pe = row.policy_evaluated || {};
1030
+ var ids = rec.identifiers || {};
1031
+ var ar = rec.auth_results || {};
1032
+ var dkimResults = _arrayOf(ar.dkim).map(function (d) {
1033
+ return {
1034
+ domain: d.domain || null,
1035
+ selector: d.selector || null,
1036
+ result: d.result || null,
1037
+ humanResult: d.human_result || null,
1038
+ };
1039
+ });
1040
+ var spfResults = _arrayOf(ar.spf).map(function (s) {
1041
+ return {
1042
+ domain: s.domain || null,
1043
+ result: s.result || null,
1044
+ scope: s.scope || null,
1045
+ };
1046
+ });
1047
+ var reasons = _arrayOf(pe.reason).map(function (r) {
1048
+ return { type: r.type || null, comment: r.comment || null };
1049
+ });
1050
+ var count = parseInt(row.count, 10);
1051
+ return {
1052
+ sourceIp: row.source_ip || null,
1053
+ count: isFinite(count) ? count : null,
1054
+ dispositions: {
1055
+ disposition: pe.disposition || null,
1056
+ dkim: pe.dkim || null,
1057
+ spf: pe.spf || null,
1058
+ reasons: reasons,
1059
+ },
1060
+ identifiers: {
1061
+ headerFrom: ids.header_from || null,
1062
+ envelopeFrom: ids.envelope_from || null,
1063
+ envelopeTo: ids.envelope_to || null,
1064
+ },
1065
+ authResults: {
1066
+ dkim: dkimResults,
1067
+ spf: spfResults,
1068
+ },
1069
+ };
1070
+ }),
1071
+ };
1072
+
1073
+ // Convenience aggregates — most operators want the totals up front.
1074
+ var totalCount = 0;
1075
+ var passCount = 0;
1076
+ var failCount = 0;
1077
+ for (var i = 0; i < shaped.records.length; i += 1) {
1078
+ var r = shaped.records[i];
1079
+ if (typeof r.count === "number") totalCount += r.count;
1080
+ var dispDkim = r.dispositions.dkim;
1081
+ var dispSpf = r.dispositions.spf;
1082
+ if (dispDkim === "pass" || dispSpf === "pass") {
1083
+ if (typeof r.count === "number") passCount += r.count;
1084
+ } else {
1085
+ if (typeof r.count === "number") failCount += r.count;
1086
+ }
1087
+ }
1088
+ shaped.totals = {
1089
+ messages: totalCount,
1090
+ aligned: passCount,
1091
+ notAligned: failCount,
1092
+ };
1093
+ return shaped;
1094
+ }
1095
+
747
1096
  module.exports = {
748
1097
  spf: Object.freeze({
749
1098
  verify: spfVerify,
750
1099
  parseRecord: _parseSpfRecord,
751
1100
  }),
752
1101
  dmarc: Object.freeze({
753
- evaluate: dmarcEvaluate,
754
- parseRecord: _parseDmarcRecord,
1102
+ evaluate: dmarcEvaluate,
1103
+ parseRecord: _parseDmarcRecord,
1104
+ parseAggregateReport: dmarcParseAggregateReport,
755
1105
  }),
756
1106
  arc: Object.freeze({
757
1107
  verify: arcVerify,
1108
+ evaluate: arcEvaluate,
1109
+ }),
1110
+ authResults: Object.freeze({
1111
+ emit: authResultsEmit,
758
1112
  }),
759
1113
  MailAuthError: MailAuthError,
760
1114
  SPF_DNS_LOOKUP_LIMIT: SPF_DNS_LOOKUP_LIMIT,
package/lib/mail.js CHANGED
@@ -1098,9 +1098,10 @@ module.exports = {
1098
1098
  // DMARC (RFC 7489), ARC (RFC 8617). Outbound DKIM signing lives in
1099
1099
  // .dkim above; per-hop DKIM verification is deferred (composes with
1100
1100
  // the existing canonicalization helpers in lib/mail-dkim.js).
1101
- spf: mailAuth.spf,
1102
- dmarc: mailAuth.dmarc,
1103
- arc: mailAuth.arc,
1101
+ spf: mailAuth.spf,
1102
+ dmarc: mailAuth.dmarc,
1103
+ arc: mailAuth.arc,
1104
+ authResults: mailAuth.authResults,
1104
1105
  // Test-only export: lets unit tests inspect the wire format without
1105
1106
  // standing up a TLS-capable SMTP fixture. Operators don't call this.
1106
1107
  _buildRfc822ForTest: _buildRfc822,
@@ -60,6 +60,7 @@ var lazyRequire = require("./lazy-require");
60
60
  var validateOpts = require("./validate-opts");
61
61
  var crypto = require("./crypto");
62
62
  var safeUrl = require("./safe-url");
63
+ var safeJson = require("./safe-json");
63
64
  var C = require("./constants");
64
65
  var { SmtpPolicyError } = require("./framework-error");
65
66
 
@@ -535,6 +536,115 @@ async function tlsRptSubmit(report, opts) {
535
536
  return { submitted: results.length, results: results };
536
537
  }
537
538
 
539
+ // ---- TLS-RPT receive-side report parsing (RFC 8460 §4) ----
540
+ //
541
+ // MTAs that publish a TLS-RPT rua endpoint receive `application/
542
+ // tlsrpt+gzip` (or `application/json`) HTTPS POSTs from peers. The
543
+ // receiver parses the report, attributes failures to the right policy/
544
+ // MX-host pair, and feeds the data into the operator's observability
545
+ // stack. This primitive is the receive-side counterpart to
546
+ // tlsRpt.recordShape / tlsRpt.submit on the send side.
547
+
548
+ var TLS_RPT_MAX_REPORT_BYTES = C.BYTES.mib(8);
549
+ var TLS_RPT_MAX_POLICIES_PER_REPORT = 1024; // allow:raw-byte-literal allow:raw-time-literal — count cap, not seconds
550
+
551
+ function tlsRptParseReport(body, opts) {
552
+ opts = opts || {};
553
+ if (body === null || body === undefined) {
554
+ throw new SmtpPolicyError("smtp/tls-rpt-bad-input",
555
+ "tlsRpt.parseReport: body is required (Buffer | string)");
556
+ }
557
+ var bodyBuf;
558
+ if (Buffer.isBuffer(body)) bodyBuf = body;
559
+ else if (typeof body === "string") bodyBuf = Buffer.from(body, "utf8");
560
+ else {
561
+ throw new SmtpPolicyError("smtp/tls-rpt-bad-input",
562
+ "tlsRpt.parseReport: body must be a Buffer or string");
563
+ }
564
+ if (bodyBuf.length > TLS_RPT_MAX_REPORT_BYTES) {
565
+ throw new SmtpPolicyError("smtp/tls-rpt-too-large",
566
+ "tlsRpt.parseReport: report exceeds " + TLS_RPT_MAX_REPORT_BYTES + " bytes");
567
+ }
568
+
569
+ // Decompress if the operator passes contentType: "application/tlsrpt+gzip"
570
+ // OR if the body sniffs as gzip (magic 0x1f 0x8b).
571
+ var contentType = (opts.contentType || "").toLowerCase();
572
+ var looksGzip = bodyBuf.length >= 2 && bodyBuf[0] === 0x1f && bodyBuf[1] === 0x8b;
573
+ if (contentType.indexOf("gzip") !== -1 || looksGzip) {
574
+ try { bodyBuf = zlib.gunzipSync(bodyBuf, { maxOutputLength: TLS_RPT_MAX_REPORT_BYTES }); }
575
+ catch (e) {
576
+ throw new SmtpPolicyError("smtp/tls-rpt-gunzip-failed",
577
+ "tlsRpt.parseReport: gunzip failed: " + ((e && e.message) || String(e)));
578
+ }
579
+ }
580
+
581
+ var report;
582
+ try { report = safeJson.parse(bodyBuf.toString("utf8"), { maxBytes: TLS_RPT_MAX_REPORT_BYTES }); }
583
+ catch (e) {
584
+ throw new SmtpPolicyError("smtp/tls-rpt-bad-json",
585
+ "tlsRpt.parseReport: JSON parse failed: " + ((e && e.message) || String(e)));
586
+ }
587
+ if (!report || typeof report !== "object") {
588
+ throw new SmtpPolicyError("smtp/tls-rpt-bad-shape",
589
+ "tlsRpt.parseReport: report must be an object");
590
+ }
591
+
592
+ // Validate the RFC 8460 §4.4 required fields. Optional fields are
593
+ // surfaced as-is when present (some operators ship `contact-info`,
594
+ // some don't).
595
+ var requiredKeys = ["organization-name", "date-range", "report-id", "policies"];
596
+ for (var ri = 0; ri < requiredKeys.length; ri += 1) {
597
+ if (!Object.prototype.hasOwnProperty.call(report, requiredKeys[ri])) {
598
+ throw new SmtpPolicyError("smtp/tls-rpt-missing-field",
599
+ "tlsRpt.parseReport: report missing required field '" + requiredKeys[ri] + "' (RFC 8460 §4.4)");
600
+ }
601
+ }
602
+ if (!report["date-range"] ||
603
+ typeof report["date-range"]["start-datetime"] !== "string" ||
604
+ typeof report["date-range"]["end-datetime"] !== "string") {
605
+ throw new SmtpPolicyError("smtp/tls-rpt-bad-date-range",
606
+ "tlsRpt.parseReport: date-range must have start-datetime + end-datetime");
607
+ }
608
+ if (!Array.isArray(report.policies)) {
609
+ throw new SmtpPolicyError("smtp/tls-rpt-bad-policies",
610
+ "tlsRpt.parseReport: policies must be an array");
611
+ }
612
+ if (report.policies.length > TLS_RPT_MAX_POLICIES_PER_REPORT) {
613
+ throw new SmtpPolicyError("smtp/tls-rpt-too-many-policies",
614
+ "tlsRpt.parseReport: report has " + report.policies.length +
615
+ " policies (cap " + TLS_RPT_MAX_POLICIES_PER_REPORT + ")");
616
+ }
617
+
618
+ // Aggregate counters operators most commonly want surfaced.
619
+ var totalSuccess = 0;
620
+ var totalFailure = 0;
621
+ for (var pi = 0; pi < report.policies.length; pi += 1) {
622
+ var entry = report.policies[pi];
623
+ if (entry && entry.summary) {
624
+ var s = entry.summary["total-successful-session-count"];
625
+ var f = entry.summary["total-failure-session-count"];
626
+ if (typeof s === "number" && isFinite(s)) totalSuccess += s;
627
+ if (typeof f === "number" && isFinite(f)) totalFailure += f;
628
+ }
629
+ }
630
+
631
+ return {
632
+ organization: report["organization-name"],
633
+ contact: report["contact-info"] || null,
634
+ reportId: report["report-id"],
635
+ dateRange: {
636
+ start: report["date-range"]["start-datetime"],
637
+ end: report["date-range"]["end-datetime"],
638
+ },
639
+ policies: report.policies,
640
+ totals: {
641
+ successful: totalSuccess,
642
+ failure: totalFailure,
643
+ },
644
+ raw: report,
645
+ };
646
+ }
647
+
538
648
  module.exports = {
539
649
  mtaSts: Object.freeze({
540
650
  fetch: mtaStsFetch,
@@ -550,6 +660,7 @@ module.exports = {
550
660
  recordShape: tlsRptRecordShape,
551
661
  fetchPolicy: tlsRptFetchPolicy,
552
662
  submit: tlsRptSubmit,
663
+ parseReport: tlsRptParseReport,
553
664
  }),
554
665
  SmtpPolicyError: SmtpPolicyError,
555
666
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.73",
3
+ "version": "0.7.74",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:55a0114a-f249-481f-b122-6997247485a1",
5
+ "serialNumber": "urn:uuid:a82c1893-13c0-448e-a951-71bc3d182da3",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T03:31:31.641Z",
8
+ "timestamp": "2026-05-06T03:56:40.534Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.73",
22
+ "bom-ref": "@blamejs/core@0.7.74",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.73",
25
+ "version": "0.7.74",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.73",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.74",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.73",
57
+ "ref": "@blamejs/core@0.7.74",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]