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 +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +99 -0
- package/bin/mailauth.js +62 -0
- package/cli.md +149 -7
- package/docs/dkim2.md +154 -0
- package/index.d.ts +359 -0
- package/lib/commands/dkim2-hash.js +25 -0
- package/lib/commands/dkim2-sign.js +77 -0
- package/lib/commands/dkim2-verify.js +59 -0
- package/lib/commands/report.js +8 -33
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +2 -32
- package/lib/commands/spf.js +2 -33
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim2/dkim2-parser.js +52 -0
- package/lib/dkim2/dkim2-signer.js +334 -0
- package/lib/dkim2/dkim2-verifier.js +572 -0
- package/lib/dkim2/fields.js +570 -0
- package/lib/dkim2/hash.d.ts +3 -0
- package/lib/dkim2/hash.js +51 -0
- package/lib/dkim2/key.js +203 -0
- package/lib/dkim2/message-state.js +181 -0
- package/lib/dkim2/recipe.js +134 -0
- package/lib/dkim2/sign.d.ts +3 -0
- package/lib/dkim2/sign.js +38 -0
- package/lib/dkim2/verify.d.ts +3 -0
- package/lib/dkim2/verify.js +19 -0
- package/lib/mailauth.js +40 -0
- package/lib/tools.js +1 -0
- package/package.json +3 -3
- package/stryker.config.json +2 -2
package/.prettierignore
CHANGED
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) — Validate SPF, DKIM, DMARC, ARC, and BIMI
|
|
16
16
|
- [`sign`](#sign) — Sign an email with DKIM
|
|
17
|
+
- [`dkim2-sign`](#dkim2-sign) — Sign an email with DKIM2 (experimental)
|
|
18
|
+
- [`dkim2-verify`](#dkim2-verify) — Verify the DKIM2 signatures of an email (experimental)
|
|
19
|
+
- [`dkim2-hash`](#dkim2-hash) — Generate the DKIM2 header and body hashes for an email
|
|
17
20
|
- [`seal`](#seal) — Seal an email with ARC
|
|
18
21
|
- [`spf`](#spf) — Validate SPF for an IP address and email address
|
|
19
22
|
- [`vmc`](#vmc) — 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) — Validate SPF, DKIM, DMARC, ARC, and BIMI.
|
|
62
|
+
1. [`report`](#report) — Validate SPF, DKIM, DMARC, ARC, and BIMI, and optionally DKIM2.
|
|
60
63
|
2. [`sign`](#sign) — Sign an email with DKIM.
|
|
61
|
-
3. [`
|
|
62
|
-
4. [`
|
|
63
|
-
5. [`
|
|
64
|
-
6. [`
|
|
65
|
-
7. [`
|
|
64
|
+
3. [`dkim2-sign`](#dkim2-sign) — Sign an email with DKIM2 (experimental).
|
|
65
|
+
4. [`dkim2-verify`](#dkim2-verify) — Verify the DKIM2 signatures of an email (experimental).
|
|
66
|
+
5. [`dkim2-hash`](#dkim2-hash) — Generate the DKIM2 header and body hashes for an email.
|
|
67
|
+
6. [`seal`](#seal) — Seal an email with ARC.
|
|
68
|
+
7. [`spf`](#spf) — Validate SPF for an IP address and email address.
|
|
69
|
+
8. [`vmc`](#vmc) — Validate BIMI VMC logo files.
|
|
70
|
+
9. [`bodyhash`](#bodyhash) — Generate the body hash value for an email.
|
|
71
|
+
10. [`license`](#license) — 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.
|