Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions docs/components/vault.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions src/SonsOfPHP/Component/Vault/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Cipher;

use SensitiveParameter;

/**
* Defines methods for encrypting and decrypting secrets.
*/
interface CipherInterface
{
/**
* Encrypts plaintext using the provided key and optional authenticated data.
*
* @param array<array-key, mixed> $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<array-key, mixed> $aad Additional authenticated data.
*/
public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitiveParameter] string $key, array $aad = []): string;
}
67 changes: 67 additions & 0 deletions src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Cipher;

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.
*/
class OpenSSLCipher implements CipherInterface
{
/**
* @param string $cipherMethod OpenSSL cipher method supporting AEAD.
*/
public function __construct(private readonly string $cipherMethod = 'aes-256-gcm') {}

/**
* @param array<array-key, mixed> $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<array-key, mixed> $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;
}
}
10 changes: 10 additions & 0 deletions src/SonsOfPHP/Component/Vault/Exception/CipherException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Base exception for cipher related failures.
*/
class CipherException extends VaultException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Thrown when a value cannot be decrypted.
*/
class DecryptionFailedException extends CipherException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Thrown when a value cannot be encrypted.
*/
class EncryptionFailedException extends CipherException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Thrown when ciphertext cannot be decoded before decryption.
*/
class InvalidCiphertextException extends CipherException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Thrown when marshalling or unmarshalling secret data fails.
*/
class MarshallingException extends VaultException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Thrown when the stored secret data is malformed or corrupted.
*/
class SecretStorageCorruptedException extends VaultException {}
10 changes: 10 additions & 0 deletions src/SonsOfPHP/Component/Vault/Exception/UnknownKeyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

/**
* Thrown when a key cannot be found in the key ring.
*/
class UnknownKeyException extends VaultException {}
12 changes: 12 additions & 0 deletions src/SonsOfPHP/Component/Vault/Exception/VaultException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\Exception;

use RuntimeException;

/**
* Base exception for the Vault component.
*/
class VaultException extends RuntimeException {}
40 changes: 40 additions & 0 deletions src/SonsOfPHP/Component/Vault/KeyRing/InMemoryKeyRing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\KeyRing;

use SensitiveParameter;

/**
* Simple key ring that keeps keys in memory.
*/
class InMemoryKeyRing implements KeyRingInterface
{
/**
* @param array<string, string> $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;
}
}
33 changes: 33 additions & 0 deletions src/SonsOfPHP/Component/Vault/KeyRing/KeyRingInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace SonsOfPHP\Component\Vault\KeyRing;

use SensitiveParameter;

/**
* Manages encryption keys and their versions.
*/
interface KeyRingInterface
{
/**
* Returns the identifier for the current key.
*/
public function getCurrentKeyId(): string;

/**
* Returns the current encryption key.
*/
public function getCurrentKey(): string;

/**
* Fetches a key by its identifier or null if missing.
*/
public function getKey(string $keyId): ?string;

/**
* Rotates to a new key and sets it as current.
*/
public function rotate(string $keyId, #[SensitiveParameter] string $key): void;
}
Loading
Loading