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 = `Example` + +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 @@ + + + +CNN + + + 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 @@ + + + bimi-svg-tiny-12-ps + + + + + \ 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(`x`) + 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/); + }); });