This repository is a place to share materials and examples for the IETF 124 hackathon.
Please feel free to clone and make pull requests, or ask for direct commit privileges on this repo.
Steve Atkins' test vectors (https://forge.turscar.ie/turscar/dkim2tests) are a
git submodule at dkim2tests/. To run all of them against every verifier here
(Python, Perl, Go, C, browser JS):
git submodule update --init
./util/turscar-all.sh
Each language's runner is also runnable on its own — see the comment at the top
of util/turscar-all.sh.
draft-05 added sha512 alongside sha256. To check that every signer's output
verifies in every implementation, at each algorithm:
./util/hash-matrix.sh
This signs one message with each of the four signers (Python, Go, C, Perl) at
--hash sha256, sha512 and both, then verifies every output with all
five verifiers (adding browser JS) — 60 signer/algorithm/verifier
combinations in total.
A signer that is handed a message with a DKIM2 chain must verify that chain
before extending it (see "Verify before you sign" in
docs/dkim2-implementer-guide.md). util/signer-gate.sh runs a set of
chain-shaped fixtures (no chain, valid chain, broken signature, broken MI
chain, null body Recipe, fake "coverage" signatures, Message-Instance-only
chains, nd= bridges, a From that is or is not Authentic for the signer)
through all four signer CLIs, plain and with --allow-null-body-recipe, and
checks each signs or refuses as specified (25 fixtures, 200 cells):
./util/signer-gate.sh
A DKIM2-Signature with m=k covers Message-Instances 1..k. A null body
Recipe on any instance above the highest m= of the valid upstream
signatures is refused without --allow-null-body-recipe: a null this hop
introduces, whether it is the top instance (null-top, mi-only-null) or
another unsigned instance was added over it (null-below-unsigned-top). A
null that arrived already signed (null-top-signed: a list post the list
host signed, forwarded unchanged; null-below-signed: the same with an
ordinary unsigned instance on top) is signed without the option. Only a
host that introduces a null body Recipe and signs it itself — a list host whose
list manager adds an unsigned instance — needs the option; a forwarder needs
nothing. Only a signature with a valid i= counts as covering the top: the
fake-cover-* fixtures put a DKIM2-Signature naming m=2 with no i=,
i=0, i=abc, a rewritten m= or no tag-list syntax on top of null-top,
and every signer refuses them in both modes, because every verifier reports
such a signature as a PERMERROR rather than skipping it.
It needs the built c/dkim2sign and go/dkim2sign, and uses dns.json for
the verification keys. The Perl milters make the same decision with the same
Mail::DKIM2::Gate: perl/t/milter-script.t covers dkim2-milter, and
perl/t/milter-sign-gate.t runs these cases through the authentication_milter
DKIM2Sign handler (allow_null_body_recipe in its config).
Draft -07 §11 adds a signer-side From check: a signer that originates a
message (no DKIM2-Signature yet), or signs over an unsigned Message-Instance
that changed From, refuses unless From is Authentic for its own d=, with
From header field is not Authentic for d=<D>. fresh-from-not-authentic
(no chain, From in another domain) and from-rewritten-other (an unsigned
m=2 rewrote From into another domain) must be refused for exactly that
reason; fresh, mi-only and from-rewritten-us carry a From in the
signer's domain and must be signed.
util/negative-vectors.sh hand-builds one cryptographically valid message
per -07 PERMERROR — a duplicate hash algorithm, a duplicate
Selector, more selectors than allowed, malformed Recipe JSON, an unsigned
top instance, a wrongly-keyed nd= bridge, and Recipe copy ranges that are
out of order or overlap (§5.2), a duplicated Message-Instance m=, and a
DKIM2-Signature with no usable i= (missing, or not a positive integer) on
top of an otherwise valid chain or on its own, an i= or m= that is not
a chain number — above the chain length limit of 32 (i=33), above the
chain-number bound of 100 (i=101, i=99999999999999999999, a signature
m=4294967297, a Message-Instance m=99999999999999999999), or not
1*DIGIT (m=4294967297x) — which must be a PERMERROR before any gap check
walks up to it, and a Recipe whose JSON gives a key twice ("b" as [...]
and null, either order, or an "h" key twice). Draft -07 added: Chain of
Custody failures (a bounce mf=<> signed by a domain that was not in the
previous rt=; an nd= hop by the parent of the rt= domain, since only the
first parameter of the relaxed match is shortened; a hop after nd= that is
not the nd= domain; domain-literal rt=/mf=; an mf= outside its d=),
From not Authentic (at i=1, including when i=1 signs a later state than
m=1, and after a list rewrote From into a domain it does not sign for), a "u" step with non-zero pad bits, the -06 "b" step
key, a step object with two keys or none, and two "h" keys that differ
only in case. Positive controls: the same algorithm signed twice under
distinct Selectors, which §10.9 explicitly permits; a Recipe on the bottom
instance; a correct bridge; an unsigned lower Message-Instance under a signed
higher one; "u" steps restoring non-UTF-8 octets; a bounce back along the
chain (with the rt= recorded as <list@Lists.Test2.DKIM2.com.>); an nd=
hop under a subdomain rt=; a folded RFC 2047 From; a list that rewrote From
into its own domain, with or without a signature under the rewrite (an
author's From replaced before the first signature is never checked, -07 §11);
and a Recipe with upper-case keys. Multi-fault vectors carry two faults
each and pin which error comes first (contract C9, -07 §14 section order): a
From changed after signing is the §14.7 header hash FAIL, not the §14.8 From
PERMERROR; an expired signature comes before a custody mismatch, custody
before a bad signature, a bad signature before a hash mismatch; a malformed
Recipe before an expired signature, but a Recipe that only fails against the
message (a copy past the end) after it. The runner feeds them
all through every verifier's real CLI entry point and asserts each negative
vector is REJECTED with the exact -07 error string as the verifier's
PRIMARY error (its first STATE: text line, compared exactly) and the
matching state, and each positive control is ACCEPTED (66 vectors, 330
cells):
./util/negative-vectors.sh
Each fixture is built to be otherwise valid (correct hashes, correct signature bytes) so a check that's implemented but unreachable from the real verify path — the exact bug class found more than once during this upgrade — shows up as a false accept rather than being masked by an unrelated signature failure.
https://github.com/croessner/dkim2 is the other actively developed DKIM2
implementation (Go, tracking draft-ietf-dkim-dkim2-spec-06 when this runner
was written; util/croessner-process.py pins that draft). The runner now
checks that -06 peer against our -07 implementations, so disagreements are
expected wherever -07 changed the rules (the "u" Recipe step, Chain of
Custody, From Authentic, the error strings); read its output as a diff
between the drafts, not as a pass/fail gate. To feed his verifier everything
this repo can produce:
./util/croessner-verify.sh
It signs one message with each of our four signers at each of the three hash
algorithms, then runs the whole negative-vector set, and gates on his verdict —
18 cells. His implementation has no file-based CLI, so the runner builds his
dkim2d daemon, mints a capability, boots it on a loopback port and POSTs each
message to /v1/process.
Two consequences worth knowing:
- It needs the network.
dkim2dresolves DNS live and has no offline records file, so this runner depends on the publishedtest1.dkim2.comkeys rather thandns.json. That's why it's a separate script:hash-matrix.shand the other runners stay offline-clean. - It skips instead of failing. His repository is not vendored here. Point
the runner at a checkout with
DKIM2_GO_PEER=/path/to/dkim2, or put one beside this repo as../mailde-dkim2; with no checkout, no Go toolchain, or no DNS it printsSKIPPEDand exits 0.
Signing in the other direction — his signer against our five verifiers — isn't
covered yet. /v1/sign returns an append-only action plan rather than a signed
message, and enabling it requires a full protected generation (datasource,
private-key manifest with SPKI digests, PKCS#8 children), so that direction is
waiting on a command-line signer.
The hand-written fixtures are ASCII and UTF-8. util/charset-corpus.sh
fetches public archives that still serve full raw messages (the Apache
ponymail mbox API, a HyperKitty export, the SpamAssassin public corpus), keeps
a sample spread over every charset / transfer-encoding combination they
contain -- ISO-2022-JP 7bit, GB18030 base64, Big5 and EUC-KR with raw 8-bit
Subjects, Latin-1, charset=3Dbig5 -- and then:
./util/charset-corpus.sh # all stages
./util/charset-corpus.sh --stage matrix # local only: 4 signers x 5 verifiers per sample
./util/charset-corpus.sh --stage lists # replay through the dkim2corpus lists on the box
The matrix stage is util/hash-matrix.sh's shape on real bytes. The lists
stage signs each sample as dkim2.com/sel1, posts it to the Mailman and Sympa
corpus lists on mail.dkim2.com (members: the local capture address only; see
deploy/SERVER.md), pulls the captured copies back and checks that each list's
m=2 Recipe rebuilds the signed m=1 under every verifier. Results land in
corpus/results/ (not committed): per-cell TSVs, summaries by charset, and
the failing messages with verifier logs under corpus/results/fail/.
The captures taken so far are -06 list mail ("b" Recipe steps, the author's
From), which every -07 verifier rejects; recapture with --stage lists after
the -07 deploy before reading the captures results.
Pipermail's "Gzip'd Text" archives are scrubbed (no Content-Type, bodies
re-encoded), which is why the many lists.ubuntu.com locale lists are not a
source despite looking ideal; util/charset-corpus-sample.py lists what is.
BSD 3-Clause — see LICENSE. This work was contributed under the
IETF Note Well (BCP 78), whose Trust
Legal Provisions license code components under the Revised BSD licence, so that
is what applies here. Copyright is held by the DKIM2 interop contributors; by
sending a pull request you licence your contribution under the same terms.
Two things in this tree are not covered by that licence:
dkim2tests/is a git submodule pointing at Steve Atkins' https://forge.turscar.ie/turscar/dkim2tests, which carries its own BSD-2-Clause licence (Copyright (c) 2026 Turscar). Nothing here relicenses it.
A Deployment Profile for DKIM2 via Milter Interface (IETF Datatracker): https://datatracker.ietf.org/doc/draft-moccia-dkim2-deployment-profile/