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)
16
17func rfc6376Section541() []string {
18 return []string{
19 "From",
20 "Reply-To",
21 "Subject",
22 "Date",
23 "To",
24 "Cc",
25 "Resent-Date",
26 "Resent-From",
27 "Resent-To",
28 "Resent-Cc",
29 "In-Reply-To",
30 "References",
31 "List-Id",
32 "List-Help",
33 "List-Unsubscribe",
34 "List-Subscribe",
35 "List-Post",
36 "List-Owner",
37 "List-Archive",
38 }
39}
40
41// Sign signs the message with the first enabled selector for the From: domain and returns the signed message.
42func (d *DKIM) Sign(ctx context.Context, message []byte) ([]byte, error) {
43 // TODO check if we can make this a zero-copy operation with io.Reader instead of message []byte
44 // for DMARC alignment, we first need to find the From: domain
45 msg, err := mail.ReadMessage(bytes.NewReader(message))
46 if err != nil {
47 return nil, fmt.Errorf("parsing message: %w", err)
48 }
49
50 fromHeader := msg.Header.Get("From")
51 if fromHeader == "" {
52 return nil, fmt.Errorf("from header is empty")
53 }
54 parsedFromHeader, err := mail.ParseAddress(fromHeader)
55 if err != nil {
56 return nil, fmt.Errorf("parsing from header: %w", err)
57 }
58 i := strings.LastIndex(parsedFromHeader.Address, "@")
59 if i == -1 {
60 return nil, fmt.Errorf("from header does not contain @")
61 }
62 domain := parsedFromHeader.Address[i+1:]
63
64 slog.Info("[DKIM] signing based on From: header", "domain", domain, "from", fromHeader)
65
66 dRec, err := d.db.GetDKIMForDomain(ctx, domain)
67 if err != nil {
68 return nil, fmt.Errorf("get DKIM for domain: %w", err)
69 }
70
71 signer, err := d.readPrivateKey(dRec.BlobAddress)
72 if err != nil {
73 return nil, fmt.Errorf("reading signing key: %w", err)
74 }
75
76 var buffer bytes.Buffer
77 _, err = io.Copy(&buffer, bytes.NewReader(message))
78 if err != nil {
79 return nil, fmt.Errorf("copy buffer: %w", err)
80 }
81
82 err = dkim.Sign(&buffer, &buffer, &dkim.SignOptions{
83 Domain: domain,
84 Selector: dRec.Selector,
85 Identifier: "",
86 Signer: signer.(crypto.Signer),
87 Hash: crypto.SHA256,
88 HeaderCanonicalization: dkim.CanonicalizationSimple,
89 BodyCanonicalization: dkim.CanonicalizationSimple,
90 HeaderKeys: rfc6376Section541(),
91 Expiration: time.Time{},
92 QueryMethods: nil,
93 })
94 if err != nil {
95 return nil, fmt.Errorf("creating signer: %w", err)
96 }
97
98 return buffer.Bytes(), nil
99}
100
101// readPrivateKey loads and decodes the private key blob referenced by nonceIdentifier.
102func (d *DKIM) readPrivateKey(nonceIdentifier string) (privateKey, error) {
103 pk, err := d.persistence.BlobReader(nonceIdentifier)
104 if err != nil {
105 return nil, err
106 }
107 defer func(privKey io.ReadCloser) {
108 _ = privKey.Close()
109 }(pk)
110
111 privKeyBytes, err := io.ReadAll(pk)
112 if err != nil {
113 return nil, err
114 }
115
116 return decodePrivateKey(string(privKeyBytes))
117}