mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-23 15:37:01 +00:00
dkim: Implement internal dkim signing and verification
This patch implements internal DKIM signing and verification.
This commit is contained in:
310
internal/dkim/verify.go
Normal file
310
internal/dkim/verify.go
Normal file
@@ -0,0 +1,310 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user