fix(legacy): Enforce matrix recovery for mnemonics under 24 words.
What changed, and why it matters
This update changes how older Trezor devices (the 'legacy' models) restore a wallet from a recovery phrase. Previously, users could type in each word directly in order ('ScrambledWords'). Now, for phrases shorter than 24 words, the device requires the more secure 'matrix' method unless the user has deliberately lowered safety checks. The change is meant to reduce the risk that malware on a computer can spy on or manipulate the recovery words during wallet restore.
Treat this as a security-hardening fix for legacy recovery. Users with 12- or 18-word seeds should use matrix recovery or explicitly accept reduced safety checks. Review whether any host software or documentation still relies on ScrambledWords for short mnemonics and update accordingly. No immediate emergency patch action is indicated, but firmware should be updated to include this restriction.
Security signals we found
Enforcement of matrix-based recovery for shorter mnemonics
ScrambledWords recovery restricted to 24-word mnemonics under strict safety checks
New failure path in recovery_init for disallowed input method
Changelog entry labeled +recovery.security
Test changes require lowering safety checks to use legacy ScrambledWords with 12/18-word seeds
Evidence from the diff
The patch enforces RecoveryDeviceInputMethod_Matrix for RecoveryDevice on legacy firmware when word_count is 12 or 18 and the current safety-check level is Strict. If ScrambledWords is requested under those conditions, recovery_init() now returns Failure_DataError with the message ‘Advanced recovery must be used.’ Tests are updated to set SafetyCheckLevel.PromptTemporarily before exercising ScrambledWords with short mnemonics, and several legacy tests switch to 24-word mnemonics to keep using ScrambledWords. The proto comment is updated to note that Matrix is the recommended method and ScrambledWords is allowed only for 24-word recovery unless safety checks are disabled.
Changed components
legacy/firmware/recovery.ccommon/protob/messages-management.protolegacy device recovery flowRecoveryDevice message handlingInspect captured patch +53 / −16
diff --git a/common/protob/messages-management.proto b/common/protob/messages-management.proto
index 23ee3c82..dfc12a92 100644
--- a/common/protob/messages-management.proto
+++ b/common/protob/messages-management.proto
@@ -523,8 +523,8 @@ message RecoveryDevice {
* `RecoveryDeviceInputMethod_ScrambledWords | RecoveryDeviceInputMethod_Matrix`
* listing every method supported by the host computer.
*
- * Note that ScrambledWords must be supported by every implementation
- * for backward compatibility; there is no way to not support it.
+ * Note that Matrix recovery is the recommended recovery method. ScrambledWords
+ * is allowed only for 24 word recovery, unless safety checks are disabled.
*/
enum RecoveryDeviceInputMethod {
// use powers of two when extending this field
diff --git a/legacy/firmware/.changelog.d/+recovery.security b/legacy/firmware/.changelog.d/+recovery.security
new file mode 100644
index 00000000..0f90179e
--- /dev/null
+++ b/legacy/firmware/.changelog.d/+recovery.security
@@ -0,0 +1 @@
+Enforce advanced recovery for mnemonics shorter than 24 words.
diff --git a/legacy/firmware/recovery.c b/legacy/firmware/recovery.c
index 179c5a64..5c695c24 100644
--- a/legacy/firmware/recovery.c
+++ b/legacy/firmware/recovery.c
@@ -478,6 +478,15 @@ void recovery_init(uint32_t _word_count, bool passphrase_protection,
uint32_t u2f_counter, bool _dry_run) {
if (_word_count != 12 && _word_count != 18 && _word_count != 24) return;
+ if (_word_count < 24 &&
+ input_method == RecoveryDeviceInputMethod_ScrambledWords &&
+ config_getSafetyCheckLevel() == SafetyCheckLevel_Strict) {
+ fsm_sendFailure(FailureType_Failure_DataError,
+ _("Advanced recovery must be used."));
+ layoutHome();
+ return;
+ }
+
recovery_mode = RECOVERY_NONE;
word_pincode = 0;
word_index = 0;
diff --git a/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py b/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py
index 164b6998..f853209d 100644
--- a/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py
+++ b/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py
@@ -38,10 +38,17 @@ def do_recover_legacy(session: Session, mnemonic: list[str]) -> None:
return word
+ word_count = len(mnemonic)
+ if word_count < 24:
+ # `ScrambledWords` is disabled by default for shorter mnemonics.
+ device.apply_settings(
+ session, safety_checks=messages.SafetyCheckLevel.PromptTemporarily
+ )
+
device.recover(
session,
type=messages.RecoveryType.DryRun,
- word_count=len(mnemonic),
+ word_count=word_count,
input_method=messages.RecoveryDeviceInputMethod.ScrambledWords,
input_callback=input_callback,
)
diff --git a/tests/device_tests/reset_recovery/test_recovery_bip39_t1.py b/tests/device_tests/reset_recovery/test_recovery_bip39_t1.py
index 79b25eda..51216397 100644
--- a/tests/device_tests/reset_recovery/test_recovery_bip39_t1.py
+++ b/tests/device_tests/reset_recovery/test_recovery_bip39_t1.py
@@ -34,6 +34,12 @@ pytestmark = [
def test_pin_passphrase(test_ctx: TrezorTestContext):
session = test_ctx.get_seedless_session()
+
+ # `ScrambledWords` is disabled by default for shorter mnemonics.
+ device.apply_settings(
+ session, safety_checks=messages.SafetyCheckLevel.PromptTemporarily
+ )
+
debug = session.debug
mnemonic = MNEMONIC12.split(" ")
ret = session.call_raw(
@@ -108,6 +114,12 @@ def test_pin_passphrase(test_ctx: TrezorTestContext):
def test_nopin_nopassphrase(test_ctx: TrezorTestContext):
session = test_ctx.get_seedless_session()
+
+ # `ScrambledWords` is disabled by default for shorter mnemonics.
+ device.apply_settings(
+ session, safety_checks=messages.SafetyCheckLevel.PromptTemporarily
+ )
+
mnemonic = MNEMONIC12.split(" ")
ret = session.call_raw(
messages.RecoveryDevice(
@@ -162,7 +174,7 @@ def test_word_fail(session: Session):
debug = session.debug
ret = session.call_raw(
messages.RecoveryDevice(
- word_count=12,
+ word_count=24,
passphrase_protection=False,
pin_protection=False,
label="label",
@@ -176,7 +188,7 @@ def test_word_fail(session: Session):
ret = session.call_raw(messages.ButtonAck())
assert isinstance(ret, messages.WordRequest)
- for _ in range(int(12 * 2)):
+ for _ in range(int(24 * 2)):
(word, pos) = debug.read_recovery_word()
if pos != 0:
ret = session.call_raw(messages.WordAck(word="kwyjibo"))
@@ -190,7 +202,7 @@ def test_pin_fail(session: Session):
debug = session.debug
ret = session.call_raw(
messages.RecoveryDevice(
- word_count=12,
+ word_count=24,
passphrase_protection=True,
pin_protection=True,
label="label",
@@ -223,7 +235,7 @@ def test_already_initialized(session: Session):
with pytest.raises(RuntimeError):
device.recover(
session,
- word_count=12,
+ word_count=24,
pin_protection=False,
passphrase_protection=False,
label="label",
@@ -232,7 +244,7 @@ def test_already_initialized(session: Session):
ret = session.call_raw(
messages.RecoveryDevice(
- word_count=12,
+ word_count=24,
input_method=messages.RecoveryDeviceInputMethod.ScrambledWords,
)
)
diff --git a/tests/device_tests/test_protection_levels.py b/tests/device_tests/test_protection_levels.py
index 0d99ba4b..da07cfe7 100644
--- a/tests/device_tests/test_protection_levels.py
+++ b/tests/device_tests/test_protection_levels.py
@@ -327,11 +327,19 @@ def test_recovery_device(session: Session):
session.test_ctx.use_mnemonic(MNEMONIC12)
with session.test_ctx as client:
client.set_expected_responses(
- [messages.Features, messages.ButtonRequest]
+ # for `apply_settings`
+ [messages.Features, messages.ButtonRequest, messages.Success]
+ # for `recover`
+ + [messages.Features, messages.ButtonRequest]
+ [messages.WordRequest] * 24
+ [messages.Success, messages.Features]
)
+ # `ScrambledWords` is disabled by default for shorter mnemonics.
+ device.apply_settings(
+ session, safety_checks=messages.SafetyCheckLevel.PromptTemporarily
+ )
+
device.recover(
session,
12,
diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json
index 1547bcb3..3e4a58a4 100644
--- a/tests/ui_tests/fixtures.json
+++ b/tests/ui_tests/fixtures.json
@@ -633,14 +633,14 @@
"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_bad_parameters[passphrase_protection-True]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_bad_parameters[pin_protection-True]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_bad_parameters[u2f_counter-1]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
-"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_dry_run": "19107fcdec9d88bb254164797b3e959c14c34138ca792e061312324f047f8868",
-"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_invalid_seed_t1": "5cbdd2bbb62db19c2dd8a2209c18c803fca6754d5452f6bafa4a32d4aeee2300",
-"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_seed_mismatch": "1de2c1b0e785fd429d886f6912e99380e9d2607d5741db4437ed0b2ba2156aa4",
-"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_uninitialized": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_dry_run": "24bb5e13f07b4679fb6636daa3b21f0047b4c6cd8ea1d5bf4ddc57c92f4e11cb",
+"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_invalid_seed_t1": "b14c44886a5c9fbd17465bb5dd6cd4b4e09d1df9a34efab2b01676065233b66e",
+"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_seed_mismatch": "517734ea33770b2a9d856c6930a9aa641b99324e19c555361745bb0b7507ee4c",
+"T1B1_en_reset_recovery-test_recovery_bip39_dryrun.py::test_uninitialized": "55f043b3e286b778a02baea8f7c3547208849e2e18f90837bd9374a4a14c5c0b",
"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_already_initialized": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
-"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_nopin_nopassphrase": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_nopin_nopassphrase": "55f043b3e286b778a02baea8f7c3547208849e2e18f90837bd9374a4a14c5c0b",
"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_pin_fail": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
-"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_pin_passphrase": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_pin_passphrase": "55f043b3e286b778a02baea8f7c3547208849e2e18f90837bd9374a4a14c5c0b",
"T1B1_en_reset_recovery-test_recovery_bip39_t1.py::test_word_fail": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_reset_recovery-test_reset_bip39_skipbackup.py::test_initialized_device_backup_fail": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_reset_recovery-test_reset_bip39_skipbackup.py::test_reset_device_skip_backup": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
@@ -772,7 +772,7 @@
"T1B1_en_test_protection_levels.py::test_get_public_key": "7efc10b726fdd4d79f6aef9c68b409ae8c11b923907c30c790fda69f6562e0d1",
"T1B1_en_test_protection_levels.py::test_initialize": "8bfe20a8b630b9c6c8019d7c1227833bdb5f3929e5603b895bc11cfe3db9b8e5",
"T1B1_en_test_protection_levels.py::test_ping": "de7fc40b2f35e82fa486f1b97ee3e34a96d0a67412537e8a0fddacc0b0b1649d",
-"T1B1_en_test_protection_levels.py::test_recovery_device": "9bcc413cf3e44af03f2dbb038c4df43bf503805447b71dd5713ab34335f9341b",
+"T1B1_en_test_protection_levels.py::test_recovery_device": "d23b969dc0be6cd2568b8a93b7acffb986c5c2093b1062bba27a003994642b60",
"T1B1_en_test_protection_levels.py::test_reset_device": "6661fd92df3c034ae64d5e4ba5fe61eb1d7b76d9ba3232348da4a1a3ffd5d464",
"T1B1_en_test_protection_levels.py::test_sign_message": "b97d465af82847dd673d41de699e8ae1f5290f0e1b3cac80d023ab6088514ce3",
"T1B1_en_test_protection_levels.py::test_sign_message_seedless": "7efc10b726fdd4d79f6aef9c68b409ae8c11b923907c30c790fda69f6562e0d1",
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.