Skip to content

Commit 835f6b0

Browse files
committed
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
2 parents ca566a5 + 6e70d12 commit 835f6b0

23 files changed

+1074
-0
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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\RuntimeException;
15+
use Symfony\Component\Mime\Part\SMimePart;
16+
17+
/**
18+
* @author Sebastiaan Stok <s.stok@rollerscapes.net>
19+
*
20+
* @internal
21+
*/
22+
abstract class SMime
23+
{
24+
protected function normalizeFilePath(string $path): string
25+
{
26+
if (!file_exists($path)) {
27+
throw new RuntimeException(sprintf('File does not exist: %s.', $path));
28+
}
29+
30+
return 'file://'.str_replace('\\', '/', realpath($path));
31+
}
32+
33+
protected function iteratorToFile(iterable $iterator, $stream): void
34+
{
35+
foreach ($iterator as $chunk) {
36+
fwrite($stream, $chunk);
37+
}
38+
}
39+
40+
protected function convertMessageToSMimePart($stream, string $type, string $subtype): SMimePart
41+
{
42+
rewind($stream);
43+
44+
$headers = '';
45+
46+
while (!feof($stream)) {
47+
$buffer = fread($stream, 78);
48+
$headers .= $buffer;
49+
50+
// Detect ending of header list
51+
if (preg_match('/(\r\n\r\n|\n\n)/', $headers, $match)) {
52+
$headersPosEnd = strpos($headers, $headerBodySeparator = $match[0]);
53+
54+
break;
55+
}
56+
}
57+
58+
$headers = $this->getMessageHeaders(trim(substr($headers, 0, $headersPosEnd)));
59+
60+
fseek($stream, $headersPosEnd + \strlen($headerBodySeparator));
61+
62+
return new SMimePart($this->getStreamIterator($stream), $type, $subtype, $this->getParametersFromHeader($headers['content-type']));
63+
}
64+
65+
protected function getStreamIterator($stream): iterable
66+
{
67+
while (!feof($stream)) {
68+
yield fread($stream, 16372);
69+
}
70+
}
71+
72+
private function getMessageHeaders(string $headerData): array
73+
{
74+
$headers = [];
75+
$headerLines = explode("\r\n", str_replace("\n", "\r\n", str_replace("\r\n", "\n", $headerData)));
76+
$currentHeaderName = '';
77+
78+
// Transform header lines into an associative array
79+
foreach ($headerLines as $headerLine) {
80+
// Empty lines between headers indicate a new mime-entity
81+
if ('' === $headerLine) {
82+
break;
83+
}
84+
85+
// Handle headers that span multiple lines
86+
if (false === strpos($headerLine, ':')) {
87+
$headers[$currentHeaderName] .= ' '.trim($headerLine);
88+
continue;
89+
}
90+
91+
$header = explode(':', $headerLine, 2);
92+
$currentHeaderName = strtolower($header[0]);
93+
$headers[$currentHeaderName] = trim($header[1]);
94+
}
95+
96+
return $headers;
97+
}
98+
99+
private function getParametersFromHeader(string $header): array
100+
{
101+
$params = [];
102+
103+
preg_match_all('/(?P<name>[a-z-0-9]+)=(?P<value>"[^"]+"|(?:[^\s;]+|$))(?:\s+;)?/i', $header, $matches);
104+
105+
foreach ($matches['value'] as $pos => $paramValue) {
106+
$params[$matches['name'][$pos]] = trim($paramValue, '"');
107+
}
108+
109+
return $params;
110+
}
111+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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\RuntimeException;
15+
use Symfony\Component\Mime\Message;
16+
17+
/**
18+
* @author Sebastiaan Stok <s.stok@rollerscapes.net>
19+
*/
20+
final class SMimeEncrypter extends SMime
21+
{
22+
private $certs;
23+
private $cipher;
24+
25+
/**
26+
* @param string|string[] $certificate Either a lone X.509 certificate, or an array of X.509 certificates
27+
*/
28+
public function __construct($certificate, int $cipher = OPENSSL_CIPHER_AES_256_CBC)
29+
{
30+
if (\is_array($certificate)) {
31+
$this->certs = array_map([$this, 'normalizeFilePath'], $certificate);
32+
} else {
33+
$this->certs = $this->normalizeFilePath($certificate);
34+
}
35+
36+
$this->cipher = $cipher;
37+
}
38+
39+
public function encrypt(Message $message): Message
40+
{
41+
$bufferFile = tmpfile();
42+
$outputFile = tmpfile();
43+
44+
$this->iteratorToFile($message->toIterable(), $bufferFile);
45+
46+
if (!@openssl_pkcs7_encrypt(stream_get_meta_data($bufferFile)['uri'], stream_get_meta_data($outputFile)['uri'], $this->certs, [], 0, $this->cipher)) {
47+
throw new RuntimeException(sprintf('Failed to encrypt S/Mime message. Error: "%s".', openssl_error_string()));
48+
}
49+
50+
$mimePart = $this->convertMessageToSMimePart($outputFile, 'application', 'pkcs7-mime');
51+
$mimePart->getHeaders()
52+
->addTextHeader('Content-Transfer-Encoding', 'base64')
53+
->addParameterizedHeader('Content-Disposition', 'attachment', ['name' => 'smime.p7m'])
54+
;
55+
56+
return new Message($message->getHeaders(), $mimePart);
57+
}
58+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\RuntimeException;
15+
use Symfony\Component\Mime\Message;
16+
17+
/**
18+
* @author Sebastiaan Stok <s.stok@rollerscapes.net>
19+
*/
20+
final class SMimeSigner extends SMime
21+
{
22+
private $signCertificate;
23+
private $signPrivateKey;
24+
private $signOptions;
25+
private $extraCerts;
26+
27+
/**
28+
* @var string|null
29+
*/
30+
private $privateKeyPassphrase;
31+
32+
/**
33+
* @see https://secure.php.net/manual/en/openssl.pkcs7.flags.php
34+
*
35+
* @param string $certificate
36+
* @param string $privateKey A file containing the private key (in PEM format)
37+
* @param string|null $privateKeyPassphrase A passphrase of the private key (if any)
38+
* @param string $extraCerts A file containing intermediate certificates (in PEM format) needed by the signing certificate
39+
* @param int $signOptions Bitwise operator options for openssl_pkcs7_sign()
40+
*/
41+
public function __construct(string $certificate, string $privateKey, ?string $privateKeyPassphrase = null, ?string $extraCerts = null, int $signOptions = PKCS7_DETACHED)
42+
{
43+
$this->signCertificate = $this->normalizeFilePath($certificate);
44+
45+
if (null !== $privateKeyPassphrase) {
46+
$this->signPrivateKey = [$this->normalizeFilePath($privateKey), $privateKeyPassphrase];
47+
} else {
48+
$this->signPrivateKey = $this->normalizeFilePath($privateKey);
49+
}
50+
51+
$this->signOptions = $signOptions;
52+
$this->extraCerts = $extraCerts ? realpath($extraCerts) : null;
53+
$this->privateKeyPassphrase = $privateKeyPassphrase;
54+
}
55+
56+
public function sign(Message $message): Message
57+
{
58+
$bufferFile = tmpfile();
59+
$outputFile = tmpfile();
60+
61+
$this->iteratorToFile($message->getBody()->toIterable(), $bufferFile);
62+
63+
if (!@openssl_pkcs7_sign(stream_get_meta_data($bufferFile)['uri'], stream_get_meta_data($outputFile)['uri'], $this->signCertificate, $this->signPrivateKey, [], $this->signOptions, $this->extraCerts)) {
64+
throw new RuntimeException(sprintf('Failed to sign S/Mime message. Error: "%s".', openssl_error_string()));
65+
}
66+
67+
return new Message($message->getHeaders(), $this->convertMessageToSMimePart($outputFile, 'multipart', 'signed'));
68+
}
69+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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\Part;
13+
14+
use Symfony\Component\Mime\Header\Headers;
15+
16+
/**
17+
* @author Sebastiaan Stok <s.stok@rollerscapes.net>
18+
*
19+
* @experimental in 4.4
20+
*/
21+
class SMimePart extends AbstractPart
22+
{
23+
private $body;
24+
private $type;
25+
private $subtype;
26+
private $parameters;
27+
28+
/**
29+
* @param iterable|string $body
30+
*/
31+
public function __construct($body, string $type, string $subtype, array $parameters)
32+
{
33+
parent::__construct();
34+
35+
if (!\is_string($body) && !is_iterable($body)) {
36+
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)));
37+
}
38+
39+
$this->body = $body;
40+
$this->type = $type;
41+
$this->subtype = $subtype;
42+
$this->parameters = $parameters;
43+
}
44+
45+
public function getMediaType(): string
46+
{
47+
return $this->type;
48+
}
49+
50+
public function getMediaSubtype(): string
51+
{
52+
return $this->subtype;
53+
}
54+
55+
public function bodyToString(): string
56+
{
57+
if (\is_string($this->body)) {
58+
return $this->body;
59+
}
60+
61+
$body = '';
62+
foreach ($this->body as $chunk) {
63+
$body .= $chunk;
64+
}
65+
$this->body = $body;
66+
67+
return $body;
68+
}
69+
70+
public function bodyToIterable(): iterable
71+
{
72+
if (\is_string($this->body)) {
73+
yield $this->body;
74+
75+
return;
76+
}
77+
78+
$body = '';
79+
foreach ($this->body as $chunk) {
80+
$body .= $chunk;
81+
yield $chunk;
82+
}
83+
$this->body = $body;
84+
}
85+
86+
public function getPreparedHeaders(): Headers
87+
{
88+
$headers = clone parent::getHeaders();
89+
90+
$headers->setHeaderBody('Parameterized', 'Content-Type', $this->getMediaType().'/'.$this->getMediaSubtype());
91+
92+
foreach ($this->parameters as $name => $value) {
93+
$headers->setHeaderParameter('Content-Type', $name, $value);
94+
}
95+
96+
return $headers;
97+
}
98+
99+
public function __sleep(): array
100+
{
101+
// convert iterables to strings for serialization
102+
if (is_iterable($this->body)) {
103+
$this->body = $this->bodyToString();
104+
}
105+
106+
$this->_headers = $this->getHeaders();
107+
108+
return ['_headers', 'body', 'type', 'subtype', 'parameters'];
109+
}
110+
111+
public function __wakeup(): void
112+
{
113+
$r = new \ReflectionProperty(AbstractPart::class, 'headers');
114+
$r->setAccessible(true);
115+
$r->setValue($this, $this->_headers);
116+
unset($this->_headers);
117+
}
118+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy