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:
@@ -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/
|
||||
|
||||
30
chasquid.go
30
chasquid.go
@@ -12,7 +12,9 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -297,6 +299,14 @@ func loadDomain(name, dir string, s *smtpsrv.Server) {
|
||||
if err != nil {
|
||||
log.Errorf(" aliases file error: %v", err)
|
||||
}
|
||||
|
||||
err = loadDKIM(name, dir, s)
|
||||
if err != nil {
|
||||
// DKIM errors are fatal because if the user set DKIM up, then we
|
||||
// don't want it to be failing silently, as that could cause
|
||||
// deliverability issues.
|
||||
log.Fatalf(" DKIM loading error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadDovecot(s *smtpsrv.Server, userdb, client string) {
|
||||
@@ -309,6 +319,26 @@ func loadDovecot(s *smtpsrv.Server, userdb, client string) {
|
||||
}
|
||||
}
|
||||
|
||||
func loadDKIM(domain, dir string, s *smtpsrv.Server) error {
|
||||
glob := path.Clean(dir + "/dkim:*.pem")
|
||||
pems, err := filepath.Glob(glob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, pem := range pems {
|
||||
base := filepath.Base(pem)
|
||||
selector := strings.TrimPrefix(base, "dkim:")
|
||||
selector = strings.TrimSuffix(selector, ".pem")
|
||||
|
||||
err = s.AddDKIMSigner(domain, selector, pem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read a directory, which must have at least some entries.
|
||||
func mustReadDir(path string) []os.DirEntry {
|
||||
dirs, err := os.ReadDir(path)
|
||||
|
||||
@@ -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
260
cmd/chasquid-util/dkim.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
190
cmd/chasquid-util/test_dkim.cmy
Normal file
190
cmd/chasquid-util/test_dkim.cmy
Normal 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
|
||||
|
||||
3
cmd/chasquid-util/test_openssl_genpkey_ed25519.pem
Normal file
3
cmd/chasquid-util/test_openssl_genpkey_ed25519.pem
Normal file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIBul+k51unaApEcZBmt1i65n09asM/howsN4B1AjNY5V
|
||||
-----END PRIVATE KEY-----
|
||||
28
cmd/chasquid-util/test_openssl_genpkey_rsa.pem
Normal file
28
cmd/chasquid-util/test_openssl_genpkey_rsa.pem
Normal 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-----
|
||||
106
docs/dkim.md
106
docs/dkim.md
@@ -1,70 +1,90 @@
|
||||
|
||||
# DKIM integration
|
||||
|
||||
[chasquid] supports generating [DKIM] signatures via the [hooks](hooks.md)
|
||||
mechanism.
|
||||
[chasquid] supports verifying and generating [DKIM] signatures since version
|
||||
1.14.
|
||||
|
||||
All incoming email is verified, and *authenticated* emails for domains which
|
||||
have a private DKIM key set up will be signed.
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
158
internal/dkim/canonicalize.go
Normal file
158
internal/dkim/canonicalize.go
Normal 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
|
||||
}
|
||||
214
internal/dkim/canonicalize_test.go
Normal file
214
internal/dkim/canonicalize_test.go
Normal 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
56
internal/dkim/context.go
Normal 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
|
||||
}
|
||||
67
internal/dkim/context_test.go
Normal file
67
internal/dkim/context_test.go
Normal 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
201
internal/dkim/dns.go
Normal 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
248
internal/dkim/dns_test.go
Normal 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
235
internal/dkim/file_test.go
Normal 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
335
internal/dkim/header.go
Normal 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
|
||||
}
|
||||
433
internal/dkim/header_test.go
Normal file
433
internal/dkim/header_test.go
Normal 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
77
internal/dkim/message.go
Normal 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
|
||||
}
|
||||
99
internal/dkim/message_test.go
Normal file
99
internal/dkim/message_test.go
Normal 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
198
internal/dkim/sign.go
Normal 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
257
internal/dkim/sign_test.go
Normal 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
4
internal/dkim/testdata/.gitignore
vendored
Normal 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
8
internal/dkim/testdata/01-rfc8463.dns
vendored
Normal 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
|
||||
|
||||
1
internal/dkim/testdata/01-rfc8463.error
vendored
Normal file
1
internal/dkim/testdata/01-rfc8463.error
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<nil>
|
||||
27
internal/dkim/testdata/01-rfc8463.msg
vendored
Normal file
27
internal/dkim/testdata/01-rfc8463.msg
vendored
Normal 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.
|
||||
|
||||
22
internal/dkim/testdata/01-rfc8463.result
vendored
Normal file
22
internal/dkim/testdata/01-rfc8463.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
internal/dkim/testdata/02-too_many_headers.dns
vendored
Symbolic link
1
internal/dkim/testdata/02-too_many_headers.dns
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.dns
|
||||
1
internal/dkim/testdata/02-too_many_headers.error
vendored
Normal file
1
internal/dkim/testdata/02-too_many_headers.error
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<nil>
|
||||
62
internal/dkim/testdata/02-too_many_headers.msg
vendored
Normal file
62
internal/dkim/testdata/02-too_many_headers.msg
vendored
Normal 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.
|
||||
|
||||
5
internal/dkim/testdata/02-too_many_headers.readme
vendored
Normal file
5
internal/dkim/testdata/02-too_many_headers.readme
vendored
Normal 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).
|
||||
|
||||
46
internal/dkim/testdata/02-too_many_headers.result
vendored
Normal file
46
internal/dkim/testdata/02-too_many_headers.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
internal/dkim/testdata/03-bad_message.error
vendored
Normal file
1
internal/dkim/testdata/03-bad_message.error
vendored
Normal file
@@ -0,0 +1 @@
|
||||
invalid header: bad continuation
|
||||
1
internal/dkim/testdata/03-bad_message.msg
vendored
Normal file
1
internal/dkim/testdata/03-bad_message.msg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This is not a valid message.
|
||||
19
internal/dkim/testdata/04-bad_dkim_signature_header.msg
vendored
Normal file
19
internal/dkim/testdata/04-bad_dkim_signature_header.msg
vendored
Normal 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.
|
||||
|
||||
4
internal/dkim/testdata/04-bad_dkim_signature_header.readme
vendored
Normal file
4
internal/dkim/testdata/04-bad_dkim_signature_header.readme
vendored
Normal 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.
|
||||
14
internal/dkim/testdata/04-bad_dkim_signature_header.result
vendored
Normal file
14
internal/dkim/testdata/04-bad_dkim_signature_header.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
internal/dkim/testdata/05-dns_temp_error.dns
vendored
Normal file
6
internal/dkim/testdata/05-dns_temp_error.dns
vendored
Normal 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
|
||||
|
||||
27
internal/dkim/testdata/05-dns_temp_error.msg
vendored
Normal file
27
internal/dkim/testdata/05-dns_temp_error.msg
vendored
Normal 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.
|
||||
|
||||
22
internal/dkim/testdata/05-dns_temp_error.result
vendored
Normal file
22
internal/dkim/testdata/05-dns_temp_error.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
internal/dkim/testdata/06-dns_perm_error.dns
vendored
Normal file
6
internal/dkim/testdata/06-dns_perm_error.dns
vendored
Normal 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
|
||||
|
||||
27
internal/dkim/testdata/06-dns_perm_error.msg
vendored
Normal file
27
internal/dkim/testdata/06-dns_perm_error.msg
vendored
Normal 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.
|
||||
|
||||
22
internal/dkim/testdata/06-dns_perm_error.result
vendored
Normal file
22
internal/dkim/testdata/06-dns_perm_error.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
internal/dkim/testdata/07-algo_mismatch.dns
vendored
Normal file
12
internal/dkim/testdata/07-algo_mismatch.dns
vendored
Normal 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
|
||||
|
||||
1
internal/dkim/testdata/07-algo_mismatch.msg
vendored
Symbolic link
1
internal/dkim/testdata/07-algo_mismatch.msg
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.msg
|
||||
4
internal/dkim/testdata/07-algo_mismatch.readme
vendored
Normal file
4
internal/dkim/testdata/07-algo_mismatch.readme
vendored
Normal 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.
|
||||
1
internal/dkim/testdata/07-algo_mismatch.result
vendored
Symbolic link
1
internal/dkim/testdata/07-algo_mismatch.result
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.result
|
||||
11
internal/dkim/testdata/08-our_signature.dns
vendored
Normal file
11
internal/dkim/testdata/08-our_signature.dns
vendored
Normal 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
|
||||
|
||||
32
internal/dkim/testdata/08-our_signature.msg
vendored
Normal file
32
internal/dkim/testdata/08-our_signature.msg
vendored
Normal 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.
|
||||
|
||||
30
internal/dkim/testdata/08-our_signature.result
vendored
Normal file
30
internal/dkim/testdata/08-our_signature.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
internal/dkim/testdata/09-limited_body.dns
vendored
Symbolic link
1
internal/dkim/testdata/09-limited_body.dns
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
08-our_signature.dns
|
||||
32
internal/dkim/testdata/09-limited_body.msg
vendored
Normal file
32
internal/dkim/testdata/09-limited_body.msg
vendored
Normal 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.
|
||||
|
||||
3
internal/dkim/testdata/09-limited_body.readme
vendored
Normal file
3
internal/dkim/testdata/09-limited_body.readme
vendored
Normal 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.
|
||||
30
internal/dkim/testdata/09-limited_body.result
vendored
Normal file
30
internal/dkim/testdata/09-limited_body.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
internal/dkim/testdata/10-strict_domain_check_pass.dns
vendored
Normal file
8
internal/dkim/testdata/10-strict_domain_check_pass.dns
vendored
Normal 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
|
||||
|
||||
1
internal/dkim/testdata/10-strict_domain_check_pass.msg
vendored
Symbolic link
1
internal/dkim/testdata/10-strict_domain_check_pass.msg
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
01-rfc8463.msg
|
||||
22
internal/dkim/testdata/10-strict_domain_check_pass.result
vendored
Normal file
22
internal/dkim/testdata/10-strict_domain_check_pass.result
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
2
internal/dkim/testdata/11-strict_domain_check_fail.dns
vendored
Normal file
2
internal/dkim/testdata/11-strict_domain_check_fail.dns
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
selector._domainkey.example.com: \
|
||||
v=DKIM1; k=ed25519; t=s; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ=
|
||||
19
internal/dkim/testdata/11-strict_domain_check_fail.msg
vendored
Normal file
19
internal/dkim/testdata/11-strict_domain_check_fail.msg
vendored
Normal 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.
|
||||
|
||||
6
internal/dkim/testdata/11-strict_domain_check_fail.readme
vendored
Normal file
6
internal/dkim/testdata/11-strict_domain_check_fail.readme
vendored
Normal 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.
|
||||
14
internal/dkim/testdata/11-strict_domain_check_fail.result
vendored
Normal file
14
internal/dkim/testdata/11-strict_domain_check_fail.result
vendored
Normal 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
310
internal/dkim/verify.go
Normal 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)
|
||||
}
|
||||
415
internal/dkim/verify_test.go
Normal file
415
internal/dkim/verify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ===
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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=. "$@"
|
||||
|
||||
@@ -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
|
||||
|
||||
1
test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error
Normal file
1
test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error
Normal file
@@ -0,0 +1 @@
|
||||
DKIM loading error: error decoding PEM block
|
||||
9
test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf
Normal file
9
test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf
Normal 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"
|
||||
@@ -0,0 +1 @@
|
||||
Bad key
|
||||
@@ -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
2
test/t-21-dkim/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Ignore the configuration domain directories.
|
||||
?/domains
|
||||
9
test/t-21-dkim/A/chasquid.conf
Normal file
9
test/t-21-dkim/A/chasquid.conf
Normal 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"
|
||||
3
test/t-21-dkim/A/s1._domainkey.srv-a.pem
Normal file
3
test/t-21-dkim/A/s1._domainkey.srv-a.pem
Normal file
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS
|
||||
-----END PRIVATE KEY-----
|
||||
9
test/t-21-dkim/B/chasquid.conf
Normal file
9
test/t-21-dkim/B/chasquid.conf
Normal 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"
|
||||
11
test/t-21-dkim/from_A_to_B
Normal file
11
test/t-21-dkim/from_A_to_B
Normal 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?
|
||||
14
test/t-21-dkim/from_A_to_B.expected
Normal file
14
test/t-21-dkim/from_A_to_B.expected
Normal 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?
|
||||
5
test/t-21-dkim/from_B_to_A
Normal file
5
test/t-21-dkim/from_B_to_A
Normal file
@@ -0,0 +1,5 @@
|
||||
From: user-b@srv-b
|
||||
To: user-a@srv-a
|
||||
Subject: Feliz primavera!
|
||||
|
||||
Espero que florezcas feliz!
|
||||
15
test/t-21-dkim/from_B_to_A.expected
Normal file
15
test/t-21-dkim/from_B_to_A.expected
Normal 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
67
test/t-21-dkim/run.sh
Executable 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
6
test/t-21-dkim/zones
Normal 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=
|
||||
@@ -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.
|
||||
|
||||
@@ -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=. \
|
||||
"$@" \
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user