diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md index fd6e2eb6e..880944150 100644 --- a/content/momentum/4/dkim2.md +++ b/content/momentum/4/dkim2.md @@ -1,7 +1,7 @@ --- -lastUpdated: "08/16/2026" +lastUpdated: "10/03/2026" 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-04." +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-06." --- ## On This Page @@ -24,11 +24,11 @@ description: "DKIM2 is the successor to DKIM that adds replay protection (per-me ### Warning DKIM2 targets the in-progress IETF draft -[`draft-ietf-dkim-dkim2-spec-04`](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-04) -(5 July 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. +[`draft-ietf-dkim-dkim2-spec-06`](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-06) +(28 August 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. > **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 @@ -82,15 +82,15 @@ DKIM2 addresses both: 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-04); -the operationally-relevant signal codes (per-signature reasons, overall -verdicts, paniclog lines) are inventoried in the -[Debugging](/momentum/4/dkim2/debug) reference page. +draft](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-06); +the per-signature reasons and overall verdicts are listed under +[Reason codes](/momentum/4/dkim2/verify#reason-codes), and the paniclog +lines on the [Debugging](/momentum/4/dkim2/debug) page. ## How it differs from DKIM1 at a glance -| Concern | DKIM1 (RFC 6376) | DKIM2 (draft `-04`) | +| Concern | DKIM1 (RFC 6376) | DKIM2 (draft `-06`) | |---|---|---| | 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=` | @@ -241,32 +241,6 @@ the other. Receivers that support both will evaluate each chain separately. The following are known gaps or operational considerations to be aware of: -* **Inbound verification breaks if the receiving MTA adds a header outside the §4 "unsigned" set (notably `Received-SPF`)**: DKIM2's Message-Instance header hash covers *every* header except the §4 unsigned set — the exact trace names `Received` / `Return-Path` / `Delivered-To`, plus `Authentication-Results`, `DKIM-Signature`, and any `ARC-*` or `X-*` field. The trace-header exclusion is the exact name `Received`, **not** a `Received-*` prefix, and `Received-SPF` (RFC 7208) is not listed anywhere in §4 — so it *is* covered by the header hash. A receiving MTA that stamps `Received-SPF` on delivery (**Google / Gmail does this**) therefore changes the hashed header set and DKIM2 verification **fails on otherwise-valid mail**. This is behaviour mandated by draft-04 §4 (the §4.1 summary set), not a Momentum-specific choice — Momentum's exclusion set matches the DKIM2 reference implementations exactly (the Perl and Python signers/verifiers in the IETF interop project use the same list and likewise do not exempt `Received-SPF`), so a reference verifier fails the same message. The [dkim2.com](https://dkim2.com/validate) validator will report a *pass* for such a message, but only as a flagged, non-strict diagnostic — it detects the trace header, removes it, and notes that it did so; its strict verdict is the same failure. Until a future draft widens §4 (e.g. to a `Received-*` prefix), expect DKIM2 verification failures for mail delivered through such receivers, and prefer testing verification on a path that does not rewrite the header set. - -* **Lower-hop signatures not cryptographically verified (§10.1 / §9.2 / §11.5–11.6)**: - Momentum runs the full cryptographic procedure — key fetch (§11.5) and - EVP signature verification (§11.6) — only on the highest-`i` signature, - which §10.1 makes a SHOULD. Earlier hops (`i < max_i`) get no key lookup - and no crypto (`status="chain_verified"`); their integrity rests on the - §9.2/§9.4 chain-of-custody check and the §10.2 recipe reconstruction, - which reverse-applies each hop's recipe to rebuild the original message - and confirms the reconstructed instance-1 hashes match MI[1]'s `h=`. This - proves end-to-end content integrity but does not authenticate each lower - hop's signing key, so earlier-hop signer identities should not be used - for Reviser reputation. §10.3 ("Checking the DKIM2-Signature Header - Fields") names exactly the use cases that need more — assessing whether a - message was exploded, honoring `feedback` requests, and assigning - reputation to Revisers — and says that for those, *all* of the - DKIM2-Signature header fields "will have to be checked for validity." It - frames this as a functional necessity for those uses, not a MUST, and the - spec-correct **acceptance** decision (§10.1) needs only the most-recent - hop. Momentum exposes every hop's `d=`/`s=`/`mf=`/`rt=`/`f=` in - `result.signatures` so policy can inspect the chain, but offers **no - option today to cryptographically verify each lower hop** — that full - per-hop verification (fetch each hop's key and EVP-verify its hop-relative - signed input; the recipe chain already confirms each hop's hashes) is - deferred to a future release. - * **§10.1 / §12 DSN**: Per §10.1, after a failed DKIM2 verification the MTA MUST NOT generate a DSN; the spec recommends rejecting with a 5xx during the SMTP conversation as the best alternative. This is not @@ -276,14 +250,15 @@ The following are known gaps or operational considerations to be aware of: of the highest-numbered DKIM2-Signature, and a `<>` (null-sender) indicator — as `result.highest_mf` and the `dkim2_highest_mf` message- context variable, so a generation hook can address the DSN or suppress it - when the value is `<>`. What is not yet built is the automatic wiring in - the bounce-generation path to consume it. Inbound DSN - authentication (§12.1.2, a SHOULD) is also not implemented: the - reject/propagate decision is scriptable via the inbound hooks, but + when the value is `<>`; `result.dsn_prohibited` says whether the + verification result forbids a DSN. What is not yet built is the + automatic wiring in the bounce-generation path to consume these values. + Inbound DSN authentication (§12.1.2, a SHOULD) is also not implemented: + the reject/propagate decision is scriptable via the inbound hooks, but verifying the embedded returned message's signatures — and checking signing-domain alignment against its highest-`i=` `rt=` — has no exposed - API, since `verify()` operates only on the live message. *(Draft-04 - clarified §12.1.2: the embedded `message/rfc822` part is authenticated by + API, since `verify()` operates only on the live message. *(Under + §12.1.2, the embedded `message/rfc822` part is authenticated by checking its header hashes — and body hash, if the body is present — against the highest-numbered `Message-Instance` header field; that is the piece Momentum does not yet expose.)* Note also the §12.1.1 rule: a DSN @@ -316,47 +291,20 @@ The following are known gaps or operational considerations to be aware of: Prefer per-recipient signing (or separate copies) for any mail that may carry blind copies. See [Signing hook](/momentum/4/dkim2/sign#signing-hook). -* **Content modifier recipe composition**: When an upstream-signed - message passes through a Momentum stage that modifies it — the - engagement tracker rewriting URLs, a footer filter, a list processor - changing headers — `sign()` automatically detects the change (its - freshly computed header/body hashes no longer match the prior - Message-Instance) and requires a `recipe=` describing how to reverse - the hop; without one the sign call fails. Header changes MUST be - reversible: a recipe MUST be provided for any changed header - field, and there is no null-header form. To remove all instances of a - header field, give that field name an empty step array (`[]`). When - the full body diff isn't available, a null **body** recipe declaring - the change irreversible is permitted: `recipe='{"b":null}'` for a body - change. This lets signing succeed and this hop's signature verifies - downstream — but earlier signatures' content can no longer be - reconstructed past this hop, so the inner chain is broken for that - field and acceptance depends on the verifier's policy toward a broken - chain. (Originated mail needs no recipe — there's no prior instance to - diff against.) See the [Forwarder and modifier signing](/momentum/4/dkim2/sign#forwarder-and-modifier-signing) section for - examples. Automatic change-recording by pipeline stages is not yet - built; a planned Recipe Accumulator API would let `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). - -* **§11.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 §11.1 SHOULD — - not a MUST — so verification behaviour is unaffected. +* **Content modifier recipe composition**: When a Momentum stage + modifies an upstream-signed message — the engagement tracker rewriting + URLs, a footer filter, a list processor changing headers — Momentum does + not record the change; the policy passes a `recipe` to `sign()` + describing it. Without a recipe, `sign()` records `{"b":null}`, which + declares the earlier body unrecoverable. A changed header field still + needs a recipe, or it fails the header hash of the instances below. See + [Forwarder and modifier signing](/momentum/4/dkim2/sign#forwarder-and-modifier-signing). * **§13 Bare CR/LF normalization**: The spec (§13) 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) - 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. + to `fix` when DKIM2 signing is in use** — `ignore` leaves a lone CR or + LF in the message. DKIM2 hashes each as CRLF, so the signature breaks at + any downstream hop that does not read it that way. diff --git a/content/momentum/4/dkim2/ar-clauses.md b/content/momentum/4/dkim2/ar-clauses.md index 2f251d72a..759af3ba9 100644 --- a/content/momentum/4/dkim2/ar-clauses.md +++ b/content/momentum/4/dkim2/ar-clauses.md @@ -1,5 +1,5 @@ --- -lastUpdated: "06/29/2026" +lastUpdated: "10/03/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." --- @@ -21,18 +21,13 @@ For full control — or to merge DKIM2 results with other authentication methods `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. +strings for the given verify result, one for each entry of `result.ar`. An +entry whose result is `none` — an unsigned message, or a signature set aside +under §3.4 or for a key in testing mode — gives no clause. `ar_clauses()` +returns `nil` when `result` is `nil` or no entry gives a clause. + +Each entry is a complete clause string, for example +``dkim2=pass header.d=example.com header.s="sel-1:rsa-sha256" ...``. ### Usage examples @@ -75,82 +70,85 @@ msys.registerModule("my_combined_ar_policy", mod) ### 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: +There is one clause for each signature that was checked, in ascending `i=` +order, followed by one for each problem that no signature accounts for, such +as a `Message-Instance` that no signature covers. Each clause has the form: ``` -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= [reason=] [header.d=] [header.s=:] + [header.i=] [header.m=] [header.mf=
] [header.rt=] ``` -Partial sig-set failure (§11.6) — a signature carries multiple sig-sets in -`s=` (algorithm agility); one verifies so the signature passes, but another -failed. The pass clause carries a `reason=` noting the partial failure: +`result` is `pass`, `fail`, `permerror` or `temperror`. `reason=` is the +problem's text, in the draft's wording where it has one, with its values +filled in, for example `Message Instance m=1 header hash sha256 mismatch`; +it is present on every clause except a plain pass, and ends with the +problem's detail in parentheses when it has one. Every value is written as +it stands when it is a plain token, and otherwise as an RFC 8601 +quoted-string. A value with a colon, an at sign or a comma is quoted, so +`header.s`, `header.mf` and `header.rt` are usually quoted and `header.d` is +not. A control character other than tab, or a byte that is not ASCII, +becomes `?`. `header.i=` (the signature's `i=`) and `header.m=` (its `m=`) +link the clause to its signature. -``` -Authentication-Results: mta-1.example.com; - dkim2=pass reason="rsa-sha256 signature passed; 1 other sig-set(s) failed" - 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 -``` +A `header.rt` too long for one line keeps its first entries and the number +of the rest, for example +`header.rt="a@example.com,b@example.com,c@example.com,...(+97 more)"`. When +the field `verify()` adds would pass 8 KiB, Momentum shortens its clauses, +and a comment counts any clauses it leaves out. -Transient DNS failure (`key_unavailable` → `dkim2=temperror`): +> **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 of the first sig-set (e.g. +> `"sel-1:rsa-sha256"`) in `header.s=`, omitting the bulk base64 signature bytes. + +Normal pass: ``` -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 +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" ``` -Permanent error — key does not exist in DNS (`no_key` → `dkim2=permerror`): +A signature below a `{"b":null}` recipe passes with a `reason=` saying that +its body was not verified. + +Transient DNS failure (`key_unavailable`): ``` -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 +Authentication-Results: mta-1.example.com; dkim2=temperror + reason="DKIM2-Signature i=1 public key sel-1 could not be fetched (DNS lookup failed for sel-1._domainkey.example.com)" + 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 §11.1 — ordinals come from `header.i=` / `header.m=`): +Hash mismatch (`header_hash_mismatch`): ``` -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 +Authentication-Results: mta-1.example.com; dkim2=fail + reason="Message Instance m=1 header hash sha256 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): +A multi-hop message gives one clause for each signature, and a failure in +a lower hop is reported on that hop's clause: ``` -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="recipe chain hash mismatch" +Authentication-Results: mta-1.example.com; dkim2=fail + reason="Message Instance m=1 body hash sha256 mismatch (hash_mismatch)" + header.d=sender.example header.s="sel-1:rsa-sha256" header.i=1 header.m=1 + header.mf="alice@sender.example" header.rt="list@forwarder.example.net"; + dkim2=pass header.d=forwarder.example.net header.s="sel-2:rsa-sha256" + header.i=2 header.m=2 header.mf="bounce@forwarder.example.net" + header.rt="rcpt@a.com" ``` -The `reason=` string names the specific chain failure rather than a single -blanket message. Possible chain reasons: `"recipe chain hash mismatch"`, -`"chain of custody broken"` (a §9.2 `mf=`/`rt=` link or `nd=` bridge break), -`"signature i= sequence broken"`, `"Message-Instance syntax error"`, -`"Message-Instance missing a required tag"`, `"Message-Instance revision -sequence broken"`, `"Message-Instance revision ahead of signatures"`, and -`"signature references a missing Message-Instance"`. A body/header hash -mismatch against a non-default Message-Instance hash algorithm names that -algorithm, e.g. `reason="body hash mismatch (sha512)"`. - -Policy-downgrade example (`d=` does not match the `mf=` domain — §11.4 -enumerates this as a PERMERROR output state): +An entry in `result.ar` that names no signature gets a clause without +`header.i=`. This is the case when verification stops at a limit, such as +a signature field larger than the limit allows: ``` -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=permerror reason="MAIL FROM and d= do not match" +Authentication-Results: mta-1.example.com; dkim2=permerror + reason="DKIM2 verification failed: a limit was exceeded" ``` diff --git a/content/momentum/4/dkim2/debug.md b/content/momentum/4/dkim2/debug.md index 2271fb01b..1503e4093 100644 --- a/content/momentum/4/dkim2/debug.md +++ b/content/momentum/4/dkim2/debug.md @@ -1,7 +1,7 @@ --- -lastUpdated: "08/16/2026" +lastUpdated: "10/03/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." +description: "What each debug_level of the dkim2 configuration stanza writes to paniclog for DKIM2 sign and verify." --- ## Debugging @@ -17,7 +17,6 @@ dkim2 { | Level | What surfaces | |---|---| -| `error` | Failures and resolver problems only. **Default.** | -| `warning` | Adds DNS issues (including non-conformant key records Momentum tolerates for compatibility) 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. | +| `error` | Errors, such as a `sign()` whose key cannot be read. **Default.** | +| `warning` | Adds a key lookup that fails or returns a resolver error other than NXDOMAIN; a verification that stops at a limit; a `sign()` that signs with `f=exploded` although an earlier signature asked for `f=donotexplode`; and a `sign()` that signs over two fields claiming the same `i=` or the same `m=`. | +| `info` | Adds, for each key looked up in DNS, a line naming the TXT record and a line with the answer; the verdict of each `verify()` with its reason (for example `body_hash_mismatch`); a line for each `sign()` that adds fields; and each refusal by `sign()`, including one it then signs over, bridges or skips. | diff --git a/content/momentum/4/dkim2/sign.md b/content/momentum/4/dkim2/sign.md index 78a1a4ca9..606a33b49 100644 --- a/content/momentum/4/dkim2/sign.md +++ b/content/momentum/4/dkim2/sign.md @@ -1,5 +1,5 @@ --- -lastUpdated: "08/16/2026" +lastUpdated: "10/03/2026" title: "DKIM2 Signing — sign()" description: "Reference for the msys.validate.dkim2.sign() Lua API: hook selection, sign options, forwarder and modifier signing." --- @@ -63,9 +63,6 @@ signing / separate copies. Note that this shared single-signature mode is in any case unavailable on the HTTP/transmissions path, which never presents a shared multi-recipient message to a signing hook. -Passing an explicit `rcptto` option overrides the auto-populated recipient. -Accepts a string (single address) or a Lua table of bare addresses. - ### Signing order: DKIM2, DKIM v1, and ARC When more than one signing scheme is active they run in a fixed order at the @@ -94,8 +91,7 @@ function mod:core_final_validation2(msg, ac, vctx) 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. + -- err describes the failure. msys.log(msys.core.LOG_WARNING, "dkim2 sign failed: " .. (err or "unknown")) end -- Return EC_HOOK_CONT to continue even on a sign failure (message is @@ -139,11 +135,10 @@ 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 §11.6 the verifier checks -every sig-set; Momentum's overall verdict 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, -§11.6). +every sig-set. A signature passes when at least one of its sig-sets +verifies and none fails to verify: a sig-set whose signature does not verify +makes the verdict `fail` with reason `sig_partial`, and a sig-set whose key +is missing or cannot be read is set aside. **If any sig-set fails**, the entire `sign()` call returns `(nil, error_string)` — no partial signature is produced. @@ -156,26 +151,28 @@ 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 on disk. Mutually exclusive with `keybuf`; one of the two is required. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keyfile` | yes (single) | Path to the PEM-encoded private key on disk. One of `keyfile` and `keybuf` is required; when both are set, `keybuf` is used. 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. 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, keybuf, algorithm}` tables for multi-algorithm signing (§8.9). When `sig_sets` is used, `selector`/`keyfile`/`keybuf`/`algorithm` are set per entry inside `sig_sets` — do not set them at the top level. All other options (`domain`, `mailfrom`, `rcptto`, `flags`, `recipe`, etc.) remain at the top level and apply to all entries. | -| `mailfrom` | no | **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 testing/simulation of specific envelope conditions without real SMTP transit. An explicit value may be given bare (`user@example.com`) or envelope-decorated (``, `MAIL FROM:`, or `msg:mailfrom()`) — it is normalized to the bare address before being written into `mf=`, the same as `rcptto`. `""` (null sender) is preserved. | -| `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. In the recommended `core_final_validation2` hook (and in `validate_data_spool_each_rcpt`), each recipient/cowref auto-populates correctly and is BCC-safe. One exception applies only to the shared `validate_data_spool` hook: pass the full recipient list explicitly to cover all recipients in a single `rt=` — but you MUST exclude any `bcc:` recipient (§8.6), since the multi-recipient `rt=` is visible to every recipient of that copy. Accepts a string or a Lua table of bare addresses. | +| `sig_sets` | no | Array of `{selector, keyfile, keybuf, algorithm}` tables for multi-algorithm signing (§8.9). When `sig_sets` is used, `selector`/`keyfile`/`keybuf`/`algorithm` are set per entry inside `sig_sets` — do not set them at the top level. All other options (`domain`, `mailfrom`, `rcptto`, `flags`, `recipe`, etc.) remain at the top level and apply to all entries. At most two entries may use the same algorithm (§8.9); a third fails the sign call. | +| `mailfrom` | no | **Normally omitted** — Momentum reads the envelope MAIL FROM, including the null sender `<>` of a DSN or bounce. Pass `mailfrom=""` to sign as the null sender whatever the envelope says, or an address to sign with a MAIL FROM other than the envelope's. An address may be given bare (`user@example.com`) or envelope-decorated (``, `MAIL FROM:`, or `msg:mailfrom()`); it is normalized to the bare address before being written into `mf=`, the same as `rcptto`. | +| `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. In the recommended `core_final_validation2` hook (and in `validate_data_spool_each_rcpt`), each recipient/cowref auto-populates correctly and is BCC-safe. One exception applies only to the shared `validate_data_spool` hook: pass the full recipient list explicitly to cover all recipients in a single `rt=` — but you MUST exclude any `bcc:` recipient (§8.6), since the multi-recipient `rt=` is visible to every recipient of that copy. Accepts a string or a Lua table of bare addresses. A string may hold one address or several separated by commas. | | `bridge_mailfrom` | no | The `mf=` for an auto-generated **fabricated** bridging signature when the new `mf=` domain does not relaxed-domain-match (§9.4, domain-only) any address in the previous signature's `rt=` (§9.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"`/`"nd"`, or because no chain break is detected. Example: `bridge_flags={"donotmodify"}` to prevent further modifications after the bridge hop. | -| `on_chain_break` | no | Action when a §9.2 chain break is detected: `"bridge"` (fabricated `mf=`/`rt=` bridge; default when `bridge_mailfrom` set), `"nd"` (emit an `nd=` "imaginary hop" bridge — §8.7/§9.3), `"skip"` (default otherwise), `"warn"`, or `"error"`. See the Forwarder signing section for details. | -| `bridge_domain`, `bridge_selector`, `bridge_keyfile`, `bridge_keybuf` | no | Signing identity for an `nd=` bridge (`on_chain_break="nd"`). `bridge_domain` MUST be a domain present in the prior `rt=` (per §9.3 the bridge is signed by a domain that actually received the message); the key (`bridge_keyfile`/`bridge_keybuf`) is for that domain. `bridge_selector` defaults to the primary `selector` if omitted. | -| `next_domain` | no | Low-level `nd=` passthrough (§8.7): emit **this** signature as an `nd=` bridge carrying `nd=` and **no** `mf=`/`rt=`. The call's `domain`/`selector`/key must belong to a domain in the prior `rt=`, and `next_domain` MUST equal the `d=` of the next signature in the chain. Chain-break detection is skipped for such a call. Prefer `on_chain_break="nd"` for the common auto-bridge case. | -| `on_donotmodify` | no | Action when any prior `DKIM2-Signature` on the message carries `f=donotmodify` (§8.10 / §11.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 — no headers added to the message), `"ignore"` (proceed silently). | -| `timestamp` | no | `t=` value. Defaults to the current UNIX time. | -| `nonce` | no | `n=` value (`-04` §8.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | +| `bridge_flags` | no | Flag tokens (same format as `flags`) to set on the auto-generated bridge signature only. The primary signature is unaffected. A value that is not a table, or an entry that is not a string or number, 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"`/`"nd"`, or because no chain break is detected. Example: `bridge_flags={"donotmodify"}` to prevent further modifications after the bridge hop. | +| `on_chain_break` | no | Action when signing would break the chain of custody (§9.4): the new `mf=` domain links to no prior `rt=`, `mf=` is `<>` after a signature with `rt=`, or the previous signature's `nd=` is not a domain or not this one. Values: `"bridge"` (fabricated `mf=`/`rt=` bridge; default when `bridge_mailfrom` is set; mends only an unlinked `mf=` domain), `"nd"` (emit an `nd=` "imaginary hop" bridge — §8.7/§9.3), `"skip"` (default otherwise), `"warn"`, or `"error"`. See the Forwarder signing section for details. | +| `bridge_domain`, `bridge_selector`, `bridge_keyfile`, `bridge_keybuf` | no | Signing identity for an `nd=` bridge (`on_chain_break="nd"`). `bridge_domain` is a domain that received the message (§9.3): a prior `rt=` domain must be `bridge_domain` or a parent of it, or the previous `nd=` must name it. The key (`bridge_keyfile`/`bridge_keybuf`) is for that domain. `bridge_selector` defaults to the primary `selector` if omitted. | +| `next_domain` | no | Low-level `nd=` passthrough (§8.7): emit **this** signature as an `nd=` bridge carrying `nd=` and **no** `mf=`/`rt=`. The call's `domain`/`selector`/key must belong to a domain in the prior `rt=`, and `next_domain` MUST equal the `d=` of the next signature in the chain. A chain break is an error for such a call, whatever `on_chain_break` says. Prefer `on_chain_break="nd"` for the common auto-bridge case. | +| `on_donotmodify` | no | Action when any prior `DKIM2-Signature` on the message carries `f=donotmodify` (§8.10 / §11.8). The check is unconditional — it does not detect whether content was actually modified. Values: `"error"` (default — refuse to sign), `"warn"` (sign over the request; caller is responsible for logging/auditing), `"skip"` (return `(true, nil, {donotmodify=true})` without signing — no headers added to the message), `"ignore"` (sign over the request silently). With `"warn"` and `"ignore"` the other checks are still made: the chain of custody (see `on_chain_break`), gaps in `i=` or `m=`, and prior fields that cannot be read. | +| `timestamp` | no | `t=` value, a whole number of seconds that is not negative. `0` or absent means the current UNIX time. | +| `nonce` | no | `n=` value (`-06` §8.3). Caller-supplied string of at most 64 printable ASCII characters, without spaces or `;`. 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** (table) of flag tokens for `f=` (`-04` §8.10): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`, `"feedhere"`. `"feedhere"` (spec-04 §8.10) means this Signer requests that any feedback about how this message is handled during delivery and thereafter is relayed via this hop. A plain string is not accepted — pass a one-element array, e.g. `flags = {"donotmodify"}`. See §8.10 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 `-04` §5. 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 (§6). 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 | §9.4 / §11.4 expect `d=` to relaxed-domain-match the `mf=` (MAIL FROM) domain; a §11.4 verifier reports PERMERROR on a mismatch. Default `false` — `sign()` refuses to emit a non-aligned signature and returns an error. **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_missing_recipe` | no | If `true`, permit signing when content has changed since the prior `Message-Instance` but no `recipe` is supplied (§8.1 SHOULD). Default `false` (strict — sign call fails). When set, signing succeeds but the downstream §10.2 chain-walk cannot complete for this hop (no recipe to reconstruct prior state) and will produce `permerror`/`chain_broken` at verifiers. Use only when you accept that chain auditability is broken for this hop. | +| `flags` | no | Lua **array** (table) of flag tokens for `f=` (§8.10): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`, `"feedhere"`. Each flag must be a word; anything else fails the sign call. `"feedhere"` means this Signer requests that any feedback about how this message is handled during delivery and thereafter is relayed via this hop. A plain string is not accepted — pass a one-element array, e.g. `flags = {"donotmodify"}`. See §8.10 for semantics. 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 `-06` §5. Attached to the `Message-Instance` header as the base64-encoded `r=` tag. Validated against the schema at sign time; a recipe that is not JSON or does not follow the schema fails the sign call, with an error naming the problem. For header-field keys that are not lower case, see `on_recipe_key_case`. | +| `mi_hash_algorithms` | no | Lua array of hash algorithms for the `Message-Instance` `h=` body and header hashes (§6). Default `{"sha256"}`. Multiple algorithms produce comma-separated entries in `h=`, always `sha256` first and then `sha512`, whatever the order of the list: `{"sha512","sha256"}` → `h=sha256:HH:BH,sha512:HH:BH`. A plain string `mi_hash_algorithm="sha512"` is also accepted as a single-algorithm alias. For an unsupported or repeated algorithm, see `on_bad_hash_algorithm`. | +| `relax_d_mf_check` | no | §8.8 / §11.4 expect the `mf=` (MAIL FROM) domain to lie within `d=`; a §11.4 verifier reports PERMERROR on a mismatch. Default `false` — `sign()` refuses to emit a non-aligned signature and returns an error. **Setting to `true` is non-spec-compliant**; it signs the non-aligned signature. Recommended only for testing or debugging cross-domain signing configurations. | +| `require_recipe` | no | What to do when the message has changed since the highest `Message-Instance` and no `recipe` is supplied (§8.1 SHOULD). Default `false`: `sign()` adds a `Message-Instance` with the recipe `{"b":null}` (§5.2), which declares the earlier body unrecoverable. Header fields that changed still fail the header hash of the instances below, so supply a `recipe` for them. `true` makes `sign()` return an error. | +| `on_recipe_key_case` | no | What to do with a `recipe` whose header-field keys are not lower case (§5.1): `"error"` (default) fails the sign call; `"lowercase"` signs the recipe as written, with only its header-field keys lower-cased. | +| `on_bad_hash_algorithm` | no | What to do when `mi_hash_algorithms` names something other than `sha256` or `sha512`, or names one twice (§7.3): `"error"` (default) fails the sign call; `"drop"` signs with the valid algorithms, each once, and fails only if none is left. | `sign()` return values: @@ -183,9 +180,15 @@ of the options table. - `(true, header_value_string, info)` — success with chain-break info; `info.chain_break=true`, `info.bridged=true/false`. Returned when `on_chain_break="warn"` fires or a bridge was auto-generated. - `(true, nil, {donotmodify=true})` — when `on_donotmodify="skip"`: no signature was added, no `DKIM2-Signature` or `Message-Instance` header was written. - `(true, nil, {chain_break=true, bridged=false})` — when `on_chain_break="skip"`: signing skipped due to chain break. -- `(nil, error_string)` — failure; the message is left unmodified. +- `(nil, error_string)` — failure; the message is left unmodified. A bridge added for `on_chain_break` is removed again when the signature after it fails. +- `(nil, error_string, refusal)` — failure because `sign()` refused to sign the message. `refusal` is a table: + - `refused`: the reason, one of `too_large`, `sig_unreadable`, `donotmodify`, `mi_unreadable`, `seq_gap`, `seq_limit`, `bad_header`, `chain` or `bad_rcpt`. + - `chain_break`: set only when `refused` is `chain`; it names the break (`null_sender`, `mf_unrelated`, `nd_mismatch`, `mf_d_mismatch`, `bad_nd` or `no_mf_domain`). + - `i`: the `i=` of the signature the refusal names, absent when it names none. + + When a bridge signature failed, the refusal is the bridge's. Other failures, such as a bad option, return only the error string. -Always check the first return value. On `nil`, no headers were modified. Recipe validation failure and content-changed-without-recipe also log to paniclog at level `error`. +Always check the first return value. ### Publishing the public key @@ -199,14 +202,12 @@ DKIM2 reuses a subset of the DKIM v1 key-record format (RFC 6376 existing key deployment (draft-chuang-dkim2-dns-04 §3.4.1). The `DKIM2-Signature` header carries no version tag of its own; the generation is identified by the field name -(draft-ietf-dkim-dkim2-spec-04 §8). +(draft-ietf-dkim-dkim2-spec-06 §8). **The `p=` encoding is not the same for both algorithms.** Publish the form shown below for each. For `k=ed25519` that form is exactly what the specs require; for `k=rsa` it is deployed practice rather than the -RFC's normative text, as explained below. The one form Momentum accepts -that other verifiers will not is covered under -[Upgrading from an earlier 5.3 build](#upgrading-from-an-earlier-53-build). +RFC's normative text, as explained below. For `rsa-sha256`, `p=` is the base64 of a SubjectPublicKeyInfo — the body of the PEM with the `-----BEGIN/END-----` lines and all whitespace @@ -238,7 +239,7 @@ characters fit in one string. For `ed25519-sha256`, `p=` is the base64 of the **bare 32-byte public key** — no ASN.1 wrapper of any kind, so always 44 characters. This comes from draft-chuang-dkim2-dns-04 §3.4.1, which -draft-ietf-dkim-dkim2-spec-04 §3.6 defers to for the key-record format, +draft-ietf-dkim-dkim2-spec-06 §3.6 defers to for the key-record format, and identically from RFC 8463 §4.2. `openssl` has no output format for the bare key — `-outform` is @@ -257,27 +258,19 @@ sel-ed25519._domainkey.example.com. IN TXT ( An empty `p=` signals deliberate key revocation. -#### Upgrading from an earlier 5.3 build +#### Upgrading from Momentum 5.3.0 -Momentum 5.3 builds before this fix read every `p=` as a +Momentum 5.3.0 reads every `p=` as a SubjectPublicKeyInfo, including for `k=ed25519`. A deployment that -published an Ed25519 key in that form to make such a build verify its -own mail has a record that conformant verifiers reject. Momentum still -accepts it, but the record should be republished in the raw 32-byte -form above. Nothing needs to change for `k=rsa`. +published an Ed25519 key in that form to make 5.3.0 verify its +own mail has a record that conformant verifiers reject. Momentum now +rejects it too: `verify()` reports the sig-set as `key_fault` with reason +`key_syntax`. Republish the record in the raw 32-byte form above. Nothing +needs to change for `k=rsa`. The quickest check is the record itself: a conformant Ed25519 `p=` is 44 base64 characters, the SubjectPublicKeyInfo form 60. -Momentum can also report it, but only from the **verifying** side — -the warning fires when a key record is resolved during verification, -so a deployment that only signs will never see it. On a Momentum that -verifies mail carrying the selector, set `debug_level = "warning"` on -the `dkim2` stanza and it logs a `DWARNING` naming the selector each -time it resolves one. The shipped default is `error`, at which this -warning does **not** appear — see -[Debugging](/momentum/4/dkim2/debug). - Replace the record; do not publish both forms side by side. Two TXT records at one selector is a §11.5 PERMERROR, so a verifier that would have accepted either accepts neither. @@ -325,12 +318,12 @@ 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. +With `on_chain_break="bridge"`, `bridge_mailfrom` can be omitted when the +prior `rt=` has a single entry — Momentum infers it. When the prior `rt=` +has multiple entries, `bridge_mailfrom` is required to identify which entry +this hop received at. -#### `nd=` "imaginary hop" bridge (spec-04 §8.7 / §9.3) +#### `nd=` "imaginary hop" bridge (spec-06 §8.7 / §9.3) The spec provides an alternative to the fabricated bridge above: the `nd=` ("next domain") tag. Instead of inventing `mf=`/`rt=` values for the imaginary @@ -375,23 +368,47 @@ local ok, val, info = msys.validate.dkim2.sign(msg, vctx, { -- info.chain_break=true, info.bridged=true, info.nd=true when an nd= bridge was added ``` -The `on_chain_break` option controls what happens when a chain break is -detected but cannot be bridged: +The `on_chain_break` option controls what happens when signing would break +the chain of custody: | `on_chain_break` | Behavior | Third return value | |---|---|---| -| `"bridge"` (default with `bridge_mailfrom`) | Fabricated `mf=`/`rt=` bridge; error if ambiguous | `{chain_break=true, bridged=true}` | -| `"nd"` | `nd=` bridge; requires `bridge_domain` + key | `{chain_break=true, bridged=true, nd=true}` | +| `"bridge"` (default with `bridge_mailfrom`) | Fabricated `mf=`/`rt=` bridge; error if ambiguous | `{chain_break=true, bridged=true}`; on an error, `refusal` | +| `"nd"` | `nd=` bridge; requires `bridge_domain` + key | `{chain_break=true, bridged=true, nd=true}`; on an error, `refusal` | | `"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)` | — | +| `"error"` | Return `(nil, errmsg, refusal)` | `refusal` | 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. +of which `on_chain_break` value was used. `info.reason` names the break: +`mf_unrelated` (the new `mf=` domain links to no prior `rt=`), `null_sender` +(`mf=` is `<>` after a signature with `rt=`), `nd_mismatch` (the previous +`nd=` names another domain) or `bad_nd` (the previous `nd=` is not a +domain). + +A fabricated bridge mends only `mf_unrelated`. For the other three, +`on_chain_break="bridge"` returns an error; use `"nd"`, `"warn"` or `"skip"` +(`"nd"` cannot mend `bad_nd`). + +A relay sends the message with its own MAIL FROM, which must lie within +the signing domain, and signs with that address. Set the relay's own +address as the envelope MAIL FROM, and pass it as `mailfrom`. This links +to the previous signature only when that signature's `rt=` names an +address at the relay's MAIL FROM domain or a parent of it. Otherwise, or +if the envelope MAIL FROM is still the original sender's, the signature +would break the chain (`mf_unrelated`): under the default +`on_chain_break="skip"`, `sign()` returns without signing. To sign such +a message, set `on_chain_break` to `"bridge"` or `"nd"` (see above). -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: +```lua +msys.validate.dkim2.sign(msg, vctx, { + domain = "relay.example.org", + selector = "relay-2026", + keyfile = "/etc/dkim2/relay.example.org/relay-2026.key", + mailfrom = "bounces@relay.example.org", +}) +``` A modifier that **rewrites** the message (Subject change, body footer, attachment strip, etc.) additionally attaches a `recipe`: @@ -403,20 +420,21 @@ local ok, err = 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"]}]}}]], + recipe = [[{"h":{"subject":[{"d":["Original subject"]}]}}]], }) if not ok then msys.log(msys.core.LOG_WARNING, "dkim2 modifier sign failed: " .. (err or "unknown")) end ``` -The recipe schema is documented in `-04` §5. Recipes are mandatory only -when the hop modifies content; non-modifying hops (pure-forwarding without -edits) omit `recipe` entirely. +The recipe schema is documented in `-06` §5. Without a recipe, +a hop that changed the content signs a `{"b":null}` recipe, which declares +the earlier body unrecoverable; set `require_recipe` to refuse that. A hop +that did not change the content omits `recipe`. -When `on_chain_break="bridge"` is used and the message was modified, -supply `recipe` on the outer `sign()` call — Momentum forwards it -automatically to the auto-generated bridge signature. The bridge needs +When a bridge is added (`on_chain_break="bridge"` or `"nd"`) and the +message was modified, supply `recipe` on the outer `sign()` call — Momentum +forwards it to the auto-generated bridge signature. The bridge needs the recipe to document the content change in its `Message-Instance` header so the §10.2 chain walk can reconstruct the original state. diff --git a/content/momentum/4/dkim2/verify.md b/content/momentum/4/dkim2/verify.md index e5d6e28de..f83ae45f6 100644 --- a/content/momentum/4/dkim2/verify.md +++ b/content/momentum/4/dkim2/verify.md @@ -1,5 +1,5 @@ --- -lastUpdated: "08/16/2026" +lastUpdated: "10/03/2026" title: "DKIM2 Verifying — verify()" description: "Reference for the msys.validate.dkim2.verify() Lua API: verify options, result table, and SMTP response codes." --- @@ -42,22 +42,14 @@ function mod:validate_data_spool_each_rcpt(msg, ac, vctx) end -- result.overall is one of: - -- "pass" chain intact and most-recent sig cryptographically verified - -- (lower-hop sigs confirmed via §10.2 recipe chain only) + -- "pass" every signature checked verified and the chain of custody + -- is intact -- "fail" verified but wrong: hash/sig mismatch or policy - -- violation (donotmodify/donotexplode, etc.) - -- "permerror" could not verify: key missing/invalid/revoked, - -- key/algorithm mismatch (overall_reason= - -- "key_alg_mismatch"), signature syntax error, chain - -- integrity failure (overall_reason names the cause: - -- mi_syntax / mi_missing_tag / mi_revision_ahead / - -- mi_revision_gap / mi_unsigned / i_sequence_broken / - -- custody_break / recipe_chain_mismatch / - -- nd_highest_no_oob / body_redacted), or d=/mf= - -- domain mismatch (§11.4, overall_reason="d_mf_mismatch") + -- violation (donotmodify/donotexplode) + -- "permerror" a permanent error; result.overall_reason names it -- "temperror" resolver-side transient failure (SERVFAIL, timeout) - -- "none" no DKIM2-Signature headers, or all use unsupported - -- algorithms (§3.4 — ignored rather than failed) + -- "none" no DKIM2-Signature headers, or every signature set + -- aside (ignored rather than failed) if result.overall == "temperror" then vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") @@ -83,14 +75,16 @@ 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` | **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. An explicit value may be given bare (`user@example.com`) or envelope-decorated (``, `MAIL FROM:`, or `msg:mailfrom()`) — it is normalized to the bare address before the `mf=` binding comparison, the same as `rcptto`. `""` (null sender) is preserved. | -| `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 §11.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 §9.4 / §11.4 `d=`/`mf=` domain alignment check from a hard failure to a warning. Default `false`: a mismatch on the most-recently-applied signature downgrades `pass` → `permerror` (§11.4 enumerates this as a PERMERROR output state). **Setting to `true` is non-spec-compliant**; recommended only for testing. | -| `skip_recipe_chain` | If `true`, skip the `-04` §10.2 recipe-chain check. The per-signature crypto + envelope checks and the §9.2 chain-of-custody check still run. Default `false` (chain check ON). **Setting this to `true` makes the verifier non-spec-compliant** — §10.2 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 §8.9. **Setting this to `true` makes the verifier non-spec-compliant** — §8.9 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | -| `allow_nd_highest` | §8.7 special case: accept a message whose **highest-numbered** DKIM2-Signature carries an `nd=` tag (and therefore no `mf=`/`rt=`) — the "imaginary final hop". The receiver cannot validate `MAIL FROM`/`RCPT TO` and must "rely on out-of-band information" to accept it. Default `false`: such a message is rejected with `overall="permerror"`, `overall_reason="nd_highest_no_oob"` (distinct from a generic `custody_break` — the signature is well-formed, there is simply no arrangement to skip envelope validation). Set to `true` **only** when such an out-of-band arrangement exists; the §11.4 delivery-envelope binding is then skipped for that hop and the message verifies normally. Lua policy can gate acceptance further by inspecting the surfaced `nd=`/`d=`. | -| `allow_body_redaction` | §5.2 irreversible body: accept a message whose recipe chain contains a `"b": null` body recipe — a hop that redacted the body and declared the prior body cannot be recreated. Default `false`: the **header** chain is still verified, but the message is rejected with `overall="permerror"`, `overall_reason="body_redacted"` (distinct from a content tamper, which is `recipe_chain_mismatch`). Set to `true` to accept the redaction (§5.2 "acceptable to them", e.g. a contractually-trusted forwarder): the body hash is not walked back past the redaction; the header chain and the most-recent signature verify normally. Lua policy can gate acceptance on the redacting `d=`. | +| `mailfrom` | **Normally omitted** — Momentum reads the envelope MAIL FROM, including the null sender `<>`. Pass `mailfrom=""` to check as the null sender whatever the envelope says, or an address to check against a MAIL FROM other than the envelope's. An address may be given bare (`user@example.com`) or envelope-decorated (``, `MAIL FROM:`, or `msg:mailfrom()`); it is normalized to the bare address before the `mf=` binding comparison, the same as `rcptto`. | +| `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 §11.4 multi-recipient checking. In `validate_data_spool_each_rcpt` (recommended), auto-populates correctly per recipient. Accepts a string or a Lua table of bare addresses. A string may hold one address or several separated by commas. 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 clause) with this value as the authentication service identifier. It must be a host name or another token: printable ASCII characters other than space, double quote and `()<>@,;:\/[]?=`. A number is accepted as its decimal text. Any other value makes `verify()` return an error. Existing AR headers are never modified. When absent, no AR header is emitted. | +| `relax_d_mf_check` | If `true`, do not require the `mf=` domain to lie within `d=` (§8.8 / §11.4), so `mf_d_mismatch` is never reported. Default `false`: a mismatch is a `permerror` for the signature. **Setting this to `true` is non-spec-compliant**; use it only for testing. | +| `skip_recipe_chain` | If `true`, Momentum reads no recipe and does not rebuild or check any `Message-Instance` below the highest one. A signature that covers a lower instance then has `status="none"` unless a problem names it. Default `false`. **Setting this to `true` makes the verifier non-spec-compliant** — §10.2 is a SHOULD requirement. Use it only for debugging or when a signer's recipes are known to be broken. | +| `relax_s_selectors` | If `true`, accept duplicate selectors within a single `s=` tag. Default `false` — duplicates give `reason="sig_dup_selector"` per §8.9. **Setting this to `true` makes the verifier non-spec-compliant** — §8.9 places a MUST requirement on distinct selectors. Use only for interop with known non-compliant signers. | +| `allow_nd_highest` | §8.7 special case: accept a message whose **highest-numbered** DKIM2-Signature carries an `nd=` tag (and therefore no `mf=`/`rt=`) — the "imaginary final hop". The receiver cannot validate `MAIL FROM`/`RCPT TO` and must "rely on out-of-band information" to accept it. Default `false`: such a message is a `permerror` with `reason="nd_unexpected"`. Set to `true` **only** when such an out-of-band arrangement exists; the §11.4 envelope check is then skipped for that hop. Lua policy can gate acceptance further by inspecting the `nd=` and `d=` values. | +| `reject_body_redaction` | §5.2 irreversible body: a recipe with `"b": null` says a hop redacted the body and the earlier body cannot be recreated. Default `false`: the message can pass, `result.body_redacted` is `true`, and the body of the instances below the redaction is not verified; the header fields and the highest signature are checked as usual. Set to `true` to make such a message a `permerror` with reason `body_redacted`. | +| `on_recipe_key_case` | What to do with a recipe whose header-field keys are not lower case (§5.1): `"lowercase"` (default) applies the recipe as if its keys were lower case; `"error"` makes the recipe invalid, a `permerror` with reason `mi_invalid_json`. | +| `on_bad_hash_algorithm` | What to do with a `Message-Instance` that names a hash algorithm twice (§7.3): `"error"` (default) makes it a `permerror` with reason `mi_dup_hash_alg`; `"drop"` keeps the first hash-set for that algorithm and reads the instance as usual. | | `max_sig_age_days` | §11.3: reject signatures whose `t=` timestamp is older than this many days. Default `14`. Values `<= 0` disable the age check. | | `max_sig_future_secs` | §8.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`. | @@ -99,8 +93,10 @@ header format, `ar_clauses()` API, and examples of building combined headers. - **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. +- **Internal failure**, such as running out of memory: `result` is `nil`, + `err` is a non-nil string describing the cause. +- **Invalid option value**, such as `on_bad_hash_algorithm = "dropped"`: + `result` is `nil` and `err` names the option. Always capture both return values so internal failures can be logged and acted on separately from signature verdicts. @@ -109,211 +105,151 @@ separately from signature verdicts. ``` result = { - overall = "pass" -- all verifiable signatures passed - | "fail" -- verified but wrong: hash/sig mismatch, or - | -- policy violation (donotmodify/donotexplode, etc.) - | "permerror" -- could not verify: key missing/revoked/invalid, - | -- signature syntax error, chain integrity failure, - | -- or d=/mf= domain mismatch (§11.1 / §11.4 PERMERROR) + overall = "pass" -- every signature checked passed + | "fail" -- verified but wrong: a hash or signature + | -- mismatch, or a donotmodify/donotexplode + | -- violation + | "permerror" -- a permanent error; overall_reason names it | "temperror" -- transient key-fetch failure (DNS timeout / SERVFAIL) - | "none", -- no DKIM2-Signature headers, or a lone signature - | -- using an unsupported algorithm (§3.4). NB: an - | -- unsupported *highest* sig in a multi-hop chain is - | -- permerror (highest_sig_unsupported), not none. - 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="permerror": d= doesn't match - -- mf= domain of the most-recent sig after - -- crypto pass (§9.4 / §11.4) - | "donotmodify_violated" -- overall="fail": f=donotmodify sig - -- followed by a modifying hop (§11.8) - | "donotexplode_violated" -- overall="fail": f=donotexplode sig - -- followed by f=exploded (§11.8) - | "highest_sig_unsupported",-- overall="permerror": the highest-i - -- sig uses an unsupported algorithm in a - -- multi-hop chain, so the most-recent hop - -- can't be authenticated — not "none" - -- (§3.4 / §11.5) - signatures = { - { seq = , - m = , - status = "pass" -- signature verified - | "fail" -- signature failed; see reason - | "chain_verified" -- earlier hop (i, -- see Per-signature reason codes table below + | "none", -- no DKIM2-Signature headers, or every signature + | -- set aside + overall_reason = "", -- the reason of the problem that decided + -- overall; absent when overall is "pass" + -- or "none" with no problem + overall_text = "", -- that problem's text, in the draft's + -- wording where it has one + overall_detail = "", -- why, for an operator; often absent + signatures = { -- highest i= first + { seq = , -- 1 for the originator, 2 for the first + -- forwarder, ... + m = , -- the Message-Instance it covers + status = "pass" | "fail" | "permerror" | "temperror" | "none", + reason = "", -- see the reason table below d = "", - s = "::", -- raw s= value; AR header.s= carries - -- only ":" (base64 stripped) - mf = "", -- decoded from base64; absent on an nd= bridge - rt = "[,...]", -- all entries decoded from base64; absent on an nd= bridge - nd = "", -- present (with mf/rt absent) on an - -- "imaginary hop" bridge signature - 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. + nd = "", -- only on an nd= bridge + mf = "", -- "" for the null sender; absent on a bridge + rt = "[,...]", + s = "[,...]", + n = "", -- if present + f = "", -- if present, comma-separated + t = , + key_testing = true, -- a key had t=y (RFC 6376 §3.6.1) + current = true, -- its Message-Instance records the same + -- hashes as the highest signature's + sets = { -- one per sig-set of s=, in order + { selector = "", alg = "", + outcome = "pass" | "fail" | "key_fault" | "testing" | "unchecked", + reason = "", -- for a key_fault + key_name = "._domainkey.", + detail = "", + key_bits = , }, + ... + }, }, ... }, - highest_i = , - highest_mf = "", -- §12 DSN target; - -- "<>" for the null sender (a DSN MUST NOT be sent); - -- absent when no signature carried an mf= + problems = { -- every problem found, in the order checked + { result = "", reason = "", i = , m = , + text = "", detail = "" }, + ... + }, + ar = { -- the Authentication-Results entries + { result = "", i = , d = "", + reason = "", text = "" }, + ... + }, + highest_i = , -- absent with no signature + highest_mf = "", + -- the DSN target (§12); "<>" for the null sender; + -- absent when the highest signature has no mf= + dsn_prohibited = true | false, -- true when the message must not get a DSN + replay_id = "", -- only when the message has one (§11.9) + replay_record = true | false, -- with replay_id: true when the identity + -- may be recorded + body_redacted = true, -- only when a recipe declared an earlier + -- body irreversible } ``` -**Key testing mode (`key_testing`)**: When a signing key is published with `t=y` in its DNS TXT record, the signer is testing that key in production. RFC 6376 §3.6.1 (inherited by DKIM2) says verifiers SHOULD NOT treat failures as definitive in this case. Momentum surfaces the flag as `key_testing=true` on the affected per-sig entry and logs a `DWARNING`, but leaves the policy decision to operator code. To follow the SHOULD recommendation: - -```lua -for _, sig in ipairs(result.signatures or {}) do - if sig.key_testing and sig.status ~= "pass" then - -- key is in testing mode; treat this sig's failure as non-definitive - -- (e.g. override result.overall to "none" or log and accept) - end -end -``` - -For messages that passed through multiple signing hops, Momentum verifies -the **most recent signature** cryptographically (§11.5) and confirms the -**full chain of custody** end-to-end — the §11.4 envelope `mf=`/`rt=` linkage -plus the §10.2 Message-Instance recipe reconstruction. 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 -an `overall_reason` naming the specific cause — one of `mi_syntax`, -`mi_missing_tag`, `mi_revision_ahead`, `mi_revision_gap`, `mi_unsigned`, -`i_sequence_broken`, `custody_break`, or `recipe_chain_mismatch` (the -generic `chain_broken` remains as a fallback). `overall="pass"` means the content -chain is intact; note that public-key (§11.5) cryptographic verification -is only performed for the most recent hop. This is correct for the §10.1 -acceptance decision, but §10.3 use cases that need each lower hop's -signature individually verified — Reviser reputation, `feedback` handling, -explosion assessment — are not supported (there is no option to crypto-verify -every hop); see [Known Limitations](/momentum/4/dkim2#known-limitations) for -details. +Absent fields are `nil`. The `reason` names are listed under +[Reason codes](#reason-codes). + +**Key testing mode (`key_testing`)**: When a signing key is published with +`t=y` in its DNS TXT record, the signer is testing that key in production. +RFC 6376 §3.6.1 (inherited by DKIM2) says verifiers MUST NOT treat a message +signed with a testing key differently from unsigned mail. Momentum sets +`key_testing=true` on the signature. A signature whose checked keys are all +in testing mode is set aside: its `status` is `"none"` and its `reason` is +`key_testing`, and it decides `overall` only when no other signature passed. + +For a message that passed through several signing hops, Momentum checks +every signature: it looks up each key, verifies each signature, and compares +the hashes of each `Message-Instance` with the message as reconstructed from +the recipes. Each entry in `result.signatures` carries its own verdict. +Momentum also checks the chain of custody — the §11.4 envelope `mf=`/`rt=` links — +end to end. `overall` is the first `fail` or `permerror` found, in the order +of §11, else the first `temperror`, else `none` when a signature was set +aside and none passed, else `pass`. A signature whose `Message-Instance` +cannot be rebuilt has `status="none"` unless a problem names it. **`nd=` "imaginary hop" bridges (§8.7 / §9.3).** A forwarder that changes domains may insert a bridge signature carrying an `nd=` ("next domain") tag -instead of fabricating `mf=`/`rt=` values. Momentum verifies these: an `nd=` -signature must omit `mf=`/`rt=`, must not be the highest-numbered signature, -and its `nd=` value must exactly match the `d=` of the next signature in `i=` -order (§11.4). Momentum additionally checks that the bridge's own signing -domain (`d=`) relaxed-matches a recipient domain in the prior hop's `rt=` -(§9.3 — the bridge must be signed by a domain that received the message). Any -of these failing surfaces as `overall="permerror"`, `overall_reason="custody_break"`, -with the specific cause (`nd= does not match`, `nd= with mf=/rt=`, etc.) logged -at `info` level. The bridge signature itself appears in `result.signatures` -with `status="chain_verified"` and its `nd` field populated. - -### 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. Unless otherwise noted, each reason code below pairs with `status="fail"` in `result.signatures[i].status`. - -> **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. +instead of fabricating `mf=`/`rt=` values. Momentum verifies these. The +bridge must not be the highest-numbered signature, unless `allow_nd_highest` +is set (`reason="nd_unexpected"`). Its `nd=` value must exactly match the +`d=` of the next signature: if it does not, the next signature has +`reason="custody_break"`, and the bridge, when it verifies, +`reason="nd_mismatch"`. Its own signing domain must relaxed-match a +recipient domain in the prior hop's `rt=` (`reason="custody_break"`). The +bridge appears in `result.signatures` with its `nd` field set. + +### Reason codes + +`reason` is set on each entry of `result.signatures` and `result.problems`, +on a `key_fault` entry of `signatures[].sets`, and on +`result.overall_reason`. Policy code can branch on them. A signature's +`reason` also appears in the `X-MSYS-DKIM2-Verify-Sig` debug header. A +signature whose checks all pass has `status="pass"` and a `reason` of `none`. + +The result for each reason is `permerror` unless the table says otherwise. | 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.2 recipe chain rather than performing a full §11.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). §11.3 says verifiers SHOULD reject such signatures; Momentum's implementation choice is to treat this as PERMERROR (permanently unverifiable — no cryptographic verification is attempted). 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 (§8.4 MAY) does not define a verdict for this case. | -| `nonce_too_long` | The `n=` nonce exceeded the 64-character ceiling (§8.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 (§11.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 bytes are not a usable public key for the record's `k=`. For `k=rsa` that means they are not a valid DER SubjectPublicKeyInfo. For `k=ed25519` it means they are neither the bare 32-byte key the spec requires nor a SubjectPublicKeyInfo — see [Publishing the public key](/momentum/4/dkim2/sign#publishing-the-public-key). | -| `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | -| `key_alg_mismatch` | The resolved public key's type doesn't match the signature's named algorithm — e.g. an `rsa-sha256` sig-set verified against a `k=ed25519` key (or vice versa), a DNS record whose `k=` tag disagrees with the actual `p=` key, or an RSA-PSS public key; maps to `dkim2=permerror`. | -| `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 outside the accepted size range — smaller than the 1024-bit minimum required, or larger than 16384 bits. The upper bound bounds DoS-via-huge-key (an oversized modulus makes verification expensive). 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, the MI's `h=` tag was malformed, or it carried no entry for a hash algorithm Momentum implements. Momentum allow-lists `sha256` / `sha512`; it ignores any other algorithm (e.g. `md5`, `sha1`) rather than computing it, so an `h=` that names only an unsupported algorithm yields this reason instead of a downgraded hash check. | -| `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 (§11.1):** Most `status="fail"` reasons produce `dkim2=fail` in the AR header. Exceptions, per the §11.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_alg_mismatch`, `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`). - -**Non-conformant Ed25519 key records**: a `k=ed25519` record must carry -the bare 32-byte public key in `p=` (RFC 8463 §4.2, -draft-chuang-dkim2-dns-04 §3.4.1), not a DER SubjectPublicKeyInfo. -Momentum 5.3 builds before this fix instead expected a -SubjectPublicKeyInfo, so a sender running such a build against its own -mail may have published that form. Momentum accepts both — the -signature still verifies — and at `debug_level = "warning"` or a more -verbose level logs a `DWARNING` naming the selector whenever it -resolves the SubjectPublicKeyInfo form, since that record fails at -other verifiers and only the publishing sender can correct it. The -default level is `error`, at which the warning is not emitted — see -`debug_level` in [Debugging](/momentum/4/dkim2/debug). See -[Publishing the public key](/momentum/4/dkim2/sign#publishing-the-public-key) -for which form to publish. - -### recipe_chain detail strings (paniclog only) - -When the recipe-chain check fails, the overall verdict is `permerror` -with `overall_reason="recipe_chain_mismatch"`, 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. | -| `body_redacted` | A hop's recipe declared `"b": null` (§5.2) — an irreversibly redacted body that cannot be reverse-reconstructed. The **header** chain and the most-recent signature still verify; only the body hash can't be walked back to `MI[1]`. Rejected by default (a §5.2-permitted strict posture), distinct from a content tamper (`recipe_chain_mismatch`). Set the `allow_body_redaction` verify option to accept the redaction from a trusted forwarder. | -| `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 always emit an `r=` recipe rather than omitting it: provide a header recipe for any changed header field (an empty step array `[]` removes all instances of a field); the body may be declared irreversible with `"b":null`. | -| `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. | +| `mi_missing` | A signature names a `Message-Instance` that is not on the message. | +| `mi_syntax`, `mi_tag_missing`, `mi_invalid_json` | A `Message-Instance` is malformed, lacks a required tag, or its recipe is not valid JSON or fails the recipe schema (for example, a header-field key that is not lower case with `on_recipe_key_case="error"`). | +| `mi_not_signed` | No signature covers a `Message-Instance`. | +| `mi_dup_hash_alg` | A `Message-Instance` names a hash algorithm twice. See `on_bad_hash_algorithm`. | +| `mi_no_supported_hash` | A `Message-Instance` has no hash-set in an algorithm Momentum implements (`sha256`, `sha512`). | +| `sig_missing`, `sig_syntax`, `sig_tag_missing`, `sig_tag_unexpected` | A `DKIM2-Signature` is missing from the `i=` sequence, malformed, lacks a required tag, or carries a tag it should not. | +| `sig_dup_selector`, `sig_too_many_selectors` | `s=` repeats a selector (§8.9; see `relax_s_selectors`), or has more than two sig-sets for one algorithm. | +| `sig_m_order` | A signature covers an older `Message-Instance` than the signature below it. | +| `sig_expired` | `t=` is older than `max_sig_age_days`. | +| `sig_future` | `t=` is more than `max_sig_future_secs` in the future. | +| `mail_from_mismatch`, `rcpt_to_mismatch` | The signed `mf=` or `rt=` does not match the envelope MAIL FROM or RCPT TO. | +| `mf_d_mismatch` | The `mf=` domain does not lie within `d=` (see `relax_d_mf_check`). | +| `nd_mismatch`, `nd_unexpected` | An `nd=` value differs from the next `d=`, or `nd=` is on the highest signature. See `allow_nd_highest`. | +| `custody_break` | The chain of custody is broken: an `mf=` or bridge `d=` matches no `rt=` of the previous signature, a `d=` is not the domain the previous signature's `nd=` names, or `mf=` is `<>` after a signature with `rt=`. | +| `key_unavailable` | DNS returned a transient failure (SERVFAIL, timeout). Result `temperror`. | +| `key_missing` | No TXT record exists for the selector. | +| `key_multiple` | More than one TXT record exists for the selector. | +| `key_syntax` | The key record cannot be used: malformed record, `p=` that is not base64 or not a usable key of the record's `k=`, RSA key under 1024 bits or over 4096 bits, or an exponent other than 65537; `detail` says which. For an Ed25519 key in the form Momentum 5.3.0 expected, see [Upgrading from Momentum 5.3.0](/momentum/4/dkim2/sign#upgrading-from-momentum-530). | +| `key_alg_mismatch` | The key's type does not match the signature's algorithm. | +| `key_revoked` | The key record has an empty `p=`. | +| `sig_incorrect` | The signature does not verify. Result `fail`. | +| `sig_partial` | At least one sig-set of a signature verified and another sig-set's signature did not. A sig-set with a missing or unusable key does not cause this. Result `fail`. | +| `sig_no_supported_alg` | Every sig-set uses an algorithm Momentum does not implement (§3.4). Result `none`. | +| `key_testing` | Every key checked is in testing mode. Result `none`. | +| `header_hash_mismatch`, `body_hash_mismatch` | A hash in a `Message-Instance` does not match the message. Result `fail`. | +| `recipe_unappliable` | A recipe cannot be applied to the message, so the instance below it cannot be rebuilt. | +| `donotmodify_violated`, `donotexplode_violated` | The message was modified or exploded after a signature asked it not to be. Result `fail`. | +| `body_redacted` | A recipe declared an earlier body irreversible and `reject_body_redaction` is `true`. | +| `limit` | The message exceeds a verification limit, such as the number of header fields. | + +A `Message-Instance` may carry both `sha256` and `sha512` hash-sets; every +one is compared, and one mismatch fails the signature (§11.7). + +Authentication-Results output uses these results; see +[Authentication-Results Output](/momentum/4/dkim2/ar-clauses). ### ec_message context fields @@ -324,7 +260,7 @@ read the outcome without re-verifying or parsing `Authentication-Results:`: |---|---|---| | `dkim2_overall` | string | Verdict: `"pass"`, `"fail"`, `"permerror"`, `"temperror"`, or `"none"`. See the [SMTP response codes](/momentum/4/dkim2/verify#smtp-response-codes-104-guidance) table. | | `dkim2_n_sigs` | string | Number of `DKIM2-Signature` headers found on the message. Parse with `tonumber()`. | -| `dkim2_highest_mf` | string | §12 DSN target: the `mf=` (bare MAIL FROM) of the highest-`i=` signature — the address a bounce would be addressed to. `"<>"` for the null sender, for which §12 says a DSN MUST NOT be sent. Not set when no signature carried an `mf=`. | +| `dkim2_highest_mf` | string | §12 DSN target: the `mf=` (bare MAIL FROM) of the highest-`i=` signature — the address a bounce would be addressed to. `"<>"` for the null sender, for which §12 says a DSN MUST NOT be sent. Not set when the highest readable signature has no `mf=`. | These keys are not set until `verify()` runs. @@ -338,9 +274,9 @@ SMTP behaviour as required by §10.4 of the DKIM2 spec: | `overall` | Meaning | §10.4 guidance | Suggested action | |---|---|---|---| | `pass` | All verifiable signatures passed | — | Accept | -| `none` | No DKIM2 signatures present, or all use unsupported algorithms (§3.4) | — | Local policy | +| `none` | No DKIM2 signatures present, or every signature set aside | — | Local policy | | `fail` | Verified but wrong: hash/sig mismatch or policy violation (`donotmodify`/`donotexplode`, etc.) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject or accept per policy | -| `permerror` | Could not verify: key missing/revoked/invalid, key/algorithm mismatch (`overall_reason="key_alg_mismatch"`), syntax error, chain integrity failure (`overall_reason` is one of `mi_syntax`/`mi_missing_tag`/`mi_revision_ahead`/`mi_revision_gap`/`mi_unsigned`/`i_sequence_broken`/`custody_break`/`recipe_chain_mismatch`, or the generic `chain_broken`), or `d=`/`mf=` domain mismatch (`overall_reason="d_mf_mismatch"`) (§11.1 / §11.4 PERMERROR) | SHOULD 550/5.7.x | Reject (permanent) | +| `permerror` | A permanent error; `overall_reason` names it (see [Reason codes](#reason-codes)) | SHOULD 550/5.7.x | Reject (permanent) | | `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | **Key rules from §10.4**: diff --git a/content/momentum/changelog/5/5-3-1.md b/content/momentum/changelog/5/5-3-1.md new file mode 100644 index 000000000..cfb551ac4 --- /dev/null +++ b/content/momentum/changelog/5/5-3-1.md @@ -0,0 +1,23 @@ +--- +lastUpdated: "10/03/2026" +title: "Momentum 5.3.1 Changelog" +description: "This section will list all of the major changes that happened with the release of Momentum 5.3.1. Depending on installation type, all changes may not be applicable" +--- + +This section will list all of the major changes that happened with the release of **Momentum 5.3.1**. Depending on installation type, all changes may not be applicable + + + +| Type | Ticket | Description | +| --- | --- | --- | +| Enhancement | I-1651 | The **experimental** [DKIM2](/momentum/4/dkim2) module now implements the **-06** draft version of the specification. Because what is hashed and signed changed, signatures do not verify between Momentum 5.3.0 and 5.3.1 in either direction for Ed25519 signatures, for messages with a body line starting with `.`, and for messages carrying a header field whose hashing changed, such as `Received-SPF`. | +| Feature | I-1651 | DKIM2 [signing](/momentum/4/dkim2/sign) now refuses, by default, a recipe whose header-field keys are not lower case, a `mi_hash_algorithms` list naming anything other than `sha256` or `sha512` or naming one twice, and more than two `sig_sets` for one algorithm; policy scripts that did this under 5.3.0 fail to sign after the upgrade. [Verification](/momentum/4/dkim2/verify) returns `permerror` for a `Message-Instance` naming `sha256` or `sha512` twice and for a signature carrying more than two sig-sets for one algorithm, and reads a recipe key that is not lower case as if it were. The new `on_recipe_key_case` and `on_bad_hash_algorithm` options, on `sign()` and `verify()`, choose the behavior. | +| Feature | I-1705 | DKIM2 [verification](/momentum/4/dkim2/verify) now checks every signature on a message, not only the highest, and the `verify()` result has a new shape that is not compatible with 5.3.0: each entry of `signatures` has its own verdict, and reasons have new names, such as `body_hash_mismatch` and `key_missing`. Policy scripts that read the result must change. | +| Feature | I-1705 | DKIM2 [Authentication-Results](/momentum/4/dkim2/ar-clauses) has one clause for each signature checked and one for each problem no signature accounts for, with the `reason=` text of the draft and no extra overall clause. `header.s`, `header.mf` and `header.rt` are usually written as quoted-strings. | +| Feature | I-1705 | The DKIM2 options `require_recipe` for [signing](/momentum/4/dkim2/sign) and `reject_body_redaction` for [verification](/momentum/4/dkim2/verify) replace `allow_missing_recipe` and `allow_body_redaction`, which are now rejected with an error naming the new option. The defaults changed: `sign()` signs a changed message that has no recipe with the recipe `{"b":null}`, and `verify()` accepts such a message without verifying the earlier body; 5.3.0 refused both. Set the new option to `true` to keep the 5.3.0 behavior. | +| Feature | I-1705 | DKIM2 [`sign()`](/momentum/4/dkim2/sign) returns a third value on failure, a `refusal` table naming why it refused to sign, and its `info` table names a chain break in `reason`. A null sender after a signature with `rt=`, and a previous `nd=` that does not name the signing domain, are now chain breaks, and a call with `next_domain` fails on a chain break; 5.3.0 did not check those. A failed `sign()` now leaves the message unchanged: a bridge signature it added is removed. | +| Feature | I-1705 | DKIM2 [verification](/momentum/4/dkim2/verify) now rejects an Ed25519 key record whose `p=` is a DER SubjectPublicKeyInfo instead of the raw 32-byte key (reason `key_syntax`), and an RSA key over 4096 bits; 5.3.0 accepted both. A signature with one sig-set that verifies and another that does not is now a `fail` with reason `sig_partial`; 5.3.0 passed it. | +| Feature | I-1705 | DKIM2 [`sign()`](/momentum/4/dkim2/sign) and [`verify()`](/momentum/4/dkim2/verify) now fail with an error naming the option when an option has a value of the wrong type, where 5.3.0 ignored the value or read it differently. For example, `emit_debug_headers = "false"` turned the debug headers on in 5.3.0 and is now an error. `authservid` must be a host name or a token. Policy scripts that did this under 5.3.0 fail after the upgrade. | +| Fix | I-1678 | Fixed SMTP smuggling when relaying a message with a line that starts with `.` right after a bare CR or LF. Momentum wrote that line ending as CRLF without dot-stuffing the line, so a lone `.` ended the message at the next hop and the lines after it arrived there as SMTP commands; a line such as `.b` lost its `.`. This affected `RFC2822_Lone_LF_in_Body = fix`, a header block that ends with a bare CR or LF under any setting, a header line after a bare LF under `RFC2822_Lone_LF_in_Headers = fix`, a line after a MIME closing boundary that ends in a bare LF when a policy fully parses the message, and messages forwarded with Lua `msg:forward()`. | +| Fix | I-1678 | Fixed a header, or the blank line after the headers, being sent twice when relaying some large messages, depending on the length of the headers. | +| Fix | I-1683 | Fixed message parts that have a line over the length limit when `RFC2822_Max_Line_Length = fix`: with `RFC2822_Lone_LF_in_Body = fix`, their bare CR and LF line endings are now written as CRLF, and lines within the limit are no longer split. Before, a 997-character line was split inside its CRLF and a 998-character line got an empty line inserted after it. | diff --git a/content/momentum/changelog/5/index.md b/content/momentum/changelog/5/index.md index 25c3fcd29..d1edcf389 100644 --- a/content/momentum/changelog/5/index.md +++ b/content/momentum/changelog/5/index.md @@ -6,6 +6,7 @@ name: "Momentum 5.x Changelogs" description: "Momentum 5.x Changelogs" --- +* [Momentum 5.3.1 Changelogs](/momentum/changelog/5/5-3-1) * [Momentum 5.3.0 Changelogs](/momentum/changelog/5/5-3-0) * [Momentum 5.2.1 Changelogs](/momentum/changelog/5/5-2-1) * [Momentum 5.2.0 Changelogs](/momentum/changelog/5/5-2-0) diff --git a/content/momentum/navigation.yml b/content/momentum/navigation.yml index 6250aa3e0..084760945 100644 --- a/content/momentum/navigation.yml +++ b/content/momentum/navigation.yml @@ -2003,6 +2003,8 @@ - link: /momentum/changelog/5 title: Momentum 5.x Changelog items: + - link: /momentum/changelog/5/5-3-1 + title: Momentum 5.3.1 Changelog - link: /momentum/changelog/5/5-3-0 title: Momentum 5.3.0 Changelog - link: /momentum/changelog/5/5-2-1