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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The public key record, RFC 6376 §3.6.1.//!//! The other half of a signature lives in DNS, as a TXT record at//! `<selector>._domainkey.<domain>`, and it is another `tag=value` list://!//! ```text//! v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ...//! ```//!//! **Nothing here does DNS.** The lookup is the caller's, and deliberately//! so: a library that resolved names would need an `Io`, a resolver, a//! timeout policy and an opinion about DNSSEC, and would be untestable//! without a network. `Signature.recordName` says what to ask for and this//! reads the answer, which leaves the caller free to use whatever resolver it//! already has — and leaves the whole of DKIM's logic reachable from a unit//! test.//!//! ## Two things that are not what they look like//!//! **An absent `p=` and an empty `p=` are different.** `p=` with nothing after//! it means *this key is revoked*, and RFC 6376 §3.6.1 requires a verifier to//! treat the signature as failed rather than as unverifiable. It is how a//! domain retires a key that has leaked without waiting for the record to//! fall out of every cache. `revoked` reports it, and `decodeKey` refuses.//!//! **A TXT record may arrive in pieces.** DNS strings are at most 255 bytes,//! so a 2048-bit RSA key — which is about 400 bytes of base64 — is published//! as several character-strings that the resolver hands back separately. They//! are concatenated with nothing between them, not with spaces, and a//! resolver that returns them already joined has done the right thing.//! `parse` takes the joined string; `joinStrings` will do the joining for a//! caller whose resolver does not.
const std = @import("std");const testing = std.testing;
const Signature = @import("Signature.zig");const tag_list = @import("tag_list.zig");
const Record = @This();
/// What can go wrong reading a key record.pub const ParseError = tag_list.Error || error{ UnknownKeyType, /// A `v=` that is present and is not `DKIM1`. UnsupportedVersion, /// No `p=` at all, which is not the same as an empty one. MissingKey, /// `p=` is not base64, or is not a key of the type `k=` claims. InvalidKey,};
/// `k=`, the key type. Defaults to `rsa` when the record does not say.key_type: Signature.KeyType,/// `p=`, the key itself, base64 with folding whitespace still in it. Empty/// when the key has been revoked.key_data: []const u8,/// Whether `p=` was present and empty, which means the key is revoked and/// every signature made with it must be treated as failing.revoked: bool,/// `h=`, the hash algorithms this key may be used with. `null` means the/// record did not say, which means all of them.hash_algorithms: ?[]const u8,/// `s=`, the service types this key is for. `null` means the record did not/// say, which means `*`.service_types: ?[]const u8,/// `n=`, a note for a human reading the record. Never acted on.notes: ?[]const u8,/// `t=`, the flags.flags: Flags,
/// The `t=` flags, §3.6.1.pub const Flags = struct { /// `y`: the domain is testing DKIM. A verifier must not treat a failure /// as grounds for rejecting the message, because the domain has said in /// as many words that it is not ready to be judged on this. testing: bool = false, /// `s`: the `i=` domain must be exactly `d=`, not a subdomain of it. A /// domain sets this when it does not delegate signing to its subdomains. strict_identity: bool = false,};
/// Reads a record out of the text of a TXT record.pub fn parse(txt: []const u8) ParseError!Record { try tag_list.validate(txt);
// §3.6.1: `v=` is optional, but if it is there it must be first and must // be DKIM1. The ordering is not enforced here for the same reason it is // not enforced on a signature: nothing depends on it, and records in the // wild are written by hand. if (try tag_list.find(txt, "v")) |tag| { if (!std.mem.eql(u8, tag.value, "DKIM1")) return error.UnsupportedVersion; }
const key_type: Signature.KeyType = if (try tag_list.find(txt, "k")) |tag| try .parse(tag.value) else .rsa;
const p = (try tag_list.find(txt, "p")) orelse return error.MissingKey;
return .{ .key_type = key_type, .key_data = p.value, .revoked = isBlank(p.value), .hash_algorithms = if (try tag_list.find(txt, "h")) |tag| tag.value else null, .service_types = if (try tag_list.find(txt, "s")) |tag| tag.value else null, .notes = if (try tag_list.find(txt, "n")) |tag| tag.value else null, .flags = parseFlags(if (try tag_list.find(txt, "t")) |tag| tag.value else ""), };}
/// Whether this key may be used with a signature's algorithm.////// A record's `h=` narrows what the key is allowed to sign with, which is how/// a domain publishing a key retires SHA-1 without retiring the key. A record/// with no `h=` permits everything.pub fn acceptsHash(self: Record, algorithm: Signature.Algorithm) bool { const list = self.hash_algorithms orelse return true; const wanted: []const u8 = switch (algorithm) { .rsa_sha1 => "sha1", .rsa_sha256, .ed25519_sha256 => "sha256", }; var it: Signature.HeaderIterator = .{ .rest = list }; var any = false; while (it.next()) |name| { any = true; if (std.ascii.eqlIgnoreCase(name, wanted)) return true; } // `h=` present but empty names nothing, and a key that may be used with // no hash may not be used at all. return !any and false;}
/// Whether this key may be used for email.////// `s=` exists so that a key can be published for something other than mail,/// and §3.6.1 says a verifier must ignore a record whose service types do not/// include `email` or `*`.pub fn acceptsEmail(self: Record) bool { const list = self.service_types orelse return true; var it: Signature.HeaderIterator = .{ .rest = list }; var any = false; while (it.next()) |name| { any = true; if (std.mem.eql(u8, name, "*")) return true; if (std.ascii.eqlIgnoreCase(name, "email")) return true; } return !any and false;}
/// The key, base64-decoded into `buf`.////// For an RSA key this is DER — a `SubjectPublicKeyInfo`, or the bare/// `RSAPublicKey` that some publishers write instead, and both are accepted/// downstream. For Ed25519 it is the 32 raw bytes of the public key.pub fn decodeKey(self: Record, buf: []u8) ParseError![]const u8 { if (self.revoked) return error.InvalidKey; var packed_buf: [4096]u8 = undefined; const tag: tag_list.Tag = .{ .name = "p", .value = self.key_data }; const text = tag.stripped(&packed_buf) orelse return error.InvalidKey; const decoder = std.base64.standard.Decoder; const len = decoder.calcSizeForSlice(text) catch return error.InvalidKey; if (len > buf.len) return error.InvalidKey; decoder.decode(buf[0..len], text) catch return error.InvalidKey; return buf[0..len];}
/// Joins the character-strings of a TXT record into `buf`.////// DNS caps a character-string at 255 bytes, so any RSA key above about 1024/// bits arrives in several, and they are joined with **nothing** between/// them. A resolver that hands back a single already-joined string needs none/// of this; one that hands back an array needs exactly this, and joining with/// a space instead is a way to produce a record that parses and whose key/// does not decode.pub fn joinStrings(buf: []u8, strings: []const []const u8) error{BufferTooSmall}![]const u8 { var len: usize = 0; for (strings) |s| { if (len + s.len > buf.len) return error.BufferTooSmall; @memcpy(buf[len..][0..s.len], s); len += s.len; } return buf[0..len];}
fn isBlank(s: []const u8) bool { for (s) |c| { switch (c) { ' ', '\t', '\r', '\n' => {}, else => return false, } } return true;}
fn parseFlags(value: []const u8) Flags { var flags: Flags = .{}; var it: Signature.HeaderIterator = .{ .rest = value }; while (it.next()) |flag| { // §3.6.1: "Unrecognized flags MUST be ignored", which is what makes // it possible to define a new one without breaking every verifier. if (std.ascii.eqlIgnoreCase(flag, "y")) flags.testing = true; if (std.ascii.eqlIgnoreCase(flag, "s")) flags.strict_identity = true; } return flags;}
// -- tests -------------------------------------------------------------------
test "a plain record" { const rec = try parse("v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQ=="); try testing.expectEqual(Signature.KeyType.rsa, rec.key_type); try testing.expectEqualStrings("MIGfMA0GCSqGSIb3DQ==", rec.key_data); try testing.expect(!rec.revoked); try testing.expect(!rec.flags.testing); try testing.expect(!rec.flags.strict_identity); try testing.expect(rec.acceptsEmail()); try testing.expect(rec.acceptsHash(.rsa_sha256)); try testing.expect(rec.acceptsHash(.rsa_sha1));}
test "the version is optional but must be DKIM1 when present" { _ = try parse("p=AAAA"); _ = try parse("v=DKIM1; p=AAAA"); try testing.expectError(error.UnsupportedVersion, parse("v=DKIM2; p=AAAA")); try testing.expectError(error.UnsupportedVersion, parse("v=dkim1; p=AAAA"));}
test "the key type defaults to rsa" { try testing.expectEqual(Signature.KeyType.rsa, (try parse("p=AAAA")).key_type); try testing.expectEqual(Signature.KeyType.ed25519, (try parse("k=ed25519; p=AAAA")).key_type); try testing.expectEqual(Signature.KeyType.rsa, (try parse("k=RSA; p=AAAA")).key_type); try testing.expectError(error.UnknownKeyType, parse("k=dsa; p=AAAA"));}
test "an empty p= is a revoked key, and a missing one is no record at all" { const revoked = try parse("v=DKIM1; k=rsa; p="); try testing.expect(revoked.revoked); var buf: [512]u8 = undefined; try testing.expectError(error.InvalidKey, revoked.decodeKey(&buf));
// Whitespace is not a key either: `p=` folded onto the next line with // nothing after it is still revoked. try testing.expect((try parse("p=\r\n\t")).revoked);
try testing.expectError(error.MissingKey, parse("v=DKIM1; k=rsa"));}
test "h= narrows which hashes the key may be used with" { const sha256_only = try parse("v=DKIM1; h=sha256; p=AAAA"); try testing.expect(sha256_only.acceptsHash(.rsa_sha256)); try testing.expect(sha256_only.acceptsHash(.ed25519_sha256)); try testing.expect(!sha256_only.acceptsHash(.rsa_sha1));
const both = try parse("v=DKIM1; h=sha1:sha256; p=AAAA"); try testing.expect(both.acceptsHash(.rsa_sha1)); try testing.expect(both.acceptsHash(.rsa_sha256));
// Folding and case are allowed in the list. const folded = try parse("v=DKIM1; h=sha1\r\n\t: sha256; p=AAAA"); try testing.expect(folded.acceptsHash(.rsa_sha256)); const upper = try parse("v=DKIM1; h=SHA256; p=AAAA"); try testing.expect(upper.acceptsHash(.rsa_sha256));
// A key usable with no hash is usable for nothing. const none = try parse("v=DKIM1; h=; p=AAAA"); try testing.expect(!none.acceptsHash(.rsa_sha256)); try testing.expect(!none.acceptsHash(.rsa_sha1));}
test "s= narrows what the key is for" { try testing.expect((try parse("p=AAAA")).acceptsEmail()); try testing.expect((try parse("s=*; p=AAAA")).acceptsEmail()); try testing.expect((try parse("s=email; p=AAAA")).acceptsEmail()); try testing.expect((try parse("s=email:tlsa; p=AAAA")).acceptsEmail()); try testing.expect((try parse("s=EMAIL; p=AAAA")).acceptsEmail()); try testing.expect(!(try parse("s=tlsa; p=AAAA")).acceptsEmail()); try testing.expect(!(try parse("s=; p=AAAA")).acceptsEmail());}
test "t= flags, and unknown ones are ignored" { try testing.expect((try parse("t=y; p=AAAA")).flags.testing); try testing.expect((try parse("t=s; p=AAAA")).flags.strict_identity); const both = try parse("t=y:s; p=AAAA"); try testing.expect(both.flags.testing); try testing.expect(both.flags.strict_identity); // §3.6.1 requires unrecognized flags to be ignored, not to invalidate. const unknown = try parse("t=y:q:zzz; p=AAAA"); try testing.expect(unknown.flags.testing); try testing.expect(!unknown.flags.strict_identity);}
test "a key decodes out of folded base64" { // What a real record looks like once a resolver has joined it: base64 // with the folding a TXT record picks up along the way. const rec = try parse("v=DKIM1; k=rsa; p=MIGf MA0G\r\n\tCSqG SIb3DQ=="); var buf: [512]u8 = undefined; const der = try rec.decodeKey(&buf); try testing.expectEqual(@as(usize, 13), der.len); try testing.expectEqual(@as(u8, 0x30), der[0]);}
test "base64 that is not base64 is refused" { const rec = try parse("v=DKIM1; p=!!!!"); var buf: [512]u8 = undefined; try testing.expectError(error.InvalidKey, rec.decodeKey(&buf));}
test "TXT character-strings join with nothing between them" { var buf: [512]u8 = undefined; const joined = try joinStrings(&buf, &.{ "v=DKIM1; p=MIGf", "MA0GCSqG", "SIb3DQ==" }); try testing.expectEqualStrings("v=DKIM1; p=MIGfMA0GCSqGSIb3DQ==", joined);
const rec = try parse(joined); var key: [512]u8 = undefined; try testing.expectEqual(@as(usize, 13), (try rec.decodeKey(&key)).len);
var tiny: [4]u8 = undefined; try testing.expectError(error.BufferTooSmall, joinStrings(&tiny, &.{ "aaa", "bbb" }));}
test "a note is carried and never acted on" { const rec = try parse("v=DKIM1; n=rotate me in 2027; p=AAAA"); try testing.expectEqualStrings("rotate me in 2027", rec.notes.?);}
test "a duplicate tag invalidates a record" { try testing.expectError(error.DuplicateTag, parse("v=DKIM1; p=AAAA; p=BBBB"));}