| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 | <?php/* * This file is part of the Symfony package. * * (c) Fabien Potencier <fabien@symfony.com> * * 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 <fabien@symfony.com> * * 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(sprintf('Invalid DKIM signing algorithm "%s".', $options['algorithm']));        }        $headersToIgnore['return-path'] = true;        $headersToIgnore['x-transport'] = 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);        }        // Add trailing Line return if last line is non empty        if ('' !== $currentLine) {            hash_update($hash, "\r\n");            $length += \strlen("\r\n");        }        if (!$relaxed && 0 === $length) {            hash_update($hash, "\r\n");            $length = 2;        }        return [hash_final($hash, true), $length];    }}
 |