diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..40e3787 --- /dev/null +++ b/.php_cs @@ -0,0 +1,46 @@ +setRiskyAllowed(true) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => false, + 'cast_spaces' => single, + 'concat_space' => ['spacing' => 'one'], + 'heredoc_to_nowdoc' => true, + 'method_argument_space' => true, + 'no_extra_consecutive_blank_lines' => [ + 'break', + 'continue', + 'extra', + 'return', + 'throw', + 'use', + 'parenthesis_brace_block', + 'square_brace_block', + 'curly_brace_block' + ], + 'no_php4_constructor' => true, + 'no_short_echo_tag' => true, + 'no_unreachable_default_argument_value' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'ordered_imports' => true, + 'php_unit_fqcn_annotation' => false, + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_order' => true, + 'phpdoc_summary' => false, + 'semicolon_after_instruction' => true, + 'simplified_null_return' => true, + 'native_function_invocation' => false, + 'yoda_style' => false, + 'no_break_comment' => false, + 'native_constant_invocation' => false, + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in(__DIR__ . '/src') + ->in(__DIR__ . '/test') + ) +; diff --git a/DKIM.php b/DKIM.php deleted file mode 100644 index b220f8f..0000000 --- a/DKIM.php +++ /dev/null @@ -1,243 +0,0 @@ -_raw = $rawMessage; - if (!$this->_raw) { - throw new DKIM_Exception('No message content provided'); - } - - $this->_params = $params; - - // to-do: validate RFC-2822 compatible message string - - return $this; - } - - /** - * Canonicalizes a header in either "relaxed" or "simple" modes. - * Requires an array of headers (header names are part of array values) - * - * @param array $headers - * @param string $style - * @return string - * @throws DKIM_Exception - */ - protected function _canonicalizeHeader($headers=array(), $style="simple") { - $headers = (array)$headers; - if (sizeof($headers) == 0) { - throw new DKIM_Exception("Attempted to canonicalize empty header array"); - } - - $cHeader = ''; - switch ($style) { - case 'simple': - $cHeader = implode("\r\n", $headers); - break; - case 'relaxed': - default: - - $new = array(); - foreach ($headers as $header) { - // split off header name - list($name, $val) = explode(':', $header, 2); - - // lowercase field name - $name = trim(strtolower($name)); - - // unfold header values and reduce whitespace - $val = trim(preg_replace('/\s+/s', ' ', $val)); - - $new[] = "$name:$val"; - } - $cHeader = implode("\r\n", $new); - - break; - } - - return $cHeader; - } - - /** - * Canonicalizes a message body in either "relaxed" or "simple" modes. - * Requires a string containing all body content, with an optional byte-length - * - * @param string $body - * @param string $style - * @param int $length - * @return string - * @throws DKIM_Exception - */ - protected function _canonicalizeBody($style='simple', $length=-1) { - - $cBody = $this->_getBodyFromRaw(); - - // trim leading whitespace - - if ($cBody == '') { - return "\r\n"; - } - - # [DG]: mangle newlines - $cBody = str_replace("\r\n","\n",$cBody); - switch ($style) { - case 'relaxed': - default: - // http://tools.ietf.org/html/rfc4871#section-3.4.4 - // strip whitespace off end of lines & - // replace whitespace strings with single whitespace - $cBody = preg_replace('/[ \t]+$/m', '', $cBody); - $cBody = preg_replace('/[ \t]+/m', ' ', $cBody); - - // also perform rules for "simple" canonicalization - - case 'simple': - // http://tools.ietf.org/html/rfc4871#section-3.4.3 - // remove any trailing empty lines - $cBody = preg_replace('/\n+$/s', '', $cBody); - break; - } - $cBody = str_replace("\n","\r\n",$cBody); - - // Add last trailing CRLF - $cBody .= "\r\n"; - - return ($length > 0) ? substr($cBody, 0, $length) : $cBody; - } - - /** - * - * - */ - protected function _getHeaderFromRaw($headerKey, $style='array') { - - $raw = (isset($this->_params['headers'])) ? - str_replace("\r", '', $this->_params['headers']) - : str_replace("\r", '', $this->_raw); - $lines = explode("\n", $raw); - $rawHeaders = array(); - $headerVal = array(); - $counter = 0; - $on = false; - foreach ($lines as $line) { - if ($on === true) { - if (preg_match('/^\w/', $line) !== 0 || trim($line) == '') { - // new header is starting or end of headers - $on = false; - switch ($style) { - case 'array': - default: - list($key, $val) = explode(':', implode("\r\n", $headerVal), 2); - $rawHeaders[$headerKey][$counter] = trim($val); - break; - case 'string': - $rawHeaders[$counter] = implode("\r\n", $headerVal); - break; - } - $headerVal = array(); - $counter++; - } else { - $headerVal[] = $line; - } - } - if (stripos($line, $headerKey) === 0) { - $on = true; - $headerVal[] = $line; - } - - if (trim($line) == '') { - break; - } - } - - return $rawHeaders; - - } - - /** - * - * - */ - protected function _getBodyFromRaw($style='string') { - - if (isset($this->_params['body'])) { - return (string)$this->_params['body']; - } - - $lines = explode("\r\n", $this->_raw); - // Jump past all the headers - $on = false; - while ($line = array_shift($lines)) { - if ($on === true && $line != '') { - break; - } - if ($line == '') { - $on = true; - } - } - - return implode("\r\n", $lines); - - } - - /** - * - * - */ - protected static function _hashBody($body, $method='sha1') { - - // prefer to use phpseclib - // http://phpseclib.sourceforge.net - if (class_exists('Crypt_Hash')) { - $hash = new Crypt_Hash($method); - return base64_encode($hash->hash($body)); - } else { - // try standard PHP hash function - return base64_encode(hash($method, $body, true)); - } - - } -} - - -class DKIM_Exception extends Exception { } \ No newline at end of file diff --git a/DKIM/Sign.php b/DKIM/Sign.php deleted file mode 100644 index 530333d..0000000 --- a/DKIM/Sign.php +++ /dev/null @@ -1,33 +0,0 @@ -_getHeaderFromRaw('DKIM-Signature'); - $signatures = $signatures['DKIM-Signature']; - - // Validate the Signature Header Field - $pubKeys = array(); - foreach ($signatures as $num => $signature) { - - $dkim = preg_replace('/\s+/s', '', $signature); - $dkim = explode(';', trim($dkim)); - foreach ($dkim as $key => $val) { - list($newkey, $newval) = explode('=', trim($val), 2); - unset($dkim[$key]); - if ($newkey == '') { - continue; - } - $dkim[$newkey] = $newval; - } - - // Verify all required values are present - // http://tools.ietf.org/html/rfc4871#section-6.1.1 - $required = array ('v', 'a', 'b', 'bh', 'd', 'h', 's'); - foreach ($required as $key) { - if (!isset($dkim[$key])) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => "signature missing required tag: $key", - ); - continue; - } - } - // abort if we have any errors at this point - if (!empty($results[$num])) { - continue; - } - - if ($dkim['v'] != 1) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => 'incompatible version: ' . $dkim['v'], - ); - continue; - } - // todo: other field validations - - // d is same or subdomain of i - // permfail: domain mismatch - // if no i, assume it is "@d" - - // if h does not include From, - // permfail: From field not signed - - // if x exists and expired, - // permfail: signature expired - - // check d= against list of configurable unacceptable domains - - // optionally require user controlled list of other required signed headers - - - // Get the Public Key - // (note: may retrieve more than one key) - # [DG]: yes, the 'q' tag MAY be empty - fallback to default - if ( empty($dkim['q']) ) $dkim['q'] = 'dns/txt'; - - list($qType, $qFormat) = explode('/', $dkim['q']); - $pubDns = array(); - $abort = false; - switch ($qType) { - case 'dns': - switch ($qFormat) { - case 'txt': - $this->_publicKeys[$dkim['d']] = self::fetchPublicKey($dkim['d'], $dkim['s']); - - break; - default: - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => 'Public key unavailable (unknown q= query format)', - ); - $abort = true; - continue; - break; - } - break; - default: - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => 'Public key unavailable (unknown q= query format)', - ); - $abort = true; - continue; - break; - } - if ($abort === true) { - continue; - } - - // http://tools.ietf.org/html/rfc4871#section-6.1.3 - // build/canonicalize headers - $headerList = array_unique(explode(':', $dkim['h'])); - $headersToCanonicalize = array(); - foreach ($headerList as $headerName) { - $headersToCanonicalize = array_merge($headersToCanonicalize, $this->_getHeaderFromRaw($headerName, 'string')); - } - $headersToCanonicalize[] = 'DKIM-Signature: ' . preg_replace('/b=(.*?)(;|$)/s', 'b=$2', $signature); - - // get canonicalization algorithm - list($cHeaderStyle, $cBodyStyle) = explode('/', $dkim['c']); - list($alg, $hash) = explode('-', $dkim['a']); - - // hash the headers - $cHeaders = $this->_canonicalizeHeader($headersToCanonicalize, $cHeaderStyle); - # [DG]: useless - # $hHeaders = self::_hashBody($cHeaders, $hash); - - // canonicalize body - $cBody = $this->_canonicalizeBody($cBodyStyle); - - // Hash/encode the body - $bh = self::_hashBody($cBody, $hash); - - if ($bh !== $dkim['bh']) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => "Computed body hash does not match signature body hash", - ); - } - - // Iterate over keys - foreach ($this->_publicKeys[$dkim['d']] as $num => $publicKey) { - // Validate key - // confirm that pubkey version matches sig version (v=) - # [DG]: may be missed - if (isset($publicKey['v']) && $publicKey['v'] !== 'DKIM' . $dkim['v']) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => "Public key version does not match signature version ({$dkim['d']} key #$num)", - ); - } - - // confirm that published hash matches sig hash (h=) - if (isset($publicKey['h']) && $publicKey['h'] !== $hash) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => "Public key hash algorithm does not match signature hash algorithm ({$dkim['d']} key #$num)", - ); - } - - // confirm that the key type matches the sig key type (k=) - if (isset($publicKey['k']) && $publicKey['k'] !== $alg) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => "Public key type does not match signature key type ({$dkim['d']} key #$num)", - ); - } - - // See http://tools.ietf.org/html/rfc4871#section-3.6.1 - // verify pubkey granularity (g=) - - // verify service type (s=) - - // check testing flag - - - # [DG]: is $hash algo available for openssl_verify ? - if ( !class_exists('Crypt_RSA') && !defined('OPENSSL_ALGO_'.strtoupper($hash)) ) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => " Signature Algorithm $hash does not available for openssl_verify(), key #$num)", - ); - continue; - } - // Compute the Verification - # [DG]: verify canonized string, not hash ! - $vResult = self::_signatureIsValid($publicKey['p'], $dkim['b'], $cHeaders, $hash); - - if (!$vResult) { - $results[$num][] = array ( - 'status' => 'permfail', - 'reason' => "signature did not verify ({$dkim['d']} key #$num)", - ); - } else { - $results[$num][] = array ( - 'status' => 'pass', - 'reason' => 'Success!', - ); - } - } - - } - - return $results; - } - - /** - * - * - */ - public static function fetchPublicKey($domain, $selector) { - $host = sprintf('%s._domainkey.%s', $selector, $domain); - $pubDns = dns_get_record($host, DNS_TXT); - - if ($pubDns === false) { - return false; - } - - $public = array(); - foreach ($pubDns as $record) { - # [DG]: long key may be split to parts - if ( isset($record['entries']) ) $record['txt'] = implode('',$record['entries']); - $parts = explode(';', trim($record['txt'])); - $record = array(); - foreach ($parts as $part) { - list($key, $val) = explode('=', trim($part), 2); - $record[$key] = $val; - } - $public[] = $record; - } - - return $public; - } - - /** - * - * - */ - protected static function _signatureIsValid($pub, $sig, $str, $hash='sha1') { - // Convert key back into PEM format - $key = sprintf("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", wordwrap($pub, 64, "\n", true)); - - // prefer Crypt_RSA - // http://phpseclib.sourceforge.net - # [DG]: X3 how Crypt_RSA works, skip - if (class_exists('Crypt_RSA')) { - $rsa = new Crypt_RSA(); - $rsa->setHash($hash); - $rsa->setSignatureMode(CRYPT_RSA_SIGNATURE_PKCS1); - $rsa->loadKey($pub); - return $rsa->verify($str, base64_decode($sig)); - } else { - #$pubkeyid = openssl_get_publickey($key); - $signature_alg = constant('OPENSSL_ALGO_'.strtoupper($hash)); - return openssl_verify($str, base64_decode($sig), $key, $signature_alg); - } - - } - -} diff --git a/LICENSE b/LICENSE index 1515493..e3fb94e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) Copyright (c) 2012-2015 Randall Kahler +Modified work Copyright 2015 (c) Bostjan Skufca @ Teon d.o.o. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7e387c7..c835eaa 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,42 @@ -php-dkim -======== - -**Finally, a PHP5 class for not just signing, but _verifying_ DKIM signatures.** - -Requirements ------------- -Currently this package requires PHP 5.1.2 or greater (or PECL `hash` >= 1.1), which provides the `hash()` function. - -Also required, at least one of the following present alongside your PHP installation. - -* [openssl](http://us1.php.net/manual/en/openssl.installation.php) -* [phpseclib](http://phpseclib.sourceforge.net/) - -At least one of those packages must be present in order to compute the RSA signature verification. - -Usage ------ -<pending> - - -Changelog ---------- - -**v0.02** -_5:36 PM 1/2/2013_ - -* Splitting TODOs into separate file. -* Finally got the header hash to match my expected value, based on debugging output from Mail::DKIM::Validate. -* Removed var_dump() calls -* Still doesn't verify signatures properly - not sure where to go from here. - -**v0.01** -_10:55 AM 12/31/2012_ -Initial commit. Most of the structure is in place, and the body hashes are validating, but I haven't been able to get the signature validation correct just yet. I must have some whitespace issue or some random public key problem. +# PHP DKIM Validator + +A straightforward validation class for checking DKIM signatures and header settings. Requires PHP 7.2 or later. + +Looking to *send* DKIM-signed email? Check out [PHPMailer](https://github.com/PHPMailer/PHPMailer)! + +## Installation + +``` +composer require phpmailer/dkimvalidator +``` + +## Usage + +```php +use PHPMailer\DKIMValidator\Validator; +use PHPMailer\DKIMValidator\DKIMException; +require 'vendor/autoload.php'; +//Put a whole raw email message in here +//Load the message directly from disk - +//don't copy & paste it as that will likely affect line breaks & charsets +$message = file_get_contents('message.eml'); +$dkimValidator = new Validator($message); +try { + if ($dkimValidator->validateBoolean()) { + echo "Cool, it's valid"; + } else { + echo 'Uh oh, dodgy email!'; + } +} catch (DKIMException $e) { + echo $e->getMessage(); +} +``` + +# Changelog + +* Original package [angrychimp/php-dkim](https://github.com/angrychimp/php-dkim); +* Forked by [teon/dkimvalidator](https://github.com/teonsystems/php-dkim). +* Forked into [phpmailer/dkimvalidator](https://github.com/PHPMailer/DKIMValidator) by Marcus Bointon (Synchro) in October 2019: + * Restructuring + * Cleanup for PSR-12 and PHP 7.2 + * Various bug fixes and new features. \ No newline at end of file diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 67bbf2b..0000000 --- a/TODO.md +++ /dev/null @@ -1,17 +0,0 @@ -php-dkim -======== - -General TODO List ------------------ - -12/31/2012 - -* TODO: reverse engineer Perl's Mail::DKIM::Verifier package. _(rk:1/2/13)_ -* TODO: start on signing code -* TODO: remove debugging output _(rk:1/2/13)_ - -1/2/2013 - -* TODO: 5.4 of RFC4871; Reverse-order headers to allow for multiple instances of a signed header (e.g. Cc) -* TODO: Allow debugging flags for more verbosity -* TODO: Figure out my damn verification problem \ No newline at end of file diff --git a/composer.json b/composer.json index a627b5d..33cc082 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,52 @@ { - "name": "angrychimp/php-dkim", - "description": "Finally, a PHP5 class for not just signing, but _verifying_ DKIM signatures.", - "authors": [ - { - "name": "angrychimp", - "email": "rk@angrychimp.net" - } - ], - "require": { - "phpseclib/phpseclib": ">=0.3.6" + "name": "phpmailer/dkimvalidator", + "description": "A DKIM signature validator in PHP.", + "license": "MIT", + "authors": [ + { + "name": "angrychimp", + "email": "rk@angrychimp.net" }, - "autoload": { - "psr-0": { - "DKIM": "./" - } + { + "name": "Teon d.o.o. - Bostjan Skufca", + "email": "bostjan@teon.si" + }, + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=7.2.0", + "ext-openssl": "*", + "ext-hash": "*", + "ext-mbstring": "*" + }, + "require-dev": { + "nunomaduro/phpinsights": "^v1.9.0", + "phpunit/phpunit": "8.4.1", + "friendsofphp/php-cs-fixer": "^2.15", + "roave/security-advisories": "dev-master" + }, + "autoload": { + "psr-4": { + "PHPMailer\\DKIMValidator\\": "src/" } + }, + "scripts": { + "lint": "php-cs-fixer fix -v", + "beautify": "phpcbf --standard=PSR12 src/*", + "test:lint": "php-cs-fixer fix -v --dry-run src/", + "test:insights": "phpinsights analyse --min-quality=100 src/", + "test:unit": "phpunit", + "test:types": "phpstan analyse src/", + "test": [ + "@test:lint", + "@test:insights", + "@test:unit", + "@test:types" + ] + } } diff --git a/phpinsights.php b/phpinsights.php new file mode 100644 index 0000000..ac48f9c --- /dev/null +++ b/phpinsights.php @@ -0,0 +1,54 @@ + 'default', + + /* + |-------------------------------------------------------------------------- + | Configuration + |-------------------------------------------------------------------------- + | + | Here you may adjust all the various `Insights` that will be used by PHP + | Insights. You can either add, remove or configure `Insights`. Keep in + | mind, that all added `Insights` must belong to a specific `Metric`. + | + */ + + 'exclude' => [ + // 'path/to/directory-or-file' + ], + + + 'add' => [ + // ExampleMetric::class => [ + // ExampleInsight::class, + // ] + ], + + 'remove' => [ + // ExampleInsight::class, + ], + + 'config' => [ + \ObjectCalisthenics\Sniffs\Metrics\MaxNestingLevelSniff::class => [ + 'maxNestingLevel' => 3, + ], + ], + +]; diff --git a/src/DKIM.php b/src/DKIM.php new file mode 100644 index 0000000..616dc38 --- /dev/null +++ b/src/DKIM.php @@ -0,0 +1,323 @@ +raw = $rawMessage; + if (! $this->raw) { + throw new DKIMException('No message content provided'); + } + //Normalize line breaks to CRLF + $message = str_replace([self::CRLF, "\r", "\n"], ["\n", "\n", self::CRLF], $this->raw); + //Split out headers and body, separated by the first double line break + [$headers, $body] = explode(self::CRLF . self::CRLF, $message, 2); + $this->body = $body; + $this->headers = $headers; + $this->parsedHeaders = $this->parseHeaders($this->headers); + + $this->params = $params; + } + + /** + * Canonicalize a header in either "relaxed" or "simple" modes. + * Requires an array of headers (header names are part of array values) + * + * @param array $headers + * @param string $style 'relaxed' or 'simple' + * + * @return string + * + * @throws DKIMException + */ + protected function canonicalizeHeaders(array $headers, string $style = 'relaxed'): string + { + if (count($headers) === 0) { + throw new DKIMException('Attempted to canonicalize empty header array'); + } + + switch ($style) { + case 'simple': + return implode(self::CRLF, $headers); + case 'relaxed': + default: + $new = []; + foreach ($headers as $header) { + //Split off header name + [$name, $val] = explode(':', $header, 2); + + //Lowercase field name + $name = strtolower(trim($name)); + + //Unfold header value + $val = preg_replace('/\r\n[ \t]+/', ' ', $val); + + //Collapse whitespace to a single space + $val = trim(preg_replace('/\s+/', ' ', $val)); + + $new[] = "$name:$val"; + } + + return implode(self::CRLF, $new); + } + } + + /** + * Canonicalize a message body in either "relaxed" or "simple" modes. + * Requires a string containing all body content, with an optional byte-length + * + * @param string $body The message body + * @param string $style 'relaxed' or 'simple' canonicalization algorithm + * @param int $length Restrict the output length to this to match up with the `l` tag + * + * @return string + */ + protected function canonicalizeBody(string $body, string $style = 'relaxed', int $length = -1): string + { + if ($body === '') { + return self::CRLF; + } + + //Convert CRLF to LF breaks for convenience + $canonicalBody = str_replace(self::CRLF, "\n", $body); + if ($style === 'relaxed') { + //http://tools.ietf.org/html/rfc4871#section-3.4.4 + //Remove trailing space + $canonicalBody = preg_replace('/[ \t]+$/m', '', $canonicalBody); + //Replace runs of whitespace with a single space + $canonicalBody = preg_replace('/[ \t]+/m', ' ', $canonicalBody); + } + //Always perform rules for "simple" canonicalization as well + //http://tools.ietf.org/html/rfc4871#section-3.4.3 + //Remove any trailing empty lines + $canonicalBody = preg_replace('/\n+$/', '', $canonicalBody); + //Convert line breaks back to CRLF + $canonicalBody = str_replace("\n", self::CRLF, $canonicalBody); + + //Add last trailing CRLF + $canonicalBody .= self::CRLF; + + //If we've been asked for a substring, return that, otherwise return the whole body + return $length > 0 ? substr($canonicalBody, 0, $length) : $canonicalBody; + } + + /** + * Extract the headers from a message. + * + * @param $headerName + * @param string $format + * + * @return array + * + * @throws DKIMException + */ + protected function getHeadersNamed(string $headerName, string $format = 'raw'): array + { + $headerName = strtolower($headerName); + $matchedHeaders = []; + foreach ($this->parsedHeaders as $header) { + //Don't exit early in case there are multiple headers with the same name + if ($header['lowerlabel'] === $headerName) { + switch ($format) { + case 'label': + //Only the header label + $matchedHeaders[] = $header['label']; + break; + case 'raw': + //Complete header value without label, may contain line breaks and folding + $matchedHeaders[] = $header['raw']; + break; + case 'label_raw': + //Complete header including label, may contain line breaks and folding + $matchedHeaders[] = $header['label'] . ': ' . $header['raw']; + break; + case 'array': + //Complete header including label, may be folded, with each line as an array element + $matchedHeaders[] = $header['rawarray']; + break; + case 'unfolded': + //Just the value, unfolded + $matchedHeaders[] = $header['unfolded']; + break; + case 'label_unfolded': + //Label and value, unfolded + $matchedHeaders[] = $header['label'] . ': ' . $header['unfolded']; + break; + case 'decoded': + //Just the value, unfolded and decoded; may contain UTF-8 + $matchedHeaders[] = $header['decoded']; + break; + case 'label_decoded': + //Label and value, unfolded and decoded; may contain UTF-8 + $matchedHeaders[] = $header['label'] . ': ' . $header['decoded']; + break; + default: + throw new DKIMException('Invalid header format requested'); + } + } + } + + return $matchedHeaders; + } + + /** + * Parse a set of headers in a CRLF-delimited string into an array. + * Each entry contains the header name as a `label` element and three variants of the value: + * * `raw`: a complete copy of the whole header as a single string, with FWS and CRLF breaks if folded + * * `rawarray` as raw, but with each line of the header as a separate array element + * * `value` the unfolded value, without a label. + * + * @param string $headers + * + * @return array + * + * @throws DKIMException + */ + protected function parseHeaders(string $headers): array + { + $headerLines = explode(self::CRLF, $headers); + $headerLineCount = count($headerLines); + $headerLineIndex = 0; + $parsedHeaders = []; + $currentHeaderLabel = ''; + $currentHeaderValue = ''; + $currentRawHeaderLines = []; + foreach ($headerLines as $headerLine) { + $matches = []; + if (preg_match('/^([^ \t]*?)(?::[ \t]*)(.*)$/', $headerLine, $matches)) { + //This is a line that does not start with FWS, so it's the start of a new header + if ($currentHeaderLabel !== '') { + $parsedHeaders[] = [ + 'label' => $currentHeaderLabel, + 'lowerlabel' => strtolower($currentHeaderLabel), + 'unfolded' => $currentHeaderValue, + 'decoded' => self::rfc2047Decode($currentHeaderValue), + 'rawarray' => $currentRawHeaderLines, + 'raw' => implode(self::CRLF . ' ', $currentRawHeaderLines), //Refold lines + ]; + } + $currentHeaderLabel = $matches[1]; + $currentHeaderValue = $matches[2]; + $currentRawHeaderLines = [$currentHeaderValue]; + } elseif (preg_match('/^[ \t]+(.*)$/', $headerLine, $matches)) { + if ($headerLineIndex === 0) { + throw new DKIMException('Invalid headers starting with a folded line'); + } + //This is a folded continuation of the current header + $currentHeaderValue .= $matches[1]; + $currentRawHeaderLines[] = $matches[1]; + } + ++$headerLineIndex; + if ($headerLineIndex >= $headerLineCount) { + //This was the last line, so finish off this header + $parsedHeaders[] = [ + 'label' => $currentHeaderLabel, + 'lowerlabel' => strtolower($currentHeaderLabel), + 'unfolded' => $currentHeaderValue, + 'decoded' => self::rfc2047Decode($currentHeaderValue), + 'rawarray' => $currentRawHeaderLines, + 'raw' => implode(self::CRLF, $currentRawHeaderLines), + ]; + } + } + + return $parsedHeaders; + } + + /** + * Decode a header encoded with RFC2047 Q or B encoding. + * + * @param $header + * + * @return string + */ + protected static function rfc2047decode(string $header): string + { + return mb_decode_mimeheader($header); + } + + /** + * Return the message body. + * + * @return string + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Return the original message headers as a raw string. + * + * @return string + */ + public function getHeaders(): string + { + return $this->headers; + } + + /** + * Calculate the hash of a message body. + * + * @param string $body + * @param string $hashAlgo Which hash algorithm to use + * + * @return string + */ + protected static function hashBody(string $body, string $hashAlgo = 'sha256'): string + { + return base64_encode(hash($hashAlgo, $body, true)); + } +} diff --git a/src/DKIMException.php b/src/DKIMException.php new file mode 100644 index 0000000..962f884 --- /dev/null +++ b/src/DKIMException.php @@ -0,0 +1,11 @@ +validate(); + + // Only return true in this case + return (count($res) === 1) + && (count($res[0]) === 1) + && ($res[0][0]['status'] === 'SUCCESS'); + } + + /** + * Validate all DKIM signatures found in the message. + * + * @return array + * + * @throws DKIMException + */ + public function validate(): array + { + $output = []; + + //Find any DKIM signatures amongst the headers (there may be more than 1) + $signatures = $this->getHeadersNamed('DKIM-Signature', 'raw'); + + // Validate the Signature Header Field + foreach ($signatures as $signatureIndex => $signature) { + //Strip all internal spaces + $signatureToProcess = preg_replace('/\s+/', '', $signature); + //Split into tags + $dkimTags = explode(';', $signatureToProcess); + //Drop an empty last element caused by a trailing semi-colon + if (end($dkimTags) === '') { + array_pop($dkimTags); + } + foreach ($dkimTags as $tagIndex => $tagContent) { + [$tagName, $tagValue] = explode('=', trim($tagContent), 2); + unset($dkimTags[$tagIndex]); + if ($tagName === '') { + continue; + } + $dkimTags[$tagName] = $tagValue; + } + + // Verify all required values are present + // http://tools.ietf.org/html/rfc4871#section-6.1.1 + $required = ['v', 'a', 'b', 'bh', 'd', 'h', 's']; + foreach ($required as $tagIndex) { + if (! array_key_exists($tagIndex, $dkimTags)) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => "Signature missing required tag: $tagIndex", + ]; + } + } + + //Validate DKIM version number + if ((int) $dkimTags['v'] !== 1) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Incompatible DKIM version: ' . $dkimTags['v'], + ]; + } + + //Validate canonicalization algorithms for header and body + [$headerCA, $bodyCA] = explode('/', $dkimTags['c']); + if ($headerCA !== 'relaxed' && $headerCA !== 'simple') { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Unknown header canonicalization algorithm: ' . $headerCA, + ]; + } + if ($bodyCA !== 'relaxed' && $bodyCA !== 'simple') { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Unknown body canonicalization algorithm: ' . $bodyCA, + ]; + } + + //Canonicalize body + $canonicalBody = $this->canonicalizeBody($this->body, $bodyCA); + + //Validate optional body length tag + //If this is present, the canonical body should be *at least* this long, + //though it may be longer + if (array_key_exists('l', $dkimTags)) { + $bodyLength = strlen($canonicalBody); + if ((int) $dkimTags['l'] > $bodyLength) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Body too short: ' . $dkimTags['l'] . '/' . $bodyLength, + ]; + } + } + + //Ensure the user identifier ends in the signing domain + if (array_key_exists('i', $dkimTags) && + substr($dkimTags['i'], -strlen($dkimTags['d'])) !== $dkimTags['d'] + ) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Agent or user identifier does not match domain: ' . $dkimTags['i'], + ]; + } + + //Ensure the signature includes the From field + if (array_key_exists('h', $dkimTags) && + stripos($dkimTags['h'], 'From') === false) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'From header not included in signed header list: ' . $dkimTags['h'], + ]; + } + + //Validate and check expiry time + if (array_key_exists('x', $dkimTags)) { + if ((int) $dkimTags['x'] < time()) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Signature has expired.', + ]; + } + if ( isset($dkimTags['t']) && ((int) $dkimTags['x'] < (int) $dkimTags['t']) ) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Expiry time is before signature time.', + ]; + } + } + + //The 'q' tag may be empty - fall back to default if it is + if (! array_key_exists('q', $dkimTags) || $dkimTags['q'] === '') { + $dkimTags['q'] = 'dns/txt'; + } + + //Abort if we have any errors at this point + if (count($output) > 0) { + continue; + } + + //Fetch public keys from DNS using the domain and selector from the signature + //May return multiple keys + [$qType, $qFormat] = explode('/', $dkimTags['q'], 2); + if ($qType . '/' . $qFormat === 'dns/txt') { + $dnsKeys = self::fetchPublicKeys($dkimTags['d'], $dkimTags['s']); + if ($dnsKeys === false) { + $output[$signatureIndex][] = [ + 'status' => 'TEMPFAIL', + 'reason' => 'Public key not found in DNS', + ]; + continue; + } + $this->publicKeys[$dkimTags['d']] = $dnsKeys; + } else { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Public key unavailable (unknown q= query format)', + ]; + continue; + } + + //http://tools.ietf.org/html/rfc4871#section-6.1.3 + //Select signed headers and canonicalize + $signedHeaderNames = array_unique(explode(':', $dkimTags['h'])); + $headersToCanonicalize = []; + foreach ($signedHeaderNames as $headerName) { + $matchedHeaders = $this->getHeadersNamed($headerName, 'label_raw'); + foreach ($matchedHeaders as $header) { + $headersToCanonicalize[] = $header; + } + } + //Need to remove the `b` value from the signature header before checking the hash + $headersToCanonicalize[] = 'DKIM-Signature: ' . + preg_replace('/b=(.*?)(;|$)/s', 'b=$2', $signature); + + [$alg, $hash] = explode('-', $dkimTags['a']); + + //Canonicalize the headers + $canonicalHeaders = $this->canonicalizeHeaders($headersToCanonicalize, $headerCA); + + //Calculate the body hash + $bodyHash = self::hashBody($canonicalBody, $hash); + + if ($bodyHash !== $dkimTags['bh']) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Computed body hash does not match signature body hash', + ]; + } + + // Iterate over keys + foreach ($this->publicKeys[$dkimTags['d']] as $keyIndex => $publicKey) { + // Validate key + // confirm that pubkey version matches sig version (v=) + if (array_key_exists('v', $publicKey) && $publicKey['v'] !== 'DKIM' . $dkimTags['v']) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Public key version does not match signature' . + " version ({$dkimTags['d']} key #$keyIndex)", + ]; + } + + //Confirm that published hash algorithm matches sig hash + if (array_key_exists('h', $publicKey) && $publicKey['h'] !== $hash) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Public key hash algorithm does not match signature' . + " hash algorithm ({$dkimTags['d']} key #$keyIndex)", + ]; + } + + //Confirm that the key type matches the sig key type + if (array_key_exists('k', $publicKey) && $publicKey['k'] !== $alg) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Public key type does not match signature' . + " key type ({$dkimTags['d']} key #$keyIndex)", + ]; + } + + //Ensure the service type tag allows email usage + if (array_key_exists('s', $publicKey) && $publicKey['s'] !== '*' && $publicKey['s'] !== 'email') { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Public key service type does not permit email usage' . + " ({$dkimTags['d']} key #$keyIndex)" . $publicKey['s'], + ]; + } + + // @TODO check t= flags + + # Check that the hash algorithm is available in openssl + if (! in_array($hash, openssl_get_md_methods(true), true)) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => "Signature algorithm $hash is not available" . + " for openssl_verify(), key #$keyIndex)", + ]; + continue; + } + // Validate the signature + $validationResult = self::validateSignature( + $publicKey['p'], + $dkimTags['b'], + $canonicalHeaders, + $hash + ); + + if (! $validationResult) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'DKIM signature did not verify ' . + "({$dkimTags['d']}/{$dkimTags['s']} key #$keyIndex)", + ]; + } else { + $output[$signatureIndex][] = [ + 'status' => 'SUCCESS', + 'reason' => 'DKIM signature verified successfully!', + ]; + } + } + } + + return $output; + } + + /** + * Fetch the public key(s) for a domain and selector. + * + * @param string $domain + * @param string $selector + * + * @return array|bool + */ + public static function fetchPublicKeys(string $domain, string $selector) + { + $host = sprintf('%s._domainkey.%s', $selector, $domain); + $textRecords = dns_get_record($host, DNS_TXT); + + if ($textRecords === false) { + return false; + } + + $publicKeys = []; + foreach ($textRecords as $record) { + //Long keys may be split into pieces + if (array_key_exists('entries', $record) && is_array($record)) { + $record['txt'] = implode('', $record['entries']); + } + $parts = explode(';', trim($record['txt'])); + $record = []; + foreach ($parts as $part) { + // Last record is empty if there is trailing semicolon + $part = trim($part); + if ($part === '') { + continue; + } + [$key, $val] = explode('=', $part, 2); + $record[$key] = $val; + } + $publicKeys[] = $record; + } + + return $publicKeys; + } + + /** + * Check whether a signed string matches its key. + * + * @param string $publicKey + * @param string $signature + * @param string $signedString + * @param string $hashAlgo Any of the algos returned by openssl_get_md_methods() + * + * @return bool + * + * @throws DKIMException + */ + protected static function validateSignature( + string $publicKey, + string $signature, + string $signedString, + string $hashAlgo = 'sha256' + ): bool { + // Convert key back into PEM format + $key = sprintf( + "-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----", + trim(chunk_split($publicKey, 64, "\n")) + ); + + $verified = openssl_verify($signedString, base64_decode($signature), $key, $hashAlgo); + switch ($verified) { + case 1: + return true; + case 0: + return false; + case -1: + $message = ''; + //There may be multiple errors; fetch them all + while ($error = openssl_error_string() !== false) { + $message .= $error . "\n"; + } + throw new DKIMException('OpenSSL verify error: ' . $message); + } + + //Code will never get here! + return false; + } +}