chore: start Tropic model from multicore tests
What changed, and why it matters
This commit is a testing infrastructure change. It makes the software emulator automatically start a separate 'Tropic' hardware-security model on a configurable TCP port during automated tests, so multiple test workers can run in parallel without colliding on the same port. There is no indication this fixes or introduces a security vulnerability in the device firmware itself.
No security action required. Treat as routine test infrastructure maintenance.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch refactors how the Tropic secure-element model is launched for emulator-based tests. It adds a TropicModel helper class in emulator.py that spawns the external model_server process, waits for its TCP port to become reachable, and terminates it cleanly. The emulator now accepts launch_tropic_model / tropic_model_port parameters, passes the port via a new TROPIC_MODEL_PORT environment variable, and the C emulator main parses that variable (defaulting to 28992). The CI workflow is adjusted so the model is not started twice for the multicore UI test job. This is purely test/CI plumbing.
Changed components
tests/emulators.pytests/conftest.pypython/src/trezorlib/_internal/emulator.pycore/embed/projects/unix/main.ccore/embed/projects/prodtest/main.ccore/embed/sec/tropic/tropic.ccore/embed/sec/tropic/inc/sec/tropic.h.github/workflows/core.ymlInspect captured patch +195 / −10
diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml
index 801cefb55..c44918dc8 100644
--- a/.github/workflows/core.yml
+++ b/.github/workflows/core.yml
@@ -300,7 +300,7 @@ jobs:
- run: chmod +x core/build/unix/trezor-emu-core*
- uses: ./.github/actions/environment
- name: Start Tropic model
- if: ${{ env.TREZOR_MODEL == 'T3W1' }}
+ if: ${{ env.TREZOR_MODEL == 'T3W1' && env.ACTIONS_DO_UI_TEST != 'true' }} # ACTIONS_DO_UI_TEST refers to the test_emu_ui_multicore below which uses --control-emulators and starts tvl internally
run: |
nix-shell --arg fullDeps true --run "cd vendor/ts-tvl && poetry env use 3.12 && poetry install && poetry run model_server tcp -c ../../tests/tropic_model/config.yml > ../../tests/trezor-tropic-model.log 2>&1 &"
- run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_ui_multicore' || 'test_emu' }}"
diff --git a/core/embed/projects/prodtest/main.c b/core/embed/projects/prodtest/main.c
index 9455b5491..d0cade1b5 100644
--- a/core/embed/projects/prodtest/main.c
+++ b/core/embed/projects/prodtest/main.c
@@ -191,7 +191,11 @@ static void drivers_init(void) {
ble_init();
#endif
#ifdef USE_TROPIC
+#ifdef TREZOR_EMULATOR
+ tropic_init(28992);
+#else
tropic_init();
+#endif
tropic_wait_for_ready();
#endif
#ifdef USE_HW_REVISION
diff --git a/core/embed/projects/unix/main.c b/core/embed/projects/unix/main.c
index 3e494a650..a7d515ab0 100644
--- a/core/embed/projects/unix/main.c
+++ b/core/embed/projects/unix/main.c
@@ -504,7 +504,7 @@ static int sdl_event_filter(void *userdata, SDL_Event *event) {
return 1;
}
-void drivers_init() {
+void drivers_init(uint16_t tropic_model_port) {
flash_init();
flash_otp_init();
@@ -521,7 +521,7 @@ void drivers_init() {
#endif
#ifdef USE_TROPIC
- tropic_init();
+ tropic_init(tropic_model_port);
#endif
usb_configure(NULL);
@@ -531,7 +531,7 @@ void drivers_init() {
// The function is called from the Rust before the test main function is run.
void rust_tests_c_setup(void) {
system_init(NULL);
- drivers_init();
+ drivers_init(28992);
}
MP_NOINLINE int main_(int argc, char **argv) {
@@ -559,7 +559,23 @@ MP_NOINLINE int main_(int argc, char **argv) {
system_init(&rsod_panic_handler);
- drivers_init();
+ char *tropic_model_port_str = getenv("TROPIC_MODEL_PORT");
+ uint16_t tropic_model_port;
+ if (tropic_model_port_str == NULL) {
+ tropic_model_port = 28992;
+ } else {
+ char *endptr;
+ long port_long = strtol(tropic_model_port_str, &endptr, 10);
+
+ if (*endptr != '\0' || port_long < 0 || port_long > 65535) {
+ printf("FATAL: invalid TROPIC_MODEL_PORT\n");
+ exit(1);
+ }
+
+ tropic_model_port = (uint16_t)port_long;
+ }
+
+ drivers_init(tropic_model_port);
SDL_SetEventFilter(sdl_event_filter, NULL);
diff --git a/core/embed/sec/tropic/inc/sec/tropic.h b/core/embed/sec/tropic/inc/sec/tropic.h
index 5d1f0d020..fc0edae8b 100644
--- a/core/embed/sec/tropic/inc/sec/tropic.h
+++ b/core/embed/sec/tropic/inc/sec/tropic.h
@@ -61,7 +61,11 @@
#ifdef KERNEL_MODE
+#ifdef TREZOR_EMULATOR
+bool tropic_init(uint16_t port);
+#else
bool tropic_init(void);
+#endif
void tropic_deinit(void);
diff --git a/core/embed/sec/tropic/tropic.c b/core/embed/sec/tropic/tropic.c
index 6fb0585e2..04ba6ec78 100644
--- a/core/embed/sec/tropic/tropic.c
+++ b/core/embed/sec/tropic/tropic.c
@@ -185,7 +185,11 @@ bool tropic_session_start(void) {
return false;
}
+#ifdef TREZOR_EMULATOR
+bool tropic_init(uint16_t port) {
+#else
bool tropic_init(void) {
+#endif
tropic_driver_t *drv = &g_tropic_driver;
if (drv->initialized) {
@@ -194,7 +198,7 @@ bool tropic_init(void) {
#ifdef TREZOR_EMULATOR
drv->device.addr = inet_addr("127.0.0.1");
- drv->device.port = 28992;
+ drv->device.port = port;
drv->handle.l2.device = &drv->device;
#endif
diff --git a/python/src/trezorlib/_internal/emulator.py b/python/src/trezorlib/_internal/emulator.py
index 07c3193f1..08499228a 100644
--- a/python/src/trezorlib/_internal/emulator.py
+++ b/python/src/trezorlib/_internal/emulator.py
@@ -17,6 +17,7 @@
import atexit
import logging
import os
+import socket
import subprocess
import time
from pathlib import Path
@@ -28,6 +29,7 @@ from ..transport.udp import UdpTransport
LOG = logging.getLogger(__name__)
+TROPIC_MODEL_WAIT_TIME = 10
EMULATOR_WAIT_TIME = 60
_RUNNING_PIDS = set()
@@ -47,6 +49,97 @@ def _rm_f(path: Path) -> None:
pass
+class TropicModel:
+ def __init__(
+ self,
+ workdir: Path,
+ port: int,
+ configfile: str,
+ logfile: Union[TextIO, str, Path],
+ ) -> None:
+ self.workdir = workdir
+ self.port = port
+ self.configfile = configfile
+ self.logfile = logfile
+ self.process: Optional[subprocess.Popen] = None
+
+ def start(self) -> None:
+ self.process = self._launch_process()
+ _RUNNING_PIDS.add(self.process)
+ try:
+ self._wait_until_ready()
+ except TimeoutError:
+ # Assuming that after the default, the process is stuck
+ LOG.warning(
+ f"Tropic model did not come up after {TROPIC_MODEL_WAIT_TIME} seconds"
+ )
+ self.process.kill()
+ raise
+
+ def stop(self) -> None:
+ if self.process:
+ LOG.info("Terminating Tropic model...")
+ start = time.monotonic()
+ self.process.terminate()
+ try:
+ self.process.wait(TROPIC_MODEL_WAIT_TIME)
+ end = time.monotonic()
+ LOG.info(f"Tropic model shut down after {end - start:.3f} seconds")
+ except subprocess.TimeoutExpired:
+ LOG.info("Tropic model seems stuck. Sending kill signal.")
+ self.process.kill()
+ _RUNNING_PIDS.remove(self.process)
+
+ def _launch_process(self) -> subprocess.Popen:
+ # Opening the file if it is not already opened
+ if hasattr(self.logfile, "write"):
+ output = self.logfile
+ else:
+ assert isinstance(self.logfile, (str, Path))
+ output = open(self.logfile, "w")
+
+ return subprocess.Popen(
+ [
+ "model_server",
+ "tcp",
+ "-c",
+ self.configfile,
+ "-p",
+ str(self.port),
+ ],
+ cwd=self.workdir,
+ stdout=cast(TextIO, output),
+ stderr=subprocess.STDOUT,
+ )
+
+ def _wait_until_ready(self, timeout: float = TROPIC_MODEL_WAIT_TIME) -> None:
+ assert self.process is not None, "Tropic model not started"
+ LOG.info("Waiting for Tropic model to come up...")
+ start = time.monotonic()
+ while True:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.settimeout(1.0)
+ # simply check whether we can connect to the TCP port
+ # where we told the model to listen
+ result = s.connect_ex(("127.0.0.1", self.port))
+ if result == 0:
+ # seems that even if the model is listening for connections
+ # it sometimes needs up to 2 seconds more
+ # before it actually correctly processes requests
+ time.sleep(2)
+ break
+ if self.process.poll() is not None:
+ raise RuntimeError("Tropic model process died")
+
+ elapsed = time.monotonic() - start
+ if elapsed >= timeout:
+ raise TimeoutError("Can't connect to Tropic model")
+
+ time.sleep(0.1)
+
+ LOG.info(f"Emulator ready after {time.monotonic() - start:.3f} seconds")
+
+
class Emulator:
STORAGE_FILENAME: str
@@ -96,6 +189,12 @@ class Emulator:
# To save all screenshots properly in one directory between restarts
self.restart_amount = 0
+ def start_tropic_model(self) -> None:
+ pass
+
+ def stop_tropic_model(self) -> None:
+ pass
+
@property
def client(self) -> TrezorClientDebugLink:
"""So that type-checkers do not see `client` as `Optional`.
@@ -117,7 +216,7 @@ class Emulator:
def _get_transport(self) -> UdpTransport:
return UdpTransport(f"127.0.0.1:{self.port}")
- def wait_until_ready(self, timeout: float = EMULATOR_WAIT_TIME) -> None:
+ def _wait_until_ready(self, timeout: float = EMULATOR_WAIT_TIME) -> None:
assert self.process is not None, "Emulator not started"
self.transport.open()
LOG.info("Waiting for emulator to come up...")
@@ -147,7 +246,7 @@ class Emulator:
self.stop()
return ret
- def launch_process(self) -> subprocess.Popen:
+ def _launch_process(self) -> subprocess.Popen:
args = self.make_args()
env = self.make_env()
@@ -180,11 +279,13 @@ class Emulator:
# process is running, no need to start again
return
+ self.start_tropic_model()
+
self.transport = transport or self._get_transport()
- self.process = self.launch_process()
+ self.process = self._launch_process()
_RUNNING_PIDS.add(self.process)
try:
- self.wait_until_ready()
+ self._wait_until_ready()
except TimeoutError:
# Assuming that after the default 60-second timeout, the process is stuck
LOG.warning(f"Emulator did not come up after {EMULATOR_WAIT_TIME} seconds")
@@ -223,6 +324,8 @@ class Emulator:
self.process.kill()
_RUNNING_PIDS.remove(self.process)
+ self.stop_tropic_model()
+
_rm_f(self.profile_dir / "trezor.pid")
_rm_f(self.profile_dir / "trezor.port")
self.process = None
@@ -255,6 +358,10 @@ class CoreEmulator(Emulator):
def __init__(
self,
*args: Any,
+ launch_tropic_model: bool = False,
+ tropic_model_port: Optional[int] = None,
+ tropic_model_configfile: Optional[str] = None,
+ tropic_model_logfile: Union[TextIO, str, Path, None] = None,
port: Optional[int] = None,
main_args: Sequence[str] = ("-m", "main"),
workdir: Optional[Path] = None,
@@ -271,12 +378,34 @@ class CoreEmulator(Emulator):
if sdcard is not None:
self.sdcard.write_bytes(sdcard)
+ if launch_tropic_model:
+ assert tropic_model_port
+ assert tropic_model_configfile
+ self.tropic_model = TropicModel(
+ workdir=self.workdir,
+ port=tropic_model_port,
+ configfile=tropic_model_configfile,
+ logfile=(
+ tropic_model_logfile or self.profile_dir / "trezor-tropic-model.log"
+ ),
+ )
+ else:
+ self.tropic_model = None
+
if port:
self.port = port
self.disable_animation = disable_animation
self.main_args = list(main_args)
self.heap_size = heap_size
+ def start_tropic_model(self) -> None:
+ if self.tropic_model:
+ self.tropic_model.start()
+
+ def stop_tropic_model(self) -> None:
+ if self.tropic_model:
+ self.tropic_model.stop()
+
def make_env(self) -> Dict[str, str]:
env = super().make_env()
env.update(
@@ -289,6 +418,8 @@ class CoreEmulator(Emulator):
if self.headless or self.disable_animation:
env["TREZOR_DISABLE_FADE"] = "1"
env["TREZOR_DISABLE_ANIMATION"] = "1"
+ if self.tropic_model:
+ env["TROPIC_MODEL_PORT"] = str(self.tropic_model.port)
return env
@@ -309,3 +440,9 @@ class LegacyEmulator(Emulator):
if self.headless:
env["SDL_VIDEODRIVER"] = "dummy"
return env
+
+ def start_tropic_model(self) -> None:
+ pass
+
+ def stop_tropic_model(self) -> None:
+ pass
diff --git a/tests/conftest.py b/tests/conftest.py
index fd7963899..027bcf82e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -130,6 +130,7 @@ def emulator(request: pytest.FixtureRequest) -> t.Generator["Emulator", None, No
headless=True,
auto_interact=not interact,
main_args=_emulator_wrapper_main_args(),
+ launch_tropic_model=True,
) as emu:
yield emu
diff --git a/tests/emulators.py b/tests/emulators.py
index b3573abf5..90eecbc54 100644
--- a/tests/emulators.py
+++ b/tests/emulators.py
@@ -34,6 +34,8 @@ CORE_SRC_DIR = ROOT / "core" / "src"
ENV = {"SDL_VIDEODRIVER": "dummy"}
+TROPIC_MODEL_CONFIGFILE = ROOT / "tests" / "tropic_model" / "config.yml"
+
def check_version(tag: str, version_tuple: Tuple[int, int, int]) -> None:
if tag is not None and tag.startswith("v") and len(tag.split(".")) == 3:
@@ -67,6 +69,14 @@ def get_tags() -> Dict[str, List[str]]:
ALL_TAGS = get_tags()
+def _get_tropic_model_port(worker_id: int) -> int:
+ """Get a unique port for this worker process' Tropic model.
+
+ Guarantees to be unique because each worker has a unique ID.
+ """
+ return 28992 + worker_id # 28992 is the default port tvl server listens to
+
+
def _get_port(worker_id: int) -> int:
"""Get a unique port for this worker process on which it can run.
@@ -88,6 +98,7 @@ class EmulatorWrapper:
headless: bool = True,
auto_interact: bool = True,
main_args: Sequence[str] = ("-m", "main"),
+ launch_tropic_model: bool = False,
) -> None:
if tag is not None:
executable = filename_from_tag(gen, tag)
@@ -105,8 +116,12 @@ class EmulatorWrapper:
logs_dir = os.environ.get("TREZOR_PYTEST_LOGS_DIR")
logfile = None
+ tropic_model_logfile = None
if logs_dir:
logfile = Path(logs_dir) / f"trezor-{worker_id}.log"
+ tropic_model_logfile = (
+ Path(logs_dir) / f"trezor-tropic-model-{worker_id}.log"
+ )
if gen == "legacy":
self.emulator = LegacyEmulator(
@@ -123,6 +138,10 @@ class EmulatorWrapper:
self.profile_dir.name,
storage=storage,
workdir=workdir,
+ launch_tropic_model=launch_tropic_model,
+ tropic_model_port=_get_tropic_model_port(worker_id),
+ tropic_model_configfile=str(TROPIC_MODEL_CONFIGFILE),
+ tropic_model_logfile=tropic_model_logfile,
port=_get_port(worker_id),
headless=headless,
auto_interact=auto_interact,
Why this scored 15/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.