fix(core): discard low-order keys in Code Entry pairing
What changed, and why it matters
This update fixes a cryptographic weakness in Trezor's 'Code Entry' pairing feature, which lets a phone or computer connect to a Trezor device. Before the fix, an attacker could supply a special invalid public key that would cause the pairing process to produce a predictable shared secret (all zeros). This could let the attacker bypass the pairing check and potentially trick the device into accepting an unauthorized connection. The fix rejects these invalid keys and also uses a constant-time comparison to prevent subtle timing leaks.
Treat this as a security fix and ensure it is included in firmware releases. Review whether prior firmware versions without this fix are exposed to unauthorized pairing via malicious low-order CPace public keys, and consider issuing a security advisory if user-facing impact is confirmed.
Security signals we found
Rejection of low-order Curve25519 public keys in CPace key exchange
Rejection of all-zero shared secret resulting from curve25519 multiplication
Replacement of direct equality comparison with constant-time consteq for tag validation
Use of CFRG CPace draft low-order test vectors for validation
Cherry-pick from another commit suggests backport of a security fix
Evidence from the diff
The patch hardens CPace key exchange in Trezor’s Trezor-Host Protocol (THP) Code Entry pairing. It adds checks in core/src/trezor/wire/thp/cpace.py to reject low-order / all-zero Curve25519 public keys and all-zero resulting shared secrets, raising ValueError. core/src/apps/thp/pairing.py catches that error and surfaces it as DataError, and replaces a direct byte comparison with utils.consteq to avoid timing side-channels. Tests are added using CFRG CPace draft test vectors for low-order X25519 points.
Changed components
core/src/apps/thp/pairing.pycore/src/trezor/wire/thp/cpace.pycore/tests/test_trezor.wire.thp.cpace.pytests/device_tests/thp/test_pairing.pyInspect captured patch +121 / −4
diff --git a/core/src/apps/thp/pairing.py b/core/src/apps/thp/pairing.py
index 41409b39..b8414a80 100644
--- a/core/src/apps/thp/pairing.py
+++ b/core/src/apps/thp/pairing.py
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING
-from trezor import protobuf
+from trezor import protobuf, utils
from trezor.crypto import random
from trezor.crypto.hashlib import sha256
from trezor.enums import ThpMessageType, ThpPairingMethod
@@ -303,9 +303,13 @@ async def _handle_code_entry_cpace(
if message.tag is None:
raise DataError("Message ThpCodeEntryCpaceHostTag is missing a tag")
- ctx.cpace.compute_shared_secret(message.cpace_host_public_key)
+ try:
+ ctx.cpace.compute_shared_secret(message.cpace_host_public_key)
+ except ValueError:
+ raise DataError("Invalid CPACE host public key")
+
expected_tag = sha256(ctx.cpace.shared_secret).digest()
- if expected_tag != message.tag:
+ if not utils.consteq(expected_tag, message.tag):
raise DataError("Unexpected Code Entry Tag")
if ctx.code_entry_secret is None:
diff --git a/core/src/trezor/wire/thp/cpace.py b/core/src/trezor/wire/thp/cpace.py
index 32816e03..7d6f485a 100644
--- a/core/src/trezor/wire/thp/cpace.py
+++ b/core/src/trezor/wire/thp/cpace.py
@@ -61,6 +61,12 @@ class Cpace:
Compute a shared secret using host's public (cpace) key.
Must be called after `generate_keys`.
"""
+ from trezor.utils import consteq
+
+ if consteq(bytes(host_public_key), b"\x00" * 32):
+ raise ValueError
self.shared_secret = curve25519.multiply(
self.trezor_private_key, host_public_key
)
+ if consteq(self.shared_secret, b"\x00" * 32):
+ raise ValueError
diff --git a/core/tests/test_trezor.wire.thp.cpace.py b/core/tests/test_trezor.wire.thp.cpace.py
new file mode 100644
index 00000000..856214e2
--- /dev/null
+++ b/core/tests/test_trezor.wire.thp.cpace.py
@@ -0,0 +1,73 @@
+# flake8: noqa: F403,F405
+from common import * # isort:skip
+
+if utils.USE_THP:
+ from trezor.wire.thp import cpace
+
+
+@unittest.skipUnless(utils.USE_THP, "only needed for THP")
+class TestTrezorHostProtocolCPace(unittest.TestCase):
+ # Vectors from https://www.ietf.org/archive/id/draft-irtf-cfrg-cpace-21.html#name-test-vectors-for-g_x25519sc
+ # Vectors ending in _256 have the last bit set to 1. This bit is ignored by the curve25519
+ # implementation - as is specified by the RFC 7748.
+ vectors_raise = {
+ ("u0", "0000000000000000000000000000000000000000000000000000000000000000"),
+ ("u1", "0100000000000000000000000000000000000000000000000000000000000000"),
+ ("u2", "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ ("u3", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800"),
+ ("u4", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157"),
+ ("u5", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ ("u7", "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"),
+ ("u0_256", "0000000000000000000000000000000000000000000000000000000000000080"),
+ ("u1_256", "0100000000000000000000000000000000000000000000000000000000000080"),
+ ("u2_256", "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
+ ("u3_256", "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880"),
+ ("u4_256", "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7"),
+ ("u5_256", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
+ ("u7_256", "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
+ }
+ # Vectors from https://www.ietf.org/archive/id/draft-irtf-cfrg-cpace-21.html#name-test-vectors-for-g_x25519sc
+ # that degrade to low-order points when the last bit is not ignored as specified in RFC 7748.
+ vectors_valid = {
+ (
+ "u6",
+ "daffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "d8e2c776bbacd510d09fd9278b7edcd25fc5ae9adfba3b6e040e8d3b71b21806",
+ ),
+ (
+ "u8",
+ "dbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "c85c655ebe8be44ba9c0ffde69f2fe10194458d137f09bbff725ce58803cdb38",
+ ),
+ (
+ "u9",
+ "d9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ "db64dafa9b8fdd136914e61461935fe92aa372cb056314e1231bc4ec12417456",
+ ),
+ (
+ "ua",
+ "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",
+ "e062dcd5376d58297be2618c7498f55baa07d7e03184e8aada20bca28888bf7a",
+ ),
+ (
+ "ub",
+ "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",
+ "993c6ad11c4c29da9a56f7691fd0ff8d732e49de6250b6c2e80003ff4629a175",
+ ),
+ }
+
+ def test_compute_shared_secret(self):
+ ctx = cpace.Cpace(b"")
+ s = unhexlify(
+ "af46e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449aff"
+ )
+ ctx.trezor_private_key = s
+ for _, input in self.vectors_raise:
+ self.assertRaises(ValueError, ctx.compute_shared_secret, unhexlify(input))
+ for _, input, expected_out in self.vectors_valid:
+ ctx.compute_shared_secret(unhexlify(input))
+ self.assertEqual(hexlify(ctx.shared_secret).decode(), expected_out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/device_tests/thp/test_pairing.py b/tests/device_tests/thp/test_pairing.py
index cdac1ad8..06db615a 100644
--- a/tests/device_tests/thp/test_pairing.py
+++ b/tests/device_tests/thp/test_pairing.py
@@ -17,6 +17,7 @@ from trezorlib.messages import (
Cancel,
Failure,
FailureType,
+ ThpCodeEntryCpaceHostTag,
ThpCredentialRequest,
ThpCredentialResponse,
ThpEndRequest,
@@ -41,6 +42,16 @@ ignore_ephemeral_keypair_warning = pytest.mark.filterwarnings(
"ignore:One of ephemeral keypairs is already set. This is OK for testing, but should NEVER happen in production!"
)
+# Test vectors from https://www.ietf.org/archive/id/draft-irtf-cfrg-cpace-21.html#name-test-vectors-for-g_x25519sc.
+# Vector u3_256 is the vector u3 with last bit set to 1. This bit is ignored as specified in RFC 7748. More vectors
+# are tested in the CPace unit tests - they are not included here for performance reasons
+LOW_ORDER_POINTS = {
+ "u0": "0000000000000000000000000000000000000000000000000000000000000000",
+ "u1": "0100000000000000000000000000000000000000000000000000000000000000",
+ "u2": "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f",
+ "u3_256": "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",
+}
+
@contextmanager
def deterministic_secrets() -> t.Generator[None, None, None]:
@@ -109,7 +120,7 @@ def test_pairing_code_entry_invalid_cpace_key(test_ctx: TrezorTestContext) -> No
code_str = f"{code:06}"
invalid_msg = method._perform_cpace(code_str)
- invalid_msg.cpace_host_public_key = b"\x00" * 32
+ invalid_msg.cpace_host_public_key = b"\x11" * 32
method._perform_cpace = lambda code: invalid_msg
with pytest.raises(
exceptions.TrezorFailure, match="DataError: Unexpected Code Entry Tag"
@@ -143,6 +154,29 @@ def test_pairing_code_entry_invalid_cpace_key_length(
method.send_code(code_str)
+@ignore_ephemeral_keypair_warning
+@deterministic_secrets()
+@pytest.mark.parametrize(
+ "key",
+ [
+ pytest.param(bytes.fromhex(point_hex), id=name)
+ for name, point_hex in LOW_ORDER_POINTS.items()
+ ],
+)
+def test_pairing_code_entry_forbbidden_cpace_key(
+ test_ctx: TrezorTestContext, key: bytes
+) -> None:
+ pairing = prepare_channel_for_pairing(test_ctx, fixed_entropy=True)
+ method = CodeEntry(pairing)
+ attack_msg = ThpCodeEntryCpaceHostTag(cpace_host_public_key=key, tag=b"\x00" * 32)
+ method._perform_cpace = lambda code: attack_msg
+ with pytest.raises(
+ exceptions.TrezorFailure,
+ match="DataError: Invalid CPACE host public key",
+ ):
+ method.send_code("123456")
+
+
@ignore_ephemeral_keypair_warning
@deterministic_secrets()
def test_pairing_code_entry_cancel(test_ctx: TrezorTestContext) -> None:
Why this scored 59/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.