This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+160
View File
@@ -0,0 +1,160 @@
// 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 _<port>._tcp.<host> 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")
}
}