// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Taking an RFC 5322 message apart far enough to sign it. //! //! This is not a mail parser and does not want to be. DKIM needs to know //! where the headers stop, where each header field starts and ends, and where //! the name ends and the value begins — and nothing else at all. It never //! looks inside a value, never decodes a MIME part, never cares what a //! `Received` field means. //! //! ## Line endings //! //! RFC 5322 messages end their lines with CRLF and RFC 6376 hashes them that //! way, but a message that has been sitting in a Maildir or come out of a //! pipe usually has bare LF, because that is what almost everything on a Unix //! system does to it. Refusing those would make the library useless for the //! job people actually have; hashing them as they lie would compute a //! different hash from the one the signer computed, and every signature would //! fail. //! //! So line endings are **normalized to CRLF** on the way into the hash, and //! `Line.terminator` reports what was actually there. A bare CR that is not //! followed by LF is ordinary data and is left alone: SMTP forbids it, so a //! message containing one is already outside the specification, and guessing //! at what it meant would be a way to make two implementations disagree about //! what was signed. const std = @import("std"); const testing = std.testing; /// A message, split at the empty line. pub const Message = struct { /// Everything before the empty line, including the line terminator of the /// last header field. Empty if the message begins with an empty line. headers: []const u8, /// Everything after the empty line. Empty if there is no body — which is /// not the same as a body of one empty line, and the two canonicalize /// differently. body: []const u8, }; /// Splits a message into its header block and its body. /// /// The separator is the first empty line, whatever its terminator. A message /// with no empty line in it is all headers and no body, which is what a /// message truncated in transit looks like and is treated as exactly that /// rather than as an error — the signature over it will simply not verify. pub fn split(raw: []const u8) Message { var lines: LineIterator = .init(raw); while (lines.next()) |line| { if (line.content.len == 0) { return .{ .headers = raw[0..line.start], .body = raw[line.start + line.terminator.len ..], }; } } return .{ .headers = raw, .body = "" }; } /// One line of a message. pub const Line = struct { /// The line's bytes, without its terminator. content: []const u8, /// The terminator exactly as it appeared: `"\r\n"`, `"\n"`, or `""` for a /// final line that the message simply stopped after. terminator: []const u8, /// Where the line starts in the buffer it came from. start: usize, }; /// Walks the lines of a message, accepting CRLF and bare LF alike. pub const LineIterator = struct { buf: []const u8, pos: usize = 0, pub fn init(buf: []const u8) LineIterator { return .{ .buf = buf }; } pub fn next(self: *LineIterator) ?Line { if (self.pos >= self.buf.len) return null; const start = self.pos; const rest = self.buf[start..]; if (std.mem.findScalar(u8, rest, '\n')) |nl| { // A CR immediately before the LF is part of the terminator; one // anywhere else is data. const has_cr = nl > 0 and rest[nl - 1] == '\r'; const end = if (has_cr) nl - 1 else nl; self.pos = start + nl + 1; return .{ .content = rest[0..end], .terminator = if (has_cr) "\r\n" else "\n", .start = start, }; } self.pos = self.buf.len; return .{ .content = rest, .terminator = "", .start = start }; } }; /// One header field, as it sits in the message. pub const Header = struct { /// The whole field — name, colon, value, and the CRLF and leading /// whitespace of every continuation line — but **not** the terminator of /// its own last line. /// /// This is what "simple" canonicalization signs, so it is kept byte for /// byte rather than reassembled. raw: []const u8, /// The field name with no surrounding whitespace, which is what `h=` /// names and what one field is compared to another by. name: []const u8, /// The bytes before the colon exactly as written, which may carry /// trailing whitespace: RFC 5322's obsolete syntax allows `From : a@b`, /// and DKIM's relaxed canonicalization has a rule for deleting it, so /// both forms turn up. `simple` canonicalization needs these bytes rather /// than the trimmed name, since it has to reproduce the field exactly. raw_name: []const u8, /// Everything after the colon, folds and all, with nothing trimmed. value: []const u8, /// Where the field starts in the header block. start: usize, /// Whether this field's name is `name`, compared the way RFC 5322 says /// to: without regard to case. pub fn is(self: Header, name: []const u8) bool { return std.ascii.eqlIgnoreCase(self.name, name); } }; /// Walks the header fields of a header block. /// /// A field runs from a line that does not start with whitespace up to the /// last line before the next one that does not — which is how RFC 5322 /// folding works, and means a field is found without understanding anything /// about what it contains. /// /// A line that is a continuation with nothing to continue, which is what a /// message whose first line is indented looks like, is skipped: it belongs to /// no field, so there is no field for a signature to name. pub const HeaderIterator = struct { block: []const u8, lines: LineIterator, /// The line held back from the last call, which had already been read /// before it turned out to belong to the next field. pending: ?Line = null, pub fn init(block: []const u8) HeaderIterator { return .{ .block = block, .lines = .init(block) }; } pub fn next(self: *HeaderIterator) ?Header { var first = self.pending orelse self.lines.next() orelse return null; self.pending = null; // Skip continuation lines with no field above them, and empty lines, // neither of which can begin a field. while (first.content.len == 0 or isWsp(first.content[0])) { first = self.lines.next() orelse return null; } const start = first.start; var end = first.start + first.content.len; while (self.lines.next()) |line| { if (line.content.len == 0 or !isWsp(line.content[0])) { self.pending = line; break; } end = line.start + line.content.len; } const raw = self.block[start..end]; const colon = std.mem.findScalar(u8, raw, ':') orelse { // A field with no colon is not a field. RFC 5322 has no such // thing, and DKIM cannot name it in `h=` because it has no name, // so it is passed over rather than guessed at. return self.next(); }; return .{ .raw = raw, .name = std.mem.trimEnd(u8, raw[0..colon], " \t"), .raw_name = raw[0..colon], .value = raw[colon + 1 ..], .start = start, }; } }; /// Finds the `n`th-from-last field with this name, counting from zero. /// /// `exclude`, if given, is the `start` of a field to pass over as though it /// were not there. /// /// Counting from the bottom is not an eccentricity: RFC 6376 §5.4.2 requires /// that when `h=` names a field more than once, the instances are taken from /// the bottom of the header block upwards. The reason is that 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 and the top-most is the one /// added since. pub fn findFromBottom(block: []const u8, name: []const u8, n: usize, exclude: ?usize) ?Header { var it: HeaderIterator = .init(block); var matches: [64]Header = undefined; var count: usize = 0; while (it.next()) |header| { if (!header.is(name)) continue; // The field at `exclude` is passed over entirely, as though it were // not in the message. RFC 6376 §3.7 needs this: a signature must not // be in its own `h=`, but it may sign *other* DKIM-Signature fields, // so the one being verified has to be invisible to the walk while the // others stay visible. if (exclude) |offset| { if (header.start == offset) continue; } if (count < matches.len) { matches[count] = header; } else { // More than 64 fields of one name. Keep the last 64, since it is // the bottom of the message that `h=` counts from, and a `h=` // long enough to reach past them is not something any signer // produces. std.mem.copyForwards(Header, matches[0 .. matches.len - 1], matches[1..]); matches[matches.len - 1] = header; } count += 1; } const held = @min(count, matches.len); if (n >= held) return null; return matches[held - 1 - n]; } fn isWsp(c: u8) bool { return c == ' ' or c == '\t'; } // -- tests ------------------------------------------------------------------- test "a message splits at the empty line" { const m = split("From: a@b\r\nTo: c@d\r\n\r\nbody\r\n"); try testing.expectEqualStrings("From: a@b\r\nTo: c@d\r\n", m.headers); try testing.expectEqualStrings("body\r\n", m.body); } test "a message with LF endings splits the same way" { const m = split("From: a@b\nTo: c@d\n\nbody\n"); try testing.expectEqualStrings("From: a@b\nTo: c@d\n", m.headers); try testing.expectEqualStrings("body\n", m.body); } test "a message with no body" { // No empty line at all: everything is headers. const m = split("From: a@b\r\n"); try testing.expectEqualStrings("From: a@b\r\n", m.headers); try testing.expectEqualStrings("", m.body); // An empty line and then nothing: headers, and a body that is empty. // These two are different messages and canonicalize differently. const n = split("From: a@b\r\n\r\n"); try testing.expectEqualStrings("From: a@b\r\n", n.headers); try testing.expectEqualStrings("", n.body); } test "a message that is all body" { const m = split("\r\nbody"); try testing.expectEqualStrings("", m.headers); try testing.expectEqualStrings("body", m.body); } test "the body keeps every empty line that is not the separator" { const m = split("From: a@b\r\n\r\n\r\n\r\nbody\r\n\r\n"); try testing.expectEqualStrings("\r\n\r\nbody\r\n\r\n", m.body); } fn expectHeaders(block: []const u8, expected: []const [2][]const u8) !void { var it: HeaderIterator = .init(block); var i: usize = 0; while (it.next()) |h| : (i += 1) { try testing.expect(i < expected.len); try testing.expectEqualStrings(expected[i][0], h.name); try testing.expectEqualStrings(expected[i][1], h.value); } try testing.expectEqual(expected.len, i); } test "header fields, one per line" { try expectHeaders("From: a@b\r\nTo: c@d\r\n", &.{ .{ "From", " a@b" }, .{ "To", " c@d" }, }); } test "a folded header field is one field" { var it: HeaderIterator = .init("Subject: a\r\n b\r\n\tc\r\nTo: d\r\n"); const subject = it.next().?; try testing.expectEqualStrings("Subject", subject.name); try testing.expectEqualStrings(" a\r\n b\r\n\tc", subject.value); try testing.expectEqualStrings("Subject: a\r\n b\r\n\tc", subject.raw); try testing.expectEqualStrings("To", it.next().?.name); try testing.expectEqual(@as(?Header, null), it.next()); } test "a field with an empty value" { try expectHeaders("X-Empty:\r\nTo: c@d\r\n", &.{ .{ "X-Empty", "" }, .{ "To", " c@d" }, }); } test "the last field need not be terminated" { try expectHeaders("From: a@b", &.{.{ "From", " a@b" }}); } test "a line with no colon is not a field" { try expectHeaders("garbage\r\nFrom: a@b\r\n", &.{.{ "From", " a@b" }}); } test "a header block beginning with a continuation line" { // Nothing to continue, so the line belongs to no field. try expectHeaders(" orphan\r\nFrom: a@b\r\n", &.{.{ "From", " a@b" }}); } test "whitespace before the colon is not part of the name" { // RFC 5322's obsolete syntax allows it, and relaxed canonicalization has // a rule for deleting it, so it has to be understood rather than refused. var it: HeaderIterator = .init("From : a@b\r\n"); const h = it.next().?; try testing.expectEqualStrings("From", h.name); try testing.expectEqualStrings("From ", h.raw_name); try testing.expect(h.is("from")); } test "field names are matched without regard to case" { var it: HeaderIterator = .init("FROM: a@b\r\n"); const h = it.next().?; try testing.expect(h.is("from")); try testing.expect(h.is("From")); try testing.expect(!h.is("to")); } test "repeated fields are counted from the bottom" { const block = "Received: three\r\n" ++ "Received: two\r\n" ++ "Received: one\r\n" ++ "From: a@b\r\n"; try testing.expectEqualStrings(" one", findFromBottom(block, "received", 0, null).?.value); try testing.expectEqualStrings(" two", findFromBottom(block, "received", 1, null).?.value); try testing.expectEqualStrings(" three", findFromBottom(block, "Received", 2, null).?.value); try testing.expectEqual(@as(?Header, null), findFromBottom(block, "received", 3, null)); try testing.expectEqualStrings(" a@b", findFromBottom(block, "from", 0, null).?.value); try testing.expectEqual(@as(?Header, null), findFromBottom(block, "subject", 0, null)); } test "a field can be excluded from the walk by where it starts" { // What a DKIM-Signature needs when `h=` names `dkim-signature`: the // signature being verified is not in its own list, but any other is. const block = "DKIM-Signature: first\r\n" ++ "DKIM-Signature: second\r\n" ++ "From: a@b\r\n"; const second = findFromBottom(block, "dkim-signature", 0, null).?; try testing.expectEqualStrings(" second", second.value); // Excluding it leaves the other one as the bottom-most. try testing.expectEqualStrings( " first", findFromBottom(block, "dkim-signature", 0, second.start).?.value, ); // ...and there is then only one. try testing.expectEqual( @as(?Header, null), findFromBottom(block, "dkim-signature", 1, second.start), ); } test "lines are iterated with either terminator" { var it: LineIterator = .init("a\r\nb\nc"); const a = it.next().?; try testing.expectEqualStrings("a", a.content); try testing.expectEqualStrings("\r\n", a.terminator); const b = it.next().?; try testing.expectEqualStrings("b", b.content); try testing.expectEqualStrings("\n", b.terminator); const c = it.next().?; try testing.expectEqualStrings("c", c.content); try testing.expectEqualStrings("", c.terminator); try testing.expectEqual(@as(?Line, null), it.next()); } test "a lone CR is data, not a line ending" { var it: LineIterator = .init("a\rb\r\n"); const line = it.next().?; try testing.expectEqualStrings("a\rb", line.content); try testing.expectEqualStrings("\r\n", line.terminator); try testing.expectEqual(@as(?Line, null), it.next()); }