1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-17 14:37:02 +00:00

dkim: Implement internal dkim signing and verification

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

View File

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

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

@@ -0,0 +1,260 @@
package main
import (
"bytes"
"context"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/mail"
"os"
"path"
"path/filepath"
"strings"
"time"
"blitiri.com.ar/go/chasquid/internal/dkim"
"blitiri.com.ar/go/chasquid/internal/envelope"
"blitiri.com.ar/go/chasquid/internal/normalize"
)
func dkimSign() {
domain := args["$2"]
selector := args["$3"]
keyPath := args["$4"]
msg, err := io.ReadAll(os.Stdin)
if err != nil {
Fatalf("%v", err)
}
msg = normalize.ToCRLF(msg)
if domain == "" {
domain = getDomainFromMsg(msg)
}
if selector == "" {
selector = findSelectorForDomain(domain)
}
if keyPath == "" {
keyPath = keyPathFor(domain, selector)
}
signer := &dkim.Signer{
Domain: domain,
Selector: selector,
Signer: loadPrivateKey(keyPath),
}
ctx := context.Background()
if _, verbose := args["-v"]; verbose {
ctx = dkim.WithTraceFunc(ctx,
func(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
})
}
header, err := signer.Sign(ctx, string(msg))
if err != nil {
Fatalf("Error signing message: %v", err)
}
fmt.Printf("DKIM-Signature: %s\r\n",
strings.ReplaceAll(header, "\r\n", "\r\n\t"))
}
func dkimVerify() {
msg, err := io.ReadAll(os.Stdin)
if err != nil {
Fatalf("%v", err)
}
msg = normalize.ToCRLF(msg)
ctx := context.Background()
if _, verbose := args["-v"]; verbose {
ctx = dkim.WithTraceFunc(ctx,
func(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
})
}
results, err := dkim.VerifyMessage(ctx, string(msg))
if err != nil {
Fatalf("Error verifying message: %v", err)
}
hostname, _ := os.Hostname()
ar := "Authentication-Results: " + hostname + "\r\n\t"
ar += strings.ReplaceAll(
results.AuthenticationResults(), "\r\n", "\r\n\t")
fmt.Println(ar)
}
func dkimDNS() {
domain := args["$2"]
selector := args["$3"]
keyPath := args["$4"]
if domain == "" {
Fatalf("Error: missing domain parameter")
}
if selector == "" {
selector = findSelectorForDomain(domain)
}
if keyPath == "" {
keyPath = keyPathFor(domain, selector)
}
fmt.Println(dnsRecordFor(domain, selector, loadPrivateKey(keyPath)))
}
func dnsRecordFor(domain, selector string, private crypto.Signer) string {
public := private.Public()
var err error
algoStr := ""
pubBytes := []byte{}
switch private.(type) {
case *rsa.PrivateKey:
algoStr = "rsa"
pubBytes, err = x509.MarshalPKIXPublicKey(public)
case ed25519.PrivateKey:
algoStr = "ed25519"
pubBytes = public.(ed25519.PublicKey)
}
if err != nil {
Fatalf("Error marshaling public key: %v", err)
}
return fmt.Sprintf(
"%s._domainkey.%s\tTXT\t\"v=DKIM1; k=%s; p=%s\"",
selector, domain,
algoStr, base64.StdEncoding.EncodeToString(pubBytes))
}
func dkimKeygen() {
domain := args["$2"]
selector := args["$3"]
keyPath := args["$4"]
algo := args["--algo"]
if domain == "" {
Fatalf("Error: missing domain parameter")
}
if selector == "" {
selector = time.Now().UTC().Format("20060102")
}
if keyPath == "" {
keyPath = keyPathFor(domain, selector)
}
if _, err := os.Stat(keyPath); !os.IsNotExist(err) {
Fatalf("Error: key already exists at %q", keyPath)
}
var private crypto.Signer
var err error
switch algo {
case "", "rsa3072":
private, err = rsa.GenerateKey(rand.Reader, 3072)
case "rsa4096":
private, err = rsa.GenerateKey(rand.Reader, 4096)
case "ed25519":
_, private, err = ed25519.GenerateKey(rand.Reader)
default:
Fatalf("Error: unsupported algorithm %q", algo)
}
if err != nil {
Fatalf("Error generating key: %v", err)
}
privB, err := x509.MarshalPKCS8PrivateKey(private)
if err != nil {
Fatalf("Error marshaling private key: %v", err)
}
f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
if err != nil {
Fatalf("Error creating key file %q: %v", keyPath, err)
}
block := &pem.Block{
Type: "PRIVATE KEY",
Bytes: privB,
}
if err := pem.Encode(f, block); err != nil {
Fatalf("Error PEM-encoding key: %v", err)
}
f.Close()
fmt.Printf("Key written to %q\n\n", keyPath)
fmt.Println(dnsRecordFor(domain, selector, private))
}
func keyPathFor(domain, selector string) string {
return path.Clean(fmt.Sprintf("%s/domains/%s/dkim:%s.pem",
configDir, domain, selector))
}
func getDomainFromMsg(msg []byte) string {
m, err := mail.ReadMessage(bytes.NewReader(msg))
if err != nil {
Fatalf("Error parsing message: %v", err)
}
addr, err := mail.ParseAddress(m.Header.Get("From"))
if err != nil {
Fatalf("Error parsing From: header: %v", err)
}
return envelope.DomainOf(addr.Address)
}
func findSelectorForDomain(domain string) string {
glob := path.Clean(configDir + "/domains/" + domain + "/dkim:*.pem")
ms, err := filepath.Glob(glob)
if err != nil {
Fatalf("Error finding DKIM keys: %v", err)
}
for _, m := range ms {
base := filepath.Base(m)
selector := strings.TrimPrefix(base, "dkim:")
selector = strings.TrimSuffix(selector, ".pem")
return selector
}
Fatalf("No DKIM keys found in %q", glob)
return ""
}
func loadPrivateKey(path string) crypto.Signer {
key, err := os.ReadFile(path)
if err != nil {
Fatalf("Error reading private key from %q: %v", path, err)
}
block, _ := pem.Decode(key)
if block == nil {
Fatalf("Error decoding PEM block")
}
switch strings.ToUpper(block.Type) {
case "PRIVATE KEY":
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
Fatalf("Error parsing private key: %v", err)
}
return k.(crypto.Signer)
default:
Fatalf("Unsupported key type: %s", block.Type)
return nil
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCJ5laGXt2fEcbK
5xlLb53ITE9DK2P9pjjpFcnjJPfNZjyCHliRVnYvXH77tczPNKD/kVHluQphX1dr
6M7piWGuJgqje21ZKbFTSEMpsbt2kP1Ovu2MJyYqoTUhd62XoNeVCXstF2aR4g0P
iC1/0DXM48tHf/6S+y7W7ZxSwQVWzfoWptN7gFgufZFCVPghChxPl5US+gdzlqkq
5M4oFhOGFZFpJkb1he+x13VSOeBHZeaLO7l7+GPZ/db72uZFHLW6SZSvK4xQcxp7
ZXbtbLW+seJigxQDWRAlj4dMbmQyS5q3E26L3KOZ2qtBS8IhTYVrmPElhBMsjPhT
T/Pi1K1HAgMBAAECggEAJRKywk8wv7oUuqnkh/5K6fVx/bnlmOSeOjOsYg+nOyY4
MDceUnxvK45vaRZYKICao/qajOrxWno6U310Wx6fDyWVCJx/KlBmJuCvhb8NifOy
1f/IdzxzK1TJpuS426HXM28oGVhIMAIYxssyiEEepaW8Gc3UUAmNbyTUOP9BgzNZ
8qH5PA5MTTSiC1ql96b5otKPTlizxT13d3MYeSBN4b31Kb/AYRNSZlyOSBFCwcqf
qeZEV4cwILX+58PYwfGGRYQWbCT62ZOs5AWiPt/cH9bZg7Gk1GqNx8HKFYaq+QHq
hzXkiAjDZrANuK+xeQERuAWViagtX/qtNsQJwAJP6QKBgQDAJxGCYXxv//eM09uU
DBz3jrAvROPylrX+eifoleWtdHnBHXcn9G3uNwOSpVS36PcspeH44w2B/WpzDsWn
HjVWP2UmeWvPMZsY81Kxd4KINB/l+z03ctYuus80UJmYH70bkJ2uxLWioU1e/Edf
ruMGx16ZdBVOCWJ7BtrUc41dswKBgQC3uGZ9QdVoEMDB7dFKl5foYqHE51p4ruMv
Rpb5peFQJIdbbCUSaNN9swtDemktf0OnPyGMNLogGBZ/fhf8N2QX5+OwvQeh01Mu
vPCFUZ4sNXv7lPPCwj23SmoMd1Z/RdksAlF8kHVBOsHrNurPUqkbhKLChuiAAKDC
S0qdoAKwHQKBgQCsqe6X5BW3ZqEBkNX8wK2+3h7/Or5CHJ9JHmeCHkAWj1Vg7KNH
6eJmblTtj1cDM3n4Ss81oIFgz2C6JwoA06pF6A1ydyUjN4YQ84TZJ3TKA1yuggZO
Lwi7UO4kKlD6W3rIrDik9OnqS1uFANj55+LlEn21EpSaXOB7gHte8L6U9QKBgEy8
I2qbzbPak3gsiacbLCKu15xzeTFA8rjzRend4/7iUvrXb6CB0hwFZWX4wedz6WD4
mF2ERF1VUkhL9V6uEAuAGnTeb0qjBnJWDivRDDyw1ikdbLbjBH4DAcpVKfacyPl9
umVJvP/St94zoN2ZS/KncofHa2LTYFHmurKde6HtAoGBAIGZHOxJF856GJlq3otA
9wGGkNpmlVhHdYYvRKCMRr1FcduCrWFrr5zZT/fb6eHSoCtYjsiqRB/j6STgnBiX
2jSsPRadUrpyZOkINTl16vC6Bnv4plfP3VIBQAIoD9ViP0v9w8VrQyIGXWAeSHcu
eXZyxHh81OEU8M2hWKZf54UI
-----END PRIVATE KEY-----