feat(core): Use ML-DSA-44 for MCU device attestation key.
What changed, and why it matters
This commit swaps the cryptographic algorithm used for one of the Trezor hardware wallet's internal device-attestation keys from Ed25519 to ML-DSA-44, a post-quantum NIST-standardized signature scheme. It also enlarges the certificate storage area and doubles the production-test command-line input buffer. There is no direct evidence in the commit of a security vulnerability being fixed; it reads as a planned cryptographic modernization change.
Treat this as a high-risk cryptographic change rather than an active vulnerability. Review the integration of mldsa-native for constant-time properties, stack/heap usage, and side-channel behavior on the STM32U5 secure core. Verify that the new secret derivation path preserves the same security assumptions as the old curve25519-clamped derivation, and ensure certificate size limits and CLI buffer expansion do not introduce parsing or memory issues. Request a changelog entry and design documentation from the vendor.
Security signals we found
Algorithm migration from Ed25519 to ML-DSA-44 for device attestation
New third-party cryptographic submodule added (pq-code-package/mldsa-native)
Certificate storage size increased fourfold
CLI input buffer doubled, likely to handle larger signatures/keys
Secret key derivation path changed from curve25519 clamping to raw symmetric derivation
No changelog entry despite cryptographic change
Evidence from the diff
The patch replaces Ed25519-based MCU device attestation with ML-DSA-44 via the pq-code-package/mldsa-native library. It adds the mldsa-native submodule and build sources, updates secret_key_mcu_device_auth() to output an ML-DSA seed instead of an Ed25519 secret key, changes certificate-chain verification to accept the ML-DSA-44 OID and verify with crypto_sign_verify(), and increases SECRET_MCU_DEVICE_CERT_SIZE from 0x400 to 0x1000 to accommodate larger post-quantum certificates. The CLI_LINE_BUFFER_SIZE is doubled from 4096 to 8192, likely because ML-DSA signatures and public keys are much larger than Ed25519 equivalents. No changelog entry is present.
Changed components
core/embed/projects/prodtest/cmd/common.ccore/embed/projects/prodtest/cmd/prodtest_secrets.ccore/embed/sec/secret/stm32u5/secret_keys.ccore/embed/sec/secret/unix/secret_keys.ccore/embed/sec/secret/inc/sec/secret_keys.hcore/embed/models/T3W1/secret_layout.hcore/embed/rtl/inc/rtl/cli.hcore/SConscript.prodtestcore/SConscript.prodtest_emuvendor/mldsa-native (new submodule)Inspect captured patch +131 / −37
diff --git a/.gitmodules b/.gitmodules
index 94ecc045..c04fee7a 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -41,3 +41,7 @@
path = vendor/sphincsplus
url = https://github.com/sphincs/sphincsplus/
branch = consistent-basew
+[submodule "vendor/mldsa-native"]
+ path = vendor/mldsa-native
+ url = https://github.com/pq-code-package/mldsa-native.git
+ ignore = untracked
diff --git a/core/SConscript.prodtest b/core/SConscript.prodtest
index 6f6f59a8..31c91531 100644
--- a/core/SConscript.prodtest
+++ b/core/SConscript.prodtest
@@ -144,6 +144,17 @@ if 'boot_ucb' in FEATURES_AVAILABLE:
'vendor/sphincsplus/ref/wots.c',
]
+SOURCE_MLDSA = [
+ 'vendor/mldsa-native/mldsa/fips202/fips202.c',
+ 'vendor/mldsa-native/mldsa/fips202/fips202x4.c',
+ 'vendor/mldsa-native/mldsa/fips202/keccakf1600.c',
+ 'vendor/mldsa-native/mldsa/ntt.c',
+ 'vendor/mldsa-native/mldsa/packing.c',
+ 'vendor/mldsa-native/mldsa/poly.c',
+ 'vendor/mldsa-native/mldsa/polyvec.c',
+ 'vendor/mldsa-native/mldsa/sign.c',
+]
+
# modtrezorui
CPPPATH_MOD += [
'vendor/micropython/lib/uzlib'
@@ -303,6 +314,7 @@ obj_program.extend(env.Object(source=SOURCE_MOD))
obj_program.extend(env.Object(source=SOURCE_MOD_CRYPTO, CCFLAGS='$CCFLAGS -ftrivial-auto-var-init=zero'))
obj_program.extend(env.Object(source=SOURCE_PRODTEST))
obj_program.extend(env.Object(source=SOURCE_HAL))
+obj_program.extend(env.Object(source=SOURCE_MLDSA))
if 'boot_ucb' in FEATURES_AVAILABLE:
obj_program += env.Object(
diff --git a/core/SConscript.prodtest_emu b/core/SConscript.prodtest_emu
index d7d03762..b5790ac3 100644
--- a/core/SConscript.prodtest_emu
+++ b/core/SConscript.prodtest_emu
@@ -106,6 +106,16 @@ if FEATURE_FLAGS["AES_GCM"]:
'vendor/trezor-crypto/aes/aesgcm.c',
]
+SOURCE_MLDSA = [
+ 'vendor/mldsa-native/mldsa/fips202/fips202.c',
+ 'vendor/mldsa-native/mldsa/fips202/fips202x4.c',
+ 'vendor/mldsa-native/mldsa/fips202/keccakf1600.c',
+ 'vendor/mldsa-native/mldsa/ntt.c',
+ 'vendor/mldsa-native/mldsa/packing.c',
+ 'vendor/mldsa-native/mldsa/poly.c',
+ 'vendor/mldsa-native/mldsa/polyvec.c',
+ 'vendor/mldsa-native/mldsa/sign.c',
+]
# modtrezorui
CPPPATH_MOD += [
@@ -287,6 +297,7 @@ obj_program += env.Object(source=SOURCE_MOD)
obj_program += env.Object(source=SOURCE_MOD_CRYPTO, CCFLAGS='$CCFLAGS -ftrivial-auto-var-init=zero')
obj_program += env.Object(source=SOURCE_PRODTEST)
obj_program += env.Object(source=SOURCE_HAL)
+obj_program += env.Object(source=SOURCE_MLDSA)
program_elf = env.Command(
target='prodtest.elf',
diff --git a/core/embed/models/T3W1/secret_layout.h b/core/embed/models/T3W1/secret_layout.h
index 601a7a6b..0fedd8ab 100644
--- a/core/embed/models/T3W1/secret_layout.h
+++ b/core/embed/models/T3W1/secret_layout.h
@@ -45,7 +45,7 @@
#define SECRET_KEY_SLOT_2_PUBLIC 1
#define SECRET_MCU_DEVICE_CERT_OFFSET 0x870
-#define SECRET_MCU_DEVICE_CERT_SIZE 0x400
+#define SECRET_MCU_DEVICE_CERT_SIZE 0x1000
#define SECRET_LOCK_SLOT_OFFSET 0x1FF0
#define SECRET_LOCK_SLOT_LEN 0x10
diff --git a/core/embed/projects/prodtest/cmd/common.c b/core/embed/projects/prodtest/cmd/common.c
index ed772708..a8d533f7 100644
--- a/core/embed/projects/prodtest/cmd/common.c
+++ b/core/embed/projects/prodtest/cmd/common.c
@@ -32,6 +32,8 @@
#include "sha2.h"
#include "string.h"
+#include <../vendor/mldsa-native/mldsa/sign.h>
+
// Identifier of context-specific constructed tag 3, which is used for
// extensions in X.509.
#define DER_X509_EXTENSIONS 0xa3
@@ -59,6 +61,12 @@ static const uint8_t EDDSA_25519[] = {
0x2b, 0x65, 0x70, // corresponds to EdDSA 25519 in X.509
};
+static const uint8_t MLDSA44[] = {
+ 0x30, 0x0b, // a sequence of 11 bytes
+ 0x06, 0x09, // an OID of 9 bytes
+ 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x11, // corresponds to id-ml-dsa-44 in X.509
+};
+
static const uint8_t OID_COMMON_NAME[] = {
0x06, 0x03, // an OID of 3 bytes
0x55, 0x04, 0x03, // corresponds to commonName in X.509
@@ -87,7 +95,11 @@ static const uint8_t SUBJECT_COMMON_NAME[] = {
};
// clang-format on
-typedef enum { ALG_ID_ECDSA_P256_WITH_SHA256, ALG_ID_EDDSA_25519 } alg_id_t;
+typedef enum {
+ ALG_ID_ECDSA_P256_WITH_SHA256,
+ ALG_ID_EDDSA_25519,
+ ALG_ID_MLDSA44
+} alg_id_t;
static bool get_algorithm(DER_ITEM* alg, alg_id_t* alg_id) {
if (alg->buf.size == sizeof(ECDSA_P256_WITH_SHA256) &&
@@ -103,6 +115,12 @@ static bool get_algorithm(DER_ITEM* alg, alg_id_t* alg_id) {
return true;
}
+ if (alg->buf.size == sizeof(MLDSA44) &&
+ memcmp(alg->buf.data, MLDSA44, sizeof(MLDSA44)) == 0) {
+ *alg_id = ALG_ID_MLDSA44;
+ return true;
+ }
+
return false;
}
@@ -275,6 +293,19 @@ static bool verify_signature(alg_id_t alg_id, const uint8_t* pub_key,
return true;
}
+ if (alg_id == ALG_ID_MLDSA44) {
+ if (pub_key_size != CRYPTO_PUBLICKEYBYTES) {
+ return false;
+ }
+
+ if (crypto_sign_verify(sig, sig_size, msg, msg_size, (const uint8_t*)"", 0,
+ pub_key) != 0) {
+ return false;
+ }
+
+ return true;
+ }
+
return false;
}
diff --git a/core/embed/projects/prodtest/cmd/prodtest_secrets.c b/core/embed/projects/prodtest/cmd/prodtest_secrets.c
index b13777b4..e929ebe7 100644
--- a/core/embed/projects/prodtest/cmd/prodtest_secrets.c
+++ b/core/embed/projects/prodtest/cmd/prodtest_secrets.c
@@ -38,6 +38,8 @@
#include <trezor_model.h>
#endif
+#include <../vendor/mldsa-native/mldsa/sign.h>
+
secbool generate_random_secret(uint8_t* secret, size_t length) {
random_buffer(secret, length);
@@ -141,15 +143,20 @@ static void prodtest_secrets_get_mcu_device_key(cli_t* cli) {
return;
}
- ed25519_secret_key mcu_private = {0};
- if (secret_key_mcu_device_auth(mcu_private) != sectrue) {
+ uint8_t seed[MLDSA_SEEDBYTES] = {0};
+ if (secret_key_mcu_device_auth(seed) != sectrue) {
cli_error(cli, CLI_ERROR, "`secret_key_mcu_device_auth()` failed.");
- return;
+ goto cleanup;
+ }
+
+ uint8_t mcu_public[CRYPTO_PUBLICKEYBYTES] = {0};
+ uint8_t mcu_private[CRYPTO_SECRETKEYBYTES] = {0};
+ if (crypto_sign_keypair_internal(mcu_public, mcu_private, seed) != 0) {
+ cli_error(cli, CLI_ERROR, "`crypto_sign_keypair_internal()` failed.");
+ goto cleanup;
}
- ed25519_public_key mcu_public = {0};
- ed25519_publickey(mcu_private, mcu_public);
- uint8_t output[sizeof(ed25519_public_key) + NOISE_TAG_SIZE] = {0};
+ uint8_t output[sizeof(mcu_public) + NOISE_TAG_SIZE] = {0};
if (!secure_channel_encrypt(mcu_public, sizeof(mcu_public), NULL, 0,
output)) {
// `secure_channel_handshake_2()` might not have been called
@@ -160,31 +167,56 @@ static void prodtest_secrets_get_mcu_device_key(cli_t* cli) {
cli_ok_hexdata(cli, output, sizeof(output));
cleanup:
+ memzero(seed, sizeof(seed));
memzero(mcu_private, sizeof(mcu_private));
}
#ifndef TREZOR_EMULATOR
static bool check_device_cert_chain(cli_t* cli, const uint8_t* chain,
size_t chain_size) {
- ed25519_secret_key mcu_private = {0};
- if (secret_key_mcu_device_auth(mcu_private) != sectrue) {
+ bool ret = false;
+
+ uint8_t seed[MLDSA_SEEDBYTES] = {0};
+ if (secret_key_mcu_device_auth(seed) != sectrue) {
cli_error(cli, CLI_ERROR, "`secret_key_mcu_device_auth()` failed.");
- return false;
+ goto cleanup;
+ }
+
+ uint8_t mcu_public[CRYPTO_PUBLICKEYBYTES] = {0};
+ uint8_t mcu_private[CRYPTO_SECRETKEYBYTES] = {0};
+ if (crypto_sign_keypair_internal(mcu_public, mcu_private, seed) != 0) {
+ cli_error(cli, CLI_ERROR, "`crypto_sign_keypair_internal()` failed.");
+ goto cleanup;
}
+ uint8_t rnd[MLDSA_RNDBYTES] = {0};
+ random_buffer(rnd, sizeof(rnd));
+
// The challenge is intentionally constant zero.
+ const uint8_t ENCODED_EMPTY_CONTEXT_STRING[] = {0, 0};
uint8_t challenge[CHALLENGE_SIZE] = {0};
- ed25519_signature signature = {0};
- ed25519_sign(challenge, sizeof(challenge), mcu_private, signature);
- memzero(mcu_private, sizeof(mcu_private));
+ uint8_t signature[CRYPTO_BYTES] = {0};
+ size_t siglen = 0;
+ if (crypto_sign_signature_internal(
+ signature, &siglen, challenge, sizeof(challenge),
+ ENCODED_EMPTY_CONTEXT_STRING, sizeof(ENCODED_EMPTY_CONTEXT_STRING),
+ rnd, mcu_private, 0) != 0) {
+ cli_error(cli, CLI_ERROR, "`crypto_sign_signature()` failed.");
+ goto cleanup;
+ }
- if (!check_cert_chain(cli, chain, chain_size, signature, sizeof(signature),
- challenge)) {
+ if (!check_cert_chain(cli, chain, chain_size, signature, siglen, challenge)) {
// Error returned by check_cert_chain().
- return false;
+ goto cleanup;
}
- return true;
+ ret = true;
+
+cleanup:
+ memzero(seed, sizeof(seed));
+ memzero(mcu_private, sizeof(mcu_private));
+ memzero(rnd, sizeof(rnd));
+ return ret;
}
#endif
diff --git a/core/embed/rtl/inc/rtl/cli.h b/core/embed/rtl/inc/rtl/cli.h
index 6c36d1d6..ebea207e 100644
--- a/core/embed/rtl/inc/rtl/cli.h
+++ b/core/embed/rtl/inc/rtl/cli.h
@@ -26,7 +26,7 @@
typedef struct cli cli_t;
// Maximum length of command line input (including command, arguments)
-#define CLI_LINE_BUFFER_SIZE 4096
+#define CLI_LINE_BUFFER_SIZE 8192
// Maximum number of command arguments + 1
#define CLI_MAX_ARGS 64
diff --git a/core/embed/sec/secret/inc/sec/secret_keys.h b/core/embed/sec/secret/inc/sec/secret_keys.h
index dafed935..cf13ad0d 100644
--- a/core/embed/sec/secret/inc/sec/secret_keys.h
+++ b/core/embed/sec/secret/inc/sec/secret_keys.h
@@ -27,9 +27,9 @@
#define SECRET_KEY_MASKING
-#include <ed25519-donna/ed25519.h>
+#include <../vendor/mldsa-native/mldsa/params.h>
-secbool secret_key_mcu_device_auth(ed25519_secret_key dest);
+secbool secret_key_mcu_device_auth(uint8_t dest[MLDSA_SEEDBYTES]);
#endif // SECRET_MASTER_KEY_SLOT_SIZE
diff --git a/core/embed/sec/secret/stm32u5/secret_keys.c b/core/embed/sec/secret/stm32u5/secret_keys.c
index 6924151e..41b448aa 100644
--- a/core/embed/sec/secret/stm32u5/secret_keys.c
+++ b/core/embed/sec/secret/stm32u5/secret_keys.c
@@ -71,17 +71,6 @@ cleanup:
return ret;
}
-static secbool secret_key_derive_curve25519(uint8_t slot, uint16_t index,
- curve25519_key dest) {
- _Static_assert(sizeof(curve25519_key) == SHA256_DIGEST_LENGTH);
-
- secbool ret = secret_key_derive_sym(slot, index, 0, dest);
- dest[0] &= 248;
- dest[31] &= 127;
- dest[31] |= 64;
- return ret;
-}
-
#if defined(USE_OPTIGA) || defined(USE_TROPIC)
static secbool secret_key_derive_nist256p1(
uint8_t slot, uint16_t index, uint8_t dest[ECDSA_PRIVATE_KEY_SIZE]) {
@@ -114,9 +103,10 @@ cleanup:
}
#endif
-secbool secret_key_mcu_device_auth(ed25519_secret_key dest) {
- return secret_key_derive_curve25519(SECRET_PRIVILEGED_MASTER_KEY_SLOT,
- KEY_INDEX_MCU_DEVICE_AUTH, dest);
+secbool secret_key_mcu_device_auth(uint8_t dest[MLDSA_SEEDBYTES]) {
+ _Static_assert(MLDSA_SEEDBYTES == SHA256_DIGEST_LENGTH);
+ return secret_key_derive_sym(SECRET_PRIVILEGED_MASTER_KEY_SLOT,
+ KEY_INDEX_MCU_DEVICE_AUTH, 0, dest);
}
#ifdef USE_OPTIGA
@@ -133,6 +123,17 @@ secbool secret_key_optiga_masking(uint8_t dest[ECDSA_PRIVATE_KEY_SIZE]) {
#endif // USE_OPTIGA
#ifdef USE_TROPIC
+static secbool secret_key_derive_curve25519(uint8_t slot, uint16_t index,
+ curve25519_key dest) {
+ _Static_assert(sizeof(curve25519_key) == SHA256_DIGEST_LENGTH);
+
+ secbool ret = secret_key_derive_sym(slot, index, 0, dest);
+ dest[0] &= 248;
+ dest[31] &= 127;
+ dest[31] |= 64;
+ return ret;
+}
+
secbool secret_key_tropic_public(curve25519_key dest) {
return secret_key_get(SECRET_TROPIC_TROPIC_PUBKEY_SLOT, dest,
sizeof(curve25519_key));
diff --git a/core/embed/sec/secret/unix/secret_keys.c b/core/embed/sec/secret/unix/secret_keys.c
index 660401d1..145f082e 100644
--- a/core/embed/sec/secret/unix/secret_keys.c
+++ b/core/embed/sec/secret/unix/secret_keys.c
@@ -44,8 +44,9 @@ _Static_assert(sizeof(SECRET_TROPIC_PAIRING_BYTES) == sizeof(curve25519_key),
_Static_assert(sizeof(SECRET_TROPIC_PUBKEY_BYTES) == sizeof(curve25519_key),
"Invalid size of Tropic public key");
-secbool secret_key_mcu_device_auth(ed25519_secret_key dest) {
- memset(dest, 3, sizeof(ed25519_secret_key));
+secbool secret_key_mcu_device_auth(uint8_t dest[MLDSA_SEEDBYTES]) {
+ _Static_assert(MLDSA_SEEDBYTES == SHA256_DIGEST_LENGTH);
+ memset(dest, 3, SHA256_DIGEST_LENGTH);
return sectrue;
}
diff --git a/core/vendor/mldsa-native b/core/vendor/mldsa-native
new file mode 120000
index 00000000..a4189375
--- /dev/null
+++ b/core/vendor/mldsa-native
@@ -0,0 +1 @@
+../../vendor/mldsa-native/
\ No newline at end of file
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.