From 1e6ed376329b0ecd87ae92ed0b6dd291d4f020df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ioan=20Biz=C4=83u?= Date: Tue, 23 Apr 2024 12:26:46 +0200 Subject: [PATCH] feat(core): implement repeated backup --- common/protob/messages-management.proto | 14 +- core/.changelog.d/3640.added | 1 + core/embed/rust/librust_qstr.h | 3 + .../generated/translated_string.rs | 17 +- core/embed/rust/src/ui/model_tt/layout.rs | 2 +- core/mocks/trezortranslate_keys.pyi | 4 +- core/src/all_modules.py | 2 + core/src/apps/management/backup_device.py | 16 +- .../management/recovery_device/__init__.py | 77 ++++--- .../management/recovery_device/homescreen.py | 99 ++++++++- core/src/storage/cache.py | 3 +- core/src/storage/recovery.py | 17 +- core/src/trezor/enums/RecoveryKind.py | 7 + core/src/trezor/enums/__init__.py | 5 + core/src/trezor/messages.py | 5 +- core/tools/translations/rules.json | 1 - core/translations/cs.json | 4 +- core/translations/de.json | 4 +- core/translations/en.json | 4 +- core/translations/es.json | 4 +- core/translations/fr.json | 4 +- core/translations/order.json | 7 +- core/translations/signatures.json | 6 +- docs/common/message-workflows.md | 16 +- legacy/firmware/fsm_msg_common.h | 4 +- python/src/trezorlib/cli/device.py | 3 + python/src/trezorlib/device.py | 18 +- python/src/trezorlib/messages.py | 12 +- rust/trezor-client/src/client/mod.rs | 6 +- .../protos/generated/messages_management.rs | 179 ++++++++++----- tests/click_tests/recovery.py | 32 ++- tests/click_tests/reset.py | 6 +- .../click_tests/test_backup_slip39_custom.py | 2 +- tests/click_tests/test_repeated_backup.py | 208 ++++++++++++++++++ tests/click_tests/test_reset_bip39.py | 2 +- .../click_tests/test_reset_slip39_advanced.py | 4 +- tests/click_tests/test_reset_slip39_basic.py | 2 +- .../test_recovery_bip39_dryrun.py | 9 +- tests/device_tests/test_repeated_backup.py | 63 ++++++ tests/ui_tests/fixtures.json | 4 + 40 files changed, 713 insertions(+), 163 deletions(-) create mode 100644 core/.changelog.d/3640.added create mode 100644 core/src/trezor/enums/RecoveryKind.py create mode 100644 tests/click_tests/test_repeated_backup.py create mode 100644 tests/device_tests/test_repeated_backup.py diff --git a/common/protob/messages-management.proto b/common/protob/messages-management.proto index 4c5c8f60036..bf3a1b4a889 100644 --- a/common/protob/messages-management.proto +++ b/common/protob/messages-management.proto @@ -427,16 +427,16 @@ message EntropyAck { * @next WordRequest */ message RecoveryDevice { - optional uint32 word_count = 1; // number of words in BIP-39 mnemonic + optional uint32 word_count = 1; // number of words in BIP-39 mnemonic (T1 only) optional bool passphrase_protection = 2; // enable master node encryption using passphrase optional bool pin_protection = 3; // enable PIN protection optional string language = 4 [deprecated=true]; // deprecated (use ChangeLanguage) optional string label = 5; // device label - optional bool enforce_wordlist = 6; // enforce BIP-39 wordlist during the process + optional bool enforce_wordlist = 6; // enforce BIP-39 wordlist during the process (T1 only) // 7 reserved for unused recovery method - optional RecoveryDeviceType type = 8; // supported recovery type + optional RecoveryDeviceType type = 8; // supported recovery type (T1 only) optional uint32 u2f_counter = 9; // U2F counter - optional bool dry_run = 10; // perform dry-run recovery workflow (for safe mnemonic validation) + optional RecoveryKind kind = 10; // the kind of recovery to perform /** * Type of recovery procedure. These should be used as bitmask, e.g., * `RecoveryDeviceType_ScrambledWords | RecoveryDeviceType_Matrix` @@ -450,6 +450,12 @@ message RecoveryDevice { RecoveryDeviceType_ScrambledWords = 0; // words in scrambled order RecoveryDeviceType_Matrix = 1; // matrix recovery type } + + enum RecoveryKind { + RecoveryKind_NormalRecovery = 0; // recovery from seedphrase on an uninitialized device + RecoveryKind_DryRun = 1; // mnemonic validation + RecoveryKind_UnlockRepeatedBackup = 2; // unlock SLIP-39 repeated backup + } } /** diff --git a/core/.changelog.d/3640.added b/core/.changelog.d/3640.added new file mode 100644 index 00000000000..ec047d5b0ae --- /dev/null +++ b/core/.changelog.d/3640.added @@ -0,0 +1 @@ +Added support for repeated backups. diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index 0512b15891a..4ff465cb352 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -369,7 +369,10 @@ static void _librust_qstrs(void) { MP_QSTR_recovery__title_dry_run; MP_QSTR_recovery__title_recover; MP_QSTR_recovery__title_remaining_shares; + MP_QSTR_recovery__title_unlock_repeated_backup; MP_QSTR_recovery__type_word_x_of_y_template; + MP_QSTR_recovery__unlock_repeated_backup; + MP_QSTR_recovery__unlock_repeated_backup_verb; MP_QSTR_recovery__wallet_recovered; MP_QSTR_recovery__wanna_cancel_dry_run; MP_QSTR_recovery__wanna_cancel_recovery; diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index 6f3f09a5320..5bc1909b59b 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -799,7 +799,7 @@ pub enum TranslatedString { recovery__num_of_words = 511, // "Select the number of words in your backup." recovery__only_first_n_letters = 512, // "You'll only have to select the first 2-4 letters of each word." recovery__progress_will_be_lost = 513, // "All progress will be lost." - recovery__select_num_of_words = 514, // "Select the number of words in your backup." + recovery__select_num_of_words = 514, // "\"\"" recovery__share_already_entered = 515, // "Share already entered" recovery__share_from_another_shamir = 516, // "You have entered a share from another Shamir Backup." recovery__share_num_template = 517, // "Share {0}" @@ -1237,8 +1237,11 @@ pub enum TranslatedString { storage_msg__starting = 842, // "STARTING UP" storage_msg__verifying_pin = 843, // "VERIFYING PIN" storage_msg__wrong_pin = 844, // "WRONG PIN" - reset__create_x_of_y_shamir_backup_template = 845, // "Do you want to create a {0} of {1} Shamir backup?" - reset__title_shamir_backup = 846, // "SHAMIR BACKUP" + recovery__title_unlock_repeated_backup = 845, // "UNLOCK BACKUP" + recovery__unlock_repeated_backup = 846, // "Do you want to unlock the backup?" + recovery__unlock_repeated_backup_verb = 847, // "Unlock backup" + reset__create_x_of_y_shamir_backup_template = 848, // "Do you want to create a {0} of {1} Shamir backup?" + reset__title_shamir_backup = 849, // "SHAMIR BACKUP" } impl TranslatedString { @@ -2033,7 +2036,7 @@ impl TranslatedString { Self::recovery__num_of_words => "Select the number of words in your backup.", Self::recovery__only_first_n_letters => "You'll only have to select the first 2-4 letters of each word.", Self::recovery__progress_will_be_lost => "All progress will be lost.", - Self::recovery__select_num_of_words => "Select the number of words in your backup.", + Self::recovery__select_num_of_words => "\"\"", Self::recovery__share_already_entered => "Share already entered", Self::recovery__share_from_another_shamir => "You have entered a share from another Shamir Backup.", Self::recovery__share_num_template => "Share {0}", @@ -2471,6 +2474,9 @@ impl TranslatedString { Self::storage_msg__starting => "STARTING UP", Self::storage_msg__verifying_pin => "VERIFYING PIN", Self::storage_msg__wrong_pin => "WRONG PIN", + Self::recovery__title_unlock_repeated_backup => "UNLOCK BACKUP", + Self::recovery__unlock_repeated_backup => "Do you want to unlock the backup?", + Self::recovery__unlock_repeated_backup_verb => "Unlock backup", Self::reset__create_x_of_y_shamir_backup_template => "Do you want to create a {0} of {1} Shamir backup?", Self::reset__title_shamir_backup => "SHAMIR BACKUP", } @@ -3706,6 +3712,9 @@ impl TranslatedString { Qstr::MP_QSTR_storage_msg__starting => Some(Self::storage_msg__starting), Qstr::MP_QSTR_storage_msg__verifying_pin => Some(Self::storage_msg__verifying_pin), Qstr::MP_QSTR_storage_msg__wrong_pin => Some(Self::storage_msg__wrong_pin), + Qstr::MP_QSTR_recovery__title_unlock_repeated_backup => Some(Self::recovery__title_unlock_repeated_backup), + Qstr::MP_QSTR_recovery__unlock_repeated_backup => Some(Self::recovery__unlock_repeated_backup), + Qstr::MP_QSTR_recovery__unlock_repeated_backup_verb => Some(Self::recovery__unlock_repeated_backup_verb), Qstr::MP_QSTR_reset__create_x_of_y_shamir_backup_template => Some(Self::reset__create_x_of_y_shamir_backup_template), Qstr::MP_QSTR_reset__title_shamir_backup => Some(Self::reset__title_shamir_backup), _ => None, diff --git a/core/embed/rust/src/ui/model_tt/layout.rs b/core/embed/rust/src/ui/model_tt/layout.rs index 2bcb1772898..9f5cec7c89e 100644 --- a/core/embed/rust/src/ui/model_tt/layout.rs +++ b/core/embed/rust/src/ui/model_tt/layout.rs @@ -1439,7 +1439,7 @@ extern "C" fn new_select_word_count(n_args: usize, args: *const Obj, kwargs: *mu let paragraphs = Paragraphs::new(Paragraph::new( &theme::TEXT_DEMIBOLD, - TR::recovery__select_num_of_words, + TR::recovery__num_of_words, )); let obj = LayoutObj::new(Frame::left_aligned( diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi index ee950bdbe28..cc9b034a7c3 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -525,7 +525,6 @@ class TR: recovery__num_of_words: str = "Select the number of words in your backup." recovery__only_first_n_letters: str = "You'll only have to select the first 2-4 letters of each word." recovery__progress_will_be_lost: str = "All progress will be lost." - recovery__select_num_of_words: str = "Select the number of words in your backup." recovery__share_already_entered: str = "Share already entered" recovery__share_from_another_shamir: str = "You have entered a share from another Shamir Backup." recovery__share_num_template: str = "Share {0}" @@ -535,7 +534,10 @@ class TR: recovery__title_dry_run: str = "BACKUP CHECK" recovery__title_recover: str = "RECOVER WALLET" recovery__title_remaining_shares: str = "REMAINING SHARES" + recovery__title_unlock_repeated_backup: str = "UNLOCK BACKUP" recovery__type_word_x_of_y_template: str = "Type word {0} of {1}" + recovery__unlock_repeated_backup: str = "Do you want to unlock the backup?" + recovery__unlock_repeated_backup_verb: str = "Unlock backup" recovery__wallet_recovered: str = "Wallet recovered successfully" recovery__wanna_cancel_dry_run: str = "Are you sure you want to cancel the backup check?" recovery__wanna_cancel_recovery: str = "Are you sure you want to cancel the recovery process?" diff --git a/core/src/all_modules.py b/core/src/all_modules.py index 571e7cda5e3..cd3b499a450 100644 --- a/core/src/all_modules.py +++ b/core/src/all_modules.py @@ -121,6 +121,8 @@ import trezor.enums.PinMatrixRequestType trezor.enums.RecoveryDeviceType import trezor.enums.RecoveryDeviceType +trezor.enums.RecoveryKind +import trezor.enums.RecoveryKind trezor.enums.RequestType import trezor.enums.RequestType trezor.enums.SafetyCheckLevel diff --git a/core/src/apps/management/backup_device.py b/core/src/apps/management/backup_device.py index 95a60eb9ec9..686c25c3bfc 100644 --- a/core/src/apps/management/backup_device.py +++ b/core/src/apps/management/backup_device.py @@ -10,6 +10,7 @@ async def backup_device(msg: BackupDevice) -> Success: + import storage.cache as storage_cache import storage.device as storage_device from trezor import wire from trezor.messages import Success @@ -18,9 +19,16 @@ async def backup_device(msg: BackupDevice) -> Success: from .reset_device import backup_seed, backup_slip39_custom, layout + # do this early before we show any UI + # the homescreen will clear the flag right after its own UI is gone + repeated_backup_unlocked = ( + storage_cache.get(storage_cache.APP_RECOVERY_REPEATED_BACKUP_UNLOCKED) + == b"\x01" + ) + if not storage_device.is_initialized(): raise wire.NotInitialized("Device is not initialized") - if not storage_device.needs_backup(): + if not storage_device.needs_backup() and not repeated_backup_unlocked: raise wire.ProcessError("Seed already backed up") mnemonic_secret, backup_type = mnemonic.get() @@ -40,7 +48,9 @@ async def backup_device(msg: BackupDevice) -> Success: elif len(groups) > 0: raise wire.DataError("group_threshold is missing") - storage_device.set_unfinished_backup(True) + if not repeated_backup_unlocked: + storage_device.set_unfinished_backup(True) + storage_device.set_backed_up() if group_threshold is not None: @@ -52,4 +62,6 @@ async def backup_device(msg: BackupDevice) -> Success: await layout.show_backup_success() + storage_cache.delete(storage_cache.APP_RECOVERY_REPEATED_BACKUP_UNLOCKED) + return Success(message="Seed successfully backed up") diff --git a/core/src/apps/management/recovery_device/__init__.py b/core/src/apps/management/recovery_device/__init__.py index a4f349ba8bf..0479cbd49e7 100644 --- a/core/src/apps/management/recovery_device/__init__.py +++ b/core/src/apps/management/recovery_device/__init__.py @@ -1,12 +1,14 @@ from typing import TYPE_CHECKING +from trezor.enums import RecoveryKind + if TYPE_CHECKING: from trezor.messages import RecoveryDevice, Success # List of RecoveryDevice fields that can be set when doing dry-run recovery. -# All except `dry_run` are allowed for T1 compatibility, but their values are ignored. +# All except `kind` are allowed for T1 compatibility, but their values are ignored. # If set, `enforce_wordlist` must be True, because we do not support non-enforcing. -DRY_RUN_ALLOWED_FIELDS = ("dry_run", "word_count", "enforce_wordlist", "type") +DRY_RUN_ALLOWED_FIELDS = ("kind", "word_count", "enforce_wordlist", "type") async def recovery_device(msg: RecoveryDevice) -> Success: @@ -31,68 +33,75 @@ async def recovery_device(msg: RecoveryDevice) -> Success: from .homescreen import recovery_homescreen, recovery_process - dry_run = msg.dry_run # local_cache_attribute + recovery_kind = msg.kind or RecoveryKind.NormalRecovery # local_cache_attribute # -------------------------------------------------------- # validate - if not dry_run and storage_device.is_initialized(): - raise wire.UnexpectedMessage("Already initialized") - if dry_run and not storage_device.is_initialized(): - raise wire.NotInitialized("Device is not initialized") + if recovery_kind == RecoveryKind.NormalRecovery: + if storage_device.is_initialized(): + raise wire.UnexpectedMessage("Already initialized") + elif recovery_kind in (RecoveryKind.DryRun, RecoveryKind.UnlockRepeatedBackup): + if not storage_device.is_initialized(): + raise wire.NotInitialized("Device is not initialized") + # check that only allowed fields are set + for key, value in msg.__dict__.items(): + if key not in DRY_RUN_ALLOWED_FIELDS and value is not None: + raise wire.ProcessError( + f"Forbidden field set in DryRun or UnlockRepeatedBackup: {key}" + ) + else: + raise ValueError("Unknown RecoveryKind") + if msg.enforce_wordlist is False: raise wire.ProcessError( "Value enforce_wordlist must be True, Trezor Core enforces words automatically." ) - if dry_run: - # check that only allowed fields are set - for key, value in msg.__dict__.items(): - if key not in DRY_RUN_ALLOWED_FIELDS and value is not None: - raise wire.ProcessError(f"Forbidden field set in dry-run: {key}") # END validate # -------------------------------------------------------- if storage_recovery.is_in_progress(): return await recovery_process() - # -------------------------------------------------------- - # _continue_dialog - if not dry_run: + if recovery_kind == RecoveryKind.NormalRecovery: await confirm_reset_device(TR.recovery__title_recover, recovery=True) - else: - await confirm_action( - "confirm_seedcheck", - TR.recovery__title_dry_run, - description=TR.recovery__check_dry_run, - br_code=ButtonRequestType.ProtectCall, - verb=TR.buttons__check, - ) - # END _continue_dialog - # -------------------------------------------------------- - if not dry_run: # wipe storage to make sure the device is in a clear state storage.reset() - # for dry run pin needs to be entered - if dry_run: - curpin, salt = await request_pin_and_sd_salt(TR.pin__enter) - if not config.check_pin(curpin, salt): - await error_pin_invalid() - - if not dry_run: # set up pin if requested if msg.pin_protection: newpin = await request_pin_confirm(allow_cancel=False) config.change_pin("", newpin, None, None) storage_device.set_passphrase_enabled(bool(msg.passphrase_protection)) + if msg.u2f_counter is not None: storage_device.set_u2f_counter(msg.u2f_counter) + if msg.label is not None: storage_device.set_label(msg.label) + elif recovery_kind in (RecoveryKind.DryRun, RecoveryKind.UnlockRepeatedBackup): + title = ( + TR.recovery__title_dry_run + if recovery_kind == RecoveryKind.DryRun + else TR.recovery__title_unlock_repeated_backup + ) + await confirm_action( + "confirm_seedcheck", + title, + description=TR.recovery__check_dry_run, + br_code=ButtonRequestType.ProtectCall, + verb=TR.buttons__check, + ) + + curpin, salt = await request_pin_and_sd_salt(TR.pin__enter) + if not config.check_pin(curpin, salt): + await error_pin_invalid() storage_recovery.set_in_progress(True) - storage_recovery.set_dry_run(bool(dry_run)) + + storage_recovery.set_kind(int(recovery_kind)) workflow.set_default(recovery_homescreen) + return await recovery_process() diff --git a/core/src/apps/management/recovery_device/homescreen.py b/core/src/apps/management/recovery_device/homescreen.py index 2691c6cbb05..7e1fcbf03e1 100644 --- a/core/src/apps/management/recovery_device/homescreen.py +++ b/core/src/apps/management/recovery_device/homescreen.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +import storage.cache as storage_cache import storage.device as storage_device import storage.recovery as storage_recovery import storage.recovery_shares as storage_recovery_shares @@ -21,32 +22,82 @@ async def recovery_homescreen() -> None: if not storage_recovery.is_in_progress(): workflow.set_default(homescreen) return - - await recovery_process() + elif ( + storage_cache.get(storage_cache.APP_RECOVERY_REPEATED_BACKUP_UNLOCKED) + == b"\x01" + ): + await _continue_repeated_backup() + else: + await recovery_process() async def recovery_process() -> Success: import storage from trezor.enums import MessageType - wire.AVOID_RESTARTING_FOR = (MessageType.Initialize, MessageType.GetFeatures) + is_special_kind = ( + storage_recovery.is_dry_run() or storage_recovery.is_unlock_repeated_backup() + ) + + wire.AVOID_RESTARTING_FOR = ( + MessageType.Initialize, + MessageType.GetFeatures, + MessageType.EndSession, + ) try: return await _continue_recovery_process() except recover.RecoveryAborted: - dry_run = storage_recovery.is_dry_run() - if dry_run: + if is_special_kind: storage_recovery.end_progress() else: storage.wipe() raise wire.ActionCancelled +async def _continue_repeated_backup() -> None: + from trezor import workflow + from trezor.enums import ButtonRequestType, MessageType + from trezor.ui.layouts import confirm_action + from trezor.wire import ActionCancelled + + from apps.common import mnemonic + from apps.homescreen import homescreen + from apps.management.reset_device import backup_seed + + wire.AVOID_RESTARTING_FOR = ( + MessageType.Initialize, + MessageType.GetFeatures, + MessageType.EndSession, + ) + + try: + await confirm_action( + "confirm_repeated_backup", + TR.recovery__title_unlock_repeated_backup, + description=TR.recovery__unlock_repeated_backup, + br_code=ButtonRequestType.ProtectCall, + verb=TR.recovery__unlock_repeated_backup_verb, + ) + + mnemonic_secret, backup_type = mnemonic.get() + if mnemonic_secret is None: + raise RuntimeError + + await backup_seed(backup_type, mnemonic_secret) + except ActionCancelled: + workflow.set_default(homescreen) + finally: + storage_cache.delete(storage_cache.APP_RECOVERY_REPEATED_BACKUP_UNLOCKED) + storage_recovery.end_progress() + + async def _continue_recovery_process() -> Success: from trezor import utils from trezor.errors import MnemonicError # gather the current recovery state from storage dry_run = storage_recovery.is_dry_run() + unlock_repeated_backup = storage_recovery.is_unlock_repeated_backup() word_count, backup_type = recover.load_slip39_state() # Both word_count and backup_type are derived from the same data. Both will be @@ -95,27 +146,25 @@ async def _continue_recovery_process() -> Success: assert backup_type is not None if dry_run: result = await _finish_recovery_dry_run(secret, backup_type) + elif unlock_repeated_backup: + result = await _finish_recovery_unlock_repeated_backup(secret, backup_type) else: result = await _finish_recovery(secret, backup_type) return result -async def _finish_recovery_dry_run(secret: bytes, backup_type: BackupType) -> Success: +def _check_secret_against_stored_secret(secret: bytes, is_slip39: bool) -> bool: from trezor import utils from trezor.crypto.hashlib import sha256 from apps.common import mnemonic - if backup_type is None: - raise RuntimeError - digest_input = sha256(secret).digest() stored = mnemonic.get_secret() digest_stored = sha256(stored).digest() result = utils.consteq(digest_stored, digest_input) - is_slip39 = backup_types.is_slip39_backup_type(backup_type) # Check that the identifier and iteration exponent match as well if is_slip39: result &= ( @@ -127,6 +176,17 @@ async def _finish_recovery_dry_run(secret: bytes, backup_type: BackupType) -> Su == storage_recovery.get_slip39_iteration_exponent() ) + return result + + +async def _finish_recovery_dry_run(secret: bytes, backup_type: BackupType) -> Success: + if backup_type is None: + raise RuntimeError + + is_slip39 = backup_types.is_slip39_backup_type(backup_type) + + result = _check_secret_against_stored_secret(secret, is_slip39) + storage_recovery.end_progress() await layout.show_dry_run_result(result, is_slip39) @@ -137,6 +197,25 @@ async def _finish_recovery_dry_run(secret: bytes, backup_type: BackupType) -> Su raise wire.ProcessError("The seed does not match the one in the device") +async def _finish_recovery_unlock_repeated_backup( + secret: bytes, backup_type: BackupType +) -> Success: + import storage.cache as storage_cache + + if backup_type is None: + raise RuntimeError + + is_slip39 = backup_types.is_slip39_backup_type(backup_type) + + result = _check_secret_against_stored_secret(secret, is_slip39) + + if result: + storage_cache.set(storage_cache.APP_RECOVERY_REPEATED_BACKUP_UNLOCKED, b"\x01") + return Success(message="Backup unlocked") + else: + raise wire.ProcessError("The seed does not match the one in the device") + + async def _finish_recovery(secret: bytes, backup_type: BackupType) -> Success: from trezor.enums import BackupType from trezor.ui.layouts import show_success diff --git a/core/src/storage/cache.py b/core/src/storage/cache.py index 1e1afdd845c..0989d765344 100644 --- a/core/src/storage/cache.py +++ b/core/src/storage/cache.py @@ -34,7 +34,7 @@ APP_COMMON_BUSY_DEADLINE_MS = const(4 | _SESSIONLESS_FLAG) APP_MISC_COSI_NONCE = const(5 | _SESSIONLESS_FLAG) APP_MISC_COSI_COMMITMENT = const(6 | _SESSIONLESS_FLAG) - +APP_RECOVERY_REPEATED_BACKUP_UNLOCKED = const(7 | _SESSIONLESS_FLAG) # === Homescreen storage === # This does not logically belong to the "cache" functionality, but the cache module is @@ -145,6 +145,7 @@ def __init__(self) -> None: 8, # APP_COMMON_BUSY_DEADLINE_MS 32, # APP_MISC_COSI_NONCE 32, # APP_MISC_COSI_COMMITMENT + 1, # APP_RECOVERY_REPEATED_BACKUP_UNLOCKED ) super().__init__() diff --git a/core/src/storage/recovery.py b/core/src/storage/recovery.py index 5e7324aa26c..fa1604c610a 100644 --- a/core/src/storage/recovery.py +++ b/core/src/storage/recovery.py @@ -1,6 +1,7 @@ from micropython import const from storage import common +from trezor.enums import RecoveryKind # Namespace: _NAMESPACE = common.APP_RECOVERY @@ -8,13 +9,14 @@ # fmt: off # Keys: _IN_PROGRESS = const(0x00) # bool -_DRY_RUN = const(0x01) # bool +_KIND = const(0x01) # int _SLIP39_IDENTIFIER = const(0x03) # bytes _REMAINING = const(0x05) # int _SLIP39_ITERATION_EXPONENT = const(0x06) # int _SLIP39_GROUP_COUNT = const(0x07) # int # Deprecated Keys: +# _DRY_RUN = const(0x01) # bool (got upgraded to int) # _WORD_COUNT = const(0x02) # int # _SLIP39_THRESHOLD = const(0x04) # int # fmt: on @@ -36,14 +38,19 @@ def is_in_progress() -> bool: return common.get_bool(_NAMESPACE, _IN_PROGRESS) -def set_dry_run(val: bool) -> None: +def set_kind(val: int) -> None: _require_progress() - common.set_bool(_NAMESPACE, _DRY_RUN, val) + common.set_uint8(_NAMESPACE, _KIND, val) def is_dry_run() -> bool: _require_progress() - return common.get_bool(_NAMESPACE, _DRY_RUN) + return common.get_uint8(_NAMESPACE, _KIND) == RecoveryKind.DryRun + + +def is_unlock_repeated_backup() -> bool: + _require_progress() + return common.get_uint8(_NAMESPACE, _KIND) == RecoveryKind.UnlockRepeatedBackup def set_slip39_identifier(identifier: int) -> None: @@ -128,7 +135,7 @@ def end_progress() -> None: _require_progress() for key in ( _IN_PROGRESS, - _DRY_RUN, + _KIND, _SLIP39_IDENTIFIER, _REMAINING, _SLIP39_ITERATION_EXPONENT, diff --git a/core/src/trezor/enums/RecoveryKind.py b/core/src/trezor/enums/RecoveryKind.py new file mode 100644 index 00000000000..8806a3063a8 --- /dev/null +++ b/core/src/trezor/enums/RecoveryKind.py @@ -0,0 +1,7 @@ +# Automatically generated by pb2py +# fmt: off +# isort:skip_file + +NormalRecovery = 0 +DryRun = 1 +UnlockRepeatedBackup = 2 diff --git a/core/src/trezor/enums/__init__.py b/core/src/trezor/enums/__init__.py index 3335f1b27d0..e74bd11a447 100644 --- a/core/src/trezor/enums/__init__.py +++ b/core/src/trezor/enums/__init__.py @@ -457,6 +457,11 @@ class RecoveryDeviceType(IntEnum): ScrambledWords = 0 Matrix = 1 + class RecoveryKind(IntEnum): + NormalRecovery = 0 + DryRun = 1 + UnlockRepeatedBackup = 2 + class WordRequestType(IntEnum): Plain = 0 Matrix9 = 1 diff --git a/core/src/trezor/messages.py b/core/src/trezor/messages.py index 0335bb53daf..7e4ff3bc168 100644 --- a/core/src/trezor/messages.py +++ b/core/src/trezor/messages.py @@ -53,6 +53,7 @@ def __getattr__(name: str) -> Any: from trezor.enums import OutputScriptType # noqa: F401 from trezor.enums import PinMatrixRequestType # noqa: F401 from trezor.enums import RecoveryDeviceType # noqa: F401 + from trezor.enums import RecoveryKind # noqa: F401 from trezor.enums import RequestType # noqa: F401 from trezor.enums import SafetyCheckLevel # noqa: F401 from trezor.enums import SdProtectOperationType # noqa: F401 @@ -2571,7 +2572,7 @@ class RecoveryDevice(protobuf.MessageType): enforce_wordlist: "bool | None" type: "RecoveryDeviceType | None" u2f_counter: "int | None" - dry_run: "bool | None" + kind: "RecoveryKind | None" def __init__( self, @@ -2583,7 +2584,7 @@ def __init__( enforce_wordlist: "bool | None" = None, type: "RecoveryDeviceType | None" = None, u2f_counter: "int | None" = None, - dry_run: "bool | None" = None, + kind: "RecoveryKind | None" = None, ) -> None: pass diff --git a/core/tools/translations/rules.json b/core/tools/translations/rules.json index 5f9511ef7e7..1ae4fac6d18 100644 --- a/core/tools/translations/rules.json +++ b/core/tools/translations/rules.json @@ -516,7 +516,6 @@ "recovery__num_of_words": "text,2", "recovery__only_first_n_letters": "text,4", "recovery__progress_will_be_lost": "text,2", - "recovery__select_num_of_words": "text,3", "recovery__share_already_entered": "text,2", "recovery__share_from_another_shamir": "text,3", "recovery__share_num_template": "text,1", diff --git a/core/translations/cs.json b/core/translations/cs.json index 36f4ded771f..25f13d50cbb 100644 --- a/core/translations/cs.json +++ b/core/translations/cs.json @@ -550,7 +550,6 @@ "recovery__num_of_words": "Vyberte počet slov v záloze.", "recovery__only_first_n_letters": "Musíte vybrat pouze první 2-4 písmena každého slova.", "recovery__progress_will_be_lost": "Veškerý postup bude ztracen.", - "recovery__select_num_of_words": "Vyberte počet slov v záloze.", "recovery__share_already_entered": "Část jste již zadali", "recovery__share_from_another_shamir": "Zadali jste část z jiné zálohy Shamir.", "recovery__share_num_template": "Část {0}", @@ -560,7 +559,10 @@ "recovery__title_dry_run": "KONTROLA ZÁLOHY", "recovery__title_recover": "OBNOVIT PENĚŽENKU", "recovery__title_remaining_shares": "ZBÝVAJÍCÍ ČÁSTI", + "recovery__title_unlock_repeated_backup": "UNLOCK BACKUP", "recovery__type_word_x_of_y_template": "Zadejte slovo {0} z {1}", + "recovery__unlock_repeated_backup": "Do you want to unlock the backup?", + "recovery__unlock_repeated_backup_verb": "Unlock backup", "recovery__wallet_recovered": "Peněženka obnovena", "recovery__wanna_cancel_dry_run": "Opravdu chcete zrušit kontrolu zálohy?", "recovery__wanna_cancel_recovery": "Opravdu chcete proces obnovy zrušit?", diff --git a/core/translations/de.json b/core/translations/de.json index e8595915ce4..7607ee4296b 100644 --- a/core/translations/de.json +++ b/core/translations/de.json @@ -550,7 +550,6 @@ "recovery__num_of_words": "Wörteranzahl in deinem Backup auswählen.", "recovery__only_first_n_letters": "Du musst nur die ersten 2-4 Buchstaben jedes Worts auswählen.", "recovery__progress_will_be_lost": "Alle Fortschritte gehen verloren.", - "recovery__select_num_of_words": "Wörteranzahl in deinem Backup auswählen.", "recovery__share_already_entered": "Share wurde bereits eingegeben", "recovery__share_from_another_shamir": "Share eines anderen Shamir-Backups eingegeben.", "recovery__share_num_template": "Share {0}", @@ -560,7 +559,10 @@ "recovery__title_dry_run": "BACKUP-ÜBERPRÜFUNG", "recovery__title_recover": "WALLET WIEDERHERST.", "recovery__title_remaining_shares": "VERBLEIBENDE SHARES", + "recovery__title_unlock_repeated_backup": "UNLOCK BACKUP", "recovery__type_word_x_of_y_template": "Tippe Wort {0} von {1}", + "recovery__unlock_repeated_backup": "Do you want to unlock the backup?", + "recovery__unlock_repeated_backup_verb": "Unlock backup", "recovery__wallet_recovered": "Wallet-Wiederherstell. erfolgreich", "recovery__wanna_cancel_dry_run": "Möchtest du die Backup-Überprüfung wirklich abbrechen?", "recovery__wanna_cancel_recovery": "Möchtest du die Wiederherstellung wirklich abbrechen?", diff --git a/core/translations/en.json b/core/translations/en.json index 4d266032f3a..9a3e2b94b57 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -527,7 +527,6 @@ "recovery__num_of_words": "Select the number of words in your backup.", "recovery__only_first_n_letters": "You'll only have to select the first 2-4 letters of each word.", "recovery__progress_will_be_lost": "All progress will be lost.", - "recovery__select_num_of_words": "Select the number of words in your backup.", "recovery__share_already_entered": "Share already entered", "recovery__share_from_another_shamir": "You have entered a share from another Shamir Backup.", "recovery__share_num_template": "Share {0}", @@ -537,7 +536,10 @@ "recovery__title_dry_run": "BACKUP CHECK", "recovery__title_recover": "RECOVER WALLET", "recovery__title_remaining_shares": "REMAINING SHARES", + "recovery__title_unlock_repeated_backup": "UNLOCK BACKUP", "recovery__type_word_x_of_y_template": "Type word {0} of {1}", + "recovery__unlock_repeated_backup": "Do you want to unlock the backup?", + "recovery__unlock_repeated_backup_verb": "Unlock backup", "recovery__wallet_recovered": "Wallet recovered successfully", "recovery__wanna_cancel_dry_run": "Are you sure you want to cancel the backup check?", "recovery__wanna_cancel_recovery": "Are you sure you want to cancel the recovery process?", diff --git a/core/translations/es.json b/core/translations/es.json index ce7b8a1f070..fa9820a7d0c 100644 --- a/core/translations/es.json +++ b/core/translations/es.json @@ -550,7 +550,6 @@ "recovery__num_of_words": "Elige el nro.de p. de la copia seg.", "recovery__only_first_n_letters": "Solo tendrás que seleccionar las primeras 2-4 letras de cada palabra.", "recovery__progress_will_be_lost": "Se perderá todo el progreso.", - "recovery__select_num_of_words": "Elige el nro.de p. de la copia seg.", "recovery__share_already_entered": "Ya se ha introducido el recurso compartido", "recovery__share_from_another_shamir": "El rec. comp. es de otra copia de seguridad de Shamir.", "recovery__share_num_template": "Recurso compartido {0}", @@ -560,7 +559,10 @@ "recovery__title_dry_run": "REVISAR C. SEG.", "recovery__title_recover": "RECUPERAR MONEDERO", "recovery__title_remaining_shares": "RESTO REC. COMP.", + "recovery__title_unlock_repeated_backup": "UNLOCK BACKUP", "recovery__type_word_x_of_y_template": "Meter palabra {0} de {1}", + "recovery__unlock_repeated_backup": "Do you want to unlock the backup?", + "recovery__unlock_repeated_backup_verb": "Unlock backup", "recovery__wallet_recovered": "Se ha recuperado el monedero.", "recovery__wanna_cancel_dry_run": "¿Quieres anular la revisión de la copia de seguridad?", "recovery__wanna_cancel_recovery": "¿Quieres anular el proceso de recuperación?", diff --git a/core/translations/fr.json b/core/translations/fr.json index 8ddb5880bde..a4651724504 100644 --- a/core/translations/fr.json +++ b/core/translations/fr.json @@ -550,7 +550,6 @@ "recovery__num_of_words": "Sélect. le nbre de mots dans votre sauv.", "recovery__only_first_n_letters": "Il vous suffit de sélectionner les 2 ou 4 premières lettres de chaque mot.", "recovery__progress_will_be_lost": "Toute progression sera perdue.", - "recovery__select_num_of_words": "Sélect. le nbre de mots dans votre sauv.", "recovery__share_already_entered": "Fragment déjà saisi", "recovery__share_from_another_shamir": "Vous avez saisi un fragm. d'une autre sauv. Shamir.", "recovery__share_num_template": "Fragment {0}", @@ -560,7 +559,10 @@ "recovery__title_dry_run": "VÉRIF. SAUVEGARDE", "recovery__title_recover": "RÉCUP. PORTEF.", "recovery__title_remaining_shares": "FRAGM. RESTANTS", + "recovery__title_unlock_repeated_backup": "UNLOCK BACKUP", "recovery__type_word_x_of_y_template": "Entrez le mot {0} sur {1}", + "recovery__unlock_repeated_backup": "Do you want to unlock the backup?", + "recovery__unlock_repeated_backup_verb": "Unlock backup", "recovery__wallet_recovered": "Portef. récupéré avec succès", "recovery__wanna_cancel_dry_run": "Voulez-vous vraiment annuler la vér. de la sauv. ?", "recovery__wanna_cancel_recovery": "Voulez-vous vraiment annuler le processus de récup. ?", diff --git a/core/translations/order.json b/core/translations/order.json index 39d74465d7d..dae7ae12fd4 100644 --- a/core/translations/order.json +++ b/core/translations/order.json @@ -844,6 +844,9 @@ "842": "storage_msg__starting", "843": "storage_msg__verifying_pin", "844": "storage_msg__wrong_pin", - "845": "reset__create_x_of_y_shamir_backup_template", - "846": "reset__title_shamir_backup" + "845": "recovery__title_unlock_repeated_backup", + "846": "recovery__unlock_repeated_backup", + "847": "recovery__unlock_repeated_backup_verb", + "848": "reset__create_x_of_y_shamir_backup_template", + "849": "reset__title_shamir_backup" } diff --git a/core/translations/signatures.json b/core/translations/signatures.json index 13b3fa203a3..693cdc6f104 100644 --- a/core/translations/signatures.json +++ b/core/translations/signatures.json @@ -1,8 +1,8 @@ { "current": { - "merkle_root": "fe1f2942f8723f3f65fe7972de93e1dcdd214cc14be8251c44b7047808c10e11", - "datetime": "2024-04-24T15:46:21.632186", - "commit": "c52db3a8deae36eb2becc96f4917ea7164656f76" + "merkle_root": "93ab728e926914ae45f6ac6d9395bf7d9e614d0ffed3dc71552a39ef589ffb2d", + "datetime": "2024-05-02T09:17:04.436087", + "commit": "08c98e3572f979ebe4a51ec528a0369be20f2d59" }, "history": [ { diff --git a/docs/common/message-workflows.md b/docs/common/message-workflows.md index 7a72f9be4f0..e0fbabc713f 100644 --- a/docs/common/message-workflows.md +++ b/docs/common/message-workflows.md @@ -279,14 +279,24 @@ entering PIN) or standard recovery (with entering the seed to the host computer one by one in random order). The process continues with optional check of the seed validity and optional setting up the PIN, which has to be confirmed. Finally the recovered wallet is saved into -device storage. The same process is used with the dry run recovery, the +device storage. + +The same process is used with the dry run recovery, the differences are that this process can be done only with already -initialized deviice and that the mnemonic is not saved into the device +initialized device and that the mnemonic is not saved into the device but it is only compared to the mnemonic already loaded into the device with the successful result (The seed is valid and matches the one in the -device) or unsuccessful result(The seed is valid but does not match the +device) or unsuccessful result (The seed is valid but does not match the one in the device). +A third kind of recovery is one that is done in order to +unlock a repeated backup. This is similar to the dry run recovery in that +the device needs to be already initialized and that the mnemonic entered +is compared against the one stored in the device. Once successful, +a special mode is activated, which allows an additional backup +to be performed. This is useful for upgrading SLIP39 backups +to multiple shares. + ## LoadDevice Load device lets user to load the device with the specific recovery diff --git a/legacy/firmware/fsm_msg_common.h b/legacy/firmware/fsm_msg_common.h index 5debd36ead8..b4ceb670431 100644 --- a/legacy/firmware/fsm_msg_common.h +++ b/legacy/firmware/fsm_msg_common.h @@ -491,7 +491,7 @@ void fsm_msgApplyFlags(const ApplyFlags *msg) { void fsm_msgRecoveryDevice(const RecoveryDevice *msg) { CHECK_PIN_UNCACHED - const bool dry_run = msg->has_dry_run ? msg->dry_run : false; + const bool dry_run = msg->has_kind ? msg->kind : false; if (!dry_run) { CHECK_NOT_INITIALIZED } else { @@ -499,7 +499,7 @@ void fsm_msgRecoveryDevice(const RecoveryDevice *msg) { CHECK_PARAM(!msg->has_passphrase_protection && !msg->has_pin_protection && !msg->has_language && !msg->has_label && !msg->has_u2f_counter, - _("Forbidden field set in dry-run")) + _("Forbidden field set in DryRun or UnlockRepeatedBackup")) } CHECK_PARAM(!msg->has_word_count || msg->word_count == 12 || diff --git a/python/src/trezorlib/cli/device.py b/python/src/trezorlib/cli/device.py index 1949749f64b..acc8c01b92b 100644 --- a/python/src/trezorlib/cli/device.py +++ b/python/src/trezorlib/cli/device.py @@ -151,6 +151,7 @@ def load( "-t", "--type", "rec_type", type=ChoiceType(RECOVERY_TYPE), default="scrambled" ) @click.option("-d", "--dry-run", is_flag=True) +@click.option("-b", "--unlock-repeated-backup", is_flag=True) @with_client def recover( client: "TrezorClient", @@ -162,6 +163,7 @@ def recover( u2f_counter: int, rec_type: messages.RecoveryDeviceType, dry_run: bool, + unlock_repeated_backup: bool, ) -> "MessageType": """Start safe recovery workflow.""" if rec_type == messages.RecoveryDeviceType.ScrambledWords: @@ -180,6 +182,7 @@ def recover( input_callback=input_callback, type=rec_type, dry_run=dry_run, + unlock_repeated_backup=unlock_repeated_backup, ) diff --git a/python/src/trezorlib/device.py b/python/src/trezorlib/device.py index bdebdc06b6d..edf08a0f8ce 100644 --- a/python/src/trezorlib/device.py +++ b/python/src/trezorlib/device.py @@ -159,6 +159,7 @@ def recover( input_callback: Optional[Callable] = None, type: messages.RecoveryDeviceType = messages.RecoveryDeviceType.ScrambledWords, dry_run: bool = False, + unlock_repeated_backup: bool = False, u2f_counter: Optional[int] = None, ) -> "MessageType": if language is not None: @@ -173,7 +174,7 @@ def recover( if word_count not in (12, 18, 24): raise ValueError("Invalid word count. Use 12/18/24") - if client.features.initialized and not dry_run: + if client.features.initialized and not (dry_run or unlock_repeated_backup): raise RuntimeError( "Device already initialized. Call device.wipe() and try again." ) @@ -181,11 +182,22 @@ def recover( if u2f_counter is None: u2f_counter = int(time.time()) + if not dry_run and not unlock_repeated_backup: + kind = messages.RecoveryKind.NormalRecovery + elif dry_run and not unlock_repeated_backup: + kind = messages.RecoveryKind.DryRun + elif unlock_repeated_backup and not dry_run: + kind = messages.RecoveryKind.UnlockRepeatedBackup + else: + raise RuntimeError( + "Only one of dry_run and unlock_repeated_backup can be requested at the same time." + ) + msg = messages.RecoveryDevice( - word_count=word_count, enforce_wordlist=True, type=type, dry_run=dry_run + word_count=word_count, enforce_wordlist=True, type=type, kind=kind ) - if not dry_run: + if kind == messages.RecoveryKind.NormalRecovery: # set additional parameters msg.passphrase_protection = passphrase_protection msg.pin_protection = pin_protection diff --git a/python/src/trezorlib/messages.py b/python/src/trezorlib/messages.py index b5f52bce1fc..ab638d1fea2 100644 --- a/python/src/trezorlib/messages.py +++ b/python/src/trezorlib/messages.py @@ -491,6 +491,12 @@ class RecoveryDeviceType(IntEnum): Matrix = 1 +class RecoveryKind(IntEnum): + NormalRecovery = 0 + DryRun = 1 + UnlockRepeatedBackup = 2 + + class WordRequestType(IntEnum): Plain = 0 Matrix9 = 1 @@ -3732,7 +3738,7 @@ class RecoveryDevice(protobuf.MessageType): 6: protobuf.Field("enforce_wordlist", "bool", repeated=False, required=False, default=None), 8: protobuf.Field("type", "RecoveryDeviceType", repeated=False, required=False, default=None), 9: protobuf.Field("u2f_counter", "uint32", repeated=False, required=False, default=None), - 10: protobuf.Field("dry_run", "bool", repeated=False, required=False, default=None), + 10: protobuf.Field("kind", "RecoveryKind", repeated=False, required=False, default=None), } def __init__( @@ -3746,7 +3752,7 @@ def __init__( enforce_wordlist: Optional["bool"] = None, type: Optional["RecoveryDeviceType"] = None, u2f_counter: Optional["int"] = None, - dry_run: Optional["bool"] = None, + kind: Optional["RecoveryKind"] = None, ) -> None: self.word_count = word_count self.passphrase_protection = passphrase_protection @@ -3756,7 +3762,7 @@ def __init__( self.enforce_wordlist = enforce_wordlist self.type = type self.u2f_counter = u2f_counter - self.dry_run = dry_run + self.kind = kind class WordRequest(protobuf.MessageType): diff --git a/rust/trezor-client/src/client/mod.rs b/rust/trezor-client/src/client/mod.rs index 052a2fa8ad6..56cbb3fd8c1 100644 --- a/rust/trezor-client/src/client/mod.rs +++ b/rust/trezor-client/src/client/mod.rs @@ -169,7 +169,11 @@ impl Trezor { req.set_pin_protection(pin_protection); req.set_label(label); req.set_enforce_wordlist(true); - req.set_dry_run(dry_run); + if dry_run { + req.set_kind(protos::recovery_device::RecoveryKind::RecoveryKind_DryRun); + } else { + req.set_kind(protos::recovery_device::RecoveryKind::RecoveryKind_NormalRecovery); + } req.set_type( protos::recovery_device::RecoveryDeviceType::RecoveryDeviceType_ScrambledWords, ); diff --git a/rust/trezor-client/src/protos/generated/messages_management.rs b/rust/trezor-client/src/protos/generated/messages_management.rs index 799111f5a8a..eccf9d0c22c 100644 --- a/rust/trezor-client/src/protos/generated/messages_management.rs +++ b/rust/trezor-client/src/protos/generated/messages_management.rs @@ -7682,8 +7682,8 @@ pub struct RecoveryDevice { pub type_: ::std::option::Option<::protobuf::EnumOrUnknown>, // @@protoc_insertion_point(field:hw.trezor.messages.management.RecoveryDevice.u2f_counter) pub u2f_counter: ::std::option::Option, - // @@protoc_insertion_point(field:hw.trezor.messages.management.RecoveryDevice.dry_run) - pub dry_run: ::std::option::Option, + // @@protoc_insertion_point(field:hw.trezor.messages.management.RecoveryDevice.kind) + pub kind: ::std::option::Option<::protobuf::EnumOrUnknown>, // special fields // @@protoc_insertion_point(special_field:hw.trezor.messages.management.RecoveryDevice.special_fields) pub special_fields: ::protobuf::SpecialFields, @@ -7889,23 +7889,26 @@ impl RecoveryDevice { self.u2f_counter = ::std::option::Option::Some(v); } - // optional bool dry_run = 10; + // optional .hw.trezor.messages.management.RecoveryDevice.RecoveryKind kind = 10; - pub fn dry_run(&self) -> bool { - self.dry_run.unwrap_or(false) + pub fn kind(&self) -> recovery_device::RecoveryKind { + match self.kind { + Some(e) => e.enum_value_or(recovery_device::RecoveryKind::RecoveryKind_NormalRecovery), + None => recovery_device::RecoveryKind::RecoveryKind_NormalRecovery, + } } - pub fn clear_dry_run(&mut self) { - self.dry_run = ::std::option::Option::None; + pub fn clear_kind(&mut self) { + self.kind = ::std::option::Option::None; } - pub fn has_dry_run(&self) -> bool { - self.dry_run.is_some() + pub fn has_kind(&self) -> bool { + self.kind.is_some() } // Param is passed by value, moved - pub fn set_dry_run(&mut self, v: bool) { - self.dry_run = ::std::option::Option::Some(v); + pub fn set_kind(&mut self, v: recovery_device::RecoveryKind) { + self.kind = ::std::option::Option::Some(::protobuf::EnumOrUnknown::new(v)); } fn generated_message_descriptor_data() -> ::protobuf::reflect::GeneratedMessageDescriptorData { @@ -7952,9 +7955,9 @@ impl RecoveryDevice { |m: &mut RecoveryDevice| { &mut m.u2f_counter }, )); fields.push(::protobuf::reflect::rt::v2::make_option_accessor::<_, _>( - "dry_run", - |m: &RecoveryDevice| { &m.dry_run }, - |m: &mut RecoveryDevice| { &mut m.dry_run }, + "kind", + |m: &RecoveryDevice| { &m.kind }, + |m: &mut RecoveryDevice| { &mut m.kind }, )); ::protobuf::reflect::GeneratedMessageDescriptorData::new_2::( "RecoveryDevice", @@ -7999,7 +8002,7 @@ impl ::protobuf::Message for RecoveryDevice { self.u2f_counter = ::std::option::Option::Some(is.read_uint32()?); }, 80 => { - self.dry_run = ::std::option::Option::Some(is.read_bool()?); + self.kind = ::std::option::Option::Some(is.read_enum_or_unknown()?); }, tag => { ::protobuf::rt::read_unknown_or_skip_group(tag, is, self.special_fields.mut_unknown_fields())?; @@ -8037,8 +8040,8 @@ impl ::protobuf::Message for RecoveryDevice { if let Some(v) = self.u2f_counter { my_size += ::protobuf::rt::uint32_size(9, v); } - if let Some(v) = self.dry_run { - my_size += 1 + 1; + if let Some(v) = self.kind { + my_size += ::protobuf::rt::int32_size(10, v.value()); } my_size += ::protobuf::rt::unknown_fields_size(self.special_fields.unknown_fields()); self.special_fields.cached_size().set(my_size as u32); @@ -8070,8 +8073,8 @@ impl ::protobuf::Message for RecoveryDevice { if let Some(v) = self.u2f_counter { os.write_uint32(9, v)?; } - if let Some(v) = self.dry_run { - os.write_bool(10, v)?; + if let Some(v) = self.kind { + os.write_enum(10, ::protobuf::EnumOrUnknown::value(&v))?; } os.write_unknown_fields(self.special_fields.unknown_fields())?; ::std::result::Result::Ok(()) @@ -8098,7 +8101,7 @@ impl ::protobuf::Message for RecoveryDevice { self.enforce_wordlist = ::std::option::Option::None; self.type_ = ::std::option::Option::None; self.u2f_counter = ::std::option::Option::None; - self.dry_run = ::std::option::Option::None; + self.kind = ::std::option::Option::None; self.special_fields.clear(); } @@ -8112,7 +8115,7 @@ impl ::protobuf::Message for RecoveryDevice { enforce_wordlist: ::std::option::Option::None, type_: ::std::option::Option::None, u2f_counter: ::std::option::Option::None, - dry_run: ::std::option::Option::None, + kind: ::std::option::Option::None, special_fields: ::protobuf::SpecialFields::new(), }; &instance @@ -8199,6 +8202,73 @@ pub mod recovery_device { ::protobuf::reflect::GeneratedEnumDescriptorData::new::("RecoveryDevice.RecoveryDeviceType") } } + + #[derive(Clone,Copy,PartialEq,Eq,Debug,Hash)] + // @@protoc_insertion_point(enum:hw.trezor.messages.management.RecoveryDevice.RecoveryKind) + pub enum RecoveryKind { + // @@protoc_insertion_point(enum_value:hw.trezor.messages.management.RecoveryDevice.RecoveryKind.RecoveryKind_NormalRecovery) + RecoveryKind_NormalRecovery = 0, + // @@protoc_insertion_point(enum_value:hw.trezor.messages.management.RecoveryDevice.RecoveryKind.RecoveryKind_DryRun) + RecoveryKind_DryRun = 1, + // @@protoc_insertion_point(enum_value:hw.trezor.messages.management.RecoveryDevice.RecoveryKind.RecoveryKind_UnlockRepeatedBackup) + RecoveryKind_UnlockRepeatedBackup = 2, + } + + impl ::protobuf::Enum for RecoveryKind { + const NAME: &'static str = "RecoveryKind"; + + fn value(&self) -> i32 { + *self as i32 + } + + fn from_i32(value: i32) -> ::std::option::Option { + match value { + 0 => ::std::option::Option::Some(RecoveryKind::RecoveryKind_NormalRecovery), + 1 => ::std::option::Option::Some(RecoveryKind::RecoveryKind_DryRun), + 2 => ::std::option::Option::Some(RecoveryKind::RecoveryKind_UnlockRepeatedBackup), + _ => ::std::option::Option::None + } + } + + fn from_str(str: &str) -> ::std::option::Option { + match str { + "RecoveryKind_NormalRecovery" => ::std::option::Option::Some(RecoveryKind::RecoveryKind_NormalRecovery), + "RecoveryKind_DryRun" => ::std::option::Option::Some(RecoveryKind::RecoveryKind_DryRun), + "RecoveryKind_UnlockRepeatedBackup" => ::std::option::Option::Some(RecoveryKind::RecoveryKind_UnlockRepeatedBackup), + _ => ::std::option::Option::None + } + } + + const VALUES: &'static [RecoveryKind] = &[ + RecoveryKind::RecoveryKind_NormalRecovery, + RecoveryKind::RecoveryKind_DryRun, + RecoveryKind::RecoveryKind_UnlockRepeatedBackup, + ]; + } + + impl ::protobuf::EnumFull for RecoveryKind { + fn enum_descriptor() -> ::protobuf::reflect::EnumDescriptor { + static descriptor: ::protobuf::rt::Lazy<::protobuf::reflect::EnumDescriptor> = ::protobuf::rt::Lazy::new(); + descriptor.get(|| super::file_descriptor().enum_by_package_relative_name("RecoveryDevice.RecoveryKind").unwrap()).clone() + } + + fn descriptor(&self) -> ::protobuf::reflect::EnumValueDescriptor { + let index = *self as usize; + Self::enum_descriptor().value_by_index(index) + } + } + + impl ::std::default::Default for RecoveryKind { + fn default() -> Self { + RecoveryKind::RecoveryKind_NormalRecovery + } + } + + impl RecoveryKind { + pub(in super) fn generated_enum_descriptor_data() -> ::protobuf::reflect::GeneratedEnumDescriptorData { + ::protobuf::reflect::GeneratedEnumDescriptorData::new::("RecoveryDevice.RecoveryKind") + } + } } // @@protoc_insertion_point(message:hw.trezor.messages.management.WordRequest) @@ -10713,7 +10783,7 @@ static file_descriptor_proto_data: &'static [u8] = b"\ ackupDevice.Slip39GroupR\x06groups\x1a[\n\x0bSlip39Group\x12)\n\x10membe\ r_threshold\x18\x01\x20\x02(\rR\x0fmemberThreshold\x12!\n\x0cmember_coun\ t\x18\x02\x20\x02(\rR\x0bmemberCount\"\x10\n\x0eEntropyRequest\"&\n\nEnt\ - ropyAck\x12\x18\n\x07entropy\x18\x01\x20\x02(\x0cR\x07entropy\"\xd8\x03\ + ropyAck\x12\x18\n\x07entropy\x18\x01\x20\x02(\x0cR\x07entropy\"\x80\x05\ \n\x0eRecoveryDevice\x12\x1d\n\nword_count\x18\x01\x20\x01(\rR\twordCoun\ t\x123\n\x15passphrase_protection\x18\x02\x20\x01(\x08R\x14passphrasePro\ tection\x12%\n\x0epin_protection\x18\x03\x20\x01(\x08R\rpinProtection\ @@ -10721,36 +10791,38 @@ static file_descriptor_proto_data: &'static [u8] = b"\ \x14\n\x05label\x18\x05\x20\x01(\tR\x05label\x12)\n\x10enforce_wordlist\ \x18\x06\x20\x01(\x08R\x0fenforceWordlist\x12T\n\x04type\x18\x08\x20\x01\ (\x0e2@.hw.trezor.messages.management.RecoveryDevice.RecoveryDeviceTypeR\ - \x04type\x12\x1f\n\x0bu2f_counter\x18\t\x20\x01(\rR\nu2fCounter\x12\x17\ - \n\x07dry_run\x18\n\x20\x01(\x08R\x06dryRun\"Z\n\x12RecoveryDeviceType\ - \x12%\n!RecoveryDeviceType_ScrambledWords\x10\0\x12\x1d\n\x19RecoveryDev\ - iceType_Matrix\x10\x01\"\xc5\x01\n\x0bWordRequest\x12N\n\x04type\x18\x01\ - \x20\x02(\x0e2:.hw.trezor.messages.management.WordRequest.WordRequestTyp\ - eR\x04type\"f\n\x0fWordRequestType\x12\x19\n\x15WordRequestType_Plain\ - \x10\0\x12\x1b\n\x17WordRequestType_Matrix9\x10\x01\x12\x1b\n\x17WordReq\ - uestType_Matrix6\x10\x02\"\x1d\n\x07WordAck\x12\x12\n\x04word\x18\x01\ - \x20\x02(\tR\x04word\"0\n\rSetU2FCounter\x12\x1f\n\x0bu2f_counter\x18\ - \x01\x20\x02(\rR\nu2fCounter\"\x13\n\x11GetNextU2FCounter\"1\n\x0eNextU2\ - FCounter\x12\x1f\n\x0bu2f_counter\x18\x01\x20\x02(\rR\nu2fCounter\"\x11\ - \n\x0fDoPreauthorized\"\x16\n\x14PreauthorizedRequest\"\x15\n\x13CancelA\ - uthorization\"\x9a\x02\n\x12RebootToBootloader\x12o\n\x0cboot_command\ - \x18\x01\x20\x01(\x0e2=.hw.trezor.messages.management.RebootToBootloader\ - .BootCommand:\rSTOP_AND_WAITR\x0bbootCommand\x12'\n\x0ffirmware_header\ - \x18\x02\x20\x01(\x0cR\x0efirmwareHeader\x123\n\x14language_data_length\ - \x18\x03\x20\x01(\r:\x010R\x12languageDataLength\"5\n\x0bBootCommand\x12\ - \x11\n\rSTOP_AND_WAIT\x10\0\x12\x13\n\x0fINSTALL_UPGRADE\x10\x01\"\x10\n\ - \x08GetNonce:\x04\x88\xb2\x19\x01\"#\n\x05Nonce\x12\x14\n\x05nonce\x18\ - \x01\x20\x02(\x0cR\x05nonce:\x04\x88\xb2\x19\x01\";\n\nUnlockPath\x12\ - \x1b\n\taddress_n\x18\x01\x20\x03(\rR\x08addressN\x12\x10\n\x03mac\x18\ - \x02\x20\x01(\x0cR\x03mac\"'\n\x13UnlockedPathRequest\x12\x10\n\x03mac\ - \x18\x01\x20\x01(\x0cR\x03mac\"\x14\n\x12ShowDeviceTutorial\"\x12\n\x10U\ - nlockBootloader*>\n\nBackupType\x12\t\n\x05Bip39\x10\0\x12\x10\n\x0cSlip\ - 39_Basic\x10\x01\x12\x13\n\x0fSlip39_Advanced\x10\x02*G\n\x10SafetyCheck\ - Level\x12\n\n\x06Strict\x10\0\x12\x10\n\x0cPromptAlways\x10\x01\x12\x15\ - \n\x11PromptTemporarily\x10\x02*0\n\x10HomescreenFormat\x12\x08\n\x04Toi\ - f\x10\x01\x12\x08\n\x04Jpeg\x10\x02\x12\x08\n\x04ToiG\x10\x03BB\n#com.sa\ - toshilabs.trezor.lib.protobufB\x17TrezorMessageManagement\x80\xa6\x1d\ - \x01\ + \x04type\x12\x1f\n\x0bu2f_counter\x18\t\x20\x01(\rR\nu2fCounter\x12N\n\ + \x04kind\x18\n\x20\x01(\x0e2:.hw.trezor.messages.management.RecoveryDevi\ + ce.RecoveryKindR\x04kind\"Z\n\x12RecoveryDeviceType\x12%\n!RecoveryDevic\ + eType_ScrambledWords\x10\0\x12\x1d\n\x19RecoveryDeviceType_Matrix\x10\ + \x01\"o\n\x0cRecoveryKind\x12\x1f\n\x1bRecoveryKind_NormalRecovery\x10\0\ + \x12\x17\n\x13RecoveryKind_DryRun\x10\x01\x12%\n!RecoveryKind_UnlockRepe\ + atedBackup\x10\x02\"\xc5\x01\n\x0bWordRequest\x12N\n\x04type\x18\x01\x20\ + \x02(\x0e2:.hw.trezor.messages.management.WordRequest.WordRequestTypeR\ + \x04type\"f\n\x0fWordRequestType\x12\x19\n\x15WordRequestType_Plain\x10\ + \0\x12\x1b\n\x17WordRequestType_Matrix9\x10\x01\x12\x1b\n\x17WordRequest\ + Type_Matrix6\x10\x02\"\x1d\n\x07WordAck\x12\x12\n\x04word\x18\x01\x20\ + \x02(\tR\x04word\"0\n\rSetU2FCounter\x12\x1f\n\x0bu2f_counter\x18\x01\ + \x20\x02(\rR\nu2fCounter\"\x13\n\x11GetNextU2FCounter\"1\n\x0eNextU2FCou\ + nter\x12\x1f\n\x0bu2f_counter\x18\x01\x20\x02(\rR\nu2fCounter\"\x11\n\ + \x0fDoPreauthorized\"\x16\n\x14PreauthorizedRequest\"\x15\n\x13CancelAut\ + horization\"\x9a\x02\n\x12RebootToBootloader\x12o\n\x0cboot_command\x18\ + \x01\x20\x01(\x0e2=.hw.trezor.messages.management.RebootToBootloader.Boo\ + tCommand:\rSTOP_AND_WAITR\x0bbootCommand\x12'\n\x0ffirmware_header\x18\ + \x02\x20\x01(\x0cR\x0efirmwareHeader\x123\n\x14language_data_length\x18\ + \x03\x20\x01(\r:\x010R\x12languageDataLength\"5\n\x0bBootCommand\x12\x11\ + \n\rSTOP_AND_WAIT\x10\0\x12\x13\n\x0fINSTALL_UPGRADE\x10\x01\"\x10\n\x08\ + GetNonce:\x04\x88\xb2\x19\x01\"#\n\x05Nonce\x12\x14\n\x05nonce\x18\x01\ + \x20\x02(\x0cR\x05nonce:\x04\x88\xb2\x19\x01\";\n\nUnlockPath\x12\x1b\n\ + \taddress_n\x18\x01\x20\x03(\rR\x08addressN\x12\x10\n\x03mac\x18\x02\x20\ + \x01(\x0cR\x03mac\"'\n\x13UnlockedPathRequest\x12\x10\n\x03mac\x18\x01\ + \x20\x01(\x0cR\x03mac\"\x14\n\x12ShowDeviceTutorial\"\x12\n\x10UnlockBoo\ + tloader*>\n\nBackupType\x12\t\n\x05Bip39\x10\0\x12\x10\n\x0cSlip39_Basic\ + \x10\x01\x12\x13\n\x0fSlip39_Advanced\x10\x02*G\n\x10SafetyCheckLevel\ + \x12\n\n\x06Strict\x10\0\x12\x10\n\x0cPromptAlways\x10\x01\x12\x15\n\x11\ + PromptTemporarily\x10\x02*0\n\x10HomescreenFormat\x12\x08\n\x04Toif\x10\ + \x01\x12\x08\n\x04Jpeg\x10\x02\x12\x08\n\x04ToiG\x10\x03BB\n#com.satoshi\ + labs.trezor.lib.protobufB\x17TrezorMessageManagement\x80\xa6\x1d\x01\ "; /// `FileDescriptorProto` object which was a source for this generated file @@ -10815,13 +10887,14 @@ pub fn file_descriptor() -> &'static ::protobuf::reflect::FileDescriptor { messages.push(ShowDeviceTutorial::generated_message_descriptor_data()); messages.push(UnlockBootloader::generated_message_descriptor_data()); messages.push(backup_device::Slip39Group::generated_message_descriptor_data()); - let mut enums = ::std::vec::Vec::with_capacity(8); + let mut enums = ::std::vec::Vec::with_capacity(9); enums.push(BackupType::generated_enum_descriptor_data()); enums.push(SafetyCheckLevel::generated_enum_descriptor_data()); enums.push(HomescreenFormat::generated_enum_descriptor_data()); enums.push(features::Capability::generated_enum_descriptor_data()); enums.push(sd_protect::SdProtectOperationType::generated_enum_descriptor_data()); enums.push(recovery_device::RecoveryDeviceType::generated_enum_descriptor_data()); + enums.push(recovery_device::RecoveryKind::generated_enum_descriptor_data()); enums.push(word_request::WordRequestType::generated_enum_descriptor_data()); enums.push(reboot_to_bootloader::BootCommand::generated_enum_descriptor_data()); ::protobuf::reflect::GeneratedFileDescriptor::new_generated( diff --git a/tests/click_tests/recovery.py b/tests/click_tests/recovery.py index c6def77f272..981e62e368a 100644 --- a/tests/click_tests/recovery.py +++ b/tests/click_tests/recovery.py @@ -46,9 +46,9 @@ def enter_word( raise ValueError("Unknown model") -def confirm_recovery(debug: "DebugLink") -> None: +def confirm_recovery(debug: "DebugLink", title="recovery__title") -> None: layout = debug.wait_layout() - TR.assert_equals(layout.title(), "recovery__title") + TR.assert_equals(layout.title(), title) if debug.model in (models.T2T1, models.T3T1): debug.click(buttons.OK, wait=True) elif debug.model in (models.T2B1,): @@ -109,7 +109,11 @@ def enter_share( return layout -def enter_shares(debug: "DebugLink", shares: list[str]) -> None: +def enter_shares( + debug: "DebugLink", + shares: list[str], + after_layout_text="recovery__wallet_recovered", +) -> None: TR.assert_in(debug.read_layout().text_content(), "recovery__enter_any_share") for index, share in enumerate(shares): enter_share(debug, share, is_first=index == 0) @@ -120,16 +124,22 @@ def enter_shares(debug: "DebugLink", shares: list[str]) -> None: template=(index + 1, len(shares)), ) - TR.assert_in(debug.read_layout().text_content(), "recovery__wallet_recovered") + TR.assert_in(debug.read_layout().text_content(), after_layout_text) -def enter_seed(debug: "DebugLink", seed_words: list[str]) -> None: - prepare_enter_seed(debug) +def enter_seed( + debug: "DebugLink", + seed_words: list[str], + is_slip39=False, + prepare_layout_text="recovery__enter_backup", + after_layout_text="recovery__wallet_recovered", +) -> None: + prepare_enter_seed(debug, prepare_layout_text) for word in seed_words: - enter_word(debug, word, is_slip39=False) + enter_word(debug, word, is_slip39=is_slip39) - TR.assert_in(debug.read_layout().text_content(), "recovery__wallet_recovered") + TR.assert_in(debug.read_layout().text_content(), after_layout_text) def enter_seed_previous_correct( @@ -177,8 +187,10 @@ def enter_seed_previous_correct( TR.assert_in(debug.read_layout().text_content(), "recovery__wallet_recovered") -def prepare_enter_seed(debug: "DebugLink") -> None: - TR.assert_in(debug.read_layout().text_content(), "recovery__enter_backup") +def prepare_enter_seed( + debug: "DebugLink", layout_text="recovery__enter_backup" +) -> None: + TR.assert_in(debug.read_layout().text_content(), layout_text) if debug.model in (models.T2T1, models.T3T1): debug.click(buttons.OK, wait=True) elif debug.model in (models.T2B1,): diff --git a/tests/click_tests/reset.py b/tests/click_tests/reset.py index 3650b880915..1fd29413ba9 100644 --- a/tests/click_tests/reset.py +++ b/tests/click_tests/reset.py @@ -3,7 +3,7 @@ from shamir_mnemonic import shamir # type: ignore -from trezorlib import messages, models +from trezorlib import models from .. import buttons from .. import translations as TR @@ -68,9 +68,7 @@ def set_selection(debug: "DebugLink", button: tuple[int, int], diff: int) -> Non debug.press_middle(wait=True) -def read_words( - debug: "DebugLink", backup_type: messages.BackupType, do_htc: bool = True -) -> list[str]: +def read_words(debug: "DebugLink", do_htc: bool = True) -> list[str]: words: list[str] = [] if debug.model in (models.T2B1,): diff --git a/tests/click_tests/test_backup_slip39_custom.py b/tests/click_tests/test_backup_slip39_custom.py index 8779865812d..dbbd3fc62f9 100644 --- a/tests/click_tests/test_backup_slip39_custom.py +++ b/tests/click_tests/test_backup_slip39_custom.py @@ -85,7 +85,7 @@ def test_backup_slip39_custom( all_words: list[str] = [] for _ in range(share_count): # read words - words = reset.read_words(debug, messages.BackupType.Slip39_Basic) + words = reset.read_words(debug) # confirm words reset.confirm_words(debug, words) diff --git a/tests/click_tests/test_repeated_backup.py b/tests/click_tests/test_repeated_backup.py new file mode 100644 index 00000000000..ed436d28988 --- /dev/null +++ b/tests/click_tests/test_repeated_backup.py @@ -0,0 +1,208 @@ +# This file is part of the Trezor project. +# +# Copyright (C) 2012-2024 SatoshiLabs and contributors +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + +from typing import TYPE_CHECKING + +import pytest + +from trezorlib import device, messages + +from .. import buttons +from ..common import WITH_MOCK_URANDOM +from . import recovery, reset +from .common import go_next + +if TYPE_CHECKING: + from ..device_handler import BackgroundDeviceHandler + + +pytestmark = [pytest.mark.skip_t1b1] + + +@pytest.mark.setup_client(uninitialized=True) +@WITH_MOCK_URANDOM +def test_repeated_backup( + device_handler: "BackgroundDeviceHandler", +): + features = device_handler.features() + debug = device_handler.debuglink() + + assert features.initialized is False + + device_handler.run( + device.reset, + strength=128, + backup_type=messages.BackupType.Slip39_Basic, + pin_protection=False, + ) + + # confirm new wallet + reset.confirm_new_wallet(debug) + + # confirm back up + reset.confirm_read(debug) + + # confirm backup warning + reset.confirm_read(debug, middle_r=True) + + # let's make a 1-of-1 backup to start with... + + # shares=1 + reset.set_selection(debug, buttons.RESET_MINUS, 5 - 1) + + # confirm checklist + reset.confirm_read(debug) + + # threshold=1 + reset.set_selection(debug, buttons.RESET_PLUS, 0) + + # confirm checklist + reset.confirm_read(debug) + + # confirm backup warning + reset.confirm_read(debug, middle_r=True) + + # read words + initial_backup_1_of_1 = reset.read_words(debug) + + # confirm words + reset.confirm_words(debug, initial_backup_1_of_1) + + # confirm share checked + reset.confirm_read(debug) + + # confirm backup done + reset.confirm_read(debug) + + # Your backup is done + go_next(debug) + + # great ... device is initialized, backup done, and we are not in recovery mode! + assert device_handler.result() == "Initialized" + features = device_handler.features() + assert features.backup_type is messages.BackupType.Slip39_Basic + assert features.initialized is True + assert features.needs_backup is False + assert features.no_backup is False + assert features.recovery_mode is False + + # run recovery to unlock backup + device_handler.run( + device.recover, + unlock_repeated_backup=True, + ) + + recovery.confirm_recovery(debug, "recovery__title_unlock_repeated_backup") + + recovery.select_number_of_words(debug, num_of_words=20) + recovery.enter_seed( + debug, + initial_backup_1_of_1, + True, + "recovery__enter_any_share", + "recovery__unlock_repeated_backup", + ) + + # backup is enabled + go_next(debug) + + assert device_handler.result().message == "Backup unlocked" + + # we are now in recovery mode + features = device_handler.features() + assert features.backup_type is messages.BackupType.Slip39_Basic + assert features.initialized is True + assert features.needs_backup is False + assert features.no_backup is False + assert features.recovery_mode is True + + # at this point, the backup is unlocked... + + # ... so let's try to do a 2-of-3 backup + + # confirm checklist + reset.confirm_read(debug) + + # shares=3 + reset.set_selection(debug, buttons.RESET_MINUS, 5 - 3) + + # confirm checklist + reset.confirm_read(debug) + + # threshold=2 + reset.set_selection(debug, buttons.RESET_MINUS, 1) + + # confirm checklist + reset.confirm_read(debug) + + # confirm backup warning + reset.confirm_read(debug, middle_r=True) + + second_backup_2_of_3: list[str] = [] + for _ in range(3): + # read words + words = reset.read_words(debug, do_htc=False) + + # confirm words + reset.confirm_words(debug, words) + + # confirm share checked + reset.confirm_read(debug) + + second_backup_2_of_3.append(" ".join(words)) + + # we are not in recovery mode anymore, because we finished the backup process! + features = device_handler.features() + assert features.backup_type is messages.BackupType.Slip39_Basic + assert features.initialized is True + assert features.needs_backup is False + assert features.no_backup is False + assert features.recovery_mode is False + + # try to unlock backup again... + device_handler.run( + device.recover, + unlock_repeated_backup=True, + ) + + recovery.confirm_recovery(debug, "recovery__title_unlock_repeated_backup") + + # ... this time with the 2 shares from the *new* backup, which was a 2-of-3! + recovery.select_number_of_words(debug, num_of_words=20) + recovery.enter_shares( + debug, second_backup_2_of_3[-2:], "recovery__unlock_repeated_backup" + ) + + assert device_handler.result().message == "Backup unlocked" + + # we are now in recovery mode again! + features = device_handler.features() + assert features.backup_type is messages.BackupType.Slip39_Basic + assert features.initialized is True + assert features.needs_backup is False + assert features.no_backup is False + assert features.recovery_mode is True + + # but if we cancel the backup at this point... + reset.cancel_backup(debug) + + # ...we are out of recovery mode! + features = device_handler.features() + assert features.backup_type is messages.BackupType.Slip39_Basic + assert features.initialized is True + assert features.needs_backup is False + assert features.no_backup is False + assert features.recovery_mode is False diff --git a/tests/click_tests/test_reset_bip39.py b/tests/click_tests/test_reset_bip39.py index 9433efcc6ec..9e990ee9a95 100644 --- a/tests/click_tests/test_reset_bip39.py +++ b/tests/click_tests/test_reset_bip39.py @@ -56,7 +56,7 @@ def test_reset_bip39(device_handler: "BackgroundDeviceHandler"): reset.confirm_read(debug, middle_r=True) # read words - words = reset.read_words(debug, messages.BackupType.Bip39) + words = reset.read_words(debug) # confirm words reset.confirm_words(debug, words) diff --git a/tests/click_tests/test_reset_slip39_advanced.py b/tests/click_tests/test_reset_slip39_advanced.py index 12500dc9338..19ce87db9bf 100644 --- a/tests/click_tests/test_reset_slip39_advanced.py +++ b/tests/click_tests/test_reset_slip39_advanced.py @@ -112,9 +112,7 @@ def test_reset_slip39_advanced( for _ in range(group_count): for _ in range(share_count): # read words - words = reset.read_words( - debug, messages.BackupType.Slip39_Advanced, do_htc=False - ) + words = reset.read_words(debug, do_htc=False) # confirm words reset.confirm_words(debug, words) diff --git a/tests/click_tests/test_reset_slip39_basic.py b/tests/click_tests/test_reset_slip39_basic.py index cdb7f56544b..8ca9605dadc 100644 --- a/tests/click_tests/test_reset_slip39_basic.py +++ b/tests/click_tests/test_reset_slip39_basic.py @@ -91,7 +91,7 @@ def test_reset_slip39_basic( all_words: list[str] = [] for _ in range(num_of_shares): # read words - words = reset.read_words(debug, messages.BackupType.Slip39_Basic) + words = reset.read_words(debug) # confirm words reset.confirm_words(debug, words) 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 5e6494a7669..cb5c36de047 100644 --- a/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py +++ b/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py @@ -105,7 +105,7 @@ def test_uninitialized(client: Client): DRY_RUN_ALLOWED_FIELDS = ( - "dry_run", + "kind", "word_count", "enforce_wordlist", "type", @@ -131,6 +131,8 @@ def _make_bad_params(): yield field.name, True elif field.type == "string": yield field.name, "test" + elif field.type == "RecoveryKind": + yield field.name, 1 else: # Someone added a field to RecoveryDevice of a type that has no assigned # default value. This test must be fixed. @@ -140,13 +142,14 @@ def _make_bad_params(): @pytest.mark.parametrize("field_name, field_value", _make_bad_params()) def test_bad_parameters(client: Client, field_name: str, field_value: Any): msg = messages.RecoveryDevice( - dry_run=True, + kind=messages.RecoveryKind.DryRun, word_count=12, enforce_wordlist=True, type=messages.RecoveryDeviceType.ScrambledWords, ) setattr(msg, field_name, field_value) with pytest.raises( - exceptions.TrezorFailure, match="Forbidden field set in dry-run" + exceptions.TrezorFailure, + match="Forbidden field set in DryRun or UnlockRepeatedBackup", ): client.call(msg) diff --git a/tests/device_tests/test_repeated_backup.py b/tests/device_tests/test_repeated_backup.py new file mode 100644 index 00000000000..20112638e56 --- /dev/null +++ b/tests/device_tests/test_repeated_backup.py @@ -0,0 +1,63 @@ +# This file is part of the Trezor project. +# +# Copyright (C) 2012-2024 SatoshiLabs and contributors +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the License along with this library. +# If not, see . + + +import pytest + +from trezorlib import device, messages +from trezorlib.debuglink import TrezorClientDebugLink as Client +from trezorlib.exceptions import TrezorFailure + +from ..common import WITH_MOCK_URANDOM, MNEMONIC_SLIP39_BASIC_20_3of6 +from ..input_flows import InputFlowSlip39BasicBackup, InputFlowSlip39BasicRecoveryDryRun + + +@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_BASIC_20_3of6) +@pytest.mark.skip_t1b1 +@WITH_MOCK_URANDOM +def test_repeated_backup(client: Client): + assert client.features.needs_backup is True + + # initial device backup + mnemonics = [] + with client: + IF = InputFlowSlip39BasicBackup(client, False) + client.set_input_flow(IF.get()) + device.backup(client) + mnemonics = IF.mnemonics + + assert len(mnemonics) == 5 + + # cannot backup, since we already just did that! + with pytest.raises(TrezorFailure, match=r".*Seed already backed up"): + device.backup(client) + + # unlock repeated backup by entering 3 of the 5 shares we have got + with client: + IF = InputFlowSlip39BasicRecoveryDryRun(client, mnemonics[:3]) + client.set_input_flow(IF.get()) + ret = device.recover(client, unlock_repeated_backup=True) + assert ret == messages.Success(message="Backup unlocked") + + # we can now perform another backup + with client: + IF = InputFlowSlip39BasicBackup(client, False) + client.set_input_flow(IF.get()) + device.backup(client) + + # the backup feature is locked again... + with pytest.raises(TrezorFailure, match=r".*Seed already backed up"): + device.backup(client) diff --git a/tests/ui_tests/fixtures.json b/tests/ui_tests/fixtures.json index 981b37cb140..15de895144d 100644 --- a/tests/ui_tests/fixtures.json +++ b/tests/ui_tests/fixtures.json @@ -876,6 +876,7 @@ "T3T1_en_test_recovery.py::test_recovery_bip39": "62947c5512e7ba33f77af5d67d2063918dcccea5d5530db89f04bffa0c309ae6", "T3T1_en_test_recovery.py::test_recovery_bip39_previous_word": "35c67f93da34ba0f3c72a09076391bbc1ef24661d7ff8102f0394ee50250b29b", "T3T1_en_test_recovery.py::test_recovery_slip39_basic": "a9f1ab4971b614ec67fdaa9602f72ba5eebe54204b3735f8aa2b91ddeee41a19", +"T3T1_en_test_repeated_backup.py::test_repeated_backup": "6cefadb1d5a1694763f5700ea4ed5956274ea2e3ce1f12a411ce22ce4a479892", "T3T1_en_test_reset_bip39.py::test_reset_bip39": "beab50474b19fedaf4487c64071529e4217288942e4695ed795632559835b20b", "T3T1_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[16of16]": "ef7f63ddb82bcd7ca92d739865cf466741b531b86946b1524b8440382823a56a", "T3T1_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[2of2]": "5ac9a51693538d546a37d7c6c8c3be260849bdccd84d4f92a2c48ac036914100", @@ -5172,6 +5173,7 @@ "T3T1_en_test_protection_levels.py::test_unlocked": "835c5327c35866df9acdf1433e75775eb652956b34ab6123595a06764389f61e", "T3T1_en_test_protection_levels.py::test_verify_message_t2": "456368c2e05ef1a743baa467897ddcad4af06b9cc863b809740d145a66dd88cd", "T3T1_en_test_protection_levels.py::test_wipe_device": "79922a132a2567d0a1b9b296b95fe7d1756e8af75681d9a0386fd104c0addf8c", +"T3T1_en_test_repeated_backup.py::test_repeated_backup": "d19e048726ad8c4bc0963dddcb0560c2c3ecb085cc05670f9053a5bb6f9c6b51", "T3T1_en_test_sdcard.py::test_sd_format": "eb00c8c6d53fb2cbb1020f053219babe32ef3accb8ccefa8dbe315c03fb19149", "T3T1_en_test_sdcard.py::test_sd_no_format": "14511e3d3ee535d97287d8ade25101e8c16db17c1dc5d3151b91e5e8eba61ba5", "T3T1_en_test_sdcard.py::test_sd_protect_unlock": "097106b59798ff84733f7c6c4855cd52fbc3c0923e91aa84c0f81ac3ebc651b2", @@ -15292,6 +15294,7 @@ "T2T1_en_test_recovery.py::test_recovery_bip39": "65a138f634806c6483c55c6ce5365b8a7a4073a3c0c340b1826042262faa8545", "T2T1_en_test_recovery.py::test_recovery_bip39_previous_word": "a009899ccd3305cb6737c8fa645cc9eedf4e46d6669a621a07d8cd9447d80f2f", "T2T1_en_test_recovery.py::test_recovery_slip39_basic": "9b0f5a7b8d2ab0fed1e5389076bc035e24dce377d275824220f1aa61e9bb4810", +"T2T1_en_test_repeated_backup.py::test_repeated_backup": "690bff435eb5ff9dc53a2ce0268d5e2790a18181c6e9d8d50c57b01f63aa8d68", "T2T1_en_test_reset_bip39.py::test_reset_bip39": "1feb3fcae2c593ea9193bcab23d8e64accd6fdbd2b05e2d2403ff89f8f94e4d8", "T2T1_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[16of16]": "6dc6b1ed736a1073c05067f37b817bf139e832a41db7d7804622d4a452db7b2a", "T2T1_en_test_reset_slip39_advanced.py::test_reset_slip39_advanced[2of2]": "50b553055335d639728e075c6757ae8cecea4887a0508876ce36501f81fcf7dc", @@ -19714,6 +19717,7 @@ "T2T1_en_test_protection_levels.py::test_unlocked": "4488e2b6f06fdff05749ac271d080182f1c95645de37898457ff12f0fb190381", "T2T1_en_test_protection_levels.py::test_verify_message_t2": "cc09f0acf9e48b1355dae0be65a2a97abbe2811808feee08d46ae7146ce6bb6f", "T2T1_en_test_protection_levels.py::test_wipe_device": "7ff017de646b7cf70832605e1750c635d0eb661d51534b56007b49e82b927011", +"T2T1_en_test_repeated_backup.py::test_repeated_backup": "729f3e12bf5f942ed2df44b4cef88dbfbe5c5eb8bf944ae88b3da458d4ce527e", "T2T1_en_test_sdcard.py::test_sd_format": "83d0d9b4eab3bf0eafc22d7d95e8b70bce477ba9c8b4ba13eeca9380ad5fdafa", "T2T1_en_test_sdcard.py::test_sd_no_format": "14511e3d3ee535d97287d8ade25101e8c16db17c1dc5d3151b91e5e8eba61ba5", "T2T1_en_test_sdcard.py::test_sd_protect_unlock": "1302f9a0835cac621142a17031d2150553e676261a3eeeefd32fcf1e69c7bd1a",