Skip to content

Commit 4bb09e3

Browse files
committed
Enhance Vault with AAD and key rotation
1 parent 42de637 commit 4bb09e3

8 files changed

Lines changed: 141 additions & 68 deletions

File tree

ROADMAP.md

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -121,35 +121,6 @@ This roadmap outlines planned libraries and updates for the Sons of PHP monorepo
121121
- Release notes highlight Symfony 7 support.
122122
- Implementation meets the Global DoD.
123123

124-
### Epic: Introduce Vault Component
125-
**Description:** Provide secure secret storage with pluggable backends.
126-
127-
- [ ] Add filesystem storage adapter.
128-
- **Acceptance Criteria**
129-
- All Global DoR items are satisfied before implementation begins.
130-
- Filesystem adapter encrypts and retrieves secrets reliably.
131-
- Implementation meets the Global DoD.
132-
- [ ] Add database storage adapter.
133-
- **Acceptance Criteria**
134-
- All Global DoR items are satisfied before implementation begins.
135-
- Database adapter persists encrypted secrets.
136-
- Implementation meets the Global DoD.
137-
- [ ] Add Redis storage adapter.
138-
- **Acceptance Criteria**
139-
- All Global DoR items are satisfied before implementation begins.
140-
- Redis adapter stores encrypted secrets and respects expirations.
141-
- Implementation meets the Global DoD.
142-
- [ ] Support key rotation and secret versioning.
143-
- **Acceptance Criteria**
144-
- All Global DoR items are satisfied before implementation begins.
145-
- Secrets can be rotated without loss using new keys.
146-
- Implementation meets the Global DoD.
147-
- [ ] Provide CLI tools for managing secrets.
148-
- **Acceptance Criteria**
149-
- All Global DoR items are satisfied before implementation begins.
150-
- CLI allows setting, retrieving, and rotating secrets.
151-
- Implementation meets the Global DoD.
152-
153124
## Suggestions
154125

155126
- Automate dependency updates with a scheduled tool (e.g., Renovate or Dependabot).

docs/components/vault.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Vault
22

3-
The Vault component securely stores secrets using pluggable storage backends and encryption.
3+
The Vault component securely stores secrets using pluggable storage backends and encryption with key rotation and support for additional authenticated data (AAD).
44

55
## Basic Usage
66

@@ -9,7 +9,15 @@ use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher;
99
use SonsOfPHP\Component\Vault\Storage\InMemoryStorage;
1010
use SonsOfPHP\Component\Vault\Vault;
1111

12-
$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), 'encryption-key');
13-
$vault->set('db_password', 'secret');
14-
$secret = $vault->get('db_password');
12+
$keys = ['v1' => '32_byte_master_key_example!!'];
13+
$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), $keys, 'v1');
14+
$vault->set('db_password', 'secret', 'app');
15+
$secret = $vault->get('db_password', 'app');
16+
17+
// Rotate the master key
18+
$vault->rotateKey('v2', 'another_32_byte_master_key!!');
19+
20+
// Store non-string data
21+
$vault->set('config', ['user' => 'root']);
22+
$config = $vault->get('config');
1523
```

src/SonsOfPHP/Component/Vault/Cipher/CipherInterface.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
interface CipherInterface
1111
{
1212
/**
13-
* Encrypts plaintext using the provided key.
13+
* Encrypts plaintext using the provided key and optional authenticated data.
1414
*/
15-
public function encrypt(string $plaintext, string $key): string;
15+
public function encrypt(string $plaintext, string $key, string $aad = ''): string;
1616

1717
/**
18-
* Decrypts ciphertext using the provided key.
18+
* Decrypts ciphertext using the provided key and optional authenticated data.
1919
*/
20-
public function decrypt(string $ciphertext, string $key): string;
20+
public function decrypt(string $ciphertext, string $key, string $aad = ''): string;
2121
}

src/SonsOfPHP/Component/Vault/Cipher/OpenSSLCipher.php

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,37 @@
1111
*/
1212
class OpenSSLCipher implements CipherInterface
1313
{
14-
public function __construct(private readonly string $cipherMethod = 'aes-256-ctr')
15-
{
16-
}
14+
/**
15+
* @param string $cipherMethod OpenSSL cipher method supporting AEAD.
16+
*/
17+
public function __construct(private readonly string $cipherMethod = 'aes-256-gcm') {}
1718

18-
public function encrypt(string $plaintext, string $key): string
19+
public function encrypt(string $plaintext, string $key, string $aad = ''): string
1920
{
2021
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
2122
$iv = random_bytes($ivLength);
22-
$encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv);
23+
$tag = '';
24+
$encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16);
2325
if (false === $encrypted) {
2426
throw new RuntimeException('Unable to encrypt secret.');
2527
}
2628

27-
return base64_encode($iv . $encrypted);
29+
return base64_encode($iv . $tag . $encrypted);
2830
}
2931

30-
public function decrypt(string $ciphertext, string $key): string
32+
public function decrypt(string $ciphertext, string $key, string $aad = ''): string
3133
{
32-
$data = base64_decode($ciphertext, true);
33-
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
34-
$iv = substr($data, 0, $ivLength);
35-
$payload = substr($data, $ivLength);
36-
$decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv);
34+
$data = base64_decode($ciphertext, true);
35+
if (false === $data) {
36+
throw new RuntimeException('Invalid ciphertext.');
37+
}
38+
39+
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
40+
$tagLength = 16;
41+
$iv = substr($data, 0, $ivLength);
42+
$tag = substr($data, $ivLength, $tagLength);
43+
$payload = substr($data, $ivLength + $tagLength);
44+
$decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad);
3745
if (false === $decrypted) {
3846
throw new RuntimeException('Unable to decrypt secret.');
3947
}

src/SonsOfPHP/Component/Vault/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
Sons of PHP - Vault
22
===================
33

4+
Secure secret storage with pluggable backends, key rotation, and support for additional authenticated data.
5+
46
## Learn More
57

68
* [Documentation][docs]

src/SonsOfPHP/Component/Vault/ROADMAP.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
- Redis adapter encrypts secrets and handles expirations.
2424
- Implementation meets the Global DoD.
2525

26-
### Support key rotation and secret versioning
27-
- [ ] Provide APIs to rotate encryption keys and maintain secret history.
26+
### Add secret versioning
27+
- [ ] Provide APIs to maintain secret history across updates.
2828
- **Acceptance Criteria**
2929
- All Global DoR items are satisfied before implementation begins.
30-
- Secrets can be re-encrypted with new keys without loss.
30+
- Previous versions of a secret remain accessible.
3131
- Implementation meets the Global DoD.
3232

3333
### Provide CLI tools for managing secrets

src/SonsOfPHP/Component/Vault/Tests/VaultTest.php

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace SonsOfPHP\Component\Vault\Tests;
66

77
use PHPUnit\Framework\TestCase;
8+
use RuntimeException;
89
use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher;
910
use SonsOfPHP\Component\Vault\Storage\InMemoryStorage;
1011
use SonsOfPHP\Component\Vault\Vault;
@@ -18,13 +19,13 @@ class VaultTest extends TestCase
1819
/**
1920
* Creates a vault instance for testing.
2021
*/
21-
private function createVault(): Vault
22+
private function createVault(?InMemoryStorage &$storage = null): Vault
2223
{
23-
$storage = new InMemoryStorage();
24+
$storage ??= new InMemoryStorage();
2425
$cipher = new OpenSSLCipher();
25-
$key = 'test_encryption_key_32_bytes!';
26+
$keys = ['v1' => 'test_encryption_key_32_bytes!'];
2627

27-
return new Vault($storage, $cipher, $key);
28+
return new Vault($storage, $cipher, $keys, 'v1');
2829
}
2930

3031
public function testSecretCanBeRetrieved(): void
@@ -50,4 +51,48 @@ public function testSecretCanBeDeleted(): void
5051

5152
$this->assertNull($vault->get('api_key'));
5253
}
54+
55+
public function testSetAndGetWithArray(): void
56+
{
57+
$vault = $this->createVault();
58+
$vault->set('config', ['user' => 'root']);
59+
60+
$this->assertSame(['user' => 'root'], $vault->get('config'));
61+
}
62+
63+
public function testSetAndGetWithAad(): void
64+
{
65+
$vault = $this->createVault();
66+
$vault->set('token', 'secret', 'aad');
67+
68+
$this->assertSame('secret', $vault->get('token', 'aad'));
69+
}
70+
71+
public function testGetThrowsWhenAadDoesNotMatch(): void
72+
{
73+
$vault = $this->createVault();
74+
$vault->set('token', 'secret', 'aad');
75+
76+
$this->expectException(RuntimeException::class);
77+
$vault->get('token', 'bad');
78+
}
79+
80+
public function testSecretsEncryptedBeforeRotationAreStillAccessible(): void
81+
{
82+
$vault = $this->createVault();
83+
$vault->set('legacy', 'secret');
84+
$vault->rotateKey('v2', 'another_32_byte_encryption_key!!');
85+
86+
$this->assertSame('secret', $vault->get('legacy'));
87+
}
88+
89+
public function testRotateKeyChangesActiveKey(): void
90+
{
91+
$storage = new InMemoryStorage();
92+
$vault = $this->createVault($storage);
93+
$vault->rotateKey('v2', 'another_32_byte_encryption_key!!');
94+
$vault->set('current', 'secret');
95+
96+
$this->assertStringStartsWith('v2:', $storage->get('current'));
97+
}
5398
}

src/SonsOfPHP/Component/Vault/Vault.php

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,70 @@
44

55
namespace SonsOfPHP\Component\Vault;
66

7+
use RuntimeException;
78
use SonsOfPHP\Component\Vault\Cipher\CipherInterface;
89
use SonsOfPHP\Component\Vault\Storage\StorageInterface;
910

1011
/**
11-
* Vault stores encrypted secrets using a pluggable storage backend.
12+
* Vault stores encrypted secrets using a pluggable storage backend with
13+
* support for additional authenticated data and key rotation.
1214
*/
1315
class Vault
1416
{
1517
/**
16-
* @param StorageInterface $storage The storage backend.
17-
* @param CipherInterface $cipher The cipher used for encryption.
18-
* @param string $encryptionKey The encryption key.
18+
* @param StorageInterface $storage The storage backend.
19+
* @param CipherInterface $cipher The cipher used for encryption.
20+
* @param array<string,string> $keys Map of key IDs to encryption keys.
21+
* @param string $currentKeyId Identifier of the active key.
1922
*/
20-
public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private readonly string $encryptionKey)
23+
public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private array $keys, private string $currentKeyId)
2124
{
2225
}
2326

2427
/**
2528
* Stores a secret in the vault.
29+
*
30+
* @param string $name Identifier of the secret.
31+
* @param mixed $secret The secret to store.
32+
* @param string $aad Additional authenticated data.
2633
*/
27-
public function set(string $name, string $secret): void
34+
public function set(string $name, mixed $secret, string $aad = ''): void
2835
{
29-
$encrypted = $this->cipher->encrypt($secret, $this->encryptionKey);
30-
$this->storage->set($name, $encrypted);
36+
$serialized = serialize($secret);
37+
$key = $this->keys[$this->currentKeyId];
38+
$encrypted = $this->cipher->encrypt($serialized, $key, $aad);
39+
$this->storage->set($name, $this->currentKeyId . ':' . $encrypted);
3140
}
3241

3342
/**
3443
* Retrieves a secret from the vault or null if it does not exist.
44+
*
45+
* @param string $name Identifier of the secret.
46+
* @param string $aad Additional authenticated data.
47+
*
48+
* @return mixed|null
3549
*/
36-
public function get(string $name): ?string
50+
public function get(string $name, string $aad = ''): mixed
3751
{
38-
$encrypted = $this->storage->get($name);
39-
if (null === $encrypted) {
52+
$record = $this->storage->get($name);
53+
if (null === $record) {
4054
return null;
4155
}
4256

43-
return $this->cipher->decrypt($encrypted, $this->encryptionKey);
57+
[$keyId, $ciphertext] = explode(':', $record, 2);
58+
$key = $this->keys[$keyId] ?? null;
59+
if (null === $key) {
60+
throw new RuntimeException('Unknown key identifier.');
61+
}
62+
63+
$serialized = $this->cipher->decrypt($ciphertext, $key, $aad);
64+
65+
$secret = @unserialize($serialized, ['allowed_classes' => false]);
66+
if (false === $secret && 'b:0;' !== $serialized) {
67+
throw new RuntimeException('Unable to unserialize secret.');
68+
}
69+
70+
return $secret;
4471
}
4572

4673
/**
@@ -50,4 +77,16 @@ public function delete(string $name): void
5077
{
5178
$this->storage->delete($name);
5279
}
80+
81+
/**
82+
* Rotates the active encryption key.
83+
*
84+
* @param string $keyId Identifier for the new key.
85+
* @param string $key The new encryption key.
86+
*/
87+
public function rotateKey(string $keyId, string $key): void
88+
{
89+
$this->keys[$keyId] = $key;
90+
$this->currentKeyId = $keyId;
91+
}
5392
}

0 commit comments

Comments
 (0)