mirror of
https://github.com/mjl-/mox.git
synced 2025-07-10 07:54:40 +03:00
mox!
This commit is contained in:
702
dkim/dkim_test.go
Normal file
702
dkim/dkim_test.go
Normal file
@ -0,0 +1,702 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/config"
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func policyOK(sig *Sig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRSAKey(t *testing.T, rsaText string) *rsa.PrivateKey {
|
||||
rsab, _ := pem.Decode([]byte(rsaText))
|
||||
if rsab == nil {
|
||||
t.Fatalf("no pem in privKey")
|
||||
}
|
||||
|
||||
key, err := x509.ParsePKCS8PrivateKey(rsab.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing private key: %s", err)
|
||||
}
|
||||
return key.(*rsa.PrivateKey)
|
||||
}
|
||||
|
||||
func getRSAKey(t *testing.T) *rsa.PrivateKey {
|
||||
// Generated with:
|
||||
// openssl genrsa -out pkcs1.pem 2048
|
||||
// openssl pkcs8 -topk8 -inform pem -in pkcs1.pem -outform pem -nocrypt -out pkcs8.pem
|
||||
const rsaText = `-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCu7iTF/AAvJQ3U
|
||||
WRlcXd+n6HXOSYvmDlqjLsuCKn6/T+Ma0ZtobCRfzyXh5pFQBCHffW6fpEzJs/2o
|
||||
+e896zb1QKjD8Xxsjarjdw1iXzgMj/lhDGWyNyUHC34+k77UfpQBZgPLvZHyYyQG
|
||||
sVMzzmvURE+GMFmXYUiGI581PdCx4bNba/4gYQnc/eqQ8oX0T//2RdRqdhdDM2d7
|
||||
CYALtkxKetH1F+Rz7XDjFmI3GjPs1KwVdh+Cl8kejThi0SVxXpqnoqB2WGsr/lGG
|
||||
GxsxcpLb/+KWFjI0go3OJjMaxFCmhB0pGdW8I7kNwNrZsCdSvmjMDojNuegx6WMg
|
||||
/T7go3CvAgMBAAECggEAQA3AlmSDtr+lNDvZ7voKwwN6W6qPmRJpevZQG54u4iPA
|
||||
/5mAA/kRSqnh77mLPRb+RkU6RCeX3IXVXNIEGhKugZiHE5Sx4FfxmrAFzR8buXHg
|
||||
uXoeJOdPXiiFtilIh6u/y1FNE4YbUnud/fthgYdU8Zl/2x2KOMWtFj0l94tmhzOI
|
||||
b2y8/U8r85anI5XGYuzRCqKS1WskXhkXH8LZUB+9yAxX7V5ysgxjofM4FW8ns7yj
|
||||
K4cBS8KY2v3t7TZ4FgwkAhPcTfBc/E2UWT1Ztmr+18LFV5bqI8g2YlN+BgCxU7U/
|
||||
1tawxqFhs+xowEpzNwAvjAIPpptIRiY1rz7sBB9g5QKBgQDLo/5rTUwNOPR9dYvA
|
||||
+DYUSCfxvNamI4GI66AgwOeN8O+W+dRDF/Ewbk/SJsBPSLIYzEiQ2uYKcNEmIjo+
|
||||
7WwSCJZjKujovw77s9JAHexhpd8uLD2w9l3KeTg41LEYm2uVwoXWEHYSYJ9Ynz0M
|
||||
PWxvi2Hm0IoQ7gJIfxng/wIw3QKBgQDb6GFvPH/OTs40+dopwtm3irmkBAmT8N0b
|
||||
3TpehONCOiL4GPxmn2DN6ELhHFV27Jj/1CfpGVbcBlaS1xYUGUGsB9gYukhdaBST
|
||||
KGHRoeZDcf0gaQLKG15EEfFOvcKI9aGljV8FdFfG+Z4fW3LA8khvpvjLLkv1A1jM
|
||||
MrEBthco+wKBgD45EM9GohtUMNh450gCT7voxFPICKphJP5qSNZZOyeS3BJ8qdAK
|
||||
a8cJndgvwQk4xDpxiSbBzBKaoD2Prc52i1QDTbhlbx9W6cQdEPxIaGb54PThzcPZ
|
||||
s5Tfbz9mNeq36qqq8mwTQZCh926D0YqA5jY7F6IITHeZ0hbGx2iJYuj9AoGARIyK
|
||||
ms8kE95y3wanX+8ySMmAlsT/a1NgyUfL4xzPbpyKvAWl4CN8XJMzDdL0PS8BfnXW
|
||||
vw28CrgbEojjg/5ff02uqf6fgiZoi3rCC0PJcGq++fRh/zhKyTNCokX6txDCg8Wu
|
||||
wheDKS40gRfTjJu5wrwsv8E9wjF546VFkf/99jMCgYEAm/x+kEfWKuzx8pQT66TY
|
||||
pxnC41upJOO1htTHNIN24J7XrrFI5+OZq90G+t/VgWX08Z8RlhejX+ukBf+SRu3u
|
||||
5VMGcAs4px+iECX/FHo21YQFnrmArN1zdFxPU3rBWoBueqmGO6FT0HBbKzTuS7N0
|
||||
7fIv3GQqImz3+ZbYWlXfkPI=
|
||||
-----END PRIVATE KEY-----`
|
||||
return parseRSAKey(t, rsaText)
|
||||
}
|
||||
|
||||
func getWeakRSAKey(t *testing.T) *rsa.PrivateKey {
|
||||
const rsaText = `-----BEGIN PRIVATE KEY-----
|
||||
MIIBUwIBADANBgkqhkiG9w0BAQEFAASCAT0wggE5AgEAAkEAsQo3ATJAZ4aAZz+l
|
||||
ndXl27ODOY+49DjYxwhgtg+OU8A1WEYCfWaZ7ozYtpsqH8GNFvlKtK38eKbdDuLw
|
||||
gsFYMQIDAQABAkBwstb2/P1Aqb9deoe8JOiw5eJYJySO2w0sDio6W0a4Cqi7XQ7r
|
||||
/yZ1gOp+ZnShX/sJq0Pd16UkJUUEtEPoZyptAiEA4KLP8pz/9R0t7Envqph1oVjQ
|
||||
CVDIL/UKRmdnMiwwDosCIQDJwiu08UgNNeliAygbkC2cdszjf4a3laGmYbfWrtAn
|
||||
swIgUBfc+w0degDgadpm2LWpY1DuRBQIfIjrE/U0Z0A4FkcCIHxEuoLycjygziTu
|
||||
aM/BWDac/cnKDIIbCbvfSEpU1iT9AiBsbkAcYCQ8mR77BX6gZKEc74nSce29gmR7
|
||||
mtrKWknTDQ==
|
||||
-----END PRIVATE KEY-----`
|
||||
return parseRSAKey(t, rsaText)
|
||||
}
|
||||
|
||||
func TestParseSignature(t *testing.T) {
|
||||
// Domain name must always be A-labels, not U-labels. We do allow localpart with non-ascii.
|
||||
hdr := `DKIM-Signature: v=1; a=rsa-sha256; d=xn--h-bga.mox.example; s=xn--yr2021-pua;
|
||||
i=møx@xn--h-bga.mox.example; t=1643719203; h=From:To:Cc:Bcc:Reply-To:
|
||||
References:In-Reply-To:Subject:Date:Message-ID:Content-Type:From:To:Subject:
|
||||
Date:Message-ID:Content-Type;
|
||||
bh=g3zLYH4xKxcPrHOD18z9YfpQcnk/GaJedfustWU5uGs=; b=dtgAOl71h/dNPQrmZTi3SBVkm+
|
||||
EjMnF7sWGT123fa5g+m6nGpPue+I+067wwtkWQhsedbDkqT7gZb5WaG5baZsr9e/XpJ/iX4g6YXpr
|
||||
07aLY8eF9jazcGcRCVCqLtyq0UJQ2Oz/ML74aYu1beh3jXsoI+k3fJ+0/gKSVC7enCFpNe1HhbXVS
|
||||
4HRy/Rw261OEIy2e20lyPT4iDk2oODabzYa28HnXIciIMELjbc/sSawG68SAnhwdkWBrRzBDMCCHm
|
||||
wvkmgDsVJWtdzjJqjxK2mYVxBMJT0lvsutXgYQ+rr6BLtjHsOb8GMSbQGzY5SJ3N8TP02pw5OykBu
|
||||
B/aHff1A==
|
||||
`
|
||||
smtputf8 := true
|
||||
_, _, err := parseSignature([]byte(strings.ReplaceAll(hdr, "\n", "\r\n")), smtputf8)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing signature: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRSA(t *testing.T) {
|
||||
message := strings.ReplaceAll(`Return-Path: <mechiel@ueber.net>
|
||||
X-Original-To: mechiel@ueber.net
|
||||
Delivered-To: mechiel@ueber.net
|
||||
Received: from [IPV6:2a02:a210:4a3:b80:ca31:30ee:74a7:56e0] (unknown [IPv6:2a02:a210:4a3:b80:ca31:30ee:74a7:56e0])
|
||||
by koriander.ueber.net (Postfix) with ESMTPSA id E119EDEB0B
|
||||
for <mechiel@ueber.net>; Fri, 10 Dec 2021 20:09:08 +0100 (CET)
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=simple/simple; d=ueber.net;
|
||||
s=koriander; t=1639163348;
|
||||
bh=g3zLYH4xKxcPrHOD18z9YfpQcnk/GaJedfustWU5uGs=;
|
||||
h=Date:To:From:Subject:From;
|
||||
b=rpWruWprs2TB7/MnulA2n2WtfUIfrrnAvRoSrip1ruX5ORN4AOYPPMmk/gGBDdc6O
|
||||
grRpSsNzR9BrWcooYfbNfSbl04nPKMp0acsZGfpvkj0+mqk5b8lqZs3vncG1fHlQc7
|
||||
0KXfnAHyEs7bjyKGbrw2XG1p/EDoBjIjUsdpdCAtamMGv3A3irof81oSqvwvi2KQks
|
||||
17aB1YAL9Xzkq9ipo1aWvDf2W6h6qH94YyNocyZSVJ+SlVm3InNaF8APkV85wOm19U
|
||||
9OW81eeuQbvSPcQZJVOmrWzp7XKHaXH0MYE3+hdH/2VtpCnPbh5Zj9SaIgVbaN6NPG
|
||||
Ua0E07rwC86sg==
|
||||
Message-ID: <427999f6-114f-e59c-631e-ab2a5f6bfe4c@ueber.net>
|
||||
Date: Fri, 10 Dec 2021 20:09:08 +0100
|
||||
MIME-Version: 1.0
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
|
||||
Thunderbird/91.4.0
|
||||
Content-Language: nl
|
||||
To: mechiel@ueber.net
|
||||
From: Mechiel Lukkien <mechiel@ueber.net>
|
||||
Subject: test
|
||||
Content-Type: text/plain; charset=UTF-8; format=flowed
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
test
|
||||
`, "\n", "\r\n")
|
||||
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"koriander._domainkey.ueber.net.": {"v=DKIM1; k=rsa; s=email; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy3Z9ffZe8gUTJrdGuKj6IwEembmKYpp0jMa8uhudErcI4gFVUaFiiRWxc4jP/XR9NAEv3XwHm+CVcHu+L/n6VWt6g59U7vHXQicMfKGmEp2VplsgojNy/Y5X9HdVYM0azsI47NcJCDW9UVfeOHdOSgFME4F8dNtUKC4KTB2d1pqj/yixz+V8Sv8xkEyPfSRHcNXIw0LvelqJ1MRfN3hO/3uQSVrPYYk4SyV0b6wfnkQs28fpiIpGQvzlGI5WkrdOQT5k4YHaEvZDLNdwiMeVZOEL7dDoFs2mQsovm+tH0StUAZTnr61NLVFfD5V6Ip1V9zVtspPHvYSuOWwyArFZ9QIDAQAB"},
|
||||
},
|
||||
}
|
||||
|
||||
results, err := Verify(context.Background(), resolver, false, policyOK, strings.NewReader(message), false)
|
||||
if err != nil {
|
||||
t.Fatalf("dkim verify: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Status != StatusPass {
|
||||
t.Fatalf("verify: unexpected results %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEd25519(t *testing.T) {
|
||||
// ../rfc/8463:287
|
||||
message := strings.ReplaceAll(`DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
|
||||
d=football.example.com; i=@football.example.com;
|
||||
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
|
||||
subject : date : message-id : from : subject : date;
|
||||
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
|
||||
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
|
||||
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
|
||||
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
|
||||
d=football.example.com; i=@football.example.com;
|
||||
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
|
||||
date : message-id : from : subject : date;
|
||||
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
|
||||
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
|
||||
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
|
||||
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
|
||||
From: Joe SixPack <joe@football.example.com>
|
||||
To: Suzie Q <suzie@shopping.example.net>
|
||||
Subject: Is dinner ready?
|
||||
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
|
||||
Message-ID: <20030712040037.46341.5F8J@football.example.com>
|
||||
|
||||
Hi.
|
||||
|
||||
We lost the game. Are you hungry yet?
|
||||
|
||||
Joe.
|
||||
|
||||
`, "\n", "\r\n")
|
||||
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"brisbane._domainkey.football.example.com.": {"v=DKIM1; k=ed25519; p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="},
|
||||
"test._domainkey.football.example.com.": {"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB"},
|
||||
},
|
||||
}
|
||||
|
||||
results, err := Verify(context.Background(), resolver, false, policyOK, strings.NewReader(message), false)
|
||||
if err != nil {
|
||||
t.Fatalf("dkim verify: %v", err)
|
||||
}
|
||||
if len(results) != 2 || results[0].Status != StatusPass || results[1].Status != StatusPass {
|
||||
t.Fatalf("verify: unexpected results %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign(t *testing.T) {
|
||||
message := strings.ReplaceAll(`Message-ID: <427999f6-114f-e59c-631e-ab2a5f6bfe4c@ueber.net>
|
||||
Date: Fri, 10 Dec 2021 20:09:08 +0100
|
||||
MIME-Version: 1.0
|
||||
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
|
||||
Thunderbird/91.4.0
|
||||
Content-Language: nl
|
||||
To: mechiel@ueber.net
|
||||
From: Mechiel Lukkien <mechiel@ueber.net>
|
||||
Subject: test
|
||||
test
|
||||
Content-Type: text/plain; charset=UTF-8; format=flowed
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
test
|
||||
`, "\n", "\r\n")
|
||||
|
||||
rsaKey := getRSAKey(t)
|
||||
ed25519Key := ed25519.NewKeyFromSeed(make([]byte, 32))
|
||||
|
||||
selrsa := config.Selector{
|
||||
HashEffective: "sha256",
|
||||
Key: rsaKey,
|
||||
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
|
||||
Domain: dns.Domain{ASCII: "testrsa"},
|
||||
}
|
||||
|
||||
// Now with sha1 and relaxed canonicalization.
|
||||
selrsa2 := config.Selector{
|
||||
HashEffective: "sha1",
|
||||
Key: rsaKey,
|
||||
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
|
||||
Domain: dns.Domain{ASCII: "testrsa2"},
|
||||
}
|
||||
selrsa2.Canonicalization.HeaderRelaxed = true
|
||||
selrsa2.Canonicalization.BodyRelaxed = true
|
||||
|
||||
// Ed25519 key.
|
||||
seled25519 := config.Selector{
|
||||
HashEffective: "sha256",
|
||||
Key: ed25519Key,
|
||||
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
|
||||
Domain: dns.Domain{ASCII: "tested25519"},
|
||||
}
|
||||
// Again ed25519, but without sealing headers. Use sha256 again, for reusing the body hash from the previous dkim-signature.
|
||||
seled25519b := config.Selector{
|
||||
HashEffective: "sha256",
|
||||
Key: ed25519Key,
|
||||
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,Subject,Date", ","),
|
||||
DontSealHeaders: true,
|
||||
Domain: dns.Domain{ASCII: "tested25519b"},
|
||||
}
|
||||
dkimConf := config.DKIM{
|
||||
Selectors: map[string]config.Selector{
|
||||
"testrsa": selrsa,
|
||||
"testrsa2": selrsa2,
|
||||
"tested25519": seled25519,
|
||||
"tested25519b": seled25519b,
|
||||
},
|
||||
Sign: []string{"testrsa", "testrsa2", "tested25519", "tested25519b"},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
headers, err := Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader(message))
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
|
||||
makeRecord := func(k string, publicKey any) string {
|
||||
tr := &Record{
|
||||
Version: "DKIM1",
|
||||
Key: k,
|
||||
PublicKey: publicKey,
|
||||
Flags: []string{"s"},
|
||||
}
|
||||
txt, err := tr.Record()
|
||||
if err != nil {
|
||||
t.Fatalf("making dns txt record: %s", err)
|
||||
}
|
||||
//log.Infof("txt record: %s", txt)
|
||||
return txt
|
||||
}
|
||||
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"testrsa._domainkey.mox.example.": {makeRecord("rsa", rsaKey.Public())},
|
||||
"testrsa2._domainkey.mox.example.": {makeRecord("rsa", rsaKey.Public())},
|
||||
"tested25519._domainkey.mox.example.": {makeRecord("ed25519", ed25519Key.Public())},
|
||||
"tested25519b._domainkey.mox.example.": {makeRecord("ed25519", ed25519Key.Public())},
|
||||
},
|
||||
}
|
||||
|
||||
nmsg := headers + message
|
||||
|
||||
results, err := Verify(ctx, resolver, false, policyOK, strings.NewReader(nmsg), false)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %s", err)
|
||||
}
|
||||
if len(results) != 4 || results[0].Status != StatusPass || results[1].Status != StatusPass || results[2].Status != StatusPass || results[3].Status != StatusPass {
|
||||
t.Fatalf("verify: unexpected results %v\nheaders:\n%s", results, headers)
|
||||
}
|
||||
//log.Infof("headers:%s", headers)
|
||||
//log.Infof("nmsg\n%s", nmsg)
|
||||
|
||||
// Multiple From headers.
|
||||
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("From: <mjl@mox.example>\r\nFrom: <mjl@mox.example>\r\n\r\ntest"))
|
||||
if !errors.Is(err, ErrFrom) {
|
||||
t.Fatalf("sign, got err %v, expected ErrFrom", err)
|
||||
}
|
||||
|
||||
// No From header.
|
||||
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("Brom: <mjl@mox.example>\r\n\r\ntest"))
|
||||
if !errors.Is(err, ErrFrom) {
|
||||
t.Fatalf("sign, got err %v, expected ErrFrom", err)
|
||||
}
|
||||
|
||||
// Malformed headers.
|
||||
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader(":\r\n\r\ntest"))
|
||||
if !errors.Is(err, ErrHeaderMalformed) {
|
||||
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
|
||||
}
|
||||
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader(" From:<mjl@mox.example>\r\n\r\ntest"))
|
||||
if !errors.Is(err, ErrHeaderMalformed) {
|
||||
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
|
||||
}
|
||||
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("Frøm:<mjl@mox.example>\r\n\r\ntest"))
|
||||
if !errors.Is(err, ErrHeaderMalformed) {
|
||||
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
|
||||
}
|
||||
_, err = Sign(ctx, "mjl", dns.Domain{ASCII: "mox.example"}, dkimConf, false, strings.NewReader("From:<mjl@mox.example>"))
|
||||
if !errors.Is(err, ErrHeaderMalformed) {
|
||||
t.Fatalf("sign, got err %v, expected ErrHeaderMalformed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
// We do many Verify calls, each time starting out with a valid configuration, then
|
||||
// we modify one thing to trigger an error, which we check for.
|
||||
|
||||
const message = `From: <mjl@mox.example>
|
||||
To: <other@mox.example>
|
||||
Subject: test
|
||||
Date: Fri, 10 Dec 2021 20:09:08 +0100
|
||||
Message-ID: <test@mox.example>
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8; format=flowed
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
test
|
||||
`
|
||||
|
||||
key := ed25519.NewKeyFromSeed(make([]byte, 32))
|
||||
var resolver dns.MockResolver
|
||||
var record *Record
|
||||
var recordTxt string
|
||||
var msg string
|
||||
var sel config.Selector
|
||||
var dkimConf config.DKIM
|
||||
var policy func(*Sig) error
|
||||
var signed bool
|
||||
var signDomain dns.Domain
|
||||
|
||||
prepare := func() {
|
||||
t.Helper()
|
||||
|
||||
policy = DefaultPolicy
|
||||
signDomain = dns.Domain{ASCII: "mox.example"}
|
||||
|
||||
record = &Record{
|
||||
Version: "DKIM1",
|
||||
Key: "ed25519",
|
||||
PublicKey: key.Public(),
|
||||
Flags: []string{"s"},
|
||||
}
|
||||
|
||||
txt, err := record.Record()
|
||||
if err != nil {
|
||||
t.Fatalf("making dns txt record: %s", err)
|
||||
}
|
||||
recordTxt = txt
|
||||
|
||||
resolver = dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"test._domainkey.mox.example.": {txt},
|
||||
},
|
||||
}
|
||||
|
||||
sel = config.Selector{
|
||||
HashEffective: "sha256",
|
||||
Key: key,
|
||||
HeadersEffective: strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-ID,Content-Type", ","),
|
||||
Domain: dns.Domain{ASCII: "test"},
|
||||
}
|
||||
dkimConf = config.DKIM{
|
||||
Selectors: map[string]config.Selector{
|
||||
"test": sel,
|
||||
},
|
||||
Sign: []string{"test"},
|
||||
}
|
||||
|
||||
msg = message
|
||||
signed = false
|
||||
}
|
||||
|
||||
sign := func() {
|
||||
t.Helper()
|
||||
|
||||
msg = strings.ReplaceAll(msg, "\n", "\r\n")
|
||||
|
||||
headers, err := Sign(context.Background(), "mjl", signDomain, dkimConf, false, strings.NewReader(msg))
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
msg = headers + msg
|
||||
signed = true
|
||||
}
|
||||
|
||||
test := func(expErr error, expStatus Status, expResultErr error, mod func()) {
|
||||
t.Helper()
|
||||
|
||||
prepare()
|
||||
mod()
|
||||
if !signed {
|
||||
sign()
|
||||
}
|
||||
|
||||
results, err := Verify(context.Background(), resolver, true, policy, strings.NewReader(msg), false)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("got verify error %v, expected %v", err, expErr)
|
||||
}
|
||||
if expStatus != "" && (len(results) == 0 || results[0].Status != expStatus) {
|
||||
var status Status
|
||||
if len(results) > 0 {
|
||||
status = results[0].Status
|
||||
}
|
||||
t.Fatalf("got status %q, expected %q", status, expStatus)
|
||||
}
|
||||
var resultErr error
|
||||
if len(results) > 0 {
|
||||
resultErr = results[0].Err
|
||||
}
|
||||
if (resultErr == nil) != (expResultErr == nil) || resultErr != nil && !errors.Is(resultErr, expResultErr) {
|
||||
t.Fatalf("got result error %v, expected %v", resultErr, expResultErr)
|
||||
}
|
||||
}
|
||||
|
||||
test(nil, StatusPass, nil, func() {})
|
||||
|
||||
// Cannot parse message, so not much more to do.
|
||||
test(ErrHeaderMalformed, "", nil, func() {
|
||||
sign()
|
||||
msg = ":\r\n\r\n" // Empty header key.
|
||||
})
|
||||
|
||||
// From Lookup.
|
||||
// No DKIM record. ../rfc/6376:2608
|
||||
test(nil, StatusPermerror, ErrNoRecord, func() {
|
||||
resolver.TXT = nil
|
||||
})
|
||||
// DNS request is failing temporarily.
|
||||
test(nil, StatusTemperror, ErrDNS, func() {
|
||||
resolver.Fail = map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "test._domainkey.mox.example."}: {},
|
||||
}
|
||||
})
|
||||
// Claims to be DKIM through v=, but cannot be parsed. ../rfc/6376:2621
|
||||
test(nil, StatusPermerror, ErrSyntax, func() {
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {"v=DKIM1; bogus"},
|
||||
}
|
||||
})
|
||||
// Not a DKIM record. ../rfc/6376:2621
|
||||
test(nil, StatusTemperror, ErrSyntax, func() {
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {"bogus"},
|
||||
}
|
||||
})
|
||||
// Multiple dkim records. ../rfc/6376:1609
|
||||
test(nil, StatusTemperror, ErrMultipleRecords, func() {
|
||||
resolver.TXT["test._domainkey.mox.example."] = []string{recordTxt, recordTxt}
|
||||
})
|
||||
|
||||
// Invalid DKIM-Signature header. ../rfc/6376:2503
|
||||
test(nil, StatusPermerror, errSigMissingTag, func() {
|
||||
msg = strings.ReplaceAll("DKIM-Signature: v=1\n"+msg, "\n", "\r\n")
|
||||
signed = true
|
||||
})
|
||||
|
||||
// Signature has valid syntax, but parameters aren't acceptable.
|
||||
// "From" not signed. ../rfc/6376:2546
|
||||
test(nil, StatusPermerror, ErrFrom, func() {
|
||||
sign()
|
||||
// Remove "from" from signed headers (h=).
|
||||
msg = strings.ReplaceAll(msg, ":From:", ":")
|
||||
msg = strings.ReplaceAll(msg, "=From:", "=")
|
||||
})
|
||||
// todo: check expired signatures with StatusPermerror and ErrSigExpired. ../rfc/6376:2550
|
||||
// Domain in signature is higher-level than organizational domain. ../rfc/6376:2554
|
||||
test(nil, StatusPermerror, ErrTLD, func() {
|
||||
// Pretend to sign as .com
|
||||
msg = strings.ReplaceAll(msg, "From: <mjl@mox.example>\n", "From: <mjl@com>\n")
|
||||
signDomain = dns.Domain{ASCII: "com"}
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.com.": {recordTxt},
|
||||
}
|
||||
})
|
||||
// Unknown hash algorithm.
|
||||
test(nil, StatusPermerror, ErrHashAlgorithmUnknown, func() {
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "sha256", "sha257")
|
||||
})
|
||||
// Unknown canonicalization.
|
||||
test(nil, StatusPermerror, ErrCanonicalizationUnknown, func() {
|
||||
sel.Canonicalization.HeaderRelaxed = true
|
||||
sel.Canonicalization.BodyRelaxed = true
|
||||
dkimConf.Selectors = map[string]config.Selector{
|
||||
"test": sel,
|
||||
}
|
||||
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "relaxed/relaxed", "bogus/bogus")
|
||||
})
|
||||
// Query methods without dns/txt. ../rfc/6376:1268
|
||||
test(nil, StatusPermerror, ErrQueryMethod, func() {
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "DKIM-Signature: ", "DKIM-Signature: q=other;")
|
||||
})
|
||||
|
||||
// Unacceptable through policy. ../rfc/6376:2560
|
||||
test(nil, StatusPolicy, ErrPolicy, func() {
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "DKIM-Signature: ", "DKIM-Signature: l=1;")
|
||||
})
|
||||
// Hash algorithm not allowed by DNS record. ../rfc/6376:2639
|
||||
test(nil, StatusPermerror, ErrHashAlgNotAllowed, func() {
|
||||
recordTxt += ";h=sha1"
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {recordTxt},
|
||||
}
|
||||
})
|
||||
// Signature algorithm mismatch. ../rfc/6376:2651
|
||||
test(nil, StatusPermerror, ErrSigAlgMismatch, func() {
|
||||
record.PublicKey = getRSAKey(t).Public()
|
||||
record.Key = "rsa"
|
||||
txt, err := record.Record()
|
||||
if err != nil {
|
||||
t.Fatalf("making dns txt record: %s", err)
|
||||
}
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {txt},
|
||||
}
|
||||
})
|
||||
// Empty public key means revoked key. ../rfc/6376:2645
|
||||
test(nil, StatusPermerror, ErrKeyRevoked, func() {
|
||||
record.PublicKey = nil
|
||||
txt, err := record.Record()
|
||||
if err != nil {
|
||||
t.Fatalf("making dns txt record: %s", err)
|
||||
}
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {txt},
|
||||
}
|
||||
})
|
||||
// We refuse rsa keys smaller than 1024 bits.
|
||||
test(nil, StatusPermerror, ErrWeakKey, func() {
|
||||
key := getWeakRSAKey(t)
|
||||
record.Key = "rsa"
|
||||
record.PublicKey = key.Public()
|
||||
txt, err := record.Record()
|
||||
if err != nil {
|
||||
t.Fatalf("making dns txt record: %s", err)
|
||||
}
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {txt},
|
||||
}
|
||||
sel.Key = key
|
||||
dkimConf.Selectors = map[string]config.Selector{
|
||||
"test": sel,
|
||||
}
|
||||
})
|
||||
// Key not allowed for email by DNS record. ../rfc/6376:1541
|
||||
test(nil, StatusPermerror, ErrKeyNotForEmail, func() {
|
||||
recordTxt += ";s=other"
|
||||
resolver.TXT = map[string][]string{
|
||||
"test._domainkey.mox.example.": {recordTxt},
|
||||
}
|
||||
})
|
||||
// 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
|
||||
// Wrong signature, different datahash, and thus signature.
|
||||
test(nil, StatusFail, ErrSigVerify, func() {
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "Subject: test\r\n", "Subject: modified header\r\n")
|
||||
})
|
||||
// Signature is correct for bodyhash, but the body has changed.
|
||||
test(nil, StatusFail, ErrBodyhashMismatch, func() {
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "\r\ntest\r\n", "\r\nmodified body\r\n")
|
||||
})
|
||||
|
||||
// Check that last-occurring header field is used.
|
||||
test(nil, StatusFail, ErrSigVerify, func() {
|
||||
sel.DontSealHeaders = true
|
||||
dkimConf.Selectors = map[string]config.Selector{
|
||||
"test": sel,
|
||||
}
|
||||
sign()
|
||||
msg = strings.ReplaceAll(msg, "\r\n\r\n", "\r\nsubject: another\r\n\r\n")
|
||||
})
|
||||
test(nil, StatusPass, nil, func() {
|
||||
sel.DontSealHeaders = true
|
||||
dkimConf.Selectors = map[string]config.Selector{
|
||||
"test": sel,
|
||||
}
|
||||
sign()
|
||||
msg = "subject: another\r\n" + msg
|
||||
})
|
||||
}
|
||||
|
||||
func TestBodyHash(t *testing.T) {
|
||||
simpleGot, err := bodyHash(crypto.SHA256.New(), true, bufio.NewReader(strings.NewReader("")))
|
||||
if err != nil {
|
||||
t.Fatalf("body hash, simple, empty string: %s", err)
|
||||
}
|
||||
simpleWant := base64Decode("frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=")
|
||||
if !bytes.Equal(simpleGot, simpleWant) {
|
||||
t.Fatalf("simple body hash for empty string, got %s, expected %s", base64Encode(simpleGot), base64Encode(simpleWant))
|
||||
}
|
||||
|
||||
relaxedGot, err := bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader("")))
|
||||
if err != nil {
|
||||
t.Fatalf("body hash, relaxed, empty string: %s", err)
|
||||
}
|
||||
relaxedWant := base64Decode("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=")
|
||||
if !bytes.Equal(relaxedGot, relaxedWant) {
|
||||
t.Fatalf("relaxed body hash for empty string, got %s, expected %s", base64Encode(relaxedGot), base64Encode(relaxedWant))
|
||||
}
|
||||
|
||||
compare := func(a, b []byte) {
|
||||
t.Helper()
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Fatalf("hash not equal")
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: the trailing space in the strings below are part of the test for canonicalization.
|
||||
|
||||
// ../rfc/6376:936
|
||||
exampleIn := strings.ReplaceAll(` c
|
||||
d e
|
||||
|
||||
|
||||
`, "\n", "\r\n")
|
||||
relaxedOut := strings.ReplaceAll(` c
|
||||
d e
|
||||
`, "\n", "\r\n")
|
||||
relaxedBh, err := bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader(exampleIn)))
|
||||
if err != nil {
|
||||
t.Fatalf("bodyhash: %s", err)
|
||||
}
|
||||
relaxedOutHash := sha256.Sum256([]byte(relaxedOut))
|
||||
compare(relaxedBh, relaxedOutHash[:])
|
||||
|
||||
simpleOut := strings.ReplaceAll(` c
|
||||
d e
|
||||
`, "\n", "\r\n")
|
||||
simpleBh, err := bodyHash(crypto.SHA256.New(), true, bufio.NewReader(strings.NewReader(exampleIn)))
|
||||
if err != nil {
|
||||
t.Fatalf("bodyhash: %s", err)
|
||||
}
|
||||
simpleOutHash := sha256.Sum256([]byte(simpleOut))
|
||||
compare(simpleBh, simpleOutHash[:])
|
||||
|
||||
// ../rfc/8463:343
|
||||
relaxedBody := strings.ReplaceAll(`Hi.
|
||||
|
||||
We lost the game. Are you hungry yet?
|
||||
|
||||
Joe.
|
||||
|
||||
`, "\n", "\r\n")
|
||||
relaxedGot, err = bodyHash(crypto.SHA256.New(), false, bufio.NewReader(strings.NewReader(relaxedBody)))
|
||||
if err != nil {
|
||||
t.Fatalf("body hash, relaxed, ed25519 example: %s", err)
|
||||
}
|
||||
relaxedWant = base64Decode("2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=")
|
||||
if !bytes.Equal(relaxedGot, relaxedWant) {
|
||||
t.Fatalf("relaxed body hash for ed25519 example, got %s, expected %s", base64Encode(relaxedGot), base64Encode(relaxedWant))
|
||||
}
|
||||
}
|
||||
|
||||
func base64Decode(s string) []byte {
|
||||
buf, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func base64Encode(buf []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(buf)
|
||||
}
|
Reference in New Issue
Block a user