1package dkim
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto"
8 "crypto/ed25519"
9 "crypto/rsa"
10 "crypto/sha256"
11 "crypto/x509"
12 "encoding/base64"
13 "encoding/pem"
14 "errors"
15 "strings"
16 "testing"
17
18 "github.com/mjl-/mox/dns"
19 "github.com/mjl-/mox/mlog"
20)
21
22var pkglog = mlog.New("dkim", nil)
23
24func policyOK(sig *Sig) error {
25 return nil
26}
27
28func parseRSAKey(t *testing.T, rsaText string) *rsa.PrivateKey {
29 rsab, _ := pem.Decode([]byte(rsaText))
30 if rsab == nil {
31 t.Fatalf("no pem in privKey")
32 }
33
34 key, err := x509.ParsePKCS8PrivateKey(rsab.Bytes)
35 if err != nil {
36 t.Fatalf("parsing private key: %s", err)
37 }
38 return key.(*rsa.PrivateKey)
39}
40
41func getRSAKey(t *testing.T) *rsa.PrivateKey {
42 // Generated with:
43 // openssl genrsa -out pkcs1.pem 2048
44 // openssl pkcs8 -topk8 -inform pem -in pkcs1.pem -outform pem -nocrypt -out pkcs8.pem
45 const rsaText = `-----BEGIN PRIVATE KEY-----
46MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCu7iTF/AAvJQ3U
47WRlcXd+n6HXOSYvmDlqjLsuCKn6/T+Ma0ZtobCRfzyXh5pFQBCHffW6fpEzJs/2o
48+e896zb1QKjD8Xxsjarjdw1iXzgMj/lhDGWyNyUHC34+k77UfpQBZgPLvZHyYyQG
49sVMzzmvURE+GMFmXYUiGI581PdCx4bNba/4gYQnc/eqQ8oX0T//2RdRqdhdDM2d7
50CYALtkxKetH1F+Rz7XDjFmI3GjPs1KwVdh+Cl8kejThi0SVxXpqnoqB2WGsr/lGG
51GxsxcpLb/+KWFjI0go3OJjMaxFCmhB0pGdW8I7kNwNrZsCdSvmjMDojNuegx6WMg
52/T7go3CvAgMBAAECggEAQA3AlmSDtr+lNDvZ7voKwwN6W6qPmRJpevZQG54u4iPA
53/5mAA/kRSqnh77mLPRb+RkU6RCeX3IXVXNIEGhKugZiHE5Sx4FfxmrAFzR8buXHg
54uXoeJOdPXiiFtilIh6u/y1FNE4YbUnud/fthgYdU8Zl/2x2KOMWtFj0l94tmhzOI
55b2y8/U8r85anI5XGYuzRCqKS1WskXhkXH8LZUB+9yAxX7V5ysgxjofM4FW8ns7yj
56K4cBS8KY2v3t7TZ4FgwkAhPcTfBc/E2UWT1Ztmr+18LFV5bqI8g2YlN+BgCxU7U/
571tawxqFhs+xowEpzNwAvjAIPpptIRiY1rz7sBB9g5QKBgQDLo/5rTUwNOPR9dYvA
58+DYUSCfxvNamI4GI66AgwOeN8O+W+dRDF/Ewbk/SJsBPSLIYzEiQ2uYKcNEmIjo+
597WwSCJZjKujovw77s9JAHexhpd8uLD2w9l3KeTg41LEYm2uVwoXWEHYSYJ9Ynz0M
60PWxvi2Hm0IoQ7gJIfxng/wIw3QKBgQDb6GFvPH/OTs40+dopwtm3irmkBAmT8N0b
613TpehONCOiL4GPxmn2DN6ELhHFV27Jj/1CfpGVbcBlaS1xYUGUGsB9gYukhdaBST
62KGHRoeZDcf0gaQLKG15EEfFOvcKI9aGljV8FdFfG+Z4fW3LA8khvpvjLLkv1A1jM
63MrEBthco+wKBgD45EM9GohtUMNh450gCT7voxFPICKphJP5qSNZZOyeS3BJ8qdAK
64a8cJndgvwQk4xDpxiSbBzBKaoD2Prc52i1QDTbhlbx9W6cQdEPxIaGb54PThzcPZ
65s5Tfbz9mNeq36qqq8mwTQZCh926D0YqA5jY7F6IITHeZ0hbGx2iJYuj9AoGARIyK
66ms8kE95y3wanX+8ySMmAlsT/a1NgyUfL4xzPbpyKvAWl4CN8XJMzDdL0PS8BfnXW
67vw28CrgbEojjg/5ff02uqf6fgiZoi3rCC0PJcGq++fRh/zhKyTNCokX6txDCg8Wu
68wheDKS40gRfTjJu5wrwsv8E9wjF546VFkf/99jMCgYEAm/x+kEfWKuzx8pQT66TY
69pxnC41upJOO1htTHNIN24J7XrrFI5+OZq90G+t/VgWX08Z8RlhejX+ukBf+SRu3u
705VMGcAs4px+iECX/FHo21YQFnrmArN1zdFxPU3rBWoBueqmGO6FT0HBbKzTuS7N0
717fIv3GQqImz3+ZbYWlXfkPI=
72-----END PRIVATE KEY-----`
73 return parseRSAKey(t, rsaText)
74}
75
76func getWeakRSAKey(t *testing.T) *rsa.PrivateKey {
77 const rsaText = `-----BEGIN PRIVATE KEY-----
78MIIBUwIBADANBgkqhkiG9w0BAQEFAASCAT0wggE5AgEAAkEAsQo3ATJAZ4aAZz+l
79ndXl27ODOY+49DjYxwhgtg+OU8A1WEYCfWaZ7ozYtpsqH8GNFvlKtK38eKbdDuLw
80gsFYMQIDAQABAkBwstb2/P1Aqb9deoe8JOiw5eJYJySO2w0sDio6W0a4Cqi7XQ7r
81/yZ1gOp+ZnShX/sJq0Pd16UkJUUEtEPoZyptAiEA4KLP8pz/9R0t7Envqph1oVjQ
82CVDIL/UKRmdnMiwwDosCIQDJwiu08UgNNeliAygbkC2cdszjf4a3laGmYbfWrtAn
83swIgUBfc+w0degDgadpm2LWpY1DuRBQIfIjrE/U0Z0A4FkcCIHxEuoLycjygziTu
84aM/BWDac/cnKDIIbCbvfSEpU1iT9AiBsbkAcYCQ8mR77BX6gZKEc74nSce29gmR7
85mtrKWknTDQ==
86-----END PRIVATE KEY-----`
87 return parseRSAKey(t, rsaText)
88}
89
90func TestParseSignature(t *testing.T) {
91 // Domain name must always be A-labels, not U-labels. We do allow localpart with non-ascii.
92 hdr := `DKIM-Signature: v=1; a=rsa-sha256; d=xn--h-bga.mox.example; s=xn--yr2021-pua;
93 i=møx@xn--h-bga.mox.example; t=1643719203; h=From:To:Cc:Bcc:Reply-To:
94 References:In-Reply-To:Subject:Date:Message-ID:Content-Type:From:To:Subject:
95 Date:Message-ID:Content-Type;
96 bh=g3zLYH4xKxcPrHOD18z9YfpQcnk/GaJedfustWU5uGs=; b=dtgAOl71h/dNPQrmZTi3SBVkm+
97 EjMnF7sWGT123fa5g+m6nGpPue+I+067wwtkWQhsedbDkqT7gZb5WaG5baZsr9e/XpJ/iX4g6YXpr
98 07aLY8eF9jazcGcRCVCqLtyq0UJQ2Oz/ML74aYu1beh3jXsoI+k3fJ+0/gKSVC7enCFpNe1HhbXVS
99 4HRy/Rw261OEIy2e20lyPT4iDk2oODabzYa28HnXIciIMELjbc/sSawG68SAnhwdkWBrRzBDMCCHm
100 wvkmgDsVJWtdzjJqjxK2mYVxBMJT0lvsutXgYQ+rr6BLtjHsOb8GMSbQGzY5SJ3N8TP02pw5OykBu
101 B/aHff1A==
102`
103 smtputf8 := true
104 _, _, err := parseSignature([]byte(strings.ReplaceAll(hdr, "\n", "\r\n")), smtputf8)
105 if err != nil {
106 t.Fatalf("parsing signature: %s", err)
107 }
108}
109
110func TestVerifyRSA(t *testing.T) {
111 message := strings.ReplaceAll(`Return-Path: <mechiel@ueber.net>
112X-Original-To: mechiel@ueber.net
113Delivered-To: mechiel@ueber.net
114Received: from [IPV6:2a02:a210:4a3:b80:ca31:30ee:74a7:56e0] (unknown [IPv6:2a02:a210:4a3:b80:ca31:30ee:74a7:56e0])
115 by koriander.ueber.net (Postfix) with ESMTPSA id E119EDEB0B
116 for <mechiel@ueber.net>; Fri, 10 Dec 2021 20:09:08 +0100 (CET)
117DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=ueber.net;
118 s=koriander; t=1639163348;
119 bh=g3zLYH4xKxcPrHOD18z9YfpQcnk/GaJedfustWU5uGs=;
120 h=Date:To:From:Subject:From;
121 b=rpWruWprs2TB7/MnulA2n2WtfUIfrrnAvRoSrip1ruX5ORN4AOYPPMmk/gGBDdc6O
122 grRpSsNzR9BrWcooYfbNfSbl04nPKMp0acsZGfpvkj0+mqk5b8lqZs3vncG1fHlQc7
123 0KXfnAHyEs7bjyKGbrw2XG1p/EDoBjIjUsdpdCAtamMGv3A3irof81oSqvwvi2KQks
124 17aB1YAL9Xzkq9ipo1aWvDf2W6h6qH94YyNocyZSVJ+SlVm3InNaF8APkV85wOm19U
125 9OW81eeuQbvSPcQZJVOmrWzp7XKHaXH0MYE3+hdH/2VtpCnPbh5Zj9SaIgVbaN6NPG
126 Ua0E07rwC86sg==
127Message-ID: <427999f6-114f-e59c-631e-ab2a5f6bfe4c@ueber.net>
128Date: Fri, 10 Dec 2021 20:09:08 +0100
129MIME-Version: 1.0
130User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
131 Thunderbird/91.4.0
132Content-Language: nl
133To: mechiel@ueber.net
134From: Mechiel Lukkien <mechiel@ueber.net>
135Subject: test
136Content-Type: text/plain; charset=UTF-8; format=flowed
137Content-Transfer-Encoding: 7bit
138
139test
140`, "\n", "\r\n")
141
142 resolver := dns.MockResolver{
143 TXT: map[string][]string{
144 "koriander._domainkey.ueber.net.": {"v=DKIM1; k=rsa; s=email; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy3Z9ffZe8gUTJrdGuKj6IwEembmKYpp0jMa8uhudErcI4gFVUaFiiRWxc4jP/XR9NAEv3XwHm+CVcHu+L/n6VWt6g59U7vHXQicMfKGmEp2VplsgojNy/Y5X9HdVYM0azsI47NcJCDW9UVfeOHdOSgFME4F8dNtUKC4KTB2d1pqj/yixz+V8Sv8xkEyPfSRHcNXIw0LvelqJ1MRfN3hO/3uQSVrPYYk4SyV0b6wfnkQs28fpiIpGQvzlGI5WkrdOQT5k4YHaEvZDLNdwiMeVZOEL7dDoFs2mQsovm+tH0StUAZTnr61NLVFfD5V6Ip1V9zVtspPHvYSuOWwyArFZ9QIDAQAB"},
145 },
146 }
147
148 results, err := Verify(context.Background(), pkglog.Logger, resolver, false, policyOK, strings.NewReader(message), false)
149 if err != nil {
150 t.Fatalf("dkim verify: %v", err)
151 }
152 if len(results) != 1 || results[0].Status != StatusPass {
153 t.Fatalf("verify: unexpected results %v", results)
154 }
155}
156
157func TestVerifyEd25519(t *testing.T) {
158 // ../rfc/8463:287
159 message := strings.ReplaceAll(`DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
160 d=football.example.com; i=@football.example.com;
161 q=dns/txt; s=brisbane; t=1528637909; h=from : to :
162 subject : date : message-id : from : subject : date;
163 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
164 b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
165 Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
166DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
167 d=football.example.com; i=@football.example.com;
168 q=dns/txt; s=test; t=1528637909; h=from : to : subject :
169 date : message-id : from : subject : date;
170 bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
171 b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
172 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
173 dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
174From: Joe SixPack <joe@football.example.com>
175To: Suzie Q <suzie@shopping.example.net>
176Subject: Is dinner ready?
177Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
178Message-ID: <20030712040037.46341.5F8J@football.example.com>
179
180Hi.
181
182We lost the game. Are you hungry yet?
183
184Joe.
185
186`, "\n", "\r\n")
187
188 resolver := dns.MockResolver{
189 TXT: map[string][]string{
190 "brisbane._domainkey.football.example.com.": {"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="},
191 "test._domainkey.football.example.com.": {"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB"},
192 },
193 }
194
195 results, err := Verify(context.Background(), pkglog.Logger, resolver, false, policyOK, strings.NewReader(message), false)
196 if err != nil {
197 t.Fatalf("dkim verify: %v", err)
198 }
199 if len(results) != 2 || results[0].Status != StatusPass || results[1].Status != StatusPass {
200 t.Fatalf("verify: unexpected results %#v", results)
201 }
202}
203
204func TestSign(t *testing.T) {
205 message := strings.ReplaceAll(`Message-ID: <427999f6-114f-e59c-631e-ab2a5f6bfe4c@ueber.net>
206Date: Fri, 10 Dec 2021 20:09:08 +0100
207MIME-Version: 1.0
208User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
209 Thunderbird/91.4.0
210Content-Language: nl
211To: mechiel@ueber.net
212From: Mechiel Lukkien <mechiel@ueber.net>
213Subject: test
214 test
215Content-Type: text/plain; charset=UTF-8; format=flowed
216Content-Transfer-Encoding: 7bit
217
218test
219`, "\n", "\r\n")
220
221 rsaKey := getRSAKey(t)
222 ed25519Key := ed25519.NewKeyFromSeed(make([]byte, 32))
223
224 selrsa := Selector{
225 Hash: "sha256",
226 PrivateKey: rsaKey,
227 Headers: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
228 Domain: dns.Domain{ASCII: "testrsa"},
229 }
230
231 // Now with sha1 and relaxed canonicalization.
232 selrsa2 := Selector{
233 Hash: "sha1",
234 PrivateKey: rsaKey,
235 Headers: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
236 Domain: dns.Domain{ASCII: "testrsa2"},
237 }
238 selrsa2.HeaderRelaxed = true
239 selrsa2.BodyRelaxed = true
240
241 // Ed25519 key.
242 seled25519 := Selector{
243 Hash: "sha256",
244 PrivateKey: ed25519Key,
245 Headers: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
246 Domain: dns.Domain{ASCII: "tested25519"},
247 }
248 // Again ed25519, but without sealing headers. Use sha256 again, for reusing the body hash from the previous dkim-signature.
249 seled25519b := Selector{
250 Hash: "sha256",
251 PrivateKey: ed25519Key,
252 Headers: strings.Split("From,To,Cc,Bcc,Reply-To,Subject,Date", ","),
253 SealHeaders: true,
254 Domain: dns.Domain{ASCII: "tested25519b"},
255 }
256 selectors := []Selector{selrsa, selrsa2, seled25519, seled25519b}
257
258 ctx := context.Background()
259 headers, err := Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader(message))
260 if err != nil {
261 t.Fatalf("sign: %v", err)
262 }
263
264 makeRecord := func(k string, publicKey any) string {
265 tr := &Record{
266 Version: "DKIM1",
267 Key: k,
268 PublicKey: publicKey,
269 Flags: []string{"s"},
270 }
271 txt, err := tr.Record()
272 if err != nil {
273 t.Fatalf("making dns txt record: %s", err)
274 }
275 //log.Infof("txt record: %s", txt)
276 return txt
277 }
278
279 resolver := dns.MockResolver{
280 TXT: map[string][]string{
281 "testrsa._domainkey.mox.example.": {makeRecord("rsa", rsaKey.Public())},
282 "testrsa2._domainkey.mox.example.": {makeRecord("rsa", rsaKey.Public())},
283 "tested25519._domainkey.mox.example.": {makeRecord("ed25519", ed25519Key.Public())},
284 "tested25519b._domainkey.mox.example.": {makeRecord("ed25519", ed25519Key.Public())},
285 },
286 }
287
288 nmsg := headers + message
289
290 results, err := Verify(ctx, pkglog.Logger, resolver, false, policyOK, strings.NewReader(nmsg), false)
291 if err != nil {
292 t.Fatalf("verify: %s", err)
293 }
294 if len(results) != 4 || results[0].Status != StatusPass || results[1].Status != StatusPass || results[2].Status != StatusPass || results[3].Status != StatusPass {
295 t.Fatalf("verify: unexpected results %v\nheaders:\n%s", results, headers)
296 }
297 //log.Infof("headers:%s", headers)
298 //log.Infof("nmsg\n%s", nmsg)
299
300 // Multiple From headers.
301 _, err = Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader("From: <mjl@mox.example>\r\nFrom: <mjl@mox.example>\r\n\r\ntest"))
302 if !errors.Is(err, ErrFrom) {
303 t.Fatalf("sign, got err %v, expected ErrFrom", err)
304 }
305
306 // No From header.
307 _, err = Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader("Brom: <mjl@mox.example>\r\n\r\ntest"))
308 if !errors.Is(err, ErrFrom) {
309 t.Fatalf("sign, got err %v, expected ErrFrom", err)
310 }
311
312 // Malformed headers.
313 _, err = Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader(":\r\n\r\ntest"))
314 if !errors.Is(err, ErrHeaderMalformed) {
315 t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
316 }
317 _, err = Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader(" From:<mjl@mox.example>\r\n\r\ntest"))
318 if !errors.Is(err, ErrHeaderMalformed) {
319 t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
320 }
321 _, err = Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader("Frøm:<mjl@mox.example>\r\n\r\ntest"))
322 if !errors.Is(err, ErrHeaderMalformed) {
323 t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
324 }
325 _, err = Sign(ctx, pkglog.Logger, "mjl", dns.Domain{ASCII: "mox.example"}, selectors, false, strings.NewReader("From:<mjl@mox.example>"))
326 if !errors.Is(err, ErrHeaderMalformed) {
327 t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
328 }
329}
330
331func TestVerify(t *testing.T) {
332 // We do many Verify calls, each time starting out with a valid configuration, then
333 // we modify one thing to trigger an error, which we check for.
334
335 const message = `From: <mjl@mox.example>
336To: <other@mox.example>
337Subject: test
338Date: Fri, 10 Dec 2021 20:09:08 +0100
339Message-ID: <test@mox.example>
340MIME-Version: 1.0
341Content-Type: text/plain; charset=UTF-8; format=flowed
342Content-Transfer-Encoding: 7bit
343
344test
345`
346
347 key := ed25519.NewKeyFromSeed(make([]byte, 32))
348 var resolver dns.MockResolver
349 var record *Record
350 var recordTxt string
351 var msg string
352 var policy func(*Sig) error
353 var sel Selector
354 var selectors []Selector
355 var signed bool
356 var signDomain dns.Domain
357
358 prepare := func() {
359 t.Helper()
360
361 policy = DefaultPolicy
362 signDomain = dns.Domain{ASCII: "mox.example"}
363
364 record = &Record{
365 Version: "DKIM1",
366 Key: "ed25519",
367 PublicKey: key.Public(),
368 Flags: []string{"s"},
369 }
370
371 txt, err := record.Record()
372 if err != nil {
373 t.Fatalf("making dns txt record: %s", err)
374 }
375 recordTxt = txt
376
377 resolver = dns.MockResolver{
378 TXT: map[string][]string{
379 "test._domainkey.mox.example.": {txt},
380 },
381 }
382
383 sel = Selector{
384 Hash: "sha256",
385 PrivateKey: key,
386 Headers: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
387 Domain: dns.Domain{ASCII: "test"},
388 }
389 selectors = []Selector{sel}
390
391 msg = message
392 signed = false
393 }
394
395 sign := func() {
396 t.Helper()
397
398 msg = strings.ReplaceAll(msg, "\n", "\r\n")
399
400 headers, err := Sign(context.Background(), pkglog.Logger, "mjl", signDomain, selectors, false, strings.NewReader(msg))
401 if err != nil {
402 t.Fatalf("sign: %v", err)
403 }
404 msg = headers + msg
405 signed = true
406 }
407
408 test := func(expErr error, expStatus Status, expResultErr error, mod func()) {
409 t.Helper()
410
411 prepare()
412 mod()
413 if !signed {
414 sign()
415 }
416
417 results, err := Verify(context.Background(), pkglog.Logger, resolver, true, policy, strings.NewReader(msg), false)
418 if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
419 t.Fatalf("got verify error %v, expected %v", err, expErr)
420 }
421 if expStatus != "" && (len(results) == 0 || results[0].Status != expStatus) {
422 var status Status
423 if len(results) > 0 {
424 status = results[0].Status
425 }
426 t.Fatalf("got status %q, expected %q", status, expStatus)
427 }
428 var resultErr error
429 if len(results) > 0 {
430 resultErr = results[0].Err
431 }
432 if (resultErr == nil) != (expResultErr == nil) || resultErr != nil && !errors.Is(resultErr, expResultErr) {
433 t.Fatalf("got result error %v, expected %v", resultErr, expResultErr)
434 }
435 }
436
437 test(nil, StatusPass, nil, func() {})
438
439 // Cannot parse message, so not much more to do.
440 test(ErrHeaderMalformed, "", nil, func() {
441 sign()
442 msg = ":\r\n\r\n" // Empty header key.
443 })
444
445 // From Lookup.
446 // No DKIM record. ../rfc/6376:2608
447 test(nil, StatusPermerror, ErrNoRecord, func() {
448 resolver.TXT = nil
449 })
450 // DNS request is failing temporarily.
451 test(nil, StatusTemperror, ErrDNS, func() {
452 resolver.Fail = []string{
453 "txt test._domainkey.mox.example.",
454 }
455 })
456 // Claims to be DKIM through v=, but cannot be parsed. ../rfc/6376:2621
457 test(nil, StatusPermerror, ErrSyntax, func() {
458 resolver.TXT = map[string][]string{
459 "test._domainkey.mox.example.": {"v=DKIM1; bogus"},
460 }
461 })
462 // Not a DKIM record. ../rfc/6376:2621
463 test(nil, StatusTemperror, ErrSyntax, func() {
464 resolver.TXT = map[string][]string{
465 "test._domainkey.mox.example.": {"bogus"},
466 }
467 })
468 // Multiple dkim records. ../rfc/6376:1609
469 test(nil, StatusTemperror, ErrMultipleRecords, func() {
470 resolver.TXT["test._domainkey.mox.example."] = []string{recordTxt, recordTxt}
471 })
472
473 // Invalid DKIM-Signature header. ../rfc/6376:2503
474 test(nil, StatusPermerror, errSigMissingTag, func() {
475 msg = strings.ReplaceAll("DKIM-Signature: v=1\n"+msg, "\n", "\r\n")
476 signed = true
477 })
478
479 // Signature has valid syntax, but parameters aren't acceptable.
480 // "From" not signed. ../rfc/6376:2546
481 test(nil, StatusPermerror, ErrFrom, func() {
482 sign()
483 // Remove "from" from signed headers (h=).
484 msg = strings.ReplaceAll(msg, ":From:", ":")
485 msg = strings.ReplaceAll(msg, "=From:", "=")
486 })
487 // todo: check expired signatures with StatusPermerror and ErrSigExpired. ../rfc/6376:2550
488 // Domain in signature is higher-level than organizational domain. ../rfc/6376:2554
489 test(nil, StatusPermerror, ErrTLD, func() {
490 // Pretend to sign as .com
491 msg = strings.ReplaceAll(msg, "From: <mjl@mox.example>\n", "From: <mjl@com>\n")
492 signDomain = dns.Domain{ASCII: "com"}
493 resolver.TXT = map[string][]string{
494 "test._domainkey.com.": {recordTxt},
495 }
496 })
497 // Unknown hash algorithm.
498 test(nil, StatusPermerror, ErrHashAlgorithmUnknown, func() {
499 sign()
500 msg = strings.ReplaceAll(msg, "sha256", "sha257")
501 })
502 // Unknown canonicalization.
503 test(nil, StatusPermerror, ErrCanonicalizationUnknown, func() {
504 sel.HeaderRelaxed = true
505 sel.BodyRelaxed = true
506 selectors = []Selector{sel}
507
508 sign()
509 msg = strings.ReplaceAll(msg, "relaxed/relaxed", "bogus/bogus")
510 })
511 // Query methods without dns/txt. ../rfc/6376:1268
512 test(nil, StatusPermerror, ErrQueryMethod, func() {
513 sign()
514 msg = strings.ReplaceAll(msg, "DKIM-Signature: ", "DKIM-Signature: q=other;")
515 })
516
517 // Unacceptable through policy. ../rfc/6376:2560
518 test(nil, StatusPolicy, ErrPolicy, func() {
519 sign()
520 msg = strings.ReplaceAll(msg, "DKIM-Signature: ", "DKIM-Signature: l=1;")
521 })
522 // Hash algorithm not allowed by DNS record. ../rfc/6376:2639
523 test(nil, StatusPermerror, ErrHashAlgNotAllowed, func() {
524 recordTxt += ";h=sha1"
525 resolver.TXT = map[string][]string{
526 "test._domainkey.mox.example.": {recordTxt},
527 }
528 })
529 // Signature algorithm mismatch. ../rfc/6376:2651
530 test(nil, StatusPermerror, ErrSigAlgMismatch, func() {
531 record.PublicKey = getRSAKey(t).Public()
532 record.Key = "rsa"
533 txt, err := record.Record()
534 if err != nil {
535 t.Fatalf("making dns txt record: %s", err)
536 }
537 resolver.TXT = map[string][]string{
538 "test._domainkey.mox.example.": {txt},
539 }
540 })
541 // Empty public key means revoked key. ../rfc/6376:2645
542 test(nil, StatusPermerror, ErrKeyRevoked, func() {
543 record.PublicKey = nil
544 txt, err := record.Record()
545 if err != nil {
546 t.Fatalf("making dns txt record: %s", err)
547 }
548 resolver.TXT = map[string][]string{
549 "test._domainkey.mox.example.": {txt},
550 }
551 })
552 // We refuse rsa keys smaller than 1024 bits.
553 test(nil, StatusPermerror, ErrWeakKey, func() {
554 key := getWeakRSAKey(t)
555 record.Key = "rsa"
556 record.PublicKey = key.Public()
557 txt, err := record.Record()
558 if err != nil {
559 t.Fatalf("making dns txt record: %s", err)
560 }
561 resolver.TXT = map[string][]string{
562 "test._domainkey.mox.example.": {txt},
563 }
564 sel.PrivateKey = key
565 selectors = []Selector{sel}
566 })
567 // Key not allowed for email by DNS record. ../rfc/6376:1541
568 test(nil, StatusPermerror, ErrKeyNotForEmail, func() {
569 recordTxt += ";s=other"
570 resolver.TXT = map[string][]string{
571 "test._domainkey.mox.example.": {recordTxt},
572 }
573 })
574 // todo: Record has flag "s" but identity does not have exact domain match. Cannot currently easily implement this test because Sign() always uses the same domain. ../rfc/6376:1575
575 // Wrong signature, different datahash, and thus signature.
576 test(nil, StatusFail, ErrSigVerify, func() {
577 sign()
578 msg = strings.ReplaceAll(msg, "Subject: test\r\n", "Subject: modified header\r\n")
579 })
580 // Signature is correct for bodyhash, but the body has changed.
581 test(nil, StatusFail, ErrBodyhashMismatch, func() {
582 sign()
583 msg = strings.ReplaceAll(msg, "\r\ntest\r\n", "\r\nmodified body\r\n")
584 })
585
586 // Check that last-occurring header field is used.
587 test(nil, StatusFail, ErrSigVerify, func() {
588 sel.SealHeaders = false
589 selectors = []Selector{sel}
590 sign()
591 msg = strings.ReplaceAll(msg, "\r\n\r\n", "\r\nsubject: another\r\n\r\n")
592 })
593 test(nil, StatusPass, nil, func() {
594 sel.SealHeaders = false
595 selectors = []Selector{sel}
596 sign()
597 msg = "subject: another\r\n" + msg
598 })
599}
600
601func TestBodyHash(t *testing.T) {
602 simpleGot, err := bodyHash(crypto.SHA256.New(), true, bufio.NewReader(strings.NewReader("")))
603 if err != nil {
604 t.Fatalf("body hash, simple, empty string: %s", err)
605 }
606 simpleWant := base64Decode("frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=")
607 if !bytes.Equal(simpleGot, simpleWant) {
608 t.Fatalf("simple body hash for empty string, got %s, expected %s", base64Encode(simpleGot), base64Encode(simpleWant))
609 }
610
611 relaxedGot, err := bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader("")))
612 if err != nil {
613 t.Fatalf("body hash, relaxed, empty string: %s", err)
614 }
615 relaxedWant := base64Decode("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
616 if !bytes.Equal(relaxedGot, relaxedWant) {
617 t.Fatalf("relaxed body hash for empty string, got %s, expected %s", base64Encode(relaxedGot), base64Encode(relaxedWant))
618 }
619
620 compare := func(a, b []byte) {
621 t.Helper()
622 if !bytes.Equal(a, b) {
623 t.Fatalf("hash not equal")
624 }
625 }
626
627 // NOTE: the trailing space in the strings below are part of the test for canonicalization.
628
629 // ../rfc/6376:936
630 exampleIn := strings.ReplaceAll(` c
631d e
632
633
634`, "\n", "\r\n")
635 relaxedOut := strings.ReplaceAll(` c
636d e
637`, "\n", "\r\n")
638 relaxedBh, err := bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader(exampleIn)))
639 if err != nil {
640 t.Fatalf("bodyhash: %s", err)
641 }
642 relaxedOutHash := sha256.Sum256([]byte(relaxedOut))
643 compare(relaxedBh, relaxedOutHash[:])
644
645 simpleOut := strings.ReplaceAll(` c
646d e
647`, "\n", "\r\n")
648 simpleBh, err := bodyHash(crypto.SHA256.New(), true, bufio.NewReader(strings.NewReader(exampleIn)))
649 if err != nil {
650 t.Fatalf("bodyhash: %s", err)
651 }
652 simpleOutHash := sha256.Sum256([]byte(simpleOut))
653 compare(simpleBh, simpleOutHash[:])
654
655 // ../rfc/8463:343
656 relaxedBody := strings.ReplaceAll(`Hi.
657
658We lost the game. Are you hungry yet?
659
660Joe.
661
662`, "\n", "\r\n")
663 relaxedGot, err = bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader(relaxedBody)))
664 if err != nil {
665 t.Fatalf("body hash, relaxed, ed25519 example: %s", err)
666 }
667 relaxedWant = base64Decode("2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=")
668 if !bytes.Equal(relaxedGot, relaxedWant) {
669 t.Fatalf("relaxed body hash for ed25519 example, got %s, expected %s", base64Encode(relaxedGot), base64Encode(relaxedWant))
670 }
671}
672
673func base64Decode(s string) []byte {
674 buf, err := base64.StdEncoding.DecodeString(s)
675 if err != nil {
676 panic(err)
677 }
678 return buf
679}
680
681func base64Encode(buf []byte) string {
682 return base64.StdEncoding.EncodeToString(buf)
683}
684