Repository navigation
Add modern cryptors - #71
olegbaturin wants to merge 74 commits into
Conversation
olegbaturin
commented
May 16, 2026
| Q | A |
|---|---|
| Is bugfix? | ❌ |
| New feature? | ✔️ |
| Breaks BC? | ❌ |
add tests
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #71 +/- ##
=============================================
- Coverage 100.00% 96.00% -4.00%
- Complexity 37 133 +96
=============================================
Files 7 16 +9
Lines 103 325 +222
=============================================
+ Hits 103 312 +209
- Misses 0 13 +13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
# Conflicts: # .github/workflows/build.yml
samdark
left a comment
There was a problem hiding this comment.
The truncated tag issue from my previous review is still there. The reply in that thread isn't correct: openssl_decrypt() sets the tag at whatever length it's given and compares only those bytes. With the default CHACHA20-POLY1305 a zero-length tag is always accepted.
I've checked it at the current head: KdfCryptor with default KdfKey and OpenSSLAeadCipher decrypts any 44 random bytes to an empty string without knowing the secret, and the same goes through VersionedCryptor. EnvelopeCryptor does the same for any real ciphertext cut down to its header. Details are inline.
| if (StringHelper::byteLength($nonce) !== $this->nonceSize) { | ||
| throw new EncryptionException("Nonce must be {$this->nonceSize} bytes long."); | ||
| } | ||
|
|
There was a problem hiding this comment.
There's still no length check here. If $data is shorter than 16 bytes, the whole input becomes the tag and OpenSSL checks only that many bytes. An empty input with CHACHA20-POLY1305 always decrypts to '', and a 1-byte tag passes about once in 256 tries for any of the allowed ciphers. Please reject short input before slicing:
| if (StringHelper::byteLength($data) < self::TAG_SIZE) { | |
| throw new EncryptionException('Encrypted data is too short.'); | |
| } | |
| string $secret, | ||
| string $context = '', | ||
| ): string { | ||
| if (StringHelper::byteLength($data) < $this->headerLength) { |
There was a problem hiding this comment.
The minimum length doesn't include the cipher tag, so a header-only payload (salt + nonce, 44 bytes by default) gets to the cipher with empty data. Combined with the cipher issue, that's an empty-string forgery without the secret. The cryptor shouldn't depend on every CipherInterface implementation to catch it:
| if (StringHelper::byteLength($data) < $this->headerLength) { | |
| if (StringHelper::byteLength($data) < $this->headerLength + $this->cipher->getOverheadSize()) { |
| string $secret, | ||
| string $context = '', | ||
| ): string { | ||
| if (StringHelper::byteLength($data) < $this->headerLength) { |
There was a problem hiding this comment.
Same here: the data tag isn't part of headerLength. Any real ciphertext cut down to its header passes the wrapped DEK check and then decrypts to ''.
| if (StringHelper::byteLength($data) < $this->headerLength) { | |
| if (StringHelper::byteLength($data) < $this->headerLength + $this->cipher->getOverheadSize()) { |
| $tagSize = $cipherInstance->getOverheadSize(); | ||
|
|
||
| $this->expectException(EncryptionException::class); | ||
| $cipherInstance->decrypt(substr($ciphertext, 0, -$tagSize), $key, $nonce); // remove auth tag |
There was a problem hiding this comment.
This doesn't cover the bug: the plaintext is 15 bytes, so after removing the tag there are still 15 bytes left, and they're used as the tag. Please add cases for decrypt(''), every input length from 1 to 15, and an empty plaintext with its tag truncated. Also test KdfCryptor/EnvelopeCryptor with real ciphers rather than mocks, using input truncated to the header length. These should fail on the current code.
| } | ||
|
|
||
| [$this->keySize, $nonceSize] = self::ALLOWED_CIPHERS[$this->cipher]; | ||
| $this->dummyNonce = str_repeat("\0", $nonceSize); |
There was a problem hiding this comment.
For AES-*-WRAP, OpenSSL doesn't ignore the IV. It uses it as the integrity check value. With zeros, the output doesn't match RFC 3394: the §4.1 vector gives faacb56e… instead of 1fa68b0a…. It's still authenticated, but no other AES-KW implementation can unwrap it, and once released the format can't be changed. Use the RFC default IV, update the docblock above, and replace the current expected values in OpenSSLWrapCipherTest with the RFC 3394 test vectors.
| $this->dummyNonce = str_repeat("\0", $nonceSize); | |
| $this->dummyNonce = str_repeat("\xA6", $nonceSize); |
| if (StringHelper::byteLength($key) !== $this->keySize) { | ||
| throw new EncryptionException("Key must be {$this->keySize} bytes long."); | ||
| } | ||
|
|
There was a problem hiding this comment.
decrypt('') returns '' instead of throwing. Wrapped data is at least 16 bytes and a multiple of 8, so please check that:
| $length = StringHelper::byteLength($data); | |
| if ($length < 16 || $length % 8 !== 0) { | |
| throw new EncryptionException('Wrapped data must be at least 16 bytes long and a multiple of 8 bytes.'); | |
| } | |
| use const OPENSSL_RAW_DATA; | ||
|
|
||
| /** | ||
| * Key wrapping cipher using OpenSSL (RFC 5649 / AES-KW). |
There was a problem hiding this comment.
AES-*-WRAP is RFC 3394. RFC 5649 is the padded variant (-WRAP-PAD).
| * Key wrapping cipher using OpenSSL (RFC 5649 / AES-KW). | |
| * Key wrapping cipher using OpenSSL (RFC 3394 / AES-KW). |
| '__construct()' => [ | ||
| 'cryptors' => ReferencesArray::from([ | ||
| "\x01" => Reference::to(KdfCryptor::class), | ||
| "\x96" => Reference::to(EnvelopeCryptor::class}, |
There was a problem hiding this comment.
Typo, } instead of ).
| "\x96" => Reference::to(EnvelopeCryptor::class}, | |
| "\x96" => Reference::to(EnvelopeCryptor::class), |