466 lines
14 KiB
Go
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, ";")
|
|
}
|