| 148 |
lars |
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 \OpenSSLAsymmetricKey $key;
|
|
|
34 |
private string $domainName;
|
|
|
35 |
private string $selector;
|
|
|
36 |
private array $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 |
$this->key = openssl_pkey_get_private($pk, $passphrase) ?: throw new InvalidArgumentException('Unable to load DKIM private key: '.openssl_error_string());
|
|
|
48 |
$this->domainName = $domainName;
|
|
|
49 |
$this->selector = $selector;
|
|
|
50 |
$this->defaultOptions = $defaultOptions + [
|
|
|
51 |
'algorithm' => self::ALGO_SHA256,
|
|
|
52 |
'signature_expiration_delay' => 0,
|
|
|
53 |
'body_max_length' => \PHP_INT_MAX,
|
|
|
54 |
'body_show_length' => false,
|
|
|
55 |
'header_canon' => self::CANON_RELAXED,
|
|
|
56 |
'body_canon' => self::CANON_RELAXED,
|
|
|
57 |
'headers_to_ignore' => [],
|
|
|
58 |
];
|
|
|
59 |
}
|
|
|
60 |
|
|
|
61 |
public function sign(Message $message, array $options = []): Message
|
|
|
62 |
{
|
|
|
63 |
$options += $this->defaultOptions;
|
|
|
64 |
if (!\in_array($options['algorithm'], [self::ALGO_SHA256, self::ALGO_ED25519], true)) {
|
|
|
65 |
throw new InvalidArgumentException(sprintf('Invalid DKIM signing algorithm "%s".', $options['algorithm']));
|
|
|
66 |
}
|
|
|
67 |
$headersToIgnore['return-path'] = true;
|
|
|
68 |
$headersToIgnore['x-transport'] = true;
|
|
|
69 |
foreach ($options['headers_to_ignore'] as $name) {
|
|
|
70 |
$headersToIgnore[strtolower($name)] = true;
|
|
|
71 |
}
|
|
|
72 |
unset($headersToIgnore['from']);
|
|
|
73 |
$signedHeaderNames = [];
|
|
|
74 |
$headerCanonData = '';
|
|
|
75 |
$headers = $message->getPreparedHeaders();
|
|
|
76 |
foreach ($headers->getNames() as $name) {
|
|
|
77 |
foreach ($headers->all($name) as $header) {
|
|
|
78 |
if (isset($headersToIgnore[strtolower($header->getName())])) {
|
|
|
79 |
continue;
|
|
|
80 |
}
|
|
|
81 |
|
|
|
82 |
if ('' !== $header->getBodyAsString()) {
|
|
|
83 |
$headerCanonData .= $this->canonicalizeHeader($header->toString(), $options['header_canon']);
|
|
|
84 |
$signedHeaderNames[] = $header->getName();
|
|
|
85 |
}
|
|
|
86 |
}
|
|
|
87 |
}
|
|
|
88 |
|
|
|
89 |
[$bodyHash, $bodyLength] = $this->hashBody($message->getBody(), $options['body_canon'], $options['body_max_length']);
|
|
|
90 |
|
|
|
91 |
$params = [
|
|
|
92 |
'v' => '1',
|
|
|
93 |
'q' => 'dns/txt',
|
|
|
94 |
'a' => $options['algorithm'],
|
|
|
95 |
'bh' => base64_encode($bodyHash),
|
|
|
96 |
'd' => $this->domainName,
|
|
|
97 |
'h' => implode(': ', $signedHeaderNames),
|
|
|
98 |
'i' => '@'.$this->domainName,
|
|
|
99 |
's' => $this->selector,
|
|
|
100 |
't' => time(),
|
|
|
101 |
'c' => $options['header_canon'].'/'.$options['body_canon'],
|
|
|
102 |
];
|
|
|
103 |
|
|
|
104 |
if ($options['body_show_length']) {
|
|
|
105 |
$params['l'] = $bodyLength;
|
|
|
106 |
}
|
|
|
107 |
if ($options['signature_expiration_delay']) {
|
|
|
108 |
$params['x'] = $params['t'] + $options['signature_expiration_delay'];
|
|
|
109 |
}
|
|
|
110 |
$value = '';
|
|
|
111 |
foreach ($params as $k => $v) {
|
|
|
112 |
$value .= $k.'='.$v.'; ';
|
|
|
113 |
}
|
|
|
114 |
$value = trim($value);
|
|
|
115 |
$header = new UnstructuredHeader('DKIM-Signature', $value);
|
|
|
116 |
$headerCanonData .= rtrim($this->canonicalizeHeader($header->toString()."\r\n b=", $options['header_canon']));
|
|
|
117 |
if (self::ALGO_SHA256 === $options['algorithm']) {
|
|
|
118 |
if (!openssl_sign($headerCanonData, $signature, $this->key, \OPENSSL_ALGO_SHA256)) {
|
|
|
119 |
throw new RuntimeException('Unable to sign DKIM hash: '.openssl_error_string());
|
|
|
120 |
}
|
|
|
121 |
} else {
|
|
|
122 |
throw new \RuntimeException(sprintf('The "%s" DKIM signing algorithm is not supported yet.', self::ALGO_ED25519));
|
|
|
123 |
}
|
|
|
124 |
$header->setValue($value.' b='.trim(chunk_split(base64_encode($signature), 73, ' ')));
|
|
|
125 |
$headers->add($header);
|
|
|
126 |
|
|
|
127 |
return new Message($headers, $message->getBody());
|
|
|
128 |
}
|
|
|
129 |
|
|
|
130 |
private function canonicalizeHeader(string $header, string $headerCanon): string
|
|
|
131 |
{
|
|
|
132 |
if (self::CANON_RELAXED !== $headerCanon) {
|
|
|
133 |
return $header."\r\n";
|
|
|
134 |
}
|
|
|
135 |
|
|
|
136 |
$exploded = explode(':', $header, 2);
|
|
|
137 |
$name = strtolower(trim($exploded[0]));
|
|
|
138 |
$value = str_replace("\r\n", '', $exploded[1]);
|
|
|
139 |
$value = trim(preg_replace("/[ \t][ \t]+/", ' ', $value));
|
|
|
140 |
|
|
|
141 |
return $name.':'.$value."\r\n";
|
|
|
142 |
}
|
|
|
143 |
|
|
|
144 |
private function hashBody(AbstractPart $body, string $bodyCanon, int $maxLength): array
|
|
|
145 |
{
|
|
|
146 |
$hash = hash_init('sha256');
|
|
|
147 |
$relaxed = self::CANON_RELAXED === $bodyCanon;
|
|
|
148 |
$currentLine = '';
|
|
|
149 |
$emptyCounter = 0;
|
|
|
150 |
$isSpaceSequence = false;
|
|
|
151 |
$length = 0;
|
|
|
152 |
foreach ($body->bodyToIterable() as $chunk) {
|
|
|
153 |
$canon = '';
|
|
|
154 |
for ($i = 0, $len = \strlen($chunk); $i < $len; ++$i) {
|
|
|
155 |
switch ($chunk[$i]) {
|
|
|
156 |
case "\r":
|
|
|
157 |
break;
|
|
|
158 |
case "\n":
|
|
|
159 |
// previous char is always \r
|
|
|
160 |
if ($relaxed) {
|
|
|
161 |
$isSpaceSequence = false;
|
|
|
162 |
}
|
|
|
163 |
if ('' === $currentLine) {
|
|
|
164 |
++$emptyCounter;
|
|
|
165 |
} else {
|
|
|
166 |
$currentLine = '';
|
|
|
167 |
$canon .= "\r\n";
|
|
|
168 |
}
|
|
|
169 |
break;
|
|
|
170 |
case ' ':
|
|
|
171 |
case "\t":
|
|
|
172 |
if ($relaxed) {
|
|
|
173 |
$isSpaceSequence = true;
|
|
|
174 |
break;
|
|
|
175 |
}
|
|
|
176 |
// no break
|
|
|
177 |
default:
|
|
|
178 |
if ($emptyCounter > 0) {
|
|
|
179 |
$canon .= str_repeat("\r\n", $emptyCounter);
|
|
|
180 |
$emptyCounter = 0;
|
|
|
181 |
}
|
|
|
182 |
if ($isSpaceSequence) {
|
|
|
183 |
$currentLine .= ' ';
|
|
|
184 |
$canon .= ' ';
|
|
|
185 |
$isSpaceSequence = false;
|
|
|
186 |
}
|
|
|
187 |
$currentLine .= $chunk[$i];
|
|
|
188 |
$canon .= $chunk[$i];
|
|
|
189 |
}
|
|
|
190 |
}
|
|
|
191 |
|
|
|
192 |
if ($length + \strlen($canon) >= $maxLength) {
|
|
|
193 |
$canon = substr($canon, 0, $maxLength - $length);
|
|
|
194 |
$length += \strlen($canon);
|
|
|
195 |
hash_update($hash, $canon);
|
|
|
196 |
|
|
|
197 |
break;
|
|
|
198 |
}
|
|
|
199 |
|
|
|
200 |
$length += \strlen($canon);
|
|
|
201 |
hash_update($hash, $canon);
|
|
|
202 |
}
|
|
|
203 |
|
|
|
204 |
// Add trailing Line return if last line is non empty
|
|
|
205 |
if ('' !== $currentLine) {
|
|
|
206 |
hash_update($hash, "\r\n");
|
|
|
207 |
$length += \strlen("\r\n");
|
|
|
208 |
}
|
|
|
209 |
|
|
|
210 |
if (!$relaxed && 0 === $length) {
|
|
|
211 |
hash_update($hash, "\r\n");
|
|
|
212 |
$length = 2;
|
|
|
213 |
}
|
|
|
214 |
|
|
|
215 |
return [hash_final($hash, true), $length];
|
|
|
216 |
}
|
|
|
217 |
}
|