Skip to content
Merged
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ class MyEntity
}
```

### Encryption key roll-over
In case the encryption keys ever need to be rotated, a fallback mechanism is available to minimize service interruption
while switching keys. Using the fallback logic, new values can be encrypted with a new public key while still being able
to decrypt both values encrypted with the old and values encrypted with the new public key.

**DISCLAIMER: The fallback logic assumes that trying to decrypt and old value with the new key will throw an error, and
doesn't just succeed with an unexpected value!**

The flow to roll-over encryption keys would be as follows:
- Generate a new private/public-key pair
- Store these in the paths specified in the composer.json as before
- Store the old private key somewhere next to it and specify it in the composer.json under `<encryption_alias>_fallback`
- After deploying, new values will be encrypted with the new public key and can be decrypted with the new private key,
while old values are first tried to be decrypted with the new private key and when that fails, the old (fallback)
private key is used.
- Run a script to re-encrypt all values (`get()` the values and `set()` them again using the generated methods)
- When all values have been re-encrypted, the fallback key should be removed again.

Example composer.json config:
```
"extra": {
"accessor-generator": {
<encryption_alias>: {
"public-key": <public_key_file>
"private-key": <private_key_file>
},
<encryption_alias>_fallback: {
"private-key": <fallback_private_key_file>
}
}
...
```

## Parameters using ENUM classes

Since version 2.8.0, the support of accessor generation of parameterized collections has been added. With this addition,
Expand Down
5 changes: 5 additions & 0 deletions src/Generator/CodeGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@ public function generateTraitForClass(ReflectionClass $class): string

$this->key_registry_data[$dir_name]['namespace'] = $class->getNamespace();
$this->key_registry_data[$dir_name]['keys'][$info->getEncryptionAlias()] = $keys;

$fallback_alias = $info->getEncryptionAlias() . '_fallback';
if ($fallback_keys = $this->encryption_aliases[$fallback_alias] ?? null) {
$this->key_registry_data[$dir_name]['keys'][$fallback_alias] = $fallback_keys;
}
}

$code .= $this->generateAccessors($info);
Expand Down
48 changes: 34 additions & 14 deletions src/Resources/templates/get.php.twig
Original file line number Diff line number Diff line change
Expand Up @@ -78,24 +78,44 @@
throw new \InvalidArgumentException('A private key path must be set to use this method.');
}

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

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

if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
$err_string = '';
while ($msg = openssl_error_string()) {
$err_string .= $msg . ' | ';
if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
$err_string = '';
while ($msg = openssl_error_string()) {
$err_string .= $msg . ' | ';
}
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
}
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
}

return $open_data;
return $open_data;
};

try {
return $decrypt($private_key_path);
} catch (\InvalidArgumentException $e) {
if (false == ($fallback_private_key_path = KeyRegistry::getPrivateKeyPath('{{ property.encryptionAlias() }}_fallback'))) {
throw $e;
}

try {
return $decrypt($fallback_private_key_path);
} catch (\InvalidArgumentException $fallback_exception) {
throw new \InvalidArgumentException(sprintf(
"Decryption failed: [%s]\nFallback also failed: [%s]",
$e->getMessage(),
$fallback_exception->getMessage()
), 0, $e);
}
}
{% elseif property.type == 'integer' %}
return (int) $this->{{ property.name }};
{% elseif property.collection %}
Expand Down
73 changes: 73 additions & 0 deletions test/Generator/CredentialsFallbackTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
/**
* @copyright 2025-present Hostnet B.V.
*/
declare(strict_types=1);

namespace Generator;

use Hostnet\Component\AccessorGenerator\Generator\fixtures\Credentials;
use Hostnet\Component\AccessorGenerator\Generator\fixtures\Generated\KeyRegistry;
use PHPUnit\Framework\TestCase;

class CredentialsFallbackTest extends TestCase
{
private Credentials $credentials;

protected function setUp(): void
{
KeyRegistry::addPublicKeyPath(
'database.table.column',
'file:///' . __DIR__ . '/Key/credentials_public_key.pem'
);
KeyRegistry::addPrivateKeyPath(
'database.table.column',
'file:///' . __DIR__ . '/Key/credentials_private_key_not_matching.pem'
);

$this->credentials = new Credentials();
$this->credentials->setPassword('password');
}

public function testGetPasswordWithoutFallback(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('openssl_open failed. Message:');
$this->credentials->getPassword();
}

public function testGetPasswordWithoutPrivateKeyInFile(): void
{
KeyRegistry::addPrivateKeyPath(
'database.table.column_fallback',
'file:///' . __DIR__ . '/Key/credentials_public_key.pem'
);

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('does not contain a private key.');
$this->credentials->getPassword();
}

public function testGetPasswordFallbackKeyNotMatching(): void
{
KeyRegistry::addPrivateKeyPath(
'database.table.column_fallback',
'file:///' . __DIR__ . '/Key/credentials_private_key_not_matching.pem'
);

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Decryption failed: [openssl_open failed. Message:');
$this->expectExceptionMessage('Fallback also failed: [openssl_open failed. Message:');
$this->credentials->getPassword();
}

public function testGetPasswordSuccess(): void
{
KeyRegistry::addPrivateKeyPath(
'database.table.column_fallback',
'file:///' . __DIR__ . '/Key/credentials_private_key.pem'
);

self::assertSame('password', $this->credentials->getPassword());
}
}
28 changes: 28 additions & 0 deletions test/Generator/Key/credentials_private_key_not_matching.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJpx0zYgwy4V9+
PxpO4z2PEeOM8gdqJMLgu1NQOwuVXTYICINIoZMYYOQhO8r2ElhJMXye5L4V7pyI
63B1UwhdqTffU2uqz6VitZ+Frkkgs0Ms/OodISizt5qO3HLoC7LjpU0UlQt8oE5b
erJ478bWRK+IPNByr31KQOSwkFTJJvmcjEz3WVUusVfNzRaRtAPeMywa/J7k6TX/
QV0ptr8qBbY75v0TygkfksNICgQZEy5Evax17RvIFb1Cq1FtNQt4sbFCPt3tgW1z
a6JDzTLol77sKSnSOZXbPqF5OlY2EZMIlhj4Ylh7Ed+hv4ith6do6z9Zq11VTx9G
xJibgmlZAgMBAAECggEAISopbMp63CFh3bcOIhxQgwe7p3Ik0wmxvVlBtgfH+2xF
lyOjR94+/Xrt+iNF2ZuhxoPrjYxsUNoaB5DFQZ6C2Tib9lBXfFPDTQ0267sCzux8
p1j/PgQ2l/wh4M4T3eMSrEsC9tgeeAQ7buMqmCZDSvkn712lIL+I+R3cHsfWEfDa
zsc9AcdFAfkX2FtcyoG7UqxDUD5BVx2Jo9DXi9DlQ7GBaD89FnwyTrJwJL9TyhSY
8rIaVNOY23G+R2bV7bwn3SsHNpx3Ko0ymoN3EltePJ3uEWgx7dm/vToFo7pdUk+5
f2Y0fCvf57qso/JLwkv4kb6KuASoml28jVQitntl+wKBgQDkwyv6DmWo6rPe3Y78
1AQPcEzKKNRbyHMubRRwiM8xi4DYlr6fy1+3Pp0GY1wN+oCde1uhgWoE0YjiZN62
hItiLBFe7OIsRXxUbdKbdQhP+CEGEj9FqVrxg1H53mCQbhoJH9GSJXEq/lSAWCaR
OmUjut7VAuhQ286E3qF2aloSKwKBgQDhqZ+yVEOcp8oUYMIIpLNkD6p1jV5LLX5Q
BuBau/5vdA7GBM+xqjD9aTCSpdLANPXkOpEtDSS/FV1fFa6+BeLmGyTFlPBxODEO
oDQC+ePzVvgT0kV9pjkhjym7yYuGFrNLdwgBhkb57uuznMx6RJnm4fQhgU2Gpit+
Udes+mWkiwKBgEYSzubDADr04fIztfgWTcQY5zzJsvsGdNnUyf0Ku0T28Znm2y+B
kalFAb6SMwGJKVqUDeZ0CPC+6opG0b3g7f09eHi2YTWkd0g5d9jsyYYNgLgmYMFK
9jOiwTqj9rpnL4x59a0p0PeVfnbuCapU0+RU+qsPP/B81E75D0aBn2OPAoGBAKJ9
+O95O8JXE+0+ixmcN0yq9yx0Ylyx4o2Plgff7OOmZ2jxV/jvux0OnJpMa4hZ2mHA
Rn9xQm+R2803GL/eDzdwfjcD+2sbcj+83hbyh9DWZAYp2D4U7nia1QtSonQobmy9
xncKkJsyDmkkVB0KvuOA+sERkZiOmSz5k9sL5xrnAoGAUPsYK2qQufhuLeBLjyP9
wzUVD9xX1eljvrTH1VUodO4vduNvOGqHOknd2W61Z4+QtbW18z58Y3KE3iAA7g6O
Zmed9MrIFtGA9POEPxsuynfGB9hdgQbOYC1i0cozpfUqJyCyFf78Wi7pYALs2r7Y
SK0Jv2/+yq5IzW4k1Uik6Fs=
-----END PRIVATE KEY-----
48 changes: 34 additions & 14 deletions test/Generator/fixtures/expected/CredentialsMethodsTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,44 @@ public function getPassword(): string
throw new \InvalidArgumentException('A private key path must be set to use this method.');
}

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

list($env_key_length, $iv_length, $pieces) = explode(',', $this->password, 3);
$env_key = hex2bin(substr($pieces, 0, $env_key_length));
$iv = hex2bin(substr($pieces, $env_key_length, $iv_length));
$sealed_data = hex2bin(substr($pieces, $env_key_length + $iv_length));
list($env_key_length, $iv_length, $pieces) = explode(',', $this->password, 3);
$env_key = hex2bin(substr($pieces, 0, $env_key_length));
$iv = hex2bin(substr($pieces, $env_key_length, $iv_length));
$sealed_data = hex2bin(substr($pieces, $env_key_length + $iv_length));

if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
$err_string = '';
while ($msg = openssl_error_string()) {
$err_string .= $msg . ' | ';
}
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
}

if (false === openssl_open($sealed_data, $open_data, $env_key, $private_key, 'AES256', $iv)) {
$err_string = '';
while ($msg = openssl_error_string()) {
$err_string .= $msg . ' | ';
return $open_data;
};

try {
return $decrypt($private_key_path);
} catch (\InvalidArgumentException $e) {
if (false == ($fallback_private_key_path = KeyRegistry::getPrivateKeyPath('database.table.column_fallback'))) {
throw $e;
}
throw new \InvalidArgumentException(sprintf('openssl_open failed. Message: %s', $err_string));
}

return $open_data;
try {
return $decrypt($fallback_private_key_path);
} catch (\InvalidArgumentException $fallback_exception) {
throw new \InvalidArgumentException(sprintf(
"Decryption failed: [%s]\nFallback also failed: [%s]",
$e->getMessage(),
$fallback_exception->getMessage()
), 0, $e);
}
}
}

/**
Expand Down