dkim.go raw

   1  package smtp
   2  
   3  import (
   4  	"bytes"
   5  	"crypto"
   6  	"crypto/rand"
   7  	"crypto/rsa"
   8  	"crypto/x509"
   9  	"encoding/pem"
  10  	"fmt"
  11  	"os"
  12  
  13  	"github.com/emersion/go-msgauth/dkim"
  14  	"next.orly.dev/pkg/lol/log"
  15  )
  16  
  17  // DKIMSigner signs outbound emails with a DKIM signature.
  18  type DKIMSigner struct {
  19  	domain   string
  20  	selector string
  21  	key      crypto.Signer
  22  }
  23  
  24  // NewDKIMSigner creates a DKIM signer from a PEM-encoded RSA private key file.
  25  func NewDKIMSigner(domain, selector, keyPath string) (*DKIMSigner, error) {
  26  	data, err := os.ReadFile(keyPath)
  27  	if err != nil {
  28  		return nil, fmt.Errorf("read DKIM key: %w", err)
  29  	}
  30  
  31  	block, _ := pem.Decode(data)
  32  	if block == nil {
  33  		return nil, fmt.Errorf("no PEM block found in %s", keyPath)
  34  	}
  35  
  36  	var key crypto.Signer
  37  	switch block.Type {
  38  	case "RSA PRIVATE KEY":
  39  		rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
  40  		if err != nil {
  41  			return nil, fmt.Errorf("parse RSA key: %w", err)
  42  		}
  43  		key = rsaKey
  44  	case "PRIVATE KEY":
  45  		parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
  46  		if err != nil {
  47  			return nil, fmt.Errorf("parse PKCS8 key: %w", err)
  48  		}
  49  		rsaKey, ok := parsed.(*rsa.PrivateKey)
  50  		if !ok {
  51  			return nil, fmt.Errorf("PKCS8 key is not RSA")
  52  		}
  53  		key = rsaKey
  54  	default:
  55  		return nil, fmt.Errorf("unsupported PEM type: %s", block.Type)
  56  	}
  57  
  58  	return &DKIMSigner{
  59  		domain:   domain,
  60  		selector: selector,
  61  		key:      key,
  62  	}, nil
  63  }
  64  
  65  // NewDKIMSignerFromPEM creates a DKIM signer from PEM-encoded key bytes.
  66  func NewDKIMSignerFromPEM(domain, selector string, pemData []byte) (*DKIMSigner, error) {
  67  	block, _ := pem.Decode(pemData)
  68  	if block == nil {
  69  		return nil, fmt.Errorf("no PEM block found")
  70  	}
  71  	rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
  72  	if err != nil {
  73  		// Try PKCS8
  74  		parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
  75  		if err2 != nil {
  76  			return nil, fmt.Errorf("parse key: %w", err)
  77  		}
  78  		key, ok := parsed.(*rsa.PrivateKey)
  79  		if !ok {
  80  			return nil, fmt.Errorf("PKCS8 key is not RSA")
  81  		}
  82  		return &DKIMSigner{domain: domain, selector: selector, key: key}, nil
  83  	}
  84  	return &DKIMSigner{domain: domain, selector: selector, key: rsaKey}, nil
  85  }
  86  
  87  // NewDKIMSignerFromKey creates a DKIM signer from an in-memory key.
  88  // Useful for testing.
  89  func NewDKIMSignerFromKey(domain, selector string, key crypto.Signer) *DKIMSigner {
  90  	return &DKIMSigner{
  91  		domain:   domain,
  92  		selector: selector,
  93  		key:      key,
  94  	}
  95  }
  96  
  97  // Sign adds a DKIM-Signature header to the message.
  98  // Returns the signed message bytes.
  99  func (ds *DKIMSigner) Sign(msg []byte) ([]byte, error) {
 100  	opts := &dkim.SignOptions{
 101  		Domain:                 ds.domain,
 102  		Selector:               ds.selector,
 103  		Signer:                 ds.key,
 104  		Hash:                   crypto.SHA256,
 105  		HeaderCanonicalization: dkim.CanonicalizationRelaxed,
 106  		BodyCanonicalization:   dkim.CanonicalizationRelaxed,
 107  		HeaderKeys: []string{
 108  			"from", "to", "cc", "subject", "date", "message-id",
 109  			"mime-version", "content-type",
 110  		},
 111  	}
 112  
 113  	var signed bytes.Buffer
 114  	if err := dkim.Sign(&signed, bytes.NewReader(msg), opts); err != nil {
 115  		return nil, fmt.Errorf("DKIM sign: %w", err)
 116  	}
 117  
 118  	log.D.F("DKIM signed message for domain=%s selector=%s", ds.domain, ds.selector)
 119  	return signed.Bytes(), nil
 120  }
 121  
 122  // GenerateDKIMKeyPair generates a new RSA-2048 key pair for DKIM signing.
 123  // Returns the private key PEM and the public key DNS TXT record value.
 124  func GenerateDKIMKeyPair() (privateKeyPEM []byte, dnsRecord string, err error) {
 125  	key, err := rsa.GenerateKey(rand.Reader, 2048)
 126  	if err != nil {
 127  		return nil, "", fmt.Errorf("generate RSA key: %w", err)
 128  	}
 129  
 130  	// Encode private key
 131  	privPEM := pem.EncodeToMemory(&pem.Block{
 132  		Type:  "RSA PRIVATE KEY",
 133  		Bytes: x509.MarshalPKCS1PrivateKey(key),
 134  	})
 135  
 136  	// Encode public key for DNS TXT record
 137  	pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
 138  	if err != nil {
 139  		return nil, "", fmt.Errorf("marshal public key: %w", err)
 140  	}
 141  
 142  	pubPEM := pem.EncodeToMemory(&pem.Block{
 143  		Type:  "PUBLIC KEY",
 144  		Bytes: pubDER,
 145  	})
 146  
 147  	// Format as DNS TXT record value: strip PEM headers, join lines
 148  	lines := bytes.Split(pubPEM, []byte("\n"))
 149  	var pubB64 []byte
 150  	for _, line := range lines {
 151  		if len(line) == 0 || line[0] == '-' {
 152  			continue
 153  		}
 154  		pubB64 = append(pubB64, line...)
 155  	}
 156  
 157  	dns := fmt.Sprintf("v=DKIM1; k=rsa; p=%s", string(pubB64))
 158  
 159  	return privPEM, dns, nil
 160  }
 161