@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.
- package/CHANGELOG.md +6 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/db-collection.js +290 -0
- package/lib/db-query.js +245 -0
- package/lib/db.js +173 -67
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/mtls-ca.js +15 -5
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/mail-bimi.js
CHANGED
|
@@ -1,56 +1,169 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.mail.bimi
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title BIMI
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Brand Indicators for Message Identification — RFC 9091. BIMI
|
|
9
|
+
* records publish a sender's brand-logo URL in DNS so receiving
|
|
10
|
+
* MTAs can render it next to the message in supported clients
|
|
11
|
+
* (Gmail, Yahoo, Apple Mail). The TXT record format is:
|
|
8
12
|
*
|
|
9
|
-
*
|
|
13
|
+
* default._bimi.<domain> IN TXT "v=BIMI1; l=https://...; a=https://..."
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* - `l=` URL to the SVG logo file (Tiny PS Profile per RFC 9091 §5)
|
|
16
|
+
* - `a=` URL to the Verified Mark Certificate (VMC / CMC) — §6
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* BIMI is layered on a passing DMARC posture (the receiver requires
|
|
19
|
+
* DMARC at quarantine or reject). No-op for senders without DMARC
|
|
20
|
+
* enforcement.
|
|
17
21
|
*
|
|
18
|
-
*
|
|
19
|
-
* b.mail.bimi.recordShape({ logoUrl, vmcUrl?, selector? }) → string
|
|
20
|
-
* b.mail.bimi.fetchPolicy(domain, opts?) → { v, l, a } | null
|
|
21
|
-
* b.mail.bimi.parseRecord(text) → { v, l, a } | null
|
|
22
|
+
* Surface:
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* b.mail.bimi.recordShape({ logoUrl, vmcUrl?, selector? }) -> string
|
|
25
|
+
* b.mail.bimi.fetchPolicy(domain, opts?) -> record | null
|
|
26
|
+
* b.mail.bimi.parseRecord(text) -> record | null
|
|
27
|
+
* b.mail.bimi.fetchAndVerifyMark({ domain, vmcUrl, ... }) -> verified mark
|
|
28
|
+
* b.mail.bimi.validateTinyPsSvg(svgBytes) -> { ok, violations }
|
|
29
|
+
*
|
|
30
|
+
* `fetchAndVerifyMark` fetches a VMC / CMC over HTTPS via b.httpClient,
|
|
31
|
+
* parses it as X.509, validates the chain against the BIMI Group
|
|
32
|
+
* trust anchors (vendored at lib/vendor/bimi-trust-anchors.pem,
|
|
33
|
+
* operator-overridable via `trustAnchorsPem`), confirms the cert's
|
|
34
|
+
* subjectAltName URI matches the BIMI domain, and confirms the
|
|
35
|
+
* cert carries the BIMI mark-verification policy OID
|
|
36
|
+
* (1.3.6.1.5.5.7.3.31). The verified mark is returned as
|
|
37
|
+
* { svg, evidenceDocument } pulled from RFC 3709 logotype extension
|
|
38
|
+
* when present.
|
|
39
|
+
*
|
|
40
|
+
* `validateTinyPsSvg` enforces the AuthIndicators-WG Tiny PS subset:
|
|
41
|
+
* single root <svg>, version="1.2", baseProfile="tiny-ps", viewBox
|
|
42
|
+
* present, no script / style / foreignObject / animate / filter /
|
|
43
|
+
* image, no external href / xlink:href references (only #fragment
|
|
44
|
+
* permitted), bounded byte size (32 KiB cap).
|
|
45
|
+
*
|
|
46
|
+
* @card
|
|
47
|
+
* RFC 9091 BIMI policy lookup, VMC + CMC fetch + chain validation, and Tiny-PS SVG profile enforcement for inbox brand-mark rendering.
|
|
28
48
|
*/
|
|
29
49
|
|
|
30
50
|
var dns = require("node:dns");
|
|
51
|
+
var nodeCrypto = require("node:crypto");
|
|
31
52
|
var dnsPromises = dns.promises;
|
|
32
|
-
var
|
|
33
|
-
var
|
|
53
|
+
var fs = require("node:fs");
|
|
54
|
+
var nodePath = require("node:path");
|
|
55
|
+
|
|
56
|
+
var asn1 = require("./asn1-der");
|
|
34
57
|
var C = require("./constants");
|
|
35
|
-
var
|
|
58
|
+
var httpClient = require("./http-client");
|
|
59
|
+
var lazyRequire = require("./lazy-require");
|
|
60
|
+
var safeBuffer = require("./safe-buffer");
|
|
61
|
+
var safeUrl = require("./safe-url");
|
|
62
|
+
var validateOpts = require("./validate-opts");
|
|
63
|
+
var { defineClass, MailBimiError } = require("./framework-error");
|
|
64
|
+
|
|
65
|
+
// Audit emitter — lazy to avoid pulling the audit dispatcher into the
|
|
66
|
+
// module load graph until the first verify call. fetchAndVerifyMark is
|
|
67
|
+
// the only path that emits.
|
|
68
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
36
69
|
|
|
70
|
+
// Pre-existing BimiError covered DNS / record-shape failures. Kept for
|
|
71
|
+
// backwards-compatibility on the existing surface (recordShape /
|
|
72
|
+
// parseRecord / fetchPolicy). The new fetchAndVerifyMark / Tiny-PS
|
|
73
|
+
// surface uses MailBimiError so chain / policy / SVG failures route
|
|
74
|
+
// to a domain-shared class with the documented `bimi/...` codes.
|
|
37
75
|
var BimiError = defineClass("BimiError", { alwaysPermanent: true });
|
|
38
76
|
|
|
39
77
|
var BIMI_VERSION = "BIMI1";
|
|
40
78
|
var BIMI_DEFAULT_SELECTOR = "default";
|
|
41
79
|
var BIMI_RECORD_MAX_BYTES = C.BYTES.kib(2);
|
|
42
80
|
|
|
81
|
+
// AuthIndicators-WG Tiny-PS profile cap (32 KiB). Larger SVGs are
|
|
82
|
+
// refused at validate-time before any tokenization.
|
|
83
|
+
var TINY_PS_MAX_BYTES = C.BYTES.kib(32);
|
|
84
|
+
|
|
85
|
+
// VMC / CMC fetch cap. Production VMCs are typically ~10-20 KiB;
|
|
86
|
+
// 256 KiB is a generous ceiling that still bounds the download against
|
|
87
|
+
// pathological responses. Operators with a stricter posture pass
|
|
88
|
+
// `maxResponseBytes` to override.
|
|
89
|
+
var VMC_DEFAULT_MAX_BYTES = C.BYTES.kib(256);
|
|
90
|
+
|
|
91
|
+
// HTTP timeout for the VMC / CMC fetch. Operators pass `timeoutMs` to
|
|
92
|
+
// override.
|
|
93
|
+
var VMC_DEFAULT_TIMEOUT_MS = C.TIME.seconds(15);
|
|
94
|
+
|
|
95
|
+
// RFC 9091 6.1.1 — the BIMI mark-verification ExtendedKeyUsage OID.
|
|
96
|
+
// A valid VMC / CMC MUST list this OID under id-ce-extKeyUsage
|
|
97
|
+
// (2.5.29.37). The OID is identical for both certificate types; the
|
|
98
|
+
// distinction between VMC and CMC is conveyed by the cert's policyOIDs
|
|
99
|
+
// (id-ce-certificatePolicies, 2.5.29.32):
|
|
100
|
+
//
|
|
101
|
+
// 1.3.6.1.5.5.7.3.31 - id-kp-bimi (Mark Verification)
|
|
102
|
+
// 1.3.6.1.4.1.53087.1.1 - VMC policy (registered trademark)
|
|
103
|
+
// 1.3.6.1.4.1.53087.1.2 - CMC policy (common mark, prior-use)
|
|
104
|
+
//
|
|
105
|
+
// The framework verifies the EKU OID is present; the policy OIDs are
|
|
106
|
+
// surfaced on the result so operators can branch their UI on
|
|
107
|
+
// VMC-vs-CMC if their inbox renders them differently.
|
|
108
|
+
var BIMI_EKU_MARK_VERIFICATION = "1.3.6.1.5.5.7.3.31";
|
|
109
|
+
var VMC_POLICY_OID = "1.3.6.1.4.1.53087.1.1";
|
|
110
|
+
var CMC_POLICY_OID = "1.3.6.1.4.1.53087.1.2";
|
|
111
|
+
|
|
112
|
+
// RFC 3709 4.2 — the logotype extension OID.
|
|
113
|
+
var ID_PE_LOGOTYPE = "1.3.6.1.5.5.7.1.12";
|
|
114
|
+
|
|
115
|
+
// Vendored BIMI Group trust anchors. Read once at module load. The
|
|
116
|
+
// vendor file may be empty-of-PEM in source trees (operators populate
|
|
117
|
+
// via the documented refresh procedure); fetchAndVerifyMark refuses
|
|
118
|
+
// to validate if both the vendored bundle is empty and the call-site
|
|
119
|
+
// `trustAnchorsPem` opt is absent.
|
|
120
|
+
var _vendoredTrustAnchorsPath = nodePath.join(__dirname, "vendor", "bimi-trust-anchors.pem");
|
|
121
|
+
var _vendoredTrustAnchorsPem = "";
|
|
122
|
+
try {
|
|
123
|
+
_vendoredTrustAnchorsPem = fs.readFileSync(_vendoredTrustAnchorsPath, "utf8");
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
_vendoredTrustAnchorsPem = "";
|
|
126
|
+
}
|
|
127
|
+
|
|
43
128
|
function _validateUrl(url, label) {
|
|
44
|
-
// RFC 9091
|
|
129
|
+
// RFC 9091 4.2 — `l=` and `a=` MUST be HTTPS URLs.
|
|
45
130
|
try {
|
|
46
131
|
safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
47
132
|
} catch (e) {
|
|
48
133
|
throw new BimiError("mail-bimi/bad-" + label,
|
|
49
|
-
"bimi: " + label + " must be an https:// URL
|
|
134
|
+
"bimi: " + label + " must be an https:// URL - got '" + url + "': " +
|
|
50
135
|
((e && e.message) || String(e)));
|
|
51
136
|
}
|
|
52
137
|
}
|
|
53
138
|
|
|
139
|
+
/**
|
|
140
|
+
* @primitive b.mail.bimi.recordShape
|
|
141
|
+
* @signature b.mail.bimi.recordShape(opts)
|
|
142
|
+
* @since 0.7.0
|
|
143
|
+
* @status stable
|
|
144
|
+
* @related b.mail.bimi.parseRecord, b.mail.bimi.fetchPolicy
|
|
145
|
+
*
|
|
146
|
+
* Builds the canonical RFC 9091 BIMI TXT-record string from a logo
|
|
147
|
+
* URL and optional VMC URL. Throws on missing or non-https URLs and
|
|
148
|
+
* on control / record-separator characters in the URLs. Operators
|
|
149
|
+
* publish the returned string at `default._bimi.<domain>` (or the
|
|
150
|
+
* selector subdomain if they're using non-default selectors).
|
|
151
|
+
*
|
|
152
|
+
* @opts
|
|
153
|
+
* {
|
|
154
|
+
* logoUrl: string, // required - https:// URL to Tiny-PS SVG
|
|
155
|
+
* vmcUrl: string?, // optional - https:// URL to VMC / CMC PEM
|
|
156
|
+
* selector: string?, // unused at record-shape time; reserved
|
|
157
|
+
* // for future per-selector behavior
|
|
158
|
+
* }
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* var rec = b.mail.bimi.recordShape({
|
|
162
|
+
* logoUrl: "https://example.com/bimi/logo.svg",
|
|
163
|
+
* vmcUrl: "https://example.com/bimi/cert.pem",
|
|
164
|
+
* });
|
|
165
|
+
* // -> "v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/cert.pem"
|
|
166
|
+
*/
|
|
54
167
|
function recordShape(opts) {
|
|
55
168
|
validateOpts.requireObject(opts, "bimi.recordShape", BimiError);
|
|
56
169
|
validateOpts(opts, ["logoUrl", "vmcUrl", "selector"], "bimi.recordShape");
|
|
@@ -62,7 +175,7 @@ function recordShape(opts) {
|
|
|
62
175
|
"bimi.recordShape: vmcUrl", BimiError, "mail-bimi/bad-vmc");
|
|
63
176
|
_validateUrl(opts.vmcUrl, "vmcUrl");
|
|
64
177
|
}
|
|
65
|
-
// No CR/LF/NUL/semicolon
|
|
178
|
+
// No CR/LF/NUL/semicolon - defense-in-depth so a hostile URL can't
|
|
66
179
|
// inject a record-separator sequence into the published TXT.
|
|
67
180
|
if (/[\r\n\0;]/.test(opts.logoUrl)) {
|
|
68
181
|
throw new BimiError("mail-bimi/bad-logo",
|
|
@@ -78,10 +191,27 @@ function recordShape(opts) {
|
|
|
78
191
|
return fields.join("; ");
|
|
79
192
|
}
|
|
80
193
|
|
|
194
|
+
/**
|
|
195
|
+
* @primitive b.mail.bimi.parseRecord
|
|
196
|
+
* @signature b.mail.bimi.parseRecord(text)
|
|
197
|
+
* @since 0.7.0
|
|
198
|
+
* @status stable
|
|
199
|
+
* @related b.mail.bimi.fetchPolicy
|
|
200
|
+
*
|
|
201
|
+
* Parses a BIMI TXT record into `{ v, l, a }`. Returns null when the
|
|
202
|
+
* text is not a v=BIMI1 record, the `l=` URL is missing, or the
|
|
203
|
+
* total bytes exceed the 2 KiB sanity cap. Use this when the operator
|
|
204
|
+
* already has the TXT bytes in hand (e.g. an inbound auth-results
|
|
205
|
+
* pipeline carrying the resolved record).
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* var rv = b.mail.bimi.parseRecord("v=BIMI1; l=https://example.com/logo.svg");
|
|
209
|
+
* // -> { v: "BIMI1", l: "https://example.com/logo.svg", a: null }
|
|
210
|
+
*/
|
|
81
211
|
function parseRecord(text) {
|
|
82
212
|
if (typeof text !== "string" || text.length === 0) return null;
|
|
83
|
-
if (text.length > BIMI_RECORD_MAX_BYTES) return null;
|
|
84
|
-
// RFC 9091
|
|
213
|
+
if (text.length > BIMI_RECORD_MAX_BYTES) return null;
|
|
214
|
+
// RFC 9091 4 - semicolon-separated, key=value, leading "v=BIMI1".
|
|
85
215
|
var parts = text.split(";");
|
|
86
216
|
var rv = { v: null, l: null, a: null };
|
|
87
217
|
for (var i = 0; i < parts.length; i += 1) {
|
|
@@ -97,6 +227,37 @@ function parseRecord(text) {
|
|
|
97
227
|
return rv;
|
|
98
228
|
}
|
|
99
229
|
|
|
230
|
+
/**
|
|
231
|
+
* @primitive b.mail.bimi.fetchPolicy
|
|
232
|
+
* @signature b.mail.bimi.fetchPolicy(domain, opts?)
|
|
233
|
+
* @since 0.7.0
|
|
234
|
+
* @status stable
|
|
235
|
+
* @related b.mail.bimi.fetchAndVerifyMark
|
|
236
|
+
*
|
|
237
|
+
* Resolves `default._bimi.<domain>` (or `<selector>._bimi.<domain>`
|
|
238
|
+
* if `opts.selector` is set) and returns the parsed `{ v, l, a }`.
|
|
239
|
+
* Returns null when no TXT record exists or no record on the
|
|
240
|
+
* resolved name parses as v=BIMI1. Operators feed the returned
|
|
241
|
+
* `l=` / `a=` URLs into `fetchAndVerifyMark` to retrieve the
|
|
242
|
+
* verified mark.
|
|
243
|
+
*
|
|
244
|
+
* @opts
|
|
245
|
+
* {
|
|
246
|
+
* selector: string?, // default "default"
|
|
247
|
+
* dnsLookup: async (qname, type) => rows?, // operator-supplied resolver
|
|
248
|
+
* // (DoH / cache / fixture);
|
|
249
|
+
* // default: node:dns.resolveTxt
|
|
250
|
+
* }
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* var pol = await b.mail.bimi.fetchPolicy("example.com");
|
|
254
|
+
* if (pol && pol.a) {
|
|
255
|
+
* var verified = await b.mail.bimi.fetchAndVerifyMark({
|
|
256
|
+
* domain: "example.com",
|
|
257
|
+
* vmcUrl: pol.a,
|
|
258
|
+
* });
|
|
259
|
+
* }
|
|
260
|
+
*/
|
|
100
261
|
async function fetchPolicy(domain, opts) {
|
|
101
262
|
validateOpts.requireNonEmptyString(domain,
|
|
102
263
|
"bimi.fetchPolicy: domain", BimiError, "mail-bimi/bad-domain");
|
|
@@ -113,7 +274,7 @@ async function fetchPolicy(domain, opts) {
|
|
|
113
274
|
"bimi.fetchPolicy: TXT lookup for " + qname + " failed: " +
|
|
114
275
|
((e && e.message) || String(e)));
|
|
115
276
|
}
|
|
116
|
-
// RFC 9091
|
|
277
|
+
// RFC 9091 4.1 - a TXT lookup may return multiple chunks; pick
|
|
117
278
|
// the first record that begins with v=BIMI1.
|
|
118
279
|
for (var i = 0; i < (records || []).length; i += 1) {
|
|
119
280
|
var rec = records[i];
|
|
@@ -124,10 +285,764 @@ async function fetchPolicy(domain, opts) {
|
|
|
124
285
|
return null;
|
|
125
286
|
}
|
|
126
287
|
|
|
288
|
+
// ---- Tiny-PS SVG validation ----
|
|
289
|
+
|
|
290
|
+
// AuthIndicators-WG Tiny PS Profile 3 - refused element list. Each
|
|
291
|
+
// element here is an unconditional refuse: <script> enables JS
|
|
292
|
+
// execution, <style> carries CSS that can fetch external resources,
|
|
293
|
+
// <foreignObject> tunnels arbitrary HTML / XML, animation elements
|
|
294
|
+
// trigger time-based DOM changes (security + battery), <filter>
|
|
295
|
+
// requires a non-trivial renderer, <image> re-fetches arbitrary URLs
|
|
296
|
+
// (SSRF vector inside the inbox preview pipeline).
|
|
297
|
+
var TINY_PS_FORBIDDEN_TAGS = {
|
|
298
|
+
"script": true,
|
|
299
|
+
"style": true,
|
|
300
|
+
"foreignobject": true,
|
|
301
|
+
"animate": true,
|
|
302
|
+
"animatetransform": true,
|
|
303
|
+
"animatemotion": true,
|
|
304
|
+
"set": true,
|
|
305
|
+
"filter": true,
|
|
306
|
+
"image": true,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @primitive b.mail.bimi.validateTinyPsSvg
|
|
311
|
+
* @signature b.mail.bimi.validateTinyPsSvg(svgBytes)
|
|
312
|
+
* @since 0.8.53
|
|
313
|
+
* @status stable
|
|
314
|
+
* @related b.mail.bimi.fetchAndVerifyMark, b.guardSvg
|
|
315
|
+
*
|
|
316
|
+
* Validates a brand-mark SVG against the AuthIndicators-WG Tiny PS
|
|
317
|
+
* profile (RFC 9091 5). Tiny-PS is a strict subset of SVG 1.2:
|
|
318
|
+
* single <svg> root with `version="1.2"` and `baseProfile="tiny-ps"`,
|
|
319
|
+
* `viewBox` required, byte size up to 32 KiB, no scripts / styles /
|
|
320
|
+
* foreign content / animation / filters / external image refs, no
|
|
321
|
+
* external references in `href` / `xlink:href` attributes (only
|
|
322
|
+
* `#fragment` permitted), no `<!DOCTYPE>` / `<!ENTITY>` / processing
|
|
323
|
+
* instructions other than the XML prolog. Returns
|
|
324
|
+
* `{ ok, violations }` where each violation is `{ code, message }`.
|
|
325
|
+
* Throws `MailBimiError` (`bimi/svg-too-large`) when the input
|
|
326
|
+
* exceeds the byte cap; throws (`bimi/svg-tiny-ps-violation` with
|
|
327
|
+
* `parse-failed`) on tokenizer failure.
|
|
328
|
+
*
|
|
329
|
+
* @opts
|
|
330
|
+
* svgBytes: Buffer | string
|
|
331
|
+
*
|
|
332
|
+
* @example
|
|
333
|
+
* var rv = b.mail.bimi.validateTinyPsSvg('<svg version="1.2" baseProfile="tiny-ps" viewBox="0 0 1 1" xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
334
|
+
* // -> { ok: true, violations: [] }
|
|
335
|
+
*/
|
|
336
|
+
function validateTinyPsSvg(svgBytes) {
|
|
337
|
+
var s;
|
|
338
|
+
if (Buffer.isBuffer(svgBytes) || svgBytes instanceof Uint8Array) {
|
|
339
|
+
if (svgBytes.length > TINY_PS_MAX_BYTES) {
|
|
340
|
+
throw new MailBimiError("bimi/svg-too-large",
|
|
341
|
+
"bimi.validateTinyPsSvg: input " + svgBytes.length + " bytes exceeds Tiny-PS cap " + TINY_PS_MAX_BYTES);
|
|
342
|
+
}
|
|
343
|
+
s = safeBuffer.normalizeText(Buffer.from(svgBytes), {
|
|
344
|
+
maxBytes: TINY_PS_MAX_BYTES,
|
|
345
|
+
errorClass: MailBimiError,
|
|
346
|
+
typeCode: "bimi/svg-tiny-ps-violation",
|
|
347
|
+
sizeCode: "bimi/svg-too-large",
|
|
348
|
+
typeMessage: "bimi.validateTinyPsSvg: input must be Buffer / Uint8Array / string",
|
|
349
|
+
sizeMessage: "bimi.validateTinyPsSvg: input exceeds Tiny-PS cap " + TINY_PS_MAX_BYTES + " bytes",
|
|
350
|
+
});
|
|
351
|
+
} else if (typeof svgBytes === "string") {
|
|
352
|
+
if (Buffer.byteLength(svgBytes, "utf8") > TINY_PS_MAX_BYTES) {
|
|
353
|
+
throw new MailBimiError("bimi/svg-too-large",
|
|
354
|
+
"bimi.validateTinyPsSvg: input " + Buffer.byteLength(svgBytes, "utf8") + " bytes exceeds Tiny-PS cap " + TINY_PS_MAX_BYTES);
|
|
355
|
+
}
|
|
356
|
+
s = svgBytes;
|
|
357
|
+
} else {
|
|
358
|
+
throw new MailBimiError("bimi/svg-tiny-ps-violation",
|
|
359
|
+
"bimi.validateTinyPsSvg: input must be Buffer / Uint8Array / string");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
var violations = [];
|
|
363
|
+
function _vio(code, message) { violations.push({ code: code, message: message }); }
|
|
364
|
+
|
|
365
|
+
var tokens;
|
|
366
|
+
try { tokens = _tokenizeTinyPsSvg(s); }
|
|
367
|
+
catch (e) {
|
|
368
|
+
throw new MailBimiError("bimi/svg-tiny-ps-violation",
|
|
369
|
+
"bimi.validateTinyPsSvg: parse-failed: " + ((e && e.message) || String(e)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
var rootSvg = null;
|
|
373
|
+
var depth = 0;
|
|
374
|
+
var sawSecondRoot = false;
|
|
375
|
+
for (var i = 0; i < tokens.length; i += 1) {
|
|
376
|
+
var t = tokens[i];
|
|
377
|
+
|
|
378
|
+
if (t.type === "doctype") {
|
|
379
|
+
_vio("doctype-forbidden", "<!DOCTYPE> is forbidden in Tiny-PS (entity-expansion / DTD class)");
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (t.type === "declaration") {
|
|
383
|
+
_vio("declaration-forbidden",
|
|
384
|
+
"<!" + (t.raw || "").slice(2, 30) + "...> declaration is forbidden in Tiny-PS");
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (t.type === "processingInstruction") {
|
|
388
|
+
var pir = (t.raw || "").trim();
|
|
389
|
+
if (!/^<\?xml\b/i.test(pir)) {
|
|
390
|
+
_vio("pi-forbidden", "processing instruction is forbidden in Tiny-PS: " + pir.slice(0, 40)) /* allow:raw-byte-literal — display truncation chars, not bytes */;
|
|
391
|
+
}
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (t.type === "comment" || t.type === "text" || t.type === "cdata") continue;
|
|
395
|
+
|
|
396
|
+
if (t.type === "endTag") {
|
|
397
|
+
depth -= 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (t.type === "tag") {
|
|
402
|
+
var name = t.name;
|
|
403
|
+
if (TINY_PS_FORBIDDEN_TAGS[name]) {
|
|
404
|
+
_vio("element-forbidden",
|
|
405
|
+
"<" + name + "> is forbidden in Tiny-PS (script / style / animation / filter / image / foreign-content class)");
|
|
406
|
+
}
|
|
407
|
+
// Any element name starting with "animate" is animation (covers
|
|
408
|
+
// future SMIL extensions not in the static list above).
|
|
409
|
+
if (name.indexOf("animate") === 0 && !TINY_PS_FORBIDDEN_TAGS[name]) {
|
|
410
|
+
_vio("element-forbidden",
|
|
411
|
+
"<" + name + "> animation element is forbidden in Tiny-PS");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Top-level root tracking. The root <svg> MUST be at depth 0; any
|
|
415
|
+
// second top-level element is a multi-root violation.
|
|
416
|
+
if (depth === 0) {
|
|
417
|
+
if (rootSvg === null) {
|
|
418
|
+
if (name !== "svg") {
|
|
419
|
+
_vio("root-not-svg",
|
|
420
|
+
"Tiny-PS root element must be <svg> - got <" + name + ">");
|
|
421
|
+
}
|
|
422
|
+
rootSvg = t;
|
|
423
|
+
} else if (!sawSecondRoot) {
|
|
424
|
+
_vio("multiple-root-elements",
|
|
425
|
+
"Tiny-PS document must have exactly one root <svg> element");
|
|
426
|
+
sawSecondRoot = true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
var attrs = t.attrs || {};
|
|
431
|
+
for (var aname in attrs) {
|
|
432
|
+
if (!Object.prototype.hasOwnProperty.call(attrs, aname)) continue;
|
|
433
|
+
var aval = String(attrs[aname]);
|
|
434
|
+
var lname = aname.toLowerCase();
|
|
435
|
+
|
|
436
|
+
// Event-handler attrs (onload / onclick / on*) - universally
|
|
437
|
+
// forbidden; same JS-execution class as <script>.
|
|
438
|
+
if (lname.indexOf("on") === 0 && lname.length > 2) {
|
|
439
|
+
_vio("event-handler-forbidden",
|
|
440
|
+
"event-handler attribute `" + aname + "` is forbidden in Tiny-PS");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// href / xlink:href - only #fragment refs allowed.
|
|
444
|
+
if (lname === "href" || lname === "xlink:href") {
|
|
445
|
+
if (aval.length > 0 && aval.charAt(0) !== "#") {
|
|
446
|
+
_vio("external-ref-forbidden",
|
|
447
|
+
"external reference in `" + aname + "='" + aval.slice(0, 60) /* allow:raw-time-literal — display truncation chars, not seconds */ + "...'` " +
|
|
448
|
+
"is forbidden in Tiny-PS (only `#fragment` permitted)");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// style attribute - Tiny-PS forbids <style>; the style attribute
|
|
453
|
+
// is treated as the same risk surface (CSS @import / url() class).
|
|
454
|
+
if (lname === "style") {
|
|
455
|
+
_vio("style-attr-forbidden",
|
|
456
|
+
"`style` attribute is forbidden in Tiny-PS (CSS @import / url() class)");
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!t.selfClosing) depth += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (rootSvg !== null) {
|
|
465
|
+
var rootAttrs = rootSvg.attrs || {};
|
|
466
|
+
if (rootAttrs.version !== "1.2") {
|
|
467
|
+
_vio("bad-version",
|
|
468
|
+
"Tiny-PS requires version=\"1.2\" on root <svg> - got `" +
|
|
469
|
+
(rootAttrs.version === undefined ? "(missing)" : rootAttrs.version) + "`");
|
|
470
|
+
}
|
|
471
|
+
if (rootAttrs.baseProfile !== "tiny-ps" && rootAttrs.baseprofile !== "tiny-ps") {
|
|
472
|
+
_vio("bad-base-profile",
|
|
473
|
+
"Tiny-PS requires baseProfile=\"tiny-ps\" on root <svg> - got `" +
|
|
474
|
+
(rootAttrs.baseProfile || rootAttrs.baseprofile || "(missing)") + "`");
|
|
475
|
+
}
|
|
476
|
+
if (!rootAttrs.viewBox && !rootAttrs.viewbox) {
|
|
477
|
+
_vio("missing-viewbox",
|
|
478
|
+
"Tiny-PS requires viewBox attribute on root <svg>");
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { ok: violations.length === 0, violations: violations };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// _tokenizeTinyPsSvg - minimal SVG tokenizer for Tiny-PS profile checks.
|
|
486
|
+
// Same shape as guard-svg's tokenizer but tighter (Tiny-PS only needs
|
|
487
|
+
// element / attribute / declaration shapes; no sanitization output).
|
|
488
|
+
function _tokenizeTinyPsSvg(s) {
|
|
489
|
+
var tokens = [];
|
|
490
|
+
var len = s.length;
|
|
491
|
+
var pos = 0;
|
|
492
|
+
|
|
493
|
+
while (pos < len) {
|
|
494
|
+
var lt = s.indexOf("<", pos);
|
|
495
|
+
if (lt === -1) {
|
|
496
|
+
if (pos < len) tokens.push({ type: "text", raw: s.slice(pos, len) });
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
if (lt > pos) tokens.push({ type: "text", raw: s.slice(pos, lt) });
|
|
500
|
+
|
|
501
|
+
if (s.startsWith("<!--", lt)) {
|
|
502
|
+
var endC = s.indexOf("-->", lt + 4);
|
|
503
|
+
if (endC === -1) throw new Error("unterminated comment"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
504
|
+
tokens.push({ type: "comment", raw: s.slice(lt, endC + 3) });
|
|
505
|
+
pos = endC + 3;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (s.startsWith("<![CDATA[", lt)) {
|
|
509
|
+
var endX = s.indexOf("]]>", lt + 9);
|
|
510
|
+
if (endX === -1) throw new Error("unterminated CDATA"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
511
|
+
tokens.push({ type: "cdata", raw: s.slice(lt, endX + 3) });
|
|
512
|
+
pos = endX + 3;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (s.startsWith("<!DOCTYPE", lt) || s.startsWith("<!doctype", lt)) {
|
|
516
|
+
var endD = s.indexOf(">", lt);
|
|
517
|
+
if (endD === -1) throw new Error("unterminated doctype"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
518
|
+
tokens.push({ type: "doctype", raw: s.slice(lt, endD + 1) });
|
|
519
|
+
pos = endD + 1;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (s.charAt(lt + 1) === "?") {
|
|
523
|
+
var endP = s.indexOf("?>", lt + 2);
|
|
524
|
+
if (endP === -1) throw new Error("unterminated processing instruction"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
525
|
+
tokens.push({ type: "processingInstruction", raw: s.slice(lt, endP + 2) });
|
|
526
|
+
pos = endP + 2;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (s.charAt(lt + 1) === "!") {
|
|
530
|
+
var endDecl = s.indexOf(">", lt);
|
|
531
|
+
if (endDecl === -1) throw new Error("unterminated declaration"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
532
|
+
tokens.push({ type: "declaration", raw: s.slice(lt, endDecl + 1) });
|
|
533
|
+
pos = endDecl + 1;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (s.charAt(lt + 1) === "/") {
|
|
537
|
+
var endE = s.indexOf(">", lt);
|
|
538
|
+
if (endE === -1) throw new Error("unterminated end tag"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
539
|
+
var ename = s.slice(lt + 2, endE).trim().toLowerCase().split(/\s/)[0];
|
|
540
|
+
tokens.push({ type: "endTag", name: ename });
|
|
541
|
+
pos = endE + 1;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
var pp = lt + 1;
|
|
546
|
+
var inQuote = "";
|
|
547
|
+
while (pp < len) {
|
|
548
|
+
var ch = s.charAt(pp);
|
|
549
|
+
if (inQuote) {
|
|
550
|
+
if (ch === inQuote) inQuote = "";
|
|
551
|
+
} else {
|
|
552
|
+
if (ch === '"' || ch === "'") inQuote = ch;
|
|
553
|
+
else if (ch === ">") break;
|
|
554
|
+
}
|
|
555
|
+
pp += 1;
|
|
556
|
+
}
|
|
557
|
+
if (pp >= len) throw new Error("unterminated start tag"); // allow:bare-error-throw — caught by outer try/catch and re-thrown as MailBimiError("bimi/svg-tiny-ps-violation")
|
|
558
|
+
var raw = s.slice(lt, pp + 1);
|
|
559
|
+
var inner = raw.slice(1, raw.length - 1);
|
|
560
|
+
var selfClosing = inner.endsWith("/");
|
|
561
|
+
if (selfClosing) inner = inner.slice(0, inner.length - 1);
|
|
562
|
+
|
|
563
|
+
var nameMatch = inner.match(/^([A-Za-z][A-Za-z0-9:_-]*)/);
|
|
564
|
+
var tagName = nameMatch ? nameMatch[1].toLowerCase() : "";
|
|
565
|
+
var attrSrc = nameMatch ? inner.slice(nameMatch[0].length) : "";
|
|
566
|
+
|
|
567
|
+
tokens.push({
|
|
568
|
+
type: "tag",
|
|
569
|
+
name: tagName,
|
|
570
|
+
attrs: _parseTinyPsAttrs(attrSrc),
|
|
571
|
+
raw: raw,
|
|
572
|
+
selfClosing: selfClosing,
|
|
573
|
+
});
|
|
574
|
+
pos = pp + 1;
|
|
575
|
+
}
|
|
576
|
+
return tokens;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// _parseTinyPsAttrs - quoted-only attribute parser. Tiny-PS values are
|
|
580
|
+
// typically quoted in well-formed XML; bare-token / single-quoted
|
|
581
|
+
// values are still accepted (the SVG profile is permissive on quoting).
|
|
582
|
+
function _parseTinyPsAttrs(src) {
|
|
583
|
+
var attrs = {};
|
|
584
|
+
var re = /([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
585
|
+
var m;
|
|
586
|
+
while ((m = re.exec(src)) !== null) {
|
|
587
|
+
var name = m[1];
|
|
588
|
+
var value = m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : (m[5] || ""));
|
|
589
|
+
attrs[name] = value;
|
|
590
|
+
}
|
|
591
|
+
return attrs;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---- VMC / CMC fetch + chain validation ----
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @primitive b.mail.bimi.fetchAndVerifyMark
|
|
598
|
+
* @signature b.mail.bimi.fetchAndVerifyMark(opts)
|
|
599
|
+
* @since 0.8.53
|
|
600
|
+
* @status stable
|
|
601
|
+
* @related b.mail.bimi.fetchPolicy, b.mail.bimi.validateTinyPsSvg
|
|
602
|
+
*
|
|
603
|
+
* Fetches a VMC / CMC PEM from `opts.vmcUrl` (or `opts.cmcUrl`) over
|
|
604
|
+
* HTTPS, parses it as X.509, validates the chain against the BIMI
|
|
605
|
+
* Group trust anchors (vendored at lib/vendor/bimi-trust-anchors.pem,
|
|
606
|
+
* operator-overridable via `trustAnchorsPem`), confirms the cert's
|
|
607
|
+
* subjectAltName URI matches the BIMI domain, and confirms the cert
|
|
608
|
+
* carries the BIMI mark-verification ExtendedKeyUsage OID
|
|
609
|
+
* (1.3.6.1.5.5.7.3.31). Returns
|
|
610
|
+
* `{ ok, mark, certificate, vmcType }` where `vmcType` is `"vmc"`
|
|
611
|
+
* or `"cmc"` derived from the cert's policyOIDs, and `mark` carries
|
|
612
|
+
* the SVG bytes when the cert's RFC 3709 logotype extension is
|
|
613
|
+
* present (or null when not). Throws `MailBimiError` with one of
|
|
614
|
+
* the documented codes on any failure.
|
|
615
|
+
*
|
|
616
|
+
* @opts
|
|
617
|
+
* {
|
|
618
|
+
* domain: string, // required - BIMI domain to assert
|
|
619
|
+
* // matches subjectAltName URI
|
|
620
|
+
* vmcUrl: string?, // VMC PEM URL (https://); operator
|
|
621
|
+
* // passes one of vmcUrl / cmcUrl
|
|
622
|
+
* cmcUrl: string?, // CMC PEM URL (https://); same
|
|
623
|
+
* trustAnchorsPem: string?, // operator-supplied PEM bundle;
|
|
624
|
+
* // defaults to the vendored
|
|
625
|
+
* // bimi-trust-anchors.pem
|
|
626
|
+
* timeoutMs: number?, // default 15s
|
|
627
|
+
* maxResponseBytes: number?, // default 256 KiB
|
|
628
|
+
* audit: { safeEmit }, // operator-supplied audit dispatcher
|
|
629
|
+
* httpClient: object?, // default b.httpClient - test-only
|
|
630
|
+
* // override for unit tests that
|
|
631
|
+
* // want to stub the network call
|
|
632
|
+
* evidenceDocument: string?, // operator-supplied trademark
|
|
633
|
+
* // evidence URL; surfaced on
|
|
634
|
+
* // the result for audit logging
|
|
635
|
+
* }
|
|
636
|
+
*
|
|
637
|
+
* @example
|
|
638
|
+
* var rv = await b.mail.bimi.fetchAndVerifyMark({
|
|
639
|
+
* domain: "example.com",
|
|
640
|
+
* vmcUrl: "https://example.com/bimi/cert.pem",
|
|
641
|
+
* trustAnchorsPem: "-----BEGIN CERTIFICATE-----\n...",
|
|
642
|
+
* });
|
|
643
|
+
* // -> { ok, mark: { svg, evidenceDocument }, certificate, vmcType: "vmc" }
|
|
644
|
+
*/
|
|
645
|
+
async function fetchAndVerifyMark(opts) {
|
|
646
|
+
validateOpts.requireObject(opts, "bimi.fetchAndVerifyMark", MailBimiError, "bimi/bad-opts");
|
|
647
|
+
validateOpts(opts, [
|
|
648
|
+
"domain", "vmcUrl", "cmcUrl",
|
|
649
|
+
"trustAnchorsPem", "timeoutMs", "maxResponseBytes",
|
|
650
|
+
"audit", "httpClient", "evidenceDocument",
|
|
651
|
+
], "bimi.fetchAndVerifyMark");
|
|
652
|
+
validateOpts.requireNonEmptyString(opts.domain,
|
|
653
|
+
"bimi.fetchAndVerifyMark: domain", MailBimiError, "bimi/bad-opts");
|
|
654
|
+
|
|
655
|
+
var url = opts.vmcUrl || opts.cmcUrl;
|
|
656
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
657
|
+
throw new MailBimiError("bimi/bad-opts",
|
|
658
|
+
"bimi.fetchAndVerifyMark: one of vmcUrl / cmcUrl is required");
|
|
659
|
+
}
|
|
660
|
+
// RFC 9091 6 - cert URL MUST be https.
|
|
661
|
+
try { safeUrl.parse(url, { allowedProtocols: ["https:"] }); }
|
|
662
|
+
catch (e) {
|
|
663
|
+
throw new MailBimiError("bimi/bad-opts",
|
|
664
|
+
"bimi.fetchAndVerifyMark: cert URL must be https - got `" + url + "`: " +
|
|
665
|
+
((e && e.message) || String(e)));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
var timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : VMC_DEFAULT_TIMEOUT_MS;
|
|
669
|
+
var maxBytes = opts.maxResponseBytes !== undefined ? opts.maxResponseBytes : VMC_DEFAULT_MAX_BYTES;
|
|
670
|
+
|
|
671
|
+
var hc = opts.httpClient || httpClient;
|
|
672
|
+
|
|
673
|
+
var rsp;
|
|
674
|
+
try {
|
|
675
|
+
rsp = await hc.request({
|
|
676
|
+
method: "GET",
|
|
677
|
+
url: url,
|
|
678
|
+
timeoutMs: timeoutMs,
|
|
679
|
+
maxResponseBytes: maxBytes,
|
|
680
|
+
allowedProtocols: ["https:"],
|
|
681
|
+
headers: { "Accept": "application/x-pem-file, application/pem-certificate-chain, text/plain" },
|
|
682
|
+
errorClass: MailBimiError,
|
|
683
|
+
});
|
|
684
|
+
} catch (e) {
|
|
685
|
+
_emitAudit(opts, "mail.bimi.vmc.fetched", "failure",
|
|
686
|
+
{ url: url, domain: opts.domain, reason: (e && e.message) || String(e) });
|
|
687
|
+
throw new MailBimiError("bimi/vmc-fetch-failed",
|
|
688
|
+
"bimi.fetchAndVerifyMark: GET " + url + " failed: " + ((e && e.message) || String(e)));
|
|
689
|
+
}
|
|
690
|
+
if (rsp.statusCode !== 200) {
|
|
691
|
+
_emitAudit(opts, "mail.bimi.vmc.fetched", "failure",
|
|
692
|
+
{ url: url, domain: opts.domain, status: rsp.statusCode });
|
|
693
|
+
throw new MailBimiError("bimi/vmc-fetch-failed",
|
|
694
|
+
"bimi.fetchAndVerifyMark: GET " + url + " returned status " + rsp.statusCode);
|
|
695
|
+
}
|
|
696
|
+
var pemBytes = Buffer.isBuffer(rsp.body) ? rsp.body.toString("utf8") : String(rsp.body || "");
|
|
697
|
+
if (pemBytes.indexOf("-----BEGIN CERTIFICATE-----") === -1) {
|
|
698
|
+
_emitAudit(opts, "mail.bimi.vmc.fetched", "failure",
|
|
699
|
+
{ url: url, domain: opts.domain, reason: "no-pem" });
|
|
700
|
+
throw new MailBimiError("bimi/vmc-fetch-failed",
|
|
701
|
+
"bimi.fetchAndVerifyMark: response body is not a PEM-encoded CERTIFICATE chain");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
var certPems = _splitPemChain(pemBytes);
|
|
705
|
+
if (certPems.length === 0) {
|
|
706
|
+
throw new MailBimiError("bimi/vmc-fetch-failed",
|
|
707
|
+
"bimi.fetchAndVerifyMark: no CERTIFICATE blocks in PEM body");
|
|
708
|
+
}
|
|
709
|
+
var leaf;
|
|
710
|
+
var intermediates = [];
|
|
711
|
+
try {
|
|
712
|
+
leaf = new nodeCrypto.X509Certificate(certPems[0]);
|
|
713
|
+
for (var i = 1; i < certPems.length; i += 1) {
|
|
714
|
+
intermediates.push(new nodeCrypto.X509Certificate(certPems[i]));
|
|
715
|
+
}
|
|
716
|
+
} catch (e) {
|
|
717
|
+
throw new MailBimiError("bimi/vmc-chain-invalid",
|
|
718
|
+
"bimi.fetchAndVerifyMark: X.509 parse failed: " + ((e && e.message) || String(e)));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
var trustAnchorsPem = typeof opts.trustAnchorsPem === "string" && opts.trustAnchorsPem.length > 0
|
|
722
|
+
? opts.trustAnchorsPem
|
|
723
|
+
: _vendoredTrustAnchorsPem;
|
|
724
|
+
var anchorPems = _splitPemChain(trustAnchorsPem);
|
|
725
|
+
if (anchorPems.length === 0) {
|
|
726
|
+
throw new MailBimiError("bimi/vmc-chain-invalid",
|
|
727
|
+
"bimi.fetchAndVerifyMark: no trust anchors configured - populate " +
|
|
728
|
+
"lib/vendor/bimi-trust-anchors.pem or pass `trustAnchorsPem` " +
|
|
729
|
+
"(see RFC 9091 6 / BIMI Group VMC issuer list)");
|
|
730
|
+
}
|
|
731
|
+
var anchors;
|
|
732
|
+
try {
|
|
733
|
+
anchors = anchorPems.map(function (p) { return new nodeCrypto.X509Certificate(p); });
|
|
734
|
+
} catch (e) {
|
|
735
|
+
throw new MailBimiError("bimi/vmc-chain-invalid",
|
|
736
|
+
"bimi.fetchAndVerifyMark: trust-anchor PEM parse failed: " + ((e && e.message) || String(e)));
|
|
737
|
+
}
|
|
738
|
+
var chainOk = _verifyCertChain(leaf, intermediates, anchors);
|
|
739
|
+
if (!chainOk.ok) {
|
|
740
|
+
_emitAudit(opts, "mail.bimi.vmc.verified", "failure",
|
|
741
|
+
{ url: url, domain: opts.domain, reason: chainOk.reason });
|
|
742
|
+
throw new MailBimiError("bimi/vmc-chain-invalid",
|
|
743
|
+
"bimi.fetchAndVerifyMark: chain validation failed: " + chainOk.reason);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
var sanMatch = _subjectAltNameMatchesDomain(leaf, opts.domain);
|
|
747
|
+
if (!sanMatch.ok) {
|
|
748
|
+
_emitAudit(opts, "mail.bimi.vmc.verified", "failure",
|
|
749
|
+
{ url: url, domain: opts.domain, reason: "san-mismatch", san: sanMatch.found });
|
|
750
|
+
throw new MailBimiError("bimi/vmc-domain-mismatch",
|
|
751
|
+
"bimi.fetchAndVerifyMark: subjectAltName does not include BIMI domain `" +
|
|
752
|
+
opts.domain + "` - found: " + (sanMatch.found.length === 0 ? "(none)" : sanMatch.found.join(", ")));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
var policyInfo = _extractBimiCertPolicy(leaf);
|
|
756
|
+
if (!policyInfo.hasMarkVerificationEku) {
|
|
757
|
+
_emitAudit(opts, "mail.bimi.vmc.verified", "failure",
|
|
758
|
+
{ url: url, domain: opts.domain, reason: "missing-eku" });
|
|
759
|
+
throw new MailBimiError("bimi/vmc-policy-oid-missing",
|
|
760
|
+
"bimi.fetchAndVerifyMark: certificate is missing the BIMI mark-verification " +
|
|
761
|
+
"ExtendedKeyUsage OID (" + BIMI_EKU_MARK_VERIFICATION + ") - RFC 9091 6.1.1");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
var vmcType = "vmc";
|
|
765
|
+
if (policyInfo.policyOids.indexOf(CMC_POLICY_OID) !== -1 &&
|
|
766
|
+
policyInfo.policyOids.indexOf(VMC_POLICY_OID) === -1) {
|
|
767
|
+
vmcType = "cmc";
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
var mark = {
|
|
771
|
+
svg: policyInfo.logoSvg,
|
|
772
|
+
evidenceDocument: typeof opts.evidenceDocument === "string" ? opts.evidenceDocument : null,
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
_emitAudit(opts, "mail.bimi.vmc.verified", "success", {
|
|
776
|
+
url: url,
|
|
777
|
+
domain: opts.domain,
|
|
778
|
+
vmcType: vmcType,
|
|
779
|
+
issuer: leaf.issuer,
|
|
780
|
+
subject: leaf.subject,
|
|
781
|
+
notAfter: leaf.validTo,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
return {
|
|
785
|
+
ok: true,
|
|
786
|
+
mark: mark,
|
|
787
|
+
certificate: {
|
|
788
|
+
issuer: leaf.issuer,
|
|
789
|
+
subject: leaf.subject,
|
|
790
|
+
notAfter: leaf.validTo,
|
|
791
|
+
notBefore: leaf.validFrom,
|
|
792
|
+
policyOids: policyInfo.policyOids.slice(),
|
|
793
|
+
},
|
|
794
|
+
vmcType: vmcType,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---- helpers (chain validation, PEM parsing, ASN.1 OID walks) ----
|
|
799
|
+
|
|
800
|
+
function _splitPemChain(pemText) {
|
|
801
|
+
if (typeof pemText !== "string") return [];
|
|
802
|
+
var out = [];
|
|
803
|
+
var re = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
|
804
|
+
var m;
|
|
805
|
+
while ((m = re.exec(pemText)) !== null) out.push(m[0]);
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// _verifyCertChain - best-effort path validation using node:crypto
|
|
810
|
+
// X509Certificate.verify(publicKey) for signature verification, plus
|
|
811
|
+
// checkIssued() for issuer-DN matching and notBefore / notAfter for
|
|
812
|
+
// validity windows.
|
|
813
|
+
function _verifyCertChain(leaf, intermediates, anchors) {
|
|
814
|
+
var now = Date.now();
|
|
815
|
+
var current = leaf;
|
|
816
|
+
var depth = 0;
|
|
817
|
+
// Realistic VMC chains are leaf -> intermediate -> root (depth 2).
|
|
818
|
+
// 8 is a generous upper bound that prevents pathological loops.
|
|
819
|
+
var MAX_DEPTH = 8;
|
|
820
|
+
|
|
821
|
+
while (depth < MAX_DEPTH) {
|
|
822
|
+
var notBefore = Date.parse(current.validFrom);
|
|
823
|
+
var notAfter = Date.parse(current.validTo);
|
|
824
|
+
if (isFinite(notBefore) && now < notBefore) {
|
|
825
|
+
return { ok: false, reason: "cert not-yet-valid (notBefore=" + current.validFrom + ")" };
|
|
826
|
+
}
|
|
827
|
+
if (isFinite(notAfter) && now > notAfter) {
|
|
828
|
+
return { ok: false, reason: "cert expired (notAfter=" + current.validTo + ")" };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (var ai = 0; ai < anchors.length; ai += 1) {
|
|
832
|
+
var anchor = anchors[ai];
|
|
833
|
+
if (current.checkIssued(anchor)) {
|
|
834
|
+
try {
|
|
835
|
+
if (current.verify(anchor.publicKey)) return { ok: true };
|
|
836
|
+
} catch (_e) { /* fall through to next anchor */ }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (current.checkIssued(current)) {
|
|
840
|
+
return { ok: false, reason: "self-signed root not in trust-anchor bundle" };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
var nextIssuer = null;
|
|
844
|
+
for (var ii = 0; ii < intermediates.length; ii += 1) {
|
|
845
|
+
var cand = intermediates[ii];
|
|
846
|
+
if (cand === current) continue;
|
|
847
|
+
if (current.checkIssued(cand)) {
|
|
848
|
+
try {
|
|
849
|
+
if (current.verify(cand.publicKey)) {
|
|
850
|
+
nextIssuer = cand;
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
} catch (_e) { /* fall through */ }
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (nextIssuer === null) {
|
|
857
|
+
return { ok: false, reason: "no issuer found for `" + current.subject + "` in chain or trust anchors" };
|
|
858
|
+
}
|
|
859
|
+
current = nextIssuer;
|
|
860
|
+
depth += 1;
|
|
861
|
+
}
|
|
862
|
+
return { ok: false, reason: "chain depth exceeded " + MAX_DEPTH };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// _subjectAltNameMatchesDomain - RFC 9091 6 mandates a URI-form SAN
|
|
866
|
+
// pointing at the BIMI domain. Node's X509Certificate.subjectAltName
|
|
867
|
+
// is a comma-separated string like "URI:https://example.com, DNS:example.com";
|
|
868
|
+
// accept either a URI:* matching the domain's hostname OR a DNS:*
|
|
869
|
+
// exact match (compat - some VMC profiles emit DNS instead of URI).
|
|
870
|
+
function _subjectAltNameMatchesDomain(cert, domain) {
|
|
871
|
+
var raw = cert.subjectAltName || "";
|
|
872
|
+
var parts = raw.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
873
|
+
var found = parts.slice();
|
|
874
|
+
var dom = domain.toLowerCase();
|
|
875
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
876
|
+
var p = parts[i];
|
|
877
|
+
var lp = p.toLowerCase();
|
|
878
|
+
if (lp.indexOf("dns:") === 0) {
|
|
879
|
+
var dns2 = lp.slice(4);
|
|
880
|
+
if (dns2 === dom) return { ok: true, found: found };
|
|
881
|
+
}
|
|
882
|
+
if (lp.indexOf("uri:") === 0) {
|
|
883
|
+
var uri = p.slice(4);
|
|
884
|
+
try {
|
|
885
|
+
var u = safeUrl.parse(uri, { allowedProtocols: ["https:", "http:"] });
|
|
886
|
+
if ((u.hostname || "").toLowerCase() === dom) return { ok: true, found: found };
|
|
887
|
+
} catch (_e) {
|
|
888
|
+
if (lp.indexOf(dom) !== -1) return { ok: true, found: found };
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return { ok: false, found: found };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// _extractBimiCertPolicy - walks the X.509 raw DER to find:
|
|
896
|
+
// - extKeyUsage (id-ce-extKeyUsage 2.5.29.37) - confirms BIMI EKU OID
|
|
897
|
+
// - certificatePolicies (id-ce-certificatePolicies 2.5.29.32) - list
|
|
898
|
+
// - id-pe-logotype (1.3.6.1.5.5.7.1.12) - RFC 3709 SVG payload
|
|
899
|
+
function _extractBimiCertPolicy(cert) {
|
|
900
|
+
var rv = { hasMarkVerificationEku: false, policyOids: [], logoSvg: null };
|
|
901
|
+
var rawDer = cert.raw;
|
|
902
|
+
if (!rawDer || rawDer.length === 0) return rv;
|
|
903
|
+
|
|
904
|
+
var outer;
|
|
905
|
+
try { outer = asn1.readNode(rawDer, 0); }
|
|
906
|
+
catch (_e) { return rv; }
|
|
907
|
+
if (!outer || !outer.constructed) return rv;
|
|
908
|
+
var topChildren;
|
|
909
|
+
try { topChildren = asn1.readSequence(outer.value); }
|
|
910
|
+
catch (_e) { return rv; }
|
|
911
|
+
if (!topChildren || topChildren.length < 1) return rv;
|
|
912
|
+
var tbs = topChildren[0];
|
|
913
|
+
if (!tbs || !tbs.constructed) return rv;
|
|
914
|
+
var tbsChildren;
|
|
915
|
+
try { tbsChildren = asn1.readSequence(tbs.value); }
|
|
916
|
+
catch (_e) { return rv; }
|
|
917
|
+
// tbsCertificate has extensions [3] EXPLICIT - find the
|
|
918
|
+
// context-specific [3] tag (tagClass=2, tag=3).
|
|
919
|
+
var extsNode = null;
|
|
920
|
+
for (var ti = 0; ti < tbsChildren.length; ti += 1) {
|
|
921
|
+
var n = tbsChildren[ti];
|
|
922
|
+
if (n.tagClass === 2 && n.tag === 3) { extsNode = n; break; }
|
|
923
|
+
}
|
|
924
|
+
if (!extsNode) return rv;
|
|
925
|
+
var seqNode;
|
|
926
|
+
try { seqNode = asn1.readNode(extsNode.value, 0); }
|
|
927
|
+
catch (_e) { return rv; }
|
|
928
|
+
if (!seqNode || !seqNode.constructed) return rv;
|
|
929
|
+
var extList;
|
|
930
|
+
try { extList = asn1.readSequence(seqNode.value); }
|
|
931
|
+
catch (_e) { return rv; }
|
|
932
|
+
for (var ei = 0; ei < extList.length; ei += 1) {
|
|
933
|
+
var ext = extList[ei];
|
|
934
|
+
if (!ext.constructed) continue;
|
|
935
|
+
var extChildren;
|
|
936
|
+
try { extChildren = asn1.readSequence(ext.value); }
|
|
937
|
+
catch (_e) { continue; }
|
|
938
|
+
if (!extChildren || extChildren.length < 2) continue;
|
|
939
|
+
var oid;
|
|
940
|
+
try { oid = asn1.readOid(extChildren[0]); }
|
|
941
|
+
catch (_e) { continue; }
|
|
942
|
+
var octet = extChildren[extChildren.length - 1];
|
|
943
|
+
var inner;
|
|
944
|
+
try { inner = asn1.readNode(octet.value, 0); }
|
|
945
|
+
catch (_e) { continue; }
|
|
946
|
+
|
|
947
|
+
if (oid === "2.5.29.37") {
|
|
948
|
+
// ExtendedKeyUsage ::= SEQUENCE OF KeyPurposeId (KeyPurposeId ::= OBJECT IDENTIFIER)
|
|
949
|
+
if (!inner || !inner.constructed) continue;
|
|
950
|
+
var ekuList;
|
|
951
|
+
try { ekuList = asn1.readSequence(inner.value); }
|
|
952
|
+
catch (_e) { continue; }
|
|
953
|
+
for (var ek = 0; ek < ekuList.length; ek += 1) {
|
|
954
|
+
var ekuOid;
|
|
955
|
+
try { ekuOid = asn1.readOid(ekuList[ek]); }
|
|
956
|
+
catch (_e) { continue; }
|
|
957
|
+
if (ekuOid === BIMI_EKU_MARK_VERIFICATION) rv.hasMarkVerificationEku = true;
|
|
958
|
+
}
|
|
959
|
+
} else if (oid === "2.5.29.32") {
|
|
960
|
+
// certificatePolicies ::= SEQUENCE OF PolicyInformation
|
|
961
|
+
// PolicyInformation ::= SEQUENCE { policyIdentifier OID, ... }
|
|
962
|
+
if (!inner || !inner.constructed) continue;
|
|
963
|
+
var polList;
|
|
964
|
+
try { polList = asn1.readSequence(inner.value); }
|
|
965
|
+
catch (_e) { continue; }
|
|
966
|
+
for (var pi = 0; pi < polList.length; pi += 1) {
|
|
967
|
+
var polItem = polList[pi];
|
|
968
|
+
if (!polItem.constructed) continue;
|
|
969
|
+
var polChildren;
|
|
970
|
+
try { polChildren = asn1.readSequence(polItem.value); }
|
|
971
|
+
catch (_e) { continue; }
|
|
972
|
+
if (polChildren.length === 0) continue;
|
|
973
|
+
try {
|
|
974
|
+
var polOid = asn1.readOid(polChildren[0]);
|
|
975
|
+
if (polOid) rv.policyOids.push(polOid);
|
|
976
|
+
} catch (_e) { /* skip */ }
|
|
977
|
+
}
|
|
978
|
+
} else if (oid === ID_PE_LOGOTYPE) {
|
|
979
|
+
// RFC 3709 4.1 - LogotypeExtn carries SubjectLogo (best-effort
|
|
980
|
+
// SVG extraction; full RFC 3709 unpack requires walking nested
|
|
981
|
+
// SEQUENCEs to LogotypeImageData).
|
|
982
|
+
var found = _scanForEmbeddedSvg(inner, 8); /* allow:raw-byte-literal — string-prefix length for magic-bytes match, not bytes */
|
|
983
|
+
if (found) rv.logoSvg = found;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return rv;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function _scanForEmbeddedSvg(node, depthBudget) {
|
|
990
|
+
if (!node) return null;
|
|
991
|
+
if (depthBudget < 0) return null;
|
|
992
|
+
|
|
993
|
+
if (!node.constructed) {
|
|
994
|
+
if (!node.value || node.value.length < 4) return null;
|
|
995
|
+
var prefix = node.value.slice(0, Math.min(node.value.length, 64)).toString("utf8"); /* allow:raw-byte-literal — display truncation length, not bytes */
|
|
996
|
+
if (prefix.indexOf("<svg") !== -1 || /<\?xml[\s\S]*<svg/.test(prefix)) {
|
|
997
|
+
return node.value.toString("utf8");
|
|
998
|
+
}
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
var children;
|
|
1003
|
+
try { children = asn1.readSequence(node.value); }
|
|
1004
|
+
catch (_e) {
|
|
1005
|
+
try {
|
|
1006
|
+
var sub = asn1.readNode(node.value, 0);
|
|
1007
|
+
return _scanForEmbeddedSvg(sub, depthBudget - 1);
|
|
1008
|
+
} catch (_ee) { return null; }
|
|
1009
|
+
}
|
|
1010
|
+
for (var i = 0; i < children.length; i += 1) {
|
|
1011
|
+
var f = _scanForEmbeddedSvg(children[i], depthBudget - 1);
|
|
1012
|
+
if (f) return f;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function _emitAudit(opts, action, outcome, metadata) {
|
|
1018
|
+
var sink = opts && opts.audit;
|
|
1019
|
+
try {
|
|
1020
|
+
if (sink && typeof sink.safeEmit === "function") {
|
|
1021
|
+
sink.safeEmit({ action: action, outcome: outcome, metadata: metadata });
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
var defaultSink = audit();
|
|
1025
|
+
if (defaultSink && typeof defaultSink.safeEmit === "function") {
|
|
1026
|
+
defaultSink.safeEmit({ action: action, outcome: outcome, metadata: metadata });
|
|
1027
|
+
}
|
|
1028
|
+
} catch (_e) {
|
|
1029
|
+
// drop-silent - by design. Audit failure must not break the
|
|
1030
|
+
// BIMI-verify hot path; observability counter takes care of the
|
|
1031
|
+
// signal upstream.
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
127
1035
|
module.exports = {
|
|
128
|
-
recordShape:
|
|
129
|
-
parseRecord:
|
|
130
|
-
fetchPolicy:
|
|
131
|
-
|
|
132
|
-
|
|
1036
|
+
recordShape: recordShape,
|
|
1037
|
+
parseRecord: parseRecord,
|
|
1038
|
+
fetchPolicy: fetchPolicy,
|
|
1039
|
+
fetchAndVerifyMark: fetchAndVerifyMark,
|
|
1040
|
+
validateTinyPsSvg: validateTinyPsSvg,
|
|
1041
|
+
BIMI_VERSION: BIMI_VERSION,
|
|
1042
|
+
BIMI_EKU_MARK_VERIFICATION: BIMI_EKU_MARK_VERIFICATION,
|
|
1043
|
+
VMC_POLICY_OID: VMC_POLICY_OID,
|
|
1044
|
+
CMC_POLICY_OID: CMC_POLICY_OID,
|
|
1045
|
+
TINY_PS_MAX_BYTES: TINY_PS_MAX_BYTES,
|
|
1046
|
+
BimiError: BimiError,
|
|
1047
|
+
MailBimiError: MailBimiError,
|
|
133
1048
|
};
|