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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! A command-line DKIM signer and verifier.//!//! It exists for two reasons. The first is to be something to point another//! implementation at: `tests/interop` drives this against `dkimpy` and//! `opendkim` in both directions, which is the only evidence that this//! library is right rather than merely self-consistent.//!//! The second is that it is genuinely useful. `verify` takes the key record//! on the command line rather than looking it up, which is exactly what is//! wanted when the question is "is this signature good?" and not "does DNS//! currently say so" — the two differ precisely when a key has been rotated,//! which is when the question gets asked.//!//! There is no DNS here, for the reason `verify.zig` gives: the library does//! not resolve names, so neither does this.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;
const dkim = @import("dkim");const rsa = @import("std_crypto_ext").rsa;
const usage = \\usage: zig-dkim <command> [options] \\ \\ sign sign a message read from stdin, writing it signed to stdout \\ verify verify a message read from stdin \\ bodyhash print the body hash of a message read from stdin \\ \\sign options: \\ -k, --key FILE the private key, PEM, PKCS#1 or PKCS#8 (required) \\ -d, --domain DOMAIN the d= tag (required) \\ -s, --selector SEL the s= tag (required) \\ -h, --headers LIST colon-separated fields to sign (default: a sensible set) \\ -c, --canon C/C canonicalization (default relaxed/relaxed) \\ -i, --identity ID the i= tag \\ -t, --timestamp N the t= tag, seconds since the epoch \\ -x, --expire N the x= tag \\ -l, --length N the l= tag; read RFC 6376 section 8.2 first \\ --field-only print just the DKIM-Signature field, not the message \\ \\verify options: \\ -p, --record TXT the key record, as the TXT record's text (required) \\ --now N the time to judge x= against; default is not to \\ --allow-sha1 accept a=rsa-sha1, which RFC 8301 forbids \\ --min-bits N smallest RSA key to accept (default 1024) \\ \\bodyhash options: \\ -c, --canon C body canonicalization (default relaxed) \\ -a, --algorithm A rsa-sha256 or rsa-sha1 (default rsa-sha256) \\;
pub fn main(init: std.process.Init) !void { const gpa = init.arena.allocator(); const io = init.io; const args = try init.minimal.args.toSlice(gpa);
var stdout_buffer: [16 * 1024]u8 = undefined; var stdout_file: Io.File.Writer = .init(.stdout(), io, &stdout_buffer); const stdout = &stdout_file.interface; defer stdout.flush() catch {};
if (args.len < 2) { try stdout.writeAll(usage); return error.MissingCommand; }
const command = args[1]; const rest = args[2..]; if (std.mem.eql(u8, command, "sign")) { try signCommand(io, gpa, stdout, rest); } else if (std.mem.eql(u8, command, "verify")) { try verifyCommand(io, gpa, stdout, rest); } else if (std.mem.eql(u8, command, "bodyhash")) { try bodyHashCommand(io, gpa, stdout, rest); } else { try stdout.writeAll(usage); return error.UnknownCommand; }}
/// Everything on stdin, which for a mail message is the whole of it.fn readStdin(io: Io, gpa: Allocator) ![]u8 { var buffer: [64 * 1024]u8 = undefined; var reader: Io.File.Reader = .initStreaming(.stdin(), io, &buffer); var collected: std.Io.Writer.Allocating = .init(gpa); _ = try reader.interface.streamRemaining(&collected.writer); return collected.toOwnedSlice();}
fn signCommand(io: Io, gpa: Allocator, stdout: *Io.Writer, args: []const []const u8) !void { var key_path: ?[]const u8 = null; var domain: ?[]const u8 = null; var selector: ?[]const u8 = null; var header_list: ?[]const u8 = null; var canon: []const u8 = "relaxed/relaxed"; var identity: ?[]const u8 = null; var timestamp: ?u64 = null; var expire: ?u64 = null; var length: ?u64 = null; var field_only = false;
var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (match(arg, "-k", "--key")) { key_path = try value(args, &i); } else if (match(arg, "-d", "--domain")) { domain = try value(args, &i); } else if (match(arg, "-s", "--selector")) { selector = try value(args, &i); } else if (match(arg, "-h", "--headers")) { header_list = try value(args, &i); } else if (match(arg, "-c", "--canon")) { canon = try value(args, &i); } else if (match(arg, "-i", "--identity")) { identity = try value(args, &i); } else if (match(arg, "-t", "--timestamp")) { timestamp = try number(args, &i); } else if (match(arg, "-x", "--expire")) { expire = try number(args, &i); } else if (match(arg, "-l", "--length")) { length = try number(args, &i); } else if (std.mem.eql(u8, arg, "--field-only")) { field_only = true; } else { return error.UnknownOption; } }
const pem = try Io.Dir.cwd().readFileAlloc( io, key_path orelse return error.MissingKey, gpa, .limited(64 * 1024), ); var der: [rsa.max_secret_key_der]u8 = undefined; const key = try rsa.SecretKey.fromPem(&der, pem);
// The header list is split here rather than handed over as a string, // because `sign` takes the names and writes the `h=` tag itself. var names: std.ArrayList([]const u8) = .empty; if (header_list) |list| { var it = std.mem.splitScalar(u8, list, ':'); while (it.next()) |name| { const trimmed = std.mem.trim(u8, name, " \t"); if (trimmed.len > 0) try names.append(gpa, trimmed); } }
const raw = try readStdin(io, gpa); var field_buf: [dkim.signing.max_field_len]u8 = undefined; const field = try dkim.signing.sign(&field_buf, raw, .{ .domain = domain orelse return error.MissingDomain, .selector = selector orelse return error.MissingSelector, .key = .{ .rsa = key }, .headers = if (header_list != null) names.items else dkim.signing.default_headers, .canonicalization = try dkim.canonical.Algorithms.parse(canon), .identity = identity, .timestamp = timestamp, .expiration = expire, .body_length = length, });
try stdout.writeAll(field); if (!field_only) try stdout.writeAll(raw);}
fn verifyCommand(io: Io, gpa: Allocator, stdout: *Io.Writer, args: []const []const u8) !void { var txt: ?[]const u8 = null; var options: dkim.verification.Options = .{};
var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (match(arg, "-p", "--record")) { txt = try value(args, &i); } else if (std.mem.eql(u8, arg, "--now")) { options.now = @intCast(try number(args, &i)); } else if (std.mem.eql(u8, arg, "--allow-sha1")) { options.allow_rsa_sha1 = true; } else if (std.mem.eql(u8, arg, "--min-bits")) { options.min_rsa_bits = @intCast(try number(args, &i)); } else { return error.UnknownOption; } }
const record = try dkim.Record.parse(txt orelse return error.MissingRecord); const raw = try readStdin(io, gpa);
var found = false; var passed = false; var it = dkim.verification.signatures(raw); while (it.next()) |result| { found = true; const signature = result catch |err| { try stdout.print("fail: unparseable signature: {t}\n", .{err}); continue; }; if (dkim.verification.verify(raw, signature, record, options)) |verified| { passed = true; try stdout.print("pass: d={s} a={s}", .{ verified.domain, verified.algorithm.toString(), }); if (verified.identity) |id| try stdout.print(" i={s}", .{id}); if (verified.testing_key) try stdout.writeAll(" (testing key)"); if (verified.isPartiallyCovered()) { try stdout.print( " (l= covers {d} of {d} body octets)", .{ verified.covered, verified.total }, ); } try stdout.writeAll("\n"); } else |err| { try stdout.print("fail: d={s}: {t}\n", .{ signature.domain, err }); } }
if (!found) { try stdout.writeAll("none: the message carries no DKIM-Signature\n"); try stdout.flush(); return error.NoSignature; } // RFC 6376 §6.1: one signature that verifies is enough, because anybody // can add a broken one to a message in transit. if (!passed) { try stdout.flush(); return error.VerificationFailed; }}
fn bodyHashCommand(io: Io, gpa: Allocator, stdout: *Io.Writer, args: []const []const u8) !void { var canon: []const u8 = "relaxed"; var algorithm: []const u8 = "rsa-sha256"; var i: usize = 0; while (i < args.len) : (i += 1) { const arg = args[i]; if (match(arg, "-c", "--canon")) { canon = try value(args, &i); } else if (match(arg, "-a", "--algorithm")) { algorithm = try value(args, &i); } else { return error.UnknownOption; } }
const raw = try readStdin(io, gpa); const parts = dkim.message.split(raw); const result = dkim.digest.body( try dkim.Signature.Algorithm.parse(algorithm), try dkim.canonical.Canonicalization.parse(canon), parts.body, null, ); var encoded: [64]u8 = undefined; try stdout.print("{s}\n", .{ std.base64.standard.Encoder.encode(&encoded, result.digest.slice()), });}
fn match(arg: []const u8, short: []const u8, long: []const u8) bool { return std.mem.eql(u8, arg, short) or std.mem.eql(u8, arg, long);}
fn value(args: []const []const u8, i: *usize) ![]const u8 { i.* += 1; if (i.* >= args.len) return error.MissingValue; return args[i.*];}
fn number(args: []const []const u8, i: *usize) !u64 { return std.fmt.parseInt(u64, try value(args, i), 10);}