Skip to content

Commit 934ef35

Browse files
Added fallback logic to decryption in generated getters (#82)
* HNB-2686 Added fallback logic to decryption in generated getters * Updated README * added disclaimer
1 parent d1b7e39 commit 934ef35

6 files changed

Lines changed: 207 additions & 28 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,39 @@ class MyEntity
143143
}
144144
```
145145

146+
### Encryption key roll-over
147+
In case the encryption keys ever need to be rotated, a fallback mechanism is available to minimize service interruption
148+
while switching keys. Using the fallback logic, new values can be encrypted with a new public key while still being able
149+
to decrypt both values encrypted with the old and values encrypted with the new public key.
150+
151+
**DISCLAIMER: The fallback logic assumes that trying to decrypt and old value with the new key will throw an error, and
152+
doesn't just succeed with an unexpected value!**
153+
154+
The flow to roll-over encryption keys would be as follows:
155+
- Generate a new private/public-key pair
156+
- Store these in the paths specified in the composer.json as before
157+
- Store the old private key somewhere next to it and specify it in the composer.json under `<encryption_alias>_fallback`
158+
- After deploying, new values will be encrypted with the new public key and can be decrypted with the new private key,
159+
while old values are first tried to be decrypted with the new private key and when that fails, the old (fallback)
160+
private key is used.
161+
- Run a script to re-encrypt all values (`get()` the values and `set()` them again using the generated methods)
162+
- When all values have been re-encrypted, the fallback key should be removed again.
163+
164+
Example composer.json config:
165+
```
166+
"extra": {
167+
"accessor-generator": {
168+
<encryption_alias>: {
169+
"public-key": <public_key_file>
170+
"private-key": <private_key_file>
171+
},
172+
<encryption_alias>_fallback: {
173+
"private-key": <fallback_private_key_file>
174+
}
175+
}
176+
...
177+
```
178+
146179
## Parameters using ENUM classes
147180

148181
Since version 2.8.0, the support of accessor generation of parameterized collections has been added. With this addition,

src/Generator/CodeGenerator.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ public function generateTraitForClass(ReflectionClass $class): string
366366

367367
$this->key_registry_data[$dir_name]['namespace'] = $class->getNamespace();
368368
$this->key_registry_data[$dir_name]['keys'][$info->getEncryptionAlias()] = $keys;
369+
370+
$fallback_alias = $info->getEncryptionAlias() . '_fallback';
371+
if ($fallback_keys = $this->encryption_aliases[$fallback_alias] ?? null) {
372+
$this->key_registry_data[$dir_name]['keys'][$fallback_alias] = $fallback_keys;
373+
}
369374
}
370375

371376
$code .= $this->generateAccessors($info);

src/Resources/templates/get.php.twig

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,24 +78,44 @@
7878
throw new \InvalidArgumentException('A private key path must be set to use this method.');
7979
}
8080

81-
if (false === ($private_key = openssl_get_privatekey($private_key_path))) {
82-
throw new \InvalidArgumentException(sprintf('The path "%s" does not contain a private key.', $private_key_path));
83-
}
81+
$decrypt = function ($private_key_path) {
82+
if (false === ($private_key = openssl_get_privatekey($private_key_path))) {
83+
throw new \InvalidArgumentException(sprintf('The path "%s" does not contain a private key.', $private_key_path));
84+
}
8485

85-
list($env_key_length, $iv_length, $pieces) = explode(',', $this->{{ property.name }}, 3);
86-
$env_key = hex2bin(substr($pieces, 0, $env_key_length));
87-
$iv = hex2bin(substr($pieces, $env_key_length, $iv_length));
88-
$sealed_data = hex2bin(substr($pieces, $env_key_length + $iv_length));
86+
list($env_key_length, $iv_length, $pieces) = explode(',', $this->{{ property.name }}, 3);
87+
$env_key = hex2bin(substr($pieces, 0, $env_key_length));
88+
$iv = hex2bin(substr($pieces, $env_key_length, $iv_length));
89+
$sealed_data = hex2bin(substr($pieces, $env_key_length + $iv_length));
8990

90-
if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
91-
$err_string = '';
92-
while ($msg = openssl_error_string()) {
93-
$err_string .= $msg . ' | ';
91+
if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
92+
$err_string = '';
93+
while ($msg = openssl_error_string()) {
94+
$err_string .= $msg . ' | ';
95+
}
96+
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
9497
}
95-
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
96-
}
9798

98-
return $open_data;
99+
return $open_data;
100+
};
101+
102+
try {
103+
return $decrypt($private_key_path);
104+
} catch (\InvalidArgumentException $e) {
105+
if (false == ($fallback_private_key_path = KeyRegistry::getPrivateKeyPath('{{ property.encryptionAlias() }}_fallback'))) {
106+
throw $e;
107+
}
108+
109+
try {
110+
return $decrypt($fallback_private_key_path);
111+
} catch (\InvalidArgumentException $fallback_exception) {
112+
throw new \InvalidArgumentException(sprintf(
113+
"Decryption failed: [%s]\nFallback also failed: [%s]",
114+
$e->getMessage(),
115+
$fallback_exception->getMessage()
116+
), 0, $e);
117+
}
118+
}
99119
{% elseif property.type == 'integer' %}
100120
return (int) $this->{{ property.name }};
101121
{% elseif property.collection %}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
/**
3+
* @copyright 2025-present Hostnet B.V.
4+
*/
5+
declare(strict_types=1);
6+
7+
namespace Generator;
8+
9+
use Hostnet\Component\AccessorGenerator\Generator\fixtures\Credentials;
10+
use Hostnet\Component\AccessorGenerator\Generator\fixtures\Generated\KeyRegistry;
11+
use PHPUnit\Framework\TestCase;
12+
13+
class CredentialsFallbackTest extends TestCase
14+
{
15+
private Credentials $credentials;
16+
17+
protected function setUp(): void
18+
{
19+
KeyRegistry::addPublicKeyPath(
20+
'database.table.column',
21+
'file:///' . __DIR__ . '/Key/credentials_public_key.pem'
22+
);
23+
KeyRegistry::addPrivateKeyPath(
24+
'database.table.column',
25+
'file:///' . __DIR__ . '/Key/credentials_private_key_not_matching.pem'
26+
);
27+
28+
$this->credentials = new Credentials();
29+
$this->credentials->setPassword('password');
30+
}
31+
32+
public function testGetPasswordWithoutFallback(): void
33+
{
34+
$this->expectException(\InvalidArgumentException::class);
35+
$this->expectExceptionMessage('openssl_open failed. Message:');
36+
$this->credentials->getPassword();
37+
}
38+
39+
public function testGetPasswordWithoutPrivateKeyInFile(): void
40+
{
41+
KeyRegistry::addPrivateKeyPath(
42+
'database.table.column_fallback',
43+
'file:///' . __DIR__ . '/Key/credentials_public_key.pem'
44+
);
45+
46+
$this->expectException(\InvalidArgumentException::class);
47+
$this->expectExceptionMessage('does not contain a private key.');
48+
$this->credentials->getPassword();
49+
}
50+
51+
public function testGetPasswordFallbackKeyNotMatching(): void
52+
{
53+
KeyRegistry::addPrivateKeyPath(
54+
'database.table.column_fallback',
55+
'file:///' . __DIR__ . '/Key/credentials_private_key_not_matching.pem'
56+
);
57+
58+
$this->expectException(\InvalidArgumentException::class);
59+
$this->expectExceptionMessage('Decryption failed: [openssl_open failed. Message:');
60+
$this->expectExceptionMessage('Fallback also failed: [openssl_open failed. Message:');
61+
$this->credentials->getPassword();
62+
}
63+
64+
public function testGetPasswordSuccess(): void
65+
{
66+
KeyRegistry::addPrivateKeyPath(
67+
'database.table.column_fallback',
68+
'file:///' . __DIR__ . '/Key/credentials_private_key.pem'
69+
);
70+
71+
self::assertSame('password', $this->credentials->getPassword());
72+
}
73+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-----BEGIN PRIVATE KEY-----
2+
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJpx0zYgwy4V9+
3+
PxpO4z2PEeOM8gdqJMLgu1NQOwuVXTYICINIoZMYYOQhO8r2ElhJMXye5L4V7pyI
4+
63B1UwhdqTffU2uqz6VitZ+Frkkgs0Ms/OodISizt5qO3HLoC7LjpU0UlQt8oE5b
5+
erJ478bWRK+IPNByr31KQOSwkFTJJvmcjEz3WVUusVfNzRaRtAPeMywa/J7k6TX/
6+
QV0ptr8qBbY75v0TygkfksNICgQZEy5Evax17RvIFb1Cq1FtNQt4sbFCPt3tgW1z
7+
a6JDzTLol77sKSnSOZXbPqF5OlY2EZMIlhj4Ylh7Ed+hv4ith6do6z9Zq11VTx9G
8+
xJibgmlZAgMBAAECggEAISopbMp63CFh3bcOIhxQgwe7p3Ik0wmxvVlBtgfH+2xF
9+
lyOjR94+/Xrt+iNF2ZuhxoPrjYxsUNoaB5DFQZ6C2Tib9lBXfFPDTQ0267sCzux8
10+
p1j/PgQ2l/wh4M4T3eMSrEsC9tgeeAQ7buMqmCZDSvkn712lIL+I+R3cHsfWEfDa
11+
zsc9AcdFAfkX2FtcyoG7UqxDUD5BVx2Jo9DXi9DlQ7GBaD89FnwyTrJwJL9TyhSY
12+
8rIaVNOY23G+R2bV7bwn3SsHNpx3Ko0ymoN3EltePJ3uEWgx7dm/vToFo7pdUk+5
13+
f2Y0fCvf57qso/JLwkv4kb6KuASoml28jVQitntl+wKBgQDkwyv6DmWo6rPe3Y78
14+
1AQPcEzKKNRbyHMubRRwiM8xi4DYlr6fy1+3Pp0GY1wN+oCde1uhgWoE0YjiZN62
15+
hItiLBFe7OIsRXxUbdKbdQhP+CEGEj9FqVrxg1H53mCQbhoJH9GSJXEq/lSAWCaR
16+
OmUjut7VAuhQ286E3qF2aloSKwKBgQDhqZ+yVEOcp8oUYMIIpLNkD6p1jV5LLX5Q
17+
BuBau/5vdA7GBM+xqjD9aTCSpdLANPXkOpEtDSS/FV1fFa6+BeLmGyTFlPBxODEO
18+
oDQC+ePzVvgT0kV9pjkhjym7yYuGFrNLdwgBhkb57uuznMx6RJnm4fQhgU2Gpit+
19+
Udes+mWkiwKBgEYSzubDADr04fIztfgWTcQY5zzJsvsGdNnUyf0Ku0T28Znm2y+B
20+
kalFAb6SMwGJKVqUDeZ0CPC+6opG0b3g7f09eHi2YTWkd0g5d9jsyYYNgLgmYMFK
21+
9jOiwTqj9rpnL4x59a0p0PeVfnbuCapU0+RU+qsPP/B81E75D0aBn2OPAoGBAKJ9
22+
+O95O8JXE+0+ixmcN0yq9yx0Ylyx4o2Plgff7OOmZ2jxV/jvux0OnJpMa4hZ2mHA
23+
Rn9xQm+R2803GL/eDzdwfjcD+2sbcj+83hbyh9DWZAYp2D4U7nia1QtSonQobmy9
24+
xncKkJsyDmkkVB0KvuOA+sERkZiOmSz5k9sL5xrnAoGAUPsYK2qQufhuLeBLjyP9
25+
wzUVD9xX1eljvrTH1VUodO4vduNvOGqHOknd2W61Z4+QtbW18z58Y3KE3iAA7g6O
26+
Zmed9MrIFtGA9POEPxsuynfGB9hdgQbOYC1i0cozpfUqJyCyFf78Wi7pYALs2r7Y
27+
SK0Jv2/+yq5IzW4k1Uik6Fs=
28+
-----END PRIVATE KEY-----

test/Generator/fixtures/expected/CredentialsMethodsTrait.php

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,24 +42,44 @@ public function getPassword(): string
4242
throw new \InvalidArgumentException('A private key path must be set to use this method.');
4343
}
4444

45-
if (false === ($private_key = openssl_get_privatekey($private_key_path))) {
46-
throw new \InvalidArgumentException(sprintf('The path "%s" does not contain a private key.', $private_key_path));
47-
}
45+
$decrypt = function ($private_key_path) {
46+
if (false === ($private_key = openssl_get_privatekey($private_key_path))) {
47+
throw new \InvalidArgumentException(sprintf('The path "%s" does not contain a private key.', $private_key_path));
48+
}
4849

49-
list($env_key_length, $iv_length, $pieces) = explode(',', $this->password, 3);
50-
$env_key = hex2bin(substr($pieces, 0, $env_key_length));
51-
$iv = hex2bin(substr($pieces, $env_key_length, $iv_length));
52-
$sealed_data = hex2bin(substr($pieces, $env_key_length + $iv_length));
50+
list($env_key_length, $iv_length, $pieces) = explode(',', $this->password, 3);
51+
$env_key = hex2bin(substr($pieces, 0, $env_key_length));
52+
$iv = hex2bin(substr($pieces, $env_key_length, $iv_length));
53+
$sealed_data = hex2bin(substr($pieces, $env_key_length + $iv_length));
54+
55+
if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
56+
$err_string = '';
57+
while ($msg = openssl_error_string()) {
58+
$err_string .= $msg . ' | ';
59+
}
60+
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
61+
}
5362

54-
if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
55-
$err_string = '';
56-
while ($msg = openssl_error_string()) {
57-
$err_string .= $msg . ' | ';
63+
return $open_data;
64+
};
65+
66+
try {
67+
return $decrypt($private_key_path);
68+
} catch (\InvalidArgumentException $e) {
69+
if (false == ($fallback_private_key_path = KeyRegistry::getPrivateKeyPath('database.table.column_fallback'))) {
70+
throw $e;
5871
}
59-
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
60-
}
6172

62-
return $open_data;
73+
try {
74+
return $decrypt($fallback_private_key_path);
75+
} catch (\InvalidArgumentException $fallback_exception) {
76+
throw new \InvalidArgumentException(sprintf(
77+
"Decryption failed: [%s]\nFallback also failed: [%s]",
78+
$e->getMessage(),
79+
$fallback_exception->getMessage()
80+
), 0, $e);
81+
}
82+
}
6383
}
6484

6585
/**

0 commit comments

Comments
 (0)