git » chasquid » commit 76a7236

dkim: Implement internal dkim signing and verification

author Alberto Bertogli
2024-02-10 23:55:05 UTC
committer Alberto Bertogli
2024-03-12 20:43:21 UTC
parent f13fdf0ac8340834529ddc119ed3f210367ab87d

dkim: Implement internal dkim signing and verification

This patch implements internal DKIM signing and verification.

README.md +3 -2
chasquid.go +30 -0
cmd/chasquid-util/chasquid-util.go +14 -0
cmd/chasquid-util/dkim.go +260 -0
cmd/chasquid-util/test.sh +4 -1
cmd/chasquid-util/test_dkim.cmy +190 -0
cmd/chasquid-util/test_openssl_genpkey_ed25519.pem +3 -0
cmd/chasquid-util/test_openssl_genpkey_rsa.pem +28 -0
docs/dkim.md +62 -42
docs/monitoring.md +12 -0
etc/chasquid/hooks/post-data +0 -44
internal/dkim/canonicalize.go +158 -0
internal/dkim/canonicalize_test.go +214 -0
internal/dkim/context.go +56 -0
internal/dkim/context_test.go +67 -0
internal/dkim/dns.go +201 -0
internal/dkim/dns_test.go +248 -0
internal/dkim/file_test.go +235 -0
internal/dkim/header.go +335 -0
internal/dkim/header_test.go +433 -0
internal/dkim/message.go +77 -0
internal/dkim/message_test.go +99 -0
internal/dkim/sign.go +198 -0
internal/dkim/sign_test.go +257 -0
internal/dkim/testdata/.gitignore +4 -0
internal/dkim/testdata/01-rfc8463.dns +8 -0
internal/dkim/testdata/01-rfc8463.error +1 -0
internal/dkim/testdata/01-rfc8463.msg +27 -0
internal/dkim/testdata/01-rfc8463.result +22 -0
internal/dkim/testdata/02-too_many_headers.dns +1 -0
internal/dkim/testdata/02-too_many_headers.error +1 -0
internal/dkim/testdata/02-too_many_headers.msg +62 -0
internal/dkim/testdata/02-too_many_headers.readme +5 -0
internal/dkim/testdata/02-too_many_headers.result +46 -0
internal/dkim/testdata/03-bad_message.error +1 -0
internal/dkim/testdata/03-bad_message.msg +1 -0
internal/dkim/testdata/04-bad_dkim_signature_header.msg +19 -0
internal/dkim/testdata/04-bad_dkim_signature_header.readme +4 -0
internal/dkim/testdata/04-bad_dkim_signature_header.result +14 -0
internal/dkim/testdata/05-dns_temp_error.dns +6 -0
internal/dkim/testdata/05-dns_temp_error.msg +27 -0
internal/dkim/testdata/05-dns_temp_error.result +22 -0
internal/dkim/testdata/06-dns_perm_error.dns +6 -0
internal/dkim/testdata/06-dns_perm_error.msg +27 -0
internal/dkim/testdata/06-dns_perm_error.result +22 -0
internal/dkim/testdata/07-algo_mismatch.dns +12 -0
internal/dkim/testdata/07-algo_mismatch.msg +1 -0
internal/dkim/testdata/07-algo_mismatch.readme +4 -0
internal/dkim/testdata/07-algo_mismatch.result +1 -0
internal/dkim/testdata/08-our_signature.dns +11 -0
internal/dkim/testdata/08-our_signature.msg +32 -0
internal/dkim/testdata/08-our_signature.result +30 -0
internal/dkim/testdata/09-limited_body.dns +1 -0
internal/dkim/testdata/09-limited_body.msg +32 -0
internal/dkim/testdata/09-limited_body.readme +3 -0
internal/dkim/testdata/09-limited_body.result +30 -0
internal/dkim/testdata/10-strict_domain_check_pass.dns +8 -0
internal/dkim/testdata/10-strict_domain_check_pass.msg +1 -0
internal/dkim/testdata/10-strict_domain_check_pass.result +22 -0
internal/dkim/testdata/11-strict_domain_check_fail.dns +2 -0
internal/dkim/testdata/11-strict_domain_check_fail.msg +19 -0
internal/dkim/testdata/11-strict_domain_check_fail.readme +6 -0
internal/dkim/testdata/11-strict_domain_check_fail.result +14 -0
internal/dkim/verify.go +310 -0
internal/dkim/verify_test.go +415 -0
internal/normalize/normalize.go +5 -0
internal/normalize/normalize_test.go +5 -0
internal/queue/queue.go +2 -0
internal/smtpsrv/conn.go +145 -14
internal/smtpsrv/server.go +55 -0
internal/smtpsrv/server_test.go +65 -0
internal/trace/trace.go +3 -3
test/t-04-aliases/chasquid-util.sh +1 -1
test/t-19-dkimpy/config/hooks/post-data +0 -1
test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error +1 -0
test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf +9 -0
test/t-20-bad_configs/c-11-bad_dkim_key/domains/testserver/dkim:selector.pem +1 -0
test/t-20-bad_configs/run.sh +2 -1
test/t-21-dkim/.gitignore +2 -0
test/t-21-dkim/A/chasquid.conf +9 -0
test/t-21-dkim/A/s1._domainkey.srv-a.pem +3 -0
test/t-21-dkim/B/chasquid.conf +9 -0
test/t-21-dkim/from_A_to_B +11 -0
test/t-21-dkim/from_A_to_B.expected +14 -0
test/t-21-dkim/from_B_to_A +5 -0
test/t-21-dkim/from_B_to_A.expected +15 -0
test/t-21-dkim/run.sh +67 -0
test/t-21-dkim/zones +6 -0
test/util/chamuyero +1 -1
test/util/lib.sh +1 -1

diff --git a/README.md b/README.md
index ed45850..a220945 100644
--- a/README.md
+++ b/README.md
@@ -29,17 +29,18 @@ It's written in [Go](https://golang.org), and distributed under the
 * Useful
     * Multiple/virtual domains, with per-domain users and aliases.
     * Suffix dropping (`user+something@domain` → `user@domain`).
-    * [Hooks] for integration with greylisting, anti-virus, anti-spam, and
-      DKIM/DMARC.
+    * [Hooks] for integration with greylisting, anti-virus, and anti-spam.
     * International usernames ([SMTPUTF8]) and domain names ([IDNA]).
 * Secure
     * [Tracking] of per-domain TLS support, prevents connection downgrading.
     * Multiple TLS certificates.
     * Easy integration with [Let's Encrypt].
     * [SPF] and [MTA-STS] checking.
+    * [DKIM] support (signing and verification).
 
 
 [Arch]: https://blitiri.com.ar/p/chasquid/install/#arch
+[DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
 [Debian]: https://blitiri.com.ar/p/chasquid/install/#debianubuntu
 [Dovecot]: https://blitiri.com.ar/p/chasquid/dovecot/
 [Hooks]: https://blitiri.com.ar/p/chasquid/hooks/
diff --git a/chasquid.go b/chasquid.go
index b1585b6..e3bbe67 100644
--- a/chasquid.go
+++ b/chasquid.go
@@ -12,7 +12,9 @@ import (
 	"net"
 	"os"
 	"os/signal"
+	"path"
 	"path/filepath"
+	"strings"
 	"syscall"
 	"time"
 
@@ -297,6 +299,14 @@ func loadDomain(name, dir string, s *smtpsrv.Server) {
 	if err != nil {
 		log.Errorf("    aliases file error: %v", err)
 	}
+
+	err = loadDKIM(name, dir, s)
+	if err != nil {
+		// DKIM errors are fatal because if the user set DKIM up, then we
+		// don't want it to be failing silently, as that could cause
+		// deliverability issues.
+		log.Fatalf("    DKIM loading error: %v", err)
+	}
 }
 
 func loadDovecot(s *smtpsrv.Server, userdb, client string) {
@@ -309,6 +319,26 @@ func loadDovecot(s *smtpsrv.Server, userdb, client string) {
 	}
 }
 
+func loadDKIM(domain, dir string, s *smtpsrv.Server) error {
+	glob := path.Clean(dir + "/dkim:*.pem")
+	pems, err := filepath.Glob(glob)
+	if err != nil {
+		return err
+	}
+
+	for _, pem := range pems {
+		base := filepath.Base(pem)
+		selector := strings.TrimPrefix(base, "dkim:")
+		selector = strings.TrimSuffix(selector, ".pem")
+
+		err = s.AddDKIMSigner(domain, selector, pem)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // Read a directory, which must have at least some entries.
 func mustReadDir(path string) []os.DirEntry {
 	dirs, err := os.ReadDir(path)
diff --git a/cmd/chasquid-util/chasquid-util.go b/cmd/chasquid-util/chasquid-util.go
index 6f6ad64..cb1cdcc 100644
--- a/cmd/chasquid-util/chasquid-util.go
+++ b/cmd/chasquid-util/chasquid-util.go
@@ -39,8 +39,15 @@ Usage:
   chasquid-util [options] print-config
     Print the current chasquid configuration.
 
+  chasquid-util [options] dkim-keygen <domain> [<selector> <private-key.pem>] [--algo=rsa3072|rsa4096|ed25519]
+    Generate a new DKIM key pair for the domain.
+  chasquid-util [options] dkim-dns <domain> [<selector> <private-key.pem>]
+    Print the DNS TXT record to use for the domain, selector and
+    private key.
+
 Options:
   -C=<path>, --configdir=<path>  Configuration directory
+  -v                             Verbose mode
 `
 
 // Command-line arguments.
@@ -80,6 +87,13 @@ func main() {
 		"aliases-resolve":   aliasesResolve,
 		"print-config":      printConfig,
 		"domaininfo-remove": domaininfoRemove,
+		"dkim-keygen":       dkimKeygen,
+		"dkim-dns":          dkimDNS,
+
+		// These exist for testing purposes and may be removed in the future.
+		// Do not rely on them.
+		"dkim-verify": dkimVerify,
+		"dkim-sign":   dkimSign,
 	}
 
 	cmd := args["$1"]
diff --git a/cmd/chasquid-util/dkim.go b/cmd/chasquid-util/dkim.go
new file mode 100644
index 0000000..90c4390
--- /dev/null
+++ b/cmd/chasquid-util/dkim.go
@@ -0,0 +1,260 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"net/mail"
+	"os"
+	"path"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"blitiri.com.ar/go/chasquid/internal/dkim"
+	"blitiri.com.ar/go/chasquid/internal/envelope"
+	"blitiri.com.ar/go/chasquid/internal/normalize"
+)
+
+func dkimSign() {
+	domain := args["$2"]
+	selector := args["$3"]
+	keyPath := args["$4"]
+
+	msg, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		Fatalf("%v", err)
+	}
+	msg = normalize.ToCRLF(msg)
+
+	if domain == "" {
+		domain = getDomainFromMsg(msg)
+	}
+	if selector == "" {
+		selector = findSelectorForDomain(domain)
+	}
+	if keyPath == "" {
+		keyPath = keyPathFor(domain, selector)
+	}
+
+	signer := &dkim.Signer{
+		Domain:   domain,
+		Selector: selector,
+		Signer:   loadPrivateKey(keyPath),
+	}
+
+	ctx := context.Background()
+	if _, verbose := args["-v"]; verbose {
+		ctx = dkim.WithTraceFunc(ctx,
+			func(format string, args ...interface{}) {
+				fmt.Fprintf(os.Stderr, format+"\n", args...)
+			})
+	}
+
+	header, err := signer.Sign(ctx, string(msg))
+	if err != nil {
+		Fatalf("Error signing message: %v", err)
+	}
+	fmt.Printf("DKIM-Signature: %s\r\n",
+		strings.ReplaceAll(header, "\r\n", "\r\n\t"))
+}
+
+func dkimVerify() {
+	msg, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		Fatalf("%v", err)
+	}
+	msg = normalize.ToCRLF(msg)
+
+	ctx := context.Background()
+	if _, verbose := args["-v"]; verbose {
+		ctx = dkim.WithTraceFunc(ctx,
+			func(format string, args ...interface{}) {
+				fmt.Fprintf(os.Stderr, format+"\n", args...)
+			})
+	}
+
+	results, err := dkim.VerifyMessage(ctx, string(msg))
+	if err != nil {
+		Fatalf("Error verifying message: %v", err)
+	}
+
+	hostname, _ := os.Hostname()
+	ar := "Authentication-Results: " + hostname + "\r\n\t"
+	ar += strings.ReplaceAll(
+		results.AuthenticationResults(), "\r\n", "\r\n\t")
+
+	fmt.Println(ar)
+}
+
+func dkimDNS() {
+	domain := args["$2"]
+	selector := args["$3"]
+	keyPath := args["$4"]
+
+	if domain == "" {
+		Fatalf("Error: missing domain parameter")
+	}
+	if selector == "" {
+		selector = findSelectorForDomain(domain)
+	}
+	if keyPath == "" {
+		keyPath = keyPathFor(domain, selector)
+	}
+
+	fmt.Println(dnsRecordFor(domain, selector, loadPrivateKey(keyPath)))
+}
+
+func dnsRecordFor(domain, selector string, private crypto.Signer) string {
+	public := private.Public()
+
+	var err error
+	algoStr := ""
+	pubBytes := []byte{}
+	switch private.(type) {
+	case *rsa.PrivateKey:
+		algoStr = "rsa"
+		pubBytes, err = x509.MarshalPKIXPublicKey(public)
+	case ed25519.PrivateKey:
+		algoStr = "ed25519"
+		pubBytes = public.(ed25519.PublicKey)
+	}
+
+	if err != nil {
+		Fatalf("Error marshaling public key: %v", err)
+	}
+
+	return fmt.Sprintf(
+		"%s._domainkey.%s\tTXT\t\"v=DKIM1; k=%s; p=%s\"",
+		selector, domain,
+		algoStr, base64.StdEncoding.EncodeToString(pubBytes))
+}
+
+func dkimKeygen() {
+	domain := args["$2"]
+	selector := args["$3"]
+	keyPath := args["$4"]
+	algo := args["--algo"]
+
+	if domain == "" {
+		Fatalf("Error: missing domain parameter")
+	}
+	if selector == "" {
+		selector = time.Now().UTC().Format("20060102")
+	}
+	if keyPath == "" {
+		keyPath = keyPathFor(domain, selector)
+	}
+
+	if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
+		Fatalf("Error: key already exists at %q", keyPath)
+	}
+
+	var private crypto.Signer
+	var err error
+	switch algo {
+	case "", "rsa3072":
+		private, err = rsa.GenerateKey(rand.Reader, 3072)
+	case "rsa4096":
+		private, err = rsa.GenerateKey(rand.Reader, 4096)
+	case "ed25519":
+		_, private, err = ed25519.GenerateKey(rand.Reader)
+	default:
+		Fatalf("Error: unsupported algorithm %q", algo)
+	}
+
+	if err != nil {
+		Fatalf("Error generating key: %v", err)
+	}
+
+	privB, err := x509.MarshalPKCS8PrivateKey(private)
+	if err != nil {
+		Fatalf("Error marshaling private key: %v", err)
+	}
+
+	f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
+	if err != nil {
+		Fatalf("Error creating key file %q: %v", keyPath, err)
+	}
+
+	block := &pem.Block{
+		Type:  "PRIVATE KEY",
+		Bytes: privB,
+	}
+	if err := pem.Encode(f, block); err != nil {
+		Fatalf("Error PEM-encoding key: %v", err)
+	}
+	f.Close()
+
+	fmt.Printf("Key written to %q\n\n", keyPath)
+
+	fmt.Println(dnsRecordFor(domain, selector, private))
+}
+
+func keyPathFor(domain, selector string) string {
+	return path.Clean(fmt.Sprintf("%s/domains/%s/dkim:%s.pem",
+		configDir, domain, selector))
+}
+
+func getDomainFromMsg(msg []byte) string {
+	m, err := mail.ReadMessage(bytes.NewReader(msg))
+	if err != nil {
+		Fatalf("Error parsing message: %v", err)
+	}
+
+	addr, err := mail.ParseAddress(m.Header.Get("From"))
+	if err != nil {
+		Fatalf("Error parsing From: header: %v", err)
+	}
+
+	return envelope.DomainOf(addr.Address)
+}
+
+func findSelectorForDomain(domain string) string {
+	glob := path.Clean(configDir + "/domains/" + domain + "/dkim:*.pem")
+	ms, err := filepath.Glob(glob)
+	if err != nil {
+		Fatalf("Error finding DKIM keys: %v", err)
+	}
+	for _, m := range ms {
+		base := filepath.Base(m)
+		selector := strings.TrimPrefix(base, "dkim:")
+		selector = strings.TrimSuffix(selector, ".pem")
+		return selector
+	}
+
+	Fatalf("No DKIM keys found in %q", glob)
+	return ""
+}
+
+func loadPrivateKey(path string) crypto.Signer {
+	key, err := os.ReadFile(path)
+	if err != nil {
+		Fatalf("Error reading private key from %q: %v", path, err)
+	}
+
+	block, _ := pem.Decode(key)
+	if block == nil {
+		Fatalf("Error decoding PEM block")
+	}
+
+	switch strings.ToUpper(block.Type) {
+	case "PRIVATE KEY":
+		k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+		if err != nil {
+			Fatalf("Error parsing private key: %v", err)
+		}
+		return k.(crypto.Signer)
+	default:
+		Fatalf("Unsupported key type: %s", block.Type)
+		return nil
+	}
+}
diff --git a/cmd/chasquid-util/test.sh b/cmd/chasquid-util/test.sh
index 30bb2f6..c40c2ca 100755
--- a/cmd/chasquid-util/test.sh
+++ b/cmd/chasquid-util/test.sh
@@ -24,8 +24,8 @@ function check_userdb() {
 }
 
 
+rm -rf .config/
 mkdir -p .config/domains/domain/ .data/domaininfo
-rm -f .config/chasquid.conf
 echo 'data_dir: ".data"' >> .config/chasquid.conf
 
 if ! r print-config > /dev/null; then
@@ -57,6 +57,9 @@ if ! ( echo "$C" | grep -E -q "hostname:.*\"$HOSTNAME\"" ); then
 	exit 1
 fi
 
+rm -rf .keys/
+mkdir .keys/
+
 # Run all the chamuyero tests.
 for i in *.cmy; do
 	if ! chamuyero "$i" > "$i.log" 2>&1 ; then
diff --git a/cmd/chasquid-util/test_dkim.cmy b/cmd/chasquid-util/test_dkim.cmy
new file mode 100644
index 0000000..9f315cb
--- /dev/null
+++ b/cmd/chasquid-util/test_dkim.cmy
@@ -0,0 +1,190 @@
+# Test dkim-dns subcommand with keys pre-generated by openssl, to validate
+# interoperability.
+c = ./chasquid-util dkim-dns example.com sel123 test_openssl_genpkey_ed25519.pem
+c <- sel123._domainkey.example.com	TXT	"v=DKIM1; k=ed25519; p=QXNdsDCVOrViGMRh4BIE/IgUCcBEwio3kpJ3e0GAipw="
+c wait 0
+
+c = ./chasquid-util dkim-dns example.com sel123 test_openssl_genpkey_rsa.pem
+c <- sel123._domainkey.example.com	TXT	"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAieZWhl7dnxHGyucZS2+dyExPQytj/aY46RXJ4yT3zWY8gh5YkVZ2L1x++7XMzzSg/5FR5bkKYV9Xa+jO6YlhriYKo3ttWSmxU0hDKbG7dpD9Tr7tjCcmKqE1IXetl6DXlQl7LRdmkeIND4gtf9A1zOPLR3/+kvsu1u2cUsEFVs36FqbTe4BYLn2RQlT4IQocT5eVEvoHc5apKuTOKBYThhWRaSZG9YXvsdd1UjngR2Xmizu5e/hj2f3W+9rmRRy1ukmUryuMUHMae2V27Wy1vrHiYoMUA1kQJY+HTG5kMkuatxNui9yjmdqrQUvCIU2Fa5jxJYQTLIz4U0/z4tStRwIDAQAB"
+c wait 0
+
+# Generate our own keys, and then check we can parse them with dkim-dns.
+# Do this once per algorithm (including the default).
+
+# Default algorithm.
+c = ./chasquid-util dkim-keygen example.com selDef .keys/test_def.pem
+c <- Key written to ".keys/test_def.pem"
+c <-
+c <~ selDef._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
+c wait 0
+
+c = ./chasquid-util dkim-dns example.com selDef .keys/test_def.pem
+c <~ selDef._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
+c wait 0
+
+# RSA 3072.
+c = ./chasquid-util dkim-keygen example.com selRSA3 .keys/test_rsa3.pem --algo=rsa3072
+c <- Key written to ".keys/test_rsa3.pem"
+c <-
+c <~ selRSA3._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
+c wait 0
+
+c = ./chasquid-util dkim-dns example.com selRSA3 .keys/test_rsa3.pem
+c <~ selRSA3._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
+c wait 0
+
+# RSA 4096.
+c = ./chasquid-util dkim-keygen example.com selRSA4 .keys/test_rsa4.pem --algo=rsa4096
+c <- Key written to ".keys/test_rsa4.pem"
+c <-
+c <~ selRSA4._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{730,740}=*"
+c wait 0
+
+c = ./chasquid-util dkim-dns example.com selRSA4 .keys/test_rsa4.pem
+c <~ selRSA4._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{730,740}=*"
+c wait 0
+
+# Ed25519.
+c = ./chasquid-util dkim-keygen example.com selED25519 .keys/test_ed25519.pem --algo=ed25519
+c <- Key written to ".keys/test_ed25519.pem"
+c <-
+c <~ selED25519._domainkey.example.com\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
+c wait 0
+
+c = ./chasquid-util dkim-dns example.com selED25519 .keys/test_ed25519.pem
+c <~ selED25519._domainkey.example.com\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
+c wait 0
+
+# Refuse to overwrite a key file.
+c = ./chasquid-util dkim-keygen example.com selED25519 .keys/test_ed25519.pem --algo=ed25519
+c <- Error: key already exists at ".keys/test_ed25519.pem"
+c wait 1
+
+# Automatically decide on the selector and key path.
+c = ./chasquid-util -C=.config dkim-keygen domain --algo=ed25519
+c <~ Key written to ".config/domains/domain/dkim:[0-9]{8}.pem"
+c <-
+c <~ [0-9]{8}._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
+c wait 0
+
+# Custom selector, but automatic key path
+c = ./chasquid-util -C=.config dkim-keygen domain sel1 --algo=ed25519
+c <~ Key written to ".config/domains/domain/dkim:sel1.pem"
+c <-
+c <~ sel1._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
+c wait 0
+
+# Missing parameters.
+c = ./chasquid-util -C=.config dkim-keygen
+c <- Error: missing domain parameter
+c wait 1
+
+# Unsupported algorithm
+c = ./chasquid-util -C=.config dkim-keygen domain s k.pem --algo=xxx666
+c <- Error: unsupported algorithm "xxx666"
+c wait 1
+
+# Automatically find selector and key path.
+c = ./chasquid-util -C=.config dkim-dns domain
+c <~ [0-9]{8}._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
+c wait 0
+
+# Require at least a domain.
+c = ./chasquid-util -C=.config dkim-dns
+c <- Error: missing domain parameter
+c wait 1
+
+# Error reading key.
+c = ./chasquid-util -C=.config dkim-dns domain unknownsel badkey.pem
+c <- Error reading private key from "badkey.pem": open badkey.pem: no such file or directory
+c wait 1
+
+# No DKIM keys found.
+c = ./chasquid-util -C=.config dkim-dns unkdomain
+c <- No DKIM keys found in ".config/domains/unkdomain/dkim:*.pem"
+c wait 1
+
+# DKIM signing, with various forms.
+c = ./chasquid-util -C=.config dkim-sign domain
+c -> From: user-a@srv-a
+c ->
+c -> A little tiny message.
+c close
+c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+c <~ \td=domain; s=\d+; t=\d+;
+c <~ \th=from:from:subject:date:to:cc:message-id;
+c <~ \tbh=.*;
+c <~ \tb=.*
+c <~ \t  .*;
+c wait 0
+
+c = ./chasquid-util -C=.config dkim-sign domain sel1
+c -> From: user-a@srv-a
+c ->
+c -> A little tiny message.
+c close
+c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+c wait 0
+
+c = ./chasquid-util -C=.config dkim-sign domain selED25519 .keys/test_ed25519.pem
+c -> From: user-a@srv-a
+c ->
+c -> A little tiny message.
+c close
+c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+c wait 0
+
+c = ./chasquid-util -C=.config dkim-sign
+c -> From: user-a@domain
+c ->
+c -> A little tiny message.
+c close
+c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+c wait 0
+
+# Bad message for dkim-sign.
+c = ./chasquid-util -C=.config dkim-sign
+c -> Invalid message.
+c close
+c <- Error parsing message: malformed header line: Invalid message.
+c wait 1
+
+c = ./chasquid-util -C=.config dkim-sign
+c -> From: <not a good address>
+c ->
+c -> A little tiny message.
+c close
+c <- Error parsing From: header: mail: missing @ in addr-spec
+c wait 1
+
+# DKIM verification.
+# Just check that the attempt was made.
+c = ./chasquid-util -C=.config dkim-verify
+c -> From: user-a@srv-a
+c ->
+c -> A little tiny message.
+c close
+c <~ Authentication-Results: .*
+c <~ \t;dkim=none
+c wait 0
+
+# Tracing. Just check that there's some output, we don't need byte-for-byte
+# verification as the contents are not expected to be stable.
+c = ./chasquid-util -C=.config dkim-sign -v
+c -> From: user-a@domain
+c ->
+c -> A little tiny message.
+c close
+c <~ Signing for domain / \d+ with ed25519-sha256
+c wait 0
+
+c = ./chasquid-util -C=.config dkim-verify -v
+c -> From: user-a@srv-a
+c ->
+c -> A little tiny message.
+c close
+c <- Found 0 signatures, 0 valid
+c <~ Authentication-Results: .*
+c <~ \t;dkim=none
+c wait 0
+
diff --git a/cmd/chasquid-util/test_openssl_genpkey_ed25519.pem b/cmd/chasquid-util/test_openssl_genpkey_ed25519.pem
new file mode 100644
index 0000000..af278eb
--- /dev/null
+++ b/cmd/chasquid-util/test_openssl_genpkey_ed25519.pem
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIBul+k51unaApEcZBmt1i65n09asM/howsN4B1AjNY5V
+-----END PRIVATE KEY-----
diff --git a/cmd/chasquid-util/test_openssl_genpkey_rsa.pem b/cmd/chasquid-util/test_openssl_genpkey_rsa.pem
new file mode 100644
index 0000000..1b7aba1
--- /dev/null
+++ b/cmd/chasquid-util/test_openssl_genpkey_rsa.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCJ5laGXt2fEcbK
+5xlLb53ITE9DK2P9pjjpFcnjJPfNZjyCHliRVnYvXH77tczPNKD/kVHluQphX1dr
+6M7piWGuJgqje21ZKbFTSEMpsbt2kP1Ovu2MJyYqoTUhd62XoNeVCXstF2aR4g0P
+iC1/0DXM48tHf/6S+y7W7ZxSwQVWzfoWptN7gFgufZFCVPghChxPl5US+gdzlqkq
+5M4oFhOGFZFpJkb1he+x13VSOeBHZeaLO7l7+GPZ/db72uZFHLW6SZSvK4xQcxp7
+ZXbtbLW+seJigxQDWRAlj4dMbmQyS5q3E26L3KOZ2qtBS8IhTYVrmPElhBMsjPhT
+T/Pi1K1HAgMBAAECggEAJRKywk8wv7oUuqnkh/5K6fVx/bnlmOSeOjOsYg+nOyY4
+MDceUnxvK45vaRZYKICao/qajOrxWno6U310Wx6fDyWVCJx/KlBmJuCvhb8NifOy
+1f/IdzxzK1TJpuS426HXM28oGVhIMAIYxssyiEEepaW8Gc3UUAmNbyTUOP9BgzNZ
+8qH5PA5MTTSiC1ql96b5otKPTlizxT13d3MYeSBN4b31Kb/AYRNSZlyOSBFCwcqf
+qeZEV4cwILX+58PYwfGGRYQWbCT62ZOs5AWiPt/cH9bZg7Gk1GqNx8HKFYaq+QHq
+hzXkiAjDZrANuK+xeQERuAWViagtX/qtNsQJwAJP6QKBgQDAJxGCYXxv//eM09uU
+DBz3jrAvROPylrX+eifoleWtdHnBHXcn9G3uNwOSpVS36PcspeH44w2B/WpzDsWn
+HjVWP2UmeWvPMZsY81Kxd4KINB/l+z03ctYuus80UJmYH70bkJ2uxLWioU1e/Edf
+ruMGx16ZdBVOCWJ7BtrUc41dswKBgQC3uGZ9QdVoEMDB7dFKl5foYqHE51p4ruMv
+Rpb5peFQJIdbbCUSaNN9swtDemktf0OnPyGMNLogGBZ/fhf8N2QX5+OwvQeh01Mu
+vPCFUZ4sNXv7lPPCwj23SmoMd1Z/RdksAlF8kHVBOsHrNurPUqkbhKLChuiAAKDC
+S0qdoAKwHQKBgQCsqe6X5BW3ZqEBkNX8wK2+3h7/Or5CHJ9JHmeCHkAWj1Vg7KNH
+6eJmblTtj1cDM3n4Ss81oIFgz2C6JwoA06pF6A1ydyUjN4YQ84TZJ3TKA1yuggZO
+Lwi7UO4kKlD6W3rIrDik9OnqS1uFANj55+LlEn21EpSaXOB7gHte8L6U9QKBgEy8
+I2qbzbPak3gsiacbLCKu15xzeTFA8rjzRend4/7iUvrXb6CB0hwFZWX4wedz6WD4
+mF2ERF1VUkhL9V6uEAuAGnTeb0qjBnJWDivRDDyw1ikdbLbjBH4DAcpVKfacyPl9
+umVJvP/St94zoN2ZS/KncofHa2LTYFHmurKde6HtAoGBAIGZHOxJF856GJlq3otA
+9wGGkNpmlVhHdYYvRKCMRr1FcduCrWFrr5zZT/fb6eHSoCtYjsiqRB/j6STgnBiX
+2jSsPRadUrpyZOkINTl16vC6Bnv4plfP3VIBQAIoD9ViP0v9w8VrQyIGXWAeSHcu
+eXZyxHh81OEU8M2hWKZf54UI
+-----END PRIVATE KEY-----
diff --git a/docs/dkim.md b/docs/dkim.md
index 757731b..e48e2c0 100644
--- a/docs/dkim.md
+++ b/docs/dkim.md
@@ -1,70 +1,90 @@
 
 # DKIM integration
 
-[chasquid] supports generating [DKIM] signatures via the [hooks](hooks.md)
-mechanism.
+[chasquid] supports verifying and generating [DKIM] signatures since version
+1.14.
 
+All incoming email is verified, and *authenticated* emails for domains which
+have a private DKIM key set up will be signed.
 
-## Signing
+In versions older than 1.13, support is possible via the [hooks] mechanism. In
+particular, the [example hook] included support for some command-line
+implementations. That continues to be an option, especially if customization
+is needed.
 
-The [example hook] includes integration with [driusan/dkim] and [dkimpy], and
-assumes the following:
 
-- The [selector](https://tools.ietf.org/html/rfc6376#section-3.1) for a domain
-  can be found in the file `domains/$DOMAIN/dkim_selector`.
-- The private key to use for signing can be found in the file
-  `certs/$DOMAIN/dkim_privkey.pem`.
+## Easy setup
 
-Only authenticated email will be signed.
+- Run `chasquid-util dkim-keygen DOMAIN` to generate a DKIM private key for
+  your domain. The file will be in `/etc/chasquid/domains/DOMAIN/dkim:*.pem`.
+- Publish the DKIM DNS record which was shown by the
+  previous command (e.g. by following
+  [this guide](https://support.dnsimple.com/articles/dkim-record/)).
+- Change the key file's permissions, to ensure it is readable by chasquid (and
+  nobody else).
+- Restart chasquid.
 
+It is highly recommended that you use a DKIM checker (like
+[Learn DMARC](https://www.learndmarc.com/)) to confirm that your setup is
+fully functional.
 
-### Setup with [driusan/dkim]
 
-1. Install the [driusan/dkim] tools with something like the following (adjust
-   to your local environment):
+## Advanced setup
 
-     ```
-     for i in dkimsign dkimverify dkimkeygen; do
-     	go get github.com/driusan/dkim/cmd/$i
-     	go install github.com/driusan/dkim/cmd/$i
-     done
-     sudo cp ~/go/bin/{dkimsign,dkimverify,dkimkeygen} /usr/local/bin
-     ```
+You need to place the PEM-encoded private key in the domain config directory,
+with a name like `dkim:SELECTOR.pem`, where `SELECTOR` is the selector string.
 
-1. Generate the domain key for your domain using `dkimkeygen`.
-1. Publish the DNS record from `dns.txt`
-   ([guide](https://support.dnsimple.com/articles/dkim-record/)).
-1. Write the selector you chose to `domains/$DOMAIN/dkim_selector`.
-1. Copy `private.pem` to `/etc/chasquid/certs/$DOMAIN/dkim_privkey.pem`.
-1. Verify the setup using one of the publicly available tools, like
-   [mail-tester](https://www.mail-tester.com/spf-dkim-check).
+It needs to be either RSA or Ed25519.
 
+### Key rotation
 
-### Setup with [dkimpy]
+To rotate a key, you can remove the old key file, and generate a new one as
+per the previous step.
 
-1. Install [dkimpy] with `apt install python3-dkim` or the equivalent for your
-   environment.
-1. Generate the domain key for your domain using `dknewkey dkim`.
-1. Publish the DNS record from `dkim.dns`
-   ([guide](https://support.dnsimple.com/articles/dkim-record/)).
-1. Write the selector you chose to `domains/$DOMAIN/dkim_selector`.
-1. Copy `dkim.key` to `/etc/chasquid/certs/$DOMAIN/dkim_privkey.pem`.
-1. Verify the setup using one of the publicly available tools, like
-   [mail-tester](https://www.mail-tester.com/spf-dkim-check).
+It is important to remove the old key from the directory, because chasquid
+will use *all* the keys in it.
+
+You should use a different selector each time. If you don't specify a
+selector when using `chasquid-util dkim-keygen`, the current date will be
+used, which is a safe default to prevent accidental reuse.
+
+
+### Multiple keys
+
+Advanced users may want to sign outgoing mail with multiple keys (e.g. to
+support multiple signing algorithms).
+
+This is well supported: chasquid will sign email with all keys it find that
+match `dkim:*.pem` in a domain directory.
 
 
 ## Verification
 
-Verifying signatures is technically supported as well, and can be done in the
-same hook. However, it's not recommended for SMTP servers to reject mail on
-verification failures
+[chasquid] will verify all DKIM signatures of incoming mail, and record the
+results in an [`Authentication-Results:`] header, as per [RFC 8601].
+
+Note that emails will *not* be rejected even if they fail verification, as
+this is not recommended
 ([source 1](https://tools.ietf.org/html/rfc6376#section-6.3),
-[source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)), so it is not
-included in the example.
+[source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)).
+
+
+## Other implementations
+
+[chasquid] also supports [DKIM] via the [hooks] mechanism. This can be useful
+if more customization is needed.
+
+Implementations that have been tried:
+
+- [driusan/dkim]
+- [dkimpy]
 
 
 [chasquid]: https://blitiri.com.ar/p/chasquid
 [DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
+[hooks]: hooks.md
 [example hook]: https://blitiri.com.ar/git/r/chasquid/b/next/t/etc/chasquid/hooks/f=post-data.html
 [driusan/dkim]: https://github.com/driusan/dkim
 [dkimpy]: https://launchpad.net/dkimpy/
+[RFC 8601]: https://datatracker.ietf.org/doc/html/rfc8601
+[`Authentication-Results:`]: https://en.wikipedia.org/wiki/Email_authentication#Authentication-Results
diff --git a/docs/monitoring.md b/docs/monitoring.md
index 1a7a0e1..d9597ac 100644
--- a/docs/monitoring.md
+++ b/docs/monitoring.md
@@ -53,6 +53,18 @@ List of exported variables:
 - **chasquid/smtpIn/commandCount** (map of command -> count)  
   count of SMTP commands received, by command. Note that for unknown commands
   we use `unknown<COMMAND>`.
+- **chasquid/smtpIn/dkimSignErrors** (counter)  
+  count of DKIM sign errors
+- **chasquid/smtpIn/dkimSigned** (counter)  
+  count of successful DKIM signs
+- **chasquid/smtpIn/dkimVerifyErrors** (counter)  
+  count of DKIM verification errors
+- **chasquid/smtpIn/dkimVerifyFound** (counter)  
+  count of messages with at least one DKIM signature
+- **chasquid/smtpIn/dkimVerifyNotFound** (counter)  
+  count of messages with no DKIM signatures
+- **chasquid/smtpIn/dkimVerifyValid** (counter)  
+  count of messages with at least one valid DKIM signature
 - **chasquid/smtpIn/hookResults** (result -> counter)  
   count of hook invocations, by result.
 - **chasquid/smtpIn/loopsDetected** (counter)  
diff --git a/etc/chasquid/hooks/post-data b/etc/chasquid/hooks/post-data
index e9fe9ca..7616075 100755
--- a/etc/chasquid/hooks/post-data
+++ b/etc/chasquid/hooks/post-data
@@ -7,7 +7,6 @@
 #  - spamc (from Spamassassin) to filter spam.
 #  - rspamc (from rspamd) or chasquid-rspamd to filter spam.
 #  - clamdscan (from ClamAV) to filter virus.
-#  - dkimsign (from driusan/dkim or dkimpy) to do DKIM signing.
 #
 # If it exits with code 20, it will be considered a permanent error.
 # Otherwise, temporary.
@@ -78,46 +77,3 @@ if command -v clamdscan >/dev/null; then
         fi
         echo "X-Virus-Scanned: pass"
 fi
-
-# DKIM sign with either driusan/dkim or dkimpy.
-#
-# Do it only if all the following are true:
-#  - User has authenticated.
-#  - dkimsign binary exists.
-#  - domains/$DOMAIN/dkim_selector file exists.
-#  - certs/$DOMAIN/dkim_privkey.pem file exists.
-#
-# Note this has not been thoroughly tested, so might need further adjustments.
-if [ "$AUTH_AS" != "" ] && command -v dkimsign >/dev/null; then
-	DOMAIN=$( echo "$MAIL_FROM" | cut -d '@' -f 2 )
-
-	if [ -f "domains/$DOMAIN/dkim_selector" ] \
-		&& [ -f "certs/$DOMAIN/dkim_privkey.pem" ];
-	then
-		# driusan/dkim and dkimpy both provide the same binary (dkimsign) but
-		# take different arguments, so we need to tell them apart.
-		# This is awful but it should work reasonably well.
-		if dkimsign --help 2>&1 | grep -q -- --identity; then
-			# dkimpy
-			dkimsign \
-				"$(cat "domains/$DOMAIN/dkim_selector")" \
-				"$DOMAIN" \
-				"certs/$DOMAIN/dkim_privkey.pem" \
-				< "$TF" > "$TF.dkimout"
-			# dkimpy doesn't provide a way to just show the new
-			# headers, so we have to compute the difference.
-			# ALSOCHANGE(test/t-19-dkimpy/config/hooks/post-data)
-			diff --changed-group-format='%>' \
-				--unchanged-group-format='' \
-				"$TF" "$TF.dkimout" && exit 1
-			rm "$TF.dkimout"
-		else
-			# driusan/dkim
-			dkimsign -n -hd \
-				-key "certs/$DOMAIN/dkim_privkey.pem" \
-				-s "$(cat "domains/$DOMAIN/dkim_selector")" \
-				-d "$DOMAIN" \
-				< "$TF"
-		fi
-	fi
-fi
diff --git a/internal/dkim/canonicalize.go b/internal/dkim/canonicalize.go
new file mode 100644
index 0000000..c9a5357
--- /dev/null
+++ b/internal/dkim/canonicalize.go
@@ -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
+}
diff --git a/internal/dkim/canonicalize_test.go b/internal/dkim/canonicalize_test.go
new file mode 100644
index 0000000..bb6dfc5
--- /dev/null
+++ b/internal/dkim/canonicalize_test.go
@@ -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
+}
diff --git a/internal/dkim/context.go b/internal/dkim/context.go
new file mode 100644
index 0000000..406807e
--- /dev/null
+++ b/internal/dkim/context.go
@@ -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
+}
diff --git a/internal/dkim/context_test.go b/internal/dkim/context_test.go
new file mode 100644
index 0000000..5f6d6ba
--- /dev/null
+++ b/internal/dkim/context_test.go
@@ -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)
+	}
+}
diff --git a/internal/dkim/dns.go b/internal/dkim/dns.go
new file mode 100644
index 0000000..2b5e044
--- /dev/null
+++ b/internal/dkim/dns.go
@@ -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
+}
diff --git a/internal/dkim/dns_test.go b/internal/dkim/dns_test.go
new file mode 100644
index 0000000..5d25ce7
--- /dev/null
+++ b/internal/dkim/dns_test.go
@@ -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)
+	})
+}
diff --git a/internal/dkim/file_test.go b/internal/dkim/file_test.go
new file mode 100644
index 0000000..268a788
--- /dev/null
+++ b/internal/dkim/file_test.go
@@ -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
+}
diff --git a/internal/dkim/header.go b/internal/dkim/header.go
new file mode 100644
index 0000000..ece056d
--- /dev/null
+++ b/internal/dkim/header.go
@@ -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
+}
diff --git a/internal/dkim/header_test.go b/internal/dkim/header_test.go
new file mode 100644
index 0000000..5902bb1
--- /dev/null
+++ b/internal/dkim/header_test.go
@@ -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)
+		}
+	}
+}
diff --git a/internal/dkim/message.go b/internal/dkim/message.go
new file mode 100644
index 0000000..5ad3a7a
--- /dev/null
+++ b/internal/dkim/message.go
@@ -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
+}
diff --git a/internal/dkim/message_test.go b/internal/dkim/message_test.go
new file mode 100644
index 0000000..0e28b7d
--- /dev/null
+++ b/internal/dkim/message_test.go
@@ -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)
+	}
+
+}
diff --git a/internal/dkim/sign.go b/internal/dkim/sign.go
new file mode 100644
index 0000000..ac9ce62
--- /dev/null
+++ b/internal/dkim/sign.go
@@ -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()))
+}
diff --git a/internal/dkim/sign_test.go b/internal/dkim/sign_test.go
new file mode 100644
index 0000000..20505dc
--- /dev/null
+++ b/internal/dkim/sign_test.go
@@ -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)
+		}
+	}
+}
diff --git a/internal/dkim/testdata/.gitignore b/internal/dkim/testdata/.gitignore
new file mode 100644
index 0000000..f614d19
--- /dev/null
+++ b/internal/dkim/testdata/.gitignore
@@ -0,0 +1,4 @@
+*.got
+
+# Ignore private test cases, to reduce the chances of accidental leaks.
+private/
diff --git a/internal/dkim/testdata/01-rfc8463.dns b/internal/dkim/testdata/01-rfc8463.dns
new file mode 100644
index 0000000..f67af09
--- /dev/null
+++ b/internal/dkim/testdata/01-rfc8463.dns
@@ -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
+
diff --git a/internal/dkim/testdata/01-rfc8463.error b/internal/dkim/testdata/01-rfc8463.error
new file mode 100644
index 0000000..5fd4028
--- /dev/null
+++ b/internal/dkim/testdata/01-rfc8463.error
@@ -0,0 +1 @@
+<nil>
\ No newline at end of file
diff --git a/internal/dkim/testdata/01-rfc8463.msg b/internal/dkim/testdata/01-rfc8463.msg
new file mode 100644
index 0000000..caf6433
--- /dev/null
+++ b/internal/dkim/testdata/01-rfc8463.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/01-rfc8463.result b/internal/dkim/testdata/01-rfc8463.result
new file mode 100644
index 0000000..3c6b0fe
--- /dev/null
+++ b/internal/dkim/testdata/01-rfc8463.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/02-too_many_headers.dns b/internal/dkim/testdata/02-too_many_headers.dns
new file mode 120000
index 0000000..59a7aaf
--- /dev/null
+++ b/internal/dkim/testdata/02-too_many_headers.dns
@@ -0,0 +1 @@
+01-rfc8463.dns
\ No newline at end of file
diff --git a/internal/dkim/testdata/02-too_many_headers.error b/internal/dkim/testdata/02-too_many_headers.error
new file mode 100644
index 0000000..5fd4028
--- /dev/null
+++ b/internal/dkim/testdata/02-too_many_headers.error
@@ -0,0 +1 @@
+<nil>
\ No newline at end of file
diff --git a/internal/dkim/testdata/02-too_many_headers.msg b/internal/dkim/testdata/02-too_many_headers.msg
new file mode 100644
index 0000000..3406a0f
--- /dev/null
+++ b/internal/dkim/testdata/02-too_many_headers.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/02-too_many_headers.readme b/internal/dkim/testdata/02-too_many_headers.readme
new file mode 100644
index 0000000..0ceb2bd
--- /dev/null
+++ b/internal/dkim/testdata/02-too_many_headers.readme
@@ -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).
+
diff --git a/internal/dkim/testdata/02-too_many_headers.result b/internal/dkim/testdata/02-too_many_headers.result
new file mode 100644
index 0000000..1a4a2a2
--- /dev/null
+++ b/internal/dkim/testdata/02-too_many_headers.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/03-bad_message.error b/internal/dkim/testdata/03-bad_message.error
new file mode 100644
index 0000000..16bb55b
--- /dev/null
+++ b/internal/dkim/testdata/03-bad_message.error
@@ -0,0 +1 @@
+invalid header: bad continuation
\ No newline at end of file
diff --git a/internal/dkim/testdata/03-bad_message.msg b/internal/dkim/testdata/03-bad_message.msg
new file mode 100644
index 0000000..a472741
--- /dev/null
+++ b/internal/dkim/testdata/03-bad_message.msg
@@ -0,0 +1 @@
+  This is not a valid message.
diff --git a/internal/dkim/testdata/04-bad_dkim_signature_header.msg b/internal/dkim/testdata/04-bad_dkim_signature_header.msg
new file mode 100644
index 0000000..e0331ac
--- /dev/null
+++ b/internal/dkim/testdata/04-bad_dkim_signature_header.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/04-bad_dkim_signature_header.readme b/internal/dkim/testdata/04-bad_dkim_signature_header.readme
new file mode 100644
index 0000000..795d727
--- /dev/null
+++ b/internal/dkim/testdata/04-bad_dkim_signature_header.readme
@@ -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.
diff --git a/internal/dkim/testdata/04-bad_dkim_signature_header.result b/internal/dkim/testdata/04-bad_dkim_signature_header.result
new file mode 100644
index 0000000..838cd7a
--- /dev/null
+++ b/internal/dkim/testdata/04-bad_dkim_signature_header.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/05-dns_temp_error.dns b/internal/dkim/testdata/05-dns_temp_error.dns
new file mode 100644
index 0000000..91c6234
--- /dev/null
+++ b/internal/dkim/testdata/05-dns_temp_error.dns
@@ -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
+
diff --git a/internal/dkim/testdata/05-dns_temp_error.msg b/internal/dkim/testdata/05-dns_temp_error.msg
new file mode 100644
index 0000000..caf6433
--- /dev/null
+++ b/internal/dkim/testdata/05-dns_temp_error.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/05-dns_temp_error.result b/internal/dkim/testdata/05-dns_temp_error.result
new file mode 100644
index 0000000..75256e6
--- /dev/null
+++ b/internal/dkim/testdata/05-dns_temp_error.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/06-dns_perm_error.dns b/internal/dkim/testdata/06-dns_perm_error.dns
new file mode 100644
index 0000000..1cae1f5
--- /dev/null
+++ b/internal/dkim/testdata/06-dns_perm_error.dns
@@ -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
+
diff --git a/internal/dkim/testdata/06-dns_perm_error.msg b/internal/dkim/testdata/06-dns_perm_error.msg
new file mode 100644
index 0000000..caf6433
--- /dev/null
+++ b/internal/dkim/testdata/06-dns_perm_error.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/06-dns_perm_error.result b/internal/dkim/testdata/06-dns_perm_error.result
new file mode 100644
index 0000000..8f2970e
--- /dev/null
+++ b/internal/dkim/testdata/06-dns_perm_error.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/07-algo_mismatch.dns b/internal/dkim/testdata/07-algo_mismatch.dns
new file mode 100644
index 0000000..9439301
--- /dev/null
+++ b/internal/dkim/testdata/07-algo_mismatch.dns
@@ -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
+
diff --git a/internal/dkim/testdata/07-algo_mismatch.msg b/internal/dkim/testdata/07-algo_mismatch.msg
new file mode 120000
index 0000000..c842936
--- /dev/null
+++ b/internal/dkim/testdata/07-algo_mismatch.msg
@@ -0,0 +1 @@
+01-rfc8463.msg
\ No newline at end of file
diff --git a/internal/dkim/testdata/07-algo_mismatch.readme b/internal/dkim/testdata/07-algo_mismatch.readme
new file mode 100644
index 0000000..d11de0b
--- /dev/null
+++ b/internal/dkim/testdata/07-algo_mismatch.readme
@@ -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.
diff --git a/internal/dkim/testdata/07-algo_mismatch.result b/internal/dkim/testdata/07-algo_mismatch.result
new file mode 120000
index 0000000..b3ce015
--- /dev/null
+++ b/internal/dkim/testdata/07-algo_mismatch.result
@@ -0,0 +1 @@
+01-rfc8463.result
\ No newline at end of file
diff --git a/internal/dkim/testdata/08-our_signature.dns b/internal/dkim/testdata/08-our_signature.dns
new file mode 100644
index 0000000..d93edfe
--- /dev/null
+++ b/internal/dkim/testdata/08-our_signature.dns
@@ -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
+
diff --git a/internal/dkim/testdata/08-our_signature.msg b/internal/dkim/testdata/08-our_signature.msg
new file mode 100644
index 0000000..3a46642
--- /dev/null
+++ b/internal/dkim/testdata/08-our_signature.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/08-our_signature.result b/internal/dkim/testdata/08-our_signature.result
new file mode 100644
index 0000000..01ec1eb
--- /dev/null
+++ b/internal/dkim/testdata/08-our_signature.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/09-limited_body.dns b/internal/dkim/testdata/09-limited_body.dns
new file mode 120000
index 0000000..2618289
--- /dev/null
+++ b/internal/dkim/testdata/09-limited_body.dns
@@ -0,0 +1 @@
+08-our_signature.dns
\ No newline at end of file
diff --git a/internal/dkim/testdata/09-limited_body.msg b/internal/dkim/testdata/09-limited_body.msg
new file mode 100644
index 0000000..6325197
--- /dev/null
+++ b/internal/dkim/testdata/09-limited_body.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/09-limited_body.readme b/internal/dkim/testdata/09-limited_body.readme
new file mode 100644
index 0000000..4f8e2ce
--- /dev/null
+++ b/internal/dkim/testdata/09-limited_body.readme
@@ -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.
diff --git a/internal/dkim/testdata/09-limited_body.result b/internal/dkim/testdata/09-limited_body.result
new file mode 100644
index 0000000..bbdb901
--- /dev/null
+++ b/internal/dkim/testdata/09-limited_body.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/10-strict_domain_check_pass.dns b/internal/dkim/testdata/10-strict_domain_check_pass.dns
new file mode 100644
index 0000000..da61e18
--- /dev/null
+++ b/internal/dkim/testdata/10-strict_domain_check_pass.dns
@@ -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
+
diff --git a/internal/dkim/testdata/10-strict_domain_check_pass.msg b/internal/dkim/testdata/10-strict_domain_check_pass.msg
new file mode 120000
index 0000000..c842936
--- /dev/null
+++ b/internal/dkim/testdata/10-strict_domain_check_pass.msg
@@ -0,0 +1 @@
+01-rfc8463.msg
\ No newline at end of file
diff --git a/internal/dkim/testdata/10-strict_domain_check_pass.result b/internal/dkim/testdata/10-strict_domain_check_pass.result
new file mode 100644
index 0000000..3c6b0fe
--- /dev/null
+++ b/internal/dkim/testdata/10-strict_domain_check_pass.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/testdata/11-strict_domain_check_fail.dns b/internal/dkim/testdata/11-strict_domain_check_fail.dns
new file mode 100644
index 0000000..9e62c3e
--- /dev/null
+++ b/internal/dkim/testdata/11-strict_domain_check_fail.dns
@@ -0,0 +1,2 @@
+selector._domainkey.example.com: \
+  v=DKIM1; k=ed25519; t=s; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=
diff --git a/internal/dkim/testdata/11-strict_domain_check_fail.msg b/internal/dkim/testdata/11-strict_domain_check_fail.msg
new file mode 100644
index 0000000..78589ef
--- /dev/null
+++ b/internal/dkim/testdata/11-strict_domain_check_fail.msg
@@ -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.
+
diff --git a/internal/dkim/testdata/11-strict_domain_check_fail.readme b/internal/dkim/testdata/11-strict_domain_check_fail.readme
new file mode 100644
index 0000000..68355f6
--- /dev/null
+++ b/internal/dkim/testdata/11-strict_domain_check_fail.readme
@@ -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.
diff --git a/internal/dkim/testdata/11-strict_domain_check_fail.result b/internal/dkim/testdata/11-strict_domain_check_fail.result
new file mode 100644
index 0000000..6505633
--- /dev/null
+++ b/internal/dkim/testdata/11-strict_domain_check_fail.result
@@ -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"
+		}
+	]
+}
\ No newline at end of file
diff --git a/internal/dkim/verify.go b/internal/dkim/verify.go
new file mode 100644
index 0000000..7428c37
--- /dev/null
+++ b/internal/dkim/verify.go
@@ -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)
+}
diff --git a/internal/dkim/verify_test.go b/internal/dkim/verify_test.go
new file mode 100644
index 0000000..0b81183
--- /dev/null
+++ b/internal/dkim/verify_test.go
@@ -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)
+		}
+	}
+}
diff --git a/internal/normalize/normalize.go b/internal/normalize/normalize.go
index ef29066..17768a5 100644
--- a/internal/normalize/normalize.go
+++ b/internal/normalize/normalize.go
@@ -93,3 +93,8 @@ func ToCRLF(in []byte) []byte {
 	}
 	return b.Bytes()
 }
+
+// StringToCRLF is like ToCRLF, but operates on strings.
+func StringToCRLF(in string) string {
+	return string(ToCRLF([]byte(in)))
+}
diff --git a/internal/normalize/normalize_test.go b/internal/normalize/normalize_test.go
index b1c5215..e5c7829 100644
--- a/internal/normalize/normalize_test.go
+++ b/internal/normalize/normalize_test.go
@@ -142,6 +142,11 @@ func TestToCRLF(t *testing.T) {
 		if got != c.out {
 			t.Errorf("ToCRLF(%q) = %q, expected %q", c.in, got, c.out)
 		}
+
+		got = StringToCRLF(c.in)
+		if got != c.out {
+			t.Errorf("StringToCRLF(%q) = %q, expected %q", c.in, got, c.out)
+		}
 	}
 }
 
diff --git a/internal/queue/queue.go b/internal/queue/queue.go
index 28141fe..a459f56 100644
--- a/internal/queue/queue.go
+++ b/internal/queue/queue.go
@@ -456,6 +456,8 @@ func sendDSN(tr *trace.Trace, q *Queue, item *Item) {
 		return
 	}
 
+	// TODO: DKIM signing.
+
 	id, err := q.Put(tr, "<>", []string{item.From}, msg)
 	if err != nil {
 		tr.Errorf("failed to queue DSN: %v", err)
diff --git a/internal/smtpsrv/conn.go b/internal/smtpsrv/conn.go
index 5dd2edf..8073f28 100644
--- a/internal/smtpsrv/conn.go
+++ b/internal/smtpsrv/conn.go
@@ -20,6 +20,7 @@ import (
 
 	"blitiri.com.ar/go/chasquid/internal/aliases"
 	"blitiri.com.ar/go/chasquid/internal/auth"
+	"blitiri.com.ar/go/chasquid/internal/dkim"
 	"blitiri.com.ar/go/chasquid/internal/domaininfo"
 	"blitiri.com.ar/go/chasquid/internal/envelope"
 	"blitiri.com.ar/go/chasquid/internal/expvarom"
@@ -51,6 +52,20 @@ var (
 		"result", "count of hook invocations, by result")
 	wrongProtoCount = expvarom.NewMap("chasquid/smtpIn/wrongProtoCount",
 		"command", "count of commands for other protocols")
+
+	dkimSigned = expvarom.NewInt("chasquid/smtpIn/dkimSigned",
+		"count of successful DKIM signs")
+	dkimSignErrors = expvarom.NewInt("chasquid/smtpIn/dkimSignErrors",
+		"count of DKIM sign errors")
+
+	dkimVerifyFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyFound",
+		"count of messages with at least one DKIM signature")
+	dkimVerifyNotFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyNotFound",
+		"count of messages with no DKIM signatures")
+	dkimVerifyValid = expvarom.NewInt("chasquid/smtpIn/dkimVerifyValid",
+		"count of messages with at least one valid DKIM signature")
+	dkimVerifyErrors = expvarom.NewInt("chasquid/smtpIn/dkimVerifyErrors",
+		"count of DKIM verification errors")
 )
 
 var (
@@ -129,6 +144,9 @@ type Conn struct {
 	spfResult spf.Result
 	spfError  error
 
+	// DKIM verification results.
+	dkimVerifyResult *dkim.VerifyResult
+
 	// Are we using TLS?
 	onTLS bool
 
@@ -142,6 +160,9 @@ type Conn struct {
 	aliasesR     *aliases.Resolver
 	dinfo        *domaininfo.DB
 
+	// Map of domain -> DKIM signers. Taken from the server at creation time.
+	dkimSigners map[string][]*dkim.Signer
+
 	// Have we successfully completed AUTH?
 	completedAuth bool
 
@@ -666,6 +687,18 @@ func (c *Conn) DATA(params string) (code int, msg string) {
 		return 554, err.Error()
 	}
 
+	if c.completedAuth {
+		err = c.dkimSign()
+		if err != nil {
+			// If we failed to sign, then reject to prevent sending unsigned
+			// messages. Treat the failure as temporary.
+			c.tr.Errorf("DKIM failed: %v", err)
+			return 451, "4.3.0 DKIM signing failed"
+		}
+	} else {
+		c.dkimVerify()
+	}
+
 	c.addReceivedHeader()
 
 	hookOut, permanent, err := c.runPostDataHook(c.data)
@@ -704,7 +737,7 @@ func (c *Conn) DATA(params string) (code int, msg string) {
 }
 
 func (c *Conn) addReceivedHeader() {
-	var v string
+	var received string
 
 	// Format is semi-structured, defined by
 	// https://tools.ietf.org/html/rfc5321#section-4.4
@@ -712,16 +745,16 @@ func (c *Conn) addReceivedHeader() {
 	if c.completedAuth {
 		// For authenticated users, only show the EHLO domain they gave;
 		// explicitly hide their network address.
-		v += fmt.Sprintf("from %s\n", c.ehloDomain)
+		received += fmt.Sprintf("from %s\n", c.ehloDomain)
 	} else {
 		// For non-authenticated users we show the real address as canonical,
 		// and then the given EHLO domain for convenience and
 		// troubleshooting.
-		v += fmt.Sprintf("from [%s] (%s)\n",
+		received += fmt.Sprintf("from [%s] (%s)\n",
 			addrLiteral(c.remoteAddr), c.ehloDomain)
 	}
 
-	v += fmt.Sprintf("by %s (chasquid) ", c.hostname)
+	received += fmt.Sprintf("by %s (chasquid) ", c.hostname)
 
 	// https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#mail-parameters-7
 	with := "SMTP"
@@ -734,35 +767,60 @@ func (c *Conn) addReceivedHeader() {
 	if c.completedAuth {
 		with += "A"
 	}
-	v += fmt.Sprintf("with %s\n", with)
+	received += fmt.Sprintf("with %s\n", with)
 
 	if c.tlsConnState != nil {
 		// https://tools.ietf.org/html/rfc8314#section-4.3
-		v += fmt.Sprintf("tls %s\n",
+		received += fmt.Sprintf("tls %s\n",
 			tlsconst.CipherSuiteName(c.tlsConnState.CipherSuite))
 	}
 
-	v += fmt.Sprintf("(over %s, ", c.mode)
+	received += fmt.Sprintf("(over %s, ", c.mode)
 	if c.tlsConnState != nil {
-		v += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version))
+		received += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version))
 	} else {
-		v += "plain text!, "
+		received += "plain text!, "
 	}
 
 	// Note we must NOT include c.rcptTo, that would leak BCCs.
-	v += fmt.Sprintf("envelope from %q)\n", c.mailFrom)
+	received += fmt.Sprintf("envelope from %q)\n", c.mailFrom)
 
 	// This should be the last part in the Received header, by RFC.
 	// The ";" is a mandatory separator. The date format is not standard but
 	// this one seems to be widely used.
 	// https://tools.ietf.org/html/rfc5322#section-3.6.7
-	v += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z))
-	c.data = envelope.AddHeader(c.data, "Received", v)
+	received += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z))
+	c.data = envelope.AddHeader(c.data, "Received", received)
+
+	// Add Authentication-Results header too, but only if there's anything to
+	// report. We add it above the Received header, so it can easily be
+	// associated and traced to it, even though it is not a hard requirement.
+	// Note we include results even if they're "none" or "neutral", as that
+	// allows MUAs to know that the message was checked.
+	arHdr := c.hostname + "\r\n"
+	includeAR := false
 
 	if c.spfResult != "" {
 		// https://tools.ietf.org/html/rfc7208#section-9.1
-		v = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError)
-		c.data = envelope.AddHeader(c.data, "Received-SPF", v)
+		received = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError)
+		c.data = envelope.AddHeader(c.data, "Received-SPF", received)
+
+		// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.2
+		arHdr += fmt.Sprintf(";spf=%s (%v)\r\n", c.spfResult, c.spfError)
+		includeAR = true
+	}
+
+	if c.dkimVerifyResult != nil {
+		// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
+		arHdr += c.dkimVerifyResult.AuthenticationResults() + "\r\n"
+		includeAR = true
+	}
+
+	if includeAR {
+		// Only include the Authentication-Results header if we have something
+		// to report.
+		c.data = envelope.AddHeader(c.data, "Authentication-Results",
+			strings.TrimSpace(arHdr))
 	}
 }
 
@@ -957,6 +1015,79 @@ func boolToStr(b bool) string {
 	return "0"
 }
 
+func (c *Conn) dkimSign() error {
+	// We only sign if the user authenticated. However, the authenticated user
+	// and the MAIL FROM address may be different; even the domain may be
+	// different.
+	// We explicitly let this happen and trust authenticated users.
+	// So for DKIM signing purposes, we use the MAIL FROM domain: this
+	// prevents leaking the authenticated user's domain, and is more in line
+	// with expectations around signatures.
+	domain := envelope.DomainOf(c.mailFrom)
+	signers := c.dkimSigners[domain]
+	if len(signers) == 0 {
+		return nil
+	}
+
+	tr := c.tr.NewChild("DKIM.Sign", domain)
+	defer tr.Finish()
+
+	ctx := context.Background()
+	ctx = dkim.WithTraceFunc(ctx, tr.Debugf)
+
+	for _, signer := range signers {
+		sig, err := signer.Sign(ctx, normalize.StringToCRLF(string(c.data)))
+		if err != nil {
+			dkimSignErrors.Add(1)
+			return err
+		}
+
+		// The signature is returned with \r\n; however, our internal
+		// representation uses \n, so normalize it.
+		sig = strings.ReplaceAll(sig, "\r\n", "\n")
+		c.data = envelope.AddHeader(c.data, "DKIM-Signature", sig)
+	}
+	dkimSigned.Add(1)
+	return nil
+}
+
+func (c *Conn) dkimVerify() {
+	tr := c.tr.NewChild("DKIM.Verify", c.mailFrom)
+	defer tr.Finish()
+
+	var err error
+	ctx := context.Background()
+	ctx = dkim.WithTraceFunc(ctx, tr.Debugf)
+
+	c.dkimVerifyResult, err = dkim.VerifyMessage(
+		ctx, string(normalize.ToCRLF(c.data)))
+	if err != nil {
+		// The only error we expect is because of a malformed mail, which is
+		// checked before this is invoked.
+		tr.Errorf("Error verifying DKIM: %v", err)
+		dkimVerifyErrors.Add(1)
+	}
+
+	if c.dkimVerifyResult != nil {
+		if c.dkimVerifyResult.Found > 0 {
+			dkimVerifyFound.Add(1)
+		} else {
+			dkimVerifyNotFound.Add(1)
+		}
+
+		if c.dkimVerifyResult.Valid > 0 {
+			dkimVerifyValid.Add(1)
+		}
+	}
+
+	// Note we don't fail emails because they failed to verify, in line
+	// with RFC recommendations.
+	// DMARC policies may cause it to fail at some point, but that is not
+	// implemented yet, and would happen separately.
+	// The results will get included in the Authentication-Results header, see
+	// addReceivedHeader for more details.
+}
+
 // STARTTLS SMTP command handler.
 func (c *Conn) STARTTLS(params string) (code int, msg string) {
 	if c.onTLS {
diff --git a/internal/smtpsrv/server.go b/internal/smtpsrv/server.go
index d8d6a6f..76f7b07 100644
--- a/internal/smtpsrv/server.go
+++ b/internal/smtpsrv/server.go
@@ -2,18 +2,26 @@
 package smtpsrv
 
 import (
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rsa"
 	"crypto/tls"
+	"crypto/x509"
+	"encoding/pem"
 	"flag"
 	"fmt"
 	"net"
 	"net/http"
 	"net/url"
+	"os"
 	"path"
+	"strings"
 	"time"
 
 	"blitiri.com.ar/go/chasquid/internal/aliases"
 	"blitiri.com.ar/go/chasquid/internal/auth"
 	"blitiri.com.ar/go/chasquid/internal/courier"
+	"blitiri.com.ar/go/chasquid/internal/dkim"
 	"blitiri.com.ar/go/chasquid/internal/domaininfo"
 	"blitiri.com.ar/go/chasquid/internal/localrpc"
 	"blitiri.com.ar/go/chasquid/internal/maillog"
@@ -65,6 +73,9 @@ type Server struct {
 	// Domain info database.
 	dinfo *domaininfo.DB
 
+	// Map of domain -> DKIM signers.
+	dkimSigners map[string][]*dkim.Signer
+
 	// Time before we give up on a connection, even if it's sending data.
 	connTimeout time.Duration
 
@@ -91,6 +102,7 @@ func NewServer() *Server {
 		localDomains:   &set.String{},
 		authr:          authr,
 		aliasesR:       aliasesR,
+		dkimSigners:    map[string][]*dkim.Signer{},
 	}
 }
 
@@ -130,6 +142,48 @@ func (s *Server) AddAliasesFile(domain, f string) error {
 	return s.aliasesR.AddAliasesFile(domain, f)
 }
 
+var (
+	errDecodingPEMBlock     = fmt.Errorf("error decoding PEM block")
+	errUnsupportedBlockType = fmt.Errorf("unsupported block type")
+	errUnsupportedKeyType   = fmt.Errorf("unsupported key type")
+)
+
+// AddDKIMSigner for the given domain and selector.
+func (s *Server) AddDKIMSigner(domain, selector, keyPath string) error {
+	key, err := os.ReadFile(keyPath)
+	if err != nil {
+		return err
+	}
+
+	block, _ := pem.Decode(key)
+	if block == nil {
+		return errDecodingPEMBlock
+	}
+
+	if strings.ToUpper(block.Type) != "PRIVATE KEY" {
+		return fmt.Errorf("%w: %s", errUnsupportedBlockType, block.Type)
+	}
+
+	signer, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+	if err != nil {
+		return err
+	}
+
+	switch k := signer.(type) {
+	case *rsa.PrivateKey, ed25519.PrivateKey:
+		// These are supported, nothing to do.
+	default:
+		return fmt.Errorf("%w: %T", errUnsupportedKeyType, k)
+	}
+
+	s.dkimSigners[domain] = append(s.dkimSigners[domain], &dkim.Signer{
+		Domain:   domain,
+		Selector: selector,
+		Signer:   signer.(crypto.Signer),
+	})
+	return nil
+}
+
 // SetAuthFallback sets the authentication backend to use as fallback.
 func (s *Server) SetAuthFallback(be auth.Backend) {
 	s.authr.Fallback = be
@@ -287,6 +341,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
 			aliasesR:       s.aliasesR,
 			localDomains:   s.localDomains,
 			dinfo:          s.dinfo,
+			dkimSigners:    s.dkimSigners,
 			deadline:       time.Now().Add(s.connTimeout),
 			commandTimeout: s.commandTimeout,
 			queue:          s.queue,
diff --git a/internal/smtpsrv/server_test.go b/internal/smtpsrv/server_test.go
index 41ec64f..eadbbde 100644
--- a/internal/smtpsrv/server_test.go
+++ b/internal/smtpsrv/server_test.go
@@ -2,11 +2,13 @@ package smtpsrv
 
 import (
 	"crypto/tls"
+	"errors"
 	"flag"
 	"fmt"
 	"net"
 	"net/smtp"
 	"os"
+	"strings"
 	"testing"
 	"time"
 
@@ -481,6 +483,69 @@ func TestStartTLSOnTLS(t *testing.T) {
 	}
 }
 
+func TestAddDKIMSigner(t *testing.T) {
+	s := NewServer()
+	err := s.AddDKIMSigner("example.com", "selector", "keyfile-does-not-exist")
+	if !os.IsNotExist(err) {
+		t.Errorf("AddDKIMSigner: expected not exist, got %v", err)
+	}
+
+	tmpDir := testlib.MustTempDir(t)
+	defer testlib.RemoveIfOk(t, tmpDir)
+
+	// Invalid PEM file.
+	kf1 := tmpDir + "/key1-bad_pem.pem"
+	testlib.Rewrite(t, kf1, "not a valid PEM file")
+	err = s.AddDKIMSigner("example.com", "selector", kf1)
+	if !errors.Is(err, errDecodingPEMBlock) {
+		t.Errorf("AddDKIMSigner: expected %v, got %v",
+			errDecodingPEMBlock, err)
+	}
+
+	// Unsupported block type.
+	kf2 := tmpDir + "/key2.pem"
+	testlib.Rewrite(t, kf2,
+		"-----BEGIN TEST KEY-----\n-----END TEST KEY-----")
+	err = s.AddDKIMSigner("example.com", "selector", kf2)
+	if !errors.Is(err, errUnsupportedBlockType) {
+		t.Errorf("AddDKIMSigner: expected %v, got %v",
+			errUnsupportedBlockType, err)
+	}
+
+	// x509 error: this is an ed448 key, which is not supported.
+	kf3 := tmpDir + "/key3.pem"
+	testlib.Rewrite(t, kf3, `-----BEGIN PRIVATE KEY-----
+MEcCAQAwBQYDK2VxBDsEOSBHT9DNG6/FNBnRGrLay+jIrK8WrViiVMz9AoXqYSb6
+ghwTZSd3E0X8oIFTgs9ch3pxJM1KDrs4NA==
+-----END PRIVATE KEY-----`)
+	err = s.AddDKIMSigner("example.com", "selector", kf3)
+	if !strings.Contains(err.Error(),
+		"x509: PKCS#8 wrapping contained private key with unknown algorithm") {
+		t.Errorf("AddDKIMSigner: expected x509 error, got %q", err.Error())
+	}
+
+	// Unsupported key type: X25519.
+	kf4 := tmpDir + "/key4.pem"
+	testlib.Rewrite(t, kf4, `-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VuBCIEIKBUDwEDc5cCv/yEvnA93yk0gXyiTZe7Qip8QU3rJuZC
+-----END PRIVATE KEY-----`)
+	err = s.AddDKIMSigner("example.com", "selector", kf4)
+	if !errors.Is(err, errUnsupportedKeyType) {
+		t.Errorf("AddDKIMSigner: expected %v, got %v",
+			errUnsupportedKeyType, err)
+	}
+
+	// Successful.
+	kf5 := tmpDir + "/key5.pem"
+	testlib.Rewrite(t, kf5, `-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS
+-----END PRIVATE KEY-----`)
+	err = s.AddDKIMSigner("example.com", "selector", kf5)
+	if err != nil {
+		t.Errorf("AddDKIMSigner: %v", err)
+	}
+}
+
 //
 // === Benchmarks ===
 //
diff --git a/internal/trace/trace.go b/internal/trace/trace.go
index 27aeba4..67d8987 100644
--- a/internal/trace/trace.go
+++ b/internal/trace/trace.go
@@ -24,16 +24,16 @@ func New(family, title string) *Trace {
 	t := &Trace{family, title, nettrace.New(family, title)}
 
 	// The default for max events is 10, which is a bit short for a normal
-	// SMTP exchange. Expand it to 30 which should be large enough to keep
+	// SMTP exchange. Expand it to 100 which should be large enough to keep
 	// most of the traces.
-	t.t.SetMaxEvents(30)
+	t.t.SetMaxEvents(100)
 	return t
 }
 
 // NewChild creates a new child trace.
 func (t *Trace) NewChild(family, title string) *Trace {
 	n := &Trace{family, title, t.t.NewChild(family, title)}
-	n.t.SetMaxEvents(30)
+	n.t.SetMaxEvents(100)
 	return n
 }
 
diff --git a/test/t-04-aliases/chasquid-util.sh b/test/t-04-aliases/chasquid-util.sh
index 1d1f26c..5d3360e 100755
--- a/test/t-04-aliases/chasquid-util.sh
+++ b/test/t-04-aliases/chasquid-util.sh
@@ -3,4 +3,4 @@
 
 # Run from the config directory because data_dir is relative.
 cd config || exit 1
-go run ../../../cmd/chasquid-util/chasquid-util.go -C=. "$@"
+go run ../../../cmd/chasquid-util/ -C=. "$@"
diff --git a/test/t-19-dkimpy/config/hooks/post-data b/test/t-19-dkimpy/config/hooks/post-data
index 7304022..1071e3e 100755
--- a/test/t-19-dkimpy/config/hooks/post-data
+++ b/test/t-19-dkimpy/config/hooks/post-data
@@ -30,7 +30,6 @@ if [ "$AUTH_AS" != "" ]; then
 		< "$TF" > "$TF.dkimout"
 	# dkimpy doesn't provide a way to just show the new headers, so we
 	# have to compute the difference.
-	# ALSOCHANGE(etc/chasquid/hooks/post-data)
 	diff --changed-group-format='%>' \
 		--unchanged-group-format='' \
 		"$TF" "$TF.dkimout" && exit 1
diff --git a/test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error b/test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error
new file mode 100644
index 0000000..ab51f35
--- /dev/null
+++ b/test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error
@@ -0,0 +1 @@
+DKIM loading error: error decoding PEM block
diff --git a/test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf b/test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf
new file mode 100644
index 0000000..a47c3db
--- /dev/null
+++ b/test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf
@@ -0,0 +1,9 @@
+smtp_address: ":1025"
+submission_address: ":1587"
+submission_over_tls_address: ":1465"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "%to%"
+
+data_dir: "../.data"
+mail_log_path: "../.logs/mail_log"
diff --git a/test/t-20-bad_configs/c-11-bad_dkim_key/domains/testserver/dkim:selector.pem b/test/t-20-bad_configs/c-11-bad_dkim_key/domains/testserver/dkim:selector.pem
new file mode 100644
index 0000000..18d9039
--- /dev/null
+++ b/test/t-20-bad_configs/c-11-bad_dkim_key/domains/testserver/dkim:selector.pem
@@ -0,0 +1 @@
+Bad key
diff --git a/test/t-20-bad_configs/run.sh b/test/t-20-bad_configs/run.sh
index 9ba2130..870f033 100755
--- a/test/t-20-bad_configs/run.sh
+++ b/test/t-20-bad_configs/run.sh
@@ -18,7 +18,8 @@ mkdir -p c-04-no_cert_dirs/certs/
 
 # Generate certs for the tests that need them.
 for i in c-05-no_addrs c-06-bad_maillog c-07-bad_domain_info \
-	c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr ;
+	c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr \
+	c-11-bad_dkim_key;
 do
 	CONFDIR=$i/ generate_certs_for testserver
 done
diff --git a/test/t-21-dkim/.gitignore b/test/t-21-dkim/.gitignore
new file mode 100644
index 0000000..5e5dd84
--- /dev/null
+++ b/test/t-21-dkim/.gitignore
@@ -0,0 +1,2 @@
+# Ignore the configuration domain directories.
+?/domains
diff --git a/test/t-21-dkim/A/chasquid.conf b/test/t-21-dkim/A/chasquid.conf
new file mode 100644
index 0000000..6c6e987
--- /dev/null
+++ b/test/t-21-dkim/A/chasquid.conf
@@ -0,0 +1,9 @@
+smtp_address: ":1025"
+submission_address: ":1587"
+monitoring_address: ":1099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "%to%"
+
+data_dir: "../.data-A"
+mail_log_path: "../.logs-A/mail_log"
diff --git a/test/t-21-dkim/A/s1._domainkey.srv-a.pem b/test/t-21-dkim/A/s1._domainkey.srv-a.pem
new file mode 100644
index 0000000..406cdc3
--- /dev/null
+++ b/test/t-21-dkim/A/s1._domainkey.srv-a.pem
@@ -0,0 +1,3 @@
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS
+-----END PRIVATE KEY-----
diff --git a/test/t-21-dkim/B/chasquid.conf b/test/t-21-dkim/B/chasquid.conf
new file mode 100644
index 0000000..2e37697
--- /dev/null
+++ b/test/t-21-dkim/B/chasquid.conf
@@ -0,0 +1,9 @@
+smtp_address: ":2025"
+submission_address: ":2587"
+monitoring_address: ":2099"
+
+mail_delivery_agent_bin: "test-mda"
+mail_delivery_agent_args: "%to%"
+
+data_dir: "../.data-B"
+mail_log_path: "../.logs-B/mail_log"
diff --git a/test/t-21-dkim/from_A_to_B b/test/t-21-dkim/from_A_to_B
new file mode 100644
index 0000000..2d2abad
--- /dev/null
+++ b/test/t-21-dkim/from_A_to_B
@@ -0,0 +1,11 @@
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+	d=srv-a; s=s1; t=1709494311;
+	h=from:subject:to:from:subject:date:to:cc:message-id;
+	bh=0MIF2K4/fGA4bxV9yOwV0PQSZ3Glv67jLvQ8NwgjcKQ=;
+	b=JkROrF9he5gqMhWcU47h6koleiwkz0IWcRV467KuzsMdTeWPMUVB+JDu+6HElBofdzNsz5
+	  Ptug637opt4UaAAg==;
+From: user-a@srv-a
+To: user-b@srv-b
+Subject: Hola amigo pingüino!
+
+Que tal va la vida?
diff --git a/test/t-21-dkim/from_A_to_B.expected b/test/t-21-dkim/from_A_to_B.expected
new file mode 100644
index 0000000..6cc3639
--- /dev/null
+++ b/test/t-21-dkim/from_A_to_B.expected
@@ -0,0 +1,14 @@
+Authentication-Results: srv-b
+	;spf=none (no DNS record found)
+	;dkim=pass  header.b=JkROrF9he5gq  header.d=srv-a
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+	d=srv-a; s=s1; t=1709494311;
+	h=from:subject:to:from:subject:date:to:cc:message-id;
+	bh=0MIF2K4/fGA4bxV9yOwV0PQSZ3Glv67jLvQ8NwgjcKQ=;
+	b=JkROrF9he5gqMhWcU47h6koleiwkz0IWcRV467KuzsMdTeWPMUVB+JDu+6HElBofdzNsz5
+	  Ptug637opt4UaAAg==;
+From: user-a@srv-a
+To: user-b@srv-b
+Subject: Hola amigo pingüino!
+
+Que tal va la vida?
diff --git a/test/t-21-dkim/from_B_to_A b/test/t-21-dkim/from_B_to_A
new file mode 100644
index 0000000..011c81b
--- /dev/null
+++ b/test/t-21-dkim/from_B_to_A
@@ -0,0 +1,5 @@
+From: user-b@srv-b
+To: user-a@srv-a
+Subject: Feliz primavera!
+
+Espero que florezcas feliz!
diff --git a/test/t-21-dkim/from_B_to_A.expected b/test/t-21-dkim/from_B_to_A.expected
new file mode 100644
index 0000000..e7836bd
--- /dev/null
+++ b/test/t-21-dkim/from_B_to_A.expected
@@ -0,0 +1,15 @@
+From user-a@srv-a
+Authentication-Results: srv-a
+	;spf=none (no DNS record found)
+	;dkim=pass  header.b=*
+DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
+	d=srv-b; s=sel77; *
+	h=from:subject:to:from:subject:date:to:cc:message-id;
+	bh=*
+	b=*
+	  *
+From: user-b@srv-b
+To: user-a@srv-a
+Subject: Feliz primavera!
+
+Espero que florezcas feliz!
diff --git a/test/t-21-dkim/run.sh b/test/t-21-dkim/run.sh
new file mode 100755
index 0000000..c395dce
--- /dev/null
+++ b/test/t-21-dkim/run.sh
@@ -0,0 +1,67 @@
+#!/bin/bash
+
+set -e
+. "$(dirname "$0")/../util/lib.sh"
+
+init
+check_hostaliases
+
+rm -rf .data-A .data-B .mail
+
+skip_if_python_is_too_old
+
+# Build with the DNS override, so we can fake DNS records.
+export GOTAGS="dnsoverride"
+
+# srv-A has a pre-generated key, and the mail has a pre-generated header.
+# Generate a key for srv-B, and append it to our statically configured zones.
+# Use a fixed selector so we can be more thorough in from_B_to_A.expected.
+rm -f B/domains/srv-b/*.pem
+mkdir -p B/domains/srv-b/
+CONFDIR=B chasquid-util dkim-keygen srv-b sel77 --algo=ed25519 > /dev/null
+
+cp zones .zones
+CONFDIR=B chasquid-util dkim-dns srv-b | sed 's/"//g' >> .zones
+
+# Launch minidns in the background using our configuration.
+minidns_bg --addr=":9053" -zones=.zones >> .minidns.log 2>&1
+
+# Two servers:
+# A - listens on :1025, hosts srv-A
+# B - listens on :2015, hosts srv-B
+
+CONFDIR=A generate_certs_for srv-A
+CONFDIR=A add_user user-a@srv-a nadaA
+
+CONFDIR=B generate_certs_for srv-B
+CONFDIR=B add_user user-b@srv-b nadaB
+
+mkdir -p .logs-A .logs-B
+
+chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=2025 &
+chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \
+	--testing__dns_addr=127.0.0.1:9053 \
+	--testing__outgoing_smtp_port=1025 &
+
+wait_until_ready 1025
+wait_until_ready 2025
+wait_until_ready 9053
+
+# Send from A to B.
+smtpc.py --server=localhost:1025 --user=user-a@srv-a --password=nadaA \
+	< from_A_to_B
+
+wait_for_file .mail/user-b@srv-b
+mail_diff from_A_to_B.expected .mail/user-b@srv-b
+
+# Send from B to A.
+smtpc.py --server=localhost:2025 --user=user-b@srv-b --password=nadaB \
+	< from_B_to_A
+
+wait_for_file .mail/user-a@srv-a
+mail_diff from_B_to_A.expected .mail/user-a@srv-a
+
+
+success
diff --git a/test/t-21-dkim/zones b/test/t-21-dkim/zones
new file mode 100644
index 0000000..b0f706a
--- /dev/null
+++ b/test/t-21-dkim/zones
@@ -0,0 +1,6 @@
+srv-a  A     127.0.0.1
+srv-a  AAAA  ::1
+srv-b  A     127.0.0.1
+srv-b  AAAA  ::1
+
+s1._domainkey.srv-a  TXT  v=DKIM1; k=ed25519; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=
diff --git a/test/util/chamuyero b/test/util/chamuyero
index 3f41d4f..fa74b8a 100755
--- a/test/util/chamuyero
+++ b/test/util/chamuyero
@@ -43,7 +43,7 @@ class Process (object):
         return self.cmd.wait()
 
     def close(self):
-        return self.cmd.terminate()
+        return self.cmd.stdin.close()
 
 class Sock (object):
     """A (generic) socket.
diff --git a/test/util/lib.sh b/test/util/lib.sh
index c373dd4..8762f52 100644
--- a/test/util/lib.sh
+++ b/test/util/lib.sh
@@ -48,7 +48,7 @@ function chasquid-util() {
 	# data_dir is relative to the config.
 	CONFDIR="${CONFDIR:-config}"
 	( cd "$CONFDIR" && \
-	  go run "${TBASE}/../../cmd/chasquid-util/chasquid-util.go" \
+	  go run "${TBASE}/../../cmd/chasquid-util/" \
 		-C=. \
 		"$@" \
 	)