refactor(python): improve credential management in trezorctl
What changed, and why it matters
This commit refactors how Trezor's command-line tool stores and looks up pairing credentials. It changes the way secrets are labeled in the system keyring, adds new lookup methods, and adds a 'forget' command to remove remembered device keys. The changes appear to be a defensive improvement to credential management rather than a fix for an active vulnerability.
No immediate action required. Review the new credential storage design for completeness and ensure the random identifiers provide the intended privacy benefits. Consider whether migration or cleanup of old keyring entries is needed for users upgrading from the previous format.
Security signals we found
Credential storage structure changed to use random identifiers instead of public keys as keyring usernames
New credential lookup by masked key set, unmasked public key, and random id
New CLI command to forget/remove pairing credentials
Channel now exposes TrezorPublicKeys after handshake completion
Evidence from the diff
The commit modifies trezorctl’s THP (Trezor Host Protocol) credential handling. Previously, the system keyring username was derived directly from the Trezor public key. Now it uses a random 16-byte identifier, while the actual Trezor public key is stored as a separate keyring entry. The credential store gains lookup by id, unmasked public key, or masked key set, plus a clear() method. A new CLI command ‘forget’ is added to remove the current device’s pairing key or all remembered keys. The Channel class now exposes the TrezorPublicKeys object after the handshake phase.
Changed components
python/src/trezorlib/cli/credentials.pypython/src/trezorlib/cli/device.pypython/src/trezorlib/thp/channel.pyInspect captured patch +148 / −68
diff --git a/python/src/trezorlib/cli/credentials.py b/python/src/trezorlib/cli/credentials.py
index a956bbba..c0389602 100644
--- a/python/src/trezorlib/cli/credentials.py
+++ b/python/src/trezorlib/cli/credentials.py
@@ -17,8 +17,10 @@
from __future__ import annotations
import base64
+from contextlib import contextmanager
import json
import logging
+import secrets
import typing as t
from functools import cached_property
from pathlib import Path
@@ -27,72 +29,76 @@ import keyring
import platformdirs
from typing_extensions import Self
-from ..thp.credentials import Credential
+from ..thp.credentials import Credential, TrezorPublicKeys, matches
LOG = logging.getLogger(__name__)
+KEY_TREZOR_PUBKEY = "trezor-pubkey"
+KEY_HOST_PRIVKEY = "host-privkey"
+KEY_CREDENTIAL = "credential"
+
class KeyringCredential:
- def __init__(self, app_name: str, trezor_pubkey: bytes) -> None:
+ def __init__(self, app_name: str, id: bytes) -> None:
+ self.id = id
self.app_name = app_name
- self.trezor_pubkey = trezor_pubkey
-
- @property
- def _system(self) -> str:
- return f"{self.app_name}/thp-credentials"
- @property
- def _system_privkey(self) -> str:
- return self._system + "/privkey"
-
- @property
- def _system_credential(self) -> str:
- return self._system + "/credential"
+ def _key(self, key: str) -> str:
+ return f"{self.app_name}/thp-credentials/{key}"
@cached_property
def _username(self) -> str:
- return base64.b64encode(self.trezor_pubkey).decode()
+ return base64.b64encode(self.id).decode()
+
+ def _load_from_keyring(self, key: str) -> bytes:
+ keyring_key = self._key(key)
+ value_b64 = keyring.get_password(keyring_key, self._username)
+ if value_b64 is None:
+ raise ValueError(f"Not found in keyring: {keyring_key}")
+ return base64.b64decode(value_b64)
+
+ def _save_to_keyring(self, key: str, value: bytes) -> None:
+ keyring_key = self._key(key)
+ value_b64 = base64.b64encode(value).decode()
+ keyring.set_password(keyring_key, self._username, value_b64)
+
+ def _delete_from_keyring(self, key: str) -> None:
+ keyring_key = self._key(key)
+ try:
+ keyring.delete_password(keyring_key, self._username)
+ except Exception as e:
+ LOG.warning("Failed to delete %s from keyring: %s", keyring_key, e)
+
+ @cached_property
+ def trezor_pubkey(self) -> bytes:
+ return self._load_from_keyring(KEY_TREZOR_PUBKEY)
@cached_property
def host_privkey(self) -> bytes:
- privkey_b64 = keyring.get_password(self._system_privkey, self._username)
- if privkey_b64 is None:
- raise ValueError("Private key not found")
- return base64.b64decode(privkey_b64)
+ return self._load_from_keyring(KEY_HOST_PRIVKEY)
@cached_property
def credential(self) -> bytes:
- credential_b64 = keyring.get_password(self._system_credential, self._username)
- if credential_b64 is None:
- raise ValueError("Credential not found")
- return base64.b64decode(credential_b64)
+ return self._load_from_keyring(KEY_CREDENTIAL)
@classmethod
def save(cls, app_name: str, credential: Credential) -> Self:
- new = cls(app_name, credential.trezor_pubkey)
+ new_id = base64.b64encode(secrets.token_bytes(16))
+ new = cls(app_name, new_id)
+ new.trezor_pubkey = credential.trezor_pubkey
new.host_privkey = credential.host_privkey
new.credential = credential.credential
- keyring.set_password(
- new._system_privkey,
- new._username,
- base64.b64encode(new.host_privkey).decode(),
- )
- keyring.set_password(
- new._system_credential,
- new._username,
- base64.b64encode(new.credential).decode(),
- )
+ new._save_to_keyring(KEY_TREZOR_PUBKEY, credential.trezor_pubkey)
+ new._save_to_keyring(KEY_HOST_PRIVKEY, credential.host_privkey)
+ new._save_to_keyring(KEY_CREDENTIAL, credential.credential)
+ LOG.info("Saved credential %s for %s", new.id.hex(), new.app_name)
return new
def delete(self) -> None:
- try:
- keyring.delete_password(self._system_privkey, self._username)
- except Exception:
- pass
- try:
- keyring.delete_password(self._system_credential, self._username)
- except Exception:
- pass
+ self._delete_from_keyring(KEY_TREZOR_PUBKEY)
+ self._delete_from_keyring(KEY_HOST_PRIVKEY)
+ self._delete_from_keyring(KEY_CREDENTIAL)
+ LOG.info("Deleted credential %s for %s", self.id.hex(), self.app_name)
def as_credential(self) -> Credential:
# limitation of pyright:
@@ -118,6 +124,21 @@ class CredentialStore:
)
self.config_path = config_dir / "thp-credentials.json"
+ @contextmanager
+ def _with_app(self) -> t.Generator[list[bytes], None, None]:
+ data = self._load()
+ app_data_b64 = data.get(self.app_name, ())
+ app_data = [base64.b64decode(id) for id in app_data_b64]
+ original = app_data[:]
+ yield app_data
+ if original != app_data:
+ modified_b64 = [base64.b64encode(id).decode() for id in app_data]
+ if modified_b64:
+ data[self.app_name] = modified_b64
+ else:
+ data.pop(self.app_name, None)
+ self._save(data)
+
def _load(self) -> dict[str, t.Any]:
if not self.config_path.exists():
return {}
@@ -127,29 +148,58 @@ class CredentialStore:
self.config_path.write_text(json.dumps(data, indent=2) + "\n")
def list(self) -> t.Collection[Credential]:
- data = self._load()
- app_data = data.get(self.app_name, ())
- return [
- KeyringCredential(
- self.app_name, base64.b64decode(credential)
- ).as_credential()
- for credential in app_data
- ]
+ with self._with_app() as app_data:
+ return [
+ KeyringCredential(self.app_name, id).as_credential() for id in app_data
+ ]
def add(self, credential: Credential) -> None:
- data = self._load()
- app_data = data.setdefault(self.app_name, [])
- saved_credential = KeyringCredential.save(self.app_name, credential)
- app_data.append(saved_credential._username)
- self._save(data)
- LOG.info(
- "Added credential for %s: %s", self.app_name, credential.trezor_pubkey.hex()
- )
-
- def delete(self, trezor_pubkey: bytes) -> None:
- data = self._load()
- app_data = data.setdefault(self.app_name, [])
- credential = KeyringCredential(self.app_name, trezor_pubkey)
- app_data.remove(credential._username)
- credential.delete()
- self._save(data)
+ with self._with_app() as app_data:
+ saved_credential = KeyringCredential.save(self.app_name, credential)
+ app_data.append(saved_credential.id)
+
+ def _get(
+ self, app_data: list[bytes], id_or_key: bytes | TrezorPublicKeys
+ ) -> KeyringCredential | None:
+ if isinstance(id_or_key, bytes):
+ if id_or_key in app_data:
+ # found by the unique identifier
+ return KeyringCredential(self.app_name, id_or_key)
+ for id in app_data:
+ credential = KeyringCredential(self.app_name, id)
+ if isinstance(id_or_key, TrezorPublicKeys) and matches(
+ credential.as_credential(), id_or_key
+ ):
+ # found by matching TrezorPublicKeys
+ return credential
+ if id_or_key == credential.trezor_pubkey:
+ # found by Trezor public key
+ return credential
+ return None
+
+ def __getitem__(self, id_or_key: bytes | TrezorPublicKeys) -> KeyringCredential:
+ with self._with_app() as app_data:
+ credential = self._get(app_data, id_or_key)
+ if credential is not None:
+ return credential
+ raise KeyError(f"Credential not found: {id_or_key}")
+
+ def __contains__(self, id_or_key: bytes | TrezorPublicKeys) -> bool:
+ with self._with_app() as app_data:
+ return self._get(app_data, id_or_key) is not None
+
+ def delete(self, id_or_key: bytes | TrezorPublicKeys) -> None:
+ with self._with_app() as app_data:
+ credential = self._get(app_data, id_or_key)
+ if credential is None:
+ LOG.warning("Credential not found: %s", id_or_key)
+ else:
+ credential.delete()
+ app_data.remove(credential.id)
+
+ def clear(self) -> None:
+ with self._with_app() as app_data:
+ for id in app_data:
+ credential = KeyringCredential(self.app_name, id)
+ credential.delete()
+ app_data.clear()
diff --git a/python/src/trezorlib/cli/device.py b/python/src/trezorlib/cli/device.py
index 8e9d2017..2739d914 100644
--- a/python/src/trezorlib/cli/device.py
+++ b/python/src/trezorlib/cli/device.py
@@ -480,3 +480,32 @@ def authenticate(
def serial_number(session: "Session") -> str:
"""Get serial number."""
return device.get_serial_number(session)
+
+
+@cli.command()
+@click.option("--all", is_flag=True, help="Forget all devices.")
+@click.pass_obj
+def forget(obj: "TrezorConnection", all: bool) -> None:
+ """Forget a THP pairing key.
+
+ Forgets the THP pairing key for the currently connected device.
+ Specify --all to forget all keys for all remembered Trezors.
+ """
+ from ..thp.client import TrezorClientThp
+ from ..client import get_client
+
+ if all:
+ obj.credentials.clear()
+ return
+
+ client = get_client(obj.app, obj.transport)
+ if not isinstance(client, TrezorClientThp):
+ LOG.warning("Connected device is not a THP device, nothing to forget.")
+ return
+
+ if not client.pairing.is_paired():
+ LOG.warning("Device is not paired, nothing to forget.")
+ return
+
+ assert client.channel.trezor_public_keys is not None
+ obj.credentials.delete(client.channel.trezor_public_keys)
diff --git a/python/src/trezorlib/thp/channel.py b/python/src/trezorlib/thp/channel.py
index ae52187f..8fbace51 100644
--- a/python/src/trezorlib/thp/channel.py
+++ b/python/src/trezorlib/thp/channel.py
@@ -138,6 +138,7 @@ class Channel:
self.host_static_privkey: bytes = secrets.token_bytes(32)
self._noise: NoiseConnection | None = None
self.state = channel_state
+ self.trezor_public_keys: TrezorPublicKeys | None = None
@property
def noise(self) -> NoiseConnection:
@@ -302,10 +303,10 @@ class Channel:
def _send_handshake_completion_request(
self, credentials: t.Iterable[Credential]
) -> None:
- trezor_public_keys = TrezorPublicKeys.from_noise(
+ self.trezor_public_keys = TrezorPublicKeys.from_noise(
self.noise.noise_protocol.handshake_state
)
- cred = find_credential(credentials, trezor_public_keys)
+ cred = find_credential(credentials, self.trezor_public_keys)
if cred is not None:
LOG.info(
"Found credential for channel %04x: %s",
Why this scored 28/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.