fix(core): raise `DataError` in THP pairing
What changed, and why it matters
This commit changes how the Trezor hardware wallet reports certain pairing errors. Previously, invalid data sent during the Trezor-Host Pairing (THP) process triggered an internal 'FirmwareError' code. Now it returns a more appropriate 'DataError' code. This is mainly a correctness and user-experience improvement: callers see a clearer error and are less likely to mistake bad host input for a device firmware bug. It does not appear to fix a memory corruption, bypass, or direct asset-stealing vulnerability.
Treat as a low-risk correctness fix. Review whether any host/client code relies on the previous FirmwareError code for THP pairing failures and update expectations to DataError. No urgent patching required for security reasons based on the diff alone.
Security signals we found
Error-code hygiene: replacing FirmwareError/ThpError with DataError for malformed protocol input
Input validation tightened: CPACE host public key now checked for exact 32-byte length instead of only non-None
New negative test cases for invalid CPACE key values and lengths
No changelog entry, suggesting internal cleanup rather than advertised security fix
Evidence from the diff
The patch converts several ThpError and one missing-field check in core/src/apps/thp/pairing.py to DataError. Affected checks include: CPACE host public key length, missing tag, unexpected code-entry tag, unexpected QR-code tag, unexpected NFC unidirectional tag, handshake-hash mismatch, disallowed pairing method, and unselected pairing method. It also adds two device tests verifying that malformed CPACE public keys yield FailureType.DataError. The change removes the ThpError import and tightens the CPACE key validation from a None check to an exact 32-byte length check.
Changed components
core/src/apps/thp/pairing.pytests/device_tests/thp/test_pairing.pytests/ui_tests/fixtures.jsonInspect captured patch +133 / −18
diff --git a/core/src/apps/thp/pairing.py b/core/src/apps/thp/pairing.py
index d67b6c031..1e81a461a 100644
--- a/core/src/apps/thp/pairing.py
+++ b/core/src/apps/thp/pairing.py
@@ -34,13 +34,7 @@ from trezor.wire.errors import (
SilentError,
UnexpectedMessage,
)
-from trezor.wire.thp import (
- ChannelState,
- ThpError,
- crypto,
- get_enabled_pairing_methods,
- ui,
-)
+from trezor.wire.thp import ChannelState, crypto, get_enabled_pairing_methods, ui
from trezor.wire.thp.paired_cache import cache_host_info
from trezor.wire.thp.pairing_context import PairingContext
@@ -305,17 +299,15 @@ async def _handle_code_entry_cpace(
if TYPE_CHECKING:
assert ThpCodeEntryCpaceHostTag.is_type_of(message)
- if message.cpace_host_public_key is None:
- raise ThpError(
- "Message ThpCodeEntryCpaceHostTag is missing cpace_host_public_key"
- )
+ if len(message.cpace_host_public_key) != 32:
+ raise DataError("CPACE host public key must be 32 bytes long")
if message.tag is None:
- raise ThpError("Message ThpCodeEntryCpaceHostTag is missing tag")
+ raise DataError("Message ThpCodeEntryCpaceHostTag is missing a tag")
ctx.cpace.compute_shared_secret(message.cpace_host_public_key)
expected_tag = sha256(ctx.cpace.shared_secret).digest()
if expected_tag != message.tag:
- raise ThpError("Unexpected Code Entry Tag")
+ raise DataError("Unexpected Code Entry Tag")
if ctx.code_entry_secret is None:
raise FirmwareError(message="Failed to create code entry secret")
@@ -338,7 +330,7 @@ async def _handle_qr_code_tag(
sha_ctx.update(ctx.code_qr_code)
expected_tag = sha_ctx.digest()
if expected_tag != message.tag:
- raise ThpError("Unexpected QR Code Tag")
+ raise DataError("Unexpected QR Code Tag")
if ctx.qr_code_secret is None:
raise FirmwareError(message="Failed to create qr code secret")
@@ -366,10 +358,10 @@ async def _handle_nfc_tag(
sha_ctx.update(ctx.nfc_secret)
expected_tag = sha_ctx.digest()
if expected_tag != message.tag:
- raise ThpError("Unexpected NFC Unidirectional Tag")
+ raise DataError("Unexpected NFC Unidirectional Tag")
if ctx.handshake_hash_host[:16] != ctx.channel_ctx.get_handshake_hash()[:16]:
- raise ThpError("Handshake hash mismatch")
+ raise DataError("Handshake hash mismatch")
sha_ctx = sha256(ThpPairingMethod.NFC.to_bytes(1, "big"))
sha_ctx.update(ctx.channel_ctx.get_handshake_hash())
@@ -478,9 +470,9 @@ def _check_state(ctx: PairingContext, *allowed_states: ChannelState) -> None:
def _check_method_is_allowed(ctx: PairingContext, method: ThpPairingMethod) -> None:
if method not in get_enabled_pairing_methods(ctx.iface):
- raise ThpError("Unexpected pairing method")
+ raise DataError("Unexpected pairing method")
def _check_method_is_selected(ctx: PairingContext, method: ThpPairingMethod) -> None:
if method is not ctx.selected_method:
- raise ThpError("Not selected pairing method")
+ raise DataError("Not selected pairing method")
diff --git a/tests/device_tests/thp/test_pairing.py b/tests/device_tests/thp/test_pairing.py
index f2cb6be67..eeb7f9186 100644
--- a/tests/device_tests/thp/test_pairing.py
+++ b/tests/device_tests/thp/test_pairing.py
@@ -175,6 +175,117 @@ def test_pairing_code_entry(
protocol._is_paired = True
+@pytest.mark.filterwarnings(
+ "ignore:One of ephemeral keypairs is already set. This is OK for testing, but should NEVER happen in production!"
+)
+def test_pairing_code_entry_invalid_cpace_key(
+ client: Client, deterministic_urandom: None # noqa:F811
+) -> None:
+ # start from a clean slate:
+ protocol = prepare_protocol_for_pairing(
+ client,
+ host_static_randomness=os.urandom(32),
+ host_ephemeral_randomness=os.urandom(64)[-32:],
+ )
+
+ handle_pairing_request(client, protocol, "TestTrezor CodeEntry")
+
+ protocol._send_message(
+ ThpSelectMethod(selected_pairing_method=ThpPairingMethod.CodeEntry)
+ )
+
+ protocol._read_message(ThpCodeEntryCommitment)
+
+ challenge = os.urandom(16)
+ protocol._send_message(ThpCodeEntryChallenge(challenge=challenge))
+
+ cpace_trezor = protocol._read_message(ThpCodeEntryCpaceTrezor)
+ cpace_trezor_public_key = cpace_trezor.cpace_trezor_public_key
+
+ # Code Entry code shown
+
+ pairing_info = client.debug.pairing_info(
+ thp_channel_id=protocol.channel_id.to_bytes(2, "big")
+ )
+ code = pairing_info.code_entry_code
+
+ cpace = Cpace(handshake_hash=protocol.handshake_hash)
+ cpace.random_bytes = os.urandom
+ cpace.generate_keys_and_secret(
+ f"{code:06}".encode("ascii"), cpace_trezor_public_key
+ )
+ sha_ctx = sha256(cpace.shared_secret)
+ tag = sha_ctx.digest()
+ invalid_key = b"\x00" * 32
+ protocol._send_message(
+ ThpCodeEntryCpaceHostTag(
+ cpace_host_public_key=invalid_key,
+ tag=tag,
+ )
+ )
+
+ failure = protocol._read_message(Failure)
+ assert failure == Failure(
+ code=FailureType.DataError, message="Unexpected Code Entry Tag"
+ )
+
+
+@pytest.mark.filterwarnings(
+ "ignore:One of ephemeral keypairs is already set. This is OK for testing, but should NEVER happen in production!"
+)
+def test_pairing_code_entry_invalid_cpace_key_length(
+ client: Client, deterministic_urandom: None # noqa:F811
+) -> None:
+ # start from a clean slate:
+ protocol = prepare_protocol_for_pairing(
+ client,
+ host_static_randomness=os.urandom(32),
+ host_ephemeral_randomness=os.urandom(64)[-32:],
+ )
+
+ handle_pairing_request(client, protocol, "TestTrezor CodeEntry")
+
+ protocol._send_message(
+ ThpSelectMethod(selected_pairing_method=ThpPairingMethod.CodeEntry)
+ )
+
+ protocol._read_message(ThpCodeEntryCommitment)
+
+ challenge = os.urandom(16)
+ protocol._send_message(ThpCodeEntryChallenge(challenge=challenge))
+
+ cpace_trezor = protocol._read_message(ThpCodeEntryCpaceTrezor)
+ cpace_trezor_public_key = cpace_trezor.cpace_trezor_public_key
+
+ # Code Entry code shown
+
+ pairing_info = client.debug.pairing_info(
+ thp_channel_id=protocol.channel_id.to_bytes(2, "big")
+ )
+ code = pairing_info.code_entry_code
+
+ cpace = Cpace(handshake_hash=protocol.handshake_hash)
+ cpace.random_bytes = os.urandom
+ cpace.generate_keys_and_secret(
+ f"{code:06}".encode("ascii"), cpace_trezor_public_key
+ )
+ sha_ctx = sha256(cpace.shared_secret)
+ tag = sha_ctx.digest()
+
+ protocol._send_message(
+ ThpCodeEntryCpaceHostTag(
+ cpace_host_public_key=cpace.host_public_key[0:16],
+ tag=tag,
+ )
+ )
+
+ failure = protocol._read_message(Failure)
+ assert failure == Failure(
+ code=FailureType.DataError,
+ message="CPACE host public key must be 32 bytes long",
+ )
+
+
@pytest.mark.filterwarnings(
"ignore:One of ephemeral keypairs is already set. This is OK for testing, but should NEVER happen in production!"
)
diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json
index fd585938f..cd84a5037 100644
--- a/tests/ui_tests/fixtures.json
+++ b/tests/ui_tests/fixtures.json
@@ -31535,6 +31535,8 @@
"T3W1_cs_thp-test_pairing.py::test_pairing_cancel_2": "a5cac060747b4be07468c92002563e15b0f6dbb6b65ea3d741a3321c12a4460d",
"T3W1_cs_thp-test_pairing.py::test_pairing_code_entry": "9a45a21113c4cff9ee1f02784115f6c979f00cac8cf4b61dc3e67ba2cfb99d08",
"T3W1_cs_thp-test_pairing.py::test_pairing_code_entry_cancel": "9a45a21113c4cff9ee1f02784115f6c979f00cac8cf4b61dc3e67ba2cfb99d08",
+"T3W1_cs_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key": "9a45a21113c4cff9ee1f02784115f6c979f00cac8cf4b61dc3e67ba2cfb99d08",
+"T3W1_cs_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key_length": "9a45a21113c4cff9ee1f02784115f6c979f00cac8cf4b61dc3e67ba2cfb99d08",
"T3W1_cs_thp-test_pairing.py::test_pairing_nfc": "613bcea2556dddf79d2394f5c5609235f7bde128fd21f6409613043f1a7d4657",
"T3W1_cs_thp-test_pairing.py::test_pairing_qr_code": "c24521e569c08e3605b164212c876f8ac57c5eef6cca6f2ca53a635a883ebc4b",
"T3W1_cs_tron-test_get_address.py::test_get_address[parameters0-result0]": "e83d99ce1632d150352ecd88b36e6c5a22b29db4cd2e7d2ed274a83b463875c4",
@@ -33068,6 +33070,8 @@
"T3W1_de_thp-test_pairing.py::test_pairing_cancel_2": "ce831bcba5b6a76c6e502c67e6f7c6729fe5ce79ab67b4f6dcd5e4d1cec29e03",
"T3W1_de_thp-test_pairing.py::test_pairing_code_entry": "16b867c617775a31bf4270e80209a1ba8388b64619acafb85840ba05f9c813f8",
"T3W1_de_thp-test_pairing.py::test_pairing_code_entry_cancel": "16b867c617775a31bf4270e80209a1ba8388b64619acafb85840ba05f9c813f8",
+"T3W1_de_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key": "16b867c617775a31bf4270e80209a1ba8388b64619acafb85840ba05f9c813f8",
+"T3W1_de_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key_length": "16b867c617775a31bf4270e80209a1ba8388b64619acafb85840ba05f9c813f8",
"T3W1_de_thp-test_pairing.py::test_pairing_nfc": "212ef841f8b4508ff8e937df4430828cd9d5876245a39410f197b212386d5ad6",
"T3W1_de_thp-test_pairing.py::test_pairing_qr_code": "b3ce10e1620297758d6f2b235127f1dd0d61804c8cc32bc6664db5c3a9d81dde",
"T3W1_de_tron-test_get_address.py::test_get_address[parameters0-result0]": "f6a09c86d1148cc99368d88613de58cffae7133a586ccbf6e4fcc4300b838341",
@@ -34601,6 +34605,8 @@
"T3W1_en_thp-test_pairing.py::test_pairing_cancel_2": "554f7a5c9236a8ec9f1cebbaa0e56e057c862f1e37b3b796879c54b5f4f60db9",
"T3W1_en_thp-test_pairing.py::test_pairing_code_entry": "c2714bd6889ab99ea93906c2533f0bc3778b7dc5e6d33bba3f6767bc806c8325",
"T3W1_en_thp-test_pairing.py::test_pairing_code_entry_cancel": "c2714bd6889ab99ea93906c2533f0bc3778b7dc5e6d33bba3f6767bc806c8325",
+"T3W1_en_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key": "c2714bd6889ab99ea93906c2533f0bc3778b7dc5e6d33bba3f6767bc806c8325",
+"T3W1_en_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key_length": "c2714bd6889ab99ea93906c2533f0bc3778b7dc5e6d33bba3f6767bc806c8325",
"T3W1_en_thp-test_pairing.py::test_pairing_nfc": "a35f27a25123e935371966b194c0221d93139450f612a63518f5f5a5865d30d1",
"T3W1_en_thp-test_pairing.py::test_pairing_qr_code": "931d9afceb0ba1e4faae891775819277242d889644d5c0c5863fc8c9fcf859b1",
"T3W1_en_tron-test_get_address.py::test_get_address[parameters0-result0]": "ec0f1dff9cd732d228d23ea478c885e75478af957378d9db37d9c1a64ee3e363",
@@ -36134,6 +36140,8 @@
"T3W1_es_thp-test_pairing.py::test_pairing_cancel_2": "86c4f60834e05acf3bba979a15b1025d261f30ca5354fc9d0037c86214ea037d",
"T3W1_es_thp-test_pairing.py::test_pairing_code_entry": "b1792049516b12096f2f49f14ea5bcf8ba3e5300dc360bf9af4d581df6e9dea4",
"T3W1_es_thp-test_pairing.py::test_pairing_code_entry_cancel": "b1792049516b12096f2f49f14ea5bcf8ba3e5300dc360bf9af4d581df6e9dea4",
+"T3W1_es_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key": "b1792049516b12096f2f49f14ea5bcf8ba3e5300dc360bf9af4d581df6e9dea4",
+"T3W1_es_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key_length": "b1792049516b12096f2f49f14ea5bcf8ba3e5300dc360bf9af4d581df6e9dea4",
"T3W1_es_thp-test_pairing.py::test_pairing_nfc": "0b709292e4af0de42c43a6ea11cd0c32e567c5c9f2c4145f2a68f4c2b7c815ad",
"T3W1_es_thp-test_pairing.py::test_pairing_qr_code": "56536ae9cd7c4ff8022def4ec3350031d3614064d961f53a2850f2be425af201",
"T3W1_es_tron-test_get_address.py::test_get_address[parameters0-result0]": "d30e60e12c61ea40b076838de12cda7b71df668d5c1b59709d5eb129a7ce8b1a",
@@ -37667,6 +37675,8 @@
"T3W1_fr_thp-test_pairing.py::test_pairing_cancel_2": "b9fd29996cf8fcd33cac4a3c8577454109b49a831c485ffa6c2adbb0bad928bb",
"T3W1_fr_thp-test_pairing.py::test_pairing_code_entry": "294a5e5c3dfed80c2b892fa19e02c56f2de64c0e6cd8cf2b8c35975514cfdf5b",
"T3W1_fr_thp-test_pairing.py::test_pairing_code_entry_cancel": "294a5e5c3dfed80c2b892fa19e02c56f2de64c0e6cd8cf2b8c35975514cfdf5b",
+"T3W1_fr_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key": "294a5e5c3dfed80c2b892fa19e02c56f2de64c0e6cd8cf2b8c35975514cfdf5b",
+"T3W1_fr_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key_length": "294a5e5c3dfed80c2b892fa19e02c56f2de64c0e6cd8cf2b8c35975514cfdf5b",
"T3W1_fr_thp-test_pairing.py::test_pairing_nfc": "96956944ecf8db96ac7fb4a5eeffbafc27f181c49321a5eb67980680acff28f8",
"T3W1_fr_thp-test_pairing.py::test_pairing_qr_code": "e8156cf4eda1d29060f05b4a18d50032b0b344230609b7de7cababfe0a86b20b",
"T3W1_fr_tron-test_get_address.py::test_get_address[parameters0-result0]": "1974ee4f7614f89cbb8577006a77995ecf61baabe11fef3b7bb6ef04a5411274",
@@ -39205,6 +39215,8 @@
"T3W1_pt_thp-test_pairing.py::test_pairing_cancel_2": "a16fdba151a20b4ccb73e6c5b2413a2ccb7653a78a7cec8750b5d1c5cea926f4",
"T3W1_pt_thp-test_pairing.py::test_pairing_code_entry": "af518495cdf555a112d1ec0ffb6a4c7d57c5c50617a2b575c107e95d04bc0c40",
"T3W1_pt_thp-test_pairing.py::test_pairing_code_entry_cancel": "af518495cdf555a112d1ec0ffb6a4c7d57c5c50617a2b575c107e95d04bc0c40",
+"T3W1_pt_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key": "af518495cdf555a112d1ec0ffb6a4c7d57c5c50617a2b575c107e95d04bc0c40",
+"T3W1_pt_thp-test_pairing.py::test_pairing_code_entry_invalid_cpace_key_length": "af518495cdf555a112d1ec0ffb6a4c7d57c5c50617a2b575c107e95d04bc0c40",
"T3W1_pt_thp-test_pairing.py::test_pairing_nfc": "8ab7b02cbad200e055dd8930deedf3835a9fc92f130c595df7cb8b2fd5f2bd19",
"T3W1_pt_thp-test_pairing.py::test_pairing_qr_code": "1c74667c078e25e7e0d37c7b2aa35f7c8ab02cd88e74695ecb355c82a293b0cc",
"T3W1_pt_tron-test_get_address.py::test_get_address[parameters0-result0]": "be9dd1bf9b95958f2eb25a1423a41e587b3a3233d01323128b256b067694d311",
Why this scored 29/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.