From b488189b910609daa3b3d1ee31eed93e1d512e00 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 09:40:13 -0400 Subject: [PATCH 01/52] DKIM2 --- content/momentum/4/using-dkim2.md | 413 ++++++++++++++++++++++++++++++ content/momentum/navigation.yml | 7 + 2 files changed, 420 insertions(+) create mode 100644 content/momentum/4/using-dkim2.md diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/using-dkim2.md new file mode 100644 index 000000000..5fa3acb1f --- /dev/null +++ b/content/momentum/4/using-dkim2.md @@ -0,0 +1,413 @@ +--- +lastUpdated: "05/23/2026" +title: "Using DKIM2 (DomainKeys Identified Mail v2) Signatures" +description: "DKIM2 is the successor to DKIM that adds replay protection (per-message envelope binding), an explicit chain of custody across forwarders, and a structured way for modifying hops to record what they changed. Momentum ships a prototype DKIM2 implementation that targets draft-ietf-dkim-dkim2-spec-02." +--- + + +### Warning + +DKIM2 is a **prototype** implementation that targets the in-progress IETF draft +[`draft-ietf-dkim-dkim2-spec-02`](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-02) +(May 2026, expires November 2026). The wire format is **not stable** — the +working group can and will revise it. Do not enable DKIM2 on production +outbound traffic without staging it first, and do not rely on the current +wire format being byte-compatible with any future Momentum release. + +Known prototype-versus-draft gaps are listed under +[Known limitations](/momentum/4/using-dkim2#dkim2_caveats) at the end of +this page. + + +## What DKIM2 is, and why + +[DKIM1](/momentum/4/using-dkim) (RFC 6376) lets a sending domain attach a +cryptographic signature that lets a receiver confirm "this message came from +that domain, and the body + signed headers haven't been altered since +signing". It is widely deployed, but it has two known limitations: + +1. **Replay.** Nothing in a DKIM1 signature is bound to *who the message is + for*. An attacker who captures a DKIM1-signed message can re-inject it + to a different recipient and the signature still verifies. Receivers + have no way to tell, from the signature alone, that the message + bypassed the original delivery path. + +2. **Indirect mail flows.** Forwarders and mailing lists routinely modify + messages — rewriting the Subject, adding a footer, expanding the + recipient list — and DKIM1 has no native way for them to attest to + those modifications. The upstream signature breaks, and the receiver + has to fall back on ARC or on heuristics. + +DKIM2 addresses both: + +* Each signature **binds the envelope** to the signed bytes (per-signature + `mf=` for MAIL FROM and `rt=` for RCPT TO). A replay to a different + recipient mismatches `rt=`; a replay from a different sender mismatches + `mf=`. + +* The chain of signatures forms an explicit **chain of custody**: each + hop's `mf=` must match the previous hop's `rt=`, so the verifier can + confirm the path was a real forward, not a detour. + +* Modifying hops **record their modifications** as a JSON "recipe" on a + new `Message-Instance:` header. The verifier can reverse-apply the + recipe to reconstruct the previous instance's bytes and confirm the + upstream hashes still hold. + +This page covers everything an operator needs to enable, observe, and +debug DKIM2 signing and verification on Momentum. The wire-format +specifics live in the [IETF +draft](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-02); +the operationally-relevant signal codes (per-signature reasons, overall +verdicts, paniclog lines) are inventoried in the +[Debugging](/momentum/4/using-dkim2#dkim2_debugging) section below. + + +## How it differs from DKIM1 at a glance + +| Concern | DKIM1 (RFC 6376) | DKIM2 (draft `-02`) | +|---|---|---| +| Header name | `DKIM-Signature:` | `DKIM2-Signature:` | +| Hashes carried in | The signature header itself (`bh=` + `b=`) | A separate `Message-Instance:` header (`h=sha256::`) referenced via `m=` | +| Envelope binding | None | `mf=` / `rt=`, base64-encoded | +| Chain | Implicit (multiple sigs, no required ordering) | Explicit (`i=N` 1..N, `i=N`'s `mf=` must appear in `i=N-1`'s `rt=`) | +| Modifications | Break the upstream signature | Recorded as a JSON recipe on the modifier's MI; reverse-applicable | +| Key record | DNS TXT at `._domainkey.` | Same — DKIM2 reuses the DKIM1 key-publishing format | +| Algorithm | `rsa-sha256`, `ed25519-sha256` | `rsa-sha256`, `ed25519-sha256` | + +Sending domains keep their existing DKIM1 keys: DKIM2 uses the same +`._domainkey.` TXT-record format. There is no extra DNS +provisioning step to start signing DKIM2. + + +## DKIM2 Signing + +DKIM2 signing in Momentum is driven from Lua policy via +`msys.validate.dkim2.sign`. There is no per-binding `dkim2_sign` configuration +flag (the equivalent of DKIM1's `opendkim_sign`); enabling DKIM2 means +calling `sign()` from your validation hook. + +### Warning + +Always call DKIM2 signing from the **per-recipient** validation hook +(`validate_data_spool_each_rcpt`), not from `validate_data_spool`. The +latter fires once on the parent message before the cowref split, and the +resulting signature would commit to a single recipient binding and then be +cloned across every delivered copy — defeating DKIM2's per-recipient +replay protection. + +### Minimum signer + +```lua +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local ok, err = msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "dkim2048", + keyfile = "/opt/msys/ecelerity/etc/conf/dkim/example.com/dkim2048.key", + }) + if not ok then + -- err is a static-literal string describing the failure. See the + -- "Debugging" section below for the full set. + msg:logf(msys.core.log_err, + "dkim2 sign failed: %s", tostring(err)) + end + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_signer", mod) +``` + +`mf=` defaults to the message's envelope MAIL FROM and `rt=` defaults to its +RCPT TO; both can be overridden in the options table for forwarder +scenarios (see *Forwarder / modifier signing* below). + +### Sign options + +| Option | Required? | Meaning | +|---|---|---| +| `domain` | yes | `d=` tag — the signing domain. Combined with `selector` to locate the public key in DNS at `._domainkey.`. | +| `selector` | yes | First component of `s=::`. | +| `keyfile` | yes | Path to the PEM-encoded private key on disk. | +| `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519"` / `"ed25519-sha256"`. | +| `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder. | +| `rcpt` | no | Override the envelope RCPT TO for the `rt=` tag. | +| `timestamp` | no | `t=` value. Defaults to the current UNIX time. | +| `nonce` | no | `n=` value (`-02` §8.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | +| `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | +| `flags` | no | Lua array of flag tokens for `f=` (`-02` §8.9). Recognized values: `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. Joined into the on-wire comma-separated form by the glue layer. | +| `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | +| `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at parse time. Default `false`. The truncated-body recipe is in a state of flux in `-02` (the changelog removes it but §11.1 still references it), so it is opt-in until the WG resolves the inconsistency. | + +`sign()` returns `(true, header_value_string)` on success and `(nil, +error_string)` on failure. Always check the return; on failure the message +is left unmodified (no `DKIM2-Signature:` or `Message-Instance:` is +attached) and an error line is also logged to paniclog at level `error`. + +### Forwarder / modifier signing + +A forwarder that **re-routes** a message (different envelope) signs with +explicit overrides so the §8.3 chain-of-custody check downstream succeeds: + +```lua +-- Hop 2 (forwarder) — its mf= is the upstream rt=, and its rt= is the +-- new downstream recipient. +msys.validate.dkim2.sign(msg, vctx, { + domain = "forwarder.example.net", + selector = "fwd-2026", + keyfile = "/etc/dkim2/forwarder.example.net/fwd-2026.key", + mailfrom = "list-bounce@forwarder.example.net", + rcpt = "subscriber@downstream.example.org", +}) +``` + +A modifier that **rewrites** the message (Subject change, body footer, +attachment strip, etc.) additionally attaches a `recipe`: + +```lua +-- Forwarder rewrote Subject; recipe restores the original on +-- reverse-apply. +msys.validate.dkim2.sign(msg, vctx, { + domain = "list.example.org", + selector = "list-2026", + keyfile = "/etc/dkim2/list.example.org/list-2026.key", + recipe = [[{"h":{"Subject":[{"d":["Original subject"]}]}}]], +}) +``` + +The recipe schema is documented in `-02` §4. Recipes are mandatory only +when the hop modifies content; non-modifying hops (pure-forwarding without +edits) omit `recipe` entirely. + + +## DKIM2 Verifying + +DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. +Typical inbound policy: + +```lua +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local result = msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta-1.example.com", + }) + if not result then + -- Internal error during verification (alloc failure, etc.). + -- Different from a per-sig fail, which lands in result.signatures. + return msys.core.VALIDATE_CONT + end + + -- result.overall is one of: + -- "pass" all sigs verified, chain intact + -- "fail" at least one sig failed (see signatures[i].reason) + -- "chain_broken" i= gap, or §8.3 mf=/rt= bridge broken, or §10.6 + -- recipe-chain reconstruction didn't match MI[1] + -- "temperror" resolver-side transient failure; treat as defer + -- "none" no DKIM2-Signature headers on the message + + if result.overall == "chain_broken" or result.overall == "fail" then + -- Local policy: reject, quarantine, lower reputation, etc. + end + + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_verifier", mod) +``` + +When `authservid` is supplied, the wrapper stamps an +`Authentication-Results:` header summarizing the per-signature verdicts +with one `dkim2=…` clause per signature. + +### Verify options + +| Option | Meaning | +|---|---| +| `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | +| `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. | +| `authservid` | If set, the wrapper stamps an `Authentication-Results:` header naming this authentication-services identity. | +| `skip_ar_header_update` | If `true` (and `authservid` is set), suppress the AR stamp. Use this when the policy stamps AR itself. | +| `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | + +### Result table + +``` +result = { + overall = "pass" | "fail" | "chain_broken" | "temperror" | "none", + signatures = { + { seq = , + status = "pass" | "fail" | "chain_verified", + reason = "ok" | "deferred" | , + d = "", + s = "::", + mf = "", + rt = "", + n = "", -- if present + f = "", -- if present + }, + ... + } +} +``` + +Per `-02` §10.5, **only the most-recently-applied signature** (highest +`i=`) gets full cryptographic verification against the current message +state. Earlier signatures are tracked but marked +`status="chain_verified", reason="deferred"` — the §10.6 recipe-chain +check is the authoritative integrity signal for those hops, not their +own cryptographic verify (which would naturally fail for any modified +message). + + +## Debugging + +Setting `debug_level` on the `dkim2` configuration stanza routes sign and +verify activity to `paniclog`: + +``` +dkim2 { + debug_level = "info" +} +``` + +`error` (the default) surfaces only failures and resolver problems. +`warning` adds DNS issues and SHOULD-violation warnings. `info` adds one +line per sign / verify with the signature i= value, the resolved key +source, and the overall verdict. `debug` adds raw TXT-record bytes from +the resolver and per-step trace lines — too noisy for steady-state +production but useful when chasing a specific record. + +### Per-signature `reason` codes + +Every signature on a verified message gets a `reason` string in +`result.signatures[i].reason`. The full set: + +| Reason | Meaning | +|---|---| +| `ok` | Signature verified cleanly. Paired with `status="pass"`. | +| `deferred` | Earlier signature (i<N) — not directly verified per `-02` §10.5; the recipe-chain check is the authoritative integrity signal for it. Paired with `status="chain_verified"`. | +| `sig_invalid` | Cryptographic verification failed — signed bytes don't hash to the value in `s=`. Almost always means a hop modified a signed header or the body without recording it. | +| `bh_mismatch` | The body the verifier sees doesn't hash to the body-hash recorded in the upstream Message-Instance. The body was modified without a corresponding new Message-Instance. | +| `parse_error` | The `DKIM2-Signature:` header didn't parse as a valid tag-value list. Corrupt header or a broken signer. | +| `missing_required_tags` | The signature is missing the required `s=` tag. | +| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (per `-02` §8.3 the verifier rejects oversize nonces). | +| `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM. Classic replay-to-different-sender symptom. | +| `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO. Classic replay-to-different-recipient symptom. | +| `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). | +| `no_key` | DNS returned NXDOMAIN, or the TXT record had `p=` empty (key revocation per RFC 6376 §3.6.1). | +| `key_k_unknown` | DNS returned a record but its `k=` algorithm tag names an algorithm the verifier doesn't support. | + +### `recipe_chain:` detail strings (paniclog only) + +When the recipe-chain check fails, the overall verdict rolls up to +`chain_broken` and the underlying cause is logged at `error` level in +paniclog as `recipe-chain check failed: recipe_chain: `. The +chain-check failure does NOT appear in the per-signature result struct — +it's a cross-hop verdict, not a per-signature outcome — so paniclog is the +only place this detail surfaces. + +| Detail | Meaning | +|---|---| +| `no_mi_1` | The message had ≥ 2 signatures but no `Message-Instance` with `m=1`. The chain has no anchor. | +| `parse_h` | `Message-Instance` `m=1`'s `h=` tag didn't parse as `sha256::`. The originator's MI is malformed. | +| `recipe_decode` | A hop's `r=` value didn't base64-decode. Wire-format corruption or a broken signer. | +| `recipe_invalid` | A hop's recipe failed schema validation at verify time. Should not occur with conforming signers (sign-time validation prevents emission of bad recipes); appearing here means the signer is broken. | +| `irreversible` | A hop's recipe declared `"h": null`, `"b": null`, or `"b": {"z": true}`. The verifier can't reverse-reconstruct past this hop. Local policy may accept irreversibility from trusted forwarders. | +| `apply_failed` | A recipe references a header or body line that doesn't exist in the current message. The recipe is inconsistent with the modification it claims to describe — likely a downstream hop modified the message AGAIN without recording it. | +| `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Most common cause: a hop modified the message but didn't emit a recipe, or the recipe was wrong. | + +### `ec_message` context fields + +For downstream Lua hooks that need to know what the verifier decided +without re-verifying or re-parsing AR: + +```lua +local overall = msg:context_get(msys.core.ECMESS_CTX_MESS, "dkim2_overall") +local n_sigs = msg:context_get(msys.core.ECMESS_CTX_MESS, "dkim2_n_sigs") +``` + +`dkim2_overall` is one of the five verdict strings above. +`dkim2_n_sigs` is the count of `DKIM2-Signature` headers verified +(string; parse with `tonumber()`). + +### Authentication-Results output + +When `authservid` is supplied to `verify()`, the wrapper stamps an +`Authentication-Results:` header summarizing the per-signature verdicts +per RFC 8601: + +``` +Authentication-Results: ; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256: + header.i=1 header.mf= + header.rt=; + dkim2=fail reason=sig_invalid header.d=example.com + header.s=sel-1:rsa-sha256: header.i=2 … +``` + +One `dkim2=` clause per directly-verified signature. Deferred earlier-hop +signatures (`status="chain_verified"`) are intentionally omitted — they +carry an internal state value that is not a valid RFC 8601 result token, +and the `chain_broken` / `pass` overall verdict already conveys the +chain-integrity outcome to downstream consumers. The `reason=` field +appears only on failures and uses the per-sig reason codes from the table +above. + + +## Key management + +DKIM2 reuses the DKIM1 key infrastructure. Keys are PEM-encoded RSA or +Ed25519 private keys on disk; the matching public key is published in DNS +at `._domainkey.` as a TXT record with the standard +RFC 6376 §3.6.1 format (`v=DKIM1; k=rsa; p=`). + +If you already publish DKIM1 keys at a selector, you can reuse the same +selector for DKIM2 without any DNS change. To generate fresh keys for +DKIM2 specifically, follow the standard openssl recipe in +[Generating DKIM Keys](/momentum/4/using-dkim#using_dkim.generating). + +### Warning + +DKIM2 signatures and DKIM1 signatures **coexist on the wire**: they are +distinct headers (`DKIM2-Signature:` vs. `DKIM-Signature:`) and use +separate verifier paths. A message can carry both and either or both can +pass independently. If you enable DKIM2 signing for a domain that +already does DKIM1 signing, downstream verifiers that don't know about +DKIM2 will simply ignore the new header — they will continue to verify +the DKIM1 signature normally. + + +## Known limitations of the prototype + +DKIM2 in Momentum currently differs from the `-02` draft in a few ways. +These don't affect signature validity within Momentum's own sign / verify +loop (the signer and verifier are symmetric, so DKIM2-signed traffic +between two Momentum nodes verifies correctly today), but they may +matter for byte-level interop with strict-`-02` verifiers in other +implementations: + +* **Header canonicalization** uses Momentum's existing relaxed-canon + rather than `-02` §5.2's slightly tighter "collapse runs of WSP to + single SP" rule. +* **Signed-input canonicalization** uses the same relaxed-canon rather + than §8.5's "delete all WSP characters" rule. +* **The truncated-body recipe** (`b: {"z": true}` per `-02` §4.2) is + gated behind the `allow_recipe_z` sign opt and defaults to off. The + spec is internally inconsistent on this recipe shape — the changelog + removes it but §11.1 still references it — so the prototype + refuses to emit it without an explicit opt-in. +* **Multi-value `s=`** (the `s=set1,set2` shape sketched in `-02` + §7.8 for future signature-set evolution) is not handled; single + sig-set only. + +These are the only known wire-level deltas. The full DKIM2 logical +flow — per-signature envelope binding, `-02` §8.3 chain-of-custody, +`-02` §10.6 recipe-chain reconstruction with `-02` §10.5's +most-recent-only crypto verification — is implemented and exercised +end-to-end. diff --git a/content/momentum/navigation.yml b/content/momentum/navigation.yml index bdf6aabcf..0d8d87f48 100644 --- a/content/momentum/navigation.yml +++ b/content/momentum/navigation.yml @@ -291,6 +291,13 @@ title: DKIM Signing - link: /momentum/4/using-dkim-validation title: DKIM Validation + - link: /momentum/4/using-dkim2 + title: Using DKIM2 (DomainKeys Identified Mail v2) Signatures + items: + - link: '/momentum/4/using-dkim2#dkim2_signing' + title: DKIM2 Signing + - link: '/momentum/4/using-dkim2#dkim2_verifying' + title: DKIM2 Verifying - link: /momentum/4/multi-event-loops title: Configuring Multiple Event Loops - link: /momentum/4/outbound-mail From 3b7f03b229faa28478471dae30b0f68f3817b9ce Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 11:25:17 -0400 Subject: [PATCH 02/52] review comments from cursor --- content/momentum/4/using-dkim2.md | 52 ++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/using-dkim2.md index 5fa3acb1f..e78644f18 100644 --- a/content/momentum/4/using-dkim2.md +++ b/content/momentum/4/using-dkim2.md @@ -46,7 +46,8 @@ DKIM2 addresses both: `mf=`. * The chain of signatures forms an explicit **chain of custody**: each - hop's `mf=` must match the previous hop's `rt=`, so the verifier can + hop's `mf=` must appear in the previous hop's `rt=` list (which may + encode more than one recipient, comma-separated), so the verifier can confirm the path was a real forward, not a detour. * Modifying hops **record their modifications** as a JSON "recipe" on a @@ -153,8 +154,8 @@ A forwarder that **re-routes** a message (different envelope) signs with explicit overrides so the §8.3 chain-of-custody check downstream succeeds: ```lua --- Hop 2 (forwarder) — its mf= is the upstream rt=, and its rt= is the --- new downstream recipient. +-- Hop 2 (forwarder) — its mf= must appear in the upstream rt= list, +-- and its rt= is the new downstream recipient. msys.validate.dkim2.sign(msg, vctx, { domain = "forwarder.example.net", selector = "fwd-2026", @@ -185,6 +186,16 @@ edits) omit `recipe` entirely. ## DKIM2 Verifying +### Warning + +Always call DKIM2 verification from the **per-recipient** validation hook +(`validate_data_spool_each_rcpt`), not from `validate_data_spool`. The +`rt=` binding check compares the signed recipient against the actual +envelope RCPT TO. If `verify()` runs on the parent message before the +cowref split, `ec_message_get_rcptto` returns only the first recipient +and all other copies pass or fail based on that single address — defeating +the per-recipient replay protection. + DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. Typical inbound policy: @@ -208,9 +219,17 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) -- "fail" at least one sig failed (see signatures[i].reason) -- "chain_broken" i= gap, or §8.3 mf=/rt= bridge broken, or §10.6 -- recipe-chain reconstruction didn't match MI[1] - -- "temperror" resolver-side transient failure; treat as defer + -- "temperror" resolver-side transient failure (SERVFAIL, timeout) -- "none" no DKIM2-Signature headers on the message + if result.overall == "temperror" then + -- Transient DNS failure: defer so the sender can retry once the + -- resolver recovers. msys.core.VALIDATE_ERROR triggers a 4xx + -- temporary rejection back to the sending MTA. + vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") + return msys.core.VALIDATE_ERROR + end + if result.overall == "chain_broken" or result.overall == "fail" then -- Local policy: reject, quarantine, lower reputation, etc. end @@ -332,6 +351,13 @@ local overall = msg:context_get(msys.core.ECMESS_CTX_MESS, "dkim2_overall") local n_sigs = msg:context_get(msys.core.ECMESS_CTX_MESS, "dkim2_n_sigs") ``` +Both fields are written by `msys.validate.dkim2.verify()` at the moment +it runs. **They are empty strings until `verify()` has been called on +that message.** Hooks that execute before verification, or on messages +where `verify()` was never called, will receive `""` from +`msg:context_get` — not a verdict string. Always guard with a nil / empty +check before acting on the value. + `dkim2_overall` is one of the five verdict strings above. `dkim2_n_sigs` is the count of `DKIM2-Signature` headers verified (string; parse with `tonumber()`). @@ -346,18 +372,16 @@ per RFC 8601: Authentication-Results: ; dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256: header.i=1 header.mf= - header.rt=; - dkim2=fail reason=sig_invalid header.d=example.com - header.s=sel-1:rsa-sha256: header.i=2 … + header.rt= ``` -One `dkim2=` clause per directly-verified signature. Deferred earlier-hop -signatures (`status="chain_verified"`) are intentionally omitted — they -carry an internal state value that is not a valid RFC 8601 result token, -and the `chain_broken` / `pass` overall verdict already conveys the -chain-integrity outcome to downstream consumers. The `reason=` field -appears only on failures and uses the per-sig reason codes from the table -above. +One `dkim2=` clause per **directly-verified** signature. Per `-02` §10.5, +only the most-recently-applied signature (highest `i=`) receives full +cryptographic verification; earlier hops are deferred to the §10.6 +recipe-chain check and omitted from the AR header entirely. A two-hop +message therefore produces exactly one `dkim2=` clause (for the i=N sig), +not two. The `reason=` field appears only on failures and uses the per-sig +reason codes from the table above. ## Key management From fced77f4eb2c9c9a0d8cdfed8728a72d5238937c Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 12:05:38 -0400 Subject: [PATCH 03/52] for more review comments --- content/momentum/4/using-dkim2.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/using-dkim2.md index e78644f18..a30e39a54 100644 --- a/content/momentum/4/using-dkim2.md +++ b/content/momentum/4/using-dkim2.md @@ -100,6 +100,7 @@ replay protection. ### Minimum signer ```lua +require("msys.core") require("msys.validate.dkim2") local mod = {} @@ -200,6 +201,7 @@ DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. Typical inbound policy: ```lua +require("msys.core") require("msys.validate.dkim2") local mod = {} @@ -209,8 +211,11 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) authservid = "mta-1.example.com", }) if not result then - -- Internal error during verification (alloc failure, etc.). - -- Different from a per-sig fail, which lands in result.signatures. + -- Internal error during verification (alloc failure, etc.) — + -- distinct from a per-sig fail, which lands in result.signatures. + -- Defer rather than silently accepting: the message has not been + -- verified and should not be treated as if it were. + vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") return msys.core.VALIDATE_CONT end @@ -223,11 +228,10 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) -- "none" no DKIM2-Signature headers on the message if result.overall == "temperror" then - -- Transient DNS failure: defer so the sender can retry once the - -- resolver recovers. msys.core.VALIDATE_ERROR triggers a 4xx - -- temporary rejection back to the sending MTA. + -- Transient DNS failure: set a 4xx code so Momentum issues a + -- temporary rejection after the validation pipeline completes, + -- allowing the sender to retry once the resolver recovers. vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") - return msys.core.VALIDATE_ERROR end if result.overall == "chain_broken" or result.overall == "fail" then From ef30b8a22cf18dc8e0ef3d262e2ef59d4d48c722 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 16:28:03 -0400 Subject: [PATCH 04/52] dkim2: minor update --- content/momentum/4/using-dkim2.md | 57 +++++++++++++++++++------------ 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/using-dkim2.md index a30e39a54..975415aaf 100644 --- a/content/momentum/4/using-dkim2.md +++ b/content/momentum/4/using-dkim2.md @@ -81,21 +81,32 @@ Sending domains keep their existing DKIM1 keys: DKIM2 uses the same provisioning step to start signing DKIM2. +## Enabling the module + +Add the following stanza to your Momentum configuration before using any +DKIM2 Lua API: + +``` +dkim2 {} +``` + +The `debug_level` option is documented in the +[Debugging](/momentum/4/using-dkim2#dkim2_debugging) section. + ## DKIM2 Signing DKIM2 signing in Momentum is driven from Lua policy via -`msys.validate.dkim2.sign`. There is no per-binding `dkim2_sign` configuration -flag (the equivalent of DKIM1's `opendkim_sign`); enabling DKIM2 means -calling `sign()` from your validation hook. +`msys.validate.dkim2.sign`; enabling DKIM2 means calling `sign()` from +your validation hook. ### Warning Always call DKIM2 signing from the **per-recipient** validation hook (`validate_data_spool_each_rcpt`), not from `validate_data_spool`. The -latter fires once on the parent message before the cowref split, and the -resulting signature would commit to a single recipient binding and then be -cloned across every delivered copy — defeating DKIM2's per-recipient -replay protection. +latter fires once on the shared parent message before per-recipient copies +are split off, and the resulting signature would commit to a single +recipient binding and then be cloned across every delivered copy — +defeating DKIM2's per-recipient replay protection. ### Minimum signer @@ -114,8 +125,7 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) if not ok then -- err is a static-literal string describing the failure. See the -- "Debugging" section below for the full set. - msg:logf(msys.core.log_err, - "dkim2 sign failed: %s", tostring(err)) + print("dkim2 sign failed: " .. tostring(err)) end return msys.core.VALIDATE_CONT end @@ -192,10 +202,10 @@ edits) omit `recipe` entirely. Always call DKIM2 verification from the **per-recipient** validation hook (`validate_data_spool_each_rcpt`), not from `validate_data_spool`. The `rt=` binding check compares the signed recipient against the actual -envelope RCPT TO. If `verify()` runs on the parent message before the -cowref split, `ec_message_get_rcptto` returns only the first recipient -and all other copies pass or fail based on that single address — defeating -the per-recipient replay protection. +envelope RCPT TO. If `verify()` runs on the shared parent message before per-recipient +copies are split off, the envelope RCPT TO resolves to only the first +recipient and all other copies pass or fail based on that single address +— defeating the per-recipient replay protection. DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. Typical inbound policy: @@ -244,9 +254,13 @@ end msys.registerModule("my_dkim2_verifier", mod) ``` -When `authservid` is supplied, the wrapper stamps an -`Authentication-Results:` header summarizing the per-signature verdicts -with one `dkim2=…` clause per signature. +The wrapper automatically updates the `Authentication-Results:` header +with one `dkim2=…` clause per directly-verified signature. If an AR +header already exists on the message (stamped by an earlier verifier +such as SPF or DKIM1), the dkim2 results are appended to it. If no AR +header exists, a new one is created — but only when `authservid` is +supplied, since a well-formed AR header requires an authentication +service identifier. ### Verify options @@ -254,8 +268,8 @@ with one `dkim2=…` clause per signature. |---|---| | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | | `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. | -| `authservid` | If set, the wrapper stamps an `Authentication-Results:` header naming this authentication-services identity. | -| `skip_ar_header_update` | If `true` (and `authservid` is set), suppress the AR stamp. Use this when the policy stamps AR itself. | +| `authservid` | If set and no `Authentication-Results:` header already exists, a new one is created with this value as the authentication service identifier. Not required when an AR header is already present — the dkim2 results are appended to it automatically. | +| `skip_ar_header_update` | If `true`, suppress all AR output. Use this when the policy stamps AR itself. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | ### Result table @@ -368,9 +382,10 @@ check before acting on the value. ### Authentication-Results output -When `authservid` is supplied to `verify()`, the wrapper stamps an -`Authentication-Results:` header summarizing the per-signature verdicts -per RFC 8601: +`verify()` appends its results to the existing `Authentication-Results:` +header when one is already present (e.g. stamped by SPF or DKIM1 +earlier in the pipeline), or creates a new one if `authservid` is +supplied and none exists yet: ``` Authentication-Results: ; From a8bfb95ba48dd988d542b589175120ba349c508e Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 16:43:38 -0400 Subject: [PATCH 05/52] dkim2: update limitation section --- content/momentum/4/using-dkim2.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/using-dkim2.md index 975415aaf..b2b9ffd69 100644 --- a/content/momentum/4/using-dkim2.md +++ b/content/momentum/4/using-dkim2.md @@ -152,7 +152,7 @@ scenarios (see *Forwarder / modifier signing* below). | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | | `flags` | no | Lua array of flag tokens for `f=` (`-02` §8.9). Recognized values: `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. Joined into the on-wire comma-separated form by the glue layer. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | -| `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at parse time. Default `false`. The truncated-body recipe is in a state of flux in `-02` (the changelog removes it but §11.1 still references it), so it is opt-in until the WG resolves the inconsistency. | +| `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | `sign()` returns `(true, header_value_string)` on success and `(nil, error_string)` on failure. Always check the return; on failure the message @@ -435,16 +435,6 @@ between two Momentum nodes verifies correctly today), but they may matter for byte-level interop with strict-`-02` verifiers in other implementations: -* **Header canonicalization** uses Momentum's existing relaxed-canon - rather than `-02` §5.2's slightly tighter "collapse runs of WSP to - single SP" rule. -* **Signed-input canonicalization** uses the same relaxed-canon rather - than §8.5's "delete all WSP characters" rule. -* **The truncated-body recipe** (`b: {"z": true}` per `-02` §4.2) is - gated behind the `allow_recipe_z` sign opt and defaults to off. The - spec is internally inconsistent on this recipe shape — the changelog - removes it but §11.1 still references it — so the prototype - refuses to emit it without an explicit opt-in. * **Multi-value `s=`** (the `s=set1,set2` shape sketched in `-02` §7.8 for future signature-set evolution) is not handled; single sig-set only. From 34735581ad249ff6667398191394c2ab26d8afda Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 18:11:09 -0400 Subject: [PATCH 06/52] some refinement --- content/momentum/4/using-dkim2.md | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/using-dkim2.md index b2b9ffd69..d1dff88b1 100644 --- a/content/momentum/4/using-dkim2.md +++ b/content/momentum/4/using-dkim2.md @@ -245,7 +245,7 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) end if result.overall == "chain_broken" or result.overall == "fail" then - -- Local policy: reject, quarantine, lower reputation, etc. + vctx:set_code(550, "5.7.1 DKIM2 verification failed") end return msys.core.VALIDATE_CONT @@ -415,7 +415,7 @@ selector for DKIM2 without any DNS change. To generate fresh keys for DKIM2 specifically, follow the standard openssl recipe in [Generating DKIM Keys](/momentum/4/using-dkim#using_dkim.generating). -### Warning +### Note DKIM2 signatures and DKIM1 signatures **coexist on the wire**: they are distinct headers (`DKIM2-Signature:` vs. `DKIM-Signature:`) and use @@ -426,21 +426,16 @@ DKIM2 will simply ignore the new header — they will continue to verify the DKIM1 signature normally. -## Known limitations of the prototype +### Note -DKIM2 in Momentum currently differs from the `-02` draft in a few ways. -These don't affect signature validity within Momentum's own sign / verify -loop (the signer and verifier are symmetric, so DKIM2-signed traffic -between two Momentum nodes verifies correctly today), but they may -matter for byte-level interop with strict-`-02` verifiers in other -implementations: +The full DKIM2 logical flow is implemented and exercised end-to-end: -* **Multi-value `s=`** (the `s=set1,set2` shape sketched in `-02` - §7.8 for future signature-set evolution) is not handled; single - sig-set only. +* Per-signature envelope binding (`mf=` / `rt=`) +* `-02` §8.3 chain-of-custody check +* `-02` §10.5 most-recent-only cryptographic verification +* `-02` §10.6 recipe-chain reconstruction -These are the only known wire-level deltas. The full DKIM2 logical -flow — per-signature envelope binding, `-02` §8.3 chain-of-custody, -`-02` §10.6 recipe-chain reconstruction with `-02` §10.5's -most-recent-only crypto verification — is implemented and exercised -end-to-end. +**Multi-value `s=`** (`s=sel1:rsa-sha256:,sel2:ed25519-sha256:`, +sketched in `-02` §7.8) is not implemented. Momentum emits one sig-set +per signature. No practical use case for the multi-value form is known +and its semantics are not yet finalised in the standard draft. From f6100934f0874c402b5ec5a6123f0e5a05286cae Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Mon, 1 Jun 2026 21:46:58 -0400 Subject: [PATCH 07/52] rename dkim2.md --- content/momentum/4/{using-dkim2.md => dkim2.md} | 6 +++--- content/momentum/navigation.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) rename content/momentum/4/{using-dkim2.md => dkim2.md} (99%) diff --git a/content/momentum/4/using-dkim2.md b/content/momentum/4/dkim2.md similarity index 99% rename from content/momentum/4/using-dkim2.md rename to content/momentum/4/dkim2.md index d1dff88b1..ee9a605c2 100644 --- a/content/momentum/4/using-dkim2.md +++ b/content/momentum/4/dkim2.md @@ -15,7 +15,7 @@ outbound traffic without staging it first, and do not rely on the current wire format being byte-compatible with any future Momentum release. Known prototype-versus-draft gaps are listed under -[Known limitations](/momentum/4/using-dkim2#dkim2_caveats) at the end of +[Known limitations](/momentum/4/dkim2#dkim2_caveats) at the end of this page. @@ -61,7 +61,7 @@ specifics live in the [IETF draft](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-02); the operationally-relevant signal codes (per-signature reasons, overall verdicts, paniclog lines) are inventoried in the -[Debugging](/momentum/4/using-dkim2#dkim2_debugging) section below. +[Debugging](/momentum/4/dkim2#dkim2_debugging) section below. ## How it differs from DKIM1 at a glance @@ -91,7 +91,7 @@ dkim2 {} ``` The `debug_level` option is documented in the -[Debugging](/momentum/4/using-dkim2#dkim2_debugging) section. +[Debugging](/momentum/4/dkim2#dkim2_debugging) section. ## DKIM2 Signing diff --git a/content/momentum/navigation.yml b/content/momentum/navigation.yml index 0d8d87f48..460b1b4b2 100644 --- a/content/momentum/navigation.yml +++ b/content/momentum/navigation.yml @@ -291,12 +291,12 @@ title: DKIM Signing - link: /momentum/4/using-dkim-validation title: DKIM Validation - - link: /momentum/4/using-dkim2 + - link: /momentum/4/dkim2 title: Using DKIM2 (DomainKeys Identified Mail v2) Signatures items: - - link: '/momentum/4/using-dkim2#dkim2_signing' + - link: '/momentum/4/dkim2#dkim2_signing' title: DKIM2 Signing - - link: '/momentum/4/using-dkim2#dkim2_verifying' + - link: '/momentum/4/dkim2#dkim2_verifying' title: DKIM2 Verifying - link: /momentum/4/multi-event-loops title: Configuring Multiple Event Loops From 997c5a105690cb87e07b088869a1bb86b5c3289b Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Tue, 2 Jun 2026 13:08:09 -0400 Subject: [PATCH 08/52] =?UTF-8?q?update=20to=20support=20multi-algorithm?= =?UTF-8?q?=20signing=20via=20sig=5Fsets=20(=C2=A77.8=20algorithm=20agilit?= =?UTF-8?q?y)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/momentum/4/dkim2.md | 46 +++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index ee9a605c2..b6045b902 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -139,12 +139,44 @@ scenarios (see *Forwarder / modifier signing* below). ### Sign options +`sign()` accepts either a single options table or a multi-algorithm +form using an explicit `sig_sets` key (§7.8 algorithm agility): + +```lua +-- Single sig-set (most common): +msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "sel-2048", + keyfile = "/etc/dkim2/rsa.key", +}) + +-- Multi-algorithm (RSA + Ed25519 in one DKIM2-Signature): +msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + sig_sets = { + { selector = "sel-rsa", keyfile = "/etc/dkim2/rsa.key" }, + { selector = "sel-ed25519", keyfile = "/etc/dkim2/ed25519.key", + algorithm = "ed25519-sha256" }, + }, +}) +``` + +When `sig_sets` is present, all entries sign the same canonical +signed-input and are combined into a single `s=sel1:alg1:sig1,sel2:alg2:sig2` +value on one `DKIM2-Signature` header. The verifier tries each sig-set +in order and passes on the first that validates (OR semantics), so a +receiver that only supports RSA will still verify cleanly. The +`selector`, `keyfile`, and `algorithm` fields belong to each sig-set +entry; all other options below are header-level and go at the top level +of the options table. + | Option | Required? | Meaning | |---|---|---| -| `domain` | yes | `d=` tag — the signing domain. Combined with `selector` to locate the public key in DNS at `._domainkey.`. | -| `selector` | yes | First component of `s=::`. | -| `keyfile` | yes | Path to the PEM-encoded private key on disk. | -| `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519"` / `"ed25519-sha256"`. | +| `domain` | yes | `d=` tag — the signing domain. | +| `selector` | yes (single) | Selector component of `s=::`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keyfile` | yes (single) | Path to the PEM-encoded private key. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `sig_sets` | no | Array of `{selector, keyfile, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`algorithm` at the top level are ignored. | | `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder. | | `rcpt` | no | Override the envelope RCPT TO for the `rt=` tag. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | @@ -426,7 +458,7 @@ DKIM2 will simply ignore the new header — they will continue to verify the DKIM1 signature normally. -### Note +### Interoperability The full DKIM2 logical flow is implemented and exercised end-to-end: @@ -435,7 +467,3 @@ The full DKIM2 logical flow is implemented and exercised end-to-end: * `-02` §10.5 most-recent-only cryptographic verification * `-02` §10.6 recipe-chain reconstruction -**Multi-value `s=`** (`s=sel1:rsa-sha256:,sel2:ed25519-sha256:`, -sketched in `-02` §7.8) is not implemented. Momentum emits one sig-set -per signature. No practical use case for the multi-value form is known -and its semantics are not yet finalised in the standard draft. From 7bbc81a87fa915146504ec251066d287a052e343 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 5 Jun 2026 22:34:47 +0000 Subject: [PATCH 09/52] update --- content/momentum/4/dkim2.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index b6045b902..d7fff5ce8 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -286,13 +286,11 @@ end msys.registerModule("my_dkim2_verifier", mod) ``` -The wrapper automatically updates the `Authentication-Results:` header -with one `dkim2=…` clause per directly-verified signature. If an AR -header already exists on the message (stamped by an earlier verifier -such as SPF or DKIM1), the dkim2 results are appended to it. If no AR -header exists, a new one is created — but only when `authservid` is -supplied, since a well-formed AR header requires an authentication -service identifier. +The wrapper stamps a new `Authentication-Results:` header with one +`dkim2=…` clause per directly-verified signature. RFC 8601 §5 states +that an MTA **MUST NOT** add a result to an existing header field, so +`verify()` always prepends a fresh AR header. `authservid` is required; +nothing is emitted when it is absent. ### Verify options @@ -303,6 +301,7 @@ service identifier. | `authservid` | If set and no `Authentication-Results:` header already exists, a new one is created with this value as the authentication service identifier. Not required when an AR header is already present — the dkim2 results are appended to it automatically. | | `skip_ar_header_update` | If `true`, suppress all AR output. Use this when the policy stamps AR itself. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | +| `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message for test and debug inspection. Default `false`. | ### Result table @@ -347,10 +346,13 @@ dkim2 { `error` (the default) surfaces only failures and resolver problems. `warning` adds DNS issues and SHOULD-violation warnings. `info` adds one -line per sign / verify with the signature i= value, the resolved key -source, and the overall verdict. `debug` adds raw TXT-record bytes from -the resolver and per-step trace lines — too noisy for steady-state -production but useful when chasing a specific record. +DNS resolution line per verified signature plus any verification failure +with its cause (`bh_mismatch` with expected vs. actual hash, `sig_invalid` +with selector, algorithm, signed-input length, and OpenSSL detail). +`debug` adds raw TXT-record bytes from the resolver, a per-crypto-check +trace line, and the raw signed-input bytes on failure — too noisy for +steady-state production but useful when chasing a specific sign/verify +mismatch. ### Per-signature `reason` codes @@ -361,7 +363,8 @@ Every signature on a verified message gets a `reason` string in |---|---| | `ok` | Signature verified cleanly. Paired with `status="pass"`. | | `deferred` | Earlier signature (i<N) — not directly verified per `-02` §10.5; the recipe-chain check is the authoritative integrity signal for it. Paired with `status="chain_verified"`. | -| `sig_invalid` | Cryptographic verification failed — signed bytes don't hash to the value in `s=`. Almost always means a hop modified a signed header or the body without recording it. | +| `hh_mismatch` | The freshly-computed header hash doesn't match the value recorded in the upstream `Message-Instance:`. A content header (Subject, From, etc.) was modified after signing without a corresponding new MI. | +| `sig_invalid` | Cryptographic verification failed — the signed-input bytes (MI headers + prior DKIM2-Signatures + partial current sig) don't hash to the value in `s=`. Almost always means the `Message-Instance:` or a prior `DKIM2-Signature:` was altered after signing. Enable `debug_level = info` to see the selector, algorithm, and signed-input length. | | `bh_mismatch` | The body the verifier sees doesn't hash to the body-hash recorded in the upstream Message-Instance. The body was modified without a corresponding new Message-Instance. | | `parse_error` | The `DKIM2-Signature:` header didn't parse as a valid tag-value list. Corrupt header or a broken signer. | | `missing_required_tags` | The signature is missing the required `s=` tag. | From 86efa82f26584ef27dded1462cf619085c2abc19 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Tue, 9 Jun 2026 14:17:45 +0000 Subject: [PATCH 10/52] update with new sign/verify options --- content/momentum/4/dkim2.md | 166 +++++++++++++++++++++++++++--------- 1 file changed, 128 insertions(+), 38 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index d7fff5ce8..b8ae53352 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -1,22 +1,23 @@ --- -lastUpdated: "05/23/2026" +lastUpdated: "06/09/2026" title: "Using DKIM2 (DomainKeys Identified Mail v2) Signatures" -description: "DKIM2 is the successor to DKIM that adds replay protection (per-message envelope binding), an explicit chain of custody across forwarders, and a structured way for modifying hops to record what they changed. Momentum ships a prototype DKIM2 implementation that targets draft-ietf-dkim-dkim2-spec-02." +description: "DKIM2 is the successor to DKIM that adds replay protection (per-message envelope binding), an explicit chain of custody across forwarders, and a structured way for modifying hops to record what they changed. Momentum implements DKIM2 targeting draft-ietf-dkim-dkim2-spec-02." --- ### Warning -DKIM2 is a **prototype** implementation that targets the in-progress IETF draft +DKIM2 targets the in-progress IETF draft [`draft-ietf-dkim-dkim2-spec-02`](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-02) -(May 2026, expires November 2026). The wire format is **not stable** — the -working group can and will revise it. Do not enable DKIM2 on production -outbound traffic without staging it first, and do not rely on the current -wire format being byte-compatible with any future Momentum release. +(May 2026). The wire format is **not yet final** — the working group may revise +it before publication. Do not enable DKIM2 on production outbound traffic +without staging it first. If the spec changes, a future Momentum release may +not verify messages signed by an earlier release. -Known prototype-versus-draft gaps are listed under -[Known limitations](/momentum/4/dkim2#dkim2_caveats) at the end of -this page. +> **What this means in practice:** Stage DKIM2 on a limited outbound mail +> stream first. If you later upgrade Momentum and the spec has changed, messages +> signed by the old release will fail verification at receivers that have also +> upgraded. Messages signed by DKIM1 are unaffected. ## What DKIM2 is, and why @@ -184,7 +185,9 @@ of the options table. | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | | `flags` | no | Lua array of flag tokens for `f=` (`-02` §8.9). Recognized values: `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. Joined into the on-wire comma-separated form by the glue layer. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | +| `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` — `sign()` returns an error on mismatch. Set to `true` to downgrade to a `DWARNING` log and proceed, for configurations where the signing domain intentionally differs from the envelope domain. | | `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | +| `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | `sign()` returns `(true, header_value_string)` on success and `(nil, error_string)` on failure. Always check the return; on failure the message @@ -297,27 +300,42 @@ nothing is emitted when it is absent. | Option | Meaning | |---|---| | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | -| `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. | +| `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. **For testing only** — mirrors sign()'s `mailfrom=` option; useful for simulating specific envelope conditions without real SMTP transit. | +| `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. **For testing only** — in production the envelope RCPT TO is always read from the message automatically and this option should not be set. | | `authservid` | If set and no `Authentication-Results:` header already exists, a new one is created with this value as the authentication service identifier. Not required when an AR header is already present — the dkim2 results are appended to it automatically. | | `skip_ar_header_update` | If `true`, suppress all AR output. Use this when the policy stamps AR itself. | +| `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (strict — mismatched signing domain fails verification). | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | -| `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message for test and debug inspection. Default `false`. | +| `strict_s_selectors` | If `true`, treat duplicate selectors within a single `s=` tag as `reason=parse_error`. Default `false` (relax — §7.8 places the MUST only on signers; verifiers may accept duplicates). | +| `max_sig_age_days` | §10.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | +| `max_sig_future_secs` | §7.4: reject signatures whose `t=` timestamp is more than this many seconds in the future. Default `300` (5-minute clock-skew tolerance). Values `<= 0` disable the check. | +| `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message. Useful for staging and debugging; **do not enable in production** as these headers expose internal verification detail and inflate message size. Default `false`. | ### Result table ``` result = { - overall = "pass" | "fail" | "chain_broken" | "temperror" | "none", + overall = "pass" -- all verifiable signatures passed + | "permfail" -- all signatures failed (crypto or structural) + | "fail" -- policy-level failure (d=/mf= mismatch, donotmodify, etc.) + | "chain_broken" -- chain-of-custody or MI integrity failure + | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) + | "none", -- no DKIM2 signatures present signatures = { - { seq = , - status = "pass" | "fail" | "chain_verified", - reason = "ok" | "deferred" | , - d = "", - s = "::", - mf = "", - rt = "", - n = "", -- if present - f = "", -- if present + { seq = , + status = "pass" -- signature verified + | "fail" -- signature failed; see reason + | "chain_verified" -- earlier hop (i, -- see Per-signature reason codes table below + d = "", + s = "::", -- raw s= value + mf = "", -- decoded from base64 + rt = "", -- decoded from base64; first entry only + n = "", -- if present + f = "", -- if present; comma-separated }, ... } @@ -362,18 +380,24 @@ Every signature on a verified message gets a `reason` string in | Reason | Meaning | |---|---| | `ok` | Signature verified cleanly. Paired with `status="pass"`. | -| `deferred` | Earlier signature (i<N) — not directly verified per `-02` §10.5; the recipe-chain check is the authoritative integrity signal for it. Paired with `status="chain_verified"`. | -| `hh_mismatch` | The freshly-computed header hash doesn't match the value recorded in the upstream `Message-Instance:`. A content header (Subject, From, etc.) was modified after signing without a corresponding new MI. | -| `sig_invalid` | Cryptographic verification failed — the signed-input bytes (MI headers + prior DKIM2-Signatures + partial current sig) don't hash to the value in `s=`. Almost always means the `Message-Instance:` or a prior `DKIM2-Signature:` was altered after signing. Enable `debug_level = info` to see the selector, algorithm, and signed-input length. | -| `bh_mismatch` | The body the verifier sees doesn't hash to the body-hash recorded in the upstream Message-Instance. The body was modified without a corresponding new Message-Instance. | -| `parse_error` | The `DKIM2-Signature:` header didn't parse as a valid tag-value list. Corrupt header or a broken signer. | -| `missing_required_tags` | The signature is missing the required `s=` tag. | -| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (per `-02` §8.3 the verifier rejects oversize nonces). | -| `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM. Classic replay-to-different-sender symptom. | -| `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO. Classic replay-to-different-recipient symptom. | -| `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). | -| `no_key` | DNS returned NXDOMAIN, or the TXT record had `p=` empty (key revocation per RFC 6376 §3.6.1). | -| `key_k_unknown` | DNS returned a record but its `k=` algorithm tag names an algorithm the verifier doesn't support. | +| `deferred` | Earlier signature (i<N) — not directly verified (§10.5); the §10.6 recipe-chain check is the authoritative integrity signal for it. Paired with `status="chain_verified"`. | +| `hh_mismatch` | Header hash mismatch — a content header (Subject, From, etc.) was modified after signing without a new `Message-Instance:` recording the change. | +| `bh_mismatch` | Body hash mismatch — the message body was modified after signing without a new `Message-Instance:` recording the change. | +| `sig_invalid` | Cryptographic verification failed — the signed-input bytes don't match the value in `s=`. Enable `debug_level = info` for selector, algorithm, and signed-input length detail. | +| `parse_error` | The `DKIM2-Signature:` header couldn't be parsed. Corrupt header or a broken upstream signer. | +| `missing_required_tags` | One or more of the seven required tags (`i=`, `m=`, `t=`, `mf=`, `rt=`, `d=`, `s=`) is absent from the signature. | +| `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). | +| `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. | +| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§8.3). | +| `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM — replay-to-different-sender. | +| `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO — replay-to-different-recipient. | +| `d_mf_mismatch` | The signing domain `d=` does not match the rightmost labels of the `mf=` domain (§7.7). Only set when `relax_d_mf_check` is not enabled. | +| `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). Rolls up to `overall="temperror"`. | +| `no_key` | DNS returned NXDOMAIN — no TXT record exists for the selector. | +| `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | +| `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | +| `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | +| `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | ### `recipe_chain:` detail strings (paniclog only) @@ -387,7 +411,7 @@ only place this detail surfaces. | Detail | Meaning | |---|---| | `no_mi_1` | The message had ≥ 2 signatures but no `Message-Instance` with `m=1`. The chain has no anchor. | -| `parse_h` | `Message-Instance` `m=1`'s `h=` tag didn't parse as `sha256::`. The originator's MI is malformed. | +| `parse_h` | `Message-Instance` `h=` tag didn't parse as `::`. The MI is malformed. | | `recipe_decode` | A hop's `r=` value didn't base64-decode. Wire-format corruption or a broken signer. | | `recipe_invalid` | A hop's recipe failed schema validation at verify time. Should not occur with conforming signers (sign-time validation prevents emission of bad recipes); appearing here means the signer is broken. | | `irreversible` | A hop's recipe declared `"h": null`, `"b": null`, or `"b": {"z": true}`. The verifier can't reverse-reconstruct past this hop. Local policy may accept irreversibility from trusted forwarders. | @@ -438,6 +462,53 @@ not two. The `reason=` field appears only on failures and uses the per-sig reason codes from the table above. +## SMTP response codes (§9.4 guidance) + +Momentum leaves the decision of whether to accept, reject, or defer a +message — and which SMTP reply code to use — entirely to the operator's +Lua hook. The `overall` field of the verify result maps to the following +SMTP behaviour as required by §9.4 of the DKIM2 spec: + +| `overall` | Meaning | §9.4 guidance | Suggested action | +|---|---|---|---| +| `pass` | All verifiable signatures passed | — | Accept | +| `none` | No DKIM2 signatures present | — | Local policy | +| `fail` | Policy-level failure (e.g. d=/mf= mismatch, donotmodify violated) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | +| `permfail` | All signatures failed crypto or structural checks | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | +| `chain_broken` | Chain-of-custody or MI integrity failure | SHOULD 550/5.7.x | Reject (permanent) | +| `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | + +**Key rule from §9.4**: cryptographic and structural failures (`permfail`, +`chain_broken`) **MUST NOT** result in a 4xx (temporary) SMTP reply. Only +`temperror` warrants a temporary failure code. + +Example hook skeleton: + +```lua +local result = msys.validate.dkim2.verify(msg, vctx, { ... }) +local overall = result and result.overall or "none" + +if overall == "permfail" or overall == "chain_broken" then + -- §9.4 SHOULD 550/5.7.x for permanent failures + vctx:set_code(550, "5.7.1", "DKIM2 verification failed") + return msys.core.SMFIS_REJECT + +elseif overall == "temperror" then + -- §9.4 MAY 451/4.7.5 for transient key-fetch failures + vctx:set_code(451, "4.7.5", "DKIM2 key server temporarily unavailable") + return msys.core.SMFIS_TEMPFAIL + +else + -- pass / none / fail: local policy + return msys.core.VALIDATE_CONT +end +``` + +> **Note**: Whether to reject on `fail`, `chain_broken`, or `none` is a +> local policy decision. The spec only mandates the reply-code *type* +> (4xx vs 5xx) for the cases shown above. + + ## Key management DKIM2 reuses the DKIM1 key infrastructure. Keys are PEM-encoded RSA or @@ -466,7 +537,26 @@ the DKIM1 signature normally. The full DKIM2 logical flow is implemented and exercised end-to-end: * Per-signature envelope binding (`mf=` / `rt=`) -* `-02` §8.3 chain-of-custody check -* `-02` §10.5 most-recent-only cryptographic verification -* `-02` §10.6 recipe-chain reconstruction +* §8.3 chain-of-custody check +* §10.5 most-recent-only cryptographic verification +* §10.6 recipe-chain reconstruction + + +## Known limitations + +The following features are not yet implemented and may be addressed in a +future release: + +* **§11 DSN routing**: When generating a Delivery Status Notification, + Momentum does not yet address it to the `mf=` address from the + highest-numbered DKIM2-Signature of the original message, nor does it + suppress DSN generation when the original sender was `<>` (null sender). + Inbound DSN authentication (§11.1.2) is also not implemented. + +* **§8.2 Forwarder auto-detection**: The `sign()` API fully supports + co-signing (call it twice to add a forwarder signature), but Momentum + does not auto-detect chain-of-custody breaks. Operators must explicitly + call `sign()` when forwarding changes the MAIL FROM. Full automation + requires the Recipe Accumulator API (planned; not yet available). + From c33d5307375e2817d2529a7e00e85a4729302852 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 00:36:08 +0000 Subject: [PATCH 11/52] more update --- content/momentum/4/dkim2.md | 64 +++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index b8ae53352..81fda64e8 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -164,12 +164,13 @@ msys.validate.dkim2.sign(msg, vctx, { When `sig_sets` is present, all entries sign the same canonical signed-input and are combined into a single `s=sel1:alg1:sig1,sel2:alg2:sig2` -value on one `DKIM2-Signature` header. The verifier tries each sig-set -in order and passes on the first that validates (OR semantics), so a -receiver that only supports RSA will still verify cleanly. The -`selector`, `keyfile`, and `algorithm` fields belong to each sig-set -entry; all other options below are header-level and go at the top level -of the options table. +value on one `DKIM2-Signature` header. Per §7.2 the verifier checks +every sig-set; overall passes if any one validates, so a receiver that +only supports RSA will still verify cleanly. Any sig-set that fails +alongside a passing one is reported as a DWARNING in paniclog +(partial-sig-failure condition). The `selector`, `keyfile`, and +`algorithm` fields belong to each sig-set entry; all other options below +are header-level and go at the top level of the options table. | Option | Required? | Meaning | |---|---|---| @@ -266,7 +267,10 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) -- result.overall is one of: -- "pass" all sigs verified, chain intact - -- "fail" at least one sig failed (see signatures[i].reason) + -- "fail" verified but wrong: hash/sig mismatch or policy + -- violation (d=/mf= mismatch, donotmodify, etc.) + -- "permerror" could not verify: key missing/invalid/revoked, + -- signature syntax error -- "chain_broken" i= gap, or §8.3 mf=/rt= bridge broken, or §10.6 -- recipe-chain reconstruction didn't match MI[1] -- "temperror" resolver-side transient failure (SERVFAIL, timeout) @@ -279,7 +283,9 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") end - if result.overall == "chain_broken" or result.overall == "fail" then + if result.overall == "chain_broken" or + result.overall == "fail" or + result.overall == "permerror" then vctx:set_code(550, "5.7.1 DKIM2 verification failed") end @@ -306,7 +312,7 @@ nothing is emitted when it is absent. | `skip_ar_header_update` | If `true`, suppress all AR output. Use this when the policy stamps AR itself. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (strict — mismatched signing domain fails verification). | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | -| `strict_s_selectors` | If `true`, treat duplicate selectors within a single `s=` tag as `reason=parse_error`. Default `false` (relax — §7.8 places the MUST only on signers; verifiers may accept duplicates). | +| `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag (for interop with non-spec-compliant signers). Default `false` — duplicates produce `reason=parse_error` per §7.8. | | `max_sig_age_days` | §10.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | | `max_sig_future_secs` | §7.4: reject signatures whose `t=` timestamp is more than this many seconds in the future. Default `300` (5-minute clock-skew tolerance). Values `<= 0` disable the check. | | `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message. Useful for staging and debugging; **do not enable in production** as these headers expose internal verification detail and inflate message size. Default `false`. | @@ -316,17 +322,23 @@ nothing is emitted when it is absent. ``` result = { overall = "pass" -- all verifiable signatures passed - | "permfail" -- all signatures failed (crypto or structural) - | "fail" -- policy-level failure (d=/mf= mismatch, donotmodify, etc.) + | "fail" -- verified but wrong: hash/sig mismatch, or + | -- policy violation (d=/mf= mismatch, donotmodify, etc.) + | "permerror" -- could not verify: key missing/revoked/invalid, + | -- or signature syntax error (§10.1 PERMERROR) | "chain_broken" -- chain-of-custody or MI integrity failure | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) | "none", -- no DKIM2 signatures present + overall_reason = nil -- nil unless overall="fail" due to a + | "d_mf_mismatch" -- policy downgrade after crypto pass: + | "donotmodify_violated" -- §7.7 domain alignment failed + | "donotexplode_violated", -- §10.8 flag violation signatures = { { seq = , status = "pass" -- signature verified | "fail" -- signature failed; see reason | "chain_verified" -- earlier hop (i, -- see Per-signature reason codes table below @@ -398,6 +410,8 @@ Every signature on a verified message gets a `reason` string in | `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | | `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | | `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | +| `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | +| `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | ### `recipe_chain:` detail strings (paniclog only) @@ -435,7 +449,7 @@ where `verify()` was never called, will receive `""` from `msg:context_get` — not a verdict string. Always guard with a nil / empty check before acting on the value. -`dkim2_overall` is one of the five verdict strings above. +`dkim2_overall` is one of the six verdict strings above. `dkim2_n_sigs` is the count of `DKIM2-Signature` headers verified (string; parse with `tonumber()`). @@ -473,14 +487,14 @@ SMTP behaviour as required by §9.4 of the DKIM2 spec: |---|---|---|---| | `pass` | All verifiable signatures passed | — | Accept | | `none` | No DKIM2 signatures present | — | Local policy | -| `fail` | Policy-level failure (e.g. d=/mf= mismatch, donotmodify violated) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | -| `permfail` | All signatures failed crypto or structural checks | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | +| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | +| `permerror` | Could not verify: key missing, revoked, or invalid; signature syntax error (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | | `chain_broken` | Chain-of-custody or MI integrity failure | SHOULD 550/5.7.x | Reject (permanent) | | `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | -**Key rule from §9.4**: cryptographic and structural failures (`permfail`, -`chain_broken`) **MUST NOT** result in a 4xx (temporary) SMTP reply. Only -`temperror` warrants a temporary failure code. +**Key rule from §9.4**: permanent failures (`permerror`, `chain_broken`) +**MUST NOT** result in a 4xx (temporary) SMTP reply. Only `temperror` +warrants a temporary failure code. Example hook skeleton: @@ -488,8 +502,10 @@ Example hook skeleton: local result = msys.validate.dkim2.verify(msg, vctx, { ... }) local overall = result and result.overall or "none" -if overall == "permfail" or overall == "chain_broken" then - -- §9.4 SHOULD 550/5.7.x for permanent failures +if overall == "permerror" or overall == "chain_broken" or + overall == "fail" then + -- §9.4 SHOULD 550/5.7.x for permanent failures. + -- Note: "permerror" and "chain_broken" MUST NOT use 4xx. vctx:set_code(550, "5.7.1", "DKIM2 verification failed") return msys.core.SMFIS_REJECT @@ -499,14 +515,14 @@ elseif overall == "temperror" then return msys.core.SMFIS_TEMPFAIL else - -- pass / none / fail: local policy + -- pass / none: local policy return msys.core.VALIDATE_CONT end ``` -> **Note**: Whether to reject on `fail`, `chain_broken`, or `none` is a -> local policy decision. The spec only mandates the reply-code *type* -> (4xx vs 5xx) for the cases shown above. +> **Note**: Whether to reject on `fail` or `none` is a local policy +> decision. The spec only mandates the reply-code *type* (4xx vs 5xx) +> for the cases shown above. ## Key management From 9e48da2f4902d7f501d9ecccf30bf6b53ec81af5 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 02:23:13 +0000 Subject: [PATCH 12/52] update verify error code --- content/momentum/4/dkim2.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 81fda64e8..24c2870be 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -430,7 +430,8 @@ only place this detail surfaces. | `recipe_invalid` | A hop's recipe failed schema validation at verify time. Should not occur with conforming signers (sign-time validation prevents emission of bad recipes); appearing here means the signer is broken. | | `irreversible` | A hop's recipe declared `"h": null`, `"b": null`, or `"b": {"z": true}`. The verifier can't reverse-reconstruct past this hop. Local policy may accept irreversibility from trusted forwarders. | | `apply_failed` | A recipe references a header or body line that doesn't exist in the current message. The recipe is inconsistent with the modification it claims to describe — likely a downstream hop modified the message AGAIN without recording it. | -| `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Most common cause: a hop modified the message but didn't emit a recipe, or the recipe was wrong. | +| `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should emit `r={"h":null,"b":null}` to declare irreversibility rather than omitting `r=` entirely. | +| `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Every non-first MI had a recipe, so the mismatch indicates a hop's recipe was wrong or a hop modified the message after signing. | ### `ec_message` context fields From 9f7bf8a136da312294d72fa2b0b63926287c3b68 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 03:33:47 +0000 Subject: [PATCH 13/52] rt returns a list of recipients --- content/momentum/4/dkim2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 24c2870be..be6f613cc 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -345,7 +345,7 @@ result = { d = "", s = "::", -- raw s= value mf = "", -- decoded from base64 - rt = "", -- decoded from base64; first entry only + rt = "[,...]", -- all entries decoded from base64 n = "", -- if present f = "", -- if present; comma-separated }, From 42e8bcfd01a2299aa8af9e7dec4aa8cf5fdf8d92 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 14:21:54 +0000 Subject: [PATCH 14/52] =?UTF-8?q?add=20warning=20on=20=C2=A712=20Bare=20CR?= =?UTF-8?q?/LF=20normalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/momentum/4/dkim2.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index be6f613cc..c985996b7 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -408,6 +408,7 @@ Every signature on a verified message gets a `reason` string in | `no_key` | DNS returned NXDOMAIN — no TXT record exists for the selector. | | `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | | `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | +| `key_invalid` | DNS returned more than one TXT record for the selector (§10.5 MUST treat as PERMERROR), or the record was structurally unusable. DNS misconfiguration on the sender side. | | `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | | `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | @@ -576,4 +577,16 @@ future release: call `sign()` when forwarding changes the MAIL FROM. Full automation requires the Recipe Accumulator API (planned; not yet available). +### Warning + +**§12 Bare CR/LF normalization**: §12 requires signing the message as it +will be received by the verifier. Momentum's +[`rfc2822_lone_lf_in_body`](/momentum/4/config/ref-rfc-2822-lone-lf-in-body) +and +[`rfc2822_lone_lf_in_headers`](/momentum/4/config/ref-rfc-2822-lone-lf-in-headers) +options control bare LF handling. If either is set to `ignore`, DKIM2 +signs bare-LF content as-is; a downstream SMTP hop that normalizes it to +CRLF will silently break the signature. **Set both options to `fix` when +DKIM2 signing is in use.** + From 68f9761c4ca907b548d58abfbe50321112da566d Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 14:51:39 +0000 Subject: [PATCH 15/52] update according to review comments --- content/momentum/4/dkim2.md | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index c985996b7..fb3431090 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -27,11 +27,13 @@ cryptographic signature that lets a receiver confirm "this message came from that domain, and the body + signed headers haven't been altered since signing". It is widely deployed, but it has two known limitations: -1. **Replay.** Nothing in a DKIM1 signature is bound to *who the message is - for*. An attacker who captures a DKIM1-signed message can re-inject it - to a different recipient and the signature still verifies. Receivers - have no way to tell, from the signature alone, that the message - bypassed the original delivery path. +1. **Replay.** DKIM1 can sign the `To:` header field, but nothing in a + DKIM1 signature is bound to the *SMTP envelope RCPT TO* — the address + that controls actual delivery. An attacker who captures a DKIM1-signed + message can change the envelope recipient and re-inject it; the `To:` + header and the signature remain intact and valid. Receivers have no way + to tell, from the signature alone, that the message was delivered to + someone other than the originally intended recipient. 2. **Indirect mail flows.** Forwarders and mailing lists routinely modify messages — rewriting the Subject, adding a footer, expanding the @@ -298,8 +300,12 @@ msys.registerModule("my_dkim2_verifier", mod) The wrapper stamps a new `Authentication-Results:` header with one `dkim2=…` clause per directly-verified signature. RFC 8601 §5 states that an MTA **MUST NOT** add a result to an existing header field, so -`verify()` always prepends a fresh AR header. `authservid` is required; -nothing is emitted when it is absent. +`verify()` always prepends a fresh AR header. AR emission is opt-in: +nothing is emitted unless `authservid` is supplied. Alternatively, a +policy hook can omit `authservid` and build a combined +`Authentication-Results:` header later, merging DKIM2 results with those +from other authentication methods (SPF, DKIM1, ARC, etc.) into a single +header. ### Verify options @@ -451,7 +457,7 @@ where `verify()` was never called, will receive `""` from `msg:context_get` — not a verdict string. Always guard with a nil / empty check before acting on the value. -`dkim2_overall` is one of the six verdict strings above. +`dkim2_overall` is one of the verdict strings above. `dkim2_n_sigs` is the count of `DKIM2-Signature` headers verified (string; parse with `tonumber()`). @@ -508,13 +514,13 @@ if overall == "permerror" or overall == "chain_broken" or overall == "fail" then -- §9.4 SHOULD 550/5.7.x for permanent failures. -- Note: "permerror" and "chain_broken" MUST NOT use 4xx. - vctx:set_code(550, "5.7.1", "DKIM2 verification failed") - return msys.core.SMFIS_REJECT + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + return msys.core.VALIDATE_DONE elseif overall == "temperror" then -- §9.4 MAY 451/4.7.5 for transient key-fetch failures - vctx:set_code(451, "4.7.5", "DKIM2 key server temporarily unavailable") - return msys.core.SMFIS_TEMPFAIL + vctx:set_code(451, "4.7.5 DKIM2 key server temporarily unavailable") + return msys.core.VALIDATE_DONE else -- pass / none: local policy @@ -549,6 +555,13 @@ already does DKIM1 signing, downstream verifiers that don't know about DKIM2 will simply ignore the new header — they will continue to verify the DKIM1 signature normally. +ARC also coexists with DKIM2 without conflict: ARC uses its own header +set (`ARC-Seal:`, `ARC-Message-Signature:`, `ARC-Authentication-Results:`) +and an independent chain model. A message can carry DKIM1, DKIM2, and ARC +headers simultaneously. Momentum's ARC module (`msys.validate.openarc`) +and the DKIM2 module operate independently — enabling one does not affect +the other. Receivers that support both will evaluate each chain separately. + ### Interoperability From 606989016ec1da7d08bbe3f0dfd270cbbb9f68ae Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 16:32:11 +0000 Subject: [PATCH 16/52] update signing hook guidance and rt= options for multi-recipient default --- content/momentum/4/dkim2.md | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index fb3431090..d6655894f 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -99,17 +99,39 @@ The `debug_level` option is documented in the ## DKIM2 Signing DKIM2 signing in Momentum is driven from Lua policy via -`msys.validate.dkim2.sign`; enabling DKIM2 means calling `sign()` from +`msys.validate.dkim2.sign`; enabling DKIM2 signing means calling `sign()` from your validation hook. +### Signing hook: shared vs. per-recipient + +`sign()` can be called from either the shared hook (`validate_data_spool`) +or the per-recipient hook (`validate_data_spool_each_rcpt`). Both are valid; +the difference is how the `rt=` envelope-recipient list is populated: + +- **`validate_data_spool_each_rcpt`** — fires once per recipient on each + per-recipient copy (cowref). `sign()` with no `rcpt` option produces a + signature whose `rt=` contains that single recipient. Each delivered copy + carries an independent signature bound to its own recipient. This is the + most restrictive form of replay protection. + +- **`validate_data_spool`** — fires once on the shared parent message before + the cowref split. `sign()` with no `rcpt` option enumerates **all** + envelope recipients and produces a signature whose `rt=` is a + comma-separated list of every intended recipient. The same signed message + is then cloned to every cowref. A replay to any address not in the original + list still fails; delivering to a subset of the original recipients is + legitimate and verifies correctly. + +Both approaches comply with §7.6. Choose `validate_data_spool_each_rcpt` +when you need each signature to be exclusive to one recipient; +`validate_data_spool` is simpler and sufficient for most deployments. + ### Warning -Always call DKIM2 signing from the **per-recipient** validation hook -(`validate_data_spool_each_rcpt`), not from `validate_data_spool`. The -latter fires once on the shared parent message before per-recipient copies -are split off, and the resulting signature would commit to a single -recipient binding and then be cloned across every delivered copy — -defeating DKIM2's per-recipient replay protection. +Passing an explicit `rcpt` option overrides the automatic recipient +enumeration. If you supply a single address, the signature commits only to +that address and will not cover any other recipients. Omit `rcpt` to get +guaranteed spec-compliant `rt=`. ### Minimum signer From b425b88b54277a82ebec59c13da8341ff8e5aca3 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 16:54:28 +0000 Subject: [PATCH 17/52] clarify that verify() can be called from both validate_data_spool_* hooks --- content/momentum/4/dkim2.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index d6655894f..09cea34c5 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -257,17 +257,12 @@ edits) omit `recipe` entirely. ## DKIM2 Verifying -### Warning - -Always call DKIM2 verification from the **per-recipient** validation hook -(`validate_data_spool_each_rcpt`), not from `validate_data_spool`. The -`rt=` binding check compares the signed recipient against the actual -envelope RCPT TO. If `verify()` runs on the shared parent message before per-recipient -copies are split off, the envelope RCPT TO resolves to only the first -recipient and all other copies pass or fail based on that single address -— defeating the per-recipient replay protection. - DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. +`verify()` can be called from either `validate_data_spool` or +`validate_data_spool_each_rcpt`. The `rt=` binding check compares the +actual envelope RCPT TO against the signed `rt=` list — a match passes, +no match fails. Either hook correctly rejects a replay to an address not +in the original `rt=` list. Typical inbound policy: ```lua From 3d98a7f7d59c0f88083240eb6bd24e003c9fa9a6 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 19:16:34 +0000 Subject: [PATCH 18/52] update --- content/momentum/4/dkim2.md | 144 +++++++++++++++++++----------------- 1 file changed, 77 insertions(+), 67 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 09cea34c5..2d56c8484 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -331,8 +331,7 @@ header. | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | | `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. **For testing only** — mirrors sign()'s `mailfrom=` option; useful for simulating specific envelope conditions without real SMTP transit. | | `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. **For testing only** — in production the envelope RCPT TO is always read from the message automatically and this option should not be set. | -| `authservid` | If set and no `Authentication-Results:` header already exists, a new one is created with this value as the authentication service identifier. Not required when an AR header is already present — the dkim2 results are appended to it automatically. | -| `skip_ar_header_update` | If `true`, suppress all AR output. Use this when the policy stamps AR itself. | +| `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Per RFC 8601 §5, existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (strict — mismatched signing domain fails verification). | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | | `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag (for interop with non-spec-compliant signers). Default `false` — duplicates produce `reason=parse_error` per §7.8. | @@ -357,7 +356,7 @@ result = { | "donotmodify_violated" -- §7.7 domain alignment failed | "donotexplode_violated", -- §10.8 flag violation signatures = { - { seq = , + { seq = , status = "pass" -- signature verified | "fail" -- signature failed; see reason | "chain_verified" -- earlier hop (i SMTP response codes (§9.4 guidance) + +Momentum leaves the decision of whether to accept, reject, or defer a +message — and which SMTP reply code to use — entirely to the operator's +Lua hook. The `overall` field of the verify result maps to the following +SMTP behaviour as required by §9.4 of the DKIM2 spec: + +| `overall` | Meaning | §9.4 guidance | Suggested action | +|---|---|---|---| +| `pass` | All verifiable signatures passed | — | Accept | +| `none` | No DKIM2 signatures present | — | Local policy | +| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | +| `permerror` | Could not verify: key missing, revoked, or invalid; signature syntax error (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | +| `chain_broken` | Chain-of-custody or MI integrity failure | SHOULD 550/5.7.x | Reject (permanent) | +| `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | + +**Key rule from §9.4**: permanent failures (`permerror`, `chain_broken`) +**MUST NOT** result in a 4xx (temporary) SMTP reply. Only `temperror` +warrants a temporary failure code. + +Example hook skeleton: + +```lua +local result = msys.validate.dkim2.verify(msg, vctx, { ... }) +local overall = result and result.overall or "none" + +if overall == "permerror" or overall == "chain_broken" or + overall == "fail" then + -- §9.4 SHOULD 550/5.7.x for permanent failures. + -- Note: "permerror" and "chain_broken" MUST NOT use 4xx. + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + return msys.core.VALIDATE_DONE + +elseif overall == "temperror" then + -- §9.4 MAY 451/4.7.5 for transient key-fetch failures + vctx:set_code(451, "4.7.5 DKIM2 key server temporarily unavailable") + return msys.core.VALIDATE_DONE + +else + -- pass / none: local policy + return msys.core.VALIDATE_CONT +end +``` + +> **Note**: Whether to reject on `fail` or `none` is a local policy +> decision. The spec only mandates the reply-code *type* (4xx vs 5xx) +> for the cases shown above. + + ## Debugging Setting `debug_level` on the `dkim2` configuration stanza routes sign and @@ -397,15 +445,12 @@ dkim2 { } ``` -`error` (the default) surfaces only failures and resolver problems. -`warning` adds DNS issues and SHOULD-violation warnings. `info` adds one -DNS resolution line per verified signature plus any verification failure -with its cause (`bh_mismatch` with expected vs. actual hash, `sig_invalid` -with selector, algorithm, signed-input length, and OpenSSL detail). -`debug` adds raw TXT-record bytes from the resolver, a per-crypto-check -trace line, and the raw signed-input bytes on failure — too noisy for -steady-state production but useful when chasing a specific sign/verify -mismatch. +| Level | What surfaces | +|---|---| +| `error` | Failures and resolver problems only. **Default.** | +| `warning` | Adds DNS issues and SHOULD-violation warnings. | +| `info` | Adds one DNS resolution line per verified signature plus verification failures with their cause (`bh_mismatch` with expected vs. actual hash; `sig_invalid` with selector, algorithm, signed-input length, and OpenSSL detail). | +| `debug` | Adds raw TXT-record bytes from the resolver, a per-crypto-check trace line, and the raw signed-input bytes on failure. Too noisy for steady-state production; useful when chasing a specific sign/verify mismatch. | ### Per-signature `reason` codes @@ -480,10 +525,10 @@ check before acting on the value. ### Authentication-Results output -`verify()` appends its results to the existing `Authentication-Results:` -header when one is already present (e.g. stamped by SPF or DKIM1 -earlier in the pipeline), or creates a new one if `authservid` is -supplied and none exists yet: +`verify()` always prepends a **new** `Authentication-Results:` header +when `authservid` is supplied. Per RFC 8601 §5, an MTA MUST NOT add +results to an existing header field, so any prior AR headers (e.g. from +SPF or DKIM1) are left untouched. ``` Authentication-Results: ; @@ -492,62 +537,27 @@ Authentication-Results: ; header.rt= ``` -One `dkim2=` clause per **directly-verified** signature. Per `-02` §10.5, -only the most-recently-applied signature (highest `i=`) receives full -cryptographic verification; earlier hops are deferred to the §10.6 -recipe-chain check and omitted from the AR header entirely. A two-hop -message therefore produces exactly one `dkim2=` clause (for the i=N sig), -not two. The `reason=` field appears only on failures and uses the per-sig -reason codes from the table above. - - -## SMTP response codes (§9.4 guidance) - -Momentum leaves the decision of whether to accept, reject, or defer a -message — and which SMTP reply code to use — entirely to the operator's -Lua hook. The `overall` field of the verify result maps to the following -SMTP behaviour as required by §9.4 of the DKIM2 spec: - -| `overall` | Meaning | §9.4 guidance | Suggested action | -|---|---|---|---| -| `pass` | All verifiable signatures passed | — | Accept | -| `none` | No DKIM2 signatures present | — | Local policy | -| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | -| `permerror` | Could not verify: key missing, revoked, or invalid; signature syntax error (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | -| `chain_broken` | Chain-of-custody or MI integrity failure | SHOULD 550/5.7.x | Reject (permanent) | -| `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | - -**Key rule from §9.4**: permanent failures (`permerror`, `chain_broken`) -**MUST NOT** result in a 4xx (temporary) SMTP reply. Only `temperror` -warrants a temporary failure code. - -Example hook skeleton: +One `dkim2=` clause per directly-verified signature (deferred hops are +omitted). The `reason=` field appears only on failures. When the overall +verdict is `chain_broken` or a policy downgrade (`d=/mf=` mismatch, +`donotmodify`, etc.) occurs after a crypto pass, an additional overall +clause is appended so the real verdict is always visible to AR consumers. -```lua -local result = msys.validate.dkim2.verify(msg, vctx, { ... }) -local overall = result and result.overall or "none" +Chain-broken example (crypto passed but recipe-chain check failed): -if overall == "permerror" or overall == "chain_broken" or - overall == "fail" then - -- §9.4 SHOULD 550/5.7.x for permanent failures. - -- Note: "permerror" and "chain_broken" MUST NOT use 4xx. - vctx:set_code(550, "5.7.1 DKIM2 verification failed") - return msys.core.VALIDATE_DONE +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.i=2; + dkim2=permerror reason="chain of custody broken" +``` -elseif overall == "temperror" then - -- §9.4 MAY 451/4.7.5 for transient key-fetch failures - vctx:set_code(451, "4.7.5 DKIM2 key server temporarily unavailable") - return msys.core.VALIDATE_DONE +Policy-downgrade example (`d=` does not match the `mf=` domain): -else - -- pass / none: local policy - return msys.core.VALIDATE_CONT -end ``` - -> **Note**: Whether to reject on `fail` or `none` is a local policy -> decision. The spec only mandates the reply-code *type* (4xx vs 5xx) -> for the cases shown above. +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.i=1; + dkim2=fail reason="MAIL FROM and d= do not match" +``` ## Key management From 495c64cd162f983c84a064496b7a6fdbc5818ed4 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 19:56:50 +0000 Subject: [PATCH 19/52] remove interoperability part --- content/momentum/4/dkim2.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 2d56c8484..71c4b9d31 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -590,16 +590,6 @@ and the DKIM2 module operate independently — enabling one does not affect the other. Receivers that support both will evaluate each chain separately. -### Interoperability - -The full DKIM2 logical flow is implemented and exercised end-to-end: - -* Per-signature envelope binding (`mf=` / `rt=`) -* §8.3 chain-of-custody check -* §10.5 most-recent-only cryptographic verification -* §10.6 recipe-chain reconstruction - - ## Known limitations The following features are not yet implemented and may be addressed in a From 3dd7a84361acb5146a5e81609f5c87146be99099 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 20:43:13 +0000 Subject: [PATCH 20/52] add keybuf support for sign() as alternative to keyfile --- content/momentum/4/dkim2.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 71c4b9d31..1105fae8f 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -200,9 +200,10 @@ are header-level and go at the top level of the options table. |---|---|---| | `domain` | yes | `d=` tag — the signing domain. | | `selector` | yes (single) | Selector component of `s=::`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | -| `keyfile` | yes (single) | Path to the PEM-encoded private key. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keyfile` | yes (single) | Path to the PEM-encoded private key on disk. Mutually exclusive with `keybuf`; one of the two is required. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. | | `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | -| `sig_sets` | no | Array of `{selector, keyfile, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`algorithm` at the top level are ignored. | +| `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`keybuf`/`algorithm` at the top level are ignored. | | `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder. | | `rcpt` | no | Override the envelope RCPT TO for the `rt=` tag. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | From ded3dc8660dc250ddc5072a3318f4a30c950ea61 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 10 Jun 2026 21:27:11 +0000 Subject: [PATCH 21/52] clarify forwarder auto-detection limitation --- content/momentum/4/dkim2.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 1105fae8f..9922fcd29 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -603,10 +603,13 @@ future release: Inbound DSN authentication (§11.1.2) is also not implemented. * **§8.2 Forwarder auto-detection**: The `sign()` API fully supports - co-signing (call it twice to add a forwarder signature), but Momentum - does not auto-detect chain-of-custody breaks. Operators must explicitly - call `sign()` when forwarding changes the MAIL FROM. Full automation - requires the Recipe Accumulator API (planned; not yet available). + co-signing, but Momentum has no built-in trigger that automatically + calls `sign()` when a message is being forwarded. The operator's policy + hook must call `sign()` explicitly to add the forwarder's chain link. + Without it, the chain-of-custody bridge is missing — the receiver sees + a signature from the original sender with no subsequent hop vouching + for the delivery path. Full automation requires the Recipe Accumulator + API (planned; not yet available). ### Warning From 7d95eb29758cb18b05a7333ef030f3680d120116 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 00:23:19 +0000 Subject: [PATCH 22/52] update BCC privacy --- content/momentum/4/dkim2.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 9922fcd29..dd1bca8f8 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -128,6 +128,13 @@ when you need each signature to be exclusive to one recipient; ### Warning +**BCC privacy (§7.6)**: when signing from `validate_data_spool`, the `rt=` +list includes **all** envelope recipients. BCC addresses would appear in the +`DKIM2-Signature:` header on every delivered copy, exposing them to TO and +CC recipients. If your deployment uses BCC, sign from +`validate_data_spool_each_rcpt` instead — each cowref carries only its own +recipient in `rt=`, so no address is ever visible to another recipient. + Passing an explicit `rcpt` option overrides the automatic recipient enumeration. If you supply a single address, the signature commits only to that address and will not cover any other recipients. Omit `rcpt` to get From caee2dceedcf27bf35a5de93a77b47ee614ee988 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 01:30:25 +0000 Subject: [PATCH 23/52] more update --- content/momentum/4/dkim2.md | 60 ++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index dd1bca8f8..073c9a232 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -216,7 +216,7 @@ are header-level and go at the top level of the options table. | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §8.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | -| `flags` | no | Lua array of flag tokens for `f=` (`-02` §8.9). Recognized values: `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. Joined into the on-wire comma-separated form by the glue layer. | +| `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | | `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` — `sign()` returns an error on mismatch. Set to `true` to downgrade to a `DWARNING` log and proceed, for configurations where the signing domain intentionally differs from the envelope domain. | | `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | @@ -297,9 +297,8 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) -- "fail" verified but wrong: hash/sig mismatch or policy -- violation (d=/mf= mismatch, donotmodify, etc.) -- "permerror" could not verify: key missing/invalid/revoked, - -- signature syntax error - -- "chain_broken" i= gap, or §8.3 mf=/rt= bridge broken, or §10.6 - -- recipe-chain reconstruction didn't match MI[1] + -- signature syntax error, or chain integrity failure + -- (result.overall_reason == "chain_broken") -- "temperror" resolver-side transient failure (SERVFAIL, timeout) -- "none" no DKIM2-Signature headers on the message @@ -310,9 +309,7 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") end - if result.overall == "chain_broken" or - result.overall == "fail" or - result.overall == "permerror" then + if result.overall == "fail" or result.overall == "permerror" then vctx:set_code(550, "5.7.1 DKIM2 verification failed") end @@ -341,8 +338,8 @@ header. | `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. **For testing only** — in production the envelope RCPT TO is always read from the message automatically and this option should not be set. | | `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Per RFC 8601 §5, existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (strict — mismatched signing domain fails verification). | -| `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). | -| `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag (for interop with non-spec-compliant signers). Default `false` — duplicates produce `reason=parse_error` per §7.8. | +| `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | +| `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag. Default `false` — duplicates produce `reason=parse_error` per §7.8. **Setting this to `true` makes the verifier non-spec-compliant** — §7.8 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | | `max_sig_age_days` | §10.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | | `max_sig_future_secs` | §7.4: reject signatures whose `t=` timestamp is more than this many seconds in the future. Default `300` (5-minute clock-skew tolerance). Values `<= 0` disable the check. | | `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message. Useful for staging and debugging; **do not enable in production** as these headers expose internal verification detail and inflate message size. Default `false`. | @@ -356,15 +353,19 @@ result = { | -- policy violation (d=/mf= mismatch, donotmodify, etc.) | "permerror" -- could not verify: key missing/revoked/invalid, | -- or signature syntax error (§10.1 PERMERROR) - | "chain_broken" -- chain-of-custody or MI integrity failure + | "chain_broken" -- overall_reason when permerror is due to chain integrity | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) | "none", -- no DKIM2 signatures present - overall_reason = nil -- nil unless overall="fail" due to a - | "d_mf_mismatch" -- policy downgrade after crypto pass: - | "donotmodify_violated" -- §7.7 domain alignment failed - | "donotexplode_violated", -- §10.8 flag violation + overall_reason = nil -- nil unless overall="fail" due to a + -- policy downgrade after crypto pass: + | "d_mf_mismatch" -- d= doesn't match mf= domain (§7.7) + | "donotmodify_violated" -- f=donotmodify sig followed by a hop + -- that modified the message (§10.8) + | "donotexplode_violated", -- f=donotexplode sig followed by a + -- sig with f=exploded (§10.8) signatures = { { seq = , + m = , status = "pass" -- signature verified | "fail" -- signature failed; see reason | "chain_verified" -- earlier hop (i`. The chain-check failure does NOT appear in the per-signature result struct — it's a cross-hop verdict, not a per-signature outcome — so paniclog is the @@ -546,8 +546,14 @@ Authentication-Results: ; ``` One `dkim2=` clause per directly-verified signature (deferred hops are -omitted). The `reason=` field appears only on failures. When the overall -verdict is `chain_broken` or a policy downgrade (`d=/mf=` mismatch, +omitted). The `reason=` field appears only on failures; it carries a +simplified human-readable string rather than the fully-interpolated +template from §10.1 (e.g. `reason="body hash mismatch"` instead of +`FAIL: Message Instance m= body hash mismatch`). The +structured property tokens — `header.i=`, `header.m=`, `header.d=`, +`header.s=` — provide the ordinal and key values in machine-readable +form. When the overall +verdict is `permerror` with `overall_reason="chain_broken"`, or a policy downgrade (`d=/mf=` mismatch, `donotmodify`, etc.) occurs after a crypto pass, an additional overall clause is appended so the real verdict is always visible to AR consumers. From 2e8d7e4a90b74b456b0887aca8f534cf3e456d02 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 14:30:55 +0000 Subject: [PATCH 24/52] key_service_mismatch --- content/momentum/4/dkim2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 073c9a232..1b2390ac7 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -484,6 +484,7 @@ Every signature on a verified message gets a `reason` string in | `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | | `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | | `key_multiple_records` | DNS returned more than one TXT record for the selector (§10.5 MUST treat as PERMERROR). DNS admin misconfiguration on the sender side — only one TXT record is allowed. | +| `key_service_mismatch` | The DNS TXT record's `s=` service list does not include `email` or `*` (RFC 6376 §3.6.1). The key is published for a different service. | | `key_invalid` | The DNS TXT record was present but structurally unusable (empty content, internal resolver error, or selector/domain too long to query). | | `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | | `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | From 7e51464061527cd4f033ef3d369140cfc6e3c582 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 15:31:31 +0000 Subject: [PATCH 25/52] consolidate signing hook comparison into table --- content/momentum/4/dkim2.md | 43 +++++++++++-------------------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 1b2390ac7..cd6b12f7b 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -104,41 +104,24 @@ your validation hook. ### Signing hook: shared vs. per-recipient -`sign()` can be called from either the shared hook (`validate_data_spool`) -or the per-recipient hook (`validate_data_spool_each_rcpt`). Both are valid; -the difference is how the `rt=` envelope-recipient list is populated: - -- **`validate_data_spool_each_rcpt`** — fires once per recipient on each - per-recipient copy (cowref). `sign()` with no `rcpt` option produces a - signature whose `rt=` contains that single recipient. Each delivered copy - carries an independent signature bound to its own recipient. This is the - most restrictive form of replay protection. - -- **`validate_data_spool`** — fires once on the shared parent message before - the cowref split. `sign()` with no `rcpt` option enumerates **all** - envelope recipients and produces a signature whose `rt=` is a - comma-separated list of every intended recipient. The same signed message - is then cloned to every cowref. A replay to any address not in the original - list still fails; delivering to a subset of the original recipients is - legitimate and verifies correctly. - -Both approaches comply with §7.6. Choose `validate_data_spool_each_rcpt` -when you need each signature to be exclusive to one recipient; -`validate_data_spool` is simpler and sufficient for most deployments. +`sign()` can be called from either hook. The choice affects how `rt=` is +populated and whether BCC addresses are exposed. -### Warning +| | `validate_data_spool` | `validate_data_spool_each_rcpt` | +|---|---|---| +| **Fires** | Once on shared parent message | Once per recipient (cowref) | +| **`rt=` default** | All envelope recipients (comma-separated list) | Single cowref recipient | +| **Replay protection** | Rejects delivery to any address not in the original list | Each copy bound exclusively to its own recipient | +| **BCC privacy** | ⚠️ BCC addresses appear in `rt=` on every copy, visible to TO/CC recipients | ✅ Each copy carries only its own recipient; no address leaks to others | +| **Complexity** | Simpler — one `sign()` call per message | One `sign()` call per recipient | -**BCC privacy (§7.6)**: when signing from `validate_data_spool`, the `rt=` -list includes **all** envelope recipients. BCC addresses would appear in the -`DKIM2-Signature:` header on every delivered copy, exposing them to TO and -CC recipients. If your deployment uses BCC, sign from -`validate_data_spool_each_rcpt` instead — each cowref carries only its own -recipient in `rt=`, so no address is ever visible to another recipient. +Use `validate_data_spool_each_rcpt` when your deployment uses BCC or when +you need each signature to be exclusive to one recipient. `validate_data_spool` +is sufficient for TO/CC-only delivery. Passing an explicit `rcpt` option overrides the automatic recipient enumeration. If you supply a single address, the signature commits only to -that address and will not cover any other recipients. Omit `rcpt` to get -guaranteed spec-compliant `rt=`. +that address and will not cover any other recipients. ### Minimum signer From 7d5f5341a7835402cbe038df70b56523cf79dd2d Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 17:02:51 +0000 Subject: [PATCH 26/52] operator-facing doc revisions, known limitations, AR output and context field clarifications --- content/momentum/4/dkim2.md | 196 ++++++++++++++++++++++-------------- 1 file changed, 122 insertions(+), 74 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index cd6b12f7b..bf8251f6f 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -4,6 +4,28 @@ title: "Using DKIM2 (DomainKeys Identified Mail v2) Signatures" description: "DKIM2 is the successor to DKIM that adds replay protection (per-message envelope binding), an explicit chain of custody across forwarders, and a structured way for modifying hops to record what they changed. Momentum implements DKIM2 targeting draft-ietf-dkim-dkim2-spec-02." --- +## On This Page + +- [What DKIM2 is, and why](#dkim2_intro) +- [How it differs from DKIM1 at a glance](#dkim2_atglance) +- [Enabling the module](#dkim2_config) +- [DKIM2 Signing](#dkim2_signing) + - [Signing hook: shared vs. per-recipient](#signing-hook-shared-vs-per-recipient) + - [Sign options](#sign-options) + - [Forwarder / modifier signing](#forwarder--modifier-signing) +- [DKIM2 Verifying](#dkim2_verifying) + - [Verify options](#verify-options) + - [Result table](#result-table) + - [SMTP response codes](#dkim2_smtp_codes) +- [Authentication-Results output](#authentication-results-output) +- [Debugging](#dkim2_debugging) + - [Per-signature reason codes](#per-signature-reason-codes) + - [recipe_chain: detail strings](#recipe_chain-detail-strings-paniclog-only) + - [ec_message context fields](#ec_message-context-fields) +- [Key management](#dkim2_key_management) +- [Known limitations](#dkim2_caveats) + +--- ### Warning @@ -104,8 +126,9 @@ your validation hook. ### Signing hook: shared vs. per-recipient -`sign()` can be called from either hook. The choice affects how `rt=` is -populated and whether BCC addresses are exposed. +`sign()` can be called from either `validate_data_spool` or +`validate_data_spool_each_rcpt`. The choice affects how `rt=` is populated +and whether BCC addresses are exposed. | | `validate_data_spool` | `validate_data_spool_each_rcpt` | |---|---|---| @@ -201,7 +224,7 @@ are header-level and go at the top level of the options table. | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | | `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | -| `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` — `sign()` returns an error on mismatch. Set to `true` to downgrade to a `DWARNING` log and proceed, for configurations where the signing domain intentionally differs from the envelope domain. | +| `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` (spec-compliant — `sign()` returns an error on mismatch). **Setting to `true` is non-spec-compliant**; it downgrades the check to a `DWARNING` and proceeds. Recommended only for testing or debugging cross-domain signing configurations. | | `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | | `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | @@ -216,8 +239,11 @@ A forwarder that **re-routes** a message (different envelope) signs with explicit overrides so the §8.3 chain-of-custody check downstream succeeds: ```lua --- Hop 2 (forwarder) — its mf= must appear in the upstream rt= list, --- and its rt= is the new downstream recipient. +-- Hop 2 (forwarder): mf= is the forwarder's own bounce address (must +-- match the rt= in hop 1's signature that listed this forwarder as a +-- recipient); rt= is the new downstream recipient. Using the forwarder's +-- own envelope values here does not break the chain — it builds the next +-- link correctly. msys.validate.dkim2.sign(msg, vctx, { domain = "forwarder.example.net", selector = "fwd-2026", @@ -319,8 +345,8 @@ header. | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | | `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. **For testing only** — mirrors sign()'s `mailfrom=` option; useful for simulating specific envelope conditions without real SMTP transit. | | `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. **For testing only** — in production the envelope RCPT TO is always read from the message automatically and this option should not be set. | -| `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Per RFC 8601 §5, existing AR headers are never modified. When absent, no AR header is emitted. | -| `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (strict — mismatched signing domain fails verification). | +| `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | +| `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | | `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag. Default `false` — duplicates produce `reason=parse_error` per §7.8. **Setting this to `true` makes the verifier non-spec-compliant** — §7.8 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | | `max_sig_age_days` | §10.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | @@ -368,16 +394,20 @@ result = { } ``` -Per `-02` §10.5, **only the most-recently-applied signature** (highest -`i=`) gets full cryptographic verification against the current message -state. Earlier signatures are tracked but marked -`status="chain_verified", reason="deferred"` — the §10.6 recipe-chain -check is the authoritative integrity signal for those hops, not their -own cryptographic verify (which would naturally fail for any modified -message). +For messages that passed through multiple signing hops, Momentum verifies +the **most recent signature** cryptographically (§10.5) and confirms the +**full chain of custody** end-to-end (§10.6). Earlier signatures in a +multi-hop message appear in `result.signatures` with +`status="chain_verified"` — this means Momentum validated that each +intermediate hop correctly recorded what it changed, and that those +changes are consistent all the way back to the original sender. If +anything in that chain is wrong (a hop modified the message without +recording it, or a recipe was incorrect), `overall` is `permerror` with +`overall_reason="chain_broken"`. You do not need to do anything special +in policy code — `overall="pass"` means the entire history checked out. -## SMTP response codes (§9.4 guidance) +### SMTP response codes (§9.4 guidance) Momentum leaves the decision of whether to accept, reject, or defer a message — and which SMTP reply code to use — entirely to the operator's @@ -390,7 +420,6 @@ SMTP behaviour as required by §9.4 of the DKIM2 spec: | `none` | No DKIM2 signatures present | — | Local policy | | `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | | `permerror` | Could not verify: key missing/revoked/invalid, syntax error, or chain integrity failure (`overall_reason="chain_broken"`) (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | - | `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | **Key rule from §9.4**: `permerror` **MUST NOT** result in a 4xx (temporary) @@ -445,12 +474,16 @@ dkim2 { ### Per-signature `reason` codes Every signature on a verified message gets a `reason` string in -`result.signatures[i].reason`. The full set: +`result.signatures[i].reason`. These codes are Momentum-internal tokens — +not defined by the DKIM2 spec — but are exposed through the `verify()` API. +They appear in `result.signatures[i].reason`, in the +`X-MSYS-DKIM2-Verify-Sig` debug header, and in `Authentication-Results:` +`reason=` output. Policy code can safely branch on them. The full set: | Reason | Meaning | |---|---| | `ok` | Signature verified cleanly. Paired with `status="pass"`. | -| `deferred` | Earlier signature (i<N) — not directly verified (§10.5); the §10.6 recipe-chain check is the authoritative integrity signal for it. Paired with `status="chain_verified"`. | +| `deferred` | An earlier hop's signature in a multi-hop message. Momentum validates the full chain of custody end-to-end rather than re-running that hop's cryptographic check directly (the content was legitimately modified by later hops, so a direct crypto check would always fail). If the chain is intact, `overall="pass"`. Paired with `status="chain_verified"`. | | `hh_mismatch` | Header hash mismatch — a content header (Subject, From, etc.) was modified after signing without a new `Message-Instance:` recording the change. | | `bh_mismatch` | Body hash mismatch — the message body was modified after signing without a new `Message-Instance:` recording the change. | | `sig_invalid` | Cryptographic verification failed — the signed-input bytes don't match the value in `s=`. Enable `debug_level = info` for selector, algorithm, and signed-input length detail. | @@ -466,7 +499,7 @@ Every signature on a verified message gets a `reason` string in | `no_key` | DNS returned NXDOMAIN — no TXT record exists for the selector. | | `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | | `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | -| `key_multiple_records` | DNS returned more than one TXT record for the selector (§10.5 MUST treat as PERMERROR). DNS admin misconfiguration on the sender side — only one TXT record is allowed. | +| `key_multiple_records` | DNS returned more than one TXT record for the selector (§10.5). DNS admin misconfiguration on the sender side — only one TXT record is allowed per selector. | | `key_service_mismatch` | The DNS TXT record's `s=` service list does not include `email` or `*` (RFC 6376 §3.6.1). The key is published for a different service. | | `key_invalid` | The DNS TXT record was present but structurally unusable (empty content, internal resolver error, or selector/domain too long to query). | | `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | @@ -496,50 +529,38 @@ only place this detail surfaces. ### `ec_message` context fields -For downstream Lua hooks that need to know what the verifier decided -without re-verifying or re-parsing AR: +`verify()` writes the following context variables so downstream hooks can +read the outcome without re-verifying or parsing `Authentication-Results:`: -```lua -local overall = msg:context_get(msys.core.ECMESS_CTX_MESS, "dkim2_overall") -local n_sigs = msg:context_get(msys.core.ECMESS_CTX_MESS, "dkim2_n_sigs") -``` - -Both fields are written by `msys.validate.dkim2.verify()` at the moment -it runs. **They are empty strings until `verify()` has been called on -that message.** Hooks that execute before verification, or on messages -where `verify()` was never called, will receive `""` from -`msg:context_get` — not a verdict string. Always guard with a nil / empty -check before acting on the value. +| Context key | Type | Value | +|---|---|---| +| `dkim2_overall` | string | Verdict: `"pass"`, `"fail"`, `"permerror"`, `"temperror"`, or `"none"`. See the verdict table above. | +| `dkim2_n_sigs` | string | Number of `DKIM2-Signature` headers found on the message. Parse with `tonumber()`. | -`dkim2_overall` is one of the verdict strings above. -`dkim2_n_sigs` is the count of `DKIM2-Signature` headers verified -(string; parse with `tonumber()`). +These keys are not set until `verify()` runs. ### Authentication-Results output `verify()` always prepends a **new** `Authentication-Results:` header -when `authservid` is supplied. Per RFC 8601 §5, an MTA MUST NOT add -results to an existing header field, so any prior AR headers (e.g. from -SPF or DKIM1) are left untouched. +when `authservid` is supplied; any prior AR headers (e.g. from SPF or +DKIM1) are left untouched (see RFC 8601 §5 note above). + +Normal pass: ``` -Authentication-Results: ; - dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256: - header.i=1 header.mf= - header.rt= +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-1 header.i=1 header.m=1 + header.mf= header.rt= ``` -One `dkim2=` clause per directly-verified signature (deferred hops are -omitted). The `reason=` field appears only on failures; it carries a -simplified human-readable string rather than the fully-interpolated -template from §10.1 (e.g. `reason="body hash mismatch"` instead of -`FAIL: Message Instance m= body hash mismatch`). The -structured property tokens — `header.i=`, `header.m=`, `header.d=`, -`header.s=` — provide the ordinal and key values in machine-readable -form. When the overall -verdict is `permerror` with `overall_reason="chain_broken"`, or a policy downgrade (`d=/mf=` mismatch, -`donotmodify`, etc.) occurs after a crypto pass, an additional overall -clause is appended so the real verdict is always visible to AR consumers. +Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=fail reason="body hash mismatch" header.d=example.com header.i=1 +``` + +When the overall verdict is worse than the per-sig result — chain failure or policy downgrade after a crypto pass — an extra overall clause is appended: Chain-broken example (crypto passed but recipe-chain check failed): @@ -561,7 +582,8 @@ Authentication-Results: mta-1.example.com; ## Key management DKIM2 reuses the DKIM1 key infrastructure. Keys are PEM-encoded RSA or -Ed25519 private keys on disk; the matching public key is published in DNS +Ed25519 private keys, supplied either as a file path (`keyfile`) or as +raw PEM bytes in memory (`keybuf`). The matching public key is published in DNS at `._domainkey.` as a TXT record with the standard RFC 6376 §3.6.1 format (`v=DKIM1; k=rsa; p=`). @@ -599,25 +621,51 @@ future release: suppress DSN generation when the original sender was `<>` (null sender). Inbound DSN authentication (§11.1.2) is also not implemented. -* **§8.2 Forwarder auto-detection**: The `sign()` API fully supports - co-signing, but Momentum has no built-in trigger that automatically - calls `sign()` when a message is being forwarded. The operator's policy - hook must call `sign()` explicitly to add the forwarder's chain link. - Without it, the chain-of-custody bridge is missing — the receiver sees - a signature from the original sender with no subsequent hop vouching - for the delivery path. Full automation requires the Recipe Accumulator - API (planned; not yet available). - -### Warning - -**§12 Bare CR/LF normalization**: §12 requires signing the message as it -will be received by the verifier. Momentum's -[`rfc2822_lone_lf_in_body`](/momentum/4/config/ref-rfc-2822-lone-lf-in-body) -and -[`rfc2822_lone_lf_in_headers`](/momentum/4/config/ref-rfc-2822-lone-lf-in-headers) -options control bare LF handling. If either is set to `ignore`, DKIM2 -signs bare-LF content as-is; a downstream SMTP hop that normalizes it to -CRLF will silently break the signature. **Set both options to `fix` when -DKIM2 signing is in use.** +* **§8.2 Forwarder auto-detection**: When Momentum acts as a forwarder + or mailing list (changing the envelope MAIL FROM and re-delivering), + the policy hook must explicitly call `sign()` to add the forwarder's + own DKIM2 signature. Momentum does not automatically detect that a + forward is happening and call `sign()` on its own. Without this + explicit call, the receiver only sees the original sender's signature — + it has no way to verify that the forwarder handled the message + correctly. See the *Forwarder / modifier signing* section for how to + do this. + +* **Content modifier recipe composition**: When a Momentum pipeline + stage modifies message content — for example, the engagement tracker + rewriting URLs, a content filter adding a footer, or a list processor + changing headers — the `sign()` call requires a `recipe=` JSON string + describing exactly what changed so that downstream verifiers can + reconstruct the original message state. Currently the operator must + construct this JSON manually and pass it to `sign()`, which is + impractical at scale when multiple stages may modify the message + independently. A planned Recipe Accumulator API will let pipeline + stages record their changes without any DKIM2 knowledge, and `sign()` + will assemble the recipe automatically. Until then, operators must + build the recipe explicitly or use `{"b":null}` to declare the body + irreversible (see the *Forwarder / modifier signing* section). + + Both this limitation and the forwarder auto-detection above are blocked + on the same Recipe Accumulator API (planned; not yet available). + +* **§10.1 AR reason strings use simplified form**: The spec defines + error string templates with interpolated values, e.g. + `"FAIL: Message Instance m= body hash mismatch"`. Momentum + emits simplified strings without the ordinals or hash values, e.g. + `reason="body hash mismatch"`. The full detail is always available + from the message itself — ordinals and key values are in the + `DKIM2-Signature:` and `Message-Instance:` headers, and the structured + AR property tokens (`header.i=`, `header.m=`, `header.d=`, + `header.s=`) repeat them in the AR clause. This is a §10.1 SHOULD — + not a MUST — so verification behaviour is unaffected. + +* **§12 Bare CR/LF normalization**: Momentum's + [`rfc2822_lone_lf_in_body`](/momentum/4/config/ref-rfc-2822-lone-lf-in-body) + and + [`rfc2822_lone_lf_in_headers`](/momentum/4/config/ref-rfc-2822-lone-lf-in-headers) + options control bare LF handling. If either is set to `ignore`, DKIM2 + signs bare-LF content as-is; a downstream SMTP hop that normalizes it + to CRLF will silently break the signature. **Set both options to `fix` + when DKIM2 signing is in use.** From 7fd3bd41c5f9bc2d38f1dc6df8ce23f10a5e1475 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 17:26:15 +0000 Subject: [PATCH 27/52] clarify limitations, add recipe workaround, operator-facing rewrites --- content/momentum/4/dkim2.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index bf8251f6f..e90f527da 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -16,7 +16,7 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [DKIM2 Verifying](#dkim2_verifying) - [Verify options](#verify-options) - [Result table](#result-table) - - [SMTP response codes](#dkim2_smtp_codes) + - [SMTP response codes](#smtp-response-codes-94-guidance) - [Authentication-Results output](#authentication-results-output) - [Debugging](#dkim2_debugging) - [Per-signature reason codes](#per-signature-reason-codes) @@ -612,8 +612,7 @@ the other. Receivers that support both will evaluate each chain separately. ## Known limitations -The following features are not yet implemented and may be addressed in a -future release: +The following are known gaps or operational considerations to be aware of: * **§11 DSN routing**: When generating a Delivery Status Notification, Momentum does not yet address it to the `mf=` address from the @@ -634,16 +633,17 @@ future release: * **Content modifier recipe composition**: When a Momentum pipeline stage modifies message content — for example, the engagement tracker rewriting URLs, a content filter adding a footer, or a list processor - changing headers — the `sign()` call requires a `recipe=` JSON string - describing exactly what changed so that downstream verifiers can - reconstruct the original message state. Currently the operator must - construct this JSON manually and pass it to `sign()`, which is - impractical at scale when multiple stages may modify the message - independently. A planned Recipe Accumulator API will let pipeline - stages record their changes without any DKIM2 knowledge, and `sign()` - will assemble the recipe automatically. Until then, operators must - build the recipe explicitly or use `{"b":null}` to declare the body - irreversible (see the *Forwarder / modifier signing* section). + changing headers — Momentum automatically detects the change and + requires a `recipe=` to proceed without a failure. The practical workaround is to pass + `recipe='{"b":null}'` (declaring the body change irreversible) when + the full diff is not available, or a precise recipe when it is. This + allows signing to succeed; downstream verifiers will accept the + message while understanding that body reconstruction through this hop + is not possible. See the *Forwarder / modifier signing* section for + examples. The missing automation is having pipeline stages record + their changes automatically — a planned Recipe Accumulator API will + do this, letting `sign()` assemble the recipe without operator + involvement. Both this limitation and the forwarder auto-detection above are blocked on the same Recipe Accumulator API (planned; not yet available). From b7236d62cedeac30faded9ca69bf6f3a5269ec83 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 17:44:36 +0000 Subject: [PATCH 28/52] fix links --- content/momentum/4/dkim2.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index e90f527da..4303cff03 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -6,24 +6,24 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me ## On This Page -- [What DKIM2 is, and why](#dkim2_intro) -- [How it differs from DKIM1 at a glance](#dkim2_atglance) -- [Enabling the module](#dkim2_config) -- [DKIM2 Signing](#dkim2_signing) +- [What DKIM2 is, and why](#what-dkim2-is-and-why) +- [How it differs from DKIM1 at a glance](#how-it-differs-from-dkim1-at-a-glance) +- [Enabling the module](#enabling-the-module) +- [DKIM2 Signing](#dkim2-signing) - [Signing hook: shared vs. per-recipient](#signing-hook-shared-vs-per-recipient) - [Sign options](#sign-options) - [Forwarder / modifier signing](#forwarder--modifier-signing) -- [DKIM2 Verifying](#dkim2_verifying) +- [DKIM2 Verifying](#dkim2-verifying) - [Verify options](#verify-options) - [Result table](#result-table) - [SMTP response codes](#smtp-response-codes-94-guidance) - [Authentication-Results output](#authentication-results-output) -- [Debugging](#dkim2_debugging) +- [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - [recipe_chain: detail strings](#recipe_chain-detail-strings-paniclog-only) - [ec_message context fields](#ec_message-context-fields) -- [Key management](#dkim2_key_management) -- [Known limitations](#dkim2_caveats) +- [Key management](#key-management) +- [Known limitations](#known-limitations) --- @@ -42,7 +42,7 @@ not verify messages signed by an earlier release. > upgraded. Messages signed by DKIM1 are unaffected. -## What DKIM2 is, and why +## What DKIM2 is, and why [DKIM1](/momentum/4/using-dkim) (RFC 6376) lets a sending domain attach a cryptographic signature that lets a receiver confirm "this message came from @@ -89,7 +89,7 @@ verdicts, paniclog lines) are inventoried in the [Debugging](/momentum/4/dkim2#dkim2_debugging) section below. -## How it differs from DKIM1 at a glance +## How it differs from DKIM1 at a glance | Concern | DKIM1 (RFC 6376) | DKIM2 (draft `-02`) | |---|---|---| @@ -106,7 +106,7 @@ Sending domains keep their existing DKIM1 keys: DKIM2 uses the same provisioning step to start signing DKIM2. -## Enabling the module +## Enabling the module Add the following stanza to your Momentum configuration before using any DKIM2 Lua API: @@ -118,7 +118,7 @@ dkim2 {} The `debug_level` option is documented in the [Debugging](/momentum/4/dkim2#dkim2_debugging) section. -## DKIM2 Signing +## DKIM2 Signing DKIM2 signing in Momentum is driven from Lua policy via `msys.validate.dkim2.sign`; enabling DKIM2 signing means calling `sign()` from @@ -272,7 +272,7 @@ when the hop modifies content; non-modifying hops (pure-forwarding without edits) omit `recipe` entirely. -## DKIM2 Verifying +## DKIM2 Verifying DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. `verify()` can be called from either `validate_data_spool` or @@ -407,7 +407,7 @@ recording it, or a recipe was incorrect), `overall` is `permerror` with in policy code — `overall="pass"` means the entire history checked out. -### SMTP response codes (§9.4 guidance) +### SMTP response codes (§9.4 guidance) Momentum leaves the decision of whether to accept, reject, or defer a message — and which SMTP reply code to use — entirely to the operator's @@ -453,7 +453,7 @@ end > for the cases shown above. -## Debugging +## Debugging Setting `debug_level` on the `dkim2` configuration stanza routes sign and verify activity to `paniclog`: @@ -579,7 +579,7 @@ Authentication-Results: mta-1.example.com; ``` -## Key management +## Key management DKIM2 reuses the DKIM1 key infrastructure. Keys are PEM-encoded RSA or Ed25519 private keys, supplied either as a file path (`keyfile`) or as @@ -610,7 +610,7 @@ and the DKIM2 module operate independently — enabling one does not affect the other. Receivers that support both will evaluate each chain separately. -## Known limitations +## Known limitations The following are known gaps or operational considerations to be aware of: From 74e45d5b6ba256310f3c817ec8debe7b5da0b4fd Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 17:53:49 +0000 Subject: [PATCH 29/52] fix broken links --- content/momentum/4/dkim2.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 4303cff03..62e791813 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -12,7 +12,7 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [DKIM2 Signing](#dkim2-signing) - [Signing hook: shared vs. per-recipient](#signing-hook-shared-vs-per-recipient) - [Sign options](#sign-options) - - [Forwarder / modifier signing](#forwarder--modifier-signing) + - [Forwarder and modifier signing](#forwarder-and-modifier-signing) - [DKIM2 Verifying](#dkim2-verifying) - [Verify options](#verify-options) - [Result table](#result-table) @@ -20,7 +20,7 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [Authentication-Results output](#authentication-results-output) - [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - - [recipe_chain: detail strings](#recipe_chain-detail-strings-paniclog-only) + - [recipe_chain detail strings](#recipe_chain-detail-strings-paniclog-only) - [ec_message context fields](#ec_message-context-fields) - [Key management](#key-management) - [Known limitations](#known-limitations) @@ -233,7 +233,7 @@ error_string)` on failure. Always check the return; on failure the message is left unmodified (no `DKIM2-Signature:` or `Message-Instance:` is attached) and an error line is also logged to paniclog at level `error`. -### Forwarder / modifier signing +### Forwarder and modifier signing A forwarder that **re-routes** a message (different envelope) signs with explicit overrides so the §8.3 chain-of-custody check downstream succeeds: @@ -471,7 +471,7 @@ dkim2 { | `info` | Adds one DNS resolution line per verified signature plus verification failures with their cause (`bh_mismatch` with expected vs. actual hash; `sig_invalid` with selector, algorithm, signed-input length, and OpenSSL detail). | | `debug` | Adds raw TXT-record bytes from the resolver, a per-crypto-check trace line, and the raw signed-input bytes on failure. Too noisy for steady-state production; useful when chasing a specific sign/verify mismatch. | -### Per-signature `reason` codes +### Per-signature reason codes Every signature on a verified message gets a `reason` string in `result.signatures[i].reason`. These codes are Momentum-internal tokens — @@ -507,7 +507,7 @@ They appear in `result.signatures[i].reason`, in the | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | | `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | -### `recipe_chain:` detail strings (paniclog only) +### recipe_chain detail strings (paniclog only) When the recipe-chain check fails, the overall verdict is `permerror` with `overall_reason="chain_broken"`, and the underlying cause is logged at `error` level in @@ -527,7 +527,7 @@ only place this detail surfaces. | `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should emit `r={"h":null,"b":null}` to declare irreversibility rather than omitting `r=` entirely. | | `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Every non-first MI had a recipe, so the mismatch indicates a hop's recipe was wrong or a hop modified the message after signing. | -### `ec_message` context fields +### ec_message context fields `verify()` writes the following context variables so downstream hooks can read the outcome without re-verifying or parsing `Authentication-Results:`: From a892f7fc4a85154565da153dede85d170c8641fe Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 18:12:02 +0000 Subject: [PATCH 30/52] fix broken links --- content/momentum/4/dkim2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 62e791813..9afc3ff8d 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -20,8 +20,8 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [Authentication-Results output](#authentication-results-output) - [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - - [recipe_chain detail strings](#recipe_chain-detail-strings-paniclog-only) - - [ec_message context fields](#ec_message-context-fields) + - [recipe_chain detail strings](#recipechain-detail-strings-paniclog-only) + - [ec_message context fields](#ecmessage-context-fields) - [Key management](#key-management) - [Known limitations](#known-limitations) From 70537034951c20bdb5220df950bc2a5c05bd87f6 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 18:24:03 +0000 Subject: [PATCH 31/52] fix broken links --- content/momentum/4/dkim2.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 9afc3ff8d..f28f7df15 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -20,8 +20,8 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [Authentication-Results output](#authentication-results-output) - [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - - [recipe_chain detail strings](#recipechain-detail-strings-paniclog-only) - - [ec_message context fields](#ecmessage-context-fields) + - [recipe_chain detail strings](#recipe-chain-detail-strings) + - [ec_message context fields](#ec-message-context-fields) - [Key management](#key-management) - [Known limitations](#known-limitations) @@ -507,6 +507,8 @@ They appear in `result.signatures[i].reason`, in the | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | | `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | + + ### recipe_chain detail strings (paniclog only) When the recipe-chain check fails, the overall verdict is `permerror` @@ -527,6 +529,8 @@ only place this detail surfaces. | `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should emit `r={"h":null,"b":null}` to declare irreversibility rather than omitting `r=` entirely. | | `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Every non-first MI had a recipe, so the mismatch indicates a hop's recipe was wrong or a hop modified the message after signing. | + + ### ec_message context fields `verify()` writes the following context variables so downstream hooks can From 726b14447a7c9949d3ce667a8db97c8fa0311fe1 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 18:49:32 +0000 Subject: [PATCH 32/52] fix broken links --- content/momentum/4/dkim2.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index f28f7df15..4af3e6d91 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -20,7 +20,7 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [Authentication-Results output](#authentication-results-output) - [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - - [recipe_chain detail strings](#recipe-chain-detail-strings) + - [recipe_chain detail strings](#recipe-chain-detail-strings-paniclog-only) - [ec_message context fields](#ec-message-context-fields) - [Key management](#key-management) - [Known limitations](#known-limitations) @@ -507,8 +507,6 @@ They appear in `result.signatures[i].reason`, in the | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | | `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | - - ### recipe_chain detail strings (paniclog only) When the recipe-chain check fails, the overall verdict is `permerror` @@ -529,8 +527,6 @@ only place this detail surfaces. | `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should emit `r={"h":null,"b":null}` to declare irreversibility rather than omitting `r=` entirely. | | `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Every non-first MI had a recipe, so the mismatch indicates a hop's recipe was wrong or a hop modified the message after signing. | - - ### ec_message context fields `verify()` writes the following context variables so downstream hooks can From 325d854d64f2d18fde60c1e990048cb9f426d145 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 19:28:49 +0000 Subject: [PATCH 33/52] some fixes --- content/momentum/4/dkim2.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 4af3e6d91..83e617268 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -220,7 +220,7 @@ are header-level and go at the top level of the options table. | `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder. | | `rcpt` | no | Override the envelope RCPT TO for the `rt=` tag. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | -| `nonce` | no | `n=` value (`-02` §8.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | +| `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | | `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | @@ -361,17 +361,19 @@ result = { | "fail" -- verified but wrong: hash/sig mismatch, or | -- policy violation (d=/mf= mismatch, donotmodify, etc.) | "permerror" -- could not verify: key missing/revoked/invalid, - | -- or signature syntax error (§10.1 PERMERROR) - | "chain_broken" -- overall_reason when permerror is due to chain integrity + | -- signature syntax error, or chain integrity failure + | -- (§10.1 PERMERROR) | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) | "none", -- no DKIM2 signatures present - overall_reason = nil -- nil unless overall="fail" due to a - -- policy downgrade after crypto pass: - | "d_mf_mismatch" -- d= doesn't match mf= domain (§7.7) - | "donotmodify_violated" -- f=donotmodify sig followed by a hop - -- that modified the message (§10.8) - | "donotexplode_violated", -- f=donotexplode sig followed by a - -- sig with f=exploded (§10.8) + overall_reason = nil -- nil in most cases; set when: + | "chain_broken" -- overall="permerror": chain integrity + -- failure (MI gap, recipe mismatch, etc.) + | "d_mf_mismatch" -- overall="fail": d= doesn't match + -- mf= domain after crypto pass (§7.7) + | "donotmodify_violated" -- overall="fail": f=donotmodify sig + -- followed by a modifying hop (§10.8) + | "donotexplode_violated", -- overall="fail": f=donotexplode sig + -- followed by f=exploded (§10.8) signatures = { { seq = , m = , @@ -491,7 +493,7 @@ They appear in `result.signatures[i].reason`, in the | `missing_required_tags` | One or more of the seven required tags (`i=`, `m=`, `t=`, `mf=`, `rt=`, `d=`, `s=`) is absent from the signature. | | `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). | | `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. | -| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§8.3). | +| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§7.3). | | `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM — replay-to-different-sender. | | `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO — replay-to-different-recipient. | | `d_mf_mismatch` | The signing domain `d=` does not match the rightmost labels of the `mf=` domain (§7.7). Only set when `relax_d_mf_check` is not enabled. | @@ -553,6 +555,12 @@ Authentication-Results: mta-1.example.com; header.mf= header.rt= ``` +> **Note on `header.s=`:** RFC 8601 expects the selector name only (e.g. `sel-1`). +> In DKIM2 the `s=` tag encodes selector, algorithm, and signature together +> (`sel-1:rsa-sha256:`), so Momentum's `header.s=` carries that full +> value rather than the bare selector. AR consumers that key on `header.s=` for +> DKIM1-style selector lookups will see the combined string. + Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): ``` From 3d0f95fadba13327538c2a6f886f88dc896c97af Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 20:23:18 +0000 Subject: [PATCH 34/52] update verify() called from validate_data_spool hook --- content/momentum/4/dkim2.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 83e617268..7416d7d1e 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -276,10 +276,17 @@ edits) omit `recipe` entirely. DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. `verify()` can be called from either `validate_data_spool` or -`validate_data_spool_each_rcpt`. The `rt=` binding check compares the -actual envelope RCPT TO against the signed `rt=` list — a match passes, -no match fails. Either hook correctly rejects a replay to an address not -in the original `rt=` list. +`validate_data_spool_each_rcpt`. The choice affects how the §10.4 `rt=` +binding check is performed: + +| | `validate_data_spool` | `validate_data_spool_each_rcpt` | +|---|---|---| +| **Fires** | Once on shared parent message | Once per recipient (cowref) | +| **`rt=` check** | All envelope recipients checked against `rt=` (§10.4 MUST) | Single cowref recipient checked | +| **BCC support** | No — Bcc recipients will not be in `rt=` and will fail the check | Yes — each cowref is checked independently | +| **Complexity** | Simpler — one `verify()` call per message | One `verify()` call per recipient | + +Use `validate_data_spool_each_rcpt` when your deployment uses BCC. Typical inbound policy: ```lua From 315081d2bbd5ebde41ab43d4093546d6e523fa82 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 20:56:38 +0000 Subject: [PATCH 35/52] note auto-exploded heuristic limitation for single-subscriber lists --- content/momentum/4/dkim2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 7416d7d1e..fca658556 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -222,7 +222,7 @@ are header-level and go at the top level of the options table. | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | -| `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. | +| `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. **Note:** the auto-`exploded` heuristic is based solely on recipient count — it triggers when `rt=` contains more than one address. Mailing lists with a single subscriber will not have `"exploded"` added automatically; pass `flags = {"exploded"}` explicitly in that case. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | | `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` (spec-compliant — `sign()` returns an error on mismatch). **Setting to `true` is non-spec-compliant**; it downgrades the check to a `DWARNING` and proceeds. Recommended only for testing or debugging cross-domain signing configurations. | | `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | From aea66b8f896c7cd02eb8cbdda2bc88217a8b95c9 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 11 Jun 2026 21:28:28 +0000 Subject: [PATCH 36/52] nit --- content/momentum/4/dkim2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index fca658556..bddc7d395 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -224,9 +224,9 @@ are header-level and go at the top level of the options table. | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | | `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. **Note:** the auto-`exploded` heuristic is based solely on recipient count — it triggers when `rt=` contains more than one address. Mailing lists with a single subscriber will not have `"exploded"` added automatically; pass `flags = {"exploded"}` explicitly in that case. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | +| `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | | `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` (spec-compliant — `sign()` returns an error on mismatch). **Setting to `true` is non-spec-compliant**; it downgrades the check to a `DWARNING` and proceeds. Recommended only for testing or debugging cross-domain signing configurations. | | `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | -| `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | `sign()` returns `(true, header_value_string)` on success and `(nil, error_string)` on failure. Always check the return; on failure the message From c5eee0507f6bfc285bf3529aff5b25c572d53a1d Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 03:39:42 +0000 Subject: [PATCH 37/52] update overall_reason --- content/momentum/4/dkim2.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index bddc7d395..ac51b565f 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -372,7 +372,13 @@ result = { | -- (§10.1 PERMERROR) | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) | "none", -- no DKIM2 signatures present - overall_reason = nil -- nil in most cases; set when: + overall_reason = nil -- nil when overall="pass", or when + -- overall is non-pass due to per-sig + -- failures (key errors, bad crypto, + -- syntax errors) — in that case check + -- result.signatures[i].reason for detail. + -- Non-nil only for structural conditions + -- that apply to the chain as a whole: | "chain_broken" -- overall="permerror": chain integrity -- failure (MI gap, recipe mismatch, etc.) | "d_mf_mismatch" -- overall="fail": d= doesn't match From 9c9fa52561dbc3433865555ae855f38bf5fa6847 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 04:53:03 +0000 Subject: [PATCH 38/52] rcptto option, result fields, AR examples and reason code mapping --- content/momentum/4/dkim2.md | 43 ++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index ac51b565f..dd9841ebd 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -142,9 +142,9 @@ Use `validate_data_spool_each_rcpt` when your deployment uses BCC or when you need each signature to be exclusive to one recipient. `validate_data_spool` is sufficient for TO/CC-only delivery. -Passing an explicit `rcpt` option overrides the automatic recipient -enumeration. If you supply a single address, the signature commits only to -that address and will not cover any other recipients. +Passing an explicit `rcptto` option overrides the automatic recipient +enumeration. If you supply a single address (string), the signature commits +only to that address and will not cover any other recipients. ### Minimum signer @@ -218,7 +218,7 @@ are header-level and go at the top level of the options table. | `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | | `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`keybuf`/`algorithm` at the top level are ignored. | | `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder. | -| `rcpt` | no | Override the envelope RCPT TO for the `rt=` tag. | +| `rcptto` | no | Override the envelope RCPT TO(s) for the `rt=` tag. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). When not set, the wrapper enumerates all envelope recipients automatically via `ctx:iterate_rcpt()`. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | @@ -249,7 +249,7 @@ msys.validate.dkim2.sign(msg, vctx, { selector = "fwd-2026", keyfile = "/etc/dkim2/forwarder.example.net/fwd-2026.key", mailfrom = "list-bounce@forwarder.example.net", - rcpt = "subscriber@downstream.example.org", + rcptto = "subscriber@downstream.example.org", }) ``` @@ -282,7 +282,7 @@ binding check is performed: | | `validate_data_spool` | `validate_data_spool_each_rcpt` | |---|---|---| | **Fires** | Once on shared parent message | Once per recipient (cowref) | -| **`rt=` check** | All envelope recipients checked against `rt=` (§10.4 MUST) | Single cowref recipient checked | +| **`rt=` check** | All envelope recipients enumerated into `rcptto` and checked against `rt=` (§10.4 MUST) | Single cowref recipient enumerated into `rcptto` and checked | | **BCC support** | No — Bcc recipients will not be in `rt=` and will fail the check | Yes — each cowref is checked independently | | **Complexity** | Simpler — one `verify()` call per message | One `verify()` call per recipient | @@ -314,7 +314,8 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) -- violation (d=/mf= mismatch, donotmodify, etc.) -- "permerror" could not verify: key missing/invalid/revoked, -- signature syntax error, or chain integrity failure - -- (result.overall_reason == "chain_broken") + -- (overall_reason="chain_broken" for chain failures; + -- nil for key/syntax errors — check signatures[i].reason) -- "temperror" resolver-side transient failure (SERVFAIL, timeout) -- "none" no DKIM2-Signature headers on the message @@ -351,7 +352,7 @@ header. |---|---| | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | | `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. **For testing only** — mirrors sign()'s `mailfrom=` option; useful for simulating specific envelope conditions without real SMTP transit. | -| `rcpt` | Override the actual envelope RCPT TO for the `rt=` binding check. Defaults to the bare address from `ec_message_get_rcptto`. **For testing only** — in production the envelope RCPT TO is always read from the message automatically and this option should not be set. | +| `rcptto` | Override the envelope RCPT TO(s) for the `rt=` binding check. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). ALL listed addresses must be present in `rt=` for the signature to pass (§10.4). When not set, the wrapper enumerates all envelope recipients automatically. | | `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | @@ -372,10 +373,10 @@ result = { | -- (§10.1 PERMERROR) | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) | "none", -- no DKIM2 signatures present - overall_reason = nil -- nil when overall="pass", or when - -- overall is non-pass due to per-sig - -- failures (key errors, bad crypto, - -- syntax errors) — in that case check + overall_reason = nil -- nil when overall="pass", "temperror", + -- or when overall is non-pass due to + -- per-sig failures (key errors, bad + -- crypto, syntax errors) — check -- result.signatures[i].reason for detail. -- Non-nil only for structural conditions -- that apply to the chain as a whole: @@ -403,6 +404,10 @@ result = { rt = "[,...]", -- all entries decoded from base64 n = "", -- if present f = "", -- if present; comma-separated + key_testing = true, -- if present: signing key has t=y + -- (RFC 6376 §3.6.1 testing mode). + -- Per spec, failures SHOULD NOT be + -- treated as definitive when set. }, ... } @@ -520,8 +525,15 @@ They appear in `result.signatures[i].reason`, in the | `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | | `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | +| `mi_hash_missing` | The body hash could not be retrieved from the `Message-Instance:` `h=` tag: either no MI with a matching sequence number (`m=`) was present, or the MI's `h=` tag was malformed or lacked a hash entry for the algorithm named in its own `h=` prefix. | | `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | +**Authentication-Results mapping (§10.1):** Most `status="fail"` reasons produce `dkim2=fail` in the AR header. Exceptions, per the §10.1 FAIL / PERMERROR / TEMPERROR distinction: +- `key_unavailable` → `dkim2=temperror` (transient DNS failure) +- The following produce `dkim2=permerror` (unrecoverable errors): `no_key`, `key_invalid`, `key_multiple_records`, `key_service_mismatch`, `key_k_unknown`, `key_revoked`, `key_b64_decode`, `key_der_parse`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` + +`reason=` is only included in failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`). Pass clauses (`dkim2=pass`) do not carry `reason=`. + ### recipe_chain detail strings (paniclog only) When the recipe-chain check fails, the overall verdict is `permerror` @@ -574,6 +586,13 @@ Authentication-Results: mta-1.example.com; > value rather than the bare selector. AR consumers that key on `header.s=` for > DKIM1-style selector lookups will see the combined string. +Transient DNS failure (`key_unavailable` → `dkim2=temperror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256: header.i=1 +``` + Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): ``` From 64f0728e9b735adae6b935ee9f760a45080e9001 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 05:42:52 +0000 Subject: [PATCH 39/52] update header.s part in AR --- content/momentum/4/dkim2.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index dd9841ebd..ccf5fcab2 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -399,7 +399,8 @@ result = { | "deferred" -- paired with status="chain_verified" | , -- see Per-signature reason codes table below d = "", - s = "::", -- raw s= value + s = "::", -- raw s= value; AR header.s= carries + -- only ":" (base64 stripped) mf = "", -- decoded from base64 rt = "[,...]", -- all entries decoded from base64 n = "", -- if present @@ -576,21 +577,20 @@ Normal pass: ``` Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.s=sel-1 header.i=1 header.m=1 + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 header.mf= header.rt= ``` -> **Note on `header.s=`:** RFC 8601 expects the selector name only (e.g. `sel-1`). -> In DKIM2 the `s=` tag encodes selector, algorithm, and signature together -> (`sel-1:rsa-sha256:`), so Momentum's `header.s=` carries that full -> value rather than the bare selector. AR consumers that key on `header.s=` for -> DKIM1-style selector lookups will see the combined string. +> **Note on `header.s=`:** RFC 8601 expects the selector name only. In DKIM2 the +> `s=` tag encodes selector, algorithm, and signature together, but Momentum emits +> only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in `header.s=`, +> omitting the bulk base64 signature bytes. Transient DNS failure (`key_unavailable` → `dkim2=temperror`): ``` Authentication-Results: mta-1.example.com; - dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256: header.i=1 + dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 ``` Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): From beef6808d45ab7b0f311d692221cabca78e9002d Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 13:02:53 +0000 Subject: [PATCH 40/52] add ar_clauses() API, fix doc gaps, remove skip_ar_header_update dead code --- content/momentum/4/dkim2.md | 99 ++++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index ccf5fcab2..0cefc01bf 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -17,7 +17,7 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [Verify options](#verify-options) - [Result table](#result-table) - [SMTP response codes](#smtp-response-codes-94-guidance) -- [Authentication-Results output](#authentication-results-output) +- [ar_clauses() — Authentication-Results output](#ar_clauses----authentication-results-output) - [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - [recipe_chain detail strings](#recipe-chain-detail-strings-paniclog-only) @@ -217,7 +217,7 @@ are header-level and go at the top level of the options table. | `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. | | `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | | `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`keybuf`/`algorithm` at the top level are ignored. | -| `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder. | +| `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder (e.g. the forwarder's own address rather than the original sender's). Pass `mailfrom=""` (empty string) for null-sender DSN/bounce messages (`MAIL FROM:<>`), since the envelope API returns nil for null senders. | | `rcptto` | no | Override the envelope RCPT TO(s) for the `rt=` tag. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). When not set, the wrapper enumerates all envelope recipients automatically via `ctx:iterate_rcpt()`. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | @@ -296,14 +296,14 @@ require("msys.validate.dkim2") local mod = {} function mod:validate_data_spool_each_rcpt(msg, ac, vctx) - local result = msys.validate.dkim2.verify(msg, vctx, { + local result, err = msys.validate.dkim2.verify(msg, vctx, { authservid = "mta-1.example.com", }) if not result then - -- Internal error during verification (alloc failure, etc.) — - -- distinct from a per-sig fail, which lands in result.signatures. - -- Defer rather than silently accepting: the message has not been - -- verified and should not be treated as if it were. + -- Internal error (alloc failure, crypto init error, etc.) — err carries + -- the reason string. Distinct from a per-sig fail, which lands in + -- result.signatures. Defer rather than silently accepting. + msys.log(msys.core.DWARNING, "DKIM2 verify failed internally: " .. tostring(err)) vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") return msys.core.VALIDATE_CONT end @@ -351,7 +351,7 @@ header. | Option | Meaning | |---|---| | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | -| `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. **For testing only** — mirrors sign()'s `mailfrom=` option; useful for simulating specific envelope conditions without real SMTP transit. | +| `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. Pass `mailfrom=""` (empty string) when verifying a DSN/bounce message (`MAIL FROM:<>`), since the envelope API returns nil for null senders. Useful for testing to simulate specific envelope conditions without real SMTP transit. | | `rcptto` | Override the envelope RCPT TO(s) for the `rt=` binding check. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). ALL listed addresses must be present in `rt=` for the signature to pass (§10.4). When not set, the wrapper enumerates all envelope recipients automatically. | | `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | @@ -361,6 +361,16 @@ header. | `max_sig_future_secs` | §7.4: reject signatures whose `t=` timestamp is more than this many seconds in the future. Default `300` (5-minute clock-skew tolerance). Values `<= 0` disable the check. | | `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message. Useful for staging and debugging; **do not enable in production** as these headers expose internal verification detail and inflate message size. Default `false`. | +`verify()` returns `(result, err)`: +- **Normal execution** (including messages with no DKIM2 signatures): `result` is + the result table below, `err` is `nil`. A message with no signatures returns + `result.overall = "none"` — `result` is never `nil` in this case. +- **Internal failure** (alloc or crypto init error): `result` is `nil`, `err` is a + non-nil string describing the cause. + +Always capture both return values so internal failures can be logged and acted on +separately from signature verdicts. + ### Result table ``` @@ -449,7 +459,7 @@ SMTP reply. Only `temperror` warrants a temporary failure code. Example hook skeleton: ```lua -local result = msys.validate.dkim2.verify(msg, vctx, { ... }) +local result, err = msys.validate.dkim2.verify(msg, vctx, { ... }) local overall = result and result.overall or "none" if overall == "permerror" or overall == "fail" then @@ -501,6 +511,11 @@ They appear in `result.signatures[i].reason`, in the `X-MSYS-DKIM2-Verify-Sig` debug header, and in `Authentication-Results:` `reason=` output. Policy code can safely branch on them. The full set: +> **Note:** `d_mf_mismatch`, `donotmodify_violated`, and `donotexplode_violated` are +> **not** per-signature reason codes. They are set on `result.overall_reason` when a +> policy check downgrades the overall verdict after crypto passes. See the Result table +> above for details. + | Reason | Meaning | |---|---| | `ok` | Signature verified cleanly. Paired with `status="pass"`. | @@ -510,12 +525,11 @@ They appear in `result.signatures[i].reason`, in the | `sig_invalid` | Cryptographic verification failed — the signed-input bytes don't match the value in `s=`. Enable `debug_level = info` for selector, algorithm, and signed-input length detail. | | `parse_error` | The `DKIM2-Signature:` header couldn't be parsed. Corrupt header or a broken upstream signer. | | `missing_required_tags` | One or more of the seven required tags (`i=`, `m=`, `t=`, `mf=`, `rt=`, `d=`, `s=`) is absent from the signature. | -| `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). | -| `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. | +| `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). §10.3 classifies this as PERMERROR — Momentum treats it as permanently unverifiable and does not attempt cryptographic verification. Maps to `dkim2=permerror` in AR output. | +| `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. Treated as a soft policy failure (`dkim2=fail`): the timestamp was evaluated and rejected, but it is not a permanent infrastructure error — the spec (§7.4 MAY) does not define a verdict for this case. | | `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§7.3). | | `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM — replay-to-different-sender. | | `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO — replay-to-different-recipient. | -| `d_mf_mismatch` | The signing domain `d=` does not match the rightmost labels of the `mf=` domain (§7.7). Only set when `relax_d_mf_check` is not enabled. | | `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). Rolls up to `overall="temperror"`. | | `no_key` | DNS returned NXDOMAIN — no TXT record exists for the selector. | | `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | @@ -527,6 +541,7 @@ They appear in `result.signatures[i].reason`, in the | `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | | `mi_hash_missing` | The body hash could not be retrieved from the `Message-Instance:` `h=` tag: either no MI with a matching sequence number (`m=`) was present, or the MI's `h=` tag was malformed or lacked a hash entry for the algorithm named in its own `h=` prefix. | +| `verify_internal` | An internal error occurred during signature verification (memory allocation failure or cryptographic library error). The signature could not be evaluated. Maps to `dkim2=permerror` in AR output. | | `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | **Authentication-Results mapping (§10.1):** Most `status="fail"` reasons produce `dkim2=fail` in the AR header. Exceptions, per the §10.1 FAIL / PERMERROR / TEMPERROR distinction: @@ -567,25 +582,64 @@ read the outcome without re-verifying or parsing `Authentication-Results:`: These keys are not set until `verify()` runs. -### Authentication-Results output +## ar_clauses() — Authentication-Results output -`verify()` always prepends a **new** `Authentication-Results:` header -when `authservid` is supplied; any prior AR headers (e.g. from SPF or -DKIM1) are left untouched (see RFC 8601 §5 note above). +``` +msys.validate.dkim2.ar_clauses(result) → clauses | nil +``` -Normal pass: +Returns a Lua array of DKIM2 `Authentication-Results:` clause strings for a +given verify result, or `nil` when the result carries no signatures or when +`result` itself is `nil` (e.g. `verify()` returned an internal error). -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf= header.rt= +Each entry is a complete, ready-to-use clause string (e.g. +`"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). +The array contains one entry per directly-verified signature plus any extra +overall clauses for chain failures or policy downgrades. Deferred signatures +(`status="chain_verified"`) are excluded — they have no valid RFC 8601 token. + +When `authservid` is supplied to `verify()`, Momentum calls `ar_clauses()` +internally and prepends the result as a fresh `Authentication-Results:` +header (RFC 8601 §5 — an MTA MUST NOT add to an existing AR header). Use +`ar_clauses()` directly when you need to merge DKIM2 results with other +authentication methods (SPF, DKIM1, ARC) into a single combined header. + +### Usage examples + +```lua +-- Simple: replicate what verify() does when authservid is set +local clauses = msys.validate.dkim2.ar_clauses(result) +if clauses then + msg:header("Authentication-Results", + "mta-1.example.com; " .. table.concat(clauses, "; "), + "prepend") +end + +-- Combined: merge DKIM2 clauses with SPF into one AR header +local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} +local spf_clause = build_spf_clause() -- caller-supplied +local all_clauses = { spf_clause } +for _, c in ipairs(dkim2_clauses) do all_clauses[#all_clauses + 1] = c end +msg:header("Authentication-Results", + "mta-1.example.com; " .. table.concat(all_clauses, "; "), + "prepend") ``` +### Output format + > **Note on `header.s=`:** RFC 8601 expects the selector name only. In DKIM2 the > `s=` tag encodes selector, algorithm, and signature together, but Momentum emits > only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in `header.s=`, > omitting the bulk base64 signature bytes. +Normal pass: + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf= header.rt= +``` + Transient DNS failure (`key_unavailable` → `dkim2=temperror`): ``` @@ -600,7 +654,8 @@ Authentication-Results: mta-1.example.com; dkim2=fail reason="body hash mismatch" header.d=example.com header.i=1 ``` -When the overall verdict is worse than the per-sig result — chain failure or policy downgrade after a crypto pass — an extra overall clause is appended: +When the overall verdict is worse than the per-sig result — chain failure or +policy downgrade after a crypto pass — an extra overall clause is appended: Chain-broken example (crypto passed but recipe-chain check failed): From bb1772b259f596514490da8bf7024d7aaf11492d Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 16:21:58 +0000 Subject: [PATCH 41/52] update multi-recipient message handling --- content/momentum/4/dkim2.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 0cefc01bf..9caccb76a 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -132,19 +132,19 @@ and whether BCC addresses are exposed. | | `validate_data_spool` | `validate_data_spool_each_rcpt` | |---|---|---| -| **Fires** | Once on shared parent message | Once per recipient (cowref) | -| **`rt=` default** | All envelope recipients (comma-separated list) | Single cowref recipient | -| **Replay protection** | Rejects delivery to any address not in the original list | Each copy bound exclusively to its own recipient | -| **BCC privacy** | ⚠️ BCC addresses appear in `rt=` on every copy, visible to TO/CC recipients | ✅ Each copy carries only its own recipient; no address leaks to others | -| **Complexity** | Simpler — one `sign()` call per message | One `sign()` call per recipient | +| **Fires** | Once on the shared parent message | Once per recipient (cowref) | +| **`rt=` auto-populate** | Primary recipient only (`msg:rcptto()`) — extra recipients are inaccessible in this hook | Single cowref recipient | +| **Multi-recipient rt=** | Must pass explicit `rcptto = {r1, r2, ...}` — collect the full list in an earlier hook (e.g. `validate_rcptto`) | Each cowref signs for its own single address automatically | +| **BCC privacy** | ⚠️ Policy's responsibility — operator must exclude BCC from the explicit `rcptto` list | ✅ Check `mo_rcpt_type == "bcc"` and skip; each copy carries only its own address | +| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; BCC detection built-in | -Use `validate_data_spool_each_rcpt` when your deployment uses BCC or when -you need each signature to be exclusive to one recipient. `validate_data_spool` -is sufficient for TO/CC-only delivery. +Use `validate_data_spool_each_rcpt` for most deployments — it handles +per-recipient signing and BCC privacy automatically. Use `validate_data_spool` +only when you need a single signature covering all recipients and are willing +to manage the recipient list and BCC exclusion yourself. -Passing an explicit `rcptto` option overrides the automatic recipient -enumeration. If you supply a single address (string), the signature commits -only to that address and will not cover any other recipients. +Passing an explicit `rcptto` option overrides the auto-populated primary recipient. +Accepts a string (single address) or a Lua table of bare addresses. ### Minimum signer @@ -218,7 +218,7 @@ are header-level and go at the top level of the options table. | `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | | `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`keybuf`/`algorithm` at the top level are ignored. | | `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder (e.g. the forwarder's own address rather than the original sender's). Pass `mailfrom=""` (empty string) for null-sender DSN/bounce messages (`MAIL FROM:<>`), since the envelope API returns nil for null senders. | -| `rcptto` | no | Override the envelope RCPT TO(s) for the `rt=` tag. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). When not set, the wrapper enumerates all envelope recipients automatically via `ctx:iterate_rcpt()`. | +| `rcptto` | no | Override the envelope RCPT TO(s) for the `rt=` tag. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). When not set, the primary envelope recipient (`msg:rcptto()`) is used automatically. For multi-recipient rt= covering all addresses, pass the full list explicitly. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | @@ -282,11 +282,13 @@ binding check is performed: | | `validate_data_spool` | `validate_data_spool_each_rcpt` | |---|---|---| | **Fires** | Once on shared parent message | Once per recipient (cowref) | -| **`rt=` check** | All envelope recipients enumerated into `rcptto` and checked against `rt=` (§10.4 MUST) | Single cowref recipient enumerated into `rcptto` and checked | -| **BCC support** | No — Bcc recipients will not be in `rt=` and will fail the check | Yes — each cowref is checked independently | -| **Complexity** | Simpler — one `verify()` call per message | One `verify()` call per recipient | +| **`rt=` auto-check** | First accessible recipient only (`msg:rcptto()`) — **all other recipients bypass the §10.4 check** unless explicitly listed in `rcptto` | Single cowref recipient checked; §10.4 satisfied per-delivery | +| **Multi-recipient §10.4** | ⚠️ Must pass explicit `rcptto = {r1, r2, ...}` — omitting any recipient silently skips its binding check | ✅ Every recipient verified automatically in its own cowref | +| **BCC support** | Policy's responsibility — exclude BCC from explicit `rcptto` | ✅ Each cowref checked independently; skip BCC cowrefs with `mo_rcpt_type` check | +| **Complexity** | Requires explicit recipient collection for complete §10.4 compliance | One `verify()` call per cowref; correct by default | -Use `validate_data_spool_each_rcpt` when your deployment uses BCC. +Use `validate_data_spool_each_rcpt` for most deployments — it satisfies §10.4 +for every recipient automatically without additional setup. Typical inbound policy: ```lua @@ -352,7 +354,7 @@ header. |---|---| | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | | `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. Pass `mailfrom=""` (empty string) when verifying a DSN/bounce message (`MAIL FROM:<>`), since the envelope API returns nil for null senders. Useful for testing to simulate specific envelope conditions without real SMTP transit. | -| `rcptto` | Override the envelope RCPT TO(s) for the `rt=` binding check. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). ALL listed addresses must be present in `rt=` for the signature to pass (§10.4). When not set, the wrapper enumerates all envelope recipients automatically. | +| `rcptto` | Override the envelope RCPT TO(s) for the `rt=` binding check. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). ALL listed addresses must be present in `rt=` for the signature to pass (§10.4). When not set, the primary envelope recipient (`msg:rcptto()`) is used automatically. Pass an explicit list for multi-recipient §10.4 checking. | | `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | From d85fcbbb3b8da7720f9ccbc2fbcc6f1a03391ff7 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 16:55:57 +0000 Subject: [PATCH 42/52] update --- content/momentum/4/dkim2.md | 220 ++++++++++++++++++------------------ 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 9caccb76a..4ce961ca9 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -17,7 +17,7 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [Verify options](#verify-options) - [Result table](#result-table) - [SMTP response codes](#smtp-response-codes-94-guidance) -- [ar_clauses() — Authentication-Results output](#ar_clauses----authentication-results-output) +- [Authentication-Results Output](#authentication-results-output) - [Debugging](#debugging) - [Per-signature reason codes](#per-signature-reason-codes) - [recipe_chain detail strings](#recipe-chain-detail-strings-paniclog-only) @@ -86,7 +86,7 @@ specifics live in the [IETF draft](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-02); the operationally-relevant signal codes (per-signature reasons, overall verdicts, paniclog lines) are inventoried in the -[Debugging](/momentum/4/dkim2#dkim2_debugging) section below. +[Debugging](/momentum/4/dkim2#debugging) section below. ## How it differs from DKIM1 at a glance @@ -116,7 +116,7 @@ dkim2 {} ``` The `debug_level` option is documented in the -[Debugging](/momentum/4/dkim2#dkim2_debugging) section. +[Debugging](/momentum/4/dkim2#debugging) section. ## DKIM2 Signing @@ -203,9 +203,9 @@ When `sig_sets` is present, all entries sign the same canonical signed-input and are combined into a single `s=sel1:alg1:sig1,sel2:alg2:sig2` value on one `DKIM2-Signature` header. Per §7.2 the verifier checks every sig-set; overall passes if any one validates, so a receiver that -only supports RSA will still verify cleanly. Any sig-set that fails -alongside a passing one is reported as a DWARNING in paniclog -(partial-sig-failure condition). The `selector`, `keyfile`, and +only supports RSA will still verify cleanly. On the verifier side, any +sig-set that fails alongside a passing one is reported as a DWARNING in +paniclog (partial-sig-failure condition, §7.2). The `selector`, `keyfile`, and `algorithm` fields belong to each sig-set entry; all other options below are header-level and go at the top level of the options table. @@ -216,7 +216,7 @@ are header-level and go at the top level of the options table. | `keyfile` | yes (single) | Path to the PEM-encoded private key on disk. Mutually exclusive with `keybuf`; one of the two is required. When `sig_sets` is used, set per entry inside `sig_sets` instead. | | `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. | | `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | -| `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, `selector`/`keyfile`/`keybuf`/`algorithm` at the top level are ignored. | +| `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, fields supplied in `sig_sets[1]` override the corresponding top-level fields; any field omitted from `sig_sets[1]` falls back to the top-level value. | | `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder (e.g. the forwarder's own address rather than the original sender's). Pass `mailfrom=""` (empty string) for null-sender DSN/bounce messages (`MAIL FROM:<>`), since the envelope API returns nil for null senders. | | `rcptto` | no | Override the envelope RCPT TO(s) for the `rt=` tag. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). When not set, the primary envelope recipient (`msg:rcptto()`) is used automatically. For multi-recipient rt= covering all addresses, pass the full list explicitly. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | @@ -231,7 +231,9 @@ are header-level and go at the top level of the options table. `sign()` returns `(true, header_value_string)` on success and `(nil, error_string)` on failure. Always check the return; on failure the message is left unmodified (no `DKIM2-Signature:` or `Message-Instance:` is -attached) and an error line is also logged to paniclog at level `error`. +attached). Recipe validation failure and content-changed-without-recipe +also log to paniclog at level `error`; most other failure paths return +only the error string to the caller without logging. ### Forwarder and modifier signing @@ -319,7 +321,8 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) -- (overall_reason="chain_broken" for chain failures; -- nil for key/syntax errors — check signatures[i].reason) -- "temperror" resolver-side transient failure (SERVFAIL, timeout) - -- "none" no DKIM2-Signature headers on the message + -- "none" no DKIM2-Signature headers, or all use unsupported + -- algorithms (§3.4 — ignored rather than failed) if result.overall == "temperror" then -- Transient DNS failure: set a 4xx code so Momentum issues a @@ -338,15 +341,8 @@ end msys.registerModule("my_dkim2_verifier", mod) ``` -The wrapper stamps a new `Authentication-Results:` header with one -`dkim2=…` clause per directly-verified signature. RFC 8601 §5 states -that an MTA **MUST NOT** add a result to an existing header field, so -`verify()` always prepends a fresh AR header. AR emission is opt-in: -nothing is emitted unless `authservid` is supplied. Alternatively, a -policy hook can omit `authservid` and build a combined -`Authentication-Results:` header later, merging DKIM2 results with those -from other authentication methods (SPF, DKIM1, ARC, etc.) into a single -header. +See [Authentication-Results Output](#authentication-results-output) for the AR +header format, `ar_clauses()` API, and examples of building combined headers. ### Verify options @@ -384,7 +380,8 @@ result = { | -- signature syntax error, or chain integrity failure | -- (§10.1 PERMERROR) | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) - | "none", -- no DKIM2 signatures present + | "none", -- no DKIM2-Signature headers present, or all + | -- use unsupported algorithms (§3.4) overall_reason = nil -- nil when overall="pass", "temperror", -- or when overall is non-pass due to -- per-sig failures (key errors, bad @@ -450,7 +447,7 @@ SMTP behaviour as required by §9.4 of the DKIM2 spec: | `overall` | Meaning | §9.4 guidance | Suggested action | |---|---|---|---| | `pass` | All verifiable signatures passed | — | Accept | -| `none` | No DKIM2 signatures present | — | Local policy | +| `none` | No DKIM2 signatures present, or all use unsupported algorithms (§3.4) | — | Local policy | | `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | | `permerror` | Could not verify: key missing/revoked/invalid, syntax error, or chain integrity failure (`overall_reason="chain_broken"`) (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | | `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | @@ -486,6 +483,98 @@ end > for the cases shown above. +## Authentication-Results Output + +``` +msys.validate.dkim2.ar_clauses(result) → clauses | nil +``` + +Returns a Lua array of DKIM2 `Authentication-Results:` clause strings for a +given verify result, or `nil` when the result carries no signatures or when +`result` itself is `nil` (e.g. `verify()` returned an internal error). + +Each entry is a complete, ready-to-use clause string (e.g. +`"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). +The array contains one entry per directly-verified signature plus any extra +overall clauses for chain failures or policy downgrades. Deferred signatures +(`status="chain_verified"`) are excluded — they have no valid RFC 8601 token. + +When `authservid` is supplied to `verify()`, Momentum calls `ar_clauses()` +internally and prepends the result as a fresh `Authentication-Results:` +header (RFC 8601 §5 — an MTA MUST NOT add to an existing AR header). Use +`ar_clauses()` directly when you need to merge DKIM2 results with other +authentication methods (SPF, DKIM1, ARC) into a single combined header. + +### Usage examples + +```lua +-- Simple: replicate what verify() does when authservid is set +local clauses = msys.validate.dkim2.ar_clauses(result) +if clauses then + msg:header("Authentication-Results", + "mta-1.example.com; " .. table.concat(clauses, "; "), + "prepend") +end + +-- Combined: merge DKIM2 clauses with SPF into one AR header +local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} +local spf_clause = build_spf_clause() -- caller-supplied +local all_clauses = { spf_clause } +for _, c in ipairs(dkim2_clauses) do all_clauses[#all_clauses + 1] = c end +msg:header("Authentication-Results", + "mta-1.example.com; " .. table.concat(all_clauses, "; "), + "prepend") +``` + +### Output format + +> **Note on `header.s=`:** RFC 8601 expects the selector name only. In DKIM2 the +> `s=` tag encodes selector, algorithm, and signature together, but Momentum emits +> only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in `header.s=`, +> omitting the bulk base64 signature bytes. + +Normal pass: + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Transient DNS failure (`key_unavailable` → `dkim2=temperror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 +``` + +Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=fail reason="body hash mismatch" header.d=example.com header.i=1 +``` + +When the overall verdict is worse than the per-sig result — chain failure or +policy downgrade after a crypto pass — an extra overall clause is appended: + +Chain-broken example (crypto passed but recipe-chain check failed): + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.i=2; + dkim2=permerror reason="chain of custody broken" +``` + +Policy-downgrade example (`d=` does not match the `mf=` domain): + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.i=1; + dkim2=fail reason="MAIL FROM and d= do not match" +``` + + ## Debugging Setting `debug_level` on the `dkim2` configuration stanza routes sign and @@ -584,97 +673,6 @@ read the outcome without re-verifying or parsing `Authentication-Results:`: These keys are not set until `verify()` runs. -## ar_clauses() — Authentication-Results output - -``` -msys.validate.dkim2.ar_clauses(result) → clauses | nil -``` - -Returns a Lua array of DKIM2 `Authentication-Results:` clause strings for a -given verify result, or `nil` when the result carries no signatures or when -`result` itself is `nil` (e.g. `verify()` returned an internal error). - -Each entry is a complete, ready-to-use clause string (e.g. -`"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). -The array contains one entry per directly-verified signature plus any extra -overall clauses for chain failures or policy downgrades. Deferred signatures -(`status="chain_verified"`) are excluded — they have no valid RFC 8601 token. - -When `authservid` is supplied to `verify()`, Momentum calls `ar_clauses()` -internally and prepends the result as a fresh `Authentication-Results:` -header (RFC 8601 §5 — an MTA MUST NOT add to an existing AR header). Use -`ar_clauses()` directly when you need to merge DKIM2 results with other -authentication methods (SPF, DKIM1, ARC) into a single combined header. - -### Usage examples - -```lua --- Simple: replicate what verify() does when authservid is set -local clauses = msys.validate.dkim2.ar_clauses(result) -if clauses then - msg:header("Authentication-Results", - "mta-1.example.com; " .. table.concat(clauses, "; "), - "prepend") -end - --- Combined: merge DKIM2 clauses with SPF into one AR header -local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} -local spf_clause = build_spf_clause() -- caller-supplied -local all_clauses = { spf_clause } -for _, c in ipairs(dkim2_clauses) do all_clauses[#all_clauses + 1] = c end -msg:header("Authentication-Results", - "mta-1.example.com; " .. table.concat(all_clauses, "; "), - "prepend") -``` - -### Output format - -> **Note on `header.s=`:** RFC 8601 expects the selector name only. In DKIM2 the -> `s=` tag encodes selector, algorithm, and signature together, but Momentum emits -> only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in `header.s=`, -> omitting the bulk base64 signature bytes. - -Normal pass: - -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf= header.rt= -``` - -Transient DNS failure (`key_unavailable` → `dkim2=temperror`): - -``` -Authentication-Results: mta-1.example.com; - dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 -``` - -Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): - -``` -Authentication-Results: mta-1.example.com; - dkim2=fail reason="body hash mismatch" header.d=example.com header.i=1 -``` - -When the overall verdict is worse than the per-sig result — chain failure or -policy downgrade after a crypto pass — an extra overall clause is appended: - -Chain-broken example (crypto passed but recipe-chain check failed): - -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.i=2; - dkim2=permerror reason="chain of custody broken" -``` - -Policy-downgrade example (`d=` does not match the `mf=` domain): - -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.i=1; - dkim2=fail reason="MAIL FROM and d= do not match" -``` - ## Key management From 89b841d6c69335d681c1295bf10440776ff40918 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 17:26:00 +0000 Subject: [PATCH 43/52] minor update --- content/momentum/4/dkim2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 4ce961ca9..7797dd126 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -135,11 +135,11 @@ and whether BCC addresses are exposed. | **Fires** | Once on the shared parent message | Once per recipient (cowref) | | **`rt=` auto-populate** | Primary recipient only (`msg:rcptto()`) — extra recipients are inaccessible in this hook | Single cowref recipient | | **Multi-recipient rt=** | Must pass explicit `rcptto = {r1, r2, ...}` — collect the full list in an earlier hook (e.g. `validate_rcptto`) | Each cowref signs for its own single address automatically | -| **BCC privacy** | ⚠️ Policy's responsibility — operator must exclude BCC from the explicit `rcptto` list | ✅ Check `mo_rcpt_type == "bcc"` and skip; each copy carries only its own address | -| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; BCC detection built-in | +| **BCC privacy** | ⚠️ Operator must exclude BCC from the explicit `rcptto` list | ⚠️ Operator must check `mo_rcpt_type == "bcc"` and skip `sign()` for BCC cowrefs; auto-populate suppresses the BCC address from `rt=` if `sign()` is called anyway, but the signature still proceeds without any `rt=` binding | +| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; requires explicit BCC check | Use `validate_data_spool_each_rcpt` for most deployments — it handles -per-recipient signing and BCC privacy automatically. Use `validate_data_spool` +per-recipient signing automatically. Check `mo_rcpt_type` to handle BCC privacy. Use `validate_data_spool` only when you need a single signature covering all recipients and are willing to manage the recipient list and BCC exclusion yourself. From f5f8d578605a3dbced4c24d6793c4ef1bf6f0588 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 18:38:28 +0000 Subject: [PATCH 44/52] AR session restrucure --- content/momentum/4/dkim2.md | 68 ++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 7797dd126..aa7c51942 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -135,11 +135,11 @@ and whether BCC addresses are exposed. | **Fires** | Once on the shared parent message | Once per recipient (cowref) | | **`rt=` auto-populate** | Primary recipient only (`msg:rcptto()`) — extra recipients are inaccessible in this hook | Single cowref recipient | | **Multi-recipient rt=** | Must pass explicit `rcptto = {r1, r2, ...}` — collect the full list in an earlier hook (e.g. `validate_rcptto`) | Each cowref signs for its own single address automatically | -| **BCC privacy** | ⚠️ Operator must exclude BCC from the explicit `rcptto` list | ⚠️ Operator must check `mo_rcpt_type == "bcc"` and skip `sign()` for BCC cowrefs; auto-populate suppresses the BCC address from `rt=` if `sign()` is called anyway, but the signature still proceeds without any `rt=` binding | -| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; requires explicit BCC check | +| **BCC privacy** | ⚠️ Operator must exclude BCC from the explicit `rcptto` list — a shared signature exposing a BCC address is visible to all recipients | ✅ No concern — each cowref is private to that recipient; `rt=` is bound to their address only | +| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; correct by default | Use `validate_data_spool_each_rcpt` for most deployments — it handles -per-recipient signing automatically. Check `mo_rcpt_type` to handle BCC privacy. Use `validate_data_spool` +per-recipient signing automatically. Use `validate_data_spool` only when you need a single signature covering all recipients and are willing to manage the recipient list and BCC exclusion yourself. @@ -286,7 +286,7 @@ binding check is performed: | **Fires** | Once on shared parent message | Once per recipient (cowref) | | **`rt=` auto-check** | First accessible recipient only (`msg:rcptto()`) — **all other recipients bypass the §10.4 check** unless explicitly listed in `rcptto` | Single cowref recipient checked; §10.4 satisfied per-delivery | | **Multi-recipient §10.4** | ⚠️ Must pass explicit `rcptto = {r1, r2, ...}` — omitting any recipient silently skips its binding check | ✅ Every recipient verified automatically in its own cowref | -| **BCC support** | Policy's responsibility — exclude BCC from explicit `rcptto` | ✅ Each cowref checked independently; skip BCC cowrefs with `mo_rcpt_type` check | +| **BCC support** | ⚠️ Operator must exclude BCC from explicit `rcptto` — omitting a BCC address skips its §10.4 binding check | ✅ Each cowref checked independently; no special handling needed | | **Complexity** | Requires explicit recipient collection for complete §10.4 compliance | One `verify()` call per cowref; correct by default | Use `validate_data_spool_each_rcpt` for most deployments — it satisfies §10.4 @@ -351,7 +351,7 @@ header format, `ar_clauses()` API, and examples of building combined headers. | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | | `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. Pass `mailfrom=""` (empty string) when verifying a DSN/bounce message (`MAIL FROM:<>`), since the envelope API returns nil for null senders. Useful for testing to simulate specific envelope conditions without real SMTP transit. | | `rcptto` | Override the envelope RCPT TO(s) for the `rt=` binding check. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). ALL listed addresses must be present in `rt=` for the signature to pass (§10.4). When not set, the primary envelope recipient (`msg:rcptto()`) is used automatically. Pass an explicit list for multi-recipient §10.4 checking. | -| `authservid` | When set, a new `Authentication-Results:` header is always prepended with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | +| `authservid` | When set, a new `Authentication-Results:` header is prepended (when the result contains at least one actionable clause) with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | | `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag. Default `false` — duplicates produce `reason=parse_error` per §7.8. **Setting this to `true` makes the verifier non-spec-compliant** — §7.8 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | @@ -485,37 +485,37 @@ end ## Authentication-Results Output +When `authservid` is supplied to `verify()`, Momentum automatically builds +and prepends a fresh `Authentication-Results:` header (RFC 8601 §5 — an MTA +MUST NOT add to an existing AR header): + +```lua +msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta-1.example.com", +}) +``` + +For full control — or to merge DKIM2 results with other authentication methods +(SPF, DKIM1, ARC) into a single combined header — use `ar_clauses()` directly: + ``` msys.validate.dkim2.ar_clauses(result) → clauses | nil ``` Returns a Lua array of DKIM2 `Authentication-Results:` clause strings for a -given verify result, or `nil` when the result carries no signatures or when -`result` itself is `nil` (e.g. `verify()` returned an internal error). +given verify result, or `nil` when `result` is `nil`, `result.signatures` is +absent, or `result.signatures` is empty. Each entry is a complete, ready-to-use clause string (e.g. `"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). -The array contains one entry per directly-verified signature plus any extra -overall clauses for chain failures or policy downgrades. Deferred signatures +The array contains one entry per non-deferred signature (each signature with +`status` other than `"chain_verified"`) plus any extra overall clauses for +chain failures or policy downgrades. Deferred signatures (`status="chain_verified"`) are excluded — they have no valid RFC 8601 token. -When `authservid` is supplied to `verify()`, Momentum calls `ar_clauses()` -internally and prepends the result as a fresh `Authentication-Results:` -header (RFC 8601 §5 — an MTA MUST NOT add to an existing AR header). Use -`ar_clauses()` directly when you need to merge DKIM2 results with other -authentication methods (SPF, DKIM1, ARC) into a single combined header. - ### Usage examples ```lua --- Simple: replicate what verify() does when authservid is set -local clauses = msys.validate.dkim2.ar_clauses(result) -if clauses then - msg:header("Authentication-Results", - "mta-1.example.com; " .. table.concat(clauses, "; "), - "prepend") -end - -- Combined: merge DKIM2 clauses with SPF into one AR header local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} local spf_clause = build_spf_clause() -- caller-supplied @@ -548,17 +548,24 @@ Authentication-Results: mta-1.example.com; dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 ``` +Permanent error — key does not exist in DNS (`no_key` → `dkim2=permerror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=permerror reason="public key does not exist" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 +``` + Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): ``` Authentication-Results: mta-1.example.com; - dkim2=fail reason="body hash mismatch" header.d=example.com header.i=1 + dkim2=fail reason="body hash mismatch" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 ``` When the overall verdict is worse than the per-sig result — chain failure or policy downgrade after a crypto pass — an extra overall clause is appended: -Chain-broken example (crypto passed but recipe-chain check failed): +Chain-broken example (2-hop message: crypto passed but recipe-chain check failed): ``` Authentication-Results: mta-1.example.com; @@ -600,7 +607,14 @@ Every signature on a verified message gets a `reason` string in not defined by the DKIM2 spec — but are exposed through the `verify()` API. They appear in `result.signatures[i].reason`, in the `X-MSYS-DKIM2-Verify-Sig` debug header, and in `Authentication-Results:` -`reason=` output. Policy code can safely branch on them. The full set: +`reason=` output. Policy code can safely branch on them. + +The per-signature AR verdict is derived from `status` and `reason` together: +`status="pass"` → `dkim2=pass`; `status="fail"` → `dkim2=fail` by default, +promoted to `dkim2=temperror` or `dkim2=permerror` for specific reason codes +(noted in the table below); `status="chain_verified"` is excluded from AR output. + +The full set: > **Note:** `d_mf_mismatch`, `donotmodify_violated`, and `donotexplode_violated` are > **not** per-signature reason codes. They are set on `result.overall_reason` when a @@ -618,7 +632,7 @@ They appear in `result.signatures[i].reason`, in the | `missing_required_tags` | One or more of the seven required tags (`i=`, `m=`, `t=`, `mf=`, `rt=`, `d=`, `s=`) is absent from the signature. | | `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). §10.3 classifies this as PERMERROR — Momentum treats it as permanently unverifiable and does not attempt cryptographic verification. Maps to `dkim2=permerror` in AR output. | | `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. Treated as a soft policy failure (`dkim2=fail`): the timestamp was evaluated and rejected, but it is not a permanent infrastructure error — the spec (§7.4 MAY) does not define a verdict for this case. | -| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§7.3). | +| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§7.3 SHOULD). Treated as `dkim2=fail` — the constraint is a SHOULD, not a structural permanent error. | | `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM — replay-to-different-sender. | | `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO — replay-to-different-recipient. | | `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). Rolls up to `overall="temperror"`. | From 8405d6de181ae9cd22f60f4b7c7b5c96974b2161 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 12 Jun 2026 23:00:05 +0000 Subject: [PATCH 45/52] AR update --- content/momentum/4/dkim2.md | 56 ++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index aa7c51942..a2c7dd841 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -496,27 +496,28 @@ msys.validate.dkim2.verify(msg, vctx, { ``` For full control — or to merge DKIM2 results with other authentication methods -(SPF, DKIM1, ARC) into a single combined header — use `ar_clauses()` directly: +(SPF, DKIM1, ARC) into a single combined header — use +`msys.validate.dkim2.ar_clauses(result)`. -``` -msys.validate.dkim2.ar_clauses(result) → clauses | nil -``` +`ar_clauses()` returns an array of DKIM2 `Authentication-Results:` clause +strings for the given verify result. It returns `nil` when `result` is `nil`, or `result.signatures` is absent or +empty. It also returns `nil` when all per-signature entries are non-actionable +(`status="chain_verified"` or `status="none"`) and `result.overall` is `"none"`. -Returns a Lua array of DKIM2 `Authentication-Results:` clause strings for a -given verify result, or `nil` when `result` is `nil`, `result.signatures` is -absent, or `result.signatures` is empty. - -Each entry is a complete, ready-to-use clause string (e.g. +Each entry is a complete clause string (e.g. `"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). -The array contains one entry per non-deferred signature (each signature with -`status` other than `"chain_verified"`) plus any extra overall clauses for -chain failures or policy downgrades. Deferred signatures -(`status="chain_verified"`) are excluded — they have no valid RFC 8601 token. +The array contains one entry per actionable signature — signatures with +`status="chain_verified"` (deferred, no RFC 8601 equivalent) and +`status="none"` (unsupported algorithm, §3.4 — no `dkim2=none` token exists) +are excluded. Extra overall clauses for chain failures or policy downgrades +are appended when applicable. ### Usage examples ```lua --- Combined: merge DKIM2 clauses with SPF into one AR header +-- Omit authservid so no DKIM2-only AR header is auto-prepended; build +-- the combined header below. +local result, err = msys.validate.dkim2.verify(msg, vctx) local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} local spf_clause = build_spf_clause() -- caller-supplied local all_clauses = { spf_clause } @@ -528,10 +529,10 @@ msg:header("Authentication-Results", ### Output format -> **Note on `header.s=`:** RFC 8601 expects the selector name only. In DKIM2 the -> `s=` tag encodes selector, algorithm, and signature together, but Momentum emits -> only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in `header.s=`, -> omitting the bulk base64 signature bytes. +> **Note on `header.s=`:** In DKIM1, `header.s=` carries just the selector name. +> In DKIM2 the `s=` wire tag encodes selector, algorithm, and signature together; +> Momentum emits only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in +> `header.s=`, omitting the bulk base64 signature bytes. Normal pass: @@ -545,21 +546,24 @@ Transient DNS failure (`key_unavailable` → `dkim2=temperror`): ``` Authentication-Results: mta-1.example.com; - dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 + dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com ``` Permanent error — key does not exist in DNS (`no_key` → `dkim2=permerror`): ``` Authentication-Results: mta-1.example.com; - dkim2=permerror reason="public key does not exist" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 + dkim2=permerror reason="public key does not exist" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com ``` Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): ``` Authentication-Results: mta-1.example.com; - dkim2=fail reason="body hash mismatch" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 + dkim2=fail reason="body hash mismatch" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com ``` When the overall verdict is worse than the per-sig result — chain failure or @@ -569,7 +573,8 @@ Chain-broken example (2-hop message: crypto passed but recipe-chain check failed ``` Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.i=2; + dkim2=pass header.d=example.com header.s=sel-2:rsa-sha256 header.i=2 header.m=2 + header.mf=bounce@forwarder.example.net header.rt=rcpt@a.com; dkim2=permerror reason="chain of custody broken" ``` @@ -577,7 +582,8 @@ Policy-downgrade example (`d=` does not match the `mf=` domain): ``` Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.i=1; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com; dkim2=fail reason="MAIL FROM and d= do not match" ``` @@ -612,7 +618,7 @@ They appear in `result.signatures[i].reason`, in the The per-signature AR verdict is derived from `status` and `reason` together: `status="pass"` → `dkim2=pass`; `status="fail"` → `dkim2=fail` by default, promoted to `dkim2=temperror` or `dkim2=permerror` for specific reason codes -(noted in the table below); `status="chain_verified"` is excluded from AR output. +(noted in the table below); `status="chain_verified"` and `status="none"` are excluded from AR output. The full set: @@ -653,7 +659,7 @@ The full set: - `key_unavailable` → `dkim2=temperror` (transient DNS failure) - The following produce `dkim2=permerror` (unrecoverable errors): `no_key`, `key_invalid`, `key_multiple_records`, `key_service_mismatch`, `key_k_unknown`, `key_revoked`, `key_b64_decode`, `key_der_parse`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` -`reason=` is only included in failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`). Pass clauses (`dkim2=pass`) do not carry `reason=`. +`reason=` is included in all failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`) and absent from pass clauses (`dkim2=pass`). ### recipe_chain detail strings (paniclog only) From 4c8161422d0386a5142809339977a46f0f0ad983 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Sat, 13 Jun 2026 00:17:29 +0000 Subject: [PATCH 46/52] update --- content/momentum/4/dkim2.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index a2c7dd841..83139b7ca 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -448,12 +448,13 @@ SMTP behaviour as required by §9.4 of the DKIM2 spec: |---|---|---|---| | `pass` | All verifiable signatures passed | — | Accept | | `none` | No DKIM2 signatures present, or all use unsupported algorithms (§3.4) | — | Local policy | -| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x if rejecting | Reject or accept per policy | +| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject or accept per policy | | `permerror` | Could not verify: key missing/revoked/invalid, syntax error, or chain integrity failure (`overall_reason="chain_broken"`) (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | | `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | -**Key rule from §9.4**: `permerror` **MUST NOT** result in a 4xx (temporary) -SMTP reply. Only `temperror` warrants a temporary failure code. +**Key rules from §9.4**: +- `fail` and `permerror` **MUST NOT** use a 4xx reply code. +- Only `temperror` warrants a temporary (4xx) failure code. Example hook skeleton: @@ -729,8 +730,11 @@ the other. Receivers that support both will evaluate each chain separately. The following are known gaps or operational considerations to be aware of: -* **§11 DSN routing**: When generating a Delivery Status Notification, - Momentum does not yet address it to the `mf=` address from the +* **§9.1 / §11 DSN**: The spec requires that when DKIM2 verification + fails the MTA MUST NOT generate a DSN — reject with 5xx instead. + This is not automatically enforced; policy must explicitly reject + rather than bounce on verify failure. When generating a DSN, Momentum + does not yet address it to the `mf=` address from the highest-numbered DKIM2-Signature of the original message, nor does it suppress DSN generation when the original sender was `<>` (null sender). Inbound DSN authentication (§11.1.2) is also not implemented. @@ -774,13 +778,13 @@ The following are known gaps or operational considerations to be aware of: `header.s=`) repeat them in the AR clause. This is a §10.1 SHOULD — not a MUST — so verification behaviour is unaffected. -* **§12 Bare CR/LF normalization**: Momentum's +* **§12 Bare CR/LF normalization**: The spec (§12) requires signing the + message with all line endings in CRLF form. **Set [`rfc2822_lone_lf_in_body`](/momentum/4/config/ref-rfc-2822-lone-lf-in-body) and [`rfc2822_lone_lf_in_headers`](/momentum/4/config/ref-rfc-2822-lone-lf-in-headers) - options control bare LF handling. If either is set to `ignore`, DKIM2 - signs bare-LF content as-is; a downstream SMTP hop that normalizes it - to CRLF will silently break the signature. **Set both options to `fix` - when DKIM2 signing is in use.** + to `fix` when DKIM2 signing is in use** — `ignore` causes DKIM2 to + sign non-CRLF content as-is, breaking the signature at any downstream + hop that normalizes line endings. From 57965e3eb76bee81ba0fe72befc78bc16cababa6 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Sat, 13 Jun 2026 01:44:15 +0000 Subject: [PATCH 47/52] update AR status code --- content/momentum/4/dkim2.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 83139b7ca..c2c42e668 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -651,6 +651,10 @@ The full set: | `key_invalid` | The DNS TXT record was present but structurally unusable (empty content, internal resolver error, or selector/domain too long to query). | | `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | | `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | +| `key_v_mismatch` | The DNS TXT record's `v=` tag does not match the expected value. Malformed or wrong-version key record. Maps to `dkim2=permerror`. | +| `key_p_missing` | The DNS TXT record has no `p=` tag (distinct from empty `p=` which is revocation). Malformed key record. Maps to `dkim2=permerror`. | +| `key_size_invalid` | The RSA public key is smaller than the 1024-bit minimum required by §3.2. Maps to `dkim2=permerror`. | +| `key_e_invalid` | The RSA public key exponent is not 65537 as required by §3.2. Maps to `dkim2=permerror`. | | `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | | `mi_hash_missing` | The body hash could not be retrieved from the `Message-Instance:` `h=` tag: either no MI with a matching sequence number (`m=`) was present, or the MI's `h=` tag was malformed or lacked a hash entry for the algorithm named in its own `h=` prefix. | | `verify_internal` | An internal error occurred during signature verification (memory allocation failure or cryptographic library error). The signature could not be evaluated. Maps to `dkim2=permerror` in AR output. | @@ -658,7 +662,7 @@ The full set: **Authentication-Results mapping (§10.1):** Most `status="fail"` reasons produce `dkim2=fail` in the AR header. Exceptions, per the §10.1 FAIL / PERMERROR / TEMPERROR distinction: - `key_unavailable` → `dkim2=temperror` (transient DNS failure) -- The following produce `dkim2=permerror` (unrecoverable errors): `no_key`, `key_invalid`, `key_multiple_records`, `key_service_mismatch`, `key_k_unknown`, `key_revoked`, `key_b64_decode`, `key_der_parse`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` +- The following produce `dkim2=permerror` (unrecoverable errors): `no_key`, `key_invalid`, `key_multiple_records`, `key_service_mismatch`, `key_k_unknown`, `key_revoked`, `key_b64_decode`, `key_der_parse`, `key_v_mismatch`, `key_p_missing`, `key_size_invalid`, `key_e_invalid`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` `reason=` is included in all failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`) and absent from pass clauses (`dkim2=pass`). From bc8a823c40e0d6cada4f804d8828a469852d15fd Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Tue, 16 Jun 2026 02:07:17 +0000 Subject: [PATCH 48/52] =?UTF-8?q?update=20with=20=C2=A78.2=20auto-bridge?= =?UTF-8?q?=20options=20and=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- content/momentum/4/dkim2.md | 75 ++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index c2c42e668..d8dd47556 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -217,8 +217,10 @@ are header-level and go at the top level of the options table. | `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. | | `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | | `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, fields supplied in `sig_sets[1]` override the corresponding top-level fields; any field omitted from `sig_sets[1]` falls back to the top-level value. | -| `mailfrom` | no | Override the envelope MAIL FROM for the `mf=` tag. Use this when signing as a forwarder (e.g. the forwarder's own address rather than the original sender's). Pass `mailfrom=""` (empty string) for null-sender DSN/bounce messages (`MAIL FROM:<>`), since the envelope API returns nil for null senders. | -| `rcptto` | no | Override the envelope RCPT TO(s) for the `rt=` tag. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). When not set, the primary envelope recipient (`msg:rcptto()`) is used automatically. For multi-recipient rt= covering all addresses, pass the full list explicitly. | +| `mailfrom` | no | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Two production exceptions: (1) null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`; (2) testing/simulation of specific envelope conditions without real SMTP transit. | +| `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. One production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly to cover all recipients in a single `rt=`. In `validate_data_spool_each_rcpt` (recommended), each cowref auto-populates correctly. Accepts a string or a Lua table of bare addresses. | +| `bridge_mailfrom` | no | The `mf=` for an auto-generated bridging signature when the new `mf=` is not in the previous signature's `rt=` (§8.2). Required when the prior `rt=` has multiple entries; inferred automatically when it has exactly one. | +| `on_chain_break` | no | Action when a §8.2 chain break is detected: `"bridge"` (default when `bridge_mailfrom` set), `"skip"` (default otherwise), `"warn"`, or `"error"`. See the Forwarder signing section for details. | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | | `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | @@ -237,24 +239,63 @@ only the error string to the caller without logging. ### Forwarder and modifier signing -A forwarder that **re-routes** a message (different envelope) signs with -explicit overrides so the §8.3 chain-of-custody check downstream succeeds: +When a forwarder changes the envelope MAIL FROM to an address not present +in the previous signature's `rt=` list, §8.2 requires an extra bridging +`DKIM2-Signature` before the primary — one whose `mf=` matches the previous +`rt=` and whose own `rt=` contains the new outgoing MAIL FROM. Momentum +automates this: supply `bridge_mailfrom` with the address this hop received +the message at, and `sign()` detects the chain break and prepends the bridge +automatically. + +The most common case is a **mailing list**: the original sender's signature +has `rt=list@mailing-list.com`; the list re-sends with +`MAIL FROM: bounce@mailing-list.com`, which is not in the prior `rt=` — +a chain break that requires a bridge: ```lua --- Hop 2 (forwarder): mf= is the forwarder's own bounce address (must --- match the rt= in hop 1's signature that listed this forwarder as a --- recipient); rt= is the new downstream recipient. Using the forwarder's --- own envelope values here does not break the chain — it builds the next --- link correctly. -msys.validate.dkim2.sign(msg, vctx, { - domain = "forwarder.example.net", - selector = "fwd-2026", - keyfile = "/etc/dkim2/forwarder.example.net/fwd-2026.key", - mailfrom = "list-bounce@forwarder.example.net", - rcptto = "subscriber@downstream.example.org", +-- Mailing list scenario: +-- i=1 (originator): mf=alice@sender.com rt=list@mailing-list.com +-- i=2 (auto-bridge): mf=list@mailing-list.com rt=bounce@mailing-list.com +-- i=3 (primary): mf=bounce@mailing-list.com rt=subscriber@recipient.com +local ok, val, info = msys.validate.dkim2.sign(msg, vctx, { + domain = "mailing-list.com", + selector = "list-2026", + keyfile = "/etc/dkim2/mailing-list.com/list-2026.key", + mailfrom = "bounce@mailing-list.com", + rcptto = "subscriber@recipient.com", + bridge_mailfrom = "list@mailing-list.com", -- the address the list received at + -- on_chain_break defaults to "bridge" since bridge_mailfrom is provided }) +if not ok then + -- sign() failed (key error, bridge error, etc.) + vctx:set_code(550, "5.7.1 DKIM2 signing failed: " .. tostring(val)) + return msys.core.VALIDATE_DONE +end +-- info.chain_break=true, info.bridged=true when bridge was auto-generated ``` +When the forwarding address is unambiguous (prior `rt=` has a single entry), +`bridge_mailfrom` can be omitted — Momentum infers it automatically. When the +prior `rt=` has multiple entries, `bridge_mailfrom` is required to identify +which entry this hop received at. + +The `on_chain_break` option controls what happens when a chain break is +detected but cannot be bridged: + +| `on_chain_break` | Behavior | Third return value | +|---|---|---| +| `"bridge"` (default with `bridge_mailfrom`) | Auto-bridge; error if ambiguous | `{chain_break=true, bridged=true}` | +| `"skip"` (default without `bridge_mailfrom`) | Skip signing | `{chain_break=true, bridged=false}` | +| `"warn"` | Sign without bridge | `{chain_break=true, bridged=false}` | +| `"error"` | Return `(nil, errmsg)` | — | + +The third return value gives policy full control: inspect `info.chain_break` +and `info.bridged` to decide whether to accept, reject, or log — regardless +of which `on_chain_break` value was used. + +A forwarder that does not change the MAIL FROM (pure relay) signs with +the envelope values directly — no bridge needed since the chain is intact: + A modifier that **rewrites** the message (Subject change, body footer, attachment strip, etc.) additionally attaches a `recipe`: @@ -349,8 +390,8 @@ header format, `ar_clauses()` API, and examples of building combined headers. | Option | Meaning | |---|---| | `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | -| `mailfrom` | Override the envelope MAIL FROM used for the `mf=` binding check. Defaults to the bare address from `ec_message_get_mailfrom`. Pass `mailfrom=""` (empty string) when verifying a DSN/bounce message (`MAIL FROM:<>`), since the envelope API returns nil for null senders. Useful for testing to simulate specific envelope conditions without real SMTP transit. | -| `rcptto` | Override the envelope RCPT TO(s) for the `rt=` binding check. Accepts a string (single bare address) or a Lua table of bare addresses (multiple). ALL listed addresses must be present in `rt=` for the signature to pass (§10.4). When not set, the primary envelope recipient (`msg:rcptto()`) is used automatically. Pass an explicit list for multi-recipient §10.4 checking. | +| `mailfrom` | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Production exception: null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`. Otherwise test/simulation use only. | +| `rcptto` | **Normally omitted** — Momentum auto-populates from the active envelope recipient. Production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly for complete §10.4 multi-recipient checking. In `validate_data_spool_each_rcpt` (recommended), auto-populates correctly per cowref. Accepts a string or a Lua table of bare addresses. ALL listed addresses must be present in `rt=` for the signature to pass. | | `authservid` | When set, a new `Authentication-Results:` header is prepended (when the result contains at least one actionable clause) with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | | `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | | `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | From 8eb2dc65372516f40706c196b3399d29b542e35d Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 17 Jun 2026 13:33:47 +0000 Subject: [PATCH 49/52] =?UTF-8?q?dkim2:=20fix=20=C2=A710.5/=C2=A710.6=20ch?= =?UTF-8?q?ain=5Fverified=20doc=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- content/momentum/4/dkim2.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index d8dd47556..a00c3a656 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -474,8 +474,10 @@ intermediate hop correctly recorded what it changed, and that those changes are consistent all the way back to the original sender. If anything in that chain is wrong (a hop modified the message without recording it, or a recipe was incorrect), `overall` is `permerror` with -`overall_reason="chain_broken"`. You do not need to do anything special -in policy code — `overall="pass"` means the entire history checked out. +`overall_reason="chain_broken"`. `overall="pass"` means the content +chain is intact; note that public-key (§10.5) cryptographic verification +is only performed for the most recent hop — see Known Limitations for +details. ### SMTP response codes (§9.4 guidance) @@ -549,7 +551,8 @@ empty. It also returns `nil` when all per-signature entries are non-actionable Each entry is a complete clause string (e.g. `"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). The array contains one entry per actionable signature — signatures with -`status="chain_verified"` (deferred, no RFC 8601 equivalent) and +`status="chain_verified"` (lower-hop: public-key verification not +performed, so no `dkim2=pass` claim can be asserted for them) and `status="none"` (unsupported algorithm, §3.4 — no `dkim2=none` token exists) are excluded. Extra overall clauses for chain failures or policy downgrades are appended when applicable. @@ -672,7 +675,7 @@ The full set: | Reason | Meaning | |---|---| | `ok` | Signature verified cleanly. Paired with `status="pass"`. | -| `deferred` | An earlier hop's signature in a multi-hop message. Momentum validates the full chain of custody end-to-end rather than re-running that hop's cryptographic check directly (the content was legitimately modified by later hops, so a direct crypto check would always fail). If the chain is intact, `overall="pass"`. Paired with `status="chain_verified"`. | +| `deferred` | An earlier hop's signature in a multi-hop message. Momentum validates the full chain of custody end-to-end via the §10.6 recipe chain rather than performing a full §10.5 per-signature key lookup and cryptographic check for each lower hop. If the chain is intact, `overall="pass"`. See Known Limitations for what this means for key provenance. Paired with `status="chain_verified"`. | | `hh_mismatch` | Header hash mismatch — a content header (Subject, From, etc.) was modified after signing without a new `Message-Instance:` recording the change. | | `bh_mismatch` | Body hash mismatch — the message body was modified after signing without a new `Message-Instance:` recording the change. | | `sig_invalid` | Cryptographic verification failed — the signed-input bytes don't match the value in `s=`. Enable `debug_level = info` for selector, algorithm, and signed-input length detail. | @@ -775,6 +778,23 @@ the other. Receivers that support both will evaluate each chain separately. The following are known gaps or operational considerations to be aware of: +* **§10.5/§10.6 Lower-hop signatures not cryptographically verified**: + §10.5 covers the full per-signature verification procedure (key + lookup, record validation, and EVP cryptographic verification) for all + signatures; §10.6 requires that the recipe chain be checked for every + hop. Momentum satisfies §10.6 for all hops, and applies the full §10.5 + procedure only to the highest-i signature (the most recent hop). For + earlier hop signatures (`i < max_i`), only the §10.6 recipe-chain hash + comparison is performed — no key lookup and no EVP crypto. These + appear as `status="chain_verified"`. The recipe chain confirms + end-to-end content integrity (the fully reconstructed original-state + hashes match the recorded MI[1] values) but does not verify that each + lower-hop signature was made with the claimed signing key. Full §10.5 + compliance for lower hops would require reconstructing each hop's + message state by reverse-applying subsequent recipes and + EVP-verifying each lower-hop signature against that state — a + significant architectural change planned for a future release. + * **§9.1 / §11 DSN**: The spec requires that when DKIM2 verification fails the MTA MUST NOT generate a DSN — reject with 5xx instead. This is not automatically enforced; policy must explicitly reject From 892316573d23aa2299f6e78cdb064e6941534f5f Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 17 Jun 2026 13:34:20 +0000 Subject: [PATCH 50/52] dkim2: soften future-release wording in known limitation Co-Authored-By: Claude Sonnet 4.6 --- content/momentum/4/dkim2.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index a00c3a656..6a0ff3311 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -793,7 +793,8 @@ The following are known gaps or operational considerations to be aware of: compliance for lower hops would require reconstructing each hop's message state by reverse-applying subsequent recipes and EVP-verifying each lower-hop signature against that state — a - significant architectural change planned for a future release. + significant architectural change that can be planned in a future + release if desirable. * **§9.1 / §11 DSN**: The spec requires that when DKIM2 verification fails the MTA MUST NOT generate a DSN — reject with 5xx instead. From 411c25bab42771afa6e97fa753951cb639fc8fae Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 17 Jun 2026 23:09:05 +0000 Subject: [PATCH 51/52] update along with the code change --- content/momentum/4/dkim2.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index 6a0ff3311..cfb13adc1 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -220,10 +220,12 @@ are header-level and go at the top level of the options table. | `mailfrom` | no | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Two production exceptions: (1) null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`; (2) testing/simulation of specific envelope conditions without real SMTP transit. | | `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. One production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly to cover all recipients in a single `rt=`. In `validate_data_spool_each_rcpt` (recommended), each cowref auto-populates correctly. Accepts a string or a Lua table of bare addresses. | | `bridge_mailfrom` | no | The `mf=` for an auto-generated bridging signature when the new `mf=` is not in the previous signature's `rt=` (§8.2). Required when the prior `rt=` has multiple entries; inferred automatically when it has exactly one. | +| `bridge_flags` | no | Flag tokens (same format as `flags`) to set on the auto-generated bridge signature only. The primary signature is unaffected. A non-table value always returns an error regardless of whether a bridge fires. A valid table value (or nil) is silently ignored when no bridge is generated — either because `on_chain_break` is not `"bridge"`, or because `on_chain_break` is `"bridge"` but no chain break is detected. Example: `bridge_flags={"donotmodify"}` to prevent further modifications after the bridge hop. | | `on_chain_break` | no | Action when a §8.2 chain break is detected: `"bridge"` (default when `bridge_mailfrom` set), `"skip"` (default otherwise), `"warn"`, or `"error"`. See the Forwarder signing section for details. | +| `on_donotmodify` | no | Action when any prior `DKIM2-Signature` on the message carries `f=donotmodify` (§7.9 / §10.8). The check is unconditional — it does not detect whether content was actually modified. Values: `"error"` (default — refuse to sign), `"warn"` (proceed; caller is responsible for logging/auditing), `"skip"` (return `(true, nil, {donotmodify=true})` without signing), `"ignore"` (proceed silently). | | `timestamp` | no | `t=` value. Defaults to the current UNIX time. | | `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | -| `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. | +| `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. Inherited by auto-bridge signatures so every signature in the chain gets its own fresh nonce. | | `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. **Note:** the auto-`exploded` heuristic is based solely on recipient count — it triggers when `rt=` contains more than one address. Mailing lists with a single subscriber will not have `"exploded"` added automatically; pass `flags = {"exploded"}` explicitly in that case. | | `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | | `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | @@ -314,6 +316,24 @@ The recipe schema is documented in `-02` §4. Recipes are mandatory only when the hop modifies content; non-modifying hops (pure-forwarding without edits) omit `recipe` entirely. +When `on_chain_break="bridge"` is used and the message was modified, +supply `recipe` (and `allow_recipe_z` if needed) on the outer `sign()` +call — Momentum forwards them automatically to the auto-generated bridge +signature. The bridge needs the recipe to document the content change in +its `Message-Instance` header so the §10.6 chain walk can reconstruct the +original state. + +**Note**: auto-bridge signatures do not inherit `flags`. Use `bridge_flags` +to set flags on the bridge signature independently of the primary. For +example, `bridge_flags={"donotmodify"}` marks the bridge hop as +non-modifiable while leaving the primary signature's `flags` unchanged. + +`nonce_random` is inherited by the bridge so that when it is set, each +signature gets its own fresh nonce. An explicit `nonce=` value is NOT +inherited — the bridge's `n=` tag is absent (unless `nonce_random` was +set) to avoid two signatures sharing the same nonce value, which would +defeat anti-replay protection. + ## DKIM2 Verifying From 7faadc50be98acf213b03cc2ca0e92dae7031473 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Wed, 17 Jun 2026 23:38:32 +0000 Subject: [PATCH 52/52] split into parent overview + per-API child pages --- content/momentum/4/dkim2.md | 667 ++----------------------- content/momentum/4/dkim2/ar-clauses.md | 110 ++++ content/momentum/4/dkim2/debug.md | 114 +++++ content/momentum/4/dkim2/sign.md | 221 ++++++++ content/momentum/4/dkim2/verify.md | 216 ++++++++ 5 files changed, 701 insertions(+), 627 deletions(-) create mode 100644 content/momentum/4/dkim2/ar-clauses.md create mode 100644 content/momentum/4/dkim2/debug.md create mode 100644 content/momentum/4/dkim2/sign.md create mode 100644 content/momentum/4/dkim2/verify.md diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index cfb13adc1..31b6f478e 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -1,6 +1,6 @@ --- lastUpdated: "06/09/2026" -title: "Using DKIM2 (DomainKeys Identified Mail v2) Signatures" +title: "Using DKIM2 — Overview" description: "DKIM2 is the successor to DKIM that adds replay protection (per-message envelope binding), an explicit chain of custody across forwarders, and a structured way for modifying hops to record what they changed. Momentum implements DKIM2 targeting draft-ietf-dkim-dkim2-spec-02." --- @@ -9,22 +9,16 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me - [What DKIM2 is, and why](#what-dkim2-is-and-why) - [How it differs from DKIM1 at a glance](#how-it-differs-from-dkim1-at-a-glance) - [Enabling the module](#enabling-the-module) -- [DKIM2 Signing](#dkim2-signing) - - [Signing hook: shared vs. per-recipient](#signing-hook-shared-vs-per-recipient) - - [Sign options](#sign-options) - - [Forwarder and modifier signing](#forwarder-and-modifier-signing) -- [DKIM2 Verifying](#dkim2-verifying) - - [Verify options](#verify-options) - - [Result table](#result-table) - - [SMTP response codes](#smtp-response-codes-94-guidance) -- [Authentication-Results Output](#authentication-results-output) -- [Debugging](#debugging) - - [Per-signature reason codes](#per-signature-reason-codes) - - [recipe_chain detail strings](#recipe-chain-detail-strings-paniclog-only) - - [ec_message context fields](#ec-message-context-fields) - [Key management](#key-management) - [Known limitations](#known-limitations) +## Reference + +- [DKIM2 Signing — sign()](/momentum/4/dkim2/sign) +- [DKIM2 Verifying — verify()](/momentum/4/dkim2/verify) +- [DKIM2 Authentication-Results — ar_clauses()](/momentum/4/dkim2/ar-clauses) +- [DKIM2 Debugging Reference](/momentum/4/dkim2/debug) + --- ### Warning @@ -86,7 +80,7 @@ specifics live in the [IETF draft](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-02); the operationally-relevant signal codes (per-signature reasons, overall verdicts, paniclog lines) are inventoried in the -[Debugging](/momentum/4/dkim2#debugging) section below. +[Debugging](/momentum/4/dkim2/debug) reference page. ## How it differs from DKIM1 at a glance @@ -108,45 +102,28 @@ provisioning step to start signing DKIM2. ## Enabling the module -Add the following stanza to your Momentum configuration before using any -DKIM2 Lua API: +**Step 1 — Configuration**: Add the following stanza to your Momentum +configuration before using any DKIM2 Lua API: ``` dkim2 {} ``` The `debug_level` option is documented in the -[Debugging](/momentum/4/dkim2#debugging) section. - -## DKIM2 Signing - -DKIM2 signing in Momentum is driven from Lua policy via -`msys.validate.dkim2.sign`; enabling DKIM2 signing means calling `sign()` from -your validation hook. - -### Signing hook: shared vs. per-recipient - -`sign()` can be called from either `validate_data_spool` or -`validate_data_spool_each_rcpt`. The choice affects how `rt=` is populated -and whether BCC addresses are exposed. - -| | `validate_data_spool` | `validate_data_spool_each_rcpt` | -|---|---|---| -| **Fires** | Once on the shared parent message | Once per recipient (cowref) | -| **`rt=` auto-populate** | Primary recipient only (`msg:rcptto()`) — extra recipients are inaccessible in this hook | Single cowref recipient | -| **Multi-recipient rt=** | Must pass explicit `rcptto = {r1, r2, ...}` — collect the full list in an earlier hook (e.g. `validate_rcptto`) | Each cowref signs for its own single address automatically | -| **BCC privacy** | ⚠️ Operator must exclude BCC from the explicit `rcptto` list — a shared signature exposing a BCC address is visible to all recipients | ✅ No concern — each cowref is private to that recipient; `rt=` is bound to their address only | -| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; correct by default | +[Debugging](/momentum/4/dkim2/debug) reference page. -Use `validate_data_spool_each_rcpt` for most deployments — it handles -per-recipient signing automatically. Use `validate_data_spool` -only when you need a single signature covering all recipients and are willing -to manage the recipient list and BCC exclusion yourself. +**Step 2 — Policy hook**: DKIM2 signing and verification are driven +entirely from Lua policy. The module does nothing automatically — you +must call the APIs explicitly from a validation hook. The recommended +hook is `validate_data_spool_each_rcpt`, which runs once per recipient +and gives sign() access to the per-recipient envelope address for +`rt=` binding: -Passing an explicit `rcptto` option overrides the auto-populated primary recipient. -Accepts a string (single address) or a Lua table of bare addresses. +Signing and verification are separate concerns — typically signing is done +on outbound messages and verification on inbound. The examples below show +each in isolation. -### Minimum signer +**Outbound signing:** ```lua require("msys.core") @@ -157,13 +134,13 @@ local mod = {} function mod:validate_data_spool_each_rcpt(msg, ac, vctx) local ok, err = msys.validate.dkim2.sign(msg, vctx, { domain = "example.com", - selector = "dkim2048", - keyfile = "/opt/msys/ecelerity/etc/conf/dkim/example.com/dkim2048.key", + selector = "dkim2-2026", + keyfile = "/etc/dkim2/example.com/dkim2-2026.key", }) if not ok then - -- err is a static-literal string describing the failure. See the - -- "Debugging" section below for the full set. - print("dkim2 sign failed: " .. tostring(err)) + msys.log(msys.core.LOG_WARNING, + "dkim2 sign failed: " .. (err or "unknown")) + -- message continues unsigned; adjust policy as needed end return msys.core.VALIDATE_CONT end @@ -171,188 +148,7 @@ end msys.registerModule("my_dkim2_signer", mod) ``` -`mf=` defaults to the message's envelope MAIL FROM and `rt=` defaults to its -RCPT TO; both can be overridden in the options table for forwarder -scenarios (see *Forwarder / modifier signing* below). - -### Sign options - -`sign()` accepts either a single options table or a multi-algorithm -form using an explicit `sig_sets` key (§7.8 algorithm agility): - -```lua --- Single sig-set (most common): -msys.validate.dkim2.sign(msg, vctx, { - domain = "example.com", - selector = "sel-2048", - keyfile = "/etc/dkim2/rsa.key", -}) - --- Multi-algorithm (RSA + Ed25519 in one DKIM2-Signature): -msys.validate.dkim2.sign(msg, vctx, { - domain = "example.com", - sig_sets = { - { selector = "sel-rsa", keyfile = "/etc/dkim2/rsa.key" }, - { selector = "sel-ed25519", keyfile = "/etc/dkim2/ed25519.key", - algorithm = "ed25519-sha256" }, - }, -}) -``` - -When `sig_sets` is present, all entries sign the same canonical -signed-input and are combined into a single `s=sel1:alg1:sig1,sel2:alg2:sig2` -value on one `DKIM2-Signature` header. Per §7.2 the verifier checks -every sig-set; overall passes if any one validates, so a receiver that -only supports RSA will still verify cleanly. On the verifier side, any -sig-set that fails alongside a passing one is reported as a DWARNING in -paniclog (partial-sig-failure condition, §7.2). The `selector`, `keyfile`, and -`algorithm` fields belong to each sig-set entry; all other options below -are header-level and go at the top level of the options table. - -| Option | Required? | Meaning | -|---|---|---| -| `domain` | yes | `d=` tag — the signing domain. | -| `selector` | yes (single) | Selector component of `s=::`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | -| `keyfile` | yes (single) | Path to the PEM-encoded private key on disk. Mutually exclusive with `keybuf`; one of the two is required. When `sig_sets` is used, set per entry inside `sig_sets` instead. | -| `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. | -| `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | -| `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, fields supplied in `sig_sets[1]` override the corresponding top-level fields; any field omitted from `sig_sets[1]` falls back to the top-level value. | -| `mailfrom` | no | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Two production exceptions: (1) null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`; (2) testing/simulation of specific envelope conditions without real SMTP transit. | -| `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. One production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly to cover all recipients in a single `rt=`. In `validate_data_spool_each_rcpt` (recommended), each cowref auto-populates correctly. Accepts a string or a Lua table of bare addresses. | -| `bridge_mailfrom` | no | The `mf=` for an auto-generated bridging signature when the new `mf=` is not in the previous signature's `rt=` (§8.2). Required when the prior `rt=` has multiple entries; inferred automatically when it has exactly one. | -| `bridge_flags` | no | Flag tokens (same format as `flags`) to set on the auto-generated bridge signature only. The primary signature is unaffected. A non-table value always returns an error regardless of whether a bridge fires. A valid table value (or nil) is silently ignored when no bridge is generated — either because `on_chain_break` is not `"bridge"`, or because `on_chain_break` is `"bridge"` but no chain break is detected. Example: `bridge_flags={"donotmodify"}` to prevent further modifications after the bridge hop. | -| `on_chain_break` | no | Action when a §8.2 chain break is detected: `"bridge"` (default when `bridge_mailfrom` set), `"skip"` (default otherwise), `"warn"`, or `"error"`. See the Forwarder signing section for details. | -| `on_donotmodify` | no | Action when any prior `DKIM2-Signature` on the message carries `f=donotmodify` (§7.9 / §10.8). The check is unconditional — it does not detect whether content was actually modified. Values: `"error"` (default — refuse to sign), `"warn"` (proceed; caller is responsible for logging/auditing), `"skip"` (return `(true, nil, {donotmodify=true})` without signing), `"ignore"` (proceed silently). | -| `timestamp` | no | `t=` value. Defaults to the current UNIX time. | -| `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | -| `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. Inherited by auto-bridge signatures so every signature in the chain gets its own fresh nonce. | -| `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. **Note:** the auto-`exploded` heuristic is based solely on recipient count — it triggers when `rt=` contains more than one address. Mailing lists with a single subscriber will not have `"exploded"` added automatically; pass `flags = {"exploded"}` explicitly in that case. | -| `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | -| `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | -| `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` (spec-compliant — `sign()` returns an error on mismatch). **Setting to `true` is non-spec-compliant**; it downgrades the check to a `DWARNING` and proceeds. Recommended only for testing or debugging cross-domain signing configurations. | -| `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | - -`sign()` returns `(true, header_value_string)` on success and `(nil, -error_string)` on failure. Always check the return; on failure the message -is left unmodified (no `DKIM2-Signature:` or `Message-Instance:` is -attached). Recipe validation failure and content-changed-without-recipe -also log to paniclog at level `error`; most other failure paths return -only the error string to the caller without logging. - -### Forwarder and modifier signing - -When a forwarder changes the envelope MAIL FROM to an address not present -in the previous signature's `rt=` list, §8.2 requires an extra bridging -`DKIM2-Signature` before the primary — one whose `mf=` matches the previous -`rt=` and whose own `rt=` contains the new outgoing MAIL FROM. Momentum -automates this: supply `bridge_mailfrom` with the address this hop received -the message at, and `sign()` detects the chain break and prepends the bridge -automatically. - -The most common case is a **mailing list**: the original sender's signature -has `rt=list@mailing-list.com`; the list re-sends with -`MAIL FROM: bounce@mailing-list.com`, which is not in the prior `rt=` — -a chain break that requires a bridge: - -```lua --- Mailing list scenario: --- i=1 (originator): mf=alice@sender.com rt=list@mailing-list.com --- i=2 (auto-bridge): mf=list@mailing-list.com rt=bounce@mailing-list.com --- i=3 (primary): mf=bounce@mailing-list.com rt=subscriber@recipient.com -local ok, val, info = msys.validate.dkim2.sign(msg, vctx, { - domain = "mailing-list.com", - selector = "list-2026", - keyfile = "/etc/dkim2/mailing-list.com/list-2026.key", - mailfrom = "bounce@mailing-list.com", - rcptto = "subscriber@recipient.com", - bridge_mailfrom = "list@mailing-list.com", -- the address the list received at - -- on_chain_break defaults to "bridge" since bridge_mailfrom is provided -}) -if not ok then - -- sign() failed (key error, bridge error, etc.) - vctx:set_code(550, "5.7.1 DKIM2 signing failed: " .. tostring(val)) - return msys.core.VALIDATE_DONE -end --- info.chain_break=true, info.bridged=true when bridge was auto-generated -``` - -When the forwarding address is unambiguous (prior `rt=` has a single entry), -`bridge_mailfrom` can be omitted — Momentum infers it automatically. When the -prior `rt=` has multiple entries, `bridge_mailfrom` is required to identify -which entry this hop received at. - -The `on_chain_break` option controls what happens when a chain break is -detected but cannot be bridged: - -| `on_chain_break` | Behavior | Third return value | -|---|---|---| -| `"bridge"` (default with `bridge_mailfrom`) | Auto-bridge; error if ambiguous | `{chain_break=true, bridged=true}` | -| `"skip"` (default without `bridge_mailfrom`) | Skip signing | `{chain_break=true, bridged=false}` | -| `"warn"` | Sign without bridge | `{chain_break=true, bridged=false}` | -| `"error"` | Return `(nil, errmsg)` | — | - -The third return value gives policy full control: inspect `info.chain_break` -and `info.bridged` to decide whether to accept, reject, or log — regardless -of which `on_chain_break` value was used. - -A forwarder that does not change the MAIL FROM (pure relay) signs with -the envelope values directly — no bridge needed since the chain is intact: - -A modifier that **rewrites** the message (Subject change, body footer, -attachment strip, etc.) additionally attaches a `recipe`: - -```lua --- Forwarder rewrote Subject; recipe restores the original on --- reverse-apply. -msys.validate.dkim2.sign(msg, vctx, { - domain = "list.example.org", - selector = "list-2026", - keyfile = "/etc/dkim2/list.example.org/list-2026.key", - recipe = [[{"h":{"Subject":[{"d":["Original subject"]}]}}]], -}) -``` - -The recipe schema is documented in `-02` §4. Recipes are mandatory only -when the hop modifies content; non-modifying hops (pure-forwarding without -edits) omit `recipe` entirely. - -When `on_chain_break="bridge"` is used and the message was modified, -supply `recipe` (and `allow_recipe_z` if needed) on the outer `sign()` -call — Momentum forwards them automatically to the auto-generated bridge -signature. The bridge needs the recipe to document the content change in -its `Message-Instance` header so the §10.6 chain walk can reconstruct the -original state. - -**Note**: auto-bridge signatures do not inherit `flags`. Use `bridge_flags` -to set flags on the bridge signature independently of the primary. For -example, `bridge_flags={"donotmodify"}` marks the bridge hop as -non-modifiable while leaving the primary signature's `flags` unchanged. - -`nonce_random` is inherited by the bridge so that when it is set, each -signature gets its own fresh nonce. An explicit `nonce=` value is NOT -inherited — the bridge's `n=` tag is absent (unless `nonce_random` was -set) to avoid two signatures sharing the same nonce value, which would -defeat anti-replay protection. - - -## DKIM2 Verifying - -DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. -`verify()` can be called from either `validate_data_spool` or -`validate_data_spool_each_rcpt`. The choice affects how the §10.4 `rt=` -binding check is performed: - -| | `validate_data_spool` | `validate_data_spool_each_rcpt` | -|---|---|---| -| **Fires** | Once on shared parent message | Once per recipient (cowref) | -| **`rt=` auto-check** | First accessible recipient only (`msg:rcptto()`) — **all other recipients bypass the §10.4 check** unless explicitly listed in `rcptto` | Single cowref recipient checked; §10.4 satisfied per-delivery | -| **Multi-recipient §10.4** | ⚠️ Must pass explicit `rcptto = {r1, r2, ...}` — omitting any recipient silently skips its binding check | ✅ Every recipient verified automatically in its own cowref | -| **BCC support** | ⚠️ Operator must exclude BCC from explicit `rcptto` — omitting a BCC address skips its §10.4 binding check | ✅ Each cowref checked independently; no special handling needed | -| **Complexity** | Requires explicit recipient collection for complete §10.4 compliance | One `verify()` call per cowref; correct by default | - -Use `validate_data_spool_each_rcpt` for most deployments — it satisfies §10.4 -for every recipient automatically without additional setup. -Typical inbound policy: +**Inbound verification:** ```lua require("msys.core") @@ -362,406 +158,25 @@ local mod = {} function mod:validate_data_spool_each_rcpt(msg, ac, vctx) local result, err = msys.validate.dkim2.verify(msg, vctx, { - authservid = "mta-1.example.com", + authservid = "mta.example.com", }) if not result then - -- Internal error (alloc failure, crypto init error, etc.) — err carries - -- the reason string. Distinct from a per-sig fail, which lands in - -- result.signatures. Defer rather than silently accepting. - msys.log(msys.core.DWARNING, "DKIM2 verify failed internally: " .. tostring(err)) - vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") - return msys.core.VALIDATE_CONT - end - - -- result.overall is one of: - -- "pass" all sigs verified, chain intact - -- "fail" verified but wrong: hash/sig mismatch or policy - -- violation (d=/mf= mismatch, donotmodify, etc.) - -- "permerror" could not verify: key missing/invalid/revoked, - -- signature syntax error, or chain integrity failure - -- (overall_reason="chain_broken" for chain failures; - -- nil for key/syntax errors — check signatures[i].reason) - -- "temperror" resolver-side transient failure (SERVFAIL, timeout) - -- "none" no DKIM2-Signature headers, or all use unsupported - -- algorithms (§3.4 — ignored rather than failed) - - if result.overall == "temperror" then - -- Transient DNS failure: set a 4xx code so Momentum issues a - -- temporary rejection after the validation pipeline completes, - -- allowing the sender to retry once the resolver recovers. - vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") - end - - if result.overall == "fail" or result.overall == "permerror" then - vctx:set_code(550, "5.7.1 DKIM2 verification failed") + -- internal error; treat as temperror + msys.log(msys.core.LOG_WARNING, + "dkim2 verify error: " .. (err or "unknown")) + vctx:set_code(451, "4.7.5 DKIM2 verification error; please retry") + return msys.core.VALIDATE_DONE end - + -- result.overall: "pass" | "fail" | "permerror" | "temperror" | "none" return msys.core.VALIDATE_CONT end msys.registerModule("my_dkim2_verifier", mod) ``` -See [Authentication-Results Output](#authentication-results-output) for the AR -header format, `ar_clauses()` API, and examples of building combined headers. - -### Verify options - -| Option | Meaning | -|---|---| -| `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | -| `mailfrom` | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Production exception: null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`. Otherwise test/simulation use only. | -| `rcptto` | **Normally omitted** — Momentum auto-populates from the active envelope recipient. Production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly for complete §10.4 multi-recipient checking. In `validate_data_spool_each_rcpt` (recommended), auto-populates correctly per cowref. Accepts a string or a Lua table of bare addresses. ALL listed addresses must be present in `rt=` for the signature to pass. | -| `authservid` | When set, a new `Authentication-Results:` header is prepended (when the result contains at least one actionable clause) with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | -| `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | -| `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | -| `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag. Default `false` — duplicates produce `reason=parse_error` per §7.8. **Setting this to `true` makes the verifier non-spec-compliant** — §7.8 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | -| `max_sig_age_days` | §10.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | -| `max_sig_future_secs` | §7.4: reject signatures whose `t=` timestamp is more than this many seconds in the future. Default `300` (5-minute clock-skew tolerance). Values `<= 0` disable the check. | -| `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message. Useful for staging and debugging; **do not enable in production** as these headers expose internal verification detail and inflate message size. Default `false`. | - -`verify()` returns `(result, err)`: -- **Normal execution** (including messages with no DKIM2 signatures): `result` is - the result table below, `err` is `nil`. A message with no signatures returns - `result.overall = "none"` — `result` is never `nil` in this case. -- **Internal failure** (alloc or crypto init error): `result` is `nil`, `err` is a - non-nil string describing the cause. - -Always capture both return values so internal failures can be logged and acted on -separately from signature verdicts. - -### Result table - -``` -result = { - overall = "pass" -- all verifiable signatures passed - | "fail" -- verified but wrong: hash/sig mismatch, or - | -- policy violation (d=/mf= mismatch, donotmodify, etc.) - | "permerror" -- could not verify: key missing/revoked/invalid, - | -- signature syntax error, or chain integrity failure - | -- (§10.1 PERMERROR) - | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) - | "none", -- no DKIM2-Signature headers present, or all - | -- use unsupported algorithms (§3.4) - overall_reason = nil -- nil when overall="pass", "temperror", - -- or when overall is non-pass due to - -- per-sig failures (key errors, bad - -- crypto, syntax errors) — check - -- result.signatures[i].reason for detail. - -- Non-nil only for structural conditions - -- that apply to the chain as a whole: - | "chain_broken" -- overall="permerror": chain integrity - -- failure (MI gap, recipe mismatch, etc.) - | "d_mf_mismatch" -- overall="fail": d= doesn't match - -- mf= domain after crypto pass (§7.7) - | "donotmodify_violated" -- overall="fail": f=donotmodify sig - -- followed by a modifying hop (§10.8) - | "donotexplode_violated", -- overall="fail": f=donotexplode sig - -- followed by f=exploded (§10.8) - signatures = { - { seq = , - m = , - status = "pass" -- signature verified - | "fail" -- signature failed; see reason - | "chain_verified" -- earlier hop (i, -- see Per-signature reason codes table below - d = "", - s = "::", -- raw s= value; AR header.s= carries - -- only ":" (base64 stripped) - mf = "", -- decoded from base64 - rt = "[,...]", -- all entries decoded from base64 - n = "", -- if present - f = "", -- if present; comma-separated - key_testing = true, -- if present: signing key has t=y - -- (RFC 6376 §3.6.1 testing mode). - -- Per spec, failures SHOULD NOT be - -- treated as definitive when set. - }, - ... - } -} -``` - -For messages that passed through multiple signing hops, Momentum verifies -the **most recent signature** cryptographically (§10.5) and confirms the -**full chain of custody** end-to-end (§10.6). Earlier signatures in a -multi-hop message appear in `result.signatures` with -`status="chain_verified"` — this means Momentum validated that each -intermediate hop correctly recorded what it changed, and that those -changes are consistent all the way back to the original sender. If -anything in that chain is wrong (a hop modified the message without -recording it, or a recipe was incorrect), `overall` is `permerror` with -`overall_reason="chain_broken"`. `overall="pass"` means the content -chain is intact; note that public-key (§10.5) cryptographic verification -is only performed for the most recent hop — see Known Limitations for -details. - - -### SMTP response codes (§9.4 guidance) - -Momentum leaves the decision of whether to accept, reject, or defer a -message — and which SMTP reply code to use — entirely to the operator's -Lua hook. The `overall` field of the verify result maps to the following -SMTP behaviour as required by §9.4 of the DKIM2 spec: - -| `overall` | Meaning | §9.4 guidance | Suggested action | -|---|---|---|---| -| `pass` | All verifiable signatures passed | — | Accept | -| `none` | No DKIM2 signatures present, or all use unsupported algorithms (§3.4) | — | Local policy | -| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject or accept per policy | -| `permerror` | Could not verify: key missing/revoked/invalid, syntax error, or chain integrity failure (`overall_reason="chain_broken"`) (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | -| `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | - -**Key rules from §9.4**: -- `fail` and `permerror` **MUST NOT** use a 4xx reply code. -- Only `temperror` warrants a temporary (4xx) failure code. - -Example hook skeleton: - -```lua -local result, err = msys.validate.dkim2.verify(msg, vctx, { ... }) -local overall = result and result.overall or "none" - -if overall == "permerror" or overall == "fail" then - -- §9.4 SHOULD 550/5.7.x for permanent failures. - -- Note: "permerror" MUST NOT use 4xx. - vctx:set_code(550, "5.7.1 DKIM2 verification failed") - return msys.core.VALIDATE_DONE - -elseif overall == "temperror" then - -- §9.4 MAY 451/4.7.5 for transient key-fetch failures - vctx:set_code(451, "4.7.5 DKIM2 key server temporarily unavailable") - return msys.core.VALIDATE_DONE - -else - -- pass / none: local policy - return msys.core.VALIDATE_CONT -end -``` - -> **Note**: Whether to reject on `fail` or `none` is a local policy -> decision. The spec only mandates the reply-code *type* (4xx vs 5xx) -> for the cases shown above. - - -## Authentication-Results Output - -When `authservid` is supplied to `verify()`, Momentum automatically builds -and prepends a fresh `Authentication-Results:` header (RFC 8601 §5 — an MTA -MUST NOT add to an existing AR header): - -```lua -msys.validate.dkim2.verify(msg, vctx, { - authservid = "mta-1.example.com", -}) -``` - -For full control — or to merge DKIM2 results with other authentication methods -(SPF, DKIM1, ARC) into a single combined header — use -`msys.validate.dkim2.ar_clauses(result)`. - -`ar_clauses()` returns an array of DKIM2 `Authentication-Results:` clause -strings for the given verify result. It returns `nil` when `result` is `nil`, or `result.signatures` is absent or -empty. It also returns `nil` when all per-signature entries are non-actionable -(`status="chain_verified"` or `status="none"`) and `result.overall` is `"none"`. - -Each entry is a complete clause string (e.g. -`"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). -The array contains one entry per actionable signature — signatures with -`status="chain_verified"` (lower-hop: public-key verification not -performed, so no `dkim2=pass` claim can be asserted for them) and -`status="none"` (unsupported algorithm, §3.4 — no `dkim2=none` token exists) -are excluded. Extra overall clauses for chain failures or policy downgrades -are appended when applicable. - -### Usage examples - -```lua --- Omit authservid so no DKIM2-only AR header is auto-prepended; build --- the combined header below. -local result, err = msys.validate.dkim2.verify(msg, vctx) -local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} -local spf_clause = build_spf_clause() -- caller-supplied -local all_clauses = { spf_clause } -for _, c in ipairs(dkim2_clauses) do all_clauses[#all_clauses + 1] = c end -msg:header("Authentication-Results", - "mta-1.example.com; " .. table.concat(all_clauses, "; "), - "prepend") -``` - -### Output format - -> **Note on `header.s=`:** In DKIM1, `header.s=` carries just the selector name. -> In DKIM2 the `s=` wire tag encodes selector, algorithm, and signature together; -> Momentum emits only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in -> `header.s=`, omitting the bulk base64 signature bytes. - -Normal pass: - -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf=sender@example.com header.rt=rcpt@a.com -``` - -Transient DNS failure (`key_unavailable` → `dkim2=temperror`): - -``` -Authentication-Results: mta-1.example.com; - dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf=sender@example.com header.rt=rcpt@a.com -``` - -Permanent error — key does not exist in DNS (`no_key` → `dkim2=permerror`): - -``` -Authentication-Results: mta-1.example.com; - dkim2=permerror reason="public key does not exist" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf=sender@example.com header.rt=rcpt@a.com -``` - -Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): - -``` -Authentication-Results: mta-1.example.com; - dkim2=fail reason="body hash mismatch" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf=sender@example.com header.rt=rcpt@a.com -``` - -When the overall verdict is worse than the per-sig result — chain failure or -policy downgrade after a crypto pass — an extra overall clause is appended: - -Chain-broken example (2-hop message: crypto passed but recipe-chain check failed): - -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.s=sel-2:rsa-sha256 header.i=2 header.m=2 - header.mf=bounce@forwarder.example.net header.rt=rcpt@a.com; - dkim2=permerror reason="chain of custody broken" -``` - -Policy-downgrade example (`d=` does not match the `mf=` domain): - -``` -Authentication-Results: mta-1.example.com; - dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 - header.mf=sender@example.com header.rt=rcpt@a.com; - dkim2=fail reason="MAIL FROM and d= do not match" -``` - - -## Debugging - -Setting `debug_level` on the `dkim2` configuration stanza routes sign and -verify activity to `paniclog`: - -``` -dkim2 { - debug_level = "info" -} -``` - -| Level | What surfaces | -|---|---| -| `error` | Failures and resolver problems only. **Default.** | -| `warning` | Adds DNS issues and SHOULD-violation warnings. | -| `info` | Adds one DNS resolution line per verified signature plus verification failures with their cause (`bh_mismatch` with expected vs. actual hash; `sig_invalid` with selector, algorithm, signed-input length, and OpenSSL detail). | -| `debug` | Adds raw TXT-record bytes from the resolver, a per-crypto-check trace line, and the raw signed-input bytes on failure. Too noisy for steady-state production; useful when chasing a specific sign/verify mismatch. | - -### Per-signature reason codes - -Every signature on a verified message gets a `reason` string in -`result.signatures[i].reason`. These codes are Momentum-internal tokens — -not defined by the DKIM2 spec — but are exposed through the `verify()` API. -They appear in `result.signatures[i].reason`, in the -`X-MSYS-DKIM2-Verify-Sig` debug header, and in `Authentication-Results:` -`reason=` output. Policy code can safely branch on them. - -The per-signature AR verdict is derived from `status` and `reason` together: -`status="pass"` → `dkim2=pass`; `status="fail"` → `dkim2=fail` by default, -promoted to `dkim2=temperror` or `dkim2=permerror` for specific reason codes -(noted in the table below); `status="chain_verified"` and `status="none"` are excluded from AR output. - -The full set: - -> **Note:** `d_mf_mismatch`, `donotmodify_violated`, and `donotexplode_violated` are -> **not** per-signature reason codes. They are set on `result.overall_reason` when a -> policy check downgrades the overall verdict after crypto passes. See the Result table -> above for details. - -| Reason | Meaning | -|---|---| -| `ok` | Signature verified cleanly. Paired with `status="pass"`. | -| `deferred` | An earlier hop's signature in a multi-hop message. Momentum validates the full chain of custody end-to-end via the §10.6 recipe chain rather than performing a full §10.5 per-signature key lookup and cryptographic check for each lower hop. If the chain is intact, `overall="pass"`. See Known Limitations for what this means for key provenance. Paired with `status="chain_verified"`. | -| `hh_mismatch` | Header hash mismatch — a content header (Subject, From, etc.) was modified after signing without a new `Message-Instance:` recording the change. | -| `bh_mismatch` | Body hash mismatch — the message body was modified after signing without a new `Message-Instance:` recording the change. | -| `sig_invalid` | Cryptographic verification failed — the signed-input bytes don't match the value in `s=`. Enable `debug_level = info` for selector, algorithm, and signed-input length detail. | -| `parse_error` | The `DKIM2-Signature:` header couldn't be parsed. Corrupt header or a broken upstream signer. | -| `missing_required_tags` | One or more of the seven required tags (`i=`, `m=`, `t=`, `mf=`, `rt=`, `d=`, `s=`) is absent from the signature. | -| `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). §10.3 classifies this as PERMERROR — Momentum treats it as permanently unverifiable and does not attempt cryptographic verification. Maps to `dkim2=permerror` in AR output. | -| `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. Treated as a soft policy failure (`dkim2=fail`): the timestamp was evaluated and rejected, but it is not a permanent infrastructure error — the spec (§7.4 MAY) does not define a verdict for this case. | -| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§7.3 SHOULD). Treated as `dkim2=fail` — the constraint is a SHOULD, not a structural permanent error. | -| `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM — replay-to-different-sender. | -| `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO — replay-to-different-recipient. | -| `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). Rolls up to `overall="temperror"`. | -| `no_key` | DNS returned NXDOMAIN — no TXT record exists for the selector. | -| `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | -| `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | -| `key_multiple_records` | DNS returned more than one TXT record for the selector (§10.5). DNS admin misconfiguration on the sender side — only one TXT record is allowed per selector. | -| `key_service_mismatch` | The DNS TXT record's `s=` service list does not include `email` or `*` (RFC 6376 §3.6.1). The key is published for a different service. | -| `key_invalid` | The DNS TXT record was present but structurally unusable (empty content, internal resolver error, or selector/domain too long to query). | -| `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | -| `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | -| `key_v_mismatch` | The DNS TXT record's `v=` tag does not match the expected value. Malformed or wrong-version key record. Maps to `dkim2=permerror`. | -| `key_p_missing` | The DNS TXT record has no `p=` tag (distinct from empty `p=` which is revocation). Malformed key record. Maps to `dkim2=permerror`. | -| `key_size_invalid` | The RSA public key is smaller than the 1024-bit minimum required by §3.2. Maps to `dkim2=permerror`. | -| `key_e_invalid` | The RSA public key exponent is not 65537 as required by §3.2. Maps to `dkim2=permerror`. | -| `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | -| `mi_hash_missing` | The body hash could not be retrieved from the `Message-Instance:` `h=` tag: either no MI with a matching sequence number (`m=`) was present, or the MI's `h=` tag was malformed or lacked a hash entry for the algorithm named in its own `h=` prefix. | -| `verify_internal` | An internal error occurred during signature verification (memory allocation failure or cryptographic library error). The signature could not be evaluated. Maps to `dkim2=permerror` in AR output. | -| `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | - -**Authentication-Results mapping (§10.1):** Most `status="fail"` reasons produce `dkim2=fail` in the AR header. Exceptions, per the §10.1 FAIL / PERMERROR / TEMPERROR distinction: -- `key_unavailable` → `dkim2=temperror` (transient DNS failure) -- The following produce `dkim2=permerror` (unrecoverable errors): `no_key`, `key_invalid`, `key_multiple_records`, `key_service_mismatch`, `key_k_unknown`, `key_revoked`, `key_b64_decode`, `key_der_parse`, `key_v_mismatch`, `key_p_missing`, `key_size_invalid`, `key_e_invalid`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` - -`reason=` is included in all failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`) and absent from pass clauses (`dkim2=pass`). - -### recipe_chain detail strings (paniclog only) - -When the recipe-chain check fails, the overall verdict is `permerror` -with `overall_reason="chain_broken"`, and the underlying cause is logged at `error` level in -paniclog as `recipe-chain check failed: recipe_chain: `. The -chain-check failure does NOT appear in the per-signature result struct — -it's a cross-hop verdict, not a per-signature outcome — so paniclog is the -only place this detail surfaces. - -| Detail | Meaning | -|---|---| -| `no_mi_1` | The message had ≥ 2 signatures but no `Message-Instance` with `m=1`. The chain has no anchor. | -| `parse_h` | `Message-Instance` `h=` tag didn't parse as `::`. The MI is malformed. | -| `recipe_decode` | A hop's `r=` value didn't base64-decode. Wire-format corruption or a broken signer. | -| `recipe_invalid` | A hop's recipe failed schema validation at verify time. Should not occur with conforming signers (sign-time validation prevents emission of bad recipes); appearing here means the signer is broken. | -| `irreversible` | A hop's recipe declared `"h": null`, `"b": null`, or `"b": {"z": true}`. The verifier can't reverse-reconstruct past this hop. Local policy may accept irreversibility from trusted forwarders. | -| `apply_failed` | A recipe references a header or body line that doesn't exist in the current message. The recipe is inconsistent with the modification it claims to describe — likely a downstream hop modified the message AGAIN without recording it. | -| `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should emit `r={"h":null,"b":null}` to declare irreversibility rather than omitting `r=` entirely. | -| `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Every non-first MI had a recipe, so the mismatch indicates a hop's recipe was wrong or a hop modified the message after signing. | - -### ec_message context fields - -`verify()` writes the following context variables so downstream hooks can -read the outcome without re-verifying or parsing `Authentication-Results:`: - -| Context key | Type | Value | -|---|---|---| -| `dkim2_overall` | string | Verdict: `"pass"`, `"fail"`, `"permerror"`, `"temperror"`, or `"none"`. See the verdict table above. | -| `dkim2_n_sigs` | string | Number of `DKIM2-Signature` headers found on the message. Parse with `tonumber()`. | - -These keys are not set until `verify()` runs. - +See [DKIM2 Signing](/momentum/4/dkim2/sign) and +[DKIM2 Verifying](/momentum/4/dkim2/verify) for the full option +reference and more complete policy examples. ## Key management @@ -832,7 +247,7 @@ The following are known gaps or operational considerations to be aware of: forward is happening and call `sign()` on its own. Without this explicit call, the receiver only sees the original sender's signature — it has no way to verify that the forwarder handled the message - correctly. See the *Forwarder / modifier signing* section for how to + correctly. See the [Forwarder and modifier signing](/momentum/4/dkim2/sign#forwarder-and-modifier-signing) section for how to do this. * **Content modifier recipe composition**: When a Momentum pipeline @@ -844,7 +259,7 @@ The following are known gaps or operational considerations to be aware of: the full diff is not available, or a precise recipe when it is. This allows signing to succeed; downstream verifiers will accept the message while understanding that body reconstruction through this hop - is not possible. See the *Forwarder / modifier signing* section for + is not possible. See the [Forwarder and modifier signing](/momentum/4/dkim2/sign#forwarder-and-modifier-signing) section for examples. The missing automation is having pipeline stages record their changes automatically — a planned Recipe Accumulator API will do this, letting `sign()` assemble the recipe without operator @@ -872,5 +287,3 @@ The following are known gaps or operational considerations to be aware of: to `fix` when DKIM2 signing is in use** — `ignore` causes DKIM2 to sign non-CRLF content as-is, breaking the signature at any downstream hop that normalizes line endings. - - diff --git a/content/momentum/4/dkim2/ar-clauses.md b/content/momentum/4/dkim2/ar-clauses.md new file mode 100644 index 000000000..16a3cb923 --- /dev/null +++ b/content/momentum/4/dkim2/ar-clauses.md @@ -0,0 +1,110 @@ +--- +lastUpdated: "06/09/2026" +title: "DKIM2 Authentication-Results — ar_clauses()" +description: "Reference for the msys.validate.dkim2.ar_clauses() Lua API: usage examples and Authentication-Results output format." +--- + +## Authentication-Results Output + +When `authservid` is supplied to `verify()`, Momentum automatically builds +and prepends a fresh `Authentication-Results:` header (RFC 8601 §5 — an MTA +MUST NOT add to an existing AR header): + +```lua +msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta-1.example.com", +}) +``` + +For full control — or to merge DKIM2 results with other authentication methods +(SPF, DKIM1, ARC) into a single combined header — use +`msys.validate.dkim2.ar_clauses(result)`. + +`ar_clauses()` returns an array of DKIM2 `Authentication-Results:` clause +strings for the given verify result. It returns `nil` when `result` is `nil`, or `result.signatures` is absent or +empty. It also returns `nil` when all per-signature entries are non-actionable +(`status="chain_verified"` or `status="none"`) and `result.overall` is `"none"`. + +Each entry is a complete clause string (e.g. +`"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). +The array contains one entry per actionable signature — signatures with +`status="chain_verified"` (lower-hop: public-key verification not +performed, so no `dkim2=pass` claim can be asserted for them) and +`status="none"` (unsupported algorithm, §3.4 — no `dkim2=none` token exists) +are excluded. Extra overall clauses for chain failures or policy downgrades +are appended when applicable. + +### Usage examples + +```lua +-- Omit authservid so no DKIM2-only AR header is auto-prepended; build +-- the combined header below. +local result, err = msys.validate.dkim2.verify(msg, vctx) +local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} +local spf_clause = build_spf_clause() -- caller-supplied +local all_clauses = { spf_clause } +for _, c in ipairs(dkim2_clauses) do all_clauses[#all_clauses + 1] = c end +msg:header("Authentication-Results", + "mta-1.example.com; " .. table.concat(all_clauses, "; "), + "prepend") +``` + +### Output format + +> **Note on `header.s=`:** In DKIM1, `header.s=` carries just the selector name. +> In DKIM2 the `s=` wire tag encodes selector, algorithm, and signature together; +> Momentum emits only the selector and algorithm (e.g. `sel-1:rsa-sha256`) in +> `header.s=`, omitting the bulk base64 signature bytes. + +Normal pass: + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Transient DNS failure (`key_unavailable` → `dkim2=temperror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Permanent error — key does not exist in DNS (`no_key` → `dkim2=permerror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=permerror reason="public key does not exist" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Failure with reason (simplified string per §10.1 — ordinals come from `header.i=` / `header.m=`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=fail reason="body hash mismatch" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +When the overall verdict is worse than the per-sig result — chain failure or +policy downgrade after a crypto pass — an extra overall clause is appended: + +Chain-broken example (2-hop message: crypto passed but recipe-chain check failed): + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-2:rsa-sha256 header.i=2 header.m=2 + header.mf=bounce@forwarder.example.net header.rt=rcpt@a.com; + dkim2=permerror reason="chain of custody broken" +``` + +Policy-downgrade example (`d=` does not match the `mf=` domain): + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com; + dkim2=fail reason="MAIL FROM and d= do not match" +``` diff --git a/content/momentum/4/dkim2/debug.md b/content/momentum/4/dkim2/debug.md new file mode 100644 index 000000000..d07dda549 --- /dev/null +++ b/content/momentum/4/dkim2/debug.md @@ -0,0 +1,114 @@ +--- +lastUpdated: "06/09/2026" +title: "DKIM2 Debugging Reference" +description: "Per-signature reason codes, recipe_chain detail strings, and ec_message context fields for DKIM2 sign and verify operations." +--- + +## Debugging + +Setting `debug_level` on the `dkim2` configuration stanza routes sign and +verify activity to `paniclog`: + +``` +dkim2 { + debug_level = "info" +} +``` + +| Level | What surfaces | +|---|---| +| `error` | Failures and resolver problems only. **Default.** | +| `warning` | Adds DNS issues and SHOULD-violation warnings. | +| `info` | Adds one DNS resolution line per verified signature plus verification failures with their cause (`bh_mismatch` with expected vs. actual hash; `sig_invalid` with selector, algorithm, signed-input length, and OpenSSL detail). | +| `debug` | Adds raw TXT-record bytes from the resolver, a per-crypto-check trace line, and the raw signed-input bytes on failure. Too noisy for steady-state production; useful when chasing a specific sign/verify mismatch. | + +### Per-signature reason codes + +Every signature on a verified message gets a `reason` string in +`result.signatures[i].reason`. These codes are Momentum-internal tokens — +not defined by the DKIM2 spec — but are exposed through the `verify()` API. +They appear in `result.signatures[i].reason`, in the +`X-MSYS-DKIM2-Verify-Sig` debug header, and in `Authentication-Results:` +`reason=` output. Policy code can safely branch on them. + +The per-signature AR verdict is derived from `status` and `reason` together: +`status="pass"` → `dkim2=pass`; `status="fail"` → `dkim2=fail` by default, +promoted to `dkim2=temperror` or `dkim2=permerror` for specific reason codes +(noted in the table below); `status="chain_verified"` and `status="none"` are excluded from AR output. + +The full set: + +> **Note:** `d_mf_mismatch`, `donotmodify_violated`, and `donotexplode_violated` are +> **not** per-signature reason codes. They are set on `result.overall_reason` when a +> policy check downgrades the overall verdict after crypto passes. See the [Result table](/momentum/4/dkim2/verify#result-table) +> for details. + +| Reason | Meaning | +|---|---| +| `ok` | Signature verified cleanly. Paired with `status="pass"`. | +| `deferred` | An earlier hop's signature in a multi-hop message. Momentum validates the full chain of custody end-to-end via the §10.6 recipe chain rather than performing a full §10.5 per-signature key lookup and cryptographic check for each lower hop. If the chain is intact, `overall="pass"`. See [Known Limitations](/momentum/4/dkim2#known-limitations) for what this means for key provenance. Paired with `status="chain_verified"`. | +| `hh_mismatch` | Header hash mismatch — a content header (Subject, From, etc.) was modified after signing without a new `Message-Instance:` recording the change. | +| `bh_mismatch` | Body hash mismatch — the message body was modified after signing without a new `Message-Instance:` recording the change. | +| `sig_invalid` | Cryptographic verification failed — the signed-input bytes don't match the value in `s=`. Enable `debug_level = info` for selector, algorithm, and signed-input length detail. | +| `parse_error` | The `DKIM2-Signature:` header couldn't be parsed. Corrupt header or a broken upstream signer. | +| `missing_required_tags` | One or more of the seven required tags (`i=`, `m=`, `t=`, `mf=`, `rt=`, `d=`, `s=`) is absent from the signature. | +| `signature_expired` | The `t=` timestamp is older than `max_sig_age_days` (default 14). §10.3 classifies this as PERMERROR — Momentum treats it as permanently unverifiable and does not attempt cryptographic verification. Maps to `dkim2=permerror` in AR output. | +| `signature_future` | The `t=` timestamp is more than `max_sig_future_secs` (default 300 s) in the future. Treated as a soft policy failure (`dkim2=fail`): the timestamp was evaluated and rejected, but it is not a permanent infrastructure error — the spec (§7.4 MAY) does not define a verdict for this case. | +| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§7.3 SHOULD). Treated as `dkim2=fail` — the constraint is a SHOULD, not a structural permanent error. | +| `mailfrom_mismatch` | The signed `mf=` doesn't match the actual envelope MAIL FROM — replay-to-different-sender. | +| `rcpt_mismatch` | The signed `rt=` doesn't match the actual envelope RCPT TO — replay-to-different-recipient. | +| `key_unavailable` | DNS resolver returned a transient failure (SERVFAIL, timeout, REFUSED). Rolls up to `overall="temperror"`. | +| `no_key` | DNS returned NXDOMAIN — no TXT record exists for the selector. | +| `key_revoked` | The DNS TXT record exists but `p=` is empty, signalling deliberate key revocation. | +| `key_b64_decode` | The `p=` value in the DNS record is not valid base64. Malformed DNS record. | +| `key_multiple_records` | DNS returned more than one TXT record for the selector (§10.5). DNS admin misconfiguration on the sender side — only one TXT record is allowed per selector. | +| `key_service_mismatch` | The DNS TXT record's `s=` service list does not include `email` or `*` (RFC 6376 §3.6.1). The key is published for a different service. | +| `key_invalid` | The DNS TXT record was present but structurally unusable (empty content, internal resolver error, or selector/domain too long to query). | +| `key_der_parse` | The `p=` base64 decoded successfully but the DER structure is not a valid public key. | +| `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | +| `key_v_mismatch` | The DNS TXT record's `v=` tag does not match the expected value. Malformed or wrong-version key record. Maps to `dkim2=permerror`. | +| `key_p_missing` | The DNS TXT record has no `p=` tag (distinct from empty `p=` which is revocation). Malformed key record. Maps to `dkim2=permerror`. | +| `key_size_invalid` | The RSA public key is smaller than the 1024-bit minimum required by §3.2. Maps to `dkim2=permerror`. | +| `key_e_invalid` | The RSA public key exponent is not 65537 as required by §3.2. Maps to `dkim2=permerror`. | +| `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | +| `mi_hash_missing` | The body hash could not be retrieved from the `Message-Instance:` `h=` tag: either no MI with a matching sequence number (`m=`) was present, or the MI's `h=` tag was malformed or lacked a hash entry for the algorithm named in its own `h=` prefix. | +| `verify_internal` | An internal error occurred during signature verification (memory allocation failure or cryptographic library error). The signature could not be evaluated. Maps to `dkim2=permerror` in AR output. | +| `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | + +**Authentication-Results mapping (§10.1):** Most `status="fail"` reasons produce `dkim2=fail` in the AR header. Exceptions, per the §10.1 FAIL / PERMERROR / TEMPERROR distinction: +- `key_unavailable` → `dkim2=temperror` (transient DNS failure) +- The following produce `dkim2=permerror` (unrecoverable errors): `no_key`, `key_invalid`, `key_multiple_records`, `key_service_mismatch`, `key_k_unknown`, `key_revoked`, `key_b64_decode`, `key_der_parse`, `key_v_mismatch`, `key_p_missing`, `key_size_invalid`, `key_e_invalid`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` + +`reason=` is included in all failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`) and absent from pass clauses (`dkim2=pass`). + +### recipe_chain detail strings (paniclog only) + +When the recipe-chain check fails, the overall verdict is `permerror` +with `overall_reason="chain_broken"`, and the underlying cause is logged at `error` level in +paniclog as `recipe-chain check failed: recipe_chain: `. The +chain-check failure does NOT appear in the per-signature result struct — +it's a cross-hop verdict, not a per-signature outcome — so paniclog is the +only place this detail surfaces. + +| Detail | Meaning | +|---|---| +| `no_mi_1` | The message had ≥ 2 signatures but no `Message-Instance` with `m=1`. The chain has no anchor. | +| `parse_h` | `Message-Instance` `h=` tag didn't parse as `::`. The MI is malformed. | +| `recipe_decode` | A hop's `r=` value didn't base64-decode. Wire-format corruption or a broken signer. | +| `recipe_invalid` | A hop's recipe failed schema validation at verify time. Should not occur with conforming signers (sign-time validation prevents emission of bad recipes); appearing here means the signer is broken. | +| `irreversible` | A hop's recipe declared `"h": null`, `"b": null`, or `"b": {"z": true}`. The verifier can't reverse-reconstruct past this hop. Local policy may accept irreversibility from trusted forwarders. | +| `apply_failed` | A recipe references a header or body line that doesn't exist in the current message. The recipe is inconsistent with the modification it claims to describe — likely a downstream hop modified the message AGAIN without recording it. | +| `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should emit `r={"h":null,"b":null}` to declare irreversibility rather than omitting `r=` entirely. | +| `hash_mismatch` | After walking all recipes in reverse, the reconstructed instance-1 hashes didn't match `Message-Instance` `m=1`'s recorded `h=`. Every non-first MI had a recipe, so the mismatch indicates a hop's recipe was wrong or a hop modified the message after signing. | + +### ec_message context fields + +`verify()` writes the following context variables so downstream hooks can +read the outcome without re-verifying or parsing `Authentication-Results:`: + +| Context key | Type | Value | +|---|---|---| +| `dkim2_overall` | string | Verdict: `"pass"`, `"fail"`, `"permerror"`, `"temperror"`, or `"none"`. See the [SMTP response codes](/momentum/4/dkim2/verify#smtp-response-codes-94-guidance) table. | +| `dkim2_n_sigs` | string | Number of `DKIM2-Signature` headers found on the message. Parse with `tonumber()`. | + +These keys are not set until `verify()` runs. diff --git a/content/momentum/4/dkim2/sign.md b/content/momentum/4/dkim2/sign.md new file mode 100644 index 000000000..f5031242c --- /dev/null +++ b/content/momentum/4/dkim2/sign.md @@ -0,0 +1,221 @@ +--- +lastUpdated: "06/09/2026" +title: "DKIM2 Signing — sign()" +description: "Reference for the msys.validate.dkim2.sign() Lua API: hook selection, sign options, forwarder and modifier signing." +--- + +## DKIM2 Signing + +DKIM2 signing in Momentum is driven from Lua policy via +`msys.validate.dkim2.sign`; enabling DKIM2 signing means calling `sign()` from +your validation hook. + +### Signing hook: shared vs. per-recipient + +`sign()` can be called from either `validate_data_spool` or +`validate_data_spool_each_rcpt`. The choice affects how `rt=` is populated +and whether BCC addresses are exposed. + +| | `validate_data_spool` | `validate_data_spool_each_rcpt` | +|---|---|---| +| **Fires** | Once on the shared parent message | Once per recipient (cowref) | +| **`rt=` auto-populate** | Primary recipient only (`msg:rcptto()`) — extra recipients are inaccessible in this hook | Single cowref recipient | +| **Multi-recipient rt=** | Must pass explicit `rcptto = {r1, r2, ...}` — collect the full list in an earlier hook (e.g. `validate_rcptto`) | Each cowref signs for its own single address automatically | +| **BCC privacy** | ⚠️ Operator must exclude BCC from the explicit `rcptto` list — a shared signature exposing a BCC address is visible to all recipients | ✅ No concern — each cowref is private to that recipient; `rt=` is bound to their address only | +| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; correct by default | + +Use `validate_data_spool_each_rcpt` for most deployments — it handles +per-recipient signing automatically. Use `validate_data_spool` +only when you need a single signature covering all recipients and are willing +to manage the recipient list and BCC exclusion yourself. + +Passing an explicit `rcptto` option overrides the auto-populated primary recipient. +Accepts a string (single address) or a Lua table of bare addresses. + +### Minimum signer + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local ok, err = msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "dkim2048", + keyfile = "/opt/msys/ecelerity/etc/conf/dkim/example.com/dkim2048.key", + }) + if not ok then + -- err is a static-literal string describing the failure. See + -- /momentum/4/dkim2/debug for the full set. + print("dkim2 sign failed: " .. tostring(err)) + end + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_signer", mod) +``` + +`mf=` defaults to the message's envelope MAIL FROM and `rt=` defaults to its +RCPT TO; both can be overridden in the options table for forwarder +scenarios (see *Forwarder / modifier signing* below). + +### Sign options + +`sign()` accepts either a single options table or a multi-algorithm +form using an explicit `sig_sets` key (§7.8 algorithm agility): + +```lua +-- Single sig-set (most common): +msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "sel-2048", + keyfile = "/etc/dkim2/rsa.key", +}) + +-- Multi-algorithm (RSA + Ed25519 in one DKIM2-Signature): +msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + sig_sets = { + { selector = "sel-rsa", keyfile = "/etc/dkim2/rsa.key" }, + { selector = "sel-ed25519", keyfile = "/etc/dkim2/ed25519.key", + algorithm = "ed25519-sha256" }, + }, +}) +``` + +When `sig_sets` is present, all entries sign the same canonical +signed-input and are combined into a single `s=sel1:alg1:sig1,sel2:alg2:sig2` +value on one `DKIM2-Signature` header. Per §7.2 the verifier checks +every sig-set; overall passes if any one validates, so a receiver that +only supports RSA will still verify cleanly. On the verifier side, any +sig-set that fails alongside a passing one is reported as a DWARNING in +paniclog (partial-sig-failure condition, §7.2). The `selector`, `keyfile`, and +`algorithm` fields belong to each sig-set entry; all other options below +are header-level and go at the top level of the options table. + +| Option | Required? | Meaning | +|---|---|---| +| `domain` | yes | `d=` tag — the signing domain. | +| `selector` | yes (single) | Selector component of `s=::`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keyfile` | yes (single) | Path to the PEM-encoded private key on disk. Mutually exclusive with `keybuf`; one of the two is required. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. | +| `algorithm` | no | `"rsa-sha256"` (default) or `"ed25519-sha256"`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§7.8). When present, fields supplied in `sig_sets[1]` override the corresponding top-level fields; any field omitted from `sig_sets[1]` falls back to the top-level value. | +| `mailfrom` | no | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Two production exceptions: (1) null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`; (2) testing/simulation of specific envelope conditions without real SMTP transit. | +| `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. One production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly to cover all recipients in a single `rt=`. In `validate_data_spool_each_rcpt` (recommended), each cowref auto-populates correctly. Accepts a string or a Lua table of bare addresses. | +| `bridge_mailfrom` | no | The `mf=` for an auto-generated bridging signature when the new `mf=` is not in the previous signature's `rt=` (§8.2). Required when the prior `rt=` has multiple entries; inferred automatically when it has exactly one. | +| `bridge_flags` | no | Flag tokens (same format as `flags`) to set on the auto-generated bridge signature only. The primary signature is unaffected. A non-table value always returns an error regardless of whether a bridge fires. A valid table value (or nil) is silently ignored when no bridge is generated — either because `on_chain_break` is not `"bridge"`, or because `on_chain_break` is `"bridge"` but no chain break is detected. Example: `bridge_flags={"donotmodify"}` to prevent further modifications after the bridge hop. | +| `on_chain_break` | no | Action when a §8.2 chain break is detected: `"bridge"` (default when `bridge_mailfrom` set), `"skip"` (default otherwise), `"warn"`, or `"error"`. See the Forwarder signing section for details. | +| `on_donotmodify` | no | Action when any prior `DKIM2-Signature` on the message carries `f=donotmodify` (§7.9 / §10.8). The check is unconditional — it does not detect whether content was actually modified. Values: `"error"` (default — refuse to sign), `"warn"` (proceed; caller is responsible for logging/auditing), `"skip"` (return `(true, nil, {donotmodify=true})` without signing), `"ignore"` (proceed silently). | +| `timestamp` | no | `t=` value. Defaults to the current UNIX time. | +| `nonce` | no | `n=` value (`-02` §7.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | +| `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. Inherited by auto-bridge signatures so every signature in the chain gets its own fresh nonce. | +| `flags` | no | Lua array of flag tokens for `f=` (`-02` §7.9): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`. See §7.9 for semantics. Joined into the on-wire comma-separated form by the glue layer. When `rt=` carries multiple recipients, `"exploded"` is added automatically unless already present. **Note:** the auto-`exploded` heuristic is based solely on recipient count — it triggers when `rt=` contains more than one address. Mailing lists with a single subscriber will not have `"exploded"` added automatically; pass `flags = {"exploded"}` explicitly in that case. | +| `recipe` | no | Raw JSON string conforming to `-02` §4. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; malformed recipes fail the sign call with `recipe_invalid: `. | +| `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§5). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, e.g. `{"sha256","sha512"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. The verifier automatically detects and uses whatever algorithm is present in the received MI `h=` tag. | +| `relax_d_mf_check` | no | §7.7 requires `d=` to match the rightmost labels of the `mf=` (MAIL FROM) domain. Default `false` (spec-compliant — `sign()` returns an error on mismatch). **Setting to `true` is non-spec-compliant**; it downgrades the check to a `DWARNING` and proceeds. Recommended only for testing or debugging cross-domain signing configurations. | +| `allow_recipe_z` | no | If `true`, accept the `b: {"z": true}` (truncated-body) recipe at sign time. Default `false`. The `-02` spec is internally inconsistent on this recipe shape — the changelog removes it but §11.1 still references it — so the signer refuses to emit it without an explicit opt-in. Set this only if you are interoperating with a verifier that requires the truncated-body recipe and you accept that the shape may be removed from the final spec. | + +`sign()` returns `(true, header_value_string)` on success and `(nil, +error_string)` on failure. Always check the return; on failure the message +is left unmodified (no `DKIM2-Signature:` or `Message-Instance:` is +attached). Recipe validation failure and content-changed-without-recipe +also log to paniclog at level `error`; most other failure paths return +only the error string to the caller without logging. + +### Forwarder and modifier signing + +When a forwarder changes the envelope MAIL FROM to an address not present +in the previous signature's `rt=` list, §8.2 requires an extra bridging +`DKIM2-Signature` before the primary — one whose `mf=` matches the previous +`rt=` and whose own `rt=` contains the new outgoing MAIL FROM. Momentum +automates this: supply `bridge_mailfrom` with the address this hop received +the message at, and `sign()` detects the chain break and prepends the bridge +automatically. + +The most common case is a **mailing list**: the original sender's signature +has `rt=list@mailing-list.com`; the list re-sends with +`MAIL FROM: bounce@mailing-list.com`, which is not in the prior `rt=` — +a chain break that requires a bridge: + +```lua +-- Mailing list scenario: +-- i=1 (originator): mf=alice@sender.com rt=list@mailing-list.com +-- i=2 (auto-bridge): mf=list@mailing-list.com rt=bounce@mailing-list.com +-- i=3 (primary): mf=bounce@mailing-list.com rt=subscriber@recipient.com +local ok, val, info = msys.validate.dkim2.sign(msg, vctx, { + domain = "mailing-list.com", + selector = "list-2026", + keyfile = "/etc/dkim2/mailing-list.com/list-2026.key", + mailfrom = "bounce@mailing-list.com", + rcptto = "subscriber@recipient.com", + bridge_mailfrom = "list@mailing-list.com", -- the address the list received at + -- on_chain_break defaults to "bridge" since bridge_mailfrom is provided +}) +if not ok then + -- sign() failed (key error, bridge error, etc.) + vctx:set_code(550, "5.7.1 DKIM2 signing failed: " .. tostring(val)) + return msys.core.VALIDATE_DONE +end +-- info.chain_break=true, info.bridged=true when bridge was auto-generated +``` + +When the forwarding address is unambiguous (prior `rt=` has a single entry), +`bridge_mailfrom` can be omitted — Momentum infers it automatically. When the +prior `rt=` has multiple entries, `bridge_mailfrom` is required to identify +which entry this hop received at. + +The `on_chain_break` option controls what happens when a chain break is +detected but cannot be bridged: + +| `on_chain_break` | Behavior | Third return value | +|---|---|---| +| `"bridge"` (default with `bridge_mailfrom`) | Auto-bridge; error if ambiguous | `{chain_break=true, bridged=true}` | +| `"skip"` (default without `bridge_mailfrom`) | Skip signing | `{chain_break=true, bridged=false}` | +| `"warn"` | Sign without bridge | `{chain_break=true, bridged=false}` | +| `"error"` | Return `(nil, errmsg)` | — | + +The third return value gives policy full control: inspect `info.chain_break` +and `info.bridged` to decide whether to accept, reject, or log — regardless +of which `on_chain_break` value was used. + +A forwarder that does not change the MAIL FROM (pure relay) signs with +the envelope values directly — no bridge needed since the chain is intact: + +A modifier that **rewrites** the message (Subject change, body footer, +attachment strip, etc.) additionally attaches a `recipe`: + +```lua +-- Forwarder rewrote Subject; recipe restores the original on +-- reverse-apply. +msys.validate.dkim2.sign(msg, vctx, { + domain = "list.example.org", + selector = "list-2026", + keyfile = "/etc/dkim2/list.example.org/list-2026.key", + recipe = [[{"h":{"Subject":[{"d":["Original subject"]}]}}]], +}) +``` + +The recipe schema is documented in `-02` §4. Recipes are mandatory only +when the hop modifies content; non-modifying hops (pure-forwarding without +edits) omit `recipe` entirely. + +When `on_chain_break="bridge"` is used and the message was modified, +supply `recipe` (and `allow_recipe_z` if needed) on the outer `sign()` +call — Momentum forwards them automatically to the auto-generated bridge +signature. The bridge needs the recipe to document the content change in +its `Message-Instance` header so the §10.6 chain walk can reconstruct the +original state. + +**Note**: auto-bridge signatures do not inherit `flags`. Use `bridge_flags` +to set flags on the bridge signature independently of the primary. For +example, `bridge_flags={"donotmodify"}` marks the bridge hop as +non-modifiable while leaving the primary signature's `flags` unchanged. + +`nonce_random` is inherited by the bridge so that when it is set, each +signature gets its own fresh nonce. An explicit `nonce=` value is NOT +inherited — the bridge's `n=` tag is absent (unless `nonce_random` was +set) to avoid two signatures sharing the same nonce value, which would +defeat anti-replay protection. diff --git a/content/momentum/4/dkim2/verify.md b/content/momentum/4/dkim2/verify.md new file mode 100644 index 000000000..b34aa513d --- /dev/null +++ b/content/momentum/4/dkim2/verify.md @@ -0,0 +1,216 @@ +--- +lastUpdated: "06/09/2026" +title: "DKIM2 Verifying — verify()" +description: "Reference for the msys.validate.dkim2.verify() Lua API: verify options, result table, and SMTP response codes." +--- + +## DKIM2 Verifying + +DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. +`verify()` can be called from either `validate_data_spool` or +`validate_data_spool_each_rcpt`. The choice affects how the §10.4 `rt=` +binding check is performed: + +| | `validate_data_spool` | `validate_data_spool_each_rcpt` | +|---|---|---| +| **Fires** | Once on shared parent message | Once per recipient (cowref) | +| **`rt=` auto-check** | First accessible recipient only (`msg:rcptto()`) — **all other recipients bypass the §10.4 check** unless explicitly listed in `rcptto` | Single cowref recipient checked; §10.4 satisfied per-delivery | +| **Multi-recipient §10.4** | ⚠️ Must pass explicit `rcptto = {r1, r2, ...}` — omitting any recipient silently skips its binding check | ✅ Every recipient verified automatically in its own cowref | +| **BCC support** | ⚠️ Operator must exclude BCC from explicit `rcptto` — omitting a BCC address skips its §10.4 binding check | ✅ Each cowref checked independently; no special handling needed | +| **Complexity** | Requires explicit recipient collection for complete §10.4 compliance | One `verify()` call per cowref; correct by default | + +Use `validate_data_spool_each_rcpt` for most deployments — it satisfies §10.4 +for every recipient automatically without additional setup. +Typical inbound policy: + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local result, err = msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta-1.example.com", + }) + if not result then + -- Internal error (alloc failure, crypto init error, etc.) — err carries + -- the reason string. Distinct from a per-sig fail, which lands in + -- result.signatures. Defer rather than silently accepting. + msys.log(msys.core.DWARNING, "DKIM2 verify failed internally: " .. tostring(err)) + vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") + return msys.core.VALIDATE_CONT + end + + -- result.overall is one of: + -- "pass" all sigs verified, chain intact + -- "fail" verified but wrong: hash/sig mismatch or policy + -- violation (d=/mf= mismatch, donotmodify, etc.) + -- "permerror" could not verify: key missing/invalid/revoked, + -- signature syntax error, or chain integrity failure + -- (overall_reason="chain_broken" for chain failures; + -- nil for key/syntax errors — check signatures[i].reason) + -- "temperror" resolver-side transient failure (SERVFAIL, timeout) + -- "none" no DKIM2-Signature headers, or all use unsupported + -- algorithms (§3.4 — ignored rather than failed) + + if result.overall == "temperror" then + -- Transient DNS failure: set a 4xx code so Momentum issues a + -- temporary rejection after the validation pipeline completes, + -- allowing the sender to retry once the resolver recovers. + vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") + end + + if result.overall == "fail" or result.overall == "permerror" then + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + end + + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_verifier", mod) +``` + +See [Authentication-Results Output](/momentum/4/dkim2/ar-clauses) for the AR +header format, `ar_clauses()` API, and examples of building combined headers. + +### Verify options + +| Option | Meaning | +|---|---| +| `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | +| `mailfrom` | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Production exception: null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`. Otherwise test/simulation use only. | +| `rcptto` | **Normally omitted** — Momentum auto-populates from the active envelope recipient. Production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly for complete §10.4 multi-recipient checking. In `validate_data_spool_each_rcpt` (recommended), auto-populates correctly per cowref. Accepts a string or a Lua table of bare addresses. ALL listed addresses must be present in `rt=` for the signature to pass. | +| `authservid` | When set, a new `Authentication-Results:` header is prepended (when the result contains at least one actionable clause) with this value as the authentication service identifier. Existing AR headers are never modified. When absent, no AR header is emitted. | +| `relax_d_mf_check` | If `true`, downgrade the §7.7 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false` (spec-compliant). **Setting to `true` is non-spec-compliant**; recommended only for testing. | +| `skip_recipe_chain` | If `true`, skip the `-02` §10.6 recipe-chain check. The per-signature crypto + envelope checks and the §8.3 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.6 is a SHOULD requirement. Use only for debugging or when interoperating with a signer whose recipe implementation is known to be broken. | +| `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag. Default `false` — duplicates produce `reason=parse_error` per §7.8. **Setting this to `true` makes the verifier non-spec-compliant** — §7.8 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | +| `max_sig_age_days` | §10.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | +| `max_sig_future_secs` | §7.4: reject signatures whose `t=` timestamp is more than this many seconds in the future. Default `300` (5-minute clock-skew tolerance). Values `<= 0` disable the check. | +| `emit_debug_headers` | If `true`, stamp `X-MSYS-DKIM2-Verify-Overall` and `X-MSYS-DKIM2-Verify-Sig` headers on the message. Useful for staging and debugging; **do not enable in production** as these headers expose internal verification detail and inflate message size. Default `false`. | + +`verify()` returns `(result, err)`: +- **Normal execution** (including messages with no DKIM2 signatures): `result` is + the result table below, `err` is `nil`. A message with no signatures returns + `result.overall = "none"` — `result` is never `nil` in this case. +- **Internal failure** (alloc or crypto init error): `result` is `nil`, `err` is a + non-nil string describing the cause. + +Always capture both return values so internal failures can be logged and acted on +separately from signature verdicts. + +### Result table + +``` +result = { + overall = "pass" -- all verifiable signatures passed + | "fail" -- verified but wrong: hash/sig mismatch, or + | -- policy violation (d=/mf= mismatch, donotmodify, etc.) + | "permerror" -- could not verify: key missing/revoked/invalid, + | -- signature syntax error, or chain integrity failure + | -- (§10.1 PERMERROR) + | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) + | "none", -- no DKIM2-Signature headers present, or all + | -- use unsupported algorithms (§3.4) + overall_reason = nil -- nil when overall="pass", "temperror", + -- or when overall is non-pass due to + -- per-sig failures (key errors, bad + -- crypto, syntax errors) — check + -- result.signatures[i].reason for detail. + -- Non-nil only for structural conditions + -- that apply to the chain as a whole: + | "chain_broken" -- overall="permerror": chain integrity + -- failure (MI gap, recipe mismatch, etc.) + | "d_mf_mismatch" -- overall="fail": d= doesn't match + -- mf= domain after crypto pass (§7.7) + | "donotmodify_violated" -- overall="fail": f=donotmodify sig + -- followed by a modifying hop (§10.8) + | "donotexplode_violated", -- overall="fail": f=donotexplode sig + -- followed by f=exploded (§10.8) + signatures = { + { seq = , + m = , + status = "pass" -- signature verified + | "fail" -- signature failed; see reason + | "chain_verified" -- earlier hop (i, -- see Per-signature reason codes table below + d = "", + s = "::", -- raw s= value; AR header.s= carries + -- only ":" (base64 stripped) + mf = "", -- decoded from base64 + rt = "[,...]", -- all entries decoded from base64 + n = "", -- if present + f = "", -- if present; comma-separated + key_testing = true, -- if present: signing key has t=y + -- (RFC 6376 §3.6.1 testing mode). + -- Per spec, failures SHOULD NOT be + -- treated as definitive when set. + }, + ... + } +} +``` + +For messages that passed through multiple signing hops, Momentum verifies +the **most recent signature** cryptographically (§10.5) and confirms the +**full chain of custody** end-to-end (§10.6). Earlier signatures in a +multi-hop message appear in `result.signatures` with +`status="chain_verified"` — this means Momentum validated that each +intermediate hop correctly recorded what it changed, and that those +changes are consistent all the way back to the original sender. If +anything in that chain is wrong (a hop modified the message without +recording it, or a recipe was incorrect), `overall` is `permerror` with +`overall_reason="chain_broken"`. `overall="pass"` means the content +chain is intact; note that public-key (§10.5) cryptographic verification +is only performed for the most recent hop — see [Known Limitations](/momentum/4/dkim2#known-limitations) for +details. + + +### SMTP response codes (§9.4 guidance) + +Momentum leaves the decision of whether to accept, reject, or defer a +message — and which SMTP reply code to use — entirely to the operator's +Lua hook. The `overall` field of the verify result maps to the following +SMTP behaviour as required by §9.4 of the DKIM2 spec: + +| `overall` | Meaning | §9.4 guidance | Suggested action | +|---|---|---|---| +| `pass` | All verifiable signatures passed | — | Accept | +| `none` | No DKIM2 signatures present, or all use unsupported algorithms (§3.4) | — | Local policy | +| `fail` | Verified but wrong: hash/sig mismatch or policy violation (d=/mf= mismatch, donotmodify, etc.) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject or accept per policy | +| `permerror` | Could not verify: key missing/revoked/invalid, syntax error, or chain integrity failure (`overall_reason="chain_broken"`) (§10.1 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | +| `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | + +**Key rules from §9.4**: +- `fail` and `permerror` **MUST NOT** use a 4xx reply code. +- Only `temperror` warrants a temporary (4xx) failure code. + +Example hook skeleton: + +```lua +local result, err = msys.validate.dkim2.verify(msg, vctx, { ... }) +local overall = result and result.overall or "none" + +if overall == "permerror" or overall == "fail" then + -- §9.4 SHOULD 550/5.7.x for permanent failures. + -- Note: "permerror" MUST NOT use 4xx. + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + return msys.core.VALIDATE_DONE + +elseif overall == "temperror" then + -- §9.4 MAY 451/4.7.5 for transient key-fetch failures + vctx:set_code(451, "4.7.5 DKIM2 key server temporarily unavailable") + return msys.core.VALIDATE_DONE + +else + -- pass / none: local policy + return msys.core.VALIDATE_CONT +end +``` + +> **Note**: Whether to reject on `fail` or `none` is a local policy +> decision. The spec only mandates the reply-code *type* (4xx vs 5xx) +> for the cases shown above.