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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! What the parsers must do with input nobody wrote.//!//! Everything a DKIM verifier reads comes from somewhere hostile. The message//! arrived over SMTP from a stranger; the `DKIM-Signature` field in it was//! written by whoever wanted it there; the key record came back from a DNS//! lookup that an attacker may have influenced and certainly did not have to//! be honest about. A verifier that can be made to read off the end of a//! buffer, loop forever, or panic is a denial of service on the mail server//! at best.//!//! So the property for the parsers is the weak one, and deliberately: return//! something or return an error, but stay inside the input, terminate, and do//! not panic. Asserting anything stronger would mean claiming to know what a//! signature made of random bytes *should* say, and for nearly all of them//! the answer is that it should say nothing.//!//! Two properties are stronger than that, and they are the ones worth having://!//! * **Canonicalization never grows its input by more than a constant//! factor,** and produces the same bytes whether the message arrived with//! CRLF or with bare LF. The second is what makes a message signed on a//! Unix system verify after it has been through SMTP.//! * **Verification refuses everything.** The fuzzer will not stumble on a//! valid signature, so every input it generates is a forgery attempt, and//! every one has to fail. A verifier that accepts something it should not//! is the only bug here that is worse than a crash.//!//! Each target is an ordinary test as well as a fuzz target, so `zig build//! test` runs the same properties over the seeds checked in beside them.
const builtin = @import("builtin");const std = @import("std");const Allocator = std.mem.Allocator;const testing = std.testing;const Smith = std.testing.Smith;
const dkim = @import("dkim");const keys = @import("keys.zig");
/// Unused -- nothing in this library allocates -- but the standalone driver/// sets it, so it has to exist.pub var backing: Allocator = if (builtin.is_test) testing.allocator else undefined;
// -- canonicalization --------------------------------------------------------
/// Both canonicalizations of both halves of a message, over anything at all.////// The assertions are about shape rather than content, because what the/// canonical form of arbitrary bytes *should* be is precisely what the/// implementation is; asserting it here would only restate the code. What can/// be asserted without restating it is that the output is bounded, that the/// invariants RFC 6376 states hold, and that line endings do not matter.fn canonicalProperty(input: []const u8) !void { const parts = dkim.message.split(input); // The split is exhaustive: nothing is lost between the two halves, and // the separator is at most the two bytes of a CRLF. try testing.expect(parts.headers.len + parts.body.len <= input.len);
const both = [_]dkim.canonical.Canonicalization{ .simple, .relaxed }; for (both) |algorithm| { var out: dkim.canonical.Collector(8192) = .{}; dkim.canonical.body(&out, algorithm, parts.body);
// Relaxing only ever removes bytes; `simple` adds at most one CRLF to // a body that had none, and turns each bare LF into a CRLF. So the // output can exceed the input, but only by a factor of two. try testing.expect(out.offered <= parts.body.len * 2 + 2);
if (out.offered <= out.buf.len) { const canonical_form = out.slice(); // §3.4.3 and §3.4.4: a canonical body is either empty or ends in // CRLF, and never ends in more than one. if (canonical_form.len > 0) { try testing.expect(std.mem.endsWith(u8, canonical_form, "\r\n")); if (canonical_form.len >= 4) { try testing.expect(!std.mem.endsWith(u8, canonical_form, "\r\n\r\n")); } } else { // Only `relaxed` may produce nothing at all. try testing.expectEqual(dkim.canonical.Canonicalization.relaxed, algorithm); } // Canonicalizing twice changes nothing: the canonical form of a // canonical body is itself. This is the property that catches a // rule applied inconsistently at the edges of the input. var again: dkim.canonical.Collector(8192) = .{}; dkim.canonical.body(&again, algorithm, canonical_form); if (again.offered <= again.buf.len and canonical_form.len > 0) { try testing.expectEqualSlices(u8, canonical_form, again.slice()); } } }}
test "fuzz canonical" { for (seeds) |seed| try canonicalProperty(seed); try testing.fuzz({}, fuzzCanonical, .{});}
fn fuzzCanonical(_: void, smith: *Smith) !void { var buffer: [4096]u8 = undefined; const len = smith.slice(&buffer); try canonicalProperty(buffer[0..len]);}
/// A message with bare LF hashes the same as the same message with CRLF.////// This is the one that matters in practice. A message signed out of a/// Maildir has LF endings and arrives at the verifier with CRLF, and if the/// two do not hash alike then every signature this library makes is rejected/// by everyone -- which is a failure no unit test over a fixed message finds,/// because a fixed message has whichever endings it was written with.fn lineEndingProperty(input: []const u8) !void { // Build the CRLF version of an input whose endings are all bare LF. var lf: [2048]u8 = undefined; var crlf: [4096]u8 = undefined; var lf_len: usize = 0; var crlf_len: usize = 0; for (input) |c| { // A CR in the input would make this test meaningless, since the two // forms would then differ in ways the normalization is not about. if (c == '\r') continue; if (lf_len == lf.len) break; lf[lf_len] = c; lf_len += 1; if (c == '\n') { crlf[crlf_len] = '\r'; crlf_len += 1; } crlf[crlf_len] = c; crlf_len += 1; }
const both = [_]dkim.canonical.Canonicalization{ .simple, .relaxed }; for (both) |algorithm| { const a = dkim.digest.body(.rsa_sha256, algorithm, dkim.message.split(lf[0..lf_len]).body, null); const b = dkim.digest.body(.rsa_sha256, algorithm, dkim.message.split(crlf[0..crlf_len]).body, null); try testing.expectEqualSlices(u8, a.digest.slice(), b.digest.slice()); }}
test "fuzz line endings" { for (seeds) |seed| try lineEndingProperty(seed); try testing.fuzz({}, fuzzLineEndings, .{});}
fn fuzzLineEndings(_: void, smith: *Smith) !void { var buffer: [2048]u8 = undefined; const len = smith.slice(&buffer); try lineEndingProperty(buffer[0..len]);}
// -- the tag lists -----------------------------------------------------------
/// The `tag=value` parser, the signature header and the key record, over/// arbitrary bytes.fn parseProperty(input: []const u8) !void { // The tag list walk, which everything else is built on. var it: dkim.tag_list.Iterator = .init(input); var count: usize = 0; while (it.next() catch null) |tag| { count += 1; // Every slice handed back has to point inside the input. try testing.expect(within(input, tag.name)); try testing.expect(within(input, tag.value)); var buf: [4096]u8 = undefined; if (tag.value.len <= buf.len) { // Stripping only removes bytes. const stripped = tag.stripped(&buf).?; try testing.expect(stripped.len <= tag.value.len); } // A list long enough to be an attack is still bounded work. if (count > 10_000) break; }
// A key record. if (dkim.Record.parse(input)) |record| { try testing.expect(within(input, record.key_data)); var key: [4096]u8 = undefined; _ = record.decodeKey(&key) catch {}; _ = record.acceptsEmail(); _ = record.acceptsHash(.rsa_sha256); } else |_| {}
// And a signature field, which needs the bytes to look enough like a // header for the field parser to find one at all. var header_buf: [4096]u8 = undefined; const prefix = "DKIM-Signature:"; if (prefix.len + input.len <= header_buf.len) { @memcpy(header_buf[0..prefix.len], prefix); @memcpy(header_buf[prefix.len..][0..input.len], input); const field = header_buf[0 .. prefix.len + input.len]; var headers: dkim.message.HeaderIterator = .init(field); if (headers.next()) |h| { if (dkim.Signature.parse(h)) |signature| { // The two halves either side of `b=` have to partition the // value: no byte counted twice, none lost. const before = signature.valueBeforeSignature(); const after = signature.valueAfterSignature(); try testing.expect(before.len + after.len <= signature.field.value.len); try testing.expect(within(field, before)); try testing.expect(within(field, after)); var scratch: [1024]u8 = undefined; _ = signature.decodeSignature(&scratch) catch {}; _ = signature.decodeBodyHash(&scratch) catch {}; var name: [253]u8 = undefined; _ = signature.recordName(&name) catch {}; var names = signature.signedHeaders(); while (names.next()) |n| try testing.expect(within(field, n)); } else |_| {} } }}
fn within(haystack: []const u8, needle: []const u8) bool { if (needle.len == 0) return true; const start = @intFromPtr(needle.ptr); const base = @intFromPtr(haystack.ptr); return start >= base and start + needle.len <= base + haystack.len;}
test "fuzz parse" { for (seeds) |seed| try parseProperty(seed); try testing.fuzz({}, fuzzParse, .{});}
fn fuzzParse(_: void, smith: *Smith) !void { var buffer: [2048]u8 = undefined; const len = smith.slice(&buffer); try parseProperty(buffer[0..len]);}
// -- verification ------------------------------------------------------------
/// Verification, over messages nobody signed.////// The chance of the fuzzer producing a valid signature is nil, so every/// input is a forgery and every one has to be refused. An input that verifies/// is either a bug of the worst kind or a very strange day.fn verifyProperty(input: []const u8) !void { const record = dkim.Record.parse("v=DKIM1; k=rsa; p=" ++ keys.rsa2048_public) catch unreachable;
var it = dkim.verification.signatures(input); var count: usize = 0; while (it.next()) |result| { count += 1; if (count > 100) break; const signature = result catch continue; if (dkim.verification.verify(input, signature, record, .{})) |_| { return error.ForgeryAccepted; } else |_| {} }}
test "fuzz verify" { for (seeds) |seed| try verifyProperty(seed); for (message_seeds) |seed| try verifyProperty(seed); try testing.fuzz({}, fuzzVerify, .{});}
fn fuzzVerify(_: void, smith: *Smith) !void { var buffer: [4096]u8 = undefined; const len = smith.slice(&buffer); try verifyProperty(buffer[0..len]);}
// -- the corpus --------------------------------------------------------------
/// The shapes a parser gets wrong before it gets anything else wrong.////// Mutating one of these reaches further into the code than random bytes/// ever will: the parsers are looking for `=`, `;`, `:` and CRLF, and a/// uniformly random buffer contains almost none of them in the right order.const seeds = [_][]const u8{ "", " ", ";", "=", ":", "\r\n", "\r\n\r\n", "\n", "a=b", "a=b;", "v=1; a=rsa-sha256; d=example.com; s=test; h=from; bh=AA==; b=AA==", "v=DKIM1; k=rsa; p=AAAA", "v=DKIM1; p=", "v=DKIM1; k=ed25519; p=AAAA", "From: a@b\r\n\r\nbody\r\n", "From: a@b\r\n", "From: a@b\r\n\r\n", "\r\nbody", "From: a\r\n b\r\n\tc\r\n\r\n", "a:b:c", "h=from:to:subject", "b=AAAA\r\n\tBBBB", "l=99999999999999999999999999", "t=1; x=0", "i=@example.com",};
/// Whole messages with a signature on them, so that the verifier is reached/// rather than bailing out at the first field.const message_seeds = [_][]const u8{ "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;\r\n" ++ "\ts=test; h=from; bh=47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=;\r\n" ++ "\tb=AAAA\r\nFrom: a@example.com\r\n\r\n", "DKIM-Signature: v=1; a=ed25519-sha256; d=example.com; s=t; h=from;\r\n" ++ "\tbh=AA==; b=AA==\r\nFrom: a@example.com\r\n\r\nbody\r\n", "DKIM-Signature: v=1\r\nFrom: a@b\r\n\r\n", "dkim-signature: v=1; a=rsa-sha256; d=x; s=y; h=from; bh=; b=\r\nFrom: a@b\r\n\r\n",};
// -- the table the standalone driver reads ----------------------------------
pub const Target = struct { name: []const u8, run: *const fn (input: []const u8) anyerror!void, corpus: []const []const u8, /// The buffer this target reads its input into. /// /// `Smith.slice` yields an *empty* slice for a length larger than the /// buffer rather than a truncated one, so a generator that does not know /// this number hands the target nothing at all most of the time. content_max: usize,};
fn Driven(comptime one: fn (void, *Smith) anyerror!void) type { return struct { fn run(input: []const u8) anyerror!void { var smith: Smith = .{ .in = input }; return one({}, &smith); } };}
pub const all = [_]Target{ .{ .name = "canonical", .run = Driven(fuzzCanonical).run, .corpus = &seeds, .content_max = 4096, }, .{ .name = "line-endings", .run = Driven(fuzzLineEndings).run, .corpus = &seeds, .content_max = 2048, }, .{ .name = "parse", .run = Driven(fuzzParse).run, .corpus = &seeds, .content_max = 2048, }, .{ .name = "verify", .run = Driven(fuzzVerify).run, .corpus = &message_seeds, .content_max = 4096, },};