package dkim
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"errors"
"regexp"
"strings"
"testing"
)
var basicMessage = toCRLF(
`Received: from client1.football.example.com [192.0.2.1]
by submitserver.example.com with SUBMISSION;
Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
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.
`)
func TestSignRSA(t *testing.T) {
ctx := t.Context()
ctx = WithTraceFunc(ctx, t.Logf)
// Generate a new key pair.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
pub, err := x509.MarshalPKIXPublicKey(priv.Public())
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"test._domainkey.example.com": {
"v=DKIM1; p=" + base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
sig, err := s.Sign(ctx, basicMessage)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Verify the signature.
res, err := VerifyMessage(ctx, addSig(sig, basicMessage))
if err != nil || res.Valid != 1 {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
// Compare the reproducible parts against a known-good header.
want := regexp.MustCompile(
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n" +
"d=example.com; s=test; t=\\d+;\r\n" +
"h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n" +
"bh=[A-Za-z0-9+/]+=*;\r\n" +
"b=[A-Za-z0-9+/ \r\n]+=*;")
if !want.MatchString(sig) {
t.Errorf("Unexpected signature:")
t.Errorf(" Want: %q (regexp)", want)
t.Errorf(" Got: %q", sig)
}
}
func TestSignEd25519(t *testing.T) {
ctx := t.Context()
ctx = WithTraceFunc(ctx, t.Logf)
// Generate a new key pair.
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"test._domainkey.example.com": {
"v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
sig, err := s.Sign(ctx, basicMessage)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Verify the signature.
res, err := VerifyMessage(ctx, addSig(sig, basicMessage))
if err != nil || res.Valid != 1 {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
// Compare the reproducible parts against a known-good header.
want := regexp.MustCompile(
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n" +
"d=example.com; s=test; t=\\d+;\r\n" +
"h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n" +
"bh=[A-Za-z0-9+/]+=*;\r\n" +
"b=[A-Za-z0-9+/ \r\n]+=*;")
if !want.MatchString(sig) {
t.Errorf("Unexpected signature:")
t.Errorf(" Want: %q (regexp)", want)
t.Errorf(" Got: %q", sig)
}
}
func TestSignManyHeaders(t *testing.T) {
// Regression test for signing being quadratic on the number of instances
// of a header. This takes milliseconds, while the quadratic
// implementation took minutes with this input. We don't check the
// timing, so a regression will show up as this test being very slow.
const n = 50_000
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
message := "From: from@example.com\r\n" +
strings.Repeat("To: to@example.com\r\n", n) +
"\r\nHi.\r\n"
sig, err := s.Sign(t.Context(), message)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// All instances must be in h=, plus the extra one we always add.
hTag := regexp.MustCompile(`h=([^;]*);`).FindStringSubmatch(sig)
if hTag == nil {
t.Fatalf("h= not found in signature: %q", sig)
}
if got := strings.Count(hTag[1], "to"); got != n+1 {
t.Errorf("wanted %d instances of 'to' in h=, got %d", n+1, got)
}
}
func TestSignRepeatedHeaders(t *testing.T) {
// When a signed header appears multiple times (with different values),
// they must be signed from bottom to top, otherwise the signature will
// not verify.
ctx := t.Context()
ctx = WithTraceFunc(ctx, t.Logf)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"test._domainkey.example.com": {
"v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
message := toCRLF(`From: from@example.com
To: to1@example.com
Subject: Subject 1
To: to2@example.com
Subject: Subject 2
To: to3@example.com
Hi.
`)
sig, err := s.Sign(ctx, message)
if err != nil {
t.Fatalf("Sign: %v", err)
}
res, err := VerifyMessage(ctx, addSig(sig, message))
if err != nil || res.Valid != 1 {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
}
func TestSignNonASCIIHeaderNames(t *testing.T) {
// Header names with non-ASCII characters are invalid, but we must still
// sign them consistently with how we verify them.
// Here, "\u017fubject" (U+017F is a long s) is not the same as
// "Subject", and should not be signed as such.
ctx := t.Context()
ctx = WithTraceFunc(ctx, t.Logf)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"test._domainkey.example.com": {
"v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
message := toCRLF("From: from@example.com\n" +
"\u017fubject: Not a subject\n" +
"\n" +
"Hi.\n")
sig, err := s.Sign(ctx, message)
if err != nil {
t.Fatalf("Sign: %v", err)
}
res, err := VerifyMessage(ctx, addSig(sig, message))
if err != nil || res.Valid != 1 {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
}
func addSig(sig, message string) string {
return "DKIM-Signature: " +
strings.ReplaceAll(sig, "\r\n", "\r\n\t") +
"\r\n" + message
}
func TestSignBadMessage(t *testing.T) {
s := &Signer{
Domain: "example.com",
Selector: "test",
}
_, err := s.Sign(t.Context(), "Bad message")
if err == nil {
t.Errorf("Sign: wanted error; got nil")
}
}
func TestSignBadAlgorithm(t *testing.T) {
s := &Signer{
Domain: "example.com",
Selector: "test",
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
s.Signer = priv
_, err = s.Sign(t.Context(), basicMessage)
if !errors.Is(err, errUnsupportedKeyType) {
t.Errorf("Sign: wanted unsupported key type; got %v", err)
}
}
func TestBreakLongLines(t *testing.T) {
cases := []struct {
in string
want string
}{
{"1234567890", "1234567890"},
{
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70",
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70",
},
{
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70123",
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70\r\n 123",
},
{
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70xxxxxxxx80" +
"xxxxxxxx90xxxxxxx100xxxxxxx110xxxxxxx120" +
"xxxxxxx130xxxxxxx140xxxxxxx150xxxxxxx160",
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70\r\n " +
"xxxxxxxx80xxxxxxxx90xxxxxxx100xxxxxxx110" +
"xxxxxxx120xxxxxxx130xxxxxxx140\r\n " +
"xxxxxxx150xxxxxxx160",
},
}
for i, c := range cases {
got := breakLongLines(c.in)
if got != c.want {
t.Errorf("%d: breakLongLines(%q):", i, c.in)
t.Errorf(" want %q", c.want)
t.Errorf(" got %q", got)
}
}
}
func TestFormatHeaders(t *testing.T) {
cases := []struct {
in []string
want string
}{
{[]string{"From"}, "from"},
{
[]string{"From", "Subject", "Date"},
"from:subject:date",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "in-reply-to",
"message-id"},
"from:subject:date:to:message-id:" +
"from:subject:date:to:cc:in-reply-to\r\n" +
" :message-id",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "xxxxxxxxxxxx70"},
"from:subject:date:to:message-id:" +
"from:subject:date:to:cc:xxxxxxxxxxxx70",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "xxxxxxxxxxxx701"},
"from:subject:date:to:message-id:from:subject:date:to:cc\r\n" +
" :xxxxxxxxxxxx701",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "xxxxxxxxxxxx70",
"1"},
"from:subject:date:to:message-id:" +
"from:subject:date:to:cc:xxxxxxxxxxxx70\r\n" +
" :1",
},
}
for i, c := range cases {
got := formatHeaders(c.in)
if got != c.want {
t.Errorf("%d: formatHeaders(%q):", i, c.in)
t.Errorf(" want %q", c.want)
t.Errorf(" got %q", got)
}
}
}
func FuzzSignAndVerify(f *testing.F) {
f.Add(basicMessage)
f.Add("From: a@b\r\n\r\n")
f.Add("")
// Repeated headers with different values.
f.Add("From: a@b\r\nTo: c@d\r\n e@f\r\nTo: g@h\r\n\r\nbody\r\n")
// Non-ASCII header name (U+017F is a long s).
f.Add("From: a@b\r\nſubject: x\r\n\r\nbody\r\n")
// Messages from the file-based tests, which have more realistic headers,
// including existing DKIM signatures.
addTestdataCorpus(f)
// Use a fixed key, as generating one for each run would be too slow.
priv := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize))
pub := priv.Public().(ed25519.PublicKey)
ctx := WithLookupTXTFunc(context.Background(),
makeLookupTXT(map[string][]string{
"test._domainkey.example.com": {
"v=DKIM1; k=ed25519; p=" +
base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
f.Fuzz(func(t *testing.T, msg string) {
sig, err := s.Sign(ctx, msg)
if err != nil {
return
}
// Any message we can sign, we must be able to verify.
// Our signature is the first one, so it's always verified even if
// the message has other signatures.
res, err := VerifyMessage(ctx, addSig(sig, msg))
if err != nil {
t.Fatalf("VerifyMessage error: %v", err)
}
if len(res.Results) == 0 {
t.Fatalf("signature not found when verifying")
}
if r := res.Results[0]; r.State != SUCCESS {
t.Errorf("signature did not verify: %v / %v", r.State, r.Error)
}
})
}