Something went wrong. Try again.
DKIM (RFC 6376) signing and verification for Zig 0.16: both canonicalizations, RSA and Ed25519, no I/O or allocation.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us># SPDX-License-Identifier: MIT
"""Sign and verify against other people's DKIM implementations.
Everything in `zig build test` checks this library against what RFC 6376 says,or against itself. Neither finds the failure that matters most: a signer and averifier that are wrong in the *same* way agree with each other perfectly andwith nobody else, and a DKIM library that nobody else can verify is worse thanno DKIM library at all, because the mail still goes out.
So this drives the real thing against two other implementations, in bothdirections where each allows it:
dkimpy Scott Kitterman's, the Python one, used by a great deal of mail-handling software. Signs and verifies. opendkim Murray Kucherawy's, the C one that most mail servers run as a milter. `opendkim-testmsg` signs without needing DNS, which is what makes it usable here; verifying with it would need a resolver and a zone, so this only goes one way.
Run it with `zig build interop`, which builds the command-line tool first.Everything it needs is in the devshell."""
import osimport subprocessimport sysimport tempfile
HERE = os.path.dirname(os.path.abspath(__file__))KEY = os.path.join(HERE, "rsa2048.key")with open(os.path.join(HERE, "rsa2048.pub")) as f: RECORD = "v=DKIM1; k=rsa; p=" + f.read().strip()
CANONICALIZATIONS = ["relaxed/relaxed", "relaxed/simple", "simple/relaxed", "simple/simple"]
# The header fields every implementation is told to sign, so that they are all# hashing the same thing. `opendkim-testmsg` chooses its own list and cannot# be told otherwise, which is fine: what is being compared is whether each can# verify the other, not whether they pick the same fields.HEADERS = ["from", "to", "subject", "date"]
HEADER_BLOCK = ( b"From: Alice <alice@example.com>\r\n" b"To: Bob <bob@example.net>\r\n" b"Subject: interop\r\n" b"Date: Fri, 12 Sep 2026 12:00:00 -0500\r\n")
# One message per thing that has ever been got wrong. The names say what each# is for; the comments say why it is hard.MESSAGES = { # The ordinary case, so that a total failure is obvious. "plain": HEADER_BLOCK + b"\r\nHello there.\r\nSecond line.\r\n", # `simple` hashes a bare CRLF for an empty body and `relaxed` hashes # nothing at all: the one input where the two algorithms genuinely # disagree. "empty-body": HEADER_BLOCK + b"\r\n", # No empty line at all, which is a different message from one with an # empty body even though both have nothing in the body. "no-blank-line": HEADER_BLOCK, # Both algorithms drop empty lines from the end of the body; the question # is whether they drop the same number. "trailing-blank-lines": HEADER_BLOCK + b"\r\nbody\r\n\r\n\r\n\r\n", # `relaxed` collapses runs of whitespace and drops it from line ends; # `simple` must not touch any of it. "whitespace": HEADER_BLOCK + b"\r\ntrailing \r\n leading\r\ninner runs\r\n", "tabs": HEADER_BLOCK + b"\r\na\tb\t\tc\t\r\n", # A body whose last line has no terminator at all. "no-trailing-crlf": HEADER_BLOCK + b"\r\nbody without a newline", # Empty lines in the *middle* are kept, unlike the ones at the end. "blank-lines-inside": HEADER_BLOCK + b"\r\none\r\n\r\n\r\ntwo\r\n", # Unfolding: the CRLF goes, and the whitespace either side of it becomes # one space under `relaxed`. "folded-header": ( b"From: Alice <alice@example.com>\r\n" b"To: Bob <bob@example.net>\r\n" b"Subject: a subject that is\r\n folded across\r\n\tthree lines\r\n" b"Date: Fri, 12 Sep 2026 12:00:00 -0500\r\n" b"\r\nbody\r\n" ), # A field with nothing after the colon. "empty-subject": ( b"From: Alice <alice@example.com>\r\n" b"To: Bob <bob@example.net>\r\n" b"Subject:\r\n" b"Date: Fri, 12 Sep 2026 12:00:00 -0500\r\n" b"\r\nbody\r\n" ), # Non-ASCII bytes, which are hashed as bytes and never decoded. "utf8": ( "From: Ålice <alice@example.com>\r\n" "To: Bob <bob@example.net>\r\n" "Subject: héllo wörld — em dash\r\n" "Date: Fri, 12 Sep 2026 12:00:00 -0500\r\n" "\r\nbödy\r\n" ).encode(), # Longer than any line-oriented buffer, to catch a canonicalizer that # relaxes a line in chunks and gets the join wrong. "very-long-line": HEADER_BLOCK + b"\r\n" + (b"x " * 900) + b"\r\n", # Repeated fields, which `h=` takes from the bottom upwards. "repeated-fields": ( b"Received: third\r\nReceived: second\r\nReceived: first\r\n" + HEADER_BLOCK + b"\r\nbody\r\n" ), # A line that looks like the start of the body but is not. "body-looks-like-header": HEADER_BLOCK + b"\r\nSubject: not a header\r\nbody\r\n", # A long body, to be sure nothing is buffering the whole thing badly. "large-body": HEADER_BLOCK + b"\r\n" + (b"a line of text here\r\n" * 5000),}
# Cases where this library and another deliberately disagree, with the reason.# They are run and their failure is expected; a case here that starts *passing*# is as much a change worth noticing as one that starts failing.EXPECTED_DIVERGENCE = { ("dkimpy", "obsolete-header-syntax"): ( "dkimpy's parser requires a field name to touch its colon, so it drops " "`From : a@b` entirely and signs a message it thinks has no From field. " "This library accepts it, as RFC 5322's obs-optional allows and as RFC " "6376 §3.4.2 rule (e) presumes by having a rule for deleting the space. " "opendkim agrees with this library. Accepting it is also the safer " "reading: a verifier that cannot see the field cannot notice one being " "added in transit." ),}
DIVERGENT_MESSAGES = { "obsolete-header-syntax": ( b"From : Alice <alice@example.com>\r\n" b"To: Bob <bob@example.net>\r\n" b"Subject: obsolete syntax\r\n" b"Date: Fri, 12 Sep 2026 12:00:00 -0500\r\n" b"\r\nbody\r\n" ),}
def zig_dkim(): """The command-line tool, which the build has already put in zig-out.""" root = os.path.abspath(os.path.join(HERE, "..", "..")) path = os.environ.get("ZIG_DKIM") or os.path.join(root, "zig-out", "bin", "zig-dkim") if not os.path.exists(path): sys.exit(f"{path} is not there; run `zig build` first") return path
def zig_sign(data, canon, headers=HEADERS): return subprocess.run( [zig_dkim(), "sign", "-k", KEY, "-d", "example.com", "-s", "test", "-t", "1757000000", "-c", canon, "-h", ":".join(headers)], input=data, stdout=subprocess.PIPE, check=True, ).stdout
def zig_verify(data, extra=()): result = subprocess.run( [zig_dkim(), "verify", "-p", RECORD, *extra], input=data, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) out = result.stdout.decode(errors="replace").strip().splitlines() return result.returncode == 0, (out[0] if out else "no output")
def dkimpy_sign(data, canon, headers=HEADERS): import dkim head, body = canon.split("/") return dkim.sign( data, b"test", b"example.com", open(KEY, "rb").read(), canonicalize=(head.encode(), body.encode()), include_headers=[h.encode() for h in headers], ) + data
def dkimpy_verify(data): import dkim try: ok = dkim.verify(data, dnsfunc=lambda name, timeout=5: RECORD.encode()) return ok, "PASS" if ok else "FAIL" except Exception as exc: # noqa: BLE001 - report anything return False, f"{type(exc).__name__}: {exc}"
def opendkim_sign(data, canon, headers=HEADERS): """opendkim-testmsg signs and cannot be told which fields or which canonicalization to use, so `canon` and `headers` are ignored -- it always produces relaxed/simple over a list of its own choosing, with rsa-sha1.""" del canon, headers with tempfile.TemporaryDirectory() as tmp: result = subprocess.run( ["opendkim-testmsg", "-C", "-k", KEY, "-d", "example.com", "-s", "test", "-t", tmp], input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) if result.returncode != 0: raise RuntimeError(result.stderr.decode(errors="replace").strip()) return result.stdout
def main(): failures = [] unexpected_passes = [] checked = 0
def check(direction, name, canon, ok, detail, divergence_key=None): nonlocal checked checked += 1 expected = EXPECTED_DIVERGENCE.get(divergence_key) if divergence_key else None if expected and ok: unexpected_passes.append((direction, name, canon, expected)) elif not expected and not ok: failures.append((direction, name, canon, detail))
for name, data in {**MESSAGES, **DIVERGENT_MESSAGES}.items(): divergent = name in DIVERGENT_MESSAGES for canon in CANONICALIZATIONS: signed = zig_sign(data, canon) ok, detail = dkimpy_verify(signed) check("zig -> dkimpy", name, canon, ok, detail, ("dkimpy", name) if divergent else None)
signed = dkimpy_sign(data, canon) ok, detail = zig_verify(signed) check("dkimpy -> zig", name, canon, ok, detail, ("dkimpy", name) if divergent else None)
# opendkim signs with rsa-sha1, which this library refuses by default # and RFC 8301 forbids -- so verifying it takes saying so out loud. try: signed = opendkim_sign(data, "relaxed/simple") except (RuntimeError, FileNotFoundError) as exc: failures.append(("opendkim -> zig", name, "-", f"could not sign: {exc}")) continue ok, detail = zig_verify(signed, ("--allow-sha1",)) check("opendkim -> zig", name, "rsa-sha1", ok, detail)
for direction, name, canon, detail in failures: print(f"FAIL {direction:<16} {name:<24} {canon:<16} {detail}") for direction, name, canon, why in unexpected_passes: print(f"PASSED UNEXPECTEDLY {direction} {name} {canon}") print(f" this was documented as a known divergence: {why}") print(" the other implementation may have changed; check and update the note")
print(f"\n{checked - len(failures) - len(unexpected_passes)}/{checked} as expected") if EXPECTED_DIVERGENCE and not unexpected_passes: print(f"({len(EXPECTED_DIVERGENCE)} documented divergence(s) still diverging, as expected)") return 1 if failures or unexpected_passes else 0
if __name__ == "__main__": sys.exit(main())