1// Package mtasts implements MTA-STS (SMTP MTA Strict Transport Security, RFC 8461)
2// which allows a domain to specify SMTP TLS requirements.
3//
4// SMTP for message delivery to a remote mail server always starts out unencrypted,
5// in plain text. STARTTLS allows upgrading the connection to TLS, but is optional
6// and by default mail servers will fall back to plain text communication if
7// STARTTLS does not work (which can be sabotaged by DNS manipulation or SMTP
8// connection manipulation). MTA-STS can specify a policy for requiring STARTTLS to
9// be used for message delivery. A TXT DNS record at "_mta-sts.<domain>" specifies
10// the version of the policy, and
11// "https://mta-sts.<domain>/.well-known/mta-sts.txt" serves the policy.
12package mtasts
13
14import (
15 "context"
16 "errors"
17 "fmt"
18 "io"
19 "net/http"
20 "strings"
21 "time"
22
23 "golang.org/x/exp/slog"
24
25 "github.com/mjl-/adns"
26
27 "github.com/mjl-/mox/dns"
28 "github.com/mjl-/mox/mlog"
29 "github.com/mjl-/mox/moxio"
30 "github.com/mjl-/mox/stub"
31)
32
33var (
34 MetricGet stub.HistogramVec = stub.HistogramVecIgnore{}
35 HTTPClientObserve func(ctx context.Context, log *slog.Logger, pkg, method string, statusCode int, err error, start time.Time) = stub.HTTPClientObserveIgnore
36)
37
38// Pair is an extension key/value pair in a MTA-STS DNS record or policy.
39type Pair struct {
40 Key string
41 Value string
42}
43
44// Record is an MTA-STS DNS record, served under "_mta-sts.<domain>" as a TXT
45// record.
46//
47// Example:
48//
49// v=STSv1; id=20160831085700Z
50type Record struct {
51 Version string // "STSv1", for "v=". Required.
52 ID string // Record version, for "id=". Required.
53 Extensions []Pair // Optional extensions.
54}
55
56// String returns a textual version of the MTA-STS record for use as DNS TXT
57// record.
58func (r Record) String() string {
59 b := &strings.Builder{}
60 fmt.Fprint(b, "v="+r.Version)
61 fmt.Fprint(b, "; id="+r.ID)
62 for _, p := range r.Extensions {
63 fmt.Fprint(b, "; "+p.Key+"="+p.Value)
64 }
65 return b.String()
66}
67
68// Mode indicates how the policy should be interpreted.
69type Mode string
70
71// ../rfc/8461:655
72
73const (
74 ModeEnforce Mode = "enforce" // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made.
75 ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT.
76 ModeNone Mode = "none" // In case MTA-STS is not or no longer implemented.
77)
78
79// STSMX is an allowlisted MX host name/pattern.
80// todo: find a way to name this just STSMX without getting duplicate names for "MX" in the sherpa api.
81type STSMX struct {
82 // "*." wildcard, e.g. if a subdomain matches. A wildcard must match exactly one
83 // label. *.example.com matches mail.example.com, but not example.com, and not
84 // foor.bar.example.com.
85 Wildcard bool
86
87 Domain dns.Domain
88}
89
90// LogString returns a loggable string representing the host, with both unicode
91// and ascii version for IDNA domains.
92func (s STSMX) LogString() string {
93 pre := ""
94 if s.Wildcard {
95 pre = "*."
96 }
97 if s.Domain.Unicode == "" {
98 return pre + s.Domain.ASCII
99 }
100 return pre + s.Domain.Unicode + "/" + pre + s.Domain.ASCII
101}
102
103// Policy is an MTA-STS policy as served at "https://mta-sts.<domain>/.well-known/mta-sts.txt".
104type Policy struct {
105 Version string // "STSv1"
106 Mode Mode
107 MX []STSMX
108 MaxAgeSeconds int // How long this policy can be cached. Suggested values are in weeks or more.
109 Extensions []Pair
110}
111
112// String returns a textual representation for serving at the well-known URL.
113func (p Policy) String() string {
114 b := &strings.Builder{}
115 line := func(k, v string) {
116 fmt.Fprint(b, k+": "+v+"\n")
117 }
118 line("version", p.Version)
119 line("mode", string(p.Mode))
120 line("max_age", fmt.Sprintf("%d", p.MaxAgeSeconds))
121 for _, mx := range p.MX {
122 s := mx.Domain.Name()
123 if mx.Wildcard {
124 s = "*." + s
125 }
126 line("mx", s)
127 }
128 return b.String()
129}
130
131// Matches returns whether the hostname matches the mx list in the policy.
132func (p *Policy) Matches(host dns.Domain) bool {
133 // ../rfc/8461:636
134 for _, mx := range p.MX {
135 if mx.Wildcard {
136 v := strings.SplitN(host.ASCII, ".", 2)
137 if len(v) == 2 && v[1] == mx.Domain.ASCII {
138 return true
139 }
140 } else if host == mx.Domain {
141 return true
142 }
143 }
144 return false
145}
146
147// TLSReportFailureReason returns a concise error for known error types, or an
148// empty string. For use in TLSRPT.
149func TLSReportFailureReason(err error) string {
150 // If this is a DNSSEC authentication error, we'll collect it for TLS reporting.
151 // We can also use this reason for STS, not only DANE. ../rfc/8460:580
152 var errCode adns.ErrorCode
153 if errors.As(err, &errCode) && errCode.IsAuthentication() {
154 return fmt.Sprintf("dns-extended-error-%d-%s", errCode, strings.ReplaceAll(errCode.String(), " ", "-"))
155 }
156
157 for _, e := range mtastsErrors {
158 if errors.Is(err, e) {
159 s := strings.TrimPrefix(e.Error(), "mtasts: ")
160 return strings.ReplaceAll(s, " ", "-")
161 }
162 }
163 return ""
164}
165
166var mtastsErrors = []error{
167 ErrNoRecord, ErrMultipleRecords, ErrDNS, ErrRecordSyntax, // Lookup
168 ErrNoPolicy, ErrPolicyFetch, ErrPolicySyntax, // Fetch
169}
170
171// Lookup errors.
172var (
173 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.
174 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.
175 ErrDNS = errors.New("mtasts: dns lookup") // For temporary DNS errors.
176 ErrRecordSyntax = errors.New("mtasts: record syntax error")
177)
178
179// LookupRecord looks up the MTA-STS TXT DNS record at "_mta-sts.<domain>",
180// following CNAME records, and returns the parsed MTA-STS record and the DNS TXT
181// record.
182func LookupRecord(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rrecord *Record, rtxt string, rerr error) {
183 log := mlog.New("mtasts", elog)
184 start := time.Now()
185 defer func() {
186 log.Debugx("mtasts lookup result", rerr,
187 slog.Any("domain", domain),
188 slog.Any("record", rrecord),
189 slog.Duration("duration", time.Since(start)))
190 }()
191
192 // ../rfc/8461:289
193 // ../rfc/8461:351
194 // We lookup the txt record, but must follow CNAME records when the TXT does not
195 // exist. LookupTXT follows CNAMEs.
196 name := "_mta-sts." + domain.ASCII + "."
197 var txts []string
198 txts, _, err := dns.WithPackage(resolver, "mtasts").LookupTXT(ctx, name)
199 if dns.IsNotFound(err) {
200 return nil, "", ErrNoRecord
201 } else if err != nil {
202 return nil, "", fmt.Errorf("%w: %s", ErrDNS, err)
203 }
204
205 var text string
206 var record *Record
207 for _, txt := range txts {
208 r, ismtasts, err := ParseRecord(txt)
209 if !ismtasts {
210 // ../rfc/8461:331 says we should essentially treat a record starting with e.g.
211 // "v=STSv1 ;" (note the space) as a non-STS record too in case of multiple TXT
212 // records. We treat it as an STS record that is invalid, which is possibly more
213 // reasonable.
214 continue
215 }
216 if err != nil {
217 return nil, "", err
218 }
219 if record != nil {
220 return nil, "", ErrMultipleRecords
221 }
222 record = r
223 text = txt
224 }
225 if record == nil {
226 return nil, "", ErrNoRecord
227 }
228 return record, text, nil
229}
230
231// Policy fetch errors.
232var (
233 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".
234 ErrPolicyFetch = errors.New("mtasts: cannot fetch policy") // E.g. for HTTP request errors.
235 ErrPolicySyntax = errors.New("mtasts: policy syntax error")
236)
237
238// HTTPClient is used by FetchPolicy for HTTP requests.
239var HTTPClient = &http.Client{
240 CheckRedirect: func(req *http.Request, via []*http.Request) error {
241 return fmt.Errorf("redirect not allowed for MTA-STS policies") // ../rfc/8461:549
242 },
243}
244
245// FetchPolicy fetches a new policy for the domain, at
246// https://mta-sts.<domain>/.well-known/mta-sts.txt.
247//
248// FetchPolicy returns the parsed policy and the literal policy text as fetched
249// from the server. If a policy was fetched but could not be parsed, the policyText
250// return value will be set.
251//
252// Policies longer than 64KB result in a syntax error.
253//
254// If an error is returned, callers should back off for 5 minutes until the next
255// attempt.
256func FetchPolicy(ctx context.Context, elog *slog.Logger, domain dns.Domain) (policy *Policy, policyText string, rerr error) {
257 log := mlog.New("mtasts", elog)
258 start := time.Now()
259 defer func() {
260 log.Debugx("mtasts fetch policy result", rerr,
261 slog.Any("domain", domain),
262 slog.Any("policy", policy),
263 slog.String("policytext", policyText),
264 slog.Duration("duration", time.Since(start)))
265 }()
266
267 // Timeout of 1 minute. ../rfc/8461:569
268 ctx, cancel := context.WithTimeout(ctx, time.Minute)
269 defer cancel()
270
271 // TLS requirements are what the Go standard library checks: trusted, non-expired,
272 // hostname verified against DNS-ID supporting wildcard. ../rfc/8461:524
273 url := "https://mta-sts." + domain.Name() + "/.well-known/mta-sts.txt"
274 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
275 if err != nil {
276 return nil, "", fmt.Errorf("%w: http request: %s", ErrPolicyFetch, err)
277 }
278 // We are not likely to reuse a connection: we cache policies and negative DNS
279 // responses. So don't keep connections open unnecessarily.
280 req.Close = true
281
282 resp, err := HTTPClient.Do(req)
283 if dns.IsNotFound(err) {
284 return nil, "", ErrNoPolicy
285 }
286 if err != nil {
287 // We pass along underlying TLS certificate errors.
288 return nil, "", fmt.Errorf("%w: http get: %w", ErrPolicyFetch, err)
289 }
290 HTTPClientObserve(ctx, log.Logger, "mtasts", req.Method, resp.StatusCode, err, start)
291 defer resp.Body.Close()
292 if resp.StatusCode == http.StatusNotFound {
293 return nil, "", ErrNoPolicy
294 }
295 if resp.StatusCode != http.StatusOK {
296 // ../rfc/8461:548
297 return nil, "", fmt.Errorf("%w: http status %s while status 200 is required", ErrPolicyFetch, resp.Status)
298 }
299
300 // We don't look at Content-Type and charset. It should be ASCII or UTF-8, we'll
301 // just always whatever is sent as UTF-8. ../rfc/8461:367
302
303 // ../rfc/8461:570
304 buf, err := io.ReadAll(&moxio.LimitReader{R: resp.Body, Limit: 64 * 1024})
305 if err != nil {
306 return nil, "", fmt.Errorf("%w: reading policy: %s", ErrPolicySyntax, err)
307 }
308 policyText = string(buf)
309 policy, err = ParsePolicy(policyText)
310 if err != nil {
311 return nil, policyText, fmt.Errorf("parsing policy: %w", err)
312 }
313 return policy, policyText, nil
314}
315
316// Get looks up the MTA-STS DNS record and fetches the policy.
317//
318// Errors can be those returned by LookupRecord and FetchPolicy.
319//
320// If a valid policy cannot be retrieved, a sender must treat the domain as not
321// implementing MTA-STS. If a sender has a non-expired cached policy, that policy
322// would still apply.
323//
324// If a record was retrieved, but a policy could not be retrieved/parsed, the
325// record is still returned.
326//
327// Also see Get in package mtastsdb.
328func Get(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (record *Record, policy *Policy, policyText string, err error) {
329 log := mlog.New("mtasts", elog)
330 start := time.Now()
331 result := "lookuperror"
332 defer func() {
333 MetricGet.ObserveLabels(float64(time.Since(start))/float64(time.Second), result)
334 log.Debugx("mtasts get result", err,
335 slog.Any("domain", domain),
336 slog.Any("record", record),
337 slog.Any("policy", policy),
338 slog.Duration("duration", time.Since(start)))
339 }()
340
341 record, _, err = LookupRecord(ctx, log.Logger, resolver, domain)
342 if err != nil {
343 return nil, nil, "", err
344 }
345
346 result = "fetcherror"
347 policy, policyText, err = FetchPolicy(ctx, log.Logger, domain)
348 if err != nil {
349 return record, nil, "", err
350 }
351
352 result = "ok"
353 return record, policy, policyText, nil
354}
355