1
0
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:
Alberto Bertogli
2024-02-10 23:55:05 +00:00
parent f13fdf0ac8
commit 76a72367ae
90 changed files with 4902 additions and 112 deletions

View 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
}

View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
}

View 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
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1 @@
<nil>

27
internal/dkim/testdata/01-rfc8463.msg vendored Normal file
View 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.

View 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"
}
]
}

View File

@@ -0,0 +1 @@
01-rfc8463.dns

View File

@@ -0,0 +1 @@
<nil>

View 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.

View 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).

View 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"
}
]
}

View File

@@ -0,0 +1 @@
invalid header: bad continuation

View File

@@ -0,0 +1 @@
This is not a valid message.

View 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.

View 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.

View 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"
}
]
}

View 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

View 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.

View 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"
}
]
}

View 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

View 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.

View 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"
}
]
}

View 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

View File

@@ -0,0 +1 @@
01-rfc8463.msg

View 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.

View File

@@ -0,0 +1 @@
01-rfc8463.result

View 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

View 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.

View 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"
}
]
}

View File

@@ -0,0 +1 @@
08-our_signature.dns

View 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.

View 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.

View 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"
}
]
}

View 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

View File

@@ -0,0 +1 @@
01-rfc8463.msg

View 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"
}
]
}

View File

@@ -0,0 +1,2 @@
selector._domainkey.example.com: \
v=DKIM1; k=ed25519; t=s; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=

View 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.

View 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.

View 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
View 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)
}

View 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)
}
}
}