diff --git a/.codeclimate.yml b/.codeclimate.yml index 96490fa..c889eb8 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -1,10 +1,10 @@ engines: eslint: enabled: true - channel: "eslint-8" + channel: 'eslint-8' config: - config: ".eslintrc.yaml" + config: '.eslintrc.yaml' ratings: paths: - - "**.js" + - '**.js' diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 693b320..035a400 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -4,4 +4,4 @@ env: mocha: true es2022: true -extends: ["@haraka"] +extends: ['@haraka'] diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c3ac4a..df04b68 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,9 +2,9 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "monthly" + interval: 'monthly' allow: - dependency-type: production diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2b614e3..816e8c3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,4 @@ -name: "CodeQL" +name: 'CodeQL' on: push: @@ -6,7 +6,7 @@ on: pull_request: branches: [master] schedule: - - cron: "18 7 * * 4" + - cron: '18 7 * * 4' jobs: codeql: diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1d2127c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,2 @@ +semi: false +singleQuote: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9ea33..e463f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,44 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased -### [1.0.6] - 2024-04-08 +### 1.0.0 - 2024-04-09 -- dep: eslint-plugin-haraka -> @haraka/eslint-config -- lint: remove duplicate / stale rules from .eslintrc -- populate [files] in package.json. Delete .npmignore. -- doc: Changes.md -> CHANGELOG.md -- test: switch to node --test +- repackaged from haraka/Haraka -### 1.0.5 - 2023-12-12 - -- ci: run tests on PR -- ci(publish): add on.release trigger -- deps(dev\*): pin versions - -### [1.0.3] - 2022-06-05 - -- ci: replace hard coded vers with node-lts-versions -- ci(publish): add secrets: inherit -- ci: use reusable workflows (#18) - -### [1.0.2] - 2022-05-23 - -- ci: replace hard coded node vers with node-lts-versions -- ci(publish): add secrets: inherit -- ci: use reusable workflows (#18) -- packaging updates - -### [1.0.1] - 2021-02-04 - -- test: added example tests that set up conn/txn -- ci: add automated package publishing -- ci(GHA): consolidate \*nix & win tests - -### 1.0.0 - 2017-02-02 - -- initial release - -[1.0.1]: https://github.com/haraka/haraka-plugin-template/releases/tag/1.0.1 -[1.0.2]: https://github.com/haraka/haraka-plugin-template/releases/tag/1.0.2 -[1.0.3]: https://github.com/haraka/haraka-plugin-template/releases/tag/1.0.3 -[1.0.6]: https://github.com/haraka/haraka-plugin-template/releases/tag/v1.0.6 +[1.0.0]: https://github.com/haraka/haraka-plugin-template/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 9f4306f..dd1b024 100644 --- a/README.md +++ b/README.md @@ -3,74 +3,152 @@ [![NPM][npm-img]][npm-url] -# haraka-plugin-template +# haraka-plugin-dkim -Clone me, to create a new Haraka plugin! +## INSTALL + +```sh +cd /path/to/local/haraka +npm install haraka-plugin-dkim +echo "dkim" >> config/plugins +service haraka restart +``` -# Template Instructions +### Configuration + +If the default configuration is not sufficient, copy the config file from the distribution into your haraka config dir and then modify it: -These instructions will not self-destruct after use. Use and destroy. +```sh +cp node_modules/haraka-plugin-dkim/config/dkim.ini config/dkim.ini +$EDITOR config/dkim.ini +``` -See also, [How to Write a Plugin](https://github.com/haraka/Haraka/wiki/Write-a-Plugin) and [Plugins.md](https://github.com/haraka/Haraka/blob/master/docs/Plugins.md) for additional plugin writing information. +## SIGNING -## Create a new repo for your plugin +This plugin implements the [DKIM Core specification](dkimcore.org). -Haraka plugins are named like `haraka-plugin-something`. All the namespace after `haraka-plugin-` is yours for the taking. Please check the [Plugins](https://github.com/haraka/Haraka/blob/master/Plugins.md) page and a Google search to see what plugins already exist. +### Getting Started -Once you've settled on a name, create the GitHub repo. On the repo's main page, click the _Clone or download_ button and copy the URL. Then paste that URL into a local ENV variable with a command like this: +Generate a DKIM selector and keys for your domain: ```sh -export MY_GITHUB_ORG=haraka -export MY_PLUGIN_NAME=haraka-plugin-SOMETHING +cd /path/to/haraka/config/dkim +./dkim_key_gen.sh example.org ``` -Clone and rename the template repo: +Within the config/dkim/${domain} directory will be 4 files: ```sh -git clone git@github.com:haraka/haraka-plugin-template.git -mv haraka-plugin-template $MY_PLUGIN_NAME -cd $MY_PLUGIN_NAME -git remote rm origin -git remote add origin "git@github.com:$MY_GITHUB_ORG/$MY_PLUGIN_NAME.git" +ls config/dkim/example.org/ +dns private public selector ``` -Now you'll have a local git repo to begin authoring your plugin +The selector file contains the DNS label where the DKIM public key is published. The `private` and `public` files contain the DKIM keys. -## rename boilerplate +The `dns` file contains a formatted record of the public key suitable for copy/pasting into your domains zone file. It also has suggestions for DKIM, SPF, and DMARC policy records. -Replaces all uses of the word `template` with your plugin's name. +The DKIM DNS record will look like this: -./redress.sh [something] + may2013._domainkey TXT "v=DKIM1;p=[public key stripped of whitespace];" -You'll then be prompted to update package.json and then force push this repo onto the GitHub repo you've created earlier. +The values in the address have the following meaning: -# Add your content here + hash: h=[ sha1 | sha256 ] + test; t=[ s | s:y ] + granularity: g=[ ] + notes: n=[ ] + services: s=[email] + keytypes: [ rsa ] -## INSTALL +## Key size -```sh -cd /path/to/local/haraka -npm install haraka-plugin-template -echo "template" >> config/plugins -service haraka restart +The default key size created by `dkim_key_gen.sh` is 2048. That is considered secure as of mid-2024. + +# What to sign + +The DKIM signing key for messages from example.org _should_ be signed with +a DKIM key for example.org. Failing to do so will result in messages not +having an _aligned_ DKIM signature. For DMARC enabled domains, this will +likely result in deliverability problems. + +For correct alignment, Haraka signs each message with that domains DKIM key. +For an alternative, see the legacy Single Domain Configuration below. + +# Configuration + +DKIM signing is configured in the sign section of `dkim.ini`. + +```ini +[sign] +enabled = [ 1 | true | yes ], default=false +headers = list, of; headers (REQUIRED) + +; for single domain configuration +selector = name +domain = name ``` -### Configuration +* headers: the list of headers that should be signed, separated by commas, colons or semi-colons. Signing prevents tampering with the specified headers. The 'From' header is required by the RFC and will be added if missing. -If the default configuration is not sufficient, copy the config file from the distribution into your haraka config dir and then modify it: +## Single Domain Configuration + +To sign all messages with a single DKIM key, you must set the selector and domain in dkim.ini. You must also save your DKIM private key in the file `dkim.private.key` in the Haraka config directory. + +- selector - Set this to the selector name published in DNS under the \_domainkey sub-domain of the domain referenced below. +- domain - Set this to the domain name that will be used to sign messages which don't match a per-domain DKIM key. The DNS TXT entry for: + + .\_domainkey. + +Test that your DKIM key is published properly with a DNS request like this: ```sh -cp node_modules/haraka-plugin-template/config/template.ini config/template.ini -$EDITOR config/template.ini +drill TXT $SELECTOR._domainkey.$DOMAIN +dig TXT $SELECTOR._domainkey.$DOMAIN +short +``` + +### Example DNS query + +```sh +export SELECTOR=mar2013 +export DOMAIN=simerson.net +$ dig TXT $SELECTOR._domainkey.$DOMAIN +short +"v=DKIM1;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoyUzGOTSOmakY8BcxXgi0mN/nFegLBPs7aaGQUtjHfa8yUrt9T2j6GSXgdjLuG3R43WjePQv3RHzc+bwwOkdw0XDOXiztn5mhrlaflbVr5PMSTrv64/cpFQKLtgQx8Vgqp7Dh3jw13rLomRTqJFgMrMHdhIibZEa69gtuAfDqoeXo6QDSGk5JuBAeRHEH27FriHulg5ob" "4F4lmh7fMFVsDGkQEF6jaIVYqvRjDyyQed3R3aTJX3fpb3QrtRqvfn/LAf+3kzW58AjsERpsNCSTD2RquxbnyoR/1wdGKb8cUlD/EXvqtvpVnOzHeSeMEqex3kQI8HOGsEehWZlKd+GqwIDAQAB" +``` + +# DKIM VERIFY + +Verify DKIM signatures as defined by RFC 6376 and add an Authentication-Results header as appropriate. + +## Configuration + +```ini +[verify] +; allowed_time_skew = (How far can we stretch on time matching, in secs. Useful when clock is skewed.) +; sigerror_log_level = ``` -## USAGE +## Testing + +This plugin provides a command-line test tool that can be used to +debug DKIM issues or to check results. + +``` +# dkimverify < message +identity="@gmail.com" domain="gmail.com" result=pass +``` + +You can add `--debug` to the option arguments to see a full trace of the processing. + +## Notes + +This plugin and underlying library do not currently support DKIM body length limits (l=). + -[ci-img]: https://github.com/haraka/haraka-plugin-template/actions/workflows/ci.yml/badge.svg -[ci-url]: https://github.com/haraka/haraka-plugin-template/actions/workflows/ci.yml -[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-template/badges/gpa.svg -[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-template -[npm-img]: https://nodei.co/npm/haraka-plugin-template.png -[npm-url]: https://www.npmjs.com/package/haraka-plugin-template +[ci-img]: https://github.com/haraka/haraka-plugin-dkim/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-dkim/actions/workflows/ci.yml +[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-dkim/badges/gpa.svg +[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-dkim +[npm-img]: https://nodei.co/npm/haraka-plugin-dkim.png +[npm-url]: https://www.npmjs.com/package/haraka-plugin-dkim diff --git a/bin/dkimverify b/bin/dkimverify new file mode 100755 index 0000000..ee197e5 --- /dev/null +++ b/bin/dkimverify @@ -0,0 +1,41 @@ +#!/usr/bin/env node + +// DKIM test tool + +const nopt = require('nopt') +const path = require('path') +const base_path = path.join(__dirname, '..') +const dkim = require(`${base_path}/lib/dkim`) +const DKIMVerifyStream = dkim.DKIMVerifyStream + +const parsed = nopt({ debug: Boolean, time_skew: Number, help: Boolean }) + +function print_usage() { + console.log('Usage: dkimverify [--debug] [--time_skew=123] < message') + process.exit(1) +} + +if (parsed.help) print_usage() + +if (!parsed.debug) { + dkim.DKIMObject.prototype.debug = function (str) {} + DKIMVerifyStream.prototype.debug = function (str) {} +} + +const opts = {} +if (parsed.time_skew) opts.allowed_time_skew = parsed.time_skew + +const verifier = new DKIMVerifyStream(opts, (err, result, results) => { + if (err) console.log(err.message) + if (Array.isArray(results)) { + results.forEach(function (res) { + console.log( + `identity="${res.identity}" domain="${res.domain}" result=${res.result} ${res.error ? `(${res.error})` : ''}`, + ) + }) + } else { + console.log(`Result: ${result}`) + } +}) + +process.stdin.pipe(verifier) diff --git a/config/dkim.ini b/config/dkim.ini new file mode 100644 index 0000000..3f0cfaf --- /dev/null +++ b/config/dkim.ini @@ -0,0 +1,18 @@ + +[main] + + +[sign] +enabled = false +selector = mail +domain = example.com +headers = From, Sender, Reply-To, Subject, Date, Message-ID, To, Cc, MIME-Version + + +[verify] +; Recommended (but not default) values presented below. + +; in secs +;allowed_time_skew = 60 +; +;sigerror_log_level = info diff --git a/config/dkim_key_gen.sh b/config/dkim_key_gen.sh new file mode 100755 index 0000000..edbc944 --- /dev/null +++ b/config/dkim_key_gen.sh @@ -0,0 +1,72 @@ +#!/bin/sh + +usage() +{ + echo " usage: ${0} [haraka username]" 2>&1 + echo 2>&1 + exit 1 +} + +DOMAIN="$1" +if [ -z "$DOMAIN" ]; then usage; fi + +SMTPD=${2="www"} + +# Create a directory for each DKIM signing domain +mkdir -p "$DOMAIN" +cd "$DOMAIN" || exit + +# The selector can be any value that is a valid DNS label +# Create in the common format: mmmYYYY (apr2014) +date '+%h%Y' | tr '[:upper:]' '[:lower:]' > selector + +# Generate private and public keys +# - Key length considerations - +# The minimum recommended key length for short duration keys (ones that +# will be replaced within a few months) is 1024. If you are unlikely to +# rotate your keys frequently, choose 2048, at the expense of more CPU. +openssl genrsa -out private 2048 +chmod 0400 private +openssl rsa -in private -out public -pubout + +DNS_NAME="$(tr -d '\n' < selector)._domainkey" +DNS_ADDRESS="v=DKIM1;p=$(grep -v '^-' public | tr -d '\n')" + +# Fold width is arbitrary, any value between 80 and 255 is reasonable +BIND_SPLIT_ADDRESS="$(echo "$DNS_ADDRESS" | fold -w 110 | sed -e 's/^/ "/g; s/$/"/g')" + +# Make it really easy to publish the public key in DNS +# by creating a file named 'dns', with instructions +cat > dns < { - this.load_example_ini(); + 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() + } + + exports.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 +} + +dkim.DKIMObject.prototype.debug = (str) => { + exports.logdebug(str) +} + +DKIMVerifyStream.prototype.debug = (str) => { + exports.logdebug(str) +} + +exports.dkim_verify = function (next, connection) { + 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') + } + results.forEach((res) => { + 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' }) +} diff --git a/lib/dkim.js b/lib/dkim.js new file mode 100644 index 0000000..2f5469c --- /dev/null +++ b/lib/dkim.js @@ -0,0 +1,775 @@ +'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 diff --git a/package.json b/package.json index 5721aec..a059644 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,13 @@ { - "name": "haraka-plugin-template", - "version": "1.0.6", - "description": "Haraka plugin that...CHANGE THIS", + "name": "haraka-plugin-dkim", + "version": "1.0.1", + "description": "Haraka DKIM plugin", "main": "index.js", "files": [ "CHANGELOG.md", - "config" + "bin", + "config", + "lib" ], "scripts": { "format": "npm run prettier:fix && npm run lint:fix", @@ -19,21 +21,29 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/haraka/haraka-plugin-template.git" + "url": "git+https://github.com/haraka/haraka-plugin-dkim.git" }, "keywords": [ "haraka", - "plugin", - "template" + "haraka-plugin", + "dkim" ], - "author": "Welcome Member ", + "author": "Welcome Member ", "license": "MIT", "bugs": { - "url": "https://github.com/haraka/haraka-plugin-template/issues" + "url": "https://github.com/haraka/haraka-plugin-dkim/issues" }, - "homepage": "https://github.com/haraka/haraka-plugin-template#readme", + "homepage": "https://github.com/haraka/haraka-plugin-dkim#readme", "devDependencies": { "@haraka/eslint-config": "1.1.3", "haraka-test-fixtures": "1.3.5" + }, + "dependencies": { + "address-rfc2821": "^2.1.2", + "address-rfc2822": "^2.2.1", + "async": "^3.2.5", + "haraka-email-message": "^1.2.2", + "haraka-utils": "^1.1.2", + "nopt": "^7.2.0" } } diff --git a/redress.sh b/redress.sh deleted file mode 100755 index 88660e5..0000000 --- a/redress.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/sh - -if [ -z "$1" ]; then - echo "$0 something" - exit -fi - -sed -i '' \ - -e "s/template/${1}/g" \ - README.md - -sed -i '' \ - -e "s/template/${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - test/index.js - -sed -i '' \ - -e "s/template/${1}/g" \ - package.json - -sed -i '' \ - -e "s/_template/_${1}/g" \ - -e "s/template\.ini/$1.ini/" \ - index.js - -tee Changes.md < { + this.plugin = new fixtures.plugin('dkim') + + this.connection = fixtures.connection.createConnection() + this.connection.transaction = fixtures.transaction.createTransaction() +}) + +describe('get_sender_domain', () => { + beforeEach(() => { + this.connection.transaction.mail_from = {} + }) + + it('no transaction', () => { + delete this.connection.transaction + assert.equal(this.plugin.get_sender_domain(this.connection), undefined) + }) + + it('no headers', () => { + assert.equal(this.plugin.get_sender_domain(this.connection), undefined) + }) + + it('no from header', () => { + this.connection.transaction.header.add( + 'Date', + utils.date_to_str(new Date()), + ) + assert.equal(this.plugin.get_sender_domain(this.connection), undefined) + }) + + it('no from header, env MAIL FROM', () => { + this.connection.transaction.mail_from = new Address.Address( + '', + ) + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.com') + }) + + it('env MAIL FROM, case insensitive', () => { + this.connection.transaction.mail_from = new Address.Address( + '', + ) + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.com') + }) + + it('From header not a fqdn', () => { + this.connection.transaction.header.add('From', 'root (Cron Daemon)') + const r = this.plugin.get_sender_domain(this.connection) + this.plugin.get_key_dir(this.connection, { domain: r }, (err, dir) => { + assert.equal(dir, undefined) + }) + }) + + it('from header, simple', () => { + this.connection.transaction.header.add( + 'From', + 'John Doe ', + ) + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.com') + }) + + it('from header, case insensitive', () => { + this.connection.transaction.header.add( + 'From', + 'John Doe ', + ) + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.com') + }) + + it('from header, less simple', () => { + this.connection.transaction.header.add( + 'From', + '"Joe Q. Public" ', + ) + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.com') + }) + + it('from header, RFC 5322 odd', () => { + this.connection.transaction.header.add( + 'From', + 'Pete(A nice \\) chap) ', + ) + assert.equal(this.plugin.get_sender_domain(this.connection), 'silly.test') + }) + + it('from header group', () => { + this.connection.transaction.header.add( + 'From', + 'ben@example.com,carol@example.com', + ) + this.connection.transaction.header.add('Sender', 'dave@example.net') + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.net') + }) + + it('from header group, RFC 6854', () => { + // TODO: this test passes, but the parsing isn't correct. The From + // addr parser doesn't support the RFC 6854 Group Syntax + this.connection.transaction.header.add( + 'From', + 'Managing Partners:ben@example.com,carol@example.com;', + ) + this.connection.transaction.header.add('Sender', 'dave@example.net') + assert.equal(this.plugin.get_sender_domain(this.connection), 'example.net') + }) +}) + +describe('get_key_dir', () => { + beforeEach(async () => { + await fs.mkdir(path.resolve('test', 'config', 'dkim', 'example.com'), { + recursive: true, + }) + }) + + it('no transaction', (t, done) => { + this.plugin.get_key_dir(this.connection, '', (err, dir) => { + assert.ifError(err) + assert.equal(dir, undefined) + done() + }) + }) + + it('no key dir', (t, done) => { + this.connection.transaction.mail_from = new Address.Address( + '', + ) + this.plugin.get_key_dir(this.connection, 'non-exist.com', (err, dir) => { + assert.equal(dir, undefined) + done() + }) + }) + + it('test example.com key dir', (t, done) => { + process.env.HARAKA = path.resolve('test') + this.connection.transaction.mail_from = new Address.Address( + '', + ) + this.plugin.get_key_dir( + this.connection, + { domain: 'example.com' }, + (err, dir) => { + // console.log(arguments); + const expected = path.resolve('test', 'config', 'dkim', 'example.com') + assert.equal(dir, expected) + done() + }, + ) + }) +}) + +describe('get_headers_to_sign', () => { + it('none configured, includes from', () => { + this.plugin.cfg = { sign: {} } + assert.deepEqual(this.plugin.get_headers_to_sign(), ['from']) + }) + + it('from, subject', () => { + this.plugin.cfg = { sign: { headers: 'from,subject' } } + assert.deepEqual(this.plugin.get_headers_to_sign(), ['from', 'subject']) + }) + + it('subject configured, subject and from returned', () => { + this.plugin.cfg = { sign: { headers: 'subject' } } + assert.deepEqual(this.plugin.get_headers_to_sign(), ['subject', 'from']) + }) +}) + +const insecure_512b_test_key = + '-----BEGIN RSA PRIVATE KEY-----\nMIGqAgEAAiEAsw3E27MbZuxmWpYfjNX5XzKTMxIv8bIAU/MpjiJE5rkCAwEAAQIg\nIVsyTj96nlzx4HRRIlqGXw7wx3C+vGhoM/Ql/eFXRVECEQDbUYF19fyzPDKAqb7p\nEu5tAhEA0QBD5Ns4QgpC8m1Qob05/QIQf1jWWU5aSyC7GmZ2ChQKCQIQIACNZNaY\nZ6xQkfRhG1LxNQIRAIyKwDCULf7Jl5ygc1MIIdk=\n-----END RSA PRIVATE KEY-----' + +describe('get_sign_properties', () => { + beforeEach(() => { + this.plugin.config.root_path = path.resolve(__dirname, '../config') + this.plugin.load_dkim_ini() + this.plugin.load_dkim_default_key() + }) + + it('example.com from ENV mail from', () => { + this.connection.transaction.mail_from = new Address.Address( + '', + ) + this.plugin.get_sign_properties(this.connection, (err, props) => { + if (err) console.error(err) + assert.deepEqual(props, { + domain: 'example.com', + selector: 'aug2019', + private_key: insecure_512b_test_key, + }) + }) + }) + + it('no domain discovered returns default', () => { + this.connection.transaction.mail_from = {} + this.plugin.get_sign_properties(this.connection, (err, props) => { + if (err) console.error(err) + assert.deepEqual(props, { + domain: this.plugin.cfg.sign.domain, + selector: this.plugin.cfg.sign.selector, + private_key: this.plugin.private_key, + }) + }) + }) +}) + +describe('has_key_data', () => { + it('no data', () => { + assert.equal(this.plugin.has_key_data(this.connection, {}), false) + }) + + it('fully populated', () => { + assert.equal( + this.plugin.has_key_data(this.connection, { + selector: 'foo', + domain: 'bar', + private_key: 'anything', + }), + true, + ) + }) +}) + +describe('load_key', () => { + it('example.com test key', () => { + const testKey = path.resolve( + 'test', + 'config', + 'dkim', + 'example.com', + 'private', + ) + assert.equal(this.plugin.load_key(testKey), insecure_512b_test_key) + }) +}) diff --git a/test/dkim_signer.js b/test/dkim_signer.js new file mode 100644 index 0000000..39f4dfc --- /dev/null +++ b/test/dkim_signer.js @@ -0,0 +1,129 @@ +'use strict' + +const assert = require('node:assert') +const { beforeEach, describe, it } = require('node:test') + +const fixtures = require('haraka-test-fixtures') +const message = require('haraka-email-message') + +const { DKIMSignStream } = require('../lib/dkim') + +const privateKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC +jxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gb +to/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB +AoGBALmn+XwWk7akvkUlqb+dOxyLB9i5VBVfje89Teolwc9YJT36BGN/l4e0l6QX +/1//6DWUTB3KI6wFcm7TWJcxbS0tcKZX7FsJvUz1SbQnkS54DJck1EZO/BLa5ckJ +gAYIaqlA9C0ZwM6i58lLlPadX/rtHb7pWzeNcZHjKrjM461ZAkEA+itss2nRlmyO +n1/5yDyCluST4dQfO8kAB3toSEVc7DeFeDhnC1mZdjASZNvdHS4gbLIA1hUGEF9m +3hKsGUMMPwJBAPW5v/U+AWTADFCS22t72NUurgzeAbzb1HWMqO4y4+9Hpjk5wvL/ +eVYizyuce3/fGke7aRYw/ADKygMJdW8H/OcCQQDz5OQb4j2QDpPZc0Nc4QlbvMsj +7p7otWRO5xRa6SzXqqV3+F0VpqvDmshEBkoCydaYwc2o6WQ5EBmExeV8124XAkEA +qZzGsIxVP+sEVRWZmW6KNFSdVUpk3qzK0Tz/WjQMe5z0UunY9Ax9/4PVhp/j61bf +eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX +GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc= +-----END RSA PRIVATE KEY-----` + +/* +Body hash can be checked by: + +$ echo -e -n 'Hello world!\r\n'| openssl dgst -binary -sha256 | openssl base64 +z6TUz85EdYrACGMHYgZhJGvVy5oQI0dooVMKa2ZT7c4= +$ echo -e -n '\r\n' | openssl dgst -binary -sha256 | openssl base64 +frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY= +*/ + +function getValueFromDKIM(dkim_header, key) { + const kv = dkim_header.split(';') + for (let i = 0, len = kv.length; i < len; i++) { + const arr = kv[i].match(/^\s*([^=]+)=(.*)$/) + if (arr[1] === key) { + return arr[2] + } + } + throw `Key ${key} not found at ${dkim_header}` +} + +const props = { + selector: 'selector', + domain: 'haraka.top', + private_key: privateKey, +} + +beforeEach(() => { + this.plugin = new fixtures.plugin('dkim') + + this.connection = fixtures.connection.createConnection() + this.connection.transaction = fixtures.transaction.createTransaction() + // this.connection.transaction.mail_from = {} +}) + +describe('sign', () => { + beforeEach(() => { + this.plugin.load_dkim_ini() + props.headers = this.plugin.cfg.headers_to_sign + }) + + it('body hash simple', () => { + // from RFC + const email = + 'Ignored: header\r\n\r\nHi.\r\n\r\nWe lost the game. Are you hungry yet?\r\n\r\nJoe.\r\n' + + const header = new message.Header() + header.parse(['Ignored: header']) + const signer = new DKIMSignStream(props, header, (n, dkim) => { + assert.equal( + getValueFromDKIM(dkim, 'bh'), + '2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=', + ) + }) + signer.write(Buffer.from(email)) + signer.end() + }) + + it('empty body hash simple', () => { + const email = 'Ignored: header\r\n\r\n' + const header = new message.Header() + header.parse(['Ignored: header']) + const signer = new DKIMSignStream(props, header, (n, dkim) => { + assert.equal( + getValueFromDKIM(dkim, 'bh'), + 'frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=', + ) + }) + signer.write(Buffer.from(email)) + signer.end() + }) + + it('body hash simple, two writes', () => { + const header = new message.Header() + header.parse(['Ignored: header']) + const signer = new DKIMSignStream(props, header, (n, dkim) => { + assert.equal( + getValueFromDKIM(dkim, 'bh'), + '2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=', + ) + }) + signer.write( + Buffer.from('Ignored: header\r\n\r\nHi.\r\n\r\nWe lost the game. '), + ) + signer.write(Buffer.from('Are you hungry yet?\r\n\r\nJoe.\r\n')) + signer.end() + }) + + it('body hash simple, empty lines', () => { + const email = + 'Ignored: header\r\n\r\nHi.\r\n\r\nWe lost the game. Are you hungry yet?\r\n\r\nJoe.\r\n\r\n\r\n' + + const header = new message.Header() + header.parse(['Ignored: header']) + const signer = new DKIMSignStream(props, header, (n, dkim) => { + assert.equal( + getValueFromDKIM(dkim, 'bh'), + '2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=', + ) + }) + signer.write(Buffer.from(email)) + signer.end() + }) +}) diff --git a/test/index.js b/test/index.js index 5f65df3..9d887d1 100644 --- a/test/index.js +++ b/test/index.js @@ -1,43 +1,93 @@ -const assert = require("node:assert/strict"); -const { beforeEach, describe, it } = require("node:test"); +const assert = require('node:assert') +const { beforeEach, describe, it } = require('node:test') +const path = require('path') // npm modules -const fixtures = require("haraka-test-fixtures"); - -// start of tests -// assert: https://nodejs.org/api/assert.html - -beforeEach(function () { - this.plugin = new fixtures.plugin("template"); -}); - -describe("template", function () { - it("loads", function () { - assert.ok(this.plugin); - }); -}); - -describe("load_template_ini", function () { - it("loads template.ini from config/template.ini", function () { - this.plugin.load_template_ini(); - assert.ok(this.plugin.cfg); - }); - - it("initializes enabled boolean", function () { - this.plugin.load_template_ini(); - assert.equal(this.plugin.cfg.main.enabled, true, this.plugin.cfg); - }); -}); - -describe("uses text fixtures", function () { - it("sets up a connection", function () { - this.connection = fixtures.connection.createConnection({}); - assert.ok(this.connection.server); - }); - - it("sets up a transaction", function () { - this.connection = fixtures.connection.createConnection({}); - this.connection.transaction = fixtures.transaction.createTransaction({}); - assert.ok(this.connection.transaction.header); - }); -}); +const fixtures = require('haraka-test-fixtures') + +beforeEach(() => { + this.plugin = new fixtures.plugin('dkim') + this.plugin.config.root_path = path.resolve('test','config') + delete this.plugin.config.overrides_path +}) + +describe('plugin', () => { + + it('loads', () => { + assert.ok(this.plugin) + }) + + it('loads dkim.ini', () => { + this.plugin.load_dkim_ini() + assert.ok(this.plugin.cfg) + }) + + it('initializes enabled boolean', () => { + this.plugin.load_dkim_ini() + // console.log(this.plugin.cfg?.sign) + assert.equal(this.plugin.cfg.sign.enabled, true, this.plugin.cfg) + }) +}) + +describe('uses text fixtures', () => { + it('sets up a connection', () => { + this.connection = fixtures.connection.createConnection({}) + assert.ok(this.connection.server) + }) + + it('sets up a transaction', () => { + this.connection = fixtures.connection.createConnection({}) + this.connection.transaction = fixtures.transaction.createTransaction({}) + assert.ok(this.connection.transaction.header) + }) +}) + +const expectedCfg = { + main: {}, + sign: { + enabled: false, + selector: 'mail', + domain: 'example.com', + headers: + 'From, Sender, Reply-To, Subject, Date, Message-ID, To, Cc, MIME-Version', + }, + verify: { + timeout: 29, + }, + headers_to_sign: [ + 'from', + 'sender', + 'reply-to', + 'subject', + 'date', + 'message-id', + 'to', + 'cc', + 'mime-version', + ], +} + +describe('register', () => { + beforeEach(() => { + this.plugin.config.root_path = path.resolve(__dirname, '../config') + }) + + it('registers', () => { + assert.deepEqual(this.plugin.cfg, undefined) + this.plugin.register() + assert.deepEqual(this.plugin.cfg, expectedCfg) + }) +}) + +describe('load_dkim_ini', () => { + beforeEach(() => { + this.plugin.config.root_path = path.resolve(__dirname, '../config') + }) + + it('loads dkim.ini', () => { + // console.log(this.plugin) + assert.deepEqual(this.plugin.cfg, undefined) + this.plugin.load_dkim_ini() + assert.deepEqual(this.plugin.cfg, expectedCfg) + }) +})