1// Package dmarc implements DMARC (Domain-based Message Authentication,
2// Reporting, and Conformance; RFC 7489) verification.
3//
4// DMARC is a mechanism for verifying ("authenticating") the address in the "From"
5// message header, since users will look at that header to identify the sender of a
6// message. DMARC compares the "From"-(sub)domain against the SPF and/or
7// DKIM-validated domains, based on the DMARC policy that a domain has published in
8// DNS as TXT record under "_dmarc.<domain>". A DMARC policy can also ask for
9// feedback about evaluations by other email servers, for monitoring/debugging
10// problems with email delivery.
11package dmarc
12
13import (
14 "context"
15 "errors"
16 "fmt"
17 mathrand "math/rand"
18 "time"
19
20 "github.com/prometheus/client_golang/prometheus"
21 "github.com/prometheus/client_golang/prometheus/promauto"
22
23 "github.com/mjl-/mox/dkim"
24 "github.com/mjl-/mox/dns"
25 "github.com/mjl-/mox/mlog"
26 "github.com/mjl-/mox/publicsuffix"
27 "github.com/mjl-/mox/spf"
28)
29
30var xlog = mlog.New("dmarc")
31
32var (
33 metricDMARCVerify = promauto.NewHistogramVec(
34 prometheus.HistogramOpts{
35 Name: "mox_dmarc_verify_duration_seconds",
36 Help: "DMARC verify, including lookup, duration and result.",
37 Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
38 },
39 []string{
40 "status",
41 "reject", // yes/no
42 "use", // yes/no, if policy is used after random selection
43 },
44 )
45)
46
47// link errata:
48// ../rfc/7489-eid5440 ../rfc/7489:1585
49
50// Lookup errors.
51var (
52 ErrNoRecord = errors.New("dmarc: no dmarc dns record")
53 ErrMultipleRecords = errors.New("dmarc: multiple dmarc dns records") // Must also be treated as if domain does not implement DMARC.
54 ErrDNS = errors.New("dmarc: dns lookup")
55 ErrSyntax = errors.New("dmarc: malformed dmarc dns record")
56)
57
58// Status is the result of DMARC policy evaluation, for use in an Authentication-Results header.
59type Status string
60
61// ../rfc/7489:2339
62
63const (
64 StatusNone Status = "none" // No DMARC TXT DNS record found.
65 StatusPass Status = "pass" // SPF and/or DKIM pass with identifier alignment.
66 StatusFail Status = "fail" // Either both SPF and DKIM failed or identifier did not align with a pass.
67 StatusTemperror Status = "temperror" // Typically a DNS lookup. A later attempt may results in a conclusion.
68 StatusPermerror Status = "permerror" // Typically a malformed DMARC DNS record.
69)
70
71// Result is a DMARC policy evaluation.
72type Result struct {
73 // Whether to reject the message based on policies. If false, the message should
74 // not necessarily be accepted, e.g. due to reputation or content-based analysis.
75 Reject bool
76 // Result of DMARC validation. A message can fail validation, but still
77 // not be rejected, e.g. if the policy is "none".
78 Status Status
79 AlignedSPFPass bool
80 AlignedDKIMPass bool
81 // Domain with the DMARC DNS record. May be the organizational domain instead of
82 // the domain in the From-header.
83 Domain dns.Domain
84 // Parsed DMARC record.
85 Record *Record
86 // Whether DMARC DNS response was DNSSEC-signed, regardless of whether SPF/DKIM records were DNSSEC-signed.
87 RecordAuthentic bool
88 // Details about possible error condition, e.g. when parsing the DMARC record failed.
89 Err error
90}
91
92// Lookup looks up the DMARC TXT record at "_dmarc.<domain>" for the domain in the
93// "From"-header of a message.
94//
95// If no DMARC record is found for the "From"-domain, another lookup is done at
96// the organizational domain of the domain (if different). The organizational
97// domain is determined using the public suffix list. E.g. for
98// "sub.example.com", the organizational domain is "example.com". The returned
99// domain is the domain with the DMARC record.
100//
101// rauthentic indicates if the DNS results were DNSSEC-verified.
102func Lookup(ctx context.Context, resolver dns.Resolver, from dns.Domain) (status Status, domain dns.Domain, record *Record, txt string, rauthentic bool, rerr error) {
103 log := xlog.WithContext(ctx)
104 start := time.Now()
105 defer func() {
106 log.Debugx("dmarc lookup result", rerr, mlog.Field("fromdomain", from), mlog.Field("status", status), mlog.Field("domain", domain), mlog.Field("record", record), mlog.Field("duration", time.Since(start)))
107 }()
108
109 // ../rfc/7489:859 ../rfc/7489:1370
110 domain = from
111 status, record, txt, authentic, err := lookupRecord(ctx, resolver, domain)
112 if status != StatusNone {
113 return status, domain, record, txt, authentic, err
114 }
115 if record == nil {
116 // ../rfc/7489:761 ../rfc/7489:1377
117 domain = publicsuffix.Lookup(ctx, from)
118 if domain == from {
119 return StatusNone, domain, nil, txt, authentic, err
120 }
121
122 var xauth bool
123 status, record, txt, xauth, err = lookupRecord(ctx, resolver, domain)
124 authentic = authentic && xauth
125 }
126 return status, domain, record, txt, authentic, err
127}
128
129func lookupRecord(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (Status, *Record, string, bool, error) {
130 name := "_dmarc." + domain.ASCII + "."
131 txts, result, err := dns.WithPackage(resolver, "dmarc").LookupTXT(ctx, name)
132 if err != nil && !dns.IsNotFound(err) {
133 return StatusTemperror, nil, "", result.Authentic, fmt.Errorf("%w: %s", ErrDNS, err)
134 }
135 var record *Record
136 var text string
137 var rerr error = ErrNoRecord
138 for _, txt := range txts {
139 r, isdmarc, err := ParseRecord(txt)
140 if !isdmarc {
141 // ../rfc/7489:1374
142 continue
143 } else if err != nil {
144 return StatusPermerror, nil, text, result.Authentic, fmt.Errorf("%w: %s", ErrSyntax, err)
145 }
146 if record != nil {
147 // ../rfc/7489:1388
148 return StatusNone, nil, "", result.Authentic, ErrMultipleRecords
149 }
150 text = txt
151 record = r
152 rerr = nil
153 }
154 return StatusNone, record, text, result.Authentic, rerr
155}
156
157func lookupReportsRecord(ctx context.Context, resolver dns.Resolver, dmarcDomain, extDestDomain dns.Domain) (Status, []*Record, []string, bool, error) {
158 // ../rfc/7489:1566
159 name := dmarcDomain.ASCII + "._report._dmarc." + extDestDomain.ASCII + "."
160 txts, result, err := dns.WithPackage(resolver, "dmarc").LookupTXT(ctx, name)
161 if err != nil && !dns.IsNotFound(err) {
162 return StatusTemperror, nil, nil, result.Authentic, fmt.Errorf("%w: %s", ErrDNS, err)
163 }
164 var records []*Record
165 var texts []string
166 var rerr error = ErrNoRecord
167 for _, txt := range txts {
168 r, isdmarc, err := ParseRecordNoRequired(txt)
169 // Examples in the RFC use "v=DMARC1", even though it isn't a valid DMARC record.
170 // Accept the specific example.
171 // ../rfc/7489-eid5440
172 if !isdmarc && txt == "v=DMARC1" {
173 xr := DefaultRecord
174 r, isdmarc, err = &xr, true, nil
175 }
176 if !isdmarc {
177 // ../rfc/7489:1586
178 continue
179 }
180 texts = append(texts, txt)
181 records = append(records, r)
182 if err != nil {
183 return StatusPermerror, records, texts, result.Authentic, fmt.Errorf("%w: %s", ErrSyntax, err)
184 }
185 // Multiple records are allowed for the _report record, unlike for policies. ../rfc/7489:1593
186 rerr = nil
187 }
188 return StatusNone, records, texts, result.Authentic, rerr
189}
190
191// LookupExternalReportsAccepted returns whether the extDestDomain has opted in
192// to receiving dmarc reports for dmarcDomain (where the dmarc record was found),
193// through a "._report._dmarc." DNS TXT DMARC record.
194//
195// accepts is true if the external domain has opted in.
196// If a temporary error occurred, the returned status is StatusTemperror, and a
197// later retry may give an authoritative result.
198// The returned error is ErrNoRecord if no opt-in DNS record exists, which is
199// not a failure condition.
200//
201// The normally invalid "v=DMARC1" record is accepted since it is used as
202// example in RFC 7489.
203//
204// authentic indicates if the DNS results were DNSSEC-verified.
205func LookupExternalReportsAccepted(ctx context.Context, resolver dns.Resolver, dmarcDomain dns.Domain, extDestDomain dns.Domain) (accepts bool, status Status, records []*Record, txts []string, authentic bool, rerr error) {
206 log := xlog.WithContext(ctx)
207 start := time.Now()
208 defer func() {
209 log.Debugx("dmarc externalreports result", rerr, mlog.Field("accepts", accepts), mlog.Field("dmarcdomain", dmarcDomain), mlog.Field("extdestdomain", extDestDomain), mlog.Field("records", records), mlog.Field("duration", time.Since(start)))
210 }()
211
212 status, records, txts, authentic, rerr = lookupReportsRecord(ctx, resolver, dmarcDomain, extDestDomain)
213 accepts = rerr == nil
214 return accepts, status, records, txts, authentic, rerr
215}
216
217// Verify evaluates the DMARC policy for the domain in the From-header of a
218// message given the DKIM and SPF evaluation results.
219//
220// applyRandomPercentage determines whether the records "pct" is honored. This
221// field specifies the percentage of messages the DMARC policy is applied to. It
222// is used for slow rollout of DMARC policies and should be honored during normal
223// email processing
224//
225// Verify always returns the result of verifying the DMARC policy
226// against the message (for inclusion in Authentication-Result headers).
227//
228// useResult indicates if the result should be applied in a policy decision.
229func Verify(ctx context.Context, resolver dns.Resolver, from dns.Domain, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, applyRandomPercentage bool) (useResult bool, result Result) {
230 log := xlog.WithContext(ctx)
231 start := time.Now()
232 defer func() {
233 use := "no"
234 if useResult {
235 use = "yes"
236 }
237 reject := "no"
238 if result.Reject {
239 reject = "yes"
240 }
241 metricDMARCVerify.WithLabelValues(string(result.Status), reject, use).Observe(float64(time.Since(start)) / float64(time.Second))
242 log.Debugx("dmarc verify result", result.Err, mlog.Field("fromdomain", from), mlog.Field("dkimresults", dkimResults), mlog.Field("spfresult", spfResult), mlog.Field("status", result.Status), mlog.Field("reject", result.Reject), mlog.Field("use", useResult), mlog.Field("duration", time.Since(start)))
243 }()
244
245 status, recordDomain, record, _, authentic, err := Lookup(ctx, resolver, from)
246 if record == nil {
247 return false, Result{false, status, false, false, recordDomain, record, authentic, err}
248 }
249 result.Domain = recordDomain
250 result.Record = record
251 result.RecordAuthentic = authentic
252
253 // Record can request sampling of messages to apply policy.
254 // See ../rfc/7489:1432
255 useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand.Intn(100) < record.Percentage
256
257 // We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
258 // from reject to quarantine if this message was sampled out.
259 // ../rfc/7489:1446 ../rfc/7489:1024
260 if recordDomain != from && record.SubdomainPolicy != PolicyEmpty {
261 result.Reject = record.SubdomainPolicy != PolicyNone
262 } else {
263 result.Reject = record.Policy != PolicyNone
264 }
265
266 // ../rfc/7489:1338
267 result.Status = StatusFail
268 if spfResult == spf.StatusTemperror {
269 result.Status = StatusTemperror
270 result.Reject = false
271 }
272
273 // Below we can do a bunch of publicsuffix lookups. Cache the results, mostly to
274 // reduce log pollution.
275 pubsuffixes := map[dns.Domain]dns.Domain{}
276 pubsuffix := func(name dns.Domain) dns.Domain {
277 if r, ok := pubsuffixes[name]; ok {
278 return r
279 }
280 r := publicsuffix.Lookup(ctx, name)
281 pubsuffixes[name] = r
282 return r
283 }
284
285 // ../rfc/7489:1319
286 // ../rfc/7489:544
287 if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == from || result.Record.ASPF == "r" && pubsuffix(from) == pubsuffix(*spfIdentity)) {
288 result.AlignedSPFPass = true
289 }
290
291 for _, dkimResult := range dkimResults {
292 if dkimResult.Status == dkim.StatusTemperror {
293 result.Reject = false
294 result.Status = StatusTemperror
295 continue
296 }
297 // ../rfc/7489:511
298 if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == from || result.Record.ADKIM == "r" && pubsuffix(from) == pubsuffix(dkimResult.Sig.Domain)) {
299 // ../rfc/7489:535
300 result.AlignedDKIMPass = true
301 break
302 }
303 }
304
305 if result.AlignedSPFPass || result.AlignedDKIMPass {
306 result.Reject = false
307 result.Status = StatusPass
308 }
309 return
310}
311