mailauth 7.1.1 → 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/.c8rc.json +11 -0
- package/.prettierignore +3 -0
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +68 -0
- package/README.md +131 -31
- package/bin/mailauth.js +62 -0
- package/cli.md +150 -8
- package/docs/arc.md +62 -54
- package/docs/bimi.md +153 -54
- package/docs/dkim.md +37 -36
- package/docs/dkim2.md +154 -0
- package/docs/dmarc.md +4 -1
- package/docs/mta-sts.md +12 -9
- package/docs/spf.md +14 -13
- package/index.d.ts +583 -42
- package/lib/arc/index.d.ts +6 -2
- package/lib/arc/index.js +45 -15
- package/lib/bimi/index.js +362 -102
- package/lib/bimi/validate-svg.js +493 -60
- 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 +10 -35
- package/lib/commands/resolver.js +51 -0
- package/lib/commands/seal.js +10 -38
- package/lib/commands/spf.js +4 -35
- package/lib/dkim/dkim-signer.js +50 -30
- package/lib/dkim/dkim-verifier.js +79 -27
- package/lib/dkim/header/simple.js +2 -2
- package/lib/dkim/sign.js +7 -2
- package/lib/dkim/syntax.js +11 -0
- package/lib/dkim/verify.js +1 -0
- 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/dmarc/author-domain.js +270 -5
- package/lib/dmarc/get-dmarc-record.js +24 -11
- package/lib/dmarc/verify.js +7 -3
- package/lib/mailauth.js +57 -14
- package/lib/mta-sts.js +37 -11
- package/lib/parse-received.js +214 -25
- package/lib/spf/index.js +21 -4
- package/lib/spf/macro.js +5 -3
- package/lib/spf/spf-verify.js +14 -5
- package/lib/spf/syntax.js +5 -0
- package/lib/tools.js +160 -21
- package/package.json +15 -6
- package/stryker.config.json +16 -0
package/docs/bimi.md
CHANGED
|
@@ -12,7 +12,7 @@ Before looking up a BIMI record, mailauth checks the requirements of section 7.1
|
|
|
12
12
|
- DMARC passed, with an effective policy other than `none` and without `t=y`.
|
|
13
13
|
- Neither the DMARC record of the author domain nor the one of its Organizational Domain has `p=none` or `sp=none`, or `p=quarantine` with a `pct` other than 100. A record found at the author domain itself does not hide a lax record of the Organizational Domain. `dmarc()` passes both records to `bimi()`, for a DMARC result built elsewhere `bimi()` looks up the Organizational Domain's record itself.
|
|
14
14
|
|
|
15
|
-
The record is looked up at `<selector>._bimi.<author domain>` and then at `<selector>._bimi.<organizational domain>`, where the selector comes from the `BIMI-Selector` header and defaults to `default`. A custom selector does not fall back to `default`. TXT records that do not start with `v=BIMI1` are ignored, several BIMI records are a failure.
|
|
15
|
+
The record is looked up at `<selector>._bimi.<author domain>` and then at `<selector>._bimi.<organizational domain>`, where the selector comes from the `BIMI-Selector` header and defaults to `default`. A `BIMI-Selector` header without `v=BIMI1` or without a valid `s=` selector is ignored, and `default` is used (sections 5.1 and 7.2). A custom selector does not fall back to `default`. TXT records that do not start with `v=BIMI1` are ignored, several BIMI records are a failure.
|
|
16
16
|
|
|
17
17
|
```javascript
|
|
18
18
|
const { authenticate } = require('mailauth');
|
|
@@ -26,14 +26,29 @@ const { bimi } = await authenticate(message, {
|
|
|
26
26
|
|
|
27
27
|
## Result Object Fields
|
|
28
28
|
|
|
29
|
-
| Field | Type | Presence
|
|
30
|
-
| ------------ | -------- |
|
|
31
|
-
| `status` | `object` | Always
|
|
32
|
-
| `location` | `string` | Found
|
|
33
|
-
| `authority` | `string` | Found
|
|
34
|
-
| `preference` | `string` | When `
|
|
35
|
-
| `rr` | `string` | Found
|
|
36
|
-
| `
|
|
29
|
+
| Field | Type | Presence | Description |
|
|
30
|
+
| ------------ | -------- | -------------------- | ------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `status` | `object` | Always | Resolution status object (see below) |
|
|
32
|
+
| `location` | `string` | Found | HTTPS URL for the logo SVG file (from `l=` tag) |
|
|
33
|
+
| `authority` | `string` | Found | HTTPS URL for the VMC/CMC certificate (from `a=` tag) |
|
|
34
|
+
| `preference` | `string` | When `avp=` is valid | Avatar preference from the `avp=` tag, `personal` or `brand`. Other values are ignored |
|
|
35
|
+
| `rr` | `string` | Found | Raw BIMI DNS TXT record |
|
|
36
|
+
| `warnings` | `array` | When there are any | Assertion Record deviations accepted because `strict` is not set, see [Strict Mode](#strict-mode) |
|
|
37
|
+
| `info` | `string` | Always | Formatted Authentication-Results header value |
|
|
38
|
+
|
|
39
|
+
## Strict Mode
|
|
40
|
+
|
|
41
|
+
The Assertion Record uses the DKIM tag-list syntax, and the draft says that receivers must not fix syntax or capitalization errors (section 4.3). By default mailauth accepts the deviations below in the record and in the `BIMI-Selector` header, and lists them in `warnings`. With `strict: true` they are handled exactly:
|
|
42
|
+
|
|
43
|
+
| Input | Default | `strict: true` |
|
|
44
|
+
| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------ |
|
|
45
|
+
| `v=` value in another case, such as `v=bimi1` | used, warning `record-version-case` | record ignored (`none` if it is the only one) |
|
|
46
|
+
| Upper case tag names, such as `L=` | used as the lower case tag, warning `record-tag-case` | unknown tag, ignored |
|
|
47
|
+
| Duplicate tags or other tag-list syntax errors | last duplicate wins, warning `record-syntax` | `fail` (`invalid syntax in dns record`) |
|
|
48
|
+
| Unencoded comma in `l=` or `a=` | used, warning `uri-comma` | `fail` (`invalid location value...`, `invalid authority value...`) |
|
|
49
|
+
| `l=` URI ending in another image format suffix, such as `.png` | used, warning `location-format` | `fail` (`unsupported image format in location value`) |
|
|
50
|
+
| `BIMI-Selector` header with `v=` in another case, `v=` not first, or a tag-list syntax error | used, warning `selector-version-case` or `selector-syntax` | header ignored, `default` is used |
|
|
51
|
+
| `BIMI-Selector` selector with underscores | used, warning `selector-syntax` | header ignored, `default` is used |
|
|
37
52
|
|
|
38
53
|
## status Object
|
|
39
54
|
|
|
@@ -53,10 +68,10 @@ const { bimi } = await authenticate(message, {
|
|
|
53
68
|
|
|
54
69
|
### status.policy Object
|
|
55
70
|
|
|
56
|
-
| Field | Type | Description
|
|
57
|
-
| --------------- | -------- |
|
|
58
|
-
| `authority` | `string` | VMC validation status
|
|
59
|
-
| `authority-uri` | `string` | URL of the authority evidence document
|
|
71
|
+
| Field | Type | Description |
|
|
72
|
+
| --------------- | -------- | ---------------------------------------------------------------------------------------- |
|
|
73
|
+
| `authority` | `string` | VMC validation status, `"none"` from `bimi()`, `"pass"` or `"fail"` from `validateVMC()` |
|
|
74
|
+
| `authority-uri` | `string` | URL of the authority evidence document |
|
|
60
75
|
|
|
61
76
|
## Result Values
|
|
62
77
|
|
|
@@ -89,15 +104,16 @@ The `status.comment` field explains why BIMI was skipped:
|
|
|
89
104
|
|
|
90
105
|
## Fail Reasons
|
|
91
106
|
|
|
92
|
-
| Comment
|
|
93
|
-
|
|
|
94
|
-
| `"multiple BIMI-Selector headers"`
|
|
95
|
-
| `"missing bimi version in
|
|
96
|
-
| `"missing
|
|
97
|
-
| `"
|
|
98
|
-
| `"invalid
|
|
99
|
-
| `"invalid
|
|
100
|
-
| `"
|
|
107
|
+
| Comment | Description |
|
|
108
|
+
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
109
|
+
| `"multiple BIMI-Selector headers"` | Message has more than one BIMI-Selector header |
|
|
110
|
+
| `"missing bimi version in dns record"` | DNS record missing `v=BIMI1` |
|
|
111
|
+
| `"missing location value in dns record"` | Record has no `l=` value (and is not a declination), even if it has `a=` |
|
|
112
|
+
| `"invalid location value in dns record"` | `l=` value is not a valid HTTPS URL with a domain name (IP addresses, `localhost` and single-label hosts are not accepted) |
|
|
113
|
+
| `"invalid authority value in dns record"` | `a=` value is not a valid HTTPS URL with a domain name |
|
|
114
|
+
| `"invalid syntax in dns record"` | Tag-list syntax error, such as a duplicate tag (strict mode) |
|
|
115
|
+
| `"unsupported image format in location value"` | `l=` ends with an image format suffix other than SVG or SVGZ (strict mode) |
|
|
116
|
+
| `"multiple BIMI records for {name}"` | More than one `v=BIMI1` record at the name |
|
|
101
117
|
|
|
102
118
|
## Temperror Reasons
|
|
103
119
|
|
|
@@ -117,24 +133,44 @@ const bimiResult = await bimi(data);
|
|
|
117
133
|
const vmcResult = await validateVMC(bimiResult, options);
|
|
118
134
|
```
|
|
119
135
|
|
|
136
|
+
### Options
|
|
137
|
+
|
|
138
|
+
| Option | Type | Default | Description |
|
|
139
|
+
| ----------------- | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
140
|
+
| `strict` | `boolean` | `false` | Validate the logo files exactly against the SVG Tiny PS profile, see [Logo SVG Validation](#logo-svg-validation) |
|
|
141
|
+
| `now` | `Date` | now | Time used for the certificate validity checks (passed to `@postalsys/vmc`) |
|
|
142
|
+
| `maxLogoSize` | `number` | `65536` | Maximum size of the logo file in bytes, also the limit for an uncompressed SVGZ file |
|
|
143
|
+
| `maxEvidenceSize` | `number` | `262144` | Maximum size of the evidence document in bytes |
|
|
144
|
+
| `timeout` | `number` | `30000` | Time limit in milliseconds for each download, including redirects and the response body |
|
|
145
|
+
| `dispatcher` | `Dispatcher` | | undici dispatcher for the downloads, for example to use a proxy |
|
|
146
|
+
|
|
147
|
+
Downloads only use HTTPS URLs whose host is a domain name. Redirects are followed up to 3 times when the target is such a URL as well, and the body is read up to the size limit only (section 7.6 of the draft allows a retrieval limit). The limits also apply to `locationPath` and `authorityPath` buffers.
|
|
148
|
+
|
|
120
149
|
### VMC Result Object
|
|
121
150
|
|
|
122
|
-
| Field | Type | Description
|
|
123
|
-
| ----------- | -------- |
|
|
124
|
-
| `location` | `object` | Logo file fetch result
|
|
125
|
-
| `authority` | `object` | VMC/CMC fetch and validation result
|
|
126
|
-
| `headers` | `object` | Ready-to-use email headers (only on validation success)
|
|
151
|
+
| Field | Type | Description |
|
|
152
|
+
| ----------- | -------- | ----------------------------------------------------------------- |
|
|
153
|
+
| `location` | `object` | Logo file fetch result |
|
|
154
|
+
| `authority` | `object` | VMC/CMC fetch and validation result |
|
|
155
|
+
| `headers` | `object` | Ready-to-use email headers (only on validation success) |
|
|
156
|
+
| `status` | `object` | Updated BIMI status, when `bimiData.status.result` was `pass` |
|
|
157
|
+
| `info` | `string` | Updated Authentication-Results entry, along with `status` |
|
|
158
|
+
| `warnings` | `array` | Accepted SVG Tiny PS profile deviations of the logo files, if any |
|
|
159
|
+
|
|
160
|
+
The `info` of `bimi()` covers record discovery only, it reports `bimi=pass policy.authority=none` before anything is downloaded. When `validateVMC()` is used, use its `info` for the Authentication-Results header instead (section 7.7 of the draft): the result is `fail` when the indicator can not be retrieved (`failed to retrieve indicator`), fails SVG validation (`invalid SVG indicator`), the evidence document does not validate (`evidence document validation failed`) or its logo does not match the indicator (`indicator does not match evidence document`). When the record has `a=`, `policy.authority` is `pass` if the evidence document validated and its logo matches, and `fail` otherwise. `authenticate()` does not download anything, so its `bimi.info` is the discovery result.
|
|
127
161
|
|
|
128
162
|
### location Object
|
|
129
163
|
|
|
130
|
-
| Field | Type | Description
|
|
131
|
-
| ----------- | --------- |
|
|
132
|
-
| `url` | `string` | Logo URL
|
|
133
|
-
| `success` | `boolean` | Whether
|
|
134
|
-
| `logoFile` | `string` | Base64-encoded logo SVG (
|
|
135
|
-
| `error` | `object` | Error details (on failure)
|
|
136
|
-
| `hashAlgo` | `string` | Hash algorithm used for verification
|
|
137
|
-
| `hashValue` | `string` | Calculated hash of the logo file
|
|
164
|
+
| Field | Type | Description |
|
|
165
|
+
| ----------- | --------- | -------------------------------------------------------------- |
|
|
166
|
+
| `url` | `string` | Logo URL |
|
|
167
|
+
| `success` | `boolean` | Whether the logo was fetched and passed SVG validation |
|
|
168
|
+
| `logoFile` | `string` | Base64-encoded logo SVG, uncompressed if it was SVGZ (success) |
|
|
169
|
+
| `error` | `object` | Error details (on failure) |
|
|
170
|
+
| `hashAlgo` | `string` | Hash algorithm used for verification |
|
|
171
|
+
| `hashValue` | `string` | Calculated hash of the logo file |
|
|
172
|
+
|
|
173
|
+
The logo downloaded from `l=` is always checked with the SVG validator (section 7.6 of the draft), also when the record has no `a=` evidence document. An SVGZ file is uncompressed first. A logo that fails validation gives `success: false` with the error code `SVG_VALIDATION_FAILED`, and no headers are generated.
|
|
138
174
|
|
|
139
175
|
### authority Object
|
|
140
176
|
|
|
@@ -149,13 +185,13 @@ const vmcResult = await validateVMC(bimiResult, options);
|
|
|
149
185
|
|
|
150
186
|
### headers Object
|
|
151
187
|
|
|
152
|
-
Present only when
|
|
188
|
+
Present only when the logo passes SVG validation and, if the record has an `a=` tag, the evidence document validates and its logo hash matches. Contains ready-to-use email headers.
|
|
153
189
|
|
|
154
|
-
| Field | Type | Presence
|
|
155
|
-
| ------------ | -------- |
|
|
156
|
-
| `indicator` | `string` | Always
|
|
157
|
-
| `location` | `string` | Always
|
|
158
|
-
| `preference` | `string` | `
|
|
190
|
+
| Field | Type | Presence | Description |
|
|
191
|
+
| ------------ | -------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
192
|
+
| `indicator` | `string` | Always | BIMI-Indicator header with base64-encoded SVG logo |
|
|
193
|
+
| `location` | `string` | Always | BIMI-Location header with the logo URL, and the `a=` evidence document URL when it was verified |
|
|
194
|
+
| `preference` | `string` | `preference` is `personal` or `brand` | BIMI-Logo-Preference header, for example `BIMI-Logo-Preference: avp=brand` |
|
|
159
195
|
|
|
160
196
|
These headers should be added to messages after successful BIMI validation. The MTA should:
|
|
161
197
|
|
|
@@ -164,14 +200,65 @@ These headers should be added to messages after successful BIMI validation. The
|
|
|
164
200
|
|
|
165
201
|
### VMC Error Codes
|
|
166
202
|
|
|
167
|
-
| Code
|
|
168
|
-
|
|
|
169
|
-
| `HTTP_REQUEST_FAILED`
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
203
|
+
| Code | Description |
|
|
204
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
205
|
+
| `HTTP_REQUEST_FAILED` | HTTP request failed |
|
|
206
|
+
| `HTTP_REQUEST_TIMEOUT` | The download took longer than `timeout` |
|
|
207
|
+
| `HTTP_REDIRECT_NOT_ALLOWED` | A redirect to a URL that is not HTTPS with a domain name, or more than 3 redirects. `redirect` has the target |
|
|
208
|
+
| `INVALID_URL` | The URL is not HTTPS, or its host is an IP address, `localhost` or a single label |
|
|
209
|
+
| `FILE_TOO_LARGE` | The file, or the uncompressed SVGZ logo, is larger than the size limit |
|
|
210
|
+
| `MISSING_VMC_LOGO` | VMC does not contain a logo file |
|
|
211
|
+
| `INVALID_MEDIATYPE` | Logo media type is not `image/svg+xml` |
|
|
212
|
+
| `INVALID_LOGO_HASH` | Logo hash does not match certificate |
|
|
213
|
+
| `SVG_VALIDATION_FAILED` | SVG file failed validation, `details` has the validator's error code (`INVALID_SVGZ_FILE` for an SVGZ file that can not be uncompressed) |
|
|
214
|
+
| `VMC_DOMAIN_MISMATCH` | Domain not found in certificate SAN |
|
|
215
|
+
|
|
216
|
+
## Logo SVG Validation
|
|
217
|
+
|
|
218
|
+
`validateBimiSvg(logo, options)` (also `validateSvg` in `mailauth/lib/bimi/validate-svg`) checks a logo against the SVG Tiny Portable/Secure profile of [draft-svg-tiny-ps-abrotman](https://datatracker.ietf.org/doc/html/draft-svg-tiny-ps-abrotman). It returns `true` or throws an error with one of the codes below. `validateVMC()` runs it on the logo from `l=` and on the logo embedded in the evidence document, with the `strict` option of `validateVMC()`, and lists the warnings in `warnings` of its result.
|
|
219
|
+
|
|
220
|
+
```javascript
|
|
221
|
+
const { validateBimiSvg } = require('mailauth');
|
|
222
|
+
|
|
223
|
+
const warnings = [];
|
|
224
|
+
validateBimiSvg(logo, { strict: false, warnings }); // throws if the logo is not valid
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The root element must be `svg` in the `http://www.w3.org/2000/svg` namespace. Some rules of the profile do not make a logo unsafe. By default a logo that breaks them is accepted and a marker is added to the `warnings` array, with `strict: true` it is rejected:
|
|
228
|
+
|
|
229
|
+
| Warning | Rule |
|
|
230
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| `svg-version` | The root element has `version="1.2"` (`INVALID_SVG_VERSION`) |
|
|
232
|
+
| `svg-title-count` | Only one `title` child of the root element (`LOGO_MULTIPLE_TITLES`) |
|
|
233
|
+
| `svg-title-position` | `title` is the first child of the root element, and not used elsewhere (`LOGO_INVALID_ELEMENT`) |
|
|
234
|
+
| `svg-attribute-value` | `zoomAndPan`, `externalResourcesRequired`, `focusable`, `snapshotTime`, `playbackOrder`, `timelineBegin` and `editable` have their only allowed value (`LOGO_INVALID_ATTRIBUTE`) |
|
|
235
|
+
| `svg-empty-desc` | A `desc` element is not empty (`LOGO_INVALID_CONTENT`) |
|
|
236
|
+
| `svg-element` | Only the elements of the validation schema, not the other static SVG elements listed below (`LOGO_INVALID_ELEMENT`) |
|
|
237
|
+
| `svg-foreign-element` | No elements in other namespaces outside `metadata` (`LOGO_INVALID_ELEMENT`) |
|
|
238
|
+
| `svg-metadata-content` | `metadata` has text content only (`LOGO_INVALID_ELEMENT`) |
|
|
239
|
+
|
|
240
|
+
The rules below apply in both modes. Namespaces are resolved, and elements are checked against an allowlist: the element set of the validation schema in section 7 of the profile, plus static SVG 1.1 rendering elements found in published logos (`style`, `clipPath`, `mask`, `pattern`, `symbol`, `marker`, `tspan`, `textPath`, filter primitives other than `feImage`, and a few font elements). Script, interactivity, linking, multimedia, `image`, `switch`, `foreignObject` and animation elements are rejected, as are XHTML and MathML elements and SVG elements inside `metadata`. Elements in other namespaces, such as RDF metadata, are allowed by default.
|
|
241
|
+
|
|
242
|
+
These are rejected in every element and namespace:
|
|
243
|
+
|
|
244
|
+
- event handler attributes (any attribute whose local name starts with `on`)
|
|
245
|
+
- `href` in any namespace (XLink, no namespace, or another prefix) unless it is a same-document `#fragment` reference
|
|
246
|
+
- `url()` references that are not `#fragment`, `@import`, CSS image functions and CSS escapes, in `style` elements, `style` attributes and presentation attributes
|
|
247
|
+
- a non-empty `xml:base`
|
|
248
|
+
- processing instructions such as `xml-stylesheet`, DTDs with an internal subset, and entity references other than the predefined ones and character references
|
|
249
|
+
- markup that an HTML parser would read differently: `<` inside CDATA sections, comments that contain `<`, `>` or `--`, and a `font` element with `color`, `face` or `size` attributes
|
|
250
|
+
|
|
251
|
+
| Code | Description |
|
|
252
|
+
| ------------------------- | ------------------------------------------------------------------------ |
|
|
253
|
+
| `INVALID_XML_FILE` | Not well-formed XML, or XML features that are not allowed (DTD, entity) |
|
|
254
|
+
| `INVALID_SVG_FILE` | The root element is not `svg` in the SVG namespace |
|
|
255
|
+
| `INVALID_BASE_PROFILE` | `baseProfile` is not `tiny-ps` |
|
|
256
|
+
| `LOGO_MISSING_TITLE` | No `title` child of the root element, or it is empty |
|
|
257
|
+
| `LOGO_INVALID_ROOT_ATTRS` | The root element has `x` or `y` attributes |
|
|
258
|
+
| `LOGO_INVALID_ELEMENT` | An element that is not allowed, `details.element` names it |
|
|
259
|
+
| `LOGO_INVALID_ATTRIBUTE` | An event handler attribute or `xml:base`, `details.attribute` names it |
|
|
260
|
+
| `LOGO_INCLUDES_REFERENCE` | An external reference, `details.link` has the value |
|
|
261
|
+
| `LOGO_INVALID_CONTENT` | A processing instruction, unexpected markup in text or comments, nesting |
|
|
175
262
|
|
|
176
263
|
## Example Output
|
|
177
264
|
|
|
@@ -192,8 +279,8 @@ These headers should be added to messages after successful BIMI validation. The
|
|
|
192
279
|
},
|
|
193
280
|
"location": "https://example.com/bimi/logo.svg",
|
|
194
281
|
"authority": "https://example.com/bimi/vmc.pem",
|
|
195
|
-
"preference": "
|
|
196
|
-
"rr": "v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/vmc.pem;
|
|
282
|
+
"preference": "brand",
|
|
283
|
+
"rr": "v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/vmc.pem; avp=brand",
|
|
197
284
|
"info": "bimi=pass header.selector=default header.d=example.com policy.authority=none policy.authority-uri=https://example.com/bimi/vmc.pem"
|
|
198
285
|
}
|
|
199
286
|
```
|
|
@@ -283,8 +370,20 @@ These headers should be added to messages after successful BIMI validation. The
|
|
|
283
370
|
},
|
|
284
371
|
"headers": {
|
|
285
372
|
"indicator": "BIMI-Indicator: PHN2ZyB4bWxucz0i...",
|
|
286
|
-
"location": "BIMI-Location: v=BIMI1; l=https://example.com/bimi/logo.svg",
|
|
287
|
-
"preference": "BIMI-Logo-Preference:
|
|
288
|
-
}
|
|
373
|
+
"location": "BIMI-Location: v=BIMI1; l=https://example.com/bimi/logo.svg; a=https://example.com/bimi/vmc.pem",
|
|
374
|
+
"preference": "BIMI-Logo-Preference: avp=brand"
|
|
375
|
+
},
|
|
376
|
+
"status": {
|
|
377
|
+
"result": "pass",
|
|
378
|
+
"header": {
|
|
379
|
+
"selector": "default",
|
|
380
|
+
"d": "example.com"
|
|
381
|
+
},
|
|
382
|
+
"policy": {
|
|
383
|
+
"authority": "pass",
|
|
384
|
+
"authority-uri": "https://example.com/bimi/vmc.pem"
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
"info": "bimi=pass policy.authority=pass policy.authority-uri=\"https://example.com/bimi/vmc.pem\" header.selector=default header.d=example.com"
|
|
289
388
|
}
|
|
290
389
|
```
|
package/docs/dkim.md
CHANGED
|
@@ -67,7 +67,7 @@ Each entry in the `results` array has the following structure:
|
|
|
67
67
|
| ------------ | --------------- | --------------- | ----------------------------------------------------------------------------------------------------- |
|
|
68
68
|
| `result` | `string` | Always | Verification result code (see below) |
|
|
69
69
|
| `comment` | `string` | On error/info | Human-readable explanation |
|
|
70
|
-
| `aligned` | `string\|false` | DKIM signatures | DMARC-aligned domain, or false
|
|
70
|
+
| `aligned` | `string\|false` | DKIM signatures | DMARC-aligned domain, or false (also when From does not yield a single Author Domain) |
|
|
71
71
|
| `header` | `object` | Always | Signature header info |
|
|
72
72
|
| `policy` | `object` | Policy result | Policy violation details |
|
|
73
73
|
| `underSized` | `number` | Body limited | Number of unsigned bytes |
|
|
@@ -99,46 +99,47 @@ Each entry in the `results` array has the following structure:
|
|
|
99
99
|
|
|
100
100
|
Common values for `status.comment`:
|
|
101
101
|
|
|
102
|
-
| Comment | Description
|
|
103
|
-
| --------------------------------------------------- |
|
|
104
|
-
| `"body hash did not verify"` | Calculated body hash does not match `bh=` tag
|
|
105
|
-
| `"bad signature"` | Cryptographic signature verification failed
|
|
106
|
-
| `"invalid expiration"` | Expiration timestamp is before signing timestamp
|
|
107
|
-
| `"expired"` | Signature has expired (past `x=` timestamp)
|
|
108
|
-
| `"no key"` | No DKIM key found in DNS
|
|
109
|
-
| `"unknown key version"` | Unsupported key version in DNS record
|
|
110
|
-
| `"unknown key type"` | Unsupported key type in DNS record
|
|
111
|
-
| `"invalid public key"` | Public key in DNS record is malformed, or revoked (empty `p=`)
|
|
112
|
-
| `"inappropriate hash algorithm"` | The key record `h=` tag does not list the hash algorithm of the signature (both modes)
|
|
113
|
-
| `"key not for email"` | The key record `s=` tag does not list `email` or `*` (both modes)
|
|
114
|
-
| `"From field not signed"` | The `h=` tag does not include From (both modes, RFC 6376 section 6.1.1)
|
|
115
|
-
| `"weak algorithm"` | Strict mode or `rejectRsaSha1`: rsa-sha1 signature (RFC 8301)
|
|
116
|
-
| `"signature syntax error"` | Strict mode: the signature is not valid RFC 6376 syntax
|
|
117
|
-
| `"signature missing required tag"` | Strict mode: `v=`, `a=`, `b=`, `bh=`, `d=`, `h=` or `s=` is missing
|
|
118
|
-
| `"incompatible version"` | Strict mode: `v=` is not 1
|
|
119
|
-
| `"domain mismatch"` | Strict mode: the `i=` domain is not `d=` or its subdomain, or is a subdomain while the key has `t=s`
|
|
120
|
-
| `"unsupported query method"` | Strict mode: `q=` does not list `dns/txt`
|
|
121
|
-
| `"inappropriate key algorithm"` |
|
|
122
|
-
| `"unknown algorithm"`, `"unknown canonicalization"` | Strict mode: the signature can not be processed
|
|
123
|
-
| `"DNS failure: {code}"` | DNS lookup failed with error code
|
|
124
|
-
| `"message not signed"` | No DKIM-Signature headers found
|
|
102
|
+
| Comment | Description |
|
|
103
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
104
|
+
| `"body hash did not verify"` | Calculated body hash does not match `bh=` tag |
|
|
105
|
+
| `"bad signature"` | Cryptographic signature verification failed |
|
|
106
|
+
| `"invalid expiration"` | Expiration timestamp is before signing timestamp |
|
|
107
|
+
| `"expired"` | Signature has expired (past `x=` timestamp) |
|
|
108
|
+
| `"no key"` | No DKIM key found in DNS, or the key record name can not exist in the DNS (an empty label, a label over 63 octets or a name over 253 octets), which is not queried |
|
|
109
|
+
| `"unknown key version"` | Unsupported key version in DNS record |
|
|
110
|
+
| `"unknown key type"` | Unsupported key type in DNS record |
|
|
111
|
+
| `"invalid public key"` | Public key in DNS record is malformed, or revoked (empty `p=`) |
|
|
112
|
+
| `"inappropriate hash algorithm"` | The key record `h=` tag does not list the hash algorithm of the signature (both modes) |
|
|
113
|
+
| `"key not for email"` | The key record `s=` tag does not list `email` or `*` (both modes) |
|
|
114
|
+
| `"From field not signed"` | The `h=` tag does not include From (both modes, RFC 6376 section 6.1.1) |
|
|
115
|
+
| `"weak algorithm"` | Strict mode or `rejectRsaSha1`: rsa-sha1 signature (RFC 8301) |
|
|
116
|
+
| `"signature syntax error"` | Strict mode: the signature is not valid RFC 6376 syntax. Both modes: `d=` has a character that can not be in a host name, such as `/`, `?`, `#`, `@` or `:` |
|
|
117
|
+
| `"signature missing required tag"` | Strict mode: `v=`, `a=`, `b=`, `bh=`, `d=`, `h=` or `s=` is missing |
|
|
118
|
+
| `"incompatible version"` | Strict mode: `v=` is not 1 |
|
|
119
|
+
| `"domain mismatch"` | Strict mode: the `i=` domain is not `d=` or its subdomain, or is a subdomain while the key has `t=s` |
|
|
120
|
+
| `"unsupported query method"` | Strict mode: `q=` does not list `dns/txt` |
|
|
121
|
+
| `"inappropriate key algorithm"` | The key type does not match the signature algorithm (both modes) |
|
|
122
|
+
| `"unknown algorithm"`, `"unknown canonicalization"` | Strict mode: the signature can not be processed |
|
|
123
|
+
| `"DNS failure: {code}"` | DNS lookup failed with error code |
|
|
124
|
+
| `"message not signed"` | No DKIM-Signature headers found |
|
|
125
125
|
|
|
126
126
|
## Warnings
|
|
127
127
|
|
|
128
128
|
In the default mode `status.warnings` lists what was accepted although strict mode would have rejected it. The array is only present when there is something to report, and it is never written into the Authentication-Results text.
|
|
129
129
|
|
|
130
|
-
| Warning | Meaning
|
|
131
|
-
| ---------------------------------------------------- |
|
|
132
|
-
| `rsa-sha1` | The signature uses rsa-sha1, which RFC 8301 section 3.1 does not allow. A domain can refuse it with `h=sha256` in its key record, which applies in both modes
|
|
133
|
-
| `tag-syntax` | Duplicate tags, upper case tag names, a malformed `t=`, `x=` or `l=` value, or a `d=` or `s=` that is not a domain name
|
|
134
|
-
| `missing-v`, `missing-h` (and the other `missing-*`) | A required tag is missing. Without `h=` the default header list is used, which includes From
|
|
135
|
-
| `invalid-v` | `v=` is not `1`
|
|
136
|
-
| `identity-domain` | The `i=` domain is not `d=` or its subdomain, or it is a subdomain while the key record has `t=s`
|
|
137
|
-
| `invalid-expiration` | `x=` is equal to `t=`. An `x=` before `t=` is `neutral (invalid expiration)` in both modes
|
|
138
|
-
| `query-method` | `q=` does not list `dns/txt`
|
|
139
|
-
| `key-syntax` | The key record is not valid tag-list syntax
|
|
140
|
-
| `key-v-syntax` | The key record `v=` is not the first tag, or not exactly `DKIM1`
|
|
141
|
-
| `key-type-inferred` | An Ed25519 key was recognized from its length, the key record has no `k=ed25519`
|
|
130
|
+
| Warning | Meaning |
|
|
131
|
+
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| `rsa-sha1` | The signature uses rsa-sha1, which RFC 8301 section 3.1 does not allow. A domain can refuse it with `h=sha256` in its key record, which applies in both modes |
|
|
133
|
+
| `tag-syntax` | Duplicate tags, upper case tag names, a malformed `c=`, `t=`, `x=` or `l=` value, an `l=` larger than the canonicalized body, or a `d=` or `s=` that is not a domain name, or that makes the key record name longer than 253 octets. A `d=` with a character that can not be in a host name is `neutral (signature syntax error)` in both modes |
|
|
134
|
+
| `missing-v`, `missing-h` (and the other `missing-*`) | A required tag is missing. Without `h=` the default header list is used, which includes From |
|
|
135
|
+
| `invalid-v` | `v=` is not `1` |
|
|
136
|
+
| `identity-domain` | The `i=` domain is not `d=` or its subdomain, or it is a subdomain while the key record has `t=s` |
|
|
137
|
+
| `invalid-expiration` | `x=` is equal to `t=`. An `x=` before `t=` is `neutral (invalid expiration)` in both modes |
|
|
138
|
+
| `query-method` | `q=` does not list `dns/txt` |
|
|
139
|
+
| `key-syntax` | The key record is not valid tag-list syntax, or a `k=`, `s=` or `t=` value has whitespace inside it, which is removed |
|
|
140
|
+
| `key-v-syntax` | The key record `v=` is not the first tag, or not exactly `DKIM1` |
|
|
141
|
+
| `key-type-inferred` | An Ed25519 key was recognized from its length, the key record has no `k=ed25519` |
|
|
142
|
+
| `key-ed25519-spki` | An Ed25519 key record has the key as a SubjectPublicKeyInfo structure instead of the bare 32 octet key (RFC 8463 section 4.2). Strict mode reports `invalid public key` |
|
|
142
143
|
|
|
143
144
|
A negative `l=` value is read as no limit in the default mode, so the whole body is covered and nothing is left unsigned. Strict mode reports it as a syntax error.
|
|
144
145
|
|
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.
|
package/docs/dmarc.md
CHANGED
|
@@ -18,7 +18,7 @@ const { dmarc } = await authenticate(message, {
|
|
|
18
18
|
});
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The author domain is the domain of the addr-spec in the From header (RFC 9989 5.3.1, RFC 5322 3.4.1). An "@" inside a quoted local-part (`"ceo@x"@bank.example`) or an obsolete source route (`<@relay.example:ceo@bank.example>`) does not change it. It is lower-cased, converted to an A-label, and a trailing dot is ignored. Several From addresses are evaluated when they all have the same domain. When there is no domain, or more than one, or a mailbox has no usable domain, or the message has more than one From header field, DMARC validation is not possible: `dmarc()` returns `false` and `authenticate()` returns `dmarc: false`, as for a message without a From address. The lower level `evaluateDmarc()` from `mailauth/lib/dmarc` also returns `reason` (`"no-author-domain"`, `"multiple-author-domains"`, `"invalid-author-domain"` or `"multiple-from-fields"`) and `authorDomains` in that case. RFC 9989 11.5 warns that such messages are often abusive, so a receiver should handle them by local policy instead of treating them as mail without DMARC.
|
|
21
|
+
The author domain is the domain of the addr-spec in the From header (RFC 9989 5.3.1, RFC 5322 3.4.1). An "@" inside a quoted local-part (`"ceo@x"@bank.example`) or an obsolete source route (`<@relay.example:ceo@bank.example>`) does not change it. The From header is parsed by the RFC 5322 grammar, including the obsolete syntax of section 4, so whitespace or a comment between the atoms of the domain is removed (`ceo@bank (x).example` is `bank.example`). A From header that does not follow the grammar, for example text after the angle-addr or two addresses without a comma between them (`<bob@evil.example> alice@bank.example`), has no reliable author domain and gives `invalid-author-domain`. The default mode accepts a few common deviations and lists `"from-syntax"` in `warnings`: an unquoted comma in a display name (`Doe, John <john@example.com>`), a group without the closing `;`, a domain with a trailing dot, a domain literal in a display name, and an address used as a display name (`john@example.com <john@example.com>`, where both addresses count as author addresses). Strict mode treats these as `invalid-author-domain`. It is lower-cased, converted to an A-label, and a trailing dot is ignored. A domain with whitespace, a control character or an invisible format character (such as U+200B or the soft hyphen, but not the U+200C and U+200D joiners) is not a valid domain name and gives `invalid-author-domain`. Several From addresses are evaluated when they all have the same domain. When there is no domain, or more than one, or a mailbox has no usable domain, or the message has more than one From header field, DMARC validation is not possible: `dmarc()` returns `false` and `authenticate()` returns `dmarc: false`, as for a message without a From address. The lower level `evaluateDmarc()` from `mailauth/lib/dmarc` also returns `reason` (`"no-author-domain"`, `"multiple-author-domains"`, `"invalid-author-domain"` or `"multiple-from-fields"`) and `authorDomains` in that case. RFC 9989 11.5 warns that such messages are often abusive, so a receiver should handle them by local policy instead of treating them as mail without DMARC.
|
|
22
22
|
|
|
23
23
|
```javascript
|
|
24
24
|
const { dmarc } = require('mailauth/lib/dmarc');
|
|
@@ -27,6 +27,7 @@ const result = await dmarc({
|
|
|
27
27
|
headerFrom: 'user@example.com', // or a list of From addresses
|
|
28
28
|
spfDomains: ['example.com'], // the MAIL FROM domain, if SPF passed
|
|
29
29
|
dkimDomains: [{ domain: 'example.com' }], // signing domains of passing DKIM signatures
|
|
30
|
+
fromSyntax: 'valid', // optional, the From header syntax ('valid', 'lax' or 'invalid'), see above
|
|
30
31
|
strict: false, // true for the RFC tag-list rules and no header.d, see below
|
|
31
32
|
resolver // optional, defaults to dns.promises.resolve
|
|
32
33
|
});
|
|
@@ -37,6 +38,8 @@ const result = await dmarc({
|
|
|
37
38
|
With `strict: true`, which `authenticate(message, { strict: true })` passes on:
|
|
38
39
|
|
|
39
40
|
- Records are parsed by the tag-list rules of RFC 6376 3.2 that DMARC uses (RFC 9989 4.7). Tag names are case sensitive, so `P=reject` is an unknown tag (only the version tag may be `V`, which the DMARC ABNF allows), and a record with a duplicated tag is invalid and ignored, as if it was not published. By default tag names are case-folded and the last of duplicated tags wins, and the result lists `"tag-case"` or `"duplicate-tag"` in `warnings`.
|
|
41
|
+
- A record must start with the `v` tag (RFC 9989 4.10), so one with whitespace before it is discarded. The default mode uses it and lists `"record-whitespace"` in `warnings`.
|
|
42
|
+
- A From header that only the lenient parsing accepts (see above) gives `invalid-author-domain` instead of the `"from-syntax"` warning.
|
|
40
43
|
- `status.header.d` is not set and `header.d` is left out of the Authentication-Results entry, because it is not a registered property for `dmarc` (RFC 9989 9.1). `policyDomain` still has the domain.
|
|
41
44
|
|
|
42
45
|
## Result Object Fields
|