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/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/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-----
+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
+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-----
+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
+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/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/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/src/wellknown-routing.test.ts b/services/webmail/src/wellknown-routing.test.ts
index 3d44f24..cc0357d 100644
--- a/services/webmail/src/wellknown-routing.test.ts
+++ b/services/webmail/src/wellknown-routing.test.ts
@@ -58,4 +58,12 @@ describe("the routes the protocols service owns", () => {
expect(config).toContain("/.well-known/bimi/default.svg");
expect(config).toMatch(/bimi\/default\.svg[\s\S]{0,200}PROTOCOLS_SERVICE_URL/);
});
+
+ // The a= tag of the BIMI record points here when the certificate is held by
+ // the service; without the rewrite the Next 404 page is served instead, and
+ // the receiver sees a record whose certificate cannot be fetched.
+ it("routes the BIMI certificate to the service that stores it", () => {
+ expect(config).toContain("/.well-known/bimi/vmc.pem");
+ expect(config).toMatch(/bimi\/vmc\.pem[\s\S]{0,200}PROTOCOLS_SERVICE_URL/);
+ });
});