fix(python): improve session handling and unlocking
What changed, and why it matters
This commit refactors how the Trezor Python command-line tool (trezorctl) manages device sessions and unlocking. It aims to avoid creating duplicate sessions when unlocking the device and to handle passphrases more cleanly. The changes are framed by the developer as an improvement/fix to session handling, not as a response to a known security vulnerability. There is no public evidence that this fixes an exploitable bug.
Treat as a routine maintenance/refactor patch. Reviewers may want to verify that reusing the standard session does not leak passphrase-protected state between operations and that the cached features are invalidated appropriately on device state changes. No urgent security action is indicated by the commit alone.
Security signals we found
Refactor of device unlock/session reuse logic in CLI client
Removal of get_default_session() path that could trigger redundant ensure_unlocked() calls
Caching of standard session and features to avoid repeated state queries
Change from explicit `is True` to truthiness checks for feature flags
No changelog entry and no CVE/advisory references in commit
Evidence from the diff
The patch modifies TrezorConnection in trezorlib/cli/init.py to cache the TrezorClient, transport, features, and a ‘standard session’, and adds a custom ensure_unlocked() that reuses the standard session instead of calling client.ensure_unlocked(). It moves passphrase-source resolution into TrezorConnection, removes reliance on get_default_session(), and adjusts client.py and protocol_v1.py to use truthiness checks for features flags. The firmware.py change closes and reopens the transport around a reboot-to-bootloader. Overall this is a defensive refactor of session lifecycle and state handling.
Changed components
python/src/trezorlib/cli/__init__.pypython/src/trezorlib/cli/debug.pypython/src/trezorlib/cli/firmware.pypython/src/trezorlib/cli/trezorctl.pypython/src/trezorlib/client.pypython/src/trezorlib/protocol_v1.pyInspect captured patch +90 / −31
diff --git a/python/src/trezorlib/cli/__init__.py b/python/src/trezorlib/cli/__init__.py
index 37d906dd..6bfd1cc9 100644
--- a/python/src/trezorlib/cli/__init__.py
+++ b/python/src/trezorlib/cli/__init__.py
@@ -27,15 +27,8 @@ from enum import Enum
import click
-from .. import exceptions, protocol_v1, transport, ui
-from ..client import (
- AppManifest,
- PassphraseSetting,
- Session,
- TrezorClient,
- get_client,
- get_default_session,
-)
+from .. import exceptions, messages, protocol_v1, transport, ui
+from ..client import AppManifest, PassphraseSetting, Session, TrezorClient, get_client
from ..thp import client as thp_client
from ..transport import Transport
from . import credentials
@@ -145,6 +138,11 @@ def get_code_entry_code() -> str:
class TrezorConnection:
+ _client: TrezorClient | None = None
+ _features: messages.Features | None = None
+ _transport: Transport | None = None
+ _standard_session: Session | None = None
+
def __init__(
self,
path: str,
@@ -168,6 +166,55 @@ class TrezorConnection:
self.app.button_callback = click_ui.button_request
self.app.pin_callback = click_ui.get_pin
+ def ensure_unlocked(self) -> None:
+ """Ensure that the device is unlocked.
+
+ Separate from `client.ensure_unlocked()` because we want to reuse the
+ standard session.
+ """
+ if not self.get_client().features.initialized:
+ # uninitialized device cannot be locked
+ return
+ # query the standard session instead
+ self.standard_session.ensure_unlocked()
+
+ @property
+ def standard_session(self) -> Session:
+ if self._standard_session is None:
+ self._standard_session = self.get_client().get_session(
+ passphrase=PassphraseSetting.STANDARD_WALLET
+ )
+ # seems to be a weird typechecker limitation that it still thinks that
+ # session could be None here
+ return self._standard_session # type: ignore ["None" is not assignable]
+
+ @property
+ def features(self) -> messages.Features:
+ if self._features is None:
+ self.ensure_unlocked()
+ self._features = self.get_client().features
+ return self._features
+
+ @property
+ def transport(self) -> Transport:
+ if self._transport is None:
+ self.open()
+ assert self._transport is not None
+ return self._transport
+
+ def open(self) -> None:
+ if self._transport is None:
+ self._transport = self._get_transport()
+ self._transport.open()
+
+ def close(self) -> None:
+ if self._transport is not None:
+ self._transport.close()
+ self._transport = None
+ self._client = None
+ self._features = None
+ self._standard_session = None
+
def get_session(
self,
use_passphrase: bool = True,
@@ -175,19 +222,27 @@ class TrezorConnection:
derive_cardano: bool = False,
) -> Session:
client = self.get_client()
- client.ensure_unlocked()
if (
- not client.features.passphrase_protection
+ not self.features.passphrase_protection
and not self.passphrase_source.ok_if_disabled()
):
raise click.ClickException("Passphrase protection is not enabled")
- # if empty passphrase is requested, do not try to resume and instead
- # create a new session
- if not use_passphrase or self.passphrase_source == PassphraseSource.EMPTY:
- return client.get_session(passphrase=PassphraseSetting.STANDARD_WALLET)
+ passphrase_source = self.passphrase_source
+ if passphrase_source == PassphraseSource.AUTO:
+ if not self.features.passphrase_protection:
+ passphrase_source = PassphraseSource.EMPTY
+ elif messages.Capability.PassphraseEntry in self.features.capabilities:
+ passphrase_source = PassphraseSource.DEVICE
+ else:
+ passphrase_source = PassphraseSource.PROMPT
+
if seedless:
return client.get_session(passphrase=None)
+ # if empty passphrase is requested, do not try to resume and instead
+ # create a new session
+ if not use_passphrase or passphrase_source == PassphraseSource.EMPTY:
+ return self.standard_session
# Try resume session from id
if self.session_id is not None:
@@ -217,18 +272,16 @@ class TrezorConnection:
else:
return session
- if self.passphrase_source == PassphraseSource.PROMPT:
+ if passphrase_source == PassphraseSource.PROMPT:
passphrase = get_passphrase()
return client.get_session(passphrase=passphrase)
- if self.passphrase_source == PassphraseSource.DEVICE:
+ if passphrase_source == PassphraseSource.DEVICE:
return client.get_session(passphrase=PassphraseSetting.ON_DEVICE)
- if self.passphrase_source == PassphraseSource.AUTO:
- return get_default_session(client, derive_cardano=derive_cardano)
raise NotImplementedError(
f"Passphrase source {self.passphrase_source} not implemented"
)
- def get_transport(self) -> Transport:
+ def _get_transport(self) -> Transport:
try:
# look for transport without prefix search
return transport.get_transport(self.path, prefix_search=False)
@@ -240,8 +293,8 @@ class TrezorConnection:
# if this fails, we want the exception to bubble up to the caller
return transport.get_transport(self.path, prefix_search=True)
- def get_client(self) -> TrezorClient:
- client = get_client(self.app, self.get_transport())
+ def _get_client(self) -> TrezorClient:
+ client = get_client(self.app, self.transport)
if not client.pairing.is_paired():
from ..thp import pairing
@@ -253,6 +306,11 @@ class TrezorConnection:
return client
+ def get_client(self) -> TrezorClient:
+ if self._client is None:
+ self._client = self._get_client()
+ return self._client
+
def _connection_context(
self,
connect_fn: t.Callable[P, R],
diff --git a/python/src/trezorlib/cli/debug.py b/python/src/trezorlib/cli/debug.py
index 9eeb5fe6..e38fb2c1 100644
--- a/python/src/trezorlib/cli/debug.py
+++ b/python/src/trezorlib/cli/debug.py
@@ -51,8 +51,7 @@ def record_screen_from_connection(
obj: "TrezorConnection", directory: Union[str, None]
) -> None:
"""Record screen helper to transform TrezorConnection into TrezorClientDebugLink."""
- transport = obj.get_transport()
- debug_client = TrezorTestContext(transport=transport, auto_interact=False)
+ debug_client = TrezorTestContext(transport=obj.transport, auto_interact=False)
record_screen(debug_client, directory, report_func=click.echo)
diff --git a/python/src/trezorlib/cli/firmware.py b/python/src/trezorlib/cli/firmware.py
index 4585086e..34c0040d 100644
--- a/python/src/trezorlib/cli/firmware.py
+++ b/python/src/trezorlib/cli/firmware.py
@@ -686,11 +686,12 @@ def update(
else:
device.reboot_to_bootloader(seedless_session)
+ obj.close()
click.echo("Waiting for bootloader...")
while True:
time.sleep(0.5)
try:
- obj.get_transport()
+ obj.open()
break
except Exception:
pass
diff --git a/python/src/trezorlib/cli/trezorctl.py b/python/src/trezorlib/cli/trezorctl.py
index 59475609..6c0171da 100755
--- a/python/src/trezorlib/cli/trezorctl.py
+++ b/python/src/trezorlib/cli/trezorctl.py
@@ -18,6 +18,7 @@
from __future__ import annotations
+import atexit
import importlib.metadata
import json
import logging
@@ -232,6 +233,8 @@ def cli_main(
passphrase_source = PassphraseSource.AUTO
ctx.obj = TrezorConnection(path, session_id, passphrase_source, script)
+ ctx.obj.open()
+ atexit.register(ctx.obj.close)
# Optionally record the screen into a specified directory.
if record:
diff --git a/python/src/trezorlib/client.py b/python/src/trezorlib/client.py
index 8fa61098..ffe24aec 100644
--- a/python/src/trezorlib/client.py
+++ b/python/src/trezorlib/client.py
@@ -372,11 +372,11 @@ class TrezorClient(t.Generic[SessionType], metaclass=ABCMeta):
must_request_passphrase = (
passphrase_is_nonempty or passphrase is PassphraseSetting.ON_DEVICE
)
- if must_request_passphrase and not self.features.passphrase_protection:
+ if must_request_passphrase and self.features.passphrase_protection is False:
raise exceptions.PassphraseError(
"Passphrase protection is disabled on this device."
)
- if passphrase_is_nonempty and self.features.passphrase_always_on_device is True:
+ if passphrase_is_nonempty and self.features.passphrase_always_on_device:
raise exceptions.PassphraseError(
"Only on-device entry allowed for passphrase."
)
diff --git a/python/src/trezorlib/protocol_v1.py b/python/src/trezorlib/protocol_v1.py
index 46001fb2..49bb54db 100644
--- a/python/src/trezorlib/protocol_v1.py
+++ b/python/src/trezorlib/protocol_v1.py
@@ -187,7 +187,7 @@ class SessionV1(client.Session["TrezorClientV1", t.Optional[bytes]]):
resp = self.call(ack)
elif (
- self.features.passphrase_always_on_device is True
+ self.features.passphrase_always_on_device
and passphrase is client.PassphraseSetting.ON_DEVICE
):
# Passphrase was processed on device without asking the host. This is OK.
@@ -196,9 +196,7 @@ class SessionV1(client.Session["TrezorClientV1", t.Optional[bytes]]):
elif passphrase:
# We didn't get a PassphraseRequest, but passphrase_protection is enabled.
# Looks like the session is already initialized. Bail out.
- raise exceptions.TrezorException(
- f"Failed to activate passphrase session {resp}"
- )
+ raise exceptions.PassphraseError("Failed to activate passphrase session")
# after processing any PassphraseRequest, we should have an Address response
resp = messages.PublicKey.ensure_isinstance(resp)
Why this scored 34/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.