diff --git a/ci.yml b/.github/workflows/ci.yml similarity index 100% rename from ci.yml rename to .github/workflows/ci.yml 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/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/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 - - 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; + $this->paths = $paths; } - public function load() + /** + * 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) { - $this->dotenv_key = getenv("DOTENV_KEY"); - if ($this->dotenv_key !== false){ - - $entries = $this->parser->parse($this->store->read()); - $this->loader->load($this->repository, $entries); + $builder = $names === null ? StoreBuilder::createWithDefaultName() : StoreBuilder::createWithNoNames(); - $plaintext = $this->parse_vault(); + foreach ((array) $paths as $path) { + $builder = $builder->addPath($path); + } - // parsing plaintext and loading to getenv - $vault_entries = $this->parser->parse($plaintext); - $this->loader->load($this->repository, $vault_entries); + foreach ((array) $names as $name) { + $builder = $builder->addName($name); } - else { - $entries = $this->parser->parse($this->store->read()); - return $this->loader->load($this->repository, $entries); + 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); } - public function parse_vault() + /** + * 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) { - $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); + $repository = RepositoryBuilder::createWithDefaultAdapters() + ->addAdapter(PutenvAdapter::class) + ->immutable() + ->make(); + + return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); } - private function key_rotation($keys){ - $count = count($keys); - foreach($keys as $index=>$value) { - $decrypt = $this->decrypt($value['ciphertext'], $value['encrypted_key']); + public function _loadDotenv() + { + $entries = $this->parser->parse($this->store->read()); - if ($decrypt == false && $index + 1 >= $count){ - throw new DotEnvVaultError('INVALID_DOTENV_KEY: Key must be valid.'); - } - elseif($decrypt == false){ - continue; - } - else{ - return $decrypt; - } - } + return $this->loader->load($this->repository, $entries); } - private function decrypt($data, $secret) + public function load() { - $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; + $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 static function createImmutable($paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) - { - $repository = RepositoryBuilder::createWithDefaultAdapters()->immutable()->make(); + public function _loadVault() { + // _log('Loading env from encrypted .env.vault') - return self::create($repository, $paths, $names, $shortCircuit, $fileEncoding); + $decrypted = $this->_decryptVault(); + + $vaultEntries = $this->parser->parse($decrypted); + + return $this->loader->load($this->repository, $vaultEntries); } - public static function create(RepositoryInterface $repository, $paths, $names = null, bool $shortCircuit = true, string $fileEncoding = null) + public function _decryptVault() { - $builder = $names === null ? StoreBuilder::createWithDefaultName() : StoreBuilder::createWithNoNames(); + $vaultPath = $this->_vaultPath(); - foreach ((array) $paths as $path) { - $builder = $builder->addPath($path); + // .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; } - foreach ((array) $names as $name) { - $builder = $builder->addName($name); + // 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 + } + } - if ($shortCircuit) { - $builder = $builder->shortCircuit(); + 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 new self($builder->fileEncoding($fileEncoding)->make(), new Parser(), new Loader(), $repository); + 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() + // { + // $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/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 67953e5..052574b 100644 --- a/tests/DotenvVault/DotenvVaultTest.php +++ b/tests/DotenvVault/DotenvVaultTest.php @@ -29,14 +29,37 @@ public static function setFolder() self::$folder = \dirname(__DIR__).'/fixtures/env'; } - public function testDotenvThrowsExceptionIfUnableToLoadFile() + 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() + { + $dotenvVault = DotenvVault::createMutable([__DIR__, self::$folder]); + self::assertCount(4, $dotenvVault->load()); + } + + public function testLoadsFromEnvFile() + { + $dotenvVault = DotenvVault::createImmutable([__DIR__, self::$folder]); + $dotenvVault->load(); + + 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 = DotenvVault::createImmutable([__DIR__, self::$folder]); + $dotenvVault->load(); + + self::assertEquals($_ENV['ALPHA'], 'zeta'); } } -?> 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'