diff --git a/content/momentum/4/dkim2.md b/content/momentum/4/dkim2.md new file mode 100644 index 00000000..9808eae1 --- /dev/null +++ b/content/momentum/4/dkim2.md @@ -0,0 +1,344 @@ +--- +lastUpdated: "06/25/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-03." +--- + +## On This Page + +- [What DKIM2 is, and why](#what-dkim2-is-and-why) +- [How it differs from DKIM1 at a glance](#how-it-differs-from-dkim1-at-a-glance) +- [Enabling the module](#enabling-the-module) +- [Key management](#key-management) +- [Known limitations](#known-limitations) + +## Reference + +- [DKIM2 Signing — sign()](/momentum/4/dkim2/sign) +- [DKIM2 Verifying — verify()](/momentum/4/dkim2/verify) +- [DKIM2 Authentication-Results — ar_clauses()](/momentum/4/dkim2/ar-clauses) +- [DKIM2 Debugging Reference](/momentum/4/dkim2/debug) + +--- + +### Warning + +DKIM2 targets the in-progress IETF draft +[`draft-ietf-dkim-dkim2-spec-03`](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-03) +(24 June 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 +> signed by the old release will fail verification at receivers that have also +> upgraded. Messages signed by DKIM1 are unaffected. + + +## What DKIM2 is, and why + +[DKIM1](/momentum/4/using-dkim) (RFC 6376) lets a sending domain attach a +cryptographic signature that lets a receiver confirm "this message came from +that domain, and the body + signed headers haven't been altered since +signing". It is widely deployed, but it has two known limitations: + +1. **Replay.** DKIM1 can sign the `To:` header field, but nothing in a + DKIM1 signature is bound to the *SMTP envelope RCPT TO* — the address + that controls actual delivery. An attacker who captures a DKIM1-signed + message can change the envelope recipient and re-inject it; the `To:` + header and the signature remain intact and valid. Receivers have no way + to tell, from the signature alone, that the message was delivered to + someone other than the originally intended recipient. + +2. **Indirect mail flows.** Forwarders and mailing lists routinely modify + messages — rewriting the Subject, adding a footer, expanding the + recipient list — and DKIM1 has no native way for them to attest to + those modifications. The upstream signature breaks, and the receiver + has to fall back on ARC or on heuristics. + +DKIM2 addresses both: + +* Each signature **binds the envelope** to the signed bytes (per-signature + `mf=` for MAIL FROM and `rt=` for RCPT TO). A replay to a different + recipient mismatches `rt=`; a replay from a different sender mismatches + `mf=`. + +* The chain of signatures forms an explicit **chain of custody**: each + hop's `mf=` must match the previous hop's `rt=` list (which may encode + more than one recipient, comma-separated), so the verifier can confirm + the path was a real forward, not a detour. This inter-signature match is + **relaxed and domain-only** (§9.4) — the local part is ignored and a + bounce subdomain (`bounce@mail.example.com`) links to a prior + `rt=user@example.com` — so a bridge is only needed on a real domain + change. (The separate **delivery binding** of the most-recent signature + against the live envelope, §11.4, stays exact and local-part-aware.) + +* Modifying hops **record their modifications** as a JSON "recipe" on a + new `Message-Instance:` header. The verifier can reverse-apply the + recipe to reconstruct the previous instance's bytes and confirm the + upstream hashes still hold. + +This page covers everything an operator needs to enable, observe, and +debug DKIM2 signing and verification on Momentum. The wire-format +specifics live in the [IETF +draft](https://datatracker.ietf.org/doc/html/draft-ietf-dkim-dkim2-spec-03); +the operationally-relevant signal codes (per-signature reasons, overall +verdicts, paniclog lines) are inventoried in the +[Debugging](/momentum/4/dkim2/debug) reference page. + + +## How it differs from DKIM1 at a glance + +| Concern | DKIM1 (RFC 6376) | DKIM2 (draft `-03`) | +|---|---|---| +| Header name | `DKIM-Signature:` | `DKIM2-Signature:` | +| Hashes carried in | The signature header itself (`bh=` + `b=`) | A separate `Message-Instance:` header (`h=sha256::`) referenced via `m=` | +| Envelope binding | None | `mf=` / `rt=`, base64-encoded | +| Chain | Implicit (multiple sigs, no required ordering) | Explicit (`i=N` 1..N, `i=N`'s `mf=` domain relaxed-matches an `i=N-1` `rt=` domain, §9.4) | +| Modifications | Break the upstream signature | Recorded as a JSON recipe on the modifier's MI; reverse-applicable | +| Key record | DNS TXT at `._domainkey.` | Same — DKIM2 reuses the DKIM1 key-publishing format | +| Algorithm | `rsa-sha256`, `ed25519-sha256` | `rsa-sha256`, `ed25519-sha256` | + +Sending domains keep their existing DKIM1 keys: DKIM2 uses the same +`._domainkey.` TXT-record format. There is no extra DNS +provisioning step to start signing DKIM2. + + +## Enabling the module + +**Step 1 — Configuration**: Add the following stanza to your Momentum +configuration before using any DKIM2 Lua API: + +``` +dkim2 {} +``` + +The `debug_level` option is documented in the +[Debugging](/momentum/4/dkim2/debug) reference page. + +**Step 2 — Policy hook**: DKIM2 signing and verification are driven +entirely from Lua policy. The module does nothing automatically — you +must call the APIs explicitly from a validation hook. The recommended +hook is `validate_data_spool_each_rcpt`, which runs once per recipient +and gives sign() access to the per-recipient envelope address for +`rt=` binding: + +Signing and verification are separate concerns — typically signing is done +on outbound messages and verification on inbound. The examples below show +each in isolation. + +**Outbound signing:** + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local ok, err = msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "dkim2-2026", + keyfile = "/etc/dkim2/example.com/dkim2-2026.key", + }) + if not ok then + msys.log(msys.core.LOG_WARNING, + "dkim2 sign failed: " .. (err or "unknown")) + -- message continues unsigned; adjust policy as needed + end + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_signer", mod) +``` + +**Inbound verification:** + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local result, err = msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta.example.com", + }) + if not result then + -- internal error; treat as temperror + msys.log(msys.core.LOG_WARNING, + "dkim2 verify error: " .. (err or "unknown")) + vctx:set_code(451, "4.7.5 DKIM2 verification error; please retry") + return msys.core.VALIDATE_DONE + end + -- Apply local policy based on result.overall. + -- See /momentum/4/dkim2/verify for the full skeleton with all cases. + if result.overall == "temperror" then + vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") + return msys.core.VALIDATE_DONE + end + if result.overall == "fail" or result.overall == "permerror" then + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + return msys.core.VALIDATE_DONE + end + -- "pass" or "none": accept per local policy + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_verifier", mod) +``` + +See [DKIM2 Signing](/momentum/4/dkim2/sign) and +[DKIM2 Verifying](/momentum/4/dkim2/verify) for the full option +reference and more complete policy examples. + +## Key management + +DKIM2 reuses the DKIM1 key infrastructure. Keys are PEM-encoded RSA or +Ed25519 private keys, supplied either as a file path (`keyfile`) or as +raw PEM bytes in memory (`keybuf`). The matching public key is published in DNS +at `._domainkey.` as a TXT record with the standard +RFC 6376 §3.6.1 format (`v=DKIM1; k=rsa; p=`). + +If you already publish DKIM1 keys at a selector, you can reuse the same +selector for DKIM2 without any DNS change. To generate fresh keys for +DKIM2 specifically, follow the standard openssl recipe in +[Generating DKIM Keys](/momentum/4/using-dkim#generating-dkim-keys). + +### Note + +DKIM2 signatures and DKIM1 signatures **coexist on the wire**: they are +distinct headers (`DKIM2-Signature:` vs. `DKIM-Signature:`) and use +separate verifier paths. A message can carry both and either or both can +pass independently. If you enable DKIM2 signing for a domain that +already does DKIM1 signing, downstream verifiers that don't know about +DKIM2 will simply ignore the new header — they will continue to verify +the DKIM1 signature normally. + +ARC also coexists with DKIM2 without conflict: ARC uses its own header +set (`ARC-Seal:`, `ARC-Message-Signature:`, `ARC-Authentication-Results:`) +and an independent chain model. A message can carry DKIM1, DKIM2, and ARC +headers simultaneously. Momentum's ARC module (`msys.validate.openarc`) +and the DKIM2 module operate independently — enabling one does not affect +the other. Receivers that support both will evaluate each chain separately. + + +## Known limitations + +The following are known gaps or operational considerations to be aware of: + +* **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 + automatically enforced — `verify()` only reports a verdict, so policy + must explicitly reject rather than bounce on verify failure. On the + generation side (§12), Momentum **exposes the DSN target** — the `mf=` + 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 (`soft_bounce.c`) 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 + 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. Note also the + -03 rule (§12.1.1): a DSN always contains the message headers up to the + point at which the DSN creator saw the message on the outward journey, + and the DSN is rebuilt to reflect the state the message was in when it + was forwarded. + +* **§9.2 Forwarder auto-detection**: When Momentum acts as a forwarder + or mailing list (changing the envelope MAIL FROM and re-delivering), + the policy hook must explicitly call `sign()` to add the forwarder's + own DKIM2 signature. Momentum does not automatically detect that a + forward is happening and call `sign()` on its own. Without this + explicit call, the receiver only sees the original sender's signature — + it has no way to verify that the forwarder handled the message + correctly. See the [Forwarder and modifier signing](/momentum/4/dkim2/sign#forwarder-and-modifier-signing) + section for how to do this. + +* **§8.6 BCC privacy is a signer policy obligation**: §8.6 requires that a + signer MUST NOT reveal `bcc:` recipients to any other recipient. Because + `rt=` lives in the `DKIM2-Signature:` header that every recipient of a + copy can read, a single `rt=` listing a blind-copied address would leak + it. Momentum's per-cowref signing (`validate_data_spool_each_rcpt`) + satisfies §8.6 by construction — each delivery's `rt=` carries only that + recipient's address. Momentum does **not** attempt to auto-detect BCC at + sign time (there is no envelope-level BCC marker — a BCC recipient is + simply an envelope RCPT TO absent from `To:`/`Cc:`, which is only a + heuristic), so when a policy writer instead signs once with an explicit + multi-recipient `rcptto`/`rt=` in the shared hook, excluding BCC recipients + is the policy writer's responsibility. Prefer per-recipient signing + (or separate copies) for any mail that may carry blind copies. See + [Signing hook: shared vs. per-recipient](/momentum/4/dkim2/sign#signing-hook-shared-vs-per-recipient). + +* **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. + +* **§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. diff --git a/content/momentum/4/dkim2/ar-clauses.md b/content/momentum/4/dkim2/ar-clauses.md new file mode 100644 index 00000000..c6690d31 --- /dev/null +++ b/content/momentum/4/dkim2/ar-clauses.md @@ -0,0 +1,135 @@ +--- +lastUpdated: "06/09/2026" +title: "DKIM2 Authentication-Results — ar_clauses()" +description: "Reference for the msys.validate.dkim2.ar_clauses() Lua API: usage examples and Authentication-Results output format." +--- + +## Authentication-Results Output + +When `authservid` is supplied to `verify()`, Momentum automatically builds +and prepends a fresh `Authentication-Results:` header (RFC 8601 §5 — an MTA +MUST NOT add to an existing AR header): + +```lua +msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta-1.example.com", +}) +``` + +For full control — or to merge DKIM2 results with other authentication methods +(SPF, DKIM1, ARC) into a single combined header — use +`msys.validate.dkim2.ar_clauses(result)`. + +`ar_clauses()` returns an array of DKIM2 `Authentication-Results:` clause +strings for the given verify result. It returns `nil` when `result` is `nil`, or `result.signatures` is absent or +empty. It also returns `nil` when all per-signature entries are non-actionable +(`status="chain_verified"` or `status="none"`) and `result.overall` is `"none"`. + +Each entry is a complete clause string (e.g. +`"dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 ..."`). +The array contains one entry per actionable signature — signatures with +`status="chain_verified"` (lower-hop: public-key verification not +performed, so no `dkim2=pass` claim can be asserted for them) and +`status="none"` (unsupported algorithm, §3.4 — no `dkim2=none` token exists) +are excluded. Extra overall clauses for chain failures or policy downgrades +are appended when applicable. + +### Usage examples + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + -- Pass an empty options table (no authservid) so verify() does not + -- auto-prepend a DKIM2-only AR header; build the combined header below. + local result, err = msys.validate.dkim2.verify(msg, vctx, {}) + if not result then + msys.log(msys.core.LOG_WARNING, "dkim2 verify error: " .. (err or "unknown")) + vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") + return msys.core.VALIDATE_DONE + end + + local dkim2_clauses = msys.validate.dkim2.ar_clauses(result) or {} + local spf_clause = build_spf_clause() -- caller-supplied; returns nil when absent + local all_clauses = {} + if spf_clause then all_clauses[#all_clauses + 1] = spf_clause end + for _, c in ipairs(dkim2_clauses) do all_clauses[#all_clauses + 1] = c end + if #all_clauses > 0 then + msg:header("Authentication-Results", + "mta-1.example.com; " .. table.concat(all_clauses, "; "), + "prepend") + end + + -- NOTE: This example only shows AR header construction. + -- You must still enforce SMTP policy based on result.overall — + -- see the full skeleton in /momentum/4/dkim2/verify for the + -- set_code / VALIDATE_DONE pattern for fail, permerror, and temperror. + return msys.core.VALIDATE_CONT +end + +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: + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Transient DNS failure (`key_unavailable` → `dkim2=temperror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=temperror reason="public key could not be fetched" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Permanent error — key does not exist in DNS (`no_key` → `dkim2=permerror`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=permerror reason="public key does not exist" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +Failure with reason (simplified string per §11.1 — ordinals come from `header.i=` / `header.m=`): + +``` +Authentication-Results: mta-1.example.com; + dkim2=fail reason="body hash mismatch" header.d=example.com header.s=sel-1:rsa-sha256 header.i=1 header.m=1 + header.mf=sender@example.com header.rt=rcpt@a.com +``` + +When the overall verdict is worse than the per-sig result — chain failure or +policy downgrade after a crypto pass — an extra overall clause is appended: + +Chain-broken example (2-hop message: crypto passed but recipe-chain check failed): + +``` +Authentication-Results: mta-1.example.com; + dkim2=pass header.d=example.com header.s=sel-2:rsa-sha256 header.i=2 header.m=2 + header.mf=bounce@forwarder.example.net header.rt=rcpt@a.com; + dkim2=permerror reason="chain of custody broken" +``` + +Policy-downgrade example (`d=` does not match the `mf=` domain — §11.4 +enumerates this as a PERMERROR output state): + +``` +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" +``` diff --git a/content/momentum/4/dkim2/debug.md b/content/momentum/4/dkim2/debug.md new file mode 100644 index 00000000..91256a11 --- /dev/null +++ b/content/momentum/4/dkim2/debug.md @@ -0,0 +1,23 @@ +--- +lastUpdated: "06/09/2026" +title: "DKIM2 Debugging Reference" +description: "Per-signature reason codes, recipe_chain detail strings, and ec_message context fields for DKIM2 sign and verify operations." +--- + +## Debugging + +Setting `debug_level` on the `dkim2` configuration stanza routes sign and +verify activity to `paniclog`: + +``` +dkim2 { + debug_level = "info" +} +``` + +| Level | What surfaces | +|---|---| +| `error` | Failures and resolver problems only. **Default.** | +| `warning` | Adds DNS issues and SHOULD-violation warnings. | +| `info` | Adds one DNS resolution line per verified signature plus verification failures with their cause (`bh_mismatch` with expected vs. actual hash; `sig_invalid` with selector, algorithm, signed-input length, and OpenSSL detail). | +| `debug` | Adds raw TXT-record bytes from the resolver, a per-crypto-check trace line, and the raw signed-input bytes on failure. Too noisy for steady-state production; useful when chasing a specific sign/verify mismatch. | diff --git a/content/momentum/4/dkim2/sign.md b/content/momentum/4/dkim2/sign.md new file mode 100644 index 00000000..b5490836 --- /dev/null +++ b/content/momentum/4/dkim2/sign.md @@ -0,0 +1,285 @@ +--- +lastUpdated: "06/25/2026" +title: "DKIM2 Signing — sign()" +description: "Reference for the msys.validate.dkim2.sign() Lua API: hook selection, sign options, forwarder and modifier signing." +--- + +## DKIM2 Signing + +DKIM2 signing in Momentum is driven from Lua policy via +`msys.validate.dkim2.sign`; enabling DKIM2 signing means calling `sign()` from +your validation hook. + +### Signing hook: shared vs. per-recipient + +`sign()` can be called from either `validate_data_spool` or +`validate_data_spool_each_rcpt`. The choice affects how `rt=` is populated +and whether BCC addresses are exposed. Per **§8.6 the signer MUST NOT reveal +`bcc:` recipients to any other recipient** (RFC 5322 §3.6.3); since `rt=` is +carried in the `DKIM2-Signature:` header that every recipient of a given copy +can read, the `rt=` of a copy delivered to one recipient must not list another +recipient who was blind-copied. + +| | `validate_data_spool` | `validate_data_spool_each_rcpt` | +|---|---|---| +| **Fires** | Once on the shared parent message | Once per recipient (cowref) | +| **`rt=` auto-populate** | Primary recipient only (`msg:rcptto()`) — extra recipients are inaccessible in this hook | Single cowref recipient | +| **Multi-recipient rt=** | Must pass explicit `rcptto = {r1, r2, ...}` — collect the full list in an earlier hook (e.g. `validate_rcptto`) | Each cowref signs for its own single address automatically | +| **BCC privacy (§8.6)** | ⚠️ Operator MUST exclude BCC from the explicit `rcptto` list — a shared signature whose `rt=` lists a BCC address reveals it to all recipients | ✅ Satisfied by construction — each cowref is private to its recipient; `rt=` is bound to their address only | +| **Complexity** | Requires explicit recipient collection for multi-recipient | One `sign()` call per cowref; correct by default | + +Use `validate_data_spool_each_rcpt` for most deployments — it handles +per-recipient signing automatically and is BCC-safe by construction. Use +`validate_data_spool` only when you need a single signature covering all +recipients and are willing to manage the recipient list and §8.6 BCC exclusion +yourself: Momentum cannot detect which envelope recipients are blind copies +(there is no envelope-level BCC marker), so when you pass an explicit +multi-recipient `rcptto` the §8.6 MUST NOT is **your** obligation — exclude any +BCC address, or fall back to per-recipient signing / separate copies. + +Passing an explicit `rcptto` option overrides the auto-populated primary recipient. +Accepts a string (single address) or a Lua table of bare addresses. + +### Minimum signer + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local ok, err = msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "dkim2048", + keyfile = "/opt/msys/ecelerity/etc/conf/dkim/example.com/dkim2048.key", + }) + if not ok then + -- err is a static-literal string describing the failure. See + -- /momentum/4/dkim2/debug for the full set. + msys.log(msys.core.LOG_WARNING, "dkim2 sign failed: " .. (err or "unknown")) + end + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_signer", mod) +``` + +`mf=` defaults to the message's envelope MAIL FROM and `rt=` defaults to its +RCPT TO; both can be overridden in the options table for forwarder +scenarios (see *Forwarder / modifier signing* below). + +### Sign options + +`sign()` accepts either a single options table or a multi-algorithm +form using an explicit `sig_sets` key (§8.9 algorithm agility): + +```lua +-- Single sig-set (most common): +msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + selector = "sel-2048", + keyfile = "/etc/dkim2/rsa.key", +}) + +-- Multi-algorithm (RSA + Ed25519 in one DKIM2-Signature): +msys.validate.dkim2.sign(msg, vctx, { + domain = "example.com", + sig_sets = { + { selector = "sel-rsa", keyfile = "/etc/dkim2/rsa.key" }, + { selector = "sel-ed25519", keyfile = "/etc/dkim2/ed25519.key", + algorithm = "ed25519-sha256" }, + }, +}) +``` + +When `sig_sets` is present, all entries sign the same canonical +signed-input and are combined into a single `s=sel1:alg1:sig1,sel2:alg2:sig2` +value on one `DKIM2-Signature` header. Per §11.6 the verifier checks +every sig-set; overall passes if any one validates, so a receiver that +only supports RSA will still verify cleanly. On the verifier side, any +sig-set that fails alongside a passing one is reported as a DWARNING in +paniclog (partial-sig-failure condition, §11.6). + +**If any sig-set fails**, the entire `sign()` call returns +`(nil, error_string)` — no partial signature is produced. + +The `selector`, `keyfile`, and `algorithm` fields belong to each sig-set +entry; all other options below are header-level and go at the top level +of the options table. + +| Option | Required? | Meaning | +|---|---|---| +| `domain` | yes | `d=` tag — the signing domain. | +| `selector` | yes (single) | Selector component of `s=::`. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keyfile` | yes (single) | Path to the PEM-encoded private key on disk. Mutually exclusive with `keybuf`; one of the two is required. When `sig_sets` is used, set per entry inside `sig_sets` instead. | +| `keybuf` | yes (single) | PEM-encoded private key as a string in memory. Alternative to `keyfile` for cases where the key is held in a secrets manager or generated at runtime. 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. | +| `rcptto` | no | **Normally omitted** — Momentum auto-populates from the active envelope recipient. One production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly to cover all recipients in a single `rt=` — but you MUST exclude any `bcc:` recipient (§8.6), since the multi-recipient `rt=` is visible to every recipient of that copy. In `validate_data_spool_each_rcpt` (recommended), each cowref auto-populates correctly and is BCC-safe. Accepts a string or a Lua table of bare addresses. | +| `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 (`-03` §8.3). Caller-supplied ASCII string, ≤ 64 chars, no `;`. Typically a DSN-correlation key or replay-cache key. | +| `nonce_random` | no | If `true` AND `nonce` is not set, the signer fills `n=` with a 22-character base64 random nonce. 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=` (`-03` §8.10): `"exploded"`, `"donotexplode"`, `"donotmodify"`, `"feedback"`, `"feedhere"`. `"feedhere"` (spec-03 §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 `-03` §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. | + +`sign()` return values: + +- `(true, header_value_string)` — success; the `DKIM2-Signature` value was added. +- `(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. + +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`. + +### Forwarder and modifier signing + +The chain-of-custody link between adjacent signatures is a **relaxed, +domain-only** match (§9.4): a hop's `mf=` links to the previous `rt=` when its +*domain* relaxed-matches a prior `rt=` domain (local part ignored, labels +stripped from the left of the MAIL FROM domain). A forwarder breaks the chain +only when its outgoing MAIL FROM is at a **domain** that matches no prior `rt=` +domain. A same-domain re-send — even with a different local part, as a +single-domain mailing list does (`list@example.com` received, +`bounce@example.com` sent) — is **not** a break and needs **no** bridge. + +A genuine **domain change** does break the chain, and §9.2 requires a bridging +`DKIM2-Signature` before the primary. Momentum automates the **fabricated** +bridge: supply `bridge_mailfrom` with the address this hop received at, and +`sign()` prepends a bridge whose `mf=` is that address and whose `rt=` is the +new outgoing MAIL FROM. The fabricated bridge signs both the bridge and the +primary with the call's `domain`, so it fits domain *shifts within one +parent* (sibling subdomains); for a cross-organisation domain change prefer the +`nd=` bridge below (it signs the bridge with the prior-`rt=` domain's own key). + +```lua +-- Subdomain-shift forward (received at in.relay.example, sent from +-- out.relay.example — sibling subdomains of relay.example): +-- i=1 (originator): mf=alice@sender.com rt=fwd@in.relay.example +-- i=2 (auto-bridge): mf=fwd@in.relay.example rt=bounce@out.relay.example +-- i=3 (primary): mf=bounce@out.relay.example rt=subscriber@recipient.com +local ok, val, info = msys.validate.dkim2.sign(msg, vctx, { + domain = "relay.example", -- relaxed-matches both in. and out. subdomains + selector = "relay-2026", + keyfile = "/etc/dkim2/relay.example/relay-2026.key", + mailfrom = "bounce@out.relay.example", + rcptto = "subscriber@recipient.com", + bridge_mailfrom = "fwd@in.relay.example", -- the address this hop received at + -- on_chain_break defaults to "bridge" since bridge_mailfrom is provided +}) +if not ok then + -- sign() failed (key error, bridge error, etc.) + vctx:set_code(550, "5.7.1 DKIM2 signing failed: " .. tostring(val)) + return msys.core.VALIDATE_DONE +end +-- info.chain_break=true, info.bridged=true when bridge was auto-generated +``` + +When the forwarding address is unambiguous (prior `rt=` has a single entry), +`bridge_mailfrom` can be omitted — Momentum infers it automatically. When the +prior `rt=` has multiple entries, `bridge_mailfrom` is required to identify +which entry this hop received at. + +#### `nd=` "imaginary hop" bridge (spec-03 §8.7 / §9.3) + +Spec-03 added an alternative to the fabricated bridge above: the `nd=` +("next domain") tag. Instead of inventing `mf=`/`rt=` values for the imaginary +transfer, the bridge signature carries `nd=` and **omits** +`mf=`/`rt=` entirely; a verifier checks that `nd=` exactly matches the `d=` of +the next signature in the chain. Per §9.3 the bridge MUST be signed by a domain +that actually received the message — i.e. a domain present in the prior `rt=` — +so the bridge uses a **different signing identity** from the forwarder's own. + +Momentum both **emits** `nd=` (`on_chain_break="nd"`, or the low-level +`next_domain` passthrough) and **verifies** it. To auto-generate an `nd=` +bridge on chain break: + +```lua +-- Cross-domain mailing list: received at list@mailing-list.com, re-sent from a +-- different outbound domain (relay.example) — a real domain change, bridged via +-- nd= signed by the receiving domain (mailing-list.com, present in the prior rt=): +-- i=1 (originator): d=sender.com mf=alice@sender.com rt=list@mailing-list.com +-- i=2 (nd= bridge): d=mailing-list.com nd=relay.example (no mf=/rt=) +-- i=3 (primary): d=relay.example mf=bounce@relay.example rt=subscriber@recipient.com +local ok, val, info = msys.validate.dkim2.sign(msg, vctx, { + domain = "relay.example", + selector = "relay-2026", + keyfile = "/etc/dkim2/relay.example/relay-2026.key", + mailfrom = "bounce@relay.example", + rcptto = "subscriber@recipient.com", + on_chain_break = "nd", + -- bridge identity: a domain in the prior rt= (the list received at + -- list@mailing-list.com, so the bridge is signed by mailing-list.com): + bridge_domain = "mailing-list.com", + bridge_selector = "list-2026", + bridge_keyfile = "/etc/dkim2/mailing-list.com/list-2026.key", +}) +-- 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: + +| `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}` | +| `"skip"` (default without `bridge_mailfrom`) | Skip signing | `{chain_break=true, bridged=false}` | +| `"warn"` | Sign without bridge | `{chain_break=true, bridged=false}` | +| `"error"` | Return `(nil, errmsg)` | — | + +The third return value gives policy full control: inspect `info.chain_break` +and `info.bridged` to decide whether to accept, reject, or log — regardless +of which `on_chain_break` value was used. + +A forwarder that does not change the MAIL FROM (pure relay) signs with +the envelope values directly — no bridge needed since the chain is intact: + +A modifier that **rewrites** the message (Subject change, body footer, +attachment strip, etc.) additionally attaches a `recipe`: + +```lua +-- Forwarder rewrote Subject; recipe restores the original on +-- reverse-apply. +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"]}]}}]], +}) +if not ok then + msys.log(msys.core.LOG_WARNING, "dkim2 modifier sign failed: " .. (err or "unknown")) +end +``` + +The recipe schema is documented in `-03` §5. Recipes are mandatory only +when the hop modifies content; non-modifying hops (pure-forwarding without +edits) omit `recipe` entirely. + +When `on_chain_break="bridge"` is used and the message was modified, +supply `recipe` on the outer `sign()` call — Momentum forwards it +automatically to the auto-generated bridge signature. The bridge needs +the recipe to document the content change in its `Message-Instance` +header so the §10.2 chain walk can reconstruct the original state. + +**Note**: auto-bridge signatures do not inherit `flags`. Use `bridge_flags` +to set flags on the bridge signature independently of the primary. For +example, `bridge_flags={"donotmodify"}` marks the bridge hop as +non-modifiable while leaving the primary signature's `flags` unchanged. + +`nonce_random` is inherited by the bridge so that when it is set, each +signature gets its own fresh nonce. An explicit `nonce=` value is NOT +inherited — the bridge's `n=` tag is absent (unless `nonce_random` was +set) to avoid two signatures sharing the same nonce value, which would +defeat anti-replay protection. diff --git a/content/momentum/4/dkim2/verify.md b/content/momentum/4/dkim2/verify.md new file mode 100644 index 00000000..07ca4fd1 --- /dev/null +++ b/content/momentum/4/dkim2/verify.md @@ -0,0 +1,355 @@ +--- +lastUpdated: "06/25/2026" +title: "DKIM2 Verifying — verify()" +description: "Reference for the msys.validate.dkim2.verify() Lua API: verify options, result table, and SMTP response codes." +--- + +## DKIM2 Verifying + +DKIM2 verification is driven from Lua via `msys.validate.dkim2.verify`. +`verify()` can be called from either `validate_data_spool` or +`validate_data_spool_each_rcpt`. The choice affects how the §11.4 `rt=` +binding check is performed: + +| | `validate_data_spool` | `validate_data_spool_each_rcpt` | +|---|---|---| +| **Fires** | Once on shared parent message | Once per recipient (cowref) | +| **`rt=` auto-check** | First accessible recipient only (`msg:rcptto()`) — **all other recipients bypass the §11.4 check** unless explicitly listed in `rcptto` | Single cowref recipient checked; §11.4 satisfied per-delivery | +| **Multi-recipient §11.4** | ⚠️ Must pass explicit `rcptto = {r1, r2, ...}` — omitting any recipient silently skips its binding check | ✅ Every recipient verified automatically in its own cowref | +| **BCC support** | ⚠️ Operator must exclude BCC from explicit `rcptto` — omitting a BCC address skips its §11.4 binding check | ✅ Each cowref checked independently; no special handling needed | +| **Complexity** | Requires explicit recipient collection for complete §11.4 compliance | One `verify()` call per cowref; correct by default | + +Use `validate_data_spool_each_rcpt` for most deployments — it satisfies §11.4 +for every recipient automatically without additional setup. +Typical inbound policy: + +```lua +require("msys.core") +require("msys.validate.dkim2") + +local mod = {} + +function mod:validate_data_spool_each_rcpt(msg, ac, vctx) + local result, err = msys.validate.dkim2.verify(msg, vctx, { + authservid = "mta-1.example.com", + }) + if not result then + -- Internal error (alloc failure, crypto init error, etc.) — err carries + -- the reason string. Defer rather than silently accepting. + msys.log(msys.core.LOG_WARNING, "DKIM2 verify failed internally: " .. (err or "unknown")) + vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") + return msys.core.VALIDATE_DONE + 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) + -- "fail" verified but wrong: hash/sig mismatch or policy + -- violation (donotmodify/donotexplode, etc.) + -- "permerror" could not verify: key missing/invalid/revoked, + -- signature syntax error, chain integrity failure + -- (overall_reason="chain_broken"), or d=/mf= domain + -- mismatch (§11.4 PERMERROR, overall_reason="d_mf_mismatch") + -- "temperror" resolver-side transient failure (SERVFAIL, timeout) + -- "none" no DKIM2-Signature headers, or all use unsupported + -- algorithms (§3.4 — ignored rather than failed) + + if result.overall == "temperror" then + vctx:set_code(451, "4.7.5 DKIM2 key lookup failed; please retry") + return msys.core.VALIDATE_DONE + end + + if result.overall == "fail" or result.overall == "permerror" then + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + return msys.core.VALIDATE_DONE + end + + return msys.core.VALIDATE_CONT +end + +msys.registerModule("my_dkim2_verifier", mod) +``` + +See [Authentication-Results Output](/momentum/4/dkim2/ar-clauses) for the AR +header format, `ar_clauses()` API, and examples of building combined headers. + +### Verify options + +| Option | Meaning | +|---|---| +| `pubkey_pem` | A PEM-encoded public key. When set, the same key is used for every signature on the message (typically used in tests and policies that already have the key). When absent, each signature's `(d, s)` pair is resolved from DNS at `._domainkey.`. | +| `mailfrom` | **Normally omitted** — Momentum reads the live envelope MAIL FROM automatically. Production exception: null-sender DSN/bounce messages where `mailfrom=""` is required since the envelope API returns nil for `MAIL FROM:<>`. Otherwise test/simulation use only. | +| `rcptto` | **Normally omitted** — Momentum auto-populates from the active envelope recipient. Production exception: in `validate_data_spool` (shared hook), pass the full recipient list explicitly for complete §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 `-03` §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. | +| `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`. | + +`verify()` returns `(result, err)`: +- **Normal execution** (including messages with no DKIM2 signatures): `result` is + the result table below, `err` is `nil`. A message with no signatures returns + `result.overall = "none"` — `result` is never `nil` in this case. +- **Internal failure** (alloc or crypto init error): `result` is `nil`, `err` is a + non-nil string describing the cause. + +Always capture both return values so internal failures can be logged and acted on +separately from signature verdicts. + +### Result table + +``` +result = { + overall = "pass" -- all verifiable signatures passed + | "fail" -- verified but wrong: hash/sig mismatch, or + | -- policy violation (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) + | "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 + 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. + }, + ... + }, + highest_i = , + highest_mf = "", -- §12 DSN target; + -- "<>" for the null sender (a DSN MUST NOT be sent); + -- absent when no signature carried an mf= +} +``` + +**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 +`overall_reason="chain_broken"`. `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. + +**`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="chain_broken"`, +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. + +| 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 DER structure is not a valid public key. | +| `key_k_unknown` | The DNS record's `k=` tag names an algorithm Momentum doesn't support. | +| `key_v_mismatch` | The DNS TXT record's `v=` tag does not match the expected value. Malformed or wrong-version key record. Maps to `dkim2=permerror`. | +| `key_p_missing` | The DNS TXT record has no `p=` tag (distinct from empty `p=` which is revocation). Malformed key record. Maps to `dkim2=permerror`. | +| `key_size_invalid` | The RSA public key is smaller than the 1024-bit minimum required by §3.2. Maps to `dkim2=permerror`. | +| `key_e_invalid` | The RSA public key exponent is not 65537 as required by §3.2. Maps to `dkim2=permerror`. | +| `sig_parse_failed` | The signature value inside the `s=` tag could not be parsed or stripped for canonical-input construction. Indicates a malformed signature from the signer. | +| `mi_hash_missing` | The body hash could not be retrieved from the `Message-Instance:` `h=` tag: either no MI with a matching sequence number (`m=`) was present, or the MI's `h=` tag was malformed or lacked a hash entry for the algorithm named in its own `h=` prefix. | +| `verify_internal` | An internal error occurred during signature verification (memory allocation failure or cryptographic library error). The signature could not be evaluated. Maps to `dkim2=permerror` in AR output. | +| `unsupported_algorithm` | Every sig-set in `s=` uses an algorithm Momentum does not implement. Per §3.4 these are ignored rather than failed; paired with `status="none"`. | + +**Authentication-Results mapping (§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_revoked`, `key_b64_decode`, `key_der_parse`, `key_v_mismatch`, `key_p_missing`, `key_size_invalid`, `key_e_invalid`, `missing_required_tags`, `parse_error`, `sig_parse_failed`, `mi_hash_missing`, `signature_expired`, `verify_internal` + +`reason=` is included in all failure clauses (`dkim2=fail`, `dkim2=permerror`, `dkim2=temperror`) and absent from pass clauses (`dkim2=pass`). + +### recipe_chain detail strings (paniclog only) + +When the recipe-chain check fails, the overall verdict is `permerror` +with `overall_reason="chain_broken"`, and the underlying cause is logged at `error` level in +paniclog as `recipe-chain check failed: recipe_chain: `. The +chain-check failure does NOT appear in the per-signature result struct — +it's a cross-hop verdict, not a per-signature outcome — so paniclog is the +only place this detail surfaces. + +| Detail | Meaning | +|---|---| +| `no_mi_1` | The message had ≥ 2 signatures but no `Message-Instance` with `m=1`. The chain has no anchor. | +| `parse_h` | `Message-Instance` `h=` tag didn't parse as `::`. The MI is malformed. | +| `recipe_decode` | A hop's `r=` value didn't base64-decode. Wire-format corruption or a broken signer. | +| `recipe_invalid` | A hop's recipe failed schema validation at verify time. Should not occur with conforming signers (sign-time validation prevents emission of bad recipes); appearing here means the signer is broken. | +| `irreversible` | A hop's recipe declared `"b": null` (an unrecoverable body). The verifier can't reverse-reconstruct past this hop. Local policy may accept irreversibility from trusted forwarders. | +| `apply_failed` | A recipe references a header or body line that doesn't exist in the current message. The recipe is inconsistent with the modification it claims to describe — likely a downstream hop modified the message AGAIN without recording it. | +| `no_recipe` | One or more non-first `Message-Instance` headers had no `r=` tag (treated as no-modification hops), yet the final reconstructed hashes didn't match `MI[1]`. A hop likely modified the message without recording a recipe. The signer should 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. | + +### ec_message context fields + +`verify()` writes the following context variables so downstream hooks can +read the outcome without re-verifying or parsing `Authentication-Results:`: + +| Context key | Type | Value | +|---|---|---| +| `dkim2_overall` | string | Verdict: `"pass"`, `"fail"`, `"permerror"`, `"temperror"`, or `"none"`. See the [SMTP response codes](/momentum/4/dkim2/verify#smtp-response-codes-111-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=`. | + +These keys are not set until `verify()` runs. + +### SMTP response codes (§11.1 guidance) + +Momentum leaves the decision of whether to accept, reject, or defer a +message — and which SMTP reply code to use — entirely to the operator's +Lua hook. The `overall` field of the verify result maps to the following +SMTP behaviour as required by §11.1 of the DKIM2 spec: + +| `overall` | Meaning | §11.1 guidance | Suggested action | +|---|---|---|---| +| `pass` | All verifiable signatures passed | — | Accept | +| `none` | No DKIM2 signatures present, or all use unsupported algorithms (§3.4) | — | Local policy | +| `fail` | Verified but wrong: hash/sig mismatch or policy violation (`donotmodify`/`donotexplode`, etc.) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject or accept per policy | +| `permerror` | Could not verify: key missing/revoked/invalid, syntax error, chain integrity failure (`overall_reason="chain_broken"`), or `d=`/`mf=` domain mismatch (`overall_reason="d_mf_mismatch"`) (§11.1 / §11.4 PERMERROR) | SHOULD 550/5.7.x; **MUST NOT 4xx** | Reject (permanent) | +| `temperror` | Transient key-fetch failure (DNS timeout / SERVFAIL) | MAY 451/4.7.5 | Defer (temporary) | + +**Key rules from §11.1**: +- `fail` and `permerror` **MUST NOT** use a 4xx reply code. +- Only `temperror` warrants a temporary (4xx) failure code. + +Example hook skeleton: + +```lua +local result, err = msys.validate.dkim2.verify(msg, vctx, { ... }) +if not result then + -- internal error (e.g. OOM); defer rather than silently accepting + msys.log(msys.core.LOG_WARNING, "dkim2 verify error: " .. (err or "unknown")) + vctx:set_code(451, "4.7.5 DKIM2 verification unavailable; please retry") + return msys.core.VALIDATE_DONE +end +local overall = result.overall + +if overall == "permerror" or overall == "fail" then + -- §11.1 SHOULD 550/5.7.x for permanent failures. + -- Note: "permerror" MUST NOT use 4xx. + vctx:set_code(550, "5.7.1 DKIM2 verification failed") + return msys.core.VALIDATE_DONE + +elseif overall == "temperror" then + -- §11.1 MAY 451/4.7.5 for transient key-fetch failures + vctx:set_code(451, "4.7.5 DKIM2 key server temporarily unavailable") + return msys.core.VALIDATE_DONE + +else + -- pass / none: local policy + return msys.core.VALIDATE_CONT +end +``` + +> **Note**: Whether to reject on `fail` or `none` is a local policy +> decision. The spec only mandates the reply-code *type* (4xx vs 5xx) +> for the cases shown above. diff --git a/content/momentum/navigation.yml b/content/momentum/navigation.yml index bdf6aabc..460b1b4b 100644 --- a/content/momentum/navigation.yml +++ b/content/momentum/navigation.yml @@ -291,6 +291,13 @@ title: DKIM Signing - link: /momentum/4/using-dkim-validation title: DKIM Validation + - link: /momentum/4/dkim2 + title: Using DKIM2 (DomainKeys Identified Mail v2) Signatures + items: + - link: '/momentum/4/dkim2#dkim2_signing' + title: DKIM2 Signing + - link: '/momentum/4/dkim2#dkim2_verifying' + title: DKIM2 Verifying - link: /momentum/4/multi-event-loops title: Configuring Multiple Event Loops - link: /momentum/4/outbound-mail