mailauth
Version:
Email authentication library for Node.js
772 lines (659 loc) • 23.5 kB
JavaScript
/* eslint no-control-regex: 0 */
'use strict';
const { Buffer } = require('node:buffer');
const punycode = require('punycode.js');
const libmime = require('libmime');
const dns = require('node:dns').promises;
const crypto = require('node:crypto');
const https = require('node:https');
const packageData = require('../package');
const parseDkimHeaders = require('./parse-dkim-headers');
const tldts = require('tldts');
const Joi = require('joi');
const base64Schema = Joi.string().base64({ paddingRequired: false });
const defaultDKIMFieldNames =
'From:Sender:Reply-To:Subject:Date:Message-ID:To:' +
'Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:' +
'Content-Description:Resent-Date:Resent-From:Resent-Sender:' +
'Resent-To:Resent-Cc:Resent-Message-ID:In-Reply-To:References:' +
'List-Id:List-Help:List-Unsubscribe:List-Subscribe:List-Post:' +
'List-Owner:List-Archive:BIMI-Selector';
const defaultARCFieldNames = `DKIM-Signature:Delivered-To:${defaultDKIMFieldNames}`;
const defaultASFieldNames = `ARC-Authentication-Results:ARC-Message-Signature:ARC-Seal`;
const keyOrderingDKIM = ['v', 'a', 'c', 'd', 'h', 'i', 'l', 'q', 's', 't', 'x', 'z', 'bh', 'b'];
const keyOrderingARC = ['i', 'a', 'c', 'd', 'h', 'l', 'q', 's', 't', 'x', 'z', 'bh', 'b'];
const keyOrderingAS = ['i', 'a', 't', 'cv', 'd', 's', 'b'];
const TLDTS_OPTS = {
allowIcannDomains: true,
allowPrivateDomains: true
};
const writeToStream = async (stream, input, chunkSize) => {
chunkSize = chunkSize || 64 * 1024;
if (typeof input === 'string') {
input = Buffer.from(input);
}
return new Promise((resolve, reject) => {
if (typeof input.on === 'function') {
// pipe as stream
input.pipe(stream);
input.on('error', reject);
} else {
let pos = 0;
let writeChunk = () => {
if (pos >= input.length) {
return stream.end();
}
let chunk;
if (pos + chunkSize >= input.length) {
chunk = input.slice(pos);
} else {
chunk = input.slice(pos, pos + chunkSize);
}
pos += chunk.length;
if (stream.write(chunk) === false) {
stream.once('drain', () => writeChunk());
return;
}
setImmediate(writeChunk);
};
setImmediate(writeChunk);
}
stream.on('end', resolve);
stream.on('finish', resolve);
stream.on('error', reject);
});
};
const parseHeaders = buf => {
let rows = buf
.toString('binary')
.replace(/[\r\n]+$/, '')
.split(/\r?\n/)
.map(row => [row]);
for (let i = rows.length - 1; i >= 0; i--) {
if (i > 0 && /^\s/.test(rows[i][0])) {
rows[i - 1] = rows[i - 1].concat(rows[i]);
rows.splice(i, 1);
}
}
rows = rows.map(row => {
row = row.join('\r\n');
let key = row.match(/^[^:]+/);
let casedKey;
if (key) {
casedKey = key[0].trim();
key = casedKey.toLowerCase();
}
return { key, casedKey, line: Buffer.from(row, 'binary') };
});
return { parsed: rows, original: buf };
};
const getSigningHeaderLines = (parsedHeaders, fieldNames, verify) => {
fieldNames = (typeof fieldNames === 'string' ? fieldNames : defaultDKIMFieldNames)
.split(':')
.map(key => key.trim().toLowerCase())
.filter(key => key);
let signingList = [];
if (verify) {
let parsedList = [].concat(parsedHeaders);
for (let fieldName of fieldNames) {
for (let i = parsedList.length - 1; i >= 0; i--) {
let header = parsedList[i];
if (fieldName === header.key) {
signingList.push(header);
parsedList.splice(i, 1);
break;
}
}
}
} else {
for (let i = parsedHeaders.length - 1; i >= 0; i--) {
let header = parsedHeaders[i];
if (fieldNames.includes(header.key)) {
signingList.push(header);
}
}
}
return {
keys: signingList.map(entry => entry.casedKey).join(': '),
headers: signingList
};
};
/**
* Generates `DKIM-Signature: ...` header for selected values
* @param {Object} values
*/
const formatSignatureHeaderLine = (type, values, folded) => {
type = (type || '').toString().toUpperCase();
let keyOrdering, headerKey;
switch (type) {
case 'DKIM':
headerKey = 'DKIM-Signature';
keyOrdering = keyOrderingDKIM;
values = Object.assign(
{
v: 1,
t: Math.round(Date.now() / 1000),
q: 'dns/txt'
},
values
);
break;
case 'ARC':
headerKey = 'ARC-Message-Signature';
keyOrdering = keyOrderingARC;
values = Object.assign(
{
t: Math.round(Date.now() / 1000),
q: 'dns/txt'
},
values
);
break;
case 'AS':
headerKey = 'ARC-Seal';
keyOrdering = keyOrderingAS;
values = Object.assign(
{
t: Math.round(Date.now() / 1000)
},
values
);
break;
default:
throw new Error('Unknown Signature type');
}
const header =
`${headerKey}: ` +
Object.keys(values)
.filter(key => values[key] !== false && typeof values[key] !== 'undefined' && values.key !== null && keyOrdering.includes(key))
.sort((a, b) => keyOrdering.indexOf(a) - keyOrdering.indexOf(b))
.map(key => {
let val = values[key] || '';
if (key === 'b' && folded && val) {
// fold signature value
return `${key}=${val}`.replace(/.{75}/g, '$& ').trim();
}
if (['d', 's'].includes(key)) {
try {
// convert to A-label if needed
val = punycode.toASCII(val);
} catch (err) {
// ignore
}
}
if (key === 'i' && type === 'DKIM') {
let atPos = val.indexOf('@');
if (atPos >= 0) {
let domainPart = val.substr(atPos + 1);
try {
// convert to A-label if needed
domainPart = punycode.toASCII(domainPart);
} catch (err) {
// ignore
}
val = val.substr(0, atPos + 1) + domainPart;
}
}
return `${key}=${val}`;
})
.join('; ');
if (folded) {
return libmime.foldLines(header);
}
return header;
};
const getPublicKey = async (type, name, minBitLength, resolver) => {
minBitLength = minBitLength || 1024;
resolver = resolver || dns.resolve;
let list = await resolver(name, 'TXT');
let rr =
list &&
[]
.concat(list[0] || [])
.join('')
.replace(/\s+/g, '');
if (rr) {
// prefix value for parsing as there is no default value
let entry = parseDkimHeaders(`DNS: TXT;${rr}`);
const publicKeyValue = entry?.parsed?.p?.value;
if (!publicKeyValue) {
let err = new Error('Missing key value');
err.code = 'EINVALIDVAL';
err.rr = rr;
throw err;
}
let validation = base64Schema.validate(publicKeyValue);
if (validation.error) {
let err = new Error('Invalid base64 format for public key');
err.code = 'EINVALIDVAL';
err.rr = rr;
err.details = validation.error;
throw err;
}
if (type === 'DKIM' && entry?.parsed?.v && (entry?.parsed?.v?.value || '').toString().toLowerCase().trim() !== 'dkim1') {
let err = new Error('Unknown key version');
err.code = 'EINVALIDVER';
err.rr = rr;
throw err;
}
let paddingNeeded = publicKeyValue.length % 4 ? 4 - (publicKeyValue.length % 4) : 0;
let paddedPublicKey = publicKeyValue + '='.repeat(paddingNeeded);
let rawPublicKey = Buffer.from(publicKeyValue, 'base64');
let publicKeyObj;
let publicKeyOpts;
if (rawPublicKey.length === 32) {
// seems like an ed25519 key
rawPublicKey = Buffer.concat([Buffer.from('302A300506032B6570032100', 'hex'), rawPublicKey]);
publicKeyOpts = {
key: rawPublicKey,
format: 'der',
type: 'spki'
};
} else {
const publicKeyPem = Buffer.from(`-----BEGIN PUBLIC KEY-----\n${paddedPublicKey.replace(/.{64}/g, '$&\n').trim()}\n-----END PUBLIC KEY-----`);
publicKeyOpts = {
key: publicKeyPem,
format: 'pem'
};
}
try {
publicKeyObj = crypto.createPublicKey(publicKeyOpts);
} catch (err) {
let error = new Error('Unknown key type (${keyType})', { cause: err });
error.code = 'EINVALIDTYPE';
error.rr = rr;
throw error;
}
let keyType = publicKeyObj.asymmetricKeyType;
if (!['rsa', 'ed25519'].includes(keyType) || (entry?.parsed?.k && entry?.parsed?.k?.value?.toLowerCase() !== keyType)) {
let err = new Error('Unknown key type (${keyType})');
err.code = 'EINVALIDTYPE';
err.rr = rr;
throw err;
}
let modulusLength = publicKeyObj.asymmetricKeyDetails.modulusLength;
if (keyType === 'rsa' && modulusLength < minBitLength) {
let err = new Error('RSA key too short');
err.code = 'ESHORTKEY';
err.rr = rr;
throw err;
}
return {
publicKey: publicKeyObj.export({
type: publicKeyObj.asymmetricKeyType === 'ed25519' ? 'spki' : 'pkcs1',
format: 'pem'
}),
rr,
modulusLength
};
}
let err = new Error('Missing key value');
err.code = 'EINVALIDVAL';
throw err;
};
const getPrivateKey = privateKeyBuf => {
let privateKeyOpts;
if (typeof privateKeyBuf === 'string') {
privateKeyBuf = Buffer.from(privateKeyBuf);
}
if (privateKeyBuf.length === 32) {
// seems like a raw ed25519 key
privateKeyBuf = Buffer.concat([Buffer.from('MC4CAQAwBQYDK2VwBCIEIA==', 'base64'), privateKeyBuf]);
privateKeyOpts = {
key: privateKeyBuf,
format: 'der',
type: 'pkcs8'
};
} else {
privateKeyOpts = { key: privateKeyBuf, format: 'pem' };
}
return crypto.createPrivateKey(privateKeyOpts);
};
const fetch = url =>
new Promise((resolve, reject) => {
https
.get(
url,
{
headers: {
'User-Agent': `mailauth/${packageData.version} (+${packageData.homepage}`
}
},
res => {
let chunks = [];
let chunklen = 0;
res.on('readable', () => {
let chunk;
while ((chunk = res.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: Buffer.concat(chunks, chunklen)
});
});
}
)
.on('error', reject);
});
const escapePropValue = value => {
value = (value || '')
.toString()
.replace(/[\x00-\x1F]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!/[\s\x00-\x1F\x7F-\uFFFF()<>,;:\\"/[\]?=]/.test(value)) {
// return token value
return value;
}
// return quoted string with escaped quotes
return `"${value.replace(/["\\]/g, c => `\\${c}`)}"`;
};
const escapeCommentValue = value => {
value = (value || '')
.toString()
.replace(/[\x00-\x1F]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return `${value.replace(/[\\)]/g, c => `\\${c}`)}`;
};
const formatAuthHeaderRow = (method, status) => {
status = status || {};
let parts = [];
parts.push(`${method}=${status.result || 'none'}`);
if (status.underSized) {
parts.push(`(${escapeCommentValue(`undersized signature: ${status.underSized} bytes unsigned`)})`);
}
if (status.comment) {
parts.push(`(${escapeCommentValue(status.comment)})`);
}
for (let ptype of ['policy', 'smtp', 'body', 'header']) {
if (!status[ptype] || typeof status[ptype] !== 'object') {
continue;
}
for (let prop of Object.keys(status[ptype])) {
if (status[ptype][prop]) {
parts.push(`${ptype}.${prop}=${escapePropValue(status[ptype][prop])}`);
}
}
}
return parts.join(' ');
};
const formatRelaxedLine = (line, suffix) => {
let result =
line
?.toString('binary')
// unfold
.replace(/\r?\n/g, '')
// key to lowercase, trim around :
.replace(/^([^:]*):\s*/, (m, k) => k.toLowerCase().trim() + ':')
// single WSP
.replace(/\s+/g, ' ')
.trim() + (suffix ? suffix : '');
return Buffer.from(result, 'binary');
};
const formatDomain = domain => {
domain = domain.toLowerCase().trim();
try {
domain = punycode.toASCII(domain).toLowerCase().trim();
} catch (err) {
// ignore punycode errors
}
return domain;
};
const getAlignment = (fromDomain, domainList, strict) => {
domainList = []
.concat(domainList || [])
.map(entry => {
if (typeof entry === 'string') {
return { domain: entry };
}
return entry;
})
.sort((a, b) => (a.underSized || 0) - (b.underSized || 0));
if (strict) {
fromDomain = formatDomain(fromDomain);
for (let entry of domainList) {
let domain = formatDomain(tldts.getDomain(entry.domain, TLDTS_OPTS) || entry.domain);
if (formatDomain(domain) === fromDomain) {
return entry;
}
}
}
// match org domains
fromDomain = formatDomain(tldts.getDomain(fromDomain, TLDTS_OPTS) || fromDomain);
for (let entry of domainList) {
let domain = formatDomain(tldts.getDomain(entry.domain, TLDTS_OPTS) || entry.domain);
if (domain === fromDomain) {
return entry;
}
}
return false;
};
const validateAlgorithm = (algorithm, strict) => {
try {
if (!algorithm || !/^[^-]+-[^-]+$/.test(algorithm)) {
throw new Error('Invalid algorithm format');
}
let [signAlgo, hashAlgo] = algorithm.toLowerCase().split('-');
if (!['rsa', 'ed25519'].includes(signAlgo)) {
let error = new Error('Unknown signing algorithm');
error.signAlgo = signAlgo;
throw error;
}
if (!['sha256'].concat(!strict ? 'sha1' : []).includes(hashAlgo)) {
let error = new Error('Unknown hashing algorithm');
error.hashAlgo = hashAlgo;
throw error;
}
} catch (err) {
err.code = 'EINVALIDALGO';
throw err;
}
};
const getPtrHostname = parsedAddr => {
let bytes = parsedAddr.toByteArray();
if (bytes.length === 4) {
return `${bytes
.map(a => a.toString(10))
.reverse()
.join('.')}.in-addr.arpa`;
} else {
return `${bytes
.flatMap(a => a.toString(16).padStart(2, '0').split(''))
.reverse()
.join('.')}.ip6.arpa`;
}
};
function getCurTime(timeValue) {
if (timeValue) {
if (typeof timeValue === 'object' && typeof timeValue.toISOString === 'function') {
return timeValue;
}
if (typeof timeValue === 'number' || !isNaN(timeValue)) {
let timestamp = Number(timeValue);
let curTime = new Date(timestamp);
if (curTime.toString !== 'Invalid Date') {
return curTime;
}
} else if (typeof timeValue === 'string') {
let curTime = new Date(timeValue);
if (curTime.toString !== 'Invalid Date') {
return curTime;
}
}
}
return new Date();
}
function parseTagValueRecord(record, options = {}) {
const {
requiredTags = [],
allowedTags = null, // null means allow all, array means restrict to these
caseSensitive = false,
strictMode = false, // if true, stops parsing on first malformed part
allowDuplicateKeys = true // if false, treats duplicate keys as errors
} = options;
let sanitized = (record || '')
.replace(/[\x00-\x1F]+/g, ' ') // control chars
.replace(/\\r\\n/g, '')
.replace(/\\n/g, '')
.replace(/\r?\n/g, '')
.replace(/\s+/g, ' ')
.trim();
// Split on semicolons
const parts = sanitized.split(';');
const tags = {};
const validPairs = [];
const errors = [];
const warnings = [];
for (let part of parts) {
part = part.trim();
if (!part) continue; // Skip empty parts
// Look for tag=value pattern
const equalIndex = part.indexOf('=');
if (equalIndex === -1) {
const error = `Malformed part (no equals sign): "${part}"`;
errors.push(error);
if (strictMode) break;
continue;
}
let key = part.substring(0, equalIndex).trim();
let value = part.substring(equalIndex + 1).trim();
const normalizedKey = caseSensitive ? key : key.toLowerCase();
// Validate key format (should be alphanumeric, may include hyphens/underscores)
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
const error = `Invalid tag name: "${key}"`;
errors.push(error);
if (strictMode) break;
continue;
}
if (allowedTags && !allowedTags.includes(normalizedKey)) {
warnings.push(`Unknown/disallowed tag ignored: "${key}"`);
continue;
}
if (normalizedKey in tags) {
if (!allowDuplicateKeys) {
const error = `Duplicate tag not allowed: "${key}"`;
errors.push(error);
if (strictMode) break;
continue;
}
if (Array.isArray(tags[normalizedKey])) {
tags[normalizedKey].push(value);
} else {
tags[normalizedKey] = [tags[normalizedKey], value];
}
warnings.push(`Duplicate tag "${key}" found`);
} else {
tags[normalizedKey] = value;
}
validPairs.push([normalizedKey, value]);
}
for (const requiredTag of requiredTags) {
const normalizedRequired = caseSensitive ? requiredTag : requiredTag.toLowerCase();
if (!(normalizedRequired in tags)) {
errors.push(`Missing required tag: "${requiredTag}"`);
}
}
const sanitizedRecord = validPairs.map(([key, value]) => `${key}=${value}`).join('; ');
return {
tags,
errors,
warnings,
isValid: errors.length === 0,
sanitizedRecord,
originalRecord: record
};
}
function convertToASCII(value) {
return (value || '').replace(/[^\x20-\x7E]/g, '');
}
function validateTagValueRecord(record, recordType) {
const configs = {
BIMI: {
requiredTags: ['v', 'l', 'a'],
allowedTags: ['v', 'l', 'a'],
caseSensitive: false,
strictMode: true,
allowDuplicateKeys: false,
validators: {
v: value => (/^BIMI\d+$/i.test(value) ? null : `Version must match BIMI<digit>, got: ${value}`),
l: value => {
if (!value.trim()) return 'Location cannot be empty';
try {
const url = new URL(value.trim());
return url.protocol !== 'https:' ? 'Location must use HTTPS protocol' : null;
} catch (e) {
return `Invalid location URL: ${value}`;
}
},
a: value => {
if (!value.trim()) return 'Authority cannot be empty';
try {
const url = new URL(value.trim());
return url.protocol !== 'https:' ? 'Authority must use HTTPS protocol' : null;
} catch (e) {
return `Invalid authority URL: ${value}`;
}
}
},
mappers: {
v: value => convertToASCII(value)
}
}
};
const config = configs[recordType.toUpperCase()];
if (!config) {
throw new Error(`Unknown record type: ${recordType}`);
}
const parsed = parseTagValueRecord(record, config);
// Mappers run regardless whether the resulting parsed object is valid
if (config.mappers) {
for (const [tag, mapper] of Object.entries(config.mappers)) {
if (parsed.tags && tag in parsed.tags) {
parsed.tags[tag] = mapper(parsed.tags[tag]);
}
}
}
if (config.validators && parsed.isValid) {
for (const [tag, validator] of Object.entries(config.validators)) {
if (parsed.tags && tag in parsed.tags) {
const validationError = validator(parsed.tags[tag]);
if (validationError) {
parsed.errors.push(validationError);
}
}
}
parsed.isValid = parsed.errors.length === 0;
}
return parsed;
}
module.exports = {
writeToStream,
parseHeaders,
defaultDKIMFieldNames,
defaultARCFieldNames,
defaultASFieldNames,
getSigningHeaderLines,
formatSignatureHeaderLine,
parseDkimHeaders,
getPublicKey,
getPrivateKey,
formatAuthHeaderRow,
escapeCommentValue,
fetch,
validateAlgorithm,
getAlignment,
formatRelaxedLine,
formatDomain,
getPtrHostname,
getCurTime,
TLDTS_OPTS,
validateTagValueRecord,
parseTagValueRecord,
convertToASCII
};