UNPKG

haraka-plugin-dkim

Version:
776 lines (701 loc) 23 kB
'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