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/lib/dkim2/key.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// DKIM2 public key lookup (draft-ietf-dkim-dkim2-dns-00 section 3.4 and
|
|
4
|
+
// draft-ietf-dkim-dkim2-spec-06 section 11.5). The records are DKIM1 key records, read with
|
|
5
|
+
// the DKIM2 rules: one TXT record per selector, the retired h=, n= and s= tags ignored, and
|
|
6
|
+
// only the rsa and ed25519 key types.
|
|
7
|
+
|
|
8
|
+
const { Buffer } = require('node:buffer');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const dns = require('node:dns').promises;
|
|
11
|
+
const { parseTagList, toALabel, isValidDnsName, isBareRsaPublicKey } = require('../tools');
|
|
12
|
+
const { isBase64 } = require('./fields');
|
|
13
|
+
|
|
14
|
+
const MIN_RSA_KEY_SIZE = 1024;
|
|
15
|
+
// Verifiers MUST handle 1024 to 2048 bits and MAY handle more (section 3.2). Longer keys are not used
|
|
16
|
+
// in practice and only make verification slower
|
|
17
|
+
const MAX_RSA_KEY_SIZE = 8192;
|
|
18
|
+
// section 3.2: "The signing algorithm MUST use a public exponent of 65537"
|
|
19
|
+
const RSA_EXPONENT = 65537n;
|
|
20
|
+
|
|
21
|
+
// DNS answers that mean there is no record. Anything else could be transient
|
|
22
|
+
const NO_RECORD_CODES = new Set(['ENOTFOUND', 'ENODATA']);
|
|
23
|
+
|
|
24
|
+
// the key type of each signing algorithm (section 8.9: the k= value has to match the algorithm)
|
|
25
|
+
const ALGORITHM_KEY_TYPES = new Map([
|
|
26
|
+
['rsa-sha256', 'rsa'],
|
|
27
|
+
['ed25519-sha256', 'ed25519']
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Section 3.2: why an RSA key can not be used, or null when it can
|
|
31
|
+
const rsaKeyProblem = keyObject => {
|
|
32
|
+
let details = keyObject.asymmetricKeyDetails;
|
|
33
|
+
if (details.modulusLength < MIN_RSA_KEY_SIZE) {
|
|
34
|
+
return 'is too short';
|
|
35
|
+
}
|
|
36
|
+
if (details.modulusLength > MAX_RSA_KEY_SIZE) {
|
|
37
|
+
return 'is too long';
|
|
38
|
+
}
|
|
39
|
+
if (details.publicExponent !== RSA_EXPONENT) {
|
|
40
|
+
return 'has an unsupported exponent';
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Sections 3.2 and 3.3: the hash name and the data for crypto.sign() and crypto.verify(). RSA
|
|
46
|
+
// PKCS#1 v1.5 hashes the input itself, Ed25519 (PureEdDSA) signs the SHA-256 hash of it
|
|
47
|
+
const signatureData = (algorithm, input) => (algorithm === 'ed25519-sha256' ? [null, crypto.createHash('sha256').update(input).digest()] : ['sha256', input]);
|
|
48
|
+
|
|
49
|
+
// RFC 8410 SubjectPublicKeyInfo prefix for a raw 32 byte Ed25519 key
|
|
50
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
51
|
+
|
|
52
|
+
class KeyError extends Error {
|
|
53
|
+
/**
|
|
54
|
+
* @param {String} result "permerror" or "temperror"
|
|
55
|
+
* @param {String} message The end of the section 11.5 error string, after "public key <selector>"
|
|
56
|
+
* @param {String} [rr] The key record
|
|
57
|
+
*/
|
|
58
|
+
constructor(result, message, rr) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.result = result;
|
|
61
|
+
if (rr) {
|
|
62
|
+
this.rr = rr;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const permerror = (reason, rr) => new KeyError('permerror', reason, rr);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fetches and parses the key record of a selector
|
|
71
|
+
*
|
|
72
|
+
* @param {String} name Key record name, "selector._domainkey.domain"
|
|
73
|
+
* @param {Function} resolver DNS resolver
|
|
74
|
+
* @returns {Object} { publicKey: KeyObject, keyType, modulusLength, testing, rr }
|
|
75
|
+
* @throws {KeyError}
|
|
76
|
+
*/
|
|
77
|
+
const fetchKey = async (name, resolver) => {
|
|
78
|
+
let lookupName = toALabel(name);
|
|
79
|
+
if (!isValidDnsName(lookupName)) {
|
|
80
|
+
throw permerror('does not exist');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let list;
|
|
84
|
+
try {
|
|
85
|
+
list = await resolver(lookupName, 'TXT');
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// section 11.5: a timeout is a TEMPERROR, an absent key a PERMERROR
|
|
88
|
+
if (NO_RECORD_CODES.has(err.code)) {
|
|
89
|
+
throw permerror('does not exist');
|
|
90
|
+
}
|
|
91
|
+
throw new KeyError('temperror', 'could not be fetched');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!Array.isArray(list) || !list.length) {
|
|
95
|
+
throw permerror('does not exist');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (list.length > 1) {
|
|
99
|
+
// draft-ietf-dkim-dkim2-dns-00 section 3.4.2.2, an error per section 11.5
|
|
100
|
+
throw permerror('has multiple records');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// the strings of a TXT record are joined with no whitespace between them
|
|
104
|
+
let rr = [].concat(list[0]).join('');
|
|
105
|
+
|
|
106
|
+
let entry = parseTagList(rr, { strict: true });
|
|
107
|
+
let tags = entry.parsed;
|
|
108
|
+
if (entry.syntaxErrors.length) {
|
|
109
|
+
throw permerror('has a syntax error', rr);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// v= is optional, but when present it is "DKIM1" and the first tag. A record with any other
|
|
113
|
+
// version is discarded
|
|
114
|
+
if (tags.v && (tags.v.value !== 'DKIM1' || entry.tags[0].name !== 'v')) {
|
|
115
|
+
throw permerror('has a syntax error', rr);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!tags.p) {
|
|
119
|
+
// p= is required
|
|
120
|
+
throw permerror('has a syntax error', rr);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!tags.p.value) {
|
|
124
|
+
// an empty p= means the key has been revoked
|
|
125
|
+
throw permerror('has been revoked', rr);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!isBase64(tags.p.value)) {
|
|
129
|
+
throw permerror('has a syntax error', rr);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// k= defaults to "rsa". Unknown key types are ignored, so they never match an algorithm
|
|
133
|
+
let keyType = tags.k ? tags.k.value : 'rsa';
|
|
134
|
+
if (!['rsa', 'ed25519'].includes(keyType)) {
|
|
135
|
+
return { publicKey: null, keyType, rr };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let rawKey = Buffer.from(tags.p.value, 'base64');
|
|
139
|
+
let publicKey;
|
|
140
|
+
try {
|
|
141
|
+
if (keyType === 'ed25519') {
|
|
142
|
+
// RFC 8463 section 4: the bare 32 byte key
|
|
143
|
+
if (rawKey.length !== 32) {
|
|
144
|
+
throw new Error('Invalid Ed25519 key length');
|
|
145
|
+
}
|
|
146
|
+
publicKey = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, rawKey]), format: 'der', type: 'spki' });
|
|
147
|
+
} else {
|
|
148
|
+
// an RSAPublicKey, or the SubjectPublicKeyInfo that is published in practice
|
|
149
|
+
publicKey = crypto.createPublicKey({ key: rawKey, format: 'der', type: isBareRsaPublicKey(rawKey) ? 'pkcs1' : 'spki' });
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw permerror('has a syntax error', rr);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (publicKey.asymmetricKeyType !== keyType) {
|
|
156
|
+
throw permerror('has a syntax error', rr);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let modulusLength;
|
|
160
|
+
if (keyType === 'rsa') {
|
|
161
|
+
modulusLength = publicKey.asymmetricKeyDetails.modulusLength;
|
|
162
|
+
let problem = rsaKeyProblem(publicKey);
|
|
163
|
+
if (problem) {
|
|
164
|
+
throw permerror(problem, rr);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// t= flags. Only "y" means anything to DKIM2, "s" is about the DKIM1 i= tag
|
|
169
|
+
let flags = String(tags.t?.value || '')
|
|
170
|
+
.split(':')
|
|
171
|
+
.map(flag => flag.trim());
|
|
172
|
+
|
|
173
|
+
return { publicKey, keyType, modulusLength, testing: flags.includes('y'), rr };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Creates a key lookup for one message, so that every signature naming the same key record
|
|
178
|
+
* shares one DNS query
|
|
179
|
+
*
|
|
180
|
+
* @param {Function} [resolver] DNS resolver, defaults to dns.promises.resolve
|
|
181
|
+
* @returns {Function} async (domain, selector, algorithm) => key info, throws KeyError
|
|
182
|
+
*/
|
|
183
|
+
const createKeyLookup = resolver => {
|
|
184
|
+
resolver = resolver || dns.resolve;
|
|
185
|
+
let cache = new Map();
|
|
186
|
+
|
|
187
|
+
return async (domain, selector, algorithm) => {
|
|
188
|
+
let name = `${selector}._domainkey.${domain}`;
|
|
189
|
+
if (!cache.has(name)) {
|
|
190
|
+
let lookup = fetchKey(name, resolver);
|
|
191
|
+
lookup.catch(() => false);
|
|
192
|
+
cache.set(name, lookup);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let key = await cache.get(name);
|
|
196
|
+
if (key.keyType !== ALGORITHM_KEY_TYPES.get(algorithm)) {
|
|
197
|
+
throw permerror('algorithm mismatch', key.rr);
|
|
198
|
+
}
|
|
199
|
+
return key;
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
module.exports = { createKeyLookup, KeyError, ALGORITHM_KEY_TYPES, rsaKeyProblem, signatureData };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One instance of a message as DKIM2 hashes it: the hashed header fields grouped by name, and
|
|
4
|
+
// the body. Applying the Recipe of a Message-Instance gives the previous instance
|
|
5
|
+
// (draft-ietf-dkim-dkim2-spec-06 sections 5, 6 and 11)
|
|
6
|
+
|
|
7
|
+
const { Buffer } = require('node:buffer');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
const { SimpleHash } = require('../dkim/body/simple');
|
|
10
|
+
const { isHashedField, canonicalHeaderField } = require('./fields');
|
|
11
|
+
const { applySteps } = require('./recipe');
|
|
12
|
+
|
|
13
|
+
// how much body text is handed to the hash at a time when a body is hashed from its lines
|
|
14
|
+
const HASH_CHUNK_SIZE = 64 * 1024;
|
|
15
|
+
|
|
16
|
+
// "d" values are JSON strings, the message is handled as 'binary' strings of its bytes
|
|
17
|
+
const utf8ToBinary = value => Buffer.from(value, 'utf8').toString('binary');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Splits a body into lines numbered top down (section 5.2), without the line terminators
|
|
21
|
+
*
|
|
22
|
+
* @param {Buffer} body Message body with CRLF line endings
|
|
23
|
+
* @returns {String[]} Lines as 'binary' strings
|
|
24
|
+
*/
|
|
25
|
+
const splitBodyLines = body => {
|
|
26
|
+
let str = body.toString('binary');
|
|
27
|
+
if (!str) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
let lines = str.split('\r\n');
|
|
31
|
+
if (str.endsWith('\r\n')) {
|
|
32
|
+
// the terminator of the last line, not an empty line after it
|
|
33
|
+
lines.pop();
|
|
34
|
+
}
|
|
35
|
+
return lines;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Section 6.1: the body hash of a list of lines, each ending with CRLF
|
|
39
|
+
const hashBodyLines = (lines, algorithm) => {
|
|
40
|
+
let hasher = new SimpleHash(algorithm);
|
|
41
|
+
let chunk = '';
|
|
42
|
+
for (let line of lines) {
|
|
43
|
+
chunk += line + '\r\n';
|
|
44
|
+
if (chunk.length >= HASH_CHUNK_SIZE) {
|
|
45
|
+
hasher.update(Buffer.from(chunk, 'binary'));
|
|
46
|
+
chunk = '';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (chunk) {
|
|
50
|
+
hasher.update(Buffer.from(chunk, 'binary'));
|
|
51
|
+
}
|
|
52
|
+
return hasher.digest('base64');
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
class MessageState {
|
|
56
|
+
/**
|
|
57
|
+
* @param {Map} headers Lower case field name to the canonicalized fields, numbered bottom up
|
|
58
|
+
* @param {Object|null} body { lines, hashes } where `lines` may be null when the body was not
|
|
59
|
+
* kept and `hashes` is a Map of algorithm to base64 hash. null for a body that can not
|
|
60
|
+
* be recreated (a null body Recipe)
|
|
61
|
+
*/
|
|
62
|
+
constructor(headers, body) {
|
|
63
|
+
this.headers = headers;
|
|
64
|
+
this.body = body;
|
|
65
|
+
this.headerHashes = new Map();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds the state of the message as it is
|
|
70
|
+
*
|
|
71
|
+
* @param {Object[]} parsedHeaders Rows from parseHeaders(), top down
|
|
72
|
+
* @param {Object} body { lines, hashes }
|
|
73
|
+
* @returns {MessageState}
|
|
74
|
+
*/
|
|
75
|
+
static fromMessage(parsedHeaders, body) {
|
|
76
|
+
let headers = new Map();
|
|
77
|
+
for (let i = parsedHeaders.length - 1; i >= 0; i--) {
|
|
78
|
+
let row = parsedHeaders[i];
|
|
79
|
+
if (!isHashedField(row.key)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!headers.has(row.key)) {
|
|
83
|
+
headers.set(row.key, []);
|
|
84
|
+
}
|
|
85
|
+
headers.get(row.key).push(canonicalHeaderField(row.line));
|
|
86
|
+
}
|
|
87
|
+
return new MessageState(headers, body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Section 6.2: the fields in alphabetical order of their names, fields with the same name
|
|
91
|
+
// from the bottom up
|
|
92
|
+
headerHash(algorithm) {
|
|
93
|
+
if (!this.headerHashes.has(algorithm)) {
|
|
94
|
+
let hash = crypto.createHash(algorithm);
|
|
95
|
+
for (let name of Array.from(this.headers.keys()).sort()) {
|
|
96
|
+
for (let line of this.headers.get(name)) {
|
|
97
|
+
hash.update(line);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
this.headerHashes.set(algorithm, hash.digest('base64'));
|
|
101
|
+
}
|
|
102
|
+
return this.headerHashes.get(algorithm);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// the body hash, or null when the body of this instance can not be recreated
|
|
106
|
+
bodyHash(algorithm) {
|
|
107
|
+
if (!this.body) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (!this.body.hashes.has(algorithm)) {
|
|
111
|
+
this.body.hashes.set(algorithm, hashBodyLines(this.body.lines, algorithm));
|
|
112
|
+
}
|
|
113
|
+
return this.body.hashes.get(algorithm);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Applies a Recipe to get the previous instance of the message
|
|
118
|
+
*
|
|
119
|
+
* @param {Object} recipe Parsed Recipe ({ h: Map, b })
|
|
120
|
+
* @returns {MessageState}
|
|
121
|
+
* @throws {Error} When the Recipe does not fit this instance
|
|
122
|
+
*/
|
|
123
|
+
previous(recipe) {
|
|
124
|
+
let headers = new Map(this.headers);
|
|
125
|
+
for (let [name, steps] of recipe.h) {
|
|
126
|
+
// section 5.1: Recipes SHOULD NOT name the fields that are not signed, and the
|
|
127
|
+
// hash does not see them anyway
|
|
128
|
+
if (!isHashedField(name)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let fields = applySteps(this.headers.get(name) || [], steps, value => canonicalHeaderField(Buffer.from(`${name}:${value}`, 'utf8')));
|
|
132
|
+
headers.set(name, fields);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let body = this.body;
|
|
136
|
+
if (recipe.b === null || (Array.isArray(recipe.b) && !body)) {
|
|
137
|
+
// the previous body can not be recreated, or it was built from one that can not
|
|
138
|
+
body = null;
|
|
139
|
+
} else if (Array.isArray(recipe.b)) {
|
|
140
|
+
if (!body.lines) {
|
|
141
|
+
throw new Error('The message body was not kept for the body Recipe');
|
|
142
|
+
}
|
|
143
|
+
body = { lines: applySteps(body.lines, recipe.b, utf8ToBinary), hashes: new Map() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return new MessageState(headers, body);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether `later` only added header fields to this instance and kept the body, which is what a
|
|
151
|
+
* donotmodify request allows (section 8.10)
|
|
152
|
+
*
|
|
153
|
+
* @param {MessageState} later A later instance of the message
|
|
154
|
+
* @param {String} algorithm Hash algorithm for comparing the bodies
|
|
155
|
+
* @returns {Boolean}
|
|
156
|
+
*/
|
|
157
|
+
isPreservedIn(later, algorithm) {
|
|
158
|
+
let bodyHash = this.bodyHash(algorithm);
|
|
159
|
+
if (!bodyHash || bodyHash !== later.bodyHash(algorithm)) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (let [name, fields] of this.headers) {
|
|
164
|
+
// every field of this instance is still there, in the same order
|
|
165
|
+
let laterFields = later.headers.get(name) || [];
|
|
166
|
+
let pos = 0;
|
|
167
|
+
for (let field of fields) {
|
|
168
|
+
while (pos < laterFields.length && !laterFields[pos].equals(field)) {
|
|
169
|
+
pos++;
|
|
170
|
+
}
|
|
171
|
+
if (pos >= laterFields.length) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
pos++;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { MessageState, splitBodyLines };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// DKIM2 Recipes (draft-ietf-dkim-dkim2-spec-06 section 5): JSON instructions in the r= tag of a
|
|
4
|
+
// Message-Instance header field that recreate the previous instance of the message from the
|
|
5
|
+
// current one. This module validates a Recipe and applies it to a message state.
|
|
6
|
+
|
|
7
|
+
// RFC 5322 field-name, the keys of the "h" object
|
|
8
|
+
const FIELD_NAME = /^[\x21-\x39\x3b-\x7e]+$/;
|
|
9
|
+
|
|
10
|
+
const isPlainObject = value => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
12
|
+
|
|
13
|
+
// Validates the steps of a header or body Recipe. Every step is an object with exactly one of
|
|
14
|
+
// "c" (copy a range) or "d" (literal data). Other keys are ignored, as section 2 requires for
|
|
15
|
+
// unrecognised JSON fields, although the schema of section 5 does not allow them
|
|
16
|
+
const validateSteps = (steps, label) => {
|
|
17
|
+
if (!Array.isArray(steps)) {
|
|
18
|
+
return `${label} is not an array`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let lastEnd = 0;
|
|
22
|
+
for (let step of steps) {
|
|
23
|
+
if (!isPlainObject(step) || own(step, 'c') === own(step, 'd')) {
|
|
24
|
+
return `${label} has a step that is not a "c" or a "d" step`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (own(step, 'c')) {
|
|
28
|
+
let range = step.c;
|
|
29
|
+
if (!Array.isArray(range) || range.length !== 2 || !range.every(value => Number.isSafeInteger(value) && value >= 1)) {
|
|
30
|
+
return `${label} has an invalid "c" range`;
|
|
31
|
+
}
|
|
32
|
+
// the start of every "c" step is after the end of all the earlier ones
|
|
33
|
+
if (range[0] > range[1] || range[0] <= lastEnd) {
|
|
34
|
+
return `${label} has "c" ranges out of order`;
|
|
35
|
+
}
|
|
36
|
+
lastEnd = range[1];
|
|
37
|
+
} else {
|
|
38
|
+
let data = step.d;
|
|
39
|
+
if (!Array.isArray(data) || !data.length || !data.every(value => typeof value === 'string' && !/[\r\n]/.test(value))) {
|
|
40
|
+
return `${label} has an invalid "d" step`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses and validates the JSON of a Recipe
|
|
50
|
+
*
|
|
51
|
+
* @param {String} json Decoded r= value
|
|
52
|
+
* @returns {Object} { value, error }. `value.h` is a Map of lower case header field name to its
|
|
53
|
+
* steps, `value.b` is the body steps, null for a body that can not be recreated, or
|
|
54
|
+
* undefined when the body did not change
|
|
55
|
+
*/
|
|
56
|
+
const parseRecipe = json => {
|
|
57
|
+
let recipe;
|
|
58
|
+
try {
|
|
59
|
+
recipe = JSON.parse(json);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return { error: 'not valid JSON' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!isPlainObject(recipe)) {
|
|
65
|
+
return { error: 'not a JSON object' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!own(recipe, 'h') && !own(recipe, 'b')) {
|
|
69
|
+
return { error: 'neither "h" nor "b" is present' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let value = { h: new Map(), b: undefined };
|
|
73
|
+
|
|
74
|
+
if (own(recipe, 'h')) {
|
|
75
|
+
if (!isPlainObject(recipe.h) || !Object.keys(recipe.h).length) {
|
|
76
|
+
return { error: '"h" is not an object with header field names' };
|
|
77
|
+
}
|
|
78
|
+
for (let name of Object.keys(recipe.h)) {
|
|
79
|
+
// section 5.1: header field names in the JSON keys MUST be in lower case
|
|
80
|
+
if (!FIELD_NAME.test(name) || name !== name.toLowerCase()) {
|
|
81
|
+
return { error: `invalid header field name ${JSON.stringify(name)}` };
|
|
82
|
+
}
|
|
83
|
+
let error = validateSteps(recipe.h[name], `"h" Recipe for ${name}`);
|
|
84
|
+
if (error) {
|
|
85
|
+
return { error };
|
|
86
|
+
}
|
|
87
|
+
value.h.set(name, recipe.h[name]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (own(recipe, 'b')) {
|
|
92
|
+
if (recipe.b !== null) {
|
|
93
|
+
let error = validateSteps(recipe.b, '"b" Recipe');
|
|
94
|
+
if (error) {
|
|
95
|
+
return { error };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
value.b = recipe.b;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { value };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Runs the steps of a Recipe against a list of items: header fields of one name numbered
|
|
106
|
+
* bottom up, or body lines numbered top down. The output keeps the same direction
|
|
107
|
+
*
|
|
108
|
+
* @param {Array} items Current items
|
|
109
|
+
* @param {Object[]} steps Validated Recipe steps
|
|
110
|
+
* @param {Function} fromData Converts a "d" string to an item
|
|
111
|
+
* @returns {Array} Items of the previous instance
|
|
112
|
+
* @throws {Error} When a "c" range goes past the last item
|
|
113
|
+
*/
|
|
114
|
+
const applySteps = (items, steps, fromData) => {
|
|
115
|
+
let output = [];
|
|
116
|
+
for (let step of steps) {
|
|
117
|
+
if (step.c) {
|
|
118
|
+
let [start, end] = step.c;
|
|
119
|
+
if (end > items.length) {
|
|
120
|
+
throw new Error(`"c" range ${start}-${end} is past the last of ${items.length}`);
|
|
121
|
+
}
|
|
122
|
+
for (let pos = start - 1; pos < end; pos++) {
|
|
123
|
+
output.push(items[pos]);
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
for (let data of step.d) {
|
|
127
|
+
output.push(fromData(data));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return output;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
module.exports = { parseRecipe, applySteps };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { writeToStream } = require('../tools');
|
|
4
|
+
const { DkimSignStream, formatSignatures } = require('../dkim/sign');
|
|
5
|
+
const { Dkim2Signer } = require('./dkim2-signer');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Signs a message with DKIM2: adds a DKIM2-Signature header field and, when needed, a
|
|
9
|
+
* Message-Instance header field (draft-ietf-dkim-dkim2-spec-06 section 9)
|
|
10
|
+
*
|
|
11
|
+
* @param {ReadableStream|Buffer|String} input RFC 5322 message
|
|
12
|
+
* @param {Object} options Signing options, see README
|
|
13
|
+
* @returns {Object} { signatures, signature, messageInstance, i, m }. `signatures` holds the
|
|
14
|
+
* header lines to prepend to the message, each ending with CRLF
|
|
15
|
+
* @throws {Error} For invalid options, an invalid DKIM2 chain on the message, or a recipe that
|
|
16
|
+
* does not recreate the previous instance
|
|
17
|
+
*/
|
|
18
|
+
const dkim2Sign = async (input, options) => {
|
|
19
|
+
let signer = new Dkim2Signer(options);
|
|
20
|
+
await writeToStream(signer, input);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
signatures: formatSignatures(signer.signatureHeaders),
|
|
24
|
+
signature: signer.signatureHeaders[0],
|
|
25
|
+
messageInstance: signer.signatureHeaders[1] || null,
|
|
26
|
+
i: signer.i,
|
|
27
|
+
m: signer.m
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Transform stream that outputs the message with the DKIM2 header fields on top
|
|
32
|
+
class Dkim2SignStream extends DkimSignStream {
|
|
33
|
+
createSigner(options) {
|
|
34
|
+
return new Dkim2Signer(options);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { dkim2Sign, Dkim2SignStream };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { writeToStream } = require('../tools');
|
|
4
|
+
const { Dkim2Verifier } = require('./dkim2-verifier');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Verifies the DKIM2 header fields of a message (draft-ietf-dkim-dkim2-spec-06 section 11)
|
|
8
|
+
*
|
|
9
|
+
* @param {ReadableStream|Buffer|String} input RFC 5322 message
|
|
10
|
+
* @param {Object} [options] Verification options, see Dkim2Verifier
|
|
11
|
+
* @returns {Object} Verification result, see docs/dkim2.md
|
|
12
|
+
*/
|
|
13
|
+
const dkim2Verify = async (input, options) => {
|
|
14
|
+
let verifier = new Dkim2Verifier(options);
|
|
15
|
+
await writeToStream(verifier, input);
|
|
16
|
+
return verifier.result;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
module.exports = { dkim2Verify };
|
package/lib/mailauth.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
const { dkimSign, DkimSignStream } = require('./dkim/sign');
|
|
4
4
|
const { dkimVerify } = require('./dkim/verify');
|
|
5
|
+
const { dkim2Sign, Dkim2SignStream } = require('./dkim2/sign');
|
|
6
|
+
const { dkim2Verify } = require('./dkim2/verify');
|
|
7
|
+
const { dkim2Hash } = require('./dkim2/hash');
|
|
5
8
|
const { spf } = require('./spf');
|
|
6
9
|
const { dmarc, evaluateDmarc } = require('./dmarc');
|
|
7
10
|
const { arc, createSeal, sealMessage, getARChain, verifyASChain } = require('./arc');
|
|
@@ -9,6 +12,7 @@ const { bimi, validateVMC: validateBimiVmc } = require('./bimi');
|
|
|
9
12
|
const { validateSvg: validateBimiSvg } = require('./bimi/validate-svg');
|
|
10
13
|
const { parseReceived, getClientAddress, getClientHelo } = require('./parse-received');
|
|
11
14
|
const { formatDomain, getAlignment, formatAuthservId } = require('./tools');
|
|
15
|
+
const { buffer: readStream } = require('node:stream/consumers');
|
|
12
16
|
const libmime = require('libmime');
|
|
13
17
|
const os = require('node:os');
|
|
14
18
|
const { isIP } = require('net');
|
|
@@ -33,6 +37,9 @@ const { isIP } = require('net');
|
|
|
33
37
|
* @param {Boolean} [opts.disableDmarc=false] If true then do not perform DMARC check
|
|
34
38
|
* @param {Boolean} [opts.disableBimi=false] If true then do not perform BIMI check
|
|
35
39
|
* @param {Boolean} [opts.strict=false] If true then every check follows its RFC exactly instead of the lenient defaults
|
|
40
|
+
* @param {Boolean} [opts.dkim2=false] If true then DKIM2 header fields are verified as well (draft-ietf-dkim-dkim2-spec-06)
|
|
41
|
+
* @param {String|String[]} [opts.rcptTo] RCPT TO addresses of the delivery, checked against the DKIM2 chain of custody
|
|
42
|
+
* @param {Object} [opts.dkim2Options] Extra options for the DKIM2 verifier (maxSignatureAge, maxFutureTime, maxInstances, checkReplay, curTime)
|
|
36
43
|
* @returns Authentication result
|
|
37
44
|
*/
|
|
38
45
|
const authenticate = async (input, opts) => {
|
|
@@ -45,6 +52,11 @@ const authenticate = async (input, opts) => {
|
|
|
45
52
|
// quoted, or throws under strict, before any work is done
|
|
46
53
|
const authservId = formatAuthservId(opts.mta, opts.strict);
|
|
47
54
|
|
|
55
|
+
if (opts.dkim2 && typeof input?.on === 'function') {
|
|
56
|
+
// DKIM2 reads the message a second time, so a stream is read into memory first
|
|
57
|
+
input = await readStream(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
const dkimResult = await dkimVerify(input, {
|
|
49
61
|
resolver: opts.resolver,
|
|
50
62
|
sender: opts.sender, // defaults to Return-Path header
|
|
@@ -97,7 +109,24 @@ const authenticate = async (input, opts) => {
|
|
|
97
109
|
opts.helo = `[${opts.helo}]`;
|
|
98
110
|
}
|
|
99
111
|
|
|
112
|
+
// DKIM2 runs alongside SPF. The delivery is compared with the highest DKIM2-Signature only
|
|
113
|
+
// when it is known
|
|
114
|
+
const dkim2Verification =
|
|
115
|
+
opts.dkim2 &&
|
|
116
|
+
dkim2Verify(
|
|
117
|
+
input,
|
|
118
|
+
Object.assign({}, opts.dkim2Options, {
|
|
119
|
+
resolver: opts.resolver,
|
|
120
|
+
mailFrom: typeof opts.sender === 'string' ? opts.sender : undefined,
|
|
121
|
+
rcptTo: opts.rcptTo
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
// a rejection is handled by the await below, this keeps it from being reported as unhandled
|
|
125
|
+
// while SPF runs
|
|
126
|
+
dkim2Verification?.catch?.(() => false);
|
|
127
|
+
|
|
100
128
|
const spfResult = await spf(opts);
|
|
129
|
+
const dkim2Result = await dkim2Verification;
|
|
101
130
|
|
|
102
131
|
let arcResult;
|
|
103
132
|
if (!opts.disableArc) {
|
|
@@ -118,6 +147,12 @@ const authenticate = async (input, opts) => {
|
|
|
118
147
|
arHeader.push(`${libmime.foldLines(row.info, 160)}`);
|
|
119
148
|
});
|
|
120
149
|
|
|
150
|
+
if (dkim2Result?.info) {
|
|
151
|
+
// draft-gondwana-dkim2-authres-00: the method is not registered yet. DKIM2 results do not
|
|
152
|
+
// take part in DMARC, that is an open question (draft-ietf-dkim-dkim2-bcp-01 section 7.2)
|
|
153
|
+
arHeader.push(libmime.foldLines(dkim2Result.info, 160));
|
|
154
|
+
}
|
|
155
|
+
|
|
121
156
|
if (spfResult) {
|
|
122
157
|
arHeader.push(libmime.foldLines(spfResult.info, 160));
|
|
123
158
|
receivedSpfHeader = spfResult.header;
|
|
@@ -237,6 +272,7 @@ const authenticate = async (input, opts) => {
|
|
|
237
272
|
|
|
238
273
|
const result = {
|
|
239
274
|
dkim: dkimResult,
|
|
275
|
+
dkim2: dkim2Result || false,
|
|
240
276
|
spf: spfResult,
|
|
241
277
|
dmarc: dmarcResult || false,
|
|
242
278
|
arc: arcResult || false,
|
|
@@ -260,6 +296,10 @@ module.exports = {
|
|
|
260
296
|
dkimSign,
|
|
261
297
|
DkimSignStream,
|
|
262
298
|
dkimVerify,
|
|
299
|
+
dkim2Sign,
|
|
300
|
+
Dkim2SignStream,
|
|
301
|
+
dkim2Verify,
|
|
302
|
+
dkim2Hash,
|
|
263
303
|
spf,
|
|
264
304
|
dmarc,
|
|
265
305
|
arc,
|