Comparing version 1.0.2 to 1.0.4
'use strict'; | ||
const { authenticate } = require('../lib/mailauth'); | ||
//const { dkimSign } = require('../lib/dkim/sign'); | ||
const fs = require('fs'); | ||
const main = async () => { | ||
let message = fs.createReadStream(__dirname + '/../test/fixtures/authtest.eml'); | ||
let message = await fs.promises.readFile(process.argv[2] || __dirname + '/../test/fixtures/message4.eml'); | ||
let res = await authenticate(message, { | ||
@@ -19,2 +21,18 @@ ip: '217.146.67.33', | ||
console.log('----'); | ||
/* | ||
let signed = await dkimSign(message, { | ||
signTime: Date.now(), | ||
arc: { | ||
instance: 1, | ||
algorithm: 'rsa-sha256', | ||
signingDomain: 'tahvel.info', | ||
selector: 'test.rsa', | ||
privateKey: fs.readFileSync('./test/fixtures/private-rsa.pem') | ||
} | ||
}); | ||
console.log(signed); | ||
*/ | ||
}; | ||
@@ -21,0 +39,0 @@ |
@@ -38,3 +38,3 @@ 'use strict'; | ||
if (result.results.some(r => r.status === 'fail')) { | ||
if (result.results.some(r => r.status.result === 'fail')) { | ||
if (dest) { | ||
@@ -46,3 +46,3 @@ let out = Buffer.concat([Buffer.from('X-DGB: ' + JSON.stringify(result.results)), msg]); | ||
if (result.results.some(r => r.status === 'neutral')) { | ||
if (result.results.some(r => r.status.result === 'neutral')) { | ||
if (dest) { | ||
@@ -49,0 +49,0 @@ let out = Buffer.concat([Buffer.from('X-DGB: ' + JSON.stringify(result.results)), msg]); |
'use strict'; | ||
const { getSignedHeaderLines, formatDKIMHeaderLine } = require('../../lib/tools'); | ||
const { getSigningHeaderLines, formatSignatureHeaderLine, defaultDKIMFieldNames, defaultARCFieldNames } = require('../../lib/tools'); | ||
const { MessageParser } = require('./message-parser'); | ||
const { dkimBody } = require('./body'); | ||
const { dkimHeader } = require('./header'); | ||
const { generateCanonicalizedHeader } = require('./header'); | ||
const crypto = require('crypto'); | ||
@@ -13,3 +13,3 @@ | ||
let { canonicalization, signTime, headerList, signatureData } = options || {}; | ||
let { canonicalization, signTime, headerList, signatureData, arc } = options || {}; | ||
@@ -26,5 +26,22 @@ this.canonicalization = canonicalization || 'relaxed/relaxed'; | ||
this.signatureData = signatureData; | ||
this.signatureData = [].concat(signatureData || []).map(entry => { | ||
entry.type = 'DKIM'; | ||
return entry; | ||
}); | ||
this.signatureHeaders = []; | ||
this.arc = Object.assign({}, arc); | ||
if (this.arc && this.arc.instance && this.arc.signingDomain && this.arc.selector && this.arc.privateKey) { | ||
this.arc.set = this.arc.set || {}; | ||
this.signatureData.push({ | ||
type: 'ARC', | ||
signingDomain: this.arc.signingDomain, // d= | ||
selector: this.arc.selector, // s= | ||
privateKey: this.arc.privateKey, | ||
algorithm: 'rsa-sha256', // fixed for now | ||
instance: this.arc.instance | ||
}); | ||
} | ||
this.bodyHashes = new Map(); | ||
@@ -35,3 +52,3 @@ this.setupHashes(); | ||
setupHashes() { | ||
for (let signatureData of this.signatureData || []) { | ||
for (let signatureData of this.signatureData) { | ||
if (!signatureData.privateKey) { | ||
@@ -98,4 +115,2 @@ continue; | ||
let signedHeaderLines = getSignedHeaderLines(this.headers.parsed, this.headerList); | ||
for (let signatureData of this.signatureData || []) { | ||
@@ -106,2 +121,18 @@ if (!signatureData.privateKey) { | ||
let fieldNames = this.headerList && this.headerList.length ? this.headerList : false; | ||
if (!fieldNames) { | ||
switch (signatureData.type) { | ||
case 'ARC': | ||
fieldNames = defaultARCFieldNames; | ||
break; | ||
case 'DKIM': | ||
default: | ||
fieldNames = defaultDKIMFieldNames; | ||
break; | ||
} | ||
} | ||
let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, this.headerList); | ||
let algorithm = (signatureData.algorithm || '').toLowerCase().trim(); | ||
@@ -129,2 +160,3 @@ let signAlgo = algorithm.split('-').shift().toLowerCase().trim() || null; | ||
} | ||
algorithm = `${signAlgo}-${hashAlgo}`; | ||
@@ -153,5 +185,7 @@ } catch (err) { | ||
let { signingHeaders, dkimHeaderOpts } = dkimHeader( | ||
signedHeaderLines, | ||
let { canonicalizedHeader, dkimHeaderOpts } = generateCanonicalizedHeader( | ||
signatureData.type, | ||
signingHeaderLines, | ||
Object.assign({}, signatureData, { | ||
instance: signatureData.instance, // ARC only | ||
algorithm, | ||
@@ -169,3 +203,3 @@ canonicalization: this.canonicalization, | ||
signAlgo === 'rsa' ? algorithm : null, | ||
signingHeaders, | ||
canonicalizedHeader, | ||
signatureData.privateKey | ||
@@ -176,5 +210,18 @@ ) | ||
dkimHeaderOpts.b = signature; | ||
this.signatureHeaders.push(formatDKIMHeaderLine(dkimHeaderOpts, true)); | ||
const signatureHeaderLine = formatSignatureHeaderLine(signatureData.type, dkimHeaderOpts, true); | ||
switch (signatureData.type) { | ||
case 'ARC': | ||
this.arc.set['arc-message-signature'] = signatureHeaderLine; | ||
break; | ||
case 'DKIM': | ||
default: | ||
this.signatureHeaders.push(signatureHeaderLine); | ||
break; | ||
} | ||
} catch (err) { | ||
this.errors.push({ | ||
type: signatureData.type, | ||
algorithm, | ||
@@ -181,0 +228,0 @@ selector: signatureData.selector, |
'use strict'; | ||
const { getSignedHeaderLines, getPublicKey, parseDkimHeader } = require('../../lib/tools'); | ||
const { getSigningHeaderLines, getPublicKey, parseDkimHeaders, formatAuthHeaderRow, getAligment } = require('../../lib/tools'); | ||
const { MessageParser } = require('./message-parser'); | ||
const { dkimBody } = require('./body'); | ||
const { dkimHeader } = require('./header'); | ||
const { generateCanonicalizedHeader } = require('./header'); | ||
const { getARChain } = require('../arc'); | ||
const addressparser = require('nodemailer/lib/addressparser'); | ||
@@ -24,2 +25,4 @@ const crypto = require('crypto'); | ||
this.envelopeFrom = false; | ||
this.arc = { chain: false }; | ||
} | ||
@@ -29,5 +32,21 @@ | ||
this.headers = headers; | ||
this.signatureHeaders = headers.parsed.filter(h => h.key === 'dkim-signature').map(h => parseDkimHeader(h.line)); | ||
let fromHeaders = headers.parsed.filter(h => h.key === 'from'); | ||
try { | ||
this.arc.chain = getARChain(headers); | ||
if (this.arc.chain?.length) { | ||
this.arc.lastEntry = this.arc.chain[this.arc.chain.length - 1]; | ||
} | ||
} catch (err) { | ||
this.arc.error = err; | ||
} | ||
this.signatureHeaders = headers.parsed | ||
.filter(h => h.key === 'dkim-signature') | ||
.map(h => { | ||
const value = parseDkimHeaders(h.line); | ||
value.type = 'DKIM'; | ||
return value; | ||
}); | ||
let fromHeaders = headers?.parsed?.filter(h => h.key === 'from'); | ||
for (let fromHeader of fromHeaders) { | ||
@@ -63,8 +82,19 @@ fromHeader = fromHeader.line.toString(); | ||
// include newest ARC-Message-Signature as one of the signature headers to check for | ||
if (this.arc.lastEntry) { | ||
const signatureHeader = this.arc.lastEntry['arc-message-signature']; | ||
signatureHeader.type = 'ARC'; | ||
this.signatureHeaders.push(signatureHeader); | ||
const sealHeader = this.arc.lastEntry['arc-seal']; | ||
sealHeader.type = 'AS'; | ||
this.signatureHeaders.push(sealHeader); | ||
} | ||
for (let signatureHeader of this.signatureHeaders) { | ||
signatureHeader.algorithm = signatureHeader.parsed.a || ''; | ||
signatureHeader.algorithm = signatureHeader.parsed?.a?.value || ''; | ||
signatureHeader.signAlgo = signatureHeader.algorithm.split('-').shift().toLowerCase().trim(); | ||
signatureHeader.hashAlgo = signatureHeader.algorithm.split('-').pop().toLowerCase().trim(); | ||
signatureHeader.canonicalization = signatureHeader.parsed.c || ''; | ||
signatureHeader.canonicalization = signatureHeader.parsed?.c?.value || ''; | ||
signatureHeader.headerCanon = signatureHeader.canonicalization.split('/').shift().toLowerCase().trim(); | ||
@@ -74,10 +104,15 @@ // if body canonicalization is not set, then defaults to 'simple' | ||
signatureHeader.signingDomain = signatureHeader.parsed.d || ''; | ||
signatureHeader.selector = signatureHeader.parsed.s || ''; | ||
signatureHeader.signingDomain = signatureHeader.parsed?.d?.value || ''; | ||
signatureHeader.selector = signatureHeader.parsed?.s?.value || ''; | ||
const validSignAlgo = ['rsa', 'ed25519']; | ||
const validHeaderAlgo = signatureHeader.type === 'DKIM' ? ['sha256', 'sha1'] : ['sha256']; | ||
const validHeaderCanon = signatureHeader.type === 'DKIM' ? ['relaxed', 'simple'] : ['relaxed']; | ||
const validBodyCanon = signatureHeader.type === 'DKIM' ? ['relaxed', 'simple'] : ['relaxed']; | ||
if ( | ||
!['rsa', 'ed25519'].includes(signatureHeader.signAlgo) || | ||
!['sha256', 'sha1'].includes(signatureHeader.hashAlgo) || | ||
!['relaxed', 'simple'].includes(signatureHeader.headerCanon) || | ||
!['relaxed', 'simple'].includes(signatureHeader.bodyCanon) || | ||
!validSignAlgo.includes(signatureHeader.signAlgo) || | ||
!validHeaderAlgo.includes(signatureHeader.hashAlgo) || | ||
!validHeaderCanon.includes(signatureHeader.headerCanon) || | ||
!validBodyCanon.includes(signatureHeader.bodyCanon) || | ||
!signatureHeader.signingDomain || | ||
@@ -93,5 +128,7 @@ !signatureHeader.selector | ||
let maxLength = false; | ||
if (signatureHeader.parsed.l && Number(signatureHeader.parsed.l) > 0 && !isNaN(signatureHeader.parsed.l)) { | ||
maxLength = Number(signatureHeader.parsed.l); | ||
if (signatureHeader.parsed?.l?.value) { | ||
maxLength = signatureHeader.parsed?.l?.value; | ||
} | ||
this.bodyHashes.set(signatureHeader.bodyHashKey, dkimBody(signatureHeader.bodyCanon, signatureHeader.hashAlgo, maxLength)); | ||
@@ -124,26 +161,50 @@ } | ||
let signedHeaderLines = getSignedHeaderLines(this.headers.parsed, signatureHeader.parsed.h, true); | ||
let signingHeaderLines = getSigningHeaderLines(this.headers.parsed, signatureHeader.parsed?.h?.value, true); | ||
let { signingHeaders } = dkimHeader(signedHeaderLines, { | ||
dkimHeaderLine: signatureHeader.original, | ||
canonicalization: signatureHeader.canonicalization | ||
let { canonicalizedHeader } = generateCanonicalizedHeader(signatureHeader.type, signingHeaderLines, { | ||
signatureHeaderLine: signatureHeader.original, | ||
canonicalization: signatureHeader.canonicalization, | ||
instance: ['ARC', 'AS'].includes(signatureHeader.type) ? signatureHeader.parsed?.i?.value : false | ||
}); | ||
let error; | ||
let publicKey; | ||
let status; | ||
let status = { | ||
result: 'neutral', | ||
comment: false, | ||
// ptype properties | ||
header: { | ||
// signing domain | ||
i: signatureHeader.signingDomain ? `@${signatureHeader.signingDomain}` : false, | ||
// dkim selector | ||
s: signatureHeader.selector, | ||
// algo | ||
a: signatureHeader.parsed?.a?.value, | ||
// signature value | ||
b: signatureHeader.parsed?.b?.value ? `${signatureHeader.parsed?.b?.value.substr(0, 8)}` : false | ||
}, | ||
policy: {} | ||
}; | ||
if (signatureHeader.type === 'DKIM' && this.headerFrom?.length) { | ||
status.aligned = this.headerFrom?.length ? getAligment(this.headerFrom[0].split('@').pop(), [signatureHeader.signingDomain]) : false; | ||
} | ||
let bodyHash = this.bodyHashes.get(signatureHeader.bodyHashKey); | ||
if (signatureHeader.parsed.bh !== bodyHash) { | ||
status = 'neutral'; | ||
error = `body hash did not verify`; | ||
if (signatureHeader.parsed?.bh?.value !== bodyHash) { | ||
status.result = 'neutral'; | ||
status.comment = `body hash did not verify`; | ||
} else { | ||
try { | ||
publicKey = await getPublicKey(`${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`, this.resolver); | ||
publicKey = await getPublicKey( | ||
signatureHeader.type, | ||
`${signatureHeader.selector}._domainkey.${signatureHeader.signingDomain}`, | ||
this.resolver | ||
); | ||
try { | ||
status = crypto.verify( | ||
status.result = crypto.verify( | ||
signatureHeader.signAlgo === 'rsa' ? signatureHeader.algorithm : null, | ||
signingHeaders, | ||
canonicalizedHeader, | ||
publicKey, | ||
Buffer.from(signatureHeader.parsed.b, 'base64') | ||
Buffer.from(signatureHeader.parsed?.b?.value, 'base64') | ||
) | ||
@@ -153,7 +214,7 @@ ? 'pass' | ||
if (status === 'fail') { | ||
error = 'bad signature'; | ||
status.comment = 'bad signature'; | ||
} | ||
} catch (err) { | ||
status = 'neutral'; | ||
error = err.message; | ||
status.result = 'neutral'; | ||
status.comment = err.message; | ||
} | ||
@@ -163,29 +224,29 @@ } catch (err) { | ||
case 'ENOTFOUND': | ||
status = 'neutral'; | ||
error = `no key`; | ||
status.result = 'neutral'; | ||
status.comment = `no key`; | ||
break; | ||
case 'EINVALIDVER': | ||
status = 'neutral'; | ||
error = `unknown key version`; | ||
status.result = 'neutral'; | ||
status.comment = `unknown key version`; | ||
break; | ||
case 'EINVALIDTYPE': | ||
status = 'neutral'; | ||
error = `unknown key type`; | ||
status.result = 'neutral'; | ||
status.comment = `unknown key type`; | ||
break; | ||
case 'EINVALIDVAL': | ||
status = 'neutral'; | ||
error = `invalid public key`; | ||
status.result = 'neutral'; | ||
status.comment = `invalid public key`; | ||
break; | ||
case 'ESHORTKEY': | ||
status = 'policy'; | ||
error = `weak key`; | ||
status.result = 'policy'; | ||
status.policy['dkim-rules'] = `weak-key`; | ||
break; | ||
default: | ||
status = 'temperror'; | ||
error = `DNS failure. ${err.message}`; | ||
status.result = 'temperror'; | ||
status.comment = `DNS failure: ${err.code || err.message}`; | ||
} | ||
@@ -198,7 +259,7 @@ } | ||
selector: signatureHeader.selector, | ||
signature: signatureHeader.parsed.b, | ||
algo: signatureHeader.parsed.a, | ||
format: signatureHeader.parsed.c, | ||
signature: signatureHeader.parsed?.b?.value, | ||
algo: signatureHeader.parsed?.a?.value, | ||
format: signatureHeader.parsed?.c?.value, | ||
bodyHash, | ||
bodyHashExpecting: signatureHeader.parsed.bh, | ||
bodyHashExpecting: signatureHeader.parsed?.bh?.value, | ||
status | ||
@@ -211,7 +272,13 @@ }; | ||
if (error) { | ||
result.message = error; | ||
switch (signatureHeader.type) { | ||
case 'ARC': | ||
if (this.arc.lastEntry) { | ||
this.arc.lastEntry.messageSignature = result; | ||
} | ||
break; | ||
case 'DKIM': | ||
default: | ||
this.results.push(result); | ||
break; | ||
} | ||
this.results.push(result); | ||
} | ||
@@ -221,4 +288,6 @@ } finally { | ||
this.results.push({ | ||
status: 'none', | ||
message: 'message not signed' | ||
status: { | ||
result: 'none', | ||
comment: 'message not signed' | ||
} | ||
}); | ||
@@ -228,11 +297,3 @@ } | ||
this.results.forEach(result => { | ||
result.info = [ | ||
`dkim=${result.status}`, | ||
result.message ? `(${result.message})` : '', | ||
result.signingDomain ? `header.i=@${result.signingDomain}` : '', | ||
result.selector ? `header.s=${result.selector}` : '', | ||
result.signature ? `header.b="${result.signature.substr(0, 8)}"` : '' | ||
] | ||
.filter(val => val) | ||
.join(' '); | ||
result.info = formatAuthHeaderRow('dkim', result.status); | ||
}); | ||
@@ -239,0 +300,0 @@ } |
@@ -6,3 +6,3 @@ 'use strict'; | ||
const dkimHeader = (signedHeaderLines, options) => { | ||
const generateCanonicalizedHeader = (type, signingHeaderLines, options) => { | ||
options = options || {}; | ||
@@ -12,5 +12,5 @@ let canonicalization = (options.canonicalization || 'relaxed/relaxed').toString().split('/').shift().toLowerCase().trim(); | ||
case 'simple': | ||
return simpleHeaders(signedHeaderLines, options); | ||
return simpleHeaders(type, signingHeaderLines, options); | ||
case 'relaxed': | ||
return relaxedHeaders(signedHeaderLines, options); | ||
return relaxedHeaders(type, signingHeaderLines, options); | ||
default: | ||
@@ -21,2 +21,2 @@ throw new Error('Unknown header canonicalization'); | ||
module.exports = { dkimHeader }; | ||
module.exports = { generateCanonicalizedHeader }; |
'use strict'; | ||
const { formatDKIMHeaderLine } = require('../../../lib/tools'); | ||
const { formatSignatureHeaderLine, formatRelaxedLine } = require('../../../lib/tools'); | ||
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'); | ||
}; | ||
// generate headers for signing | ||
const relaxedHeaders = (signedHeaderLines, options) => { | ||
let { dkimHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature } = options || {}; | ||
const relaxedHeaders = (type, signingHeaderLines, options) => { | ||
let { signatureHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature, instance } = options || {}; | ||
let chunks = []; | ||
for (let signedHeaderLine of signedHeaderLines.headers) { | ||
for (let signedHeaderLine of signingHeaderLines.headers) { | ||
chunks.push(formatRelaxedLine(signedHeaderLine.line, '\r\n')); | ||
@@ -30,3 +16,3 @@ } | ||
if (!dkimHeaderLine) { | ||
if (!signatureHeaderLine) { | ||
opts = { | ||
@@ -37,6 +23,11 @@ a: algorithm, | ||
d: signingDomain, | ||
h: signedHeaderLines.keys, | ||
h: signingHeaderLines.keys, | ||
bh: bodyHash | ||
}; | ||
if (instance) { | ||
// ARC only | ||
opts.i = instance; | ||
} | ||
if (signTime) { | ||
@@ -54,3 +45,4 @@ if (typeof signTime === 'string' || typeof signTime === 'number') { | ||
dkimHeaderLine = formatDKIMHeaderLine( | ||
signatureHeaderLine = formatSignatureHeaderLine( | ||
type, | ||
Object.assign( | ||
@@ -69,3 +61,3 @@ { | ||
Buffer.from( | ||
formatRelaxedLine(dkimHeaderLine) | ||
formatRelaxedLine(signatureHeaderLine) | ||
.toString('binary') | ||
@@ -78,5 +70,5 @@ // remove value from b= key | ||
return { signingHeaders: Buffer.concat(chunks), dkimHeaderLine, dkimHeaderOpts: opts }; | ||
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts }; | ||
}; | ||
module.exports = { relaxedHeaders }; |
'use strict'; | ||
const { formatDKIMHeaderLine } = require('../../../lib/tools'); | ||
const { formatSignatureHeaderLine } = require('../../../lib/tools'); | ||
@@ -10,7 +10,7 @@ const formatSimpleLine = (line, suffix) => { | ||
// generate headers for signing | ||
const simpleHeaders = (signedHeaderLines, options) => { | ||
let { dkimHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature } = options || {}; | ||
const simpleHeaders = (type, signingHeaderLines, options) => { | ||
let { signatureHeaderLine, signingDomain, selector, algorithm, canonicalization, bodyHash, signTime, signature, instance } = options || {}; | ||
let chunks = []; | ||
for (let signedHeaderLine of signedHeaderLines.headers) { | ||
for (let signedHeaderLine of signingHeaderLines.headers) { | ||
chunks.push(formatSimpleLine(signedHeaderLine.line, '\r\n')); | ||
@@ -21,3 +21,3 @@ } | ||
if (!dkimHeaderLine) { | ||
if (!signatureHeaderLine) { | ||
opts = { | ||
@@ -28,6 +28,11 @@ a: algorithm, | ||
d: signingDomain, | ||
h: signedHeaderLines.keys, | ||
h: signingHeaderLines.keys, | ||
bh: bodyHash | ||
}; | ||
if (instance) { | ||
// ARC only (should never happen thoug as simple algo is not allowed) | ||
opts.i = instance; | ||
} | ||
if (signTime) { | ||
@@ -45,3 +50,4 @@ if (typeof signTime === 'string' || typeof signTime === 'number') { | ||
dkimHeaderLine = formatDKIMHeaderLine( | ||
signatureHeaderLine = formatSignatureHeaderLine( | ||
type, | ||
Object.assign( | ||
@@ -60,3 +66,3 @@ { | ||
Buffer.from( | ||
formatSimpleLine(dkimHeaderLine) | ||
formatSimpleLine(signatureHeaderLine) | ||
.toString('binary') | ||
@@ -69,5 +75,5 @@ // remove value from b= key | ||
return { signingHeaders: Buffer.concat(chunks), dkimHeaderLine, dkimHeaderOpts: opts }; | ||
return { canonicalizedHeader: Buffer.concat(chunks), signatureHeaderLine, dkimHeaderOpts: opts }; | ||
}; | ||
module.exports = { simpleHeaders }; |
@@ -27,5 +27,5 @@ 'use strict'; | ||
return { signatures: dkimSigner.signatureHeaders.join('\r\n') + '\r\n', errors: dkimSigner.errors }; | ||
return { signatures: dkimSigner.signatureHeaders.join('\r\n') + '\r\n', arc: dkimSigner.arc, errors: dkimSigner.errors }; | ||
}; | ||
module.exports = { dkimSign }; |
@@ -9,3 +9,5 @@ 'use strict'; | ||
await writeToStream(dkimVerifier, input); | ||
return { | ||
const result = { | ||
//headers: dkimVerifier.headers, | ||
headerFrom: dkimVerifier.headerFrom, | ||
@@ -15,4 +17,20 @@ envelopeFrom: dkimVerifier.envelopeFrom, | ||
}; | ||
Object.defineProperty(result, 'headers', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.headers | ||
}); | ||
Object.defineProperty(result, 'arc', { | ||
enumerable: false, | ||
configurable: false, | ||
writable: false, | ||
value: dkimVerifier.arc | ||
}); | ||
return result; | ||
}; | ||
module.exports = { dkimVerify }; |
@@ -6,22 +6,5 @@ 'use strict'; | ||
const dmarc = async opts => { | ||
let result = await verifyDmarc(opts); | ||
switch (result && result.status) { | ||
case 'pass': | ||
case 'fail': | ||
case 'none': | ||
case 'temperror': | ||
case 'permerror': | ||
{ | ||
let p = result.p; | ||
let sp = result.sp || result.p; | ||
let policyInfo = [].concat(p ? `p=${p.toUpperCase()}` : []).concat(sp ? `sp=${sp.toUpperCase()}` : []); | ||
result.info = `dmarc=${result.status}${policyInfo.length ? ` (${policyInfo.join(' ')})` : ''} header.from=${result.domain}`; | ||
} | ||
break; | ||
} | ||
return result; | ||
return await verifyDmarc(opts); | ||
}; | ||
module.exports = { dmarc }; |
@@ -6,2 +6,3 @@ 'use strict'; | ||
const psl = require('psl'); | ||
const { formatAuthHeaderRow, getAligment } = require('../tools'); | ||
@@ -74,38 +75,4 @@ const resolveTxt = async (domain, resolver) => { | ||
const formatDomain = domain => { | ||
domain = domain.toLowerCase().trim(); | ||
try { | ||
domain = punycode.toASCII(domain).toLowerCase().trim(); | ||
} catch (err) { | ||
// ignore punycode errors | ||
} | ||
return domain; | ||
}; | ||
const getAligment = (fromDomain, domainList, strict) => { | ||
domainList = [].concat(domainList || []); | ||
if (strict) { | ||
fromDomain = formatDomain(fromDomain); | ||
for (let domain of domainList) { | ||
domain = formatDomain(psl.get(domain) || domain); | ||
if (formatDomain(domain) === fromDomain) { | ||
return domain; | ||
} | ||
} | ||
} | ||
// match org domains | ||
fromDomain = formatDomain(psl.get(fromDomain) || fromDomain); | ||
for (let domain of domainList) { | ||
domain = formatDomain(psl.get(domain) || domain); | ||
if (domain === fromDomain) { | ||
return domain; | ||
} | ||
} | ||
return false; | ||
}; | ||
const verifyDmarc = async opts => { | ||
let { headerFrom, spfDomains, dkimDomains, resolver } = opts; | ||
let { headerFrom, spfDomains, dkimDomains, resolver, arcResult } = opts; | ||
@@ -134,4 +101,18 @@ resolver = resolver || dns.resolve; | ||
let formatResponse = response => { | ||
response.info = formatAuthHeaderRow('dmarc', response.status); | ||
return response; | ||
}; | ||
let orgDomain = psl.get(domain); | ||
let status = { | ||
result: 'neutral', | ||
comment: false, | ||
// ptype properties | ||
header: { | ||
from: orgDomain || domain | ||
} | ||
}; | ||
let dmarcRecord; | ||
@@ -142,3 +123,4 @@ try { | ||
// temperror? | ||
return { status: 'temperror', domain: orgDomain || domain, error: err.message }; | ||
status.result = 'temperror'; | ||
return formatResponse({ status, domain: orgDomain || domain, error: err.message }); | ||
} | ||
@@ -149,5 +131,12 @@ | ||
// none | ||
return { status: 'none', domain: orgDomain || domain }; | ||
status.result = 'none'; | ||
return formatResponse({ status, domain: orgDomain || domain }); | ||
} | ||
status.comment = [] | ||
.concat(dmarcRecord.p ? `p=${dmarcRecord.p.toUpperCase()}` : []) | ||
.concat(dmarcRecord.sp ? `sp=${dmarcRecord.sp.toUpperCase()}` : []) | ||
.concat(arcResult?.status?.result ? `arc=${arcResult?.status?.result}` : []) | ||
.join(' '); | ||
// use "sp" if this is a subdomain of an org domain and "sp" is set, otherwise use "p" | ||
@@ -158,10 +147,20 @@ const policy = dmarcRecord.isOrgRecord && dmarcRecord.sp ? dmarcRecord.sp : dmarcRecord.p; | ||
const spfAlignment = getAligment(domain, spfDomains, { strict: dmarcRecord.aspf === 's' }); | ||
if (dkimAlignment || spfAlignment) { | ||
// pass | ||
return { status: 'pass', domain: orgDomain || domain, policy, p: dmarcRecord.p, sp: dmarcRecord.sp || dmarcRecord.p }; | ||
status.result = 'pass'; | ||
} else { | ||
// fail | ||
status.result = 'fail'; | ||
} | ||
// fail | ||
return { status: 'fail', domain: orgDomain || domain, policy, p: dmarcRecord.p, sp: dmarcRecord.sp || dmarcRecord.p }; | ||
return formatResponse({ | ||
status, | ||
domain: orgDomain || domain, | ||
policy, | ||
p: dmarcRecord.p, | ||
sp: dmarcRecord.sp || dmarcRecord.p | ||
}); | ||
}; | ||
module.exports = verifyDmarc; |
@@ -6,2 +6,3 @@ 'use strict'; | ||
const { dmarc } = require('./dmarc'); | ||
const { arc } = require('./arc'); | ||
const libmime = require('libmime'); | ||
@@ -32,2 +33,6 @@ const os = require('os'); | ||
const arcResult = await arc(dkimResult.arc, { | ||
resolver: opts.resolver | ||
}); | ||
let headers = []; | ||
@@ -38,3 +43,3 @@ | ||
dkimResult.results.forEach(row => { | ||
arHeader.push(`${libmime.foldLines(row.info)}`); | ||
arHeader.push(`${libmime.foldLines(row.info, 160)}`); | ||
}); | ||
@@ -44,12 +49,10 @@ } | ||
if (spfResult) { | ||
arHeader.push( | ||
libmime.foldLines( | ||
`spf=${spfResult.status}${spfResult.info ? ` (${spfResult.info})` : ''}${ | ||
spfResult['envelope-from'] ? ` smtp.mailfrom=${spfResult['envelope-from']}` : '' | ||
}` | ||
) | ||
); | ||
arHeader.push(libmime.foldLines(spfResult.info, 160)); | ||
headers.push(spfResult.header); | ||
} | ||
if (arcResult && arcResult.info) { | ||
arHeader.push(`${libmime.foldLines(arcResult.info, 160)}`); | ||
} | ||
let dmarcResult; | ||
@@ -59,8 +62,9 @@ if (dkimResult && dkimResult.headerFrom) { | ||
headerFrom: dkimResult.headerFrom, | ||
spfDomains: [].concat((spfResult && spfResult.status === 'pass' && spfResult.domain) || []), | ||
dkimDomains: (dkimResult.results || []).filter(r => r.status === 'pass').map(r => r.signingDomain), | ||
spfDomains: [].concat((spfResult && spfResult.status.result === 'pass' && spfResult.domain) || []), | ||
dkimDomains: (dkimResult.results || []).filter(r => r.status.result === 'pass').map(r => r.signingDomain), | ||
arcResult, | ||
resolver: opts.resolver | ||
}); | ||
if (dmarcResult.info) { | ||
arHeader.push(`${libmime.foldLines(dmarcResult.info)}`); | ||
arHeader.push(`${libmime.foldLines(dmarcResult.info, 160)}`); | ||
} | ||
@@ -75,2 +79,3 @@ } | ||
dmarc: dmarcResult, | ||
arc: arcResult, | ||
headers: headers.join('\r\n') + '\r\n' | ||
@@ -77,0 +82,0 @@ }; |
@@ -9,2 +9,3 @@ 'use strict'; | ||
const domainSchema = Joi.string().domain({ allowUnicode: false, tlds: false }); | ||
const { formatAuthHeaderRow, escapeCommentValue } = require('../tools'); | ||
@@ -14,44 +15,15 @@ const MAX_RESOLVE_COUNT = 50; | ||
const formatHeaders = result => { | ||
let header = `Received-SPF: ${result.status}${result.info ? ` (${result.info})` : ''} client-ip=${result['client-ip']};`; | ||
let header = `Received-SPF: ${result.status.result}${result.status.comment ? ` (${escapeCommentValue(result.status.comment)})` : ''} client-ip=${ | ||
result['client-ip'] | ||
};`; | ||
return libmime.foldLines(header); | ||
return libmime.foldLines(header, 160); | ||
}; | ||
/** | ||
* | ||
* @param {Object} opts | ||
* @param {String} opts.sender Email address | ||
* @param {String} opts.ip Client IP address | ||
* @param {String} [opts.mta] Hostname of the MTA or MX server that processes the message | ||
* @param {String} opts.helo Client EHLO/HELO hostname | ||
*/ | ||
const verify = async opts => { | ||
let { sender, ip, mta, helo, resolver, maxResolveCount } = opts || {}; | ||
mta = mta || os.hostname(); | ||
sender = sender || `postmaster@${helo}`; | ||
// convert mapped IPv6 IP addresses to IPv4 | ||
let mappingMatch = (ip || '').toString().match(/^[:A-F]+:((\d+\.){3}\d+)$/i); | ||
if (mappingMatch) { | ||
ip = mappingMatch[1]; | ||
} | ||
let atPos = sender.indexOf('@'); | ||
if (atPos < 0) { | ||
sender = `postmaster@${sender}`; | ||
} else if (atPos === 0) { | ||
sender = `postmaster${sender}`; | ||
} | ||
let domain = sender.split('@').pop().toLowerCase().trim(); | ||
resolver = resolver || dns.promises.resolve; | ||
// DNS resolver method | ||
let limitedResolver = (resolver, maxResolveCount) => { | ||
let resolveCount = 0; | ||
maxResolveCount = maxResolveCount || MAX_RESOLVE_COUNT; | ||
// DNS resolver method | ||
let limitedResolver = async (domain, type) => { | ||
return async (domain, type) => { | ||
// do not allow to make more that MAX_RESOLVE_COUNT DNS requests per SPF check | ||
@@ -117,3 +89,46 @@ resolveCount++; | ||
}; | ||
}; | ||
/** | ||
* | ||
* @param {Object} opts | ||
* @param {String} opts.sender Email address | ||
* @param {String} opts.ip Client IP address | ||
* @param {String} [opts.mta] Hostname of the MTA or MX server that processes the message | ||
* @param {String} opts.helo Client EHLO/HELO hostname | ||
*/ | ||
const verify = async opts => { | ||
let { sender, ip, mta, helo, resolver, maxResolveCount } = opts || {}; | ||
mta = mta || os.hostname(); | ||
sender = sender || `postmaster@${helo}`; | ||
// convert mapped IPv6 IP addresses to IPv4 | ||
let mappingMatch = (ip || '').toString().match(/^[:A-F]+:((\d+\.){3}\d+)$/i); | ||
if (mappingMatch) { | ||
ip = mappingMatch[1]; | ||
} | ||
let atPos = sender.indexOf('@'); | ||
if (atPos < 0) { | ||
sender = `postmaster@${sender}`; | ||
} else if (atPos === 0) { | ||
sender = `postmaster${sender}`; | ||
} | ||
let domain = sender.split('@').pop().toLowerCase().trim(); | ||
resolver = resolver || dns.promises.resolve; | ||
let status = { | ||
result: 'neutral', | ||
comment: false, | ||
// ptype properties | ||
smtp: { | ||
mailfrom: sender, | ||
helo | ||
} | ||
}; | ||
let result; | ||
@@ -131,3 +146,11 @@ try { | ||
result = await spfVerify(domain, { sender, ip, mta, helo, resolver: limitedResolver }); | ||
result = await spfVerify(domain, { | ||
sender, | ||
ip, | ||
mta, | ||
helo, | ||
// generate DNS handler | ||
resolver: limitedResolver(resolver, maxResolveCount) | ||
}); | ||
} catch (err) { | ||
@@ -160,19 +183,19 @@ if (err.spfResult) { | ||
case '+': | ||
response.status = 'pass'; | ||
response.info = `${mta}: domain of ${sender} designates ${ip} as permitted sender`; | ||
status.result = 'pass'; | ||
status.comment = `${mta}: domain of ${sender} designates ${ip} as permitted sender`; | ||
break; | ||
case '~': | ||
response.status = 'softfail'; | ||
response.info = `${mta}: domain of transitioning ${sender} does not designate ${ip} as permitted sender`; | ||
status.result = 'softfail'; | ||
status.comment = `${mta}: domain of transitioning ${sender} does not designate ${ip} as permitted sender`; | ||
break; | ||
case '-': | ||
response.status = 'fail'; | ||
response.info = `${mta}: domain of ${sender} does not designate ${ip} as permitted sender`; | ||
status.result = 'fail'; | ||
status.comment = `${mta}: domain of ${sender} does not designate ${ip} as permitted sender`; | ||
break; | ||
case '?': | ||
response.status = 'neutral'; | ||
response.info = `${mta}: ${ip} is neither permitted nor denied by domain of ${sender}`; | ||
status.result = 'neutral'; | ||
status.comment = `${mta}: ${ip} is neither permitted nor denied by domain of ${sender}`; | ||
break; | ||
@@ -182,9 +205,9 @@ | ||
case 'none': | ||
response.status = 'none'; | ||
response.info = `${mta}: ${domain} does not designate permitted sender hosts`; | ||
status.result = 'none'; | ||
status.comment = `${mta}: ${domain} does not designate permitted sender hosts`; | ||
break; | ||
case 'permerror': | ||
response.status = 'permerror'; | ||
response.info = `${mta}: permanent error in processing during lookup of ${sender}${result.text ? `: ${result.text}` : ''}`; | ||
status.result = 'permerror'; | ||
status.comment = `${mta}: permanent error in processing during lookup of ${sender}${result.text ? `: ${result.text}` : ''}`; | ||
break; | ||
@@ -194,8 +217,11 @@ | ||
default: | ||
response.status = 'temperror'; | ||
response.info = `${mta}: error in processing during lookup of ${sender}${result.text ? `: ${result.text}` : ''}`; | ||
status.result = 'temperror'; | ||
status.comment = `${mta}: error in processing during lookup of ${sender}${result.text ? `: ${result.text}` : ''}`; | ||
break; | ||
} | ||
response.status = status; | ||
response.header = formatHeaders(response); | ||
response.info = formatAuthHeaderRow('spf', status); | ||
return response; | ||
@@ -202,0 +228,0 @@ }; |
@@ -28,3 +28,3 @@ 'use strict'; | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `invalid address definition: ${val}` }; | ||
err.spfResult = { error: 'permerror', text: `invalid address definition: ${val}` }; | ||
throw err; | ||
@@ -41,3 +41,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `invalid cidr definition: ${val}` }; | ||
err.spfResult = { error: 'permerror', text: `invalid cidr definition: ${val}` }; | ||
throw err; | ||
@@ -80,3 +80,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `multiple SPF records found for ${domain}` }; | ||
err.spfResult = { error: 'permerror', text: `multiple SPF records found for ${domain}` }; | ||
throw err; | ||
@@ -90,3 +90,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'none', message: `no SPF records found for ${domain}` }; | ||
err.spfResult = { error: 'none', text: `no SPF records found for ${domain}` }; | ||
throw err; | ||
@@ -103,3 +103,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `invalid modifier ${part}` }; | ||
err.spfResult = { error: 'permerror', text: `invalid modifier ${part}` }; | ||
throw err; | ||
@@ -118,7 +118,7 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `Empty modifier value for ${modifier}` }; | ||
err.spfResult = { error: 'permerror', text: `Empty modifier value for ${modifier}` }; | ||
throw err; | ||
} else if (modifier === 'redirect' && !/^([\x21-\x2D\x2f-\x7e]+\.)+[a-z]+[a-z\-0-9]*$/.test(value)) { | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `Invalid redirect target ${value}` }; | ||
err.spfResult = { error: 'permerror', text: `Invalid redirect target ${value}` }; | ||
throw err; | ||
@@ -140,3 +140,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `Unknown mechanism ${mechanism}` }; | ||
err.spfResult = { error: 'permerror', text: `Unknown mechanism ${mechanism}` }; | ||
throw err; | ||
@@ -149,3 +149,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `more than 1 redirect found` }; | ||
err.spfResult = { error: 'permerror', text: `more than 1 redirect found` }; | ||
throw err; | ||
@@ -200,3 +200,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `unexpected empty value` }; | ||
err.spfResult = { error: 'permerror', text: `unexpected empty value` }; | ||
throw err; | ||
@@ -227,3 +227,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `unexpected extension for all` }; | ||
err.spfResult = { error: 'permerror', text: `unexpected extension for all` }; | ||
throw err; | ||
@@ -265,3 +265,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `bare IP address` }; | ||
err.spfResult = { error: 'permerror', text: `bare IP address` }; | ||
throw err; | ||
@@ -285,3 +285,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `invalid CIDR for IP` }; | ||
err.spfResult = { error: 'permerror', text: `invalid CIDR for IP` }; | ||
throw err; | ||
@@ -301,3 +301,3 @@ } | ||
let err = new Error('SPF failure'); | ||
err.spfResult = { error: 'permerror', message: `invalid IP address` }; | ||
err.spfResult = { error: 'permerror', text: `invalid IP address` }; | ||
throw err; | ||
@@ -304,0 +304,0 @@ } |
261
lib/tools.js
@@ -0,1 +1,3 @@ | ||
/* eslint no-control-regex: 0 */ | ||
'use strict'; | ||
@@ -10,4 +12,6 @@ | ||
const packageData = require('../package'); | ||
const parseDkimHeaders = require('./parse-dkim-headers'); | ||
const psl = require('psl'); | ||
const defaultFieldNames = | ||
const defaultDKIMFieldNames = | ||
'From:Sender:Reply-To:Subject:Date:Message-ID:To:' + | ||
@@ -20,2 +24,9 @@ 'Cc:MIME-Version:Content-Type:Content-Transfer-Encoding:Content-ID:' + | ||
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 writeToStream = async (stream, input, chunkSize) => { | ||
@@ -91,4 +102,4 @@ chunkSize = chunkSize || 64 * 1024; | ||
const getSignedHeaderLines = (parsedHeaders, fieldNames, verify) => { | ||
fieldNames = (fieldNames || defaultFieldNames) | ||
const getSigningHeaderLines = (parsedHeaders, fieldNames, verify) => { | ||
fieldNames = (fieldNames || defaultDKIMFieldNames) | ||
.split(':') | ||
@@ -131,10 +142,51 @@ .map(key => key.trim().toLowerCase()) | ||
*/ | ||
const formatDKIMHeaderLine = (values, folded) => { | ||
values = Object.assign({ v: 1, t: Math.round(Date.now() / 1000), q: 'dns/txt' }, values); | ||
let keyOrdering = ['v', 'a', 'c', 'd', 'h', 'i', 'l', 'q', 's', 't', 'x', 'z', 'bh', 'b']; | ||
const formatSignatureHeaderLine = (type, values, folded) => { | ||
type = (type || '').toString().toUpperCase(); | ||
let header = | ||
'DKIM-Signature: ' + | ||
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) | ||
.filter(key => values[key] !== false && typeof values[key] !== 'undefined' && values.key !== null && keyOrdering.includes(key)) | ||
.sort((a, b) => keyOrdering.indexOf(a) - keyOrdering.indexOf(b)) | ||
@@ -157,3 +209,3 @@ .map(key => { | ||
if (key === 'i') { | ||
if (key === 'i' && type === 'DKIM') { | ||
let atPos = val.indexOf('@'); | ||
@@ -183,53 +235,7 @@ if (atPos >= 0) { | ||
const parseDkimHeader = buf => { | ||
let line = (buf || '').toString().trim(); | ||
let splitterPos = line.indexOf(':'); | ||
if (splitterPos >= 0) { | ||
line = line.substr(splitterPos + 1).trim(); | ||
} | ||
return { | ||
parsed: Object.fromEntries( | ||
line | ||
.replace(/\r?\n/g, '') | ||
.split(/\s*;\s*/) | ||
.map(entry => { | ||
let splitterPos = entry.indexOf('='); | ||
let key, value; | ||
if (splitterPos < 0) { | ||
key = 'default'; | ||
value = entry.replace(/\s+/g, ' ').trim(); | ||
} else { | ||
key = entry.substr(0, splitterPos); | ||
value = entry | ||
.substr(splitterPos + 1) | ||
.trim() | ||
.replace(/\s+/g, ' ') | ||
.trim(); | ||
} | ||
key = key.toLowerCase().trim(); | ||
value = value.trim(); | ||
switch (key) { | ||
case 'bh': | ||
case 'b': | ||
case 'p': | ||
value = value.replace(/\s+/g, ''); | ||
break; | ||
} | ||
return [key, value.trim()]; | ||
}) | ||
), | ||
original: buf | ||
}; | ||
}; | ||
const getPublicKey = async (rr, resolver) => { | ||
const getPublicKey = async (type, rr, resolver) => { | ||
resolver = resolver || dns.resolve; | ||
let list = await resolver(rr, 'TXT'); | ||
let entry = | ||
let row = | ||
list && | ||
@@ -241,12 +247,7 @@ [] | ||
if (entry) { | ||
entry = parseDkimHeader(entry); | ||
if (row) { | ||
// prefix value for parsing as there is no default value | ||
let entry = parseDkimHeaders(`DNS: TXT;${row}`); | ||
if (entry.parsed.v && (entry.parsed.v || '').toString().toLowerCase().trim() !== 'dkim1') { | ||
let err = new Error('Unknown key version'); | ||
err.code = 'EINVALIDVER'; | ||
throw err; | ||
} | ||
let pubKey = entry.parsed && entry.parsed.p; | ||
let pubKey = entry?.parsed?.p?.value; | ||
if (!pubKey) { | ||
@@ -258,6 +259,12 @@ let err = new Error('Missing key value'); | ||
if (type === 'DKIM' && entry?.parsed?.v && (entry?.parsed?.v?.value || '').toString().toLowerCase().trim() !== 'dkim1') { | ||
let err = new Error('Unknown key version'); | ||
err.code = 'EINVALIDVER'; | ||
throw err; | ||
} | ||
pubKey = Buffer.from(`-----BEGIN PUBLIC KEY-----\n${pubKey}\n-----END PUBLIC KEY-----`); | ||
let keyType = crypto.createPublicKey({ key: pubKey, format: 'pem' }).asymmetricKeyType; | ||
if (!['rsa', 'ed25519'].includes(keyType) || (entry.parsed.k && entry.parsed.k.toLowerCase() !== keyType)) { | ||
if (!['rsa', 'ed25519'].includes(keyType) || (entry?.parsed?.k && entry?.parsed?.k?.value?.toLowerCase() !== keyType)) { | ||
let err = new Error('Unknown key type'); | ||
@@ -320,10 +327,120 @@ err.code = 'EINVALIDTYPE'; | ||
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.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 getAligment = (fromDomain, domainList, strict) => { | ||
domainList = [].concat(domainList || []); | ||
if (strict) { | ||
fromDomain = formatDomain(fromDomain); | ||
for (let domain of domainList) { | ||
domain = formatDomain(psl.get(domain) || domain); | ||
if (formatDomain(domain) === fromDomain) { | ||
return domain; | ||
} | ||
} | ||
} | ||
// match org domains | ||
fromDomain = formatDomain(psl.get(fromDomain) || fromDomain); | ||
for (let domain of domainList) { | ||
domain = formatDomain(psl.get(domain) || domain); | ||
if (domain === fromDomain) { | ||
return domain; | ||
} | ||
} | ||
return false; | ||
}; | ||
module.exports = { | ||
writeToStream, | ||
parseHeaders, | ||
getSignedHeaderLines, | ||
formatDKIMHeaderLine, | ||
parseDkimHeader, | ||
defaultDKIMFieldNames, | ||
defaultARCFieldNames, | ||
defaultASFieldNames, | ||
getSigningHeaderLines, | ||
formatSignatureHeaderLine, | ||
parseDkimHeaders, | ||
getPublicKey, | ||
fetch | ||
formatAuthHeaderRow, | ||
escapeCommentValue, | ||
fetch, | ||
getAligment, | ||
formatRelaxedLine | ||
}; |
{ | ||
"name": "mailauth", | ||
"version": "1.0.2", | ||
"version": "1.0.4", | ||
"description": "Email authentication library for Node.js", | ||
@@ -27,12 +27,12 @@ "main": "lib/mailauth.js", | ||
"chai": "4.2.0", | ||
"eslint": "7.11.0", | ||
"eslint": "7.12.1", | ||
"eslint-config-nodemailer": "1.2.0", | ||
"eslint-config-prettier": "6.12.0", | ||
"eslint-config-prettier": "6.15.0", | ||
"js-yaml": "3.14.0", | ||
"mbox-reader": "1.1.5", | ||
"mocha": "8.1.3" | ||
"mocha": "8.2.0" | ||
}, | ||
"dependencies": { | ||
"ipaddr.js": "2.0.0", | ||
"joi": "17.2.1", | ||
"joi": "17.3.0", | ||
"libmime": "5.0.0", | ||
@@ -39,0 +39,0 @@ "node-forge": "0.10.0", |
@@ -9,4 +9,4 @@ # mailauth | ||
- [x] DMARC verification | ||
- [ ] ARC signing | ||
- [ ] ARC verification | ||
- [x] ARC verification | ||
- [ ] ARC sealing | ||
- [ ] MTA-STS resolver | ||
@@ -62,3 +62,3 @@ | ||
Validate DKIM signatures, SPF and DMARC for an email. | ||
Validate DKIM signatures, SPF, DMARC and ARC for an email. | ||
@@ -86,8 +86,8 @@ ```js | ||
``` | ||
Received-SPF: pass (mx.ethereal.email: domain of andris@ekiri.ee designates | ||
217.146.67.33 as permitted sender) client-ip=217.146.67.33; | ||
Received-SPF: pass (mx.ethereal.email: domain of andris@ekiri.ee designates 217.146.67.33 as permitted sender) client-ip=217.146.67.33; | ||
Authentication-Results: mx.ethereal.email; | ||
dkim=pass header.i=@ekiri.ee header.s=default header.b="1VSEye1n" | ||
spf=pass (mx.ethereal.email: domain of andris@ekiri.ee designates | ||
217.146.67.33 as permitted sender) smtp.mailfrom=andris@ekiri.ee; | ||
dkim=pass header.i=@ekiri.ee header.s=default header.a=rsa-sha256 header.b=TXuCNlsq; | ||
spf=pass (mx.ethereal.email: domain of andris@ekiri.ee designates 217.146.67.33 as permitted sender) smtp.mailfrom=andris@ekiri.ee | ||
smtp.helo=uvn-67-33.tll01.zonevs.eu; | ||
arc=pass (i=2 spf=neutral dkim=pass dkdomain=ekiri.ee); | ||
dmarc=none header.from=ekiri.ee | ||
@@ -163,3 +163,3 @@ From: ... | ||
dkim=pass header.i=@tahvel.info header.s=test.rsa header.b="BrEgDN4A" | ||
dkim=policy (weak key) header.i=@tahvel.info header.s=test.small header.b="d0jjgPun" | ||
dkim=policy policy.dkim-rules=weak-key header.i=@tahvel.info header.s=test.small header.b="d0jjgPun" | ||
``` | ||
@@ -166,0 +166,0 @@ |
@@ -142,5 +142,5 @@ /* eslint no-unused-expressions:0 */ | ||
if (Array.isArray(testdata.result)) { | ||
expect(testdata.result).to.include(result.status); | ||
expect(testdata.result).to.include(result?.status?.result); | ||
} else { | ||
expect(result.status).to.equal(testdata.result); | ||
expect(testdata.result).to.equal(result?.status?.result); | ||
} | ||
@@ -147,0 +147,0 @@ }); |
Sorry, the diff of this file is not supported yet
418215
54
3311
+ Added@sideway/address@4.1.5(transitive)
+ Added@sideway/formula@3.0.1(transitive)
+ Added@sideway/pinpoint@2.0.0(transitive)
+ Addedjoi@17.3.0(transitive)
- Removed@hapi/address@4.1.0(transitive)
- Removed@hapi/formula@2.0.0(transitive)
- Removed@hapi/pinpoint@2.0.1(transitive)
- Removedjoi@17.2.1(transitive)
Updatedjoi@17.3.0