// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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..]; } } };