// Package dkim implements DKIM (RFC 6376) signing for outbound mail using // only stdlib crypto — no third-party DKIM library. Verification of inbound // DKIM signatures is added in Phase 4's security pipeline. package dkim import ( "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" ) // KeyPair holds a freshly generated DKIM signing key, both as PEM (for // encrypted storage) and the DNS TXT record value the operator must publish. type KeyPair struct { PrivateKeyPEM []byte // PKCS#1 PEM — store encrypted in domains.dkim_private_key_enc DNSRecordValue string // paste into: {selector}._domainkey.{domain} TXT record } // GenerateKeyPair creates a new RSA-2048 DKIM key pair. RSA-2048 is used // (rather than Ed25519) because it has universal support across mail // receivers — Ed25519 DKIM (RFC 8463) support is not yet ubiquitous. func GenerateKeyPair() (*KeyPair, error) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, fmt.Errorf("generating RSA key: %w", err) } privDER := x509.MarshalPKCS1PrivateKey(priv) privPEM := pem.EncodeToMemory(&pem.Block{ Type: "RSA PRIVATE KEY", Bytes: privDER, }) pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) if err != nil { return nil, fmt.Errorf("marshaling public key: %w", err) } pubB64 := base64.StdEncoding.EncodeToString(pubDER) dnsValue := fmt.Sprintf("v=DKIM1; k=rsa; p=%s", pubB64) return &KeyPair{ PrivateKeyPEM: privPEM, DNSRecordValue: dnsValue, }, nil } // ParsePrivateKey decodes a PEM-encoded RSA private key (as produced by // GenerateKeyPair, after decryption from storage). func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { return nil, fmt.Errorf("no PEM block found") } key, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("parsing RSA private key: %w", err) } return key, nil } // ExtractSignatureInfo pulls the signing domain and selector out of a // message's DKIM-Signature header, without doing any verification — the // caller uses this to know which DNS TXT record to fetch before calling // Verify. Returns found=false if no DKIM-Signature header is present. func ExtractSignatureInfo(raw []byte) (domain, selector string, found bool) { headers, _ := splitMessage(raw) headerMap := parseHeaders(headers) sigHeader, ok := headerMap["dkim-signature"] if !ok { return "", "", false } tags := parseDKIMTags(sigHeader) domain = tags["d"] selector = tags["s"] return domain, selector, domain != "" && selector != "" } // ParseDNSPublicKey decodes the "p=" tag value from a DKIM DNS TXT record // (as published by GenerateKeyPair's DNSRecordValue, or any RFC 6376 // compliant record) into the raw public key DER bytes Verify expects. func ParseDNSPublicKey(txtRecord string) ([]byte, error) { tags := parseDKIMTags(txtRecord) pValue, ok := tags["p"] if !ok || pValue == "" { return nil, fmt.Errorf("no p= tag found in DNS record") } return base64.StdEncoding.DecodeString(pValue) }