DKIM (RFC 6376) signing and verification for Zig 0.16: both canonicalizations, RSA and Ed25519, no I/O or allocation.

Files
README.md

zig-dkim #

DKIM — DomainKeys Identified Mail, RFC 6376 — for Zig 0.17: signing, verification, both canonicalizations, RSA and Ed25519. No I/O, no allocation, no DNS.

The API documentation is generated from the doc comments, which carry most of the explanation, and is published at https://jeff.jcollie.page/zig-dkim/.

$ git clone https://git.jcollie.dev/jeff/zig-dkim.git
$ cd zig-dkim
$ nix develop -c zig build test

Where this lives #

Three homes, with the same history in all of them.

  • Forgejo, at https://git.jcollie.dev/jeff/zig-dkim, which is where the workflow runs and where the documentation is published from.

  • Tangled, at https://tangled.org/jcollie.dev/zig-dkim.

  • Radicle, the peer-to-peer forge, as rad:zVMTM5eCvsCiKnfSiRiDUqFPeZ1U:

    $ rad clone rad:zVMTM5eCvsCiKnfSiRiDUqFPeZ1U
    

    A Radicle repository is findable only by that ID, so it is written out here rather than linked — there is nothing else to look it up by. If you have not set Radicle up, curl -sSf https://radicle.xyz/install | sh and then rad auth. Cloning also seeds the repository, which is what keeps it available on the network.

What DKIM is for #

DKIM answers exactly one question: which domain takes responsibility for this message?

A domain publishes a public key in DNS and signs the messages it sends. A verifier that checks the signature learns that that domain put its name to these bytes. That is all. It does not say the message is honest, it does not say the From address is real, and it does not say the domain is one anybody should trust. Deciding what a verified domain means — in particular whether it lines up with the From header a reader sees — is DMARC's job, and DMARC is a different specification that this library does not implement.

What makes it work at all is canonicalization. A message picks up damage crossing the mail system: a header refolded, a run of spaces collapsed, trailing whitespace trimmed, a blank line appended. A signature over the raw bytes would break every time. So both ends hash a rewritten form of the message, chosen so the routine damage makes no difference and the damage that matters still does.

Using it #

It needs Zig 0.17.0. The development shell and the Nix package take the official release binary from zig-overlay.

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dkim.git

For Zig 0.16.0, use the zig-0.16 branch, which holds the last of this library to build with it:

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-dkim.git#zig-0.16

The v0.1.0 tag also predates the move to 0.17 and builds with 0.16.0.

Signing gives you a header field to put at the top of the message. Nothing else about the message changes — canonicalization decides what is hashed, never what is sent.

const dkim = @import("dkim");

var buf: [dkim.signing.max_field_len]u8 = undefined;
const field = try dkim.signing.sign(&buf, raw_message, .{
    .domain = "example.com",
    .selector = "mail",
    .key = .{ .rsa = secret_key },
    .timestamp = now,
});
// Send `field`, then `raw_message` unchanged.

Verifying is in two steps, because the middle one is a DNS lookup and this library does not do those:

var it = dkim.signatures(raw_message);
while (it.next()) |result| {
    const signature = try result;
    var name_buf: [253]u8 = undefined;
    const name = try signature.recordName(&name_buf);   // what to look up
    const txt = try yourResolver.txt(name);             // your resolver
    const record = try dkim.Record.parse(txt);
    const verified = try dkim.verify(raw_message, signature, record, .{});
    std.log.info("signed by {s}", .{verified.domain});
}

There is a command-line tool as well, which takes the key record on the command line rather than looking it up — which is exactly what is wanted when the question is "is this signature good?" rather than "does DNS currently say so". The two differ precisely when a key has been rotated, which is when the question gets asked.

$ zig build run -- sign -k mail.key -d example.com -s mail < message > signed
$ zig build run -- verify -p "v=DKIM1; k=rsa; p=MIIBIjAN..." < signed
pass: d=example.com a=rsa-sha256

What it does not do #

No I/O and no allocation. Not a socket, not a file, not an allocator anywhere in the signing or verification path. A message is a []const u8 the caller already has; everything else fits in fixed buffers whose sizes are public constants. That is not asceticism — it means the whole of DKIM's logic is reachable from a unit test, that a milter can run it without deciding what to do about an allocation failure halfway through a message, and that it cannot stall on a slow network while an SMTP transaction waits.

No DNS. Signature.recordName says what to ask for and Record.parse reads the answer; the lookup in between is the caller's. A library that resolved names would need an Io, a timeout policy, a cache and an opinion about DNSSEC, and would be untestable without a network — and a caller running a mail server already has a resolver it would rather use. One consequence worth naming: TEMPFAIL is not a result this library can return, because a DNS failure happens before any of it runs. Every failure it reports is permanent.

No clock. A signature's t= and a verifier's idea of "now" are both passed in. A signer that read the clock could not produce the same bytes twice and so could not be tested against a fixed vector; a verifier that read it could not be tested against a signature that expired in 2019.

No policy. verify returns an error unless the signature is cryptographically sound and the key record permits it. What it will not do is decide what a pass means, and the Verified it hands back is worth reading rather than discarding: it says which domain signed, whether the key is flagged as testing, and how much of the body l= actually covered. A pass over the first 200 bytes of a 40 KB message, from a key whose owner has said it is still testing, is not the same thing as a pass.

What is here #

sign Produces the DKIM-Signature field. Chooses relaxed/relaxed and SHA-256 unless told otherwise, folds the result under 78 columns, and refuses to sign without covering From.
signatures Walks the DKIM-Signature fields of a message. A field that does not parse is reported and the walk continues, so one malformed signature cannot hide a good one below it.
verify Checks one signature against one key record, and returns what it actually established.
Signature The signature field: every tag, the h= list, and the two halves either side of the b= value that the hash needs.
Record The key record. Tells a revoked key (p= empty) from an absent one, respects h=, s= and t=, and will join the character-strings a TXT record arrives in.
canonical simple and relaxed, for headers and bodies, streaming into any hasher.
digest What actually gets hashed, §3.7, shared by both ends so they cannot drift apart.
message Where the headers stop and each field begins. Not a mail parser and not trying to be.

Algorithms #

rsa-sha256 What almost every signature in the world is, and the default.
ed25519-sha256 RFC 8463. Keys short enough to fit in one DNS string, and signatures a fraction of the cost — but a verifier that does not know it treats the signature as unverifiable, so it is published alongside an RSA signature rather than instead of one.
rsa-sha1 Parsed, and refused. RFC 8301 says "rsa-sha1 MUST NOT be used for signing or verifying"; Options.allow_rsa_sha1 exists so that an old message can be examined on purpose, and for no other reason.

RSA keys below 1024 bits are refused, as RFC 8301 requires. 2048 is what a signer should use, and Options.min_rsa_bits will insist on it — at the price of rejecting real mail, since a great deal of the internet is still signing with 1024.

The RSA itself is not here. Zig 0.17 ships half of RSA — std.crypto can verify a PKCS#1 v1.5 signature, buried in the certificate parser, and has no private key type at all — so the signing half lives in zig-std-crypto-ext, which is where anything std.crypto leaves out goes.

Tests #

$ nix develop -c zig build test --summary all
$ nix develop -c zig build interop
$ nix develop -c zig build fuzz --fuzz          # Zig's fuzzer, until interrupted
$ nix develop -c zig build fuzz-run -- --seconds 60

zig build interop is the one that matters. Everything else checks this library against what the RFC says or against itself, and neither finds the failure that counts: a signer and a verifier that are wrong in the same way agree with each other perfectly and with nobody else. A DKIM library nobody else can verify is worse than no DKIM library, because the mail still goes out.

So tests/interop signs and verifies against two other implementations — dkimpy, the Python one, and opendkim, the C milter most mail servers run — in both directions, over every canonicalization, across sixteen messages chosen for the things that have historically been got wrong: an empty body, a body with no trailing CRLF, trailing blank lines, runs of whitespace and tabs, folded headers, an empty field value, non-ASCII bytes, a line longer than any line buffer, repeated fields, and a body line that looks like a header. That is 144 checks, and they pass.

One of them is a documented disagreement, and it is recorded in the script rather than papered over. Given From : alice@example.com — a space before the colon, which RFC 5322's obsolete syntax permits — dkimpy's parser does not recognise the field at all and signs a message it believes has no From. This library accepts it, which is what RFC 6376 §3.4.2 rule (e) presumes by having a rule for deleting that very space, and opendkim agrees. It is also the safer reading: a verifier that cannot see a field cannot notice one being added in transit. The interop script expects that case to diverge and reports it if it ever stops diverging, since that would mean the other implementation changed.

The fuzz targets cover what a verifier reads, which is all attacker-supplied: the message, the signature field, and the key record that came back from a DNS lookup nobody had to be honest about. Most of the properties are the weak one — return something or return an error, but stay inside the buffer, terminate, and do not panic — because asserting more would mean claiming to know what a signature made of random bytes should say. Two are stronger and are the point: that canonicalizing twice changes nothing and that a message with bare LF hashes identically to the same message with CRLF; and that the verifier refuses everything, since the fuzzer will not stumble on a valid signature and so every input it generates is a forgery.

There are two ways to run them. zig build fuzz --fuzz is Zig's own fuzzer, with coverage feedback, which needs the test binary compiled by LLVM rather than the self-hosted backend Debug uses by default; build.zig sees to that. zig build fuzz-run is a loop of our own, described in tools/fuzz.zig: no coverage, but a corpus of inputs that already parse and a run that is the same on every machine for a given seed, which is what the workflow uses.

Things worth knowing if you are reading the code #

Four details account for most of the ways a DKIM implementation fails to interoperate, and each has a test named after it:

  1. The DKIM-Signature field is hashed last, with its own b= value removed and without a trailing CRLF. Every other field gets one. Get either half wrong and nothing you sign verifies anywhere, with no indication of which end is at fault.
  2. Fields are hashed in the order h= names them, not the order they appear in the message, and a name appearing twice takes successive fields from the bottom upwards — because a message gains headers at the top as it is relayed.
  3. A name in h= matching no field contributes nothing at all — not an empty line. Which is what makes it possible to sign a header's absence: listing subject twice when the message has one means a second one cannot be added in transit without breaking the signature.
  4. An empty body canonicalizes differently under the two algorithms: to "\r\n" under simple and to nothing at all under relaxed. The bh= for an empty message under simple is frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=, which is worth recognising on sight.

And one about the l= tag, which the library supports and you should not use: everything past the count is unsigned, so anyone at all can append anything to a signed message and the signature still verifies. RFC 6376 §8.2 is blunt about it. It exists so a mailing list can add a footer. Verified.covered and Verified.total report when it was used, so that a caller can treat a partial cover as a failure — which is a defensible policy, and cannot be one if nobody is told.

References #

  • RFC 6376, DomainKeys Identified Mail (DKIM) Signatures, the specification this implements.
  • RFC 8301, Cryptographic Algorithm and Key Usage Update to DKIM, which forbids rsa-sha1 and sets the key size floor.
  • RFC 8463, A New Cryptographic Signature Method for DKIM, which adds Ed25519.
  • RFC 5322, Internet Message Format, for what a header field is.
  • RFC 7489, DMARC, for what to do with a verified domain.

Licence #

MIT. See LICENSES/MIT.txt; the repository follows REUSE and nix develop -c reuse lint checks it.