From 712885c091a1eb3d37eb760d68fa1c5cdd9b7c59 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Tue, 1 Sep 2026 12:08:25 -0300 Subject: [PATCH] feat(tls): o painel diz o digest que o registo TLSA tem de carregar Uma associacao DANE e o SHA-256 sobre o SubjectPublicKeyInfo do certificado que esta a ser servido. Se o que esta no DNS e o que o servidor apresenta se afastarem, qualquer remetente que valide DANE recusa o correio -- e nada no painel dizia porque. Passa a mostrar o numero, para poder ser comparado com o registo publicado. Co-Authored-By: Claude Opus 5 --- .../infrastructure/tls/provider_status.go | 17 +++++++++ .../tls/provider_status_test.go | 37 +++++++++++++++++++ .../presentation/http/admin_tls_api.go | 15 ++++++++ services/webmail/messages/en.json | 3 +- services/webmail/messages/es.json | 3 +- services/webmail/messages/pt-BR.json | 3 +- .../src/app/(admin)/admin/security/page.tsx | 11 ++++++ 7 files changed, 86 insertions(+), 3 deletions(-) diff --git a/services/protocols/internal/infrastructure/tls/provider_status.go b/services/protocols/internal/infrastructure/tls/provider_status.go index 6ac7e2b..a097c5e 100644 --- a/services/protocols/internal/infrastructure/tls/provider_status.go +++ b/services/protocols/internal/infrastructure/tls/provider_status.go @@ -2,8 +2,10 @@ package tlsprovider import ( "bytes" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "time" "lambdamail/protocols/internal/application/port" @@ -94,3 +96,18 @@ func (s *ProviderStatus) CertificateSummary(host string) (string, bool, bool) { leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature) == nil return issuer, selfSigned, true } + +// CertificateSpkiDigest is the SHA-256 over the certificate's +// SubjectPublicKeyInfo: the number a TLSA "3 1 1" record has to carry. +// +// Published so an operator can compare what is in DNS against what the server +// is actually serving. When those two disagree, every DANE-validating sender +// refuses the mail, and nothing else on the panel would show why. +func (s *ProviderStatus) CertificateSpkiDigest(host string) (string, bool) { + leaf := s.leafFor(host) + if leaf == nil { + return "", false + } + sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + return hex.EncodeToString(sum[:]), true +} diff --git a/services/protocols/internal/infrastructure/tls/provider_status_test.go b/services/protocols/internal/infrastructure/tls/provider_status_test.go index c1d373e..e98a98f 100644 --- a/services/protocols/internal/infrastructure/tls/provider_status_test.go +++ b/services/protocols/internal/infrastructure/tls/provider_status_test.go @@ -1,6 +1,9 @@ package tlsprovider import ( + "crypto/sha256" + "encoding/hex" + "strings" "testing" "time" ) @@ -47,3 +50,37 @@ func TestProviderStatus_ReportsAnEphemeralSelfSignedCertificate(t *testing.T) { t.Error("no issuer reported") } } + +// The DANE association an operator publishes has to be the hash of the key the +// server is actually serving. If the two ever drift, mail from a validating +// sender is refused outright - so the panel states the digest of the live +// certificate, which is the number to compare against the TLSA record. +func TestProviderStatus_ReportsTheSpkiDigestOfTheServedCertificate(t *testing.T) { + provider, err := NewEphemeralSelfSignedCertProvider() + if err != nil { + t.Fatalf("build provider: %v", err) + } + status := NewProviderStatus(provider) + + digest, ok := status.CertificateSpkiDigest("mail.example.test") + if !ok { + t.Fatal("no digest reported") + } + // TLSA 3 1 1 is a SHA-256 over the SubjectPublicKeyInfo: 64 hex characters. + if len(digest) != 64 { + t.Errorf("digest %q is %d characters, want 64", digest, len(digest)) + } + for _, c := range digest { + if !strings.ContainsRune("0123456789abcdef", c) { + t.Fatalf("digest %q is not lowercase hex", digest) + } + } + + // It must be the SPKI hash, not a hash of the whole certificate: those are + // different numbers and publishing the wrong one breaks delivery. + leaf := status.leafFor("mail.example.test") + want := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + if digest != hex.EncodeToString(want[:]) { + t.Errorf("digest is not the SHA-256 of the SubjectPublicKeyInfo") + } +} diff --git a/services/protocols/internal/presentation/http/admin_tls_api.go b/services/protocols/internal/presentation/http/admin_tls_api.go index b071e20..cb9fb56 100644 --- a/services/protocols/internal/presentation/http/admin_tls_api.go +++ b/services/protocols/internal/presentation/http/admin_tls_api.go @@ -23,6 +23,12 @@ type TlsCertificateInspector interface { CertificateSummary(host string) (issuer string, selfSigned bool, ok bool) } +// TlsDaneInspector reports the digest a TLSA "3 1 1" record must carry for the +// certificate currently being served. +type TlsDaneInspector interface { + CertificateSpkiDigest(host string) (string, bool) +} + type adminTlsAPI struct { source TlsStatusSource mailHost string @@ -99,6 +105,15 @@ func (a *adminTlsAPI) handleStatus(w http.ResponseWriter, r *http.Request) { } } + // The number a DANE association has to match. Published so an operator can + // compare it against the TLSA record in DNS: if the two drift apart, every + // validating sender refuses the mail and nothing else here would say why. + if dane, ok := a.source.(TlsDaneInspector); ok { + if digest, found := dane.CertificateSpkiDigest(a.mailHost); found { + status["tlsa_digest"] = digest + } + } + if !status["has_certificate"].(bool) { status["state"] = "NO_CERTIFICATE" } diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index dccc7d7..7e228fb 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -112,7 +112,8 @@ "passwordReset": "Password changed. This user's sessions were signed out.", "passwordResetWarning": "Signs out every session this user holds, including one open right now.", "removeTotp": "Remove second factor", - "totpRemoved": "Second factor removed, with its recovery codes." + "totpRemoved": "Second factor removed, with its recovery codes.", + "tlsaDigest": "Digest for the TLSA record (DANE 3 1 1)" }, "auth": { "adminLoginTitle": "Admin console", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 2d847f7..8f05d7f 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -112,7 +112,8 @@ "passwordReset": "Contrasena cambiada. Las sesiones de este usuario se cerraron.", "passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.", "removeTotp": "Quitar segundo factor", - "totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion." + "totpRemoved": "Segundo factor eliminado, con sus codigos de recuperacion.", + "tlsaDigest": "Digest para el registro TLSA (DANE 3 1 1)" }, "auth": { "adminLoginTitle": "Consola de administración", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 8cc5a66..7bdcf5b 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -112,7 +112,8 @@ "passwordReset": "Palavra-passe alterada. As sessoes deste utilizador foram terminadas.", "passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.", "removeTotp": "Remover segundo factor", - "totpRemoved": "Segundo factor removido, com os codigos de recuperacao." + "totpRemoved": "Segundo factor removido, com os codigos de recuperacao.", + "tlsaDigest": "Digest para o registo TLSA (DANE 3 1 1)" }, "auth": { "adminLoginTitle": "Console de administração", diff --git a/services/webmail/src/app/(admin)/admin/security/page.tsx b/services/webmail/src/app/(admin)/admin/security/page.tsx index 7e5932a..05c6544 100644 --- a/services/webmail/src/app/(admin)/admin/security/page.tsx +++ b/services/webmail/src/app/(admin)/admin/security/page.tsx @@ -306,6 +306,17 @@ export default function AdminSecurityPage() { + {/* The number a TLSA "3 1 1" record has to carry. If DNS and this + disagree, every DANE-validating sender refuses the mail. */} + {tlsStatus.tlsa_digest ? ( +
+
{t("admin.tlsaDigest")}
+
+ {String(tlsStatus.tlsa_digest)} +
+
+ ) : null} +
{t("admin.mailHost")}