Skip to content

Commit e13d2e7

Browse files
committed
[Mime] Add DKIM support
1 parent 4d477ec commit e13d2e7

File tree

4 files changed

+480
-1
lines changed

4 files changed

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

src/Symfony/Component/Mime/Message.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ public function getPreparedHeaders(): Headers
8080
$headers->addMailboxListHeader('From', [$headers->get('Sender')->getAddress()]);
8181
}
8282

83-
$headers->addTextHeader('MIME-Version', '1.0');
83+
if (!$headers->has('MIME-Version')) {
84+
$headers->addTextHeader('MIME-Version', '1.0');
85+
}
8486

8587
if (!$headers->has('Date')) {
8688
$headers->addDateHeader('Date', new \DateTimeImmutable());

0 commit comments

Comments
 (0)