From 8572cb80d95ea4c1a4a31db2b56478be7edb0144 Mon Sep 17 00:00:00 2001 From: Floris Turkenburg Date: Fri, 5 Sep 2025 16:14:18 +0200 Subject: [PATCH 1/6] HNB-2686 Added fallback logic to decryption in generated getters --- src/Generator/CodeGenerator.php | 5 ++ src/Resources/templates/get.php.twig | 47 +++++++++++++------ .../expected/CredentialsMethodsTrait.php | 47 +++++++++++++------ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/Generator/CodeGenerator.php b/src/Generator/CodeGenerator.php index b509bde..669153d 100644 --- a/src/Generator/CodeGenerator.php +++ b/src/Generator/CodeGenerator.php @@ -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); diff --git a/src/Resources/templates/get.php.twig b/src/Resources/templates/get.php.twig index 0cb5547..d2e16b8 100644 --- a/src/Resources/templates/get.php.twig +++ b/src/Resources/templates/get.php.twig @@ -78,24 +78,43 @@ 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 new \InvalidArgumentException('Initial decryption failed and no fallback key was found.', 0, $e); + } + + try { + return $decrypt($fallback_private_key_path); + } catch (\InvalidArgumentException $fallback_exception) { + throw new \InvalidArgumentException(sprintf( + 'Initial decryption failed and fallback also failed with message: %s', + $fallback_exception->getMessage() + ), 0, $e); + } + } {% elseif property.type == 'integer' %} return (int) $this->{{ property.name }}; {% elseif property.collection %} diff --git a/test/Generator/fixtures/expected/CredentialsMethodsTrait.php b/test/Generator/fixtures/expected/CredentialsMethodsTrait.php index 0422b4c..990fcba 100644 --- a/test/Generator/fixtures/expected/CredentialsMethodsTrait.php +++ b/test/Generator/fixtures/expected/CredentialsMethodsTrait.php @@ -42,24 +42,43 @@ 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 new \InvalidArgumentException('Initial decryption failed and no fallback key was found.', 0, $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( + 'Initial decryption failed and fallback also failed with message: %s', + $fallback_exception->getMessage() + ), 0, $e); + } + } } /** From e7b855c41bf4ca268e2546658f108cf042c883db Mon Sep 17 00:00:00 2001 From: Floris Turkenburg Date: Mon, 8 Sep 2025 16:08:15 +0200 Subject: [PATCH 2/6] Refined the exception messages --- src/Resources/templates/get.php.twig | 5 +++-- test/Generator/fixtures/expected/CredentialsMethodsTrait.php | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Resources/templates/get.php.twig b/src/Resources/templates/get.php.twig index d2e16b8..82816ff 100644 --- a/src/Resources/templates/get.php.twig +++ b/src/Resources/templates/get.php.twig @@ -103,14 +103,15 @@ return $decrypt($private_key_path); } catch (\InvalidArgumentException $e) { if (false == ($fallback_private_key_path = KeyRegistry::getPrivateKeyPath('{{ property.encryptionAlias() }}_fallback'))) { - throw new \InvalidArgumentException('Initial decryption failed and no fallback key was found.', 0, $e); + throw $e; } try { return $decrypt($fallback_private_key_path); } catch (\InvalidArgumentException $fallback_exception) { throw new \InvalidArgumentException(sprintf( - 'Initial decryption failed and fallback also failed with message: %s', + "Decryption failed: [%s]\nFallback also failed: [%s]", + $e->getMessage(), $fallback_exception->getMessage() ), 0, $e); } diff --git a/test/Generator/fixtures/expected/CredentialsMethodsTrait.php b/test/Generator/fixtures/expected/CredentialsMethodsTrait.php index 990fcba..8a9b08f 100644 --- a/test/Generator/fixtures/expected/CredentialsMethodsTrait.php +++ b/test/Generator/fixtures/expected/CredentialsMethodsTrait.php @@ -67,14 +67,15 @@ public function getPassword(): string return $decrypt($private_key_path); } catch (\InvalidArgumentException $e) { if (false == ($fallback_private_key_path = KeyRegistry::getPrivateKeyPath('database.table.column_fallback'))) { - throw new \InvalidArgumentException('Initial decryption failed and no fallback key was found.', 0, $e); + throw $e; } try { return $decrypt($fallback_private_key_path); } catch (\InvalidArgumentException $fallback_exception) { throw new \InvalidArgumentException(sprintf( - 'Initial decryption failed and fallback also failed with message: %s', + "Decryption failed: [%s]\nFallback also failed: [%s]", + $e->getMessage(), $fallback_exception->getMessage() ), 0, $e); } From d476c3a012b71c7dabb569e801abb881e042fa57 Mon Sep 17 00:00:00 2001 From: Floris Turkenburg Date: Wed, 10 Sep 2025 09:48:19 +0200 Subject: [PATCH 3/6] Update README --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index b71c1d7..f90d43a 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,36 @@ 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. + +The flow to roll-over encryption keys would we 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 `_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": { + : { + "public-key": + "private-key": + }, + _fallback: { + "private-key": + } + } +... +``` + ## Parameters using ENUM classes Since version 2.8.0, the support of accessor generation of parameterized collections has been added. With this addition, From 7af39962d9bad16a7f87d299b0746604323bab41 Mon Sep 17 00:00:00 2001 From: Floris Turkenburg Date: Wed, 10 Sep 2025 11:30:49 +0200 Subject: [PATCH 4/6] Added unittest for fallback logic --- test/Generator/CredentialsFallbackTest.php | 73 +++++++++++++++++++ .../credentials_private_key_not_matching.pem | 28 +++++++ 2 files changed, 101 insertions(+) create mode 100644 test/Generator/CredentialsFallbackTest.php create mode 100644 test/Generator/Key/credentials_private_key_not_matching.pem diff --git a/test/Generator/CredentialsFallbackTest.php b/test/Generator/CredentialsFallbackTest.php new file mode 100644 index 0000000..022083b --- /dev/null +++ b/test/Generator/CredentialsFallbackTest.php @@ -0,0 +1,73 @@ +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()); + } +} diff --git a/test/Generator/Key/credentials_private_key_not_matching.pem b/test/Generator/Key/credentials_private_key_not_matching.pem new file mode 100644 index 0000000..d908203 --- /dev/null +++ b/test/Generator/Key/credentials_private_key_not_matching.pem @@ -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----- From cbd0268aa0f50db54239c374b9f7da1a1c569209 Mon Sep 17 00:00:00 2001 From: Floris Turkenburg Date: Wed, 10 Sep 2025 11:59:32 +0200 Subject: [PATCH 5/6] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f90d43a..dd6d670 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ In case the encryption keys ever need to be rotated, a fallback mechanism is ava 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. -The flow to roll-over encryption keys would we as follows: +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 `_fallback` From cccfda0e3e8a919441f61f4af70b4dbd0f9e1da0 Mon Sep 17 00:00:00 2001 From: Floris Turkenburg Date: Thu, 11 Sep 2025 12:40:42 +0200 Subject: [PATCH 6/6] added disclaimer --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index dd6d670..93a3cd5 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,9 @@ In case the encryption keys ever need to be rotated, a fallback mechanism is ava 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