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