helppc / encryption-bundle
Symfony bundle exposing a typed encryption facade over spaze/encryption (Halite, libsodium)
Package info
github.com/helppc/encryption-bundle
Type:symfony-bundle
pkg:composer/helppc/encryption-bundle
Requires
- php: >=8.4
- ext-sodium: *
- spaze/encryption: ^3.0
- symfony/config: ^8.0
- symfony/console: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/http-kernel: ^8.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^12.3
- rector/rector: ^2.1
- shipmonk/phpstan-rules: ^4.2
- slevomat/coding-standard: ^8.27
- squizlabs/php_codesniffer: ^4.0
- symfony/error-handler: ^8.0
- symfony/filesystem: ^8.0
- symfony/framework-bundle: ^8.0
- symfony/yaml: ^8.0
README
Symfony bundle exposing a typed encryption facade over
spaze/encryption, which uses
Halite on top of libsodium.
The bundle implements no cryptography of its own. It contributes configuration, dependency injection, key rotation ergonomics, and interfaces narrow enough that asking a group for something it cannot do — reading a write-only group, binding additional data to a sealed box — fails while the container is compiled rather than on the first request in production. Key material itself is validated when the encryption service is created; see Exceptions.
Requirements
- PHP >= 8.4
ext-sodium- Symfony 8
spaze/encryption^3.0
Installation
composer require helppc/encryption-bundle
Register the bundle in config/bundles.php:
return [ // ... HelpPC\EncryptionBundle\EncryptionBundle::class => ['all' => true], ];
Generating keys
$ bin/console encryption:generate-key --symmetric --prefix=adek adek_3bf4c484daf46ccf286e25a67073b143870879cc770f85a6cdadcd19839bf60f $ bin/console encryption:generate-key --asymmetric --prefix=vault public_key vault_public_a7dcfc9a8f692ec1cf82fcbc1c27781a36c709c6eb1f75be1f8d1ed31de1550f secret_key vault_secret_f6677e62a1ce2ecc6dc46364941f23b14a75553ba1c2e0af8d9cfacf8e0876eb
The prefix is free-form and makes a leaked credential identifiable — usually an initialism of its
purpose, adek for address data encryption key. The command refuses a prefix containing _,
because _ separates the prefix from the key and from the optional public/secret role tag, and
the command's output would stop being readable in one direction. It is a rule of the command, not a
validation of the configuration: a key whose prefix contains _ is accepted at runtime, as long as
key_prefix says exactly the same thing.
Put the values in .env.local and reference them with %env()%. A key written literally into a
YAML file ends up in plain text in the compiled container under var/cache/, which leaks into
backups, deploy artifacts and debug tarballs.
Configuration
# config/packages/encryption.yaml encryption: # Optional. Defaults to the only configured group, or to one named "default". default_group: default groups: default: type: symmetric # the default, can be omitted key_prefix: adek active_key: v2 keys: v1: '%env(ENCRYPTION_KEY_V1)%' # scalar shorthand for { key: ... } v2: '%env(ENCRYPTION_KEY_V2)%' partner_inbox: # write-only: can encrypt, can never decrypt type: anonymous_asymmetric key_prefix: partner active_key: v1 keys: v1: public_key: '%env(PARTNER_PUBLIC_KEY)%' vault: # sealed boxes, readable type: anonymous_asymmetric key_prefix: vault active_key: v1 keys: v1: public_key: '%env(VAULT_PUBLIC_KEY)%' secret_key: '%env(VAULT_SECRET_KEY)%' peer: # two-party, supports additional data type: asymmetric key_prefix: peer active_key: v1 keys: v1: public_key: '%env(PEER_PUBLIC_KEY)%' # the other party's secret_key: '%env(PEER_SECRET_KEY)%' # yours
Each group becomes one service, encryption.<group>.
Encryption types
type |
EncryptionType case |
Who can encrypt | Who can decrypt | Additional data | Can be write-only |
|---|---|---|---|---|---|
symmetric (default) |
Symmetric |
anyone with the key | anyone with the key | yes | no |
asymmetric |
Asymmetric |
holder of the key pair | the other party, and you | yes | no |
anonymous_asymmetric |
AnonymousAsymmetric |
anyone with the public key | holder of the secret key | no | yes |
For anonymous_asymmetric, either every key of the group defines secret_key or none does. A
mixed state is rejected: a group that can decrypt has to be able to decrypt data encrypted with
its older keys too.
A group goes write-only by leaving secret_key out, not by setting it to an empty string. An
explicit secret_key: '' is rejected, because a config generator emitting an empty value would
otherwise silently take away the ability to decrypt, and nothing would notice until something asked
that group for a Decryptor.
Naming the type through the enum
type is backed by the HelpPC\EncryptionBundle\EncryptionType enum. Plain strings keep working,
but both YAML and PHP can name the case instead.
In YAML through Symfony's !php/enum tag:
encryption: groups: partner_inbox: type: !php/enum HelpPC\EncryptionBundle\EncryptionType::AnonymousAsymmetric key_prefix: partner active_key: v1 keys: v1: public_key: '%env(PARTNER_PUBLIC_KEY)%'
A typo in the case name is then a parse error naming the enum, instead of a value that silently means nothing.
In PHP configuration the case goes in directly:
// config/packages/encryption.php use HelpPC\EncryptionBundle\EncryptionType; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use function Symfony\Component\DependencyInjection\Loader\Configurator\env; return static function (ContainerConfigurator $container): void { $container->extension('encryption', [ 'default_group' => 'default', 'groups' => [ 'default' => [ 'type' => EncryptionType::Symmetric, 'key_prefix' => 'adek', 'active_key' => 'v2', 'keys' => [ 'v1' => env('ENCRYPTION_KEY_V1'), 'v2' => env('ENCRYPTION_KEY_V2'), ], ], 'partner_inbox' => [ 'type' => EncryptionType::AnonymousAsymmetric, 'key_prefix' => 'partner', 'active_key' => 'v1', 'keys' => [ 'v1' => ['public_key' => env('PARTNER_PUBLIC_KEY')], ], ], ], ]); };
Whichever spelling is used, an unknown value is rejected while the configuration is processed:
The value "rot13" is not allowed for path "encryption.groups.g.type". Permissible values:
"symmetric", "asymmetric", "anonymous_asymmetric" (cases of the
"HelpPC\EncryptionBundle\EncryptionType" enum).
Usage
The default group is wired to the bare type hints:
use HelpPC\EncryptionBundle\Encryption\Decryptor; use HelpPC\EncryptionBundle\Encryption\Encryptor; public function __construct( private Encryptor $encryptor, private Decryptor $decryptor, ) { }
Other groups are addressed by name:
use Symfony\Component\DependencyInjection\Attribute\Target; public function __construct( #[Target('vault')] private Decryptor $vault, #[Target('partner_inbox')] private Encryptor $partnerInbox, ) { }
#[Target] accepts both partner_inbox and partnerInbox. Alternatively use
#[Autowire(service: 'encryption.partner_inbox')].
Interfaces
| Interface | Methods |
|---|---|
Encryptor |
encrypt, isEncrypted, needsReEncryption |
Decryptor |
decrypt |
AdditionalDataEncryptor (extends Encryptor) |
encryptWithAdditionalData |
AdditionalDataDecryptor (extends Decryptor) |
decryptWithAdditionalData |
The split is deliberate. A write-only group is registered as a class that does not implement
Decryptor, and no Decryptor alias is created for it, so type-hinting one fails while the
container is being compiled. Anonymous asymmetric groups do not implement the AdditionalData*
interfaces, because sealed boxes have no such API in Halite.
Additional data
Additional data cryptographically binds a cipher text to a context — a row id, a column name, a tenant id. It is authenticated but not encrypted, so it must not be a secret. It prevents a valid cipher text from being copied from one place to another.
The methods live on the extended interfaces, so those are what a consumer type-hints:
use HelpPC\EncryptionBundle\Encryption\AdditionalDataDecryptor; use HelpPC\EncryptionBundle\Encryption\AdditionalDataEncryptor; public function __construct( private AdditionalDataEncryptor $aadEncryptor, private AdditionalDataDecryptor $aadDecryptor, ) { }
$cipherText = $this->aadEncryptor->encryptWithAdditionalData($addressData, $tenantId); $plainText = $this->aadDecryptor->decryptWithAdditionalData($cipherText, $tenantId);
The value must be non-empty and byte identical on both sides, otherwise decryption fails. Not
available for anonymous_asymmetric.
Key rotation
Add a new key, make it active, and new data is encrypted with it. Old data stays readable as long as the old key remains configured.
if ($this->encryptor->needsReEncryption($cipherText)) { $cipherText = $this->encryptor->encrypt($this->decryptor->decrypt($cipherText)); }
Once nothing reports needsReEncryption() any more, drop the old key.
Both needsReEncryption() and isEncrypted() are structural checks that decrypt nothing. They read
the key id and the marker out of the envelope, and neither of them verifies that the key id is one
of the configured ones, or that the payload authenticates. A structurally valid value carrying an
unknown key id therefore reports needsReEncryption() === true, and only the decrypt() in the
snippet above fails on it. Do not read a true as proof that the value belongs to this group.
The difference between the two is what happens to a value that is not cipher text of this type at
all: needsReEncryption() throws, isEncrypted() returns false and never throws. Use
isEncrypted() when scanning a column that still holds a mix of plain and encrypted values.
Always generate a fresh key for a new key id. How well the key id in a stored value is protected depends on the format and the type:
| Value | Key id and marker |
|---|---|
symmetric or asymmetric, written by spaze/encryption 3.0 and later |
authenticated — they go into what decryption verifies, so editing either makes decryption fail |
anonymous_asymmetric, any version |
not authenticated — a sealed box has nowhere to carry the verification |
| any type, written before 3.0, without a marker | not authenticated |
Where they are not authenticated, editing the stored key id only makes decryption reach for a different key, and it fails because that key is a different one. That is the whole reason two ids must never point at the same key: a value moved between them would then decrypt under both. The rule holds for every group, because the anonymous type never gets the protection and pre-3.0 values never had it.
Upgrading to spaze/encryption 3.0
Warning
Upgrade every deployment that reads the data before any of them starts writing it.
Version 3 writes a new cipher text format carrying a marker that says which method created the
value, $<keyId>$<marker>$<cipherText>. Versions before it wrote $<keyId>$<cipherText> and cannot
read a value that has a marker in it. When several deployments share the same database — a rolling
deploy, a worker fleet, a read replica of the same rows — one of them upgrading first and writing a
single value is enough to hand the others data they cannot decrypt.
So either upgrade all of them before the first new write, or stop writing for the duration of the upgrade. There is no format flag to turn the marker off.
Nothing is lost the other way round: values written by version 2 keep decrypting, and
needsReEncryption() reports them, so the usual re-encryption sweep migrates them to the marked
format. Until then, they keep the weaker guarantees of the unmarked format.
Upstream release notes: https://github.com/spaze/encryption/releases/tag/v3.0.0
Exceptions
| Exception | When |
|---|---|
EncryptionException |
encryption failed; checked |
DecryptionException |
malformed cipher text, unknown key id, wrong format, or failed authentication; checked |
InvalidEncryptionConfigurationException |
a group cannot be built from its configuration; unchecked |
InvalidEncryptionConfigurationException is thrown at two different moments, and the difference
matters for what a deploy can still discover after a green build:
| Fails while the container is compiled | Fails when the encryption service is created |
|---|---|
the shape of the configuration: a default_group naming a group that does not exist, more than one group and no default, a key missing the component its type needs (key, public_key, secret_key), an anonymous_asymmetric group defining secret_key for some of its keys but not all |
the key material: a wrong prefix, a value that is not hexadecimal, a key that does not decode to 32 bytes, a secret key where a public one belongs, a public key that does not belong to its secret key, an active_key that is not among the keys |
Two more kinds of error fail at compile time without going through this exception. A group with no
key_prefix, active_key or keys at all is rejected by Symfony's config component with
InvalidConfigurationException, the same way an unknown type is. And a type error — type-hinting
Decryptor for a write-only group, or AdditionalDataEncryptor for an anonymous one — cannot be
autowired, which is what the narrow interfaces are for.
Key material is deliberately left for later. The keys come from %env()%, and resolving them while
the container is compiled would write them into var/cache/ — exactly what
Generating keys warns against. So the container builds, boots, and the group
throws on the first get() of its service. tests/Integration/ContainerTest.php covers both halves
of this: testReadingAWriteOnlyGroupFailsWhileTheContainerIsCompiled() and
testMisconfiguredGroupFailsWhenTheServiceIsBuilt().
Development
composer phpcs # coding standards composer phpstan # static analysis, level 10 composer rector:check # automated refactors composer phpunit # tests
Before tagging a release, read the breaking change and migration notes of every upstream package
that changed, spaze/encryption above all: a new cipher text format there is a coordination problem
for everyone deploying this bundle, and it belongs in CHANGELOG.md and in the
release notes, not only in the dependency bump.
License
MIT