git » chasquid » disable-domaininfo-incoming » tree

[disable-domaininfo-incoming] / internal / dkim / verify.go

package dkim

import (
	"bytes"
	"context"
	"crypto"
	"encoding/base64"
	"errors"
	"fmt"
	"net"
	"regexp"
	"slices"
	"strings"
)

// These two errors are returned when the verification fails, but the header
// is considered valid.
var (
	ErrBodyHashMismatch   = errors.New("body hash mismatch")
	ErrVerificationFailed = errors.New("verification failed")
)

// Evaluation states, as per
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.9.
type EvaluationState string

const (
	SUCCESS  EvaluationState = "SUCCESS"
	PERMFAIL EvaluationState = "PERMFAIL"
	TEMPFAIL EvaluationState = "TEMPFAIL"
)

type VerifyResult struct {
	// How many signatures were found.
	Found uint

	// How many signatures were verified successfully.
	Valid uint

	// The details for each signature that was found.
	Results []*OneResult
}

type OneResult struct {
	// The raw signature header.
	SignatureHeader string

	// Domain and selector from the signature header.
	Domain   string
	Selector string

	// Base64-encoded signature. May be missing if it is not present in the
	// header.
	B string

	// The result of the evaluation.
	State EvaluationState
	Error error
}

// Returns the DKIM-specific contents for an Authentication-Results header.
// It is just the contents, the header needs to still be constructed.
// Note that the output will need to be indented by the caller.
// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
func (r *VerifyResult) AuthenticationResults() string {
	// The weird placement of the ";" is due to the specification saying they
	// have to be before each method, not at the end.
	// By doing it this way, we can concate the output of this function with
	// other results.
	ar := &strings.Builder{}
	if r.Found == 0 {
		// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
		ar.WriteString(";dkim=none\r\n")
		return ar.String()
	}

	for _, res := range r.Results {
		// Map state to the corresponding result.
		// https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1
		switch res.State {
		case SUCCESS:
			ar.WriteString(";dkim=pass")
		case TEMPFAIL:
			// The reason must come before the properties, include it here.
			fmt.Fprintf(ar, ";dkim=temperror  reason=%q\r\n", res.Error)
		case PERMFAIL:
			// The reason must come before the properties, include it here.
			if errors.Is(res.Error, ErrVerificationFailed) ||
				errors.Is(res.Error, ErrBodyHashMismatch) {
				fmt.Fprintf(ar, ";dkim=fail  reason=%q\r\n", res.Error)
			} else {
				fmt.Fprintf(ar, ";dkim=permerror  reason=%q\r\n", res.Error)
			}
		}

		if res.B != "" {
			// Include a partial b= tag to help identify which signature
			// is being referred to.
			// https://datatracker.ietf.org/doc/html/rfc6008#section-4
			fmt.Fprintf(ar, "  header.b=%.12s", res.B)
		}

		ar.WriteString("  header.d=" + res.Domain + "\r\n")
	}

	return ar.String()
}

func VerifyMessage(ctx context.Context, message string) (*VerifyResult, error) {
	// https://datatracker.ietf.org/doc/html/rfc6376#section-6
	headers, body, err := parseMessage(message)
	if err != nil {
		trace(ctx, "Error parsing message: %v", err)
		return nil, err
	}

	results := &VerifyResult{
		Results: []*OneResult{},
	}

	for i, sig := range headers.FindAll("DKIM-Signature") {
		trace(ctx, "Found DKIM-Signature header: %s", sig.Value)

		if i >= maxHeaders(ctx) {
			// Protect from potential DoS by capping the number of signatures.
			// https://datatracker.ietf.org/doc/html/rfc6376#section-4.2
			// https://datatracker.ietf.org/doc/html/rfc6376#section-8.4
			trace(ctx, "Too many DKIM-Signature headers found")
			break
		}

		results.Found++
		res := verifySignature(ctx, sig, headers, body)
		results.Results = append(results.Results, res)
		if res.State == SUCCESS {
			results.Valid++
		}
	}

	trace(ctx, "Found %d signatures, %d valid", results.Found, results.Valid)
	return results, nil
}

// Regular expression that matches the "b=" tag.
// First capture group is the "b=" part (including any whitespace up to the
// '=').
var bTag = regexp.MustCompile(`(b[ \t\r\n]*=)[^;]+`)

func verifySignature(ctx context.Context, sigH header,
	headers headers, body string) *OneResult {
	result := &OneResult{
		SignatureHeader: sigH.Value,
	}

	sig, err := dkimSignatureFromHeader(sigH.Value)
	if err != nil {
		// Header validation errors are a PERMFAIL.
		// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
		result.Error = err
		result.State = PERMFAIL
		return result
	}

	result.Domain = sig.d
	result.Selector = sig.s
	result.B = base64.StdEncoding.EncodeToString(sig.b)

	// Get the public key.
	// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
	pubKeys, err := findPublicKeys(ctx, sig.d, sig.s)
	if err != nil {
		result.Error = err

		// DNS errors when looking up the public key are a TEMPFAIL; all
		// others are PERMFAIL.
		// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
		if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.Temporary() {
			result.State = TEMPFAIL
		} else {
			result.State = PERMFAIL
		}
		return result
	}

	// Compute the verification.
	// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3

	// Step 1: Prepare a canonicalized version of the body, truncate it to l=
	// (if present).
	// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
	bodyC := sig.cB.body(body)
	if sig.l > 0 {
		bodyC = bodyC[:sig.l]
	}

	// Step 2: Compute the hash of the canonicalized body.
	bodyH := hashWith(sig.Hash, []byte(bodyC))

	// Step 3: Verify the hash of the body by comparing it with bh=.
	if !bytes.Equal(bodyH, sig.bh) {
		bodyHStr := base64.StdEncoding.EncodeToString(bodyH)
		trace(ctx, "Body hash mismatch: %q", bodyHStr)

		result.Error = fmt.Errorf("%w (got %s)",
			ErrBodyHashMismatch, bodyHStr)
		result.State = PERMFAIL
		return result
	}
	trace(ctx, "Body hash matches: %q",
		base64.StdEncoding.EncodeToString(bodyH))

	// Step 4 A: Hash the (canonicalized) headers that appear in the h= tag.
	// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
	b := sig.Hash.New()
	for _, header := range headersToInclude(sigH, sig.h, headers) {
		hsrc := sig.cH.header(header).Source + "\r\n"
		trace(ctx, "Hashing header: %q", hsrc)
		b.Write([]byte(hsrc))
	}

	// Step 4 B: Hash the (canonicalized) DKIM-Signature header itself, but
	// with an empty b= tag, and without a trailing \r\n.
	// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
	sigC := sig.cH.header(sigH)
	sigCStr := bTag.ReplaceAllString(sigC.Source, "$1")
	trace(ctx, "Hashing header: %q", sigCStr)
	b.Write([]byte(sigCStr))
	bSum := b.Sum(nil)
	trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum))

	// Step 4 C: Validate the signature.
	for _, pubKey := range pubKeys {
		if !pubKey.Matches(sig.KeyType, sig.Hash) {
			trace(ctx, "PK %v: key type or hash mismatch, skipping", pubKey)
			continue
		}

		if sig.i != "" && pubKey.StrictDomainCheck() {
			_, domain, _ := strings.Cut(sig.i, "@")
			if domain != sig.d {
				trace(ctx, "PK %v: Strict domain check failed: %q != %q (%q)",
					pubKey, sig.d, domain, sig.i)
				continue
			}

			trace(ctx, "PK %v: Strict domain check passed", pubKey)
		}

		err := pubKey.verify(sig.Hash, bSum, sig.b)
		if err != nil {
			trace(ctx, "PK %v: Verification failed: %v", pubKey, err)
			continue
		}
		trace(ctx, "PK %v: Verification succeeded", pubKey)
		result.State = SUCCESS
		return result
	}

	result.State = PERMFAIL
	result.Error = ErrVerificationFailed
	return result
}

func headersToInclude(sigH header, hTag []string, headers headers) []header {
	// Return the actual headers to include in the hash, based on the list
	// given in the h= tag.
	// This is complicated because:
	//  - Headers can be included multiple times. In that case, we must pick
	//    the last instance (which hasn't been already included).
	//    https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.2
	//  - Headers may appear fewer times than they are requested.
	//  - DKIM-Signature header may be included, but we must not include the
	//    one being verified.
	//    https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
	//  - Headers may be missing, and that's allowed.
	//    https://datatracker.ietf.org/doc/html/rfc6376#section-5.4
	seen := map[string]int{}
	include := []header{}
	for _, h := range hTag {
		all := headers.FindAll(h)
		slices.Reverse(all)

		// We keep track of the last instance of each header that we
		// included, and find the next one every time it appears in h=.
		// We have to be careful because the header itself may not be present,
		// or we may be asked to include it more times than it appears.
		lh := strings.ToLower(h)
		i := seen[lh]
		if i >= len(all) {
			continue
		}
		seen[lh]++

		selected := all[i]

		if selected == sigH {
			continue
		}

		include = append(include, selected)
	}

	return include
}

func hashWith(a crypto.Hash, data []byte) []byte {
	h := a.New()
	h.Write(data)
	return h.Sum(nil)
}