internal/dkim/sign.go (view raw)
1package dkim
2
3import (
4 "bytes"
5 "context"
6 "crypto"
7 "fmt"
8 "io"
9 "log/slog"
10 "net/mail"
11 "strings"
12 "time"
13
14 "github.com/emersion/go-msgauth/dkim"
15 "go.turscar.ie/dkim2"
16)
17
18func rfc6376Section541() []string {
19 return []string{
20 "From",
21 "Reply-To",
22 "Subject",
23 "Date",
24 "To",
25 "Cc",
26 "Resent-Date",
27 "Resent-From",
28 "Resent-To",
29 "Resent-Cc",
30 "In-Reply-To",
31 "References",
32 "List-Id",
33 "List-Help",
34 "List-Unsubscribe",
35 "List-Subscribe",
36 "List-Post",
37 "List-Owner",
38 "List-Archive",
39 }
40}
41
42// Sign signs the message with the first enabled selector for the From: domain and returns the signed message.
43func (d *DKIM) Sign(ctx context.Context, message []byte, mailFrom string, rcptTo []string) ([]byte, error) {
44 // TODO check if we can make this a zero-copy operation with io.Reader instead of message []byte
45 // for DMARC alignment, we first need to find the From: domain
46 msg, err := mail.ReadMessage(bytes.NewReader(message))
47 if err != nil {
48 return nil, fmt.Errorf("parsing message: %w", err)
49 }
50
51 fromHeader := msg.Header.Get("From")
52 if fromHeader == "" {
53 return nil, fmt.Errorf("from header is empty")
54 }
55 parsedFromHeader, err := mail.ParseAddress(fromHeader)
56 if err != nil {
57 return nil, fmt.Errorf("parsing from header: %w", err)
58 }
59 i := strings.LastIndex(parsedFromHeader.Address, "@")
60 if i == -1 {
61 return nil, fmt.Errorf("from header does not contain @")
62 }
63 domain := parsedFromHeader.Address[i+1:]
64
65 slog.Info("[DKIM] signing based on From: header", "domain", domain, "from", fromHeader)
66
67 signer, selector, err := d.retrievePrivateKey(ctx, "", domain)
68 if err != nil {
69 return nil, fmt.Errorf("reading signing key: %w", err)
70 }
71
72 var buffer bytes.Buffer
73 _, err = io.Copy(&buffer, bytes.NewReader(message))
74 if err != nil {
75 return nil, fmt.Errorf("copy buffer: %w", err)
76 }
77
78 err = dkim.Sign(&buffer, &buffer, &dkim.SignOptions{
79 Domain: domain,
80 Selector: selector,
81 Identifier: "",
82 Signer: signer.(crypto.Signer),
83 Hash: crypto.SHA256,
84 HeaderCanonicalization: dkim.CanonicalizationSimple,
85 BodyCanonicalization: dkim.CanonicalizationSimple,
86 HeaderKeys: rfc6376Section541(),
87 Expiration: time.Time{},
88 QueryMethods: nil,
89 })
90 if err != nil {
91 return nil, fmt.Errorf("signing DKIM: %w", err)
92 }
93
94 err = dkim2.Sign(&buffer, &buffer, dkim2.SignOptions{
95 Timestamp: time.Now().UTC().Unix(),
96 Domain: domain,
97 Exploded: false,
98 DoNotExplode: true,
99 DoNotModify: true,
100 Feedback: false,
101 FeedHere: false,
102 MailFrom: mailFrom,
103 RcptTo: rcptTo,
104 Keys: []dkim2.SigningKey{{
105 Selector: selector,
106 Signer: signer.(crypto.Signer),
107 }},
108 })
109 if err != nil {
110 return nil, fmt.Errorf("signing DKIM2: %w", err)
111 }
112
113 return buffer.Bytes(), nil
114}