DkimSigner.php 7.9 KB

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