diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f333f6f..0712b63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: extensions: sodium, openssl phpunit-without-openssl: - name: PHP 8.2 without openssl + name: PHP 8.2 without openssl and sodium runs-on: windows-latest steps: - name: Checkout @@ -57,7 +57,7 @@ jobs: with: php-version: '8.2' coverage: xdebug - extensions: :openssl + extensions: :openssl, :sodium - name: Run tests with PHPUnit with code coverage run: vendor/bin/phpunit --coverage-clover=coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbdd6e..461f368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.2.1 under development +- New #71: Add `KdfCryptor`, `EnvelopeCryptor` and `VersionedCryptor` (@olegbaturin) - Bug #72: Fix possibly null offset in `PasswordHasher` (@olegbaturin) - Chg #75: Bump minimal required PHP version to 8.2 (@vjik) - Enh #75: Mark `Mac` class as readonly (@vjik) diff --git a/README.md b/README.md index 6dac482..419f1ad 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Security package provides a set of classes to handle common security-related tas - PHP 8.2 - 8.5. - `hash` PHP extension. -- `openssl` PHP extension. +- `openssl` PHP extension - optional. +- `sodium` PHP extension - optional. ## Installation @@ -71,46 +72,6 @@ $hash = getHash(); $result = (new PasswordHasher())->validate($password, $hash); ``` -### Encryption and decryption by password - -Encrypting data: - -```php -$encryptedData = (new Crypt())->encryptByPassword($data, $password); - -// save data to database or another storage -saveData($encryptedData); -``` - -Decrypting it: - -```php -// obtain encrypted data from database or another storage -$encryptedData = getEncryptedData(); - -$data = (new Crypt())->decryptByPassword($encryptedData, $password); -``` - -### Encryption and decryption by key - -Encrypting data: - -```php -$encryptedData = (new Crypt())->encryptByKey($data, $key); - -// save data to database or another storage -saveData($encryptedData); -``` - -Decrypting it: - -```php -// obtain encrypted data from database or another storage -$encryptedData = getEncryptedData(); - -$data = (new Crypt())->decryptByKey($encryptedData, $key); -``` - ### Data tampering prevention MAC signing could be used in order to prevent data tampering. The `$key` should be present at both sending and receiving @@ -167,6 +128,476 @@ There is a special function in PHP that compares strings in a constant time: hash_equals($expected, $actual); ``` +## Crypto module + +The `Crypto` module provides a modern, authenticated encryption layer based on `AEAD` ciphers. It provides three built‑in cryptors: + +- `KdfCryptor` – derives a fresh `DEK` per message using a `KDF`. +- `EnvelopeCryptor` – wraps a random `DEK` with a `KEK` derived from the secret. +- `VersionedCryptor` – adds a version prefix to delegate to different cryptors. + +### Basic usage example + +All cryptors implement the same `CryptorInterface`. Inject the desired cryptor and use it as follows: + +```php +//via container +use Yiisoft\Security\Crypto\CryptorInterface; + +$cryptor = $container->get(CryptorInterface::class); + +$secret = 'high-entropy-key-or-password'; +$context = 'application-specific-context'; + +$encrypted = $cryptor->encrypt('secret data', $secret, $context); +$data = $cryptor->decrypt($encrypted, $secret, $context); +``` + +### `KdfCryptor` + +`KDF`‑based encryption (single key derived per message, no key wrapping). +A fresh Data Encryption Key (`DEK`) is derived from the secret and the provided context using the configured `KDF`. +If the configured `KDF` requires a salt, a random salt is generated for each message and prepended to the ciphertext. + +Output structure: +``` +kdfSalt (optional) || nonce || encryptedData (with tag) + +``` + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\KdfCryptor; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\Kdf\KdfPasswordArgon2; +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +// For high‑entropy keys +$kdf = new KdfKey(); +// Or for user‑supplied passwords +$kdf = new KdfPasswordArgon2(); + +$cipher = new SodiumAeadCipher(); +$cryptor = new KdfCryptor($kdf, $cipher); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\DI\Reference; +use Yiisoft\Security\Crypto\KdfCryptor; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +KdfCryptor::class => [ + '__construct()' => [ + 'kdf' => Reference::to(KdfKey::class), // replace with KdfPasswordArgon2::class for passwords + 'cipher' => Reference::to(SodiumAeadCipher::class), + ], +], +``` + + +### `EnvelopeCryptor` + +Envelope encryption (key wrapping) using a `KDF` to derive a Key Encryption Key (`KEK`) +and a random Data Encryption Key (`DEK`). The `DEK` is wrapped with the `KEK` and stored +alongside the ciphertext. The `DEK` is used to encrypt the actual data. + +The `DEK` wrap cipher can be specified separately (e.g., `OpenSSLWrapCipher`); if omitted, the data cipher is used for wrapping as well. + +Output structure: +``` +kdfSalt || dekNonce || wrappedDEK (with tag) || dataNonce || encryptedData (with tag) +``` + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\EnvelopeCryptor; +use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; +use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +$kdf = new KdfKey(); +$cipher = new OpenSSLAeadCipher(); + +// One cipher is used for both data encryption and DEK wrapping +$cryptor = new EnvelopeCryptor($kdf, $cipher); + +// Separate cipher is used to wrap the DEK +$kwCipher = new OpenSSLWrapCipher(); +$cryptor = new EnvelopeCryptor($kdf, $cipher, $kwCipher); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\DI\Reference; +use Yiisoft\Security\Crypto\EnvelopeCryptor; +use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; +use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +EnvelopeCryptor::class => [ + '__construct()' => [ + 'kdf' => Reference::to(KdfKey::class), + 'cipher' => Reference::to(OpenSSLAeadCipher::class), + 'kwCipher' => Reference::to(OpenSSLWrapCipher::class), // optional, if separate cipher is used to wrap the DEK + ], +], +``` + + +### `VersionedCryptor` + +Wraps multiple cryptors and adds a fixed‑length version prefix to every ciphertext. + +Output structure: +``` +version (fixed length) || encrypted payload from underlying cryptor +``` + +Runtime configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\VersionedCryptor; + +// Assume $kdfCryptor and $envelopeCryptor are already instantiated +$cryptor = new VersionedCryptor( + cryptors: [ + chr(0x01) => $kdfCryptor, + chr(0x96) => $envelopeCryptor, + ], + currentVersion: chr(0x01), +); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\DI\Reference; +use Yiisoft\DI\ReferencesArray; +use Yiisoft\Security\Crypto\VersionedCryptor; +use Yiisoft\Security\Crypto\KdfCryptor; +use Yiisoft\Security\Crypto\EnvelopeCryptor; + +VersionedCryptor::class => [ + '__construct()' => [ + 'cryptors' => ReferencesArray::from([ + chr(0x01) => Reference::to(KdfCryptor::class), + chr(0x96) => Reference::to(EnvelopeCryptor::class}, + ]), + 'currentVersion' => chr(0x01), + // 'versionSize' => 1, // optional, auto-detected from currentVersion + ], +], +``` + + +### Configuring KDF + +The `KDF` is responsible for deriving cryptographic keys from the provided secret. Choose the appropriate `KDF` based on the type of secret. + +#### `KdfKey` - for high‑entropy keys + +Directly applies `HKDF` (RFC 5869) to the input secret. Suitable when the secret is already a strong random key (32 bytes or more). + +This implementation satisfies the **KDF Security** requirements (resistance to key extraction and key expansion attacks) as defined in the `HKDF` specification. + +`KdfKey` supports static salt for domain separation, ensuring that keys derived for different contexts remain distinct even when the same secret is used. It also provides dynamic salt for per‑message randomness, which is enabled by default. When dynamic salt is disabled, the caller must supply a unique context for each derivation to prevent key reuse. + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +// With dynamic salt (default) – a random salt will be used per message +$kdf = new KdfKey( + hashAlgo: 'sha512', + hashStaticSalt: $staticSalt, // domain separation +); + +// Without dynamic salt – ensure $context is unique per call +$kdf = new KdfKey( + hashAlgo: 'sha512', + hashStaticSalt: $staticSalt, // domain separation + saltSize: 0, +); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +KdfKey::class => [ + '__construct()' => [ + 'hashAlgo' => 'sha512', + 'hashStaticSalt' => 'your-static-salt-binary-string', // must match hash length + 'saltSize' => 0, // set to 0 to disable dynamic salt + ], +], +``` + + +#### KdfPasswordArgon2 - for low‑entropy passwords + +Uses `Argon2` (via `libsodium`) to hash the password, then `HKDF` to expand. This is the recommended `KDF` for passwords when `Sodium` is available. + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\Kdf\KdfPasswordArgon2; + +$kdf = new KdfPasswordArgon2( + opslimit: SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE, + memlimit: SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE, + hashAlgo: 'sha512', // any hash_hmac_algos() +); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\Kdf\KdfPasswordArgon2; + +KdfPasswordArgon2::class => [ + '__construct()' => [ + 'opslimit' => SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE, + 'memlimit' => SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE, + 'hashAlgo' => 'sha512', // any hash_hmac_algos() + ], +], +``` + + +#### KdfPasswordPbkdf2 - for low‑entropy passwords + +Applies `PBKDF2` (with `SHA‑256`) to the password and salt, then `HKDF` to expand to the final key length. +Follow `OWASP` recommendations for iteration counts. + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\Kdf\KdfPasswordPbkdf2; + +$kdf = new KdfPasswordPbkdf2(iterations: 700_000, hashAlgo: 'sha512'); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\Kdf\KdfPasswordPbkdf2; + +KdfPasswordPbkdf2::class => [ + '__construct()' => [ + 'iterations' => 700_000, + 'hashAlgo' => 'sha512', // any hash_hmac_algos() + ], +], +``` + +### Configuring ciphers + +The module provides two backends: `OpenSSL` and `Sodium` (`libsodium`). + +#### OpenSSLAeadCipher + +Uses `OpenSSL`'s `AEAD` ciphers. Supports the following algorithms: + +- `AES-128-GCM` +- `AES-192-GCM` +- `AES-256-GCM` +- `CHACHA20-POLY1305` (`IETF` variant, 12‑byte nonce) - **default** + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; + +// Using the default algorithm (`CHACHA20-POLY1305`) +$cipher = new OpenSSLAeadCipher(); + +// Explicitly specify an algorithm +$cipher = new OpenSSLAeadCipher(cipher: 'AES-256-GCM'); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; + +OpenSSLAeadCipher::class => [ + '__construct()' => [ + 'cipher' => 'AES-256-GCM', + ], +], +``` + +#### SodiumAeadCipher + +Uses `libsodium`'s high‑performance `AEAD` ciphers. Supports the following algorithms: + +- `AES-256-GCM` – requires hardware `AES‑NI` support. +- `CHACHA20-POLY1305-IETF` - **default** +- `XCHACHA20-POLY1305-IETF` + +Note: `AES‑256‑GCM` with `Sodium` requires CPU support for AES instructions (`AES‑NI`). + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; + +// Using the default algorithm (`CHACHA20-POLY1305-IETF`) +$cipher = new SodiumAeadCipher(); + +// Explicitly specify an algorithm +$cipher = new SodiumAeadCipher(cipher: 'AES-256-GCM'); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; + +SodiumAeadCipher::class => [ + '__construct()' => [ + 'cipher' => 'AES-256-GCM', + ], +], +``` + +#### OpenSSLWrapCipher + +A dedicated cipher for key wrapping (RFC 5649 `AES‑KW`). This cipher should only be used inside `EnvelopeCryptor` for wrapping `DEKs`, not for general‑purpose encryption. +Allowed algorithms: + +- `AES-128-WRAP` +- `AES-192-WRAP` +- `AES-256-WRAP` - **default** + +Runtime configuration: +```php +use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; + +// Using the default algorithm ('AES-256-WRAP') +$cipher = new OpenSSLWrapCipher(); + +// Explicitly specify an algorithm +$cipher = new OpenSSLWrapCipher(cipher: 'AES-128-WRAP'); +``` + +Yii DI configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; + +OpenSSLWrapCipher::class => [ + '__construct()' => [ + 'cipher' => 'AES-128-WRAP', + ], +], +``` + + +### Examples + +#### User data encryption + +Use this when each entity (user, record, document) has a natural unique identifier. The context includes that identifier, so no dynamic salt is needed. + +```php +use Yiisoft\Security\Crypto\EnvelopeCryptor; +use Yiisoft\Security\Crypto\KdfCryptor; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +// static salt for domain separation +$salt = getenv('USER_ENCRYPTION_SALT'); // must be exactly 32 bytes for SHA‑256 +$kdf = new KdfKey( + hashStaticSalt: $salt, + saltSize: 0, // disabled – rely on unique context +); +$cipher = new SodiumAeadCipher('AES-256-GCM'); +$cryptor = new KdfCryptor($kdf, $cipher); // or EnvelopeCryptor + +$userId = 12345; +// Unique context per user +$context = 'user_data_' . $userId; + +$secret = getenv('MASTER_ENCRYPTION_KEY'); + +$encrypted = $cryptor->encrypt('sensitive user information', $secret, $context); +$decrypted = $cryptor->decrypt($encrypted, $secret, $context); +``` + +#### Static context encryption + +Use this when data does not have a natural unique identifier. The dynamic salt provides per‑message randomness. + +```php +use Yiisoft\Security\Crypto\EnvelopeCryptor; +use Yiisoft\Security\Crypto\KdfCryptor; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\Kdf\KdfKey; + +// static salt for domain separation, dynamic salt enabled (default 32 bytes) +$salt = getenv('USER_ENCRYPTION_SALT'); // must be exactly 32 bytes for SHA‑256 +$kdf = new KdfKey( + hashStaticSalt: $salt, +); +$cipher = new SodiumAeadCipher('AES-256-GCM'); +$cryptor = new KdfCryptor($kdf, $cipher); // or EnvelopeCryptor + +$context = 'app_config_v1'; +$secret = getenv('MASTER_ENCRYPTION_KEY'); + +$encrypted = $cryptor->encrypt('sensitive application configuration', $secret, $context); +$decrypted = $cryptor->decrypt($encrypted, $secret, $context); +``` + + +## Legacy encryption (`Crypt`) + +Note: This is the legacy encryption component based on `CBC` mode + `HMAC`. +For new projects, prefer the AEAD‑based cryptors (`AES‑GCM`, `ChaCha20‑Poly1305`) which provide authenticated encryption in a single step and are less error‑prone. + +### Encryption and decryption by password + +Encrypting data: + +```php +$encryptedData = (new Crypt())->encryptByPassword($data, $password); + +// save data to database or another storage +saveData($encryptedData); +``` + +Decrypting it: + +```php +// obtain encrypted data from database or another storage +$encryptedData = getEncryptedData(); + +$data = (new Crypt())->decryptByPassword($encryptedData, $password); +``` + +### Encryption and decryption by key + +Encrypting data: + +```php +$encryptedData = (new Crypt())->encryptByKey($data, $key); + +// save data to database or another storage +saveData($encryptedData); +``` + +Decrypting it: + +```php +// obtain encrypted data from database or another storage +$encryptedData = getEncryptedData(); + +$data = (new Crypt())->decryptByKey($encryptedData, $key); +``` + ## Documentation - [Internals](docs/internals.md) diff --git a/composer-require-checker.json b/composer-require-checker.json index 738a356..adc21fe 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -6,6 +6,7 @@ "SPL", "random", "standard", - "openssl" + "openssl", + "sodium" ] } diff --git a/composer.json b/composer.json index a3605bb..21e271c 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,8 @@ "yiisoft/strings": "^2.0" }, "suggest": { - "ext-openssl": "Required to use Crypt class" + "ext-openssl": "Required for OpenSSL based ciphers", + "ext-sodium": "Required for Sodium based ciphers" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.9.1", diff --git a/docs/internals.md b/docs/internals.md index 0a0a1cd..3891461 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -34,6 +34,12 @@ use either newest or any specific version of PHP: ./vendor/bin/rector ``` +Use [PHP-CS-Fixer](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer) to fix your code to follow the standards. + +```shell +./vendor/bin/php-cs-fixer fix +``` + ## Dependencies This package uses [composer-require-checker](https://github.com/maglnet/ComposerRequireChecker) to check if all diff --git a/psalm.xml b/psalm.xml index 679cd9b..12e2e75 100644 --- a/psalm.xml +++ b/psalm.xml @@ -3,6 +3,7 @@ errorLevel="1" findUnusedBaselineEntry="true" findUnusedCode="false" + ensureOverrideAttribute="false" strictBinaryOperands="false" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" @@ -14,8 +15,4 @@ - - - - diff --git a/src/Crypto/Cipher/OpenSSLAeadCipher.php b/src/Crypto/Cipher/OpenSSLAeadCipher.php new file mode 100644 index 0000000..a953cb1 --- /dev/null +++ b/src/Crypto/Cipher/OpenSSLAeadCipher.php @@ -0,0 +1,161 @@ + + */ + private const ALLOWED_CIPHERS = [ + 'AES-128-GCM' => [16, 12], + 'AES-192-GCM' => [24, 12], + 'AES-256-GCM' => [32, 12], + 'CHACHA20-POLY1305' => [32, 12], // IETF variant + ]; + + /** + * @psalm-var int<1, max> + */ + private int $keySize; + + /** + * @psalm-var int<1, max> + */ + private int $nonceSize; + + /** + * @param string $cipher Cipher method (must be one of ALLOWED_CIPHERS keys). + * + * @throws RuntimeException If OpenSSL extension is not loaded or the cipher is not allowed. + */ + public function __construct( + private string $cipher = 'CHACHA20-POLY1305', + ) { + if (!extension_loaded('openssl')) { + throw new RuntimeException('Encryption requires the OpenSSL PHP extension.'); + } + if (!array_key_exists($cipher, self::ALLOWED_CIPHERS)) { + throw new RuntimeException("'{$cipher}' is not an allowed cipher."); + } + + [$this->keySize, $this->nonceSize] = self::ALLOWED_CIPHERS[$this->cipher]; + } + + /** + * {@inheritdoc} + * + * @throws EncryptionException If key or nonce length is invalid, or OpenSSL encryption fails. + */ + public function encrypt( + string $data, + #[SensitiveParameter] + string $key, + string $nonce = '', + string $aad = '', + ): string { + if (StringHelper::byteLength($key) !== $this->keySize) { + throw new EncryptionException("Key must be {$this->keySize} bytes long."); + } + if (StringHelper::byteLength($nonce) !== $this->nonceSize) { + throw new EncryptionException("Nonce must be {$this->nonceSize} bytes long."); + } + + $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $nonce, $tag, $aad, self::TAG_SIZE); + + if ($encrypted === false) { + /** @psalm-suppress RiskyTruthyFalsyComparison */ + $error = openssl_error_string() ?: 'Unknown error'; + throw new EncryptionException('OpenSSL failure on encryption: ' . $error); + } + + return $encrypted . $tag; + } + + /** + * {@inheritdoc} + * + * @throws EncryptionException If key or nonce length is invalid, or OpenSSL decryption fails (including tag mismatch). + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $key, + string $nonce = '', + string $aad = '', + ): string { + if (StringHelper::byteLength($key) !== $this->keySize) { + throw new EncryptionException("Key must be {$this->keySize} bytes long."); + } + if (StringHelper::byteLength($nonce) !== $this->nonceSize) { + throw new EncryptionException("Nonce must be {$this->nonceSize} bytes long."); + } + + $tag = StringHelper::byteSubstring($data, -self::TAG_SIZE); + $ciphertext = StringHelper::byteSubstring($data, 0, -self::TAG_SIZE); + + $decrypted = openssl_decrypt($ciphertext, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $nonce, $tag, $aad); + + if ($decrypted === false) { + /** @psalm-suppress RiskyTruthyFalsyComparison */ + $error = openssl_error_string() ?: 'Unknown error'; + throw new EncryptionException('OpenSSL failure on decryption: ' . $error); + } + + return $decrypted; + } + + public function getKeySize(): int + { + return $this->keySize; + } + + /** + * {@inheritdoc} + * + * @psalm-return int<1, max> + */ + public function getNonceSize(): int + { + return $this->nonceSize; + } + + /** + * {@inheritdoc} + * + * @psalm-return 16 + */ + public function getOverheadSize(): int + { + return self::TAG_SIZE; + } +} diff --git a/src/Crypto/Cipher/OpenSSLWrapCipher.php b/src/Crypto/Cipher/OpenSSLWrapCipher.php new file mode 100644 index 0000000..a39e1fc --- /dev/null +++ b/src/Crypto/Cipher/OpenSSLWrapCipher.php @@ -0,0 +1,172 @@ + + */ + private const ALLOWED_CIPHERS = [ + 'AES-128-WRAP' => [16, 8], + 'AES-192-WRAP' => [24, 8], + 'AES-256-WRAP' => [32, 8], + ]; + + /** + * @psalm-var int<1, max> + */ + private int $keySize; + + /** + * Dummy nonce (all zeros) to prevent OpenSSL from issuing warnings. + * + * The `openssl_encrypt()` and `openssl_decrypt()` functions require an IV parameter, + * even for key wrap algorithms that don't use one internally. Passing an empty string + * would trigger a warning. This dummy nonce of the appropriate size satisfies the + * function signature without affecting the key wrap operation, as the algorithm ignores it. + */ + private string $dummyNonce; + + /** + * @param string $cipher Cipher method (must be one of ALLOWED_CIPHERS keys). + * + * @throws RuntimeException If OpenSSL extension is not loaded or the cipher is not allowed. + */ + public function __construct( + private string $cipher = 'AES-256-WRAP', + ) { + if (!extension_loaded('openssl')) { + throw new RuntimeException('Encryption requires the OpenSSL PHP extension.'); + } + if (!array_key_exists($cipher, self::ALLOWED_CIPHERS)) { + throw new RuntimeException("'{$cipher}' is not an allowed cipher."); + } + + [$this->keySize, $nonceSize] = self::ALLOWED_CIPHERS[$this->cipher]; + $this->dummyNonce = str_repeat("\0", $nonceSize); + } + + /** + * {@inheritdoc} + * + * Data must be a multiple of 8 bytes. + * Key wrap does not use a nonce or AAD; both parameters are ignored. + * + * @throws EncryptionException If key length is invalid or OpenSSL encryption fails. + */ + public function encrypt( + string $data, + #[SensitiveParameter] + string $key, + string $nonce = '', + string $aad = '', + ): string { + if (StringHelper::byteLength($key) !== $this->keySize) { + throw new EncryptionException("Key must be {$this->keySize} bytes long."); + } + + $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $this->dummyNonce); + + if ($encrypted === false) { + /** @psalm-suppress RiskyTruthyFalsyComparison */ + $error = openssl_error_string() ?: 'Unknown error'; + throw new EncryptionException('OpenSSL failure on encryption: ' . $error); + } + + return $encrypted; + } + + /** + * {@inheritdoc} + * + * Data must be a multiple of 8 bytes. + * Key wrap does not use a nonce or AAD; both parameters are ignored. + * + * @throws EncryptionException If key length is invalid or OpenSSL decryption fails. + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $key, + string $nonce = '', + string $aad = '', + ): string { + if (StringHelper::byteLength($key) !== $this->keySize) { + throw new EncryptionException("Key must be {$this->keySize} bytes long."); + } + + $decrypted = openssl_decrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $this->dummyNonce); + + if ($decrypted === false) { + /** @psalm-suppress RiskyTruthyFalsyComparison */ + $error = openssl_error_string() ?: 'Unknown error'; + throw new EncryptionException('OpenSSL failure on decryption: ' . $error); + } + + return $decrypted; + } + + public function getKeySize(): int + { + return $this->keySize; + } + + /** + * {@inheritdoc} + * + * Key wrap does not use a nonce, so this method returns 0. + * + * @psalm-return 0 + */ + public function getNonceSize(): int + { + return 0; + } + + /** + * {@inheritdoc} + * + * @psalm-return 8 + */ + public function getOverheadSize(): int + { + return self::TAG_SIZE; + } +} diff --git a/src/Crypto/Cipher/SodiumAeadCipher.php b/src/Crypto/Cipher/SodiumAeadCipher.php new file mode 100644 index 0000000..aa1d6c1 --- /dev/null +++ b/src/Crypto/Cipher/SodiumAeadCipher.php @@ -0,0 +1,163 @@ + + */ + private const ALLOWED_CIPHERS = [ + 'AES-256-GCM' => [32, 12], + 'CHACHA20-POLY1305-IETF' => [32, 12], + 'XCHACHA20-POLY1305-IETF' => [32, 24], + ]; + + /** + * @psalm-var int<1, max> + */ + private int $keySize; + + /** + * @psalm-var int<1, max> + */ + private int $nonceSize; + + /** + * @param string $cipher The cipher to use (must be one of ALLOWED_CIPHERS keys). + * + * @throws RuntimeException If sodium extension is missing, cipher not allowed, or AES-256-GCM without hardware support. + */ + public function __construct( + private string $cipher = 'CHACHA20-POLY1305-IETF', + ) { + if (!extension_loaded('sodium')) { + throw new RuntimeException('Encryption requires the Sodium PHP extension.'); + } + if (!array_key_exists($cipher, self::ALLOWED_CIPHERS)) { + throw new RuntimeException("'{$cipher}' is not an allowed cipher."); + } + if ($cipher === 'AES-256-GCM' && !sodium_crypto_aead_aes256gcm_is_available()) { + throw new RuntimeException("'{$cipher}' requires hardware that supports hardware-accelerated AES."); + } + + [$this->keySize, $this->nonceSize] = self::ALLOWED_CIPHERS[$this->cipher]; + } + + /** + * {@inheritdoc} + * + * The key and nonce must match the required sizes for the selected cipher. + * + * @throws EncryptionException If encryption fails (e.g., invalid key/nonce length or Sodium error). + */ + public function encrypt( + string $data, + #[SensitiveParameter] + string $key, + string $nonce = '', + string $aad = '', + ): string { + try { + $encrypted = match ($this->cipher) { + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, $aad, $nonce, $key), + 'CHACHA20-POLY1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, $aad, $nonce, $key), + 'XCHACHA20-POLY1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, $aad, $nonce, $key), + }; + } catch (SodiumException $e) { + throw new EncryptionException($e->getMessage()); + } + + return $encrypted; + } + + /** + * {@inheritdoc} + * + * The key and nonce must match the values used during encryption. + * + * @throws EncryptionException If decryption fails (e.g., invalid key/nonce, tag mismatch, or Sodium error). + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $key, + string $nonce = '', + string $aad = '', + ): string { + try { + $decrypted = match ($this->cipher) { + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, $aad, $nonce, $key), + 'CHACHA20-POLY1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, $aad, $nonce, $key), + 'XCHACHA20-POLY1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, $aad, $nonce, $key), + }; + } catch (SodiumException $e) { + throw new EncryptionException($e->getMessage()); + } + + if ($decrypted === false) { + throw new EncryptionException('Sodium failure on decryption'); + } + + return $decrypted; + } + + public function getKeySize(): int + { + return $this->keySize; + } + + /** + * {@inheritdoc} + * + * @psalm-return int<1, max> + */ + public function getNonceSize(): int + { + return $this->nonceSize; + } + + /** + * {@inheritdoc} + * + * @psalm-return 16 + */ + public function getOverheadSize(): int + { + return self::TAG_SIZE; + } +} diff --git a/src/Crypto/CipherInterface.php b/src/Crypto/CipherInterface.php new file mode 100644 index 0000000..fe053d7 --- /dev/null +++ b/src/Crypto/CipherInterface.php @@ -0,0 +1,74 @@ + + */ + public function getKeySize(): int; + + /** + * @return int Nonce size in bytes (may be 0 if the cipher does not use a nonce). + * + * @psalm-return int<0, max> + */ + public function getNonceSize(): int; + + /** + * @return int Overhead size in bytes. + * + * @psalm-return int<0, max> + */ + public function getOverheadSize(): int; +} diff --git a/src/Crypto/CryptorInterface.php b/src/Crypto/CryptorInterface.php new file mode 100644 index 0000000..ac0a92a --- /dev/null +++ b/src/Crypto/CryptorInterface.php @@ -0,0 +1,49 @@ + + */ + private int $kekSize; + + /** + * @psalm-var int<1, max> + */ + private int $dekSize; + + /** + * @psalm-var int<0, max> + */ + private int $dekNonceSize; + + /** + * @psalm-var int<0, max> + */ + private int $dataNonceSize; + + /** + * @psalm-var int<0, max> + */ + private int $saltSize; + + private int $saltDekNonceLength; + private int $wrapDekLength; + private int $saltDekNonceWrapDekLength; + private int $headerLength; + + /** + * @param KdfInterface $kdf Key derivation function. Used to derive KEK from secret. + * @param CipherInterface $cipher Cipher used to encrypt the actual data. + * @param CipherInterface|null $kwCipher Cipher used to wrap the DEK. If not provided (or `null`), + * the same cipher as `$cipher` is used for both data encryption and DEK wrapping. + */ + public function __construct( + private KdfInterface $kdf, + private CipherInterface $cipher, + ?CipherInterface $kwCipher = null, + ) { + $this->kwCipher = $kwCipher ?? $this->cipher; + + $this->kekSize = $this->kwCipher->getKeySize(); + $this->dekSize = $this->cipher->getKeySize(); + + $this->dekNonceSize = $this->kwCipher->getNonceSize(); + $dekTagSize = $this->kwCipher->getOverheadSize(); + $this->dataNonceSize = $this->cipher->getNonceSize(); + $this->saltSize = $this->kdf->getSaltSize(); + + $this->saltDekNonceLength = $this->saltSize + $this->dekNonceSize; + $this->wrapDekLength = $this->dekSize + $dekTagSize; + $this->saltDekNonceWrapDekLength = $this->saltDekNonceLength + $this->wrapDekLength; + $this->headerLength = $this->saltDekNonceWrapDekLength + $this->dataNonceSize; + } + + /** + * {@inheritdoc} + * + * Structure: kdfSalt (saltSize) || + * dekNonce (kwCipher nonce size) || + * wrappedDEK (dekSize + kwCipher tag size) || + * dataNonce (cipher nonce size) || + * encryptedData (variable + cipher tag size) + */ + public function encrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '', + ): string { + $kdfSalt = $this->saltSize ? random_bytes($this->saltSize) : ''; + $dek = random_bytes($this->dekSize); + $dekNonce = $this->dekNonceSize ? random_bytes($this->dekNonceSize) : ''; + $dataNonce = $this->dataNonceSize ? random_bytes($this->dataNonceSize) : ''; + + $kek = $this->kdf->derive($secret, $this->kekSize, $context, $kdfSalt); + $dekWrapped = $this->kwCipher->encrypt($dek, $kek, $dekNonce); + $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); + + // kdfSalt || dekNonce || cipherdek || tag || dataNonce || ciphertext || tag + return $kdfSalt . $dekNonce . $dekWrapped . $dataNonce . $dataEncrypted; + } + + /** + * {@inheritdoc} + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '', + ): string { + if (StringHelper::byteLength($data) < $this->headerLength) { + throw new EncryptionException('Encrypted data is too short.'); + } + + $kdfSalt = $this->saltSize ? StringHelper::byteSubstring($data, 0, $this->saltSize) : ''; + $dekNonce = $this->dekNonceSize ? StringHelper::byteSubstring($data, $this->saltSize, $this->dekNonceSize) : ''; + $dekWrapped = StringHelper::byteSubstring($data, $this->saltDekNonceLength, $this->wrapDekLength); + $dataNonce = $this->dataNonceSize ? StringHelper::byteSubstring($data, $this->saltDekNonceWrapDekLength, $this->dataNonceSize) : ''; + $dataEncrypted = StringHelper::byteSubstring($data, $this->headerLength); + + $kek = $this->kdf->derive($secret, $this->kekSize, $context, $kdfSalt); + $dek = $this->kwCipher->decrypt($dekWrapped, $kek, $dekNonce); + + return $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); + } +} diff --git a/src/Crypto/Kdf/KdfKey.php b/src/Crypto/Kdf/KdfKey.php new file mode 100644 index 0000000..9fdfd17 --- /dev/null +++ b/src/Crypto/Kdf/KdfKey.php @@ -0,0 +1,117 @@ + $saltSize + * + * @throws RuntimeException + */ + public function __construct( + private string $hashAlgo = 'sha256', + string|Stringable $hashStaticSalt = '', + private int $saltSize = 32, + ) { + if (!in_array($hashAlgo, hash_hmac_algos())) { + throw new RuntimeException("'{$hashAlgo}' is not an allowed algorithm."); + } + + $this->hashStaticSalt = (string) $hashStaticSalt; + + if ($this->hashStaticSalt !== '' + && ($staticSaltSize = StringHelper::byteLength(hash($this->hashAlgo, '', true))) !== StringHelper::byteLength($this->hashStaticSalt) + ) { + throw new RuntimeException("Static salt must be {$staticSaltSize} bytes long."); + } + } + + /** + * Derives a key using HKDF (RFC 5869). + * + * The HKDF `info` parameter is built as `$context . $salt`. This allows the application to provide + * a fixed `$context` while using a random `$salt` as a per‑operation unique part of the info. + * This is useful when the application only supplies a static context + * but still needs domain separation and randomness in the derivation. + * + * @param string $secret High-entropy secret key (must be at least as long as the hash output). + * @param int $keySize Desired key length in bytes. + * @param string $context Application-specific context (used as prefix of HKDF info). + * @param string $salt Dynamic salt value. Mmust be exactly {@see getSaltSize()} bytes if salt size > 0, + * otherwise empty. Acts as a random suffix of the HKDF info. If salt size is 0, ensure + * the `$context` is random or unique per call. + * + * @psalm-mutation-free + * + * @throws EncryptionException + * + * @return string Derived key (raw binary). + */ + public function derive( + #[SensitiveParameter] + string $secret, + int $keySize, + string $context, + string $salt = '', + ): string { + /** @psalm-suppress ImpureMethodCall */ + if (StringHelper::byteLength($salt) !== $this->saltSize) { + throw new EncryptionException("Salt must be {$this->saltSize} bytes long."); + } + + try { + return hash_hkdf($this->hashAlgo, $secret, $keySize, $context . $salt, $this->hashStaticSalt); + } catch (ValueError $e) { + throw new EncryptionException($e->getMessage()); + } + } + + /** + * Returns the required dynamic salt size in bytes. + * + * @return int Salt size (0 if no salt is used). + * + * @psalm-return int<0, max> + */ + public function getSaltSize(): int + { + return $this->saltSize; + } +} diff --git a/src/Crypto/Kdf/KdfPasswordArgon2.php b/src/Crypto/Kdf/KdfPasswordArgon2.php new file mode 100644 index 0000000..b213ecd --- /dev/null +++ b/src/Crypto/Kdf/KdfPasswordArgon2.php @@ -0,0 +1,105 @@ +kdfKey = new KdfKey( + hashAlgo: $hashAlgo, + hashStaticSalt: $hashStaticSalt, + saltSize: 0, + ); + } + + /** + * Derives a key from a password using Argon2 + HKDF. + * + * Steps: + * 1. Argon2id hashes the password and salt into a high-entropy intermediate key (32 bytes). + * 2. HKDF expands the intermediate key to the requested size using the context as info. + * + * @param string $secret The password (low-entropy secret). Sensitive. + * @param int $keySize Desired key length in bytes. + * @param string $context Application-specific context (used as HKDF info). + * @param string $salt Salt value for Argon2 (must be random and unique, exactly {@see getSaltSize()} bytes). + * + * @throws EncryptionException If hashing or key expansion fails. + * + * @psalm-mutation-free + * + * @return string Derived key (raw binary). + */ + public function derive( + #[SensitiveParameter] + string $secret, + int $keySize, + string $context, + string $salt = '', + ): string { + try { + $key = sodium_crypto_pwhash(self::PW_HASH_LENGTH, $secret, $salt, $this->opslimit, $this->memlimit, $this->algo); + + return $this->kdfKey->derive($key, $keySize, $context); + } catch (ValueError|SodiumException $e) { + throw new EncryptionException($e->getMessage()); + } + } + + /** + * Returns the salt size required by Argon2. + * + * @return int Fixed salt size. + * + * @psalm-return 16 + */ + public function getSaltSize(): int + { + return self::PW_SALT_SIZE; + } +} diff --git a/src/Crypto/Kdf/KdfPasswordPbkdf2.php b/src/Crypto/Kdf/KdfPasswordPbkdf2.php new file mode 100644 index 0000000..956523a --- /dev/null +++ b/src/Crypto/Kdf/KdfPasswordPbkdf2.php @@ -0,0 +1,104 @@ + 0). See OWASP recommendations. + * @param string $hashAlgo Hash algorithm for the HKDF expansion step. Must be one of {@see hash_hmac_algos()}. + * @param string|Stringable $hashStaticSalt Optional static salt for the HKDF step {@see KdfKey::$hashStaticSalt}. + * + * @throws RuntimeException If iteration count is invalid or the inner KDF construction fails. + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + */ + public function __construct( + private int $iterations = 600_000, + string $hashAlgo = 'sha256', + string|Stringable $hashStaticSalt = '', + ) { + if ($iterations <= 0) { + throw new RuntimeException("Iterations must be greater than 0, but {$iterations} provided."); + } + + $this->kdfKey = new KdfKey( + hashAlgo: $hashAlgo, + hashStaticSalt: $hashStaticSalt, + saltSize: 0, + ); + } + + /** + * Derives a key from a password using PBKDF2 + HKDF. + * + * Steps: + * 1. PBKDF2 expands the password and salt into an intermediate key (using SHA-256, raw output). + * 2. HKDF derives the final key of requested size using the context as info. + * + * @param string $secret The password (low-entropy secret). Sensitive. + * @param int $keySize Desired key length in bytes. + * @param string $context Application-specific context (used as HKDF info). + * @param string $salt Salt value (must be random and unique, exactly {@see getSaltSize()} bytes). + * + * @throws EncryptionException If PBKDF2 or HKDF fails, or if salt length is invalid. + * + * @psalm-mutation-free + * + * @return string Derived key (raw binary). + */ + public function derive( + #[SensitiveParameter] + string $secret, + int $keySize, + string $context, + string $salt = '', + ): string { + /** @psalm-suppress ImpureMethodCall */ + if (StringHelper::byteLength($salt) !== self::PW_SALT_SIZE) { + throw new EncryptionException(sprintf('Salt must be %d bytes long.', self::PW_SALT_SIZE)); + } + + try { + $key = hash_pbkdf2(self::PW_HASH_ALGO, $secret, $salt, $this->iterations, 0, true); + + return $this->kdfKey->derive($key, $keySize, $context); + } catch (ValueError $e) { + throw new EncryptionException($e->getMessage()); + } + } + + /** + * Returns the required salt size in bytes. + * + * @return int Fixed salt size. + * + * @psalm-return 32 + */ + public function getSaltSize(): int + { + return self::PW_SALT_SIZE; + } +} diff --git a/src/Crypto/KdfCryptor.php b/src/Crypto/KdfCryptor.php new file mode 100644 index 0000000..b189a13 --- /dev/null +++ b/src/Crypto/KdfCryptor.php @@ -0,0 +1,95 @@ + + */ + private int $keySize; + + /** + * @psalm-var int<0, max> + */ + private int $nonceSize; + + /** + * @psalm-var int<0, max> + */ + private int $saltSize; + + private int $headerLength; + + /** + * @param KdfInterface $kdf Key derivation function. Used to derive DEK from secret. + * @param CipherInterface $cipher Low‑level cipher (must support AEAD). + */ + public function __construct( + private KdfInterface $kdf, + private CipherInterface $cipher, + ) { + $this->keySize = $this->cipher->getKeySize(); + $this->nonceSize = $this->cipher->getNonceSize(); + $this->saltSize = $this->kdf->getSaltSize(); + $this->headerLength = $this->saltSize + $this->nonceSize; + } + + /** + * {@inheritdoc} + * + * Structure: salt || nonce || ciphertext (with tag for AEAD ciphers) + */ + public function encrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '', + ): string { + $kdfSalt = $this->saltSize ? random_bytes($this->saltSize) : ''; + $dataNonce = $this->nonceSize ? random_bytes($this->nonceSize) : ''; + + $dek = $this->kdf->derive($secret, $this->keySize, $context, $kdfSalt); + $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); + + // kdfSalt || nonce || ciphertext || tag + return $kdfSalt . $dataNonce . $dataEncrypted; + } + + /** + * {@inheritdoc} + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '', + ): string { + if (StringHelper::byteLength($data) < $this->headerLength) { + throw new EncryptionException('Encrypted data is too short.'); + } + + $kdfSalt = $this->saltSize ? StringHelper::byteSubstring($data, 0, $this->saltSize) : ''; + $dataNonce = $this->nonceSize ? StringHelper::byteSubstring($data, $this->saltSize, $this->nonceSize) : ''; + $dataEncrypted = StringHelper::byteSubstring($data, $this->headerLength); + + $dek = $this->kdf->derive($secret, $this->keySize, $context, $kdfSalt); + + return $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); + } +} diff --git a/src/Crypto/KdfInterface.php b/src/Crypto/KdfInterface.php new file mode 100644 index 0000000..047eb76 --- /dev/null +++ b/src/Crypto/KdfInterface.php @@ -0,0 +1,41 @@ + + */ + public function getSaltSize(): int; +} diff --git a/src/Crypto/VersionedCryptor.php b/src/Crypto/VersionedCryptor.php new file mode 100644 index 0000000..86559e9 --- /dev/null +++ b/src/Crypto/VersionedCryptor.php @@ -0,0 +1,128 @@ + Storage for registered cryptors indexed by their version identifier. + */ + private array $cryptors; + + /** + * @psalm-var int<1, max> + */ + private int $versionSize; + + /** + * @param array $cryptors List of cryptors indexed by version string. + * @param string $currentVersion Version identifier used for new encryptions. + * @param int|null $versionSize Fixed byte length of the version prefix. When `null`, it is computed from `$currentVersion`. + * + * @psalm-param int<1, max>|null $versionSize + * + * @throws RuntimeException If validation fails or current version is not registered. + */ + public function __construct( + array $cryptors, + private string $currentVersion, + ?int $versionSize = null, + ) { + $versionSize ??= StringHelper::byteLength($this->currentVersion); + + if ($versionSize < 1) { + throw new RuntimeException('Version size must be greater than 0.'); + } + + $this->versionSize = $versionSize; + $this->cryptors = $this->validateAndNormalize($cryptors); + + if (!isset($this->cryptors[$this->currentVersion])) { + throw new RuntimeException(sprintf('Current version "0x%s" is not registered.', bin2hex($this->currentVersion))); + } + } + + /** + * {@inheritdoc} + * + * Structure: version (versionSize bytes) || encrypted payload from underlying cryptor + */ + public function encrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '', + ): string { + $payload = $this->cryptors[$this->currentVersion]->encrypt($data, $secret, $context); + + return $this->currentVersion . $payload; + } + + /** + * {@inheritdoc} + * + * @throws EncryptionException When decryption fails. + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '', + ): string { + if (StringHelper::byteLength($data) < $this->versionSize) { + throw new EncryptionException('Encrypted data is too short to contain a version identifier.'); + } + + $version = StringHelper::byteSubstring($data, 0, $this->versionSize); + $cryptor = $this->cryptors[$version] + ?? throw new EncryptionException(sprintf('Unsupported encrypted data version "0x%s"', bin2hex($version))); + + $payload = StringHelper::byteSubstring($data, $this->versionSize); + + return $cryptor->decrypt($payload, $secret, $context); + } + + /** + * Validates the input array, normalises keys to strings, + * and ensures each version identifier has exactly `$versionSize` bytes. + * + * @param array $cryptors Raw input mapping. + * + * @throws RuntimeException On validation error. + * + * @return array Normalised array. + */ + private function validateAndNormalize(array $cryptors): array + { + $normalized = []; + foreach ($cryptors as $version => $cryptor) { + $version = (string) $version; + + if (!$cryptor instanceof CryptorInterface) { + throw new RuntimeException('All cryptors must implement CryptorInterface.'); + } + + if (StringHelper::byteLength($version) !== $this->versionSize) { + throw new RuntimeException("Version identifier '$version' must be exactly {$this->versionSize} bytes."); + } + + $normalized[$version] = $cryptor; + } + + return $normalized; + } +} diff --git a/tests/Crypto/Cipher/AbstractCipherCase.php b/tests/Crypto/Cipher/AbstractCipherCase.php new file mode 100644 index 0000000..0a29c32 --- /dev/null +++ b/tests/Crypto/Cipher/AbstractCipherCase.php @@ -0,0 +1,192 @@ +createCipherInstance($cipher); + $plaintext = $this->getPlainText(); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + $this->assertNotSame($plaintext, $ciphertext); + + $decrypted = $cipherInstance->decrypt($ciphertext, $key, $nonce); + $this->assertSame($plaintext, $decrypted); + } + + /** + * @param string $cipher + * @param string $key encryption key hex string + * @param string $nonce encryption nonce hex string + * @param string $data plaintext data + * @param string $encrypted ciphertext hex string + */ + #[DataProvider('dataProviderEncrypted')] + public function testEncrypted(string $cipher, string $key, string $nonce, string $data, string $encrypted): void + { + $cipherInstance = $this->createCipherInstance($cipher); + + $key = hex2bin(preg_replace('{\s+}', '', $key)); + $nonce = hex2bin(preg_replace('{\s+}', '', $nonce)); + $encrypted = hex2bin(preg_replace('{\s+}', '', $encrypted)); + + $this->assertSame($encrypted, $cipherInstance->encrypt($data, $key, $nonce)); + $this->assertSame($data, $cipherInstance->decrypt($encrypted, $key, $nonce)); + } + + public function testInvalidCipherThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->createCipherInstance('Non-Existing-Cipher'); + } + + #[DataProvider('dataProviderCiphers')] + public function testEncryptWithKeyTooShortThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $plaintext = $this->getPlainText(); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + + $key = random_bytes($cipherInstance->getKeySize() - 1); // wrong key size + + $this->expectException(EncryptionException::class); + $cipherInstance->encrypt($plaintext, $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testEncryptWithKeyTooLongThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $plaintext = $this->getPlainText(); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + + $key = random_bytes($cipherInstance->getKeySize() + 1); // wrong key size + + $this->expectException(EncryptionException::class); + $cipherInstance->encrypt($plaintext, $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testEncryptWithEmptyKeyThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $plaintext = $this->getPlainText(); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + + $this->expectException(EncryptionException::class); + $cipherInstance->encrypt($plaintext, '', $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithKeyTooLongThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $key . 'X', $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithKeyTooShortThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, substr($key, 1), $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithEmptyKeyThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, '', $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithCiphertextCorruptedThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt(substr_replace($ciphertext, 'XXX', -3), $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithCiphertextTruncatedThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt(substr($ciphertext, 1), $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithWrongKeyThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $wrongKey = random_bytes($cipherInstance->getKeySize()); + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); + + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $wrongKey, $nonce); + } + + public function testGetSizes(): void + { + $cipher = $this->createCipherInstance(); + + $this->assertIsInt($cipher->getKeySize()); + $this->assertIsInt($cipher->getNonceSize()); + $this->assertIsInt($cipher->getOverheadSize()); + } + + abstract protected function createCipherInstance(?string $cipher = null): CipherInterface; + + abstract protected static function getPlainText(): string; +} diff --git a/tests/Crypto/Cipher/CipherWithAeadTrait.php b/tests/Crypto/Cipher/CipherWithAeadTrait.php new file mode 100644 index 0000000..b4785e9 --- /dev/null +++ b/tests/Crypto/Cipher/CipherWithAeadTrait.php @@ -0,0 +1,82 @@ +createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext . 'X', $key, $nonce); // wrong tag + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithTagTooShortThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt(substr($ciphertext, 0, -1), $key, $nonce); // wrong tag + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithTagRemovedThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + $tagSize = $cipherInstance->getOverheadSize(); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt(substr($ciphertext, 0, -$tagSize), $key, $nonce); // remove auth tag + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithWrongTagThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + $tagSize = $cipherInstance->getOverheadSize(); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt(substr_replace($ciphertext, random_bytes($tagSize), -$tagSize), $key, $nonce); // wrong tag + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithFakeCiphertextThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + $fakePlaintext = $this->getPlainText() . '-fake'; + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + $fakeCiphertext = $cipherInstance->encrypt($fakePlaintext, $key, $nonce); + $tagSize = $cipherInstance->getOverheadSize(); + $tag = substr($ciphertext, -$tagSize); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt(substr_replace($fakeCiphertext, $tag, -$tagSize), $key, $nonce); // fake ciphertext + } +} diff --git a/tests/Crypto/Cipher/CipherWithNonceTrait.php b/tests/Crypto/Cipher/CipherWithNonceTrait.php new file mode 100644 index 0000000..9d4f33f --- /dev/null +++ b/tests/Crypto/Cipher/CipherWithNonceTrait.php @@ -0,0 +1,51 @@ +createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize() + 1); // wrong nonce size + $plaintext = $this->getPlainText(); + + $this->expectException(EncryptionException::class); + $cipherInstance->encrypt($plaintext, $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithWrongNonceSizeThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $key, $nonce . 'X'); // wrong nonce + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithWrongNonceThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $wrongNonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = $this->getPlainText(); + + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $key, $wrongNonce); + } +} diff --git a/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php new file mode 100644 index 0000000..50b4e34 --- /dev/null +++ b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php @@ -0,0 +1,87 @@ +markTestSkipped('OpenSSL extension is required for these tests.'); + } + } + + public static function dataProviderCiphers(): iterable + { + yield ['AES-128-GCM']; + yield ['AES-192-GCM']; + yield ['AES-256-GCM']; + yield ['CHACHA20-POLY1305']; + } + + public static function dataProviderEncrypted(): iterable + { + yield [ + 'AES-128-GCM', + '54c4cc0f038dc65dfaaebef3cecbfcec', + '553defeffbe4e315bf9816f6', + '', + '7b5f0f96b230d9847a7a72a078569df1', + ]; + yield [ + 'AES-128-GCM', + '54c4cc0f038dc65dfaaebef3cecbfcec', + '553defeffbe4e315bf9816f6', + 'test-plain-data', + '4b87ea2f31b25f503a44a3ffb1e2b47597d0671d7077163bd126757d7aa0af', + ]; + yield [ + 'AES-192-GCM', + '9757543de0cce63fb868f4da1aef19cbc4277e867b2eb862', + '0d14ea15adb2c3cee018a858', + 'test-plain-data', + '8cca6a6348f688b64f8ea62187b9de55ecb9f4dd0199d0bd39e428d72a4b3f', + ]; + yield [ + 'AES-256-GCM', + '647a582c7c0ef535b88dcaa8671effb413228d8eef72c8d111029c4825aca7d6', + '3437af16a83c0284b449a4a4', + 'test-plain-data', + '7c5fd62f60ad234d9dbf8efd26252a71b273b66b5e9fa89d27c519aac6bb54', + ]; + yield [ + 'CHACHA20-POLY1305', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + '', + '3584c3be670fa3a6d6ffc332beaf2302', + ]; + yield [ + 'CHACHA20-POLY1305', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + 'test-plain-data', + '75058e089d84a58fed82a822b462b2a3dcdf5b5b4cda445fdba26ccd012503', + ]; + } + + protected function createCipherInstance(?string $cipher = null): CipherInterface + { + return $cipher ? new OpenSSLAeadCipher($cipher) : new OpenSSLAeadCipher(); + } + + protected static function getPlainText(): string + { + return 'test-plain-data'; + } +} diff --git a/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php b/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php new file mode 100644 index 0000000..70cade3 --- /dev/null +++ b/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php @@ -0,0 +1,82 @@ +markTestSkipped('OpenSSL extension is required for these tests.'); + } + } + + public static function dataProviderCiphers(): iterable + { + yield ['AES-128-WRAP']; + yield ['AES-192-WRAP']; + yield ['AES-256-WRAP']; + } + + public static function dataProviderEncrypted(): iterable + { + yield [ + 'AES-128-WRAP', + '54c4cc0f038dc65dfaaebef3cecbfcec', + '', + '', + '', + ]; + yield [ + 'AES-128-WRAP', + '54c4cc0f038dc65dfaaebef3cecbfcec', + '', + 'test-plain-data-', + 'f5e0073e78eb2621fab4f6b58eb184b8cff4fa1d1ef4b6b9', + ]; + yield [ + 'AES-192-WRAP', + '9757543de0cce63fb868f4da1aef19cbc4277e867b2eb862', + '', + 'test-plain-data-', + '54bb69969c91d6163ef463989d932f0c492674abef0873f2', + ]; + yield [ + 'AES-256-WRAP', + '647a582c7c0ef535b88dcaa8671effb413228d8eef72c8d111029c4825aca7d6', + '', + 'test-plain-data-', + 'c08c23d569b502cb4b98dd4ac8672e0487f8e3d5e490f790', + ]; + } + + public function testNonceIsIgnored(): void + { + $cipher = $this->createCipherInstance(); + $key = random_bytes($cipher->getKeySize()); + $plaintext = $this->getPlainText(); + $nonce1 = random_bytes(8); // размер не важен + $nonce2 = random_bytes(8); + + $ciphertext1 = $cipher->encrypt($plaintext, $key, $nonce1); + $ciphertext2 = $cipher->encrypt($plaintext, $key, $nonce2); + $this->assertSame($ciphertext1, $ciphertext2); + } + + protected function createCipherInstance(?string $cipher = null): CipherInterface + { + return $cipher ? new OpenSSLWrapCipher($cipher) : new OpenSSLWrapCipher(); + } + + protected static function getPlainText(): string + { + return 'test-plain-data-'; + } +} diff --git a/tests/Crypto/Cipher/SodiumAeadCipherTest.php b/tests/Crypto/Cipher/SodiumAeadCipherTest.php new file mode 100644 index 0000000..2ce3680 --- /dev/null +++ b/tests/Crypto/Cipher/SodiumAeadCipherTest.php @@ -0,0 +1,64 @@ +markTestSkipped('Sodium extension is required for these tests.'); + } + } + + public static function dataProviderCiphers(): iterable + { + yield ['CHACHA20-POLY1305-IETF']; + yield ['XCHACHA20-POLY1305-IETF']; + } + + public static function dataProviderEncrypted(): iterable + { + yield [ + 'CHACHA20-POLY1305-IETF', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + '', + '3584c3be670fa3a6d6ffc332beaf2302', + ]; + yield [ + 'CHACHA20-POLY1305-IETF', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + 'test-plain-data', + '75058e089d84a58fed82a822b462b2a3dcdf5b5b4cda445fdba26ccd012503', + ]; + yield [ + 'XCHACHA20-POLY1305-IETF', + '89fe0c0b2c9b74cdb87d13f0b9f835bde84a3f0c4940c026c5d888db254271f0', + 'fc6f945727c02ac590d53cc17c2f144949526a4f2d2fef41', + 'test-plain-data', + '4c88400da53f878bf9de7749a70b38022ce8166effecc64b8c8a49c2c0f28c', + ]; + } + + protected function createCipherInstance(?string $cipher = null): CipherInterface + { + return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); + } + + protected static function getPlainText(): string + { + return 'test-plain-data'; + } +} diff --git a/tests/Crypto/Cipher/SodiumGcmCipherTest.php b/tests/Crypto/Cipher/SodiumGcmCipherTest.php new file mode 100644 index 0000000..fb5d9bb --- /dev/null +++ b/tests/Crypto/Cipher/SodiumGcmCipherTest.php @@ -0,0 +1,58 @@ +markTestSkipped('Sodium extension is required for these tests.'); + } elseif (!sodium_crypto_aead_aes256gcm_is_available()) { + $this->markTestSkipped('Sodium AES-256-GCM requires hardware that supports hardware-accelerated AES.'); + } + } + + public static function dataProviderCiphers(): iterable + { + yield ['AES-256-GCM']; + } + + public static function dataProviderEncrypted(): iterable + { + yield [ + 'AES-256-GCM', + 'd2000811111ba11ba7a2497911c43111a00b433d8437b3538d57d75366b32bb2', + '429895de6466a4622f287f0c', + '', + '5f82ba64af12dbd7f594a51c235c4b98', + ]; + yield [ + 'AES-256-GCM', + 'd2000811111ba11ba7a2497911c43111a00b433d8437b3538d57d75366b32bb2', + '429895de6466a4622f287f0c', + 'test-plain-data', + 'ae9cf157604ed2a9fd7ad971d005c4e571ec8a6e697e000414e5820748912c', + ]; + } + + protected function createCipherInstance(?string $cipher = null): CipherInterface + { + return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); + } + + protected static function getPlainText(): string + { + return 'test-plain-data'; + } +} diff --git a/tests/Crypto/EnvelopeCryptorTest.php b/tests/Crypto/EnvelopeCryptorTest.php new file mode 100644 index 0000000..1320883 --- /dev/null +++ b/tests/Crypto/EnvelopeCryptorTest.php @@ -0,0 +1,322 @@ +createMocks( + $kdfSaltSize, + $dataKeySize, + $dataNonceSize, + $dataOverheadSize, + $kwKeySize, + $kwNonceSize, + $kwOverheadSize, + ); + + $kek = random_bytes($kwKeySize); + + $kdf->expects($this->once()) + ->method('derive') + ->with($secret, $kwKeySize, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === $kdfSaltSize)) + ->willReturn($kek); + + // Expect cipher->encrypt() for data + $cipher->expects($this->once()) + ->method('encrypt') + ->with($plaintext, $this->callback(static fn($dek) => StringHelper::byteLength($dek) === $dataKeySize), $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === $dataNonceSize)) + ->willReturn($cyphertext . str_repeat('t', $dataOverheadSize)); + + // Expect kwCipher->encrypt() for DEK wrapping + $kwCipher->expects($this->once()) + ->method('encrypt') + ->willReturnCallback(function ($dek, $key, $nonce) use ($kek, $kwNonceSize, $kwOverheadSize, $dataKeySize, $wrappedDek) { + $this->assertSame($dataKeySize, StringHelper::byteLength($dek)); + $this->assertSame($kek, $key); + $this->assertSame($kwNonceSize, StringHelper::byteLength($nonce)); + return $wrappedDek . str_repeat('t', $kwOverheadSize); + }); + + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher, kwCipher: $kwCipher); + $result = $cryptor->encrypt($plaintext, $secret, $context); + + // Check overall length: salt + dekNonce + wrappedDEK + dataNonce + encryptedData + $expectedLength = $kdfSaltSize + + $kwNonceSize + + ($dataKeySize + $kwOverheadSize) + + $dataNonceSize + + (StringHelper::byteLength($cyphertext) + $dataOverheadSize); + + $this->assertSame($expectedLength, StringHelper::byteLength($result)); + + // Parse components + $offset = 0; + $salt = StringHelper::byteSubstring($result, $offset, $kdfSaltSize); + $offset += $kdfSaltSize; + $dekNonce = StringHelper::byteSubstring($result, $offset, $kwNonceSize); + $offset += $kwNonceSize; + $parsedWrappedDek = StringHelper::byteSubstring($result, $offset, $dataKeySize + $kwOverheadSize); + $offset += $dataKeySize + $kwOverheadSize; + $dataNonce = StringHelper::byteSubstring($result, $offset, $dataNonceSize); + $offset += $dataNonceSize; + $encryptedData = StringHelper::byteSubstring($result, $offset); + + $this->assertSame($kdfSaltSize, StringHelper::byteLength($salt)); + $this->assertSame($kwNonceSize, StringHelper::byteLength($dekNonce)); + $this->assertSame($dataNonceSize, StringHelper::byteLength($dataNonce)); + $this->assertSame($wrappedDek . str_repeat('t', $kwOverheadSize), $parsedWrappedDek); + $this->assertSame($cyphertext . str_repeat('t', $dataOverheadSize), $encryptedData); + } + + #[DataProvider('dataProviderConfigs')] + public function testDecryptReturnsPlaintextAndUsesKdfAndCiphers( + int $kdfSaltSize, + int $dataKeySize, + int $dataNonceSize, + int $dataOverheadSize, + int $kwKeySize, + int $kwNonceSize, + int $kwOverheadSize, + ): void { + $plaintext = 'test-plain-data'; + $secret = 'test-secret'; + $context = 'test-context'; + + $salt = str_repeat("\x01", $kdfSaltSize); + $dekNonce = str_repeat("\x02", $kwNonceSize); + $dataNonce = str_repeat("\x03", $dataNonceSize); + $dek = str_repeat("\x10", $dataKeySize); + $wrappedDek = $dek . str_repeat("\x20", $kwOverheadSize); + $encryptedData = $plaintext . str_repeat("\x30", $dataOverheadSize); + + $blob = $salt . $dekNonce . $wrappedDek . $dataNonce . $encryptedData; + + [$kdf, $cipher, $kwCipher] = $this->createMocks( + $kdfSaltSize, + $dataKeySize, + $dataNonceSize, + $dataOverheadSize, + $kwKeySize, + $kwNonceSize, + $kwOverheadSize, + ); + + $kdf->expects($this->once()) + ->method('derive') + ->with($secret, $kwKeySize, $context, $salt) + ->willReturn('kek'); + + $cipher->expects($this->once()) + ->method('decrypt') + ->with($encryptedData, $dek, $dataNonce) + ->willReturn($plaintext); + + $kwCipher->expects($this->once()) + ->method('decrypt') + ->with($wrappedDek, 'kek', $dekNonce) + ->willReturn($dek); + + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher, kwCipher: $kwCipher); + $decrypted = $cryptor->decrypt($blob, $secret, $context); + + $this->assertSame($plaintext, $decrypted); + } + + #[DataProvider('dataProviderConfigs')] + public function testEncryptionIsRandomized( + int $kdfSaltSize, + int $dataKeySize, + int $dataNonceSize, + int $dataOverheadSize, + int $kwKeySize, + int $kwNonceSize, + int $kwOverheadSize, + ): void { + [$kdf, $cipher, $kwCipher] = $this->createMocks( + $kdfSaltSize, + $dataKeySize, + $dataNonceSize, + $dataOverheadSize, + $kwKeySize, + $kwNonceSize, + $kwOverheadSize, + ); + + $kdf->method('derive')->willReturn('kek'); + $cipher->method('encrypt')->willReturn('encrypted_data' . str_repeat("\x30", $dataOverheadSize)); + $kwCipher->method('encrypt')->willReturnCallback(static fn($dek, $key, $nonce) => $dek . str_repeat("\x10", $kwOverheadSize)); + + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher, kwCipher: $kwCipher); + + $res1 = $cryptor->encrypt('data', 'secret'); + $res2 = $cryptor->encrypt('data', 'secret'); + + $offset = 0; + + // 1. KDF salt (if $kdfSaltSize > 0) + if ($kdfSaltSize > 0) { + $salt1 = StringHelper::byteSubstring($res1, $offset, $kdfSaltSize); + $salt2 = StringHelper::byteSubstring($res2, $offset, $kdfSaltSize); + $this->assertNotSame($salt1, $salt2, 'KDF salt must be different for each encryption'); + } + $offset += $kdfSaltSize; + + // 2. DEK nonce ($kwNonceSize > 0) + if ($kwNonceSize > 0) { + $dekNonce1 = StringHelper::byteSubstring($res1, $offset, $kwNonceSize); + $dekNonce2 = StringHelper::byteSubstring($res2, $offset, $kwNonceSize); + $this->assertNotSame($dekNonce1, $dekNonce2, 'DEK nonce must be different for each encryption'); + } + $offset += $kwNonceSize; + + // 3. DEK (must be > 0) + $dek1 = StringHelper::byteSubstring($res1, $offset, $dataKeySize); + $dek2 = StringHelper::byteSubstring($res2, $offset, $dataKeySize); + $this->assertNotSame($dek1, $dek2, 'DEK must be different for each encryption'); + $offset += $dataKeySize + $kwOverheadSize; + + // 4. Data nonce (if $dataNonceSize > 0) + if ($dataNonceSize > 0) { + $dataNonce1 = StringHelper::byteSubstring($res1, $offset, $dataNonceSize); + $dataNonce2 = StringHelper::byteSubstring($res2, $offset, $dataNonceSize); + $this->assertNotSame($dataNonce1, $dataNonce2, 'Data nonce must be different for each encryption'); + } + } + + public function testDecryptThrowsWhenDataTooShort(): void + { + [$kdf, $cipher, $kwCipher] = $this->createMocks(...[ + 'kdfSaltSize' => 16, + 'dataKeySize' => 32, + 'dataNonceSize' => 12, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 12, + 'kwOverheadSize' => 16, + ]); + + $cipher->method('encrypt')->willReturn('encrypted_data'); + + $this->expectException(EncryptionException::class); + + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher, kwCipher: $kwCipher); + $cryptor->decrypt('short', 'secret'); + } + + /** + * [kdfSaltSize, kwKeySize, kwNonceSize, kwOverheadSize, dataKeySize, dataNonceSize, dataOverheadSize] + */ + public static function dataProviderConfigs(): iterable + { + yield [ + 'kdfSaltSize' => 16, + 'dataKeySize' => 32, + 'dataNonceSize' => 12, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 12, + 'kwOverheadSize' => 16, + ]; + // kdf without salt + yield [ + 'kdfSaltSize' => 0, + 'dataKeySize' => 32, + 'dataNonceSize' => 12, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 12, + 'kwOverheadSize' => 16, + ]; + // kdf without salt, kw ciper without nonce + yield [ + 'kdfSaltSize' => 0, + 'dataKeySize' => 32, + 'dataNonceSize' => 12, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 0, + 'kwOverheadSize' => 16, + ]; + // kdf without salt, data ciper without nonce + yield [ + 'kdfSaltSize' => 0, + 'dataKeySize' => 32, + 'dataNonceSize' => 0, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 12, + 'kwOverheadSize' => 16, + ]; + // kdf without salt, kw/data ciper without nonce + yield [ + 'kdfSaltSize' => 0, + 'dataKeySize' => 32, + 'dataNonceSize' => 0, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 0, + 'kwOverheadSize' => 16, + ]; + // kw/data ciper without nonce + yield [ + 'kdfSaltSize' => 16, + 'dataKeySize' => 32, + 'dataNonceSize' => 0, + 'dataOverheadSize' => 16, + 'kwKeySize' => 32, + 'kwNonceSize' => 0, + 'kwOverheadSize' => 16, + ]; + } + + private function createMocks( + int $kdfSaltSize, + int $dataKeySize, + int $dataNonceSize, + int $dataOverheadSize, + int $kwKeySize, + int $kwNonceSize, + int $kwOverheadSize, + ): array { + $kdf = $this->createMock(KdfInterface::class); + $kdf->method('getSaltSize')->willReturn($kdfSaltSize); + + $cipher = $this->createMock(CipherInterface::class); + $cipher->method('getKeySize')->willReturn($dataKeySize); + $cipher->method('getNonceSize')->willReturn($dataNonceSize); + $cipher->method('getOverheadSize')->willReturn($dataOverheadSize); + + $kwCipher = $this->createMock(CipherInterface::class); + $kwCipher->method('getKeySize')->willReturn($kwKeySize); + $kwCipher->method('getNonceSize')->willReturn($kwNonceSize); + $kwCipher->method('getOverheadSize')->willReturn($kwOverheadSize); + + return [$kdf, $cipher, $kwCipher]; + } +} diff --git a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php new file mode 100644 index 0000000..798dac8 --- /dev/null +++ b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php @@ -0,0 +1,139 @@ +getKdfStub(); + $cipher = $this->getCipherStub(); + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher); + + $plaintext = 'test-plain-data'; + + $encrypted = $cryptor->encrypt($plaintext, 'test-secret', 'test-context'); + $decrypted = $cryptor->decrypt($encrypted, 'test-secret', 'test-context'); + + $this->assertSame($plaintext, $decrypted); + } + + public function testSingleCipherEncryptionIsRandomized(): void + { + $kdf = $this->getKdfStub(); + $cipher = $this->getCipherStub(); + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher); + + $enc1 = $cryptor->encrypt('test-plain-data', 'test-secret'); + $enc2 = $cryptor->encrypt('test-plain-data', 'test-secret'); + $this->assertNotSame($enc1, $enc2); + } + + public function testSingleCipherWrongSecretThrowsException(): void + { + $kdf = $this->getKdfStub(); + $cipher = $this->getCipherStub(); + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher); + + $encrypted = $cryptor->encrypt('test-plain-data', 'correct'); + $this->expectException(EncryptionException::class); + $cryptor->decrypt($encrypted, 'wrong'); + } + + public function testSingleCipherTooShortDataThrowsException(): void + { + $kdf = $this->getKdfStub(); + $cipher = $this->getCipherStub(); + $cryptor = new EnvelopeCryptor(kdf: $kdf, cipher: $cipher); + + $this->expectException(EncryptionException::class); + $cryptor->decrypt('short', 'secret'); + } + + private function getKdfStub(int $saltSize = 16): KdfInterface + { + return new class ($saltSize) implements KdfInterface { + public function __construct(private readonly int $saltSize) {} + + public function derive(string $secret, int $keySize, string $context, string $salt = ''): string + { + $hash = hash('sha256', $secret . $context . $salt, true); + + return StringHelper::byteSubstring(str_repeat($hash, (int) ceil($keySize / 32)), 0, $keySize); + } + + public function getSaltSize(): int + { + return $this->saltSize; + } + }; + } + + private function getCipherStub(int $keySize = 32, int $nonceSize = 12): CipherInterface + { + return new class ($keySize, $nonceSize) implements CipherInterface { + // sha256 hash length + private const TAG_SIZE = 32; + + public function __construct( + private readonly int $keySize, + private readonly int $nonceSize, + ) {} + + public function encrypt(string $data, #[SensitiveParameter] string $key, string $nonce = '', string $aad = ''): string + { + $encrypted = $this->jgurdaCipher($data, $key); + //echo $encrypted . PHP_EOL; + return $encrypted . hash_hmac('sha256', $encrypted . $nonce, $key, true); + //return $this->jgurdaCipher($data, $key) . str_repeat("\x20", $this->overheadSize); + } + + public function decrypt(string $data, #[SensitiveParameter] string $key, string $nonce = '', string $aad = ''): string + { + $payloadLen = StringHelper::byteLength($data) - self::TAG_SIZE; + if ($payloadLen < 0) { + throw new EncryptionException('Invalid data'); + } + + $storedData = StringHelper::byteSubstring($data, 0, -self::TAG_SIZE); + $tag = StringHelper::byteSubstring($data, -self::TAG_SIZE); + $expectedTag = hash_hmac('sha256', $storedData . $nonce, $key, true); + + if ($tag !== $expectedTag) { + throw new EncryptionException('Decryption failed'); + } + + return $this->jgurdaCipher($storedData, $key); + } + + private function jgurdaCipher(string $text, string $key): string + { + return $text ^ str_repeat($key, StringHelper::byteLength($text)); + } + + public function getKeySize(): int + { + return $this->keySize; + } + + public function getNonceSize(): int + { + return $this->nonceSize; + } + + public function getOverheadSize(): int + { + return self::TAG_SIZE; + } + }; + } +} diff --git a/tests/Crypto/Kdf/AbstractKdfCase.php b/tests/Crypto/Kdf/AbstractKdfCase.php new file mode 100644 index 0000000..a78c1b7 --- /dev/null +++ b/tests/Crypto/Kdf/AbstractKdfCase.php @@ -0,0 +1,159 @@ +createKdfInstance(); + $keySize = 32; + $secret = random_bytes($keySize); + $salt = random_bytes($kdf->getSaltSize()); + $key = $kdf->derive($secret, $keySize, 'test-context', $salt); + + $this->assertSame($keySize, StringHelper::byteLength($key)); + $this->assertNotSame($secret, $key); + } + + #[DataProvider('dataProviderKeyValues')] + public function testKeyValues(string $hashAlgo, string $secret, int $keySize, string $context, string $salt, string $key): void + { + $kdf = $this->createKdfInstance($hashAlgo); + + $secret = hex2bin(preg_replace('{\s+}', '', $secret)); + $salt = hex2bin(preg_replace('{\s+}', '', $salt)); + $key = hex2bin(preg_replace('{\s+}', '', $key)); + + $this->assertSame($key, $kdf->derive($secret, $keySize, $context, $salt)); + } + + #[DataProvider('dataProviderAlgoKeySize')] + public function testDeriveWithCustomAlgorithm(string $hashAlgo, int $keySize): void + { + $kdf = $this->createKdfInstance($hashAlgo); + $salt = random_bytes($kdf->getSaltSize()); + + $key = $kdf->derive('test-secret', $keySize, 'test-context', $salt); + + $this->assertSame($keySize, StringHelper::byteLength($key)); + } + + public function testDeriveWithHashStaticSalt(): void + { + $staticSalt = random_bytes(32); + $kdf1 = $this->createKdfInstance(hashStaticSalt: $staticSalt); + $kdf2 = $this->createKdfInstance(hashStaticSalt: new StringableParam($staticSalt)); + $keySize = 32; + $secret = random_bytes($keySize); + $salt = random_bytes($kdf1->getSaltSize()); + $key1 = $kdf1->derive($secret, $keySize, 'test-context', $salt); + $key2 = $kdf2->derive($secret, $keySize, 'test-context', $salt); + + $this->assertSame($keySize, StringHelper::byteLength($key1)); + $this->assertSame($keySize, StringHelper::byteLength($key2)); + $this->assertNotSame($secret, $key1); + $this->assertNotSame($secret, $key2); + $this->assertSame($key1, $key2); + } + + public function testSameParametersProduceSameKey(): void + { + $kdf = $this->createKdfInstance(); + $keySize = 64; + $secret = random_bytes($keySize); + $salt = random_bytes($kdf->getSaltSize()); + + $key1 = $kdf->derive($secret, $keySize, 'test-context', $salt); + $key2 = $kdf->derive($secret, $keySize, 'test-context', $salt); + + $this->assertSame($key1, $key2); + } + + public function testDifferentParamsProducesDifferentKey(): void + { + $kdf = $this->createKdfInstance(); + $keySize = 32; + $secret = random_bytes($keySize); + $secret2 = random_bytes($keySize); + $salt1 = random_bytes($kdf->getSaltSize()); + $salt2 = random_bytes($kdf->getSaltSize()); + + // different salt + $key11 = $kdf->derive($secret, $keySize, 'test-context', $salt1); + $key12 = $kdf->derive($secret, $keySize, 'test-context', $salt2); + $this->assertNotSame($key11, $key12); + + // different context + $key21 = $kdf->derive($secret, $keySize, 'context-1', $salt1); + $key22 = $kdf->derive($secret, $keySize, 'context-2', $salt1); + $this->assertNotSame($key21, $key22); + + // different secret + $key31 = $kdf->derive($secret, $keySize, 'test-context', $salt1); + $key32 = $kdf->derive($secret2, $keySize, 'test-context', $salt1); + $this->assertNotSame($key31, $key32); + } + + public function testInvalidHashAlgoThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->createKdfInstance('Non-Existing-Algorithm'); + } + + public function testInvalidSizeThrowsException(): void + { + $kdf = $this->createKdfInstance(); + + $this->expectException(EncryptionException::class); + $kdf->derive('test-secret', -1, 'test-context', 'test-salt'); + } + + public function testSaltTooShortThrowsException(): void + { + $kdf = $this->createKdfInstance(); + $salt = random_bytes($kdf->getSaltSize() - 1); + + $this->expectException(EncryptionException::class); + $kdf->derive(random_bytes(32), 32, 'test-context', $salt); + } + + public function testSaltTooLongThrowsException(): void + { + $kdf = $this->createKdfInstance(); + $salt = random_bytes($kdf->getSaltSize() + 1); + + $this->expectException(EncryptionException::class); + $kdf->derive(random_bytes(32), 32, 'test-context', $salt); + } + + public function testGetSizes(): void + { + $cipher = $this->createKdfInstance(); + $keySize = $cipher->getSaltSize(); + + $this->assertIsInt($keySize); + $this->assertGreaterThanOrEqual(0, $keySize); + } + + abstract protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface; +} diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php new file mode 100644 index 0000000..ce9d616 --- /dev/null +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -0,0 +1,139 @@ +derive($secret, $keySize, 'context-1'); + $key22 = $kdf->derive($secret, $keySize, 'context-2'); + $this->assertNotSame($key21, $key22); + + // different secret + $key31 = $kdf->derive($secret, $keySize, 'test-context'); + $key32 = $kdf->derive($secret2, $keySize, 'test-context'); + $this->assertNotSame($key31, $key32); + } + + public function testDifferentStaticSaltProducesDifferentKey(): void + { + $kdf1 = new KdfKey(hashStaticSalt: random_bytes(32), saltSize: 0); + $kdf2 = new KdfKey(hashStaticSalt: random_bytes(32), saltSize: 0); + $keySize = 32; + $secret = random_bytes($keySize); + + $key1 = $kdf1->derive($secret, $keySize, 'test-context'); + $key2 = $kdf2->derive($secret, $keySize, 'test-context'); + $this->assertNotSame($key1, $key2); + } + + public function testSaltSizeValid(): void + { + $kdf1 = new KdfKey(saltSize: 0); + $this->assertSame(0, $kdf1->getSaltSize()); + + $kdf = new KdfKey(saltSize: 24); + $this->assertSame(24, $kdf->getSaltSize()); + } + + public function testInvalidSecretThrowsException(): void + { + $kdf = $this->createKdfInstance(); + + $this->expectException(EncryptionException::class); + $kdf->derive('', 32, 'test-context', 'test-salt'); + } + + public function testInvalidStaticSaltThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->createKdfInstance(hashAlgo: 'sha256', hashStaticSalt: random_bytes(31)); + } + + public function testInvalidSaltSizeThrowsException(): void + { + $kdf = new KdfKey(saltSize: -1); + + $this->expectException(EncryptionException::class); + $kdf->derive('test-secret', 32, 'test-context', 'test-salt'); + } + + #[DataProvider('dataProviderEmptyStaticSaltKeyValues')] + public function testEmptyStaticSaltDerivesExpectedKey(string $hashAlgo, string $secret, int $keySize, string $context, string $key): void + { + $kdf = new KdfKey(hashAlgo: $hashAlgo, hashStaticSalt: '', saltSize: 0); + $secret = hex2bin(preg_replace('{\s+}', '', $secret)); + $key = hex2bin(preg_replace('{\s+}', '', $key)); + + $this->assertSame($key, $kdf->derive($secret, $keySize, $context)); + } + + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface + { + return isset($hashAlgo) + ? new KdfKey(hashAlgo: $hashAlgo, hashStaticSalt: $hashStaticSalt) + : new KdfKey(hashStaticSalt: $hashStaticSalt); + } +} diff --git a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php new file mode 100644 index 0000000..f576685 --- /dev/null +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -0,0 +1,56 @@ +markTestSkipped('Sodium extension is required for these tests.'); + } + } + + public static function dataProviderKeyValues(): iterable + { + yield [ + 'sha256', + '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', + 32, + 'test-context', + 'ae8cbb001c062cd2c00ed6956842dc4d', + '0dd1df2a07aa3727520f1863b0f753d4e118bec28e324c05eeea4a274b7f5d5e', + ]; + yield [ + 'sha512', + '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', + 64, + 'test-context', + '7f22a943efd3537ef9e0dc98e7031d9f', + '9c2182653d63d369cecc7bf96e24325aaa09eaca943accd53b263ad8390eb4e39b36ad4a9e89b2849cd7699138f14b825722073729eebae8a49f8e9ad278a367', + ]; + yield [ + 'sha3-256', + '983447213c2c295a72a64d95e069793b9acf4cbaef59b71a86cbc6aec4f020e4', + 32, + 'test-context', + 'aa24ea6b979b1a857d9f9dfa0dcac8a4', + 'f3485c8fec6e20d0e81a332d9a6e7293985ad345076a2b167d3b682e612ab549', + ]; + } + + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface + { + return isset($hashAlgo) + ? new KdfPasswordArgon2(hashAlgo: $hashAlgo, hashStaticSalt: $hashStaticSalt) + : new KdfPasswordArgon2(hashStaticSalt: $hashStaticSalt); + } +} diff --git a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php new file mode 100644 index 0000000..588547a --- /dev/null +++ b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php @@ -0,0 +1,54 @@ +expectException(RuntimeException::class); + new KdfPasswordPbkdf2(iterations: 0); + } + + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface + { + return isset($hashAlgo) + ? new KdfPasswordPbkdf2(hashAlgo: $hashAlgo, iterations: 100_000, hashStaticSalt: $hashStaticSalt) + : new KdfPasswordPbkdf2(iterations: 100_000, hashStaticSalt: $hashStaticSalt); + } +} diff --git a/tests/Crypto/Kdf/StringableParam.php b/tests/Crypto/Kdf/StringableParam.php new file mode 100644 index 0000000..f0150ca --- /dev/null +++ b/tests/Crypto/Kdf/StringableParam.php @@ -0,0 +1,21 @@ +value; + } +} diff --git a/tests/Crypto/KdfCryptorTest.php b/tests/Crypto/KdfCryptorTest.php new file mode 100644 index 0000000..3861212 --- /dev/null +++ b/tests/Crypto/KdfCryptorTest.php @@ -0,0 +1,202 @@ +createMocks( + $kdfSaltSize, + $keySize, + $nonceSize, + ); + + $kdf->expects($this->once()) + ->method('derive') + ->with($secret, $keySize, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === $kdfSaltSize)) + ->willReturn('test-derivedkey-123456'); + + $cipher->expects($this->once()) + ->method('encrypt') + ->with($plaintext, 'test-derivedkey-123456', $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === $nonceSize)) + ->willReturn('test-ciphertext-and-tag'); + + $cryptor = new KdfCryptor(kdf: $kdf, cipher: $cipher); + $result = $cryptor->encrypt($plaintext, $secret, $context); + + // result structure: keySalt || nonce || ciphertext + $this->assertIsString($result); + $this->assertSame( + $kdfSaltSize + $nonceSize + StringHelper::byteLength('test-ciphertext-and-tag'), + StringHelper::byteLength($result), + ); + + $keySalt = StringHelper::byteSubstring($result, 0, $kdfSaltSize); + $nonce = StringHelper::byteSubstring($result, $kdfSaltSize, $nonceSize); + $ciphertext = StringHelper::byteSubstring($result, $kdfSaltSize + $nonceSize); + + $this->assertSame($kdfSaltSize, StringHelper::byteLength($keySalt)); + $this->assertSame($nonceSize, StringHelper::byteLength($nonce)); + $this->assertSame('test-ciphertext-and-tag', $ciphertext); + } + + #[DataProvider('dataProviderConfigs')] + public function testDecryptReturnsPlaintextAndUsesKdfAndCipher( + int $kdfSaltSize, + int $keySize, + int $nonceSize, + ): void { + $plaintext = 'test-plain-data'; + $secret = 'test-secret'; + $context = 'test-context'; + + $keySalt = str_repeat("\x01", $kdfSaltSize); + $nonce = str_repeat("\x02", $nonceSize); + + $encryptedPayload = 'encrypted-by-cipher'; + + [$kdf, $cipher] = $this->createMocks( + $kdfSaltSize, + $keySize, + $nonceSize, + ); + + $kdf->expects($this->once()) + ->method('derive') + ->with($secret, $keySize, $context, $keySalt) + ->willReturn('dek'); + + $cipher->expects($this->once()) + ->method('decrypt') + ->with($encryptedPayload, 'dek', $nonce) + ->willReturn($plaintext); + + // Build the encrypted blob: keySalt || nonce || encryptedPayload + $blob = $keySalt . $nonce . $encryptedPayload; + $cryptor = new KdfCryptor(kdf: $kdf, cipher: $cipher); + $decrypted = $cryptor->decrypt($blob, $secret, $context); + $this->assertSame($plaintext, $decrypted); + } + + #[DataProvider('dataProviderConfigs')] + public function testEncryptionIsRandomized( + int $kdfSaltSize, + int $keySize, + int $nonceSize, + ): void { + [$kdf, $cipher] = $this->createMocks( + $kdfSaltSize, + $keySize, + $nonceSize, + ); + + $kdf->method('derive')->willReturn('dek'); + $cipher->method('encrypt')->willReturn('encrypted_data'); + + $cryptor = new KdfCryptor(kdf: $kdf, cipher: $cipher); + + $res1 = $cryptor->encrypt('data', 'secret'); + $res2 = $cryptor->encrypt('data', 'secret'); + + // If at least one random component (salt or nonce) exists, the results must differ. + if ($kdfSaltSize > 0 || $nonceSize > 0) { + $this->assertNotSame($res1, $res2, 'Results must differ when salt or nonce is present.'); + } else { + $this->assertSame($res1, $res2); + } + + // Verify that KDF salt is random when its size > 0 + if ($kdfSaltSize > 0) { + $salt1 = StringHelper::byteSubstring($res1, 0, $kdfSaltSize); + $salt2 = StringHelper::byteSubstring($res2, 0, $kdfSaltSize); + $this->assertNotSame($salt1, $salt2, 'KDF salt must be different for each encryption'); + } + + // Verify that data nonce is random when its size > 0 + if ($nonceSize > 0) { + $nonce1 = StringHelper::byteSubstring($res1, $kdfSaltSize, $nonceSize); + $nonce2 = StringHelper::byteSubstring($res2, $kdfSaltSize, $nonceSize); + $this->assertNotSame($nonce1, $nonce2, 'Data nonce must be different for each encryption'); + } + } + + public function testDecryptThrowsWhenDataTooShort(): void + { + [$kdf, $cipher] = $this->createMocks(...[ + 'kdfSaltSize' => 16, + 'keySize' => 32, + 'nonceSize' => 12, + ]); + + $cipher->method('encrypt')->willReturn('encrypted_data'); + + $this->expectException(EncryptionException::class); + + $cryptor = new KdfCryptor(kdf: $kdf, cipher: $cipher); + $cryptor->decrypt('short', 'secret'); + } + + /** + * [kdfSaltSize, kwKeySize, kwNonceSize, kwOverheadSize, dataKeySize, dataNonceSize, dataOverheadSize] + */ + public static function dataProviderConfigs(): iterable + { + yield [ + 'kdfSaltSize' => 16, + 'keySize' => 32, + 'nonceSize' => 12, + ]; + // data ciper without nonce + yield [ + 'kdfSaltSize' => 16, + 'keySize' => 32, + 'nonceSize' => 0, + ]; + // kdf without salt + yield [ + 'kdfSaltSize' => 0, + 'keySize' => 32, + 'nonceSize' => 12, + ]; + // kdf without salt, kw ciper without nonce + yield [ + 'kdfSaltSize' => 0, + 'keySize' => 32, + 'nonceSize' => 0, + ]; + } + + private function createMocks( + int $kdfSaltSize, + int $keySize, + int $nonceSize, + ): array { + $kdf = $this->createMock(KdfInterface::class); + $kdf->method('getSaltSize')->willReturn($kdfSaltSize); + + $cipher = $this->createMock(CipherInterface::class); + $cipher->method('getKeySize')->willReturn($keySize); + $cipher->method('getNonceSize')->willReturn($nonceSize); + + return [$kdf, $cipher]; + } +} diff --git a/tests/Crypto/VersionedCryptorTest.php b/tests/Crypto/VersionedCryptorTest.php new file mode 100644 index 0000000..6acc3db --- /dev/null +++ b/tests/Crypto/VersionedCryptorTest.php @@ -0,0 +1,197 @@ +createMock(CryptorInterface::class); + $cryptor->expects($this->once()) + ->method('encrypt') + ->with($plaintext, $secret, $context) + ->willReturn('encrypted-payload'); + + $versioned = new VersionedCryptor(cryptors: [$v => $cryptor], currentVersion: $v); + $result = $versioned->encrypt($plaintext, $secret, $context); + + $this->assertSame($v . 'encrypted-payload', $result); + } + + public function testDecryptExtractsVersionAndCallsCorrectCryptor(): void + { + $plaintext = 'test-plain-data'; + $secret = 'test-secret'; + $context = 'test-context'; + + $encryptedPayload = 'encrypted-part'; + $fullData = 'v2' . $encryptedPayload; + + $cryptorV2 = $this->createMock(CryptorInterface::class); + $cryptorV2->expects($this->once()) + ->method('decrypt') + ->with($encryptedPayload, $secret, $context) + ->willReturn($plaintext); + + $versioned = new VersionedCryptor(cryptors: ['v2' => $cryptorV2], currentVersion: 'v2'); + $result = $versioned->decrypt($fullData, $secret, $context); + + $this->assertSame($plaintext, $result); + } + + public function testEncryptDecryptDifferentVersions(): void + { + $plaintext = 'test-plain-data'; + $secret = 'test-secret'; + + $cryptorV1 = $this->createMock(CryptorInterface::class); + $cryptorV1->method('encrypt')->willReturn('encrypted_data_v1'); + $cryptorV1->method('decrypt')->willReturn($plaintext); + + $cryptorV2 = $this->createMock(CryptorInterface::class); + $cryptorV2->method('encrypt')->willReturn('encrypted_data_v2'); + $cryptorV2->method('decrypt')->willReturn($plaintext); + + $versionedCryptor = new VersionedCryptor(cryptors: [ + 'v1' => $cryptorV1, + 'v2' => $cryptorV2, + ], currentVersion: 'v2'); + + $encryptedDataV1 = 'v1' . $cryptorV1->encrypt($plaintext, $secret); + $encryptedDataV2 = 'v2' . $cryptorV2->encrypt($plaintext, $secret); + + $decryptedDataV1 = $versionedCryptor->decrypt($encryptedDataV1, $secret); + $decryptedDataV2 = $versionedCryptor->decrypt($encryptedDataV2, $secret); + + $this->assertSame($plaintext, $decryptedDataV1); + $this->assertSame($plaintext, $decryptedDataV2); + } + + public function testContextPassedToUnderlyingCryptor(): void + { + $secret = 'test-secret'; + $context = 'test-context'; + + $cryptor = $this->createMock(CryptorInterface::class); + $cryptor->expects($this->once()) + ->method('encrypt') + ->with('data', $secret, $context) + ->willReturn('encrypted'); + + $cryptor->expects($this->once()) + ->method('decrypt') + ->with('encrypted', $secret, $context) + ->willReturn('data'); + + $versioned = new VersionedCryptor(cryptors: ['v1' => $cryptor], currentVersion: 'v1'); + + $encrypted = $versioned->encrypt('data', $secret, $context); + $decrypted = $versioned->decrypt($encrypted, $secret, $context); + + $this->assertSame('data', $decrypted); + } + + public function testVersionSizeWorks(): void + { + $plaintext = 'test-plain-data'; + $secret = 'test-secret'; + $version = 'v1'; + + $cryptor = $this->createMock(CryptorInterface::class); + $cryptor->method('encrypt') + ->with($plaintext, $secret, '') + ->willReturn('encrypted'); + $cryptor->method('decrypt') + ->with('encrypted', $secret, '') + ->willReturn($plaintext); + + $versioned = new VersionedCryptor(cryptors: [$version => $cryptor], currentVersion: $version, versionSize: 2); + $encrypted = $versioned->encrypt($plaintext, $secret); + $decrypted = $versioned->decrypt($encrypted, $secret); + + $this->assertSame($plaintext, $decrypted); + $this->assertSame($version . 'encrypted', $encrypted); + } + + public function testIntegerKeyIsNormalizedToStringAndLengthChecked(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor( + cryptors: [12 => $this->createMock(CryptorInterface::class)], + currentVersion: '123', + versionSize: 3, + ); + } + + public function testConstructThrowsWhenCurrentVersionNotRegistered(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v2'); + } + + public function testConstructorValidationThrows(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(cryptors: [], currentVersion: 'v1'); + } + + public function testConstructorThrowsExceptionWhenCryptorNotInstanceOfInterface(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(cryptors: ['v1' => new stdClass()], currentVersion: 'v1'); + } + + public function testConstructorThrowsExceptionWhenVersionSizeLessThanOne(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v1', versionSize: 0); + } + + public function testConstructorThrowsExceptionWhenVersionLengthMismatch(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v1', versionSize: 3); + } + + public function testDecryptThrowsExceptionWhenDataTooShort(): void + { + $cryptor = $this->createMock(CryptorInterface::class); + $versionedCryptor = new VersionedCryptor(cryptors: ['v1' => $cryptor], currentVersion: 'v1', versionSize: 2); + + $this->expectException(EncryptionException::class); + $versionedCryptor->decrypt('x', 'secret'); + } + + public function testDecryptThrowsExceptionWhenVersionNotFound(): void + { + $versionedCryptor = new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v1'); + + $this->expectException(EncryptionException::class); + $versionedCryptor->decrypt('v2' . 'test-plain-data', 'test-secret'); + } + + public function testDecryptInvalidData(): void + { + $cryptor = $this->createMock(CryptorInterface::class); + $cryptor->method('decrypt')->willThrowException(new EncryptionException()); + + $versionedCryptor = new VersionedCryptor(cryptors: ['v1' => $cryptor], currentVersion: 'v1'); + + $this->expectException(EncryptionException::class); + $versionedCryptor->decrypt('v1' . 'test-plain-data', 'test-secret'); + } +}