|
| 1 | +<?php |
| 2 | + |
| 3 | +/* |
| 4 | + * This file is part of the Symfony package. |
| 5 | + * |
| 6 | + * (c) Fabien Potencier <fabien@symfony.com> |
| 7 | + * |
| 8 | + * For the full copyright and license information, please view the LICENSE |
| 9 | + * file that was distributed with this source code. |
| 10 | + */ |
| 11 | + |
| 12 | +namespace Symfony\Component\Mime\Crypto; |
| 13 | + |
| 14 | +use Symfony\Component\Mime\Exception\InvalidArgumentException; |
| 15 | +use Symfony\Component\Mime\Exception\RuntimeException; |
| 16 | +use Symfony\Component\Mime\Header\UnstructuredHeader; |
| 17 | +use Symfony\Component\Mime\Message; |
| 18 | +use Symfony\Component\Mime\Part\AbstractPart; |
| 19 | + |
| 20 | +/** |
| 21 | + * @author Fabien Potencier <fabien@symfony.com> |
| 22 | + * |
| 23 | + * RFC 6376 and 8301 |
| 24 | + */ |
| 25 | +final class DkimSigner |
| 26 | +{ |
| 27 | + public const CANON_SIMPLE = 'simple'; |
| 28 | + public const CANON_RELAXED = 'relaxed'; |
| 29 | + |
| 30 | + public const ALGO_SHA256 = 'rsa-sha256'; |
| 31 | + public const ALGO_ED25519 = 'ed25519-sha256'; // RFC 8463 |
| 32 | + |
| 33 | + private $key; |
| 34 | + private $domainName; |
| 35 | + private $selector; |
| 36 | + private $defaultOptions; |
| 37 | + |
| 38 | + /** |
| 39 | + * @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) |
| 40 | + * @param string $passphrase A passphrase of the private key (if any) |
| 41 | + */ |
| 42 | + public function __construct(string $pk, string $domainName, string $selector, array $defaultOptions = [], string $passphrase = '') |
| 43 | + { |
| 44 | + if (!\extension_loaded('openssl')) { |
| 45 | + throw new \LogicException('PHP extension "openssl" is required to use DKIM.'); |
| 46 | + } |
| 47 | + if (!$this->key = openssl_pkey_get_private($pk, $passphrase)) { |
| 48 | + throw new InvalidArgumentException('Unable to load DKIM private key: '.openssl_error_string()); |
| 49 | + } |
| 50 | + |
| 51 | + $this->domainName = $domainName; |
| 52 | + $this->selector = $selector; |
| 53 | + $this->defaultOptions = $defaultOptions + [ |
| 54 | + 'algorithm' => self::ALGO_SHA256, |
| 55 | + 'signature_expiration_delay' => 0, |
| 56 | + 'body_max_length' => PHP_INT_MAX, |
| 57 | + 'body_show_length' => false, |
| 58 | + 'header_canon' => self::CANON_RELAXED, |
| 59 | + 'body_canon' => self::CANON_RELAXED, |
| 60 | + 'headers_to_ignore' => [], |
| 61 | + ]; |
| 62 | + } |
| 63 | + |
| 64 | + public function sign(Message $message, array $options = []): Message |
| 65 | + { |
| 66 | + $options += $this->defaultOptions; |
| 67 | + if (!\in_array($options['algorithm'], [self::ALGO_SHA256, self::ALGO_ED25519], true)) { |
| 68 | + throw new InvalidArgumentException('Invalid DKIM signing algorithm "%s".', $options['algorithm']); |
| 69 | + } |
| 70 | + $headersToIgnore['return-path'] = true; |
| 71 | + foreach ($options['headers_to_ignore'] as $name) { |
| 72 | + $headersToIgnore[strtolower($name)] = true; |
| 73 | + } |
| 74 | + unset($headersToIgnore['from']); |
| 75 | + $signedHeaderNames = []; |
| 76 | + $headerCanonData = ''; |
| 77 | + $headers = $message->getPreparedHeaders(); |
| 78 | + foreach ($headers->getNames() as $name) { |
| 79 | + foreach ($headers->all($name) as $header) { |
| 80 | + if (isset($headersToIgnore[strtolower($header->getName())])) { |
| 81 | + continue; |
| 82 | + } |
| 83 | + |
| 84 | + if ('' !== $header->getBodyAsString()) { |
| 85 | + $headerCanonData .= $this->canonicalizeHeader($header->toString(), $options['header_canon']); |
| 86 | + $signedHeaderNames[] = $header->getName(); |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + [$bodyHash, $bodyLength] = $this->hashBody($message->getBody(), $options['body_canon'], $options['body_max_length']); |
| 92 | + |
| 93 | + $params = [ |
| 94 | + 'v' => '1', |
| 95 | + 'q' => 'dns/txt', |
| 96 | + 'a' => $options['algorithm'], |
| 97 | + 'bh' => base64_encode($bodyHash), |
| 98 | + 'd' => $this->domainName, |
| 99 | + 'h' => implode(': ', $signedHeaderNames), |
| 100 | + 'i' => '@'.$this->domainName, |
| 101 | + 's' => $this->selector, |
| 102 | + 't' => time(), |
| 103 | + 'c' => $options['header_canon'].'/'.$options['body_canon'], |
| 104 | + ]; |
| 105 | + |
| 106 | + if ($options['body_show_length']) { |
| 107 | + $params['l'] = $bodyLength; |
| 108 | + } |
| 109 | + if ($options['signature_expiration_delay']) { |
| 110 | + $params['x'] = $params['t'] + $options['signature_expiration_delay']; |
| 111 | + } |
| 112 | + $value = ''; |
| 113 | + foreach ($params as $k => $v) { |
| 114 | + $value .= $k.'='.$v.'; '; |
| 115 | + } |
| 116 | + $value = trim($value); |
| 117 | + $header = new UnstructuredHeader('DKIM-Signature', $value); |
| 118 | + $headerCanonData .= rtrim($this->canonicalizeHeader($header->toString()."\r\n b=", $options['header_canon'])); |
| 119 | + if (self::ALGO_SHA256 === $options['algorithm']) { |
| 120 | + if (!openssl_sign($headerCanonData, $signature, $this->key, OPENSSL_ALGO_SHA256)) { |
| 121 | + throw new RuntimeException('Unable to sign DKIM hash: '.openssl_error_string()); |
| 122 | + } |
| 123 | + } else { |
| 124 | + throw new \RuntimeException(sprintf('The "%s" DKIM signing algorithm is not supported yet.', self::ALGO_ED25519)); |
| 125 | + } |
| 126 | + $header->setValue($value.' b='.trim(chunk_split(base64_encode($signature), 73, ' '))); |
| 127 | + $headers->add($header); |
| 128 | + |
| 129 | + return new Message($headers, $message->getBody()); |
| 130 | + } |
| 131 | + |
| 132 | + private function canonicalizeHeader(string $header, string $headerCanon): string |
| 133 | + { |
| 134 | + if (self::CANON_RELAXED !== $headerCanon) { |
| 135 | + return $header."\r\n"; |
| 136 | + } |
| 137 | + |
| 138 | + $exploded = explode(':', $header, 2); |
| 139 | + $name = strtolower(trim($exploded[0])); |
| 140 | + $value = str_replace("\r\n", '', $exploded[1]); |
| 141 | + $value = trim(preg_replace("/[ \t][ \t]+/", ' ', $value)); |
| 142 | + |
| 143 | + return $name.':'.$value."\r\n"; |
| 144 | + } |
| 145 | + |
| 146 | + private function hashBody(AbstractPart $body, string $bodyCanon, int $maxLength): array |
| 147 | + { |
| 148 | + $hash = hash_init('sha256'); |
| 149 | + $relaxed = self::CANON_RELAXED === $bodyCanon; |
| 150 | + $currentLine = ''; |
| 151 | + $emptyCounter = 0; |
| 152 | + $isSpaceSequence = false; |
| 153 | + $length = 0; |
| 154 | + foreach ($body->bodyToIterable() as $chunk) { |
| 155 | + $canon = ''; |
| 156 | + for ($i = 0, $len = \strlen($chunk); $i < $len; ++$i) { |
| 157 | + switch ($chunk[$i]) { |
| 158 | + case "\r": |
| 159 | + break; |
| 160 | + case "\n": |
| 161 | + // previous char is always \r |
| 162 | + if ($relaxed) { |
| 163 | + $isSpaceSequence = false; |
| 164 | + } |
| 165 | + if ('' === $currentLine) { |
| 166 | + ++$emptyCounter; |
| 167 | + } else { |
| 168 | + $currentLine = ''; |
| 169 | + $canon .= "\r\n"; |
| 170 | + } |
| 171 | + break; |
| 172 | + case ' ': |
| 173 | + case "\t": |
| 174 | + if ($relaxed) { |
| 175 | + $isSpaceSequence = true; |
| 176 | + break; |
| 177 | + } |
| 178 | + // no break |
| 179 | + default: |
| 180 | + if ($emptyCounter > 0) { |
| 181 | + $canon .= str_repeat("\r\n", $emptyCounter); |
| 182 | + $emptyCounter = 0; |
| 183 | + } |
| 184 | + if ($isSpaceSequence) { |
| 185 | + $currentLine .= ' '; |
| 186 | + $canon .= ' '; |
| 187 | + $isSpaceSequence = false; |
| 188 | + } |
| 189 | + $currentLine .= $chunk[$i]; |
| 190 | + $canon .= $chunk[$i]; |
| 191 | + } |
| 192 | + } |
| 193 | + |
| 194 | + if ($length + \strlen($canon) >= $maxLength) { |
| 195 | + $canon = substr($canon, 0, $maxLength - $length); |
| 196 | + $length += \strlen($canon); |
| 197 | + hash_update($hash, $canon); |
| 198 | + |
| 199 | + break; |
| 200 | + } |
| 201 | + |
| 202 | + $length += \strlen($canon); |
| 203 | + hash_update($hash, $canon); |
| 204 | + } |
| 205 | + |
| 206 | + if (0 === $length) { |
| 207 | + hash_update($hash, "\r\n"); |
| 208 | + $length = 2; |
| 209 | + } |
| 210 | + |
| 211 | + return [hash_final($hash, true), $length]; |
| 212 | + } |
| 213 | +} |
0 commit comments