mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
5b20cba50a
we don't want external software to include internal details like mlog. slog.Logger is/will be the standard. we still have mlog for its helper functions, and its handler that logs in concise logfmt used by mox. packages that are not meant for reuse still pass around mlog.Log for convenience. we use golang.org/x/exp/slog because we also support the previous Go toolchain version. with the next Go release, we'll switch to the builtin slog.
705 lines
24 KiB
Go
705 lines
24 KiB
Go
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"
|
|
"github.com/mjl-/mox/mlog"
|
|
)
|
|
|
|
var pkglog = mlog.New("dkim", nil)
|
|
|
|
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(), pkglog.Logger, 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(), pkglog.Logger, 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, pkglog.Logger, "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, pkglog.Logger, 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, pkglog.Logger, "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, pkglog.Logger, "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, pkglog.Logger, "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, pkglog.Logger, "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, pkglog.Logger, "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, pkglog.Logger, "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(), pkglog.Logger, "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(), pkglog.Logger, 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 = []string{
|
|
"txt 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)
|
|
}
|