mirror of
https://blitiri.com.ar/repos/chasquid
synced 2026-10-28 15:02:28 +00:00
0b0e520692
When verifying a signature we need to strip the value of the b= tag. Today, we do this with a regexp that can accidentally match other tag values. For example, "z=Subject:club=20". This can cause valid emails to fail verification. But it can't cause an invalid email to pass verification, so it's not a significant security concern. To fix this, this patch updates the regular expression to match the tag more precisely. It also adds a few tests to validate the behaviour is correct, which were cross-checked with other implementations. Thanks to John JEAN <john@wargan.com> for reporting this bug.
697 lines
21 KiB
Go
697 lines
21 KiB
Go
package dkim
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
)
|
|
|
|
func toCRLF(s string) string {
|
|
return strings.ReplaceAll(s, "\n", "\r\n")
|
|
}
|
|
|
|
func makeLookupTXT(results map[string][]string) lookupTXTFunc {
|
|
return func(ctx context.Context, domain string) ([]string, error) {
|
|
return results[domain], nil
|
|
}
|
|
}
|
|
|
|
func TestVerifyRF6376CExample(t *testing.T) {
|
|
ctx := t.Context()
|
|
ctx = WithTraceFunc(ctx, t.Logf)
|
|
|
|
// Use the public key from the example in RFC 6376 appendix C.
|
|
// https://datatracker.ietf.org/doc/html/rfc6376#appendix-C
|
|
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
|
|
"brisbane._domainkey.example.com": {
|
|
"v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" +
|
|
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" +
|
|
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" +
|
|
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" +
|
|
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
|
|
},
|
|
}))
|
|
|
|
// Note that the examples in the RFC text have multiple issues:
|
|
// - The double space in "game. Are" should be a single
|
|
// space. Otherwise, the body hash does not match.
|
|
// https://www.rfc-editor.org/errata/eid3192
|
|
// - The header indentation is incorrect. This causes
|
|
// signature validation failure (because the example uses simple
|
|
// canonicalization, which leaves the indentation untouched).
|
|
// https://www.rfc-editor.org/errata/eid4926
|
|
message := toCRLF(
|
|
`DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com;
|
|
c=simple/simple; q=dns/txt; i=joe@football.example.com;
|
|
h=Received : From : To : Subject : Date : Message-ID;
|
|
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
|
|
b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB
|
|
4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut
|
|
KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV
|
|
4bmp/YzhwvcubU4=;
|
|
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.
|
|
`)
|
|
|
|
res, err := VerifyMessage(ctx, message)
|
|
if res.Valid != 1 || err != nil {
|
|
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
|
|
}
|
|
|
|
// Extend the message, check it does not pass validation.
|
|
res, err = VerifyMessage(ctx, message+"Extra line.\r\n")
|
|
if res.Valid != 0 || err != nil {
|
|
t.Errorf("VerifyMessage: wanted 0 valid / nil; got %v / %v", res, err)
|
|
}
|
|
|
|
// Alter a header, check it does not pass validation.
|
|
res, err = VerifyMessage(ctx,
|
|
strings.Replace(message, "Subject", "X-Subject", 1))
|
|
if res.Valid != 0 || err != nil {
|
|
t.Errorf("VerifyMessage: wanted 0 valid / nil; got %v / %v", res, err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyRFC8463Example(t *testing.T) {
|
|
ctx := t.Context()
|
|
ctx = WithTraceFunc(ctx, t.Logf)
|
|
|
|
// Use the public keys from the example in RFC 8463 appendix A.2.
|
|
// https://datatracker.ietf.org/doc/html/rfc6376#appendix-C
|
|
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(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=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWR" +
|
|
"iGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/b" +
|
|
"yYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKr" +
|
|
"M3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K" +
|
|
"4w3QIDAQAB"},
|
|
}))
|
|
|
|
message := toCRLF(
|
|
`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.
|
|
`)
|
|
|
|
expected := &VerifyResult{
|
|
Found: 2,
|
|
Valid: 2,
|
|
Results: []*OneResult{
|
|
{
|
|
SignatureHeader: toCRLF(
|
|
` 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==`),
|
|
Domain: "football.example.com",
|
|
Selector: "brisbane",
|
|
B: "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11" +
|
|
"BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
|
State: SUCCESS,
|
|
Error: nil,
|
|
},
|
|
{
|
|
SignatureHeader: toCRLF(
|
|
` 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=`),
|
|
Domain: "football.example.com",
|
|
Selector: "test",
|
|
B: "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe" +
|
|
"3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefO" +
|
|
"sk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZ" +
|
|
"Q4FADY+8=",
|
|
State: SUCCESS,
|
|
Error: nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
res, err := VerifyMessage(ctx, message)
|
|
if err != nil {
|
|
t.Fatalf("VerifyMessage returned error: %v", err)
|
|
}
|
|
if diff := cmp.Diff(expected, res); diff != "" {
|
|
t.Errorf("VerifyMessage diff (-want +got):\n%s", diff)
|
|
}
|
|
|
|
// Extend the message, check it does not pass validation.
|
|
res, err = VerifyMessage(ctx, message+"Extra line.\r\n")
|
|
if res.Found != 2 || res.Valid != 0 || err != nil {
|
|
t.Errorf("VerifyMessage: wanted 2 found, 0 valid / nil; got %v / %v",
|
|
res, err)
|
|
}
|
|
|
|
// Alter a header, check it does not pass validation.
|
|
res, err = VerifyMessage(ctx,
|
|
strings.Replace(message, "Subject", "X-Subject", 1))
|
|
if res.Found != 2 || res.Valid != 0 || err != nil {
|
|
t.Errorf("VerifyMessage: wanted 2 found, 0 valid / nil; got %v / %v",
|
|
res, err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBodyLength(t *testing.T) {
|
|
ctx := t.Context()
|
|
ctx = WithTraceFunc(ctx, t.Logf)
|
|
|
|
// Any valid key works, since none of the signatures here are valid. We
|
|
// use the one from the example in RFC 8463 appendix A.2.
|
|
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
|
|
"brisbane._domainkey.example.com": {
|
|
"v=DKIM1; k=ed25519; " +
|
|
"p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="},
|
|
}))
|
|
|
|
// Use a valid body hash (of the "signed" part of the body), so we can
|
|
// tell the l= cases apart: when l= covers exactly the signed part, the
|
|
// body hash matches and we fail later on (because b= is bogus).
|
|
body := "Hi.\r\n"
|
|
|
|
cases := []struct {
|
|
l string
|
|
signed string
|
|
want error
|
|
}{
|
|
{"0", "", ErrVerificationFailed},
|
|
{"0", body, ErrBodyHashMismatch},
|
|
{"4", body, ErrBodyHashMismatch},
|
|
{"5", body, ErrVerificationFailed},
|
|
{"6", body, errInvalidTag},
|
|
{"18446744073709551615", body, errInvalidTag},
|
|
}
|
|
for _, c := range cases {
|
|
bh := base64.StdEncoding.EncodeToString(
|
|
hashWith(crypto.SHA256, []byte(c.signed)))
|
|
message := toCRLF(
|
|
"DKIM-Signature: v=1; a=ed25519-sha256; c=simple/simple;\n"+
|
|
" d=example.com; s=brisbane; h=from; l="+c.l+";\n"+
|
|
" bh="+bh+"; b=AAAA\n"+
|
|
"From: Joe SixPack <joe@example.com>\n"+
|
|
"\n") + body
|
|
|
|
res, err := VerifyMessage(ctx, message)
|
|
if err != nil {
|
|
t.Fatalf("l=%s signed=%q: VerifyMessage returned error: %v",
|
|
c.l, c.signed, err)
|
|
}
|
|
if res.Found != 1 || res.Valid != 0 {
|
|
t.Fatalf("l=%s signed=%q: wanted 1 found, 0 valid; got %v",
|
|
c.l, c.signed, res)
|
|
}
|
|
r := res.Results[0]
|
|
if r.State != PERMFAIL || !errors.Is(r.Error, c.want) {
|
|
t.Errorf("l=%s signed=%q: wanted PERMFAIL / %v; got %v / %v",
|
|
c.l, c.signed, c.want, r.State, r.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyInvalidDomain(t *testing.T) {
|
|
ctx := t.Context()
|
|
ctx = WithTraceFunc(ctx, t.Logf)
|
|
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{}))
|
|
|
|
// A d= tag that is not a valid domain must be rejected, and must not
|
|
// make it to the Authentication-Results header, where it could inject
|
|
// content.
|
|
message := toCRLF(
|
|
"DKIM-Signature: v=1; a=ed25519-sha256; c=simple/simple;\n" +
|
|
" d=example.com dkim=pass header.d=example.net; s=sel;\n" +
|
|
" h=from; bh=AAAA; b=AAAA\n" +
|
|
"From: Joe SixPack <joe@example.com>\n" +
|
|
"\n" +
|
|
"Hi.\n")
|
|
|
|
res, err := VerifyMessage(ctx, message)
|
|
if err != nil {
|
|
t.Fatalf("VerifyMessage returned error: %v", err)
|
|
}
|
|
if res.Found != 1 || res.Valid != 0 {
|
|
t.Fatalf("wanted 1 found, 0 valid; got %v", res)
|
|
}
|
|
r := res.Results[0]
|
|
if r.State != PERMFAIL || !errors.Is(r.Error, errInvalidTag) {
|
|
t.Errorf("wanted PERMFAIL / %v; got %v / %v",
|
|
errInvalidTag, r.State, r.Error)
|
|
}
|
|
|
|
ar := res.AuthenticationResults()
|
|
if strings.Contains(ar, "example.net") {
|
|
t.Errorf("Authentication-Results contains the invalid d=: %q", ar)
|
|
}
|
|
}
|
|
|
|
func TestBTag(t *testing.T) {
|
|
// Test that we remove the value of the b= tag (and only that) from the
|
|
// canonicalized DKIM-Signature header.
|
|
// The expected values match what Mail::DKIM does (except for the cases
|
|
// without a b= tag, where it appends one).
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
// Relaxed canonicalization, b= in different positions.
|
|
{"dkim-signature:v=1; bh=AAAA; b=SIG",
|
|
"dkim-signature:v=1; bh=AAAA; b="},
|
|
{"dkim-signature:v=1; bh=AAAA; b=SIG;",
|
|
"dkim-signature:v=1; bh=AAAA; b=;"},
|
|
{"dkim-signature:b=SIG; v=1",
|
|
"dkim-signature:b=; v=1"},
|
|
{"dkim-signature:v=1; b=SIG; bh=AAAA; a=ed25519-sha256",
|
|
"dkim-signature:v=1; b=; bh=AAAA; a=ed25519-sha256"},
|
|
|
|
// Simple canonicalization: the header name is unchanged, and
|
|
// whitespace (including folding) is preserved.
|
|
{"DKIM-Signature: b=SIG; v=1",
|
|
"DKIM-Signature: b=; v=1"},
|
|
{"DKIM-Signature: v=1;\r\n\tb=SIG1\r\n\t SIG2;\r\n\tbh=AAAA",
|
|
"DKIM-Signature: v=1;\r\n\tb=;\r\n\tbh=AAAA"},
|
|
{"DKIM-Signature: v=1;\r\n\t b=SIG",
|
|
"DKIM-Signature: v=1;\r\n\t b="},
|
|
{"DKIM-Signature: v=1;\r\n b\r\n =\r\n SIG\r\n ;",
|
|
"DKIM-Signature: v=1;\r\n b\r\n =;"},
|
|
|
|
// Whitespace around and inside the value is removed.
|
|
{"dkim-signature:v=1; b = SI G ;",
|
|
"dkim-signature:v=1; b =;"},
|
|
|
|
// Empty b= tag.
|
|
{"dkim-signature:v=1; b=; bh=AAAA",
|
|
"dkim-signature:v=1; b=; bh=AAAA"},
|
|
{"dkim-signature:v=1; b=",
|
|
"dkim-signature:v=1; b="},
|
|
|
|
// The whole value is removed, even if it's not valid base64.
|
|
{"dkim-signature:v=1; b=SI!G; x=1",
|
|
"dkim-signature:v=1; b=; x=1"},
|
|
|
|
// Degenerate folding, with a ':' at the beginning of a continuation
|
|
// line.
|
|
{"DKIM-Signature: v=1; h=from:to\r\n\t:subject;\r\n\tb=SIG",
|
|
"DKIM-Signature: v=1; h=from:to\r\n\t:subject;\r\n\tb="},
|
|
|
|
// Tags and values that contain "b=" but are not the b= tag must be
|
|
// left alone.
|
|
{"dkim-signature:v=1; bh=AAAA",
|
|
"dkim-signature:v=1; bh=AAAA"},
|
|
{"dkim-signature:v=1; bh=AAb=; b=SIG",
|
|
"dkim-signature:v=1; bh=AAb=; b="},
|
|
{"dkim-signature:v=1; xb=1; b=SIG",
|
|
"dkim-signature:v=1; xb=1; b="},
|
|
{"dkim-signature:v=1; x=b=1; b=SIG",
|
|
"dkim-signature:v=1; x=b=1; b="},
|
|
{"dkim-signature:v=1; x=y b=1; b=SIG",
|
|
"dkim-signature:v=1; x=y b=1; b="},
|
|
{"dkim-signature:v=1; z=Subject:Is=20the=20club=20open?; b=SIG",
|
|
"dkim-signature:v=1; z=Subject:Is=20the=20club=20open?; b="},
|
|
{"dkim-signature:z=Subject:b=20x; b=SIG",
|
|
"dkim-signature:z=Subject:b=20x; b="},
|
|
{"dkim-signature:v=1; i=bob=2B@example.com; b=SIG",
|
|
"dkim-signature:v=1; i=bob=2B@example.com; b="},
|
|
{"dkim-signature:v=1; xb=1; z=a:b=20",
|
|
"dkim-signature:v=1; xb=1; z=a:b=20"},
|
|
|
|
// Tag names are case-sensitive.
|
|
{"dkim-signature:v=1; B=SIG; b=SIG",
|
|
"dkim-signature:v=1; B=SIG; b="},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
got := bTag.ReplaceAllString(c.in, "$1$2")
|
|
if got != c.want {
|
|
t.Errorf("bTag replace on %q:", c.in)
|
|
t.Errorf(" want %q", c.want)
|
|
t.Errorf(" got %q", got)
|
|
}
|
|
|
|
// Cross-check against the tag parser: the result must have the
|
|
// same tags as the input, with only b= (if present) emptied.
|
|
_, inV, _ := strings.Cut(c.in, ":")
|
|
_, gotV, _ := strings.Cut(got, ":")
|
|
inTags, err := parseTags(inV)
|
|
if err != nil {
|
|
t.Fatalf("parseTags(%q) error: %v", inV, err)
|
|
}
|
|
gotTags, err := parseTags(gotV)
|
|
if err != nil {
|
|
t.Fatalf("parseTags(%q) error: %v", gotV, err)
|
|
}
|
|
if _, ok := inTags["b"]; ok {
|
|
inTags["b"] = ""
|
|
}
|
|
if diff := cmp.Diff(inTags, gotTags); diff != "" {
|
|
t.Errorf("bTag replace on %q: tags diff (-want +got):\n%s",
|
|
c.in, diff)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHeadersToInclude(t *testing.T) {
|
|
// Test that headersToInclude returns the expected headers.
|
|
cases := []struct {
|
|
sigH header
|
|
hTag []string
|
|
headers headers
|
|
want []header
|
|
}{
|
|
// Check that if a header appears more than once, we pick the latest
|
|
// first.
|
|
{
|
|
sigH: header{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
|
|
},
|
|
hTag: []string{"From", "To", "Subject"},
|
|
headers: headers{
|
|
{Name: "From", Value: "from1"},
|
|
{Name: "To", Value: "to1"},
|
|
{Name: "Subject", Value: "subject1"},
|
|
{Name: "From", Value: "from2"},
|
|
},
|
|
want: []header{
|
|
{Name: "From", Value: "from2"},
|
|
{Name: "To", Value: "to1"},
|
|
{Name: "Subject", Value: "subject1"},
|
|
},
|
|
},
|
|
|
|
// Check that if a header is requested twice but only appears once, we
|
|
// only return it once.
|
|
// This is a common technique suggested by the RFC to make signatures
|
|
// fail if a header is added.
|
|
{
|
|
sigH: header{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
|
|
},
|
|
hTag: []string{"From", "From", "To", "Subject"},
|
|
headers: headers{
|
|
{Name: "From", Value: "from1"},
|
|
{Name: "To", Value: "to1"},
|
|
{Name: "Subject", Value: "subject1"},
|
|
},
|
|
want: []header{
|
|
{Name: "From", Value: "from1"},
|
|
{Name: "To", Value: "to1"},
|
|
{Name: "Subject", Value: "subject1"},
|
|
},
|
|
},
|
|
|
|
// Check that if DKIM-Signature is included, we do *not* include the
|
|
// one we're currently verifying in the headers to include.
|
|
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
|
|
{
|
|
sigH: header{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
|
|
},
|
|
hTag: []string{"From", "From", "DKIM-Signature", "DKIM-Signature"},
|
|
headers: headers{
|
|
{Name: "From", Value: "from1"},
|
|
{Name: "To", Value: "to1"},
|
|
{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=sidney; d=example.com;",
|
|
},
|
|
{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
|
|
},
|
|
},
|
|
want: []header{
|
|
{Name: "From", Value: "from1"},
|
|
{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=sidney; d=example.com;",
|
|
},
|
|
},
|
|
},
|
|
|
|
// Check that header names are compared case-insensitively, and that
|
|
// headers that are not present are skipped.
|
|
{
|
|
sigH: header{
|
|
Name: "DKIM-Signature",
|
|
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
|
|
},
|
|
hTag: []string{"from", "X-Missing", "FROM", "subject"},
|
|
headers: headers{
|
|
{Name: "FROM", Value: "from1"},
|
|
{Name: "Subject", Value: "subject1"},
|
|
{Name: "From", Value: "from2"},
|
|
},
|
|
want: []header{
|
|
{Name: "From", Value: "from2"},
|
|
{Name: "FROM", Value: "from1"},
|
|
{Name: "Subject", Value: "subject1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
got := headersToInclude(c.sigH, c.hTag, c.headers)
|
|
if diff := cmp.Diff(c.want, got); diff != "" {
|
|
t.Errorf("headersToInclude(%q, %v, %v) diff (-want +got):\n%s",
|
|
c.sigH, c.hTag, c.headers, diff)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHeadersToIncludeLarge(t *testing.T) {
|
|
// Regression test for a DoS where headersToInclude was O(len(h=) *
|
|
// len(headers)), which with large (attacker-controlled) inputs could take
|
|
// hours. 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 = 100_000
|
|
|
|
sigH := header{Name: "DKIM-Signature", Value: "v=1;"}
|
|
hTag := make([]string, n)
|
|
hdrs := make(headers, n)
|
|
for i := range n {
|
|
hTag[i] = "a"
|
|
hdrs[i] = header{Name: "a", Value: fmt.Sprint(i)}
|
|
}
|
|
|
|
got := headersToInclude(sigH, hTag, hdrs)
|
|
if len(got) != n {
|
|
t.Fatalf("wanted %d headers, got %d", n, len(got))
|
|
}
|
|
// Last instance first.
|
|
if got[0].Value != fmt.Sprint(n-1) || got[n-1].Value != "0" {
|
|
t.Errorf("unexpected order: first %q, last %q",
|
|
got[0].Value, got[n-1].Value)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticationResults(t *testing.T) {
|
|
resBrisbane := &OneResult{
|
|
Domain: "football.example.com",
|
|
Selector: "brisbane",
|
|
B: "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11" +
|
|
"BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
|
State: SUCCESS,
|
|
Error: nil,
|
|
}
|
|
resTest := &OneResult{
|
|
Domain: "football.example.com",
|
|
Selector: "test",
|
|
B: "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe" +
|
|
"3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefO" +
|
|
"sk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZ" +
|
|
"Q4FADY+8=",
|
|
State: SUCCESS,
|
|
Error: nil,
|
|
}
|
|
resFail := &OneResult{
|
|
Domain: "football.example.com",
|
|
Selector: "paris",
|
|
B: "slfkdMSDFeslif39seFfjl93sljisdsdlif923l",
|
|
State: PERMFAIL,
|
|
Error: ErrVerificationFailed,
|
|
}
|
|
resPermFail := &OneResult{
|
|
Domain: "football.example.com",
|
|
Selector: "paris",
|
|
// No B tag on purpose.
|
|
State: PERMFAIL,
|
|
Error: errMissingRequiredTag,
|
|
}
|
|
resTempFail := &OneResult{
|
|
Domain: "football.example.com",
|
|
Selector: "paris",
|
|
B: "shorty", // Less than 12 characters to check we include it well.
|
|
State: TEMPFAIL,
|
|
Error: &net.DNSError{
|
|
Err: "dns temp error (for testing)",
|
|
IsTemporary: true,
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
results *VerifyResult
|
|
want string
|
|
}{
|
|
{
|
|
results: &VerifyResult{},
|
|
want: ";dkim=none\r\n",
|
|
},
|
|
{
|
|
results: &VerifyResult{
|
|
Found: 1,
|
|
Valid: 1,
|
|
Results: []*OneResult{resBrisbane},
|
|
},
|
|
want: ";dkim=pass" +
|
|
" header.b=/gCrinpcQOoI header.d=football.example.com\r\n",
|
|
},
|
|
{
|
|
results: &VerifyResult{
|
|
Found: 2,
|
|
Valid: 2,
|
|
Results: []*OneResult{resBrisbane, resTest},
|
|
},
|
|
want: ";dkim=pass" +
|
|
" header.b=/gCrinpcQOoI header.d=football.example.com\r\n" +
|
|
";dkim=pass" +
|
|
" header.b=F45dVWDfMbQD header.d=football.example.com\r\n",
|
|
},
|
|
{
|
|
results: &VerifyResult{
|
|
Found: 2,
|
|
Valid: 2,
|
|
Results: []*OneResult{resBrisbane, resTest},
|
|
},
|
|
want: ";dkim=pass" +
|
|
" header.b=/gCrinpcQOoI header.d=football.example.com\r\n" +
|
|
";dkim=pass" +
|
|
" header.b=F45dVWDfMbQD header.d=football.example.com\r\n",
|
|
},
|
|
{
|
|
results: &VerifyResult{
|
|
Found: 2,
|
|
Valid: 1,
|
|
Results: []*OneResult{resFail, resTest},
|
|
},
|
|
want: ";dkim=fail reason=\"verification failed\"\r\n" +
|
|
" header.b=slfkdMSDFesl header.d=football.example.com\r\n" +
|
|
";dkim=pass" +
|
|
" header.b=F45dVWDfMbQD header.d=football.example.com\r\n",
|
|
},
|
|
{
|
|
results: &VerifyResult{
|
|
Found: 1,
|
|
Results: []*OneResult{resPermFail},
|
|
},
|
|
want: ";dkim=permerror reason=\"missing required tag\"\r\n" +
|
|
" header.d=football.example.com\r\n",
|
|
},
|
|
{
|
|
results: &VerifyResult{
|
|
Found: 1,
|
|
Results: []*OneResult{resTempFail},
|
|
},
|
|
want: ";dkim=temperror reason=\"lookup : dns temp error (for testing)\"\r\n" +
|
|
" header.b=shorty header.d=football.example.com\r\n",
|
|
},
|
|
}
|
|
|
|
for i, c := range cases {
|
|
got := c.results.AuthenticationResults()
|
|
if diff := cmp.Diff(c.want, got); diff != "" {
|
|
t.Errorf("case %d: AuthenticationResults() diff (-want +got):\n%s",
|
|
i, diff)
|
|
}
|
|
}
|
|
}
|
|
|
|
func FuzzVerifyMessage(f *testing.F) {
|
|
addTestdataCorpus(f)
|
|
|
|
// Return valid keys for every domain, so we can get past the key lookup
|
|
// and exercise the rest of the verification. The ed25519 key is the one
|
|
// used by testdata/01-rfc8463.
|
|
ctx := WithLookupTXTFunc(context.Background(),
|
|
func(ctx context.Context, domain string) ([]string, error) {
|
|
return []string{
|
|
"v=DKIM1; k=ed25519; p=" + exampleEd25519KeyB64,
|
|
"v=DKIM1; k=rsa; p=" + exampleRSAKeyB64,
|
|
}, nil
|
|
})
|
|
|
|
f.Fuzz(func(t *testing.T, msg string) {
|
|
res, err := VerifyMessage(ctx, msg)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if res.Found != uint(len(res.Results)) {
|
|
t.Errorf("found %d signatures, but got %d results",
|
|
res.Found, len(res.Results))
|
|
}
|
|
if res.Valid > res.Found {
|
|
t.Errorf("%d valid signatures, but only %d found",
|
|
res.Valid, res.Found)
|
|
}
|
|
|
|
// This is always called on the result when receiving a message, so
|
|
// exercise it too.
|
|
res.AuthenticationResults()
|
|
})
|
|
}
|