From 1dd1b201fcfb6de20fc157ce10abca0521e51ee2 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Tue, 1 Dec 2020 17:57:24 +0100 Subject: [PATCH 01/14] Adding new Encryption component --- composer.json | 1 + .../DependencyInjection/Configuration.php | 14 ++ .../FrameworkExtension.php | 10 + .../Resources/config/encryption.php | 22 +++ .../DependencyInjection/ConfigurationTest.php | 4 + .../Component/Encryption/.gitattributes | 4 + src/Symfony/Component/Encryption/.gitignore | 3 + src/Symfony/Component/Encryption/CHANGELOG.md | 7 + .../Component/Encryption/Ciphertext.php | 183 ++++++++++++++++++ .../Encryption/EncryptionInterface.php | 155 +++++++++++++++ .../Exception/DecryptionException.php | 27 +++ .../Exception/EncryptionException.php | 27 +++ .../Exception/ExceptionInterface.php | 23 +++ .../Exception/InvalidKeyException.php | 27 +++ .../Exception/MalformedCipherException.php | 25 +++ ...SignatureVerificationRequiredException.php | 28 +++ .../UnableToVerifySignatureException.php | 25 +++ .../UnsupportedAlgorithmException.php | 25 +++ .../Component/Encryption/KeyInterface.php | 38 ++++ src/Symfony/Component/Encryption/LICENSE | 19 ++ src/Symfony/Component/Encryption/README.md | 20 ++ .../Encryption/Sodium/SodiumEncryption.php | 125 ++++++++++++ .../Component/Encryption/Sodium/SodiumKey.php | 165 ++++++++++++++++ .../Tests/AbstractEncryptionTest.php | 174 +++++++++++++++++ .../Tests/Sodium/SodiumEncryptionTest.php | 38 ++++ .../Encryption/Tests/Sodium/SodiumKeyTest.php | 30 +++ .../Component/Encryption/composer.json | 28 +++ .../Component/Encryption/phpunit.xml.dist | 30 +++ 28 files changed, 1277 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php create mode 100644 src/Symfony/Component/Encryption/.gitattributes create mode 100644 src/Symfony/Component/Encryption/.gitignore create mode 100644 src/Symfony/Component/Encryption/CHANGELOG.md create mode 100644 src/Symfony/Component/Encryption/Ciphertext.php create mode 100644 src/Symfony/Component/Encryption/EncryptionInterface.php create mode 100644 src/Symfony/Component/Encryption/Exception/DecryptionException.php create mode 100644 src/Symfony/Component/Encryption/Exception/EncryptionException.php create mode 100644 src/Symfony/Component/Encryption/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/Encryption/Exception/InvalidKeyException.php create mode 100644 src/Symfony/Component/Encryption/Exception/MalformedCipherException.php create mode 100644 src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php create mode 100644 src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php create mode 100644 src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php create mode 100644 src/Symfony/Component/Encryption/KeyInterface.php create mode 100644 src/Symfony/Component/Encryption/LICENSE create mode 100644 src/Symfony/Component/Encryption/README.md create mode 100644 src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php create mode 100644 src/Symfony/Component/Encryption/Sodium/SodiumKey.php create mode 100644 src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php create mode 100644 src/Symfony/Component/Encryption/Tests/Sodium/SodiumEncryptionTest.php create mode 100644 src/Symfony/Component/Encryption/Tests/Sodium/SodiumKeyTest.php create mode 100644 src/Symfony/Component/Encryption/composer.json create mode 100644 src/Symfony/Component/Encryption/phpunit.xml.dist diff --git a/composer.json b/composer.json index ee2dd7e68012..47f75add8cc8 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "symfony/doctrine-bridge": "self.version", "symfony/dom-crawler": "self.version", "symfony/dotenv": "self.version", + "symfony/encryption": "self.version", "symfony/error-handler": "self.version", "symfony/event-dispatcher": "self.version", "symfony/expression-language": "self.version", diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 68b6db95ee87..73b42cf2b108 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -24,6 +24,7 @@ use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\Encryption\EncryptionInterface; use Symfony\Component\Form\Form; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpFoundation\Cookie; @@ -149,6 +150,7 @@ public function getConfigTreeBuilder() $this->addNotifierSection($rootNode, $enableIfStandalone); $this->addRateLimiterSection($rootNode, $enableIfStandalone); $this->addUidSection($rootNode, $enableIfStandalone); + $this->addEncryptionSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -1975,4 +1977,16 @@ private function addUidSection(ArrayNodeDefinition $rootNode, callable $enableIf ->end() ; } + + private function addEncryptionSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone) + { + $rootNode + ->children() + ->arrayNode('encryption') + ->info('Encryption configuration') + ->{$enableIfStandalone('symfony/encryption', EncryptionInterface::class)}() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 8e5548639b5b..f9535386ed44 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -420,6 +420,7 @@ public function load(array $configs, ContainerBuilder $container) $this->registerAnnotationsConfiguration($config['annotations'], $container, $loader); $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); $this->registerSecretsConfiguration($config['secrets'], $container, $loader); + $this->registerEncryptionConfiguration($config['encryption'], $container, $loader); if ($this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists(\Symfony\Component\Serializer\Serializer::class)) { @@ -636,6 +637,15 @@ private function registerHttpCacheConfiguration(array $config, ContainerBuilder } } + private function registerEncryptionConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) + { + if (!$this->isConfigEnabled($container, $config)) { + return; + } + + $loader->load('encryption.php'); + } + private function registerEsiConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader) { if (!$this->isConfigEnabled($container, $config)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php new file mode 100644 index 000000000000..fdce9ee6e1f4 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Encryption\EncryptionInterface; +use Symfony\Component\Encryption\Sodium\SodiumEncryption; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('security.encryption.sodium', SodiumEncryption::class) + ->alias(EncryptionInterface::class, 'security.encryption.sodium') + ; +}; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index 8273beafdcfb..837842a701f1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -18,6 +18,7 @@ use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Encryption\EncryptionInterface; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; @@ -576,6 +577,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'name_based_uuid_version' => 5, 'time_based_uuid_version' => 6, ], + 'encryption' => [ + 'enabled' => !class_exists(FullStack::class) && interface_exists(EncryptionInterface::class), + ], ]; } } diff --git a/src/Symfony/Component/Encryption/.gitattributes b/src/Symfony/Component/Encryption/.gitattributes new file mode 100644 index 000000000000..07024e3e0157 --- /dev/null +++ b/src/Symfony/Component/Encryption/.gitattributes @@ -0,0 +1,4 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/Tests export-ignore diff --git a/src/Symfony/Component/Encryption/.gitignore b/src/Symfony/Component/Encryption/.gitignore new file mode 100644 index 000000000000..5414c2c655e7 --- /dev/null +++ b/src/Symfony/Component/Encryption/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/Encryption/CHANGELOG.md b/src/Symfony/Component/Encryption/CHANGELOG.md new file mode 100644 index 000000000000..35bb11a20844 --- /dev/null +++ b/src/Symfony/Component/Encryption/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +6.0 +--- + + * Introduced the component as experimental diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/Ciphertext.php new file mode 100644 index 000000000000..91fa9b0896c6 --- /dev/null +++ b/src/Symfony/Component/Encryption/Ciphertext.php @@ -0,0 +1,183 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption; + +use Symfony\Component\Encryption\Exception\DecryptionException; +use Symfony\Component\Encryption\Exception\MalformedCipherException; + +/** + * This class is responsible for the payload API. + * + * @author Tobias Nyholm + * + * @internal + */ +class Ciphertext +{ + /** + * @var string algorithm for the encryption + */ + private $algorithm; + + /** + * @var string + */ + private $version; + + /** + * @var string the encoded payload + */ + private $payload; + + /** + * @var string nonce for the algorithm + */ + private $nonce; + + /** + * @var array additional headers + */ + private $headers = []; + + private function __construct() + { + } + + /** + * @param array $headers with ascii keys and values + */ + public static function create(string $algorithm, string $ciphertext, string $nonce, array $headers = []): self + { + $model = new self(); + $model->algorithm = $algorithm; + $model->payload = $ciphertext; + $model->nonce = $nonce; + $model->headers = $headers; + + return $model; + } + + /** + * Take a string representation of the chiphertext and parse it into an object. + * + * @throws MalformedCipherException + */ + public static function parse(string $input): self + { + $parts = explode('.', $input); + if (!\is_array($parts) || 4 !== \count($parts)) { + throw new MalformedCipherException(); + } + + [$headersString, $payload, $nonce, $hashSignature] = $parts; + + $headersString = self::base64UrlDecode($headersString); + $payload = self::base64UrlDecode($payload); + $nonce = self::base64UrlDecode($nonce); + $hashSignature = self::base64UrlDecode($hashSignature); + + // Check if integrity hash is valid + $hash = hash('sha256', $headersString.$payload.$nonce); + if (!hash_equals($hash, $hashSignature)) { + throw new MalformedCipherException(); + } + + $headers = json_decode($headersString, true); + if (!\is_array($headers) || !\array_key_exists('alg', $headers) || !\array_key_exists('ver', $headers) || '1' !== $headers['ver']) { + throw new MalformedCipherException(); + } + + $model = new self(); + $model->algorithm = $headers['alg']; + unset($headers['alg']); + $model->version = $headers['ver']; + unset($headers['ver']); + $model->headers = $headers; + $model->nonce = $nonce; + $model->payload = $payload; + + return $model; + } + + /** + * @return string + */ + public function __toString() + { + return $this->getString(); + } + + public function getString(): string + { + $headers = $this->headers; + $headers['alg'] = $this->algorithm; + $headers['ver'] = (isset($headers['ver']) && '' !== $headers['ver']) ? $headers['ver'] : '1'; + $headers = json_encode($headers); + + return sprintf('%s.%s.%s.%s', + self::base64UrlEncode($headers), + self::base64UrlEncode($this->payload), + self::base64UrlEncode($this->nonce), + self::base64UrlEncode(hash('sha256', $headers.$this->payload.$this->nonce)) + ); + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getPayload(): string + { + return $this->payload; + } + + public function getNonce(): string + { + return $this->nonce; + } + + public function hasHeader(string $name): bool + { + return \array_key_exists($name, $this->headers); + } + + public function getHeader(string $name): string + { + if ($this->hasHeader($name)) { + return $this->headers[$name]; + } + + throw new DecryptionException(sprintf('The expected header "%s" is not found.', $name)); + } + + private static function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private static function base64UrlDecode(string $data): string + { + $decodedContent = base64_decode(strtr($data, '-_', '+/'), true); + + if (!\is_string($decodedContent)) { + throw new MalformedCipherException('Could not base64 decode the content.'); + } + + return $decodedContent; + } +} diff --git a/src/Symfony/Component/Encryption/EncryptionInterface.php b/src/Symfony/Component/Encryption/EncryptionInterface.php new file mode 100644 index 000000000000..f8ec8cd6ff49 --- /dev/null +++ b/src/Symfony/Component/Encryption/EncryptionInterface.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption; + +use Symfony\Component\Encryption\Exception\DecryptionException; +use Symfony\Component\Encryption\Exception\EncryptionException; +use Symfony\Component\Encryption\Exception\InvalidKeyException; + +/** + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +interface EncryptionInterface +{ + /** + * Generates new a key to be used with encryption. + * + * Don't lose your private key and make sure to keep it a secret. + * + * @param string|null $secret A secret to be used in symmetric encryption. A + * new secret is generated if none is provided. + * + * @throws EncryptionException + */ + public function generateKey(string $secret = null): KeyInterface; + + /** + * Gets an encrypted version of the message. + * + * Symmetric encryption uses the same key to encrypt and decrypt a message. + * The key should be kept safe and should not be exposed to the public. Symmetric + * encryption should be used when you are sending the encrypted message to + * yourself. + * + * Example: You store a value on disk or in a cookie and don't want anyone else + * to read it. + * + * Symmetric encryption is in theory weaker than asymmetric encryption. + * + * + * $key = $encryption->generateKey(); + * $ciphertext = $encryption->encrypt('input', $key); + * $message = $encryption->decrypt($ciphertext, $key); + * + * + * @param string $message Plain text version of the message + * @param KeyInterface $key A key that holds a string secret + * + * @return string Output formatted by Ciphertext + * + * @throws EncryptionException + * @throws InvalidKeyException + */ + public function encrypt(string $message, KeyInterface $key): string; + + /** + * Gets an encrypted version of the message that only the recipient can read. + * + * Asymmetric encryption uses a "key pair" ie a public key and a private key. + * It is safe to share your public key, but the private key should always be + * kept a secret. + * + * When Alice and Bob wants to communicate securely, they share their public keys with + * each other. Alice will encrypt a message with Bob's public key. When Bob + * receives the message, he will decrypt it with his private key. + * + * + * + * // Bob: + * $bobKey = $encryption->generateKey(); + * $bobPublicOnly = $bobKey->extractPublicKey(); + * // Bob sends $bobPublicOnly to Alice + * + * // Alice: + * $ciphertext = $encryption->encryptFor('input', $bobPublicOnly); + * // Alice sends $ciphertext to Bob + * + * // Bob: + * $message = $encryption->decrypt($ciphertext, $bobKey); + * + * + * @param string $message Plain text version of the message + * @param KeyInterface $recipientKey Key with a public key of the recipient + * + * @return string Output formatted by Ciphertext + * + * @throws EncryptionException + * @throws InvalidKeyException + */ + public function encryptFor(string $message, KeyInterface $recipientKey): string; + + /** + * Gets an encrypted version of the message that only the recipient can read. + * The recipient can also verify who sent the message. + * + * Asymmetric encryption uses a "key pair" i.e. a public key and a private key. + * It is safe to share your public key, but the private key should always be + * kept secret. + * + * When Alice and Bob wants to communicate securely, they share their public keys with + * each other. Alice will encrypt a message with keypair [ alice_private, bob_public ]. + * When Bob receives the message, he will decrypt it with keypair [ bob_private, alice_public ]. + * + * + * // Alice: + * $aliceKey = $encryption->generateKey(); + * $alicePublicOnly = $aliceKey->extractPublicKey(); + * // Alice sends $alicePublicOnly to Bob + * + * // Bob: + * $bobKey = $encryption->generateKey(); + * $bobPublicOnly = $bobKey->extractPublicKey(); + * // Bob sends $bobPublicOnly to Alice + * + * // Alice: + * $ciphertext = $encryption->encryptForAndSign('input', $bobPublicOnly, $aliceKey); + * // Alice sends $ciphertext to Bob + * + * // Bob: + * $message = $encryption->decrypt($ciphertext, $bobKey, $alicePublicOnly); + * + * + * @param string $message Plain text version of the message + * @param KeyInterface $recipientKey Public key of the recipient + * @param KeyInterface $senderKey Private key of the sender + * + * @return string Output formatted by Ciphertext + * + * @throws EncryptionException + * @throws InvalidKeyException + */ + public function encryptForAndSign(string $message, KeyInterface $recipientKey, KeyInterface $senderKey): string; + + /** + * Gets a plain text version of the encrypted message. + * + * @param string $message Encrypted version of the message + * @param KeyInterface $key Key of the recipient, it should contain a private key + * @param KeyInterface|null $senderPublicKey A public key to the sender to verify the signature + * + * @throws DecryptionException + * @throws InvalidKeyException + */ + public function decrypt(string $message, KeyInterface $key, KeyInterface $senderPublicKey = null): string; +} diff --git a/src/Symfony/Component/Encryption/Exception/DecryptionException.php b/src/Symfony/Component/Encryption/Exception/DecryptionException.php new file mode 100644 index 000000000000..fecc2d93e6ae --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/DecryptionException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * Thrown when a message cannot be decrypted. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class DecryptionException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(string $message = null, \Throwable $previous = null) + { + parent::__construct($message ?? 'Could not decrypt the ciphertext.', 0, $previous); + } +} diff --git a/src/Symfony/Component/Encryption/Exception/EncryptionException.php b/src/Symfony/Component/Encryption/Exception/EncryptionException.php new file mode 100644 index 000000000000..ff592216c448 --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/EncryptionException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * Thrown when a message cannot be encrypted. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class EncryptionException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(string $message = null, \Throwable $previous = null) + { + parent::__construct($message ?? 'Could not encrypt the message.', 0, $previous); + } +} diff --git a/src/Symfony/Component/Encryption/Exception/ExceptionInterface.php b/src/Symfony/Component/Encryption/Exception/ExceptionInterface.php new file mode 100644 index 000000000000..cb9e12a01119 --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * Base ExceptionInterface for the Encryption Component. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php b/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php new file mode 100644 index 000000000000..3d94efb3ff33 --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * Thrown when a message cannot be encrypted. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class InvalidKeyException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(string $message = null, \Throwable $previous = null) + { + parent::__construct($message ?? 'This key is not valid.', 0, $previous); + } +} diff --git a/src/Symfony/Component/Encryption/Exception/MalformedCipherException.php b/src/Symfony/Component/Encryption/Exception/MalformedCipherException.php new file mode 100644 index 000000000000..f769988da8ae --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/MalformedCipherException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class MalformedCipherException extends DecryptionException +{ + public function __construct(string $message = null, \Throwable $previous = null) + { + parent::__construct($message ?? 'The message you provided is not a valid ciphertext.', $previous); + } +} diff --git a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php new file mode 100644 index 000000000000..8fe8412b5f8e --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * The sender requires you to verify the signature. You should pass both your + * private key and the senders public key to the decrypt() method. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class SignatureVerificationRequiredException extends DecryptionException +{ + public function __construct(\Throwable $previous = null) + { + parent::__construct('The sender requires you to verify the signature.'); + } +} diff --git a/src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php b/src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php new file mode 100644 index 000000000000..2936f4a478db --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class UnableToVerifySignatureException extends DecryptionException +{ + public function __construct(\Throwable $previous = null) + { + parent::__construct('The origin of the message could not be verified.', $previous); + } +} diff --git a/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php b/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php new file mode 100644 index 000000000000..fac1ec79b8a9 --- /dev/null +++ b/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Exception; + +/** + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +class UnsupportedAlgorithmException extends DecryptionException +{ + public function __construct(string $algorithm, \Throwable $previous = null) + { + parent::__construct(sprintf('The ciphertext is encrypted with "%s" algorithm. Decryption of that algorithm is not supported.', $algorithm)); + } +} diff --git a/src/Symfony/Component/Encryption/KeyInterface.php b/src/Symfony/Component/Encryption/KeyInterface.php new file mode 100644 index 000000000000..5f9a32e5b6cd --- /dev/null +++ b/src/Symfony/Component/Encryption/KeyInterface.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption; + +use Symfony\Component\Encryption\Exception\InvalidKeyException; + +/** + * A key for a specific user and specific Encryption implementation. Keys cannot + * be shared between Encryption implementations. + * + * A key is always serializable. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +interface KeyInterface +{ + /** + * Creates a new KeyInterface object. + * + * When Alice wants share her public key with Bob, she sends him this object. + * + * The public key can be shared. + * + * @throws InvalidKeyException + */ + public function extractPublicKey(): self; +} diff --git a/src/Symfony/Component/Encryption/LICENSE b/src/Symfony/Component/Encryption/LICENSE new file mode 100644 index 000000000000..efb17f98e7dd --- /dev/null +++ b/src/Symfony/Component/Encryption/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Encryption/README.md b/src/Symfony/Component/Encryption/README.md new file mode 100644 index 000000000000..9cd880870335 --- /dev/null +++ b/src/Symfony/Component/Encryption/README.md @@ -0,0 +1,20 @@ +Encryption Component +==================== + +The Encryption Component is an opinionated, high level abstraction over encryption +libraries and PHP's Sodium extension. Encryption should be understandable without +an degree in Cryptography. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/encryption.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php new file mode 100644 index 000000000000..009f60761dc4 --- /dev/null +++ b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Sodium; + +use Symfony\Component\Encryption\Ciphertext; +use Symfony\Component\Encryption\EncryptionInterface; +use Symfony\Component\Encryption\Exception\DecryptionException; +use Symfony\Component\Encryption\Exception\EncryptionException; +use Symfony\Component\Encryption\Exception\InvalidKeyException; +use Symfony\Component\Encryption\Exception\SignatureVerificationRequiredException; +use Symfony\Component\Encryption\Exception\UnableToVerifySignatureException; +use Symfony\Component\Encryption\Exception\UnsupportedAlgorithmException; +use Symfony\Component\Encryption\KeyInterface; + +/** + * Using the Sodium extension to safely encrypt your data. + * + * @author Tobias Nyholm + * + * @experimental in 6.0 + */ +final class SodiumEncryption implements EncryptionInterface +{ + public function generateKey(string $secret = null): KeyInterface + { + return SodiumKey::create($secret ?? sodium_crypto_secretbox_keygen(), sodium_crypto_box_keypair()); + } + + public function encrypt(string $message, KeyInterface $key): string + { + if (!$key instanceof SodiumKey) { + throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); + } + + $nonce = random_bytes(\SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + try { + $ciphertext = sodium_crypto_secretbox($message, $nonce, $key->getSecret()); + } catch (\SodiumException $exception) { + throw new EncryptionException('Failed to encrypt message.', $exception); + } + + return Ciphertext::create('sodium_secretbox', $ciphertext, $nonce)->getString(); + } + + public function encryptFor(string $message, KeyInterface $recipientKey): string + { + if (!$recipientKey instanceof SodiumKey) { + throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); + } + + try { + $ciphertext = sodium_crypto_box_seal($message, $recipientKey->getPublicKey()); + } catch (\SodiumException $exception) { + throw new EncryptionException('Failed to encrypt message.', $exception); + } + + return Ciphertext::create('sodium_crypto_box_seal', $ciphertext, random_bytes(\SODIUM_CRYPTO_BOX_NONCEBYTES))->getString(); + } + + public function encryptForAndSign(string $message, KeyInterface $recipientKey, KeyInterface $senderKey): string + { + if (!$recipientKey instanceof SodiumKey || !$senderKey instanceof SodiumKey) { + throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); + } + + try { + $nonce = random_bytes(\SODIUM_CRYPTO_BOX_NONCEBYTES); + $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($senderKey->getPrivateKey(), $recipientKey->getPublicKey()); + $ciphertext = sodium_crypto_box($message, $nonce, $keypair); + } catch (\SodiumException $exception) { + throw new EncryptionException('Failed to encrypt message.', $exception); + } + + return Ciphertext::create('sodium_crypto_box', $ciphertext, $nonce)->getString(); + } + + public function decrypt(string $message, KeyInterface $key, KeyInterface $senderPublicKey = null): string + { + if (!$key instanceof SodiumKey) { + throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); + } + + $ciphertext = Ciphertext::parse($message); + $algorithm = $ciphertext->getAlgorithm(); + $payload = $ciphertext->getPayload(); + $nonce = $ciphertext->getNonce(); + + if (null !== $senderPublicKey && 'sodium_crypto_box' !== $algorithm) { + throw new UnableToVerifySignatureException(); + } + + try { + if ('sodium_crypto_box_seal' === $algorithm) { + $output = sodium_crypto_box_seal_open($payload, $key->getKeypair()); + } elseif ('sodium_crypto_box' === $algorithm) { + if (null === $senderPublicKey) { + throw new SignatureVerificationRequiredException(); + } + $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($key->getPrivateKey(), $senderPublicKey->getPublicKey()); + $output = sodium_crypto_box_open($payload, $nonce, $keypair); + } elseif ('sodium_secretbox' === $algorithm) { + $output = sodium_crypto_secretbox_open($payload, $nonce, $key->getSecret()); + } else { + throw new UnsupportedAlgorithmException($algorithm); + } + } catch (\SodiumException $exception) { + throw new DecryptionException(sprintf('Failed to decrypt message with algorithm "%s".', $algorithm), $exception); + } + + if (false === $output) { + throw new DecryptionException(); + } + + return $output; + } +} diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php new file mode 100644 index 000000000000..910107b03e9c --- /dev/null +++ b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Sodium; + +use Symfony\Component\Encryption\Exception\InvalidKeyException; +use Symfony\Component\Encryption\KeyInterface; + +/** + * @internal + * + * @author Tobias Nyholm + */ +final class SodiumKey implements KeyInterface +{ + /** + * @var string|null + */ + private $secret; + + /** + * @var string|null + */ + private $privateKey; + + /** + * @var string|null + */ + private $publicKey; + + /** + * A keypair can only be created from a public and private key. + * + * @var string|null + */ + private $keypair; + + public static function create(string $secret, string $keypair): self + { + $key = self::fromSecret($secret); + $key->keypair = $keypair; + $key->publicKey = sodium_crypto_box_publickey($keypair); + $key->privateKey = sodium_crypto_box_secretkey($keypair); + + return $key; + } + + /** + * The secret key length should be 32 bytes, but other sizes are accepted. + */ + public static function fromSecret(string $secret): self + { + $key = new self(); + if (\SODIUM_CRYPTO_SECRETBOX_KEYBYTES === \strlen($secret)) { + $key->secret = $secret; + } else { + // Trim the key to a good size + $key->secret = substr(sha1($secret), 0, \SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + } + + return $key; + } + + public static function fromPrivateKey(string $privateKey): self + { + $key = new self(); + $key->privateKey = $privateKey; + + return $key; + } + + public static function fromPrivateAndPublicKeys(string $privateKey, string $publicKey): self + { + $key = new self(); + $key->privateKey = $privateKey; + $key->publicKey = $publicKey; + + return $key; + } + + public static function fromPublicKey(string $publicKey): self + { + $key = new self(); + $key->publicKey = $publicKey; + + return $key; + } + + public static function fromKeypair(string $keypair): self + { + $key = new self(); + $key->keypair = $keypair; + $key->publicKey = sodium_crypto_box_publickey($keypair); + $key->privateKey = sodium_crypto_box_secretkey($keypair); + + return $key; + } + + public function extractPublicKey(): KeyInterface + { + return self::fromPublicKey($this->getPublicKey()); + } + + public function __serialize(): array + { + return [$this->secret, $this->privateKey, $this->publicKey, $this->keypair]; + } + + public function __unserialize(array $data): void + { + [$this->secret, $this->privateKey, $this->publicKey, $this->keypair] = $data; + } + + public function getSecret(): string + { + if (null === $this->secret) { + throw new InvalidKeyException('This key does not have a secret.'); + } + + return $this->secret; + } + + public function getPrivateKey(): string + { + if (null === $this->privateKey) { + throw new InvalidKeyException('This key does not have a private key.'); + } + + return $this->privateKey; + } + + public function getPublicKey(): string + { + if (null === $this->publicKey) { + throw new InvalidKeyException('This key does not have a public key.'); + } + + return $this->publicKey; + } + + public function getKeypair(): string + { + if (null === $this->keypair) { + if (null === $this->privateKey) { + throw new InvalidKeyException('This key does not have a keypair.'); + } + + if (null === $this->publicKey) { + $this->publicKey = sodium_crypto_box_publickey_from_secretkey($this->privateKey); + } + + $this->keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($this->privateKey, $this->publicKey); + } + + return $this->keypair; + } +} diff --git a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php new file mode 100644 index 000000000000..52686f803360 --- /dev/null +++ b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Encryption\Ciphertext; +use Symfony\Component\Encryption\EncryptionInterface; +use Symfony\Component\Encryption\Exception\DecryptionException; +use Symfony\Component\Encryption\Exception\MalformedCipherException; +use Symfony\Component\Encryption\Exception\SignatureVerificationRequiredException; +use Symfony\Component\Encryption\Exception\UnsupportedAlgorithmException; +use Symfony\Component\Encryption\KeyInterface; + +/** + * @author Tobias Nyholm + */ +abstract class AbstractEncryptionTest extends TestCase +{ + public function testGenerateKey() + { + $cipher = $this->getEncryption(); + $key = $cipher->generateKey('s3cr3t'); + + $message = 'input'; + $ciphertext = $cipher->encrypt($message, $key); + + $key2 = $cipher->generateKey('s3cr3t'); + $this->assertSame($message, $cipher->decrypt($ciphertext, $key2)); + } + + public function testEncrypt() + { + $cipher = $this->getEncryption(); + $key = $cipher->generateKey(); + + $ciphertext = $cipher->encrypt('', $key); + $this->assertNotEmpty($ciphertext); + $this->assertTrue(\strlen($ciphertext) > 10); + $this->assertNotEquals('input', $cipher->encrypt('input', $key)); + + $input = 'random_string'; + $key2 = $cipher->generateKey(); + $this->assertNotEquals($cipher->encrypt($input, $key), $cipher->encrypt($input, $key2)); + } + + public function testDecryptSymmetric() + { + $cipher = $this->getEncryption(); + $key = $cipher->generateKey(); + + $this->assertSame($input = '', $cipher->decrypt($cipher->encrypt($input, $key), $key)); + $this->assertSame($input = 'foobar', $cipher->decrypt($cipher->encrypt($input, $key), $key)); + } + + public function testDecryptionThrowsOnMalformedCipher() + { + $cipher = $this->getEncryption(); + $key = $cipher->generateKey(); + $this->expectException(MalformedCipherException::class); + $cipher->decrypt('foo', $key); + } + + public function testDecryptionThrowsOnUnsupportedAlgorithm() + { + $cipher = $this->getEncryption(); + $key = $cipher->generateKey(); + + $this->expectException(UnsupportedAlgorithmException::class); + $cipher->decrypt(Ciphertext::create('foo', 'bar', 'baz')->getString(), $key); + } + + public function testEncryptFor() + { + $cipher = $this->getEncryption(); + $bobKey = $cipher->generateKey(); + $bobPublic = $bobKey->extractPublicKey(); + + $ciphertext = $cipher->encryptFor('', $bobPublic); + $this->assertNotEmpty($ciphertext); + $this->assertTrue(\strlen($ciphertext) > 10); + $this->assertNotEquals('input', $cipher->encryptFor('input', $bobPublic)); + + $message = 'the cake is a lie'; + $ciphertext = $cipher->encryptFor($message, $bobPublic); + $this->assertSame($message, $cipher->decrypt($ciphertext, $bobKey)); + $this->assertSame($message, $cipher->decrypt($ciphertext, $this->createPrivateKey($bobKey))); + } + + public function testEncryptForAndSign() + { + $cipher = $this->getEncryption(); + $aliceKey = $cipher->generateKey(); + $bobKey = $cipher->generateKey(); + + $ciphertext = $cipher->encryptForAndSign('', $bobKey->extractPublicKey(), $aliceKey); + $this->assertNotEmpty($ciphertext); + $this->assertTrue(\strlen($ciphertext) > 10); + + $message = 'the cake is a lie'; + $ciphertext = $cipher->encryptForAndSign($message, $bobKey->extractPublicKey(), $aliceKey); + $this->assertSame($message, $cipher->decrypt($ciphertext, $bobKey, $aliceKey->extractPublicKey())); + } + + /** + * Bob wants to be sure that Alice sent the message, but Alice never signed it. + */ + public function testDecryptUnableToVerifySender() + { + $cipher = $this->getEncryption(); + $aliceKey = $cipher->generateKey(); + $bobKey = $cipher->generateKey(); + + $ciphertext = $cipher->encryptFor($input = 'input', $bobKey->extractPublicKey()); + $this->expectException(DecryptionException::class); + $this->assertSame($input, $cipher->decrypt($ciphertext, $bobKey, $aliceKey->extractPublicKey())); + } + + /** + * Alice signs the message but Bob never verifies it. + */ + public function testDecryptIgnoreToVerifySender() + { + $cipher = $this->getEncryption(); + $aliceKey = $cipher->generateKey(); + $bobKey = $cipher->generateKey(); + + $ciphertext = $cipher->encryptForAndSign($input = 'input', $bobKey->extractPublicKey(), $aliceKey); + $this->expectException(SignatureVerificationRequiredException::class); + $this->assertSame($input, $cipher->decrypt($ciphertext, $this->createPrivateKey($bobKey))); + } + + /** + * Bob receives a message he thinks is from Alice, but it was sent by Eve. + */ + public function testDecryptThrowsExceptionOnWrongPublicKey() + { + $cipher = $this->getEncryption(); + $aliceKey = $cipher->generateKey(); + $bobKey = $cipher->generateKey(); + $eveKey = $cipher->generateKey(); + + $ciphertext = $cipher->encryptForAndSign('input', $bobKey, $eveKey); + $this->expectException(DecryptionException::class); + $cipher->decrypt($ciphertext, $bobKey, $aliceKey->extractPublicKey()); + } + + /** + * Alice sends a message to Bob, but Eve is trying to read it. + */ + public function testDecryptThrowsExceptionOnWrongPrivateKey() + { + $cipher = $this->getEncryption(); + $aliceKey = $cipher->generateKey(); + $bobKey = $cipher->generateKey(); + $eveKey = $cipher->generateKey(); + + $ciphertext = $cipher->encryptForAndSign('input', $bobKey, $aliceKey); + $this->expectException(DecryptionException::class); + $cipher->decrypt($ciphertext, $eveKey, $aliceKey->extractPublicKey()); + } + + abstract protected function getEncryption(): EncryptionInterface; + + abstract protected function createPrivateKey(KeyInterface $key): KeyInterface; +} diff --git a/src/Symfony/Component/Encryption/Tests/Sodium/SodiumEncryptionTest.php b/src/Symfony/Component/Encryption/Tests/Sodium/SodiumEncryptionTest.php new file mode 100644 index 000000000000..aa3b17532350 --- /dev/null +++ b/src/Symfony/Component/Encryption/Tests/Sodium/SodiumEncryptionTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Tests\Encryption\Sodium; + +use Symfony\Component\Encryption\EncryptionInterface; +use Symfony\Component\Encryption\KeyInterface; +use Symfony\Component\Encryption\Sodium\SodiumEncryption; +use Symfony\Component\Encryption\Sodium\SodiumKey; +use Symfony\Component\Encryption\Tests\AbstractEncryptionTest; + +/** + * @author Tobias Nyholm + */ +class SodiumEncryptionTest extends AbstractEncryptionTest +{ + protected function getEncryption(): EncryptionInterface + { + if (!\function_exists('sodium_crypto_box_keypair')) { + $this->markTestSkipped('Sodium extension is not installed and enabled.'); + } + + return new SodiumEncryption(); + } + + protected function createPrivateKey(KeyInterface $key): KeyInterface + { + return SodiumKey::fromPrivateKey($key->getPrivateKey()); + } +} diff --git a/src/Symfony/Component/Encryption/Tests/Sodium/SodiumKeyTest.php b/src/Symfony/Component/Encryption/Tests/Sodium/SodiumKeyTest.php new file mode 100644 index 000000000000..8ba2174d93b0 --- /dev/null +++ b/src/Symfony/Component/Encryption/Tests/Sodium/SodiumKeyTest.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Encryption\Tests\Encryption\Sodium; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Encryption\Sodium\SodiumKey; + +class SodiumKeyTest extends TestCase +{ + public function testKeySize() + { + // Secret is longer than \SODIUM_CRYPTO_SECRETBOX_KEYBYTES + $secret = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + $keyA = SodiumKey::fromSecret($secret.'v1'); + $keyB = SodiumKey::fromSecret($secret.'v2'); + + $this->assertNotEquals($keyA->getSecret(), $keyB->getSecret()); + $this->assertTrue(\SODIUM_CRYPTO_SECRETBOX_KEYBYTES === \strlen($keyA->getSecret())); + $this->assertTrue(\SODIUM_CRYPTO_SECRETBOX_KEYBYTES === \strlen($keyB->getSecret())); + } +} diff --git a/src/Symfony/Component/Encryption/composer.json b/src/Symfony/Component/Encryption/composer.json new file mode 100644 index 000000000000..adf03e094339 --- /dev/null +++ b/src/Symfony/Component/Encryption/composer.json @@ -0,0 +1,28 @@ +{ + "name": "symfony/encryption", + "type": "library", + "description": "Symfony Encryption Component", + "keywords": ["encryption"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.0.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Encryption\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Encryption/phpunit.xml.dist b/src/Symfony/Component/Encryption/phpunit.xml.dist new file mode 100644 index 000000000000..31ba66834e2a --- /dev/null +++ b/src/Symfony/Component/Encryption/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + From 2348d7402934829fb06d6d1a9b50e1f011fa6b10 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 12 Jul 2021 16:37:33 +0200 Subject: [PATCH 02/14] Apply suggestions from code review Co-authored-by: Robin Chalas --- src/Symfony/Component/Encryption/Ciphertext.php | 9 +++------ .../Component/Encryption/EncryptionInterface.php | 10 +++++----- .../SignatureVerificationRequiredException.php | 2 +- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/Ciphertext.php index 91fa9b0896c6..60a54184f6c2 100644 --- a/src/Symfony/Component/Encryption/Ciphertext.php +++ b/src/Symfony/Component/Encryption/Ciphertext.php @@ -21,7 +21,7 @@ * * @internal */ -class Ciphertext +class Ciphertext implements \Stringable { /** * @var string algorithm for the encryption @@ -67,7 +67,7 @@ public static function create(string $algorithm, string $ciphertext, string $non } /** - * Take a string representation of the chiphertext and parse it into an object. + * Take a string representation of the ciphertext and parse it into an object. * * @throws MalformedCipherException */ @@ -108,10 +108,7 @@ public static function parse(string $input): self return $model; } - /** - * @return string - */ - public function __toString() + public function __toString(): string { return $this->getString(); } diff --git a/src/Symfony/Component/Encryption/EncryptionInterface.php b/src/Symfony/Component/Encryption/EncryptionInterface.php index f8ec8cd6ff49..f787e05f2828 100644 --- a/src/Symfony/Component/Encryption/EncryptionInterface.php +++ b/src/Symfony/Component/Encryption/EncryptionInterface.php @@ -23,7 +23,7 @@ interface EncryptionInterface { /** - * Generates new a key to be used with encryption. + * Generates a new key to be used for encryption. * * Don't lose your private key and make sure to keep it a secret. * @@ -66,11 +66,11 @@ public function encrypt(string $message, KeyInterface $key): string; /** * Gets an encrypted version of the message that only the recipient can read. * - * Asymmetric encryption uses a "key pair" ie a public key and a private key. - * It is safe to share your public key, but the private key should always be + * Asymmetric encryption uses a "key pair" i.e. a public key and a private key. + * It is safe to share the public key, but the private key should always be * kept a secret. * - * When Alice and Bob wants to communicate securely, they share their public keys with + * When Alice and Bob want to communicate securely, they share their public keys with * each other. Alice will encrypt a message with Bob's public key. When Bob * receives the message, he will decrypt it with his private key. * @@ -107,7 +107,7 @@ public function encryptFor(string $message, KeyInterface $recipientKey): string; * It is safe to share your public key, but the private key should always be * kept secret. * - * When Alice and Bob wants to communicate securely, they share their public keys with + * When Alice and Bob want to communicate securely, they share their public keys with * each other. Alice will encrypt a message with keypair [ alice_private, bob_public ]. * When Bob receives the message, he will decrypt it with keypair [ bob_private, alice_public ]. * diff --git a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php index 8fe8412b5f8e..088cd0103e26 100644 --- a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php +++ b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php @@ -13,7 +13,7 @@ /** * The sender requires you to verify the signature. You should pass both your - * private key and the senders public key to the decrypt() method. + * private key and the sender's public key to the decrypt() method. * * @author Tobias Nyholm * From 2aa49389f584a5f059252954391ec1a86607366a Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 07:52:35 -0700 Subject: [PATCH 03/14] Fixed exceptions --- .../Exception/SignatureVerificationRequiredException.php | 4 +++- .../Encryption/Exception/UnsupportedAlgorithmException.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php index 088cd0103e26..c65a09bfe07e 100644 --- a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php +++ b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Encryption\Exception; +use Symfony\Component\Encryption\EncryptionInterface; + /** * The sender requires you to verify the signature. You should pass both your * private key and the sender's public key to the decrypt() method. @@ -23,6 +25,6 @@ class SignatureVerificationRequiredException extends DecryptionException { public function __construct(\Throwable $previous = null) { - parent::__construct('The sender requires you to verify the signature.'); + parent::__construct(sprintf('The sender requires you to verify the signature. Please pass the sender\'s public key to the %s::decrypt()', EncryptionInterface::class), $previous); } } diff --git a/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php b/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php index fac1ec79b8a9..69fa6502a1ff 100644 --- a/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php +++ b/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php @@ -20,6 +20,6 @@ class UnsupportedAlgorithmException extends DecryptionException { public function __construct(string $algorithm, \Throwable $previous = null) { - parent::__construct(sprintf('The ciphertext is encrypted with "%s" algorithm. Decryption of that algorithm is not supported.', $algorithm)); + parent::__construct(sprintf('The ciphertext is encrypted with "%s" algorithm. Decryption of that algorithm is not supported.', $algorithm), $previous); } } From 3fedf0ba2cef59fee56258f3b59072db470f300c Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 08:01:05 -0700 Subject: [PATCH 04/14] Use PHP8 syntax --- .../Component/Encryption/Ciphertext.php | 22 ++++++------------- .../Component/Encryption/Sodium/SodiumKey.php | 21 ++++-------------- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/Ciphertext.php index 60a54184f6c2..439640fd5e1f 100644 --- a/src/Symfony/Component/Encryption/Ciphertext.php +++ b/src/Symfony/Component/Encryption/Ciphertext.php @@ -24,29 +24,21 @@ class Ciphertext implements \Stringable { /** - * @var string algorithm for the encryption + * Algorithm used to encrypt the message */ - private $algorithm; + private string $algorithm; + private string $version; + private string $payload; /** - * @var string + * Nonce used with the algorithm */ - private $version; - - /** - * @var string the encoded payload - */ - private $payload; - - /** - * @var string nonce for the algorithm - */ - private $nonce; + private string $nonce; /** * @var array additional headers */ - private $headers = []; + private array $headers = []; private function __construct() { diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php index 910107b03e9c..11dc46424f96 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php @@ -21,27 +21,14 @@ */ final class SodiumKey implements KeyInterface { - /** - * @var string|null - */ - private $secret; - - /** - * @var string|null - */ - private $privateKey; - - /** - * @var string|null - */ - private $publicKey; + private ?string $secret; + private ?string $privateKey; + private ?string $publicKey; /** * A keypair can only be created from a public and private key. - * - * @var string|null */ - private $keypair; + private ?string $keypair; public static function create(string $secret, string $keypair): self { From 7a630699d41bd09c712649bf3968a2de25507e0e Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 08:05:19 -0700 Subject: [PATCH 05/14] Minor fixes --- src/Symfony/Component/Encryption/Ciphertext.php | 4 ++-- src/Symfony/Component/Encryption/Sodium/SodiumKey.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/Ciphertext.php index 439640fd5e1f..1f6c9e286f38 100644 --- a/src/Symfony/Component/Encryption/Ciphertext.php +++ b/src/Symfony/Component/Encryption/Ciphertext.php @@ -24,14 +24,14 @@ class Ciphertext implements \Stringable { /** - * Algorithm used to encrypt the message + * Algorithm used to encrypt the message. */ private string $algorithm; private string $version; private string $payload; /** - * Nonce used with the algorithm + * Nonce used with the algorithm. */ private string $nonce; diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php index 11dc46424f96..2b3d170f1977 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php @@ -21,14 +21,14 @@ */ final class SodiumKey implements KeyInterface { - private ?string $secret; - private ?string $privateKey; - private ?string $publicKey; + private ?string $secret = null; + private ?string $privateKey = null; + private ?string $publicKey = null; /** * A keypair can only be created from a public and private key. */ - private ?string $keypair; + private ?string $keypair = null; public static function create(string $secret, string $keypair): self { From 40006850a901a0a285366fa50775f862a3802d5a Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Mon, 12 Jul 2021 17:41:24 +0200 Subject: [PATCH 06/14] Apply suggestions from code review Co-authored-by: Alexander M. Turek --- .../Bundle/FrameworkBundle/Resources/config/encryption.php | 2 +- src/Symfony/Component/Encryption/Sodium/SodiumKey.php | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php index fdce9ee6e1f4..f8c012fd948b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php @@ -16,7 +16,7 @@ return static function (ContainerConfigurator $container) { $container->services() - ->set('security.encryption.sodium', SodiumEncryption::class) + ->set('encryption.sodium', SodiumEncryption::class) ->alias(EncryptionInterface::class, 'security.encryption.sodium') ; }; diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php index 2b3d170f1977..7535de20574b 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php @@ -108,11 +108,7 @@ public function __unserialize(array $data): void public function getSecret(): string { - if (null === $this->secret) { - throw new InvalidKeyException('This key does not have a secret.'); - } - - return $this->secret; + return $this->secret ?? throw new InvalidKeyException('This key does not have a secret.'); } public function getPrivateKey(): string From 4792b64d53937653c1e8551576872253f4b8afeb Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 08:42:47 -0700 Subject: [PATCH 07/14] minior --- .../Bundle/FrameworkBundle/Resources/config/encryption.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php index f8c012fd948b..1b7fd8425a8d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php @@ -17,6 +17,6 @@ return static function (ContainerConfigurator $container) { $container->services() ->set('encryption.sodium', SodiumEncryption::class) - ->alias(EncryptionInterface::class, 'security.encryption.sodium') + ->alias(EncryptionInterface::class, 'encryption.sodium') ; }; From 3c608b399510e02e2a7327eb0ac1b647bf148355 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 10:47:49 -0700 Subject: [PATCH 08/14] From suggestion by Alexander --- .../FrameworkBundle/Resources/config/encryption.php | 1 + .../Component/Encryption/Sodium/SodiumKey.php | 12 ++---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php index 1b7fd8425a8d..fb5751178f9c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php @@ -18,5 +18,6 @@ $container->services() ->set('encryption.sodium', SodiumEncryption::class) ->alias(EncryptionInterface::class, 'encryption.sodium') + ->alias('encryption', 'encryption.sodium') ; }; diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php index 7535de20574b..54f016698635 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumKey.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php @@ -113,20 +113,12 @@ public function getSecret(): string public function getPrivateKey(): string { - if (null === $this->privateKey) { - throw new InvalidKeyException('This key does not have a private key.'); - } - - return $this->privateKey; + return $this->privateKey ?? throw new InvalidKeyException('This key does not have a private key.'); } public function getPublicKey(): string { - if (null === $this->publicKey) { - throw new InvalidKeyException('This key does not have a public key.'); - } - - return $this->publicKey; + return $this->publicKey ?? throw new InvalidKeyException('This key does not have a public key.'); } public function getKeypair(): string From bc85b3477709e977da71ab5d9e508ac9c4d7a655 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 10:55:40 -0700 Subject: [PATCH 09/14] updated comment --- src/Symfony/Component/Encryption/Ciphertext.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/Ciphertext.php index 1f6c9e286f38..2c33ce590192 100644 --- a/src/Symfony/Component/Encryption/Ciphertext.php +++ b/src/Symfony/Component/Encryption/Ciphertext.php @@ -77,7 +77,7 @@ public static function parse(string $input): self $nonce = self::base64UrlDecode($nonce); $hashSignature = self::base64UrlDecode($hashSignature); - // Check if integrity hash is valid + // Check if data has been modified $hash = hash('sha256', $headersString.$payload.$nonce); if (!hash_equals($hash, $hashSignature)) { throw new MalformedCipherException(); From 3ac815669d81cd632509c8dc81d319ce6a2f783e Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 10:56:30 -0700 Subject: [PATCH 10/14] Readme update --- src/Symfony/Component/Encryption/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Encryption/README.md b/src/Symfony/Component/Encryption/README.md index 9cd880870335..379e1d6e0f3a 100644 --- a/src/Symfony/Component/Encryption/README.md +++ b/src/Symfony/Component/Encryption/README.md @@ -1,9 +1,8 @@ Encryption Component ==================== -The Encryption Component is an opinionated, high level abstraction over encryption -libraries and PHP's Sodium extension. Encryption should be understandable without -an degree in Cryptography. +The Encryption Component is an opinionated, high level abstraction over PHP's Sodium +extension. Encryption should be understandable without an degree in Cryptography. **This Component is experimental**. [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) From e49230e97e890210a0d9f0c60dc753a9d57705c3 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 11:12:29 -0700 Subject: [PATCH 11/14] Remove signing --- .../Encryption/EncryptionInterface.php | 45 +----------- ...SignatureVerificationRequiredException.php | 30 -------- .../UnableToVerifySignatureException.php | 25 ------- src/Symfony/Component/Encryption/README.md | 2 +- .../Encryption/Sodium/SodiumEncryption.php | 29 +------- .../Tests/AbstractEncryptionTest.php | 73 ------------------- 6 files changed, 3 insertions(+), 201 deletions(-) delete mode 100644 src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php delete mode 100644 src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php diff --git a/src/Symfony/Component/Encryption/EncryptionInterface.php b/src/Symfony/Component/Encryption/EncryptionInterface.php index f787e05f2828..279ce96258fa 100644 --- a/src/Symfony/Component/Encryption/EncryptionInterface.php +++ b/src/Symfony/Component/Encryption/EncryptionInterface.php @@ -99,57 +99,14 @@ public function encrypt(string $message, KeyInterface $key): string; */ public function encryptFor(string $message, KeyInterface $recipientKey): string; - /** - * Gets an encrypted version of the message that only the recipient can read. - * The recipient can also verify who sent the message. - * - * Asymmetric encryption uses a "key pair" i.e. a public key and a private key. - * It is safe to share your public key, but the private key should always be - * kept secret. - * - * When Alice and Bob want to communicate securely, they share their public keys with - * each other. Alice will encrypt a message with keypair [ alice_private, bob_public ]. - * When Bob receives the message, he will decrypt it with keypair [ bob_private, alice_public ]. - * - * - * // Alice: - * $aliceKey = $encryption->generateKey(); - * $alicePublicOnly = $aliceKey->extractPublicKey(); - * // Alice sends $alicePublicOnly to Bob - * - * // Bob: - * $bobKey = $encryption->generateKey(); - * $bobPublicOnly = $bobKey->extractPublicKey(); - * // Bob sends $bobPublicOnly to Alice - * - * // Alice: - * $ciphertext = $encryption->encryptForAndSign('input', $bobPublicOnly, $aliceKey); - * // Alice sends $ciphertext to Bob - * - * // Bob: - * $message = $encryption->decrypt($ciphertext, $bobKey, $alicePublicOnly); - * - * - * @param string $message Plain text version of the message - * @param KeyInterface $recipientKey Public key of the recipient - * @param KeyInterface $senderKey Private key of the sender - * - * @return string Output formatted by Ciphertext - * - * @throws EncryptionException - * @throws InvalidKeyException - */ - public function encryptForAndSign(string $message, KeyInterface $recipientKey, KeyInterface $senderKey): string; - /** * Gets a plain text version of the encrypted message. * * @param string $message Encrypted version of the message * @param KeyInterface $key Key of the recipient, it should contain a private key - * @param KeyInterface|null $senderPublicKey A public key to the sender to verify the signature * * @throws DecryptionException * @throws InvalidKeyException */ - public function decrypt(string $message, KeyInterface $key, KeyInterface $senderPublicKey = null): string; + public function decrypt(string $message, KeyInterface $key): string; } diff --git a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php b/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php deleted file mode 100644 index c65a09bfe07e..000000000000 --- a/src/Symfony/Component/Encryption/Exception/SignatureVerificationRequiredException.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Encryption\Exception; - -use Symfony\Component\Encryption\EncryptionInterface; - -/** - * The sender requires you to verify the signature. You should pass both your - * private key and the sender's public key to the decrypt() method. - * - * @author Tobias Nyholm - * - * @experimental in 6.0 - */ -class SignatureVerificationRequiredException extends DecryptionException -{ - public function __construct(\Throwable $previous = null) - { - parent::__construct(sprintf('The sender requires you to verify the signature. Please pass the sender\'s public key to the %s::decrypt()', EncryptionInterface::class), $previous); - } -} diff --git a/src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php b/src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php deleted file mode 100644 index 2936f4a478db..000000000000 --- a/src/Symfony/Component/Encryption/Exception/UnableToVerifySignatureException.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Encryption\Exception; - -/** - * @author Tobias Nyholm - * - * @experimental in 6.0 - */ -class UnableToVerifySignatureException extends DecryptionException -{ - public function __construct(\Throwable $previous = null) - { - parent::__construct('The origin of the message could not be verified.', $previous); - } -} diff --git a/src/Symfony/Component/Encryption/README.md b/src/Symfony/Component/Encryption/README.md index 379e1d6e0f3a..387ae258628a 100644 --- a/src/Symfony/Component/Encryption/README.md +++ b/src/Symfony/Component/Encryption/README.md @@ -2,7 +2,7 @@ Encryption Component ==================== The Encryption Component is an opinionated, high level abstraction over PHP's Sodium -extension. Encryption should be understandable without an degree in Cryptography. +extension. Encryption should be understandable without a degree in Cryptography. **This Component is experimental**. [Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php index 009f60761dc4..45ec8f97f93c 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php @@ -66,24 +66,7 @@ public function encryptFor(string $message, KeyInterface $recipientKey): string return Ciphertext::create('sodium_crypto_box_seal', $ciphertext, random_bytes(\SODIUM_CRYPTO_BOX_NONCEBYTES))->getString(); } - public function encryptForAndSign(string $message, KeyInterface $recipientKey, KeyInterface $senderKey): string - { - if (!$recipientKey instanceof SodiumKey || !$senderKey instanceof SodiumKey) { - throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); - } - - try { - $nonce = random_bytes(\SODIUM_CRYPTO_BOX_NONCEBYTES); - $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($senderKey->getPrivateKey(), $recipientKey->getPublicKey()); - $ciphertext = sodium_crypto_box($message, $nonce, $keypair); - } catch (\SodiumException $exception) { - throw new EncryptionException('Failed to encrypt message.', $exception); - } - - return Ciphertext::create('sodium_crypto_box', $ciphertext, $nonce)->getString(); - } - - public function decrypt(string $message, KeyInterface $key, KeyInterface $senderPublicKey = null): string + public function decrypt(string $message, KeyInterface $key): string { if (!$key instanceof SodiumKey) { throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); @@ -94,19 +77,9 @@ public function decrypt(string $message, KeyInterface $key, KeyInterface $sender $payload = $ciphertext->getPayload(); $nonce = $ciphertext->getNonce(); - if (null !== $senderPublicKey && 'sodium_crypto_box' !== $algorithm) { - throw new UnableToVerifySignatureException(); - } - try { if ('sodium_crypto_box_seal' === $algorithm) { $output = sodium_crypto_box_seal_open($payload, $key->getKeypair()); - } elseif ('sodium_crypto_box' === $algorithm) { - if (null === $senderPublicKey) { - throw new SignatureVerificationRequiredException(); - } - $keypair = sodium_crypto_box_keypair_from_secretkey_and_publickey($key->getPrivateKey(), $senderPublicKey->getPublicKey()); - $output = sodium_crypto_box_open($payload, $nonce, $keypair); } elseif ('sodium_secretbox' === $algorithm) { $output = sodium_crypto_secretbox_open($payload, $nonce, $key->getSecret()); } else { diff --git a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php index 52686f803360..cf89cae542b9 100644 --- a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php +++ b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php @@ -95,79 +95,6 @@ public function testEncryptFor() $this->assertSame($message, $cipher->decrypt($ciphertext, $this->createPrivateKey($bobKey))); } - public function testEncryptForAndSign() - { - $cipher = $this->getEncryption(); - $aliceKey = $cipher->generateKey(); - $bobKey = $cipher->generateKey(); - - $ciphertext = $cipher->encryptForAndSign('', $bobKey->extractPublicKey(), $aliceKey); - $this->assertNotEmpty($ciphertext); - $this->assertTrue(\strlen($ciphertext) > 10); - - $message = 'the cake is a lie'; - $ciphertext = $cipher->encryptForAndSign($message, $bobKey->extractPublicKey(), $aliceKey); - $this->assertSame($message, $cipher->decrypt($ciphertext, $bobKey, $aliceKey->extractPublicKey())); - } - - /** - * Bob wants to be sure that Alice sent the message, but Alice never signed it. - */ - public function testDecryptUnableToVerifySender() - { - $cipher = $this->getEncryption(); - $aliceKey = $cipher->generateKey(); - $bobKey = $cipher->generateKey(); - - $ciphertext = $cipher->encryptFor($input = 'input', $bobKey->extractPublicKey()); - $this->expectException(DecryptionException::class); - $this->assertSame($input, $cipher->decrypt($ciphertext, $bobKey, $aliceKey->extractPublicKey())); - } - - /** - * Alice signs the message but Bob never verifies it. - */ - public function testDecryptIgnoreToVerifySender() - { - $cipher = $this->getEncryption(); - $aliceKey = $cipher->generateKey(); - $bobKey = $cipher->generateKey(); - - $ciphertext = $cipher->encryptForAndSign($input = 'input', $bobKey->extractPublicKey(), $aliceKey); - $this->expectException(SignatureVerificationRequiredException::class); - $this->assertSame($input, $cipher->decrypt($ciphertext, $this->createPrivateKey($bobKey))); - } - - /** - * Bob receives a message he thinks is from Alice, but it was sent by Eve. - */ - public function testDecryptThrowsExceptionOnWrongPublicKey() - { - $cipher = $this->getEncryption(); - $aliceKey = $cipher->generateKey(); - $bobKey = $cipher->generateKey(); - $eveKey = $cipher->generateKey(); - - $ciphertext = $cipher->encryptForAndSign('input', $bobKey, $eveKey); - $this->expectException(DecryptionException::class); - $cipher->decrypt($ciphertext, $bobKey, $aliceKey->extractPublicKey()); - } - - /** - * Alice sends a message to Bob, but Eve is trying to read it. - */ - public function testDecryptThrowsExceptionOnWrongPrivateKey() - { - $cipher = $this->getEncryption(); - $aliceKey = $cipher->generateKey(); - $bobKey = $cipher->generateKey(); - $eveKey = $cipher->generateKey(); - - $ciphertext = $cipher->encryptForAndSign('input', $bobKey, $aliceKey); - $this->expectException(DecryptionException::class); - $cipher->decrypt($ciphertext, $eveKey, $aliceKey->extractPublicKey()); - } - abstract protected function getEncryption(): EncryptionInterface; abstract protected function createPrivateKey(KeyInterface $key): KeyInterface; From 045c27875fff58e444666099006d7c3e19663bd4 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 11:24:19 -0700 Subject: [PATCH 12/14] Move nonce to the cyphertext headers --- .../Component/Encryption/Ciphertext.php | 39 ++++++++----------- .../Encryption/Sodium/SodiumEncryption.php | 6 +-- .../Tests/AbstractEncryptionTest.php | 2 +- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/Ciphertext.php index 2c33ce590192..b7189d9fd37d 100644 --- a/src/Symfony/Component/Encryption/Ciphertext.php +++ b/src/Symfony/Component/Encryption/Ciphertext.php @@ -31,12 +31,7 @@ class Ciphertext implements \Stringable private string $payload; /** - * Nonce used with the algorithm. - */ - private string $nonce; - - /** - * @var array additional headers + * @var array */ private array $headers = []; @@ -45,14 +40,13 @@ private function __construct() } /** - * @param array $headers with ascii keys and values + * @param array $headers */ - public static function create(string $algorithm, string $ciphertext, string $nonce, array $headers = []): self + public static function create(string $algorithm, string $ciphertext, array $headers = []): self { $model = new self(); $model->algorithm = $algorithm; $model->payload = $ciphertext; - $model->nonce = $nonce; $model->headers = $headers; return $model; @@ -66,35 +60,37 @@ public static function create(string $algorithm, string $ciphertext, string $non public static function parse(string $input): self { $parts = explode('.', $input); - if (!\is_array($parts) || 4 !== \count($parts)) { + if (!\is_array($parts) || 3 !== \count($parts)) { throw new MalformedCipherException(); } - [$headersString, $payload, $nonce, $hashSignature] = $parts; + [$headersString, $payload, $hashSignature] = $parts; $headersString = self::base64UrlDecode($headersString); $payload = self::base64UrlDecode($payload); - $nonce = self::base64UrlDecode($nonce); $hashSignature = self::base64UrlDecode($hashSignature); // Check if data has been modified - $hash = hash('sha256', $headersString.$payload.$nonce); + $hash = hash('sha256', $headersString.$payload); if (!hash_equals($hash, $hashSignature)) { throw new MalformedCipherException(); } $headers = json_decode($headersString, true); - if (!\is_array($headers) || !\array_key_exists('alg', $headers) || !\array_key_exists('ver', $headers) || '1' !== $headers['ver']) { + if (!\is_array($headers) || !\array_key_exists('alg', $headers) || !\array_key_exists('ver', $headers)) { throw new MalformedCipherException(); } + foreach ($headers as $name => $value) { + $headers[$name] = self::base64UrlDecode($value); + } + $model = new self(); $model->algorithm = $headers['alg']; unset($headers['alg']); $model->version = $headers['ver']; unset($headers['ver']); $model->headers = $headers; - $model->nonce = $nonce; $model->payload = $payload; return $model; @@ -110,13 +106,15 @@ public function getString(): string $headers = $this->headers; $headers['alg'] = $this->algorithm; $headers['ver'] = (isset($headers['ver']) && '' !== $headers['ver']) ? $headers['ver'] : '1'; + foreach ($headers as $name => $value) { + $headers[$name] = self::base64UrlEncode($value); + } $headers = json_encode($headers); - return sprintf('%s.%s.%s.%s', + return sprintf('%s.%s.%s', self::base64UrlEncode($headers), self::base64UrlEncode($this->payload), - self::base64UrlEncode($this->nonce), - self::base64UrlEncode(hash('sha256', $headers.$this->payload.$this->nonce)) + self::base64UrlEncode(hash('sha256', $headers.$this->payload)) ); } @@ -135,11 +133,6 @@ public function getPayload(): string return $this->payload; } - public function getNonce(): string - { - return $this->nonce; - } - public function hasHeader(string $name): bool { return \array_key_exists($name, $this->headers); diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php index 45ec8f97f93c..a710a9017318 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php @@ -48,7 +48,7 @@ public function encrypt(string $message, KeyInterface $key): string throw new EncryptionException('Failed to encrypt message.', $exception); } - return Ciphertext::create('sodium_secretbox', $ciphertext, $nonce)->getString(); + return Ciphertext::create('sodium_secretbox', $ciphertext, ['nonce'=>$nonce])->getString(); } public function encryptFor(string $message, KeyInterface $recipientKey): string @@ -63,7 +63,7 @@ public function encryptFor(string $message, KeyInterface $recipientKey): string throw new EncryptionException('Failed to encrypt message.', $exception); } - return Ciphertext::create('sodium_crypto_box_seal', $ciphertext, random_bytes(\SODIUM_CRYPTO_BOX_NONCEBYTES))->getString(); + return Ciphertext::create('sodium_crypto_box_seal', $ciphertext)->getString(); } public function decrypt(string $message, KeyInterface $key): string @@ -75,12 +75,12 @@ public function decrypt(string $message, KeyInterface $key): string $ciphertext = Ciphertext::parse($message); $algorithm = $ciphertext->getAlgorithm(); $payload = $ciphertext->getPayload(); - $nonce = $ciphertext->getNonce(); try { if ('sodium_crypto_box_seal' === $algorithm) { $output = sodium_crypto_box_seal_open($payload, $key->getKeypair()); } elseif ('sodium_secretbox' === $algorithm) { + $nonce = $ciphertext->getHeader('nonce'); $output = sodium_crypto_secretbox_open($payload, $nonce, $key->getSecret()); } else { throw new UnsupportedAlgorithmException($algorithm); diff --git a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php index cf89cae542b9..0582651a9b9c 100644 --- a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php +++ b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php @@ -75,7 +75,7 @@ public function testDecryptionThrowsOnUnsupportedAlgorithm() $key = $cipher->generateKey(); $this->expectException(UnsupportedAlgorithmException::class); - $cipher->decrypt(Ciphertext::create('foo', 'bar', 'baz')->getString(), $key); + $cipher->decrypt(Ciphertext::create('foo', 'bar')->getString(), $key); } public function testEncryptFor() From 117c77b779e4872c2a60ad86ea438dddb8cdf9ff Mon Sep 17 00:00:00 2001 From: Nyholm Date: Mon, 12 Jul 2021 11:43:52 -0700 Subject: [PATCH 13/14] Rename cihpertext --- .../Encryption/EncryptionInterface.php | 10 +++---- .../Exception/InvalidKeyException.php | 2 +- .../Encryption/Sodium/SodiumEncryption.php | 20 ++++++------- ...hertext.php => SymfonyEncryptionToken.php} | 30 +++++++++---------- .../Tests/AbstractEncryptionTest.php | 6 ++-- 5 files changed, 31 insertions(+), 37 deletions(-) rename src/Symfony/Component/Encryption/{Ciphertext.php => SymfonyEncryptionToken.php} (82%) diff --git a/src/Symfony/Component/Encryption/EncryptionInterface.php b/src/Symfony/Component/Encryption/EncryptionInterface.php index 279ce96258fa..ee7ac6fcaef7 100644 --- a/src/Symfony/Component/Encryption/EncryptionInterface.php +++ b/src/Symfony/Component/Encryption/EncryptionInterface.php @@ -29,8 +29,6 @@ interface EncryptionInterface * * @param string|null $secret A secret to be used in symmetric encryption. A * new secret is generated if none is provided. - * - * @throws EncryptionException */ public function generateKey(string $secret = null): KeyInterface; @@ -56,7 +54,7 @@ public function generateKey(string $secret = null): KeyInterface; * @param string $message Plain text version of the message * @param KeyInterface $key A key that holds a string secret * - * @return string Output formatted by Ciphertext + * @return string formatted as a Symfony Encryption Token * * @throws EncryptionException * @throws InvalidKeyException @@ -92,7 +90,7 @@ public function encrypt(string $message, KeyInterface $key): string; * @param string $message Plain text version of the message * @param KeyInterface $recipientKey Key with a public key of the recipient * - * @return string Output formatted by Ciphertext + * @return string formatted as a Symfony Encryption Token * * @throws EncryptionException * @throws InvalidKeyException @@ -102,8 +100,8 @@ public function encryptFor(string $message, KeyInterface $recipientKey): string; /** * Gets a plain text version of the encrypted message. * - * @param string $message Encrypted version of the message - * @param KeyInterface $key Key of the recipient, it should contain a private key + * @param string $message formatted in the Symfony Encryption Token format + * @param KeyInterface $key Key of the recipient, it should contain a private key * * @throws DecryptionException * @throws InvalidKeyException diff --git a/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php b/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php index 3d94efb3ff33..f016136addbb 100644 --- a/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php +++ b/src/Symfony/Component/Encryption/Exception/InvalidKeyException.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Encryption\Exception; /** - * Thrown when a message cannot be encrypted. + * Thrown when there is an issue with the Key. * * @author Tobias Nyholm * diff --git a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php index a710a9017318..0af66a59678b 100644 --- a/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php +++ b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php @@ -11,15 +11,13 @@ namespace Symfony\Component\Encryption\Sodium; -use Symfony\Component\Encryption\Ciphertext; use Symfony\Component\Encryption\EncryptionInterface; use Symfony\Component\Encryption\Exception\DecryptionException; use Symfony\Component\Encryption\Exception\EncryptionException; use Symfony\Component\Encryption\Exception\InvalidKeyException; -use Symfony\Component\Encryption\Exception\SignatureVerificationRequiredException; -use Symfony\Component\Encryption\Exception\UnableToVerifySignatureException; use Symfony\Component\Encryption\Exception\UnsupportedAlgorithmException; use Symfony\Component\Encryption\KeyInterface; +use Symfony\Component\Encryption\SymfonyEncryptionToken; /** * Using the Sodium extension to safely encrypt your data. @@ -48,7 +46,7 @@ public function encrypt(string $message, KeyInterface $key): string throw new EncryptionException('Failed to encrypt message.', $exception); } - return Ciphertext::create('sodium_secretbox', $ciphertext, ['nonce'=>$nonce])->getString(); + return SymfonyEncryptionToken::create('sodium_secretbox', $ciphertext, ['nonce' => $nonce])->getString(); } public function encryptFor(string $message, KeyInterface $recipientKey): string @@ -63,7 +61,7 @@ public function encryptFor(string $message, KeyInterface $recipientKey): string throw new EncryptionException('Failed to encrypt message.', $exception); } - return Ciphertext::create('sodium_crypto_box_seal', $ciphertext)->getString(); + return SymfonyEncryptionToken::create('sodium_crypto_box_seal', $ciphertext)->getString(); } public function decrypt(string $message, KeyInterface $key): string @@ -72,16 +70,16 @@ public function decrypt(string $message, KeyInterface $key): string throw new InvalidKeyException(sprintf('Class "%s" will only accept key objects of class "%s".', self::class, SodiumKey::class)); } - $ciphertext = Ciphertext::parse($message); - $algorithm = $ciphertext->getAlgorithm(); - $payload = $ciphertext->getPayload(); + $encryptionToken = SymfonyEncryptionToken::parse($message); + $algorithm = $encryptionToken->getAlgorithm(); + $ciphertext = $encryptionToken->getCiphertext(); try { if ('sodium_crypto_box_seal' === $algorithm) { - $output = sodium_crypto_box_seal_open($payload, $key->getKeypair()); + $output = sodium_crypto_box_seal_open($ciphertext, $key->getKeypair()); } elseif ('sodium_secretbox' === $algorithm) { - $nonce = $ciphertext->getHeader('nonce'); - $output = sodium_crypto_secretbox_open($payload, $nonce, $key->getSecret()); + $nonce = $encryptionToken->getHeader('nonce'); + $output = sodium_crypto_secretbox_open($ciphertext, $nonce, $key->getSecret()); } else { throw new UnsupportedAlgorithmException($algorithm); } diff --git a/src/Symfony/Component/Encryption/Ciphertext.php b/src/Symfony/Component/Encryption/SymfonyEncryptionToken.php similarity index 82% rename from src/Symfony/Component/Encryption/Ciphertext.php rename to src/Symfony/Component/Encryption/SymfonyEncryptionToken.php index b7189d9fd37d..7c78ed783454 100644 --- a/src/Symfony/Component/Encryption/Ciphertext.php +++ b/src/Symfony/Component/Encryption/SymfonyEncryptionToken.php @@ -15,20 +15,20 @@ use Symfony\Component\Encryption\Exception\MalformedCipherException; /** - * This class is responsible for the payload API. + * This class is responsible for creating and parsing Symfony Encryption Tokens. + * It is an helper class for the EncryptionInterface implementations. In all common + * scenarios, one does not need to interact with this class directly. * * @author Tobias Nyholm - * - * @internal */ -class Ciphertext implements \Stringable +class SymfonyEncryptionToken implements \Stringable { /** * Algorithm used to encrypt the message. */ private string $algorithm; private string $version; - private string $payload; + private string $ciphertext; /** * @var array @@ -40,13 +40,13 @@ private function __construct() } /** - * @param array $headers + * @param array $headers keys MUST be ascii values only */ public static function create(string $algorithm, string $ciphertext, array $headers = []): self { $model = new self(); $model->algorithm = $algorithm; - $model->payload = $ciphertext; + $model->ciphertext = $ciphertext; $model->headers = $headers; return $model; @@ -64,14 +64,14 @@ public static function parse(string $input): self throw new MalformedCipherException(); } - [$headersString, $payload, $hashSignature] = $parts; + [$headersString, $ciphertext, $hashSignature] = $parts; $headersString = self::base64UrlDecode($headersString); - $payload = self::base64UrlDecode($payload); + $ciphertext = self::base64UrlDecode($ciphertext); $hashSignature = self::base64UrlDecode($hashSignature); // Check if data has been modified - $hash = hash('sha256', $headersString.$payload); + $hash = hash('sha256', $headersString.$ciphertext); if (!hash_equals($hash, $hashSignature)) { throw new MalformedCipherException(); } @@ -91,7 +91,7 @@ public static function parse(string $input): self $model->version = $headers['ver']; unset($headers['ver']); $model->headers = $headers; - $model->payload = $payload; + $model->ciphertext = $ciphertext; return $model; } @@ -113,8 +113,8 @@ public function getString(): string return sprintf('%s.%s.%s', self::base64UrlEncode($headers), - self::base64UrlEncode($this->payload), - self::base64UrlEncode(hash('sha256', $headers.$this->payload)) + self::base64UrlEncode($this->ciphertext), + self::base64UrlEncode(hash('sha256', $headers.$this->ciphertext)) ); } @@ -128,9 +128,9 @@ public function getVersion(): string return $this->version; } - public function getPayload(): string + public function getCiphertext(): string { - return $this->payload; + return $this->ciphertext; } public function hasHeader(string $name): bool diff --git a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php index 0582651a9b9c..b420c74a93ba 100644 --- a/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php +++ b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php @@ -12,13 +12,11 @@ namespace Symfony\Component\Encryption\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\Encryption\Ciphertext; use Symfony\Component\Encryption\EncryptionInterface; -use Symfony\Component\Encryption\Exception\DecryptionException; use Symfony\Component\Encryption\Exception\MalformedCipherException; -use Symfony\Component\Encryption\Exception\SignatureVerificationRequiredException; use Symfony\Component\Encryption\Exception\UnsupportedAlgorithmException; use Symfony\Component\Encryption\KeyInterface; +use Symfony\Component\Encryption\SymfonyEncryptionToken; /** * @author Tobias Nyholm @@ -75,7 +73,7 @@ public function testDecryptionThrowsOnUnsupportedAlgorithm() $key = $cipher->generateKey(); $this->expectException(UnsupportedAlgorithmException::class); - $cipher->decrypt(Ciphertext::create('foo', 'bar')->getString(), $key); + $cipher->decrypt(SymfonyEncryptionToken::create('foo', 'bar')->getString(), $key); } public function testEncryptFor() From fe7806f87227630db5d4247d79a6fef201b26356 Mon Sep 17 00:00:00 2001 From: Tobias Nyholm Date: Sun, 18 Jul 2021 12:09:50 -0700 Subject: [PATCH 14/14] Update src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php Co-authored-by: Robin Chalas --- .../Bundle/FrameworkBundle/Resources/config/encryption.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php index fb5751178f9c..8082f8f4911b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.php @@ -19,5 +19,5 @@ ->set('encryption.sodium', SodiumEncryption::class) ->alias(EncryptionInterface::class, 'encryption.sodium') ->alias('encryption', 'encryption.sodium') - ; + ; };