From 10953f021b3bab8a92d10def897bb489cacad625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 30 Mar 2026 11:47:13 +0200 Subject: [PATCH 1/7] CMAC: fix wraparound in streaming update. The guard `if (cmac->totalSz != 0)` was used to skip XOR-chaining on the first block (where digest is all-zeros and the XOR is a no-op). However, totalSz is word32 and wraps to zero after 2^28 block flushes (4 GiB), causing the guard to erroneously fire again and discard the live CBC-MAC chain state. Any two messages sharing a common suffix beyond the 4 GiB mark then produce identical CMAC tags, enabling a zero-work prefix-substitution forgery. The fix removes the guard, making the XOR unconditional; the no-op property on the first block is preserved because digest is zero-initialized by wc_InitCmac_ex. Identified by: Nicholas Carlini (Anthropic) & Thai Duong (Calif.io) --- wolfcrypt/src/aes.c | 4 +--- wolfcrypt/src/cmac.c | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/wolfcrypt/src/aes.c b/wolfcrypt/src/aes.c index cb4258e6349..a3b75dbb34b 100644 --- a/wolfcrypt/src/aes.c +++ b/wolfcrypt/src/aes.c @@ -16377,9 +16377,7 @@ int wc_local_CmacUpdateAes(struct Cmac *cmac, const byte* in, word32 inSz) { in += add; if (cmac->bufferSz == WC_AES_BLOCK_SIZE && inSz != 0) { - if (cmac->totalSz != 0) { - xorbuf(cmac->buffer, cmac->digest, WC_AES_BLOCK_SIZE); - } + xorbuf(cmac->buffer, cmac->digest, WC_AES_BLOCK_SIZE); ret = AesEncrypt_preFetchOpt(aes, cmac->buffer, cmac->digest, &did_prefetches); if (ret == 0) { diff --git a/wolfcrypt/src/cmac.c b/wolfcrypt/src/cmac.c index 66e45f92477..ba579516d1b 100644 --- a/wolfcrypt/src/cmac.c +++ b/wolfcrypt/src/cmac.c @@ -238,9 +238,7 @@ int wc_CmacUpdate(Cmac* cmac, const byte* in, word32 inSz) inSz -= add; if (cmac->bufferSz == WC_AES_BLOCK_SIZE && inSz != 0) { - if (cmac->totalSz != 0) { - xorbuf(cmac->buffer, cmac->digest, WC_AES_BLOCK_SIZE); - } + xorbuf(cmac->buffer, cmac->digest, WC_AES_BLOCK_SIZE); wc_AesEncryptDirect(&cmac->aes, cmac->digest, cmac->buffer); cmac->totalSz += WC_AES_BLOCK_SIZE; From 13a016367ff4b4d3cc4c9bc2bfdfe692a512dd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 30 Mar 2026 11:57:53 +0200 Subject: [PATCH 2/7] eccsi: fix universal signature forgery via r=0/s=0 wc_VerifyEccsiHash did not validate that r and s lie in [1, q-1] after decoding them from the signature buffer. With s=0 the scalar multiplication [s](...) returns the point at infinity (J_x=0); with r=0 the final mp_cmp(0,0)==MP_EQ check then accepts the forged signature unconditionally against any message and any identity. Add [1, q-1] range checks for r (in wc_VerifyEccsiHash, after params are loaded) and for s (in eccsi_calc_j, after eccsi_decode_sig_s), mirroring the checks already present in wc_ecc_check_r_s_range. Add a defense-in-depth point-at-infinity guard on J before the final comparison. Reported-by: Nicholas Carlini (Anthropic) & Bronson Yen (Calif.io) --- wolfcrypt/src/eccsi.c | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/wolfcrypt/src/eccsi.c b/wolfcrypt/src/eccsi.c index b4cf859e500..d919dd8a341 100644 --- a/wolfcrypt/src/eccsi.c +++ b/wolfcrypt/src/eccsi.c @@ -2159,6 +2159,18 @@ static int eccsi_calc_j(EccsiKey* key, const mp_int* hem, const byte* sig, if (err == 0) { err = eccsi_decode_sig_s(key, sig, sigSz, s); } + /* Validate s is in [1, q-1]: reject zero or out-of-range second signature + * component. With s=0, [s](...) yields the point at infinity whose + * affine x-coordinate is 0, making the final mp_cmp(0,0) accept any + * forged signature. */ + if (err == 0) { + if (mp_iszero(s)) { + err = MP_ZERO_E; + } + else if (mp_cmp(s, &key->params.order) != MP_LT) { + err = ECC_OUT_OF_RANGE_E; + } + } /* [s]( [HE]G + [r]Y ) */ if (err == 0) { err = eccsi_mulmod_point(key, s, j, j, 1); @@ -2238,6 +2250,19 @@ int wc_VerifyEccsiHash(EccsiKey* key, enum wc_HashType hashType, err = mp_montgomery_setup(¶ms->prime, &mp); } + /* Validate r is in [1, q-1]: reject zero or out-of-range first signature + * component before any scalar multiplication takes place. + * Without this check, r=0 causes J_x=0 and the final mp_cmp(0,0)==MP_EQ + * comparison accepts the forged signature unconditionally. */ + if (err == 0) { + if (mp_iszero(r)) { + err = MP_ZERO_E; + } + else if (mp_cmp(r, ¶ms->order) != MP_LT) { + err = ECC_OUT_OF_RANGE_E; + } + } + /* Step 1: Validate PVT is on curve */ if (err == 0) { err = wc_ecc_is_point(pvt, ¶ms->a, ¶ms->b, ¶ms->prime); @@ -2273,6 +2298,16 @@ int wc_VerifyEccsiHash(EccsiKey* key, enum wc_HashType hashType, key->params.haveBase = 0; } + /* Defense-in-depth: reject J = point at infinity before the final + * comparison. Catches any future path that might reach this point + * with a neutral-element result (e.g. s = 0 mod q for a non-zero + * encoded s). */ + if (err == 0) { + if (wc_ecc_point_is_at_infinity(j)) { + err = ECC_INF_E; + } + } + /* Step 6: Jx fitting, compare with r */ if (err == 0) { jx = &key->tmp; From 1faddd640edc8195c1fb7cb3904876e434ecab87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 30 Mar 2026 12:10:58 +0200 Subject: [PATCH 3/7] evp: verify Poly1305 tag on ChaCha20-Poly1305 decrypt EVP_DecryptFinal_ex() called wc_ChaCha20Poly1305_Final() which only computes the Poly1305 tag, writing it into ctx->authTag and overwriting the expected tag stored there by EVP_CTRL_AEAD_SET_TAG. No comparison was ever performed, so any forged tag was accepted. Fix: save the expected tag before calling Final(), then verify with wc_ChaCha20Poly1305_CheckTag() on the decrypt path, mirroring the existing AES-GCM branch. Add a regression test that asserts EVP_DecryptFinal_ex() rejects an all-zero forged tag. Reported-by: Nicholas Carlini (Anthropic) & Bronson Yen (Calif.io) --- tests/api/test_evp_cipher.c | 23 +++++++++++++++++++++++ wolfcrypt/src/evp.c | 25 +++++++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/tests/api/test_evp_cipher.c b/tests/api/test_evp_cipher.c index b4e37df7a28..1e88da9979c 100644 --- a/tests/api/test_evp_cipher.c +++ b/tests/api/test_evp_cipher.c @@ -1915,6 +1915,7 @@ int test_wolfssl_EVP_chacha20_poly1305(void) byte cipherText[sizeof(plainText)]; byte decryptedText[sizeof(plainText)]; byte tag[CHACHA20_POLY1305_AEAD_AUTHTAG_SIZE]; + byte badTag[CHACHA20_POLY1305_AEAD_AUTHTAG_SIZE]; EVP_CIPHER_CTX* ctx = NULL; int outSz; @@ -1979,6 +1980,28 @@ int test_wolfssl_EVP_chacha20_poly1305(void) EVP_CIPHER_CTX_free(ctx); ctx = NULL; + /* Negative test: forged (all-zero) tag must be rejected. */ + XMEMSET(badTag, 0, sizeof(badTag)); + ExpectNotNull((ctx = EVP_CIPHER_CTX_new())); + ExpectIntEQ(EVP_DecryptInit_ex(ctx, EVP_chacha20_poly1305(), NULL, + NULL, NULL), WOLFSSL_SUCCESS); + ExpectIntEQ(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_IVLEN, + CHACHA20_POLY1305_AEAD_IV_SIZE, NULL), WOLFSSL_SUCCESS); + ExpectIntEQ(EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, + CHACHA20_POLY1305_AEAD_AUTHTAG_SIZE, badTag), + WOLFSSL_SUCCESS); + ExpectIntEQ(EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv), + WOLFSSL_SUCCESS); + ExpectIntEQ(EVP_DecryptUpdate(ctx, NULL, &outSz, aad, sizeof(aad)), + WOLFSSL_SUCCESS); + ExpectIntEQ(EVP_DecryptUpdate(ctx, decryptedText, &outSz, cipherText, + sizeof(cipherText)), WOLFSSL_SUCCESS); + /* EVP_DecryptFinal_ex MUST return failure on tag mismatch */ + ExpectIntNE(EVP_DecryptFinal_ex(ctx, decryptedText, &outSz), + WOLFSSL_SUCCESS); + EVP_CIPHER_CTX_free(ctx); + ctx = NULL; + /* Test partial Inits. CipherInit() allow setting of key and iv * in separate calls. */ ExpectNotNull((ctx = EVP_CIPHER_CTX_new())); diff --git a/wolfcrypt/src/evp.c b/wolfcrypt/src/evp.c index fc4f68eb9fc..121d926555f 100644 --- a/wolfcrypt/src/evp.c +++ b/wolfcrypt/src/evp.c @@ -1499,16 +1499,33 @@ int wolfSSL_EVP_CipherFinal(WOLFSSL_EVP_CIPHER_CTX *ctx, unsigned char *out, * HAVE_FIPS_VERSION >= 2 */ #if defined(HAVE_CHACHA) && defined(HAVE_POLY1305) case WC_CHACHA20_POLY1305_TYPE: + { + byte computedTag[CHACHA20_POLY1305_AEAD_AUTHTAG_SIZE]; + if (!ctx->enc) { + /* Save the expected tag before _Final() overwrites + * ctx->authTag */ + XMEMCPY(computedTag, ctx->authTag, sizeof(computedTag)); + } if (wc_ChaCha20Poly1305_Final(&ctx->cipher.chachaPoly, ctx->authTag) != 0) { WOLFSSL_MSG("wc_ChaCha20Poly1305_Final failed"); return WOLFSSL_FAILURE; } - else { - *outl = 0; - return WOLFSSL_SUCCESS; + if (!ctx->enc) { + /* ctx->authTag now holds computed tag; computedTag holds + * expected */ + int tagErr = wc_ChaCha20Poly1305_CheckTag(computedTag, + ctx->authTag); + ForceZero(computedTag, sizeof(computedTag)); + if (tagErr != 0) { + WOLFSSL_MSG("ChaCha20-Poly1305 tag mismatch"); + return WOLFSSL_FAILURE; + } } - break; + *outl = 0; + return WOLFSSL_SUCCESS; + } + break; #endif #ifdef WOLFSSL_SM4_GCM case WC_SM4_GCM_TYPE: From a88dd07c70e9415c3daa64d478f9a45bd65f9e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 30 Mar 2026 15:56:20 +0200 Subject: [PATCH 4/7] pkcs7,aes: reject truncated GCM auth tags wc_PKCS7_DecodeAuthEnvelopedData() accepted an attacker-controlled GCM tag length from the mac OCTET STRING and did not validate it against the parsed aes-ICVlen parameter. In parallel, wc_AesGcmDecrypt() accepted very short tags on decrypt while encrypt enforced WOLFSSL_MIN_AUTH_TAG_SZ. This made short-tag verification reachable through CMS AuthEnvelopedData and weakened integrity checks by allowing tag truncation. Fixes: - validate parsed macSz range in AuthEnvelopedData decode - require authTagSz to match parsed macSz - reject undersized GCM tags in PKCS7 decode - enforce WOLFSSL_MIN_AUTH_TAG_SZ in wc_AesGcmDecrypt() and wc_AesGcmDecryptFinal() Also add a regression test in pkcs7authenveloped vectors that truncates the final MAC OCTET STRING length from 16 to 1 and verifies decode fails. Reported by: Nicholas Carlini (Anthropic) & Thai Duong (Calif.io) --- wolfcrypt/src/aes.c | 11 +++++----- wolfcrypt/src/pkcs7.c | 20 ++++++++++++++++- wolfcrypt/test/test.c | 42 ++++++++++++++++++++++++++++++++++++ wolfssl/wolfcrypt/settings.h | 2 ++ 4 files changed, 69 insertions(+), 6 deletions(-) diff --git a/wolfcrypt/src/aes.c b/wolfcrypt/src/aes.c index a3b75dbb34b..41b01031c41 100644 --- a/wolfcrypt/src/aes.c +++ b/wolfcrypt/src/aes.c @@ -10217,8 +10217,9 @@ int wc_AesGcmDecrypt(Aes* aes, byte* out, const byte* in, word32 sz, /* If the sz is non-zero, both in and out must be set. If sz is 0, * in and out are don't cares, as this is is the GMAC case. */ if (aes == NULL || iv == NULL || (sz != 0 && (in == NULL || out == NULL)) || - authTag == NULL || authTagSz > WC_AES_BLOCK_SIZE || authTagSz == 0 || - ivSz == 0 || ((authInSz > 0) && (authIn == NULL))) + authTag == NULL || authTagSz > WC_AES_BLOCK_SIZE || + authTagSz < WOLFSSL_MIN_AUTH_TAG_SZ || ivSz == 0 || + ((authInSz > 0) && (authIn == NULL))) { return BAD_FUNC_ARG; } @@ -10781,8 +10782,8 @@ int wc_AesGcmDecrypt(Aes* aes, byte* out, const byte* in, word32 sz, /* If the sz is non-zero, both in and out must be set. If sz is 0, * in and out are don't cares, as this is is the GMAC case. */ if (aes == NULL || iv == NULL || (sz != 0 && (in == NULL || out == NULL)) || - authTag == NULL || authTagSz > WC_AES_BLOCK_SIZE || authTagSz == 0 || - ivSz == 0) { + authTag == NULL || authTagSz > WC_AES_BLOCK_SIZE || + authTagSz < WOLFSSL_MIN_AUTH_TAG_SZ || ivSz == 0) { return BAD_FUNC_ARG; } @@ -12473,7 +12474,7 @@ int wc_AesGcmEncryptFinal(Aes* aes, byte* authTag, word32 authTagSz) /* Check validity of parameters. */ if ((aes == NULL) || (authTag == NULL) || (authTagSz > WC_AES_BLOCK_SIZE) || - (authTagSz == 0)) { + (authTagSz < WOLFSSL_MIN_AUTH_TAG_SZ)) { ret = BAD_FUNC_ARG; } diff --git a/wolfcrypt/src/pkcs7.c b/wolfcrypt/src/pkcs7.c index 9efec15ec7f..174c04d632c 100644 --- a/wolfcrypt/src/pkcs7.c +++ b/wolfcrypt/src/pkcs7.c @@ -140,6 +140,7 @@ struct PKCS7State { word32 nonceSz; /* size of nonce stored */ word32 aadSz; /* size of additional AEAD data */ word32 tagSz; /* size of tag for AEAD */ + word32 icvSz; /* expected ICV/MAC size from AlgoID parameter */ word32 contentSz; word32 currContIdx; /* index of current content */ word32 currContSz; /* size of current content */ @@ -14235,6 +14236,10 @@ int wc_PKCS7_DecodeAuthEnvelopedData(wc_PKCS7* pkcs7, byte* in, if (ret == 0 && GetMyVersion(pkiMsg, &idx, &macSz, pkiMsgSz) < 0) { ret = ASN_PARSE_E; } + if (ret == 0 && (macSz <= 0 || macSz > WC_AES_BLOCK_SIZE)) { + WOLFSSL_MSG("AuthEnvelopedData invalid MAC length"); + ret = ASN_PARSE_E; + } if (ret == 0) { explicitOctet = 0; @@ -14280,7 +14285,8 @@ int wc_PKCS7_DecodeAuthEnvelopedData(wc_PKCS7* pkcs7, byte* in, break; } - /* store nonce for later */ + /* store nonce and macSz for later */ + pkcs7->stream->icvSz = (word32)macSz; if (nonceSz > 0) { pkcs7->stream->nonceSz = (word32)nonceSz; pkcs7->stream->nonce = (byte*)XMALLOC((word32)nonceSz, @@ -14471,6 +14477,7 @@ int wc_PKCS7_DecodeAuthEnvelopedData(wc_PKCS7* pkcs7, byte* in, encodedAttribSz = pkcs7->stream->aadSz; encodedAttribs = pkcs7->stream->aad; } + macSz = (int)pkcs7->stream->icvSz; #endif @@ -14487,6 +14494,17 @@ int wc_PKCS7_DecodeAuthEnvelopedData(wc_PKCS7* pkcs7, byte* in, ret = ASN_PARSE_E; } authTagSz = (word32)length; + if (ret == 0 && authTagSz != (word32)macSz) { + WOLFSSL_MSG("AuthEnvelopedData authTag size mismatch"); + ret = ASN_PARSE_E; + } + if (ret == 0 && + (encOID == AES128GCMb || encOID == AES192GCMb || + encOID == AES256GCMb) && + authTagSz < WOLFSSL_MIN_AUTH_TAG_SZ) { + WOLFSSL_MSG("AuthEnvelopedData GCM authTag too small"); + ret = ASN_PARSE_E; + } #ifndef NO_PKCS7_STREAM /* there might not be enough data for the auth tag too */ diff --git a/wolfcrypt/test/test.c b/wolfcrypt/test/test.c index 755aa94358e..8077342566b 100644 --- a/wolfcrypt/test/test.c +++ b/wolfcrypt/test/test.c @@ -57721,6 +57721,9 @@ static wc_test_ret_t pkcs7authenveloped_run_vectors(byte* rsaCert, word32 rsaCer wc_test_ret_t ret = 0; int testSz = 0, i; int envelopedSz, decodedSz; +#ifdef HAVE_AESGCM + int tagTruncationChecked = 0; +#endif byte *enveloped = NULL; byte *decoded = NULL; @@ -58232,6 +58235,45 @@ static wc_test_ret_t pkcs7authenveloped_run_vectors(byte* rsaCert, word32 rsaCer ERROR_OUT(WC_TEST_RET_ENC_NC, out); } +#ifdef HAVE_AESGCM + if (tagTruncationChecked == 0 && + (testVectors[i].encryptOID == AES128GCMb || + testVectors[i].encryptOID == AES192GCMb || + testVectors[i].encryptOID == AES256GCMb) && + testVectors[i].authAttribsSz == 0 && + testVectors[i].unauthAttribsSz == 0 && + envelopedSz > (WC_AES_BLOCK_SIZE + 2)) { + int macIdx = envelopedSz - (WC_AES_BLOCK_SIZE + 2); + byte* tampered = NULL; + + /* For plain DER output without unauthenticated attributes, the + * MAC OCTET STRING is the final field. */ + if (enveloped[macIdx] == ASN_OCTET_STRING && + enveloped[macIdx + 1] == WC_AES_BLOCK_SIZE) { + tampered = (byte*)XMALLOC((word32)envelopedSz, HEAP_HINT, + DYNAMIC_TYPE_TMP_BUFFER); + if (tampered == NULL) { + wc_PKCS7_Free(pkcs7); + ERROR_OUT(WC_TEST_RET_ENC_ERRNO, out); + } + XMEMCPY(tampered, enveloped, (word32)envelopedSz); + tampered[macIdx + 1] = 1; + + decodedSz = wc_PKCS7_DecodeAuthEnvelopedData(pkcs7, tampered, + (word32)envelopedSz, decoded, PKCS7_BUF_SIZE); + + XFREE(tampered, HEAP_HINT, DYNAMIC_TYPE_TMP_BUFFER); + tampered = NULL; + + if (decodedSz > 0) { + wc_PKCS7_Free(pkcs7); + ERROR_OUT(WC_TEST_RET_ENC_NC, out); + } + tagTruncationChecked = 1; + } + } +#endif + #ifdef PKCS7_OUTPUT_TEST_BUNDLES /* output pkcs7 envelopedData for external testing */ pkcs7File = XFOPEN(testVectors[i].outFileName, "wb"); diff --git a/wolfssl/wolfcrypt/settings.h b/wolfssl/wolfcrypt/settings.h index a0c79c5e846..4929b61cb43 100644 --- a/wolfssl/wolfcrypt/settings.h +++ b/wolfssl/wolfcrypt/settings.h @@ -3429,6 +3429,8 @@ extern void uITRON4_free(void *p) ; /* Default AES minimum auth tag sz, allow user to override */ #ifndef WOLFSSL_MIN_AUTH_TAG_SZ #define WOLFSSL_MIN_AUTH_TAG_SZ 12 +#elif WOLFSSL_MIN_AUTH_TAG_SZ < 1 + #error WOLFSSL_MIN_AUTH_TAG_SZ must be at least 1 #endif From e5ab7fa7457d82294bafcfb6c103a9e79b4aa213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 30 Mar 2026 16:19:00 +0200 Subject: [PATCH 5/7] x509: fix CA:FALSE bypass in wolfSSL_X509_verify_cert When an untrusted issuer has CA:FALSE and no verify_cb is registered, the !isCa branch now fails closed (ret=WOLFSSL_FAILURE, goto exit) instead of falling through and skipping X509StoreVerifyCert for the leaf. SetupStoreCtxError_ex is also hardened to never overwrite a previously recorded error with success, preventing a later valid chain link from clobbering ctx->error back to X509_V_OK. Tests added for both the no-callback rejection and the error-preservation cases. Reported by: Nicholas Carlini (Anthropic) & Thai Duong (Calif.io) --- src/x509_str.c | 11 +++++++- tests/api/test_ossl_x509_str.c | 51 +++++++++++++++++++++++++++++++++- tests/api/test_ossl_x509_str.h | 2 ++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/x509_str.c b/src/x509_str.c index 2c48c419644..90113caede2 100644 --- a/src/x509_str.c +++ b/src/x509_str.c @@ -318,6 +318,11 @@ static void SetupStoreCtxError_ex(WOLFSSL_X509_STORE_CTX* ctx, int ret, { int error = GetX509Error(ret); + /* Do not overwrite a previously recorded error with success; preserve + * the worst-seen error across the chain walk. */ + if (error == 0 && ctx->error != 0) + return; + wolfSSL_X509_STORE_CTX_set_error(ctx, error); wolfSSL_X509_STORE_CTX_set_error_depth(ctx, depth); } @@ -635,9 +640,14 @@ int wolfSSL_X509_verify_cert(WOLFSSL_X509_STORE_CTX* ctx) if (ctx->store->verify_cb) { ret = ctx->store->verify_cb(0, ctx); if (ret != WOLFSSL_SUCCESS) { + ret = WOLFSSL_FAILURE; goto exit; } } + else { + ret = WOLFSSL_FAILURE; + goto exit; + } } else #endif { @@ -2174,4 +2184,3 @@ int wolfSSL_X509_STORE_set1_param(WOLFSSL_X509_STORE *ctx, #endif /* !WOLFCRYPT_ONLY */ #endif /* !WOLFSSL_X509_STORE_INCLUDED */ - diff --git a/tests/api/test_ossl_x509_str.c b/tests/api/test_ossl_x509_str.c index 111994a3473..99b82877c30 100644 --- a/tests/api/test_ossl_x509_str.c +++ b/tests/api/test_ossl_x509_str.c @@ -1074,6 +1074,56 @@ int test_X509_STORE_InvalidCa(void) ExpectIntEQ(X509_STORE_CTX_init(ctx, str, cert, untrusted), 1); ExpectIntEQ(X509_verify_cert(ctx), 1); ExpectIntEQ(last_errcode, X509_V_ERR_INVALID_CA); + /* Defense in depth: ctx->error must not be clobbered back to X509_V_OK + * by the later successful verification of the intermediate against the + * trusted root. The worst-seen error must persist. */ + ExpectIntEQ(X509_STORE_CTX_get_error(ctx), X509_V_ERR_INVALID_CA); + + X509_free(cert); + X509_STORE_free(str); + X509_STORE_CTX_free(ctx); + sk_X509_pop_free(untrusted, NULL); +#endif + return EXPECT_RESULT(); +} + +int test_X509_STORE_InvalidCa_NoCallback(void) +{ + EXPECT_DECLS; +#if defined(OPENSSL_ALL) && !defined(NO_RSA) && !defined(NO_FILESYSTEM) + const char* filename = "./certs/intermediate/ca_false_intermediate/" + "test_int_not_cacert.pem"; + const char* srvfile = "./certs/intermediate/ca_false_intermediate/" + "test_sign_bynoca_srv.pem"; + X509_STORE_CTX* ctx = NULL; + X509_STORE* str = NULL; + XFILE fp = XBADFILE; + X509* cert = NULL; + STACK_OF(X509)* untrusted = NULL; + + ExpectTrue((fp = XFOPEN(srvfile, "rb")) + != XBADFILE); + ExpectNotNull(cert = PEM_read_X509(fp, 0, 0, 0 )); + if (fp != XBADFILE) { + XFCLOSE(fp); + fp = XBADFILE; + } + + ExpectNotNull(str = X509_STORE_new()); + ExpectNotNull(ctx = X509_STORE_CTX_new()); + ExpectNotNull(untrusted = sk_X509_new_null()); + + /* Create cert chain stack with an intermediate that is CA:FALSE. */ + ExpectIntEQ(test_X509_STORE_untrusted_load_cert_to_stack(filename, + untrusted), TEST_SUCCESS); + + ExpectIntEQ(X509_STORE_load_locations(str, + "./certs/intermediate/ca_false_intermediate/test_ca.pem", + NULL), 1); + ExpectIntEQ(X509_STORE_CTX_init(ctx, str, cert, untrusted), 1); + /* No verify callback: verification must fail on CA:FALSE issuer. */ + ExpectIntNE(X509_verify_cert(ctx), 1); + ExpectIntEQ(X509_STORE_CTX_get_error(ctx), X509_V_ERR_INVALID_CA); X509_free(cert); X509_STORE_free(str); @@ -1793,4 +1843,3 @@ int test_X509_STORE_No_SSL_CTX(void) #endif return EXPECT_RESULT(); } - diff --git a/tests/api/test_ossl_x509_str.h b/tests/api/test_ossl_x509_str.h index 9525a3a1879..67370464533 100644 --- a/tests/api/test_ossl_x509_str.h +++ b/tests/api/test_ossl_x509_str.h @@ -31,6 +31,7 @@ int test_wolfSSL_X509_STORE_CTX(void); int test_wolfSSL_X509_STORE_CTX_ex(void); int test_X509_STORE_untrusted(void); int test_X509_STORE_InvalidCa(void); +int test_X509_STORE_InvalidCa_NoCallback(void); int test_wolfSSL_X509_STORE_CTX_trusted_stack_cleanup(void); int test_wolfSSL_X509_STORE_CTX_get_issuer(void); int test_wolfSSL_X509_STORE_set_flags(void); @@ -51,6 +52,7 @@ int test_X509_STORE_No_SSL_CTX(void); TEST_DECL_GROUP("ossl_x509_store", test_wolfSSL_X509_STORE_CTX_ex), \ TEST_DECL_GROUP("ossl_x509_store", test_X509_STORE_untrusted), \ TEST_DECL_GROUP("ossl_x509_store", test_X509_STORE_InvalidCa), \ + TEST_DECL_GROUP("ossl_x509_store", test_X509_STORE_InvalidCa_NoCallback), \ TEST_DECL_GROUP("ossl_x509_store", \ test_wolfSSL_X509_STORE_CTX_trusted_stack_cleanup), \ TEST_DECL_GROUP("ossl_x509_store", \ From 1823f2e9fcdf0c3a6a41d255033d817d23a5ebb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 30 Mar 2026 16:44:50 +0200 Subject: [PATCH 6/7] tls: fix ECH heap buffer overflow via publicName SNI pollution In TLSX_EchChangeSNI, the ctx->extensions branch set extensions unconditionally even when TLSX_Find returned NULL. This caused TLSX_UseSNI to attach the attacker-controlled publicName to the shared WOLFSSL_CTX when no inner SNI was configured. TLSX_EchRestoreSNI then failed to clean it up because its removal was gated on serverNameX != NULL. The inner ClientHello was sized before the pollution but written after it, causing TLSX_SNI_Write to memcpy 255 bytes past the allocation boundary. Fix by mirroring the guarded pattern of the ssl->extensions branch: only set extensions when TLSX_Find returns non-NULL, and only perform the SNI swap when extensions is non-NULL. Also move TLSX_Remove in TLSX_EchRestoreSNI outside the serverNameX guard so any injected publicName SNI is always cleaned up. Also return BAD_FUNC_ARG when ECH is used without an inner SNI, preventing ECH ClientHello construction in an invalid configuration. Reported by: Nicholas Carlini (Anthropic) & Thai Duong (Calif.io) --- src/tls.c | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/tls.c b/src/tls.c index b854b8f8cd5..09e6c921740 100644 --- a/src/tls.c +++ b/src/tls.c @@ -16086,11 +16086,18 @@ static int TLSX_EchChangeSNI(WOLFSSL* ssl, TLSX** pEchX, if (serverNameX == NULL && ssl->ctx && ssl->ctx->extensions) { serverNameX = TLSX_Find(ssl->ctx->extensions, TLSX_SERVER_NAME); - extensions = &ssl->ctx->extensions; + if (serverNameX != NULL) + extensions = &ssl->ctx->extensions; + } + + /* ECH requires an inner SNI to be present for ClientHelloInner. + * Without it, fail instead of mutating extension lists. */ + if (serverNameX == NULL) { + ret = BAD_FUNC_ARG; } /* store the inner server name */ - if (serverNameX != NULL) { + if (ret == 0 && serverNameX != NULL) { char* hostName = ((SNI*)serverNameX->data)->data.host_name; word32 hostNameSz = (word32)XSTRLEN(hostName) + 1; @@ -16101,15 +16108,19 @@ static int TLSX_EchChangeSNI(WOLFSSL* ssl, TLSX** pEchX, XMEMCPY(serverName, hostName, hostNameSz); } - /* remove the inner server name */ - TLSX_Remove(extensions, TLSX_SERVER_NAME, ssl->heap); + /* only swap the SNI if one was found; extensions is non-NULL if an + * SNI entry was found on ssl->extensions or ctx->extensions */ + if (ret == 0 && extensions != NULL) { + /* remove the inner server name */ + TLSX_Remove(extensions, TLSX_SERVER_NAME, ssl->heap); - /* set the public name as the server name */ - if ((ret = TLSX_UseSNI(extensions, WOLFSSL_SNI_HOST_NAME, - ((WOLFSSL_ECH*)echX->data)->echConfig->publicName, - XSTRLEN(((WOLFSSL_ECH*)echX->data)->echConfig->publicName), - ssl->heap)) == WOLFSSL_SUCCESS) - ret = 0; + /* set the public name as the server name */ + if ((ret = TLSX_UseSNI(extensions, WOLFSSL_SNI_HOST_NAME, + ((WOLFSSL_ECH*)echX->data)->echConfig->publicName, + XSTRLEN(((WOLFSSL_ECH*)echX->data)->echConfig->publicName), + ssl->heap)) == WOLFSSL_SUCCESS) + ret = 0; + } } *pServerNameX = serverNameX; *pExtensions = extensions; @@ -16122,10 +16133,12 @@ static int TLSX_EchRestoreSNI(WOLFSSL* ssl, char* serverName, { int ret = 0; - if (serverNameX != NULL) { - /* remove the public name SNI */ + /* always remove the publicName SNI we injected, regardless of whether + * there was a prior inner SNI to restore */ + if (extensions != NULL) TLSX_Remove(extensions, TLSX_SERVER_NAME, ssl->heap); + if (serverNameX != NULL) { /* restore the inner server name */ ret = TLSX_UseSNI(extensions, WOLFSSL_SNI_HOST_NAME, serverName, XSTRLEN(serverName), ssl->heap); From 2ae20723db6b7b4b7c3f1bc75b79389a9f739a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Tue, 31 Mar 2026 21:59:30 +0200 Subject: [PATCH 7/7] evp: fix EVP_PKEY2PKCS8 returning NULL for private-key-only EC keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an EC_KEY is created via EC_KEY_new + EC_KEY_set_group + EC_KEY_set_private_key (no public point set), SetECKeyInternal incorrectly marks the internal ecc_key as ECC_PRIVATEKEY (instead of ECC_PRIVATEKEY_ONLY) because pub_key is always non-NULL — EC_KEY_new always allocates it as an empty, zero-initialised EC_POINT. ECC_populate_EVP_PKEY only calls wc_ecc_make_pub for ECC_PRIVATEKEY_ONLY keys, so the zero public-key point was serialised into the DER stored in pkey->pkey.ptr. After commit 929dd9913 made wc_ecc_import_x963_ex always pass untrusted=1, the re-decode inside wolfSSL_EVP_PKEY2PKCS8 → wolfSSL_d2i_PrivateKey_EVP correctly rejected that zero point with an on-curve failure, causing EVP_PKEY2PKCS8 to return NULL. Fix: in ECC_populate_EVP_PKEY, also call wc_ecc_make_pub when the key type is ECC_PRIVATEKEY but pubkey.x is zero (meaning the public key was never actually populated). This reconstructs the public key from the private scalar so that the encoded DER contains a valid on-curve point. --- wolfcrypt/src/evp.c | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/wolfcrypt/src/evp.c b/wolfcrypt/src/evp.c index 121d926555f..df8d9b860ba 100644 --- a/wolfcrypt/src/evp.c +++ b/wolfcrypt/src/evp.c @@ -3715,6 +3715,10 @@ int wolfSSL_EVP_PKEY_keygen_init(WOLFSSL_EVP_PKEY_CTX *ctx) return WOLFSSL_SUCCESS; } +#ifdef HAVE_ECC +static int ECC_populate_EVP_PKEY(WOLFSSL_EVP_PKEY* pkey, WOLFSSL_EC_KEY *key); +#endif + int wolfSSL_EVP_PKEY_keygen(WOLFSSL_EVP_PKEY_CTX *ctx, WOLFSSL_EVP_PKEY **ppkey) { @@ -3769,6 +3773,8 @@ int wolfSSL_EVP_PKEY_keygen(WOLFSSL_EVP_PKEY_CTX *ctx, ret = wolfSSL_EC_KEY_generate_key(pkey->ecc); if (ret == WOLFSSL_SUCCESS) { pkey->ownEcc = 1; + if (ECC_populate_EVP_PKEY(pkey, pkey->ecc) != WOLFSSL_SUCCESS) + ret = WOLFSSL_FAILURE; } } break; @@ -9521,7 +9527,15 @@ static int ECC_populate_EVP_PKEY(WOLFSSL_EVP_PKEY* pkey, WOLFSSL_EC_KEY *key) else #endif /* HAVE_PKCS8 */ { - if (ecc->type == ECC_PRIVATEKEY_ONLY) { + if (ecc->type == ECC_PRIVATEKEY_ONLY || + (ecc->type == ECC_PRIVATEKEY && + mp_iszero(ecc->pubkey.x))) { + /* Reconstruct public key from private scalar. This covers + * both ECC_PRIVATEKEY_ONLY keys and ECC_PRIVATEKEY keys whose + * public-key point was never populated (e.g. when only + * EC_KEY_set_private_key was called, SetECKeyInternal copies + * the zero-initialized pub_key point and marks the type as + * ECC_PRIVATEKEY, leaving pubkey.x == 0). */ if (wc_ecc_make_pub(ecc, NULL) != MP_OKAY) { return WOLFSSL_FAILURE; }