// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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), ); }