Skip to content

Commit d1b014a

Browse files
committed
feature #37165 [Mime] Add DKIM support (fabpot)
This PR was merged into the 5.2-dev branch. Discussion ---------- [Mime] Add DKIM support | Q | A | ------------- | --- | Branch? | master <!-- see below --> | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | Fix #36014 <!-- prefix each issue number with "Fix #", if any --> | License | MIT | Doc PR | not yet ```php $dkimSigner = new DkimSigner($pk, 'example.com', 'sf'); $signedEmail = $dkimSigner->sign($email); ``` You can also pass options: ```php $dkimSigner = new DkimSigner($pk, 'example.com', 'sf'); $signedEmail = $dkimSigner->sign($email, (new DkimOptions()) ->bodyCanon('relaxed') ->headerCanon('relaxed') ->headersToIgnore(['Message-ID']) ->toArray() ); ``` Commits ------- 6dc5338 [Mime] Add DKIM support
2 parents 753cac7 + 6dc5338 commit d1b014a

File tree

5 files changed

+478
-1
lines changed

5 files changed

+478
-1
lines changed

src/Symfony/Component/Mime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ CHANGELOG
44
5.2.0
55
-----
66

7+
* Add support for DKIM
78
* Deprecated `Address::fromString()`, use `Address::create()` instead
89

910
4.4.0
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+
final 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): self
32+
{
33+
$this->options['algorithm'] = $algo;
34+
35+
return $this;
36+
}
37+
38+
/**
39+
* @return $this
40+
*/
41+
public function signatureExpirationDelay(int $show): self
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): self
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): self
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): self
72+
{
73+
$this->options['header_canon'] = $canon;
74+
75+
return $this;
76+
}
77+
78+
/**
79+
* @return $this
80+
*/
81+
public function bodyCanon(string $canon): self
82+
{
83+
$this->options['body_canon'] = $canon;
84+
85+
return $this;
86+
}
87+
88+
/**
89+
* @return $this
90+
*/
91+
public function headersToIgnore(array $headers): self
92+
{
93+
$this->options['headers_to_ignore'] = $headers;
94+
95+
return $this;
96+
}
97+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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+
}

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)