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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c06691a056
commit
712885c091
7 files changed
+86
-3
No files matched your search
@@ -2,8 +2,10 @@ package tlsprovider
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/port"
|
"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
|
leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature) == nil
|
||||||
return issuer, selfSigned, true
|
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package tlsprovider
|
package tlsprovider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -47,3 +50,37 @@ func TestProviderStatus_ReportsAnEphemeralSelfSignedCertificate(t *testing.T) {
|
|||||||
t.Error("no issuer reported")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ type TlsCertificateInspector interface {
|
|||||||
CertificateSummary(host string) (issuer string, selfSigned bool, ok bool)
|
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 {
|
type adminTlsAPI struct {
|
||||||
source TlsStatusSource
|
source TlsStatusSource
|
||||||
mailHost string
|
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) {
|
if !status["has_certificate"].(bool) {
|
||||||
status["state"] = "NO_CERTIFICATE"
|
status["state"] = "NO_CERTIFICATE"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"passwordReset": "Password changed. This user's sessions were signed out.",
|
"passwordReset": "Password changed. This user's sessions were signed out.",
|
||||||
"passwordResetWarning": "Signs out every session this user holds, including one open right now.",
|
"passwordResetWarning": "Signs out every session this user holds, including one open right now.",
|
||||||
"removeTotp": "Remove second factor",
|
"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": {
|
"auth": {
|
||||||
"adminLoginTitle": "Admin console",
|
"adminLoginTitle": "Admin console",
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"passwordReset": "Contrasena cambiada. Las sesiones de este usuario se cerraron.",
|
"passwordReset": "Contrasena cambiada. Las sesiones de este usuario se cerraron.",
|
||||||
"passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.",
|
"passwordResetWarning": "Cierra todas las sesiones de este usuario, incluida una abierta ahora.",
|
||||||
"removeTotp": "Quitar segundo factor",
|
"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": {
|
"auth": {
|
||||||
"adminLoginTitle": "Consola de administración",
|
"adminLoginTitle": "Consola de administración",
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"passwordReset": "Palavra-passe alterada. As sessoes deste utilizador foram terminadas.",
|
"passwordReset": "Palavra-passe alterada. As sessoes deste utilizador foram terminadas.",
|
||||||
"passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.",
|
"passwordResetWarning": "Termina todas as sessoes deste utilizador, incluindo a que esteja aberta agora.",
|
||||||
"removeTotp": "Remover segundo factor",
|
"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": {
|
"auth": {
|
||||||
"adminLoginTitle": "Console de administração",
|
"adminLoginTitle": "Console de administração",
|
||||||
|
|||||||
@@ -306,6 +306,17 @@ export default function AdminSecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 ? (
|
||||||
|
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge md:col-span-3">
|
||||||
|
<div className="text-xs text-slate-400">{t("admin.tlsaDigest")}</div>
|
||||||
|
<div className="break-all font-mono text-[12px] text-slate-200">
|
||||||
|
{String(tlsStatus.tlsa_digest)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
<div className="text-xs text-slate-400">{t("admin.mailHost")}</div>
|
<div className="text-xs text-slate-400">{t("admin.mailHost")}</div>
|
||||||
<div className="break-all text-[15px] font-medium text-slate-100">
|
<div className="break-all text-[15px] font-medium text-slate-100">
|
||||||
|
|||||||
Reference in new issue
Block a user