all repos — postern @ main

Modern mail management

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}