I found an edge case in the provider CCM implementations where an empty ciphertext can reach Final without its tag being checked. It happens when the caller declares a zero message length, supplies AAD, and doesn't make a separate payload Update call:
EVP_DecryptUpdate(ctx, NULL, &outl, NULL, 0); /* declare length 0 */
EVP_DecryptUpdate(ctx, NULL, &outl, aad, aad_len); /* optional AAD */
EVP_DecryptFinal_ex(ctx, out, &outl); /* accepts a bad tag */
I see the same result with EVP_CipherFinal_ex, EVP_Cipher(ctx, out, NULL, 0), and EVP_Cipher(ctx, NULL, NULL, 0). A NULL output buffer doesn't change the result. AES-CCM, ARIA-CCM, and SM4-CCM are all affected.
There is a return value detail worth pointing out for the EVP_Cipher case.
With a provider cipher, an empty successful operation returns its output length, which is zero, while failure is reported as -1. So, a return value of zero here means that the invalid tag was accepted.
Empty encryption shows the other side of the problem: Final reports success, but GET_TAG can't retrieve a tag when the payload Update was skipped.
I reproduced this on OpenSSL 3.5.7 and on 4.1.0-dev at commit 7fba23f. Looking through the released source, the affected provider CCM finalization behavior is present from OpenSSL 3.0 onward.
The attached ccm_empty_aad_repro.c uses an AES-CCM NIST CAVS vector marked invalid. It has empty plaintext and ciphertext, non-empty AAD, and an invalid tag.
On an affected build I get:
stream final with invalid tag: ret=1 (ACCEPTED - BUG)
EVP_Cipher final with invalid tag: ret=0 (ACCEPTED - BUG)
zero length payload update control: ret=0 (rejected, tag was checked)
RESULT: invalid CCM tag accepted for empty ciphertext
For an empty ciphertext, I would expect Final to reject an invalid tag and to reject a missing tag with PROV_R_TAG_NOT_SET. A correct tag should succeed with outl == 0. In the encryption direction, Final should make the tag available through GET_TAG. Final should also fail if a nonzero payload length was declared but no payload was supplied.
I spent some time checking whether an OpenSSL protocol path reaches this case and didn't find one.
Generic AEAD wrappers can still run into it if they skip the payload call for an empty buffer, or normalize an empty input buffer to NULL and rely on Final for authentication. Code that makes the non-NULL zero-length payload Update and checks its return value is not affected.
I consider this an API correctness and fail-closed issue. It is closely related to the empty AEAD finalization work in #31555 / #32173 and the missing-tag Final behavior in #28730 / #28872.
I have a fix and I will open a PR shortly.
ccm_empty_aad_repro.zip
I found an edge case in the provider CCM implementations where an empty ciphertext can reach Final without its tag being checked. It happens when the caller declares a zero message length, supplies AAD, and doesn't make a separate payload Update call:
I see the same result with
EVP_CipherFinal_ex,EVP_Cipher(ctx, out, NULL, 0), andEVP_Cipher(ctx, NULL, NULL, 0). A NULL output buffer doesn't change the result. AES-CCM, ARIA-CCM, and SM4-CCM are all affected.There is a return value detail worth pointing out for the
EVP_Ciphercase.With a provider cipher, an empty successful operation returns its output length, which is zero, while failure is reported as
-1. So, a return value of zero here means that the invalid tag was accepted.Empty encryption shows the other side of the problem: Final reports success, but
GET_TAGcan't retrieve a tag when the payload Update was skipped.I reproduced this on OpenSSL 3.5.7 and on 4.1.0-dev at commit 7fba23f. Looking through the released source, the affected provider CCM finalization behavior is present from OpenSSL 3.0 onward.
The attached
ccm_empty_aad_repro.cuses an AES-CCM NIST CAVS vector marked invalid. It has empty plaintext and ciphertext, non-empty AAD, and an invalid tag.On an affected build I get:
For an empty ciphertext, I would expect Final to reject an invalid tag and to reject a missing tag with
PROV_R_TAG_NOT_SET. A correct tag should succeed withoutl == 0. In the encryption direction, Final should make the tag available throughGET_TAG. Final should also fail if a nonzero payload length was declared but no payload was supplied.I spent some time checking whether an OpenSSL protocol path reaches this case and didn't find one.
Generic AEAD wrappers can still run into it if they skip the payload call for an empty buffer, or normalize an empty input buffer to NULL and rely on Final for authentication. Code that makes the non-NULL zero-length payload Update and checks its return value is not affected.
I consider this an API correctness and fail-closed issue. It is closely related to the empty AEAD finalization work in #31555 / #32173 and the missing-tag Final behavior in #28730 / #28872.
I have a fix and I will open a PR shortly.
ccm_empty_aad_repro.zip