1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2026-02-08 22:35:58 +00:00

dkim: Implement internal dkim signing and verification

This patch implements internal DKIM signing and verification.
This commit is contained in:
Alberto Bertogli
2024-02-10 23:55:05 +00:00
parent f13fdf0ac8
commit 76a72367ae
90 changed files with 4902 additions and 112 deletions

View File

@@ -29,17 +29,18 @@ It's written in [Go](https://golang.org), and distributed under the
* Useful * Useful
* Multiple/virtual domains, with per-domain users and aliases. * Multiple/virtual domains, with per-domain users and aliases.
* Suffix dropping (`user+something@domain``user@domain`). * Suffix dropping (`user+something@domain``user@domain`).
* [Hooks] for integration with greylisting, anti-virus, anti-spam, and * [Hooks] for integration with greylisting, anti-virus, and anti-spam.
DKIM/DMARC.
* International usernames ([SMTPUTF8]) and domain names ([IDNA]). * International usernames ([SMTPUTF8]) and domain names ([IDNA]).
* Secure * Secure
* [Tracking] of per-domain TLS support, prevents connection downgrading. * [Tracking] of per-domain TLS support, prevents connection downgrading.
* Multiple TLS certificates. * Multiple TLS certificates.
* Easy integration with [Let's Encrypt]. * Easy integration with [Let's Encrypt].
* [SPF] and [MTA-STS] checking. * [SPF] and [MTA-STS] checking.
* [DKIM] support (signing and verification).
[Arch]: https://blitiri.com.ar/p/chasquid/install/#arch [Arch]: https://blitiri.com.ar/p/chasquid/install/#arch
[DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
[Debian]: https://blitiri.com.ar/p/chasquid/install/#debianubuntu [Debian]: https://blitiri.com.ar/p/chasquid/install/#debianubuntu
[Dovecot]: https://blitiri.com.ar/p/chasquid/dovecot/ [Dovecot]: https://blitiri.com.ar/p/chasquid/dovecot/
[Hooks]: https://blitiri.com.ar/p/chasquid/hooks/ [Hooks]: https://blitiri.com.ar/p/chasquid/hooks/

View File

@@ -12,7 +12,9 @@ import (
"net" "net"
"os" "os"
"os/signal" "os/signal"
"path"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
@@ -297,6 +299,14 @@ func loadDomain(name, dir string, s *smtpsrv.Server) {
if err != nil { if err != nil {
log.Errorf(" aliases file error: %v", err) 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) { 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. // Read a directory, which must have at least some entries.
func mustReadDir(path string) []os.DirEntry { func mustReadDir(path string) []os.DirEntry {
dirs, err := os.ReadDir(path) dirs, err := os.ReadDir(path)

View File

@@ -39,8 +39,15 @@ Usage:
chasquid-util [options] print-config chasquid-util [options] print-config
Print the current chasquid configuration. Print the current chasquid configuration.
chasquid-util [options] dkim-keygen <domain> [<selector> <private-key.pem>] [--algo=rsa3072|rsa4096|ed25519]
Generate a new DKIM key pair for the domain.
chasquid-util [options] dkim-dns <domain> [<selector> <private-key.pem>]
Print the DNS TXT record to use for the domain, selector and
private key.
Options: Options:
-C=<path>, --configdir=<path> Configuration directory -C=<path>, --configdir=<path> Configuration directory
-v Verbose mode
` `
// Command-line arguments. // Command-line arguments.
@@ -80,6 +87,13 @@ func main() {
"aliases-resolve": aliasesResolve, "aliases-resolve": aliasesResolve,
"print-config": printConfig, "print-config": printConfig,
"domaininfo-remove": domaininfoRemove, "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"] cmd := args["$1"]

260
cmd/chasquid-util/dkim.go Normal file
View File

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

View File

@@ -24,8 +24,8 @@ function check_userdb() {
} }
rm -rf .config/
mkdir -p .config/domains/domain/ .data/domaininfo mkdir -p .config/domains/domain/ .data/domaininfo
rm -f .config/chasquid.conf
echo 'data_dir: ".data"' >> .config/chasquid.conf echo 'data_dir: ".data"' >> .config/chasquid.conf
if ! r print-config > /dev/null; then if ! r print-config > /dev/null; then
@@ -57,6 +57,9 @@ if ! ( echo "$C" | grep -E -q "hostname:.*\"$HOSTNAME\"" ); then
exit 1 exit 1
fi fi
rm -rf .keys/
mkdir .keys/
# Run all the chamuyero tests. # Run all the chamuyero tests.
for i in *.cmy; do for i in *.cmy; do
if ! chamuyero "$i" > "$i.log" 2>&1 ; then if ! chamuyero "$i" > "$i.log" 2>&1 ; then

View File

@@ -0,0 +1,190 @@
# Test dkim-dns subcommand with keys pre-generated by openssl, to validate
# interoperability.
c = ./chasquid-util dkim-dns example.com sel123 test_openssl_genpkey_ed25519.pem
c <- sel123._domainkey.example.com TXT "v=DKIM1; k=ed25519; p=QXNdsDCVOrViGMRh4BIE/IgUCcBEwio3kpJ3e0GAipw="
c wait 0
c = ./chasquid-util dkim-dns example.com sel123 test_openssl_genpkey_rsa.pem
c <- sel123._domainkey.example.com TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAieZWhl7dnxHGyucZS2+dyExPQytj/aY46RXJ4yT3zWY8gh5YkVZ2L1x++7XMzzSg/5FR5bkKYV9Xa+jO6YlhriYKo3ttWSmxU0hDKbG7dpD9Tr7tjCcmKqE1IXetl6DXlQl7LRdmkeIND4gtf9A1zOPLR3/+kvsu1u2cUsEFVs36FqbTe4BYLn2RQlT4IQocT5eVEvoHc5apKuTOKBYThhWRaSZG9YXvsdd1UjngR2Xmizu5e/hj2f3W+9rmRRy1ukmUryuMUHMae2V27Wy1vrHiYoMUA1kQJY+HTG5kMkuatxNui9yjmdqrQUvCIU2Fa5jxJYQTLIz4U0/z4tStRwIDAQAB"
c wait 0
# Generate our own keys, and then check we can parse them with dkim-dns.
# Do this once per algorithm (including the default).
# Default algorithm.
c = ./chasquid-util dkim-keygen example.com selDef .keys/test_def.pem
c <- Key written to ".keys/test_def.pem"
c <-
c <~ selDef._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
c wait 0
c = ./chasquid-util dkim-dns example.com selDef .keys/test_def.pem
c <~ selDef._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
c wait 0
# RSA 3072.
c = ./chasquid-util dkim-keygen example.com selRSA3 .keys/test_rsa3.pem --algo=rsa3072
c <- Key written to ".keys/test_rsa3.pem"
c <-
c <~ selRSA3._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
c wait 0
c = ./chasquid-util dkim-dns example.com selRSA3 .keys/test_rsa3.pem
c <~ selRSA3._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*"
c wait 0
# RSA 4096.
c = ./chasquid-util dkim-keygen example.com selRSA4 .keys/test_rsa4.pem --algo=rsa4096
c <- Key written to ".keys/test_rsa4.pem"
c <-
c <~ selRSA4._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{730,740}=*"
c wait 0
c = ./chasquid-util dkim-dns example.com selRSA4 .keys/test_rsa4.pem
c <~ selRSA4._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{730,740}=*"
c wait 0
# Ed25519.
c = ./chasquid-util dkim-keygen example.com selED25519 .keys/test_ed25519.pem --algo=ed25519
c <- Key written to ".keys/test_ed25519.pem"
c <-
c <~ selED25519._domainkey.example.com\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
c wait 0
c = ./chasquid-util dkim-dns example.com selED25519 .keys/test_ed25519.pem
c <~ selED25519._domainkey.example.com\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
c wait 0
# Refuse to overwrite a key file.
c = ./chasquid-util dkim-keygen example.com selED25519 .keys/test_ed25519.pem --algo=ed25519
c <- Error: key already exists at ".keys/test_ed25519.pem"
c wait 1
# Automatically decide on the selector and key path.
c = ./chasquid-util -C=.config dkim-keygen domain --algo=ed25519
c <~ Key written to ".config/domains/domain/dkim:[0-9]{8}.pem"
c <-
c <~ [0-9]{8}._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
c wait 0
# Custom selector, but automatic key path
c = ./chasquid-util -C=.config dkim-keygen domain sel1 --algo=ed25519
c <~ Key written to ".config/domains/domain/dkim:sel1.pem"
c <-
c <~ sel1._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
c wait 0
# Missing parameters.
c = ./chasquid-util -C=.config dkim-keygen
c <- Error: missing domain parameter
c wait 1
# Unsupported algorithm
c = ./chasquid-util -C=.config dkim-keygen domain s k.pem --algo=xxx666
c <- Error: unsupported algorithm "xxx666"
c wait 1
# Automatically find selector and key path.
c = ./chasquid-util -C=.config dkim-dns domain
c <~ [0-9]{8}._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*"
c wait 0
# Require at least a domain.
c = ./chasquid-util -C=.config dkim-dns
c <- Error: missing domain parameter
c wait 1
# Error reading key.
c = ./chasquid-util -C=.config dkim-dns domain unknownsel badkey.pem
c <- Error reading private key from "badkey.pem": open badkey.pem: no such file or directory
c wait 1
# No DKIM keys found.
c = ./chasquid-util -C=.config dkim-dns unkdomain
c <- No DKIM keys found in ".config/domains/unkdomain/dkim:*.pem"
c wait 1
# DKIM signing, with various forms.
c = ./chasquid-util -C=.config dkim-sign domain
c -> From: user-a@srv-a
c ->
c -> A little tiny message.
c close
c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
c <~ \td=domain; s=\d+; t=\d+;
c <~ \th=from:from:subject:date:to:cc:message-id;
c <~ \tbh=.*;
c <~ \tb=.*
c <~ \t .*;
c wait 0
c = ./chasquid-util -C=.config dkim-sign domain sel1
c -> From: user-a@srv-a
c ->
c -> A little tiny message.
c close
c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
c wait 0
c = ./chasquid-util -C=.config dkim-sign domain selED25519 .keys/test_ed25519.pem
c -> From: user-a@srv-a
c ->
c -> A little tiny message.
c close
c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
c wait 0
c = ./chasquid-util -C=.config dkim-sign
c -> From: user-a@domain
c ->
c -> A little tiny message.
c close
c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
c wait 0
# Bad message for dkim-sign.
c = ./chasquid-util -C=.config dkim-sign
c -> Invalid message.
c close
c <- Error parsing message: malformed header line: Invalid message.
c wait 1
c = ./chasquid-util -C=.config dkim-sign
c -> From: <not a good address>
c ->
c -> A little tiny message.
c close
c <- Error parsing From: header: mail: missing @ in addr-spec
c wait 1
# DKIM verification.
# Just check that the attempt was made.
c = ./chasquid-util -C=.config dkim-verify
c -> From: user-a@srv-a
c ->
c -> A little tiny message.
c close
c <~ Authentication-Results: .*
c <~ \t;dkim=none
c wait 0
# Tracing. Just check that there's some output, we don't need byte-for-byte
# verification as the contents are not expected to be stable.
c = ./chasquid-util -C=.config dkim-sign -v
c -> From: user-a@domain
c ->
c -> A little tiny message.
c close
c <~ Signing for domain / \d+ with ed25519-sha256
c wait 0
c = ./chasquid-util -C=.config dkim-verify -v
c -> From: user-a@srv-a
c ->
c -> A little tiny message.
c close
c <- Found 0 signatures, 0 valid
c <~ Authentication-Results: .*
c <~ \t;dkim=none
c wait 0

View File

@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIBul+k51unaApEcZBmt1i65n09asM/howsN4B1AjNY5V
-----END PRIVATE KEY-----

View File

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

View File

@@ -1,70 +1,90 @@
# DKIM integration # DKIM integration
[chasquid] supports generating [DKIM] signatures via the [hooks](hooks.md) [chasquid] supports verifying and generating [DKIM] signatures since version
mechanism. 1.14.
All incoming email is verified, and *authenticated* emails for domains which
have a private DKIM key set up will be signed.
In versions older than 1.13, support is possible via the [hooks] mechanism. In
particular, the [example hook] included support for some command-line
implementations. That continues to be an option, especially if customization
is needed.
## Signing ## Easy setup
The [example hook] includes integration with [driusan/dkim] and [dkimpy], and - Run `chasquid-util dkim-keygen DOMAIN` to generate a DKIM private key for
assumes the following: 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.
- The [selector](https://tools.ietf.org/html/rfc6376#section-3.1) for a domain It is highly recommended that you use a DKIM checker (like
can be found in the file `domains/$DOMAIN/dkim_selector`. [Learn DMARC](https://www.learndmarc.com/)) to confirm that your setup is
- The private key to use for signing can be found in the file fully functional.
`certs/$DOMAIN/dkim_privkey.pem`.
Only authenticated email will be signed.
### Setup with [driusan/dkim] ## Advanced setup
1. Install the [driusan/dkim] tools with something like the following (adjust You need to place the PEM-encoded private key in the domain config directory,
to your local environment): with a name like `dkim:SELECTOR.pem`, where `SELECTOR` is the selector string.
``` It needs to be either RSA or Ed25519.
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
```
1. Generate the domain key for your domain using `dkimkeygen`. ### Key rotation
1. Publish the DNS record from `dns.txt`
([guide](https://support.dnsimple.com/articles/dkim-record/)). To rotate a key, you can remove the old key file, and generate a new one as
1. Write the selector you chose to `domains/$DOMAIN/dkim_selector`. per the previous step.
1. Copy `private.pem` to `/etc/chasquid/certs/$DOMAIN/dkim_privkey.pem`.
1. Verify the setup using one of the publicly available tools, like It is important to remove the old key from the directory, because chasquid
[mail-tester](https://www.mail-tester.com/spf-dkim-check). 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.
### Setup with [dkimpy] ### Multiple keys
1. Install [dkimpy] with `apt install python3-dkim` or the equivalent for your Advanced users may want to sign outgoing mail with multiple keys (e.g. to
environment. support multiple signing algorithms).
1. Generate the domain key for your domain using `dknewkey dkim`.
1. Publish the DNS record from `dkim.dns` This is well supported: chasquid will sign email with all keys it find that
([guide](https://support.dnsimple.com/articles/dkim-record/)). match `dkim:*.pem` in a domain directory.
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).
## Verification ## Verification
Verifying signatures is technically supported as well, and can be done in the [chasquid] will verify all DKIM signatures of incoming mail, and record the
same hook. However, it's not recommended for SMTP servers to reject mail on results in an [`Authentication-Results:`] header, as per [RFC 8601].
verification failures
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 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 [source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)).
included in the example.
## 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 [chasquid]: https://blitiri.com.ar/p/chasquid
[DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail [DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
[hooks]: hooks.md
[example hook]: https://blitiri.com.ar/git/r/chasquid/b/next/t/etc/chasquid/hooks/f=post-data.html [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 [driusan/dkim]: https://github.com/driusan/dkim
[dkimpy]: https://launchpad.net/dkimpy/ [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

View File

@@ -53,6 +53,18 @@ List of exported variables:
- **chasquid/smtpIn/commandCount** (map of command -> count) - **chasquid/smtpIn/commandCount** (map of command -> count)
count of SMTP commands received, by command. Note that for unknown commands count of SMTP commands received, by command. Note that for unknown commands
we use `unknown<COMMAND>`. we use `unknown<COMMAND>`.
- **chasquid/smtpIn/dkimSignErrors** (counter)
count of DKIM sign errors
- **chasquid/smtpIn/dkimSigned** (counter)
count of successful DKIM signs
- **chasquid/smtpIn/dkimVerifyErrors** (counter)
count of DKIM verification errors
- **chasquid/smtpIn/dkimVerifyFound** (counter)
count of messages with at least one DKIM signature
- **chasquid/smtpIn/dkimVerifyNotFound** (counter)
count of messages with no DKIM signatures
- **chasquid/smtpIn/dkimVerifyValid** (counter)
count of messages with at least one valid DKIM signature
- **chasquid/smtpIn/hookResults** (result -> counter) - **chasquid/smtpIn/hookResults** (result -> counter)
count of hook invocations, by result. count of hook invocations, by result.
- **chasquid/smtpIn/loopsDetected** (counter) - **chasquid/smtpIn/loopsDetected** (counter)

View File

@@ -7,7 +7,6 @@
# - spamc (from Spamassassin) to filter spam. # - spamc (from Spamassassin) to filter spam.
# - rspamc (from rspamd) or chasquid-rspamd to filter spam. # - rspamc (from rspamd) or chasquid-rspamd to filter spam.
# - clamdscan (from ClamAV) to filter virus. # - 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. # If it exits with code 20, it will be considered a permanent error.
# Otherwise, temporary. # Otherwise, temporary.
@@ -78,46 +77,3 @@ if command -v clamdscan >/dev/null; then
fi fi
echo "X-Virus-Scanned: pass" echo "X-Virus-Scanned: pass"
fi 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

View File

@@ -0,0 +1,158 @@
package dkim
import (
"errors"
"fmt"
"regexp"
"strings"
)
var (
errNoBody = errors.New("no body found")
errUnknownCanonicalization = errors.New("unknown canonicalization")
)
type canonicalization string
var (
simpleCanonicalization canonicalization = "simple"
relaxedCanonicalization canonicalization = "relaxed"
)
func (c canonicalization) body(b string) string {
switch c {
case simpleCanonicalization:
return simpleBody(b)
case relaxedCanonicalization:
return relaxBody(b)
default:
panic("unknown canonicalization")
}
}
func (c canonicalization) headers(hs headers) headers {
switch c {
case simpleCanonicalization:
return hs
case relaxedCanonicalization:
return relaxHeaders(hs)
default:
panic("unknown canonicalization")
}
}
func (c canonicalization) header(h header) header {
switch c {
case simpleCanonicalization:
return h
case relaxedCanonicalization:
return relaxHeader(h)
default:
panic("unknown canonicalization")
}
}
func stringToCanonicalization(s string) (canonicalization, error) {
switch s {
case "simple":
return simpleCanonicalization, nil
case "relaxed":
return relaxedCanonicalization, nil
default:
return "", fmt.Errorf("%w: %s", errUnknownCanonicalization, s)
}
}
// Notes on whitespace reduction:
// https://datatracker.ietf.org/doc/html/rfc6376#section-2.8
// There are only 3 forms of whitespace:
// - WSP = SP / HTAB
// Simple whitespace: space or tab.
// - LWSP = *(WSP / CRLF WSP)
// Linear whitespace: any number of { simple whitespace OR CRLF followed by
// simple whitespace }.
// - FWS = [*WSP CRLF] 1*WSP
// Folding whitespace: optional { simple whitespace OR CRLF } followed by
// one or more simple whitespace.
func simpleBody(body string) string {
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3
// Replace repeated CRLF at the end of the body with a single CRLF.
body = repeatedCRLFAtTheEnd.ReplaceAllString(body, "\r\n")
// Ensure a non-empty body ends with a single CRLF.
// All bodies (including an empty one) must end with a CRLF.
if !strings.HasSuffix(body, "\r\n") {
body += "\r\n"
}
return body
}
var (
// Continued header: WSP after CRLF.
continuedHeader = regexp.MustCompile(`\r\n[ \t]+`)
// WSP before CRLF.
wspBeforeCRLF = regexp.MustCompile(`[ \t]+\r\n`)
// Repeated WSP.
repeatedWSP = regexp.MustCompile(`[ \t]+`)
// Empty lines at the end of the body.
repeatedCRLFAtTheEnd = regexp.MustCompile(`(\r\n)+$`)
)
func relaxBody(body string) string {
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.4
body = wspBeforeCRLF.ReplaceAllLiteralString(body, "\r\n")
body = repeatedWSP.ReplaceAllLiteralString(body, " ")
body = repeatedCRLFAtTheEnd.ReplaceAllLiteralString(body, "\r\n")
// Ensure a non-empty body ends with a single CRLF.
if len(body) >= 1 && !strings.HasSuffix(body, "\r\n") {
body += "\r\n"
}
return body
}
func relaxHeader(h header) header {
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2
// Convert all header field names to lowercase.
name := strings.ToLower(h.Name)
// Remove WSP before the ":" separating the name and value.
name = strings.TrimRight(name, " \t")
// Unfold continuation lines in values.
value := continuedHeader.ReplaceAllString(h.Value, " ")
// Reduce all sequences of WSP to a single SP.
value = repeatedWSP.ReplaceAllLiteralString(value, " ")
// Delete all WSP at the end of each unfolded header field value.
value = strings.TrimRight(value, " \t")
// Remove WSP after the ":" separating the name and value.
value = strings.TrimLeft(value, " \t")
return header{
Name: name,
Value: value,
// The "source" is the relaxed field: name, colon, and value (with
// no space around the colon).
Source: name + ":" + value,
}
}
func relaxHeaders(hs headers) headers {
rh := make(headers, 0, len(hs))
for _, h := range hs {
rh = append(rh, relaxHeader(h))
}
return rh
}

View File

@@ -0,0 +1,214 @@
package dkim
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestStringToCanonicalization(t *testing.T) {
cases := []struct {
in string
want canonicalization
err error
}{
{"simple", simpleCanonicalization, nil},
{"relaxed", relaxedCanonicalization, nil},
{"", "", errUnknownCanonicalization},
{" ", "", errUnknownCanonicalization},
{" simple", "", errUnknownCanonicalization},
{"simple ", "", errUnknownCanonicalization},
{"si mple ", "", errUnknownCanonicalization},
}
for _, c := range cases {
got, err := stringToCanonicalization(c.in)
if diff := cmp.Diff(c.want, got); diff != "" {
t.Errorf("stringToCanonicalization(%q) diff (-want +got): %s",
c.in, diff)
}
diff := cmp.Diff(c.err, err, cmpopts.EquateErrors())
if diff != "" {
t.Errorf("stringToCanonicalization(%q) err diff (-want +got): %s",
c.in, diff)
}
}
}
func TestSimpleBody(t *testing.T) {
cases := []struct {
in, want string
}{
// Bodies end with \r\n, including the empty one.
{"", "\r\n"},
{"a", "a\r\n"},
{"a\r\n", "a\r\n"},
// Repeated CRLF at the end of the body is replaced with a single CRLF.
{"Body \r\n\r\n\r\n", "Body \r\n"},
// Example from RFC.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5
{
" C \r\nD \t E\r\n\r\n\r\n",
" C \r\nD \t E\r\n",
},
}
for _, c := range cases {
got := simpleCanonicalization.body(c.in)
if diff := cmp.Diff(c.want, got); diff != "" {
t.Errorf("simpleCanonicalization.body(%q) diff (-want +got): %s",
c.in, diff)
}
}
}
func TestRelaxBody(t *testing.T) {
cases := []struct {
in, want string
}{
{"a\r\n", "a\r\n"},
// Repeated WSP before CRLF.
{"a \r\n", "a\r\n"},
{"a \r\n", "a\r\n"},
{"a \t \r\n", "a\r\n"},
{"a\t\t\t\r\n", "a\r\n"},
// Repeated WSP within a line.
{"a b\r\n", "a b\r\n"},
{"a\t\t\tb\r\n", "a b\r\n"},
{"a \t \t b\r\n", "a b\r\n"},
// Ignore empty lines at the end.
{"a\r\n\r\n", "a\r\n"},
{"a\r\n\r\n\r\n", "a\r\n"},
// Body must end with \r\n, unless it's empty.
{"", ""},
{"\r\n", "\r\n"},
{"a", "a\r\n"},
// Example from RFC.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5
{" C \r\nD \t E\r\n\r\n\r\n", " C\r\nD E\r\n"},
}
for _, c := range cases {
got := relaxedCanonicalization.body(c.in)
if diff := cmp.Diff(c.want, got); diff != "" {
t.Errorf("relaxedCanonicalization.body(%q) diff (-want +got): %s",
c.in, diff)
}
}
}
func mkHs(hs ...string) headers {
var headers headers
for i := 0; i < len(hs); i += 2 {
h := header{
Name: hs[i],
Value: hs[i+1],
Source: hs[i] + ":" + hs[i+1],
}
headers = append(headers, h)
}
return headers
}
func TestHeaders(t *testing.T) {
cases := []struct {
in string
wantS headers
wantR headers
}{
// Unfold headers.
{"A: B\r\n C\r\n", mkHs("A", " B\r\n C"), mkHs("a", "B C")},
{"A: B\r\n\tC\r\n", mkHs("A", " B\r\n\tC"), mkHs("a", "B C")},
{"A: B\r\n \t C\r\n", mkHs("A", " B\r\n \t C"), mkHs("a", "B C")},
// Reduce all sequences of WSP within a line to a single SP.
{"A: B C\r\n", mkHs("A", " B C"), mkHs("a", "B C")},
{"A: B\t\tC\r\n", mkHs("A", " B\t\tC"), mkHs("a", "B C")},
{"A: B \t \t C\r\n", mkHs("A", " B \t \t C"), mkHs("a", "B C")},
// Delete all WSP at the end of each unfolded header field.
{"A: B \r\n", mkHs("A", " B "), mkHs("a", "B")},
{"A: B \r\n", mkHs("A", " B "), mkHs("a", "B")},
{"A: B\t \r\n", mkHs("A", " B\t "), mkHs("a", "B")},
{"A: B\t\t\t\r\n", mkHs("A", " B\t\t\t"), mkHs("a", "B")},
{"A: B\r\n \t C \t\r\n",
mkHs("A", " B\r\n \t C \t"), mkHs("a", "B C")},
// Whitespace before and after the colon.
{"A : B\r\n", mkHs("A ", " B"), mkHs("a", "B")},
{"A : B\r\n", mkHs("A ", " B"), mkHs("a", "B")},
{"A\t:\tB\r\n", mkHs("A\t", "\tB"), mkHs("a", "B")},
{"A\t \t : \t \tB\r\n", mkHs("A\t \t ", " \t \tB"), mkHs("a", "B")},
// Example from RFC.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5
{"A: X\r\nB : Y\t\r\n\tZ \r\n",
mkHs("A", " X", "B ", " Y\t\r\n\tZ "),
mkHs("a", "X", "b", "Y Z")},
}
for i, c := range cases {
hs, _, err := parseMessage(c.in)
if err != nil {
t.Fatalf("parseMessage(%q) = %v, want nil", c.in, err)
}
gotS := simpleCanonicalization.headers(hs)
if diff := cmp.Diff(c.wantS, gotS); diff != "" {
t.Errorf("%d: simpleCanonicalization.headers(%q) diff (-want +got): %s",
i, c.in, diff)
}
gotR := relaxedCanonicalization.headers(hs)
if diff := cmp.Diff(c.wantR, gotR); diff != "" {
t.Errorf("%d: relaxedCanonicalization.headers(%q) diff (-want +got): %s",
i, c.in, diff)
}
// Test the single-header variant if possible.
if len(hs) == 1 {
gotS := simpleCanonicalization.header(hs[0])
if diff := cmp.Diff(c.wantS[0], gotS); diff != "" {
t.Errorf("%d: simpleCanonicalization.header(%q) diff (-want +got): %s",
i, c.in, diff)
}
gotR := relaxedCanonicalization.header(hs[0])
if diff := cmp.Diff(c.wantR[0], gotR); diff != "" {
t.Errorf("%d: relaxedCanonicalization.header(%q) diff (-want +got): %s",
i, c.in, diff)
}
}
}
}
func TestBadCanonicalization(t *testing.T) {
bad := canonicalization("bad")
if !panics(func() { bad.body("") }) {
t.Errorf("bad.body() did not panic")
}
if !panics(func() { bad.header(header{}) }) {
t.Errorf("bad.header() did not panic")
}
if !panics(func() { bad.headers(nil) }) {
t.Errorf("bad.headers() did not panic")
}
}
func panics(f func()) (panicked bool) {
defer func() {
r := recover()
panicked = r != nil
}()
f()
return
}

56
internal/dkim/context.go Normal file
View File

@@ -0,0 +1,56 @@
package dkim
import (
"context"
"net"
)
type contextKey string
const traceKey contextKey = "trace"
func trace(ctx context.Context, f string, args ...interface{}) {
traceFunc, ok := ctx.Value(traceKey).(TraceFunc)
if !ok {
return
}
traceFunc(f, args...)
}
type TraceFunc func(f string, a ...interface{})
func WithTraceFunc(ctx context.Context, trace TraceFunc) context.Context {
return context.WithValue(ctx, traceKey, trace)
}
const lookupTXTKey contextKey = "lookupTXT"
func lookupTXT(ctx context.Context, domain string) ([]string, error) {
lookupTXTFunc, ok := ctx.Value(lookupTXTKey).(lookupTXTFunc)
if !ok {
return net.LookupTXT(domain)
}
return lookupTXTFunc(ctx, domain)
}
type lookupTXTFunc func(ctx context.Context, domain string) ([]string, error)
func WithLookupTXTFunc(ctx context.Context, lookupTXT lookupTXTFunc) context.Context {
return context.WithValue(ctx, lookupTXTKey, lookupTXT)
}
const maxHeadersKey contextKey = "maxHeaders"
func WithMaxHeaders(ctx context.Context, maxHeaders int) context.Context {
return context.WithValue(ctx, maxHeadersKey, maxHeaders)
}
func maxHeaders(ctx context.Context) int {
maxHeaders, ok := ctx.Value(maxHeadersKey).(int)
if !ok {
// By default, cap the number of headers to 5 (arbitrarily chosen, may
// be adjusted in the future).
return 5
}
return maxHeaders
}

View File

@@ -0,0 +1,67 @@
package dkim
import (
"context"
"fmt"
"net"
"testing"
)
func TestTraceNoCtx(t *testing.T) {
// Call trace() on a context without a trace function, to check it doesn't
// panic.
ctx := context.Background()
trace(ctx, "test")
}
func TestTrace(t *testing.T) {
s := ""
traceF := func(f string, a ...interface{}) {
s = fmt.Sprintf(f, a...)
}
ctx := WithTraceFunc(context.Background(), traceF)
trace(ctx, "test %d", 1)
if s != "test 1" {
t.Errorf("trace function not called")
}
}
func TestLookupTXTNoCtx(t *testing.T) {
// Call lookupTXT() on a context without an override, to check it calls
// the real function.
// We just check there is a reasonable error.
// We don't specifically check that it's NXDOMAIN because if we don't have
// internet access, the error may be different.
ctx := context.Background()
_, err := lookupTXT(ctx, "does.not.exist.example.com")
if _, ok := err.(*net.DNSError); !ok {
t.Fatalf("expected *net.DNSError, got %T", err)
}
}
func TestLookupTXT(t *testing.T) {
called := false
lookupTXTF := func(ctx context.Context, name string) ([]string, error) {
called = true
return nil, nil
}
ctx := WithLookupTXTFunc(context.Background(), lookupTXTF)
lookupTXT(ctx, "example.com")
if !called {
t.Errorf("lookupTXT function not called")
}
}
func TestMaxHeaders(t *testing.T) {
// First without an override, check we return the default.
ctx := context.Background()
if m := maxHeaders(ctx); m != 5 {
t.Errorf("expected 5, got %d", m)
}
// Now with an override.
ctx = WithMaxHeaders(ctx, 10)
if m := maxHeaders(ctx); m != 10 {
t.Errorf("expected 10, got %d", m)
}
}

201
internal/dkim/dns.go Normal file
View File

@@ -0,0 +1,201 @@
package dkim
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"slices"
"strings"
)
func findPublicKeys(ctx context.Context, domain, selector string) ([]*publicKey, error) {
// Subdomain where the key lives.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.2
d := selector + "._domainkey." + domain
values, err := lookupTXT(ctx, d)
if err != nil {
trace(ctx, "TXT lookup of %q failed: %v", d, err)
return nil, err
}
// There should be only a single record; RFC 6376 says the results are
// undefined if there are multiple TXT records.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.2.2
//
// What other implementations do:
// - dkimpy: Use the first TXT record (whatever it is).
// - OpenDKIM: Use the first TXT record (whatever it is).
// - driusan/dkim: Use the first TXT record that can be parsed as a key.
// - go-msgauth: Reject if there are multiple records.
//
// What we do: use _all_ TXT records that can be parsed as keys. This is
// possibly too much, and we could reconsider this in the future.
pks := []*publicKey{}
for _, v := range values {
trace(ctx, "TXT record for %q: %q", d, v)
pk, err := parsePublicKey(v)
if err != nil {
trace(ctx, "Skipping: %v", err)
continue
}
trace(ctx, "Parsed public key: %s", pk)
pks = append(pks, pk)
}
return pks, nil
}
// Function to verify a signature with this public key.
type verifyFunc func(h crypto.Hash, hashed, signature []byte) error
type publicKey struct {
H []crypto.Hash
K keyType
P []byte
T []string // t= tag, representing flags.
verify verifyFunc
}
func (pk *publicKey) String() string {
return fmt.Sprintf("[%s:%.8x]", pk.K, pk.P)
}
func (pk *publicKey) Matches(kt keyType, h crypto.Hash) bool {
if pk.K != kt {
return false
}
if len(pk.H) > 0 {
return slices.Contains(pk.H, h)
}
return true
}
func (pk *publicKey) StrictDomainCheck() bool {
// t=s is set.
return slices.Contains(pk.T, "s")
}
func parsePublicKey(v string) (*publicKey, error) {
// Public key is a tag-value list.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1
tags, err := parseTags(v)
if err != nil {
return nil, err
}
// "v" is optional, but if present it must be "DKIM1".
ver, ok := tags["v"]
if ok && ver != "DKIM1" {
return nil, fmt.Errorf("%w: %q", errInvalidVersion, ver)
}
pk := &publicKey{
// The default key type is rsa.
K: keyTypeRSA,
}
// h is a colon-separated list of hashing algorithm names.
if tags["h"] != "" {
hs := strings.Split(eatWhitespace.Replace(tags["h"]), ":")
for _, h := range hs {
x, err := hashFromString(h)
if err != nil {
// Unrecognized algorithms must be ignored.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1
continue
}
pk.H = append(pk.H, x)
}
}
// k is key type (may not be present, rsa is used in that case).
if tags["k"] != "" {
pk.K, err = keyTypeFromString(tags["k"])
if err != nil {
return nil, err
}
}
// p is public-key data, base64-encoded, and whitespace in it must be
// ignored. Required.
p, err := base64.StdEncoding.DecodeString(
eatWhitespace.Replace(tags["p"]))
if err != nil {
return nil, fmt.Errorf("error decoding p=: %w", err)
}
pk.P = p
switch pk.K {
case keyTypeRSA:
pk.verify, err = parseRSAPublicKey(p)
case keyTypeEd25519:
pk.verify, err = parseEd25519PublicKey(p)
}
// t is a colon-separated list of flags.
if t := eatWhitespace.Replace(tags["t"]); t != "" {
pk.T = strings.Split(t, ":")
}
if err != nil {
return nil, err
}
return pk, nil
}
var (
errInvalidRSAPublicKey = errors.New("invalid RSA public key")
errNotRSAPublicKey = errors.New("not an RSA public key")
errRSAKeyTooSmall = errors.New("RSA public key too small")
errInvalidEd25519Key = errors.New("invalid Ed25519 public key")
)
func parseRSAPublicKey(p []byte) (verifyFunc, error) {
// Either PKCS#1 or SubjectPublicKeyInfo.
// See https://www.rfc-editor.org/errata/eid3017.
pub, err := x509.ParsePKIXPublicKey(p)
if err != nil {
pub, err = x509.ParsePKCS1PublicKey(p)
}
if err != nil {
return nil, fmt.Errorf("%w: %w", errInvalidRSAPublicKey, err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errNotRSAPublicKey
}
// Enforce 1024-bit minimum.
// https://datatracker.ietf.org/doc/html/rfc8301#section-3.2
if rsaPub.Size()*8 < 1024 {
return nil, errRSAKeyTooSmall
}
return func(h crypto.Hash, hashed, signature []byte) error {
return rsa.VerifyPKCS1v15(rsaPub, h, hashed, signature)
}, nil
}
func parseEd25519PublicKey(p []byte) (verifyFunc, error) {
// https: //datatracker.ietf.org/doc/html/rfc8463
if len(p) != ed25519.PublicKeySize {
return nil, errInvalidEd25519Key
}
pub := ed25519.PublicKey(p)
return func(h crypto.Hash, hashed, signature []byte) error {
if ed25519.Verify(pub, hashed, signature) {
return nil
}
return errors.New("signature verification failed")
}, nil
}

248
internal/dkim/dns_test.go Normal file
View File

@@ -0,0 +1,248 @@
package dkim
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"errors"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestLookupError(t *testing.T) {
testErr := errors.New("lookup error")
errLookupF := func(ctx context.Context, name string) ([]string, error) {
return nil, testErr
}
ctx := WithLookupTXTFunc(context.Background(), errLookupF)
pks, err := findPublicKeys(ctx, "example.com", "selector")
if pks != nil || err != testErr {
t.Errorf("findPublicKeys expected nil / lookup error, got %v / %v",
pks, err)
}
}
// RSA key from the RFC example.
// https://datatracker.ietf.org/doc/html/rfc6376#appendix-C
const exampleRSAKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" +
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" +
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" +
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" +
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"
var exampleRSAKeyBuf, _ = base64.StdEncoding.DecodeString(exampleRSAKeyB64)
var exampleRSAKey, _ = x509.ParsePKCS1PublicKey(exampleRSAKeyBuf)
// Ed25519 key from the RFC example.
// https://datatracker.ietf.org/doc/html/rfc8463#appendix-A.2
const exampleEd25519KeyB64 = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
var exampleEd25519KeyBuf, _ = base64.StdEncoding.DecodeString(
exampleEd25519KeyB64)
var exampleEd25519Key = ed25519.PublicKey(exampleEd25519KeyBuf)
var results = map[string][]string{}
var resultErr = map[string]error{}
func testLookupTXT(ctx context.Context, name string) ([]string, error) {
return results[name], resultErr[name]
}
func TestSkipBadRecords(t *testing.T) {
ctx := WithLookupTXTFunc(context.Background(), testLookupTXT)
results["selector._domainkey.example.com"] = []string{
"not a tag",
"v=DKIM1; p=" + exampleRSAKeyB64,
}
defer clear(results)
pks, err := findPublicKeys(ctx, "example.com", "selector")
if err != nil {
t.Errorf("findPublicKeys expected nil, got %v", err)
}
if len(pks) != 1 {
t.Errorf("findPublicKeys expected 1 key, got %v", len(pks))
}
}
func TestParsePublicKey(t *testing.T) {
cases := []struct {
in string
pk *publicKey
err error
}{
// Invalid records.
{"not a tag", nil, errInvalidTag},
{"v=DKIM666;", nil, errInvalidVersion},
{"p=abc~*#def", nil, base64.CorruptInputError(3)},
{"k=blah; p=" + exampleRSAKeyB64, nil, errUnsupportedKeyType},
// Error parsing the keys.
{"p=", nil, errInvalidRSAPublicKey},
// RSA key but the contents are a (valid) ECDSA key.
{"p=" +
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIT0qsh+0jdY" +
"DhK5+rSedhT7W/5rTRiulhphqtuplGFAyNiSh9I5t6MsrIu" +
"xFQV7A/cWAt8qcbVscT3Q2l6iu3w==",
nil, errNotRSAPublicKey},
// Valid RSA key, that is too short.
{"p=" +
"MEgCQQCo9+BpMRYQ/dL3DS2CyJxRF+j6ctbT3/Qp84+KeFh" +
"nii7NT7fELilKUSnxS30WAvQCCo2yU1orfgqr41mM70MBAg" +
"MBAAE=", nil, errRSAKeyTooSmall},
// Invalid ed25519 key.
{"k=ed25519; p=MFkwEwYH", nil, errInvalidEd25519Key},
// Valid.
{"p=" + exampleRSAKeyB64,
&publicKey{K: keyTypeRSA, P: exampleRSAKeyBuf}, nil},
{"k=rsa ; p=" + exampleRSAKeyB64,
&publicKey{K: keyTypeRSA, P: exampleRSAKeyBuf}, nil},
{
"k=rsa; h=sha256; p=" + exampleRSAKeyB64,
&publicKey{
K: keyTypeRSA,
H: []crypto.Hash{crypto.SHA256},
P: exampleRSAKeyBuf},
nil,
},
{"t=s; p=" + exampleRSAKeyB64,
&publicKey{
K: keyTypeRSA,
P: exampleRSAKeyBuf,
T: []string{"s"},
},
nil,
},
{"t = s : y; p=" + exampleRSAKeyB64,
&publicKey{
K: keyTypeRSA,
P: exampleRSAKeyBuf,
T: []string{"s", "y"},
},
nil,
},
{
// We should ignore unrecognized hash algorithms.
"k=rsa; h=sha1:xxx123:sha256; p=" + exampleRSAKeyB64,
&publicKey{
K: keyTypeRSA,
H: []crypto.Hash{crypto.SHA256},
P: exampleRSAKeyBuf},
nil,
},
{"k=ed25519; p=" + exampleEd25519KeyB64,
&publicKey{K: keyTypeEd25519, P: exampleEd25519KeyBuf}, nil},
}
for i, c := range cases {
pk, err := parsePublicKey(c.in)
diff := cmp.Diff(c.pk, pk,
cmpopts.IgnoreUnexported(publicKey{}),
cmpopts.EquateEmpty(),
)
if diff != "" {
t.Errorf("%d: parsePublicKey(%q) key: (-want +got)\n%s",
i, c.in, diff)
}
if !errors.Is(err, c.err) {
t.Errorf("%d: parsePublicKey(%q) error: want %v, got %v",
i, c.in, c.err, err)
}
}
}
func TestPublicKeyMatches(t *testing.T) {
cases := []struct {
pk *publicKey
kt keyType
h crypto.Hash
ok bool
}{
{
&publicKey{K: keyTypeRSA},
keyTypeRSA, crypto.SHA256,
true,
},
{
&publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}},
keyTypeRSA, crypto.SHA1,
true,
},
{
&publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}},
keyTypeRSA, crypto.SHA256,
false,
},
{
&publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}},
keyTypeEd25519, crypto.SHA1,
false,
},
}
for i, c := range cases {
if ok := c.pk.Matches(c.kt, c.h); ok != c.ok {
t.Errorf("%d: matches(%v, %v) = %v, want %v",
i, c.kt, c.h, ok, c.ok)
}
}
}
func TestStrictDomainCheck(t *testing.T) {
cases := []struct {
t string
ok bool
}{
{"", false},
{"y", false},
{"x:y", false},
{":x::y", false},
{"s", true},
{"y:s", true},
{" y: s", true},
{"y:s:x", true},
}
for i, c := range cases {
pkS := "k=ed25519; p=" + exampleEd25519KeyB64 + "; t=" + c.t
pk, err := parsePublicKey(pkS)
if err != nil {
t.Fatalf("%d: parsePublicKey(%q) = %v", i, pkS, err)
}
if ok := pk.StrictDomainCheck(); ok != c.ok {
t.Errorf("%d: strictDomainCheck(t=%q) = %v, want %v",
i, c.t, ok, c.ok)
}
}
}
func FuzzParsePublicKey(f *testing.F) {
// Add some initial corpus from the tests above.
f.Add("not a tag")
f.Add("v=DKIM666;")
f.Add("p=abc~*#def")
f.Add("k=blah; p=" + exampleRSAKeyB64)
f.Add("p=")
f.Add("k=ed25519; p=")
f.Add("k=ed25519; p=MFkwEwYH")
f.Add("p=" + exampleEd25519KeyB64)
f.Add("k=rsa ; p=" + exampleRSAKeyB64)
f.Add("v=DKIM1; p=" + exampleRSAKeyB64)
f.Add("t=s; p=" + exampleRSAKeyB64)
f.Add("t = s : y; p=" + exampleRSAKeyB64)
f.Add("k=rsa; h=sha256; p=" + exampleRSAKeyB64)
f.Add("k=rsa; h=sha1:xxx123:sha256; p=" + exampleRSAKeyB64)
f.Fuzz(func(t *testing.T, in string) {
parsePublicKey(in)
})
}

235
internal/dkim/file_test.go Normal file
View File

@@ -0,0 +1,235 @@
package dkim
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestFromFiles(t *testing.T) {
msgfs, err := filepath.Glob("testdata/*.msg")
if err != nil {
t.Fatalf("error finding test files: %v", err)
}
for _, msgf := range msgfs {
base := strings.TrimSuffix(msgf, filepath.Ext(msgf))
t.Run(base, func(t *testing.T) { testOne(t, base) })
}
}
// This is the same as TestFromFiles, but it runs the private test files,
// which are not included in the git repository.
// This is useful for running tests on your own machine, with emails that you
// don't necessarily want to share publicly.
func TestFromPrivateFiles(t *testing.T) {
msgfs, err := filepath.Glob("testdata/private/*/*.msg")
if err != nil {
t.Fatalf("error finding private test files: %v", err)
}
for _, msgf := range msgfs {
base := strings.TrimSuffix(msgf, filepath.Ext(msgf))
t.Run(base, func(t *testing.T) { testOne(t, base) })
}
}
func testOne(t *testing.T, base string) {
ctx := context.Background()
ctx = WithTraceFunc(ctx, t.Logf)
ctx = loadDNS(t, ctx, base+".dns")
msg := toCRLF(mustReadFile(t, base+".msg"))
wantResult := loadResult(t, base+".result")
wantError := loadError(t, base+".error")
t.Logf("Message: %.60q", msg)
t.Logf("Want result: %+v", wantResult)
t.Logf("Want error: %v", wantError)
res, err := VerifyMessage(ctx, msg)
// Write the results out for easy updating.
writeResults(t, base, res, err)
diff := cmp.Diff(wantResult, res, cmp.Comparer(equalErrors))
if diff != "" {
t.Errorf("VerifyMessage result diff (-want +got):\n%s", diff)
}
// We need to compare them by hand because cmp.Diff won't use our comparer
// for top-level errors.
if !equalErrors(wantError, err) {
diff := cmp.Diff(wantError, err)
t.Errorf("VerifyMessage error diff (-want +got):\n%s", diff)
}
}
// Used to make cmp.Diff compare errors by their messages. This is obviously
// not great, but it's good enough for this test.
func equalErrors(a, b error) bool {
if a == nil {
return b == nil
}
if b == nil {
return false
}
return a.Error() == b.Error()
}
func mustReadFile(t *testing.T, path string) string {
t.Helper()
contents, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return ""
}
if err != nil {
t.Fatalf("error reading %q: %v", path, err)
}
return string(contents)
}
func loadDNS(t *testing.T, ctx context.Context, path string) context.Context {
t.Helper()
results := map[string][]string{}
errors := map[string]error{}
txtFunc := func(ctx context.Context, domain string) ([]string, error) {
return results[domain], errors[domain]
}
ctx = WithLookupTXTFunc(ctx, txtFunc)
c := mustReadFile(t, path)
// Unfold \-terminated lines.
c = strings.ReplaceAll(c, "\\\n", "")
for _, line := range strings.Split(c, "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue
}
domain, txt, ok := strings.Cut(line, ":")
if !ok {
continue
}
domain = strings.TrimSpace(domain)
switch strings.TrimSpace(txt) {
case "TEMPERROR":
errors[domain] = &net.DNSError{
Err: "temporary error (for testing)",
IsTemporary: true,
}
case "PERMERROR":
errors[domain] = &net.DNSError{
Err: "permanent error (for testing)",
IsTemporary: false,
}
case "NOTFOUND":
errors[domain] = &net.DNSError{
Err: "domain not found (for testing)",
IsNotFound: true,
}
default:
results[domain] = append(results[domain], txt)
}
}
t.Logf("Loaded DNS results: %#v", results)
t.Logf("Loaded DNS errors: %v", errors)
return ctx
}
func loadResult(t *testing.T, path string) *VerifyResult {
t.Helper()
res := &VerifyResult{}
c := mustReadFile(t, path)
if c == "" {
return nil
}
err := json.Unmarshal([]byte(c), res)
if err != nil {
t.Fatalf("error unmarshalling %q: %v", path, err)
}
return res
}
func loadError(t *testing.T, path string) error {
t.Helper()
c := strings.TrimSpace(mustReadFile(t, path))
if c == "" || c == "nil" || c == "<nil>" {
return nil
}
return errors.New(c)
}
func mustWriteFile(t *testing.T, path string, c []byte) {
t.Helper()
err := os.WriteFile(path, c, 0644)
if err != nil {
t.Fatalf("error writing %q: %v", path, err)
}
}
func writeResults(t *testing.T, base string, res *VerifyResult, err error) {
t.Helper()
mustWriteFile(t, base+".error.got", []byte(fmt.Sprintf("%v", err)))
c, err := json.MarshalIndent(res, "", "\t")
if err != nil {
t.Fatalf("error marshalling result: %v", err)
}
mustWriteFile(t, base+".result.got", c)
}
// Custom json marshaller so we can write errors as strings.
func (or *OneResult) MarshalJSON() ([]byte, error) {
// We use an alias to avoid infinite recursion.
type Alias OneResult
aux := &struct {
Error string `json:""`
*Alias
}{
Alias: (*Alias)(or),
}
if or.Error != nil {
aux.Error = or.Error.Error()
}
return json.Marshal(aux)
}
// Custom json unmarshaller so we can read errors as strings.
func (or *OneResult) UnmarshalJSON(b []byte) error {
// We use an alias to avoid infinite recursion.
type Alias OneResult
aux := &struct {
Error string `json:""`
*Alias
}{
Alias: (*Alias)(or),
}
if err := json.Unmarshal(b, aux); err != nil {
return err
}
if aux.Error != "" {
or.Error = errors.New(aux.Error)
}
return nil
}

335
internal/dkim/header.go Normal file
View File

@@ -0,0 +1,335 @@
package dkim
import (
"crypto"
"encoding/base64"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
// https://datatracker.ietf.org/doc/html/rfc6376#section-6
type dkimSignature struct {
// Version. Must be "1".
v string
// Algorithm. Like "rsa-sha256".
a string
// Key type, extracted from a=.
KeyType keyType
// Hash, extracted from a=.
Hash crypto.Hash
// Signature data.
// Decoded from base64, ignoring whitespace.
b []byte
// Hash of canonicalized body.
// Decoded from base64, ignoring whitespace.
bh []byte
// Canonicalization modes.
cH canonicalization
cB canonicalization
// Domain ("SDID"), in plain text.
// IDNs MUST be encoded as A-labels.
d string
// Signed header fields.
// Colon-separated list of header fields.
h []string
// AUID, in plain text.
i string
// Body octet count of the canonicalized body.
l uint64
// Query methods used for DNS lookup.
// Colon-separated list of methods. Only "dns/txt" is valid.
q []string
// Selector.
s string
// Timestamp. In Seconds since the UNIX epoch.
t time.Time
// Signature expiration. In Seconds since the UNIX epoch.
x time.Time
// Copied header fields.
// Has a specific encoding but whitespace is ignored.
z string
}
func (sig *dkimSignature) canonicalizationFromString(s string) error {
if s == "" {
sig.cH = simpleCanonicalization
sig.cB = simpleCanonicalization
return nil
}
// Either "header/body" or "header". In the latter case, "simple" is used
// for the body canonicalization.
// No whitespace around the '/' is allowed.
hs, bs, _ := strings.Cut(s, "/")
if bs == "" {
bs = "simple"
}
var err error
sig.cH, err = stringToCanonicalization(hs)
if err != nil {
return fmt.Errorf("header: %w", err)
}
sig.cB, err = stringToCanonicalization(bs)
if err != nil {
return fmt.Errorf("body: %w", err)
}
return nil
}
func (sig *dkimSignature) checkRequiredTags() error {
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
if sig.a == "" {
return fmt.Errorf("%w: a=", errMissingRequiredTag)
}
if len(sig.b) == 0 {
return fmt.Errorf("%w: b=", errMissingRequiredTag)
}
if len(sig.bh) == 0 {
return fmt.Errorf("%w: bh=", errMissingRequiredTag)
}
if sig.d == "" {
return fmt.Errorf("%w: d=", errMissingRequiredTag)
}
if len(sig.h) == 0 {
return fmt.Errorf("%w: h=", errMissingRequiredTag)
}
if sig.s == "" {
return fmt.Errorf("%w: s=", errMissingRequiredTag)
}
// h= must contain From.
var isFrom = func(s string) bool { return strings.EqualFold(s, "from") }
if !slices.ContainsFunc(sig.h, isFrom) {
return fmt.Errorf("%w: h= does not contain 'from'", errInvalidTag)
}
// If i= is present, its domain must be equal to, or a subdomain of, d=.
if sig.i != "" {
_, domain, _ := strings.Cut(sig.i, "@")
if domain != sig.d && !strings.HasSuffix(domain, "."+sig.d) {
return fmt.Errorf("%w: i= is not a subdomain of d=",
errInvalidTag)
}
}
return nil
}
var (
errInvalidSignature = errors.New("invalid signature")
errInvalidVersion = errors.New("invalid version")
errBadATag = errors.New("invalid a= tag")
errUnsupportedHash = errors.New("unsupported hash")
errUnsupportedKeyType = errors.New("unsupported key type")
errMissingRequiredTag = errors.New("missing required tag")
)
// String replacer that removes whitespace.
var eatWhitespace = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "")
func dkimSignatureFromHeader(header string) (*dkimSignature, error) {
tags, err := parseTags(header)
if err != nil {
return nil, err
}
sig := &dkimSignature{
v: tags["v"],
a: tags["a"],
}
// v= tag is mandatory and must be 1.
if sig.v != "1" {
return nil, errInvalidVersion
}
// a= tag is mandatory; check that we can parse it and that we support the
// algorithms.
ktS, hS, found := strings.Cut(sig.a, "-")
if !found {
return nil, errBadATag
}
sig.KeyType, err = keyTypeFromString(ktS)
if err != nil {
return nil, fmt.Errorf("%w: %s", err, sig.a)
}
sig.Hash, err = hashFromString(hS)
if err != nil {
return nil, fmt.Errorf("%w: %s", err, sig.a)
}
// b is base64-encoded, and whitespace in it must be ignored.
sig.b, err = base64.StdEncoding.DecodeString(
eatWhitespace.Replace(tags["b"]))
if err != nil {
return nil, fmt.Errorf("%w: failed to decode b: %w",
errInvalidSignature, err)
}
// bh - same as b.
sig.bh, err = base64.StdEncoding.DecodeString(
eatWhitespace.Replace(tags["bh"]))
if err != nil {
return nil, fmt.Errorf("%w: failed to decode bh: %w",
errInvalidSignature, err)
}
err = sig.canonicalizationFromString(tags["c"])
if err != nil {
return nil, fmt.Errorf("%w: failed to parse c: %w",
errInvalidSignature, err)
}
sig.d = tags["d"]
// h is a colon-separated list of header fields.
if tags["h"] != "" {
sig.h = strings.Split(eatWhitespace.Replace(tags["h"]), ":")
}
sig.i = tags["i"]
if tags["l"] != "" {
sig.l, err = strconv.ParseUint(tags["l"], 10, 64)
if err != nil {
return nil, fmt.Errorf("%w: failed to parse l: %w",
errInvalidSignature, err)
}
}
// q is a colon-separated list of query methods.
if tags["q"] != "" {
sig.q = strings.Split(eatWhitespace.Replace(tags["q"]), ":")
}
if len(sig.q) > 0 && !slices.Contains(sig.q, "dns/txt") {
return nil, fmt.Errorf("%w: no dns/txt query method in q",
errInvalidSignature)
}
sig.s = tags["s"]
if tags["t"] != "" {
sig.t, err = unixStrToTime(tags["t"])
if err != nil {
return nil, fmt.Errorf("%w: failed to parse t: %w",
errInvalidSignature, err)
}
}
if tags["x"] != "" {
sig.x, err = unixStrToTime(tags["x"])
if err != nil {
return nil, fmt.Errorf("%w: failed to parse x: %w",
errInvalidSignature, err)
}
}
sig.z = eatWhitespace.Replace(tags["z"])
// Check required tags are present.
if err := sig.checkRequiredTags(); err != nil {
return nil, err
}
return sig, nil
}
func unixStrToTime(s string) (time.Time, error) {
ti, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(int64(ti), 0), nil
}
type keyType string
const (
keyTypeRSA keyType = "rsa"
keyTypeEd25519 keyType = "ed25519"
)
func keyTypeFromString(s string) (keyType, error) {
switch s {
case "rsa":
return keyTypeRSA, nil
case "ed25519":
return keyTypeEd25519, nil
default:
return "", errUnsupportedKeyType
}
}
func hashFromString(s string) (crypto.Hash, error) {
switch s {
// Note SHA1 is not supported: as per RFC 8301, it must not be used
// for signing or verifying.
// https://datatracker.ietf.org/doc/html/rfc8301#section-3.1
case "sha256":
return crypto.SHA256, nil
default:
return 0, errUnsupportedHash
}
}
// DKIM Tag=Value lists, as defined in RFC 6376, Section 3.2.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.2
type tags map[string]string
var errInvalidTag = errors.New("invalid tag")
func parseTags(s string) (tags, error) {
// First trim space, and trailing semicolon, to simplify parsing below.
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, ";")
tags := make(tags)
for _, tv := range strings.Split(s, ";") {
t, v, found := strings.Cut(tv, "=")
if !found {
return nil, fmt.Errorf("%w: missing '='", errInvalidTag)
}
// Trim leading and trailing whitespace from tag and value, as per
// RFC.
t = strings.TrimSpace(t)
v = strings.TrimSpace(v)
if t == "" {
return nil, fmt.Errorf("%w: missing tag name", errInvalidTag)
}
// RFC 6376, Section 3.2: Tags with duplicate names MUST NOT occur
// within a single tag-list; if a tag name does occur more than once,
// the entire tag-list is invalid.
if _, exists := tags[t]; exists {
return nil, fmt.Errorf("%w: duplicate tag", errInvalidTag)
}
tags[t] = v
}
return tags, nil
}

View File

@@ -0,0 +1,433 @@
package dkim
import (
"crypto"
"encoding/base64"
"errors"
"fmt"
"strconv"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestSignatureFromHeader(t *testing.T) {
cases := []struct {
in string
want *dkimSignature
err error
}{
{
in: "v=1; a=rsa-sha256",
want: nil,
err: errMissingRequiredTag,
},
{
in: "v=1; a=rsa-sha256 ; c = simple/relaxed ;" +
" d=example.com; h= from : to: subject ; " +
"i=agent@example.com; l=77; q=dns/txt; " +
"s=selector; t=1600700888; x=1600700999; " +
"z=From:lala@lele | to:lili@lolo;" +
"b=aG9sY\r\n SBxdWUgdGFs;" +
"bh = Y29\ttby Bhbm Rhcw==",
want: &dkimSignature{
v: "1",
a: "rsa-sha256",
cH: simpleCanonicalization,
cB: relaxedCanonicalization,
d: "example.com",
h: []string{"from", "to", "subject"},
i: "agent@example.com",
l: 77,
q: []string{"dns/txt"},
s: "selector",
t: time.Unix(1600700888, 0),
x: time.Unix(1600700999, 0),
z: "From:lala@lele|to:lili@lolo",
b: []byte("hola que tal"),
bh: []byte("como andas"),
KeyType: keyTypeRSA,
Hash: crypto.SHA256,
},
},
{
// Example from RFC.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.5
in: "v=1; a=rsa-sha256; d=example.net; s=brisbane;\r\n" +
" c=simple; q=dns/txt; i=@eng.example.net;\r\n" +
" t=1117574938; x=1118006938;\r\n" +
" h=from:to:subject:date;\r\n" +
" z=From:foo@eng.example.net|To:joe@example.com|\r\n" +
" Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;\r\n" +
"bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;\r\n" +
"b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniS" +
"bav+yuU4zGeeruD00lszZVoG4ZHRNiYzR",
want: &dkimSignature{
v: "1",
a: "rsa-sha256",
d: "example.net",
s: "brisbane",
cH: simpleCanonicalization,
cB: simpleCanonicalization,
q: []string{"dns/txt"},
i: "@eng.example.net",
t: time.Unix(1117574938, 0),
x: time.Unix(1118006938, 0),
h: []string{"from", "to", "subject", "date"},
z: "From:foo@eng.example.net|To:joe@example.com|" +
"Subject:demo=20run|" +
"Date:July=205,=202005=203:44:08=20PM=20-0700",
bh: []byte("12345678901234567890123456789012"),
b: []byte("w7U\xc8\xe7\xc0('K]\xd2Ns\xd1\xb6" +
"\xab\xc2\xe8])D\x9e$\x9bj\xff\xb2\xb9N3" +
"\x19\xe7\xab\xb8=4\x96\xcc\xd9V\x81\xb8" +
"dtM\x89\x8c\xd1"),
KeyType: keyTypeRSA,
Hash: crypto.SHA256,
},
},
{
in: "",
want: nil,
err: errInvalidTag,
},
{
in: "v=666",
want: nil,
err: errInvalidVersion,
},
{
in: "v=1; a=something;",
want: nil,
err: errBadATag,
},
{
// Invalid b= tag.
in: "v=1; a=rsa-sha256; b=invalid",
want: nil,
err: base64.CorruptInputError(4),
},
{
// Invalid bh= tag.
in: "v=1; a=rsa-sha256; bh=invalid",
want: nil,
err: base64.CorruptInputError(4),
},
{
// Invalid c= tag.
in: "v=1; a=rsa-sha256; c=caca",
want: nil,
err: errUnknownCanonicalization,
},
{
// Invalid l= tag.
in: "v=1; a=rsa-sha256; l=a1234b",
want: nil,
err: strconv.ErrSyntax,
},
{
// q= tag without dns/txt.
in: "v=1; a=rsa-sha256; q=other/method",
want: nil,
err: errInvalidSignature,
},
{
// Invalid t= tag.
in: "v=1; a=rsa-sha256; t=a1234b",
want: nil,
err: strconv.ErrSyntax,
},
{
// Invalid x= tag.
in: "v=1; a=rsa-sha256; x=a1234b",
want: nil,
err: strconv.ErrSyntax,
},
{
// Unknown hash algorithm.
in: "v=1; a=rsa-sxa666",
want: nil,
err: errUnsupportedHash,
},
{
// Unknown key type.
in: "v=1; a=rxa-sha256",
want: nil,
err: errUnsupportedKeyType,
},
}
for _, c := range cases {
sig, err := dkimSignatureFromHeader(c.in)
diff := cmp.Diff(c.want, sig,
cmp.AllowUnexported(dkimSignature{}),
cmpopts.EquateEmpty(),
)
if diff != "" {
t.Errorf("dkimSignatureFromHeader(%q) mismatch (-want +got):\n%s",
c.in, diff)
}
if !errors.Is(err, c.err) {
t.Errorf("dkimSignatureFromHeader(%q) error: got %v, want %v",
c.in, err, c.err)
}
}
}
func TestCanonicalizationFromString(t *testing.T) {
cases := []struct {
in string
cH, cB canonicalization
err error
}{
{
in: "",
cH: simpleCanonicalization,
cB: simpleCanonicalization,
},
{
in: "simple",
cH: simpleCanonicalization,
cB: simpleCanonicalization,
},
{
in: "relaxed",
cH: relaxedCanonicalization,
cB: simpleCanonicalization,
},
{
in: "simple/simple",
cH: simpleCanonicalization,
cB: simpleCanonicalization,
},
{
in: "relaxed/relaxed",
cH: relaxedCanonicalization,
cB: relaxedCanonicalization,
},
{
in: "simple/relaxed",
cH: simpleCanonicalization,
cB: relaxedCanonicalization,
},
{
in: "relaxed/bad",
cH: relaxedCanonicalization,
err: errUnknownCanonicalization,
},
{
in: "bad/relaxed",
err: errUnknownCanonicalization,
},
{
in: "bad",
err: errUnknownCanonicalization,
},
}
for _, c := range cases {
sig := &dkimSignature{}
err := sig.canonicalizationFromString(c.in)
if sig.cH != c.cH || sig.cB != c.cB || !errors.Is(err, c.err) {
t.Errorf("canonicalizationFromString(%q) "+
"got (%v, %v, %v), want (%v, %v, %v)",
c.in, sig.cH, sig.cB, err, c.cH, c.cB, c.err)
}
}
}
func TestCheckRequiredTags(t *testing.T) {
cases := []struct {
sig *dkimSignature
err string
}{
{
sig: &dkimSignature{},
err: "missing required tag: a=",
},
{
sig: &dkimSignature{a: "rsa-sha256"},
err: "missing required tag: b=",
},
{
sig: &dkimSignature{a: "rsa-sha256", b: []byte("hola que tal")},
err: "missing required tag: bh=",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
},
err: "missing required tag: d=",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
},
err: "missing required tag: h=",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"from"},
},
err: "missing required tag: s=",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"subject"},
s: "selector",
},
err: "invalid tag: h= does not contain 'from'",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"from"},
s: "selector",
i: "@example.net",
},
err: "invalid tag: i= is not a subdomain of d=",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"from"},
s: "selector",
i: "@anexample.com", // i= is a substring but not subdomain.
},
err: "invalid tag: i= is not a subdomain of d=",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"From"}, // Capitalize to check case fold.
s: "selector",
i: "@example.com", // i= is the same as d=
},
err: "<nil>",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"From"},
s: "selector",
i: "@sub.example.com", // i= is a subdomain of d=
},
err: "<nil>",
},
{
sig: &dkimSignature{
a: "rsa-sha256",
b: []byte("hola que tal"),
bh: []byte("como andas"),
d: "example.com",
h: []string{"from"},
s: "selector",
},
err: "<nil>",
},
}
for i, c := range cases {
err := c.sig.checkRequiredTags()
got := fmt.Sprintf("%v", err)
if c.err != got {
t.Errorf("%d: checkRequiredTags() got %v, want %v",
i, err, c.err)
}
}
}
func TestParseTags(t *testing.T) {
cases := []struct {
in string
want tags
err error
}{
{
in: "v=1; a=lalala; b = 123 ; c= 456;\t d \t= \t789\t ",
want: tags{
"v": "1",
"a": "lalala",
"b": "123",
"c": "456",
"d": "789",
},
err: nil,
},
{
// Trailing semicolon.
in: "v=1; a=lalala ; ",
want: tags{
"v": "1",
"a": "lalala",
},
err: nil,
},
{
// Missing tag value; this is okay.
in: "v=1; b = ; c = d;",
want: tags{
"v": "1",
"b": "",
"c": "d",
},
err: nil,
},
{
// Missing '='.
in: "v=1; ; c = d;",
want: nil,
err: errInvalidTag,
},
{
// Missing tag name.
in: "v=1; = b ; c = d;",
want: nil,
err: errInvalidTag,
},
{
// Duplicate tag.
in: "v=1; a=b; a=c;",
want: nil,
err: errInvalidTag,
},
}
for _, c := range cases {
got, err := parseTags(c.in)
if diff := cmp.Diff(c.want, got); diff != "" {
t.Errorf("parseTags(%q) mismatch (-want +got):\n%s", c.in, diff)
}
if !errors.Is(err, c.err) {
t.Errorf("parseTags(%q) error: got %v, want %v", c.in, err, c.err)
}
}
}

77
internal/dkim/message.go Normal file
View File

@@ -0,0 +1,77 @@
package dkim
import (
"errors"
"fmt"
"strings"
)
type header struct {
Name string
Value string
Source string
}
type headers []header
// FindAll the headers with the given name, in order of appearance.
func (h headers) FindAll(name string) headers {
hs := make(headers, 0)
for _, header := range h {
if strings.EqualFold(header.Name, name) {
hs = append(hs, header)
}
}
return hs
}
var errInvalidHeader = errors.New("invalid header")
// Parse a RFC822 message, return the headers, body, and error if any.
// We expect it to only contain CRLF line endings.
// Does NOT touch whitespace, this is important to preserve the original
// message and headers, which is required for the signature.
func parseMessage(message string) (headers, string, error) {
headers := make(headers, 0)
lines := strings.Split(message, "\r\n")
eoh := 0
for i, line := range lines {
if line == "" {
eoh = i
break
}
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
// Continuation of the previous header.
if len(headers) == 0 {
return nil, "", fmt.Errorf(
"%w: bad continuation", errInvalidHeader)
}
headers[len(headers)-1].Value += "\r\n" + line
headers[len(headers)-1].Source += "\r\n" + line
} else {
// New header.
h, err := parseHeader(line)
if err != nil {
return nil, "", err
}
headers = append(headers, h)
}
}
return headers, strings.Join(lines[eoh+1:], "\r\n"), nil
}
func parseHeader(line string) (header, error) {
name, value, found := strings.Cut(line, ":")
if !found {
return header{}, fmt.Errorf("%w: no colon", errInvalidHeader)
}
return header{
Name: name,
Value: value,
Source: line,
}, nil
}

View File

@@ -0,0 +1,99 @@
package dkim
import (
"testing"
"blitiri.com.ar/go/chasquid/internal/normalize"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func TestParseMessage(t *testing.T) {
cases := []struct {
message string
headers headers
body string
}{
{
message: normalize.StringToCRLF(`From: a@b
To: c@d
Subject: test
Continues: This
continues.
body`),
headers: headers{
header{Name: "From", Value: " a@b",
Source: "From: a@b"},
header{Name: "To", Value: " c@d",
Source: "To: c@d"},
header{Name: "Subject", Value: " test",
Source: "Subject: test"},
header{Name: "Continues", Value: " This\r\n continues.",
Source: "Continues: This\r\n continues."},
},
body: "body",
},
}
for i, c := range cases {
headers, body, err := parseMessage(c.message)
if diff := cmp.Diff(c.headers, headers); diff != "" {
t.Errorf("parseMessage([%d]) headers mismatch (-want +got):\n%s",
i, diff)
}
if diff := cmp.Diff(c.body, body); diff != "" {
t.Errorf("parseMessage([%d]) body mismatch (-want +got):\n%s",
i, diff)
}
if err != nil {
t.Errorf("parseMessage([%d]) error: %v", i, err)
}
}
}
func TestParseMessageWithErrors(t *testing.T) {
cases := []struct {
message string
err error
}{
{
// Continuation without previous header.
message: " continuation.",
err: errInvalidHeader,
},
{
// Header without ':'.
message: "No colon",
err: errInvalidHeader,
},
}
for i, c := range cases {
_, _, err := parseMessage(c.message)
if diff := cmp.Diff(c.err, err, cmpopts.EquateErrors()); diff != "" {
t.Errorf("parseMessage([%d]) err mismatch (-want +got):\n%s",
i, diff)
}
}
}
func TestHeadersFindAll(t *testing.T) {
hs := headers{
{Name: "From", Value: "a@b", Source: "From: a@b"},
{Name: "To", Value: "c@d", Source: "To: c@d"},
{Name: "Subject", Value: "test", Source: "Subject: test"},
{Name: "fROm", Value: "z@y", Source: "fROm: z@y"},
}
fromHs := hs.FindAll("froM")
expected := headers{
{Name: "From", Value: "a@b", Source: "From: a@b"},
{Name: "fROm", Value: "z@y", Source: "fROm: z@y"},
}
if diff := cmp.Diff(expected, fromHs); diff != "" {
t.Errorf("headers.Find() mismatch (-want +got):\n%s", diff)
}
}

198
internal/dkim/sign.go Normal file
View File

@@ -0,0 +1,198 @@
package dkim
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"time"
)
type Signer struct {
// Domain to sign for.
Domain string
// Selector to use.
Selector string
// Signer containing the private key.
// This can be an *rsa.PrivateKey or a ed25519.PrivateKey.
Signer crypto.Signer
}
var headersToSign = []string{
// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.1
"From", // Required.
"Reply-To",
"Subject",
"Date",
"To", "Cc",
"Resent-Date", "Resent-From", "Resent-To", "Resent-Cc",
"In-Reply-To", "References",
"List-Id", "List-Help", "List-Unsubscribe", "List-Subscribe", "List-Post",
"List-Owner", "List-Archive",
// Our additions.
"Message-ID",
}
var extraHeadersToSign = []string{
// Headers to add an extra of, to prevent additions after signing.
// If they're included here, they must be in headersToSign too.
"From",
"Subject", "Date",
"To", "Cc",
"Message-ID",
}
// Sign the given message. Returns the *value* of the DKIM-Signature header to
// be added to the message. It will usually be multi-line, but without
// indenting.
func (s *Signer) Sign(ctx context.Context, message string) (string, error) {
headers, body, err := parseMessage(message)
if err != nil {
return "", err
}
algoStr, err := s.algoStr()
if err != nil {
return "", err
}
trace(ctx, "Signing for %s / %s with %s", s.Domain, s.Selector, algoStr)
dkimSignature := fmt.Sprintf(
"v=1; a=%s; c=relaxed/relaxed;\r\n", algoStr)
dkimSignature += fmt.Sprintf(
"d=%s; s=%s; t=%d;\r\n", s.Domain, s.Selector, time.Now().Unix())
// Add the headers to sign.
hsForHeader := []string{}
for _, h := range headersToSign {
// Append the header as many times as it appears in the message.
for i := 0; i < len(headers.FindAll(h)); i++ {
hsForHeader = append(hsForHeader, h)
}
}
hsForHeader = append(hsForHeader, extraHeadersToSign...)
dkimSignature += fmt.Sprintf(
"h=%s;\r\n", formatHeaders(hsForHeader))
// Compute and add bh= (body hash).
bodyH := sha256.Sum256([]byte(
relaxedCanonicalization.body(body)))
dkimSignature += fmt.Sprintf(
"bh=%s;\r\n", base64.StdEncoding.EncodeToString(bodyH[:]))
// Compute b= (signature).
// First, the canonicalized headers.
b := sha256.New()
for _, h := range headersToSign {
for _, header := range headers.FindAll(h) {
hsrc := relaxedCanonicalization.header(header).Source + "\r\n"
trace(ctx, "Hashing header: %q", hsrc)
b.Write([]byte(hsrc))
}
}
// Now, the (canonicalized) DKIM-Signature header itself, but with an
// empty b= tag, without a trailing \r\n, and ending with ";".
// We include the ";" because we will add it at the end (see below). It is
// legal not to include that final ";", we just choose to do so.
// We replace \r\n with \r\n\t so the canonicalization considers them
// proper continuations, and works correctly.
dkimSignature += "b="
dkimSignatureForSigning := strings.ReplaceAll(
dkimSignature, "\r\n", "\r\n\t") + ";"
relaxedDH := relaxedCanonicalization.header(header{
Name: "DKIM-Signature",
Value: dkimSignatureForSigning,
Source: dkimSignatureForSigning,
})
b.Write([]byte(relaxedDH.Source))
trace(ctx, "Hashing header: %q", relaxedDH.Source)
bSum := b.Sum(nil)
trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum))
// Finally, sign the hash.
sig, err := s.sign(bSum)
if err != nil {
return "", err
}
sigb64 := base64.StdEncoding.EncodeToString(sig)
dkimSignature += breakLongLines(sigb64) + ";"
return dkimSignature, nil
}
func (s *Signer) algoStr() (string, error) {
switch k := s.Signer.(type) {
case *rsa.PrivateKey:
return "rsa-sha256", nil
case ed25519.PrivateKey:
return "ed25519-sha256", nil
default:
return "", fmt.Errorf("%w: %T", errUnsupportedKeyType, k)
}
}
func (s *Signer) sign(bSum []byte) ([]byte, error) {
var h crypto.Hash
switch s.Signer.(type) {
case *rsa.PrivateKey:
h = crypto.SHA256
case ed25519.PrivateKey:
h = crypto.Hash(0)
}
return s.Signer.Sign(rand.Reader, bSum, h)
}
func breakLongLines(s string) string {
// Break long lines, indenting with 2 spaces for continuation (to make
// it clear it's under the same tag).
const limit = 70
var sb strings.Builder
for len(s) > 0 {
if len(s) > limit {
sb.WriteString(s[:limit])
sb.WriteString("\r\n ")
s = s[limit:]
} else {
sb.WriteString(s)
s = ""
}
}
return sb.String()
}
func formatHeaders(hs []string) string {
// Format the list of headers for inclusion in the DKIM-Signature header.
// This includes converting them to lowercase, and line-wrapping.
// Extra lines will be indented with 2 spaces, to make it clear they're
// under the same tag.
const limit = 70
var sb strings.Builder
line := ""
for i, h := range hs {
if len(line)+1+len(h) > limit {
sb.WriteString(line + "\r\n ")
line = ""
}
if i > 0 {
line += ":"
}
line += h
}
sb.WriteString(line)
return strings.TrimSpace(strings.ToLower(sb.String()))
}

257
internal/dkim/sign_test.go Normal file
View File

@@ -0,0 +1,257 @@
package dkim
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"errors"
"regexp"
"strings"
"testing"
)
var basicMessage = toCRLF(
`Received: from client1.football.example.com [192.0.2.1]
by submitserver.example.com with SUBMISSION;
Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.
`)
func TestSignRSA(t *testing.T) {
ctx := context.Background()
ctx = WithTraceFunc(ctx, t.Logf)
// Generate a new key pair.
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
pub, err := x509.MarshalPKIXPublicKey(priv.Public())
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"test._domainkey.example.com": []string{
"v=DKIM1; p=" + base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
sig, err := s.Sign(ctx, basicMessage)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Verify the signature.
res, err := VerifyMessage(ctx, addSig(sig, basicMessage))
if err != nil || res.Valid != 1 {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
// Compare the reproducible parts against a known-good header.
want := regexp.MustCompile(
"v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n" +
"d=example.com; s=test; t=\\d+;\r\n" +
"h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n" +
"bh=[A-Za-z0-9+/]+=*;\r\n" +
"b=[A-Za-z0-9+/ \r\n]+=*;")
if !want.MatchString(sig) {
t.Errorf("Unexpected signature:")
t.Errorf(" Want: %q (regexp)", want)
t.Errorf(" Got: %q", sig)
}
}
func TestSignEd25519(t *testing.T) {
ctx := context.Background()
ctx = WithTraceFunc(ctx, t.Logf)
// Generate a new key pair.
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519.GenerateKey: %v", err)
}
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"test._domainkey.example.com": []string{
"v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(pub),
},
}))
s := &Signer{
Domain: "example.com",
Selector: "test",
Signer: priv,
}
sig, err := s.Sign(ctx, basicMessage)
if err != nil {
t.Fatalf("Sign: %v", err)
}
// Verify the signature.
res, err := VerifyMessage(ctx, addSig(sig, basicMessage))
if err != nil || res.Valid != 1 {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
// Compare the reproducible parts against a known-good header.
want := regexp.MustCompile(
"v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n" +
"d=example.com; s=test; t=\\d+;\r\n" +
"h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n" +
"bh=[A-Za-z0-9+/]+=*;\r\n" +
"b=[A-Za-z0-9+/ \r\n]+=*;")
if !want.MatchString(sig) {
t.Errorf("Unexpected signature:")
t.Errorf(" Want: %q (regexp)", want)
t.Errorf(" Got: %q", sig)
}
}
func addSig(sig, message string) string {
return "DKIM-Signature: " +
strings.ReplaceAll(sig, "\r\n", "\r\n\t") +
"\r\n" + message
}
func TestSignBadMessage(t *testing.T) {
s := &Signer{
Domain: "example.com",
Selector: "test",
}
_, err := s.Sign(context.Background(), "Bad message")
if err == nil {
t.Errorf("Sign: wanted error; got nil")
}
}
func TestSignBadAlgorithm(t *testing.T) {
s := &Signer{
Domain: "example.com",
Selector: "test",
}
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
s.Signer = priv
_, err = s.Sign(context.Background(), basicMessage)
if !errors.Is(err, errUnsupportedKeyType) {
t.Errorf("Sign: wanted unsupported key type; got %v", err)
}
}
func TestBreakLongLines(t *testing.T) {
cases := []struct {
in string
want string
}{
{"1234567890", "1234567890"},
{
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70",
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70",
},
{
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70123",
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70\r\n 123",
},
{
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70xxxxxxxx80" +
"xxxxxxxx90xxxxxxx100xxxxxxx110xxxxxxx120" +
"xxxxxxx130xxxxxxx140xxxxxxx150xxxxxxx160",
"xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" +
"xxxxxxxx50xxxxxxxx60xxxxxxxx70\r\n " +
"xxxxxxxx80xxxxxxxx90xxxxxxx100xxxxxxx110" +
"xxxxxxx120xxxxxxx130xxxxxxx140\r\n " +
"xxxxxxx150xxxxxxx160",
},
}
for i, c := range cases {
got := breakLongLines(c.in)
if got != c.want {
t.Errorf("%d: breakLongLines(%q):", i, c.in)
t.Errorf(" want %q", c.want)
t.Errorf(" got %q", got)
}
}
}
func TestFormatHeaders(t *testing.T) {
cases := []struct {
in []string
want string
}{
{[]string{"From"}, "from"},
{
[]string{"From", "Subject", "Date"},
"from:subject:date",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "in-reply-to",
"message-id"},
"from:subject:date:to:message-id:" +
"from:subject:date:to:cc:in-reply-to\r\n" +
" :message-id",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "xxxxxxxxxxxx70"},
"from:subject:date:to:message-id:" +
"from:subject:date:to:cc:xxxxxxxxxxxx70",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "xxxxxxxxxxxx701"},
"from:subject:date:to:message-id:from:subject:date:to:cc\r\n" +
" :xxxxxxxxxxxx701",
},
{
[]string{"from", "subject", "date", "to", "message-id",
"from", "subject", "date", "to", "cc", "xxxxxxxxxxxx70",
"1"},
"from:subject:date:to:message-id:" +
"from:subject:date:to:cc:xxxxxxxxxxxx70\r\n" +
" :1",
},
}
for i, c := range cases {
got := formatHeaders(c.in)
if got != c.want {
t.Errorf("%d: formatHeaders(%q):", i, c.in)
t.Errorf(" want %q", c.want)
t.Errorf(" got %q", got)
}
}
}

4
internal/dkim/testdata/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.got
# Ignore private test cases, to reduce the chances of accidental leaks.
private/

8
internal/dkim/testdata/01-rfc8463.dns vendored Normal file
View File

@@ -0,0 +1,8 @@
brisbane._domainkey.football.example.com: \
v=DKIM1; k=ed25519; \
p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
test._domainkey.football.example.com: \
v=DKIM1; k=rsa; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB

View File

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

27
internal/dkim/testdata/01-rfc8463.msg vendored Normal file
View File

@@ -0,0 +1,27 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,22 @@
{
"Found": 2,
"Valid": 2,
"Results": [
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"Domain": "football.example.com",
"Selector": "test",
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"State": "SUCCESS"
}
]
}

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,5 @@
Check that we don't process more than 5 headers.
The message contains 7 headers, but only the first 5 should be validated (and
appear as valid).

View File

@@ -0,0 +1,46 @@
{
"Found": 5,
"Valid": 5,
"Results": [
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
}
]
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
DKIM-Signature: v=8; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,4 @@
Check that we reject invalid DKIM signature headers.
In this case, we force this by taking an otherwise valid header, but using v=8
instead of v=1.

View File

@@ -0,0 +1,14 @@
{
"Found": 1,
"Valid": 0,
"Results": [
{
"Error": "invalid version",
"SignatureHeader": " v=8; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "",
"Selector": "",
"B": "",
"State": "PERMFAIL"
}
]
}

View File

@@ -0,0 +1,6 @@
brisbane._domainkey.football.example.com: TEMPERROR
test._domainkey.football.example.com: \
v=DKIM1; k=rsa; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB

View File

@@ -0,0 +1,27 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,22 @@
{
"Found": 2,
"Valid": 1,
"Results": [
{
"Error": "lookup : temporary error (for testing)",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "TEMPFAIL"
},
{
"Error": "",
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"Domain": "football.example.com",
"Selector": "test",
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"State": "SUCCESS"
}
]
}

View File

@@ -0,0 +1,6 @@
brisbane._domainkey.football.example.com: PERMERROR
test._domainkey.football.example.com: \
v=DKIM1; k=rsa; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB

View File

@@ -0,0 +1,27 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,22 @@
{
"Found": 2,
"Valid": 1,
"Results": [
{
"Error": "lookup : permanent error (for testing)",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "PERMFAIL"
},
{
"Error": "",
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"Domain": "football.example.com",
"Selector": "test",
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"State": "SUCCESS"
}
]
}

View File

@@ -0,0 +1,12 @@
brisbane._domainkey.football.example.com: \
v=DKIM1; k=rsa; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB
brisbane._domainkey.football.example.com: \
v=DKIM1; k=ed25519; \
p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
test._domainkey.football.example.com: \
v=DKIM1; k=rsa; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB

View File

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

View File

@@ -0,0 +1,4 @@
In this test, one of the selectors has two valid TXT records with different
key types.
Only one of them is valid.

View File

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

View File

@@ -0,0 +1,11 @@
selector._domainkey.example.com: \
v=DKIM1; k=ed25519; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=
brisbane._domainkey.football.example.com: \
v=DKIM1; k=ed25519; \
p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
test._domainkey.football.example.com: \
v=DKIM1; k=rsa; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB

View File

@@ -0,0 +1,32 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=example.com; s=selector; t=1709341950;
h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==;
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,30 @@
{
"Found": 3,
"Valid": 3,
"Results": [
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709341950;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==;",
"Domain": "example.com",
"Selector": "selector",
"B": "Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"Domain": "football.example.com",
"Selector": "test",
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"State": "SUCCESS"
}
]
}

View File

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

View File

@@ -0,0 +1,32 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=example.com; s=selector; t=1709368031;
h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;
l=17; bh=2Lb+x7ZAi8ljletRVg9Cn+VSkE36HadUTTOwsYyzZJg=;
b=2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==;
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,3 @@
This test a DKIM signature that uses an l= tag.
It was constructed using an ad-hoc modified version of the signer.

View File

@@ -0,0 +1,30 @@
{
"Found": 3,
"Valid": 3,
"Results": [
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709368031;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;\r\n l=17; bh=2Lb+x7ZAi8ljletRVg9Cn+VSkE36HadUTTOwsYyzZJg=;\r\n b=2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==;",
"Domain": "example.com",
"Selector": "selector",
"B": "2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"Domain": "football.example.com",
"Selector": "test",
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"State": "SUCCESS"
}
]
}

View File

@@ -0,0 +1,8 @@
brisbane._domainkey.football.example.com: \
v=DKIM1; k=ed25519; t=s; \
p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=
test._domainkey.football.example.com: \
v=DKIM1; k=rsa; t=s; \
p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB

View File

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

View File

@@ -0,0 +1,22 @@
{
"Found": 2,
"Valid": 2,
"Results": [
{
"Error": "",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"Domain": "football.example.com",
"Selector": "brisbane",
"B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
"State": "SUCCESS"
},
{
"Error": "",
"SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"Domain": "football.example.com",
"Selector": "test",
"B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=",
"State": "SUCCESS"
}
]
}

View File

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

View File

@@ -0,0 +1,19 @@
DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=example.com; s=selector; t=1709466347;
i=test@sub.example.com;
h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSq
b/xGMFTFmpSbNeCg==;
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.

View File

@@ -0,0 +1,6 @@
Strict domain check is enabled, but fails.
This test has a DNS key with t=s, but the DKIM signature's i= is different
than d= (but it is a subdomain, which is enforced at parsing time as per RFC).
It was constructed using an ad-hoc modified version of the signer.

View File

@@ -0,0 +1,14 @@
{
"Found": 1,
"Valid": 0,
"Results": [
{
"Error": "verification failed",
"SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709466347;\r\n i=test@sub.example.com;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSq\r\n b/xGMFTFmpSbNeCg==;",
"Domain": "example.com",
"Selector": "selector",
"B": "NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSqb/xGMFTFmpSbNeCg==",
"State": "PERMFAIL"
}
]
}

310
internal/dkim/verify.go Normal file
View File

@@ -0,0 +1,310 @@
package dkim
import (
"bytes"
"context"
"crypto"
"encoding/base64"
"errors"
"fmt"
"net"
"regexp"
"slices"
"strings"
)
// These two errors are returned when the verification fails, but the header
// is considered valid.
var (
ErrBodyHashMismatch = errors.New("body hash mismatch")
ErrVerificationFailed = errors.New("verification failed")
)
// Evaluation states, as per
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.9.
type EvaluationState string
const (
SUCCESS EvaluationState = "SUCCESS"
PERMFAIL EvaluationState = "PERMFAIL"
TEMPFAIL EvaluationState = "TEMPFAIL"
)
type VerifyResult struct {
// How many signatures were found.
Found uint
// How many signatures were verified successfully.
Valid uint
// The details for each signature that was found.
Results []*OneResult
}
type OneResult struct {
// The raw signature header.
SignatureHeader string
// Domain and selector from the signature header.
Domain string
Selector string
// Base64-encoded signature. May be missing if it is not present in the
// header.
B string
// The result of the evaluation.
State EvaluationState
Error error
}
// Returns the DKIM-specific contents for an Authentication-Results header.
// It is just the contents, the header needs to still be constructed.
// Note that the output will need to be indented by the caller.
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
func (r *VerifyResult) AuthenticationResults() string {
// The weird placement of the ";" is due to the specification saying they
// have to be before each method, not at the end.
// By doing it this way, we can concate the output of this function with
// other results.
ar := &strings.Builder{}
if r.Found == 0 {
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
ar.WriteString(";dkim=none\r\n")
return ar.String()
}
for _, res := range r.Results {
// Map state to the corresponding result.
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
switch res.State {
case SUCCESS:
ar.WriteString(";dkim=pass")
case TEMPFAIL:
// The reason must come before the properties, include it here.
fmt.Fprintf(ar, ";dkim=temperror reason=%q\r\n", res.Error)
case PERMFAIL:
// The reason must come before the properties, include it here.
if errors.Is(res.Error, ErrVerificationFailed) ||
errors.Is(res.Error, ErrBodyHashMismatch) {
fmt.Fprintf(ar, ";dkim=fail reason=%q\r\n", res.Error)
} else {
fmt.Fprintf(ar, ";dkim=permerror reason=%q\r\n", res.Error)
}
}
if res.B != "" {
// Include a partial b= tag to help identify which signature
// is being referred to.
// https://datatracker.ietf.org/doc/html/rfc6008#section-4
fmt.Fprintf(ar, " header.b=%.12s", res.B)
}
ar.WriteString(" header.d=" + res.Domain + "\r\n")
}
return ar.String()
}
func VerifyMessage(ctx context.Context, message string) (*VerifyResult, error) {
// https://datatracker.ietf.org/doc/html/rfc6376#section-6
headers, body, err := parseMessage(message)
if err != nil {
trace(ctx, "Error parsing message: %v", err)
return nil, err
}
results := &VerifyResult{
Results: []*OneResult{},
}
for i, sig := range headers.FindAll("DKIM-Signature") {
trace(ctx, "Found DKIM-Signature header: %s", sig.Value)
if i >= maxHeaders(ctx) {
// Protect from potential DoS by capping the number of signatures.
// https://datatracker.ietf.org/doc/html/rfc6376#section-4.2
// https://datatracker.ietf.org/doc/html/rfc6376#section-8.4
trace(ctx, "Too many DKIM-Signature headers found")
break
}
results.Found++
res := verifySignature(ctx, sig, headers, body)
results.Results = append(results.Results, res)
if res.State == SUCCESS {
results.Valid++
}
}
trace(ctx, "Found %d signatures, %d valid", results.Found, results.Valid)
return results, nil
}
// Regular expression that matches the "b=" tag.
// First capture group is the "b=" part (including any whitespace up to the
// '=').
var bTag = regexp.MustCompile(`(b[ \t\r\n]*=)[^;]+`)
func verifySignature(ctx context.Context, sigH header,
headers headers, body string) *OneResult {
result := &OneResult{
SignatureHeader: sigH.Value,
}
sig, err := dkimSignatureFromHeader(sigH.Value)
if err != nil {
// Header validation errors are a PERMFAIL.
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
result.Error = err
result.State = PERMFAIL
return result
}
result.Domain = sig.d
result.Selector = sig.s
result.B = base64.StdEncoding.EncodeToString(sig.b)
// Get the public key.
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
pubKeys, err := findPublicKeys(ctx, sig.d, sig.s)
if err != nil {
result.Error = err
// DNS errors when looking up the public key are a TEMPFAIL; all
// others are PERMFAIL.
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.Temporary() {
result.State = TEMPFAIL
} else {
result.State = PERMFAIL
}
return result
}
// Compute the verification.
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3
// Step 1: Prepare a canonicalized version of the body, truncate it to l=
// (if present).
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
bodyC := sig.cB.body(body)
if sig.l > 0 {
bodyC = bodyC[:sig.l]
}
// Step 2: Compute the hash of the canonicalized body.
bodyH := hashWith(sig.Hash, []byte(bodyC))
// Step 3: Verify the hash of the body by comparing it with bh=.
if !bytes.Equal(bodyH, sig.bh) {
bodyHStr := base64.StdEncoding.EncodeToString(bodyH)
trace(ctx, "Body hash mismatch: %q", bodyHStr)
result.Error = fmt.Errorf("%w (got %s)",
ErrBodyHashMismatch, bodyHStr)
result.State = PERMFAIL
return result
}
trace(ctx, "Body hash matches: %q",
base64.StdEncoding.EncodeToString(bodyH))
// Step 4 A: Hash the (canonicalized) headers that appear in the h= tag.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
b := sig.Hash.New()
for _, header := range headersToInclude(sigH, sig.h, headers) {
hsrc := sig.cH.header(header).Source + "\r\n"
trace(ctx, "Hashing header: %q", hsrc)
b.Write([]byte(hsrc))
}
// Step 4 B: Hash the (canonicalized) DKIM-Signature header itself, but
// with an empty b= tag, and without a trailing \r\n.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
sigC := sig.cH.header(sigH)
sigCStr := bTag.ReplaceAllString(sigC.Source, "$1")
trace(ctx, "Hashing header: %q", sigCStr)
b.Write([]byte(sigCStr))
bSum := b.Sum(nil)
trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum))
// Step 4 C: Validate the signature.
for _, pubKey := range pubKeys {
if !pubKey.Matches(sig.KeyType, sig.Hash) {
trace(ctx, "PK %v: key type or hash mismatch, skipping", pubKey)
continue
}
if sig.i != "" && pubKey.StrictDomainCheck() {
_, domain, _ := strings.Cut(sig.i, "@")
if domain != sig.d {
trace(ctx, "PK %v: Strict domain check failed: %q != %q (%q)",
pubKey, sig.d, domain, sig.i)
continue
}
trace(ctx, "PK %v: Strict domain check passed", pubKey)
}
err := pubKey.verify(sig.Hash, bSum, sig.b)
if err != nil {
trace(ctx, "PK %v: Verification failed: %v", pubKey, err)
continue
}
trace(ctx, "PK %v: Verification succeeded", pubKey)
result.State = SUCCESS
return result
}
result.State = PERMFAIL
result.Error = ErrVerificationFailed
return result
}
func headersToInclude(sigH header, hTag []string, headers headers) []header {
// Return the actual headers to include in the hash, based on the list
// given in the h= tag.
// This is complicated because:
// - Headers can be included multiple times. In that case, we must pick
// the last instance (which hasn't been already included).
// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.2
// - Headers may appear fewer times than they are requested.
// - DKIM-Signature header may be included, but we must not include the
// one being verified.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
// - Headers may be missing, and that's allowed.
// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4
seen := map[string]int{}
include := []header{}
for _, h := range hTag {
all := headers.FindAll(h)
slices.Reverse(all)
// We keep track of the last instance of each header that we
// included, and find the next one every time it appears in h=.
// We have to be careful because the header itself may not be present,
// or we may be asked to include it more times than it appears.
lh := strings.ToLower(h)
i := seen[lh]
if i >= len(all) {
continue
}
seen[lh]++
selected := all[i]
if selected == sigH {
continue
}
include = append(include, selected)
}
return include
}
func hashWith(a crypto.Hash, data []byte) []byte {
h := a.New()
h.Write(data)
return h.Sum(nil)
}

View File

@@ -0,0 +1,415 @@
package dkim
import (
"context"
"net"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
)
func toCRLF(s string) string {
return strings.ReplaceAll(s, "\n", "\r\n")
}
func makeLookupTXT(results map[string][]string) lookupTXTFunc {
return func(ctx context.Context, domain string) ([]string, error) {
return results[domain], nil
}
}
func TestVerifyRF6376CExample(t *testing.T) {
ctx := context.Background()
ctx = WithTraceFunc(ctx, t.Logf)
// Use the public key from the example in RFC 6376 appendix C.
// https://datatracker.ietf.org/doc/html/rfc6376#appendix-C
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"brisbane._domainkey.example.com": []string{
"v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" +
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" +
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" +
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" +
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
},
}))
// Note that the examples in the RFC text have multiple issues:
// - The double space in "game. Are" should be a single
// space. Otherwise, the body hash does not match.
// https://www.rfc-editor.org/errata/eid3192
// - The header indentation is incorrect. This causes
// signature validation failure (because the example uses simple
// canonicalization, which leaves the indentation untouched).
// https://www.rfc-editor.org/errata/eid4926
message := toCRLF(
`DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com;
c=simple/simple; q=dns/txt; i=joe@football.example.com;
h=Received : From : To : Subject : Date : Message-ID;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB
4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut
KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV
4bmp/YzhwvcubU4=;
Received: from client1.football.example.com [192.0.2.1]
by submitserver.example.com with SUBMISSION;
Fri, 11 Jul 2003 21:01:54 -0700 (PDT)
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.
`)
res, err := VerifyMessage(ctx, message)
if res.Valid != 1 || err != nil {
t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err)
}
// Extend the message, check it does not pass validation.
res, err = VerifyMessage(ctx, message+"Extra line.\r\n")
if res.Valid != 0 || err != nil {
t.Errorf("VerifyMessage: wanted 0 valid / nil; got %v / %v", res, err)
}
// Alter a header, check it does not pass validation.
res, err = VerifyMessage(ctx,
strings.Replace(message, "Subject", "X-Subject", 1))
if res.Valid != 0 || err != nil {
t.Errorf("VerifyMessage: wanted 0 valid / nil; got %v / %v", res, err)
}
}
func TestVerifyRFC8463Example(t *testing.T) {
ctx := context.Background()
ctx = WithTraceFunc(ctx, t.Logf)
// Use the public keys from the example in RFC 8463 appendix A.2.
// https://datatracker.ietf.org/doc/html/rfc6376#appendix-C
ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{
"brisbane._domainkey.football.example.com": []string{
"v=DKIM1; k=ed25519; " +
"p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="},
"test._domainkey.football.example.com": []string{
"v=DKIM1; k=rsa; " +
"p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWR" +
"iGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/b" +
"yYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKr" +
"M3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K" +
"4w3QIDAQAB"},
}))
message := toCRLF(
`DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=
From: Joe SixPack <joe@football.example.com>
To: Suzie Q <suzie@shopping.example.net>
Subject: Is dinner ready?
Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT)
Message-ID: <20030712040037.46341.5F8J@football.example.com>
Hi.
We lost the game. Are you hungry yet?
Joe.
`)
expected := &VerifyResult{
Found: 2,
Valid: 2,
Results: []*OneResult{
{
SignatureHeader: toCRLF(
` v=1; a=ed25519-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=brisbane; t=1528637909; h=from : to :
subject : date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus
Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==`),
Domain: "football.example.com",
Selector: "brisbane",
B: "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11" +
"BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
State: SUCCESS,
Error: nil,
},
{
SignatureHeader: toCRLF(
` v=1; a=rsa-sha256; c=relaxed/relaxed;
d=football.example.com; i=@football.example.com;
q=dns/txt; s=test; t=1528637909; h=from : to : subject :
date : message-id : from : subject : date;
bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;
b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3
DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz
dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=`),
Domain: "football.example.com",
Selector: "test",
B: "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe" +
"3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefO" +
"sk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZ" +
"Q4FADY+8=",
State: SUCCESS,
Error: nil,
},
},
}
res, err := VerifyMessage(ctx, message)
if err != nil {
t.Fatalf("VerifyMessage returned error: %v", err)
}
if diff := cmp.Diff(expected, res); diff != "" {
t.Errorf("VerifyMessage diff (-want +got):\n%s", diff)
}
// Extend the message, check it does not pass validation.
res, err = VerifyMessage(ctx, message+"Extra line.\r\n")
if res.Found != 2 || res.Valid != 0 || err != nil {
t.Errorf("VerifyMessage: wanted 2 found, 0 valid / nil; got %v / %v",
res, err)
}
// Alter a header, check it does not pass validation.
res, err = VerifyMessage(ctx,
strings.Replace(message, "Subject", "X-Subject", 1))
if res.Found != 2 || res.Valid != 0 || err != nil {
t.Errorf("VerifyMessage: wanted 2 found, 0 valid / nil; got %v / %v",
res, err)
}
}
func TestHeadersToInclude(t *testing.T) {
// Test that headersToInclude returns the expected headers.
cases := []struct {
sigH header
hTag []string
headers headers
want []header
}{
// Check that if a header appears more than once, we pick the latest
// first.
{
sigH: header{
Name: "DKIM-Signature",
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
},
hTag: []string{"From", "To", "Subject"},
headers: headers{
{Name: "From", Value: "from1"},
{Name: "To", Value: "to1"},
{Name: "Subject", Value: "subject1"},
{Name: "From", Value: "from2"},
},
want: []header{
{Name: "From", Value: "from2"},
{Name: "To", Value: "to1"},
{Name: "Subject", Value: "subject1"},
},
},
// Check that if a header is requested twice but only appears once, we
// only return it once.
// This is a common technique suggested by the RFC to make signatures
// fail if a header is added.
{
sigH: header{
Name: "DKIM-Signature",
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
},
hTag: []string{"From", "From", "To", "Subject"},
headers: headers{
{Name: "From", Value: "from1"},
{Name: "To", Value: "to1"},
{Name: "Subject", Value: "subject1"},
},
want: []header{
{Name: "From", Value: "from1"},
{Name: "To", Value: "to1"},
{Name: "Subject", Value: "subject1"},
},
},
// Check that if DKIM-Signature is included, we do *not* include the
// one we're currently verifying in the headers to include.
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
{
sigH: header{
Name: "DKIM-Signature",
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
},
hTag: []string{"From", "From", "DKIM-Signature", "DKIM-Signature"},
headers: headers{
{Name: "From", Value: "from1"},
{Name: "To", Value: "to1"},
{
Name: "DKIM-Signature",
Value: "v=1; a=rsa-sha256; s=sidney; d=example.com;",
},
{
Name: "DKIM-Signature",
Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;",
},
},
want: []header{
{Name: "From", Value: "from1"},
{
Name: "DKIM-Signature",
Value: "v=1; a=rsa-sha256; s=sidney; d=example.com;",
},
},
},
}
for _, c := range cases {
got := headersToInclude(c.sigH, c.hTag, c.headers)
if diff := cmp.Diff(c.want, got); diff != "" {
t.Errorf("headersToInclude(%q, %v, %v) diff (-want +got):\n%s",
c.sigH, c.hTag, c.headers, diff)
}
}
}
func TestAuthenticationResults(t *testing.T) {
resBrisbane := &OneResult{
Domain: "football.example.com",
Selector: "brisbane",
B: "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11" +
"BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==",
State: SUCCESS,
Error: nil,
}
resTest := &OneResult{
Domain: "football.example.com",
Selector: "test",
B: "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe" +
"3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefO" +
"sk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZ" +
"Q4FADY+8=",
State: SUCCESS,
Error: nil,
}
resFail := &OneResult{
Domain: "football.example.com",
Selector: "paris",
B: "slfkdMSDFeslif39seFfjl93sljisdsdlif923l",
State: PERMFAIL,
Error: ErrVerificationFailed,
}
resPermFail := &OneResult{
Domain: "football.example.com",
Selector: "paris",
// No B tag on purpose.
State: PERMFAIL,
Error: errMissingRequiredTag,
}
resTempFail := &OneResult{
Domain: "football.example.com",
Selector: "paris",
B: "shorty", // Less than 12 characters to check we include it well.
State: TEMPFAIL,
Error: &net.DNSError{
Err: "dns temp error (for testing)",
IsTemporary: true,
},
}
cases := []struct {
results *VerifyResult
want string
}{
{
results: &VerifyResult{},
want: ";dkim=none\r\n",
},
{
results: &VerifyResult{
Found: 1,
Valid: 1,
Results: []*OneResult{resBrisbane},
},
want: ";dkim=pass" +
" header.b=/gCrinpcQOoI header.d=football.example.com\r\n",
},
{
results: &VerifyResult{
Found: 2,
Valid: 2,
Results: []*OneResult{resBrisbane, resTest},
},
want: ";dkim=pass" +
" header.b=/gCrinpcQOoI header.d=football.example.com\r\n" +
";dkim=pass" +
" header.b=F45dVWDfMbQD header.d=football.example.com\r\n",
},
{
results: &VerifyResult{
Found: 2,
Valid: 2,
Results: []*OneResult{resBrisbane, resTest},
},
want: ";dkim=pass" +
" header.b=/gCrinpcQOoI header.d=football.example.com\r\n" +
";dkim=pass" +
" header.b=F45dVWDfMbQD header.d=football.example.com\r\n",
},
{
results: &VerifyResult{
Found: 2,
Valid: 1,
Results: []*OneResult{resFail, resTest},
},
want: ";dkim=fail reason=\"verification failed\"\r\n" +
" header.b=slfkdMSDFesl header.d=football.example.com\r\n" +
";dkim=pass" +
" header.b=F45dVWDfMbQD header.d=football.example.com\r\n",
},
{
results: &VerifyResult{
Found: 1,
Results: []*OneResult{resPermFail},
},
want: ";dkim=permerror reason=\"missing required tag\"\r\n" +
" header.d=football.example.com\r\n",
},
{
results: &VerifyResult{
Found: 1,
Results: []*OneResult{resTempFail},
},
want: ";dkim=temperror reason=\"lookup : dns temp error (for testing)\"\r\n" +
" header.b=shorty header.d=football.example.com\r\n",
},
}
for i, c := range cases {
got := c.results.AuthenticationResults()
if diff := cmp.Diff(c.want, got); diff != "" {
t.Errorf("case %d: AuthenticationResults() diff (-want +got):\n%s",
i, diff)
}
}
}

View File

@@ -93,3 +93,8 @@ func ToCRLF(in []byte) []byte {
} }
return b.Bytes() return b.Bytes()
} }
// StringToCRLF is like ToCRLF, but operates on strings.
func StringToCRLF(in string) string {
return string(ToCRLF([]byte(in)))
}

View File

@@ -142,6 +142,11 @@ func TestToCRLF(t *testing.T) {
if got != c.out { if got != c.out {
t.Errorf("ToCRLF(%q) = %q, expected %q", c.in, 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)
}
} }
} }

View File

@@ -456,6 +456,8 @@ func sendDSN(tr *trace.Trace, q *Queue, item *Item) {
return return
} }
// TODO: DKIM signing.
id, err := q.Put(tr, "<>", []string{item.From}, msg) id, err := q.Put(tr, "<>", []string{item.From}, msg)
if err != nil { if err != nil {
tr.Errorf("failed to queue DSN: %v", err) tr.Errorf("failed to queue DSN: %v", err)

View File

@@ -20,6 +20,7 @@ import (
"blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/aliases"
"blitiri.com.ar/go/chasquid/internal/auth" "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/domaininfo"
"blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/envelope"
"blitiri.com.ar/go/chasquid/internal/expvarom" "blitiri.com.ar/go/chasquid/internal/expvarom"
@@ -51,6 +52,20 @@ var (
"result", "count of hook invocations, by result") "result", "count of hook invocations, by result")
wrongProtoCount = expvarom.NewMap("chasquid/smtpIn/wrongProtoCount", wrongProtoCount = expvarom.NewMap("chasquid/smtpIn/wrongProtoCount",
"command", "count of commands for other protocols") "command", "count of commands for other protocols")
dkimSigned = expvarom.NewInt("chasquid/smtpIn/dkimSigned",
"count of successful DKIM signs")
dkimSignErrors = expvarom.NewInt("chasquid/smtpIn/dkimSignErrors",
"count of DKIM sign errors")
dkimVerifyFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyFound",
"count of messages with at least one DKIM signature")
dkimVerifyNotFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyNotFound",
"count of messages with no DKIM signatures")
dkimVerifyValid = expvarom.NewInt("chasquid/smtpIn/dkimVerifyValid",
"count of messages with at least one valid DKIM signature")
dkimVerifyErrors = expvarom.NewInt("chasquid/smtpIn/dkimVerifyErrors",
"count of DKIM verification errors")
) )
var ( var (
@@ -129,6 +144,9 @@ type Conn struct {
spfResult spf.Result spfResult spf.Result
spfError error spfError error
// DKIM verification results.
dkimVerifyResult *dkim.VerifyResult
// Are we using TLS? // Are we using TLS?
onTLS bool onTLS bool
@@ -142,6 +160,9 @@ type Conn struct {
aliasesR *aliases.Resolver aliasesR *aliases.Resolver
dinfo *domaininfo.DB 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? // Have we successfully completed AUTH?
completedAuth bool completedAuth bool
@@ -666,6 +687,18 @@ func (c *Conn) DATA(params string) (code int, msg string) {
return 554, err.Error() 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() c.addReceivedHeader()
hookOut, permanent, err := c.runPostDataHook(c.data) hookOut, permanent, err := c.runPostDataHook(c.data)
@@ -704,7 +737,7 @@ func (c *Conn) DATA(params string) (code int, msg string) {
} }
func (c *Conn) addReceivedHeader() { func (c *Conn) addReceivedHeader() {
var v string var received string
// Format is semi-structured, defined by // Format is semi-structured, defined by
// https://tools.ietf.org/html/rfc5321#section-4.4 // https://tools.ietf.org/html/rfc5321#section-4.4
@@ -712,16 +745,16 @@ func (c *Conn) addReceivedHeader() {
if c.completedAuth { if c.completedAuth {
// For authenticated users, only show the EHLO domain they gave; // For authenticated users, only show the EHLO domain they gave;
// explicitly hide their network address. // explicitly hide their network address.
v += fmt.Sprintf("from %s\n", c.ehloDomain) received += fmt.Sprintf("from %s\n", c.ehloDomain)
} else { } else {
// For non-authenticated users we show the real address as canonical, // For non-authenticated users we show the real address as canonical,
// and then the given EHLO domain for convenience and // and then the given EHLO domain for convenience and
// troubleshooting. // troubleshooting.
v += fmt.Sprintf("from [%s] (%s)\n", received += fmt.Sprintf("from [%s] (%s)\n",
addrLiteral(c.remoteAddr), c.ehloDomain) 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 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#mail-parameters-7
with := "SMTP" with := "SMTP"
@@ -734,35 +767,60 @@ func (c *Conn) addReceivedHeader() {
if c.completedAuth { if c.completedAuth {
with += "A" with += "A"
} }
v += fmt.Sprintf("with %s\n", with) received += fmt.Sprintf("with %s\n", with)
if c.tlsConnState != nil { if c.tlsConnState != nil {
// https://tools.ietf.org/html/rfc8314#section-4.3 // 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)) tlsconst.CipherSuiteName(c.tlsConnState.CipherSuite))
} }
v += fmt.Sprintf("(over %s, ", c.mode) received += fmt.Sprintf("(over %s, ", c.mode)
if c.tlsConnState != nil { if c.tlsConnState != nil {
v += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version)) received += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version))
} else { } else {
v += "plain text!, " received += "plain text!, "
} }
// Note we must NOT include c.rcptTo, that would leak BCCs. // 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. // This should be the last part in the Received header, by RFC.
// The ";" is a mandatory separator. The date format is not standard but // The ";" is a mandatory separator. The date format is not standard but
// this one seems to be widely used. // this one seems to be widely used.
// https://tools.ietf.org/html/rfc5322#section-3.6.7 // https://tools.ietf.org/html/rfc5322#section-3.6.7
v += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z)) received += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z))
c.data = envelope.AddHeader(c.data, "Received", v) 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 != "" { if c.spfResult != "" {
// https://tools.ietf.org/html/rfc7208#section-9.1 // https://tools.ietf.org/html/rfc7208#section-9.1
v = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError) received = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError)
c.data = envelope.AddHeader(c.data, "Received-SPF", v) c.data = envelope.AddHeader(c.data, "Received-SPF", received)
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.2
arHdr += fmt.Sprintf(";spf=%s (%v)\r\n", c.spfResult, c.spfError)
includeAR = true
}
if c.dkimVerifyResult != nil {
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
arHdr += c.dkimVerifyResult.AuthenticationResults() + "\r\n"
includeAR = true
}
if includeAR {
// Only include the Authentication-Results header if we have something
// to report.
c.data = envelope.AddHeader(c.data, "Authentication-Results",
strings.TrimSpace(arHdr))
} }
} }
@@ -957,6 +1015,79 @@ func boolToStr(b bool) string {
return "0" return "0"
} }
func (c *Conn) dkimSign() error {
// We only sign if the user authenticated. However, the authenticated user
// and the MAIL FROM address may be different; even the domain may be
// different.
// We explicitly let this happen and trust authenticated users.
// So for DKIM signing purposes, we use the MAIL FROM domain: this
// prevents leaking the authenticated user's domain, and is more in line
// with expectations around signatures.
domain := envelope.DomainOf(c.mailFrom)
signers := c.dkimSigners[domain]
if len(signers) == 0 {
return nil
}
tr := c.tr.NewChild("DKIM.Sign", domain)
defer tr.Finish()
ctx := context.Background()
ctx = dkim.WithTraceFunc(ctx, tr.Debugf)
for _, signer := range signers {
sig, err := signer.Sign(ctx, normalize.StringToCRLF(string(c.data)))
if err != nil {
dkimSignErrors.Add(1)
return err
}
// The signature is returned with \r\n; however, our internal
// representation uses \n, so normalize it.
sig = strings.ReplaceAll(sig, "\r\n", "\n")
c.data = envelope.AddHeader(c.data, "DKIM-Signature", sig)
}
dkimSigned.Add(1)
return nil
}
func (c *Conn) dkimVerify() {
tr := c.tr.NewChild("DKIM.Verify", c.mailFrom)
defer tr.Finish()
var err error
ctx := context.Background()
ctx = dkim.WithTraceFunc(ctx, tr.Debugf)
c.dkimVerifyResult, err = dkim.VerifyMessage(
ctx, string(normalize.ToCRLF(c.data)))
if err != nil {
// The only error we expect is because of a malformed mail, which is
// checked before this is invoked.
tr.Errorf("Error verifying DKIM: %v", err)
dkimVerifyErrors.Add(1)
}
if c.dkimVerifyResult != nil {
if c.dkimVerifyResult.Found > 0 {
dkimVerifyFound.Add(1)
} else {
dkimVerifyNotFound.Add(1)
}
if c.dkimVerifyResult.Valid > 0 {
dkimVerifyValid.Add(1)
}
}
// Note we don't fail emails because they failed to verify, in line
// with RFC recommendations.
// DMARC policies may cause it to fail at some point, but that is not
// implemented yet, and would happen separately.
// The results will get included in the Authentication-Results header, see
// addReceivedHeader for more details.
}
// STARTTLS SMTP command handler. // STARTTLS SMTP command handler.
func (c *Conn) STARTTLS(params string) (code int, msg string) { func (c *Conn) STARTTLS(params string) (code int, msg string) {
if c.onTLS { if c.onTLS {

View File

@@ -2,18 +2,26 @@
package smtpsrv package smtpsrv
import ( import (
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/pem"
"flag" "flag"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"strings"
"time" "time"
"blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/aliases"
"blitiri.com.ar/go/chasquid/internal/auth" "blitiri.com.ar/go/chasquid/internal/auth"
"blitiri.com.ar/go/chasquid/internal/courier" "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/domaininfo"
"blitiri.com.ar/go/chasquid/internal/localrpc" "blitiri.com.ar/go/chasquid/internal/localrpc"
"blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/maillog"
@@ -65,6 +73,9 @@ type Server struct {
// Domain info database. // Domain info database.
dinfo *domaininfo.DB 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. // Time before we give up on a connection, even if it's sending data.
connTimeout time.Duration connTimeout time.Duration
@@ -91,6 +102,7 @@ func NewServer() *Server {
localDomains: &set.String{}, localDomains: &set.String{},
authr: authr, authr: authr,
aliasesR: aliasesR, 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) 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. // SetAuthFallback sets the authentication backend to use as fallback.
func (s *Server) SetAuthFallback(be auth.Backend) { func (s *Server) SetAuthFallback(be auth.Backend) {
s.authr.Fallback = be s.authr.Fallback = be
@@ -287,6 +341,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
aliasesR: s.aliasesR, aliasesR: s.aliasesR,
localDomains: s.localDomains, localDomains: s.localDomains,
dinfo: s.dinfo, dinfo: s.dinfo,
dkimSigners: s.dkimSigners,
deadline: time.Now().Add(s.connTimeout), deadline: time.Now().Add(s.connTimeout),
commandTimeout: s.commandTimeout, commandTimeout: s.commandTimeout,
queue: s.queue, queue: s.queue,

View File

@@ -2,11 +2,13 @@ package smtpsrv
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"flag" "flag"
"fmt" "fmt"
"net" "net"
"net/smtp" "net/smtp"
"os" "os"
"strings"
"testing" "testing"
"time" "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 === // === Benchmarks ===
// //

View File

@@ -24,16 +24,16 @@ func New(family, title string) *Trace {
t := &Trace{family, title, nettrace.New(family, title)} t := &Trace{family, title, nettrace.New(family, title)}
// The default for max events is 10, which is a bit short for a normal // 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. // most of the traces.
t.t.SetMaxEvents(30) t.t.SetMaxEvents(100)
return t return t
} }
// NewChild creates a new child trace. // NewChild creates a new child trace.
func (t *Trace) NewChild(family, title string) *Trace { func (t *Trace) NewChild(family, title string) *Trace {
n := &Trace{family, title, t.t.NewChild(family, title)} n := &Trace{family, title, t.t.NewChild(family, title)}
n.t.SetMaxEvents(30) n.t.SetMaxEvents(100)
return n return n
} }

View File

@@ -3,4 +3,4 @@
# Run from the config directory because data_dir is relative. # Run from the config directory because data_dir is relative.
cd config || exit 1 cd config || exit 1
go run ../../../cmd/chasquid-util/chasquid-util.go -C=. "$@" go run ../../../cmd/chasquid-util/ -C=. "$@"

View File

@@ -30,7 +30,6 @@ if [ "$AUTH_AS" != "" ]; then
< "$TF" > "$TF.dkimout" < "$TF" > "$TF.dkimout"
# dkimpy doesn't provide a way to just show the new headers, so we # dkimpy doesn't provide a way to just show the new headers, so we
# have to compute the difference. # have to compute the difference.
# ALSOCHANGE(etc/chasquid/hooks/post-data)
diff --changed-group-format='%>' \ diff --changed-group-format='%>' \
--unchanged-group-format='' \ --unchanged-group-format='' \
"$TF" "$TF.dkimout" && exit 1 "$TF" "$TF.dkimout" && exit 1

View File

@@ -0,0 +1 @@
DKIM loading error: error decoding PEM block

View File

@@ -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"

View File

@@ -18,7 +18,8 @@ mkdir -p c-04-no_cert_dirs/certs/
# Generate certs for the tests that need them. # Generate certs for the tests that need them.
for i in c-05-no_addrs c-06-bad_maillog c-07-bad_domain_info \ for i in c-05-no_addrs c-06-bad_maillog c-07-bad_domain_info \
c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr ; c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr \
c-11-bad_dkim_key;
do do
CONFDIR=$i/ generate_certs_for testserver CONFDIR=$i/ generate_certs_for testserver
done done

2
test/t-21-dkim/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Ignore the configuration domain directories.
?/domains

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS
-----END PRIVATE KEY-----

View File

@@ -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"

View File

@@ -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?

View File

@@ -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?

View File

@@ -0,0 +1,5 @@
From: user-b@srv-b
To: user-a@srv-a
Subject: Feliz primavera!
Espero que florezcas feliz!

View File

@@ -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!

67
test/t-21-dkim/run.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
set -e
. "$(dirname "$0")/../util/lib.sh"
init
check_hostaliases
rm -rf .data-A .data-B .mail
skip_if_python_is_too_old
# Build with the DNS override, so we can fake DNS records.
export GOTAGS="dnsoverride"
# srv-A has a pre-generated key, and the mail has a pre-generated header.
# Generate a key for srv-B, and append it to our statically configured zones.
# Use a fixed selector so we can be more thorough in from_B_to_A.expected.
rm -f B/domains/srv-b/*.pem
mkdir -p B/domains/srv-b/
CONFDIR=B chasquid-util dkim-keygen srv-b sel77 --algo=ed25519 > /dev/null
cp zones .zones
CONFDIR=B chasquid-util dkim-dns srv-b | sed 's/"//g' >> .zones
# Launch minidns in the background using our configuration.
minidns_bg --addr=":9053" -zones=.zones >> .minidns.log 2>&1
# Two servers:
# A - listens on :1025, hosts srv-A
# B - listens on :2015, hosts srv-B
CONFDIR=A generate_certs_for srv-A
CONFDIR=A add_user user-a@srv-a nadaA
CONFDIR=B generate_certs_for srv-B
CONFDIR=B add_user user-b@srv-b nadaB
mkdir -p .logs-A .logs-B
chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \
--testing__dns_addr=127.0.0.1:9053 \
--testing__outgoing_smtp_port=2025 &
chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \
--testing__dns_addr=127.0.0.1:9053 \
--testing__outgoing_smtp_port=1025 &
wait_until_ready 1025
wait_until_ready 2025
wait_until_ready 9053
# Send from A to B.
smtpc.py --server=localhost:1025 --user=user-a@srv-a --password=nadaA \
< from_A_to_B
wait_for_file .mail/user-b@srv-b
mail_diff from_A_to_B.expected .mail/user-b@srv-b
# Send from B to A.
smtpc.py --server=localhost:2025 --user=user-b@srv-b --password=nadaB \
< from_B_to_A
wait_for_file .mail/user-a@srv-a
mail_diff from_B_to_A.expected .mail/user-a@srv-a
success

6
test/t-21-dkim/zones Normal file
View File

@@ -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=

View File

@@ -43,7 +43,7 @@ class Process (object):
return self.cmd.wait() return self.cmd.wait()
def close(self): def close(self):
return self.cmd.terminate() return self.cmd.stdin.close()
class Sock (object): class Sock (object):
"""A (generic) socket. """A (generic) socket.

View File

@@ -48,7 +48,7 @@ function chasquid-util() {
# data_dir is relative to the config. # data_dir is relative to the config.
CONFDIR="${CONFDIR:-config}" CONFDIR="${CONFDIR:-config}"
( cd "$CONFDIR" && \ ( cd "$CONFDIR" && \
go run "${TBASE}/../../cmd/chasquid-util/chasquid-util.go" \ go run "${TBASE}/../../cmd/chasquid-util/" \
-C=. \ -C=. \
"$@" \ "$@" \
) )