161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
// 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")
|
||
|
|
}
|
||
|
|
}
|