@blamejs/core 0.8.52 → 0.8.58

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 (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/db-collection.js +290 -0
  9. package/lib/db-query.js +245 -0
  10. package/lib/db.js +173 -67
  11. package/lib/framework-error.js +55 -0
  12. package/lib/guard-cidr.js +2 -1
  13. package/lib/guard-jwt.js +2 -2
  14. package/lib/guard-oauth.js +2 -2
  15. package/lib/http-client-cache.js +916 -0
  16. package/lib/http-client.js +242 -0
  17. package/lib/mail-arf.js +343 -0
  18. package/lib/mail-auth.js +265 -40
  19. package/lib/mail-bimi.js +948 -33
  20. package/lib/mail-bounce.js +386 -4
  21. package/lib/mail-mdn.js +424 -0
  22. package/lib/mail-unsubscribe.js +265 -25
  23. package/lib/mail.js +403 -21
  24. package/lib/middleware/bearer-auth.js +1 -1
  25. package/lib/middleware/clear-site-data.js +122 -0
  26. package/lib/middleware/dpop.js +1 -1
  27. package/lib/middleware/index.js +9 -0
  28. package/lib/middleware/nel.js +214 -0
  29. package/lib/middleware/security-headers.js +56 -4
  30. package/lib/middleware/speculation-rules.js +323 -0
  31. package/lib/mime-parse.js +198 -0
  32. package/lib/mtls-ca.js +15 -5
  33. package/lib/network-dns.js +890 -27
  34. package/lib/network-tls.js +745 -0
  35. package/lib/object-store/sigv4.js +54 -0
  36. package/lib/public-suffix.js +414 -0
  37. package/lib/safe-buffer.js +7 -0
  38. package/lib/safe-json.js +1 -1
  39. package/lib/static.js +120 -0
  40. package/lib/storage.js +11 -0
  41. package/lib/vendor/MANIFEST.json +33 -0
  42. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  43. package/lib/vendor/public-suffix-list.dat +16376 -0
  44. package/package.json +1 -1
  45. package/sbom.cyclonedx.json +6 -6
package/lib/mail-auth.js CHANGED
@@ -35,11 +35,13 @@
35
35
  var dns = require("node:dns");
36
36
  var dnsPromises = dns.promises;
37
37
  var zlib = require("node:zlib");
38
+ var net = require("node:net");
38
39
  var lazyRequire = require("./lazy-require");
39
40
  var validateOpts = require("./validate-opts");
40
41
  var C = require("./constants");
41
42
  var dkim = require("./mail-dkim");
42
43
  var safeXml = require("./parsers/safe-xml");
44
+ var publicSuffix = require("./public-suffix");
43
45
  var { MailAuthError } = require("./framework-error");
44
46
 
45
47
  var observability = lazyRequire(function () { return require("./observability"); });
@@ -361,8 +363,22 @@ async function _fetchDmarcRecord(domain, dnsLookup) {
361
363
  return matches[0];
362
364
  }
363
365
 
366
+ // RFC 7489 base policy keys + DMARCbis (draft-ietf-dmarc-dmarcbis)
367
+ // extensions:
368
+ // np=<none|quarantine|reject> policy for non-existent subdomains
369
+ // psd=<y|n|u> applies-at-public-suffix-domain (TLD
370
+ // operator publishes a DMARC record on
371
+ // the suffix itself)
372
+ // Validation tier: parse is config-time (operator-supplied DNS bytes);
373
+ // throw on malformed v= / unrecognized np= or psd= values rather than
374
+ // silently dropping — operators with a typo'd record otherwise see the
375
+ // fallback policy applied without warning.
376
+ var DMARCBIS_VALID_NP = { none: 1, quarantine: 1, reject: 1 };
377
+ var DMARCBIS_VALID_PSD = { y: 1, n: 1, u: 1 };
378
+
364
379
  function _parseDmarcRecord(text) {
365
- var policy = { v: null, p: null, sp: null, pct: 100, adkim: "r", aspf: "r" }; // allow:raw-byte-literal — RFC 7489 default pct
380
+ var policy = { v: null, p: null, sp: null, np: null, psd: null,
381
+ pct: 100, adkim: "r", aspf: "r" }; // allow:raw-byte-literal — RFC 7489 default pct
366
382
  var pairs = text.split(";");
367
383
  for (var i = 0; i < pairs.length; i += 1) {
368
384
  var kv = pairs[i].trim();
@@ -377,6 +393,22 @@ function _parseDmarcRecord(text) {
377
393
  else if (key === "pct") policy.pct = parseInt(val, 10);
378
394
  else if (key === "adkim") policy.adkim = val.toLowerCase();
379
395
  else if (key === "aspf") policy.aspf = val.toLowerCase();
396
+ else if (key === "np") {
397
+ var npVal = val.toLowerCase();
398
+ if (!DMARCBIS_VALID_NP[npVal]) {
399
+ throw new MailAuthError("mail-auth/dmarcbis-bad-tag",
400
+ "DMARC np= must be one of none|quarantine|reject, got " + JSON.stringify(val));
401
+ }
402
+ policy.np = npVal;
403
+ }
404
+ else if (key === "psd") {
405
+ var psdVal = val.toLowerCase();
406
+ if (!DMARCBIS_VALID_PSD[psdVal]) {
407
+ throw new MailAuthError("mail-auth/dmarcbis-bad-tag",
408
+ "DMARC psd= must be one of y|n|u, got " + JSON.stringify(val));
409
+ }
410
+ policy.psd = psdVal;
411
+ }
380
412
  }
381
413
  if (policy.v !== "DMARC1") {
382
414
  throw new MailAuthError("mail-auth/dmarc-bad-version",
@@ -401,7 +433,8 @@ function _alignmentCheck(fromDomain, authDomain, mode) {
401
433
 
402
434
  async function dmarcEvaluate(opts) {
403
435
  opts = opts || {};
404
- validateOpts(opts, ["from", "spf", "dkim", "dnsLookup"], "mail.dmarc.evaluate");
436
+ validateOpts(opts, ["from", "spf", "dkim", "dnsLookup", "domainExists"],
437
+ "mail.dmarc.evaluate");
405
438
  if (typeof opts.from !== "string") {
406
439
  throw new MailAuthError("mail-auth/dmarc-bad-from",
407
440
  "dmarc.evaluate: opts.from must be the From-header email address");
@@ -411,47 +444,89 @@ async function dmarcEvaluate(opts) {
411
444
  throw new MailAuthError("mail-auth/dmarc-bad-from",
412
445
  "dmarc.evaluate: opts.from is missing the @domain part");
413
446
  }
447
+ fromDomain = fromDomain.toLowerCase();
448
+
449
+ // DMARCbis (draft-ietf-dmarc-dmarcbis) replaces the legacy "drop one
450
+ // label" org-domain heuristic with a proper Public Suffix List lookup.
451
+ // organizationalDomain returns null when the input IS a public suffix
452
+ // (e.g. "co.uk") OR when no PSL match resolves; either way, the
453
+ // org-domain walk below short-circuits.
454
+ var orgDomain = null;
455
+ try { orgDomain = publicSuffix.organizationalDomain(fromDomain); }
456
+ catch (_e) { orgDomain = null; }
414
457
 
415
458
  var policy = null;
416
459
  var policyOriginDomain = null;
417
460
  var orgDomainPolicyApplied = false;
461
+ var psdPolicyApplied = false;
418
462
  try {
419
463
  var rec = await _fetchDmarcRecord(fromDomain, opts.dnsLookup);
420
464
  if (rec) {
421
465
  policy = _parseDmarcRecord(rec);
422
466
  policyOriginDomain = fromDomain;
423
- } else {
424
- // RFC 7489 §6.6.3 when no DMARC record exists at the From
425
- // domain, the receiver MUST walk to the organizational domain
426
- // and query for a record there, then apply that record's `sp=`
427
- // subdomain policy (or fall back to `p=`). Without a PSL we
428
- // approximate the org domain by dropping one label at a time —
429
- // covers the common one-level-subdomain case (mail.example.com
430
- // example.com) but not multi-label public suffixes (.co.uk).
431
- // Operators with PSL-aware needs override `dnsLookup` and
432
- // implement the full lookup themselves.
433
- var labels = fromDomain.split(".");
434
- if (labels.length >= 3) {
435
- var parent = labels.slice(1).join(".");
436
- var parentRec = await _fetchDmarcRecord(parent, opts.dnsLookup);
437
- if (parentRec) {
438
- var parentPolicy = _parseDmarcRecord(parentRec);
439
- // Apply sp= if set, else fall back to p=. The result is the
440
- // policy the receiver applies to mail from this subdomain.
441
- parentPolicy.p = parentPolicy.sp || parentPolicy.p;
442
- policy = parentPolicy;
443
- policyOriginDomain = parent;
444
- orgDomainPolicyApplied = true;
467
+ } else if (orgDomain && orgDomain !== fromDomain) {
468
+ // RFC 7489 §6.6.3 + DMARCbis §4.6 fall through to organizational
469
+ // domain. When the org-domain record sets sp= it applies to this
470
+ // subdomain; otherwise p= is the operative policy.
471
+ var orgRec = await _fetchDmarcRecord(orgDomain, opts.dnsLookup);
472
+ if (orgRec) {
473
+ var orgPolicy = _parseDmarcRecord(orgRec);
474
+ orgPolicy.p = orgPolicy.sp || orgPolicy.p;
475
+ policy = orgPolicy;
476
+ policyOriginDomain = orgDomain;
477
+ orgDomainPolicyApplied = true;
478
+ }
479
+ }
480
+
481
+ // DMARCbis §4.7 — when the org-domain record carries `psd=y`, OR
482
+ // the published record sits at the public suffix itself (TLD
483
+ // operator), the receiver continues lookup at the public suffix
484
+ // for downstream DSP cooperation. We honor the `psd=y` opt-in by
485
+ // surfacing the tag so operators can route on it; the explicit
486
+ // suffix walk below covers the suffix-record case.
487
+ if (!policy) {
488
+ var suffix = null;
489
+ try { suffix = publicSuffix.publicSuffix(fromDomain); }
490
+ catch (_e) { suffix = null; }
491
+ if (suffix && suffix !== fromDomain && suffix !== orgDomain) {
492
+ var psdRec = await _fetchDmarcRecord(suffix, opts.dnsLookup);
493
+ if (psdRec) {
494
+ var psdPolicy = _parseDmarcRecord(psdRec);
495
+ if (psdPolicy.psd === "y") {
496
+ psdPolicy.p = psdPolicy.sp || psdPolicy.p;
497
+ policy = psdPolicy;
498
+ policyOriginDomain = suffix;
499
+ psdPolicyApplied = true;
500
+ }
445
501
  }
446
502
  }
447
503
  }
448
504
  } catch (e) {
449
505
  return { result: "temperror", explanation: e.message,
450
- policy: null, alignment: { spf: false, dkim: false } };
506
+ policy: null, alignment: { spf: false, dkim: false },
507
+ orgDomain: orgDomain };
451
508
  }
452
509
  if (!policy) {
453
510
  return { result: "none", explanation: "no DMARC record at _dmarc." + fromDomain,
454
- policy: null, alignment: { spf: false, dkim: false } };
511
+ policy: null, alignment: { spf: false, dkim: false },
512
+ orgDomain: orgDomain };
513
+ }
514
+
515
+ // DMARCbis §4.8 — non-existent subdomain (NXDOMAIN on MX/A/AAAA for
516
+ // the message-from domain) gets the np= policy when published. The
517
+ // operator wires the existence check via opts.domainExists; absent
518
+ // that callback we conservatively treat the domain as existing
519
+ // (the np= path is opt-in observability, not a downgrade gate).
520
+ var npApplied = false;
521
+ if (typeof policy.np === "string" && typeof opts.domainExists === "function" &&
522
+ orgDomainPolicyApplied) {
523
+ var exists = true;
524
+ try { exists = await opts.domainExists(fromDomain); }
525
+ catch (_e) { exists = true; }
526
+ if (exists === false) {
527
+ policy = Object.assign({}, policy, { p: policy.np });
528
+ npApplied = true;
529
+ }
455
530
  }
456
531
 
457
532
  var spfDomain = (opts.spf && opts.spf.domain) || null;
@@ -492,7 +567,10 @@ async function dmarcEvaluate(opts) {
492
567
  result: pass ? "pass" : "fail",
493
568
  policy: policy,
494
569
  policyOriginDomain: policyOriginDomain,
570
+ orgDomain: orgDomain,
495
571
  orgDomainPolicyApplied: orgDomainPolicyApplied,
572
+ psdPolicyApplied: psdPolicyApplied,
573
+ npPolicyApplied: npApplied,
496
574
  alignment: { spf: spfAligned, dkim: dkimAligned },
497
575
  recommendedAction: recommendedAction,
498
576
  explanation: pass
@@ -967,7 +1045,11 @@ async function arcEvaluate(rfc822, opts) {
967
1045
  var trusted = {};
968
1046
  for (var ti = 0; ti < opts.trustedSealers.length; ti += 1) {
969
1047
  var d = opts.trustedSealers[ti];
970
- if (typeof d === "string" && d.length > 0) trusted[d.toLowerCase()] = true;
1048
+ if (typeof d !== "string" || d.length === 0) {
1049
+ throw new MailAuthError("mail-auth/arc-trust-eval-failed",
1050
+ "arc.evaluate: trustedSealers[" + ti + "] must be a non-empty domain string");
1051
+ }
1052
+ trusted[d.toLowerCase()] = true;
971
1053
  }
972
1054
 
973
1055
  var verdict = await arcVerify(rfc822, opts);
@@ -977,39 +1059,82 @@ async function arcEvaluate(rfc822, opts) {
977
1059
  trusted: false,
978
1060
  trustedHop: null,
979
1061
  trustedDomain: null,
1062
+ // RFC 8617 §6 trust evaluation extension surface (B6).
1063
+ // trust: "trusted" | "unverified" | "failed"
1064
+ // trustedHops: [{ instance, domain }] of every trusted sealer
1065
+ // in the validated chain
1066
+ // finalAr: verbatim AAR from the most-recent hop (the
1067
+ // receiver's view of upstream auth results)
1068
+ // breakAt: first instance whose AMS or AS failed, or null
1069
+ // when every hop verified
1070
+ trust: verdict.chainStatus === "pass" ? "unverified" : "failed",
1071
+ trustedHops: [],
1072
+ finalAr: null,
1073
+ breakAt: null,
980
1074
  };
981
1075
  if (verdict.reason) out.reason = verdict.reason;
982
1076
 
983
- if (verdict.chainStatus !== "pass" || !Array.isArray(verdict.hops)) return out;
984
-
985
- // Re-extract the d= of each hop's AS from the original headers — the
986
- // verify-result shape doesn't carry it. Walk hops most-recent-first
987
- // so we attribute the trust decision to the deepest trusted sealer.
1077
+ // Re-extract per-hop d= (signing domain on AS) AND the AAR text from
1078
+ // the original headers — the verify-result shape doesn't carry
1079
+ // them. One pass over the header section.
988
1080
  var headers = _parseHeaderLines(_splitHeaders(rfc822));
989
1081
  var hopDomains = {};
1082
+ var hopAr = {};
990
1083
  for (var hi = 0; hi < headers.length; hi += 1) {
991
1084
  var line = headers[hi];
992
1085
  var colonAt = line.indexOf(":");
993
1086
  if (colonAt === -1) continue;
994
1087
  var name = line.slice(0, colonAt).trim().toLowerCase();
995
- if (name !== "arc-seal") continue;
996
1088
  var value = line.slice(colonAt + 1).trim();
997
- var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
998
- var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
999
- if (iMatch && dMatch) hopDomains[parseInt(iMatch[1], 10)] = dMatch[1].toLowerCase();
1089
+ if (name === "arc-seal") {
1090
+ var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
1091
+ var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
1092
+ if (iMatch && dMatch) hopDomains[parseInt(iMatch[1], 10)] = dMatch[1].toLowerCase();
1093
+ } else if (name === "arc-authentication-results") {
1094
+ var arIMatch = value.match(/\bi\s*=\s*(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
1095
+ if (arIMatch) hopAr[parseInt(arIMatch[1], 10)] = value;
1096
+ }
1000
1097
  }
1001
1098
 
1099
+ // finalAr — the most-recent hop's AAR. Always populated when the
1100
+ // chain has at least one hop (regardless of pass/fail), so the
1101
+ // operator can surface upstream auth context even on a broken chain.
1102
+ if (verdict.hopCount > 0) {
1103
+ out.finalAr = hopAr[verdict.hopCount] || null;
1104
+ }
1105
+
1106
+ // breakAt — first instance whose AMS or AS failed.
1107
+ if (Array.isArray(verdict.hops)) {
1108
+ for (var bi = 0; bi < verdict.hops.length; bi += 1) {
1109
+ var bhop = verdict.hops[bi];
1110
+ if (!bhop) continue;
1111
+ if (bhop.amsResult !== "pass" || bhop.asResult !== "pass") {
1112
+ out.breakAt = bhop.instance;
1113
+ break;
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ if (verdict.chainStatus !== "pass" || !Array.isArray(verdict.hops)) return out;
1119
+
1120
+ // Walk hops most-recent-first so we attribute the primary trust
1121
+ // decision to the deepest (closest-to-receiver) trusted sealer, but
1122
+ // also collect EVERY trusted hop so the operator can audit the
1123
+ // full custody chain.
1002
1124
  for (var ri2 = verdict.hops.length - 1; ri2 >= 0; ri2 -= 1) {
1003
1125
  var hop = verdict.hops[ri2];
1004
1126
  if (!hop || hop.amsResult !== "pass" || hop.asResult !== "pass") continue;
1005
1127
  var domain = hopDomains[hop.instance];
1006
1128
  if (domain && trusted[domain]) {
1007
- out.trusted = true;
1008
- out.trustedHop = hop.instance;
1009
- out.trustedDomain = domain;
1010
- break;
1129
+ out.trustedHops.push({ instance: hop.instance, domain: domain });
1130
+ if (!out.trusted) {
1131
+ out.trusted = true;
1132
+ out.trustedHop = hop.instance;
1133
+ out.trustedDomain = domain;
1134
+ }
1011
1135
  }
1012
1136
  }
1137
+ out.trust = out.trusted ? "trusted" : "unverified";
1013
1138
  return out;
1014
1139
  }
1015
1140
 
@@ -1323,6 +1448,103 @@ function _shapeAggregateReport(parsed) {
1323
1448
  return shaped;
1324
1449
  }
1325
1450
 
1451
+ // ---- iprev (RFC 8601 §3) — Forward-Confirmed Reverse DNS verifier ----
1452
+ //
1453
+ // The receiving SMTP server reverse-resolves the connecting peer's IP
1454
+ // to a PTR name, forward-resolves the PTR name to an A or AAAA set,
1455
+ // and confirms the original IP appears in the forward set. Spoofed
1456
+ // PTR records (attacker controls the rDNS zone but not the forward
1457
+ // zone) fail this check and SHOULD be reflected in the
1458
+ // Authentication-Results header so downstream policies can react.
1459
+ //
1460
+ // Surface:
1461
+ // await b.mail.iprev.verify(ip)
1462
+ // → { result: "pass"|"fail"|"permerror"|"temperror",
1463
+ // ptr, forward, fcrdns, ip }
1464
+ //
1465
+ // Returns "permerror" on bad-shape input (not an IP literal); returns
1466
+ // "temperror" on ENODATA / ENOTFOUND / lookup failure (the receiver
1467
+ // retries on transient DNS faults). Pure-DNS — no operator state.
1468
+
1469
+ async function iprevVerify(ip) {
1470
+ if (typeof ip !== "string" || ip.length === 0) {
1471
+ return { result: "permerror", ip: ip || null,
1472
+ ptr: null, forward: [], fcrdns: false,
1473
+ explanation: "ip must be a non-empty string" };
1474
+ }
1475
+ if (!net.isIP(ip)) {
1476
+ return { result: "permerror", ip: ip,
1477
+ ptr: null, forward: [], fcrdns: false,
1478
+ explanation: "ip is not a valid IPv4 / IPv6 literal" };
1479
+ }
1480
+
1481
+ var ptrs;
1482
+ try { ptrs = await dnsPromises.reverse(ip); }
1483
+ catch (e) {
1484
+ var rcode = e && e.code;
1485
+ if (rcode === "ENOTFOUND" || rcode === "ENODATA") {
1486
+ return { result: "fail", ip: ip,
1487
+ ptr: null, forward: [], fcrdns: false,
1488
+ explanation: "no PTR record for " + ip };
1489
+ }
1490
+ return { result: "temperror", ip: ip,
1491
+ ptr: null, forward: [], fcrdns: false,
1492
+ explanation: "PTR lookup failed: " + ((e && e.message) || String(e)) };
1493
+ }
1494
+ if (!Array.isArray(ptrs) || ptrs.length === 0) {
1495
+ return { result: "fail", ip: ip,
1496
+ ptr: null, forward: [], fcrdns: false,
1497
+ explanation: "PTR returned empty answer set" };
1498
+ }
1499
+
1500
+ // RFC 8601 §3 — when multiple PTRs exist the receiver picks ONE
1501
+ // and continues. We pick the first (matches mainstream MTA
1502
+ // behavior) and stash the rest for operator visibility on the
1503
+ // out-of-band metadata.
1504
+ var ptr = String(ptrs[0]);
1505
+ var isV6 = net.isIPv6(ip);
1506
+ var forwardAddrs;
1507
+ try {
1508
+ forwardAddrs = isV6
1509
+ ? await dnsPromises.resolve6(ptr)
1510
+ : await dnsPromises.resolve4(ptr);
1511
+ } catch (e) {
1512
+ var fcode = e && e.code;
1513
+ if (fcode === "ENOTFOUND" || fcode === "ENODATA") {
1514
+ return { result: "fail", ip: ip,
1515
+ ptr: ptr, forward: [], fcrdns: false,
1516
+ explanation: "no forward record for PTR " + ptr };
1517
+ }
1518
+ if (fcode === "ETIMEOUT" || fcode === "ESERVFAIL") {
1519
+ return { result: "temperror", ip: ip,
1520
+ ptr: ptr, forward: [], fcrdns: false,
1521
+ explanation: "forward lookup transient failure: " + fcode };
1522
+ }
1523
+ // Anything else — propagate as temperror; Node DNS surfaces some
1524
+ // non-RFC error codes via the platform resolver. Permerror only
1525
+ // for definitive negative answers above.
1526
+ throw new MailAuthError("mail-auth/iprev-temperror",
1527
+ "iprev.verify: forward lookup of " + ptr + " threw: " +
1528
+ ((e && e.message) || String(e)));
1529
+ }
1530
+ var forward = Array.isArray(forwardAddrs) ? forwardAddrs.slice() : [];
1531
+ var ipLc = ip.toLowerCase();
1532
+ var fcrdns = false;
1533
+ for (var i = 0; i < forward.length; i += 1) {
1534
+ if (String(forward[i]).toLowerCase() === ipLc) { fcrdns = true; break; }
1535
+ }
1536
+ return {
1537
+ result: fcrdns ? "pass" : "fail",
1538
+ ip: ip,
1539
+ ptr: ptr,
1540
+ forward: forward,
1541
+ fcrdns: fcrdns,
1542
+ explanation: fcrdns
1543
+ ? "PTR " + ptr + " forward-resolves to " + ip
1544
+ : "PTR " + ptr + " does not forward-resolve to " + ip,
1545
+ };
1546
+ }
1547
+
1326
1548
  module.exports = {
1327
1549
  spf: Object.freeze({
1328
1550
  verify: spfVerify,
@@ -1339,6 +1561,9 @@ module.exports = {
1339
1561
  sign: require("./mail-arc-sign").sign, // allow:inline-require — re-export from sibling module
1340
1562
  ALLOWED_CV: require("./mail-arc-sign").ALLOWED_CV, // allow:inline-require — re-export from sibling module
1341
1563
  }),
1564
+ iprev: Object.freeze({
1565
+ verify: iprevVerify,
1566
+ }),
1342
1567
  authResults: Object.freeze({
1343
1568
  emit: authResultsEmit,
1344
1569
  }),