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