From 917f747e445dc397c00f5e8d691a29f64e9cc5a5 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 11 May 2026 17:22:35 +0700 Subject: [PATCH 01/70] add cryptor --- config/di.php | 48 ++++++++++++ src/Crypt/AeadCipherInterface.php | 10 +++ src/Crypt/Cipher/OpenSSLCipher.php | 94 ++++++++++++++++++++++++ src/Crypt/Cipher/SodiumCipher.php | 107 +++++++++++++++++++++++++++ src/Crypt/CipherInterface.php | 26 +++++++ src/Crypt/CryptorInterface.php | 56 ++++++++++++++ src/Crypt/EncryptionException.php | 11 +++ src/Crypt/EnvelopeCryptor.php | 80 ++++++++++++++++++++ src/Crypt/Kdf/KdfKey.php | 28 +++++++ src/Crypt/Kdf/KdfPassword.php | 32 ++++++++ src/Crypt/KdfInterface.php | 17 +++++ src/Crypt/SessionCryptor.php | 67 +++++++++++++++++ src/Crypt/VersionedCryptor.php | 113 +++++++++++++++++++++++++++++ 13 files changed, 689 insertions(+) create mode 100644 config/di.php create mode 100644 src/Crypt/AeadCipherInterface.php create mode 100644 src/Crypt/Cipher/OpenSSLCipher.php create mode 100644 src/Crypt/Cipher/SodiumCipher.php create mode 100644 src/Crypt/CipherInterface.php create mode 100644 src/Crypt/CryptorInterface.php create mode 100644 src/Crypt/EncryptionException.php create mode 100644 src/Crypt/EnvelopeCryptor.php create mode 100644 src/Crypt/Kdf/KdfKey.php create mode 100644 src/Crypt/Kdf/KdfPassword.php create mode 100644 src/Crypt/KdfInterface.php create mode 100644 src/Crypt/SessionCryptor.php create mode 100644 src/Crypt/VersionedCryptor.php diff --git a/config/di.php b/config/di.php new file mode 100644 index 0000000..fecfcdc --- /dev/null +++ b/config/di.php @@ -0,0 +1,48 @@ + SessionCryptor::class, + + SessionCryptor::class => [ + '__construct()' => [ + 'cipher' => Reference::to(OpenSSLCipher::class), + //'cipher' => Reference::to(SodiumCipher::class), + 'kdf' => Reference::to(KdfKey::class), + //'kdf' => Reference::to(KdfPassword::class), + ], + ], + + EnvelopeCryptor::class => [ + '__construct()' => [ + 'cipher' => Reference::to(OpenSSLCipher::class), + 'kdf' => Reference::to(KdfKey::class), + ], + ], + + VersionedCryptor::class => [ + '__construct()' => [ + 'cryptors' => ReferencesArray::from([ + //chr(0b00000001) => SessionCryptor::class, + pack('C', 20) => SessionCryptor::class, + ]), + 'currentVersion' => pack('C', 20), + 'versionSize' => 1 + ], + ], +]; diff --git a/src/Crypt/AeadCipherInterface.php b/src/Crypt/AeadCipherInterface.php new file mode 100644 index 0000000..ad9ff8c --- /dev/null +++ b/src/Crypt/AeadCipherInterface.php @@ -0,0 +1,10 @@ + Note: Yii's encryption protocol uses the same size for cipher key, HMAC signature key and key + * derivation salt. + */ + private const ALLOWED_CIPHERS = [ + 'AES-128-GCM' => [12, 16], + 'AES-192-GCM' => [12, 24], + 'AES-256-GCM' => [12, 32], + ]; + + /** + * @param string $cipher The cipher to use for encryption and decryption. + * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php + */ + public function __construct( + private readonly string $cipher = 'AES-256-GCM', + ) { + 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.'); + } + } + + public function encrypt( + string $data, + #[SensitiveParameter] string $key, + string $nounce, + ): string + { + $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $nounce, $tag, '', self::TAG_SIZE); + + if ($encrypted === false) { + throw new EncryptionException('Sodium failure on encryption'); + } + + return $encrypted . $tag; + } + + public function decrypt( + string $data, + #[SensitiveParameter] string $key, + string $nounce, + ): string + { + $tag = mb_substr($data, -self::TAG_SIZE, null, '8bit'); + $encrypted = mb_substr($data, 0, -self::TAG_SIZE, '8bit'); + + $decrypted = openssl_decrypt($encrypted, $this->cipher, $key, OPENSSL_RAW_DATA, $nounce, $tag); + + if ($decrypted === false) { + throw new EncryptionException('Sodium failure on decryption'); + } + + return $decrypted; + } + + public function getNounceSize(): int + { + return self::ALLOWED_CIPHERS[$this->cipher][0]; + } + + public function getKeySize(): int + { + return self::ALLOWED_CIPHERS[$this->cipher][1]; + } + + public function getTagSize(): int + { + return self::TAG_SIZE; + } +} diff --git a/src/Crypt/Cipher/SodiumCipher.php b/src/Crypt/Cipher/SodiumCipher.php new file mode 100644 index 0000000..dbdc9bd --- /dev/null +++ b/src/Crypt/Cipher/SodiumCipher.php @@ -0,0 +1,107 @@ + Note: Yii's encryption protocol uses the same size for cipher key, HMAC signature key and key + * derivation salt. + */ + private const ALLOWED_CIPHERS = [ + 'AES-256-GCM' => [SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES], + 'ChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES, SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES], + 'XChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES], + ]; + + /** + * @param string $cipher The cipher to use for encryption and decryption. + * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php + */ + public function __construct( + private readonly string $cipher = 'AES-256-GCM', + ) { + 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 supports hardware-accelerated AES.'); + } + } + + public function encrypt( + string $data, + #[SensitiveParameter] string $key, + string $nounce, + ): string + { + $encrypted = match ($this->cipher) { + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nounce, $key), + 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nounce, $key), + 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nounce, $key), + }; + + if ($encrypted === false) { + throw new EncryptionException('Sodium failure on encryption'); + } + + return $encrypted; + } + + public function decrypt( + string $data, + #[SensitiveParameter] string $key, + string $nounce, + ): string + { + $decrypted = match ($this->cipher) { + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nounce, $key), + 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nounce, $key), + 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nounce, $key), + }; + + if ($decrypted === false) { + throw new EncryptionException('Sodium failure on decryption'); + } + + return $decrypted; + } + + public function getNounceSize(): int + { + return self::ALLOWED_CIPHERS[$this->cipher][0]; + } + + public function getKeySize(): int + { + return self::ALLOWED_CIPHERS[$this->cipher][1]; + } + + public function getTagSize(): int + { + return self::TAG_SIZE; + } +} diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php new file mode 100644 index 0000000..56b88c6 --- /dev/null +++ b/src/Crypt/CipherInterface.php @@ -0,0 +1,26 @@ +nounceSize = $this->cipher->getNounceSize(); + $this->keySize = $this->cipher->getKeySize(); + $this->tagSize = $this->cipher->getTagSize(); + + $this->keyNounceSize = $this->keySize + $this->nounceSize; + $this->encKeyNounceSize = $this->keyNounceSize + $this->tagSize; + $this->prefixSize = $this->keyNounceSize + $this->encKeyNounceSize; + } + + public function encrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '' + ): string { + $keySalt = random_bytes($this->keySize); + $dek = random_bytes($this->keySize); + $dekNounce = random_bytes($this->nounceSize); + $dataNounce = random_bytes($this->nounceSize); + + $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); + $dekEncrypted = $this->cipher->encrypt($dek . $dataNounce, $kek, $dekNounce); + $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNounce); + + // keySalt || dekNounce || cipher(dek + dataNounce) || tag || ciphertext || tag + return $keySalt.$dekNounce.$dekEncrypted . $dataEncrypted; + //return $keySalt.$dekNounce.$dekEncrypted . $dataNounce.$dataEncrypted; + } + + public function decrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '' + ): string { + if (mb_strlen($data, '8bit') < $this->prefixSize) { + throw new EncryptionException('Encrypted data is too short.'); + } + + $keySalt = mb_substr($data, 0, $this->keySize, '8bit'); + $dekNounce = mb_substr($data, $this->keySize, $this->nounceSize, '8bit'); + $encDekWithNounce = mb_substr($data, $this->keyNounceSize, $this->encKeyNounceSize, '8bit'); + $dataEncrypted = mb_substr($data, $this->prefixSize, null, '8bit'); + + $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); + $dekWithNounce = $this->cipher->decrypt($encDekWithNounce, $kek, $dekNounce); + $decrypted = $this->cipher->decrypt($dataEncrypted, mb_substr($dekWithNounce, 0, $this->keySize, '8bit'), mb_substr($dekWithNounce, $this->keySize, null, '8bit')); + + return $decrypted; + } +} diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php new file mode 100644 index 0000000..805e0f8 --- /dev/null +++ b/src/Crypt/Kdf/KdfKey.php @@ -0,0 +1,28 @@ +algorithm, $secret, $keySize, $context, $salt); + } +} diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php new file mode 100644 index 0000000..d1680eb --- /dev/null +++ b/src/Crypt/Kdf/KdfPassword.php @@ -0,0 +1,32 @@ +algorithm, $secret, $salt, $this->iterations, $keySize, true); + + return hash_hkdf($this->algorithm, $key, $keySize, $context); + } +} diff --git a/src/Crypt/KdfInterface.php b/src/Crypt/KdfInterface.php new file mode 100644 index 0000000..256950b --- /dev/null +++ b/src/Crypt/KdfInterface.php @@ -0,0 +1,17 @@ +keySize = $this->cipher->getKeySize(); + $this->nounceSize = $this->cipher->getNounceSize(); + $this->keyNounceSize = $this->keySize + $this->nounceSize; + } + + public function encrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '' + ): string { + $keySalt = random_bytes($this->keySize); + $dataNounce = random_bytes($this->nounceSize); + + $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); + $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNounce); + + // keySalt || nounce || ciphertext || tag + return $keySalt . $dataNounce . $dataEncrypted; + } + + public function decrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '' + ): string { + if (mb_strlen($data, '8bit') < $this->keyNounceSize) { + throw new EncryptionException('Encrypted data is too short.'); + } + + $keySalt = mb_substr($data, 0, $this->keySize, '8bit'); + $dataNounce = mb_substr($data, $this->keySize, $this->nounceSize, '8bit'); + $dataEncrypted = mb_substr($data, $this->keyNounceSize, null, '8bit'); + + $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); + $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNounce); + + return $decrypted; + } +} diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php new file mode 100644 index 0000000..bfc5e43 --- /dev/null +++ b/src/Crypt/VersionedCryptor.php @@ -0,0 +1,113 @@ + Storage for registered cryptors indexed by their version identifier. + */ + private readonly array $cryptors; + + /** + * @param array $cryptors List of cryptors where the key is the version string and the value is a CryptorInterface instance. + * @param string $currentVersion The version identifier to be used for new encryptions. + * @param int $versionSize The fixed byte length of the version prefix. + * + * @throws RuntimeException If the current version is missing or identifiers have invalid length. + */ + public function __construct( + array $cryptors, + private readonly string $currentVersion, + private readonly int $versionSize, + ) { + if ($versionSize < 1) { + throw new RuntimeException('Version size must be greather than 0.'); + } + + $this->cryptors = $this->validateAndNormalize($cryptors); + + if (!isset($this->cryptors[$this->currentVersion])) { + throw new RuntimeException("Current version '{$this->currentVersion}' is not registered."); + } + } + + /** + * {@inheritdoc} + */ + 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 RuntimeException If the version prefix is not recognized or data is malformed. + */ + public function decrypt( + string $data, + #[SensitiveParameter] + string $secret, + string $context = '' + ): string { + if (mb_strlen($data, '8bit') < $this->versionSize) { + throw new RuntimeException('Encrypted data is too short to contain a version identifier.'); + } + + $version = mb_substr($data, 0, $this->versionSize, '8bit'); + $cryptor = $this->cryptors[$version] + ?? throw new RuntimeException('version not found'); + + $payload = mb_substr($data, $this->versionSize, null, '8bit'); + + return $cryptor->decrypt($payload, $secret, $context); + } + + /** + * Validates input array and ensures all version identifiers match the required size. + * + * @param array $cryptors Map of version => cryptor instances. + * @return array Normalized array. + * @throws RuntimeException If validation fails. + */ + 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 (mb_strlen($version, '8bit') !== $this->versionSize) { + throw new RuntimeException("Version identifier '$version' must be exactly {$this->versionSize} bytes."); + } + + $normalized[$version] = $cryptor; + } + + return $normalized; + } +} From 6156063ccb6f1409ed385078b4312e39f7bb8e28 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 11 May 2026 18:18:50 +0700 Subject: [PATCH 02/70] fix style --- src/Crypt/Cipher/OpenSSLCipher.php | 6 ++++-- src/Crypt/Cipher/SodiumCipher.php | 6 ++++-- src/Crypt/CipherInterface.php | 6 ++++-- src/Crypt/Kdf/KdfKey.php | 3 ++- src/Crypt/Kdf/KdfPassword.php | 3 ++- src/Crypt/KdfInterface.php | 3 ++- src/Crypt/VersionedCryptor.php | 2 +- 7 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/Crypt/Cipher/OpenSSLCipher.php b/src/Crypt/Cipher/OpenSSLCipher.php index 652cc4b..fe1c022 100644 --- a/src/Crypt/Cipher/OpenSSLCipher.php +++ b/src/Crypt/Cipher/OpenSSLCipher.php @@ -46,7 +46,8 @@ public function __construct( public function encrypt( string $data, - #[SensitiveParameter] string $key, + #[SensitiveParameter] + string $key, string $nounce, ): string { @@ -61,7 +62,8 @@ public function encrypt( public function decrypt( string $data, - #[SensitiveParameter] string $key, + #[SensitiveParameter] + string $key, string $nounce, ): string { diff --git a/src/Crypt/Cipher/SodiumCipher.php b/src/Crypt/Cipher/SodiumCipher.php index dbdc9bd..98a3ab1 100644 --- a/src/Crypt/Cipher/SodiumCipher.php +++ b/src/Crypt/Cipher/SodiumCipher.php @@ -54,7 +54,8 @@ public function __construct( public function encrypt( string $data, - #[SensitiveParameter] string $key, + #[SensitiveParameter] + string $key, string $nounce, ): string { @@ -73,7 +74,8 @@ public function encrypt( public function decrypt( string $data, - #[SensitiveParameter] string $key, + #[SensitiveParameter] + string $key, string $nounce, ): string { diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index 56b88c6..6c4d901 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -10,13 +10,15 @@ interface CipherInterface { public function encrypt( string $data, - #[SensitiveParameter] string $key, + #[SensitiveParameter] + string $key, string $nounce, ): string; public function decrypt( string $date, - #[SensitiveParameter] string $key, + #[SensitiveParameter] + string $key, string $nounce, ): string; diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 805e0f8..e605c56 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -17,7 +17,8 @@ public function __construct( } public function createKey( - #[SensitiveParameter] string $secret, + #[SensitiveParameter] + string $secret, int $keySize, string $context, string $salt, diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index d1680eb..893e54d 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -19,7 +19,8 @@ public function __construct( } public function createKey( - #[SensitiveParameter] string $secret, + #[SensitiveParameter] + string $secret, int $keySize, string $context, string $salt, diff --git a/src/Crypt/KdfInterface.php b/src/Crypt/KdfInterface.php index 256950b..76293bd 100644 --- a/src/Crypt/KdfInterface.php +++ b/src/Crypt/KdfInterface.php @@ -9,7 +9,8 @@ interface KdfInterface { public function createKey( - #[SensitiveParameter] string $secret, + #[SensitiveParameter] + string $secret, int $keySize, string $context, string $salt, diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index bfc5e43..ec41ea7 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -74,7 +74,7 @@ public function decrypt( if (mb_strlen($data, '8bit') < $this->versionSize) { throw new RuntimeException('Encrypted data is too short to contain a version identifier.'); } - + $version = mb_substr($data, 0, $this->versionSize, '8bit'); $cryptor = $this->cryptors[$version] ?? throw new RuntimeException('version not found'); From c5bd2acb23577480a471dc5152e1deff531b446a Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 11 May 2026 19:22:25 +0700 Subject: [PATCH 03/70] add readonly --- src/Crypt/Cipher/OpenSSLCipher.php | 4 ++-- src/Crypt/Cipher/SodiumCipher.php | 4 ++-- src/Crypt/EnvelopeCryptor.php | 6 +++--- src/Crypt/Kdf/KdfKey.php | 2 +- src/Crypt/Kdf/KdfPassword.php | 2 +- src/Crypt/SessionCryptor.php | 6 +++--- src/Crypt/VersionedCryptor.php | 8 ++++---- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Crypt/Cipher/OpenSSLCipher.php b/src/Crypt/Cipher/OpenSSLCipher.php index fe1c022..f2cdc13 100644 --- a/src/Crypt/Cipher/OpenSSLCipher.php +++ b/src/Crypt/Cipher/OpenSSLCipher.php @@ -9,7 +9,7 @@ use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; -final class OpenSSLCipher implements AeadCipherInterface +final readonly class OpenSSLCipher implements AeadCipherInterface { private const TAG_SIZE = 16; @@ -34,7 +34,7 @@ final class OpenSSLCipher implements AeadCipherInterface * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php */ public function __construct( - private readonly string $cipher = 'AES-256-GCM', + private string $cipher = 'AES-256-GCM', ) { if (!extension_loaded('openssl')) { throw new RuntimeException('Encryption requires the OpenSSL PHP extension.'); diff --git a/src/Crypt/Cipher/SodiumCipher.php b/src/Crypt/Cipher/SodiumCipher.php index 98a3ab1..ffa8f28 100644 --- a/src/Crypt/Cipher/SodiumCipher.php +++ b/src/Crypt/Cipher/SodiumCipher.php @@ -14,7 +14,7 @@ sodium_crypto_aead_aes256gcm_is_available, sodium_crypto_aead_aes256gcm_encrypt; -final class SodiumCipher implements AeadCipherInterface +final readonly class SodiumCipher implements AeadCipherInterface { private const TAG_SIZE = 16; @@ -39,7 +39,7 @@ final class SodiumCipher implements AeadCipherInterface * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php */ public function __construct( - private readonly string $cipher = 'AES-256-GCM', + private string $cipher = 'AES-256-GCM', ) { if (!extension_loaded('sodium')) { throw new RuntimeException('Encryption requires the Sodium PHP extension.'); diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index de13b91..df9c1a4 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -9,7 +9,7 @@ mb_substr, random_bytes; -final class EnvelopeCryptor implements CryptorInterface +final readonly class EnvelopeCryptor implements CryptorInterface { private int $nounceSize; private int $keySize; @@ -24,8 +24,8 @@ final class EnvelopeCryptor implements CryptorInterface * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php */ public function __construct( - private readonly AeadCipherInterface $cipher, - private readonly KdfInterface $kdf, + private AeadCipherInterface $cipher, + private KdfInterface $kdf, ) { $this->nounceSize = $this->cipher->getNounceSize(); $this->keySize = $this->cipher->getKeySize(); diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index e605c56..8ee9595 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -9,7 +9,7 @@ use function hash_hkdf; -final class KdfKey implements KdfInterface +final readonly class KdfKey implements KdfInterface { public function __construct( private string $algorithm = 'sha256', diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 893e54d..7d13255 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -10,7 +10,7 @@ hash_hkdf, hash_pbkdf2; -final class KdfPassword implements KdfInterface +final readonly class KdfPassword implements KdfInterface { public function __construct( private string $algorithm = 'sha256', diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index 513c5c9..c0ddf32 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -9,7 +9,7 @@ mb_substr, random_bytes; -final class SessionCryptor implements CryptorInterface +final readonly class SessionCryptor implements CryptorInterface { private int $keySize; private int $nounceSize; @@ -21,8 +21,8 @@ final class SessionCryptor implements CryptorInterface * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php */ public function __construct( - private readonly CipherInterface $cipher, - private readonly KdfInterface $kdf, + private CipherInterface $cipher, + private KdfInterface $kdf, ) { $this->keySize = $this->cipher->getKeySize(); $this->nounceSize = $this->cipher->getNounceSize(); diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index ec41ea7..738a948 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -16,12 +16,12 @@ * This allows for seamless migration between different encryption algorithms or configurations. * Each encrypted message is prefixed with a version identifier of a fixed size. */ -final class VersionedCryptor implements CryptorInterface +final readonly class VersionedCryptor implements CryptorInterface { /** * @var array Storage for registered cryptors indexed by their version identifier. */ - private readonly array $cryptors; + private array $cryptors; /** * @param array $cryptors List of cryptors where the key is the version string and the value is a CryptorInterface instance. @@ -32,8 +32,8 @@ final class VersionedCryptor implements CryptorInterface */ public function __construct( array $cryptors, - private readonly string $currentVersion, - private readonly int $versionSize, + private string $currentVersion, + private int $versionSize, ) { if ($versionSize < 1) { throw new RuntimeException('Version size must be greather than 0.'); From 90205a7e889723d3d8ccc4305f7e46264c97474d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 11 May 2026 20:11:44 +0700 Subject: [PATCH 04/70] update doc --- src/Crypt/AeadCipherInterface.php | 6 ++++ src/Crypt/Cipher/OpenSSLCipher.php | 43 +++++++++++++++---------- src/Crypt/Cipher/SodiumCipher.php | 50 ++++++++++++++++++------------ src/Crypt/CipherInterface.php | 39 ++++++++++++++++++++--- src/Crypt/EncryptionException.php | 3 ++ src/Crypt/EnvelopeCryptor.php | 36 ++++++++++----------- src/Crypt/SessionCryptor.php | 24 +++++++------- 7 files changed, 132 insertions(+), 69 deletions(-) diff --git a/src/Crypt/AeadCipherInterface.php b/src/Crypt/AeadCipherInterface.php index ad9ff8c..9cf8d09 100644 --- a/src/Crypt/AeadCipherInterface.php +++ b/src/Crypt/AeadCipherInterface.php @@ -4,7 +4,13 @@ namespace Yiisoft\Security\Crypt; +/** + * Interface for authenticated encryption with associated data (AEAD) ciphers. + */ interface AeadCipherInterface extends CipherInterface { + /** + * @return int Tag size in bytes. + */ public function getTagSize(): int; } diff --git a/src/Crypt/Cipher/OpenSSLCipher.php b/src/Crypt/Cipher/OpenSSLCipher.php index f2cdc13..a862e24 100644 --- a/src/Crypt/Cipher/OpenSSLCipher.php +++ b/src/Crypt/Cipher/OpenSSLCipher.php @@ -9,19 +9,29 @@ use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; +/** + * AEAD cipher implementation using OpenSSL extension. + * Supports only AES-GCM family (128, 192, 256) with 16-byte authentication tags. + * + * @psalm-immutable + */ final readonly class OpenSSLCipher implements AeadCipherInterface { + /** + * Authentication tag size in bytes (always 16 for GCM). + */ private const TAG_SIZE = 16; /** - * @var array[] Look-up table of block sizes and key sizes for each supported OpenSSL cipher. + * Look-up table of allowed OpenSSL ciphers. * - * In each element, the key is one of the ciphers supported by OpenSSL {@see openssl_get_cipher_methods()}. - * The value is an array of two integers, the first is the cipher's block size in bytes and the second is - * the key size in bytes. + * Each entry maps a cipher name to: + * - Nonce size (bytes) – used as IV length. + * - Key size (bytes) – required key length. * - * > Note: Yii's encryption protocol uses the same size for cipher key, HMAC signature key and key - * derivation salt. + * @var array + * + * @psalm-var array */ private const ALLOWED_CIPHERS = [ 'AES-128-GCM' => [12, 16], @@ -30,8 +40,9 @@ ]; /** - * @param string $cipher The cipher to use for encryption and decryption. - * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php + * @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-GCM', @@ -48,13 +59,13 @@ public function encrypt( string $data, #[SensitiveParameter] string $key, - string $nounce, + string $nonce, ): string { - $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $nounce, $tag, '', self::TAG_SIZE); + $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $nonce, $tag, '', self::TAG_SIZE); if ($encrypted === false) { - throw new EncryptionException('Sodium failure on encryption'); + throw new EncryptionException('OpenSSL failure on encryption: ' . openssl_error_string()); } return $encrypted . $tag; @@ -64,22 +75,22 @@ public function decrypt( string $data, #[SensitiveParameter] string $key, - string $nounce, + string $nonce, ): string { $tag = mb_substr($data, -self::TAG_SIZE, null, '8bit'); - $encrypted = mb_substr($data, 0, -self::TAG_SIZE, '8bit'); + $ciphertext = mb_substr($data, 0, -self::TAG_SIZE, '8bit'); - $decrypted = openssl_decrypt($encrypted, $this->cipher, $key, OPENSSL_RAW_DATA, $nounce, $tag); + $decrypted = openssl_decrypt($ciphertext, $this->cipher, $key, OPENSSL_RAW_DATA, $nonce, $tag); if ($decrypted === false) { - throw new EncryptionException('Sodium failure on decryption'); + throw new EncryptionException('OpenSSL failure on decryption: ' . openssl_error_string()); } return $decrypted; } - public function getNounceSize(): int + public function getNonceSize(): int { return self::ALLOWED_CIPHERS[$this->cipher][0]; } diff --git a/src/Crypt/Cipher/SodiumCipher.php b/src/Crypt/Cipher/SodiumCipher.php index ffa8f28..35d32ed 100644 --- a/src/Crypt/Cipher/SodiumCipher.php +++ b/src/Crypt/Cipher/SodiumCipher.php @@ -12,21 +12,32 @@ array_key_exists, extension_loaded, sodium_crypto_aead_aes256gcm_is_available, - sodium_crypto_aead_aes256gcm_encrypt; + sodium_crypto_aead_aes256gcm_encrypt, + sodium_crypto_aead_aes256gcm_decrypt, + sodium_crypto_aead_chacha20poly1305_ietf_encrypt, + sodium_crypto_aead_chacha20poly1305_ietf_decrypt, + sodium_crypto_aead_xchacha20poly1305_ietf_encrypt, + sodium_crypto_aead_xchacha20poly1305_ietf_decrypt; +/** + * AEAD cipher implementation using libsodium extension. + * Supports AES-256-GCM (hardware accelerated), ChaCha20-Poly1305-IETF, and XChaCha20-Poly1305-IETF. + * + * @psalm-immutable + */ final readonly class SodiumCipher implements AeadCipherInterface { + /** + * Authentication tag size in bytes (always 16 for these AEAD modes). + */ private const TAG_SIZE = 16; /** - * @var array[] Look-up table of block sizes and key sizes for each supported OpenSSL cipher. + * Look-up table of allowed sodium ciphers with their nonce and key sizes. * - * In each element, the key is one of the ciphers supported by OpenSSL {@see openssl_get_cipher_methods()}. - * The value is an array of two integers, the first is the cipher's block size in bytes and the second is - * the key size in bytes. + * @var array * - * > Note: Yii's encryption protocol uses the same size for cipher key, HMAC signature key and key - * derivation salt. + * @psalm-var array */ private const ALLOWED_CIPHERS = [ 'AES-256-GCM' => [SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES], @@ -35,8 +46,9 @@ ]; /** - * @param string $cipher The cipher to use for encryption and decryption. - * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php + * @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 = 'AES-256-GCM', @@ -48,7 +60,7 @@ public function __construct( 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 supports hardware-accelerated AES.'); + throw new RuntimeException($cipher . ' requires hardware that supports hardware-accelerated AES.'); } } @@ -56,13 +68,13 @@ public function encrypt( string $data, #[SensitiveParameter] string $key, - string $nounce, + string $nonce, ): string { $encrypted = match ($this->cipher) { - 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nounce, $key), - 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nounce, $key), - 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nounce, $key), + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nonce, $key), + 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nonce, $key), + 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nonce, $key), }; if ($encrypted === false) { @@ -76,13 +88,13 @@ public function decrypt( string $data, #[SensitiveParameter] string $key, - string $nounce, + string $nonce, ): string { $decrypted = match ($this->cipher) { - 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nounce, $key), - 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nounce, $key), - 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nounce, $key), + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nonce, $key), + 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nonce, $key), + 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nonce, $key), }; if ($decrypted === false) { @@ -92,7 +104,7 @@ public function decrypt( return $decrypted; } - public function getNounceSize(): int + public function getNonceSize(): int { return self::ALLOWED_CIPHERS[$this->cipher][0]; } diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index 6c4d901..5455ec5 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -6,23 +6,54 @@ use SensitiveParameter; +/** + * Base interface for symmetric encryption ciphers. + */ interface CipherInterface { + /** + * Encrypts the provided data with the given key and nonce. + * + * @param string $data Plaintext to encrypt. + * @param string $key Secret encryption key (sensitive). + * @param string $nonce Initialization vector or nonce. + * + * @return string Ciphertext. + * + * @throws EncryptionException If encryption fails. + */ public function encrypt( string $data, #[SensitiveParameter] string $key, - string $nounce, + string $nonce, ): string; + /** + * Decrypts the provided ciphertext with the given key and nonce. + * + * @param string $data Ciphertext to decrypt. + * @param string $key Secret encryption key (sensitive). + * @param string $nonce Nonce used during encryption. + * + * @return string Decrypted plaintext. + * + * @throws EncryptionException If decryption fails. + */ public function decrypt( - string $date, + string $data, #[SensitiveParameter] string $key, - string $nounce, + string $nonce, ): string; - public function getNounceSize(): int; + /** + * @return int Nonce size in bytes + */ + public function getNonceSize(): int; + /** + * @return int Key size in bytes. + */ public function getKeySize(): int; } diff --git a/src/Crypt/EncryptionException.php b/src/Crypt/EncryptionException.php index 4efd96b..b9130a2 100644 --- a/src/Crypt/EncryptionException.php +++ b/src/Crypt/EncryptionException.php @@ -6,6 +6,9 @@ use RuntimeException; +/** + * Exception thrown when encryption or decryption fails. + */ final class EncryptionException extends RuntimeException { } diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index df9c1a4..b0d1a95 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -11,12 +11,12 @@ final readonly class EnvelopeCryptor implements CryptorInterface { - private int $nounceSize; + private int $nonceSize; private int $keySize; private int $tagSize; - private int $keyNounceSize; - private int $encKeyNounceSize; + private int $keyNonceSize; + private int $encKeyNonceSize; private int $prefixSize; /** @@ -27,13 +27,13 @@ public function __construct( private AeadCipherInterface $cipher, private KdfInterface $kdf, ) { - $this->nounceSize = $this->cipher->getNounceSize(); + $this->nonceSize = $this->cipher->getNonceSize(); $this->keySize = $this->cipher->getKeySize(); $this->tagSize = $this->cipher->getTagSize(); - $this->keyNounceSize = $this->keySize + $this->nounceSize; - $this->encKeyNounceSize = $this->keyNounceSize + $this->tagSize; - $this->prefixSize = $this->keyNounceSize + $this->encKeyNounceSize; + $this->keyNonceSize = $this->keySize + $this->nonceSize; + $this->encKeyNonceSize = $this->keyNonceSize + $this->tagSize; + $this->prefixSize = $this->keyNonceSize + $this->encKeyNonceSize; } public function encrypt( @@ -44,16 +44,16 @@ public function encrypt( ): string { $keySalt = random_bytes($this->keySize); $dek = random_bytes($this->keySize); - $dekNounce = random_bytes($this->nounceSize); - $dataNounce = random_bytes($this->nounceSize); + $dekNonce = random_bytes($this->nonceSize); + $dataNonce = random_bytes($this->nonceSize); $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dekEncrypted = $this->cipher->encrypt($dek . $dataNounce, $kek, $dekNounce); - $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNounce); + $dekEncrypted = $this->cipher->encrypt($dek . $dataNonce, $kek, $dekNonce); + $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); - // keySalt || dekNounce || cipher(dek + dataNounce) || tag || ciphertext || tag - return $keySalt.$dekNounce.$dekEncrypted . $dataEncrypted; - //return $keySalt.$dekNounce.$dekEncrypted . $dataNounce.$dataEncrypted; + // keySalt || dekNonce || cipher(dek + dataNonce) || tag || ciphertext || tag + return $keySalt.$dekNonce.$dekEncrypted . $dataEncrypted; + //return $keySalt.$dekNonce.$dekEncrypted . $dataNonce.$dataEncrypted; } public function decrypt( @@ -67,13 +67,13 @@ public function decrypt( } $keySalt = mb_substr($data, 0, $this->keySize, '8bit'); - $dekNounce = mb_substr($data, $this->keySize, $this->nounceSize, '8bit'); - $encDekWithNounce = mb_substr($data, $this->keyNounceSize, $this->encKeyNounceSize, '8bit'); + $dekNonce = mb_substr($data, $this->keySize, $this->nonceSize, '8bit'); + $encDekWithNonce = mb_substr($data, $this->keyNonceSize, $this->encKeyNonceSize, '8bit'); $dataEncrypted = mb_substr($data, $this->prefixSize, null, '8bit'); $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dekWithNounce = $this->cipher->decrypt($encDekWithNounce, $kek, $dekNounce); - $decrypted = $this->cipher->decrypt($dataEncrypted, mb_substr($dekWithNounce, 0, $this->keySize, '8bit'), mb_substr($dekWithNounce, $this->keySize, null, '8bit')); + $dekWithNonce = $this->cipher->decrypt($encDekWithNonce, $kek, $dekNonce); + $decrypted = $this->cipher->decrypt($dataEncrypted, mb_substr($dekWithNonce, 0, $this->keySize, '8bit'), mb_substr($dekWithNonce, $this->keySize, null, '8bit')); return $decrypted; } diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index c0ddf32..e32b301 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -12,9 +12,9 @@ final readonly class SessionCryptor implements CryptorInterface { private int $keySize; - private int $nounceSize; + private int $nonceSize; - private int $keyNounceSize; + private int $keyNonceSize; /** * @param string $cipher The cipher to use for encryption and decryption. @@ -25,8 +25,8 @@ public function __construct( private KdfInterface $kdf, ) { $this->keySize = $this->cipher->getKeySize(); - $this->nounceSize = $this->cipher->getNounceSize(); - $this->keyNounceSize = $this->keySize + $this->nounceSize; + $this->nonceSize = $this->cipher->getNonceSize(); + $this->keyNonceSize = $this->keySize + $this->nonceSize; } public function encrypt( @@ -36,13 +36,13 @@ public function encrypt( string $context = '' ): string { $keySalt = random_bytes($this->keySize); - $dataNounce = random_bytes($this->nounceSize); + $dataNonce = random_bytes($this->nonceSize); $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNounce); + $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); - // keySalt || nounce || ciphertext || tag - return $keySalt . $dataNounce . $dataEncrypted; + // keySalt || nonce || ciphertext || tag + return $keySalt . $dataNonce . $dataEncrypted; } public function decrypt( @@ -51,16 +51,16 @@ public function decrypt( string $secret, string $context = '' ): string { - if (mb_strlen($data, '8bit') < $this->keyNounceSize) { + if (mb_strlen($data, '8bit') < $this->keyNonceSize) { throw new EncryptionException('Encrypted data is too short.'); } $keySalt = mb_substr($data, 0, $this->keySize, '8bit'); - $dataNounce = mb_substr($data, $this->keySize, $this->nounceSize, '8bit'); - $dataEncrypted = mb_substr($data, $this->keyNounceSize, null, '8bit'); + $dataNonce = mb_substr($data, $this->keySize, $this->nonceSize, '8bit'); + $dataEncrypted = mb_substr($data, $this->keyNonceSize, null, '8bit'); $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNounce); + $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); return $decrypted; } From be7446374261aa1d0887d47be612380d25751fbd Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 11 May 2026 20:21:35 +0700 Subject: [PATCH 05/70] add kdf doc --- src/Crypt/Kdf/KdfKey.php | 18 ++++++++++++++++++ src/Crypt/Kdf/KdfPassword.php | 22 ++++++++++++++++++++++ src/Crypt/KdfInterface.php | 16 ++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 8ee9595..a04fe60 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -9,6 +9,12 @@ use function hash_hkdf; +/** + * KDF that directly applies HKDF (HMAC-based Key Derivation Function) to the input secret. + * Suitable for deriving additional keys from a high-entropy secret (e.g., another key). + * + * @psalm-immutable + */ final readonly class KdfKey implements KdfInterface { public function __construct( @@ -16,6 +22,18 @@ public function __construct( ) { } + /** + * Derives a key using HKDF (RFC 5869). + * + * @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 HKDF info). + * @param string $salt Salt value (optional, but recommended for stronger extraction). + * + * @return string Derived key (raw binary). + * + * @throws RuntimeException If HKDF fails. + */ public function createKey( #[SensitiveParameter] string $secret, diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 7d13255..1f24280 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -10,6 +10,12 @@ hash_hkdf, hash_pbkdf2; +/** + * KDF that first applies PBKDF2 to the input password, + * then applies HKDF to the result. Suitable for deriving cryptographic keys from low-entropy passwords. + * + * @psalm-immutable + */ final readonly class KdfPassword implements KdfInterface { public function __construct( @@ -18,6 +24,22 @@ public function __construct( ) { } + /** + * Derives a key from a password using PBKDF2 + HKDF. + * + * Steps: + * 1. PBKDF2 expands the password and salt into an intermediate key. + * 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, at least 16 bytes). + * + * @return string Derived key (raw binary). + * + * @throws RuntimeException If PBKDF2 or HKDF fails. + */ public function createKey( #[SensitiveParameter] string $secret, diff --git a/src/Crypt/KdfInterface.php b/src/Crypt/KdfInterface.php index 76293bd..915a35b 100644 --- a/src/Crypt/KdfInterface.php +++ b/src/Crypt/KdfInterface.php @@ -6,8 +6,24 @@ use SensitiveParameter; +/** + * Interface for key derivation functions (KDF). + * Used to derive cryptographic keys from a secret (password or raw key material). + */ interface KdfInterface { + /** + * Derives a key of the specified size from the given secret. + * + * @param string $secret The input secret (password or raw key material). Sensitive parameter. + * @param int $keySize Desired key length in bytes. + * @param string $context Application-specific context string (used as HKDF info). + * @param string $salt Salt value (must be random and unique for each derivation). + * + * @return string The derived key (raw binary string). + * + * @throws \RuntimeException If key derivation fails. + */ public function createKey( #[SensitiveParameter] string $secret, From 1e2dbd0dd243079ca18c163414c1cb6446ca67bb Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 11 May 2026 20:39:38 +0700 Subject: [PATCH 06/70] add doc to the cryptors --- src/Crypt/CryptorInterface.php | 39 ++++++++++++++---------------- src/Crypt/EnvelopeCryptor.php | 26 ++++++++++++++++++-- src/Crypt/SessionCryptor.php | 21 +++++++++++++++-- src/Crypt/VersionedCryptor.php | 43 +++++++++++++++++++--------------- 4 files changed, 84 insertions(+), 45 deletions(-) diff --git a/src/Crypt/CryptorInterface.php b/src/Crypt/CryptorInterface.php index 29c861c..209af00 100644 --- a/src/Crypt/CryptorInterface.php +++ b/src/Crypt/CryptorInterface.php @@ -6,23 +6,22 @@ use SensitiveParameter; +/** + * Interface for high-level encryption/decryption with key derivation. + */ interface CryptorInterface { /** - * Encrypts data. + * Encrypts the given data using the secret and context string. * - * @param string $data data to be encrypted - * @param bool $passwordBased set true to use password-based key derivation - * @param string $secret the encryption password or key - * @param string $info context/application specific information, e.g. a user ID - * See [RFC 5869 Section 3.2](https://tools.ietf.org/html/rfc5869#section-3.2) for more details. + * @param string $data Plaintext to encrypt. + * @param string $secret Password or raw key (sensitive). + * @param string $context Application-specific context (used in key derivation). * - * @throws \RuntimeException on OpenSSL not loaded - * @throws \Exception on OpenSSL error + * @return string Encrypted payload (includes nonce, salt, authentication tag, etc.). * - * @return string the encrypted data as byte string - * - * @see decrypt() + * @throws EncryptionException If encryption fails. + * @throws \RuntimeException If required PHP extension is missing. */ public function encrypt( string $data, @@ -32,20 +31,16 @@ public function encrypt( ): string; /** - * Decrypts data. - * - * @param string $data encrypted data to be decrypted. - * @param bool $passwordBased set true to use password-based key derivation - * @param string $secret the decryption password or key - * @param string $info context/application specific information, @see encrypt() + * Decrypts the given data using the secret and context string. * - * @throws \RuntimeException on OpenSSL not loaded - * @throws \Exception on OpenSSL errors - * @throws AuthenticationException on authentication failure + * @param string $data Encrypted payload to decrypt. + * @param string $secret Password or raw key (sensitive). + * @param string $context Application-specific context (must match the one used for encryption). * - * @return string the decrypted data + * @return string Decrypted plaintext. * - * @see encrypt() + * @throws EncryptionException If decryption fails. + * @throws \RuntimeException If required PHP extension is missing or data is malformed. */ public function decrypt( string $data, diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index b0d1a95..43ef9dc 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -9,6 +9,16 @@ mb_substr, random_bytes; +/** + * Envelope encryption (key wrapping) using a KDF to derive a Key Encryption Key (KEK) + * and a random Data Encryption Key (DEK). The DEK is encrypted with the KEK and stored + * together with the ciphertext. + * + * This scheme enables secure handling of long‑term secrets: the DEK is fresh for each + * encryption, and the KEK never touches the actual data payload. + * + * @psalm-immutable + */ final readonly class EnvelopeCryptor implements CryptorInterface { private int $nonceSize; @@ -20,8 +30,8 @@ private int $prefixSize; /** - * @param string $cipher The cipher to use for encryption and decryption. - * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php + * @param AeadCipherInterface $cipher AEAD cipher (e.g., AES-256-GCM) + * @param KdfInterface $kdf Key derivation function (used to derive KEK from secret) */ public function __construct( private AeadCipherInterface $cipher, @@ -36,6 +46,13 @@ public function __construct( $this->prefixSize = $this->keyNonceSize + $this->encKeyNonceSize; } + /** + * {@inheritdoc} + * + * Structure: keySalt (keySize) || dekNonce (nonceSize) || + * encrypted(dek || dataNonce) (keyNonceSize + tagSize) || + * encrypted(data) (variable + tag) + */ public function encrypt( string $data, #[SensitiveParameter] @@ -56,6 +73,11 @@ public function encrypt( //return $keySalt.$dekNonce.$dekEncrypted . $dataNonce.$dataEncrypted; } + /** + * {@inheritdoc} + * + * @throws EncryptionException If decryption fails. + */ public function decrypt( string $data, #[SensitiveParameter] diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index e32b301..e726f82 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -9,6 +9,13 @@ mb_substr, random_bytes; +/** + * Session‑oriented encryption (single key derived per message, no key wrapping). + * A fresh data encryption key (DEK) is derived from the secret and a random salt. + * This is suitable for encrypting large amounts of data in a single session. + * + * @psalm-immutable + */ final readonly class SessionCryptor implements CryptorInterface { private int $keySize; @@ -17,8 +24,8 @@ private int $keyNonceSize; /** - * @param string $cipher The cipher to use for encryption and decryption. - * @param string Hash algorithm for key derivation. Recommend sha256, sha384 or sha512. @see https://php.net/manual/en/function.hash-algos.php + * @param CipherInterface $cipher Low‑level cipher + * @param KdfInterface $kdf Key derivation function */ public function __construct( private CipherInterface $cipher, @@ -29,6 +36,11 @@ public function __construct( $this->keyNonceSize = $this->keySize + $this->nonceSize; } + /** + * {@inheritdoc} + * + * Structure: keySalt || nonce || ciphertext (with tag for AEAD ciphers) + */ public function encrypt( string $data, #[SensitiveParameter] @@ -45,6 +57,11 @@ public function encrypt( return $keySalt . $dataNonce . $dataEncrypted; } + /** + * {@inheritdoc} + * + * @throws EncryptionException If decryption fails. + */ public function decrypt( string $data, #[SensitiveParameter] diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 738a948..1d87843 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -11,10 +11,11 @@ mb_substr; /** - * VersionedCryptor provides a wrapper for multiple cryptors, identifying them by a version prefix. - * - * This allows for seamless migration between different encryption algorithms or configurations. - * Each encrypted message is prefixed with a version identifier of a fixed size. + * VersionedCryptor wraps multiple cryptors and adds a version prefix to the ciphertext. + * This enables seamless migration between different encryption algorithms or key lengths. + * Each encrypted message begins with a fixed‑length version identifier. + * + * @psalm-immutable */ final readonly class VersionedCryptor implements CryptorInterface { @@ -23,12 +24,12 @@ */ private array $cryptors; - /** - * @param array $cryptors List of cryptors where the key is the version string and the value is a CryptorInterface instance. - * @param string $currentVersion The version identifier to be used for new encryptions. - * @param int $versionSize The fixed byte length of the version prefix. - * - * @throws RuntimeException If the current version is missing or identifiers have invalid length. + /** + * @param array $cryptors List of cryptors indexed by version string. + * @param string $currentVersion Version identifier used for new encryptions. + * @param int $versionSize Fixed byte length of the version prefix (must be >=1). + * + * @throws RuntimeException If validation fails or current version is not registered. */ public function __construct( array $cryptors, @@ -36,7 +37,7 @@ public function __construct( private int $versionSize, ) { if ($versionSize < 1) { - throw new RuntimeException('Version size must be greather than 0.'); + throw new RuntimeException('Version size must be greater than 0.'); } $this->cryptors = $this->validateAndNormalize($cryptors); @@ -48,6 +49,8 @@ public function __construct( /** * {@inheritdoc} + * + * @throws RuntimeException If encryption fails. */ public function encrypt( string $data, @@ -62,8 +65,9 @@ public function encrypt( /** * {@inheritdoc} - * - * @throws RuntimeException If the version prefix is not recognized or data is malformed. + * + * @throws RuntimeException If the version prefix cannot be read or no cryptor matches. + * @throws EncryptionException If decryption fails . */ public function decrypt( string $data, @@ -72,7 +76,7 @@ public function decrypt( string $context = '' ): string { if (mb_strlen($data, '8bit') < $this->versionSize) { - throw new RuntimeException('Encrypted data is too short to contain a version identifier.'); + throw new EncryptionException('Encrypted data is too short to contain a version identifier.'); } $version = mb_substr($data, 0, $this->versionSize, '8bit'); @@ -85,11 +89,12 @@ public function decrypt( } /** - * Validates input array and ensures all version identifiers match the required size. - * - * @param array $cryptors Map of version => cryptor instances. - * @return array Normalized array. - * @throws RuntimeException If validation fails. + * Validates the input array, normalises keys to strings, + * and ensures each version identifier has exactly `$versionSize` bytes. + * + * @param array $cryptors Raw input mapping. + * @return array Normalised array. + * @throws RuntimeException On validation error. */ private function validateAndNormalize(array $cryptors): array { From 395e49751437af7c1798c67a53a90f78d77713a8 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 12 May 2026 01:38:15 +0700 Subject: [PATCH 07/70] replace mb_substr with StringHelper --- src/Crypt/Cipher/OpenSSLCipher.php | 5 +++-- src/Crypt/EnvelopeCryptor.php | 16 ++++++++++------ src/Crypt/SessionCryptor.php | 8 ++++---- src/Crypt/VersionedCryptor.php | 8 ++++---- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/Crypt/Cipher/OpenSSLCipher.php b/src/Crypt/Cipher/OpenSSLCipher.php index a862e24..9136c82 100644 --- a/src/Crypt/Cipher/OpenSSLCipher.php +++ b/src/Crypt/Cipher/OpenSSLCipher.php @@ -8,6 +8,7 @@ use SensitiveParameter; use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; +use Yiisoft\Strings\StringHelper; /** * AEAD cipher implementation using OpenSSL extension. @@ -78,8 +79,8 @@ public function decrypt( string $nonce, ): string { - $tag = mb_substr($data, -self::TAG_SIZE, null, '8bit'); - $ciphertext = mb_substr($data, 0, -self::TAG_SIZE, '8bit'); + $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, $nonce, $tag); diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index 43ef9dc..c45e0ed 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -5,8 +5,8 @@ namespace Yiisoft\Security\Crypt; use SensitiveParameter; +use Yiisoft\Strings\StringHelper; use function - mb_substr, random_bytes; /** @@ -88,14 +88,18 @@ public function decrypt( throw new EncryptionException('Encrypted data is too short.'); } - $keySalt = mb_substr($data, 0, $this->keySize, '8bit'); - $dekNonce = mb_substr($data, $this->keySize, $this->nonceSize, '8bit'); - $encDekWithNonce = mb_substr($data, $this->keyNonceSize, $this->encKeyNonceSize, '8bit'); - $dataEncrypted = mb_substr($data, $this->prefixSize, null, '8bit'); + $keySalt = StringHelper::byteSubstring($data, 0, $this->keySize); + $dekNonce = StringHelper::byteSubstring($data, $this->keySize, $this->nonceSize); + $encDekWithNonce = StringHelper::byteSubstring($data, $this->keyNonceSize, $this->encKeyNonceSize); + $dataEncrypted = StringHelper::byteSubstring($data, $this->prefixSize); $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); $dekWithNonce = $this->cipher->decrypt($encDekWithNonce, $kek, $dekNonce); - $decrypted = $this->cipher->decrypt($dataEncrypted, mb_substr($dekWithNonce, 0, $this->keySize, '8bit'), mb_substr($dekWithNonce, $this->keySize, null, '8bit')); + + $dek = StringHelper::byteSubstring($dekWithNonce, 0, $this->keySize); + $dataNonce = StringHelper::byteSubstring($dekWithNonce, $this->keySize); + + $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); return $decrypted; } diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index e726f82..06e31a0 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -5,8 +5,8 @@ namespace Yiisoft\Security\Crypt; use SensitiveParameter; +use Yiisoft\Strings\StringHelper; use function - mb_substr, random_bytes; /** @@ -72,9 +72,9 @@ public function decrypt( throw new EncryptionException('Encrypted data is too short.'); } - $keySalt = mb_substr($data, 0, $this->keySize, '8bit'); - $dataNonce = mb_substr($data, $this->keySize, $this->nonceSize, '8bit'); - $dataEncrypted = mb_substr($data, $this->keyNonceSize, null, '8bit'); + $keySalt = StringHelper::byteSubstring($data, 0, $this->keySize); + $dataNonce = StringHelper::byteSubstring($data, $this->keySize, $this->nonceSize); + $dataEncrypted = StringHelper::byteSubstring($data, $this->keyNonceSize); $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 1d87843..5f2a450 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -6,9 +6,9 @@ use RuntimeException; use SensitiveParameter; +use Yiisoft\Strings\StringHelper; use function - mb_strlen, - mb_substr; + mb_strlen; /** * VersionedCryptor wraps multiple cryptors and adds a version prefix to the ciphertext. @@ -79,11 +79,11 @@ public function decrypt( throw new EncryptionException('Encrypted data is too short to contain a version identifier.'); } - $version = mb_substr($data, 0, $this->versionSize, '8bit'); + $version = StringHelper::byteSubstring($data, 0, $this->versionSize); $cryptor = $this->cryptors[$version] ?? throw new RuntimeException('version not found'); - $payload = mb_substr($data, $this->versionSize, null, '8bit'); + $payload = StringHelper::byteSubstring($data, $this->versionSize); return $cryptor->decrypt($payload, $secret, $context); } From 74ab9ee8bbe5e85b31872e280087d8400d32a0bb Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 12 May 2026 01:44:13 +0700 Subject: [PATCH 08/70] replace mb_strlen --- src/Crypt/EnvelopeCryptor.php | 2 +- src/Crypt/SessionCryptor.php | 2 +- src/Crypt/VersionedCryptor.php | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index c45e0ed..a8a0b9a 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -84,7 +84,7 @@ public function decrypt( string $secret, string $context = '' ): string { - if (mb_strlen($data, '8bit') < $this->prefixSize) { + if (StringHelper::byteLength($data) < $this->prefixSize) { throw new EncryptionException('Encrypted data is too short.'); } diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index 06e31a0..68a70d4 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -68,7 +68,7 @@ public function decrypt( string $secret, string $context = '' ): string { - if (mb_strlen($data, '8bit') < $this->keyNonceSize) { + if (StringHelper::byteLength($data) < $this->keyNonceSize) { throw new EncryptionException('Encrypted data is too short.'); } diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 5f2a450..22d8da3 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -7,8 +7,6 @@ use RuntimeException; use SensitiveParameter; use Yiisoft\Strings\StringHelper; -use function - mb_strlen; /** * VersionedCryptor wraps multiple cryptors and adds a version prefix to the ciphertext. @@ -75,7 +73,7 @@ public function decrypt( string $secret, string $context = '' ): string { - if (mb_strlen($data, '8bit') < $this->versionSize) { + if (StringHelper::byteLength($data) < $this->versionSize) { throw new EncryptionException('Encrypted data is too short to contain a version identifier.'); } @@ -106,7 +104,7 @@ private function validateAndNormalize(array $cryptors): array throw new RuntimeException('All cryptors must implement CryptorInterface.'); } - if (mb_strlen($version, '8bit') !== $this->versionSize) { + if (StringHelper::byteLength($version) !== $this->versionSize) { throw new RuntimeException("Version identifier '$version' must be exactly {$this->versionSize} bytes."); } From ab8899909621e7858630f8a15b3b4cb27610abc3 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 12 May 2026 01:58:15 +0700 Subject: [PATCH 09/70] update config --- config/di.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/di.php b/config/di.php index fecfcdc..b879931 100644 --- a/config/di.php +++ b/config/di.php @@ -39,9 +39,11 @@ '__construct()' => [ 'cryptors' => ReferencesArray::from([ //chr(0b00000001) => SessionCryptor::class, - pack('C', 20) => SessionCryptor::class, + //pack('C', 20) => SessionCryptor::class, + chr(20) => SessionCryptor::class, + chr(200) => EnvelopeCryptor::class, ]), - 'currentVersion' => pack('C', 20), + 'currentVersion' => chr(200), 'versionSize' => 1 ], ], From e3d033e72dd683c0a6cd520cbfe2e39defbacf35 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 12 May 2026 02:14:50 +0700 Subject: [PATCH 10/70] fix config --- config/di.php | 20 +++++++++---------- ...OpenSSLCipher.php => OpenSSLGcmCipher.php} | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) rename src/Crypt/Cipher/{OpenSSLCipher.php => OpenSSLGcmCipher.php} (97%) diff --git a/config/di.php b/config/di.php index b879931..4ceb423 100644 --- a/config/di.php +++ b/config/di.php @@ -5,14 +5,14 @@ use Yiisoft\Definitions\Reference; use Yiisoft\Definitions\ReferencesArray; -use Yiisoft\Security\CryptorInterface; -use Yiisoft\Security\EnvelopeCryptor; -use Yiisoft\Security\SessionCryptor; -use Yiisoft\Security\VersionedCryptor; -use Yiisoft\Security\Cipher\OpenSSLCipher; -use Yiisoft\Security\Cipher\SodiumCipher; -use Yiisoft\Security\Kdf\KdfKey; -use Yiisoft\Security\Kdf\KdfPassword; +use Yiisoft\Security\Crypt\CryptorInterface; +use Yiisoft\Security\Crypt\EnvelopeCryptor; +use Yiisoft\Security\Crypt\SessionCryptor; +use Yiisoft\Security\Crypt\VersionedCryptor; +use Yiisoft\Security\Crypt\Cipher\OpenSSLGcmCipher; +use Yiisoft\Security\Crypt\Cipher\SodiumCipher; +use Yiisoft\Security\Crypt\Kdf\KdfKey; +use Yiisoft\Security\Crypt\Kdf\KdfPassword; /** @var array $params */ @@ -21,7 +21,7 @@ SessionCryptor::class => [ '__construct()' => [ - 'cipher' => Reference::to(OpenSSLCipher::class), + 'cipher' => Reference::to(OpenSSLGcmCipher::class), //'cipher' => Reference::to(SodiumCipher::class), 'kdf' => Reference::to(KdfKey::class), //'kdf' => Reference::to(KdfPassword::class), @@ -30,7 +30,7 @@ EnvelopeCryptor::class => [ '__construct()' => [ - 'cipher' => Reference::to(OpenSSLCipher::class), + 'cipher' => Reference::to(OpenSSLGcmCipher::class), 'kdf' => Reference::to(KdfKey::class), ], ], diff --git a/src/Crypt/Cipher/OpenSSLCipher.php b/src/Crypt/Cipher/OpenSSLGcmCipher.php similarity index 97% rename from src/Crypt/Cipher/OpenSSLCipher.php rename to src/Crypt/Cipher/OpenSSLGcmCipher.php index 9136c82..df84858 100644 --- a/src/Crypt/Cipher/OpenSSLCipher.php +++ b/src/Crypt/Cipher/OpenSSLGcmCipher.php @@ -16,7 +16,7 @@ * * @psalm-immutable */ -final readonly class OpenSSLCipher implements AeadCipherInterface +final readonly class OpenSSLGcmCipher implements AeadCipherInterface { /** * Authentication tag size in bytes (always 16 for GCM). From f3371b9dc5b266c3958d23c1da40e5056ba16da3 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Wed, 13 May 2026 14:56:08 +0700 Subject: [PATCH 11/70] update envelope cryptor add tests --- src/Crypt/EnvelopeCryptor.php | 32 +++-- tests/Crypt/EnvelopeCryptorTest.php | 174 ++++++++++++++++++++++++++++ tests/Crypt/SessionCryptorTest.php | 125 ++++++++++++++++++++ 3 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 tests/Crypt/EnvelopeCryptorTest.php create mode 100644 tests/Crypt/SessionCryptorTest.php diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index a8a0b9a..0cc8b94 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -21,12 +21,13 @@ */ final readonly class EnvelopeCryptor implements CryptorInterface { - private int $nonceSize; private int $keySize; + private int $nonceSize; private int $tagSize; private int $keyNonceSize; - private int $encKeyNonceSize; + private int $encKeySize; + private int $keyNonceEncKeySize; private int $prefixSize; /** @@ -37,21 +38,22 @@ public function __construct( private AeadCipherInterface $cipher, private KdfInterface $kdf, ) { - $this->nonceSize = $this->cipher->getNonceSize(); $this->keySize = $this->cipher->getKeySize(); + $this->nonceSize = $this->cipher->getNonceSize(); $this->tagSize = $this->cipher->getTagSize(); $this->keyNonceSize = $this->keySize + $this->nonceSize; - $this->encKeyNonceSize = $this->keyNonceSize + $this->tagSize; - $this->prefixSize = $this->keyNonceSize + $this->encKeyNonceSize; + $this->encKeySize = $this->keySize + $this->tagSize; + $this->keyNonceEncKeySize = $this->keyNonceSize + $this->encKeySize; + $this->prefixSize = $this->keyNonceEncKeySize + $this->nonceSize; } /** * {@inheritdoc} * * Structure: keySalt (keySize) || dekNonce (nonceSize) || - * encrypted(dek || dataNonce) (keyNonceSize + tagSize) || - * encrypted(data) (variable + tag) + * encrypted(dek) (keySize + tagSize) || + * dataNonce (nonceSize) || encrypted(data) (variable + tagSize) */ public function encrypt( string $data, @@ -65,12 +67,11 @@ public function encrypt( $dataNonce = random_bytes($this->nonceSize); $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dekEncrypted = $this->cipher->encrypt($dek . $dataNonce, $kek, $dekNonce); + $dekEncrypted = $this->cipher->encrypt($dek, $kek, $dekNonce); $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); - // keySalt || dekNonce || cipher(dek + dataNonce) || tag || ciphertext || tag - return $keySalt.$dekNonce.$dekEncrypted . $dataEncrypted; - //return $keySalt.$dekNonce.$dekEncrypted . $dataNonce.$dataEncrypted; + // keySalt || dekNonce || cipherdek || tag || dataNonce || ciphertext || tag + return $keySalt . $dekNonce . $dekEncrypted . $dataNonce . $dataEncrypted; } /** @@ -90,15 +91,12 @@ public function decrypt( $keySalt = StringHelper::byteSubstring($data, 0, $this->keySize); $dekNonce = StringHelper::byteSubstring($data, $this->keySize, $this->nonceSize); - $encDekWithNonce = StringHelper::byteSubstring($data, $this->keyNonceSize, $this->encKeyNonceSize); + $encDek = StringHelper::byteSubstring($data, $this->keyNonceSize, $this->encKeySize); + $dataNonce = StringHelper::byteSubstring($data, $this->keyNonceEncKeySize, $this->nonceSize); $dataEncrypted = StringHelper::byteSubstring($data, $this->prefixSize); $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dekWithNonce = $this->cipher->decrypt($encDekWithNonce, $kek, $dekNonce); - - $dek = StringHelper::byteSubstring($dekWithNonce, 0, $this->keySize); - $dataNonce = StringHelper::byteSubstring($dekWithNonce, $this->keySize); - + $dek = $this->cipher->decrypt($encDek, $kek, $dekNonce); $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); return $decrypted; diff --git a/tests/Crypt/EnvelopeCryptorTest.php b/tests/Crypt/EnvelopeCryptorTest.php new file mode 100644 index 0000000..5dda405 --- /dev/null +++ b/tests/Crypt/EnvelopeCryptorTest.php @@ -0,0 +1,174 @@ +createMocks(); + + $kdf->expects($this->once()) + ->method('createKey') + ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::KEY_SIZE)) + ->willReturn($kek); + + $cipher->expects($this->exactly(2)) + ->method('encrypt') + ->willReturnCallback(function (...$args) use ($plain, $kek) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + // Первый вызов: payload = dek + dataNonce, key = kek, nonce length = nonceSize + [$payload, $key, $nonce] = $args; + $this->assertIsString($payload); + $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($payload)); + $this->assertEquals($kek, $key); + $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); + + //return 'encDekWithTag-------------------'; + return 'encDek--------------------------'; + } + + // Второй вызов: payload = data, key = dek (keySize), nonce length = nonceSize + [$payload, $key, $nonce] = $args; + $this->assertEquals($plain, $payload); + $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($key)); + $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); + + //return 'encDataWithTag'; + return 'encData'; + }); + + $cryptor = new EnvelopeCryptor($cipher, $kdf); + + $result = $cryptor->encrypt($plain, $secret, $context); + $this->assertIsString($result); + $this->assertEquals( + self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('encData'), + //self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('encDekWithTag') + StringHelper::byteLength('encDataWithTag'), + StringHelper::byteLength($result) + ); + + $keySalt = StringHelper::byteSubstring($result, 0, self::KEY_SIZE); + $dekNonce = StringHelper::byteSubstring($result, self::KEY_SIZE, self::NONCE_SIZE); + $encDek = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE, self::KEY_SIZE); + $dataNonce = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE, self::NONCE_SIZE); + $ciphertext = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE + self::NONCE_SIZE); + + $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($keySalt)); + $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dekNonce)); + $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dataNonce)); + $this->assertEquals('encDek--------------------------', $encDek); + $this->assertEquals('encData', $ciphertext); + } + + public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void + { + $plain = 'test-plain-data'; + $secret = 'test-secret'; + $context = 'test-context'; + + $keySalt = str_repeat("\x01", self::KEY_SIZE); + $dekNonce = str_repeat("\x02", self::NONCE_SIZE); + $dek = str_repeat("\x10", self::KEY_SIZE); + $dataNonce = str_repeat("\x20", self::NONCE_SIZE); + $tag = str_repeat("\x30", self::TAG_SIZE); + + $encDekWithTag = $dek . $tag; + $encDataWithTag = $plain . $tag; + + [$cipher, $kdf] = $this->createMocks(); + + $kdf->expects($this->once()) + ->method('createKey') + ->with($secret, self::KEY_SIZE, $context, $keySalt) + ->willReturn('kek'); + + $cipher->expects($this->exactly(2)) + ->method('decrypt') + ->willReturnCallback(function (...$args) use ($plain, $encDekWithTag, $encDataWithTag, $dekNonce, $dek, $dataNonce) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + [$payload, $key, $nonce] = $args; + $this->assertEquals($encDekWithTag, $payload); + $this->assertEquals('kek', $key); + $this->assertEquals($dekNonce, $nonce); + + return $dek; + } + + [$payload, $key, $nonce] = $args; + $this->assertEquals($encDataWithTag, $payload); + $this->assertEquals($dek, $key); + $this->assertEquals($dataNonce, $nonce); + + return $plain; + }); + + $blob = $keySalt . $dekNonce . $encDekWithTag . $dataNonce . $encDataWithTag; + $cryptor = new EnvelopeCryptor($cipher, $kdf); + $decrypted = $cryptor->decrypt($blob, $secret, $context); + $this->assertSame($plain, $decrypted); + } + + public function testEncryptionIsRandomized(): void + { + [$cipher, $kdf] = $this->createMocks(); + + $kdf->method('createKey')->willReturn('dek'); + $cipher->method('encrypt')->willReturn('cipher'); + + $cryptor = new EnvelopeCryptor($cipher, $kdf); + + $res1 = $cryptor->encrypt('data', 'secret'); + $res2 = $cryptor->encrypt('data', 'secret'); + + $this->assertNotSame($res1, $res2); + } + + public function testDecryptThrowsWhenDataTooShort(): void + { + [$cipher, $kdf] = $this->createMocks(); + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('Encrypted data is too short.'); + + $cryptor = new EnvelopeCryptor($cipher, $kdf); + $cryptor->decrypt('short', 'secret'); + } + + private function createMocks(): array + { + $kdf = $this->createMock(KdfInterface::class); + + $cipher = $this->createMock(AeadCipherInterface::class); + $cipher->method('getKeySize')->willReturn(self::KEY_SIZE); + $cipher->method('getNonceSize')->willReturn(self::NONCE_SIZE); + $cipher->method('getTagSize')->willReturn(self::TAG_SIZE); + + return [$cipher, $kdf]; + } +} diff --git a/tests/Crypt/SessionCryptorTest.php b/tests/Crypt/SessionCryptorTest.php new file mode 100644 index 0000000..14228f1 --- /dev/null +++ b/tests/Crypt/SessionCryptorTest.php @@ -0,0 +1,125 @@ +createMocks(); + + //$kdf = $this->createMock(KdfInterface::class); + $kdf->expects($this->once()) + ->method('createKey') + ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::KEY_SIZE)) + ->willReturn('test-derivedkey-123456'); + + // encrypt should be called with data, derived key, and a nonce of nonceSize + $cipher->expects($this->once()) + ->method('encrypt') + ->with($plain, 'test-derivedkey-123456', $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === self::NONCE_SIZE)) + ->willReturn('test-ciphertext-and-tag'); + + $cryptor = new SessionCryptor($cipher, $kdf); + $result = $cryptor->encrypt($plain, $secret, $context); + + // result structure: keySalt || nonce || ciphertext + $this->assertIsString($result); + $this->assertEquals( + self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('test-ciphertext-and-tag'), + StringHelper::byteLength($result) + ); + + $keySalt = StringHelper::byteSubstring($result, 0, self::KEY_SIZE); + $nonce = StringHelper::byteSubstring($result, self::KEY_SIZE, self::NONCE_SIZE); + $ciphertext = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE); + + $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($keySalt)); + $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); + $this->assertEquals('test-ciphertext-and-tag', $ciphertext); + } + + public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void + { + $plain = 'test-plain-data'; + $secret = 'test-secret'; + $context = 'test-context'; + + $keySalt = str_repeat("\x01", self::KEY_SIZE); + $nonce = str_repeat("\x02", self::NONCE_SIZE); + + $encryptedPayload = 'encrypted-by-cipher'; + + [$cipher, $kdf] = $this->createMocks(); + + $kdf->expects($this->once()) + ->method('createKey') + ->with($secret, self::KEY_SIZE, $context, $keySalt) + ->willReturn('dek'); + + $cipher->expects($this->once()) + ->method('decrypt') + ->with($encryptedPayload, 'dek', $nonce) + ->willReturn($plain); + + // Build the encrypted blob: keySalt || nonce || encryptedPayload + $blob = $keySalt . $nonce . $encryptedPayload; + $cryptor = new SessionCryptor($cipher, $kdf); + $decrypted = $cryptor->decrypt($blob, $secret, $context); + $this->assertSame($plain, $decrypted); + } + + public function testEncryptionIsRandomized(): void + { + [$cipher, $kdf] = $this->createMocks(); + + $kdf->method('createKey')->willReturn('dek'); + $cipher->method('encrypt')->willReturn('cipher'); + + $cryptor = new SessionCryptor($cipher, $kdf); + + $res1 = $cryptor->encrypt('data', 'secret'); + $res2 = $cryptor->encrypt('data', 'secret'); + + $this->assertNotSame($res1, $res2); + } + + public function testDecryptThrowsWhenDataTooShort(): void + { + [$cipher, $kdf] = $this->createMocks(); + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('Encrypted data is too short.'); + + $cryptor = new SessionCryptor($cipher, $kdf); + $cryptor->decrypt('short', 'secret'); + } + + private function createMocks(): array + { + $kdf = $this->createMock(KdfInterface::class); + + $cipher = $this->createMock(CipherInterface::class); + $cipher->method('getKeySize')->willReturn(self::KEY_SIZE); + $cipher->method('getNonceSize')->willReturn(self::NONCE_SIZE); + + return [$cipher, $kdf]; + } + +} From 024ab0fde11ad2ba71775d5ce7e5c44f74317d38 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Fri, 15 May 2026 02:07:33 +0700 Subject: [PATCH 12/70] add copher tests --- ...SSLGcmCipher.php => OpenSSLAeadCipher.php} | 43 ++++- ...{SodiumCipher.php => SodiumAeadCipher.php} | 56 +++--- src/Crypt/CipherInterface.php | 9 +- src/Crypt/Kdf/KdfPassword.php | 6 +- tests/Crypt/AbstractCipherCase.php | 159 ++++++++++++++++++ tests/Crypt/OpenSSLAeadCipherTest.php | 55 ++++++ tests/Crypt/SodiumAeadCipherTest.php | 47 ++++++ tests/Crypt/SodiumGcmCipherTest.php | 41 +++++ 8 files changed, 383 insertions(+), 33 deletions(-) rename src/Crypt/Cipher/{OpenSSLGcmCipher.php => OpenSSLAeadCipher.php} (70%) rename src/Crypt/Cipher/{SodiumCipher.php => SodiumAeadCipher.php} (61%) create mode 100644 tests/Crypt/AbstractCipherCase.php create mode 100644 tests/Crypt/OpenSSLAeadCipherTest.php create mode 100644 tests/Crypt/SodiumAeadCipherTest.php create mode 100644 tests/Crypt/SodiumGcmCipherTest.php diff --git a/src/Crypt/Cipher/OpenSSLGcmCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php similarity index 70% rename from src/Crypt/Cipher/OpenSSLGcmCipher.php rename to src/Crypt/Cipher/OpenSSLAeadCipher.php index df84858..91a00c4 100644 --- a/src/Crypt/Cipher/OpenSSLGcmCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -9,6 +9,12 @@ use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Strings\StringHelper; +use function + array_key_exists, + extension_loaded, + openssl_decrypt, + openssl_encrypt, + openssl_error_string; /** * AEAD cipher implementation using OpenSSL extension. @@ -16,28 +22,31 @@ * * @psalm-immutable */ -final readonly class OpenSSLGcmCipher implements AeadCipherInterface +final readonly class OpenSSLAeadCipher implements AeadCipherInterface { /** * Authentication tag size in bytes (always 16 for GCM). */ private const TAG_SIZE = 16; + private int $keySize; + private int $nonceSize; + /** * Look-up table of allowed OpenSSL ciphers. * * Each entry maps a cipher name to: - * - Nonce size (bytes) – used as IV length. * - Key size (bytes) – required key length. + * - Nonce size (bytes) – used as IV length. * * @var array * * @psalm-var array */ private const ALLOWED_CIPHERS = [ - 'AES-128-GCM' => [12, 16], - 'AES-192-GCM' => [12, 24], - 'AES-256-GCM' => [12, 32], + 'AES-128-GCM' => [16, 12], + 'AES-192-GCM' => [24, 12], + 'AES-256-GCM' => [32, 12], ]; /** @@ -54,6 +63,8 @@ public function __construct( 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]; } public function encrypt( @@ -63,6 +74,13 @@ public function encrypt( string $nonce, ): 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, $nonce, $tag, '', self::TAG_SIZE); if ($encrypted === false) { @@ -79,6 +97,13 @@ public function decrypt( string $nonce, ): 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); @@ -91,14 +116,14 @@ public function decrypt( return $decrypted; } - public function getNonceSize(): int + public function getKeySize(): int { - return self::ALLOWED_CIPHERS[$this->cipher][0]; + return $this->keySize; } - public function getKeySize(): int + public function getNonceSize(): int { - return self::ALLOWED_CIPHERS[$this->cipher][1]; + return $this->nonceSize; } public function getTagSize(): int diff --git a/src/Crypt/Cipher/SodiumCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php similarity index 61% rename from src/Crypt/Cipher/SodiumCipher.php rename to src/Crypt/Cipher/SodiumAeadCipher.php index 35d32ed..e24ab43 100644 --- a/src/Crypt/Cipher/SodiumCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -4,6 +4,7 @@ namespace Yiisoft\Security\Crypt\Cipher; +use Exception; use RuntimeException; use SensitiveParameter; use Yiisoft\Security\Crypt\AeadCipherInterface; @@ -25,24 +26,31 @@ * * @psalm-immutable */ -final readonly class SodiumCipher implements AeadCipherInterface +final readonly class SodiumAeadCipher implements AeadCipherInterface { /** * Authentication tag size in bytes (always 16 for these AEAD modes). */ private const TAG_SIZE = 16; + private int $keySize; + private int $nonceSize; + /** - * Look-up table of allowed sodium ciphers with their nonce and key sizes. + * Look-up table of allowed Sodium ciphers. + * + * Each entry maps a cipher name to: + * - Key size (bytes) – required key length. + * - Nonce size (bytes) – used as nonce length. * * @var array * * @psalm-var array */ private const ALLOWED_CIPHERS = [ - 'AES-256-GCM' => [SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES], - 'ChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES, SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES], - 'XChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES], + 'AES-256-GCM' => [SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES, SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES], + 'ChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES, SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES], + 'XChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES], ]; /** @@ -62,6 +70,8 @@ public function __construct( 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]; } public function encrypt( @@ -71,11 +81,15 @@ public function encrypt( string $nonce, ): string { - $encrypted = match ($this->cipher) { - 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nonce, $key), - 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nonce, $key), - 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nonce, $key), - }; + try { + $encrypted = match ($this->cipher) { + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nonce, $key), + 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nonce, $key), + 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nonce, $key), + }; + } catch (Exception $e) { + throw new EncryptionException($e->getMessage()); + } if ($encrypted === false) { throw new EncryptionException('Sodium failure on encryption'); @@ -91,11 +105,15 @@ public function decrypt( string $nonce, ): string { - $decrypted = match ($this->cipher) { - 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nonce, $key), - 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nonce, $key), - 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nonce, $key), - }; + try { + $decrypted = match ($this->cipher) { + 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nonce, $key), + 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nonce, $key), + 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nonce, $key), + }; + } catch (Exception $e) { + throw new EncryptionException($e->getMessage()); + } if ($decrypted === false) { throw new EncryptionException('Sodium failure on decryption'); @@ -104,14 +122,14 @@ public function decrypt( return $decrypted; } - public function getNonceSize(): int + public function getKeySize(): int { - return self::ALLOWED_CIPHERS[$this->cipher][0]; + return $this->keySize; } - public function getKeySize(): int + public function getNonceSize(): int { - return self::ALLOWED_CIPHERS[$this->cipher][1]; + return $this->nonceSize; } public function getTagSize(): int diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index 5455ec5..da3d8ef 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -47,13 +47,14 @@ public function decrypt( string $nonce, ): string; - /** - * @return int Nonce size in bytes - */ - public function getNonceSize(): int; /** * @return int Key size in bytes. */ public function getKeySize(): int; + + /** + * @return int Nonce size in bytes + */ + public function getNonceSize(): int; } diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 1f24280..17926bf 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -18,9 +18,13 @@ */ final readonly class KdfPassword implements KdfInterface { + /** + * @param string $algorithm + * @param int $iterations {@see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2} + */ public function __construct( private string $algorithm = 'sha256', - private int $iterations = 100_000, + private int $iterations = 600_000, ) { } diff --git a/tests/Crypt/AbstractCipherCase.php b/tests/Crypt/AbstractCipherCase.php new file mode 100644 index 0000000..c8d5eda --- /dev/null +++ b/tests/Crypt/AbstractCipherCase.php @@ -0,0 +1,159 @@ +createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = 'test-plain-data'; + + $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->assertEquals($encrypted, $cipherInstance->encrypt($data, $key, $nonce)); + $this->assertEquals($data, $cipherInstance->decrypt($encrypted, $key, $nonce)); + } + + public function testInvalidCipherThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->createCipherInstance('Non-Existing-Cipher'); + } + + #[DataProvider('dataProviderCiphers')] + public function testEncryptWithWrongKeySizeThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize() + 1); // неверный размер + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = 'test-plain-data'; + + $this->expectException(EncryptionException::class); + $cipherInstance->encrypt($plaintext, $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testEncryptWithWrongNonceSizeThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize() + 1); // неверный размер + $plaintext = 'test-plain-data'; + + $this->expectException(EncryptionException::class); + $cipherInstance->encrypt($plaintext, $key, $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithWrongKeySizeThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = 'test-plain-data'; + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $key . 'X', $nonce); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithWrongNonceSizeThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); // неверный размер + $plaintext = 'test-plain-data'; + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $key, $nonce . 'X'); + } + + #[DataProvider('dataProviderCiphers')] + public function testDecryptWithTamperedCiphertextThrowsException(string $cipher): void + { + $cipherInstance = $this->createCipherInstance($cipher); + $key = random_bytes($cipherInstance->getKeySize()); + $nonce = random_bytes($cipherInstance->getNonceSize()); + $plaintext = 'test-plain-data'; + + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + $tampered = substr_replace($ciphertext, 'XXX', -3); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($tampered, $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 = random_bytes($cipherInstance->getNonceSize()); + $plaintext = 'test-plain-data'; + + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $wrongKey, $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 = 'test-plain-data'; + + $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); + + $this->expectException(EncryptionException::class); + $cipherInstance->decrypt($ciphertext, $key, $wrongNonce); + } +} diff --git a/tests/Crypt/OpenSSLAeadCipherTest.php b/tests/Crypt/OpenSSLAeadCipherTest.php new file mode 100644 index 0000000..44d4cc5 --- /dev/null +++ b/tests/Crypt/OpenSSLAeadCipherTest.php @@ -0,0 +1,55 @@ +markTestSkipped('OpenSSL extension is required for these tests.'); + } + } + + protected function createCipherInstance(string $cipher): CipherInterface + { + return new OpenSSLAeadCipher($cipher); + } + + public static function dataProviderCiphers(): iterable + { + yield ['AES-128-GCM']; + yield ['AES-192-GCM']; + yield ['AES-256-GCM']; + } + + public static function dataProviderEncrypted(): iterable + { + 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', + ]; + } +} diff --git a/tests/Crypt/SodiumAeadCipherTest.php b/tests/Crypt/SodiumAeadCipherTest.php new file mode 100644 index 0000000..a47c6c7 --- /dev/null +++ b/tests/Crypt/SodiumAeadCipherTest.php @@ -0,0 +1,47 @@ +markTestSkipped('Sodium extension is required for these tests.'); + } + } + + protected function createCipherInstance(string $cipher): CipherInterface + { + return new SodiumAeadCipher($cipher); + } + + public static function dataProviderCiphers(): iterable + { + yield ['ChaCha20-Poly1305-IETF']; + yield ['XChaCha20-Poly1305-IETF']; + } + + public static function dataProviderEncrypted(): iterable + { + yield [ + 'ChaCha20-Poly1305-IETF', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + 'test-plain-data', + '75058e089d84a58fed82a822b462b2a3dcdf5b5b4cda445fdba26ccd012503', + ]; + yield [ + 'XChaCha20-Poly1305-IETF', + '89fe0c0b2c9b74cdb87d13f0b9f835bde84a3f0c4940c026c5d888db254271f0', + 'fc6f945727c02ac590d53cc17c2f144949526a4f2d2fef41', + 'test-plain-data', + '4c88400da53f878bf9de7749a70b38022ce8166effecc64b8c8a49c2c0f28c', + ]; + } +} diff --git a/tests/Crypt/SodiumGcmCipherTest.php b/tests/Crypt/SodiumGcmCipherTest.php new file mode 100644 index 0000000..d5d4744 --- /dev/null +++ b/tests/Crypt/SodiumGcmCipherTest.php @@ -0,0 +1,41 @@ +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.'); + } + } + + protected function createCipherInstance(string $cipher): CipherInterface + { + return new SodiumAeadCipher($cipher); + } + + public static function dataProviderCiphers(): iterable + { + yield ['AES-256-GCM']; + } + + public static function dataProviderEncrypted(): iterable + { + yield [ + 'AES-256-GCM', + 'd2000811111ba11ba7a2497911c43111a00b433d8437b3538d57d75366b32bb2', + '429895de6466a4622f287f0c', + 'test-plain-data', + 'ae9cf157604ed2a9fd7ad971d005c4e571ec8a6e697e000414e5820748912c', + ]; + } +} From a9b2fed26acfb8d08516f3db9b48ebc31d21a013 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Fri, 15 May 2026 21:41:03 +0700 Subject: [PATCH 13/70] add kdf tests --- src/Crypt/Kdf/KdfKey.php | 12 +++- src/Crypt/Kdf/KdfPassword.php | 18 +++++- tests/Crypt/AbstractKdfCase.php | 102 ++++++++++++++++++++++++++++++++ tests/Crypt/KdfKeyTest.php | 60 +++++++++++++++++++ tests/Crypt/KdfPasswordTest.php | 51 ++++++++++++++++ 5 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 tests/Crypt/AbstractKdfCase.php create mode 100644 tests/Crypt/KdfKeyTest.php create mode 100644 tests/Crypt/KdfPasswordTest.php diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index a04fe60..6d5c074 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -4,7 +4,10 @@ namespace Yiisoft\Security\Crypt\Kdf; +use RuntimeException; use SensitiveParameter; +use ValueError; +use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Security\Crypt\KdfInterface; use function hash_hkdf; @@ -20,6 +23,9 @@ public function __construct( private string $algorithm = 'sha256', ) { + if (!in_array($algorithm, hash_hmac_algos())) { + throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); + } } /** @@ -42,6 +48,10 @@ public function createKey( string $salt, ): string { - return hash_hkdf($this->algorithm, $secret, $keySize, $context, $salt); + try { + return hash_hkdf($this->algorithm, $secret, $keySize, $context, $salt); + } catch (ValueError $e) { + throw new EncryptionException($e->getMessage()); + } } } diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 17926bf..4f7be72 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -4,7 +4,10 @@ namespace Yiisoft\Security\Crypt\Kdf; +use RuntimeException; use SensitiveParameter; +use ValueError; +use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Security\Crypt\KdfInterface; use function hash_hkdf, @@ -26,6 +29,13 @@ public function __construct( private string $algorithm = 'sha256', private int $iterations = 600_000, ) { + if (!in_array($algorithm, hash_hmac_algos())) { + throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); + } + + if ($iterations <= 0) { + throw new RuntimeException("Iterations must be greather then 0, but {$iterations} provided."); + } } /** @@ -52,8 +62,12 @@ public function createKey( string $salt, ): string { - $key = hash_pbkdf2($this->algorithm, $secret, $salt, $this->iterations, $keySize, true); + try { + $key = hash_pbkdf2($this->algorithm, $secret, $salt, $this->iterations, $keySize, true); - return hash_hkdf($this->algorithm, $key, $keySize, $context); + return hash_hkdf($this->algorithm, $key, $keySize, $context); + } catch (ValueError $e) { + throw new EncryptionException($e->getMessage()); + } } } diff --git a/tests/Crypt/AbstractKdfCase.php b/tests/Crypt/AbstractKdfCase.php new file mode 100644 index 0000000..591b8db --- /dev/null +++ b/tests/Crypt/AbstractKdfCase.php @@ -0,0 +1,102 @@ +createKdfInstance(); + $keySize = 32; + $secret = random_bytes($keySize); + + $key = $kdf->createKey($secret, $keySize, 'test-context', 'text-salt'); + + $this->assertSame($keySize, strlen($key)); + $this->assertNotEmpty($key); + } + + #[DataProvider('dataProviderKeyValues')] + public function testKeyValues(string $algo, string $secret, int $keySize, string $context, string $salt, string $key): void + { + $kdf = $this->createKdfInstance($algo); + + + $secret = hex2bin(preg_replace('{\s+}', '', $secret)); + $salt = hex2bin(preg_replace('{\s+}', '', $salt)); + $key = hex2bin(preg_replace('{\s+}', '', $key)); + + $this->assertEquals($key, $kdf->createKey($secret, $keySize, $context, $salt)); + } + + #[DataProvider('dataProviderAlgos')] + public function testCreateKeyWithCustomAlgorithm(string $algo, int $keySize): void + { + $kdf = $this->createKdfInstance($algo); + $secret = random_bytes($keySize); + + $key = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); + + $this->assertSame($keySize, strlen($key)); + } + + public function testSameParametersProduceSameKey(): void + { + $kdf = $this->createKdfInstance(); + $keySize = 32; + $secret = random_bytes($keySize); + + $key1 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); + $key2 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); + + $this->assertSame($key1, $key2); + } + + public function testDifferentParamsProducesDifferentKey(): void + { + $kdf = $this->createKdfInstance(); + $keySize = 32; + $secret = random_bytes($keySize); + $secret2 = random_bytes($keySize); + + $key11 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt-1'); + $key12 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt-2'); + $this->assertNotSame($key11, $key12); + + $key21 = $kdf->createKey($secret, $keySize, 'context-1', 'test-salt'); + $key22 = $kdf->createKey($secret, $keySize, 'context-2', 'test-salt'); + $this->assertNotSame($key21, $key22); + + $key31 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); + $key32 = $kdf->createKey($secret2, $keySize, 'test-context', 'test-salt'); + $this->assertNotSame($key31, $key32); + } + + public function testInvalidAlgoThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->createKdfInstance('Non-Existing-Algorithm'); + } + + public function testInvalidSizeThrowsException(): void + { + $kdf = $this->createKdfInstance(); + + $this->expectException(EncryptionException::class); + $kdf->createKey('test-secret', -1, 'test-context', 'test-salt'); + } +} diff --git a/tests/Crypt/KdfKeyTest.php b/tests/Crypt/KdfKeyTest.php new file mode 100644 index 0000000..48227e6 --- /dev/null +++ b/tests/Crypt/KdfKeyTest.php @@ -0,0 +1,60 @@ +createKdfInstance(); + + $this->expectException(EncryptionException::class); + $kdf->createKey('', 32, 'test-context', 'test-salt'); + } +} diff --git a/tests/Crypt/KdfPasswordTest.php b/tests/Crypt/KdfPasswordTest.php new file mode 100644 index 0000000..67c87c1 --- /dev/null +++ b/tests/Crypt/KdfPasswordTest.php @@ -0,0 +1,51 @@ + Date: Sat, 16 May 2026 02:32:34 +0700 Subject: [PATCH 14/70] fix psalm --- psalm.xml | 1 + src/Crypt/AeadCipherInterface.php | 2 ++ src/Crypt/Cipher/OpenSSLAeadCipher.php | 15 +++++++++++---- src/Crypt/Cipher/SodiumAeadCipher.php | 13 +++++++------ src/Crypt/CipherInterface.php | 4 ++++ src/Crypt/EnvelopeCryptor.php | 13 +++++++++++-- src/Crypt/Kdf/KdfKey.php | 4 ++-- src/Crypt/Kdf/KdfPassword.php | 4 ++-- src/Crypt/SessionCryptor.php | 9 +++++++-- src/Crypt/VersionedCryptor.php | 2 -- tests/Crypt/EnvelopeCryptorTest.php | 18 +++++++++--------- tests/Crypt/SessionCryptorTest.php | 12 ++++++------ 12 files changed, 62 insertions(+), 35 deletions(-) diff --git a/psalm.xml b/psalm.xml index 679cd9b..39a3bda 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" diff --git a/src/Crypt/AeadCipherInterface.php b/src/Crypt/AeadCipherInterface.php index 9cf8d09..48cf0f8 100644 --- a/src/Crypt/AeadCipherInterface.php +++ b/src/Crypt/AeadCipherInterface.php @@ -11,6 +11,8 @@ interface AeadCipherInterface extends CipherInterface { /** * @return int Tag size in bytes. + * + * @psalm-return int<1, max> */ public function getTagSize(): int; } diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php index 91a00c4..90fb9b1 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -19,8 +19,6 @@ /** * AEAD cipher implementation using OpenSSL extension. * Supports only AES-GCM family (128, 192, 256) with 16-byte authentication tags. - * - * @psalm-immutable */ final readonly class OpenSSLAeadCipher implements AeadCipherInterface { @@ -29,7 +27,14 @@ */ private const TAG_SIZE = 16; + /** + * @psalm-var int<1, max> + */ private int $keySize; + + /** + * @psalm-var int<1, max> + */ private int $nonceSize; /** @@ -84,7 +89,8 @@ public function encrypt( $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $nonce, $tag, '', self::TAG_SIZE); if ($encrypted === false) { - throw new EncryptionException('OpenSSL failure on encryption: ' . openssl_error_string()); + $error = openssl_error_string() ?: 'Unknown error'; + throw new EncryptionException('OpenSSL failure on encryption: ' . $error); } return $encrypted . $tag; @@ -110,7 +116,8 @@ public function decrypt( $decrypted = openssl_decrypt($ciphertext, $this->cipher, $key, OPENSSL_RAW_DATA, $nonce, $tag); if ($decrypted === false) { - throw new EncryptionException('OpenSSL failure on decryption: ' . openssl_error_string()); + $error = openssl_error_string() ?: 'Unknown error'; + throw new EncryptionException('OpenSSL failure on decryption: ' . $error); } return $decrypted; diff --git a/src/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php index e24ab43..7ca62ac 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -23,8 +23,6 @@ /** * AEAD cipher implementation using libsodium extension. * Supports AES-256-GCM (hardware accelerated), ChaCha20-Poly1305-IETF, and XChaCha20-Poly1305-IETF. - * - * @psalm-immutable */ final readonly class SodiumAeadCipher implements AeadCipherInterface { @@ -33,7 +31,14 @@ */ private const TAG_SIZE = 16; + /** + * @psalm-var int<1, max> + */ private int $keySize; + + /** + * @psalm-var int<1, max> + */ private int $nonceSize; /** @@ -91,10 +96,6 @@ public function encrypt( throw new EncryptionException($e->getMessage()); } - if ($encrypted === false) { - throw new EncryptionException('Sodium failure on encryption'); - } - return $encrypted; } diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index da3d8ef..de2ab08 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -50,11 +50,15 @@ public function decrypt( /** * @return int Key size in bytes. + * + * @psalm-return int<1, max> */ public function getKeySize(): int; /** * @return int Nonce size in bytes + * + * @psalm-return int<1, max> */ public function getNonceSize(): int; } diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index 0cc8b94..9b44598 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -16,13 +16,22 @@ * * This scheme enables secure handling of long‑term secrets: the DEK is fresh for each * encryption, and the KEK never touches the actual data payload. - * - * @psalm-immutable */ final readonly class EnvelopeCryptor implements CryptorInterface { + /** + * @psalm-var int<1, max> + */ private int $keySize; + + /** + * @psalm-var int<1, max> + */ private int $nonceSize; + + /** + * @psalm-var int<1, max> + */ private int $tagSize; private int $keyNonceSize; diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 6d5c074..3c49f5e 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -15,8 +15,6 @@ /** * KDF that directly applies HKDF (HMAC-based Key Derivation Function) to the input secret. * Suitable for deriving additional keys from a high-entropy secret (e.g., another key). - * - * @psalm-immutable */ final readonly class KdfKey implements KdfInterface { @@ -39,6 +37,8 @@ public function __construct( * @return string Derived key (raw binary). * * @throws RuntimeException If HKDF fails. + * + * @psalm-mutation-free */ public function createKey( #[SensitiveParameter] diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 4f7be72..e1a3578 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -16,8 +16,6 @@ /** * KDF that first applies PBKDF2 to the input password, * then applies HKDF to the result. Suitable for deriving cryptographic keys from low-entropy passwords. - * - * @psalm-immutable */ final readonly class KdfPassword implements KdfInterface { @@ -53,6 +51,8 @@ public function __construct( * @return string Derived key (raw binary). * * @throws RuntimeException If PBKDF2 or HKDF fails. + * + * @psalm-mutation-free */ public function createKey( #[SensitiveParameter] diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index 68a70d4..8aa2d38 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -13,12 +13,17 @@ * Session‑oriented encryption (single key derived per message, no key wrapping). * A fresh data encryption key (DEK) is derived from the secret and a random salt. * This is suitable for encrypting large amounts of data in a single session. - * - * @psalm-immutable */ final readonly class SessionCryptor implements CryptorInterface { + /** + * @psalm-var int<1, max> + */ private int $keySize; + + /** + * @psalm-var int<1, max> + */ private int $nonceSize; private int $keyNonceSize; diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 22d8da3..389453d 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -12,8 +12,6 @@ * VersionedCryptor wraps multiple cryptors and adds a version prefix to the ciphertext. * This enables seamless migration between different encryption algorithms or key lengths. * Each encrypted message begins with a fixed‑length version identifier. - * - * @psalm-immutable */ final readonly class VersionedCryptor implements CryptorInterface { diff --git a/tests/Crypt/EnvelopeCryptorTest.php b/tests/Crypt/EnvelopeCryptorTest.php index 5dda405..c03a6f1 100644 --- a/tests/Crypt/EnvelopeCryptorTest.php +++ b/tests/Crypt/EnvelopeCryptorTest.php @@ -19,7 +19,7 @@ final class EnvelopeCryptorTest extends TestCase public function testEncryptProducesExpectedStructure(): void { - $plain = 'test-plain-data'; + $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -34,7 +34,7 @@ public function testEncryptProducesExpectedStructure(): void $cipher->expects($this->exactly(2)) ->method('encrypt') - ->willReturnCallback(function (...$args) use ($plain, $kek) { + ->willReturnCallback(function (...$args) use ($plaintext, $kek) { static $callCount = 0; $callCount++; @@ -52,7 +52,7 @@ public function testEncryptProducesExpectedStructure(): void // Второй вызов: payload = data, key = dek (keySize), nonce length = nonceSize [$payload, $key, $nonce] = $args; - $this->assertEquals($plain, $payload); + $this->assertEquals($plaintext, $payload); $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($key)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); @@ -62,7 +62,7 @@ public function testEncryptProducesExpectedStructure(): void $cryptor = new EnvelopeCryptor($cipher, $kdf); - $result = $cryptor->encrypt($plain, $secret, $context); + $result = $cryptor->encrypt($plaintext, $secret, $context); $this->assertIsString($result); $this->assertEquals( self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('encData'), @@ -85,7 +85,7 @@ public function testEncryptProducesExpectedStructure(): void public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void { - $plain = 'test-plain-data'; + $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -96,7 +96,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $tag = str_repeat("\x30", self::TAG_SIZE); $encDekWithTag = $dek . $tag; - $encDataWithTag = $plain . $tag; + $encDataWithTag = $plaintext . $tag; [$cipher, $kdf] = $this->createMocks(); @@ -107,7 +107,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $cipher->expects($this->exactly(2)) ->method('decrypt') - ->willReturnCallback(function (...$args) use ($plain, $encDekWithTag, $encDataWithTag, $dekNonce, $dek, $dataNonce) { + ->willReturnCallback(function (...$args) use ($plaintext, $encDekWithTag, $encDataWithTag, $dekNonce, $dek, $dataNonce) { static $callCount = 0; $callCount++; @@ -125,13 +125,13 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $this->assertEquals($dek, $key); $this->assertEquals($dataNonce, $nonce); - return $plain; + return $plaintext; }); $blob = $keySalt . $dekNonce . $encDekWithTag . $dataNonce . $encDataWithTag; $cryptor = new EnvelopeCryptor($cipher, $kdf); $decrypted = $cryptor->decrypt($blob, $secret, $context); - $this->assertSame($plain, $decrypted); + $this->assertSame($plaintext, $decrypted); } public function testEncryptionIsRandomized(): void diff --git a/tests/Crypt/SessionCryptorTest.php b/tests/Crypt/SessionCryptorTest.php index 14228f1..b1e7269 100644 --- a/tests/Crypt/SessionCryptorTest.php +++ b/tests/Crypt/SessionCryptorTest.php @@ -18,7 +18,7 @@ final class SessionCryptorTest extends TestCase public function testEncryptProducesExpectedStructure(): void { - $plain = 'test-plain-data'; + $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -33,11 +33,11 @@ public function testEncryptProducesExpectedStructure(): void // encrypt should be called with data, derived key, and a nonce of nonceSize $cipher->expects($this->once()) ->method('encrypt') - ->with($plain, 'test-derivedkey-123456', $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === self::NONCE_SIZE)) + ->with($plaintext, 'test-derivedkey-123456', $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === self::NONCE_SIZE)) ->willReturn('test-ciphertext-and-tag'); $cryptor = new SessionCryptor($cipher, $kdf); - $result = $cryptor->encrypt($plain, $secret, $context); + $result = $cryptor->encrypt($plaintext, $secret, $context); // result structure: keySalt || nonce || ciphertext $this->assertIsString($result); @@ -57,7 +57,7 @@ public function testEncryptProducesExpectedStructure(): void public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void { - $plain = 'test-plain-data'; + $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -76,13 +76,13 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $cipher->expects($this->once()) ->method('decrypt') ->with($encryptedPayload, 'dek', $nonce) - ->willReturn($plain); + ->willReturn($plaintext); // Build the encrypted blob: keySalt || nonce || encryptedPayload $blob = $keySalt . $nonce . $encryptedPayload; $cryptor = new SessionCryptor($cipher, $kdf); $decrypted = $cryptor->decrypt($blob, $secret, $context); - $this->assertSame($plain, $decrypted); + $this->assertSame($plaintext, $decrypted); } public function testEncryptionIsRandomized(): void From 1ada6d75aded2b3a0c421c6a9fbba7336f3482d5 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 18:15:02 +0700 Subject: [PATCH 15/70] add versioned test --- src/Crypt/Kdf/KdfPassword.php | 5 +- tests/Crypt/VersionedCryptorTest.php | 170 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 tests/Crypt/VersionedCryptorTest.php diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index e1a3578..8260593 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -20,8 +20,9 @@ final readonly class KdfPassword implements KdfInterface { /** - * @param string $algorithm - * @param int $iterations {@see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2} + * @param string $algorithm Hash algorithm for key derivation. + * @param int $iterations Derivation iterations count. + * See [PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for more details. */ public function __construct( private string $algorithm = 'sha256', diff --git a/tests/Crypt/VersionedCryptorTest.php b/tests/Crypt/VersionedCryptorTest.php new file mode 100644 index 0000000..be4fdb6 --- /dev/null +++ b/tests/Crypt/VersionedCryptorTest.php @@ -0,0 +1,170 @@ +createMock(CryptorInterface::class); + $cryptor->expects($this->once()) + ->method('encrypt') + ->with($plaintext, $secret, $context) + ->willReturn('encrypted-payload'); + + $versioned = new VersionedCryptor([$v => $cryptor], $v, 2); + $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(['v2' => $cryptorV2], 'v2', 2); + $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([ + 'v1' => $cryptorV1, + 'v2' => $cryptorV2, + ], 'v2', 2); + + $encryptedDataV1 = 'v1' . $cryptorV1->encrypt($plaintext, $secret); + $encryptedDataV2 = 'v2' . $cryptorV2->encrypt($plaintext, $secret); + + $decryptedDataV1 = $versionedCryptor->decrypt($encryptedDataV1, $secret); + $decryptedDataV2 = $versionedCryptor->decrypt($encryptedDataV2, $secret); + + $this->assertEquals($plaintext, $decryptedDataV1); + $this->assertEquals($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(['v1' => $cryptor], 'v1', 2); + + $encrypted = $versioned->encrypt('data', $secret, $context); + $decrypted = $versioned->decrypt($encrypted, $secret, $context); + + $this->assertSame('data', $decrypted); + } + + public function testIntegerKeyIsNormalizedToStringAndLengthChecked(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor([12 => $this->createMock(CryptorInterface::class)], '123', 3); + } + + public function testDecryptThrowsExceptionWhenVersionNotFound(): void + { + $versionedCryptor = new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 2); + + $this->expectException(RuntimeException::class); + $versionedCryptor->decrypt('v2' . 'test-plain-data', 'test-secret'); + } + + public function testConstructThrowsWhenCurrentVersionNotRegistered(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v2', 2); + } + + public function testConstructorValidationThrows(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor([], 'v1', 2); + } + + public function testConstructorThrowsExceptionWhenCryptorNotInstanceOfInterface(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(['v1' => new \stdClass()], 'v1', 2); + } + + public function testConstructorThrowsExceptionWhenVersionSizeLessThanOne(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 0); + } + + public function testConstructorThrowsExceptionWhenVersionLengthMismatch(): void + { + $this->expectException(RuntimeException::class); + new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 3); + } + + public function testDecryptThrowsExceptionWhenDataTooShort(): void + { + $cryptor = $this->createMock(CryptorInterface::class); + $versionedCryptor = new VersionedCryptor(['v1' => $cryptor], 'v1', 2); + + $this->expectException(EncryptionException::class); + $versionedCryptor->decrypt('x', 'secret'); + } + + public function testDecryptInvalidData(): void + { + $cryptor = $this->createMock(CryptorInterface::class); + $cryptor->method('decrypt')->willThrowException(new EncryptionException()); + + $versionedCryptor = new VersionedCryptor(['v1' => $cryptor], 'v1', 2); + + $this->expectException(EncryptionException::class); + $versionedCryptor->decrypt('v1' . 'test-plain-data', 'test-secret'); + } +} From e30b7897ad0754e9931ab062f79a572c38691568 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 18:17:21 +0700 Subject: [PATCH 16/70] remove config --- config/di.php | 50 -------------------------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 config/di.php diff --git a/config/di.php b/config/di.php deleted file mode 100644 index 4ceb423..0000000 --- a/config/di.php +++ /dev/null @@ -1,50 +0,0 @@ - SessionCryptor::class, - - SessionCryptor::class => [ - '__construct()' => [ - 'cipher' => Reference::to(OpenSSLGcmCipher::class), - //'cipher' => Reference::to(SodiumCipher::class), - 'kdf' => Reference::to(KdfKey::class), - //'kdf' => Reference::to(KdfPassword::class), - ], - ], - - EnvelopeCryptor::class => [ - '__construct()' => [ - 'cipher' => Reference::to(OpenSSLGcmCipher::class), - 'kdf' => Reference::to(KdfKey::class), - ], - ], - - VersionedCryptor::class => [ - '__construct()' => [ - 'cryptors' => ReferencesArray::from([ - //chr(0b00000001) => SessionCryptor::class, - //pack('C', 20) => SessionCryptor::class, - chr(20) => SessionCryptor::class, - chr(200) => EnvelopeCryptor::class, - ]), - 'currentVersion' => chr(200), - 'versionSize' => 1 - ], - ], -]; From c31e2e20debd067c97c0ce0a113af3e205ebc3d5 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 18:38:50 +0700 Subject: [PATCH 17/70] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 229be4e..7469004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.2.1 under development -- no changes in this release. +- New #71: Add Session, Envelope and Versioned Cryptors (@olegbaturin) ## 1.2.0 November 25, 2025 From 14c655563911f101862958c4468a22b0c41ab5a8 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 18:48:49 +0700 Subject: [PATCH 18/70] fix style --- src/Crypt/AeadCipherInterface.php | 2 +- src/Crypt/Cipher/OpenSSLAeadCipher.php | 8 ++++---- src/Crypt/Cipher/SodiumAeadCipher.php | 8 ++++---- src/Crypt/CipherInterface.php | 10 ++++------ src/Crypt/CryptorInterface.php | 6 ++---- src/Crypt/EnvelopeCryptor.php | 20 ++++++++++---------- src/Crypt/Kdf/KdfKey.php | 4 ++-- src/Crypt/Kdf/KdfPassword.php | 6 +++--- src/Crypt/KdfInterface.php | 3 +-- src/Crypt/SessionCryptor.php | 12 ++++++------ src/Crypt/VersionedCryptor.php | 8 ++++---- tests/Crypt/AbstractKdfCase.php | 1 - tests/Crypt/EnvelopeCryptorTest.php | 9 +++------ tests/Crypt/SessionCryptorTest.php | 4 +--- tests/Crypt/VersionedCryptorTest.php | 4 ++-- 15 files changed, 47 insertions(+), 58 deletions(-) diff --git a/src/Crypt/AeadCipherInterface.php b/src/Crypt/AeadCipherInterface.php index 48cf0f8..af54c67 100644 --- a/src/Crypt/AeadCipherInterface.php +++ b/src/Crypt/AeadCipherInterface.php @@ -11,7 +11,7 @@ interface AeadCipherInterface extends CipherInterface { /** * @return int Tag size in bytes. - * + * * @psalm-return int<1, max> */ public function getTagSize(): int; diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php index 90fb9b1..4222757 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -20,7 +20,7 @@ * AEAD cipher implementation using OpenSSL extension. * Supports only AES-GCM family (128, 192, 256) with 16-byte authentication tags. */ -final readonly class OpenSSLAeadCipher implements AeadCipherInterface +final class OpenSSLAeadCipher implements AeadCipherInterface { /** * Authentication tag size in bytes (always 16 for GCM). @@ -30,12 +30,12 @@ /** * @psalm-var int<1, max> */ - private int $keySize; + private readonly int $keySize; /** * @psalm-var int<1, max> */ - private int $nonceSize; + private readonly int $nonceSize; /** * Look-up table of allowed OpenSSL ciphers. @@ -60,7 +60,7 @@ * @throws RuntimeException If OpenSSL extension is not loaded or the cipher is not allowed. */ public function __construct( - private string $cipher = 'AES-256-GCM', + private readonly string $cipher = 'AES-256-GCM', ) { if (!extension_loaded('openssl')) { throw new RuntimeException('Encryption requires the OpenSSL PHP extension.'); diff --git a/src/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php index 7ca62ac..590e552 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -24,7 +24,7 @@ * AEAD cipher implementation using libsodium extension. * Supports AES-256-GCM (hardware accelerated), ChaCha20-Poly1305-IETF, and XChaCha20-Poly1305-IETF. */ -final readonly class SodiumAeadCipher implements AeadCipherInterface +final class SodiumAeadCipher implements AeadCipherInterface { /** * Authentication tag size in bytes (always 16 for these AEAD modes). @@ -34,12 +34,12 @@ /** * @psalm-var int<1, max> */ - private int $keySize; + private readonly int $keySize; /** * @psalm-var int<1, max> */ - private int $nonceSize; + private readonly int $nonceSize; /** * Look-up table of allowed Sodium ciphers. @@ -64,7 +64,7 @@ * @throws RuntimeException If sodium extension is missing, cipher not allowed, or AES-256-GCM without hardware support. */ public function __construct( - private string $cipher = 'AES-256-GCM', + private readonly string $cipher = 'AES-256-GCM', ) { if (!extension_loaded('sodium')) { throw new RuntimeException('Encryption requires the Sodium PHP extension.'); diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index de2ab08..47a6605 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -18,9 +18,8 @@ interface CipherInterface * @param string $key Secret encryption key (sensitive). * @param string $nonce Initialization vector or nonce. * - * @return string Ciphertext. - * * @throws EncryptionException If encryption fails. + * @return string Ciphertext. */ public function encrypt( string $data, @@ -36,9 +35,8 @@ public function encrypt( * @param string $key Secret encryption key (sensitive). * @param string $nonce Nonce used during encryption. * - * @return string Decrypted plaintext. - * * @throws EncryptionException If decryption fails. + * @return string Decrypted plaintext. */ public function decrypt( string $data, @@ -50,14 +48,14 @@ public function decrypt( /** * @return int Key size in bytes. - * + * * @psalm-return int<1, max> */ public function getKeySize(): int; /** * @return int Nonce size in bytes - * + * * @psalm-return int<1, max> */ public function getNonceSize(): int; diff --git a/src/Crypt/CryptorInterface.php b/src/Crypt/CryptorInterface.php index 209af00..0a96368 100644 --- a/src/Crypt/CryptorInterface.php +++ b/src/Crypt/CryptorInterface.php @@ -18,10 +18,9 @@ interface CryptorInterface * @param string $secret Password or raw key (sensitive). * @param string $context Application-specific context (used in key derivation). * - * @return string Encrypted payload (includes nonce, salt, authentication tag, etc.). - * * @throws EncryptionException If encryption fails. * @throws \RuntimeException If required PHP extension is missing. + * @return string Encrypted payload (includes nonce, salt, authentication tag, etc.). */ public function encrypt( string $data, @@ -37,10 +36,9 @@ public function encrypt( * @param string $secret Password or raw key (sensitive). * @param string $context Application-specific context (must match the one used for encryption). * - * @return string Decrypted plaintext. - * * @throws EncryptionException If decryption fails. * @throws \RuntimeException If required PHP extension is missing or data is malformed. + * @return string Decrypted plaintext. */ public function decrypt( string $data, diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index 9b44598..312636e 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -17,35 +17,35 @@ * This scheme enables secure handling of long‑term secrets: the DEK is fresh for each * encryption, and the KEK never touches the actual data payload. */ -final readonly class EnvelopeCryptor implements CryptorInterface +final class EnvelopeCryptor implements CryptorInterface { /** * @psalm-var int<1, max> */ - private int $keySize; + private readonly int $keySize; /** * @psalm-var int<1, max> */ - private int $nonceSize; + private readonly int $nonceSize; /** * @psalm-var int<1, max> */ - private int $tagSize; + private readonly int $tagSize; - private int $keyNonceSize; - private int $encKeySize; - private int $keyNonceEncKeySize; - private int $prefixSize; + private readonly int $keyNonceSize; + private readonly int $encKeySize; + private readonly int $keyNonceEncKeySize; + private readonly int $prefixSize; /** * @param AeadCipherInterface $cipher AEAD cipher (e.g., AES-256-GCM) * @param KdfInterface $kdf Key derivation function (used to derive KEK from secret) */ public function __construct( - private AeadCipherInterface $cipher, - private KdfInterface $kdf, + private readonly AeadCipherInterface $cipher, + private readonly KdfInterface $kdf, ) { $this->keySize = $this->cipher->getKeySize(); $this->nonceSize = $this->cipher->getNonceSize(); diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 3c49f5e..0e08705 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -16,10 +16,10 @@ * KDF that directly applies HKDF (HMAC-based Key Derivation Function) to the input secret. * Suitable for deriving additional keys from a high-entropy secret (e.g., another key). */ -final readonly class KdfKey implements KdfInterface +final class KdfKey implements KdfInterface { public function __construct( - private string $algorithm = 'sha256', + private readonly string $algorithm = 'sha256', ) { if (!in_array($algorithm, hash_hmac_algos())) { throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 8260593..7e7013f 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -17,7 +17,7 @@ * KDF that first applies PBKDF2 to the input password, * then applies HKDF to the result. Suitable for deriving cryptographic keys from low-entropy passwords. */ -final readonly class KdfPassword implements KdfInterface +final class KdfPassword implements KdfInterface { /** * @param string $algorithm Hash algorithm for key derivation. @@ -25,8 +25,8 @@ * See [PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for more details. */ public function __construct( - private string $algorithm = 'sha256', - private int $iterations = 600_000, + private readonly string $algorithm = 'sha256', + private readonly int $iterations = 600_000, ) { if (!in_array($algorithm, hash_hmac_algos())) { throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); diff --git a/src/Crypt/KdfInterface.php b/src/Crypt/KdfInterface.php index 915a35b..78f3a61 100644 --- a/src/Crypt/KdfInterface.php +++ b/src/Crypt/KdfInterface.php @@ -20,9 +20,8 @@ interface KdfInterface * @param string $context Application-specific context string (used as HKDF info). * @param string $salt Salt value (must be random and unique for each derivation). * - * @return string The derived key (raw binary string). - * * @throws \RuntimeException If key derivation fails. + * @return string The derived key (raw binary string). */ public function createKey( #[SensitiveParameter] diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index 8aa2d38..711779e 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -14,27 +14,27 @@ * A fresh data encryption key (DEK) is derived from the secret and a random salt. * This is suitable for encrypting large amounts of data in a single session. */ -final readonly class SessionCryptor implements CryptorInterface +final class SessionCryptor implements CryptorInterface { /** * @psalm-var int<1, max> */ - private int $keySize; + private readonly int $keySize; /** * @psalm-var int<1, max> */ - private int $nonceSize; + private readonly int $nonceSize; - private int $keyNonceSize; + private readonly int $keyNonceSize; /** * @param CipherInterface $cipher Low‑level cipher * @param KdfInterface $kdf Key derivation function */ public function __construct( - private CipherInterface $cipher, - private KdfInterface $kdf, + private readonly CipherInterface $cipher, + private readonly KdfInterface $kdf, ) { $this->keySize = $this->cipher->getKeySize(); $this->nonceSize = $this->cipher->getNonceSize(); diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 389453d..2325b63 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -13,12 +13,12 @@ * This enables seamless migration between different encryption algorithms or key lengths. * Each encrypted message begins with a fixed‑length version identifier. */ -final readonly class VersionedCryptor implements CryptorInterface +final class VersionedCryptor implements CryptorInterface { /** * @var array Storage for registered cryptors indexed by their version identifier. */ - private array $cryptors; + private readonly array $cryptors; /** * @param array $cryptors List of cryptors indexed by version string. @@ -29,8 +29,8 @@ */ public function __construct( array $cryptors, - private string $currentVersion, - private int $versionSize, + private readonly string $currentVersion, + private readonly int $versionSize, ) { if ($versionSize < 1) { throw new RuntimeException('Version size must be greater than 0.'); diff --git a/tests/Crypt/AbstractKdfCase.php b/tests/Crypt/AbstractKdfCase.php index 591b8db..458f10f 100644 --- a/tests/Crypt/AbstractKdfCase.php +++ b/tests/Crypt/AbstractKdfCase.php @@ -35,7 +35,6 @@ public function testKeyValues(string $algo, string $secret, int $keySize, string { $kdf = $this->createKdfInstance($algo); - $secret = hex2bin(preg_replace('{\s+}', '', $secret)); $salt = hex2bin(preg_replace('{\s+}', '', $salt)); $key = hex2bin(preg_replace('{\s+}', '', $key)); diff --git a/tests/Crypt/EnvelopeCryptorTest.php b/tests/Crypt/EnvelopeCryptorTest.php index c03a6f1..c54458d 100644 --- a/tests/Crypt/EnvelopeCryptorTest.php +++ b/tests/Crypt/EnvelopeCryptorTest.php @@ -26,7 +26,7 @@ public function testEncryptProducesExpectedStructure(): void $kek = random_bytes(self::KEY_SIZE); [$cipher, $kdf] = $this->createMocks(); - + $kdf->expects($this->once()) ->method('createKey') ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::KEY_SIZE)) @@ -46,17 +46,14 @@ public function testEncryptProducesExpectedStructure(): void $this->assertEquals($kek, $key); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); - //return 'encDekWithTag-------------------'; return 'encDek--------------------------'; } - // Второй вызов: payload = data, key = dek (keySize), nonce length = nonceSize [$payload, $key, $nonce] = $args; $this->assertEquals($plaintext, $payload); $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($key)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); - //return 'encDataWithTag'; return 'encData'; }); @@ -99,12 +96,12 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $encDataWithTag = $plaintext . $tag; [$cipher, $kdf] = $this->createMocks(); - + $kdf->expects($this->once()) ->method('createKey') ->with($secret, self::KEY_SIZE, $context, $keySalt) ->willReturn('kek'); - + $cipher->expects($this->exactly(2)) ->method('decrypt') ->willReturnCallback(function (...$args) use ($plaintext, $encDekWithTag, $encDataWithTag, $dekNonce, $dek, $dataNonce) { diff --git a/tests/Crypt/SessionCryptorTest.php b/tests/Crypt/SessionCryptorTest.php index b1e7269..63ba669 100644 --- a/tests/Crypt/SessionCryptorTest.php +++ b/tests/Crypt/SessionCryptorTest.php @@ -23,14 +23,12 @@ public function testEncryptProducesExpectedStructure(): void $context = 'test-context'; [$cipher, $kdf] = $this->createMocks(); - - //$kdf = $this->createMock(KdfInterface::class); + $kdf->expects($this->once()) ->method('createKey') ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::KEY_SIZE)) ->willReturn('test-derivedkey-123456'); - // encrypt should be called with data, derived key, and a nonce of nonceSize $cipher->expects($this->once()) ->method('encrypt') ->with($plaintext, 'test-derivedkey-123456', $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === self::NONCE_SIZE)) diff --git a/tests/Crypt/VersionedCryptorTest.php b/tests/Crypt/VersionedCryptorTest.php index be4fdb6..6682997 100644 --- a/tests/Crypt/VersionedCryptorTest.php +++ b/tests/Crypt/VersionedCryptorTest.php @@ -118,7 +118,7 @@ public function testDecryptThrowsExceptionWhenVersionNotFound(): void $versionedCryptor->decrypt('v2' . 'test-plain-data', 'test-secret'); } - public function testConstructThrowsWhenCurrentVersionNotRegistered(): void + public function testConstructThrowsWhenCurrentVersionNotRegistered(): void { $this->expectException(RuntimeException::class); new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v2', 2); @@ -133,7 +133,7 @@ public function testConstructorValidationThrows(): void public function testConstructorThrowsExceptionWhenCryptorNotInstanceOfInterface(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor(['v1' => new \stdClass()], 'v1', 2); + new VersionedCryptor(['v1' => new \stdClass()], 'v1', 2); } public function testConstructorThrowsExceptionWhenVersionSizeLessThanOne(): void From 5a261fc0e94e27d6374dc5cc5dfc7417756dedd9 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 18:56:14 +0700 Subject: [PATCH 19/70] fix style --- src/Crypt/Cipher/OpenSSLAeadCipher.php | 17 +++++++---------- src/Crypt/Cipher/SodiumAeadCipher.php | 25 +++++++++++-------------- src/Crypt/CipherInterface.php | 1 - src/Crypt/EnvelopeCryptor.php | 6 ++---- src/Crypt/Kdf/KdfKey.php | 9 +++------ src/Crypt/Kdf/KdfPassword.php | 11 ++++------- src/Crypt/SessionCryptor.php | 6 ++---- src/Crypt/VersionedCryptor.php | 2 +- tests/Crypt/SessionCryptorTest.php | 7 +++---- 9 files changed, 33 insertions(+), 51 deletions(-) diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php index 4222757..8cd07af 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -9,12 +9,11 @@ use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Strings\StringHelper; -use function - array_key_exists, - extension_loaded, - openssl_decrypt, - openssl_encrypt, - openssl_error_string; +use function array_key_exists; +use function extension_loaded; +use function openssl_decrypt; +use function openssl_encrypt; +use function openssl_error_string; /** * AEAD cipher implementation using OpenSSL extension. @@ -77,8 +76,7 @@ public function encrypt( #[SensitiveParameter] string $key, string $nonce, - ): string - { + ): string { if (StringHelper::byteLength($key) !== $this->keySize) { throw new EncryptionException("Key must be {$this->keySize} bytes long."); } @@ -101,8 +99,7 @@ public function decrypt( #[SensitiveParameter] string $key, string $nonce, - ): string - { + ): string { if (StringHelper::byteLength($key) !== $this->keySize) { throw new EncryptionException("Key must be {$this->keySize} bytes long."); } diff --git a/src/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php index 590e552..25f5961 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -9,16 +9,15 @@ use SensitiveParameter; use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; -use function - array_key_exists, - extension_loaded, - sodium_crypto_aead_aes256gcm_is_available, - sodium_crypto_aead_aes256gcm_encrypt, - sodium_crypto_aead_aes256gcm_decrypt, - sodium_crypto_aead_chacha20poly1305_ietf_encrypt, - sodium_crypto_aead_chacha20poly1305_ietf_decrypt, - sodium_crypto_aead_xchacha20poly1305_ietf_encrypt, - sodium_crypto_aead_xchacha20poly1305_ietf_decrypt; +use function array_key_exists; +use function extension_loaded; +use function sodium_crypto_aead_aes256gcm_is_available; +use function sodium_crypto_aead_aes256gcm_encrypt; +use function sodium_crypto_aead_aes256gcm_decrypt; +use function sodium_crypto_aead_chacha20poly1305_ietf_encrypt; +use function sodium_crypto_aead_chacha20poly1305_ietf_decrypt; +use function sodium_crypto_aead_xchacha20poly1305_ietf_encrypt; +use function sodium_crypto_aead_xchacha20poly1305_ietf_decrypt; /** * AEAD cipher implementation using libsodium extension. @@ -84,8 +83,7 @@ public function encrypt( #[SensitiveParameter] string $key, string $nonce, - ): string - { + ): string { try { $encrypted = match ($this->cipher) { 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nonce, $key), @@ -104,8 +102,7 @@ public function decrypt( #[SensitiveParameter] string $key, string $nonce, - ): string - { + ): string { try { $decrypted = match ($this->cipher) { 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nonce, $key), diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index 47a6605..96b4959 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -45,7 +45,6 @@ public function decrypt( string $nonce, ): string; - /** * @return int Key size in bytes. * diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index 312636e..04cc971 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -6,8 +6,7 @@ use SensitiveParameter; use Yiisoft\Strings\StringHelper; -use function - random_bytes; +use function random_bytes; /** * Envelope encryption (key wrapping) using a KDF to derive a Key Encryption Key (KEK) @@ -106,8 +105,7 @@ public function decrypt( $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); $dek = $this->cipher->decrypt($encDek, $kek, $dekNonce); - $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); - return $decrypted; + return $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); } } diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 0e08705..c5b0579 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -9,8 +9,7 @@ use ValueError; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Security\Crypt\KdfInterface; -use function - hash_hkdf; +use function hash_hkdf; /** * KDF that directly applies HKDF (HMAC-based Key Derivation Function) to the input secret. @@ -34,9 +33,8 @@ public function __construct( * @param string $context Application-specific context (used as HKDF info). * @param string $salt Salt value (optional, but recommended for stronger extraction). * - * @return string Derived key (raw binary). - * * @throws RuntimeException If HKDF fails. + * @return string Derived key (raw binary). * * @psalm-mutation-free */ @@ -46,8 +44,7 @@ public function createKey( int $keySize, string $context, string $salt, - ): string - { + ): string { try { return hash_hkdf($this->algorithm, $secret, $keySize, $context, $salt); } catch (ValueError $e) { diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 7e7013f..290b26b 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -9,9 +9,8 @@ use ValueError; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Security\Crypt\KdfInterface; -use function - hash_hkdf, - hash_pbkdf2; +use function hash_hkdf; +use function hash_pbkdf2; /** * KDF that first applies PBKDF2 to the input password, @@ -49,9 +48,8 @@ public function __construct( * @param string $context Application-specific context (used as HKDF info). * @param string $salt Salt value (must be random and unique, at least 16 bytes). * - * @return string Derived key (raw binary). - * * @throws RuntimeException If PBKDF2 or HKDF fails. + * @return string Derived key (raw binary). * * @psalm-mutation-free */ @@ -61,8 +59,7 @@ public function createKey( int $keySize, string $context, string $salt, - ): string - { + ): string { try { $key = hash_pbkdf2($this->algorithm, $secret, $salt, $this->iterations, $keySize, true); diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index 711779e..a94477f 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -6,8 +6,7 @@ use SensitiveParameter; use Yiisoft\Strings\StringHelper; -use function - random_bytes; +use function random_bytes; /** * Session‑oriented encryption (single key derived per message, no key wrapping). @@ -82,8 +81,7 @@ public function decrypt( $dataEncrypted = StringHelper::byteSubstring($data, $this->keyNonceSize); $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $decrypted = $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); - return $decrypted; + return $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); } } diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 2325b63..df822d0 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -89,8 +89,8 @@ public function decrypt( * and ensures each version identifier has exactly `$versionSize` bytes. * * @param array $cryptors Raw input mapping. - * @return array Normalised array. * @throws RuntimeException On validation error. + * @return array Normalised array. */ private function validateAndNormalize(array $cryptors): array { diff --git a/tests/Crypt/SessionCryptorTest.php b/tests/Crypt/SessionCryptorTest.php index 63ba669..ce9a30c 100644 --- a/tests/Crypt/SessionCryptorTest.php +++ b/tests/Crypt/SessionCryptorTest.php @@ -40,8 +40,8 @@ public function testEncryptProducesExpectedStructure(): void // result structure: keySalt || nonce || ciphertext $this->assertIsString($result); $this->assertEquals( - self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('test-ciphertext-and-tag'), - StringHelper::byteLength($result) + self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('test-ciphertext-and-tag'), + StringHelper::byteLength($result) ); $keySalt = StringHelper::byteSubstring($result, 0, self::KEY_SIZE); @@ -63,7 +63,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $nonce = str_repeat("\x02", self::NONCE_SIZE); $encryptedPayload = 'encrypted-by-cipher'; - + [$cipher, $kdf] = $this->createMocks(); $kdf->expects($this->once()) @@ -119,5 +119,4 @@ private function createMocks(): array return [$cipher, $kdf]; } - } From 7469377049bfec8f5286bc35e7a40d19ab19d44a Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 18:59:14 +0700 Subject: [PATCH 20/70] fix style --- src/Crypt/Cipher/OpenSSLAeadCipher.php | 1 + src/Crypt/Cipher/SodiumAeadCipher.php | 1 + src/Crypt/EnvelopeCryptor.php | 1 + src/Crypt/Kdf/KdfKey.php | 3 ++- src/Crypt/Kdf/KdfPassword.php | 2 +- src/Crypt/SessionCryptor.php | 1 + src/Crypt/VersionedCryptor.php | 2 +- 7 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php index 8cd07af..4594896 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -9,6 +9,7 @@ use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Strings\StringHelper; + use function array_key_exists; use function extension_loaded; use function openssl_decrypt; diff --git a/src/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php index 25f5961..7d41825 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -9,6 +9,7 @@ use SensitiveParameter; use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; + use function array_key_exists; use function extension_loaded; use function sodium_crypto_aead_aes256gcm_is_available; diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index 04cc971..feb76a6 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -6,6 +6,7 @@ use SensitiveParameter; use Yiisoft\Strings\StringHelper; + use function random_bytes; /** diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index c5b0579..819955e 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -9,6 +9,7 @@ use ValueError; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Security\Crypt\KdfInterface; + use function hash_hkdf; /** @@ -35,7 +36,7 @@ public function __construct( * * @throws RuntimeException If HKDF fails. * @return string Derived key (raw binary). - * + * * @psalm-mutation-free */ public function createKey( diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 290b26b..5643990 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -50,7 +50,7 @@ public function __construct( * * @throws RuntimeException If PBKDF2 or HKDF fails. * @return string Derived key (raw binary). - * + * * @psalm-mutation-free */ public function createKey( diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index a94477f..ca0ddd8 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -6,6 +6,7 @@ use SensitiveParameter; use Yiisoft\Strings\StringHelper; + use function random_bytes; /** diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index df822d0..4a6a122 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -20,7 +20,7 @@ final class VersionedCryptor implements CryptorInterface */ private readonly array $cryptors; - /** + /** * @param array $cryptors List of cryptors indexed by version string. * @param string $currentVersion Version identifier used for new encryptions. * @param int $versionSize Fixed byte length of the version prefix (must be >=1). From b53adc4cbeedd748ee159ad06e4ca2df4f3460d9 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 19:14:31 +0700 Subject: [PATCH 21/70] fix style --- src/Crypt/Kdf/KdfPassword.php | 1 + src/Crypt/VersionedCryptor.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 5643990..2b20841 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -9,6 +9,7 @@ use ValueError; use Yiisoft\Security\Crypt\EncryptionException; use Yiisoft\Security\Crypt\KdfInterface; + use function hash_hkdf; use function hash_pbkdf2; diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 4a6a122..dde07dc 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -20,7 +20,7 @@ final class VersionedCryptor implements CryptorInterface */ private readonly array $cryptors; - /** + /** * @param array $cryptors List of cryptors indexed by version string. * @param string $currentVersion Version identifier used for new encryptions. * @param int $versionSize Fixed byte length of the version prefix (must be >=1). From dd005ec9d25ff5eb8263460454881ebd26353e06 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 19:43:25 +0700 Subject: [PATCH 22/70] fix psalm --- src/PasswordHasher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PasswordHasher.php b/src/PasswordHasher.php index 7702604..ea34bf7 100644 --- a/src/PasswordHasher.php +++ b/src/PasswordHasher.php @@ -39,7 +39,7 @@ public function __construct( array|null $parameters = null, ) { if ($parameters === null) { - $this->parameters = self::SAFE_PARAMETERS[$this->algorithm] ?? []; + $this->parameters = self::SAFE_PARAMETERS[$this->algorithm ?? ''] ?? []; } else { $this->parameters = $parameters; } From ef21a64df61c821beeaa66dddcc2b714aec9ec45 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 20:26:02 +0700 Subject: [PATCH 23/70] update tests --- ...actCipherCase.php => AbstractAeadCipherCase.php} | 13 +++++++++++-- tests/Crypt/KdfPasswordTest.php | 7 +++++++ tests/Crypt/OpenSSLAeadCipherTest.php | 6 +++--- tests/Crypt/SodiumAeadCipherTest.php | 6 +++--- tests/Crypt/SodiumGcmCipherTest.php | 6 +++--- 5 files changed, 27 insertions(+), 11 deletions(-) rename tests/Crypt/{AbstractCipherCase.php => AbstractAeadCipherCase.php} (93%) diff --git a/tests/Crypt/AbstractCipherCase.php b/tests/Crypt/AbstractAeadCipherCase.php similarity index 93% rename from tests/Crypt/AbstractCipherCase.php rename to tests/Crypt/AbstractAeadCipherCase.php index c8d5eda..886dfcb 100644 --- a/tests/Crypt/AbstractCipherCase.php +++ b/tests/Crypt/AbstractAeadCipherCase.php @@ -13,9 +13,9 @@ /** * @abstract */ -abstract class AbstractCipherCase extends TestCase +abstract class AbstractAeadCipherCase extends TestCase { - abstract protected function createCipherInstance(string $cipher): CipherInterface; + abstract protected function createCipherInstance(?string $cipher = null): CipherInterface; abstract public static function dataProviderCiphers(): iterable; @@ -156,4 +156,13 @@ public function testDecryptWithWrongNonceThrowsException(string $cipher): void $this->expectException(EncryptionException::class); $cipherInstance->decrypt($ciphertext, $key, $wrongNonce); } + + public function testGetSizes(): void + { + $cipher = $this->createCipherInstance(); + + $this->assertIsInt($cipher->getKeySize()); + $this->assertIsInt($cipher->getNonceSize()); + $this->assertIsInt($cipher->getTagSize()); + } } diff --git a/tests/Crypt/KdfPasswordTest.php b/tests/Crypt/KdfPasswordTest.php index 67c87c1..1766bcf 100644 --- a/tests/Crypt/KdfPasswordTest.php +++ b/tests/Crypt/KdfPasswordTest.php @@ -4,6 +4,7 @@ namespace Yiisoft\Security\Tests\Crypt; +use RuntimeException; use Yiisoft\Security\Crypt\KdfInterface; use Yiisoft\Security\Crypt\Kdf\KdfPassword; @@ -48,4 +49,10 @@ public static function dataProviderKeyValues(): iterable '07f140674180f0ba9d4c6dea90a0ad389274624bc966c550519c98704f1df504', ]; } + + public function testConstructorThrowsExceptionWhenIterationsLessThanOne(): void + { + $this->expectException(RuntimeException::class); + new KdfPassword('sha256', 0); + } } diff --git a/tests/Crypt/OpenSSLAeadCipherTest.php b/tests/Crypt/OpenSSLAeadCipherTest.php index 44d4cc5..8ddfb98 100644 --- a/tests/Crypt/OpenSSLAeadCipherTest.php +++ b/tests/Crypt/OpenSSLAeadCipherTest.php @@ -7,7 +7,7 @@ use Yiisoft\Security\Crypt\CipherInterface; use Yiisoft\Security\Crypt\Cipher\OpenSSLAeadCipher; -final class OpenSSLAeadCipherTest extends AbstractCipherCase +final class OpenSSLAeadCipherTest extends AbstractAeadCipherCase { protected function setUp(): void { @@ -16,9 +16,9 @@ protected function setUp(): void } } - protected function createCipherInstance(string $cipher): CipherInterface + protected function createCipherInstance(?string $cipher = null): CipherInterface { - return new OpenSSLAeadCipher($cipher); + return $cipher ? new OpenSSLAeadCipher($cipher) : new OpenSSLAeadCipher(); } public static function dataProviderCiphers(): iterable diff --git a/tests/Crypt/SodiumAeadCipherTest.php b/tests/Crypt/SodiumAeadCipherTest.php index a47c6c7..6729255 100644 --- a/tests/Crypt/SodiumAeadCipherTest.php +++ b/tests/Crypt/SodiumAeadCipherTest.php @@ -7,7 +7,7 @@ use Yiisoft\Security\Crypt\CipherInterface; use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; -final class SodiumAeadCipherTest extends AbstractCipherCase +final class SodiumAeadCipherTest extends AbstractAeadCipherCase { protected function setUp(): void { @@ -16,9 +16,9 @@ protected function setUp(): void } } - protected function createCipherInstance(string $cipher): CipherInterface + protected function createCipherInstance(?string $cipher = null): CipherInterface { - return new SodiumAeadCipher($cipher); + return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); } public static function dataProviderCiphers(): iterable diff --git a/tests/Crypt/SodiumGcmCipherTest.php b/tests/Crypt/SodiumGcmCipherTest.php index d5d4744..bc62de1 100644 --- a/tests/Crypt/SodiumGcmCipherTest.php +++ b/tests/Crypt/SodiumGcmCipherTest.php @@ -7,7 +7,7 @@ use Yiisoft\Security\Crypt\CipherInterface; use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; -final class SodiumGcmCipherTest extends AbstractCipherCase +final class SodiumGcmCipherTest extends AbstractAeadCipherCase { protected function setUp(): void { @@ -18,9 +18,9 @@ protected function setUp(): void } } - protected function createCipherInstance(string $cipher): CipherInterface + protected function createCipherInstance(?string $cipher = null): CipherInterface { - return new SodiumAeadCipher($cipher); + return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); } public static function dataProviderCiphers(): iterable From b2cb9da556db58dabb51ee2d1e5933588623a37c Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 20:33:30 +0700 Subject: [PATCH 24/70] update composer.json --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index dcd8410..fe6d65b 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "php": "8.1 - 8.5", "ext-hash": "*", "ext-openssl": "*", + "ext-sodium": "*", "yiisoft/strings": "^2.0" }, "require-dev": { From ac79a08e4f922028820128cdd97cbcbd48180bd7 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 22:05:27 +0700 Subject: [PATCH 25/70] update ci --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b1e2d8..4c0a2b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,3 +32,4 @@ jobs: ['ubuntu-latest', 'windows-latest'] php: >- ['8.1', '8.2', '8.3', '8.4', '8.5'] + extensions: ${{ matrix.os == 'windows-latest' && 'ext-sodium' || '' }} From bf24623a9c81d4172d6f3d454d140d67e9ccfe67 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 22:09:56 +0700 Subject: [PATCH 26/70] update ci --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c0a2b1..9d38f34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,4 +32,4 @@ jobs: ['ubuntu-latest', 'windows-latest'] php: >- ['8.1', '8.2', '8.3', '8.4', '8.5'] - extensions: ${{ matrix.os == 'windows-latest' && 'ext-sodium' || '' }} + extensions: ${{ matrix.os == 'windows-latest' && 'sodium' || '' }} From 8b64a322fcc5758f7c9029514aa8d3549c77e5f4 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 16 May 2026 22:36:19 +0700 Subject: [PATCH 27/70] update ci --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d38f34..2b31b82 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,4 +32,4 @@ jobs: ['ubuntu-latest', 'windows-latest'] php: >- ['8.1', '8.2', '8.3', '8.4', '8.5'] - extensions: ${{ matrix.os == 'windows-latest' && 'sodium' || '' }} + extensions: sodium From 1001c881427e6363fd51b4bd958668ea3d71e880 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 17 May 2026 12:36:03 +0700 Subject: [PATCH 28/70] update phpdoc --- src/Crypt/Kdf/KdfKey.php | 5 +++++ src/Crypt/Kdf/KdfPassword.php | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 819955e..d70d277 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -18,6 +18,11 @@ */ final class KdfKey implements KdfInterface { + /** + * @param string $algorithm Hash algorithm for key derivation. {@see hash_hmac_algos()} + * + * @throws RuntimeException + */ public function __construct( private readonly string $algorithm = 'sha256', ) { diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 2b20841..085a241 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -20,9 +20,11 @@ final class KdfPassword implements KdfInterface { /** - * @param string $algorithm Hash algorithm for key derivation. + * @param string $algorithm Hash algorithm for key derivation. {@see hash_hmac_algos()} * @param int $iterations Derivation iterations count. * See [PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for more details. + * + * @throws RuntimeException */ public function __construct( private readonly string $algorithm = 'sha256', From 2750e71ca6f99552bdbc2ef70b239e8686d78f36 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 17 May 2026 17:32:53 +0700 Subject: [PATCH 29/70] update readme --- README.md | 248 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 208 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index eb32a42..e800edd 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Security package provides a set of classes to handle common security-related tas - PHP 8.1 - 8.5. - `hash` PHP extension. - `openssl` PHP extension. +- `sodium` PHP extension. ## 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,213 @@ There is a special function in PHP that compares strings in a constant time: hash_equals($expected, $actual); ``` +## New cryptor + +`Crypt` provides encryption layer based on `AEAD` algorithms. +It supports key derivation, session‑oriented encryption, envelope encryption, and versioned ciphertexts for seamless algorithm migration. + +All high‑level encryptors implement the `CryptorInterface`. Inject the desired cryptor (`SessionCryptor`, `EnvelopeCryptor` or `VersionedCryptor`) and use it as follows: + +```php +use Yiisoft\Security\Crypt\CryptorInterface; + +$cryptor = $contaier->get(CryptorInterface::class); +/** @var high‑entropy key or low‑entropy password */ +$secret; +/** @var Optional application‑specific string that is mixed into the KDF */ +$context; + +$encrypted = $cryptor->encrypt('secret data', $secret, $context); +$data = $cryptor->decrypt($encrypted, $secret, $context); +``` + +### Session cryptor + +Session‑oriented encryption (single key derived per message, no key wrapping). +A fresh data encryption key (DEK) is derived from the secret and a random salt. + +Structure: +``` +keySalt || nonce || encrypted(data) + tag +``` + +DI Configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypt\SessionCryptor; +use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypt\Kdf\KdfKey; + +SessionCryptor::class => [ + '__construct()' => [ + 'cipher' => Reference::to(SodiumAeadCipher::class), + 'kdf' => Reference::to(KdfKey::class), + ], +], +``` + +### Envelope cryptor + +Envelope encryption (key wrapping) using a KDF to derive a Key Encryption Key (KEK) +and a random Data Encryption Key (DEK). The DEK is encrypted with the KEK and stored +together with the ciphertext. + +Structure: +``` +keySalt || dekNonce || encrypted(DEK) + tag || dataNonce || encrypted(data) + tag +``` + +DI Configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypt\EnvelopeCryptor; +use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypt\Kdf\KdfKey; + +EnvelopeCryptor::class => [ + '__construct()' => [ + 'cipher' => Reference::to(SodiumAeadCipher::class), + 'kdf' => Reference::to(KdfKey::class), + ], +], +``` + + +### Versioned cryptor + +Wraps multiple cryptors and adds a fixed‑length version prefix to every ciphertext. + +DI Configuration: +```php +// /config/di.php +use Yiisoft\Security\Crypt\VersionedCryptor; +use Yiisoft\Security\Crypt\SessionCryptor; +use Yiisoft\Security\Crypt\EnvelopeCryptor; + +VersionedCryptor::class => [ + '__construct()' => [ + 'cryptors' => ReferencesArray::from([ + chr(0x01) => SessionCryptor::class, + chr(0x96) => EnvelopeCryptor::class, + ]), + 'currentVersion' => chr(0x01), + 'versionSize' => 1 + ], +], +``` + +### Configure 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 +Use this when the secret is already a strong cryptographic key (e.g. a 256‑bit random value). It applies `HKDF` directly. + +```php +// /config/di.php +use Yiisoft\Security\Crypt\Kdf\KdfKey; + +KdfKey::class => [ + '__construct()' => [ + 'algorithm' => 'sha512', // any hash_hmac_algos() + ], +], +``` + +#### KdfPassword - for low‑entropy passwords +This first applies `PBKDF2` with a configurable iteration count, then `HKDF` to derive the final key. +Follow OWASP recommendations for iteration counts. + +```php +// /config/di.php +use Yiisoft\Security\Crypt\Kdf\KdfPassword; + +KdfPassword::class => [ + '__construct()' => [ + 'algorithm' => 'sha512', // any hash_hmac_algos() + 'iterations' => 700_000, + ], +], +``` + +### Configuring AEAD Ciphers + +Two backends are available: `OpenSSL` and `Sodium` (libsodium). + +#### OpenSSLAeadCipher +Supports `AES‑GCM` family. + +```php +// /config/di.php +use Yiisoft\Security\Crypt\Cipher\OpenSSLAeadCipher; + +OpenSSLAeadCipher::class => [ + '__construct()' => [ + 'cipher' => 'AES-192-GCM', + ], +], +``` + +#### SodiumAeadCipher +Supports `AES‑256‑GCM` (hardware accelerated), `ChaCha20‑Poly1305‑IETF`, and `XChaCha20‑Poly1305‑IETF`. +Note: `AES‑256‑GCM` with `Sodium` requires CPU support for AES instructions (`AES‑NI`). Use `ChaCha20‑Poly1305‑IETF` for a safe, non‑hardware‑dependent alternative. + +```php +// /config/di.php +use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; + +SodiumAeadCipher::class => [ + '__construct()' => [ + 'cipher' => 'ChaCha20-Poly1305-IETF', + ], +], +``` + +## Old cryptor + +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) From d574ca8bddbff16e34fa3fe01fb260493bc78f25 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Wed, 20 May 2026 16:42:30 +0700 Subject: [PATCH 30/70] add OPENSSL_DONT_ZERO_PAD_KEY to gcp cipher --- src/Crypt/Cipher/OpenSSLAeadCipher.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php index 4594896..12d7c4d 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -85,7 +85,7 @@ public function encrypt( throw new EncryptionException("Nonce must be {$this->nonceSize} bytes long."); } - $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA, $nonce, $tag, '', self::TAG_SIZE); + $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $nonce, $tag, '', self::TAG_SIZE); if ($encrypted === false) { $error = openssl_error_string() ?: 'Unknown error'; @@ -111,7 +111,7 @@ public function decrypt( $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, $nonce, $tag); + $decrypted = openssl_decrypt($ciphertext, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $nonce, $tag); if ($decrypted === false) { $error = openssl_error_string() ?: 'Unknown error'; From 2dee4ae7cd7bd62100b7d470ef234f09088eb025 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 24 May 2026 02:31:03 +0700 Subject: [PATCH 31/70] fix copilot suggestions --- README.md | 2 +- src/Crypt/Kdf/KdfPassword.php | 2 +- src/Crypt/VersionedCryptor.php | 3 ++- tests/Crypt/AbstractAeadCipherCase.php | 10 +++++----- tests/Crypt/EnvelopeCryptorTest.php | 15 +++++++-------- tests/Crypt/OpenSSLAeadCipherTest.php | 4 ++-- tests/Crypt/SodiumAeadCipherTest.php | 4 ++-- tests/Crypt/SodiumGcmCipherTest.php | 4 ++-- tests/Crypt/VersionedCryptorTest.php | 18 +++++++++--------- 9 files changed, 31 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e800edd..c4afeee 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ All high‑level encryptors implement the `CryptorInterface`. Inject the desired ```php use Yiisoft\Security\Crypt\CryptorInterface; -$cryptor = $contaier->get(CryptorInterface::class); +$cryptor = $container->get(CryptorInterface::class); /** @var high‑entropy key or low‑entropy password */ $secret; /** @var Optional application‑specific string that is mixed into the KDF */ diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPassword.php index 085a241..7fe1504 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPassword.php @@ -35,7 +35,7 @@ public function __construct( } if ($iterations <= 0) { - throw new RuntimeException("Iterations must be greather then 0, but {$iterations} provided."); + throw new RuntimeException("Iterations must be greater than 0, but {$iterations} provided."); } } diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index dde07dc..09e6ed9 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -7,6 +7,7 @@ use RuntimeException; use SensitiveParameter; use Yiisoft\Strings\StringHelper; +use function bin2hex; /** * VersionedCryptor wraps multiple cryptors and adds a version prefix to the ciphertext. @@ -77,7 +78,7 @@ public function decrypt( $version = StringHelper::byteSubstring($data, 0, $this->versionSize); $cryptor = $this->cryptors[$version] - ?? throw new RuntimeException('version not found'); + ?? throw new EncryptionException(sprintf('Unsupported encrypted data version "0x%s"', bin2hex($version))); $payload = StringHelper::byteSubstring($data, $this->versionSize); diff --git a/tests/Crypt/AbstractAeadCipherCase.php b/tests/Crypt/AbstractAeadCipherCase.php index 886dfcb..9ddb9eb 100644 --- a/tests/Crypt/AbstractAeadCipherCase.php +++ b/tests/Crypt/AbstractAeadCipherCase.php @@ -8,14 +8,14 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Security\Crypt\EncryptionException; -use Yiisoft\Security\Crypt\CipherInterface; +use Yiisoft\Security\Crypt\AeadCipherInterface; /** * @abstract */ abstract class AbstractAeadCipherCase extends TestCase { - abstract protected function createCipherInstance(?string $cipher = null): CipherInterface; + abstract protected function createCipherInstance(?string $cipher = null): AeadCipherInterface; abstract public static function dataProviderCiphers(): iterable; @@ -66,7 +66,7 @@ public function testInvalidCipherThrowsException(): void public function testEncryptWithWrongKeySizeThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); - $key = random_bytes($cipherInstance->getKeySize() + 1); // неверный размер + $key = random_bytes($cipherInstance->getKeySize() + 1); // wrong key size $nonce = random_bytes($cipherInstance->getNonceSize()); $plaintext = 'test-plain-data'; @@ -79,7 +79,7 @@ public function testEncryptWithWrongNonceSizeThrowsException(string $cipher): vo { $cipherInstance = $this->createCipherInstance($cipher); $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize() + 1); // неверный размер + $nonce = random_bytes($cipherInstance->getNonceSize() + 1); // wrong nounce size $plaintext = 'test-plain-data'; $this->expectException(EncryptionException::class); @@ -104,7 +104,7 @@ public function testDecryptWithWrongNonceSizeThrowsException(string $cipher): vo { $cipherInstance = $this->createCipherInstance($cipher); $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize()); // неверный размер + $nonce = random_bytes($cipherInstance->getNonceSize()); // wrong nounce size $plaintext = 'test-plain-data'; $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); diff --git a/tests/Crypt/EnvelopeCryptorTest.php b/tests/Crypt/EnvelopeCryptorTest.php index c54458d..62181f9 100644 --- a/tests/Crypt/EnvelopeCryptorTest.php +++ b/tests/Crypt/EnvelopeCryptorTest.php @@ -39,14 +39,14 @@ public function testEncryptProducesExpectedStructure(): void $callCount++; if ($callCount === 1) { - // Первый вызов: payload = dek + dataNonce, key = kek, nonce length = nonceSize + // First call: payload = dek, key = kek, nonce length = nonceSize [$payload, $key, $nonce] = $args; $this->assertIsString($payload); $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($payload)); $this->assertEquals($kek, $key); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); - return 'encDek--------------------------'; + return 'encDek--------------------------' . '________________'; } [$payload, $key, $nonce] = $args; @@ -62,21 +62,20 @@ public function testEncryptProducesExpectedStructure(): void $result = $cryptor->encrypt($plaintext, $secret, $context); $this->assertIsString($result); $this->assertEquals( - self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('encData'), - //self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('encDekWithTag') + StringHelper::byteLength('encDataWithTag'), + self::KEY_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE + StringHelper::byteLength('encData'), StringHelper::byteLength($result) ); $keySalt = StringHelper::byteSubstring($result, 0, self::KEY_SIZE); $dekNonce = StringHelper::byteSubstring($result, self::KEY_SIZE, self::NONCE_SIZE); - $encDek = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE, self::KEY_SIZE); - $dataNonce = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE, self::NONCE_SIZE); - $ciphertext = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + self::KEY_SIZE + self::NONCE_SIZE); + $encDek = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE, self::KEY_SIZE + self::TAG_SIZE); + $dataNonce = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE), self::NONCE_SIZE); + $ciphertext = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE); $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($keySalt)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dekNonce)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dataNonce)); - $this->assertEquals('encDek--------------------------', $encDek); + $this->assertEquals('encDek--------------------------' . '________________', $encDek); $this->assertEquals('encData', $ciphertext); } diff --git a/tests/Crypt/OpenSSLAeadCipherTest.php b/tests/Crypt/OpenSSLAeadCipherTest.php index 8ddfb98..b6b6dcf 100644 --- a/tests/Crypt/OpenSSLAeadCipherTest.php +++ b/tests/Crypt/OpenSSLAeadCipherTest.php @@ -4,7 +4,7 @@ namespace Yiisoft\Security\Tests\Crypt; -use Yiisoft\Security\Crypt\CipherInterface; +use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\Cipher\OpenSSLAeadCipher; final class OpenSSLAeadCipherTest extends AbstractAeadCipherCase @@ -16,7 +16,7 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface + protected function createCipherInstance(?string $cipher = null): AeadCipherInterface { return $cipher ? new OpenSSLAeadCipher($cipher) : new OpenSSLAeadCipher(); } diff --git a/tests/Crypt/SodiumAeadCipherTest.php b/tests/Crypt/SodiumAeadCipherTest.php index 6729255..6f48457 100644 --- a/tests/Crypt/SodiumAeadCipherTest.php +++ b/tests/Crypt/SodiumAeadCipherTest.php @@ -4,7 +4,7 @@ namespace Yiisoft\Security\Tests\Crypt; -use Yiisoft\Security\Crypt\CipherInterface; +use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; final class SodiumAeadCipherTest extends AbstractAeadCipherCase @@ -16,7 +16,7 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface + protected function createCipherInstance(?string $cipher = null): AeadCipherInterface { return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); } diff --git a/tests/Crypt/SodiumGcmCipherTest.php b/tests/Crypt/SodiumGcmCipherTest.php index bc62de1..ca1e66f 100644 --- a/tests/Crypt/SodiumGcmCipherTest.php +++ b/tests/Crypt/SodiumGcmCipherTest.php @@ -4,7 +4,7 @@ namespace Yiisoft\Security\Tests\Crypt; -use Yiisoft\Security\Crypt\CipherInterface; +use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; final class SodiumGcmCipherTest extends AbstractAeadCipherCase @@ -18,7 +18,7 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface + protected function createCipherInstance(?string $cipher = null): AeadCipherInterface { return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); } diff --git a/tests/Crypt/VersionedCryptorTest.php b/tests/Crypt/VersionedCryptorTest.php index 6682997..ad13d2c 100644 --- a/tests/Crypt/VersionedCryptorTest.php +++ b/tests/Crypt/VersionedCryptorTest.php @@ -12,7 +12,7 @@ final class VersionedCryptorTest extends TestCase { - public function testEncryptPrependsVersionAndDelegates(): void + public function testEncryptPrependsVersionAndDelegates(): void { $plaintext = 'test-plain-data'; $secret = 'test-secret'; @@ -110,14 +110,6 @@ public function testIntegerKeyIsNormalizedToStringAndLengthChecked(): void new VersionedCryptor([12 => $this->createMock(CryptorInterface::class)], '123', 3); } - public function testDecryptThrowsExceptionWhenVersionNotFound(): void - { - $versionedCryptor = new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 2); - - $this->expectException(RuntimeException::class); - $versionedCryptor->decrypt('v2' . 'test-plain-data', 'test-secret'); - } - public function testConstructThrowsWhenCurrentVersionNotRegistered(): void { $this->expectException(RuntimeException::class); @@ -157,6 +149,14 @@ public function testDecryptThrowsExceptionWhenDataTooShort(): void $versionedCryptor->decrypt('x', 'secret'); } + public function testDecryptThrowsExceptionWhenVersionNotFound(): void + { + $versionedCryptor = new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 2); + + $this->expectException(EncryptionException::class); + $versionedCryptor->decrypt('v2' . 'test-plain-data', 'test-secret'); + } + public function testDecryptInvalidData(): void { $cryptor = $this->createMock(CryptorInterface::class); From bad4df8e3fab4dd83c52b03eafa4da2f4b9fe0ec Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 24 May 2026 02:33:59 +0700 Subject: [PATCH 32/70] fix style --- src/Crypt/VersionedCryptor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index 09e6ed9..d06563f 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -7,6 +7,7 @@ use RuntimeException; use SensitiveParameter; use Yiisoft\Strings\StringHelper; + use function bin2hex; /** From d49ad96b9c02dfcc10d75ec4f865926c536d9bb3 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 24 May 2026 03:52:51 +0700 Subject: [PATCH 33/70] update phpdoc --- src/Crypt/VersionedCryptor.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypt/VersionedCryptor.php index d06563f..d002256 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypt/VersionedCryptor.php @@ -45,11 +45,6 @@ public function __construct( } } - /** - * {@inheritdoc} - * - * @throws RuntimeException If encryption fails. - */ public function encrypt( string $data, #[SensitiveParameter] @@ -64,8 +59,7 @@ public function encrypt( /** * {@inheritdoc} * - * @throws RuntimeException If the version prefix cannot be read or no cryptor matches. - * @throws EncryptionException If decryption fails . + * @throws EncryptionException If the version prefix cannot be read or no cryptor matches. */ public function decrypt( string $data, From c7b0247801cf296421387439d3ae413b3bc75a15 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 26 May 2026 01:46:58 +0700 Subject: [PATCH 34/70] add saltSize to kdf interface add argon2 kdf --- src/Crypt/Cipher/SodiumAeadCipher.php | 6 +- src/Crypt/EnvelopeCryptor.php | 28 +++--- src/Crypt/Kdf/KdfKey.php | 8 ++ src/Crypt/Kdf/KdfPasswordArgon2.php | 93 +++++++++++++++++++ ...{KdfPassword.php => KdfPasswordPbrdf2.php} | 22 +++-- src/Crypt/KdfInterface.php | 7 ++ src/Crypt/SessionCryptor.php | 20 ++-- tests/Crypt/AbstractKdfCase.php | 32 +++++-- tests/Crypt/EnvelopeCryptorTest.php | 20 ++-- tests/Crypt/KdfPasswordArgon2Test.php | 51 ++++++++++ ...wordTest.php => KdfPasswordPbrdf2Test.php} | 8 +- tests/Crypt/SessionCryptorTest.php | 16 ++-- 12 files changed, 253 insertions(+), 58 deletions(-) create mode 100644 src/Crypt/Kdf/KdfPasswordArgon2.php rename src/Crypt/Kdf/{KdfPassword.php => KdfPasswordPbrdf2.php} (73%) create mode 100644 tests/Crypt/KdfPasswordArgon2Test.php rename tests/Crypt/{KdfPasswordTest.php => KdfPasswordPbrdf2Test.php} (87%) diff --git a/src/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php index 7d41825..93f0060 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -4,9 +4,9 @@ namespace Yiisoft\Security\Crypt\Cipher; -use Exception; use RuntimeException; use SensitiveParameter; +use SodiumException; use Yiisoft\Security\Crypt\AeadCipherInterface; use Yiisoft\Security\Crypt\EncryptionException; @@ -91,7 +91,7 @@ public function encrypt( 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nonce, $key), 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nonce, $key), }; - } catch (Exception $e) { + } catch (SodiumException $e) { throw new EncryptionException($e->getMessage()); } @@ -110,7 +110,7 @@ public function decrypt( 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nonce, $key), 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nonce, $key), }; - } catch (Exception $e) { + } catch (SodiumException $e) { throw new EncryptionException($e->getMessage()); } diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index feb76a6..d088509 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -34,9 +34,14 @@ final class EnvelopeCryptor implements CryptorInterface */ private readonly int $tagSize; - private readonly int $keyNonceSize; + /** + * @psalm-var int<1, max> + */ + private readonly int $saltSize; + + private readonly int $saltNonceSize; private readonly int $encKeySize; - private readonly int $keyNonceEncKeySize; + private readonly int $saltNonceEncKeySize; private readonly int $prefixSize; /** @@ -50,17 +55,18 @@ public function __construct( $this->keySize = $this->cipher->getKeySize(); $this->nonceSize = $this->cipher->getNonceSize(); $this->tagSize = $this->cipher->getTagSize(); + $this->saltSize = $this->kdf->getSaltSize(); - $this->keyNonceSize = $this->keySize + $this->nonceSize; + $this->saltNonceSize = $this->saltSize + $this->nonceSize; $this->encKeySize = $this->keySize + $this->tagSize; - $this->keyNonceEncKeySize = $this->keyNonceSize + $this->encKeySize; - $this->prefixSize = $this->keyNonceEncKeySize + $this->nonceSize; + $this->saltNonceEncKeySize = $this->saltNonceSize + $this->encKeySize; + $this->prefixSize = $this->saltNonceEncKeySize + $this->nonceSize; } /** * {@inheritdoc} * - * Structure: keySalt (keySize) || dekNonce (nonceSize) || + * Structure: keySalt (saltSize) || dekNonce (nonceSize) || * encrypted(dek) (keySize + tagSize) || * dataNonce (nonceSize) || encrypted(data) (variable + tagSize) */ @@ -70,7 +76,7 @@ public function encrypt( string $secret, string $context = '' ): string { - $keySalt = random_bytes($this->keySize); + $keySalt = random_bytes($this->saltSize); $dek = random_bytes($this->keySize); $dekNonce = random_bytes($this->nonceSize); $dataNonce = random_bytes($this->nonceSize); @@ -98,10 +104,10 @@ public function decrypt( throw new EncryptionException('Encrypted data is too short.'); } - $keySalt = StringHelper::byteSubstring($data, 0, $this->keySize); - $dekNonce = StringHelper::byteSubstring($data, $this->keySize, $this->nonceSize); - $encDek = StringHelper::byteSubstring($data, $this->keyNonceSize, $this->encKeySize); - $dataNonce = StringHelper::byteSubstring($data, $this->keyNonceEncKeySize, $this->nonceSize); + $keySalt = StringHelper::byteSubstring($data, 0, $this->saltSize); + $dekNonce = StringHelper::byteSubstring($data, $this->saltSize, $this->nonceSize); + $encDek = StringHelper::byteSubstring($data, $this->saltNonceSize, $this->encKeySize); + $dataNonce = StringHelper::byteSubstring($data, $this->saltNonceEncKeySize, $this->nonceSize); $dataEncrypted = StringHelper::byteSubstring($data, $this->prefixSize); $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index d70d277..16dd027 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -11,6 +11,8 @@ use Yiisoft\Security\Crypt\KdfInterface; use function hash_hkdf; +use function hash_hmac_algos; +use function in_array; /** * KDF that directly applies HKDF (HMAC-based Key Derivation Function) to the input secret. @@ -25,6 +27,7 @@ final class KdfKey implements KdfInterface */ public function __construct( private readonly string $algorithm = 'sha256', + private readonly int $saltSize = 32, ) { if (!in_array($algorithm, hash_hmac_algos())) { throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); @@ -57,4 +60,9 @@ public function createKey( throw new EncryptionException($e->getMessage()); } } + + public function getSaltSize(): int + { + return $this->saltSize; + } } diff --git a/src/Crypt/Kdf/KdfPasswordArgon2.php b/src/Crypt/Kdf/KdfPasswordArgon2.php new file mode 100644 index 0000000..1e75663 --- /dev/null +++ b/src/Crypt/Kdf/KdfPasswordArgon2.php @@ -0,0 +1,93 @@ +opslimit, $this->memlimit, $this->algo); + + return hash_hkdf($this->hashAlgo, $key, $keySize, $context); + } catch (ValueError|SodiumException $e) { + throw new EncryptionException($e->getMessage()); + } + } + + public function getSaltSize(): int + { + return SODIUM_CRYPTO_PWHASH_SALTBYTES; + } +} diff --git a/src/Crypt/Kdf/KdfPassword.php b/src/Crypt/Kdf/KdfPasswordPbrdf2.php similarity index 73% rename from src/Crypt/Kdf/KdfPassword.php rename to src/Crypt/Kdf/KdfPasswordPbrdf2.php index 7fe1504..b0389e1 100644 --- a/src/Crypt/Kdf/KdfPassword.php +++ b/src/Crypt/Kdf/KdfPasswordPbrdf2.php @@ -12,26 +12,29 @@ use function hash_hkdf; use function hash_pbkdf2; +use function hash_hmac_algos; +use function in_array; /** * KDF that first applies PBKDF2 to the input password, * then applies HKDF to the result. Suitable for deriving cryptographic keys from low-entropy passwords. */ -final class KdfPassword implements KdfInterface +final class KdfPasswordPbrdf2 implements KdfInterface { /** - * @param string $algorithm Hash algorithm for key derivation. {@see hash_hmac_algos()} + * @param string $hashAlgo Hash algorithm for key derivation. {@see hash_hmac_algos()} * @param int $iterations Derivation iterations count. * See [PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for more details. * * @throws RuntimeException */ public function __construct( - private readonly string $algorithm = 'sha256', + private readonly string $hashAlgo = 'sha256', private readonly int $iterations = 600_000, + private readonly int $saltSize = 32, ) { - if (!in_array($algorithm, hash_hmac_algos())) { - throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); + if (!in_array($hashAlgo, hash_hmac_algos())) { + throw new RuntimeException($hashAlgo . ' is not an allowed algorithm.'); } if ($iterations <= 0) { @@ -64,11 +67,16 @@ public function createKey( string $salt, ): string { try { - $key = hash_pbkdf2($this->algorithm, $secret, $salt, $this->iterations, $keySize, true); + $key = hash_pbkdf2($this->hashAlgo, $secret, $salt, $this->iterations, $keySize, true); - return hash_hkdf($this->algorithm, $key, $keySize, $context); + return hash_hkdf($this->hashAlgo, $key, $keySize, $context); } catch (ValueError $e) { throw new EncryptionException($e->getMessage()); } } + + public function getSaltSize(): int + { + return $this->saltSize; + } } diff --git a/src/Crypt/KdfInterface.php b/src/Crypt/KdfInterface.php index 78f3a61..96d84b9 100644 --- a/src/Crypt/KdfInterface.php +++ b/src/Crypt/KdfInterface.php @@ -30,4 +30,11 @@ public function createKey( string $context, string $salt, ): string; + + /** + * @return int Salt size in bytes. + * + * @psalm-return int<1, max> + */ + public function getSaltSize(): int; } diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index ca0ddd8..02135ad 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -26,7 +26,12 @@ final class SessionCryptor implements CryptorInterface */ private readonly int $nonceSize; - private readonly int $keyNonceSize; + /** + * @psalm-var int<1, max> + */ + private readonly int $saltSize; + + private readonly int $saltNonceSize; /** * @param CipherInterface $cipher Low‑level cipher @@ -38,7 +43,8 @@ public function __construct( ) { $this->keySize = $this->cipher->getKeySize(); $this->nonceSize = $this->cipher->getNonceSize(); - $this->keyNonceSize = $this->keySize + $this->nonceSize; + $this->saltSize = $this->kdf->getSaltSize(); + $this->saltNonceSize = $this->saltSize + $this->nonceSize; } /** @@ -52,7 +58,7 @@ public function encrypt( string $secret, string $context = '' ): string { - $keySalt = random_bytes($this->keySize); + $keySalt = random_bytes($this->saltSize); $dataNonce = random_bytes($this->nonceSize); $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); @@ -73,13 +79,13 @@ public function decrypt( string $secret, string $context = '' ): string { - if (StringHelper::byteLength($data) < $this->keyNonceSize) { + if (StringHelper::byteLength($data) < $this->saltNonceSize) { throw new EncryptionException('Encrypted data is too short.'); } - $keySalt = StringHelper::byteSubstring($data, 0, $this->keySize); - $dataNonce = StringHelper::byteSubstring($data, $this->keySize, $this->nonceSize); - $dataEncrypted = StringHelper::byteSubstring($data, $this->keyNonceSize); + $keySalt = StringHelper::byteSubstring($data, 0, $this->saltSize); + $dataNonce = StringHelper::byteSubstring($data, $this->saltSize, $this->nonceSize); + $dataEncrypted = StringHelper::byteSubstring($data, $this->saltNonceSize); $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); diff --git a/tests/Crypt/AbstractKdfCase.php b/tests/Crypt/AbstractKdfCase.php index 458f10f..33ec79f 100644 --- a/tests/Crypt/AbstractKdfCase.php +++ b/tests/Crypt/AbstractKdfCase.php @@ -23,8 +23,9 @@ public function testCreateKeySuccess(): void $kdf = $this->createKdfInstance(); $keySize = 32; $secret = random_bytes($keySize); + $salt = random_bytes($kdf->getSaltSize()); - $key = $kdf->createKey($secret, $keySize, 'test-context', 'text-salt'); + $key = $kdf->createKey($secret, $keySize, 'test-context', $salt); $this->assertSame($keySize, strlen($key)); $this->assertNotEmpty($key); @@ -47,8 +48,9 @@ public function testCreateKeyWithCustomAlgorithm(string $algo, int $keySize): vo { $kdf = $this->createKdfInstance($algo); $secret = random_bytes($keySize); + $salt = random_bytes($kdf->getSaltSize()); - $key = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); + $key = $kdf->createKey($secret, $keySize, 'test-context', $salt); $this->assertSame($keySize, strlen($key)); } @@ -58,9 +60,10 @@ public function testSameParametersProduceSameKey(): void $kdf = $this->createKdfInstance(); $keySize = 32; $secret = random_bytes($keySize); + $salt = random_bytes($kdf->getSaltSize()); - $key1 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); - $key2 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); + $key1 = $kdf->createKey($secret, $keySize, 'test-context', $salt); + $key2 = $kdf->createKey($secret, $keySize, 'test-context', $salt); $this->assertSame($key1, $key2); } @@ -71,17 +74,19 @@ public function testDifferentParamsProducesDifferentKey(): void $keySize = 32; $secret = random_bytes($keySize); $secret2 = random_bytes($keySize); + $salt1 = random_bytes($kdf->getSaltSize()); + $salt2 = random_bytes($kdf->getSaltSize()); - $key11 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt-1'); - $key12 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt-2'); + $key11 = $kdf->createKey($secret, $keySize, 'test-context', $salt1); + $key12 = $kdf->createKey($secret, $keySize, 'test-context', $salt2); $this->assertNotSame($key11, $key12); - $key21 = $kdf->createKey($secret, $keySize, 'context-1', 'test-salt'); - $key22 = $kdf->createKey($secret, $keySize, 'context-2', 'test-salt'); + $key21 = $kdf->createKey($secret, $keySize, 'context-1', $salt1); + $key22 = $kdf->createKey($secret, $keySize, 'context-2', $salt1); $this->assertNotSame($key21, $key22); - $key31 = $kdf->createKey($secret, $keySize, 'test-context', 'test-salt'); - $key32 = $kdf->createKey($secret2, $keySize, 'test-context', 'test-salt'); + $key31 = $kdf->createKey($secret, $keySize, 'test-context', $salt1); + $key32 = $kdf->createKey($secret2, $keySize, 'test-context', $salt1); $this->assertNotSame($key31, $key32); } @@ -98,4 +103,11 @@ public function testInvalidSizeThrowsException(): void $this->expectException(EncryptionException::class); $kdf->createKey('test-secret', -1, 'test-context', 'test-salt'); } + + public function testGetSizes(): void + { + $cipher = $this->createKdfInstance(); + + $this->assertIsInt($cipher->getSaltSize()); + } } diff --git a/tests/Crypt/EnvelopeCryptorTest.php b/tests/Crypt/EnvelopeCryptorTest.php index 62181f9..a1d554e 100644 --- a/tests/Crypt/EnvelopeCryptorTest.php +++ b/tests/Crypt/EnvelopeCryptorTest.php @@ -16,6 +16,7 @@ final class EnvelopeCryptorTest extends TestCase private const KEY_SIZE = 32; private const NONCE_SIZE = 12; private const TAG_SIZE = 16; + private const SALT_SIZE = 16; public function testEncryptProducesExpectedStructure(): void { @@ -29,7 +30,7 @@ public function testEncryptProducesExpectedStructure(): void $kdf->expects($this->once()) ->method('createKey') - ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::KEY_SIZE)) + ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::SALT_SIZE)) ->willReturn($kek); $cipher->expects($this->exactly(2)) @@ -62,17 +63,17 @@ public function testEncryptProducesExpectedStructure(): void $result = $cryptor->encrypt($plaintext, $secret, $context); $this->assertIsString($result); $this->assertEquals( - self::KEY_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE + StringHelper::byteLength('encData'), + self::SALT_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE + StringHelper::byteLength('encData'), StringHelper::byteLength($result) ); - $keySalt = StringHelper::byteSubstring($result, 0, self::KEY_SIZE); - $dekNonce = StringHelper::byteSubstring($result, self::KEY_SIZE, self::NONCE_SIZE); - $encDek = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE, self::KEY_SIZE + self::TAG_SIZE); - $dataNonce = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE), self::NONCE_SIZE); - $ciphertext = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE); + $keySalt = StringHelper::byteSubstring($result, 0, self::SALT_SIZE); + $dekNonce = StringHelper::byteSubstring($result, self::SALT_SIZE, self::NONCE_SIZE); + $encDek = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE, self::KEY_SIZE + self::TAG_SIZE); + $dataNonce = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE), self::NONCE_SIZE); + $ciphertext = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE); - $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($keySalt)); + $this->assertEquals(self::SALT_SIZE, StringHelper::byteLength($keySalt)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dekNonce)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dataNonce)); $this->assertEquals('encDek--------------------------' . '________________', $encDek); @@ -85,7 +86,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $secret = 'test-secret'; $context = 'test-context'; - $keySalt = str_repeat("\x01", self::KEY_SIZE); + $keySalt = str_repeat("\x01", self::SALT_SIZE); $dekNonce = str_repeat("\x02", self::NONCE_SIZE); $dek = str_repeat("\x10", self::KEY_SIZE); $dataNonce = str_repeat("\x20", self::NONCE_SIZE); @@ -159,6 +160,7 @@ public function testDecryptThrowsWhenDataTooShort(): void private function createMocks(): array { $kdf = $this->createMock(KdfInterface::class); + $kdf->method('getSaltSize')->willReturn(self::SALT_SIZE); $cipher = $this->createMock(AeadCipherInterface::class); $cipher->method('getKeySize')->willReturn(self::KEY_SIZE); diff --git a/tests/Crypt/KdfPasswordArgon2Test.php b/tests/Crypt/KdfPasswordArgon2Test.php new file mode 100644 index 0000000..fb77172 --- /dev/null +++ b/tests/Crypt/KdfPasswordArgon2Test.php @@ -0,0 +1,51 @@ +expectException(RuntimeException::class); - new KdfPassword('sha256', 0); + new KdfPasswordPbrdf2('sha256', 0); } } diff --git a/tests/Crypt/SessionCryptorTest.php b/tests/Crypt/SessionCryptorTest.php index ce9a30c..21dede6 100644 --- a/tests/Crypt/SessionCryptorTest.php +++ b/tests/Crypt/SessionCryptorTest.php @@ -15,6 +15,7 @@ final class SessionCryptorTest extends TestCase { private const KEY_SIZE = 32; private const NONCE_SIZE = 12; + private const SALT_SIZE = 16; public function testEncryptProducesExpectedStructure(): void { @@ -26,7 +27,7 @@ public function testEncryptProducesExpectedStructure(): void $kdf->expects($this->once()) ->method('createKey') - ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::KEY_SIZE)) + ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::SALT_SIZE)) ->willReturn('test-derivedkey-123456'); $cipher->expects($this->once()) @@ -40,15 +41,15 @@ public function testEncryptProducesExpectedStructure(): void // result structure: keySalt || nonce || ciphertext $this->assertIsString($result); $this->assertEquals( - self::KEY_SIZE + self::NONCE_SIZE + StringHelper::byteLength('test-ciphertext-and-tag'), + self::SALT_SIZE + self::NONCE_SIZE + StringHelper::byteLength('test-ciphertext-and-tag'), StringHelper::byteLength($result) ); - $keySalt = StringHelper::byteSubstring($result, 0, self::KEY_SIZE); - $nonce = StringHelper::byteSubstring($result, self::KEY_SIZE, self::NONCE_SIZE); - $ciphertext = StringHelper::byteSubstring($result, self::KEY_SIZE + self::NONCE_SIZE); + $keySalt = StringHelper::byteSubstring($result, 0, self::SALT_SIZE); + $nonce = StringHelper::byteSubstring($result, self::SALT_SIZE, self::NONCE_SIZE); + $ciphertext = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE); - $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($keySalt)); + $this->assertEquals(self::SALT_SIZE, StringHelper::byteLength($keySalt)); $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); $this->assertEquals('test-ciphertext-and-tag', $ciphertext); } @@ -59,7 +60,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void $secret = 'test-secret'; $context = 'test-context'; - $keySalt = str_repeat("\x01", self::KEY_SIZE); + $keySalt = str_repeat("\x01", self::SALT_SIZE); $nonce = str_repeat("\x02", self::NONCE_SIZE); $encryptedPayload = 'encrypted-by-cipher'; @@ -112,6 +113,7 @@ public function testDecryptThrowsWhenDataTooShort(): void private function createMocks(): array { $kdf = $this->createMock(KdfInterface::class); + $kdf->method('getSaltSize')->willReturn(self::SALT_SIZE); $cipher = $this->createMock(CipherInterface::class); $cipher->method('getKeySize')->willReturn(self::KEY_SIZE); From 7e29630413f6ae6eaa91feb039f0842de565160d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 26 May 2026 01:55:53 +0700 Subject: [PATCH 35/70] fix psalm --- src/Crypt/Kdf/KdfKey.php | 3 +++ src/Crypt/Kdf/KdfPasswordArgon2.php | 9 ++------- src/Crypt/Kdf/KdfPasswordPbrdf2.php | 3 +++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index 16dd027..e8daacb 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -22,6 +22,9 @@ final class KdfKey implements KdfInterface { /** * @param string $algorithm Hash algorithm for key derivation. {@see hash_hmac_algos()} + * @param int $saltSize + * + * @psalm-param int<1, max> $saltSize * * @throws RuntimeException */ diff --git a/src/Crypt/Kdf/KdfPasswordArgon2.php b/src/Crypt/Kdf/KdfPasswordArgon2.php index 1e75663..c31ffe8 100644 --- a/src/Crypt/Kdf/KdfPasswordArgon2.php +++ b/src/Crypt/Kdf/KdfPasswordArgon2.php @@ -23,13 +23,6 @@ */ final class KdfPasswordArgon2 implements KdfInterface { - /** - * @param string $algorithm Hash algorithm for key derivation. {@see hash_hmac_algos()} - * @param int $iterations Derivation iterations count. - * See [PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for more details. - * - * @throws RuntimeException - */ /** * @param string $hashAlgo Hash algorithm for the HKDF expansion step. {@see hash_hmac_algos()} * @param int $algo Argon2 variant (defaults to Argon2id). @@ -37,6 +30,8 @@ final class KdfPasswordArgon2 implements KdfInterface * @param int $memlimit RAM limit in bytes (memory cost). * See [Argon2 recommendations](https://owasp.org) for details. * + * @psalm-param int<1, max> $saltSize + * * @throws RuntimeException If the Sodium extension is missing. */ public function __construct( diff --git a/src/Crypt/Kdf/KdfPasswordPbrdf2.php b/src/Crypt/Kdf/KdfPasswordPbrdf2.php index b0389e1..9bfdcdf 100644 --- a/src/Crypt/Kdf/KdfPasswordPbrdf2.php +++ b/src/Crypt/Kdf/KdfPasswordPbrdf2.php @@ -25,6 +25,9 @@ final class KdfPasswordPbrdf2 implements KdfInterface * @param string $hashAlgo Hash algorithm for key derivation. {@see hash_hmac_algos()} * @param int $iterations Derivation iterations count. * See [PBKDF2](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for more details. + * @param int $saltSize + * + * @psalm-param int<1, max> $saltSize * * @throws RuntimeException */ From b638977defb3173b331d79527609acf5ab35e3a1 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 26 May 2026 02:12:11 +0700 Subject: [PATCH 36/70] update phpdoc --- src/Crypt/Kdf/KdfKey.php | 10 +++++----- src/Crypt/Kdf/KdfPasswordArgon2.php | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php index e8daacb..6f8c653 100644 --- a/src/Crypt/Kdf/KdfKey.php +++ b/src/Crypt/Kdf/KdfKey.php @@ -21,7 +21,7 @@ final class KdfKey implements KdfInterface { /** - * @param string $algorithm Hash algorithm for key derivation. {@see hash_hmac_algos()} + * @param string $hashAlgo Hash algorithm for key derivation. {@see hash_hmac_algos()} * @param int $saltSize * * @psalm-param int<1, max> $saltSize @@ -29,11 +29,11 @@ final class KdfKey implements KdfInterface * @throws RuntimeException */ public function __construct( - private readonly string $algorithm = 'sha256', + private readonly string $hashAlgo = 'sha256', private readonly int $saltSize = 32, ) { - if (!in_array($algorithm, hash_hmac_algos())) { - throw new RuntimeException($algorithm . ' is not an allowed algorithm.'); + if (!in_array($hashAlgo, hash_hmac_algos())) { + throw new RuntimeException($hashAlgo . ' is not an allowed algorithm.'); } } @@ -58,7 +58,7 @@ public function createKey( string $salt, ): string { try { - return hash_hkdf($this->algorithm, $secret, $keySize, $context, $salt); + return hash_hkdf($this->hashAlgo, $secret, $keySize, $context, $salt); } catch (ValueError $e) { throw new EncryptionException($e->getMessage()); } diff --git a/src/Crypt/Kdf/KdfPasswordArgon2.php b/src/Crypt/Kdf/KdfPasswordArgon2.php index c31ffe8..bafd9b5 100644 --- a/src/Crypt/Kdf/KdfPasswordArgon2.php +++ b/src/Crypt/Kdf/KdfPasswordArgon2.php @@ -20,6 +20,8 @@ /** * KDF that applies Argon2id to the input password, followed by HKDF for key expansion. * Suitable for deriving high-entropy cryptographic keys from low-entropy passwords. + * + * Note: `sodium_crypto_pwhash()` always uses a single thread (p=1). */ final class KdfPasswordArgon2 implements KdfInterface { From 40430109b119653cc2af86bf557d755a7bf67b14 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 26 May 2026 02:15:44 +0700 Subject: [PATCH 37/70] fix misprint --- .../Kdf/{KdfPasswordPbrdf2.php => KdfPasswordPbkdf2.php} | 2 +- ...dfPasswordPbrdf2Test.php => KdfPasswordPbkdf2Test.php} | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/Crypt/Kdf/{KdfPasswordPbrdf2.php => KdfPasswordPbkdf2.php} (98%) rename tests/Crypt/{KdfPasswordPbrdf2Test.php => KdfPasswordPbkdf2Test.php} (87%) diff --git a/src/Crypt/Kdf/KdfPasswordPbrdf2.php b/src/Crypt/Kdf/KdfPasswordPbkdf2.php similarity index 98% rename from src/Crypt/Kdf/KdfPasswordPbrdf2.php rename to src/Crypt/Kdf/KdfPasswordPbkdf2.php index 9bfdcdf..d4698dd 100644 --- a/src/Crypt/Kdf/KdfPasswordPbrdf2.php +++ b/src/Crypt/Kdf/KdfPasswordPbkdf2.php @@ -19,7 +19,7 @@ * KDF that first applies PBKDF2 to the input password, * then applies HKDF to the result. Suitable for deriving cryptographic keys from low-entropy passwords. */ -final class KdfPasswordPbrdf2 implements KdfInterface +final class KdfPasswordPbkdf2 implements KdfInterface { /** * @param string $hashAlgo Hash algorithm for key derivation. {@see hash_hmac_algos()} diff --git a/tests/Crypt/KdfPasswordPbrdf2Test.php b/tests/Crypt/KdfPasswordPbkdf2Test.php similarity index 87% rename from tests/Crypt/KdfPasswordPbrdf2Test.php rename to tests/Crypt/KdfPasswordPbkdf2Test.php index 4ee287e..d1e18fa 100644 --- a/tests/Crypt/KdfPasswordPbrdf2Test.php +++ b/tests/Crypt/KdfPasswordPbkdf2Test.php @@ -6,13 +6,13 @@ use RuntimeException; use Yiisoft\Security\Crypt\KdfInterface; -use Yiisoft\Security\Crypt\Kdf\KdfPasswordPbrdf2; +use Yiisoft\Security\Crypt\Kdf\KdfPasswordPbkdf2; -final class KdfPasswordPbrdf2Test extends AbstractKdfCase +final class KdfPasswordPbkdf2Test extends AbstractKdfCase { protected function createKdfInstance(?string $hash = null): KdfInterface { - return $hash ? new KdfPasswordPbrdf2($hash, 100_000) : new KdfPasswordPbrdf2(iterations: 100_000); + return $hash ? new KdfPasswordPbkdf2($hash, 100_000) : new KdfPasswordPbkdf2(iterations: 100_000); } public static function dataProviderAlgos(): iterable @@ -53,6 +53,6 @@ public static function dataProviderKeyValues(): iterable public function testConstructorThrowsExceptionWhenIterationsLessThanOne(): void { $this->expectException(RuntimeException::class); - new KdfPasswordPbrdf2('sha256', 0); + new KdfPasswordPbkdf2('sha256', 0); } } From 2345581fb726acb135ef3ca88582fab308ff22d9 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 26 May 2026 03:00:29 +0700 Subject: [PATCH 38/70] update psalm for ciphers without nounce --- src/Crypt/Cipher/OpenSSLAeadCipher.php | 3 +++ src/Crypt/Cipher/SodiumAeadCipher.php | 3 +++ src/Crypt/CipherInterface.php | 2 +- src/Crypt/EnvelopeCryptor.php | 1 + src/Crypt/SessionCryptor.php | 1 + 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypt/Cipher/OpenSSLAeadCipher.php index 12d7c4d..b32d3bb 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypt/Cipher/OpenSSLAeadCipher.php @@ -126,6 +126,9 @@ public function getKeySize(): int return $this->keySize; } + /** + * @psalm-return int<1, max> + */ public function getNonceSize(): int { return $this->nonceSize; diff --git a/src/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypt/Cipher/SodiumAeadCipher.php index 93f0060..a988356 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypt/Cipher/SodiumAeadCipher.php @@ -126,6 +126,9 @@ public function getKeySize(): int return $this->keySize; } + /** + * @psalm-return int<1, max> + */ public function getNonceSize(): int { return $this->nonceSize; diff --git a/src/Crypt/CipherInterface.php b/src/Crypt/CipherInterface.php index 96b4959..8a011a8 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypt/CipherInterface.php @@ -55,7 +55,7 @@ public function getKeySize(): int; /** * @return int Nonce size in bytes * - * @psalm-return int<1, max> + * @psalm-return int<0, max> */ public function getNonceSize(): int; } diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php index d088509..0080603 100644 --- a/src/Crypt/EnvelopeCryptor.php +++ b/src/Crypt/EnvelopeCryptor.php @@ -53,6 +53,7 @@ public function __construct( private readonly KdfInterface $kdf, ) { $this->keySize = $this->cipher->getKeySize(); + /** @psalm-var int<1, max> */ $this->nonceSize = $this->cipher->getNonceSize(); $this->tagSize = $this->cipher->getTagSize(); $this->saltSize = $this->kdf->getSaltSize(); diff --git a/src/Crypt/SessionCryptor.php b/src/Crypt/SessionCryptor.php index 02135ad..74c8272 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypt/SessionCryptor.php @@ -42,6 +42,7 @@ public function __construct( private readonly KdfInterface $kdf, ) { $this->keySize = $this->cipher->getKeySize(); + /** @psalm-var int<1, max> */ $this->nonceSize = $this->cipher->getNonceSize(); $this->saltSize = $this->kdf->getSaltSize(); $this->saltNonceSize = $this->saltSize + $this->nonceSize; From 960dabcbe15fe468e5fd92c852bb61ba416dee8d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 18 Jun 2026 02:48:29 +0700 Subject: [PATCH 39/70] crypto rev2 --- .github/workflows/build.yml | 2 +- composer.json | 6 +- src/Crypt/AeadCipherInterface.php | 18 - src/Crypt/EnvelopeCryptor.php | 119 ------- src/Crypt/Kdf/KdfKey.php | 71 ---- src/Crypt/Kdf/KdfPasswordArgon2.php | 90 ----- src/Crypt/Kdf/KdfPasswordPbkdf2.php | 85 ----- .../Cipher/OpenSSLAeadCipher.php | 53 ++- src/Crypto/Cipher/OpenSSLWrapCipher.php | 167 +++++++++ .../Cipher/SodiumAeadCipher.php | 72 ++-- src/{Crypt => Crypto}/CipherInterface.php | 25 +- src/{Crypt => Crypto}/CryptorInterface.php | 14 +- src/{Crypt => Crypto}/EncryptionException.php | 2 +- src/Crypto/EnvelopeCryptor.php | 132 +++++++ src/Crypto/Kdf/KdfKey.php | 117 +++++++ src/Crypto/Kdf/KdfPasswordArgon2.php | 105 ++++++ src/Crypto/Kdf/KdfPasswordPbkdf2.php | 104 ++++++ .../KdfCryptor.php} | 52 +-- src/{Crypt => Crypto}/KdfInterface.php | 17 +- src/{Crypt => Crypto}/VersionedCryptor.php | 28 +- tests/Crypt/AbstractKdfCase.php | 113 ------ tests/Crypt/EnvelopeCryptorTest.php | 172 --------- tests/Crypt/KdfKeyTest.php | 60 ---- tests/Crypt/SessionCryptorTest.php | 124 ------- .../Cipher/AbstractCipherCase.php} | 106 +++--- tests/Crypto/Cipher/CipherWithAeadTrait.php | 82 +++++ tests/Crypto/Cipher/CipherWithNonceTrait.php | 51 +++ .../Cipher}/OpenSSLAeadCipherTest.php | 40 ++- tests/Crypto/Cipher/OpenSSLWrapCipherTest.php | 80 +++++ .../Cipher}/SodiumAeadCipherTest.php | 33 +- .../Cipher}/SodiumGcmCipherTest.php | 25 +- tests/Crypto/EnvelopeCryptorTest.php | 325 ++++++++++++++++++ .../EnvelopeCryptorWithSingleCipherTest.php | 141 ++++++++ tests/Crypto/Kdf/AbstractKdfCase.php | 155 +++++++++ tests/Crypto/Kdf/KdfKeyTest.php | 94 +++++ .../Kdf}/KdfPasswordArgon2Test.php | 15 +- .../Kdf}/KdfPasswordPbkdf2Test.php | 21 +- tests/Crypto/Kdf/StringableParam.php | 21 ++ tests/Crypto/KdfCryptorTest.php | 204 +++++++++++ .../VersionedCryptorTest.php | 66 ++-- 40 files changed, 2161 insertions(+), 1046 deletions(-) delete mode 100644 src/Crypt/AeadCipherInterface.php delete mode 100644 src/Crypt/EnvelopeCryptor.php delete mode 100644 src/Crypt/Kdf/KdfKey.php delete mode 100644 src/Crypt/Kdf/KdfPasswordArgon2.php delete mode 100644 src/Crypt/Kdf/KdfPasswordPbkdf2.php rename src/{Crypt => Crypto}/Cipher/OpenSSLAeadCipher.php (74%) create mode 100644 src/Crypto/Cipher/OpenSSLWrapCipher.php rename src/{Crypt => Crypto}/Cipher/SodiumAeadCipher.php (58%) rename src/{Crypt => Crypto}/CipherInterface.php (61%) rename src/{Crypt => Crypto}/CryptorInterface.php (64%) rename src/{Crypt => Crypto}/EncryptionException.php (83%) create mode 100644 src/Crypto/EnvelopeCryptor.php create mode 100644 src/Crypto/Kdf/KdfKey.php create mode 100644 src/Crypto/Kdf/KdfPasswordArgon2.php create mode 100644 src/Crypto/Kdf/KdfPasswordPbkdf2.php rename src/{Crypt/SessionCryptor.php => Crypto/KdfCryptor.php} (50%) rename src/{Crypt => Crypto}/KdfInterface.php (71%) rename src/{Crypt => Crypto}/VersionedCryptor.php (80%) delete mode 100644 tests/Crypt/AbstractKdfCase.php delete mode 100644 tests/Crypt/EnvelopeCryptorTest.php delete mode 100644 tests/Crypt/KdfKeyTest.php delete mode 100644 tests/Crypt/SessionCryptorTest.php rename tests/{Crypt/AbstractAeadCipherCase.php => Crypto/Cipher/AbstractCipherCase.php} (55%) create mode 100644 tests/Crypto/Cipher/CipherWithAeadTrait.php create mode 100644 tests/Crypto/Cipher/CipherWithNonceTrait.php rename tests/{Crypt => Crypto/Cipher}/OpenSSLAeadCipherTest.php (55%) create mode 100644 tests/Crypto/Cipher/OpenSSLWrapCipherTest.php rename tests/{Crypt => Crypto/Cipher}/SodiumAeadCipherTest.php (57%) rename tests/{Crypt => Crypto/Cipher}/SodiumGcmCipherTest.php (62%) create mode 100644 tests/Crypto/EnvelopeCryptorTest.php create mode 100644 tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php create mode 100644 tests/Crypto/Kdf/AbstractKdfCase.php create mode 100644 tests/Crypto/Kdf/KdfKeyTest.php rename tests/{Crypt => Crypto/Kdf}/KdfPasswordArgon2Test.php (67%) rename tests/{Crypt => Crypto/Kdf}/KdfPasswordPbkdf2Test.php (64%) create mode 100644 tests/Crypto/Kdf/StringableParam.php create mode 100644 tests/Crypto/KdfCryptorTest.php rename tests/{Crypt => Crypto}/VersionedCryptorTest.php (64%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b31b82..2f84bc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,4 +32,4 @@ jobs: ['ubuntu-latest', 'windows-latest'] php: >- ['8.1', '8.2', '8.3', '8.4', '8.5'] - extensions: sodium + extensions: sodium, openssl diff --git a/composer.json b/composer.json index fe6d65b..9256500 100644 --- a/composer.json +++ b/composer.json @@ -36,8 +36,6 @@ "require": { "php": "8.1 - 8.5", "ext-hash": "*", - "ext-openssl": "*", - "ext-sodium": "*", "yiisoft/strings": "^2.0" }, "require-dev": { @@ -47,6 +45,10 @@ "rector/rector": "^2.0.9", "spatie/phpunit-watcher": "^1.24" }, + "suggest": { + "ext-openssl": "*", + "ext-sodium": "*" + }, "autoload": { "psr-4": { "Yiisoft\\Security\\": "src" diff --git a/src/Crypt/AeadCipherInterface.php b/src/Crypt/AeadCipherInterface.php deleted file mode 100644 index af54c67..0000000 --- a/src/Crypt/AeadCipherInterface.php +++ /dev/null @@ -1,18 +0,0 @@ - - */ - public function getTagSize(): int; -} diff --git a/src/Crypt/EnvelopeCryptor.php b/src/Crypt/EnvelopeCryptor.php deleted file mode 100644 index 0080603..0000000 --- a/src/Crypt/EnvelopeCryptor.php +++ /dev/null @@ -1,119 +0,0 @@ - - */ - private readonly int $keySize; - - /** - * @psalm-var int<1, max> - */ - private readonly int $nonceSize; - - /** - * @psalm-var int<1, max> - */ - private readonly int $tagSize; - - /** - * @psalm-var int<1, max> - */ - private readonly int $saltSize; - - private readonly int $saltNonceSize; - private readonly int $encKeySize; - private readonly int $saltNonceEncKeySize; - private readonly int $prefixSize; - - /** - * @param AeadCipherInterface $cipher AEAD cipher (e.g., AES-256-GCM) - * @param KdfInterface $kdf Key derivation function (used to derive KEK from secret) - */ - public function __construct( - private readonly AeadCipherInterface $cipher, - private readonly KdfInterface $kdf, - ) { - $this->keySize = $this->cipher->getKeySize(); - /** @psalm-var int<1, max> */ - $this->nonceSize = $this->cipher->getNonceSize(); - $this->tagSize = $this->cipher->getTagSize(); - $this->saltSize = $this->kdf->getSaltSize(); - - $this->saltNonceSize = $this->saltSize + $this->nonceSize; - $this->encKeySize = $this->keySize + $this->tagSize; - $this->saltNonceEncKeySize = $this->saltNonceSize + $this->encKeySize; - $this->prefixSize = $this->saltNonceEncKeySize + $this->nonceSize; - } - - /** - * {@inheritdoc} - * - * Structure: keySalt (saltSize) || dekNonce (nonceSize) || - * encrypted(dek) (keySize + tagSize) || - * dataNonce (nonceSize) || encrypted(data) (variable + tagSize) - */ - public function encrypt( - string $data, - #[SensitiveParameter] - string $secret, - string $context = '' - ): string { - $keySalt = random_bytes($this->saltSize); - $dek = random_bytes($this->keySize); - $dekNonce = random_bytes($this->nonceSize); - $dataNonce = random_bytes($this->nonceSize); - - $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dekEncrypted = $this->cipher->encrypt($dek, $kek, $dekNonce); - $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); - - // keySalt || dekNonce || cipherdek || tag || dataNonce || ciphertext || tag - return $keySalt . $dekNonce . $dekEncrypted . $dataNonce . $dataEncrypted; - } - - /** - * {@inheritdoc} - * - * @throws EncryptionException If decryption fails. - */ - public function decrypt( - string $data, - #[SensitiveParameter] - string $secret, - string $context = '' - ): string { - if (StringHelper::byteLength($data) < $this->prefixSize) { - throw new EncryptionException('Encrypted data is too short.'); - } - - $keySalt = StringHelper::byteSubstring($data, 0, $this->saltSize); - $dekNonce = StringHelper::byteSubstring($data, $this->saltSize, $this->nonceSize); - $encDek = StringHelper::byteSubstring($data, $this->saltNonceSize, $this->encKeySize); - $dataNonce = StringHelper::byteSubstring($data, $this->saltNonceEncKeySize, $this->nonceSize); - $dataEncrypted = StringHelper::byteSubstring($data, $this->prefixSize); - - $kek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); - $dek = $this->cipher->decrypt($encDek, $kek, $dekNonce); - - return $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); - } -} diff --git a/src/Crypt/Kdf/KdfKey.php b/src/Crypt/Kdf/KdfKey.php deleted file mode 100644 index 6f8c653..0000000 --- a/src/Crypt/Kdf/KdfKey.php +++ /dev/null @@ -1,71 +0,0 @@ - $saltSize - * - * @throws RuntimeException - */ - public function __construct( - private readonly string $hashAlgo = 'sha256', - private readonly int $saltSize = 32, - ) { - if (!in_array($hashAlgo, hash_hmac_algos())) { - throw new RuntimeException($hashAlgo . ' is not an allowed algorithm.'); - } - } - - /** - * Derives a key using HKDF (RFC 5869). - * - * @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 HKDF info). - * @param string $salt Salt value (optional, but recommended for stronger extraction). - * - * @throws RuntimeException If HKDF fails. - * @return string Derived key (raw binary). - * - * @psalm-mutation-free - */ - public function createKey( - #[SensitiveParameter] - string $secret, - int $keySize, - string $context, - string $salt, - ): string { - try { - return hash_hkdf($this->hashAlgo, $secret, $keySize, $context, $salt); - } catch (ValueError $e) { - throw new EncryptionException($e->getMessage()); - } - } - - public function getSaltSize(): int - { - return $this->saltSize; - } -} diff --git a/src/Crypt/Kdf/KdfPasswordArgon2.php b/src/Crypt/Kdf/KdfPasswordArgon2.php deleted file mode 100644 index bafd9b5..0000000 --- a/src/Crypt/Kdf/KdfPasswordArgon2.php +++ /dev/null @@ -1,90 +0,0 @@ - $saltSize - * - * @throws RuntimeException If the Sodium extension is missing. - */ - public function __construct( - private readonly string $hashAlgo = 'sha256', - private readonly int $algo = SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13, - private readonly int $opslimit = SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, - private readonly int $memlimit = SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE, - ) { - if (!extension_loaded('sodium')) { - throw new RuntimeException('Encryption requires the Sodium PHP extension.'); - } - if (!in_array($hashAlgo, hash_hmac_algos())) { - throw new RuntimeException($hashAlgo . ' is not an allowed algorithm.'); - } - } - - /** - * Derives a key from a password using Argon2 + HKDF. - * - * Steps: - * 1. Argon2id hashes the password and salt into a high-entropy intermediate key. - * 2. HKDF expands the result 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 (must be random and unique, 16 bytes for Argon2). - * - * @throws EncryptionException If hashing or key expansion fails. - * @return string Derived key (raw binary). - * - * @psalm-mutation-free - */ - public function createKey( - #[SensitiveParameter] - string $secret, - int $keySize, - string $context, - string $salt, - ): string { - try { - $key = sodium_crypto_pwhash($keySize, $secret, $salt, $this->opslimit, $this->memlimit, $this->algo); - - return hash_hkdf($this->hashAlgo, $key, $keySize, $context); - } catch (ValueError|SodiumException $e) { - throw new EncryptionException($e->getMessage()); - } - } - - public function getSaltSize(): int - { - return SODIUM_CRYPTO_PWHASH_SALTBYTES; - } -} diff --git a/src/Crypt/Kdf/KdfPasswordPbkdf2.php b/src/Crypt/Kdf/KdfPasswordPbkdf2.php deleted file mode 100644 index d4698dd..0000000 --- a/src/Crypt/Kdf/KdfPasswordPbkdf2.php +++ /dev/null @@ -1,85 +0,0 @@ - $saltSize - * - * @throws RuntimeException - */ - public function __construct( - private readonly string $hashAlgo = 'sha256', - private readonly int $iterations = 600_000, - private readonly int $saltSize = 32, - ) { - if (!in_array($hashAlgo, hash_hmac_algos())) { - throw new RuntimeException($hashAlgo . ' is not an allowed algorithm.'); - } - - if ($iterations <= 0) { - throw new RuntimeException("Iterations must be greater than 0, but {$iterations} provided."); - } - } - - /** - * Derives a key from a password using PBKDF2 + HKDF. - * - * Steps: - * 1. PBKDF2 expands the password and salt into an intermediate key. - * 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, at least 16 bytes). - * - * @throws RuntimeException If PBKDF2 or HKDF fails. - * @return string Derived key (raw binary). - * - * @psalm-mutation-free - */ - public function createKey( - #[SensitiveParameter] - string $secret, - int $keySize, - string $context, - string $salt, - ): string { - try { - $key = hash_pbkdf2($this->hashAlgo, $secret, $salt, $this->iterations, $keySize, true); - - return hash_hkdf($this->hashAlgo, $key, $keySize, $context); - } catch (ValueError $e) { - throw new EncryptionException($e->getMessage()); - } - } - - public function getSaltSize(): int - { - return $this->saltSize; - } -} diff --git a/src/Crypt/Cipher/OpenSSLAeadCipher.php b/src/Crypto/Cipher/OpenSSLAeadCipher.php similarity index 74% rename from src/Crypt/Cipher/OpenSSLAeadCipher.php rename to src/Crypto/Cipher/OpenSSLAeadCipher.php index b32d3bb..dd77c97 100644 --- a/src/Crypt/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypto/Cipher/OpenSSLAeadCipher.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt\Cipher; +namespace Yiisoft\Security\Crypto\Cipher; use RuntimeException; use SensitiveParameter; -use Yiisoft\Security\Crypt\AeadCipherInterface; -use Yiisoft\Security\Crypt\EncryptionException; +use Yiisoft\Security\Crypto\CipherInterface; +use Yiisoft\Security\Crypto\EncryptionException; use Yiisoft\Strings\StringHelper; use function array_key_exists; @@ -18,13 +18,10 @@ /** * AEAD cipher implementation using OpenSSL extension. - * Supports only AES-GCM family (128, 192, 256) with 16-byte authentication tags. + * Supports AES-GCM (128, 192, 256) and ChaCha20-Poly1305(IETF variant) with 16-byte authentication tags. */ -final class OpenSSLAeadCipher implements AeadCipherInterface +final class OpenSSLAeadCipher implements CipherInterface { - /** - * Authentication tag size in bytes (always 16 for GCM). - */ private const TAG_SIZE = 16; /** @@ -41,10 +38,8 @@ final class OpenSSLAeadCipher implements AeadCipherInterface * Look-up table of allowed OpenSSL ciphers. * * Each entry maps a cipher name to: - * - Key size (bytes) – required key length. - * - Nonce size (bytes) – used as IV length. - * - * @var array + * - Key size (bytes) + * - Nonce size (bytes) * * @psalm-var array */ @@ -52,6 +47,7 @@ final class OpenSSLAeadCipher implements AeadCipherInterface 'AES-128-GCM' => [16, 12], 'AES-192-GCM' => [24, 12], 'AES-256-GCM' => [32, 12], + 'CHACHA20-POLY1305' => [32, 12], // IETF variant ]; /** @@ -60,23 +56,29 @@ final class OpenSSLAeadCipher implements AeadCipherInterface * @throws RuntimeException If OpenSSL extension is not loaded or the cipher is not allowed. */ public function __construct( - private readonly string $cipher = 'AES-256-GCM', + private readonly 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.'); + 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 $nonce = '', + string $aad = '', ): string { if (StringHelper::byteLength($key) !== $this->keySize) { throw new EncryptionException("Key must be {$this->keySize} bytes long."); @@ -85,7 +87,7 @@ public function encrypt( 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, '', self::TAG_SIZE); + $encrypted = openssl_encrypt($data, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $nonce, $tag, $aad, self::TAG_SIZE); if ($encrypted === false) { $error = openssl_error_string() ?: 'Unknown error'; @@ -95,11 +97,17 @@ public function encrypt( 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 $nonce = '', + string $aad = '', ): string { if (StringHelper::byteLength($key) !== $this->keySize) { throw new EncryptionException("Key must be {$this->keySize} bytes long."); @@ -111,7 +119,7 @@ public function decrypt( $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); + $decrypted = openssl_decrypt($ciphertext, $this->cipher, $key, OPENSSL_RAW_DATA | OPENSSL_DONT_ZERO_PAD_KEY, $nonce, $tag, $aad); if ($decrypted === false) { $error = openssl_error_string() ?: 'Unknown error'; @@ -127,6 +135,8 @@ public function getKeySize(): int } /** + * {@inheritdoc} + * * @psalm-return int<1, max> */ public function getNonceSize(): int @@ -134,7 +144,12 @@ public function getNonceSize(): int return $this->nonceSize; } - public function getTagSize(): int + /** + * {@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..8a42c1c --- /dev/null +++ b/src/Crypto/Cipher/OpenSSLWrapCipher.php @@ -0,0 +1,167 @@ + + */ + private readonly 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 readonly string $dummyNonce; + + /** + * Look-up table of allowed OpenSSL key wrap ciphers. + * + * Each entry maps a cipher name to: + * - Key size (bytes) + * - Nonce size (bytes) – though not used, required for interface compatibility. + * + * @psalm-var array + */ + private const ALLOWED_CIPHERS = [ + 'AES-128-WRAP' => [16, 8], + 'AES-192-WRAP' => [24, 8], + 'AES-256-WRAP' => [32, 8], + ]; + + /** + * @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 readonly 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) { + $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) { + $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/Crypt/Cipher/SodiumAeadCipher.php b/src/Crypto/Cipher/SodiumAeadCipher.php similarity index 58% rename from src/Crypt/Cipher/SodiumAeadCipher.php rename to src/Crypto/Cipher/SodiumAeadCipher.php index a988356..3047ba0 100644 --- a/src/Crypt/Cipher/SodiumAeadCipher.php +++ b/src/Crypto/Cipher/SodiumAeadCipher.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt\Cipher; +namespace Yiisoft\Security\Crypto\Cipher; use RuntimeException; use SensitiveParameter; use SodiumException; -use Yiisoft\Security\Crypt\AeadCipherInterface; -use Yiisoft\Security\Crypt\EncryptionException; +use Yiisoft\Security\Crypto\CipherInterface; +use Yiisoft\Security\Crypto\EncryptionException; use function array_key_exists; use function extension_loaded; @@ -23,11 +23,12 @@ /** * AEAD cipher implementation using libsodium extension. * Supports AES-256-GCM (hardware accelerated), ChaCha20-Poly1305-IETF, and XChaCha20-Poly1305-IETF. + * Authentication tag is always 16 bytes and is included in the returned ciphertext. */ -final class SodiumAeadCipher implements AeadCipherInterface +final class SodiumAeadCipher implements CipherInterface { /** - * Authentication tag size in bytes (always 16 for these AEAD modes). + * Authentication tag size in bytes (constant for all supported modes). */ private const TAG_SIZE = 16; @@ -45,17 +46,15 @@ final class SodiumAeadCipher implements AeadCipherInterface * Look-up table of allowed Sodium ciphers. * * Each entry maps a cipher name to: - * - Key size (bytes) – required key length. - * - Nonce size (bytes) – used as nonce length. + * - Key size (bytes) – always 32 for these ciphers. + * - Nonce size (bytes). * - * @var array - * - * @psalm-var array + * @psalm-var array */ private const ALLOWED_CIPHERS = [ - 'AES-256-GCM' => [SODIUM_CRYPTO_AEAD_AES256GCM_KEYBYTES, SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES], - 'ChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_KEYBYTES, SODIUM_CRYPTO_AEAD_CHACHA20POLY1305_IETF_NPUBBYTES], - 'XChaCha20-Poly1305-IETF' => [SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES], + 'AES-256-GCM' => [32, 12], + 'CHACHA20-POLY1305-IETF' => [32, 12], + 'XCHACHA20-POLY1305-IETF' => [32, 24], ]; /** @@ -64,32 +63,40 @@ final class SodiumAeadCipher implements AeadCipherInterface * @throws RuntimeException If sodium extension is missing, cipher not allowed, or AES-256-GCM without hardware support. */ public function __construct( - private readonly string $cipher = 'AES-256-GCM', + private readonly 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.'); + 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.'); + 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 $nonce = '', + string $aad = '', ): string { try { $encrypted = match ($this->cipher) { - 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_encrypt($data, '', $nonce, $key), - 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_encrypt($data, '', $nonce, $key), - 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($data, '', $nonce, $key), + '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()); @@ -98,17 +105,25 @@ public function encrypt( 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 $nonce = '', + string $aad = '', ): string { try { $decrypted = match ($this->cipher) { - 'AES-256-GCM' => sodium_crypto_aead_aes256gcm_decrypt($data, '', $nonce, $key), - 'ChaCha20-Poly1305-IETF' => sodium_crypto_aead_chacha20poly1305_ietf_decrypt($data, '', $nonce, $key), - 'XChaCha20-Poly1305-IETF' => sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($data, '', $nonce, $key), + '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()); @@ -127,6 +142,8 @@ public function getKeySize(): int } /** + * {@inheritdoc} + * * @psalm-return int<1, max> */ public function getNonceSize(): int @@ -134,7 +151,12 @@ public function getNonceSize(): int return $this->nonceSize; } - public function getTagSize(): int + /** + * {@inheritdoc} + * + * @psalm-return 16 + */ + public function getOverheadSize(): int { return self::TAG_SIZE; } diff --git a/src/Crypt/CipherInterface.php b/src/Crypto/CipherInterface.php similarity index 61% rename from src/Crypt/CipherInterface.php rename to src/Crypto/CipherInterface.php index 8a011a8..fe053d7 100644 --- a/src/Crypt/CipherInterface.php +++ b/src/Crypto/CipherInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt; +namespace Yiisoft\Security\Crypto; use SensitiveParameter; @@ -12,37 +12,43 @@ interface CipherInterface { /** - * Encrypts the provided data with the given key and nonce. + * Encrypts the provided data with the given key, nonce, and additional authenticated data. * * @param string $data Plaintext to encrypt. * @param string $key Secret encryption key (sensitive). * @param string $nonce Initialization vector or nonce. + * @param string $aad Additional authenticated data. * * @throws EncryptionException If encryption fails. + * * @return string Ciphertext. */ public function encrypt( string $data, #[SensitiveParameter] string $key, - string $nonce, + string $nonce = '', + string $aad = '', ): string; /** - * Decrypts the provided ciphertext with the given key and nonce. + * Decrypts the provided ciphertext with the given key, nonce, and additional authenticated data. * * @param string $data Ciphertext to decrypt. * @param string $key Secret encryption key (sensitive). * @param string $nonce Nonce used during encryption. + * @param string $aad Additional authenticated data (must match the value used during encryption). * * @throws EncryptionException If decryption fails. + * * @return string Decrypted plaintext. */ public function decrypt( string $data, #[SensitiveParameter] string $key, - string $nonce, + string $nonce = '', + string $aad = '', ): string; /** @@ -53,9 +59,16 @@ public function decrypt( public function getKeySize(): int; /** - * @return int Nonce size in bytes + * @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/Crypt/CryptorInterface.php b/src/Crypto/CryptorInterface.php similarity index 64% rename from src/Crypt/CryptorInterface.php rename to src/Crypto/CryptorInterface.php index 0a96368..2ea779d 100644 --- a/src/Crypt/CryptorInterface.php +++ b/src/Crypto/CryptorInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt; +namespace Yiisoft\Security\Crypto; use SensitiveParameter; @@ -16,11 +16,11 @@ interface CryptorInterface * * @param string $data Plaintext to encrypt. * @param string $secret Password or raw key (sensitive). - * @param string $context Application-specific context (used in key derivation). + * @param string $context Unique per-encryption context string. Must match during decryption. * * @throws EncryptionException If encryption fails. - * @throws \RuntimeException If required PHP extension is missing. - * @return string Encrypted payload (includes nonce, salt, authentication tag, etc.). + * + * @return string Encrypted payload. */ public function encrypt( string $data, @@ -33,11 +33,11 @@ public function encrypt( * Decrypts the given data using the secret and context string. * * @param string $data Encrypted payload to decrypt. - * @param string $secret Password or raw key (sensitive). - * @param string $context Application-specific context (must match the one used for encryption). + * @param string $secret Password or raw key (sensitive). Must be the same as used for encryption. + * @param string $context Context string that was used during encryption. * * @throws EncryptionException If decryption fails. - * @throws \RuntimeException If required PHP extension is missing or data is malformed. + * * @return string Decrypted plaintext. */ public function decrypt( diff --git a/src/Crypt/EncryptionException.php b/src/Crypto/EncryptionException.php similarity index 83% rename from src/Crypt/EncryptionException.php rename to src/Crypto/EncryptionException.php index b9130a2..d3cb0a7 100644 --- a/src/Crypt/EncryptionException.php +++ b/src/Crypto/EncryptionException.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt; +namespace Yiisoft\Security\Crypto; use RuntimeException; diff --git a/src/Crypto/EnvelopeCryptor.php b/src/Crypto/EnvelopeCryptor.php new file mode 100644 index 0000000..5be3aac --- /dev/null +++ b/src/Crypto/EnvelopeCryptor.php @@ -0,0 +1,132 @@ + + */ + private readonly int $kekSize; + + /** + * @psalm-var int<1, max> + */ + private readonly int $dekSize; + + /** + * @psalm-var int<0, max> + */ + private readonly int $dekNonceSize; + + /** + * @psalm-var int<0, max> + */ + private readonly int $dataNonceSize; + + /** + * @psalm-var int<0, max> + */ + private readonly int $saltSize; + + private readonly int $saltDekNonceLength; + private readonly int $wrapDekLength; + private readonly int $saltDekNonceWrapDekLength; + private readonly 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 $kwCipher Cipher used to wrap the DEK. + */ + public function __construct( + private readonly KdfInterface $kdf, + private readonly 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..d978dff --- /dev/null +++ b/src/Crypto/Kdf/KdfKey.php @@ -0,0 +1,117 @@ + $saltSize + * + * @throws RuntimeException + */ + public function __construct( + private readonly string $hashAlgo = 'sha256', + string|Stringable $hashStaticSalt = '', + private readonly 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 fixed context (used as prefix of HKDF info). + * @param string $salt Dynamic salt value (must 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..a305042 --- /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..fd1f116 --- /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 readonly 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/Crypt/SessionCryptor.php b/src/Crypto/KdfCryptor.php similarity index 50% rename from src/Crypt/SessionCryptor.php rename to src/Crypto/KdfCryptor.php index 74c8272..5265542 100644 --- a/src/Crypt/SessionCryptor.php +++ b/src/Crypto/KdfCryptor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt; +namespace Yiisoft\Security\Crypto; use SensitiveParameter; use Yiisoft\Strings\StringHelper; @@ -13,8 +13,11 @@ * Session‑oriented encryption (single key derived per message, no key wrapping). * A fresh data encryption key (DEK) is derived from the secret and a random salt. * This is suitable for encrypting large amounts of data in a single session. + * + * The resulting ciphertext contains no built‑in authentication mechanism, + * therefore the underlying cipher MUST be AEAD to provide integrity and authenticity. */ -final class SessionCryptor implements CryptorInterface +final class KdfCryptor implements CryptorInterface { /** * @psalm-var int<1, max> @@ -22,73 +25,72 @@ final class SessionCryptor implements CryptorInterface private readonly int $keySize; /** - * @psalm-var int<1, max> + * @psalm-var int<0, max> */ private readonly int $nonceSize; /** - * @psalm-var int<1, max> + * @psalm-var int<0, max> */ private readonly int $saltSize; - private readonly int $saltNonceSize; + private readonly int $headerLength; /** - * @param CipherInterface $cipher Low‑level cipher - * @param KdfInterface $kdf Key derivation function + * @param CipherInterface $cipher Low‑level cipher (must support AEAD). + * @param KdfInterface $kdf Key derivation function (used to derive DEK from secret + salt). */ public function __construct( - private readonly CipherInterface $cipher, private readonly KdfInterface $kdf, + private readonly CipherInterface $cipher, ) { $this->keySize = $this->cipher->getKeySize(); - /** @psalm-var int<1, max> */ $this->nonceSize = $this->cipher->getNonceSize(); $this->saltSize = $this->kdf->getSaltSize(); - $this->saltNonceSize = $this->saltSize + $this->nonceSize; + $this->headerLength = $this->saltSize + $this->nonceSize; } /** * {@inheritdoc} * - * Structure: keySalt || nonce || ciphertext (with tag for AEAD ciphers) + * Structure: salt || + * nonce || + * ciphertext (with tag for AEAD ciphers) */ public function encrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string { - $keySalt = random_bytes($this->saltSize); - $dataNonce = random_bytes($this->nonceSize); + $kdfSalt = $this->saltSize ? random_bytes($this->saltSize) : ''; + $dataNonce = $this->nonceSize ? random_bytes($this->nonceSize) : ''; - $dek = $this->kdf->createKey($secret, $this->keySize, $context, $keySalt); + $dek = $this->kdf->derive($secret, $this->keySize, $context, $kdfSalt); $dataEncrypted = $this->cipher->encrypt($data, $dek, $dataNonce); - // keySalt || nonce || ciphertext || tag - return $keySalt . $dataNonce . $dataEncrypted; + // kdfSalt || nonce || ciphertext || tag + return $kdfSalt . $dataNonce . $dataEncrypted; } /** * {@inheritdoc} - * - * @throws EncryptionException If decryption fails. */ public function decrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string { - if (StringHelper::byteLength($data) < $this->saltNonceSize) { + if (StringHelper::byteLength($data) < $this->headerLength) { throw new EncryptionException('Encrypted data is too short.'); } - $keySalt = StringHelper::byteSubstring($data, 0, $this->saltSize); - $dataNonce = StringHelper::byteSubstring($data, $this->saltSize, $this->nonceSize); - $dataEncrypted = StringHelper::byteSubstring($data, $this->saltNonceSize); + $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->createKey($secret, $this->keySize, $context, $keySalt); + $dek = $this->kdf->derive($secret, $this->keySize, $context, $kdfSalt); return $this->cipher->decrypt($dataEncrypted, $dek, $dataNonce); } diff --git a/src/Crypt/KdfInterface.php b/src/Crypto/KdfInterface.php similarity index 71% rename from src/Crypt/KdfInterface.php rename to src/Crypto/KdfInterface.php index 96d84b9..cc0c406 100644 --- a/src/Crypt/KdfInterface.php +++ b/src/Crypto/KdfInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt; +namespace Yiisoft\Security\Crypto; use SensitiveParameter; @@ -17,24 +17,25 @@ interface KdfInterface * * @param string $secret The input secret (password or raw key material). Sensitive parameter. * @param int $keySize Desired key length in bytes. - * @param string $context Application-specific context string (used as HKDF info). - * @param string $salt Salt value (must be random and unique for each derivation). + * @param string $context Application-specific context string (used as HKDF info or similar). + * @param string $salt Salt value (must be random and unique for each derivation, unless salt size is 0). + * + * @throws EncryptionException If key derivation fails. * - * @throws \RuntimeException If key derivation fails. * @return string The derived key (raw binary string). */ - public function createKey( + public function derive( #[SensitiveParameter] string $secret, int $keySize, string $context, - string $salt, + string $salt = '', ): string; /** - * @return int Salt size in bytes. + * @return int Salt size (may be 0 if no salt is used). * - * @psalm-return int<1, max> + * @psalm-return int<0, max> */ public function getSaltSize(): int; } diff --git a/src/Crypt/VersionedCryptor.php b/src/Crypto/VersionedCryptor.php similarity index 80% rename from src/Crypt/VersionedCryptor.php rename to src/Crypto/VersionedCryptor.php index d002256..f1fdaa2 100644 --- a/src/Crypt/VersionedCryptor.php +++ b/src/Crypto/VersionedCryptor.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Yiisoft\Security\Crypt; +namespace Yiisoft\Security\Crypto; use RuntimeException; use SensitiveParameter; use Yiisoft\Strings\StringHelper; use function bin2hex; +use function sprintf; /** * VersionedCryptor wraps multiple cryptors and adds a version prefix to the ciphertext. @@ -22,29 +23,44 @@ final class VersionedCryptor implements CryptorInterface */ private readonly array $cryptors; + /** + * @psalm-var int<1, max> + */ + private readonly int $versionSize; + /** * @param array $cryptors List of cryptors indexed by version string. * @param string $currentVersion Version identifier used for new encryptions. - * @param int $versionSize Fixed byte length of the version prefix (must be >=1). + * @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 readonly string $currentVersion, - private readonly int $versionSize, + ?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("Current version '{$this->currentVersion}' is not registered."); + 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] @@ -59,7 +75,7 @@ public function encrypt( /** * {@inheritdoc} * - * @throws EncryptionException If the version prefix cannot be read or no cryptor matches. + * @throws EncryptionException When decryption fails. */ public function decrypt( string $data, @@ -85,7 +101,9 @@ public function decrypt( * 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 diff --git a/tests/Crypt/AbstractKdfCase.php b/tests/Crypt/AbstractKdfCase.php deleted file mode 100644 index 33ec79f..0000000 --- a/tests/Crypt/AbstractKdfCase.php +++ /dev/null @@ -1,113 +0,0 @@ -createKdfInstance(); - $keySize = 32; - $secret = random_bytes($keySize); - $salt = random_bytes($kdf->getSaltSize()); - - $key = $kdf->createKey($secret, $keySize, 'test-context', $salt); - - $this->assertSame($keySize, strlen($key)); - $this->assertNotEmpty($key); - } - - #[DataProvider('dataProviderKeyValues')] - public function testKeyValues(string $algo, string $secret, int $keySize, string $context, string $salt, string $key): void - { - $kdf = $this->createKdfInstance($algo); - - $secret = hex2bin(preg_replace('{\s+}', '', $secret)); - $salt = hex2bin(preg_replace('{\s+}', '', $salt)); - $key = hex2bin(preg_replace('{\s+}', '', $key)); - - $this->assertEquals($key, $kdf->createKey($secret, $keySize, $context, $salt)); - } - - #[DataProvider('dataProviderAlgos')] - public function testCreateKeyWithCustomAlgorithm(string $algo, int $keySize): void - { - $kdf = $this->createKdfInstance($algo); - $secret = random_bytes($keySize); - $salt = random_bytes($kdf->getSaltSize()); - - $key = $kdf->createKey($secret, $keySize, 'test-context', $salt); - - $this->assertSame($keySize, strlen($key)); - } - - public function testSameParametersProduceSameKey(): void - { - $kdf = $this->createKdfInstance(); - $keySize = 32; - $secret = random_bytes($keySize); - $salt = random_bytes($kdf->getSaltSize()); - - $key1 = $kdf->createKey($secret, $keySize, 'test-context', $salt); - $key2 = $kdf->createKey($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()); - - $key11 = $kdf->createKey($secret, $keySize, 'test-context', $salt1); - $key12 = $kdf->createKey($secret, $keySize, 'test-context', $salt2); - $this->assertNotSame($key11, $key12); - - $key21 = $kdf->createKey($secret, $keySize, 'context-1', $salt1); - $key22 = $kdf->createKey($secret, $keySize, 'context-2', $salt1); - $this->assertNotSame($key21, $key22); - - $key31 = $kdf->createKey($secret, $keySize, 'test-context', $salt1); - $key32 = $kdf->createKey($secret2, $keySize, 'test-context', $salt1); - $this->assertNotSame($key31, $key32); - } - - public function testInvalidAlgoThrowsException(): void - { - $this->expectException(RuntimeException::class); - $this->createKdfInstance('Non-Existing-Algorithm'); - } - - public function testInvalidSizeThrowsException(): void - { - $kdf = $this->createKdfInstance(); - - $this->expectException(EncryptionException::class); - $kdf->createKey('test-secret', -1, 'test-context', 'test-salt'); - } - - public function testGetSizes(): void - { - $cipher = $this->createKdfInstance(); - - $this->assertIsInt($cipher->getSaltSize()); - } -} diff --git a/tests/Crypt/EnvelopeCryptorTest.php b/tests/Crypt/EnvelopeCryptorTest.php deleted file mode 100644 index a1d554e..0000000 --- a/tests/Crypt/EnvelopeCryptorTest.php +++ /dev/null @@ -1,172 +0,0 @@ -createMocks(); - - $kdf->expects($this->once()) - ->method('createKey') - ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::SALT_SIZE)) - ->willReturn($kek); - - $cipher->expects($this->exactly(2)) - ->method('encrypt') - ->willReturnCallback(function (...$args) use ($plaintext, $kek) { - static $callCount = 0; - $callCount++; - - if ($callCount === 1) { - // First call: payload = dek, key = kek, nonce length = nonceSize - [$payload, $key, $nonce] = $args; - $this->assertIsString($payload); - $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($payload)); - $this->assertEquals($kek, $key); - $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); - - return 'encDek--------------------------' . '________________'; - } - - [$payload, $key, $nonce] = $args; - $this->assertEquals($plaintext, $payload); - $this->assertEquals(self::KEY_SIZE, StringHelper::byteLength($key)); - $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); - - return 'encData'; - }); - - $cryptor = new EnvelopeCryptor($cipher, $kdf); - - $result = $cryptor->encrypt($plaintext, $secret, $context); - $this->assertIsString($result); - $this->assertEquals( - self::SALT_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE + StringHelper::byteLength('encData'), - StringHelper::byteLength($result) - ); - - $keySalt = StringHelper::byteSubstring($result, 0, self::SALT_SIZE); - $dekNonce = StringHelper::byteSubstring($result, self::SALT_SIZE, self::NONCE_SIZE); - $encDek = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE, self::KEY_SIZE + self::TAG_SIZE); - $dataNonce = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE), self::NONCE_SIZE); - $ciphertext = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE + (self::KEY_SIZE + self::TAG_SIZE) + self::NONCE_SIZE); - - $this->assertEquals(self::SALT_SIZE, StringHelper::byteLength($keySalt)); - $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dekNonce)); - $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($dataNonce)); - $this->assertEquals('encDek--------------------------' . '________________', $encDek); - $this->assertEquals('encData', $ciphertext); - } - - public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void - { - $plaintext = 'test-plain-data'; - $secret = 'test-secret'; - $context = 'test-context'; - - $keySalt = str_repeat("\x01", self::SALT_SIZE); - $dekNonce = str_repeat("\x02", self::NONCE_SIZE); - $dek = str_repeat("\x10", self::KEY_SIZE); - $dataNonce = str_repeat("\x20", self::NONCE_SIZE); - $tag = str_repeat("\x30", self::TAG_SIZE); - - $encDekWithTag = $dek . $tag; - $encDataWithTag = $plaintext . $tag; - - [$cipher, $kdf] = $this->createMocks(); - - $kdf->expects($this->once()) - ->method('createKey') - ->with($secret, self::KEY_SIZE, $context, $keySalt) - ->willReturn('kek'); - - $cipher->expects($this->exactly(2)) - ->method('decrypt') - ->willReturnCallback(function (...$args) use ($plaintext, $encDekWithTag, $encDataWithTag, $dekNonce, $dek, $dataNonce) { - static $callCount = 0; - $callCount++; - - if ($callCount === 1) { - [$payload, $key, $nonce] = $args; - $this->assertEquals($encDekWithTag, $payload); - $this->assertEquals('kek', $key); - $this->assertEquals($dekNonce, $nonce); - - return $dek; - } - - [$payload, $key, $nonce] = $args; - $this->assertEquals($encDataWithTag, $payload); - $this->assertEquals($dek, $key); - $this->assertEquals($dataNonce, $nonce); - - return $plaintext; - }); - - $blob = $keySalt . $dekNonce . $encDekWithTag . $dataNonce . $encDataWithTag; - $cryptor = new EnvelopeCryptor($cipher, $kdf); - $decrypted = $cryptor->decrypt($blob, $secret, $context); - $this->assertSame($plaintext, $decrypted); - } - - public function testEncryptionIsRandomized(): void - { - [$cipher, $kdf] = $this->createMocks(); - - $kdf->method('createKey')->willReturn('dek'); - $cipher->method('encrypt')->willReturn('cipher'); - - $cryptor = new EnvelopeCryptor($cipher, $kdf); - - $res1 = $cryptor->encrypt('data', 'secret'); - $res2 = $cryptor->encrypt('data', 'secret'); - - $this->assertNotSame($res1, $res2); - } - - public function testDecryptThrowsWhenDataTooShort(): void - { - [$cipher, $kdf] = $this->createMocks(); - - $this->expectException(EncryptionException::class); - $this->expectExceptionMessage('Encrypted data is too short.'); - - $cryptor = new EnvelopeCryptor($cipher, $kdf); - $cryptor->decrypt('short', 'secret'); - } - - private function createMocks(): array - { - $kdf = $this->createMock(KdfInterface::class); - $kdf->method('getSaltSize')->willReturn(self::SALT_SIZE); - - $cipher = $this->createMock(AeadCipherInterface::class); - $cipher->method('getKeySize')->willReturn(self::KEY_SIZE); - $cipher->method('getNonceSize')->willReturn(self::NONCE_SIZE); - $cipher->method('getTagSize')->willReturn(self::TAG_SIZE); - - return [$cipher, $kdf]; - } -} diff --git a/tests/Crypt/KdfKeyTest.php b/tests/Crypt/KdfKeyTest.php deleted file mode 100644 index 48227e6..0000000 --- a/tests/Crypt/KdfKeyTest.php +++ /dev/null @@ -1,60 +0,0 @@ -createKdfInstance(); - - $this->expectException(EncryptionException::class); - $kdf->createKey('', 32, 'test-context', 'test-salt'); - } -} diff --git a/tests/Crypt/SessionCryptorTest.php b/tests/Crypt/SessionCryptorTest.php deleted file mode 100644 index 21dede6..0000000 --- a/tests/Crypt/SessionCryptorTest.php +++ /dev/null @@ -1,124 +0,0 @@ -createMocks(); - - $kdf->expects($this->once()) - ->method('createKey') - ->with($secret, self::KEY_SIZE, $context, $this->callback(static fn($salt) => StringHelper::byteLength($salt) === self::SALT_SIZE)) - ->willReturn('test-derivedkey-123456'); - - $cipher->expects($this->once()) - ->method('encrypt') - ->with($plaintext, 'test-derivedkey-123456', $this->callback(static fn($nonce) => StringHelper::byteLength($nonce) === self::NONCE_SIZE)) - ->willReturn('test-ciphertext-and-tag'); - - $cryptor = new SessionCryptor($cipher, $kdf); - $result = $cryptor->encrypt($plaintext, $secret, $context); - - // result structure: keySalt || nonce || ciphertext - $this->assertIsString($result); - $this->assertEquals( - self::SALT_SIZE + self::NONCE_SIZE + StringHelper::byteLength('test-ciphertext-and-tag'), - StringHelper::byteLength($result) - ); - - $keySalt = StringHelper::byteSubstring($result, 0, self::SALT_SIZE); - $nonce = StringHelper::byteSubstring($result, self::SALT_SIZE, self::NONCE_SIZE); - $ciphertext = StringHelper::byteSubstring($result, self::SALT_SIZE + self::NONCE_SIZE); - - $this->assertEquals(self::SALT_SIZE, StringHelper::byteLength($keySalt)); - $this->assertEquals(self::NONCE_SIZE, StringHelper::byteLength($nonce)); - $this->assertEquals('test-ciphertext-and-tag', $ciphertext); - } - - public function testDecryptReturnsPlaintextAndUsesKdfAndCipher(): void - { - $plaintext = 'test-plain-data'; - $secret = 'test-secret'; - $context = 'test-context'; - - $keySalt = str_repeat("\x01", self::SALT_SIZE); - $nonce = str_repeat("\x02", self::NONCE_SIZE); - - $encryptedPayload = 'encrypted-by-cipher'; - - [$cipher, $kdf] = $this->createMocks(); - - $kdf->expects($this->once()) - ->method('createKey') - ->with($secret, self::KEY_SIZE, $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 SessionCryptor($cipher, $kdf); - $decrypted = $cryptor->decrypt($blob, $secret, $context); - $this->assertSame($plaintext, $decrypted); - } - - public function testEncryptionIsRandomized(): void - { - [$cipher, $kdf] = $this->createMocks(); - - $kdf->method('createKey')->willReturn('dek'); - $cipher->method('encrypt')->willReturn('cipher'); - - $cryptor = new SessionCryptor($cipher, $kdf); - - $res1 = $cryptor->encrypt('data', 'secret'); - $res2 = $cryptor->encrypt('data', 'secret'); - - $this->assertNotSame($res1, $res2); - } - - public function testDecryptThrowsWhenDataTooShort(): void - { - [$cipher, $kdf] = $this->createMocks(); - - $this->expectException(EncryptionException::class); - $this->expectExceptionMessage('Encrypted data is too short.'); - - $cryptor = new SessionCryptor($cipher, $kdf); - $cryptor->decrypt('short', 'secret'); - } - - private function createMocks(): array - { - $kdf = $this->createMock(KdfInterface::class); - $kdf->method('getSaltSize')->willReturn(self::SALT_SIZE); - - $cipher = $this->createMock(CipherInterface::class); - $cipher->method('getKeySize')->willReturn(self::KEY_SIZE); - $cipher->method('getNonceSize')->willReturn(self::NONCE_SIZE); - - return [$cipher, $kdf]; - } -} diff --git a/tests/Crypt/AbstractAeadCipherCase.php b/tests/Crypto/Cipher/AbstractCipherCase.php similarity index 55% rename from tests/Crypt/AbstractAeadCipherCase.php rename to tests/Crypto/Cipher/AbstractCipherCase.php index 9ddb9eb..9b0575d 100644 --- a/tests/Crypt/AbstractAeadCipherCase.php +++ b/tests/Crypto/Cipher/AbstractCipherCase.php @@ -2,20 +2,22 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto\Cipher; use RuntimeException; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\DataProvider; -use Yiisoft\Security\Crypt\EncryptionException; -use Yiisoft\Security\Crypt\AeadCipherInterface; +use Yiisoft\Security\Crypto\CipherInterface; +use Yiisoft\Security\Crypto\EncryptionException; /** * @abstract */ -abstract class AbstractAeadCipherCase extends TestCase +abstract class AbstractCipherCase extends TestCase { - abstract protected function createCipherInstance(?string $cipher = null): AeadCipherInterface; + abstract protected function createCipherInstance(?string $cipher = null): CipherInterface; + + abstract protected static function getPlainText(): string; abstract public static function dataProviderCiphers(): iterable; @@ -25,9 +27,9 @@ abstract public static function dataProviderEncrypted(): iterable; public function testEncryptDecryptSuccess(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); + $plaintext = $this->getPlainText(); $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize()); - $plaintext = 'test-plain-data'; + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); $this->assertNotSame($plaintext, $ciphertext); @@ -52,8 +54,8 @@ public function testEncrypted(string $cipher, string $key, string $nonce, string $nonce = hex2bin(preg_replace('{\s+}', '', $nonce)); $encrypted = hex2bin(preg_replace('{\s+}', '', $encrypted)); - $this->assertEquals($encrypted, $cipherInstance->encrypt($data, $key, $nonce)); - $this->assertEquals($data, $cipherInstance->decrypt($encrypted, $key, $nonce)); + $this->assertSame($encrypted, $cipherInstance->encrypt($data, $key, $nonce)); + $this->assertSame($data, $cipherInstance->decrypt($encrypted, $key, $nonce)); } public function testInvalidCipherThrowsException(): void @@ -63,36 +65,49 @@ public function testInvalidCipherThrowsException(): void } #[DataProvider('dataProviderCiphers')] - public function testEncryptWithWrongKeySizeThrowsException(string $cipher): void + public function testEncryptWithKeyTooShortThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); - $key = random_bytes($cipherInstance->getKeySize() + 1); // wrong key size - $nonce = random_bytes($cipherInstance->getNonceSize()); - $plaintext = 'test-plain-data'; + $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 testEncryptWithWrongNonceSizeThrowsException(string $cipher): void + public function testEncryptWithKeyTooLongThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); - $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize() + 1); // wrong nounce size - $plaintext = 'test-plain-data'; + $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 testDecryptWithWrongKeySizeThrowsException(string $cipher): void + 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 = random_bytes($cipherInstance->getNonceSize()); - $plaintext = 'test-plain-data'; + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); $this->expectException(EncryptionException::class); @@ -100,61 +115,70 @@ public function testDecryptWithWrongKeySizeThrowsException(string $cipher): void } #[DataProvider('dataProviderCiphers')] - public function testDecryptWithWrongNonceSizeThrowsException(string $cipher): void + public function testDecryptWithKeyTooShortThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize()); // wrong nounce size - $plaintext = 'test-plain-data'; + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); $this->expectException(EncryptionException::class); - $cipherInstance->decrypt($ciphertext, $key, $nonce . 'X'); + $cipherInstance->decrypt($ciphertext, substr($key, 1), $nonce); } #[DataProvider('dataProviderCiphers')] - public function testDecryptWithTamperedCiphertextThrowsException(string $cipher): void + public function testDecryptWithEmptyKeyThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize()); - $plaintext = 'test-plain-data'; - + $nonce = $cipherInstance->getNonceSize() ? random_bytes($cipherInstance->getNonceSize()) : ''; + $plaintext = $this->getPlainText(); $ciphertext = $cipherInstance->encrypt($plaintext, $key, $nonce); - $tampered = substr_replace($ciphertext, 'XXX', -3); $this->expectException(EncryptionException::class); - $cipherInstance->decrypt($tampered, $key, $nonce); + $cipherInstance->decrypt($ciphertext, '', $nonce); } #[DataProvider('dataProviderCiphers')] - public function testDecryptWithWrongKeyThrowsException(string $cipher): void + public function testDecryptWithCiphertextCorruptedThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); $key = random_bytes($cipherInstance->getKeySize()); - $wrongKey = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize()); - $plaintext = 'test-plain-data'; + $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($ciphertext, $wrongKey, $nonce); + $cipherInstance->decrypt(substr($ciphertext, 1), $key, $nonce); } #[DataProvider('dataProviderCiphers')] - public function testDecryptWithWrongNonceThrowsException(string $cipher): void + public function testDecryptWithWrongKeyThrowsException(string $cipher): void { $cipherInstance = $this->createCipherInstance($cipher); $key = random_bytes($cipherInstance->getKeySize()); - $nonce = random_bytes($cipherInstance->getNonceSize()); - $wrongNonce = random_bytes($cipherInstance->getNonceSize()); - $plaintext = 'test-plain-data'; + $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, $key, $wrongNonce); + $cipherInstance->decrypt($ciphertext, $wrongKey, $nonce); } public function testGetSizes(): void @@ -163,6 +187,6 @@ public function testGetSizes(): void $this->assertIsInt($cipher->getKeySize()); $this->assertIsInt($cipher->getNonceSize()); - $this->assertIsInt($cipher->getTagSize()); + $this->assertIsInt($cipher->getOverheadSize()); } } 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/Crypt/OpenSSLAeadCipherTest.php b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php similarity index 55% rename from tests/Crypt/OpenSSLAeadCipherTest.php rename to tests/Crypto/Cipher/OpenSSLAeadCipherTest.php index b6b6dcf..752426e 100644 --- a/tests/Crypt/OpenSSLAeadCipherTest.php +++ b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php @@ -2,13 +2,16 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto\Cipher; -use Yiisoft\Security\Crypt\AeadCipherInterface; -use Yiisoft\Security\Crypt\Cipher\OpenSSLAeadCipher; +use Yiisoft\Security\Crypto\CipherInterface; +use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; -final class OpenSSLAeadCipherTest extends AbstractAeadCipherCase +final class OpenSSLAeadCipherTest extends AbstractCipherCase { + use CipherWithNonceTrait; + use CipherWithAeadTrait; + protected function setUp(): void { if (!extension_loaded('openssl')) { @@ -16,20 +19,33 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): AeadCipherInterface + protected function createCipherInstance(?string $cipher = null): CipherInterface { return $cipher ? new OpenSSLAeadCipher($cipher) : new OpenSSLAeadCipher(); } + protected static function getPlainText(): string + { + return 'test-plain-data'; + } + 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', @@ -51,5 +67,19 @@ public static function dataProviderEncrypted(): iterable 'test-plain-data', '7c5fd62f60ad234d9dbf8efd26252a71b273b66b5e9fa89d27c519aac6bb54', ]; + yield [ + 'CHACHA20-POLY1305', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + '', + '3584c3be670fa3a6d6ffc332beaf2302', + ]; + yield [ + 'CHACHA20-POLY1305', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + 'test-plain-data', + '75058e089d84a58fed82a822b462b2a3dcdf5b5b4cda445fdba26ccd012503', + ]; } } diff --git a/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php b/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php new file mode 100644 index 0000000..1c03618 --- /dev/null +++ b/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php @@ -0,0 +1,80 @@ +markTestSkipped('OpenSSL extension is required for these tests.'); + } + } + + protected function createCipherInstance(?string $cipher = null): CipherInterface + { + return $cipher ? new OpenSSLWrapCipher($cipher) : new OpenSSLWrapCipher(); + } + + protected static function getPlainText(): string + { + return 'test-plain-data-'; + } + + 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); + } +} diff --git a/tests/Crypt/SodiumAeadCipherTest.php b/tests/Crypto/Cipher/SodiumAeadCipherTest.php similarity index 57% rename from tests/Crypt/SodiumAeadCipherTest.php rename to tests/Crypto/Cipher/SodiumAeadCipherTest.php index 6f48457..d0ad169 100644 --- a/tests/Crypt/SodiumAeadCipherTest.php +++ b/tests/Crypto/Cipher/SodiumAeadCipherTest.php @@ -2,13 +2,16 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto\Cipher; -use Yiisoft\Security\Crypt\AeadCipherInterface; -use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\CipherInterface; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; -final class SodiumAeadCipherTest extends AbstractAeadCipherCase +final class SodiumAeadCipherTest extends AbstractCipherCase { + use CipherWithNonceTrait; + use CipherWithAeadTrait; + protected function setUp(): void { if (!extension_loaded('sodium')) { @@ -16,28 +19,40 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): AeadCipherInterface + protected function createCipherInstance(?string $cipher = null): CipherInterface { return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); } + protected static function getPlainText(): string + { + return 'test-plain-data'; + } + public static function dataProviderCiphers(): iterable { - yield ['ChaCha20-Poly1305-IETF']; - yield ['XChaCha20-Poly1305-IETF']; + yield ['CHACHA20-POLY1305-IETF']; + yield ['XCHACHA20-POLY1305-IETF']; } public static function dataProviderEncrypted(): iterable { yield [ - 'ChaCha20-Poly1305-IETF', + 'CHACHA20-POLY1305-IETF', + 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', + '353bf3e8a440ddd5b125b8df', + '', + '3584c3be670fa3a6d6ffc332beaf2302', + ]; + yield [ + 'CHACHA20-POLY1305-IETF', 'adcc610fd179117c7b383b9c9e4c2b106fc72f98290c095452a07b0ad5ed5767', '353bf3e8a440ddd5b125b8df', 'test-plain-data', '75058e089d84a58fed82a822b462b2a3dcdf5b5b4cda445fdba26ccd012503', ]; yield [ - 'XChaCha20-Poly1305-IETF', + 'XCHACHA20-POLY1305-IETF', '89fe0c0b2c9b74cdb87d13f0b9f835bde84a3f0c4940c026c5d888db254271f0', 'fc6f945727c02ac590d53cc17c2f144949526a4f2d2fef41', 'test-plain-data', diff --git a/tests/Crypt/SodiumGcmCipherTest.php b/tests/Crypto/Cipher/SodiumGcmCipherTest.php similarity index 62% rename from tests/Crypt/SodiumGcmCipherTest.php rename to tests/Crypto/Cipher/SodiumGcmCipherTest.php index ca1e66f..54b25d9 100644 --- a/tests/Crypt/SodiumGcmCipherTest.php +++ b/tests/Crypto/Cipher/SodiumGcmCipherTest.php @@ -2,13 +2,16 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto\Cipher; -use Yiisoft\Security\Crypt\AeadCipherInterface; -use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\CipherInterface; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; -final class SodiumGcmCipherTest extends AbstractAeadCipherCase +final class SodiumGcmCipherTest extends AbstractCipherCase { + use CipherWithNonceTrait; + use CipherWithAeadTrait; + protected function setUp(): void { if (!extension_loaded('sodium')) { @@ -18,11 +21,16 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): AeadCipherInterface + protected function createCipherInstance(?string $cipher = null): CipherInterface { return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); } + protected static function getPlainText(): string + { + return 'test-plain-data'; + } + public static function dataProviderCiphers(): iterable { yield ['AES-256-GCM']; @@ -30,6 +38,13 @@ public static function dataProviderCiphers(): iterable public static function dataProviderEncrypted(): iterable { + yield [ + 'AES-256-GCM', + 'd2000811111ba11ba7a2497911c43111a00b433d8437b3538d57d75366b32bb2', + '429895de6466a4622f287f0c', + '', + '5f82ba64af12dbd7f594a51c235c4b98', + ]; yield [ 'AES-256-GCM', 'd2000811111ba11ba7a2497911c43111a00b433d8437b3538d57d75366b32bb2', diff --git a/tests/Crypto/EnvelopeCryptorTest.php b/tests/Crypto/EnvelopeCryptorTest.php new file mode 100644 index 0000000..cac496a --- /dev/null +++ b/tests/Crypto/EnvelopeCryptorTest.php @@ -0,0 +1,325 @@ +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'); + } + + 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]; + } + + /** + * [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, + ]; + } +} diff --git a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php new file mode 100644 index 0000000..ddf19cd --- /dev/null +++ b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php @@ -0,0 +1,141 @@ +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..4182172 --- /dev/null +++ b/tests/Crypto/Kdf/AbstractKdfCase.php @@ -0,0 +1,155 @@ +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('dataProviderAlgos')] + public function testDeriveWithCustomAlgorithm(string $hashAlgo, int $keySize): void + { + $kdf = $this->createKdfInstance($hashAlgo); + $secret = random_bytes($keySize); + $salt = random_bytes($kdf->getSaltSize()); + + $key = $kdf->derive($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); + } +} diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php new file mode 100644 index 0000000..b64031e --- /dev/null +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -0,0 +1,94 @@ +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, 'context'); + $key2 = $kdf2->derive($secret, $keySize, 'context'); + $this->assertNotSame($key1, $key2); + } + + public function testInvalidSecretThrowsException(): void + { + $kdf = $this->createKdfInstance(); + + $this->expectException(EncryptionException::class); + $kdf->derive('', 32, 'test-context', 'test-salt'); + } +} diff --git a/tests/Crypt/KdfPasswordArgon2Test.php b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php similarity index 67% rename from tests/Crypt/KdfPasswordArgon2Test.php rename to tests/Crypto/Kdf/KdfPasswordArgon2Test.php index fb77172..cba1ce4 100644 --- a/tests/Crypt/KdfPasswordArgon2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -2,16 +2,19 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto\Kdf; -use Yiisoft\Security\Crypt\KdfInterface; -use Yiisoft\Security\Crypt\Kdf\KdfPasswordArgon2; +use Stringable; +use Yiisoft\Security\Crypto\KdfInterface; +use Yiisoft\Security\Crypto\Kdf\KdfPasswordArgon2; final class KdfPasswordArgon2Test extends AbstractKdfCase { - protected function createKdfInstance(?string $hash = null): KdfInterface + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { - return $hash ? new KdfPasswordArgon2($hash) : new KdfPasswordArgon2(); + return $hashAlgo + ? new KdfPasswordArgon2(hashAlgo: $hashAlgo, hashStaticSalt: $hashStaticSalt) + : new KdfPasswordArgon2(hashStaticSalt: $hashStaticSalt); } public static function dataProviderAlgos(): iterable @@ -37,7 +40,7 @@ public static function dataProviderKeyValues(): iterable 64, 'text-context', '7f22a943efd3537ef9e0dc98e7031d9f', - 'abcd5a66003c25a8ff8190716c976825941265a50de8bef39d6f8c56d9b7bdd1cabf61ea1079eb38ba395986e3eba2605a888cc7c4433b5218624d95bdfcf0a7', + 'ac666c6333f2aa0364465b9d4b5446dc1f0424795cb10f5ffcc9161b6266b939ff07e18f17261d5016b5dc2ab0ea464284e2a70d72f8b8c3f4456b015bf9d14d', ]; yield [ 'sha3-256', diff --git a/tests/Crypt/KdfPasswordPbkdf2Test.php b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php similarity index 64% rename from tests/Crypt/KdfPasswordPbkdf2Test.php rename to tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php index d1e18fa..a913b1f 100644 --- a/tests/Crypt/KdfPasswordPbkdf2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php @@ -2,17 +2,20 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto\Kdf; use RuntimeException; -use Yiisoft\Security\Crypt\KdfInterface; -use Yiisoft\Security\Crypt\Kdf\KdfPasswordPbkdf2; +use Stringable; +use Yiisoft\Security\Crypto\KdfInterface; +use Yiisoft\Security\Crypto\Kdf\KdfPasswordPbkdf2; final class KdfPasswordPbkdf2Test extends AbstractKdfCase { - protected function createKdfInstance(?string $hash = null): KdfInterface + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { - return $hash ? new KdfPasswordPbkdf2($hash, 100_000) : new KdfPasswordPbkdf2(iterations: 100_000); + return $hashAlgo + ? new KdfPasswordPbkdf2(hashAlgo: $hashAlgo, iterations: 100_000, hashStaticSalt: $hashStaticSalt) + : new KdfPasswordPbkdf2(iterations: 100_000, hashStaticSalt: $hashStaticSalt); } public static function dataProviderAlgos(): iterable @@ -37,8 +40,8 @@ public static function dataProviderKeyValues(): iterable '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', 64, 'text-context', - '7f22a943efd3537ef9e0dc98e7031d9f71b16868ccc0aafe110ab32f7e54db613b58b5663c14b703b019278cc80dc615f60df1c6a4cc88f1b207a72783be7d44', - 'c377ad8ff1c4438f862e43ee5ef5431f928fd64890d9ed3ba401d91c37e5aee5a7d90ef09f3ad4ea82506b32c9950bebfd4820895667b8c478d3f4e57e8ebff4', + '7f22a943efd3537ef9e0dc98e7031d9f71b16868ccc0aafe110ab32f7e54db61', + '418e0b515d1b59f8840519ae3f9da693d93afaef9b1e2f6c4d5cf5f85eb1c229a66cb96977a90a1a67a888f48c23738d68e4ce735cc8529b92bb15a5e1738c29', ]; yield [ 'sha3-256', @@ -46,13 +49,13 @@ public static function dataProviderKeyValues(): iterable 32, 'text-context', 'aa24ea6b979b1a857d9f9dfa0dcac8a44c3f7b9ea061551529556ac70dd0cfeb', - '07f140674180f0ba9d4c6dea90a0ad389274624bc966c550519c98704f1df504', + '13091daa596fea871ce3e458366857fe72747e6f7ef3bc578cfefd2cc8031329', ]; } public function testConstructorThrowsExceptionWhenIterationsLessThanOne(): void { $this->expectException(RuntimeException::class); - new KdfPasswordPbkdf2('sha256', 0); + new KdfPasswordPbkdf2(iterations: 0); } } diff --git a/tests/Crypto/Kdf/StringableParam.php b/tests/Crypto/Kdf/StringableParam.php new file mode 100644 index 0000000..261b46b --- /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..7618eaa --- /dev/null +++ b/tests/Crypto/KdfCryptorTest.php @@ -0,0 +1,204 @@ +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'); + } + + 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]; + } + /** + * [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, + ]; + } +} diff --git a/tests/Crypt/VersionedCryptorTest.php b/tests/Crypto/VersionedCryptorTest.php similarity index 64% rename from tests/Crypt/VersionedCryptorTest.php rename to tests/Crypto/VersionedCryptorTest.php index ad13d2c..33dd247 100644 --- a/tests/Crypt/VersionedCryptorTest.php +++ b/tests/Crypto/VersionedCryptorTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Yiisoft\Security\Tests\Crypt; +namespace Yiisoft\Security\Tests\Crypto; use RuntimeException; use PHPUnit\Framework\TestCase; -use Yiisoft\Security\Crypt\CryptorInterface; -use Yiisoft\Security\Crypt\EncryptionException; -use Yiisoft\Security\Crypt\VersionedCryptor; +use Yiisoft\Security\Crypto\CryptorInterface; +use Yiisoft\Security\Crypto\EncryptionException; +use Yiisoft\Security\Crypto\VersionedCryptor; final class VersionedCryptorTest extends TestCase { @@ -25,7 +25,7 @@ public function testEncryptPrependsVersionAndDelegates(): void ->with($plaintext, $secret, $context) ->willReturn('encrypted-payload'); - $versioned = new VersionedCryptor([$v => $cryptor], $v, 2); + $versioned = new VersionedCryptor(cryptors: [$v => $cryptor], currentVersion: $v); $result = $versioned->encrypt($plaintext, $secret, $context); $this->assertSame($v . 'encrypted-payload', $result); @@ -46,7 +46,7 @@ public function testDecryptExtractsVersionAndCallsCorrectCryptor(): void ->with($encryptedPayload, $secret, $context) ->willReturn($plaintext); - $versioned = new VersionedCryptor(['v2' => $cryptorV2], 'v2', 2); + $versioned = new VersionedCryptor(cryptors: ['v2' => $cryptorV2], currentVersion: 'v2'); $result = $versioned->decrypt($fullData, $secret, $context); $this->assertSame($plaintext, $result); @@ -65,10 +65,10 @@ public function testEncryptDecryptDifferentVersions(): void $cryptorV2->method('encrypt')->willReturn('encrypted_data_v2'); $cryptorV2->method('decrypt')->willReturn($plaintext); - $versionedCryptor = new VersionedCryptor([ + $versionedCryptor = new VersionedCryptor(cryptors: [ 'v1' => $cryptorV1, 'v2' => $cryptorV2, - ], 'v2', 2); + ], currentVersion: 'v2'); $encryptedDataV1 = 'v1' . $cryptorV1->encrypt($plaintext, $secret); $encryptedDataV2 = 'v2' . $cryptorV2->encrypt($plaintext, $secret); @@ -76,8 +76,8 @@ public function testEncryptDecryptDifferentVersions(): void $decryptedDataV1 = $versionedCryptor->decrypt($encryptedDataV1, $secret); $decryptedDataV2 = $versionedCryptor->decrypt($encryptedDataV2, $secret); - $this->assertEquals($plaintext, $decryptedDataV1); - $this->assertEquals($plaintext, $decryptedDataV2); + $this->assertSame($plaintext, $decryptedDataV1); + $this->assertSame($plaintext, $decryptedDataV2); } public function testContextPassedToUnderlyingCryptor(): void @@ -96,7 +96,7 @@ public function testContextPassedToUnderlyingCryptor(): void ->with('encrypted', $secret, $context) ->willReturn('data'); - $versioned = new VersionedCryptor(['v1' => $cryptor], 'v1', 2); + $versioned = new VersionedCryptor(cryptors: ['v1' => $cryptor], currentVersion: 'v1'); $encrypted = $versioned->encrypt('data', $secret, $context); $decrypted = $versioned->decrypt($encrypted, $secret, $context); @@ -104,46 +104,72 @@ public function testContextPassedToUnderlyingCryptor(): void $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([12 => $this->createMock(CryptorInterface::class)], '123', 3); + new VersionedCryptor( + cryptors: [12 => $this->createMock(CryptorInterface::class)], + currentVersion: '123', + versionSize: 3, + ); } public function testConstructThrowsWhenCurrentVersionNotRegistered(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v2', 2); + new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v2'); } public function testConstructorValidationThrows(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor([], 'v1', 2); + new VersionedCryptor(cryptors: [], currentVersion: 'v1'); } public function testConstructorThrowsExceptionWhenCryptorNotInstanceOfInterface(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor(['v1' => new \stdClass()], 'v1', 2); + new VersionedCryptor(cryptors: ['v1' => new \stdClass()], currentVersion: 'v1'); } public function testConstructorThrowsExceptionWhenVersionSizeLessThanOne(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 0); + new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v1', versionSize: 0); } public function testConstructorThrowsExceptionWhenVersionLengthMismatch(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 3); + new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v1', versionSize: 3); } public function testDecryptThrowsExceptionWhenDataTooShort(): void { $cryptor = $this->createMock(CryptorInterface::class); - $versionedCryptor = new VersionedCryptor(['v1' => $cryptor], 'v1', 2); + $versionedCryptor = new VersionedCryptor(cryptors: ['v1' => $cryptor], currentVersion: 'v1', versionSize: 2); $this->expectException(EncryptionException::class); $versionedCryptor->decrypt('x', 'secret'); @@ -151,7 +177,7 @@ public function testDecryptThrowsExceptionWhenDataTooShort(): void public function testDecryptThrowsExceptionWhenVersionNotFound(): void { - $versionedCryptor = new VersionedCryptor(['v1' => $this->createMock(CryptorInterface::class)], 'v1', 2); + $versionedCryptor = new VersionedCryptor(cryptors: ['v1' => $this->createMock(CryptorInterface::class)], currentVersion: 'v1'); $this->expectException(EncryptionException::class); $versionedCryptor->decrypt('v2' . 'test-plain-data', 'test-secret'); @@ -162,7 +188,7 @@ public function testDecryptInvalidData(): void $cryptor = $this->createMock(CryptorInterface::class); $cryptor->method('decrypt')->willThrowException(new EncryptionException()); - $versionedCryptor = new VersionedCryptor(['v1' => $cryptor], 'v1', 2); + $versionedCryptor = new VersionedCryptor(cryptors: ['v1' => $cryptor], currentVersion: 'v1'); $this->expectException(EncryptionException::class); $versionedCryptor->decrypt('v1' . 'test-plain-data', 'test-secret'); From 2586a5e154ef91aeb7ff51381f2f8a74c540b32f Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 18 Jun 2026 02:59:51 +0700 Subject: [PATCH 40/70] fix ci fix composer requirements --- composer-require-checker.json | 14 +++++++++++++- tests/Crypto/Cipher/OpenSSLAeadCipherTest.php | 2 +- tests/Crypto/Cipher/SodiumAeadCipherTest.php | 2 +- tests/Crypto/Cipher/SodiumGcmCipherTest.php | 2 +- tests/Crypto/EnvelopeCryptorTest.php | 9 +++------ .../EnvelopeCryptorWithSingleCipherTest.php | 15 ++++++++------- tests/Crypto/Kdf/AbstractKdfCase.php | 2 +- tests/Crypto/Kdf/KdfKeyTest.php | 1 - tests/Crypto/Kdf/StringableParam.php | 3 ++- tests/Crypto/KdfCryptorTest.php | 9 +++------ 10 files changed, 33 insertions(+), 26 deletions(-) diff --git a/composer-require-checker.json b/composer-require-checker.json index d74f98e..08958cc 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,5 +1,17 @@ { "symbol-whitelist": [ - "random_bytes" + "random_bytes", + "openssl_encrypt", + "openssl_decrypt", + "openssl_error_string", + "sodium_crypto_aead_aes256gcm_is_available", + "sodium_crypto_aead_aes256gcm_encrypt", + "sodium_crypto_aead_aes256gcm_decrypt", + "sodium_crypto_aead_chacha20poly1305_ietf_encrypt", + "sodium_crypto_aead_chacha20poly1305_ietf_decrypt", + "sodium_crypto_aead_xchacha20poly1305_ietf_encrypt", + "sodium_crypto_aead_xchacha20poly1305_ietf_decrypt", + "sodium_crypto_pwhash", + "SodiumException" ] } diff --git a/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php index 752426e..81aefec 100644 --- a/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php +++ b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php @@ -9,8 +9,8 @@ final class OpenSSLAeadCipherTest extends AbstractCipherCase { - use CipherWithNonceTrait; use CipherWithAeadTrait; + use CipherWithNonceTrait; protected function setUp(): void { diff --git a/tests/Crypto/Cipher/SodiumAeadCipherTest.php b/tests/Crypto/Cipher/SodiumAeadCipherTest.php index d0ad169..e623559 100644 --- a/tests/Crypto/Cipher/SodiumAeadCipherTest.php +++ b/tests/Crypto/Cipher/SodiumAeadCipherTest.php @@ -9,8 +9,8 @@ final class SodiumAeadCipherTest extends AbstractCipherCase { - use CipherWithNonceTrait; use CipherWithAeadTrait; + use CipherWithNonceTrait; protected function setUp(): void { diff --git a/tests/Crypto/Cipher/SodiumGcmCipherTest.php b/tests/Crypto/Cipher/SodiumGcmCipherTest.php index 54b25d9..bd0f934 100644 --- a/tests/Crypto/Cipher/SodiumGcmCipherTest.php +++ b/tests/Crypto/Cipher/SodiumGcmCipherTest.php @@ -9,8 +9,8 @@ final class SodiumGcmCipherTest extends AbstractCipherCase { - use CipherWithNonceTrait; use CipherWithAeadTrait; + use CipherWithNonceTrait; protected function setUp(): void { diff --git a/tests/Crypto/EnvelopeCryptorTest.php b/tests/Crypto/EnvelopeCryptorTest.php index cac496a..bc8ad05 100644 --- a/tests/Crypto/EnvelopeCryptorTest.php +++ b/tests/Crypto/EnvelopeCryptorTest.php @@ -23,8 +23,7 @@ public function testEncryptProducesExpectedStructure( int $kwKeySize, int $kwNonceSize, int $kwOverheadSize, - ): void - { + ): void { $plaintext = 'test-plain-data'; $cyphertext = 'testcypherdata'; $secret = 'test-secret'; @@ -104,8 +103,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCiphers( int $kwKeySize, int $kwNonceSize, int $kwOverheadSize, - ): void - { + ): void { $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -159,8 +157,7 @@ public function testEncryptionIsRandomized( int $kwKeySize, int $kwNonceSize, int $kwOverheadSize, - ): void - { + ): void { [$kdf, $cipher, $kwCipher] = $this->createMocks( $kdfSaltSize, $dataKeySize, diff --git a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php index ddf19cd..80b736e 100644 --- a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php +++ b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php @@ -61,9 +61,10 @@ public function testSingleCipherTooShortDataThrowsException(): void private function getKdfStub(int $saltSize = 16): KdfInterface { - return new class ($saltSize) implements KdfInterface - { - public function __construct(private readonly int $saltSize) {} + 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 { @@ -74,22 +75,22 @@ public function derive(string $secret, int $keySize, string $context, string $sa public function getSaltSize(): int { - return $this->saltSize; + return $this->saltSize; } }; } private function getCipherStub(int $keySize = 32, int $nonceSize = 12): CipherInterface { - return new class ($keySize, $nonceSize) implements 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 { diff --git a/tests/Crypto/Kdf/AbstractKdfCase.php b/tests/Crypto/Kdf/AbstractKdfCase.php index 4182172..e1d75cc 100644 --- a/tests/Crypto/Kdf/AbstractKdfCase.php +++ b/tests/Crypto/Kdf/AbstractKdfCase.php @@ -55,7 +55,7 @@ public function testDeriveWithCustomAlgorithm(string $hashAlgo, int $keySize): v $this->assertSame($keySize, StringHelper::byteLength($key)); } - + public function testDeriveWithHashStaticSalt(): void { $staticSalt = random_bytes(32); diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index b64031e..07bcaee 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -8,7 +8,6 @@ use Yiisoft\Security\Crypto\EncryptionException; use Yiisoft\Security\Crypto\KdfInterface; use Yiisoft\Security\Crypto\Kdf\KdfKey; -use Yiisoft\Strings\StringHelper; final class KdfKeyTest extends AbstractKdfCase { diff --git a/tests/Crypto/Kdf/StringableParam.php b/tests/Crypto/Kdf/StringableParam.php index 261b46b..5f60abd 100644 --- a/tests/Crypto/Kdf/StringableParam.php +++ b/tests/Crypto/Kdf/StringableParam.php @@ -12,7 +12,8 @@ final class StringableParam implements Stringable public function __construct( #[SensitiveParameter] private readonly string $value - ) {} + ) { + } public function __toString(): string { diff --git a/tests/Crypto/KdfCryptorTest.php b/tests/Crypto/KdfCryptorTest.php index 7618eaa..ecf7b57 100644 --- a/tests/Crypto/KdfCryptorTest.php +++ b/tests/Crypto/KdfCryptorTest.php @@ -19,8 +19,7 @@ public function testEncryptProducesExpectedStructure( int $kdfSaltSize, int $keySize, int $nonceSize, - ): void - { + ): void { $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -65,8 +64,7 @@ public function testDecryptReturnsPlaintextAndUsesKdfAndCipher( int $kdfSaltSize, int $keySize, int $nonceSize, - ): void - { + ): void { $plaintext = 'test-plain-data'; $secret = 'test-secret'; $context = 'test-context'; @@ -104,8 +102,7 @@ public function testEncryptionIsRandomized( int $kdfSaltSize, int $keySize, int $nonceSize, - ): void - { + ): void { [$kdf, $cipher] = $this->createMocks( $kdfSaltSize, $keySize, From 53cee70925ef201560ef5b37a4382232787c6f03 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 18 Jun 2026 03:02:41 +0700 Subject: [PATCH 41/70] update --- composer-require-checker.json | 2 ++ tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php | 2 +- tests/Crypto/KdfCryptorTest.php | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/composer-require-checker.json b/composer-require-checker.json index 08958cc..223d380 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,6 +1,8 @@ { "symbol-whitelist": [ "random_bytes", + "OPENSSL_DONT_ZERO_PAD_KEY", + "OPENSSL_RAW_DATA", "openssl_encrypt", "openssl_decrypt", "openssl_error_string", diff --git a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php index 80b736e..66f584f 100644 --- a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php +++ b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php @@ -77,7 +77,7 @@ public function getSaltSize(): int { return $this->saltSize; } - }; + }; } private function getCipherStub(int $keySize = 32, int $nonceSize = 12): CipherInterface diff --git a/tests/Crypto/KdfCryptorTest.php b/tests/Crypto/KdfCryptorTest.php index ecf7b57..e0651ec 100644 --- a/tests/Crypto/KdfCryptorTest.php +++ b/tests/Crypto/KdfCryptorTest.php @@ -169,6 +169,7 @@ private function createMocks( return [$kdf, $cipher]; } + /** * [kdfSaltSize, kwKeySize, kwNonceSize, kwOverheadSize, dataKeySize, dataNonceSize, dataOverheadSize] */ From e5533c2b9c5a186ec41ba6fbc6b4d21a5f20435d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 18 Jun 2026 03:08:25 +0700 Subject: [PATCH 42/70] php min up to 8.2 --- .github/workflows/bc.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/composer-require-checker.yml | 2 +- .github/workflows/static.yml | 2 +- composer.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bc.yml b/.github/workflows/bc.yml index 00041a9..e2132c3 100644 --- a/.github/workflows/bc.yml +++ b/.github/workflows/bc.yml @@ -30,4 +30,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1'] + ['8.2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f84bc3..654dcdf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,5 +31,5 @@ jobs: os: >- ['ubuntu-latest', 'windows-latest'] php: >- - ['8.1', '8.2', '8.3', '8.4', '8.5'] + ['8.2', '8.3', '8.4', '8.5'] extensions: sodium, openssl diff --git a/.github/workflows/composer-require-checker.yml b/.github/workflows/composer-require-checker.yml index d2ef508..5605c7c 100644 --- a/.github/workflows/composer-require-checker.yml +++ b/.github/workflows/composer-require-checker.yml @@ -31,4 +31,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1', '8.2', '8.3', '8.4', '8.5'] + ['8.2', '8.3', '8.4', '8.5'] diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index d03874d..48c3bb8 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -29,4 +29,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1', '8.2', '8.3', '8.4'] + ['8.2', '8.3', '8.4'] diff --git a/composer.json b/composer.json index 9256500..438f006 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ } ], "require": { - "php": "8.1 - 8.5", + "php": "8.2 - 8.5", "ext-hash": "*", "yiisoft/strings": "^2.0" }, From 12ae81a8e413a5418b42ed504e18110d33ab2b79 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 18 Jun 2026 03:25:41 +0700 Subject: [PATCH 43/70] update changelog --- CHANGELOG.md | 2 +- src/Crypto/KdfCryptor.php | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a33168..f59ca0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 1.2.1 under development -- New #71: Add Session, Envelope and Versioned Cryptors (@olegbaturin) +- New #71: Add `KdfCryptor`, `EnvelopeCryptor` and `VersionedCryptor` (@olegbaturin) - Bug #72: Fix possibly null offset in `PasswordHasher` (@olegbaturin) ## 1.2.0 November 25, 2025 diff --git a/src/Crypto/KdfCryptor.php b/src/Crypto/KdfCryptor.php index 5265542..b2dbf15 100644 --- a/src/Crypto/KdfCryptor.php +++ b/src/Crypto/KdfCryptor.php @@ -53,9 +53,7 @@ public function __construct( /** * {@inheritdoc} * - * Structure: salt || - * nonce || - * ciphertext (with tag for AEAD ciphers) + * Structure: salt || nonce || ciphertext (with tag for AEAD ciphers) */ public function encrypt( string $data, From 363020226d8531a60e6e00af0435e93031508874 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 18 Jun 2026 04:14:32 +0700 Subject: [PATCH 44/70] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4afeee..62baf69 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ There is a special function in PHP that compares strings in a constant time: hash_equals($expected, $actual); ``` -## New cryptor +## Crypto `Crypt` provides encryption layer based on `AEAD` algorithms. It supports key derivation, session‑oriented encryption, envelope encryption, and versioned ciphertexts for seamless algorithm migration. From ccde6b2077a54541d1143ad1703e521112ff69c2 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Fri, 19 Jun 2026 04:21:55 +0700 Subject: [PATCH 45/70] update readme cryptor sections --- README.md | 149 ++++++++++++++++++++++++--------- src/Crypto/EnvelopeCryptor.php | 3 +- src/Crypto/KdfCryptor.php | 2 +- 3 files changed, 112 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 62baf69..14f6137 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ Security package provides a set of classes to handle common security-related tas ## Requirements -- PHP 8.1 - 8.5. +- PHP 8.2 - 8.5. - `hash` PHP extension. -- `openssl` PHP extension. -- `sodium` PHP extension. +- `openssl` PHP extension - optional. +- `sodium` PHP extension - optional. ## Installation @@ -128,97 +128,166 @@ There is a special function in PHP that compares strings in a constant time: hash_equals($expected, $actual); ``` -## Crypto +## Crypto module -`Crypt` provides encryption layer based on `AEAD` algorithms. -It supports key derivation, session‑oriented encryption, envelope encryption, and versioned ciphertexts for seamless algorithm migration. +The `Crypto` module provides a modern, authenticated encryption layer based on `AEAD` ciphers. It provides three built‑in cryptors: -All high‑level encryptors implement the `CryptorInterface`. Inject the desired cryptor (`SessionCryptor`, `EnvelopeCryptor` or `VersionedCryptor`) and use it as follows: +- **`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\Crypt\CryptorInterface; $cryptor = $container->get(CryptorInterface::class); -/** @var high‑entropy key or low‑entropy password */ -$secret; -/** @var Optional application‑specific string that is mixed into the KDF */ -$context; + +$secret = 'high-entropy-key-or-password'; +$context = 'application-specific-context'; $encrypted = $cryptor->encrypt('secret data', $secret, $context); $data = $cryptor->decrypt($encrypted, $secret, $context); ``` -### Session cryptor +### 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. -Session‑oriented encryption (single key derived per message, no key wrapping). -A fresh data encryption key (DEK) is derived from the secret and a random salt. +**Output structure:** +``` +kdfSalf (optional) || nonce || encryptedData (with tag) -Structure: ``` -keySalt || nonce || encrypted(data) + 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); ``` -DI Configuration: +Yii DI configuration: ```php // /config/di.php -use Yiisoft\Security\Crypt\SessionCryptor; -use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; -use Yiisoft\Security\Crypt\Kdf\KdfKey; +use Yiisoft\DI\Reference; +use Yiisoft\Security\Crypto\KdfCryptor; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\Kdf\KdfKey; -SessionCryptor::class => [ +KdfCryptor::class => [ '__construct()' => [ + 'kdf' => Reference::to(KdfKey::class), // replace with KdfPasswordArgon2::class for passwords 'cipher' => Reference::to(SodiumAeadCipher::class), - 'kdf' => Reference::to(KdfKey::class), ], ], ``` -### Envelope cryptor + +### 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 encrypted with the KEK and stored -together with the ciphertext. +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. -Structure: +Output structure: ``` -keySalt || dekNonce || encrypted(DEK) + tag || dataNonce || encrypted(data) + tag +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); ``` -DI Configuration: +Yii DI configuration: ```php // /config/di.php -use Yiisoft\Security\Crypt\EnvelopeCryptor; -use Yiisoft\Security\Crypt\Cipher\SodiumAeadCipher; -use Yiisoft\Security\Crypt\Kdf\KdfKey; +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()' => [ - 'cipher' => Reference::to(SodiumAeadCipher::class), '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 ], ], ``` -### Versioned cryptor +### VersionedCryptor Wraps multiple cryptors and adds a fixed‑length version prefix to every ciphertext. -DI Configuration: +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\Security\Crypt\VersionedCryptor; -use Yiisoft\Security\Crypt\SessionCryptor; -use Yiisoft\Security\Crypt\EnvelopeCryptor; +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) => SessionCryptor::class, + chr(0x01) => KdfCryptor::class, chr(0x96) => EnvelopeCryptor::class, ]), 'currentVersion' => chr(0x01), - 'versionSize' => 1 + // 'versionSize' => 1, // optional, auto-detected from currentVersion ], ], ``` @@ -241,7 +310,7 @@ KdfKey::class => [ ], ``` -#### KdfPassword - for low‑entropy passwords +#### KdfPasswordPbkdf2 - for low‑entropy passwords This first applies `PBKDF2` with a configurable iteration count, then `HKDF` to derive the final key. Follow OWASP recommendations for iteration counts. diff --git a/src/Crypto/EnvelopeCryptor.php b/src/Crypto/EnvelopeCryptor.php index 5be3aac..7e2164f 100644 --- a/src/Crypto/EnvelopeCryptor.php +++ b/src/Crypto/EnvelopeCryptor.php @@ -54,7 +54,8 @@ final class EnvelopeCryptor implements CryptorInterface /** * @param KdfInterface $kdf Key derivation function (used to derive KEK from secret). * @param CipherInterface $cipher Cipher used to encrypt the actual data. - * @param CipherInterface $kwCipher Cipher used to wrap the DEK. + * @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 readonly KdfInterface $kdf, diff --git a/src/Crypto/KdfCryptor.php b/src/Crypto/KdfCryptor.php index b2dbf15..8b67e8f 100644 --- a/src/Crypto/KdfCryptor.php +++ b/src/Crypto/KdfCryptor.php @@ -37,8 +37,8 @@ final class KdfCryptor implements CryptorInterface private readonly int $headerLength; /** - * @param CipherInterface $cipher Low‑level cipher (must support AEAD). * @param KdfInterface $kdf Key derivation function (used to derive DEK from secret + salt). + * @param CipherInterface $cipher Low‑level cipher (must support AEAD). */ public function __construct( private readonly KdfInterface $kdf, From f490dfe02c133e3c73fd6a12344727930421332e Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Fri, 19 Jun 2026 16:23:43 +0700 Subject: [PATCH 46/70] update readme ciphers --- README.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 14f6137..593c325 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ All cryptors implement the same `CryptorInterface`. Inject the desired cryptor a ```php //via container -use Yiisoft\Security\Crypt\CryptorInterface; +use Yiisoft\Security\Crypto\CryptorInterface; $cryptor = $container->get(CryptorInterface::class); @@ -292,7 +292,8 @@ VersionedCryptor::class => [ ], ``` -### Configure KDF + +### Configuring KDF The KDF is responsible for deriving cryptographic keys from the provided secret. Choose the appropriate KDF based on the type of secret. @@ -301,7 +302,7 @@ Use this when the secret is already a strong cryptographic key (e.g. a 256‑bit ```php // /config/di.php -use Yiisoft\Security\Crypt\Kdf\KdfKey; +use Yiisoft\Security\Crypto\Kdf\KdfKey; KdfKey::class => [ '__construct()' => [ @@ -316,7 +317,7 @@ Follow OWASP recommendations for iteration counts. ```php // /config/di.php -use Yiisoft\Security\Crypt\Kdf\KdfPassword; +use Yiisoft\Security\Crypto\Kdf\KdfPassword; KdfPassword::class => [ '__construct()' => [ @@ -326,35 +327,103 @@ KdfPassword::class => [ ], ``` -### Configuring AEAD Ciphers +### Configuring ciphers -Two backends are available: `OpenSSL` and `Sodium` (libsodium). +The module provides two backends: `OpenSSL` and `Sodium` (libsodium). #### OpenSSLAeadCipher -Supports `AES‑GCM` family. +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\Crypt\Cipher\OpenSSLAeadCipher; +use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; OpenSSLAeadCipher::class => [ '__construct()' => [ - 'cipher' => 'AES-192-GCM', + 'cipher' => 'AES-256-GCM', ], ], ``` #### SodiumAeadCipher -Supports `AES‑256‑GCM` (hardware accelerated), `ChaCha20‑Poly1305‑IETF`, and `XChaCha20‑Poly1305‑IETF`. -Note: `AES‑256‑GCM` with `Sodium` requires CPU support for AES instructions (`AES‑NI`). Use `ChaCha20‑Poly1305‑IETF` for a safe, non‑hardware‑dependent alternative. +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\Crypt\Cipher\SodiumAeadCipher; +use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; SodiumAeadCipher::class => [ '__construct()' => [ - 'cipher' => 'ChaCha20-Poly1305-IETF', + '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`, ], ], ``` From 33e578d196f0896dac94c2a646ed80f173699816 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 20 Jun 2026 01:05:44 +0700 Subject: [PATCH 47/70] update readme kdf --- README.md | 97 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 593c325..1067c2e 100644 --- a/README.md +++ b/README.md @@ -153,13 +153,13 @@ $encrypted = $cryptor->encrypt('secret data', $secret, $context); $data = $cryptor->decrypt($encrypted, $secret, $context); ``` -### KdfCryptor +### `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:** +Output structure: ``` kdfSalf (optional) || nonce || encryptedData (with tag) @@ -198,7 +198,7 @@ KdfCryptor::class => [ ``` -### EnvelopeCryptor +### `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 @@ -248,7 +248,7 @@ EnvelopeCryptor::class => [ ``` -### VersionedCryptor +### `VersionedCryptor` Wraps multiple cryptors and adds a fixed‑length version prefix to every ciphertext. @@ -295,50 +295,113 @@ VersionedCryptor::class => [ ### Configuring KDF -The KDF is responsible for deriving cryptographic keys from the provided secret. Choose the appropriate KDF based on the type of secret. +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 +); -#### KdfKey - for high‑entropy keys -Use this when the secret is already a strong cryptographic key (e.g. a 256‑bit random value). It applies `HKDF` directly. +// 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()' => [ - 'algorithm' => 'sha512', // any hash_hmac_algos() + '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 -This first applies `PBKDF2` with a configurable iteration count, then `HKDF` to derive the final key. -Follow OWASP recommendations for iteration counts. +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\KdfPassword; +use Yiisoft\Security\Crypto\Kdf\KdfPasswordPbkdf2; -KdfPassword::class => [ +KdfPasswordPbkdf2::class => [ '__construct()' => [ - 'algorithm' => 'sha512', // any hash_hmac_algos() 'iterations' => 700_000, + 'hashAlgo' => 'sha512', // any hash_hmac_algos() ], ], ``` ### Configuring ciphers -The module provides two backends: `OpenSSL` and `Sodium` (libsodium). +The module provides two backends: `OpenSSL` and `Sodium` (`libsodium`). #### OpenSSLAeadCipher -Uses OpenSSL's AEAD ciphers. Supports the following algorithms: +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** +- `CHACHA20-POLY1305` (`IETF` variant, 12‑byte nonce) - **default** Runtime configuration: ```php @@ -365,7 +428,7 @@ OpenSSLAeadCipher::class => [ #### SodiumAeadCipher -Uses libsodium's high‑performance AEAD ciphers. Supports the following algorithms: +Uses `libsodium`'s high‑performance `AEAD` ciphers. Supports the following algorithms: - `AES-256-GCM` – requires hardware AES‑NI support. - `CHACHA20-POLY1305-IETF` - **default** From fb4b9b0aa9f4a9b98afeb61fd64f96665b281d61 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 20 Jun 2026 20:38:42 +0700 Subject: [PATCH 48/70] update tests --- tests/Crypto/Kdf/KdfKeyTest.php | 45 ++++++++++++++++++---- tests/Crypto/Kdf/KdfPasswordArgon2Test.php | 12 +++--- tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php | 12 +++--- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index 07bcaee..def11e4 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Security\Tests\Crypto\Kdf; use Stringable; +use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Security\Crypto\EncryptionException; use Yiisoft\Security\Crypto\KdfInterface; use Yiisoft\Security\Crypto\Kdf\KdfKey; @@ -31,25 +32,25 @@ public static function dataProviderKeyValues(): iterable 'sha256', '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', 32, - 'text-context', + 'test-context', 'ae8cbb001c062cd2c00ed6956842dc4d36f5ce3e9b6b607e46e47018841b29d7', - '465b57608c27082a09e197024a5d0a703017fe12f6fe7f0219b652a6f5e27f3b', + 'af2da95bc3da38c4d5321779001f31119151aabdb7e553ae2534c17bd48897ac', ]; yield [ 'sha512', '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', 64, - 'text-context', + 'test-context', '7f22a943efd3537ef9e0dc98e7031d9f71b16868ccc0aafe110ab32f7e54db61', - 'db4d7ac9c6f656e0f7f0232d12993f7a1971568a2ce0a9bac97039a24beb914bd984685796a418e91d3e1a2f325861fe0b88db5e5ad2a54de342592f5af0168e', + '5a64ca7627ad8c93254123dda29e631110dea2276db55e0cf273518b367f0a0a38cb307970458cbc6e78d10d9d5b5ead975cd38a8b086ab8c776e4605ab82386', ]; yield [ 'sha3-256', '983447213c2c295a72a64d95e069793b9acf4cbaef59b71a86cbc6aec4f020e4', 32, - 'text-context', + 'test-context', 'aa24ea6b979b1a857d9f9dfa0dcac8a44c3f7b9ea061551529556ac70dd0cfeb', - '682b82147fe8f6cafa0fd6aeee12910bad20712ea93863289631a3cd6905ea5a', + '1d8a2011276aadcf62e4f999386fe9585a4e3797f55a5c43efda4b9a211c75c0', ]; } @@ -78,8 +79,8 @@ public function testDifferentStaticSaltProducesDifferentKey(): void $keySize = 32; $secret = random_bytes($keySize); - $key1 = $kdf1->derive($secret, $keySize, 'context'); - $key2 = $kdf2->derive($secret, $keySize, 'context'); + $key1 = $kdf1->derive($secret, $keySize, 'test-context'); + $key2 = $kdf2->derive($secret, $keySize, 'test-context'); $this->assertNotSame($key1, $key2); } @@ -90,4 +91,32 @@ public function testInvalidSecretThrowsException(): void $this->expectException(EncryptionException::class); $kdf->derive('', 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)); + } + + public static function dataProviderEmptyStaticSaltKeyValues(): iterable + { + yield [ + 'sha256', + '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', + 32, + 'test-context', + '50320fc7d6a85c6bb631a10475bd27e0d49892c509041692917c19b0451f98b2', + ]; + yield [ + 'sha512', + '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', + 64, + 'test-context', + 'f2b0f6e277232602dfe7588c37850f646c97b4fd8fb120ecf6b28a1b2548939f06e1941feee58a834ad8644b4f62f140a12d001ed6bb297c7b2c8386e0ef249e', + ]; + } } diff --git a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php index cba1ce4..5026bc1 100644 --- a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -30,25 +30,25 @@ public static function dataProviderKeyValues(): iterable 'sha256', '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', 32, - 'text-context', + 'test-context', 'ae8cbb001c062cd2c00ed6956842dc4d', - '97d9d6eb9b8b88eac18274b75c73b439cd099b4e778f290bcc156038e8f40e50', + '0dd1df2a07aa3727520f1863b0f753d4e118bec28e324c05eeea4a274b7f5d5e', ]; yield [ 'sha512', '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', 64, - 'text-context', + 'test-context', '7f22a943efd3537ef9e0dc98e7031d9f', - 'ac666c6333f2aa0364465b9d4b5446dc1f0424795cb10f5ffcc9161b6266b939ff07e18f17261d5016b5dc2ab0ea464284e2a70d72f8b8c3f4456b015bf9d14d', + '9c2182653d63d369cecc7bf96e24325aaa09eaca943accd53b263ad8390eb4e39b36ad4a9e89b2849cd7699138f14b825722073729eebae8a49f8e9ad278a367', ]; yield [ 'sha3-256', '983447213c2c295a72a64d95e069793b9acf4cbaef59b71a86cbc6aec4f020e4', 32, - 'text-context', + 'test-context', 'aa24ea6b979b1a857d9f9dfa0dcac8a4', - '4f097ff97d2faeb2ce0b99c29148e60929bbbea6ba3c442d5807a645a933b3b6', + 'f3485c8fec6e20d0e81a332d9a6e7293985ad345076a2b167d3b682e612ab549', ]; } } diff --git a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php index a913b1f..05a9f05 100644 --- a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php @@ -31,25 +31,25 @@ public static function dataProviderKeyValues(): iterable 'sha256', '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', 32, - 'text-context', + 'test-context', 'ae8cbb001c062cd2c00ed6956842dc4d36f5ce3e9b6b607e46e47018841b29d7', - '91718343d3673e2c2407ef6f79d3516a4e111ce56c935cd1ec9566b16b21b16b', + '9b203ca13de4bae280fcb5b0af75696f5828e8e2135b995ddc20a769517f9141', ]; yield [ 'sha512', '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', 64, - 'text-context', + 'test-context', '7f22a943efd3537ef9e0dc98e7031d9f71b16868ccc0aafe110ab32f7e54db61', - '418e0b515d1b59f8840519ae3f9da693d93afaef9b1e2f6c4d5cf5f85eb1c229a66cb96977a90a1a67a888f48c23738d68e4ce735cc8529b92bb15a5e1738c29', + 'aaca6ad950cd6e9b187abf8fd3e67c9d6f199222d84e5f1a8c172b3d4e880bbe951e2ea6f50eeb4c660393e8b6a1e420bd901453a103d16af3b6fff3da574045', ]; yield [ 'sha3-256', '983447213c2c295a72a64d95e069793b9acf4cbaef59b71a86cbc6aec4f020e4', 32, - 'text-context', + 'test-context', 'aa24ea6b979b1a857d9f9dfa0dcac8a44c3f7b9ea061551529556ac70dd0cfeb', - '13091daa596fea871ce3e458366857fe72747e6f7ef3bc578cfefd2cc8031329', + '5f38ecf98ed67feaeced2bc83d2a41e47a2850e353297f916a0b551b1ecc115f', ]; } From 473428da619537b0d483ad1529471da05a04825d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 20 Jun 2026 20:40:29 +0700 Subject: [PATCH 49/70] fix ci --- tests/Crypto/Kdf/KdfKeyTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index def11e4..24d7bdf 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -91,7 +91,7 @@ public function testInvalidSecretThrowsException(): void $this->expectException(EncryptionException::class); $kdf->derive('', 32, 'test-context', 'test-salt'); } - + #[DataProvider('dataProviderEmptyStaticSaltKeyValues')] public function testEmptyStaticSaltDerivesExpectedKey(string $hashAlgo, string $secret, int $keySize, string $context, string $key): void { From 75181ab177d3e5fda43add7b24f7a295ecdd4bfd Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 21 Jun 2026 00:05:42 +0700 Subject: [PATCH 50/70] update readme examples --- README.md | 99 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1067c2e..81ada85 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ hash_equals($expected, $actual); 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 +- `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 @@ -155,13 +155,13 @@ $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. +`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: ``` -kdfSalf (optional) || nonce || encryptedData (with tag) +kdfSalt (optional) || nonce || encryptedData (with tag) ``` @@ -200,11 +200,11 @@ KdfCryptor::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. +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. +The `DEK` wrap cipher can be specified separately (e.g., `OpenSSLWrapCipher`); if omitted, the data cipher is used for wrapping as well. Output structure: ``` @@ -275,6 +275,7 @@ $cryptor = new VersionedCryptor( 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; @@ -283,8 +284,8 @@ use Yiisoft\Security\Crypto\EnvelopeCryptor; VersionedCryptor::class => [ '__construct()' => [ 'cryptors' => ReferencesArray::from([ - chr(0x01) => KdfCryptor::class, - chr(0x96) => EnvelopeCryptor::class, + chr(0x01) => Reference::to(KdfCryptor::class), + chr(0x96) => Reference::to(EnvelopeCryptor::class}, ]), 'currentVersion' => chr(0x01), // 'versionSize' => 1, // optional, auto-detected from currentVersion @@ -367,7 +368,7 @@ KdfPasswordArgon2::class => [ #### KdfPasswordPbkdf2 - for low‑entropy passwords -Applies `PBKDF2` (with SHA‑256) to the password and salt, then `HKDF` to expand to the final key length. +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: @@ -461,22 +462,22 @@ SodiumAeadCipher::class => [ #### 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. +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** +- `AES-256-WRAP` - **default** Runtime configuration: ```php use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; -// Using the default algorithm (`AES-256-WRAP`) +// Using the default algorithm ('AES-256-WRAP') $cipher = new OpenSSLWrapCipher(); // Explicitly specify an algorithm -$cipher = new OpenSSLWrapCipher(cipher: `AES-128-WRAP`); +$cipher = new OpenSSLWrapCipher(cipher: 'AES-128-WRAP'); ``` Yii DI configuration: @@ -486,12 +487,70 @@ use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; OpenSSLWrapCipher::class => [ '__construct()' => [ - 'cipher' => `AES-128-WRAP`, + 'cipher' => 'AES-128-WRAP', ], ], ``` -## Old cryptor + +### 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. From 5c125cee13ed4f35e2d83420c670c22ff23951ed Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 21 Jun 2026 04:14:34 +0700 Subject: [PATCH 51/70] update readme --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 81ada85..1603ba1 100644 --- a/README.md +++ b/README.md @@ -132,9 +132,9 @@ hash_equals($expected, $actual); 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 +- `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 @@ -156,7 +156,7 @@ $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`. +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: @@ -296,11 +296,14 @@ VersionedCryptor::class => [ ### Configuring KDF -The `KDF` is responsible for deriving cryptographic keys from the provided secret. Choose the appropriate KDF based on the type of secret. +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. +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: @@ -431,7 +434,7 @@ OpenSSLAeadCipher::class => [ Uses `libsodium`'s high‑performance `AEAD` ciphers. Supports the following algorithms: -- `AES-256-GCM` – requires hardware AES‑NI support. +- `AES-256-GCM` – requires hardware `AES‑NI` support. - `CHACHA20-POLY1305-IETF` - **default** - `XCHACHA20-POLY1305-IETF` From 326c18b091c087a57ad4734efbd832f918b0b79c Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 21 Jun 2026 20:23:46 +0700 Subject: [PATCH 52/70] update ciphers --- composer.json | 4 ++-- src/Crypto/Cipher/OpenSSLAeadCipher.php | 3 +++ src/Crypto/Cipher/OpenSSLWrapCipher.php | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 438f006..45fbdba 100644 --- a/composer.json +++ b/composer.json @@ -46,8 +46,8 @@ "spatie/phpunit-watcher": "^1.24" }, "suggest": { - "ext-openssl": "*", - "ext-sodium": "*" + "ext-openssl": "Required for OpenSSL based ciphers", + "ext-sodium": "Required for Sodium based ciphers" }, "autoload": { "psr-4": { diff --git a/src/Crypto/Cipher/OpenSSLAeadCipher.php b/src/Crypto/Cipher/OpenSSLAeadCipher.php index dd77c97..18788a8 100644 --- a/src/Crypto/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypto/Cipher/OpenSSLAeadCipher.php @@ -16,6 +16,9 @@ use function openssl_encrypt; use function openssl_error_string; +use const OPENSSL_DONT_ZERO_PAD_KEY; +use const OPENSSL_RAW_DATA; + /** * AEAD cipher implementation using OpenSSL extension. * Supports AES-GCM (128, 192, 256) and ChaCha20-Poly1305(IETF variant) with 16-byte authentication tags. diff --git a/src/Crypto/Cipher/OpenSSLWrapCipher.php b/src/Crypto/Cipher/OpenSSLWrapCipher.php index 8a42c1c..77cc4ea 100644 --- a/src/Crypto/Cipher/OpenSSLWrapCipher.php +++ b/src/Crypto/Cipher/OpenSSLWrapCipher.php @@ -17,6 +17,9 @@ use function openssl_error_string; use function str_repeat; +use const OPENSSL_DONT_ZERO_PAD_KEY; +use const OPENSSL_RAW_DATA; + /** * Key wrapping cipher using OpenSSL (RFC 5649 / AES-KW). * Nonce and AAD are ignored. From 8d7bd7583f3ec004453ac5ddd5417f809dab473d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 21 Jun 2026 20:40:55 +0700 Subject: [PATCH 53/70] update composer-require-checker --- composer-require-checker.json | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/composer-require-checker.json b/composer-require-checker.json index 223d380..adc21fe 100644 --- a/composer-require-checker.json +++ b/composer-require-checker.json @@ -1,19 +1,12 @@ { - "symbol-whitelist": [ - "random_bytes", - "OPENSSL_DONT_ZERO_PAD_KEY", - "OPENSSL_RAW_DATA", - "openssl_encrypt", - "openssl_decrypt", - "openssl_error_string", - "sodium_crypto_aead_aes256gcm_is_available", - "sodium_crypto_aead_aes256gcm_encrypt", - "sodium_crypto_aead_aes256gcm_decrypt", - "sodium_crypto_aead_chacha20poly1305_ietf_encrypt", - "sodium_crypto_aead_chacha20poly1305_ietf_decrypt", - "sodium_crypto_aead_xchacha20poly1305_ietf_encrypt", - "sodium_crypto_aead_xchacha20poly1305_ietf_decrypt", - "sodium_crypto_pwhash", - "SodiumException" + "php-core-extensions" : [ + "Core", + "hash", + "Reflection", + "SPL", + "random", + "standard", + "openssl", + "sodium" ] } From 0fb370d005516ece75e6740058f776c7d99c4a0f Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sun, 21 Jun 2026 22:46:08 +0700 Subject: [PATCH 54/70] fix tests --- tests/Crypto/Kdf/KdfPasswordArgon2Test.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php index 5026bc1..ba1bc48 100644 --- a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -10,6 +10,13 @@ final class KdfPasswordArgon2Test extends AbstractKdfCase { + protected function setUp(): void + { + if (!extension_loaded('sodium')) { + $this->markTestSkipped('Sodium extension is required for these tests.'); + } + } + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { return $hashAlgo From a7cae865f346ccbd0b604b1ebafde4509c0cae79 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 02:48:18 +0700 Subject: [PATCH 55/70] update readony classes --- rector.php | 2 +- src/Crypto/Cipher/OpenSSLAeadCipher.php | 8 ++++---- src/Crypto/Cipher/OpenSSLWrapCipher.php | 8 ++++---- src/Crypto/Cipher/SodiumAeadCipher.php | 8 ++++---- src/Crypto/EnvelopeCryptor.php | 26 ++++++++++++------------- src/Crypto/Kdf/KdfKey.php | 8 ++++---- src/Crypto/Kdf/KdfPasswordArgon2.php | 10 +++++----- src/Crypto/Kdf/KdfPasswordPbkdf2.php | 6 +++--- src/Crypto/KdfCryptor.php | 14 ++++++------- src/Crypto/VersionedCryptor.php | 8 ++++---- tests/Crypto/Kdf/StringableParam.php | 4 ++-- 11 files changed, 51 insertions(+), 51 deletions(-) diff --git a/rector.php b/rector.php index 54537c2..cacf028 100644 --- a/rector.php +++ b/rector.php @@ -13,7 +13,7 @@ __DIR__ . '/src', __DIR__ . '/tests', ]) - ->withPhpSets(php81: true) + ->withPhpSets(php82: true) ->withRules([ InlineConstructorDefaultToPropertyRector::class, ]) diff --git a/src/Crypto/Cipher/OpenSSLAeadCipher.php b/src/Crypto/Cipher/OpenSSLAeadCipher.php index 18788a8..6700eb2 100644 --- a/src/Crypto/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypto/Cipher/OpenSSLAeadCipher.php @@ -23,19 +23,19 @@ * AEAD cipher implementation using OpenSSL extension. * Supports AES-GCM (128, 192, 256) and ChaCha20-Poly1305(IETF variant) with 16-byte authentication tags. */ -final class OpenSSLAeadCipher implements CipherInterface +final readonly class OpenSSLAeadCipher implements CipherInterface { private const TAG_SIZE = 16; /** * @psalm-var int<1, max> */ - private readonly int $keySize; + private int $keySize; /** * @psalm-var int<1, max> */ - private readonly int $nonceSize; + private int $nonceSize; /** * Look-up table of allowed OpenSSL ciphers. @@ -59,7 +59,7 @@ final class OpenSSLAeadCipher implements CipherInterface * @throws RuntimeException If OpenSSL extension is not loaded or the cipher is not allowed. */ public function __construct( - private readonly string $cipher = 'CHACHA20-POLY1305', + private string $cipher = 'CHACHA20-POLY1305', ) { if (!extension_loaded('openssl')) { throw new RuntimeException('Encryption requires the OpenSSL PHP extension.'); diff --git a/src/Crypto/Cipher/OpenSSLWrapCipher.php b/src/Crypto/Cipher/OpenSSLWrapCipher.php index 77cc4ea..04f13c7 100644 --- a/src/Crypto/Cipher/OpenSSLWrapCipher.php +++ b/src/Crypto/Cipher/OpenSSLWrapCipher.php @@ -27,7 +27,7 @@ * For key wrapping only, not general-purpose encryption. * Plaintext and ciphertext MUST be multiples of 8 bytes. */ -final class OpenSSLWrapCipher implements CipherInterface +final readonly class OpenSSLWrapCipher implements CipherInterface { /** * Tag size in bytes (8 bytes for AES-KW). @@ -37,7 +37,7 @@ final class OpenSSLWrapCipher implements CipherInterface /** * @psalm-var int<1, max> */ - private readonly int $keySize; + private int $keySize; /** * Dummy nonce (all zeros) to prevent OpenSSL from issuing warnings. @@ -47,7 +47,7 @@ final class OpenSSLWrapCipher implements CipherInterface * 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 readonly string $dummyNonce; + private string $dummyNonce; /** * Look-up table of allowed OpenSSL key wrap ciphers. @@ -70,7 +70,7 @@ final class OpenSSLWrapCipher implements CipherInterface * @throws RuntimeException If OpenSSL extension is not loaded or the cipher is not allowed. */ public function __construct( - private readonly string $cipher = 'AES-256-WRAP', + private string $cipher = 'AES-256-WRAP', ) { if (!extension_loaded('openssl')) { throw new RuntimeException('Encryption requires the OpenSSL PHP extension.'); diff --git a/src/Crypto/Cipher/SodiumAeadCipher.php b/src/Crypto/Cipher/SodiumAeadCipher.php index 3047ba0..aa0014b 100644 --- a/src/Crypto/Cipher/SodiumAeadCipher.php +++ b/src/Crypto/Cipher/SodiumAeadCipher.php @@ -25,7 +25,7 @@ * Supports AES-256-GCM (hardware accelerated), ChaCha20-Poly1305-IETF, and XChaCha20-Poly1305-IETF. * Authentication tag is always 16 bytes and is included in the returned ciphertext. */ -final class SodiumAeadCipher implements CipherInterface +final readonly class SodiumAeadCipher implements CipherInterface { /** * Authentication tag size in bytes (constant for all supported modes). @@ -35,12 +35,12 @@ final class SodiumAeadCipher implements CipherInterface /** * @psalm-var int<1, max> */ - private readonly int $keySize; + private int $keySize; /** * @psalm-var int<1, max> */ - private readonly int $nonceSize; + private int $nonceSize; /** * Look-up table of allowed Sodium ciphers. @@ -63,7 +63,7 @@ final class SodiumAeadCipher implements CipherInterface * @throws RuntimeException If sodium extension is missing, cipher not allowed, or AES-256-GCM without hardware support. */ public function __construct( - private readonly string $cipher = 'CHACHA20-POLY1305-IETF', + private string $cipher = 'CHACHA20-POLY1305-IETF', ) { if (!extension_loaded('sodium')) { throw new RuntimeException('Encryption requires the Sodium PHP extension.'); diff --git a/src/Crypto/EnvelopeCryptor.php b/src/Crypto/EnvelopeCryptor.php index 7e2164f..fa3b8bb 100644 --- a/src/Crypto/EnvelopeCryptor.php +++ b/src/Crypto/EnvelopeCryptor.php @@ -17,39 +17,39 @@ * The cipher responsible for encrypting the actual data MUST be AEAD, * because the final payload contains no external authentication. */ -final class EnvelopeCryptor implements CryptorInterface +final readonly class EnvelopeCryptor implements CryptorInterface { - private readonly CipherInterface $kwCipher; + private CipherInterface $kwCipher; /** * @psalm-var int<1, max> */ - private readonly int $kekSize; + private int $kekSize; /** * @psalm-var int<1, max> */ - private readonly int $dekSize; + private int $dekSize; /** * @psalm-var int<0, max> */ - private readonly int $dekNonceSize; + private int $dekNonceSize; /** * @psalm-var int<0, max> */ - private readonly int $dataNonceSize; + private int $dataNonceSize; /** * @psalm-var int<0, max> */ - private readonly int $saltSize; + private int $saltSize; - private readonly int $saltDekNonceLength; - private readonly int $wrapDekLength; - private readonly int $saltDekNonceWrapDekLength; - private readonly int $headerLength; + private int $saltDekNonceLength; + private int $wrapDekLength; + private int $saltDekNonceWrapDekLength; + private int $headerLength; /** * @param KdfInterface $kdf Key derivation function (used to derive KEK from secret). @@ -58,8 +58,8 @@ final class EnvelopeCryptor implements CryptorInterface * the same cipher as `$cipher` is used for both data encryption and DEK wrapping */ public function __construct( - private readonly KdfInterface $kdf, - private readonly CipherInterface $cipher, + private KdfInterface $kdf, + private CipherInterface $cipher, ?CipherInterface $kwCipher = null, ) { $this->kwCipher = $kwCipher ?? $this->cipher; diff --git a/src/Crypto/Kdf/KdfKey.php b/src/Crypto/Kdf/KdfKey.php index d978dff..e71b08b 100644 --- a/src/Crypto/Kdf/KdfKey.php +++ b/src/Crypto/Kdf/KdfKey.php @@ -21,7 +21,7 @@ * KDF that directly applies HKDF (HMAC-based Key Derivation Function) to the input secret. * Suitable for deriving additional keys from a high-entropy secret (random key). */ -final class KdfKey implements KdfInterface +final readonly class KdfKey implements KdfInterface { /** * Static salt used in HKDF extraction phase. @@ -32,7 +32,7 @@ final class KdfKey implements KdfInterface * * If provided, it must be exactly the length of the hash output (e.g., 32 bytes for SHA‑256). */ - private readonly string $hashStaticSalt; + private string $hashStaticSalt; /** * @param string $hashAlgo Hash algorithm for key derivation {@see hash_hmac_algos()}. @@ -46,9 +46,9 @@ final class KdfKey implements KdfInterface * @throws RuntimeException */ public function __construct( - private readonly string $hashAlgo = 'sha256', + private string $hashAlgo = 'sha256', string|Stringable $hashStaticSalt = '', - private readonly int $saltSize = 32, + private int $saltSize = 32, ) { if (!in_array($hashAlgo, hash_hmac_algos())) { throw new RuntimeException("'{$hashAlgo}' is not an allowed algorithm."); diff --git a/src/Crypto/Kdf/KdfPasswordArgon2.php b/src/Crypto/Kdf/KdfPasswordArgon2.php index a305042..b213ecd 100644 --- a/src/Crypto/Kdf/KdfPasswordArgon2.php +++ b/src/Crypto/Kdf/KdfPasswordArgon2.php @@ -21,12 +21,12 @@ * * Note: `sodium_crypto_pwhash()` always uses a single thread (p=1). */ -final class KdfPasswordArgon2 implements KdfInterface +final readonly class KdfPasswordArgon2 implements KdfInterface { private const PW_HASH_LENGTH = 32; private const PW_SALT_SIZE = 16; - private readonly KdfKey $kdfKey; + private KdfKey $kdfKey; /** * @param int $algo Argon2 variant (defaults to Argon2id, constant value 2 – `SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13`). @@ -40,9 +40,9 @@ final class KdfPasswordArgon2 implements KdfInterface * @see https://owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2 */ public function __construct( - private readonly int $algo = 2, - private readonly int $opslimit = 2, - private readonly int $memlimit = 67108864, + private int $algo = 2, + private int $opslimit = 2, + private int $memlimit = 67108864, string $hashAlgo = 'sha256', string|Stringable $hashStaticSalt = '', ) { diff --git a/src/Crypto/Kdf/KdfPasswordPbkdf2.php b/src/Crypto/Kdf/KdfPasswordPbkdf2.php index fd1f116..956523a 100644 --- a/src/Crypto/Kdf/KdfPasswordPbkdf2.php +++ b/src/Crypto/Kdf/KdfPasswordPbkdf2.php @@ -19,12 +19,12 @@ * KDF that first applies PBKDF2 to the input password, * then applies HKDF to the result. Suitable for deriving cryptographic keys from low-entropy passwords. */ -final class KdfPasswordPbkdf2 implements KdfInterface +final readonly class KdfPasswordPbkdf2 implements KdfInterface { private const PW_HASH_ALGO = 'sha256'; private const PW_SALT_SIZE = 32; - private readonly KdfKey $kdfKey; + private KdfKey $kdfKey; /** * @param int $iterations Derivation iteration count (must be > 0). See OWASP recommendations. @@ -36,7 +36,7 @@ final class KdfPasswordPbkdf2 implements KdfInterface * @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 */ public function __construct( - private readonly int $iterations = 600_000, + private int $iterations = 600_000, string $hashAlgo = 'sha256', string|Stringable $hashStaticSalt = '', ) { diff --git a/src/Crypto/KdfCryptor.php b/src/Crypto/KdfCryptor.php index 8b67e8f..d538921 100644 --- a/src/Crypto/KdfCryptor.php +++ b/src/Crypto/KdfCryptor.php @@ -17,32 +17,32 @@ * The resulting ciphertext contains no built‑in authentication mechanism, * therefore the underlying cipher MUST be AEAD to provide integrity and authenticity. */ -final class KdfCryptor implements CryptorInterface +final readonly class KdfCryptor implements CryptorInterface { /** * @psalm-var int<1, max> */ - private readonly int $keySize; + private int $keySize; /** * @psalm-var int<0, max> */ - private readonly int $nonceSize; + private int $nonceSize; /** * @psalm-var int<0, max> */ - private readonly int $saltSize; + private int $saltSize; - private readonly int $headerLength; + private int $headerLength; /** * @param KdfInterface $kdf Key derivation function (used to derive DEK from secret + salt). * @param CipherInterface $cipher Low‑level cipher (must support AEAD). */ public function __construct( - private readonly KdfInterface $kdf, - private readonly CipherInterface $cipher, + private KdfInterface $kdf, + private CipherInterface $cipher, ) { $this->keySize = $this->cipher->getKeySize(); $this->nonceSize = $this->cipher->getNonceSize(); diff --git a/src/Crypto/VersionedCryptor.php b/src/Crypto/VersionedCryptor.php index f1fdaa2..a0807dd 100644 --- a/src/Crypto/VersionedCryptor.php +++ b/src/Crypto/VersionedCryptor.php @@ -16,17 +16,17 @@ * This enables seamless migration between different encryption algorithms or key lengths. * Each encrypted message begins with a fixed‑length version identifier. */ -final class VersionedCryptor implements CryptorInterface +final readonly class VersionedCryptor implements CryptorInterface { /** * @var array Storage for registered cryptors indexed by their version identifier. */ - private readonly array $cryptors; + private array $cryptors; /** * @psalm-var int<1, max> */ - private readonly int $versionSize; + private int $versionSize; /** * @param array $cryptors List of cryptors indexed by version string. @@ -39,7 +39,7 @@ final class VersionedCryptor implements CryptorInterface */ public function __construct( array $cryptors, - private readonly string $currentVersion, + private string $currentVersion, ?int $versionSize = null, ) { $versionSize ??= StringHelper::byteLength($this->currentVersion); diff --git a/tests/Crypto/Kdf/StringableParam.php b/tests/Crypto/Kdf/StringableParam.php index 5f60abd..ae1d2c2 100644 --- a/tests/Crypto/Kdf/StringableParam.php +++ b/tests/Crypto/Kdf/StringableParam.php @@ -7,11 +7,11 @@ use SensitiveParameter; use Stringable; -final class StringableParam implements Stringable +final readonly class StringableParam implements Stringable { public function __construct( #[SensitiveParameter] - private readonly string $value + private string $value ) { } From 8a74908f4eabb3a738d0cc7eceb2964e597f260d Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 03:04:47 +0700 Subject: [PATCH 56/70] update psalm --- psalm.xml | 4 ---- src/Crypto/Cipher/OpenSSLAeadCipher.php | 2 ++ src/Crypto/Cipher/OpenSSLWrapCipher.php | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/psalm.xml b/psalm.xml index 39a3bda..12e2e75 100644 --- a/psalm.xml +++ b/psalm.xml @@ -15,8 +15,4 @@ - - - - diff --git a/src/Crypto/Cipher/OpenSSLAeadCipher.php b/src/Crypto/Cipher/OpenSSLAeadCipher.php index 6700eb2..a88671d 100644 --- a/src/Crypto/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypto/Cipher/OpenSSLAeadCipher.php @@ -93,6 +93,7 @@ public function encrypt( $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); } @@ -125,6 +126,7 @@ public function decrypt( $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); } diff --git a/src/Crypto/Cipher/OpenSSLWrapCipher.php b/src/Crypto/Cipher/OpenSSLWrapCipher.php index 04f13c7..eb35853 100644 --- a/src/Crypto/Cipher/OpenSSLWrapCipher.php +++ b/src/Crypto/Cipher/OpenSSLWrapCipher.php @@ -105,6 +105,7 @@ public function encrypt( $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); } @@ -134,6 +135,7 @@ public function decrypt( $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); } From 4e66f4d9f1701aa2f685db332dec2707df678a85 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 18:48:13 +0700 Subject: [PATCH 57/70] fix style --- src/Crypto/Cipher/OpenSSLAeadCipher.php | 20 ++++---- src/Crypto/Cipher/OpenSSLWrapCipher.php | 30 +++++------ src/Crypto/Cipher/SodiumAeadCipher.php | 20 ++++---- src/Crypto/CryptorInterface.php | 4 +- src/Crypto/EncryptionException.php | 4 +- src/Crypto/EnvelopeCryptor.php | 4 +- src/Crypto/VersionedCryptor.php | 4 +- tests/Crypto/Cipher/AbstractCipherCase.php | 8 +-- tests/Crypto/Cipher/OpenSSLAeadCipherTest.php | 22 ++++---- tests/Crypto/Cipher/OpenSSLWrapCipherTest.php | 22 ++++---- tests/Crypto/Cipher/SodiumAeadCipherTest.php | 22 ++++---- tests/Crypto/Cipher/SodiumGcmCipherTest.php | 22 ++++---- tests/Crypto/EnvelopeCryptorTest.php | 50 +++++++++---------- .../EnvelopeCryptorWithSingleCipherTest.php | 7 +-- tests/Crypto/Kdf/AbstractKdfCase.php | 4 +- tests/Crypto/Kdf/KdfKeyTest.php | 14 +++--- tests/Crypto/Kdf/KdfPasswordArgon2Test.php | 16 +++--- tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php | 14 +++--- tests/Crypto/Kdf/StringableParam.php | 5 +- tests/Crypto/KdfCryptorTest.php | 32 ++++++------ tests/Crypto/VersionedCryptorTest.php | 3 +- 21 files changed, 166 insertions(+), 161 deletions(-) diff --git a/src/Crypto/Cipher/OpenSSLAeadCipher.php b/src/Crypto/Cipher/OpenSSLAeadCipher.php index a88671d..a953cb1 100644 --- a/src/Crypto/Cipher/OpenSSLAeadCipher.php +++ b/src/Crypto/Cipher/OpenSSLAeadCipher.php @@ -27,16 +27,6 @@ { private const TAG_SIZE = 16; - /** - * @psalm-var int<1, max> - */ - private int $keySize; - - /** - * @psalm-var int<1, max> - */ - private int $nonceSize; - /** * Look-up table of allowed OpenSSL ciphers. * @@ -53,6 +43,16 @@ '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). * diff --git a/src/Crypto/Cipher/OpenSSLWrapCipher.php b/src/Crypto/Cipher/OpenSSLWrapCipher.php index eb35853..a39e1fc 100644 --- a/src/Crypto/Cipher/OpenSSLWrapCipher.php +++ b/src/Crypto/Cipher/OpenSSLWrapCipher.php @@ -34,21 +34,6 @@ */ private const TAG_SIZE = 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; - /** * Look-up table of allowed OpenSSL key wrap ciphers. * @@ -64,6 +49,21 @@ '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). * diff --git a/src/Crypto/Cipher/SodiumAeadCipher.php b/src/Crypto/Cipher/SodiumAeadCipher.php index aa0014b..aa1d6c1 100644 --- a/src/Crypto/Cipher/SodiumAeadCipher.php +++ b/src/Crypto/Cipher/SodiumAeadCipher.php @@ -32,16 +32,6 @@ */ private const TAG_SIZE = 16; - /** - * @psalm-var int<1, max> - */ - private int $keySize; - - /** - * @psalm-var int<1, max> - */ - private int $nonceSize; - /** * Look-up table of allowed Sodium ciphers. * @@ -57,6 +47,16 @@ '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). * diff --git a/src/Crypto/CryptorInterface.php b/src/Crypto/CryptorInterface.php index 2ea779d..ac0a92a 100644 --- a/src/Crypto/CryptorInterface.php +++ b/src/Crypto/CryptorInterface.php @@ -26,7 +26,7 @@ public function encrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string; /** @@ -44,6 +44,6 @@ public function decrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string; } diff --git a/src/Crypto/EncryptionException.php b/src/Crypto/EncryptionException.php index d3cb0a7..29e2e6a 100644 --- a/src/Crypto/EncryptionException.php +++ b/src/Crypto/EncryptionException.php @@ -9,6 +9,4 @@ /** * Exception thrown when encryption or decryption fails. */ -final class EncryptionException extends RuntimeException -{ -} +final class EncryptionException extends RuntimeException {} diff --git a/src/Crypto/EnvelopeCryptor.php b/src/Crypto/EnvelopeCryptor.php index fa3b8bb..b11ccb5 100644 --- a/src/Crypto/EnvelopeCryptor.php +++ b/src/Crypto/EnvelopeCryptor.php @@ -91,7 +91,7 @@ public function encrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string { $kdfSalt = $this->saltSize ? random_bytes($this->saltSize) : ''; $dek = random_bytes($this->dekSize); @@ -113,7 +113,7 @@ public function decrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string { if (StringHelper::byteLength($data) < $this->headerLength) { throw new EncryptionException('Encrypted data is too short.'); diff --git a/src/Crypto/VersionedCryptor.php b/src/Crypto/VersionedCryptor.php index a0807dd..86559e9 100644 --- a/src/Crypto/VersionedCryptor.php +++ b/src/Crypto/VersionedCryptor.php @@ -65,7 +65,7 @@ public function encrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string { $payload = $this->cryptors[$this->currentVersion]->encrypt($data, $secret, $context); @@ -81,7 +81,7 @@ public function decrypt( string $data, #[SensitiveParameter] string $secret, - string $context = '' + string $context = '', ): string { if (StringHelper::byteLength($data) < $this->versionSize) { throw new EncryptionException('Encrypted data is too short to contain a version identifier.'); diff --git a/tests/Crypto/Cipher/AbstractCipherCase.php b/tests/Crypto/Cipher/AbstractCipherCase.php index 9b0575d..0a29c32 100644 --- a/tests/Crypto/Cipher/AbstractCipherCase.php +++ b/tests/Crypto/Cipher/AbstractCipherCase.php @@ -15,10 +15,6 @@ */ abstract class AbstractCipherCase extends TestCase { - abstract protected function createCipherInstance(?string $cipher = null): CipherInterface; - - abstract protected static function getPlainText(): string; - abstract public static function dataProviderCiphers(): iterable; abstract public static function dataProviderEncrypted(): iterable; @@ -189,4 +185,8 @@ public function testGetSizes(): void $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/OpenSSLAeadCipherTest.php b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php index 81aefec..50b4e34 100644 --- a/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php +++ b/tests/Crypto/Cipher/OpenSSLAeadCipherTest.php @@ -7,6 +7,8 @@ use Yiisoft\Security\Crypto\CipherInterface; use Yiisoft\Security\Crypto\Cipher\OpenSSLAeadCipher; +use function extension_loaded; + final class OpenSSLAeadCipherTest extends AbstractCipherCase { use CipherWithAeadTrait; @@ -19,16 +21,6 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface - { - return $cipher ? new OpenSSLAeadCipher($cipher) : new OpenSSLAeadCipher(); - } - - protected static function getPlainText(): string - { - return 'test-plain-data'; - } - public static function dataProviderCiphers(): iterable { yield ['AES-128-GCM']; @@ -82,4 +74,14 @@ public static function dataProviderEncrypted(): iterable '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 index 1c03618..70cade3 100644 --- a/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php +++ b/tests/Crypto/Cipher/OpenSSLWrapCipherTest.php @@ -7,6 +7,8 @@ use Yiisoft\Security\Crypto\CipherInterface; use Yiisoft\Security\Crypto\Cipher\OpenSSLWrapCipher; +use function extension_loaded; + final class OpenSSLWrapCipherTest extends AbstractCipherCase { protected function setUp(): void @@ -16,16 +18,6 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface - { - return $cipher ? new OpenSSLWrapCipher($cipher) : new OpenSSLWrapCipher(); - } - - protected static function getPlainText(): string - { - return 'test-plain-data-'; - } - public static function dataProviderCiphers(): iterable { yield ['AES-128-WRAP']; @@ -77,4 +69,14 @@ public function testNonceIsIgnored(): void $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 index e623559..2ce3680 100644 --- a/tests/Crypto/Cipher/SodiumAeadCipherTest.php +++ b/tests/Crypto/Cipher/SodiumAeadCipherTest.php @@ -7,6 +7,8 @@ use Yiisoft\Security\Crypto\CipherInterface; use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use function extension_loaded; + final class SodiumAeadCipherTest extends AbstractCipherCase { use CipherWithAeadTrait; @@ -19,16 +21,6 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface - { - return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); - } - - protected static function getPlainText(): string - { - return 'test-plain-data'; - } - public static function dataProviderCiphers(): iterable { yield ['CHACHA20-POLY1305-IETF']; @@ -59,4 +51,14 @@ public static function dataProviderEncrypted(): iterable '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 index bd0f934..fb5d9bb 100644 --- a/tests/Crypto/Cipher/SodiumGcmCipherTest.php +++ b/tests/Crypto/Cipher/SodiumGcmCipherTest.php @@ -7,6 +7,8 @@ use Yiisoft\Security\Crypto\CipherInterface; use Yiisoft\Security\Crypto\Cipher\SodiumAeadCipher; +use function extension_loaded; + final class SodiumGcmCipherTest extends AbstractCipherCase { use CipherWithAeadTrait; @@ -21,16 +23,6 @@ protected function setUp(): void } } - protected function createCipherInstance(?string $cipher = null): CipherInterface - { - return $cipher ? new SodiumAeadCipher($cipher) : new SodiumAeadCipher(); - } - - protected static function getPlainText(): string - { - return 'test-plain-data'; - } - public static function dataProviderCiphers(): iterable { yield ['AES-256-GCM']; @@ -53,4 +45,14 @@ public static function dataProviderEncrypted(): iterable '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 index bc8ad05..1320883 100644 --- a/tests/Crypto/EnvelopeCryptorTest.php +++ b/tests/Crypto/EnvelopeCryptorTest.php @@ -229,31 +229,6 @@ public function testDecryptThrowsWhenDataTooShort(): void $cryptor->decrypt('short', 'secret'); } - 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]; - } - /** * [kdfSaltSize, kwKeySize, kwNonceSize, kwOverheadSize, dataKeySize, dataNonceSize, dataOverheadSize] */ @@ -319,4 +294,29 @@ public static function dataProviderConfigs(): iterable '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 index 66f584f..798dac8 100644 --- a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php +++ b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php @@ -62,9 +62,7 @@ public function testSingleCipherTooShortDataThrowsException(): void private function getKdfStub(int $saltSize = 16): KdfInterface { return new class ($saltSize) implements KdfInterface { - public function __construct(private readonly int $saltSize) - { - } + public function __construct(private readonly int $saltSize) {} public function derive(string $secret, int $keySize, string $context, string $salt = ''): string { @@ -89,8 +87,7 @@ private function getCipherStub(int $keySize = 32, int $nonceSize = 12): CipherIn public function __construct( private readonly int $keySize, private readonly int $nonceSize, - ) { - } + ) {} public function encrypt(string $data, #[SensitiveParameter] string $key, string $nonce = '', string $aad = ''): string { diff --git a/tests/Crypto/Kdf/AbstractKdfCase.php b/tests/Crypto/Kdf/AbstractKdfCase.php index e1d75cc..2a3c0ce 100644 --- a/tests/Crypto/Kdf/AbstractKdfCase.php +++ b/tests/Crypto/Kdf/AbstractKdfCase.php @@ -14,8 +14,6 @@ abstract class AbstractKdfCase extends TestCase { - abstract protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface; - abstract public static function dataProviderAlgos(): iterable; abstract public static function dataProviderKeyValues(): iterable; @@ -152,4 +150,6 @@ public function testGetSizes(): void $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 index 24d7bdf..6250f6c 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -12,13 +12,6 @@ final class KdfKeyTest extends AbstractKdfCase { - protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface - { - return $hashAlgo - ? new KdfKey(hashAlgo: $hashAlgo, hashStaticSalt: $hashStaticSalt) - : new KdfKey(hashStaticSalt: $hashStaticSalt); - } - public static function dataProviderAlgos(): iterable { yield ['sha256', 32]; @@ -119,4 +112,11 @@ public static function dataProviderEmptyStaticSaltKeyValues(): iterable 'f2b0f6e277232602dfe7588c37850f646c97b4fd8fb120ecf6b28a1b2548939f06e1941feee58a834ad8644b4f62f140a12d001ed6bb297c7b2c8386e0ef249e', ]; } + + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface + { + return $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 index ba1bc48..e775c63 100644 --- a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -8,6 +8,8 @@ use Yiisoft\Security\Crypto\KdfInterface; use Yiisoft\Security\Crypto\Kdf\KdfPasswordArgon2; +use function extension_loaded; + final class KdfPasswordArgon2Test extends AbstractKdfCase { protected function setUp(): void @@ -17,13 +19,6 @@ protected function setUp(): void } } - protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface - { - return $hashAlgo - ? new KdfPasswordArgon2(hashAlgo: $hashAlgo, hashStaticSalt: $hashStaticSalt) - : new KdfPasswordArgon2(hashStaticSalt: $hashStaticSalt); - } - public static function dataProviderAlgos(): iterable { yield ['sha256', 32]; @@ -58,4 +53,11 @@ public static function dataProviderKeyValues(): iterable 'f3485c8fec6e20d0e81a332d9a6e7293985ad345076a2b167d3b682e612ab549', ]; } + + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface + { + return $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 index 05a9f05..8c29846 100644 --- a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php @@ -11,13 +11,6 @@ final class KdfPasswordPbkdf2Test extends AbstractKdfCase { - protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface - { - return $hashAlgo - ? new KdfPasswordPbkdf2(hashAlgo: $hashAlgo, iterations: 100_000, hashStaticSalt: $hashStaticSalt) - : new KdfPasswordPbkdf2(iterations: 100_000, hashStaticSalt: $hashStaticSalt); - } - public static function dataProviderAlgos(): iterable { yield ['sha256', 32]; @@ -58,4 +51,11 @@ public function testConstructorThrowsExceptionWhenIterationsLessThanOne(): void $this->expectException(RuntimeException::class); new KdfPasswordPbkdf2(iterations: 0); } + + protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface + { + return $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 index ae1d2c2..f0150ca 100644 --- a/tests/Crypto/Kdf/StringableParam.php +++ b/tests/Crypto/Kdf/StringableParam.php @@ -11,9 +11,8 @@ { public function __construct( #[SensitiveParameter] - private string $value - ) { - } + private string $value, + ) {} public function __toString(): string { diff --git a/tests/Crypto/KdfCryptorTest.php b/tests/Crypto/KdfCryptorTest.php index e0651ec..3861212 100644 --- a/tests/Crypto/KdfCryptorTest.php +++ b/tests/Crypto/KdfCryptorTest.php @@ -47,7 +47,7 @@ public function testEncryptProducesExpectedStructure( $this->assertIsString($result); $this->assertSame( $kdfSaltSize + $nonceSize + StringHelper::byteLength('test-ciphertext-and-tag'), - StringHelper::byteLength($result) + StringHelper::byteLength($result), ); $keySalt = StringHelper::byteSubstring($result, 0, $kdfSaltSize); @@ -155,21 +155,6 @@ public function testDecryptThrowsWhenDataTooShort(): void $cryptor->decrypt('short', 'secret'); } - 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]; - } - /** * [kdfSaltSize, kwKeySize, kwNonceSize, kwOverheadSize, dataKeySize, dataNonceSize, dataOverheadSize] */ @@ -199,4 +184,19 @@ public static function dataProviderConfigs(): iterable '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 index 33dd247..6acc3db 100644 --- a/tests/Crypto/VersionedCryptorTest.php +++ b/tests/Crypto/VersionedCryptorTest.php @@ -5,6 +5,7 @@ namespace Yiisoft\Security\Tests\Crypto; use RuntimeException; +use stdClass; use PHPUnit\Framework\TestCase; use Yiisoft\Security\Crypto\CryptorInterface; use Yiisoft\Security\Crypto\EncryptionException; @@ -151,7 +152,7 @@ public function testConstructorValidationThrows(): void public function testConstructorThrowsExceptionWhenCryptorNotInstanceOfInterface(): void { $this->expectException(RuntimeException::class); - new VersionedCryptor(cryptors: ['v1' => new \stdClass()], currentVersion: 'v1'); + new VersionedCryptor(cryptors: ['v1' => new stdClass()], currentVersion: 'v1'); } public function testConstructorThrowsExceptionWhenVersionSizeLessThanOne(): void From 4e09f624f65fa94d92ab576ca24b3395590054f5 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 19:55:56 +0700 Subject: [PATCH 58/70] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1603ba1..419f1ad 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ hash_equals($expected, $actual); 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. +- `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 From 37127e0461fd75bde1d6ce0e1343d82d4f5efb3b Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 20:06:12 +0700 Subject: [PATCH 59/70] update internals doc --- docs/internals.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/internals.md b/docs/internals.md index 0a0a1cd..6f86f74 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -14,7 +14,7 @@ The package tests are checked with [Infection](https://infection.github.io/) mut [Infection Static Analysis Plugin](https://github.com/Roave/infection-static-analysis-plugin). To run it: ```shell -./vendor/bin/roave-infection-static-analysis-plugin +./vendor/bin/infection ``` ## Static analysis @@ -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 From fb09b5427242e3f727508472f49e218d504d8fc5 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 20:18:04 +0700 Subject: [PATCH 60/70] update internal docs --- docs/internals.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internals.md b/docs/internals.md index 6f86f74..3891461 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -14,7 +14,7 @@ The package tests are checked with [Infection](https://infection.github.io/) mut [Infection Static Analysis Plugin](https://github.com/Roave/infection-static-analysis-plugin). To run it: ```shell -./vendor/bin/infection +./vendor/bin/roave-infection-static-analysis-plugin ``` ## Static analysis From ce0b03794f6ee0eca93ab05f1e11384e3a0f39da Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 23:30:15 +0700 Subject: [PATCH 61/70] update workflow --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f333f6f..cfa6703 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 From cb7ccae16941b9773048e957a54dfe7112f19b46 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Mon, 22 Jun 2026 23:30:53 +0700 Subject: [PATCH 62/70] updateworkflow --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cfa6703..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 From 4641627f570d1eb73f036442e55f298fe2c3ba37 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Tue, 23 Jun 2026 21:08:15 +0700 Subject: [PATCH 63/70] update kdf key tests --- tests/Crypto/Kdf/KdfKeyTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index 6250f6c..d4a4d19 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -4,6 +4,7 @@ namespace Yiisoft\Security\Tests\Crypto\Kdf; +use RuntimeException; use Stringable; use PHPUnit\Framework\Attributes\DataProvider; use Yiisoft\Security\Crypto\EncryptionException; @@ -85,6 +86,12 @@ public function testInvalidSecretThrowsException(): void $kdf->derive('', 32, 'test-context', 'test-salt'); } + public function testInvalidStaticSaltThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->createKdfInstance(hashAlgo: 'sha256', hashStaticSalt: random_bytes(31)); + } + #[DataProvider('dataProviderEmptyStaticSaltKeyValues')] public function testEmptyStaticSaltDerivesExpectedKey(string $hashAlgo, string $secret, int $keySize, string $context, string $key): void { From e461e5778947fa8d90938eb75aac4d4bef54e4f7 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Wed, 24 Jun 2026 21:36:47 +0700 Subject: [PATCH 64/70] update tests --- tests/Crypto/Kdf/AbstractKdfCase.php | 14 +++++++++----- tests/Crypto/Kdf/KdfKeyTest.php | 7 ------- tests/Crypto/Kdf/KdfPasswordArgon2Test.php | 7 ------- tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php | 7 ------- 4 files changed, 9 insertions(+), 26 deletions(-) diff --git a/tests/Crypto/Kdf/AbstractKdfCase.php b/tests/Crypto/Kdf/AbstractKdfCase.php index 2a3c0ce..a78c1b7 100644 --- a/tests/Crypto/Kdf/AbstractKdfCase.php +++ b/tests/Crypto/Kdf/AbstractKdfCase.php @@ -14,10 +14,15 @@ abstract class AbstractKdfCase extends TestCase { - abstract public static function dataProviderAlgos(): iterable; - abstract public static function dataProviderKeyValues(): iterable; + public static function dataProviderAlgoKeySize(): iterable + { + yield ['sha256', 32]; + yield ['sha512', 64]; + yield ['sha3-256', 48]; + } + public function testDeriveSuccess(): void { $kdf = $this->createKdfInstance(); @@ -42,14 +47,13 @@ public function testKeyValues(string $hashAlgo, string $secret, int $keySize, st $this->assertSame($key, $kdf->derive($secret, $keySize, $context, $salt)); } - #[DataProvider('dataProviderAlgos')] + #[DataProvider('dataProviderAlgoKeySize')] public function testDeriveWithCustomAlgorithm(string $hashAlgo, int $keySize): void { $kdf = $this->createKdfInstance($hashAlgo); - $secret = random_bytes($keySize); $salt = random_bytes($kdf->getSaltSize()); - $key = $kdf->derive($secret, $keySize, 'test-context', $salt); + $key = $kdf->derive('test-secret', $keySize, 'test-context', $salt); $this->assertSame($keySize, StringHelper::byteLength($key)); } diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index d4a4d19..974e71f 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -13,13 +13,6 @@ final class KdfKeyTest extends AbstractKdfCase { - public static function dataProviderAlgos(): iterable - { - yield ['sha256', 32]; - yield ['sha512', 64]; - yield ['sha3-256', 32]; - } - public static function dataProviderKeyValues(): iterable { yield [ diff --git a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php index e775c63..62c217f 100644 --- a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -19,13 +19,6 @@ protected function setUp(): void } } - public static function dataProviderAlgos(): iterable - { - yield ['sha256', 32]; - yield ['sha512', 64]; - yield ['sha3-256', 32]; - } - public static function dataProviderKeyValues(): iterable { yield [ diff --git a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php index 8c29846..f34600e 100644 --- a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php @@ -11,13 +11,6 @@ final class KdfPasswordPbkdf2Test extends AbstractKdfCase { - public static function dataProviderAlgos(): iterable - { - yield ['sha256', 32]; - yield ['sha512', 64]; - yield ['sha3-256', 32]; - } - public static function dataProviderKeyValues(): iterable { yield [ From f0df068b1c9f533acc1e40fdfdcee989505c3382 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Wed, 24 Jun 2026 21:48:38 +0700 Subject: [PATCH 65/70] update kdfkey test --- tests/Crypto/Kdf/KdfKeyTest.php | 50 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index 974e71f..a828547 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -41,6 +41,24 @@ public static function dataProviderKeyValues(): iterable ]; } + public static function dataProviderEmptyStaticSaltKeyValues(): iterable + { + yield [ + 'sha256', + '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', + 32, + 'test-context', + '50320fc7d6a85c6bb631a10475bd27e0d49892c509041692917c19b0451f98b2', + ]; + yield [ + 'sha512', + '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', + 64, + 'test-context', + 'f2b0f6e277232602dfe7588c37850f646c97b4fd8fb120ecf6b28a1b2548939f06e1941feee58a834ad8644b4f62f140a12d001ed6bb297c7b2c8386e0ef249e', + ]; + } + public function testDifferentParamsAndEmptySaltProducesDifferentKey(): void { $kdf = new KdfKey(saltSize: 0); @@ -71,6 +89,12 @@ public function testDifferentStaticSaltProducesDifferentKey(): void $this->assertNotSame($key1, $key2); } + public function testSaltSizeValid(): void + { + $kdf = new KdfKey(saltSize: 24); + $this->assertSame(24, $kdf->getSaltSize()); + } + public function testInvalidSecretThrowsException(): void { $kdf = $this->createKdfInstance(); @@ -85,6 +109,14 @@ public function testInvalidStaticSaltThrowsException(): void $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 { @@ -95,24 +127,6 @@ public function testEmptyStaticSaltDerivesExpectedKey(string $hashAlgo, string $ $this->assertSame($key, $kdf->derive($secret, $keySize, $context)); } - public static function dataProviderEmptyStaticSaltKeyValues(): iterable - { - yield [ - 'sha256', - '263d2461b6464bbc898ffa385f9d4c1a8f5a1cf0e2d27c4499516142e0542125', - 32, - 'test-context', - '50320fc7d6a85c6bb631a10475bd27e0d49892c509041692917c19b0451f98b2', - ]; - yield [ - 'sha512', - '84c7e9fb214e1d5d3ac6d9ae7b7af33f23355f4795831dcdb5d97093ec42d3d32b4391c7e1b2673ec5577aad934d231d24fd9e5032dd845e86e75a965eba4207', - 64, - 'test-context', - 'f2b0f6e277232602dfe7588c37850f646c97b4fd8fb120ecf6b28a1b2548939f06e1941feee58a834ad8644b4f62f140a12d001ed6bb297c7b2c8386e0ef249e', - ]; - } - protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { return $hashAlgo From 388f0718bdfb2f810d1ea8076ff8eb4a31cafe86 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Thu, 25 Jun 2026 04:03:19 +0700 Subject: [PATCH 66/70] update phpdoc --- src/Crypto/EnvelopeCryptor.php | 4 ++-- src/Crypto/Kdf/KdfKey.php | 10 +++++----- src/Crypto/KdfCryptor.php | 2 +- src/Crypto/KdfInterface.php | 4 ++-- tests/Crypto/Kdf/KdfKeyTest.php | 5 ++++- tests/Crypto/Kdf/KdfPasswordArgon2Test.php | 2 +- tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php | 2 +- 7 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Crypto/EnvelopeCryptor.php b/src/Crypto/EnvelopeCryptor.php index b11ccb5..14dfedf 100644 --- a/src/Crypto/EnvelopeCryptor.php +++ b/src/Crypto/EnvelopeCryptor.php @@ -52,10 +52,10 @@ private int $headerLength; /** - * @param KdfInterface $kdf Key derivation function (used to derive KEK from secret). + * @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 + * the same cipher as `$cipher` is used for both data encryption and DEK wrapping. */ public function __construct( private KdfInterface $kdf, diff --git a/src/Crypto/Kdf/KdfKey.php b/src/Crypto/Kdf/KdfKey.php index e71b08b..9fdfd17 100644 --- a/src/Crypto/Kdf/KdfKey.php +++ b/src/Crypto/Kdf/KdfKey.php @@ -30,13 +30,13 @@ * It serves as a domain separator and provides additional protection against * certain attacks when the input secret is not uniformly random. * - * If provided, it must be exactly the length of the hash output (e.g., 32 bytes for SHA‑256). + * If provided, it must be exactly the length of the hash output. */ private string $hashStaticSalt; /** * @param string $hashAlgo Hash algorithm for key derivation {@see hash_hmac_algos()}. - * @param string|Stringable $hashStaticSalt Optional static salt (must be exactly the length of the hash output). + * @param string|Stringable $hashStaticSalt Optional static salt. * @param int $saltSize Required size of the dynamic salt in bytes. * If set to 0, the salt is disabled. In that case, the `$context` parameter passed to * {@see derive()} MUST be random or unique for each derivation. @@ -73,9 +73,9 @@ public function __construct( * * @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 fixed context (used as prefix of HKDF info). - * @param string $salt Dynamic salt value (must 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 + * @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 diff --git a/src/Crypto/KdfCryptor.php b/src/Crypto/KdfCryptor.php index d538921..b189a13 100644 --- a/src/Crypto/KdfCryptor.php +++ b/src/Crypto/KdfCryptor.php @@ -37,7 +37,7 @@ private int $headerLength; /** - * @param KdfInterface $kdf Key derivation function (used to derive DEK from secret + salt). + * @param KdfInterface $kdf Key derivation function. Used to derive DEK from secret. * @param CipherInterface $cipher Low‑level cipher (must support AEAD). */ public function __construct( diff --git a/src/Crypto/KdfInterface.php b/src/Crypto/KdfInterface.php index cc0c406..047eb76 100644 --- a/src/Crypto/KdfInterface.php +++ b/src/Crypto/KdfInterface.php @@ -17,8 +17,8 @@ interface KdfInterface * * @param string $secret The input secret (password or raw key material). Sensitive parameter. * @param int $keySize Desired key length in bytes. - * @param string $context Application-specific context string (used as HKDF info or similar). - * @param string $salt Salt value (must be random and unique for each derivation, unless salt size is 0). + * @param string $context Application-specific context string. + * @param string @param string $salt Salt value. Must be exactly {@see getSaltSize()} bytes. * * @throws EncryptionException If key derivation fails. * diff --git a/tests/Crypto/Kdf/KdfKeyTest.php b/tests/Crypto/Kdf/KdfKeyTest.php index a828547..ce9d616 100644 --- a/tests/Crypto/Kdf/KdfKeyTest.php +++ b/tests/Crypto/Kdf/KdfKeyTest.php @@ -91,6 +91,9 @@ public function testDifferentStaticSaltProducesDifferentKey(): void public function testSaltSizeValid(): void { + $kdf1 = new KdfKey(saltSize: 0); + $this->assertSame(0, $kdf1->getSaltSize()); + $kdf = new KdfKey(saltSize: 24); $this->assertSame(24, $kdf->getSaltSize()); } @@ -129,7 +132,7 @@ public function testEmptyStaticSaltDerivesExpectedKey(string $hashAlgo, string $ protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { - return $hashAlgo + 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 index 62c217f..f576685 100644 --- a/tests/Crypto/Kdf/KdfPasswordArgon2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordArgon2Test.php @@ -49,7 +49,7 @@ public static function dataProviderKeyValues(): iterable protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { - return $hashAlgo + 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 index f34600e..588547a 100644 --- a/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php +++ b/tests/Crypto/Kdf/KdfPasswordPbkdf2Test.php @@ -47,7 +47,7 @@ public function testConstructorThrowsExceptionWhenIterationsLessThanOne(): void protected function createKdfInstance(?string $hashAlgo = null, string|Stringable $hashStaticSalt = ''): KdfInterface { - return $hashAlgo + return isset($hashAlgo) ? new KdfPasswordPbkdf2(hashAlgo: $hashAlgo, iterations: 100_000, hashStaticSalt: $hashStaticSalt) : new KdfPasswordPbkdf2(iterations: 100_000, hashStaticSalt: $hashStaticSalt); } From ff92620a1fecb6f25dc999451a825442d5f197a3 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Fri, 26 Jun 2026 21:07:23 +0700 Subject: [PATCH 67/70] fix readme --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 419f1ad..0298735 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,6 @@ 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 @@ -523,7 +522,7 @@ $context = 'user_data_' . $userId; $secret = getenv('MASTER_ENCRYPTION_KEY'); -$encrypted = $cryptor->encrypt('sensitive user information', $secret, $context); +$encrypted = $cryptor->encrypt('sensitive user data', $secret, $context); $decrypted = $cryptor->decrypt($encrypted, $secret, $context); ``` @@ -548,7 +547,7 @@ $cryptor = new KdfCryptor($kdf, $cipher); // or EnvelopeCryptor $context = 'app_config_v1'; $secret = getenv('MASTER_ENCRYPTION_KEY'); -$encrypted = $cryptor->encrypt('sensitive application configuration', $secret, $context); +$encrypted = $cryptor->encrypt('sensitive configuration', $secret, $context); $decrypted = $cryptor->decrypt($encrypted, $secret, $context); ``` From bd24826b5e43b4771fc440433a033e5a8a401835 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 27 Jun 2026 14:35:08 +0700 Subject: [PATCH 68/70] update kdf interface --- src/Crypto/Kdf/KdfKey.php | 2 +- src/Crypto/Kdf/KdfPasswordArgon2.php | 2 +- src/Crypto/Kdf/KdfPasswordPbkdf2.php | 2 +- src/Crypto/KdfInterface.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Crypto/Kdf/KdfKey.php b/src/Crypto/Kdf/KdfKey.php index 9fdfd17..8ccb1e9 100644 --- a/src/Crypto/Kdf/KdfKey.php +++ b/src/Crypto/Kdf/KdfKey.php @@ -88,7 +88,7 @@ public function derive( #[SensitiveParameter] string $secret, int $keySize, - string $context, + string $context = '', string $salt = '', ): string { /** @psalm-suppress ImpureMethodCall */ diff --git a/src/Crypto/Kdf/KdfPasswordArgon2.php b/src/Crypto/Kdf/KdfPasswordArgon2.php index b213ecd..4feb63a 100644 --- a/src/Crypto/Kdf/KdfPasswordArgon2.php +++ b/src/Crypto/Kdf/KdfPasswordArgon2.php @@ -79,7 +79,7 @@ public function derive( #[SensitiveParameter] string $secret, int $keySize, - string $context, + string $context = '', string $salt = '', ): string { try { diff --git a/src/Crypto/Kdf/KdfPasswordPbkdf2.php b/src/Crypto/Kdf/KdfPasswordPbkdf2.php index 956523a..1e8d696 100644 --- a/src/Crypto/Kdf/KdfPasswordPbkdf2.php +++ b/src/Crypto/Kdf/KdfPasswordPbkdf2.php @@ -73,7 +73,7 @@ public function derive( #[SensitiveParameter] string $secret, int $keySize, - string $context, + string $context = '', string $salt = '', ): string { /** @psalm-suppress ImpureMethodCall */ diff --git a/src/Crypto/KdfInterface.php b/src/Crypto/KdfInterface.php index 047eb76..4b26bd2 100644 --- a/src/Crypto/KdfInterface.php +++ b/src/Crypto/KdfInterface.php @@ -28,7 +28,7 @@ public function derive( #[SensitiveParameter] string $secret, int $keySize, - string $context, + string $context = '', string $salt = '', ): string; From 8fbaccbabe843473199997f9c83f8ef8f78fa85c Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Sat, 27 Jun 2026 14:41:19 +0700 Subject: [PATCH 69/70] fix test --- tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php index 798dac8..8548840 100644 --- a/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php +++ b/tests/Crypto/EnvelopeCryptorWithSingleCipherTest.php @@ -64,7 +64,7 @@ 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 + public function derive(string $secret, int $keySize, string $context = '', string $salt = ''): string { $hash = hash('sha256', $secret . $context . $salt, true); From 8e7db7d0b8f2e6e516d627c45876277093b7e7c9 Mon Sep 17 00:00:00 2001 From: Oleg Baturin Date: Wed, 1 Jul 2026 01:16:04 +0700 Subject: [PATCH 70/70] update readme --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0298735..5afd313 100644 --- a/README.md +++ b/README.md @@ -264,10 +264,10 @@ use Yiisoft\Security\Crypto\VersionedCryptor; // Assume $kdfCryptor and $envelopeCryptor are already instantiated $cryptor = new VersionedCryptor( cryptors: [ - chr(0x01) => $kdfCryptor, - chr(0x96) => $envelopeCryptor, + "\x01" => $kdfCryptor, + "\x96" => $envelopeCryptor, ], - currentVersion: chr(0x01), + currentVersion: "\x01", ); ``` @@ -283,10 +283,10 @@ use Yiisoft\Security\Crypto\EnvelopeCryptor; VersionedCryptor::class => [ '__construct()' => [ 'cryptors' => ReferencesArray::from([ - chr(0x01) => Reference::to(KdfCryptor::class), - chr(0x96) => Reference::to(EnvelopeCryptor::class}, + "\x01" => Reference::to(KdfCryptor::class), + "\x96" => Reference::to(EnvelopeCryptor::class}, ]), - 'currentVersion' => chr(0x01), + 'currentVersion' => "\x01", // 'versionSize' => 1, // optional, auto-detected from currentVersion ], ],