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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The `DKIM-Signature` header field, RFC 6376 §3.5.//!//! A parsed signature borrows every one of its slices from the message it was//! read out of. Nothing is copied and nothing is allocated, so the message//! must outlive the `Signature` — which costs nothing in the shape this is//! used in, where a verifier holds the message anyway.//!//! ## The `b=` tag and why it needs a hole in it//!//! A signature covers the header fields named in `h=`, and then it covers//! *itself*: the `DKIM-Signature` field is hashed last, so that nobody can//! alter the signature's own terms — swap the domain, extend the expiry,//! shorten `h=` — without breaking it.//!//! That is circular unless something is left out, and what is left out is the//! signature itself. §3.7 says the field is hashed "with the value of the `b=`//! tag (including all surrounding whitespace) deleted", so `b=AbCd` becomes//! `b=` and everything else stays exactly as it is. `bodyBeforeSignature` and//! `bodyAfterSignature` are the two halves either side of that hole.//!//! The other half of §3.7 is that this last field is hashed **without a//! trailing CRLF**, unlike every field before it. Both of those are easy to//! get wrong and neither fails loudly: what happens is that every signature//! made is rejected by everyone else, and every signature received is//! rejected here, with no indication of which end is at fault.
const std = @import("std");const testing = std.testing;
const canonical = @import("canonical.zig");const message = @import("message.zig");const tag_list = @import("tag_list.zig");
const Signature = @This();
/// The signing algorithm, from the `a=` tag.pub const Algorithm = enum { /// RFC 6376's original. **RFC 8301 forbids it**: signers must not produce /// one and verifiers must treat one as invalid. It is parsed so that such /// a signature can be recognised and reported, not so that it can be /// trusted; `VerifyOptions.allow_rsa_sha1` is what it takes to accept one, /// and there is no good reason to set it. rsa_sha1, /// What almost every signature in the world is. rsa_sha256, /// RFC 8463. Short keys that fit in a DNS record without splitting it, /// and signatures a fraction of the cost, but a verifier that does not /// know it treats the signature as unverifiable — so it is published /// alongside an RSA signature rather than instead of one. ed25519_sha256,
pub fn parse(name: []const u8) error{UnknownAlgorithm}!Algorithm { // §3.5 makes `a=` a `sig-a-tag-alg`, which is a hyphenated-word pair, // and hyphenated-words are compared without regard to case. if (std.ascii.eqlIgnoreCase(name, "rsa-sha256")) return .rsa_sha256; if (std.ascii.eqlIgnoreCase(name, "rsa-sha1")) return .rsa_sha1; if (std.ascii.eqlIgnoreCase(name, "ed25519-sha256")) return .ed25519_sha256; return error.UnknownAlgorithm; }
pub fn toString(self: Algorithm) []const u8 { return switch (self) { .rsa_sha1 => "rsa-sha1", .rsa_sha256 => "rsa-sha256", .ed25519_sha256 => "ed25519-sha256", }; }
/// The key type this algorithm needs, which is what the record's `k=` /// tag has to agree with. pub fn keyType(self: Algorithm) KeyType { return switch (self) { .rsa_sha1, .rsa_sha256 => .rsa, .ed25519_sha256 => .ed25519, }; }
/// The length of this algorithm's digest, in bytes. pub fn digestLength(self: Algorithm) usize { return switch (self) { .rsa_sha1 => 20, .rsa_sha256, .ed25519_sha256 => 32, }; }};
/// The key type, from a record's `k=` tag.pub const KeyType = enum { rsa, ed25519,
pub fn parse(name: []const u8) error{UnknownKeyType}!KeyType { if (std.ascii.eqlIgnoreCase(name, "rsa")) return .rsa; if (std.ascii.eqlIgnoreCase(name, "ed25519")) return .ed25519; return error.UnknownKeyType; }
pub fn toString(self: KeyType) []const u8 { return @tagName(self); }};
/// What can go wrong reading a signature.////// Every one of these is a *permanent* failure in the sense of RFC 6376 §6:/// the signature will never verify, no matter how many times it is retried,/// so a verifier should stop rather than defer. That is the distinction/// `Verification` carries.pub const ParseError = tag_list.Error || error{ UnknownAlgorithm, UnknownCanonicalization, /// A `v=` that is not `1`, or missing. UnsupportedVersion, /// One of `a=`, `b=`, `bh=`, `d=`, `h=` or `s=` is not there. §3.5 makes /// all six mandatory. MissingRequiredTag, /// `h=` does not name `from`. §5.4 requires it: a signature that does not /// cover the From field says nothing about who the message is from, which /// is the only question DKIM exists to answer. FromNotSigned, /// `l=`, `t=` or `x=` is not a number, or is one too large to be one. InvalidNumber, /// `x=` is not after `t=`, so the signature expired before it was made. ExpiresBeforeSigned, /// `i=` is not inside `d=`. §3.5 requires the AUID to be the SDID or a /// subdomain of it; otherwise a domain could sign as anybody. IdentityOutsideDomain, /// `b=` or `bh=` is not base64, or decodes to the wrong length. InvalidBase64, /// `bh=` is not the length of its algorithm's digest. InvalidBodyHash,};
/// The field this was parsed from, kept whole because hashing it is the last/// thing verification does.field: message.Header,/// The tag list, which is the field value.tags: []const u8,
algorithm: Algorithm,canonicalization: canonical.Algorithms,/// `d=`, the domain taking responsibility for the message.domain: []const u8,/// `s=`, which with the domain names the DNS record holding the public key.selector: []const u8,/// `h=`, the colon-separated list of signed field names, as written. Use/// `signedHeaders` to walk it.headers: []const u8,/// `b=`, the signature, with folding whitespace still in it.signature: []const u8,/// `bh=`, the body hash, with folding whitespace still in it.body_hash: []const u8,/// `i=`, the identity, if the signature carries one.identity: ?[]const u8,/// `l=`, the number of canonicalized body octets covered. Absent means all/// of them, which is what a signer should mean.body_length: ?u64,/// `t=`, when the signature was made, in seconds since the epoch.timestamp: ?u64,/// `x=`, when it stops being valid.expiration: ?u64,/// `q=`, how to fetch the key. Only `dns/txt` is defined, and it is the/// default; a signature naming anything else cannot be verified.query_methods: ?[]const u8,/// Where in `field.value` the `b=` value sits, so that it can be left out of/// the hash.b_span: Span,
/// A half-open range within `field.value`.pub const Span = struct { start: usize, end: usize };
/// Reads a signature out of a `DKIM-Signature` header field.////// The field is validated as far as it can be without a key: every required/// tag present, every number a number, `h=` naming `from`, `i=` inside `d=`./// What is *not* checked here is anything needing the message or the key —/// the body hash, the signature itself, and whether the clock says it has/// expired.pub fn parse(field: message.Header) ParseError!Signature { return parseWithOptions(field, .{});}
/// What a caller will accept in place of a DKIM-Signature.////// The defaults are §3.5's rules exactly. The exceptions exist for ARC/// ([RFC 8617](https://datatracker.ietf.org/doc/html/rfc8617)), whose/// two signature fields are DKIM signatures with tags taken away:/// §4.1.2 defines no version tag for an `ARC-Message-Signature`, and/// §4.1.3's `ARC-Seal` has neither a version nor a body hash, because/// it does not cover a body at all.////// They are options here rather than a parser of ARC's own because the/// grammar, the canonicalization and the crypto are the same and only/// the required set differs — and a second parser would be a second/// place for the difference between them to drift.pub const ParseOptions = struct { /// Whether a `v=1` tag is required. ARC's fields define none. require_version: bool = true, /// Whether a `bh=` tag is required. An `ARC-Seal` signs no body. require_body_hash: bool = true, /// Whether an `h=` tag is required. An `ARC-Seal` covers a fixed /// set of fields, so §4.1.3 forbids one. require_headers: bool = true,
/// Whether to refuse an interior empty tag-spec, which §3.2's /// grammar does not allow and this library ordinarily tolerates. /// See `tag_list.validateStrict`. strict_tags: bool = false,
/// Whether `i=` is DKIM's identity. /// /// It is the one outright collision between the two specifications: /// §3.5 makes `i=` an AUID, an address that must lie within `d=`, /// while RFC 8617 §4.2.1 makes it the instance number of an ARC /// set. Same letter, unrelated meanings — so a parser reading an /// ARC field must be told not to check it as an address, or every /// ARC field fails validation for having an identity outside its /// domain. identity_tag: bool = true,};
pub fn parseWithOptions(field: message.Header, options: ParseOptions) ParseError!Signature { const tags = field.value; if (options.strict_tags) try tag_list.validateStrict(tags) else try tag_list.validate(tags);
// §3.5: "Signers MUST include this tag and Verifiers MUST return // PERMFAIL if it is absent or has a value other than 1", and it must be // first. The ordering is not enforced -- nothing else depends on it and // signatures in the wild are not always careful -- but the value is. if (options.require_version) { const version = try require(tags, "v"); if (!std.mem.eql(u8, version.value, "1")) return error.UnsupportedVersion; } else if (try tag_list.find(tags, "v")) |version| { // §4.1.2: "As required for undefined tags, if seen, a version // tag MUST be ignored" -- so it is not checked, only tolerated. _ = version; }
const algorithm = try Algorithm.parse((try require(tags, "a")).value); const domain = (try require(tags, "d")).value; const selector = (try require(tags, "s")).value; const headers = if (options.require_headers) (try require(tags, "h")).value else if (try tag_list.find(tags, "h")) |tag| tag.value else ""; const signature = (try require(tags, "b")).value; const body_hash = if (options.require_body_hash) (try require(tags, "bh")).value else if (try tag_list.find(tags, "bh")) |tag| tag.value else "";
const canon = if (try tag_list.find(tags, "c")) |tag| try canonical.Algorithms.parse(tag.value) else // §3.5: a missing `c=` is `simple/simple`, not `relaxed/relaxed`. canonical.Algorithms{};
const identity = if (options.identity_tag) if (try tag_list.find(tags, "i")) |tag| tag.value else null else null; if (identity) |id| { if (!identityWithin(id, domain)) return error.IdentityOutsideDomain; }
const body_length = try optionalNumber(tags, "l"); const timestamp = try optionalNumber(tags, "t"); const expiration = try optionalNumber(tags, "x"); if (timestamp != null and expiration != null and expiration.? <= timestamp.?) { // §3.5 of RFC 6376: "the value of the x= tag MUST be greater than the // value of the t= tag if both are present". return error.ExpiresBeforeSigned; }
// §5.4: a signature that does not cover the From field says nothing // about who the message is from. An `ARC-Seal` covers no `h=` at // all — it signs a fixed list of ARC fields, and the From field is // the `ARC-Message-Signature`'s business — so the requirement goes // with the tag. if (options.require_headers and !namesFrom(headers)) return error.FromNotSigned;
return .{ .field = field, .tags = tags, .algorithm = algorithm, .canonicalization = canon, .domain = domain, .selector = selector, .headers = headers, .signature = signature, .body_hash = body_hash, .identity = identity, .body_length = body_length, .timestamp = timestamp, .expiration = expiration, .query_methods = if (try tag_list.find(tags, "q")) |tag| tag.value else null, .b_span = findBSpan(tags) orelse return error.MissingRequiredTag, };}
/// The part of the field value before the `b=` value, which is hashed.pub fn valueBeforeSignature(self: Signature) []const u8 { return self.field.value[0..self.b_span.start];}
/// The part after it, which is also hashed. Between them they are the field/// with its signature removed and nothing else changed.pub fn valueAfterSignature(self: Signature) []const u8 { return self.field.value[self.b_span.end..];}
/// The signature bytes, base64-decoded into `buf`.pub fn decodeSignature(self: Signature, buf: []u8) ParseError![]const u8 { return decodeBase64(buf, self.signature);}
/// The body hash, base64-decoded into `buf`, checked for length.pub fn decodeBodyHash(self: Signature, buf: []u8) ParseError![]const u8 { const decoded = try decodeBase64(buf, self.body_hash); if (decoded.len != self.algorithm.digestLength()) return error.InvalidBodyHash; return decoded;}
/// The name to look the public key up under: `<selector>._domainkey.<domain>`.////// Written into `buf`, which needs 253 bytes to hold any name DNS allows.pub fn recordName(self: Signature, buf: []u8) error{NameTooLong}![]const u8 { const needed = self.selector.len + "._domainkey.".len + self.domain.len; if (needed > buf.len) return error.NameTooLong; var w: std.Io.Writer = .fixed(buf); w.print("{s}._domainkey.{s}", .{ self.selector, self.domain }) catch unreachable; return w.buffered();}
/// Walks the field names in `h=`.////// Names are separated by colons and may have folding whitespace around/// them, which is removed. An empty name — which is what `h=from::to` has in/// the middle — is skipped rather than refused: §3.5 allows the list to be/// generated loosely, and a name that is not there matches no field.pub const HeaderIterator = struct { rest: []const u8,
pub fn next(self: *HeaderIterator) ?[]const u8 { while (self.rest.len > 0) { const name = blk: { if (std.mem.findScalar(u8, self.rest, ':')) |i| { defer self.rest = self.rest[i + 1 ..]; break :blk self.rest[0..i]; } defer self.rest = self.rest[self.rest.len..]; break :blk self.rest; }; const trimmed = std.mem.trim(u8, name, " \t\r\n"); if (trimmed.len > 0) return trimmed; } return null; }};
/// The field names this signature covers, in the order they are hashed in.pub fn signedHeaders(self: Signature) HeaderIterator { return .{ .rest = self.headers };}
/// Whether the signature has expired, given the time.////// The time is passed in rather than read, because a library that reads the/// clock cannot be tested against a signature that expired in 2019 and cannot/// be used by a caller whose idea of now is not the system's.pub fn hasExpired(self: Signature, now: i64) bool { const expiry = self.expiration orelse return false; if (now < 0) return false; return @as(u64, @intCast(now)) > expiry;}
// -- parsing helpers ---------------------------------------------------------
fn require(tags: []const u8, name: []const u8) ParseError!tag_list.Tag { return (try tag_list.find(tags, name)) orelse error.MissingRequiredTag;}
fn optionalNumber(tags: []const u8, name: []const u8) ParseError!?u64 { const tag = (try tag_list.find(tags, name)) orelse return null; // §3.5 allows folding whitespace inside these, since they are `*DIGIT` // with FWS permitted between `tval`s. var buf: [32]u8 = undefined; const digits = tag.stripped(&buf) orelse return error.InvalidNumber; if (digits.len == 0) return error.InvalidNumber; for (digits) |c| if (!std.ascii.isDigit(c)) return error.InvalidNumber; // A count that does not fit in 64 bits is not a count. `l=` is capped at // 76 digits by the ABNF and `t=`/`x=` are seconds since 1970, so anything // this rejects was never meaningful. return std.fmt.parseInt(u64, digits, 10) catch error.InvalidNumber;}
fn decodeBase64(buf: []u8, value: []const u8) ParseError![]const u8 { var packed_buf: [1024]u8 = undefined; const tag: tag_list.Tag = .{ .name = "", .value = value }; const text = tag.stripped(&packed_buf) orelse return error.InvalidBase64; const decoder = std.base64.standard.Decoder; const len = decoder.calcSizeForSlice(text) catch return error.InvalidBase64; if (len > buf.len) return error.InvalidBase64; decoder.decode(buf[0..len], text) catch return error.InvalidBase64; return buf[0..len];}
/// Whether `h=` names the From field, ignoring case and whitespace.fn namesFrom(headers: []const u8) bool { var it: HeaderIterator = .{ .rest = headers }; while (it.next()) |name| { if (std.ascii.eqlIgnoreCase(name, "from")) return true; } return false;}
/// Whether an `i=` identity lies within a `d=` domain.////// The identity is `local-part@domain` or just `@domain`; the domain part/// must be the SDID or a subdomain of it. `@sub.example.com` is inside/// `example.com`, and `@notexample.com` is not — which is why the check is on/// a dot boundary rather than a suffix.fn identityWithin(identity: []const u8, domain: []const u8) bool { const at = std.mem.lastIndexOfScalar(u8, identity, '@') orelse return false; const id_domain = identity[at + 1 ..]; if (std.ascii.eqlIgnoreCase(id_domain, domain)) return true; if (id_domain.len <= domain.len + 1) return false; const boundary = id_domain.len - domain.len - 1; if (id_domain[boundary] != '.') return false; return std.ascii.eqlIgnoreCase(id_domain[boundary + 1 ..], domain);}
/// Finds where the `b=` value sits within the tag list.////// This walks the list by hand rather than through `tag_list.Iterator`,/// because what is wanted is not the value but its *offsets*: the hash needs/// the bytes either side of it, which means knowing where it starts and stops/// in the original field rather than what it says.fn findBSpan(tags: []const u8) ?Span { var pos: usize = 0; while (pos <= tags.len) { const end = if (std.mem.findScalarPos(u8, tags, pos, ';')) |i| i else tags.len; const spec = tags[pos..end]; if (std.mem.findScalar(u8, spec, '=')) |eq| { const name = std.mem.trim(u8, spec[0..eq], " \t\r\n"); if (std.mem.eql(u8, name, "b")) { // From just after the `=` to just before the `;`, which // deletes the value together with the whitespace around it, // as §3.7 requires. return .{ .start = pos + eq + 1, .end = end }; } } if (end == tags.len) return null; pos = end + 1; } return null;}
// -- tests -------------------------------------------------------------------
const example_sig = "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;\r\n" ++ "\ts=selector; t=1700000000;\r\n" ++ "\th=from:to:subject:date;\r\n" ++ "\tbh=2jmj7l5rSw0yVb/vlWAYkK/YBwk=;\r\n" ++ "\tb=abcdef\r\n" ++ "\tghijkl";
fn parseExample(raw: []const u8) !Signature { var it: message.HeaderIterator = .init(raw); return parse(it.next().?);}
test "a signature parses into its tags" { const sig = try parseExample(example_sig ++ "\r\n"); try testing.expectEqual(Algorithm.rsa_sha256, sig.algorithm); try testing.expectEqual(canonical.Canonicalization.relaxed, sig.canonicalization.header); try testing.expectEqual(canonical.Canonicalization.relaxed, sig.canonicalization.body); try testing.expectEqualStrings("example.com", sig.domain); try testing.expectEqualStrings("selector", sig.selector); try testing.expectEqual(@as(?u64, 1700000000), sig.timestamp); try testing.expectEqual(@as(?u64, null), sig.expiration); try testing.expectEqual(@as(?u64, null), sig.body_length); try testing.expectEqual(@as(?[]const u8, null), sig.identity);}
test "the signed header list is walked in order" { const sig = try parseExample(example_sig ++ "\r\n"); var it = sig.signedHeaders(); try testing.expectEqualStrings("from", it.next().?); try testing.expectEqualStrings("to", it.next().?); try testing.expectEqualStrings("subject", it.next().?); try testing.expectEqualStrings("date", it.next().?); try testing.expectEqual(@as(?[]const u8, null), it.next());}
test "the header list tolerates folding and empty entries" { var it: HeaderIterator = .{ .rest = "from : to\r\n\t: :subject" }; try testing.expectEqualStrings("from", it.next().?); try testing.expectEqualStrings("to", it.next().?); try testing.expectEqualStrings("subject", it.next().?); try testing.expectEqual(@as(?[]const u8, null), it.next());}
test "the b= value is found, and the field splits either side of it" { const sig = try parseExample(example_sig ++ "\r\n"); const before = sig.valueBeforeSignature(); const after = sig.valueAfterSignature(); try testing.expect(std.mem.endsWith(u8, before, "b=")); try testing.expectEqualStrings("", after);
// The folded base64 is one value, and stripping it joins it up. var buf: [64]u8 = undefined; const tag = (try tag_list.find(sig.tags, "b")).?; try testing.expectEqualStrings("abcdefghijkl", tag.stripped(&buf).?);}
test "a b= tag in the middle leaves a tail behind it" { const raw = "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from;" ++ " bh=2jmj7l5rSw0yVb/vlWAYkK/YBwk=; b= AAAA ; t=1\r\n"; const sig = try parseExample(raw); try testing.expect(std.mem.endsWith(u8, sig.valueBeforeSignature(), "b=")); // The whitespace around the value goes with the value, which is what // "including all surrounding whitespace" means. try testing.expectEqualStrings("; t=1", sig.valueAfterSignature());}
test "the record name is selector, _domainkey, domain" { const sig = try parseExample(example_sig ++ "\r\n"); var buf: [253]u8 = undefined; try testing.expectEqualStrings("selector._domainkey.example.com", try sig.recordName(&buf)); var tiny: [8]u8 = undefined; try testing.expectError(error.NameTooLong, sig.recordName(&tiny));}
test "a missing required tag is refused" { const base = "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from;" ++ " bh=2jmj7l5rSw0yVb/vlWAYkK/YBwk=; b=AAAA\r\n"; _ = try parseExample(base); for ([_][]const u8{ "a=rsa-sha256; ", "d=x.com; ", "s=s; ", "h=from; ", "b=AAAA" }) |tag| { var buf: [256]u8 = undefined; const without = std.mem.replacementSize(u8, base, tag, ""); _ = std.mem.replace(u8, base, tag, "", buf[0..without]); try testing.expectError(error.MissingRequiredTag, parseExample(buf[0..without])); }}
test "a version other than 1 is refused" { try testing.expectError(error.UnsupportedVersion, parseExample( "DKIM-Signature: v=2; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA\r\n", )); try testing.expectError(error.MissingRequiredTag, parseExample( "DKIM-Signature: a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA\r\n", ));}
test "a signature that does not cover From is refused" { try testing.expectError(error.FromNotSigned, parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=to:subject; bh=AA; b=AA\r\n", )); // ...and case does not matter. _ = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=To:FROM; bh=AA; b=AA\r\n", );}
test "an expiry before the signing time is refused" { try testing.expectError(error.ExpiresBeforeSigned, parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA;" ++ " t=100; x=50\r\n", )); try testing.expectError(error.ExpiresBeforeSigned, parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA;" ++ " t=100; x=100\r\n", )); _ = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA;" ++ " t=100; x=101\r\n", );}
test "expiry is judged against a time the caller supplies" { const sig = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA;" ++ " t=100; x=200\r\n", ); try testing.expect(!sig.hasExpired(199)); try testing.expect(!sig.hasExpired(200)); try testing.expect(sig.hasExpired(201));
const forever = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA\r\n", ); try testing.expect(!forever.hasExpired(std.math.maxInt(i64)));}
test "an identity outside the domain is refused" { const prefix = "DKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=s; h=from;" ++ " bh=AA; b=AA; i="; _ = try parseExample(prefix ++ "@example.com\r\n"); _ = try parseExample(prefix ++ "user@example.com\r\n"); _ = try parseExample(prefix ++ "user@sub.example.com\r\n"); _ = try parseExample(prefix ++ "user@a.b.example.com\r\n"); // A suffix is not a subdomain: the dot has to be there. try testing.expectError(error.IdentityOutsideDomain, parseExample(prefix ++ "@notexample.com\r\n")); try testing.expectError(error.IdentityOutsideDomain, parseExample(prefix ++ "@example.com.evil.net\r\n")); try testing.expectError(error.IdentityOutsideDomain, parseExample(prefix ++ "@other.net\r\n")); // ...and something with no domain at all is not an identity. try testing.expectError(error.IdentityOutsideDomain, parseExample(prefix ++ "nobody\r\n"));}
test "numbers must be numbers" { const prefix = "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA; l="; _ = try parseExample(prefix ++ "100\r\n"); // Folding inside a number is legal, since it is FWS between tvals. _ = try parseExample(prefix ++ "10\r\n\t0\r\n"); try testing.expectError(error.InvalidNumber, parseExample(prefix ++ "abc\r\n")); try testing.expectError(error.InvalidNumber, parseExample(prefix ++ "-1\r\n")); try testing.expectError(error.InvalidNumber, parseExample(prefix ++ "\r\n")); try testing.expectError(error.InvalidNumber, parseExample(prefix ++ "99999999999999999999999\r\n"));}
test "the algorithms that exist, and the ones that do not" { try testing.expectEqual(Algorithm.rsa_sha256, try Algorithm.parse("rsa-sha256")); try testing.expectEqual(Algorithm.rsa_sha256, try Algorithm.parse("RSA-SHA256")); try testing.expectEqual(Algorithm.rsa_sha1, try Algorithm.parse("rsa-sha1")); try testing.expectEqual(Algorithm.ed25519_sha256, try Algorithm.parse("ed25519-sha256")); try testing.expectError(error.UnknownAlgorithm, Algorithm.parse("rsa-md5")); try testing.expectError(error.UnknownAlgorithm, Algorithm.parse(""));
try testing.expectEqual(KeyType.rsa, Algorithm.rsa_sha256.keyType()); try testing.expectEqual(KeyType.ed25519, Algorithm.ed25519_sha256.keyType()); try testing.expectEqual(@as(usize, 32), Algorithm.rsa_sha256.digestLength()); try testing.expectEqual(@as(usize, 20), Algorithm.rsa_sha1.digestLength());}
test "a body hash of the wrong length for the algorithm is refused" { // SHA-1's digest is 20 bytes, so a 32-byte bh= is not one. const sig = try parseExample( "DKIM-Signature: v=1; a=rsa-sha1; d=x.com; s=s; h=from;" ++ " bh=2jmj7l5rSw0yVb/vlWAYkK/YBwk=; b=AA\r\n", ); var buf: [64]u8 = undefined; try testing.expectEqualStrings("", ""); try testing.expectEqual(@as(usize, 20), (try sig.decodeBodyHash(&buf)).len);
const bad = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from;" ++ " bh=2jmj7l5rSw0yVb/vlWAYkK/YBwk=; b=AA\r\n", ); try testing.expectError(error.InvalidBodyHash, bad.decodeBodyHash(&buf));}
test "base64 that is not base64 is refused" { const sig = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=!!!!\r\n", ); var buf: [512]u8 = undefined; try testing.expectError(error.InvalidBase64, sig.decodeSignature(&buf));}
test "a missing c= is simple/simple" { const sig = try parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; s=s; h=from; bh=AA; b=AA\r\n", ); try testing.expectEqual(canonical.Canonicalization.simple, sig.canonicalization.header); try testing.expectEqual(canonical.Canonicalization.simple, sig.canonicalization.body);}
test "a duplicate tag makes the whole signature invalid" { try testing.expectError(error.DuplicateTag, parseExample( "DKIM-Signature: v=1; a=rsa-sha256; d=x.com; d=evil.com; s=s; h=from;" ++ " bh=AA; b=AA\r\n", ));}
test "the tags ARC takes away" { // §4.1.2: an `ARC-Message-Signature` defines no version tag, and // §4.1.3's `ARC-Seal` has neither a version nor a body hash, // because it covers no body. Everything else about them is a DKIM // signature, which is why this is an option rather than a second // parser. const ams = "ARC-Message-Signature: a=rsa-sha256; d=example.com; s=s1;" ++ " h=from:to; bh=AA; b=BB\r\n"; var headers: message.HeaderIterator = .init(message.split(ams ++ "\r\n").headers); const field = headers.next().?; try testing.expectError(error.MissingRequiredTag, parse(field)); const parsed = try parseWithOptions(field, .{ .require_version = false }); try testing.expectEqualStrings("example.com", parsed.domain); try testing.expectEqualStrings("from:to", parsed.headers);
const seal = "ARC-Seal: a=rsa-sha256; d=example.com; s=s1; cv=none; b=BB\r\n"; var seal_headers: message.HeaderIterator = .init(message.split(seal ++ "\r\n").headers); const seal_field = seal_headers.next().?; const sealed = try parseWithOptions(seal_field, .{ .require_version = false, .require_body_hash = false, .require_headers = false, }); try testing.expectEqualStrings("example.com", sealed.domain); try testing.expectEqualStrings("", sealed.headers); try testing.expectEqualStrings("", sealed.body_hash);
// The defaults are still §3.5's rules exactly, so an ordinary // signature missing any of the three is still refused. try testing.expectError(error.MissingRequiredTag, parse(seal_field));}