Skip to content

Commit

Permalink
dkim: Implement internal dkim signing and verification
Browse files Browse the repository at this point in the history
This patch implements internal DKIM signing and verification.
  • Loading branch information
albertito committed Mar 7, 2024
1 parent c6cfe32 commit 615692f
Show file tree
Hide file tree
Showing 88 changed files with 4,834 additions and 109 deletions.
30 changes: 30 additions & 0 deletions chasquid.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (
"net"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions cmd/chasquid-util/chasquid-util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]
Expand Down
260 changes: 260 additions & 0 deletions cmd/chasquid-util/dkim.go
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 4 additions & 1 deletion cmd/chasquid-util/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 615692f

Please sign in to comment.