Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feature #30981 [Mime] S/MIME Support (sstok)
This PR was merged into the 4.4 branch. Discussion ---------- [Mime] S/MIME Support | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes <!-- don't forget to update src/**/CHANGELOG.md files --> | BC breaks? | no | Deprecations? | no | Tests pass? | no | Fixed tickets | #30875 | License | MIT | Doc PR | TODO ~~This is a heavy work in progress and far from working, I tried to finish this before the end of FOSSA but due to the large complexity of working with raw Mime data it will properly take a week or so (at least I hope so..) to completely finish this. I'm sharing it here for the statistics.~~ This adds the S/MIME Signer and Encryptor, unlike the Swiftmailer implementation both these functionalities have been separated. When a transporter doesn't support the Raw MIME entity information it will fallback to the original message (without signing/encryption). In any case using a secure connection is always the best guarantee against modification or information disclosure. Commits ------- 6e70d12 [Mime] Added SMimeSigner and Encryptor
- Loading branch information
Showing
23 changed files
with
1,074 additions
and
0 deletions.
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
<?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\RuntimeException; | ||
use Symfony\Component\Mime\Part\SMimePart; | ||
|
||
/** | ||
* @author Sebastiaan Stok <s.stok@rollerscapes.net> | ||
* | ||
* @internal | ||
*/ | ||
abstract class SMime | ||
{ | ||
protected function normalizeFilePath(string $path): string | ||
{ | ||
if (!file_exists($path)) { | ||
throw new RuntimeException(sprintf('File does not exist: %s.', $path)); | ||
} | ||
|
||
return 'file://'.str_replace('\\', '/', realpath($path)); | ||
} | ||
|
||
protected function iteratorToFile(iterable $iterator, $stream): void | ||
{ | ||
foreach ($iterator as $chunk) { | ||
fwrite($stream, $chunk); | ||
} | ||
} | ||
|
||
protected function convertMessageToSMimePart($stream, string $type, string $subtype): SMimePart | ||
{ | ||
rewind($stream); | ||
|
||
$headers = ''; | ||
|
||
while (!feof($stream)) { | ||
$buffer = fread($stream, 78); | ||
$headers .= $buffer; | ||
|
||
// Detect ending of header list | ||
if (preg_match('/(\r\n\r\n|\n\n)/', $headers, $match)) { | ||
$headersPosEnd = strpos($headers, $headerBodySeparator = $match[0]); | ||
|
||
break; | ||
} | ||
} | ||
|
||
$headers = $this->getMessageHeaders(trim(substr($headers, 0, $headersPosEnd))); | ||
|
||
fseek($stream, $headersPosEnd + \strlen($headerBodySeparator)); | ||
|
||
return new SMimePart($this->getStreamIterator($stream), $type, $subtype, $this->getParametersFromHeader($headers['content-type'])); | ||
} | ||
|
||
protected function getStreamIterator($stream): iterable | ||
{ | ||
while (!feof($stream)) { | ||
yield fread($stream, 16372); | ||
} | ||
} | ||
|
||
private function getMessageHeaders(string $headerData): array | ||
{ | ||
$headers = []; | ||
$headerLines = explode("\r\n", str_replace("\n", "\r\n", str_replace("\r\n", "\n", $headerData))); | ||
$currentHeaderName = ''; | ||
|
||
// Transform header lines into an associative array | ||
foreach ($headerLines as $headerLine) { | ||
// Empty lines between headers indicate a new mime-entity | ||
if ('' === $headerLine) { | ||
break; | ||
} | ||
|
||
// Handle headers that span multiple lines | ||
if (false === strpos($headerLine, ':')) { | ||
$headers[$currentHeaderName] .= ' '.trim($headerLine); | ||
continue; | ||
} | ||
|
||
$header = explode(':', $headerLine, 2); | ||
$currentHeaderName = strtolower($header[0]); | ||
$headers[$currentHeaderName] = trim($header[1]); | ||
} | ||
|
||
return $headers; | ||
} | ||
|
||
private function getParametersFromHeader(string $header): array | ||
{ | ||
$params = []; | ||
|
||
preg_match_all('/(?P<name>[a-z-0-9]+)=(?P<value>"[^"]+"|(?:[^\s;]+|$))(?:\s+;)?/i', $header, $matches); | ||
|
||
foreach ($matches['value'] as $pos => $paramValue) { | ||
$params[$matches['name'][$pos]] = trim($paramValue, '"'); | ||
} | ||
|
||
return $params; | ||
} | ||
} |
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,58 @@ | ||
<?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\RuntimeException; | ||
use Symfony\Component\Mime\Message; | ||
|
||
/** | ||
* @author Sebastiaan Stok <s.stok@rollerscapes.net> | ||
*/ | ||
final class SMimeEncrypter extends SMime | ||
{ | ||
private $certs; | ||
private $cipher; | ||
|
||
/** | ||
* @param string|string[] $certificate Either a lone X.509 certificate, or an array of X.509 certificates | ||
*/ | ||
public function __construct($certificate, int $cipher = OPENSSL_CIPHER_AES_256_CBC) | ||
{ | ||
if (\is_array($certificate)) { | ||
$this->certs = array_map([$this, 'normalizeFilePath'], $certificate); | ||
} else { | ||
$this->certs = $this->normalizeFilePath($certificate); | ||
} | ||
|
||
$this->cipher = $cipher; | ||
} | ||
|
||
public function encrypt(Message $message): Message | ||
{ | ||
$bufferFile = tmpfile(); | ||
$outputFile = tmpfile(); | ||
|
||
$this->iteratorToFile($message->toIterable(), $bufferFile); | ||
|
||
if (!@openssl_pkcs7_encrypt(stream_get_meta_data($bufferFile)['uri'], stream_get_meta_data($outputFile)['uri'], $this->certs, [], 0, $this->cipher)) { | ||
throw new RuntimeException(sprintf('Failed to encrypt S/Mime message. Error: "%s".', openssl_error_string())); | ||
} | ||
|
||
$mimePart = $this->convertMessageToSMimePart($outputFile, 'application', 'pkcs7-mime'); | ||
$mimePart->getHeaders() | ||
->addTextHeader('Content-Transfer-Encoding', 'base64') | ||
->addParameterizedHeader('Content-Disposition', 'attachment', ['name' => 'smime.p7m']) | ||
; | ||
|
||
return new Message($message->getHeaders(), $mimePart); | ||
} | ||
} |
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,69 @@ | ||
<?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\RuntimeException; | ||
use Symfony\Component\Mime\Message; | ||
|
||
/** | ||
* @author Sebastiaan Stok <s.stok@rollerscapes.net> | ||
*/ | ||
final class SMimeSigner extends SMime | ||
{ | ||
private $signCertificate; | ||
private $signPrivateKey; | ||
private $signOptions; | ||
private $extraCerts; | ||
|
||
/** | ||
* @var string|null | ||
*/ | ||
private $privateKeyPassphrase; | ||
|
||
/** | ||
* @see https://secure.php.net/manual/en/openssl.pkcs7.flags.php | ||
* | ||
* @param string $certificate | ||
* @param string $privateKey A file containing the private key (in PEM format) | ||
* @param string|null $privateKeyPassphrase A passphrase of the private key (if any) | ||
* @param string $extraCerts A file containing intermediate certificates (in PEM format) needed by the signing certificate | ||
* @param int $signOptions Bitwise operator options for openssl_pkcs7_sign() | ||
*/ | ||
public function __construct(string $certificate, string $privateKey, ?string $privateKeyPassphrase = null, ?string $extraCerts = null, int $signOptions = PKCS7_DETACHED) | ||
{ | ||
$this->signCertificate = $this->normalizeFilePath($certificate); | ||
|
||
if (null !== $privateKeyPassphrase) { | ||
$this->signPrivateKey = [$this->normalizeFilePath($privateKey), $privateKeyPassphrase]; | ||
} else { | ||
$this->signPrivateKey = $this->normalizeFilePath($privateKey); | ||
} | ||
|
||
$this->signOptions = $signOptions; | ||
$this->extraCerts = $extraCerts ? realpath($extraCerts) : null; | ||
$this->privateKeyPassphrase = $privateKeyPassphrase; | ||
} | ||
|
||
public function sign(Message $message): Message | ||
{ | ||
$bufferFile = tmpfile(); | ||
$outputFile = tmpfile(); | ||
|
||
$this->iteratorToFile($message->getBody()->toIterable(), $bufferFile); | ||
|
||
if (!@openssl_pkcs7_sign(stream_get_meta_data($bufferFile)['uri'], stream_get_meta_data($outputFile)['uri'], $this->signCertificate, $this->signPrivateKey, [], $this->signOptions, $this->extraCerts)) { | ||
throw new RuntimeException(sprintf('Failed to sign S/Mime message. Error: "%s".', openssl_error_string())); | ||
} | ||
|
||
return new Message($message->getHeaders(), $this->convertMessageToSMimePart($outputFile, 'multipart', 'signed')); | ||
} | ||
} |
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,118 @@ | ||
<?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\Part; | ||
|
||
use Symfony\Component\Mime\Header\Headers; | ||
|
||
/** | ||
* @author Sebastiaan Stok <s.stok@rollerscapes.net> | ||
* | ||
* @experimental in 4.4 | ||
*/ | ||
class SMimePart extends AbstractPart | ||
{ | ||
private $body; | ||
private $type; | ||
private $subtype; | ||
private $parameters; | ||
|
||
/** | ||
* @param iterable|string $body | ||
*/ | ||
public function __construct($body, string $type, string $subtype, array $parameters) | ||
{ | ||
parent::__construct(); | ||
|
||
if (!\is_string($body) && !is_iterable($body)) { | ||
throw new \TypeError(sprintf('The body of "%s" must be a string or a iterable (got "%s").', self::class, \is_object($body) ? \get_class($body) : \gettype($body))); | ||
} | ||
|
||
$this->body = $body; | ||
$this->type = $type; | ||
$this->subtype = $subtype; | ||
$this->parameters = $parameters; | ||
} | ||
|
||
public function getMediaType(): string | ||
{ | ||
return $this->type; | ||
} | ||
|
||
public function getMediaSubtype(): string | ||
{ | ||
return $this->subtype; | ||
} | ||
|
||
public function bodyToString(): string | ||
{ | ||
if (\is_string($this->body)) { | ||
return $this->body; | ||
} | ||
|
||
$body = ''; | ||
foreach ($this->body as $chunk) { | ||
$body .= $chunk; | ||
} | ||
$this->body = $body; | ||
|
||
return $body; | ||
} | ||
|
||
public function bodyToIterable(): iterable | ||
{ | ||
if (\is_string($this->body)) { | ||
yield $this->body; | ||
|
||
return; | ||
} | ||
|
||
$body = ''; | ||
foreach ($this->body as $chunk) { | ||
$body .= $chunk; | ||
yield $chunk; | ||
} | ||
$this->body = $body; | ||
} | ||
|
||
public function getPreparedHeaders(): Headers | ||
{ | ||
$headers = clone parent::getHeaders(); | ||
|
||
$headers->setHeaderBody('Parameterized', 'Content-Type', $this->getMediaType().'/'.$this->getMediaSubtype()); | ||
|
||
foreach ($this->parameters as $name => $value) { | ||
$headers->setHeaderParameter('Content-Type', $name, $value); | ||
} | ||
|
||
return $headers; | ||
} | ||
|
||
public function __sleep(): array | ||
{ | ||
// convert iterables to strings for serialization | ||
if (is_iterable($this->body)) { | ||
$this->body = $this->bodyToString(); | ||
} | ||
|
||
$this->_headers = $this->getHeaders(); | ||
|
||
return ['_headers', 'body', 'type', 'subtype', 'parameters']; | ||
} | ||
|
||
public function __wakeup(): void | ||
{ | ||
$r = new \ReflectionProperty(AbstractPart::class, 'headers'); | ||
$r->setAccessible(true); | ||
$r->setValue($this, $this->_headers); | ||
unset($this->_headers); | ||
} | ||
} |
Oops, something went wrong.