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 :
+
+
+If you don't have a copy elsewhere of your "Access Key 1" secret, you will have to create a new one.
+
+(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
+
+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);
}
}