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..64966883 --- /dev/null +++ b/docs/components/vault.md @@ -0,0 +1,62 @@ +# Vault + +The Vault component securely stores secrets using pluggable storage backends and encryption with key rotation, versioning, and additional authenticated data (AAD). + +## 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; + +$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 + +```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'); + +// Retrieve specific version +$old = $vault->get('db_password', [], 1); +``` + +## Rotating Keys + +```php +$vault->rotateKey('v2', 'another_32_byte_master_key!!'); +``` + +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 new file mode 100644 index 00000000..41683607 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/AGENTS.md @@ -0,0 +1,24 @@ +# 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` + +## 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/CipherInterface.php b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php new file mode 100644 index 00000000..291bedfa --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php @@ -0,0 +1,27 @@ + $aad Additional authenticated data. + */ + 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(#[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 new file mode 100644 index 00000000..46b06629 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php @@ -0,0 +1,67 @@ + $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); + $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 EncryptionFailedException('Unable to encrypt secret.'); + } + + return base64_encode($iv . $tag . $encrypted); + } + + /** + * @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 InvalidCiphertextException('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); + $encodedAad = json_encode($aad); + $decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad); + if (false === $decrypted) { + 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 @@ + $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 @@ + + */ + 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 @@ + 'test_encryption_key_32_bytes!'], 'v1'); + $marshaller = new JsonMarshaller(); + + return new Vault($storage, $cipher, $keyRing, $marshaller); + } + + public function testSecretCanBeRetrieved(): void + { + $vault = $this->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')); + } + + 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(DecryptionFailedException::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'); + + $stored = $storage->get('current'); + $versions = json_decode((string) $stored, true, 512, JSON_THROW_ON_ERROR); + $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)); + } + + 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 new file mode 100644 index 00000000..626bbff4 --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/Vault.php @@ -0,0 +1,135 @@ + $aad Additional authenticated data. + * + * @throws MarshallingException + * @throws SecretStorageCorruptedException + */ + public function set(string $name, #[SensitiveParameter] mixed $secret, array $aad = []): void + { + $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 { + 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 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, json_encode($versions, JSON_THROW_ON_ERROR)); + } + + /** + * Retrieves a secret from the vault or null if it does not exist. + * + * @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 + * + * @throws MarshallingException + * @throws SecretStorageCorruptedException + * @throws UnknownKeyException + */ + public function get(string $name, array $aad = [], ?int $version = null): mixed + { + $record = $this->storage->get($name); + if (null === $record) { + return null; + } + + 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 SecretStorageCorruptedException('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 UnknownKeyException(sprintf('Unknown key identifier "%s".', $keyId)); + } + + $encoded = $this->cipher->decrypt($ciphertext, $key, $aad); + + return $this->marshaller->unmarshall($encoded); + } + + /** + * Removes a secret from the vault. + */ + 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, #[SensitiveParameter] string $key): void + { + $this->keyRing->rotate($keyId, $key); + } +} diff --git a/src/SonsOfPHP/Component/Vault/composer.json b/src/SonsOfPHP/Component/Vault/composer.json new file mode 100644 index 00000000..467ded5a --- /dev/null +++ b/src/SonsOfPHP/Component/Vault/composer.json @@ -0,0 +1,58 @@ +{ + "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-json": "*", + "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 + + +