@blamejs/core 0.8.51 → 0.8.57

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 (42) 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/framework-error.js +55 -0
  9. package/lib/guard-cidr.js +2 -1
  10. package/lib/guard-jwt.js +2 -2
  11. package/lib/guard-oauth.js +2 -2
  12. package/lib/http-client-cache.js +916 -0
  13. package/lib/http-client.js +242 -0
  14. package/lib/local-db-thin.js +8 -7
  15. package/lib/mail-arf.js +343 -0
  16. package/lib/mail-auth.js +265 -40
  17. package/lib/mail-bimi.js +948 -33
  18. package/lib/mail-bounce.js +386 -4
  19. package/lib/mail-mdn.js +424 -0
  20. package/lib/mail-unsubscribe.js +265 -25
  21. package/lib/mail.js +403 -21
  22. package/lib/middleware/bearer-auth.js +1 -1
  23. package/lib/middleware/clear-site-data.js +122 -0
  24. package/lib/middleware/dpop.js +1 -1
  25. package/lib/middleware/index.js +9 -0
  26. package/lib/middleware/nel.js +214 -0
  27. package/lib/middleware/security-headers.js +56 -4
  28. package/lib/middleware/speculation-rules.js +323 -0
  29. package/lib/mime-parse.js +198 -0
  30. package/lib/network-dns.js +890 -27
  31. package/lib/network-tls.js +745 -0
  32. package/lib/object-store/sigv4.js +54 -0
  33. package/lib/public-suffix.js +414 -0
  34. package/lib/safe-buffer.js +7 -0
  35. package/lib/safe-json.js +1 -1
  36. package/lib/static.js +120 -0
  37. package/lib/storage.js +11 -0
  38. package/lib/vendor/MANIFEST.json +33 -0
  39. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  40. package/lib/vendor/public-suffix-list.dat +16376 -0
  41. package/package.json +1 -1
  42. package/sbom.cyclonedx.json +6 -6
package/lib/mail-bimi.js CHANGED
@@ -1,56 +1,169 @@
1
1
  "use strict";
2
2
  /**
3
- * BIMI — Brand Indicators for Message Identification (RFC 9091).
3
+ * @module b.mail.bimi
4
+ * @nav Mail
5
+ * @title BIMI
4
6
  *
5
- * BIMI records publish a sender's brand logo URL in DNS so receiving
6
- * MTAs can render it next to the message in supported clients
7
- * (Gmail, Yahoo, Apple Mail). The record format is:
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
- * default._bimi.<domain> IN TXT "v=BIMI1; l=https://...; a=https://..."
13
+ * default._bimi.<domain> IN TXT "v=BIMI1; l=https://...; a=https://..."
10
14
  *
11
- * - `l=` URL to the SVG logo file (Tiny PS Profile per RFC 9091 §5)
12
- * - `a=` URL to the Verified Mark Certificate (VMC) — RFC 9091 §6
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
- * BIMI is layered on a passing DMARC posture (the receiver requires
15
- * DMARC to be at quarantine or reject). No-op for senders without
16
- * DMARC enforcement.
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
- * Surface:
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
- * The framework does NOT validate the SVG / VMC contents against the
24
- * RFC 9091 §5/§6 profiles — operators feed those to their own asset
25
- * pipeline. The fetch primitive is a thin DNS lookup that returns
26
- * the structured record so an operator dashboard or SMTP send-time
27
- * preflight can verify the publication.
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 validateOpts = require("./validate-opts");
33
- var safeUrl = require("./safe-url");
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 { defineClass } = require("./framework-error");
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 §4.2 — `l=` and `a=` MUST be HTTPS URLs.
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 got '" + 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 defense-in-depth so a hostile URL can't
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; // bound BEFORE parse — TXT-record sanity cap
84
- // RFC 9091 §4 semicolon-separated, key=value, leading "v=BIMI1".
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 §4.1 a TXT lookup may return multiple chunks; pick
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: recordShape,
129
- parseRecord: parseRecord,
130
- fetchPolicy: fetchPolicy,
131
- BIMI_VERSION: BIMI_VERSION,
132
- BimiError: BimiError,
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
  };