Skip to content
Open

DKIM2 #848

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
b488189
DKIM2
juliebin Jun 1, 2026
3b7f03b
review comments from cursor
juliebin Jun 1, 2026
fced77f
for more review comments
juliebin Jun 1, 2026
ef30b8a
dkim2: minor update
juliebin Jun 1, 2026
a8bfb95
dkim2: update limitation section
juliebin Jun 1, 2026
3473558
some refinement
juliebin Jun 1, 2026
f610093
rename dkim2.md
juliebin Jun 2, 2026
997c5a1
update to support multi-algorithm signing via sig_sets (§7.8 algorith…
juliebin Jun 2, 2026
7bbc81a
update
juliebin Jun 5, 2026
86efa82
update with new sign/verify options
juliebin Jun 9, 2026
c33d530
more update
juliebin Jun 10, 2026
9e48da2
update verify error code
juliebin Jun 10, 2026
9f7bf8a
rt returns a list of recipients
juliebin Jun 10, 2026
42e8bcf
add warning on §12 Bare CR/LF normalization
juliebin Jun 10, 2026
68f9761
update according to review comments
juliebin Jun 10, 2026
6069890
update signing hook guidance and rt= options for multi-recipient default
juliebin Jun 10, 2026
b425b88
clarify that verify() can be called from both validate_data_spool_* h…
juliebin Jun 10, 2026
3d98a7f
update
juliebin Jun 10, 2026
495c64c
remove interoperability part
juliebin Jun 10, 2026
3dd7a84
add keybuf support for sign() as alternative to keyfile
juliebin Jun 10, 2026
ded3dc8
clarify forwarder auto-detection limitation
juliebin Jun 10, 2026
7d95eb2
update BCC privacy
juliebin Jun 11, 2026
caee2dc
more update
juliebin Jun 11, 2026
2e8d7e4
key_service_mismatch
juliebin Jun 11, 2026
7e51464
consolidate signing hook comparison into table
juliebin Jun 11, 2026
7d5f534
operator-facing doc revisions, known limitations, AR output and conte…
juliebin Jun 11, 2026
7fd3bd4
clarify limitations, add recipe workaround, operator-facing rewrites
juliebin Jun 11, 2026
b7236d6
fix links
juliebin Jun 11, 2026
74e45d5
fix broken links
juliebin Jun 11, 2026
a892f7f
fix broken links
juliebin Jun 11, 2026
7053703
fix broken links
juliebin Jun 11, 2026
726b144
fix broken links
juliebin Jun 11, 2026
325d854
some fixes
juliebin Jun 11, 2026
3d0f95f
update verify() called from validate_data_spool hook
juliebin Jun 11, 2026
315081d
note auto-exploded heuristic limitation for single-subscriber lists
juliebin Jun 11, 2026
aea66b8
nit
juliebin Jun 11, 2026
c5eee05
update overall_reason
juliebin Jun 12, 2026
9c9fa52
rcptto option, result fields, AR examples and reason code mapping
juliebin Jun 12, 2026
64f0728
update header.s part in AR
juliebin Jun 12, 2026
beef680
add ar_clauses() API, fix doc gaps, remove skip_ar_header_update dead…
juliebin Jun 12, 2026
bb1772b
update multi-recipient message handling
juliebin Jun 12, 2026
d85fcbb
update
juliebin Jun 12, 2026
89b841d
minor update
juliebin Jun 12, 2026
f5f8d57
AR session restrucure
juliebin Jun 12, 2026
8405d6d
AR update
juliebin Jun 12, 2026
4c81614
update
juliebin Jun 13, 2026
57965e3
update AR status code
juliebin Jun 13, 2026
bc8a823
update with §8.2 auto-bridge options and examples
juliebin Jun 16, 2026
8eb2dc6
dkim2: fix §10.5/§10.6 chain_verified doc accuracy
juliebin Jun 17, 2026
8923165
dkim2: soften future-release wording in known limitation
juliebin Jun 17, 2026
411c25b
update along with the code change
juliebin Jun 17, 2026
7faadc5
split into parent overview + per-API child pages
juliebin Jun 17, 2026
0be177c
allow_missing_recipe sign option
juliebin Jun 18, 2026
018a626
update code examples, API accuracy, operator guidance
juliebin Jun 18, 2026
86724ba
update on sig-set failures, and behavior on key testing mode
juliebin Jun 20, 2026
129960a
minor spec clarification on signature_expired
juliebin Jun 20, 2026
6dd6dc9
Merge branch 'main' into 1259-dkim2-prototyping
dkoerichbird Jun 25, 2026
a883b27
Addressing some review feedbacks (SparkPost/Momentum#1259)
dkoerichbird Jun 25, 2026
9b7e36e
Missed a small change (SparkPost/Momentum#1259)
dkoerichbird Jun 25, 2026
4b474b0
Doc: adding `nd=` (from -O3) documentation (SparkPost/Momentum#1259)
dkoerichbird Jun 26, 2026
34fe2ba
doc: Updating spec references (SparkPost/Momentum#1259)
dkoerichbird Jun 26, 2026
1ca0c08
doc: Changing to permerror in d<->mf mismatch (SparkPost/Momentum#1259)
dkoerichbird Jun 26, 2026
09b6a5d
docs: Changing verify documentation from latest -O3 updates (SparkPos…
dkoerichbird Jun 29, 2026
dc4fe63
fix: Doug`s review (SparkPost/Momentum#1259)
dkoerichbird Jun 29, 2026
7a54f6d
doc: Sweeping -O2 to -O3 references (SparkPost/Momentum#1259)
dkoerichbird Jun 29, 2026
a1599a1
doc: Sweeping -O2 to -O3 missed references (SparkPost/Momentum#1259)
dkoerichbird Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 344 additions & 0 deletions content/momentum/4/dkim2.md
Original file line number Diff line number Diff line change
@@ -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:<hh>:<bh>`) referenced via `m=` |
| Envelope binding | None | `mf=<MAIL FROM>` / `rt=<RCPT TO>`, 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 `<selector>._domainkey.<domain>` | 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
`<selector>._domainkey.<domain>` 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
Comment thread
cursor[bot] marked this conversation as resolved.
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
Comment thread
cursor[bot] marked this conversation as resolved.
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 `<selector>._domainkey.<domain>` as a TXT record with the standard
RFC 6376 §3.6.1 format (`v=DKIM1; k=rsa; p=<base64-SPKI>`).

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.

Comment thread
dkoerichbird marked this conversation as resolved.
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=<x> body hash <value> 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.
Loading
Loading