feat(trezorctl): Add ML-DSA-44 device authenticity check.
What changed, and why it matters
This commit adds support for a new post-quantum digital-signature algorithm (ML-DSA-44) to the Python trezorctl tool's device-authenticity check. It does not change how the hardware wallet itself works; it only lets the desktop companion software recognize and verify a new type of manufacturer signature on some devices. The change also moves hard-coded root public keys into a separate internal module and updates the required cryptography library version. There is no indication in the commit that this fixes a security bug or vulnerability.
No immediate security action is required. Treat this as a routine feature update. If deploying trezorctl, ensure the cryptography package is upgraded to a version >=47 so ML-DSA-44 verification works, and verify that the new _root_keys module is present in the installed package. Review the new _root_keys constants for correctness if you maintain a fork or custom build.
Security signals we found
Adds new cryptographic signature scheme (ML-DSA-44) for device authenticity verification
Bumps cryptography dependency minimum to version 47, which supplies ML-DSA primitives
Refactors hard-coded root public keys into internal _root_keys module
Adds CLI option --mldsa44-root for custom root public key
No removal of existing verification paths; existing P-256 and Ed25519 checks remain intact
Evidence from the diff
The patch extends trezorlib.authentication to handle ML-DSA-44 (FIPS 204) public keys and certificates (OID 2.16.840.1.101.3.4.3.17). It adds Mldsa44PublicKey wrapper class, wires it into RootCertificate.pubkey_for_oid, and updates authenticate_device to optionally verify an MCU signature against an ML-DSA-44 root. The CLI gains an –mldsa44-root option and raw printing of mcu_signature/mcu_certificates. Root public-key literals are replaced by imports from a new _root_keys module. The cryptography dependency is bumped from >=41 to >=47, likely because ML-DSA support arrived in cryptography 47. This is a feature addition for future/alternate device authenticity verification, not a patch for an existing flaw.
Changed components
python/src/trezorlib/authentication.pypython/src/trezorlib/cli/device.pypython/pyproject.tomlpython/.changelog.d/6826.addeduv.lockInspect captured patch +108 / −45
diff --git a/python/.changelog.d/6826.added b/python/.changelog.d/6826.added
new file mode 100644
index 00000000..ad415d24
--- /dev/null
+++ b/python/.changelog.d/6826.added
@@ -0,0 +1 @@
+Add ML-DSA-44 device authenticity check.
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 3d87804a..ea370f03 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -29,7 +29,7 @@ dependencies = [
"construct>=2.9,!=2.10.55",
"typing_extensions>=4.7.1",
"construct-classes>=0.1.2",
- "cryptography>=41",
+ "cryptography>=47",
"noiseprotocol>=0.3.1,<0.4.0",
"platformdirs>=4.4.0",
"keyring>=25.7.0",
diff --git a/python/src/trezorlib/authentication.py b/python/src/trezorlib/authentication.py
index 3c780c59..2d4139b8 100644
--- a/python/src/trezorlib/authentication.py
+++ b/python/src/trezorlib/authentication.py
@@ -23,10 +23,10 @@ import typing as t
from cryptography import exceptions, x509
from cryptography.hazmat.primitives import hashes, serialization
-from cryptography.hazmat.primitives.asymmetric import ec, ed25519, types, utils
+from cryptography.hazmat.primitives.asymmetric import ec, ed25519, mldsa, types, utils
from cryptography.x509.oid import NameOID, ObjectIdentifier, SignatureAlgorithmOID
-from . import device
+from . import _root_keys, device
from .client import Session
from .tools import workflow
@@ -41,6 +41,10 @@ def _pk_ed25519(pubkey_hex: str) -> PublicKey:
return Ed25519PublicKey.from_bytes(bytes.fromhex(pubkey_hex))
+def _pk_mldsa44(pubkey_hex: str) -> PublicKey:
+ return Mldsa44PublicKey.from_bytes(bytes.fromhex(pubkey_hex))
+
+
CHALLENGE_HEADER = b"AuthenticateDevice:"
OID_TO_NAME = {
@@ -54,6 +58,8 @@ OID_TO_NAME = {
NameOID.DN_QUALIFIER: "DNQ",
}
+MLDSA44_SIGNATURE_ALG_OID = ObjectIdentifier("2.16.840.1.101.3.4.3.17")
+
class DeviceNotAuthentic(Exception):
pass
@@ -87,6 +93,8 @@ class PublicKey:
return EcdsaPublicKey.from_bytes(data, ec.SECP256R1())
elif oid == SignatureAlgorithmOID.ED25519:
return Ed25519PublicKey.from_bytes(data)
+ elif oid == MLDSA44_SIGNATURE_ALG_OID:
+ return Mldsa44PublicKey.from_bytes(data)
else:
raise ValueError("Unsupported key type.")
@@ -96,6 +104,8 @@ class PublicKey:
return EcdsaPublicKey(pubkey)
elif isinstance(pubkey, ed25519.Ed25519PublicKey):
return Ed25519PublicKey(pubkey)
+ elif isinstance(pubkey, mldsa.MLDSA44PublicKey):
+ return Mldsa44PublicKey(pubkey)
else:
raise ValueError("Unsupported key type.")
@@ -214,12 +224,48 @@ class Ed25519PublicKey(PublicKey):
)
+class Mldsa44PublicKey(PublicKey):
+ def __init__(
+ self, pubkey: mldsa.MLDSA44PublicKey | None = None, *, raw: bytes | None = None
+ ) -> None:
+ assert (pubkey is None) != (raw is None), "Set exactly one of pubkey or raw."
+ self._pubkey = pubkey
+ self._raw = raw
+
+ @classmethod
+ def from_bytes(cls, data: bytes) -> Mldsa44PublicKey:
+ # Defer construction of the underlying key until it is first used, so that we don't require
+ # ML-DSA-44 backend support unconditionally at import time.
+ return cls(raw=bytes(data))
+
+ @property
+ def pubkey(self) -> mldsa.MLDSA44PublicKey:
+ if self._pubkey is None:
+ assert self._raw is not None
+ self._pubkey = mldsa.MLDSA44PublicKey.from_public_bytes(self._raw)
+ return self._pubkey
+
+ def to_bytes(self) -> bytes:
+ if self._raw is None:
+ self._raw = self.pubkey.public_bytes_raw()
+ return self._raw
+
+ def verify_message(self, *, signature: bytes, message: bytes) -> None:
+ self.pubkey.verify(signature, message)
+
+ def verify_certificate(self, certificate: x509.Certificate) -> None:
+ self.verify_message(
+ signature=certificate.signature, message=certificate.tbs_certificate_bytes
+ )
+
+
class RootCertificate(t.NamedTuple):
name: str
device: str
devel: bool
p256_pubkey: PublicKey
ed25519_pubkey: PublicKey | None = None
+ mldsa44_pubkey: PublicKey | None = None
def pubkey_for_oid(self, oid: ObjectIdentifier) -> PublicKey:
if oid == SignatureAlgorithmOID.ECDSA_WITH_SHA256:
@@ -228,6 +274,10 @@ class RootCertificate(t.NamedTuple):
if self.ed25519_pubkey is None:
raise ValueError("ED25519 public key not set.")
return self.ed25519_pubkey
+ elif oid == MLDSA44_SIGNATURE_ALG_OID:
+ if self.mldsa44_pubkey is None:
+ raise ValueError("ML-DSA-44 public key not set.")
+ return self.mldsa44_pubkey
else:
raise ValueError("Unsupported key type.")
@@ -238,91 +288,69 @@ ROOT_PUBLIC_KEYS = [
"Trezor Company",
"Trezor Safe 3",
False,
- _pk_p256(
- "04ca97480ac0d7b1e6efafe518cd433cec2bf8ab9822d76eafd34363b55d63e60"
- "380bff20acc75cde03cffcb50ab6f8ce70c878e37ebc58ff7cca0a83b16b15fa5"
- ),
+ _pk_p256(_root_keys.T2B1_DEV_AUTH_ROOT_PROD_P256_HEX),
),
RootCertificate(
# Root production key for T3B1.
"Trezor Company",
"Trezor Safe 3",
False,
- _pk_p256(
- "045b5c3fdd01f3602092834209b86df0ca86a9faf25cac35c73bf6237d66eb21e"
- "afcec3706f1ccd5eb4cc7f2fa1751213eccb1c78389afba89a5788ff31ee46a5d"
- ),
+ _pk_p256(_root_keys.T3B1_DEV_AUTH_ROOT_PROD_P256_HEX),
),
RootCertificate(
+ # Root production key for T3T1.
"Trezor Company",
"Trezor Safe 5",
False,
- _pk_p256(
- "041854b27fb1d9f65abb66828e78c9dc0ca301e66081ab0c6a4d104f9df1cd0ad"
- "5a7c75f77a8c092f55cf825d2abaf734f934c9394d5e75f75a5a06a5ee9be93ae"
- ),
+ _pk_p256(_root_keys.T3T1_DEV_AUTH_ROOT_PROD_P256_HEX),
),
RootCertificate(
# Root production keys for T3W1.
"Trezor Company",
"Trezor Safe 7",
False,
- _pk_p256(
- "040dde0d3e0d4da593fac6fd02a461d0e7eef238aca55c7c50b4e9ec37f387330"
- "3b6429ef1c9b78b4411a7dcbbc5dde5225979c1c2da3b073e82b1ed3f5f9825bb"
- ),
- _pk_ed25519("59237acd17134061d655b3f8d624573ca06ce8d862f38ba4e05140ce1d3d609d"),
+ _pk_p256(_root_keys.T3W1_DEV_AUTH_ROOT_PROD_P256_HEX),
+ _pk_ed25519(_root_keys.T3W1_DEV_AUTH_ROOT_PROD_ED25519_HEX),
+ _pk_mldsa44(_root_keys.T3W1_DEV_AUTH_ROOT_PROD_MLDSA44_HEX),
),
RootCertificate(
# Root backup production keys for T3W1.
"Trezor Company",
"Trezor Safe 7",
False,
- _pk_p256(
- "04c6a673af4ec44b10441b1d78676e15173ad0e36df9f7f2fa1cd819955f20fe3"
- "2917b60da5fed3b3aa54a9ab8b3ed27d198b3768cad26eef5935cd87af0af065e"
- ),
- _pk_ed25519("5612606584ee7e0bc313b13f7ac94156bb4cb75bd77585ddbe579301306e85f1"),
+ _pk_p256(_root_keys.T3W1_DEV_AUTH_ROOT_PROD_BACKUP_P256_HEX),
+ _pk_ed25519(_root_keys.T3W1_DEV_AUTH_ROOT_PROD_BACKUP_ED25519_HEX),
+ _pk_mldsa44(_root_keys.T3W1_DEV_AUTH_ROOT_PROD_BACKUP_MLDSA44_HEX),
),
RootCertificate(
# Root debug key for T2B1 and T3B1.
"TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
"Trezor Safe 3",
True,
- _pk_p256(
- "047f77368dea2d4d61e989f474a56723c3212dacf8a808d8795595ef38441427c"
- "4389bc454f02089d7f08b873005e4c28d432468997871c0bf286fd3861e21e96a"
- ),
+ _pk_p256(_root_keys.T2B1_DEV_AUTH_ROOT_DEBUG_P256_HEX),
),
RootCertificate(
+ # Root debug key for T3T1.
"TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
"Trezor Safe 5",
True,
- _pk_p256(
- "04e48b69cd7962068d3cca3bcc6b1747ef496c1e28b5529e34ad7295215ea161d"
- "be8fb08ae0479568f9d2cb07630cb3e52f4af0692102da5873559e45e9fa72959"
- ),
+ _pk_p256(_root_keys.T3T1_DEV_AUTH_ROOT_DEBUG_P256_HEX),
),
RootCertificate(
# Root debug keys for T3W1.
"TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
"Trezor Safe 7",
True,
- _pk_p256(
- "04521192e173a9da4e3023f747d836563725372681eba3079c56ff11b2fc137ab"
- "189eb4155f371127651b5594f8c332fc1e9c0f3b80d4212822668b63189706578"
- ),
+ _pk_p256(_root_keys.T3W1_DEV_AUTH_ROOT_DEBUG_P256_HEX),
),
RootCertificate(
# Root staging keys for T3W1.
"TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
"Trezor Safe 7",
- False,
- _pk_p256(
- "0465e88f9b2cea67e8364f0cfcfacd500af24e9040b357beee629ccc4fce1704d"
- "1a7ef7284f387708f92ef14600e2caad6894016fee819d623b95d66210c3e7519"
- ),
- _pk_ed25519("cd318dc8405ae4f4144e3284dcb7b0cb0f0c2195c2ca14a0f6fccd9104e32a4b"),
+ True,
+ _pk_p256(_root_keys.T3W1_DEV_AUTH_ROOT_STAGING_P256_HEX),
+ _pk_ed25519(_root_keys.T3W1_DEV_AUTH_ROOT_STAGING_ED25519_HEX),
+ _pk_mldsa44(_root_keys.T3W1_DEV_AUTH_ROOT_STAGING_MLDSA44_HEX),
),
]
@@ -550,6 +578,7 @@ def authenticate_device(
allow_development_devices: bool = False,
p256_root_pubkey: bytes | PublicKey | None = None,
ed25519_root_pubkey: bytes | PublicKey | None = None,
+ mldsa44_root_pubkey: bytes | PublicKey | None = None,
) -> None:
if challenge is None:
challenge = secrets.token_bytes(16)
@@ -585,3 +614,24 @@ def authenticate_device(
if optiga_root is not tropic_root:
LOG.error("Certificates issued by different root authorities.")
raise DeviceNotAuthentic
+
+ if (
+ getattr(optiga_root, "mldsa44_pubkey", None) is not None
+ or mldsa44_root_pubkey is not None
+ ):
+ if not resp.mcu_signature:
+ LOG.error("Missing MCU signature.")
+ raise DeviceNotAuthentic
+
+ mcu_root = verify_authentication_response(
+ challenge,
+ resp.mcu_signature,
+ resp.mcu_certificates,
+ allowlist=allowlist,
+ allow_development_devices=allow_development_devices,
+ root_pubkey=mldsa44_root_pubkey,
+ )
+
+ if optiga_root is not mcu_root:
+ LOG.error("Certificates issued by different root authorities.")
+ raise DeviceNotAuthentic
diff --git a/python/src/trezorlib/cli/device.py b/python/src/trezorlib/cli/device.py
index 2a1b1ff5..e8972fe6 100644
--- a/python/src/trezorlib/cli/device.py
+++ b/python/src/trezorlib/cli/device.py
@@ -401,6 +401,9 @@ def _print_auth_data(signature: bytes, certificates: t.Sequence[bytes]) -> None:
@click.option(
"--ed25519-root", type=click.File("rb"), help="Custom root Ed25519 public key."
)
+@click.option(
+ "--mldsa44-root", type=click.File("rb"), help="Custom root ML-DSA-44 public key."
+)
@click.option(
"-r", "--raw", is_flag=True, help="Print raw cryptographic data and exit."
)
@@ -416,6 +419,7 @@ def authenticate(
hex_challenge: str | None,
p256_root: t.BinaryIO | None,
ed25519_root: t.BinaryIO | None,
+ mldsa44_root: t.BinaryIO | None,
raw: bool | None,
offline: bool | None,
) -> None:
@@ -440,6 +444,8 @@ def authenticate(
_print_auth_data(msg.optiga_signature, msg.optiga_certificates)
if msg.tropic_signature is not None:
_print_auth_data(msg.tropic_signature, msg.tropic_certificates)
+ if msg.mcu_signature is not None:
+ _print_auth_data(msg.mcu_signature, msg.mcu_certificates)
return
if p256_root is not None:
@@ -452,6 +458,11 @@ def authenticate(
else:
ed25519_root_bytes = None
+ if mldsa44_root is not None:
+ mldsa44_root_bytes = mldsa44_root.read()
+ else:
+ mldsa44_root_bytes = None
+
class ColoredFormatter(logging.Formatter):
LEVELS = {
logging.ERROR: click.style("ERROR", fg="red"),
@@ -495,6 +506,7 @@ def authenticate(
challenge,
p256_root_pubkey=p256_root_bytes,
ed25519_root_pubkey=ed25519_root_bytes,
+ mldsa44_root_pubkey=mldsa44_root_bytes,
allowlist=allowlist,
)
except authentication.DeviceNotAuthentic:
diff --git a/uv.lock b/uv.lock
index 2028049d..fbcaf99e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -8,7 +8,7 @@ resolution-markers = [
]
[options]
-exclude-newer = "2026-05-04T23:41:51.098266923Z"
+exclude-newer = "2026-05-09T13:36:00.892620957Z"
exclude-newer-span = "P30D"
[options.exclude-newer-package]
@@ -2146,7 +2146,7 @@ requires-dist = [
{ name = "click", specifier = ">=8,<9" },
{ name = "construct", specifier = ">=2.9,!=2.10.55" },
{ name = "construct-classes", specifier = ">=0.1.2" },
- { name = "cryptography", specifier = ">=41" },
+ { name = "cryptography", specifier = ">=47" },
{ name = "hidapi", marker = "extra == 'full'", specifier = ">=0.7.99.post20" },
{ name = "hidapi", marker = "extra == 'hidapi'", specifier = ">=0.7.99.post20" },
{ name = "keyring", specifier = ">=25.7.0" },
Why this scored 17/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.