Skip to content

RFC 9991 failure reporting: Identity-Alignment, DKIM-Canonicalized-Header/-Body, ruf= verification/rate-limiting#439

Merged
thegushi merged 9 commits into
trusteddomainproject:developfrom
thegushi:feat/rfc9991-forensic-reporting
Jul 22, 2026
Merged

RFC 9991 failure reporting: Identity-Alignment, DKIM-Canonicalized-Header/-Body, ruf= verification/rate-limiting#439
thegushi merged 9 commits into
trusteddomainproject:developfrom
thegushi:feat/rfc9991-forensic-reporting

Conversation

@thegushi

Copy link
Copy Markdown
Collaborator

Summary

Closes the RFC 9991 (DMARC failure reporting) gap tracked in DMARCBIS-REMAINING-WORK.md:

  • Identity-Alignment and DKIM-Domain/-Identity/-Selector ARF fields (S4): added to the milter-generated failure report when DKIM alignment fails and a signature was seen via a trusted upstream Authentication-Results header.
  • DKIM-Canonicalized-Header/-Body ARF fields (RFC 6591): this codebase never verifies DKIM locally, so a new opt-in ReadCanonicalizedData option instead reads X-DKIM-Canonicalized-Header/-Body staging headers added by an upstream OpenDKIM's new AddCanonicalizedData directive (trusteddomainproject/OpenDKIM#423), correlates them to the blamed signature via d=/s=, and copies the base64 payload into the real fields. The staging headers are always stripped before final delivery regardless of this setting.
  • ruf= external destination verification + rate-limiting (S2/S5/S8.1) in opendmarc-reports --forensic, reusing/generalizing the existing rua= _report._dmarc DNS verification.
  • psd=y excludes ruf= (S2).
  • Also wires RFC 9990's np/testing/discovery_method through aggregate reporting, and a couple of smaller report-skip logging improvements (loud_skip() to syslog, StaleMARC/suppressions-DB skip logging) picked up along the way.

SPF-DNS (the other RFC 6591 field) remains out of scope -- libspf2's public API doesn't expose the DNS record chain it consults, so it needs real library-level work rather than wiring; tracked separately.

Also fixes two unrelated bugs found while working on this:

Test plan

  • Built and ran the full miltertest suite against real libmilter/libspf2: 9/9 pass, including all pre-existing tests (no regressions).
  • Live-tested the restored/generalized DNS destination-verification logic against real DNS.
  • New t-verify-canondata test covers the unconditional header-stripping behavior.

thegushi added 9 commits June 23, 2026 01:05
Previously every skip reason (StaleMARC, the suppressions DB, and
NoReportsList) was only printed under --verbose, so a report silently
skipped via StaleMARC or the suppressions table left no trace in a
normal cron run. NoReportsList stays --verbose-only since it's
operator-curated and the operator already knows what's in it; the
other two are dynamic/external and worth surfacing by default.
Checking the mail log is the natural first move when a report didn't
go out, but a StaleMARC/suppressions-DB skip never reaches the local
MTA, so it never had a footprint there. Open syslog at startup with
facility mail and have log_skip() (replacing the inline print
conditionals) notice() those skips there too, so they land in the
conventional mail log (e.g. /var/log/maillog) instead of only being
visible via stderr/cron-mail. NoReportsList/--skipdomains skips stay
off syslog entirely, consistent with their existing --verbose-only
stderr treatment.
…orting

Adds the three RFC 9990 policy_published fields OpenDMARC never emitted:
np (non-existent-subdomain policy), testing (the RFC 9989 t= tag), and
discovery_method (psl|treewalk, tracking whether a record was found via
PSL-style org-domain lookup or the RFC 9989 DNS tree walk). Bumps the
report namespace to urn:ietf:params:xml:ns:dmarc-2.0 and drops <pct>,
which RFC 9990 removes from the schema entirely -- pct= is still honored
for enforcement, unchanged, it's just no longer reported to recipients.

Threaded through the full pipeline: new libopendmarc accessors
(opendmarc_policy_fetch_np/_discovery_method) and discovery_method
tracking in the walk-mode query functions, historyfile lines in the
milter, opendmarc-import parsing into new requests.npolicy/testing/
discovery_method columns (db/update-db-schema.mysql + schema.mysql), and
conditional XML output in opendmarc-reports matching the schema's
minOccurs="0" on all three.

Verified: full build + 12/12 libopendmarc test suite (including live-DNS
discovery_method assertions against gushi.org) on quark against real
libspf2, since libspf2 isn't available in the local dev sandbox. Both the
full-fields and all-omitted report shapes validate against RFC 9990's own
Appendix A XSD via xmllint.
…ion/rate-limiting, psd=y

Closes the feasible-now portion of the RFC 9991 gap: Identity-Alignment and
DKIM-Domain/-Identity/-Selector ARF header fields (RFC 9991 S4), external
destination verification and rate-limiting for ruf= in opendmarc-reports
--forensic (S2/S5/S8.1), and a psd=y exclusion for ruf= in the milter (S2).
SPF-DNS and DKIM-Canonicalized-Header/-Body remain out of scope -- both need
real library-level work (libspf2 doesn't expose the DNS record chain it
consults; no DKIM verification library is linked anywhere in this codebase),
not just wiring, and are left as follow-ups.

Also fixes a regression found while working on this: PR trusteddomainproject#392's rua=
external-destination verification (_report._dmarc DNS lookup) was silently
wiped by PR trusteddomainproject#394's commit ~5 hours later (built from a pre-trusteddomainproject#392 checkout,
squash-committed over it) despite CHANGES still documenting it as shipped.
Restored, adapted to the six commits' worth of unrelated changes that have
landed in that code since, and generalized to cover ruf= per RFC 9991 S5
(which reuses RFC 9990 S4's procedure verbatim).

Also fixes a real bug surfaced while live-testing the restored verification
against real DNS on quark: Net::DNS's query() returns undef for both
NXDOMAIN and NOERROR/NODATA (a valid domain with no matching record), but
the code only recognized the errorstring "NXDOMAIN" before trying the
wildcard fallback -- anything else, including the literal string "NOERROR",
fell into the fail-open branch meant for genuinely transient conditions
(SERVFAIL, timeout). Confirmed live: a nonexistent record under isc.org
(NXDOMAIN) was correctly rejected, but the same nonexistent record under
Cloudflare-hosted example.net (NOERROR/NODATA) was incorrectly authorized.
NOERROR now gets the same wildcard-fallback-then-reject treatment as
NXDOMAIN.

Verified: full build + 12/12 libopendmarc test suite on quark (real
libspf2), including new opendmarc_policy_fetch_psd() coverage. Perl syntax
clean on the generated opendmarc-reports. verify_external_destination()
exercised directly against live DNS for same-org, NXDOMAIN, and NODATA
cases. Rate-limiting and the full send path are not tested against a live
DB, per the same restriction as the RFC 9990 work -- quark's only opendmarc
database is live production.
Consolidates what's left across RFC 9989/9990/9991 into one file, since
issue trusteddomainproject#371's gap analysis predates most of the work done on this branch
and its sibling (feat/rfc9990-aggregate-reporting) and doesn't get
rewritten as items close. Covers items issue trusteddomainproject#371 already flagged that
we haven't touched (pass disposition value, policy_test_mode reason,
generator/error elements, DKIM signature priority/cap, rf=/ri= cleanup),
plus SPF-DNS and DKIM-Canonicalized-Header/-Body, which are finer-grained
than that issue's analysis and were found while scoping the RFC 9991 work.

Also records the cross-project (OpenDKIM + OpenDMARC) design worked out
for DKIM-Canonicalized-Header/-Body: OpenDKIM emits the canonicalized
data it already computes as extra headers, opt-in via an OpenDKIM-side
config toggle so it costs nothing for deployments that don't send failure
reports, and OpenDMARC strips those headers at eom before final delivery
so nothing leaks to recipients. Avoids building a redundant local DKIM
verifier into OpenDMARC just for this one field.
…nicalizedData

Closes the last item in the RFC 9991 gap: the DKIM-Canonicalized-Header/
-Body ARF fields required on ruf= forensic reports. This codebase never
verifies DKIM locally, so it has no canonicalized bytes of its own --
instead, a new opt-in ReadCanonicalizedData option reads
X-DKIM-Canonicalized-Header/-Body staging headers added by an upstream
OpenDKIM's new AddCanonicalizedData directive (trusteddomainproject/
OpenDKIM PR trusteddomainproject#423), correlates them to whichever DKIM signature is blamed
for a DMARC failure via d=/s=, and copies the base64 payload into the
real RFC 6591 fields, re-folded to this codebase's own ARF-body
convention rather than passed through verbatim.

The staging headers are always stripped before final delivery, whether
or not ReadCanonicalizedData is set, so a signing filter with the
upstream directive enabled can never leak them to a recipient. Getting
this right took two passes: the first version only stripped on the
single normal-completion path at the end of mlfi_eom, missing five
early "ret = SMFIS_ACCEPT; goto done;" shortcuts elsewhere in the
function (malformed/missing From with lenient config, ignored domains,
DMARC permerror) that also deliver the message -- moved the logic into
a shared dmarcf_strip_canon_headers() helper and call it from all six
delivery paths. Also caught and fixed a real bug before it shipped:
looping on smfi_chgheader()'s return value to know when to stop deleting
repeated header instances would have been an infinite loop in
production, since that call is fire-and-forget over the milter socket
and its return value only reflects whether the write succeeded, not
whether a header existed at that index. Fixed by counting real instances
via dmarcf_findheader() first, then issuing exactly that many deletes.

Also fixes an unrelated pre-existing bug found via a syntax-only compile
check while working on this: opendmarc_policy_fetch_np/_psd/
_discovery_method (from the RFC 9990 work) were defined in
opendmarc_policy.c but never prototyped in dmarc.h.in, silently relying
on implicit-declaration behavior.

New test t-verify-canondata covers the stripping behavior (deliberately
without ReadCanonicalizedData set, since stripping must not depend on
it). Built and ran the full miltertest suite against real libmilter/
libspf2 on quark: 9/9 pass, including all pre-existing tests (no
regressions).
Closes the last item in the RFC 9991 gap: the SPF-DNS ARF field (RFC
6591), required on ruf= forensic reports whenever OpenDMARC's own SPF
self-validation contributed to a DMARC failure. libspf2's public API
doesn't expose the DNS records it consults during evaluation -- only a
human-readable summary -- so unlike the DKIM-Canonicalized-Header/-Body
field, this one needed real new code rather than wiring.

New opt-in LogSPFDNS option (only meaningful alongside SPFSelfValidate,
off by default so it costs nothing when not wanted). When enabled,
opendmarc_spf2_alloc_ctx() builds a custom SPF_dns_server_t "DNS layer"
(libopendmarc/opendmarc_spf_dns_log.c) and splices it into libspf2's own
pluggable resolver chain, above the caching layer so every SPF-record
lookup a self-validated message makes is seen regardless of cache hits,
including domains reached via include:/redirect=. The layer passes every
query through to the real resolver unchanged -- evaluation behavior is
never altered -- and for TXT/SPF-type queries whose content is itself an
SPF policy record (starts "v=spf1", or any content for the historic RR
type 99), appends an "SPF-DNS: txt|spf : domain : \"content\"" line,
with '"'/'\' backslash-escaped per RFC 5322 quoted-string rules and
embedded CR/LF dropped outright (this text is later written raw into an
outgoing message piped to sendmail, so a malicious DNS record must not
be able to inject header lines into the report).

opendmarc_spf2_test()/opendmarc_spf_test() both gain a want_dns_log
parameter and an optional out-param for the accumulated lines, threaded
through a real architectural wrinkle: opendmarc_spf2_test() frees its
whole SPF_CTX_T (including the DNS layer chain) before returning to its
caller, so the accumulator has to be extracted before that free, not
read back afterward. The fallback (non-libspf2) evaluator gets the same
signature for call-site compatibility but doesn't populate the lines --
it isn't the path linked in production, so real wiring there (it already
tracks queried domains/records internally) is left as a follow-up rather
than done speculatively.

Also includes a real, independent correctness fix found while testing
this: mlfi_eom()'s job-ID lookup carried a comment describing itself as
a retry "in case it came down later than expected (e.g. postfix)", but
no earlier attempt actually existed anywhere in the code for it to be
retrying. Added one in mlfi_envfrom(), matching the documented intent.

New t-verify-self-spf-report test exercises this against isc.org's real
DNS -- deliberately chosen over the existing t-verify-self-spf test's
trusteddomain.org because isc.org's SPF record has several include:
mechanisms, so the test covers real chain-following, not just a
single-record lookup. Verified live on quark against real libmilter and
libspf2: 11/11 tests pass, and the captured report's SPF-DNS lines were
manually checked against isc.org's actual published SPF record.
…ure.ac footgun

Three unrelated bugs found and fixed while building/testing the SPF-DNS
work, all discovered because it was the first time this cycle a new
directive/build config actually got exercised for real rather than just
compiled:

opendmarc-config.h's dmarcf_config[] table -- a separate, hand-maintained
allowlist config_check() validates every directive name against before
any config_get() call ever runs -- was missing LogSPFDNS (this cycle),
ReadCanonicalizedData (previous commit's DKIM-Canonicalized-Header/-Body
work), and DMARCbisIgnorePct/DMARCbisWalkModeFallback (the earlier RFC
9989 t=/pct= work). Any of the four in a config file produced
"configuration error: unrecognized parameter" and an immediate startup
failure despite being fully wired up and documented. All four added.

t-verify-authservid-jobid was failing whenever OpenDMARC actually linked
a real SPF library instead of the built-in fallback evaluator. Root
cause, confirmed with ktrace (byte-perfect macro delivery on the wire)
and lldb (breakpoints in both OpenDMARC and, via its exported-but-
internal symbols, real libmilter itself): the test sends its "i" (job
ID) macro scoped to the MAIL stage, then calls mt.mailfrom() without
ever calling mt.helo() first, so miltertest auto-inserts a filler HELO
command at that point per its documented "fill in skipped steps"
behavior -- after the macro was already sent. Real libmilter's st_helo()
handler unconditionally clears any macros already stored for later
protocol stages (mi_clr_macros(ctx, CI_HELO+1), a correct, by-design
safeguard against stale macros surviving from a prior transaction on the
same connection), which wipes the just-stored MAIL-stage macro before
it's ever read back. Not a bug in miltertest, real libmilter, or
OpenDMARC's C code -- purely a test-script ordering issue, and it was
present identically in all eleven t-verify-*.lua tests that send the "i"
macro this way; only this one noticed, since it's the only one that
asserts on the delivered value rather than just the pass/fail outcome.
Fixed by sequencing mt.helo() before the macro send everywhere.

configure.ac: --with-spf2 (a very plausible but nonexistent flag --
SPF support is --with-spf, with --with-spf2-include/--with-spf2-lib
separately selecting libspf2 over the fallback evaluator) is what
actually caused the above to go unnoticed for as long as it did: it's
silently ignored by autoconf's default unrecognized-option handling (a
warning, not an error), so a build using it quietly falls back to the
built-in evaluator instead of libspf2 with no indication anything's
wrong. Added an explicit AC_ARG_WITH([spf2], ...) that fails configure
immediately with a message pointing at the correct flags. Hit a real
autoconf/m4 gotcha getting this right: an unquoted comma inside the
AC_MSG_ERROR text was parsed by m4 as an argument separator to the
enclosing AC_ARG_WITH, corrupting the generated shell code (a stray
"else case e in" block and a syntax error deep in ./configure). Fixed
with proper [[...]] quoting; verified by reading the generated configure
script directly, not just re-running it.

Verified together with the SPF-DNS commit: 11/11 tests pass on quark
against real libmilter/libspf2, and both --with-spf2 (now errors) and
the legitimate --with-spf --with-spf2-include=... --with-spf2-lib=...
combination (still configures HAVE_SPF2_H/WITH_SPF correctly) were
exercised directly.
Conflicts in reports/opendmarc-reports.in and .8.in: develop's trusteddomainproject#437/trusteddomainproject#438
are squash-merged copies of commits this branch already had (from before
its DMARCbis work started) under different, non-PR-suffixed hashes --
same author, same message, same content, just two different paths to
develop's tip vs. this branch's history. This branch's side of every
conflicting hunk is a strict superset (log_skip()'s syslog integration,
the "rate limit" loud_skip() reason, and the rua=/ruf= external-
destination verification block, none of which exist yet on develop) of
develop's side, so resolved by taking this branch's version throughout.

Verified on quark: perl -c on the generated opendmarc-reports script is
clean, and the full live test suite (11/11) still passes post-merge.
@thegushi
thegushi merged commit a04f296 into trusteddomainproject:develop Jul 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant