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
queryanderrorstringmethods) 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,bgbusyandbgread, 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_WAITseconds (1) of being sent, or SERVFAIL, or truncated -- is asked again withquery, so the verdicts are those of onequeryat a time. At most$Mail::DKIM2::Verifier::PREFETCH_MAXkeys (8) are prefetched per message, the top signature's first; any other key is asked for withquerywhen it is needed. Not with aPubkeyCallback.A subclass of Net::DNS::Resolver that overrides
send(its own transport, caching or metrics) but notbgsendgets no prefetch: Net::DNS'sbgsendwould bypass thatsend, and asks only the first nameserver. Every key is then asked for withquery, and so through the subclass'ssend. A resolver that provides its ownbgsendis used for the prefetch. - PubkeyCallback
-
A code reference that replaces the DNS lookup. Called as
($signature, $index, $verifier)for each item of each signature'ss=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 carryingnd=is then accepted whennd=equals it (case-insensitive), and refused with "PERMERROR: not signing: top signature nd=X names another domain" otherwise. Unset, a topnd=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,nonewhen 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-headerspart (-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
AllowUnsignedMIorMidProcess, with Message-Instances that match the message (one that does not is thefailorpermerrorthe walk found). Otherwise a Message-Instance with no signature is "is not signed", apermerror. 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
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"), everyi=andm=a chain number (1*DIGIT, at most three digits naming 1 to 100, and no more than 32;01is 1), noi=orm=twice, and no Message-Instance above the highest signedm=. Any of these is apermerrordecided 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.Chain completeness.
i=1toi=Nwith no gaps; likewisem=1to the highest instance, or the highest a signature covers.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 tom=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.Each signature. Its tags (
i=,m=,t=,d=,s=, and eithernd=or bothmf=andrt=), its timestamp (no more than 14 days before now, no more than a day after), then its signature: the signing input fori=Kis the canonicalized Message-Instance headers up to them=it names, the DKIM2-Signature headers below it, and itself with an emptys=value, in that order (-07 section 12.8). Every item ins=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.Chain of custody (-07 section 7.1). After an
nd=hop, the nextd=must be thend=domain. Otherwise annd=hop, or one with a nullmf=, must have itsd=the same as or above art=domain of the hop before; any other hop itsmf=domain the same as or below one. Every non-nullmf=must be the same as or below its ownd=. Domains are compared as A-labels; a domain literal never matches.From (-07 section 11). The From at
m=1must be Authentic fori=1, and a From changed atm=kfor the lowesti=whosem=iskor more: one From field, one mailbox, its domain the same as or belowd=.
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.