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 [ ] [--algo=rsa3072|rsa4096|ed25519] + Generate a new DKIM key pair for the domain. + chasquid-util [options] dkim-dns [ ] + Print the DNS TXT record to use for the domain, selector and + private key. + Options: -C=, --configdir= 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: +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..dcfa454 100644 --- a/docs/dkim.md +++ b/docs/dkim.md @@ -1,66 +1,83 @@ # DKIM integration -[chasquid] supports generating [DKIM] signatures via the [hooks](hooks.md) -mechanism. +[chasquid] supports verifying and generating [DKIM] signatures since version +1.14. +Before that version, support was 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. -## Signing +All incoming email is verified, and *authenticated* emails for domain which +have a private DKIM key will be signed. -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 @@ -68,3 +85,5 @@ included in the example. [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/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..f930dc5 --- /dev/null +++ b/internal/dkim/dns_test.go @@ -0,0 +1,227 @@ +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, + } + // TODO: When we use Go 1.21, change this to `defer clear(results)`. + defer func() { results = map[string][]string{} }() + + 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) + } + } +} 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 == "" { + 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: "", + }, + { + 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: "", + }, + { + sig: &dkimSignature{ + a: "rsa-sha256", + b: []byte("hola que tal"), + bh: []byte("como andas"), + d: "example.com", + h: []string{"from"}, + s: "selector", + }, + err: "", + }, + } + + 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 +To: Suzie Q +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 @@ + \ 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 +To: Suzie Q +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 @@ + \ 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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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 +To: Suzie Q +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..722538b 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" @@ -129,6 +130,9 @@ type Conn struct { spfResult spf.Result spfError error + // DKIM verification results. + dkimVerifyResult *dkim.VerifyResult + // Are we using TLS? onTLS bool @@ -142,6 +146,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 +673,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 +723,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 +731,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 +753,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 +1001,64 @@ 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 { + 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) + } + 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) + } + + // 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 1b73a50..900dc6c 100755 --- a/test/t-20-bad_configs/run.sh +++ b/test/t-20-bad_configs/run.sh @@ -21,7 +21,8 @@ mkdir -p c-04-no_cert_dirs/certs/ # each to have a different cert, and this speeds up the test quite a bit. CONFDIR=c-05-no_addrs/ generate_certs_for testserver for i in 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 cp -a c-05-no_addrs/certs/ $i/certs 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..8bd3dc2 --- /dev/null +++ b/test/t-21-dkim/run.sh @@ -0,0 +1,66 @@ +#!/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 +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 5ed52bc..2f03cf7 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=. \ "$@" \ )