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