mox/mtasts/mtasts.go

353 lines
12 KiB
Go
Raw Normal View History

2023-01-30 16:27:06 +03:00
// Package mtasts implements MTA-STS (SMTP MTA Strict Transport Security, RFC 8461)
// which allows a domain to specify SMTP TLS requirements.
//
// SMTP for message delivery to a remote mail server always starts out unencrypted,
// in plain text. STARTTLS allows upgrading the connection to TLS, but is optional
// and by default mail servers will fall back to plain text communication if
// STARTTLS does not work (which can be sabotaged by DNS manipulation or SMTP
// connection manipulation). MTA-STS can specify a policy for requiring STARTTLS to
// be used for message delivery. A TXT DNS record at "_mta-sts.<domain>" specifies
// the version of the policy, and
// "https://mta-sts.<domain>/.well-known/mta-sts.txt" serves the policy.
package mtasts
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
2023-01-30 16:27:06 +03:00
"net/http"
"strings"
"time"
implement outgoing tls reports we were already accepting, processing and displaying incoming tls reports. now we start tracking TLS connection and security-policy-related errors for outgoing message deliveries as well. we send reports once a day, to the reporting addresses specified in TLSRPT records (rua) of a policy domain. these reports are about MTA-STS policies and/or DANE policies, and about STARTTLS-related failures. sending reports is enabled by default, but can be disabled through setting NoOutgoingTLSReports in mox.conf. only at the end of the implementation process came the realization that the TLSRPT policy domain for DANE (MX) hosts are separate from the TLSRPT policy for the recipient domain, and that MTA-STS and DANE TLS/policy results are typically delivered in separate reports. so MX hosts need their own TLSRPT policies. config for the per-host TLSRPT policy should be added to mox.conf for existing installs, in field HostTLSRPT. it is automatically configured by quickstart for new installs. with a HostTLSRPT config, the "dns records" and "dns check" admin pages now suggest the per-host TLSRPT record. by creating that record, you're requesting TLS reports about your MX host. gathering all the TLS/policy results is somewhat tricky. the tentacles go throughout the code. the positive result is that the TLS/policy-related code had to be cleaned up a bit. for example, the smtpclient TLS modes now reflect reality better, with independent settings about whether PKIX and/or DANE verification has to be done, and/or whether verification errors have to be ignored (e.g. for tls-required: no header). also, cached mtasts policies of mode "none" are now cleaned up once the MTA-STS DNS record goes away.
2023-11-09 19:40:46 +03:00
"github.com/mjl-/adns"
2023-01-30 16:27:06 +03:00
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/stub"
2023-01-30 16:27:06 +03:00
)
var (
MetricGet stub.HistogramVec = stub.HistogramVecIgnore{}
HTTPClientObserve func(ctx context.Context, log *slog.Logger, pkg, method string, statusCode int, err error, start time.Time) = stub.HTTPClientObserveIgnore
2023-01-30 16:27:06 +03:00
)
// Pair is an extension key/value pair in a MTA-STS DNS record or policy.
type Pair struct {
Key string
Value string
}
// Record is an MTA-STS DNS record, served under "_mta-sts.<domain>" as a TXT
// record.
//
// Example:
//
// v=STSv1; id=20160831085700Z
type Record struct {
Version string // "STSv1", for "v=". Required.
ID string // Record version, for "id=". Required.
Extensions []Pair // Optional extensions.
}
// String returns a textual version of the MTA-STS record for use as DNS TXT
// record.
func (r Record) String() string {
b := &strings.Builder{}
fmt.Fprint(b, "v="+r.Version)
fmt.Fprint(b, "; id="+r.ID)
for _, p := range r.Extensions {
fmt.Fprint(b, "; "+p.Key+"="+p.Value)
}
return b.String()
}
// Mode indicates how the policy should be interpreted.
type Mode string
// ../rfc/8461:655
const (
ModeEnforce Mode = "enforce" // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made.
ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT.
2023-01-30 16:27:06 +03:00
ModeNone Mode = "none" // In case MTA-STS is not or no longer implemented.
)
// MX is an allowlisted MX host name/pattern.
type MX struct {
2023-01-30 16:27:06 +03:00
// "*." wildcard, e.g. if a subdomain matches. A wildcard must match exactly one
// label. *.example.com matches mail.example.com, but not example.com, and not
// foor.bar.example.com.
Wildcard bool
Domain dns.Domain
}
// LogString returns a loggable string representing the host, with both unicode
// and ascii version for IDNA domains.
func (s MX) LogString() string {
pre := ""
if s.Wildcard {
pre = "*."
}
if s.Domain.Unicode == "" {
return pre + s.Domain.ASCII
}
return pre + s.Domain.Unicode + "/" + pre + s.Domain.ASCII
}
2023-01-30 16:27:06 +03:00
// Policy is an MTA-STS policy as served at "https://mta-sts.<domain>/.well-known/mta-sts.txt".
type Policy struct {
Version string // "STSv1"
Mode Mode
MX []MX
2023-01-30 16:27:06 +03:00
MaxAgeSeconds int // How long this policy can be cached. Suggested values are in weeks or more.
Extensions []Pair
}
// String returns a textual representation for serving at the well-known URL.
func (p Policy) String() string {
b := &strings.Builder{}
line := func(k, v string) {
fmt.Fprint(b, k+": "+v+"\n")
}
line("version", p.Version)
line("mode", string(p.Mode))
line("max_age", fmt.Sprintf("%d", p.MaxAgeSeconds))
for _, mx := range p.MX {
s := mx.Domain.Name()
if mx.Wildcard {
s = "*." + s
}
line("mx", s)
}
return b.String()
}
// Matches returns whether the hostname matches the mx list in the policy.
func (p *Policy) Matches(host dns.Domain) bool {
// ../rfc/8461:636
for _, mx := range p.MX {
if mx.Wildcard {
v := strings.SplitN(host.ASCII, ".", 2)
if len(v) == 2 && v[1] == mx.Domain.ASCII {
return true
}
} else if host == mx.Domain {
return true
}
}
return false
}
implement outgoing tls reports we were already accepting, processing and displaying incoming tls reports. now we start tracking TLS connection and security-policy-related errors for outgoing message deliveries as well. we send reports once a day, to the reporting addresses specified in TLSRPT records (rua) of a policy domain. these reports are about MTA-STS policies and/or DANE policies, and about STARTTLS-related failures. sending reports is enabled by default, but can be disabled through setting NoOutgoingTLSReports in mox.conf. only at the end of the implementation process came the realization that the TLSRPT policy domain for DANE (MX) hosts are separate from the TLSRPT policy for the recipient domain, and that MTA-STS and DANE TLS/policy results are typically delivered in separate reports. so MX hosts need their own TLSRPT policies. config for the per-host TLSRPT policy should be added to mox.conf for existing installs, in field HostTLSRPT. it is automatically configured by quickstart for new installs. with a HostTLSRPT config, the "dns records" and "dns check" admin pages now suggest the per-host TLSRPT record. by creating that record, you're requesting TLS reports about your MX host. gathering all the TLS/policy results is somewhat tricky. the tentacles go throughout the code. the positive result is that the TLS/policy-related code had to be cleaned up a bit. for example, the smtpclient TLS modes now reflect reality better, with independent settings about whether PKIX and/or DANE verification has to be done, and/or whether verification errors have to be ignored (e.g. for tls-required: no header). also, cached mtasts policies of mode "none" are now cleaned up once the MTA-STS DNS record goes away.
2023-11-09 19:40:46 +03:00
// TLSReportFailureReason returns a concise error for known error types, or an
// empty string. For use in TLSRPT.
func TLSReportFailureReason(err error) string {
// If this is a DNSSEC authentication error, we'll collect it for TLS reporting.
// We can also use this reason for STS, not only DANE. ../rfc/8460:580
var errCode adns.ErrorCode
if errors.As(err, &errCode) && errCode.IsAuthentication() {
return fmt.Sprintf("dns-extended-error-%d-%s", errCode, strings.ReplaceAll(errCode.String(), " ", "-"))
}
for _, e := range mtastsErrors {
if errors.Is(err, e) {
s := strings.TrimPrefix(e.Error(), "mtasts: ")
return strings.ReplaceAll(s, " ", "-")
}
}
return ""
}
var mtastsErrors = []error{
ErrNoRecord, ErrMultipleRecords, ErrDNS, ErrRecordSyntax, // Lookup
ErrNoPolicy, ErrPolicyFetch, ErrPolicySyntax, // Fetch
}
2023-01-30 16:27:06 +03:00
// Lookup errors.
var (
ErrNoRecord = errors.New("mtasts: no mta-sts dns txt record") // Domain does not implement MTA-STS. If a cached non-expired policy is available, it should still be used.
ErrMultipleRecords = errors.New("mtasts: multiple mta-sts records") // Should be treated as if domain does not implement MTA-STS, unless a cached non-expired policy is available.
ErrDNS = errors.New("mtasts: dns lookup") // For temporary DNS errors.
ErrRecordSyntax = errors.New("mtasts: record syntax error")
)
// LookupRecord looks up the MTA-STS TXT DNS record at "_mta-sts.<domain>",
// following CNAME records, and returns the parsed MTA-STS record and the DNS TXT
// record.
func LookupRecord(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rrecord *Record, rtxt string, rerr error) {
log := mlog.New("mtasts", elog)
2023-01-30 16:27:06 +03:00
start := time.Now()
defer func() {
log.Debugx("mtasts lookup result", rerr,
slog.Any("domain", domain),
slog.Any("record", rrecord),
slog.Duration("duration", time.Since(start)))
2023-01-30 16:27:06 +03:00
}()
// ../rfc/8461:289
// ../rfc/8461:351
// We lookup the txt record, but must follow CNAME records when the TXT does not
// exist. LookupTXT follows CNAMEs.
2023-01-30 16:27:06 +03:00
name := "_mta-sts." + domain.ASCII + "."
var txts []string
txts, _, err := dns.WithPackage(resolver, "mtasts").LookupTXT(ctx, name)
if dns.IsNotFound(err) {
return nil, "", ErrNoRecord
} else if err != nil {
return nil, "", fmt.Errorf("%w: %s", ErrDNS, err)
2023-01-30 16:27:06 +03:00
}
var text string
var record *Record
for _, txt := range txts {
r, ismtasts, err := ParseRecord(txt)
if !ismtasts {
// ../rfc/8461:331 says we should essentially treat a record starting with e.g.
// "v=STSv1 ;" (note the space) as a non-STS record too in case of multiple TXT
// records. We treat it as an STS record that is invalid, which is possibly more
// reasonable.
continue
}
if err != nil {
return nil, "", err
2023-01-30 16:27:06 +03:00
}
if record != nil {
return nil, "", ErrMultipleRecords
2023-01-30 16:27:06 +03:00
}
record = r
text = txt
}
if record == nil {
return nil, "", ErrNoRecord
2023-01-30 16:27:06 +03:00
}
return record, text, nil
2023-01-30 16:27:06 +03:00
}
// Policy fetch errors.
var (
ErrNoPolicy = errors.New("mtasts: no policy served") // If the name "mta-sts.<domain>" does not exist in DNS or if webserver returns HTTP status 404 "File not found".
ErrPolicyFetch = errors.New("mtasts: cannot fetch policy") // E.g. for HTTP request errors.
ErrPolicySyntax = errors.New("mtasts: policy syntax error")
)
// HTTPClient is used by FetchPolicy for HTTP requests.
var HTTPClient = &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return fmt.Errorf("redirect not allowed for MTA-STS policies") // ../rfc/8461:549
},
}
// FetchPolicy fetches a new policy for the domain, at
// https://mta-sts.<domain>/.well-known/mta-sts.txt.
//
// FetchPolicy returns the parsed policy and the literal policy text as fetched
// from the server. If a policy was fetched but could not be parsed, the policyText
// return value will be set.
//
// Policies longer than 64KB result in a syntax error.
//
// If an error is returned, callers should back off for 5 minutes until the next
// attempt.
func FetchPolicy(ctx context.Context, elog *slog.Logger, domain dns.Domain) (policy *Policy, policyText string, rerr error) {
log := mlog.New("mtasts", elog)
2023-01-30 16:27:06 +03:00
start := time.Now()
defer func() {
log.Debugx("mtasts fetch policy result", rerr,
slog.Any("domain", domain),
slog.Any("policy", policy),
slog.String("policytext", policyText),
slog.Duration("duration", time.Since(start)))
2023-01-30 16:27:06 +03:00
}()
// Timeout of 1 minute. ../rfc/8461:569
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
// TLS requirements are what the Go standard library checks: trusted, non-expired,
implement dnssec-awareness throughout code, and dane for incoming/outgoing mail delivery the vendored dns resolver code is a copy of the go stdlib dns resolver, with awareness of the "authentic data" (i.e. dnssec secure) added, as well as support for enhanced dns errors, and looking up tlsa records (for dane). ideally it would be upstreamed, but the chances seem slim. dnssec-awareness is added to all packages, e.g. spf, dkim, dmarc, iprev. their dnssec status is added to the Received message headers for incoming email. but the main reason to add dnssec was for implementing dane. with dane, the verification of tls certificates can be done through certificates/public keys published in dns (in the tlsa records). this only makes sense (is trustworthy) if those dns records can be verified to be authentic. mox now applies dane to delivering messages over smtp. mox already implemented mta-sts for webpki/pkix-verification of certificates against the (large) pool of CA's, and still enforces those policies when present. but it now also checks for dane records, and will verify those if present. if dane and mta-sts are both absent, the regular opportunistic tls with starttls is still done. and the fallback to plaintext is also still done. mox also makes it easy to setup dane for incoming deliveries, so other servers can deliver with dane tls certificate verification. the quickstart now generates private keys that are used when requesting certificates with acme. the private keys are pre-generated because they must be static and known during setup, because their public keys must be published in tlsa records in dns. autocert would generate private keys on its own, so had to be forked to add the option to provide the private key when requesting a new certificate. hopefully upstream will accept the change and we can drop the fork. with this change, using the quickstart to setup a new mox instance, the checks at internet.nl result in a 100% score, provided the domain is dnssec-signed and the network doesn't have any issues.
2023-10-10 13:09:35 +03:00
// hostname verified against DNS-ID supporting wildcard. ../rfc/8461:524
2023-01-30 16:27:06 +03:00
url := "https://mta-sts." + domain.Name() + "/.well-known/mta-sts.txt"
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", fmt.Errorf("%w: http request: %s", ErrPolicyFetch, err)
}
// We are not likely to reuse a connection: we cache policies and negative DNS
// responses. So don't keep connections open unnecessarily.
req.Close = true
resp, err := HTTPClient.Do(req)
if dns.IsNotFound(err) {
return nil, "", ErrNoPolicy
}
if err != nil {
implement outgoing tls reports we were already accepting, processing and displaying incoming tls reports. now we start tracking TLS connection and security-policy-related errors for outgoing message deliveries as well. we send reports once a day, to the reporting addresses specified in TLSRPT records (rua) of a policy domain. these reports are about MTA-STS policies and/or DANE policies, and about STARTTLS-related failures. sending reports is enabled by default, but can be disabled through setting NoOutgoingTLSReports in mox.conf. only at the end of the implementation process came the realization that the TLSRPT policy domain for DANE (MX) hosts are separate from the TLSRPT policy for the recipient domain, and that MTA-STS and DANE TLS/policy results are typically delivered in separate reports. so MX hosts need their own TLSRPT policies. config for the per-host TLSRPT policy should be added to mox.conf for existing installs, in field HostTLSRPT. it is automatically configured by quickstart for new installs. with a HostTLSRPT config, the "dns records" and "dns check" admin pages now suggest the per-host TLSRPT record. by creating that record, you're requesting TLS reports about your MX host. gathering all the TLS/policy results is somewhat tricky. the tentacles go throughout the code. the positive result is that the TLS/policy-related code had to be cleaned up a bit. for example, the smtpclient TLS modes now reflect reality better, with independent settings about whether PKIX and/or DANE verification has to be done, and/or whether verification errors have to be ignored (e.g. for tls-required: no header). also, cached mtasts policies of mode "none" are now cleaned up once the MTA-STS DNS record goes away.
2023-11-09 19:40:46 +03:00
// We pass along underlying TLS certificate errors.
return nil, "", fmt.Errorf("%w: http get: %w", ErrPolicyFetch, err)
2023-01-30 16:27:06 +03:00
}
HTTPClientObserve(ctx, log.Logger, "mtasts", req.Method, resp.StatusCode, err, start)
2023-01-30 16:27:06 +03:00
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", ErrNoPolicy
}
if resp.StatusCode != http.StatusOK {
// ../rfc/8461:548
return nil, "", fmt.Errorf("%w: http status %s while status 200 is required", ErrPolicyFetch, resp.Status)
}
// We don't look at Content-Type and charset. It should be ASCII or UTF-8, we'll
// just always whatever is sent as UTF-8. ../rfc/8461:367
// ../rfc/8461:570
buf, err := io.ReadAll(&moxio.LimitReader{R: resp.Body, Limit: 64 * 1024})
if err != nil {
return nil, "", fmt.Errorf("%w: reading policy: %s", ErrPolicySyntax, err)
}
policyText = string(buf)
policy, err = ParsePolicy(policyText)
if err != nil {
return nil, policyText, fmt.Errorf("parsing policy: %w", err)
}
return policy, policyText, nil
}
// Get looks up the MTA-STS DNS record and fetches the policy.
//
// Errors can be those returned by LookupRecord and FetchPolicy.
//
// If a valid policy cannot be retrieved, a sender must treat the domain as not
// implementing MTA-STS. If a sender has a non-expired cached policy, that policy
// would still apply.
//
// If a record was retrieved, but a policy could not be retrieved/parsed, the
// record is still returned.
//
// Also see Get in package mtastsdb.
func Get(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (record *Record, policy *Policy, policyText string, err error) {
log := mlog.New("mtasts", elog)
2023-01-30 16:27:06 +03:00
start := time.Now()
result := "lookuperror"
defer func() {
MetricGet.ObserveLabels(float64(time.Since(start))/float64(time.Second), result)
log.Debugx("mtasts get result", err,
slog.Any("domain", domain),
slog.Any("record", record),
slog.Any("policy", policy),
slog.Duration("duration", time.Since(start)))
2023-01-30 16:27:06 +03:00
}()
record, _, err = LookupRecord(ctx, log.Logger, resolver, domain)
2023-01-30 16:27:06 +03:00
if err != nil {
implement outgoing tls reports we were already accepting, processing and displaying incoming tls reports. now we start tracking TLS connection and security-policy-related errors for outgoing message deliveries as well. we send reports once a day, to the reporting addresses specified in TLSRPT records (rua) of a policy domain. these reports are about MTA-STS policies and/or DANE policies, and about STARTTLS-related failures. sending reports is enabled by default, but can be disabled through setting NoOutgoingTLSReports in mox.conf. only at the end of the implementation process came the realization that the TLSRPT policy domain for DANE (MX) hosts are separate from the TLSRPT policy for the recipient domain, and that MTA-STS and DANE TLS/policy results are typically delivered in separate reports. so MX hosts need their own TLSRPT policies. config for the per-host TLSRPT policy should be added to mox.conf for existing installs, in field HostTLSRPT. it is automatically configured by quickstart for new installs. with a HostTLSRPT config, the "dns records" and "dns check" admin pages now suggest the per-host TLSRPT record. by creating that record, you're requesting TLS reports about your MX host. gathering all the TLS/policy results is somewhat tricky. the tentacles go throughout the code. the positive result is that the TLS/policy-related code had to be cleaned up a bit. for example, the smtpclient TLS modes now reflect reality better, with independent settings about whether PKIX and/or DANE verification has to be done, and/or whether verification errors have to be ignored (e.g. for tls-required: no header). also, cached mtasts policies of mode "none" are now cleaned up once the MTA-STS DNS record goes away.
2023-11-09 19:40:46 +03:00
return nil, nil, "", err
2023-01-30 16:27:06 +03:00
}
result = "fetcherror"
policy, policyText, err = FetchPolicy(ctx, log.Logger, domain)
2023-01-30 16:27:06 +03:00
if err != nil {
implement outgoing tls reports we were already accepting, processing and displaying incoming tls reports. now we start tracking TLS connection and security-policy-related errors for outgoing message deliveries as well. we send reports once a day, to the reporting addresses specified in TLSRPT records (rua) of a policy domain. these reports are about MTA-STS policies and/or DANE policies, and about STARTTLS-related failures. sending reports is enabled by default, but can be disabled through setting NoOutgoingTLSReports in mox.conf. only at the end of the implementation process came the realization that the TLSRPT policy domain for DANE (MX) hosts are separate from the TLSRPT policy for the recipient domain, and that MTA-STS and DANE TLS/policy results are typically delivered in separate reports. so MX hosts need their own TLSRPT policies. config for the per-host TLSRPT policy should be added to mox.conf for existing installs, in field HostTLSRPT. it is automatically configured by quickstart for new installs. with a HostTLSRPT config, the "dns records" and "dns check" admin pages now suggest the per-host TLSRPT record. by creating that record, you're requesting TLS reports about your MX host. gathering all the TLS/policy results is somewhat tricky. the tentacles go throughout the code. the positive result is that the TLS/policy-related code had to be cleaned up a bit. for example, the smtpclient TLS modes now reflect reality better, with independent settings about whether PKIX and/or DANE verification has to be done, and/or whether verification errors have to be ignored (e.g. for tls-required: no header). also, cached mtasts policies of mode "none" are now cleaned up once the MTA-STS DNS record goes away.
2023-11-09 19:40:46 +03:00
return record, nil, "", err
2023-01-30 16:27:06 +03:00
}
result = "ok"
implement outgoing tls reports we were already accepting, processing and displaying incoming tls reports. now we start tracking TLS connection and security-policy-related errors for outgoing message deliveries as well. we send reports once a day, to the reporting addresses specified in TLSRPT records (rua) of a policy domain. these reports are about MTA-STS policies and/or DANE policies, and about STARTTLS-related failures. sending reports is enabled by default, but can be disabled through setting NoOutgoingTLSReports in mox.conf. only at the end of the implementation process came the realization that the TLSRPT policy domain for DANE (MX) hosts are separate from the TLSRPT policy for the recipient domain, and that MTA-STS and DANE TLS/policy results are typically delivered in separate reports. so MX hosts need their own TLSRPT policies. config for the per-host TLSRPT policy should be added to mox.conf for existing installs, in field HostTLSRPT. it is automatically configured by quickstart for new installs. with a HostTLSRPT config, the "dns records" and "dns check" admin pages now suggest the per-host TLSRPT record. by creating that record, you're requesting TLS reports about your MX host. gathering all the TLS/policy results is somewhat tricky. the tentacles go throughout the code. the positive result is that the TLS/policy-related code had to be cleaned up a bit. for example, the smtpclient TLS modes now reflect reality better, with independent settings about whether PKIX and/or DANE verification has to be done, and/or whether verification errors have to be ignored (e.g. for tls-required: no header). also, cached mtasts policies of mode "none" are now cleaned up once the MTA-STS DNS record goes away.
2023-11-09 19:40:46 +03:00
return record, policy, policyText, nil
2023-01-30 16:27:06 +03:00
}