mailauth 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.prettierignore CHANGED
@@ -8,3 +8,6 @@ node_modules/
8
8
 
9
9
  # Test fixtures with embedded content
10
10
  test/fixtures/**/*.yml
11
+ test/fixtures/dkim2tests/vectors.json
12
+ test/fixtures/dkim2-stalwart/messages.json
13
+ test/fixtures/dkim2-croessner/public-golden.json
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "7.2.0"
2
+ ".": "7.3.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [7.3.0](https://github.com/postalsys/mailauth/compare/v7.2.0...v7.3.0) (2026-10-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **dkim2:** add dkim2-verify and dkim2-hash CLI commands ([abc5867](https://github.com/postalsys/mailauth/commit/abc586767ac60a37aacb5ae4ef2cc5957b14d5cd))
9
+ * **dkim2:** add experimental DKIM2 signing and verification ([767ce96](https://github.com/postalsys/mailauth/commit/767ce96a0128f3054a4634d7f2e29ccb734c1b8b))
10
+ * **dkim2:** reject timestamps more than 5 minutes in the future by default ([85ec5b8](https://github.com/postalsys/mailauth/commit/85ec5b8a9ed6f3fe9126ae04d12fca6a6d4c003e))
11
+ * **dkim2:** warn when the SMTP envelope is not checked ([6c0f570](https://github.com/postalsys/mailauth/commit/6c0f570e1b9630b0a383876b6f9a603514731efc))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **dkim2:** match the draft-06 vectors of croessner/dkim2 ([968d0b3](https://github.com/postalsys/mailauth/commit/968d0b303be75580ea76d2207394cf26edafc2bc))
17
+
3
18
  ## [7.2.0](https://github.com/postalsys/mailauth/compare/v7.1.1...v7.2.0) (2026-10-09)
4
19
 
5
20
 
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  - **SPF** verification
10
10
  - **DKIM** signing and verification
11
+ - **DKIM2** signing and verification (experimental, built against the IETF drafts, see [DKIM2](#dkim2))
11
12
  - **DMARC** verification
12
13
  - **ARC** verification and sealing
13
14
  - Sealing during authentication
@@ -30,6 +31,9 @@ mailauth is a pure JavaScript implementation, requiring no external applications
30
31
  - [DKIM](#dkim)
31
32
  - [Signing](#dkim-signing)
32
33
  - [Verification](#dkim-verification)
34
+ - [DKIM2](#dkim2)
35
+ - [Signing](#dkim2-signing)
36
+ - [Verification](#dkim2-verification)
33
37
  - [SPF](#spf)
34
38
  - [Verification](#spf-verification)
35
39
  - [ARC](#arc)
@@ -329,6 +333,101 @@ dkim=pass header.i=@tahvel.info header.s=test.rsa header.b="BrEgDN4A"
329
333
  dkim=policy policy.dkim-rules=weak-key header.i=@tahvel.info header.s=test.small header.b="d0jjgPun"
330
334
  ```
331
335
 
336
+ ### DKIM2
337
+
338
+ > [!WARNING]
339
+ > DKIM2 is not a published RFC yet. This implementation is experimental and follows these Internet-Drafts, which can still change in ways that break compatibility:
340
+ >
341
+ > - [draft-ietf-dkim-dkim2-spec-06](https://www.ietf.org/archive/id/draft-ietf-dkim-dkim2-spec-06.txt), "DomainKeys Identified Mail Signatures v2 (DKIM2)", 28 August 2026
342
+ > - [draft-ietf-dkim-dkim2-dns-00](https://www.ietf.org/archive/id/draft-ietf-dkim-dkim2-dns-00.txt), "Domain Name Specification for DKIM2", 20 July 2026
343
+ > - [draft-ietf-dkim-dkim2-bcp-01](https://www.ietf.org/archive/id/draft-ietf-dkim-dkim2-bcp-01.txt), "DKIM2 Best Practices", 9 September 2026
344
+ >
345
+ > The `dkim2` Authentication-Results method follows the individual draft [draft-gondwana-dkim2-authres-00](https://datatracker.ietf.org/doc/html/draft-gondwana-dkim2-authres-00), as no method name is registered yet.
346
+
347
+ DKIM2 adds a `Message-Instance` header field with hashes of the message, and a `DKIM2-Signature` header field per hop that signs the DKIM2 header fields and records the SMTP envelope (MAIL FROM and RCPT TO) of that hop. Forwarders that change a message describe their changes with Recipes, so that a verifier can recreate every earlier instance of the message and check every signature. DKIM2 uses the same DNS key records as DKIM1 (`selector._domainkey.domain`, `v=DKIM1`), so existing RSA and Ed25519 keys work as they are.
348
+
349
+ DKIM1 and DKIM2 do not sign each other's header fields, so a message can carry both, added in any order. The best practices draft recommends signing with both while DKIM2 is being deployed.
350
+
351
+ DKIM2 is opt-in in `authenticate()` with the `dkim2` option. Its result is reported in `result.dkim2` and in Authentication-Results, and it is not used for DMARC (whether DMARC will use DKIM2 is still an open question of the working group). Delivery Status Notification handling (section 12 of the specification) is left to the MTA.
352
+
353
+ #### DKIM2 Signing
354
+
355
+ ```javascript
356
+ const { dkim2Sign } = require('mailauth');
357
+
358
+ const { signatures } = await dkim2Sign(message, {
359
+ signingDomain: 'example.com',
360
+ // one key per algorithm is recommended, at most two keys per algorithm
361
+ signatureData: [
362
+ { selector: 'rsa2026', privateKey: rsaPrivateKeyPem },
363
+ { selector: 'ed2026', privateKey: ed25519PrivateKeyPem }
364
+ ],
365
+ // the SMTP envelope the message is sent with, required (section 9.2)
366
+ mailFrom: 'bounces@example.com',
367
+ rcptTo: ['user@example.net']
368
+ });
369
+
370
+ // prepend the DKIM2-Signature and Message-Instance header fields to the message
371
+ const signedMessage = signatures + message;
372
+ ```
373
+
374
+ - **signingDomain** (`string`): The signing domain (`d=`). It has to be the MAIL FROM domain or a parent of it.
375
+ - **signatureData** (`array`): Keys to sign with, `{ selector, privateKey, algorithm }`. The algorithm (`rsa-sha256` or `ed25519-sha256`) follows the key type. RSA keys need at least 1024 bits and the public exponent 65537. For a single key, `selector` and `privateKey` can be given directly instead.
376
+ - **mailFrom** (`string`): MAIL FROM address (`mf=`), `''` or `'<>'` for the null sender.
377
+ - **rcptTo** (`string|string[]`): RCPT TO addresses (`rt=`).
378
+ - **nextDomain** (`string`): For a hop across a trust boundary (section 9.3), the domain of the next signature (`nd=`), used instead of `mailFrom` and `rcptTo`.
379
+ - **flags** (`string[]`): Flags (`f=`): `donotmodify`, `donotexplode`, `exploded`, `feedback`, `feedhere`.
380
+ - **nonce** (`string`): A value for your own use (`n=`), at most 64 characters.
381
+ - **hashAlgorithms** (`string[]`): `sha256` (default) and/or `sha512`.
382
+ - **recipe** (`object`): When the message was changed since it was last signed, a Recipe that recreates the previous instance (section 5). The signer checks that the Recipe really recreates it before signing.
383
+ - **signTime** (`Date`): The signing time (`t=`). Defaults to now.
384
+
385
+ `dkim2Sign` resolves with `{ signatures, signature, messageInstance, i, m }` and rejects for invalid options, an invalid DKIM2 chain on the message, or a Recipe that does not fit. A new `Message-Instance` is only added when the message has no instance yet or has changed since the last one. `Dkim2SignStream` is a Transform stream that outputs the signed message, like `DkimSignStream`.
386
+
387
+ A forwarder that changes the message passes a Recipe. This mailing list replaced the `Subject` header field, added a `List-Id` header field, and added a footer after the first 3 body lines:
388
+
389
+ ```javascript
390
+ const { signatures } = await dkim2Sign(revisedMessage, {
391
+ signingDomain: 'list.example.org',
392
+ signatureData: [{ selector: 'list', privateKey }],
393
+ mailFrom: 'bounce@list.example.org',
394
+ rcptTo: 'member@example.net',
395
+ recipe: {
396
+ h: { subject: [{ d: ['Original subject'] }], 'list-id': [] },
397
+ b: [{ c: [1, 3] }]
398
+ }
399
+ });
400
+ ```
401
+
402
+ #### DKIM2 Verification
403
+
404
+ ```javascript
405
+ const { dkim2Verify } = require('mailauth');
406
+
407
+ const result = await dkim2Verify(message, {
408
+ // the SMTP envelope of the delivery, checked against the highest DKIM2-Signature (section 11.4)
409
+ mailFrom: 'bounce@list.example.org',
410
+ rcptTo: ['member@example.net']
411
+ });
412
+
413
+ console.log(result.status.result); // "pass", "fail", "permerror", "temperror" or "none"
414
+ console.log(result.info); // dkim2=pass (i=1 example.com pass, i=2 list.example.org pass) header.d=example.com
415
+ ```
416
+
417
+ - **resolver** (`async function`): Custom DNS resolver function.
418
+ - **mailFrom** / **rcptTo**: The SMTP envelope. The chain of custody check against the envelope, which is what stops DKIM2 replay, is only done for what is set here, as a library can not know the envelope otherwise. A missing part is reported in `status.warnings` as `mail-from-not-checked` or `rcpt-to-not-checked`, without changing the result. In `authenticate()` these come from the `sender` and `rcptTo` options.
419
+ - **maxSignatureAge** (`number|false`): Seconds after which a signature expires (section 11.3). Defaults to 14 days, `false` disables the check.
420
+ - **maxFutureTime** (`number|false`): Seconds a signature timestamp may be ahead of the current time, a later one is a PERMERROR (section 8.4). Defaults to `300` (5 minutes, for clock skew), `false` disables the check.
421
+ - **curTime** (`Date`): The time to check against. Defaults to now.
422
+ - **maxInstances** (`number`): The most `Message-Instance`, and the most `DKIM2-Signature`, header fields processed for one message. Defaults to `20`, more is a PERMERROR (BCP section 7.5).
423
+ - **checkReplay** (`async function`): Called with `{ key, exploded }`, where `key` identifies the message by its `m=1` hashes. Return `true` when a message with the same key was seen before, and the result is FAIL unless a signature has the `exploded` flag (section 11.9). Storing the keys is up to you.
424
+
425
+ The verifier recreates every instance of the message with the Recipes, checks every hash and signature, the chain of custody between the hops, the timestamps, and the `donotmodify` and `donotexplode` requests. Failures use the human-readable strings of section 11 of the specification. See [DKIM2 Result Reference](docs/dkim2.md) for the result object and for how ambiguities of the drafts are handled.
426
+
427
+ `dkim2Hash(message, { algorithms })` computes the header and body hashes of a message as they go into the `h=` tag of a `Message-Instance` header field, which helps when writing or checking a Recipe. It resolves with `{ headers, hashes }`, the names of the hashed header fields and `[{ algorithm, headerHash, bodyHash }]`.
428
+
429
+ With the CLI, `mailauth dkim2-sign` signs a message, `mailauth dkim2-verify` verifies it, `mailauth dkim2-hash` prints the hashes of its `Message-Instance`, and `mailauth report --dkim2 --sender <address> --rcpt-to <address>` includes DKIM2 in the report. See the [command-line documentation](cli.md#dkim2-sign).
430
+
332
431
  ### SPF
333
432
 
334
433
  #### SPF Verification
package/bin/mailauth.js CHANGED
@@ -11,6 +11,9 @@ const commandSeal = require('../lib/commands/seal');
11
11
  const commandSpf = require('../lib/commands/spf');
12
12
  const commandVmc = require('../lib/commands/vmc');
13
13
  const commandBodyhash = require('../lib/commands/bodyhash');
14
+ const commandDkim2Sign = require('../lib/commands/dkim2-sign');
15
+ const commandDkim2Verify = require('../lib/commands/dkim2-verify');
16
+ const commandDkim2Hash = require('../lib/commands/dkim2-hash');
14
17
 
15
18
  const fs = require('node:fs');
16
19
  const pathlib = require('node:path');
@@ -70,6 +73,9 @@ const runCommand = (fn, failMessage) =>
70
73
  });
71
74
  };
72
75
 
76
+ // Collects the values of an option that can be repeated
77
+ const collect = (value, previous) => (previous || []).concat(value);
78
+
73
79
  const emailArgDescription = 'Path to the email message file in EML format. If not specified, the content is read from standard input.';
74
80
 
75
81
  const program = new Command();
@@ -105,6 +111,8 @@ program
105
111
  .option('-z, --max-void-lookups <number>', 'Maximum allowed DNS lookups that return no data (void lookups) during SPF checks. Defaults to 2.', numberArg, 2)
106
112
  .option('--strict', 'Follow the RFCs exactly instead of the lenient defaults (for example, reject rsa-sha1 DKIM signatures).')
107
113
  .option('--reject-rsa-sha1', 'Report rsa-sha1 DKIM signatures as dkim=policy and do not count them for DMARC, while keeping the other lenient defaults.')
114
+ .option('--dkim2', 'Verify DKIM2 header fields as well (experimental, built against draft-ietf-dkim-dkim2-spec-06).')
115
+ .option('-r, --rcpt-to <address>', 'Email address from a RCPT TO command, checked against the DKIM2 chain of custody. Can be repeated.', collect)
108
116
  .action(runCommand(commandReport, 'Failed to generate report for the input message.'));
109
117
 
110
118
  program
@@ -130,6 +138,60 @@ program
130
138
  .option('--strict', 'Follow the RFCs exactly (for example, refuse to sign with rsa-sha1 or with an RSA key shorter than 1024 bits).')
131
139
  .action(runCommand(commandSign, 'Failed to sign the input message.'));
132
140
 
141
+ program
142
+ .command('dkim2-sign')
143
+ .description('Sign an email with DKIM2 (experimental, built against draft-ietf-dkim-dkim2-spec-06)')
144
+ .argument('[email]', emailArgDescription)
145
+ .helpOption('--help', 'Show help.')
146
+ .requiredOption('-k, --private-key <file>', 'Path to a private key file. Repeat it together with --selector to sign with several keys.', collect)
147
+ .requiredOption('-s, --selector <selector>', 'Selector of the private key with the same position. Can be repeated.', collect)
148
+ .requiredOption('-d, --domain <domain>', 'Signing domain (d= tag).')
149
+ .option('-f, --mail-from <address>', 'MAIL FROM address the message is sent with (mf= tag). Use "<>" for the null sender.')
150
+ .option('-r, --rcpt-to <address>', 'RCPT TO address the message is sent to (rt= tag). Can be repeated.', collect)
151
+ .option('--next-domain <domain>', 'Domain of the next DKIM2 signature, instead of --mail-from and --rcpt-to (nd= tag).')
152
+ .option('--nonce <value>', 'Nonce value (n= tag).')
153
+ .option('--flag <flag>', 'Flag to set, such as "donotmodify" or "exploded" (f= tag). Can be repeated.', collect)
154
+ .option('--hash <algorithm>', 'Hash algorithm for the Message-Instance, "sha256" or "sha512". Can be repeated. Defaults to "sha256".', collect)
155
+ .option('--recipe <file>', 'Path to a JSON file with the Recipe that recreates the previous instance of a changed message.')
156
+ .option('-t, --time <timestamp>', 'Signing time as a UNIX timestamp (t= tag). Defaults to the current time.', numberArg)
157
+ .option('-o, --headers-only', 'If set, outputs only the DKIM2 header fields without the message.')
158
+ .hook('preAction', command => {
159
+ let opts = command.opts();
160
+ if (opts.nextDomain ? opts.mailFrom || opts.rcptTo : !opts.mailFrom || !opts.rcptTo) {
161
+ command.error('error: use either --mail-from and --rcpt-to, or --next-domain');
162
+ }
163
+ })
164
+ .action(runCommand(commandDkim2Sign, 'Failed to sign the input message with DKIM2.'));
165
+
166
+ program
167
+ .command('dkim2-verify')
168
+ .description('Verify the DKIM2 signatures of an email and return a JSON report (experimental, built against draft-ietf-dkim-dkim2-spec-06)')
169
+ .argument('[email]', emailArgDescription)
170
+ .helpOption('--help', 'Show help.')
171
+ .option(
172
+ '-f, --mail-from <address>',
173
+ 'MAIL FROM address the message was delivered with, "<>" for the null sender. Checked against the highest DKIM2-Signature.'
174
+ )
175
+ .option('-r, --rcpt-to <address>', 'RCPT TO address the message was delivered to, checked against the highest DKIM2-Signature. Can be repeated.', collect)
176
+ .option(
177
+ '-n, --dns-cache <file>',
178
+ 'Path to a JSON file with cached DNS responses. When provided, DNS queries use these cached responses instead of performing actual DNS lookups.'
179
+ )
180
+ .option('-t, --time <timestamp>', 'Time to verify against as a UNIX timestamp. Defaults to the current time.', numberArg)
181
+ .option('--max-age <seconds>', 'Seconds after which a signature expires, 0 to not check. Defaults to 14 days.', numberArg)
182
+ .option('--max-future <seconds>', 'Seconds a signature timestamp may be ahead of the current time, 0 to not check. Defaults to 300.', numberArg)
183
+ .option('--max-instances <number>', 'Most Message-Instance, and most DKIM2-Signature, header fields to process. Defaults to 20.', numberArg)
184
+ .option('-o, --headers-only', 'If set, outputs only the Authentication-Results entry (dkim2=...) instead of the JSON report.')
185
+ .action(runCommand(commandDkim2Verify, 'Failed to verify DKIM2 for the input message.'));
186
+
187
+ program
188
+ .command('dkim2-hash')
189
+ .description('Generate the DKIM2 header and body hashes of an email (the h= tag of a Message-Instance)')
190
+ .argument('[email]', emailArgDescription)
191
+ .helpOption('--help', 'Show help.')
192
+ .option('-a, --algo <algorithm>', 'Hash algorithm, "sha256" or "sha512". Can be repeated. Defaults to "sha256".', collect)
193
+ .action(runCommand(commandDkim2Hash, 'Failed to calculate the DKIM2 hashes for the input message.'));
194
+
133
195
  program
134
196
  .command('seal')
135
197
  .description('Authenticate and seal an email with an ARC digital signature')
package/cli.md CHANGED
@@ -14,6 +14,9 @@ mailauth provides a command-line utility for email authentication, complementing
14
14
  - [Available Commands](#available-commands)
15
15
  - [`report`](#report) &mdash; Validate SPF, DKIM, DMARC, ARC, and BIMI
16
16
  - [`sign`](#sign) &mdash; Sign an email with DKIM
17
+ - [`dkim2-sign`](#dkim2-sign) &mdash; Sign an email with DKIM2 (experimental)
18
+ - [`dkim2-verify`](#dkim2-verify) &mdash; Verify the DKIM2 signatures of an email (experimental)
19
+ - [`dkim2-hash`](#dkim2-hash) &mdash; Generate the DKIM2 header and body hashes for an email
17
20
  - [`seal`](#seal) &mdash; Seal an email with ARC
18
21
  - [`spf`](#spf) &mdash; Validate SPF for an IP address and email address
19
22
  - [`vmc`](#vmc) &mdash; Validate BIMI VMC logo files
@@ -56,13 +59,19 @@ mailauth spf --help
56
59
 
57
60
  The mailauth CLI offers several commands to perform different email authentication tasks:
58
61
 
59
- 1. [`report`](#report) &mdash; Validate SPF, DKIM, DMARC, ARC, and BIMI.
62
+ 1. [`report`](#report) &mdash; Validate SPF, DKIM, DMARC, ARC, and BIMI, and optionally DKIM2.
60
63
  2. [`sign`](#sign) &mdash; Sign an email with DKIM.
61
- 3. [`seal`](#seal) &mdash; Seal an email with ARC.
62
- 4. [`spf`](#spf) &mdash; Validate SPF for an IP address and email address.
63
- 5. [`vmc`](#vmc) &mdash; Validate BIMI VMC logo files.
64
- 6. [`bodyhash`](#bodyhash) &mdash; Generate the body hash value for an email.
65
- 7. [`license`](#license) &mdash; Display licenses for mailauth and included modules.
64
+ 3. [`dkim2-sign`](#dkim2-sign) &mdash; Sign an email with DKIM2 (experimental).
65
+ 4. [`dkim2-verify`](#dkim2-verify) &mdash; Verify the DKIM2 signatures of an email (experimental).
66
+ 5. [`dkim2-hash`](#dkim2-hash) &mdash; Generate the DKIM2 header and body hashes for an email.
67
+ 6. [`seal`](#seal) &mdash; Seal an email with ARC.
68
+ 7. [`spf`](#spf) &mdash; Validate SPF for an IP address and email address.
69
+ 8. [`vmc`](#vmc) &mdash; Validate BIMI VMC logo files.
70
+ 9. [`bodyhash`](#bodyhash) &mdash; Generate the body hash value for an email.
71
+ 10. [`license`](#license) &mdash; Display licenses for mailauth and included modules.
72
+
73
+ > [!WARNING]
74
+ > DKIM2 is not a published RFC yet. The DKIM2 commands are experimental and built against draft-ietf-dkim-dkim2-spec-06, draft-ietf-dkim-dkim2-dns-00 and draft-ietf-dkim-dkim2-bcp-01, see [DKIM2](README.md#dkim2).
66
75
 
67
76
  ### report
68
77
 
@@ -88,6 +97,8 @@ mailauth report [options] [email]
88
97
  - `--max-void-lookups number`, `-z number`: Sets the maximum number of void DNS lookups for SPF checks. Defaults to `2`.
89
98
  - `--strict`: Follows the RFCs exactly instead of the lenient defaults, for example an `rsa-sha1` DKIM signature is reported as `dkim=policy`. See [Strict mode](README.md#strict-mode).
90
99
  - `--reject-rsa-sha1`: Reports an `rsa-sha1` DKIM signature as `dkim=policy` and does not count it for DMARC, as `--strict` does, while every other check keeps the lenient default.
100
+ - `--dkim2`: Verifies DKIM2 header fields as well. The result is in the `dkim2` key of the report and a `dkim2=` entry is added to Authentication-Results. DKIM2 does not take part in DMARC.
101
+ - `--rcpt-to user@example.com`, `-r user@example.com`: RCPT TO address of the delivery, checked against the highest DKIM2-Signature together with `--sender`. Can be repeated. Without it the DKIM2 result has the `rcpt-to-not-checked` warning.
91
102
 
92
103
  #### Example
93
104
 
@@ -156,6 +167,137 @@ DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com;
156
167
  ...
157
168
  ```
158
169
 
170
+ ### dkim2-sign
171
+
172
+ The `dkim2-sign` command signs an email message with DKIM2. It adds a `DKIM2-Signature` header field, and a `Message-Instance` header field when the message has none yet or has changed since its last instance. Use it as the originator of a message, or as a forwarder that adds its own hop to a message that is already DKIM2 signed.
173
+
174
+ #### Usage
175
+
176
+ ```bash
177
+ mailauth dkim2-sign [options] [email]
178
+ ```
179
+
180
+ - **email**: (Optional) Path to the EML-formatted email message file. If omitted, the email is read from standard input.
181
+
182
+ #### Options
183
+
184
+ - `--private-key /path/to/private.key`, `-k /path/to/private.key`: Path to a private key, RSA (at least 1024 bits) or Ed25519. Repeat it to sign with several keys, each with its own `--selector`.
185
+ - `--selector selector`, `-s selector`: Selector of the private key in the same position. Can be repeated. At most two keys can use the same algorithm.
186
+ - `--domain example.com`, `-d example.com`: Signing domain (`d=` tag). It has to be the MAIL FROM domain or a parent of it.
187
+ - `--mail-from user@example.com`, `-f user@example.com`: MAIL FROM address the message is sent with (`mf=` tag), `<>` for the null sender.
188
+ - `--rcpt-to user@example.net`, `-r user@example.net`: RCPT TO address the message is sent to (`rt=` tag). Can be repeated.
189
+ - `--next-domain example.net`: For a hop across a trust boundary, the domain of the next DKIM2 signature (`nd=` tag). Used instead of `--mail-from` and `--rcpt-to`.
190
+ - `--flag flag`: Flag to set (`f=` tag): `donotmodify`, `donotexplode`, `exploded`, `feedback` or `feedhere`. Can be repeated.
191
+ - `--nonce value`: A value for your own use (`n=` tag), at most 64 characters.
192
+ - `--hash algorithm`: Hash algorithm of a new `Message-Instance`, `sha256` or `sha512`. Can be repeated. Defaults to `sha256`.
193
+ - `--recipe /path/to/recipe.json`: For a forwarder that changed the message, a JSON file with the Recipe that recreates the previous instance (`r=` tag). It is checked before signing, and the command fails if it does not recreate the previous instance.
194
+ - `--time timestamp`, `-t timestamp`: Signing time as a Unix timestamp (`t=` tag). Defaults to the current time.
195
+ - `--headers-only`, `-o`: Outputs only the DKIM2 header fields without the message.
196
+
197
+ Either `--mail-from` and `--rcpt-to`, or `--next-domain`, is required.
198
+
199
+ #### Example
200
+
201
+ ```bash
202
+ mailauth dkim2-sign /path/to/message.eml \
203
+ -k /path/to/rsa.key -s rsa2026 -k /path/to/ed25519.key -s ed2026 \
204
+ -d example.com -f bounces@example.com -r user@example.net
205
+ ```
206
+
207
+ **Sample Output:**
208
+
209
+ ```
210
+ DKIM2-Signature: i=1; m=1; t=1791646747; d=example.com; mf=
211
+ PGJvdW5jZXNAZXhhbXBsZS5jb20+; rt=PHVzZXJAZXhhbXBsZS5uZXQ+; s=rsa2026:rsa-sha256:
212
+ MXp6X81Tuu/cwbedt/kFqdO1sE4yPkfYVnFzfBMO2ZjfAtoXuptS+G79SYEpEaAo
213
+ ...
214
+ Message-Instance: m=1; h=sha256:
215
+ sbKYVMaA7tqVLzGg5HTTU3o95q7ufdnBincKg/jBBQc=:
216
+ GjyEkbey2OupCW5AKJv4dzTPsPHSaZjRDMqUSmhpTyQ=;
217
+ From: ...
218
+ ```
219
+
220
+ A mailing list that replaced the `Subject` header field, added a `List-Id` header field and appended a footer after the first 3 body lines signs its revision with a Recipe:
221
+
222
+ ```json
223
+ { "h": { "subject": [{ "d": ["Original subject"] }], "list-id": [] }, "b": [{ "c": [1, 3] }] }
224
+ ```
225
+
226
+ ```bash
227
+ mailauth dkim2-sign revised.eml -k list.key -s list -d list.example.org \
228
+ -f bounce@list.example.org -r member@example.net --recipe recipe.json
229
+ ```
230
+
231
+ ### dkim2-verify
232
+
233
+ The `dkim2-verify` command verifies only the DKIM2 header fields of an email message and returns a JSON report. See [DKIM2 Result Reference](docs/dkim2.md) for the structure. Use `report --dkim2` to check DKIM2 together with everything else.
234
+
235
+ #### Usage
236
+
237
+ ```bash
238
+ mailauth dkim2-verify [options] [email]
239
+ ```
240
+
241
+ - **email**: (Optional) Path to the EML-formatted email message file. If omitted, the email is read from standard input.
242
+
243
+ #### Options
244
+
245
+ - `--mail-from user@example.com`, `-f user@example.com`: MAIL FROM address the message was delivered with, `<>` for the null sender. Checked against the highest DKIM2-Signature.
246
+ - `--rcpt-to user@example.net`, `-r user@example.net`: RCPT TO address the message was delivered to, checked against the highest DKIM2-Signature. Can be repeated.
247
+ - `--dns-cache /path/to/dns.json`, `-n /path/to/dns.json`: Path to a DNS cache file. When provided, DNS queries use cached responses.
248
+ - `--time timestamp`, `-t timestamp`: Time to verify against as a Unix timestamp. Defaults to the current time.
249
+ - `--max-age seconds`: Seconds after which a signature expires, `0` to not check. Defaults to 14 days.
250
+ - `--max-future seconds`: Seconds a signature timestamp may be ahead of the current time, `0` to not check. Defaults to `300`.
251
+ - `--max-instances number`: The most `Message-Instance`, and the most `DKIM2-Signature`, header fields to process. Defaults to `20`.
252
+ - `--headers-only`, `-o`: Outputs only the Authentication-Results entry (`dkim2=...`) instead of the JSON report.
253
+ - `--verbose`, `-v`: Shows the DNS queries, and the parts of the envelope that were not checked.
254
+
255
+ The chain of custody against the delivery, which is what stops DKIM2 replay, is only checked for the parts of the envelope given with `--mail-from` and `--rcpt-to`. A missing part is reported in `status.warnings`.
256
+
257
+ #### Example
258
+
259
+ ```bash
260
+ mailauth dkim2-verify -f bounces@example.com -r user@example.net -o /path/to/message.eml
261
+ ```
262
+
263
+ **Sample Output:**
264
+
265
+ ```
266
+ dkim2=pass (i=1 example.com pass) header.d=example.com
267
+ ```
268
+
269
+ ### dkim2-hash
270
+
271
+ The `dkim2-hash` command computes the DKIM2 header and body hashes of an email message, in the `algorithm:header-hash:body-hash` form of the `h=` tag of a `Message-Instance` header field. Use it to check what a `Message-Instance` should contain, for example when writing a Recipe.
272
+
273
+ #### Usage
274
+
275
+ ```bash
276
+ mailauth dkim2-hash [options] [email]
277
+ ```
278
+
279
+ - **email**: (Optional) Path to the EML-formatted email message file. If omitted, the email is read from standard input.
280
+
281
+ #### Options
282
+
283
+ - `--algo algorithm`, `-a algorithm`: Hash algorithm, `sha256` or `sha512`. Can be repeated. Defaults to `sha256`.
284
+ - `--verbose`, `-v`: Also lists the header fields that go into the header hash. DKIM2 does not sign trace header fields, `X-` header fields, DKIM1, ARC and Authentication-Results header fields, or its own header fields.
285
+
286
+ #### Example
287
+
288
+ ```bash
289
+ mailauth dkim2-hash -v /path/to/message.eml
290
+ ```
291
+
292
+ **Sample Output:**
293
+
294
+ ```
295
+ Reading email message from /path/to/message.eml
296
+ Hashed header fields: content-type, date, from, message-id, mime-version, subject, to
297
+ --------
298
+ sha256:sbKYVMaA7tqVLzGg5HTTU3o95q7ufdnBincKg/jBBQc=:GjyEkbey2OupCW5AKJv4dzTPsPHSaZjRDMqUSmhpTyQ=
299
+ ```
300
+
159
301
  ### seal
160
302
 
161
303
  The `seal` command adds an ARC (Authenticated Received Chain) seal to an email message.
@@ -413,7 +555,7 @@ The `--dns-cache` option allows you to use a JSON-formatted DNS cache file for t
413
555
 
414
556
  The DNS cache file is a JSON object where:
415
557
 
416
- - **Keys**: Fully qualified domain names (e.g., `"example.com"`).
558
+ - **Keys**: Fully qualified domain names (e.g., `"example.com"`). DKIM and DKIM2 keys are looked up as `selector._domainkey.domain`.
417
559
  - **Values**: Objects with DNS record types as keys (e.g., `"TXT"`, `"MX"`) and their corresponding values.
418
560
 
419
561
  **Example:**
package/docs/dkim2.md ADDED
@@ -0,0 +1,154 @@
1
+ # DKIM2 Result Reference
2
+
3
+ This document describes the result object returned by `dkim2Verify()` and how mailauth reads the parts of the DKIM2 drafts that are open to interpretation.
4
+
5
+ > [!WARNING]
6
+ > DKIM2 support is experimental. It is built against these Internet-Drafts, which are not finished and can change:
7
+ >
8
+ > - [draft-ietf-dkim-dkim2-spec-06](https://www.ietf.org/archive/id/draft-ietf-dkim-dkim2-spec-06.txt) (28 August 2026), the protocol
9
+ > - [draft-ietf-dkim-dkim2-dns-00](https://www.ietf.org/archive/id/draft-ietf-dkim-dkim2-dns-00.txt) (20 July 2026), the key records. The specification cites its predecessor, draft-chuang-dkim2-dns-04, mailauth follows the working group version
10
+ > - [draft-ietf-dkim-dkim2-bcp-01](https://www.ietf.org/archive/id/draft-ietf-dkim-dkim2-bcp-01.txt) (9 September 2026), best practices
11
+ > - [draft-gondwana-dkim2-authres-00](https://datatracker.ietf.org/doc/html/draft-gondwana-dkim2-authres-00) (3 September 2026, not adopted by the working group), the `dkim2` Authentication-Results method
12
+ >
13
+ > Section numbers below refer to draft-ietf-dkim-dkim2-spec-06 unless noted otherwise.
14
+
15
+ ## Overview
16
+
17
+ ```javascript
18
+ const { dkim2Verify } = require('mailauth');
19
+
20
+ const result = await dkim2Verify(message, {
21
+ mailFrom: 'bounce@list.example.org',
22
+ rcptTo: ['member@example.net']
23
+ });
24
+ ```
25
+
26
+ The whole message gets one result (section 11.1):
27
+
28
+ | Result | Meaning |
29
+ | ----------- | ---------------------------------------------------------------------------------------------------------- |
30
+ | `pass` | Every instance, signature and chain of custody check passed |
31
+ | `fail` | A hash or signature was not correct, or a `donotmodify`, `donotexplode` or replay check failed |
32
+ | `permerror` | The message could not be verified: malformed or missing header fields, missing keys, an envelope mismatch… |
33
+ | `temperror` | A public key could not be fetched because of a temporary DNS failure |
34
+ | `none` | The message has no DKIM2 header fields |
35
+
36
+ When there are several problems, `fail` wins over `permerror`, and `permerror` over `temperror`, so that a cryptographic failure is never reported as temporary (section 10.4: such failures "MUST NOT provoke 4xx SMTP replies").
37
+
38
+ ## Top-Level Result Object
39
+
40
+ | Field | Type | Description |
41
+ | ------------ | ---------- | --------------------------------------------------------------------------------------------------------- |
42
+ | `status` | `object` | `{ result, comment, header }`, see below |
43
+ | `info` | `string` | Authentication-Results resinfo, such as `dkim2=pass (i=1 example.com pass) header.d=example.com` |
44
+ | `errors` | `object[]` | Every problem found, in the order the checks ran: `{ result, message, i, m }` |
45
+ | `instances` | `object[]` | One entry per `Message-Instance`, see below |
46
+ | `signatures` | `object[]` | One entry per `DKIM2-Signature`, see below |
47
+ | `replay` | `object` | `{ key, exploded }`: the `m=1` hashes that identify the message, and whether a signature has `f=exploded` |
48
+
49
+ ### status
50
+
51
+ | Field | Type | Description |
52
+ | ---------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `result` | `string` | One of the results above |
54
+ | `comment` | `string` | The human-readable string of the reported problem, as specified in section 11, such as `DKIM2-Signature i=1 rsa incorrect signature` |
55
+ | `header.d` | `string` | `d=` of the `i=1` signature, the originator (draft-gondwana-dkim2-authres-00 section 3.2.1) |
56
+ | `header.i` | `number` | `i=` of the signature the problem is reported against. Not set for `pass`, or for a problem of the whole message |
57
+ | `warnings` | `string[]` | `mail-from-not-checked` and/or `rcpt-to-not-checked` when that part of the SMTP envelope was not given, see [Envelope Warnings](#envelope-warnings) |
58
+
59
+ `header.d` is an authenticated identity only when the result is `pass`.
60
+
61
+ ### Envelope Warnings
62
+
63
+ The chain of custody is what stops DKIM2 replay: the highest `DKIM2-Signature` names the MAIL FROM and RCPT TO the message was delivered with (section 11.4). A library does not see the SMTP transaction, so this check only runs for the parts of the envelope you pass as `mailFrom` and `rcptTo`. When one is missing, `status.warnings` says so:
64
+
65
+ - `mail-from-not-checked`: `mailFrom` was not given (`""` counts as given, it is the null sender)
66
+ - `rcpt-to-not-checked`: `rcptTo` was not given
67
+
68
+ Without them an unchanged copy of the message, sent to anyone, passes as well. The warnings do not change the result and are never written into `info`, in the same way as the DKIM1 warnings. `authenticate()` passes its `sender` and `rcptTo` options on, so set `rcptTo` there to have the recipients checked.
69
+
70
+ ### instances
71
+
72
+ | Field | Type | Description |
73
+ | -------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | `m` | `number` | Revision number |
75
+ | `hashes` | `object[]` | `{ algorithm, header, body }` for every supported hash algorithm. `body` is `unknown` when a later hop declared that the body can not be recreated (`"b": null`) |
76
+ | `recipe` | `object` | When the instance has a Recipe: `{ headers, body }`, the header field names it recreates, and `unchanged`, `recipe` or `unrecoverable` for the body |
77
+
78
+ ### signatures
79
+
80
+ | Field | Type | Description |
81
+ | --------------- | ---------- | ---------------------------------------------------------------------------- |
82
+ | `i` | `number` | Sequence number |
83
+ | `m` | `number` | Highest `Message-Instance` covered |
84
+ | `signingDomain` | `string` | `d=` |
85
+ | `timestamp` | `number` | `t=`, seconds since the epoch, and `signTime` as an ISO 8601 string |
86
+ | `mailFrom` | `string` | Decoded `mf=`, with angle brackets |
87
+ | `rcptTo` | `string[]` | Decoded `rt=` |
88
+ | `nextDomain` | `string` | `nd=` |
89
+ | `nonce` | `string` | `n=` |
90
+ | `flags` | `string[]` | `f=`, including flags mailauth does not know |
91
+ | `status` | `object` | `{ result, comment }` for this hop, including the errors reported against it |
92
+ | `values` | `object[]` | One entry per signature value in `s=`, see below |
93
+
94
+ Each entry of `values` has `selector`, `algorithm`, `result` (`pass`, `fail`, `permerror`, `temperror`, or `none` for an algorithm that is not supported and so ignored, section 3.4), and when available `comment`, `rr` (the key record), `modulusLength` and `testing` (the key record has `t=y`; draft-ietf-dkim-dkim2-dns-00 says a signer in testing mode must be treated like unsigned mail, which is up to you).
95
+
96
+ ## What Is Checked
97
+
98
+ 1. **Syntax and numbering (section 11.2).** Every `Message-Instance` and `DKIM2-Signature` is parsed. `m=` and `i=` start at 1 with no gaps, no instance is above the highest `m=` of a signature, no hash algorithm or selector repeats, and no signing algorithm is used more than twice.
99
+ 2. **Recipes (sections 5 and 11).** Starting from the message as it is, the Recipe of every instance is applied to recreate the one before it.
100
+ 3. **Hashes (section 11.7).** The header and body hashes of every instance are compared with the recreated message.
101
+ 4. **Timestamps (sections 8.4 and 11.3).** A signature older than `maxSignatureAge` (14 days), or more than `maxFutureTime` (5 minutes) in the future, is a PERMERROR.
102
+ 5. **Chain of custody (sections 8.7, 8.8, 9.4 and 11.4).** `d=` matches the `mf=` domain of every signature, `nd=` names the next signer exactly, the MAIL FROM of every hop matches a RCPT TO of the hop before it, and, for the parts of the envelope given as `mailFrom` and `rcptTo`, the highest signature matches the delivery. A missing part is reported in `status.warnings`.
103
+ 6. **Signatures (sections 9.6, 11.5 and 11.6).** Every signature value with a supported algorithm is checked with its key.
104
+ 7. **Requests (section 11.8).** `donotmodify` and `donotexplode` were honored by the later hops.
105
+ 8. **Replay (section 11.9).** When `checkReplay` is given and reports a duplicate, the message fails unless a signature has `f=exploded`.
106
+
107
+ ## How the Drafts Are Read
108
+
109
+ The drafts leave some questions open. mailauth answers them like this:
110
+
111
+ - **Unknown keys in a Recipe step.** Section 2 says unrecognised JSON fields "MUST be ignored", while the JSON schema of section 5 sets `additionalProperties: false` for steps. Unknown keys are ignored, but a step still needs exactly one of `c` and `d`.
112
+ - **Recipe validity.** A Recipe that does not follow the schema (no `h` and no `b`, an empty `h`, an upper case header field name, a `c` range that is out of order or past the last line or header field, a `d` string with CR or LF) is reported as `Message-Instance m=<x> contains invalid JSON: <reason>`. Recipes for header fields that are not signed (section 4) are ignored.
113
+ - **Only unknown signature algorithms.** A `DKIM2-Signature` whose `s=` has no signature value with a supported algorithm is a PERMERROR, `DKIM2-Signature i=<x> has no supported signature algorithm`. Section 3.4 has the unknown algorithms ignored, so nothing could be verified and nothing was found incorrect, which section 11.1 defines as PERMERROR. The FAIL of section 11.6 step 3 is about the signatures that can be checked. The turscar vectors, written for spec-02, expect FAIL here, croessner/dkim2, written for spec-06, reports PERMERROR.
114
+ - **Signature values that can not be checked.** Section 11.6 requires every signature value that can be checked to pass. A value whose key is missing or broken can not be checked: when at least one other value passes and none fails, the signature passes and the key problem is reported in its `values` entry. When no value could be checked, the key problem is the result.
115
+ - **Chain of custody between hops.** Section 9.4 matches the MAIL FROM of a hop with a RCPT TO of the hop before it. A signature with `nd=` has no MAIL FROM, and the null MAIL FROM `<>` has no domain, so for those the `d=` is matched instead (section 9.3 says the signer of such a hop holds a key "associated with a domain in the RCPT TO entry"). Section 8.8 waives only the `d=` and `mf=` match for the null MAIL FROM, not the chain of custody, so a hop with `mf=<>` can not be added by a domain the message was never sent to.
116
+ - **`d=` and `mf=`.** Section 8.8 requires every signature's `d=` to match its `mf=` domain, so this is checked for every signature, not only the highest as section 11.4 describes.
117
+ - **Envelope.** The chain of custody against the SMTP envelope is only checked for the parts given as `mailFrom` and `rcptTo`, a missing part gives a warning. When either is given and the highest signature has `nd=`, the result is `DKIM2-Signature i=<x> unexpected nd= tag`, since the delivery can then only be accepted on out-of-band arrangements (section 9.3).
118
+ - **Future timestamps.** Section 8.4 allows ignoring signatures with a timestamp in the future, and draft-ietf-dkim-dkim2-bcp-01 section 3.6 lets verifiers treat them with suspicion. A timestamp more than `maxFutureTime` seconds ahead (default 300, as croessner/dkim2 does) is a PERMERROR, `DKIM2-Signature i=<x> signature timestamp is in the future`. `maxFutureTime: false` turns the check off.
119
+ - **RSA key size.** Verifiers have to handle 1024 to 2048 bits and may handle more (section 3.2). mailauth accepts up to 8192 bits, a longer key is `public key <selector> is too long`.
120
+ - **`donotmodify`.** Section 8.10 allows adding header fields. A message passes when every signed header field of the instance the request was made on is still present, in the same order, and the body hash is unchanged. A null body Recipe after the request counts as a change.
121
+ - **Key records.** Records are read with the tag-list rules of draft-ietf-dkim-dkim2-dns-00: tag names are case sensitive, `v=`, when present, is exactly `DKIM1` and the first tag, duplicate tags and other syntax errors make the record unusable, several TXT records are an error, and `k=` has to match the algorithm (`rsa` for `rsa-sha256`, `ed25519` for `ed25519-sha256`). The retired `h=`, `n=` and `s=` tags are ignored, and so is the `t=s` flag, as DKIM2 has no `i=` identity. Ed25519 keys are published as the bare 32 byte key (RFC 8463). RSA keys need at least 1024 bits and the public exponent 65537 (section 3.2).
122
+ - **Leading whitespace in Recipe data.** The header hash removes the whitespace after the colon, so `{"d": [" value"]}` and `{"d": ["value"]}` recreate the same header field.
123
+
124
+ ### Error Strings Not in the Specification
125
+
126
+ Section 11 lists the human-readable strings to use. These cases have no string there, so mailauth adds its own:
127
+
128
+ - `Message-Instance m=<x> appears more than once` and `DKIM2-Signature i=<x> appears more than once`
129
+ - `Message-Instance m=<x> has no supported hash algorithm` and `DKIM2-Signature i=<x> has no supported signature algorithm`
130
+ - `Message has more than <n> Message-Instance or DKIM2-Signature header fields` (draft-ietf-dkim-dkim2-bcp-01 section 7.5)
131
+ - `DKIM2-Signature i=<x> public key <selector> is too short`, `... is too long` and `... has an unsupported exponent`
132
+ - `DKIM2-Signature i=<x> signature timestamp is in the future`
133
+
134
+ The strings of section 11 are used as written, including their inconsistencies (`Message Instance` without the hyphen in section 11.7, `MAIL nd= does not match` in section 11.4).
135
+
136
+ ## Interoperability
137
+
138
+ mailauth has been checked against three independent DKIM2 implementations. Where an implementation built for an earlier revision of the specification disagrees with draft-ietf-dkim-dkim2-spec-06, mailauth follows spec-06.
139
+
140
+ - **[croessner/dkim2](https://github.com/croessner/dkim2)** (draft-ietf-dkim-dkim2-spec-06 and draft-ietf-dkim-dkim2-dns-00, the same drafts as mailauth). Its public verification vectors are part of the test suite (`test/fixtures/dkim2-croessner`), and its header, body and section 9.6 canonicalization, Recipe application, chain of custody, DNS record and crypto vectors give the same results. The differences are choices outside the protocol:
141
+ - a message with no DKIM2 header fields is `none` in mailauth (draft-gondwana-dkim2-authres-00 section 3.1, and draft-ietf-dkim-dkim2-bcp-01 section 6.1.3 says the absence alone is not a reason to reject), a PERMERROR in croessner/dkim2, which also refuses messages with bare LF line endings
142
+
143
+ - **[turscar/dkim2](https://forge.turscar.ie/Turscar/dkim2)** (draft-ietf-dkim-dkim2-spec-02). The 42 vectors of [turscar/dkim2tests](https://forge.turscar.ie/turscar/dkim2tests) give the expected result, except `algorithm_only_future` (only unknown signature algorithms), which is FAIL in the vector and PERMERROR by spec-06 (see above). They also give the same section 9.6 canonical form, and the same Message-Instance hashes when mailauth signs the original messages. The vectors are part of the test suite (`test/fixtures/dkim2tests`). One vector, `flags_whitespace`, expects flags that its signed message does not have.
144
+ - **[stalwartlabs/mail-auth](https://github.com/stalwartlabs/mail-auth)** (draft-ietf-dkim-dkim2-spec-04). Messages signed by each implementation verify with the other: originators with Ed25519, and with RSA and Ed25519 together, flags, nonce and several recipients, the null reverse-path, forwarders with and without a Recipe, and hops with `nd=`. The messages signed by mail-auth are part of the test suite (`test/fixtures/dkim2-stalwart`). The differences:
145
+ - mail-auth fails a message whose body was declared unrecoverable with a null body Recipe (`{"b": null}`). mailauth passes it and reports the earlier body as `unknown`, as croessner/dkim2 does, since section 5.2 makes accepting such a declaration local policy, section 9.1 allows the null Recipe, and draft-ietf-dkim-dkim2-bcp-01 sections 5.7 and 7.6 recommend it.
146
+ - The interop vectors in the mail-auth corpus that were made by an implementation for an earlier draft encode `mf=` and `rt=` without angle brackets, which sections 8.5 and 8.6 of spec-06 require (mail-auth rejects them too, outside its tests), and sign `Received-SPF`, which section 4 of spec-06 leaves unsigned. With those two rules relaxed, mailauth verifies all of them, including a chain of six hops with header and body Recipes.
147
+
148
+ ## Signing
149
+
150
+ `dkim2Sign()` follows section 9:
151
+
152
+ - A `Message-Instance` is added when the message has none, or when its hashes differ from the highest one. A changed message needs a `recipe`, which is applied before signing to make sure it recreates the highest instance.
153
+ - The new signature continues the chain of custody: its signing domain has to equal the `nd=` of the previous signature, or its MAIL FROM domain (or, with `nextDomain`, its signing domain) has to match a RCPT TO of the previous signature.
154
+ - Long tag values are folded inside base64 values, where section 2.14 allows FWS.