msimerson/Haraka

View on GitHub
dkim.js

Summary

Maintainability
F
1 wk
Test Coverage
'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;

//////////////////////
// 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;