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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Making a signature, RFC 6376 §5.//!//! Signing produces one thing: the text of a `DKIM-Signature` header field,//! ready to be put at the top of the message. Nothing else about the message//! changes — canonicalization decides what is *hashed*, never what is sent.//!//! ```zig//! var buf: [dkim.sign.max_field_len]u8 = undefined;//! const field = try dkim.sign.sign(&buf, raw_message, .{//! .domain = "example.com",//! .selector = "mail",//! .key = .{ .rsa = secret_key },//! .timestamp = 1700000000,//! });//! // Send `field` followed by `raw_message`.//! ```//!//! ## The circularity, and how it is broken//!//! The signature covers the header fields, and it also covers *itself* — the//! `DKIM-Signature` field is the last thing hashed, so that nobody can change//! its terms without invalidating it. Which would be impossible, except that//! the one part left out of the hash is the signature's own `b=` value.//!//! So the field is built twice over the same bytes. Everything up to and//! including `b=` is written into the buffer, that is hashed as though the//! field were complete, the hash is signed, and the signature is appended to//! the bytes already there. The prefix is never rebuilt, which is what//! guarantees that what was hashed and what is sent are the same bytes: a//! signer that formats the field twice and gets one space different between//! the two produces a signature that verifies nowhere.//!//! This is also why `b=` is written last. It is not required to be — a//! verifier handles `b=` anywhere — but putting it last makes the "everything//! after the signature" half of the hash empty, which is one fewer thing to//! be wrong.
const std = @import("std");const testing = std.testing;
const rsa = @import("std_crypto_ext").rsa;const Ed25519 = std.crypto.sign.Ed25519;
const canonical = @import("canonical.zig");const digest = @import("digest.zig");const message = @import("message.zig");const Signature = @import("Signature.zig");
/// Enough room for any field this will produce.////// The worst case is a 4096-bit RSA signature, which is 512 bytes and so 684/// characters of base64, plus a 44-character body hash, plus the tags, plus a/// generous `h=`. Doubling that leaves room for a long domain and a long list/// of signed fields.pub const max_field_len = 2048;
/// The key to sign with.pub const Key = union(enum) { rsa: rsa.SecretKey, ed25519: Ed25519.KeyPair,
/// The signing algorithm this key implies, with SHA-256 for RSA because /// RFC 8301 forbids the alternative. pub fn algorithm(self: Key) Signature.Algorithm { return switch (self) { .rsa => .rsa_sha256, .ed25519 => .ed25519_sha256, }; }};
/// The header fields signed when the caller does not say.////// Chosen to cover what a reader of the message actually sees — who it is/// from, who it is to, what it says it is about, when it was sent, and what/// the body is meant to be decoded as — without covering fields that a/// legitimate relay adds or rewrites.////// `From` is the only one RFC 6376 requires, and it is the only one that/// matters: DKIM exists to bind a domain to a message, and the domain a/// reader sees is the one in `From`. The rest are here so that the visible/// message cannot be rewritten under a signature that still passes.////// Deliberately absent: `Received`, which every hop adds; `Return-Path`,/// which the final delivery agent writes; and `DKIM-Signature` itself, which/// §3.7 forbids a signature from covering in its own `h=`.pub const default_headers: []const []const u8 = &.{ "from", "to", "cc", "subject", "date", "message-id", "reply-to", "in-reply-to", "references", "mime-version", "content-type", "content-transfer-encoding",};
/// What to sign, and how.pub const Options = struct { /// `d=`, the domain taking responsibility for the message. Required. domain: []const u8, /// `s=`, the selector naming which of that domain's keys this is. selector: []const u8, /// The key. Its type decides `a=`. key: Key,
/// The field names to sign, in the order they will be hashed. /// /// A name listed but not present in the message contributes nothing to /// the hash, which means listing it signs its *absence*: the field cannot /// then be added in transit without breaking the signature. Listing a /// name twice when the message has one such field does the same thing. headers: []const []const u8 = default_headers,
/// `c=`. Relaxed on both halves, because `simple` does not survive /// contact with the mail system and choosing it is almost always a /// mistake. canonicalization: canonical.Algorithms = .{ .header = .relaxed, .body = .relaxed },
/// Tags written between `s=` and `b=`, verbatim, each ending in a /// semicolon: `"i=3; cv=pass;"`. /// /// A signer cannot write a tag it does not define, and it should /// not have to define ARC's — but an `ARC-Message-Signature` /// carries an `i=` and inserting one afterwards would invalidate /// the hash just computed over the field. So the caller supplies the /// text and the signature covers it, which is the only order that /// can work. extra_tags: []const u8 = "",
/// The header field name to write, which is also the name hashed /// into the signature's own contribution. /// /// It exists for ARC ([RFC /// 8617](https://datatracker.ietf.org/doc/html/rfc8617)), whose /// `ARC-Message-Signature` is a DKIM signature under another name. /// Nothing in the algorithm looks at the name beyond hashing it, so /// this is the whole of the difference. field_name: []const u8 = "DKIM-Signature",
/// Whether to write the `v=1` tag. §4.1.2 of RFC 8617 defines no /// version tag for an `ARC-Message-Signature`. version: bool = true,
/// `i=`, the identity. Must be inside `domain`. identity: ?[]const u8 = null,
/// `t=`, when this was signed, in seconds since the epoch. /// /// Passed in rather than read from the clock, for the same reason the /// verifier takes its `now`: a library that reads the clock cannot /// produce the same bytes twice, and a signer that cannot be made to /// produce the same bytes twice cannot be tested against a fixed vector. timestamp: ?u64 = null,
/// `x=`, when the signature stops being valid. Must be after `timestamp`. expiration: ?u64 = null,
/// `l=`, how many octets of the canonicalized body to cover. /// /// **Almost always leave this alone.** It exists so that a mailing list /// can append a footer without breaking the signature, and the price is /// that anyone at all can append anything to the message and the /// signature still passes. RFC 6376 §8.2 spells out the attack. `null` /// covers the whole body. body_length: ?u64 = null,};
/// What can go wrong making a signature.pub const Error = error{ /// The output buffer is too small; `max_field_len` is always enough. BufferTooSmall, /// `headers` does not include `from`. §5.4 requires it. FromNotSigned, /// `identity` is not `domain` or a subdomain of it. IdentityOutsideDomain, /// `expiration` is not after `timestamp`. ExpiresBeforeSigned, /// `body_length` is larger than the canonicalized body. BodyLengthTooLong, /// The RSA key is too small to produce a signature with this hash, or the /// buffer could not hold one. KeyUnusable,};
/// Signs a message, returning the `DKIM-Signature` field to prepend to it.////// What comes back ends with CRLF and is ready to be written out followed by/// `raw` unchanged.pub fn sign(out: []u8, raw: []const u8, options: Options) Error![]const u8 { try checkOptions(options);
const algorithm = options.key.algorithm(); const parts = message.split(raw);
const body = digest.body( algorithm, options.canonicalization.body, parts.body, options.body_length, ); if (options.body_length) |length| { if (length > body.produced) return error.BodyLengthTooLong; }
// Everything up to and including `b=`, which is what gets hashed. var folder: Folder = .{ .buf = out }; try writePrefix(&folder, algorithm, body, options); const field = folder.written();
// The field as the hash has to see it: the name, and a value that stops // dead after `b=`. const colon = std.mem.findScalar(u8, field, ':').?; const header_digest = digest.headers( algorithm, options.canonicalization.header, parts.headers, folder.h_tag.?, .{ .raw_name = field[0..colon], .name = field[0..colon], .before = field[colon + 1 ..], .after = "", }, null, );
try writeSignature(&folder, algorithm, header_digest, options.key); folder.raw("\r\n"); if (folder.overflowed) return error.BufferTooSmall; return folder.written();}
fn checkOptions(options: Options) Error!void { var signs_from = false; for (options.headers) |name| { if (std.ascii.eqlIgnoreCase(name, "from")) signs_from = true; } if (!signs_from) return error.FromNotSigned;
if (options.identity) |identity| { const at = std.mem.lastIndexOfScalar(u8, identity, '@') orelse return error.IdentityOutsideDomain; const id_domain = identity[at + 1 ..]; const same = std.ascii.eqlIgnoreCase(id_domain, options.domain); const sub = id_domain.len > options.domain.len + 1 and id_domain[id_domain.len - options.domain.len - 1] == '.' and std.ascii.eqlIgnoreCase(id_domain[id_domain.len - options.domain.len ..], options.domain); if (!same and !sub) return error.IdentityOutsideDomain; }
if (options.timestamp != null and options.expiration != null) { if (options.expiration.? <= options.timestamp.?) return error.ExpiresBeforeSigned; }}
/// Writes the field up to and including `b=`.fn writePrefix( folder: *Folder, algorithm: Signature.Algorithm, body: digest.BodyDigest, options: Options,) Error!void { folder.raw(options.field_name); folder.raw(":"); if (options.version) folder.tag("v=1;"); folder.tagFmt("a={s};", .{algorithm.toString()}); folder.tagFmt("c={s}/{s};", .{ options.canonicalization.header.toString(), options.canonicalization.body.toString(), }); folder.tagFmt("d={s};", .{options.domain}); folder.tagFmt("s={s};", .{options.selector}); if (options.extra_tags.len > 0) folder.tag(options.extra_tags); if (options.identity) |identity| folder.tagFmt("i={s};", .{identity}); if (options.timestamp) |t| folder.tagFmt("t={d};", .{t}); if (options.expiration) |x| folder.tagFmt("x={d};", .{x}); if (options.body_length) |l| folder.tagFmt("l={d};", .{l});
var encoded: [64]u8 = undefined; const bh = std.base64.standard.Encoder.encode(&encoded, body.digest.slice()); folder.tagFmt("bh={s};", .{bh});
folder.writeHeaderList(options.headers); folder.tag("b=");}
/// Signs the header hash and appends the base64 of it./// Signs a digest, which is the last step of producing anything signed/// the way DKIM signs.////// The mirror of `verification.verifyDigest`, and exposed for the same/// reason: ARC's `ARC-Seal` hashes the ARC header field values in a/// fixed order rather than the set an `h=` names, and then signs the/// result exactly this way. The hashing differs; the algorithm/// dispatch and the key handling do not.pub fn signDigest( out: []u8, algorithm: Signature.Algorithm, header_digest: digest.Digest, key: Key,) Error![]const u8 { var raw_signature: [rsa.max_modulus_len]u8 = undefined; const signature = try rawSignature(&raw_signature, algorithm, header_digest, key); const encoder = std.base64.standard.Encoder; const len = encoder.calcSize(signature.len); if (len > out.len) return error.BufferTooSmall; return encoder.encode(out[0..len], signature);}
fn writeSignature( folder: *Folder, algorithm: Signature.Algorithm, header_digest: digest.Digest, key: Key,) Error!void { var raw_signature: [rsa.max_modulus_len]u8 = undefined; const signature = try rawSignature(&raw_signature, algorithm, header_digest, key); var encoded: [1024]u8 = undefined; const b64 = std.base64.standard.Encoder.encode(&encoded, signature); folder.wrapped(b64);}
/// The signature bytes themselves, before any encoding.fn rawSignature( raw_signature: *[rsa.max_modulus_len]u8, algorithm: Signature.Algorithm, header_digest: digest.Digest, key: Key,) Error![]const u8 { return switch (key) { .rsa => |secret| blk: { const Sha1 = std.crypto.hash.Sha1; const Sha256 = std.crypto.hash.sha2.Sha256; break :blk switch (algorithm) { .rsa_sha256 => rsa.pkcs1v1_5.Signer(Sha256).signDigest( raw_signature, header_digest.bytes[0..Sha256.digest_length].*, secret, ) catch return error.KeyUnusable, .rsa_sha1 => rsa.pkcs1v1_5.Signer(Sha1).signDigest( raw_signature, header_digest.bytes[0..Sha1.digest_length].*, secret, ) catch return error.KeyUnusable, .ed25519_sha256 => unreachable, }; }, .ed25519 => |pair| blk: { // RFC 8463: Ed25519 signs the 32-byte header hash as its message. const produced = pair.sign(header_digest.slice(), null) catch return error.KeyUnusable; const bytes = produced.toBytes(); @memcpy(raw_signature[0..bytes.len], &bytes); break :blk raw_signature[0..bytes.len]; }, };}
/// Builds the field, folding it so that no line runs past 78 columns.////// RFC 5322 requires lines under 998 octets and recommends under 78, and a/// signature is long enough that something in the chain will fold it if the/// signer does not — which would be fine, since folding whitespace is/// invisible to both canonicalizations, but a field folded on purpose is a/// field nobody else has to fold.const Folder = struct { buf: []u8, len: usize = 0, column: usize = 0, overflowed: bool = false, /// The `h=` value as written, which the hash needs to walk. h_tag: ?[]const u8 = null,
const limit = 78;
fn written(self: *const Folder) []const u8 { return self.buf[0..self.len]; }
/// Appends bytes with no folding and no space before them. fn raw(self: *Folder, bytes: []const u8) void { if (self.len + bytes.len > self.buf.len) { self.overflowed = true; return; } @memcpy(self.buf[self.len..][0..bytes.len], bytes); self.len += bytes.len; if (std.mem.findScalarLast(u8, bytes, '\n')) |nl| { self.column = bytes.len - nl - 1; } else { self.column += bytes.len; } }
/// Appends a whole tag, preceded by a space, folding first if it would /// otherwise run past the limit. fn tag(self: *Folder, bytes: []const u8) void { if (self.column + 1 + bytes.len > limit) { self.raw("\r\n\t"); } else { self.raw(" "); } self.raw(bytes); }
fn tagFmt(self: *Folder, comptime fmt: []const u8, args: anytype) void { var scratch: [512]u8 = undefined; const text = std.fmt.bufPrint(&scratch, fmt, args) catch { self.overflowed = true; return; }; self.tag(text); }
/// Writes `h=` and remembers the value, so that the hash walks exactly /// the bytes that were written rather than a list rebuilt from the /// options. fn writeHeaderList(self: *Folder, names: []const []const u8) void { self.tag("h="); const start = self.len; for (names, 0..) |name, i| { if (i > 0) { // Fold *before* the colon rather than after it, so that the // fold's whitespace is never the first thing in a `tval`. if (self.column + 1 + name.len > limit) self.raw("\r\n\t"); self.raw(":"); } self.raw(name); } self.h_tag = self.buf[start..self.len]; self.raw(";"); }
/// Appends a long run of base64, folded wherever it runs out of line. fn wrapped(self: *Folder, bytes: []const u8) void { var rest = bytes; while (rest.len > 0) { if (self.column >= limit) self.raw("\r\n\t"); const room = limit - self.column; const take = @min(rest.len, room); self.raw(rest[0..take]); rest = rest[take..]; } }};