internal/mailin/dkim_test.go

v1.43.0
gitbay/internal/mailin/dkim_test.go history · blame · raw

524 lines · 24217 bytes

  1package mailin
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto"
  7	"crypto/ed25519"
  8	"crypto/rsa"
  9	"crypto/x509"
 10	"encoding/base64"
 11	"encoding/pem"
 12	"errors"
 13	"math/big"
 14	"net"
 15	"strings"
 16	"testing"
 17	"time"
 18
 19	"github.com/emersion/go-msgauth/dkim"
 20
 21	"gitbay.org/gitbay/internal/mailreply"
 22)
 23
 24// Fixed test keys: RSA 2048 and Ed25519.
 25const rsaPEM = `-----BEGIN PRIVATE KEY-----
 26MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC6i925olmivu5U
 27iNMbgSLK4SEZNq4TIPQYPwJ3fHHFa+6V3jtD/2HllkZCrRlBAvra7H8q7TfoCj2K
 28lN7hLGkawZM0x9qhxn+AUKuTTL3XeRdl3HQejfuuQvhAQkBU53lF2v0mqzVAEz/k
 29cxcNT068yWeCT4GWyT4/A1yy1lfTzyZXrKNcdEDQ5ah8M47kaEYeeRAza19roV3F
 30StAggPG/ORC64JXQkwbIEOgNNUYp7hUUWen+kKd5qsuoxaYaWGJ/cEHeEFzsaZN2
 31fA//qFAt3jwfbQvnpPzp1c6txkiDnYJgHXp5gRcdDuZ8q85bmeqZf1OBCR2d7Ery
 32q1L1sFdRAgMBAAECggEADoc2DW8HbBVSmmLNjibQftxpp30KsZKvb/P4TTXz5lwx
 33iJp2IyWQikDZ1/eDL/z7bHFetgkjgX7KrDBL6116EgthW4r1DARZibS+qAoh/tX/
 34bH9uy7JjF38/tkFyoSol17rmXEyZKRRWtYQBF5hFmY5V8WAfx46EuoOYhJUM4gHt
 353hN6xqvGEjLTgb1xLf08+ntgTzPW3QSiE3A0b0nyRbu8t8PjP0DoABdORuI16hsi
 368V1wUoz1iTKGtV1a98moSxyWWC0udBp+tdhZJ+yun5zLqd/cALvXsl8Y7cIIuyLa
 37B/mLTkM1SX9swZ06tSa4vRd9fPQz0J5zbgo8Mf3FwQKBgQDH7SuSZ3YpTMhoAmz/
 38V6UVN4NNkYkHqNEB3bv0VRG4bPtLXJH9vyhchtwHQMsKTwbqgsFiUON2VwZ0/pfc
 390hDooWQHaoVJWRjfZQLD5K42QKQlcDowyvMYT046UViJOSQeTj4IbT0/2EehkHXA
 40qCoITU2I6zIfF1Te8yI1wFmdTwKBgQDu3f9rIZq+ihaLrrGMkagofWmVKUzFqVwK
 41ckcKXZJ0uQ/ns1er+SITVU3WdOrLsFE+zpE5CH8LG9FIoFhNcCwvzCXV5iACqIi8
 424PgkxNDYTo9kMW3yWuWiFcZpLvA7BOCnvW4gmmIofLe1OMBE9F5VquECibeoamPb
 43uQgzELnZXwKBgB24BbgXpRryjP/ZDHbQgnuq6tvG/IWk9JzAZ0YktyOhH6HOOu1r
 44UwaeDWsOmKAJq0+E7FY/C/D1csJFbjGnEFhkVUg871893VKn40dXYQYzibL/Acdr
 45A8PjVg+ZM/4B/np6ywHZqzcoYU2E+dwPo1/kjdgCjkrM3xLdNYKj+y5FAoGAOJaz
 46OggeBuHj8XeTbH/dXKpJZyL/oxw6R+dG2TfNyIVHNVcRgBZncjkVVachMNw2gzCg
 47yuguYM1YSWJjSQU4EqLEm+YG01pl+ok5gEx4RaZm5g+nwnCyUjHibWzHUNQY/OQt
 48wN+SPZE+XFpzgmJ6LsVqxRUnQ2jg+17ciGx/+vUCgYArCRxfa4ecYlZQYTvtbpwY
 49pLt6iUht7y69jkwSUTkOn+ZjBDcVWrJwfjt8R9BkMB/2rcwUj4Yo9DcgiWkfHSpM
 50W5QuMVb8coft4mC7G37mEoWWdNrc0TLkbfTSr+liNXoWp0QGL7/RQO7HHK+9QVD0
 51FfatkTVO1YuBsyGp9eE5rQ==
 52-----END PRIVATE KEY-----`
 53
 54const edPEM = `-----BEGIN PRIVATE KEY-----
 55MC4CAQAwBQYDK2VwBCIEICVufJC+iEzea5y5QlUD39QNmX2n/0c93QCcQrfQH8W8
 56-----END PRIVATE KEY-----`
 57
 58var rsaKey, edKey = parseKey(rsaPEM), parseKey(edPEM)
 59
 60func parseKey(s string) crypto.Signer {
 61	b, _ := pem.Decode([]byte(s))
 62	k, err := x509.ParsePKCS8PrivateKey(b.Bytes)
 63	if err != nil {
 64		panic(err)
 65	}
 66	return k.(crypto.Signer)
 67}
 68
 69func keyRecord(pub crypto.PublicKey) string {
 70	switch k := pub.(type) {
 71	case ed25519.PublicKey:
 72		return "v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(k)
 73	default:
 74		b, err := x509.MarshalPKIXPublicKey(k)
 75		if err != nil {
 76			panic(err)
 77		}
 78		return "v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(b)
 79	}
 80}
 81
 82// fakeDNS answers by selector whatever the domain: rsa, ed and key1 are
 83// the fixed keys, short is a 512-bit RSA key, temp fails temporarily,
 84// and anything else does not exist.
 85type fakeDNS struct{ lookups int }
 86
 87func (d *fakeDNS) lookup(_ context.Context, name string) ([]string, error) {
 88	d.lookups++
 89	sel, _, _ := strings.Cut(name, ".")
 90	switch sel {
 91	case "rsa", "key1":
 92		return []string{keyRecord(rsaKey.Public())}, nil
 93	case "ed":
 94		return []string{keyRecord(edKey.Public())}, nil
 95	case "short":
 96		n := new(big.Int).Lsh(big.NewInt(1), 511)
 97		n.Add(n, big.NewInt(0x2f))
 98		return []string{keyRecord(&rsa.PublicKey{N: n, E: 65537})}, nil
 99	case "temp":
100		return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true}
101	case "timeout":
102		return nil, context.DeadlineExceeded
103	}
104	return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
105}
106
107var exampleKeys = []string{"from", "to", "subject", "date", "message-id", "mime-version", "content-type"}
108
109func signMsg(t *testing.T, msg, domain, selector string, key crypto.Signer, canon dkim.Canonicalization, mod func(*dkim.SignOptions)) string {
110	t.Helper()
111	o := dkim.SignOptions{Domain: domain, Selector: selector, Signer: key,
112		HeaderCanonicalization: canon, BodyCanonicalization: canon, HeaderKeys: exampleKeys}
113	if mod != nil {
114		mod(&o)
115	}
116	var b bytes.Buffer
117	if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil {
118		t.Fatal(err)
119	}
120	return b.String()
121}
122
123func dkimSetup(t *testing.T) (*fixture, *fakeDNS) {
124	t.Helper()
125	f := setup(t)
126	dns := &fakeDNS{}
127	f.p.LookupTXT = dns.lookup
128	f.p.Cfg.Mail.Inbound.RequireDKIM = true
129	for _, a := range []string{"bob@cleberg.net", "bob@mail.example.test", "bob@user.github.io"} {
130		if err := f.st.AddEmail(f.bob, a, "admin", true); err != nil {
131			t.Fatal(err)
132		}
133	}
134	return f, dns
135}
136
137func TestDKIM(t *testing.T) {
138	type tc struct {
139		name, from, domain, selector string
140		key                          crypto.Signer
141		canon                        dkim.Canonicalization
142		mod                          func(*dkim.SignOptions)
143		after                        func(string) string // applied to the signed message
144		reason                       string              // "" posts
145		retry                        bool
146	}
147	var relaxed, simple dkim.Canonicalization = dkim.CanonicalizationRelaxed, dkim.CanonicalizationSimple
148	for _, c := range []tc{
149		{name: "rsa relaxed", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed},
150		{name: "rsa simple", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: simple},
151		{name: "ed25519 relaxed", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: relaxed},
152		{name: "ed25519 simple", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: simple},
153		{name: "the shape Migadu sends", from: "bob@cleberg.net", domain: "cleberg.net", selector: "key1", key: rsaKey, canon: simple,
154			after: func(m string) string {
155				if !strings.Contains(m, "d=cleberg.net;") || !strings.Contains(m, "s=key1;") || !strings.Contains(m, "c=simple/simple;") ||
156					!strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") || !strings.Contains(m, "a=rsa-sha256;") {
157					panic("signature not in the expected shape:\n" + m)
158				}
159				return m
160			}},
161		{name: "signing domain a subdomain of From's", from: "bob@example.test", domain: "mail.example.test", selector: "rsa", key: rsaKey, canon: relaxed},
162		{name: "From a subdomain of the signing domain", from: "bob@mail.example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed},
163		{name: "body altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
164			after: func(m string) string { return m + "appended\r\n" }, reason: "body hash did not verify"},
165		{name: "header altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
166			after: func(m string) string { return strings.Replace(m, "Subject: Re:", "Subject: Fwd:", 1) }, reason: "signature did not verify"},
167		{name: "From not in h=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
168			after: func(m string) string { return strings.Replace(m, "h=from:to:", "h=to:", 1) }, reason: "From field not signed"},
169		{name: "misaligned d=", from: "bob@example.test", domain: "attacker.example", selector: "rsa", key: rsaKey, canon: relaxed,
170			reason: "d=attacker.example: d= not aligned"},
171		{name: "public suffix d=", from: "bob@user.github.io", domain: "github.io", selector: "rsa", key: rsaKey, canon: relaxed,
172			reason: "d=github.io: d= not aligned"},
173		{name: "expired x=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
174			mod: func(o *dkim.SignOptions) { o.Expiration = time.Now().Add(-time.Minute) }, reason: "expired"},
175		{name: "l= refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
176			after:  func(m string) string { return strings.Replace(m, "DKIM-Signature: ", "DKIM-Signature: l=4; ", 1) },
177			reason: "body length"},
178		{name: "rsa-sha1 refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
179			after: func(m string) string { return strings.Replace(m, "a=rsa-sha256", "a=rsa-sha1", 1) }, reason: "too weak"},
180		{name: "512-bit key refused", from: "bob@example.test", domain: "example.test", selector: "short", key: rsaKey, canon: relaxed,
181			reason: "too short"},
182		{name: "no key", from: "bob@example.test", domain: "example.test", selector: "gone", key: rsaKey, canon: relaxed,
183			reason: "no key for signature"},
184		{name: "second From field", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
185			after: func(m string) string { return "From: bob@example.test\r\n" + m }, reason: "more than one From field"},
186		{name: "DNS temporary failure", from: "bob@example.test", domain: "example.test", selector: "temp", key: rsaKey, canon: relaxed,
187			reason: "key lookup failed", retry: true},
188		{name: "DNS timeout", from: "bob@example.test", domain: "example.test", selector: "timeout", key: rsaKey, canon: relaxed,
189			reason: "key lookup failed", retry: true},
190	} {
191		t.Run(c.name, func(t *testing.T) {
192			f, _ := dkimSetup(t)
193			m := signMsg(t, f.messageAs(t, f.bob, c.from, "<d@x>", "", "hi"), c.domain, c.selector, c.key, c.canon, c.mod)
194			if c.after != nil {
195				m = c.after(m)
196			}
197			res := f.p.Handle([]byte(m))
198			if c.reason == "" {
199				if !res.Posted {
200					t.Fatalf("not posted: %+v", res)
201				}
202				return
203			}
204			if res.Posted || res.Retry != c.retry || !strings.Contains(res.Reason, c.reason) {
205				t.Fatalf("result %+v, want reason %q retry %v", res, c.reason, c.retry)
206			}
207			audit := f.refusalReasons(t)
208			if c.retry {
209				if audit != "" {
210					t.Fatalf("a retry was audited:\n%s", audit)
211				}
212				return
213			}
214			if !strings.Contains(audit, c.reason) {
215				t.Fatalf("refusal not audited:\n%s", audit)
216			}
217			if strings.Contains(audit, "appended") || strings.Contains(audit, `"hi`) {
218				t.Fatalf("audit carries content:\n%s", audit)
219			}
220		})
221	}
222}
223
224func TestDKIMNoSignature(t *testing.T) {
225	f, _ := dkimSetup(t)
226	res := f.p.Handle([]byte(f.message(t, "bob@example.test", "hi")))
227	if res.Posted || res.Retry || !strings.Contains(res.Reason, "no DKIM-Signature") {
228		t.Fatalf("result %+v", res)
229	}
230}
231
232func TestDKIMFutureTime(t *testing.T) {
233	f, _ := dkimSetup(t)
234	m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
235	f.p.Now = func() time.Time { return time.Now().Add(-time.Hour) }
236	if res := f.p.Handle([]byte(m)); res.Posted || !strings.Contains(res.Reason, "dated in the future") {
237		t.Fatalf("result %+v", res)
238	}
239}
240
241// Only the first maxSignatures signatures are checked.
242func TestDKIMTooManySignatures(t *testing.T) {
243	for _, bad := range []int{maxSignatures - 1, maxSignatures} {
244		f, _ := dkimSetup(t)
245		msg := f.messageAs(t, f.bob, "bob@example.test", "<many@x>", "", "hi")
246		m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
247		for i := 0; i < bad; i++ {
248			s := signMsg(t, msg, "attacker.example", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
249			m = strings.TrimSuffix(s, msg) + m
250		}
251		res := f.p.Handle([]byte(m))
252		if want := bad < maxSignatures; res.Posted != want {
253			t.Fatalf("%d misaligned signatures first: posted %v, want %v (%+v)", bad, res.Posted, want, res)
254		}
255	}
256}
257
258// With both set, either passing is enough.
259func TestDKIMOrAuthenticationResults(t *testing.T) {
260	f, _ := dkimSetup(t)
261	f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net"
262	signed := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<one@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
263	if res := f.p.Handle([]byte(signed)); !res.Posted {
264		t.Fatalf("DKIM pass, no Authentication-Results: %+v", res)
265	}
266	ar := "Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\n"
267	if res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<two@x>", ar, "hi"))); !res.Posted {
268		t.Fatalf("Authentication-Results pass, no signature: %+v", res)
269	}
270	res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<three@x>", "", "hi")))
271	if res.Posted || !strings.Contains(res.Reason, "no Authentication-Results") || !strings.Contains(res.Reason, "no DKIM-Signature") {
272		t.Fatalf("neither: %+v", res)
273	}
274}
275
276func TestDKIMKeyCache(t *testing.T) {
277	f, dns := dkimSetup(t)
278	for _, id := range []string{"<c1@x>", "<c2@x>"} {
279		m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", id, "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
280		if res := f.p.Handle([]byte(m)); !res.Posted {
281			t.Fatalf("%s: %+v", id, res)
282		}
283	}
284	if dns.lookups != 1 {
285		t.Fatalf("%d lookups, want 1", dns.lookups)
286	}
287	f.p.Now = func() time.Time { return time.Now().Add(keyCacheTTL + time.Minute) }
288	f.p.lookupKey("rsa._domainkey.example.test")
289	if dns.lookups != 2 {
290		t.Fatalf("%d lookups after the TTL, want 2", dns.lookups)
291	}
292}
293
294func TestKeyCacheBounded(t *testing.T) {
295	var c keyCache
296	now := time.Now()
297	for i := 0; i < keyCacheSize*2; i++ {
298		c.put(strings.Repeat("x", i+1), nil, now)
299	}
300	if len(c.m) > keyCacheSize {
301		t.Fatalf("%d entries", len(c.m))
302	}
303}
304
305// A temporary DNS failure leaves the message unseen for the next poll.
306func TestDKIMRetryInDrain(t *testing.T) {
307	f, _ := dkimSetup(t)
308	m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "temp", rsaKey, dkim.CanonicalizationRelaxed, nil)
309	mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{1: []byte(m)}}
310	if err := f.p.Drain(mb); err != nil {
311		t.Fatal(err)
312	}
313	if mb.seen[1] || f.p.tries[1] != 1 {
314		t.Fatalf("seen %v tries %d", mb.seen[1], f.p.tries[1])
315	}
316}
317
318func TestLookupKeyErrors(t *testing.T) {
319	p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
320	var de *net.DNSError
321	if _, err := p.lookupKey("gone._domainkey.x"); !errors.As(err, &de) || !de.IsNotFound || de.Temporary() {
322		t.Fatalf("not found: %v", err)
323	}
324	for _, sel := range []string{"temp", "timeout"} {
325		if _, err := p.lookupKey(sel + "._domainkey.x"); !errors.As(err, &de) || !de.Temporary() {
326			t.Fatalf("%s: %v", sel, err)
327		}
328	}
329}
330
331// "From : x" is a field net/mail files under "From " and the dkim
332// package under "From". mallory, at the same provider domain as bob,
333// signs her own From, rewrites it as "From : ..." (relaxed
334// canonicalization still verifies it) and adds "From: bob" above:
335// net/mail reads bob as the sender, the signature covers mallory.
336func TestDKIMFromWithSpaceBeforeColon(t *testing.T) {
337	f, _ := dkimSetup(t)
338	m := signMsg(t, f.messageAs(t, f.bob, "mallory@example.test", "<poc@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
339	m = strings.Replace(m, "From: Someone <mallory@example.test>", "From: Someone <bob@example.test>\r\nFrom : Someone <mallory@example.test>", 1)
340	res := f.p.Handle([]byte(m))
341	if res.Posted || !strings.Contains(res.Reason, "malformed header field name") {
342		t.Fatalf("result %+v", res)
343	}
344	if !strings.Contains(f.refusalReasons(t), "malformed header field name") {
345		t.Fatal("refusal not audited")
346	}
347}
348
349func replyAddr(t *testing.T, f *fixture) string {
350	return mailreply.Address(replyBase, f.token(t, f.bob))
351}
352
353func TestDKIMTokenBinding(t *testing.T) {
354	var relaxed dkim.Canonicalization = dkim.CanonicalizationRelaxed
355	for _, c := range []struct {
356		name   string
357		build  func(t *testing.T, f *fixture) string
358		reason string
359	}{
360		{"reply address in Delivered-To only", func(t *testing.T, f *fixture) string {
361			m := f.messageAs(t, f.bob, "bob@example.test", "<b1@x>", "Delivered-To: "+replyAddr(t, f)+"\r\n", "hi")
362			m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
363			return signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
364		}, "reply address not in To or Cc"},
365		{"To not in h=", func(t *testing.T, f *fixture) string {
366			return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b2@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
367				func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "subject", "message-id", "content-type"} })
368		}, "to not in h="},
369		{"replayed with the reply address added in an unsigned Cc", func(t *testing.T, f *fixture) string {
370			m := f.messageAs(t, f.bob, "bob@example.test", "<b3@x>", "", "hi")
371			m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
372			m = signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
373			return "Cc: " + replyAddr(t, f) + "\r\n" + m
374		}, "cc not in h="},
375		{"replayed with the To replaced", func(t *testing.T, f *fixture) string {
376			m := f.messageAs(t, f.bob, "bob@example.test", "<b4@x>", "", "hi")
377			m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
378			m = signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
379			return strings.Replace(m, "To: friend@example.test", "To: "+replyAddr(t, f), 1)
380		}, "signature did not verify"},
381		{"second To field", func(t *testing.T, f *fixture) string {
382			m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b5@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed, nil)
383			return "To: other@example.test\r\n" + m
384		}, "more than one to field"},
385		{"Message-ID not in h=", func(t *testing.T, f *fixture) string {
386			return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b6@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
387				func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "to", "subject", "content-type"} })
388		}, "message-id not in h="},
389		{"Content-Type not in h=", func(t *testing.T, f *fixture) string {
390			return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b7@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
391				func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "to", "subject", "message-id"} })
392		}, "content-type not in h="},
393		{"unsigned quoted-printable Content-Transfer-Encoding", func(t *testing.T, f *fixture) string {
394			return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b8@x>", "Content-Transfer-Encoding: quoted-printable\r\n", "hi"),
395				"example.test", "rsa", rsaKey, relaxed, nil)
396		}, "content-transfer-encoding not in h= and not 7bit"},
397	} {
398		t.Run(c.name, func(t *testing.T) {
399			f, _ := dkimSetup(t)
400			res := f.p.Handle([]byte(c.build(t, f)))
401			if res.Posted || res.Retry || !strings.Contains(res.Reason, c.reason) {
402				t.Fatalf("result %+v, want %q", res, c.reason)
403			}
404			if !strings.Contains(f.refusalReasons(t), c.reason) {
405				t.Fatal("refusal not audited")
406			}
407		})
408	}
409}
410
411// Content-Transfer-Encoding in h= passes when the message has one, and
412// a reply address in a signed Cc passes.
413func TestDKIMSignedCTEAndCc(t *testing.T) {
414	f, _ := dkimSetup(t)
415	keys := append([]string{"content-transfer-encoding"}, exampleKeys...)
416	m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<p1@x>", "Content-Transfer-Encoding: 7bit\r\n", "hi"),
417		"example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, func(o *dkim.SignOptions) { o.HeaderKeys = keys })
418	if res := f.p.Handle([]byte(m)); !res.Posted {
419		t.Fatalf("CTE signed: %+v", res)
420	}
421	m = f.messageAs(t, f.bob, "bob@example.test", "<p2@x>", "", "hi")
422	m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test\r\nCc: "+replyAddr(t, f), 1)
423	m = signMsg(t, m, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed,
424		func(o *dkim.SignOptions) { o.HeaderKeys = append([]string{"cc"}, exampleKeys...) })
425	if res := f.p.Handle([]byte(m)); !res.Posted {
426		t.Fatalf("signed Cc: %+v", res)
427	}
428}
429
430// With only trusted_authserv_id set, the reply address may still come
431// from Delivered-To.
432func TestAuthservTokenFromDeliveredTo(t *testing.T) {
433	f := setup(t)
434	f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net"
435	m := f.messageAs(t, f.bob, "bob@example.test", "<ar@x>",
436		"Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\nDelivered-To: "+replyAddr(t, f)+"\r\n", "hi")
437	m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
438	if res := f.p.Handle([]byte(m)); !res.Posted {
439		t.Fatalf("result %+v", res)
440	}
441}
442
443// A copy of a signed message posts once, whatever unsigned fields or
444// signatures were changed on the way.
445func TestDKIMDedupeBySignature(t *testing.T) {
446	f, _ := dkimSetup(t)
447	m := f.messageAs(t, f.bob, "bob@example.test", "<gone@x>", "", "hi")
448	m = strings.Replace(m, "Message-ID: <gone@x>\r\n", "", 1)
449	m = signMsg(t, m, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
450	if res := f.p.Handle([]byte(m)); !res.Posted {
451		t.Fatalf("first: %+v", res)
452	}
453	if res := f.p.Handle([]byte("X-Resent: 1\r\n" + m)); res.Posted || !strings.Contains(res.Reason, "already posted") {
454		t.Fatalf("copy with an unsigned field added: %+v", res)
455	}
456
457	msg := f.messageAs(t, f.bob, "bob@example.test", "<dual@x>", "", "hi")
458	rsaSig := strings.TrimSuffix(signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil), msg)
459	edSigned := signMsg(t, msg, "example.test", "ed", edKey, dkim.CanonicalizationRelaxed, nil)
460	if res := f.p.Handle([]byte(rsaSig + edSigned)); !res.Posted {
461		t.Fatalf("dual-signed: %+v", res)
462	}
463	if res := f.p.Handle([]byte(edSigned)); res.Posted || !strings.Contains(res.Reason, "already posted") {
464		t.Fatalf("copy with one signature stripped: %+v", res)
465	}
466	if n := len(f.comments(t)); n != 2 {
467		t.Fatalf("%d comments", n)
468	}
469}
470
471func TestParseRawHeader(t *testing.T) {
472	for _, c := range []struct{ raw, reason string }{
473		{"From: a@b\r\nTo: c@d\r\n\r\nbody", ""},
474		{"From: a@b\n Subject-ish continuation\nTo: c@d\n\nbody", ""},
475		{"From : a@b\r\n\r\n", "malformed header field name"},
476		{"From\t: a@b\r\n\r\n", "malformed header field name"},
477		{"Fr\xc3\xb6m: a@b\r\nFrom: a@b\r\n\r\n", "malformed header field name"},
478		{" From: a@b\r\n\r\n", "malformed header"},
479		{"From: a@b\r\nnot a field\r\n\r\n", "malformed header"},
480		{"From: a@b\r\n: x\r\n\r\n", "malformed header"},
481		{"To: c@d\r\n\r\n", "no From field"},
482		{"From: a@b\r\nfROM: c@d\r\n\r\n", "more than one From field"},
483		{"From: a@b\r\nMessage-Id: 1\r\nMESSAGE-ID: 2\r\n\r\n", "more than one message-id field"},
484		{"From: a@b\r\n\r\nFrom : in the body is fine\r\n", ""},
485	} {
486		if _, got := parseRawHeader([]byte(c.raw)); got != c.reason {
487			t.Errorf("%q: %q, want %q", c.raw, got, c.reason)
488		}
489	}
490	h, _ := parseRawHeader([]byte("DKIM-Signature: v=1; b=ab\r\n cd ;d=x\r\nFrom: a@b\r\ndkim-signature: b= ef\r\n\r\n"))
491	if len(h.dkimB) != 2 || h.dkimB[0] != "abcd" || h.dkimB[1] != "ef" {
492		t.Fatalf("dkimB = %q", h.dkimB)
493	}
494	if h, _ := parseRawHeader([]byte("From: a@b\r\nContent-Transfer-Encoding:\r\n Quoted-Printable \r\n\r\n")); h.cte != "quoted-printable" {
495		t.Fatalf("cte = %q", h.cte)
496	}
497}
498
499// The shape Thunderbird sends through Migadu: Content-Transfer-Encoding
500// outside h=. An identity encoding posts; one that changes the decoded
501// body, added unsigned, is refused.
502func TestDKIMUnsignedCTE(t *testing.T) {
503	for _, c := range []struct {
504		cte  string
505		post bool
506	}{{"7bit", true}, {" 8BIT ", true}, {"binary", true}, {"quoted-printable", false}, {"base64", false}} {
507		f, _ := dkimSetup(t)
508		msg := "From: Bob <bob@example.test>\r\nTo: " + replyAddr(t, f) + "\r\nSubject: Re: [alice/app] #1: title\r\n" +
509			"Date: Tue, 29 Sep 2026 12:00:00 -0500\r\nMessage-ID: <tb-" + strings.TrimSpace(c.cte) + "@example.test>\r\nMIME-Version: 1.0\r\n" +
510			"Content-Type: text/plain; charset=UTF-8; format=flowed\r\nContent-Transfer-Encoding:" + c.cte + "\r\n\r\nThunderbird reply.\r\n"
511		m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationSimple, nil)
512		if !strings.Contains(m, "a=rsa-sha256;") || !strings.Contains(m, "c=simple/simple;") || !strings.Contains(m, "d=example.test;") ||
513			!strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") {
514			t.Fatalf("signature not in the expected shape:\n%s", m)
515		}
516		res := f.p.Handle([]byte(m))
517		if res.Posted != c.post {
518			t.Fatalf("CTE %q: posted %v, want %v (%+v)", c.cte, res.Posted, c.post, res)
519		}
520		if !c.post && !strings.Contains(res.Reason, "content-transfer-encoding not in h=") {
521			t.Fatalf("CTE %q: reason %q", c.cte, res.Reason)
522		}
523	}
524}