// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Signing a message and then verifying it, which is the only test that //! exercises the two halves against each other. //! //! Every other test in this repository checks one piece against what the RFC //! says it should do. These check that the pieces agree — that the bytes the //! signer hashed are the bytes the verifier hashes, in the same order, with //! the same whitespace. That is the failure mode a unit test cannot find, //! because a signer and a verifier that are wrong in the same way agree //! perfectly with each other. //! //! Which is exactly why these are not enough on their own, and why //! `tests/interop` exists: agreement with *another implementation* is the //! only evidence that this library is right rather than merely consistent. const std = @import("std"); const testing = std.testing; const dkim = @import("dkim"); const rsa = @import("std_crypto_ext").rsa; const keys = @import("keys.zig"); const Ed25519 = std.crypto.sign.Ed25519; const plain_message = "From: Alice \r\n" ++ "To: Bob \r\n" ++ "Subject: A test message\r\n" ++ "Date: Fri, 12 Sep 2026 12:00:00 -0500\r\n" ++ "Message-ID: \r\n" ++ "\r\n" ++ "Hello, Bob.\r\n" ++ "\r\n" ++ "This is a message.\r\n"; fn secretKey(der_buf: []u8) !rsa.SecretKey { return rsa.SecretKey.fromPem(der_buf, keys.rsa2048_private); } /// Signs a message and hands back the signed message: the field, then the /// original, which is exactly how a signer delivers one. fn signMessage( out: []u8, raw: []const u8, options_in: dkim.signing.Options, ) ![]const u8 { var field_buf: [dkim.signing.max_field_len]u8 = undefined; const field = try dkim.signing.sign(&field_buf, raw, options_in); if (field.len + raw.len > out.len) return error.BufferTooSmall; @memcpy(out[0..field.len], field); @memcpy(out[field.len..][0..raw.len], raw); return out[0 .. field.len + raw.len]; } /// The DNS record for the 2048-bit test key. const rsa2048_record = "v=DKIM1; k=rsa; p=" ++ keys.rsa2048_public; const rsa1024_record = "v=DKIM1; k=rsa; p=" ++ keys.rsa1024_public; fn verifyOnly(signed: []const u8, txt: []const u8, options: dkim.verification.Options) !dkim.verification.Verified { var it = dkim.verification.signatures(signed); const signature = try (it.next() orelse return error.NoSignature); const parsed = try dkim.Record.parse(txt); return dkim.verification.verify(signed, signature, parsed, options); } /// The whole round trip, with whatever options. fn roundTrip( raw: []const u8, sign_options: dkim.signing.Options, verify_options: dkim.verification.Options, ) !dkim.verification.Verified { var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, raw, sign_options); return verifyOnly(signed, rsa2048_record, verify_options); } fn defaultOptions(der_buf: []u8) !dkim.signing.Options { return .{ .domain = "example.com", .selector = "test", .key = .{ .rsa = try secretKey(der_buf) }, .timestamp = 1757000000, }; } test "a signed message verifies" { var der: [rsa.max_secret_key_der]u8 = undefined; const result = try roundTrip(plain_message, try defaultOptions(&der), .{}); try testing.expectEqualStrings("example.com", result.domain); try testing.expectEqual(dkim.Signature.Algorithm.rsa_sha256, result.algorithm); try testing.expect(!result.testing_key); try testing.expect(!result.isPartiallyCovered()); } test "every combination of canonicalization round-trips" { var der: [rsa.max_secret_key_der]u8 = undefined; const both = [_]dkim.canonical.Canonicalization{ .simple, .relaxed }; for (both) |header| { for (both) |body| { var options = try defaultOptions(&der); options.canonicalization = .{ .header = header, .body = body }; _ = roundTrip(plain_message, options, .{}) catch |err| { std.debug.print("failed for c={s}/{s}: {t}\n", .{ @tagName(header), @tagName(body), err, }); return err; }; } } } test "a message with no body round-trips under both canonicalizations" { // The empty body is where the two algorithms genuinely disagree, so it is // the case most likely to be wrong in exactly one of them. var der: [rsa.max_secret_key_der]u8 = undefined; const empty = "From: a@example.com\r\nSubject: nothing\r\n\r\n"; for ([_]dkim.canonical.Canonicalization{ .simple, .relaxed }) |body| { var options = try defaultOptions(&der); options.canonicalization = .{ .header = .relaxed, .body = body }; _ = try roundTrip(empty, options, .{}); } // ...and with no empty line at all, which is a different message again. for ([_]dkim.canonical.Canonicalization{ .simple, .relaxed }) |body| { var options = try defaultOptions(&der); options.canonicalization = .{ .header = .relaxed, .body = body }; _ = try roundTrip("From: a@example.com\r\n", options, .{}); } } test "a message with LF endings round-trips" { var der: [rsa.max_secret_key_der]u8 = undefined; const unix = "From: Alice \n" ++ "Subject: unix line endings\n" ++ "\n" ++ "body text\n"; _ = try roundTrip(unix, try defaultOptions(&der), .{}); } test "a folded header field round-trips" { var der: [rsa.max_secret_key_der]u8 = undefined; const folded = "From: Alice \r\n" ++ "Subject: this subject is long enough that it has\r\n" ++ "\tbeen folded across three lines\r\n" ++ " and here is the third\r\n" ++ "\r\n" ++ "body\r\n"; _ = try roundTrip(folded, try defaultOptions(&der), .{}); } test "changing the body breaks the signature" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, try defaultOptions(&der)); var damaged: [16384]u8 = undefined; @memcpy(damaged[0..signed.len], signed); // "Hello, Bob." becomes "Hello, Eve." const at = std.mem.indexOf(u8, damaged[0..signed.len], "Bob.").?; @memcpy(damaged[at..][0..4], "Eve."); try testing.expectError( error.BodyHashMismatch, verifyOnly(damaged[0..signed.len], rsa2048_record, .{}), ); } test "changing a signed header breaks the signature" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, try defaultOptions(&der)); var damaged: [16384]u8 = undefined; @memcpy(damaged[0..signed.len], signed); const at = std.mem.indexOf(u8, damaged[0..signed.len], "Subject: A test").?; @memcpy(damaged[at..][0.."Subject: A best".len], "Subject: A best"); // The body is untouched, so this gets past the body hash and fails at the // signature -- which is the check the two errors distinguish. try testing.expectError( error.SignatureMismatch, verifyOnly(damaged[0..signed.len], rsa2048_record, .{}), ); } test "changing an unsigned header does not break the signature" { var der: [rsa.max_secret_key_der]u8 = undefined; var options = try defaultOptions(&der); options.headers = &.{ "from", "subject" }; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, options); // A relay adding a Received field at the top is the ordinary case, and // the entire reason DKIM signs a named list rather than everything. var relayed: [16384]u8 = undefined; const added = "Received: from x.example by y.example; Fri, 12 Sep 2026 12:00:01 -0500\r\n"; @memcpy(relayed[0..added.len], added); @memcpy(relayed[added.len..][0..signed.len], signed); _ = try verifyOnly(relayed[0 .. added.len + signed.len], rsa2048_record, .{}); } test "tampering with the signature's own tags breaks it" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, try defaultOptions(&der)); // Moving the signature to another domain, which is the attack the // signature covering its own field exists to stop. var damaged: [16384]u8 = undefined; @memcpy(damaged[0..signed.len], signed); const at = std.mem.indexOf(u8, damaged[0..signed.len], "d=example.com").?; @memcpy(damaged[at..][0.."d=example.org".len], "d=example.org"); try testing.expectError( error.SignatureMismatch, verifyOnly(damaged[0..signed.len], rsa2048_record, .{}), ); // And extending the expiry. var options = try defaultOptions(&der); options.expiration = 1757000100; var buf2: [16384]u8 = undefined; const dated = try signMessage(&buf2, plain_message, options); var damaged2: [16384]u8 = undefined; @memcpy(damaged2[0..dated.len], dated); const x = std.mem.indexOf(u8, damaged2[0..dated.len], "x=1757000100").?; @memcpy(damaged2[x..][0.."x=1957000100".len], "x=1957000100"); try testing.expectError( error.SignatureMismatch, verifyOnly(damaged2[0..dated.len], rsa2048_record, .{}), ); } test "signing the absence of a header stops one being added" { var der: [rsa.max_secret_key_der]u8 = undefined; var options = try defaultOptions(&der); // Listing `subject` twice when there is one Subject field signs "there is // exactly one Subject here". A second one added in transit is then // covered by the signature and breaks it. options.headers = &.{ "from", "subject", "subject" }; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, options); _ = try verifyOnly(signed, rsa2048_record, .{}); var attacked: [16384]u8 = undefined; const added = "Subject: Please wire the money\r\n"; @memcpy(attacked[0..added.len], added); @memcpy(attacked[added.len..][0..signed.len], signed); try testing.expectError( error.SignatureMismatch, verifyOnly(attacked[0 .. added.len + signed.len], rsa2048_record, .{}), ); } test "the l= tag covers a prefix of the body and says so" { var der: [rsa.max_secret_key_der]u8 = undefined; var options = try defaultOptions(&der); options.body_length = 13; // "Hello, Bob.\r\n" var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, options); const result = try verifyOnly(signed, rsa2048_record, .{}); try testing.expect(result.isPartiallyCovered()); try testing.expectEqual(@as(u64, 13), result.covered); // Appending to the body does not break it, which is the whole hazard. var appended: [16384]u8 = undefined; @memcpy(appended[0..signed.len], signed); const extra = "And an advertisement nobody signed.\r\n"; @memcpy(appended[signed.len..][0..extra.len], extra); const still = try verifyOnly(appended[0 .. signed.len + extra.len], rsa2048_record, .{}); try testing.expect(still.isPartiallyCovered()); } test "an l= larger than the body is refused" { var der: [rsa.max_secret_key_der]u8 = undefined; var options = try defaultOptions(&der); options.body_length = 1_000_000; var buf: [16384]u8 = undefined; try testing.expectError( error.BodyLengthTooLong, signMessage(&buf, plain_message, options), ); // ...and a signature claiming one is rejected by the verifier too, since // a signer elsewhere may not have checked. options.body_length = 13; const signed = try signMessage(&buf, plain_message, options); var shortened: [16384]u8 = undefined; const cut = std.mem.indexOf(u8, signed, "Hello").? + 5; @memcpy(shortened[0..cut], signed[0..cut]); try testing.expectError( error.BodyLengthTooLong, verifyOnly(shortened[0..cut], rsa2048_record, .{}), ); } test "an expired signature is refused, and only when the clock says so" { var der: [rsa.max_secret_key_der]u8 = undefined; var options = try defaultOptions(&der); options.timestamp = 1757000000; options.expiration = 1757003600; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, options); _ = try verifyOnly(signed, rsa2048_record, .{ .now = 1757003600 }); try testing.expectError( error.SignatureExpired, verifyOnly(signed, rsa2048_record, .{ .now = 1757003601 }), ); // With no clock given, expiry is not checked at all. _ = try verifyOnly(signed, rsa2048_record, .{ .now = null }); } test "a revoked key fails the signature" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, try defaultOptions(&der)); try testing.expectError( error.KeyRevoked, verifyOnly(signed, "v=DKIM1; k=rsa; p=", .{}), ); } test "a key record that forbids the hash, the service or the key type" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, try defaultOptions(&der)); try testing.expectError(error.HashNotAcceptable, verifyOnly( signed, "v=DKIM1; h=sha1; p=" ++ keys.rsa2048_public, .{}, )); try testing.expectError(error.ServiceNotAcceptable, verifyOnly( signed, "v=DKIM1; s=tlsa; p=" ++ keys.rsa2048_public, .{}, )); try testing.expectError(error.KeyTypeMismatch, verifyOnly( signed, "v=DKIM1; k=ed25519; p=" ++ keys.rsa2048_public, .{}, )); // ...and one that permits them explicitly. _ = try verifyOnly(signed, "v=DKIM1; k=rsa; h=sha256; s=email; p=" ++ keys.rsa2048_public, .{}); } test "a testing key verifies and says it is testing" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, try defaultOptions(&der)); const result = try verifyOnly(signed, "v=DKIM1; t=y; p=" ++ keys.rsa2048_public, .{}); try testing.expect(result.testing_key); } test "t=s requires i= to be the domain exactly" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [16384]u8 = undefined; const strict = "v=DKIM1; t=s; p=" ++ keys.rsa2048_public; // No i= at all: the flag says nothing about this signature. const plain = try signMessage(&buf, plain_message, try defaultOptions(&der)); _ = try verifyOnly(plain, strict, .{}); var options = try defaultOptions(&der); options.identity = "alice@example.com"; var buf2: [16384]u8 = undefined; const exact = try signMessage(&buf2, plain_message, options); _ = try verifyOnly(exact, strict, .{}); options.identity = "alice@mail.example.com"; var buf3: [16384]u8 = undefined; const sub = try signMessage(&buf3, plain_message, options); _ = try verifyOnly(sub, rsa2048_record, .{}); // fine without the flag try testing.expectError(error.IdentityNotStrict, verifyOnly(sub, strict, .{})); } test "a key below the minimum size is refused" { var der: [rsa.max_secret_key_der]u8 = undefined; const key = try rsa.SecretKey.fromPem(&der, keys.rsa1024_private); var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, .{ .domain = "example.com", .selector = "test", .key = .{ .rsa = key }, .timestamp = 1757000000, }); // 1024 is the floor RFC 8301 sets, so it passes by default... _ = try verifyOnly(signed, rsa1024_record, .{}); // ...and a verifier asking for 2048 rejects it. try testing.expectError( error.KeyTooSmall, verifyOnly(signed, rsa1024_record, .{ .min_rsa_bits = 2048 }), ); } test "Ed25519 signs and verifies" { // RFC 8463. The key is 32 bytes, the record carries it raw rather than as // DER, and the signature is 64 bytes -- all of which is a different path // through the code from RSA. const seed: [32]u8 = @splat(7); const pair = try Ed25519.KeyPair.generateDeterministic(seed); var buf: [16384]u8 = undefined; const signed = try signMessage(&buf, plain_message, .{ .domain = "example.com", .selector = "ed", .key = .{ .ed25519 = pair }, .timestamp = 1757000000, }); try testing.expect(std.mem.indexOf(u8, signed, "a=ed25519-sha256") != null); var public_b64: [64]u8 = undefined; const encoded = std.base64.standard.Encoder.encode(&public_b64, &pair.public_key.toBytes()); var txt: [256]u8 = undefined; const record_txt = try std.fmt.bufPrint(&txt, "v=DKIM1; k=ed25519; p={s}", .{encoded}); const result = try verifyOnly(signed, record_txt, .{}); try testing.expectEqual(dkim.Signature.Algorithm.ed25519_sha256, result.algorithm); // The wrong key must not verify. const other = try Ed25519.KeyPair.generateDeterministic(@splat(8)); const other_encoded = std.base64.standard.Encoder.encode(&public_b64, &other.public_key.toBytes()); const other_txt = try std.fmt.bufPrint(&txt, "v=DKIM1; k=ed25519; p={s}", .{other_encoded}); try testing.expectError(error.SignatureMismatch, verifyOnly(signed, other_txt, .{})); } test "rsa-sha1 is refused unless asked for" { // Nothing here can produce one -- `Key.algorithm` only ever says // sha256 -- so this is built by hand, which is also how one arrives in // real life. const signed = "DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; d=example.com;\r\n" ++ "\ts=test; bh=2jmj7l5rSw0yVb/vlWAYkK/YBwk=; h=from; b=AAAA\r\n" ++ "From: a@example.com\r\n" ++ "\r\n"; try testing.expectError( error.AlgorithmForbidden, verifyOnly(signed, rsa2048_record, .{}), ); // Allowed, it gets far enough to fail on the signature itself. try testing.expectError( error.SignatureMismatch, verifyOnly(signed, rsa2048_record, .{ .allow_rsa_sha1 = true, .min_rsa_bits = 0 }), ); } test "a signer must sign From" { var der: [rsa.max_secret_key_der]u8 = undefined; var options = try defaultOptions(&der); options.headers = &.{ "to", "subject" }; var buf: [16384]u8 = undefined; try testing.expectError(error.FromNotSigned, signMessage(&buf, plain_message, options)); } test "several signatures on one message are each verifiable" { var der: [rsa.max_secret_key_der]u8 = undefined; var first_buf: [16384]u8 = undefined; const once = try signMessage(&first_buf, plain_message, try defaultOptions(&der)); var options = try defaultOptions(&der); options.selector = "second"; options.canonicalization = .{ .header = .simple, .body = .simple }; var twice_buf: [16384]u8 = undefined; const twice = try signMessage(&twice_buf, once, options); var count: usize = 0; var it = dkim.verification.signatures(twice); const parsed = try dkim.Record.parse(rsa2048_record); while (it.next()) |result| { const signature = try result; _ = try dkim.verification.verify(twice, signature, parsed, .{}); count += 1; } try testing.expectEqual(@as(usize, 2), count); } test "the buffer the field is written into can be too small" { var der: [rsa.max_secret_key_der]u8 = undefined; var tiny: [64]u8 = undefined; try testing.expectError( error.BufferTooSmall, dkim.signing.sign(&tiny, plain_message, try defaultOptions(&der)), ); } test "the produced field folds to something a mail system will not refold" { var der: [rsa.max_secret_key_der]u8 = undefined; var buf: [dkim.signing.max_field_len]u8 = undefined; const field = try dkim.signing.sign(&buf, plain_message, try defaultOptions(&der)); var lines: dkim.message.LineIterator = .init(field); while (lines.next()) |line| { // RFC 5322 recommends 78 including the CRLF, and requires under 998. try testing.expect(line.content.len <= 78); } try testing.expect(std.mem.endsWith(u8, field, "\r\n")); try testing.expect(std.mem.startsWith(u8, field, "DKIM-Signature: ")); } test "a signature under another field name, which is what ARC needs" { // RFC 8617's `ARC-Message-Signature` is a DKIM signature under a // different name and without a version tag. Nothing in the // algorithm looks at the name beyond hashing it, so a round trip // here is what proves that -- the signer hashed the name it wrote // and the verifier hashes the name it reads. var der_buf: [rsa.max_secret_key_der]u8 = undefined; const key = try secretKey(&der_buf); var out: [dkim.signing.max_field_len]u8 = undefined; const field = try dkim.sign(&out, plain_message, .{ .domain = "example.com", .selector = "test", .key = .{ .rsa = key }, .field_name = "ARC-Message-Signature", .version = false, }); try testing.expect(std.mem.startsWith(u8, field, "ARC-Message-Signature:")); try testing.expect(!std.mem.containsAtLeast(u8, field, 1, "v=1;")); const signed = try std.fmt.allocPrint(testing.allocator, "{s}{s}", .{ field, plain_message, }); defer testing.allocator.free(signed); var headers: dkim.message.HeaderIterator = .init(dkim.message.split(signed).headers); const parsed = try dkim.Signature.parseWithOptions( headers.next().?, .{ .require_version = false }, ); const record = try dkim.Record.parse("v=DKIM1; k=rsa; p=" ++ keys.rsa2048_public); const verified = try dkim.verify(signed, parsed, record, .{}); try testing.expectEqualStrings("example.com", verified.domain); } test "tags a signer does not define, which is what ARC needs" { // An `ARC-Message-Signature` carries an `i=` tag. Inserting one // after signing would invalidate the hash just computed over the // field, so the caller supplies the text and the signature covers // it -- the only order that can work. var der_buf: [rsa.max_secret_key_der]u8 = undefined; const key = try secretKey(&der_buf); var out: [dkim.signing.max_field_len]u8 = undefined; const field = try dkim.sign(&out, plain_message, .{ .domain = "example.com", .selector = "test", .key = .{ .rsa = key }, .field_name = "ARC-Message-Signature", .version = false, .extra_tags = "i=3;", }); try testing.expect(std.mem.containsAtLeast(u8, field, 1, "i=3;")); const signed = try std.fmt.allocPrint(testing.allocator, "{s}{s}", .{ field, plain_message }); defer testing.allocator.free(signed); var headers: dkim.message.HeaderIterator = .init(dkim.message.split(signed).headers); const parsed = try dkim.Signature.parseWithOptions( headers.next().?, .{ .require_version = false, .identity_tag = false }, ); const record = try dkim.Record.parse("v=DKIM1; k=rsa; p=" ++ keys.rsa2048_public); // The tag is inside the signed field, so a verifier that reads it // back gets the same bytes the signer hashed -- which is the whole // claim being tested. _ = try dkim.verify(signed, parsed, record, .{}); } test "signing and verifying a digest directly" { // The two entry points ARC's seal needs: it hashes the ARC header // field values in a fixed order rather than the set an `h=` names, // and then signs and checks the result exactly the way DKIM does. var der_buf: [rsa.max_secret_key_der]u8 = undefined; const key = try secretKey(&der_buf); const Sha256 = std.crypto.hash.sha2.Sha256; var hasher = Sha256.init(.{}); hasher.update("arc-seal:i=1; cv=none; b="); var made: dkim.digest.Digest = .{ .bytes = @splat(0), .len = Sha256.digest_length }; hasher.final(made.bytes[0..Sha256.digest_length]); var signature_buf: [1024]u8 = undefined; const b64 = try dkim.signDigest(&signature_buf, .rsa_sha256, made, .{ .rsa = key }); var raw_buf: [512]u8 = undefined; const decoder = std.base64.standard.Decoder; const raw_len = try decoder.calcSizeForSlice(b64); try decoder.decode(raw_buf[0..raw_len], b64); const record = try dkim.Record.parse("v=DKIM1; k=rsa; p=" ++ keys.rsa2048_public); var key_buf: [2048]u8 = undefined; const key_bytes = try record.decodeKey(&key_buf); try dkim.verifyDigest(.rsa_sha256, made, raw_buf[0..raw_len], key_bytes, .{}); // And a digest of something else does not check out, so the test // above is about the signature rather than about the call returning. var other = made; other.bytes[0] ^= 0xff; try testing.expectError( error.SignatureMismatch, dkim.verifyDigest(.rsa_sha256, other, raw_buf[0..raw_len], key_bytes, .{}), ); }