- Zig 92.3%
- Python 4.1%
- Nix 3.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
It hides the constant-time select mask in ff from the optimizer. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VNyiEdYtM9vjko2Qt4rzPL |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| build.zig.zon.nix | ||
| flake.lock | ||
| flake.nix | ||
| package.nix | ||
| README.md | ||
| REUSE.toml | ||
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.
-
Radicle, the peer-to-peer forge, as
rad:zVMTM5eCvsCiKnfSiRiDUqFPeZ1U:$ rad clone rad:zVMTM5eCvsCiKnfSiRiDUqFPeZ1UA 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 | shand thenrad 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 : [email protected] — 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:
- The
DKIM-Signaturefield is hashed last, with its ownb=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. - 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. - 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: listingsubjecttwice when the message has one means a second one cannot be added in transit without breaking the signature. - An empty body canonicalizes differently under the two algorithms: to
"\r\n"undersimpleand to nothing at all underrelaxed. Thebh=for an empty message undersimpleisfrcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/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-sha1and 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.