forked from symfony/symfony
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature symfony#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 symfony#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
- Loading branch information
Showing
5 changed files
with
478 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
<?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; | ||
|
||
/** | ||
* A helper providing autocompletion for available DkimSigner options. | ||
* | ||
* @author Fabien Potencier <fabien@symfony.com> | ||
*/ | ||
final class DkimOptions | ||
{ | ||
private $options = []; | ||
|
||
public function toArray(): array | ||
{ | ||
return $this->options; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function algorithm(int $algo): self | ||
{ | ||
$this->options['algorithm'] = $algo; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function signatureExpirationDelay(int $show): self | ||
{ | ||
$this->options['signature_expiration_delay'] = $show; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function bodyMaxLength(int $max): self | ||
{ | ||
$this->options['body_max_length'] = $max; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function bodyShowLength(bool $show): self | ||
{ | ||
$this->options['body_show_length'] = $show; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function headerCanon(string $canon): self | ||
{ | ||
$this->options['header_canon'] = $canon; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function bodyCanon(string $canon): self | ||
{ | ||
$this->options['body_canon'] = $canon; | ||
|
||
return $this; | ||
} | ||
|
||
/** | ||
* @return $this | ||
*/ | ||
public function headersToIgnore(array $headers): self | ||
{ | ||
$this->options['headers_to_ignore'] = $headers; | ||
|
||
return $this; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
<?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('Invalid DKIM signing algorithm "%s".', $options['algorithm']); | ||
} | ||
$headersToIgnore['return-path'] = 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); | ||
} | ||
|
||
if (0 === $length) { | ||
hash_update($hash, "\r\n"); | ||
$length = 2; | ||
} | ||
|
||
return [hash_final($hash, true), $length]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.