From 42de63783f221cbc893e9043e11fc35c79988251 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Mon, 25 Aug 2025 22:30:34 -0400 Subject: [PATCH 1/4] Add Vault component for secret storage --- ROADMAP.md | 29 ++++++++++ composer.json | 6 +- docs/SUMMARY.md | 1 + docs/components/vault.md | 15 +++++ src/SonsOfPHP/Component/Vault/AGENTS.md | 14 +++++ .../Vault/Cipher/CipherInterface.php | 21 +++++++ .../Component/Vault/Cipher/OpenSSLCipher.php | 43 ++++++++++++++ src/SonsOfPHP/Component/Vault/LICENSE | 19 +++++++ src/SonsOfPHP/Component/Vault/README.md | 16 ++++++ src/SonsOfPHP/Component/Vault/ROADMAP.md | 38 +++++++++++++ .../Vault/Storage/InMemoryStorage.php | 31 ++++++++++ .../Vault/Storage/StorageInterface.php | 26 +++++++++ .../Component/Vault/Tests/VaultTest.php | 53 +++++++++++++++++ src/SonsOfPHP/Component/Vault/Vault.php | 53 +++++++++++++++++ src/SonsOfPHP/Component/Vault/composer.json | 57 +++++++++++++++++++ .../Component/Vault/phpunit.xml.dist | 31 ++++++++++ 16 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 docs/components/vault.md create mode 100644 src/SonsOfPHP/Component/Vault/AGENTS.md create mode 100644 src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php create mode 100644 src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php create mode 100644 src/SonsOfPHP/Component/Vault/LICENSE create mode 100644 src/SonsOfPHP/Component/Vault/README.md create mode 100644 src/SonsOfPHP/Component/Vault/ROADMAP.md create mode 100644 src/SonsOfPHP/Component/Vault/Storage/InMemoryStorage.php create mode 100644 src/SonsOfPHP/Component/Vault/Storage/StorageInterface.php create mode 100644 src/SonsOfPHP/Component/Vault/Tests/VaultTest.php create mode 100644 src/SonsOfPHP/Component/Vault/Vault.php create mode 100644 src/SonsOfPHP/Component/Vault/composer.json create mode 100644 src/SonsOfPHP/Component/Vault/phpunit.xml.dist diff --git a/ROADMAP.md b/ROADMAP.md index ca629ab5..dfccdb52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -121,6 +121,35 @@ This roadmap outlines planned libraries and updates for the Sons of PHP monorepo - Release notes highlight Symfony 7 support. - Implementation meets the Global DoD. +### Epic: Introduce Vault Component +**Description:** Provide secure secret storage with pluggable backends. + +- [ ] Add filesystem storage adapter. + - **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Filesystem adapter encrypts and retrieves secrets reliably. + - Implementation meets the Global DoD. +- [ ] Add database storage adapter. + - **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Database adapter persists encrypted secrets. + - Implementation meets the Global DoD. +- [ ] Add Redis storage adapter. + - **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Redis adapter stores encrypted secrets and respects expirations. + - Implementation meets the Global DoD. +- [ ] Support key rotation and secret versioning. + - **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Secrets can be rotated without loss using new keys. + - Implementation meets the Global DoD. +- [ ] Provide CLI tools for managing secrets. + - **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - CLI allows setting, retrieving, and rotating secrets. + - Implementation meets the Global DoD. + ## Suggestions - Automate dependency updates with a scheduled tool (e.g., Renovate or Dependabot). diff --git a/composer.json b/composer.json index 7d88e48f..47d57259 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,7 @@ "sonsofphp/pager-implementation": "0.3.x-dev", "sonsofphp/registry-implementation": "0.3.x-dev", "sonsofphp/state-machine-implementation": "0.3.x-dev", + "sonsofphp/vault-implementation": "0.3.x-dev", "sonsofphp/version-implementation": "0.3.x-dev" }, "require": { @@ -136,6 +137,7 @@ "sonsofphp/registry-contract": "self.version", "sonsofphp/state-machine": "self.version", "sonsofphp/state-machine-contract": "self.version", + "sonsofphp/vault": "self.version", "sonsofphp/version": "self.version", "sonsofphp/version-contract": "self.version" }, @@ -172,7 +174,8 @@ "src/SonsOfPHP/Bridge/Doctrine/DBAL/Pager/Tests", "src/SonsOfPHP/Bridge/Doctrine/ORM/Pager/Tests", "src/SonsOfPHP/Component/Version/Tests", - "src/SonsOfPHP/Component/Registry/Tests" + "src/SonsOfPHP/Component/Registry/Tests", + "src/SonsOfPHP/Component/Vault/Tests" ], "psr-4": { "SonsOfPHP\\Bard\\": "src/SonsOfPHP/Bard/src", @@ -206,6 +209,7 @@ "SonsOfPHP\\Component\\Pager\\": "src/SonsOfPHP/Component/Pager", "SonsOfPHP\\Component\\Registry\\": "src/SonsOfPHP/Component/Registry", "SonsOfPHP\\Component\\StateMachine\\": "src/SonsOfPHP/Component/StateMachine", + "SonsOfPHP\\Component\\Vault\\": "src/SonsOfPHP/Component/Vault", "SonsOfPHP\\Component\\Version\\": "src/SonsOfPHP/Component/Version", "SonsOfPHP\\Contract\\Common\\": "src/SonsOfPHP/Contract/Common", "SonsOfPHP\\Contract\\Cookie\\": "src/SonsOfPHP/Contract/Cookie", diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 25470239..45b8232c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -74,6 +74,7 @@ * [Adapters](components/pager/adapters.md) * [Registry](components/registry.md) * [State Machine](components/state-machine.md) +* [Vault](components/vault.md) * [Version](components/version.md) ## 💁 Contributing diff --git a/docs/components/vault.md b/docs/components/vault.md new file mode 100644 index 00000000..ca410690 --- /dev/null +++ b/docs/components/vault.md @@ -0,0 +1,15 @@ +# Vault + +The Vault component securely stores secrets using pluggable storage backends and encryption. + +## Basic Usage + +```php +use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher; +use SonsOfPHP\Component\Vault\Storage\InMemoryStorage; +use SonsOfPHP\Component\Vault\Vault; + +$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), 'encryption-key'); +$vault->set('db_password', 'secret'); +$secret = $vault->get('db_password'); +``` diff --git a/src/SonsOfPHP/Component/Vault/AGENTS.md b/src/SonsOfPHP/Component/Vault/AGENTS.md new file mode 100644 index 00000000..a9188504 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/AGENTS.md @@ -0,0 +1,14 @@ +# Agents Guide for Package: src/SonsOfPHP/Component/Vault + +## Scope + +- Source: `src/SonsOfPHP/Component/Vault` +- Tests: `src/SonsOfPHP/Component/Vault/Tests` +- Do not edit: any `vendor/` directories + +## Workflows + +- Install once at repo root: `make install` +- Test this package only: `PHPUNIT_OPTIONS='src/SonsOfPHP/Component/Vault/Tests' make test` +- Style and static analysis: `make php-cs-fixer` and `make psalm` +- Upgrade code (may modify files): `make upgrade-code` diff --git a/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php new file mode 100644 index 00000000..411580a0 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php @@ -0,0 +1,21 @@ +cipherMethod); + $iv = random_bytes($ivLength); + $encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv); + if (false === $encrypted) { + throw new RuntimeException('Unable to encrypt secret.'); + } + + return base64_encode($iv . $encrypted); + } + + public function decrypt(string $ciphertext, string $key): string + { + $data = base64_decode($ciphertext, true); + $ivLength = openssl_cipher_iv_length($this->cipherMethod); + $iv = substr($data, 0, $ivLength); + $payload = substr($data, $ivLength); + $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv); + if (false === $decrypted) { + throw new RuntimeException('Unable to decrypt secret.'); + } + + return $decrypted; + } +} diff --git a/src/SonsOfPHP/Component/Vault/LICENSE b/src/SonsOfPHP/Component/Vault/LICENSE new file mode 100644 index 00000000..39238382 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/LICENSE @@ -0,0 +1,19 @@ +Copyright 2022 to Present Joshua Estes + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/SonsOfPHP/Component/Vault/README.md b/src/SonsOfPHP/Component/Vault/README.md new file mode 100644 index 00000000..3fcbb13d --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/README.md @@ -0,0 +1,16 @@ +Sons of PHP - Vault +=================== + +## Learn More + +* [Documentation][docs] +* [Contributing][contributing] +* [Report Issues][issues] and [Submit Pull Requests][pull-requests] in the [Mother Repository][mother-repo] +* Get Help & Support using [Discussions][discussions] + +[discussions]: https://github.com/orgs/SonsOfPHP/discussions +[mother-repo]: https://github.com/SonsOfPHP/sonsofphp +[contributing]: https://docs.sonsofphp.com/contributing/ +[docs]: https://docs.sonsofphp.com/components/vault/ +[issues]: https://github.com/SonsOfPHP/sonsofphp/issues?q=is%3Aopen+is%3Aissue+label%3AVault +[pull-requests]: https://github.com/SonsOfPHP/sonsofphp/pulls?q=is%3Aopen+is%3Apr+label%3AVault diff --git a/src/SonsOfPHP/Component/Vault/ROADMAP.md b/src/SonsOfPHP/Component/Vault/ROADMAP.md new file mode 100644 index 00000000..c39a3652 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/ROADMAP.md @@ -0,0 +1,38 @@ +# Vault Component Roadmap + +## Upcoming Features + +### Add filesystem storage adapter +- [ ] Implementation uses encrypted files to persist secrets. +- **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Filesystem adapter securely reads and writes secrets. + - Implementation meets the Global DoD. + +### Add database storage adapter +- [ ] Store secrets in a relational database using PDO. +- **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Secrets are encrypted before storage. + - Implementation meets the Global DoD. + +### Add Redis storage adapter +- [ ] Store secrets in Redis for fast access. +- **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Redis adapter encrypts secrets and handles expirations. + - Implementation meets the Global DoD. + +### Support key rotation and secret versioning +- [ ] Provide APIs to rotate encryption keys and maintain secret history. +- **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - Secrets can be re-encrypted with new keys without loss. + - Implementation meets the Global DoD. + +### Provide CLI tools for managing secrets +- [ ] Expose commands to set, get, and rotate secrets. +- **Acceptance Criteria** + - All Global DoR items are satisfied before implementation begins. + - CLI allows basic secret management tasks. + - Implementation meets the Global DoD. diff --git a/src/SonsOfPHP/Component/Vault/Storage/InMemoryStorage.php b/src/SonsOfPHP/Component/Vault/Storage/InMemoryStorage.php new file mode 100644 index 00000000..8089992d --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Storage/InMemoryStorage.php @@ -0,0 +1,31 @@ + + */ + private array $secrets = []; + + public function set(string $name, string $value): void + { + $this->secrets[$name] = $value; + } + + public function get(string $name): ?string + { + return $this->secrets[$name] ?? null; + } + + public function delete(string $name): void + { + unset($this->secrets[$name]); + } +} diff --git a/src/SonsOfPHP/Component/Vault/Storage/StorageInterface.php b/src/SonsOfPHP/Component/Vault/Storage/StorageInterface.php new file mode 100644 index 00000000..6db80c28 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Storage/StorageInterface.php @@ -0,0 +1,26 @@ +createVault(); + $vault->set('db_password', 'secret'); + + $this->assertSame('secret', $vault->get('db_password')); + } + + public function testGetReturnsNullWhenSecretIsMissing(): void + { + $vault = $this->createVault(); + + $this->assertNull($vault->get('missing')); + } + + public function testSecretCanBeDeleted(): void + { + $vault = $this->createVault(); + $vault->set('api_key', 'secret'); + $vault->delete('api_key'); + + $this->assertNull($vault->get('api_key')); + } +} diff --git a/src/SonsOfPHP/Component/Vault/Vault.php b/src/SonsOfPHP/Component/Vault/Vault.php new file mode 100644 index 00000000..4d74c712 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Vault.php @@ -0,0 +1,53 @@ +cipher->encrypt($secret, $this->encryptionKey); + $this->storage->set($name, $encrypted); + } + + /** + * Retrieves a secret from the vault or null if it does not exist. + */ + public function get(string $name): ?string + { + $encrypted = $this->storage->get($name); + if (null === $encrypted) { + return null; + } + + return $this->cipher->decrypt($encrypted, $this->encryptionKey); + } + + /** + * Removes a secret from the vault. + */ + public function delete(string $name): void + { + $this->storage->delete($name); + } +} diff --git a/src/SonsOfPHP/Component/Vault/composer.json b/src/SonsOfPHP/Component/Vault/composer.json new file mode 100644 index 00000000..408d9bb9 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/composer.json @@ -0,0 +1,57 @@ +{ + "name": "sonsofphp/vault", + "type": "library", + "description": "Secure secret storage for PHP applications", + "keywords": [ + "vault", + "secret", + "encryption" + ], + "homepage": "https://github.com/SonsOfPHP/vault", + "license": "MIT", + "authors": [ + { + "name": "Sons of PHP Community", + "homepage": "https://github.com/SonsOfPHP/sonsofphp/graphs/contributors" + }, + { + "name": "Joshua Estes", + "email": "joshua@sonsofphp.com" + } + ], + "support": { + "issues": "https://github.com/SonsOfPHP/sonsofphp/issues", + "forum": "https://github.com/orgs/SonsOfPHP/discussions", + "docs": "https://docs.sonsofphp.com" + }, + "autoload": { + "psr-4": { + "SonsOfPHP\\Component\\Vault\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.3", + "ext-openssl": "*" + }, + "extra": { + "sort-packages": true, + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/JoshuaEstes" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-sonsofphp-sonsofphp" + } + ] +} diff --git a/src/SonsOfPHP/Component/Vault/phpunit.xml.dist b/src/SonsOfPHP/Component/Vault/phpunit.xml.dist new file mode 100644 index 00000000..3357cf7c --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + . + + + ./Tests + ./vendor + + + From 4bb09e3af2d735765f693988595aa4abe3879dc5 Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Mon, 25 Aug 2025 22:45:57 -0400 Subject: [PATCH 2/4] Enhance Vault with AAD and key rotation --- ROADMAP.md | 29 --------- docs/components/vault.md | 16 +++-- .../Vault/Cipher/CipherInterface.php | 8 +-- .../Component/Vault/Cipher/OpenSSLCipher.php | 32 ++++++---- src/SonsOfPHP/Component/Vault/README.md | 2 + src/SonsOfPHP/Component/Vault/ROADMAP.md | 6 +- .../Component/Vault/Tests/VaultTest.php | 53 ++++++++++++++-- src/SonsOfPHP/Component/Vault/Vault.php | 63 +++++++++++++++---- 8 files changed, 141 insertions(+), 68 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dfccdb52..ca629ab5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -121,35 +121,6 @@ This roadmap outlines planned libraries and updates for the Sons of PHP monorepo - Release notes highlight Symfony 7 support. - Implementation meets the Global DoD. -### Epic: Introduce Vault Component -**Description:** Provide secure secret storage with pluggable backends. - -- [ ] Add filesystem storage adapter. - - **Acceptance Criteria** - - All Global DoR items are satisfied before implementation begins. - - Filesystem adapter encrypts and retrieves secrets reliably. - - Implementation meets the Global DoD. -- [ ] Add database storage adapter. - - **Acceptance Criteria** - - All Global DoR items are satisfied before implementation begins. - - Database adapter persists encrypted secrets. - - Implementation meets the Global DoD. -- [ ] Add Redis storage adapter. - - **Acceptance Criteria** - - All Global DoR items are satisfied before implementation begins. - - Redis adapter stores encrypted secrets and respects expirations. - - Implementation meets the Global DoD. -- [ ] Support key rotation and secret versioning. - - **Acceptance Criteria** - - All Global DoR items are satisfied before implementation begins. - - Secrets can be rotated without loss using new keys. - - Implementation meets the Global DoD. -- [ ] Provide CLI tools for managing secrets. - - **Acceptance Criteria** - - All Global DoR items are satisfied before implementation begins. - - CLI allows setting, retrieving, and rotating secrets. - - Implementation meets the Global DoD. - ## Suggestions - Automate dependency updates with a scheduled tool (e.g., Renovate or Dependabot). diff --git a/docs/components/vault.md b/docs/components/vault.md index ca410690..e56c23c2 100644 --- a/docs/components/vault.md +++ b/docs/components/vault.md @@ -1,6 +1,6 @@ # Vault -The Vault component securely stores secrets using pluggable storage backends and encryption. +The Vault component securely stores secrets using pluggable storage backends and encryption with key rotation and support for additional authenticated data (AAD). ## Basic Usage @@ -9,7 +9,15 @@ use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher; use SonsOfPHP\Component\Vault\Storage\InMemoryStorage; use SonsOfPHP\Component\Vault\Vault; -$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), 'encryption-key'); -$vault->set('db_password', 'secret'); -$secret = $vault->get('db_password'); +$keys = ['v1' => '32_byte_master_key_example!!']; +$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), $keys, 'v1'); +$vault->set('db_password', 'secret', 'app'); +$secret = $vault->get('db_password', 'app'); + +// Rotate the master key +$vault->rotateKey('v2', 'another_32_byte_master_key!!'); + +// Store non-string data +$vault->set('config', ['user' => 'root']); +$config = $vault->get('config'); ``` diff --git a/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php index 411580a0..d825a866 100644 --- a/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php +++ b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php @@ -10,12 +10,12 @@ interface CipherInterface { /** - * Encrypts plaintext using the provided key. + * Encrypts plaintext using the provided key and optional authenticated data. */ - public function encrypt(string $plaintext, string $key): string; + public function encrypt(string $plaintext, string $key, string $aad = ''): string; /** - * Decrypts ciphertext using the provided key. + * Decrypts ciphertext using the provided key and optional authenticated data. */ - public function decrypt(string $ciphertext, string $key): string; + public function decrypt(string $ciphertext, string $key, string $aad = ''): string; } diff --git a/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php index f86d3a60..9275cddd 100644 --- a/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php +++ b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php @@ -11,29 +11,37 @@ */ class OpenSSLCipher implements CipherInterface { - public function __construct(private readonly string $cipherMethod = 'aes-256-ctr') - { - } + /** + * @param string $cipherMethod OpenSSL cipher method supporting AEAD. + */ + public function __construct(private readonly string $cipherMethod = 'aes-256-gcm') {} - public function encrypt(string $plaintext, string $key): string + public function encrypt(string $plaintext, string $key, string $aad = ''): string { $ivLength = openssl_cipher_iv_length($this->cipherMethod); $iv = random_bytes($ivLength); - $encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv); + $tag = ''; + $encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16); if (false === $encrypted) { throw new RuntimeException('Unable to encrypt secret.'); } - return base64_encode($iv . $encrypted); + return base64_encode($iv . $tag . $encrypted); } - public function decrypt(string $ciphertext, string $key): string + public function decrypt(string $ciphertext, string $key, string $aad = ''): string { - $data = base64_decode($ciphertext, true); - $ivLength = openssl_cipher_iv_length($this->cipherMethod); - $iv = substr($data, 0, $ivLength); - $payload = substr($data, $ivLength); - $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv); + $data = base64_decode($ciphertext, true); + if (false === $data) { + throw new RuntimeException('Invalid ciphertext.'); + } + + $ivLength = openssl_cipher_iv_length($this->cipherMethod); + $tagLength = 16; + $iv = substr($data, 0, $ivLength); + $tag = substr($data, $ivLength, $tagLength); + $payload = substr($data, $ivLength + $tagLength); + $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad); if (false === $decrypted) { throw new RuntimeException('Unable to decrypt secret.'); } diff --git a/src/SonsOfPHP/Component/Vault/README.md b/src/SonsOfPHP/Component/Vault/README.md index 3fcbb13d..18015913 100644 --- a/src/SonsOfPHP/Component/Vault/README.md +++ b/src/SonsOfPHP/Component/Vault/README.md @@ -1,6 +1,8 @@ Sons of PHP - Vault =================== +Secure secret storage with pluggable backends, key rotation, and support for additional authenticated data. + ## Learn More * [Documentation][docs] diff --git a/src/SonsOfPHP/Component/Vault/ROADMAP.md b/src/SonsOfPHP/Component/Vault/ROADMAP.md index c39a3652..88e24e26 100644 --- a/src/SonsOfPHP/Component/Vault/ROADMAP.md +++ b/src/SonsOfPHP/Component/Vault/ROADMAP.md @@ -23,11 +23,11 @@ - Redis adapter encrypts secrets and handles expirations. - Implementation meets the Global DoD. -### Support key rotation and secret versioning -- [ ] Provide APIs to rotate encryption keys and maintain secret history. +### Add secret versioning +- [ ] Provide APIs to maintain secret history across updates. - **Acceptance Criteria** - All Global DoR items are satisfied before implementation begins. - - Secrets can be re-encrypted with new keys without loss. + - Previous versions of a secret remain accessible. - Implementation meets the Global DoD. ### Provide CLI tools for managing secrets diff --git a/src/SonsOfPHP/Component/Vault/Tests/VaultTest.php b/src/SonsOfPHP/Component/Vault/Tests/VaultTest.php index 3125e981..aa1e7302 100644 --- a/src/SonsOfPHP/Component/Vault/Tests/VaultTest.php +++ b/src/SonsOfPHP/Component/Vault/Tests/VaultTest.php @@ -5,6 +5,7 @@ namespace SonsOfPHP\Component\Vault\Tests; use PHPUnit\Framework\TestCase; +use RuntimeException; use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher; use SonsOfPHP\Component\Vault\Storage\InMemoryStorage; use SonsOfPHP\Component\Vault\Vault; @@ -18,13 +19,13 @@ class VaultTest extends TestCase /** * Creates a vault instance for testing. */ - private function createVault(): Vault + private function createVault(?InMemoryStorage &$storage = null): Vault { - $storage = new InMemoryStorage(); + $storage ??= new InMemoryStorage(); $cipher = new OpenSSLCipher(); - $key = 'test_encryption_key_32_bytes!'; + $keys = ['v1' => 'test_encryption_key_32_bytes!']; - return new Vault($storage, $cipher, $key); + return new Vault($storage, $cipher, $keys, 'v1'); } public function testSecretCanBeRetrieved(): void @@ -50,4 +51,48 @@ public function testSecretCanBeDeleted(): void $this->assertNull($vault->get('api_key')); } + + public function testSetAndGetWithArray(): void + { + $vault = $this->createVault(); + $vault->set('config', ['user' => 'root']); + + $this->assertSame(['user' => 'root'], $vault->get('config')); + } + + public function testSetAndGetWithAad(): void + { + $vault = $this->createVault(); + $vault->set('token', 'secret', 'aad'); + + $this->assertSame('secret', $vault->get('token', 'aad')); + } + + public function testGetThrowsWhenAadDoesNotMatch(): void + { + $vault = $this->createVault(); + $vault->set('token', 'secret', 'aad'); + + $this->expectException(RuntimeException::class); + $vault->get('token', 'bad'); + } + + public function testSecretsEncryptedBeforeRotationAreStillAccessible(): void + { + $vault = $this->createVault(); + $vault->set('legacy', 'secret'); + $vault->rotateKey('v2', 'another_32_byte_encryption_key!!'); + + $this->assertSame('secret', $vault->get('legacy')); + } + + public function testRotateKeyChangesActiveKey(): void + { + $storage = new InMemoryStorage(); + $vault = $this->createVault($storage); + $vault->rotateKey('v2', 'another_32_byte_encryption_key!!'); + $vault->set('current', 'secret'); + + $this->assertStringStartsWith('v2:', $storage->get('current')); + } } diff --git a/src/SonsOfPHP/Component/Vault/Vault.php b/src/SonsOfPHP/Component/Vault/Vault.php index 4d74c712..d34ea9b6 100644 --- a/src/SonsOfPHP/Component/Vault/Vault.php +++ b/src/SonsOfPHP/Component/Vault/Vault.php @@ -4,43 +4,70 @@ namespace SonsOfPHP\Component\Vault; +use RuntimeException; use SonsOfPHP\Component\Vault\Cipher\CipherInterface; use SonsOfPHP\Component\Vault\Storage\StorageInterface; /** - * Vault stores encrypted secrets using a pluggable storage backend. + * Vault stores encrypted secrets using a pluggable storage backend with + * support for additional authenticated data and key rotation. */ class Vault { /** - * @param StorageInterface $storage The storage backend. - * @param CipherInterface $cipher The cipher used for encryption. - * @param string $encryptionKey The encryption key. + * @param StorageInterface $storage The storage backend. + * @param CipherInterface $cipher The cipher used for encryption. + * @param array $keys Map of key IDs to encryption keys. + * @param string $currentKeyId Identifier of the active key. */ - public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private readonly string $encryptionKey) + public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private array $keys, private string $currentKeyId) { } /** * Stores a secret in the vault. + * + * @param string $name Identifier of the secret. + * @param mixed $secret The secret to store. + * @param string $aad Additional authenticated data. */ - public function set(string $name, string $secret): void + public function set(string $name, mixed $secret, string $aad = ''): void { - $encrypted = $this->cipher->encrypt($secret, $this->encryptionKey); - $this->storage->set($name, $encrypted); + $serialized = serialize($secret); + $key = $this->keys[$this->currentKeyId]; + $encrypted = $this->cipher->encrypt($serialized, $key, $aad); + $this->storage->set($name, $this->currentKeyId . ':' . $encrypted); } /** * Retrieves a secret from the vault or null if it does not exist. + * + * @param string $name Identifier of the secret. + * @param string $aad Additional authenticated data. + * + * @return mixed|null */ - public function get(string $name): ?string + public function get(string $name, string $aad = ''): mixed { - $encrypted = $this->storage->get($name); - if (null === $encrypted) { + $record = $this->storage->get($name); + if (null === $record) { return null; } - return $this->cipher->decrypt($encrypted, $this->encryptionKey); + [$keyId, $ciphertext] = explode(':', $record, 2); + $key = $this->keys[$keyId] ?? null; + if (null === $key) { + throw new RuntimeException('Unknown key identifier.'); + } + + $serialized = $this->cipher->decrypt($ciphertext, $key, $aad); + + $secret = @unserialize($serialized, ['allowed_classes' => false]); + if (false === $secret && 'b:0;' !== $serialized) { + throw new RuntimeException('Unable to unserialize secret.'); + } + + return $secret; } /** @@ -50,4 +77,16 @@ public function delete(string $name): void { $this->storage->delete($name); } + + /** + * Rotates the active encryption key. + * + * @param string $keyId Identifier for the new key. + * @param string $key The new encryption key. + */ + public function rotateKey(string $keyId, string $key): void + { + $this->keys[$keyId] = $key; + $this->currentKeyId = $keyId; + } } From afef94cd0db1354ec64f0cc274987b07e036262d Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Mon, 25 Aug 2025 22:58:21 -0400 Subject: [PATCH 3/4] feat(vault): add key ring and secret versioning --- docs/components/vault.md | 13 ++-- .../Vault/Cipher/CipherInterface.php | 10 ++- .../Component/Vault/Cipher/OpenSSLCipher.php | 25 ++++--- .../Vault/KeyRing/InMemoryKeyRing.php | 40 +++++++++++ .../Vault/KeyRing/KeyRingInterface.php | 33 +++++++++ src/SonsOfPHP/Component/Vault/README.md | 2 +- src/SonsOfPHP/Component/Vault/ROADMAP.md | 7 -- .../Component/Vault/Tests/VaultTest.php | 35 ++++++++-- src/SonsOfPHP/Component/Vault/Vault.php | 68 +++++++++++++------ 9 files changed, 181 insertions(+), 52 deletions(-) create mode 100644 src/SonsOfPHP/Component/Vault/KeyRing/InMemoryKeyRing.php create mode 100644 src/SonsOfPHP/Component/Vault/KeyRing/KeyRingInterface.php diff --git a/docs/components/vault.md b/docs/components/vault.md index e56c23c2..a83207d2 100644 --- a/docs/components/vault.md +++ b/docs/components/vault.md @@ -6,13 +6,18 @@ The Vault component securely stores secrets using pluggable storage backends and ```php use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher; +use SonsOfPHP\Component\Vault\KeyRing\InMemoryKeyRing; use SonsOfPHP\Component\Vault\Storage\InMemoryStorage; use SonsOfPHP\Component\Vault\Vault; -$keys = ['v1' => '32_byte_master_key_example!!']; -$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), $keys, 'v1'); -$vault->set('db_password', 'secret', 'app'); -$secret = $vault->get('db_password', 'app'); +$keyRing = new InMemoryKeyRing(['v1' => '32_byte_master_key_example!!'], 'v1'); +$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), $keyRing); +$vault->set('db_password', 'secret', ['app']); +$secret = $vault->get('db_password', ['app']); + +// Store a new version of the secret +$vault->set('db_password', 'new-secret', ['app']); +$oldSecret = $vault->get('db_password', ['app'], 1); // retrieve version 1 // Rotate the master key $vault->rotateKey('v2', 'another_32_byte_master_key!!'); diff --git a/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php index d825a866..291bedfa 100644 --- a/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php +++ b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php @@ -4,6 +4,8 @@ namespace SonsOfPHP\Component\Vault\Cipher; +use SensitiveParameter; + /** * Defines methods for encrypting and decrypting secrets. */ @@ -11,11 +13,15 @@ interface CipherInterface { /** * Encrypts plaintext using the provided key and optional authenticated data. + * + * @param array $aad Additional authenticated data. */ - public function encrypt(string $plaintext, string $key, string $aad = ''): string; + public function encrypt(#[SensitiveParameter] string $plaintext, #[SensitiveParameter] string $key, array $aad = []): string; /** * Decrypts ciphertext using the provided key and optional authenticated data. + * + * @param array $aad Additional authenticated data. */ - public function decrypt(string $ciphertext, string $key, string $aad = ''): string; + public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitiveParameter] string $key, array $aad = []): string; } diff --git a/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php index 9275cddd..195e8ba5 100644 --- a/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php +++ b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php @@ -5,6 +5,7 @@ namespace SonsOfPHP\Component\Vault\Cipher; use RuntimeException; +use SensitiveParameter; /** * Encrypts and decrypts secrets using OpenSSL. @@ -16,12 +17,14 @@ class OpenSSLCipher implements CipherInterface */ public function __construct(private readonly string $cipherMethod = 'aes-256-gcm') {} - public function encrypt(string $plaintext, string $key, string $aad = ''): string + /** @param array $aad */ + public function encrypt(#[SensitiveParameter] string $plaintext, #[SensitiveParameter] string $key, array $aad = []): string { - $ivLength = openssl_cipher_iv_length($this->cipherMethod); - $iv = random_bytes($ivLength); - $tag = ''; - $encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16); + $ivLength = openssl_cipher_iv_length($this->cipherMethod); + $iv = random_bytes($ivLength); + $tag = ''; + $encodedAad = json_encode($aad); + $encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad, 16); if (false === $encrypted) { throw new RuntimeException('Unable to encrypt secret.'); } @@ -29,7 +32,8 @@ public function encrypt(string $plaintext, string $key, string $aad = ''): strin return base64_encode($iv . $tag . $encrypted); } - public function decrypt(string $ciphertext, string $key, string $aad = ''): string + /** @param array $aad */ + public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitiveParameter] string $key, array $aad = []): string { $data = base64_decode($ciphertext, true); if (false === $data) { @@ -38,10 +42,11 @@ public function decrypt(string $ciphertext, string $key, string $aad = ''): stri $ivLength = openssl_cipher_iv_length($this->cipherMethod); $tagLength = 16; - $iv = substr($data, 0, $ivLength); - $tag = substr($data, $ivLength, $tagLength); - $payload = substr($data, $ivLength + $tagLength); - $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad); + $iv = substr($data, 0, $ivLength); + $tag = substr($data, $ivLength, $tagLength); + $payload = substr($data, $ivLength + $tagLength); + $encodedAad = json_encode($aad); + $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad); if (false === $decrypted) { throw new RuntimeException('Unable to decrypt secret.'); } diff --git a/src/SonsOfPHP/Component/Vault/KeyRing/InMemoryKeyRing.php b/src/SonsOfPHP/Component/Vault/KeyRing/InMemoryKeyRing.php new file mode 100644 index 00000000..3981ef09 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/KeyRing/InMemoryKeyRing.php @@ -0,0 +1,40 @@ + $keys Map of key IDs to keys. + * @param string $currentKeyId Identifier of the active key. + */ + public function __construct(private array $keys, private string $currentKeyId) {} + + public function getCurrentKeyId(): string + { + return $this->currentKeyId; + } + + public function getCurrentKey(): string + { + return $this->keys[$this->currentKeyId]; + } + + public function getKey(string $keyId): ?string + { + return $this->keys[$keyId] ?? null; + } + + public function rotate(string $keyId, #[SensitiveParameter] string $key): void + { + $this->keys[$keyId] = $key; + $this->currentKeyId = $keyId; + } +} diff --git a/src/SonsOfPHP/Component/Vault/KeyRing/KeyRingInterface.php b/src/SonsOfPHP/Component/Vault/KeyRing/KeyRingInterface.php new file mode 100644 index 00000000..e779e013 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/KeyRing/KeyRingInterface.php @@ -0,0 +1,33 @@ + 'test_encryption_key_32_bytes!']; + $keyRing = new InMemoryKeyRing(['v1' => 'test_encryption_key_32_bytes!'], 'v1'); - return new Vault($storage, $cipher, $keys, 'v1'); + return new Vault($storage, $cipher, $keyRing); } public function testSecretCanBeRetrieved(): void @@ -63,18 +64,18 @@ public function testSetAndGetWithArray(): void public function testSetAndGetWithAad(): void { $vault = $this->createVault(); - $vault->set('token', 'secret', 'aad'); + $vault->set('token', 'secret', ['aad']); - $this->assertSame('secret', $vault->get('token', 'aad')); + $this->assertSame('secret', $vault->get('token', ['aad'])); } public function testGetThrowsWhenAadDoesNotMatch(): void { $vault = $this->createVault(); - $vault->set('token', 'secret', 'aad'); + $vault->set('token', 'secret', ['aad']); $this->expectException(RuntimeException::class); - $vault->get('token', 'bad'); + $vault->get('token', ['bad']); } public function testSecretsEncryptedBeforeRotationAreStillAccessible(): void @@ -93,6 +94,26 @@ public function testRotateKeyChangesActiveKey(): void $vault->rotateKey('v2', 'another_32_byte_encryption_key!!'); $vault->set('current', 'secret'); - $this->assertStringStartsWith('v2:', $storage->get('current')); + $stored = $storage->get('current'); + $versions = unserialize($stored, ['allowed_classes' => false]); + $this->assertStringStartsWith('v2:', $versions['1']); + } + + public function testGetReturnsLatestVersion(): void + { + $vault = $this->createVault(); + $vault->set('name', 'first'); + $vault->set('name', 'second'); + + $this->assertSame('second', $vault->get('name')); + } + + public function testSpecificVersionCanBeRetrieved(): void + { + $vault = $this->createVault(); + $vault->set('name', 'first'); + $vault->set('name', 'second'); + + $this->assertSame('first', $vault->get('name', [], 1)); } } diff --git a/src/SonsOfPHP/Component/Vault/Vault.php b/src/SonsOfPHP/Component/Vault/Vault.php index d34ea9b6..efbb9983 100644 --- a/src/SonsOfPHP/Component/Vault/Vault.php +++ b/src/SonsOfPHP/Component/Vault/Vault.php @@ -5,7 +5,9 @@ namespace SonsOfPHP\Component\Vault; use RuntimeException; +use SensitiveParameter; use SonsOfPHP\Component\Vault\Cipher\CipherInterface; +use SonsOfPHP\Component\Vault\KeyRing\KeyRingInterface; use SonsOfPHP\Component\Vault\Storage\StorageInterface; /** @@ -15,47 +17,72 @@ class Vault { /** - * @param StorageInterface $storage The storage backend. - * @param CipherInterface $cipher The cipher used for encryption. - * @param array $keys Map of key IDs to encryption keys. - * @param string $currentKeyId Identifier of the active key. + * @param StorageInterface $storage The storage backend. + * @param CipherInterface $cipher The cipher used for encryption. + * @param KeyRingInterface $keyRing Provides access to encryption keys. */ - public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private array $keys, private string $currentKeyId) - { - } + public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private readonly KeyRingInterface $keyRing) {} /** * Stores a secret in the vault. * - * @param string $name Identifier of the secret. - * @param mixed $secret The secret to store. - * @param string $aad Additional authenticated data. + * @param string $name Identifier of the secret. + * @param mixed $secret The secret to store. + * @param array $aad Additional authenticated data. */ - public function set(string $name, mixed $secret, string $aad = ''): void + public function set(string $name, #[SensitiveParameter] mixed $secret, array $aad = []): void { $serialized = serialize($secret); - $key = $this->keys[$this->currentKeyId]; + $key = $this->keyRing->getCurrentKey(); $encrypted = $this->cipher->encrypt($serialized, $key, $aad); - $this->storage->set($name, $this->currentKeyId . ':' . $encrypted); + + $record = $this->storage->get($name); + if (null === $record) { + $versions = []; + } else { + $versions = @unserialize($record, ['allowed_classes' => false]); + if (!is_array($versions)) { + throw new RuntimeException('Invalid secret storage format.'); + } + } + + $version = $versions === [] ? 1 : max(array_map('intval', array_keys($versions))) + 1; + $versions[(string) $version] = $this->keyRing->getCurrentKeyId() . ':' . $encrypted; + $this->storage->set($name, serialize($versions)); } /** * Retrieves a secret from the vault or null if it does not exist. * - * @param string $name Identifier of the secret. - * @param string $aad Additional authenticated data. + * @param string $name Identifier of the secret. + * @param array $aad Additional authenticated data. + * @param int|null $version Specific version to retrieve or null for latest. * * @return mixed|null */ - public function get(string $name, string $aad = ''): mixed + public function get(string $name, array $aad = [], ?int $version = null): mixed { $record = $this->storage->get($name); if (null === $record) { return null; } - [$keyId, $ciphertext] = explode(':', $record, 2); - $key = $this->keys[$keyId] ?? null; + $versions = @unserialize($record, ['allowed_classes' => false]); + if (!is_array($versions)) { + throw new RuntimeException('Invalid secret storage format.'); + } + + if (null === $version) { + $version = (int) max(array_map('intval', array_keys($versions))); + } + + $entry = $versions[(string) $version] ?? null; + if (null === $entry) { + return null; + } + + [$keyId, $ciphertext] = explode(':', (string) $entry, 2); + $key = $this->keyRing->getKey($keyId); if (null === $key) { throw new RuntimeException('Unknown key identifier.'); } @@ -84,9 +111,8 @@ public function delete(string $name): void * @param string $keyId Identifier for the new key. * @param string $key The new encryption key. */ - public function rotateKey(string $keyId, string $key): void + public function rotateKey(string $keyId, #[SensitiveParameter] string $key): void { - $this->keys[$keyId] = $key; - $this->currentKeyId = $keyId; + $this->keyRing->rotate($keyId, $key); } } From 016938fae7a9e24ba71d9f13bfac562dc92ad0ea Mon Sep 17 00:00:00 2001 From: Joshua Estes Date: Tue, 26 Aug 2025 09:38:36 -0400 Subject: [PATCH 4/4] feat(vault): add marshaller and domain exceptions --- docs/components/vault.md | 56 ++++++++++++---- src/SonsOfPHP/Component/Vault/AGENTS.md | 10 +++ .../Component/Vault/Cipher/OpenSSLCipher.php | 23 +++++-- .../Vault/Exception/CipherException.php | 10 +++ .../Exception/DecryptionFailedException.php | 10 +++ .../Exception/EncryptionFailedException.php | 10 +++ .../Exception/InvalidCiphertextException.php | 10 +++ .../Vault/Exception/MarshallingException.php | 10 +++ .../SecretStorageCorruptedException.php | 10 +++ .../Vault/Exception/UnknownKeyException.php | 10 +++ .../Vault/Exception/VaultException.php | 12 ++++ .../Vault/Marshaller/JsonMarshaller.php | 32 +++++++++ .../Vault/Marshaller/MarshallerInterface.php | 25 +++++++ src/SonsOfPHP/Component/Vault/README.md | 2 +- src/SonsOfPHP/Component/Vault/ROADMAP.md | 7 ++ .../Component/Vault/Tests/VaultTest.php | 37 +++++++---- src/SonsOfPHP/Component/Vault/Vault.php | 65 ++++++++++++------- src/SonsOfPHP/Component/Vault/composer.json | 1 + 18 files changed, 287 insertions(+), 53 deletions(-) create mode 100644 src/SonsOfPHP/Component/Vault/Exception/CipherException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/DecryptionFailedException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/EncryptionFailedException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/InvalidCiphertextException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/MarshallingException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/SecretStorageCorruptedException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/UnknownKeyException.php create mode 100644 src/SonsOfPHP/Component/Vault/Exception/VaultException.php create mode 100644 src/SonsOfPHP/Component/Vault/Marshaller/JsonMarshaller.php create mode 100644 src/SonsOfPHP/Component/Vault/Marshaller/MarshallerInterface.php diff --git a/docs/components/vault.md b/docs/components/vault.md index a83207d2..64966883 100644 --- a/docs/components/vault.md +++ b/docs/components/vault.md @@ -1,28 +1,62 @@ # Vault -The Vault component securely stores secrets using pluggable storage backends and encryption with key rotation and support for additional authenticated data (AAD). +The Vault component securely stores secrets using pluggable storage backends and encryption with key rotation, versioning, and additional authenticated data (AAD). -## Basic Usage +## Setup ```php use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher; use SonsOfPHP\Component\Vault\KeyRing\InMemoryKeyRing; +use SonsOfPHP\Component\Vault\Marshaller\JsonMarshaller; use SonsOfPHP\Component\Vault\Storage\InMemoryStorage; use SonsOfPHP\Component\Vault\Vault; -$keyRing = new InMemoryKeyRing(['v1' => '32_byte_master_key_example!!'], 'v1'); -$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), $keyRing); -$vault->set('db_password', 'secret', ['app']); -$secret = $vault->get('db_password', ['app']); +$storage = new InMemoryStorage(); +$cipher = new OpenSSLCipher(); +$keyRing = new InMemoryKeyRing(['v1' => '32_byte_master_key_example!!'], 'v1'); +$marshaller = new JsonMarshaller(); +$vault = new Vault($storage, $cipher, $keyRing, $marshaller); +``` + +## Storing and Retrieving Secrets + +```php +$vault->set('db_password', 'secret'); +$secret = $vault->get('db_password'); +``` + +## Using Additional Authenticated Data -// Store a new version of the secret -$vault->set('db_password', 'new-secret', ['app']); -$oldSecret = $vault->get('db_password', ['app'], 1); // retrieve version 1 +```php +$vault->set('token', 'secret', ['app']); +$secret = $vault->get('token', ['app']); +``` + +## Versioned Secrets + +```php +$vault->set('db_password', 'old-secret'); +$vault->set('db_password', 'new-secret'); -// Rotate the master key +// Retrieve specific version +$old = $vault->get('db_password', [], 1); +``` + +## Rotating Keys + +```php $vault->rotateKey('v2', 'another_32_byte_master_key!!'); +``` -// Store non-string data +Secrets encrypted with previous keys remain accessible because the key ring keeps old keys. + +## Storing Non-String Data + +```php $vault->set('config', ['user' => 'root']); $config = $vault->get('config'); ``` + +## Custom Marshallers + +Vault uses a marshaller to convert secrets to strings before encryption. The default `JsonMarshaller` handles arrays and scalars, but you can provide your own implementation of `MarshallerInterface` for advanced use cases. diff --git a/src/SonsOfPHP/Component/Vault/AGENTS.md b/src/SonsOfPHP/Component/Vault/AGENTS.md index a9188504..41683607 100644 --- a/src/SonsOfPHP/Component/Vault/AGENTS.md +++ b/src/SonsOfPHP/Component/Vault/AGENTS.md @@ -12,3 +12,13 @@ - Test this package only: `PHPUNIT_OPTIONS='src/SonsOfPHP/Component/Vault/Tests' make test` - Style and static analysis: `make php-cs-fixer` and `make psalm` - Upgrade code (may modify files): `make upgrade-code` + +## Testing Guidelines + +- Use PHPUnit attributes such as `#[CoversClass]` for coverage. +- Each test method should verify a single behavior. + +## Component Notes + +- Secrets are marshalled using implementations of `MarshallerInterface`. +- Prefer domain-specific exceptions under `Exception/` when reporting errors. diff --git a/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php index 195e8ba5..46b06629 100644 --- a/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php +++ b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php @@ -4,8 +4,10 @@ namespace SonsOfPHP\Component\Vault\Cipher; -use RuntimeException; use SensitiveParameter; +use SonsOfPHP\Component\Vault\Exception\DecryptionFailedException; +use SonsOfPHP\Component\Vault\Exception\EncryptionFailedException; +use SonsOfPHP\Component\Vault\Exception\InvalidCiphertextException; /** * Encrypts and decrypts secrets using OpenSSL. @@ -17,7 +19,11 @@ class OpenSSLCipher implements CipherInterface */ public function __construct(private readonly string $cipherMethod = 'aes-256-gcm') {} - /** @param array $aad */ + /** + * @param array $aad Additional authenticated data. + * + * @throws EncryptionFailedException + */ public function encrypt(#[SensitiveParameter] string $plaintext, #[SensitiveParameter] string $key, array $aad = []): string { $ivLength = openssl_cipher_iv_length($this->cipherMethod); @@ -26,18 +32,23 @@ public function encrypt(#[SensitiveParameter] string $plaintext, #[SensitivePara $encodedAad = json_encode($aad); $encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad, 16); if (false === $encrypted) { - throw new RuntimeException('Unable to encrypt secret.'); + throw new EncryptionFailedException('Unable to encrypt secret.'); } return base64_encode($iv . $tag . $encrypted); } - /** @param array $aad */ + /** + * @param array $aad Additional authenticated data. + * + * @throws InvalidCiphertextException + * @throws DecryptionFailedException + */ public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitiveParameter] string $key, array $aad = []): string { $data = base64_decode($ciphertext, true); if (false === $data) { - throw new RuntimeException('Invalid ciphertext.'); + throw new InvalidCiphertextException('Invalid ciphertext.'); } $ivLength = openssl_cipher_iv_length($this->cipherMethod); @@ -48,7 +59,7 @@ public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitivePar $encodedAad = json_encode($aad); $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad); if (false === $decrypted) { - throw new RuntimeException('Unable to decrypt secret.'); + throw new DecryptionFailedException('Unable to decrypt secret.'); } return $decrypted; diff --git a/src/SonsOfPHP/Component/Vault/Exception/CipherException.php b/src/SonsOfPHP/Component/Vault/Exception/CipherException.php new file mode 100644 index 00000000..8c01d298 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Exception/CipherException.php @@ -0,0 +1,10 @@ + 'test_encryption_key_32_bytes!'], 'v1'); + $storage ??= new InMemoryStorage(); + $cipher = new OpenSSLCipher(); + $keyRing = new InMemoryKeyRing(['v1' => 'test_encryption_key_32_bytes!'], 'v1'); + $marshaller = new JsonMarshaller(); - return new Vault($storage, $cipher, $keyRing); + return new Vault($storage, $cipher, $keyRing, $marshaller); } public function testSecretCanBeRetrieved(): void @@ -74,7 +75,7 @@ public function testGetThrowsWhenAadDoesNotMatch(): void $vault = $this->createVault(); $vault->set('token', 'secret', ['aad']); - $this->expectException(RuntimeException::class); + $this->expectException(DecryptionFailedException::class); $vault->get('token', ['bad']); } @@ -95,7 +96,7 @@ public function testRotateKeyChangesActiveKey(): void $vault->set('current', 'secret'); $stored = $storage->get('current'); - $versions = unserialize($stored, ['allowed_classes' => false]); + $versions = json_decode((string) $stored, true, 512, JSON_THROW_ON_ERROR); $this->assertStringStartsWith('v2:', $versions['1']); } @@ -116,4 +117,18 @@ public function testSpecificVersionCanBeRetrieved(): void $this->assertSame('first', $vault->get('name', [], 1)); } + + public function testGetThrowsWhenKeyIsUnknown(): void + { + $storage = new InMemoryStorage(); + $vault = $this->createVault($storage); + $vault->set('secret', 'value'); + + $otherKeyRing = new InMemoryKeyRing(['v2' => 'another_32_byte_encryption_key!!'], 'v2'); + $marshaller = new JsonMarshaller(); + $otherVault = new Vault($storage, new OpenSSLCipher(), $otherKeyRing, $marshaller); + + $this->expectException(UnknownKeyException::class); + $otherVault->get('secret'); + } } diff --git a/src/SonsOfPHP/Component/Vault/Vault.php b/src/SonsOfPHP/Component/Vault/Vault.php index efbb9983..626bbff4 100644 --- a/src/SonsOfPHP/Component/Vault/Vault.php +++ b/src/SonsOfPHP/Component/Vault/Vault.php @@ -4,10 +4,14 @@ namespace SonsOfPHP\Component\Vault; -use RuntimeException; +use JsonException; use SensitiveParameter; use SonsOfPHP\Component\Vault\Cipher\CipherInterface; +use SonsOfPHP\Component\Vault\Exception\MarshallingException; +use SonsOfPHP\Component\Vault\Exception\SecretStorageCorruptedException; +use SonsOfPHP\Component\Vault\Exception\UnknownKeyException; use SonsOfPHP\Component\Vault\KeyRing\KeyRingInterface; +use SonsOfPHP\Component\Vault\Marshaller\MarshallerInterface; use SonsOfPHP\Component\Vault\Storage\StorageInterface; /** @@ -17,38 +21,47 @@ class Vault { /** - * @param StorageInterface $storage The storage backend. - * @param CipherInterface $cipher The cipher used for encryption. - * @param KeyRingInterface $keyRing Provides access to encryption keys. + * @param StorageInterface $storage The storage backend. + * @param CipherInterface $cipher The cipher used for encryption. + * @param KeyRingInterface $keyRing Provides access to encryption keys. + * @param MarshallerInterface $marshaller Converts secrets to storable strings. */ - public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private readonly KeyRingInterface $keyRing) {} + public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private readonly KeyRingInterface $keyRing, private readonly MarshallerInterface $marshaller) {} /** * Stores a secret in the vault. * - * @param string $name Identifier of the secret. - * @param mixed $secret The secret to store. - * @param array $aad Additional authenticated data. + * @param string $name Identifier of the secret. + * @param mixed $secret The secret to store. + * @param array $aad Additional authenticated data. + * + * @throws MarshallingException + * @throws SecretStorageCorruptedException */ public function set(string $name, #[SensitiveParameter] mixed $secret, array $aad = []): void { - $serialized = serialize($secret); - $key = $this->keyRing->getCurrentKey(); - $encrypted = $this->cipher->encrypt($serialized, $key, $aad); + $encoded = $this->marshaller->marshall($secret); + $key = $this->keyRing->getCurrentKey(); + $encrypted = $this->cipher->encrypt($encoded, $key, $aad); $record = $this->storage->get($name); if (null === $record) { $versions = []; } else { - $versions = @unserialize($record, ['allowed_classes' => false]); + try { + $versions = json_decode($record, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new SecretStorageCorruptedException('Invalid secret storage format.', 0, $e); + } + if (!is_array($versions)) { - throw new RuntimeException('Invalid secret storage format.'); + throw new SecretStorageCorruptedException('Invalid secret storage format.'); } } $version = $versions === [] ? 1 : max(array_map('intval', array_keys($versions))) + 1; $versions[(string) $version] = $this->keyRing->getCurrentKeyId() . ':' . $encrypted; - $this->storage->set($name, serialize($versions)); + $this->storage->set($name, json_encode($versions, JSON_THROW_ON_ERROR)); } /** @@ -59,6 +72,10 @@ public function set(string $name, #[SensitiveParameter] mixed $secret, array $aa * @param int|null $version Specific version to retrieve or null for latest. * * @return mixed|null + * + * @throws MarshallingException + * @throws SecretStorageCorruptedException + * @throws UnknownKeyException */ public function get(string $name, array $aad = [], ?int $version = null): mixed { @@ -67,9 +84,14 @@ public function get(string $name, array $aad = [], ?int $version = null): mixed return null; } - $versions = @unserialize($record, ['allowed_classes' => false]); + try { + $versions = json_decode($record, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $jsonException) { + throw new SecretStorageCorruptedException('Invalid secret storage format.', 0, $jsonException); + } + if (!is_array($versions)) { - throw new RuntimeException('Invalid secret storage format.'); + throw new SecretStorageCorruptedException('Invalid secret storage format.'); } if (null === $version) { @@ -84,17 +106,12 @@ public function get(string $name, array $aad = [], ?int $version = null): mixed [$keyId, $ciphertext] = explode(':', (string) $entry, 2); $key = $this->keyRing->getKey($keyId); if (null === $key) { - throw new RuntimeException('Unknown key identifier.'); + throw new UnknownKeyException(sprintf('Unknown key identifier "%s".', $keyId)); } - $serialized = $this->cipher->decrypt($ciphertext, $key, $aad); - - $secret = @unserialize($serialized, ['allowed_classes' => false]); - if (false === $secret && 'b:0;' !== $serialized) { - throw new RuntimeException('Unable to unserialize secret.'); - } + $encoded = $this->cipher->decrypt($ciphertext, $key, $aad); - return $secret; + return $this->marshaller->unmarshall($encoded); } /** diff --git a/src/SonsOfPHP/Component/Vault/composer.json b/src/SonsOfPHP/Component/Vault/composer.json index 408d9bb9..467ded5a 100644 --- a/src/SonsOfPHP/Component/Vault/composer.json +++ b/src/SonsOfPHP/Component/Vault/composer.json @@ -36,6 +36,7 @@ "prefer-stable": true, "require": { "php": ">=8.3", + "ext-json": "*", "ext-openssl": "*" }, "extra": {