feat(python): Check Tropic fields to AuthenticityProof.
What changed, and why it matters
This commit updates the Trezor Python library's device-authentication code to support a new hardware security chip (Tropic) in addition to the existing Optiga chip. It adds Ed25519 signature verification, new root public keys for the T3W1 device, and a check that both chips' certificate chains are issued by the same root authority. The change is a feature addition rather than a fix for a known vulnerability, but it strengthens the authentication process by making it harder for a device with a valid certificate from one root to pass authentication using a mismatched certificate from another root.
No immediate action required; this is a defensive feature. Users and integrators relying on device authentication should update to a version containing this commit and ensure they validate both Optiga and Tropic proof paths when present. Review whether the new root keys and cross-check logic are correctly integrated with firmware releases for T3W1.
Security signals we found
Adds cross-check that Optiga and Tropic certificate chains resolve to the same root authority
Adds Ed25519 signature and certificate verification support
Adds new T3W1 root public keys (production, backup, staging, debug)
Renames CLI option from generic `--root` to key-type-specific `--p256_root` and `--ed25519_root`
Returns matched root certificate from verification to enable comparison
Evidence from the diff
The patch extends trezorlib/authentication.py and the CLI in cli/device.py to handle a second AuthenticityProof path: optiga_signature/optiga_certificates and tropic_signature/tropic_certificates. It introduces Ed25519 public-key handling alongside existing P-256 ECDSA, adds root certificates for T3W1 (production, backup, staging, debug), and changes verify_authentication_response to return the matched RootCertificate. authenticate_device now verifies both proof paths and raises DeviceNotAuthentic if the returned root authorities differ. The CLI gains separate --p256_root and --ed25519_root options and prints both proof paths in raw mode.
Changed components
python/src/trezorlib/authentication.pypython/src/trezorlib/cli/device.pyInspect captured patch +185 / −45
diff --git a/python/.changelog.d/5760.added b/python/.changelog.d/5760.added
new file mode 100644
index 00000000..848aa600
--- /dev/null
+++ b/python/.changelog.d/5760.added
@@ -0,0 +1 @@
+Check Tropic fields to AuthenticityProof.
diff --git a/python/src/trezorlib/authentication.py b/python/src/trezorlib/authentication.py
index 7fb8d76e..b0d91594 100644
--- a/python/src/trezorlib/authentication.py
+++ b/python/src/trezorlib/authentication.py
@@ -23,7 +23,8 @@ import typing as t
from cryptography import exceptions, x509
from cryptography.hazmat.primitives import hashes, serialization
-from cryptography.hazmat.primitives.asymmetric import ec, utils
+from cryptography.hazmat.primitives.asymmetric import ec, ed25519, utils
+from cryptography.x509.oid import NameOID, ObjectIdentifier, SignatureAlgorithmOID
from . import device
from .transport.session import Session
@@ -37,14 +38,30 @@ def _pk_p256(pubkey_hex: str) -> ec.EllipticCurvePublicKey:
)
+def _pk_ed25519(pubkey_hex: str) -> ed25519.Ed25519PublicKey:
+ return ed25519.Ed25519PublicKey.from_public_bytes(bytes.fromhex(pubkey_hex))
+
+
CHALLENGE_HEADER = b"AuthenticateDevice:"
+OID_TO_NAME = {
+ NameOID.COMMON_NAME: "CN",
+ NameOID.LOCALITY_NAME: "L",
+ NameOID.STATE_OR_PROVINCE_NAME: "ST",
+ NameOID.ORGANIZATION_NAME: "O",
+ NameOID.ORGANIZATIONAL_UNIT_NAME: "OU",
+ NameOID.COUNTRY_NAME: "C",
+ NameOID.SERIAL_NUMBER: "SERIALNUMBER",
+ NameOID.DN_QUALIFIER: "DNQ",
+}
+
class RootCertificate(t.NamedTuple):
name: str
device: str
devel: bool
- pubkey: ec.EllipticCurvePublicKey
+ p256_pubkey: ec.EllipticCurvePublicKey
+ ed25519_pubkey: ed25519.Ed25519PublicKey | None = None
ROOT_PUBLIC_KEYS = [
@@ -66,6 +83,28 @@ ROOT_PUBLIC_KEYS = [
"5a7c75f77a8c092f55cf825d2abaf734f934c9394d5e75f75a5a06a5ee9be93ae"
),
),
+ RootCertificate(
+ # Root production keys for T3W1.
+ "Trezor Company",
+ "Trezor T3W1",
+ False,
+ _pk_p256(
+ "040dde0d3e0d4da593fac6fd02a461d0e7eef238aca55c7c50b4e9ec37f387330"
+ "3b6429ef1c9b78b4411a7dcbbc5dde5225979c1c2da3b073e82b1ed3f5f9825bb"
+ ),
+ _pk_ed25519("59237acd17134061d655b3f8d624573ca06ce8d862f38ba4e05140ce1d3d609d"),
+ ),
+ RootCertificate(
+ # Root backup production keys for T3W1.
+ "Trezor Company",
+ "Trezor T3W1",
+ False,
+ _pk_p256(
+ "04c6a673af4ec44b10441b1d78676e15173ad0e36df9f7f2fa1cd819955f20fe3"
+ "2917b60da5fed3b3aa54a9ab8b3ed27d198b3768cad26eef5935cd87af0af065e"
+ ),
+ _pk_ed25519("5612606584ee7e0bc313b13f7ac94156bb4cb75bd77585ddbe579301306e85f1"),
+ ),
RootCertificate(
"TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
"Trezor Safe 3",
@@ -85,6 +124,7 @@ ROOT_PUBLIC_KEYS = [
),
),
RootCertificate(
+ # Root debug keys for T3W1.
"TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
"Trezor T3W1",
True,
@@ -93,6 +133,17 @@ ROOT_PUBLIC_KEYS = [
"189eb4155f371127651b5594f8c332fc1e9c0f3b80d4212822668b63189706578"
),
),
+ RootCertificate(
+ # Root staging keys for T3W1.
+ "TESTING ENVIRONMENT. DO NOT USE THIS DEVICE",
+ "Trezor T3W1",
+ False,
+ _pk_p256(
+ "0465e88f9b2cea67e8364f0cfcfacd500af24e9040b357beee629ccc4fce1704d"
+ "1a7ef7284f387708f92ef14600e2caad6894016fee819d623b95d66210c3e7519"
+ ),
+ _pk_ed25519("cd318dc8405ae4f4144e3284dcb7b0cb0f0c2195c2ca14a0f6fccd9104e32a4b"),
+ ),
]
@@ -106,31 +157,60 @@ class Certificate:
self.cert = x509.load_der_x509_certificate(cert_bytes)
def __str__(self) -> str:
- return self.cert.subject.rfc4514_string()
+ return self.cert.subject.rfc4514_string(OID_TO_NAME)
def public_key_bytes(self) -> bytes:
- return self.cert.public_key().public_bytes(
- serialization.Encoding.X962,
- serialization.PublicFormat.UncompressedPoint,
- )
+ cert_pubkey = self.cert.public_key()
+ if isinstance(cert_pubkey, ec.EllipticCurvePublicKey):
+ return cert_pubkey.public_bytes(
+ serialization.Encoding.X962,
+ serialization.PublicFormat.UncompressedPoint,
+ )
+ elif isinstance(cert_pubkey, ed25519.Ed25519PublicKey):
+ return cert_pubkey.public_bytes(
+ serialization.Encoding.Raw,
+ serialization.PublicFormat.Raw,
+ )
+ else:
+ raise ValueError("Unsupported key type.")
def verify(self, signature: bytes, message: bytes) -> None:
cert_pubkey = self.cert.public_key()
- assert isinstance(cert_pubkey, ec.EllipticCurvePublicKey)
- cert_pubkey.verify(
- self.fix_signature(signature),
- message,
- ec.ECDSA(hashes.SHA256()),
- )
+ if isinstance(cert_pubkey, ec.EllipticCurvePublicKey):
+ cert_pubkey.verify(
+ self.fix_signature(signature),
+ message,
+ ec.ECDSA(hashes.SHA256()),
+ )
+ elif isinstance(cert_pubkey, ed25519.Ed25519PublicKey):
+ cert_pubkey.verify(
+ signature,
+ message,
+ )
+ else:
+ raise ValueError("Unsupported key type.")
+
+ def verify_by(
+ self, pubkey: ec.EllipticCurvePublicKey | ed25519.Ed25519PublicKey
+ ) -> None:
+ if isinstance(pubkey, ec.EllipticCurvePublicKey):
+ algo_params = self.cert.signature_algorithm_parameters
+ assert isinstance(algo_params, ec.ECDSA)
+ pubkey.verify(
+ self.fix_signature(self.cert.signature),
+ self.cert.tbs_certificate_bytes,
+ algo_params,
+ )
+ elif isinstance(pubkey, ed25519.Ed25519PublicKey):
+ pubkey.verify(
+ self.cert.signature,
+ self.cert.tbs_certificate_bytes,
+ )
+ else:
+ raise ValueError("Unsupported key type.")
- def verify_by(self, pubkey: ec.EllipticCurvePublicKey) -> None:
- algo_params = self.cert.signature_algorithm_parameters
- assert isinstance(algo_params, ec.ECDSA)
- pubkey.verify(
- self.fix_signature(self.cert.signature),
- self.cert.tbs_certificate_bytes,
- algo_params,
- )
+ def signature_algorithm_oid(self) -> ObjectIdentifier:
+ return self.cert.signature_algorithm_oid
def _check_ca_extensions(self) -> bool:
"""Check that this certificate is a valid Trezor CA.
@@ -209,7 +289,9 @@ class Certificate:
try:
pubkey = issuer.cert.public_key()
- assert isinstance(pubkey, ec.EllipticCurvePublicKey)
+ assert isinstance(
+ pubkey, (ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)
+ )
self.verify_by(pubkey)
return True
except exceptions.InvalidSignature:
@@ -265,8 +347,10 @@ def verify_authentication_response(
*,
whitelist: t.Collection[bytes] | None,
allow_development_devices: bool = False,
- root_pubkey: bytes | ec.EllipticCurvePublicKey | None = None,
-) -> None:
+ root_pubkey: (
+ bytes | ec.EllipticCurvePublicKey | ed25519.Ed25519PublicKey | None
+ ) = None,
+) -> RootCertificate | None:
"""Evaluate the response to an AuthenticateDevice call.
Performs all steps and logs their results via the logging facility. (The log can be
@@ -278,11 +362,6 @@ def verify_authentication_response(
as an `ec.EllipticCurvePublicKey` object or as a byte-string representing P-256
public key.
"""
- if isinstance(root_pubkey, (bytes, bytearray, memoryview)):
- root_pubkey = ec.EllipticCurvePublicKey.from_encoded_point(
- ec.SECP256R1(), root_pubkey
- )
-
challenge_bytes = (
len(CHALLENGE_HEADER).to_bytes(1, "big")
+ CHALLENGE_HEADER
@@ -332,7 +411,18 @@ def verify_authentication_response(
cert = ca_cert
cert_label = f"CA #{i} certificate"
+ if isinstance(root_pubkey, (bytes, bytearray, memoryview)):
+ if cert.signature_algorithm_oid() == SignatureAlgorithmOID.ECDSA_WITH_SHA256:
+ root_pubkey = ec.EllipticCurvePublicKey.from_encoded_point(
+ ec.SECP256R1(), root_pubkey
+ )
+ elif cert.signature_algorithm_oid() == SignatureAlgorithmOID.ED25519:
+ root_pubkey = ed25519.Ed25519PublicKey.from_public_bytes(root_pubkey)
+ else:
+ raise ValueError("Unsupported key type.")
+
if root_pubkey is not None:
+ root = None
try:
cert.verify_by(root_pubkey)
except Exception:
@@ -344,7 +434,18 @@ def verify_authentication_response(
else:
for root in ROOT_PUBLIC_KEYS:
try:
- cert.verify_by(root.pubkey)
+ if (
+ cert.signature_algorithm_oid()
+ == SignatureAlgorithmOID.ECDSA_WITH_SHA256
+ ):
+ cert.verify_by(root.p256_pubkey)
+ elif (
+ cert.signature_algorithm_oid() == SignatureAlgorithmOID.ED25519
+ and root.ed25519_pubkey is not None
+ ):
+ cert.verify_by(root.ed25519_pubkey)
+ else:
+ continue
except Exception:
continue
else:
@@ -372,6 +473,8 @@ def verify_authentication_response(
if failed:
raise DeviceNotAuthentic
+ return root
+
def authenticate_device(
session: Session,
@@ -379,18 +482,33 @@ def authenticate_device(
*,
whitelist: t.Collection[bytes] | None = None,
allow_development_devices: bool = False,
- root_pubkey: bytes | ec.EllipticCurvePublicKey | None = None,
+ p256_root_pubkey: bytes | ec.EllipticCurvePublicKey | None = None,
+ ed25519_root_pubkey: bytes | ed25519.Ed25519PublicKey | None = None,
) -> None:
if challenge is None:
challenge = secrets.token_bytes(16)
resp = device.authenticate(session, challenge)
- return verify_authentication_response(
+ optiga_root = verify_authentication_response(
challenge,
- resp.signature,
- resp.certificates,
+ resp.optiga_signature,
+ resp.optiga_certificates,
whitelist=whitelist,
allow_development_devices=allow_development_devices,
- root_pubkey=root_pubkey,
+ root_pubkey=p256_root_pubkey,
)
+
+ if resp.tropic_signature:
+ tropic_root = verify_authentication_response(
+ challenge,
+ resp.tropic_signature,
+ resp.tropic_certificates,
+ whitelist=whitelist,
+ allow_development_devices=allow_development_devices,
+ root_pubkey=ed25519_root_pubkey,
+ )
+
+ if optiga_root is not tropic_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 8231df53..bace9596 100644
--- a/python/src/trezorlib/cli/device.py
+++ b/python/src/trezorlib/cli/device.py
@@ -366,9 +366,21 @@ PUBKEY_WHITELIST_URL_TEMPLATE = (
)
+def _print_auth_data(signature: bytes, certificates: t.Sequence[bytes]) -> None:
+ click.echo(f"Signature of challenge: {signature.hex()}")
+ click.echo(f"Device certificate: {certificates[0].hex()}")
+ for cert in certificates[1:]:
+ click.echo(f"CA certificate: {cert.hex()}")
+
+
@cli.command()
@click.argument("hex_challenge", required=False)
-@click.option("-R", "--root", type=click.File("rb"), help="Custom root certificate.")
+@click.option(
+ "-R", "--p256_root", type=click.File("rb"), help="Custom root P-256 public key."
+)
+@click.option(
+ "--ed25519_root", type=click.File("rb"), help="Custom root Ed25519 public key."
+)
@click.option(
"-r", "--raw", is_flag=True, help="Print raw cryptographic data and exit."
)
@@ -382,7 +394,8 @@ PUBKEY_WHITELIST_URL_TEMPLATE = (
def authenticate(
session: "Session",
hex_challenge: str | None,
- root: t.BinaryIO | None,
+ p256_root: t.BinaryIO | None,
+ ed25519_root: t.BinaryIO | None,
raw: bool | None,
skip_whitelist: bool | None,
) -> None:
@@ -404,16 +417,20 @@ def authenticate(
msg = device.authenticate(session, challenge)
click.echo(f"Challenge: {hex_challenge}")
- click.echo(f"Signature of challenge: {msg.signature.hex()}")
- click.echo(f"Device certificate: {msg.certificates[0].hex()}")
- for cert in msg.certificates[1:]:
- click.echo(f"CA certificate: {cert.hex()}")
+ _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)
return
- if root is not None:
- root_bytes = root.read()
+ if p256_root is not None:
+ p256_root_bytes = p256_root.read()
+ else:
+ p256_root_bytes = None
+
+ if ed25519_root is not None:
+ ed25519_root_bytes = ed25519_root.read()
else:
- root_bytes = None
+ ed25519_root_bytes = None
class ColoredFormatter(logging.Formatter):
LEVELS = {
@@ -447,7 +464,11 @@ def authenticate(
try:
authentication.authenticate_device(
- session, challenge, root_pubkey=root_bytes, whitelist=whitelist
+ session,
+ challenge,
+ p256_root_pubkey=p256_root_bytes,
+ ed25519_root_pubkey=ed25519_root_bytes,
+ whitelist=whitelist,
)
except authentication.DeviceNotAuthentic:
click.echo("Device is not authentic.")
Why this scored 26/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.