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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! What actually gets hashed, RFC 6376 §3.7.//!//! Signing and verifying are the same computation with the arithmetic at the//! end pointing in opposite directions, so the computation lives here once.//! If a signer and a verifier disagree about a single byte of it — one space,//! one CRLF, one header taken from the wrong end of the message — then every//! signature made is rejected everywhere and every signature received is//! rejected here, and nothing says which. Sharing the code is what keeps the//! two ends from drifting apart.//!//! Two hashes are computed, and they are not computed over the same thing.//!//! **The body hash** covers the canonicalized body and lands in the `bh=` tag//! of the signature, where it is signed along with everything else. It exists//! so that a verifier can reject a message whose body has changed without//! doing any public key arithmetic at all — and so that `l=` can cover part//! of a body, which a signature over the whole message could not express.//!//! **The header hash** covers the header fields named in `h=` and then the//! `DKIM-Signature` field itself, and it is what the key signs.//!//! ## The three rules of the header hash//!//! These are the ones that are easy to get wrong, and each of them is a//! reason a DKIM implementation fails to interoperate://!//! 1. **Fields are hashed in the order `h=` names them**, not the order they//! appear in the message. `h=` is the specification of what to hash.//! 2. **A name appearing more than once in `h=` takes successive fields from//! the bottom of the message upwards.** A message gains headers at the//! top as it is relayed, so the bottom-most `Received` is the one that was//! there when the message was signed. A name in `h=` with no matching//! field contributes *nothing at all* — not an empty line — which is what//! lets `h=` list a field that is not there to stop one being added later.//! 3. **The `DKIM-Signature` field is hashed last, with its own `b=` value//! removed and without a trailing CRLF.** Every other field gets a CRLF.
const std = @import("std");const testing = std.testing;
const canonical = @import("canonical.zig");const message = @import("message.zig");const Signature = @import("Signature.zig");
const Sha1 = std.crypto.hash.Sha1;const Sha256 = std.crypto.hash.sha2.Sha256;
/// The longest digest any DKIM algorithm produces, which is SHA-256's.pub const max_digest_len = 32;
/// A digest and how long it is, so that SHA-1 and SHA-256 can be returned/// from the same function without either being heap-allocated or the caller/// having to switch on the algorithm to find out what it got.pub const Digest = struct { bytes: [max_digest_len]u8, len: usize,
pub fn slice(self: *const Digest) []const u8 { return self.bytes[0..self.len]; }
fn from(comptime Hash: type, hasher: *Hash) Digest { var digest: Digest = .{ .bytes = @splat(0), .len = Hash.digest_length }; hasher.final(digest.bytes[0..Hash.digest_length]); return digest; }};
/// The result of hashing a body.pub const BodyDigest = struct { digest: Digest, /// Octets the canonicalization produced in total. produced: u64, /// Octets actually hashed, which is less than `produced` when `l=` cut it /// short. hashed: u64,};
/// Hashes a message body.////// `limit` is the `l=` tag: `null` covers the whole body, which is what a/// signature should do.pub fn body( algorithm: Signature.Algorithm, canonicalization: canonical.Canonicalization, raw_body: []const u8, limit: ?u64,) BodyDigest { return switch (algorithm) { .rsa_sha1 => bodyWith(Sha1, canonicalization, raw_body, limit), .rsa_sha256, .ed25519_sha256 => bodyWith(Sha256, canonicalization, raw_body, limit), };}
fn bodyWith( comptime Hash: type, canonicalization: canonical.Canonicalization, raw_body: []const u8, limit: ?u64,) BodyDigest { var hasher: Hash = .init(.{}); if (limit) |l| { var limited: canonical.Limited(Hash) = .init(&hasher, l); canonical.body(&limited, canonicalization, raw_body); return .{ .digest = .from(Hash, &hasher), .produced = limited.produced, .hashed = limited.hashed, }; } var counter: Counting(Hash) = .{ .sink = &hasher }; canonical.body(&counter, canonicalization, raw_body); return .{ .digest = .from(Hash, &hasher), .produced = counter.count, .hashed = counter.count, };}
/// A sink that passes everything through and counts it.fn Counting(comptime Sink: type) type { return struct { sink: *Sink, count: u64 = 0,
const Self = @This();
pub fn update(self: *Self, bytes: []const u8) void { self.count += bytes.len; self.sink.update(bytes); } };}
/// The `DKIM-Signature` field as the header hash needs to see it: in two/// pieces, with the hole where its own `b=` value goes.pub const SignatureField = struct { /// The field name as written, for `simple`. raw_name: []const u8, /// The field name with any whitespace before the colon removed, for /// `relaxed`. name: []const u8, /// The value up to and including `b=`. before: []const u8, /// The value from the `;` that ends the `b=` tag, or empty if `b=` is /// last — which is where a signer this library writes always puts it. after: []const u8,
/// The two pieces of a signature already in a message. pub fn fromSignature(sig: Signature) SignatureField { return .{ .raw_name = sig.field.raw_name, .name = sig.field.name, .before = sig.valueBeforeSignature(), .after = sig.valueAfterSignature(), }; }};
/// Hashes the signed header fields and the signature field itself.////// `headers` is the `h=` value, walked in order. `block` is the message's/// header block. `exclude` is where the signature's own field starts, so that/// it can be passed over: §3.7 forbids a signature from being in its own `h=`/// while allowing it to sign other `DKIM-Signature` fields, and telling those/// apart is a matter of position rather than of name.pub fn headers( algorithm: Signature.Algorithm, canonicalization: canonical.Canonicalization, block: []const u8, h_tag: []const u8, signature_field: SignatureField, exclude: ?usize,) Digest { return switch (algorithm) { .rsa_sha1 => headersWith(Sha1, canonicalization, block, h_tag, signature_field, exclude), .rsa_sha256, .ed25519_sha256 => headersWith(Sha256, canonicalization, block, h_tag, signature_field, exclude), };}
fn headersWith( comptime Hash: type, canonicalization: canonical.Canonicalization, block: []const u8, h_tag: []const u8, signature_field: SignatureField, exclude: ?usize,) Digest { var hasher: Hash = .init(.{});
var it: Signature.HeaderIterator = .{ .rest = h_tag }; var position: usize = 0; while (it.next()) |name| : (position += 1) { // How many times this name has already been used decides which // instance to take, counting from the bottom. Recomputing it by // re-walking the prefix of `h=` keeps this allocation-free and holds // no state that could be got wrong; `h=` has a dozen entries at most. const occurrence = countBefore(h_tag, name, position); const field = message.findFromBottom(block, name, occurrence, exclude) orelse { // §5.4: a name in `h=` that matches no field contributes nothing. // Not an empty field, not a bare CRLF -- nothing. This is what // makes it possible to sign the *absence* of a header: listing // `subject` twice when there is one Subject field means a second // one cannot be added without breaking the signature. continue; }; canonical.header(&hasher, canonicalization, field, true); }
canonical.headerParts( &hasher, canonicalization, switch (canonicalization) { .simple => signature_field.raw_name, .relaxed => signature_field.name, }, &.{ signature_field.before, signature_field.after }, // §3.7: the signature's own field is hashed without a trailing CRLF. false, );
return .from(Hash, &hasher);}
/// How many of the first `position` entries of `h=` name this field.fn countBefore(h_tag: []const u8, name: []const u8, position: usize) usize { var it: Signature.HeaderIterator = .{ .rest = h_tag }; var seen: usize = 0; var i: usize = 0; while (it.next()) |other| : (i += 1) { if (i >= position) break; if (std.ascii.eqlIgnoreCase(other, name)) seen += 1; } return seen;}
// -- tests -------------------------------------------------------------------
fn base64(digest: Digest) [44]u8 { var out: [44]u8 = undefined; const encoded = std.base64.standard.Encoder.encode(&out, digest.slice()); std.debug.assert(encoded.len <= out.len); return out;}
test "the body hash of an empty body, both canonicalizations" { const simple = body(.rsa_sha256, .simple, "", null); try testing.expectEqualStrings( "frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=", &base64(simple.digest), ); try testing.expectEqual(@as(u64, 2), simple.produced);
const relaxed = body(.rsa_sha256, .relaxed, "", null); try testing.expectEqualStrings( "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", &base64(relaxed.digest), ); try testing.expectEqual(@as(u64, 0), relaxed.produced);}
test "l= reports what it covered and what it left out" { const all = body(.rsa_sha256, .relaxed, "hello world\r\n", null); try testing.expectEqual(@as(u64, 13), all.produced); try testing.expectEqual(@as(u64, 13), all.hashed);
const partial = body(.rsa_sha256, .relaxed, "hello world\r\n", 5); try testing.expectEqual(@as(u64, 13), partial.produced); try testing.expectEqual(@as(u64, 5), partial.hashed); // Hashing five octets of the body is hashing "hello", whatever follows. const just_hello = body(.rsa_sha256, .relaxed, "hello", 5); try testing.expectEqualStrings(&base64(partial.digest), &base64(just_hello.digest));
// A limit past the end covers the body and says the body was shorter. const over = body(.rsa_sha256, .relaxed, "hello\r\n", 1000); try testing.expectEqual(@as(u64, 7), over.produced); try testing.expectEqual(@as(u64, 7), over.hashed);}
test "SHA-1 body hashes are twenty bytes" { const d = body(.rsa_sha1, .simple, "", null); try testing.expectEqual(@as(usize, 20), d.digest.len);}
const test_block = "From: a@example.com\r\n" ++ "To: b@example.com\r\n" ++ "Subject: hello\r\n";
fn hashHeaders(h_tag: []const u8) Digest { return headers(.rsa_sha256, .relaxed, test_block, h_tag, .{ .raw_name = "DKIM-Signature", .name = "DKIM-Signature", .before = " v=1; b=", .after = "", }, null);}
/// Hashes the same thing by hand, to check the machinery against something/// written out in full.fn hashByHand(parts: []const []const u8) Digest { var hasher: Sha256 = .init(.{}); for (parts) |part| hasher.update(part); return .from(Sha256, &hasher);}
test "headers are hashed in the order h= names them" { // Not the order they appear in the message: `h=subject:from` hashes // Subject first even though From is above it. try testing.expectEqualStrings( &base64(hashByHand(&.{ "subject:hello\r\n", "from:a@example.com\r\n", "dkim-signature:v=1; b=", })), &base64(hashHeaders("subject:from")), );}
test "a name in h= that matches no field contributes nothing" { // Not an empty field and not a bare CRLF: nothing at all. try testing.expectEqualStrings( &base64(hashByHand(&.{ "from:a@example.com\r\n", "dkim-signature:v=1; b=", })), &base64(hashHeaders("from:x-not-here")), ); // ...which means listing an absent field is the same as not listing it, // and that is exactly how a signer signs a header's *absence*. try testing.expectEqualStrings( &base64(hashHeaders("from")), &base64(hashHeaders("from:x-not-here")), );}
test "a repeated name takes fields from the bottom upwards" { const relayed = "Received: third\r\n" ++ "Received: second\r\n" ++ "Received: first\r\n" ++ "From: a@example.com\r\n"; const digest = headers(.rsa_sha256, .relaxed, relayed, "received:received:from", .{ .raw_name = "DKIM-Signature", .name = "DKIM-Signature", .before = " v=1; b=", .after = "", }, null); try testing.expectEqualStrings( &base64(hashByHand(&.{ // The bottom-most first, because that is the one that was there // when the message was signed. "received:first\r\n", "received:second\r\n", "from:a@example.com\r\n", "dkim-signature:v=1; b=", })), &base64(digest), );}
test "asking for more instances than exist stops rather than repeating" { const one = "Received: only\r\nFrom: a@example.com\r\n"; const digest = headers(.rsa_sha256, .relaxed, one, "received:received:from", .{ .raw_name = "DKIM-Signature", .name = "DKIM-Signature", .before = " v=1; b=", .after = "", }, null); // The second `received` finds nothing, so it contributes nothing -- it // does not hash the same field twice. try testing.expectEqualStrings( &base64(hashByHand(&.{ "received:only\r\n", "from:a@example.com\r\n", "dkim-signature:v=1; b=", })), &base64(digest), );}
test "the signature field is hashed last and without a trailing CRLF" { // If it were terminated, this would differ. try testing.expectEqualStrings( &base64(hashByHand(&.{ "from:a@example.com\r\n", "dkim-signature:v=1; b=" })), &base64(hashHeaders("from")), ); try testing.expect(!std.mem.eql( u8, &base64(hashByHand(&.{ "from:a@example.com\r\n", "dkim-signature:v=1; b=\r\n" })), &base64(hashHeaders("from")), ));}
test "the two halves either side of b= are one value" { // A tag after `b=` means the field has a tail, and the join between the // two halves has to behave as if the value were continuous. const digest = headers(.rsa_sha256, .relaxed, test_block, "from", .{ .raw_name = "DKIM-Signature", .name = "DKIM-Signature", .before = " v=1; b=", .after = "; s=sel", }, null); try testing.expectEqualStrings( &base64(hashByHand(&.{ "from:a@example.com\r\n", "dkim-signature:v=1; b=; s=sel" })), &base64(digest), );}
test "simple canonicalization hashes the fields as written" { const digest = headers(.rsa_sha256, .simple, test_block, "from:subject", .{ .raw_name = "DKIM-Signature", .name = "DKIM-Signature", .before = " v=1; b=", .after = "", }, null); try testing.expectEqualStrings( &base64(hashByHand(&.{ "From: a@example.com\r\n", "Subject: hello\r\n", "DKIM-Signature: v=1; b=", })), &base64(digest), );}