EncryptedCast
Encrypts values when serializing to storage and decrypts them on hydration. Uses XSalsa20-Poly1305 authenticated encryption (libsodium crypto_secretbox), which provides confidentiality and detects tampering with a single ciphertext.
Requirements
PHP's sodium extension (bundled with PHP 7.2+ by default).
Usage
Attribute arguments only allow constant expressions — calling env() or config() inside an attribute is a compile error. Pass the name of an environment variable instead; the key is resolved from the environment when the cast is instantiated:
use StdOut\SimpleDataObjects\Attributes\Cast;
use StdOut\SimpleDataObjects\Casts\EncryptedCast;
class UserData extends BaseData
{
public function __construct(
public readonly string $name,
#[Cast(new EncryptedCast(env: 'DATA_ENCRYPTION_KEY'))]
public readonly string $taxId,
#[Cast(new EncryptedCast(env: 'DATA_ENCRYPTION_KEY'))]
public readonly ?string $creditCard = null,
) {}
}A literal key is also accepted (new EncryptedCast(key: '...')) — useful in tests, but avoid it in application code: the key would live in your source tree. Exactly one of key / env / provider must be provided; a missing or empty environment variable throws a RuntimeException immediately rather than silently encrypting with an empty key.
Key provider (KMS, Vault, HSM)
To keep the key out of the process environment, pass a class implementing EncryptionKeyProvider. It is instantiated without arguments when the cast is built, and its key() is called once:
use StdOut\SimpleDataObjects\Contracts\EncryptionKeyProvider;
final class KmsKeyProvider implements EncryptionKeyProvider
{
public function key(): string
{
return KmsClient::default()->decrypt(file_get_contents('/run/secrets/data-key.enc'));
}
}
#[Cast(new EncryptedCast(provider: KmsKeyProvider::class))]
public readonly string $taxId,A class that does not implement the interface throws InvalidArgumentException; an empty key throws RuntimeException.
Keyring format (recommended)
Pass a class implementing EncryptionKeyring to switch the cast to the keyring format. It is opt-in: without keyring: the cast behaves exactly as before.
use StdOut\SimpleDataObjects\Contracts\EncryptionKeyring;
final class AppKeyring implements EncryptionKeyring
{
public function currentKeyId(): string
{
return '2026';
}
public function keys(): array
{
return [
'2025' => getenv('DATA_KEY_2025'),
'2026' => getenv('DATA_KEY_2026'),
];
}
}
#[Cast(new EncryptedCast(keyring: AppKeyring::class))]
public readonly string $pan,What changes compared to the legacy format:
- Key ID in every value. Values look like
v2:2026:…; new values usecurrentKeyId(), older IDs stay readable, so keys rotate without downtime. - Bound to the field. The class and property name are authenticated as associated data. A ciphertext copied into another field (
pan→holder) or another class fails withRuntimeException: Decryption failed. Subclasses share the binding of the class that declares the property. Passcontext: 'customers.pan'to choose the binding yourself, for example to keep data readable after renaming a class. - Length hidden. The plaintext is padded to 32-byte blocks, so a 13-digit and a 19-digit card number produce ciphertexts of the same length.
- Strong keys only. Each key must be 32 random bytes — raw, or base64 / base64url-encoded (
base64_encode(random_bytes(32))). Passwords and other printable strings are rejected. A per-purpose subkey is derived withsodium_crypto_kdf_derive_from_key. - Key ID format. IDs match
[A-Za-z0-9_-]{1,32}.
To migrate existing data, keep the old key next to the keyring. The cast then reads both formats and writes only the new one:
#[Cast(new EncryptedCast(env: 'DATA_ENCRYPTION_KEY', keyring: AppKeyring::class))]Hydration (decrypt)
$user = UserData::from([
'name' => 'Alice',
'taxId' => 'zW1o...base64url...', // ciphertext from database
]);
$user->taxId; // '1234567890' — plaintextSerialization (encrypt)
$user->toArray()['taxId']; // 'Qk3x...base64url...' — ciphertext, unique each callEvery set() call produces a different ciphertext because a random 24-byte nonce is generated for each encryption. This is normal and expected.
Security Properties
| Property | Keyring format | Legacy format |
|---|---|---|
| Algorithm | XChaCha20-Poly1305-IETF (sodium_crypto_aead_xchacha20poly1305_ietf_*) | XSalsa20-Poly1305 (sodium_crypto_secretbox) |
| Authentication | Poly1305 tag over the ciphertext, the key ID and the class + field binding | Poly1305 tag over the ciphertext only: a valid ciphertext copied to another field or record still decrypts |
| Key | 32 random bytes per key ID; subkey via sodium_crypto_kdf_derive_from_key | Any string, hashed with BLAKE2b — a fast hash, not a password KDF |
| Length | Padded to 32-byte blocks (sodium_pad) | Ciphertext length reveals plaintext length |
| Nonce | 24 random bytes | 24 random bytes |
| Format | v2:<key id>:base64url(nonce ‖ ciphertext) | base64url(nonce ‖ ciphertext) |
Both formats are told apart by the v2: prefix, which cannot occur in base64url.
Key Handling
Use a key of 32 random bytes (for example base64_encode(random_bytes(32))). The keyring format enforces this. The legacy format hashes any string with a fast function, so a short or guessable key such as a password can be brute-forced from any stored ciphertext.
The cast keeps key material out of the places the library controls:
- Never written to the metadata file cache.
EncryptedCastdeliberately does not implement__set_state(), so classes using it are excluded from file-based metadata persistence — only the in-memory cache applies. Exporting the cast would write the key to disk in plaintext. - Not serializable.
serialize()on the cast throws aLogicException. - Hidden from every dump. The derived key is held in a
\SensitiveParameterValue, sovar_dump(),print_r(),var_export(),(array)casts andjson_encode()of the cast, or of metadata that contains it, never show it. The constructor parameter and the values passed toget()/set()are marked#[\SensitiveParameter], so neither the key nor the plaintext appears in stack traces.
It cannot protect the key everywhere: a key read with env: stays in the process environment — use a key provider to avoid that.
Tamper Detection
If the ciphertext has been modified, get() throws RuntimeException:
$user = UserData::from(['taxId' => $tamperedCiphertext]);
// RuntimeException: Decryption failed: authentication tag mismatch.Input that is not base64url or is shorter than a nonce throws InvalidArgumentException instead.
Key Rotation
With a keyring, add the new key, point currentKeyId() at it and deploy: new writes use it, old values stay readable. Re-encrypt old rows at your own pace and remove the old key only when nothing uses it:
$cast = (new EncryptedCast(keyring: AppKeyring::class))->forField(CustomerData::class, 'pan');
foreach (Customer::query()->cursor() as $customer) {
if ($cast->needsReencryption($customer->pan)) {
$customer->pan = $cast->set($cast->get($customer->pan));
$customer->saveQuietly();
}
}forField() applies the same binding the DTO uses. Skip it if the cast has an explicit context:. needsReencryption() is true for legacy values and for values written with an older key ID. When the cast also has a legacy key, the same loop migrates legacy data to the keyring format.
Without a keyring, a ciphertext carries no key ID, so one cast instance can only read values written with its own key. Rotate with a one-off migration that holds both keys:
$old = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY_OLD');
$new = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY');
foreach (Customer::query()->cursor() as $customer) {
$customer->tax_id = $new->set($old->get($customer->tax_id));
$customer->saveQuietly();
}Until every row is migrated, the application can read only the rows written with the key it is configured with, so run the migration during a maintenance window or before switching the application to the new key.
Searching encrypted fields
Ciphertexts are random, so WHERE pan = ? cannot match them. Store a blind index next to the encrypted column and query by it:
use StdOut\SimpleDataObjects\Support\BlindIndex;
$index = new BlindIndex(env: 'PAN_INDEX_KEY', context: 'customers.pan');
$customer->pan_index = $index->hash($pan);
Customer::where('pan_index', $index->hash($request->pan))->first();
$index->matches($pan, $customer->pan_index);hash() is a keyed HMAC-SHA256, base64url-encoded (43 characters). Use a separate key — 32 random bytes, raw or base64 — that is not one of the encryption keys. context separates indexes of different columns. An index reveals which rows share a value, so index only what you search by, and normalize input (strip spaces and dashes) before hashing. BlindIndex cannot be serialized and does not show its key in dumps.
Breaking Change Notice
EncryptedCast uses XSalsa20-Poly1305 (libsodium). If you are upgrading from an older version that used AES-256-CBC (OpenSSL), existing ciphertexts are not compatible and must be re-encrypted.

