Skip to content

Commit afef94c

Browse files
committed
feat(vault): add key ring and secret versioning
1 parent 4bb09e3 commit afef94c

File tree

9 files changed

+181
-52
lines changed

9 files changed

+181
-52
lines changed

docs/components/vault.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@ The Vault component securely stores secrets using pluggable storage backends and
66

77
```php
88
use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher;
9+
use SonsOfPHP\Component\Vault\KeyRing\InMemoryKeyRing;
910
use SonsOfPHP\Component\Vault\Storage\InMemoryStorage;
1011
use SonsOfPHP\Component\Vault\Vault;
1112

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');
13+
$keyRing = new InMemoryKeyRing(['v1' => '32_byte_master_key_example!!'], 'v1');
14+
$vault = new Vault(new InMemoryStorage(), new OpenSSLCipher(), $keyRing);
15+
$vault->set('db_password', 'secret', ['app']);
16+
$secret = $vault->get('db_password', ['app']);
17+
18+
// Store a new version of the secret
19+
$vault->set('db_password', 'new-secret', ['app']);
20+
$oldSecret = $vault->get('db_password', ['app'], 1); // retrieve version 1
1621

1722
// Rotate the master key
1823
$vault->rotateKey('v2', 'another_32_byte_master_key!!');

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@
44

55
namespace SonsOfPHP\Component\Vault\Cipher;
66

7+
use SensitiveParameter;
8+
79
/**
810
* Defines methods for encrypting and decrypting secrets.
911
*/
1012
interface CipherInterface
1113
{
1214
/**
1315
* Encrypts plaintext using the provided key and optional authenticated data.
16+
*
17+
* @param array<array-key, mixed> $aad Additional authenticated data.
1418
*/
15-
public function encrypt(string $plaintext, string $key, string $aad = ''): string;
19+
public function encrypt(#[SensitiveParameter] string $plaintext, #[SensitiveParameter] string $key, array $aad = []): string;
1620

1721
/**
1822
* Decrypts ciphertext using the provided key and optional authenticated data.
23+
*
24+
* @param array<array-key, mixed> $aad Additional authenticated data.
1925
*/
20-
public function decrypt(string $ciphertext, string $key, string $aad = ''): string;
26+
public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitiveParameter] string $key, array $aad = []): string;
2127
}

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

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

77
use RuntimeException;
8+
use SensitiveParameter;
89

910
/**
1011
* Encrypts and decrypts secrets using OpenSSL.
@@ -16,20 +17,23 @@ class OpenSSLCipher implements CipherInterface
1617
*/
1718
public function __construct(private readonly string $cipherMethod = 'aes-256-gcm') {}
1819

19-
public function encrypt(string $plaintext, string $key, string $aad = ''): string
20+
/** @param array<array-key, mixed> $aad */
21+
public function encrypt(#[SensitiveParameter] string $plaintext, #[SensitiveParameter] string $key, array $aad = []): string
2022
{
21-
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
22-
$iv = random_bytes($ivLength);
23-
$tag = '';
24-
$encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16);
23+
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
24+
$iv = random_bytes($ivLength);
25+
$tag = '';
26+
$encodedAad = json_encode($aad);
27+
$encrypted = openssl_encrypt($plaintext, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad, 16);
2528
if (false === $encrypted) {
2629
throw new RuntimeException('Unable to encrypt secret.');
2730
}
2831

2932
return base64_encode($iv . $tag . $encrypted);
3033
}
3134

32-
public function decrypt(string $ciphertext, string $key, string $aad = ''): string
35+
/** @param array<array-key, mixed> $aad */
36+
public function decrypt(#[SensitiveParameter] string $ciphertext, #[SensitiveParameter] string $key, array $aad = []): string
3337
{
3438
$data = base64_decode($ciphertext, true);
3539
if (false === $data) {
@@ -38,10 +42,11 @@ public function decrypt(string $ciphertext, string $key, string $aad = ''): stri
3842

3943
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
4044
$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);
45+
$iv = substr($data, 0, $ivLength);
46+
$tag = substr($data, $ivLength, $tagLength);
47+
$payload = substr($data, $ivLength + $tagLength);
48+
$encodedAad = json_encode($aad);
49+
$decrypted = openssl_decrypt($payload, $this->cipherMethod, $key, OPENSSL_RAW_DATA, $iv, $tag, $encodedAad);
4550
if (false === $decrypted) {
4651
throw new RuntimeException('Unable to decrypt secret.');
4752
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SonsOfPHP\Component\Vault\KeyRing;
6+
7+
use SensitiveParameter;
8+
9+
/**
10+
* Simple key ring that keeps keys in memory.
11+
*/
12+
class InMemoryKeyRing implements KeyRingInterface
13+
{
14+
/**
15+
* @param array<string, string> $keys Map of key IDs to keys.
16+
* @param string $currentKeyId Identifier of the active key.
17+
*/
18+
public function __construct(private array $keys, private string $currentKeyId) {}
19+
20+
public function getCurrentKeyId(): string
21+
{
22+
return $this->currentKeyId;
23+
}
24+
25+
public function getCurrentKey(): string
26+
{
27+
return $this->keys[$this->currentKeyId];
28+
}
29+
30+
public function getKey(string $keyId): ?string
31+
{
32+
return $this->keys[$keyId] ?? null;
33+
}
34+
35+
public function rotate(string $keyId, #[SensitiveParameter] string $key): void
36+
{
37+
$this->keys[$keyId] = $key;
38+
$this->currentKeyId = $keyId;
39+
}
40+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SonsOfPHP\Component\Vault\KeyRing;
6+
7+
use SensitiveParameter;
8+
9+
/**
10+
* Manages encryption keys and their versions.
11+
*/
12+
interface KeyRingInterface
13+
{
14+
/**
15+
* Returns the identifier for the current key.
16+
*/
17+
public function getCurrentKeyId(): string;
18+
19+
/**
20+
* Returns the current encryption key.
21+
*/
22+
public function getCurrentKey(): string;
23+
24+
/**
25+
* Fetches a key by its identifier or null if missing.
26+
*/
27+
public function getKey(string $keyId): ?string;
28+
29+
/**
30+
* Rotates to a new key and sets it as current.
31+
*/
32+
public function rotate(string $keyId, #[SensitiveParameter] string $key): void;
33+
}

src/SonsOfPHP/Component/Vault/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Sons of PHP - Vault
22
===================
33

4-
Secure secret storage with pluggable backends, key rotation, and support for additional authenticated data.
4+
Secure secret storage with pluggable backends, key rotation, secret versioning, and support for additional authenticated data.
55

66
## Learn More
77

src/SonsOfPHP/Component/Vault/ROADMAP.md

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

26-
### Add secret versioning
27-
- [ ] Provide APIs to maintain secret history across updates.
28-
- **Acceptance Criteria**
29-
- All Global DoR items are satisfied before implementation begins.
30-
- Previous versions of a secret remain accessible.
31-
- Implementation meets the Global DoD.
32-
3326
### Provide CLI tools for managing secrets
3427
- [ ] Expose commands to set, get, and rotate secrets.
3528
- **Acceptance Criteria**

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

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PHPUnit\Framework\TestCase;
88
use RuntimeException;
99
use SonsOfPHP\Component\Vault\Cipher\OpenSSLCipher;
10+
use SonsOfPHP\Component\Vault\KeyRing\InMemoryKeyRing;
1011
use SonsOfPHP\Component\Vault\Storage\InMemoryStorage;
1112
use SonsOfPHP\Component\Vault\Vault;
1213

@@ -23,9 +24,9 @@ private function createVault(?InMemoryStorage &$storage = null): Vault
2324
{
2425
$storage ??= new InMemoryStorage();
2526
$cipher = new OpenSSLCipher();
26-
$keys = ['v1' => 'test_encryption_key_32_bytes!'];
27+
$keyRing = new InMemoryKeyRing(['v1' => 'test_encryption_key_32_bytes!'], 'v1');
2728

28-
return new Vault($storage, $cipher, $keys, 'v1');
29+
return new Vault($storage, $cipher, $keyRing);
2930
}
3031

3132
public function testSecretCanBeRetrieved(): void
@@ -63,18 +64,18 @@ public function testSetAndGetWithArray(): void
6364
public function testSetAndGetWithAad(): void
6465
{
6566
$vault = $this->createVault();
66-
$vault->set('token', 'secret', 'aad');
67+
$vault->set('token', 'secret', ['aad']);
6768

68-
$this->assertSame('secret', $vault->get('token', 'aad'));
69+
$this->assertSame('secret', $vault->get('token', ['aad']));
6970
}
7071

7172
public function testGetThrowsWhenAadDoesNotMatch(): void
7273
{
7374
$vault = $this->createVault();
74-
$vault->set('token', 'secret', 'aad');
75+
$vault->set('token', 'secret', ['aad']);
7576

7677
$this->expectException(RuntimeException::class);
77-
$vault->get('token', 'bad');
78+
$vault->get('token', ['bad']);
7879
}
7980

8081
public function testSecretsEncryptedBeforeRotationAreStillAccessible(): void
@@ -93,6 +94,26 @@ public function testRotateKeyChangesActiveKey(): void
9394
$vault->rotateKey('v2', 'another_32_byte_encryption_key!!');
9495
$vault->set('current', 'secret');
9596

96-
$this->assertStringStartsWith('v2:', $storage->get('current'));
97+
$stored = $storage->get('current');
98+
$versions = unserialize($stored, ['allowed_classes' => false]);
99+
$this->assertStringStartsWith('v2:', $versions['1']);
100+
}
101+
102+
public function testGetReturnsLatestVersion(): void
103+
{
104+
$vault = $this->createVault();
105+
$vault->set('name', 'first');
106+
$vault->set('name', 'second');
107+
108+
$this->assertSame('second', $vault->get('name'));
109+
}
110+
111+
public function testSpecificVersionCanBeRetrieved(): void
112+
{
113+
$vault = $this->createVault();
114+
$vault->set('name', 'first');
115+
$vault->set('name', 'second');
116+
117+
$this->assertSame('first', $vault->get('name', [], 1));
97118
}
98119
}

src/SonsOfPHP/Component/Vault/Vault.php

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
namespace SonsOfPHP\Component\Vault;
66

77
use RuntimeException;
8+
use SensitiveParameter;
89
use SonsOfPHP\Component\Vault\Cipher\CipherInterface;
10+
use SonsOfPHP\Component\Vault\KeyRing\KeyRingInterface;
911
use SonsOfPHP\Component\Vault\Storage\StorageInterface;
1012

1113
/**
@@ -15,47 +17,72 @@
1517
class Vault
1618
{
1719
/**
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.
20+
* @param StorageInterface $storage The storage backend.
21+
* @param CipherInterface $cipher The cipher used for encryption.
22+
* @param KeyRingInterface $keyRing Provides access to encryption keys.
2223
*/
23-
public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private array $keys, private string $currentKeyId)
24-
{
25-
}
24+
public function __construct(private readonly StorageInterface $storage, private readonly CipherInterface $cipher, private readonly KeyRingInterface $keyRing) {}
2625

2726
/**
2827
* Stores a secret in the vault.
2928
*
30-
* @param string $name Identifier of the secret.
31-
* @param mixed $secret The secret to store.
32-
* @param string $aad Additional authenticated data.
29+
* @param string $name Identifier of the secret.
30+
* @param mixed $secret The secret to store.
31+
* @param array<array-key, mixed> $aad Additional authenticated data.
3332
*/
34-
public function set(string $name, mixed $secret, string $aad = ''): void
33+
public function set(string $name, #[SensitiveParameter] mixed $secret, array $aad = []): void
3534
{
3635
$serialized = serialize($secret);
37-
$key = $this->keys[$this->currentKeyId];
36+
$key = $this->keyRing->getCurrentKey();
3837
$encrypted = $this->cipher->encrypt($serialized, $key, $aad);
39-
$this->storage->set($name, $this->currentKeyId . ':' . $encrypted);
38+
39+
$record = $this->storage->get($name);
40+
if (null === $record) {
41+
$versions = [];
42+
} else {
43+
$versions = @unserialize($record, ['allowed_classes' => false]);
44+
if (!is_array($versions)) {
45+
throw new RuntimeException('Invalid secret storage format.');
46+
}
47+
}
48+
49+
$version = $versions === [] ? 1 : max(array_map('intval', array_keys($versions))) + 1;
50+
$versions[(string) $version] = $this->keyRing->getCurrentKeyId() . ':' . $encrypted;
51+
$this->storage->set($name, serialize($versions));
4052
}
4153

4254
/**
4355
* Retrieves a secret from the vault or null if it does not exist.
4456
*
45-
* @param string $name Identifier of the secret.
46-
* @param string $aad Additional authenticated data.
57+
* @param string $name Identifier of the secret.
58+
* @param array<array-key, mixed> $aad Additional authenticated data.
59+
* @param int|null $version Specific version to retrieve or null for latest.
4760
*
4861
* @return mixed|null
4962
*/
50-
public function get(string $name, string $aad = ''): mixed
63+
public function get(string $name, array $aad = [], ?int $version = null): mixed
5164
{
5265
$record = $this->storage->get($name);
5366
if (null === $record) {
5467
return null;
5568
}
5669

57-
[$keyId, $ciphertext] = explode(':', $record, 2);
58-
$key = $this->keys[$keyId] ?? null;
70+
$versions = @unserialize($record, ['allowed_classes' => false]);
71+
if (!is_array($versions)) {
72+
throw new RuntimeException('Invalid secret storage format.');
73+
}
74+
75+
if (null === $version) {
76+
$version = (int) max(array_map('intval', array_keys($versions)));
77+
}
78+
79+
$entry = $versions[(string) $version] ?? null;
80+
if (null === $entry) {
81+
return null;
82+
}
83+
84+
[$keyId, $ciphertext] = explode(':', (string) $entry, 2);
85+
$key = $this->keyRing->getKey($keyId);
5986
if (null === $key) {
6087
throw new RuntimeException('Unknown key identifier.');
6188
}
@@ -84,9 +111,8 @@ public function delete(string $name): void
84111
* @param string $keyId Identifier for the new key.
85112
* @param string $key The new encryption key.
86113
*/
87-
public function rotateKey(string $keyId, string $key): void
114+
public function rotateKey(string $keyId, #[SensitiveParameter] string $key): void
88115
{
89-
$this->keys[$keyId] = $key;
90-
$this->currentKeyId = $keyId;
116+
$this->keyRing->rotate($keyId, $key);
91117
}
92118
}

0 commit comments

Comments
 (0)