SodiumVault.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. /**
  13. * @author Tobias Schultze <http://tobion.de>
  14. * @author Jérémy Derussé <jeremy@derusse.com>
  15. * @author Nicolas Grekas <p@tchwork.com>
  16. *
  17. * @internal
  18. */
  19. class SodiumVault extends AbstractVault implements EnvVarLoaderInterface
  20. {
  21. private $encryptionKey;
  22. private $decryptionKey;
  23. private $pathPrefix;
  24. private $secretsDir;
  25. /**
  26. * @param string|\Stringable|null $decryptionKey A string or a stringable object that defines the private key to use to decrypt the vault
  27. * or null to store generated keys in the provided $secretsDir
  28. */
  29. public function __construct(string $secretsDir, $decryptionKey = null)
  30. {
  31. if (null !== $decryptionKey && !\is_string($decryptionKey) && !(\is_object($decryptionKey) && method_exists($decryptionKey, '__toString'))) {
  32. throw new \TypeError(sprintf('Decryption key should be a string or an object that implements the __toString() method, "%s" given.', get_debug_type($decryptionKey)));
  33. }
  34. $this->pathPrefix = rtrim(strtr($secretsDir, '/', \DIRECTORY_SEPARATOR), \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR.basename($secretsDir).'.';
  35. $this->decryptionKey = $decryptionKey;
  36. $this->secretsDir = $secretsDir;
  37. }
  38. public function generateKeys(bool $override = false): bool
  39. {
  40. $this->lastMessage = null;
  41. if (null === $this->encryptionKey && '' !== $this->decryptionKey = (string) $this->decryptionKey) {
  42. $this->lastMessage = 'Cannot generate keys when a decryption key has been provided while instantiating the vault.';
  43. return false;
  44. }
  45. try {
  46. $this->loadKeys();
  47. } catch (\RuntimeException $e) {
  48. // ignore failures to load keys
  49. }
  50. if ('' !== $this->decryptionKey && !is_file($this->pathPrefix.'encrypt.public.php')) {
  51. $this->export('encrypt.public', $this->encryptionKey);
  52. }
  53. if (!$override && null !== $this->encryptionKey) {
  54. $this->lastMessage = sprintf('Sodium keys already exist at "%s*.{public,private}" and won\'t be overridden.', $this->getPrettyPath($this->pathPrefix));
  55. return false;
  56. }
  57. $this->decryptionKey = sodium_crypto_box_keypair();
  58. $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
  59. $this->export('encrypt.public', $this->encryptionKey);
  60. $this->export('decrypt.private', $this->decryptionKey);
  61. $this->lastMessage = sprintf('Sodium keys have been generated at "%s*.public/private.php".', $this->getPrettyPath($this->pathPrefix));
  62. return true;
  63. }
  64. public function seal(string $name, string $value): void
  65. {
  66. $this->lastMessage = null;
  67. $this->validateName($name);
  68. $this->loadKeys();
  69. $this->export($name.'.'.substr(md5($name), 0, 6), sodium_crypto_box_seal($value, $this->encryptionKey ?? sodium_crypto_box_publickey($this->decryptionKey)));
  70. $list = $this->list();
  71. $list[$name] = null;
  72. uksort($list, 'strnatcmp');
  73. file_put_contents($this->pathPrefix.'list.php', sprintf("<?php\n\nreturn %s;\n", var_export($list, true), \LOCK_EX));
  74. $this->lastMessage = sprintf('Secret "%s" encrypted in "%s"; you can commit it.', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  75. }
  76. public function reveal(string $name): ?string
  77. {
  78. $this->lastMessage = null;
  79. $this->validateName($name);
  80. if (!is_file($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.php', -26))) {
  81. $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  82. return null;
  83. }
  84. if (!\function_exists('sodium_crypto_box_seal')) {
  85. $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);
  86. return null;
  87. }
  88. $this->loadKeys();
  89. if ('' === $this->decryptionKey) {
  90. $this->lastMessage = sprintf('Secret "%s" cannot be revealed as no decryption key was found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  91. return null;
  92. }
  93. if (false === $value = sodium_crypto_box_seal_open(include $file, $this->decryptionKey)) {
  94. $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));
  95. return null;
  96. }
  97. return $value;
  98. }
  99. public function remove(string $name): bool
  100. {
  101. $this->lastMessage = null;
  102. $this->validateName($name);
  103. if (!is_file($file = $this->pathPrefix.$name.'.'.substr_replace(md5($name), '.php', -26))) {
  104. $this->lastMessage = sprintf('Secret "%s" not found in "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  105. return false;
  106. }
  107. $list = $this->list();
  108. unset($list[$name]);
  109. file_put_contents($this->pathPrefix.'list.php', sprintf("<?php\n\nreturn %s;\n", var_export($list, true), \LOCK_EX));
  110. $this->lastMessage = sprintf('Secret "%s" removed from "%s".', $name, $this->getPrettyPath(\dirname($this->pathPrefix).\DIRECTORY_SEPARATOR));
  111. return @unlink($file) || !file_exists($file);
  112. }
  113. public function list(bool $reveal = false): array
  114. {
  115. $this->lastMessage = null;
  116. if (!is_file($file = $this->pathPrefix.'list.php')) {
  117. return [];
  118. }
  119. $secrets = include $file;
  120. if (!$reveal) {
  121. return $secrets;
  122. }
  123. foreach ($secrets as $name => $value) {
  124. $secrets[$name] = $this->reveal($name);
  125. }
  126. return $secrets;
  127. }
  128. public function loadEnvVars(): array
  129. {
  130. return $this->list(true);
  131. }
  132. private function loadKeys(): void
  133. {
  134. if (!\function_exists('sodium_crypto_box_seal')) {
  135. 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.".');
  136. }
  137. if (null !== $this->encryptionKey || '' !== $this->decryptionKey = (string) $this->decryptionKey) {
  138. return;
  139. }
  140. if (is_file($this->pathPrefix.'decrypt.private.php')) {
  141. $this->decryptionKey = (string) include $this->pathPrefix.'decrypt.private.php';
  142. }
  143. if (is_file($this->pathPrefix.'encrypt.public.php')) {
  144. $this->encryptionKey = (string) include $this->pathPrefix.'encrypt.public.php';
  145. } elseif ('' !== $this->decryptionKey) {
  146. $this->encryptionKey = sodium_crypto_box_publickey($this->decryptionKey);
  147. } else {
  148. throw new \RuntimeException(sprintf('Encryption key not found in "%s".', \dirname($this->pathPrefix)));
  149. }
  150. }
  151. private function export(string $file, string $data): void
  152. {
  153. $name = basename($this->pathPrefix.$file);
  154. $data = str_replace('%', '\x', rawurlencode($data));
  155. $data = sprintf("<?php // %s on %s\n\nreturn \"%s\";\n", $name, date('r'), $data);
  156. $this->createSecretsDir();
  157. if (false === file_put_contents($this->pathPrefix.$file.'.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. }