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