diff --git a/src/Symfony/Component/Mime/CHANGELOG.md b/src/Symfony/Component/Mime/CHANGELOG.md index df4872bb13..f272346c97 100644 --- a/src/Symfony/Component/Mime/CHANGELOG.md +++ b/src/Symfony/Component/Mime/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.2.0 ----- + * Add support for DKIM * Deprecated `Address::fromString()`, use `Address::create()` instead 4.4.0 diff --git a/src/Symfony/Component/Mime/Crypto/DkimOptions.php b/src/Symfony/Component/Mime/Crypto/DkimOptions.php new file mode 100644 index 0000000000..4c51d66158 --- /dev/null +++ b/src/Symfony/Component/Mime/Crypto/DkimOptions.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mime\Crypto; + +/** + * A helper providing autocompletion for available DkimSigner options. + * + * @author Fabien Potencier + */ +final class DkimOptions +{ + private $options = []; + + public function toArray(): array + { + return $this->options; + } + + /** + * @return $this + */ + public function algorithm(int $algo): self + { + $this->options['algorithm'] = $algo; + + return $this; + } + + /** + * @return $this + */ + public function signatureExpirationDelay(int $show): self + { + $this->options['signature_expiration_delay'] = $show; + + return $this; + } + + /** + * @return $this + */ + public function bodyMaxLength(int $max): self + { + $this->options['body_max_length'] = $max; + + return $this; + } + + /** + * @return $this + */ + public function bodyShowLength(bool $show): self + { + $this->options['body_show_length'] = $show; + + return $this; + } + + /** + * @return $this + */ + public function headerCanon(string $canon): self + { + $this->options['header_canon'] = $canon; + + return $this; + } + + /** + * @return $this + */ + public function bodyCanon(string $canon): self + { + $this->options['body_canon'] = $canon; + + return $this; + } + + /** + * @return $this + */ + public function headersToIgnore(array $headers): self + { + $this->options['headers_to_ignore'] = $headers; + + return $this; + } +} diff --git a/src/Symfony/Component/Mime/Crypto/DkimSigner.php b/src/Symfony/Component/Mime/Crypto/DkimSigner.php new file mode 100644 index 0000000000..3ddd56d381 --- /dev/null +++ b/src/Symfony/Component/Mime/Crypto/DkimSigner.php @@ -0,0 +1,213 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mime\Crypto; + +use Symfony\Component\Mime\Exception\InvalidArgumentException; +use Symfony\Component\Mime\Exception\RuntimeException; +use Symfony\Component\Mime\Header\UnstructuredHeader; +use Symfony\Component\Mime\Message; +use Symfony\Component\Mime\Part\AbstractPart; + +/** + * @author Fabien Potencier + * + * RFC 6376 and 8301 + */ +final class DkimSigner +{ + public const CANON_SIMPLE = 'simple'; + public const CANON_RELAXED = 'relaxed'; + + public const ALGO_SHA256 = 'rsa-sha256'; + public const ALGO_ED25519 = 'ed25519-sha256'; // RFC 8463 + + private $key; + private $domainName; + private $selector; + private $defaultOptions; + + /** + * @param string $pk The private key as a string or the path to the file containing the private key, should be prefixed with file:// (in PEM format) + * @param string $passphrase A passphrase of the private key (if any) + */ + public function __construct(string $pk, string $domainName, string $selector, array $defaultOptions = [], string $passphrase = '') + { + if (!\extension_loaded('openssl')) { + throw new \LogicException('PHP extension "openssl" is required to use DKIM.'); + } + if (!$this->key = openssl_pkey_get_private($pk, $passphrase)) { + throw new InvalidArgumentException('Unable to load DKIM private key: '.openssl_error_string()); + } + + $this->domainName = $domainName; + $this->selector = $selector; + $this->defaultOptions = $defaultOptions + [ + 'algorithm' => self::ALGO_SHA256, + 'signature_expiration_delay' => 0, + 'body_max_length' => PHP_INT_MAX, + 'body_show_length' => false, + 'header_canon' => self::CANON_RELAXED, + 'body_canon' => self::CANON_RELAXED, + 'headers_to_ignore' => [], + ]; + } + + public function sign(Message $message, array $options = []): Message + { + $options += $this->defaultOptions; + if (!\in_array($options['algorithm'], [self::ALGO_SHA256, self::ALGO_ED25519], true)) { + throw new InvalidArgumentException('Invalid DKIM signing algorithm "%s".', $options['algorithm']); + } + $headersToIgnore['return-path'] = true; + foreach ($options['headers_to_ignore'] as $name) { + $headersToIgnore[strtolower($name)] = true; + } + unset($headersToIgnore['from']); + $signedHeaderNames = []; + $headerCanonData = ''; + $headers = $message->getPreparedHeaders(); + foreach ($headers->getNames() as $name) { + foreach ($headers->all($name) as $header) { + if (isset($headersToIgnore[strtolower($header->getName())])) { + continue; + } + + if ('' !== $header->getBodyAsString()) { + $headerCanonData .= $this->canonicalizeHeader($header->toString(), $options['header_canon']); + $signedHeaderNames[] = $header->getName(); + } + } + } + + [$bodyHash, $bodyLength] = $this->hashBody($message->getBody(), $options['body_canon'], $options['body_max_length']); + + $params = [ + 'v' => '1', + 'q' => 'dns/txt', + 'a' => $options['algorithm'], + 'bh' => base64_encode($bodyHash), + 'd' => $this->domainName, + 'h' => implode(': ', $signedHeaderNames), + 'i' => '@'.$this->domainName, + 's' => $this->selector, + 't' => time(), + 'c' => $options['header_canon'].'/'.$options['body_canon'], + ]; + + if ($options['body_show_length']) { + $params['l'] = $bodyLength; + } + if ($options['signature_expiration_delay']) { + $params['x'] = $params['t'] + $options['signature_expiration_delay']; + } + $value = ''; + foreach ($params as $k => $v) { + $value .= $k.'='.$v.'; '; + } + $value = trim($value); + $header = new UnstructuredHeader('DKIM-Signature', $value); + $headerCanonData .= rtrim($this->canonicalizeHeader($header->toString()."\r\n b=", $options['header_canon'])); + if (self::ALGO_SHA256 === $options['algorithm']) { + if (!openssl_sign($headerCanonData, $signature, $this->key, OPENSSL_ALGO_SHA256)) { + throw new RuntimeException('Unable to sign DKIM hash: '.openssl_error_string()); + } + } else { + throw new \RuntimeException(sprintf('The "%s" DKIM signing algorithm is not supported yet.', self::ALGO_ED25519)); + } + $header->setValue($value.' b='.trim(chunk_split(base64_encode($signature), 73, ' '))); + $headers->add($header); + + return new Message($headers, $message->getBody()); + } + + private function canonicalizeHeader(string $header, string $headerCanon): string + { + if (self::CANON_RELAXED !== $headerCanon) { + return $header."\r\n"; + } + + $exploded = explode(':', $header, 2); + $name = strtolower(trim($exploded[0])); + $value = str_replace("\r\n", '', $exploded[1]); + $value = trim(preg_replace("/[ \t][ \t]+/", ' ', $value)); + + return $name.':'.$value."\r\n"; + } + + private function hashBody(AbstractPart $body, string $bodyCanon, int $maxLength): array + { + $hash = hash_init('sha256'); + $relaxed = self::CANON_RELAXED === $bodyCanon; + $currentLine = ''; + $emptyCounter = 0; + $isSpaceSequence = false; + $length = 0; + foreach ($body->bodyToIterable() as $chunk) { + $canon = ''; + for ($i = 0, $len = \strlen($chunk); $i < $len; ++$i) { + switch ($chunk[$i]) { + case "\r": + break; + case "\n": + // previous char is always \r + if ($relaxed) { + $isSpaceSequence = false; + } + if ('' === $currentLine) { + ++$emptyCounter; + } else { + $currentLine = ''; + $canon .= "\r\n"; + } + break; + case ' ': + case "\t": + if ($relaxed) { + $isSpaceSequence = true; + break; + } + // no break + default: + if ($emptyCounter > 0) { + $canon .= str_repeat("\r\n", $emptyCounter); + $emptyCounter = 0; + } + if ($isSpaceSequence) { + $currentLine .= ' '; + $canon .= ' '; + $isSpaceSequence = false; + } + $currentLine .= $chunk[$i]; + $canon .= $chunk[$i]; + } + } + + if ($length + \strlen($canon) >= $maxLength) { + $canon = substr($canon, 0, $maxLength - $length); + $length += \strlen($canon); + hash_update($hash, $canon); + + break; + } + + $length += \strlen($canon); + hash_update($hash, $canon); + } + + if (0 === $length) { + hash_update($hash, "\r\n"); + $length = 2; + } + + return [hash_final($hash, true), $length]; + } +} diff --git a/src/Symfony/Component/Mime/Message.php b/src/Symfony/Component/Mime/Message.php index b7ddb76bc7..651ffd4529 100644 --- a/src/Symfony/Component/Mime/Message.php +++ b/src/Symfony/Component/Mime/Message.php @@ -80,7 +80,9 @@ class Message extends RawMessage $headers->addMailboxListHeader('From', [$headers->get('Sender')->getAddress()]); } - $headers->addTextHeader('MIME-Version', '1.0'); + if (!$headers->has('MIME-Version')) { + $headers->addTextHeader('MIME-Version', '1.0'); + } if (!$headers->has('Date')) { $headers->addDateHeader('Date', new \DateTimeImmutable()); diff --git a/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php b/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php new file mode 100644 index 0000000000..a7dabfad7a --- /dev/null +++ b/src/Symfony/Component/Mime/Tests/Crypto/DkimSignerTest.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Mime\Tests\Crypto; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Crypto\DkimSigner; +use Symfony\Component\Mime\Email; + +/** + * @group time-sensitive + * @requires extension openssl + */ +class DkimSignerTest extends TestCase +{ + private static $pk = <<from(new Address('fabien@testdkim.symfony.net', 'Fabién')) + ->to('fabien.potencier@gmail.com') + ->subject('Tést') + ->text("Some body \n \n This \r\n\r\n is really interesting and at the same time very long line to see if everything works as expected, does it?\r\n\r\n\r\n\r\n") + ->date(new \DateTimeImmutable('2005-10-15', new \DateTimeZone('Europe/Paris'))); + + $signer = new DkimSigner(self::$pk, 'testdkim.symfony.net', 'sf'); + $signedMessage = $signer->sign($message, [ + 'header_canon' => $headerCanon, + 'body_canon' => $bodyCanon, + 'headers_to_ignore' => ['Message-ID'], + ]); + + $this->assertSame($message->getBody()->toString(), $signedMessage->getBody()->toString()); + $this->assertTrue($signedMessage->getHeaders()->has('DKIM-Signature')); + $this->assertEquals($header, $signedMessage->getHeaders()->get('DKIM-Signature')->getBody()); + } + + public function getSignData() + { + yield 'simple/simple' => [ + 1591597074, DkimSigner::CANON_SIMPLE, DkimSigner::CANON_SIMPLE, + 'v=1; q=dns/txt; a=rsa-sha256; bh=JC6qmm3afMaxL3Rm1YHxrzIpqiUuB7aAarWMcZfuca4=; d=testdkim.symfony.net; h=From: To: Subject: Date: MIME-Version; i=@testdkim.symfony.net; s=sf; t=1591597074; c=simple/simple; b=Z+KvV7QwQ7gdTy49sOzT1c+UDZbT8nFUClbiW8cCKtj4HVuIxGUgWMSN46CX8GoYd0rIsoutF +Cgc4rcp/AU9tgLswliYh66Gk5gR6tA0h13FBVFuWeWz7PiMK5s8nLymMmiKDM0GNjshy4cdD VnQdREINJOD7yycmRDPT0Q828=', + ]; + + yield 'relaxed/simple' => [ + 1591597424, DkimSigner::CANON_RELAXED, DkimSigner::CANON_SIMPLE, + 'v=1; q=dns/txt; a=rsa-sha256; bh=JC6qmm3afMaxL3Rm1YHxrzIpqiUuB7aAarWMcZfuca4=; d=testdkim.symfony.net; h=From: To: Subject: Date: MIME-Version; i=@testdkim.symfony.net; s=sf; t=1591597424; c=simple/relaxed; b=F52zm1Pg6VKb0g6ySZ6KcFxC2jlnUVkXb2OjptChUXsJBM83n1Gk48D2ipbP2L+UkKXvKl6YI BdMxkde0Tpw0hTxDJdM5xekacqWZbyC0y8wE5Ks635aDagdV+WfJ3m6l3grb+Ng+qqetEWZpP 3vRRBd8qDn9IUgoPxDJ6MpIMs=', + ]; + + yield 'relaxed/relaxed' => [ + 1591597493, DkimSigner::CANON_RELAXED, DkimSigner::CANON_RELAXED, + 'v=1; q=dns/txt; a=rsa-sha256; bh=JC6qmm3afMaxL3Rm1YHxrzIpqiUuB7aAarWMcZfuca4=; d=testdkim.symfony.net; h=From: To: Subject: Date: MIME-Version; i=@testdkim.symfony.net; s=sf; t=1591597493; c=relaxed/relaxed; b=sINllavShGfnMXymubjBflrAlRlv3zGTP/ZbI2XlFqu5G7Bvb0jFReKkgUo/Swezt50w4WqxP 3zNv4W1uilomtgqjihf4WJRi/wMnVjCt8KZ8z3AXrDK+udcXln6OCLw63CrV4FpdOfYyQUQBq NaizUh+k7y1dvqxMJTaAp2POY=', + ]; + + yield 'simple/relaxed' => [ + 1591597612, DkimSigner::CANON_SIMPLE, DkimSigner::CANON_RELAXED, + 'v=1; q=dns/txt; a=rsa-sha256; bh=JC6qmm3afMaxL3Rm1YHxrzIpqiUuB7aAarWMcZfuca4=; d=testdkim.symfony.net; h=From: To: Subject: Date: MIME-Version; i=@testdkim.symfony.net; s=sf; t=1591597612; c=relaxed/simple; b=E+BszWWfYJfrWXk5uggwZJmLlh+4IeVScnJhqAj0G4h0dhqRZ0Qs1XNPSS0IZtPSTUgNxAeTi mc8jjVCnrROPnYnaomvgTdkxwRU5ZcA4felmGjcXODrdy9GUAokES6qjy4bVwBvaHxMgr00eP J3sJqBBwcg/HsO52ppJma/1HM=', + ]; + } + + /** + * @dataProvider getCanonicalizeHeaderData + */ + public function testCanonicalizeHeader(string $bodyCanon, string $canonBody, string $body, int $maxLength) + { + $message = (new Email()) + ->from(new Address('fabien@testdkim.symfony.net', 'Fabién')) + ->to('fabien.potencier@gmail.com') + ->subject('Tést') + ->text($body) + ; + + $signer = new DkimSigner(self::$pk, 'testdkim.symfony.net', 'sf'); + $signedMessage = $signer->sign($message, [ + 'body_canon' => $bodyCanon, + 'body_max_length' => $maxLength, + 'body_show_length' => true, + ]); + + preg_match('{bh=([^;]+).+l=([^;]+)}', $signedMessage->getHeaders()->get('DKIM-Signature')->getBody(), $matches); + $bh = $matches[1]; + $l = $matches[2]; + $this->assertEquals(base64_encode(hash('sha256', $canonBody, true)), $bh); + $this->assertEquals(\strlen($canonBody), $l); + } + + public function getCanonicalizeHeaderData() + { + yield 'simple_empty' => [ + DkimSigner::CANON_SIMPLE, "\r\n", '', PHP_INT_MAX, + ]; + yield 'relaxed_empty' => [ + DkimSigner::CANON_RELAXED, "\r\n", '', PHP_INT_MAX, + ]; + + yield 'simple_empty_single_ending_CLRF' => [ + DkimSigner::CANON_SIMPLE, "\r\n", "\r\n", PHP_INT_MAX, + ]; + yield 'relaxed_empty_single_ending_CLRF' => [ + DkimSigner::CANON_RELAXED, "\r\n", "\r\n", PHP_INT_MAX, + ]; + + yield 'simple_multiple_ending_CLRF' => [ + DkimSigner::CANON_SIMPLE, "Some body\r\n", "Some body\r\n\r\n\r\n\r\n\r\n\r\n", PHP_INT_MAX, + ]; + yield 'relaxed_multiple_ending_CLRF' => [ + DkimSigner::CANON_RELAXED, "Some body\r\n", "Some body\r\n\r\n\r\n\r\n\r\n\r\n", PHP_INT_MAX, + ]; + + yield 'simple_basic' => [ + DkimSigner::CANON_SIMPLE, "Some body\r\n", "Some body\r\n", PHP_INT_MAX, + ]; + yield 'relaxed_basic' => [ + DkimSigner::CANON_RELAXED, "Some body\r\n", "Some body\r\n", PHP_INT_MAX, + ]; + + $body = "Some body with whitespaces\r\n"; + yield 'simple_with_many_inline_whitespaces' => [ + DkimSigner::CANON_SIMPLE, $body, $body, PHP_INT_MAX, + ]; + yield 'relaxed_with_many_inline_whitespaces' => [ + DkimSigner::CANON_RELAXED, "Some body with whitespaces\r\n", $body, PHP_INT_MAX, + ]; + + yield 'simple_basic_with_length' => [ + DkimSigner::CANON_SIMPLE, 'Some b', "Some body\r\n", 6, + ]; + yield 'relaxed_basic_with_length' => [ + DkimSigner::CANON_RELAXED, 'Some b', "Some body\r\n", 6, + ]; + } +}