From dbfe889463c83f8f8a581716d2e822138263f5d5 Mon Sep 17 00:00:00 2001 From: yuriizee Date: Fri, 16 Oct 2026 16:06:46 +0300 Subject: [PATCH] Release 2.5.0: keyring ciphertext format with key IDs, field binding and padding; BlindIndex - EncryptedCast(keyring:) writes v2::... with XChaCha20-Poly1305-IETF, class and field bound as associated data, plaintext padded to 32 bytes, 32-byte keys only; legacy ciphertext stays readable with the old key - needsReencryption() and forField() for rotation scripts - BindsToField contract, bound at metadata build time - Support\BlindIndex for searching encrypted columns --- CHANGELOG.md | 21 +- README.md | 2 +- docs/casts/encrypted.md | 93 +++++++- src/Casts/EncryptedCast.php | 186 +++++++++++++--- src/Contracts/BindsToField.php | 11 + src/Contracts/EncryptionKeyring.php | 13 ++ src/Support/BlindIndex.php | 57 +++++ src/Support/ClassMetaFactory.php | 11 +- src/Support/KeyMaterial.php | 65 ++++++ tests/BlindIndexTest.php | 85 +++++++ tests/EncryptedKeyringTest.php | 243 +++++++++++++++++++++ tests/Fixtures/ConfigurableKeyring.php | 25 +++ tests/Fixtures/KeyringCardData.php | 21 ++ tests/Fixtures/KeyringCardSubclassData.php | 7 + tests/Fixtures/TestKeyring.php | 23 ++ 15 files changed, 824 insertions(+), 39 deletions(-) create mode 100644 src/Contracts/BindsToField.php create mode 100644 src/Contracts/EncryptionKeyring.php create mode 100644 src/Support/BlindIndex.php create mode 100644 src/Support/KeyMaterial.php create mode 100644 tests/BlindIndexTest.php create mode 100644 tests/EncryptedKeyringTest.php create mode 100644 tests/Fixtures/ConfigurableKeyring.php create mode 100644 tests/Fixtures/KeyringCardData.php create mode 100644 tests/Fixtures/KeyringCardSubclassData.php create mode 100644 tests/Fixtures/TestKeyring.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5d88d..6f02ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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::…` (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 @@ -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 diff --git a/README.md b/README.md index 95a8174..6d446d0 100644 --- a/README.md +++ b/README.md @@ -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) --- diff --git a/docs/casts/encrypted.md b/docs/casts/encrypted.md index 795e5ff..e457267 100644 --- a/docs/casts/encrypted.md +++ b/docs/casts/encrypted.md @@ -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 @@ -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::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: @@ -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'); @@ -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. diff --git a/src/Casts/EncryptedCast.php b/src/Casts/EncryptedCast.php index 86778eb..53ef40d 100644 --- a/src/Casts/EncryptedCast.php +++ b/src/Casts/EncryptedCast.php @@ -8,24 +8,41 @@ use LogicException; use RuntimeException; use SodiumException; +use StdOut\SimpleDataObjects\Contracts\BindsToField; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\EncryptionKeyProvider; +use StdOut\SimpleDataObjects\Contracts\EncryptionKeyring; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; +use StdOut\SimpleDataObjects\Support\KeyMaterial; +use StdOut\SimpleDataObjects\Support\Redactor; /** - * Authenticated encryption using XSalsa20-Poly1305 (libsodium). + * Authenticated encryption using libsodium. * * Deliberately NOT exportable to the metadata file cache (no __set_state): * exporting would write the key material to disk in plaintext. Classes using * this cast still get the in-memory metadata cache; only file persistence * is skipped. - * - * Breaking change from previous AES-256-CBC version: existing ciphertext - * produced by the old cast is not compatible and must be re-encrypted. */ -final class EncryptedCast implements CastsValue, ProvidesJsonSchema +final class EncryptedCast implements BindsToField, CastsValue, ProvidesJsonSchema { - private readonly \SensitiveParameterValue $secretKey; + private const string PREFIX = 'v2:'; + + private const int PAD_BLOCK = 32; + + private const string KDF_CONTEXT = 'SDOcrypt'; + + private const string KEY_ID_PATTERN = '/^[A-Za-z0-9_-]{1,32}$/'; + + private readonly ?\SensitiveParameterValue $secretKey; + + private readonly ?\SensitiveParameterValue $keyring; + + private readonly ?string $currentKeyId; + + private readonly bool $hasExplicitContext; + + private string $associatedData; /** * Attribute arguments only allow constant expressions — env() or config() @@ -35,22 +52,50 @@ final class EncryptedCast implements CastsValue, ProvidesJsonSchema * * #[Cast(new EncryptedCast(env: 'DATA_ENCRYPTION_KEY'))] * #[Cast(new EncryptedCast(provider: KmsKeyProvider::class))] + * #[Cast(new EncryptedCast(keyring: AppKeyring::class))] * * @param class-string|null $provider + * @param class-string|null $keyring */ public function __construct( #[\SensitiveParameter] ?string $key = null, ?string $env = null, ?string $provider = null, + ?string $keyring = null, + ?string $context = null, ) { - $key = match (true) { - ($key !== null) + ($env !== null) + ($provider !== null) !== 1 => throw new InvalidArgumentException('Provide exactly one of $key, $env or $provider.'), - $env !== null => self::keyFromEnvironment($env), - $provider !== null => self::keyFromProvider($provider), - default => $key, - }; - - $this->secretKey = new \SensitiveParameterValue(sodium_crypto_generichash($key, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES)); + $legacyKey = KeyMaterial::resolve($key, $env, $provider); + + if ($legacyKey === null && $keyring === null) { + throw new InvalidArgumentException('Provide $keyring, or exactly one of $key, $env or $provider.'); + } + + $this->secretKey = $legacyKey === null + ? null + : new \SensitiveParameterValue(sodium_crypto_generichash($legacyKey, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES)); + + if ($keyring === null) { + $this->keyring = null; + $this->currentKeyId = null; + } else { + [$keys, $this->currentKeyId] = self::loadKeyring($keyring); + $this->keyring = new \SensitiveParameterValue($keys); + } + + $this->hasExplicitContext = $context !== null; + $this->associatedData = $context ?? ''; + } + + public function forField(string $class, string $field): static + { + if ($this->hasExplicitContext) { + return $this; + } + + $bound = clone $this; + $bound->associatedData = $class.'::'.$field; + + return $bound; } public function get(#[\SensitiveParameter] mixed $value): ?string @@ -59,6 +104,14 @@ public function get(#[\SensitiveParameter] mixed $value): ?string return null; } + if (str_starts_with((string) $value, self::PREFIX)) { + return $this->open((string) $value); + } + + if ($this->secretKey === null) { + throw new RuntimeException('Encrypted value uses the legacy format, but no $key, $env or $provider is configured to read it.'); + } + try { $decoded = sodium_base642bin((string) $value, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } catch (SodiumException) { @@ -91,12 +144,21 @@ public function set(#[\SensitiveParameter] mixed $value): ?string throw new InvalidArgumentException('EncryptedCast can only encrypt scalar or Stringable values, got '.get_debug_type($value).'.'); } + if ($this->keyring !== null) { + return $this->seal((string) $value); + } + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = sodium_crypto_secretbox((string) $value, $nonce, $this->secretKey->getValue()); return sodium_bin2base64($nonce.$ciphertext, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); } + public function needsReencryption(string $value): bool + { + return $this->keyring !== null && ! str_starts_with($value, self::PREFIX.$this->currentKeyId.':'); + } + /** Prevents the derived key from leaking through serialize(). */ public function __serialize(): array { @@ -106,7 +168,7 @@ public function __serialize(): array /** Redacts key material in var_dump()/print_r() output. */ public function __debugInfo(): array { - return ['secretKey' => '[redacted]']; + return ['secretKey' => Redactor::REDACTED]; } public function jsonSchema(): array @@ -114,29 +176,99 @@ public function jsonSchema(): array return ['type' => 'string']; } - private static function keyFromEnvironment(string $env): string + private function seal(#[\SensitiveParameter] string $plaintext): string { - $key = $_ENV[$env] ?? getenv($env); + $keyId = (string) $this->currentKeyId; + $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); + $ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( + sodium_pad($plaintext, self::PAD_BLOCK), + $this->aad($keyId), + $nonce, + $this->keyring->getValue()[$keyId], + ); - if (! is_string($key) || $key === '') { - throw new RuntimeException("Environment variable '{$env}' is not set or empty."); + return self::PREFIX.$keyId.':'.sodium_bin2base64($nonce.$ciphertext, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + } + + private function open(#[\SensitiveParameter] string $value): string + { + if ($this->keyring === null) { + throw new RuntimeException('Encrypted value uses the keyring format, but no $keyring is configured to read it.'); + } + + $parts = explode(':', $value, 3); + + if (count($parts) !== 3) { + throw new InvalidArgumentException('Encrypted value is malformed.'); + } + + [, $keyId, $payload] = $parts; + $key = $this->keyring->getValue()[$keyId] + ?? throw new RuntimeException("Unknown encryption key id '".Redactor::forMessage($keyId)."'."); + + try { + $decoded = sodium_base642bin($payload, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING); + } catch (SodiumException) { + throw new InvalidArgumentException('Encrypted value is not valid base64.'); + } + + if (strlen($decoded) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) { + throw new InvalidArgumentException('Encrypted value is too short.'); } - return $key; + $padded = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + substr($decoded, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES), + $this->aad($keyId), + substr($decoded, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES), + $key, + ); + + if ($padded === false) { + throw new RuntimeException('Decryption failed: authentication tag mismatch.'); + } + + return sodium_unpad($padded, self::PAD_BLOCK); + } + + private function aad(string $keyId): string + { + return self::PREFIX.$keyId.':'.$this->associatedData; } - private static function keyFromProvider(string $provider): string + /** + * @param class-string $keyring + * @return array{array, string} + */ + private static function loadKeyring(string $keyring): array { - if (! is_subclass_of($provider, EncryptionKeyProvider::class)) { - throw new InvalidArgumentException("Key provider '{$provider}' must implement ".EncryptionKeyProvider::class.'.'); + if (! is_subclass_of($keyring, EncryptionKeyring::class)) { + throw new InvalidArgumentException("Keyring '{$keyring}' must implement ".EncryptionKeyring::class.'.'); + } + + $source = new $keyring; + $keys = []; + + foreach ($source->keys() as $keyId => $material) { + $keyId = (string) $keyId; + + if (preg_match(self::KEY_ID_PATTERN, $keyId) !== 1) { + throw new InvalidArgumentException("Key id '".Redactor::forMessage($keyId)."' of {$keyring} must match [A-Za-z0-9_-]{1,32}."); + } + + $keys[$keyId] = sodium_crypto_kdf_derive_from_key( + KeyMaterial::BYTES, + 1, + self::KDF_CONTEXT, + KeyMaterial::strict($material, "Key '{$keyId}' of {$keyring}"), + ); } - $key = (new $provider)->key(); + $current = $source->currentKeyId(); - if ($key === '') { - throw new RuntimeException("Key provider '{$provider}' returned an empty key."); + if (! isset($keys[$current])) { + throw new InvalidArgumentException("Current key id '".Redactor::forMessage($current)."' is not among the keys of {$keyring}."); } - return $key; + return [$keys, $current]; } } diff --git a/src/Contracts/BindsToField.php b/src/Contracts/BindsToField.php new file mode 100644 index 0000000..2e1ca64 --- /dev/null +++ b/src/Contracts/BindsToField.php @@ -0,0 +1,11 @@ + */ + public function keys(): array; +} diff --git a/src/Support/BlindIndex.php b/src/Support/BlindIndex.php new file mode 100644 index 0000000..3f98527 --- /dev/null +++ b/src/Support/BlindIndex.php @@ -0,0 +1,57 @@ +|null $provider */ + public function __construct( + #[\SensitiveParameter] ?string $key = null, + ?string $env = null, + ?string $provider = null, + string $context = '', + ) { + $material = KeyMaterial::resolve($key, $env, $provider) + ?? throw new InvalidArgumentException('Provide exactly one of $key, $env or $provider.'); + + $this->key = new \SensitiveParameterValue( + sodium_crypto_kdf_derive_from_key(KeyMaterial::BYTES, 1, self::KDF_CONTEXT, KeyMaterial::strict($material, 'Blind index key')), + ); + $this->prefix = pack('N', strlen($context)).$context; + } + + public function hash(#[\SensitiveParameter] string $value): string + { + return sodium_bin2base64( + hash_hmac('sha256', $this->prefix.$value, $this->key->getValue(), true), + SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING, + ); + } + + public function matches(#[\SensitiveParameter] string $value, string $index): bool + { + return hash_equals($index, $this->hash($value)); + } + + public function __serialize(): array + { + throw new LogicException('BlindIndex holds key material and must not be serialized.'); + } + + public function __debugInfo(): array + { + return ['key' => Redactor::REDACTED]; + } +} diff --git a/src/Support/ClassMetaFactory.php b/src/Support/ClassMetaFactory.php index b2cdf4a..9dcefbe 100644 --- a/src/Support/ClassMetaFactory.php +++ b/src/Support/ClassMetaFactory.php @@ -30,6 +30,8 @@ use StdOut\SimpleDataObjects\Attributes\XmlElement; use StdOut\SimpleDataObjects\Attributes\XmlText; use StdOut\SimpleDataObjects\Concerns\HasSensitiveFields; +use StdOut\SimpleDataObjects\Contracts\BindsToField; +use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\DataObject; use StdOut\SimpleDataObjects\Exceptions\DataHydrationException; @@ -368,7 +370,7 @@ enumClass: $enumClass, ignoreIfNull: $parameter->getAttributes(IgnoreIfNull::class) !== [], flatten: $parameter->getAttributes(Flatten::class) !== [], rules: $rules, - caster: $castAttrs !== [] ? $castAttrs[0]->newInstance()->caster : null, + caster: $castAttrs !== [] ? self::bindCaster($castAttrs[0]->newInstance()->caster, $parameter) : null, pipes: $paramPipes, viaConstructor: $viaConstructor, whenLoadedRelation: $whenLoadedAttrs !== [] ? $whenLoadedAttrs[0]->newInstance()->relation : null, @@ -381,6 +383,13 @@ enumClass: $enumClass, ); } + private static function bindCaster(CastsValue $caster, ReflectionParameter|ReflectionProperty $parameter): CastsValue + { + return $caster instanceof BindsToField + ? $caster->forField((string) $parameter->getDeclaringClass()?->getName(), $parameter->getName()) + : $caster; + } + private static function xmlSource(ReflectionParameter|ReflectionProperty $parameter, string $phpName, string $defaultName): ?string { $attribute = $parameter->getAttributes(XmlAttribute::class); diff --git a/src/Support/KeyMaterial.php b/src/Support/KeyMaterial.php new file mode 100644 index 0000000..e89e3a7 --- /dev/null +++ b/src/Support/KeyMaterial.php @@ -0,0 +1,65 @@ + 1 => throw new InvalidArgumentException('Provide exactly one of $key, $env or $provider.'), + $env !== null => self::fromEnvironment($env), + $provider !== null => self::fromProvider($provider), + default => $key, + }; + } + + public static function strict(#[\SensitiveParameter] string $key, string $label): string + { + if (strlen($key) === self::BYTES && ! ctype_print($key)) { + return $key; + } + + $decoded = base64_decode(strtr($key, '-_', '+/'), true); + + if ($decoded !== false && strlen($decoded) === self::BYTES) { + return $decoded; + } + + throw new InvalidArgumentException("{$label} must be 32 random bytes, raw or base64-encoded."); + } + + private static function fromEnvironment(string $env): string + { + $key = $_ENV[$env] ?? getenv($env); + + if (! is_string($key) || $key === '') { + throw new RuntimeException("Environment variable '{$env}' is not set or empty."); + } + + return $key; + } + + private static function fromProvider(string $provider): string + { + if (! is_subclass_of($provider, EncryptionKeyProvider::class)) { + throw new InvalidArgumentException("Key provider '{$provider}' must implement ".EncryptionKeyProvider::class.'.'); + } + + $key = (new $provider)->key(); + + if ($key === '') { + throw new RuntimeException("Key provider '{$provider}' returned an empty key."); + } + + return $key; + } +} diff --git a/tests/BlindIndexTest.php b/tests/BlindIndexTest.php new file mode 100644 index 0000000..a26c20c --- /dev/null +++ b/tests/BlindIndexTest.php @@ -0,0 +1,85 @@ +hash('4111111111111111'); + + $this->assertSame($hash, (new BlindIndex(base64_encode(self::KEY), context: 'cards.pan'))->hash('4111111111111111')); + $this->assertMatchesRegularExpression('/^[A-Za-z0-9_-]{43}$/', $hash); + $this->assertStringNotContainsString('1111', $hash); + $this->assertNotSame($hash, $index->hash('4111111111111112')); + } + + public function test_context_separates_indexes(): void + { + $this->assertNotSame( + (new BlindIndex(self::KEY, context: 'cards.pan'))->hash('4111111111111111'), + (new BlindIndex(self::KEY, context: 'cards.pan2'))->hash('4111111111111111'), + ); + $this->assertNotSame( + (new BlindIndex(self::KEY, context: 'a'))->hash('bc'), + (new BlindIndex(self::KEY, context: 'ab'))->hash('c'), + ); + } + + public function test_matches_compares_in_constant_time(): void + { + $index = new BlindIndex(self::KEY); + + $this->assertTrue($index->matches('4111111111111111', $index->hash('4111111111111111'))); + $this->assertFalse($index->matches('4111111111111112', $index->hash('4111111111111111'))); + } + + public function test_key_is_read_from_the_environment(): void + { + $_ENV['SDO_TEST_INDEX_KEY'] = base64_encode(self::KEY); + + try { + $this->assertSame((new BlindIndex(self::KEY))->hash('x'), (new BlindIndex(env: 'SDO_TEST_INDEX_KEY'))->hash('x')); + } finally { + unset($_ENV['SDO_TEST_INDEX_KEY']); + } + } + + public function test_weak_key_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Blind index key must be 32 random bytes'); + + new BlindIndex('password'); + } + + public function test_a_key_source_is_required(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide exactly one of $key, $env or $provider.'); + + new BlindIndex(context: 'x'); + } + + public function test_key_never_appears_in_dumps_or_serialization(): void + { + $index = new BlindIndex(self::KEY); + $dump = var_export($index, true).print_r((array) $index, true).print_r($index, true); + + $this->assertStringNotContainsString(self::KEY, $dump); + $this->assertStringContainsString('[redacted]', print_r($index, true)); + + $this->expectException(LogicException::class); + serialize($index); + } +} diff --git a/tests/EncryptedKeyringTest.php b/tests/EncryptedKeyringTest.php new file mode 100644 index 0000000..522ce4c --- /dev/null +++ b/tests/EncryptedKeyringTest.php @@ -0,0 +1,243 @@ + base64_encode(str_repeat("\x03", 32))]; + } + + private static function ring(): EncryptedCast + { + return new EncryptedCast(keyring: ConfigurableKeyring::class); + } + + public function test_round_trips_in_the_keyring_format(): void + { + $cast = self::ring(); + $sealed = $cast->set('4111111111111111'); + + $this->assertMatchesRegularExpression('/^v2:k1:[A-Za-z0-9_-]+$/', $sealed); + $this->assertSame('4111111111111111', $cast->get($sealed)); + $this->assertNotSame($sealed, $cast->set('4111111111111111')); + } + + public function test_pads_plaintext_so_card_lengths_are_indistinguishable(): void + { + $cast = self::ring(); + $lengths = array_map(static fn (string $pan): int => strlen($cast->set($pan)), ['4111111111111', '4111111111111111', '4111111111111111111', '']); + + $this->assertCount(1, array_unique($lengths)); + } + + public function test_reads_older_keys_and_writes_with_the_current_one(): void + { + $old = self::ring()->set('secret'); + + ConfigurableKeyring::$keys['k2'] = str_repeat("\x04", 32); + ConfigurableKeyring::$current = 'k2'; + $rotated = self::ring(); + + $this->assertSame('secret', $rotated->get($old)); + $this->assertTrue($rotated->needsReencryption($old)); + $this->assertStringStartsWith('v2:k2:', $rotated->set('secret')); + $this->assertFalse($rotated->needsReencryption($rotated->set('secret'))); + } + + public function test_reads_legacy_ciphertext_when_a_legacy_key_is_configured(): void + { + $legacy = (new EncryptedCast('old-key'))->set('secret'); + $cast = new EncryptedCast('old-key', keyring: ConfigurableKeyring::class); + + $this->assertSame('secret', $cast->get($legacy)); + $this->assertTrue($cast->needsReencryption($legacy)); + $this->assertStringStartsWith('v2:k1:', $cast->set('secret')); + $this->assertFalse((new EncryptedCast('old-key'))->needsReencryption($legacy)); + } + + public function test_legacy_ciphertext_without_a_legacy_key_throws(): void + { + $legacy = (new EncryptedCast('old-key'))->set('secret'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('legacy format'); + + self::ring()->get($legacy); + } + + public function test_keyring_ciphertext_without_a_keyring_throws(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('no $keyring is configured'); + + (new EncryptedCast('old-key'))->get(self::ring()->set('secret')); + } + + public function test_unknown_key_id_throws(): void + { + $sealed = self::ring()->set('secret'); + ConfigurableKeyring::$keys = ['k9' => str_repeat("\x05", 32)]; + ConfigurableKeyring::$current = 'k9'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Unknown encryption key id 'k1'."); + + self::ring()->get($sealed); + } + + public function test_ciphertext_is_bound_to_its_field(): void + { + $cast = self::ring(); + $pan = $cast->forField(KeyringCardData::class, 'pan'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('authentication tag mismatch'); + + $cast->forField(KeyringCardData::class, 'holder')->get($pan->set('4111111111111111')); + } + + public function test_swapping_ciphertexts_between_fields_of_a_dto_is_rejected(): void + { + $card = (new KeyringCardData('4111111111111111', 'Alice'))->toArray(); + + $this->assertSame('4111111111111111', KeyringCardData::from($card)->pan); + $this->assertSame('Alice', KeyringCardSubclassData::from($card)->holder); + + $this->expectException(RuntimeException::class); + + KeyringCardData::from(['pan' => $card['holder'], 'holder' => $card['pan']]); + } + + public function test_explicit_context_survives_field_binding(): void + { + $sealed = (new KeyringCardData('1', 'a', 'n'))->toArray()['note']; + $cast = new EncryptedCast(keyring: TestKeyring::class, context: 'cards.note'); + + $this->assertSame('n', $cast->get($sealed)); + $this->assertSame($cast, $cast->forField(KeyringCardData::class, 'note')); + } + + public function test_unbound_cast_cannot_read_a_field_bound_value(): void + { + $sealed = (new KeyringCardData('4111111111111111', 'a'))->toArray()['pan']; + $cast = new EncryptedCast(keyring: TestKeyring::class); + + $this->assertSame('4111111111111111', $cast->forField(KeyringCardData::class, 'pan')->get($sealed)); + + $this->expectException(RuntimeException::class); + $cast->get($sealed); + } + + public function test_tampered_key_id_is_rejected(): void + { + ConfigurableKeyring::$keys['k2'] = ConfigurableKeyring::$keys['k1']; + $sealed = self::ring()->set('secret'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('authentication tag mismatch'); + + self::ring()->get('v2:k2:'.substr($sealed, 6)); + } + + /** @return iterable, string}> */ + public static function malformedValues(): iterable + { + yield 'missing payload' => ['v2:k1', InvalidArgumentException::class, 'malformed']; + yield 'not base64' => ['v2:k1:!!!', InvalidArgumentException::class, 'not valid base64']; + yield 'too short' => ['v2:k1:'.sodium_bin2base64(str_repeat('x', 40), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), InvalidArgumentException::class, 'too short']; + yield 'forged' => ['v2:k1:'.sodium_bin2base64(str_repeat('x', 41), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING), RuntimeException::class, 'authentication tag mismatch']; + } + + /** @param class-string<\Throwable> $exception */ + #[DataProvider('malformedValues')] + public function test_malformed_keyring_values_are_rejected(string $value, string $exception, string $message): void + { + $this->expectException($exception); + $this->expectExceptionMessage($message); + + self::ring()->get($value); + } + + /** @return iterable, string, string}> */ + public static function invalidKeyrings(): iterable + { + $key = str_repeat("\x06", 32); + + yield 'short key' => [['k1' => 'too-short'], 'k1', "Key 'k1' of ".ConfigurableKeyring::class.' must be 32 random bytes']; + yield 'printable 32-character key' => [['k1' => str_repeat('a', 32)], 'k1', 'must be 32 random bytes']; + yield 'key id with a colon' => [['k:1' => $key], 'k:1', "Key id 'k:1'"]; + yield 'current key missing' => [['k1' => $key], 'k2', "Current key id 'k2' is not among the keys"]; + } + + /** @param array $keys */ + #[DataProvider('invalidKeyrings')] + public function test_invalid_keyrings_are_rejected(array $keys, string $current, string $message): void + { + ConfigurableKeyring::$keys = $keys; + ConfigurableKeyring::$current = $current; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + self::ring(); + } + + public function test_integer_key_ids_are_accepted(): void + { + ConfigurableKeyring::$keys = [7 => str_repeat("\x07", 32)]; + ConfigurableKeyring::$current = '7'; + + $this->assertStringStartsWith('v2:7:', self::ring()->set('x')); + } + + public function test_url_safe_base64_keys_are_accepted(): void + { + $raw = str_repeat("\xfb", 32); + ConfigurableKeyring::$keys = ['k1' => rtrim(strtr(base64_encode($raw), '+/', '-_'), '=')]; + $sealed = self::ring()->set('x'); + + ConfigurableKeyring::$keys = ['k1' => $raw]; + + $this->assertSame('x', self::ring()->get($sealed)); + } + + public function test_class_that_is_not_a_keyring_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Keyring 'stdClass' must implement"); + + new EncryptedCast(keyring: \stdClass::class); + } + + public function test_no_key_source_at_all_is_rejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide $keyring, or exactly one of $key, $env or $provider.'); + + new EncryptedCast(context: 'x'); + } + + public function test_keyring_keys_never_appear_in_dumps(): void + { + $cast = self::ring(); + $dump = var_export($cast, true).print_r((array) $cast, true).print_r($cast, true); + + $this->assertStringNotContainsString(str_repeat("\x03", 32), $dump); + $this->assertStringNotContainsString(ConfigurableKeyring::$keys['k1'], $dump); + } +} diff --git a/tests/Fixtures/ConfigurableKeyring.php b/tests/Fixtures/ConfigurableKeyring.php new file mode 100644 index 0000000..0deec42 --- /dev/null +++ b/tests/Fixtures/ConfigurableKeyring.php @@ -0,0 +1,25 @@ + */ + public static array $keys = []; + + public function currentKeyId(): string + { + return self::$current; + } + + public function keys(): array + { + return self::$keys; + } +} diff --git a/tests/Fixtures/KeyringCardData.php b/tests/Fixtures/KeyringCardData.php new file mode 100644 index 0000000..8c1f84f --- /dev/null +++ b/tests/Fixtures/KeyringCardData.php @@ -0,0 +1,21 @@ + str_repeat("\x01", 32), + '2026' => base64_encode(str_repeat("\x02", 32)), + ]; + } +}