internal/mailin/dkim.go
202 lines · 6007 bytes
13 symbols in this file
1package mailin
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "net"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/emersion/go-msgauth/dkim"
15)
16
17const (
18 // maxSignatures is how many DKIM-Signature fields are checked; any
19 // after them are ignored.
20 maxSignatures = 5
21 // dnsTimeout bounds one selector key lookup.
22 dnsTimeout = 5 * time.Second
23 // futureSkew is how far ahead of this clock a signature's t= may be.
24 futureSkew = 15 * time.Minute
25 // keyCacheTTL is how long a key record is reused. The resolver API
26 // does not report the record's TTL, so this is short: a revoked key
27 // is still honoured for up to this long.
28 keyCacheTTL = 15 * time.Minute
29 keyCacheSize = 256
30)
31
32// LookupTXT returns the TXT records at name, one string per record.
33type LookupTXT func(ctx context.Context, name string) ([]string, error)
34
35type cachedKey struct {
36 txts []string
37 expires time.Time
38}
39
40// keyCache holds selector key records that resolved, for keyCacheTTL,
41// at most keyCacheSize of them. Failures are not cached.
42type keyCache struct {
43 mu sync.Mutex
44 m map[string]cachedKey
45}
46
47func (c *keyCache) get(name string, now time.Time) ([]string, bool) {
48 c.mu.Lock()
49 defer c.mu.Unlock()
50 e, ok := c.m[name]
51 if !ok || now.After(e.expires) {
52 return nil, false
53 }
54 return e.txts, true
55}
56
57func (c *keyCache) put(name string, txts []string, now time.Time) {
58 c.mu.Lock()
59 defer c.mu.Unlock()
60 if c.m == nil {
61 c.m = map[string]cachedKey{}
62 }
63 if len(c.m) >= keyCacheSize {
64 for k, e := range c.m {
65 if now.After(e.expires) {
66 delete(c.m, k)
67 }
68 }
69 for k := range c.m {
70 if len(c.m) < keyCacheSize {
71 break
72 }
73 delete(c.m, k)
74 }
75 }
76 c.m[name] = cachedKey{txts: txts, expires: now.Add(keyCacheTTL)}
77}
78
79// lookupKey is the verifier's TXT lookup: cached, bounded by dnsTimeout.
80// The dkim package tells a temporary failure from a permanent one by
81// the error implementing net.Error with Temporary true, so every error
82// returned is a *net.DNSError, and one that is not a plain "no such
83// record" is marked temporary.
84func (p *Processor) lookupKey(name string) ([]string, error) {
85 now := p.now()
86 if txts, ok := p.keys.get(name, now); ok {
87 return txts, nil
88 }
89 lookup := p.LookupTXT
90 if lookup == nil {
91 lookup = net.DefaultResolver.LookupTXT
92 }
93 ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
94 defer cancel()
95 txts, err := lookup(ctx, name)
96 if err != nil {
97 var de *net.DNSError
98 if errors.As(err, &de) && de.IsNotFound {
99 return nil, &net.DNSError{Err: "no such record", Name: name, IsNotFound: true}
100 }
101 return nil, &net.DNSError{Err: "lookup failed", Name: name, IsTemporary: true}
102 }
103 p.keys.put(name, txts, now)
104 return txts, nil
105}
106
107// dkimVerified checks the DKIM signatures on raw, the message as it
108// was fetched. A signature passes when it is one of the first
109// maxSignatures, verifies, has a d= in relaxed alignment with the From
110// domain, has not expired, is not dated in the future, and its h=
111// covers From, tokenField (the To or Cc the reply address was read
112// from), Content-Type, and Message-ID when the message has one. An
113// unsigned Content-Transfer-Encoding is accepted only when it is an
114// identity encoding (7bit, 8bit, binary), which does not change what
115// the body decodes to; mail clients commonly leave it out of h=. It returns an id for each passing
116// signature (a hash of its b=), or the refusal's reason. retry is true
117// when none passed and one could not be checked because its key lookup
118// failed for a reason that may pass.
119func (p *Processor) dkimVerified(raw []byte, rh rawHeader, from, tokenField string) (ids []string, reason string, retry bool) {
120 fromDomain := ""
121 if i := strings.LastIndex(from, "@"); i >= 0 {
122 fromDomain = strings.ToLower(from[i+1:])
123 }
124 if fromDomain == "" {
125 return nil, "no From domain", false
126 }
127 need := []string{"from", tokenField, "content-type"}
128 if rh.count["message-id"] > 0 {
129 need = append(need, "message-id")
130 }
131 cteOK := true
132 switch rh.cte {
133 case "7bit", "8bit", "binary":
134 default:
135 cteOK = rh.count["content-transfer-encoding"] == 0
136 }
137 verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
138 LookupTXT: p.lookupKey, MaxVerifications: maxSignatures})
139 if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) {
140 return nil, "DKIM: unreadable message", false
141 }
142 if len(verifs) == 0 {
143 return nil, "no DKIM-Signature", false
144 }
145 now := p.now()
146 var fails []string
147 for i, v := range verifs {
148 d := strings.ToLower(v.Domain)
149 why := ""
150 switch {
151 case dkim.IsTempFail(v.Err):
152 retry = true
153 why = "key lookup failed"
154 case v.Err != nil:
155 why = strings.TrimPrefix(v.Err.Error(), "dkim: ")
156 case !v.Expiration.IsZero() && now.After(v.Expiration):
157 why = "signature has expired"
158 case !v.Time.IsZero() && v.Time.After(now.Add(futureSkew)):
159 why = "signature dated in the future"
160 case !aligned(d, fromDomain):
161 why = "d= not aligned with the From domain"
162 default:
163 if n := unsigned(v.HeaderKeys, need); n != "" {
164 why = n + " not in h="
165 } else if !cteOK && unsigned(v.HeaderKeys, []string{"content-transfer-encoding"}) != "" {
166 why = "content-transfer-encoding not in h= and not 7bit, 8bit or binary"
167 } else if i < len(rh.dkimB) && rh.dkimB[i] != "" {
168 sum := sha256.Sum256([]byte(rh.dkimB[i]))
169 ids = append(ids, "dkim:"+hex.EncodeToString(sum[:]))
170 continue
171 } else {
172 why = "no b= tag"
173 }
174 }
175 if len(d) > 100 {
176 d = d[:100]
177 }
178 fails = append(fails, "d="+d+": "+why)
179 }
180 if len(ids) > 0 {
181 return ids, "", false
182 }
183 return nil, "DKIM: no passing signature aligned with the From domain (" + strings.Join(fails, "; ") + ")", retry
184}
185
186// unsigned returns the first of need that keys (a signature's h=) does
187// not list, or "".
188func unsigned(keys, need []string) string {
189 for _, n := range need {
190 found := false
191 for _, k := range keys {
192 if strings.EqualFold(k, n) {
193 found = true
194 break
195 }
196 }
197 if !found {
198 return n
199 }
200 }
201 return ""
202}