OpenSSL compat with ML-DSA - #10962
Conversation
|
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: APPROVE
Findings: 4 total — 3 posted, 2 skipped
Posted findings
- [High] ML-DSA private key decode in X509 sign/set_pubkey missing PRIVATE_KEY_UNLOCK bracket (fails under FIPS) —
src/x509.c:12827, src/x509.c:16508 - [Medium] WC_MAX_X509_GEN increase puts 20 KB on the stack in wolfSSL_X509_REQ_sign for all key types —
src/x509.c:12953-12962, 16922-16923 - [Medium] wolfSSL_X509_REQ_sign changes lack ML-DSA test coverage —
src/x509.c:16918-16950
Skipped findings
- [Low] fnames[] can become a zero-length array if all ML-DSA levels are disabled
- [Medium] WC_MAX_X509_GEN increase puts 20 KB on the stack in wolfSSL_X509_REQ_sign for all key types
Review generated by Skoll via Claude/Codex
Add ML-DSA test coverage for X509_REQ_sign
PRIVATE_KEY_UNLOCK to X509 sign/set_pubkey
Dynamic buff size based on expected cert size |
|
@kojo1 The CAVP self test failure is legit (undefined references during linking). |
Frauschi
left a comment
There was a problem hiding this comment.
🐺 Skoll Code Review
Overall recommendation: APPROVE
Findings: 19 total — 13 posted, 6 skipped
Posted findings
- [High] d2iTryMlDsaKey silently returns a stale EVP_PKEY when the caller reuses an existing key object —
wolfcrypt/src/evp_pk.c:1086-1096 - [Medium] DER buffer sizing ignores the subject public key, only the signing key —
src/x509.c:12961-12974 - [Medium] ML-DSA signing still requires a valid digest; comment claims md is ignored —
src/x509.c:12274-12277 - [Medium] Preprocessor gating mismatch: sigTypeFromPKEY accepts ML-DSA in builds where resign_cert cannot sign —
src/x509.c:12274 - [Medium] MlDsaKey reused across a failed PrivateKeyDecode without Free/Init in X509_set_pubkey —
src/x509.c:16519-16542 - [Medium] d2i_evp_pkey PKCS#8 algorithm cross-check not extended for ML-DSA —
wolfcrypt/src/evp_pk.c:1548-1553 - [Medium] Test coverage limited to ML-DSA-44; the riskiest new paths are untested —
tests/api/test_x509.c:1071-1128 - [Medium] wolfSSL_X509_set_pubkey stores the standard ML-DSA OID for FIPS204-draft keys; the WC_ML_DSA_*_DRAFT cases are unreachable —
src/x509.c:16546-16590 - [Low] New ML-DSA code uses MlDsaKey and mixed heap hints, unlike the rest of x509.c —
src/x509.c:12786 - [Low] WC_MAX_X509_GEN_MLDSA is an unrelated magic number to WC_MAX_X509_GEN —
src/x509.c:12962-12974 - [Low] X509_GEN_BUF_SZ sizes the DER buffer from the signing key, ignoring an ML-DSA subject public key —
src/x509.c:12961-12974 - [Low] New ML-DSA code uses legacy-only LEVEL2/3/5 spellings, breaking WOLFSSL_NO_DILITHIUM_LEGACY_NAMES builds —
src/x509.c:12279-12295 - [Info] ML-DSA key object reused across a failed private-key decode and a public-key decode without re-init —
src/x509.c:16527-16545
Skipped findings
- [Medium] WC_DECLARE_VAR/WC_ALLOC_VAR_EX abstraction dropped in X509_REQ_sign
- [Low] ML-DSA level-to-OID mapping duplicated three times; four-macro guard repeated four times
- [Low] Test guards: zero-length array risk and missing NO_SHA256 condition
- [Info] New test entry breaks the backslash alignment of TEST_X509_DECLS
- [Info] ML-DSA private key DER newly persisted in EVP_PKEY.pkey.ptr, which is freed without ForceZero
- [Info] X509_GEN_BUF_SZ evaluates its argument twice and is never #undef'd
Review generated by Skoll via Claude/Codex
|
|
||
| /* Copy the consumed DER into pkey->pkey.ptr when the input was DER */ | ||
| ret = d2i_make_pkey(out, mem, keyIdx, priv, WC_EVP_PKEY_DILITHIUM); | ||
| /* Copy the consumed DER into pkey->pkey.ptr when the input was DER. |
There was a problem hiding this comment.
🟠 [High] d2iTryMlDsaKey silently returns a stale EVP_PKEY when the caller reuses an existing key object
💡 SUGGEST
The PR changes d2iTryMlDsaKey() to skip d2i_make_pkey() whenever *out already has pkey.ptr set. That guard is correct for the new d2i_evp_pkey() call site (where local was just allocated and pre-filled with the input bytes), but d2iTryMlDsaKey() is also reached from the auto-detect path d2i_evp_pkey_try(), which at line 1124-1126 deliberately adopts a caller-supplied key: if ((out != NULL) && (*out != NULL)) pkey = *out;. In that path *out is a previously populated EVP_PKEY holding a completely different key. With the new condition, d2i_make_pkey() is never called, so pkey.ptr, pkey_sz and type are left pointing at the OLD key, yet the function returns 1 (success) and stamps the NEW mldsaOID onto the stale object. d2i_evp_pkey_try() then returns that object as the decoded ML-DSA key.
Unlike the Ed25519/Ed448 helpers this pattern was copied from, ML-DSA has no key object on the EVP_PKEY ((*out)->ed25519 = edKey has no ML-DSA analogue) - the ML-DSA key lives entirely in pkey.ptr. So for ML-DSA nothing at all is updated, not even pkey->type.
Before this PR d2i_make_pkey() was called unconditionally and correctly re-populated a reused EVP_PKEY, so this is a regression.
Trigger: EVP_PKEY *k = NULL; d2i_PUBKEY(&k, &rsa_spki, rsa_len); d2i_PUBKEY(&k, &mldsa_spki, mldsa_len); - the second call returns non-NULL "success" but EVP_PKEY_id(k) is still EVP_PKEY_RSA and k->pkey.ptr still holds the RSA SPKI. The same happens through wolfSSL_PEM_read_bio_PUBKEY() / wolfSSL_PEM_read_PUBKEY() (src/pk.c:6477 and 6645 both adopt *key) and through wolfSSL_d2i_PrivateKey_EVP(), all of which are documented OpenSSL reuse semantics.
Recommendation: Do not infer "caller already populated the key" from pkey.ptr != NULL. Pass the intent explicitly instead, e.g. add a int prePopulated parameter set to 1 only from d2i_evp_pkey() and 0 from d2i_evp_pkey_try():
static int d2iTryMlDsaKey(WOLFSSL_EVP_PKEY** out, const unsigned char* mem,
long memSz, int priv, int prePopulated)
{
...
ret = 1;
if (!prePopulated) {
ret = d2i_make_pkey(out, mem, keyIdx, priv, WC_EVP_PKEY_DILITHIUM);
}
Alternatively, in d2i_evp_pkey() do not pre-fill local->pkey.ptr for the ML-DSA case and let d2i_make_pkey() own the copy, as it did before this PR. Consider adding a regression test that calls PEM_read_bio_PUBKEY() twice with the same non-NULL EVP_PKEY** (RSA first, then ML-DSA) and asserts EVP_PKEY_id() changes.
There was a problem hiding this comment.
Explicit prePopulated flag for d2iTryMlDsaKey.
Added test_wolfSSL_d2i_PUBKEY_mldsa_reuse.
| /* able to override max size until dynamic buffer created */ | ||
| #define WC_MAX_X509_GEN 4096 | ||
| #endif | ||
| #ifdef WOLFSSL_HAVE_MLDSA |
There was a problem hiding this comment.
🟡 [Medium] DER buffer sizing ignores the subject public key, only the signing key
💡 SUGGEST bug
The new X509_GEN_BUF_SZ(pkey) macro selects 20480 vs 4096 purely from pkey->type — the signing key. But the buffer passed to wolfssl_x509_make_der() must hold the whole TBS, which includes the subject public key, and this same PR newly teaches wolfSSL_X509_set_pubkey() to accept ML-DSA keys. So the combination the PR just enabled — a certificate carrying an ML-DSA-87 subject public key (2592-byte SPKI, ~2616 encoded) signed by a classic RSA/ECC key — still gets only WC_MAX_X509_GEN (4096). With an RSA-4096 signature (512 + algid), a full issuer/subject DN and a handful of extensions, the encoding can exceed 4096 and wolfssl_x509_make_der fails with BUFFER_E. The commit message says "Dynamic buff size based on expected cert size", but the heuristic only looks at half of what determines that size.
Suggestion:
| #ifdef WOLFSSL_HAVE_MLDSA | |
| /* Consider the subject public key as well as the signing key. */ | |
| #define X509_GEN_BUF_SZ(x509, pkey) \ | |
| ((((pkey) != NULL && (pkey)->type == WC_EVP_PKEY_DILITHIUM) || \ | |
| ((x509) != NULL && wolfssl_x509_is_mldsa_pubkey(x509))) ? \ | |
| WC_MAX_X509_GEN_MLDSA : WC_MAX_X509_GEN) |
There was a problem hiding this comment.
Size X509 gen buffer from subject public key too.
| return WOLFSSL_FAILURE; | ||
| } | ||
| } | ||
| #ifdef WOLFSSL_HAVE_MLDSA |
There was a problem hiding this comment.
🟡 [Medium] ML-DSA signing still requires a valid digest; comment claims md is ignored
💡 SUGGEST api
The new ML-DSA branch is annotated /* ML-DSA does not use a separate hash; ignore md. */, but it is only reached after the unconditional wolfSSL_EVP_get_hashinfo(md, &hashType, NULL) guard at the top of the function, which returns WOLFSSL_FAILURE for a NULL or unrecognized md. wolfSSL_X509_sign() also rejects md == NULL outright. OpenSSL's idiomatic call for a hash-free signature algorithm is X509_sign(x, pkey, NULL), and X509_sign_ctx() on an EVP_DigestSignInit(ctx, &pctx, NULL, NULL, pkey) context passes a NULL md through EVP_MD_CTX_md(). Both fail here. For a PR whose stated goal is OpenSSL compatibility with ML-DSA, callers written against OpenSSL semantics will not work, and the comment misleads the next reader into thinking they will.
Suggestion:
| #ifdef WOLFSSL_HAVE_MLDSA | |
| /* In wolfSSL_sigTypeFromPKEY, hoist the ML-DSA dispatch above the hash lookup: */ | |
| #ifdef WOLFSSL_HAVE_MLDSA | |
| if (pkey->type == WC_EVP_PKEY_DILITHIUM) { | |
| /* ML-DSA signs the message directly - md is unused and may be NULL. */ | |
| switch (WOLFSSL_ATOMIC_LOAD(pkey->mldsaOID)) { | |
| ... | |
| } | |
| return sigType; | |
| } | |
| #endif | |
| if (wolfSSL_EVP_get_hashinfo(md, &hashType, NULL) == ... |
There was a problem hiding this comment.
Allow NULL md for ML-DSA X509 signing.
| return WOLFSSL_FAILURE; | ||
| } | ||
| } | ||
| #ifdef WOLFSSL_HAVE_MLDSA |
There was a problem hiding this comment.
🟡 [Medium] Preprocessor gating mismatch: sigTypeFromPKEY accepts ML-DSA in builds where resign_cert cannot sign
💡 SUGGEST bug
wolfSSL_sigTypeFromPKEY() returns a valid ML-DSA sigType under #ifdef WOLFSSL_HAVE_MLDSA alone, while the key-decoding branch in wolfSSL_X509_resign_cert() is gated on WOLFSSL_HAVE_MLDSA && WOLFSSL_MLDSA_PRIVATE_KEY && !WOLFSSL_MLDSA_NO_ASN1 && !WOLFSSL_MLDSA_NO_SIGN. In a verify-only ML-DSA build (WOLFSSL_MLDSA_VERIFY_ONLY / WOLFSSL_MLDSA_NO_SIGN), wolfSSL_X509_sign(x509, mldsa_pkey, md) now passes the sigType lookup, produces a TBS, then reaches wc_SignCert_ex(..., type = -1, key = NULL, ...). That does not crash (GetSignatureBufferSz() returns ALGO_ID_E when every key pointer is NULL), but the failure surfaces as an obscure wolfCrypt error late in the pipeline instead of a clean early rejection, and the two gates silently disagree about what the library supports. Before this PR the unsupported key type was rejected up front by sigTypeFromPKEY.
Suggestion:
| #ifdef WOLFSSL_HAVE_MLDSA | |
| /* Define one derived macro near the top of the ML-DSA section and use it everywhere: */ | |
| #if defined(WOLFSSL_HAVE_MLDSA) && defined(WOLFSSL_MLDSA_PRIVATE_KEY) && \ | |
| !defined(WOLFSSL_MLDSA_NO_ASN1) && !defined(WOLFSSL_MLDSA_NO_SIGN) | |
| #define WOLFSSL_X509_MLDSA_SIGN | |
| #endif |
There was a problem hiding this comment.
Single gate for ML-DSA X509 signing capability
| byte level = 0; | ||
| int decodeOk = 0; | ||
|
|
||
| mldsa = (MlDsaKey*)XMALLOC(sizeof(MlDsaKey), cert->heap, |
There was a problem hiding this comment.
🟡 [Medium] MlDsaKey reused across a failed PrivateKeyDecode without Free/Init in X509_set_pubkey
💡 SUGGEST bug
The new ML-DSA case tries wc_MlDsaKey_PrivateKeyDecode() first and, on failure, falls through to wc_MlDsaKey_PublicKeyDecode() on the same, un-reset MlDsaKey. wc_MlDsaKey_PrivateKeyDecode() is not atomic: at wolfcrypt/src/wc_mldsa.c:12221-12227 it calls wc_MlDsaKey_SetParams(key, level) after the ASN.1 layer succeeds and can still fail afterwards during the key-material import. In that window key->level is left set, so the subsequent PublicKeyDecode() runs with a pinned level instead of auto-detecting from the SPKI OID, and mismatching input is rejected with ASN_PARSE_E rather than being decoded. d2iTryMlDsaKey() in wolfcrypt/src/evp_pk.c handles exactly this by doing wc_MlDsaKey_Free() + wc_MlDsaKey_Init() before switching decode strategies; this new code does not.
Suggestion:
| mldsa = (MlDsaKey*)XMALLOC(sizeof(MlDsaKey), cert->heap, | |
| if (!decodeOk) { | |
| /* Private decode may have partially set params before failing; | |
| * reset the key so the public decode auto-detects the level. */ | |
| wc_MlDsaKey_Free(mldsa); | |
| if (wc_MlDsaKey_Init(mldsa, cert->heap, INVALID_DEVID) != 0) { | |
| XFREE(mldsa, cert->heap, DYNAMIC_TYPE_MLDSA); | |
| return WOLFSSL_FAILURE; | |
| } | |
| idx = 0; | |
| if (wc_MlDsaKey_PublicKeyDecode(mldsa, ..., &idx) != 0) { |
There was a problem hiding this comment.
Reset MlDsaKey between decode attempts in X509_set_pubkey
| WC_RNG rng; | ||
| #if defined(WOLFSSL_HAVE_MLDSA) && defined(WOLFSSL_MLDSA_PRIVATE_KEY) && \ | ||
| !defined(WOLFSSL_MLDSA_NO_ASN1) && !defined(WOLFSSL_MLDSA_NO_SIGN) | ||
| MlDsaKey* mldsa = NULL; |
There was a problem hiding this comment.
🔵 [Low] New ML-DSA code uses MlDsaKey and mixed heap hints, unlike the rest of x509.c
🔧 NIT convention
Three small consistency drifts from the surrounding file. (1) The new code declares MlDsaKey* mldsa, while every pre-existing ML-DSA site in src/x509.c (lines 12342, 12522) uses the canonical wc_MlDsaKey; MlDsaKey is a compat alias in wc_mldsa.h:689 alongside the legacy dilithium_key. (2) In wolfSSL_X509_set_pubkey the struct is allocated with cert->heap but wc_MlDsaKey_Init(mldsa, NULL, INVALID_DEVID) is passed a NULL heap, so the key's internal allocations bypass the hint — this matters in WOLFSSL_STATIC_MEMORY builds. In wolfSSL_X509_resign_cert both use NULL even though x509->heap is available. (3) None of the new error returns emit a WOLFSSL_MSG, whereas neighbouring branches ("Failed to allocate memory for wc_MlDsaKey", "malloc error") consistently do.
Suggestion:
| MlDsaKey* mldsa = NULL; | |
| wc_MlDsaKey* mldsa; | |
| ... | |
| mldsa = (wc_MlDsaKey*)XMALLOC(sizeof(wc_MlDsaKey), cert->heap, | |
| DYNAMIC_TYPE_MLDSA); | |
| if (mldsa == NULL) { | |
| WOLFSSL_MSG("Failed to allocate memory for wc_MlDsaKey"); | |
| return WOLFSSL_FAILURE; | |
| } | |
| if (wc_MlDsaKey_Init(mldsa, cert->heap, INVALID_DEVID) != 0) { |
| #define WC_MAX_X509_GEN 4096 | ||
| #endif | ||
| #ifdef WOLFSSL_HAVE_MLDSA | ||
| #ifndef WC_MAX_X509_GEN_MLDSA |
There was a problem hiding this comment.
🔵 [Low] WC_MAX_X509_GEN_MLDSA is an unrelated magic number to WC_MAX_X509_GEN
🔧 NIT style
WC_MAX_X509_GEN_MLDSA is hardcoded to 20480 while WC_MAX_X509_GEN defaults to 4096, and the two are unrelated. The comment justifies the value with ML-DSA-87's 2592-byte key and 4627-byte signature — roughly 7.5 KB with overhead — so 20480 is ~2.7x the stated need with no explanation of the margin. Both macros are user-overridable and independent, so a user who raises WC_MAX_X509_GEN above 20480 would get a smaller buffer for ML-DSA than for classic keys. The new macro is also undocumented (no README or manual entry, and the PR checklist leaves those boxes unticked).
Suggestion:
| #ifndef WC_MAX_X509_GEN_MLDSA | |
| #ifndef WC_MAX_X509_GEN_MLDSA | |
| /* ML-DSA-87: 2592 byte public key + 4627 byte signature, plus DN, | |
| * extensions and ASN.1 overhead; rounded up with headroom. Never | |
| * smaller than the classic buffer. */ | |
| #define WC_MAX_X509_GEN_MLDSA (WC_MAX_X509_GEN + 16384) | |
| #endif |
| /* able to override max size until dynamic buffer created */ | ||
| #define WC_MAX_X509_GEN 4096 | ||
| #endif | ||
| #ifdef WOLFSSL_HAVE_MLDSA |
There was a problem hiding this comment.
🔵 [Low] X509_GEN_BUF_SZ sizes the DER buffer from the signing key, ignoring an ML-DSA subject public key
💡 SUGGEST
The new macro picks WC_MAX_X509_GEN_MLDSA (20480) only when the signing key is ML-DSA: ((pkey)->type == WC_EVP_PKEY_DILITHIUM) ? WC_MAX_X509_GEN_MLDSA : WC_MAX_X509_GEN. But the buffer must hold the whole TBS body, which contains the subject public key set by X509_set_pubkey() - and that key can be ML-DSA while the issuer signs with RSA or ECDSA (a very common hybrid/migration setup, and exactly what the PR newly enables via the ML-DSA X509_set_pubkey() case).
An ML-DSA-87 SPKI alone is 2614 bytes (WC_MLDSA_87_PUB_KEY_DER_SIZE). Add issuer/subject DNs, validity, serial, SKID/AKID/SAN extensions and an RSA-4096 signature (512 bytes + AlgorithmIdentifier) and the 4096-byte budget is easily exhausted.
The failure is clean, not memory-unsafe - wc_MakeCert_ex(cert, der, *derSz, ...) and wc_SignCert_ex(..., der, derSz, ...) both honour the passed size - so the symptom is wolfSSL_X509_sign() returning WOLFSSL_FAILURE for a certificate that should encode fine. The condition also existed before the PR (the buffer was unconditionally 4096), so this is an incomplete fix rather than a regression; flagging it because the PR's stated goal is exactly to size the buffer for ML-DSA and the comment ("chosen from the signing key type") documents the wrong criterion.
Recommendation: Take the subject public key into account as well, e.g. make the macro a small static helper that inspects both the signing key and x509->pubKeyOID:
static int x509_gen_buf_sz(const WOLFSSL_X509* x509,
const WOLFSSL_EVP_PKEY* pkey)
{
#ifdef WOLFSSL_HAVE_MLDSA
if (((pkey != NULL) && (pkey->type == WC_EVP_PKEY_DILITHIUM)) ||
((x509 != NULL) && wolfssl_is_mldsa_oid(x509->pubKeyOID))) {
return WC_MAX_X509_GEN_MLDSA;
}
#endif
return WC_MAX_X509_GEN;
}
Better still, honour the long-standing @TODO dynamic set based on expected cert size the PR removed: size the buffer from the actual encoded length rather than from a per-algorithm constant.
| /* ML-DSA does not use a separate hash; ignore md. */ | ||
| switch (WOLFSSL_ATOMIC_LOAD(pkey->mldsaOID)) { | ||
| case ML_DSA_44k: | ||
| sigType = CTC_ML_DSA_LEVEL2; |
There was a problem hiding this comment.
🔵 [Low] New ML-DSA code uses legacy-only LEVEL2/3/5 spellings, breaking WOLFSSL_NO_DILITHIUM_LEGACY_NAMES builds
💡 SUGGEST
Both new switch statements use identifiers that only exist when the pre-standardization aliases are enabled:
CTC_ML_DSA_LEVEL2/3/5- defined inwolfssl/wolfcrypt/oid_sum.h:1791-1796under#ifndef WOLFSSL_NO_DILITHIUM_LEGACY_NAMESML_DSA_LEVEL2_TYPE/3/5- defined inwolfssl/wolfcrypt/asn_public.h:190-196under the same guard
Both new blocks are gated on WOLFSSL_HAVE_MLDSA, which is not disabled by WOLFSSL_NO_DILITHIUM_LEGACY_NAMES, so a build with WOLFSSL_NO_DILITHIUM_LEGACY_NAMES + ML-DSA + OPENSSL_EXTRA + WOLFSSL_CERT_GEN fails to compile with undeclared identifiers. WOLFSSL_NO_DILITHIUM_LEGACY_NAMES is a documented, user-settable option (see the comment at wolfssl/wolfcrypt/dilithium.h:58-60).
Adjacent pre-existing ML-DSA code in the same file uses the canonical spellings (ML_DSA_44_TYPE, wc_MlDsaKey_SetParams(mldsa, WC_ML_DSA_44) at src/x509.c:12537-12548), so the new code is also internally inconsistent.
Note for triage: other legacy uses already exist in the tree (ML_DSA_LEVEL2k at asn.c:9231 and evp_pk.c:1922) under #ifdef HAVE_DILITHIUM, which is gated by the different macro WOLFSSL_NO_DILITHIUM_LEGACY_GATES (settings.h:436-439) - so that configuration is already broken today. Severity is therefore Low rather than Medium, but this PR adds six new instances in a fresh code path and should not deepen the problem.
Recommendation: Use the canonical names, matching the rest of src/x509.c:
case ML_DSA_44k: sigType = CTC_ML_DSA_44; break;
case ML_DSA_65k: sigType = CTC_ML_DSA_65; break;
case ML_DSA_87k: sigType = CTC_ML_DSA_87; break;
case WC_ML_DSA_44: type = ML_DSA_44_TYPE; break;
case WC_ML_DSA_65: type = ML_DSA_65_TYPE; break;
case WC_ML_DSA_87: type = ML_DSA_87_TYPE; break;
| XFREE(mldsa, cert->heap, DYNAMIC_TYPE_MLDSA); | ||
| return WOLFSSL_FAILURE; | ||
| } | ||
| #ifdef WOLFSSL_MLDSA_PRIVATE_KEY |
There was a problem hiding this comment.
⚪ [Info] ML-DSA key object reused across a failed private-key decode and a public-key decode without re-init
💡 SUGGEST
The new branch calls wc_MlDsaKey_PrivateKeyDecode() first and, if it fails, retries wc_MlDsaKey_PublicKeyDecode() on the same MlDsaKey object without freeing and re-initialising it. A failed private-key decode can leave key->level and key->params set from a partially parsed AlgorithmIdentifier, which then changes how wc_MlDsaKey_PublicKeyDecode() behaves: with a non-zero key->level it derives keyType from that level (wc_mldsa.c:12553-12560) instead of auto-detecting from the input OID, and wc_MlDsaKey_ImportPubRaw() is length-checked against the already-selected parameter set.
With the inputs that reach this code today (a single DER buffer that is either PKCS#8 or SPKI) the private decode fails before extracting an OID, so key->level stays 0 and the retry auto-detects correctly - the code is currently correct. It is fragile against changes in wc_MlDsaKey_PrivateKeyDecode()'s failure points. Compare d2iTryMlDsaKey() in wolfcrypt/src/evp_pk.c:1043-1048, which explicitly does wc_MlDsaKey_Free() + wc_MlDsaKey_Init() between decode attempts.
Recommendation: Reset the key between attempts, mirroring d2iTryMlDsaKey():
if (!decodeOk) {
wc_MlDsaKey_Free(mldsa);
if (wc_MlDsaKey_Init(mldsa, NULL, INVALID_DEVID) != 0) {
XFREE(mldsa, cert->heap, DYNAMIC_TYPE_MLDSA);
return WOLFSSL_FAILURE;
}
idx = 0;
if (wc_MlDsaKey_PublicKeyDecode(mldsa, ..., &idx) != 0) {
...
}
}
Description
wolfSSL_PEM_read_bio_PrivateKey
wolfSSL_PEM_read_PrivateKey
wolfSSL_d2i_PrivateKey, wolfSSL_d2i_PublicKey
wolfSSL_X509_sign
wolfSSL_X509_REQ_sign
wolfSSL_X509_set_pubkey
Fixes zd#22151
Testing
Added test_wolfSSL_PEM_PrivateKey_mldsa
Added ML-DSA cases in test_wolfSSL_X509_set_pubkey
Checklist