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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Verifying a signature, RFC 6376 §6.//!//! ## Why this does not do DNS//!//! Verification needs a public key, the key is in DNS, and there is no DNS//! here. That is deliberate. A resolver would drag in an `Io`, a timeout//! policy, a cache, a view about DNSSEC and a network connection, and it//! would put all of DKIM's actual logic behind a socket that a test cannot//! easily stand up. Instead the work is split where the I/O is://!//! ```zig//! var it = dkim.signatures(raw_message);//! while (try it.next()) |signature| {//! var name_buf: [253]u8 = undefined;//! const name = try signature.recordName(&name_buf); // what to ask DNS//! const txt = try myResolver.txt(name); // the caller's job//! const record = try dkim.Record.parse(txt);//! const result = dkim.verify(raw_message, signature, record, .{});//! }//! ```//!//! The consequence is that a `TEMPFAIL` — the DNS lookup failed, try again//! later — is not a result this module can return. It never happens here,//! because the lookup happened before anything in this file ran. Everything//! below is a permanent failure: the signature will not verify now and will//! not verify in an hour.//!//! ## A failure is an error, and success carries detail//!//! Verification returning an error is the ordinary case, not the exceptional//! one — most signatures a busy mail server sees are fine, but the ones that//! are not are routine. Errors are used anyway, because Zig will not let a//! caller ignore one, and a DKIM verifier that silently ignores a failure is//! worse than no verifier at all.//!//! Success returns a `Verified`, and it is worth reading rather than//! discarding: it says which domain took responsibility, whether the key is//! flagged as testing, and whether `l=` left part of the body uncovered.//! A `pass` with 200 bytes of unsigned body after it is not the same thing as//! a `pass`.
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 Record = @import("Record.zig");const Signature = @import("Signature.zig");
/// How strict to be about things RFC 6376 permitted and later documents did/// not.pub const Options = struct { /// The current time, in seconds since the epoch, for checking `x=`. /// /// `null` means do not check expiry at all, which is what a test wants /// and what verifying an archived message wants. It is passed in rather /// than read because a library that reads the clock cannot be tested /// against a signature that expired in 2019. now: ?i64 = null,
/// Accept `a=rsa-sha1`. /// /// **RFC 8301 §3.1 forbids it**: "rsa-sha1 MUST NOT be used for signing /// or verifying". Off by default, and there is no good reason to turn it /// on outside of examining an old message on purpose. allow_rsa_sha1: bool = false,
/// The smallest RSA key to accept, in bits. /// /// RFC 8301 §3.2 requires verifiers to reject anything below 1024 and /// signers to use at least 1024, with 2048 recommended. Raising this to /// 2048 is defensible and will reject real mail. min_rsa_bits: usize = 1024,
/// Require `q=` to name `dns/txt` when it is present. /// /// The key here was fetched by the caller, by whatever means, so this is /// about whether the signature *asked* for the method that was used. A /// signature naming only a query method nobody has defined is one this /// library cannot honestly say it verified the way the signer intended. require_known_query_method: bool = true,};
/// Why a signature did not verify. Every one of these is permanent.pub const Error = error{ /// `a=rsa-sha1`, which RFC 8301 forbids. See `Options.allow_rsa_sha1`. AlgorithmForbidden, /// The key record's `k=` is not the key type `a=` needs. KeyTypeMismatch, /// The key record's `h=` does not permit this signature's hash. HashNotAcceptable, /// The key record's `s=` does not include email. ServiceNotAcceptable, /// The key record has `p=` empty: the key has been revoked. KeyRevoked, /// The key record has `t=s`, and the signature's `i=` is a subdomain of /// `d=` rather than equal to it. IdentityNotStrict, /// `x=` is in the past. SignatureExpired, /// `q=` does not name a query method this library understands. QueryMethodUnsupported, /// The RSA key is smaller than `Options.min_rsa_bits`. KeyTooSmall, /// The key in the record is not a usable key of its stated type. InvalidKey, /// `l=` claims more octets than the canonicalized body has. §3.5: "This /// value MUST NOT be larger than the actual number of octets in the /// canonicalized message body." BodyLengthTooLong, /// The body has changed: the hash of it is not what `bh=` says. /// /// This is checked before the signature, because it needs no public key /// arithmetic — which is exactly what makes it a cheap way to throw out /// a forgery before doing the expensive part. BodyHashMismatch, /// The headers have changed, or the signature was not made by the key in /// the record, or it was not made at all. SignatureMismatch,} || Signature.ParseError || Record.ParseError;
/// What a signature that verified actually says.pub const Verified = struct { /// `d=`: the domain taking responsibility for the message. This is the /// output of DKIM. Everything else here is context for it. domain: []const u8, /// `i=`, if the signature carried one. identity: ?[]const u8, /// The key record has `t=y`: the domain says it is still testing, and /// §3.6.1 requires a verifier not to treat this message differently from /// unsigned mail. A pass from a testing key is not evidence of anything. testing_key: bool, /// The algorithm that was used. algorithm: Signature.Algorithm, /// How much of the canonicalized body the signature actually covered. /// /// `covered` is less than `total` only when `l=` was used, and everything /// past it is unsigned: anyone can append to a message signed this way /// and the signature still passes. §8.2 is blunt about the risk, and this /// is here so that a caller can be too — treating a partial cover as a /// failure is a defensible policy and cannot be one if nobody is told. covered: u64, /// The total size of the canonicalized body. total: u64,
/// Whether any of the body was left unsigned by `l=`. pub fn isPartiallyCovered(self: Verified) bool { return self.covered < self.total; }};
/// Verifies one signature against the key record it names.pub fn verify( raw: []const u8, signature: Signature, record: Record, options: Options,) Error!Verified { if (signature.algorithm == .rsa_sha1 and !options.allow_rsa_sha1) { return error.AlgorithmForbidden; } if (options.require_known_query_method) try checkQueryMethod(signature); if (options.now) |now| { if (signature.hasExpired(now)) return error.SignatureExpired; }
// The record has to agree that this key may be used this way, before any // of it is used at all. if (record.revoked) return error.KeyRevoked; if (record.key_type != signature.algorithm.keyType()) return error.KeyTypeMismatch; if (!record.acceptsHash(signature.algorithm)) return error.HashNotAcceptable; if (!record.acceptsEmail()) return error.ServiceNotAcceptable; if (record.flags.strict_identity) { if (signature.identity) |identity| { // §3.6.1: with `t=s`, the domain either side of the `@` must be // `d=` exactly. The flag says nothing about a signature with no // `i=` at all, which is why this is nested rather than combined. const at = std.mem.lastIndexOfScalar(u8, identity, '@') orelse return error.IdentityNotStrict; if (!std.ascii.eqlIgnoreCase(identity[at + 1 ..], signature.domain)) { return error.IdentityNotStrict; } } }
const parts = message.split(raw);
// Hash step 1, the body. First because it is free -- no public key // arithmetic -- and because §6.1.3 says a body hash mismatch fails the // whole signature regardless of anything else. const body = digest.body( signature.algorithm, signature.canonicalization.body, parts.body, signature.body_length, ); if (signature.body_length) |length| { if (length > body.produced) return error.BodyLengthTooLong; }
var expected_buf: [digest.max_digest_len]u8 = undefined; const expected = try signature.decodeBodyHash(&expected_buf); if (!std.crypto.timing_safe.eql([digest.max_digest_len]u8, body.digest.bytes, blk: { var padded: [digest.max_digest_len]u8 = @splat(0); @memcpy(padded[0..expected.len], expected); break :blk padded; })) return error.BodyHashMismatch;
// Hash step 2, the headers and the signature's own field. const header_digest = digest.headers( signature.algorithm, signature.canonicalization.header, parts.headers, signature.headers, .fromSignature(signature), signature.field.start, );
var signature_buf: [rsa.max_modulus_len]u8 = undefined; const signature_bytes = try signature.decodeSignature(&signature_buf);
var key_buf: [rsa.max_secret_key_der]u8 = undefined; const key_bytes = try record.decodeKey(&key_buf);
try verifyDigest(signature.algorithm, header_digest, signature_bytes, key_bytes, options);
return .{ .domain = signature.domain, .identity = signature.identity, .testing_key = record.flags.testing, .algorithm = signature.algorithm, .covered = body.hashed, .total = body.produced, };}
fn checkQueryMethod(signature: Signature) Error!void { const q = signature.query_methods orelse return; // absent means dns/txt var it: Signature.HeaderIterator = .{ .rest = q }; while (it.next()) |method| { // §3.5 lists `dns/txt` as the only defined method and requires the // `/txt` option to be there. Unrecognized methods are to be ignored, // so this looks for one it knows rather than refusing one it does not. if (std.ascii.eqlIgnoreCase(method, "dns/txt")) return; } return error.QueryMethodUnsupported;}
/// Checks a digest against a signature and a key, which is the last/// step of verifying anything signed the way DKIM signs.////// Exposed because ARC's `ARC-Seal` signs a digest this library does not/// compute: §5.1.1 hashes the ARC header field values in a fixed order/// rather than the set an `h=` tag names, and covers no body at all. The/// hashing differs; the algorithm dispatch, the key decoding and the/// deprecation of `rsa-sha1` do not, and having two copies of those is/// how one of them quietly stops being maintained.pub fn verifyDigest( algorithm: Signature.Algorithm, header_digest: digest.Digest, signature_bytes: []const u8, key_bytes: []const u8, options: Options,) Error!void { // The guard belongs with the algorithm dispatch rather than beside // one of its callers: RFC 8301 forbids `rsa-sha1` for signing and // verifying alike, and a second entry point that quietly allowed it // would be a way around the rule rather than a second way to obey // it. if (algorithm == .rsa_sha1 and !options.allow_rsa_sha1) return error.AlgorithmForbidden; switch (algorithm) { .rsa_sha1, .rsa_sha256 => try verifyRsa( algorithm, header_digest, signature_bytes, key_bytes, options, ), .ed25519_sha256 => try verifyEd25519(header_digest, signature_bytes, key_bytes), }}
fn verifyRsa( algorithm: Signature.Algorithm, header_digest: digest.Digest, signature_bytes: []const u8, key_bytes: []const u8, options: Options,) Error!void { const public_key = rsa.PublicKey.fromDer(key_bytes) catch return error.InvalidKey; if (public_key.n.bits() < options.min_rsa_bits) return error.KeyTooSmall;
const Sha1 = std.crypto.hash.Sha1; const Sha256 = std.crypto.hash.sha2.Sha256; switch (algorithm) { .rsa_sha1 => rsa.pkcs1v1_5.Signer(Sha1).verifyDigest( signature_bytes, header_digest.bytes[0..Sha1.digest_length].*, public_key, ) catch return error.SignatureMismatch, .rsa_sha256 => rsa.pkcs1v1_5.Signer(Sha256).verifyDigest( signature_bytes, header_digest.bytes[0..Sha256.digest_length].*, public_key, ) catch return error.SignatureMismatch, .ed25519_sha256 => unreachable, }}
fn verifyEd25519( header_digest: digest.Digest, signature_bytes: []const u8, key_bytes: []const u8,) Error!void { // RFC 8463: `p=` is the 32 raw bytes of the public key, not DER, and the // thing signed is the 32-byte header hash itself rather than the message // -- Ed25519 does its own hashing on top of that. if (key_bytes.len != Ed25519.PublicKey.encoded_length) return error.InvalidKey; if (signature_bytes.len != Ed25519.Signature.encoded_length) return error.SignatureMismatch;
const public_key = Ed25519.PublicKey.fromBytes( key_bytes[0..Ed25519.PublicKey.encoded_length].*, ) catch return error.InvalidKey; const parsed: Ed25519.Signature = .fromBytes( signature_bytes[0..Ed25519.Signature.encoded_length].*, ); parsed.verify(header_digest.slice(), public_key) catch return error.SignatureMismatch;}
/// Walks the `DKIM-Signature` fields of a message, newest first.////// A message may carry several: one from the author's domain, one from a/// mailing list that resigned it, one from a forwarder. §6.1 says a verifier/// should try them and that **one that verifies is enough** — a broken/// signature alongside a good one is not evidence of anything, because/// anybody can add a broken signature to a message in transit.////// Fields that do not parse are reported as an error and the walk continues,/// so that one malformed signature does not hide a good one below it.pub const SignatureIterator = struct { headers: message.HeaderIterator,
/// The next signature, its parse error, or `null` at the end. /// /// An error is returned for the field that failed and the iterator stays /// usable, so the loop to write is `while (it.next()) |result|` with the /// error handled inside rather than propagated out of the loop. pub fn next(self: *SignatureIterator) ?(Signature.ParseError!Signature) { while (self.headers.next()) |field| { if (!field.is("dkim-signature")) continue; return Signature.parse(field); } return null; }};
/// The signatures on a message.pub fn signatures(raw: []const u8) SignatureIterator { return .{ .headers = .init(message.split(raw).headers) };}