From 4beced19c543589db552f66b7dc902ecb4bdd8fa Mon Sep 17 00:00:00 2001 From: Dan Mahoney Date: Tue, 21 Jul 2026 13:41:11 -0700 Subject: [PATCH 1/2] opendkim: add AddCanonicalizedData option for RFC 9991 forensic reporting Adds an opt-in AddCanonicalizedData directive that emits X-DKIM-Canonicalized-Header/-Body milter headers, tagged with the signature's d=/s= values, for every verified signature. Reuses the canonicalization tmp-file capture and base64 encoding already built for the unrelated SendReports feature, so a downstream DMARC filter (e.g. OpenDMARC) can populate the RFC 6591 ARF fields required by RFC 9991 forensic reports without verifying DKIM itself. --- CHANGES-202605.md | 1 + opendkim/opendkim-config.h | 1 + opendkim/opendkim.c | 180 ++++++++++++++++++++++++- opendkim/opendkim.conf.5.in | 20 +++ opendkim/opendkim.conf.sample | 12 ++ opendkim/tests/Makefile.am | 3 +- opendkim/tests/t-verify-canondata | 11 ++ opendkim/tests/t-verify-canondata.conf | 7 + opendkim/tests/t-verify-canondata.lua | 158 ++++++++++++++++++++++ 9 files changed, 391 insertions(+), 2 deletions(-) create mode 100755 opendkim/tests/t-verify-canondata create mode 100644 opendkim/tests/t-verify-canondata.conf create mode 100644 opendkim/tests/t-verify-canondata.lua diff --git a/CHANGES-202605.md b/CHANGES-202605.md index b711b655..461f632b 100644 --- a/CHANGES-202605.md +++ b/CHANGES-202605.md @@ -105,6 +105,7 @@ A systematic audit of memory and resource leaks (issue #272) produced fixes acro - **Inline IPv6 CIDR entries in dataset lists**: Bracketed IPv6 addresses (e.g. `[fd00:368::]/40`) can now be used directly in inline dataset values for `PeerList`, `InternalHosts`, and similar options, without requiring a file reference. The colons in an IPv6 address were previously misidentified as a `type:` prefix. (#368, issue #319) - **MySQL DSN special characters in passwords**: `=XY` escape sequences in DSN credential fields were never decoded. A typo also caused lowercase hex digits `a`-`e` to decode incorrectly. Both are fixed; passwords containing `=`, `%`, and other special characters now round-trip correctly through the DSN parser. (#369, issue #248) - **Auto-detect `SignatureAlgorithm` from key type**: When `SignatureAlgorithm` is not explicitly set in the config, opendkim now inspects the loaded `KeyFile` and automatically selects `ed25519-sha256` for ed25519 keys. RSA keys continue to default to `rsa-sha256`. This eliminates the previously undocumented requirement to add `SignatureAlgorithm ed25519-sha256` alongside an ed25519 `KeyFile`. (#370, issue #107) +- **`AddCanonicalizedData` option**: New opt-in directive (default off) that adds `X-DKIM-Canonicalized-Header`/`X-DKIM-Canonicalized-Body` fields, base64-encoded and tagged with the signature's `d=`/`s=` values, for every verified signature regardless of pass/fail. This exposes canonicalized bytes OpenDKIM already computes during verification -- previously only surfaced via the unrelated `SendReports` feature on failure -- so a downstream DMARC filter (e.g. OpenDMARC) can populate the `DKIM-Canonicalized-Header`/`-Body` RFC 6591 ARF fields required by RFC 9991 forensic reports, without needing to verify DKIM itself. Turning it on implies the same in-memory temporary-file usage as `SendReports`/`KeepTemporaryFiles`, but never persists files to disk. --- diff --git a/opendkim/opendkim-config.h b/opendkim/opendkim-config.h index 79ca264d..0345f7d5 100644 --- a/opendkim/opendkim-config.h +++ b/opendkim/opendkim-config.h @@ -24,6 +24,7 @@ /* config definition */ struct configdef dkimf_config[] = { + { "AddCanonicalizedData", CONFIG_TYPE_BOOLEAN, FALSE }, { "AllowSHA1Only", CONFIG_TYPE_BOOLEAN, FALSE }, { "AlwaysAddARHeader", CONFIG_TYPE_BOOLEAN, FALSE }, #ifdef _FFR_ATPS diff --git a/opendkim/opendkim.c b/opendkim/opendkim.c index 2b59d3ac..a6694c2b 100644 --- a/opendkim/opendkim.c +++ b/opendkim/opendkim.c @@ -236,6 +236,7 @@ struct dkimf_config _Bool conf_alwaysaddar; /* always add Auth-Results:? */ _Bool conf_reqreports; /* request reports */ _Bool conf_sendreports; /* signature failure reports */ + _Bool conf_addcanondata; /* add canonicalized data headers */ _Bool conf_reqhdrs; /* required header checks */ _Bool conf_authservidwithjobid; /* use jobids in A-R headers */ _Bool conf_subdomains; /* sign subdomains */ @@ -740,6 +741,7 @@ void dkimf_sendprogress (const void *); sfsistat dkimf_setpriv (SMFICTX *, void *); sfsistat dkimf_setreply (SMFICTX *, char *, char *, char *); static void dkimf_sigreport (connctx, struct dkimf_config *, char *); +static void dkimf_add_canon_headers (connctx, struct dkimf_config *, SMFICTX *); static void dkimf_log(struct dkimf_config *conf, int priority, const char *format, ...); /* GLOBALS */ @@ -4200,6 +4202,169 @@ dkimf_add_ar_fields(struct msgctx *dfc, struct dkimf_config *conf, } } +/* +** DKIMF_CANON_HEADER_VALUE -- base64-encode a canonicalization tmp file +** into a malloc'd, NUL-terminated, folded +** header value +** +** Parameters: +** fd -- descriptor of the canonicalization tmp file (header or body) +** prefix -- tag prefix ("d=...; s=...; b=") to prepend on the same +** line before the base64 data starts +** +** Return value: +** A malloc'd C string ready to hand to dkimf_insheader(), or NULL on +** failure. Caller must free() the result. +*/ + +static char * +dkimf_canon_header_value(int fd, const char *prefix) +{ + long len; + char *buf; + FILE *tmp; + + if (fd < 0) + return NULL; + + tmp = tmpfile(); + if (tmp == NULL) + return NULL; + + fputs(prefix, tmp); + + dkimf_base64_encode_file(fd, tmp, 8, DKIM_HDRMARGIN, + (int) strlen(prefix)); + + len = ftell(tmp); + if (len <= 0) + { + fclose(tmp); + return NULL; + } + + buf = (char *) malloc((size_t) len + 1); + if (buf == NULL) + { + fclose(tmp); + return NULL; + } + + rewind(tmp); + if (fread(buf, 1, (size_t) len, tmp) != (size_t) len) + { + free(buf); + fclose(tmp); + return NULL; + } + buf[len] = '\0'; + + fclose(tmp); + + return buf; +} + +/* +** DKIMF_ADD_CANON_HEADERS -- add canonicalized header/body data as milter +** headers, for downstream DMARC forensic +** reporting (RFC 9991 / RFC 6591) +** +** Parameters: +** cc -- connection context +** conf -- configuration handle +** ctx -- milter context +** +** Return value: +** None. +** +** Notes: +** The canonicalized bytes come from the same tmp files the +** SendReports feature uses (see dkimf_sigreport()); they're +** populated for every verified signature regardless of whether it +** requested reporting ("r=y") or ultimately passed. This emits one +** X-DKIM-Canonicalized-Header/-Body pair per signature, tagged with +** its domain/selector so a downstream consumer (e.g. OpenDMARC) can +** match them against whichever signature it cares about without +** needing a numeric index. +*/ + +static void +dkimf_add_canon_headers(connctx cc, struct dkimf_config *conf, SMFICTX *ctx) +{ + int c; + int nsigs = 0; + msgctx dfc; + DKIM_SIGINFO **sigs = NULL; + + assert(cc != NULL); + assert(conf != NULL); + assert(ctx != NULL); + + dfc = cc->cctx_msg; + + assert(dfc->mctx_dkimv != NULL); + + if (dkim_getsiglist(dfc->mctx_dkimv, &sigs, &nsigs) != DKIM_STAT_OK) + return; + + for (c = 0; c < nsigs; c++) + { + int bfd = -1; + int hfd = -1; + char *domain; + char *selector; + char prefix[BUFRSZ]; + char *hval; + + if (dkim_sig_getreportinfo(dfc->mctx_dkimv, sigs[c], + &hfd, &bfd, + NULL, 0, NULL, 0, + NULL, 0, NULL) != DKIM_STAT_OK) + continue; + + domain = (char *) dkim_sig_getdomain(sigs[c]); + selector = (char *) dkim_sig_getselector(sigs[c]); + + snprintf(prefix, sizeof prefix, "d=%s; s=%s; b=", + domain != NULL ? domain : "", + selector != NULL ? selector : ""); + + if (hfd != -1) + { + hval = dkimf_canon_header_value(hfd, prefix); + if (hval != NULL) + { + if (dkimf_insheader(ctx, 0, + "X-DKIM-Canonicalized-Header", + hval) == MI_FAILURE) + { + dkimf_log(conf, LOG_ERR, + "%s: X-DKIM-Canonicalized-Header header add failed", + dfc->mctx_jobid); + } + free(hval); + } + } + + if (bfd != -1) + { + hval = dkimf_canon_header_value(bfd, prefix); + if (hval != NULL) + { + if (dkimf_insheader(ctx, 0, + "X-DKIM-Canonicalized-Body", + hval) == MI_FAILURE) + { + dkimf_log(conf, LOG_ERR, + "%s: X-DKIM-Canonicalized-Body header add failed", + dfc->mctx_jobid); + } + free(hval); + } + } + } +} + /* ** DKIMF_DB_ERROR -- syslog errors related to db retrieval ** @@ -6521,6 +6686,13 @@ dkimf_config_load(struct config *data, struct dkimf_config *conf, &conf->conf_sendreports, sizeof conf->conf_sendreports); } + + if (!conf->conf_addcanondata) + { + (void) config_get(data, "AddCanonicalizedData", + &conf->conf_addcanondata, + sizeof conf->conf_addcanondata); + } (void) config_get(data, "MTACommand", &conf->conf_mtacommand, sizeof conf->conf_mtacommand); @@ -8829,6 +9001,7 @@ dkimf_config_setlib(struct dkimf_config *conf, char **err) } if (conf->conf_sendreports || conf->conf_keeptmpfiles || + conf->conf_addcanondata || conf->conf_stricthdrs || conf->conf_blen || conf->conf_ztags || conf->conf_fixcrlf) { @@ -8844,7 +9017,8 @@ dkimf_config_setlib(struct dkimf_config *conf, char **err) return FALSE; } - if (conf->conf_sendreports || conf->conf_keeptmpfiles) + if (conf->conf_sendreports || conf->conf_keeptmpfiles || + conf->conf_addcanondata) opts |= DKIM_LIBFLAGS_TMPFILES; if (conf->conf_keeptmpfiles) opts |= DKIM_LIBFLAGS_KEEPFILES; @@ -15102,6 +15276,10 @@ mlfi_eom(SMFICTX *ctx) conf->conf_sendreports) dkimf_sigreport(cc, conf, hostname); + /* expose canonicalized data for downstream DMARC reporting? */ + if (conf->conf_addcanondata) + dkimf_add_canon_headers(cc, conf, ctx); + #ifdef _FFR_VBR if (dkimf_valid_vbr(dfc)) { diff --git a/opendkim/opendkim.conf.5.in b/opendkim/opendkim.conf.5.in index 0a3e5a73..7001dbb8 100644 --- a/opendkim/opendkim.conf.5.in +++ b/opendkim/opendkim.conf.5.in @@ -48,6 +48,26 @@ Unless otherwise stated, Boolean values default to "false", integer values default to 0, and string and dataset values default to being undefined. .SH PARAMETERS +.TP +.I AddCanonicalizedData (Boolean) +If true, for every verified signature, adds +.I X-DKIM-Canonicalized-Header +and +.I X-DKIM-Canonicalized-Body +header fields containing the base64-encoded canonicalized header block and +body used to verify the signature, tagged with the signature's "d=" and +"s=" values. This is independent of the signature's own "r=" reporting +request and of whether it ultimately passed; it is intended for use by a +downstream DMARC filter needing the raw bytes for RFC6591 forensic report +fields defined by RFC9991, since this filter does not otherwise expose +them. Turning this on implies the same in-memory temporary file usage as +.I SendReports +and +.I KeepTemporaryFiles, +but never persists files to disk the way +.I KeepTemporaryFiles +does. Default "false". + .TP .I AllowSHA1Only (Boolean) Permit verify mode when only SHA1 support is available. RFC6376 requires diff --git a/opendkim/opendkim.conf.sample b/opendkim/opendkim.conf.sample index 62edd6a1..7c8d55ef 100644 --- a/opendkim/opendkim.conf.sample +++ b/opendkim/opendkim.conf.sample @@ -29,6 +29,18 @@ ## CONFIGURATION OPTIONS +## AddCanonicalizedData { yes | no } +## default "no" +## +## If set, adds "X-DKIM-Canonicalized-Header" and "X-DKIM-Canonicalized-Body" +## headers for every verified signature, containing the base64-encoded +## canonicalized header block and body used to verify it, tagged with the +## signature's "d=" and "s=" values. Intended for use by a downstream +## DMARC filter that needs these bytes for RFC9991 forensic report fields. +## See opendkim.conf(5) for details. + +# AddCanonicalizedData no + ## AllowSHA1Only { yes | no } ## default "no" ## diff --git a/opendkim/tests/Makefile.am b/opendkim/tests/Makefile.am index a34e2de1..c2b1532b 100644 --- a/opendkim/tests/Makefile.am +++ b/opendkim/tests/Makefile.am @@ -16,7 +16,7 @@ check_SCRIPTS = t-sign-ss t-sign-rs t-sign-rs-tables t-sign-rs-tables-bad \ t-verify-ss-ar-bad t-verify-ss-ar-admd-less \ t-dontsign t-peer \ t-lua-verify-tests t-sign-ss-macro t-sign-ss-macro-value \ - t-sign-ss-macro-value-file t-verify-report \ + t-sign-ss-macro-value-file t-verify-report t-verify-canondata \ t-sign-report t-conf-check t-verify-double-from if LIVE_TESTS @@ -94,6 +94,7 @@ EXTRA_DIST = \ t-peer t-peer.conf t-peer.list t-peer.lua \ t-verify-report t-verify-report.conf t-verify-report.txt \ t-verify-report.lua \ + t-verify-canondata t-verify-canondata.conf t-verify-canondata.lua \ t-sign-atps t-sign-atps.conf t-sign-atps.lua \ t-verify-ss-atps t-verify-ss-atps.conf t-verify-ss-atps.lua \ t-conf-check t-conf-check.conf t-conf-check.keytable t-conf-check.lua \ diff --git a/opendkim/tests/t-verify-canondata b/opendkim/tests/t-verify-canondata new file mode 100755 index 00000000..821690c7 --- /dev/null +++ b/opendkim/tests/t-verify-canondata @@ -0,0 +1,11 @@ +#!/bin/sh +# +# +# simple/simple verifying test with AddCanonicalizedData (should pass) + +if [ x"$srcdir" = x"" ] +then + srcdir=`pwd` +fi + +../../miltertest/miltertest $MILTERTESTFLAGS -s $srcdir/t-verify-canondata.lua diff --git a/opendkim/tests/t-verify-canondata.conf b/opendkim/tests/t-verify-canondata.conf new file mode 100644 index 00000000..ebf55bdf --- /dev/null +++ b/opendkim/tests/t-verify-canondata.conf @@ -0,0 +1,7 @@ +# simple/simple verifying test with AddCanonicalizedData (should pass) + +TestPublicKeys pubkeys +Mode v +On-BadSignature reject +Background No +AddCanonicalizedData yes diff --git a/opendkim/tests/t-verify-canondata.lua b/opendkim/tests/t-verify-canondata.lua new file mode 100644 index 00000000..f66b2a67 --- /dev/null +++ b/opendkim/tests/t-verify-canondata.lua @@ -0,0 +1,158 @@ +-- Copyright (c) 2026, The Trusted Domain Project. All rights reserved. + +-- simple/simple verify test with AddCanonicalizedData (should pass) +-- +-- Confirms that a valid, verified signature gets X-DKIM-Canonicalized-Header +-- and X-DKIM-Canonicalized-Body fields added, tagged with the signature's +-- domain and selector, containing base64 of the exact bytes used to verify +-- the signature. + +mt.echo("*** simple/simple verifying test with AddCanonicalizedData (good)") + +-- setup +if TESTSOCKET ~= nil then + sock = TESTSOCKET +else + sock = "unix:" .. mt.getcwd() .. "/t-verify-canondata.sock" +end +binpath = mt.getcwd() .. "/.." +if os.getenv("srcdir") ~= nil then + mt.chdir(os.getenv("srcdir")) +end + +-- try to start the filter +mt.startfilter(binpath .. "/opendkim", "-x", "t-verify-canondata.conf", + "-p", sock) + +-- try to connect to it +conn = mt.connect(sock, 40, 0.25) +if conn == nil then + error("mt.connect() failed") +end + +-- send connection information +-- mt.negotiate() is called implicitly +if mt.conninfo(conn, "localhost", "127.0.0.1") ~= nil then + error("mt.conninfo() failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.conninfo() unexpected reply") +end + +-- send envelope macros and sender data +-- mt.helo() is called implicitly +mt.macro(conn, SMFIC_MAIL, "i", "t-verify-canondata") +if mt.mailfrom(conn, "user@example.com") ~= nil then + error("mt.mailfrom() failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.mailfrom() unexpected reply") +end + +-- send headers +-- mt.rcptto() is called implicitly +if mt.header(conn, "DKIM-Signature", "v=1; a=rsa-sha256; c=simple/simple; d=example.com; s=test;\r\n\tt=1296710324; bh=3VWGQGY+cSNYd1MGM+X6hRXU0stl8JCaQtl4mbX/j2I=;\r\n\th=From:Date:Subject;\r\n\tb=RNAhx6cV5AeZWJDEJG1hROdvCukhJnokhI9oABHwAyUAzC6MDntoH4PrS2jS7HGw2\r\n\t D7pU4yLGrlNsGlK8JvqizYNHl+v9+B6OnWAgzkgTimWTqBCYwo8X01N6hqoXDAm8hC\r\n\t RUpmeJvC84K5/nHHLASCb4W1PC2R4VkxUoyVnlYE=") ~=nil then + error("mt.header(DKIM-Signature) failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.header(DKIM-Signature) unexpected reply") +end +if mt.header(conn, "From", "user@example.com") ~= nil then + error("mt.header(From) failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.header(From) unexpected reply") +end +if mt.header(conn, "Date", "Tue, 22 Dec 2009 13:04:12 -0800") ~= nil then + error("mt.header(Date) failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.header(Date) unexpected reply") +end +if mt.header(conn, "Subject", "Signing test") ~= nil then + error("mt.header(Subject) failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.header(Subject) unexpected reply") +end + +-- send EOH +if mt.eoh(conn) ~= nil then + error("mt.eoh() failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.eoh() unexpected reply") +end + +-- send body +if mt.bodystring(conn, "This is a test!\r\n") ~= nil then + error("mt.bodystring() failed") +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error("mt.bodystring() unexpected reply") +end + +-- end of message; let the filter react +if mt.eom(conn) ~= nil then + error("mt.eom() failed") +end +if mt.getreply(conn) ~= SMFIR_ACCEPT then + error("mt.eom() unexpected reply") +end + +-- verify that an Authentication-Results header field got added, and it +-- reports a pass, confirming this signature verified successfully (i.e. +-- the canonicalized-data headers below are being added for a *passing* +-- signature, not merely on failure) +if not mt.eom_check(conn, MT_HDRINSERT, "Authentication-Results") and + not mt.eom_check(conn, MT_HDRADD, "Authentication-Results") then + error("no Authentication-Results added") +end + +n = 0 +found = 0 +while true do + ar = mt.getheader(conn, "Authentication-Results", n) + if ar == nil then + break + end + if string.find(ar, "dkim=pass", 1, true) ~= nil then + found = 1 + break + end + n = n + 1 +end +if found == 0 then + error("incorrect DKIM result") +end + +-- verify that X-DKIM-Canonicalized-Header and X-DKIM-Canonicalized-Body +-- got added, tagged with the signature's domain/selector, and containing +-- the expected base64 of the exact canonicalized bytes used to verify it +if not mt.eom_check(conn, MT_HDRINSERT, "X-DKIM-Canonicalized-Header") and + not mt.eom_check(conn, MT_HDRADD, "X-DKIM-Canonicalized-Header") then + error("no X-DKIM-Canonicalized-Header added") +end +if not mt.eom_check(conn, MT_HDRINSERT, "X-DKIM-Canonicalized-Body") and + not mt.eom_check(conn, MT_HDRADD, "X-DKIM-Canonicalized-Body") then + error("no X-DKIM-Canonicalized-Body added") +end + +chdr = mt.getheader(conn, "X-DKIM-Canonicalized-Header", 0) +cbody = mt.getheader(conn, "X-DKIM-Canonicalized-Body", 0) + +expect_chdr = "d=example.com; s=test; b=RnJvbTogdXNlckBleGFtcGxlLmNvbQ0KRGF0ZTogVHVlLCAy\n MiBEZWMgMjAwOSAxMzowNDoxMiAtMDgwMA0KU3ViamVjdDogU2lnbmluZyB0ZXN0\n DQpES0lNLVNpZ25hdHVyZTogdj0xOyBhPXJzYS1zaGEyNTY7IGM9c2ltcGxlL3Np\n bXBsZTsgZD1leGFtcGxlLmNvbTsgcz10ZXN0Ow0KCXQ9MTI5NjcxMDMyNDsgYmg9\n M1ZXR1FHWStjU05ZZDFNR00rWDZoUlhVMHN0bDhKQ2FRdGw0bWJYL2oyST07DQoJ\n aD1Gcm9tOkRhdGU6U3ViamVjdDsNCgliPQ==" +expect_cbody = "d=example.com; s=test; b=VGhpcyBpcyBhIHRlc3QhDQo=" + +if chdr ~= expect_chdr then + print("got: " .. tostring(chdr)) + print("want: " .. expect_chdr) + error("incorrect X-DKIM-Canonicalized-Header value") +end +if cbody ~= expect_cbody then + print("got: " .. tostring(cbody)) + print("want: " .. expect_cbody) + error("incorrect X-DKIM-Canonicalized-Body value") +end + +mt.disconnect(conn) From 0a185fdb1b9ae3a67420eba722bb60bc720bba35 Mon Sep 17 00:00:00 2001 From: Dan Mahoney Date: Tue, 21 Jul 2026 13:41:50 -0700 Subject: [PATCH 2/2] CHANGES-202605.md: reference PR #423 for AddCanonicalizedData --- CHANGES-202605.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES-202605.md b/CHANGES-202605.md index 461f632b..400ec6d3 100644 --- a/CHANGES-202605.md +++ b/CHANGES-202605.md @@ -105,7 +105,7 @@ A systematic audit of memory and resource leaks (issue #272) produced fixes acro - **Inline IPv6 CIDR entries in dataset lists**: Bracketed IPv6 addresses (e.g. `[fd00:368::]/40`) can now be used directly in inline dataset values for `PeerList`, `InternalHosts`, and similar options, without requiring a file reference. The colons in an IPv6 address were previously misidentified as a `type:` prefix. (#368, issue #319) - **MySQL DSN special characters in passwords**: `=XY` escape sequences in DSN credential fields were never decoded. A typo also caused lowercase hex digits `a`-`e` to decode incorrectly. Both are fixed; passwords containing `=`, `%`, and other special characters now round-trip correctly through the DSN parser. (#369, issue #248) - **Auto-detect `SignatureAlgorithm` from key type**: When `SignatureAlgorithm` is not explicitly set in the config, opendkim now inspects the loaded `KeyFile` and automatically selects `ed25519-sha256` for ed25519 keys. RSA keys continue to default to `rsa-sha256`. This eliminates the previously undocumented requirement to add `SignatureAlgorithm ed25519-sha256` alongside an ed25519 `KeyFile`. (#370, issue #107) -- **`AddCanonicalizedData` option**: New opt-in directive (default off) that adds `X-DKIM-Canonicalized-Header`/`X-DKIM-Canonicalized-Body` fields, base64-encoded and tagged with the signature's `d=`/`s=` values, for every verified signature regardless of pass/fail. This exposes canonicalized bytes OpenDKIM already computes during verification -- previously only surfaced via the unrelated `SendReports` feature on failure -- so a downstream DMARC filter (e.g. OpenDMARC) can populate the `DKIM-Canonicalized-Header`/`-Body` RFC 6591 ARF fields required by RFC 9991 forensic reports, without needing to verify DKIM itself. Turning it on implies the same in-memory temporary-file usage as `SendReports`/`KeepTemporaryFiles`, but never persists files to disk. +- **`AddCanonicalizedData` option**: New opt-in directive (default off) that adds `X-DKIM-Canonicalized-Header`/`X-DKIM-Canonicalized-Body` fields, base64-encoded and tagged with the signature's `d=`/`s=` values, for every verified signature regardless of pass/fail. This exposes canonicalized bytes OpenDKIM already computes during verification -- previously only surfaced via the unrelated `SendReports` feature on failure -- so a downstream DMARC filter (e.g. OpenDMARC) can populate the `DKIM-Canonicalized-Header`/`-Body` RFC 6591 ARF fields required by RFC 9991 forensic reports, without needing to verify DKIM itself. Turning it on implies the same in-memory temporary-file usage as `SendReports`/`KeepTemporaryFiles`, but never persists files to disk. (#423) ---