311 lines
8.8 KiB
Go
311 lines
8.8 KiB
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)
|
|
}
|