From 9dffa3ce63231dc69371654b25d18a19a0abe685 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Tue, 14 Jul 2026 10:39:24 +0200 Subject: [PATCH 1/5] AES-GCM: constant-time output clear on decrypt auth failure AES_GCM_decrypt_C cleared the output on a tag mismatch with 'if (ret != 0) ForceZero(out, sz)'. That is a conditional branch on the secret-dependent authentication result, which is not constant time and is flagged by the ct-valgrind constant-time test (Conditional jump depends on uninitialised value in AES_GCM_decrypt_C). Mask the output with 'res' (already computed as all-ones on tag mismatch, zero on match) instead of branching, matching the constant-time idiom used for the tag comparison itself. C path only; the AES-NI/ASM paths are unaffected. --- wolfcrypt/src/aes.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wolfcrypt/src/aes.c b/wolfcrypt/src/aes.c index c8a0e5f8fb4..dc90e8c2a96 100644 --- a/wolfcrypt/src/aes.c +++ b/wolfcrypt/src/aes.c @@ -11319,6 +11319,8 @@ int WARN_UNUSED_RESULT AES_GCM_decrypt_C( ALIGN16 byte Tprime[WC_AES_BLOCK_SIZE]; ALIGN16 byte EKY0[WC_AES_BLOCK_SIZE]; volatile sword32 res; + byte mask; + word32 i; if (ivSz == GCM_NONCE_MID_SZ) { /* Counter is IV with bottom 4 bytes set to: 0x00,0x00,0x00,0x01. */ @@ -11439,8 +11441,11 @@ int WARN_UNUSED_RESULT AES_GCM_decrypt_C( ret = (ret & ~res); ret |= (res & WC_NO_ERR_TRACE(AES_GCM_AUTH_E)); #endif - if (ret != 0) { - ForceZero(out, sz); + /* Mask the output on auth failure instead of branching, to keep the tag + * compare constant time. res is all-ones on mismatch, zero on match. */ + mask = (byte)res; + for (i = 0; i < sz; i++) { + out[i] &= (byte)~mask; } return ret; } From 23ad1c1d7ad05b0e2a806a51ca629eeddec4c848 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Tue, 14 Jul 2026 12:54:57 +0200 Subject: [PATCH 2/5] AES-GCM: skip output clear for AUTH_EARLY; test auth-fail output zeroing Review follow-ups for the constant-time AES-GCM decrypt output clear: - Guard the output-masking pass with #ifndef WC_AES_GCM_DEC_AUTH_EARLY. In that configuration the tag is verified before decryption and a mismatch returns before any output is written, so the masking pass is a guaranteed no-op; skipping it avoids a wasted O(sz) pass. - Add a test in aesgcm_test: decrypt with a corrupted tag into a pre-filled buffer and assert wc_AesGcmDecrypt returns AES_GCM_AUTH_E and, on the software C path, that the output buffer is cleared to zero. The AES-NI/asm decrypt paths and the FIPS module do not clear the output on auth failure, so the zero check forces the C path (use_aesni = 0) and is limited to it (and skipped under HAVE_FIPS). The AES_GCM_AUTH_E comparison uses WC_NO_ERR_TRACE(). Verified (gcc 15.2): make check passes on the default (C path) build; testwolfcrypt AES-GCM passes with --enable-aesni and with -DWC_AES_GCM_DEC_AUTH_EARLY; ct-valgrind aes_gcm reports 0 errors. --- wolfcrypt/src/aes.c | 9 +++++++-- wolfcrypt/test/test.c | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/wolfcrypt/src/aes.c b/wolfcrypt/src/aes.c index dc90e8c2a96..d085b9cc2b4 100644 --- a/wolfcrypt/src/aes.c +++ b/wolfcrypt/src/aes.c @@ -11319,8 +11319,10 @@ int WARN_UNUSED_RESULT AES_GCM_decrypt_C( ALIGN16 byte Tprime[WC_AES_BLOCK_SIZE]; ALIGN16 byte EKY0[WC_AES_BLOCK_SIZE]; volatile sword32 res; +#ifndef WC_AES_GCM_DEC_AUTH_EARLY byte mask; word32 i; +#endif if (ivSz == GCM_NONCE_MID_SZ) { /* Counter is IV with bottom 4 bytes set to: 0x00,0x00,0x00,0x01. */ @@ -11440,13 +11442,16 @@ int WARN_UNUSED_RESULT AES_GCM_decrypt_C( */ ret = (ret & ~res); ret |= (res & WC_NO_ERR_TRACE(AES_GCM_AUTH_E)); -#endif /* Mask the output on auth failure instead of branching, to keep the tag - * compare constant time. res is all-ones on mismatch, zero on match. */ + * compare constant time. res is all-ones on mismatch, zero on match. A + * single vectorizable pass is cheaper than folding the mask into the + * decrypt loop. Not needed for WC_AES_GCM_DEC_AUTH_EARLY: there the tag is + * checked before decryption, so out is never written on a mismatch. */ mask = (byte)res; for (i = 0; i < sz; i++) { out[i] &= (byte)~mask; } +#endif return ret; } #elif (defined(__aarch64__) || defined(WOLFSSL_ARMASM_NO_HW_CRYPTO)) || \ diff --git a/wolfcrypt/test/test.c b/wolfcrypt/test/test.c index 914cdb2f0c0..4faf69389d5 100644 --- a/wolfcrypt/test/test.c +++ b/wolfcrypt/test/test.c @@ -19170,6 +19170,50 @@ static wc_test_ret_t aesgcm_setiv_test(Aes* enc, Aes* dec) ERROR_OUT(WC_TEST_RET_ENC_EC(ret), out); if (XMEMCMP(p, resultP, sizeof(p))) ERROR_OUT(WC_TEST_RET_ENC_NC, out); + + /* A tampered tag must fail decryption. On the software C path the output + * buffer is additionally cleared so unauthenticated plaintext is never + * released. The AES-NI/asm paths do not clear it, so the zero-check is + * limited to the C path, which is forced here by disabling AES-NI for + * this one call. */ + { + #ifdef WOLFSSL_AESNI + int saved_use_aesni = dec->use_aesni; + dec->use_aesni = 0; + #endif + resultT[0] ^= 0xB5; + XMEMSET(resultP, 0x5A, sizeof(p)); + ret = wc_AesGcmDecrypt(dec, resultP, resultC, sizeof(p), + randIV, sizeof(randIV), resultT, 16, a, sizeof(a)); + #if defined(WOLFSSL_ASYNC_CRYPT) + ret = wc_AsyncWait(ret, &dec->asyncDev, WC_ASYNC_FLAG_NONE); + #endif + resultT[0] ^= 0xB5; + #ifdef WOLFSSL_AESNI + dec->use_aesni = saved_use_aesni; + #endif + if (ret != WC_NO_ERR_TRACE(AES_GCM_AUTH_E)) + ERROR_OUT(WC_TEST_RET_ENC_NC, out); + #if !defined(WOLFSSL_ARMASM) && !defined(WOLFSSL_ARMASM_NO_HW_CRYPTO) && \ + !defined(__aarch64__) && !defined(WOLFSSL_PPC64_ASM) && \ + !defined(WOLFSSL_PPC32_ASM) && !defined(STM32_CRYPTO_AES_GCM) && \ + !defined(WOLFSSL_AFALG_XILINX_AES) && !defined(WOLFSSL_XILINX_CRYPT) && \ + !defined(WOLFSSL_KCAPI_AES) && !defined(WOLFSSL_DEVCRYPTO_AES) && \ + !defined(WOLFSSL_ESP32_CRYPT) && !defined(WOLFSSL_IMXRT_DCP) && \ + !defined(WOLFSSL_SILABS_SE_ACCEL) && !defined(HAVE_FIPS) + { + /* C path clears the output buffer on authentication failure. + * The FIPS module's AES-GCM decrypt does not clear it, so skip + * this check under HAVE_FIPS. */ + word32 z; + for (z = 0; z < (word32)sizeof(p); z++) { + if (resultP[z] != 0) + ERROR_OUT(WC_TEST_RET_ENC_NC, out); + } + } + #endif + ret = 0; + } #endif /* HAVE_AES_DECRYPT */ out: From e7938b3a07cfa5dd85496ca7a1e10e268f37b45e Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Tue, 14 Jul 2026 17:12:42 +0200 Subject: [PATCH 3/5] Remove unneeded .wolfssl_known_macro_extras entries check-source-text reports these as unneeded because the macros are now defined in the checked build config, so their known-extra whitelist entries are redundant: WOLFSSL_ASN_TEMPLATE_NEED_SET_INT32 WOLFSSL_ASYNC_CERT_YIELD WOLFSSL_MLKEM_DYNAMIC_KEYS --- .wolfssl_known_macro_extras | 3 --- 1 file changed, 3 deletions(-) diff --git a/.wolfssl_known_macro_extras b/.wolfssl_known_macro_extras index eabd1f217e9..b28c33a3c27 100644 --- a/.wolfssl_known_macro_extras +++ b/.wolfssl_known_macro_extras @@ -785,9 +785,7 @@ WOLFSSL_ARMASM_NEON_NO_TABLE_LOOKUP WOLFSSL_ARM_ARCH_NEON_64BIT WOLFSSL_ASCON_UNROLL WOLFSSL_ASN_EXTRA -WOLFSSL_ASN_TEMPLATE_NEED_SET_INT32 WOLFSSL_ASN_TEMPLATE_TYPE_CHECK -WOLFSSL_ASYNC_CERT_YIELD WOLFSSL_ATECC508 WOLFSSL_ATECC508A_NOSOFTECC WOLFSSL_ATECC508A_TLS @@ -881,7 +879,6 @@ WOLFSSL_MANUALLY_SELECT_DEVICE_CONFIG WOLFSSL_MDK5 WOLFSSL_MEM_FAIL_COUNT WOLFSSL_MICROCHIP_AESGCM -WOLFSSL_MLKEM_DYNAMIC_KEYS WOLFSSL_MLKEM_INVNTT_UNROLL WOLFSSL_MLKEM_NO_MALLOC WOLFSSL_MLKEM_NTT_UNROLL From 601ad77e15bc0fb6aad1b07d8178d3beb8df1c30 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Tue, 14 Jul 2026 19:53:01 +0200 Subject: [PATCH 4/5] test: fix AES-GCM auth-fail zero-check guard (review + run on x86) Skoll review of the auth-fail zero-check test in aesgcm_test: - The guard listed WOLFSSL_ARMASM_NO_HW_CRYPTO and __aarch64__, which are defined on default x86-64 builds, so the zero-check block was compiled out and the assertion never actually ran there. They are subsumed by WOLFSSL_ARMASM (the condition under which AES_GCM_decrypt_C is not the decrypt path), so use that instead and the check runs on the C path. - Exclude WC_AES_GCM_DEC_AUTH_EARLY (out is not written on an early-auth failure) and WOLFSSL_ASYNC_CRYPT (a real async device may offload the decrypt and not clear the output). Verified: default make check passes with the zero-check now executing; testwolfcrypt AES-GCM passes with --enable-aesni and with -DWC_AES_GCM_DEC_AUTH_EARLY. --- wolfcrypt/test/test.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wolfcrypt/test/test.c b/wolfcrypt/test/test.c index 4faf69389d5..b9881580d91 100644 --- a/wolfcrypt/test/test.c +++ b/wolfcrypt/test/test.c @@ -19194,13 +19194,13 @@ static wc_test_ret_t aesgcm_setiv_test(Aes* enc, Aes* dec) #endif if (ret != WC_NO_ERR_TRACE(AES_GCM_AUTH_E)) ERROR_OUT(WC_TEST_RET_ENC_NC, out); - #if !defined(WOLFSSL_ARMASM) && !defined(WOLFSSL_ARMASM_NO_HW_CRYPTO) && \ - !defined(__aarch64__) && !defined(WOLFSSL_PPC64_ASM) && \ + #if !defined(WOLFSSL_ARMASM) && !defined(WOLFSSL_PPC64_ASM) && \ !defined(WOLFSSL_PPC32_ASM) && !defined(STM32_CRYPTO_AES_GCM) && \ !defined(WOLFSSL_AFALG_XILINX_AES) && !defined(WOLFSSL_XILINX_CRYPT) && \ !defined(WOLFSSL_KCAPI_AES) && !defined(WOLFSSL_DEVCRYPTO_AES) && \ !defined(WOLFSSL_ESP32_CRYPT) && !defined(WOLFSSL_IMXRT_DCP) && \ - !defined(WOLFSSL_SILABS_SE_ACCEL) && !defined(HAVE_FIPS) + !defined(WOLFSSL_SILABS_SE_ACCEL) && !defined(HAVE_FIPS) && \ + !defined(WC_AES_GCM_DEC_AUTH_EARLY) && !defined(WOLFSSL_ASYNC_CRYPT) { /* C path clears the output buffer on authentication failure. * The FIPS module's AES-GCM decrypt does not clear it, so skip From 3ad29463ff76c54bb76e637cde34dbcd245e0c15 Mon Sep 17 00:00:00 2001 From: Daniele Lacamera Date: Tue, 14 Jul 2026 20:17:48 +0200 Subject: [PATCH 5/5] test: exclude WOLFSSL_RISCV_ASM from AES-GCM auth-fail zero-check The RISC-V ASM build provides its own AES-GCM implementation (wolfcrypt/src/port/riscv/riscv-64-aes.c) rather than AES_GCM_decrypt_C, so it does not clear the output buffer on authentication failure. Exclude it from the zero-check, matching the other non-C decrypt paths. Fixes the riscv64 multi-arch testwolfcrypt failure. --- wolfcrypt/test/test.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wolfcrypt/test/test.c b/wolfcrypt/test/test.c index b9881580d91..4baaaf2e267 100644 --- a/wolfcrypt/test/test.c +++ b/wolfcrypt/test/test.c @@ -19194,7 +19194,8 @@ static wc_test_ret_t aesgcm_setiv_test(Aes* enc, Aes* dec) #endif if (ret != WC_NO_ERR_TRACE(AES_GCM_AUTH_E)) ERROR_OUT(WC_TEST_RET_ENC_NC, out); - #if !defined(WOLFSSL_ARMASM) && !defined(WOLFSSL_PPC64_ASM) && \ + #if !defined(WOLFSSL_ARMASM) && !defined(WOLFSSL_RISCV_ASM) && \ + !defined(WOLFSSL_PPC64_ASM) && \ !defined(WOLFSSL_PPC32_ASM) && !defined(STM32_CRYPTO_AES_GCM) && \ !defined(WOLFSSL_AFALG_XILINX_AES) && !defined(WOLFSSL_XILINX_CRYPT) && \ !defined(WOLFSSL_KCAPI_AES) && !defined(WOLFSSL_DEVCRYPTO_AES) && \