Files
mailgosend/internal/dkim/dkim.go
T
2026-05-21 20:27:58 +00:00

466 lines
14 KiB
Go

// Package dkim implements DKIM signing (outbound) and verification (inbound)
// for RSA-2048 and Ed25519 keys per RFC 6376 and RFC 8463.
package dkim
import (
gocrypto "crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"net"
"strings"
"time"
)
// Signer holds a loaded private key and signing metadata.
type Signer struct {
privateKey gocrypto.PrivateKey // *rsa.PrivateKey or ed25519.PrivateKey
selector string
domain string
algo string // "rsa2048" | "ed25519"
}
// GenerateKeyPair generates a DKIM key pair for the given algorithm.
// algo must be "rsa2048" or "ed25519".
// Returns PEM-encoded private key and PEM-encoded public key.
func GenerateKeyPair(algo string) (privateKeyPEM, publicKeyPEM string, err error) {
switch algo {
case "rsa2048":
priv, genErr := rsa.GenerateKey(rand.Reader, 2048)
if genErr != nil {
return "", "", fmt.Errorf("dkim: generate RSA key: %w", genErr)
}
privDER := x509.MarshalPKCS1PrivateKey(priv)
privBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: privDER}
privateKeyPEM = string(pem.EncodeToMemory(privBlock))
pubDER, marshalErr := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if marshalErr != nil {
return "", "", fmt.Errorf("dkim: marshal RSA public key: %w", marshalErr)
}
pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}
publicKeyPEM = string(pem.EncodeToMemory(pubBlock))
return privateKeyPEM, publicKeyPEM, nil
case "ed25519":
pub, priv, genErr := ed25519.GenerateKey(rand.Reader)
if genErr != nil {
return "", "", fmt.Errorf("dkim: generate Ed25519 key: %w", genErr)
}
privDER, marshalErr := x509.MarshalPKCS8PrivateKey(priv)
if marshalErr != nil {
return "", "", fmt.Errorf("dkim: marshal Ed25519 private key: %w", marshalErr)
}
privBlock := &pem.Block{Type: "PRIVATE KEY", Bytes: privDER}
privateKeyPEM = string(pem.EncodeToMemory(privBlock))
pubDER, marshalErr := x509.MarshalPKIXPublicKey(pub)
if marshalErr != nil {
return "", "", fmt.Errorf("dkim: marshal Ed25519 public key: %w", marshalErr)
}
pubBlock := &pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}
publicKeyPEM = string(pem.EncodeToMemory(pubBlock))
return privateKeyPEM, publicKeyPEM, nil
default:
return "", "", fmt.Errorf("dkim: unsupported algorithm %q (want rsa2048 or ed25519)", algo)
}
}
// NewSigner parses a PEM private key and returns a Signer.
// Tries PKCS1 RSA first, then PKCS8 (Ed25519 or RSA).
func NewSigner(privateKeyPEM, domain, selector string) (*Signer, error) {
if domain == "" {
return nil, fmt.Errorf("dkim: domain must not be empty")
}
if selector == "" {
return nil, fmt.Errorf("dkim: selector must not be empty")
}
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
return nil, fmt.Errorf("dkim: failed to decode PEM block")
}
// Try PKCS1 RSA.
if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return &Signer{
privateKey: rsaKey,
selector: selector,
domain: domain,
algo: "rsa2048",
}, nil
}
// Try PKCS8 (covers Ed25519 and RSA PKCS8).
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("dkim: parse private key: %w", err)
}
switch k := key.(type) {
case ed25519.PrivateKey:
return &Signer{
privateKey: k,
selector: selector,
domain: domain,
algo: "ed25519",
}, nil
case *rsa.PrivateKey:
return &Signer{
privateKey: k,
selector: selector,
domain: domain,
algo: "rsa2048",
}, nil
default:
return nil, fmt.Errorf("dkim: unsupported private key type %T", key)
}
}
// Sign produces a DKIM-Signature header for the given RFC822 message.
// Returns the complete "DKIM-Signature: ..." header line (no trailing CRLF).
func (s *Signer) Sign(message []byte) (string, error) {
// Split at first blank line (\r\n\r\n or \n\n).
headerBytes, bodyBytes := splitMessage(message)
// Canonicalize body (relaxed).
canonBody := canonicalizeBodyRelaxed(bodyBytes)
// Body hash.
bodyHash := sha256.Sum256(canonBody)
bh := base64.StdEncoding.EncodeToString(bodyHash[:])
// Determine algorithm tag.
var aTag string
switch s.algo {
case "ed25519":
aTag = "ed25519-sha256"
default:
aTag = "rsa-sha256"
}
// Signed header fields (lower-case, in sign order).
signedFields := "from:to:subject:date:message-id"
// Build DKIM-Signature with b= empty.
ts := fmt.Sprintf("%d", time.Now().Unix())
sigHeader := fmt.Sprintf(
"DKIM-Signature: v=1; a=%s; c=relaxed/relaxed; d=%s; s=%s; t=%s; bh=%s; h=%s; b=",
aTag, s.domain, s.selector, ts, bh, signedFields,
)
// Canonicalize headers to sign + the sig header (b= empty).
hdrMap := parseHeaders(headerBytes)
var sb strings.Builder
for _, field := range strings.Split(signedFields, ":") {
field = strings.TrimSpace(field)
val, ok := hdrMap[strings.ToLower(field)]
if !ok {
continue
}
sb.WriteString(canonicalizeHeaderRelaxed(field, val))
sb.WriteString("\r\n")
}
// Append the DKIM-Signature line itself (with b= empty), canonicalized.
sb.WriteString(canonicalizeHeaderRelaxed("dkim-signature", strings.TrimPrefix(sigHeader, "DKIM-Signature: ")))
dataToSign := []byte(sb.String())
hash := sha256.Sum256(dataToSign)
var sigBytes []byte
var signErr error
switch s.algo {
case "ed25519":
privKey, ok := s.privateKey.(ed25519.PrivateKey)
if !ok {
return "", fmt.Errorf("dkim: private key type mismatch for ed25519")
}
// RFC 8463: ed25519-sha256 — sign the SHA-256 hash of the data.
sigBytes = ed25519.Sign(privKey, hash[:])
default:
privKey, ok := s.privateKey.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("dkim: private key type mismatch for RSA")
}
sigBytes, signErr = rsa.SignPKCS1v15(rand.Reader, privKey, gocrypto.SHA256, hash[:])
if signErr != nil {
return "", fmt.Errorf("dkim: RSA sign: %w", signErr)
}
}
b := base64.StdEncoding.EncodeToString(sigBytes)
return sigHeader + b, nil
}
// Verify finds the DKIM-Signature in message, fetches the DNS public key,
// and verifies the signature. Returns the signing domain on success.
func Verify(message []byte) (domain string, err error) {
headerBytes, bodyBytes := splitMessage(message)
hdrMap := parseHeaders(headerBytes)
sigVal, ok := hdrMap["dkim-signature"]
if !ok {
return "", fmt.Errorf("dkim: no DKIM-Signature header found")
}
params := parseDKIMParams(sigVal)
sel, ok := params["s"]
if !ok || sel == "" {
return "", fmt.Errorf("dkim: missing selector (s=) in DKIM-Signature")
}
dom, ok := params["d"]
if !ok || dom == "" {
return "", fmt.Errorf("dkim: missing domain (d=) in DKIM-Signature")
}
bh64, _ := params["bh"]
b64, ok := params["b"]
if !ok {
return "", fmt.Errorf("dkim: missing signature (b=) in DKIM-Signature")
}
signedFields, _ := params["h"]
// Verify body hash.
canonBody := canonicalizeBodyRelaxed(bodyBytes)
bodyHash := sha256.Sum256(canonBody)
expectedBH := base64.StdEncoding.EncodeToString(bodyHash[:])
// Strip whitespace from DNS-retrieved bh for comparison.
cleanBH := strings.Map(func(r rune) rune {
if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
return -1
}
return r
}, bh64)
if cleanBH != expectedBH {
return "", fmt.Errorf("dkim: body hash mismatch")
}
// DNS lookup.
lookupName := sel + "._domainkey." + dom
txts, lookupErr := net.LookupTXT(lookupName)
if lookupErr != nil {
return "", fmt.Errorf("dkim: DNS lookup %s: %w", lookupName, lookupErr)
}
if len(txts) == 0 {
return "", fmt.Errorf("dkim: no TXT record at %s", lookupName)
}
// Join TXT record parts (DNS may split at 255 bytes).
dnsRecord := strings.Join(txts, "")
dnsParams := parseDKIMParams(dnsRecord)
pVal, ok := dnsParams["p"]
if !ok || pVal == "" {
return "", fmt.Errorf("dkim: no p= (public key) in DNS TXT record")
}
pubDER, decErr := base64.StdEncoding.DecodeString(pVal)
if decErr != nil {
return "", fmt.Errorf("dkim: decode public key base64: %w", decErr)
}
// Rebuild the data-to-sign exactly as Signer.Sign did.
// Canonicalize the signed headers.
var sb strings.Builder
for _, field := range strings.Split(signedFields, ":") {
field = strings.TrimSpace(field)
val, ok2 := hdrMap[strings.ToLower(field)]
if !ok2 {
continue
}
sb.WriteString(canonicalizeHeaderRelaxed(field, val))
sb.WriteString("\r\n")
}
// Append DKIM-Signature with b= stripped (zeroed), canonicalized.
cleanedSig := stripBValue(sigVal)
sb.WriteString(canonicalizeHeaderRelaxed("dkim-signature", cleanedSig))
dataToVerify := []byte(sb.String())
hash := sha256.Sum256(dataToVerify)
sigBytes, decErr := base64.StdEncoding.DecodeString(
strings.Map(func(r rune) rune {
if r == ' ' || r == '\t' || r == '\r' || r == '\n' {
return -1
}
return r
}, b64),
)
if decErr != nil {
return "", fmt.Errorf("dkim: decode signature base64: %w", decErr)
}
// Try RSA PKIX public key first.
if pubKey, rsaErr := x509.ParsePKIXPublicKey(pubDER); rsaErr == nil {
switch k := pubKey.(type) {
case *rsa.PublicKey:
if err := rsa.VerifyPKCS1v15(k, gocrypto.SHA256, hash[:], sigBytes); err != nil {
return "", fmt.Errorf("dkim: RSA signature invalid: %w", err)
}
return dom, nil
case ed25519.PublicKey:
if !ed25519.Verify(k, hash[:], sigBytes) {
return "", fmt.Errorf("dkim: Ed25519 signature invalid")
}
return dom, nil
default:
return "", fmt.Errorf("dkim: unsupported public key type %T", pubKey)
}
}
// Try raw Ed25519 public key (32 bytes).
if len(pubDER) == ed25519.PublicKeySize {
edPub := ed25519.PublicKey(pubDER)
if !ed25519.Verify(edPub, hash[:], sigBytes) {
return "", fmt.Errorf("dkim: Ed25519 signature invalid")
}
return dom, nil
}
return "", fmt.Errorf("dkim: unable to parse public key from DNS record")
}
// DNSRecord returns the DKIM TXT record string for publishing.
func DNSRecord(selector, domain, publicKeyPEM string) string {
block, _ := pem.Decode([]byte(publicKeyPEM))
var p string
if block != nil {
p = base64.StdEncoding.EncodeToString(block.Bytes)
}
return fmt.Sprintf("%s._domainkey.%s. IN TXT \"v=DKIM1; k=rsa; p=%s\"", selector, domain, p)
}
// SPFRecord returns the recommended SPF TXT record for a domain.
func SPFRecord(domain string) string {
return fmt.Sprintf("%s IN TXT \"v=spf1 mx a ~all\"", domain)
}
// ---- Internals ----
// splitMessage splits at the first blank line (CRLF or LF variants).
func splitMessage(msg []byte) (headers, body []byte) {
s := string(msg)
for _, sep := range []string{"\r\n\r\n", "\n\n"} {
if idx := strings.Index(s, sep); idx >= 0 {
return []byte(s[:idx]), []byte(s[idx+len(sep):])
}
}
return msg, nil
}
// canonicalizeBodyRelaxed applies RFC 6376 relaxed body canonicalization.
func canonicalizeBodyRelaxed(body []byte) []byte {
if len(body) == 0 {
return []byte("\r\n")
}
// Normalize line endings.
s := strings.ReplaceAll(string(body), "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
lines := strings.Split(s, "\n")
var out []string
for _, line := range lines {
// Collapse whitespace runs within each line, trim trailing whitespace.
fields := strings.Fields(line)
out = append(out, strings.Join(fields, " "))
}
// Strip trailing empty lines.
for len(out) > 0 && out[len(out)-1] == "" {
out = out[:len(out)-1]
}
result := strings.Join(out, "\r\n") + "\r\n"
return []byte(result)
}
// canonicalizeHeaderRelaxed applies RFC 6376 relaxed header canonicalization
// to a single header name + value. Returns "lowername:value" (no trailing CRLF).
func canonicalizeHeaderRelaxed(name, value string) string {
name = strings.ToLower(strings.TrimSpace(name))
// Collapse all whitespace (including CRLF folding) to a single space.
value = strings.ReplaceAll(value, "\r\n", " ")
value = strings.ReplaceAll(value, "\r", " ")
value = strings.ReplaceAll(value, "\n", " ")
// Collapse multiple spaces.
parts := strings.Fields(value)
value = strings.Join(parts, " ")
value = strings.TrimSpace(value)
return name + ":" + value
}
// parseHeaders builds a map of lower-cased header name → last value.
// Handles multi-line (folded) headers.
func parseHeaders(headerBytes []byte) map[string]string {
m := make(map[string]string)
lines := strings.Split(strings.ReplaceAll(string(headerBytes), "\r\n", "\n"), "\n")
var curName, curVal string
flush := func() {
if curName != "" {
m[strings.ToLower(curName)] = curVal
}
}
for _, line := range lines {
if line == "" {
continue
}
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') {
// Folded continuation.
curVal += " " + strings.TrimSpace(line)
continue
}
flush()
idx := strings.IndexByte(line, ':')
if idx < 0 {
curName = ""
curVal = ""
continue
}
curName = line[:idx]
curVal = strings.TrimSpace(line[idx+1:])
}
flush()
return m
}
// parseDKIMParams parses semicolon-separated tag=value pairs (DKIM and DNS TXT).
func parseDKIMParams(s string) map[string]string {
m := make(map[string]string)
for _, part := range strings.Split(s, ";") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
idx := strings.IndexByte(part, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(part[:idx])
val := strings.TrimSpace(part[idx+1:])
m[key] = val
}
return m
}
// stripBValue removes the value of the b= tag (sets it to empty) so the
// header can be re-canonicalized for verification.
func stripBValue(sigVal string) string {
// Find b= and zero everything after it up to the next ;.
parts := strings.Split(sigVal, ";")
for i, p := range parts {
trimmed := strings.TrimSpace(p)
if strings.HasPrefix(trimmed, "b=") {
parts[i] = " b="
}
}
return strings.Join(parts, ";")
}