From 8c8f62390a18116ee681ed2a66d36971a9c19947 Mon Sep 17 00:00:00 2001 From: Tobias Schultze Date: Sat, 6 Apr 2019 19:40:45 +0200 Subject: [PATCH] Proof of concept for encrypted secrets --- .../Command/SecretsAddCommand.php | 68 ++++++++++++++++ .../Command/SecretsGenerateKeyCommand.php | 28 +++++++ .../Command/SecretsListCommand.php | 45 +++++++++++ .../DependencyInjection/Configuration.php | 19 +++++ .../FrameworkExtension.php | 15 ++++ .../Resources/config/console.xml | 14 ++++ .../Resources/config/secrets.xml | 23 ++++++ .../Secret/CachedSecretStorage.php | 78 +++++++++++++++++++ .../Secret/EncryptedMessage.php | 49 ++++++++++++ .../Secret/FilesSecretStorage.php | 69 ++++++++++++++++ .../Secret/SecretEnvVarProcessor.php | 42 ++++++++++ .../Secret/SecretStorageInterface.php | 14 ++++ 12 files changed, 464 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php new file mode 100644 index 0000000000..780d4c2373 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsAddCommand.php @@ -0,0 +1,68 @@ +secretStorage = $secretStorage; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Adds a secret with the key.') + ->addArgument( + 'key', + InputArgument::REQUIRED + ) + ->addArgument( + 'secret', + InputArgument::REQUIRED + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $key = $input->getArgument('key'); + $secret = $input->getArgument('secret'); + + $this->secretStorage->putSecret($key, $secret); + } + + protected function interact(InputInterface $input, OutputInterface $output) + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $question = new Question('Key of the secret: ', $input->getArgument('key')); + + $key = $helper->ask($input, $output, $question); + $input->setArgument('key', $key); + + $question = new Question('Plaintext secret value: ', $input->getArgument('secret')); + $question->setHidden(true); + + $secret = $helper->ask($input, $output, $question); + $input->setArgument('secret', $secret); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php new file mode 100644 index 0000000000..fd1d1a8fae --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsGenerateKeyCommand.php @@ -0,0 +1,28 @@ +setDescription('Prints a randomly generated encryption key.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $encryptionKey = sodium_crypto_stream_keygen(); + + $output->write($encryptionKey, false, OutputInterface::OUTPUT_RAW); + + sodium_memzero($encryptionKey); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php new file mode 100644 index 0000000000..c280acd6b1 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Command/SecretsListCommand.php @@ -0,0 +1,45 @@ +secretStorage = $secretStorage; + + parent::__construct(); + } + + protected function configure() + { + $this + ->setDescription('Lists all secrets.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $table = new Table($output); + $table->setHeaders(['key', 'plaintext secret']); + + foreach ($this->secretStorage->listSecrets() as $key => $secret) { + $table->addRow([$key, $secret]); + } + + $table->render(); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index c34377fa0d..fd8901c804 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -115,10 +115,29 @@ class Configuration implements ConfigurationInterface $this->addRobotsIndexSection($rootNode); $this->addHttpClientSection($rootNode); $this->addMailerSection($rootNode); + $this->addSecretsSection($rootNode); return $treeBuilder; } + private function addSecretsSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('secrets') + ->canBeEnabled() + ->children() + ->scalarNode('encrypted_secrets_dir')->end() + ->scalarNode('encryption_key')->end() + //->scalarNode('public_key')->end() + //->scalarNode('private_key')->end() + ->scalarNode('decrypted_secrets_cache')->end() + ->end() + ->end() + ->end() + ; + } + private function addCsrfSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 1d83148ff7..97486af068 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -334,6 +334,7 @@ class FrameworkExtension extends Extension $this->registerRouterConfiguration($config['router'], $container, $loader); $this->registerAnnotationsConfiguration($config['annotations'], $container, $loader); $this->registerPropertyAccessConfiguration($config['property_access'], $container, $loader); + $this->registerSecretsConfiguration($config['secrets'], $container, $loader); if ($this->isConfigEnabled($container, $config['serializer'])) { if (!class_exists('Symfony\Component\Serializer\Serializer')) { @@ -1441,6 +1442,20 @@ class FrameworkExtension extends Extension ; } + private function registerSecretsConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + if (!$this->isConfigEnabled($container, $config)) { + $container->removeDefinition('console.command.secrets_add'); + + return; + } + + $loader->load('secrets.xml'); + + $container->getDefinition('secrets.storage.files')->replaceArgument(0, $config['encrypted_secrets_dir']); + $container->getDefinition('secrets.storage.files')->replaceArgument(1, $config['encryption_key']); + } + private function registerSecurityCsrfConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) { if (!$this->isConfigEnabled($container, $config)) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml index f13aa759d3..a4249d1797 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/console.xml @@ -200,5 +200,19 @@ + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml new file mode 100644 index 0000000000..8bdb2422ec --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/secrets.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php new file mode 100644 index 0000000000..3fe9ff7184 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/CachedSecretStorage.php @@ -0,0 +1,78 @@ +decoratedStorage = $decoratedStorage; + $this->cache = $cache; + } + + public function getSecret(string $key): string + { + $cacheItem = $this->cache->getItem('secrets.php'); + + if ($cacheItem->isHit()) { + $secrets = $cacheItem->get(); + if (isset($secrets[$key])) { + return $secrets[$key]; + } + } + + $this->regenerateCache($cacheItem); + + return $this->decoratedStorage->getSecret($key); + } + + public function putSecret(string $key, string $secret): void + { + $this->decoratedStorage->putSecret($key, $secret); + $this->regenerateCache(); + } + + public function deleteSecret(string $key): void + { + $this->decoratedStorage->deleteSecret($key); + $this->regenerateCache(); + } + + public function listSecrets(): iterable + { + $cacheItem = $this->cache->getItem('secrets.php'); + + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + + return $this->regenerateCache($cacheItem); + } + + private function regenerateCache(?CacheItemInterface $cacheItem = null): array + { + $cacheItem = $cacheItem ?? $this->cache->getItem('secrets.php'); + + $secrets = []; + foreach ($this->decoratedStorage->listSecrets() as $key => $secret) { + $secrets[$key] = $secret; + } + + $cacheItem->set($secrets); + $this->cache->save($cacheItem); + + return $secrets; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php new file mode 100644 index 0000000000..72a40724db --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/EncryptedMessage.php @@ -0,0 +1,49 @@ +ciphertext = $ciphertext; + $this->nonce = $nonce; + } + + public function __toString() + { + return $this->nonce.$this->ciphertext; + } + + public function getCiphertext(): string + { + return $this->ciphertext; + } + + public function getNonce(): string + { + return $this->nonce; + } + + public static function createFromString(string $message): self + { + if (\strlen($message) < SODIUM_CRYPTO_STREAM_NONCEBYTES) { + throw new \RuntimeException('Invalid ciphertext. Message is too short.'); + } + + $nonce = substr($message, 0, SODIUM_CRYPTO_STREAM_NONCEBYTES); + $ciphertext = substr($message, SODIUM_CRYPTO_STREAM_NONCEBYTES); + + return new self($ciphertext, $nonce); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php b/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php new file mode 100644 index 0000000000..1eebc3920a --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/FilesSecretStorage.php @@ -0,0 +1,69 @@ +secretsFolder = $secretsFolder; + $this->encryptionKey = $encryptionKey; + } + + public function getSecret(string $key): string + { + return $this->decryptFile($this->getFilePath($key)); + } + + public function putSecret(string $key, string $secret): void + { + $nonce = random_bytes(SODIUM_CRYPTO_STREAM_NONCEBYTES); + $ciphertext = sodium_crypto_stream_xor($secret, $nonce, $this->encryptionKey); + + sodium_memzero($secret); + + $message = new EncryptedMessage($ciphertext, $nonce); + + file_put_contents($this->getFilePath($key), (string) $message); + } + + public function deleteSecret(string $key): void + { + unlink($this->getFilePath($key)); + } + + public function listSecrets(): iterable + { + foreach (scandir($this->secretsFolder) as $fileName) { + if ('.' === $fileName || '..' === $fileName) { + continue; + } + + $key = basename($fileName, '.bin'); + yield $key => $this->getSecret($key); + } + } + + private function decryptFile(string $filePath): string + { + $encrypted = file_get_contents($filePath); + + $message = EncryptedMessage::createFromString($encrypted); + + return sodium_crypto_stream_xor($message->getCiphertext(), $message->getNonce(), $this->encryptionKey); + } + + private function getFilePath(string $key): string + { + return $this->secretsFolder.$key.'.bin'; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php new file mode 100644 index 0000000000..17e5eb3664 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretEnvVarProcessor.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Secret; + +use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; + +class SecretEnvVarProcessor implements EnvVarProcessorInterface +{ + private $secretStorage; + + public function __construct(SecretStorageInterface $secretStorage) + { + $this->secretStorage = $secretStorage; + } + + /** + * {@inheritdoc} + */ + public static function getProvidedTypes() + { + return [ + 'secret' => 'string', + ]; + } + + /** + * {@inheritdoc} + */ + public function getEnv($prefix, $name, \Closure $getEnv) + { + return $this->secretStorage->getSecret($name); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php new file mode 100644 index 0000000000..a57a11eb8c --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Secret/SecretStorageInterface.php @@ -0,0 +1,14 @@ +