@blamejs/core 0.7.24 → 0.7.39
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 +30 -0
- package/index.js +4 -0
- package/lib/asn1-der.js +356 -0
- package/lib/audit.js +2 -0
- package/lib/compliance.js +114 -0
- package/lib/constants.js +8 -0
- package/lib/crypto.js +92 -0
- package/lib/dora.js +347 -0
- package/lib/framework-error.js +22 -0
- package/lib/gate-contract.js +20 -4
- package/lib/mail-auth.js +661 -0
- package/lib/mail-dkim.js +309 -4
- package/lib/mail.js +8 -0
- package/lib/network-smtp-policy.js +551 -0
- package/lib/network-tls.js +1169 -0
- package/lib/network.js +7 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.network.smtp.policy — MTA-STS + DANE + TLS-RPT outbound SMTP gates.
|
|
4
|
+
*
|
|
5
|
+
* Gmail and Microsoft 365 now penalize senders without MTA-STS / DANE
|
|
6
|
+
* policies. This primitive is the framework's outbound-SMTP policy
|
|
7
|
+
* surface — operators wire it into `b.mail` to enforce the recipient
|
|
8
|
+
* domain's published policy before opening the SMTP socket.
|
|
9
|
+
*
|
|
10
|
+
* var policy = b.network.smtp.policy;
|
|
11
|
+
* var sts = await policy.mtaSts.fetch("example.com");
|
|
12
|
+
* if (sts && sts.mode === "enforce") {
|
|
13
|
+
* // Verify the MX hostname matches an entry in sts.mx[*]
|
|
14
|
+
* // (wildcards allowed per RFC 8461 §3.2).
|
|
15
|
+
* var ok = policy.mtaSts.matchMx(mxHost, sts.mx);
|
|
16
|
+
* if (!ok) throw new SmtpPolicyError("smtp/mta-sts-mx-mismatch", ...);
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* var tlsa = await policy.dane.tlsa("example.com", 25);
|
|
20
|
+
* // → [{ usage, selector, mtype, dataHex }, ...] from DNS TYPE 52
|
|
21
|
+
*
|
|
22
|
+
* policy.tlsRpt.recordShape({
|
|
23
|
+
* organization: "example.com",
|
|
24
|
+
* reportingMta: "mx1.example.com",
|
|
25
|
+
* ...
|
|
26
|
+
* }) → { ... RFC 8460 TLS-RPT JSON shape ... }
|
|
27
|
+
*
|
|
28
|
+
* Surface:
|
|
29
|
+
* - mtaSts.fetch(domain) — HTTPS-fetch + parse + cache
|
|
30
|
+
* - mtaSts.matchMx(mxHost, mxList) — wildcard-aware match
|
|
31
|
+
* - dane.tlsa(domain, port) — DNS TYPE 52 lookup
|
|
32
|
+
* - dane.recordShape(buffer) — TLSA RR field decode
|
|
33
|
+
* - tlsRpt.recordShape(opts) — RFC 8460 JSON shape generator
|
|
34
|
+
* - tlsRpt.fetchPolicy(domain) — RFC 8460 §3 _smtp._tls TXT
|
|
35
|
+
* parse → { version, rua: [] }
|
|
36
|
+
* - tlsRpt.submit(report, { rua }) — gzip + POST to https rua
|
|
37
|
+
* endpoints; mailto entries
|
|
38
|
+
* surface a prepared body so
|
|
39
|
+
* operators hand it to b.mail
|
|
40
|
+
*
|
|
41
|
+
* Out of scope (deferred):
|
|
42
|
+
* - Full DANE certificate-chain verification per RFC 6698 (needs
|
|
43
|
+
* ASN.1 cert parsing). Operators today verify policy presence +
|
|
44
|
+
* match the leaf SHA-256 themselves.
|
|
45
|
+
* - DNSSEC-validated DANE lookups (node:dns doesn't expose
|
|
46
|
+
* DNSSEC ad-bit; operators pin to a DNSSEC-validating resolver
|
|
47
|
+
* externally).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
var dns = require("node:dns");
|
|
51
|
+
var dnsPromises = dns.promises;
|
|
52
|
+
var nodeCrypto = require("crypto");
|
|
53
|
+
var zlib = require("node:zlib");
|
|
54
|
+
var asn1 = require("./asn1-der");
|
|
55
|
+
var lazyRequire = require("./lazy-require");
|
|
56
|
+
var validateOpts = require("./validate-opts");
|
|
57
|
+
var crypto = require("./crypto");
|
|
58
|
+
var safeUrl = require("./safe-url");
|
|
59
|
+
var C = require("./constants");
|
|
60
|
+
var { SmtpPolicyError } = require("./framework-error");
|
|
61
|
+
|
|
62
|
+
var httpClient = lazyRequire(function () { return require("./http-client"); });
|
|
63
|
+
var cache = lazyRequire(function () { return require("./cache"); });
|
|
64
|
+
|
|
65
|
+
var DEFAULT_POLICY_CACHE_MS = C.TIME.minutes(60);
|
|
66
|
+
var MAX_POLICY_BYTES = C.BYTES.kib(64);
|
|
67
|
+
|
|
68
|
+
// ---- per-process cache for fetched MTA-STS policies ----
|
|
69
|
+
|
|
70
|
+
var _stsCache = null;
|
|
71
|
+
function _getStsCache() {
|
|
72
|
+
if (_stsCache) return _stsCache;
|
|
73
|
+
_stsCache = cache().create({
|
|
74
|
+
namespace: "smtp-policy.mta-sts",
|
|
75
|
+
ttlMs: DEFAULT_POLICY_CACHE_MS,
|
|
76
|
+
});
|
|
77
|
+
return _stsCache;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- MTA-STS (RFC 8461) ----
|
|
81
|
+
|
|
82
|
+
// Parse an MTA-STS policy text (key: value lines, MX lines may repeat).
|
|
83
|
+
function _parseStsPolicy(text) {
|
|
84
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
85
|
+
throw new SmtpPolicyError("smtp/mta-sts-empty",
|
|
86
|
+
"MTA-STS policy text is empty");
|
|
87
|
+
}
|
|
88
|
+
var policy = { version: null, mode: null, mx: [], max_age: null };
|
|
89
|
+
var lines = text.split(/\r?\n/);
|
|
90
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
91
|
+
var line = lines[i].trim();
|
|
92
|
+
if (line.length === 0) continue;
|
|
93
|
+
var colonAt = line.indexOf(":");
|
|
94
|
+
if (colonAt === -1) continue;
|
|
95
|
+
var key = line.slice(0, colonAt).trim().toLowerCase();
|
|
96
|
+
var val = line.slice(colonAt + 1).trim();
|
|
97
|
+
if (key === "version") policy.version = val;
|
|
98
|
+
else if (key === "mode") policy.mode = val.toLowerCase();
|
|
99
|
+
else if (key === "mx") policy.mx.push(val.toLowerCase());
|
|
100
|
+
else if (key === "max_age") policy.max_age = parseInt(val, 10);
|
|
101
|
+
}
|
|
102
|
+
if (policy.version !== "STSv1") {
|
|
103
|
+
throw new SmtpPolicyError("smtp/mta-sts-bad-version",
|
|
104
|
+
"MTA-STS policy version must be STSv1, got " +
|
|
105
|
+
JSON.stringify(policy.version));
|
|
106
|
+
}
|
|
107
|
+
if (["enforce", "testing", "none"].indexOf(policy.mode) === -1) {
|
|
108
|
+
throw new SmtpPolicyError("smtp/mta-sts-bad-mode",
|
|
109
|
+
"MTA-STS policy mode must be enforce|testing|none, got " +
|
|
110
|
+
JSON.stringify(policy.mode));
|
|
111
|
+
}
|
|
112
|
+
return policy;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function mtaStsFetch(domain) {
|
|
116
|
+
if (typeof domain !== "string" || domain.length === 0) {
|
|
117
|
+
throw new SmtpPolicyError("smtp/bad-domain",
|
|
118
|
+
"mtaSts.fetch: domain must be a non-empty string");
|
|
119
|
+
}
|
|
120
|
+
var lcDomain = domain.toLowerCase();
|
|
121
|
+
return await _getStsCache().wrap(lcDomain, async function () {
|
|
122
|
+
var url = "https://mta-sts." + lcDomain + "/.well-known/mta-sts.txt";
|
|
123
|
+
safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
|
|
124
|
+
var res;
|
|
125
|
+
try {
|
|
126
|
+
res = await httpClient().request({
|
|
127
|
+
method: "GET",
|
|
128
|
+
url: url,
|
|
129
|
+
maxBytes: MAX_POLICY_BYTES,
|
|
130
|
+
timeoutMs: C.TIME.seconds(10),
|
|
131
|
+
});
|
|
132
|
+
} catch (_e) {
|
|
133
|
+
// Domain doesn't publish MTA-STS — return null (not an error;
|
|
134
|
+
// operators decide policy via their own gate).
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (res.statusCode === 404) return null; // allow:raw-byte-literal — HTTP 404
|
|
138
|
+
if (res.statusCode < 200 || res.statusCode >= 300) { // allow:raw-byte-literal — HTTP 2xx range
|
|
139
|
+
throw new SmtpPolicyError("smtp/mta-sts-fetch-failed",
|
|
140
|
+
"MTA-STS fetch returned " + res.statusCode + " for " + url);
|
|
141
|
+
}
|
|
142
|
+
return _parseStsPolicy(res.body.toString("utf8"));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// MX matching per RFC 8461 §3.2 — exact host or single-label wildcard
|
|
147
|
+
// (e.g. `*.example.com` matches `mx1.example.com` but not
|
|
148
|
+
// `example.com` or `a.b.example.com`).
|
|
149
|
+
function mtaStsMatchMx(mxHost, mxList) {
|
|
150
|
+
if (typeof mxHost !== "string" || !Array.isArray(mxList)) return false;
|
|
151
|
+
var lc = mxHost.toLowerCase();
|
|
152
|
+
for (var i = 0; i < mxList.length; i += 1) {
|
|
153
|
+
var entry = String(mxList[i]).toLowerCase();
|
|
154
|
+
if (entry === lc) return true;
|
|
155
|
+
if (entry.length > 2 && entry.slice(0, 2) === "*.") {
|
|
156
|
+
var suffix = entry.slice(1); // ".example.com"
|
|
157
|
+
var dotAt = lc.indexOf(".");
|
|
158
|
+
if (dotAt === -1) continue;
|
|
159
|
+
if (lc.slice(dotAt) === suffix) return true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- DANE TLSA (RFC 6698) ----
|
|
166
|
+
|
|
167
|
+
async function daneTlsa(domain, port) {
|
|
168
|
+
if (typeof domain !== "string" || domain.length === 0) {
|
|
169
|
+
throw new SmtpPolicyError("smtp/bad-domain",
|
|
170
|
+
"dane.tlsa: domain must be a non-empty string");
|
|
171
|
+
}
|
|
172
|
+
var p = typeof port === "number" ? port : 25; // allow:raw-byte-literal — IANA SMTP port
|
|
173
|
+
var qname = "_" + p + "._tcp." + domain.toLowerCase();
|
|
174
|
+
// node:dns has resolveTlsa() since Node 18.16.0.
|
|
175
|
+
if (typeof dnsPromises.resolveTlsa !== "function") {
|
|
176
|
+
throw new SmtpPolicyError("smtp/dane-unavailable",
|
|
177
|
+
"node:dns.resolveTlsa is not available on this runtime");
|
|
178
|
+
}
|
|
179
|
+
var records;
|
|
180
|
+
try { records = await dnsPromises.resolveTlsa(qname); }
|
|
181
|
+
catch (e) {
|
|
182
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return [];
|
|
183
|
+
throw new SmtpPolicyError("smtp/dane-lookup-failed",
|
|
184
|
+
"TLSA lookup for " + qname + " failed: " + ((e && e.message) || String(e)));
|
|
185
|
+
}
|
|
186
|
+
// Normalize node's response shape to { usage, selector, mtype, dataHex }.
|
|
187
|
+
return (records || []).map(function (r) {
|
|
188
|
+
return {
|
|
189
|
+
usage: r.certUsage,
|
|
190
|
+
selector: r.selector,
|
|
191
|
+
mtype: r.match,
|
|
192
|
+
dataHex: Buffer.isBuffer(r.data) ? r.data.toString("hex") : String(r.data),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function daneRecordShape(rec) {
|
|
198
|
+
if (!rec || typeof rec !== "object") {
|
|
199
|
+
throw new SmtpPolicyError("smtp/dane-bad-record",
|
|
200
|
+
"dane.recordShape: input must be a record object");
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
usage: rec.usage,
|
|
204
|
+
selector: rec.selector,
|
|
205
|
+
mtype: rec.mtype,
|
|
206
|
+
dataHex: rec.dataHex,
|
|
207
|
+
// Human-readable label per RFC 6698:
|
|
208
|
+
usageLabel: rec.usage === 0 ? "PKIX-TA" :
|
|
209
|
+
rec.usage === 1 ? "PKIX-EE" :
|
|
210
|
+
rec.usage === 2 ? "DANE-TA" :
|
|
211
|
+
rec.usage === 3 ? "DANE-EE" : "unknown",
|
|
212
|
+
selectorLabel: rec.selector === 0 ? "Cert" :
|
|
213
|
+
rec.selector === 1 ? "SPKI" : "unknown",
|
|
214
|
+
mtypeLabel: rec.mtype === 0 ? "Full" :
|
|
215
|
+
rec.mtype === 1 ? "SHA-256" :
|
|
216
|
+
rec.mtype === 2 ? "SHA-512" : "unknown",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- DANE certificate-chain verification (RFC 6698 §2 + RFC 7672) ----
|
|
221
|
+
//
|
|
222
|
+
// Walks a peer cert chain (leaf-first DER) and confirms at least one
|
|
223
|
+
// TLSA record matches per the record's usage / selector / mtype.
|
|
224
|
+
//
|
|
225
|
+
// SMTP outbound (RFC 7672) only honors DANE-TA (2) and DANE-EE (3) —
|
|
226
|
+
// PKIX-TA (0) and PKIX-EE (1) require a full PKIX path validator and
|
|
227
|
+
// CA-bundle lookup, which is out of scope for the framework's narrow
|
|
228
|
+
// SMTP DANE surface (operators relying on PKIX modes pair this with
|
|
229
|
+
// b.network.tls's CA store + Node's TLSSocket validation).
|
|
230
|
+
|
|
231
|
+
function _extractSubjectPublicKeyInfo(certDer) {
|
|
232
|
+
// SPKI (selector=1) is the SubjectPublicKeyInfo SEQUENCE inside
|
|
233
|
+
// tbsCertificate. Tolerant of malformed input — returns null when
|
|
234
|
+
// the walk fails so verifyChain reports a structured error rather
|
|
235
|
+
// than throwing.
|
|
236
|
+
var top;
|
|
237
|
+
try { top = asn1.readNode(certDer); }
|
|
238
|
+
catch (_e) { return null; }
|
|
239
|
+
if (top.tag !== asn1.TAG.SEQUENCE) return null;
|
|
240
|
+
var children;
|
|
241
|
+
try { children = asn1.readSequence(top.value); }
|
|
242
|
+
catch (_e) { return null; }
|
|
243
|
+
if (children.length === 0) return null;
|
|
244
|
+
var tbs = children[0];
|
|
245
|
+
if (tbs.tag !== asn1.TAG.SEQUENCE) return null;
|
|
246
|
+
var tbsKids;
|
|
247
|
+
try { tbsKids = asn1.readSequence(tbs.value); }
|
|
248
|
+
catch (_e) { return null; }
|
|
249
|
+
// TBSCertificate: optional [0] EXPLICIT version, then serialNumber,
|
|
250
|
+
// signature, issuer, validity, subject, subjectPublicKeyInfo, ...
|
|
251
|
+
// SPKI is the first SEQUENCE child after the 5 prior fields, accounting
|
|
252
|
+
// for the optional version field at the front.
|
|
253
|
+
var idx = 0;
|
|
254
|
+
if (tbsKids.length > 0 &&
|
|
255
|
+
tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
|
|
256
|
+
tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
|
|
257
|
+
idx = 1;
|
|
258
|
+
}
|
|
259
|
+
// Skip serialNumber / signature / issuer / validity / subject — five fields.
|
|
260
|
+
var spkiIdx = idx + 5; // allow:raw-byte-literal — X.509 TBSCertificate field count
|
|
261
|
+
if (spkiIdx >= tbsKids.length) return null;
|
|
262
|
+
var spki = tbsKids[spkiIdx];
|
|
263
|
+
if (spki.tag !== asn1.TAG.SEQUENCE) return null;
|
|
264
|
+
return spki.raw;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _hashHex(algo, buf) {
|
|
268
|
+
return nodeCrypto.createHash(algo).update(buf).digest("hex");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function _selectorBytes(certDer, selector) {
|
|
272
|
+
if (selector === 0) return certDer; // Cert
|
|
273
|
+
if (selector === 1) return _extractSubjectPublicKeyInfo(certDer); // SPKI
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function _matchTlsaAgainstCert(rec, certDer) {
|
|
278
|
+
// Returns null on no-match, or { ok: true, mtypeLabel } on match.
|
|
279
|
+
var bytes = _selectorBytes(certDer, rec.selector);
|
|
280
|
+
if (!bytes) return null;
|
|
281
|
+
var dataHex = String(rec.dataHex || "").toLowerCase();
|
|
282
|
+
// RFC 6698 §2.1.3 matching types — Full byte match (0) or hashed
|
|
283
|
+
// comparison via SHA two-family (1 short / 2 long digest).
|
|
284
|
+
if (rec.mtype === 0) {
|
|
285
|
+
return bytes.toString("hex") === dataHex
|
|
286
|
+
? { ok: true, mtype: "Full" } : null;
|
|
287
|
+
}
|
|
288
|
+
if (rec.mtype === 1) {
|
|
289
|
+
return _hashHex("sha256", bytes) === dataHex
|
|
290
|
+
? { ok: true, mtype: "SHA-256" } : null;
|
|
291
|
+
}
|
|
292
|
+
if (rec.mtype === 2) {
|
|
293
|
+
return _hashHex("sha512", bytes) === dataHex
|
|
294
|
+
? { ok: true, mtype: "SHA-512" } : null;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function daneVerifyChain(certChain, tlsaRecords, opts) {
|
|
300
|
+
if (!Array.isArray(certChain) || certChain.length === 0) {
|
|
301
|
+
throw new SmtpPolicyError("smtp/dane-bad-chain",
|
|
302
|
+
"dane.verifyChain: certChain must be a non-empty array of cert DER buffers");
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(tlsaRecords)) {
|
|
305
|
+
throw new SmtpPolicyError("smtp/dane-bad-tlsa",
|
|
306
|
+
"dane.verifyChain: tlsaRecords must be an array");
|
|
307
|
+
}
|
|
308
|
+
for (var c = 0; c < certChain.length; c += 1) {
|
|
309
|
+
if (!Buffer.isBuffer(certChain[c])) {
|
|
310
|
+
throw new SmtpPolicyError("smtp/dane-bad-chain",
|
|
311
|
+
"dane.verifyChain: certChain[" + c + "] must be a Buffer (cert.raw)");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
opts = opts || {};
|
|
315
|
+
var allowPkixModes = opts.allowPkixModes === true;
|
|
316
|
+
|
|
317
|
+
var matches = [];
|
|
318
|
+
var errors = [];
|
|
319
|
+
for (var t = 0; t < tlsaRecords.length; t += 1) {
|
|
320
|
+
var rec = tlsaRecords[t];
|
|
321
|
+
var usage = rec.usage;
|
|
322
|
+
if (usage === 2) { // DANE-TA — match against any non-leaf cert (TA in chain)
|
|
323
|
+
for (var i = 1; i < certChain.length; i += 1) {
|
|
324
|
+
var rv = _matchTlsaAgainstCert(rec, certChain[i]);
|
|
325
|
+
if (rv) { matches.push({ tlsaIndex: t, certIndex: i, usage: "DANE-TA", mtype: rv.mtype }); break; }
|
|
326
|
+
}
|
|
327
|
+
} else if (usage === 3) { // DANE-EE — match against the leaf cert only
|
|
328
|
+
var rvEe = _matchTlsaAgainstCert(rec, certChain[0]);
|
|
329
|
+
if (rvEe) matches.push({ tlsaIndex: t, certIndex: 0, usage: "DANE-EE", mtype: rvEe.mtype });
|
|
330
|
+
} else if ((usage === 0 || usage === 1) && allowPkixModes) {
|
|
331
|
+
// PKIX modes — operator opted in. The framework matches the TLSA
|
|
332
|
+
// record but cannot do PKIX path validation here; the operator
|
|
333
|
+
// pairs this with their existing PKIX validator (Node's TLS).
|
|
334
|
+
var pkixIdx = usage === 1 ? 0 : -1; // PKIX-EE: leaf only; PKIX-TA: any TA
|
|
335
|
+
if (pkixIdx === 0) {
|
|
336
|
+
var rvPe = _matchTlsaAgainstCert(rec, certChain[0]);
|
|
337
|
+
if (rvPe) matches.push({ tlsaIndex: t, certIndex: 0, usage: "PKIX-EE", mtype: rvPe.mtype, pkixPathRequired: true });
|
|
338
|
+
} else {
|
|
339
|
+
for (var j = 1; j < certChain.length; j += 1) {
|
|
340
|
+
var rvPa = _matchTlsaAgainstCert(rec, certChain[j]);
|
|
341
|
+
if (rvPa) { matches.push({ tlsaIndex: t, certIndex: j, usage: "PKIX-TA", mtype: rvPa.mtype, pkixPathRequired: true }); break; }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else if (usage === 0 || usage === 1) {
|
|
345
|
+
errors.push({ tlsaIndex: t, reason: "pkix-modes-not-allowed",
|
|
346
|
+
note: "PKIX-TA / PKIX-EE require opts.allowPkixModes + an external PKIX validator (RFC 7672 §3.1.1)" });
|
|
347
|
+
} else {
|
|
348
|
+
errors.push({ tlsaIndex: t, reason: "unsupported-usage", usage: usage });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
ok: matches.length > 0,
|
|
353
|
+
matches: matches,
|
|
354
|
+
errors: errors,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- TLS-RPT (RFC 8460) report shape ----
|
|
359
|
+
|
|
360
|
+
function tlsRptRecordShape(opts) {
|
|
361
|
+
opts = opts || {};
|
|
362
|
+
validateOpts(opts, [
|
|
363
|
+
"organization", "reportingMta", "contact",
|
|
364
|
+
"datestart", "dateend", "policies",
|
|
365
|
+
], "tlsRpt.recordShape");
|
|
366
|
+
|
|
367
|
+
if (typeof opts.organization !== "string") {
|
|
368
|
+
throw new SmtpPolicyError("smtp/tls-rpt-bad-organization",
|
|
369
|
+
"tlsRpt.recordShape: organization must be a string");
|
|
370
|
+
}
|
|
371
|
+
if (!Array.isArray(opts.policies)) {
|
|
372
|
+
throw new SmtpPolicyError("smtp/tls-rpt-bad-policies",
|
|
373
|
+
"tlsRpt.recordShape: policies must be an array");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// RFC 8460 §4.4 JSON report format.
|
|
377
|
+
return {
|
|
378
|
+
"organization-name": opts.organization,
|
|
379
|
+
"date-range": {
|
|
380
|
+
"start-datetime": opts.datestart || new Date().toISOString(),
|
|
381
|
+
"end-datetime": opts.dateend || new Date().toISOString(),
|
|
382
|
+
},
|
|
383
|
+
"contact-info": opts.contact || null,
|
|
384
|
+
"report-id": opts.reportId || _genReportId(),
|
|
385
|
+
"policies": opts.policies.map(function (p) {
|
|
386
|
+
return {
|
|
387
|
+
"policy": {
|
|
388
|
+
"policy-type": p.type || "sts",
|
|
389
|
+
"policy-string": p.policyString || [],
|
|
390
|
+
"policy-domain": p.domain,
|
|
391
|
+
"mx-host": p.mxHosts || [],
|
|
392
|
+
},
|
|
393
|
+
"summary": {
|
|
394
|
+
"total-successful-session-count": p.successCount || 0,
|
|
395
|
+
"total-failure-session-count": p.failureCount || 0,
|
|
396
|
+
},
|
|
397
|
+
"failure-details": p.failures || [],
|
|
398
|
+
};
|
|
399
|
+
}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function _genReportId() {
|
|
404
|
+
// RFC 8460 §4.4 requires uniqueness — use timestamp + random token.
|
|
405
|
+
return Date.now() + "-" + crypto.generateToken(C.BYTES.bytes(8));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---- TLS-RPT policy fetch (RFC 8460 §3) ----
|
|
409
|
+
//
|
|
410
|
+
// Reports are sent to the rua endpoints published at
|
|
411
|
+
// `_smtp._tls.<domain>` TXT. Format: `v=TLSRPTv1; rua=https://...,mailto:...`.
|
|
412
|
+
// rua is a comma-separated list of report URIs.
|
|
413
|
+
|
|
414
|
+
async function tlsRptFetchPolicy(domain, opts) {
|
|
415
|
+
if (typeof domain !== "string" || domain.length === 0) {
|
|
416
|
+
throw new SmtpPolicyError("smtp/tls-rpt-bad-domain",
|
|
417
|
+
"tlsRpt.fetchPolicy: domain must be a non-empty string");
|
|
418
|
+
}
|
|
419
|
+
opts = opts || {};
|
|
420
|
+
var qname = "_smtp._tls." + domain;
|
|
421
|
+
var records;
|
|
422
|
+
try {
|
|
423
|
+
if (opts.dnsLookup) {
|
|
424
|
+
records = await opts.dnsLookup(qname, "TXT");
|
|
425
|
+
} else {
|
|
426
|
+
records = await dnsPromises.resolveTxt(qname);
|
|
427
|
+
}
|
|
428
|
+
} catch (e) {
|
|
429
|
+
if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
|
|
430
|
+
throw new SmtpPolicyError("smtp/tls-rpt-lookup-failed",
|
|
431
|
+
"TLS-RPT TXT lookup for " + qname + " failed: " +
|
|
432
|
+
((e && e.message) || String(e)));
|
|
433
|
+
}
|
|
434
|
+
// Pick the first record that begins with v=TLSRPTv1 per RFC 8460 §3.
|
|
435
|
+
var joined = "";
|
|
436
|
+
for (var i = 0; i < (records || []).length; i += 1) {
|
|
437
|
+
var rec = records[i];
|
|
438
|
+
var s = Array.isArray(rec) ? rec.join("") : String(rec);
|
|
439
|
+
if (/^v=TLSRPTv1\b/i.test(s)) { joined = s; break; }
|
|
440
|
+
}
|
|
441
|
+
if (joined.length === 0) return null;
|
|
442
|
+
var parts = joined.split(";");
|
|
443
|
+
var rua = [];
|
|
444
|
+
for (var p = 0; p < parts.length; p += 1) {
|
|
445
|
+
var t = parts[p].trim();
|
|
446
|
+
var eq = t.indexOf("=");
|
|
447
|
+
if (eq === -1) continue;
|
|
448
|
+
var k = t.slice(0, eq).trim().toLowerCase();
|
|
449
|
+
var v = t.slice(eq + 1).trim();
|
|
450
|
+
if (k === "rua") {
|
|
451
|
+
var uris = v.split(",");
|
|
452
|
+
for (var u = 0; u < uris.length; u += 1) {
|
|
453
|
+
var uri = uris[u].trim();
|
|
454
|
+
if (uri.length > 0) rua.push(uri);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { version: "TLSRPTv1", rua: rua };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- TLS-RPT report submission (RFC 8460 §6) ----
|
|
462
|
+
//
|
|
463
|
+
// Submit a generated report (from tlsRptRecordShape) to a published
|
|
464
|
+
// rua endpoint. HTTPS endpoints receive `application/tlsrpt+gzip`
|
|
465
|
+
// (gzip-compressed JSON); mailto: endpoints receive the JSON via SMTP
|
|
466
|
+
// (operator wires `b.mail`). The framework ships HTTPS submission
|
|
467
|
+
// directly and exposes a `mailtoBody` builder so operators can hand
|
|
468
|
+
// the body to their mail transport.
|
|
469
|
+
|
|
470
|
+
async function tlsRptSubmit(report, opts) {
|
|
471
|
+
if (!report || typeof report !== "object") {
|
|
472
|
+
throw new SmtpPolicyError("smtp/tls-rpt-bad-report",
|
|
473
|
+
"tlsRpt.submit: report must be an object");
|
|
474
|
+
}
|
|
475
|
+
opts = opts || {};
|
|
476
|
+
validateOpts(opts, ["rua", "httpClient", "timeoutMs", "audit"], "tlsRpt.submit");
|
|
477
|
+
if (!Array.isArray(opts.rua) || opts.rua.length === 0) {
|
|
478
|
+
throw new SmtpPolicyError("smtp/tls-rpt-bad-rua",
|
|
479
|
+
"tlsRpt.submit: opts.rua must be a non-empty array of URIs");
|
|
480
|
+
}
|
|
481
|
+
var json = JSON.stringify(report);
|
|
482
|
+
var gzipped = zlib.gzipSync(Buffer.from(json, "utf8"));
|
|
483
|
+
var client = opts.httpClient || httpClient();
|
|
484
|
+
var timeoutMs = opts.timeoutMs || C.TIME.seconds(30);
|
|
485
|
+
|
|
486
|
+
var results = [];
|
|
487
|
+
for (var i = 0; i < opts.rua.length; i += 1) {
|
|
488
|
+
var uri = opts.rua[i];
|
|
489
|
+
var entry = { uri: uri, ok: false, status: null, error: null, kind: null };
|
|
490
|
+
try {
|
|
491
|
+
if (/^https:\/\//i.test(uri)) {
|
|
492
|
+
entry.kind = "https";
|
|
493
|
+
// allow:raw-outbound-http — `client` is the framework httpClient
|
|
494
|
+
// (or operator-supplied test mock); SSRF + DNS-pin already
|
|
495
|
+
// applied through the framework wrapper.
|
|
496
|
+
var rv = await client.request({
|
|
497
|
+
method: "POST",
|
|
498
|
+
url: uri,
|
|
499
|
+
headers: {
|
|
500
|
+
"content-type": "application/tlsrpt+gzip",
|
|
501
|
+
"content-encoding": "gzip",
|
|
502
|
+
},
|
|
503
|
+
body: gzipped,
|
|
504
|
+
timeoutMs: timeoutMs,
|
|
505
|
+
});
|
|
506
|
+
entry.status = rv && rv.status;
|
|
507
|
+
entry.ok = entry.status >= 200 && entry.status < 300; // allow:raw-byte-literal — HTTP 2xx range
|
|
508
|
+
if (!entry.ok) entry.error = "HTTP " + entry.status;
|
|
509
|
+
} else if (/^mailto:/i.test(uri)) {
|
|
510
|
+
// Operator-side transport. Surface the prepared body so the
|
|
511
|
+
// operator can hand it to b.mail directly.
|
|
512
|
+
entry.kind = "mailto";
|
|
513
|
+
entry.ok = true;
|
|
514
|
+
entry.mailto = {
|
|
515
|
+
to: uri.slice("mailto:".length),
|
|
516
|
+
subject: "Report Domain: " + (report["organization-name"] || "") +
|
|
517
|
+
" Submitter: " + (report["organization-name"] || "") +
|
|
518
|
+
" Report-ID: <" + (report["report-id"] || "") + ">",
|
|
519
|
+
contentType: "application/tlsrpt+gzip",
|
|
520
|
+
encoding: "gzip",
|
|
521
|
+
body: gzipped,
|
|
522
|
+
};
|
|
523
|
+
} else {
|
|
524
|
+
entry.error = "unsupported rua URI scheme: " + uri.split(":")[0];
|
|
525
|
+
}
|
|
526
|
+
} catch (e) {
|
|
527
|
+
entry.error = (e && e.message) || String(e);
|
|
528
|
+
}
|
|
529
|
+
results.push(entry);
|
|
530
|
+
}
|
|
531
|
+
return { submitted: results.length, results: results };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
module.exports = {
|
|
535
|
+
mtaSts: Object.freeze({
|
|
536
|
+
fetch: mtaStsFetch,
|
|
537
|
+
matchMx: mtaStsMatchMx,
|
|
538
|
+
parsePolicy: _parseStsPolicy,
|
|
539
|
+
}),
|
|
540
|
+
dane: Object.freeze({
|
|
541
|
+
tlsa: daneTlsa,
|
|
542
|
+
recordShape: daneRecordShape,
|
|
543
|
+
verifyChain: daneVerifyChain,
|
|
544
|
+
}),
|
|
545
|
+
tlsRpt: Object.freeze({
|
|
546
|
+
recordShape: tlsRptRecordShape,
|
|
547
|
+
fetchPolicy: tlsRptFetchPolicy,
|
|
548
|
+
submit: tlsRptSubmit,
|
|
549
|
+
}),
|
|
550
|
+
SmtpPolicyError: SmtpPolicyError,
|
|
551
|
+
};
|