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