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