DKIM / SPF / DMARC Builder & Checker — Zig source
Build the three email-authentication DNS records — SPF, DKIM, and DMARC — as ready-to-paste TXT values, or look up a domain's live records over DNS-over-HTTPS. Import existing records, catch publishing mistakes, share via URL. Entirely client-side.
This is the Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! dkim-spf-dmarc — email authentication (SPF / DKIM / DMARC) record parsing
//! and lookup.
//!
//! Language: Zig 0.14 (standard library only — std.http.Client for the
//! DNS-over-HTTPS request, std.json for the resolver's reply).
//! Ported from: src/lib/dkim-spf-dmarc.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Two layers, matching the TS reference one-for-one:
//! - Pure parsers (parseSPF / parseDKIM / parseDMARC) — deterministic, never
//! fail on input shape, exercisable without a network.
//! - Check functions (checkSPF / checkDKIM / checkDMARC) — one DoH request to
//! Cloudflare's public resolver, then a pure parse. Every network or
//! protocol failure comes back as a `.fail` result carrying a message,
//! never as a thrown error; only allocation failure propagates.
//!
//! Specs: SPF RFC 7208, DKIM RFC 6376, DMARC RFC 7489.
//!
//! Ownership: every record and result owns its strings. Call `deinit` with the
//! same allocator that produced it.
const std = @import("std");
const Allocator = std.mem.Allocator;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Traffic-light verdict for one check.
pub const CheckStatus = enum { pass, warn, fail };
/// SPF mechanism prefix. An absent prefix means Pass ('+'), per RFC 7208 §4.6.2.
pub const SPFQualifier = enum {
pass, // +
fail, // -
soft_fail, // ~
neutral, // ?
pub fn symbol(self: SPFQualifier) []const u8 {
return switch (self) {
.pass => "+",
.fail => "-",
.soft_fail => "~",
.neutral => "?",
};
}
fn fromChar(c: u8) ?SPFQualifier {
return switch (c) {
'+' => .pass,
'-' => .fail,
'~' => .soft_fail,
'?' => .neutral,
else => null,
};
}
};
/// The eight mechanisms RFC 7208 defines. Anything else is ignored with a warning.
pub const SPFMechanismKind = enum {
all,
include,
a,
mx,
ip4,
ip6,
exists,
ptr,
/// Case-insensitive lookup; null for a token that is not a known mechanism.
fn fromString(s: []const u8) ?SPFMechanismKind {
inline for (@typeInfo(SPFMechanismKind).@"enum".fields) |field| {
if (std.ascii.eqlIgnoreCase(s, field.name)) return @enumFromInt(field.value);
}
return null;
}
pub fn name(self: SPFMechanismKind) []const u8 {
return @tagName(self);
}
/// True when evaluating this mechanism costs a DNS query (RFC 7208 §4.6.4).
fn costsLookup(self: SPFMechanismKind) bool {
return switch (self) {
.include, .a, .mx, .exists, .ptr => true,
.all, .ip4, .ip6 => false,
};
}
};
pub const SPFMechanism = struct {
qualifier: SPFQualifier,
kind: SPFMechanismKind,
/// Domain, IP, or CIDR argument after the colon (e.g. `_spf.google.com`,
/// `192.0.2.0/24`). Null when the mechanism took no argument.
value: ?[]const u8 = null,
};
pub const SPFRecord = struct {
valid: bool,
version: ?[]const u8 = null,
mechanisms: []SPFMechanism = &.{},
redirect: ?[]const u8 = null,
exp: ?[]const u8 = null,
/// Mechanisms that cost a DNS query under RFC 7208 §4.6.4 (limit 10).
lookup_count: usize = 0,
/// Number of `v=spf1` records found (more than one is a hard error for receivers).
record_count: usize = 0,
warnings: [][]const u8 = &.{},
pub fn deinit(self: SPFRecord, allocator: Allocator) void {
if (self.version) |v| allocator.free(v);
if (self.redirect) |v| allocator.free(v);
if (self.exp) |v| allocator.free(v);
for (self.mechanisms) |mech| if (mech.value) |v| allocator.free(v);
allocator.free(self.mechanisms);
freeStrings(allocator, self.warnings);
}
};
pub const DKIMRecord = struct {
valid: bool,
version: ?[]const u8 = null,
key_type: []const u8,
/// Public key, base64, whitespace-stripped.
public_key: ?[]const u8 = null,
/// Approximate modulus size in bits (RSA only; derived from DER byte length).
key_bits: ?usize = null,
hashes: [][]const u8 = &.{},
services: [][]const u8 = &.{},
flags: [][]const u8 = &.{},
warnings: [][]const u8 = &.{},
pub fn deinit(self: DKIMRecord, allocator: Allocator) void {
if (self.version) |v| allocator.free(v);
allocator.free(self.key_type);
if (self.public_key) |v| allocator.free(v);
freeStrings(allocator, self.hashes);
freeStrings(allocator, self.services);
freeStrings(allocator, self.flags);
freeStrings(allocator, self.warnings);
}
};
/// What a receiver should do with mail that fails alignment.
pub const DMARCPolicy = enum {
none,
quarantine,
reject,
/// Exact (already lowercased) match; null for anything else.
fn fromString(s: []const u8) ?DMARCPolicy {
inline for (@typeInfo(DMARCPolicy).@"enum".fields) |field| {
if (std.mem.eql(u8, s, field.name)) return @enumFromInt(field.value);
}
return null;
}
pub fn name(self: DMARCPolicy) []const u8 {
return @tagName(self);
}
};
/// Identifier alignment: relaxed (organizational domain) or strict (exact).
pub const Alignment = enum { relaxed, strict };
pub const DMARCRecord = struct {
valid: bool,
policy: ?DMARCPolicy = null,
subdomain_policy: ?DMARCPolicy = null,
aggregate_uris: [][]const u8 = &.{},
forensic_uris: [][]const u8 = &.{},
percent: ?u8 = null,
dkim_alignment: ?Alignment = null,
spf_alignment: ?Alignment = null,
warnings: [][]const u8 = &.{},
pub fn deinit(self: DMARCRecord, allocator: Allocator) void {
freeStrings(allocator, self.aggregate_uris);
freeStrings(allocator, self.forensic_uris);
freeStrings(allocator, self.warnings);
}
};
pub const SPFResult = struct {
status: CheckStatus,
found: bool,
record: ?SPFRecord = null,
message: ?[]const u8 = null,
pub fn deinit(self: SPFResult, allocator: Allocator) void {
if (self.record) |rec| rec.deinit(allocator);
if (self.message) |m| allocator.free(m);
}
};
pub const DKIMResult = struct {
status: CheckStatus,
found: bool,
record: ?DKIMRecord = null,
message: ?[]const u8 = null,
pub fn deinit(self: DKIMResult, allocator: Allocator) void {
if (self.record) |rec| rec.deinit(allocator);
if (self.message) |m| allocator.free(m);
}
};
pub const DMARCResult = struct {
status: CheckStatus,
found: bool,
record: ?DMARCRecord = null,
message: ?[]const u8 = null,
pub fn deinit(self: DMARCResult, allocator: Allocator) void {
if (self.record) |rec| rec.deinit(allocator);
if (self.message) |m| allocator.free(m);
}
};
// ---------------------------------------------------------------------------
// Small string helpers
// ---------------------------------------------------------------------------
const ascii_space = " \t\r\n\x0b\x0c";
fn trim(s: []const u8) []const u8 {
return std.mem.trim(u8, s, ascii_space);
}
fn freeStrings(allocator: Allocator, items: [][]const u8) void {
for (items) |item| allocator.free(item);
allocator.free(items);
}
/// Drop one pair of surrounding double quotes, mirroring `/^"(.*)"$/` in the
/// TS reference. A lone `"` is left alone.
fn stripQuotes(s: []const u8) []const u8 {
if (s.len >= 2 and s[0] == '"' and s[s.len - 1] == '"') return s[1 .. s.len - 1];
return s;
}
/// Collector for the human-readable advice each parser emits. Owns its strings
/// until `toOwnedSlice` hands them to the record.
const Warnings = struct {
list: std.ArrayList([]const u8),
fn init(allocator: Allocator) Warnings {
return .{ .list = std.ArrayList([]const u8).init(allocator) };
}
fn add(self: *Warnings, text: []const u8) !void {
try self.list.append(try self.list.allocator.dupe(u8, text));
}
fn addFmt(self: *Warnings, comptime fmt: []const u8, args: anytype) !void {
const text = try std.fmt.allocPrint(self.list.allocator, fmt, args);
errdefer self.list.allocator.free(text);
try self.list.append(text);
}
fn contains(self: Warnings, needle: []const u8) bool {
for (self.list.items) |w| {
if (std.mem.indexOf(u8, w, needle) != null) return true;
}
return false;
}
fn toOwnedSlice(self: *Warnings) ![][]const u8 {
return self.list.toOwnedSlice();
}
fn deinit(self: *Warnings) void {
for (self.list.items) |w| self.list.allocator.free(w);
self.list.deinit();
}
};
/// Split on `sep`, trim each part, drop the empties, dupe the survivors.
/// Mirrors `.split(sep).map(trim).filter(Boolean)`.
fn splitTrimmed(allocator: Allocator, raw: []const u8, sep: u8) ![][]const u8 {
var out = std.ArrayList([]const u8).init(allocator);
errdefer {
for (out.items) |item| allocator.free(item);
out.deinit();
}
var it = std.mem.splitScalar(u8, raw, sep);
while (it.next()) |part| {
const value = trim(part);
if (value.len == 0) continue;
try out.append(try allocator.dupe(u8, value));
}
return out.toOwnedSlice();
}
/// True when `list` holds `needle` exactly.
fn containsString(list: []const []const u8, needle: []const u8) bool {
for (list) |item| {
if (std.mem.eql(u8, item, needle)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Shared helpers — domain and selector hygiene
// ---------------------------------------------------------------------------
/// Strip a leading scheme, userinfo, path, query, port, and trailing dot from
/// user input. Returns an owned, lowercased host.
pub fn normalizeDomain(allocator: Allocator, raw: []const u8) ![]u8 {
var s = trim(raw);
// scheme:// — `[a-z][a-z0-9+.-]*` followed by "://"
if (std.mem.indexOf(u8, s, "://")) |idx| {
if (idx > 0 and std.ascii.isAlphabetic(s[0])) {
var schemeish = true;
for (s[1..idx]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '+' and c != '.' and c != '-') {
schemeish = false;
break;
}
}
if (schemeish) s = s[idx + 3 ..];
}
}
// mailto:user@domain
if (s.len >= 7 and std.ascii.eqlIgnoreCase(s[0..7], "mailto:")) s = s[7..];
// keep the host of user@host
if (std.mem.lastIndexOfScalar(u8, s, '@')) |at| s = s[at + 1 ..];
// drop path, query, then port
if (std.mem.indexOfScalar(u8, s, '/')) |i| s = s[0..i];
if (std.mem.indexOfScalar(u8, s, '?')) |i| s = s[0..i];
if (std.mem.indexOfScalar(u8, s, ':')) |i| s = s[0..i];
// trailing root dot(s)
s = std.mem.trimRight(u8, s, ".");
const out = try allocator.alloc(u8, s.len);
for (s, 0..) |c, i| out[i] = std.ascii.toLower(c);
return out;
}
/// True when the string looks like a plausible multi-label domain
/// (`example.com`). Equivalent to `/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/`
/// against an already-lowercased host, capped at the 253-byte DNS limit.
pub fn isDomainLike(domain: []const u8) bool {
if (domain.len == 0 or domain.len > 253) return false;
var labels = std.mem.splitScalar(u8, domain, '.');
var count: usize = 0;
var last: []const u8 = &.{};
while (labels.next()) |label| {
count += 1;
last = label;
if (label.len == 0) return false;
if (!std.ascii.isAlphanumeric(label[0])) return false;
if (!std.ascii.isAlphanumeric(label[label.len - 1])) return false;
for (label[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-') return false;
}
}
// At least one label plus a TLD of two or more letters.
if (count < 2 or last.len < 2) return false;
for (last) |c| {
if (!std.ascii.isLower(c)) return false;
}
return true;
}
/// True when the selector is a safe single DNS label chain (no spaces, no
/// traversal). Equivalent to `/^[a-z0-9][a-z0-9._-]*$/i` plus the `..` guard.
pub fn isValidSelector(selector: []const u8) bool {
const s = trim(selector);
if (s.len == 0 or s.len > 100) return false;
if (!std.ascii.isAlphanumeric(s[0])) return false;
for (s[1..]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '.' and c != '_' and c != '-') return false;
}
return std.mem.indexOf(u8, s, "..") == null;
}
// ---------------------------------------------------------------------------
// SPF — RFC 7208
// ---------------------------------------------------------------------------
/// True for a line that opens with the `v=spf1` version token.
fn isSpfLine(line: []const u8) bool {
const tag = "v=spf1";
if (line.len < tag.len) return false;
if (!std.ascii.eqlIgnoreCase(line[0..tag.len], tag)) return false;
return line.len == tag.len or std.ascii.isWhitespace(line[tag.len]);
}
/// Match `^([a-z][a-z0-9-]*)=(.*)$` — a modifier such as `redirect=` or `exp=`.
/// Returns the name/value split, or null when the term is not a modifier.
fn matchModifier(term: []const u8) ?struct { name: []const u8, value: []const u8 } {
const eq = std.mem.indexOfScalar(u8, term, '=') orelse return null;
if (eq == 0) return null;
if (!std.ascii.isAlphabetic(term[0])) return null;
for (term[1..eq]) |c| {
if (!std.ascii.isAlphanumeric(c) and c != '-') return null;
}
return .{ .name = term[0..eq], .value = term[eq + 1 ..] };
}
/// Parse one or more (newline-joined) TXT record strings for SPF. Pure.
pub fn parseSPF(allocator: Allocator, txt: []const u8) !SPFRecord {
var warnings = Warnings.init(allocator);
errdefer warnings.deinit();
// Collect the `v=spf1` lines: trim, unquote, drop the blanks.
var spf_lines = std.ArrayList([]const u8).init(allocator);
defer spf_lines.deinit(); // slices point into `txt`; nothing to free
var lines = std.mem.splitScalar(u8, txt, '\n');
while (lines.next()) |raw_line| {
const line = stripQuotes(trim(raw_line));
if (line.len == 0) continue;
if (isSpfLine(line)) try spf_lines.append(line);
}
if (spf_lines.items.len == 0) {
try warnings.add("No v=spf1 record found in the supplied text.");
return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
}
if (spf_lines.items.len > 1) {
try warnings.addFmt(
"{d} SPF records found — RFC 7208 allows exactly one. Receivers treat this as a permanent error and ignore SPF for the whole domain.",
.{spf_lines.items.len},
);
}
var mechanisms = std.ArrayList(SPFMechanism).init(allocator);
errdefer {
for (mechanisms.items) |mech| if (mech.value) |v| allocator.free(v);
mechanisms.deinit();
}
var version: ?[]const u8 = null;
errdefer if (version) |v| allocator.free(v);
var redirect: ?[]const u8 = null;
errdefer if (redirect) |v| allocator.free(v);
var exp: ?[]const u8 = null;
errdefer if (exp) |v| allocator.free(v);
var terms = std.mem.tokenizeAny(u8, spf_lines.items[0], ascii_space);
version = try allocator.dupe(u8, terms.next() orelse "v=spf1"); // the version token
while (terms.next()) |term| {
// Modifiers use '=': redirect= and exp=. Checked first, as the TS does.
if (matchModifier(term)) |modifier| {
if (std.ascii.eqlIgnoreCase(modifier.name, "redirect")) {
if (redirect) |old| allocator.free(old);
redirect = try allocator.dupe(u8, modifier.value);
} else if (std.ascii.eqlIgnoreCase(modifier.name, "exp")) {
if (exp) |old| allocator.free(old);
exp = try allocator.dupe(u8, modifier.value);
} else {
try warnings.addFmt("Unknown modifier \"{s}\" ignored.", .{term});
}
continue;
}
// Mechanisms: `^([+-~?])?([a-z0-9]+)(?::(.*))?$`
var rest = term;
var qualifier = SPFQualifier.pass; // an absent prefix means '+'
if (rest.len > 0) {
if (SPFQualifier.fromChar(rest[0])) |q| {
qualifier = q;
rest = rest[1..];
}
}
const colon = std.mem.indexOfScalar(u8, rest, ':');
const token = if (colon) |i| rest[0..i] else rest;
const value: ?[]const u8 = if (colon) |i| rest[i + 1 ..] else null;
var alphanumeric = token.len > 0;
for (token) |c| {
if (!std.ascii.isAlphanumeric(c)) alphanumeric = false;
}
if (!alphanumeric) {
try warnings.addFmt("Unrecognized term \"{s}\" ignored.", .{term});
continue;
}
const kind = SPFMechanismKind.fromString(token) orelse {
try warnings.addFmt("Unknown mechanism \"{s}\" ignored.", .{term});
continue;
};
const missing_value = value == null or value.?.len == 0;
if (missing_value and (kind == .include or kind == .exists)) {
try warnings.addFmt("Mechanism \"{s}\" is missing its required value.", .{term});
continue;
}
try mechanisms.append(.{
.qualifier = qualifier,
.kind = kind,
.value = if (value) |v| try allocator.dupe(u8, v) else null,
});
}
var lookup_count: usize = if (redirect != null) 1 else 0;
var all: ?SPFMechanism = null;
var has_ptr = false;
for (mechanisms.items) |mech| {
if (mech.kind.costsLookup()) lookup_count += 1;
if (mech.kind == .all and all == null) all = mech;
if (mech.kind == .ptr) has_ptr = true;
}
if (all == null and redirect == null) {
try warnings.add("No \"all\" mechanism and no \"redirect=\" — unmatched senders get a Neutral result, so anyone can still send mail that looks like this domain.");
}
if (all) |mech| {
switch (mech.qualifier) {
.pass => try warnings.add("\"+all\" explicitly allows every host on the internet to send mail as this domain — this defeats SPF entirely."),
.neutral => try warnings.add("\"?all\" (Neutral) lets unmatched senders through with no protection. Prefer \"~all\" or \"-all\"."),
else => {},
}
}
if (has_ptr) {
try warnings.add("The \"ptr\" mechanism is deprecated (RFC 7208 §5.5) and should not be used.");
}
if (redirect != null and all != null) {
try warnings.add("A \"redirect=\" modifier is ignored when an \"all\" mechanism is present.");
}
if (lookup_count > 10) {
try warnings.addFmt(
"{d} DNS-lookup mechanisms — RFC 7208 §4.6.4 caps SPF at 10. Receivers that hit the cap return permerror and ignore the record.",
.{lookup_count},
);
}
return .{
.valid = true,
.version = version,
.mechanisms = try mechanisms.toOwnedSlice(),
.redirect = redirect,
.exp = exp,
.lookup_count = lookup_count,
.record_count = spf_lines.items.len,
.warnings = try warnings.toOwnedSlice(),
};
}
// ---------------------------------------------------------------------------
// Tag lists — the `k=v; k=v` shape shared by DKIM and DMARC
// ---------------------------------------------------------------------------
/// Lowercased keys → trimmed values, first occurrence wins on the read side
/// because later duplicates overwrite exactly as JS `Map.set` does.
const TagMap = struct {
map: std.StringHashMap([]const u8),
fn deinit(self: *TagMap) void {
var it = self.map.iterator();
while (it.next()) |entry| self.map.allocator.free(entry.key_ptr.*);
self.map.deinit();
}
fn get(self: TagMap, key: []const u8) ?[]const u8 {
return self.map.get(key);
}
};
/// Split a `;`-separated tag list. Values are slices of `txt`; keys are owned
/// (they must be lowercased). Malformed terms are reported, never fatal.
fn parseTags(allocator: Allocator, txt: []const u8, warnings: *Warnings) !TagMap {
var tags = TagMap{ .map = std.StringHashMap([]const u8).init(allocator) };
errdefer tags.deinit();
var parts = std.mem.splitScalar(u8, txt, ';');
while (parts.next()) |part| {
const term = trim(stripQuotes(trim(part)));
if (term.len == 0) continue;
const eq = std.mem.indexOfScalar(u8, term, '=') orelse {
try warnings.addFmt("Malformed tag \"{s}\" ignored.", .{term});
continue;
};
if (eq == 0) {
try warnings.addFmt("Malformed tag \"{s}\" ignored.", .{term});
continue;
}
const raw_key = trim(term[0..eq]);
const key = try allocator.alloc(u8, raw_key.len);
errdefer allocator.free(key);
for (raw_key, 0..) |c, i| key[i] = std.ascii.toLower(c);
const gop = try tags.map.getOrPut(key);
if (gop.found_existing) allocator.free(key); // keep the first key allocation
gop.value_ptr.* = trim(term[eq + 1 ..]);
}
return tags;
}
// ---------------------------------------------------------------------------
// DKIM — RFC 6376
// ---------------------------------------------------------------------------
/// Decode base64 without failing on missing padding or embedded whitespace.
/// Returns null for input the decoder rejects (the TS catches `atob`).
fn decodeBase64Lenient(allocator: Allocator, b64: []const u8) !?[]u8 {
var clean = std.ArrayList(u8).init(allocator);
defer clean.deinit();
for (b64) |c| {
if (!std.ascii.isWhitespace(c)) try clean.append(c);
}
try clean.appendNTimes('=', (4 - (clean.items.len % 4)) % 4);
const decoder = std.base64.standard.Decoder;
const size = decoder.calcSizeForSlice(clean.items) catch return null;
const out = try allocator.alloc(u8, size);
errdefer allocator.free(out);
decoder.decode(out, clean.items) catch {
allocator.free(out);
return null;
};
return out;
}
/// Parse a `<selector>._domainkey` TXT record. Pure.
pub fn parseDKIM(allocator: Allocator, txt: []const u8) !DKIMRecord {
var warnings = Warnings.init(allocator);
errdefer warnings.deinit();
var tags = try parseTags(allocator, txt, &warnings);
defer tags.deinit();
var version: ?[]const u8 = null;
errdefer if (version) |v| allocator.free(v);
if (tags.get("v")) |v| {
if (!std.ascii.eqlIgnoreCase(v, "DKIM1")) {
try warnings.addFmt("Unusual version tag v={s} (expected DKIM1).", .{v});
}
const upper = try allocator.alloc(u8, v.len);
for (v, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
version = upper;
}
const key_type = try allocator.dupe(u8, tags.get("k") orelse "rsa");
errdefer allocator.free(key_type);
const hashes = try splitTrimmed(allocator, tags.get("h") orelse "", ':');
errdefer freeStrings(allocator, hashes);
const services = try splitTrimmed(allocator, tags.get("s") orelse "", ':');
errdefer freeStrings(allocator, services);
const flags = try splitTrimmed(allocator, tags.get("t") orelse "", ':');
errdefer freeStrings(allocator, flags);
// Everything below can still return a record — only the p= tag decides validity.
const p = tags.get("p");
if (p == null) {
try warnings.add("No p= tag — this record is not a usable DKIM key.");
return .{
.valid = false,
.version = version,
.key_type = key_type,
.hashes = hashes,
.services = services,
.flags = flags,
.warnings = try warnings.toOwnedSlice(),
};
}
if (p.?.len == 0) {
try warnings.add("p= is empty — the key is revoked. Receivers will treat mail signed with this selector as unsigned.");
return .{
.valid = true,
.version = version,
.key_type = key_type,
.hashes = hashes,
.services = services,
.flags = flags,
.warnings = try warnings.toOwnedSlice(),
};
}
const decoded = try decodeBase64Lenient(allocator, p.?);
defer if (decoded) |d| allocator.free(d);
if (decoded == null) {
try warnings.add("The p= value is not valid base64 — the key could not be read.");
return .{
.valid = true,
.version = version,
.key_type = key_type,
.hashes = hashes,
.services = services,
.flags = flags,
.warnings = try warnings.toOwnedSlice(),
};
}
// Store the key whitespace-stripped, as the TS does.
var public_key = std.ArrayList(u8).init(allocator);
errdefer public_key.deinit();
for (p.?) |c| {
if (!std.ascii.isWhitespace(c)) try public_key.append(c);
}
var key_bits: ?usize = null;
if (std.mem.eql(u8, key_type, "rsa")) {
// SubjectPublicKeyInfo DER ≈ modulus bits/8 + ~24 bytes of ASN.1
// overhead. The estimate is close enough to classify 512/1024/2048/4096.
const bits = (decoded.?.len -| 24) * 8;
key_bits = bits;
if (bits < 1024) {
try warnings.addFmt(
"Weak RSA key (~{d} bits). Keys under 1024 bits are considered breakable; RFC 8301 discourages short keys.",
.{bits},
);
} else if (bits < 2048) {
try warnings.addFmt(
"RSA key of ~{d} bits works today but is below the recommended 2048 bits (RFC 8301).",
.{bits},
);
}
}
if (containsString(flags, "y")) {
try warnings.add("t=y — the key is in test mode: receivers must treat signatures as if unsigned.");
}
if (containsString(flags, "s")) {
try warnings.add("t=s — strict domain matching: the key cannot be used for subdomain signatures (informational).");
}
return .{
.valid = true,
.version = version,
.key_type = key_type,
.public_key = try public_key.toOwnedSlice(),
.key_bits = key_bits,
.hashes = hashes,
.services = services,
.flags = flags,
.warnings = try warnings.toOwnedSlice(),
};
}
// ---------------------------------------------------------------------------
// DMARC — RFC 7489
// ---------------------------------------------------------------------------
/// Read a `pct=` value the way `Number()` does for this tag: an empty string is
/// 0, a whole float such as `100.0` is accepted, anything else is rejected.
fn parsePercent(raw: []const u8) ?u8 {
if (raw.len == 0) return 0;
const n = std.fmt.parseFloat(f64, raw) catch return null;
if (!std.math.isFinite(n)) return null;
if (n != @floor(n)) return null;
if (n < 0 or n > 100) return null;
return @intFromFloat(n);
}
fn parseAlignment(raw: []const u8) ?Alignment {
if (std.mem.eql(u8, raw, "r")) return .relaxed;
if (std.mem.eql(u8, raw, "s")) return .strict;
return null;
}
/// Lowercase into a small stack buffer; returns null when the value is longer
/// than any legal policy or alignment token could be.
fn lowerSmall(buf: []u8, s: []const u8) ?[]const u8 {
if (s.len > buf.len) return null;
for (s, 0..) |c, i| buf[i] = std.ascii.toLower(c);
return buf[0..s.len];
}
/// Parse a `_dmarc` TXT record. Pure.
pub fn parseDMARC(allocator: Allocator, txt: []const u8) !DMARCRecord {
var warnings = Warnings.init(allocator);
errdefer warnings.deinit();
var tags = try parseTags(allocator, txt, &warnings);
defer tags.deinit();
const version = tags.get("v");
if (version == null) {
try warnings.add("No v= tag — this is not a DMARC record.");
return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
}
if (!std.ascii.eqlIgnoreCase(version.?, "DMARC1")) {
try warnings.addFmt("Unknown version v={s} (expected DMARC1).", .{version.?});
return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
}
var buf: [32]u8 = undefined;
const raw_p = tags.get("p");
if (raw_p == null or raw_p.?.len == 0) {
try warnings.add("No p= policy tag — DMARC requires it.");
return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
}
const lowered_p = lowerSmall(&buf, raw_p.?);
const policy = if (lowered_p) |lp| DMARCPolicy.fromString(lp) else null;
if (policy == null) {
try warnings.addFmt(
"Invalid policy p={s} (expected none, quarantine, or reject).",
.{lowered_p orelse raw_p.?},
);
return .{ .valid = false, .warnings = try warnings.toOwnedSlice() };
}
var subdomain_policy: ?DMARCPolicy = null;
if (tags.get("sp")) |raw_sp| {
if (raw_sp.len > 0) {
var sp_buf: [32]u8 = undefined;
const lowered_sp = lowerSmall(&sp_buf, raw_sp);
if (lowered_sp) |lsp| subdomain_policy = DMARCPolicy.fromString(lsp);
if (subdomain_policy == null) {
try warnings.addFmt(
"Invalid sp={s} ignored (expected none, quarantine, or reject).",
.{lowered_sp orelse raw_sp},
);
}
}
}
var aggregate_uris: [][]const u8 = &.{};
errdefer freeStrings(allocator, aggregate_uris);
if (tags.get("rua")) |rua| {
if (rua.len > 0) aggregate_uris = try splitTrimmed(allocator, rua, ',');
}
var forensic_uris: [][]const u8 = &.{};
errdefer freeStrings(allocator, forensic_uris);
if (tags.get("ruf")) |ruf| {
if (ruf.len > 0) forensic_uris = try splitTrimmed(allocator, ruf, ',');
}
var percent: ?u8 = null;
if (tags.get("pct")) |pct| {
percent = parsePercent(pct);
if (percent == null) try warnings.addFmt("Invalid pct={s} ignored (must be 0-100).", .{pct});
}
var dkim_alignment: ?Alignment = null;
if (tags.get("adkim")) |adkim| {
if (adkim.len > 0) {
dkim_alignment = parseAlignment(adkim);
if (dkim_alignment == null) {
try warnings.addFmt("Invalid adkim={s} ignored (expected r or s).", .{adkim});
}
}
}
var spf_alignment: ?Alignment = null;
if (tags.get("aspf")) |aspf| {
if (aspf.len > 0) {
spf_alignment = parseAlignment(aspf);
if (spf_alignment == null) {
try warnings.addFmt("Invalid aspf={s} ignored (expected r or s).", .{aspf});
}
}
}
// Policy guidance
if (policy.? == .none) {
try warnings.add("p=none is monitor-only — no mail is quarantined or rejected, but you still need SPF/DKIM to pass for reports to be useful.");
}
if (aggregate_uris.len == 0) {
try warnings.add("No rua= address — without aggregate reports you cannot see who is failing authentication. Add rua=mailto:reports@example.com.");
} else if (forensic_uris.len > 0) {
try warnings.add("ruf= (forensic reports) is supported by few receivers and may leak message content to the report address (informational).");
}
if (percent) |pct| {
if (pct < 100 and policy.? != .none) {
try warnings.addFmt(
"pct={d} applies the policy to only {d}% of mail — the other {d}% is unaffected.",
.{ pct, pct, 100 - pct },
);
}
}
return .{
.valid = true,
.policy = policy,
.subdomain_policy = subdomain_policy,
.aggregate_uris = aggregate_uris,
.forensic_uris = forensic_uris,
.percent = percent,
.dkim_alignment = dkim_alignment,
.spf_alignment = spf_alignment,
.warnings = try warnings.toOwnedSlice(),
};
}
// ---------------------------------------------------------------------------
// DNS-over-HTTPS lookup
// ---------------------------------------------------------------------------
const doh_endpoint = "https://cloudflare-dns.com/dns-query";
const DohAnswer = struct {
name: []const u8 = "",
type: u32 = 0,
TTL: u32 = 0,
data: []const u8 = "",
};
const DohResponse = struct {
Status: ?i64 = null,
Answer: ?[]DohAnswer = null,
};
/// Either the TXT strings for a name, or an owned message explaining why the
/// lookup did not produce any. Mirrors the TS `try/catch` around `queryTxt`.
const TxtLookup = union(enum) {
ok: [][]const u8,
failure: []const u8,
fn deinit(self: TxtLookup, allocator: Allocator) void {
switch (self) {
.ok => |records| freeStrings(allocator, records),
.failure => |message| allocator.free(message),
}
}
};
/// Percent-encode a query-string value (`encodeURIComponent`). Callers pass
/// validated hostnames, so this only ever has to escape the unexpected.
fn appendUriEncoded(out: *std.ArrayList(u8), s: []const u8) !void {
for (s) |c| {
const unreserved = std.ascii.isAlphanumeric(c) or
c == '-' or c == '_' or c == '.' or c == '!' or
c == '~' or c == '*' or c == '\'' or c == '(' or c == ')';
if (unreserved) {
try out.append(c);
} else {
try out.writer().print("%{X:0>2}", .{c});
}
}
}
/// Remove every occurrence of `needle` from `s`, returning an owned copy.
fn removeAll(allocator: Allocator, s: []const u8, needle: []const u8) ![]u8 {
var out = std.ArrayList(u8).init(allocator);
errdefer out.deinit();
var rest = s;
while (std.mem.indexOf(u8, rest, needle)) |idx| {
try out.appendSlice(rest[0..idx]);
rest = rest[idx + needle.len ..];
}
try out.appendSlice(rest);
return out.toOwnedSlice();
}
/// Query TXT records for a name via Cloudflare's DoH JSON API. Returns unquoted
/// strings; an empty list means NXDOMAIN or no TXT data.
fn queryTxt(allocator: Allocator, name: []const u8) !TxtLookup {
var url = std.ArrayList(u8).init(allocator);
defer url.deinit();
try url.appendSlice(doh_endpoint ++ "?name=");
try appendUriEncoded(&url, name);
try url.appendSlice("&type=TXT");
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
var body = std.ArrayList(u8).init(allocator);
defer body.deinit();
const response = client.fetch(.{
.method = .GET,
.location = .{ .url = url.items },
.extra_headers = &.{.{ .name = "Accept", .value = "application/dns-json" }},
.response_storage = .{ .dynamic = &body },
.max_append_size = 64 * 1024,
}) catch {
return .{ .failure = try allocator.dupe(u8, "DNS lookup failed.") };
};
if (response.status != .ok) {
return .{ .failure = try std.fmt.allocPrint(
allocator,
"DNS resolver responded with HTTP {d}.",
.{@intFromEnum(response.status)},
) };
}
const parsed = std.json.parseFromSlice(
DohResponse,
allocator,
body.items,
.{ .ignore_unknown_fields = true },
) catch {
return .{ .failure = try allocator.dupe(u8, "DNS lookup failed.") };
};
defer parsed.deinit();
const status = parsed.value.Status orelse 0;
if (status == 3) return .{ .ok = &.{} }; // NXDOMAIN — no such domain
if (status != 0) {
return .{ .failure = try std.fmt.allocPrint(
allocator,
"DNS query failed with status {d}.",
.{status},
) };
}
var records = std.ArrayList([]const u8).init(allocator);
errdefer {
for (records.items) |r| allocator.free(r);
records.deinit();
}
for (parsed.value.Answer orelse &.{}) |answer| {
if (answer.type != 16) continue; // TXT
// Multi-chunk TXT arrives as `"part1" "part2"`: unquote, then splice.
const joined = try removeAll(allocator, stripQuotes(answer.data), "\" \"");
errdefer allocator.free(joined);
try records.append(joined);
}
return .{ .ok = try records.toOwnedSlice() };
}
/// Join TXT strings with newlines, the shape the pure parsers expect.
fn joinLines(allocator: Allocator, records: [][]const u8) ![]u8 {
return std.mem.join(allocator, "\n", records);
}
// ---------------------------------------------------------------------------
// Checks — lookup + parse + verdict
// ---------------------------------------------------------------------------
const invalid_domain_message = "Enter a valid domain, e.g. example.com.";
const invalid_selector_message = "Enter a valid selector (letters, digits, dots, hyphens, underscores).";
/// Look up and evaluate a domain's SPF record.
pub fn checkSPF(allocator: Allocator, domain: []const u8) !SPFResult {
const host = try normalizeDomain(allocator, domain);
defer allocator.free(host);
if (!isDomainLike(host)) {
return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_domain_message) };
}
const lookup = try queryTxt(allocator, host);
defer lookup.deinit(allocator);
const records = switch (lookup) {
.failure => |message| return .{
.status = .fail,
.found = false,
.message = try allocator.dupe(u8, message),
},
.ok => |records| records,
};
const joined = try joinLines(allocator, records);
defer allocator.free(joined);
var record = try parseSPF(allocator, joined);
errdefer record.deinit(allocator);
if (!record.valid) {
record.deinit(allocator);
return .{
.status = .fail,
.found = false,
.message = try std.fmt.allocPrint(
allocator,
"No SPF record found for {s}. Receivers cannot verify which servers may send mail for it.",
.{host},
),
};
}
const status: CheckStatus = if (record.warnings.len > 0) .warn else .pass;
return .{
.status = status,
.found = true,
.record = record,
.message = if (status == .pass)
try allocator.dupe(u8, "SPF record found and looks healthy.")
else
null,
};
}
/// Look up and evaluate a domain's DKIM public key for one selector.
pub fn checkDKIM(allocator: Allocator, domain: []const u8, selector: []const u8) !DKIMResult {
const host = try normalizeDomain(allocator, domain);
defer allocator.free(host);
if (!isDomainLike(host)) {
return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_domain_message) };
}
const trimmed = trim(selector);
const sel = try allocator.alloc(u8, trimmed.len);
defer allocator.free(sel);
for (trimmed, 0..) |c, i| sel[i] = std.ascii.toLower(c);
if (!isValidSelector(sel)) {
return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_selector_message) };
}
const name = try std.fmt.allocPrint(allocator, "{s}._domainkey.{s}", .{ sel, host });
defer allocator.free(name);
const lookup = try queryTxt(allocator, name);
defer lookup.deinit(allocator);
const records = switch (lookup) {
.failure => |message| return .{
.status = .fail,
.found = false,
.message = try allocator.dupe(u8, message),
},
.ok => |records| records,
};
const joined = try joinLines(allocator, records);
defer allocator.free(joined);
var record = try parseDKIM(allocator, joined);
errdefer record.deinit(allocator);
if (!record.valid) {
record.deinit(allocator);
return .{
.status = .fail,
.found = false,
.message = try std.fmt.allocPrint(
allocator,
"No DKIM record found at {s}. Try another selector — only one is checked per lookup.",
.{name},
),
};
}
var revoked = false;
for (record.warnings) |w| {
if (std.mem.indexOf(u8, w, "revoked") != null) revoked = true;
}
const status: CheckStatus = if (revoked) .fail else if (record.warnings.len > 0) .warn else .pass;
return .{ .status = status, .found = true, .record = record };
}
/// Look up and evaluate a domain's DMARC policy.
pub fn checkDMARC(allocator: Allocator, domain: []const u8) !DMARCResult {
const host = try normalizeDomain(allocator, domain);
defer allocator.free(host);
if (!isDomainLike(host)) {
return .{ .status = .fail, .found = false, .message = try allocator.dupe(u8, invalid_domain_message) };
}
const name = try std.fmt.allocPrint(allocator, "_dmarc.{s}", .{host});
defer allocator.free(name);
const lookup = try queryTxt(allocator, name);
defer lookup.deinit(allocator);
const records = switch (lookup) {
.failure => |message| return .{
.status = .fail,
.found = false,
.message = try allocator.dupe(u8, message),
},
.ok => |records| records,
};
const joined = try joinLines(allocator, records);
defer allocator.free(joined);
var record = try parseDMARC(allocator, joined);
errdefer record.deinit(allocator);
if (!record.valid) {
record.deinit(allocator);
return .{
.status = .fail,
.found = false,
.message = try std.fmt.allocPrint(
allocator,
"No DMARC record found at {s}. Receivers have no policy to apply when SPF or DKIM fails.",
.{name},
),
};
}
const status: CheckStatus = if (record.warnings.len > 0) .warn else .pass;
return .{ .status = status, .found = true, .record = record };
}
Also available in 9 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →