Skip to content

Commit 6e70d12

Browse files
committed
[Mime] Added SMimeSigner and Encryptor
1 parent ca566a5 commit 6e70d12

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