vendor/symfony/framework-bundle/Secrets/SodiumVault.php line 33

  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Bundle\FrameworkBundle\Secrets;
  11. use Symfony\Component\DependencyInjection\EnvVarLoaderInterface;
  12. use Symfony\Component\VarExporter\VarExporter;
  13. /**
  14.  * @author Tobias Schultze <http://tobion.de>
  15.  * @author Jérémy Derussé <jeremy@derusse.com>
  16.  * @author Nicolas Grekas <p@tchwork.com>
  17.  */
  18. class SodiumVault extends AbstractVault implements EnvVarLoaderInterface
  19. {
  20.     private ?string $encryptionKey = null;
  21.     private string|\Stringable|null $decryptionKey = null;
  22.     private string $pathPrefix;
  23.     private ?string $secretsDir;
  24.     /**
  25.      * @param $decryptionKey A string or a stringable object that defines the private key to use to decrypt the vault
  26.      *                       or null to store generated keys in the provided $secretsDir
  27.      */
  28.     public function __construct(string $secretsDir, #[\SensitiveParameter] string|\Stringable $decryptionKey = null)
  29.     {
  30.         $this->pathPrefix = rtrim(strtr($secretsDir, '/', \DIRECTORY_SEPARATOR), \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.basename($secretsDir).'.';
  31.         $this->decryptionKey = $decryptionKey;
  32.         $this->secretsDir = $secretsDir;
  33.     }
  34.     public function generateKeys(bool $override = false): bool
  35.     {
  36.         $this->lastMessage = null;
  37.         if (null === $this->encryptionKey && '' !== $this->decryptionKey = (string) $this->decryptionKey) {
  38.             $this->lastMessage = 'Cannot generate keys when a decryption key has been provided while instantiating the vault.';
  39.             return false;
  40.         }
  41.         try {
  42.             $this->loadKeys();
  43.         } catch (\RuntimeException) {
  44.             // ignore failures to load keys
  45.         }
  46.         if ('' !== $this->decryptionKey && !is_file($this->pathPrefix.'encrypt.public.php')) {
  47.             $this->export('encrypt.public', $this->encryptionKey);
  48.         }
  49.         if (!$override && null !== $this->encryptionKey) {
  50.             $this->lastMessage = sprintf('Sodium keys already exist at "%s*.{public,private}" and won\'t be overridden.', $this->getPrettyPath($this->pathPrefix));
  51.             return false;
  52.         }
  53.         $this->decryptionKey = sodium_crypto_box_keypair();
  54.         $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
  55.         $this->export('encrypt.public', $this->encryptionKey);
  56.         $this->export('decrypt.private', $this->decryptionKey);
  57.         $this->lastMessage = sprintf('Sodium keys have been generated at "%s*.public/private.php".', $this->getPrettyPath($this->pathPrefix));
  58.         return true;
  59.     }
  60.     public function seal(string $name, string $value): void
  61.     {
  62.         $this->lastMessage = null;
  63.         $this->validateName($name);
  64.         $this->loadKeys();
  65.         $filename = $this->getFilename($name);
  66.         $this->export($filename, sodium_crypto_box_seal($value, $this->encryptionKey ?? sodium_crypto_box_publickey($this->decryptionKey)));
  67.         $list = $this->list();
  68.         $list[$name] = null;
  69.         uksort($list, 'strnatcmp');
  70.         file_put_contents($this->pathPrefix.'list.php', sprintf("<?php\n\nreturn %s;\n", VarExporter::export($list)), \LOCK_EX);
  71.         $this->lastMessage = sprintf('Secret "%s" encrypted in "%s"; you can commit it.', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  72.     }
  73.     public function reveal(string $name): ?string
  74.     {
  75.         $this->lastMessage = null;
  76.         $this->validateName($name);
  77.         $filename = $this->getFilename($name);
  78.         if (!is_file($file = $this->pathPrefix.$filename.'.php')) {
  79.             $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  80.             return null;
  81.         }
  82.         if (!\function_exists('sodium_crypto_box_seal')) {
  83.             $this->lastMessage = sprintf('Secret "%s" cannot be revealed as the "sodium" PHP extension missing. Try running "composer require paragonie/sodium_compat" if you cannot enable the extension."', $name);
  84.             return null;
  85.         }
  86.         $this->loadKeys();
  87.         if ('' === $this->decryptionKey) {
  88.             $this->lastMessage = sprintf('Secret "%s" cannot be revealed as no decryption key was found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  89.             return null;
  90.         }
  91.         if (false === $value = sodium_crypto_box_seal_open(include $file, $this->decryptionKey)) {
  92.             $this->lastMessage = sprintf('Secret "%s" cannot be revealed as the wrong decryption key was provided for "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  93.             return null;
  94.         }
  95.         return $value;
  96.     }
  97.     public function remove(string $name): bool
  98.     {
  99.         $this->lastMessage = null;
  100.         $this->validateName($name);
  101.         $filename = $this->getFilename($name);
  102.         if (!is_file($file = $this->pathPrefix.$filename.'.php')) {
  103.             $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  104.             return false;
  105.         }
  106.         $list = $this->list();
  107.         unset($list[$name]);
  108.         file_put_contents($this->pathPrefix.'list.php', sprintf("<?php\n\nreturn %s;\n", VarExporter::export($list)), \LOCK_EX);
  109.         $this->lastMessage = sprintf('Secret "%s" removed from "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  110.         return @unlink($file) || !file_exists($file);
  111.     }
  112.     public function list(bool $reveal = false): array
  113.     {
  114.         $this->lastMessage = null;
  115.         if (!is_file($file = $this->pathPrefix.'list.php')) {
  116.             return [];
  117.         }
  118.         $secrets = include $file;
  119.         if (!$reveal) {
  120.             return $secrets;
  121.         }
  122.         foreach ($secrets as $name => $value) {
  123.             $secrets[$name] = $this->reveal($name);
  124.         }
  125.         return $secrets;
  126.     }
  127.     public function loadEnvVars(): array
  128.     {
  129.         return $this->list(true);
  130.     }
  131.     private function loadKeys(): void
  132.     {
  133.         if (!\function_exists('sodium_crypto_box_seal')) {
  134.             throw new \LogicException('The "sodium" PHP extension is required to deal with secrets. Alternatively, try running "composer require paragonie/sodium_compat" if you cannot enable the extension.".');
  135.         }
  136.         if (null !== $this->encryptionKey || '' !== $this->decryptionKey = (string) $this->decryptionKey) {
  137.             return;
  138.         }
  139.         if (is_file($this->pathPrefix.'decrypt.private.php')) {
  140.             $this->decryptionKey = (string) include $this->pathPrefix.'decrypt.private.php';
  141.         }
  142.         if (is_file($this->pathPrefix.'encrypt.public.php')) {
  143.             $this->encryptionKey = (string) include $this->pathPrefix.'encrypt.public.php';
  144.         } elseif ('' !== $this->decryptionKey) {
  145.             $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
  146.         } else {
  147.             throw new \RuntimeException(sprintf('Encryption key not found in "%s".', \dirname($this->pathPrefix)));
  148.         }
  149.     }
  150.     private function export(string $filename, string $data): void
  151.     {
  152.         $b64 = 'decrypt.private' === $filename ? '// SYMFONY_DECRYPTION_SECRET='.base64_encode($data)."\n" : '';
  153.         $name = basename($this->pathPrefix.$filename);
  154.         $data = str_replace('%', '\x', rawurlencode($data));
  155.         $data = sprintf("<?php // %s on %s\n\n%sreturn \"%s\";\n", $name, date('r'), $b64, $data);
  156.         $this->createSecretsDir();
  157.         if (false === file_put_contents($this->pathPrefix.$filename.'.php', $data, \LOCK_EX)) {
  158.             $e = error_get_last();
  159.             throw new \ErrorException($e['message'] ?? 'Failed to write secrets data.', 0, $e['type'] ?? \E_USER_WARNING);
  160.         }
  161.     }
  162.     private function createSecretsDir(): void
  163.     {
  164.         if ($this->secretsDir && !is_dir($this->secretsDir) && !@mkdir($this->secretsDir, 0777, true) && !is_dir($this->secretsDir)) {
  165.             throw new \RuntimeException(sprintf('Unable to create the secrets directory (%s).', $this->secretsDir));
  166.         }
  167.         $this->secretsDir = null;
  168.     }
  169.     private function getFilename(string $name): string
  170.     {
  171.         // The MD5 hash allows making secrets case-sensitive. The filename is not enough on Windows.
  172.         return $name.'.'.substr(md5($name), 0, 6);
  173.     }
  174. }