internal/mailin/dkim.go

202 lines · 6007 bytes

  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}