git » chasquid » master » tree

[master] / internal / dkim / sign.go

package dkim

import (
	"context"
	"crypto"
	"crypto/ed25519"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"encoding/base64"
	"fmt"
	"strings"
	"time"
)

type Signer struct {
	// Domain to sign for.
	Domain string

	// Selector to use.
	Selector string

	// Signer containing the private key.
	// This can be an *rsa.PrivateKey or a ed25519.PrivateKey.
	Signer crypto.Signer
}

var headersToSign = []string{
	// https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.1
	"From", // Required.
	"Reply-To",
	"Subject",
	"Date",
	"To", "Cc",
	"Resent-Date", "Resent-From", "Resent-To", "Resent-Cc",
	"In-Reply-To", "References",
	"List-Id", "List-Help", "List-Unsubscribe", "List-Subscribe", "List-Post",
	"List-Owner", "List-Archive",

	// Our additions.
	"Message-ID",
}

var extraHeadersToSign = []string{
	// Headers to add an extra of, to prevent additions after signing.
	// If they're included here, they must be in headersToSign too.
	"From",
	"Subject", "Date",
	"To", "Cc",
	"Message-ID",
}

// Sign the given message. Returns the *value* of the DKIM-Signature header to
// be added to the message. It will usually be multi-line, but without
// indenting.
func (s *Signer) Sign(ctx context.Context, message string) (string, error) {
	headers, body, err := parseMessage(message)
	if err != nil {
		return "", err
	}

	algoStr, err := s.algoStr()
	if err != nil {
		return "", err
	}

	trace(ctx, "Signing for %s / %s with %s", s.Domain, s.Selector, algoStr)

	dkimSignature := fmt.Sprintf(
		"v=1; a=%s; c=relaxed/relaxed;\r\n", algoStr)
	dkimSignature += fmt.Sprintf(
		"d=%s; s=%s; t=%d;\r\n", s.Domain, s.Selector, time.Now().Unix())

	// Add the headers to sign.
	hsForHeader := []string{}
	for _, h := range headersToSign {
		// Append the header as many times as it appears in the message.
		for i := 0; i < len(headers.FindAll(h)); i++ {
			hsForHeader = append(hsForHeader, h)
		}
	}
	hsForHeader = append(hsForHeader, extraHeadersToSign...)

	dkimSignature += fmt.Sprintf(
		"h=%s;\r\n", formatHeaders(hsForHeader))

	// Compute and add bh= (body hash).
	bodyH := sha256.Sum256([]byte(
		relaxedCanonicalization.body(body)))
	dkimSignature += fmt.Sprintf(
		"bh=%s;\r\n", base64.StdEncoding.EncodeToString(bodyH[:]))

	// Compute b= (signature).
	// First, the canonicalized headers.
	b := sha256.New()
	for _, h := range headersToSign {
		for _, header := range headers.FindAll(h) {
			hsrc := relaxedCanonicalization.header(header).Source + "\r\n"
			trace(ctx, "Hashing header: %q", hsrc)
			b.Write([]byte(hsrc))
		}
	}

	// Now, the (canonicalized) DKIM-Signature header itself, but with an
	// empty b= tag, without a trailing \r\n, and ending with ";".
	// We include the ";" because we will add it at the end (see below). It is
	// legal not to include that final ";", we just choose to do so.
	// We replace \r\n with \r\n\t so the canonicalization considers them
	// proper continuations, and works correctly.
	dkimSignature += "b="
	dkimSignatureForSigning := strings.ReplaceAll(
		dkimSignature, "\r\n", "\r\n\t") + ";"
	relaxedDH := relaxedCanonicalization.header(header{
		Name:   "DKIM-Signature",
		Value:  dkimSignatureForSigning,
		Source: dkimSignatureForSigning,
	})
	b.Write([]byte(relaxedDH.Source))
	trace(ctx, "Hashing header: %q", relaxedDH.Source)
	bSum := b.Sum(nil)
	trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum))

	// Finally, sign the hash.
	sig, err := s.sign(bSum)
	if err != nil {
		return "", err
	}
	sigb64 := base64.StdEncoding.EncodeToString(sig)

	dkimSignature += breakLongLines(sigb64) + ";"

	return dkimSignature, nil
}

func (s *Signer) algoStr() (string, error) {
	switch k := s.Signer.(type) {
	case *rsa.PrivateKey:
		return "rsa-sha256", nil
	case ed25519.PrivateKey:
		return "ed25519-sha256", nil
	default:
		return "", fmt.Errorf("%w: %T", errUnsupportedKeyType, k)
	}
}

func (s *Signer) sign(bSum []byte) ([]byte, error) {
	var h crypto.Hash
	switch s.Signer.(type) {
	case *rsa.PrivateKey:
		h = crypto.SHA256
	case ed25519.PrivateKey:
		h = crypto.Hash(0)
	}

	return s.Signer.Sign(rand.Reader, bSum, h)
}

func breakLongLines(s string) string {
	// Break long lines, indenting with 2 spaces for continuation (to make
	// it clear it's under the same tag).
	const limit = 70
	var sb strings.Builder
	for len(s) > 0 {
		if len(s) > limit {
			sb.WriteString(s[:limit])
			sb.WriteString("\r\n  ")
			s = s[limit:]
		} else {
			sb.WriteString(s)
			s = ""
		}
	}
	return sb.String()
}

func formatHeaders(hs []string) string {
	// Format the list of headers for inclusion in the DKIM-Signature header.
	// This includes converting them to lowercase, and line-wrapping.
	// Extra lines will be indented with 2 spaces, to make it clear they're
	// under the same tag.
	const limit = 70
	var sb strings.Builder
	line := ""
	for i, h := range hs {
		if len(line)+1+len(h) > limit {
			sb.WriteString(line + "\r\n  ")
			line = ""
		}

		if i > 0 {
			line += ":"
		}
		line += h
	}
	sb.WriteString(line)

	return strings.TrimSpace(strings.ToLower(sb.String()))
}