Encrypting Data with a KMS
A Key Management System (KMS) is a service that holds encryption keys and uses them on your behalf. Your application sends it data to encrypt or decrypt and gets the result back; it never sees the key itself. AWS KMS, Azure Key Vault, Google Cloud KMS, HashiCorp Vault, Scaleway Key Manager and any KMIP server all work this way, and so do the local backends Symfony ships for development.
Symfony integrates them through the key_management configuration. With
it you can:
- encrypt and decrypt values from your own services;
- encrypt a Doctrine column, so the database holds ciphertext only;
- search such a column by exact value, through a blind index;
- move your whole dataset to another key, or another provider, without rewriting a single row.
Warning
The KeyManagement component is experimental and is not covered by Symfony's Backward Compatibility Promise.
8.2
KMS integration was introduced in Symfony 8.2.
Installation
1
$ composer require symfony/key-management
Then install the bridge for the provider holding your keys. Each one is a separate package, so you only install what you use:
1 2 3 4 5 6
$ composer require symfony/aws-key-management
$ composer require symfony/azure-keyvault-key-management
$ composer require symfony/google-cloud-key-management
$ composer require symfony/hashicorp-vault-key-management
$ composer require symfony/scaleway-key-management
$ composer require symfony/kmip-key-management
Three more packages cover related needs:
1 2 3 4 5 6 7 8
# encrypted Doctrine DBAL types, and the table holding the data keys
$ composer require symfony/doctrine-dbal-key-management
# the Encrypted and BlindIndexed attributes, and that table in the ORM schema
$ composer require symfony/doctrine-orm-key-management
# read local keys from S3, FTP, Azure Blob, ... through Flysystem
$ composer require symfony/flysystem-key-management
The component itself ships three local backends that keep the key on disk or in an environment variable. They need no provider, which makes them convenient for development, tests and self-hosted setups. See KMS Backends for all of them.
How It Works
A few terms come up everywhere below.
The master key is the key the KMS holds. It never leaves the provider.
You name it in the configuration (alias/app-key on AWS, a resource name
on Google Cloud and so on) and the provider does the rest.
A data key is a short key, 32 bytes, that your application uses to encrypt one payload locally. The KMS creates it, hands you two copies, one in clear text to encrypt with and one encrypted under the master key, and forgets it. You store the encrypted copy next to the data. To read the data back, you ask the KMS to decrypt that copy, then decrypt the data with it.
Encrypting a payload under a data key, and the data key under the master key, is called envelope encryption. It is what Symfony does by default, for two reasons:
- the KMS only ever sees 32 bytes, so the payload can be any size, while a direct call to a provider is capped: 4 KiB on AWS KMS, and a whole KMIP message must fit in 16 MiB;
- the bulk encryption happens in PHP, so it costs no network call.
The result is an envelope: a single binary blob holding the encrypted data key, the nonce, the authentication tag and the ciphertext. You store it as is. Nothing else is needed to read it back but access to the master key.
One data key per payload means one KMS call per payload. Configuring a data key store changes that: data keys are kept in a database table, many payloads share one, and each payload carries a 16-byte reference instead of the key. The KMS is then called once per key and per process rather than once per value, and changing the master key is a matter of re-encrypting a few rows.
Configuration
The component ships its own bundle, KeyManagementBundle, which the
FrameworkBundle registers as soon as the package is installed. Declare a
client by giving it a DSN:
1 2 3 4
# config/packages/key_management.yaml
key_management:
clients:
app: '%env(KMS_DSN)%'
That is the whole configuration most applications need. The DSN says which provider to talk to and how to authenticate; keep it in an environment variable or in the secrets vault, as the example does, since it usually holds a credential:
1 2 3 4 5
# .env (production: AWS KMS in Ireland)
KMS_DSN=aws-kms://default?region=eu-west-1
# .env.local (development: local keys in a directory)
KMS_DSN=sodium+dir:///etc/kms?ext=.bin
As a shortcut, a single DSN can be the value of key_management itself.
It then declares one client named default:
1 2
# config/packages/key_management.yaml
key_management: '%env(KMS_DSN)%'
See Configuring the KMS Clients for the DSN of each provider, for declaring several clients, and for wrapping data under several providers at once.
Encrypting from a Service
Type-hint the interface you need and autowiring injects the default client:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
use Symfony\Component\KeyManagement\Ciphertext;
use Symfony\Component\KeyManagement\DecrypterInterface;
use Symfony\Component\KeyManagement\EncrypterInterface;
final class TokenVault
{
public function __construct(
private EncrypterInterface $encrypter,
private DecrypterInterface $decrypter,
) {
}
public function store(string $token): array
{
$ciphertext = $this->encrypter->encrypt('app-key', $token);
// keep both: decrypt() needs the key id, not only the bytes
return [$ciphertext->blob, $ciphertext->keyId];
}
public function read(string $blob, string $keyId): string
{
return $this->decrypter->decrypt(new Ciphertext($blob, $keyId));
}
}
Encrypting and decrypting are two interfaces rather than one so that a service that only writes, or only reads, can say so in its signature.
encrypt() returns a
Ciphertext, a
Stringable container holding the bytes the provider returned
together with the key id needed to read them back. Store both, since
casting it to a string yields the bytes alone. That key id is the one the
provider reported, which is not always the one that was asked for, since
AWS KMS resolves an alias to a key ARN and Azure Key Vault pins the version
it used. Recording the resolved id keeps the ciphertext readable after the
alias moves on.
Those two interfaces talk to the provider directly, which caps the payload size. For anything that is not a short string, inject the envelope interfaces instead. They take and return an Envelope, which stringifies to the bytes you store:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\Component\KeyManagement\EnvelopeDecrypterInterface;
use Symfony\Component\KeyManagement\EnvelopeEncrypterInterface;
final class DocumentArchive
{
public function __construct(
private EnvelopeEncrypterInterface $envelopeEncrypter,
private EnvelopeDecrypterInterface $envelopeDecrypter,
) {
}
}
Binding a Ciphertext to Its Context
The third argument of encrypt(), and the second of decrypt(), is
the Additional Authenticated Data (AAD): bytes that are authenticated but
not encrypted, and that must be supplied again, identical, to decrypt:
1 2 3 4
$aad = 'user.email:tenant=acme';
$ciphertext = $this->encrypter->encrypt('app-key', $payload, $aad);
$plaintext = $this->decrypter->decrypt($ciphertext, $aad);
Put in the AAD everything the value must belong to, here the column and the tenant. A ciphertext copied to another column or another tenant then fails to decrypt, rather than quietly succeeding. Compute the AAD from what you are reading, such as the row or the request, and never from the ciphertext itself.
AAD is opaque bytes. Structured context has to be serialized to a stable form first, such as JSON with sorted keys, or two equal contexts will not produce equal bytes. A backend that cannot enforce AAD throws UnsupportedOperationException on a non-empty value when you call it directly, rather than ignoring it.
In envelope mode, the local cipher authenticates the AAD and the KMS never sees it. A mismatch is therefore refused on every backend, including the ones that cannot authenticate data.
For the same reason, a self-contained envelope cannot rely on a KMS policy
conditioned on the AAD, such as the kms:EncryptionContext condition of AWS
KMS. A data key store with
bind_wrapping_context does pass a context
to the KMS, but it is the reference and the scope of the stored key, not your
AAD.
Choosing a Client
To inject a client other than the default one, name it with the #[Target] attribute:
1 2 3 4 5 6 7 8 9 10 11
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\KeyManagement\EncrypterInterface;
final class PiiVault
{
public function __construct(
#[Target('pii')]
private EncrypterInterface $encrypter,
) {
}
}
Each client is also aliased to an argument named after it: $piiKms for
the client itself and $piiEnvelopeEncrypter for its envelope encrypter,
which autowiring honors without the attribute.
When a store is configured, the envelope interfaces resolve to the
store-backed encrypter. #[Target('stored')] and the argument name
$storedEnvelopeEncrypter both name it explicitly. The store itself is
autowired through
DataKeyStoreInterface, or
through
RewrappableDataKeyStoreInterface
for the administrative half: listing the keys, re-wrapping them and
replacing them.
Service IDs
Beyond autowiring, the bundle registers service ids you can reference by hand in a YAML or PHP service definition:
key_management.<name>-
A configured client. Implements
EncrypterInterfaceandDecrypterInterface, plusDataKeyGeneratorInterfacewhen the backend can create data keys. Taggedkey_management.client. key_management.envelope_encrypter.<name>- The envelope encrypter of that client, which writes payloads carrying their own data key.
key_management.store-
The data key store, registered only when
storeis configured. key_management.stored_envelope_encrypter- The encrypter built on that store, which writes payloads carrying a reference instead.
key_management.doctrine_dbal.encrypted_types-
Contains the encrypted Doctrine types. A DoctrineBundle connection registers
these types when the connection is created, so they can wrap the types of
doctrine.dbal.types. Without DoctrineBundle, the types are registered when the kernel boots. Registered only when some types are declared, in the configuration or through an#[Encrypted]column with an explicitkey. key_management.factory- Turns a DSN into a backend. Used by the bundle itself.
Four tags are meant for services of your own. key_management.client adds a
client to the ones that the profiler and the encrypt, decrypt and data key
commands know about, under the name given by its key attribute or, without
that attribute, under its service id. key_management.factory, which
autoconfiguration adds to
every KmsFactoryInterface,
makes a DSN scheme of your own available. key_management.flysystem is
covered in KMS Backends and key_management.blind_index
in Searching Encrypted Data. The data key store doesn't use tagged
clients. It only trusts the client and allowed_clients options of its
configuration.
Profiling
When the profiler is enabled, every client, every envelope encrypter and the store are traced to record what a request asked of the KMS. No plaintext, ciphertext, data key or AAD is recorded. The Key Management panel groups the operations by call site, by key or scope and by service, and shows for each group how many operations there were, how many reached the KMS, the bytes in and out and the time they took.
This is where you confirm that a store is doing its job: it shows one round trip for many encrypted values rather than one per value.