diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index ca2197d..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: dependabot-auto-merge -on: pull_request_target - -permissions: - pull-requests: write - contents: write - -jobs: - dependabot: - runs-on: ubuntu-latest - if: ${{ github.actor == 'dependabot[bot]' }} - steps: - - - name: Dependabot metadata - id: metadata - uses: dependabot/fetch-metadata@v1.6.0 - with: - github-token: "${{ secrets.GITHUB_TOKEN }}" - - - name: Auto-merge Dependabot PRs for semver-minor updates - if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - - name: Auto-merge Dependabot PRs for semver-patch updates - if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} - run: gh pr merge --auto --merge "$PR_URL" - env: - PR_URL: ${{github.event.pull_request.html_url}} - GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/php-cs-fixer.yml b/.github/workflows/php-cs-fixer.yml deleted file mode 100644 index 0672942..0000000 --- a/.github/workflows/php-cs-fixer.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Check & fix styling - -on: [push] - -jobs: - php-cs-fixer: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - ref: ${{ github.head_ref }} - - - name: Run PHP CS Fixer - uses: docker://oskarstark/php-cs-fixer-ga - with: - args: --config=.php_cs.dist.php --allow-risky=yes - - - name: Commit changes - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: Fix styling diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml deleted file mode 100644 index 977b975..0000000 --- a/.github/workflows/phpstan.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: PHPStan - -on: - push: - paths: - - '**.php' - - 'phpstan.neon.dist' - -jobs: - phpstan: - name: phpstan - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.0' - coverage: none - - - name: Install composer dependencies - uses: ramsey/composer-install@v1 - - - name: Run PHPStan - run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 39ff7ee..246a17a 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,47 +1,60 @@ -name: run-tests - +name: Tests on: push: - branches: [main] + branches: + - main pull_request: - branches: [main] - + branches: + - main + workflow_dispatch: jobs: - test: - runs-on: ${{ matrix.os }} + # Unit tests back (phpunit) + laravel-tests: + runs-on: ubuntu-latest strategy: - fail-fast: true matrix: - os: [ubuntu-latest, windows-latest] - php: [8.1] - laravel: [9.*] - stability: [prefer-lowest, prefer-stable] include: - - laravel: 9.* - testbench: 7.* - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - + - php: 8.3 + env: + LARAVEL: 11.* + TESTBENCH: 9.* + - php: 8.4 + env: + LARAVEL: 11.* + TESTBENCH: 9.* + - php: 8.4 + env: + LARAVEL: 12.* + TESTBENCH: 10.* + env: ${{ matrix.env }} + name: P${{ matrix.php }} - L${{ matrix.env.LARAVEL }} - TB${{ matrix.env.TESTBENCH }} steps: - - name: Checkout code - uses: actions/checkout@v3 - + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - coverage: none - - - name: Setup problem matchers - run: | - echo "::add-matcher::${{ runner.tool_cache }}/php.json" - echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - - name: Install dependencies + extensions: mbstring, dom, fileinfo, mysql + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install Composer dependencies run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: vendor/bin/pest + composer require "laravel/framework:${LARAVEL}" "orchestra/testbench:${TESTBENCH}" --no-interaction --no-update --prefer-dist + composer update --prefer-stable --prefer-dist --no-interaction + - name: Execute tests (Unit and Feature tests) via PHPUnit + run: ./vendor/bin/pest + - uses: 8398a7/action-slack@v3 + if: failure() && (github.base_ref == 'main' || inputs.is-package) + with: + status: ${{ job.status }} + fields: job, message, author, repo + env: + MATRIX_CONTEXT: ${{ toJson(matrix) }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml deleted file mode 100644 index b20f3b6..0000000 --- a/.github/workflows/update-changelog.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: "Update Changelog" - -on: - release: - types: [released] - -jobs: - update: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - with: - ref: main - - - name: Update Changelog - uses: stefanzweifel/changelog-updater-action@v1 - with: - latest-version: ${{ github.event.release.name }} - release-notes: ${{ github.event.release.body }} - - - name: Commit updated CHANGELOG - uses: stefanzweifel/git-auto-commit-action@v4 - with: - branch: main - commit_message: Update CHANGELOG - file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore index 9a43686..1e6806a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ testbench.yaml vendor node_modules .php-cs-fixer.cache +.phpunit.cache/ diff --git a/README.md b/README.md index 5dd9436..829c361 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,17 @@ +# Occulta + ## Purpose +Save a versioned and encrypted copy of .env on a storage disk (eg: S3) -Save a versioned and encrypted copy of .env on aws s3 +## How it works +Occulta uses [AWS KMS](https://aws.amazon.com/kms/) and [Envelope encryption strategy](https://docs.aws.amazon.com/kms/latest/developerguide/kms-cryptography.html#enveloping) to encrypt your `.env` file and store it on a given laravel disk (eg: S3). +It also keeps a versioned history of your encrypted `.env` files, so you can restore previous versions if needed. +
+Occulta will create an archive containing your encrypted environment file and an encrypted key file, which will be used by occulta to decrypt your env when needed. -## Installation -This package requires Laravel 8.x or higher. +## Installation +This package requires Laravel 11.x or higher, php's extensions openssl and zip. You can install the package via composer: @@ -15,17 +22,26 @@ composer require code16/occulta Next you should publish the config file : ```bash -php artisan vendor:publish --provider="Code16\Occulta\OccultaServiceProvider" +php artisan vendor:publish --tag=occulta-config ``` and setup your values (especially the kms `key_id` and `destination disk`) in your `config/occulta.php` file : ```php - - 'key_id' => '0904c439-ff1f-4e9d-8a26-4e32ced6fe0x', - - 'destination_disk' => 's3_backup', -]; +return [ + // kms key id as seen in aws's kms dashboard (usually it looks like an uuid) + 'key_id' => '0904c439-ff1f-4e9d-8a26-4e32ced6fe0x', + + [...] + + 'destination_disk' => 's3_backup', + 'destination_path' => null, // defaults to 'dotenv/' + + // If you want to backup an env file with a suffix such as .env.production, you can set this to your desired suffix + 'env_suffix' => null, // eg: 'production' + + [...] + ]; ``` Then, you should setup credentials to the proper aws user [allowed](https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html#key-policy-default-allow-users) to "use" the given kms key, by adding a kms section in your `config/services.php` file : @@ -38,7 +54,7 @@ Then, you should setup credentials to the proper aws user [allowed](https://docs ], ``` -Nom you should schedule tasks for backup and cleanup in `app/Console/Kernel.php` : +Now you should schedule tasks for backup and cleanup in `app/Console/Kernel.php` (`bootstrap/app.php` since Laravel 11) : ```php protected function schedule(Schedule $schedule) @@ -47,3 +63,33 @@ Nom you should schedule tasks for backup and cleanup in `app/Console/Kernel.php` $schedule->command('occulta:clean')->dailyAt('02:00'); } ``` + +### Decrypting an encrypted env archive +If you need to decrypt an encrypted env archive, you can use the `occulta:decrypt` command: + +```bash +php artisan occulta:decrypt path/to/encrypted/archive.zip +``` + +Occulta will use your KMS configuration and AWS access and secret keys to decrypt your env file. +
+> [!IMPORTANT] +> It is likely that these credentials where in your lost .env, then, you can follow the [recovery procedure](docs/RECOVERY.md) to restore your environment. + + +## Testing + +The package comes with a comprehensive test suite. To run the tests, you can use the following command: + +```bash +composer test +``` + +The tests cover: + +- The main `Occulta` class functionality for encrypting and decrypting values and files +- The `EncryptFileWithKmsCommand` for encrypting .env files and storing them +- The `DecryptFileWithKmsCommand` for extracting and decrypting .env files from zip archives +- The `CleanupEncryptedDotenvsCommand` for managing the history of encrypted .env files + +The tests use mocks for AWS KMS to avoid actual AWS calls during testing. diff --git a/composer.json b/composer.json index 174d325..562fac0 100644 --- a/composer.json +++ b/composer.json @@ -13,21 +13,30 @@ "name": "Arnaud Becher", "email": "arnaud.becher@gmail.com", "role": "Developer" + }, + { + "name": "Lucien Puget", + "email": "lucien.puget@code16.fr", + "role": "Developer" } ], "require": { - "php": "^8.1", - "spatie/laravel-package-tools": "^1.9.2", + "php": "^8.3", + "ext-openssl": "*", + "ext-zip": "*", + "ext-zlib": "*", "aws/aws-sdk-php": "^3.222", - "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", - "ext-zlib": "*" + "illuminate/contracts": "^11.0|^12.0", + "laravel/prompts": "^0.3.5", + "spatie/laravel-package-tools": "^1.9.2" }, "require-dev": { - "nunomaduro/collision": "^6.0|^7.0|^8.0", - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", - "pestphp/pest": "^1.21|^3.0", - "pestphp/pest-plugin-laravel": "^1.1|^3.0", - "phpunit/phpunit": "^9.5|^10.0|^11.0", + "code16/pint-config": "^1.2", + "nunomaduro/collision": "^8.0", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpunit/phpunit": "^10.0|^11.0", "spatie/laravel-ray": "^1.26" }, "autoload": { diff --git a/config/occulta.php b/config/occulta.php index 09f9363..ffb5433 100644 --- a/config/occulta.php +++ b/config/occulta.php @@ -1,7 +1,8 @@ '', // Associative array of custom encryption's context @@ -10,9 +11,11 @@ // 'my_secret_key' => 'my_secret_value' ], - 'should_compress' => env('OCCULTA_SHOULD_COMPRESS', false), - 'destination_disk' => '', + 'destination_path' => 'dotenv/', + + // If you want to backup an env file with a suffix such as .env.production, you can set this to your desired suffix + 'env_suffix' => null, // eg: 'production' 'number_of_encrypted_dotenv_to_keep_when_cleaning_up' => env('NUMBER_OF_ENCRYPTED_DOTENV_TO_KEEP_WHEN_CLEANING_UP', 7), diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md new file mode 100644 index 0000000..f85afce --- /dev/null +++ b/docs/RECOVERY.md @@ -0,0 +1,27 @@ +# Recovery procedure +## What to do when you have fully lost your environment and want to recover it with Occulta. +To restore your environment using Occulta, you will need to have an access to your KMS key. +A good practice is to store your AWS access key and secret in your `.env`, the one you just lost... + +## Get AWS credentials +Access AWS dashboard and go to your project user's settings : +![aws-user-dashboard.png](aws-user-dashboard.png) + +If you don't have a copy elsewhere of your "Access Key 1" secret, you will have to create a new one. +![aws-user-dashboard-new-credentials.png](aws-user-dashboard-new-credentials.png) +(Follow [AWS procedure](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) to create a new access key) + +Keep the access key and secret that you just created in a safe place, you will need them to restore your environment. + +## Get Your KMS key ID and region +![aws-kms-key.png](aws-kms-key.png) +You can access your KMS key in the AWS dashboard, under "Key Management Service" > "Customer managed keys". +You will also need the region (in the top right corner of the dashboard) if you had set it in your environment file. + +## Restore your environment +You can now restore your environment by running the following command: +```bash +php artisan occulta:decrypt path/to/your/encrypted/archive.zip +``` + +Occulta will prompt you for your KMS Key ID, AWS access key and secret, and eventually region, then it will decrypt the archive and restore your environment as a `.env.decrypted` file. diff --git a/docs/aws-kms-key.png b/docs/aws-kms-key.png new file mode 100644 index 0000000..d8b993e Binary files /dev/null and b/docs/aws-kms-key.png differ diff --git a/docs/aws-user-dashboard-new-credentials.png b/docs/aws-user-dashboard-new-credentials.png new file mode 100644 index 0000000..5f42dc9 Binary files /dev/null and b/docs/aws-user-dashboard-new-credentials.png differ diff --git a/docs/aws-user-dashboard.png b/docs/aws-user-dashboard.png new file mode 100644 index 0000000..dcc34f7 Binary files /dev/null and b/docs/aws-user-dashboard.png differ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2cb9465..99091ba 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,39 +1,20 @@ - - - - tests - - - - - ./src - - - - - - - - - - + + + + tests + + + + + + + + + + + + ./src + + diff --git a/pint.json b/pint.json new file mode 120000 index 0000000..13204dd --- /dev/null +++ b/pint.json @@ -0,0 +1 @@ +vendor/code16/pint-config/pint.json \ No newline at end of file diff --git a/src/Commands/CleanupEncryptedDotenvsCommand.php b/src/Commands/CleanupEncryptedDotenvsCommand.php index 68f1b80..eaf7a19 100644 --- a/src/Commands/CleanupEncryptedDotenvsCommand.php +++ b/src/Commands/CleanupEncryptedDotenvsCommand.php @@ -16,7 +16,7 @@ public function handle(): int collect( Storage::disk( config('occulta.destination_disk') - )->files('dotenv/') + )->files(config('occulta.destination_path', 'dotenv/')) ) ->sort() ->slice(0, -1 * config('occulta.number_of_encrypted_dotenv_to_keep_when_cleaning_up')) diff --git a/src/Commands/DecryptFileWithKmsCommand.php b/src/Commands/DecryptFileWithKmsCommand.php new file mode 100644 index 0000000..1662a38 --- /dev/null +++ b/src/Commands/DecryptFileWithKmsCommand.php @@ -0,0 +1,141 @@ +argument('encryptedEnvZipPath'); + + if (!file_exists($zipPath)) { + $this->error("The specified zip file does not exist: {$zipPath}"); + + return self::FAILURE; + } + + if (!config('services.kms.key') || !config('services.kms.secret')) { + if (!config('occulta.key_id')) { + $kmsKeyId = text( + label: 'Please enter your KMS key id.', + placeholder: 'eg: 00264cd4-bf98-4b42-958b-496e7bbae7e6' + ); + config()->set('occulta.key_id', $kmsKeyId); + } + + $kmsAccessKey = text( + label: 'Please enter an AWS access key for a user with KMS decrypt permissions on your KMS key.', + placeholder: 'eg: AKIAIOSFODNN7EXAMPLE' + ); + + $kmsAccessSecret = text( + label: 'Please enter the AWS secret key corresponding to your access key.', + placeholder: 'eg: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' + ); + + if (!config('services.kms.region')) { + $kmsKeyRegion = text( + label: 'Please enter the AWS region corresponding to your key.', + placeholder: 'eg: eu-central-1' + ); + config()->set('services.kms.region', $kmsKeyRegion); + } + + config()->set('services.kms.key', $kmsAccessKey); + config()->set('services.kms.secret', $kmsAccessSecret); + } + + $zip = new ZipArchive(); + $files = []; + + if ($zip->open($zipPath) === true) { + if ($zip->numFiles !== 2) { + $this->error('The zip file must contain exactly two files: the encrypted .env and the key.'); + + return self::FAILURE; + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $files[] = $zip->getNameIndex($i); + } + + $zip->extractTo(base_path()); + $zip->close(); + + $this->info('Extraction completed successfully.'); + } else { + $this->error('Failed to open ZIP file.'); + + return self::FAILURE; + } + + $envFileName = ''; + $keyFileName = ''; + foreach ($files as $file) { + if (str($file)->startsWith('.env')) { + $envFileName = $file; + } elseif ($file === 'key.encrypted') { + $keyFileName = $file; + } + } + + if ($envFileName == '' || $keyFileName == '') { + $this->error("The zip file must contain an encrypted .env file and a key file named 'key.encrypted'."); + $this->cleanArtefacts($envFileName); + + return self::FAILURE; + } + + $envFilePath = base_path($envFileName); + $keyFilePath = base_path($keyFileName); + + if (!file_exists($envFilePath) || !file_exists($keyFilePath)) { + $this->error('The required files were not found after extraction.'); + $this->cleanArtefacts($envFileName); + + return self::FAILURE; + } + + $encryptedKeyBase64 = file_get_contents($keyFilePath); + $ciphertextBlob = base64_decode($encryptedKeyBase64); + + try { + $outputPath = $service->decrypt($ciphertextBlob, $envFilePath); + } catch (\Throwable $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } finally { + $this->cleanArtefacts($envFileName); + } + + $this->info("Decrypted ! Env located at : {$outputPath}"); + + return self::SUCCESS; + } + + private function cleanArtefacts($envFileName): void + { + $envFile = $envFileName ? base_path($envFileName) : base_path('.env.encrypted'); + $keyFile = base_path('key.encrypted'); + + if (file_exists($envFile)) { + unlink($envFile); + } + + if (file_exists($keyFile)) { + unlink($keyFile); + } + } +} diff --git a/src/Commands/EncryptDotenvWithKmsCommand.php b/src/Commands/EncryptDotenvWithKmsCommand.php deleted file mode 100644 index 39248d8..0000000 --- a/src/Commands/EncryptDotenvWithKmsCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -put( - 'dotenv/' . Carbon::now()->format('YmdHis') . '.env.kms', - $service->encrypt( - config('occulta.should_compress') - ? gzencode(file_get_contents(base_path('.env'))) - : file_get_contents(base_path('.env')) - ) - ); - - return self::SUCCESS; - } -} diff --git a/src/Commands/EncryptFileWithKmsCommand.php b/src/Commands/EncryptFileWithKmsCommand.php new file mode 100644 index 0000000..5df448a --- /dev/null +++ b/src/Commands/EncryptFileWithKmsCommand.php @@ -0,0 +1,99 @@ +error('Environment suffix contains non-alphanumeric characters.'); + + return self::FAILURE; + } + + $this->line('Using environment file: .env.'.$envFileSuffix); + $envFilePath = base_path('.env.'.$envFileSuffix); + } + + try { + $files = $service->encryptFile($envFilePath); + } catch (\Throwable $e) { + $this->error('Encryption failed: '.$e->getMessage()); + + return self::FAILURE; + } + + if (!is_array($files) || !isset($files['file']) || !isset($files['key'])) { + $this->error('Encryption failed or returned unexpected format.'); + + return self::FAILURE; + } + + $file = $files['file']; + $key = $files['key']; + + $zip = new ZipArchive(); + $zipPath = base_path($envFileSuffix ? '.env.'.$envFileSuffix.'.encrypted.zip' : '.env.encrypted.zip'); + + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + $this->error('Failed to create zip file.'); + + return self::FAILURE; + } + + // Adding the encrypted .env file and key to the zip + $zip->addFile($file, ($envFileSuffix ? '.env.'.$envFileSuffix.'.encrypted' : '.env.encrypted')); + $zip->addFile($key, 'key.encrypted'); + $zip->close(); + + // Removing local files after zipping + if (file_exists($file)) { + unlink($file); + } + if (file_exists($key)) { + unlink($key); + } + + $zipDestinationPath = sprintf( + '%s%s%s', + (str(config('occulta.destination_path', 'dotenv/'))->endsWith('/') ? config('occulta.destination_path', 'dotenv/') : config('occulta.destination_path', 'dotenv/').'/'), + Carbon::now()->format('YmdHis'), + ($envFileSuffix ? '.env.'.$envFileSuffix.'.zip' : '.env.zip') + ); + + // Pushing the zip file to the configured storage disk + Storage::disk(config('occulta.destination_disk'))->put( + path: $zipDestinationPath, + contents: file_get_contents($zipPath), + options: [ + 'recursive' => true, + ] + ); + + // Removing the zip file after storing it + unlink($zipPath); + + $this->info('File encrypted successfully: '); + $this->line( + Storage::disk(config('occulta.destination_disk'))->path($zipDestinationPath) + ); + + return self::SUCCESS; + } +} diff --git a/src/Occulta.php b/src/Occulta.php index 866fac1..8954ff7 100755 --- a/src/Occulta.php +++ b/src/Occulta.php @@ -3,6 +3,7 @@ namespace Code16\Occulta; use Aws\Kms\KmsClient; +use Illuminate\Support\Facades\Storage; class Occulta { @@ -42,4 +43,82 @@ public function encrypt($value, $serialize = true) 'EncryptionContext' => $this->encryptionContext, ])->get('CiphertextBlob')); } + + public function encryptFile($filePath) + { + if (!file_exists($filePath)) { + throw new \InvalidArgumentException("File does not exist: {$filePath}"); + } + + // Generating a data key with KMS + $result = $this->client->generateDataKey([ + 'KeyId' => $this->keyId, + 'KeySpec' => 'AES_256', + ]); + + $plaintextKey = $result['Plaintext']; + $ciphertextKey = $result['CiphertextBlob']; + + $originalContent = file_get_contents($filePath); + + $iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); + $encryptedContent = openssl_encrypt( + $originalContent, + 'aes-256-cbc', + $plaintextKey, + OPENSSL_RAW_DATA, + $iv + ); + + // as soon as encryption is done, we can unset the plaintext key to avoid memory leaks + unset($plaintextKey); + + // Saving encrypted file + $encryptedFilePath = $filePath.'.encrypted'; + // $storeFile = Storage::disk('local')->put($encryptedFilePath, $iv . $encryptedContent, ['throw' => true]); + file_put_contents($encryptedFilePath, $iv.$encryptedContent); + + // Saving encrypted key + $encryptedKeyPath = $filePath.'.key.encrypted'; + $ciphertextKeyBase64 = base64_encode($ciphertextKey); + file_put_contents($encryptedKeyPath, $ciphertextKeyBase64); + + return [ + 'file' => $encryptedFilePath, + 'key' => $encryptedKeyPath, + ]; + } + + public function decrypt($key, $filePath) + { + $decrypted = $this->client->decrypt([ + 'CiphertextBlob' => $key, + 'EncryptionContext' => $this->encryptionContext, + ]); + + $plainTextKey = $decrypted->get('Plaintext'); + + $encryptedFile = file_get_contents($filePath); + + $ivLength = openssl_cipher_iv_length('aes-256-cbc'); + $iv = substr($encryptedFile, 0, $ivLength); + $ciphertext = substr($encryptedFile, $ivLength); + + $decryptedContent = openssl_decrypt( + $ciphertext, + 'aes-256-cbc', + $plainTextKey, + OPENSSL_RAW_DATA, + $iv + ); + + if ($decryptedContent === false) { + throw new \RuntimeException('Decryption failed'); + } + + $outputPath = str($filePath)->replace('.encrypted', '')->toString().'.decrypted'; + file_put_contents($outputPath, $decryptedContent); + + return $outputPath; + } } diff --git a/src/OccultaServiceProvider.php b/src/OccultaServiceProvider.php index bf2021b..cc0ebfd 100644 --- a/src/OccultaServiceProvider.php +++ b/src/OccultaServiceProvider.php @@ -3,7 +3,8 @@ namespace Code16\Occulta; use Code16\Occulta\Commands\CleanupEncryptedDotenvsCommand; -use Code16\Occulta\Commands\EncryptDotenvWithKmsCommand; +use Code16\Occulta\Commands\DecryptFileWithKmsCommand; +use Code16\Occulta\Commands\EncryptFileWithKmsCommand; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -19,8 +20,8 @@ public function configurePackage(Package $package): void $package ->name('occulta') ->hasConfigFile() - ->hasCommand(EncryptDotenvWithKmsCommand::class) - ->hasCommand(CleanupEncryptedDotenvsCommand::class); - ; + ->hasCommand(CleanupEncryptedDotenvsCommand::class) + ->hasCommand(EncryptFileWithKmsCommand::class) + ->hasCommand(DecryptFileWithKmsCommand::class); } } diff --git a/tests/Commands/CleanupEncryptedDotenvsCommandTest.php b/tests/Commands/CleanupEncryptedDotenvsCommandTest.php new file mode 100644 index 0000000..ac74016 --- /dev/null +++ b/tests/Commands/CleanupEncryptedDotenvsCommandTest.php @@ -0,0 +1,141 @@ +put($file, 'test content'); + } + + // Run the command + $this->artisan(CleanupEncryptedDotenvsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that only the 3 most recent files are kept + Storage::disk('local')->assertMissing('dotenv/20220101000000.env.zip'); + Storage::disk('local')->assertMissing('dotenv/20220102000000.env.zip'); + Storage::disk('local')->assertExists('dotenv/20220103000000.env.zip'); + Storage::disk('local')->assertExists('dotenv/20220104000000.env.zip'); + Storage::disk('local')->assertExists('dotenv/20220105000000.env.zip'); + } + + #[Test] + public function it_handles_empty_directory() + { + // Run the command with no files in the directory + $this->artisan(CleanupEncryptedDotenvsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + } + + #[Test] + public function it_handles_fewer_files_than_the_keep_limit() + { + // Create fewer files than the keep limit + $files = [ + 'dotenv/20220101000000.env.zip', + 'dotenv/20220102000000.env.zip', + ]; + + foreach ($files as $file) { + Storage::disk('local')->put($file, 'test content'); + } + + // Run the command + $this->artisan(CleanupEncryptedDotenvsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that all files are kept + Storage::disk('local')->assertExists('dotenv/20220101000000.env.zip'); + Storage::disk('local')->assertExists('dotenv/20220102000000.env.zip'); + } + + #[Test] + public function it_respects_custom_keep_limit() + { + // Set a custom keep limit + Config::set('occulta.number_of_encrypted_dotenv_to_keep_when_cleaning_up', 2); + + // Create test files + $files = [ + 'dotenv/20220101000000.env.zip', + 'dotenv/20220102000000.env.zip', + 'dotenv/20220103000000.env.zip', + 'dotenv/20220104000000.env.zip', + ]; + + foreach ($files as $file) { + Storage::disk('local')->put($file, 'test content'); + } + + // Run the command + $this->artisan(CleanupEncryptedDotenvsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that only the 2 most recent files are kept + Storage::disk('local')->assertMissing('dotenv/20220101000000.env.zip'); + Storage::disk('local')->assertMissing('dotenv/20220102000000.env.zip'); + Storage::disk('local')->assertExists('dotenv/20220103000000.env.zip'); + Storage::disk('local')->assertExists('dotenv/20220104000000.env.zip'); + } + + #[Test] + public function it_respects_custom_destination_path() + { + // Set a custom destination path + Config::set('occulta.destination_path', 'custom/path/'); + + // Create test files in the custom path + $files = [ + 'custom/path/20220101000000.env.zip', + 'custom/path/20220102000000.env.zip', + 'custom/path/20220103000000.env.zip', + 'custom/path/20220104000000.env.zip', + ]; + + foreach ($files as $file) { + Storage::disk('local')->put($file, 'test content'); + } + + // Run the command + $this->artisan(CleanupEncryptedDotenvsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that only the 3 most recent files are kept + Storage::disk('local')->assertMissing('custom/path/20220101000000.env.zip'); + Storage::disk('local')->assertExists('custom/path/20220102000000.env.zip'); + Storage::disk('local')->assertExists('custom/path/20220103000000.env.zip'); + Storage::disk('local')->assertExists('custom/path/20220104000000.env.zip'); + } +} diff --git a/tests/Commands/DecryptFileWithKmsCommandTest.php b/tests/Commands/DecryptFileWithKmsCommandTest.php new file mode 100644 index 0000000..60c677d --- /dev/null +++ b/tests/Commands/DecryptFileWithKmsCommandTest.php @@ -0,0 +1,189 @@ +zipPath = base_path('.env.encrypted.zip'); + $zip = new ZipArchive(); + + if ($zip->open($this->zipPath, ZipArchive::CREATE) === true) { + $zip->addFromString('.env.encrypted', 'encrypted-content'); + $zip->addFromString('key.encrypted', base64_encode('encrypted-key-data')); + $zip->close(); + } + } + + protected function tearDown(): void + { + // Clean up the zip file + if (file_exists($this->zipPath)) { + unlink($this->zipPath); + } + + // Clean up any extracted files + if (file_exists(base_path('.env.encrypted'))) { + unlink(base_path('.env.encrypted')); + } + if (file_exists(base_path('key.encrypted'))) { + unlink(base_path('key.encrypted')); + } + if (file_exists(base_path('.env.decrypted'))) { + unlink(base_path('.env.decrypted')); + } + + parent::tearDown(); + } + + #[Test] + public function it_decrypts_env_file_from_zip() + { + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andReturn(base_path('.env.decrypted')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->assertExitCode(0) + ->assertSuccessful(); + } + + #[Test] + public function it_fails_when_zip_file_does_not_exist() + { + $nonExistentZipPath = base_path('non-existent.zip'); + + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $nonExistentZipPath]) + ->assertExitCode(1) + ->assertFailed(); + } + + #[Test] + public function it_fails_when_zip_file_has_wrong_number_of_files() + { + // Create a zip with wrong number of files + $wrongZipPath = base_path('wrong.zip'); + $zip = new ZipArchive(); + + if ($zip->open($wrongZipPath, ZipArchive::CREATE) === true) { + $zip->addFromString('.env.encrypted', 'encrypted-content'); + // Missing key file + $zip->close(); + } + + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $wrongZipPath]) + ->assertExitCode(1) + ->assertFailed(); + + // Clean up + if (file_exists($wrongZipPath)) { + unlink($wrongZipPath); + } + } + + #[Test] + public function it_fails_when_zip_file_has_wrong_file_names() + { + // Create a zip with wrong file names + $wrongZipPath = base_path('wrong.zip'); + $zip = new ZipArchive(); + + if ($zip->open($wrongZipPath, ZipArchive::CREATE) === true) { + $zip->addFromString('wrong.env', 'encrypted-content'); + $zip->addFromString('wrong.key', 'encrypted-key'); + $zip->close(); + } + + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $wrongZipPath]) + ->assertExitCode(1) + ->assertFailed(); + + // Clean up + if (file_exists($wrongZipPath)) { + unlink($wrongZipPath); + } + if (file_exists(base_path('wrong.env'))) { + unlink(base_path('wrong.env')); + } + if (file_exists(base_path('wrong.key'))) { + unlink(base_path('wrong.key')); + } + } + + #[Test] + public function it_fails_when_decryption_throws_exception() + { + // Mock the Occulta service to throw an exception + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andThrow(new \Exception('Decryption failed')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->assertExitCode(1) + ->assertFailed(); + } + + #[Test] + public function it_cleans_up_artifacts_after_successful_decryption() + { + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andReturn(base_path('.env.decrypted')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that artifacts were cleaned up + $this->assertFileDoesNotExist(base_path('.env.encrypted')); + $this->assertFileDoesNotExist(base_path('key.encrypted')); + } + + #[Test] + public function it_cleans_up_artifacts_after_failed_decryption() + { + // Mock the Occulta service to throw an exception + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andThrow(new \Exception('Decryption failed')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->assertExitCode(1) + ->assertFailed(); + + // Verify that artifacts were cleaned up + $this->assertFileDoesNotExist(base_path('.env.encrypted')); + $this->assertFileDoesNotExist(base_path('key.encrypted')); + } +} diff --git a/tests/Commands/EncryptFileWithKmsCommandTest.php b/tests/Commands/EncryptFileWithKmsCommandTest.php new file mode 100644 index 0000000..8746a03 --- /dev/null +++ b/tests/Commands/EncryptFileWithKmsCommandTest.php @@ -0,0 +1,160 @@ +shouldReceive('encryptFile') + ->once() + ->with(base_path('.env')) + ->andReturn([ + 'file' => base_path('.env.encrypted'), + 'key' => base_path('.env.key.encrypted'), + ]); + + // Create the encrypted files for the test + file_put_contents(base_path('.env.encrypted'), 'encrypted-content'); + file_put_contents(base_path('.env.key.encrypted'), 'encrypted-key'); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(EncryptFileWithKmsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that the files were stored in the storage disk + Storage::disk('local')->assertExists('dotenv/'.date('YmdHis').'.env.zip'); + + // Clean up + if (file_exists(base_path('.env.encrypted'))) { + unlink(base_path('.env.encrypted')); + } + if (file_exists(base_path('.env.key.encrypted'))) { + unlink(base_path('.env.key.encrypted')); + } + } + + #[Test] + public function it_encrypts_env_file_with_suffix_and_stores_it() + { + // Set up configuration with suffix + Config::set('occulta.env_suffix', 'production'); + + // Create a fake .env.production file + file_put_contents(base_path('.env.production'), 'APP_ENV=production'); + + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('encryptFile') + ->once() + ->with(base_path('.env.production')) + ->andReturn([ + 'file' => base_path('.env.production.encrypted'), + 'key' => base_path('.env.production.key.encrypted'), + ]); + + // Create the encrypted files for the test + file_put_contents(base_path('.env.production.encrypted'), 'encrypted-content'); + file_put_contents(base_path('.env.production.key.encrypted'), 'encrypted-key'); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(EncryptFileWithKmsCommand::class) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that the files were stored in the storage disk + Storage::disk('local')->assertExists('dotenv/'.date('YmdHis').'.env.production.zip'); + + // Clean up + if (file_exists(base_path('.env.production'))) { + unlink(base_path('.env.production')); + } + if (file_exists(base_path('.env.production.encrypted'))) { + unlink(base_path('.env.production.encrypted')); + } + if (file_exists(base_path('.env.production.key.encrypted'))) { + unlink(base_path('.env.production.key.encrypted')); + } + } + + #[Test] + public function it_fails_when_env_suffix_contains_invalid_characters() + { + // Set up configuration with invalid suffix + Config::set('occulta.env_suffix', 'invalid/suffix'); + + // Run the command + $this->artisan(EncryptFileWithKmsCommand::class) + ->assertExitCode(1) + ->assertFailed(); + } + + #[Test] + public function it_fails_when_encryption_throws_exception() + { + // Mock the Occulta service to throw an exception + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('encryptFile') + ->once() + ->with(base_path('.env')) + ->andThrow(new \Exception('Encryption failed')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(EncryptFileWithKmsCommand::class) + ->assertExitCode(1) + ->assertFailed(); + } + + #[Test] + public function it_fails_when_encryption_returns_unexpected_format() + { + // Mock the Occulta service to return invalid format + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('encryptFile') + ->once() + ->with(base_path('.env')) + ->andReturn('invalid-format'); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(EncryptFileWithKmsCommand::class) + ->assertExitCode(1) + ->assertFailed(); + } +} diff --git a/tests/Commands/PromptingSystemTest.php b/tests/Commands/PromptingSystemTest.php new file mode 100644 index 0000000..8a80d5a --- /dev/null +++ b/tests/Commands/PromptingSystemTest.php @@ -0,0 +1,177 @@ +zipPath = base_path('.env.encrypted.zip'); + $zip = new ZipArchive(); + + if ($zip->open($this->zipPath, ZipArchive::CREATE) === true) { + $zip->addFromString('.env.encrypted', 'encrypted-content'); + $zip->addFromString('key.encrypted', base64_encode('encrypted-key-data')); + $zip->close(); + } + } + + protected function tearDown(): void + { + // Clean up the zip file + if (file_exists($this->zipPath)) { + unlink($this->zipPath); + } + + // Clean up any extracted files + if (file_exists(base_path('.env.encrypted'))) { + unlink(base_path('.env.encrypted')); + } + if (file_exists(base_path('key.encrypted'))) { + unlink(base_path('key.encrypted')); + } + if (file_exists(base_path('.env.decrypted'))) { + unlink(base_path('.env.decrypted')); + } + + parent::tearDown(); + } + + #[Test] + public function it_prompts_for_aws_credentials_when_not_configured() + { + // Unset the AWS credentials and KMS key ID + config()->set('services.kms.key', null); + config()->set('services.kms.secret', null); + config()->set('occulta.key_id', null); + config()->set('services.kms.region', null); + + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andReturn(base_path('.env.decrypted')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->expectsQuestion('Please enter your KMS key id.', 'test-prompted-key-id') + ->expectsQuestion('Please enter an AWS access key for a user with KMS decrypt permissions on your KMS key.', 'test-prompted-access-key') + ->expectsQuestion('Please enter the AWS secret key corresponding to your access key.', 'test-prompted-secret-key') + ->expectsQuestion('Please enter the AWS region corresponding to your key.', 'test-prompted-region') + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that the configuration was updated with the prompted values + $this->assertEquals('test-prompted-key-id', config('occulta.key_id')); + $this->assertEquals('test-prompted-access-key', config('services.kms.key')); + $this->assertEquals('test-prompted-secret-key', config('services.kms.secret')); + $this->assertEquals('test-prompted-region', config('services.kms.region')); + } + + #[Test] + public function it_prompts_for_aws_credentials_when_only_key_id_is_configured() + { + // Set the KMS key ID but unset the AWS credentials + config()->set('occulta.key_id', 'test-key-id'); + config()->set('services.kms.key', null); + config()->set('services.kms.secret', null); + config()->set('services.kms.region', null); + + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andReturn(base_path('.env.decrypted')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->expectsQuestion('Please enter an AWS access key for a user with KMS decrypt permissions on your KMS key.', 'test-prompted-access-key') + ->expectsQuestion('Please enter the AWS secret key corresponding to your access key.', 'test-prompted-secret-key') + ->expectsQuestion('Please enter the AWS region corresponding to your key.', 'test-prompted-region') + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that the configuration was updated with the prompted values + $this->assertEquals('test-key-id', config('occulta.key_id')); // This should remain unchanged + $this->assertEquals('test-prompted-access-key', config('services.kms.key')); + $this->assertEquals('test-prompted-secret-key', config('services.kms.secret')); + $this->assertEquals('test-prompted-region', config('services.kms.region')); + } + + #[Test] + public function it_prompts_for_aws_credentials_when_only_region_is_configured() + { + // Set the KMS region but unset the AWS credentials and key ID + config()->set('occulta.key_id', null); + config()->set('services.kms.key', null); + config()->set('services.kms.secret', null); + config()->set('services.kms.region', 'test-region'); + + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andReturn(base_path('.env.decrypted')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->expectsQuestion('Please enter your KMS key id.', 'test-prompted-key-id') + ->expectsQuestion('Please enter an AWS access key for a user with KMS decrypt permissions on your KMS key.', 'test-prompted-access-key') + ->expectsQuestion('Please enter the AWS secret key corresponding to your access key.', 'test-prompted-secret-key') + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that the configuration was updated with the prompted values + $this->assertEquals('test-prompted-key-id', config('occulta.key_id')); + $this->assertEquals('test-prompted-access-key', config('services.kms.key')); + $this->assertEquals('test-prompted-secret-key', config('services.kms.secret')); + $this->assertEquals('test-region', config('services.kms.region')); // This should remain unchanged + } + + #[Test] + public function it_does_not_prompt_for_aws_credentials_when_already_configured() + { + // Set all the required configuration values + config()->set('occulta.key_id', 'test-key-id'); + config()->set('services.kms.key', 'test-access-key'); + config()->set('services.kms.secret', 'test-secret-key'); + config()->set('services.kms.region', 'test-region'); + + // Mock the Occulta service + $mockOcculta = Mockery::mock(Occulta::class); + $mockOcculta->shouldReceive('decrypt') + ->once() + ->with('encrypted-key-data', base_path('.env.encrypted')) + ->andReturn(base_path('.env.decrypted')); + + // Run the command + $this->app->instance(Occulta::class, $mockOcculta); + $this->artisan(DecryptFileWithKmsCommand::class, ['encryptedEnvZipPath' => $this->zipPath]) + ->assertExitCode(0) + ->assertSuccessful(); + + // Verify that the configuration values remain unchanged + $this->assertEquals('test-key-id', config('occulta.key_id')); + $this->assertEquals('test-access-key', config('services.kms.key')); + $this->assertEquals('test-secret-key', config('services.kms.secret')); + $this->assertEquals('test-region', config('services.kms.region')); + } +} diff --git a/tests/OccultaTest.php b/tests/OccultaTest.php new file mode 100644 index 0000000..dc871fd --- /dev/null +++ b/tests/OccultaTest.php @@ -0,0 +1,158 @@ +shouldReceive('encrypt') + ->once() + ->with([ + 'KeyId' => 'test-key-id', + 'Plaintext' => serialize('test-value'), + 'EncryptionContext' => ['app' => 'testing'], + ]) + ->andReturn(new Result(['CiphertextBlob' => 'encrypted-data'])); + + // Replace the KMS client in the Occulta instance + $occulta = new Occulta(); + $reflectionClass = new \ReflectionClass($occulta); + $reflectionProperty = $reflectionClass->getProperty('client'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($occulta, $mockClient); + + // Test the encrypt method + $result = $occulta->encrypt('test-value'); + + $this->assertEquals(base64_encode('encrypted-data'), $result); + } + + #[Test] + public function it_can_encrypt_a_file() + { + // Create a temporary file + $tempFile = tempnam(sys_get_temp_dir(), 'test_env_'); + file_put_contents($tempFile, 'TEST_KEY=test_value'); + + // Mock the KMS client + $mockClient = Mockery::mock(KmsClient::class); + $mockClient->shouldReceive('generateDataKey') + ->once() + ->with([ + 'KeyId' => 'test-key-id', + 'KeySpec' => 'AES_256', + ]) + ->andReturn([ + 'Plaintext' => random_bytes(32), // 256 bits key + 'CiphertextBlob' => 'encrypted-key-data', + ]); + + // Replace the KMS client in the Occulta instance + $occulta = new Occulta(); + $reflectionClass = new \ReflectionClass($occulta); + $reflectionProperty = $reflectionClass->getProperty('client'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($occulta, $mockClient); + + // Test the encryptFile method + $result = $occulta->encryptFile($tempFile); + + $this->assertArrayHasKey('file', $result); + $this->assertArrayHasKey('key', $result); + $this->assertEquals($tempFile.'.encrypted', $result['file']); + $this->assertEquals($tempFile.'.key.encrypted', $result['key']); + $this->assertFileExists($result['file']); + $this->assertFileExists($result['key']); + + // Clean up + unlink($tempFile); + unlink($result['file']); + unlink($result['key']); + } + + #[Test] + public function it_can_decrypt_a_file() + { + // Create a temporary file with encrypted content + $tempFile = tempnam(sys_get_temp_dir(), 'test_encrypted_env_'); + $iv = random_bytes(openssl_cipher_iv_length('aes-256-cbc')); + $key = random_bytes(32); // 256 bits key + $encryptedContent = openssl_encrypt('TEST_KEY=test_value', 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + file_put_contents($tempFile, $iv.$encryptedContent); + + // Mock the KMS client + $mockClient = Mockery::mock(KmsClient::class); + $mockClient->shouldReceive('decrypt') + ->once() + ->with([ + 'CiphertextBlob' => 'encrypted-key-data', + 'EncryptionContext' => ['app' => 'testing'], + ]) + ->andReturn(new Result(['Plaintext' => $key])); + + // Replace the KMS client in the Occulta instance + $occulta = new Occulta(); + $reflectionClass = new \ReflectionClass($occulta); + $reflectionProperty = $reflectionClass->getProperty('client'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($occulta, $mockClient); + + // Test the decrypt method + $result = $occulta->decrypt('encrypted-key-data', $tempFile); + + $this->assertStringEndsWith('.decrypted', $result); + $this->assertFileExists($result); + $this->assertEquals('TEST_KEY=test_value', file_get_contents($result)); + + // Clean up + unlink($tempFile); + unlink($result); + } + + #[Test] + public function it_throws_exception_when_file_does_not_exist() + { + $this->expectException(\InvalidArgumentException::class); + + $occulta = new Occulta(); + $occulta->encryptFile('/path/to/nonexistent/file'); + } + + #[Test] + public function it_throws_exception_when_decryption_fails() + { + // Create a temporary file with invalid encrypted content + $tempFile = tempnam(sys_get_temp_dir(), 'test_invalid_encrypted_env_'); + file_put_contents($tempFile, 'invalid-encrypted-content'); + + // Mock the KMS client + $mockClient = Mockery::mock(KmsClient::class); + $mockClient->shouldReceive('decrypt') + ->once() + ->andReturn(new Result(['Plaintext' => 'invalid-key'])); + + // Replace the KMS client in the Occulta instance + $occulta = new Occulta(); + $reflectionClass = new \ReflectionClass($occulta); + $reflectionProperty = $reflectionClass->getProperty('client'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($occulta, $mockClient); + + // Test the decrypt method + $this->expectException(\RuntimeException::class); + $occulta->decrypt('encrypted-key-data', $tempFile); + + // Clean up + unlink($tempFile); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index a915833..1223f85 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,35 +2,48 @@ namespace Code16\Occulta\Tests; -use Code16\Occulta\OccultaServiceProvider; -use Illuminate\Database\Eloquent\Factories\Factory; +use Closure; +use Illuminate\Support\Facades\Config; use Orchestra\Testbench\TestCase as Orchestra; -class TestCase extends Orchestra +abstract class TestCase extends Orchestra { - protected function setUp(): void - { - parent::setUp(); - - Factory::guessFactoryNamesUsing( - fn (string $modelName) => 'Code16\\Occulta\\Database\\Factories\\'.class_basename($modelName).'Factory' - ); - } - protected function getPackageProviders($app) { return [ - OccultaServiceProvider::class, + 'Code16\Occulta\OccultaServiceProvider', ]; } - public function getEnvironmentSetUp($app) + protected function setUp(): void + { + parent::setUp(); + + // Set up common configuration + Config::set('occulta.key_id', 'test-key-id'); + Config::set('occulta.context', ['app' => 'testing']); + Config::set('occulta.destination_disk', 'local'); + Config::set('occulta.destination_path', 'dotenv/'); + Config::set('occulta.env_suffix', null); + Config::set('occulta.number_of_encrypted_dotenv_to_keep_when_cleaning_up', 3); + Config::set('services.kms.key', 'test-key'); + Config::set('services.kms.secret', 'test-secret'); + Config::set('services.kms.region', 'us-west-1'); + } + + /** + * Mock a function in a namespace. + * + * @param string $name The function name to mock + * @param callable $callback The callback to execute when the function is called + */ + protected function mock($abstract, ?Closure $mock = null): void { - config()->set('database.default', 'testing'); + $namespace = explode('\\', $abstract); + $functionName = array_pop($namespace); + $namespace = implode('\\', $namespace); - /* - $migration = include __DIR__.'/../database/migrations/create_occulta_table.php.stub'; - $migration->up(); - */ + $mock = \Mockery::mock('alias:'.$namespace); + $mock->shouldReceive($functionName)->andReturnUsing($mock); } }