Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

## [2.5.0] — 2026-10-16

### Added
- `EncryptedCast(keyring: ...)` with the `EncryptionKeyring` contract turns
on a new ciphertext format, `v2:<key id>:…` (XChaCha20-Poly1305-IETF):
- the key ID is stored in the value, so keys rotate without downtime;
- the value is bound to its class and field (or to `context:`), so a
ciphertext copied into another field is rejected;
- the plaintext is padded to 32-byte blocks, hiding card-number length;
- keys must be 32 random bytes, raw or base64.
Opt-in: without `keyring:` nothing changes. With a legacy key next to the
keyring, both formats are read and only the new one is written.
- `EncryptedCast::needsReencryption()` and `forField()` for rotation and
migration scripts.
- `Support\BlindIndex`: a keyed HMAC-SHA256 to search encrypted columns.
- `Contracts\BindsToField`: casts implementing it learn their class and
field when metadata is built.

## [2.4.0] — 2026-10-16

### Added
Expand Down Expand Up @@ -587,7 +605,8 @@ Initial release.
`toResponse()`).
- CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`.

[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...HEAD
[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.5.0...HEAD
[2.5.0]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...v2.5.0
[2.4.0]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...v2.4.0
[2.3.0]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...v2.3.0
[2.2.1]: https://github.com/std-out/simple-data-objects/compare/v2.2.0...v2.2.1
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export interface OrderData {

## Built-in Casts

`DateTimeCast` · `DateTimeImmutableCast` · `EnumCast` · `BooleanCast` · `IntegerCast` · `FloatCast` · `TrimCast` · `JsonCast` · `EncryptedCast` (XSalsa20-Poly1305)
`DateTimeCast` · `DateTimeImmutableCast` · `EnumCast` · `BooleanCast` · `IntegerCast` · `FloatCast` · `TrimCast` · `JsonCast` · `EncryptedCast` (XChaCha20-Poly1305 with key IDs and field binding, or legacy XSalsa20-Poly1305)

---

Expand Down
93 changes: 84 additions & 9 deletions docs/casts/encrypted.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,47 @@ 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.

```php
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 use `currentKeyId()`, 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 with `RuntimeException: Decryption failed`. Subclasses share the binding of the class that declares the property. Pass `context: '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 with `sodium_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:

```php
#[Cast(new EncryptedCast(env: 'DATA_ENCRYPTION_KEY', keyring: AppKeyring::class))]
```

### Hydration (decrypt)

```php
Expand All @@ -72,17 +113,20 @@ Every `set()` call produces a **different ciphertext** because a random 24-byte

## Security Properties

| Property | Detail |
|---|---|
| Algorithm | XSalsa20-Poly1305 (`sodium_crypto_secretbox`) |
| Authentication | Poly1305 tag — a modified ciphertext is rejected. There is no associated data: a valid ciphertext copied to another field or record still decrypts |
| Key derivation | BLAKE2b (`sodium_crypto_generichash`) of the provided key string — a fast hash, not a password KDF |
| Nonce | 24 random bytes prepended to each ciphertext |
| Format | `base64url(nonce ‖ ciphertext)`, no padding, no version prefix or key ID |
| 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))`). Because the key is hashed with a fast function, a short or guessable string such as a password can be brute-forced from any stored ciphertext.
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:

Expand All @@ -105,7 +149,22 @@ Input that is not base64url or is shorter than a nonce throws `InvalidArgumentEx

## Key Rotation

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:
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:

```php
$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:

```php
$old = new EncryptedCast(env: 'DATA_ENCRYPTION_KEY_OLD');
Expand All @@ -119,6 +178,22 @@ foreach (Customer::query()->cursor() as $customer) {

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:

```php
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.
Loading
Loading