NAME

Mail::DKIM2::Verifier - Verify the DKIM2-Signature chain on a message

SYNOPSIS

use Mail::DKIM2::Verifier;

my $verifier = Mail::DKIM2::Verifier->new->load($message);
print $verifier->result, "\n";          # pass, fail, none, permerror, temperror
print $verifier->result_detail, "\n";   # pass (i=1..3 verified)

# Streaming, with CRLF line endings:
my $v = Mail::DKIM2::Verifier->new(Resolver => $net_dns_resolver);
$v->PRINT($chunk) for @chunks;
$v->CLOSE;

# For Authentication-Results:
if (my $top = $v->top_signature) {
    printf "dkim2=%s header.d=%s header.i=%d\n",
        $v->result, $top->domain, $top->sequence;
}

DESCRIPTION

Verifies every DKIM2-Signature on a message, not only the outermost: the chain must be complete (i=1 to i=N with no gaps), each signature must verify over the headers that existed when it was made, consecutive hops must satisfy the chain-of-custody rules of -07 section 14.4, and the Message-Instance chain must undo cleanly back to the first instance, each one matching the content it describes. Past a null body Recipe (the previous body is gone) only the header history is checked. The outcome is a result and a reason, never an exception; see result below.

Extends Mail::DKIM2::HeaderParser, which provides PRINT, CLOSE, load and the tie interface. The Message-Instances are checked first and the signatures over them second, so no key is needed to refuse a message whose instances do not describe it. A message with no DKIM2 field at all is decided from its headers alone and its body is not kept.

This module implements draft-ietf-dkim-dkim2-spec-07; see "STATUS" in Mail::DKIM2 for what that means for the wire format and the API, and "CONVENTIONS" in Mail::DKIM2 for the option, input and error conventions every module here follows.

CONSTRUCTOR

new(%options)

All options are optional. The boolean ones and Resolver also have a snake_case method of the same name that gets or sets them after construction; PubkeyCallback has set_pubkey_callback.

Resolver

A Net::DNS::Resolver (or anything with the same query and errorstring methods) for the default public-key lookup. Created on demand if not given. A milter host passes its own so its timeouts apply.

If it also has Net::DNS's bgsend, bgbusy and bgread, every key the signatures name is asked for at once, in the background, as soon as the header fields are in, and the answers are read when the signatures are checked: the lookups overlap each other, the body's arrival and the hashing. An answer that settles nothing -- none within $Mail::DKIM2::Verifier::PREFETCH_WAIT seconds (1) of being sent, or SERVFAIL, or truncated -- is asked again with query, so the verdicts are those of one query at a time. At most $Mail::DKIM2::Verifier::PREFETCH_MAX keys (8) are prefetched per message, the top signature's first; any other key is asked for with query when it is needed. Not with a PubkeyCallback.

A subclass of Net::DNS::Resolver that overrides send (its own transport, caching or metrics) but not bgsend gets no prefetch: Net::DNS's bgsend would bypass that send, and asks only the first nameserver. Every key is then asked for with query, and so through the subclass's send. A resolver that provides its own bgsend is used for the prefetch.

PubkeyCallback

A code reference that replaces the DNS lookup. Called as ($signature, $index, $verifier) for each item of each signature's s= tag; returns a Crypt::PK::RSA or Crypt::PK::Ed25519 object, undef for "no such key", or dies with a string to report a transient failure. It can fall back to $verifier->fetch_public_key($signature, $index) for keys it does not know.

SkipTimestampCheck

Do not fail a signature for the age of its t=. For test fixtures.

IgnorePrefixes

An arrayref of header-field-name prefixes an operator's own border adds and strips, excluded from the header hash. Anything but undef or an arrayref croaks. See "Operator-local header fields" in Mail::DKIM2.

AllowUnsignedMI

Permit a Message-Instance with a higher m= than any signature covers, which -07 section 14.2 otherwise makes a permerror ("is not signed"). For an outbound path that verifies the message it is about to sign, where the new instance legitimately exists before its signature does. Never set it on inbound mail.

NextDomainOK

An outbound signer's own d=. A top signature carrying nd= is then accepted when nd= equals it (case-insensitive), and refused with "PERMERROR: not signing: top signature nd=X names another domain" otherwise. Unset, a top nd= is the usual permerror.

MidProcess

The verifier is looking at a partial view of the chain, with higher signatures stripped (as the validator does walking the chain top-down), so the highest remaining signature is not the true top and the checks that apply only to the top are suppressed. Implies AllowUnsignedMI.

InstancesOnly

Check the shape of the chain and the Message-Instances, and stop: for a caller that has had the signatures verified already. No key is fetched, an instance no signature covers is allowed, and the result is pass (details "Message-Instance m=1..N intact") when the instances match the message, none when there are none.

StreamBody

On (the default): hash every Message-Instance's body as the body arrives, in one pass (Mail::DKIM2::BodyPlan), and every header hash from the fields as received (Mail::DKIM2::HeaderPlan); the body is never held and no previous version of the message is rebuilt. Off (0): keep the body and undo each instance in turn. The verdicts, and their words, are the same.

HeadersOnly

The message has no body, as with the returned original in a DSN's text/rfc822-headers part (-07 section 16.3). Signatures and the chain are checked as usual; of the Message-Instance content check, only the top instance's header hash can be, so only that is.

An option not listed here croaks.

METHODS

PRINT($bytes), CLOSE(), load($input)

Feed the message; see Mail::DKIM2::HeaderParser.

result()

One of the -07 section 14.1 states, in lower case:

pass

Every signature verified, the chain is complete and consistent, the Message-Instance chain undoes cleanly, and every From is Authentic.

fail

A hash or signature was not correct: a signature did not verify, or a Message-Instance does not match the content it describes.

none

No DKIM2-Signature header fields. With no Message-Instance either; or, under AllowUnsignedMI or MidProcess, with Message-Instances that match the message (one that does not is the fail or permerror the walk found). Otherwise a Message-Instance with no signature is "is not signed", a permerror.

permerror

The message is malformed or not Validly Signed in a way no retry will fix: a missing or malformed field or tag, a gap in the chain, a broken Chain of Custody, a timestamp too old or too far ahead, an unusable or absent key, a Recipe that is not valid JSON or does not undo, a From that is not Authentic, and the local limits (too many fields, a repeated number, a key too short, no algorithm this verifier implements).

temperror

A public key could not be fetched for a transient reason. Retry later.

Before CLOSE this is none.

details()

The reason, with no result word wrapped around it; undef when there is none. For any result but pass and none it is the -07 human-readable string, state included, e.g. "PERMERROR: DKIM2-Signature i=2 d= does not match the rt= of i=1" or "FAIL: Message-Instance m=1 body hash sha256 mismatch" (sections 14.2 to 14.8). A check -07 has no string for keeps its own words, with the PERMERROR: prefix. For pass it is "i=1..N verified", and for none "no DKIM2-Signature headers found". Use this when embedding the reason in something that already states the result, such as an Authentication-Results comment.

diagnostic()

More about the reason, for a log, when there is more; undef otherwise: the resolver's error behind TEMPERROR: ... could not be fetched, the copy range behind a Recipe that does not undo. Never part of details. New in 0.23 (0.22 appended the resolver's error to the key-fetch reason).

provisionally_verified()

After CLOSE: true when the message is Provisionally Verified (-07 section 13.1) -- the highest i= DKIM2-Signature is correct and the highest m= Message-Instance matches the message -- whatever else went wrong. A receiver may accept such a message and decide later; a DSN may only be sent for one (section 16.1, Mail::DKIM2::DSN). A pass is always Provisionally Verified; a top signature with nd=, or one that does not cover the top instance, never is. Leaves result, details and diagnostic as they were. New in 0.23.

result_detail()

result and details together, e.g. "pass (i=1..3 verified)".

signatures()

The DKIM2-Signature headers the message carried, as Mail::DKIM2::Signature objects in ascending i= order.

top_signature()

The highest-i= signature, or undef. Its domain and sequence are header.d and header.i for Authentication-Results.

fetch_public_key($signature, $index)

The default key source: a TXT lookup of <selector>._domainkey.<d=> through the Resolver (the answer to the background lookup sent at the end of the headers, if there was one; see Resolver). Each name is looked up once per message. Returns a key object, undef when the answer positively says there is no such record (NXDOMAIN, NOERROR or NODATA), and dies with a TEMPERROR: message for anything else, including a resolver error string it has never seen. The verifier maps that die to temperror: -07 section 13 makes a DNS failure retryable, never a fail, which would read as a forged signature.

A record that is present but unusable dies with PERMERROR: <why>, which the verifier reports as permerror naming the selector (-07 section 14.5): has multiple records (more than one TXT RR; several strings in one RR are joined), has a syntax error and has been revoked (see "parse_dkim_key_record" in Mail::DKIM2::Common), or algorithm mismatch (a k= that is not the signature algorithm's).

resolver([$resolver]), set_pubkey_callback(\&cb), skip_timestamp_check([$bool]), allow_unsigned_mi([$bool]), next_domain_ok([$domain]), mid_process([$bool]), headers_only([$bool]), instances_only([$bool]), stream_body([$bool])

Get or set the constructor options of the same names.

VERIFICATION PROCESS

  1. Shape. At most 32 Message-Instance and 32 DKIM2-Signature fields, every DKIM2-Signature parses with an i= that is a positive integer (one that does not is never skipped: "PERMERROR: DKIM2-Signature has a missing or malformed i= tag"), every i= and m= a chain number (1*DIGIT, at most three digits naming 1 to 100, and no more than 32; 01 is 1), no i= or m= twice, and no Message-Instance above the highest signed m=. Any of these is a permerror decided from the headers alone, before any key is fetched. A tag repeated within one signature, in any case, is a syntax error found when that signature is checked.

  2. Chain completeness. i=1 to i=N with no gaps; likewise m=1 to the highest instance, or the highest a signature covers.

  3. Content. Each Message-Instance's tags and Recipe, by ascending m=. Then the top Message-Instance must match the message; its Recipe is applied and the next instance down checked against the result, back to m=1; past an instance with a null body Recipe the walk continues over the header history only. All of this comes before any signature is looked at, so a message whose instances do not describe it is refused without a key being fetched, and a hash that does not match is the error reported whatever is wrong with the signatures.

  4. Each signature. Its tags (i=, m=, t=, d=, s=, and either nd= or both mf= and rt=), its timestamp (no more than 14 days before now, no more than a day after), then its signature: the signing input for i=K is the canonicalized Message-Instance headers up to the m= it names, the DKIM2-Signature headers below it, and itself with an empty s= value, in that order (-07 section 12.8). Every item in s= whose algorithm is known and whose key can be fetched is checked and must verify; an item whose key is absent is skipped if another item verifies.

  5. Chain of custody (-07 section 7.1). After an nd= hop, the next d= must be the nd= domain. Otherwise an nd= hop, or one with a null mf=, must have its d= the same as or above a rt= domain of the hop before; any other hop its mf= domain the same as or below one. Every non-null mf= must be the same as or below its own d=. Domains are compared as A-labels; a domain literal never matches.

  6. From (-07 section 11). The From at m=1 must be Authentic for i=1, and a From changed at m=k for the lowest i= whose m= is k or more: one From field, one mailbox, its domain the same as or below d=.

AUTHOR

Bron Gondwana <brong@fastmailteam.com>

COPYRIGHT AND LICENSE

Copyright (c) 2025-2026 Fastmail Pty Ltd. This is free software; you can redistribute it and/or modify it under the same terms as Perl itself.