update
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user