Skip to content

feat(dkim2): add experimental DKIM2 signing and verification - #172

Merged
andris9 merged 8 commits into
masterfrom
feat/dkim2
Oct 10, 2026
Merged

andris9 merged 8 commits into
masterfrom
feat/dkim2

Conversation

@andris9

@andris9 andris9 commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Adds experimental DKIM2 support, built against these Internet-Drafts (DKIM2 is not an RFC yet, the versions are stated in the README, cli.md and docs/dkim2.md):

What's included

  • Signing: dkim2Sign() / Dkim2SignStream.

    • Supports RSA and Ed25519 (several keys per signature), sha256/sha512, mf=/rt= or nd=, flags and nonce.
    • A forwarder that changes the message must supply a Recipe. It is checked against the previous instance before signing.
    • The signer refuses to break the chain of custody.
  • Verification: dkim2Verify() recreates every instance through the Recipes and checks:

    • every hash and signature
    • the chain of custody between hops, and against the SMTP envelope
    • timestamps (expired after 14 days; more than 5 minutes in the future rejected, maxFutureTime)
    • donotmodify / donotexplode
    • replay, via a callback

    Results are PASS/FAIL/PERMERROR/TEMPERROR with the human-readable strings from §11. When the caller doesn't pass the envelope, status.warnings reports which part was not checked.

  • Hashing: dkim2Hash() returns a message's Message-Instance hashes.

  • Keys: DKIM1 key records read with the DKIM2 rules: one record per selector, retired tags ignored, k= must match the algorithm, RSA 1024–8192 bits with exponent 65537.

  • authenticate(): opt-in dkim2 option (plus rcptTo, dkim2Options) that adds a dkim2= A-R entry. DKIM2 is not fed to DMARC, which is still an open WG question (BCP §7.2).

  • CLI:

    • mailauth dkim2-sign, mailauth dkim2-verify and mailauth dkim2-hash
    • report --dkim2 --rcpt-to
    • The --dns-cache resolver is now shared by all commands.
  • DSN generation and propagation (§12) is left to the MTA.

Interoperability

Checked against three independent implementations. The vectors and messages are part of the test suite, with their licenses. Details are in docs/dkim2.md.

  • croessner/dkim2 (Go, spec-06, the same drafts):
    • All its public verification vectors agree, apart from one choice outside the protocol: a message with no DKIM2 headers is dkim2=none.
    • Its canonicalization, Recipe, custody, DNS and crypto vectors give the same results.
  • turscar/dkim2 (spec-02):
    • All 42 vectors give the same canonical form and Message-Instance hashes.
    • The results match, except "only unknown signature algorithms", which the spec-02 vector calls FAIL and spec-06 §11.1 makes PERMERROR (as croessner/dkim2 does).
    • One vector has a defect (it expects flags its message lacks).
  • stalwartlabs/mail-auth (Rust, spec-04):
    • Messages signed by either implementation verify with the other. This covers RSA+Ed25519, flags, the null sender, forwarders with and without Recipes, and nd= hops.
    • Differences are spec revision issues where we follow spec-06:
      • mail-auth fails a null body Recipe, which spec-06 §5.2 and the BCP treat as legitimate (croessner/dkim2 agrees with us).
      • Older vectors in its corpus omit the angle brackets in mf=/rt= and sign Received-SPF.

Testing

  • 2831 tests pass. The DKIM2 tests cover sign/verify, chains with Recipes, custody, key errors, field syntax, Recipe JSON, CLI, authenticate(), and DKIM1/DKIM2 coexistence.
  • An independent reference implementation (test/helpers/dkim2-reference.js) cross-checks hashes, the signature input and Recipe application.
  • Property tests (fast-check) and the interop suites above.
  • Mutation testing on lib/dkim2 scored 89%. lib/dkim2 is now in stryker.config.json.
  • /security-review found that a hop with mf=<> skipped the chain-of-custody check, which allowed replay. It is fixed and has a regression test.

Also bumps nodemailer to 10.1.0 and prettier to 3.9.10 (npm run update).

🤖 Generated with Claude Code

andris9 and others added 2 commits October 10, 2026 19:05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Implements DKIM2 as specified by the IETF drafts
draft-ietf-dkim-dkim2-spec-06, draft-ietf-dkim-dkim2-dns-00 and
draft-ietf-dkim-dkim2-bcp-01. DKIM2 is not an RFC yet, so the README
and docs/dkim2.md name the exact draft versions.

- dkim2Sign() and Dkim2SignStream add a DKIM2-Signature, and a
  Message-Instance when the message is new or has changed. A changed
  message needs a Recipe, which is checked against the previous
  instance before signing. RSA and Ed25519 keys, several keys per
  signature, sha256 and sha512 hashes, mf=/rt= or nd=, flags and nonce
- dkim2Verify() recreates every instance with the Recipes and checks
  every hash and signature, the chain of custody between the hops and
  against the SMTP envelope, timestamps, donotmodify, donotexplode and
  replay (through a callback). Failures use the human-readable strings
  of the specification
- Key records are read with the DKIM2 rules (one record per selector,
  retired tags ignored, k= has to match the algorithm)
- authenticate() verifies DKIM2 when the dkim2 option is set and adds a
  dkim2 Authentication-Results entry (draft-gondwana-dkim2-authres-00).
  DKIM2 does not take part in DMARC
- CLI: dkim2-sign command, report --dkim2 --rcpt-to
- Type definitions, docs/dkim2.md, and tests with an independent
  reference implementation, property tests and mutation testing config

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@socket-security

socket-security Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatednodemailer@​10.0.16 ⏵ 10.1.098 +110098 +196 +1100
Updatedprettier@​3.9.9 ⏵ 3.9.10981009797100

View full report

@socket-security

socket-security Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Priority Alert  (click "▶" to expand/collapse) Action
Medium priority
License policy violation: npm nodemailer

License: LicenseRef-LateNiteHacking-Free-1.0 - The applicable license policy does not permit this license (5) (package/LICENSE)

From: package-lock.json → npm/nodemailer@10.1.0

ℹ Read more on: This package | This alert | What is a license policy violation?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/nodemailer@10.1.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn

View full report

andris9 and others added 6 commits October 10, 2026 20:42
The chain of custody check against the delivery (section 11.4) only runs
for the parts of the envelope the caller passes as mailFrom and rcptTo.
Without them an unchanged copy of the message sent to anyone passes too,
so status.warnings now lists mail-from-not-checked and/or
rcpt-to-not-checked. The warnings do not change the result and are not
written into Authentication-Results, like the DKIM1 warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- mailauth dkim2-verify verifies only the DKIM2 header fields, with the
  SMTP envelope (--mail-from, --rcpt-to), --dns-cache, --time, --max-age
  and --max-instances, and prints the JSON report or, with -o, the
  Authentication-Results entry
- mailauth dkim2-hash prints the Message-Instance h= hash-sets of a
  message, backed by the new public dkim2Hash() library function
- cli.md documents dkim2-sign, dkim2-verify, dkim2-hash and the DKIM2
  options of report
- the --dns-cache resolver that report, spf and seal each had a copy of
  is now shared in lib/commands/resolver.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the 42 test vectors of turscar/dkim2tests (BSD 2-Clause, made with
the independent turscar/dkim2 implementation) as a JSON fixture and a
test that verifies every vector, compares the section 9.6 canonical
form, and signs every passing original with the same Message-Instance
hashes and a signature that verifies with the vector's keys.

Fixes found by the vectors:
- a DKIM2-Signature with only unknown signature algorithms is a FAIL,
  as section 11.6 says when no signature can be checked, matching
  turscar/dkim2 (it was a PERMERROR)
- the signature input leaves a malformed s= value as it is instead of
  rewriting it

The flags_whitespace vector expects flags its signed message does not
have, which the test records as a known vector defect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cross-checked with stalwartlabs/mail-auth (draft-ietf-dkim-dkim2-spec-04)
in both directions: messages signed by either implementation verify with
the other. The 16 messages signed by mail-auth are added as a fixture,
with the harness that made them.

The differences found are draft revision differences where mailauth
follows spec-06, documented in docs/dkim2.md:
- mail-auth fails a message with a null body Recipe, which spec-06
  section 5.2 and draft-ietf-dkim-dkim2-bcp-01 treat as legitimate
- older interop vectors of its corpus encode mf=/rt= without angle
  brackets and sign Received-SPF, both contrary to spec-06

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checked against croessner/dkim2, a Go implementation built for the same
drafts (draft-ietf-dkim-dkim2-spec-06, draft-ietf-dkim-dkim2-dns-00).
Its public verification vectors are added to the interop test, and its
canonicalization, Recipe, chain of custody, DNS and crypto vectors give
the same results.

- a DKIM2-Signature with only unknown signature algorithms is a
  PERMERROR again: nothing could be verified and nothing was incorrect,
  which section 11.1 defines as PERMERROR. The turscar vector written
  for spec-02 expects FAIL and is recorded as a spec revision difference
- new opt-in maxFutureTime option (dkim2-verify --max-future) rejects
  timestamps too far in the future (section 8.4 makes it a MAY)
- RSA keys over 8192 bits are not used for verifying or signing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efault

maxFutureTime now defaults to 300 seconds, as croessner/dkim2 does
(section 8.4 lets verifiers ignore signatures from the future, and
draft-ietf-dkim-dkim2-bcp-01 section 3.6 lets them treat such
timestamps with suspicion). false turns the check off, and so does
dkim2-verify --max-future 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@andris9
andris9 merged commit 85ec5b8 into master Oct 10, 2026
15 checks passed
@andris9
andris9 deleted the feat/dkim2 branch October 10, 2026 19:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant