haraka-plugin-dkim
Version:
Haraka DKIM plugin
472 lines (432 loc) • 15.4 kB
JavaScript
'use strict'
const crypto = require('node:crypto')
const dns = require('node:dns')
function md5(str = '') {
return crypto.createHash('md5').update(str).digest('hex')
}
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 = structuredClone(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.body_found = false
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', 'invalid')
}
if (this.fields.a) {
switch (this.fields.a) {
case 'rsa-sha1':
// RFC 8301 §3.2: verifiers MUST NOT treat messages with rsa-sha1
// as having a valid author signature.
return this.result(
'rsa-sha1 is insecure and not supported',
'invalid',
)
case 'rsa-sha256':
this.bh = crypto.createHash('SHA256')
this.verifier = crypto.createVerify('RSA-SHA256')
break
case 'ed25519-sha256':
this.bh = crypto.createHash('SHA256')
// Ed25519 doesn't use the same Verify interface in Node as RSA so
// use crypto.verify later. To keep it consistent, buffer the
// canonicalized headers for Ed25519.
this.is_ed25519 = true
this.header_data = Buffer.alloc(0)
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 { i, d } = this.fields
const domOffset = i.length - d.length
const prevChar = domOffset > 0 ? i[domOffset - 1] : ''
if (
i.slice(domOffset).toLowerCase() !== d.toLowerCase() ||
(prevChar !== '' && prevChar !== '@' && prevChar !== '.')
) {
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 = Date.now() / 1000
if (this.fields.t) {
const sigTimestamp = parseInt(this.fields.t, 10)
if (
sigTimestamp >
(this.allowed_time_skew
? now + parseInt(this.allowed_time_skew, 10)
: now)
) {
return this.result(
'creation date is invalid or in the future',
'invalid',
)
}
}
if (this.fields.x) {
const expireTimestamp = parseInt(this.fields.x, 10)
const createTimestamp = this.fields.t ? parseInt(this.fields.t, 10) : 0
if (this.fields.t && expireTimestamp < createTimestamp) {
return this.result('invalid expiration date', 'invalid')
}
if (
(this.allowed_time_skew
? now - parseInt(this.allowed_time_skew, 10)
: now) > expireTimestamp
) {
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.trim().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 {
this.body_found = true
if (this.line_buffer.length > 0) {
for (const v of this.line_buffer) 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
if (!this.body_found) {
this.bh.update('\r\n')
}
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')
}
const updateVerifier = (data) => {
if (this.is_ed25519) {
this.header_data = Buffer.concat([
this.header_data,
typeof data === 'string' ? Buffer.from(data) : data,
])
} else {
this.verifier.update(data)
}
}
// 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') {
updateVerifier(this_header)
} else if (this.headercanon === 'relaxed') {
const hc = this.header_canon_relaxed(this_header)
updateVerifier(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$/, '')
updateVerifier(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 || res.length === 0)
return this.result('no key for signature', 'invalid')
if (res.length > 1) {
this.debug(`${this.identity}: multiple DNS records found`)
return this.result('multiple DNS records found', '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 eqIdx = element.indexOf('=')
if (eqIdx > 0) {
this.dns_fields[element.slice(0, eqIdx)] = element.slice(eqIdx + 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) {
// h= lists the hash algorithms the key owner considers acceptable.
// Check that the hash algorithm used in the signature (a= field) is
// in that list. a= is "keytype-hashalg" e.g. "rsa-sha256".
const acceptableHashes = this.dns_fields.h
.split(':')
.map((h) => h.trim())
const sigHash = this.fields.a.split('-').pop() // 'sha256' from 'rsa-sha256'
if (!acceptableHashes.includes(sigHash)) {
return this.result('inappropriate hash algorithm', 'invalid')
}
}
if (this.dns_fields.k) {
// k= specifies the key type. Extract the key-type component of a=
// (everything before the first '-') and compare exactly.
const sigKeyType = this.fields.a.split('-')[0] // 'rsa' from 'rsa-sha256'
if (sigKeyType !== 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 must match exactly
const iDomain = this.fields.i.slice(
this.fields.i.indexOf('@') + 1,
)
if (iDomain.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')
let verified
try {
if (this.is_ed25519) {
const publicKey = crypto.createPublicKey({
key: Buffer.from(this.dns_fields.p, 'base64'),
format: 'der',
type: 'spki',
})
verified = crypto.verify(
null,
this.header_data,
publicKey,
Buffer.from(this.fields.b, 'base64'),
)
} else {
// 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`
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)
}
}
module.exports = DKIMObject