// Package dane implements RFC 6698/7672 DANE for outbound SMTP delivery — // verifying a remote MX's certificate against a TLSA DNS record instead of // (or alongside) the normal CA/PKI trust model. // // Security caveat, stated plainly: DANE's guarantee depends entirely on the // TLSA record itself coming from a DNSSEC-validated response. This package // gets that validation from internal/dnssec, which performs real RRSIG/ // DNSKEY/DS chain-of-trust verification against IANA's root trust anchor — // not merely a resolver's "AD" flag. An unauthenticated (or unverifiable) // TLSA record is worthless (an attacker who can forge DNS can forge the // record too) and is treated identically to "no record found". package dane import ( "bytes" "context" "crypto/sha256" "crypto/sha512" "crypto/x509" "encoding/hex" "fmt" "log/slog" "github.com/miekg/dns" "gomail/internal/dnssec" ) // Certificate usage field values (RFC 6698 §2.1.1). Only DANE-TA/DANE-EE // are matched by this package — RFC 7672 §3.1 recommends against PKIX-TA/ // PKIX-EE for SMTP, since both still depend on the CA/PKI trust model DANE // exists to route around. const ( UsagePKIXTA = 0 UsagePKIXEE = 1 UsageDANETA = 2 UsageDANEEE = 3 ) const ( SelectorFullCert = 0 SelectorSPKI = 1 ) const ( MatchingExact = 0 MatchingSHA256 = 1 MatchingSHA384 = 2 ) // TLSARecord is one parsed TLSA resource record. type TLSARecord struct { Usage uint8 Selector uint8 MatchingType uint8 Data []byte } // Lookup queries _._tcp. for TLSA records, requiring a fully // DNSSEC-validated chain of trust (internal/dnssec) before trusting any of // them. A broken or absent chain is treated identically to "no records", // and records with usage 0/1 are skipped (logged, not silently dropped) // since RFC 7672 recommends against them for SMTP. func Lookup(ctx context.Context, host string, port int) ([]TLSARecord, error) { qname := fmt.Sprintf("_%d._tcp.%s", port, host) rrset, err := dnssec.Validate(ctx, qname, dns.TypeTLSA) if err != nil { slog.Warn("TLSA lookup could not be DNSSEC-authenticated — ignoring; see internal/dane's package doc comment", "host", host, "port", port, "err", err) return nil, nil } var records []TLSARecord for _, rr := range rrset { tlsa, ok := rr.(*dns.TLSA) if !ok { continue } data, err := hex.DecodeString(tlsa.Certificate) if err != nil { continue } rec := TLSARecord{Usage: tlsa.Usage, Selector: tlsa.Selector, MatchingType: tlsa.MatchingType, Data: data} if rec.Usage != UsageDANETA && rec.Usage != UsageDANEEE { slog.Warn("TLSA record has a usage type not recommended for SMTP (RFC 7672 §3.1) — skipping", "host", host, "usage", rec.Usage) continue } records = append(records, rec) } return records, nil } // matches reports whether cert (or its SPKI, per rec.Selector) matches // rec's certificate association data under rec.MatchingType. func (rec TLSARecord) matches(cert *x509.Certificate) bool { var subject []byte switch rec.Selector { case SelectorFullCert: subject = cert.Raw case SelectorSPKI: subject = cert.RawSubjectPublicKeyInfo default: return false } var digest []byte switch rec.MatchingType { case MatchingExact: digest = subject case MatchingSHA256: sum := sha256.Sum256(subject) digest = sum[:] case MatchingSHA384: sum := sha512.Sum384(subject) digest = sum[:] default: return false } return bytes.Equal(digest, rec.Data) } // VerifyPeerCertificate builds a tls.Config.VerifyPeerCertificate callback // that succeeds if ANY of records matches, per RFC 6698. Usage 3 (DANE-EE) // checks only the leaf certificate the server presents; usage 2 (DANE-TA) // checks every certificate presented (the constrained CA may be an // intermediate, not the root). This deliberately never builds or verifies a // chain to a trusted root store — usage 2/3's entire point is that the TLSA // record itself is the trust anchor, not a CA pool. Pair with // tls.Config.InsecureSkipVerify = true (this callback is the replacement // verification, not an addition to normal PKI checking). func VerifyPeerCertificate(records []TLSARecord) func(rawCerts [][]byte, _ [][]*x509.Certificate) error { return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { if len(rawCerts) == 0 { return fmt.Errorf("dane: server presented no certificates") } certs := make([]*x509.Certificate, 0, len(rawCerts)) for _, raw := range rawCerts { cert, err := x509.ParseCertificate(raw) if err != nil { return fmt.Errorf("dane: parsing presented certificate: %w", err) } certs = append(certs, cert) } for _, rec := range records { switch rec.Usage { case UsageDANEEE: if rec.matches(certs[0]) { return nil } case UsageDANETA: for _, cert := range certs { if rec.matches(cert) { return nil } } } } return fmt.Errorf("dane: no TLSA record matched the presented certificate chain") } }