1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-17 14:37:02 +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
* Multiple/virtual domains, with per-domain users and aliases.
* Suffix dropping (`user+something@domain``user@domain`).
* [Hooks] for integration with greylisting, anti-virus, anti-spam, and
DKIM/DMARC.
* [Hooks] for integration with greylisting, anti-virus, and anti-spam.
* International usernames ([SMTPUTF8]) and domain names ([IDNA]).
* Secure
* [Tracking] of per-domain TLS support, prevents connection downgrading.
* Multiple TLS certificates.
* Easy integration with [Let's Encrypt].
* [SPF] and [MTA-STS] checking.
* [DKIM] support (signing and verification).
[Arch]: https://blitiri.com.ar/p/chasquid/install/#arch
[DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
[Debian]: https://blitiri.com.ar/p/chasquid/install/#debianubuntu
[Dovecot]: https://blitiri.com.ar/p/chasquid/dovecot/
[Hooks]: https://blitiri.com.ar/p/chasquid/hooks/

View File

@@ -12,7 +12,9 @@ import (
"net"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
@@ -297,6 +299,14 @@ func loadDomain(name, dir string, s *smtpsrv.Server) {
if err != nil {
log.Errorf(" aliases file error: %v", err)
}
err = loadDKIM(name, dir, s)
if err != nil {
// DKIM errors are fatal because if the user set DKIM up, then we
// don't want it to be failing silently, as that could cause
// deliverability issues.
log.Fatalf(" DKIM loading error: %v", err)
}
}
func loadDovecot(s *smtpsrv.Server, userdb, client string) {
@@ -309,6 +319,26 @@ func loadDovecot(s *smtpsrv.Server, userdb, client string) {
}
}
func loadDKIM(domain, dir string, s *smtpsrv.Server) error {
glob := path.Clean(dir + "/dkim:*.pem")
pems, err := filepath.Glob(glob)
if err != nil {
return err
}
for _, pem := range pems {
base := filepath.Base(pem)
selector := strings.TrimPrefix(base, "dkim:")
selector = strings.TrimSuffix(selector, ".pem")
err = s.AddDKIMSigner(domain, selector, pem)
if err != nil {
return err
}
}
return nil
}
// Read a directory, which must have at least some entries.
func mustReadDir(path string) []os.DirEntry {
dirs, err := os.ReadDir(path)

View File

@@ -39,8 +39,15 @@ Usage:
chasquid-util [options] print-config
Print the current chasquid configuration.
chasquid-util [options] dkim-keygen <domain> [<selector> <private-key.pem>] [--algo=rsa3072|rsa4096|ed25519]
Generate a new DKIM key pair for the domain.
chasquid-util [options] dkim-dns <domain> [<selector> <private-key.pem>]
Print the DNS TXT record to use for the domain, selector and
private key.
Options:
-C=<path>, --configdir=<path> Configuration directory
-v Verbose mode
`
// Command-line arguments.
@@ -80,6 +87,13 @@ func main() {
"aliases-resolve": aliasesResolve,
"print-config": printConfig,
"domaininfo-remove": domaininfoRemove,
"dkim-keygen": dkimKeygen,
"dkim-dns": dkimDNS,
// These exist for testing purposes and may be removed in the future.
// Do not rely on them.
"dkim-verify": dkimVerify,
"dkim-sign": dkimSign,
}
cmd := args["$1"]

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

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
[chasquid] supports generating [DKIM] signatures via the [hooks](hooks.md)
mechanism.
[chasquid] supports verifying and generating [DKIM] signatures since version
1.14.
All incoming email is verified, and *authenticated* emails for domains which
have a private DKIM key set up will be signed.
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
assumes the following:
- Run `chasquid-util dkim-keygen DOMAIN` to generate a DKIM private key for
your domain. The file will be in `/etc/chasquid/domains/DOMAIN/dkim:*.pem`.
- Publish the DKIM DNS record which was shown by the
previous command (e.g. by following
[this guide](https://support.dnsimple.com/articles/dkim-record/)).
- Change the key file's permissions, to ensure it is readable by chasquid (and
nobody else).
- Restart chasquid.
- The [selector](https://tools.ietf.org/html/rfc6376#section-3.1) for a domain
can be found in the file `domains/$DOMAIN/dkim_selector`.
- The private key to use for signing can be found in the file
`certs/$DOMAIN/dkim_privkey.pem`.
Only authenticated email will be signed.
It is highly recommended that you use a DKIM checker (like
[Learn DMARC](https://www.learndmarc.com/)) to confirm that your setup is
fully functional.
### Setup with [driusan/dkim]
## Advanced setup
1. Install the [driusan/dkim] tools with something like the following (adjust
to your local environment):
You need to place the PEM-encoded private key in the domain config directory,
with a name like `dkim:SELECTOR.pem`, where `SELECTOR` is the selector string.
```
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
```
It needs to be either RSA or Ed25519.
1. Generate the domain key for your domain using `dkimkeygen`.
1. Publish the DNS record from `dns.txt`
([guide](https://support.dnsimple.com/articles/dkim-record/)).
1. Write the selector you chose to `domains/$DOMAIN/dkim_selector`.
1. Copy `private.pem` to `/etc/chasquid/certs/$DOMAIN/dkim_privkey.pem`.
1. Verify the setup using one of the publicly available tools, like
[mail-tester](https://www.mail-tester.com/spf-dkim-check).
### Key rotation
To rotate a key, you can remove the old key file, and generate a new one as
per the previous step.
It is important to remove the old key from the directory, because chasquid
will use *all* the keys in it.
You should use a different selector each time. If you don't specify a
selector when using `chasquid-util dkim-keygen`, the current date will be
used, which is a safe default to prevent accidental reuse.
### Setup with [dkimpy]
### Multiple keys
1. Install [dkimpy] with `apt install python3-dkim` or the equivalent for your
environment.
1. Generate the domain key for your domain using `dknewkey dkim`.
1. Publish the DNS record from `dkim.dns`
([guide](https://support.dnsimple.com/articles/dkim-record/)).
1. Write the selector you chose to `domains/$DOMAIN/dkim_selector`.
1. Copy `dkim.key` to `/etc/chasquid/certs/$DOMAIN/dkim_privkey.pem`.
1. Verify the setup using one of the publicly available tools, like
[mail-tester](https://www.mail-tester.com/spf-dkim-check).
Advanced users may want to sign outgoing mail with multiple keys (e.g. to
support multiple signing algorithms).
This is well supported: chasquid will sign email with all keys it find that
match `dkim:*.pem` in a domain directory.
## Verification
Verifying signatures is technically supported as well, and can be done in the
same hook. However, it's not recommended for SMTP servers to reject mail on
verification failures
[chasquid] will verify all DKIM signatures of incoming mail, and record the
results in an [`Authentication-Results:`] header, as per [RFC 8601].
Note that emails will *not* be rejected even if they fail verification, as
this is not recommended
([source 1](https://tools.ietf.org/html/rfc6376#section-6.3),
[source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)), so it is not
included in the example.
[source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)).
## Other implementations
[chasquid] also supports [DKIM] via the [hooks] mechanism. This can be useful
if more customization is needed.
Implementations that have been tried:
- [driusan/dkim]
- [dkimpy]
[chasquid]: https://blitiri.com.ar/p/chasquid
[DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
[hooks]: hooks.md
[example hook]: https://blitiri.com.ar/git/r/chasquid/b/next/t/etc/chasquid/hooks/f=post-data.html
[driusan/dkim]: https://github.com/driusan/dkim
[dkimpy]: https://launchpad.net/dkimpy/
[RFC 8601]: https://datatracker.ietf.org/doc/html/rfc8601
[`Authentication-Results:`]: https://en.wikipedia.org/wiki/Email_authentication#Authentication-Results

View File

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

View File

@@ -7,7 +7,6 @@
# - spamc (from Spamassassin) to filter spam.
# - rspamc (from rspamd) or chasquid-rspamd to filter spam.
# - clamdscan (from ClamAV) to filter virus.
# - dkimsign (from driusan/dkim or dkimpy) to do DKIM signing.
#
# If it exits with code 20, it will be considered a permanent error.
# Otherwise, temporary.
@@ -78,46 +77,3 @@ if command -v clamdscan >/dev/null; then
fi
echo "X-Virus-Scanned: pass"
fi
# DKIM sign with either driusan/dkim or dkimpy.
#
# Do it only if all the following are true:
# - User has authenticated.
# - dkimsign binary exists.
# - domains/$DOMAIN/dkim_selector file exists.
# - certs/$DOMAIN/dkim_privkey.pem file exists.
#
# Note this has not been thoroughly tested, so might need further adjustments.
if [ "$AUTH_AS" != "" ] && command -v dkimsign >/dev/null; then
DOMAIN=$( echo "$MAIL_FROM" | cut -d '@' -f 2 )
if [ -f "domains/$DOMAIN/dkim_selector" ] \
&& [ -f "certs/$DOMAIN/dkim_privkey.pem" ];
then
# driusan/dkim and dkimpy both provide the same binary (dkimsign) but
# take different arguments, so we need to tell them apart.
# This is awful but it should work reasonably well.
if dkimsign --help 2>&1 | grep -q -- --identity; then
# dkimpy
dkimsign \
"$(cat "domains/$DOMAIN/dkim_selector")" \
"$DOMAIN" \
"certs/$DOMAIN/dkim_privkey.pem" \
< "$TF" > "$TF.dkimout"
# dkimpy doesn't provide a way to just show the new
# headers, so we have to compute the difference.
# ALSOCHANGE(test/t-19-dkimpy/config/hooks/post-data)
diff --changed-group-format='%>' \
--unchanged-group-format='' \
"$TF" "$TF.dkimout" && exit 1
rm "$TF.dkimout"
else
# driusan/dkim
dkimsign -n -hd \
-key "certs/$DOMAIN/dkim_privkey.pem" \
-s "$(cat "domains/$DOMAIN/dkim_selector")" \
-d "$DOMAIN" \
< "$TF"
fi
fi
fi

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

View File

@@ -2,18 +2,26 @@
package smtpsrv
import (
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"blitiri.com.ar/go/chasquid/internal/aliases"
"blitiri.com.ar/go/chasquid/internal/auth"
"blitiri.com.ar/go/chasquid/internal/courier"
"blitiri.com.ar/go/chasquid/internal/dkim"
"blitiri.com.ar/go/chasquid/internal/domaininfo"
"blitiri.com.ar/go/chasquid/internal/localrpc"
"blitiri.com.ar/go/chasquid/internal/maillog"
@@ -65,6 +73,9 @@ type Server struct {
// Domain info database.
dinfo *domaininfo.DB
// Map of domain -> DKIM signers.
dkimSigners map[string][]*dkim.Signer
// Time before we give up on a connection, even if it's sending data.
connTimeout time.Duration
@@ -91,6 +102,7 @@ func NewServer() *Server {
localDomains: &set.String{},
authr: authr,
aliasesR: aliasesR,
dkimSigners: map[string][]*dkim.Signer{},
}
}
@@ -130,6 +142,48 @@ func (s *Server) AddAliasesFile(domain, f string) error {
return s.aliasesR.AddAliasesFile(domain, f)
}
var (
errDecodingPEMBlock = fmt.Errorf("error decoding PEM block")
errUnsupportedBlockType = fmt.Errorf("unsupported block type")
errUnsupportedKeyType = fmt.Errorf("unsupported key type")
)
// AddDKIMSigner for the given domain and selector.
func (s *Server) AddDKIMSigner(domain, selector, keyPath string) error {
key, err := os.ReadFile(keyPath)
if err != nil {
return err
}
block, _ := pem.Decode(key)
if block == nil {
return errDecodingPEMBlock
}
if strings.ToUpper(block.Type) != "PRIVATE KEY" {
return fmt.Errorf("%w: %s", errUnsupportedBlockType, block.Type)
}
signer, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return err
}
switch k := signer.(type) {
case *rsa.PrivateKey, ed25519.PrivateKey:
// These are supported, nothing to do.
default:
return fmt.Errorf("%w: %T", errUnsupportedKeyType, k)
}
s.dkimSigners[domain] = append(s.dkimSigners[domain], &dkim.Signer{
Domain: domain,
Selector: selector,
Signer: signer.(crypto.Signer),
})
return nil
}
// SetAuthFallback sets the authentication backend to use as fallback.
func (s *Server) SetAuthFallback(be auth.Backend) {
s.authr.Fallback = be
@@ -287,6 +341,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) {
aliasesR: s.aliasesR,
localDomains: s.localDomains,
dinfo: s.dinfo,
dkimSigners: s.dkimSigners,
deadline: time.Now().Add(s.connTimeout),
commandTimeout: s.commandTimeout,
queue: s.queue,

View File

@@ -2,11 +2,13 @@ package smtpsrv
import (
"crypto/tls"
"errors"
"flag"
"fmt"
"net"
"net/smtp"
"os"
"strings"
"testing"
"time"
@@ -481,6 +483,69 @@ func TestStartTLSOnTLS(t *testing.T) {
}
}
func TestAddDKIMSigner(t *testing.T) {
s := NewServer()
err := s.AddDKIMSigner("example.com", "selector", "keyfile-does-not-exist")
if !os.IsNotExist(err) {
t.Errorf("AddDKIMSigner: expected not exist, got %v", err)
}
tmpDir := testlib.MustTempDir(t)
defer testlib.RemoveIfOk(t, tmpDir)
// Invalid PEM file.
kf1 := tmpDir + "/key1-bad_pem.pem"
testlib.Rewrite(t, kf1, "not a valid PEM file")
err = s.AddDKIMSigner("example.com", "selector", kf1)
if !errors.Is(err, errDecodingPEMBlock) {
t.Errorf("AddDKIMSigner: expected %v, got %v",
errDecodingPEMBlock, err)
}
// Unsupported block type.
kf2 := tmpDir + "/key2.pem"
testlib.Rewrite(t, kf2,
"-----BEGIN TEST KEY-----\n-----END TEST KEY-----")
err = s.AddDKIMSigner("example.com", "selector", kf2)
if !errors.Is(err, errUnsupportedBlockType) {
t.Errorf("AddDKIMSigner: expected %v, got %v",
errUnsupportedBlockType, err)
}
// x509 error: this is an ed448 key, which is not supported.
kf3 := tmpDir + "/key3.pem"
testlib.Rewrite(t, kf3, `-----BEGIN PRIVATE KEY-----
MEcCAQAwBQYDK2VxBDsEOSBHT9DNG6/FNBnRGrLay+jIrK8WrViiVMz9AoXqYSb6
ghwTZSd3E0X8oIFTgs9ch3pxJM1KDrs4NA==
-----END PRIVATE KEY-----`)
err = s.AddDKIMSigner("example.com", "selector", kf3)
if !strings.Contains(err.Error(),
"x509: PKCS#8 wrapping contained private key with unknown algorithm") {
t.Errorf("AddDKIMSigner: expected x509 error, got %q", err.Error())
}
// Unsupported key type: X25519.
kf4 := tmpDir + "/key4.pem"
testlib.Rewrite(t, kf4, `-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VuBCIEIKBUDwEDc5cCv/yEvnA93yk0gXyiTZe7Qip8QU3rJuZC
-----END PRIVATE KEY-----`)
err = s.AddDKIMSigner("example.com", "selector", kf4)
if !errors.Is(err, errUnsupportedKeyType) {
t.Errorf("AddDKIMSigner: expected %v, got %v",
errUnsupportedKeyType, err)
}
// Successful.
kf5 := tmpDir + "/key5.pem"
testlib.Rewrite(t, kf5, `-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS
-----END PRIVATE KEY-----`)
err = s.AddDKIMSigner("example.com", "selector", kf5)
if err != nil {
t.Errorf("AddDKIMSigner: %v", err)
}
}
//
// === Benchmarks ===
//

View File

@@ -24,16 +24,16 @@ func New(family, title string) *Trace {
t := &Trace{family, title, nettrace.New(family, title)}
// The default for max events is 10, which is a bit short for a normal
// SMTP exchange. Expand it to 30 which should be large enough to keep
// SMTP exchange. Expand it to 100 which should be large enough to keep
// most of the traces.
t.t.SetMaxEvents(30)
t.t.SetMaxEvents(100)
return t
}
// NewChild creates a new child trace.
func (t *Trace) NewChild(family, title string) *Trace {
n := &Trace{family, title, t.t.NewChild(family, title)}
n.t.SetMaxEvents(30)
n.t.SetMaxEvents(100)
return n
}

View File

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

View File

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

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.
for i in c-05-no_addrs c-06-bad_maillog c-07-bad_domain_info \
c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr ;
c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr \
c-11-bad_dkim_key;
do
CONFDIR=$i/ generate_certs_for testserver
done

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()
def close(self):
return self.cmd.terminate()
return self.cmd.stdin.close()
class Sock (object):
"""A (generic) socket.

View File

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