diff --git a/.env.example b/.env.example index 2ef7d1cb8..d30fa4c61 100644 --- a/.env.example +++ b/.env.example @@ -489,6 +489,17 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org # LIBRETRANSLATE_URL=https://libretranslate.example.com # LIBRETRANSLATE_API_KEY= +# ============================================================================= +# Brand Logos (BIMI) +# ============================================================================= + +# Senders that publish a BIMI record and enforce DMARC get their logo as the +# sender icon, looked up by this server (/api/bimi). By default a logo shows +# when only the DNS record backs it, as Yahoo does. Set to true to show only +# logos backed by a Verified Mark or Common Mark Certificate, as Gmail and +# Apple Mail do. Users turn logos off with the sender icons setting. +# BIMI_REQUIRE_CERTIFICATE=true + # ============================================================================= # Web Push # ============================================================================= diff --git a/app/(main)/admin/_pages/settings.tsx b/app/(main)/admin/_pages/settings.tsx index 8deaacaae..901056059 100644 --- a/app/(main)/admin/_pages/settings.tsx +++ b/app/(main)/admin/_pages/settings.tsx @@ -176,6 +176,10 @@ export function SettingsPage() { + + + + diff --git a/app/api/bimi/route.ts b/app/api/bimi/route.ts new file mode 100644 index 000000000..9e162cacb --- /dev/null +++ b/app/api/bimi/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; +import { bimiResolver, normalizeBimiDomain, type BimiIndicator } from '@/lib/bimi/lookup'; +import type { BimiInfo } from '@/lib/bimi/types'; + +/** + * BIMI sender logos. GET ?domain= answers the logo as a PNG, or + * the same 1x1 transparent PNG /api/favicon sends when there is none (the + * avatar falls through on naturalWidth <= 1). With &format=json it answers + * what backs the logo, for the verified badge in the message view. + * + * Ask only for messages that passed DMARC for that domain (see + * brandIndicatorDomain in lib/email-headers.ts): the logo says who the domain + * is, not that a given message came from it. + */ + +const TRANSPARENT_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=', + 'base64', +); + +const IMAGE_HEADERS = { + 'Content-Type': 'image/png', + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", +}; + +// Short enough that switching "Require a mark certificate" reaches browsers +// the same day. +const LOGO_MAX_AGE = 6 * 60 * 60; +const MISSING_MAX_AGE = 60 * 60; + +async function requireCertificate(): Promise { + await configManager.ensureLoaded(); + return configManager.get('bimiRequireCertificate', false) === true; +} + +function info(indicator: BimiIndicator | null): BimiInfo { + if (!indicator) return { status: 'none' }; + const { status, markType, organization, issuer } = indicator; + return { status, markType, organization, issuer }; +} + +export async function GET(request: NextRequest) { + // The lookups reach the sender's DNS and whatever hosts its record names. + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Brand logos'); + + const domain = normalizeBimiDomain(request.nextUrl.searchParams.get('domain')); + const asJson = request.nextUrl.searchParams.get('format') === 'json'; + if (!domain) { + return new NextResponse(null, { status: 400, headers: { 'Cache-Control': 'public, max-age=86400' } }); + } + + let indicator = await bimiResolver().resolve(domain); + if (indicator && indicator.status !== 'verified' && await requireCertificate()) indicator = null; + + if (asJson) { + return NextResponse.json(info(indicator), { + headers: { 'Cache-Control': `public, max-age=${indicator ? LOGO_MAX_AGE : MISSING_MAX_AGE}` }, + }); + } + if (!indicator) { + return new NextResponse(new Uint8Array(TRANSPARENT_PNG), { + headers: { ...IMAGE_HEADERS, 'Cache-Control': `public, max-age=${MISSING_MAX_AGE}`, 'X-Bulwark-Bimi': 'missing' }, + }); + } + return new NextResponse(new Uint8Array(indicator.png), { + headers: { ...IMAGE_HEADERS, 'Cache-Control': `public, max-age=${LOGO_MAX_AGE}`, 'X-Bulwark-Bimi': indicator.status }, + }); +} diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 311757fbe..2e1ec2299 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; +import { getRootDomain } from '@/lib/root-domain'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; @@ -59,372 +60,6 @@ function isValidDomain(domain: string): boolean { return true; } -// Known multi-part TLDs where the registrable domain includes one extra label. -const MULTI_PART_TLDS = new Set([ - // .ac - "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac", - // .ae - "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae", - // .af - "com.af", "edu.af", "gov.af", "net.af", "org.af", - // .al - "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al", - // .ao - "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao", - // .ar - "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar", - // .at - "ac.at", "co.at", "gv.at", "or.at", - // .au - "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au", - // .ba - "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba", - "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba", - // .bb - "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb", - "store.bb", "tv.bb", - // .bh - "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh", - // .bn - "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn", - // .bo - "com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo", - // .br - "adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br", - "bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br", - "ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br", - "fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br", - "ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br", - "mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br", - "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", - "trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br", - // .bs - "com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs", - // .bz - "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", - // .ca - "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", - "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", - // .ck - "biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck", - // .cn - "ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn", - "gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", - "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn", - "nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", - "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", - // .co - "com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co", - // .cr - "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr", - // .cy - "ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy", - "net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy", - // .do - "art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do", - "sld.do", "web.do", - // .dz - "art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz", - // .ec - "com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec", - "org.ec", "pro.ec", - // .eg - "com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg", - // .er - "com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er", - // .es - "com.es", "edu.es", "gob.es", "nom.es", "org.es", - // .et - "biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et", - // .fj - "ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj", - // .fk - "ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk", - // .fr - "asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr", - // .gg - "co.gg", "net.gg", "org.gg", - // .gh - "com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh", - // .gn - "ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn", - // .gr - "com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr", - // .gt - "com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt", - // .gu - "com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu", - // .hk - "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk", - // .id - "ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id", - // .il - "ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il", - // .in - "4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in", - "ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in", - // .iq - "com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq", - // .ir - "ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir", - // .it - "edu.it", "gov.it", - // .je - "co.je", "net.je", "org.je", - // .jo - "com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo", - // .jp - "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp", - // .ke - "ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke", - // .kh - "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh", - // .ki - "biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki", - "org.ki", "tel.ki", - // .km - "asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km", - "nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km", - // .kn - "edu.kn", "gov.kn", "net.kn", "org.kn", - // .kr - "ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr", - "daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr", - "gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr", - "jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", - "sc.kr", "seoul.kr", "ulsan.kr", - // .kw - "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw", - // .ky - "com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky", - // .kz - "com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz", - // .lb - "com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb", - // .lk - "assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk", - "net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk", - // .lr - "com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr", - // .lv - "asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv", - // .ly - "com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly", - // .ma - "ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma", - // .mc - "asso.mc", "tm.mc", - // .me - "ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me", - // .mg - "com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg", - // .mk - "com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk", - // .ml - "com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml", - // .mn - "edu.mn", "gov.mn", "org.mn", - // .mo - "com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo", - // .mt - "com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt", - // .mu - "ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu", - // .mv - "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv", - "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv", - // .mw - "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw", - "museum.mw", "net.mw", "org.mw", - // .mx - "com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx", - // .my - "com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my", - // .mz - "ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz", - // .na - "co.na", "com.na", - // .nf - "arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf", - "rec.nf", "store.nf", "web.nf", - // .ng - "biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng", - "net.ng", "org.ng", "sch.ng", - // .ni - "ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni", - // .np - "com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np", - // .nr - "biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr", - // .nz - "ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz", - "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz", - // .om - "ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om", - "museum.om", "net.om", "org.om", "pro.om", "sch.om", - // .pa - "abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa", - "nom.pa", "org.pa", "sld.pa", - // .pe - "com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe", - // .ph - "com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph", - // .pk - "biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk", - "gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk", - // .pl - "art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl", - "gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl", - "lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl", - "pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl", - "waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl", - // .pr - "ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr", - "name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr", - // .ps - "com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps", - // .pt - "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt", - // .pw - "belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw", - // .py - "com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py", - // .qa - "com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa", - // .re - "asso.re", "com.re", "nom.re", - // .ro - "arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro", - "rec.ro", "store.ro", "tm.ro", "www.ro", - // .rs - "ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs", - // .ru - "ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru", - "bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru", - "chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru", - "dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru", - "irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru", - "kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru", - "kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru", - "koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru", - "kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru", - "mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru", - "net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru", - "orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru", - "ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru", - "simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru", - "tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru", - "tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru", - "ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru", - "vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru", - "yekaterinburg.ru", "yuzhno-sakhalinsk.ru", - // .rw - "ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw", - // .sa - "com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa", - // .sb - "com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb", - // .sc - "com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc", - // .sd - "com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd", - // .se - "a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se", - "h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se", - "parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se", - "w.se", "x.se", "y.se", "z.se", - // .sg - "com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg", - // .sh - "co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh", - // .sl - "com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl", - // .sn - "art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn", - // .st - "co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st", - "net.st", "org.st", "principe.st", "saotome.st", "store.st", - // .sv - "com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv", - // .sy - "com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy", - // .sz - "ac.sz", "co.sz", "org.sz", - // .th - "ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th", - // .tj - "ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj", - "int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj", - // .tn - "agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn", - "ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn", - "perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn", - // .tr - "av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr", - "gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr", - "tel.tr", "tsk.tr", "tv.tr", "web.tr", - // .tt - "aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt", - "info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt", - "net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt", - // .tw - "club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw", - "mil.tw", "net.tw", "org.tw", - // .tz - "ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz", - // .ua - "biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua", - "co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua", - "donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua", - "ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua", - "kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua", - "lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua", - "nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua", - "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua", - "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua", - "zp.ua", "zt.ua", - // .ug - "ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug", - // .uk - "ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk", - "icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk", - "national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk", - "nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk", - "sch.uk", "scot.uk", "soc.uk", - // .us - "4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us", - // .uy - "com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy", - // .ve - "co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve", - // .vi - "co.vi", "com.vi", "k12.vi", "net.vi", "org.vi", - // .vn - "ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn", - "int.vn", "name.vn", "net.vn", "org.vn", "pro.vn", - // .ye - "co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye", - // .yu - "ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu", - // .za - "ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za", - "db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za", - "landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za", - "olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za", - // .zm - "ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm", -]); - -function getRootDomain(domain: string): string { - const parts = domain.split("."); - if (parts.length <= 2) return domain; - const lastTwo = parts.slice(-2).join("."); - if (MULTI_PART_TLDS.has(lastTwo)) { - return parts.length >= 3 ? parts.slice(-3).join(".") : domain; - } - return parts.slice(-2).join("."); -} - function evictOldest() { if (cache.size < CACHE_MAX_SIZE) return; // Evict the oldest entry diff --git a/components/email/brand-verified-badge.tsx b/components/email/brand-verified-badge.tsx new file mode 100644 index 000000000..139300bc6 --- /dev/null +++ b/components/email/brand-verified-badge.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { BadgeCheck } from '@/components/icons'; +import { cn } from '@/lib/utils'; +import { IS_LITE } from '@/lib/lite'; +import { withBasePath } from '@/lib/browser-navigation'; +import { senderFaviconsAllowed } from '@/lib/outgoing-hosts'; +import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { BimiInfo } from '@/lib/bimi/types'; + +// One request per domain and tab; the answer is the same for every message. +const infoByDomain = new Map>(); + +function fetchInfo(domain: string): Promise { + let pending = infoByDomain.get(domain); + if (!pending) { + pending = fetch(withBasePath(`/api/bimi?domain=${encodeURIComponent(domain)}&format=json`)) + .then((response) => (response.ok ? (response.json() as Promise) : { status: 'none' as const })) + .catch(() => { + infoByDomain.delete(domain); + return { status: 'none' as const }; + }); + infoByDomain.set(domain, pending); + } + return pending; +} + +/** + * A check mark beside the sender's name when a Verified Mark or Common Mark + * Certificate backs the domain's BIMI logo. A logo the sender only published + * in DNS gets none: anyone can do that for a lookalike domain. + * + * `domain` comes from brandIndicatorDomain (the message passed DMARC for it). + * A tap opens a line naming who the logo is verified for and by whom. + */ +export function BrandVerifiedBadge({ domain, className }: { domain?: string; className?: string }) { + const t = useTranslations('email_viewer.brand_logo'); + const senderFavicons = useSettingsStore((s) => s.senderFavicons); + useNoThirdPartyTraffic(); + const enabled = !!domain && !IS_LITE && senderFavicons && senderFaviconsAllowed(); + const [info, setInfo] = useState<{ domain: string; info: BimiInfo } | null>(null); + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + const popoverId = useId(); + + useEffect(() => { + if (!enabled || !domain) return; + let cancelled = false; + void fetchInfo(domain).then((result) => { + if (!cancelled) setInfo({ domain, info: result }); + }); + return () => { cancelled = true; }; + }, [enabled, domain]); + + useEffect(() => { + if (!open) return; + const close = (e: Event) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('pointerdown', close); + document.addEventListener('focusin', close); + return () => { + document.removeEventListener('pointerdown', close); + document.removeEventListener('focusin', close); + }; + }, [open]); + + const current = enabled && info?.domain === domain ? info.info : null; + if (!current || current.status !== 'verified') return null; + + const organization = current.organization || domain!; + const description = current.issuer + ? t('verified_by', { organization, issuer: current.issuer }) + : t('verified', { organization }); + + return ( + + + {open && ( + + {description} + + )} + + ); +} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9e66a84ba..cc7d6537b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -48,7 +48,7 @@ import { useMeasuredTagDisplay } from "@/hooks/use-tag-display"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; import { useAccountTags, useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; import { getEmailTagIds, emailKeyFor } from "@/lib/thread-utils"; -import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers"; +import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndicatorDomain } from "@/lib/email-headers"; import { emailToReadView } from "@/lib/plugin-projection"; import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { ViewSourceDialog } from "./view-source-dialog"; @@ -170,6 +170,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; import { useSenderWarning } from "@/hooks/use-sender-warning"; +import { BrandVerifiedBadge } from "@/components/email/brand-verified-badge"; /** The More menu's two drill-downs: a folder list and a tag list. */ type MoreMenuSub = MessageSubmenuKey; @@ -751,7 +752,7 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema data-email-id={email.id} className={cn("w-full flex items-start py-3 border-t border-border text-start hover:bg-muted/40 transition-colors", compact ? "gap-3 px-4" : "gap-4 px-6")} > - + @@ -875,7 +876,7 @@ export function MvExpandedConversationCard({ data-mv-conversation-header="" className={cn("flex items-start pt-3 pb-2 cursor-pointer", compact ? "gap-3 px-4" : "gap-4 px-6")} > - + {sender?.name || sender?.email || ''} @@ -888,6 +889,7 @@ export function MvExpandedConversationCard({ {!compact && sender?.name && sender.email && ( <{sender.email}> )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -4930,6 +4932,7 @@ export function EmailViewer({ size="lg" className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all" senderWarning={senderVerification ? senderVerificationLabel : undefined} + brandDomain={brandIndicatorDomain(email)} /> @@ -4952,6 +4955,7 @@ export function EmailViewer({ {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( @@ -5239,6 +5243,7 @@ export function EmailViewer({ size="lg" className="shadow-sm w-10 h-10 group-hover:ring-2 group-hover:ring-primary/30 transition-all" senderWarning={senderVerification ? senderVerificationLabel : undefined} + brandDomain={brandIndicatorDomain(email)} />
@@ -5254,6 +5259,7 @@ export function EmailViewer({ ) : ( {t('unknown_sender')} )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( @@ -5447,6 +5453,7 @@ export function EmailViewer({ onViewContact={handleViewContactSidebar} className="text-sm text-start" /> + {senderVerification && ( )}
diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 4bae67c11..89793f0e0 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -8,6 +8,7 @@ import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; import { useSenderWarning } from "@/hooks/use-sender-warning"; +import { brandIndicatorDomain } from "@/lib/email-headers"; import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward, CalendarClock } from "@/components/icons"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; @@ -296,6 +297,7 @@ export function ThreadEmailItem({ size="sm" className="flex-shrink-0" senderWarning={senderWarning} + brandDomain={brandIndicatorDomain(email)} /> )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2f245cb50..f13bc0980 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -37,6 +37,7 @@ import { SearchSnippetText } from "./search-snippet-text"; import { PluginRowBadges } from "./plugin-row-badges"; import { useOwnDomainAddress } from "@/hooks/use-own-domain-address"; import { useSenderWarning } from "@/hooks/use-sender-warning"; +import { brandIndicatorDomain } from "@/lib/email-headers"; import { useTranslations } from "next-intl"; /** @@ -744,6 +745,7 @@ const SingleEmailItem = React.forwardRef( className="shadow-sm" disableImages={hideJunkAvatarImages} senderWarning={senderWarning} + brandDomain={showRecipient ? undefined : brandIndicatorDomain(email)} checked={isChecked} onToggle={handleCheckboxClick} selectLabel={selectRowLabel} @@ -1447,6 +1449,7 @@ export const ThreadListItem = React.forwardRef['email'] }) { const from = email.from?.[0]; const senderWarning = useSenderWarning(email); - return ; + return ; } /** diff --git a/components/icons/index.ts b/components/icons/index.ts index 6b1972953..a53e8ebb6 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -30,6 +30,7 @@ export { IconArrowsSort as ArrowUpDown, IconCircleArrowUp as ArrowUpCircle, IconBan as Ban, + IconRosetteDiscountCheckFilled as BadgeCheck, IconBaseline as Baseline, IconBell as Bell, IconBellOff as BellOff, diff --git a/components/ui/__tests__/avatar.test.tsx b/components/ui/__tests__/avatar.test.tsx index ea356ff44..a3e59c3c7 100644 --- a/components/ui/__tests__/avatar.test.tsx +++ b/components/ui/__tests__/avatar.test.tsx @@ -7,6 +7,7 @@ import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import type { ContactCard } from '@/lib/jmap/types'; import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { useSettingsStore } from '@/stores/settings-store'; describe('Avatar', () => { it('renders initials from full name', () => { @@ -263,5 +264,71 @@ describe('Avatar', () => { expect(container.querySelector('img')).toBeNull(); expect(fetchBlob).not.toHaveBeenCalled(); }); + + it('shows the shield, not the brand logo, when both are given', () => { + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + }); + }); + + describe('brand logo (BIMI)', () => { + beforeEach(() => { + useFaviconRefreshStore.setState({ refreshedAt: 0 }); + useFaviconRefreshStore.getState().refresh(); + useFaviconRefreshStore.setState({ refreshedAt: 0 }); + }); + + it('shows the logo of a domain the message passed DMARC for, before the favicon', () => { + const { container } = render(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/bimi?domain=brand.org'); + }); + + it('asks for no logo without a DMARC pass', () => { + const { container } = render(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org'); + }); + + it('asks for no logo when the avatar shows someone from another domain', () => { + const { container } = render(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=other.org'); + }); + + it('falls through to the favicon when the domain has no logo', () => { + const { container } = render(); + const img = container.querySelector('img')!; + // /api/bimi answers a 1x1 placeholder when there is no logo. + Object.defineProperty(img, 'naturalWidth', { value: 1 }); + fireEvent.load(img); + expect(container.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org'); + // Other avatars of the domain don't ask again. + const { container: second } = render(); + expect(second.querySelector('img')?.getAttribute('src')).toBe('/api/favicon?domain=brand.org'); + }); + + it('keeps a contact photo first', async () => { + clearContactPhotoCache(); + const fetchBlob = vi.fn(async () => new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' })); + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:photo'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + useAuthStore.setState({ client: { fetchBlob } as never }); + try { + const { container } = render(); + await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toBe('blob:photo')); + } finally { + useAuthStore.setState({ client: null }); + clearContactPhotoCache(); + vi.restoreAllMocks(); + } + }); + + it('asks for no logo while sender icons are off', () => { + useSettingsStore.setState({ senderFavicons: false }); + try { + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + } finally { + useSettingsStore.setState({ senderFavicons: true }); + } + }); }); }); diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index 3171c3e0e..15b92c42b 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -13,7 +13,7 @@ import { withBasePath } from "@/lib/browser-navigation"; import { IS_LITE } from "@/lib/lite"; import { avatarUrlAllowed, sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts"; import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; -import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store"; +import { useFaviconRefreshStore, failedFaviconDomains, failedBrandDomains } from "@/stores/favicon-refresh-store"; const IS_DEV = process.env.NODE_ENV !== "production"; @@ -162,6 +162,12 @@ interface AvatarProps { * From doesn't get the real sender's logo or photo (#1439). */ senderWarning?: string; + /** + * The From domain of a message that passed DMARC for it (see + * brandIndicatorDomain): its BIMI logo, when it publishes one, comes before + * every source but a contact photo. + */ + brandDomain?: string; } /** @@ -176,8 +182,10 @@ function initialsColor(background: string): string { return 1.05 / (l + 0.05) >= (l + 0.05) / 0.05 ? '#ffffff' : '#000000'; } -export function Avatar({ name, email, contactPhoto, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor, senderWarning }: AvatarProps) { +export function Avatar({ name, email, contactPhoto, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor, senderWarning, brandDomain }: AvatarProps) { const [imgError, setImgError] = useState(false); + // The BIMI logo of this domain answered with no logo (or didn't load). + const [failedBrand, setFailedBrand] = useState(null); const [pluginAvatarUrl, setPluginAvatarUrl] = useState(null); const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); // A contact photo that downloaded but would not render (an SVG made inert, @@ -216,6 +224,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa // A refresh of the sender icons gives a failed favicon another chance. useEffect(() => { setImgError(false); + setFailedBrand(null); }, [faviconRefreshedAt]); const domain = email?.split("@")[1]?.toLowerCase(); @@ -271,20 +280,32 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa const showFavicon = !IS_LITE && senderFaviconsAllowed() && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; - // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials + // Priority: contact photo > BIMI logo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null; // Gravatar and the like are outside hosts: initials instead in the no-third-party mode. const pluginAvatar = pluginAvatarFailed || !avatarUrlAllowed(pluginAvatarUrl) ? null : pluginAvatarUrl; - const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null; + // BIMI logos come from /api/bimi under the same switches as favicons, and + // only for the address shown: a thread row may show another participant + // than the message whose DMARC pass named the domain. + const showBrand = + !IS_LITE && senderFaviconsAllowed() && senderFavicons && !!brandDomain && domain === brandDomain && brandDomain !== failedBrand && !failedBrandDomains.has(brandDomain); + const brandSrc = showBrand ? withBasePath(`/api/bimi?domain=${encodeURIComponent(brandDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null; + const photoSrc = resolvedContactPhoto || brandSrc || pluginAvatar || customAvatar || profilePic || null; const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null; const imgSrc = disableImages ? null : (photoSrc || faviconSrc); const isFavicon = imgSrc !== null && imgSrc === faviconSrc; + const isBrand = imgSrc !== null && imgSrc === brandSrc; const handleImgError = useCallback(() => { if (resolvedContactPhoto && imgSrc === resolvedContactPhoto) { setFailedContactPhoto(resolvedContactPhoto); return; } + if (brandSrc && brandDomain && imgSrc === brandSrc) { + failedBrandDomains.add(brandDomain); + setFailedBrand(brandDomain); + return; + } // If the plugin avatar just failed, mark it and fall through to the next source if (pluginAvatar && imgSrc === pluginAvatar) { setPluginAvatarFailed(true); @@ -295,7 +316,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) { failedFaviconDomains.add(faviconDomain); } - }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); + }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, brandSrc, brandDomain, customAvatar, profilePic]); const initialsBackground = fallbackColor ?? getBackgroundColor(); @@ -324,7 +345,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa className )} style={{ - backgroundColor: imgSrc ? (isFavicon ? "#ffffff" : "transparent") : initialsBackground, + backgroundColor: imgSrc ? (isFavicon || isBrand ? "#ffffff" : "transparent") : initialsBackground, color: imgSrc ? undefined : initialsColor(initialsBackground), }} title={name || email} @@ -335,12 +356,12 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa alt="" className="w-full h-full object-cover" onError={handleImgError} - // /api/favicon returns a 1x1 transparent PNG (HTTP 200) when no real - // favicon exists, to avoid spamming the DevTools console with 404s. - // Detect that sentinel by naturalWidth and fall back to initials. + // /api/favicon and /api/bimi return a 1x1 transparent PNG (HTTP 200) + // when there is no icon, to avoid spamming the DevTools console with + // 404s. Detect that sentinel by naturalWidth and fall through. onLoad={(e) => { const img = e.currentTarget; - if (isFavicon && img.naturalWidth <= 1) { + if ((isFavicon || isBrand) && img.naturalWidth <= 1) { handleImgError(); } }} diff --git a/lib/__tests__/bimi-certificate.test.ts b/lib/__tests__/bimi-certificate.test.ts new file mode 100644 index 000000000..4cab392fa --- /dev/null +++ b/lib/__tests__/bimi-certificate.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { X509Certificate } from 'node:crypto'; +import path from 'node:path'; +import { parseCrl, sanMatches, validateMarkCertificate, type CertificateOptions } from '@/lib/bimi/certificate'; +import { bimiRoots } from '@/lib/bimi/roots'; + +// The test PKI comes from fixtures/bimi/generate.py; cnn-vmc.pem is a real VMC. +const dir = path.join(__dirname, 'fixtures', 'bimi'); +const pem = (name: string) => readFileSync(path.join(dir, `${name}.pem`), 'utf8'); +const root = new X509Certificate(pem('root')); +const otherRoot = new X509Certificate(pem('other-root')); +const intermediate = new X509Certificate(pem('intermediate')); +const crlDer = (() => { + const text = pem('intermediate.crl'); + return new Uint8Array(Buffer.from(text.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64')); +})(); + +const NOW = Date.UTC(2026, 9, 29); +const base: CertificateOptions = { domains: ['brand.example'], selector: 'default', roots: [root], now: NOW }; +/** The leaf followed by its intermediate, as a sender's PEM file has them. */ +const bundle = (leaf: string) => `${pem(leaf)}\n${pem('intermediate')}`; + +describe('validateMarkCertificate', () => { + it('accepts a VMC chaining to a pinned root and returns the logo inside it', async () => { + const result = await validateMarkCertificate(bundle('leaf'), base); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.mark.svg).toContain('Brand Example'); + expect(result.mark.markType).toBe('verified'); + expect(result.mark.organization).toBe('Brand Example Ltd'); + expect(result.mark.issuer).toBe('Test Mark Authority'); + }); + + it('tells a CMC (prior use mark) from a VMC', async () => { + const result = await validateMarkCertificate(bundle('leaf-cmc'), base); + expect(result.ok && result.mark.markType).toBe('common'); + }); + + it('refuses a chain whose root is not pinned, even when the PEM carries that root', async () => { + const result = await validateMarkCertificate(`${pem('leaf-other')}\n${pem('other-root')}`, base); + expect(result).toEqual({ ok: false, reason: 'chain does not reach a trusted root' }); + // The same chain passes once its root is trusted, so the refusal is about the root. + expect((await validateMarkCertificate(pem('leaf-other'), { ...base, roots: [otherRoot] })).ok).toBe(true); + }); + + it('refuses a chain that leaves out the intermediate', async () => { + expect((await validateMarkCertificate(pem('leaf'), base)).ok).toBe(false); + }); + + it('refuses another domain', async () => { + const result = await validateMarkCertificate(bundle('leaf'), { ...base, domains: ['brand-example.com'] }); + expect(result).toEqual({ ok: false, reason: 'domain not in certificate' }); + }); + + it('refuses an expired certificate', async () => { + const result = await validateMarkCertificate(bundle('leaf-expired'), base); + expect(result).toEqual({ ok: false, reason: 'certificate outside its validity period' }); + }); + + it('refuses a certificate without the BIMI key purpose', async () => { + expect((await validateMarkCertificate(bundle('leaf-server'), base)).ok).toBe(false); + }); + + it('refuses a certificate without SCTs', async () => { + const result = await validateMarkCertificate(bundle('leaf-no-sct'), base); + expect(result).toEqual({ ok: false, reason: 'not logged to certificate transparency' }); + }); + + it('refuses a certificate without a logo', async () => { + const result = await validateMarkCertificate(bundle('leaf-no-logo'), base); + expect(result).toEqual({ ok: false, reason: 'no logo in certificate' }); + }); + + it('refuses a certificate its CA revoked, and asks the URL in the certificate', async () => { + const fetchCrl = vi.fn(async () => crlDer); + const revoked = await validateMarkCertificate(bundle('leaf-revoked'), { ...base, fetchCrl }); + expect(revoked).toEqual({ ok: false, reason: 'certificate revoked' }); + expect(fetchCrl).toHaveBeenCalledWith('http://crl.brand-ca.test/vmc.crl'); + expect((await validateMarkCertificate(bundle('leaf'), { ...base, fetchCrl })).ok).toBe(true); + }); + + it('accepts the certificate when the CRL cannot be fetched', async () => { + const result = await validateMarkCertificate(bundle('leaf-revoked'), { ...base, fetchCrl: async () => null }); + expect(result.ok).toBe(true); + }); + + it('ignores a CRL that the issuer did not sign', async () => { + // A CRL listing the leaf, but checked against the wrong key, proves nothing. + expect(parseCrl(crlDer, intermediate)?.revoked.has('BADC0DE')).toBe(true); + expect(parseCrl(crlDer, root)).toBeNull(); + }); + + it('accepts a SAN that pins the selector', async () => { + const options = { ...base, selector: 'brand' }; + expect((await validateMarkCertificate(bundle('leaf-selector'), options)).ok).toBe(true); + expect((await validateMarkCertificate(bundle('leaf-selector'), base)).ok).toBe(false); + }); + + it('accepts CNN\'s real VMC from DigiCert with the shipped roots', async () => { + const result = await validateMarkCertificate(pem('cnn-vmc'), { domains: ['cnn.com'], selector: 'default', roots: bimiRoots(), now: NOW }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.mark.organization).toBe('Cable News Network, Inc.'); + expect(result.mark.issuer).toBe('DigiCert, Inc.'); + expect(result.mark.svg).toMatch(/ { + it('matches the domain set for the default selector and the selector set otherwise', () => { + expect(sanMatches(['brand.example'], ['brand.example'], 'default')).toBe(true); + expect(sanMatches(['sel._bimi.brand.example'], ['brand.example'], 'sel')).toBe(true); + expect(sanMatches(['sel._bimi.brand.example'], ['brand.example'], 'default')).toBe(false); + expect(sanMatches(['other.example'], ['brand.example'], 'default')).toBe(false); + }); +}); + +describe('bimiRoots', () => { + it('parses every pinned root', () => { + const names = bimiRoots().map((cert) => /CN=([^\n]+)/.exec(cert.subject)?.[1]); + expect(names).toEqual([ + 'DigiCert Verified Mark Root CA', + 'GlobalSign Verified Mark Root R42', + 'SSL.com VMC RSA Root CA 2024', + 'SSL.com VMC ECC Root CA 2024', + ]); + }); +}); diff --git a/lib/__tests__/bimi-lookup.test.ts b/lib/__tests__/bimi-lookup.test.ts new file mode 100644 index 000000000..70ad925e9 --- /dev/null +++ b/lib/__tests__/bimi-lookup.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { X509Certificate } from 'node:crypto'; +import path from 'node:path'; +import { createBimiResolver, normalizeBimiDomain } from '@/lib/bimi/lookup'; + +const LOGO = 'Brand'; +const fixtures = path.join(__dirname, 'fixtures', 'bimi'); +const fixture = (name: string) => readFileSync(path.join(fixtures, `${name}.pem`), 'utf8'); + +function dnsError(code: string) { + return Object.assign(new Error(code), { code }); +} + +/** A resolver over fake DNS records and URLs; unknown names are NXDOMAIN. */ +function setup(records: Record, urls: Record = {}, now = Date.UTC(2026, 9, 29)) { + const clock = { now }; + const resolveTxt = vi.fn(async (name: string) => { + if (records[name]) return records[name].map((r) => [r]); + throw dnsError('ENOTFOUND'); + }); + const fetchBytes = vi.fn(async (url: string) => (url in urls ? new Uint8Array(Buffer.from(urls[url])) : null)); + const resolver = createBimiResolver({ + resolveTxt, + fetchBytes, + roots: () => [new X509Certificate(fixture('root'))], + now: () => clock.now, + }); + return { resolver, resolveTxt, fetchBytes, clock }; +} + +describe('BIMI resolver', () => { + it('shows a self-published logo when DMARC enforces', async () => { + const { resolver } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + const indicator = await resolver.resolve('brand.org'); + expect(indicator?.status).toBe('self-asserted'); + expect(indicator?.png.subarray(1, 4).toString()).toBe('PNG'); + }); + + it('looks no further when DMARC does not enforce', async () => { + const { resolver, fetchBytes, resolveTxt } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=none'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + expect(await resolver.resolve('brand.org')).toBeNull(); + expect(fetchBytes).not.toHaveBeenCalled(); + expect(resolveTxt).not.toHaveBeenCalledWith('default._bimi.brand.org'); + }); + + it('falls back to the organizational domain for a subdomain', async () => { + const { resolver, resolveTxt } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + expect((await resolver.resolve('news.brand.org'))?.status).toBe('self-asserted'); + expect(resolveTxt).toHaveBeenCalledWith('default._bimi.news.brand.org'); + }); + + it('shows the certificate\'s logo, not the l= one, when a valid VMC backs the record', async () => { + const { resolver } = setup( + { '_dmarc.brand.example': ['v=DMARC1; p=quarantine'], 'default._bimi.brand.example': ['v=BIMI1; l=https://cdn.brand.example/logo.svg; a=https://cdn.brand.example/vmc.pem'] }, + { 'https://cdn.brand.example/vmc.pem': `${fixture('leaf')}\n${fixture('intermediate')}` }, + ); + const indicator = await resolver.resolve('brand.example'); + expect(indicator?.status).toBe('verified'); + expect(indicator?.organization).toBe('Brand Example Ltd'); + // The certificate's logo is green (#0a6); the l= URL was never fetched. + const sharp = (await import('sharp')).default; + const { data } = await sharp(indicator!.png).raw().toBuffer({ resolveWithObject: true }); + expect([data[0], data[1], data[2]]).toEqual([0x00, 0xaa, 0x66]); + }); + + it('refuses the logo when the certificate does not validate, even with a valid l=', async () => { + const { resolver } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg; a=https://cdn.brand.org/vmc.pem'] }, + // The test leaf names brand.example, not brand.org. + { 'https://cdn.brand.org/logo.svg': LOGO, 'https://cdn.brand.org/vmc.pem': `${fixture('leaf')}\n${fixture('intermediate')}` }, + ); + expect(await resolver.resolve('brand.org')).toBeNull(); + }); + + it('caches per domain and shares a lookup between concurrent callers', async () => { + const { resolver, resolveTxt } = setup( + { '_dmarc.brand.org': ['v=DMARC1; p=reject'], 'default._bimi.brand.org': ['v=BIMI1; l=https://cdn.brand.org/logo.svg'] }, + { 'https://cdn.brand.org/logo.svg': LOGO }, + ); + await Promise.all([resolver.resolve('brand.org'), resolver.resolve('Brand.org.')]); + await resolver.resolve('brand.org'); + expect(resolveTxt.mock.calls.filter(([name]) => name === '_dmarc.brand.org')).toHaveLength(1); + }); + + it('asks again soon after a DNS failure instead of hiding the logo for hours', async () => { + const { resolver, resolveTxt, clock } = setup({}); + resolveTxt.mockRejectedValueOnce(dnsError('ESERVFAIL')); + expect(await resolver.resolve('brand.org')).toBeNull(); + const calls = resolveTxt.mock.calls.length; + await resolver.resolve('brand.org'); + expect(resolveTxt.mock.calls.length).toBe(calls); + clock.now += 11 * 60 * 1000; + await resolver.resolve('brand.org'); + expect(resolveTxt.mock.calls.length).toBeGreaterThan(calls); + }); + + it('refuses names it must not look up', async () => { + const { resolver, resolveTxt } = setup({}); + for (const name of ['localhost', 'printer.local', 'nas.lan', 'x', '10.0.0.1.in-addr.arpa', 'a..b', 'brand.org/x']) { + expect(normalizeBimiDomain(name)).toBeNull(); + expect(await resolver.resolve(name)).toBeNull(); + } + expect(resolveTxt).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/bimi-records.test.ts b/lib/__tests__/bimi-records.test.ts new file mode 100644 index 000000000..7e0cc2a41 --- /dev/null +++ b/lib/__tests__/bimi-records.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { dmarcAllowsBimi, pickBimiRecord, pickDmarcRecord, type DmarcRecord } from '@/lib/bimi/records'; + +describe('pickBimiRecord', () => { + it('reads the logo and certificate locations', () => { + expect(pickBimiRecord(['v=BIMI1; l=https://brand.example/logo.svg; a=https://brand.example/vmc.pem;'])).toEqual({ + logoUrl: 'https://brand.example/logo.svg', + evidenceUrl: 'https://brand.example/vmc.pem', + declined: false, + }); + }); + + it('ignores the SPF record a wildcard TXT answers with', () => { + // netflix.com and coinbase.com answer default._bimi queries this way. + expect(pickBimiRecord(['v=spf1 include:_spf.example.com -all'])).toBeNull(); + expect(pickBimiRecord(['v=spf1 -all', 'v=BIMI1;l=https://brand.example/logo.svg'])?.logoUrl).toBe('https://brand.example/logo.svg'); + }); + + it('refuses two assertion records', () => { + expect(pickBimiRecord(['v=BIMI1; l=https://a.example/1.svg', 'v=BIMI1; l=https://a.example/2.svg'])).toBeNull(); + }); + + it('treats empty l= and a= as declining', () => { + expect(pickBimiRecord(['v=BIMI1; l=; a=;'])?.declined).toBe(true); + // uber.com: a logo, an empty a=. + expect(pickBimiRecord(['v=BIMI1; l=https://brand.example/logo.svg; a='])).toEqual({ + logoUrl: 'https://brand.example/logo.svg', evidenceUrl: undefined, declined: false, + }); + }); + + it('refuses locations that are not https', () => { + expect(pickBimiRecord(['v=BIMI1; l=http://brand.example/logo.svg'])).toBeNull(); + expect(pickBimiRecord(['v=BIMI1; l=https://brand.example/logo.svg; a=ftp://brand.example/vmc.pem'])).toBeNull(); + }); +}); + +describe('pickDmarcRecord', () => { + it('reads p, sp and pct', () => { + expect(pickDmarcRecord(['v=DMARC1; p=quarantine; sp=reject; pct=100; rua=mailto:d@example.com'])).toEqual({ p: 'quarantine', sp: 'reject', pct: 100 }); + expect(pickDmarcRecord(['v=DMARC1;p=reject'])).toEqual({ p: 'reject', sp: undefined, pct: 100 }); + }); + + it('refuses a record without a valid p', () => { + expect(pickDmarcRecord(['v=DMARC1; rua=mailto:d@example.com'])).toBeNull(); + expect(pickDmarcRecord(['v=spf1 -all'])).toBeNull(); + }); +}); + +describe('dmarcAllowsBimi', () => { + const reject: DmarcRecord = { p: 'reject', pct: 100 }; + const quarantine: DmarcRecord = { p: 'quarantine', pct: 100 }; + const none: DmarcRecord = { p: 'none', pct: 100 }; + + it('needs quarantine or reject for every message', () => { + expect(dmarcAllowsBimi(reject, reject, false)).toBe(true); + expect(dmarcAllowsBimi(quarantine, quarantine, false)).toBe(true); + expect(dmarcAllowsBimi(none, none, false)).toBe(false); + expect(dmarcAllowsBimi({ p: 'reject', pct: 50 }, null, false)).toBe(false); + expect(dmarcAllowsBimi(null, null, false)).toBe(false); + }); + + it('applies the organizational domain\'s sp to a subdomain without its own record', () => { + expect(dmarcAllowsBimi(null, { p: 'reject', sp: 'none', pct: 100 }, true)).toBe(false); + expect(dmarcAllowsBimi(null, { p: 'quarantine', sp: 'reject', pct: 100 }, true)).toBe(true); + expect(dmarcAllowsBimi(null, reject, true)).toBe(true); + }); + + it('refuses a subdomain whose organizational domain is p=none, whatever the subdomain says', () => { + expect(dmarcAllowsBimi(reject, none, true)).toBe(false); + }); +}); diff --git a/lib/__tests__/bimi-route.test.ts b/lib/__tests__/bimi-route.test.ts new file mode 100644 index 000000000..086f5fe7d --- /dev/null +++ b/lib/__tests__/bimi-route.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { NextRequest } from 'next/server'; +import type { BimiIndicator } from '@/lib/bimi/lookup'; + +const resolve = vi.hoisted(() => vi.fn(async (_domain: string): Promise => null)); +vi.mock('@/lib/bimi/lookup', async (importOriginal) => ({ + ...(await importOriginal()), + bimiResolver: () => ({ resolve, clear: () => {} }), +})); + +import { configManager } from '@/lib/admin/config-manager'; +import { GET } from '@/app/api/bimi/route'; + +const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452000000c0000000c0', 'hex'); +const verified: BimiIndicator = { status: 'verified', png: PNG, markType: 'verified', organization: 'Brand Ltd', issuer: 'DigiCert, Inc.' }; +const selfAsserted: BimiIndicator = { status: 'self-asserted', png: PNG }; + +let dir: string; +async function setConfig(config: Record) { + writeFileSync(path.join(dir, 'config.json'), JSON.stringify(config)); + await configManager.load(); +} +const get = (query: string) => GET(new NextRequest(`https://webmail.example/api/bimi?${query}`)); + +beforeAll(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bulwark-bimi-')); + process.env.ADMIN_CONFIG_DIR = dir; + delete process.env.NO_THIRD_PARTY_TRAFFIC; + delete process.env.BIMI_REQUIRE_CERTIFICATE; +}); +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + delete process.env.ADMIN_CONFIG_DIR; +}); +beforeEach(async () => { + resolve.mockReset(); + await setConfig({}); +}); + +describe('/api/bimi', () => { + it('answers the logo as a PNG that cannot run anything', async () => { + resolve.mockResolvedValue(selfAsserted); + const res = await get('domain=brand.org'); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('x-bulwark-bimi')).toBe('self-asserted'); + expect(res.headers.get('content-security-policy')).toContain('sandbox'); + expect(Buffer.from(await res.arrayBuffer())).toEqual(PNG); + expect(resolve).toHaveBeenCalledWith('brand.org'); + }); + + it('answers the 1x1 placeholder when there is no logo', async () => { + resolve.mockResolvedValue(null); + const res = await get('domain=brand.org'); + expect(res.status).toBe(200); + expect(res.headers.get('x-bulwark-bimi')).toBe('missing'); + const bytes = Buffer.from(await res.arrayBuffer()); + // PNG width and height from the IHDR chunk. + expect([bytes.readUInt32BE(16), bytes.readUInt32BE(20)]).toEqual([1, 1]); + }); + + it('drops self-published logos when the admin requires a mark certificate', async () => { + await setConfig({ bimiRequireCertificate: true }); + resolve.mockResolvedValue(selfAsserted); + expect((await get('domain=brand.org')).headers.get('x-bulwark-bimi')).toBe('missing'); + expect(await (await get('domain=brand.org&format=json')).json()).toEqual({ status: 'none' }); + + resolve.mockResolvedValue(verified); + expect((await get('domain=brand.org')).headers.get('x-bulwark-bimi')).toBe('verified'); + }); + + it('describes what backs the logo in JSON', async () => { + resolve.mockResolvedValue(verified); + expect(await (await get('domain=brand.org&format=json')).json()).toEqual({ + status: 'verified', markType: 'verified', organization: 'Brand Ltd', issuer: 'DigiCert, Inc.', + }); + }); + + it('refuses a missing or local domain without a lookup', async () => { + expect((await get('')).status).toBe(400); + expect((await get('domain=localhost')).status).toBe(400); + expect(resolve).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/bimi-svg.test.ts b/lib/__tests__/bimi-svg.test.ts new file mode 100644 index 000000000..28e7ccb05 --- /dev/null +++ b/lib/__tests__/bimi-svg.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import sharp from 'sharp'; +import { checkSvg, rasterize, INDICATOR_SIZE } from '@/lib/bimi/svg'; + +const svg = (body: string, root = 'version="1.2" baseProfile="tiny-ps" viewBox="0 0 100 100"') => + `Brand${body}`; +const strict = { strictProfile: true }; +const lenient = { strictProfile: false }; + +describe('checkSvg', () => { + it('accepts a Tiny PS logo', () => { + expect(checkSvg(svg(''), strict)).toBeNull(); + }); + + it('refuses scripts, event handlers and foreign content', () => { + expect(checkSvg(svg(''), lenient)).toMatch(/script/); + expect(checkSvg(svg(''), lenient)).toBe('event handler'); + expect(checkSvg(svg('
'), lenient)).toMatch(/foreignobject/); + expect(checkSvg(svg(''), lenient)).toMatch(/animate/); + }); + + it('refuses anything loaded from elsewhere', () => { + expect(checkSvg(svg(''), lenient)).toBe('external reference'); + expect(checkSvg(svg(''), lenient)).toBe('external reference'); + expect(checkSvg(svg(''), lenient)).toBe('external reference'); + // References inside the document are fine. + expect(checkSvg(svg(''), lenient)).toBeNull(); + }); + + it('refuses entity declarations', () => { + expect(checkSvg(`]>${svg('')}`, lenient)).toBe('doctype'); + }); + + it('refuses a logo that isn\'t square', () => { + expect(checkSvg(svg('', 'version="1.2" baseProfile="tiny-ps" viewBox="0 0 200 100"'), lenient)).toBe('not square'); + }); + + it('holds self-published logos to the profile, certificate logos only to safety', () => { + const plain = svg('', 'viewBox="0 0 100 100"'); + expect(checkSvg(plain, strict)).toBe('not tiny-ps'); + expect(checkSvg(plain, lenient)).toBeNull(); + const big = svg(`${'x'.repeat(40 * 1024)}`); + expect(checkSvg(big, strict)).toBe('too large'); + expect(checkSvg(big, lenient)).toBeNull(); + }); +}); + +describe('rasterize', () => { + it('draws a square PNG of the indicator size', async () => { + const png = await rasterize(svg('')); + const meta = await sharp(png).metadata(); + expect(meta.format).toBe('png'); + expect([meta.width, meta.height]).toEqual([INDICATOR_SIZE, INDICATOR_SIZE]); + const { data } = await sharp(png).raw().toBuffer({ resolveWithObject: true }); + // The fill colour, not an empty canvas. + expect([data[0], data[1], data[2]]).toEqual([0x00, 0xaa, 0x66]); + }); +}); diff --git a/lib/__tests__/brand-indicator-domain.test.ts b/lib/__tests__/brand-indicator-domain.test.ts new file mode 100644 index 000000000..217d6a0d2 --- /dev/null +++ b/lib/__tests__/brand-indicator-domain.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { brandIndicatorDomain, parseAuthenticationResults } from '@/lib/email-headers'; + +const message = (from: string, ...headers: string[]) => ({ + from: [{ email: from }], + authenticationResults: headers.length ? parseAuthenticationResults(headers) : undefined, +}); + +describe('brandIndicatorDomain', () => { + it('names the From domain of a message that passed DMARC for it', () => { + const email = message('news@Brand.org', 'mx.example; dkim=pass header.d=brand.org; spf=pass smtp.mailfrom=bounce.brand.org; dmarc=pass header.from=brand.org'); + expect(brandIndicatorDomain(email)).toBe('brand.org'); + }); + + it('needs a DMARC verdict, not only an aligned DKIM pass', () => { + expect(brandIndicatorDomain(message('news@brand.org', 'mx.example; dkim=pass header.d=brand.org; spf=pass smtp.mailfrom=brand.org'))).toBeUndefined(); + expect(brandIndicatorDomain(message('news@brand.org', 'mx.example; dkim=pass header.d=brand.org; dmarc=none header.from=brand.org'))).toBeUndefined(); + }); + + it('refuses a pass the server recorded for another header.from', () => { + expect(brandIndicatorDomain(message('ceo@bank.com', 'mx.example; dkim=pass header.d=lookalike.com; dmarc=pass header.from=lookalike.com'))).toBeUndefined(); + }); + + it('trusts only the receiving server\'s own header, the topmost', () => { + const forged = message( + 'ceo@bank.com', + 'mx.example; spf=fail smtp.mailfrom=bank.com; dmarc=fail header.from=bank.com', + 'mx.example; dkim=pass header.d=bank.com; dmarc=pass header.from=bank.com', + ); + expect(brandIndicatorDomain(forged)).toBeUndefined(); + }); + + it('gives nothing without results or a From', () => { + expect(brandIndicatorDomain(message('news@brand.org'))).toBeUndefined(); + expect(brandIndicatorDomain({ from: [], authenticationResults: parseAuthenticationResults('mx.example; dmarc=pass header.from=brand.org') })).toBeUndefined(); + expect(brandIndicatorDomain(undefined)).toBeUndefined(); + }); +}); diff --git a/lib/__tests__/fixtures/bimi/generate.py b/lib/__tests__/fixtures/bimi/generate.py new file mode 100644 index 000000000..8dcb58660 --- /dev/null +++ b/lib/__tests__/fixtures/bimi/generate.py @@ -0,0 +1,158 @@ +""" +Makes the test mark-certificate PKI the BIMI tests use (lib/__tests__/bimi-certificate.test.ts): +a root, an intermediate with the BIMI key purpose, leaves with and without +each required extension, and a CRL from the intermediate that revokes +leaf-revoked. Needs OpenSSL 3.4 or newer. + + python generate.py + +Copy the .pem files (never the keys) back here. The certificates run from +2026 to 2036; the tests pin the time they check at. + +cnn-vmc.pem is not made here: it is CNN's real VMC from DigiCert, as its +BIMI record served it in October 2026. +""" +import base64, gzip, hashlib, os, subprocess, sys + +out = sys.argv[1] +os.makedirs(out, exist_ok=False) +os.chdir(out) + + +def run(*args): + r = subprocess.run(args, capture_output=True, text=True) + if r.returncode: + print(' '.join(args), r.stderr) + sys.exit(1) + return r.stdout + + +def tlv(tag, content): + n = len(content) + if n < 0x80: + length = bytes([n]) + else: + b = n.to_bytes((n.bit_length() + 7) // 8, 'big') + length = bytes([0x80 | len(b)]) + b + return bytes([tag]) + length + content + + +def oid(s): + p = [int(x) for x in s.split('.')] + body = bytes([40 * p[0] + p[1]]) + for v in p[2:]: + enc = [v & 0x7f] + v >>= 7 + while v: + enc.append(0x80 | (v & 0x7f)) + v >>= 7 + body += bytes(reversed(enc)) + return tlv(0x06, body) + + +SVG = b'Brand Example' +gz = gzip.compress(SVG, mtime=0) +uri = b'data:image/svg+xml;base64,' + base64.b64encode(gz) +hav = tlv(0x30, tlv(0x30, oid('2.16.840.1.101.3.4.2.1') + b'\x05\x00') + tlv(0x04, hashlib.sha256(gz).digest())) +details = tlv(0x30, tlv(0x16, b'image/svg+xml') + tlv(0x30, hav) + tlv(0x30, tlv(0x16, uri))) +image = tlv(0x30, details) +logotype = tlv(0x30, tlv(0xa2, tlv(0xa0, tlv(0x30, image)))) +LOGO = logotype.hex() +SCT = tlv(0x04, b'\x00\x00').hex() +BIMI = '1.3.6.1.5.5.7.3.31' + +with open('openssl.cnf', 'w') as f: + f.write(f""" +oid_section = new_oids +[ new_oids ] +markType = 1.3.6.1.4.1.53087.1.13 +[ req ] +distinguished_name = dn +prompt = no +[ dn ] +CN = placeholder +[ root_ext ] +basicConstraints = critical,CA:true +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +[ int_ext ] +basicConstraints = critical,CA:true,pathlen:0 +keyUsage = critical,keyCertSign,cRLSign +extendedKeyUsage = {BIMI} +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid +[ leaf_ext ] +basicConstraints = critical,CA:false +keyUsage = critical,digitalSignature +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand.example +crlDistributionPoints = URI:http://crl.brand-ca.test/vmc.crl +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +authorityKeyIdentifier = keyid +[ leaf_selector_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand._bimi.brand.example +crlDistributionPoints = URI:http://crl.brand-ca.test/vmc.crl +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +[ leaf_no_sct_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand.example +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +[ leaf_no_logo_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = {BIMI} +subjectAltName = DNS:brand.example +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +[ leaf_server_ext ] +basicConstraints = critical,CA:false +extendedKeyUsage = serverAuth +subjectAltName = DNS:brand.example +1.3.6.1.5.5.7.1.12 = DER:{LOGO} +1.3.6.1.4.1.11129.2.4.2 = DER:{SCT} +[ ca ] +default_ca = int_ca +[ int_ca ] +database = index.txt +crlnumber = crlnumber +default_md = sha256 +default_crl_days = 3650 +""") +open('index.txt', 'w').close() +with open('crlnumber', 'w') as f: + f.write('01\n') + + +def cert(name, subj, issuer, ext, nb='20260101000000Z', na='20360101000000Z', serial=None): + run('openssl', 'genrsa', '-out', f'{name}.key', '2048') + run('openssl', 'req', '-new', '-key', f'{name}.key', '-subj', subj, '-out', f'{name}.csr', '-config', 'openssl.cnf') + args = ['openssl', 'x509', '-req', '-in', f'{name}.csr', '-out', f'{name}.pem', '-extfile', 'openssl.cnf', + '-extensions', ext, '-not_before', nb, '-not_after', na, '-sha256', + '-set_serial', serial or str(int.from_bytes(os.urandom(8), 'big'))] + if issuer: + args += ['-CA', f'{issuer}.pem', '-CAkey', f'{issuer}.key'] + else: + args += ['-key', f'{name}.key'] + run(*args) + + +cert('root', '/C=XX/O=Test Mark Authority/CN=Test Verified Mark Root', None, 'root_ext') +cert('other-root', '/C=XX/O=Other Authority/CN=Test Verified Mark Root', None, 'root_ext') +cert('intermediate', '/C=XX/O=Test Mark Authority/CN=Test Verified Mark CA', 'root', 'int_ext') +LEAF = '/C=XX/O=Brand Example Ltd/CN=Brand Example Ltd/markType=Registered Mark' +cert('leaf', LEAF, 'intermediate', 'leaf_ext') +cert('leaf-cmc', '/C=XX/O=Brand Example Ltd/CN=Brand Example Ltd/markType=Prior Use Mark', 'intermediate', 'leaf_ext') +cert('leaf-selector', LEAF, 'intermediate', 'leaf_selector_ext') +cert('leaf-no-sct', LEAF, 'intermediate', 'leaf_no_sct_ext') +cert('leaf-no-logo', LEAF, 'intermediate', 'leaf_no_logo_ext') +cert('leaf-server', LEAF, 'intermediate', 'leaf_server_ext') +cert('leaf-expired', LEAF, 'intermediate', 'leaf_ext', nb='20240101000000Z', na='20250101000000Z') +cert('leaf-revoked', LEAF, 'intermediate', 'leaf_ext', serial='0x0BADC0DE') +cert('leaf-other', LEAF, 'other-root', 'leaf_ext') +run('openssl', 'ca', '-config', 'openssl.cnf', '-revoke', 'leaf-revoked.pem', '-keyfile', 'intermediate.key', '-cert', 'intermediate.pem') +run('openssl', 'ca', '-config', 'openssl.cnf', '-gencrl', '-keyfile', 'intermediate.key', '-cert', 'intermediate.pem', '-out', 'intermediate.crl.pem') +run('openssl', 'crl', '-in', 'intermediate.crl.pem', '-outform', 'DER', '-out', 'intermediate.crl') +print(run('openssl', 'x509', '-in', 'leaf.pem', '-noout', '-text')[:1800]) diff --git a/lib/__tests__/outgoing-hosts-mode.test.ts b/lib/__tests__/outgoing-hosts-mode.test.ts index 71f52778c..fd4b4d713 100644 --- a/lib/__tests__/outgoing-hosts-mode.test.ts +++ b/lib/__tests__/outgoing-hosts-mode.test.ts @@ -25,6 +25,12 @@ vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn(async () => ({ serverUrl: 'https://mail.example.com', authHeader: 'Basic x', username: 'u' })), })); vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null })); +// The BIMI route would query real DNS; the probe only needs to see whether it gets that far. +const bimiResolve = vi.hoisted(() => vi.fn(async (_domain: string) => null)); +vi.mock('@/lib/bimi/lookup', async (importOriginal) => ({ + ...(await importOriginal()), + bimiResolver: () => ({ resolve: bimiResolve, clear: () => {} }), +})); import { configManager } from '@/lib/admin/config-manager'; @@ -70,6 +76,12 @@ const probes: Record Promise> = { await GET(new NextRequest(`https://webmail.example/api/favicon?domain=probe${Date.now()}.org`)); return fetchedHosts().includes('icons.duckduckgo.com') || senderFaviconsAllowed(); }, + bimi: async () => { + const { GET } = await import('@/app/api/bimi/route'); + bimiResolve.mockClear(); + await GET(new NextRequest('https://webmail.example/api/bimi?domain=probe.org')); + return bimiResolve.mock.calls.length > 0; + }, translate: async () => { const { POST } = await import('@/app/api/translate/route'); await POST(new NextRequest('https://webmail.example/api/translate', { diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index 98ae3791d..5b336e453 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -80,6 +80,7 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ { id: 'login', title: 'Login page', keywords: 'version 2fa totp code access token remember me heading subtitle' }, { id: 'servers', title: 'JMAP servers', keywords: 'multi server domain auto pick list' }, { id: 'wopi', title: 'Document editing', keywords: 'wopi collabora onlyoffice office files host url' }, + { id: 'bimi', title: 'Brand logos', keywords: 'bimi vmc cmc mark certificate sender logo icon dmarc' }, { id: 'logging', title: 'Logging', keywords: 'log level format json text debug' }, { id: 'sync', title: 'Settings sync', keywords: 'session secret devices' }, ], diff --git a/lib/admin/outgoing-hosts-registry.ts b/lib/admin/outgoing-hosts-registry.ts index 2eb0ab5e1..9fab19789 100644 --- a/lib/admin/outgoing-hosts-registry.ts +++ b/lib/admin/outgoing-hosts-registry.ts @@ -94,6 +94,18 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [ inMode: 'Avatars show initials; /api/favicon answers 403 before any request.', lite: false, }, + { + id: 'bimi', + host: ' (BIMI)', + // The hosts come from the sender's DNS records; no literal in the source. + literals: [], + files: [], + side: 'server', + usedBy: 'Brand logos (BIMI) for messages that pass DMARC, looked up by /api/bimi: TXT records at the sender\'s domain, then the logo, mark certificate and CRL they name.', + off: 'Users can turn sender icons off in Settings > Message list.', + inMode: 'Avatars show no brand logos; /api/bimi answers 403 before any lookup.', + lite: false, + }, { id: 'translate', host: 'api.mymemory.translated.net', diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 3908ce360..45c005261 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -380,6 +380,10 @@ export const CONFIG_ENV_MAP: Record/hosting/discovery` unless the URL already carries a path. diff --git a/lib/bimi/certificate.ts b/lib/bimi/certificate.ts new file mode 100644 index 000000000..0be758853 --- /dev/null +++ b/lib/bimi/certificate.ts @@ -0,0 +1,298 @@ +import { X509Certificate, verify as verifySignature } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import { + certificateExtensions, + children, + content, + decodeOid, + encoding, + integerHex, + normalizeSerial, + readNode, + stringsIn, + type DerNode, +} from './der'; + +/** + * Validation of a BIMI evidence document: the PEM chain of a Verified Mark + * Certificate (VMC) or Common Mark Certificate (CMC), following + * draft-fetch-validation-vmc-wchuang. The indicator shown is the SVG inside + * the certificate, so a valid chain also fixes which logo appears. + * + * Not checked: the signatures on the embedded SCTs (that needs the CT log + * list); the certificate only has to carry them. + */ + +/** id-kp-BrandIndicatorforMessageIdentification */ +export const BIMI_EKU = '1.3.6.1.5.5.7.3.31'; +const LOGOTYPE_OID = '1.3.6.1.5.5.7.1.12'; +const SCT_LIST_OID = '1.3.6.1.4.1.11129.2.4.2'; +const CRL_DISTRIBUTION_POINTS_OID = '2.5.29.31'; +const MARK_TYPE_OID = '1.3.6.1.4.1.53087.1.13'; +const MAX_CHAIN = 6; +const MAX_SVG_BYTES = 64 * 1024; + +export type MarkType = 'verified' | 'common'; + +export interface MarkCertificate { + svg: string; + /** VMC (registered or government mark) or CMC (prior use mark). */ + markType: MarkType; + /** The organization the mark belongs to, from the certificate subject. */ + organization?: string; + /** The issuing CA's organization. */ + issuer?: string; + /** When the certificate expires (ms since the epoch). */ + notAfter: number; +} + +export type CertificateCheck = + | { ok: true; mark: MarkCertificate } + | { ok: false; reason: string }; + +export interface CertificateOptions { + /** Where the assertion record was found and its organizational domain. */ + domains: readonly string[]; + selector: string; + roots: readonly X509Certificate[]; + now?: number; + /** + * Fetches a CRL. Null when it can't be had, which counts as not revoked: + * like browsers, an unreachable CRL doesn't fail the check. + */ + fetchCrl?: (url: string) => Promise; +} + +const PEM_BLOCK = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +export function splitPem(pem: string): X509Certificate[] { + return (pem.match(PEM_BLOCK) ?? []).map((block) => new X509Certificate(block)); +} + +function hasBimiEku(cert: X509Certificate): boolean { + return Array.isArray(cert.keyUsage) && cert.keyUsage.includes(BIMI_EKU); +} + +function within(cert: X509Certificate, now: number): boolean { + const from = Date.parse(cert.validFrom); + const to = Date.parse(cert.validTo); + return Number.isFinite(from) && Number.isFinite(to) && from <= now && now <= to; +} + +function issuedBy(cert: X509Certificate, issuer: X509Certificate): boolean { + try { + return cert.checkIssued(issuer) && cert.verify(issuer.publicKey); + } catch { + return false; + } +} + +/** Values of one attribute in an X509Certificate subject or issuer string. */ +function attribute(name: string, field: string): string | undefined { + for (const line of name.split('\n')) { + const eq = line.indexOf('='); + if (eq > 0 && line.slice(0, eq) === field) return line.slice(eq + 1).replace(/\\(.)/g, '$1'); + } + return undefined; +} + +function sanDnsNames(cert: X509Certificate): string[] { + return (cert.subjectAltName ?? '') + .split(/,\s*/) + .filter((entry) => entry.startsWith('DNS:')) + .map((entry) => entry.slice(4).trim().toLowerCase().replace(/\.$/, '')); +} + +/** + * The SAN rule of the draft (section 5.2): names with a "_bimi" label pin a + * selector, the others cover the default one. + */ +export function sanMatches(names: readonly string[], domains: readonly string[], selector: string): boolean { + const selectorSet = names.filter((name) => name.split('.').includes('_bimi')); + const domainSet = names.filter((name) => !name.split('.').includes('_bimi')); + return domains.some((domain) => { + const d = domain.toLowerCase(); + return selectorSet.includes(`${selector.toLowerCase()}._bimi.${d}`) || domainSet.includes(d); + }); +} + +/** The SVG in the logotype extension: a data: URI holding gzip-compressed, base64-encoded SVG. */ +export function logotypeSvg(extension: Uint8Array): string | null { + for (const uri of stringsIn(extension, 0x16)) { + const match = /^data:image\/svg\+xml(?:;[^,]*)?;base64,([\s\S]+)$/i.exec(uri.trim()); + if (!match) continue; + let bytes: Buffer = Buffer.from(match[1].replace(/\s+/g, ''), 'base64'); + if (bytes[0] === 0x1f && bytes[1] === 0x8b) { + try { + bytes = gunzipSync(bytes, { maxOutputLength: MAX_SVG_BYTES }); + } catch { + return null; + } + } + if (bytes.length === 0 || bytes.length > MAX_SVG_BYTES) return null; + return bytes.toString('utf8'); + } + return null; +} + +function crlUrls(cert: X509Certificate): string[] { + const value = certificateExtensions(new Uint8Array(cert.raw)).get(CRL_DISTRIBUTION_POINTS_OID); + if (!value) return []; + // GeneralName uniformResourceIdentifier is [6] IMPLICIT IA5String. + return stringsIn(value, 0x86).filter((url) => /^https?:\/\//i.test(url)); +} + +const SIGNATURE_HASHES: Record = { + '1.2.840.113549.1.1.11': 'sha256', + '1.2.840.113549.1.1.12': 'sha384', + '1.2.840.113549.1.1.13': 'sha512', + '1.2.840.10045.4.3.2': 'sha256', + '1.2.840.10045.4.3.3': 'sha384', + '1.2.840.10045.4.3.4': 'sha512', +}; + +function parseTime(buf: Uint8Array, node: DerNode): number { + const text = Buffer.from(content(buf, node)).toString('latin1'); + const m = node.tag === 0x17 + ? /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(text) + : /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(text); + if (!m) return NaN; + let year = Number(m[1]); + if (node.tag === 0x17) year += year < 50 ? 2000 : 1900; + return Date.UTC(year, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])); +} + +export interface ParsedCrl { + revoked: Set; + nextUpdate?: number; +} + +/** + * Read a DER CertificateList and check its signature with the issuer's key. + * Null when it doesn't parse, uses an algorithm we don't know, or isn't + * signed by `issuer`. + */ +export function parseCrl(der: Uint8Array, issuer: X509Certificate): ParsedCrl | null { + try { + const list = readNode(der, 0); + const [tbs, algorithm, signature] = children(der, list); + if (!tbs || !algorithm || !signature || signature.tag !== 0x03) return null; + const algorithmOid = children(der, algorithm)[0]; + const hash = SIGNATURE_HASHES[decodeOid(content(der, algorithmOid))]; + if (!hash) return null; + // BIT STRING: the first content byte counts the unused bits. + const signatureBytes = content(der, signature).subarray(1); + if (!verifySignature(hash, encoding(der, tbs), issuer.publicKey, signatureBytes)) return null; + + const fields = children(der, tbs); + let i = fields[0]?.tag === 0x02 ? 1 : 0; // version + i += 2; // signature algorithm, issuer + i += 1; // thisUpdate + let nextUpdate: number | undefined; + if (fields[i] && (fields[i].tag === 0x17 || fields[i].tag === 0x18)) { + nextUpdate = parseTime(der, fields[i]); + i++; + } + const revoked = new Set(); + if (fields[i]?.tag === 0x30) { + for (const entry of children(der, fields[i])) { + const serial = children(der, entry)[0]; + if (serial?.tag === 0x02) revoked.add(integerHex(content(der, serial))); + } + } + return { revoked, nextUpdate }; + } catch { + return null; + } +} + +async function isRevoked( + cert: X509Certificate, + issuer: X509Certificate, + fetchCrl: NonNullable, + now: number, +): Promise { + const serial = normalizeSerial(cert.serialNumber); + for (const url of crlUrls(cert)) { + const der = await fetchCrl(url); + if (!der) continue; + const crl = parseCrl(der, issuer); + // A stale CRL still lists what it lists; it just can't clear anything new. + if (!crl) continue; + if (crl.revoked.has(serial)) return true; + if (crl.nextUpdate === undefined || crl.nextUpdate >= now) return false; + } + return false; +} + +export async function validateMarkCertificate(pem: string, options: CertificateOptions): Promise { + const now = options.now ?? Date.now(); + let certs: X509Certificate[]; + try { + certs = splitPem(pem); + } catch { + return { ok: false, reason: 'unparsable PEM' }; + } + const leaves = certs.filter((cert) => !cert.ca && hasBimiEku(cert)); + if (leaves.length !== 1) return { ok: false, reason: leaves.length ? 'more than one mark certificate' : 'no mark certificate' }; + const leaf = leaves[0]; + + // Walk up through the certificates the sender supplied until one is + // signed by a pinned root's key. A root in the PEM itself proves nothing: + // it can only ever be a step on the way, never the end. + const chain = [leaf]; + let root: X509Certificate | undefined; + while (!root && chain.length <= MAX_CHAIN) { + const current = chain[chain.length - 1]; + root = options.roots.find((candidate) => issuedBy(current, candidate)); + if (root) break; + const next = certs.find((cert) => cert.ca && !chain.includes(cert) && issuedBy(current, cert)); + if (!next) return { ok: false, reason: 'chain does not reach a trusted root' }; + chain.push(next); + } + if (!root) return { ok: false, reason: 'chain too long' }; + + for (const cert of [...chain, root]) { + if (!within(cert, now)) return { ok: false, reason: 'certificate outside its validity period' }; + } + // The leaf and the CA that issued it both carry the BIMI key purpose. + const issuing = chain[1] ?? root; + if (!hasBimiEku(leaf) || (issuing !== root && !hasBimiEku(issuing))) return { ok: false, reason: 'missing BIMI key purpose' }; + + if (!sanMatches(sanDnsNames(leaf), options.domains, options.selector)) return { ok: false, reason: 'domain not in certificate' }; + + let extensions: Map; + try { + extensions = certificateExtensions(new Uint8Array(leaf.raw)); + } catch { + return { ok: false, reason: 'unparsable certificate' }; + } + if (!extensions.has(SCT_LIST_OID)) return { ok: false, reason: 'not logged to certificate transparency' }; + const logotype = extensions.get(LOGOTYPE_OID); + let svg: string | null = null; + try { + svg = logotype ? logotypeSvg(logotype) : null; + } catch { + svg = null; + } + if (!svg) return { ok: false, reason: 'no logo in certificate' }; + + if (options.fetchCrl) { + const path = [...chain, root]; + for (let i = 0; i < chain.length; i++) { + if (await isRevoked(path[i], path[i + 1], options.fetchCrl, now)) return { ok: false, reason: 'certificate revoked' }; + } + } + + const markTypeName = attribute(leaf.subject, MARK_TYPE_OID); + return { + ok: true, + mark: { + svg, + markType: markTypeName === 'Prior Use Mark' ? 'common' : 'verified', + organization: attribute(leaf.subject, 'O'), + issuer: attribute(leaf.issuer, 'O'), + notAfter: Date.parse(leaf.validTo), + }, + }; +} diff --git a/lib/bimi/der.ts b/lib/bimi/der.ts new file mode 100644 index 000000000..c9c40b302 --- /dev/null +++ b/lib/bimi/der.ts @@ -0,0 +1,141 @@ +/** + * Just enough DER to read the parts of a mark certificate and its CRL that + * node:crypto's X509Certificate doesn't expose: extensions by OID, the + * logotype's data: URI and the serial numbers of a certificate list. + * Definite lengths only, as DER requires. + */ + +export interface DerNode { + tag: number; + /** Offset of the tag byte in the buffer. */ + start: number; + /** Offset of the first content byte. */ + contentStart: number; + /** Offset just past the content. */ + end: number; +} + +export class DerError extends Error {} + +export function readNode(buf: Uint8Array, offset: number, limit = buf.length): DerNode { + if (offset + 2 > limit) throw new DerError('truncated'); + const tag = buf[offset]; + // Multi-byte tag numbers never occur in certificates. + if ((tag & 0x1f) === 0x1f) throw new DerError('long tag'); + let length = buf[offset + 1]; + let contentStart = offset + 2; + if (length & 0x80) { + const count = length & 0x7f; + if (count === 0 || count > 4 || contentStart + count > limit) throw new DerError('bad length'); + length = 0; + for (let i = 0; i < count; i++) length = length * 256 + buf[contentStart + i]; + contentStart += count; + } + const end = contentStart + length; + if (end > limit) throw new DerError('truncated'); + return { tag, start: offset, contentStart, end }; +} + +/** The children of a constructed node. */ +export function children(buf: Uint8Array, node: DerNode): DerNode[] { + if (!(node.tag & 0x20)) throw new DerError('not constructed'); + const out: DerNode[] = []; + let offset = node.contentStart; + while (offset < node.end) { + const child = readNode(buf, offset, node.end); + out.push(child); + offset = child.end; + } + return out; +} + +export function content(buf: Uint8Array, node: DerNode): Uint8Array { + return buf.subarray(node.contentStart, node.end); +} + +/** The whole encoding of a node, tag and length included. */ +export function encoding(buf: Uint8Array, node: DerNode): Uint8Array { + return buf.subarray(node.start, node.end); +} + +export function decodeOid(bytes: Uint8Array): string { + if (bytes.length === 0) throw new DerError('empty oid'); + const parts: number[] = []; + let value = 0; + for (const byte of bytes) { + value = value * 128 + (byte & 0x7f); + if (!(byte & 0x80)) { + parts.push(value); + value = 0; + } + } + const first = parts.shift()!; + const head = first < 80 ? [Math.floor(first / 40), first % 40] : [2, first - 80]; + return [...head, ...parts].join('.'); +} + +/** + * A serial number as upper-case hex without leading zeros. X509Certificate + * keeps whole bytes ("0BADC0DE"), DER may add a zero byte for the sign, so + * both sides go through this before they are compared. + */ +export function normalizeSerial(hex: string): string { + return hex.toUpperCase().replace(/^0+(?=.)/, ''); +} + +/** An INTEGER as a normalized serial (see normalizeSerial). */ +export function integerHex(bytes: Uint8Array): string { + return normalizeSerial(Buffer.from(bytes).toString('hex')); +} + +const TAG_SEQUENCE = 0x30; +const TAG_OID = 0x06; +const TAG_BOOLEAN = 0x01; +const TAG_OCTET_STRING = 0x04; +const TAG_EXTENSIONS = 0xa3; + +/** + * The extnValue contents of every extension of a certificate, by OID. + * Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signature }, + * and the extensions are the [3] field of tbsCertificate. + */ +export function certificateExtensions(der: Uint8Array): Map { + const out = new Map(); + const cert = readNode(der, 0); + if (cert.tag !== TAG_SEQUENCE) throw new DerError('not a certificate'); + const [tbs] = children(der, cert); + if (!tbs || tbs.tag !== TAG_SEQUENCE) throw new DerError('no tbsCertificate'); + const wrapper = children(der, tbs).find((node) => node.tag === TAG_EXTENSIONS); + if (!wrapper) return out; + const [list] = children(der, wrapper); + if (!list || list.tag !== TAG_SEQUENCE) throw new DerError('bad extensions'); + for (const extension of children(der, list)) { + const parts = children(der, extension); + const oid = parts[0]; + const value = parts.find((node, i) => i > 0 && node.tag === TAG_OCTET_STRING); + if (oid?.tag !== TAG_OID || !value) continue; + // A boolean (critical) may sit between the two. + if (parts.length > 3 || (parts.length === 3 && parts[1].tag !== TAG_BOOLEAN)) continue; + out.set(decodeOid(content(der, oid)), content(der, value)); + } + return out; +} + +/** Every primitive node with `tag` anywhere below `node`, in order. */ +export function findAll(buf: Uint8Array, node: DerNode, tag: number, out: DerNode[] = [], depth = 0): DerNode[] { + if (depth > 32) throw new DerError('too deep'); + if (node.tag === tag) { + out.push(node); + return out; + } + if (node.tag & 0x20) { + for (const child of children(buf, node)) findAll(buf, child, tag, out, depth + 1); + } + return out; +} + +/** The text of every string with `tag` (IA5String 0x16, a [6] URI 0x86) inside an extension value. */ +export function stringsIn(value: Uint8Array, tag: number): string[] { + const root = readNode(value, 0); + return findAll(value, root, tag).map((node) => Buffer.from(content(value, node)).toString('latin1')); +} diff --git a/lib/bimi/lookup.ts b/lib/bimi/lookup.ts new file mode 100644 index 000000000..287a1bb0f --- /dev/null +++ b/lib/bimi/lookup.ts @@ -0,0 +1,293 @@ +import { resolveTxt } from 'node:dns/promises'; +import type { X509Certificate } from 'node:crypto'; +import { fetchPublicUrl } from '@/lib/security/url-guard'; +import { getRootDomain } from '@/lib/root-domain'; +import { logger } from '@/lib/logger'; +import { validateMarkCertificate, type MarkType } from './certificate'; +import { dmarcAllowsBimi, pickBimiRecord, pickDmarcRecord } from './records'; +import { bimiRoots } from './roots'; +import { checkSvg, rasterize, MAX_SELF_ASSERTED_BYTES } from './svg'; + +/** + * The receiver side of BIMI, which Stalwart doesn't do: for a From domain, + * check its DMARC policy, find its assertion record, validate the mark + * certificate when there is one, and turn the logo into a PNG. + * + * The caller decides whether the message itself passed DMARC; this only + * answers per domain, so one lookup serves every message from it. + */ + +export type BimiStatus = 'verified' | 'self-asserted'; + +export interface BimiIndicator { + /** `verified`: a VMC or CMC backs the logo. `self-asserted`: only the DNS record does. */ + status: BimiStatus; + png: Buffer; + markType?: MarkType; + organization?: string; + issuer?: string; +} + +const SELECTOR = 'default'; +const MAX_PEM_BYTES = 128 * 1024; +const MAX_CRL_BYTES = 2 * 1024 * 1024; +const FETCH_TIMEOUT_MS = 5000; +const MAX_REDIRECTS = 3; +const POSITIVE_TTL_MS = 24 * 60 * 60 * 1000; +const NEGATIVE_TTL_MS = 6 * 60 * 60 * 1000; +/** After a DNS or network failure: try again soon rather than hide the logo for hours. */ +const ERROR_TTL_MS = 10 * 60 * 1000; +const CRL_TTL_MS = 6 * 60 * 60 * 1000; +const CACHE_MAX = 2000; +const CRL_CACHE_MAX = 50; +/** Concurrent uncached lookups; over it a domain is simply missing for now. */ +const MAX_IN_FLIGHT = 16; + +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +/** A host name we may look up: public-looking, lower case, no trailing dot. */ +export function normalizeBimiDomain(domain: string | null | undefined): string | null { + const lower = domain?.trim().toLowerCase().replace(/\.$/, ''); + if (!lower || lower.length > 253 || !DOMAIN_RE.test(lower)) return null; + // Names a local resolver may answer for the LAN rather than the sender. + if (/(^|\.)(localhost|local|internal|lan|home|arpa)$/.test(lower)) return null; + return lower; +} + +class TemporaryError extends Error {} + +export interface BimiResolverDeps { + resolveTxt: (name: string) => Promise; + /** GET a URL with a byte ceiling; null on 4xx/5xx, throws on network failure. */ + fetchBytes: (url: string, maxBytes: number, allowHttp: boolean) => Promise; + roots: () => readonly X509Certificate[]; + now: () => number; +} + +async function readCapped(body: ReadableStream | null, maxBytes: number): Promise { + if (!body) return new Uint8Array(); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => {}); + return null; + } + chunks.push(value); + } + return new Uint8Array(Buffer.concat(chunks)); +} + +async function defaultFetchBytes(url: string, maxBytes: number, allowHttp: boolean): Promise { + let current = url; + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const parsed = new URL(current); + if (parsed.protocol !== 'https:' && !(allowHttp && parsed.protocol === 'http:')) return null; + // fetchPublicUrl refuses private addresses at connect time; the URLs + // come from DNS records any sender controls. + const get = () => fetchPublicUrl(current, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { 'User-Agent': 'Bulwark-Webmail-BIMI' }, + }); + let response: Awaited>; + try { + response = await get(); + } catch (err) { + // Some logo hosts reset the first connection now and then (seen with + // Valimail's); one more try saves showing no logo for ten minutes. + if ((err as { cause?: { code?: string } }).cause?.code !== 'ECONNRESET') throw err; + response = await get(); + } + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + await response.body?.cancel().catch(() => {}); + if (!location) return null; + current = new URL(location, current).href; + continue; + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + return null; + } + return readCapped(response.body as ReadableStream | null, maxBytes); + } + return null; +} + +async function defaultResolveTxt(name: string): Promise { + return resolveTxt(name); +} + +const defaultDeps: BimiResolverDeps = { + resolveTxt: defaultResolveTxt, + fetchBytes: defaultFetchBytes, + roots: bimiRoots, + now: () => Date.now(), +}; + +interface CacheEntry { + indicator: BimiIndicator | null; + expiresAt: number; +} + +export function createBimiResolver(overrides: Partial = {}) { + const deps = { ...defaultDeps, ...overrides }; + const cache = new Map(); + const crlCache = new Map(); + const inFlight = new Map>(); + + function remember(domain: string, indicator: BimiIndicator | null, ttl: number) { + cache.delete(domain); + // Map keeps insertion order: the first key is the oldest. + while (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!); + cache.set(domain, { indicator, expiresAt: deps.now() + ttl }); + } + + async function txt(name: string): Promise { + try { + return (await deps.resolveTxt(name)).map((chunks) => chunks.join('')); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOTFOUND' || code === 'ENODATA') return []; + throw new TemporaryError(`DNS ${code ?? 'error'} for ${name}`); + } + } + + async function fetchCrl(url: string): Promise { + const cached = crlCache.get(url); + if (cached && cached.expiresAt > deps.now()) return cached.bytes; + let bytes: Uint8Array | null = null; + try { + bytes = await deps.fetchBytes(url, MAX_CRL_BYTES, true); + } catch { + bytes = null; + } + while (crlCache.size >= CRL_CACHE_MAX) crlCache.delete(crlCache.keys().next().value!); + // An unreachable CRL is asked for again after the error delay, not hours later. + crlCache.set(url, { bytes, expiresAt: deps.now() + (bytes ? CRL_TTL_MS : ERROR_TTL_MS) }); + return bytes; + } + + async function fetchText(url: string, maxBytes: number): Promise { + try { + const bytes = await deps.fetchBytes(url, maxBytes, false); + return bytes ? Buffer.from(bytes).toString('utf8') : null; + } catch (err) { + // undici reports "fetch failed" and keeps the reason in `cause`. + const cause = (err as { cause?: { code?: string; message?: string } }).cause; + const reason = cause ? ` (${cause.code ?? cause.message})` : ''; + throw new TemporaryError(`fetch failed for ${url}: ${(err as Error).message}${reason}`); + } + } + + async function lookup(domain: string): Promise<{ indicator: BimiIndicator | null; ttl: number }> { + const none = { indicator: null, ttl: NEGATIVE_TTL_MS }; + const organizational = getRootDomain(domain); + const isSubdomain = organizational !== domain; + + const [authorDmarc, orgDmarc] = await Promise.all([ + txt(`_dmarc.${domain}`).then(pickDmarcRecord), + isSubdomain ? txt(`_dmarc.${organizational}`).then(pickDmarcRecord) : Promise.resolve(null), + ]); + if (!dmarcAllowsBimi(authorDmarc, isSubdomain ? orgDmarc : authorDmarc, isSubdomain)) return none; + + // The author domain's record, else the organizational domain's. A record + // that declines BIMI stops the search. + let record = pickBimiRecord(await txt(`${SELECTOR}._bimi.${domain}`)); + if (!record && isSubdomain) record = pickBimiRecord(await txt(`${SELECTOR}._bimi.${organizational}`)); + if (!record || record.declined) return none; + + let svg: string | null; + let indicator: Omit; + let ttl = POSITIVE_TTL_MS; + if (record.evidenceUrl) { + const pem = await fetchText(record.evidenceUrl, MAX_PEM_BYTES); + if (!pem) return none; + const check = await validateMarkCertificate(pem, { + domains: [...new Set([domain, organizational])], + selector: SELECTOR, + roots: deps.roots(), + now: deps.now(), + fetchCrl, + }); + // An evidence document that doesn't validate fails BIMI outright; the + // self-published logo next to it doesn't count instead. + if (!check.ok) { + logger.debug('BIMI certificate rejected', { domain, reason: check.reason }); + return none; + } + svg = check.mark.svg; + indicator = { + status: 'verified', + markType: check.mark.markType, + organization: check.mark.organization, + issuer: check.mark.issuer, + }; + ttl = Math.max(0, Math.min(ttl, check.mark.notAfter - deps.now())); + } else { + if (!record.logoUrl) return none; + svg = await fetchText(record.logoUrl, MAX_SELF_ASSERTED_BYTES); + if (!svg) return none; + indicator = { status: 'self-asserted' }; + } + + const problem = checkSvg(svg, { strictProfile: indicator.status === 'self-asserted' }); + if (problem) { + logger.debug('BIMI logo rejected', { domain, reason: problem }); + return none; + } + let png: Buffer; + try { + png = await rasterize(svg); + } catch { + return none; + } + return { indicator: { ...indicator, png }, ttl }; + } + + /** + * The indicator for a From domain, or null. Cached per domain; concurrent + * calls for one domain share a lookup. + */ + async function resolve(rawDomain: string): Promise { + const domain = normalizeBimiDomain(rawDomain); + if (!domain) return null; + const cached = cache.get(domain); + if (cached && cached.expiresAt > deps.now()) return cached.indicator; + const pending = inFlight.get(domain); + if (pending) return pending; + if (inFlight.size >= MAX_IN_FLIGHT) return cached?.indicator ?? null; + + const run = (async () => { + try { + const { indicator, ttl } = await lookup(domain); + remember(domain, indicator, ttl); + return indicator; + } catch (err) { + if (err instanceof TemporaryError) logger.debug('BIMI lookup failed, retrying later', { domain, error: err.message }); + else logger.warn('BIMI lookup failed', { domain, error: (err as Error).message }); + // Keep showing what we had; otherwise ask again after a short pause. + const fallback = cached?.indicator ?? null; + remember(domain, fallback, ERROR_TTL_MS); + return fallback; + } finally { + inFlight.delete(domain); + } + })(); + inFlight.set(domain, run); + return run; + } + + return { resolve, clear: () => { cache.clear(); crlCache.clear(); } }; +} + +let shared: ReturnType | null = null; + +export function bimiResolver() { + if (!shared) shared = createBimiResolver(); + return shared; +} diff --git a/lib/bimi/records.ts b/lib/bimi/records.ts new file mode 100644 index 000000000..910f69ed3 --- /dev/null +++ b/lib/bimi/records.ts @@ -0,0 +1,110 @@ +/** + * The DNS side of BIMI: the assertion record at ._bimi. + * and the DMARC policy that has to back it. Pure parsing; lib/bimi/lookup.ts + * does the queries. + */ + +export interface BimiRecord { + /** The indicator (SVG) location, https only. Undefined when the tag is empty or missing. */ + logoUrl?: string; + /** The evidence document (mark certificate PEM) location, https only. */ + evidenceUrl?: string; + /** l= and a= both empty: the domain asks for no indicator. */ + declined: boolean; +} + +export type DmarcPolicy = 'none' | 'quarantine' | 'reject'; + +export interface DmarcRecord { + p: DmarcPolicy; + sp?: DmarcPolicy; + pct: number; +} + +function tags(record: string): Map { + const out = new Map(); + for (const part of record.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const name = part.slice(0, eq).trim().toLowerCase(); + if (name && !out.has(name)) out.set(name, part.slice(eq + 1).trim()); + } + return out; +} + +function httpsUrl(value: string | undefined): string | undefined { + // Early drafts allowed a comma-separated list; the first entry counts. + const first = value?.split(',')[0]?.trim(); + if (!first) return undefined; + try { + const url = new URL(first); + return url.protocol === 'https:' ? url.href : undefined; + } catch { + return undefined; + } +} + +function isBimi(record: string): boolean { + return /^v\s*=\s*BIMI1\s*(;|$)/i.test(record.trim()); +} + +function isDmarc(record: string): boolean { + return /^v\s*=\s*DMARC1\s*(;|$)/i.test(record.trim()); +} + +/** + * The one record that starts with `v=BIMI1` among the TXT answers. Zero or + * several make it null: a domain with a wildcard TXT answers BIMI queries + * with its SPF record, and two assertion records are an error. + */ +export function pickBimiRecord(answers: readonly string[]): BimiRecord | null { + const records = answers.filter(isBimi); + if (records.length !== 1) return null; + const parsed = tags(records[0]); + const logo = parsed.get('l') ?? ''; + const evidence = parsed.get('a') ?? ''; + const logoUrl = httpsUrl(logo); + const evidenceUrl = httpsUrl(evidence); + // A tag that names a non-https location is invalid, not a declination. + if ((logo && !logoUrl) || (evidence && !evidenceUrl)) return null; + return { logoUrl, evidenceUrl, declined: !logoUrl && !evidenceUrl }; +} + +function policy(value: string | undefined): DmarcPolicy | undefined { + const lower = value?.toLowerCase(); + return lower === 'none' || lower === 'quarantine' || lower === 'reject' ? lower : undefined; +} + +/** The one DMARC record among the TXT answers, or null. */ +export function pickDmarcRecord(answers: readonly string[]): DmarcRecord | null { + const records = answers.filter(isDmarc); + if (records.length !== 1) return null; + const parsed = tags(records[0]); + const p = policy(parsed.get('p')); + if (!p) return null; + const pctText = parsed.get('pct'); + const pct = pctText === undefined ? 100 : Number(pctText); + return { p, sp: policy(parsed.get('sp')), pct: Number.isInteger(pct) ? pct : 0 }; +} + +/** + * Whether the published DMARC policy is strict enough for BIMI: quarantine + * or reject for every message (pct=100), and for a subdomain an + * organizational domain that isn't p=none either. + * + * `author` is the record at _dmarc., `organizational` the one + * at _dmarc.; pass the same record twice when they + * are the same domain. + */ +export function dmarcAllowsBimi( + author: DmarcRecord | null, + organizational: DmarcRecord | null, + isSubdomain: boolean, +): boolean { + const applied = author ?? (isSubdomain ? organizational : null); + if (!applied) return false; + const effective = author ? author.p : (applied.sp ?? applied.p); + if (effective === 'none' || applied.pct !== 100) return false; + if (isSubdomain && organizational && organizational.p === 'none') return false; + return true; +} diff --git a/lib/bimi/roots.ts b/lib/bimi/roots.ts new file mode 100644 index 000000000..af554e889 --- /dev/null +++ b/lib/bimi/roots.ts @@ -0,0 +1,158 @@ +import { X509Certificate } from 'node:crypto'; + +/** + * Root CAs of the Mark Verifying Authorities whose VMCs and CMCs we accept. + * Each was downloaded from the CA itself (the URL above it) and pinned here; + * the root a sender's PEM carries is never trusted on its own. SSL.com's + * roots also anchor the mark certificates Sectigo issues. + * + * To add a root, take it from the CA's repository, not from a sender's PEM, + * and list its SHA-256 fingerprint here as well. + */ +const ROOT_PEMS: readonly { name: string; sha256: string; pem: string }[] = [ + // From cacerts.digicert.com/DigiCertVerifiedMarkRootCA.crt + { + name: 'DigiCert Verified Mark Root CA', + sha256: '50:43:86:C9:EE:89:32:FE:CC:95:FA:DE:42:7F:69:C3:E2:53:4B:73:10:48:9E:30:0F:EE:44:8E:33:C4:6B:42', + pem: `-----BEGIN CERTIFICATE----- +MIIF3jCCA8agAwIBAgIQBsFnz+v0jTXWJBAYXhHF6zANBgkqhkiG9w0BAQsFADCB +iDELMAkGA1UEBhMCVVMxDTALBgNVBAgTBFV0YWgxDTALBgNVBAcTBExlaGkxFzAV +BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t +MScwJQYDVQQDEx5EaWdpQ2VydCBWZXJpZmllZCBNYXJrIFJvb3QgQ0EwHhcNMTkw +OTIzMTIxMjA2WhcNNDkwOTIzMTIxMjA2WjCBiDELMAkGA1UEBhMCVVMxDTALBgNV +BAgTBFV0YWgxDTALBgNVBAcTBExlaGkxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu +MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMScwJQYDVQQDEx5EaWdpQ2VydCBW +ZXJpZmllZCBNYXJrIFJvb3QgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDawvvIO7cL04ptZxgLw/YwqDuluiFsMvGsr+vZcfq5c3hKuX0uMrslza91 +OFB6SPmbkG2hLErOcaVH0nMnG0RE3AM6dpfhw7qU+n3c6XPS7HlO9ZC57GJeaOXy +b0cmcK2G96WC/VRuB1ZgjqYoq6PP4yjn/DB/Pc+7kjwJ2EDH5BFEnywVq4rH1a+Q +AbVDpxJfCfQZV1VKW+JNtO/KKKX+NlPrtHroSgKiRZ019oWptImyfgpg7j6FNNAT +R8uPsvU5zYJyCDOxKv4MqllMJmUVwGUHF61WnbiZeJsxzb5H5wMpikX4mfdKaIm0 +ym2QsHVRazST1bIVvAZThcKPd2EnysQi6XpYpMcpiSRo58ENXZW47M/Ocu7mBCLP +TJEPEC9YG2aCfHxFSz/n6xZR+1rvNPUxcLZ+FNOwZRnHqcqe5TDNQewoC8/AWR0O +dKqu2WgBF40ncXmtm5QnYhlTmBcoPUWfR40bCLJsm4fV2B4hkC5ZCHV/91jpsv7j +hsGkpQpY6n9XWBABW6ZGQWM4jXxybbNmb3u21xx8rEkaIh22is08i41xeV9iLYec +Pup6npZnZbiKSOEFQ3WAwzi3TtABmRknOMybFJKSlJQXMfHqENfwKpNvMMRVO8Pl +J+Oh6AN8l75vZaFF27gqBhbmjJ2Y9ioqTI7g+Dg4qClUQqXPCQIDAQABo0IwQDAd +BgNVHQ4EFgQU7G8ipLME4sFjh+Z3Y+pGaU7u/OswDgYDVR0PAQH/BAQDAgGGMA8G +A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBAC832YLVevVWINnr3vWC +XNvLPtmPOPLKO5cHupQpkcug+IOli2FAxnC8JDlbOT6hiMK7MYaurag9QvDI/As0 +4cNOa+4sqKCxQR3aLEyyqeLA4WdA6UFIHdMSIzLHZylzjuwciI706x83Ib17DMKO +cpO2QVB7Beqv240TWxKxH21pFZsl44OgI+HcAPDbfJe3PEzwEZKNcKRkMWa/FFu2 +ckQxpTcfZABrarnuRLcSINiodSW7VfxctzegXWM4WmQeutPBOicceV3J4ZVkhthB +m784vES1DIuDTqT9/iqStBGN8eOGx9qKvjaXT8SdcrP58FpXrtm/xKgtILptxfVT +042oogQfb2cNahKRSvs0xH3jyhO944t0zMH/bEpRdU36wR1/Fo56zXy2Zv4czMwg +3Hg7mbAalJvcnBvH+NHPgucQI432XX11K29vz7HuNC7P9yKhxns+MbOQDMDPOhtS +LUpBmzRNG4+2BZJZyKGqYd+STHisEGYeYCi3MVrwSe2UqcDi9f2UAWVbkDE/YB6/ +e7+C7o6UWkXSU7dzR7FwFsfBHi6EqgIb2e9pINAxdvlc/3E19Ld/GJEtlw7nSdzp +71eMp5Z48iY54fV2lM/rXogS1R4r3p2oPe9efG0XaJMd0v1gom5Da/khJA7+wjRB +0wberd/tg3N0dJsSSznZjwYB +-----END CERTIFICATE-----`, + }, + // From secure.globalsign.com/cacert/gsverifiedmarkrootr42.crt + { + name: 'GlobalSign Verified Mark Root R42', + sha256: 'CD:12:2C:B8:77:C6:92:8B:90:17:B0:F0:B8:0D:BD:50:81:96:30:0B:BD:03:CD:73:56:C3:BE:EF:52:4E:7E:0B', + pem: `-----BEGIN CERTIFICATE----- +MIIFdDCCA1ygAwIBAgIQf+UwA4GYp199F8APJCyr8zANBgkqhkiG9w0BAQwFADBU +MQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1zYTEqMCgGA1UE +AxMhR2xvYmFsU2lnbiBWZXJpZmllZCBNYXJrIFJvb3QgUjQyMB4XDTIzMTExNTAz +NDA1MloXDTQyMTExNTAwMDAwMFowVDELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEds +b2JhbFNpZ24gbnYtc2ExKjAoBgNVBAMTIUdsb2JhbFNpZ24gVmVyaWZpZWQgTWFy +ayBSb290IFI0MjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANzqTcvu +bRbqX0CzP7sZbHfd1usqWOPrHzoLvbZTnJqt9WgBwQwcGEFl8rmIcOss7XmewAqj +oCx7PQp1FJ+/o/yGezMbnPCglAVwWptUOMIa7UJ/8qrV50/qQSRN8s8MAcnhj3Ab +Ja/mVjlMHzjYCo0O+jc24uHlEHcIo3vJPsP2Sy9HwcMH4wB2rQ4xDN7kAn5NmqXI +xAvSnJ2KGrZsoNx8MGAfX5dl0ACOpaaqbonBGtawvRGAE6f747E+pUamjGjisQ8y +ZC0X5Ht1s53sliy9AHDpOUWVcCLOoQCP/Y8yDjkjE1VWiL1Y7LlCnYfAVfY87DRD +81toIOLjFNACxeG02Qvgzg0JMKh81seiZfSLbq8DgepDABUTs8WYpN/u52/kdM+/ +UeaQEn+q2HDDI8OMmBqwVo98k+dCQ0QgKLkHaLxpm5EjacSkNZFeqp1ttDZsGKQt +9REPJxpY6OO9KSYuFNFpnmLfaDNA/VngLw+Z4hYH59RnE2p3s95CfUa+CT4BUvDy +Wefc9RNiJVppbQNiZ4DKKpixVfXptgukbnh5l7vyDVonKGdgNEZeN5YjopM5V2+J +eIrsC3u0OmDV41cSFfe/1dmoUhy5EfTgWXqLOOgoUxqy62Yl7DDwoyXerEzDzZAq +nSBE/HAra3/d5pdj8/5WXoybjbXYM/bsKALLAgMBAAGjQjBAMA4GA1UdDwEB/wQE +AwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQ9s+P1IK6506ybFLxwJe4P +Cn6VyTANBgkqhkiG9w0BAQwFAAOCAgEA24nHkncSyqeuk1lkpRq/MLVHdVONJnZ6 +t8OZEu3nr6DHAuImAonIqO8HcBcgwydDIAeD7GOafOZ47mRkYOFIa+tkJ21pfmJv +KUt3ASW1uLAHCJNYHVLXs7txnq1mRKuwyuaeb6JdTazzjXgNDLZQgvOfM7mkDf6f +S2Z+dbKOZBYwo6zspqoQc1s6k0Xg9h7oEiJCC+3FUfJQSYyi2bynZSEn2c67OSFT +fqXVr9nhlaawLGEKUaFlYeQspUO5MUFYmGWqnf38M8ZM0rPx5R00jZ/E5sKcSqFH +CaVghDPiVv9Jzs8gNllwDbi7rXwMstaKATJJXiWgSdsxrfQr7+FRyXQJmRVXKxZ6 +6uNe1ZgAzgNbjCvGmwF1StBtr1DrdOSk7sOMBMuJftB2g9/K67VUfz+jkylIbv5y +bvnCqBoAvq2DSQ76O5WyWiTBkPYp2Fh/WMHkXVIQpT6Q7FMTiGxtAxv69sq1CeJ9 +i9g/iAJBdToQwJwH80Ah3hSPg1DQ8LTIjTtOxxkJ6Aeh/XU1/w9lW91XGLgXye1U +fjUIWKsoe1rE2RK+RSP6BZWcRNhJOyqi40gdTRLJkbc0Itq/62NFV4mIWcWKafhb +4ZOU1rvk1woeteJJZHd7cLakW6ONJAEtWGe43ZVx/lqFXQH4kOMPuR/7h2G1Aie6 +Fk6hsIjUv20= +-----END CERTIFICATE-----`, + }, + // From repo-certs.ssl.com/repo/certs/SSL.com-VMC-Root-2024-RSA.pem + { + name: 'SSL.com VMC RSA Root CA 2024', + sha256: '8F:9D:1B:76:98:88:67:82:A5:99:B4:85:10:65:1C:66:A1:AA:0C:5C:A3:19:20:97:BD:C6:85:34:15:4B:D3:0D', + pem: `-----BEGIN CERTIFICATE----- +MIIFiTCCA3GgAwIBAgIQcJRvrr6zzODYb7h2d4BhyzANBgkqhkiG9w0BAQsFADBO +MQswCQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQD +DBxTU0wuY29tIFZNQyBSU0EgUm9vdCBDQSAyMDI0MB4XDTI0MDIxOTE2MjU1MVoX +DTQ4MDIxMzE2MjU1MFowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jw +b3JhdGlvbjElMCMGA1UEAwwcU1NMLmNvbSBWTUMgUlNBIFJvb3QgQ0EgMjAyNDCC +AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAIyEglUdOfMiwwS13+Q9dho5 +swa3HbdPZByWNPwgD1tDdPYyCdox2F0bJxfrBnUFKCf46SZV7DESkn/YTKj7tMSk +Vr4c9GzNbr7WWSMprVEsgDrwDw/RM9YcwsFNlx1PFt+gDQhitA/ezIRO0uextBp4 +0/HP7HQ7ZeOKVB0W71WoBty6TBCN+l5LVQGGDSvRGmQndf9zsmITQpddp1xAbUMA +uxFnkS22vYCHdEWzSVuEWLakVh53qcYF7krDZY82kIurOC1wEwGNkdsfQ0HLpf8Y +VhHMOm/JoCOXcA79nvjehykZb+7c1zgPgUjRd4d/UbwoDBy0Y4GrGw4Q+c0bscz/ +xlrxf+SYIozASUEFjNcfvOkPCLlTHj7V8yhQRm58Q36/GdnahwLgdRwhk4WP5p7W +tkxJrHIvdsQJgIH9OVL089MHfOHQHuAcxV54+/YKr/AT6m3pgr+Zs067rQYMMc/k +TCGial39KMnkHydy8AduW+17U1auIpqt7XOtroC46IMbL2zVQDY1lyvUfvwc+5Kt +fjYVpnsle7g+fkEupWvbQHCWZR4iE9jFNR6ETF2damAMx+yuFP+dfyc4CAZUoGwz +0+ZhUIYuTeEMCPSuZAvHy0ugTPEuSc5LKnJ5JK+bOeqtbRkQVmFC74fkAqhkCfzt +vkckHbTCI5ukoeKueCRJAgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0j +BBgwFoAUwTWi9FnZB3aX/croa3fWJq56WvswHQYDVR0OBBYEFME1ovRZ2Qd2l/3K +6Gt31iauelr7MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOCAgEAIet3 ++K2+sef6SZBE+5cFsnKYtagi50I1iECJQziusyw1wK9Keman4TZshwio4Z/nvaq2 +ed3s3YYFL6BGksJTJPImrEaQI3aqHa16AEzw6F2k3gz78wbcpjygGJMekVrhyF5l +E+ErRcY9JGo1IGscBYlZcXkHJThNO9gEyoH+os73KIsY4120CBe3jbfOhRpDZioF +wUxepdzEzx8ZRXGsz58m+0NS0cBcvkqpIpnI2TFDT/lK0s2ys65h2YLhDsUnhtJ2 +Kd8boP+ImazgpfpjgVEeCCpoPzWmmlMzxr083f0Mya4uSPidk/FkVYo3l+NAUcfH +WcfH6ca7Xrm5DXsOaP0cDWaYphbEKVVi164V03fNClW3p7xzLCDUfzdAaQs1JY1G +h0GgH4dNdp+BgGjjUPZ86IZhCuOq0rm3czfxb25l6WuAf7A0gq4oVgMNbbIepap8 +YRbbU3nGl4A+s6qjtIhy6QGVWYqwQVElP7ba1sbhqLu5Uu/QyI/B5uEBO3oTvNgc +pld8jFKTqIO9yFgzvIO6XhfwW5VvLHozlzb7fM8hPKnrdd5HT3XnFTkKl/y4zLrw +OnvNhh5QMQe+HEnpScufX/cFPh568bVicV061IvLoGr84rlgxO+RjWuILOiXCVi0 +7JsQLmpgD+ebpTit7HrVpCJa1SZXeHlzkc/yI74= +-----END CERTIFICATE-----`, + }, + // From repo-certs.ssl.com/repo/certs/SSL.com-VMC-Root-2024-ECC.pem + { + name: 'SSL.com VMC ECC Root CA 2024', + sha256: '1B:82:E7:F4:91:0B:51:E3:E8:02:A4:93:AC:DC:17:FF:58:EA:C8:B9:EB:7C:09:B5:2A:C6:CD:2E:FB:83:59:8C', + pem: `-----BEGIN CERTIFICATE----- +MIICOjCCAcCgAwIBAgIQEUfBai0/SvdnXWXlwaytjjAKBggqhkjOPQQDAzBOMQsw +CQYDVQQGEwJVUzEYMBYGA1UECgwPU1NMIENvcnBvcmF0aW9uMSUwIwYDVQQDDBxT +U0wuY29tIFZNQyBFQ0MgUm9vdCBDQSAyMDI0MB4XDTI0MDIxOTE2MjUzMFoXDTQ4 +MDIxMzE2MjUyOVowTjELMAkGA1UEBhMCVVMxGDAWBgNVBAoMD1NTTCBDb3Jwb3Jh +dGlvbjElMCMGA1UEAwwcU1NMLmNvbSBWTUMgRUNDIFJvb3QgQ0EgMjAyNDB2MBAG +ByqGSM49AgEGBSuBBAAiA2IABAlYNKIok2H2MoERpO66/tfLns/aRbbVN8HhNGbe +znXOJhQozKiHoCOCN7bCCuMdpAXVosUptXs+8Sksd8Hluh91J1W1QQVi9KyFSwgM +VTAynuA64X9MbrQNwkZaCQXPtKNjMGEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSME +GDAWgBQl8MYq5N4ipgAt8VK4J42gUeKgpTAdBgNVHQ4EFgQUJfDGKuTeIqYALfFS +uCeNoFHioKUwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMHkYwAy0 +rQ/BXQCanvUeTylOOCbaIO+rbFxOY8KF+AHmv0MJI7t++BnWUE1XwYABXAIxAKbi +1Mpz6UpoC5yWsed9Z1rhT+pMwzPwNrdsRMLAesSq2Jbv0UGQ/1uJcM/6OtDFgQ== +-----END CERTIFICATE-----`, + }, +]; + +let parsed: X509Certificate[] | null = null; + +export function bimiRoots(): X509Certificate[] { + if (!parsed) { + parsed = ROOT_PEMS.map(({ name, sha256, pem }) => { + const cert = new X509Certificate(pem); + if (cert.fingerprint256 !== sha256) throw new Error(`BIMI root ${name} does not match its pinned fingerprint`); + return cert; + }); + } + return parsed; +} diff --git a/lib/bimi/svg.ts b/lib/bimi/svg.ts new file mode 100644 index 000000000..039ffc410 --- /dev/null +++ b/lib/bimi/svg.ts @@ -0,0 +1,95 @@ +import sharp from 'sharp'; + +/** + * BIMI indicators are SVG Tiny Portable/Secure (draft-svg-tiny-ps-abrotman): + * no script, no animation, nothing loaded from elsewhere. The browser never + * gets the sender's SVG: it is checked here and drawn to a PNG, so whatever + * the checks miss can't run or call out on the webmail origin anyway. + */ + +/** The size the BIMI Group recommends as the ceiling for a self-published logo. */ +export const MAX_SELF_ASSERTED_BYTES = 32 * 1024; +/** Mark certificates carry larger logos now and then; the CA checked those. */ +export const MAX_CERTIFIED_BYTES = 64 * 1024; +/** Avatars are at most 48 CSS px; this covers 4x screens. */ +export const INDICATOR_SIZE = 192; + +const FORBIDDEN_ELEMENTS = new Set([ + 'script', 'foreignobject', 'image', 'iframe', 'video', 'audio', 'canvas', 'embed', 'object', + 'animate', 'animatemotion', 'animatetransform', 'animatecolor', 'set', 'discard', 'handler', 'listener', +]); + +export interface SvgCheckOptions { + /** + * Hold the logo to the profile itself: baseProfile="tiny-ps", a title and + * the size ceiling. For self-published logos; a mark certificate's logo was + * checked by the CA that issued it. + */ + strictProfile: boolean; +} + +function attributesOf(tag: string): Map { + const out = new Map(); + for (const m of tag.matchAll(/([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) { + out.set(m[1].toLowerCase(), m[3] ?? m[4] ?? ''); + } + return out; +} + +/** Why the SVG can't be shown, or null when it can. */ +export function checkSvg(svg: string, { strictProfile }: SvgCheckOptions): string | null { + const bytes = Buffer.byteLength(svg, 'utf8'); + if (bytes > (strictProfile ? MAX_SELF_ASSERTED_BYTES : MAX_CERTIFIED_BYTES)) return 'too large'; + // Entities can expand without bound or pull in files. + if (/]*>/i.exec(svg); + if (!root) return 'not svg'; + const rootAttributes = attributesOf(root[0]); + + for (const m of svg.matchAll(/<\s*([\w:.-]+)/g)) { + const name = m[1].toLowerCase().replace(/^[\w.-]+:/, ''); + if (FORBIDDEN_ELEMENTS.has(name)) return `forbidden element <${name}>`; + } + for (const m of svg.matchAll(/<[^>]+>/g)) { + for (const [name, value] of attributesOf(m[0])) { + if (/^on/.test(name)) return 'event handler'; + if ((name === 'href' || name.endsWith(':href')) && !value.trim().startsWith('#')) return 'external reference'; + } + } + // url(...) in attributes and style sheets may only point inside the document. + for (const m of svg.matchAll(/url\(\s*['"]?\s*([^)'"\s]*)/gi)) { + if (!m[1].startsWith('#')) return 'external reference'; + } + if (/@import/i.test(svg)) return 'external reference'; + + const viewBox = rootAttributes.get('viewbox')?.trim().split(/[\s,]+/).map(Number); + if (viewBox && viewBox.length === 4) { + const [, , width, height] = viewBox; + if (!(width > 0 && height > 0)) return 'empty viewBox'; + if (Math.abs(width - height) / Math.max(width, height) > 0.01) return 'not square'; + } + + if (strictProfile) { + if (rootAttributes.get('baseprofile')?.toLowerCase() !== 'tiny-ps') return 'not tiny-ps'; + if (!/]*>[^<]*\S[^<]*<\/title>/i.test(svg)) return 'no title'; + // The profile forbids positioning the root element. + if (rootAttributes.has('x') || rootAttributes.has('y')) return 'positioned root'; + } + return null; +} + +/** Draw the SVG to a square PNG of INDICATOR_SIZE pixels. */ +export async function rasterize(svg: string): Promise { + const input = Buffer.from(svg, 'utf8'); + // librsvg draws at 72 dpi from the intrinsic size; raise the density so a + // small viewBox isn't scaled up blurred, and cap it so a huge one doesn't + // allocate a giant canvas. + const meta = await sharp(input).metadata(); + const intrinsic = Math.max(1, Math.min(meta.width ?? INDICATOR_SIZE, meta.height ?? INDICATOR_SIZE)); + const density = Math.max(1, Math.min(2400, Math.ceil((72 * INDICATOR_SIZE) / intrinsic))); + return sharp(input, { density, limitInputPixels: 4096 * 4096 }) + .resize(INDICATOR_SIZE, INDICATOR_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .png() + .toBuffer(); +} diff --git a/lib/bimi/types.ts b/lib/bimi/types.ts new file mode 100644 index 000000000..d01ce3c26 --- /dev/null +++ b/lib/bimi/types.ts @@ -0,0 +1,11 @@ +/** What /api/bimi?format=json answers for a From domain. Shared with the browser. */ +export type BimiInfo = + | { status: 'none' } + | { + /** `verified`: a VMC or CMC backs the logo. `self-asserted`: only the DNS record does. */ + status: 'verified' | 'self-asserted'; + /** VMC (registered or government mark) or CMC (prior use mark). */ + markType?: 'verified' | 'common'; + organization?: string; + issuer?: string; + }; diff --git a/lib/email-headers.ts b/lib/email-headers.ts index 79a021a68..c4ed679f6 100644 --- a/lib/email-headers.ts +++ b/lib/email-headers.ts @@ -133,6 +133,25 @@ export function getSenderVerification( return { status: 'unverified', domain, sentFrom }; } +/** + * The From domain whose BIMI logo this message may show, or undefined. BIMI + * needs a DMARC pass for the very domain in From: an aligned SPF or DKIM + * pass without a DMARC verdict isn't enough, and neither is a pass that the + * receiving server recorded for another header.from. Whether the domain + * enforces DMARC and publishes a logo is /api/bimi's to find out. + */ +export function brandIndicatorDomain( + email: { from?: { email?: string | null }[] | null; authenticationResults?: AuthenticationResults } | undefined, +): string | undefined { + const auth = email?.authenticationResults; + const fromEmail = email?.from?.[0]?.email ?? undefined; + if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined; + const domain = domainOf(fromEmail); + if (!domain || isAuthenticationSpoofed(auth)) return undefined; + const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain; + return checked === domain ? domain : undefined; +} + interface ResInfo { method: string; result: string; diff --git a/lib/root-domain.ts b/lib/root-domain.ts new file mode 100644 index 000000000..67161f123 --- /dev/null +++ b/lib/root-domain.ts @@ -0,0 +1,372 @@ +/** + * The registrable ("organizational") domain of a host name, from a list of + * the common two-label public suffixes rather than the full Public Suffix + * List. Shared by the sender icon proxy and the BIMI lookups, which both + * fall back from a subdomain to the domain it belongs to. + */ + +// Known multi-part TLDs where the registrable domain includes one extra label. +const MULTI_PART_TLDS = new Set([ + // .ac + "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac", + // .ae + "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae", + // .af + "com.af", "edu.af", "gov.af", "net.af", "org.af", + // .al + "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al", + // .ao + "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao", + // .ar + "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar", + // .at + "ac.at", "co.at", "gv.at", "or.at", + // .au + "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au", + // .ba + "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba", + "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba", + // .bb + "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb", + "store.bb", "tv.bb", + // .bh + "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh", + // .bn + "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn", + // .bo + "com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo", + // .br + "adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br", + "bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br", + "ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br", + "fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br", + "ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br", + "mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br", + "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", + "trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br", + // .bs + "com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs", + // .bz + "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", + // .ca + "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", + "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", + // .ck + "biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck", + // .cn + "ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn", + "gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", + "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn", + "nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", + "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", + // .co + "com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co", + // .cr + "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr", + // .cy + "ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy", + "net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy", + // .do + "art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do", + "sld.do", "web.do", + // .dz + "art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz", + // .ec + "com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec", + "org.ec", "pro.ec", + // .eg + "com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg", + // .er + "com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er", + // .es + "com.es", "edu.es", "gob.es", "nom.es", "org.es", + // .et + "biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et", + // .fj + "ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj", + // .fk + "ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk", + // .fr + "asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr", + // .gg + "co.gg", "net.gg", "org.gg", + // .gh + "com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh", + // .gn + "ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn", + // .gr + "com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr", + // .gt + "com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt", + // .gu + "com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu", + // .hk + "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk", + // .id + "ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id", + // .il + "ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il", + // .in + "4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in", + "ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in", + // .iq + "com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq", + // .ir + "ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir", + // .it + "edu.it", "gov.it", + // .je + "co.je", "net.je", "org.je", + // .jo + "com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo", + // .jp + "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp", + // .ke + "ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke", + // .kh + "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh", + // .ki + "biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki", + "org.ki", "tel.ki", + // .km + "asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km", + "nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km", + // .kn + "edu.kn", "gov.kn", "net.kn", "org.kn", + // .kr + "ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr", + "daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr", + "gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr", + "jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", + "sc.kr", "seoul.kr", "ulsan.kr", + // .kw + "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw", + // .ky + "com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky", + // .kz + "com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz", + // .lb + "com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb", + // .lk + "assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk", + "net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk", + // .lr + "com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr", + // .lv + "asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv", + // .ly + "com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly", + // .ma + "ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma", + // .mc + "asso.mc", "tm.mc", + // .me + "ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me", + // .mg + "com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg", + // .mk + "com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk", + // .ml + "com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml", + // .mn + "edu.mn", "gov.mn", "org.mn", + // .mo + "com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo", + // .mt + "com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt", + // .mu + "ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu", + // .mv + "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv", + "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv", + // .mw + "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw", + "museum.mw", "net.mw", "org.mw", + // .mx + "com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx", + // .my + "com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my", + // .mz + "ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz", + // .na + "co.na", "com.na", + // .nf + "arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf", + "rec.nf", "store.nf", "web.nf", + // .ng + "biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng", + "net.ng", "org.ng", "sch.ng", + // .ni + "ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni", + // .np + "com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np", + // .nr + "biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr", + // .nz + "ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz", + "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz", + // .om + "ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om", + "museum.om", "net.om", "org.om", "pro.om", "sch.om", + // .pa + "abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa", + "nom.pa", "org.pa", "sld.pa", + // .pe + "com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe", + // .ph + "com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph", + // .pk + "biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk", + "gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk", + // .pl + "art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl", + "gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl", + "lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl", + "pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl", + "waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl", + // .pr + "ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr", + "name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr", + // .ps + "com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps", + // .pt + "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt", + // .pw + "belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw", + // .py + "com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py", + // .qa + "com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa", + // .re + "asso.re", "com.re", "nom.re", + // .ro + "arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro", + "rec.ro", "store.ro", "tm.ro", "www.ro", + // .rs + "ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs", + // .ru + "ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru", + "bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru", + "chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru", + "dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru", + "irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru", + "kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru", + "kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru", + "koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru", + "kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru", + "mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru", + "net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru", + "orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru", + "ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru", + "simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru", + "tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru", + "tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru", + "ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru", + "vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru", + "yekaterinburg.ru", "yuzhno-sakhalinsk.ru", + // .rw + "ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw", + // .sa + "com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa", + // .sb + "com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb", + // .sc + "com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc", + // .sd + "com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd", + // .se + "a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se", + "h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se", + "parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se", + "w.se", "x.se", "y.se", "z.se", + // .sg + "com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg", + // .sh + "co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh", + // .sl + "com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl", + // .sn + "art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn", + // .st + "co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st", + "net.st", "org.st", "principe.st", "saotome.st", "store.st", + // .sv + "com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv", + // .sy + "com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy", + // .sz + "ac.sz", "co.sz", "org.sz", + // .th + "ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th", + // .tj + "ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj", + "int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj", + // .tn + "agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn", + "ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn", + "perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn", + // .tr + "av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr", + "gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr", + "tel.tr", "tsk.tr", "tv.tr", "web.tr", + // .tt + "aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt", + "info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt", + "net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt", + // .tw + "club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw", + "mil.tw", "net.tw", "org.tw", + // .tz + "ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz", + // .ua + "biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua", + "co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua", + "donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua", + "ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua", + "kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua", + "lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua", + "nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua", + "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua", + "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua", + "zp.ua", "zt.ua", + // .ug + "ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug", + // .uk + "ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk", + "icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk", + "national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk", + "nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk", + "sch.uk", "scot.uk", "soc.uk", + // .us + "4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us", + // .uy + "com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy", + // .ve + "co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve", + // .vi + "co.vi", "com.vi", "k12.vi", "net.vi", "org.vi", + // .vn + "ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn", + "int.vn", "name.vn", "net.vn", "org.vn", "pro.vn", + // .ye + "co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye", + // .yu + "ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu", + // .za + "ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za", + "db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za", + "landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za", + "olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za", + // .zm + "ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm", +]); + +export function getRootDomain(domain: string): string { + const parts = domain.split("."); + if (parts.length <= 2) return domain; + const lastTwo = parts.slice(-2).join("."); + if (MULTI_PART_TLDS.has(lastTwo)) { + return parts.length >= 3 ? parts.slice(-3).join(".") : domain; + } + return parts.slice(-2).join("."); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index cb0c291f7..16f68c921 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "تدّعي هذه الرسالة أنها من {domain}، لكنها أُرسلت من {host} ولم تجتز فحوصات المرسل الخاصة بـ {domain}. قد تكون مزوّرة.", "caution": "توخَّ الحذر مع الروابط والمرفقات وطلبات كلمات المرور أو بيانات الدفع." }, + "brand_logo": { + "verified_label": "شعار موثّق", + "verified": "هذا الشعار موثّق على أنه يخص {organization}.", + "verified_by": "تحققت {issuer} من أن هذا الشعار يخص {organization}." + }, "back_to_list": "العودة إلى القائمة", "view_contact": "عرض جهة الاتصال", "message_details": "تفاصيل الرسالة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0b1c8d3a6..df68c6863 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Aquest missatge diu que és de {domain}, però s'ha enviat des de {host} i no ha superat les comprovacions de remitent de {domain}. Podria estar falsificat.", "caution": "Aneu amb compte amb els enllaços, els fitxers adjunts i les sol·licituds de contrasenyes o dades de pagament." }, + "brand_logo": { + "verified_label": "Logotip verificat", + "verified": "Aquest logotip està verificat com a propietat de {organization}.", + "verified_by": "{issuer} ha verificat que aquest logotip pertany a {organization}." + }, "back_to_list": "Torna a la llista", "view_contact": "Mostra el contacte", "message_details": "Detalls del missatge", diff --git a/locales/cs/common.json b/locales/cs/common.json index 743f5fb4c..12e216360 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Tato zpráva tvrdí, že pochází z domény {domain}, ale byla odeslána z domény {host} a neprošla kontrolami odesílatele domény {domain}. Může být podvržená.", "caution": "Buďte opatrní u odkazů, příloh a žádostí o hesla nebo platební údaje." }, + "brand_logo": { + "verified_label": "Ověřené logo", + "verified": "Toto logo je ověřeno jako logo společnosti {organization}.", + "verified_by": "{issuer} ověřil, že toto logo patří společnosti {organization}." + }, "back_to_list": "Zpět na seznam", "view_contact": "Zobrazit kontakt", "message_details": "Podrobnosti zprávy", diff --git a/locales/da/common.json b/locales/da/common.json index b08254f01..8b62f80f6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Denne besked udgiver sig for at være fra {domain}, men den blev sendt fra {host} og bestod ikke afsenderkontrollen for {domain}. Den kan være forfalsket.", "caution": "Vær forsigtig med links, vedhæftede filer og anmodninger om adgangskoder eller betalingsoplysninger." }, + "brand_logo": { + "verified_label": "Verificeret logo", + "verified": "Dette logo er verificeret som tilhørende {organization}.", + "verified_by": "{issuer} har verificeret, at dette logo tilhører {organization}." + }, "back_to_list": "Tilbage til liste", "view_contact": "Vis kontakt", "message_details": "Beskeddetaljer", diff --git a/locales/de/common.json b/locales/de/common.json index 980ba1e7b..63694491a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Diese Nachricht gibt vor, von {domain} zu kommen, wurde aber von {host} verschickt und hat die Absenderprüfung von {domain} nicht bestanden. Sie ist möglicherweise gefälscht.", "caution": "Seien Sie vorsichtig mit Links, Anhängen und Fragen nach Passwörtern oder Zahlungsdaten." }, + "brand_logo": { + "verified_label": "Verifiziertes Logo", + "verified": "Dieses Logo ist als Logo von {organization} verifiziert.", + "verified_by": "{issuer} hat bestätigt, dass dieses Logo zu {organization} gehört." + }, "back_to_list": "Zurück zur Liste", "view_contact": "Kontakt anzeigen", "message_details": "Nachrichtendetails", diff --git a/locales/en/common.json b/locales/en/common.json index df7a98ef0..339fb5304 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "This message claims to be from {domain}, but it was sent from {host} and failed {domain}'s sender checks. It may be forged.", "caution": "Be careful with links, attachments and requests for passwords or payment details." }, + "brand_logo": { + "verified_label": "Verified logo", + "verified": "This logo is verified as belonging to {organization}.", + "verified_by": "{issuer} verified that this logo belongs to {organization}." + }, "back_to_list": "Back to list", "view_contact": "View contact", "message_details": "Message Details", diff --git a/locales/es/common.json b/locales/es/common.json index 32a1b37da..5b4c61ada 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Este mensaje dice proceder de {domain}, pero se envió desde {host} y no superó las comprobaciones de remitente de {domain}. Podría estar falsificado.", "caution": "Tenga cuidado con los enlaces, los archivos adjuntos y las solicitudes de contraseñas o datos de pago." }, + "brand_logo": { + "verified_label": "Logotipo verificado", + "verified": "Este logotipo está verificado como propiedad de {organization}.", + "verified_by": "{issuer} verificó que este logotipo pertenece a {organization}." + }, "back_to_list": "Volver a la lista", "view_contact": "Ver contacto", "message_details": "Detalles del Mensaje", diff --git a/locales/et/common.json b/locales/et/common.json index 427dfc56a..12a2f10f2 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "See kiri väidab, et on saadetud domeenilt {domain}, kuid see saadeti hostilt {host} ega läbinud domeeni {domain} saatjakontrolle. See võib olla võltsitud.", "caution": "Ole ettevaatlik linkide, manuste ning paroolide või makseandmete küsimistega." }, + "brand_logo": { + "verified_label": "Kinnitatud logo", + "verified": "See logo on kinnitatud kui {organization} logo.", + "verified_by": "{issuer} kinnitas, et see logo kuulub ettevõttele {organization}." + }, "back_to_list": "Tagasi loetellu", "view_contact": "Vaata kontakti", "message_details": "Kirja üksikasjad", diff --git a/locales/fa/common.json b/locales/fa/common.json index bbe9afa8e..05b5e0229 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "این پیام ادعا می‌کند از {domain} است، اما از {host} ارسال شده و در بررسی‌های فرستنده برای {domain} رد شده است. ممکن است جعلی باشد.", "caution": "مراقب پیوندها، پیوست‌ها و درخواست‌های رمز عبور یا اطلاعات پرداخت باشید." }, + "brand_logo": { + "verified_label": "لوگوی تأییدشده", + "verified": "این لوگو به‌عنوان متعلق به {organization} تأیید شده است.", + "verified_by": "{issuer} تأیید کرده است که این لوگو متعلق به {organization} است." + }, "back_to_list": "بازگشت به لیست", "view_contact": "مشاهده مخاطب", "message_details": "جزئیات پیام", diff --git a/locales/fr/common.json b/locales/fr/common.json index e672743a7..06c3c27a5 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Ce message prétend venir de {domain}, mais il a été envoyé depuis {host} et a échoué aux vérifications d'expéditeur de {domain}. Il est peut-être falsifié.", "caution": "Méfiez-vous des liens, des pièces jointes et des demandes de mots de passe ou d'informations de paiement." }, + "brand_logo": { + "verified_label": "Logo vérifié", + "verified": "Ce logo est vérifié comme appartenant à {organization}.", + "verified_by": "{issuer} a vérifié que ce logo appartient à {organization}." + }, "back_to_list": "Retour à la liste", "view_contact": "Voir le contact", "message_details": "Détails du message", diff --git a/locales/he/common.json b/locales/he/common.json index fb2b3dcde..0ac090b4d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -446,6 +446,11 @@ "failed_sent_from": "ההודעה הזו טוענת שהיא מהדומיין {domain}, אבל היא נשלחה מהדומיין {host} ונכשלה בבדיקות השולח של {domain}. ייתכן שהיא מזויפת.", "caution": "היזהר מקישורים, מקבצים מצורפים ומבקשות לסיסמאות או לפרטי תשלום." }, + "brand_logo": { + "verified_label": "לוגו מאומת", + "verified": "הלוגו הזה מאומת כשייך ל-{organization}.", + "verified_by": "{issuer} אימת שהלוגו הזה שייך ל-{organization}." + }, "back_to_list": "חזרה לרשימה", "view_contact": "הצג איש קשר", "message_details": "פרטי הודעה", diff --git a/locales/hu/common.json b/locales/hu/common.json index 299c043db..11c00331b 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Ez az üzenet azt állítja, hogy a(z) {domain} tartományból jött, de a(z) {host} tartományból küldték, és nem ment át a(z) {domain} tartomány feladóellenőrzésén. Lehet, hogy hamisított.", "caution": "Légy óvatos a linkekkel, a mellékletekkel, és ha jelszót vagy fizetési adatokat kérnek tőled." }, + "brand_logo": { + "verified_label": "Ellenőrzött logó", + "verified": "Ez a logó ellenőrzötten a(z) {organization} tulajdona.", + "verified_by": "A(z) {issuer} ellenőrizte, hogy ez a logó a(z) {organization} tulajdona." + }, "back_to_list": "Vissza a listához", "view_contact": "Névjegy megtekintése", "message_details": "Üzenet részletei", diff --git a/locales/it/common.json b/locales/it/common.json index 396d86c3f..63b01167f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Questo messaggio dichiara di provenire da {domain}, ma è stato inviato da {host} e non ha superato i controlli sul mittente di {domain}. Potrebbe essere falsificato.", "caution": "Fai attenzione a link, allegati e richieste di password o dati di pagamento." }, + "brand_logo": { + "verified_label": "Logo verificato", + "verified": "Questo logo è verificato come appartenente a {organization}.", + "verified_by": "{issuer} ha verificato che questo logo appartiene a {organization}." + }, "back_to_list": "Torna all'elenco", "view_contact": "Visualizza contatto", "message_details": "Dettagli del messaggio", diff --git a/locales/ja/common.json b/locales/ja/common.json index ed53c4e6d..c6fd4b814 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "このメッセージは{domain}からのものとされていますが、実際には{host}から送信されており、{domain}の送信者チェックにも失敗しました。なりすましの可能性があります。", "caution": "リンクや添付ファイル、パスワードや支払い情報の要求には注意してください。" }, + "brand_logo": { + "verified_label": "認証済みロゴ", + "verified": "このロゴは {organization} のものとして認証されています。", + "verified_by": "{issuer} がこのロゴが {organization} のものであることを認証しました。" + }, "back_to_list": "リストに戻る", "view_contact": "連絡先を表示", "message_details": "メッセージの詳細", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4456df28c..7cf2bbbc7 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "이 메시지는 {domain}에서 보냈다고 하지만 실제로는 {host}에서 발송되었고 {domain}의 보낸 사람 확인을 통과하지 못했어요. 위조된 메시지일 수 있어요.", "caution": "링크, 첨부 파일, 비밀번호나 결제 정보 요청에 주의하세요." }, + "brand_logo": { + "verified_label": "인증된 로고", + "verified": "이 로고는 {organization}의 로고로 인증되었습니다.", + "verified_by": "{issuer}에서 이 로고가 {organization}의 로고임을 인증했습니다." + }, "back_to_list": "목록으로", "view_contact": "연락처 보기", "message_details": "메시지 상세 정보", diff --git a/locales/lv/common.json b/locales/lv/common.json index 50a7efa7b..0db3f22a5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Šis ziņojums apgalvo, ka ir no domēna {domain}, taču tas nosūtīts no domēna {host} un neizturēja domēna {domain} sūtītāja pārbaudes. Tas var būt viltots.", "caution": "Esiet piesardzīgi ar saitēm, pielikumiem un paroļu vai maksājumu datu pieprasījumiem." }, + "brand_logo": { + "verified_label": "Verificēts logotips", + "verified": "Šis logotips ir verificēts kā {organization} logotips.", + "verified_by": "{issuer} ir verificējis, ka šis logotips pieder {organization}." + }, "back_to_list": "Atpakaļ uz sarakstu", "view_contact": "Skatīt kontaktu", "message_details": "Informācija par ziņojumu", diff --git a/locales/mn/common.json b/locales/mn/common.json index a37c37b25..313dba597 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Энэ зурвас {domain} домэйнээс ирсэн гэж мэдэгдэж байгаа ч {host} домэйнээс илгээгдсэн бөгөөд {domain} домэйны илгээгчийн шалгалтыг давж чадаагүй. Хуурамч байж магадгүй.", "caution": "Холбоос, хавсралт болон нууц үг, төлбөрийн мэдээлэл асуусан хүсэлтэд болгоомжтой хандаарай." }, + "brand_logo": { + "verified_label": "Баталгаажсан лого", + "verified": "Энэ лого {organization}-д хамаарах нь баталгаажсан.", + "verified_by": "{issuer} энэ лого {organization}-д хамаарахыг баталгаажуулсан." + }, "back_to_list": "Жагсаалт руу буцах", "view_contact": "Харилцагчийг харах", "message_details": "Зурвасны дэлгэрэнгүй мэдээлэл", diff --git a/locales/nb/common.json b/locales/nb/common.json index 74b1d625c..0217528bd 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Denne meldingen utgir seg for å være fra {domain}, men den ble sendt fra {host} og besto ikke avsenderkontrollene til {domain}. Den kan være forfalsket.", "caution": "Vær forsiktig med lenker, vedlegg og forespørsler om passord eller betalingsopplysninger." }, + "brand_logo": { + "verified_label": "Bekreftet logo", + "verified": "Denne logoen er bekreftet å tilhøre {organization}.", + "verified_by": "{issuer} har bekreftet at denne logoen tilhører {organization}." + }, "back_to_list": "Tilbake til listen", "view_contact": "Se kontakt", "message_details": "Meldingsdetaljer", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1007e62bf..fd1e0a2d9 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Dit bericht beweert van {domain} te komen, maar is verzonden vanaf {host} en is niet door de afzendercontroles van {domain} gekomen. Het kan vervalst zijn.", "caution": "Wees voorzichtig met links, bijlagen en verzoeken om wachtwoorden of betaalgegevens." }, + "brand_logo": { + "verified_label": "Geverifieerd logo", + "verified": "Dit logo is geverifieerd als logo van {organization}.", + "verified_by": "{issuer} heeft geverifieerd dat dit logo van {organization} is." + }, "back_to_list": "Terug naar lijst", "view_contact": "Contact bekijken", "message_details": "Berichtdetails", diff --git a/locales/pl/common.json b/locales/pl/common.json index 4036b29cd..39424a911 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Ta wiadomość twierdzi, że pochodzi z domeny {domain}, ale została wysłana z domeny {host} i nie przeszła weryfikacji nadawcy domeny {domain}. Może być sfałszowana.", "caution": "Uważaj na linki, załączniki i prośby o hasła lub dane płatnicze." }, + "brand_logo": { + "verified_label": "Zweryfikowane logo", + "verified": "To logo zostało zweryfikowane jako należące do {organization}.", + "verified_by": "{issuer} potwierdził, że to logo należy do {organization}." + }, "back_to_list": "Powrót do listy", "view_contact": "Pokaż kontakt", "message_details": "Szczegóły wiadomości", diff --git a/locales/pt/common.json b/locales/pt/common.json index f9ae49b67..3c4c24106 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Esta mensagem afirma ser de {domain}, mas foi enviada de {host} e não passou nas verificações de remetente de {domain}. Pode ter sido falsificada.", "caution": "Tenha cuidado com links, anexos e pedidos de senhas ou dados de pagamento." }, + "brand_logo": { + "verified_label": "Logotipo verificado", + "verified": "Este logotipo foi verificado como pertencente a {organization}.", + "verified_by": "{issuer} verificou que este logotipo pertence a {organization}." + }, "back_to_list": "Voltar para a lista", "view_contact": "Ver contato", "message_details": "Detalhes da Mensagem", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1b9f6be13..83222e1ac 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Acest mesaj pretinde că provine de la {domain}, dar a fost trimis de pe {host} și nu a trecut de verificările de expeditor ale domeniului {domain}. Este posibil să fie falsificat.", "caution": "Fiți atenți la linkuri, atașamente și solicitări de parole sau date de plată." }, + "brand_logo": { + "verified_label": "Logo verificat", + "verified": "Acest logo este verificat ca aparținând {organization}.", + "verified_by": "{issuer} a verificat că acest logo aparține {organization}." + }, "back_to_list": "Înapoi la listă", "view_contact": "Vizualizare contact", "message_details": "Detalii mesaj", diff --git a/locales/ru/common.json b/locales/ru/common.json index 4beb6f65b..30b6f233f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Это сообщение якобы отправлено с домена {domain}, но на самом деле оно отправлено с домена {host} и не прошло проверку отправителя домена {domain}. Возможно, оно поддельное.", "caution": "Будьте осторожны со ссылками, вложениями и просьбами указать пароль или платёжные данные." }, + "brand_logo": { + "verified_label": "Подтверждённый логотип", + "verified": "Этот логотип подтверждён как принадлежащий {organization}.", + "verified_by": "{issuer} подтвердил, что этот логотип принадлежит {organization}." + }, "back_to_list": "Вернуться к списку", "view_contact": "Просмотреть контакт", "message_details": "Детали сообщения", diff --git a/locales/sk/common.json b/locales/sk/common.json index be68b68c1..d9144010c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Táto správa tvrdí, že pochádza z domény {domain}, ale bola odoslaná z domény {host} a neprešla kontrolami odosielateľa domény {domain}. Môže byť podvrhnutá.", "caution": "Buďte opatrní pri odkazoch, prílohách a žiadostiach o heslá alebo platobné údaje." }, + "brand_logo": { + "verified_label": "Overené logo", + "verified": "Toto logo je overené ako logo spoločnosti {organization}.", + "verified_by": "{issuer} overil, že toto logo patrí spoločnosti {organization}." + }, "back_to_list": "Späť na zoznam", "view_contact": "Zobraziť kontakt", "message_details": "Podrobnosti správy", diff --git a/locales/th/common.json b/locales/th/common.json index 04bc0c8c3..66e44a8f8 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1432,6 +1432,11 @@ "failed_sent_from": "ข้อความนี้อ้างว่าส่งมาจาก {domain} แต่ส่งมาจาก {host} และไม่ผ่านการตรวจสอบความปลอดภัย อาจเป็นข้อความปลอมแปลง", "caution": "โปรดระมัดระวังการคลิกลิงก์หรือเปิดไฟล์แนบในข้อความนี้" }, + "brand_logo": { + "verified_label": "โลโก้ที่ยืนยันแล้ว", + "verified": "โลโก้นี้ได้รับการยืนยันว่าเป็นของ {organization}", + "verified_by": "{issuer} ยืนยันแล้วว่าโลโก้นี้เป็นของ {organization}" + }, "back_to_list": "กลับไปยังรายการอีเมล", "view_contact": "ดูข้อมูลผู้ติดต่อ", "message_details": "รายละเอียดข้อความ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 665a1e40c..c53a09b0e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Bu ileti {domain} etki alanından geldiğini iddia ediyor, ancak {host} etki alanından gönderildi ve {domain} etki alanının gönderen denetimlerinden geçemedi. Sahte olabilir.", "caution": "Bağlantılara, eklere ve parola ya da ödeme bilgisi isteklerine karşı dikkatli olun." }, + "brand_logo": { + "verified_label": "Doğrulanmış logo", + "verified": "Bu logonun {organization} kuruluşuna ait olduğu doğrulandı.", + "verified_by": "{issuer}, bu logonun {organization} kuruluşuna ait olduğunu doğruladı." + }, "back_to_list": "Listeye dön", "view_contact": "Kişiyi görüntüle", "message_details": "İleti Ayrıntıları", diff --git a/locales/uk/common.json b/locales/uk/common.json index 48dd3c434..440dc7fc7 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "Це повідомлення нібито надіслано з домену {domain}, але насправді його надіслано з домену {host}, і воно не пройшло перевірку відправника домену {domain}. Можливо, воно підроблене.", "caution": "Будьте обережні з посиланнями, вкладеннями та запитами паролів чи платіжних даних." }, + "brand_logo": { + "verified_label": "Підтверджений логотип", + "verified": "Цей логотип підтверджено як такий, що належить {organization}.", + "verified_by": "{issuer} підтвердив, що цей логотип належить {organization}." + }, "back_to_list": "Повернутися до списку", "view_contact": "Переглянути контакт", "message_details": "Деталі повідомлення", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index e1a63932e..79cdca437 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "這封郵件聲稱來自 {domain},但它是從 {host} 寄出的,而且未通過 {domain} 的寄件者驗證,可能是偽造的。", "caution": "請小心其中的連結、附件,以及要求提供密碼或付款資訊的請求。" }, + "brand_logo": { + "verified_label": "已驗證的標誌", + "verified": "此標誌已驗證屬於 {organization}。", + "verified_by": "{issuer} 已驗證此標誌屬於 {organization}。" + }, "back_to_list": "回到清單", "view_contact": "檢視聯絡人", "message_details": "郵件詳細資料", diff --git a/locales/zh/common.json b/locales/zh/common.json index 238f74379..42e4e518d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -497,6 +497,11 @@ "failed_sent_from": "此邮件声称来自 {domain},但它是从 {host} 发出的,并且未通过 {domain} 的发件人验证。它可能是伪造的。", "caution": "请谨慎对待其中的链接、附件,以及索要密码或付款信息的请求。" }, + "brand_logo": { + "verified_label": "已验证的标志", + "verified": "此标志已验证属于 {organization}。", + "verified_by": "{issuer} 已验证此标志属于 {organization}。" + }, "back_to_list": "返回列表", "view_contact": "查看联系人", "message_details": "邮件详情", diff --git a/stores/favicon-refresh-store.ts b/stores/favicon-refresh-store.ts index f6087774b..d366c6a33 100644 --- a/stores/favicon-refresh-store.ts +++ b/stores/favicon-refresh-store.ts @@ -4,6 +4,8 @@ import { persist } from 'zustand/middleware'; // Domains whose favicon failed to load in this tab. Avatars skip them so a // missing icon isn't requested again on every render. export const failedFaviconDomains = new Set(); +// The same for BIMI logos (/api/bimi), by From domain. +export const failedBrandDomains = new Set(); interface FaviconRefreshState { /** When the user last asked for fresh sender icons (ms), 0 if never. */ @@ -21,6 +23,7 @@ export const useFaviconRefreshStore = create()( refreshedAt: 0, refresh: () => { failedFaviconDomains.clear(); + failedBrandDomains.clear(); set({ refreshedAt: Date.now() }); }, }),