diff --git a/README.md b/README.md index b71c1d7..93a3cd5 100644 --- a/README.md +++ b/README.md @@ -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 `_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, 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..82816ff 100644 --- a/src/Resources/templates/get.php.twig +++ b/src/Resources/templates/get.php.twig @@ -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 %} 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----- diff --git a/test/Generator/fixtures/expected/CredentialsMethodsTrait.php b/test/Generator/fixtures/expected/CredentialsMethodsTrait.php index 0422b4c..8a9b08f 100644 --- a/test/Generator/fixtures/expected/CredentialsMethodsTrait.php +++ b/test/Generator/fixtures/expected/CredentialsMethodsTrait.php @@ -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); + } + } } /**