From ccae4d7f54de60b3aba2c6288c9e71355f30fa54 Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Thu, 13 Aug 2015 00:15:58 +0000 Subject: [PATCH 01/19] Refactoring: switch to psr-4, move to \Teon\DKIM namespace for the time being, add license and disclamer, suggest original package. --- composer.json | 16 ++++++++++++---- DKIM.php => src/Teon/DKIM/AbstractDKIM.php | 21 ++++++++++++--------- src/Teon/DKIM/Exception.php | 15 +++++++++++++++ {DKIM => src/Teon/DKIM}/Verify.php | 15 +++++++++------ 4 files changed, 48 insertions(+), 19 deletions(-) rename DKIM.php => src/Teon/DKIM/AbstractDKIM.php (95%) create mode 100644 src/Teon/DKIM/Exception.php rename {DKIM => src/Teon/DKIM}/Verify.php (98%) mode change 100755 => 100644 diff --git a/composer.json b/composer.json index a627b5d..95481f5 100644 --- a/composer.json +++ b/composer.json @@ -1,18 +1,26 @@ { - "name": "angrychimp/php-dkim", - "description": "Finally, a PHP5 class for not just signing, but _verifying_ DKIM signatures.", + "name": "teon/dkim", + "description": "Update of angrychimp/php-dkim package. License is faked as 'proprietary' until original author declares license for his work. Using 'proprietary' license specification does not imply any ownership of original code.", + "license": "proprietary", "authors": [ { "name": "angrychimp", "email": "rk@angrychimp.net" + }, + { + "name": "Bostjan Skufca", + "email": "bostjan@teon.si" } ], "require": { "phpseclib/phpseclib": ">=0.3.6" }, + "suggest": { + "angrychimp/php-dkim": "Original, psr-0 compatible php-dkim package" + }, "autoload": { - "psr-0": { - "DKIM": "./" + "psr-4": { + "Teon\\DKIM\\": "src/Teon/DKIM/" } } } diff --git a/DKIM.php b/src/Teon/DKIM/AbstractDKIM.php similarity index 95% rename from DKIM.php rename to src/Teon/DKIM/AbstractDKIM.php index b220f8f..44dfcaf 100644 --- a/DKIM.php +++ b/src/Teon/DKIM/AbstractDKIM.php @@ -1,5 +1,11 @@ _raw = $rawMessage; if (!$this->_raw) { - throw new DKIM_Exception('No message content provided'); + throw new Exception('No message content provided'); } $this->_params = $params; @@ -61,12 +67,12 @@ public function __construct($rawMessage='', $params=array()) { * @param array $headers * @param string $style * @return string - * @throws DKIM_Exception + * @throws 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"); + throw new Exception("Attempted to canonicalize empty header array"); } $cHeader = ''; @@ -106,7 +112,7 @@ protected function _canonicalizeHeader($headers=array(), $style="simple") { * @param string $style * @param int $length * @return string - * @throws DKIM_Exception + * @throws Exception */ protected function _canonicalizeBody($style='simple', $length=-1) { @@ -238,6 +244,3 @@ protected static function _hashBody($body, $method='sha1') { } } - - -class DKIM_Exception extends Exception { } \ No newline at end of file diff --git a/src/Teon/DKIM/Exception.php b/src/Teon/DKIM/Exception.php new file mode 100644 index 0000000..c67d296 --- /dev/null +++ b/src/Teon/DKIM/Exception.php @@ -0,0 +1,15 @@ + Date: Thu, 13 Aug 2015 00:17:08 +0000 Subject: [PATCH 02/19] Remove DKIM/Sign.php file as it depends on package that is not publicly available --- DKIM/Sign.php | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 DKIM/Sign.php 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 @@ - Date: Thu, 13 Aug 2015 00:31:03 +0000 Subject: [PATCH 03/19] Remove dependency on phpseclib, as it is obsolete and composer complains about it. As soon as original package updates this dependency to recent release (2.x+), this should be reverted. --- composer.json | 2 +- src/Teon/DKIM/AbstractDKIM.php | 27 +++------------------------ src/Teon/DKIM/Verify.php | 20 +++----------------- 3 files changed, 7 insertions(+), 42 deletions(-) diff --git a/composer.json b/composer.json index 95481f5..305f28a 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ } ], "require": { - "phpseclib/phpseclib": ">=0.3.6" + "ext-openssl": "*" }, "suggest": { "angrychimp/php-dkim": "Original, psr-0 compatible php-dkim package" diff --git a/src/Teon/DKIM/AbstractDKIM.php b/src/Teon/DKIM/AbstractDKIM.php index 44dfcaf..9be3171 100644 --- a/src/Teon/DKIM/AbstractDKIM.php +++ b/src/Teon/DKIM/AbstractDKIM.php @@ -6,19 +6,6 @@ -/** - * @see phpseclib/Crypt/RSA - */ -// require_once 'phpseclib/Crypt/RSA.php'; - -/** - * @see phpseclib/Crypt/Hash - * @link http://phpseclib.sourceforge.net - */ -// require_once 'phpseclib/Crypt/Hash.php'; - -define('PHPSECLIB_USE_EXCEPTIONS', true); - abstract class AbstractDKIM { /** @@ -231,16 +218,8 @@ protected function _getBodyFromRaw($style='string') { * */ 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)); - } - + + return base64_encode(hash($method, $body, true)); + } } diff --git a/src/Teon/DKIM/Verify.php b/src/Teon/DKIM/Verify.php index aaadb96..2e5db11 100644 --- a/src/Teon/DKIM/Verify.php +++ b/src/Teon/DKIM/Verify.php @@ -256,22 +256,8 @@ public static function fetchPublicKey($domain, $selector) { 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); - } - + + $signature_alg = constant('OPENSSL_ALGO_'.strtoupper($hash)); + return openssl_verify($str, base64_decode($sig), $key, $signature_alg); } - } From ec3a0a2d230a331130151c66dd949881eff6aa68 Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Thu, 13 Aug 2015 00:37:55 +0000 Subject: [PATCH 04/19] Composer: add ext-hash dependency --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 305f28a..7b72d56 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ } ], "require": { - "ext-openssl": "*" + "ext-openssl": "*", + "ext-hash": "*" }, "suggest": { "angrychimp/php-dkim": "Original, psr-0 compatible php-dkim package" From d6ca39b511009997f9aedfeefe4da6d04815e023 Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Thu, 13 Aug 2015 01:03:35 +0000 Subject: [PATCH 05/19] DKIM verification fixup: make parser \r\n vs. \n agnostic. Reason: If email content file is pulled directly from Maildir on Linux, it has body newlines separated by \n rather than \r\n which is what mail systems use internally (unverified claim!). This broke validation. --- src/Teon/DKIM/AbstractDKIM.php | 7 ++++++- src/Teon/DKIM/Verify.php | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Teon/DKIM/AbstractDKIM.php b/src/Teon/DKIM/AbstractDKIM.php index 9be3171..11fe737 100644 --- a/src/Teon/DKIM/AbstractDKIM.php +++ b/src/Teon/DKIM/AbstractDKIM.php @@ -197,10 +197,15 @@ protected function _getBodyFromRaw($style='string') { return (string)$this->_params['body']; } - $lines = explode("\r\n", $this->_raw); + // Do not explode by \r\n, rather do it by \n and strip \r from line endif if it is found - see comment below + $lines = explode("\n", $this->_raw); // Jump past all the headers $on = false; while ($line = array_shift($lines)) { + // Remove trailing carriage-return if present + // It might be present if emails are read from Unix maildirs directly instead of via IMAP/POP3 + $line = preg_replace('/\r$/', '', $line); + if ($on === true && $line != '') { break; } diff --git a/src/Teon/DKIM/Verify.php b/src/Teon/DKIM/Verify.php index 2e5db11..ac16f76 100644 --- a/src/Teon/DKIM/Verify.php +++ b/src/Teon/DKIM/Verify.php @@ -240,6 +240,12 @@ public static function fetchPublicKey($domain, $selector) { $parts = explode(';', trim($record['txt'])); $record = array(); foreach ($parts as $part) { + // Last record is empty if there is trailing semicolon + $part = trim($part); + if (empty($part)) { + continue; + } + list($key, $val) = explode('=', trim($part), 2); $record[$key] = $val; } From d037affe7bd4e8846e03d11a1bd19dcd99f7b560 Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Thu, 13 Aug 2015 01:11:17 +0000 Subject: [PATCH 06/19] Add validation wrapper that returns only true or false as validation result --- src/Teon/DKIM/Verify.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/Teon/DKIM/Verify.php b/src/Teon/DKIM/Verify.php index ac16f76..a230eb5 100644 --- a/src/Teon/DKIM/Verify.php +++ b/src/Teon/DKIM/Verify.php @@ -16,6 +16,31 @@ class Verify */ private $_publicKeys; + /** + * Validation wrapper - return boolean true/false about validation success/failure + * + * @return bool + * @throws Exception + */ + public function validateBoolean() { + + // Executte original validation method + $res = $this->validate(); + + // Only return true in this case + if ( + true + && (count($res) == 1) + && (count($res[0]) == 1) + && ($res[0][0]['status'] == 'pass' ) + ) { + return true; + } + + // Return failure on all other occasions + return false; + } + /** * Validates all present DKIM signatures * From 19fd3be46c6d393f984b2ac4457c0cc9202c2c5f Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Tue, 18 Aug 2015 04:14:01 +0000 Subject: [PATCH 07/19] Rename Verify to Validate class, update readme and create howto --- README.md | 40 +++++++++------------ src/Teon/DKIM/{Verify.php => Validator.php} | 2 +- 2 files changed, 17 insertions(+), 25 deletions(-) rename src/Teon/DKIM/{Verify.php => Validator.php} (99%) diff --git a/README.md b/README.md index 7e387c7..face556 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,27 @@ -php-dkim -======== +# PHP DKIM validator -**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. +## Installation -* [openssl](http://us1.php.net/manual/en/openssl.installation.php) -* [phpseclib](http://phpseclib.sourceforge.net/) +``` +composer require teon/dkim +``` -At least one of those packages must be present in order to compute the RSA signature verification. -Usage ------ -<pending> +## Usage -Changelog ---------- +```php +$msgContent = "..."; +$dkimValidator = new \Teon\DKIM\Validator($msgContent); +if (!$dkimValidator->validateBoolean()) { + throw new Exception("DKIM validation FAILED!"); +} +``` -**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. +# Changelog + +See git history :) diff --git a/src/Teon/DKIM/Verify.php b/src/Teon/DKIM/Validator.php similarity index 99% rename from src/Teon/DKIM/Verify.php rename to src/Teon/DKIM/Validator.php index a230eb5..23f85d1 100644 --- a/src/Teon/DKIM/Verify.php +++ b/src/Teon/DKIM/Validator.php @@ -6,7 +6,7 @@ -class Verify +class Validator extends AbstractDKIM { From 1da327d0140222f48b0e2c236648cd4d499ce4e3 Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Mon, 19 Oct 2015 08:13:14 +0000 Subject: [PATCH 08/19] Add new author to LICENSE file, also releasing changes under MIT license. --- LICENSE | 1 + 1 file changed, 1 insertion(+) 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 From 4348d25b67b5061d5f3dd3988b5db806d8a819e9 Mon Sep 17 00:00:00 2001 From: Bostjan Skufca Date: Mon, 19 Oct 2015 08:14:01 +0000 Subject: [PATCH 09/19] composer.json: update license to MIT, and change description - remove license notice. --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 7b72d56..e6cf336 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,14 @@ { "name": "teon/dkim", - "description": "Update of angrychimp/php-dkim package. License is faked as 'proprietary' until original author declares license for his work. Using 'proprietary' license specification does not imply any ownership of original code.", - "license": "proprietary", + "description": "Update of angrychimp/php-dkim package.", + "license": "MIT", "authors": [ { "name": "angrychimp", "email": "rk@angrychimp.net" }, { - "name": "Bostjan Skufca", + "name": "Teon d.o.o. - Bostjan Skufca", "email": "bostjan@teon.si" } ], @@ -20,8 +20,8 @@ "angrychimp/php-dkim": "Original, psr-0 compatible php-dkim package" }, "autoload": { - "psr-4": { - "Teon\\DKIM\\": "src/Teon/DKIM/" - } + "psr-4": { + "Teon\\DKIM\\": "src/Teon/DKIM/" + } } } From 49b65b1c0e022afdbac7afa89c997d74606fc881 Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Thu, 10 Oct 2019 19:19:06 +0200 Subject: [PATCH 10/19] PHPMailer fork of DKIM Validator --- .php_cs | 45 +++++ README.md | 37 +++- TODO.md | 17 -- composer.json | 61 ++++-- src/DKIM.php | 311 +++++++++++++++++++++++++++++ src/DKIMException.php | 9 + src/Teon/DKIM/AbstractDKIM.php | 230 --------------------- src/Teon/DKIM/Exception.php | 15 -- src/Teon/DKIM/Validator.php | 294 --------------------------- src/Validator.php | 355 +++++++++++++++++++++++++++++++++ 10 files changed, 785 insertions(+), 589 deletions(-) create mode 100644 .php_cs delete mode 100644 TODO.md create mode 100644 src/DKIM.php create mode 100644 src/DKIMException.php delete mode 100644 src/Teon/DKIM/AbstractDKIM.php delete mode 100644 src/Teon/DKIM/Exception.php delete mode 100644 src/Teon/DKIM/Validator.php create mode 100644 src/Validator.php diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..2b17bb7 --- /dev/null +++ b/.php_cs @@ -0,0 +1,45 @@ +setRiskyAllowed(true) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => false, + '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/README.md b/README.md index face556..c835eaa 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,42 @@ -# PHP DKIM validator +# 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 teon/dkim +composer require phpmailer/dkimvalidator ``` - - ## Usage ```php -$msgContent = "..."; -$dkimValidator = new \Teon\DKIM\Validator($msgContent); -if (!$dkimValidator->validateBoolean()) { - throw new Exception("DKIM validation FAILED!"); +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 -See git history :) +* 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 e6cf336..fff1679 100644 --- a/composer.json +++ b/composer.json @@ -1,27 +1,44 @@ { - "name": "teon/dkim", - "description": "Update of angrychimp/php-dkim package.", - "license": "MIT", - "authors": [ - { - "name": "angrychimp", - "email": "rk@angrychimp.net" - }, - { - "name": "Teon d.o.o. - Bostjan Skufca", - "email": "bostjan@teon.si" - } - ], - "require": { - "ext-openssl": "*", - "ext-hash": "*" + "name": "phpmailer/dkimvalidator", + "description": "A DKIM signature validator in PHP.", + "license": "MIT", + "authors": [ + { + "name": "angrychimp", + "email": "rk@angrychimp.net" }, - "suggest": { - "angrychimp/php-dkim": "Original, psr-0 compatible php-dkim package" + { + "name": "Teon d.o.o. - Bostjan Skufca", + "email": "bostjan@teon.si" }, - "autoload": { - "psr-4": { - "Teon\\DKIM\\": "src/Teon/DKIM/" - } + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" } + ], + "require": { + "php": ">=7.2.0", + "ext-openssl": "*", + "ext-hash": "*", + "ext-mbstring": "*" + }, + "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/src/DKIM.php b/src/DKIM.php new file mode 100644 index 0000000..48bbc3e --- /dev/null +++ b/src/DKIM.php @@ -0,0 +1,311 @@ +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 values and collapse whitespace + $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['unfolded']; + 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), + ]; + } + $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..fcc90b2 --- /dev/null +++ b/src/DKIMException.php @@ -0,0 +1,9 @@ +_raw = $rawMessage; - if (!$this->_raw) { - throw new 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 Exception - */ - protected function _canonicalizeHeader($headers=array(), $style="simple") { - $headers = (array)$headers; - if (sizeof($headers) == 0) { - throw new 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 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']; - } - - // Do not explode by \r\n, rather do it by \n and strip \r from line endif if it is found - see comment below - $lines = explode("\n", $this->_raw); - // Jump past all the headers - $on = false; - while ($line = array_shift($lines)) { - // Remove trailing carriage-return if present - // It might be present if emails are read from Unix maildirs directly instead of via IMAP/POP3 - $line = preg_replace('/\r$/', '', $line); - - if ($on === true && $line != '') { - break; - } - if ($line == '') { - $on = true; - } - } - - return implode("\r\n", $lines); - - } - - /** - * - * - */ - protected static function _hashBody($body, $method='sha1') { - - return base64_encode(hash($method, $body, true)); - - } -} diff --git a/src/Teon/DKIM/Exception.php b/src/Teon/DKIM/Exception.php deleted file mode 100644 index c67d296..0000000 --- a/src/Teon/DKIM/Exception.php +++ /dev/null @@ -1,15 +0,0 @@ -validate(); - - // Only return true in this case - if ( - true - && (count($res) == 1) - && (count($res[0]) == 1) - && ($res[0][0]['status'] == 'pass' ) - ) { - return true; - } - - // Return failure on all other occasions - return false; - } - - /** - * Validates all present DKIM signatures - * - * @return array - * @throws Exception - */ - public function validate() { - - $results = array(); - - // find the present DKIM signatures - $signatures = $this->_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) { - // Last record is empty if there is trailing semicolon - $part = trim($part); - if (empty($part)) { - continue; - } - - 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)); - - $signature_alg = constant('OPENSSL_ALGO_'.strtoupper($hash)); - return openssl_verify($str, base64_decode($sig), $key, $signature_alg); - } -} diff --git a/src/Validator.php b/src/Validator.php new file mode 100644 index 0000000..d0e1717 --- /dev/null +++ b/src/Validator.php @@ -0,0 +1,355 @@ +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); + 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", + ]; + continue; + } + } + // abort if we have any errors at this point + if (!empty($output[$signatureIndex])) { + continue; + } + + if ((int)$dkimTags['v'] !== 1) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Incompatible DKIM version: ' . $dkimTags['v'], + ]; + continue; + } + + //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, + ]; + continue; + } + if ($bodyCA !== 'relaxed' && $bodyCA !== 'simple') { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Unknown body canonicalization algorithm: ' . $bodyCA, + ]; + continue; + } + //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' => 'fail', + 'reason' => 'Body length mismatch: ' . $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'] < (int)$dkimTags['t']) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Expiry time is before signature time.', + ]; + } elseif ((int)$dkimTags['x'] < time()) { + $output[$signatureIndex][] = [ + 'status' => 'PERMFAIL', + 'reason' => 'Signature has expired.', + ]; + } + } + + //Get the Public Key from DNS + // (note: may retrieve more than one key) + //The 'q' tag may be empty - fall back to default if it is + if (empty($dkimTags['q'])) { + $dkimTags['q'] = 'dns/txt'; + } + + [$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; + } +} From b66dd2cd1471f0518b243e0178f29277a44c170e Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Fri, 11 Oct 2019 20:10:59 +0200 Subject: [PATCH 11/19] Add phpinsights, phpunit, php-cs-fixer, security-advisories to require-dev --- composer.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/composer.json b/composer.json index fff1679..33cc082 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,20 @@ "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/" From 0ca912bd720392e1a57051945e45922dd6a95fa1 Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Fri, 11 Oct 2019 20:11:13 +0200 Subject: [PATCH 12/19] Add .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock From 0390d042d70f858bdc24955a66ac7a564a3f2939 Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Fri, 11 Oct 2019 20:11:56 +0200 Subject: [PATCH 13/19] Insights & phpcs config --- .php_cs | 1 + phpinsights.php | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 phpinsights.php diff --git a/.php_cs b/.php_cs index 2b17bb7..40e3787 100644 --- a/.php_cs +++ b/.php_cs @@ -6,6 +6,7 @@ return PhpCsFixer\Config::create() '@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, 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, + ], + ], + +]; From 6b7d41b2ab8be7f7514ff133769f3c04debaaf08 Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Fri, 11 Oct 2019 20:12:26 +0200 Subject: [PATCH 14/19] Strict types --- src/DKIM.php | 2 ++ src/DKIMException.php | 2 ++ src/Validator.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/DKIM.php b/src/DKIM.php index 48bbc3e..c59b644 100644 --- a/src/DKIM.php +++ b/src/DKIM.php @@ -1,5 +1,7 @@ Date: Fri, 11 Oct 2019 20:12:43 +0200 Subject: [PATCH 15/19] Code cleanup --- src/DKIM.php | 26 ++++++++++----- src/Validator.php | 85 ++++++++++++++++++++++++++--------------------- 2 files changed, 65 insertions(+), 46 deletions(-) diff --git a/src/DKIM.php b/src/DKIM.php index c59b644..ee0a936 100644 --- a/src/DKIM.php +++ b/src/DKIM.php @@ -8,36 +8,39 @@ abstract class DKIM { /** * We use this a lot, so make it a constant - * @type string */ public const CRLF = "\r\n"; /** * The original, unaltered message - * @type string + * + * @var string */ protected $raw = ''; /** * Message headers, as a string with CRLF line breaks - * @type string + * + * @var string */ protected $headers = ''; /** * Message headers, parsed into an array - * @type array + * + * @var array */ protected $parsedHeaders = []; /** * Message body, as a string with CRLF line breaks - * @type string + * + * @var string */ protected $body = ''; /** - * @type array + * @var array */ protected $params = []; @@ -54,7 +57,7 @@ public function __construct(string $rawMessage = '', array $params = []) //Ensure all processing uses UTF-8 mb_internal_encoding('UTF-8'); $this->raw = $rawMessage; - if (!$this->raw) { + if (! $this->raw) { throw new DKIMException('No message content provided'); } //Normalize line breaks to CRLF @@ -76,6 +79,7 @@ public function __construct(string $rawMessage = '', array $params = []) * @param string $style 'relaxed' or 'simple' * * @return string + * * @throws DKIMException */ protected function canonicalizeHeaders(array $headers, string $style = 'relaxed'): string @@ -97,7 +101,10 @@ protected function canonicalizeHeaders(array $headers, string $style = 'relaxed' //Lowercase field name $name = strtolower(trim($name)); - //Unfold header values and collapse whitespace + //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"; @@ -153,6 +160,7 @@ protected function canonicalizeBody(string $body, string $style = 'relaxed', int * @param string $format * * @return array + * * @throws DKIMException */ protected function getHeadersNamed(string $headerName, string $format = 'raw'): array @@ -214,6 +222,7 @@ protected function getHeadersNamed(string $headerName, string $format = 'raw'): * @param string $headers * * @return array + * * @throws DKIMException */ protected function parseHeaders(string $headers): array @@ -278,6 +287,7 @@ protected static function rfc2047decode(string $header): string { return mb_decode_mimeheader($header); } + /** * Return the message body. * diff --git a/src/Validator.php b/src/Validator.php index b813257..25ae7bf 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -8,7 +8,7 @@ class Validator extends DKIM { /** - * @type array + * @var array */ private $publicKeys = []; @@ -63,25 +63,20 @@ public function validate(): array // 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)) { + if (! array_key_exists($tagIndex, $dkimTags)) { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', 'reason' => "Signature missing required tag: $tagIndex", ]; - continue; } } - // abort if we have any errors at this point - if (!empty($output[$signatureIndex])) { - continue; - } - if ((int)$dkimTags['v'] !== 1) { + //Validate DKIM version number + if ((int) $dkimTags['v'] !== 1) { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', 'reason' => 'Incompatible DKIM version: ' . $dkimTags['v'], ]; - continue; } //Validate canonicalization algorithms for header and body @@ -91,37 +86,33 @@ public function validate(): array 'status' => 'PERMFAIL', 'reason' => 'Unknown header canonicalization algorithm: ' . $headerCA, ]; - continue; } if ($bodyCA !== 'relaxed' && $bodyCA !== 'simple') { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', 'reason' => 'Unknown body canonicalization algorithm: ' . $bodyCA, ]; - continue; } + //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 + //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) { + if ((int) $dkimTags['l'] > $bodyLength) { $output[$signatureIndex][] = [ - 'status' => 'fail', - 'reason' => 'Body length mismatch: ' . $dkimTags['l'] . '/' . $bodyLength, + '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'] + if (array_key_exists('i', $dkimTags) && + substr($dkimTags['i'], -strlen($dkimTags['d'])) !== $dkimTags['d'] ) { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', @@ -130,7 +121,8 @@ public function validate(): array } //Ensure the signature includes the From field - if (array_key_exists('h', $dkimTags) && stripos($dkimTags['h'], 'From') === false) { + 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'], @@ -139,26 +131,32 @@ public function validate(): array //Validate and check expiry time if (array_key_exists('x', $dkimTags)) { - if ((int)$dkimTags['x'] < (int)$dkimTags['t']) { + if ((int) $dkimTags['x'] < time()) { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', - 'reason' => 'Expiry time is before signature time.', + 'reason' => 'Signature has expired.', ]; - } elseif ((int)$dkimTags['x'] < time()) { + } + if ((int) $dkimTags['x'] < (int) $dkimTags['t']) { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', - 'reason' => 'Signature has expired.', + 'reason' => 'Expiry time is before signature time.', ]; } } - //Get the Public Key from DNS - // (note: may retrieve more than one key) //The 'q' tag may be empty - fall back to default if it is - if (empty($dkimTags['q'])) { + 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']); @@ -189,7 +187,8 @@ public function validate(): array } } //Need to remove the `b` value from the signature header before checking the hash - $headersToCanonicalize[] = 'DKIM-Signature: ' . preg_replace('/b=(.*?)(;|$)/s', 'b=$2', $signature); + $headersToCanonicalize[] = 'DKIM-Signature: ' . + preg_replace('/b=(.*?)(;|$)/s', 'b=$2', $signature); [$alg, $hash] = explode('-', $dkimTags['a']); @@ -213,7 +212,8 @@ public function validate(): array 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)", + 'reason' => 'Public key version does not match signature' . + " version ({$dkimTags['d']} key #$keyIndex)", ]; } @@ -221,7 +221,8 @@ public function validate(): array 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)", + 'reason' => 'Public key hash algorithm does not match signature' . + " hash algorithm ({$dkimTags['d']} key #$keyIndex)", ]; } @@ -229,7 +230,8 @@ public function validate(): array 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)", + 'reason' => 'Public key type does not match signature' . + " key type ({$dkimTags['d']} key #$keyIndex)", ]; } @@ -245,20 +247,27 @@ public function validate(): array // @TODO check t= flags # Check that the hash algorithm is available in openssl - if (!in_array($hash, openssl_get_md_methods(true), true)) { + 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)", + '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) { + $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)", + 'reason' => 'DKIM signature did not verify ' . + "({$dkimTags['d']}/{$dkimTags['s']} key #$keyIndex)", ]; } else { $output[$signatureIndex][] = [ From 32366e548304673b6fcb76d9e947716f3d90d575 Mon Sep 17 00:00:00 2001 From: Max-PC Date: Tue, 7 Jan 2020 15:18:51 +0100 Subject: [PATCH 16/19] Fix DKIM signatures ending with a semicolon --- src/Validator.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Validator.php b/src/Validator.php index 25ae7bf..dcbab30 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -50,6 +50,8 @@ public function validate(): array $signatureToProcess = preg_replace('/\s+/', '', $signature); //Split into tags $dkimTags = explode(';', $signatureToProcess); + if(end($dkimTags) === '') + array_pop($dkimTags); foreach ($dkimTags as $tagIndex => $tagContent) { [$tagName, $tagValue] = explode('=', trim($tagContent), 2); unset($dkimTags[$tagIndex]); From a30835eeabfbae7e8c04e68a7e4b1e71a665e014 Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Mon, 14 Oct 2019 13:07:34 +0200 Subject: [PATCH 17/19] Fix header retrieval --- src/DKIM.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DKIM.php b/src/DKIM.php index ee0a936..616dc38 100644 --- a/src/DKIM.php +++ b/src/DKIM.php @@ -181,7 +181,7 @@ protected function getHeadersNamed(string $headerName, string $format = 'raw'): break; case 'label_raw': //Complete header including label, may contain line breaks and folding - $matchedHeaders[] = $header['label'] . ' :' . $header['raw']; + $matchedHeaders[] = $header['label'] . ': ' . $header['raw']; break; case 'array': //Complete header including label, may be folded, with each line as an array element @@ -201,7 +201,7 @@ protected function getHeadersNamed(string $headerName, string $format = 'raw'): break; case 'label_decoded': //Label and value, unfolded and decoded; may contain UTF-8 - $matchedHeaders[] = $header['label'] . ': ' . $header['unfolded']; + $matchedHeaders[] = $header['label'] . ': ' . $header['decoded']; break; default: throw new DKIMException('Invalid header format requested'); @@ -245,7 +245,7 @@ protected function parseHeaders(string $headers): array 'unfolded' => $currentHeaderValue, 'decoded' => self::rfc2047Decode($currentHeaderValue), 'rawarray' => $currentRawHeaderLines, - 'raw' => implode(self::CRLF, $currentRawHeaderLines), + 'raw' => implode(self::CRLF . ' ', $currentRawHeaderLines), //Refold lines ]; } $currentHeaderLabel = $matches[1]; From 1d84f39debb9eae9dabd4f8fba5f36f442a519b3 Mon Sep 17 00:00:00 2001 From: Marcus Bointon Date: Tue, 2 Jun 2020 20:43:24 +0200 Subject: [PATCH 18/19] CS --- src/Validator.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Validator.php b/src/Validator.php index dcbab30..f8c965d 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -50,8 +50,10 @@ public function validate(): array $signatureToProcess = preg_replace('/\s+/', '', $signature); //Split into tags $dkimTags = explode(';', $signatureToProcess); - if(end($dkimTags) === '') + //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]); From 3d90eff0e420c0f42e2112cd7e7ba1de884a7d5f Mon Sep 17 00:00:00 2001 From: StinkyTACO <35038928+StinkyTACO@users.noreply.github.com> Date: Fri, 12 Apr 2024 02:10:39 +0200 Subject: [PATCH 19/19] Update Validator.php The value of the "x=" tag MUST be greater than the value of the "t=" tag if both are present. --- src/Validator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Validator.php b/src/Validator.php index f8c965d..eb89a18 100644 --- a/src/Validator.php +++ b/src/Validator.php @@ -141,7 +141,7 @@ public function validate(): array 'reason' => 'Signature has expired.', ]; } - if ((int) $dkimTags['x'] < (int) $dkimTags['t']) { + if ( isset($dkimTags['t']) && ((int) $dkimTags['x'] < (int) $dkimTags['t']) ) { $output[$signatureIndex][] = [ 'status' => 'PERMFAIL', 'reason' => 'Expiry time is before signature time.',