Skip to content

Commit

Permalink
feature #30981 [Mime] S/MIME Support (sstok)
Browse files Browse the repository at this point in the history
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
fabpot committed Jun 29, 2019
2 parents ca566a5 + 6e70d12 commit 835f6b0
Show file tree
Hide file tree
Showing 23 changed files with 1,074 additions and 0 deletions.
111 changes: 111 additions & 0 deletions src/Symfony/Component/Mime/Crypto/SMime.php
@@ -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;
}
}
58 changes: 58 additions & 0 deletions src/Symfony/Component/Mime/Crypto/SMimeEncrypter.php
@@ -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);
}
}
69 changes: 69 additions & 0 deletions src/Symfony/Component/Mime/Crypto/SMimeSigner.php
@@ -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'));
}
}
118 changes: 118 additions & 0 deletions src/Symfony/Component/Mime/Part/SMimePart.php
@@ -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);
}
}

0 comments on commit 835f6b0

Please sign in to comment.