From 5255277a100f451631e03ade792284fd035d8369 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 08:37:35 -0700 Subject: [PATCH 1/9] Add SPDM 1.4 ML-KEM standalone key exchange (FIPS 203, DSP0274 1.4) --- .github/workflows/spdm-emu-pqc-test.yml | 83 +++++++++- config.h.in | 3 + configure.ac | 54 ++++++ examples/spdm_demo.c | 34 +++- src/spdm_context.c | 26 ++- src/spdm_crypto.c | 113 ++++++++++++- src/spdm_internal.h | 41 ++++- src/spdm_msg.c | 209 +++++++++++++++++++----- src/spdm_session.c | 2 +- test/unit_test.c | 145 ++++++++++++++++ wolfspdm/spdm.h | 15 ++ wolfspdm/spdm_types.h | 47 +++++- 12 files changed, 711 insertions(+), 61 deletions(-) diff --git a/.github/workflows/spdm-emu-pqc-test.yml b/.github/workflows/spdm-emu-pqc-test.yml index 31cf5c3..42e08d6 100644 --- a/.github/workflows/spdm-emu-pqc-test.yml +++ b/.github/workflows/spdm-emu-pqc-test.yml @@ -62,25 +62,25 @@ jobs: run: echo "biweekly=$(( $(date +%s) / 1296000 ))" >> $GITHUB_OUTPUT # --- wolfSSL master with ML-DSA (rebuilt to track upstream drift) --- - - name: Build wolfSSL master (--enable-mldsa) + - name: Build wolfSSL master (--enable-mldsa --enable-mlkem) run: | cd ~ git clone --depth 1 --branch master https://github.com/wolfSSL/wolfssl.git cd wolfssl ./autogen.sh ./configure --enable-ecc --enable-sha384 --enable-aesgcm \ - --enable-hkdf --enable-sp --enable-mldsa \ + --enable-hkdf --enable-sp --enable-mldsa --enable-mlkem \ --prefix=$HOME/wolfssl-install make -j"$(nproc)" make install grep LIBWOLFSSL_VERSION_STRING $HOME/wolfssl-install/include/wolfssl/version.h - # --- wolfSPDM with ML-DSA asserted on, static or dynamic memory --- - - name: Build and install wolfSPDM (--enable-mldsa) + # --- wolfSPDM with ML-DSA + ML-KEM asserted on, static or dynamic memory --- + - name: Build and install wolfSPDM (--enable-mldsa --enable-mlkem) run: | ./autogen.sh ./configure --with-wolfssl=$HOME/wolfssl-install \ - --prefix=$HOME/wolfspdm-install --enable-mldsa \ + --prefix=$HOME/wolfspdm-install --enable-mldsa --enable-mlkem \ ${{ matrix.dynamic-mem == 'yes' && '--enable-dynamic-mem' || '' }} make -j"$(nproc)" make install @@ -101,7 +101,7 @@ jobs: uses: actions/cache@v4 with: path: ~/spdm-emu/build - key: spdm-emu-pqc-openssl-v3-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.cache-period.outputs.biweekly }} + key: spdm-emu-pqc-openssl-v4-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.cache-period.outputs.biweekly }} - name: Build spdm-emu (CRYPTO=openssl) if: steps.cache-spdm-emu.outputs.cache-hit != 'true' @@ -190,6 +190,77 @@ jobs: fi echo "All ML-DSA 44/65/87 interop cases passed." + # --- ML-KEM interop: responder offers only ML-KEM (--dhe NONE) with ECDSA + # signing, so the handshake performs ML-KEM key exchange in isolation. + # The requester forces KEM-only advertisement with --kex. ek (<=1568 B) + # and ciphertext c (<=1568 B) fit one message under the responder's + # DataTransferSize, so this tests the KEM path without chunking. + - name: ML-KEM 512/768/1024 session + measurements + challenge + run: | + export LD_LIBRARY_PATH=$HOME/wolfspdm-install/lib:$HOME/wolfssl-install/lib + export SPDM_EMU_PATH=$HOME/spdm-emu/build/bin + export SPDM_EMU_CERT_DIR=ecp384 + DEMO=./examples/spdm_demo + FAILURES="" + + wait_for_port() { + local i + for i in $(seq 1 50); do + if ss -ltn 2>/dev/null | grep -q ':2323 '; then return 0; fi + sleep 0.2 + done + return 1 + } + + # $1 emu KEM name, $2 demo --kex name, $3 label, then demo args. + run_kem() { + local kem="$1" kex="$2" label="$3"; shift 3 + echo "::group::$kem $label" + local attempt rc=1 emu + for attempt in 1 2 3; do + ( cd "$SPDM_EMU_PATH" && ./spdm_responder_emu --ver 1.4 \ + --hash SHA_384 --asym ECDSA_P384 --pqc_asym NONE \ + --dhe NONE --kem "$kem" --aead AES_256_GCM \ + >/tmp/pqc_emu_${kem}_${label}.log 2>&1 ) & + emu=$! + if wait_for_port; then + sleep 1 + if "$DEMO" "$@" --ver 1.4 --kex "$kex" --debug; then rc=0; else rc=$?; fi + else + rc=1 + fi + kill $emu 2>/dev/null || true + wait $emu 2>/dev/null || true + [ $rc -eq 0 ] && break + echo "--- responder log (attempt $attempt) ---" + cat /tmp/pqc_emu_${kem}_${label}.log || true + echo "attempt $attempt for $kem $label failed (rc=$rc), retrying" + sleep 1 + done + echo "::endgroup::" + if [ $rc -ne 0 ]; then + echo "::error::$kem $label failed after retries (rc=$rc)" + FAILURES="$FAILURES $kem/$label" + else + echo "$kem $label: OK" + fi + } + + for spec in "ML_KEM_512 mlkem512" "ML_KEM_768 mlkem768" \ + "ML_KEM_1024 mlkem1024"; do + set -- $spec + kem="$1"; kex="$2" + run_kem "$kem" "$kex" session --emu + run_kem "$kem" "$kex" meas --meas + run_kem "$kem" "$kex" challenge --challenge + done + + if [ -n "$FAILURES" ]; then + echo "::error::ML-KEM interop failures:$FAILURES" + exit 1 + fi + echo "All ML-KEM 512/768/1024 interop cases passed." + - name: Upload logs on failure if: failure() uses: actions/upload-artifact@v4 diff --git a/config.h.in b/config.h.in index a0cef4b..3d4d91c 100644 --- a/config.h.in +++ b/config.h.in @@ -77,6 +77,9 @@ /* Disable ML-DSA support */ #undef WOLFSPDM_NO_MLDSA +/* Disable ML-KEM support */ +#undef WOLFSPDM_NO_MLKEM + /* Define for Solaris 2.5.1 so the uint32_t typedef from , , or is not used. If the typedef were allowed, the #define below would cause a syntax error. */ diff --git a/configure.ac b/configure.ac index 66c4a99..eb1ece5 100644 --- a/configure.ac +++ b/configure.ac @@ -149,6 +149,59 @@ else mldsa_status=disabled fi +# ML-KEM (FIPS 203) post-quantum key exchange (DSP0274 1.4). Default: auto-follow +# the linked wolfSSL - on when it reports WOLFSSL_HAVE_MLKEM, off otherwise. +AC_ARG_ENABLE([mlkem], + [AS_HELP_STRING([--disable-mlkem], [Disable ML-KEM support even if wolfSSL has it])], + [enable_mlkem=$enableval], + [enable_mlkem=auto]) + +AC_MSG_CHECKING([whether wolfSSL provides ML-KEM]) +AC_COMPILE_IFELSE([AC_LANG_SOURCE([[ + #include + #include + #ifndef WOLFSSL_HAVE_MLKEM + #error "no mlkem" + #endif + int main(void) { return 0; } +]])], +[have_wolfssl_mlkem=yes], +[have_wolfssl_mlkem=no]) +AC_MSG_RESULT([$have_wolfssl_mlkem]) + +# wolfSPDM uses the wc_MlKemKey_* API (keygen, encapsulation-key encode, and +# decapsulation). Capability-test for it rather than gating on a version number. +have_mlkem_api=no +if test "x$enable_mlkem" != "xno" && test "x$have_wolfssl_mlkem" = "xyes"; then + AC_MSG_CHECKING([for the wc_MlKemKey API]) + AC_LINK_IFELSE([AC_LANG_PROGRAM([[ + #include + #include + #include + ]], [[ + MlKemKey k; word32 len = 0; + (void)wc_MlKemKey_Init(&k, 0, 0, 0); + (void)wc_MlKemKey_EncodePublicKey(&k, 0, 0); + (void)wc_MlKemKey_Decapsulate(&k, 0, 0, 0); + (void)wc_MlKemKey_PublicKeySize(&k, &len); + ]])], + [have_mlkem_api=yes], + [have_mlkem_api=no]) + AC_MSG_RESULT([$have_mlkem_api]) +fi + +if test "x$enable_mlkem" = "xno"; then + AC_DEFINE([WOLFSPDM_NO_MLKEM], [1], [Disable ML-KEM support]) + mlkem_status=disabled +elif test "x$have_mlkem_api" = "xyes"; then + mlkem_status=enabled +elif test "x$enable_mlkem" = "xyes"; then + AC_MSG_ERROR([--enable-mlkem requires a wolfSSL with the wc_MlKemKey API, built with --enable-mlkem. Use --disable-mlkem or update wolfSSL.]) +else + AC_DEFINE([WOLFSPDM_NO_MLKEM], [1], [Disable ML-KEM support]) + mlkem_status=disabled +fi + # Output files AC_CONFIG_FILES([Makefile wolfspdm.pc]) AC_OUTPUT @@ -160,5 +213,6 @@ echo " Debug: $enable_debug" echo " Dynamic mem: $enable_dynamic_mem" echo " Chunking: $chunking_status" echo " ML-DSA: $mldsa_status" +echo " ML-KEM: $mlkem_status" echo " wolfSSL: ${WOLFSSL_DIR:-system}" echo "" diff --git a/examples/spdm_demo.c b/examples/spdm_demo.c index c02bde1..a8181f9 100644 --- a/examples/spdm_demo.c +++ b/examples/spdm_demo.c @@ -236,7 +236,8 @@ static void usage(const char* argv0) { fprintf(stderr, "Usage: %s {--emu|--meas|--challenge|--heartbeat|--key-update}\n" - " [--no-sig] [--ver 1.2|1.3|1.4] [--debug]\n" + " [--no-sig] [--ver 1.2|1.3|1.4]\n" + " [--kex ecdhe|mlkem512|mlkem768|mlkem1024] [--debug]\n" "\n" "Env:\n" " SPDM_EMU_PATH path to spdm-emu build/bin/ (used for trusted CA\n" @@ -469,6 +470,7 @@ int main(int argc, char* argv[]) { "heartbeat", no_argument, 0, 'b' }, { "key-update", no_argument, 0, 'k' }, { "ver", required_argument, 0, 'v' }, + { "kex", required_argument, 0, 'K' }, { "debug", no_argument, 0, 'd' }, { "help", no_argument, 0, 'h' }, { 0, 0, 0, 0 } @@ -477,6 +479,8 @@ int main(int argc, char* argv[]) int withSig = 1; int debug = 0; byte maxVer = 0; + int kexEcdheOnly = 0; + word16 kexKemOnly = 0; int opt; int rc; WOLFSPDM_CTX* ctx = (WOLFSPDM_CTX*)g_ctxBuf; @@ -498,6 +502,25 @@ int main(int argc, char* argv[]) return 1; } break; + case 'K': + if (strcmp(optarg, "ecdhe") == 0) { + kexEcdheOnly = 1; + } + else if (strcmp(optarg, "mlkem512") == 0) { + kexKemOnly = SPDM_KEM_ALGO_ML_KEM_512; + } + else if (strcmp(optarg, "mlkem768") == 0) { + kexKemOnly = SPDM_KEM_ALGO_ML_KEM_768; + } + else if (strcmp(optarg, "mlkem1024") == 0) { + kexKemOnly = SPDM_KEM_ALGO_ML_KEM_1024; + } + else { + fprintf(stderr, "Invalid --kex %s (expected ecdhe/" + "mlkem512/mlkem768/mlkem1024)\n", optarg); + return 1; + } + break; case 'h': usage(argv[0]); return 0; default: usage(argv[0]); return 1; } @@ -545,6 +568,15 @@ int main(int argc, char* argv[]) } } + if (kexEcdheOnly || kexKemOnly != 0) { + rc = wolfSPDM_SetKeyExchangePref(ctx, kexEcdheOnly ? 1 : 0, kexKemOnly); + if (rc != WOLFSPDM_SUCCESS) { + fprintf(stderr, "wolfSPDM_SetKeyExchangePref: %s\n", + wolfSPDM_GetErrorString(rc)); + goto done; + } + } + switch (mode) { case MODE_SESSION: rc = do_session(ctx); break; case MODE_MEAS: rc = do_meas(ctx, withSig); break; diff --git a/src/spdm_context.c b/src/spdm_context.c index c670c7a..93f6626 100644 --- a/src/spdm_context.c +++ b/src/spdm_context.c @@ -73,6 +73,15 @@ int wolfSPDM_Init(WOLFSPDM_CTX* ctx) /* Set default requester capabilities */ ctx->reqCaps = WOLFSPDM_DEFAULT_REQ_CAPS; + /* Key-exchange advertisement: DHE always; ML-KEM (all sets, at 1.4) + * dual-stack alongside it by default. wolfSPDM_SetKeyExchangePref overrides. */ + ctx->kexAdvDhe = 1; +#ifdef WOLFSPDM_HAVE_MLKEM + ctx->kexAdvKem = (word16)(SPDM_KEM_ALGO_ML_KEM_512 | + SPDM_KEM_ALGO_ML_KEM_768 | + SPDM_KEM_ALGO_ML_KEM_1024); +#endif + /* Pick a random, non-reserved ReqSessionID (DSP0277 reserves 0x0000 and * 0xFFFF). Callers needing determinism can override via * wolfSPDM_SetRequesterSessionId. */ @@ -133,7 +142,7 @@ void wolfSPDM_Free(WOLFSPDM_CTX* ctx) /* Free ephemeral key */ if (ctx->flags.ephemeralKeyInit) { - wc_ecc_free(&ctx->ephemeralKey); + wolfSPDM_FreeEphemeralKey(ctx); } /* Free responder public key (used for measurement/challenge verification) */ @@ -286,6 +295,21 @@ int wolfSPDM_SetMaxVersion(WOLFSPDM_CTX* ctx, byte maxVersion) return WOLFSPDM_SUCCESS; } +int wolfSPDM_SetKeyExchangePref(WOLFSPDM_CTX* ctx, int advDhe, word16 kemMask) +{ + if (ctx == NULL) { + return WOLFSPDM_E_INVALID_ARG; + } +#ifndef WOLFSPDM_HAVE_MLKEM + if (kemMask != 0) { + return WOLFSPDM_E_INVALID_ARG; /* ML-KEM not built in */ + } +#endif + ctx->kexAdvDhe = (byte)(advDhe != 0); + ctx->kexAdvKem = kemMask; + return WOLFSPDM_SUCCESS; +} + /* --- Session Status --- */ int wolfSPDM_IsConnected(WOLFSPDM_CTX* ctx) diff --git a/src/spdm_crypto.c b/src/spdm_crypto.c index d506da2..d7555f6 100644 --- a/src/spdm_crypto.c +++ b/src/spdm_crypto.c @@ -77,20 +77,22 @@ int wolfSPDM_GenerateEphemeralKey(WOLFSPDM_CTX* ctx) /* Free existing key if any */ if (ctx->flags.ephemeralKeyInit) { - wc_ecc_free(&ctx->ephemeralKey); + wolfSPDM_FreeEphemeralKey(ctx); ctx->flags.ephemeralKeyInit = 0; } + ctx->kexType = WOLFSPDM_KEX_ECDHE; /* Initialize new key */ - rc = wc_ecc_init(&ctx->ephemeralKey); + rc = wc_ecc_init(&ctx->ephemeralKey.ecc); if (rc != 0) { return WOLFSPDM_E_CRYPTO_FAIL; } /* Generate P-384 key pair */ - rc = wc_ecc_make_key(&ctx->rng, WOLFSPDM_ECC_KEY_SIZE, &ctx->ephemeralKey); + rc = wc_ecc_make_key(&ctx->rng, WOLFSPDM_ECC_KEY_SIZE, + &ctx->ephemeralKey.ecc); if (rc != 0) { - wc_ecc_free(&ctx->ephemeralKey); + wc_ecc_free(&ctx->ephemeralKey.ecc); return WOLFSPDM_E_CRYPTO_FAIL; } @@ -120,7 +122,7 @@ int wolfSPDM_ExportEphemeralPubKey(WOLFSPDM_CTX* ctx, return WOLFSPDM_E_BUFFER_SMALL; } - rc = wc_ecc_export_public_raw(&ctx->ephemeralKey, + rc = wc_ecc_export_public_raw(&ctx->ephemeralKey.ecc, pubKeyX, pubKeyXSz, pubKeyY, pubKeyYSz); if (rc != 0) { return WOLFSPDM_E_CRYPTO_FAIL; @@ -181,7 +183,7 @@ int wolfSPDM_ComputeSharedSecret(WOLFSPDM_CTX* ctx, /* Compute ECDH shared secret */ ctx->sharedSecretSz = sizeof(ctx->sharedSecret); - rc = wc_ecc_shared_secret(&ctx->ephemeralKey, &peerKey, + rc = wc_ecc_shared_secret(&ctx->ephemeralKey.ecc, &peerKey, ctx->sharedSecret, &ctx->sharedSecretSz); if (rc != 0) { wolfSPDM_DebugPrint(ctx, "ECDH shared_secret failed: %d\n", rc); @@ -219,3 +221,102 @@ int wolfSPDM_ComputeSharedSecret(WOLFSPDM_CTX* ctx, return (rc == 0) ? WOLFSPDM_SUCCESS : WOLFSPDM_E_CRYPTO_FAIL; } + +#ifdef WOLFSPDM_HAVE_MLKEM +/* Map the negotiated SPDM KEM selection to the wolfSSL ML-KEM parameter set. */ +static int wolfSPDM_MlKemType(word16 kemAlgSel, int* type) +{ + switch (kemAlgSel) { + case SPDM_KEM_ALGO_ML_KEM_512: *type = WC_ML_KEM_512; break; + case SPDM_KEM_ALGO_ML_KEM_768: *type = WC_ML_KEM_768; break; + case SPDM_KEM_ALGO_ML_KEM_1024: *type = WC_ML_KEM_1024; break; + default: return WOLFSPDM_E_ALGO_MISMATCH; + } + return WOLFSPDM_SUCCESS; +} + +/* Generate the ephemeral ML-KEM key pair for KEY_EXCHANGE and export the + * encapsulation key ek into ekOut. The decapsulation key dk stays in + * ctx->ephemeralKey.mlkem for wolfSPDM_MlKemDecapsulate. */ +int wolfSPDM_GenerateMlKemKey(WOLFSPDM_CTX* ctx, byte* ekOut, word32* ekOutSz) +{ + int type; + word32 ekSz; + int rc; + + if (ctx == NULL || ekOut == NULL || ekOutSz == NULL) { + return WOLFSPDM_E_INVALID_ARG; + } + if (!ctx->flags.rngInitialized) { + return WOLFSPDM_E_BAD_STATE; + } + rc = wolfSPDM_MlKemType(ctx->kemAlgSel, &type); + if (rc != WOLFSPDM_SUCCESS) { + return rc; + } + + if (ctx->flags.ephemeralKeyInit) { + wolfSPDM_FreeEphemeralKey(ctx); + ctx->flags.ephemeralKeyInit = 0; + } + ctx->kexType = WOLFSPDM_KEX_MLKEM; + + rc = wc_MlKemKey_Init(&ctx->ephemeralKey.mlkem, type, NULL, INVALID_DEVID); + if (rc != 0) { + return WOLFSPDM_E_CRYPTO_FAIL; + } + rc = wc_MlKemKey_MakeKey(&ctx->ephemeralKey.mlkem, &ctx->rng); + if (rc != 0) { + wc_MlKemKey_Free(&ctx->ephemeralKey.mlkem); + return WOLFSPDM_E_CRYPTO_FAIL; + } + ctx->flags.ephemeralKeyInit = 1; + + rc = wc_MlKemKey_PublicKeySize(&ctx->ephemeralKey.mlkem, &ekSz); + if (rc != 0) { + return WOLFSPDM_E_CRYPTO_FAIL; + } + if (ekSz > *ekOutSz) { + return WOLFSPDM_E_BUFFER_SMALL; + } + rc = wc_MlKemKey_EncodePublicKey(&ctx->ephemeralKey.mlkem, ekOut, ekSz); + if (rc != 0) { + return WOLFSPDM_E_CRYPTO_FAIL; + } + *ekOutSz = ekSz; + + wolfSPDM_DebugPrint(ctx, "Generated ML-KEM ephemeral key (ek %u bytes)\n", + ekSz); + return WOLFSPDM_SUCCESS; +} + +/* Decapsulate the responder's ciphertext c into ctx->sharedSecret (K'). */ +int wolfSPDM_MlKemDecapsulate(WOLFSPDM_CTX* ctx, const byte* ct, word32 ctSz) +{ + word32 ssSz; + int rc; + + if (ctx == NULL || ct == NULL) { + return WOLFSPDM_E_INVALID_ARG; + } + if (!ctx->flags.ephemeralKeyInit || + ctx->kexType != WOLFSPDM_KEX_MLKEM) { + return WOLFSPDM_E_BAD_STATE; + } + + rc = wc_MlKemKey_SharedSecretSize(&ctx->ephemeralKey.mlkem, &ssSz); + if (rc != 0 || ssSz > sizeof(ctx->sharedSecret)) { + return WOLFSPDM_E_CRYPTO_FAIL; + } + rc = wc_MlKemKey_Decapsulate(&ctx->ephemeralKey.mlkem, ctx->sharedSecret, + ct, ctSz); + if (rc != 0) { + wolfSPDM_DebugPrint(ctx, "ML-KEM decapsulate failed: %d\n", rc); + return WOLFSPDM_E_CRYPTO_FAIL; + } + ctx->sharedSecretSz = ssSz; + + wolfSPDM_DebugPrint(ctx, "ML-KEM shared secret derived (%u bytes)\n", ssSz); + return WOLFSPDM_SUCCESS; +} +#endif /* WOLFSPDM_HAVE_MLKEM */ diff --git a/src/spdm_internal.h b/src/spdm_internal.h index a48fa26..19a4e64 100644 --- a/src/spdm_internal.h +++ b/src/spdm_internal.h @@ -50,6 +50,9 @@ #ifdef WOLFSPDM_HAVE_MLDSA #include #endif +#ifdef WOLFSPDM_HAVE_MLKEM + #include +#endif #if defined(LIBWOLFSSL_VERSION_HEX) && LIBWOLFSSL_VERSION_HEX < 0x05008004 /* wc_ForceZero added in wolfSSL v5.8.4; provide a stub for older releases. */ @@ -159,10 +162,22 @@ struct WOLFSPDM_CTX { byte slotMask; /* DIGESTS Param1: bit i = slot i populated */ byte currentSlotId; /* Slot the most recent GET_CERTIFICATE used */ - /* Ephemeral ECDHE key (generated for KEY_EXCHANGE) */ - ecc_key ephemeralKey; + /* Ephemeral key generated for KEY_EXCHANGE. kexType records the negotiated + * key-exchange method; only one union member is ever live. The ML-KEM key + * also holds the decapsulation key dk between request and response. */ + byte kexType; /* WOLFSPDM_KEX_ECDHE|_MLKEM */ + word16 kemAlgSel; /* Selected SPDM_KEM_ALGO_* (0 if ECDHE) */ + byte kexAdvDhe; /* Advertise the DHE group (default 1) */ + word16 kexAdvKem; /* ML-KEM mask to advertise (0 = none) */ + union { + ecc_key ecc; /* ECDHE secp384r1 (Algorithm Set B) */ +#ifdef WOLFSPDM_HAVE_MLKEM + MlKemKey mlkem; /* ML-KEM (DSP0274 1.4) */ +#endif + } ephemeralKey; - /* ECDH shared secret (P-384 X-coordinate = 48 bytes) */ + /* Key-exchange shared secret: ECDH P-384 X-coordinate (48 bytes) or an + * ML-KEM decapsulated shared secret (32 bytes); sharedSecretSz tracks which. */ byte sharedSecret[WOLFSPDM_ECC_KEY_SIZE]; word32 sharedSecretSz; @@ -273,6 +288,18 @@ static WC_INLINE void wolfSPDM_FreeResponderPubKey(WOLFSPDM_CTX* ctx) wc_ecc_free(&ctx->responderPubKey.ecc); } +/* Free whichever ephemeral key is live (union member by kexType). */ +static WC_INLINE void wolfSPDM_FreeEphemeralKey(WOLFSPDM_CTX* ctx) +{ +#ifdef WOLFSPDM_HAVE_MLKEM + if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { + wc_MlKemKey_Free(&ctx->ephemeralKey.mlkem); + return; + } +#endif + wc_ecc_free(&ctx->ephemeralKey.ecc); +} + /* --- Byte-Order Helpers --- */ static WC_INLINE void SPDM_Set16LE(byte* buf, word16 val) { @@ -409,6 +436,14 @@ int wolfSPDM_ExportEphemeralPubKey(WOLFSPDM_CTX* ctx, int wolfSPDM_ComputeSharedSecret(WOLFSPDM_CTX* ctx, const byte* peerPubKeyX, const byte* peerPubKeyY); +#ifdef WOLFSPDM_HAVE_MLKEM +/* Generate the ephemeral ML-KEM key pair and export the encapsulation key ek */ +int wolfSPDM_GenerateMlKemKey(WOLFSPDM_CTX* ctx, byte* ekOut, word32* ekOutSz); + +/* Decapsulate the responder's ciphertext c into ctx->sharedSecret (K') */ +int wolfSPDM_MlKemDecapsulate(WOLFSPDM_CTX* ctx, const byte* ct, word32 ctSz); +#endif + /* Generate random bytes */ int wolfSPDM_GetRandom(WOLFSPDM_CTX* ctx, byte* out, word32 outSz); diff --git a/src/spdm_msg.c b/src/spdm_msg.c index 48ed4a3..d1de196 100644 --- a/src/spdm_msg.c +++ b/src/spdm_msg.c @@ -69,14 +69,29 @@ int wolfSPDM_BuildGetCapabilities(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) int wolfSPDM_BuildNegotiateAlgorithms(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) { - SPDM_CHECK_BUILD_ARGS(ctx, buf, bufSz, 48); + word32 off; + byte numStructs = 0; + int advDhe; +#ifdef WOLFSPDM_HAVE_MLKEM + int advKem; +#endif + + /* Fixed header (32 bytes) + up to 5 AlgStructs (4 bytes each) = 52. */ + SPDM_CHECK_BUILD_ARGS(ctx, buf, bufSz, 52); + + advDhe = (ctx->kexAdvDhe != 0); +#ifdef WOLFSPDM_HAVE_MLKEM + advKem = (ctx->spdmVersion >= SPDM_VERSION_14 && ctx->kexAdvKem != 0); + if (!advDhe && !advKem) { + advDhe = 1; /* never advertise zero key-exchange methods */ + } +#else + advDhe = 1; +#endif - XMEMSET(buf, 0, 48); + XMEMSET(buf, 0, 52); buf[0] = ctx->spdmVersion; /* Use negotiated version */ buf[1] = SPDM_NEGOTIATE_ALGORITHMS; - buf[2] = 0x04; /* NumAlgoStructTables = 4 */ - buf[3] = 0x00; - buf[4] = 48; buf[5] = 0x00; /* Length = 48 */ buf[6] = 0x01; /* MeasurementSpecification = DMTF */ buf[7] = 0x02; /* OtherParamsSupport = MULTI_KEY_CONN */ @@ -97,17 +112,38 @@ int wolfSPDM_BuildNegotiateAlgorithms(WOLFSPDM_CTX* ctx, byte* buf, word32* bufS } #endif - /* Struct tables start at offset 32 */ - /* DHE: SECP_384_R1 */ - buf[32] = 0x02; buf[33] = 0x20; buf[34] = 0x10; buf[35] = 0x00; - /* AEAD: AES_256_GCM */ - buf[36] = 0x03; buf[37] = 0x20; buf[38] = 0x02; buf[39] = 0x00; - /* ReqBaseAsymAlg */ - buf[40] = 0x04; buf[41] = 0x20; buf[42] = 0x0F; buf[43] = 0x00; - /* KeySchedule */ - buf[44] = 0x05; buf[45] = 0x20; buf[46] = 0x01; buf[47] = 0x00; - - *bufSz = 48; + /* AlgStruct tables start at offset 32, emitted at a running offset so the + * key-exchange methods can be advertised independently (DHE, ML-KEM, or + * both). */ + off = 32; + if (advDhe) { + buf[off] = SPDM_ALG_TYPE_DHE; buf[off + 1] = 0x20; + SPDM_Set16LE(&buf[off + 2], SPDM_DHE_ALGO_SECP384R1); + off += 4; numStructs++; + } + buf[off] = SPDM_ALG_TYPE_AEAD; buf[off + 1] = 0x20; + SPDM_Set16LE(&buf[off + 2], SPDM_AEAD_ALGO_AES_256_GCM); + off += 4; numStructs++; + buf[off] = SPDM_ALG_TYPE_REQ_BASE_ASYM; buf[off + 1] = 0x20; + buf[off + 2] = 0x0F; + off += 4; numStructs++; + buf[off] = SPDM_ALG_TYPE_KEY_SCHEDULE; buf[off + 1] = 0x20; + buf[off + 2] = 0x01; + off += 4; numStructs++; +#ifdef WOLFSPDM_HAVE_MLKEM + /* DSP0274 1.4 Table 24 KEMAlg struct (AlgType 0x07). AlgCount 0x20 = + * 2-byte mask. No hybrid in 1.4: the responder selects DHE or a KEM. */ + if (advKem) { + buf[off] = SPDM_ALG_TYPE_KEM; buf[off + 1] = 0x20; + SPDM_Set16LE(&buf[off + 2], ctx->kexAdvKem); + off += 4; numStructs++; + } +#endif + + buf[2] = numStructs; /* NumAlgoStructTables */ + buf[4] = (byte)off; buf[5] = 0; /* Length */ + + *bufSz = off; return WOLFSPDM_SUCCESS; } @@ -151,21 +187,13 @@ int wolfSPDM_BuildKeyExchange(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) byte pubKeyY[WOLFSPDM_ECC_KEY_SIZE]; word32 pubKeyXSz = sizeof(pubKeyX); word32 pubKeyYSz = sizeof(pubKeyY); +#ifdef WOLFSPDM_HAVE_MLKEM + word32 ekSz = 0; +#endif int rc; SPDM_CHECK_BUILD_ARGS(ctx, buf, bufSz, 180); - rc = wolfSPDM_GenerateEphemeralKey(ctx); - if (rc != WOLFSPDM_SUCCESS) { - return rc; - } - - rc = wolfSPDM_ExportEphemeralPubKey(ctx, pubKeyX, &pubKeyXSz, - pubKeyY, &pubKeyYSz); - if (rc != WOLFSPDM_SUCCESS) { - return rc; - } - XMEMSET(buf, 0, *bufSz); /* Use negotiated SPDM version (not hardcoded 1.2) */ @@ -192,11 +220,35 @@ int wolfSPDM_BuildKeyExchange(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) } offset += WOLFSPDM_RANDOM_SIZE; - /* ExchangeData: X || Y */ - XMEMCPY(&buf[offset], pubKeyX, WOLFSPDM_ECC_KEY_SIZE); - offset += WOLFSPDM_ECC_KEY_SIZE; - XMEMCPY(&buf[offset], pubKeyY, WOLFSPDM_ECC_KEY_SIZE); - offset += WOLFSPDM_ECC_KEY_SIZE; + /* ExchangeData: the negotiated method's public value (DSP0274 1.4 Table 77). + * ML-KEM sends the encapsulation key ek; ECDHE sends X || Y. */ + rc = WOLFSPDM_SUCCESS; +#ifdef WOLFSPDM_HAVE_MLKEM + if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { + ekSz = *bufSz - offset; + rc = wolfSPDM_GenerateMlKemKey(ctx, &buf[offset], &ekSz); + if (rc == WOLFSPDM_SUCCESS) { + offset += ekSz; + } + } + else +#endif + if (ctx->kexType == WOLFSPDM_KEX_ECDHE) { + rc = wolfSPDM_GenerateEphemeralKey(ctx); + if (rc == WOLFSPDM_SUCCESS) { + rc = wolfSPDM_ExportEphemeralPubKey(ctx, pubKeyX, &pubKeyXSz, + pubKeyY, &pubKeyYSz); + } + if (rc == WOLFSPDM_SUCCESS) { + XMEMCPY(&buf[offset], pubKeyX, WOLFSPDM_ECC_KEY_SIZE); + offset += WOLFSPDM_ECC_KEY_SIZE; + XMEMCPY(&buf[offset], pubKeyY, WOLFSPDM_ECC_KEY_SIZE); + offset += WOLFSPDM_ECC_KEY_SIZE; + } + } + if (rc != WOLFSPDM_SUCCESS) { + return rc; + } /* OpaqueData for secured message version negotiation. DSP0277 v1.2 is * the current spec, defining secured-message versions 1.0, 1.1, 1.2. @@ -600,6 +652,7 @@ int wolfSPDM_ParseAlgorithms(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz) int dheOk = 0; int aeadOk = 0; int ksOk = 0; + int kemOk = 0; SPDM_CHECK_PARSE_OR_ERROR_ARGS(ctx, buf, bufSz, 36); SPDM_CHECK_RESPONSE(ctx, buf, bufSz, SPDM_ALGORITHMS, WOLFSPDM_E_ALGO_MISMATCH); @@ -733,13 +786,35 @@ int wolfSPDM_ParseAlgorithms(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz) word32 extLen = ((word32)(algCount & 0x0F)) * 4; switch (algType) { case SPDM_ALG_TYPE_DHE: - if (algSel != SPDM_DHE_ALGO_SECP384R1) { + /* algSel == 0 means the responder did not pick a DHE group + * (it selected a KEM instead); only a non-zero value must be + * the supported group. */ + if (algSel == SPDM_DHE_ALGO_SECP384R1) { + dheOk = 1; + } + else if (algSel != 0) { wolfSPDM_DebugPrint(ctx, "ALGORITHMS: DHE not SECP_384_R1 (0x%04x)\n", algSel); return WOLFSPDM_E_ALGO_MISMATCH; } - dheOk = 1; break; +#ifdef WOLFSPDM_HAVE_MLKEM + case SPDM_ALG_TYPE_KEM: + /* DSP0274 1.4 Table 24: the responder selected one ML-KEM set + * (or 0 if it picked DHE instead). */ + if (algSel == SPDM_KEM_ALGO_ML_KEM_512 || + algSel == SPDM_KEM_ALGO_ML_KEM_768 || + algSel == SPDM_KEM_ALGO_ML_KEM_1024) { + kemOk = 1; + ctx->kemAlgSel = algSel; + } + else if (algSel != 0) { + wolfSPDM_DebugPrint(ctx, + "ALGORITHMS: unsupported KEM (0x%04x)\n", algSel); + return WOLFSPDM_E_ALGO_MISMATCH; + } + break; +#endif case SPDM_ALG_TYPE_AEAD: if (algSel != SPDM_AEAD_ALGO_AES_256_GCM) { wolfSPDM_DebugPrint(ctx, @@ -760,12 +835,21 @@ int wolfSPDM_ParseAlgorithms(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz) } off += 4 + extLen; } - if (!dheOk || !aeadOk || !ksOk) { + if (!aeadOk || !ksOk) { wolfSPDM_DebugPrint(ctx, - "ALGORITHMS: missing required AlgStruct(s) dhe=%d aead=%d ks=%d\n", - dheOk, aeadOk, ksOk); + "ALGORITHMS: missing required AlgStruct(s) aead=%d ks=%d\n", + aeadOk, ksOk); return WOLFSPDM_E_ALGO_MISMATCH; } + /* Exactly one key-exchange method: a DHE group or a KEM, never both and + * never neither (DSP0274 1.4: no hybrid). */ + if (dheOk + kemOk != 1) { + wolfSPDM_DebugPrint(ctx, + "ALGORITHMS: key-exchange must be exactly one dhe=%d kem=%d\n", + dheOk, kemOk); + return WOLFSPDM_E_ALGO_MISMATCH; + } + ctx->kexType = kemOk ? WOLFSPDM_KEX_MLKEM : WOLFSPDM_KEX_ECDHE; wolfSPDM_DebugPrint(ctx, "ALGORITHMS: BaseAsym=0x%08x BaseHash=0x%08x\n", baseAsymAlgo, baseHashAlgo); @@ -834,10 +918,26 @@ int wolfSPDM_ParseCertificate(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz, return WOLFSPDM_SUCCESS; } +#ifdef WOLFSPDM_HAVE_MLKEM +/* Ciphertext size of the negotiated ML-KEM set (the KEY_EXCHANGE_RSP + * ExchangeData length). The ephemeral key is live between request and + * response, so query it directly. Returns 0 on failure. */ +static word32 wolfSPDM_GetKemCtSize(WOLFSPDM_CTX* ctx) +{ + word32 ctSz = 0; + if (wc_MlKemKey_CipherTextSize(&ctx->ephemeralKey.mlkem, &ctSz) != 0) { + return 0; + } + return ctSz; +} +#endif + int wolfSPDM_ParseKeyExchangeRsp(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz) { static const char sigCtx[] = "responder-key_exchange_rsp signing"; word16 opaqueLen; + word32 exDataLen; + word32 opaqueLenOff; word32 sigOffset; word32 keRspPartialLen; byte peerPubKeyX[WOLFSPDM_ECC_KEY_SIZE]; @@ -872,9 +972,24 @@ int wolfSPDM_ParseKeyExchangeRsp(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufS } /* Compute and validate the layout BEFORE committing any ctx fields so - * a truncated response doesn't leak partial sessionId/peer-key state. */ - opaqueLen = SPDM_Get16LE(&buf[136]); - sigOffset = 138 + opaqueLen; + * a truncated response doesn't leak partial sessionId/peer-key state. + * ExchangeData (offset 40) is ECDHE X||Y (96) or the ML-KEM ciphertext c; + * OpaqueData/signature/ResponderVerifyData follow it. */ + exDataLen = WOLFSPDM_ECC_POINT_SIZE; +#ifdef WOLFSPDM_HAVE_MLKEM + if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { + exDataLen = wolfSPDM_GetKemCtSize(ctx); + if (exDataLen == 0 || exDataLen > WOLFSPDM_MAX_KEM_CT_SIZE) { + return WOLFSPDM_E_CRYPTO_FAIL; + } + } +#endif + opaqueLenOff = 40 + exDataLen; + if (bufSz < opaqueLenOff + 2u) { + return WOLFSPDM_E_BUFFER_SMALL; + } + opaqueLen = SPDM_Get16LE(&buf[opaqueLenOff]); + sigOffset = opaqueLenOff + 2u + opaqueLen; keRspPartialLen = sigOffset; if (bufSz < sigOffset + sigSize + WOLFSPDM_HASH_SIZE) { @@ -927,8 +1042,18 @@ int wolfSPDM_ParseKeyExchangeRsp(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufS goto cleanup; } - /* Compute ECDH shared secret */ - rc = wolfSPDM_ComputeSharedSecret(ctx, peerPubKeyX, peerPubKeyY); + /* Derive the key-exchange shared secret: ML-KEM decapsulation of the + * responder's ciphertext c (ExchangeData), or ECDH from the peer point. */ + rc = WOLFSPDM_SUCCESS; +#ifdef WOLFSPDM_HAVE_MLKEM + if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { + rc = wolfSPDM_MlKemDecapsulate(ctx, &buf[40], exDataLen); + } + else +#endif + if (ctx->kexType == WOLFSPDM_KEX_ECDHE) { + rc = wolfSPDM_ComputeSharedSecret(ctx, peerPubKeyX, peerPubKeyY); + } if (rc != WOLFSPDM_SUCCESS) { goto cleanup; } diff --git a/src/spdm_session.c b/src/spdm_session.c index e91ae1a..99ef999 100644 --- a/src/spdm_session.c +++ b/src/spdm_session.c @@ -274,7 +274,7 @@ int wolfSPDM_GetCertificate(WOLFSPDM_CTX* ctx, int slotId) int wolfSPDM_KeyExchange(WOLFSPDM_CTX* ctx) { - byte txBuf[192]; /* KEY_EXCHANGE: ~158 bytes */ + byte txBuf[WOLFSPDM_KEX_REQ_BUF]; /* KEY_EXCHANGE: ~158 B / ML-KEM ek */ byte rxBuf[WOLFSPDM_SIG_RSP_BUF]; /* KEY_EXCHANGE_RSP (ECDSA ~302 / ML-DSA) */ word32 txSz = sizeof(txBuf); word32 rxSz = sizeof(rxBuf); diff --git a/test/unit_test.c b/test/unit_test.c index c389bd2..180225c 100644 --- a/test/unit_test.c +++ b/test/unit_test.c @@ -657,6 +657,146 @@ static int test_challenge_auth_mldsa_sigsize(void) #endif /* !NO_WOLFSPDM_CHALLENGE */ #endif /* WOLFSPDM_HAVE_MLDSA */ +#ifdef WOLFSPDM_HAVE_MLKEM +static int test_negotiate_algorithms_kem_build(void) +{ + byte buf[64]; + word32 bufSz; + TEST_CTX_SETUP(); + + printf("test_negotiate_algorithms_kem_build...\n"); + + /* SPDM 1.4: a 5th KEMAlg struct (AlgType 0x07) at offset 48 advertises + * ML-KEM 512|768|1024 = 0x07, NumAlgoStructTables = 5, Length = 52. */ + ctx->spdmVersion = SPDM_VERSION_14; + bufSz = sizeof(buf); + ASSERT_SUCCESS(wolfSPDM_BuildNegotiateAlgorithms(ctx, buf, &bufSz)); + ASSERT_EQ(bufSz, 52, "1.4 NEGOTIATE_ALGORITHMS with KEM is 52 bytes"); + ASSERT_EQ(buf[2], 0x05, "NumAlgoStructTables = 5"); + ASSERT_EQ(buf[48], SPDM_ALG_TYPE_KEM, "KEMAlg AlgType 0x07 at offset 48"); + ASSERT_EQ(buf[50], + (SPDM_KEM_ALGO_ML_KEM_512 | SPDM_KEM_ALGO_ML_KEM_768 | + SPDM_KEM_ALGO_ML_KEM_1024), + "KEMAlg advertises ML-KEM 512/768/1024"); + ASSERT_EQ(buf[32], SPDM_ALG_TYPE_DHE, "DHE struct still present (dual-stack)"); + + /* SPDM 1.2: no KEMAlg struct, message stays 48 bytes. */ + ctx->spdmVersion = SPDM_VERSION_12; + bufSz = sizeof(buf); + ASSERT_SUCCESS(wolfSPDM_BuildNegotiateAlgorithms(ctx, buf, &bufSz)); + ASSERT_EQ(bufSz, 48, "1.2 NEGOTIATE_ALGORITHMS stays 48 bytes"); + ASSERT_EQ(buf[2], 0x04, "1.2 NumAlgoStructTables = 4"); + + TEST_CTX_FREE(); + TEST_PASS(); +} + +/* Build a 1.4 ALGORITHMS response with a 5th KEMAlg struct. dheSel is the DHE + * selection (0 = not selected) and kemSel the ML-KEM selection; the caller picks + * the mutual-exclusivity case. Returns total length (56). */ +static word32 build_algorithms_14_kem(byte* rsp, word16 dheSel, word16 kemSel) +{ + XMEMSET(rsp, 0, 56); + rsp[0] = SPDM_VERSION_14; + rsp[1] = SPDM_ALGORITHMS; + rsp[2] = 5; /* AlgStructCount = 5 */ + SPDM_Set16LE(&rsp[4], 56); + rsp[6] = 0x01; + rsp[7] = 0x02; + SPDM_Set32LE(&rsp[12], SPDM_ASYM_ALGO_ECDSA_P384); + rsp[16] = SPDM_HASH_ALGO_SHA_384; + rsp[36] = 2; rsp[37] = 0x20; SPDM_Set16LE(&rsp[38], dheSel); /* DHE */ + rsp[40] = 3; rsp[41] = 0x20; rsp[42] = 0x02; /* AEAD */ + rsp[44] = 4; rsp[45] = 0x20; rsp[46] = 0x0F; /* ReqBaseAsym */ + rsp[48] = 5; rsp[49] = 0x20; rsp[50] = 0x01; /* KeySchedule */ + rsp[52] = SPDM_ALG_TYPE_KEM; rsp[53] = 0x20; /* KEMAlg */ + SPDM_Set16LE(&rsp[54], kemSel); + return 56; +} + +static int test_parse_algorithms_kem_select(void) +{ + byte rsp[72]; + word32 len; + TEST_CTX_SETUP(); + + printf("test_parse_algorithms_kem_select...\n"); + + /* Responder selects ML-KEM-768 (DHE = 0): kexType becomes MLKEM. */ + len = build_algorithms_14_kem(rsp, 0, SPDM_KEM_ALGO_ML_KEM_768); + ASSERT_SUCCESS(wolfSPDM_ParseAlgorithms(ctx, rsp, len)); + ASSERT_EQ(ctx->kexType, WOLFSPDM_KEX_MLKEM, "kexType MLKEM on KEM select"); + ASSERT_EQ(ctx->kemAlgSel, SPDM_KEM_ALGO_ML_KEM_768, "kemAlgSel = ML-KEM-768"); + + /* Responder selects DHE (KEM = 0): kexType stays ECDHE. */ + len = build_algorithms_14_kem(rsp, SPDM_DHE_ALGO_SECP384R1, 0); + ASSERT_SUCCESS(wolfSPDM_ParseAlgorithms(ctx, rsp, len)); + ASSERT_EQ(ctx->kexType, WOLFSPDM_KEX_ECDHE, "kexType ECDHE on DHE select"); + + /* Both DHE and KEM selected: no hybrid in 1.4, must be rejected. */ + len = build_algorithms_14_kem(rsp, SPDM_DHE_ALGO_SECP384R1, + SPDM_KEM_ALGO_ML_KEM_768); + ASSERT_EQ(wolfSPDM_ParseAlgorithms(ctx, rsp, len), + WOLFSPDM_E_ALGO_MISMATCH, "DHE+KEM (hybrid) must be rejected"); + + /* Neither selected: must be rejected. */ + len = build_algorithms_14_kem(rsp, 0, 0); + ASSERT_EQ(wolfSPDM_ParseAlgorithms(ctx, rsp, len), + WOLFSPDM_E_ALGO_MISMATCH, "no key-exchange method must be rejected"); + + /* Unsupported KEM bit: must be rejected. */ + len = build_algorithms_14_kem(rsp, 0, 0x0008); + ASSERT_EQ(wolfSPDM_ParseAlgorithms(ctx, rsp, len), + WOLFSPDM_E_ALGO_MISMATCH, "unsupported KEM must be rejected"); + + TEST_CTX_FREE(); + TEST_PASS(); +} + +static int test_mlkem_decapsulate(void) +{ + byte ek[WOLFSPDM_MLKEM768_EK_SIZE]; + byte ct[WOLFSPDM_MLKEM768_CT_SIZE]; + byte ssResponder[WOLFSPDM_KEM_SS_SIZE]; + word32 ekSz = sizeof(ek); + MlKemKey rspKey; + int rspKeyInit = 0; + int rc; + TEST_CTX_SETUP(); + + printf("test_mlkem_decapsulate...\n"); + + /* Requester generates the ephemeral ML-KEM-768 key and exports ek. */ + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_768; + ASSERT_SUCCESS(wolfSPDM_GenerateMlKemKey(ctx, ek, &ekSz)); + ASSERT_EQ(ekSz, WOLFSPDM_MLKEM768_EK_SIZE, "ML-KEM-768 ek is 1184 bytes"); + ASSERT_EQ(ctx->kexType, WOLFSPDM_KEX_MLKEM, "kexType set to MLKEM"); + + /* Responder side: import ek, encapsulate -> ciphertext c + shared secret K. */ + ASSERT_EQ(wc_MlKemKey_Init(&rspKey, WC_ML_KEM_768, NULL, INVALID_DEVID), 0, + "responder MlKemKey_Init"); + rspKeyInit = 1; + ASSERT_EQ(wc_MlKemKey_DecodePublicKey(&rspKey, ek, ekSz), 0, + "responder decode ek"); + ASSERT_EQ(wc_MlKemKey_Encapsulate(&rspKey, ct, ssResponder, &ctx->rng), 0, + "responder encapsulate"); + + /* Requester decapsulates c -> K'; K' must equal the responder's K. */ + rc = wolfSPDM_MlKemDecapsulate(ctx, ct, sizeof(ct)); + ASSERT_SUCCESS(rc); + ASSERT_EQ(ctx->sharedSecretSz, WOLFSPDM_KEM_SS_SIZE, + "decapsulated secret is 32 bytes"); + ASSERT_EQ(XMEMCMP(ctx->sharedSecret, ssResponder, WOLFSPDM_KEM_SS_SIZE), 0, + "K' (decapsulation) equals K (encapsulation)"); + + if (rspKeyInit) { + wc_MlKemKey_Free(&rspKey); + } + TEST_CTX_FREE(); + TEST_PASS(); +} +#endif /* WOLFSPDM_HAVE_MLKEM */ + #ifdef WOLFSPDM_HAVE_CHUNK #define CHUNK_TEST_MAX 4627 /* ML-DSA-87 SigLen — largest we reassemble */ #define CHUNK_NONE 0xFFFFFFFFu @@ -2680,6 +2820,11 @@ int main(void) test_challenge_auth_mldsa_sigsize(); #endif #endif +#ifdef WOLFSPDM_HAVE_MLKEM + test_negotiate_algorithms_kem_build(); + test_parse_algorithms_kem_select(); + test_mlkem_decapsulate(); +#endif #ifdef WOLFSPDM_HAVE_CHUNK test_chunk_reassemble(); test_chunk_reassemble_secured(); diff --git a/wolfspdm/spdm.h b/wolfspdm/spdm.h index 3baef83..ac36575 100644 --- a/wolfspdm/spdm.h +++ b/wolfspdm/spdm.h @@ -208,6 +208,21 @@ WOLFSPDM_API int wolfSPDM_SetIO(WOLFSPDM_CTX* ctx, WOLFSPDM_IO_CB ioCb, void* us */ WOLFSPDM_API int wolfSPDM_SetMaxVersion(WOLFSPDM_CTX* ctx, byte maxVersion); +/** + * Choose which key-exchange methods NEGOTIATE_ALGORITHMS advertises (SPDM 1.4). + * Default is dual-stack: the DHE group and all ML-KEM sets, letting the responder + * select. Pass advDhe=0 with a single SPDM_KEM_ALGO_ML_KEM_* in kemMask to force + * the responder onto a specific ML-KEM set (e.g. for PQC-only interop). ML-KEM is + * only advertised at SPDM 1.4+ and when built with ML-KEM support. + * + * @param ctx The wolfSPDM context. + * @param advDhe Non-zero to advertise the classical DHE group. + * @param kemMask Bit mask of SPDM_KEM_ALGO_ML_KEM_* sets to advertise (0 = none). + * @return WOLFSPDM_SUCCESS or negative error code. + */ +WOLFSPDM_API int wolfSPDM_SetKeyExchangePref(WOLFSPDM_CTX* ctx, int advDhe, + word16 kemMask); + /** * Pin the requester session ID used during KEY_EXCHANGE. * Default behavior is to draw a random non-reserved value during Connect(). diff --git a/wolfspdm/spdm_types.h b/wolfspdm/spdm_types.h index 405305e..193ee8f 100644 --- a/wolfspdm/spdm_types.h +++ b/wolfspdm/spdm_types.h @@ -60,6 +60,14 @@ extern "C" { #endif #endif +/* ML-KEM (FIPS 203) key exchange follows the linked wolfSSL: on when it + * reports WOLFSSL_HAVE_MLKEM unless WOLFSPDM_NO_MLKEM is defined. */ +#if defined(WOLFSSL_HAVE_MLKEM) && !defined(WOLFSPDM_NO_MLKEM) + #ifndef WOLFSPDM_HAVE_MLKEM + #define WOLFSPDM_HAVE_MLKEM + #endif +#endif + /* --- SPDM Protocol Constants (DMTF DSP0274 / DSP0277) --- */ /* SPDM Version Numbers */ @@ -157,6 +165,12 @@ extern "C" { /* DHE (Diffie-Hellman Ephemeral) Algorithms */ #define SPDM_DHE_ALGO_SECP384R1 0x0010 /* secp384r1 */ +/* KEM Algorithms (DSP0274 1.4 Table 24 KEMAlg AlgSupported). Byte 0 bit mask; + * one selected, mutually exclusive with a DHE group (no hybrid in 1.4). */ +#define SPDM_KEM_ALGO_ML_KEM_512 0x0001 /* ML-KEM-512 */ +#define SPDM_KEM_ALGO_ML_KEM_768 0x0002 /* ML-KEM-768 */ +#define SPDM_KEM_ALGO_ML_KEM_1024 0x0004 /* ML-KEM-1024 */ + /* AEAD Algorithms */ #define SPDM_AEAD_ALGO_AES_256_GCM 0x0002 /* AES-256-GCM */ @@ -175,6 +189,10 @@ extern "C" { #define WOLFSPDM_ASYM_ECDSA 0 /* BaseAsymSel = ECDSA P-384 */ #define WOLFSPDM_ASYM_MLDSA 1 /* PqcAsymSel = ML-DSA */ +/* Which key-exchange method the responder selected (ctx->kexType) */ +#define WOLFSPDM_KEX_ECDHE 0 /* DHE group = secp384r1 */ +#define WOLFSPDM_KEX_MLKEM 1 /* KEM = ML-KEM */ + /* Algorithm Set B Fixed Parameters */ #define WOLFSPDM_HASH_SIZE 48 /* SHA-384 output size */ #define WOLFSPDM_ECC_KEY_SIZE 48 /* P-384 coordinate size */ @@ -196,6 +214,25 @@ extern "C" { #define WOLFSPDM_MAX_SIG_SIZE WOLFSPDM_ECC_SIG_SIZE #endif +#ifdef WOLFSPDM_HAVE_MLKEM +/* ML-KEM sizes (FIPS 203). The KEY_EXCHANGE ExchangeData carries the + * encapsulation key ek; the KEY_EXCHANGE_RSP ExchangeData carries the ciphertext + * c; decapsulation yields a 32-byte shared secret. */ +#define WOLFSPDM_MLKEM512_EK_SIZE 800 +#define WOLFSPDM_MLKEM512_CT_SIZE 768 +#define WOLFSPDM_MLKEM768_EK_SIZE 1184 +#define WOLFSPDM_MLKEM768_CT_SIZE 1088 +#define WOLFSPDM_MLKEM1024_EK_SIZE 1568 +#define WOLFSPDM_MLKEM1024_CT_SIZE 1568 +#define WOLFSPDM_KEM_SS_SIZE 32 +#define WOLFSPDM_MAX_KEM_EK_SIZE WOLFSPDM_MLKEM1024_EK_SIZE +#define WOLFSPDM_MAX_KEM_CT_SIZE WOLFSPDM_MLKEM1024_CT_SIZE +/* KEY_EXCHANGE request buffer: fixed fields + the largest ek + OpaqueData. */ +#define WOLFSPDM_KEX_REQ_BUF (96 + WOLFSPDM_MAX_KEM_EK_SIZE) +#else +#define WOLFSPDM_KEX_REQ_BUF 192 +#endif + /* Receive-buffer size for the signature-bearing responses (KEY_EXCHANGE_RSP, * CHALLENGE_AUTH). Sized for fixed fields + a small OpaqueData block + the * negotiated SigLen + HMAC, NOT the full advertised DataTransferSize: like the @@ -203,8 +240,16 @@ extern "C" { * responders keep OpaqueData in these two responses small. These are on-stack * buffers in wolfSPDM_KeyExchange / wolfSPDM_Challenge, so the ML-DSA value * (~5.3 KB) is the per-call stack cost on constrained targets. */ -#ifdef WOLFSPDM_HAVE_MLDSA +#if defined(WOLFSPDM_HAVE_MLDSA) && defined(WOLFSPDM_HAVE_MLKEM) +/* ML-KEM ciphertext c (up to 1568 B) replaces the 96-byte ECDHE point in the + * KEY_EXCHANGE_RSP ExchangeData, so add headroom for it alongside the SigLen. */ +#define WOLFSPDM_SIG_RSP_BUF (640 + WOLFSPDM_MAX_SIG_SIZE + \ + WOLFSPDM_MAX_KEM_CT_SIZE) +#elif defined(WOLFSPDM_HAVE_MLDSA) #define WOLFSPDM_SIG_RSP_BUF (640 + WOLFSPDM_MAX_SIG_SIZE) +#elif defined(WOLFSPDM_HAVE_MLKEM) +#define WOLFSPDM_SIG_RSP_BUF (640 + WOLFSPDM_MAX_SIG_SIZE + \ + WOLFSPDM_MAX_KEM_CT_SIZE) #else #define WOLFSPDM_SIG_RSP_BUF 512 #endif From ed2c93526ca0d8d7048686a46f29e0bb63599c43 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 08:41:10 -0700 Subject: [PATCH 2/9] CI: add full-PQ interop leg (ML-KEM-768 + ML-DSA 65/87, exercises chunking) --- .github/workflows/spdm-emu-pqc-test.yml | 71 +++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.github/workflows/spdm-emu-pqc-test.yml b/.github/workflows/spdm-emu-pqc-test.yml index 42e08d6..6419ea1 100644 --- a/.github/workflows/spdm-emu-pqc-test.yml +++ b/.github/workflows/spdm-emu-pqc-test.yml @@ -261,6 +261,77 @@ jobs: fi echo "All ML-KEM 512/768/1024 interop cases passed." + # --- Full post-quantum handshake: ML-KEM-768 key exchange + ML-DSA + # signing together, no classical asymmetric crypto. ML-DSA-65 fits one + # message; ML-DSA-87 (sig 4627 B) + ciphertext c (1088 B) exceeds the + # DataTransferSize, so this case also exercises CHUNK_GET reassembly - + # ML-KEM + ML-DSA + chunking in a single handshake. + - name: Full PQ (ML-KEM-768 + ML-DSA 65/87) session + measurements + challenge + run: | + export LD_LIBRARY_PATH=$HOME/wolfspdm-install/lib:$HOME/wolfssl-install/lib + export SPDM_EMU_PATH=$HOME/spdm-emu/build/bin + DEMO=./examples/spdm_demo + FAILURES="" + + wait_for_port() { + local i + for i in $(seq 1 50); do + if ss -ltn 2>/dev/null | grep -q ':2323 '; then return 0; fi + sleep 0.2 + done + return 1 + } + + # $1 pqc_asym (ML_DSA_xx), $2 cert dir, $3 label, then demo args. KEM is + # ML-KEM-768 throughout; the requester forces it with --kex mlkem768. + run_pq() { + local pqc="$1" certdir="$2" label="$3"; shift 3 + export SPDM_EMU_CERT_DIR="$certdir" + echo "::group::$pqc+ML_KEM_768 $label" + local attempt rc=1 emu + for attempt in 1 2 3; do + ( cd "$SPDM_EMU_PATH" && ./spdm_responder_emu --ver 1.4 \ + --hash SHA_384 --asym NONE --pqc_asym "$pqc" \ + --dhe NONE --kem ML_KEM_768 --aead AES_256_GCM \ + >/tmp/pqc_emu_${pqc}_kem_${label}.log 2>&1 ) & + emu=$! + if wait_for_port; then + sleep 1 + if "$DEMO" "$@" --ver 1.4 --kex mlkem768 --debug; then rc=0; else rc=$?; fi + else + rc=1 + fi + kill $emu 2>/dev/null || true + wait $emu 2>/dev/null || true + [ $rc -eq 0 ] && break + echo "--- responder log (attempt $attempt) ---" + cat /tmp/pqc_emu_${pqc}_kem_${label}.log || true + echo "attempt $attempt for $pqc+KEM $label failed (rc=$rc), retrying" + sleep 1 + done + echo "::endgroup::" + if [ $rc -ne 0 ]; then + echo "::error::$pqc+ML_KEM_768 $label failed after retries (rc=$rc)" + FAILURES="$FAILURES $pqc+kem/$label" + else + echo "$pqc+ML_KEM_768 $label: OK" + fi + } + + for spec in "ML_DSA_65 mldsa65" "ML_DSA_87 mldsa87"; do + set -- $spec + pqc="$1"; dir="$2" + run_pq "$pqc" "$dir" session --emu + run_pq "$pqc" "$dir" meas --meas + run_pq "$pqc" "$dir" challenge --challenge + done + + if [ -n "$FAILURES" ]; then + echo "::error::Full-PQ interop failures:$FAILURES" + exit 1 + fi + echo "All full-PQ (ML-KEM + ML-DSA) interop cases passed." + - name: Upload logs on failure if: failure() uses: actions/upload-artifact@v4 From d00a06b40a561c06be38a45859c8ebee925a8869 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 08:58:17 -0700 Subject: [PATCH 3/9] Address skoll: fix type-confused ephemeral-key free on KEX switch, cppcheck redundant assign, KEY_EXCHANGE/reconnect tests, rename PQC workflow --- .github/workflows/spdm-emu-pqc-test.yml | 17 +++--- src/spdm_context.c | 3 + src/spdm_msg.c | 21 +++++-- test/unit_test.c | 75 +++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 14 deletions(-) diff --git a/.github/workflows/spdm-emu-pqc-test.yml b/.github/workflows/spdm-emu-pqc-test.yml index 6419ea1..2ba920c 100644 --- a/.github/workflows/spdm-emu-pqc-test.yml +++ b/.github/workflows/spdm-emu-pqc-test.yml @@ -1,11 +1,12 @@ -name: SPDM Emulator PQC (ML-DSA) Test +name: SPDM Emulator PQC Test -# Post-quantum interop, mirroring the classical SPDM Emulator Integration Test -# matrix (ubuntu 22.04/24.04 x64 + 24.04 aarch64, each with static and dynamic -# memory). The wc_MlDsaKey context API wolfSPDM verifies with lands -# post-v5.9.1-stable, so this job pins wolfSSL master. libspdm's ML-DSA is only -# in its OpenSSL backend (the mbedtls backend stubs it out), so spdm-emu is -# built with CRYPTO=openssl. +# Post-quantum interop (ML-DSA signing + ML-KEM key exchange, and a full-PQ +# combination), mirroring the classical SPDM Emulator Integration Test matrix +# (ubuntu 22.04/24.04 x64 + 24.04 aarch64, each with static and dynamic memory). +# The wc_MlDsaKey context API wolfSPDM verifies with lands post-v5.9.1-stable, +# so this job pins wolfSSL master. libspdm's ML-DSA/ML-KEM are only in its +# OpenSSL backend (the mbedtls backend stubs them out), so spdm-emu is built +# with CRYPTO=openssl. on: push: @@ -85,7 +86,7 @@ jobs: make -j"$(nproc)" make install - - name: Run unit tests (includes ML-DSA verify) + - name: Run unit tests (includes ML-DSA verify + ML-KEM decap) run: make check env: LD_LIBRARY_PATH: ${{ github.workspace }}/.libs:${{ github.workspace }}/src/.libs:${{ env.HOME }}/wolfssl-install/lib diff --git a/src/spdm_context.c b/src/spdm_context.c index 93f6626..addb158 100644 --- a/src/spdm_context.c +++ b/src/spdm_context.c @@ -300,6 +300,9 @@ int wolfSPDM_SetKeyExchangePref(WOLFSPDM_CTX* ctx, int advDhe, word16 kemMask) if (ctx == NULL) { return WOLFSPDM_E_INVALID_ARG; } + if (advDhe == 0 && kemMask == 0) { + return WOLFSPDM_E_INVALID_ARG; /* must advertise at least one method */ + } #ifndef WOLFSPDM_HAVE_MLKEM if (kemMask != 0) { return WOLFSPDM_E_INVALID_ARG; /* ML-KEM not built in */ diff --git a/src/spdm_msg.c b/src/spdm_msg.c index d1de196..a919a58 100644 --- a/src/spdm_msg.c +++ b/src/spdm_msg.c @@ -79,14 +79,14 @@ int wolfSPDM_BuildNegotiateAlgorithms(WOLFSPDM_CTX* ctx, byte* buf, word32* bufS /* Fixed header (32 bytes) + up to 5 AlgStructs (4 bytes each) = 52. */ SPDM_CHECK_BUILD_ARGS(ctx, buf, bufSz, 52); - advDhe = (ctx->kexAdvDhe != 0); #ifdef WOLFSPDM_HAVE_MLKEM + advDhe = (ctx->kexAdvDhe != 0); advKem = (ctx->spdmVersion >= SPDM_VERSION_14 && ctx->kexAdvKem != 0); if (!advDhe && !advKem) { advDhe = 1; /* never advertise zero key-exchange methods */ } #else - advDhe = 1; + advDhe = 1; /* DHE is the only key-exchange method without ML-KEM */ #endif XMEMSET(buf, 0, 52); @@ -704,6 +704,16 @@ int wolfSPDM_ParseAlgorithms(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz) ctx->flags.hasResponderPubKey = 0; } + /* Same hazard for the ephemeral key union: a prior handshake may have left a + * key live whose union member is named by the OLD ctx->kexType. Free it now, + * before kexType is reassigned below, so the free dispatches on the matching + * member (otherwise a reconnect that switches DHE<->ML-KEM would type-confuse + * the free in wolfSPDM_FreeEphemeralKey). */ + if (ctx->flags.ephemeralKeyInit) { + wolfSPDM_FreeEphemeralKey(ctx); + ctx->flags.ephemeralKeyInit = 0; + } + /* DSP0274 1.4 Table 20: PqcAsymSel (offset 20). Present from 1.4; earlier * versions leave these bytes reserved-zero. The spec caps the combined * bit count of BaseAsymSel and PqcAsymSel at one, so exactly one of the @@ -1000,10 +1010,6 @@ int wolfSPDM_ParseKeyExchangeRsp(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufS ctx->rspSessionId = SPDM_Get16LE(&buf[4]); ctx->sessionId = (word32)ctx->reqSessionId | ((word32)ctx->rspSessionId << 16); - /* Extract responder's ephemeral public key (offset 40 = 4+2+1+1+32) */ - XMEMCPY(peerPubKeyX, &buf[40], WOLFSPDM_ECC_KEY_SIZE); - XMEMCPY(peerPubKeyY, &buf[88], WOLFSPDM_ECC_KEY_SIZE); - signature = buf + sigOffset; rspVerifyData = buf + sigOffset + sigSize; @@ -1052,6 +1058,9 @@ int wolfSPDM_ParseKeyExchangeRsp(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufS else #endif if (ctx->kexType == WOLFSPDM_KEX_ECDHE) { + /* ExchangeData is the responder's ephemeral point X||Y at offset 40. */ + XMEMCPY(peerPubKeyX, &buf[40], WOLFSPDM_ECC_KEY_SIZE); + XMEMCPY(peerPubKeyY, &buf[88], WOLFSPDM_ECC_KEY_SIZE); rc = wolfSPDM_ComputeSharedSecret(ctx, peerPubKeyX, peerPubKeyY); } if (rc != WOLFSPDM_SUCCESS) { diff --git a/test/unit_test.c b/test/unit_test.c index 180225c..7e39fa5 100644 --- a/test/unit_test.c +++ b/test/unit_test.c @@ -795,6 +795,79 @@ static int test_mlkem_decapsulate(void) TEST_CTX_FREE(); TEST_PASS(); } + +/* Reconnect on a reused context switching key-exchange method must free the + * prior ephemeral key while ctx->kexType still names its union member (no + * type-confused free). Run under valgrind in CI to catch a mismatched free. */ +static int test_kex_reconnect_method_switch(void) +{ + byte ek[WOLFSPDM_MLKEM768_EK_SIZE]; + byte rsp[72]; + word32 ekSz = sizeof(ek); + word32 len; + TEST_CTX_SETUP(); + + printf("test_kex_reconnect_method_switch...\n"); + ctx->spdmVersion = SPDM_VERSION_14; + + /* Round 1 leaves a live ML-KEM ephemeral key. */ + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_768; + ASSERT_SUCCESS(wolfSPDM_GenerateMlKemKey(ctx, ek, &ekSz)); + ASSERT_EQ(ctx->flags.ephemeralKeyInit, 1, "ML-KEM key live"); + ASSERT_EQ(ctx->kexType, WOLFSPDM_KEX_MLKEM, "kexType MLKEM"); + + /* Reconnect negotiates ECDHE: ParseAlgorithms frees the live ML-KEM key + * (still matching kexType) before flipping to ECDHE. */ + len = build_algorithms_14_kem(rsp, SPDM_DHE_ALGO_SECP384R1, 0); + ASSERT_SUCCESS(wolfSPDM_ParseAlgorithms(ctx, rsp, len)); + ASSERT_EQ(ctx->kexType, WOLFSPDM_KEX_ECDHE, "switched to ECDHE"); + ASSERT_EQ(ctx->flags.ephemeralKeyInit, 0, "stale ML-KEM key freed"); + + /* And the reverse: a live ECDHE key, then a reconnect negotiating ML-KEM. */ + ASSERT_SUCCESS(wolfSPDM_GenerateEphemeralKey(ctx)); + ASSERT_EQ(ctx->flags.ephemeralKeyInit, 1, "ECDHE key live"); + len = build_algorithms_14_kem(rsp, 0, SPDM_KEM_ALGO_ML_KEM_768); + ASSERT_SUCCESS(wolfSPDM_ParseAlgorithms(ctx, rsp, len)); + ASSERT_EQ(ctx->kexType, WOLFSPDM_KEX_MLKEM, "switched to ML-KEM"); + ASSERT_EQ(ctx->flags.ephemeralKeyInit, 0, "stale ECDHE key freed"); + + TEST_CTX_FREE(); + TEST_PASS(); +} + +/* KEY_EXCHANGE with ML-KEM places the encapsulation key ek as ExchangeData at + * offset 40; confirm it decodes as a valid ML-KEM-768 public key. */ +static int test_build_key_exchange_mlkem(void) +{ + byte buf[WOLFSPDM_KEX_REQ_BUF]; + word32 bufSz = sizeof(buf); + MlKemKey check; + int checkInit = 0; + TEST_CTX_SETUP(); + + printf("test_build_key_exchange_mlkem...\n"); + ctx->spdmVersion = SPDM_VERSION_14; + ctx->kexType = WOLFSPDM_KEX_MLKEM; + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_768; + + ASSERT_SUCCESS(wolfSPDM_BuildKeyExchange(ctx, buf, &bufSz)); + ASSERT_EQ(buf[1], SPDM_KEY_EXCHANGE, "KEY_EXCHANGE code"); + /* offset 40 = 4 hdr + 2 sessionId + 2 policy/rsvd + 32 random. */ + ASSERT_EQ(wc_MlKemKey_Init(&check, WC_ML_KEM_768, NULL, INVALID_DEVID), 0, + "MlKemKey_Init"); + checkInit = 1; + ASSERT_EQ(wc_MlKemKey_DecodePublicKey(&check, &buf[40], + WOLFSPDM_MLKEM768_EK_SIZE), 0, "ek decodes at offset 40"); + /* 40 fixed + 1184 ek + 22 OpaqueData block. */ + ASSERT_EQ(bufSz, 40u + WOLFSPDM_MLKEM768_EK_SIZE + 22u, + "ML-KEM KEY_EXCHANGE total size"); + + if (checkInit) { + wc_MlKemKey_Free(&check); + } + TEST_CTX_FREE(); + TEST_PASS(); +} #endif /* WOLFSPDM_HAVE_MLKEM */ #ifdef WOLFSPDM_HAVE_CHUNK @@ -2824,6 +2897,8 @@ int main(void) test_negotiate_algorithms_kem_build(); test_parse_algorithms_kem_select(); test_mlkem_decapsulate(); + test_kex_reconnect_method_switch(); + test_build_key_exchange_mlkem(); #endif #ifdef WOLFSPDM_HAVE_CHUNK test_chunk_reassemble(); From 99bd13394d7e864e0e78ce0a0aa3bafd790a5ea2 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 09:00:21 -0700 Subject: [PATCH 4/9] Fail fast when ML-KEM KEY_EXCHANGE exceeds responder DataTransferSize (no CHUNK_SEND) --- docs/Message-Chunking.md | 23 +++++++++++++++-------- src/spdm_session.c | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/Message-Chunking.md b/docs/Message-Chunking.md index 96791b6..79cfb41 100644 --- a/docs/Message-Chunking.md +++ b/docs/Message-Chunking.md @@ -79,14 +79,21 @@ The mechanism has two directions, controlled by different endpoints: the requester decides to chunk its own outbound request; a responder cannot force it. -Every request wolfSPDM builds (GET_VERSION through GET_MEASUREMENTS, -KEY_EXCHANGE, FINISH) is small and fixed, well under any responder's -`DataTransferSize`, so `CHUNK_SEND` is never needed — it is not applicable to -this library's traffic rather than missing. `CHUNK_CAP` advertises support for -the large-message mechanism; it does not obligate an endpoint to chunk requests -it never sends, so a requester that only emits small requests is fully -conformant supporting `CHUNK_GET` alone. `CHUNK_SEND` / `CHUNK_SEND_ACK` are -defined in `spdm_types.h` for completeness but intentionally unimplemented. +Almost every request wolfSPDM builds (GET_VERSION through GET_MEASUREMENTS, +FINISH) is small and fixed, well under any responder's `DataTransferSize`. The +one exception is an **ML-KEM** KEY_EXCHANGE, whose `ExchangeData` carries the +encapsulation key `ek` (800/1184/1568 B for ML-KEM-512/768/1024) — a request of +~870–1640 B. This still fits common responders (e.g. spdm-emu advertises +4608 B), but a constrained responder could advertise a smaller +`DataTransferSize`. Because `CHUNK_SEND` is unimplemented, `wolfSPDM_KeyExchange` +**fails fast** (`WOLFSPDM_E_BUFFER_SMALL`) when the built request exceeds the +responder's `DataTransferSize` rather than emit a non-conformant oversized +message — so the library never sends something it cannot chunk. + +`CHUNK_CAP` advertises support for the large-message mechanism; it does not +obligate an endpoint to chunk requests it never sends. `CHUNK_SEND` / +`CHUNK_SEND_ACK` are defined in `spdm_types.h` for completeness but intentionally +unimplemented; the fail-fast guard keeps that conformant. ## References diff --git a/src/spdm_session.c b/src/spdm_session.c index 99ef999..20edb35 100644 --- a/src/spdm_session.c +++ b/src/spdm_session.c @@ -306,6 +306,20 @@ int wolfSPDM_KeyExchange(WOLFSPDM_CTX* ctx) return rc; } + /* An ML-KEM encapsulation key makes this request large (up to ~1.6 KB for + * ML-KEM-1024), unlike the ~158-byte ECDHE request. wolfSPDM implements + * CHUNK_GET (response reassembly) but not CHUNK_SEND (request + * fragmentation), so if the request exceeds the responder's advertised + * DataTransferSize, fail fast with a clear error rather than transmit a + * non-conformant oversized request (DSP0274 Sec. 10.27). */ + if (ctx->dataTransferSize != 0 && txSz > ctx->dataTransferSize) { + wolfSPDM_DebugPrint(ctx, + "KEY_EXCHANGE %u B exceeds responder DataTransferSize %u " + "(no CHUNK_SEND)\n", + (unsigned)txSz, (unsigned)ctx->dataTransferSize); + return WOLFSPDM_E_BUFFER_SMALL; + } + rc = wolfSPDM_TranscriptAdd(ctx, txBuf, txSz); if (rc != WOLFSPDM_SUCCESS) { return rc; From 166ad24d81a4ad5262b81e1500718fee69ce790c Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 12:38:06 -0700 Subject: [PATCH 5/9] Address skoll v2: ML-KEM CTX_STATIC_SIZE budget, bound ek OpaqueData write, validate kemMask, fail-fast test, ML-KEM-only CI --- .github/workflows/spdm-emu-pqc-test.yml | 16 ++++++++++++++++ src/spdm_context.c | 5 +++++ src/spdm_msg.c | 16 ++++++++++++---- test/unit_test.c | 21 +++++++++++++++++++++ wolfspdm/spdm.h | 7 ++++++- wolfspdm/spdm_types.h | 3 +++ 6 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.github/workflows/spdm-emu-pqc-test.yml b/.github/workflows/spdm-emu-pqc-test.yml index 2ba920c..532b7a7 100644 --- a/.github/workflows/spdm-emu-pqc-test.yml +++ b/.github/workflows/spdm-emu-pqc-test.yml @@ -76,6 +76,22 @@ jobs: make install grep LIBWOLFSSL_VERSION_STRING $HOME/wolfssl-install/include/wolfssl/version.h + # --- ML-KEM without ML-DSA: a real config (ML-KEM/Kyber is commonly + # enabled for TLS hybrid KEX while ML-DSA is not). Exercises the + # ML-KEM-only WOLFSPDM_CTX_STATIC_SIZE budget and unit tests, which + # the combined build below does not. Cleaned up before the full build. --- + - name: Build + test wolfSPDM ML-KEM-only (--disable-mldsa --enable-mlkem) + run: | + ./autogen.sh + ./configure --with-wolfssl=$HOME/wolfssl-install \ + --disable-mldsa --enable-mlkem \ + ${{ matrix.dynamic-mem == 'yes' && '--enable-dynamic-mem' || '' }} + make -j"$(nproc)" + make check + make distclean + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/.libs:${{ github.workspace }}/src/.libs:${{ env.HOME }}/wolfssl-install/lib + # --- wolfSPDM with ML-DSA + ML-KEM asserted on, static or dynamic memory --- - name: Build and install wolfSPDM (--enable-mldsa --enable-mlkem) run: | diff --git a/src/spdm_context.c b/src/spdm_context.c index addb158..490c31e 100644 --- a/src/spdm_context.c +++ b/src/spdm_context.c @@ -303,6 +303,11 @@ int wolfSPDM_SetKeyExchangePref(WOLFSPDM_CTX* ctx, int advDhe, word16 kemMask) if (advDhe == 0 && kemMask == 0) { return WOLFSPDM_E_INVALID_ARG; /* must advertise at least one method */ } + if ((kemMask & ~(word16)(SPDM_KEM_ALGO_ML_KEM_512 | + SPDM_KEM_ALGO_ML_KEM_768 | + SPDM_KEM_ALGO_ML_KEM_1024)) != 0) { + return WOLFSPDM_E_INVALID_ARG; /* undefined ML-KEM bit(s) */ + } #ifndef WOLFSPDM_HAVE_MLKEM if (kemMask != 0) { return WOLFSPDM_E_INVALID_ARG; /* ML-KEM not built in */ diff --git a/src/spdm_msg.c b/src/spdm_msg.c index a919a58..0f97e56 100644 --- a/src/spdm_msg.c +++ b/src/spdm_msg.c @@ -225,10 +225,18 @@ int wolfSPDM_BuildKeyExchange(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) rc = WOLFSPDM_SUCCESS; #ifdef WOLFSPDM_HAVE_MLKEM if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { - ekSz = *bufSz - offset; - rc = wolfSPDM_GenerateMlKemKey(ctx, &buf[offset], &ekSz); - if (rc == WOLFSPDM_SUCCESS) { - offset += ekSz; + /* Reserve the trailing OpaqueData block so handing the remaining buffer + * to GenerateMlKemKey (which only bounds the ek) cannot leave the + * OpaqueData write below to overrun. */ + if (*bufSz < offset + WOLFSPDM_KEX_OPAQUE_LEN) { + rc = WOLFSPDM_E_BUFFER_SMALL; + } + else { + ekSz = *bufSz - offset - WOLFSPDM_KEX_OPAQUE_LEN; + rc = wolfSPDM_GenerateMlKemKey(ctx, &buf[offset], &ekSz); + if (rc == WOLFSPDM_SUCCESS) { + offset += ekSz; + } } } else diff --git a/test/unit_test.c b/test/unit_test.c index 7e39fa5..96bd50a 100644 --- a/test/unit_test.c +++ b/test/unit_test.c @@ -868,6 +868,26 @@ static int test_build_key_exchange_mlkem(void) TEST_CTX_FREE(); TEST_PASS(); } + +/* An ML-KEM KEY_EXCHANGE request larger than the responder's DataTransferSize + * must fail fast (no CHUNK_SEND), not emit a non-conformant oversized message. */ +static int test_key_exchange_mlkem_exceeds_dts(void) +{ + TEST_CTX_SETUP(); + + printf("test_key_exchange_mlkem_exceeds_dts...\n"); + ctx->spdmVersion = SPDM_VERSION_14; + ctx->kexType = WOLFSPDM_KEX_MLKEM; + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_1024; /* ek 1568 -> request ~1630 B */ + ctx->flags.hasResponderPubKey = 1; /* pass the precondition */ + ctx->dataTransferSize = 512; /* smaller than the request */ + + ASSERT_EQ(wolfSPDM_KeyExchange(ctx), WOLFSPDM_E_BUFFER_SMALL, + "oversized ML-KEM KEY_EXCHANGE rejected before send"); + + TEST_CTX_FREE(); + TEST_PASS(); +} #endif /* WOLFSPDM_HAVE_MLKEM */ #ifdef WOLFSPDM_HAVE_CHUNK @@ -2899,6 +2919,7 @@ int main(void) test_mlkem_decapsulate(); test_kex_reconnect_method_switch(); test_build_key_exchange_mlkem(); + test_key_exchange_mlkem_exceeds_dts(); #endif #ifdef WOLFSPDM_HAVE_CHUNK test_chunk_reassemble(); diff --git a/wolfspdm/spdm.h b/wolfspdm/spdm.h index ac36575..4678714 100644 --- a/wolfspdm/spdm.h +++ b/wolfspdm/spdm.h @@ -94,8 +94,13 @@ extern "C" { * key push it to ~67 KB, rounded to 72 KB. wolfSPDM_InitStatic() verifies at * runtime that the provided buffer is large enough (WOLFSPDM_E_BUFFER_SMALL); * a compile-time _Static_assert in spdm_context.c also guards this value. */ -#ifdef WOLFSPDM_HAVE_MLDSA +#if defined(WOLFSPDM_HAVE_MLDSA) #define WOLFSPDM_CTX_STATIC_SIZE 73728 /* 72KB - fits CTX with ML-DSA buffers */ +#elif defined(WOLFSPDM_HAVE_MLKEM) +/* ML-KEM (no ML-DSA): the ephemeralKey union holds an MlKemKey (larger than + * ecc_key, and larger still with WOLFSSL_MLKEM_CACHE_A) instead of the classical + * key, so allow extra headroom over the 32KB classical budget. */ +#define WOLFSPDM_CTX_STATIC_SIZE 49152 /* 48KB */ #else #define WOLFSPDM_CTX_STATIC_SIZE 32768 /* 32KB - fits CTX with cert validation + challenge + key update fields */ #endif diff --git a/wolfspdm/spdm_types.h b/wolfspdm/spdm_types.h index 193ee8f..8535e6c 100644 --- a/wolfspdm/spdm_types.h +++ b/wolfspdm/spdm_types.h @@ -227,6 +227,9 @@ extern "C" { #define WOLFSPDM_KEM_SS_SIZE 32 #define WOLFSPDM_MAX_KEM_EK_SIZE WOLFSPDM_MLKEM1024_EK_SIZE #define WOLFSPDM_MAX_KEM_CT_SIZE WOLFSPDM_MLKEM1024_CT_SIZE +/* Fixed OpaqueData block wolfSPDM_BuildKeyExchange appends after ExchangeData + * (2-byte OpaqueLength + 20-byte secured-message-version block). */ +#define WOLFSPDM_KEX_OPAQUE_LEN 22 /* KEY_EXCHANGE request buffer: fixed fields + the largest ek + OpaqueData. */ #define WOLFSPDM_KEX_REQ_BUF (96 + WOLFSPDM_MAX_KEM_EK_SIZE) #else From 0db1cdfa2d05a66984ba31cab4fdaef3f7544be4 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 13:03:24 -0700 Subject: [PATCH 6/9] Address skoll v3: fail (not downgrade) on KEM-only below 1.4, guard KEM ct-size read, ML-KEM-only transcript budget, parse/negotiate tests, configure probe, doc comment --- configure.ac | 6 +++++- src/spdm_msg.c | 13 ++++++++++- src/spdm_session.c | 2 +- test/unit_test.c | 50 +++++++++++++++++++++++++++++++++++++++++++ wolfspdm/spdm_types.h | 6 +++++- 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/configure.ac b/configure.ac index eb1ece5..29ea00a 100644 --- a/configure.ac +++ b/configure.ac @@ -181,9 +181,13 @@ if test "x$enable_mlkem" != "xno" && test "x$have_wolfssl_mlkem" = "xyes"; then ]], [[ MlKemKey k; word32 len = 0; (void)wc_MlKemKey_Init(&k, 0, 0, 0); + (void)wc_MlKemKey_MakeKey(&k, 0); (void)wc_MlKemKey_EncodePublicKey(&k, 0, 0); - (void)wc_MlKemKey_Decapsulate(&k, 0, 0, 0); (void)wc_MlKemKey_PublicKeySize(&k, &len); + (void)wc_MlKemKey_CipherTextSize(&k, &len); + (void)wc_MlKemKey_SharedSecretSize(&k, &len); + (void)wc_MlKemKey_Decapsulate(&k, 0, 0, 0); + (void)wc_MlKemKey_Free(&k); ]])], [have_mlkem_api=yes], [have_mlkem_api=no]) diff --git a/src/spdm_msg.c b/src/spdm_msg.c index 0f97e56..5395d53 100644 --- a/src/spdm_msg.c +++ b/src/spdm_msg.c @@ -83,7 +83,13 @@ int wolfSPDM_BuildNegotiateAlgorithms(WOLFSPDM_CTX* ctx, byte* buf, word32* bufS advDhe = (ctx->kexAdvDhe != 0); advKem = (ctx->spdmVersion >= SPDM_VERSION_14 && ctx->kexAdvKem != 0); if (!advDhe && !advKem) { - advDhe = 1; /* never advertise zero key-exchange methods */ + /* The caller forced KEM-only (kexAdvDhe == 0) but ML-KEM cannot be + * advertised at the negotiated version (< 1.4). Fail rather than + * silently re-enable DHE and downgrade the caller's PQC-only intent. */ + wolfSPDM_DebugPrint(ctx, + "NEGOTIATE: KEM-only requested but unavailable at version 0x%02x\n", + ctx->spdmVersion); + return WOLFSPDM_E_ALGO_MISMATCH; } #else advDhe = 1; /* DHE is the only key-exchange method without ML-KEM */ @@ -996,6 +1002,11 @@ int wolfSPDM_ParseKeyExchangeRsp(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufS exDataLen = WOLFSPDM_ECC_POINT_SIZE; #ifdef WOLFSPDM_HAVE_MLKEM if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { + /* GetKemCtSize reads the ephemeral ML-KEM key; require it live, mirroring + * the hasResponderPubKey guard above. */ + if (!ctx->flags.ephemeralKeyInit) { + return WOLFSPDM_E_BAD_STATE; + } exDataLen = wolfSPDM_GetKemCtSize(ctx); if (exDataLen == 0 || exDataLen > WOLFSPDM_MAX_KEM_CT_SIZE) { return WOLFSPDM_E_CRYPTO_FAIL; diff --git a/src/spdm_session.c b/src/spdm_session.c index 20edb35..0b45424 100644 --- a/src/spdm_session.c +++ b/src/spdm_session.c @@ -97,7 +97,7 @@ int wolfSPDM_GetCapabilities(WOLFSPDM_CTX* ctx) int wolfSPDM_NegotiateAlgorithms(WOLFSPDM_CTX* ctx) { - byte txBuf[52]; /* NEGOTIATE_ALGORITHMS: 48 bytes */ + byte txBuf[52]; /* NEGOTIATE_ALGORITHMS: 48 B, or 52 with the KEM struct */ byte rxBuf[80]; /* ALGORITHMS: ~56 bytes with struct tables */ int rc; #ifndef NO_WOLFSPDM_CHALLENGE diff --git a/test/unit_test.c b/test/unit_test.c index 96bd50a..ddf6b8f 100644 --- a/test/unit_test.c +++ b/test/unit_test.c @@ -687,6 +687,22 @@ static int test_negotiate_algorithms_kem_build(void) ASSERT_EQ(bufSz, 48, "1.2 NEGOTIATE_ALGORITHMS stays 48 bytes"); ASSERT_EQ(buf[2], 0x04, "1.2 NumAlgoStructTables = 4"); + /* KEM-only preference below 1.4 must fail rather than silently advertise + * DHE (no security downgrade of an explicit PQC-only request). */ + ASSERT_SUCCESS(wolfSPDM_SetKeyExchangePref(ctx, 0, SPDM_KEM_ALGO_ML_KEM_768)); + ctx->spdmVersion = SPDM_VERSION_12; + bufSz = sizeof(buf); + ASSERT_EQ(wolfSPDM_BuildNegotiateAlgorithms(ctx, buf, &bufSz), + WOLFSPDM_E_ALGO_MISMATCH, "KEM-only below 1.4 must not downgrade to DHE"); + /* At 1.4 the same preference advertises a KEM-only request: DHE struct + * dropped, so 4 structs (AEAD, ReqBaseAsym, KeySchedule, KEM) and no DHE. */ + ctx->spdmVersion = SPDM_VERSION_14; + bufSz = sizeof(buf); + ASSERT_SUCCESS(wolfSPDM_BuildNegotiateAlgorithms(ctx, buf, &bufSz)); + ASSERT_EQ(buf[2], 0x04, "KEM-only: 4 structs (no DHE)"); + ASSERT_EQ(buf[32], SPDM_ALG_TYPE_AEAD, "KEM-only: DHE dropped, AEAD first"); + ASSERT_EQ(buf[44], SPDM_ALG_TYPE_KEM, "KEM-only: KEMAlg struct present"); + TEST_CTX_FREE(); TEST_PASS(); } @@ -888,6 +904,39 @@ static int test_key_exchange_mlkem_exceeds_dts(void) TEST_CTX_FREE(); TEST_PASS(); } + +/* KEY_EXCHANGE_RSP parsing for ML-KEM must locate OpaqueData/signature after a + * ciphertext-sized ExchangeData, not the 96-byte ECDHE point. A buffer that + * stops between the two offsets distinguishes them. */ +static int test_parse_key_exchange_rsp_mlkem_offset(void) +{ + byte ek[WOLFSPDM_MLKEM768_EK_SIZE]; + byte rsp[1100]; + word32 ekSz = sizeof(ek); + TEST_CTX_SETUP(); + + printf("test_parse_key_exchange_rsp_mlkem_offset...\n"); + ctx->spdmVersion = SPDM_VERSION_14; + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_768; + ASSERT_SUCCESS(wolfSPDM_GenerateMlKemKey(ctx, ek, &ekSz)); + ctx->flags.hasResponderPubKey = 1; + + XMEMSET(rsp, 0, sizeof(rsp)); + rsp[0] = SPDM_VERSION_14; + rsp[1] = SPDM_KEY_EXCHANGE_RSP; + rsp[6] = 0; /* no mutual auth */ + + /* With the ML-KEM-768 ciphertext (1088), OpaqueLength sits at offset + * 40+1088 = 1128, so a 1000-byte buffer fails the length check. If the parse + * wrongly used the 96-byte ECDHE size (OpaqueLength at 136) it would read + * past 1000 instead of returning here. */ + ASSERT_EQ(wolfSPDM_ParseKeyExchangeRsp(ctx, rsp, 1000), + WOLFSPDM_E_BUFFER_SMALL, + "ML-KEM RSP uses ciphertext-sized ExchangeData offset"); + + TEST_CTX_FREE(); + TEST_PASS(); +} #endif /* WOLFSPDM_HAVE_MLKEM */ #ifdef WOLFSPDM_HAVE_CHUNK @@ -2920,6 +2969,7 @@ int main(void) test_kex_reconnect_method_switch(); test_build_key_exchange_mlkem(); test_key_exchange_mlkem_exceeds_dts(); + test_parse_key_exchange_rsp_mlkem_offset(); #endif #ifdef WOLFSPDM_HAVE_CHUNK test_chunk_reassemble(); diff --git a/wolfspdm/spdm_types.h b/wolfspdm/spdm_types.h index 8535e6c..d564d76 100644 --- a/wolfspdm/spdm_types.h +++ b/wolfspdm/spdm_types.h @@ -350,8 +350,12 @@ extern "C" { #endif #endif #ifndef WOLFSPDM_MAX_TRANSCRIPT -#ifdef WOLFSPDM_HAVE_MLDSA +#if defined(WOLFSPDM_HAVE_MLDSA) #define WOLFSPDM_MAX_TRANSCRIPT 16384 /* Maximum transcript buffer */ +#elif defined(WOLFSPDM_HAVE_MLKEM) +/* ML-KEM (no ML-DSA): an ML-KEM-1024 handshake transcript (ek 1568 + ciphertext + * 1568 + the fixed messages) approaches 4 KB, so allow extra headroom. */ +#define WOLFSPDM_MAX_TRANSCRIPT 8192 #else #define WOLFSPDM_MAX_TRANSCRIPT 4096 #endif From 25fb64645062f51badde5183c2913b4c4afacf35 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 13:18:22 -0700 Subject: [PATCH 7/9] Address skoll v4 (Low/Info): fail-closed on unknown kexType, clear stale kemAlgSel, robust emu cleanup in CI --- .github/workflows/spdm-emu-pqc-test.yml | 3 +++ src/spdm_msg.c | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/spdm-emu-pqc-test.yml b/.github/workflows/spdm-emu-pqc-test.yml index 532b7a7..dbb10b6 100644 --- a/.github/workflows/spdm-emu-pqc-test.yml +++ b/.github/workflows/spdm-emu-pqc-test.yml @@ -178,6 +178,7 @@ jobs: fi kill $emu 2>/dev/null || true wait $emu 2>/dev/null || true + pkill -f spdm_responder_emu 2>/dev/null || true [ $rc -eq 0 ] && break echo "--- responder log (attempt $attempt) ---" cat /tmp/pqc_emu_${alg}_${label}.log || true @@ -248,6 +249,7 @@ jobs: fi kill $emu 2>/dev/null || true wait $emu 2>/dev/null || true + pkill -f spdm_responder_emu 2>/dev/null || true [ $rc -eq 0 ] && break echo "--- responder log (attempt $attempt) ---" cat /tmp/pqc_emu_${kem}_${label}.log || true @@ -320,6 +322,7 @@ jobs: fi kill $emu 2>/dev/null || true wait $emu 2>/dev/null || true + pkill -f spdm_responder_emu 2>/dev/null || true [ $rc -eq 0 ] && break echo "--- responder log (attempt $attempt) ---" cat /tmp/pqc_emu_${pqc}_kem_${label}.log || true diff --git a/src/spdm_msg.c b/src/spdm_msg.c index 5395d53..d3eeccc 100644 --- a/src/spdm_msg.c +++ b/src/spdm_msg.c @@ -260,6 +260,9 @@ int wolfSPDM_BuildKeyExchange(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) offset += WOLFSPDM_ECC_KEY_SIZE; } } + else { + rc = WOLFSPDM_E_BAD_STATE; /* unrecognized kexType: fail closed */ + } if (rc != WOLFSPDM_SUCCESS) { return rc; } @@ -874,6 +877,9 @@ int wolfSPDM_ParseAlgorithms(WOLFSPDM_CTX* ctx, const byte* buf, word32 bufSz) return WOLFSPDM_E_ALGO_MISMATCH; } ctx->kexType = kemOk ? WOLFSPDM_KEX_MLKEM : WOLFSPDM_KEX_ECDHE; + if (!kemOk) { + ctx->kemAlgSel = 0; /* no stale KEM selection on the ECDHE branch */ + } wolfSPDM_DebugPrint(ctx, "ALGORITHMS: BaseAsym=0x%08x BaseHash=0x%08x\n", baseAsymAlgo, baseHashAlgo); From 5343f336aba10af826f818e893a881d239964b47 Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 13:36:32 -0700 Subject: [PATCH 8/9] Fix cppcheck/scan-build dead rc init in BuildKeyExchange; add ML-KEM API/helper error-path tests --- src/spdm_msg.c | 4 ++-- test/unit_test.c | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/spdm_msg.c b/src/spdm_msg.c index d3eeccc..d6c5072 100644 --- a/src/spdm_msg.c +++ b/src/spdm_msg.c @@ -227,8 +227,8 @@ int wolfSPDM_BuildKeyExchange(WOLFSPDM_CTX* ctx, byte* buf, word32* bufSz) offset += WOLFSPDM_RANDOM_SIZE; /* ExchangeData: the negotiated method's public value (DSP0274 1.4 Table 77). - * ML-KEM sends the encapsulation key ek; ECDHE sends X || Y. */ - rc = WOLFSPDM_SUCCESS; + * ML-KEM sends the encapsulation key ek; ECDHE sends X || Y. Every branch + * below (incl. the unrecognized-kexType else) assigns rc. */ #ifdef WOLFSPDM_HAVE_MLKEM if (ctx->kexType == WOLFSPDM_KEX_MLKEM) { /* Reserve the trailing OpaqueData block so handing the remaining buffer diff --git a/test/unit_test.c b/test/unit_test.c index ddf6b8f..7aeb3dd 100644 --- a/test/unit_test.c +++ b/test/unit_test.c @@ -937,6 +937,67 @@ static int test_parse_key_exchange_rsp_mlkem_offset(void) TEST_CTX_FREE(); TEST_PASS(); } + +/* Error/guard paths: the public preference API and the ML-KEM helpers must + * fail closed on invalid input and bad state. */ +static int test_mlkem_error_paths(void) +{ + byte ek[WOLFSPDM_MLKEM768_EK_SIZE]; + byte small[64]; + byte req[WOLFSPDM_KEX_REQ_BUF]; + byte ct[WOLFSPDM_MLKEM768_CT_SIZE]; + word32 sz; + TEST_CTX_SETUP(); + + printf("test_mlkem_error_paths...\n"); + ctx->spdmVersion = SPDM_VERSION_14; + + /* wolfSPDM_SetKeyExchangePref validation. */ + ASSERT_EQ(wolfSPDM_SetKeyExchangePref(NULL, 1, 0), WOLFSPDM_E_INVALID_ARG, + "SetKeyExchangePref NULL ctx"); + ASSERT_EQ(wolfSPDM_SetKeyExchangePref(ctx, 0, 0), WOLFSPDM_E_INVALID_ARG, + "SetKeyExchangePref no methods"); + ASSERT_EQ(wolfSPDM_SetKeyExchangePref(ctx, 0, 0x0008), + WOLFSPDM_E_INVALID_ARG, "SetKeyExchangePref undefined KEM bit"); + ASSERT_SUCCESS(wolfSPDM_SetKeyExchangePref(ctx, 1, + SPDM_KEM_ALGO_ML_KEM_768)); + + /* GenerateMlKemKey: NULL, unknown KEM selection, ek output too small. */ + sz = sizeof(ek); + ASSERT_EQ(wolfSPDM_GenerateMlKemKey(NULL, ek, &sz), WOLFSPDM_E_INVALID_ARG, + "GenerateMlKemKey NULL ctx"); + ctx->kemAlgSel = 0; /* not a valid ML-KEM selection */ + sz = sizeof(ek); + ASSERT_EQ(wolfSPDM_GenerateMlKemKey(ctx, ek, &sz), WOLFSPDM_E_ALGO_MISMATCH, + "GenerateMlKemKey unknown KEM set"); + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_768; + sz = sizeof(small); /* 64 < 1184 */ + ASSERT_EQ(wolfSPDM_GenerateMlKemKey(ctx, small, &sz), + WOLFSPDM_E_BUFFER_SMALL, "GenerateMlKemKey ek buffer too small"); + + /* MlKemDecapsulate: NULL, and no live ephemeral key / wrong kexType. */ + ASSERT_EQ(wolfSPDM_MlKemDecapsulate(ctx, NULL, 0), WOLFSPDM_E_INVALID_ARG, + "MlKemDecapsulate NULL ct"); + ctx->flags.ephemeralKeyInit = 0; + ctx->kexType = WOLFSPDM_KEX_MLKEM; + ASSERT_EQ(wolfSPDM_MlKemDecapsulate(ctx, ct, sizeof(ct)), + WOLFSPDM_E_BAD_STATE, "MlKemDecapsulate no live key"); + + /* BuildKeyExchange: unrecognized kexType fails closed; ML-KEM request that + * does not fit the caller buffer is rejected. */ + ctx->kexType = (byte)0xEE; + sz = sizeof(req); + ASSERT_EQ(wolfSPDM_BuildKeyExchange(ctx, req, &sz), WOLFSPDM_E_BAD_STATE, + "BuildKeyExchange unknown kexType"); + ctx->kexType = WOLFSPDM_KEX_MLKEM; + ctx->kemAlgSel = SPDM_KEM_ALGO_ML_KEM_768; + sz = 500; /* >= 180 arg check, < 40 + 1184 ek + 22 */ + ASSERT_EQ(wolfSPDM_BuildKeyExchange(ctx, req, &sz), WOLFSPDM_E_BUFFER_SMALL, + "BuildKeyExchange ML-KEM request exceeds buffer"); + + TEST_CTX_FREE(); + TEST_PASS(); +} #endif /* WOLFSPDM_HAVE_MLKEM */ #ifdef WOLFSPDM_HAVE_CHUNK @@ -2970,6 +3031,7 @@ int main(void) test_build_key_exchange_mlkem(); test_key_exchange_mlkem_exceeds_dts(); test_parse_key_exchange_rsp_mlkem_offset(); + test_mlkem_error_paths(); #endif #ifdef WOLFSPDM_HAVE_CHUNK test_chunk_reassemble(); From 34e1e11b8bb35137b8b794ada18e45ab2d917d6a Mon Sep 17 00:00:00 2001 From: aidan garske Date: Thu, 11 Jun 2026 13:36:33 -0700 Subject: [PATCH 9/9] Docs: document ML-KEM key exchange and full post-quantum SPDM support (README + wiki) --- .github/workflows/README.md | 1 + README.md | 9 ++- docs/Configuration-and-Macros.md | 23 ++++--- docs/Home.md | 3 + docs/Post-Quantum-ML-DSA.md | 4 +- docs/Post-Quantum-ML-KEM.md | 109 +++++++++++++++++++++++++++++++ docs/Supported-Operations.md | 28 +++++--- docs/Testing-and-CI.md | 30 +++++++-- docs/_Sidebar.md | 1 + 9 files changed, 182 insertions(+), 26 deletions(-) create mode 100644 docs/Post-Quantum-ML-KEM.md diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ba3501e..d5087c7 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -11,3 +11,4 @@ | CodeQL Security | `codeql.yml` | GitHub CodeQL security-and-quality analysis. Runs on PRs and weekly (Monday 6 AM UTC). | | Codespell | `codespell.yml` | Spell-checks source files. | | SPDM Emulator Test | `spdm-emu-test.yml` | End-to-end integration test against the DMTF libspdm emulator. Runs 18 tests (6 scenarios x SPDM 1.2/1.3/1.4): session establishment, signed/unsigned measurements, challenge authentication, heartbeat, key update. Matrix across ubuntu-22.04 (x64), ubuntu-24.04 (x64), ubuntu-24.04-arm (aarch64). | +| SPDM Emulator PQC Test | `spdm-emu-pqc-test.yml` | Post-quantum interop against spdm-emu (OpenSSL backend, wolfSSL master) on the x64 + aarch64 matrix: ML-DSA-44/65/87 signatures, ML-KEM-512/768/1024 key exchange, and a fully post-quantum leg (ML-KEM-768 + ML-DSA-65/87, also exercising chunking) for session/measurements/challenge. Also builds the ML-KEM-only config. | diff --git a/README.md b/README.md index 0033322..2cdf0f3 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ wolfSPDM is a lightweight C library implementing [SPDM 1.2 / 1.3 / 1.4](https:// - **Standard SPDM 1.2 / 1.3 / 1.4 requester** per DMTF DSP0274 and DSP0277 - **Algorithm Set B fixed:** ECDSA P-384, ECDHE P-384, SHA-384, AES-256-GCM, HKDF-SHA384 - **Post-quantum signatures (SPDM 1.4):** optional ML-DSA-44 / 65 / 87 (FIPS 204), dual-stacked with ECDSA P-384 — see the [Post-Quantum ML-DSA](https://github.com/aidangarske/wolfSPDM/wiki/Post-Quantum-ML-DSA) wiki page +- **Post-quantum key exchange (SPDM 1.4):** optional ML-KEM-512 / 768 / 1024 (FIPS 203), advertised alongside ECDHE P-384 — see the [Post-Quantum ML-KEM](https://github.com/aidangarske/wolfSPDM/wiki/Post-Quantum-ML-KEM) wiki page +- **Fully post-quantum SPDM handshake:** ML-KEM key exchange + ML-DSA authentication (no classical asymmetric crypto), proven end-to-end against spdm-emu - **Zero-malloc by default:** static memory, ~32 KB context, ideal for constrained/embedded environments - **Optional `--enable-dynamic-mem`** for heap-allocated contexts on small-stack platforms - **Full session lifecycle:** key exchange, finish, encrypted messaging, heartbeat keep-alive, key update @@ -43,7 +45,7 @@ sudo ldconfig `--enable-sp` enables Single Precision math with optimized ECC P-384, required for SPDM Algorithm Set B on ARM64 and other constrained targets. `--enable-all` works as a superset. -For post-quantum ML-DSA signatures, add `--enable-mldsa` and use wolfSSL master (or a release that ships the `wc_MlDsaKey` context API). wolfSPDM then auto-enables ML-DSA; `./configure --disable-mldsa` forces it off. +For post-quantum cryptography, add `--enable-mldsa` (signatures, FIPS 204) and/or `--enable-mlkem` (key exchange, FIPS 203) to wolfSSL — use wolfSSL master (or a release that ships the `wc_MlDsaKey` context API and `wc_MlKemKey` API). wolfSPDM then auto-enables each when the linked wolfSSL provides it; `./configure --disable-mldsa` / `--disable-mlkem` force them off. Enabling both gives a fully post-quantum SPDM handshake (ML-KEM key exchange + ML-DSA authentication). ## Build @@ -60,6 +62,8 @@ make check |---|---| | `--enable-debug` | Debug output with `-g -O0` (default: `-O2`) | | `--enable-dynamic-mem` | Use heap allocation for `WOLFSPDM_CTX` (default: static) | +| `--disable-mldsa` / `--disable-mlkem` | Force off ML-DSA signatures / ML-KEM key exchange (default: auto-follow wolfSSL) | +| `--disable-chunking` | Compile out SPDM 1.2 message chunking (default: enabled) | | `--with-wolfssl=PATH` | wolfSSL installation path | ### Memory Modes @@ -148,6 +152,9 @@ Full documentation is available in the [GitHub Wiki](https://github.com/aidangar - [Supported Operations](https://github.com/aidangarske/wolfSPDM/wiki/Supported-Operations): SPDM operation coverage and API mapping - [API Reference](https://github.com/aidangarske/wolfSPDM/wiki/API-Reference): Public function groups and common error-code references - [Configuration and Macros](https://github.com/aidangarske/wolfSPDM/wiki/Configuration-and-Macros): Configure flags and compile-time feature controls +- [Post-Quantum ML-DSA](https://github.com/aidangarske/wolfSPDM/wiki/Post-Quantum-ML-DSA): Post-quantum signatures (FIPS 204) +- [Post-Quantum ML-KEM](https://github.com/aidangarske/wolfSPDM/wiki/Post-Quantum-ML-KEM): Post-quantum key exchange (FIPS 203) and the fully post-quantum handshake +- [Message Chunking](https://github.com/aidangarske/wolfSPDM/wiki/Message-Chunking): SPDM 1.2 CHUNK_GET reassembly for large responses - [Testing and CI](https://github.com/aidangarske/wolfSPDM/wiki/Testing-and-CI): Unit tests, emulator integration tests, and CI workflow coverage - [Project Structure](https://github.com/aidangarske/wolfSPDM/wiki/Project-Structure): Source layout and module responsibilities - [Attestation Notes](https://github.com/aidangarske/wolfSPDM/wiki/Attestation-Notes): Measurement and challenge attestation behavior diff --git a/docs/Configuration-and-Macros.md b/docs/Configuration-and-Macros.md index caa40e9..b925bbb 100644 --- a/docs/Configuration-and-Macros.md +++ b/docs/Configuration-and-Macros.md @@ -10,6 +10,7 @@ From `configure.ac`: | `--enable-debug` | off | Defines `WOLFSPDM_DEBUG`, builds with `-g -O0` | | `--enable-dynamic-mem` | off | Defines `WOLFSPDM_DYNAMIC_MEMORY` and enables `wolfSPDM_New` | | `--disable-mldsa` | auto | Force ML-DSA off (default follows wolfSSL — see [[Post-Quantum ML-DSA]]) | +| `--disable-mlkem` | auto | Force ML-KEM off (default follows wolfSSL — see [[Post-Quantum ML-KEM]]) | | `--disable-chunking` | on | Defines `WOLFSPDM_NO_CHUNK` — compile out CHUNK_GET (see [[Message Chunking]]) | ## Public feature macros @@ -21,20 +22,26 @@ Defined in `wolfspdm/spdm.h` depending on build flags: - `WOLFSPDM_HAS_HEARTBEAT` - `WOLFSPDM_HAS_KEY_UPDATE` - `WOLFSPDM_HAVE_MLDSA` *(defined when ML-DSA is built in; follows wolfSSL's `WOLFSSL_HAVE_MLDSA`, suppress with `WOLFSPDM_NO_MLDSA`)* +- `WOLFSPDM_HAVE_MLKEM` *(defined when ML-KEM key exchange is built in; follows wolfSSL's `WOLFSSL_HAVE_MLKEM`, suppress with `WOLFSPDM_NO_MLKEM`)* — see [[Post-Quantum ML-KEM]]. The advertised key-exchange methods are chosen at runtime with `wolfSPDM_SetKeyExchangePref(ctx, advDhe, kemMask)` (default: ECDHE + all ML-KEM sets). - `WOLFSPDM_HAVE_CHUNK` *(defined when CHUNK_GET chunking is built in; suppress with `WOLFSPDM_NO_CHUNK`)* — tunables `WOLFSPDM_CHUNK_BUF_SIZE` (MTU, default 4096), `WOLFSPDM_CHUNK_MAX_CHUNKS` (default 64), and `WOLFSPDM_CHUNK_NO_SECURED` (drop the encrypted path). See [[Message Chunking]]. ## Size and protocol constants From `wolfspdm/spdm.h` and `wolfspdm/spdm_types.h`. The buffer/context defaults grow when ML-DSA is built in so ML-DSA-65 payloads fit a single message -(all three buffer caps are overridable with `-D`): - -| Constant | Classical | With ML-DSA | -|----------|-----------|-------------| -| `WOLFSPDM_CTX_STATIC_SIZE` | `32768` | `73728` | -| `WOLFSPDM_MAX_MSG_SIZE` | `4096` | `8192` | -| `WOLFSPDM_MAX_CERT_CHAIN` | `4096` | `24576` | -| `WOLFSPDM_MAX_TRANSCRIPT` | `4096` | `16384` | +(all caps are overridable with `-D`). ML-KEM-only builds use an intermediate +context/transcript budget for the in-context ephemeral ML-KEM key: + +| Constant | Classical | ML-KEM only | With ML-DSA | +|----------|-----------|-------------|-------------| +| `WOLFSPDM_CTX_STATIC_SIZE` | `32768` | `49152` | `73728` | +| `WOLFSPDM_MAX_MSG_SIZE` | `4096` | `4096` | `8192` | +| `WOLFSPDM_MAX_CERT_CHAIN` | `4096` | `4096` | `24576` | +| `WOLFSPDM_MAX_TRANSCRIPT` | `4096` | `8192` | `16384` | + +ML-KEM size constants (`WOLFSPDM_HAVE_MLKEM`): `WOLFSPDM_MLKEM{512,768,1024}_EK_SIZE` +(encapsulation key), `_CT_SIZE` (ciphertext), and `WOLFSPDM_KEM_SS_SIZE` (32). +KEM algorithm bits: `SPDM_KEM_ALGO_ML_KEM_512/768/1024` (`0x01/0x02/0x04`). Version constants: - `SPDM_VERSION_12`, `SPDM_VERSION_13`, `SPDM_VERSION_14` diff --git a/docs/Home.md b/docs/Home.md index 8f1e94d..bd843f4 100644 --- a/docs/Home.md +++ b/docs/Home.md @@ -18,6 +18,8 @@ It uses [wolfSSL / wolfCrypt](https://www.wolfssl.com/) as its crypto backend an | SPDM 1.2/1.3/1.4 | Standards-based negotiation and session setup | | Fixed Algorithm Set B | ECDSA P-384, ECDHE P-384, SHA-384, AES-256-GCM, HKDF-SHA384 | | Post-quantum signatures (1.4) | Optional ML-DSA-44/65/87 (FIPS 204), dual-stacked with ECDSA P-384 | +| Post-quantum key exchange (1.4) | Optional ML-KEM-512/768/1024 (FIPS 203), advertised alongside ECDHE P-384 | +| Fully post-quantum handshake | ML-KEM key exchange + ML-DSA authentication, no classical asymmetric crypto | | Message chunking | SPDM 1.2 CHUNK_GET reassembly over a fixed MTU buffer (zero-alloc) | | Zero-malloc by default | Static context (`WOLFSPDM_CTX_STATIC_SIZE`, 32 KB; ~72 KB with ML-DSA) | | Optional dynamic context | `--enable-dynamic-mem` enables `wolfSPDM_New()` | @@ -32,6 +34,7 @@ It uses [wolfSSL / wolfCrypt](https://www.wolfssl.com/) as its crypto backend an | [[Getting Started]] | Dependencies, build, install, and first connection flow | | [[Supported Operations]] | Supported SPDM flows and operation/API mapping | | [[Post-Quantum ML-DSA]] | SPDM 1.4 ML-DSA (FIPS 204) post-quantum signatures | +| [[Post-Quantum ML-KEM]] | SPDM 1.4 ML-KEM (FIPS 203) post-quantum key exchange + fully post-quantum handshake | | [[Message Chunking]] | SPDM 1.2 CHUNK_GET reassembly of large responses | | [[API Reference]] | Public API grouped by lifecycle and purpose | | [[Configuration and Macros]] | Configure flags and compile-time feature controls | diff --git a/docs/Post-Quantum-ML-DSA.md b/docs/Post-Quantum-ML-DSA.md index 944e20c..add34c1 100644 --- a/docs/Post-Quantum-ML-DSA.md +++ b/docs/Post-Quantum-ML-DSA.md @@ -7,7 +7,9 @@ alongside the classical ECDSA P-384 profile. All three parameter sets work over the wire: ML-DSA-87's larger responses are reassembled with **SPDM 1.2 message chunking** ([[Message Chunking]]). ML-KEM -hybrid key exchange is tracked as follow-on work. +**key exchange** is also implemented ([[Post-Quantum ML-KEM]]); combining the two +gives a **fully post-quantum SPDM handshake** (ML-KEM key exchange + ML-DSA +authentication, no classical asymmetric crypto). ## How negotiation works diff --git a/docs/Post-Quantum-ML-KEM.md b/docs/Post-Quantum-ML-KEM.md new file mode 100644 index 0000000..3cbcb16 --- /dev/null +++ b/docs/Post-Quantum-ML-KEM.md @@ -0,0 +1,109 @@ +# Post-Quantum ML-KEM + +SPDM 1.4 (DMTF DSP0274 1.4.0) adds **ML-KEM** (FIPS 203) as a post-quantum +**key-exchange** method. wolfSPDM implements the requester side, advertised +alongside the classical ECDHE P-384 group so the responder selects one. + +ML-KEM in SPDM 1.4 is **standalone, not hybrid** — DSP0274 §23.5 states "key +encapsulation (ML-KEM) for session establishment. **No support for hybrid +algorithms.**" When ML-KEM is negotiated it *replaces* the DHE group, and its +decapsulated 32-byte shared secret feeds the existing key schedule in place of +the ECDH secret. Combine it with [[Post-Quantum ML-DSA]] signing for a **fully +post-quantum SPDM handshake**. + +## How it works (DSP0274 1.4 §10.17.2) + +1. The requester generates an ephemeral ML-KEM key pair and sends the + **encapsulation key `ek`** as the `KEY_EXCHANGE` `ExchangeData` (replacing the + 96-byte ECDHE X‖Y point). +2. The responder encapsulates, returning the **ciphertext `c`** as the + `KEY_EXCHANGE_RSP` `ExchangeData` (alongside its signature and HMAC). +3. The requester **decapsulates** `c` with its decapsulation key `dk` to recover + the 32-byte shared secret `K′`, which drives the key schedule (§12.2). TH1/TH2 + and all downstream derivation are unchanged from the ECDHE path. + +Per FIPS 203 implicit rejection, a `K′ ≠ K` mismatch surfaces only as a FINISH +integrity-check failure and is handled like any other session-message failure. + +## How negotiation works + +In `NEGOTIATE_ALGORITHMS`, wolfSPDM advertises a `KEMAlg` `AlgStruct` +(`AlgType = 0x07`, DSP0274 1.4 Table 24) with the supported ML-KEM sets, dual-stack +alongside the DHE group. The responder selects **exactly one** key-exchange +method — a DHE group **or** a KEM, never both (no hybrid). wolfSPDM enforces that +mutual exclusivity when parsing `ALGORITHMS`. + +| KEMAlg bit | Algorithm | `ek` (request) | ciphertext `c` (response) | shared secret | +|-----------|-----------|----------------|---------------------------|---------------| +| `0x01` | ML-KEM-512 | 800 B | 768 B | 32 B | +| `0x02` | ML-KEM-768 | 1184 B | 1088 B | 32 B | +| `0x04` | ML-KEM-1024 | 1568 B | 1568 B | 32 B | + +By default wolfSPDM advertises ECDHE **and** all three ML-KEM sets. To force a +PQC-only key exchange (e.g. for testing), pin it at runtime: + +```c +/* Advertise only ML-KEM-768 (advDhe = 0). The responder must use it or fail. */ +wolfSPDM_SetKeyExchangePref(ctx, 0, SPDM_KEM_ALGO_ML_KEM_768); +``` + +A KEM-only preference below SPDM 1.4 fails (`WOLFSPDM_E_ALGO_MISMATCH`) rather +than silently downgrading to DHE. + +## Building + +ML-KEM follows the linked wolfSSL automatically: it is enabled when wolfSSL +reports `WOLFSSL_HAVE_MLKEM` and provides the `wc_MlKemKey` API (build wolfSSL +with `--enable-mlkem`). The capability is detected at configure time. + +```sh +# wolfSSL with ML-KEM (and ML-DSA for a fully post-quantum handshake) +./configure --enable-ecc --enable-sha384 --enable-aesgcm --enable-hkdf \ + --enable-sp --enable-mlkem --enable-mldsa --prefix=$HOME/wolfssl-install +make && make install + +# wolfSPDM (ML-KEM auto-enabled; --enable-mlkem asserts it, --disable-mlkem off) +./configure --with-wolfssl=$HOME/wolfssl-install +make && make check +``` + +The configure summary prints `ML-KEM: enabled|disabled`. wolfSPDM uses the +`wc_MlKemKey_*` API only (`Init`, `MakeKey`, `EncodePublicKey`, `Decapsulate`, +the size getters, `Free`); the legacy `wc_KyberKey_*` aliases are not used. + +The demo selects a key exchange with `--kex`: + +```sh +./examples/spdm_demo --emu --ver 1.4 --kex mlkem768 # ML-KEM key exchange +./examples/spdm_demo --emu --ver 1.4 --kex ecdhe # classical ECDHE +``` + +## Memory and message-size notes + +ML-KEM only changes the key-exchange `ExchangeData`; everything downstream is +unchanged. Two size effects: + +- **Larger KEY_EXCHANGE request.** The `ek` (up to 1568 B for ML-KEM-1024) makes + the request ~870–1640 B, vs ~158 B for ECDHE. This still fits common responders + (spdm-emu advertises 4608 B), but wolfSPDM implements only `CHUNK_GET` (response + reassembly), not `CHUNK_SEND` (request fragmentation). If the request exceeds the + responder's advertised `DataTransferSize`, `wolfSPDM_KeyExchange` **fails fast** + (`WOLFSPDM_E_BUFFER_SMALL`) rather than emit a non-conformant oversized message + — see [[Message Chunking]]. +- **Static context.** The ephemeral ML-KEM key lives in the context (a union with + the classical `ecc_key`; only one is ever live), so ML-KEM-only builds use a + larger `WOLFSPDM_CTX_STATIC_SIZE` than the classical profile (see + [[Configuration and Macros]]). A fully post-quantum (ML-KEM + ML-DSA) build uses + the ML-DSA budget, which already covers it. + +When ML-KEM and ML-DSA are combined, an ML-DSA-87 signed response plus the ML-KEM +ciphertext can exceed the DataTransferSize, so the responder chunks it and +wolfSPDM reassembles via CHUNK_GET — the full-PQ path exercises ML-KEM, ML-DSA, +and chunking together. + +## References + +- DMTF DSP0274 1.4.0 — §10.17.2 (ML-KEM scheme), §10.17.3 (message formats, + Table 77), §12.2 (KEM K/K′ computation), Table 24 (KEMAlg), §23.5 (no hybrid) +- NIST FIPS 203 — ML-KEM +- wolfSSL `wc_mlkem.h` — `wc_MlKemKey_*` API diff --git a/docs/Supported-Operations.md b/docs/Supported-Operations.md index 5faf317..1a402cc 100644 --- a/docs/Supported-Operations.md +++ b/docs/Supported-Operations.md @@ -34,21 +34,29 @@ Maximum negotiated version can be capped with `wolfSPDM_SetMaxVersion`. - Hash: SHA-384 - Asymmetric signature: ECDSA P-384 -- DHE: secp384r1 +- Key exchange: ECDHE secp384r1 - AEAD: AES-256-GCM - Key schedule: SPDM key schedule + HKDF-SHA384 -## Post-quantum signatures (SPDM 1.4, optional) +## Post-quantum cryptography (SPDM 1.4, optional) -When built against a wolfSSL with ML-DSA (FIPS 204), wolfSPDM additionally -advertises **ML-DSA-44 / ML-DSA-65 / ML-DSA-87** in the SPDM 1.4 `PqcAsymAlgo` -field (dual-stack alongside ECDSA P-384). The responder selects exactly one -signature algorithm; wolfSPDM verifies whichever was negotiated. See -[[Post-Quantum ML-DSA]]. +When built against a wolfSSL with the matching support, wolfSPDM adds two +independent post-quantum capabilities, each dual-stacked with the classical +profile so the responder selects one: -Large responses (e.g. an ML-DSA-87 signature that exceeds the negotiated -`DataTransferSize`) are reassembled with **SPDM 1.2 message chunking** -(`CHUNK_GET`); see [[Message Chunking]]. +- **Signatures (ML-DSA, FIPS 204):** advertises **ML-DSA-44 / 65 / 87** in the + 1.4 `PqcAsymAlgo` field alongside ECDSA P-384; verifies whichever was + negotiated. See [[Post-Quantum ML-DSA]]. +- **Key exchange (ML-KEM, FIPS 203):** advertises **ML-KEM-512 / 768 / 1024** + as a `KEMAlg` struct alongside the ECDHE group; sends the encapsulation key and + decapsulates the responder's ciphertext. Standalone (not hybrid). See + [[Post-Quantum ML-KEM]]. + +Enabling both yields a **fully post-quantum SPDM handshake** (ML-KEM key +exchange + ML-DSA authentication). Large responses (e.g. an ML-DSA-87 signature, +or ML-DSA + the ML-KEM ciphertext, exceeding the negotiated `DataTransferSize`) +are reassembled with **SPDM 1.2 message chunking** (`CHUNK_GET`); see +[[Message Chunking]]. ## Notable implementation scope diff --git a/docs/Testing-and-CI.md b/docs/Testing-and-CI.md index cbc92fa..6ada247 100644 --- a/docs/Testing-and-CI.md +++ b/docs/Testing-and-CI.md @@ -39,12 +39,15 @@ Documented workflows include: - CodeQL Security - Codespell - SPDM Emulator Test (integration matrix on x64 + aarch64) -- SPDM Emulator PQC (ML-DSA) Test — wolfSSL master + spdm-emu (OpenSSL backend), - ML-DSA-44/65 session/measurements/challenge over the wire +- SPDM Emulator PQC Test — wolfSSL master + spdm-emu (OpenSSL backend) on the + full x64 + aarch64 matrix. Builds wolfSPDM ML-KEM-only as well as the combined + config, then runs over the wire: ML-DSA-44/65/87 (signatures), ML-KEM-512/768/1024 + (key exchange), and a **fully post-quantum** leg (ML-KEM-768 + ML-DSA-65/87) for + session, measurements, and challenge. See `.github/workflows/README.md` for workflow inventory details. -## ML-DSA (post-quantum) test coverage +## ML-DSA (post-quantum signatures) test coverage - **Unit (`make check`, ML-DSA build):** PqcAsymAlgo/PqcAsymSel wire offsets and the Base/Pqc mutual-exclusion; a real wolfSSL ML-DSA sign + verify round-trip @@ -54,9 +57,24 @@ See `.github/workflows/README.md` for workflow inventory details. validated against the actual spdm-emu ML-DSA cert chains for all three levels (44 -> WC_ML_DSA_44, 65 -> 65, 87 -> 87), plus a negative case where a level-65 cert is rejected when ML-DSA-87 was negotiated (level pinning). -- **Over-the-wire (CI):** ML-DSA-44 and ML-DSA-65 complete against spdm-emu. - ML-DSA-87 responses exceed the 4608 B DataTransferSize and need the SPDM 1.2 - chunking engine (follow-on), so 87 is covered at the unit/cert level. +- **Over-the-wire (CI):** ML-DSA-44/65/87 all complete against spdm-emu; + ML-DSA-87 responses exceed the 4608 B DataTransferSize and are reassembled via + the SPDM 1.2 chunking engine (see [[Message Chunking]]). + +## ML-KEM (post-quantum key exchange) test coverage + +- **Unit (`make check`, ML-KEM build):** KEMAlg negotiation wire offsets, the + DHE-xor-KEM mutual-exclusion, a real wolfSSL ML-KEM encapsulate/decapsulate + round-trip asserting `K′ == K`, the KEY_EXCHANGE `ek` placement, the + KEY_EXCHANGE_RSP ciphertext-offset math, the reconnect key-type-switch (no + type-confused free), the KEM-only-below-1.4 refusal, and the oversized-request + fail-fast guard. +- **Over-the-wire (CI):** ML-KEM-512/768/1024 against spdm-emu (`--dhe NONE + --kem ML_KEM_*`), and a **fully post-quantum** leg pairing ML-KEM-768 with + ML-DSA-65/87 — the ML-DSA-87 case also exercises chunking, so ML-KEM + ML-DSA + + CHUNK_GET reassembly all run in a single handshake. +- **Config coverage (CI):** an ML-KEM-only build (`--disable-mldsa + --enable-mlkem`) exercises the ML-KEM-only `WOLFSPDM_CTX_STATIC_SIZE` budget. ## Validation caveat diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 16712c0..fca606f 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -4,6 +4,7 @@ - [[Getting Started]] - [[Supported Operations]] - [[Post-Quantum ML-DSA]] +- [[Post-Quantum ML-KEM]] - [[Message Chunking]] - [[API Reference]] - [[Configuration and Macros]]