index.js
'use strict'
const fs = require('fs')
const path = require('path')
const async = require('async')
const addrparser = require('address-rfc2822')
const dkim = require('./lib/dkim')
const { DKIMVerifyStream, DKIMSignStream } = dkim
exports.register = function () {
const plugin = this
this.load_dkim_ini()
dkim.DKIMObject.prototype.debug = (str) => {
plugin.logdebug(str)
}
DKIMVerifyStream.prototype.debug = (str) => {
plugin.logdebug(str)
}
if (this.cfg.verify.enabled) {
this.register_hook('data_post', 'dkim_verify')
}
if (this.cfg.sign.enabled) {
this.register_hook('queue_outbound', 'hook_pre_send_trans_email')
}
}
exports.load_dkim_ini = function () {
this.cfg = this.config.get(
'dkim.ini',
{
booleans: ['-sign.enabled', '+verify.enabled'],
},
() => {
this.load_dkim_ini()
},
)
if (this.cfg.verify === undefined) this.cfg.verify = {}
if (!this.cfg.verify.timeout) {
this.cfg.verify.timeout = this.timeout ? this.timeout - 1 : 29
}
this.load_dkim_default_key()
this.cfg.headers_to_sign = this.get_headers_to_sign()
}
// dkim_signer
// Implements DKIM core as per www.dkimcore.org
exports.load_dkim_default_key = function () {
this.private_key = this.config
.get('dkim.private.key', 'data', () => {
this.load_dkim_default_key()
})
.join('\n')
}
exports.load_key = function (file) {
return this.config.get(file, 'data').join('\n')
}
exports.hook_pre_send_trans_email = function (next, connection) {
if (!this.cfg.sign.enabled) return next()
if (!connection?.transaction) return next()
if (connection.transaction.notes?.dkim_signed) {
connection.logdebug(this, 'already signed')
return next()
}
this.get_sign_properties(connection, (err, props) => {
if (!connection?.transaction) return next()
// props: selector, domain, & private_key
if (err) connection.logerror(this, `${err.message}`)
if (!this.has_key_data(connection, props)) return next()
connection.logdebug(this, `domain: ${props.domain}`)
const txn = connection.transaction
props.headers = this.cfg.headers_to_sign
txn.message_stream.pipe(
new DKIMSignStream(props, txn.header, (err2, dkim_header) => {
if (err2) {
txn.results.add(this, { err: err2.message })
return next(err2)
}
connection.loginfo(this, `signed for ${props.domain}`)
txn.results.add(this, { pass: dkim_header })
txn.add_header('DKIM-Signature', dkim_header)
connection.transaction.notes.dkim_signed = true
next()
}),
)
})
}
exports.get_sign_properties = function (connection, done) {
if (!connection.transaction) return
const domain = this.get_sender_domain(connection)
if (!domain) {
connection.transaction.results.add(this, {
msg: 'sending domain not detected',
emit: true,
})
}
const props = { domain }
this.get_key_dir(connection, props, (err, keydir) => {
if (err) {
console.error(`err: ${err}`)
connection.logerror(this, err)
return done(
new Error(`Error getting DKIM key_dir for ${domain}: ${err}`),
props,
)
}
if (!connection.transaction) return done(null, props)
// a directory for ${domain} exists
if (keydir) {
props.domain = path.basename(keydir) // keydir might be apex (vs sub)domain
props.private_key = this.load_key(
path.join('dkim', props.domain, 'private'),
)
props.selector = this.load_key(
path.join('dkim', props.domain, 'selector'),
).trim()
if (!props.selector) {
connection.transaction.results.add(this, {
err: `missing selector for domain ${domain}`,
})
}
if (!props.private_key) {
connection.transaction.results.add(this, {
err: `missing dkim private_key for domain ${domain}`,
})
}
if (props.selector && props.private_key) {
// AND has correct files
return done(null, props)
}
}
// try [default / single domain] configuration
if (this.cfg.sign.domain && this.cfg.sign.selector && this.private_key) {
connection.transaction.results.add(this, {
msg: 'using default key',
emit: true,
})
props.domain = this.cfg.sign.domain
props.private_key = this.private_key
props.selector = this.cfg.sign.selector
return done(null, props)
}
console.error(`no valid DKIM properties found`)
done(null, props)
})
}
exports.get_key_dir = function (connection, props, done) {
if (!props.domain) return done()
// split the domain name into labels
const labels = props.domain.split('.')
const haraka_dir = process.env.HARAKA || ''
// list possible matches (ex: mail.example.com, example.com, com)
const dom_hier = []
for (let i = 0; i < labels.length; i++) {
const dom = labels.slice(i).join('.')
dom_hier[i] = path.resolve(haraka_dir, 'config', 'dkim', dom)
}
async.detectSeries(
dom_hier,
(filePath, iterDone) => {
fs.stat(filePath, (err, stats) => {
if (err) return iterDone(null, false)
iterDone(null, stats.isDirectory())
})
},
(err, results) => {
connection.logdebug(this, results)
done(err, results)
},
)
}
exports.has_key_data = function (conn, props) {
let missing = undefined
// Make sure we have all the relevant configuration
if (!props.private_key) {
missing = 'private key'
} else if (!props.selector) {
missing = 'selector'
} else if (!props.domain) {
missing = 'domain'
}
if (missing) {
if (props.domain) {
conn.lognotice(this, `skipped: no ${missing} for ${props.domain}`)
} else {
conn.lognotice(this, `skipped: no ${missing}`)
}
return false
}
conn.logprotocol(
this,
`using selector: ${props.selector} at domain ${props.domain}`,
)
return true
}
exports.get_headers_to_sign = function () {
if (!this.cfg?.sign?.headers) return ['from']
const headers = this.cfg.sign.headers
.toLowerCase()
.replace(/\s+/g, '')
.split(/[,;:]/)
// From MUST be present
if (!headers.includes('from')) headers.push('from')
return headers
}
exports.get_sender_domain = function (connection) {
const txn = connection?.transaction
if (!txn) return
// fallback: use Envelope FROM when header parsing fails
let domain
if (txn.mail_from.host) {
try {
domain = txn.mail_from.host.toLowerCase()
} catch (e) {
connection.logerror(this, e)
}
}
// In case of forwarding, only use the Envelope
if (txn.notes.forward) return domain
if (!txn.header) return domain
// the DKIM signing key should be aligned with the domain in the From
// header (see DMARC). Try to parse the domain from there.
const from_hdr = txn.header.get_decoded('From')
if (!from_hdr) return domain
// The From header can contain multiple addresses and should be
// parsed as described in RFC 2822 3.6.2.
let addrs
try {
addrs = addrparser.parse(from_hdr)
} catch (e) {
connection.logerror(
this,
`address-rfc2822 failed to parse From header: ${from_hdr}`,
)
return domain
}
if (!addrs || !addrs.length) return domain
// If From has a single address, we're done
if (addrs.length === 1 && addrs[0].host) {
let fromHost = addrs[0].host()
if (fromHost) {
// don't attempt to lower a null or undefined value #1575
fromHost = fromHost.toLowerCase()
}
return fromHost
}
// If From has multiple-addresses, we must parse and
// use the domain in the Sender header.
const sender = txn.header.get_decoded('Sender')
if (sender) {
try {
domain = addrparser.parse(sender)[0].host().toLowerCase()
} catch (e) {
connection.logerror(this, e)
}
}
return domain
}
exports.dkim_verify = function (next, connection) {
if (!this.cfg.verify.enabled) return next()
const txn = connection?.transaction
if (!txn) return next()
const verifier = new DKIMVerifyStream(
this.cfg.verify,
(err, result, results) => {
if (err) {
txn.results.add(this, { err })
return next()
}
if (!results || results.length === 0) {
txn.results.add(this, { skip: 'no/bad dkim signature' })
return next(CONT, 'no/bad signature')
}
for (const res of results) {
let res_err = ''
if (res.error) res_err = ` (${res.error})`
connection.auth_results(
`dkim=${res.result}${res_err} header.i=${res.identity} header.d=${res.domain} header.s=${res.selector}`,
)
connection.loginfo(
this,
`identity="${res.identity}" domain="${res.domain}" selector="${res.selector}" result=${res.result} ${res_err}`,
)
// save to ResultStore
const rs_obj = JSON.parse(JSON.stringify(res))
if (res.result === 'pass') {
rs_obj.pass = res.domain
} else if (res.result === 'fail') {
rs_obj.fail = res.domain + res_err
} else {
rs_obj.err = res.domain + res_err
}
txn.results.add(this, rs_obj)
}
connection.logdebug(this, JSON.stringify(results))
// Store results for other plugins
txn.notes.dkim_results = results
next()
},
)
txn.message_stream.pipe(verifier, { line_endings: '\r\n' })
}