internal/mailin/dkim_test.go
524 lines · 24217 bytes
28 symbols in this file
rsaPEMedPEMrsaKeyedKeyparseKeykeyRecordfakeDNSfakeDNS.lookupexampleKeyssignMsgdkimSetupTestDKIMTestDKIMNoSignatureTestDKIMFutureTimeTestDKIMTooManySignaturesTestDKIMOrAuthenticationResultsTestDKIMKeyCacheTestKeyCacheBoundedTestDKIMRetryInDrainTestLookupKeyErrorsTestDKIMFromWithSpaceBeforeColonreplyAddrTestDKIMTokenBindingTestDKIMSignedCTEAndCcTestAuthservTokenFromDeliveredToTestDKIMDedupeBySignatureTestParseRawHeaderTestDKIMUnsignedCTE
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}