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