mailauth 6.0.0 → 7.1.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +113 -39
- package/bin/mailauth.js +15 -6
- package/cli.md +9 -3
- package/docs/arc.md +126 -43
- package/docs/bimi.md +54 -37
- package/docs/dkim.md +79 -40
- package/docs/dmarc.md +80 -33
- package/docs/mta-sts.md +140 -42
- package/docs/spf.md +69 -4
- package/index.d.ts +508 -43
- package/lib/arc/index.d.ts +10 -8
- package/lib/arc/index.js +505 -124
- package/lib/bimi/index.js +162 -81
- package/lib/commands/report.js +3 -1
- package/lib/commands/seal.js +35 -5
- package/lib/commands/sign.js +8 -1
- package/lib/commands/spf.js +8 -0
- package/lib/dkim/body/relaxed.js +6 -5
- package/lib/dkim/body/simple.js +1 -3
- package/lib/dkim/dkim-signer.js +219 -33
- package/lib/dkim/dkim-verifier.js +577 -231
- package/lib/dkim/header/relaxed.js +6 -43
- package/lib/dkim/header/signature-opts.js +87 -0
- package/lib/dkim/header/simple.js +6 -43
- package/lib/dkim/message-parser.js +18 -4
- package/lib/dkim/sign.d.ts +13 -7
- package/lib/dkim/sign.js +18 -2
- package/lib/dkim/syntax.js +31 -0
- package/lib/dkim/verify.js +1 -0
- package/lib/dmarc/author-domain.js +181 -0
- package/lib/dmarc/get-dmarc-record.js +154 -47
- package/lib/dmarc/index.d.ts +29 -2
- package/lib/dmarc/verify.js +120 -44
- package/lib/mailauth.js +77 -36
- package/lib/mta-sts.d.ts +77 -11
- package/lib/mta-sts.js +497 -182
- package/lib/parse-dkim-headers.js +376 -191
- package/lib/parse-received.js +104 -32
- package/lib/spf/index.d.ts +37 -1
- package/lib/spf/index.js +260 -28
- package/lib/spf/macro.js +50 -13
- package/lib/spf/spf-verify.js +440 -204
- package/lib/spf/syntax.js +366 -0
- package/lib/tools.js +522 -116
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const {
|
|
4
|
+
const { formatRelaxedLine, stripSignatureValue } = require('../../../lib/tools');
|
|
5
|
+
const { buildSignatureOpts } = require('./signature-opts');
|
|
5
6
|
|
|
6
7
|
// generate headers for signing
|
|
7
8
|
const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
8
|
-
let { signatureHeaderLine,
|
|
9
|
-
options || {};
|
|
9
|
+
let { signatureHeaderLine, strict } = options || {};
|
|
10
10
|
let chunks = [];
|
|
11
11
|
|
|
12
12
|
for (let signedHeaderLine of signingHeaderLines.headers) {
|
|
@@ -16,48 +16,11 @@ const relaxedHeaders = (type, signingHeaderLines, options) => {
|
|
|
16
16
|
let opts = false;
|
|
17
17
|
|
|
18
18
|
if (!signatureHeaderLine) {
|
|
19
|
-
opts =
|
|
20
|
-
a: algorithm,
|
|
21
|
-
c: canonicalization,
|
|
22
|
-
s: selector,
|
|
23
|
-
d: signingDomain,
|
|
24
|
-
h: signingHeaderLines.keys,
|
|
25
|
-
bh: bodyHash
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
if (typeof bodyHashedBytes === 'number') {
|
|
29
|
-
opts.l = bodyHashedBytes;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (instance) {
|
|
33
|
-
// ARC only
|
|
34
|
-
opts.i = instance;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// The same opts object formats the line that gets signed here and, once the
|
|
38
|
-
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
39
|
-
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
40
|
-
// boundary falling between the two reads signs one timestamp and publishes another
|
|
41
|
-
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
42
|
-
|
|
43
|
-
if (expires) {
|
|
44
|
-
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
signatureHeaderLine = formatSignatureHeaderLine(
|
|
48
|
-
type,
|
|
49
|
-
Object.assign(
|
|
50
|
-
{
|
|
51
|
-
// make sure that b= always has a value, otherwise folding would be different
|
|
52
|
-
b: signature || 'a'.repeat(73)
|
|
53
|
-
},
|
|
54
|
-
opts
|
|
55
|
-
),
|
|
56
|
-
true
|
|
57
|
-
);
|
|
19
|
+
({ opts, signatureHeaderLine } = buildSignatureOpts(type, signingHeaderLines, options));
|
|
58
20
|
}
|
|
59
21
|
|
|
60
|
-
|
|
22
|
+
// the strict mode reads the tag names of a signature header case-sensitively
|
|
23
|
+
chunks.push(stripSignatureValue(formatRelaxedLine(signatureHeaderLine), strict));
|
|
61
24
|
|
|
62
25
|
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
|
|
63
26
|
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { formatSignatureHeaderLine, getCurTime } = require('../../tools');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the tags of a signature header that is being created, and the header line that the
|
|
7
|
+
* header canonicalization hashes for it. Shared by the relaxed and the simple canonicalization.
|
|
8
|
+
*
|
|
9
|
+
* @param {String} type 'DKIM', 'ARC' or 'AS'
|
|
10
|
+
* @param {Object} signingHeaderLines { keys } as returned by getSigningHeaderLines()
|
|
11
|
+
* @param {Object} options The options of the canonicalization
|
|
12
|
+
* @returns {Object} { opts, signatureHeaderLine }
|
|
13
|
+
*/
|
|
14
|
+
const buildSignatureOpts = (type, signingHeaderLines, options) => {
|
|
15
|
+
let {
|
|
16
|
+
signingDomain,
|
|
17
|
+
selector,
|
|
18
|
+
algorithm,
|
|
19
|
+
canonicalization,
|
|
20
|
+
bodyHash,
|
|
21
|
+
signTime,
|
|
22
|
+
signature,
|
|
23
|
+
instance,
|
|
24
|
+
identity,
|
|
25
|
+
bodyHashedBytes,
|
|
26
|
+
expires,
|
|
27
|
+
timestamp,
|
|
28
|
+
expiration
|
|
29
|
+
} = options || {};
|
|
30
|
+
|
|
31
|
+
let opts = {
|
|
32
|
+
a: algorithm,
|
|
33
|
+
c: canonicalization,
|
|
34
|
+
s: selector,
|
|
35
|
+
d: signingDomain,
|
|
36
|
+
h: signingHeaderLines.keys,
|
|
37
|
+
bh: bodyHash
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (typeof bodyHashedBytes === 'number') {
|
|
41
|
+
opts.l = bodyHashedBytes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (instance && type === 'ARC') {
|
|
45
|
+
// ARC only, the i= of an ARC-Message-Signature is the instance number
|
|
46
|
+
opts.i = instance;
|
|
47
|
+
} else if (identity && type === 'DKIM') {
|
|
48
|
+
// DKIM only, the i= of a DKIM-Signature is the AUID
|
|
49
|
+
opts.i = identity;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The same opts object formats the line that gets signed here and, once the
|
|
53
|
+
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
54
|
+
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
55
|
+
// boundary falling between the two reads signs one timestamp and publishes another.
|
|
56
|
+
// `timestamp` and `expiration` are the values the signer already validated, false
|
|
57
|
+
// leaves the tag out (an explicit null keeps formatSignatureHeaderLine from adding
|
|
58
|
+
// a t= of its own). Callers that do not pass them get the values from signTime and
|
|
59
|
+
// expires
|
|
60
|
+
if (typeof timestamp === 'number' || timestamp === false) {
|
|
61
|
+
opts.t = timestamp === false ? null : timestamp;
|
|
62
|
+
} else {
|
|
63
|
+
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof expiration === 'number') {
|
|
67
|
+
opts.x = expiration;
|
|
68
|
+
} else if (expiration !== false && expires) {
|
|
69
|
+
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let signatureHeaderLine = formatSignatureHeaderLine(
|
|
73
|
+
type,
|
|
74
|
+
Object.assign(
|
|
75
|
+
{
|
|
76
|
+
// make sure that b= always has a value, otherwise folding would be different
|
|
77
|
+
b: signature || 'a'.repeat(73)
|
|
78
|
+
},
|
|
79
|
+
opts
|
|
80
|
+
),
|
|
81
|
+
true
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return { opts, signatureHeaderLine };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
module.exports = { buildSignatureOpts };
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { Buffer } = require('node:buffer');
|
|
4
|
-
const {
|
|
4
|
+
const { stripSignatureValue } = require('../../../lib/tools');
|
|
5
|
+
const { buildSignatureOpts } = require('./signature-opts');
|
|
5
6
|
|
|
6
7
|
const formatSimpleLine = (line, suffix) => Buffer.from(line.toString('binary') + (suffix ? suffix : ''), 'binary');
|
|
7
8
|
|
|
8
9
|
// generate headers for signing
|
|
9
10
|
const simpleHeaders = (type, signingHeaderLines, options) => {
|
|
10
|
-
let { signatureHeaderLine,
|
|
11
|
-
options || {};
|
|
11
|
+
let { signatureHeaderLine, strict } = options || {};
|
|
12
12
|
let chunks = [];
|
|
13
13
|
|
|
14
14
|
for (let signedHeaderLine of signingHeaderLines.headers) {
|
|
@@ -18,48 +18,11 @@ const simpleHeaders = (type, signingHeaderLines, options) => {
|
|
|
18
18
|
let opts = false;
|
|
19
19
|
|
|
20
20
|
if (!signatureHeaderLine) {
|
|
21
|
-
opts =
|
|
22
|
-
a: algorithm,
|
|
23
|
-
c: canonicalization,
|
|
24
|
-
s: selector,
|
|
25
|
-
d: signingDomain,
|
|
26
|
-
h: signingHeaderLines.keys,
|
|
27
|
-
bh: bodyHash
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
if (typeof bodyHashedBytes === 'number') {
|
|
31
|
-
opts.l = bodyHashedBytes;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (instance) {
|
|
35
|
-
// ARC only (should never happen thoug as simple algo is not allowed)
|
|
36
|
-
opts.i = instance;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// The same opts object formats the line that gets signed here and, once the
|
|
40
|
-
// signature exists, the line that is emitted. Leaving t= out of it lets
|
|
41
|
-
// formatSignatureHeaderLine read the clock again for the second one, and a second
|
|
42
|
-
// boundary falling between the two reads signs one timestamp and publishes another
|
|
43
|
-
opts.t = Math.floor(getCurTime(signTime).getTime() / 1000);
|
|
44
|
-
|
|
45
|
-
if (expires) {
|
|
46
|
-
opts.x = Math.floor(getCurTime(expires).getTime() / 1000);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
signatureHeaderLine = formatSignatureHeaderLine(
|
|
50
|
-
type,
|
|
51
|
-
Object.assign(
|
|
52
|
-
{
|
|
53
|
-
// make sure that b= has a value, otherwise folding would be different
|
|
54
|
-
b: signature || 'a'.repeat(73)
|
|
55
|
-
},
|
|
56
|
-
opts
|
|
57
|
-
),
|
|
58
|
-
true
|
|
59
|
-
);
|
|
21
|
+
({ opts, signatureHeaderLine } = buildSignatureOpts(type, signingHeaderLines, options));
|
|
60
22
|
}
|
|
61
23
|
|
|
62
|
-
|
|
24
|
+
// the strict mode reads the tag names of a signature header case-sensitively
|
|
25
|
+
chunks.push(stripSignatureValue(formatSimpleLine(signatureHeaderLine), strict));
|
|
63
26
|
|
|
64
27
|
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts };
|
|
65
28
|
};
|
|
@@ -20,6 +20,8 @@ class MessageParser extends Writable {
|
|
|
20
20
|
|
|
21
21
|
this.state = 'header';
|
|
22
22
|
this.stateBytes = [];
|
|
23
|
+
// bytes of the header section seen so far
|
|
24
|
+
this.headerBytes = 0;
|
|
23
25
|
|
|
24
26
|
this.headers = false;
|
|
25
27
|
this.headerChunks = [];
|
|
@@ -61,12 +63,22 @@ class MessageParser extends Writable {
|
|
|
61
63
|
if (this.stateBytes.length > 4) {
|
|
62
64
|
this.stateBytes = this.stateBytes.slice(-4);
|
|
63
65
|
}
|
|
66
|
+
this.headerBytes++;
|
|
64
67
|
|
|
65
68
|
let b0 = this.stateBytes[this.stateBytes.length - 1];
|
|
66
69
|
let b1 = this.stateBytes.length > 1 && this.stateBytes[this.stateBytes.length - 2];
|
|
67
70
|
let b2 = this.stateBytes.length > 2 && this.stateBytes[this.stateBytes.length - 3];
|
|
68
71
|
|
|
69
|
-
if (
|
|
72
|
+
if (
|
|
73
|
+
b0 === 0x0a &&
|
|
74
|
+
(b1 === 0x0a ||
|
|
75
|
+
(b1 === 0x0d && b2 === 0x0a) ||
|
|
76
|
+
// RFC 5322 section 2.1: an empty line ends the header section, and when
|
|
77
|
+
// the very first line is empty the header section is empty. Everything
|
|
78
|
+
// after it is body, even if it looks like header fields
|
|
79
|
+
this.headerBytes === 1 ||
|
|
80
|
+
(this.headerBytes === 2 && b1 === 0x0d))
|
|
81
|
+
) {
|
|
70
82
|
// found header ending
|
|
71
83
|
if (i === chunk.length - 1) {
|
|
72
84
|
//end of chunk
|
|
@@ -88,13 +100,14 @@ class MessageParser extends Writable {
|
|
|
88
100
|
await this.nextChunk(chunk);
|
|
89
101
|
}
|
|
90
102
|
|
|
103
|
+
// Rewrites every bare <LF> to <CRLF>. `lastByte` has to follow every byte, the <LF>s
|
|
104
|
+
// included: an <LF> directly after a <CRLF> is a bare one too, and it was left as it was
|
|
105
|
+
// when only the other bytes were tracked
|
|
91
106
|
*ensureLinebreaks(input) {
|
|
92
107
|
let pos = 0;
|
|
93
108
|
for (let i = 0; i < input.length; i++) {
|
|
94
109
|
let c = input[i];
|
|
95
|
-
if (c !==
|
|
96
|
-
this.lastByte = c;
|
|
97
|
-
} else if (this.lastByte !== 0x0d) {
|
|
110
|
+
if (c === 0x0a && this.lastByte !== 0x0d) {
|
|
98
111
|
// emit line break
|
|
99
112
|
let buf;
|
|
100
113
|
if (i === 0 || pos === i) {
|
|
@@ -106,6 +119,7 @@ class MessageParser extends Writable {
|
|
|
106
119
|
|
|
107
120
|
pos = i + 1;
|
|
108
121
|
}
|
|
122
|
+
this.lastByte = c;
|
|
109
123
|
}
|
|
110
124
|
if (pos === 0) {
|
|
111
125
|
yield input;
|
package/lib/dkim/sign.d.ts
CHANGED
|
@@ -3,31 +3,37 @@
|
|
|
3
3
|
/// <reference types="node" />
|
|
4
4
|
|
|
5
5
|
import { Transform } from 'stream';
|
|
6
|
-
import { MessageInput, DKIMSignOptions, DKIMSignResult } from '../../index';
|
|
6
|
+
import { MessageInput, DKIMSignOptions, DKIMSignResult, DKIMSignError, DKIMSignWarning } from '../../index';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Signs an email message with DKIM signature(s)
|
|
10
10
|
*
|
|
11
11
|
* @param input - RFC822 formatted message (stream, buffer, or string)
|
|
12
|
-
* @param options - DKIM signing options
|
|
13
|
-
* @returns DKIM signature header(s)
|
|
12
|
+
* @param options - DKIM signing options, including `strict`
|
|
13
|
+
* @returns DKIM signature header(s), an empty string if none was created, plus errors and warnings
|
|
14
14
|
*/
|
|
15
15
|
export function dkimSign(input: MessageInput, options: DKIMSignOptions): Promise<DKIMSignResult>;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Transform stream for DKIM signing
|
|
19
|
-
* Prepends DKIM-Signature header to the message stream
|
|
19
|
+
* Prepends the DKIM-Signature header(s) to the message stream. When no signature could be
|
|
20
|
+
* created the message is passed through unchanged
|
|
20
21
|
*/
|
|
21
22
|
export class DkimSignStream extends Transform {
|
|
22
23
|
/**
|
|
23
24
|
* Creates a DKIM signing stream
|
|
24
25
|
*
|
|
25
|
-
* @param options - DKIM signing options
|
|
26
|
+
* @param options - DKIM signing options, including `strict`
|
|
26
27
|
*/
|
|
27
28
|
constructor(options: DKIMSignOptions);
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
*
|
|
31
|
+
* The signatures that could not be created, set when the stream ends
|
|
31
32
|
*/
|
|
32
|
-
errors:
|
|
33
|
+
errors: DKIMSignError[] | null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What was signed although strict mode would have refused it, set when the stream ends
|
|
37
|
+
*/
|
|
38
|
+
warnings: DKIMSignWarning[] | null;
|
|
33
39
|
}
|
package/lib/dkim/sign.js
CHANGED
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
const { writeToStream } = require('../../lib/tools');
|
|
4
4
|
const { DkimSigner } = require('./dkim-signer');
|
|
5
5
|
const { Transform } = require('node:stream');
|
|
6
|
+
const { Buffer } = require('node:buffer');
|
|
7
|
+
|
|
8
|
+
// The DKIM-Signature header lines to prepend to the message, each ending with a CRLF. An
|
|
9
|
+
// empty string when no signature was created: a lone CRLF in front of the message would be
|
|
10
|
+
// the empty line that ends the header section, and turn every header of it into body
|
|
11
|
+
const formatSignatures = signatureHeaders => (signatureHeaders.length ? signatureHeaders.join('\r\n') + '\r\n' : '');
|
|
6
12
|
|
|
7
13
|
const dkimSign = async (input, options) => {
|
|
8
14
|
let dkimSigner = new DkimSigner(options);
|
|
9
15
|
await writeToStream(dkimSigner, input);
|
|
10
16
|
|
|
11
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
signatures: formatSignatures(dkimSigner.signatureHeaders),
|
|
19
|
+
arc: dkimSigner.arc,
|
|
20
|
+
errors: dkimSigner.errors,
|
|
21
|
+
warnings: dkimSigner.warnings
|
|
22
|
+
};
|
|
12
23
|
};
|
|
13
24
|
|
|
14
25
|
class DkimSignStream extends Transform {
|
|
@@ -20,6 +31,7 @@ class DkimSignStream extends Transform {
|
|
|
20
31
|
this.chunklen = 0;
|
|
21
32
|
|
|
22
33
|
this.errors = null;
|
|
34
|
+
this.warnings = null;
|
|
23
35
|
|
|
24
36
|
this.finished = false;
|
|
25
37
|
this.finishCb = null;
|
|
@@ -40,8 +52,12 @@ class DkimSignStream extends Transform {
|
|
|
40
52
|
this.finishCb = null;
|
|
41
53
|
|
|
42
54
|
this.errors = this.signer.errors;
|
|
55
|
+
this.warnings = this.signer.warnings;
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
let signatures = formatSignatures(this.signer.signatureHeaders);
|
|
58
|
+
if (signatures) {
|
|
59
|
+
this.push(Buffer.from(signatures));
|
|
60
|
+
}
|
|
45
61
|
this.push(Buffer.concat(this.chunks, this.chunklen));
|
|
46
62
|
done();
|
|
47
63
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Grammar shared by the DKIM and ARC signers and verifiers. ARC reuses the DKIM tag syntax
|
|
4
|
+
// (RFC 8617 section 4.1), so both read these from here. This module requires nothing, so
|
|
5
|
+
// that lib/tools.js can use it as well.
|
|
6
|
+
|
|
7
|
+
// RFC 6376 section 3.5 sub-domain
|
|
8
|
+
const DOMAIN_LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
|
|
9
|
+
// domain-name = sub-domain 1*("." sub-domain)
|
|
10
|
+
const DOMAIN_NAME = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})+$`);
|
|
11
|
+
// selector = sub-domain *("." sub-domain)
|
|
12
|
+
const SELECTOR = new RegExp(`^${DOMAIN_LABEL}(?:\\.${DOMAIN_LABEL})*$`);
|
|
13
|
+
// sig-t-tag and sig-x-tag are 1*12DIGIT, sig-l-tag is 1*76DIGIT
|
|
14
|
+
const TIMESTAMP = /^[0-9]{1,12}$/;
|
|
15
|
+
const BODY_LENGTH = /^[0-9]{1,76}$/;
|
|
16
|
+
|
|
17
|
+
// RFC 8617 section 4.2.1: instance values range from 1 to 50
|
|
18
|
+
const MAX_ARC_INSTANCE = 50;
|
|
19
|
+
|
|
20
|
+
// the header fields of an ARC set (RFC 8617 section 4.1)
|
|
21
|
+
const ARC_HEADER_KEYS = ['arc-seal', 'arc-message-signature', 'arc-authentication-results'];
|
|
22
|
+
|
|
23
|
+
module.exports = {
|
|
24
|
+
DOMAIN_LABEL,
|
|
25
|
+
DOMAIN_NAME,
|
|
26
|
+
SELECTOR,
|
|
27
|
+
TIMESTAMP,
|
|
28
|
+
BODY_LENGTH,
|
|
29
|
+
MAX_ARC_INSTANCE,
|
|
30
|
+
ARC_HEADER_KEYS
|
|
31
|
+
};
|
package/lib/dkim/verify.js
CHANGED
|
@@ -10,6 +10,7 @@ const dkimVerify = async (input, options) => {
|
|
|
10
10
|
const result = {
|
|
11
11
|
//headers: dkimVerifier.headers,
|
|
12
12
|
headerFrom: dkimVerifier.headerFrom,
|
|
13
|
+
fromFields: dkimVerifier.fromFields,
|
|
13
14
|
envelopeFrom: dkimVerifier.envelopeFrom,
|
|
14
15
|
results: dkimVerifier.results
|
|
15
16
|
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { formatDomain, toALabel } = require('../tools');
|
|
4
|
+
|
|
5
|
+
// Characters that can not appear in a domain name taken from an address. Anything like this
|
|
6
|
+
// means the address was malformed and the text after the "@" is not a domain.
|
|
7
|
+
const INVALID_DOMAIN_CHARS = /[\s"<>(),;:\\@[\]]/;
|
|
8
|
+
|
|
9
|
+
// A domain literal (RFC 5322 3.4.1). It is kept as is, it can not publish a DMARC record.
|
|
10
|
+
const DOMAIN_LITERAL = /^\[[^[\]\\]*\]$/;
|
|
11
|
+
|
|
12
|
+
// Removes an obsolete source route (RFC 5322 4.4 obs-angle-addr), as in "@relay.example:ceo@bank.example",
|
|
13
|
+
// where the route ends with the first ":" that is not inside a domain literal
|
|
14
|
+
const stripRoute = address => {
|
|
15
|
+
if (address.charAt(0) !== '@') {
|
|
16
|
+
return address;
|
|
17
|
+
}
|
|
18
|
+
let inLiteral = false;
|
|
19
|
+
for (let i = 1; i < address.length; i++) {
|
|
20
|
+
let c = address.charAt(i);
|
|
21
|
+
if (c === '[') {
|
|
22
|
+
inLiteral = true;
|
|
23
|
+
} else if (c === ']') {
|
|
24
|
+
inLiteral = false;
|
|
25
|
+
} else if (c === ':' && !inLiteral) {
|
|
26
|
+
return address.slice(i + 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return address;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Returns the positions of the "@" signs that separate a local-part from a domain. An "@" inside
|
|
33
|
+
// a quoted string of the local-part (RFC 5322 3.2.4) or inside a domain literal does not count.
|
|
34
|
+
// With an unterminated quoted string the quotes are not honored, so that the "@" of an address
|
|
35
|
+
// such as '"ceo@bank.example' still counts and the domain the reader sees is not dropped.
|
|
36
|
+
const findSeparators = (address, honorQuotes) => {
|
|
37
|
+
let positions = [];
|
|
38
|
+
let inQuote = false;
|
|
39
|
+
let escaped = false;
|
|
40
|
+
let inLiteral = false;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < address.length; i++) {
|
|
43
|
+
let c = address.charAt(i);
|
|
44
|
+
if (inQuote) {
|
|
45
|
+
if (escaped) {
|
|
46
|
+
escaped = false;
|
|
47
|
+
} else if (c === '\\') {
|
|
48
|
+
escaped = true;
|
|
49
|
+
} else if (c === '"') {
|
|
50
|
+
inQuote = false;
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (inLiteral) {
|
|
55
|
+
if (c === ']') {
|
|
56
|
+
inLiteral = false;
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '"' && honorQuotes && !positions.length) {
|
|
61
|
+
inQuote = true;
|
|
62
|
+
} else if (c === '[' && positions.length) {
|
|
63
|
+
inLiteral = true;
|
|
64
|
+
} else if (c === '@') {
|
|
65
|
+
positions.push(i);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (inQuote) {
|
|
70
|
+
return findSeparators(address, false);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return positions;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Normalizes the domain part of an address. Returns { domain, authorDomain } or null when it is
|
|
77
|
+
// not a domain. "domain" is lower case and an A-label (RFC 9989 5.3.1), "authorDomain" also has
|
|
78
|
+
// the trailing root dot removed and is the name that is queried and compared.
|
|
79
|
+
const normalizeDomain = value => {
|
|
80
|
+
let domain = (value || '').trim().toLowerCase();
|
|
81
|
+
if (!domain) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!DOMAIN_LITERAL.test(domain)) {
|
|
86
|
+
if (INVALID_DOMAIN_CHARS.test(domain)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
domain = toALabel(domain);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let authorDomain = formatDomain(domain);
|
|
93
|
+
if (!authorDomain) {
|
|
94
|
+
// "user@." has no domain name, only the root
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { domain, authorDomain };
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Returns the normalized domains found in one address. A valid addr-spec has one domain, the text
|
|
102
|
+
// after the "@" that ends the local-part. An address with more than one "@" outside a quoted string
|
|
103
|
+
// is malformed and every candidate is returned, so that it counts as more than one Author Domain.
|
|
104
|
+
const getAddressDomains = address => {
|
|
105
|
+
if (typeof address !== 'string') {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
address = address.trim();
|
|
110
|
+
|
|
111
|
+
if (address.indexOf('@') < 0) {
|
|
112
|
+
// not an address, but dmarc() has always accepted a bare domain name here
|
|
113
|
+
let entry = normalizeDomain(address);
|
|
114
|
+
return entry ? [entry] : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
address = stripRoute(address);
|
|
118
|
+
|
|
119
|
+
let positions = findSeparators(address, true);
|
|
120
|
+
let domains = [];
|
|
121
|
+
for (let i = 0; i < positions.length; i++) {
|
|
122
|
+
let entry = normalizeDomain(address.slice(positions[i] + 1, i + 1 < positions.length ? positions[i + 1] : address.length));
|
|
123
|
+
if (entry) {
|
|
124
|
+
domains.push(entry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return domains;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// Extracts the Author Domain from the RFC5322.From addresses (RFC 9989 5.3.1). Several mailboxes
|
|
132
|
+
// are fine as long as they all use the same domain. Returns
|
|
133
|
+
// { domain, authorDomain, mailboxes } when there is exactly one Author Domain, or
|
|
134
|
+
// { reason, domains, mailboxes } when DMARC validation is not possible, where "reason" is
|
|
135
|
+
// "no-author-domain", "multiple-author-domains", "invalid-author-domain" or
|
|
136
|
+
// "multiple-from-fields".
|
|
137
|
+
// "fromFields" is the number of From header fields the addresses came from, if known. RFC 5322
|
|
138
|
+
// allows only one, and with several the DKIM signature can cover a different field than the one
|
|
139
|
+
// a mail client shows, so their addresses are not merged into one Author Domain.
|
|
140
|
+
const getAuthorDomain = (headerFrom, fromFields) => {
|
|
141
|
+
let addresses = [].concat(headerFrom === undefined || headerFrom === null ? [] : headerFrom);
|
|
142
|
+
|
|
143
|
+
let found = new Map();
|
|
144
|
+
let invalid = false;
|
|
145
|
+
for (let address of addresses) {
|
|
146
|
+
let entries = getAddressDomains(address);
|
|
147
|
+
if (!entries.length) {
|
|
148
|
+
// a mailbox without a usable domain is not skipped, the domain a reader sees for it
|
|
149
|
+
// is unknown, so the Author Domain can not be determined
|
|
150
|
+
invalid = true;
|
|
151
|
+
}
|
|
152
|
+
for (let entry of entries) {
|
|
153
|
+
if (!found.has(entry.authorDomain)) {
|
|
154
|
+
found.set(entry.authorDomain, entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let mailboxes = addresses.length;
|
|
160
|
+
|
|
161
|
+
if (fromFields > 1) {
|
|
162
|
+
return { reason: 'multiple-from-fields', domains: Array.from(found.keys()), mailboxes };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (invalid && found.size) {
|
|
166
|
+
return { reason: 'invalid-author-domain', domains: Array.from(found.keys()), mailboxes };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (found.size === 1) {
|
|
170
|
+
let [entry] = found.values();
|
|
171
|
+
return { domain: entry.domain, authorDomain: entry.authorDomain, mailboxes };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
reason: found.size ? 'multiple-author-domains' : 'no-author-domain',
|
|
176
|
+
domains: Array.from(found.keys()),
|
|
177
|
+
mailboxes
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
module.exports = { getAuthorDomain, getAddressDomains };
|