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()))
}