From 2e210b42610e9ab797a7a51633aa46d90036c0a6 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 3 Nov 2023 10:20:34 -0700 Subject: [PATCH 1/5] DotenvVault loads .env file --- tests/DotenvVault/DotenvVaultTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/DotenvVault/DotenvVaultTest.php b/tests/DotenvVault/DotenvVaultTest.php index 67953e5..0b1807b 100644 --- a/tests/DotenvVault/DotenvVaultTest.php +++ b/tests/DotenvVault/DotenvVaultTest.php @@ -38,5 +38,11 @@ public function testDotenvThrowsExceptionIfUnableToLoadFile() $dotenv->load(); } + + public function testDotenvTriesPathsToLoad() + { + $dotenv = DotenvVault::createMutable([__DIR__, self::$folder]); + self::assertCount(4, $dotenv->load()); + } } ?> From 757cdaa397c518b6138a1295c4d1a4636f72e5c5 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 3 Nov 2023 14:19:08 -0700 Subject: [PATCH 2/5] move things around. create decrypter in separate class with separate specs --- ci.yml => .github/workflows/ci.yml | 2 +- src/Decrypter/Decrypter.php | 62 +++++ src/Decrypter/DecrypterInterface.php | 21 ++ src/DotenvVault.php | 211 ++++++------------ src/DotenvVaultBackup.php | 193 ++++++++++++++++ tests/DotenvVault/Decrypter/DecrypterTest.php | 64 ++++++ tests/DotenvVault/DotenvVaultTest.php | 22 +- 7 files changed, 431 insertions(+), 144 deletions(-) rename ci.yml => .github/workflows/ci.yml (94%) create mode 100644 src/Decrypter/Decrypter.php create mode 100644 src/Decrypter/DecrypterInterface.php create mode 100644 src/DotenvVaultBackup.php create mode 100644 tests/DotenvVault/Decrypter/DecrypterTest.php diff --git a/ci.yml b/.github/workflows/ci.yml similarity index 94% rename from ci.yml rename to .github/workflows/ci.yml index 584bb1c..1169991 100644 --- a/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: timeout_minutes: 5 max_attempts: 5 command: composer update --no-interaction --no-progress - - run: vendor/bin/phpunit + - run: vendor/bin/phpunit --testdox diff --git a/src/Decrypter/Decrypter.php b/src/Decrypter/Decrypter.php new file mode 100644 index 0000000..60e2eac --- /dev/null +++ b/src/Decrypter/Decrypter.php @@ -0,0 +1,62 @@ +store = $store; - $this->parser = $parser; - $this->loader = $loader; - $this->repository = $repository; - } - - public function load() - { - $this->dotenv_key = getenv("DOTENV_KEY"); - if ($this->dotenv_key !== false){ - - $entries = $this->parser->parse($this->store->read()); - $this->loader->load($this->repository, $entries); - - $plaintext = $this->parse_vault(); - - // parsing plaintext and loading to getenv - $vault_entries = $this->parser->parse($plaintext); - $this->loader->load($this->repository, $vault_entries); - } - else { - $entries = $this->parser->parse($this->store->read()); - - return $this->loader->load($this->repository, $entries); - } - } - - public function parse_vault() - { - $dotenv_keys = explode(',', $this->dotenv_key); - $keys = array(); - foreach($dotenv_keys as $key) - { - // parse DOTENV_KEY, format is a URI. - $uri = parse_url(trim($key)); - - // get encrypted key - $pass = $uri['pass']; - - // Get environment from query params. - parse_str($uri['query'], $params); - $vault_environment = $params['environment'] or throw new DotEnvVaultError('INVALID_DOTENV_KEY: Missing environment part.'); - - # Getting ciphertext from correct environment in .env.vault - $vault_environment = strtoupper($vault_environment); - $environment_key = "DOTENV_VAULT_{$vault_environment}"; - - $ciphertext = getenv("{$environment_key}") or throw new DotEnvVaultError("NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment {$environment_key} in your .env.vault file. Run 'npx dotenv-vault build' to include it."); - - array_push($keys, array('encrypted_key' => $pass, 'ciphertext' => $ciphertext)); - } - return $this->key_rotation($keys); +class DotenvVault { + public static function __callStatic($name, $arguments) { + // Forward the call to the Dotenv\Dotenv class + return call_user_func_array(['Dotenv\Dotenv', $name], $arguments); } - private function key_rotation($keys){ - $count = count($keys); - foreach($keys as $index=>$value) { - $decrypt = $this->decrypt($value['ciphertext'], $value['encrypted_key']); - - if ($decrypt == false && $index + 1 >= $count){ - throw new DotEnvVaultError('INVALID_DOTENV_KEY: Key must be valid.'); - } - elseif($decrypt == false){ - continue; - } - else{ - return $decrypt; - } - } - } - - private function decrypt($data, $secret) - { - $secret = hex2bin(substr($secret, 4, strlen($secret))); - $data = base64_decode($data, true); - $nonce = substr($data, 0, 12); - $tag = substr($data, -16); - $ciphertext = substr($data, 12, -16); - - try { - return openssl_decrypt( - $ciphertext, - 'aes-256-gcm', - $secret, - OPENSSL_RAW_DATA, - $nonce, - $tag - ); - } catch (Exception $e) { - return false; - } - } - - public static function createImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) - { - $repository = RepositoryBuilder::createWithDefaultAdapters()->immutable()->make(); - - return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); - } - - public static function create(RepositoryInterface $repository, $paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) - { - $builder = $names === null ? StoreBuilder::createWithDefaultName() : StoreBuilder::createWithNoNames(); - - foreach ((array) $paths as $path) { - $builder = $builder->addPath($path); - } - - foreach ((array) $names as $name) { - $builder = $builder->addName($name); - } - - if ($shortCircuit) { - $builder = $builder->shortCircuit(); - } - - return new self($builder->fileEncoding($fileEncoding)->make(), new Parser(), new Loader(), $repository); - } + // public function load() + // { + // $this->dotenv_key = getenv("DOTENV_KEY"); + // if ($this->dotenv_key !== false){ + + // $entries = $this->parser->parse($this->store->read()); + // $this->loader->load($this->repository, $entries); + + // $plaintext = $this->parse_vault(); + + // // parsing plaintext and loading to getenv + // $vault_entries = $this->parser->parse($plaintext); + // return $this->loader->load($this->repository, $vault_entries); + // } + // else { + // $entries = $this->parser->parse($this->store->read()); + + // return $this->loader->load($this->repository, $entries); + // } + // } + + // public function parse_vault() + // { + // $dotenv_keys = explode(',', $this->dotenv_key); + // $keys = array(); + // foreach($dotenv_keys as $key) + // { + // // parse DOTENV_KEY, format is a URI. + // $uri = parse_url(trim($key)); + + // // get encrypted key + // $pass = $uri['pass']; + // + // // Get environment from query params. + // parse_str($uri['query'], $params); + // $vault_environment = $params['environment'] or throw new Exception('INVALID_DOTENV_KEY: Missing environment part.'); + + // # Getting ciphertext from correct environment in .env.vault + // $vault_environment = strtoupper($vault_environment); + // $environment_key = "DOTENV_VAULT_{$vault_environment}"; + + // $ciphertext = getenv("{$environment_key}") or throw new Exception("NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment {$environment_key} in your .env.vault file. Run 'npx dotenv-vault build' to include it."); + // + // array_push($keys, array('encrypted_key' => $pass, 'ciphertext' => $ciphertext)); + // } + // return $this->key_rotation($keys); + // } + + // private function key_rotation($keys){ + // $count = count($keys); + // foreach($keys as $index=>$value) { + // $decrypt = $this->decrypt($value['ciphertext'], $value['encrypted_key']); + + // if ($decrypt == false && $index + 1 >= $count){ + // throw new Exception('INVALID_DOTENV_KEY: Key must be valid.'); + // } + // elseif($decrypt == false){ + // continue; + // } + // else{ + // return $decrypt; + // } + // } + // } } diff --git a/src/DotenvVaultBackup.php b/src/DotenvVaultBackup.php new file mode 100644 index 0000000..68ebbfb --- /dev/null +++ b/src/DotenvVaultBackup.php @@ -0,0 +1,193 @@ +store = $store; + $this->parser = $parser; + $this->loader = $loader; + $this->repository = $repository; + } + + public function load() + { + $this->dotenv_key = getenv("DOTENV_KEY"); + if ($this->dotenv_key !== false){ + + $entries = $this->parser->parse($this->store->read()); + $this->loader->load($this->repository, $entries); + + $plaintext = $this->parse_vault(); + + // parsing plaintext and loading to getenv + $vault_entries = $this->parser->parse($plaintext); + return $this->loader->load($this->repository, $vault_entries); + } + else { + $entries = $this->parser->parse($this->store->read()); + + return $this->loader->load($this->repository, $entries); + } + } + + public function parse_vault() + { + $dotenv_keys = explode(',', $this->dotenv_key); + $keys = array(); + foreach($dotenv_keys as $key) + { + // parse DOTENV_KEY, format is a URI. + $uri = parse_url(trim($key)); + + // get encrypted key + $pass = $uri['pass']; + + // Get environment from query params. + parse_str($uri['query'], $params); + $vault_environment = $params['environment'] or throw new DotEnvVaultError('INVALID_DOTENV_KEY: Missing environment part.'); + + # Getting ciphertext from correct environment in .env.vault + $vault_environment = strtoupper($vault_environment); + $environment_key = "DOTENV_VAULT_{$vault_environment}"; + + $ciphertext = getenv("{$environment_key}") or throw new DotEnvVaultError("NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment {$environment_key} in your .env.vault file. Run 'npx dotenv-vault build' to include it."); + + array_push($keys, array('encrypted_key' => $pass, 'ciphertext' => $ciphertext)); + } + return $this->key_rotation($keys); + } + + private function key_rotation($keys){ + $count = count($keys); + foreach($keys as $index=>$value) { + $decrypt = $this->decrypt($value['ciphertext'], $value['encrypted_key']); + + if ($decrypt == false && $index + 1 >= $count){ + throw new DotEnvVaultError('INVALID_DOTENV_KEY: Key must be valid.'); + } + elseif($decrypt == false){ + continue; + } + else{ + return $decrypt; + } + } + } + + public static function decrypt($encrypted, $keyStr) + { + // grab last 64 to permit keys like vlt_64 or custom_64 + $last64 = substr($keyStr, -64); + + if (strlen($last64) !== 64) { + $msg = 'INVALID_DOTENV_KEY: It must be 64 characters long (or more)'; + throw new Exception($msg); + } + + // check key length is good INVALID_DOTENV_KEY: It must be 64 characters long (or more) + $key = hex2bin($last64); + + // base64 decode + $decoded = base64_decode($encrypted, true); + + // determine cipher and pull out nonce and tag + $ciphertext = substr($decoded, 12, -16); + $nonce = substr($decoded, 0, 12); + $tag = substr($decoded, -16); + + try { + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag); + + if ($plaintext === false) { + $msg = 'DECRYPTION_FAILED: Please check your DOTENV_KEY'; + throw new Exception($msg); + } else { + return $plaintext; + } + } catch (ExceptionType $e) { + $msg = 'DECRYPTION_FAILED: Please check your DOTENV_KEY'; + throw new Exception($msg); + } + } + + /** + * Create a new immutable dotenvVault instance with default repository. + * + * @param string|string[] $paths + * @param string|string[]|null $names + * @param bool $shortCircuit + * @param string|null $fileEncoding + * + * @return \DotenvVault\DotenvVault + */ + public static function createImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + { + $repository = RepositoryBuilder::createWithDefaultAdapters()->immutable()->make(); + + return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); + } + + /** + * Create a new immutable dotenvVault instance with default repository with the putenv adapter. + * + * @param string|string[] $paths + * @param string|string[]|null $names + * @param bool $shortCircuit + * @param string|null $fileEncoding + * + * @return \DotenvVault\DotenvVault + */ + public static function createUnsafeImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + { + $repository = RepositoryBuilder::createWithDefaultAdapters() + ->addAdapter(PutenvAdapter::class) + ->immutable() + ->make(); + + return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); + } + + public static function create(RepositoryInterface $repository, $paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + { + $builder = $names === null ? StoreBuilder::createWithDefaultName() : StoreBuilder::createWithNoNames(); + + foreach ((array) $paths as $path) { + $builder = $builder->addPath($path); + } + + foreach ((array) $names as $name) { + $builder = $builder->addName($name); + } + + if ($shortCircuit) { + $builder = $builder->shortCircuit(); + } + + return new self($builder->fileEncoding($fileEncoding)->make(), new Parser(), new Loader(), $repository); + } +} diff --git a/tests/DotenvVault/Decrypter/DecrypterTest.php b/tests/DotenvVault/Decrypter/DecrypterTest.php new file mode 100644 index 0000000..c3a1fc9 --- /dev/null +++ b/tests/DotenvVault/Decrypter/DecrypterTest.php @@ -0,0 +1,64 @@ +encrypted = 's7NYXa809k/bVSPwIAmJhPJmEGTtU0hG58hOZy7I0ix6y5HP8LsHBsZCYC/gw5DDFy5DgOcyd18R'; + } + + public function testDecrypterInstanceOf() + { + self::assertInstanceOf(DecrypterInterface::class, new Decrypter()); + } + + public function testFullDecrypt() + { + $keyStr = 'ddcaa26504cd70a6fef9801901c3981538563a1767c297cb8416e8a38c62fe00'; + + $result = (new Decrypter())->decrypt($this->encrypted, $keyStr); + + self::assertEquals($result, "# development@v6\nALPHA=\"zeta\""); + } + + public function testDecryptWhenKeyIsTooShort() + { + $keyStr = 'vlt_tooshort'; + + $this->expectExceptionMessage('INVALID_DOTENV_KEY: It must be 64 characters long (or more)'); + + $result = (new Decrypter())->decrypt($this->encrypted, $keyStr); + + self::assertEquals($result, "# development@v6\nALPHA=\"zeta\""); + } + + public function testDecryptWhenKeyIsWrong() + { + $keyStr = 'AAcaa26504cd70a6fef9801901c3981538563a1767c297cb8416e8a38c62fe00'; + + $this->expectExceptionMessage('DECRYPTION_FAILED: Please check your DOTENV_KEY'); + + $result = (new Decrypter())->decrypt($this->encrypted, $keyStr); + + self::assertEquals($result, "# development@v6\nALPHA=\"zeta\""); + } + + public function testDecryptEmptyEncryptedText() + { + $keyStr = 'ddcaa26504cd70a6fef9801901c3981538563a1767c297cb8416e8a38c62fe00'; + + $this->expectExceptionMessage('MISSING_CIPHERTEXT: It must be a non-empty string'); + + $result = (new Decrypter())->decrypt('', $keyStr); + } +} diff --git a/tests/DotenvVault/DotenvVaultTest.php b/tests/DotenvVault/DotenvVaultTest.php index 0b1807b..340d6e9 100644 --- a/tests/DotenvVault/DotenvVaultTest.php +++ b/tests/DotenvVault/DotenvVaultTest.php @@ -29,7 +29,7 @@ public static function setFolder() self::$folder = \dirname(__DIR__).'/fixtures/env'; } - public function testDotenvThrowsExceptionIfUnableToLoadFile() + public function testThrowsExceptionIfUnableToLoadFile() { $dotenv = DotenvVault::createMutable(__DIR__); @@ -39,10 +39,26 @@ public function testDotenvThrowsExceptionIfUnableToLoadFile() $dotenv->load(); } - public function testDotenvTriesPathsToLoad() + public function testTriesPathsToLoad() { $dotenv = DotenvVault::createMutable([__DIR__, self::$folder]); self::assertCount(4, $dotenv->load()); } + + public function testLoadsFromEnvFile() + { + DotenvVault::createMutable([__DIR__, self::$folder]); + + self::assertEquals($_ENV['FOO'], 'bar'); + self::assertEquals($_ENV['BAR'], 'baz'); + } + + public function testLoadsFromEnvVaultFileWhenDotenvKeyPresent() + { + $_ENV["DOTENV_KEY"] = 'dotenv://:key_ddcaa26504cd70a6fef9801901c3981538563a1767c297cb8416e8a38c62fe00@dotenv.org/vault/.env.vault?environment=development'; + + DotenvVault::createMutable([__DIR__, self::$folder]); + + self::assertEquals($_ENV['ALPA'], 'zeta'); + } } -?> From 3ea6a92f4466428f89dfb39cb91efbe1ffffc1d8 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 3 Nov 2023 21:08:36 -0700 Subject: [PATCH 3/5] Cleaned up --- .github/workflows/ci.yml | 2 +- DEVELOPMENT.md | 2 +- README.md | 2 +- src/DotenvVault.php | 275 +++++++++++++++++++++++++- src/DotenvVaultBackup.php | 193 ------------------ tests/DotenvVault/DotenvVaultTest.php | 17 +- 6 files changed, 283 insertions(+), 208 deletions(-) delete mode 100644 src/DotenvVaultBackup.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1169991..fcbc29b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: timeout_minutes: 5 max_attempts: 5 command: composer update --no-interaction --no-progress - - run: vendor/bin/phpunit --testdox + - run: vendor/bin/phpunit --display-deprecations diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b84ca90..e67b365 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -5,7 +5,7 @@ Tests use PHPUnit. ``` -./vendor/bin/phpunit +./vendor/bin/phpunit --testdox --display-deprecations ``` ## Publishing diff --git a/README.md b/README.md index 92f14c9..31bbede 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ As early as possible in your application bootstrap process, load .env: require 'vendor/autoload.php'; $dotenv = DotenvVault\DotenvVault::createImmutable(__DIR__); -$dotenv->load(); +$dotenv->safeLoad(); ``` When your application loads, these variables will be available in `$_ENV` or `$_SERVER`: diff --git a/src/DotenvVault.php b/src/DotenvVault.php index 4b9f9ba..49372d6 100644 --- a/src/DotenvVault.php +++ b/src/DotenvVault.php @@ -5,12 +5,279 @@ namespace DotenvVault; use Dotenv\Dotenv; +use Dotenv\Loader\Loader; +use Dotenv\Loader\LoaderInterface; +use Dotenv\Parser\Parser; +use Dotenv\Parser\ParserInterface; +use Dotenv\Repository\RepositoryBuilder; +use Dotenv\Repository\RepositoryInterface; +use Dotenv\Store\StoreBuilder; +use Dotenv\Store\StoreInterface; +use Dotenv\Util\Str; +use PhpOption\Option; use Exception; -class DotenvVault { - public static function __callStatic($name, $arguments) { - // Forward the call to the Dotenv\Dotenv class - return call_user_func_array(['Dotenv\Dotenv', $name], $arguments); +use DotenvVault\Decrypter\Decrypter; +use DotenvVault\Decrypter\DecrypterInterface; + +class DotenvVault extends Dotenv { + /** + * Keep track of the original paths + * $paths string|string[] + */ + private $paths; + + private $store; + private $parser; + private $loader; + private $repository; + + public function __construct( + StoreInterface $store, + ParserInterface $parser, + LoaderInterface $loader, + RepositoryInterface $repository, + $paths = null + ) + { + $this->store = $store; + $this->parser = $parser; + $this->loader = $loader; + $this->repository = $repository; + $this->paths = $paths; + } + + /** + * Create a new dotenv instance. + * + * @param \Dotenv\Repository\RepositoryInterface $repository + * @param string|string[] $paths + * @param string|string[]|null $names + * @param bool $shortCircuit + * @param string|null $fileEncoding + * + * @return \Dotenv\Dotenv + */ + public static function create(RepositoryInterface $repository, $paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + { + $builder = $names === null ? StoreBuilder::createWithDefaultName() : StoreBuilder::createWithNoNames(); + + foreach ((array) $paths as $path) { + $builder = $builder->addPath($path); + } + + foreach ((array) $names as $name) { + $builder = $builder->addName($name); + } + + if ($shortCircuit) { + $builder = $builder->shortCircuit(); + } + + return new self($builder->fileEncoding($fileEncoding)->make(), new Parser(), new Loader(), $repository, $paths); + } + + /** + * Create a new immutable dotenvVault instance with default repository. + * + * @param string|string[] $paths + * @param string|string[]|null $names + * @param bool $shortCircuit + * @param string|null $fileEncoding + * + * @return \DotenvVault\DotenvVault + */ + public static function createImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + { + $repository = RepositoryBuilder::createWithDefaultAdapters()->immutable()->make(); + + return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); + } + + /** + * Create a new immutable dotenvVault instance with default repository with the putenv adapter. + * + * @param string|string[] $paths + * @param string|string[]|null $names + * @param bool $shortCircuit + * @param string|null $fileEncoding + * + * @return \DotenvVault\DotenvVault + */ + public static function createUnsafeImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + { + $repository = RepositoryBuilder::createWithDefaultAdapters() + ->addAdapter(PutenvAdapter::class) + ->immutable() + ->make(); + + return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); + } + + public function _loadDotenv() + { + $entries = $this->parser->parse($this->store->read()); + + return $this->loader->load($this->repository, $entries); + } + + public function load() + { + $vaultPath = $this->_vaultPath(); + + // fallback to original dotenv if DOTENV_KEY is not set + if (strlen($this->_dotenvKey()) === 0) { + return $this->_loadDotenv(); + } + + // // dotenvKey exists but .env.vault file does not exist + if (!$vaultPath || !file_exists($vaultPath)) { + trigger_error('You set DOTENV_KEY but you are missing a .env.vault file at ' . $vaultPath . '. Did you forget to build it?', E_USER_WARNING); + + return $this->_loadDotenv(); + } + + $this->_loadVault(); + } + + public function _loadVault() { + // _log('Loading env from encrypted .env.vault') + + $decrypted = $this->_decryptVault(); + + $vaultEntries = $this->parser->parse($decrypted); + + return $this->loader->load($this->repository, $vaultEntries); + } + + public function _decryptVault() + { + $vaultPath = $this->_vaultPath(); + + // .env.vault as string + $content = file_get_contents($vaultPath); + + // Parse .env.vault file with Dotenv parser + $entries = (new Parser())->parse($content); + + // built DOTENV_${ENVIRONMENT} lookups + $lookups = []; + foreach ($entries as $entry) { + $key = $entry->getName(); + $value = $entry->getValue()->get()->getChars(); // really ugly api here from the original phpdotenv lib. + + $lookups[$key] = $value; + } + + // handle scenario for comma separated keys - for use with key rotation + // example: DOTENV_KEY="dotenv://:key_1234@dotenv.org/vault/.env.vault?environment=prod,dotenv://:key_7890@dotenv.org/vault/.env.vault?environment=prod" + $keys = explode(',', $this->_dotenvKey()); + + $decrypted = null; + + for ($i = 0; $i < count($keys); $i++) { + try { + // Get full key + $key = trim($keys[$i]); + + // Get instructions for decrypt + $attrs = $this->_instructions($lookups, $key); + + // Decrypt + $decrypted = (new Decrypter())->decrypt($attrs['ciphertext'], $attrs['key']); + + // If successful, break the loop + break; + } catch (Exception $error) { + // if last key + if ($i + 1 >= count($keys)) { + // rethrow the exception + throw $error; + } + // Otherwise, the loop will continue to the next key + } + + } + + return $decrypted; + + // Parse contents of decrypted DOTENV_VAULT_${ENVIRONMENT} + // $vaultEntries = (new Parser())->parse($decrypted); + + // return $vaultEntries; + } + + public function _instructions($lookups, $dotenvKey) { + // Parse DOTENV_KEY. Format is a URI + $uri = parse_url($dotenvKey); + if ($uri === false) { + throw new Exception('INVALID_DOTENV_KEY: Wrong format. Must be in valid URI format like dotenv://key_1234@dotenv.org/vault/.env.vault?environment=development'); + } + + // Get decrypt key + $key = $uri['pass'] ?? null; + if (!$key) { + throw new Exception('INVALID_DOTENV_KEY: Missing key part'); + } + + // Get environment + parse_str($uri['query'], $queryParams); + $environment = $queryParams['environment'] ?? null; + if (!$environment) { + throw new Exception('INVALID_DOTENV_KEY: Missing environment part'); + } + + // Get ciphertext payload + $environmentKey = 'DOTENV_VAULT_' . strtoupper($environment); + $ciphertext = $lookups[$environmentKey] ?? null; + if (!$ciphertext) { + throw new Exception("NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment {$environmentKey} in your .env.vault file."); + } + + return ['ciphertext' => $ciphertext, 'key' => $key]; + } + + public function _dotenvKey() + { + $dotenv_key = $_ENV['DOTENV_KEY'] ?? $_SERVER['DOTENV_KEY'] ?? getenv('DOTENV_KEY'); + + // infra already contains a DOTENV_KEY environment variable + if ($dotenv_key && strlen($dotenv_key) > 0) { + return $dotenv_key; + } + + // fallback to empty string + return ''; + } + + public function _vaultPath() + { + $dotenvVaultPath = null; + + foreach ($this->paths as $dotenvPath) { + // Check if the path ends with '.vault'. If not, append '.vault' to the path. + $dotenvPath .= '/.env.vault'; + + // check if .env.vault exists + if (file_exists($dotenvPath)) { + $dotenvVaultPath = $dotenvPath; + break; + } + } + + return $dotenvVaultPath; + } + + public function _readFromFile(string $path, string $encoding = null) + { + /** @var Option */ + $content = Option::fromValue(@\file_get_contents($path), false); + + return $content->flatMap(static function (string $content) use ($encoding) { + return Str::utf8($content, $encoding)->mapError(static function (string $error) { + throw new Exception($error); + })->success(); + }); } // public function load() diff --git a/src/DotenvVaultBackup.php b/src/DotenvVaultBackup.php deleted file mode 100644 index 68ebbfb..0000000 --- a/src/DotenvVaultBackup.php +++ /dev/null @@ -1,193 +0,0 @@ -store = $store; - $this->parser = $parser; - $this->loader = $loader; - $this->repository = $repository; - } - - public function load() - { - $this->dotenv_key = getenv("DOTENV_KEY"); - if ($this->dotenv_key !== false){ - - $entries = $this->parser->parse($this->store->read()); - $this->loader->load($this->repository, $entries); - - $plaintext = $this->parse_vault(); - - // parsing plaintext and loading to getenv - $vault_entries = $this->parser->parse($plaintext); - return $this->loader->load($this->repository, $vault_entries); - } - else { - $entries = $this->parser->parse($this->store->read()); - - return $this->loader->load($this->repository, $entries); - } - } - - public function parse_vault() - { - $dotenv_keys = explode(',', $this->dotenv_key); - $keys = array(); - foreach($dotenv_keys as $key) - { - // parse DOTENV_KEY, format is a URI. - $uri = parse_url(trim($key)); - - // get encrypted key - $pass = $uri['pass']; - - // Get environment from query params. - parse_str($uri['query'], $params); - $vault_environment = $params['environment'] or throw new DotEnvVaultError('INVALID_DOTENV_KEY: Missing environment part.'); - - # Getting ciphertext from correct environment in .env.vault - $vault_environment = strtoupper($vault_environment); - $environment_key = "DOTENV_VAULT_{$vault_environment}"; - - $ciphertext = getenv("{$environment_key}") or throw new DotEnvVaultError("NOT_FOUND_DOTENV_ENVIRONMENT: Cannot locate environment {$environment_key} in your .env.vault file. Run 'npx dotenv-vault build' to include it."); - - array_push($keys, array('encrypted_key' => $pass, 'ciphertext' => $ciphertext)); - } - return $this->key_rotation($keys); - } - - private function key_rotation($keys){ - $count = count($keys); - foreach($keys as $index=>$value) { - $decrypt = $this->decrypt($value['ciphertext'], $value['encrypted_key']); - - if ($decrypt == false && $index + 1 >= $count){ - throw new DotEnvVaultError('INVALID_DOTENV_KEY: Key must be valid.'); - } - elseif($decrypt == false){ - continue; - } - else{ - return $decrypt; - } - } - } - - public static function decrypt($encrypted, $keyStr) - { - // grab last 64 to permit keys like vlt_64 or custom_64 - $last64 = substr($keyStr, -64); - - if (strlen($last64) !== 64) { - $msg = 'INVALID_DOTENV_KEY: It must be 64 characters long (or more)'; - throw new Exception($msg); - } - - // check key length is good INVALID_DOTENV_KEY: It must be 64 characters long (or more) - $key = hex2bin($last64); - - // base64 decode - $decoded = base64_decode($encrypted, true); - - // determine cipher and pull out nonce and tag - $ciphertext = substr($decoded, 12, -16); - $nonce = substr($decoded, 0, 12); - $tag = substr($decoded, -16); - - try { - $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag); - - if ($plaintext === false) { - $msg = 'DECRYPTION_FAILED: Please check your DOTENV_KEY'; - throw new Exception($msg); - } else { - return $plaintext; - } - } catch (ExceptionType $e) { - $msg = 'DECRYPTION_FAILED: Please check your DOTENV_KEY'; - throw new Exception($msg); - } - } - - /** - * Create a new immutable dotenvVault instance with default repository. - * - * @param string|string[] $paths - * @param string|string[]|null $names - * @param bool $shortCircuit - * @param string|null $fileEncoding - * - * @return \DotenvVault\DotenvVault - */ - public static function createImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) - { - $repository = RepositoryBuilder::createWithDefaultAdapters()->immutable()->make(); - - return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); - } - - /** - * Create a new immutable dotenvVault instance with default repository with the putenv adapter. - * - * @param string|string[] $paths - * @param string|string[]|null $names - * @param bool $shortCircuit - * @param string|null $fileEncoding - * - * @return \DotenvVault\DotenvVault - */ - public static function createUnsafeImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) - { - $repository = RepositoryBuilder::createWithDefaultAdapters() - ->addAdapter(PutenvAdapter::class) - ->immutable() - ->make(); - - return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); - } - - public static function create(RepositoryInterface $repository, $paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) - { - $builder = $names === null ? StoreBuilder::createWithDefaultName() : StoreBuilder::createWithNoNames(); - - foreach ((array) $paths as $path) { - $builder = $builder->addPath($path); - } - - foreach ((array) $names as $name) { - $builder = $builder->addName($name); - } - - if ($shortCircuit) { - $builder = $builder->shortCircuit(); - } - - return new self($builder->fileEncoding($fileEncoding)->make(), new Parser(), new Loader(), $repository); - } -} diff --git a/tests/DotenvVault/DotenvVaultTest.php b/tests/DotenvVault/DotenvVaultTest.php index 340d6e9..052574b 100644 --- a/tests/DotenvVault/DotenvVaultTest.php +++ b/tests/DotenvVault/DotenvVaultTest.php @@ -31,23 +31,23 @@ public static function setFolder() public function testThrowsExceptionIfUnableToLoadFile() { - $dotenv = DotenvVault::createMutable(__DIR__); + $dotenvVault = DotenvVault::createMutable(__DIR__); - $this->expectException(Dotenv\Exception\InvalidPathException::class); $this->expectExceptionMessage('Unable to read any of the environment file(s) at'); - $dotenv->load(); + $dotenvVault->load(); } public function testTriesPathsToLoad() { - $dotenv = DotenvVault::createMutable([__DIR__, self::$folder]); - self::assertCount(4, $dotenv->load()); + $dotenvVault = DotenvVault::createMutable([__DIR__, self::$folder]); + self::assertCount(4, $dotenvVault->load()); } public function testLoadsFromEnvFile() { - DotenvVault::createMutable([__DIR__, self::$folder]); + $dotenvVault = DotenvVault::createImmutable([__DIR__, self::$folder]); + $dotenvVault->load(); self::assertEquals($_ENV['FOO'], 'bar'); self::assertEquals($_ENV['BAR'], 'baz'); @@ -57,8 +57,9 @@ public function testLoadsFromEnvVaultFileWhenDotenvKeyPresent() { $_ENV["DOTENV_KEY"] = 'dotenv://:key_ddcaa26504cd70a6fef9801901c3981538563a1767c297cb8416e8a38c62fe00@dotenv.org/vault/.env.vault?environment=development'; - DotenvVault::createMutable([__DIR__, self::$folder]); + $dotenvVault = DotenvVault::createImmutable([__DIR__, self::$folder]); + $dotenvVault->load(); - self::assertEquals($_ENV['ALPA'], 'zeta'); + self::assertEquals($_ENV['ALPHA'], 'zeta'); } } From bf21fd5b0804bb66b541795e548f5d2792be29b0 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 3 Nov 2023 21:12:32 -0700 Subject: [PATCH 4/5] Include .env files for tests --- .gitignore | 8 +++----- tests/fixtures/env/.env | 5 +++++ tests/fixtures/env/.env.vault | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/env/.env create mode 100644 tests/fixtures/env/.env.vault diff --git a/.gitignore b/.gitignore index 290276a..e811ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,7 @@ - -# Environments -.env +.env* .venv -.env.vault -.env.me +!.env.vault +!tests/**/.env* .phpunit.result.cache composer.lock diff --git a/tests/fixtures/env/.env b/tests/fixtures/env/.env new file mode 100644 index 0000000..3a006e1 --- /dev/null +++ b/tests/fixtures/env/.env @@ -0,0 +1,5 @@ +FOO=bar +BAR=baz +SPACED="with spaces" + +NULL= diff --git a/tests/fixtures/env/.env.vault b/tests/fixtures/env/.env.vault new file mode 100644 index 0000000..19bdd46 --- /dev/null +++ b/tests/fixtures/env/.env.vault @@ -0,0 +1 @@ +DOTENV_VAULT_DEVELOPMENT='s7NYXa809k/bVSPwIAmJhPJmEGTtU0hG58hOZy7I0ix6y5HP8LsHBsZCYC/gw5DDFy5DgOcyd18R' From 87d2365da715e2b46c80a9e904f57255ed92eba5 Mon Sep 17 00:00:00 2001 From: Scott Motte Date: Fri, 3 Nov 2023 21:17:26 -0700 Subject: [PATCH 5/5] Modify tests --- .github/workflows/ci.yml | 2 +- composer.json | 2 +- phpunit.xml.dist | 13 ++++++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcbc29b..584bb1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: timeout_minutes: 5 max_attempts: 5 command: composer update --no-interaction --no-progress - - run: vendor/bin/phpunit --display-deprecations + - run: vendor/bin/phpunit diff --git a/composer.json b/composer.json index 2cb677b..35f6655 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,6 @@ "vlucas/phpdotenv": "^5.5" }, "require-dev": { - "phpunit/phpunit": "^10.4" + "phpunit/phpunit": "^9.0|^8.0|^7.0|6.0" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 020141b..4237315 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,14 +1,13 @@ - - + + + + ./src + + ./tests - - - ./src - -