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..8082f8f4911b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/encryption.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\DependencyInjection\Loader\Configurator; + +use Symfony\Component\Encryption\EncryptionInterface; +use Symfony\Component\Encryption\Sodium\SodiumEncryption; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('encryption.sodium', SodiumEncryption::class) + ->alias(EncryptionInterface::class, 'encryption.sodium') + ->alias('encryption', '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/EncryptionInterface.php b/src/Symfony/Component/Encryption/EncryptionInterface.php new file mode 100644 index 000000000000..ee7ac6fcaef7 --- /dev/null +++ b/src/Symfony/Component/Encryption/EncryptionInterface.php @@ -0,0 +1,110 @@ + + * + * 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 a new key to be used for 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. + */ + 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 formatted as a Symfony Encryption Token + * + * @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" 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 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. + * + * + * + * // 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 formatted as a Symfony Encryption Token + * + * @throws EncryptionException + * @throws InvalidKeyException + */ + public function encryptFor(string $message, KeyInterface $recipientKey): string; + + /** + * Gets a plain text version of the encrypted message. + * + * @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 + */ + public function decrypt(string $message, KeyInterface $key): 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..f016136addbb --- /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 there is an issue with the Key. + * + * @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/UnsupportedAlgorithmException.php b/src/Symfony/Component/Encryption/Exception/UnsupportedAlgorithmException.php new file mode 100644 index 000000000000..69fa6502a1ff --- /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), $previous); + } +} 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..387ae258628a --- /dev/null +++ b/src/Symfony/Component/Encryption/README.md @@ -0,0 +1,19 @@ +Encryption Component +==================== + +The Encryption Component is an opinionated, high level abstraction over PHP's Sodium +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) +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..0af66a59678b --- /dev/null +++ b/src/Symfony/Component/Encryption/Sodium/SodiumEncryption.php @@ -0,0 +1,96 @@ + + * + * 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\EncryptionInterface; +use Symfony\Component\Encryption\Exception\DecryptionException; +use Symfony\Component\Encryption\Exception\EncryptionException; +use Symfony\Component\Encryption\Exception\InvalidKeyException; +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. + * + * @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 SymfonyEncryptionToken::create('sodium_secretbox', $ciphertext, ['nonce' => $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 SymfonyEncryptionToken::create('sodium_crypto_box_seal', $ciphertext)->getString(); + } + + 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)); + } + + $encryptionToken = SymfonyEncryptionToken::parse($message); + $algorithm = $encryptionToken->getAlgorithm(); + $ciphertext = $encryptionToken->getCiphertext(); + + try { + if ('sodium_crypto_box_seal' === $algorithm) { + $output = sodium_crypto_box_seal_open($ciphertext, $key->getKeypair()); + } elseif ('sodium_secretbox' === $algorithm) { + $nonce = $encryptionToken->getHeader('nonce'); + $output = sodium_crypto_secretbox_open($ciphertext, $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..54f016698635 --- /dev/null +++ b/src/Symfony/Component/Encryption/Sodium/SodiumKey.php @@ -0,0 +1,140 @@ + + * + * 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 +{ + 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 = null; + + 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 + { + return $this->secret ?? throw new InvalidKeyException('This key does not have a secret.'); + } + + public function getPrivateKey(): string + { + return $this->privateKey ?? throw new InvalidKeyException('This key does not have a private key.'); + } + + public function getPublicKey(): string + { + return $this->publicKey ?? throw new InvalidKeyException('This key does not have a public key.'); + } + + 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/SymfonyEncryptionToken.php b/src/Symfony/Component/Encryption/SymfonyEncryptionToken.php new file mode 100644 index 000000000000..7c78ed783454 --- /dev/null +++ b/src/Symfony/Component/Encryption/SymfonyEncryptionToken.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; + +use Symfony\Component\Encryption\Exception\DecryptionException; +use Symfony\Component\Encryption\Exception\MalformedCipherException; + +/** + * 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 + */ +class SymfonyEncryptionToken implements \Stringable +{ + /** + * Algorithm used to encrypt the message. + */ + private string $algorithm; + private string $version; + private string $ciphertext; + + /** + * @var array + */ + private array $headers = []; + + private function __construct() + { + } + + /** + * @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->ciphertext = $ciphertext; + $model->headers = $headers; + + return $model; + } + + /** + * Take a string representation of the ciphertext and parse it into an object. + * + * @throws MalformedCipherException + */ + public static function parse(string $input): self + { + $parts = explode('.', $input); + if (!\is_array($parts) || 3 !== \count($parts)) { + throw new MalformedCipherException(); + } + + [$headersString, $ciphertext, $hashSignature] = $parts; + + $headersString = self::base64UrlDecode($headersString); + $ciphertext = self::base64UrlDecode($ciphertext); + $hashSignature = self::base64UrlDecode($hashSignature); + + // Check if data has been modified + $hash = hash('sha256', $headersString.$ciphertext); + 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)) { + 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->ciphertext = $ciphertext; + + return $model; + } + + public function __toString(): string + { + return $this->getString(); + } + + 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', + self::base64UrlEncode($headers), + self::base64UrlEncode($this->ciphertext), + self::base64UrlEncode(hash('sha256', $headers.$this->ciphertext)) + ); + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getCiphertext(): string + { + return $this->ciphertext; + } + + 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/Tests/AbstractEncryptionTest.php b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php new file mode 100644 index 000000000000..b420c74a93ba --- /dev/null +++ b/src/Symfony/Component/Encryption/Tests/AbstractEncryptionTest.php @@ -0,0 +1,99 @@ + + * + * 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\EncryptionInterface; +use Symfony\Component\Encryption\Exception\MalformedCipherException; +use Symfony\Component\Encryption\Exception\UnsupportedAlgorithmException; +use Symfony\Component\Encryption\KeyInterface; +use Symfony\Component\Encryption\SymfonyEncryptionToken; + +/** + * @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(SymfonyEncryptionToken::create('foo', 'bar')->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))); + } + + 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 + + +