mirror of
https://blitiri.com.ar/repos/chasquid
synced 2026-01-07 17:47:14 +00:00
dkim: Implement internal dkim signing and verification
This patch implements internal DKIM signing and verification.
This commit is contained in:
158
internal/dkim/canonicalize.go
Normal file
158
internal/dkim/canonicalize.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoBody = errors.New("no body found")
|
||||
|
||||
errUnknownCanonicalization = errors.New("unknown canonicalization")
|
||||
)
|
||||
|
||||
type canonicalization string
|
||||
|
||||
var (
|
||||
simpleCanonicalization canonicalization = "simple"
|
||||
relaxedCanonicalization canonicalization = "relaxed"
|
||||
)
|
||||
|
||||
func (c canonicalization) body(b string) string {
|
||||
switch c {
|
||||
case simpleCanonicalization:
|
||||
return simpleBody(b)
|
||||
case relaxedCanonicalization:
|
||||
return relaxBody(b)
|
||||
default:
|
||||
panic("unknown canonicalization")
|
||||
}
|
||||
}
|
||||
|
||||
func (c canonicalization) headers(hs headers) headers {
|
||||
switch c {
|
||||
case simpleCanonicalization:
|
||||
return hs
|
||||
case relaxedCanonicalization:
|
||||
return relaxHeaders(hs)
|
||||
default:
|
||||
panic("unknown canonicalization")
|
||||
}
|
||||
}
|
||||
|
||||
func (c canonicalization) header(h header) header {
|
||||
switch c {
|
||||
case simpleCanonicalization:
|
||||
return h
|
||||
case relaxedCanonicalization:
|
||||
return relaxHeader(h)
|
||||
default:
|
||||
panic("unknown canonicalization")
|
||||
}
|
||||
}
|
||||
|
||||
func stringToCanonicalization(s string) (canonicalization, error) {
|
||||
switch s {
|
||||
case "simple":
|
||||
return simpleCanonicalization, nil
|
||||
case "relaxed":
|
||||
return relaxedCanonicalization, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", errUnknownCanonicalization, s)
|
||||
}
|
||||
}
|
||||
|
||||
// Notes on whitespace reduction:
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-2.8
|
||||
// There are only 3 forms of whitespace:
|
||||
// - WSP = SP / HTAB
|
||||
// Simple whitespace: space or tab.
|
||||
// - LWSP = *(WSP / CRLF WSP)
|
||||
// Linear whitespace: any number of { simple whitespace OR CRLF followed by
|
||||
// simple whitespace }.
|
||||
// - FWS = [*WSP CRLF] 1*WSP
|
||||
// Folding whitespace: optional { simple whitespace OR CRLF } followed by
|
||||
// one or more simple whitespace.
|
||||
|
||||
func simpleBody(body string) string {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3
|
||||
// Replace repeated CRLF at the end of the body with a single CRLF.
|
||||
body = repeatedCRLFAtTheEnd.ReplaceAllString(body, "\r\n")
|
||||
|
||||
// Ensure a non-empty body ends with a single CRLF.
|
||||
// All bodies (including an empty one) must end with a CRLF.
|
||||
if !strings.HasSuffix(body, "\r\n") {
|
||||
body += "\r\n"
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
var (
|
||||
// Continued header: WSP after CRLF.
|
||||
continuedHeader = regexp.MustCompile(`\r\n[ \t]+`)
|
||||
|
||||
// WSP before CRLF.
|
||||
wspBeforeCRLF = regexp.MustCompile(`[ \t]+\r\n`)
|
||||
|
||||
// Repeated WSP.
|
||||
repeatedWSP = regexp.MustCompile(`[ \t]+`)
|
||||
|
||||
// Empty lines at the end of the body.
|
||||
repeatedCRLFAtTheEnd = regexp.MustCompile(`(\r\n)+$`)
|
||||
)
|
||||
|
||||
func relaxBody(body string) string {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.4
|
||||
body = wspBeforeCRLF.ReplaceAllLiteralString(body, "\r\n")
|
||||
body = repeatedWSP.ReplaceAllLiteralString(body, " ")
|
||||
body = repeatedCRLFAtTheEnd.ReplaceAllLiteralString(body, "\r\n")
|
||||
|
||||
// Ensure a non-empty body ends with a single CRLF.
|
||||
if len(body) >= 1 && !strings.HasSuffix(body, "\r\n") {
|
||||
body += "\r\n"
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func relaxHeader(h header) header {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2
|
||||
// Convert all header field names to lowercase.
|
||||
name := strings.ToLower(h.Name)
|
||||
|
||||
// Remove WSP before the ":" separating the name and value.
|
||||
name = strings.TrimRight(name, " \t")
|
||||
|
||||
// Unfold continuation lines in values.
|
||||
value := continuedHeader.ReplaceAllString(h.Value, " ")
|
||||
|
||||
// Reduce all sequences of WSP to a single SP.
|
||||
value = repeatedWSP.ReplaceAllLiteralString(value, " ")
|
||||
|
||||
// Delete all WSP at the end of each unfolded header field value.
|
||||
value = strings.TrimRight(value, " \t")
|
||||
|
||||
// Remove WSP after the ":" separating the name and value.
|
||||
value = strings.TrimLeft(value, " \t")
|
||||
|
||||
return header{
|
||||
Name: name,
|
||||
Value: value,
|
||||
|
||||
// The "source" is the relaxed field: name, colon, and value (with
|
||||
// no space around the colon).
|
||||
Source: name + ":" + value,
|
||||
}
|
||||
}
|
||||
|
||||
func relaxHeaders(hs headers) headers {
|
||||
rh := make(headers, 0, len(hs))
|
||||
for _, h := range hs {
|
||||
rh = append(rh, relaxHeader(h))
|
||||
}
|
||||
|
||||
return rh
|
||||
}
|
||||
214
internal/dkim/canonicalize_test.go
Normal file
214
internal/dkim/canonicalize_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
func TestStringToCanonicalization(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want canonicalization
|
||||
err error
|
||||
}{
|
||||
{"simple", simpleCanonicalization, nil},
|
||||
{"relaxed", relaxedCanonicalization, nil},
|
||||
{"", "", errUnknownCanonicalization},
|
||||
{" ", "", errUnknownCanonicalization},
|
||||
{" simple", "", errUnknownCanonicalization},
|
||||
{"simple ", "", errUnknownCanonicalization},
|
||||
{"si mple ", "", errUnknownCanonicalization},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := stringToCanonicalization(c.in)
|
||||
if diff := cmp.Diff(c.want, got); diff != "" {
|
||||
t.Errorf("stringToCanonicalization(%q) diff (-want +got): %s",
|
||||
c.in, diff)
|
||||
}
|
||||
diff := cmp.Diff(c.err, err, cmpopts.EquateErrors())
|
||||
if diff != "" {
|
||||
t.Errorf("stringToCanonicalization(%q) err diff (-want +got): %s",
|
||||
c.in, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
|
||||
// Bodies end with \r\n, including the empty one.
|
||||
{"", "\r\n"},
|
||||
{"a", "a\r\n"},
|
||||
{"a\r\n", "a\r\n"},
|
||||
|
||||
// Repeated CRLF at the end of the body is replaced with a single CRLF.
|
||||
{"Body \r\n\r\n\r\n", "Body \r\n"},
|
||||
|
||||
// Example from RFC.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5
|
||||
{
|
||||
" C \r\nD \t E\r\n\r\n\r\n",
|
||||
" C \r\nD \t E\r\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := simpleCanonicalization.body(c.in)
|
||||
if diff := cmp.Diff(c.want, got); diff != "" {
|
||||
t.Errorf("simpleCanonicalization.body(%q) diff (-want +got): %s",
|
||||
c.in, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelaxBody(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"a\r\n", "a\r\n"},
|
||||
|
||||
// Repeated WSP before CRLF.
|
||||
{"a \r\n", "a\r\n"},
|
||||
{"a \r\n", "a\r\n"},
|
||||
{"a \t \r\n", "a\r\n"},
|
||||
{"a\t\t\t\r\n", "a\r\n"},
|
||||
|
||||
// Repeated WSP within a line.
|
||||
{"a b\r\n", "a b\r\n"},
|
||||
{"a\t\t\tb\r\n", "a b\r\n"},
|
||||
{"a \t \t b\r\n", "a b\r\n"},
|
||||
|
||||
// Ignore empty lines at the end.
|
||||
{"a\r\n\r\n", "a\r\n"},
|
||||
{"a\r\n\r\n\r\n", "a\r\n"},
|
||||
|
||||
// Body must end with \r\n, unless it's empty.
|
||||
{"", ""},
|
||||
{"\r\n", "\r\n"},
|
||||
{"a", "a\r\n"},
|
||||
|
||||
// Example from RFC.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5
|
||||
{" C \r\nD \t E\r\n\r\n\r\n", " C\r\nD E\r\n"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := relaxedCanonicalization.body(c.in)
|
||||
if diff := cmp.Diff(c.want, got); diff != "" {
|
||||
t.Errorf("relaxedCanonicalization.body(%q) diff (-want +got): %s",
|
||||
c.in, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mkHs(hs ...string) headers {
|
||||
var headers headers
|
||||
for i := 0; i < len(hs); i += 2 {
|
||||
h := header{
|
||||
Name: hs[i],
|
||||
Value: hs[i+1],
|
||||
Source: hs[i] + ":" + hs[i+1],
|
||||
}
|
||||
headers = append(headers, h)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func TestHeaders(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
wantS headers
|
||||
wantR headers
|
||||
}{
|
||||
// Unfold headers.
|
||||
{"A: B\r\n C\r\n", mkHs("A", " B\r\n C"), mkHs("a", "B C")},
|
||||
{"A: B\r\n\tC\r\n", mkHs("A", " B\r\n\tC"), mkHs("a", "B C")},
|
||||
{"A: B\r\n \t C\r\n", mkHs("A", " B\r\n \t C"), mkHs("a", "B C")},
|
||||
|
||||
// Reduce all sequences of WSP within a line to a single SP.
|
||||
{"A: B C\r\n", mkHs("A", " B C"), mkHs("a", "B C")},
|
||||
{"A: B\t\tC\r\n", mkHs("A", " B\t\tC"), mkHs("a", "B C")},
|
||||
{"A: B \t \t C\r\n", mkHs("A", " B \t \t C"), mkHs("a", "B C")},
|
||||
|
||||
// Delete all WSP at the end of each unfolded header field.
|
||||
{"A: B \r\n", mkHs("A", " B "), mkHs("a", "B")},
|
||||
{"A: B \r\n", mkHs("A", " B "), mkHs("a", "B")},
|
||||
{"A: B\t \r\n", mkHs("A", " B\t "), mkHs("a", "B")},
|
||||
{"A: B\t\t\t\r\n", mkHs("A", " B\t\t\t"), mkHs("a", "B")},
|
||||
{"A: B\r\n \t C \t\r\n",
|
||||
mkHs("A", " B\r\n \t C \t"), mkHs("a", "B C")},
|
||||
|
||||
// Whitespace before and after the colon.
|
||||
{"A : B\r\n", mkHs("A ", " B"), mkHs("a", "B")},
|
||||
{"A : B\r\n", mkHs("A ", " B"), mkHs("a", "B")},
|
||||
{"A\t:\tB\r\n", mkHs("A\t", "\tB"), mkHs("a", "B")},
|
||||
{"A\t \t : \t \tB\r\n", mkHs("A\t \t ", " \t \tB"), mkHs("a", "B")},
|
||||
|
||||
// Example from RFC.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5
|
||||
{"A: X\r\nB : Y\t\r\n\tZ \r\n",
|
||||
mkHs("A", " X", "B ", " Y\t\r\n\tZ "),
|
||||
mkHs("a", "X", "b", "Y Z")},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
hs, _, err := parseMessage(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseMessage(%q) = %v, want nil", c.in, err)
|
||||
}
|
||||
|
||||
gotS := simpleCanonicalization.headers(hs)
|
||||
if diff := cmp.Diff(c.wantS, gotS); diff != "" {
|
||||
t.Errorf("%d: simpleCanonicalization.headers(%q) diff (-want +got): %s",
|
||||
i, c.in, diff)
|
||||
}
|
||||
|
||||
gotR := relaxedCanonicalization.headers(hs)
|
||||
if diff := cmp.Diff(c.wantR, gotR); diff != "" {
|
||||
t.Errorf("%d: relaxedCanonicalization.headers(%q) diff (-want +got): %s",
|
||||
i, c.in, diff)
|
||||
}
|
||||
|
||||
// Test the single-header variant if possible.
|
||||
if len(hs) == 1 {
|
||||
gotS := simpleCanonicalization.header(hs[0])
|
||||
if diff := cmp.Diff(c.wantS[0], gotS); diff != "" {
|
||||
t.Errorf("%d: simpleCanonicalization.header(%q) diff (-want +got): %s",
|
||||
i, c.in, diff)
|
||||
}
|
||||
|
||||
gotR := relaxedCanonicalization.header(hs[0])
|
||||
if diff := cmp.Diff(c.wantR[0], gotR); diff != "" {
|
||||
t.Errorf("%d: relaxedCanonicalization.header(%q) diff (-want +got): %s",
|
||||
i, c.in, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadCanonicalization(t *testing.T) {
|
||||
bad := canonicalization("bad")
|
||||
if !panics(func() { bad.body("") }) {
|
||||
t.Errorf("bad.body() did not panic")
|
||||
}
|
||||
if !panics(func() { bad.header(header{}) }) {
|
||||
t.Errorf("bad.header() did not panic")
|
||||
}
|
||||
if !panics(func() { bad.headers(nil) }) {
|
||||
t.Errorf("bad.headers() did not panic")
|
||||
}
|
||||
}
|
||||
|
||||
func panics(f func()) (panicked bool) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
panicked = r != nil
|
||||
}()
|
||||
f()
|
||||
return
|
||||
}
|
||||
56
internal/dkim/context.go
Normal file
56
internal/dkim/context.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const traceKey contextKey = "trace"
|
||||
|
||||
func trace(ctx context.Context, f string, args ...interface{}) {
|
||||
traceFunc, ok := ctx.Value(traceKey).(TraceFunc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
traceFunc(f, args...)
|
||||
}
|
||||
|
||||
type TraceFunc func(f string, a ...interface{})
|
||||
|
||||
func WithTraceFunc(ctx context.Context, trace TraceFunc) context.Context {
|
||||
return context.WithValue(ctx, traceKey, trace)
|
||||
}
|
||||
|
||||
const lookupTXTKey contextKey = "lookupTXT"
|
||||
|
||||
func lookupTXT(ctx context.Context, domain string) ([]string, error) {
|
||||
lookupTXTFunc, ok := ctx.Value(lookupTXTKey).(lookupTXTFunc)
|
||||
if !ok {
|
||||
return net.LookupTXT(domain)
|
||||
}
|
||||
return lookupTXTFunc(ctx, domain)
|
||||
}
|
||||
|
||||
type lookupTXTFunc func(ctx context.Context, domain string) ([]string, error)
|
||||
|
||||
func WithLookupTXTFunc(ctx context.Context, lookupTXT lookupTXTFunc) context.Context {
|
||||
return context.WithValue(ctx, lookupTXTKey, lookupTXT)
|
||||
}
|
||||
|
||||
const maxHeadersKey contextKey = "maxHeaders"
|
||||
|
||||
func WithMaxHeaders(ctx context.Context, maxHeaders int) context.Context {
|
||||
return context.WithValue(ctx, maxHeadersKey, maxHeaders)
|
||||
}
|
||||
|
||||
func maxHeaders(ctx context.Context) int {
|
||||
maxHeaders, ok := ctx.Value(maxHeadersKey).(int)
|
||||
if !ok {
|
||||
// By default, cap the number of headers to 5 (arbitrarily chosen, may
|
||||
// be adjusted in the future).
|
||||
return 5
|
||||
}
|
||||
return maxHeaders
|
||||
}
|
||||
67
internal/dkim/context_test.go
Normal file
67
internal/dkim/context_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTraceNoCtx(t *testing.T) {
|
||||
// Call trace() on a context without a trace function, to check it doesn't
|
||||
// panic.
|
||||
ctx := context.Background()
|
||||
trace(ctx, "test")
|
||||
}
|
||||
|
||||
func TestTrace(t *testing.T) {
|
||||
s := ""
|
||||
traceF := func(f string, a ...interface{}) {
|
||||
s = fmt.Sprintf(f, a...)
|
||||
}
|
||||
ctx := WithTraceFunc(context.Background(), traceF)
|
||||
trace(ctx, "test %d", 1)
|
||||
if s != "test 1" {
|
||||
t.Errorf("trace function not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupTXTNoCtx(t *testing.T) {
|
||||
// Call lookupTXT() on a context without an override, to check it calls
|
||||
// the real function.
|
||||
// We just check there is a reasonable error.
|
||||
// We don't specifically check that it's NXDOMAIN because if we don't have
|
||||
// internet access, the error may be different.
|
||||
ctx := context.Background()
|
||||
_, err := lookupTXT(ctx, "does.not.exist.example.com")
|
||||
if _, ok := err.(*net.DNSError); !ok {
|
||||
t.Fatalf("expected *net.DNSError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupTXT(t *testing.T) {
|
||||
called := false
|
||||
lookupTXTF := func(ctx context.Context, name string) ([]string, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
}
|
||||
ctx := WithLookupTXTFunc(context.Background(), lookupTXTF)
|
||||
lookupTXT(ctx, "example.com")
|
||||
if !called {
|
||||
t.Errorf("lookupTXT function not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHeaders(t *testing.T) {
|
||||
// First without an override, check we return the default.
|
||||
ctx := context.Background()
|
||||
if m := maxHeaders(ctx); m != 5 {
|
||||
t.Errorf("expected 5, got %d", m)
|
||||
}
|
||||
|
||||
// Now with an override.
|
||||
ctx = WithMaxHeaders(ctx, 10)
|
||||
if m := maxHeaders(ctx); m != 10 {
|
||||
t.Errorf("expected 10, got %d", m)
|
||||
}
|
||||
}
|
||||
201
internal/dkim/dns.go
Normal file
201
internal/dkim/dns.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func findPublicKeys(ctx context.Context, domain, selector string) ([]*publicKey, error) {
|
||||
// Subdomain where the key lives.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.2
|
||||
d := selector + "._domainkey." + domain
|
||||
values, err := lookupTXT(ctx, d)
|
||||
if err != nil {
|
||||
trace(ctx, "TXT lookup of %q failed: %v", d, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// There should be only a single record; RFC 6376 says the results are
|
||||
// undefined if there are multiple TXT records.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.2.2
|
||||
//
|
||||
// What other implementations do:
|
||||
// - dkimpy: Use the first TXT record (whatever it is).
|
||||
// - OpenDKIM: Use the first TXT record (whatever it is).
|
||||
// - driusan/dkim: Use the first TXT record that can be parsed as a key.
|
||||
// - go-msgauth: Reject if there are multiple records.
|
||||
//
|
||||
// What we do: use _all_ TXT records that can be parsed as keys. This is
|
||||
// possibly too much, and we could reconsider this in the future.
|
||||
|
||||
pks := []*publicKey{}
|
||||
for _, v := range values {
|
||||
trace(ctx, "TXT record for %q: %q", d, v)
|
||||
pk, err := parsePublicKey(v)
|
||||
if err != nil {
|
||||
trace(ctx, "Skipping: %v", err)
|
||||
continue
|
||||
}
|
||||
trace(ctx, "Parsed public key: %s", pk)
|
||||
pks = append(pks, pk)
|
||||
}
|
||||
|
||||
return pks, nil
|
||||
}
|
||||
|
||||
// Function to verify a signature with this public key.
|
||||
type verifyFunc func(h crypto.Hash, hashed, signature []byte) error
|
||||
|
||||
type publicKey struct {
|
||||
H []crypto.Hash
|
||||
K keyType
|
||||
P []byte
|
||||
|
||||
T []string // t= tag, representing flags.
|
||||
|
||||
verify verifyFunc
|
||||
}
|
||||
|
||||
func (pk *publicKey) String() string {
|
||||
return fmt.Sprintf("[%s:%.8x]", pk.K, pk.P)
|
||||
}
|
||||
|
||||
func (pk *publicKey) Matches(kt keyType, h crypto.Hash) bool {
|
||||
if pk.K != kt {
|
||||
return false
|
||||
}
|
||||
if len(pk.H) > 0 {
|
||||
return slices.Contains(pk.H, h)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (pk *publicKey) StrictDomainCheck() bool {
|
||||
// t=s is set.
|
||||
return slices.Contains(pk.T, "s")
|
||||
}
|
||||
|
||||
func parsePublicKey(v string) (*publicKey, error) {
|
||||
// Public key is a tag-value list.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1
|
||||
tags, err := parseTags(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// "v" is optional, but if present it must be "DKIM1".
|
||||
ver, ok := tags["v"]
|
||||
if ok && ver != "DKIM1" {
|
||||
return nil, fmt.Errorf("%w: %q", errInvalidVersion, ver)
|
||||
}
|
||||
|
||||
pk := &publicKey{
|
||||
// The default key type is rsa.
|
||||
K: keyTypeRSA,
|
||||
}
|
||||
|
||||
// h is a colon-separated list of hashing algorithm names.
|
||||
if tags["h"] != "" {
|
||||
hs := strings.Split(eatWhitespace.Replace(tags["h"]), ":")
|
||||
for _, h := range hs {
|
||||
x, err := hashFromString(h)
|
||||
if err != nil {
|
||||
// Unrecognized algorithms must be ignored.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1
|
||||
continue
|
||||
}
|
||||
pk.H = append(pk.H, x)
|
||||
}
|
||||
}
|
||||
|
||||
// k is key type (may not be present, rsa is used in that case).
|
||||
if tags["k"] != "" {
|
||||
pk.K, err = keyTypeFromString(tags["k"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// p is public-key data, base64-encoded, and whitespace in it must be
|
||||
// ignored. Required.
|
||||
p, err := base64.StdEncoding.DecodeString(
|
||||
eatWhitespace.Replace(tags["p"]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding p=: %w", err)
|
||||
}
|
||||
pk.P = p
|
||||
|
||||
switch pk.K {
|
||||
case keyTypeRSA:
|
||||
pk.verify, err = parseRSAPublicKey(p)
|
||||
case keyTypeEd25519:
|
||||
pk.verify, err = parseEd25519PublicKey(p)
|
||||
}
|
||||
|
||||
// t is a colon-separated list of flags.
|
||||
if t := eatWhitespace.Replace(tags["t"]); t != "" {
|
||||
pk.T = strings.Split(t, ":")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk, nil
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidRSAPublicKey = errors.New("invalid RSA public key")
|
||||
errNotRSAPublicKey = errors.New("not an RSA public key")
|
||||
errRSAKeyTooSmall = errors.New("RSA public key too small")
|
||||
errInvalidEd25519Key = errors.New("invalid Ed25519 public key")
|
||||
)
|
||||
|
||||
func parseRSAPublicKey(p []byte) (verifyFunc, error) {
|
||||
// Either PKCS#1 or SubjectPublicKeyInfo.
|
||||
// See https://www.rfc-editor.org/errata/eid3017.
|
||||
pub, err := x509.ParsePKIXPublicKey(p)
|
||||
if err != nil {
|
||||
pub, err = x509.ParsePKCS1PublicKey(p)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", errInvalidRSAPublicKey, err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, errNotRSAPublicKey
|
||||
}
|
||||
|
||||
// Enforce 1024-bit minimum.
|
||||
// https://datatracker.ietf.org/doc/html/rfc8301#section-3.2
|
||||
if rsaPub.Size()*8 < 1024 {
|
||||
return nil, errRSAKeyTooSmall
|
||||
}
|
||||
|
||||
return func(h crypto.Hash, hashed, signature []byte) error {
|
||||
return rsa.VerifyPKCS1v15(rsaPub, h, hashed, signature)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseEd25519PublicKey(p []byte) (verifyFunc, error) {
|
||||
// https: //datatracker.ietf.org/doc/html/rfc8463
|
||||
if len(p) != ed25519.PublicKeySize {
|
||||
return nil, errInvalidEd25519Key
|
||||
}
|
||||
|
||||
pub := ed25519.PublicKey(p)
|
||||
return func(h crypto.Hash, hashed, signature []byte) error {
|
||||
if ed25519.Verify(pub, hashed, signature) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("signature verification failed")
|
||||
}, nil
|
||||
}
|
||||
248
internal/dkim/dns_test.go
Normal file
248
internal/dkim/dns_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
func TestLookupError(t *testing.T) {
|
||||
testErr := errors.New("lookup error")
|
||||
errLookupF := func(ctx context.Context, name string) ([]string, error) {
|
||||
return nil, testErr
|
||||
}
|
||||
ctx := WithLookupTXTFunc(context.Background(), errLookupF)
|
||||
|
||||
pks, err := findPublicKeys(ctx, "example.com", "selector")
|
||||
if pks != nil || err != testErr {
|
||||
t.Errorf("findPublicKeys expected nil / lookup error, got %v / %v",
|
||||
pks, err)
|
||||
}
|
||||
}
|
||||
|
||||
// RSA key from the RFC example.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#appendix-C
|
||||
const exampleRSAKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" +
|
||||
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" +
|
||||
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" +
|
||||
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" +
|
||||
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"
|
||||
|
||||
var exampleRSAKeyBuf, _ = base64.StdEncoding.DecodeString(exampleRSAKeyB64)
|
||||
var exampleRSAKey, _ = x509.ParsePKCS1PublicKey(exampleRSAKeyBuf)
|
||||
|
||||
// Ed25519 key from the RFC example.
|
||||
// https://datatracker.ietf.org/doc/html/rfc8463#appendix-A.2
|
||||
const exampleEd25519KeyB64 = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
|
||||
|
||||
var exampleEd25519KeyBuf, _ = base64.StdEncoding.DecodeString(
|
||||
exampleEd25519KeyB64)
|
||||
var exampleEd25519Key = ed25519.PublicKey(exampleEd25519KeyBuf)
|
||||
|
||||
var results = map[string][]string{}
|
||||
var resultErr = map[string]error{}
|
||||
|
||||
func testLookupTXT(ctx context.Context, name string) ([]string, error) {
|
||||
return results[name], resultErr[name]
|
||||
}
|
||||
|
||||
func TestSkipBadRecords(t *testing.T) {
|
||||
ctx := WithLookupTXTFunc(context.Background(), testLookupTXT)
|
||||
results["selector._domainkey.example.com"] = []string{
|
||||
"not a tag",
|
||||
"v=DKIM1; p=" + exampleRSAKeyB64,
|
||||
}
|
||||
defer clear(results)
|
||||
|
||||
pks, err := findPublicKeys(ctx, "example.com", "selector")
|
||||
if err != nil {
|
||||
t.Errorf("findPublicKeys expected nil, got %v", err)
|
||||
}
|
||||
if len(pks) != 1 {
|
||||
t.Errorf("findPublicKeys expected 1 key, got %v", len(pks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePublicKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
pk *publicKey
|
||||
err error
|
||||
}{
|
||||
// Invalid records.
|
||||
{"not a tag", nil, errInvalidTag},
|
||||
{"v=DKIM666;", nil, errInvalidVersion},
|
||||
{"p=abc~*#def", nil, base64.CorruptInputError(3)},
|
||||
{"k=blah; p=" + exampleRSAKeyB64, nil, errUnsupportedKeyType},
|
||||
|
||||
// Error parsing the keys.
|
||||
{"p=", nil, errInvalidRSAPublicKey},
|
||||
|
||||
// RSA key but the contents are a (valid) ECDSA key.
|
||||
{"p=" +
|
||||
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIT0qsh+0jdY" +
|
||||
"DhK5+rSedhT7W/5rTRiulhphqtuplGFAyNiSh9I5t6MsrIu" +
|
||||
"xFQV7A/cWAt8qcbVscT3Q2l6iu3w==",
|
||||
nil, errNotRSAPublicKey},
|
||||
|
||||
// Valid RSA key, that is too short.
|
||||
{"p=" +
|
||||
"MEgCQQCo9+BpMRYQ/dL3DS2CyJxRF+j6ctbT3/Qp84+KeFh" +
|
||||
"nii7NT7fELilKUSnxS30WAvQCCo2yU1orfgqr41mM70MBAg" +
|
||||
"MBAAE=", nil, errRSAKeyTooSmall},
|
||||
|
||||
// Invalid ed25519 key.
|
||||
{"k=ed25519; p=MFkwEwYH", nil, errInvalidEd25519Key},
|
||||
|
||||
// Valid.
|
||||
{"p=" + exampleRSAKeyB64,
|
||||
&publicKey{K: keyTypeRSA, P: exampleRSAKeyBuf}, nil},
|
||||
{"k=rsa ; p=" + exampleRSAKeyB64,
|
||||
&publicKey{K: keyTypeRSA, P: exampleRSAKeyBuf}, nil},
|
||||
{
|
||||
"k=rsa; h=sha256; p=" + exampleRSAKeyB64,
|
||||
&publicKey{
|
||||
K: keyTypeRSA,
|
||||
H: []crypto.Hash{crypto.SHA256},
|
||||
P: exampleRSAKeyBuf},
|
||||
nil,
|
||||
},
|
||||
{"t=s; p=" + exampleRSAKeyB64,
|
||||
&publicKey{
|
||||
K: keyTypeRSA,
|
||||
P: exampleRSAKeyBuf,
|
||||
T: []string{"s"},
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{"t = s : y; p=" + exampleRSAKeyB64,
|
||||
&publicKey{
|
||||
K: keyTypeRSA,
|
||||
P: exampleRSAKeyBuf,
|
||||
T: []string{"s", "y"},
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
// We should ignore unrecognized hash algorithms.
|
||||
"k=rsa; h=sha1:xxx123:sha256; p=" + exampleRSAKeyB64,
|
||||
&publicKey{
|
||||
K: keyTypeRSA,
|
||||
H: []crypto.Hash{crypto.SHA256},
|
||||
P: exampleRSAKeyBuf},
|
||||
nil,
|
||||
},
|
||||
{"k=ed25519; p=" + exampleEd25519KeyB64,
|
||||
&publicKey{K: keyTypeEd25519, P: exampleEd25519KeyBuf}, nil},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
pk, err := parsePublicKey(c.in)
|
||||
diff := cmp.Diff(c.pk, pk,
|
||||
cmpopts.IgnoreUnexported(publicKey{}),
|
||||
cmpopts.EquateEmpty(),
|
||||
)
|
||||
if diff != "" {
|
||||
t.Errorf("%d: parsePublicKey(%q) key: (-want +got)\n%s",
|
||||
i, c.in, diff)
|
||||
}
|
||||
if !errors.Is(err, c.err) {
|
||||
t.Errorf("%d: parsePublicKey(%q) error: want %v, got %v",
|
||||
i, c.in, c.err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
pk *publicKey
|
||||
kt keyType
|
||||
h crypto.Hash
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
&publicKey{K: keyTypeRSA},
|
||||
keyTypeRSA, crypto.SHA256,
|
||||
true,
|
||||
},
|
||||
{
|
||||
&publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}},
|
||||
keyTypeRSA, crypto.SHA1,
|
||||
true,
|
||||
},
|
||||
{
|
||||
&publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}},
|
||||
keyTypeRSA, crypto.SHA256,
|
||||
false,
|
||||
},
|
||||
{
|
||||
&publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}},
|
||||
keyTypeEd25519, crypto.SHA1,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
if ok := c.pk.Matches(c.kt, c.h); ok != c.ok {
|
||||
t.Errorf("%d: matches(%v, %v) = %v, want %v",
|
||||
i, c.kt, c.h, ok, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictDomainCheck(t *testing.T) {
|
||||
cases := []struct {
|
||||
t string
|
||||
ok bool
|
||||
}{
|
||||
{"", false},
|
||||
{"y", false},
|
||||
{"x:y", false},
|
||||
{":x::y", false},
|
||||
{"s", true},
|
||||
{"y:s", true},
|
||||
{" y: s", true},
|
||||
{"y:s:x", true},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
pkS := "k=ed25519; p=" + exampleEd25519KeyB64 + "; t=" + c.t
|
||||
pk, err := parsePublicKey(pkS)
|
||||
if err != nil {
|
||||
t.Fatalf("%d: parsePublicKey(%q) = %v", i, pkS, err)
|
||||
}
|
||||
if ok := pk.StrictDomainCheck(); ok != c.ok {
|
||||
t.Errorf("%d: strictDomainCheck(t=%q) = %v, want %v",
|
||||
i, c.t, ok, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParsePublicKey(f *testing.F) {
|
||||
// Add some initial corpus from the tests above.
|
||||
f.Add("not a tag")
|
||||
f.Add("v=DKIM666;")
|
||||
f.Add("p=abc~*#def")
|
||||
f.Add("k=blah; p=" + exampleRSAKeyB64)
|
||||
f.Add("p=")
|
||||
f.Add("k=ed25519; p=")
|
||||
f.Add("k=ed25519; p=MFkwEwYH")
|
||||
f.Add("p=" + exampleEd25519KeyB64)
|
||||
f.Add("k=rsa ; p=" + exampleRSAKeyB64)
|
||||
f.Add("v=DKIM1; p=" + exampleRSAKeyB64)
|
||||
f.Add("t=s; p=" + exampleRSAKeyB64)
|
||||
f.Add("t = s : y; p=" + exampleRSAKeyB64)
|
||||
f.Add("k=rsa; h=sha256; p=" + exampleRSAKeyB64)
|
||||
f.Add("k=rsa; h=sha1:xxx123:sha256; p=" + exampleRSAKeyB64)
|
||||
|
||||
f.Fuzz(func(t *testing.T, in string) {
|
||||
parsePublicKey(in)
|
||||
})
|
||||
}
|
||||
235
internal/dkim/file_test.go
Normal file
235
internal/dkim/file_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestFromFiles(t *testing.T) {
|
||||
msgfs, err := filepath.Glob("testdata/*.msg")
|
||||
if err != nil {
|
||||
t.Fatalf("error finding test files: %v", err)
|
||||
}
|
||||
|
||||
for _, msgf := range msgfs {
|
||||
base := strings.TrimSuffix(msgf, filepath.Ext(msgf))
|
||||
t.Run(base, func(t *testing.T) { testOne(t, base) })
|
||||
}
|
||||
}
|
||||
|
||||
// This is the same as TestFromFiles, but it runs the private test files,
|
||||
// which are not included in the git repository.
|
||||
// This is useful for running tests on your own machine, with emails that you
|
||||
// don't necessarily want to share publicly.
|
||||
func TestFromPrivateFiles(t *testing.T) {
|
||||
msgfs, err := filepath.Glob("testdata/private/*/*.msg")
|
||||
if err != nil {
|
||||
t.Fatalf("error finding private test files: %v", err)
|
||||
}
|
||||
|
||||
for _, msgf := range msgfs {
|
||||
base := strings.TrimSuffix(msgf, filepath.Ext(msgf))
|
||||
t.Run(base, func(t *testing.T) { testOne(t, base) })
|
||||
}
|
||||
}
|
||||
|
||||
func testOne(t *testing.T, base string) {
|
||||
ctx := context.Background()
|
||||
ctx = WithTraceFunc(ctx, t.Logf)
|
||||
|
||||
ctx = loadDNS(t, ctx, base+".dns")
|
||||
msg := toCRLF(mustReadFile(t, base+".msg"))
|
||||
wantResult := loadResult(t, base+".result")
|
||||
wantError := loadError(t, base+".error")
|
||||
|
||||
t.Logf("Message: %.60q", msg)
|
||||
t.Logf("Want result: %+v", wantResult)
|
||||
t.Logf("Want error: %v", wantError)
|
||||
|
||||
res, err := VerifyMessage(ctx, msg)
|
||||
|
||||
// Write the results out for easy updating.
|
||||
writeResults(t, base, res, err)
|
||||
|
||||
diff := cmp.Diff(wantResult, res, cmp.Comparer(equalErrors))
|
||||
if diff != "" {
|
||||
t.Errorf("VerifyMessage result diff (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// We need to compare them by hand because cmp.Diff won't use our comparer
|
||||
// for top-level errors.
|
||||
if !equalErrors(wantError, err) {
|
||||
diff := cmp.Diff(wantError, err)
|
||||
t.Errorf("VerifyMessage error diff (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// Used to make cmp.Diff compare errors by their messages. This is obviously
|
||||
// not great, but it's good enough for this test.
|
||||
func equalErrors(a, b error) bool {
|
||||
if a == nil {
|
||||
return b == nil
|
||||
}
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
return a.Error() == b.Error()
|
||||
}
|
||||
|
||||
func mustReadFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return ""
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("error reading %q: %v", path, err)
|
||||
}
|
||||
return string(contents)
|
||||
}
|
||||
|
||||
func loadDNS(t *testing.T, ctx context.Context, path string) context.Context {
|
||||
t.Helper()
|
||||
|
||||
results := map[string][]string{}
|
||||
errors := map[string]error{}
|
||||
txtFunc := func(ctx context.Context, domain string) ([]string, error) {
|
||||
return results[domain], errors[domain]
|
||||
}
|
||||
ctx = WithLookupTXTFunc(ctx, txtFunc)
|
||||
|
||||
c := mustReadFile(t, path)
|
||||
|
||||
// Unfold \-terminated lines.
|
||||
c = strings.ReplaceAll(c, "\\\n", "")
|
||||
|
||||
for _, line := range strings.Split(c, "\n") {
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
domain, txt, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
domain = strings.TrimSpace(domain)
|
||||
|
||||
switch strings.TrimSpace(txt) {
|
||||
case "TEMPERROR":
|
||||
errors[domain] = &net.DNSError{
|
||||
Err: "temporary error (for testing)",
|
||||
IsTemporary: true,
|
||||
}
|
||||
case "PERMERROR":
|
||||
errors[domain] = &net.DNSError{
|
||||
Err: "permanent error (for testing)",
|
||||
IsTemporary: false,
|
||||
}
|
||||
case "NOTFOUND":
|
||||
errors[domain] = &net.DNSError{
|
||||
Err: "domain not found (for testing)",
|
||||
IsNotFound: true,
|
||||
}
|
||||
default:
|
||||
results[domain] = append(results[domain], txt)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Loaded DNS results: %#v", results)
|
||||
t.Logf("Loaded DNS errors: %v", errors)
|
||||
return ctx
|
||||
}
|
||||
|
||||
func loadResult(t *testing.T, path string) *VerifyResult {
|
||||
t.Helper()
|
||||
|
||||
res := &VerifyResult{}
|
||||
c := mustReadFile(t, path)
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(c), res)
|
||||
if err != nil {
|
||||
t.Fatalf("error unmarshalling %q: %v", path, err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func loadError(t *testing.T, path string) error {
|
||||
t.Helper()
|
||||
|
||||
c := strings.TrimSpace(mustReadFile(t, path))
|
||||
if c == "" || c == "nil" || c == "<nil>" {
|
||||
return nil
|
||||
}
|
||||
return errors.New(c)
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path string, c []byte) {
|
||||
t.Helper()
|
||||
err := os.WriteFile(path, c, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("error writing %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeResults(t *testing.T, base string, res *VerifyResult, err error) {
|
||||
t.Helper()
|
||||
|
||||
mustWriteFile(t, base+".error.got", []byte(fmt.Sprintf("%v", err)))
|
||||
|
||||
c, err := json.MarshalIndent(res, "", "\t")
|
||||
if err != nil {
|
||||
t.Fatalf("error marshalling result: %v", err)
|
||||
}
|
||||
mustWriteFile(t, base+".result.got", c)
|
||||
}
|
||||
|
||||
// Custom json marshaller so we can write errors as strings.
|
||||
func (or *OneResult) MarshalJSON() ([]byte, error) {
|
||||
// We use an alias to avoid infinite recursion.
|
||||
type Alias OneResult
|
||||
aux := &struct {
|
||||
Error string `json:""`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(or),
|
||||
}
|
||||
if or.Error != nil {
|
||||
aux.Error = or.Error.Error()
|
||||
}
|
||||
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
|
||||
// Custom json unmarshaller so we can read errors as strings.
|
||||
func (or *OneResult) UnmarshalJSON(b []byte) error {
|
||||
// We use an alias to avoid infinite recursion.
|
||||
type Alias OneResult
|
||||
aux := &struct {
|
||||
Error string `json:""`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(or),
|
||||
}
|
||||
if err := json.Unmarshal(b, aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if aux.Error != "" {
|
||||
or.Error = errors.New(aux.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
335
internal/dkim/header.go
Normal file
335
internal/dkim/header.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6
|
||||
|
||||
type dkimSignature struct {
|
||||
// Version. Must be "1".
|
||||
v string
|
||||
|
||||
// Algorithm. Like "rsa-sha256".
|
||||
a string
|
||||
|
||||
// Key type, extracted from a=.
|
||||
KeyType keyType
|
||||
|
||||
// Hash, extracted from a=.
|
||||
Hash crypto.Hash
|
||||
|
||||
// Signature data.
|
||||
// Decoded from base64, ignoring whitespace.
|
||||
b []byte
|
||||
|
||||
// Hash of canonicalized body.
|
||||
// Decoded from base64, ignoring whitespace.
|
||||
bh []byte
|
||||
|
||||
// Canonicalization modes.
|
||||
cH canonicalization
|
||||
cB canonicalization
|
||||
|
||||
// Domain ("SDID"), in plain text.
|
||||
// IDNs MUST be encoded as A-labels.
|
||||
d string
|
||||
|
||||
// Signed header fields.
|
||||
// Colon-separated list of header fields.
|
||||
h []string
|
||||
|
||||
// AUID, in plain text.
|
||||
i string
|
||||
|
||||
// Body octet count of the canonicalized body.
|
||||
l uint64
|
||||
|
||||
// Query methods used for DNS lookup.
|
||||
// Colon-separated list of methods. Only "dns/txt" is valid.
|
||||
q []string
|
||||
|
||||
// Selector.
|
||||
s string
|
||||
|
||||
// Timestamp. In Seconds since the UNIX epoch.
|
||||
t time.Time
|
||||
|
||||
// Signature expiration. In Seconds since the UNIX epoch.
|
||||
x time.Time
|
||||
|
||||
// Copied header fields.
|
||||
// Has a specific encoding but whitespace is ignored.
|
||||
z string
|
||||
}
|
||||
|
||||
func (sig *dkimSignature) canonicalizationFromString(s string) error {
|
||||
if s == "" {
|
||||
sig.cH = simpleCanonicalization
|
||||
sig.cB = simpleCanonicalization
|
||||
return nil
|
||||
}
|
||||
|
||||
// Either "header/body" or "header". In the latter case, "simple" is used
|
||||
// for the body canonicalization.
|
||||
// No whitespace around the '/' is allowed.
|
||||
hs, bs, _ := strings.Cut(s, "/")
|
||||
if bs == "" {
|
||||
bs = "simple"
|
||||
}
|
||||
|
||||
var err error
|
||||
sig.cH, err = stringToCanonicalization(hs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("header: %w", err)
|
||||
}
|
||||
sig.cB, err = stringToCanonicalization(bs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("body: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sig *dkimSignature) checkRequiredTags() error {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
|
||||
if sig.a == "" {
|
||||
return fmt.Errorf("%w: a=", errMissingRequiredTag)
|
||||
}
|
||||
if len(sig.b) == 0 {
|
||||
return fmt.Errorf("%w: b=", errMissingRequiredTag)
|
||||
}
|
||||
if len(sig.bh) == 0 {
|
||||
return fmt.Errorf("%w: bh=", errMissingRequiredTag)
|
||||
}
|
||||
if sig.d == "" {
|
||||
return fmt.Errorf("%w: d=", errMissingRequiredTag)
|
||||
}
|
||||
if len(sig.h) == 0 {
|
||||
return fmt.Errorf("%w: h=", errMissingRequiredTag)
|
||||
}
|
||||
if sig.s == "" {
|
||||
return fmt.Errorf("%w: s=", errMissingRequiredTag)
|
||||
}
|
||||
|
||||
// h= must contain From.
|
||||
var isFrom = func(s string) bool { return strings.EqualFold(s, "from") }
|
||||
if !slices.ContainsFunc(sig.h, isFrom) {
|
||||
return fmt.Errorf("%w: h= does not contain 'from'", errInvalidTag)
|
||||
}
|
||||
|
||||
// If i= is present, its domain must be equal to, or a subdomain of, d=.
|
||||
if sig.i != "" {
|
||||
_, domain, _ := strings.Cut(sig.i, "@")
|
||||
if domain != sig.d && !strings.HasSuffix(domain, "."+sig.d) {
|
||||
return fmt.Errorf("%w: i= is not a subdomain of d=",
|
||||
errInvalidTag)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
errInvalidSignature = errors.New("invalid signature")
|
||||
errInvalidVersion = errors.New("invalid version")
|
||||
errBadATag = errors.New("invalid a= tag")
|
||||
errUnsupportedHash = errors.New("unsupported hash")
|
||||
errUnsupportedKeyType = errors.New("unsupported key type")
|
||||
errMissingRequiredTag = errors.New("missing required tag")
|
||||
)
|
||||
|
||||
// String replacer that removes whitespace.
|
||||
var eatWhitespace = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "")
|
||||
|
||||
func dkimSignatureFromHeader(header string) (*dkimSignature, error) {
|
||||
tags, err := parseTags(header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sig := &dkimSignature{
|
||||
v: tags["v"],
|
||||
a: tags["a"],
|
||||
}
|
||||
|
||||
// v= tag is mandatory and must be 1.
|
||||
if sig.v != "1" {
|
||||
return nil, errInvalidVersion
|
||||
}
|
||||
|
||||
// a= tag is mandatory; check that we can parse it and that we support the
|
||||
// algorithms.
|
||||
ktS, hS, found := strings.Cut(sig.a, "-")
|
||||
if !found {
|
||||
return nil, errBadATag
|
||||
}
|
||||
sig.KeyType, err = keyTypeFromString(ktS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", err, sig.a)
|
||||
}
|
||||
sig.Hash, err = hashFromString(hS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", err, sig.a)
|
||||
}
|
||||
|
||||
// b is base64-encoded, and whitespace in it must be ignored.
|
||||
sig.b, err = base64.StdEncoding.DecodeString(
|
||||
eatWhitespace.Replace(tags["b"]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to decode b: %w",
|
||||
errInvalidSignature, err)
|
||||
}
|
||||
|
||||
// bh - same as b.
|
||||
sig.bh, err = base64.StdEncoding.DecodeString(
|
||||
eatWhitespace.Replace(tags["bh"]))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to decode bh: %w",
|
||||
errInvalidSignature, err)
|
||||
}
|
||||
|
||||
err = sig.canonicalizationFromString(tags["c"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to parse c: %w",
|
||||
errInvalidSignature, err)
|
||||
}
|
||||
|
||||
sig.d = tags["d"]
|
||||
|
||||
// h is a colon-separated list of header fields.
|
||||
if tags["h"] != "" {
|
||||
sig.h = strings.Split(eatWhitespace.Replace(tags["h"]), ":")
|
||||
}
|
||||
|
||||
sig.i = tags["i"]
|
||||
|
||||
if tags["l"] != "" {
|
||||
sig.l, err = strconv.ParseUint(tags["l"], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to parse l: %w",
|
||||
errInvalidSignature, err)
|
||||
}
|
||||
}
|
||||
|
||||
// q is a colon-separated list of query methods.
|
||||
if tags["q"] != "" {
|
||||
sig.q = strings.Split(eatWhitespace.Replace(tags["q"]), ":")
|
||||
}
|
||||
if len(sig.q) > 0 && !slices.Contains(sig.q, "dns/txt") {
|
||||
return nil, fmt.Errorf("%w: no dns/txt query method in q",
|
||||
errInvalidSignature)
|
||||
}
|
||||
|
||||
sig.s = tags["s"]
|
||||
|
||||
if tags["t"] != "" {
|
||||
sig.t, err = unixStrToTime(tags["t"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to parse t: %w",
|
||||
errInvalidSignature, err)
|
||||
}
|
||||
}
|
||||
|
||||
if tags["x"] != "" {
|
||||
sig.x, err = unixStrToTime(tags["x"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: failed to parse x: %w",
|
||||
errInvalidSignature, err)
|
||||
}
|
||||
}
|
||||
|
||||
sig.z = eatWhitespace.Replace(tags["z"])
|
||||
|
||||
// Check required tags are present.
|
||||
if err := sig.checkRequiredTags(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sig, nil
|
||||
}
|
||||
|
||||
func unixStrToTime(s string) (time.Time, error) {
|
||||
ti, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.Unix(int64(ti), 0), nil
|
||||
}
|
||||
|
||||
type keyType string
|
||||
|
||||
const (
|
||||
keyTypeRSA keyType = "rsa"
|
||||
keyTypeEd25519 keyType = "ed25519"
|
||||
)
|
||||
|
||||
func keyTypeFromString(s string) (keyType, error) {
|
||||
switch s {
|
||||
case "rsa":
|
||||
return keyTypeRSA, nil
|
||||
case "ed25519":
|
||||
return keyTypeEd25519, nil
|
||||
default:
|
||||
return "", errUnsupportedKeyType
|
||||
}
|
||||
}
|
||||
|
||||
func hashFromString(s string) (crypto.Hash, error) {
|
||||
switch s {
|
||||
// Note SHA1 is not supported: as per RFC 8301, it must not be used
|
||||
// for signing or verifying.
|
||||
// https://datatracker.ietf.org/doc/html/rfc8301#section-3.1
|
||||
case "sha256":
|
||||
return crypto.SHA256, nil
|
||||
default:
|
||||
return 0, errUnsupportedHash
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM Tag=Value lists, as defined in RFC 6376, Section 3.2.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.2
|
||||
type tags map[string]string
|
||||
|
||||
var errInvalidTag = errors.New("invalid tag")
|
||||
|
||||
func parseTags(s string) (tags, error) {
|
||||
// First trim space, and trailing semicolon, to simplify parsing below.
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimSuffix(s, ";")
|
||||
|
||||
tags := make(tags)
|
||||
for _, tv := range strings.Split(s, ";") {
|
||||
t, v, found := strings.Cut(tv, "=")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: missing '='", errInvalidTag)
|
||||
}
|
||||
|
||||
// Trim leading and trailing whitespace from tag and value, as per
|
||||
// RFC.
|
||||
t = strings.TrimSpace(t)
|
||||
v = strings.TrimSpace(v)
|
||||
|
||||
if t == "" {
|
||||
return nil, fmt.Errorf("%w: missing tag name", errInvalidTag)
|
||||
}
|
||||
|
||||
// RFC 6376, Section 3.2: Tags with duplicate names MUST NOT occur
|
||||
// within a single tag-list; if a tag name does occur more than once,
|
||||
// the entire tag-list is invalid.
|
||||
if _, exists := tags[t]; exists {
|
||||
return nil, fmt.Errorf("%w: duplicate tag", errInvalidTag)
|
||||
}
|
||||
|
||||
tags[t] = v
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
433
internal/dkim/header_test.go
Normal file
433
internal/dkim/header_test.go
Normal file
@@ -0,0 +1,433 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
func TestSignatureFromHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want *dkimSignature
|
||||
err error
|
||||
}{
|
||||
{
|
||||
in: "v=1; a=rsa-sha256",
|
||||
want: nil,
|
||||
err: errMissingRequiredTag,
|
||||
},
|
||||
{
|
||||
in: "v=1; a=rsa-sha256 ; c = simple/relaxed ;" +
|
||||
" d=example.com; h= from : to: subject ; " +
|
||||
"i=agent@example.com; l=77; q=dns/txt; " +
|
||||
"s=selector; t=1600700888; x=1600700999; " +
|
||||
"z=From:lala@lele | to:lili@lolo;" +
|
||||
"b=aG9sY\r\n SBxdWUgdGFs;" +
|
||||
"bh = Y29\ttby Bhbm Rhcw==",
|
||||
want: &dkimSignature{
|
||||
v: "1",
|
||||
a: "rsa-sha256",
|
||||
cH: simpleCanonicalization,
|
||||
cB: relaxedCanonicalization,
|
||||
d: "example.com",
|
||||
h: []string{"from", "to", "subject"},
|
||||
i: "agent@example.com",
|
||||
l: 77,
|
||||
q: []string{"dns/txt"},
|
||||
s: "selector",
|
||||
t: time.Unix(1600700888, 0),
|
||||
x: time.Unix(1600700999, 0),
|
||||
z: "From:lala@lele|to:lili@lolo",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
|
||||
KeyType: keyTypeRSA,
|
||||
Hash: crypto.SHA256,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Example from RFC.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.5
|
||||
in: "v=1; a=rsa-sha256; d=example.net; s=brisbane;\r\n" +
|
||||
" c=simple; q=dns/txt; i=@eng.example.net;\r\n" +
|
||||
" t=1117574938; x=1118006938;\r\n" +
|
||||
" h=from:to:subject:date;\r\n" +
|
||||
" z=From:foo@eng.example.net|To:joe@example.com|\r\n" +
|
||||
" Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;\r\n" +
|
||||
"bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;\r\n" +
|
||||
"b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniS" +
|
||||
"bav+yuU4zGeeruD00lszZVoG4ZHRNiYzR",
|
||||
want: &dkimSignature{
|
||||
v: "1",
|
||||
a: "rsa-sha256",
|
||||
d: "example.net",
|
||||
s: "brisbane",
|
||||
cH: simpleCanonicalization,
|
||||
cB: simpleCanonicalization,
|
||||
q: []string{"dns/txt"},
|
||||
i: "@eng.example.net",
|
||||
t: time.Unix(1117574938, 0),
|
||||
x: time.Unix(1118006938, 0),
|
||||
h: []string{"from", "to", "subject", "date"},
|
||||
z: "From:foo@eng.example.net|To:joe@example.com|" +
|
||||
"Subject:demo=20run|" +
|
||||
"Date:July=205,=202005=203:44:08=20PM=20-0700",
|
||||
bh: []byte("12345678901234567890123456789012"),
|
||||
b: []byte("w7U\xc8\xe7\xc0('K]\xd2Ns\xd1\xb6" +
|
||||
"\xab\xc2\xe8])D\x9e$\x9bj\xff\xb2\xb9N3" +
|
||||
"\x19\xe7\xab\xb8=4\x96\xcc\xd9V\x81\xb8" +
|
||||
"dtM\x89\x8c\xd1"),
|
||||
KeyType: keyTypeRSA,
|
||||
Hash: crypto.SHA256,
|
||||
},
|
||||
},
|
||||
{
|
||||
in: "",
|
||||
want: nil,
|
||||
err: errInvalidTag,
|
||||
},
|
||||
{
|
||||
in: "v=666",
|
||||
want: nil,
|
||||
err: errInvalidVersion,
|
||||
},
|
||||
{
|
||||
in: "v=1; a=something;",
|
||||
want: nil,
|
||||
err: errBadATag,
|
||||
},
|
||||
{
|
||||
// Invalid b= tag.
|
||||
in: "v=1; a=rsa-sha256; b=invalid",
|
||||
want: nil,
|
||||
err: base64.CorruptInputError(4),
|
||||
},
|
||||
{
|
||||
// Invalid bh= tag.
|
||||
in: "v=1; a=rsa-sha256; bh=invalid",
|
||||
want: nil,
|
||||
err: base64.CorruptInputError(4),
|
||||
},
|
||||
{
|
||||
// Invalid c= tag.
|
||||
in: "v=1; a=rsa-sha256; c=caca",
|
||||
want: nil,
|
||||
err: errUnknownCanonicalization,
|
||||
},
|
||||
{
|
||||
// Invalid l= tag.
|
||||
in: "v=1; a=rsa-sha256; l=a1234b",
|
||||
want: nil,
|
||||
err: strconv.ErrSyntax,
|
||||
},
|
||||
{
|
||||
// q= tag without dns/txt.
|
||||
in: "v=1; a=rsa-sha256; q=other/method",
|
||||
want: nil,
|
||||
err: errInvalidSignature,
|
||||
},
|
||||
{
|
||||
// Invalid t= tag.
|
||||
in: "v=1; a=rsa-sha256; t=a1234b",
|
||||
want: nil,
|
||||
err: strconv.ErrSyntax,
|
||||
},
|
||||
{
|
||||
// Invalid x= tag.
|
||||
in: "v=1; a=rsa-sha256; x=a1234b",
|
||||
want: nil,
|
||||
err: strconv.ErrSyntax,
|
||||
},
|
||||
{
|
||||
// Unknown hash algorithm.
|
||||
in: "v=1; a=rsa-sxa666",
|
||||
want: nil,
|
||||
err: errUnsupportedHash,
|
||||
},
|
||||
{
|
||||
// Unknown key type.
|
||||
in: "v=1; a=rxa-sha256",
|
||||
want: nil,
|
||||
err: errUnsupportedKeyType,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
sig, err := dkimSignatureFromHeader(c.in)
|
||||
diff := cmp.Diff(c.want, sig,
|
||||
cmp.AllowUnexported(dkimSignature{}),
|
||||
cmpopts.EquateEmpty(),
|
||||
)
|
||||
if diff != "" {
|
||||
t.Errorf("dkimSignatureFromHeader(%q) mismatch (-want +got):\n%s",
|
||||
c.in, diff)
|
||||
}
|
||||
if !errors.Is(err, c.err) {
|
||||
t.Errorf("dkimSignatureFromHeader(%q) error: got %v, want %v",
|
||||
c.in, err, c.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizationFromString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
cH, cB canonicalization
|
||||
err error
|
||||
}{
|
||||
{
|
||||
in: "",
|
||||
cH: simpleCanonicalization,
|
||||
cB: simpleCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "simple",
|
||||
cH: simpleCanonicalization,
|
||||
cB: simpleCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "relaxed",
|
||||
cH: relaxedCanonicalization,
|
||||
cB: simpleCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "simple/simple",
|
||||
cH: simpleCanonicalization,
|
||||
cB: simpleCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "relaxed/relaxed",
|
||||
cH: relaxedCanonicalization,
|
||||
cB: relaxedCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "simple/relaxed",
|
||||
cH: simpleCanonicalization,
|
||||
cB: relaxedCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "relaxed/bad",
|
||||
cH: relaxedCanonicalization,
|
||||
err: errUnknownCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "bad/relaxed",
|
||||
err: errUnknownCanonicalization,
|
||||
},
|
||||
{
|
||||
in: "bad",
|
||||
err: errUnknownCanonicalization,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
sig := &dkimSignature{}
|
||||
err := sig.canonicalizationFromString(c.in)
|
||||
if sig.cH != c.cH || sig.cB != c.cB || !errors.Is(err, c.err) {
|
||||
t.Errorf("canonicalizationFromString(%q) "+
|
||||
"got (%v, %v, %v), want (%v, %v, %v)",
|
||||
c.in, sig.cH, sig.cB, err, c.cH, c.cB, c.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRequiredTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
sig *dkimSignature
|
||||
err string
|
||||
}{
|
||||
{
|
||||
sig: &dkimSignature{},
|
||||
err: "missing required tag: a=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{a: "rsa-sha256"},
|
||||
err: "missing required tag: b=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{a: "rsa-sha256", b: []byte("hola que tal")},
|
||||
err: "missing required tag: bh=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
},
|
||||
err: "missing required tag: d=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
},
|
||||
err: "missing required tag: h=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"from"},
|
||||
},
|
||||
err: "missing required tag: s=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"subject"},
|
||||
s: "selector",
|
||||
},
|
||||
err: "invalid tag: h= does not contain 'from'",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"from"},
|
||||
s: "selector",
|
||||
i: "@example.net",
|
||||
},
|
||||
err: "invalid tag: i= is not a subdomain of d=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"from"},
|
||||
s: "selector",
|
||||
i: "@anexample.com", // i= is a substring but not subdomain.
|
||||
},
|
||||
err: "invalid tag: i= is not a subdomain of d=",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"From"}, // Capitalize to check case fold.
|
||||
s: "selector",
|
||||
i: "@example.com", // i= is the same as d=
|
||||
},
|
||||
err: "<nil>",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"From"},
|
||||
s: "selector",
|
||||
i: "@sub.example.com", // i= is a subdomain of d=
|
||||
},
|
||||
err: "<nil>",
|
||||
},
|
||||
{
|
||||
sig: &dkimSignature{
|
||||
a: "rsa-sha256",
|
||||
b: []byte("hola que tal"),
|
||||
bh: []byte("como andas"),
|
||||
d: "example.com",
|
||||
h: []string{"from"},
|
||||
s: "selector",
|
||||
},
|
||||
err: "<nil>",
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
err := c.sig.checkRequiredTags()
|
||||
got := fmt.Sprintf("%v", err)
|
||||
if c.err != got {
|
||||
t.Errorf("%d: checkRequiredTags() got %v, want %v",
|
||||
i, err, c.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTags(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want tags
|
||||
err error
|
||||
}{
|
||||
{
|
||||
in: "v=1; a=lalala; b = 123 ; c= 456;\t d \t= \t789\t ",
|
||||
want: tags{
|
||||
"v": "1",
|
||||
"a": "lalala",
|
||||
"b": "123",
|
||||
"c": "456",
|
||||
"d": "789",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
// Trailing semicolon.
|
||||
in: "v=1; a=lalala ; ",
|
||||
want: tags{
|
||||
"v": "1",
|
||||
"a": "lalala",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
// Missing tag value; this is okay.
|
||||
in: "v=1; b = ; c = d;",
|
||||
want: tags{
|
||||
"v": "1",
|
||||
"b": "",
|
||||
"c": "d",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
// Missing '='.
|
||||
in: "v=1; ; c = d;",
|
||||
want: nil,
|
||||
err: errInvalidTag,
|
||||
},
|
||||
{
|
||||
// Missing tag name.
|
||||
in: "v=1; = b ; c = d;",
|
||||
want: nil,
|
||||
err: errInvalidTag,
|
||||
},
|
||||
{
|
||||
// Duplicate tag.
|
||||
in: "v=1; a=b; a=c;",
|
||||
want: nil,
|
||||
err: errInvalidTag,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got, err := parseTags(c.in)
|
||||
if diff := cmp.Diff(c.want, got); diff != "" {
|
||||
t.Errorf("parseTags(%q) mismatch (-want +got):\n%s", c.in, diff)
|
||||
}
|
||||
if !errors.Is(err, c.err) {
|
||||
t.Errorf("parseTags(%q) error: got %v, want %v", c.in, err, c.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
77
internal/dkim/message.go
Normal file
77
internal/dkim/message.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type header struct {
|
||||
Name string
|
||||
Value string
|
||||
Source string
|
||||
}
|
||||
|
||||
type headers []header
|
||||
|
||||
// FindAll the headers with the given name, in order of appearance.
|
||||
func (h headers) FindAll(name string) headers {
|
||||
hs := make(headers, 0)
|
||||
for _, header := range h {
|
||||
if strings.EqualFold(header.Name, name) {
|
||||
hs = append(hs, header)
|
||||
}
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
var errInvalidHeader = errors.New("invalid header")
|
||||
|
||||
// Parse a RFC822 message, return the headers, body, and error if any.
|
||||
// We expect it to only contain CRLF line endings.
|
||||
// Does NOT touch whitespace, this is important to preserve the original
|
||||
// message and headers, which is required for the signature.
|
||||
func parseMessage(message string) (headers, string, error) {
|
||||
headers := make(headers, 0)
|
||||
lines := strings.Split(message, "\r\n")
|
||||
eoh := 0
|
||||
for i, line := range lines {
|
||||
if line == "" {
|
||||
eoh = i
|
||||
break
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
|
||||
// Continuation of the previous header.
|
||||
if len(headers) == 0 {
|
||||
return nil, "", fmt.Errorf(
|
||||
"%w: bad continuation", errInvalidHeader)
|
||||
}
|
||||
headers[len(headers)-1].Value += "\r\n" + line
|
||||
headers[len(headers)-1].Source += "\r\n" + line
|
||||
} else {
|
||||
// New header.
|
||||
h, err := parseHeader(line)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
headers = append(headers, h)
|
||||
}
|
||||
}
|
||||
|
||||
return headers, strings.Join(lines[eoh+1:], "\r\n"), nil
|
||||
}
|
||||
|
||||
func parseHeader(line string) (header, error) {
|
||||
name, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
return header{}, fmt.Errorf("%w: no colon", errInvalidHeader)
|
||||
}
|
||||
|
||||
return header{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Source: line,
|
||||
}, nil
|
||||
}
|
||||
99
internal/dkim/message_test.go
Normal file
99
internal/dkim/message_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"blitiri.com.ar/go/chasquid/internal/normalize"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
)
|
||||
|
||||
func TestParseMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
message string
|
||||
headers headers
|
||||
body string
|
||||
}{
|
||||
{
|
||||
message: normalize.StringToCRLF(`From: a@b
|
||||
To: c@d
|
||||
Subject: test
|
||||
Continues: This
|
||||
continues.
|
||||
|
||||
body`),
|
||||
headers: headers{
|
||||
header{Name: "From", Value: " a@b",
|
||||
Source: "From: a@b"},
|
||||
header{Name: "To", Value: " c@d",
|
||||
Source: "To: c@d"},
|
||||
header{Name: "Subject", Value: " test",
|
||||
Source: "Subject: test"},
|
||||
header{Name: "Continues", Value: " This\r\n continues.",
|
||||
Source: "Continues: This\r\n continues."},
|
||||
},
|
||||
body: "body",
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
headers, body, err := parseMessage(c.message)
|
||||
if diff := cmp.Diff(c.headers, headers); diff != "" {
|
||||
t.Errorf("parseMessage([%d]) headers mismatch (-want +got):\n%s",
|
||||
i, diff)
|
||||
}
|
||||
if diff := cmp.Diff(c.body, body); diff != "" {
|
||||
t.Errorf("parseMessage([%d]) body mismatch (-want +got):\n%s",
|
||||
i, diff)
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("parseMessage([%d]) error: %v", i, err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMessageWithErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
message string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
// Continuation without previous header.
|
||||
message: " continuation.",
|
||||
err: errInvalidHeader,
|
||||
},
|
||||
{
|
||||
// Header without ':'.
|
||||
message: "No colon",
|
||||
err: errInvalidHeader,
|
||||
},
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
_, _, err := parseMessage(c.message)
|
||||
if diff := cmp.Diff(c.err, err, cmpopts.EquateErrors()); diff != "" {
|
||||
t.Errorf("parseMessage([%d]) err mismatch (-want +got):\n%s",
|
||||
i, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadersFindAll(t *testing.T) {
|
||||
hs := headers{
|
||||
{Name: "From", Value: "a@b", Source: "From: a@b"},
|
||||
{Name: "To", Value: "c@d", Source: "To: c@d"},
|
||||
{Name: "Subject", Value: "test", Source: "Subject: test"},
|
||||
{Name: "fROm", Value: "z@y", Source: "fROm: z@y"},
|
||||
}
|
||||
|
||||
fromHs := hs.FindAll("froM")
|
||||
expected := headers{
|
||||
{Name: "From", Value: "a@b", Source: "From: a@b"},
|
||||
{Name: "fROm", Value: "z@y", Source: "fROm: z@y"},
|
||||
}
|
||||
if diff := cmp.Diff(expected, fromHs); diff != "" {
|
||||
t.Errorf("headers.Find() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
}
|
||||
198
internal/dkim/sign.go
Normal file
198
internal/dkim/sign.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Signer struct {
|
||||
// Domain to sign for.
|
||||
Domain string
|
||||
|
||||
// Selector to use.
|
||||
Selector string
|
||||
|
||||
// Signer containing the private key.
|
||||
// This can be an *rsa.PrivateKey or a ed25519.PrivateKey.
|
||||
Signer crypto.Signer
|
||||
}
|
||||
|
||||
var headersToSign = []string{
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.1
|
||||
"From", // Required.
|
||||
"Reply-To",
|
||||
"Subject",
|
||||
"Date",
|
||||
"To", "Cc",
|
||||
"Resent-Date", "Resent-From", "Resent-To", "Resent-Cc",
|
||||
"In-Reply-To", "References",
|
||||
"List-Id", "List-Help", "List-Unsubscribe", "List-Subscribe", "List-Post",
|
||||
"List-Owner", "List-Archive",
|
||||
|
||||
// Our additions.
|
||||
"Message-ID",
|
||||
}
|
||||
|
||||
var extraHeadersToSign = []string{
|
||||
// Headers to add an extra of, to prevent additions after signing.
|
||||
// If they're included here, they must be in headersToSign too.
|
||||
"From",
|
||||
"Subject", "Date",
|
||||
"To", "Cc",
|
||||
"Message-ID",
|
||||
}
|
||||
|
||||
// Sign the given message. Returns the *value* of the DKIM-Signature header to
|
||||
// be added to the message. It will usually be multi-line, but without
|
||||
// indenting.
|
||||
func (s *Signer) Sign(ctx context.Context, message string) (string, error) {
|
||||
headers, body, err := parseMessage(message)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
algoStr, err := s.algoStr()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
trace(ctx, "Signing for %s / %s with %s", s.Domain, s.Selector, algoStr)
|
||||
|
||||
dkimSignature := fmt.Sprintf(
|
||||
"v=1; a=%s; c=relaxed/relaxed;\r\n", algoStr)
|
||||
dkimSignature += fmt.Sprintf(
|
||||
"d=%s; s=%s; t=%d;\r\n", s.Domain, s.Selector, time.Now().Unix())
|
||||
|
||||
// Add the headers to sign.
|
||||
hsForHeader := []string{}
|
||||
for _, h := range headersToSign {
|
||||
// Append the header as many times as it appears in the message.
|
||||
for i := 0; i < len(headers.FindAll(h)); i++ {
|
||||
hsForHeader = append(hsForHeader, h)
|
||||
}
|
||||
}
|
||||
hsForHeader = append(hsForHeader, extraHeadersToSign...)
|
||||
|
||||
dkimSignature += fmt.Sprintf(
|
||||
"h=%s;\r\n", formatHeaders(hsForHeader))
|
||||
|
||||
// Compute and add bh= (body hash).
|
||||
bodyH := sha256.Sum256([]byte(
|
||||
relaxedCanonicalization.body(body)))
|
||||
dkimSignature += fmt.Sprintf(
|
||||
"bh=%s;\r\n", base64.StdEncoding.EncodeToString(bodyH[:]))
|
||||
|
||||
// Compute b= (signature).
|
||||
// First, the canonicalized headers.
|
||||
b := sha256.New()
|
||||
for _, h := range headersToSign {
|
||||
for _, header := range headers.FindAll(h) {
|
||||
hsrc := relaxedCanonicalization.header(header).Source + "\r\n"
|
||||
trace(ctx, "Hashing header: %q", hsrc)
|
||||
b.Write([]byte(hsrc))
|
||||
}
|
||||
}
|
||||
|
||||
// Now, the (canonicalized) DKIM-Signature header itself, but with an
|
||||
// empty b= tag, without a trailing \r\n, and ending with ";".
|
||||
// We include the ";" because we will add it at the end (see below). It is
|
||||
// legal not to include that final ";", we just choose to do so.
|
||||
// We replace \r\n with \r\n\t so the canonicalization considers them
|
||||
// proper continuations, and works correctly.
|
||||
dkimSignature += "b="
|
||||
dkimSignatureForSigning := strings.ReplaceAll(
|
||||
dkimSignature, "\r\n", "\r\n\t") + ";"
|
||||
relaxedDH := relaxedCanonicalization.header(header{
|
||||
Name: "DKIM-Signature",
|
||||
Value: dkimSignatureForSigning,
|
||||
Source: dkimSignatureForSigning,
|
||||
})
|
||||
b.Write([]byte(relaxedDH.Source))
|
||||
trace(ctx, "Hashing header: %q", relaxedDH.Source)
|
||||
bSum := b.Sum(nil)
|
||||
trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum))
|
||||
|
||||
// Finally, sign the hash.
|
||||
sig, err := s.sign(bSum)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sigb64 := base64.StdEncoding.EncodeToString(sig)
|
||||
|
||||
dkimSignature += breakLongLines(sigb64) + ";"
|
||||
|
||||
return dkimSignature, nil
|
||||
}
|
||||
|
||||
func (s *Signer) algoStr() (string, error) {
|
||||
switch k := s.Signer.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
return "rsa-sha256", nil
|
||||
case ed25519.PrivateKey:
|
||||
return "ed25519-sha256", nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %T", errUnsupportedKeyType, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Signer) sign(bSum []byte) ([]byte, error) {
|
||||
var h crypto.Hash
|
||||
switch s.Signer.(type) {
|
||||
case *rsa.PrivateKey:
|
||||
h = crypto.SHA256
|
||||
case ed25519.PrivateKey:
|
||||
h = crypto.Hash(0)
|
||||
}
|
||||
|
||||
return s.Signer.Sign(rand.Reader, bSum, h)
|
||||
}
|
||||
|
||||
func breakLongLines(s string) string {
|
||||
// Break long lines, indenting with 2 spaces for continuation (to make
|
||||
// it clear it's under the same tag).
|
||||
const limit = 70
|
||||
var sb strings.Builder
|
||||
for len(s) > 0 {
|
||||
if len(s) > limit {
|
||||
sb.WriteString(s[:limit])
|
||||
sb.WriteString("\r\n ")
|
||||
s = s[limit:]
|
||||
} else {
|
||||
sb.WriteString(s)
|
||||
s = ""
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatHeaders(hs []string) string {
|
||||
// Format the list of headers for inclusion in the DKIM-Signature header.
|
||||
// This includes converting them to lowercase, and line-wrapping.
|
||||
// Extra lines will be indented with 2 spaces, to make it clear they're
|
||||
// under the same tag.
|
||||
const limit = 70
|
||||
var sb strings.Builder
|
||||
line := ""
|
||||
for i, h := range hs {
|
||||
if len(line)+1+len(h) > limit {
|
||||
sb.WriteString(line + "\r\n ")
|
||||
line = ""
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
line += ":"
|
||||
}
|
||||
line += h
|
||||
}
|
||||
sb.WriteString(line)
|
||||
|
||||
return strings.TrimSpace(strings.ToLower(sb.String()))
|
||||
}
|
||||
257
internal/dkim/sign_test.go
Normal file
257
internal/dkim/sign_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
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 := context.Background()
|
||||
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": []string{
|
||||
"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 := context.Background()
|
||||
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": []string{
|
||||
"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 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(context.Background(), "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(context.Background(), 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
4
internal/dkim/testdata/.gitignore
vendored
Normal file
4
internal/dkim/testdata/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.got
|
||||
|
||||
# Ignore private test cases, to reduce the chances of accidental leaks.
|
||||
private/
|
||||
8
internal/dkim/testdata/01-rfc8463.dns
vendored
Normal file
8
internal/dkim/testdata/01-rfc8463.dns
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
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
|
||||
|
||||
1
internal/dkim/testdata/01-rfc8463.error
vendored
Normal file
1
internal/dkim/testdata/01-rfc8463.error
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<nil>
|
||||
27
internal/dkim/testdata/01-rfc8463.msg
vendored
Normal file
27
internal/dkim/testdata/01-rfc8463.msg
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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.
|
||||
|
||||
22
internal/dkim/testdata/01-rfc8463.result
vendored
Normal file
22
internal/dkim/testdata/01-rfc8463.result
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Found": 2,
|
||||
"Valid": 2,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "test",
|
||||
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
internal/dkim/testdata/02-too_many_headers.dns
vendored
Symbolic link
1
internal/dkim/testdata/02-too_many_headers.dns
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.dns
|
||||
1
internal/dkim/testdata/02-too_many_headers.error
vendored
Normal file
1
internal/dkim/testdata/02-too_many_headers.error
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<nil>
|
||||
62
internal/dkim/testdata/02-too_many_headers.msg
vendored
Normal file
62
internal/dkim/testdata/02-too_many_headers.msg
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
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=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=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=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=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=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.
|
||||
|
||||
5
internal/dkim/testdata/02-too_many_headers.readme
vendored
Normal file
5
internal/dkim/testdata/02-too_many_headers.readme
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Check that we don't process more than 5 headers.
|
||||
|
||||
The message contains 7 headers, but only the first 5 should be validated (and
|
||||
appear as valid).
|
||||
|
||||
46
internal/dkim/testdata/02-too_many_headers.result
vendored
Normal file
46
internal/dkim/testdata/02-too_many_headers.result
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Found": 5,
|
||||
"Valid": 5,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
internal/dkim/testdata/03-bad_message.error
vendored
Normal file
1
internal/dkim/testdata/03-bad_message.error
vendored
Normal file
@@ -0,0 +1 @@
|
||||
invalid header: bad continuation
|
||||
1
internal/dkim/testdata/03-bad_message.msg
vendored
Normal file
1
internal/dkim/testdata/03-bad_message.msg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is not a valid message.
|
||||
19
internal/dkim/testdata/04-bad_dkim_signature_header.msg
vendored
Normal file
19
internal/dkim/testdata/04-bad_dkim_signature_header.msg
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
DKIM-Signature: v=8; 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==
|
||||
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.
|
||||
|
||||
4
internal/dkim/testdata/04-bad_dkim_signature_header.readme
vendored
Normal file
4
internal/dkim/testdata/04-bad_dkim_signature_header.readme
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
Check that we reject invalid DKIM signature headers.
|
||||
|
||||
In this case, we force this by taking an otherwise valid header, but using v=8
|
||||
instead of v=1.
|
||||
14
internal/dkim/testdata/04-bad_dkim_signature_header.result
vendored
Normal file
14
internal/dkim/testdata/04-bad_dkim_signature_header.result
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Found": 1,
|
||||
"Valid": 0,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "invalid version",
|
||||
"SignatureHeader": " v=8; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "",
|
||||
"Selector": "",
|
||||
"B": "",
|
||||
"State": "PERMFAIL"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
internal/dkim/testdata/05-dns_temp_error.dns
vendored
Normal file
6
internal/dkim/testdata/05-dns_temp_error.dns
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
brisbane._domainkey.football.example.com: TEMPERROR
|
||||
|
||||
test._domainkey.football.example.com: \
|
||||
v=DKIM1; k=rsa; \
|
||||
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB
|
||||
|
||||
27
internal/dkim/testdata/05-dns_temp_error.msg
vendored
Normal file
27
internal/dkim/testdata/05-dns_temp_error.msg
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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.
|
||||
|
||||
22
internal/dkim/testdata/05-dns_temp_error.result
vendored
Normal file
22
internal/dkim/testdata/05-dns_temp_error.result
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Found": 2,
|
||||
"Valid": 1,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "lookup : temporary error (for testing)",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "TEMPFAIL"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "test",
|
||||
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
internal/dkim/testdata/06-dns_perm_error.dns
vendored
Normal file
6
internal/dkim/testdata/06-dns_perm_error.dns
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
brisbane._domainkey.football.example.com: PERMERROR
|
||||
|
||||
test._domainkey.football.example.com: \
|
||||
v=DKIM1; k=rsa; \
|
||||
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB
|
||||
|
||||
27
internal/dkim/testdata/06-dns_perm_error.msg
vendored
Normal file
27
internal/dkim/testdata/06-dns_perm_error.msg
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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.
|
||||
|
||||
22
internal/dkim/testdata/06-dns_perm_error.result
vendored
Normal file
22
internal/dkim/testdata/06-dns_perm_error.result
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Found": 2,
|
||||
"Valid": 1,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "lookup : permanent error (for testing)",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "PERMFAIL"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "test",
|
||||
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
internal/dkim/testdata/07-algo_mismatch.dns
vendored
Normal file
12
internal/dkim/testdata/07-algo_mismatch.dns
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
brisbane._domainkey.football.example.com: \
|
||||
v=DKIM1; k=rsa; \
|
||||
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB
|
||||
|
||||
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
|
||||
|
||||
1
internal/dkim/testdata/07-algo_mismatch.msg
vendored
Symbolic link
1
internal/dkim/testdata/07-algo_mismatch.msg
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.msg
|
||||
4
internal/dkim/testdata/07-algo_mismatch.readme
vendored
Normal file
4
internal/dkim/testdata/07-algo_mismatch.readme
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
In this test, one of the selectors has two valid TXT records with different
|
||||
key types.
|
||||
|
||||
Only one of them is valid.
|
||||
1
internal/dkim/testdata/07-algo_mismatch.result
vendored
Symbolic link
1
internal/dkim/testdata/07-algo_mismatch.result
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.result
|
||||
11
internal/dkim/testdata/08-our_signature.dns
vendored
Normal file
11
internal/dkim/testdata/08-our_signature.dns
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
selector._domainkey.example.com: \
|
||||
v=DKIM1; k=ed25519; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=
|
||||
|
||||
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
|
||||
|
||||
32
internal/dkim/testdata/08-our_signature.msg
vendored
Normal file
32
internal/dkim/testdata/08-our_signature.msg
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
|
||||
d=example.com; s=selector; t=1709341950;
|
||||
h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;
|
||||
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
|
||||
b=Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==;
|
||||
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.
|
||||
|
||||
30
internal/dkim/testdata/08-our_signature.result
vendored
Normal file
30
internal/dkim/testdata/08-our_signature.result
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Found": 3,
|
||||
"Valid": 3,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709341950;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==;",
|
||||
"Domain": "example.com",
|
||||
"Selector": "selector",
|
||||
"B": "Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "test",
|
||||
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
internal/dkim/testdata/09-limited_body.dns
vendored
Symbolic link
1
internal/dkim/testdata/09-limited_body.dns
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
08-our_signature.dns
|
||||
32
internal/dkim/testdata/09-limited_body.msg
vendored
Normal file
32
internal/dkim/testdata/09-limited_body.msg
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
|
||||
d=example.com; s=selector; t=1709368031;
|
||||
h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;
|
||||
l=17; bh=2Lb+x7ZAi8ljletRVg9Cn+VSkE36HadUTTOwsYyzZJg=;
|
||||
b=2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==;
|
||||
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.
|
||||
|
||||
3
internal/dkim/testdata/09-limited_body.readme
vendored
Normal file
3
internal/dkim/testdata/09-limited_body.readme
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
This test a DKIM signature that uses an l= tag.
|
||||
|
||||
It was constructed using an ad-hoc modified version of the signer.
|
||||
30
internal/dkim/testdata/09-limited_body.result
vendored
Normal file
30
internal/dkim/testdata/09-limited_body.result
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Found": 3,
|
||||
"Valid": 3,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709368031;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;\r\n l=17; bh=2Lb+x7ZAi8ljletRVg9Cn+VSkE36HadUTTOwsYyzZJg=;\r\n b=2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==;",
|
||||
"Domain": "example.com",
|
||||
"Selector": "selector",
|
||||
"B": "2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "test",
|
||||
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
internal/dkim/testdata/10-strict_domain_check_pass.dns
vendored
Normal file
8
internal/dkim/testdata/10-strict_domain_check_pass.dns
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
brisbane._domainkey.football.example.com: \
|
||||
v=DKIM1; k=ed25519; t=s; \
|
||||
p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
|
||||
|
||||
test._domainkey.football.example.com: \
|
||||
v=DKIM1; k=rsa; t=s; \
|
||||
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB
|
||||
|
||||
1
internal/dkim/testdata/10-strict_domain_check_pass.msg
vendored
Symbolic link
1
internal/dkim/testdata/10-strict_domain_check_pass.msg
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.msg
|
||||
22
internal/dkim/testdata/10-strict_domain_check_pass.result
vendored
Normal file
22
internal/dkim/testdata/10-strict_domain_check_pass.result
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Found": 2,
|
||||
"Valid": 2,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "brisbane",
|
||||
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
|
||||
"State": "SUCCESS"
|
||||
},
|
||||
{
|
||||
"Error": "",
|
||||
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"Domain": "football.example.com",
|
||||
"Selector": "test",
|
||||
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
|
||||
"State": "SUCCESS"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
internal/dkim/testdata/11-strict_domain_check_fail.dns
vendored
Normal file
2
internal/dkim/testdata/11-strict_domain_check_fail.dns
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
selector._domainkey.example.com: \
|
||||
v=DKIM1; k=ed25519; t=s; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=
|
||||
19
internal/dkim/testdata/11-strict_domain_check_fail.msg
vendored
Normal file
19
internal/dkim/testdata/11-strict_domain_check_fail.msg
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
|
||||
d=example.com; s=selector; t=1709466347;
|
||||
i=test@sub.example.com;
|
||||
h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;
|
||||
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
|
||||
b=NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSq
|
||||
b/xGMFTFmpSbNeCg==;
|
||||
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.
|
||||
|
||||
6
internal/dkim/testdata/11-strict_domain_check_fail.readme
vendored
Normal file
6
internal/dkim/testdata/11-strict_domain_check_fail.readme
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
Strict domain check is enabled, but fails.
|
||||
|
||||
This test has a DNS key with t=s, but the DKIM signature's i= is different
|
||||
than d= (but it is a subdomain, which is enforced at parsing time as per RFC).
|
||||
|
||||
It was constructed using an ad-hoc modified version of the signer.
|
||||
14
internal/dkim/testdata/11-strict_domain_check_fail.result
vendored
Normal file
14
internal/dkim/testdata/11-strict_domain_check_fail.result
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"Found": 1,
|
||||
"Valid": 0,
|
||||
"Results": [
|
||||
{
|
||||
"Error": "verification failed",
|
||||
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709466347;\r\n i=test@sub.example.com;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSq\r\n b/xGMFTFmpSbNeCg==;",
|
||||
"Domain": "example.com",
|
||||
"Selector": "selector",
|
||||
"B": "NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSqb/xGMFTFmpSbNeCg==",
|
||||
"State": "PERMFAIL"
|
||||
}
|
||||
]
|
||||
}
|
||||
310
internal/dkim/verify.go
Normal file
310
internal/dkim/verify.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// These two errors are returned when the verification fails, but the header
|
||||
// is considered valid.
|
||||
var (
|
||||
ErrBodyHashMismatch = errors.New("body hash mismatch")
|
||||
ErrVerificationFailed = errors.New("verification failed")
|
||||
)
|
||||
|
||||
// Evaluation states, as per
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.9.
|
||||
type EvaluationState string
|
||||
|
||||
const (
|
||||
SUCCESS EvaluationState = "SUCCESS"
|
||||
PERMFAIL EvaluationState = "PERMFAIL"
|
||||
TEMPFAIL EvaluationState = "TEMPFAIL"
|
||||
)
|
||||
|
||||
type VerifyResult struct {
|
||||
// How many signatures were found.
|
||||
Found uint
|
||||
|
||||
// How many signatures were verified successfully.
|
||||
Valid uint
|
||||
|
||||
// The details for each signature that was found.
|
||||
Results []*OneResult
|
||||
}
|
||||
|
||||
type OneResult struct {
|
||||
// The raw signature header.
|
||||
SignatureHeader string
|
||||
|
||||
// Domain and selector from the signature header.
|
||||
Domain string
|
||||
Selector string
|
||||
|
||||
// Base64-encoded signature. May be missing if it is not present in the
|
||||
// header.
|
||||
B string
|
||||
|
||||
// The result of the evaluation.
|
||||
State EvaluationState
|
||||
Error error
|
||||
}
|
||||
|
||||
// Returns the DKIM-specific contents for an Authentication-Results header.
|
||||
// It is just the contents, the header needs to still be constructed.
|
||||
// Note that the output will need to be indented by the caller.
|
||||
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
|
||||
func (r *VerifyResult) AuthenticationResults() string {
|
||||
// The weird placement of the ";" is due to the specification saying they
|
||||
// have to be before each method, not at the end.
|
||||
// By doing it this way, we can concate the output of this function with
|
||||
// other results.
|
||||
ar := &strings.Builder{}
|
||||
if r.Found == 0 {
|
||||
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
|
||||
ar.WriteString(";dkim=none\r\n")
|
||||
return ar.String()
|
||||
}
|
||||
|
||||
for _, res := range r.Results {
|
||||
// Map state to the corresponding result.
|
||||
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
|
||||
switch res.State {
|
||||
case SUCCESS:
|
||||
ar.WriteString(";dkim=pass")
|
||||
case TEMPFAIL:
|
||||
// The reason must come before the properties, include it here.
|
||||
fmt.Fprintf(ar, ";dkim=temperror reason=%q\r\n", res.Error)
|
||||
case PERMFAIL:
|
||||
// The reason must come before the properties, include it here.
|
||||
if errors.Is(res.Error, ErrVerificationFailed) ||
|
||||
errors.Is(res.Error, ErrBodyHashMismatch) {
|
||||
fmt.Fprintf(ar, ";dkim=fail reason=%q\r\n", res.Error)
|
||||
} else {
|
||||
fmt.Fprintf(ar, ";dkim=permerror reason=%q\r\n", res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if res.B != "" {
|
||||
// Include a partial b= tag to help identify which signature
|
||||
// is being referred to.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6008#section-4
|
||||
fmt.Fprintf(ar, " header.b=%.12s", res.B)
|
||||
}
|
||||
|
||||
ar.WriteString(" header.d=" + res.Domain + "\r\n")
|
||||
}
|
||||
|
||||
return ar.String()
|
||||
}
|
||||
|
||||
func VerifyMessage(ctx context.Context, message string) (*VerifyResult, error) {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6
|
||||
headers, body, err := parseMessage(message)
|
||||
if err != nil {
|
||||
trace(ctx, "Error parsing message: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := &VerifyResult{
|
||||
Results: []*OneResult{},
|
||||
}
|
||||
|
||||
for i, sig := range headers.FindAll("DKIM-Signature") {
|
||||
trace(ctx, "Found DKIM-Signature header: %s", sig.Value)
|
||||
|
||||
if i >= maxHeaders(ctx) {
|
||||
// Protect from potential DoS by capping the number of signatures.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-4.2
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-8.4
|
||||
trace(ctx, "Too many DKIM-Signature headers found")
|
||||
break
|
||||
}
|
||||
|
||||
results.Found++
|
||||
res := verifySignature(ctx, sig, headers, body)
|
||||
results.Results = append(results.Results, res)
|
||||
if res.State == SUCCESS {
|
||||
results.Valid++
|
||||
}
|
||||
}
|
||||
|
||||
trace(ctx, "Found %d signatures, %d valid", results.Found, results.Valid)
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Regular expression that matches the "b=" tag.
|
||||
// First capture group is the "b=" part (including any whitespace up to the
|
||||
// '=').
|
||||
var bTag = regexp.MustCompile(`(b[ \t\r\n]*=)[^;]+`)
|
||||
|
||||
func verifySignature(ctx context.Context, sigH header,
|
||||
headers headers, body string) *OneResult {
|
||||
result := &OneResult{
|
||||
SignatureHeader: sigH.Value,
|
||||
}
|
||||
|
||||
sig, err := dkimSignatureFromHeader(sigH.Value)
|
||||
if err != nil {
|
||||
// Header validation errors are a PERMFAIL.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
|
||||
result.Error = err
|
||||
result.State = PERMFAIL
|
||||
return result
|
||||
}
|
||||
|
||||
result.Domain = sig.d
|
||||
result.Selector = sig.s
|
||||
result.B = base64.StdEncoding.EncodeToString(sig.b)
|
||||
|
||||
// Get the public key.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
|
||||
pubKeys, err := findPublicKeys(ctx, sig.d, sig.s)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
|
||||
// DNS errors when looking up the public key are a TEMPFAIL; all
|
||||
// others are PERMFAIL.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
|
||||
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.Temporary() {
|
||||
result.State = TEMPFAIL
|
||||
} else {
|
||||
result.State = PERMFAIL
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Compute the verification.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3
|
||||
|
||||
// Step 1: Prepare a canonicalized version of the body, truncate it to l=
|
||||
// (if present).
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
|
||||
bodyC := sig.cB.body(body)
|
||||
if sig.l > 0 {
|
||||
bodyC = bodyC[:sig.l]
|
||||
}
|
||||
|
||||
// Step 2: Compute the hash of the canonicalized body.
|
||||
bodyH := hashWith(sig.Hash, []byte(bodyC))
|
||||
|
||||
// Step 3: Verify the hash of the body by comparing it with bh=.
|
||||
if !bytes.Equal(bodyH, sig.bh) {
|
||||
bodyHStr := base64.StdEncoding.EncodeToString(bodyH)
|
||||
trace(ctx, "Body hash mismatch: %q", bodyHStr)
|
||||
|
||||
result.Error = fmt.Errorf("%w (got %s)",
|
||||
ErrBodyHashMismatch, bodyHStr)
|
||||
result.State = PERMFAIL
|
||||
return result
|
||||
}
|
||||
trace(ctx, "Body hash matches: %q",
|
||||
base64.StdEncoding.EncodeToString(bodyH))
|
||||
|
||||
// Step 4 A: Hash the (canonicalized) headers that appear in the h= tag.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
|
||||
b := sig.Hash.New()
|
||||
for _, header := range headersToInclude(sigH, sig.h, headers) {
|
||||
hsrc := sig.cH.header(header).Source + "\r\n"
|
||||
trace(ctx, "Hashing header: %q", hsrc)
|
||||
b.Write([]byte(hsrc))
|
||||
}
|
||||
|
||||
// Step 4 B: Hash the (canonicalized) DKIM-Signature header itself, but
|
||||
// with an empty b= tag, and without a trailing \r\n.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
|
||||
sigC := sig.cH.header(sigH)
|
||||
sigCStr := bTag.ReplaceAllString(sigC.Source, "$1")
|
||||
trace(ctx, "Hashing header: %q", sigCStr)
|
||||
b.Write([]byte(sigCStr))
|
||||
bSum := b.Sum(nil)
|
||||
trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum))
|
||||
|
||||
// Step 4 C: Validate the signature.
|
||||
for _, pubKey := range pubKeys {
|
||||
if !pubKey.Matches(sig.KeyType, sig.Hash) {
|
||||
trace(ctx, "PK %v: key type or hash mismatch, skipping", pubKey)
|
||||
continue
|
||||
}
|
||||
|
||||
if sig.i != "" && pubKey.StrictDomainCheck() {
|
||||
_, domain, _ := strings.Cut(sig.i, "@")
|
||||
if domain != sig.d {
|
||||
trace(ctx, "PK %v: Strict domain check failed: %q != %q (%q)",
|
||||
pubKey, sig.d, domain, sig.i)
|
||||
continue
|
||||
}
|
||||
|
||||
trace(ctx, "PK %v: Strict domain check passed", pubKey)
|
||||
}
|
||||
|
||||
err := pubKey.verify(sig.Hash, bSum, sig.b)
|
||||
if err != nil {
|
||||
trace(ctx, "PK %v: Verification failed: %v", pubKey, err)
|
||||
continue
|
||||
}
|
||||
trace(ctx, "PK %v: Verification succeeded", pubKey)
|
||||
result.State = SUCCESS
|
||||
return result
|
||||
}
|
||||
|
||||
result.State = PERMFAIL
|
||||
result.Error = ErrVerificationFailed
|
||||
return result
|
||||
}
|
||||
|
||||
func headersToInclude(sigH header, hTag []string, headers headers) []header {
|
||||
// Return the actual headers to include in the hash, based on the list
|
||||
// given in the h= tag.
|
||||
// This is complicated because:
|
||||
// - Headers can be included multiple times. In that case, we must pick
|
||||
// the last instance (which hasn't been already included).
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.2
|
||||
// - Headers may appear fewer times than they are requested.
|
||||
// - DKIM-Signature header may be included, but we must not include the
|
||||
// one being verified.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
|
||||
// - Headers may be missing, and that's allowed.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4
|
||||
seen := map[string]int{}
|
||||
include := []header{}
|
||||
for _, h := range hTag {
|
||||
all := headers.FindAll(h)
|
||||
slices.Reverse(all)
|
||||
|
||||
// We keep track of the last instance of each header that we
|
||||
// included, and find the next one every time it appears in h=.
|
||||
// We have to be careful because the header itself may not be present,
|
||||
// or we may be asked to include it more times than it appears.
|
||||
lh := strings.ToLower(h)
|
||||
i := seen[lh]
|
||||
if i >= len(all) {
|
||||
continue
|
||||
}
|
||||
seen[lh]++
|
||||
|
||||
selected := all[i]
|
||||
|
||||
if selected == sigH {
|
||||
continue
|
||||
}
|
||||
|
||||
include = append(include, selected)
|
||||
}
|
||||
|
||||
return include
|
||||
}
|
||||
|
||||
func hashWith(a crypto.Hash, data []byte) []byte {
|
||||
h := a.New()
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
415
internal/dkim/verify_test.go
Normal file
415
internal/dkim/verify_test.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package dkim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 := context.Background()
|
||||
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": []string{
|
||||
"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 := context.Background()
|
||||
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": []string{
|
||||
"v=DKIM1; k=ed25519; " +
|
||||
"p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="},
|
||||
|
||||
"test._domainkey.football.example.com": []string{
|
||||
"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 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;",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user