diff --git a/README.md b/README.md
index f51bd3d..756b96a 100644
--- a/README.md
+++ b/README.md
@@ -194,21 +194,45 @@ Gmail or Outlook shows beside a message comes from the *recipient's* own
contacts, or from the sender's Google or Microsoft account. A self-hosted
server cannot put an image there.
-**BIMI** is what external clients do read, and it is per domain — one mark for
-everyone who sends from it. Publishing one needs:
+**BIMI** is what external clients do read, and it is per domain: one mark for
+everyone who sends from it. Publishing one needs three things, and the console
+(`/admin/domains`, under the domain) handles each:
-- DMARC at `p=quarantine` or `p=reject`, which this server already sets;
-- a logo in SVG Tiny Portable/Secure — square viewBox, one `
`, nothing
- active or external. `/admin/domains` validates an upload against every rule
- at once and names the ones it fails, because a receiver that rejects a mark
- simply shows nothing;
-- a **Verified Mark Certificate** for Gmail and Outlook to render it. It is
- issued against a registered trademark, costs on the order of US$1000 a year,
- and without it the record is still valid — some clients use it, those two do
- not.
+1. **The logo.** Upload a PNG or an SVG. The studio converts it to what BIMI
+ accepts, SVG Tiny Portable/Secure on a square canvas of at most 32 KB with
+ nothing active or external in it, and lets you place it the way the profile
+ photo editor does: drag, zoom, background colour, with a preview inside the
+ circle or rounded square clients draw. An SVG has what the format forbids
+ removed (scripts, links, embedded bitmaps, editor metadata) and says what; a
+ PNG is traced into vector paths, which loses some detail, so send the SVG
+ when you have it. The server validates the result again against every rule
+ at once, because a receiver that rejects a mark simply shows nothing.
+2. **DMARC at `p=quarantine` or `p=reject`** with `pct=100`, which this server
+ already sets.
+3. **A certificate, for Gmail and Apple Mail to render it.** A Verified Mark
+ Certificate (VMC, for a registered trademark) or a Common Mark Certificate
+ (CMC, for a mark in use) is **issued by a certificate authority**
+ (DigiCert, Entrust, SSL.com and others) after it checks the organisation.
+ It cannot be generated here from an image, and nothing signed here would be
+ trusted. What the console does: gives you the exact SVG to send the
+ authority, takes the `.pem` it returns, and refuses one that would not work
+ (the logo inside it must be byte for byte the published one, it must cover
+ the domain, be in date, and never contain a private key). It then serves it
+ from `https:///.well-known/bimi/vmc.pem` and points the `a=` tag
+ there. Without a certificate the record is still valid and some clients use
+ it; Gmail and Apple Mail do not.
+
+"Verificar configuracao" in the panel looks at what a receiver would: the logo
+file, DMARC, the BIMI record in DNS, the public URL of the logo, the
+certificate and its public URL, and names the one that is wrong.
The logo is served from `https:///.well-known/bimi/default.svg` and
-the record is published with the rest of the zone.
+the record is published with the rest of the zone. Replacing the logo under a
+held certificate breaks the certificate, and the console says so.
+
+A changed profile photo shows up at once everywhere in the webmail: every
+photo carries a version that is bumped on save, in this tab and the others. The
+server lets a browser reuse one for a minute and then revalidates it.
### DANE
diff --git a/migrations/0015_bimi_certificate.down.sql b/migrations/0015_bimi_certificate.down.sql
new file mode 100644
index 0000000..c0adb2e
--- /dev/null
+++ b/migrations/0015_bimi_certificate.down.sql
@@ -0,0 +1,3 @@
+ALTER TABLE domain_bimi
+ DROP COLUMN IF EXISTS vmc_pem,
+ DROP COLUMN IF EXISTS vmc_uploaded_at;
diff --git a/migrations/0015_bimi_certificate.up.sql b/migrations/0015_bimi_certificate.up.sql
new file mode 100644
index 0000000..8e1ec87
--- /dev/null
+++ b/migrations/0015_bimi_certificate.up.sql
@@ -0,0 +1,17 @@
+-- The BIMI certificate a domain publishes, held here instead of only linked.
+--
+-- A Verified Mark Certificate is issued by an authority after it has checked
+-- the organisation, so it cannot be generated here; but the authority hands
+-- back a .pem file, and until now the console only took a URL to one hosted
+-- somewhere else. Holding the file lets the service serve it at
+-- /.well-known/bimi/vmc.pem and point the DNS record there, next to the logo
+-- the certificate was issued for.
+--
+-- Stored as the PEM chain exactly as delivered. Everything the console shows
+-- about it (validity, subject, whether it matches the logo) is read from the
+-- file when asked, so it cannot drift from what is actually served.
+--
+-- vmc_url stays: a domain may keep its certificate with the authority.
+ALTER TABLE domain_bimi
+ ADD COLUMN IF NOT EXISTS vmc_pem BYTEA,
+ ADD COLUMN IF NOT EXISTS vmc_uploaded_at TIMESTAMPTZ;
diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts
index b67dfba..1b5febd 100644
--- a/services/auth/src/router.test.ts
+++ b/services/auth/src/router.test.ts
@@ -946,6 +946,14 @@ describeDb("auth API against a real database", () => {
expect(got.headers.get("content-type")).toBe("image/png");
expect(Buffer.from(await got.arrayBuffer())).toEqual(png);
+ // A new photo has to show up quickly: the browser may reuse the old one
+ // for at most a minute, and must ask again after that. It was five minutes
+ // with no revalidation, which is what made a changed photo "take forever".
+ const cacheControl = got.headers.get("cache-control") ?? "";
+ expect(cacheControl).toContain("private");
+ expect(cacheControl).toContain("must-revalidate");
+ expect(Number(/max-age=(\d+)/.exec(cacheControl)?.[1])).toBeLessThanOrEqual(60);
+
// A photo appears beside every message from that person; without the
// conditional answer it would be re-sent on every one of them.
const etag = got.headers.get("etag")!;
diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts
index 78e29d9..1dd4450 100644
--- a/services/auth/src/router.ts
+++ b/services/auth/src/router.ts
@@ -343,7 +343,10 @@ async function getAvatar(
res.setHeader("ETag", `"${avatar.etag}"`);
// Private: this is served to a signed-in reader, and a shared cache must not
// hand one person's photo to the next request that comes along.
- res.setHeader("Cache-Control", "private, max-age=300");
+ // One minute, then revalidate with the ETag (a 304 costs no body). It was five
+ // minutes with no revalidation, so a new photo took that long to show up
+ // wherever the page did not add a version to the URL.
+ res.setHeader("Cache-Control", "private, max-age=60, must-revalidate");
// The bytes were sniffed on the way in, but a browser that ignores the type
// and sniffs for itself must not be allowed to find something else.
res.setHeader("X-Content-Type-Options", "nosniff");
diff --git a/services/protocols/cmd/lambdamail-protocols/bimi_store.go b/services/protocols/cmd/lambdamail-protocols/bimi_store.go
index aec8ec8..ed8c057 100644
--- a/services/protocols/cmd/lambdamail-protocols/bimi_store.go
+++ b/services/protocols/cmd/lambdamail-protocols/bimi_store.go
@@ -22,9 +22,17 @@ func (s bimiStore) Load(ctx context.Context, domain string) (*httppresentation.B
if err != nil || logo == nil {
return nil, err
}
- return &httppresentation.BimiLogo{SVG: logo.SVG, ETag: logo.ETag, VmcURL: logo.VmcURL}, nil
+ return &httppresentation.BimiLogo{SVG: logo.SVG, ETag: logo.ETag, VmcURL: logo.VmcURL, VmcPEM: logo.VmcPEM}, nil
}
func (s bimiStore) Delete(ctx context.Context, domain string) error {
return s.repo.Delete(ctx, domain)
}
+
+func (s bimiStore) SaveCertificate(ctx context.Context, domain string, pem []byte) (bool, error) {
+ return s.repo.SaveCertificate(ctx, domain, pem)
+}
+
+func (s bimiStore) DeleteCertificate(ctx context.Context, domain string) error {
+ return s.repo.DeleteCertificate(ctx, domain)
+}
diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go
index 8e6bb9a..3d40377 100644
--- a/services/protocols/cmd/lambdamail-protocols/main.go
+++ b/services/protocols/cmd/lambdamail-protocols/main.go
@@ -239,7 +239,7 @@ func run(cfg config) {
}
// The BIMI mark: served to receivers over the well-known URL, managed from
// the domains panel.
- router.SetBimiAPI(bimiStore{postgres.NewBimiRepository(pool)}, cfg.JwtSecret, cfg.domain())
+ router.SetBimiAPI(bimiStore{postgres.NewBimiRepository(pool)}, cfg.JwtSecret, cfg.domain(), cfg.PrimaryMailHost)
// The TLS panel reads the live watcher instead of the constants the admin
// service used to return, which reported a healthy certificate whatever
@@ -722,7 +722,13 @@ func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]e
if s.bimi != nil {
if logo, err := s.bimi.Load(ctx, domain); err == nil && logo != nil {
spec.BimiLogoURL = httppresentation.BimiLogoURL(s.cfg.PrimaryMailHost)
- spec.BimiVmcURL = logo.VmcURL
+ // The certificate held here wins: it is the one verified against the
+ // logo being served. An external URL is the fallback.
+ if len(logo.VmcPEM) > 0 {
+ spec.BimiVmcURL = httppresentation.BimiVmcURL(s.cfg.PrimaryMailHost)
+ } else {
+ spec.BimiVmcURL = logo.VmcURL
+ }
}
}
diff --git a/services/protocols/internal/domain/bimi/check.go b/services/protocols/internal/domain/bimi/check.go
new file mode 100644
index 0000000..76c0d0b
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/check.go
@@ -0,0 +1,256 @@
+package bimi
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// What decides whether a receiver shows the logo is spread over six places that
+// each fail silently: the logo file, the DMARC policy, the BIMI DNS record, the
+// public URL of the logo, the certificate, and the public URL of the
+// certificate. RunChecks looks at all of them the way a receiver would and says
+// which one is wrong, instead of leaving the operator to wait for a message to
+// arrive somewhere and show nothing.
+
+// Status of one check.
+type Status string
+
+const (
+ StatusOK Status = "ok"
+ StatusWarn Status = "warn"
+ StatusFail Status = "fail"
+)
+
+// Check is the outcome of one step.
+type Check struct {
+ ID string
+ Status Status
+ Detail string
+}
+
+// Resolver looks up TXT records.
+type Resolver interface {
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+}
+
+// Response is what a fetch returned.
+type Response struct {
+ Status int
+ ContentType string
+ Body []byte
+}
+
+// Fetcher gets a public HTTPS URL.
+type Fetcher func(ctx context.Context, url string) (Response, error)
+
+// Inputs describe what should be published.
+type Inputs struct {
+ Domain string
+ // LogoURL and CertificateURL are where the DNS record is expected to
+ // point. CertificateURL is empty when no certificate is published.
+ LogoURL string
+ CertificateURL string
+ SVG []byte
+ // CertificatePEM is the chain held here, if any. ExternalCertificateURL is
+ // the one the operator keeps elsewhere, used only when none is held.
+ CertificatePEM []byte
+ ExternalCertificateURL string
+ Now time.Time
+}
+
+// RunChecks runs every check and returns them in the order a receiver would
+// meet them. It never fails as a whole: a lookup that errors is a check that
+// fails, with the reason.
+func RunChecks(ctx context.Context, in Inputs, dns Resolver, fetch Fetcher) []Check {
+ checks := []Check{
+ checkLogo(in),
+ checkDMARC(ctx, in.Domain, dns),
+ checkRecord(ctx, in, dns),
+ checkLogoURL(ctx, in, fetch),
+ }
+ checks = append(checks, checkCertificate(ctx, in, fetch)...)
+ return checks
+}
+
+func checkLogo(in Inputs) Check {
+ violations := Validate(in.SVG)
+ if len(violations) == 0 {
+ return Check{ID: "logo", Status: StatusOK, Detail: fmt.Sprintf("SVG Tiny PS valid, %d bytes", len(in.SVG))}
+ }
+ reasons := make([]string, 0, len(violations))
+ for _, v := range violations {
+ reasons = append(reasons, v.String())
+ }
+ return Check{ID: "logo", Status: StatusFail, Detail: strings.Join(reasons, "; ")}
+}
+
+// checkDMARC: BIMI is only honoured for a domain that enforces DMARC. A policy
+// of "none" is the common reason a correct logo and certificate show nothing.
+func checkDMARC(ctx context.Context, domain string, dns Resolver) Check {
+ records, err := dns.LookupTXT(ctx, "_dmarc."+domain)
+ if err != nil {
+ return Check{ID: "dmarc", Status: StatusFail, Detail: fmt.Sprintf("no DMARC record at _dmarc.%s (%v)", domain, err)}
+ }
+ var record string
+ for _, r := range records {
+ if strings.HasPrefix(strings.ToLower(strings.TrimSpace(r)), "v=dmarc1") {
+ record = r
+ break
+ }
+ }
+ if record == "" {
+ return Check{ID: "dmarc", Status: StatusFail, Detail: "no DMARC record: BIMI needs a policy of quarantine or reject"}
+ }
+
+ tags := parseTags(record)
+ policy := strings.ToLower(tags["p"])
+ switch policy {
+ case "quarantine", "reject":
+ default:
+ return Check{ID: "dmarc", Status: StatusFail, Detail: fmt.Sprintf("DMARC policy is %q: BIMI needs quarantine or reject", policy)}
+ }
+ if pct, ok := tags["pct"]; ok && strings.TrimSpace(pct) != "100" {
+ return Check{ID: "dmarc", Status: StatusWarn, Detail: fmt.Sprintf("DMARC applies to only %s%% of mail; BIMI needs pct=100 (or no pct)", pct)}
+ }
+ return Check{ID: "dmarc", Status: StatusOK, Detail: "DMARC policy is " + policy}
+}
+
+// checkRecord compares the published BIMI record with the one the service
+// would publish.
+func checkRecord(ctx context.Context, in Inputs, dns Resolver) Check {
+ name := "default._bimi." + in.Domain
+ records, err := dns.LookupTXT(ctx, name)
+ if err != nil {
+ return Check{ID: "record", Status: StatusFail, Detail: fmt.Sprintf("no TXT record at %s (%v)", name, err)}
+ }
+ var found map[string]string
+ for _, r := range records {
+ tags := parseTags(r)
+ if strings.EqualFold(tags["v"], "BIMI1") {
+ found = tags
+ break
+ }
+ }
+ if found == nil {
+ return Check{ID: "record", Status: StatusFail, Detail: fmt.Sprintf("%s has no v=BIMI1 record", name)}
+ }
+
+ var problems []string
+ if found["l"] != in.LogoURL {
+ problems = append(problems, fmt.Sprintf("l= is %q, expected %q", found["l"], in.LogoURL))
+ }
+ if found["a"] != in.CertificateURL {
+ if in.CertificateURL == "" {
+ problems = append(problems, fmt.Sprintf("a= is %q but no certificate is published here", found["a"]))
+ } else {
+ problems = append(problems, fmt.Sprintf("a= is %q, expected %q", found["a"], in.CertificateURL))
+ }
+ }
+ if len(problems) > 0 {
+ return Check{ID: "record", Status: StatusFail, Detail: strings.Join(problems, "; ")}
+ }
+ return Check{ID: "record", Status: StatusOK, Detail: "the BIMI record points at this logo" + ifs(in.CertificateURL != "", " and certificate")}
+}
+
+func checkLogoURL(ctx context.Context, in Inputs, fetch Fetcher) Check {
+ resp, err := fetch(ctx, in.LogoURL)
+ if err != nil {
+ return Check{ID: "logoUrl", Status: StatusFail, Detail: fmt.Sprintf("could not fetch %s (%v)", in.LogoURL, err)}
+ }
+ switch {
+ case resp.Status != 200:
+ return Check{ID: "logoUrl", Status: StatusFail, Detail: fmt.Sprintf("%s answered %d", in.LogoURL, resp.Status)}
+ case !strings.HasPrefix(strings.ToLower(resp.ContentType), "image/svg+xml"):
+ return Check{ID: "logoUrl", Status: StatusFail, Detail: fmt.Sprintf("served as %q, receivers require image/svg+xml", resp.ContentType)}
+ case !bytes.Equal(resp.Body, in.SVG):
+ return Check{ID: "logoUrl", Status: StatusFail, Detail: "the public URL serves a different file than the one stored (a cache in front?)"}
+ }
+ return Check{ID: "logoUrl", Status: StatusOK, Detail: "the logo is public over HTTPS and matches"}
+}
+
+// checkCertificate returns one or two checks: the certificate itself, and for a
+// hosted one, that the public URL really serves it.
+func checkCertificate(ctx context.Context, in Inputs, fetch Fetcher) []Check {
+ now := in.Now
+ if now.IsZero() {
+ now = time.Now()
+ }
+
+ var pemData []byte
+ var out []Check
+
+ switch {
+ case len(in.CertificatePEM) > 0:
+ pemData = in.CertificatePEM
+ if in.CertificateURL != "" {
+ out = append(out, checkCertificateURL(ctx, in, fetch))
+ }
+ case in.ExternalCertificateURL != "":
+ resp, err := fetch(ctx, in.ExternalCertificateURL)
+ if err != nil || resp.Status != 200 {
+ detail := fmt.Sprintf("could not fetch %s", in.ExternalCertificateURL)
+ if err != nil {
+ detail += fmt.Sprintf(" (%v)", err)
+ } else {
+ detail += fmt.Sprintf(": it answered %d", resp.Status)
+ }
+ return []Check{{ID: "certificate", Status: StatusFail, Detail: detail}}
+ }
+ pemData = resp.Body
+ default:
+ return []Check{{
+ ID: "certificate",
+ Status: StatusWarn,
+ Detail: "no certificate: Gmail and Apple Mail show the logo only with a VMC or CMC from a certificate authority; some other receivers show it without one",
+ }}
+ }
+
+ cert, violations := InspectCertificate(pemData, in.Domain, in.SVG, now)
+ if len(violations) > 0 {
+ reasons := make([]string, 0, len(violations))
+ for _, v := range violations {
+ reasons = append(reasons, v.String())
+ }
+ return append([]Check{{ID: "certificate", Status: StatusFail, Detail: strings.Join(reasons, "; ")}}, out...)
+ }
+ if len(cert.Warnings) > 0 {
+ return append([]Check{{ID: "certificate", Status: StatusWarn, Detail: strings.Join(cert.Warnings, "; ")}}, out...)
+ }
+ detail := fmt.Sprintf("%s issued to %s, valid until %s, and it carries this logo", strings.ToUpper(string(cert.Kind)), cert.Organization, cert.NotAfter.UTC().Format("2006-01-02"))
+ return append([]Check{{ID: "certificate", Status: StatusOK, Detail: detail}}, out...)
+}
+
+func checkCertificateURL(ctx context.Context, in Inputs, fetch Fetcher) Check {
+ resp, err := fetch(ctx, in.CertificateURL)
+ switch {
+ case err != nil:
+ return Check{ID: "certificateUrl", Status: StatusFail, Detail: fmt.Sprintf("could not fetch %s (%v)", in.CertificateURL, err)}
+ case resp.Status != 200:
+ return Check{ID: "certificateUrl", Status: StatusFail, Detail: fmt.Sprintf("%s answered %d", in.CertificateURL, resp.Status)}
+ case !bytes.Equal(bytes.TrimSpace(resp.Body), bytes.TrimSpace(in.CertificatePEM)):
+ return Check{ID: "certificateUrl", Status: StatusFail, Detail: "the public URL serves a different file than the one stored"}
+ }
+ return Check{ID: "certificateUrl", Status: StatusOK, Detail: "the certificate is public over HTTPS and matches"}
+}
+
+// parseTags reads "k=v; k=v" records (DMARC, BIMI).
+func parseTags(record string) map[string]string {
+ tags := map[string]string{}
+ for _, part := range strings.Split(record, ";") {
+ key, value, found := strings.Cut(strings.TrimSpace(part), "=")
+ if found {
+ tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
+ }
+ }
+ return tags
+}
+
+func ifs(condition bool, text string) string {
+ if condition {
+ return text
+ }
+ return ""
+}
diff --git a/services/protocols/internal/domain/bimi/check_test.go b/services/protocols/internal/domain/bimi/check_test.go
new file mode 100644
index 0000000..8879ea8
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/check_test.go
@@ -0,0 +1,176 @@
+package bimi
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+type fakeDNS map[string][]string
+
+func (f fakeDNS) LookupTXT(_ context.Context, name string) ([]string, error) {
+ records, ok := f[name]
+ if !ok {
+ return nil, errors.New("no such host")
+ }
+ return records, nil
+}
+
+func fakeFetch(files map[string]Response) Fetcher {
+ return func(_ context.Context, url string) (Response, error) {
+ resp, ok := files[url]
+ if !ok {
+ return Response{}, errors.New("connection refused")
+ }
+ return resp, nil
+ }
+}
+
+func byID(checks []Check) map[string]Check {
+ out := map[string]Check{}
+ for _, c := range checks {
+ out[c.ID] = c
+ }
+ return out
+}
+
+const (
+ logoURL = "https://mail.ebay.com/.well-known/bimi/default.svg"
+ vmcURL = "https://mail.ebay.com/.well-known/bimi/vmc.pem"
+)
+
+// goodSVG is a logo the validator accepts.
+const goodSVG = ``
+
+func TestRunChecksAllGoodWithTheRealCertificate(t *testing.T) {
+ svg := read(t, "ebay.svg")
+ pemData := read(t, "ebay.pem")
+ in := Inputs{Domain: "ebay.com", LogoURL: logoURL, CertificateURL: vmcURL, SVG: svg, CertificatePEM: pemData, Now: fixtureNow}
+ dns := fakeDNS{
+ "_dmarc.ebay.com": {"v=DMARC1; p=reject; pct=100; rua=mailto:x@ebay.com"},
+ "default._bimi.ebay.com": {"v=BIMI1; l=" + logoURL + "; a=" + vmcURL + ";"},
+ }
+ fetch := fakeFetch(map[string]Response{
+ logoURL: {Status: 200, ContentType: "image/svg+xml", Body: svg},
+ vmcURL: {Status: 200, ContentType: "application/x-pem-file", Body: pemData},
+ })
+
+ got := byID(RunChecks(context.Background(), in, dns, fetch))
+ for _, id := range []string{"logo", "dmarc", "record", "logoUrl", "certificate", "certificateUrl"} {
+ if got[id].Status != StatusOK {
+ t.Errorf("%s = %s (%s)", id, got[id].Status, got[id].Detail)
+ }
+ }
+}
+
+func TestRunChecksDMARC(t *testing.T) {
+ cases := []struct {
+ record string
+ want Status
+ }{
+ {"v=DMARC1; p=none", StatusFail},
+ {"v=DMARC1; p=quarantine", StatusOK},
+ {"v=DMARC1; p=reject; pct=100", StatusOK},
+ {"v=DMARC1; p=reject; pct=50", StatusWarn},
+ {"v=spf1 -all", StatusFail},
+ }
+ for _, c := range cases {
+ got := checkDMARC(context.Background(), "example.test", fakeDNS{"_dmarc.example.test": {c.record}})
+ if got.Status != c.want {
+ t.Errorf("%q: %s (%s), want %s", c.record, got.Status, got.Detail, c.want)
+ }
+ }
+ if got := checkDMARC(context.Background(), "example.test", fakeDNS{}); got.Status != StatusFail {
+ t.Errorf("missing record: %s", got.Status)
+ }
+}
+
+func TestRunChecksRecordMismatchesAreSpelledOut(t *testing.T) {
+ in := Inputs{Domain: "example.test", LogoURL: "https://mail.example.test/.well-known/bimi/default.svg", CertificateURL: "https://mail.example.test/.well-known/bimi/vmc.pem"}
+
+ missing := checkRecord(context.Background(), in, fakeDNS{})
+ if missing.Status != StatusFail || !strings.Contains(missing.Detail, "default._bimi.example.test") {
+ t.Errorf("missing: %+v", missing)
+ }
+
+ wrong := checkRecord(context.Background(), in, fakeDNS{"default._bimi.example.test": {"v=BIMI1; l=https://elsewhere.test/logo.svg;"}})
+ if wrong.Status != StatusFail || !strings.Contains(wrong.Detail, "l=") || !strings.Contains(wrong.Detail, "a=") {
+ t.Errorf("wrong: %+v", wrong)
+ }
+
+ // Without a certificate here, an a= in DNS is a fault too: it points nowhere
+ noCert := Inputs{Domain: "example.test", LogoURL: in.LogoURL}
+ stray := checkRecord(context.Background(), noCert, fakeDNS{"default._bimi.example.test": {"v=BIMI1; l=" + in.LogoURL + "; a=https://old.test/x.pem"}})
+ if stray.Status != StatusFail {
+ t.Errorf("stray a=: %+v", stray)
+ }
+}
+
+func TestRunChecksLogoURL(t *testing.T) {
+ in := Inputs{Domain: "example.test", LogoURL: logoURL, SVG: []byte(goodSVG)}
+ cases := map[string]struct {
+ resp Response
+ err bool
+ want Status
+ }{
+ "ok": {Response{Status: 200, ContentType: "image/svg+xml; charset=utf-8", Body: []byte(goodSVG)}, false, StatusOK},
+ "not found": {Response{Status: 404}, false, StatusFail},
+ "wrong type": {Response{Status: 200, ContentType: "text/html", Body: []byte(goodSVG)}, false, StatusFail},
+ "different bytes": {Response{Status: 200, ContentType: "image/svg+xml", Body: []byte("")}, false, StatusFail},
+ "unreachable": {Response{}, true, StatusFail},
+ }
+ for name, c := range cases {
+ files := map[string]Response{}
+ if !c.err {
+ files[logoURL] = c.resp
+ }
+ if got := checkLogoURL(context.Background(), in, fakeFetch(files)); got.Status != c.want {
+ t.Errorf("%s: %s (%s), want %s", name, got.Status, got.Detail, c.want)
+ }
+ }
+}
+
+func TestRunChecksCertificate(t *testing.T) {
+ svg := read(t, "ebay.svg")
+ pemData := read(t, "ebay.pem")
+ base := Inputs{Domain: "ebay.com", LogoURL: logoURL, SVG: svg, Now: fixtureNow}
+
+ t.Run("none is a warning, with the reason", func(t *testing.T) {
+ got := byID(checkCertificate(context.Background(), base, fakeFetch(nil)))["certificate"]
+ if got.Status != StatusWarn || !strings.Contains(got.Detail, "Gmail") {
+ t.Errorf("%+v", got)
+ }
+ })
+
+ t.Run("hosted but the public URL serves something else", func(t *testing.T) {
+ in := base
+ in.CertificatePEM, in.CertificateURL = pemData, vmcURL
+ got := byID(checkCertificate(context.Background(), in, fakeFetch(map[string]Response{vmcURL: {Status: 200, Body: []byte("other")}})))
+ if got["certificate"].Status != StatusOK || got["certificateUrl"].Status != StatusFail {
+ t.Errorf("%+v", got)
+ }
+ })
+
+ t.Run("hosted for a logo that changed since", func(t *testing.T) {
+ in := base
+ in.CertificatePEM, in.CertificateURL, in.SVG = pemData, vmcURL, []byte(goodSVG)
+ got := byID(checkCertificate(context.Background(), in, fakeFetch(map[string]Response{vmcURL: {Status: 200, Body: pemData}})))
+ if got["certificate"].Status != StatusFail || !strings.Contains(got["certificate"].Detail, "logoMismatch") {
+ t.Errorf("%+v", got["certificate"])
+ }
+ })
+
+ t.Run("kept elsewhere, fetched and inspected", func(t *testing.T) {
+ in := base
+ in.ExternalCertificateURL = "https://vmc.example.test/x.pem"
+ got := byID(checkCertificate(context.Background(), in, fakeFetch(map[string]Response{in.ExternalCertificateURL: {Status: 200, Body: pemData}})))
+ if got["certificate"].Status != StatusOK {
+ t.Errorf("%+v", got["certificate"])
+ }
+ bad := byID(checkCertificate(context.Background(), in, fakeFetch(nil)))
+ if bad["certificate"].Status != StatusFail {
+ t.Errorf("unreachable external certificate: %+v", bad["certificate"])
+ }
+ })
+}
diff --git a/services/protocols/internal/domain/bimi/svg_test.go b/services/protocols/internal/domain/bimi/svg_test.go
index 48a5543..91cf37d 100644
--- a/services/protocols/internal/domain/bimi/svg_test.go
+++ b/services/protocols/internal/domain/bimi/svg_test.go
@@ -2,6 +2,8 @@ package bimi
import (
"fmt"
+ "os"
+ "path/filepath"
"strings"
"testing"
)
@@ -150,3 +152,26 @@ func TestValidate_ReportsEveryViolationAtOnce(t *testing.T) {
}
}
}
+
+// The webmail's logo studio (services/webmail/src/lib/bimiLogo.ts) produces the
+// files in testdata/studio, and its tests keep them current. This is the other
+// half of the contract: every one of them has to pass the validator that guards
+// the upload. A studio that starts producing something the server refuses
+// fails here, instead of in front of an operator who did everything right.
+func TestWhatTheStudioProducesIsAccepted(t *testing.T) {
+ files, err := filepath.Glob("testdata/studio/*.svg")
+ if err != nil || len(files) == 0 {
+ t.Fatalf("no studio output to check (%v)", err)
+ }
+ for _, file := range files {
+ t.Run(filepath.Base(file), func(t *testing.T) {
+ svg, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if violations := Validate(svg); len(violations) != 0 {
+ t.Errorf("the validator refused what the studio made: %v", violations)
+ }
+ })
+ }
+}
diff --git a/services/protocols/internal/domain/bimi/testdata/README.md b/services/protocols/internal/domain/bimi/testdata/README.md
new file mode 100644
index 0000000..c7c114c
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/README.md
@@ -0,0 +1,15 @@
+Real Verified Mark Certificates, used only to test the parser.
+
+Both are public: a BIMI certificate is published on purpose at the URL in the
+`a=` tag of the sender's `default._bimi` DNS record, so that any mail provider
+can fetch it.
+
+| Files | Published by | Fetched from |
+|-------|--------------|--------------|
+| `ebay.pem`, `ebay.svg` | eBay, issued by DigiCert | `default._bimi.ebay.com` (2026-10-16) |
+| `cnn.pem`, `cnn.svg` | CNN, issued through Valimail | `default._bimi.cnn.com` (2026-10-16) |
+
+The tests pin the clock to 2026-10-16, inside both validity periods. They hold
+the format the parser has to read: a leaf certificate with the "Brand Indicator
+for Message Identification" extended key usage and a logotype extension whose
+data URI is the logo, gzipped and base64 encoded.
diff --git a/services/protocols/internal/domain/bimi/testdata/cnn.pem b/services/protocols/internal/domain/bimi/testdata/cnn.pem
new file mode 100644
index 0000000..844fb7b
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/cnn.pem
@@ -0,0 +1,144 @@
+-----BEGIN CERTIFICATE-----
+MIIMqTCCCpGgAwIBAgIQDLZYmZx5WjoZx+5ENKgQvTANBgkqhkiG9w0BAQsFADBf
+MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xNzA1BgNVBAMT
+LkRpZ2lDZXJ0IFZlcmlmaWVkIE1hcmsgUlNBNDA5NiBTSEEyNTYgMjAyMSBDQTEw
+HhcNMjYwODI4MDAwMDAwWhcNMjcwOTAxMjM1OTU5WjCCAUMxEzARBgsrBgEEAYI3
+PAIBAxMCVVMxGTAXBgsrBgEEAYI3PAIBAhMIRGVsYXdhcmUxHTAbBgNVBA8TFFBy
+aXZhdGUgT3JnYW5pemF0aW9uMRAwDgYDVQQFEwcyOTc2NzMwMQswCQYDVQQGEwJV
+UzEQMA4GA1UECBMHR2VvcmdpYTEQMA4GA1UEBxMHQXRsYW50YTEbMBkGA1UECRMS
+MTkwIE1hcmlldHRhIFN0IE5XMSEwHwYDVQQKExhDYWJsZSBOZXdzIE5ldHdvcmss
+IEluYy4xITAfBgNVBAMTGENhYmxlIE5ld3MgTmV0d29yaywgSW5jLjEfMB0GCisG
+AQQBg55fAQ0TD1JlZ2lzdGVyZWQgTWFyazESMBAGCisGAQQBg55fAQMTAlVTMRcw
+FQYKKwYBBAGDnl8BBBMHNTgxNzkzMDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
+AQoCggEBALyeWFAqMTLwoyBIgdj2TEVoTgikbTjri0AR7W0nFPIW0X/yrG1jPaG2
+lB/OHFWGplM44aKkTvQ9fw+/bTK4Mc9exuRTyGXD9xx71VbKyngJgFdZOJYUMKO7
+nq2jbriLB6HeE02QTcxrGO+fTuhX38qUw46SNKSw7FnOreYnx88QHyU9x31XE71a
+eqYGR5216exHj7G2jKyT8H4WDVaIzRv/GFG7z75Kmg49+RgQyvBo3n7Okqvdk7q5
+Ta/LYPpi1nd0/KpfMHAz8NaPAIRMYxm7UaFI0iPuE6wDOoVSIhGYPuj3LluHdRhX
+qXWU+ChUEby5unBLYJDt/pcPUcyE8H0CAwEAAaOCB3kwggd1MB8GA1UdIwQYMBaA
+FL6fvY1XbZW1rWPDl06rqIRdOgf1MB0GA1UdDgQWBBQzLUCqtZi1xOrnA3sLqvdw
+53Zw0jASBgNVHREECzAJggdjbm4uY29tMFAGA1UdIARJMEcwNwYKYIZIAYb9bAAC
+BTApMCcGCCsGAQUFBwIBFhtodHRwOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDAYK
+KwYBBAGDnl8BATATBgNVHSUEDDAKBggrBgEFBQcDHzCBpQYDVR0fBIGdMIGaMEug
+SaBHhkVodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRWZXJpZmllZE1h
+cmtSU0E0MDk2U0hBMjU2MjAyMUNBMS5jcmwwS6BJoEeGRWh0dHA6Ly9jcmw0LmRp
+Z2ljZXJ0LmNvbS9EaWdpQ2VydFZlcmlmaWVkTWFya1JTQTQwOTZTSEEyNTYyMDIx
+Q0ExLmNybDBkBggrBgEFBQcBAQRYMFYwVAYIKwYBBQUHMAKGSGh0dHA6Ly9jYWNl
+cnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFZlcmlmaWVkTWFya1JTQTQwOTZTSEEy
+NTYyMDIxQ0ExLmNydDAMBgNVHRMBAf8EAjAAMIIFDAYIKwYBBQUHAQwEggT+MIIE
++qKCBPagggTyMIIE7jCCBOowggTmFg1pbWFnZS9zdmcreG1sMCMwITAJBgUrDgMC
+GgUABBTqjIHaYzxmoWJiE0p4V2zfBnY46TCCBK4WggSqZGF0YTppbWFnZS9zdmcr
+eG1sO2Jhc2U2NCxINHNJQUFBQUFBQUFDcDFVVFcvYk9CQTl4NytDcTU0S2tEU0hG
+TCtNT0VVYkZFMkJibEZnQVY4WHJxTEd3bXB0UTFia3RMOSszMUFKMGtOUVlBc2s1
+SGlHbkhuejVsR1hieDcrN2NYVURxZnVzRjlYcEUwbDJuMXp1TzMyZCt2cWZ2eW1V
+dlhtYW5INWgxTGlRN3R2aCsxNEdGYmk3ZTNoYXlzKzl2MzlhU3d1WVd1TnkxTDh0
+ZmtnM2o4Y0Q4TW92dlQzZCtyalh1amkzTXcxVmlKb1k4UzcrNjYvRmVhMUVFb2gv
+V202K3htRXJjVFg3YW45TWh5K2RYMjdyc1p1LzEwZFQ1WG9idGZWcCszM2R2aWJL
+Z0hrKzlPNjJvM2pjYlZjbnM5bmZYYjZNTnd0clRGbWlaU1BSMVlQZmJmLzU2V0Rs
+SE5lbG1pMW1McjIvTzd3c0s2TU1DS0hxUDI4bGlTcjAzSGJBTWh4YUUvdE1MVVZN
+SS9kMkxkWDE1OC9YeTVuYzNGNTNJNDdBY2o5dW5wMWZRMFVZQk9JLzR3eGF5K2Rk
+ZHBPTnBDdUd5UEo2Rm9GdUNsclVoUzBsWlowYWhUcExJMzJ5dW1FUFNtdkkrK05p
+cnFXUnBHSHorbW9yRlVFMGorRldNczZCWjBYRjcxQ2NxZkllRDd2VUlBUEoyM3g3
+eFZaM0lyYWNTUkxRbkZjMUtIc2ViS0dxLzhDVjlKT1dxU0NQeXVEbUkzSVIwNDdM
+dXh4VUdXZ0FJYkFmdkl2SWtnemdscVNaZ1FCQ1BJakFzb2NBZ0owQUk0a0pGVXJY
+b3AxWXhNS05adzlNUTNXNEtLdkdXTXhlUVgrT2Y3c2w4VS8yenVnM25rN0taZDEy
+Q2x2MFZRQ0JCejFHU3hIcHBWOGdrbkJjbDhOeVJUQlFDUW14RWVPekhGZWR3VE9i
+T1BRbWpReVlMb1lDbXhZNkEwUUpwVnhFUzBoV25NRzlHVWxxd29tQ0d0cWtpRXpB
+a284RENDZFRTYVpGaGU0SHpYOTd2MU5FZHhORWQrUGF2a0xmVkwwR0c2ZDBIOERq
+aVRZWUUyaEZwbkk2VTNwbkZmMGJGQTQ0RHdtQlRncXpCc3ZHNWNzNnhCallCbkp3
+RE1IRXNsZ1ZaRVJWS1FzSjg1RjVpUnRTZTE0OHF4di9IQTFRMGlJbDFsQ1NlREFN
+dXMxNC9MOElIS1FyRGpzT3FHZVl6L09NbVNlQmd0TnNwckFCSkpCWjZybWZHQW16
+eUJmZ2toUEVPMFR4UFFFTWNoWjZNOGdqZlNFblYrem5CL2NzNnM0TE1lWU95VCtu
+d0I1Ymp0V2FaeUkyK1c2MER4clFORk1laXdtYTBEVkVCdEVvT2JKMTBYdVJRL2Mx
+L1Nrb04rN3pRK0RTdHNZQ1Y1WlVhSGhaMktnQUNyZkpZNFpyTGFzME1WT2hSSnd6
+QnNLNDN0VlBpb0pmNmRIVTVaZk44NzVqWXZsTVM0dXJpMGFEeEsvZVI3UUpFU0lw
+Ky9rVC9xY2hjeGY5NnZGZjJ5VHVidTlCZ0FBMIGLBgorBgEEAdZ5AgQCBH0EewB5
+AHcAVVlTrjCWAIBs0utSCKbJnpMYKKwQVrRCHFU2FUxfdawAAAGgSGMMygAABAMA
+SDBGAiEAlJdKzRd67dcx2hlBC2RAcJRjdrR8XUyZyenMZ/TtsboCIQCsiYcIGQQ6
+SVZG6GkOmLwf3MeyEaHSj5QjDNx4qyU9xTANBgkqhkiG9w0BAQsFAAOCAgEAVzEq
+16pm/ip7w7G/qya9tB2uyv+dvbq+W265tquwubh0KwwoG5aM0xhWZVDV51sR+DM2
+ZEvHVhubgsuXUp5K5XisXxQVgTkjFsZ3WL3CFQ/uZbK6SXEMpO188ER/qKvo3jnj
+JPTX8GmAnyJwPESinbDzj/jHFPZp0aey1R5LxqyDCOvgs3KO28+M0rOQTyZQYjFe
+HIwRFBfF/lF9ioQcQlAxafwbl44pW/Ticc4gSAT/7EvPT2mVKNfcC1IXVPG7nUvH
+2RiNpDZmWwFxbnbK3PGfks+b6Br9QI6DXSOropIzXH+yunwk+SCDaqi46cEAUPOH
+m9coCPVd5Sf9VhI3YnbiL0o2aGPk2KaFii96t+z68ibscdw42PPbjESwGeEHRT3B
+hKoS5IRUYyCCBf6lEdg2n43z7J5L3VyiWK5ezhdnJ1eoLFNVMIxzjNqGJuanVaXc
+Vmu4XSwxqVqidR8RVoW5fWVVqKOndvFtO97I7ZOlz638DqiersMASMBEMnczajvN
+lyw1GdkEEM9LADwDq/1IyionsTGD3mm4YbWkZTlzMxiyLtzwDnleXssG4Fj5Ep2x
+Dzmzajf8EiI1A6FXeTBkTsWRJhoqkdQGGpn9mcJzHugofCEkFG71b9Tf4jwYbn49
+iOgI0ojsOWpNIVstxRBNW91bftm5FaDFQR6iOMo=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHCzCCBPOgAwIBAgIQDFtvfuuz8cKs3aE6mJ29gjANBgkqhkiG9w0BAQsFADCB
+iDELMAkGA1UEBhMCVVMxDTALBgNVBAgTBFV0YWgxDTALBgNVBAcTBExlaGkxFzAV
+BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t
+MScwJQYDVQQDEx5EaWdpQ2VydCBWZXJpZmllZCBNYXJrIFJvb3QgQ0EwHhcNMjEw
+NzAxMDAwMDAwWhcNMzYwNjMwMjM1OTU5WjBfMQswCQYDVQQGEwJVUzEXMBUGA1UE
+ChMORGlnaUNlcnQsIEluYy4xNzA1BgNVBAMTLkRpZ2lDZXJ0IFZlcmlmaWVkIE1h
+cmsgUlNBNDA5NiBTSEEyNTYgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQDcL2QJnDBab/pzEf3Yoi6mAXmKD34MOgqMouGLTT4CvWSugTi/
+xjPPbFTf2zJG4Pgx34YaIGYzvnTSgpscpNf+sstiAPDrEPwuWk/ZQmo4E47crnOV
+hHTHltOc8XRip1UwCPzOB2hwmnZwFTPP6kS6hp8ao9P5qa40Up03sh1QhhxvhVTX
+f6DsxSjMVpCz2BICa56mW7lPeEf5BgqqdaeUoEvYdw4mw+37V5vugUF8pH1lus8J
+f97DYPCC8uf14Rww8NXhOeLLyUylqP9t0WS5nlwPjiobJAm/VdhgrOzbAuUyU5uJ
+2bmT4btp6aHNvmOesHvhES96fWdjsu2jdVQTa9e87PK+VzUiSdDDNHwtY/maUG0y
+9AYThU9PaKEkGifmF1+88dzN+IeTsy469ikQ5nWEs7lEiW8omO2aqDcY2BYRfcUD
+aP4EX5RdVwLlIf3WgsVVejgZ0adlqB+imip2DHFHD7xnCA+akDfRALgdckNtxGic
+MxWCuttmuQGsf7+3OexM0qyMo3F+yvr+Hr8zrV9FjJn8R1crABvouX+9m24hj2Ql
+I3OGAXSj3rrLMXZZmFeAb1iTPqpfkDAJb5tpMTqWWbEas2L76aoztFt9n4kZQfc3
+DDx4L4HxUkF+GBsi/pqp50Op2taInqnTkl6cZt1JFaXJyIv/zZrXgTwN1QIDAQAB
+o4IBlzCCAZMwHQYDVR0OBBYEFL6fvY1XbZW1rWPDl06rqIRdOgf1MB8GA1UdIwQY
+MBaAFOxvIqSzBOLBY4fmd2PqRmlO7vzrMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUE
+DDAKBggrBgEFBQcDHzASBgNVHRMBAf8ECDAGAQH/AgEAMHwGCCsGAQUFBwEBBHAw
+bjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEYGCCsGAQUF
+BzAChjpodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRWZXJpZmll
+ZE1hcmtSb290Q0EuY3J0MEgGA1UdHwRBMD8wPaA7oDmGN2h0dHA6Ly9jcmwzLmRp
+Z2ljZXJ0LmNvbS9EaWdpQ2VydFZlcmlmaWVkTWFya1Jvb3RDQS5jcmwwUAYDVR0g
+BEkwRzA3BgpghkgBhv1sAAIFMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGln
+aWNlcnQuY29tL0NQUzAMBgorBgEEAYOeXwEBMA0GCSqGSIb3DQEBCwUAA4ICAQCq
+g+EBenB6355D4oN3WUj741u8a/McUbAWAh2jvBq9beUBpeortI0zhwo2jXQLIDjt
+lJdtNwcZWmmV6ouc6cp3ZqGnk0HRnD1HNASCQ33hTw5UaqG0tuEUOq8VKNnNmVBk
+XNGb66PVw6RFjQH4/WKDBqiglhiI23f5cpDalfSXX4RVJZ3XLGPVSMmq2TOHmQWF
++h1NR6pVyDdXJoouUK8WOkIksYyjlxMatVX0QasX8tepHizWBMZC0HMg3Q2gr0h4
+HKCducumhbuuRnSwpYhG+OU+QAITKK29FicuGMx1i+Cg1PyQEIdmq41LgzLendaR
+tewhUuFfm4FYG/b3sSklg+vesw8CbDE3gXQR7Y/b1M1jiZ6vnlnP/q72ZRUB5ApN
+kA/h9fkoJNR8+R42BDfJ7o9Qu9EWnMeshNB26EIUpM20FdYfOhtWI6IQQ9kmnLwk
+6yzD2nolhwuSEp32hK9X5q/jPfXstjOsQCkUkRoGkGBkSERU2xjaWhOYYJbrmg4c
+1Qtqcr65mc9FyqYckZ0LdLMjd8SPQn/jQPLrQ+5PYcfI/GEZIZ4dqZ73enWRxMKq
+1d/v891yZBQrmsa+f/H1uHAHmGfMzwcA6WWlXZYnjJD0qPLpUzyBwFoGd9YIy0/I
+saBEzyI2AsX9Eal3wrjflZOyMzE7zYKV5YEtRDDBWA==
+-----END CERTIFICATE-----
+-----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-----
diff --git a/services/protocols/internal/domain/bimi/testdata/cnn.svg b/services/protocols/internal/domain/bimi/testdata/cnn.svg
new file mode 100644
index 0000000..401404d
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/cnn.svg
@@ -0,0 +1,19 @@
+
+
+
diff --git a/services/protocols/internal/domain/bimi/testdata/ebay.pem b/services/protocols/internal/domain/bimi/testdata/ebay.pem
new file mode 100644
index 0000000..9fa655a
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/ebay.pem
@@ -0,0 +1,145 @@
+-----BEGIN CERTIFICATE-----
+MIIMxTCCCq2gAwIBAgIQCAlX4tQpnQMqdL+3VMj70zANBgkqhkiG9w0BAQsFADBf
+MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xNzA1BgNVBAMT
+LkRpZ2lDZXJ0IFZlcmlmaWVkIE1hcmsgUlNBNDA5NiBTSEEyNTYgMjAyMSBDQTEw
+HhcNMjYwNzMxMDAwMDAwWhcNMjcwNzMwMjM1OTU5WjCCASwxEzARBgsrBgEEAYI3
+PAIBAxMCVVMxGTAXBgsrBgEEAYI3PAIBAhMIRGVsYXdhcmUxHTAbBgNVBA8TFFBy
+aXZhdGUgT3JnYW5pemF0aW9uMRAwDgYDVQQFEwcyODcxMzUyMQswCQYDVQQGEwJV
+UzETMBEGA1UECBMKQ2FsaWZvcm5pYTERMA8GA1UEBxMIU2FuIEpvc2UxHDAaBgNV
+BAkTEzIxMDUgRSBIYW1pbHRvbiBBdmUxEzARBgNVBAoTCmVCYXksIEluYy4xEzAR
+BgNVBAMTCmVCYXksIEluYy4xHzAdBgorBgEEAYOeXwENEw9SZWdpc3RlcmVkIE1h
+cmsxEjAQBgorBgEEAYOeXwEDEwJVUzEXMBUGCisGAQQBg55fAQQTBzQ0MDg0MjMw
+ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCavWp79EddD6sGELSbUndP
+YMMfMReYDQip95UcIv5oftvFnaKEleeFbII2cHhyBV2qpo0Owb8b5cuWXU2R+oiM
+MDuxcIf63oUC594zqVW4uwcTu5c3rdxkag2sO134SKRMlvw4/cZdd3mX4t0N+818
+jtU79DtIz7qN/R35BpGgmg+Z7VZNIHZZDlcWqvM3KdyAfg6lpvJDBt5acUYIAyqn
+2639CmFpREYl/LykqQJzpmx0JEXPruBjAjRBbsMcWH3qvlBSNAbheljN/5vs0x8T
+kkpyegPf6yvWPfWFvhf59PHK9srFnokvENcC06fDs8KtW2ziN18CgZYnk4aMvBrx
+AgMBAAGjggesMIIHqDAfBgNVHSMEGDAWgBS+n72NV22Vta1jw5dOq6iEXToH9TAd
+BgNVHQ4EFgQUDTLqc07pn6IOX44v66+0NfysV6MwgaAGA1UdEQSBmDCBlYIKZWJh
+eS5jby51a4IHZWJheS5kZYIIZWJheS5jb22CEW5vcmVwbHkxLmViYXkuY29tgg1y
+ZXBseS5lYmF5LmVzghFyZXBseS5lYmF5LmNvbS5hdYINcmVwbHkuZWJheS5pdIIQ
+cmVwbHkuZWJheS5jby51a4INcmVwbHkuZWJheS5kZYIPcmVwbHk1LmViYXkuY29t
+MFAGA1UdIARJMEcwNwYKYIZIAYb9bAACBTApMCcGCCsGAQUFBwIBFhtodHRwOi8v
+d3d3LmRpZ2ljZXJ0LmNvbS9DUFMwDAYKKwYBBAGDnl8BATATBgNVHSUEDDAKBggr
+BgEFBQcDHzCBpQYDVR0fBIGdMIGaMEugSaBHhkVodHRwOi8vY3JsMy5kaWdpY2Vy
+dC5jb20vRGlnaUNlcnRWZXJpZmllZE1hcmtSU0E0MDk2U0hBMjU2MjAyMUNBMS5j
+cmwwS6BJoEeGRWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFZlcmlm
+aWVkTWFya1JTQTQwOTZTSEEyNTYyMDIxQ0ExLmNybDBkBggrBgEFBQcBAQRYMFYw
+VAYIKwYBBQUHMAKGSGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy
+dFZlcmlmaWVkTWFya1JTQTQwOTZTSEEyNTYyMDIxQ0ExLmNydDAMBgNVHRMBAf8E
+AjAAMIIEsAYIKwYBBQUHAQwEggSiMIIEnqKCBJqgggSWMIIEkjCCBI4wggSKFg1p
+bWFnZS9zdmcreG1sMCMwITAJBgUrDgMCGgUABBQDJLNL8Giz899rSPVMpJc6EIYs
+DDCCBFIWggROZGF0YTppbWFnZS9zdmcreG1sO2Jhc2U2NCxINHNJQUFBQUFBQUFD
+blZVVFkvYk9BeTk5MWNZM3F1a2lQcXc1VUV5ZzUxdUIzT1lBZ1cyM1h1cThjYkd1
+a2tRRzBuYlg5OUhxczBNc0dnQVI4K1VTRDQrVWw3ZmZmMHlWZWYrTkkrSC9hWW1Z
+K3VxMytmRDg3amZiZXBQSHg5MHFxdDUyZTZmdDlOaDMyL3FiLzFjMzkyK1djL24z
+V3MzVjFlZnQzUC80WFQ0ZDV4d2JCbjMzL1J4cnF2ejJGL3VEMTgzdGExczVhamhw
+NjZROUdZK2JqTk9Iay85M0ovT3ZSajM4NlllbHVWNHMxcGRMaGR6OGVadzJxMmN0
+WGFGaFBYdG02cGFMK015OWJlZnh5K2poazFMSm5KSXRsNlZMVDYwcThiblRYMy9Y
+L0dCNGRUbnBRSzNhVlAvOFNDL3VycU16OHV3cVlYUjBJKzdZU2t2S3dteDJyMks5
+SFRZSFg2RjJwVVY2TGhkaG12UStPZURoUXc0L0o1OE1LU0lyUEZaSjlNcUFFMWlN
+MTBCcldteVZSNTdIcmhWT0JzWlpBQlNWbEUwUVVmWUJNQWxualZ4MHJjVW5Ba1Mz
+Q255NllxdUtiOVg3eW1LcTNQR0lZc3pTWEZVdG1GZnBRR2t1cXpocDUxbWJ2Q1Z0
+Y21BQ1FmQjBwcUdXV3BLd3F6aDVGa1RjVEEya203Z0w0QzhpZGxxcVNHeEMzTnhN
+QXJ4WEtJVHRscFp5V0VIQUJYQnEwWEtxRHRPNlVDaFkyYXhKR0hxb1lSdFRHUUNB
+NzlSSms3S3BSQVZ2MDQ1NHFOQUdiWUFCUjBwb1lGOGphRi9SSXluVjhKOEwzMytY
+eVBmUmU5OEtvMk14SFJ0aEl3Z0hIU0htbFBoaVpvTHlycGxYZGtRbEJpRHBERCtr
+ZDNSRFdSRGcwR3JhRmhVYXJra3RBM1VpVFZubENQUENzOUMwbzZyWTZGYUV3YUk4
+amJpakV2SUVSRE1vMjdsMFpVQzBaZVd4MGE3QUkwN01CV0F4RlJTZ2hSNmlLZ2Vz
+NEs5Z2pJM2pxWHkwdUxTTXdXdmVNYno5TUw3RmZ5ZGJOWTI0YStmODUrNGYrUWl4
+TUdneVRSRHVNQUw5MkxHQ3duU1Z3dHhpd012L1A1MzI3QXVIRUc5QklPVURUNDFx
+dW1La3BDbE9hTVl5bHhzWkRHd0RjRndIWGh5VzY3UWlxVGd6OVBzQ3NCZndyMGdu
+cllnOVdvcGtCSGFXWm9KR3c5d0NSU0tqTXJKQUFlK0xIeExIaHRNTUZKNnVlR04z
+RE1aNGFjcjBSZjBPK1ZTYzUrb0xjbzVHN2hrWEtwSnV3NlVZakplaHI2ZGtoR3gz
+TVJrY1dmUjNJRUxub2kxOVJoOVFaclJvd1M2WnZ6MU1lTmx6Wi9TMngvaHVqVmcr
+UVVBQUE9PTCBiwYKKwYBBAHWeQIEAgR9BHsAeQB3AFVZU64wlgCAbNLrUgimyZ6T
+GCisEFa0QhxVNhVMX3WsAAABn7gGRosAAAQDAEgwRgIhAIuPenLVXIsZEtLGDOIA
+BQTuWMt6kA44mnTJtKY54ZmdAiEAn7uNidm55MgI8mhbnAYC1SZ0I7BsOD9NIFgm
+eoCGfs4wDQYJKoZIhvcNAQELBQADggIBAL8vksiwFDsEapsHi3pw8FV8lLH1WpLi
++M7u3ZsDjMDN7cSKAoWbeqH/eQ1dds7M+WCl6fjMgLdURiAyB1dDOxP0tyO+q4Io
+fCjraNKrocRRzj4SADx4Ry558EuHIc43W4GebFSJFLp2mCB9HkRJ3pJAGvaoz6t9
+PLGmyzQN2BOdZkrsWgiYMaftrCnrqeVyQ3v57KiE+oPMlH8EtOnGgTXHLDRnDmy/
+AUxTc+irAzOE2GEExJhGiWvAWmiA/2jEen1iP61V+PPRfrC7Md/Q+Z67xj9VsS6U
+6Cz+HDrZe+DEcAi0op9qFzt9wCvYTgyIafHXFobyvrFbYo3MYSLsT6qn4Svk3C3m
+1KdCQkjFbBDQvOSPAQrzStdyGwrTJMXXFgUE2wN3MtSovemL0f4+/CSW2CthMVUo
+0UZOguZ5KplodbJAkZcdCAQVqkp3dR/f3nwhhixbY+N/KmqLwPRcn+TAVnP8AbKQ
+acny9E8BryF+b1dOkIfzvyWjLVyAXZVTAtXIFkiJiWFo6WT0uyHRDBT0cwGHYeik
+KT0pI6lcsL5ScUlAVnuG3dm8oT0K+H1oNy4xf7IKAyd6BlcZVD2E1MMicf7ikn1v
+RUwwrQsPGq09+JE7wlthiJijWXlGNCzELu6oO6ztyqQ9U6dLdUjvPxdQBLSu3Dnj
+RjlGzqYTaTCs
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHCzCCBPOgAwIBAgIQDFtvfuuz8cKs3aE6mJ29gjANBgkqhkiG9w0BAQsFADCB
+iDELMAkGA1UEBhMCVVMxDTALBgNVBAgTBFV0YWgxDTALBgNVBAcTBExlaGkxFzAV
+BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t
+MScwJQYDVQQDEx5EaWdpQ2VydCBWZXJpZmllZCBNYXJrIFJvb3QgQ0EwHhcNMjEw
+NzAxMDAwMDAwWhcNMzYwNjMwMjM1OTU5WjBfMQswCQYDVQQGEwJVUzEXMBUGA1UE
+ChMORGlnaUNlcnQsIEluYy4xNzA1BgNVBAMTLkRpZ2lDZXJ0IFZlcmlmaWVkIE1h
+cmsgUlNBNDA5NiBTSEEyNTYgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQDcL2QJnDBab/pzEf3Yoi6mAXmKD34MOgqMouGLTT4CvWSugTi/
+xjPPbFTf2zJG4Pgx34YaIGYzvnTSgpscpNf+sstiAPDrEPwuWk/ZQmo4E47crnOV
+hHTHltOc8XRip1UwCPzOB2hwmnZwFTPP6kS6hp8ao9P5qa40Up03sh1QhhxvhVTX
+f6DsxSjMVpCz2BICa56mW7lPeEf5BgqqdaeUoEvYdw4mw+37V5vugUF8pH1lus8J
+f97DYPCC8uf14Rww8NXhOeLLyUylqP9t0WS5nlwPjiobJAm/VdhgrOzbAuUyU5uJ
+2bmT4btp6aHNvmOesHvhES96fWdjsu2jdVQTa9e87PK+VzUiSdDDNHwtY/maUG0y
+9AYThU9PaKEkGifmF1+88dzN+IeTsy469ikQ5nWEs7lEiW8omO2aqDcY2BYRfcUD
+aP4EX5RdVwLlIf3WgsVVejgZ0adlqB+imip2DHFHD7xnCA+akDfRALgdckNtxGic
+MxWCuttmuQGsf7+3OexM0qyMo3F+yvr+Hr8zrV9FjJn8R1crABvouX+9m24hj2Ql
+I3OGAXSj3rrLMXZZmFeAb1iTPqpfkDAJb5tpMTqWWbEas2L76aoztFt9n4kZQfc3
+DDx4L4HxUkF+GBsi/pqp50Op2taInqnTkl6cZt1JFaXJyIv/zZrXgTwN1QIDAQAB
+o4IBlzCCAZMwHQYDVR0OBBYEFL6fvY1XbZW1rWPDl06rqIRdOgf1MB8GA1UdIwQY
+MBaAFOxvIqSzBOLBY4fmd2PqRmlO7vzrMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUE
+DDAKBggrBgEFBQcDHzASBgNVHRMBAf8ECDAGAQH/AgEAMHwGCCsGAQUFBwEBBHAw
+bjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEYGCCsGAQUF
+BzAChjpodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRWZXJpZmll
+ZE1hcmtSb290Q0EuY3J0MEgGA1UdHwRBMD8wPaA7oDmGN2h0dHA6Ly9jcmwzLmRp
+Z2ljZXJ0LmNvbS9EaWdpQ2VydFZlcmlmaWVkTWFya1Jvb3RDQS5jcmwwUAYDVR0g
+BEkwRzA3BgpghkgBhv1sAAIFMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGln
+aWNlcnQuY29tL0NQUzAMBgorBgEEAYOeXwEBMA0GCSqGSIb3DQEBCwUAA4ICAQCq
+g+EBenB6355D4oN3WUj741u8a/McUbAWAh2jvBq9beUBpeortI0zhwo2jXQLIDjt
+lJdtNwcZWmmV6ouc6cp3ZqGnk0HRnD1HNASCQ33hTw5UaqG0tuEUOq8VKNnNmVBk
+XNGb66PVw6RFjQH4/WKDBqiglhiI23f5cpDalfSXX4RVJZ3XLGPVSMmq2TOHmQWF
++h1NR6pVyDdXJoouUK8WOkIksYyjlxMatVX0QasX8tepHizWBMZC0HMg3Q2gr0h4
+HKCducumhbuuRnSwpYhG+OU+QAITKK29FicuGMx1i+Cg1PyQEIdmq41LgzLendaR
+tewhUuFfm4FYG/b3sSklg+vesw8CbDE3gXQR7Y/b1M1jiZ6vnlnP/q72ZRUB5ApN
+kA/h9fkoJNR8+R42BDfJ7o9Qu9EWnMeshNB26EIUpM20FdYfOhtWI6IQQ9kmnLwk
+6yzD2nolhwuSEp32hK9X5q/jPfXstjOsQCkUkRoGkGBkSERU2xjaWhOYYJbrmg4c
+1Qtqcr65mc9FyqYckZ0LdLMjd8SPQn/jQPLrQ+5PYcfI/GEZIZ4dqZ73enWRxMKq
+1d/v891yZBQrmsa+f/H1uHAHmGfMzwcA6WWlXZYnjJD0qPLpUzyBwFoGd9YIy0/I
+saBEzyI2AsX9Eal3wrjflZOyMzE7zYKV5YEtRDDBWA==
+-----END CERTIFICATE-----
+-----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-----
diff --git a/services/protocols/internal/domain/bimi/testdata/ebay.svg b/services/protocols/internal/domain/bimi/testdata/ebay.svg
new file mode 100644
index 0000000..39063d1
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/ebay.svg
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/services/protocols/internal/domain/bimi/testdata/studio/from-a-design-tool.svg b/services/protocols/internal/domain/bimi/testdata/studio/from-a-design-tool.svg
new file mode 100644
index 0000000..dd7d26a
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/studio/from-a-design-tool.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/services/protocols/internal/domain/bimi/testdata/studio/positioned-and-zoomed.svg b/services/protocols/internal/domain/bimi/testdata/studio/positioned-and-zoomed.svg
new file mode 100644
index 0000000..db9972a
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/studio/positioned-and-zoomed.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/services/protocols/internal/domain/bimi/testdata/studio/traced-from-a-png.svg b/services/protocols/internal/domain/bimi/testdata/studio/traced-from-a-png.svg
new file mode 100644
index 0000000..b9bd5c5
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/testdata/studio/traced-from-a-png.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/services/protocols/internal/domain/bimi/vmc.go b/services/protocols/internal/domain/bimi/vmc.go
new file mode 100644
index 0000000..c37c28b
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/vmc.go
@@ -0,0 +1,268 @@
+package bimi
+
+import (
+ "bytes"
+ "compress/gzip"
+ "crypto/x509"
+ "encoding/asn1"
+ "encoding/base64"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+)
+
+// A BIMI certificate is not something this service can make. A Verified Mark
+// Certificate (VMC) or Common Mark Certificate (CMC) is issued by a certificate
+// authority after it has checked the organisation, and, for a VMC, a registered
+// trademark. What the service can do is hold the certificate the authority
+// returns, serve it where the DNS record points, and check it against the logo
+// before anything is published, because a certificate whose embedded logo is
+// not the logo at the "l=" URL makes the receiver ignore BIMI without a word.
+
+var (
+ // id-kp-BrandIndicatorforMessageIdentification (RFC 9495 / BIMI draft).
+ oidBrandIndicatorEKU = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 31}
+ // id-pe-logotype (RFC 3709): where the logo is embedded.
+ oidLogotype = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 12}
+ // The mark type the issuer records in the subject: "Registered Mark" for a
+ // VMC, "Prior Use Mark" for a CMC.
+ oidMarkType = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 53087, 1, 13}
+)
+
+// MaxCertificateBytes bounds what is accepted. Real chains are about 10 KB.
+const MaxCertificateBytes = 64 * 1024
+
+// Kind is what sort of mark certificate this is.
+type Kind string
+
+const (
+ KindVMC Kind = "vmc"
+ KindCMC Kind = "cmc"
+ KindUnknown Kind = "unknown"
+)
+
+// Certificate describes a parsed BIMI certificate chain for the console.
+type Certificate struct {
+ Subject string
+ Organization string
+ Issuer string
+ NotBefore time.Time
+ NotAfter time.Time
+ DNSNames []string
+ MarkType string
+ Kind Kind
+ ChainLength int
+ // MatchesLogo is true when the logo embedded in the certificate is, byte
+ // for byte, the logo being published.
+ MatchesLogo bool
+ Warnings []string
+}
+
+// InspectCertificate parses a PEM chain and reports every reason it would not
+// work for domain and logo. A nil Certificate means nothing could be read.
+//
+// All reasons at once, for the same reason Validate reports all of its own.
+func InspectCertificate(pemData []byte, domain string, logo []byte, now time.Time) (*Certificate, []Violation) {
+ var out []Violation
+ add := func(rule, detail string) { out = append(out, Violation{Rule: rule, Detail: detail}) }
+
+ if len(pemData) == 0 {
+ return nil, []Violation{{Rule: "empty", Detail: "the file is empty"}}
+ }
+ if len(pemData) > MaxCertificateBytes {
+ return nil, []Violation{{Rule: "size", Detail: fmt.Sprintf("%d bytes; the limit is %d", len(pemData), MaxCertificateBytes)}}
+ }
+
+ var certs []*x509.Certificate
+ rest := pemData
+ for {
+ var block *pem.Block
+ block, rest = pem.Decode(rest)
+ if block == nil {
+ break
+ }
+ // Anything but a certificate is refused outright. A private key pasted
+ // here by mistake would be served to the whole Internet from a public URL.
+ if block.Type != "CERTIFICATE" {
+ return nil, []Violation{{Rule: "pemBlock", Detail: fmt.Sprintf("found a %q block; only CERTIFICATE blocks are accepted, and never a private key", block.Type)}}
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return nil, []Violation{{Rule: "certificate", Detail: "a certificate in the file could not be parsed"}}
+ }
+ certs = append(certs, cert)
+ }
+ if len(certs) == 0 {
+ return nil, []Violation{{Rule: "pem", Detail: "no PEM certificate found"}}
+ }
+
+ leaf := certs[0]
+ info := &Certificate{
+ Subject: leaf.Subject.CommonName,
+ Issuer: leaf.Issuer.CommonName,
+ NotBefore: leaf.NotBefore,
+ NotAfter: leaf.NotAfter,
+ DNSNames: leaf.DNSNames,
+ ChainLength: len(certs),
+ Kind: KindUnknown,
+ }
+ if len(leaf.Subject.Organization) > 0 {
+ info.Organization = leaf.Subject.Organization[0]
+ }
+ info.MarkType = markType(leaf)
+ switch {
+ case strings.Contains(strings.ToLower(info.MarkType), "registered"):
+ info.Kind = KindVMC
+ case strings.Contains(strings.ToLower(info.MarkType), "prior use"):
+ info.Kind = KindCMC
+ }
+
+ if !hasBrandIndicatorEKU(leaf) {
+ add("extendedKeyUsage", "the first certificate is not a BIMI certificate (no Brand Indicator for Message Identification usage); the leaf certificate must come first")
+ }
+
+ switch {
+ case now.Before(leaf.NotBefore):
+ add("validity", fmt.Sprintf("not valid until %s", leaf.NotBefore.UTC().Format("2006-01-02")))
+ case now.After(leaf.NotAfter):
+ add("validity", fmt.Sprintf("expired on %s", leaf.NotAfter.UTC().Format("2006-01-02")))
+ case leaf.NotAfter.Sub(now) < 30*24*time.Hour:
+ info.Warnings = append(info.Warnings, fmt.Sprintf("expires on %s: ask the authority for a renewal", leaf.NotAfter.UTC().Format("2006-01-02")))
+ }
+
+ if !coversDomain(leaf, domain) {
+ add("domain", fmt.Sprintf("the certificate does not cover %s (it covers %s)", domain, strings.Join(leaf.DNSNames, ", ")))
+ }
+
+ embedded, err := embeddedLogo(leaf)
+ switch {
+ case err != nil:
+ add("logotype", err.Error())
+ case !bytes.Equal(embedded, logo):
+ add("logoMismatch", "the logo inside the certificate is not the logo published for this domain; upload exactly the SVG the certificate was issued for")
+ default:
+ info.MatchesLogo = true
+ }
+
+ if len(certs) == 1 {
+ info.Warnings = append(info.Warnings, "the file holds only the leaf certificate; Gmail and Apple Mail expect the intermediate certificates after it, as the authority delivers them")
+ } else {
+ for i := 0; i+1 < len(certs); i++ {
+ if err := certs[i].CheckSignatureFrom(certs[i+1]); err != nil {
+ info.Warnings = append(info.Warnings, fmt.Sprintf("certificate %d was not signed by certificate %d: the chain is out of order or incomplete", i+1, i+2))
+ break
+ }
+ }
+ }
+
+ return info, out
+}
+
+func hasBrandIndicatorEKU(cert *x509.Certificate) bool {
+ for _, usage := range cert.UnknownExtKeyUsage {
+ if usage.Equal(oidBrandIndicatorEKU) {
+ return true
+ }
+ }
+ return false
+}
+
+// markType reads the issuer's mark-type attribute from the subject.
+func markType(cert *x509.Certificate) string {
+ for _, name := range cert.Subject.Names {
+ if name.Type.Equal(oidMarkType) {
+ if value, ok := name.Value.(string); ok {
+ return value
+ }
+ }
+ }
+ return ""
+}
+
+// coversDomain applies the rule receivers use: the certificate must name the
+// sender's domain, or the organisational domain above it ("ebay.com" covers
+// "reply.ebay.com"), exactly or through a wildcard.
+func coversDomain(cert *x509.Certificate, domain string) bool {
+ domain = strings.ToLower(strings.TrimSuffix(domain, "."))
+ for _, name := range cert.DNSNames {
+ name = strings.ToLower(name)
+ if name == domain || strings.HasSuffix(domain, "."+strings.TrimPrefix(name, "*.")) {
+ return true
+ }
+ }
+ return false
+}
+
+// embeddedLogo returns the SVG carried in the certificate's logotype extension.
+//
+// The extension is a nest of ASN.1 structures, but the logo inside is a single
+// IA5String holding a data URI, so it is found by that, not by walking the
+// structure: "data:image/svg+xml;base64," followed by a gzipped SVG. The type
+// says svg+xml and the payload is gzip, which is what issuers actually produce.
+func embeddedLogo(cert *x509.Certificate) ([]byte, error) {
+ var ext []byte
+ for _, e := range cert.Extensions {
+ if e.Id.Equal(oidLogotype) {
+ ext = e.Value
+ break
+ }
+ }
+ if ext == nil {
+ return nil, fmt.Errorf("the certificate has no embedded logo (logotype extension)")
+ }
+
+ marker := []byte("data:image/svg+xml")
+ at := bytes.Index(ext, marker)
+ if at < 0 {
+ return nil, fmt.Errorf("the embedded logo is not an SVG data URI")
+ }
+ uri, err := ia5StringAt(ext, at)
+ if err != nil {
+ return nil, err
+ }
+ _, payload, found := strings.Cut(uri, ",")
+ if !found {
+ return nil, fmt.Errorf("the embedded logo is malformed")
+ }
+ raw, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ return nil, fmt.Errorf("the embedded logo is not valid base64")
+ }
+ if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
+ reader, err := gzip.NewReader(bytes.NewReader(raw))
+ if err != nil {
+ return nil, fmt.Errorf("the embedded logo is not valid gzip")
+ }
+ // A decompression bomb inside a certificate is still a bomb
+ return io.ReadAll(io.LimitReader(reader, 4*MaxLogoBytes))
+ }
+ return raw, nil
+}
+
+// ia5StringAt returns the ASN.1 IA5String whose content begins at offset, by
+// reading the tag and length just before it.
+func ia5StringAt(der []byte, offset int) (string, error) {
+ for _, back := range []int{2, 3, 4} {
+ start := offset - back
+ if start < 0 || der[start] != 0x16 {
+ continue
+ }
+ var length, header int
+ switch lenByte := der[start+1]; {
+ case back == 2 && lenByte < 0x80:
+ length, header = int(lenByte), 2
+ case back == 3 && lenByte == 0x81:
+ length, header = int(der[start+2]), 3
+ case back == 4 && lenByte == 0x82:
+ length, header = int(der[start+2])<<8|int(der[start+3]), 4
+ default:
+ continue
+ }
+ if header == back && offset+length <= len(der) {
+ return string(der[offset : offset+length]), nil
+ }
+ }
+ return "", fmt.Errorf("the embedded logo could not be located in the certificate")
+}
diff --git a/services/protocols/internal/domain/bimi/vmc_test.go b/services/protocols/internal/domain/bimi/vmc_test.go
new file mode 100644
index 0000000..442cda4
--- /dev/null
+++ b/services/protocols/internal/domain/bimi/vmc_test.go
@@ -0,0 +1,234 @@
+package bimi
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The clock is pinned inside both fixtures' validity periods.
+var fixtureNow = time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
+
+func read(t *testing.T, name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile("testdata/" + name)
+ if err != nil {
+ t.Fatalf("read %s: %v", name, err)
+ }
+ return data
+}
+
+func rules(violations []Violation) []string {
+ out := make([]string, 0, len(violations))
+ for _, v := range violations {
+ out = append(out, v.Rule)
+ }
+ return out
+}
+
+// Real certificates, issued by DigiCert and through Valimail. These are the
+// formats the parser has to read; a certificate built by hand would only prove
+// the parser reads what it was written to read.
+func TestInspectRealCertificates(t *testing.T) {
+ cases := []struct {
+ file, logo, domain, issuer string
+ }{
+ {"ebay.pem", "ebay.svg", "ebay.com", "DigiCert Verified Mark RSA4096 SHA256 2021 CA1"},
+ {"cnn.pem", "cnn.svg", "cnn.com", ""},
+ }
+ for _, c := range cases {
+ t.Run(c.file, func(t *testing.T) {
+ cert, violations := InspectCertificate(read(t, c.file), c.domain, read(t, c.logo), fixtureNow)
+ if len(violations) != 0 {
+ t.Fatalf("a genuine certificate was refused: %v", violations)
+ }
+ if !cert.MatchesLogo {
+ t.Error("the logo embedded in the certificate should match the published one")
+ }
+ if cert.Kind != KindVMC {
+ t.Errorf("kind = %q, want vmc (both are registered marks)", cert.Kind)
+ }
+ if cert.ChainLength < 2 {
+ t.Errorf("chain length = %d, want the intermediates too", cert.ChainLength)
+ }
+ if c.issuer != "" && cert.Issuer != c.issuer {
+ t.Errorf("issuer = %q", cert.Issuer)
+ }
+ if cert.Organization == "" || cert.NotAfter.IsZero() {
+ t.Errorf("incomplete description: %+v", cert)
+ }
+ })
+ }
+}
+
+func TestInspectCoversSubdomainsOfTheCertifiedDomain(t *testing.T) {
+ // ebay.com's certificate names ebay.com, and mail from reply.ebay.com is covered
+ _, violations := InspectCertificate(read(t, "ebay.pem"), "reply.ebay.com", read(t, "ebay.svg"), fixtureNow)
+ if len(violations) != 0 {
+ t.Fatalf("a subdomain of the certified domain was refused: %v", violations)
+ }
+}
+
+func TestInspectRefusesTheWrongDomain(t *testing.T) {
+ _, violations := InspectCertificate(read(t, "ebay.pem"), "example.test", read(t, "ebay.svg"), fixtureNow)
+ if got := rules(violations); len(got) != 1 || got[0] != "domain" {
+ t.Fatalf("violations = %v, want only the domain", got)
+ }
+}
+
+func TestInspectRefusesADifferentLogo(t *testing.T) {
+ // The certificate vouches for exactly one SVG. Anything else published at
+ // l= makes the receiver ignore BIMI, so it is refused here instead.
+ _, violations := InspectCertificate(read(t, "ebay.pem"), "ebay.com", read(t, "cnn.svg"), fixtureNow)
+ if got := rules(violations); len(got) != 1 || got[0] != "logoMismatch" {
+ t.Fatalf("violations = %v, want only the mismatch", got)
+ }
+
+ // Even one byte
+ altered := append(read(t, "ebay.svg"), '\n')
+ _, violations = InspectCertificate(read(t, "ebay.pem"), "ebay.com", altered, fixtureNow)
+ if got := rules(violations); len(got) != 1 || got[0] != "logoMismatch" {
+ t.Fatalf("a one-byte change must be a mismatch, got %v", got)
+ }
+}
+
+func TestInspectChecksValidity(t *testing.T) {
+ logo := read(t, "ebay.svg")
+ _, violations := InspectCertificate(read(t, "ebay.pem"), "ebay.com", logo, time.Date(2028, 1, 1, 0, 0, 0, 0, time.UTC))
+ if got := rules(violations); len(got) != 1 || got[0] != "validity" {
+ t.Fatalf("an expired certificate: violations = %v", got)
+ }
+ _, violations = InspectCertificate(read(t, "ebay.pem"), "ebay.com", logo, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+ if got := rules(violations); len(got) != 1 || got[0] != "validity" {
+ t.Fatalf("a certificate not yet valid: violations = %v", got)
+ }
+
+ // Close to expiry is allowed, with a warning
+ cert, violations := InspectCertificate(read(t, "ebay.pem"), "ebay.com", logo, time.Date(2027, 7, 10, 0, 0, 0, 0, time.UTC))
+ if len(violations) != 0 || len(cert.Warnings) == 0 {
+ t.Fatalf("near expiry: violations=%v warnings=%v", violations, cert)
+ }
+}
+
+func TestInspectNeverAcceptsAPrivateKey(t *testing.T) {
+ // A key pasted next to the certificate would be published at a public URL
+ key := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: []byte("not a real key")})
+ for _, payload := range [][]byte{key, append(read(t, "ebay.pem"), key...), append(key, read(t, "ebay.pem")...)} {
+ cert, violations := InspectCertificate(payload, "ebay.com", read(t, "ebay.svg"), fixtureNow)
+ if cert != nil || len(violations) != 1 || violations[0].Rule != "pemBlock" {
+ t.Fatalf("a private key block was not refused: cert=%v violations=%v", cert, violations)
+ }
+ if strings.Contains(violations[0].Detail, "not a real key") {
+ t.Error("the refusal must not echo the key")
+ }
+ }
+}
+
+func TestInspectRefusesGarbage(t *testing.T) {
+ cases := map[string][]byte{
+ "empty": nil,
+ "text": []byte("this is not a certificate"),
+ "corrupt": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")}),
+ "huge": make([]byte, MaxCertificateBytes+1),
+ }
+ for name, payload := range cases {
+ if cert, violations := InspectCertificate(payload, "ebay.com", read(t, "ebay.svg"), fixtureNow); cert != nil || len(violations) == 0 {
+ t.Errorf("%s: cert=%v violations=%v", name, cert, violations)
+ }
+ }
+}
+
+// An ordinary web certificate is not a BIMI certificate: it has neither the
+// brand-indicator usage nor a logo inside.
+func TestInspectRefusesAnOrdinaryCertificate(t *testing.T) {
+ key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "example.test", Organization: []string{"Example"}},
+ NotBefore: fixtureNow.Add(-time.Hour),
+ NotAfter: fixtureNow.Add(365 * 24 * time.Hour),
+ DNSNames: []string{"example.test"},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+
+ cert, violations := InspectCertificate(payload, "example.test", []byte(""), fixtureNow)
+ got := rules(violations)
+ want := map[string]bool{"extendedKeyUsage": true, "logotype": true}
+ if cert == nil || len(got) != 2 || !want[got[0]] || !want[got[1]] {
+ t.Fatalf("violations = %v, want the missing usage and the missing logo", got)
+ }
+ if cert.MatchesLogo {
+ t.Error("an ordinary certificate cannot match a logo")
+ }
+}
+
+func TestInspectWarnsAboutALeafWithoutItsChain(t *testing.T) {
+ first, _ := pem.Decode(read(t, "ebay.pem"))
+ leafOnly := pem.EncodeToMemory(first)
+ cert, violations := InspectCertificate(leafOnly, "ebay.com", read(t, "ebay.svg"), fixtureNow)
+ if len(violations) != 0 {
+ t.Fatalf("a leaf alone is usable, only worth a warning: %v", violations)
+ }
+ if len(cert.Warnings) == 0 || !strings.Contains(cert.Warnings[0], "intermediate") {
+ t.Fatalf("warnings = %v", cert.Warnings)
+ }
+}
+
+func TestInspectWarnsAboutAnOutOfOrderChain(t *testing.T) {
+ var blocks []*pem.Block
+ rest := read(t, "ebay.pem")
+ for {
+ var b *pem.Block
+ b, rest = pem.Decode(rest)
+ if b == nil {
+ break
+ }
+ blocks = append(blocks, b)
+ }
+ // leaf first, then the chain reversed: each is no longer signed by the next
+ swapped := pem.EncodeToMemory(blocks[0])
+ for i := len(blocks) - 1; i >= 1; i-- {
+ swapped = append(swapped, pem.EncodeToMemory(blocks[i])...)
+ }
+ cert, violations := InspectCertificate(swapped, "ebay.com", read(t, "ebay.svg"), fixtureNow)
+ if len(violations) != 0 {
+ t.Fatalf("violations = %v", violations)
+ }
+ found := false
+ for _, w := range cert.Warnings {
+ found = found || strings.Contains(w, "chain")
+ }
+ if !found {
+ t.Fatalf("an out-of-order chain should warn: %v", cert.Warnings)
+ }
+}
+
+func TestCoversDomain(t *testing.T) {
+ cert := &x509.Certificate{DNSNames: []string{"example.test", "*.wild.test"}}
+ for domain, want := range map[string]bool{
+ "example.test": true,
+ "EXAMPLE.test": true,
+ "mail.example.test": true,
+ "notexample.test": false,
+ "example.test.evil": false,
+ "a.wild.test": true,
+ "wild.test": false, // a wildcard never covers its own apex
+ "other.test": false,
+ } {
+ if got := coversDomain(cert, domain); got != want {
+ t.Errorf("coversDomain(%q) = %v, want %v", domain, got, want)
+ }
+ }
+}
diff --git a/services/protocols/internal/infrastructure/postgres/bimi_repository.go b/services/protocols/internal/infrastructure/postgres/bimi_repository.go
index 8e0eefe..2eed3fb 100644
--- a/services/protocols/internal/infrastructure/postgres/bimi_repository.go
+++ b/services/protocols/internal/infrastructure/postgres/bimi_repository.go
@@ -25,6 +25,8 @@ type BimiLogo struct {
SVG []byte
ETag string
VmcURL string
+ // VmcPEM is the certificate chain held here, when the operator uploaded one.
+ VmcPEM []byte
}
// Save replaces a domain's logo, returning the digest receivers can cache on.
@@ -50,10 +52,10 @@ func (r *BimiRepository) Load(ctx context.Context, domain string) (*BimiLogo, er
var logo BimiLogo
var vmc *string
err := r.pool.QueryRow(ctx, `
- SELECT b.svg, b.etag, b.vmc_url
+ SELECT b.svg, b.etag, b.vmc_url, b.vmc_pem
FROM domain_bimi b JOIN domains d ON d.id = b.domain_id
WHERE lower(d.name) = lower($1)
- `, domain).Scan(&logo.SVG, &logo.ETag, &vmc)
+ `, domain).Scan(&logo.SVG, &logo.ETag, &vmc, &logo.VmcPEM)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
@@ -77,3 +79,33 @@ func (r *BimiRepository) Delete(ctx context.Context, domain string) error {
}
return nil
}
+
+// SaveCertificate stores the certificate chain for a domain that already has a
+// logo. It reports false when there is no logo to attach it to: a certificate
+// only means something next to the logo it was issued for.
+func (r *BimiRepository) SaveCertificate(ctx context.Context, domain string, pem []byte) (bool, error) {
+ tag, err := r.pool.Exec(ctx, `
+ UPDATE domain_bimi b
+ SET vmc_pem = $2, vmc_uploaded_at = NOW()
+ FROM domains d
+ WHERE b.domain_id = d.id AND lower(d.name) = lower($1)
+ `, domain, pem)
+ if err != nil {
+ return false, fmt.Errorf("save BIMI certificate for %s: %w", domain, err)
+ }
+ return tag.RowsAffected() > 0, nil
+}
+
+// DeleteCertificate removes the stored certificate, leaving the logo.
+func (r *BimiRepository) DeleteCertificate(ctx context.Context, domain string) error {
+ _, err := r.pool.Exec(ctx, `
+ UPDATE domain_bimi b
+ SET vmc_pem = NULL, vmc_uploaded_at = NULL
+ FROM domains d
+ WHERE b.domain_id = d.id AND lower(d.name) = lower($1)
+ `, domain)
+ if err != nil {
+ return fmt.Errorf("delete BIMI certificate for %s: %w", domain, err)
+ }
+ return nil
+}
diff --git a/services/protocols/internal/infrastructure/postgres/bimi_repository_test.go b/services/protocols/internal/infrastructure/postgres/bimi_repository_test.go
new file mode 100644
index 0000000..a0109b0
--- /dev/null
+++ b/services/protocols/internal/infrastructure/postgres/bimi_repository_test.go
@@ -0,0 +1,74 @@
+package postgres
+
+import (
+ "bytes"
+ "context"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestBimiRepositoryHoldsALogoAndItsCertificate(t *testing.T) {
+ pool := testPool(t)
+ ctx := context.Background()
+
+ name := "bimi" + uuid.NewString()[:8] + ".test"
+ id := uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name, is_active) VALUES ($1,$2,$3,true)`, id, name, name); err != nil {
+ t.Fatalf("seed domain: %v", err)
+ }
+ t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM domains WHERE id = $1`, id) })
+
+ repo := NewBimiRepository(pool)
+ svg := []byte(``)
+ pem := []byte("-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n")
+
+ // A certificate means something only next to a logo
+ saved, err := repo.SaveCertificate(ctx, name, pem)
+ if err != nil || saved {
+ t.Fatalf("SaveCertificate with no logo = (%v, %v), want (false, nil)", saved, err)
+ }
+
+ if _, err := repo.Save(ctx, name, svg, "https://elsewhere.test/vmc.pem"); err != nil {
+ t.Fatal(err)
+ }
+ logo, err := repo.Load(ctx, name)
+ if err != nil || logo == nil || len(logo.VmcPEM) != 0 || logo.VmcURL != "https://elsewhere.test/vmc.pem" {
+ t.Fatalf("a logo with no certificate: %+v, %v", logo, err)
+ }
+
+ if saved, err := repo.SaveCertificate(ctx, name, pem); err != nil || !saved {
+ t.Fatalf("SaveCertificate = (%v, %v)", saved, err)
+ }
+ logo, _ = repo.Load(ctx, name)
+ if !bytes.Equal(logo.VmcPEM, pem) {
+ t.Errorf("certificate read back = %q", logo.VmcPEM)
+ }
+
+ // Replacing the logo keeps the certificate: it is the operator who decides
+ // whether it still applies, and the console says when it does not
+ if _, err := repo.Save(ctx, name, append(svg, ' '), ""); err != nil {
+ t.Fatal(err)
+ }
+ logo, _ = repo.Load(ctx, name)
+ if !bytes.Equal(logo.VmcPEM, pem) {
+ t.Error("replacing the logo dropped the certificate")
+ }
+
+ if err := repo.DeleteCertificate(ctx, name); err != nil {
+ t.Fatal(err)
+ }
+ logo, _ = repo.Load(ctx, name)
+ if logo == nil || len(logo.VmcPEM) != 0 || len(logo.SVG) == 0 {
+ t.Errorf("deleting the certificate must leave the logo: %+v", logo)
+ }
+
+ // The certificate goes with the logo
+ repo.SaveCertificate(ctx, name, pem)
+ if err := repo.Delete(ctx, name); err != nil {
+ t.Fatal(err)
+ }
+ if logo, _ := repo.Load(ctx, name); logo != nil {
+ t.Errorf("the logo survived its own deletion: %+v", logo)
+ }
+}
diff --git a/services/protocols/internal/presentation/http/bimi_api.go b/services/protocols/internal/presentation/http/bimi_api.go
index 2f0547c..cfdd39e 100644
--- a/services/protocols/internal/presentation/http/bimi_api.go
+++ b/services/protocols/internal/presentation/http/bimi_api.go
@@ -2,21 +2,28 @@ package httppresentation
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
+ "time"
"lambdamail/protocols/internal/domain/bimi"
)
-// BimiStore holds the mark a domain publishes.
+// BimiStore holds the mark a domain publishes, and the certificate that
+// vouches for it.
type BimiStore interface {
Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error)
Load(ctx context.Context, domain string) (*BimiLogo, error)
Delete(ctx context.Context, domain string) error
+ // SaveCertificate reports false when the domain has no logo to attach it to.
+ SaveCertificate(ctx context.Context, domain string, pem []byte) (bool, error)
+ DeleteCertificate(ctx context.Context, domain string) error
}
// BimiLogo is a stored mark as this layer sees it.
@@ -24,6 +31,8 @@ type BimiLogo struct {
SVG []byte
ETag string
VmcURL string
+ // VmcPEM is the certificate chain held here, if the operator uploaded one.
+ VmcPEM []byte
}
type bimiAPI struct {
@@ -32,14 +41,27 @@ type bimiAPI struct {
// domainOf maps the host a request arrived on to the mail domain whose
// logo should answer, so one deployment can serve several.
defaultDomain string
+ // mailHost is where the logo and the certificate are served from: the host
+ // the DNS record names.
+ mailHost string
+ now func() time.Time
+ dns bimi.Resolver
+ fetch bimi.Fetcher
}
// SetBimiAPI wires the BIMI logo store.
-func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain string) {
+func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain, mailHost string) {
if store == nil {
return
}
- r.bimi = &bimiAPI{store: store, defaultDomain: defaultDomain}
+ r.bimi = &bimiAPI{
+ store: store,
+ defaultDomain: defaultDomain,
+ mailHost: mailHost,
+ now: time.Now,
+ dns: netResolver{},
+ fetch: newBimiFetcher(),
+ }
if sessionSecret != "" {
r.bimi.sessions = NewWebSessionVerifier(sessionSecret)
}
@@ -101,27 +123,37 @@ func (a *bimiAPI) domainFor(req *http.Request) string {
return a.defaultDomain
}
-// handleBimiAdmin uploads, inspects or removes a domain's mark.
-func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
+// requireBimiAdmin answers the admin session and domain checks shared by every
+// BIMI console route. It writes the refusal itself and returns false.
+func (r *Router) requireBimiAdmin(w http.ResponseWriter, req *http.Request) (string, bool) {
if r.bimi == nil || r.bimi.sessions == nil {
writeError(w, http.StatusServiceUnavailable, "BIMI_DISABLED",
"The BIMI panel needs JWT_SECRET and a database")
- return
+ return "", false
}
token := bearerOrCookie(req, "lm_admin_session")
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
- return
+ return "", false
}
if _, err := r.bimi.sessions.RequireSurface(token, "admin"); err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
- return
+ return "", false
}
domain := strings.TrimSpace(req.URL.Query().Get("domain"))
if domain == "" {
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
+ return "", false
+ }
+ return domain, true
+}
+
+// handleBimiAdmin uploads, inspects or removes a domain's mark.
+func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
+ domain, ok := r.requireBimiAdmin(w, req)
+ if !ok {
return
}
@@ -142,6 +174,120 @@ func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
}
}
+// handleBimiCertificate serves the certificate chain, like the logo: fetched
+// by receiving providers that hold no session.
+func (r *Router) handleBimiCertificate(w http.ResponseWriter, req *http.Request) {
+ if r.bimi == nil {
+ http.NotFound(w, req)
+ return
+ }
+ if req.Method != http.MethodGet && req.Method != http.MethodHead {
+ writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
+ return
+ }
+
+ logo, err := r.bimi.store.Load(req.Context(), r.bimi.domainFor(req))
+ if err != nil || logo == nil || len(logo.VmcPEM) == 0 {
+ http.NotFound(w, req)
+ return
+ }
+
+ sum := sha256.Sum256(logo.VmcPEM)
+ etag := `"` + hex.EncodeToString(sum[:]) + `"`
+ if req.Header.Get("If-None-Match") == etag {
+ w.WriteHeader(http.StatusNotModified)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/x-pem-file")
+ w.Header().Set("ETag", etag)
+ // Shorter than the logo: a renewed certificate should reach receivers
+ // within the hour, not the day.
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if req.Method == http.MethodHead {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ _, _ = w.Write(logo.VmcPEM)
+}
+
+// handleBimiCertificateAdmin uploads or removes the certificate chain.
+func (r *Router) handleBimiCertificateAdmin(w http.ResponseWriter, req *http.Request) {
+ domain, ok := r.requireBimiAdmin(w, req)
+ if !ok {
+ return
+ }
+
+ switch req.Method {
+ case http.MethodPut:
+ r.bimi.putCertificate(w, req, domain)
+ case http.MethodDelete:
+ if err := r.bimi.store.DeleteCertificate(req.Context(), domain); err != nil {
+ log.Printf("bimi: could not delete the certificate for %s: %v", domain, err)
+ writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the certificate")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "PUT or DELETE")
+ }
+}
+
+// handleBimiCheck looks at everything a receiver would look at, and says which
+// part is wrong.
+func (r *Router) handleBimiCheck(w http.ResponseWriter, req *http.Request) {
+ domain, ok := r.requireBimiAdmin(w, req)
+ if !ok {
+ return
+ }
+ if req.Method != http.MethodGet {
+ writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
+ return
+ }
+
+ logo, err := r.bimi.store.Load(req.Context(), domain)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
+ return
+ }
+ if logo == nil {
+ writeJSON(w, http.StatusOK, map[string]any{"configured": false, "checks": []any{}})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(req.Context(), 25*time.Second)
+ defer cancel()
+
+ in := bimi.Inputs{
+ Domain: domain,
+ LogoURL: BimiLogoURL(r.bimi.mailHost),
+ SVG: logo.SVG,
+ CertificatePEM: logo.VmcPEM,
+ ExternalCertificateURL: logo.VmcURL,
+ Now: r.bimi.now(),
+ }
+ // The record must name the certificate that is actually served
+ switch {
+ case len(logo.VmcPEM) > 0:
+ in.CertificateURL = BimiVmcURL(r.bimi.mailHost)
+ default:
+ in.CertificateURL = logo.VmcURL
+ }
+
+ results := bimi.RunChecks(ctx, in, r.bimi.dns, r.bimi.fetch)
+ checks := make([]map[string]string, 0, len(results))
+ ready := true
+ for _, c := range results {
+ checks = append(checks, map[string]string{"id": c.ID, "status": string(c.Status), "detail": c.Detail})
+ // A missing certificate is a warning, not a fault: some receivers show the logo without one
+ if c.Status == bimi.StatusFail {
+ ready = false
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"configured": true, "ready": ready, "checks": checks})
+}
+
func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
logo, err := a.store.Load(req.Context(), domain)
if err != nil {
@@ -153,11 +299,101 @@ func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
- "configured": true,
- "etag": logo.ETag,
- "vmc_url": logo.VmcURL,
- "bytes": len(logo.SVG),
- "svg": string(logo.SVG),
+ "configured": true,
+ "etag": logo.ETag,
+ "vmc_url": logo.VmcURL,
+ "bytes": len(logo.SVG),
+ "svg": string(logo.SVG),
+ "logo_url": BimiLogoURL(a.mailHost),
+ "certificate_url": BimiVmcURL(a.mailHost),
+ "certificate": a.describeCertificate(domain, logo),
+ })
+}
+
+// describeCertificate reports the held certificate as it stands against the
+// logo now being served. Read from the file each time, so it cannot drift from
+// what receivers get.
+func (a *bimiAPI) describeCertificate(domain string, logo *BimiLogo) map[string]any {
+ if len(logo.VmcPEM) == 0 {
+ return map[string]any{"present": false}
+ }
+ cert, violations := bimi.InspectCertificate(logo.VmcPEM, domain, logo.SVG, a.now())
+ reasons := make([]string, 0, len(violations))
+ for _, v := range violations {
+ reasons = append(reasons, v.String())
+ }
+ out := map[string]any{"present": true, "valid": len(violations) == 0, "violations": reasons}
+ if cert != nil {
+ out["subject"] = cert.Subject
+ out["organization"] = cert.Organization
+ out["issuer"] = cert.Issuer
+ out["not_before"] = cert.NotBefore.UTC().Format(time.RFC3339)
+ out["not_after"] = cert.NotAfter.UTC().Format(time.RFC3339)
+ out["dns_names"] = cert.DNSNames
+ out["mark_type"] = cert.MarkType
+ out["kind"] = string(cert.Kind)
+ out["chain_length"] = cert.ChainLength
+ out["matches_logo"] = cert.MatchesLogo
+ out["warnings"] = cert.Warnings
+ }
+ return out
+}
+
+// putCertificate stores a certificate chain after checking it against the
+// logo and the domain. A certificate that does not match is refused rather
+// than stored: publishing one makes receivers ignore BIMI without a word.
+func (a *bimiAPI) putCertificate(w http.ResponseWriter, req *http.Request, domain string) {
+ body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxCertificateBytes*2))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
+ return
+ }
+ var payload struct {
+ PEM string `json:"pem"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with a pem field")
+ return
+ }
+
+ logo, err := a.store.Load(req.Context(), domain)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
+ return
+ }
+ if logo == nil {
+ writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first: a certificate only means something next to the logo it was issued for")
+ return
+ }
+
+ pem := []byte(strings.TrimSpace(payload.PEM) + "\n")
+ _, violations := bimi.InspectCertificate(pem, domain, logo.SVG, a.now())
+ if len(violations) > 0 {
+ reasons := make([]string, 0, len(violations))
+ for _, v := range violations {
+ reasons = append(reasons, v.String())
+ }
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "INVALID_CERTIFICATE",
+ "message": "The certificate cannot be used for this domain and logo",
+ "violations": reasons,
+ })
+ return
+ }
+
+ saved, err := a.store.SaveCertificate(req.Context(), domain, pem)
+ if err != nil {
+ log.Printf("bimi: could not save the certificate for %s: %v", domain, err)
+ writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the certificate")
+ return
+ }
+ if !saved {
+ writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "certificate": a.describeCertificate(domain, &BimiLogo{SVG: logo.SVG, VmcPEM: pem}),
})
}
@@ -208,10 +444,23 @@ func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the logo")
return
}
- writeJSON(w, http.StatusOK, map[string]any{"ok": true, "etag": etag})
+ // A certificate vouches for one exact SVG. Replacing the logo under a held
+ // certificate breaks it, which receivers do not report; say so now.
+ response := map[string]any{"ok": true, "etag": etag}
+ if held, err := a.store.Load(req.Context(), domain); err == nil && held != nil && len(held.VmcPEM) > 0 {
+ if cert, _ := bimi.InspectCertificate(held.VmcPEM, domain, held.SVG, a.now()); cert != nil && !cert.MatchesLogo {
+ response["certificate_mismatch"] = true
+ }
+ }
+ writeJSON(w, http.StatusOK, response)
}
// BimiLogoURL is where a domain's mark is served from.
func BimiLogoURL(mailHost string) string {
return fmt.Sprintf("https://%s/.well-known/bimi/default.svg", mailHost)
}
+
+// BimiVmcURL is where the certificate held here is served from.
+func BimiVmcURL(mailHost string) string {
+ return fmt.Sprintf("https://%s/.well-known/bimi/vmc.pem", mailHost)
+}
diff --git a/services/protocols/internal/presentation/http/bimi_api_test.go b/services/protocols/internal/presentation/http/bimi_api_test.go
new file mode 100644
index 0000000..fbbcd9e
--- /dev/null
+++ b/services/protocols/internal/presentation/http/bimi_api_test.go
@@ -0,0 +1,337 @@
+package httppresentation
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "lambdamail/protocols/internal/domain/bimi"
+)
+
+// memoryBimiStore is the store without a database.
+type memoryBimiStore struct {
+ logos map[string]*BimiLogo
+}
+
+func (m *memoryBimiStore) Save(_ context.Context, domain string, svg []byte, vmcURL string) (string, error) {
+ held := m.logos[domain]
+ logo := &BimiLogo{SVG: svg, ETag: "etag-" + domain, VmcURL: vmcURL}
+ if held != nil {
+ // Replacing the logo keeps a held certificate, as the real store does
+ logo.VmcPEM = held.VmcPEM
+ }
+ m.logos[domain] = logo
+ return logo.ETag, nil
+}
+
+func (m *memoryBimiStore) Load(_ context.Context, domain string) (*BimiLogo, error) {
+ return m.logos[domain], nil
+}
+
+func (m *memoryBimiStore) Delete(_ context.Context, domain string) error {
+ delete(m.logos, domain)
+ return nil
+}
+
+func (m *memoryBimiStore) SaveCertificate(_ context.Context, domain string, pem []byte) (bool, error) {
+ logo := m.logos[domain]
+ if logo == nil {
+ return false, nil
+ }
+ logo.VmcPEM = pem
+ return true, nil
+}
+
+func (m *memoryBimiStore) DeleteCertificate(_ context.Context, domain string) error {
+ if logo := m.logos[domain]; logo != nil {
+ logo.VmcPEM = nil
+ }
+ return nil
+}
+
+type fakeBimiDNS map[string][]string
+
+func (f fakeBimiDNS) LookupTXT(_ context.Context, name string) ([]string, error) {
+ if records, ok := f[name]; ok {
+ return records, nil
+ }
+ return nil, errors.New("no such host")
+}
+
+func fixture(t *testing.T, name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile("../../domain/bimi/testdata/" + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return data
+}
+
+// The certificates in the fixtures are valid on this date.
+var bimiTestNow = time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC)
+
+func newBimiRouter(store *memoryBimiStore) *Router {
+ r := &Router{mux: http.NewServeMux()}
+ r.bimi = &bimiAPI{
+ store: store,
+ sessions: testVerifier(interopSecret),
+ defaultDomain: "ebay.com",
+ mailHost: "mail.ebay.com",
+ now: func() time.Time { return bimiTestNow },
+ dns: fakeBimiDNS{},
+ fetch: func(context.Context, string) (bimi.Response, error) {
+ return bimi.Response{}, errors.New("offline")
+ },
+ }
+ r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo)
+ r.mux.HandleFunc("/.well-known/bimi/vmc.pem", r.handleBimiCertificate)
+ r.mux.HandleFunc("/api/v1/admin/bimi", r.handleBimiAdmin)
+ r.mux.HandleFunc("/api/v1/admin/bimi/certificate", r.handleBimiCertificateAdmin)
+ r.mux.HandleFunc("/api/v1/admin/bimi/check", r.handleBimiCheck)
+ return r
+}
+
+func bimiCall(r *Router, method, target, body string, authed bool) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, target, strings.NewReader(body))
+ if authed {
+ req.Header.Set("Authorization", "Bearer "+adminSurfaceToken2())
+ }
+ rec := httptest.NewRecorder()
+ r.mux.ServeHTTP(rec, req)
+ return rec
+}
+
+func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
+ t.Helper()
+ var out map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("not JSON (%d): %s", rec.Code, rec.Body)
+ }
+ return out
+}
+
+func putLogo(t *testing.T, r *Router, svg []byte) *httptest.ResponseRecorder {
+ t.Helper()
+ payload, _ := json.Marshal(map[string]string{"svg": string(svg)})
+ return bimiCall(r, http.MethodPut, "/api/v1/admin/bimi?domain=ebay.com", string(payload), true)
+}
+
+func putCertificate(r *Router, pem []byte) *httptest.ResponseRecorder {
+ payload, _ := json.Marshal(map[string]string{"pem": string(pem)})
+ return bimiCall(r, http.MethodPut, "/api/v1/admin/bimi/certificate?domain=ebay.com", string(payload), true)
+}
+
+func TestBimiAdminRoutesNeedAnAdminSession(t *testing.T) {
+ r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{}})
+ for _, target := range []string{
+ "/api/v1/admin/bimi?domain=ebay.com",
+ "/api/v1/admin/bimi/certificate?domain=ebay.com",
+ "/api/v1/admin/bimi/check?domain=ebay.com",
+ } {
+ for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
+ if rec := bimiCall(r, method, target, "{}", false); rec.Code != http.StatusUnauthorized {
+ t.Errorf("%s %s without a session answered %d", method, target, rec.Code)
+ }
+ }
+ }
+ if rec := bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check", "", true); rec.Code != http.StatusBadRequest {
+ t.Errorf("no domain answered %d", rec.Code)
+ }
+}
+
+func TestBimiCertificateNeedsALogoFirst(t *testing.T) {
+ r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{}})
+ rec := putCertificate(r, fixture(t, "ebay.pem"))
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("answered %d: %s", rec.Code, rec.Body)
+ }
+}
+
+func TestBimiAcceptsTheRealCertificateAndServesIt(t *testing.T) {
+ store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
+ r := newBimiRouter(store)
+
+ if rec := putLogo(t, r, fixture(t, "ebay.svg")); rec.Code != http.StatusOK {
+ t.Fatalf("the real logo was refused: %s", rec.Body)
+ }
+ if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/vmc.pem", "", false); rec.Code != http.StatusNotFound {
+ t.Errorf("before upload the public URL answered %d, want 404", rec.Code)
+ }
+
+ rec := putCertificate(r, fixture(t, "ebay.pem"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("the real certificate was refused: %s", rec.Body)
+ }
+ cert := jsonBody(t, rec)["certificate"].(map[string]any)
+ if cert["valid"] != true || cert["matches_logo"] != true || cert["kind"] != "vmc" {
+ t.Errorf("certificate report = %v", cert)
+ }
+
+ // The public document: no session, the PEM, cacheable, and conditional
+ public := bimiCall(r, http.MethodGet, "/.well-known/bimi/vmc.pem", "", false)
+ if public.Code != http.StatusOK || !strings.Contains(public.Body.String(), "BEGIN CERTIFICATE") {
+ t.Fatalf("public answered %d", public.Code)
+ }
+ if got := public.Header().Get("Content-Type"); got != "application/x-pem-file" {
+ t.Errorf("content type = %q", got)
+ }
+ if public.Header().Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(public.Header().Get("Cache-Control"), "public") {
+ t.Errorf("headers = %v", public.Header())
+ }
+ conditional := httptest.NewRequest(http.MethodGet, "/.well-known/bimi/vmc.pem", nil)
+ conditional.Header.Set("If-None-Match", public.Header().Get("ETag"))
+ again := httptest.NewRecorder()
+ r.mux.ServeHTTP(again, conditional)
+ if again.Code != http.StatusNotModified {
+ t.Errorf("conditional request answered %d", again.Code)
+ }
+
+ // The console reads it back, with the URLs the DNS record should carry
+ got := jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi?domain=ebay.com", "", true))
+ if got["logo_url"] != "https://mail.ebay.com/.well-known/bimi/default.svg" || got["certificate_url"] != "https://mail.ebay.com/.well-known/bimi/vmc.pem" {
+ t.Errorf("urls = %v / %v", got["logo_url"], got["certificate_url"])
+ }
+ if got["certificate"].(map[string]any)["present"] != true {
+ t.Errorf("certificate = %v", got["certificate"])
+ }
+
+ // Removing it leaves the logo
+ if rec := bimiCall(r, http.MethodDelete, "/api/v1/admin/bimi/certificate?domain=ebay.com", "", true); rec.Code != http.StatusOK {
+ t.Fatalf("delete answered %d", rec.Code)
+ }
+ if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/vmc.pem", "", false); rec.Code != http.StatusNotFound {
+ t.Errorf("after delete the public URL answered %d", rec.Code)
+ }
+ if rec := bimiCall(r, http.MethodGet, "/.well-known/bimi/default.svg", "", false); rec.Code != http.StatusOK {
+ t.Errorf("the logo disappeared with the certificate: %d", rec.Code)
+ }
+}
+
+func TestBimiRefusesACertificateForAnotherLogo(t *testing.T) {
+ store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
+ r := newBimiRouter(store)
+ // CNN's logo is valid SVG Tiny PS but is not the one eBay's certificate holds
+ if rec := putLogo(t, r, fixture(t, "cnn.svg")); rec.Code != http.StatusOK {
+ t.Fatalf("logo refused: %s", rec.Body)
+ }
+
+ rec := putCertificate(r, fixture(t, "ebay.pem"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("answered %d: %s", rec.Code, rec.Body)
+ }
+ violations, _ := jsonBody(t, rec)["violations"].([]any)
+ if len(violations) == 0 || !strings.Contains(violations[0].(string), "logoMismatch") {
+ t.Errorf("violations = %v", violations)
+ }
+ if len(store.logos["ebay.com"].VmcPEM) != 0 {
+ t.Error("a certificate that does not match was stored")
+ }
+}
+
+func TestBimiNeverStoresAPrivateKey(t *testing.T) {
+ store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
+ r := newBimiRouter(store)
+ putLogo(t, r, fixture(t, "ebay.svg"))
+
+ key := "-----BEGIN PRIVATE KEY-----\nQUJD\n-----END PRIVATE KEY-----\n"
+ rec := putCertificate(r, append(fixture(t, "ebay.pem"), key...))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("answered %d", rec.Code)
+ }
+ if len(store.logos["ebay.com"].VmcPEM) != 0 {
+ t.Error("a private key was stored and would be served publicly")
+ }
+ if strings.Contains(rec.Body.String(), "QUJD") {
+ t.Error("the refusal echoed the key")
+ }
+}
+
+func TestBimiCertificateBodyMustBeJSON(t *testing.T) {
+ r := newBimiRouter(&memoryBimiStore{logos: map[string]*BimiLogo{"ebay.com": {SVG: fixture(t, "ebay.svg")}}})
+ if rec := bimiCall(r, http.MethodPut, "/api/v1/admin/bimi/certificate?domain=ebay.com", "-----BEGIN CERTIFICATE-----", true); rec.Code != http.StatusBadRequest {
+ t.Errorf("answered %d", rec.Code)
+ }
+}
+
+func TestBimiReplacingTheLogoWarnsAboutTheHeldCertificate(t *testing.T) {
+ store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
+ r := newBimiRouter(store)
+ putLogo(t, r, fixture(t, "ebay.svg"))
+ putCertificate(r, fixture(t, "ebay.pem"))
+
+ // Another valid logo: the certificate no longer vouches for what is served
+ rec := putLogo(t, r, fixture(t, "cnn.svg"))
+ if rec.Code != http.StatusOK || jsonBody(t, rec)["certificate_mismatch"] != true {
+ t.Fatalf("no warning: %d %s", rec.Code, rec.Body)
+ }
+ cert := jsonBody(t, bimiCall(r, http.MethodGet, "/api/v1/admin/bimi?domain=ebay.com", "", true))["certificate"].(map[string]any)
+ if cert["valid"] != false || cert["matches_logo"] != false {
+ t.Errorf("the console should show the certificate as no longer matching: %v", cert)
+ }
+}
+
+func TestBimiCheckReportsEachStep(t *testing.T) {
+ store := &memoryBimiStore{logos: map[string]*BimiLogo{}}
+ r := newBimiRouter(store)
+ putLogo(t, r, fixture(t, "ebay.svg"))
+
+ rec := bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check?domain=ebay.com", "", true)
+ body := jsonBody(t, rec)
+ if rec.Code != http.StatusOK || body["ready"] != false {
+ t.Fatalf("with nothing published it must not be ready: %d %v", rec.Code, body)
+ }
+ ids := map[string]string{}
+ for _, c := range body["checks"].([]any) {
+ m := c.(map[string]any)
+ ids[m["id"].(string)] = m["status"].(string)
+ }
+ if ids["logo"] != "ok" || ids["dmarc"] != "fail" || ids["record"] != "fail" {
+ t.Errorf("checks = %v", ids)
+ }
+
+ none := bimiCall(r, http.MethodGet, "/api/v1/admin/bimi/check?domain=unknown.test", "", true)
+ if jsonBody(t, none)["configured"] != false {
+ t.Errorf("a domain with no logo: %s", none.Body)
+ }
+}
+
+func TestPublicAddress(t *testing.T) {
+ for address, want := range map[string]bool{
+ "8.8.8.8": true,
+ "1.1.1.1": true,
+ "2001:4860::8888": true,
+ "127.0.0.1": false,
+ "10.0.0.5": false,
+ "172.16.3.4": false,
+ "192.168.1.1": false,
+ "169.254.169.254": false,
+ "100.64.0.1": false,
+ "0.0.0.0": false,
+ "::1": false,
+ "fe80::1": false,
+ "fc00::1": false,
+ "224.0.0.1": false,
+ } {
+ if got := publicAddress(net.ParseIP(address)); got != want {
+ t.Errorf("publicAddress(%s) = %v, want %v", address, got, want)
+ }
+ }
+}
+
+// The fetcher is what keeps the console's checks from reaching this server's
+// own network. It refuses a literal private address before any request leaves.
+func TestFetcherRefusesPrivateAndPlainHTTP(t *testing.T) {
+ fetch := newBimiFetcher()
+ for _, url := range []string{"http://example.test/logo.svg", "https://127.0.0.1/logo.svg", "https://169.254.169.254/latest/meta-data/", "https://[::1]/x"} {
+ if _, err := fetch(context.Background(), url); err == nil {
+ t.Errorf("%s was fetched", url)
+ }
+ }
+}
diff --git a/services/protocols/internal/presentation/http/bimi_check.go b/services/protocols/internal/presentation/http/bimi_check.go
new file mode 100644
index 0000000..37c9351
--- /dev/null
+++ b/services/protocols/internal/presentation/http/bimi_check.go
@@ -0,0 +1,95 @@
+package httppresentation
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "syscall"
+ "time"
+
+ "lambdamail/protocols/internal/domain/bimi"
+)
+
+// netResolver reads TXT records from the system resolver.
+type netResolver struct{}
+
+func (netResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
+ return net.DefaultResolver.LookupTXT(ctx, name)
+}
+
+// maxFetchBytes bounds what a check reads. A logo is at most 32 KB and a
+// certificate chain about 10 KB.
+const maxFetchBytes = 256 * 1024
+
+// newBimiFetcher returns the HTTP client the console's checks use.
+//
+// The URLs it is given come from DNS and from a field an operator filled in,
+// so it must not become a way to make this server call its own network: HTTPS
+// only, and the address actually connected to (after DNS, so a name that
+// rebinds to a private address is caught too) must be a public one.
+func newBimiFetcher() bimi.Fetcher {
+ dialer := &net.Dialer{
+ Timeout: 6 * time.Second,
+ Control: func(_, address string, _ syscall.RawConn) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return err
+ }
+ if ip := net.ParseIP(host); ip == nil || !publicAddress(ip) {
+ return errors.New("refusing to connect to a non-public address")
+ }
+ return nil
+ },
+ }
+ client := &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{DialContext: dialer.DialContext, TLSHandshakeTimeout: 6 * time.Second},
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 3 {
+ return errors.New("too many redirects")
+ }
+ if req.URL.Scheme != "https" {
+ return errors.New("redirect to a non-HTTPS URL")
+ }
+ return nil
+ },
+ }
+
+ return func(ctx context.Context, url string) (bimi.Response, error) {
+ if !strings.HasPrefix(url, "https://") {
+ return bimi.Response{}, fmt.Errorf("%q is not an https URL", url)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return bimi.Response{}, err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return bimi.Response{}, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchBytes))
+ if err != nil {
+ return bimi.Response{}, err
+ }
+ return bimi.Response{Status: resp.StatusCode, ContentType: resp.Header.Get("Content-Type"), Body: body}, nil
+ }
+}
+
+// publicAddress is false for anything that is not a routable public address.
+func publicAddress(ip net.IP) bool {
+ switch {
+ case ip.IsLoopback(), ip.IsPrivate(), ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(),
+ ip.IsMulticast(), ip.IsUnspecified(), ip.IsInterfaceLocalMulticast():
+ return false
+ }
+ // Carrier-grade NAT, 100.64.0.0/10, which IsPrivate does not cover
+ if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xC0 == 64 {
+ return false
+ }
+ return true
+}
diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go
index 10227af..b779ef1 100644
--- a/services/protocols/internal/presentation/http/router.go
+++ b/services/protocols/internal/presentation/http/router.go
@@ -80,7 +80,10 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt)
r.mux.HandleFunc("/.well-known/bimi/default.svg", r.handleBimiLogo)
+ r.mux.HandleFunc("/.well-known/bimi/vmc.pem", r.handleBimiCertificate)
r.mux.HandleFunc("/api/v1/admin/bimi", r.handleBimiAdmin)
+ r.mux.HandleFunc("/api/v1/admin/bimi/certificate", r.handleBimiCertificateAdmin)
+ r.mux.HandleFunc("/api/v1/admin/bimi/check", r.handleBimiCheck)
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json
index dbe7f27..56fe4d3 100644
--- a/services/webmail/messages/en.json
+++ b/services/webmail/messages/en.json
@@ -115,14 +115,79 @@
"totpRemoved": "Second factor removed, with its recovery codes.",
"tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)",
"bimiTitle": "BIMI for {domain}",
- "bimiExplainer": "BIMI is the only mechanism that shows an image beside your mail in external clients. It is per domain, not per user. Gmail and Outlook render it only when the record points at a VMC.",
+ "bimiExplainer": "BIMI is the only mechanism that shows an image next to your mail in outside clients. It is per domain, not per user. Upload the logo as a PNG or SVG and the system converts it to the BIMI format. Gmail and Apple Mail show it only with a certificate (VMC or CMC) issued by a certificate authority, which this system cannot create.",
"bimiConfigured": "Logo published",
"bimiNotConfigured": "No logo",
"bimiNoVmc": "No VMC: Gmail and Outlook will not show this logo.",
- "bimiSvg": "Logo (SVG Tiny PS)",
"bimiVmc": "VMC URL (optional)",
"bimiRejected": "The logo does not meet SVG Tiny Portable/Secure:",
- "bimiSaved": "Logo saved."
+ "bimiSaved": "Logo saved.",
+ "bimiNone": "No logo",
+ "bimiChoose": "Choose PNG or SVG",
+ "bimiReplace": "Replace logo",
+ "bimiFormatHint": "PNG or SVG. The result is a square SVG Tiny PS of at most 32 KB.",
+ "bimiDragHint": "Drag to position. Mail clients show the logo inside a circle or a rounded square.",
+ "bimiZoom": "Zoom",
+ "bimiBackground": "Background colour",
+ "bimiBrandTitle": "Brand name (SVG title)",
+ "bimiMask": "Preview",
+ "bimiMask_circle": "Circle",
+ "bimiMask_rounded": "Rounded square",
+ "bimiMask_none": "No mask",
+ "bimiPreset": "Conversion quality",
+ "bimiPreset_high": "High (most faithful)",
+ "bimiPreset_balanced": "Balanced",
+ "bimiPreset_compact": "Compact",
+ "bimiPreset_tiny": "Minimal",
+ "bimiTraced": "The PNG was converted to vector, which loses some detail. For the sharpest result, upload the original SVG.",
+ "bimiAutoLowered": "The quality was lowered automatically to fit in 32 KB.",
+ "bimiSize": "{size} of {max}",
+ "bimiTooBig": "The file is over 32 KB. Pick a lower quality or simplify the drawing.",
+ "bimiRemoved": "Removed from the file, because BIMI does not allow them: {items}.",
+ "bimiTextWarning": "The logo contains text, which uses the reader's font and may look different. Convert the text to outlines in your editor.",
+ "bimiDownload": "Download SVG",
+ "bimiSaveLogo": "Save logo",
+ "bimiCertTitle": "Certificate (VMC or CMC)",
+ "bimiCertExplainer": "Gmail and Apple Mail show the logo only with a certificate issued by a certificate authority such as DigiCert, Entrust or SSL.com: a VMC, for a registered trademark, or a CMC, for a mark in use. This system cannot create that certificate. Download the SVG above, send it to the authority, and put the .pem file it returns here. Gmail accepts both types; other clients may require a VMC.",
+ "bimiCertChoose": "Upload .pem file",
+ "bimiCertReplace": "Replace certificate",
+ "bimiCertPaste": "Or paste the .pem contents",
+ "bimiCertSave": "Save certificate",
+ "bimiCertValid": "Valid certificate",
+ "bimiCertInvalid": "Certificate has problems",
+ "bimiCertIssued": "Issued to {organization} by {issuer}",
+ "bimiCertValidUntil": "Valid until {date}",
+ "bimiCertDomains": "Domains: {names}",
+ "bimiCertMatches": "It carries exactly this logo.",
+ "bimiCertMismatch": "The logo inside the certificate is not the one published. Upload the SVG it was issued for.",
+ "bimiCertRemove": "Remove certificate",
+ "bimiCertSaved": "Certificate saved.",
+ "bimiCertRejected": "The certificate was refused:",
+ "bimiRecordTitle": "DNS record",
+ "bimiRecordHint": "This TXT record is created by the DNS reconciliation. Check it, or create it by hand at your provider.",
+ "bimiCopy": "Copy",
+ "bimiCopied": "Copied",
+ "bimiCheckTitle": "Check the setup",
+ "bimiCheckExplainer": "Looks at DNS and the public URLs the way a mail server would, and says which part is wrong.",
+ "bimiCheckRun": "Check now",
+ "bimiCheckRunning": "Checking...",
+ "bimiCheckReady": "Everything is in place for BIMI.",
+ "bimiCheckNotReady": "Some items need fixing.",
+ "bimiCheckStatus_ok": "OK",
+ "bimiCheckStatus_warn": "Notice",
+ "bimiCheckStatus_fail": "Failed",
+ "bimiCheckItem_logo": "Logo file",
+ "bimiCheckItem_dmarc": "DMARC policy",
+ "bimiCheckItem_record": "BIMI record in DNS",
+ "bimiCheckItem_logoUrl": "Public URL of the logo",
+ "bimiCheckItem_certificate": "Certificate",
+ "bimiCheckItem_certificateUrl": "Public URL of the certificate",
+ "bimiError_notXml": "That file is not a valid SVG.",
+ "bimiError_notSvg": "That file is not an SVG.",
+ "bimiError_noSize": "The SVG has no size (viewBox, or width and height), so it cannot be positioned.",
+ "bimiError_empty": "No drawing could be found in that file.",
+ "bimiError_notImage": "Choose a PNG or SVG file.",
+ "bimiError_tooComplex": "The drawing is too complex to fit in 32 KB."
},
"auth": {
"adminLoginTitle": "Admin console",
diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json
index a78242f..e72d6eb 100644
--- a/services/webmail/messages/es.json
+++ b/services/webmail/messages/es.json
@@ -115,14 +115,79 @@
"totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.",
"tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)",
"bimiTitle": "BIMI de {domain}",
- "bimiExplainer": "BIMI es el unico mecanismo que muestra una imagen junto a su correo en clientes externos. Es por dominio, no por usuario. Gmail y Outlook solo lo dibujan cuando el registro apunta a un VMC.",
+ "bimiExplainer": "BIMI es el único mecanismo que muestra una imagen junto a su correo en clientes externos. Es por dominio, no por usuario. Suba el logotipo en PNG o SVG y el sistema lo convierte al formato de BIMI. Gmail y Apple Mail solo lo muestran con un certificado (VMC o CMC) emitido por una autoridad certificadora, que este sistema no puede crear.",
"bimiConfigured": "Logotipo publicado",
"bimiNotConfigured": "Sin logotipo",
- "bimiNoVmc": "Sin VMC: Gmail y Outlook no mostraran este logotipo.",
- "bimiSvg": "Logotipo (SVG Tiny PS)",
- "bimiVmc": "URL del VMC (opcional)",
+ "bimiNoVmc": "Sin certificado: Gmail y Apple Mail no mostrarán este logotipo.",
+ "bimiVmc": "URL del certificado (opcional)",
"bimiRejected": "El logotipo no cumple SVG Tiny Portable/Secure:",
- "bimiSaved": "Logotipo guardado."
+ "bimiSaved": "Logotipo guardado.",
+ "bimiNone": "Sin logotipo",
+ "bimiChoose": "Elegir PNG o SVG",
+ "bimiReplace": "Cambiar logotipo",
+ "bimiFormatHint": "PNG o SVG. El resultado es un SVG Tiny PS cuadrado de como máximo 32 KB.",
+ "bimiDragHint": "Arrastre para colocar. Los clientes de correo muestran el logotipo dentro de un círculo o de un cuadrado redondeado.",
+ "bimiZoom": "Zoom",
+ "bimiBackground": "Color de fondo",
+ "bimiBrandTitle": "Nombre de la marca (título del SVG)",
+ "bimiMask": "Vista previa",
+ "bimiMask_circle": "Círculo",
+ "bimiMask_rounded": "Cuadrado redondeado",
+ "bimiMask_none": "Sin máscara",
+ "bimiPreset": "Calidad de la conversión",
+ "bimiPreset_high": "Alta (más fiel)",
+ "bimiPreset_balanced": "Equilibrada",
+ "bimiPreset_compact": "Compacta",
+ "bimiPreset_tiny": "Mínima",
+ "bimiTraced": "El PNG se convirtió a vector, lo que pierde algo de detalle. Para el resultado más nítido, suba el SVG original.",
+ "bimiAutoLowered": "La calidad se redujo automáticamente para caber en 32 KB.",
+ "bimiSize": "{size} de {max}",
+ "bimiTooBig": "El archivo supera 32 KB. Elija una calidad menor o simplifique el dibujo.",
+ "bimiRemoved": "Se quitaron del archivo, porque BIMI no los permite: {items}.",
+ "bimiTextWarning": "El logotipo contiene texto, que usa la fuente de quien lee el mensaje y puede verse distinto. Convierta el texto en curvas en su editor.",
+ "bimiDownload": "Descargar SVG",
+ "bimiSaveLogo": "Guardar logotipo",
+ "bimiCertTitle": "Certificado (VMC o CMC)",
+ "bimiCertExplainer": "Gmail y Apple Mail solo muestran el logotipo con un certificado emitido por una autoridad certificadora, como DigiCert, Entrust o SSL.com: VMC, para marca registrada, o CMC, para marca en uso. El sistema no crea ese certificado. Descargue el SVG de arriba, envíelo a la autoridad y ponga aquí el archivo .pem que le devuelva. Gmail acepta los dos tipos; otros clientes pueden exigir VMC.",
+ "bimiCertChoose": "Subir archivo .pem",
+ "bimiCertReplace": "Cambiar certificado",
+ "bimiCertPaste": "O pegue el contenido del .pem",
+ "bimiCertSave": "Guardar certificado",
+ "bimiCertValid": "Certificado válido",
+ "bimiCertInvalid": "Certificado con problemas",
+ "bimiCertIssued": "Emitido para {organization} por {issuer}",
+ "bimiCertValidUntil": "Válido hasta {date}",
+ "bimiCertDomains": "Dominios: {names}",
+ "bimiCertMatches": "Contiene exactamente este logotipo.",
+ "bimiCertMismatch": "El logotipo dentro del certificado no es el publicado. Suba el SVG para el que fue emitido.",
+ "bimiCertRemove": "Quitar certificado",
+ "bimiCertSaved": "Certificado guardado.",
+ "bimiCertRejected": "El certificado fue rechazado:",
+ "bimiRecordTitle": "Registro DNS",
+ "bimiRecordHint": "Este registro TXT lo crea la reconciliación del DNS. Revíselo, o créelo manualmente en su proveedor.",
+ "bimiCopy": "Copiar",
+ "bimiCopied": "Copiado",
+ "bimiCheckTitle": "Verificar la configuración",
+ "bimiCheckExplainer": "Consulta el DNS y las URL públicas como lo haría un servidor de correo, y dice qué parte está mal.",
+ "bimiCheckRun": "Verificar ahora",
+ "bimiCheckRunning": "Verificando...",
+ "bimiCheckReady": "Todo listo para BIMI.",
+ "bimiCheckNotReady": "Hay elementos por corregir.",
+ "bimiCheckStatus_ok": "OK",
+ "bimiCheckStatus_warn": "Aviso",
+ "bimiCheckStatus_fail": "Falló",
+ "bimiCheckItem_logo": "Archivo del logotipo",
+ "bimiCheckItem_dmarc": "Política DMARC",
+ "bimiCheckItem_record": "Registro BIMI en el DNS",
+ "bimiCheckItem_logoUrl": "URL pública del logotipo",
+ "bimiCheckItem_certificate": "Certificado",
+ "bimiCheckItem_certificateUrl": "URL pública del certificado",
+ "bimiError_notXml": "Ese archivo no es un SVG válido.",
+ "bimiError_notSvg": "Ese archivo no es un SVG.",
+ "bimiError_noSize": "El SVG no tiene tamaño (viewBox, o width y height), así que no se puede colocar.",
+ "bimiError_empty": "No se encontró ningún dibujo en ese archivo.",
+ "bimiError_notImage": "Elija un archivo PNG o SVG.",
+ "bimiError_tooComplex": "El dibujo es demasiado complejo para caber en 32 KB."
},
"auth": {
"adminLoginTitle": "Consola de administración",
@@ -455,7 +520,7 @@
},
"avatar": {
"title": "Foto de perfil",
- "explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la pagina de dominios.",
+ "explainer": "Esta foto aparece dentro de LambdaMail. Servicios externos como Gmail y Outlook no la leen: para eso, vea BIMI en la página de dominios.",
"none": "Sin foto",
"choose": "Elegir foto",
"replace": "Cambiar foto",
diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json
index 9cf5e2f..2799a1e 100644
--- a/services/webmail/messages/pt-BR.json
+++ b/services/webmail/messages/pt-BR.json
@@ -115,14 +115,79 @@
"totpRemoved": "Segundo factor removido, com os codigos de recuperacao.",
"tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)",
"bimiTitle": "BIMI de {domain}",
- "bimiExplainer": "O BIMI e o unico mecanismo que mostra uma imagem ao lado do seu correio em clientes externos. E por dominio, nao por utilizador. O Gmail e o Outlook so o desenham quando o registo aponta para um VMC.",
+ "bimiExplainer": "O BIMI é o único mecanismo que mostra uma imagem ao lado do seu e-mail em clientes externos. Ele vale por domínio, não por usuário. Envie o logotipo em PNG ou SVG e o sistema o converte para o formato do BIMI. O Gmail e o Apple Mail só o mostram com um certificado (VMC ou CMC) emitido por uma autoridade certificadora, que este sistema não consegue criar.",
"bimiConfigured": "Logotipo publicado",
"bimiNotConfigured": "Sem logotipo",
- "bimiNoVmc": "Sem VMC: o Gmail e o Outlook nao vao mostrar este logotipo.",
- "bimiSvg": "Logotipo (SVG Tiny PS)",
- "bimiVmc": "URL do VMC (opcional)",
- "bimiRejected": "O logotipo nao cumpre o SVG Tiny Portable/Secure:",
- "bimiSaved": "Logotipo guardado."
+ "bimiNoVmc": "Sem certificado: o Gmail e o Apple Mail não vão mostrar este logotipo.",
+ "bimiVmc": "URL do certificado (opcional)",
+ "bimiRejected": "O logotipo não cumpre o SVG Tiny Portable/Secure:",
+ "bimiSaved": "Logotipo salvo.",
+ "bimiNone": "Sem logotipo",
+ "bimiChoose": "Escolher PNG ou SVG",
+ "bimiReplace": "Trocar logotipo",
+ "bimiFormatHint": "PNG ou SVG. O resultado é um SVG Tiny PS quadrado, com no máximo 32 KB.",
+ "bimiDragHint": "Arraste para posicionar. Os clientes de e-mail mostram o logotipo dentro de um círculo ou de um quadrado arredondado.",
+ "bimiZoom": "Zoom",
+ "bimiBackground": "Cor de fundo",
+ "bimiBrandTitle": "Nome da marca (título do SVG)",
+ "bimiMask": "Pré-visualização",
+ "bimiMask_circle": "Círculo",
+ "bimiMask_rounded": "Quadrado arredondado",
+ "bimiMask_none": "Sem máscara",
+ "bimiPreset": "Qualidade da conversão",
+ "bimiPreset_high": "Alta (mais fiel)",
+ "bimiPreset_balanced": "Equilibrada",
+ "bimiPreset_compact": "Compacta",
+ "bimiPreset_tiny": "Mínima",
+ "bimiTraced": "O PNG foi convertido em vetor, o que perde um pouco de detalhe. Para o resultado mais nítido, envie o SVG original.",
+ "bimiAutoLowered": "A qualidade foi reduzida automaticamente para caber em 32 KB.",
+ "bimiSize": "{size} de {max}",
+ "bimiTooBig": "O arquivo passa de 32 KB. Escolha uma qualidade menor ou simplifique o desenho.",
+ "bimiRemoved": "Foram removidos do arquivo, porque o BIMI não os permite: {items}.",
+ "bimiTextWarning": "O logotipo contém texto, que usa a fonte de quem lê a mensagem e pode mudar de aparência. Converta o texto em curvas no seu editor.",
+ "bimiDownload": "Baixar SVG",
+ "bimiSaveLogo": "Salvar logotipo",
+ "bimiCertTitle": "Certificado (VMC ou CMC)",
+ "bimiCertExplainer": "O Gmail e o Apple Mail só mostram o logotipo com um certificado emitido por uma autoridade certificadora, como DigiCert, Entrust ou SSL.com: VMC, para marca registrada, ou CMC, para marca em uso. O sistema não cria esse certificado. Baixe o SVG acima, envie-o à autoridade e coloque aqui o arquivo .pem que ela devolver. O Gmail aceita os dois tipos; outros clientes podem exigir VMC.",
+ "bimiCertChoose": "Enviar arquivo .pem",
+ "bimiCertReplace": "Trocar certificado",
+ "bimiCertPaste": "Ou cole o conteúdo do .pem",
+ "bimiCertSave": "Salvar certificado",
+ "bimiCertValid": "Certificado válido",
+ "bimiCertInvalid": "Certificado com problemas",
+ "bimiCertIssued": "Emitido para {organization} por {issuer}",
+ "bimiCertValidUntil": "Válido até {date}",
+ "bimiCertDomains": "Domínios: {names}",
+ "bimiCertMatches": "Contém exatamente este logotipo.",
+ "bimiCertMismatch": "O logotipo dentro do certificado não é o que está publicado. Envie o SVG para o qual ele foi emitido.",
+ "bimiCertRemove": "Remover certificado",
+ "bimiCertSaved": "Certificado salvo.",
+ "bimiCertRejected": "O certificado foi recusado:",
+ "bimiRecordTitle": "Registro DNS",
+ "bimiRecordHint": "Este registro TXT é criado pela reconciliação do DNS. Confira-o, ou crie-o manualmente no seu provedor.",
+ "bimiCopy": "Copiar",
+ "bimiCopied": "Copiado",
+ "bimiCheckTitle": "Verificar configuração",
+ "bimiCheckExplainer": "Consulta o DNS e as URLs públicas como um servidor de e-mail faria, e diz qual parte está errada.",
+ "bimiCheckRun": "Verificar agora",
+ "bimiCheckRunning": "Verificando...",
+ "bimiCheckReady": "Tudo certo para o BIMI.",
+ "bimiCheckNotReady": "Há itens a corrigir.",
+ "bimiCheckStatus_ok": "OK",
+ "bimiCheckStatus_warn": "Atenção",
+ "bimiCheckStatus_fail": "Falhou",
+ "bimiCheckItem_logo": "Arquivo do logotipo",
+ "bimiCheckItem_dmarc": "Política DMARC",
+ "bimiCheckItem_record": "Registro BIMI no DNS",
+ "bimiCheckItem_logoUrl": "URL pública do logotipo",
+ "bimiCheckItem_certificate": "Certificado",
+ "bimiCheckItem_certificateUrl": "URL pública do certificado",
+ "bimiError_notXml": "Esse arquivo não é um SVG válido.",
+ "bimiError_notSvg": "Esse arquivo não é um SVG.",
+ "bimiError_noSize": "O SVG não tem tamanho (viewBox, ou width e height), por isso não dá para posicioná-lo.",
+ "bimiError_empty": "Não foi possível encontrar um desenho nesse arquivo.",
+ "bimiError_notImage": "Escolha um arquivo PNG ou SVG.",
+ "bimiError_tooComplex": "O desenho é complexo demais para caber em 32 KB."
},
"auth": {
"adminLoginTitle": "Console de administração",
@@ -455,12 +520,12 @@
},
"avatar": {
"title": "Foto de perfil",
- "explainer": "Esta foto aparece dentro do LambdaMail. Servicos externos como Gmail e Outlook nao a leem: para eles, veja BIMI na pagina de dominios.",
+ "explainer": "Esta foto aparece dentro do LambdaMail. Serviços externos como Gmail e Outlook não a leem: para eles, veja o BIMI na página de domínios.",
"none": "Sem foto",
"choose": "Escolher foto",
"replace": "Trocar foto",
"zoom": "Zoom",
"dragToPosition": "Arraste para posicionar",
- "notAnImage": "Nao foi possivel ler esse ficheiro como imagem."
+ "notAnImage": "Não foi possível ler esse arquivo como imagem."
}
}
diff --git a/services/webmail/next.config.ts b/services/webmail/next.config.ts
index 82bd934..57adf04 100644
--- a/services/webmail/next.config.ts
+++ b/services/webmail/next.config.ts
@@ -36,6 +36,12 @@ const nextConfig: NextConfig = {
source: "/.well-known/bimi/default.svg",
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/bimi/default.svg`,
},
+ {
+ // The BIMI certificate (VMC/CMC) the DNS record's a= tag points at,
+ // fetched the same way as the logo above.
+ source: "/.well-known/bimi/vmc.pem",
+ destination: `${PROTOCOLS_SERVICE_URL}/.well-known/bimi/vmc.pem`,
+ },
{
source: "/.well-known/security.txt",
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/security.txt`,
diff --git a/services/webmail/package-lock.json b/services/webmail/package-lock.json
index 3f86b2d..188b692 100644
--- a/services/webmail/package-lock.json
+++ b/services/webmail/package-lock.json
@@ -11,6 +11,7 @@
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"framer-motion": "^11.18.2",
+ "imagetracerjs": "1.2.6",
"lucide-react": "^0.474.0",
"next": "15.5.27",
"qrcode-generator": "^2.0.4",
@@ -2721,6 +2722,12 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/imagetracerjs": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/imagetracerjs/-/imagetracerjs-1.2.6.tgz",
+ "integrity": "sha512-LKJlnKmXFzDdh6IZtXTyBxXcCLTAkwgKYS+NMiPXiXVnlTLjQC8fq7U89laUSgHtypJB3TdMMDK4ecG5NI/Cgw==",
+ "license": "Unlicense"
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
diff --git a/services/webmail/package.json b/services/webmail/package.json
index bc1f3bd..6768627 100644
--- a/services/webmail/package.json
+++ b/services/webmail/package.json
@@ -13,6 +13,7 @@
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"framer-motion": "^11.18.2",
+ "imagetracerjs": "1.2.6",
"lucide-react": "^0.474.0",
"next": "15.5.27",
"qrcode-generator": "^2.0.4",
diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx
index 37fd678..6e5b5c9 100644
--- a/services/webmail/src/app/(admin)/admin/domains/page.tsx
+++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx
@@ -5,6 +5,7 @@ import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "l
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
+import { BimiPanel } from "../../../../components/BimiPanel";
/**
* Domains, read from the database.
@@ -79,13 +80,6 @@ export default function AdminDomainsPage() {
const [reconciling, setReconciling] = useState(false);
- // The BIMI mark for the selected domain: the one thing an external mail
- // client will ever render for this sender.
- const [bimi, setBimi] = useState<{ configured: boolean; vmc_url?: string; bytes?: number } | null>(null);
- const [bimiSvg, setBimiSvg] = useState("");
- const [bimiVmc, setBimiVmc] = useState("");
- const [bimiBusy, setBimiBusy] = useState(false);
- const [bimiProblems, setBimiProblems] = useState([]);
const [verification, setVerification] = useState(null);
// What the last reconciliation actually did, so the panel can say it rather
// than leaving the operator to infer it from the verification that follows.
@@ -126,73 +120,6 @@ export default function AdminDomainsPage() {
// Asks the protocols service to resolve every expected record and report
// each one. It used to post to the auth service, which has no resolver and
// could only re-read the status already in the database.
- // Reloaded whenever the selected domain changes: the mark belongs to the
- // domain, not to the console.
- useEffect(() => {
- if (!selected) {
- setBimi(null);
- return;
- }
- let cancelled = false;
- void (async () => {
- try {
- const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`);
- const data = res.ok ? await res.json() : { configured: false };
- if (cancelled) return;
- setBimi(data);
- setBimiVmc(data.vmc_url ?? "");
- setBimiSvg("");
- setBimiProblems([]);
- } catch {
- if (!cancelled) setBimi({ configured: false });
- }
- })();
- return () => {
- cancelled = true;
- };
- }, [selected?.name]);
-
- const saveBimi = async () => {
- if (!selected || !bimiSvg.trim()) return;
- setBimiBusy(true);
- setBimiProblems([]);
- setError(null);
- try {
- const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ svg: bimiSvg, vmc_url: bimiVmc.trim() }),
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- // Every rule that failed, not the first: a receiver that rejects the
- // mark shows nothing at all, so one round trip per rule is the worst
- // possible way to learn this format.
- setBimiProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
- return;
- }
- setBimi({ configured: true, vmc_url: bimiVmc.trim(), bytes: bimiSvg.length });
- setBimiSvg("");
- setNotice(t("admin.bimiSaved"));
- } catch {
- setError(t("errors.serverError"));
- } finally {
- setBimiBusy(false);
- }
- };
-
- const removeBimi = async () => {
- if (!selected) return;
- setBimiBusy(true);
- try {
- await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, { method: "DELETE" });
- setBimi({ configured: false });
- setBimiVmc("");
- } finally {
- setBimiBusy(false);
- }
- };
-
const handleReconcile = async () => {
if (!selected) return;
setReconciling(true);
@@ -523,67 +450,7 @@ export default function AdminDomainsPage() {
{/* BIMI: the only mark an external mail client ever renders for this
sender, and the reason a profile photo does not travel. */}
- {activeTab === "domains" && selected && (
-
-