Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
mldsa: derive the public key for a private-only host key
  • Loading branch information
Emma Stensland
Emma Stensland committed Sep 30, 2026
commit 01474df524be8a4bfec6698bfb4cb2e97df21baf
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,17 @@ After that, configure and build wolfSSH as usual:
$ ./configure
$ make all

A private-only ML-DSA key, used as a host key or read as a client key with
`wolfSSH_ReadKey_buffer()`/`wolfSSH_ReadKey_file()`, loads when wolfSSL
provides `wc_MlDsaKey_MakePublicKey()`; wolfSSH derives the public key. This
is on when `./configure` detects the function, or in any build when wolfSSL
defines `WC_MLDSA_HAVE_MAKE_PUBLIC_KEY` alongside it. A build without
`./configure` against a wolfSSL that has the function but not that macro
must define `WOLFSSH_HAVE_MLDSA_DERIVE_PUB`. Without it, such keys are
rejected with `WS_CRYPTO_FAILED`.
An ML-DSA private key of a level disabled in wolfSSH
(`WOLFSSH_NO_MLDSA44/65/87`) is rejected at load with `WS_UNIMPLEMENTED_E`.

The wolfSSH client and server will automatically negotiate using ML-KEM-768
hybridized with ECDHE over the P-256 ECC curve and ML-DSA for host keys/client
public key authentication.
Expand Down
12 changes: 12 additions & 0 deletions configure.ac
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ AC_ARG_WITH(wolfssl,
AC_CHECK_LIB([wolfssl],[wolfCrypt_Init],,[AC_MSG_ERROR([libwolfssl is required for ${PACKAGE}. It can be obtained from https://www.wolfssl.com/download.html/ .])])
AC_CHECK_FUNCS([gethostbyname getaddrinfo gettimeofday inet_ntoa memset socket wc_ecc_set_rng])

# Check if the wc_MlDsaKey_MakePublicKey API is available.
# The declaration is ML-DSA config-gated, so probe through the real header.
AC_MSG_CHECKING([for wc_MlDsaKey_MakePublicKey])
AC_LINK_IFELSE(
[AC_LANG_PROGRAM([[#include <wolfssl/options.h>
#include <wolfssl/wolfcrypt/wc_mldsa.h>]],
[[(void)wc_MlDsaKey_MakePublicKey(NULL);]])],
[AC_DEFINE([WOLFSSH_HAVE_MLDSA_DERIVE_PUB], [1],
[wc_MlDsaKey_MakePublicKey() available and declared])
AC_MSG_RESULT([yes])],
[AC_MSG_RESULT([no])])

# futimens()/utimensat() declarations are feature-test-macro gated, so a plain
# AC_CHECK_FUNCS link test can pass while the prototype stays hidden at compile
# time. Probe through the real header so a positive result is build-safe.
Expand Down
103 changes: 90 additions & 13 deletions src/internal.c
Original file line number Diff line number Diff line change
Expand Up @@ -2001,6 +2001,43 @@ static int IsCompositeMlDsaId(byte id)
#endif


#ifndef WOLFSSH_NO_MLDSA
/* Nonzero when keyId is an ML-DSA level wolfSSH was built without. */
static int MlDsaIdDisabled(byte keyId)
{
WOLFSSH_UNUSED(keyId);
return
#ifdef WOLFSSH_NO_MLDSA44
keyId == ID_MLDSA44 ||
#endif
#ifdef WOLFSSH_NO_MLDSA65
keyId == ID_MLDSA65 ||
#endif
#ifdef WOLFSSH_NO_MLDSA87
keyId == ID_MLDSA87 ||
#endif
0;
}


/* MlDsaIdDisabled() for a decoded key. */
static int MlDsaLevelDisabled(MlDsaKey* key)
{
byte level = 0;

if (wc_MlDsaKey_GetParams(key, &level) != 0)
return 0;
if (level == WC_ML_DSA_44)
return MlDsaIdDisabled(ID_MLDSA44);
if (level == WC_ML_DSA_65)
return MlDsaIdDisabled(ID_MLDSA65);
if (level == WC_ML_DSA_87)
return MlDsaIdDisabled(ID_MLDSA87);
return 0;
}
#endif


void wolfSSH_KEY_clean(WS_KeySignature* key)
{
if (key != NULL) {
Expand Down Expand Up @@ -2070,10 +2107,14 @@ void wolfSSH_KEY_clean(WS_KeySignature* key)
* fails try to load it as if ECDSA. Both public and private keys can be
* decoded. For RSA keys, the key format is described as "ssh-rsa".
*
* Private-only ML-DSA keys are rejected (WS_CRYPTO_FAILED) as public keys
* cannot be derived. ECDSA derives and validates the public key here.
* Private-only ML-DSA keys have their public key derived here when
* WOLFSSH_HAVE_MLDSA_DERIVE_PUB is set; otherwise, or when derivation
* fails, they are rejected (WS_CRYPTO_FAILED, or WS_MEMORY_E if it ran
* out of memory). A private ML-DSA key of a level disabled in wolfSSH is
* rejected with WS_UNIMPLEMENTED_E. ECDSA derives and validates the public
* key here.
* Ed25519 allows missing public keys if HAVE_ED25519_MAKE_KEY is defined
* (derived later at KEX); otherwise rejected like ML-DSA.
* (derived later at KEX); otherwise rejected.
*
* @param in key to identify
* @param inSz size of key
Expand All @@ -2090,8 +2131,9 @@ int IdentifyAsn1Key(const byte* in, word32 inSz, int isPrivate, void* heap,
word32 idx;
int ret;
int dynType = isPrivate ? DYNTYPE_PRIVKEY : DYNTYPE_PUBKEY;
/* Set to WS_CRYPTO_FAILED if ML-DSA key lacks derivable public key.
* Prevents Ed25519 fallback decode. */
/* Set to the rejection code when a private key lacks a derivable
* public key or is of a disabled ML-DSA level. Prevents a fallback
* decode as another key type. */
int noPubKeyRet = 0;
#ifndef WOLFSSH_NO_MLDSA
byte mlDsaLevel = 0;
Expand Down Expand Up @@ -2208,14 +2250,32 @@ int IdentifyAsn1Key(const byte* in, word32 inSz, int isPrivate, void* heap,
if (isPrivate) {
ret = wc_MlDsaKey_PrivateKeyDecode(&key->ks.mldsa.key,
in, inSz, &idx);
if (ret == 0) {
/* Priv-only decode can succeed with no derivable
* public key; reject here instead of at first
* handshake. */
if (!key->ks.mldsa.key.pubKeySet) {
WLOG(WS_LOG_ERROR,
"ML-DSA priv-only key rejected; no derivable pubkey");
ret = WS_CRYPTO_FAILED;
if (ret == 0 && MlDsaLevelDisabled(&key->ks.mldsa.key)) {
/* Either DER form; before paying for derivation. */
WLOG(WS_LOG_ERROR, "ML-DSA level not enabled in "
"this build");
ret = WS_UNIMPLEMENTED_E;
noPubKeyRet = ret;
}
else if (ret == 0 && !key->ks.mldsa.key.pubKeySet) {
/* Derive the public key now so underivable keys
* are rejected at load time instead of handshake. */
#ifdef WOLFSSH_HAVE_MLDSA_DERIVE_PUB
int makeRet = wc_MlDsaKey_MakePublicKey(
&key->ks.mldsa.key);
#else
int makeRet = WC_NO_ERR_TRACE(NOT_COMPILED_IN);
#endif
if (makeRet != 0) {
WLOG(WS_LOG_ERROR, "ML-DSA priv-only key "
"rejected; no derivable pubkey (%d)",
makeRet);
if (makeRet == WC_NO_ERR_TRACE(MEMORY_E)) {
ret = WS_MEMORY_E;
}
else {
ret = WS_CRYPTO_FAILED;
}
noPubKeyRet = ret;
}
}
Expand Down Expand Up @@ -2950,6 +3010,16 @@ static int SetHostPrivateKey(WOLFSSH_CTX* ctx,
WFREE(der, ctx->heap, dynamicType);
ret = WS_BAD_ARGUMENT;
}
#endif
#ifndef WOLFSSH_NO_MLDSA
/* Defensive: IdentifyAsn1Key() already refuses a disabled level. */
else if (MlDsaIdDisabled(keyId)) {
WLOG(WS_LOG_ERROR, "SetHostPrivateKey: ML-DSA level not enabled "
"in this build");
WS_FORCEZERO(der, derSz);
WFREE(der, ctx->heap, dynamicType);
ret = WS_UNIMPLEMENTED_E;
}
#endif
else {
WOLFSSH_PVT_KEY* pvtKey = ctx->privateKey + destIdx;
Expand Down Expand Up @@ -16177,6 +16247,13 @@ static int SendKexGetSigningKey(WOLFSSH* ssh,
&sigKeyBlock_ptr->sk.mldsa.key,
ssh->ctx->privateKey[keyIdx].key,
ssh->ctx->privateKey[keyIdx].keySz, &scratch);
#ifdef WOLFSSH_HAVE_MLDSA_DERIVE_PUB
/* The exporters below are pure accessors; a private-only host
* key needs its public half derived first. */
if (ret == 0 && !sigKeyBlock_ptr->sk.mldsa.key.pubKeySet)
ret = wc_MlDsaKey_MakePublicKey(
&sigKeyBlock_ptr->sk.mldsa.key);
#endif
if (ret == 0)
ret = wc_MlDsaKey_ExportPubRaw(
&sigKeyBlock_ptr->sk.mldsa.key,
Expand Down
15 changes: 10 additions & 5 deletions tests/auth.c
Original file line number Diff line number Diff line change
Expand Up @@ -1415,18 +1415,23 @@ static void test_pubkey_auth_wrong_key(void)
#if !defined(WOLFSSH_NO_MLDSA) && !defined(WOLFSSH_NO_MLDSA44) && \
defined(WOLFSSL_MLDSA_PRIVATE_KEY) && !defined(WOLFSSL_MLDSA_NO_ASN1) && \
!defined(WOLFSSL_MLDSA_NO_MAKE_KEY)
/* Confirms a private-only ML-DSA host key is rejected through the real
* load path (wolfSSH_CTX_UsePrivateKey_buffer), not just IdentifyAsn1Key
* called directly as in the unit test. */
/* Covers the private-only ML-DSA host key through the real load path
* (wolfSSH_CTX_UsePrivateKey_buffer), not just IdentifyAsn1Key called
* directly as in the unit test. */
static void test_pubkey_load_mldsa_privonly_hostkey(void)
{
WOLFSSH_CTX* ctx;
MlDsaKey mlKey;
WC_RNG mlRng;
byte* mlDer;
int mlDerSz;
#ifdef WOLFSSH_HAVE_MLDSA_DERIVE_PUB
const int expect = WS_SUCCESS;
#else
const int expect = WS_CRYPTO_FAILED;
#endif

printf("Testing ML-DSA private-only host key load rejection\n");
printf("Testing ML-DSA private-only host key load\n");

WMEMSET(&mlKey, 0, sizeof(mlKey));
AssertIntEQ(wc_MlDsaKey_Init(&mlKey, NULL, INVALID_DEVID), 0);
Expand All @@ -1445,7 +1450,7 @@ static void test_pubkey_load_mldsa_privonly_hostkey(void)
ctx = wolfSSH_CTX_new(WOLFSSH_ENDPOINT_SERVER, NULL);
AssertNotNull(ctx);
AssertIntEQ(wolfSSH_CTX_UsePrivateKey_buffer(ctx, mlDer, (word32)mlDerSz,
WOLFSSH_FORMAT_ASN1), WS_CRYPTO_FAILED);
WOLFSSH_FORMAT_ASN1), expect);
wolfSSH_CTX_free(ctx);

WFREE(mlDer, NULL, 0);
Expand Down
38 changes: 25 additions & 13 deletions tests/unit.c
Original file line number Diff line number Diff line change
Expand Up @@ -16849,17 +16849,26 @@ static int test_ECCKexDeriveFallbackFailure(void)
!defined(WOLFSSL_MLDSA_NO_MAKE_KEY) && \
(!defined(WOLFSSH_NO_MLDSA44) || !defined(WOLFSSH_NO_MLDSA65) || \
!defined(WOLFSSH_NO_MLDSA87))
/* Private-only DER: rejected at decode time. Shared across 44/65/87 levels.
/* Private-only DER: the public key is derived at decode time where wolfSSL
* supports it, otherwise rejected there. Shared across 44/65/87 levels.
* Return codes -692..-699 */
static int test_IdentifyAsn1Key_MlDsaPrivOnlyDer(byte level,
word32 derBufSz, const char* levelName)
word32 derBufSz, int expectedKeyId, const char* levelName)
{
#ifdef WOLFSSH_HAVE_MLDSA_DERIVE_PUB
const int expect = expectedKeyId;
#else
const int expect = WS_CRYPTO_FAILED;
#endif
int ret;
MlDsaKey mlKey;
WC_RNG mlRng;
byte* mlDer = NULL;
int mlDerSz;

#ifndef WOLFSSH_HAVE_MLDSA_DERIVE_PUB
WOLFSSH_UNUSED(expectedKeyId);
#endif
WMEMSET(&mlKey, 0, sizeof(mlKey));
if (wc_MlDsaKey_Init(&mlKey, NULL, INVALID_DEVID) != 0) {
return -692;
Expand Down Expand Up @@ -16892,27 +16901,30 @@ static int test_IdentifyAsn1Key_MlDsaPrivOnlyDer(byte level,
}

ret = IdentifyAsn1Key(mlDer, (word32)mlDerSz, 1, NULL, NULL);
if (ret != WS_CRYPTO_FAILED) {
if (ret != expect) {
WFREE(mlDer, NULL, 0);
printf("IdentifyAsn1Key: private-only MlDsa %s DER expected "
"WS_CRYPTO_FAILED, got %d\n", levelName, ret);
"%d, got %d\n", levelName, expect, ret);
return -698;
}

/* Confirms *pkey stays NULL on rejection path. */
/* On the derive path *pkey comes back set; on the reject path it
* stays NULL. */
{
WS_KeySignature* mlKeySig = NULL;
int bad;

ret = IdentifyAsn1Key(mlDer, (word32)mlDerSz, 1, NULL,
&mlKeySig);
WFREE(mlDer, NULL, 0);
if (ret != WS_CRYPTO_FAILED || mlKeySig != NULL) {
bad = (ret != expect) || ((mlKeySig != NULL) != (expect > 0));
if (mlKeySig != NULL) {
wolfSSH_KEY_clean(mlKeySig);
WFREE(mlKeySig, NULL, DYNTYPE_PRIVKEY);
}
if (bad) {
printf("IdentifyAsn1Key: private-only MlDsa %s DER pkey-out "
"variant failed, ret=%d\n", levelName, ret);
if (mlKeySig != NULL) {
wolfSSH_KEY_clean(mlKeySig);
WFREE(mlKeySig, NULL, DYNTYPE_PRIVKEY);
}
return -699;
}
}
Expand Down Expand Up @@ -17584,7 +17596,7 @@ static int test_IdentifyAsn1Key(void)
#if defined(WOLFSSL_MLDSA_PRIVATE_KEY) && !defined(WOLFSSL_MLDSA_NO_ASN1) && \
!defined(WOLFSSL_MLDSA_NO_MAKE_KEY)
ret = test_IdentifyAsn1Key_MlDsaPrivOnlyDer(WC_ML_DSA_44,
WC_MLDSA_44_PRV_KEY_DER_SIZE, "44");
WC_MLDSA_44_PRV_KEY_DER_SIZE, ID_MLDSA44, "44");
if (ret != 0) {
result = ret; goto done;
}
Expand All @@ -17595,7 +17607,7 @@ static int test_IdentifyAsn1Key(void)
defined(WOLFSSL_MLDSA_PRIVATE_KEY) && !defined(WOLFSSL_MLDSA_NO_ASN1) && \
!defined(WOLFSSL_MLDSA_NO_MAKE_KEY)
ret = test_IdentifyAsn1Key_MlDsaPrivOnlyDer(WC_ML_DSA_65,
WC_MLDSA_65_PRV_KEY_DER_SIZE, "65");
WC_MLDSA_65_PRV_KEY_DER_SIZE, ID_MLDSA65, "65");
if (ret != 0) {
result = ret; goto done;
}
Expand All @@ -17605,7 +17617,7 @@ static int test_IdentifyAsn1Key(void)
defined(WOLFSSL_MLDSA_PRIVATE_KEY) && !defined(WOLFSSL_MLDSA_NO_ASN1) && \
!defined(WOLFSSL_MLDSA_NO_MAKE_KEY)
ret = test_IdentifyAsn1Key_MlDsaPrivOnlyDer(WC_ML_DSA_87,
WC_MLDSA_87_PRV_KEY_DER_SIZE, "87");
WC_MLDSA_87_PRV_KEY_DER_SIZE, ID_MLDSA87, "87");
if (ret != 0) {
result = ret; goto done;
}
Expand Down
13 changes: 13 additions & 0 deletions wolfssh/internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ extern "C" {
#define WOLFSSH_NO_MLDSA87
#endif

/* Check if the wc_MlDsaKey_MakePublicKey API is available.
* A private-only key needs this call made explicitly before its
* public half is read. Set by configure, by wolfSSL's own
* WC_MLDSA_HAVE_MAKE_PUBLIC_KEY, or predefined. */
#if defined(WC_MLDSA_HAVE_MAKE_PUBLIC_KEY) && \
!defined(WOLFSSH_HAVE_MLDSA_DERIVE_PUB)
#define WOLFSSH_HAVE_MLDSA_DERIVE_PUB
#endif
#if defined(WOLFSSH_NO_MLDSA) || defined(WOLFSSL_MLDSA_ASSIGN_KEY) || \
defined(WOLFSSL_MLDSA_NO_MAKE_KEY) || defined(WOLFSSL_MLDSA_VERIFY_ONLY)
#undef WOLFSSH_HAVE_MLDSA_DERIVE_PUB
#endif

#ifdef NO_SHA
#undef WOLFSSH_NO_SHA1
#define WOLFSSH_NO_SHA1
Expand Down