feat(core/prodtest): Add tropic-tests-cleanup.
What changed, and why it matters
This commit adds a new factory-test command called tropic-tests-cleanup to the Trezor device's production-test firmware. The command erases leftover test data from secure chip slots so a device is not accidentally shipped with stray data from earlier tests. It also slightly broadens the conditions under which a PIN-change counter is reinitialized in the normal firmware. There is no direct evidence in the commit that this fixes an active security vulnerability; it reads as a defensive manufacturing-hygiene improvement.
Treat as a routine hardening/cleanup change. Review the tropic.c counter-handling change independently to confirm that reinitializing a depleted change-PIN counter is the intended behavior and does not weaken anti-hammering protections. No urgent action is indicated by the commit alone.
Security signals we found
New factory-only CLI command that erases test artifacts from secure-element slots before shipping
Broadened counter reinitialization path in the change-PIN flow to handle a depleted counter
No mention of CVE, bug bounty, researcher credit, or advisory in commit or supplied references
Evidence from the diff
The change introduces prodtest_tropic_tests_cleanup(), a CLI command in the production-test (prodtest) image that iterates over Tropic secure-element R-memory slots, ECC key slots, and monotonic counters touched by tropic-test-* commands and restores them to an empty/maximum state. It adds three new prodtest error codes. A small edit in core/embed/sec/tropic/tropic.c treats LT_L3_UPDATE_ERR the same as LT_L3_COUNTER_INVALID when updating the change-PIN counter, causing the counter to be reinitialized if it is depleted rather than only if it has never been initialized.
Changed components
core/embed/projects/prodtest/cmd/prodtest_tropic.ccore/embed/projects/prodtest/error_codes.jsoncore/embed/projects/prodtest/prodtest_error_codes.hcore/embed/sec/tropic/tropic.cInspect captured patch +178 / −25
diff --git a/core/embed/projects/prodtest/README.md b/core/embed/projects/prodtest/README.md
index 067e1391..8df8e674 100644
--- a/core/embed/projects/prodtest/README.md
+++ b/core/embed/projects/prodtest/README.md
@@ -1507,6 +1507,16 @@ Several resources are partitioned by pairing-key privilege, so the available slo
The R-memory test never touches the certificate slots (`0`–`5`).
+### tropic-tests-cleanup
+
+Ensures that the slots written by the `tropic-test-*` commands are reset to a clean state. Run this if one of the test commands reports a problem, so the device is not shipped with stray data left in a slot.
+
+Example:
+```
+tropic-tests-cleanup
+OK
+```
+
### tropic-benchmark
Measures the actual average duration of individual Tropic operations over 25 iterations and prints the results.
diff --git a/core/embed/projects/prodtest/cmd/prodtest_tropic.c b/core/embed/projects/prodtest/cmd/prodtest_tropic.c
index 212c2b03..d253c26a 100644
--- a/core/embed/projects/prodtest/cmd/prodtest_tropic.c
+++ b/core/embed/projects/prodtest/cmd/prodtest_tropic.c
@@ -478,6 +478,33 @@ static const struct lt_config_t g_reversible_configuration = {
}};
// clang-format on
+// Total number of MAC-and-destroy slots.
+#define TROPIC_MAC_AND_DESTROY_SLOT_TOTAL \
+ (2 * TROPIC_MAC_AND_DESTROY_SLOT_COUNT)
+
+// Number of monotonic counters.
+#define TROPIC_MCOUNTER_COUNT (TR01_MCOUNTER_INDEX_15 + 1)
+// For unprivileged sessions, counter initialization is restricted to counters
+// 4-15. Mirrors `CFG_UAP_MCOUNTER_INIT`.
+#define TROPIC_FIRST_UNPRIVILEGED_MCOUNTER 4
+
+// R-memory range used by the writable-slot test. Starts right after the
+// certificate slots (0-5) and Tropic config distribution version slots (6, 7),
+// which must not be overwritten.
+#define TROPIC_RMEM_TEST_FIRST 8
+#define TROPIC_RMEM_TEST_LAST TR01_R_MEM_DATA_SLOT_MAX
+#define TROPIC_RMEM_TEST_COUNT \
+ (TROPIC_RMEM_TEST_LAST - TROPIC_RMEM_TEST_FIRST + 1)
+// For unprivileged sessions, R-memory writes are restricted to slots 256-511.
+// Mirrors `CFG_UAP_R_MEM_DATA_WRITE`,
+#define TROPIC_RMEM_UNPRIVILEGED_FIRST 256
+// Amount of data written and read back per R-memory slot in the test.
+#define TROPIC_RMEM_TEST_DATA_SIZE 64
+
+// First ECC key slot that is not provisioned (device key is slot 0, FIDO key is
+// slot 1).
+#define TROPIC_ECC_TEST_FIRST TR01_ECC_SLOT_2
+
static void prodtest_tropic_get_riscv_fw_version(cli_t* cli) {
if (cli_arg_count(cli) > 0) {
cli_error_arg_count(cli);
@@ -1173,6 +1200,103 @@ static void prodtest_tropic_send_command(cli_t* cli) {
cli_ok_hexdata(cli, output, output_length);
}
+// Brings the non-provisioned slots into a clean state in case a test fails to
+// clean them up.
+static bool tropic_tests_cleanup(cli_t* cli, lt_handle_t* h,
+ bool unprivileged) {
+ if (unprivileged) {
+ cli_trace(cli,
+ "Privileged session unavailable; cleaning only the unprivileged "
+ "slot ranges.");
+ }
+
+ // R-memory data slots that were tested and can be erased.
+ uint16_t rmem_first =
+ unprivileged ? TROPIC_RMEM_UNPRIVILEGED_FIRST : TROPIC_RMEM_TEST_FIRST;
+ for (uint16_t slot = rmem_first; slot <= TROPIC_RMEM_TEST_LAST; slot++) {
+ uint8_t data[TROPIC_RMEM_TEST_DATA_SIZE] = {0};
+ uint16_t read_size = 0;
+ lt_ret_t res = lt_r_mem_data_read(h, slot, data, sizeof(data), &read_size);
+ if (res == LT_L3_R_MEM_DATA_READ_SLOT_EMPTY) {
+ continue; // Expected: already empty.
+ }
+ cli_trace(cli, "WARNING: data slot %d was not empty (read '%s'); erasing.",
+ slot, lt_ret_verbose(res));
+ res = lt_r_mem_data_erase(h, slot);
+ if (res != LT_OK) {
+ cli_error(cli, PRODTEST_ERR_TROPIC_TESTS_CLEANUP_RMEM,
+ "Failed to erase data slot %d: '%s'", slot,
+ lt_ret_verbose(res));
+ return false;
+ }
+ res = lt_r_mem_data_read(h, slot, data, sizeof(data), &read_size);
+ if (res != LT_L3_R_MEM_DATA_READ_SLOT_EMPTY) {
+ cli_error(cli, PRODTEST_ERR_TROPIC_TESTS_CLEANUP_RMEM,
+ "Data slot %d still not empty after erase ('%s').", slot,
+ lt_ret_verbose(res));
+ return false;
+ }
+ }
+
+ // ECC key slots above the device and FIDO keys.
+ for (lt_ecc_slot_t slot = TROPIC_ECC_TEST_FIRST; slot <= TR01_ECC_SLOT_31;
+ slot++) {
+ uint8_t pubkey[64] = {0};
+ lt_ecc_curve_type_t curve = 0;
+ lt_ecc_key_origin_t origin = 0;
+ lt_ret_t res =
+ lt_ecc_key_read(h, slot, pubkey, sizeof(pubkey), &curve, &origin);
+ if (res == LT_L3_INVALID_KEY) {
+ continue; // Expected: already empty.
+ }
+ cli_trace(cli, "WARNING: ECC slot %d was not empty (read '%s'); erasing.",
+ slot, lt_ret_verbose(res));
+ res = lt_ecc_key_erase(h, slot);
+ if (res != LT_OK) {
+ cli_error(cli, PRODTEST_ERR_TROPIC_TESTS_CLEANUP_ECC,
+ "Failed to erase ECC slot %d: '%s'", slot, lt_ret_verbose(res));
+ return false;
+ }
+ res = lt_ecc_key_read(h, slot, pubkey, sizeof(pubkey), &curve, &origin);
+ if (res != LT_L3_INVALID_KEY) {
+ cli_error(cli, PRODTEST_ERR_TROPIC_TESTS_CLEANUP_ECC,
+ "ECC slot %d still not empty after erase ('%s').", slot,
+ lt_ret_verbose(res));
+ return false;
+ }
+ }
+
+ // Monotonic counters cannot be de-initialized, so at least ensure that the
+ // ones we can reinitialize are set to the maximum value if they were touched.
+ lt_mcounter_index_t counter_first =
+ unprivileged ? TROPIC_FIRST_UNPRIVILEGED_MCOUNTER : 0;
+ for (lt_mcounter_index_t idx = counter_first; idx < TROPIC_MCOUNTER_COUNT;
+ idx++) {
+ uint32_t value = 0;
+ lt_ret_t res = lt_mcounter_get(h, idx, &value);
+ if (res == LT_L3_COUNTER_INVALID ||
+ (res == LT_OK && value == TR01_MCOUNTER_VALUE_MAX)) {
+ continue; // Uninitialized or already at maximum.
+ }
+ res = lt_mcounter_init(h, idx, TR01_MCOUNTER_VALUE_MAX);
+ if (res != LT_OK) {
+ cli_error(cli, PRODTEST_ERR_TROPIC_TESTS_CLEANUP_COUNTER,
+ "Failed to reset counter %d to max: '%s'", idx,
+ lt_ret_verbose(res));
+ return false;
+ }
+ res = lt_mcounter_get(h, idx, &value);
+ if (res != LT_OK || value != TR01_MCOUNTER_VALUE_MAX) {
+ cli_error(cli, PRODTEST_ERR_TROPIC_TESTS_CLEANUP_COUNTER,
+ "Counter %d not at max after reset (value %u, '%s').", idx,
+ (unsigned)value, lt_ret_verbose(res));
+ return false;
+ }
+ }
+
+ return true;
+}
+
static void prodtest_tropic_lock(cli_t* cli) {
// This function is:
// * idempotent (it can be called multiple times without changing the state
@@ -1654,29 +1778,6 @@ cleanup:
tropic_deinit();
}
-// Total number of MAC-and-destroy slots.
-#define TROPIC_MAC_AND_DESTROY_SLOT_TOTAL \
- (2 * TROPIC_MAC_AND_DESTROY_SLOT_COUNT)
-
-// Number of monotonic counters.
-#define TROPIC_MCOUNTER_COUNT (TR01_MCOUNTER_INDEX_15 + 1)
-// For unprivileged sessions, counter initialization is restricted to counters
-// 4-15. Mirrors `CFG_UAP_MCOUNTER_INIT`.
-#define TROPIC_FIRST_UNPRIVILEGED_MCOUNTER 4
-
-// R-memory range used by the writable-slot test. Starts right after the
-// certificate slots (0-5), which must never be overwritten.
-#define TROPIC_RMEM_TEST_FIRST \
- (TROPIC_DEVICE_CERT_FIRST_SLOT + TROPIC_DEVICE_CERT_SLOT_COUNT)
-#define TROPIC_RMEM_TEST_LAST TR01_R_MEM_DATA_SLOT_MAX
-#define TROPIC_RMEM_TEST_COUNT \
- (TROPIC_RMEM_TEST_LAST - TROPIC_RMEM_TEST_FIRST + 1)
-// For unprivileged sessions, R-memory writes are restricted to slots 256-511.
-// Mirrors `CFG_UAP_R_MEM_DATA_WRITE`,
-#define TROPIC_RMEM_UNPRIVILEGED_FIRST 256
-// Amount of data written and read back per R-memory slot in the test.
-#define TROPIC_RMEM_TEST_DATA_SIZE 64
-
// Per-command identifiers used as PRNG seeds so that different commands sample
// different slot subsets.
typedef enum {
@@ -3021,8 +3122,32 @@ static void prodtest_tropic_read_configs(cli_t* cli) {
cli_ok(cli, "");
}
+static void prodtest_tropic_tests_cleanup(cli_t* cli) {
+ if (cli_arg_count(cli) != 0) {
+ cli_error_arg_count(cli);
+ return;
+ }
+ lt_pkey_index_t pairing_key_index = -1;
+ if (!tropic_ensure_session(cli, &pairing_key_index)) {
+ return;
+ }
+ bool unprivileged = pairing_key_index == TROPIC_UNPRIVILEGED_PAIRING_KEY_SLOT;
+ if (!tropic_tests_cleanup(cli, tropic_get_handle(), unprivileged)) {
+ // Error already reported by tropic_tests_cleanup().
+ return;
+ }
+ cli_ok(cli, "");
+}
+
// clang-format off
+PRODTEST_CLI_CMD(
+ .name = "tropic-tests-cleanup",
+ .func = prodtest_tropic_tests_cleanup,
+ .info = "Reset the slots written by the tropic-test-* commands to a clean state",
+ .args = ""
+);
+
PRODTEST_CLI_CMD(
.name = "tropic-get-riscv-fw-version",
.func = prodtest_tropic_get_riscv_fw_version,
diff --git a/core/embed/projects/prodtest/error_codes.json b/core/embed/projects/prodtest/error_codes.json
index 597787ec..43ae8c31 100644
--- a/core/embed/projects/prodtest/error_codes.json
+++ b/core/embed/projects/prodtest/error_codes.json
@@ -1636,6 +1636,21 @@
"name": "PRODTEST_ERR_TROPIC_SENSORS_REBOOT",
"module": "tropic"
},
+ {
+ "code": 20140,
+ "name": "PRODTEST_ERR_TROPIC_TESTS_CLEANUP_RMEM",
+ "module": "tropic"
+ },
+ {
+ "code": 20141,
+ "name": "PRODTEST_ERR_TROPIC_TESTS_CLEANUP_ECC",
+ "module": "tropic"
+ },
+ {
+ "code": 20142,
+ "name": "PRODTEST_ERR_TROPIC_TESTS_CLEANUP_COUNTER",
+ "module": "tropic"
+ },
{
"code": 21010,
"name": "PRODTEST_ERR_UNIT_TEST_FAILED",
diff --git a/core/embed/projects/prodtest/prodtest_error_codes.h b/core/embed/projects/prodtest/prodtest_error_codes.h
index 8c475fb8..b49bdbb8 100644
--- a/core/embed/projects/prodtest/prodtest_error_codes.h
+++ b/core/embed/projects/prodtest/prodtest_error_codes.h
@@ -398,6 +398,9 @@ typedef enum {
PRODTEST_ERR_TROPIC_EXPLICIT_SLOT_COUNT = 20137,
PRODTEST_ERR_TROPIC_EXPLICIT_SLOT_RANGE = 20138,
PRODTEST_ERR_TROPIC_SENSORS_REBOOT = 20139,
+ PRODTEST_ERR_TROPIC_TESTS_CLEANUP_RMEM = 20140,
+ PRODTEST_ERR_TROPIC_TESTS_CLEANUP_ECC = 20141,
+ PRODTEST_ERR_TROPIC_TESTS_CLEANUP_COUNTER = 20142,
// === unit-test (21000–21999) ===
PRODTEST_ERR_UNIT_TEST_FAILED = 21010,
diff --git a/core/embed/sec/tropic/tropic.c b/core/embed/sec/tropic/tropic.c
index b87e420b..29ae3c44 100644
--- a/core/embed/sec/tropic/tropic.c
+++ b/core/embed/sec/tropic/tropic.c
@@ -685,8 +685,8 @@ static bool update_change_pin_counter() {
g_is_change_pin_counter_cached = false;
ret = TROPIC_RETRY_COMMAND(
lt_mcounter_update(&drv->handle, TROPIC_CHANGE_COUNTER_SLOT));
- if (ret == LT_L3_COUNTER_INVALID) {
- // The counter has not been initialized yet
+ if (ret == LT_L3_COUNTER_INVALID || ret == LT_L3_UPDATE_ERR) {
+ // The counter has not been initialized yet or is depleted.
ret = TROPIC_RETRY_COMMAND(
lt_mcounter_init(&drv->handle, TROPIC_CHANGE_COUNTER_SLOT,
TROPIC_CHANGE_COUNTER_SLOT_MAX_VALUE - 1));
Why this scored 22/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.