lib/dkim.js
'use strict'
const crypto = require('crypto')
const dns = require('dns')
const { Stream } = require('stream')
const utils = require('haraka-utils')
//////////////////////
// Common functions //
//////////////////////
function md5(str) {
if (!str) str = ''
const h = crypto.createHash('md5')
return h.update(str).digest('hex')
}
class Buf {
constructor() {
this.bar = []
this.blen = 0
}
pop(buf) {
if (!this.bar.length) {
if (!buf) buf = Buffer.from('')
return buf
}
if (buf?.length) {
this.bar.push(buf)
this.blen += buf.length
}
const nb = Buffer.concat(this.bar, this.blen)
this.bar = []
this.blen = 0
return nb
}
push(buf) {
if (buf.length) {
this.bar.push(buf)
this.blen += buf.length
}
}
}
////////////////
// DKIMObject //
////////////////
// There is one DKIMObject created for each signature found
class DKIMObject {
constructor(header, header_idx, cb, opts) {
this.cb = cb
this.sig = header
this.sig_md5 = md5(header)
this.run_cb = false
this.header_idx = JSON.parse(JSON.stringify(header_idx))
this.timeout = opts.timeout || 30
this.allowed_time_skew = opts.allowed_time_skew
this.fields = {}
this.headercanon = this.bodycanon = 'simple'
this.signed_headers = []
this.identity = 'unknown'
this.line_buffer = []
this.dns_fields = {
v: 'DKIM1',
k: 'rsa',
g: '*',
}
const [, , dkim_signature] = /^([^:]+):\s*((?:.|[\r\n])*)$/.exec(header)
const sig = dkim_signature.trim().replace(/\s+/g, '')
const keys = sig.split(';')
for (const keyElement of keys) {
const key = keyElement.trim()
if (!key) continue // skip empty keys
const [, key_name, key_value] =
/^([^= ]+)=((?:.|[\r\n])+)$/.exec(key) || []
if (key_name) {
this.fields[key_name] = key_value
} else {
return this.result('header parse error', 'invalid')
}
}
/////////////////////
// Validate fields //
/////////////////////
if (this.fields.v) {
if (this.fields.v !== '1') {
return this.result('incompatible version', 'invalid')
}
} else {
return this.result('missing version', 'invalid')
}
if (this.fields.l) {
return this.result('length tag is unsupported', 'none')
}
if (this.fields.a) {
switch (this.fields.a) {
case 'rsa-sha1':
this.bh = crypto.createHash('SHA1')
this.verifier = crypto.createVerify('RSA-SHA1')
break
case 'rsa-sha256':
this.bh = crypto.createHash('SHA256')
this.verifier = crypto.createVerify('RSA-SHA256')
break
default:
this.debug(`Invalid algorithm: ${this.fields.a}`)
return this.result('invalid algorithm', 'invalid')
}
} else {
return this.result('missing algorithm', 'invalid')
}
if (!this.fields.b) return this.result('signature missing', 'invalid')
if (!this.fields.bh) return this.result('body hash missing', 'invalid')
if (this.fields.c) {
const c = this.fields.c.split('/')
if (c[0]) this.headercanon = c[0]
if (c[1]) this.bodycanon = c[1]
}
if (!this.fields.d) return this.result('domain missing', 'invalid')
if (this.fields.h) {
const headers = this.fields.h.split(':')
for (const h of headers) {
this.signed_headers.push(h.trim().toLowerCase())
}
if (!this.signed_headers.includes('from')) {
return this.result('from field not signed', 'invalid')
}
} else {
return this.result('signed headers missing', 'invalid')
}
if (this.fields.i) {
// Make sure that this is a sub-domain of the 'd' field
const dom = this.fields.i.substr(
this.fields.i.length - this.fields.d.length,
)
if (dom.toLowerCase() !== this.fields.d.toLowerCase()) {
return this.result('i/d selector domain mismatch', 'invalid')
}
} else {
this.fields.i = `@${this.fields.d}`
}
this.identity = this.fields.i
if (this.fields.q && this.fields.q !== 'dns/txt') {
return this.result('unknown query method', 'invalid')
}
const now = new Date().getTime() / 1000
if (this.fields.t) {
if (
this.fields.t >
(this.allowed_time_skew ? now + parseInt(this.allowed_time_skew) : now)
) {
return this.result(
'creation date is invalid or in the future',
'invalid',
)
}
}
if (this.fields.x) {
if (this.fields.t && parseInt(this.fields.x) < parseInt(this.fields.t)) {
return this.result('invalid expiration date', 'invalid')
}
if (
(this.allowed_time_skew
? now - parseInt(this.allowed_time_skew)
: now) > parseInt(this.fields.x)
) {
return this.result(`signature expired`, 'invalid')
}
}
this.debug(`${this.identity}: DKIM fields validated OK`)
this.debug(
`${this.identity}: a=${this.fields.a} c=${this.headercanon}/${this.bodycanon} h=${this.signed_headers}`,
)
}
debug(str) {
console.debug(str)
}
header_canon_relaxed(header) {
// `|| []` prevents errors thrown when no match
// `\s*` eats all FWS after the colon
// eslint-disable-next-line prefer-const
let [, header_name, header_value] = /^([^:]+):\s*([^]*)$/.exec(header) || []
if (!header_name) return header
if (header_value.length === 0) header_value = '\r\n'
let hc = `${header_name.toLowerCase()}:${header_value}`
hc = hc.replace(/\r\n([\t ]+)/g, '$1')
hc = hc.replace(/[\t ]+/g, ' ')
hc = hc.replace(/[\t ]+(\r?\n)$/, '$1')
return hc
}
add_body_line(line) {
if (this.run_cb) return
if (this.bodycanon === 'relaxed') {
line = DKIMObject.canonicalize(line)
}
// Buffer any lines
const isCRLF = line.length === 2 && line[0] === 0x0d && line[1] === 0x0a
const isLF = line.length === 1 && line[0] === 0x0a
if (isCRLF || isLF) {
// Store any empty lines as both canonicalization algorithms
// ignore all empty lines at the end of the message body.
this.line_buffer.push(line)
} else {
if (this.line_buffer.length > 0) {
this.line_buffer.forEach((v) => this.bh.update(v))
this.line_buffer = []
}
this.bh.update(line)
}
}
result(error, result) {
this.run_cb = true
return this.cb(error ? new Error(error) : null, {
identity: this.identity,
selector: this.fields.s,
domain: this.fields.d,
result,
})
}
end() {
if (this.run_cb) return
const bh = this.bh.digest('base64')
this.debug(`${this.identity}: bodyhash=${this.fields.bh} computed=${bh}`)
if (bh !== this.fields.bh) {
return this.result('body hash did not verify', 'fail')
}
// Now we canonicalize the specified headers
for (const header of this.signed_headers) {
this.debug(`${this.identity}: canonicalize header: ${header}`)
if (this.header_idx[header]) {
// RFC 6376 section 5.4.2, read headers from bottom to top
const this_header = this.header_idx[header].pop()
if (this_header) {
// Skip this signature if dkim-signature is specified
if (header === 'dkim-signature') {
const h_md5 = md5(this_header)
if (h_md5 === this.sig_md5) {
this.debug(`${this.identity}: skipped our own DKIM-Signature`)
continue
}
}
if (this.headercanon === 'simple') {
this.verifier.update(this_header)
} else if (this.headercanon === 'relaxed') {
const hc = this.header_canon_relaxed(this_header)
this.verifier.update(hc)
}
}
}
}
// Now add in our original DKIM-Signature header without the b= and trailing CRLF
let our_sig = this.sig.replace(/([:;\s\t]|^)b=([^;]+)/, '$1b=')
if (this.headercanon === 'relaxed') {
our_sig = this.header_canon_relaxed(our_sig)
}
our_sig = our_sig.replace(/\r\n$/, '')
this.verifier.update(our_sig)
let timeout = false
const timer = setTimeout(() => {
timeout = true
return this.result('DNS timeout', 'tempfail')
}, this.timeout * 1000)
const lookup = `${this.fields.s}._domainkey.${this.fields.d}`
this.debug(
`${this.identity}: DNS lookup ${lookup} (timeout= ${this.timeout}s)`,
)
dns.resolveTxt(lookup, (err, res) => {
if (timeout) return
clearTimeout(timer)
if (err) {
switch (err.code) {
case dns.NOTFOUND:
case dns.NODATA:
case dns.NXDOMAIN:
return this.result('no key for signature', 'invalid')
default:
this.debug(`${this.identity}: DNS lookup error: ${err.code}`)
return this.result('key unavailable', 'tempfail')
}
}
if (!res) return this.result('no key for signature', 'invalid')
for (const recordSegments of res) {
const record = recordSegments.join('')
if (!record.includes('p=')) {
this.debug(`${this.identity}: ignoring TXT record: ${record}`)
continue
}
this.debug(`${this.identity}: got DNS record: ${record}`)
const rec = record.replace(/\r?\n/g, '').replace(/\s+/g, '')
const split = rec.split(';')
for (const element of split) {
const split2 = element.split('=')
if (split2[0]) this.dns_fields[split2[0]] = split2[1]
}
// Validate
if (!this.dns_fields.v || this.dns_fields.v !== 'DKIM1') {
return this.result('invalid version', 'invalid')
}
if (this.dns_fields.g) {
if (this.dns_fields.g !== '*') {
let s = this.dns_fields.g
// Escape any special regexp characters
s = s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
// Make * a non-greedy match against anything except @
s = s.replace('\\*', '[^@]*?')
const reg = new RegExp(`^${s}@`)
this.debug(
`${this.identity}: matching ${this.dns_fields.g} against i=${this.fields.i} regexp=${reg.toString()}`,
)
if (!reg.test(this.fields.i)) {
return this.result('inapplicable key', 'invalid')
}
}
} else {
return this.result('inapplicable key', 'invalid')
}
if (this.dns_fields.h) {
const hashes = this.dns_fields.h.split(':')
for (const hashElement of hashes) {
const hash = hashElement.trim()
if (!this.fields.a.includes(hash)) {
return this.result('inappropriate hash algorithm', 'invalid')
}
}
}
if (this.dns_fields.k) {
if (!this.fields.a.includes(this.dns_fields.k)) {
return this.result('inappropriate key type', 'invalid')
}
}
if (this.dns_fields.t) {
const flags = this.dns_fields.t.split(':')
for (const flagElement of flags) {
const flag = flagElement.trim()
if (flag === 'y') {
// Test mode
this.test_mode = true
} else if (flag === 's') {
// 'i' and 'd' domain much match exactly
let { i } = this.fields
i = i.substr(i.indexOf('@') + 1, i.length)
if (i.toLowerCase() !== this.fields.d.toLowerCase()) {
return this.result(
'i/d selector domain mismatch (t=s)',
'invalid',
)
}
}
}
}
if (!this.dns_fields.p) return this.result('key revoked', 'invalid')
// crypto.verifier requires the key in PEM format
this.public_key = `-----BEGIN PUBLIC KEY-----\r\n${this.dns_fields.p.replace(
/(.{1,76})/g,
'$1\r\n',
)}-----END PUBLIC KEY-----\r\n`
let verified
try {
verified = this.verifier.verify(
this.public_key,
this.fields.b,
'base64',
)
this.debug(`${this.identity}: verified=${verified}`)
} catch (e) {
this.debug(`${this.identity}: verification error: ${e.message}`)
return this.result('verification error', 'invalid')
}
return this.result(null, verified ? 'pass' : 'fail')
}
// We didn't find a valid DKIM record for this signature
this.result('no key for signature', 'invalid')
})
}
static canonicalize(bufin) {
const tmp = []
const len = bufin.length
let last_chunk_idx = 0
let idx_wsp = 0
let in_wsp = false
for (let idx = 0; idx < len; idx++) {
const char = bufin[idx]
if (char === 9 || char === 32) {
// inside WSP
if (!in_wsp) {
// WSP started
in_wsp = true
idx_wsp = idx
}
} else if (char === 13 || char === 10) {
// CR?LF
if (in_wsp) {
// just after WSP
tmp.push(bufin.slice(last_chunk_idx, idx_wsp))
} else {
// just after regular char
tmp.push(bufin.slice(last_chunk_idx, idx))
}
break
} else if (in_wsp) {
// regular char after WSP
in_wsp = false
tmp.push(bufin.slice(last_chunk_idx, idx_wsp))
tmp.push(Buffer.from(' '))
last_chunk_idx = idx
}
}
tmp.push(Buffer.from([13, 10]))
return Buffer.concat(tmp)
}
}
exports.DKIMObject = DKIMObject
class DKIMSignStream extends Stream {
constructor(props, header, done) {
super()
this.selector = props.selector
// fix issue #2668 renaming reserved kw/property of 'domain' to 'domain_name'
this.domain_name = props.domain
this.private_key = props.private_key
this.headers_to_sign = props.headers
this.header = header
this.end_callback = done
this.writable = true
this.found_eoh = false
this.buffer = { ar: [], len: 0 }
this.hash = crypto.createHash('SHA256')
this.line_buffer = { ar: [], len: 0 }
this.signer = crypto.createSign('RSA-SHA256')
this.body_found = false
}
write(buf) {
/*
** BODY (simple canonicalization)
*/
// Merge in any partial data from last iteration
if (this.buffer.ar.length) {
this.buffer.ar.push(buf)
this.buffer.len += buf.length
const nb = Buffer.concat(this.buffer.ar, this.buffer.len)
buf = nb
this.buffer = { ar: [], len: 0 }
}
// Process input buffer into lines
let offset = 0
while ((offset = utils.indexOfLF(buf)) !== -1) {
const line = buf.slice(0, offset + 1)
if (buf.length > offset) {
buf = buf.slice(offset + 1)
}
// Look for CRLF
if (line.length === 2 && line[0] === 0x0d && line[1] === 0x0a) {
// Look for end of headers marker
if (!this.found_eoh) {
this.found_eoh = true
} else {
// Store any empty lines so that we can discard
// any trailing CRLFs at the end of the message
this.line_buffer.ar.push(line)
this.line_buffer.len += line.length
}
} else {
if (!this.found_eoh) continue // Skip headers
if (this.line_buffer.ar.length) {
// We need to process the buffered CRLFs
const lb = Buffer.concat(this.line_buffer.ar, this.line_buffer.len)
this.line_buffer = { ar: [], len: 0 }
this.hash.update(lb)
}
this.hash.update(line)
this.body_found = true
}
}
if (buf.length) {
// We have partial data...
this.buffer.ar.push(buf)
this.buffer.len += buf.length
}
}
end(buf) {
this.writable = false
// Add trailing CRLF if we have data left over
if (this.buffer.ar.length) {
this.buffer.ar.push(Buffer.from('\r\n'))
this.buffer.len += 2
const le = Buffer.concat(this.buffer.ar, this.buffer.len)
this.hash.update(le)
this.buffer = { ar: [], len: 0 }
}
if (!this.body_found) {
this.hash.update(Buffer.from('\r\n'))
}
const bodyhash = this.hash.digest('base64')
/*
** HEADERS (relaxed canonicaliztion)
*/
const headers = []
for (const element of this.headers_to_sign) {
let head = this.header.get(element)
if (head) {
head = head.replace(/\r?\n/gm, '')
head = head.replace(/\s+/gm, ' ')
head = head.replace(/\s+$/gm, '')
this.signer.update(`${element}:${head}\r\n`)
headers.push(element)
}
}
// Create DKIM header
let dkim_header = `v=1; a=rsa-sha256; c=relaxed/simple; d=${this.domain_name}; s=${this.selector}; h=${headers.join(':')}; bh=${bodyhash}; b=`
this.signer.update(`dkim-signature:${dkim_header}`)
const signature = this.signer.sign(this.private_key, 'base64')
dkim_header = `v=1; a=rsa-sha256; c=relaxed/simple;\r\n\td=${this.domain_name}; s=${this.selector};\r\n\th=${headers.join(':')};\r\n\tbh=${bodyhash};\r\n\tb=`
dkim_header += signature.substring(0, 74)
for (let i = 74; i < signature.length; i += 76) {
dkim_header += `\r\n\t${signature.substring(i, i + 76)}`
}
if (this.end_callback) this.end_callback(null, dkim_header)
this.end_callback = null
}
destroy() {
this.writable = false
// Stream destroyed before the callback ran
if (this.end_callback) {
this.end_callback(new Error('Stream destroyed'))
}
}
}
exports.DKIMSignStream = DKIMSignStream
//////////////////////
// DKIMVerifyStream //
//////////////////////
class DKIMVerifyStream extends Stream {
constructor(opts, cb) {
super()
this.run_cb = false
this.cb = (err, result, results) => {
if (!this.run_cb) {
this.run_cb = true
return cb(err, result, results)
}
}
this._in_body = false
this._no_signatures_found = false
this.buffer = new Buf()
this.headers = []
this.header_idx = {}
this.dkim_objects = []
this.results = []
this.result = 'none'
this.pending = 0
this.writable = true
this.opts = opts
}
debug(str) {
console.debug(str)
}
handle_buf(buf) {
const self = this
// Abort any further processing if the headers
// did not contain any DKIM-Signature fields.
if (this._in_body && this._no_signatures_found) {
return true
}
let once = false
if (buf === null) {
once = true
buf = this.buffer.pop()
if (
!!buf &&
buf[buf.length - 2] === 0x0d &&
buf[buf.length - 1] === 0x0a
) {
return true
}
buf = Buffer.concat([buf, Buffer.from('\r\n\r\n')])
} else {
buf = this.buffer.pop(buf)
}
function callback(err, result) {
self.pending--
if (result) {
const results = {
identity: result.identity,
domain: result.domain,
selector: result.selector,
result: result.result,
}
if (err) {
results.error = err.message
if (self.opts.sigerror_log_level)
results.emit_log_level = self.opts.sigerror_log_level
}
self.results.push(results)
// Set the overall result based on this precedence order
const rr = ['pass', 'tempfail', 'fail', 'invalid', 'none']
for (const element of rr) {
if (
!self.result ||
(self.result &&
self.result !== element &&
result.result === element)
) {
self.result = element
}
}
}
self.debug(JSON.stringify(result))
if (self.pending === 0 && self.cb) {
return process.nextTick(() => {
self.cb(null, self.result, self.results)
})
}
}
// Process input buffer into lines
let offset = 0
while ((offset = utils.indexOfLF(buf)) !== -1) {
let line = buf.slice(0, offset + 1)
if (buf.length > offset) {
buf = buf.slice(offset + 1)
}
// Check for LF line endings and convert to CRLF if necessary
if (line[line.length - 2] !== 0x0d) {
line = Buffer.concat(
[line.slice(0, line.length - 1), Buffer.from('\r\n')],
line.length + 1,
)
}
// Look for CRLF
if (line.length === 2 && line[0] === 0x0d && line[1] === 0x0a) {
// Look for end of headers marker
if (!this._in_body) {
this._in_body = true
// Parse the headers
for (const header of this.headers) {
const match = /^([^: ]+):\s*((:?.|[\r\n])*)/.exec(header)
if (!match) continue
const header_name = match[1]
if (!header_name) continue
const hn = header_name.toLowerCase()
if (!this.header_idx[hn]) this.header_idx[hn] = []
this.header_idx[hn].push(header)
}
if (!this.header_idx['dkim-signature']) {
this._no_signatures_found = true
return process.nextTick(() => {
self.cb(null, self.result, self.results)
})
} else {
// Create new DKIM objects for each header
const dkim_headers = this.header_idx['dkim-signature']
this.debug(`Found ${dkim_headers.length} DKIM signatures`)
this.pending = dkim_headers.length
for (const dkimHeader of dkim_headers) {
this.dkim_objects.push(
new DKIMObject(
dkimHeader,
this.header_idx,
callback,
this.opts,
),
)
}
if (this.pending === 0) {
process.nextTick(() => {
if (self.cb) self.cb(new Error('no signatures found'))
})
}
}
continue // while()
}
}
if (!this._in_body) {
// Parse headers
if (line[0] === 0x20 || line[0] === 0x09) {
// Header continuation
this.headers[this.headers.length - 1] += line.toString('utf-8')
} else {
this.headers.push(line.toString('utf-8'))
}
} else {
for (const dkimObject of this.dkim_objects) {
dkimObject.add_body_line(line)
}
}
if (once) {
break
}
}
this.buffer.push(buf)
return true
}
write(buf) {
return this.handle_buf(buf)
}
end(buf) {
this.handle_buf(buf ? buf : null)
for (const dkimObject of this.dkim_objects) {
dkimObject.end()
}
if (this.pending === 0 && this._no_signatures_found === false) {
process.nextTick(() => {
this.cb(null, this.result, this.results)
})
}
}
}
exports.DKIMVerifyStream = DKIMVerifyStream