feat(core): add N4W1 backup/recovery flows
What changed, and why it matters
This commit adds a new backup and recovery method called N4W1 to Trezor firmware. It is a feature addition that lets users back up and restore their wallet seed using a near-field wireless tag instead of typing words on the device screen. The change is mostly about adding new code paths and tests; it does not appear to fix a known security bug, nor does the vendor describe it as a security patch.
Treat as a normal feature commit. If reviewing for security, focus on the N4W1 I/O path: verify that tag read/write is authenticated, that the mnemonic blob is encrypted in transit, that the device confirms tag presence before writing, and that the debug mock cannot be enabled in production builds. No immediate patching is indicated by this commit alone.
Security signals we found
New backup/recovery surface introduced (N4W1 tag I/O)
Conditional compilation/build flag `USE_N4W1` / `N4W1` added to CI
Debug-link mock used for N4W1 I/O in tests (`apps.debug.n4w1_mock`)
Existing SLIP-39/BIP39 validation reused for N4W1 recovery shares
No vendor statement of security relevance or CVE in commit message
No independent researcher attribution in commit or vendor material
Evidence from the diff
The commit introduces N4W1 (near-field wireless) backup/recovery flows for the Trezor Safe 5 (T3W1) model. It adds conditional logic gated by utils.USE_N4W1, new handler classes (_N4W1Handler, _N4W1Backup), UI method selection, debug-link mock helpers, and CI matrix entries for N4W1 builds and tests. The N4W1 path reads/writes the mnemonic as a blob over a debug-link mock context during tests, and reuses existing SLIP-39 word validation. The commit also parametrizes many existing reset/recovery tests by backup_method (Display vs N4W1).
Changed components
core/src/apps/management/recovery_device/layout.pycore/src/apps/management/reset_device/layout.pycore/src/trezor/ui/layouts/eckhart/recovery.py.github/workflows/core.ymltests/conftest.pytests/input_flows.pytests/input_flows_helpers.pytests/device_tests/reset_recovery/*Inspect captured patch +685 / −131
diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml
index cb4fb6b0..56fa2d2a 100644
--- a/.github/workflows/core.yml
+++ b/.github/workflows/core.yml
@@ -47,7 +47,7 @@ jobs:
cat $GITHUB_OUTPUT
core_firmware:
- name: Build firmware (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }})
+ name: Build firmware (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}${{matrix.n4w1 && ', n4w1' || '' }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -55,6 +55,7 @@ jobs:
model: ${{ fromJSON(github.event_name == 'push' && '["T2B1", "T2T1", "T3B1", "T3T1", "T3W1"]' || '["T2T1", "T3B1", "T3T1", "T3W1"]') }}
coins: [universal, btconly]
type: ${{ fromJSON(github.event_name == 'schedule' && '["normal", "debuglink", "production"]' || '["normal", "debuglink"]') }}
+ n4w1: [false]
include:
- model: D001
coins: universal
@@ -62,6 +63,10 @@ jobs:
- model: T2B1
coins: universal
type: normal
+ - model: T3W1
+ coins: universal
+ type: debuglink
+ n4w1: true # currently N4W1 is not supported for normal builds
exclude:
- model: T3W1
type: production
@@ -69,6 +74,7 @@ jobs:
TREZOR_MODEL: ${{ matrix.model }}
BITCOIN_ONLY: ${{ matrix.coins == 'universal' && '0' || '1' }}
PYOPT: ${{ matrix.type == 'debuglink' && '0' || '1' }}
+ N4W1: ${{ matrix.n4w1 && '1' || '0' }}
PRODUCTION: ${{ matrix.type == 'production' && '1' || '0' }}
BOOTLOADER_DEVEL: ${{ matrix.model == 'T3W1' && '1' || '0' }}
QUIET_MODE: 1
@@ -96,7 +102,7 @@ jobs:
if: matrix.coins == 'btconly' && matrix.type != 'debuglink'
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
with:
- name: core-firmware-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}
+ name: core-firmware-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}${{ matrix.n4w1 && '-n4w1' || '' }}
path: |
core/build/boardloader/*.bin
core/build/bootloader/*.bin
@@ -109,7 +115,7 @@ jobs:
retention-days: 7
core_emu:
- name: Build emu (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}, ${{ matrix.asan }})
+ name: Build emu (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}, ${{ matrix.asan }}${{matrix.n4w1 && ', n4w1' || '' }})
runs-on: ubuntu-latest
needs: param
strategy:
@@ -120,13 +126,21 @@ jobs:
# type: [normal, debuglink]
type: ${{ fromJSON(github.event_name == 'schedule' && '["normal", "debuglink"]' || '["debuglink"]') }}
asan: ${{ fromJSON(needs.param.outputs.asan) }}
+ n4w1: [false]
exclude:
- type: normal
asan: asan
+ include:
+ - model: T3W1
+ coins: universal
+ type: debuglink
+ asan: noasan
+ n4w1: true # currently N4W1 is not supported for normal builds
env:
TREZOR_MODEL: ${{ matrix.model }}
BITCOIN_ONLY: ${{ matrix.coins == 'universal' && '0' || '1' }}
PYOPT: ${{ matrix.type == 'debuglink' && '0' || '1' }}
+ N4W1: ${{ matrix.n4w1 && '1' || '0' }}
ADDRESS_SANITIZER: ${{ matrix.asan == 'asan' && '1' || '0' }}
LSAN_OPTIONS: "suppressions=../../asan_suppressions.txt"
QUIET_MODE: 1
@@ -141,10 +155,10 @@ jobs:
- run: nix-shell --run "uv run make -C core build_unix_frozen"
- run: nix-shell --arg fullDeps true --run "cd vendor/ts-tvl && poetry env use 3.12 && poetry install && poetry run model_server tcp -c ../../tests/tropic_model/config.yml > ../../tests/trezor-tropic-model.log 2>&1 &"
- run: nix-shell --run "uv run make -C core test_emu_sanity"
- - run: cp core/build/unix/trezor-emu-core core/build/unix/trezor-emu-core-${{ matrix.model }}-${{ matrix.coins }}
+ - run: cp core/build/unix/trezor-emu-core core/build/unix/trezor-emu-core-${{ matrix.model }}-${{ matrix.coins }}${{ matrix.n4w1 && '-n4w1' || '' }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
with:
- name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}
+ name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
path: |
core/build/unix/trezor-emu-core*
core/build/bootloader_emu/bootloader.elf
@@ -153,7 +167,7 @@ jobs:
core_emu_arm:
if: github.event_name == 'schedule'
- name: Build emu arm
+ name: Build emu ARM (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}, ${{ matrix.asan }}${{matrix.n4w1 && ', n4w1' || '' }})
runs-on: ubuntu-latest-arm64
needs: param
strategy:
@@ -163,10 +177,18 @@ jobs:
coins: [universal]
type: [debuglink]
asan: [noasan]
+ n4w1: [false]
+ include:
+ - model: T3W1
+ coins: universal
+ type: debuglink
+ asan: noasan
+ n4w1: true # currently N4W1 is not supported for normal builds
env:
TREZOR_MODEL: ${{ matrix.model }}
BITCOIN_ONLY: ${{ matrix.coins == 'universal' && '0' || '1' }}
PYOPT: ${{ matrix.type == 'debuglink' && '0' || '1' }}
+ N4W1: ${{ matrix.n4w1 && '1' || '0' }}
ADDRESS_SANITIZER: ${{ matrix.asan == 'asan' && '1' || '0' }}
LSAN_OPTIONS: "suppressions=../../asan_suppressions.txt"
QUIET_MODE: 1
@@ -178,10 +200,10 @@ jobs:
- run: nix-shell --run "uv run make -C core build_bootloader_emu"
if: matrix.coins == 'universal'
- run: nix-shell --run "uv run make -C core build_unix_frozen"
- - run: mv core/build/unix/trezor-emu-core core/build/unix/trezor-emu-arm-core-${{ matrix.model }}-${{ matrix.coins }}
+ - run: mv core/build/unix/trezor-emu-core core/build/unix/trezor-emu-arm-core-${{ matrix.model }}-${{ matrix.coins }}${{ matrix.n4w1 && '-n4w1' || '' }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
with:
- name: core-emu-arm-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}
+ name: core-emu-arm-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
path: |
core/build/unix/trezor-emu-*
core/build/bootloader_emu/bootloader.elf
@@ -268,7 +290,7 @@ jobs:
# See artifacts for a comprehensive report of UI.
# See [docs/tests/ui-tests](../tests/ui-tests.md) for more info.
core_device_test:
- name: Device tests (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.asan }}, ${{ matrix.lang }})
+ name: Device tests (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.asan }}, ${{ matrix.lang }}${{ matrix.n4w1 && ', n4w1' || '' }})
runs-on: ubuntu-latest
needs:
- param
@@ -280,6 +302,13 @@ jobs:
coins: [universal, btconly]
asan: ${{ fromJSON(needs.param.outputs.asan) }}
lang: ${{ fromJSON(needs.param.outputs.test_lang) }}
+ n4w1: [false]
+ include:
+ - model: T3W1
+ coins: universal
+ asan: noasan
+ lang: en
+ n4w1: true
env:
TREZOR_PROFILING: ${{ matrix.asan == 'noasan' && '1' || '0' }}
TREZOR_MODEL: ${{ matrix.model }}
@@ -295,19 +324,24 @@ jobs:
submodules: recursive
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
with:
- name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-debuglink-${{ matrix.asan }}
+ name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-debuglink-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
path: core/build
- run: chmod +x core/build/unix/trezor-emu-core*
- uses: ./.github/actions/environment
- name: Start Tropic model
if: ${{ env.TREZOR_MODEL == 'T3W1' && env.ACTIONS_DO_UI_TEST != 'true' }} # ACTIONS_DO_UI_TEST refers to the test_emu_ui_multicore below which uses --control-emulators and starts tvl internally
run: nix-shell --arg fullDeps true --run "cd vendor/ts-tvl && poetry env use 3.12 && poetry install && poetry run model_server tcp -c ../../tests/tropic_model/config.yml > ../../tests/trezor-tropic-model.log 2>&1 &"
- - run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_ui_multicore' || 'test_emu' }}"
+ - name: Run device tests
+ if: ${{ !matrix.n4w1 }}
+ run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_ui_multicore' || 'test_emu' }}"
+ - name: Run device tests (N4W1)
+ if: ${{ matrix.n4w1 }} # TODO(N4W1): test N4W1 UI fixtures as well
+ run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_multicore' || 'test_emu' }}"
- run: tail -v -n50 tests/trezor*.log || true
if: failure()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
with:
- name: core-test-device-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.lang }}-${{ matrix.asan }}
+ name: core-test-device-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.lang }}-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
path: tests/trezor*.log
retention-days: 7
if: always()
@@ -316,9 +350,10 @@ jobs:
model: ${{ matrix.model }}
lang: ${{ matrix.lang }}
status: ${{ job.status }}
- if: ${{ always() && env.ACTIONS_DO_UI_TEST == 'true' }}
+ if: ${{ always() && env.ACTIONS_DO_UI_TEST == 'true' && !matrix.n4w1 }}
continue-on-error: true
- uses: ./.github/actions/upload-coverage
+ if: ${{ !matrix.n4w1 }}
# Click tests - UI.
# See [docs/tests/click-tests](../tests/click-tests.md) for more info.
@@ -769,7 +804,7 @@ jobs:
steps:
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
with:
- pattern: core-emu*debuglink-noasan
+ pattern: core-emu*debuglink-noasan*
merge-multiple: true
- name: Configure aws credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # aws-actions/configure-aws-credentials@v6.0.0
@@ -792,7 +827,7 @@ jobs:
steps:
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
with:
- pattern: core-emu*debuglink-noasan
+ pattern: core-emu*debuglink-noasan*
merge-multiple: true
- name: Configure aws credentials
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # aws-actions/configure-aws-credentials@v6.0.0
diff --git a/core/src/apps/management/recovery_device/layout.py b/core/src/apps/management/recovery_device/layout.py
index ce36a181..95e3e3f5 100644
--- a/core/src/apps/management/recovery_device/layout.py
+++ b/core/src/apps/management/recovery_device/layout.py
@@ -2,7 +2,7 @@ from typing import TYPE_CHECKING
import storage.recovery as storage_recovery
import storage.recovery_shares as storage_recovery_shares
-from trezor import TR
+from trezor import TR, utils
from trezor.ui.layouts.recovery import ( # noqa: F401
request_word_count,
show_already_added,
@@ -278,12 +278,141 @@ class _DisplayHandler:
return None
-async def choose_handler(method: BackupMethod | None) -> type[RecoveryHandler]:
- from trezor.enums import BackupMethod
+if not utils.USE_N4W1:
- if method is not BackupMethod.Display and __debug__:
- from trezor import log
+ async def choose_handler(method: BackupMethod | None) -> type[RecoveryHandler]:
+ from trezor.enums import BackupMethod
- log.warning(__name__, "Unsupported backup method: %s", method)
+ if method is not BackupMethod.Display and __debug__:
+ from trezor import log
- return _DisplayHandler
+ log.warning(__name__, "Unsupported backup method: %s", method)
+
+ return _DisplayHandler
+
+else:
+
+ if TYPE_CHECKING:
+ from trezor.messages import BackupMethod
+
+ from .recover import Slip39State
+
+ async def choose_handler(method: BackupMethod | None) -> type[RecoveryHandler]:
+ from trezor.enums import BackupMethod
+
+ if method is None:
+ from trezor.ui.layouts.recovery import choose_method
+
+ method = await choose_method(TR.recovery__title, TR.backup__type_have)
+
+ if method is BackupMethod.N4W1:
+ return _N4W1Handler
+
+ if method not in (None, BackupMethod.Display):
+ from trezor import log
+
+ if __debug__:
+ log.warning(__name__, "Unsupported backup method: %s", method)
+
+ return _DisplayHandler
+
+ class RetryRead(Exception):
+ def __init__(self, msg: str) -> None:
+ self.msg = msg
+
+ async def _read_share() -> str:
+ from apps.debug import n4w1_mock
+
+ with n4w1_mock.ctx as ctx:
+ # returns `None` on cancellation or retriable error.
+ await ctx.confirm_connect(
+ title=TR.recovery__title,
+ description=TR.n4w1__hold_next,
+ button=TR.n4w1__footer_next,
+ br_name="backup_read",
+ )
+ # continue N4W1 communication (the tag is connected)
+
+ # TODO(N4W1): animate during read?
+ if (blob := await ctx.read(key="mnemonic")) is None:
+ raise RetryRead(TR.n4w1__err_empty)
+
+ # TODO(N4W1): use protobuf?
+ blob = bytes(blob)
+ try:
+ return blob.decode()
+ except ValueError:
+ raise RetryRead(TR.n4w1__err_damaged)
+
+ class _N4W1Handler:
+ def __init__(
+ self,
+ recovery_type: RecoveryType,
+ slip39_state: Slip39State | None,
+ ) -> None:
+ super().__init__()
+ self.recovery_type = recovery_type
+ # `slip39_state is None` indicates that we are (re)starting the first recovery step.
+ self.slip39_state = slip39_state
+
+ @classmethod
+ async def load(cls, recovery_type: RecoveryType) -> "RecoveryHandler":
+ return cls(recovery_type, load_slip39_state())
+
+ async def show_state(self, is_retry: bool) -> None:
+ if is_retry or self.slip39_state is None:
+ # don't show recovery state on retries and before the first share is entered
+ return
+ word_count = self.slip39_state[0]
+ await _request_share_first_screen(word_count, self.recovery_type)
+
+ async def request_mnemonic(self) -> str | None:
+ """Return the mnemonic or `None` on cancellation/validation error."""
+ import trezorui_api
+ from trezor.ui.layouts.common import raise_if_not_confirmed
+
+ while True:
+ try:
+ share = await _read_share()
+ break
+ except RetryRead as exc:
+ await raise_if_not_confirmed(
+ trezorui_api.show_warning(
+ title=TR.words__important,
+ button=TR.buttons__continue,
+ description=exc.msg,
+ danger=True,
+ ),
+ br_name="recovery_retry",
+ )
+ # wait for a new N4W1 tag
+ continue
+
+ return await self.check_words(share)
+
+ async def check_words(self, share: str) -> str | None:
+ from trezor.ui.layouts.progress import progress
+
+ from .word_validity import WordValidityResult, check
+
+ # Can be `None` when checking the first share.
+ backup_type = self.slip39_state and self.slip39_state[1]
+ share_words = share.split(" ")
+
+ progress_obj = progress(description=TR.n4w1__reading)
+ progress_obj.start()
+
+ try:
+ # Re-verify mnemonic prefixes:
+ steps = len(share_words)
+ for prefix_len in range(1, 1 + steps):
+ progress_obj.report((1000 * prefix_len) // steps)
+ check(backup_type, partial_mnemonic=share_words[:prefix_len])
+
+ return share
+ except WordValidityResult as exc:
+ # if they were invalid or some checks failed we continue and request them again
+ await exc.show_error()
+ return None
+ finally:
+ progress_obj.stop()
diff --git a/core/src/apps/management/reset_device/layout.py b/core/src/apps/management/reset_device/layout.py
index ed72ba93..4ab820f1 100644
--- a/core/src/apps/management/reset_device/layout.py
+++ b/core/src/apps/management/reset_device/layout.py
@@ -1,6 +1,7 @@
from micropython import const
from typing import TYPE_CHECKING, Iterable, Protocol, Sequence
+from trezor import utils
from trezor.ui.layouts.reset import ( # noqa: F401
show_share_words,
slip39_advanced_prompt_group_threshold,
@@ -195,13 +196,110 @@ class _DisplayBackup:
async def choose_backup_handler(method: BackupMethod | None) -> BackupHandler:
- # TODO: prompt the user if method is `None`.
- if __debug__:
- from trezor.enums import BackupMethod
+ from trezor.enums import BackupMethod
- if method not in (None, BackupMethod.Display):
- from trezor import log
+ if utils.USE_N4W1:
+ if method is None:
+ from trezor.ui.layouts.recovery import choose_method
+ method = await choose_method(
+ TR.backup__title_create_wallet_backup,
+ TR.backup__type_create,
+ )
+
+ if method is BackupMethod.N4W1:
+ return _N4W1Backup()
+
+ if method not in (None, BackupMethod.Display):
+ from trezor import log
+
+ if __debug__:
log.warning(__name__, "Unsupported backup method: %s", method)
return _DisplayBackup()
+
+
+if utils.USE_N4W1:
+
+ from trezor import TR
+
+ if TYPE_CHECKING:
+ from buffer_types import AnyBytes
+
+ from apps.debug.n4w1_mock import N4W1Context
+
+ class RetryWrite(Exception):
+ def __init__(self, msg: str) -> None:
+ self.msg = msg
+
+ class _N4W1Backup:
+
+ async def intro(self, num_of_words: int | None = None) -> None:
+ # TODO(N4W1): design/copy
+ pass
+
+ async def backup(self, iter_shares: Iterable[ShareInfo]) -> None:
+ # TODO(N4W1): warn user about safety
+
+ # backup all shares
+ for share in iter_shares:
+ await self._backup_share(share)
+
+ async def _backup_share(self, share: ShareInfo) -> None:
+ from apps.debug import n4w1_mock
+
+ # TODO(N4W1): use protobuf?
+ blob = " ".join(share.words).encode()
+
+ if share.index == 0 or share.num_of_shares is None:
+ description, button = TR.n4w1__hold_first, TR.n4w1__footer_first
+ elif share.index == share.num_of_shares - 1:
+ description, button = TR.n4w1__hold_last, TR.n4w1__footer_last
+ else:
+ description, button = TR.n4w1__hold_next, TR.n4w1__footer_next
+
+ while True:
+ try:
+ with n4w1_mock.ctx as ctx:
+ return await _write_share(ctx, description, button, blob)
+ except RetryWrite as exc:
+ import trezorui_api
+ from trezor.ui.layouts.common import raise_if_not_confirmed
+
+ await raise_if_not_confirmed(
+ trezorui_api.show_warning(
+ title=TR.words__important,
+ button=TR.buttons__continue,
+ description=exc.msg,
+ danger=True,
+ ),
+ br_name="backup_retry",
+ )
+ # wait for a new N4W1 tag
+ continue
+
+ async def _write_share(
+ ctx: N4W1Context, description: str, button: str, blob: AnyBytes
+ ) -> None:
+ from trezor.ui.layouts.progress import progress
+
+ await ctx.confirm_connect(
+ title=TR.backup__title_create_wallet_backup,
+ description=description,
+ button=button,
+ br_name="backup_write",
+ )
+ # continue N4W1 communication (the tag is connected)
+ result = await ctx.read(key="mnemonic")
+ if result is not None:
+ raise RetryWrite(TR.n4w1__err_nonempty)
+
+ progress_obj = progress(description=TR.n4w1__writing)
+ progress_obj.start()
+ progress_obj.report(100)
+ try:
+ await ctx.write(key="mnemonic", value=blob)
+ # TODO(N4W1): animate during I/O?
+ progress_obj.report(1000)
+ finally:
+ progress_obj.stop()
diff --git a/core/src/trezor/ui/layouts/eckhart/recovery.py b/core/src/trezor/ui/layouts/eckhart/recovery.py
index 3eb92a7d..0af393ef 100644
--- a/core/src/trezor/ui/layouts/eckhart/recovery.py
+++ b/core/src/trezor/ui/layouts/eckhart/recovery.py
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING
import trezorui_api
-from trezor import TR
+from trezor import TR, utils
from trezor.enums import ButtonRequestType, RecoveryType
from apps.common import backup_types
@@ -16,6 +16,8 @@ INFO = trezorui_api.INFO # global_import_cache
SUCCESS_SCREEN_TIMEOUT_MS = 2000
if TYPE_CHECKING:
+ from trezor.messages import BackupMethod
+
from apps.management.recovery_device.layout import RemainingSharesInfo
@@ -215,3 +217,20 @@ async def show_dry_run_result(result: bool, is_slip39: bool) -> None:
subheader="",
button=TR.buttons__try_again,
)
+
+
+if utils.USE_N4W1:
+
+ async def choose_method(title: str, description: str) -> BackupMethod:
+ import trezorui_api
+ from trezor.enums import BackupMethod
+
+ index = await interact(
+ trezorui_api.select_word(
+ title=title,
+ description=description,
+ words=(TR.backup__type_n4w1, TR.backup__type_wordlist, ""),
+ ),
+ br_name="choose_method",
+ )
+ return (BackupMethod.N4W1, BackupMethod.Display)[index]
diff --git a/tests/conftest.py b/tests/conftest.py
index a15202c2..057f3846 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -642,3 +642,30 @@ def device_handler(
finalized_ok = device_handler.check_finalize()
if test_res and not finalized_ok: # type: ignore [rep_call must exist]
raise RuntimeError("Test did not check result of background task")
+
+
+BACKUP_METHODS = [
+ pytest.param(arg, id=arg.name)
+ for arg in [
+ messages.BackupMethod.Display,
+ messages.BackupMethod.N4W1,
+ ]
+]
+
+
+@pytest.fixture(scope="session", params=BACKUP_METHODS)
+def backup_method(request, _raw_test_ctx: TrezorTestContext) -> messages.BackupMethod:
+ """
+ Return supported backup methods for current device using parametrized fixture.
+
+ See https://docs.pytest.org/en/stable/how-to/fixtures.html#parametrizing-fixtures.
+ """
+ REQUIRED_CAPABILITY = {
+ messages.BackupMethod.N4W1: messages.Capability.N4W1,
+ }
+ method: messages.BackupMethod = request.param
+ if (capability := REQUIRED_CAPABILITY.get(method)) is not None:
+ if capability not in _raw_test_ctx.capabilities:
+ pytest.skip(f"Missing {capability}")
+
+ return method
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 97180d68..030ebd28 100644
--- a/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py
+++ b/tests/device_tests/reset_recovery/test_recovery_bip39_dryrun.py
@@ -63,7 +63,11 @@ def do_recover_core(
client.watch_layout()
IF = InputFlowBip39RecoveryDryRun(session, mnemonic, mismatch=mismatch)
client.set_input_flow(IF.get())
- return device.recover(session, type=messages.RecoveryType.DryRun)
+ return device.recover(
+ session,
+ type=messages.RecoveryType.DryRun,
+ backup_method=messages.BackupMethod.Display,
+ )
def do_recover(session: Session, mnemonic: list[str], mismatch: bool = False) -> None:
@@ -102,6 +106,7 @@ def test_invalid_seed_core(session: Session):
return device.recover(
session,
type=messages.RecoveryType.DryRun,
+ backup_method=messages.BackupMethod.Display,
)
diff --git a/tests/device_tests/reset_recovery/test_recovery_bip39_t2.py b/tests/device_tests/reset_recovery/test_recovery_bip39_t2.py
index e989a422..291dc8dc 100644
--- a/tests/device_tests/reset_recovery/test_recovery_bip39_t2.py
+++ b/tests/device_tests/reset_recovery/test_recovery_bip39_t2.py
@@ -35,6 +35,7 @@ def test_tt_pin_passphrase(session: Session):
pin_protection=True,
passphrase_protection=True,
label="hello",
+ backup_method=messages.BackupMethod.Display,
)
assert session.debug.state().mnemonic_secret.decode() == MNEMONIC12
@@ -55,6 +56,7 @@ def test_tt_nopin_nopassphrase(session: Session):
pin_protection=False,
passphrase_protection=False,
label="hello",
+ backup_method=messages.BackupMethod.Display,
)
assert session.debug.state().mnemonic_secret.decode() == MNEMONIC12
diff --git a/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py b/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py
index 9cbb7f60..b558f1d5 100644
--- a/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py
+++ b/tests/device_tests/reset_recovery/test_recovery_slip39_advanced.py
@@ -56,6 +56,7 @@ def _test_secret(
pin_protection=False,
passphrase_protection=False,
label="label",
+ backup_method=messages.BackupMethod.Display,
)
assert session.features.initialized is True
@@ -89,7 +90,12 @@ def test_abort(session: Session):
IF = InputFlowSlip39AdvancedRecoveryAbort(session)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is False
@@ -100,7 +106,12 @@ def test_noabort(session: Session):
session, EXTRA_GROUP_SHARE + MNEMONIC_SLIP39_ADVANCED_20
)
client.set_input_flow(IF.get())
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is True
@@ -118,7 +129,12 @@ def test_same_share(session: Session):
)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
def test_group_threshold_reached(session: Session):
@@ -133,4 +149,9 @@ def test_group_threshold_reached(session: Session):
)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
diff --git a/tests/device_tests/reset_recovery/test_recovery_slip39_advanced_dryrun.py b/tests/device_tests/reset_recovery/test_recovery_slip39_advanced_dryrun.py
index dfbb4c5e..e519ca6f 100644
--- a/tests/device_tests/reset_recovery/test_recovery_slip39_advanced_dryrun.py
+++ b/tests/device_tests/reset_recovery/test_recovery_slip39_advanced_dryrun.py
@@ -51,6 +51,7 @@ def test_2of3_dryrun(session: Session):
pin_protection=False,
label="label",
type=messages.RecoveryType.DryRun,
+ backup_method=messages.BackupMethod.Display,
)
@@ -73,4 +74,5 @@ def test_2of3_invalid_seed_dryrun(session: Session):
pin_protection=False,
label="label",
type=messages.RecoveryType.DryRun,
+ backup_method=messages.BackupMethod.Display,
)
diff --git a/tests/device_tests/reset_recovery/test_recovery_slip39_basic.py b/tests/device_tests/reset_recovery/test_recovery_slip39_basic.py
index 4265b2b8..55c01129 100644
--- a/tests/device_tests/reset_recovery/test_recovery_slip39_basic.py
+++ b/tests/device_tests/reset_recovery/test_recovery_slip39_basic.py
@@ -56,28 +56,42 @@ VECTORS = (
MNEMONIC_SLIP39_BASIC_20_3of6,
MNEMONIC_SLIP39_BASIC_20_3of6_SECRET,
messages.BackupType.Slip39_Basic,
+ "3of6",
),
(
MNEMONIC_SLIP39_BASIC_EXT_20_2of3,
MNEMONIC_SLIP39_BASIC_EXT_20_2of3_SECRET,
messages.BackupType.Slip39_Basic_Extendable,
+ "2of3-ext",
),
(
MNEMONIC_SLIP39_BASIC_33_2of5,
"b770e0da1363247652de97a39bdbf2463be087848d709ecbf28e84508e31202a",
messages.BackupType.Slip39_Basic,
+ "2of5-33",
),
)
+TEST_PARAMS = [pytest.param(*args, id=desc) for *args, desc in VECTORS]
-@pytest.mark.parametrize("shares, secret, backup_type", VECTORS)
+
+@pytest.mark.parametrize("shares, secret, backup_type", TEST_PARAMS)
def test_secret(
- session: Session, shares: list[str], secret: str, backup_type: messages.BackupType
+ session: Session,
+ shares: list[str],
+ secret: str,
+ backup_type: messages.BackupType,
+ backup_method: messages.BackupMethod,
):
with session.test_ctx as client:
- IF = InputFlowSlip39BasicRecovery(session, shares)
+ IF = InputFlowSlip39BasicRecovery(session, shares, method=backup_method)
client.set_input_flow(IF.get())
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=backup_method,
+ )
# Workflow successfully ended
assert session.features.pin_protection is False
@@ -99,6 +113,7 @@ def test_recover_with_pin_passphrase(session: Session):
pin_protection=True,
passphrase_protection=True,
label="label",
+ backup_method=messages.BackupMethod.Display,
)
# Workflow successfully ended
@@ -112,7 +127,12 @@ def test_abort(session: Session):
IF = InputFlowSlip39BasicRecoveryAbort(session)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is False
assert session.features.recovery_status is messages.RecoveryStatus.Nothing
@@ -125,7 +145,12 @@ def test_abort_on_number_of_words(session: Session):
IF = InputFlowSlip39BasicRecoveryAbortOnNumberOfWords(session)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
assert session.features.initialized is False
assert session.features.recovery_status is messages.RecoveryStatus.Nothing
@@ -137,7 +162,12 @@ def test_abort_between_shares(session: Session):
)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is False
assert session.features.recovery_status is messages.RecoveryStatus.Nothing
@@ -158,7 +188,12 @@ def test_abort_on_mnemonic(session: Session, first_share: bool):
)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is False
assert session.features.recovery_status is messages.RecoveryStatus.Nothing
@@ -172,7 +207,12 @@ def test_share_info_between_shares(session: Session):
)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert client.features.initialized is False
@@ -181,7 +221,12 @@ def test_noabort(session: Session):
with session.test_ctx as client:
IF = InputFlowSlip39BasicRecoveryNoAbort(session, MNEMONIC_SLIP39_BASIC_20_3of6)
client.set_input_flow(IF.get())
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is True
@@ -191,7 +236,12 @@ def test_invalid_mnemonic_first_share(session: Session):
IF = InputFlowSlip39BasicRecoveryInvalidFirstShare(session)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is False
@@ -203,7 +253,12 @@ def test_invalid_mnemonic_second_share(session: Session):
)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
session.refresh_features()
assert session.features.initialized is False
@@ -215,7 +270,12 @@ def test_wrong_nth_word(session: Session, nth_word: int):
IF = InputFlowSlip39BasicRecoveryWrongNthWord(session, share, nth_word)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
def test_same_share(session: Session):
@@ -224,7 +284,12 @@ def test_same_share(session: Session):
IF = InputFlowSlip39BasicRecoverySameShare(session, share)
client.set_input_flow(IF.get())
with pytest.raises(exceptions.Cancelled):
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
def test_1of1(session: Session):
@@ -236,6 +301,7 @@ def test_1of1(session: Session):
pin_protection=False,
passphrase_protection=False,
label="label",
+ backup_method=messages.BackupMethod.Display,
)
# Workflow successfully ended
diff --git a/tests/device_tests/reset_recovery/test_recovery_slip39_basic_dryrun.py b/tests/device_tests/reset_recovery/test_recovery_slip39_basic_dryrun.py
index 9707b23b..8ea97eda 100644
--- a/tests/device_tests/reset_recovery/test_recovery_slip39_basic_dryrun.py
+++ b/tests/device_tests/reset_recovery/test_recovery_slip39_basic_dryrun.py
@@ -37,9 +37,11 @@ INVALID_SHARES_20_2of3 = [
@pytest.mark.setup_client(mnemonic=SHARES_20_2of3[0:2])
-def test_2of3_dryrun(session: Session):
+def test_2of3_dryrun(session: Session, backup_method: messages.BackupMethod):
with session.test_ctx as client:
- IF = InputFlowSlip39BasicRecoveryDryRun(session, SHARES_20_2of3[1:3])
+ IF = InputFlowSlip39BasicRecoveryDryRun(
+ session, SHARES_20_2of3[1:3], method=backup_method
+ )
client.set_input_flow(IF.get())
device.recover(
session,
@@ -47,11 +49,14 @@ def test_2of3_dryrun(session: Session):
pin_protection=False,
label="label",
type=messages.RecoveryType.DryRun,
+ backup_method=backup_method,
)
@pytest.mark.setup_client(mnemonic=SHARES_20_2of3[0:2])
-def test_2of3_invalid_seed_dryrun(session: Session):
+def test_2of3_invalid_seed_dryrun(
+ session: Session, backup_method: messages.BackupMethod
+):
# test fails because of different seed on device
with (
session.test_ctx as client,
@@ -60,7 +65,7 @@ def test_2of3_invalid_seed_dryrun(session: Session):
),
):
IF = InputFlowSlip39BasicRecoveryDryRun(
- session, INVALID_SHARES_20_2of3, mismatch=True
+ session, INVALID_SHARES_20_2of3, mismatch=True, method=backup_method
)
client.set_input_flow(IF.get())
device.recover(
@@ -69,4 +74,5 @@ def test_2of3_invalid_seed_dryrun(session: Session):
pin_protection=False,
label="label",
type=messages.RecoveryType.DryRun,
+ backup_method=backup_method,
)
diff --git a/tests/device_tests/reset_recovery/test_reset_recovery_bip39.py b/tests/device_tests/reset_recovery/test_reset_recovery_bip39.py
index bd503a64..e9b9d649 100644
--- a/tests/device_tests/reset_recovery/test_reset_recovery_bip39.py
+++ b/tests/device_tests/reset_recovery/test_reset_recovery_bip39.py
@@ -80,7 +80,12 @@ def recover(session: Session, mnemonic: str):
IF = InputFlowBip39Recovery(session, words)
client.set_input_flow(IF.get())
client.watch_layout()
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
# Workflow successfully ended
assert session.features.pin_protection is False
diff --git a/tests/device_tests/reset_recovery/test_reset_recovery_slip39_advanced.py b/tests/device_tests/reset_recovery/test_reset_recovery_slip39_advanced.py
index 77e3d039..4ed2732e 100644
--- a/tests/device_tests/reset_recovery/test_reset_recovery_slip39_advanced.py
+++ b/tests/device_tests/reset_recovery/test_reset_recovery_slip39_advanced.py
@@ -99,7 +99,12 @@ def recover(session: Session, shares: list[str], click_info: bool = False):
with session.test_ctx as client:
IF = InputFlowSlip39AdvancedRecovery(client, shares, click_info)
client.set_input_flow(IF.get())
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session,
+ pin_protection=False,
+ label="label",
+ backup_method=messages.BackupMethod.Display,
+ )
# Workflow successfully ended
assert session.features.pin_protection is False
diff --git a/tests/device_tests/reset_recovery/test_reset_recovery_slip39_basic.py b/tests/device_tests/reset_recovery/test_reset_recovery_slip39_basic.py
index dfbadd3a..02349910 100644
--- a/tests/device_tests/reset_recovery/test_reset_recovery_slip39_basic.py
+++ b/tests/device_tests/reset_recovery/test_reset_recovery_slip39_basic.py
@@ -22,7 +22,7 @@ import pytest
from trezorlib import btc, device, messages
from trezorlib.debuglink import DebugSession as Session
from trezorlib.debuglink import TrezorTestContext as Client
-from trezorlib.messages import BackupType
+from trezorlib.messages import BackupMethod, BackupType
from trezorlib.tools import parse_path
from ...common import MOCK_GET_ENTROPY
@@ -35,8 +35,9 @@ from ...translations import set_language
@pytest.mark.models("core")
@pytest.mark.setup_client(uninitialized=True)
-def test_reset_recovery(client: Client):
+def test_reset_recovery(client: Client, backup_method: BackupMethod):
session = client.get_seedless_session()
+
mnemonics = reset(session)
session = client.get_session()
address_before = btc.get_address(session, "Bitcoin", parse_path("m/44h/0h/0h/0/0"))
@@ -48,7 +49,7 @@ def test_reset_recovery(client: Client):
session = client.get_seedless_session()
set_language(session, lang[:2])
selected_mnemonics = share_subset
- recover(session, selected_mnemonics)
+ recover(session, selected_mnemonics, backup_method)
session = client.get_session()
address_after = btc.get_address(
session, "Bitcoin", parse_path("m/44h/0h/0h/0/0")
@@ -85,11 +86,13 @@ def reset(session: Session, strength: int = 128) -> list[str]:
return IF.mnemonics
-def recover(session: Session, shares: t.Sequence[str]):
+def recover(session: Session, shares: t.Sequence[str], method: BackupMethod):
with session.test_ctx as client:
- IF = InputFlowSlip39BasicRecovery(session, shares)
+ IF = InputFlowSlip39BasicRecovery(session, shares, method=method)
client.set_input_flow(IF.get())
- device.recover(session, pin_protection=False, label="label")
+ device.recover(
+ session, pin_protection=False, label="label", backup_method=method
+ )
# Workflow successfully ended
assert session.features.pin_protection is False
diff --git a/tests/device_tests/reset_recovery/test_reset_slip39_basic.py b/tests/device_tests/reset_recovery/test_reset_slip39_basic.py
index f9b72c59..a44bfc84 100644
--- a/tests/device_tests/reset_recovery/test_reset_slip39_basic.py
+++ b/tests/device_tests/reset_recovery/test_reset_slip39_basic.py
@@ -23,7 +23,7 @@ from trezorlib import device
from trezorlib.btc import get_public_node
from trezorlib.debuglink import DebugSession as Session
from trezorlib.exceptions import TrezorFailure
-from trezorlib.messages import BackupAvailability, BackupType
+from trezorlib.messages import BackupAvailability, BackupMethod, BackupType
from ...common import EXTERNAL_ENTROPY, MOCK_GET_ENTROPY, generate_entropy
from ...input_flows import (
@@ -41,11 +41,14 @@ FLOW_ADAPTERS = [
]
-def reset_device(session: Session, strength: int, adapt_flow: FlowAdapter):
+def reset_device(
+ session: Session, strength: int, adapt_flow: FlowAdapter, method: BackupMethod
+):
+
member_threshold = 3
with session.test_ctx as client:
- IF = InputFlowSlip39BasicResetRecovery(session)
+ IF = InputFlowSlip39BasicResetRecovery(session, method)
client.set_input_flow(adapt_flow(session, IF.get()))
# No PIN, no passphrase, don't display random
@@ -56,6 +59,7 @@ def reset_device(session: Session, strength: int, adapt_flow: FlowAdapter):
pin_protection=False,
label="test",
backup_type=BackupType.Slip39_Basic,
+ backup_method=method,
entropy_check_count=0,
_get_entropy=MOCK_GET_ENTROPY,
)
@@ -80,27 +84,38 @@ def reset_device(session: Session, strength: int, adapt_flow: FlowAdapter):
device.backup(session)
+TEST_PARAMS = [
+ pytest.param(adapt_flow, id=adapt_flow.__name__) for adapt_flow in FLOW_ADAPTERS
+]
+
+
@pytest.mark.setup_client(uninitialized=True)
-@pytest.mark.parametrize("adapt_flow", FLOW_ADAPTERS, ids=lambda f: f.__name__)
-def test_reset_device_slip39_basic(session: Session, adapt_flow: FlowAdapter):
- reset_device(session, 128, adapt_flow)
+@pytest.mark.parametrize("adapt_flow", TEST_PARAMS)
+def test_reset_device_slip39_basic(
+ session: Session, adapt_flow: FlowAdapter, backup_method: BackupMethod
+):
+ reset_device(session, 128, adapt_flow, method=backup_method)
-@pytest.mark.parametrize("adapt_flow", FLOW_ADAPTERS, ids=lambda f: f.__name__)
+@pytest.mark.parametrize("adapt_flow", TEST_PARAMS)
@pytest.mark.setup_client(uninitialized=True)
-def test_reset_device_slip39_basic_256(session: Session, adapt_flow: FlowAdapter):
- reset_device(session, 256, adapt_flow)
+def test_reset_device_slip39_basic_256(
+ session: Session, adapt_flow: FlowAdapter, backup_method: BackupMethod
+):
+ reset_device(session, 256, adapt_flow, method=backup_method)
@pytest.mark.setup_client(uninitialized=True)
-@pytest.mark.parametrize("adapt_flow", FLOW_ADAPTERS, ids=lambda f: f.__name__)
-def test_reset_entropy_check(session: Session, adapt_flow: FlowAdapter):
+@pytest.mark.parametrize("adapt_flow", TEST_PARAMS)
+def test_reset_entropy_check(
+ session: Session, adapt_flow: FlowAdapter, backup_method: BackupMethod
+):
member_threshold = 3
strength = 128 # 20 words
with session.test_ctx as client:
- IF = InputFlowSlip39BasicResetRecovery(session)
+ IF = InputFlowSlip39BasicResetRecovery(session, backup_method)
client.set_input_flow(adapt_flow(session, IF.get()))
# No PIN, no passphrase.
@@ -111,6 +126,7 @@ def test_reset_entropy_check(session: Session, adapt_flow: FlowAdapter):
pin_protection=False,
label="test",
backup_type=BackupType.Slip39_Basic,
+ backup_method=backup_method,
entropy_check_count=3,
_get_entropy=MOCK_GET_ENTROPY,
)
diff --git a/tests/device_tests/test_repeated_backup.py b/tests/device_tests/test_repeated_backup.py
index f2fb28ad..fecdf4c7 100644
--- a/tests/device_tests/test_repeated_backup.py
+++ b/tests/device_tests/test_repeated_backup.py
@@ -33,16 +33,18 @@ pytestmark = pytest.mark.models("core")
@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_BASIC_20_3of6)
-def test_repeated_backup_via_host(session: Session):
+def test_repeated_backup_via_host(
+ session: Session, backup_method: messages.BackupMethod
+):
assert session.features.backup_availability == messages.BackupAvailability.Required
assert session.features.recovery_status == messages.RecoveryStatus.Nothing
# initial device backup
mnemonics = []
with session.test_ctx as client:
- IF = InputFlowSlip39BasicBackup(session, False)
+ IF = InputFlowSlip39BasicBackup(session, False, method=backup_method)
client.set_input_flow(IF.get())
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
mnemonics = IF.mnemonics
assert len(mnemonics) == 5
@@ -53,15 +55,19 @@ def test_repeated_backup_via_host(session: Session):
)
assert session.features.recovery_status == messages.RecoveryStatus.Nothing
with pytest.raises(TrezorFailure, match=r".*Seed already backed up"):
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
# unlock repeated backup by entering 3 of the 5 shares we have got
with session.test_ctx as client:
IF = InputFlowSlip39BasicRecoveryDryRun(
- session, mnemonics[:3], unlock_repeated_backup=True
+ session, mnemonics[:3], unlock_repeated_backup=True, method=backup_method
)
client.set_input_flow(IF.get())
- device.recover(session, type=messages.RecoveryType.UnlockRepeatedBackup)
+ device.recover(
+ session,
+ type=messages.RecoveryType.UnlockRepeatedBackup,
+ backup_method=backup_method,
+ )
assert (
session.features.backup_availability
== messages.BackupAvailability.Available
@@ -70,9 +76,11 @@ def test_repeated_backup_via_host(session: Session):
# we can now perform another backup
with session.test_ctx as client:
- IF = InputFlowSlip39BasicBackup(session, False, repeated=True)
+ IF = InputFlowSlip39BasicBackup(
+ session, False, repeated=True, method=backup_method
+ )
client.set_input_flow(IF.get())
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
# the backup feature is locked again...
assert (
@@ -84,7 +92,9 @@ def test_repeated_backup_via_host(session: Session):
@pytest.mark.setup_client(mnemonic=MNEMONIC_SLIP39_SINGLE_EXT_20)
-def test_repeated_backup_via_host_upgrade_single(session: Session):
+def test_repeated_backup_via_host_upgrade_single(
+ session: Session, backup_method: messages.BackupMethod
+):
assert (
session.features.backup_availability == messages.BackupAvailability.NotAvailable
)
@@ -94,10 +104,17 @@ def test_repeated_backup_via_host_upgrade_single(session: Session):
# unlock repeated backup by entering the single share
with session.test_ctx as client:
IF = InputFlowSlip39BasicRecoveryDryRun(
- session, MNEMONIC_SLIP39_SINGLE_EXT_20, unlock_repeated_backup=True
+ session,
+ MNEMONIC_SLIP39_SINGLE_EXT_20,
+ unlock_repeated_backup=True,
+ method=backup_method,
)
client.set_input_flow(IF.get())
- device.recover(session, type=messages.RecoveryType.UnlockRepeatedBackup)
+ device.recover(
+ session,
+ type=messages.RecoveryType.UnlockRepeatedBackup,
+ backup_method=backup_method,
+ )
assert (
session.features.backup_availability
== messages.BackupAvailability.Available
@@ -106,9 +123,11 @@ def test_repeated_backup_via_host_upgrade_single(session: Session):
# we can now perform another backup
with session.test_ctx as client:
- IF = InputFlowSlip39BasicBackup(session, False, repeated=True)
+ IF = InputFlowSlip39BasicBackup(
+ session, False, repeated=True, method=backup_method
+ )
client.set_input_flow(IF.get())
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
# backup type was upgraded:
assert session.features.backup_type == messages.BackupType.Slip39_Basic_Extendable
@@ -122,16 +141,18 @@ def test_repeated_backup_via_host_upgrade_single(session: Session):
@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_BASIC_20_3of6)
-def test_repeated_backup_via_host_cancel(session: Session):
+def test_repeated_backup_via_host_cancel(
+ session: Session, backup_method: messages.BackupMethod
+):
assert session.features.backup_availability == messages.BackupAvailability.Required
assert session.features.recovery_status == messages.RecoveryStatus.Nothing
# initial device backup
mnemonics = []
with session.test_ctx as client:
- IF = InputFlowSlip39BasicBackup(session, False)
+ IF = InputFlowSlip39BasicBackup(session, False, method=backup_method)
client.set_input_flow(IF.get())
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
mnemonics = IF.mnemonics
assert len(mnemonics) == 5
@@ -142,15 +163,19 @@ def test_repeated_backup_via_host_cancel(session: Session):
)
assert session.features.recovery_status == messages.RecoveryStatus.Nothing
with pytest.raises(TrezorFailure, match=r".*Seed already backed up"):
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
# unlock repeated backup by entering 3 of the 5 shares we have got
with session.test_ctx as client:
IF = InputFlowSlip39BasicRecoveryDryRun(
- session, mnemonics[:3], unlock_repeated_backup=True
+ session, mnemonics[:3], unlock_repeated_backup=True, method=backup_method
)
client.set_input_flow(IF.get())
- device.recover(session, type=messages.RecoveryType.UnlockRepeatedBackup)
+ device.recover(
+ session,
+ type=messages.RecoveryType.UnlockRepeatedBackup,
+ backup_method=backup_method,
+ )
assert (
session.features.backup_availability
== messages.BackupAvailability.Available
@@ -177,16 +202,18 @@ def test_repeated_backup_via_host_cancel(session: Session):
@pytest.mark.setup_client(needs_backup=True, mnemonic=MNEMONIC_SLIP39_BASIC_20_3of6)
-def test_repeated_backup_via_host_send_disallowed_message(session: Session):
+def test_repeated_backup_via_host_send_disallowed_message(
+ session: Session, backup_method: messages.BackupMethod
+):
assert session.features.backup_availability == messages.BackupAvailability.Required
assert session.features.recovery_status == messages.RecoveryStatus.Nothing
# initial device backup
mnemonics = []
with session.test_ctx as client:
- IF = InputFlowSlip39BasicBackup(session, False)
+ IF = InputFlowSlip39BasicBackup(session, False, method=backup_method)
client.set_input_flow(IF.get())
- device.backup(session)
+ device.backup(session, backup_method=backup_method)
mnemonics = IF.mnemonics
assert len(mnemonics) == 5
@@ -202,10 +229,14 @@ def test_repeated_backup_via_host_send_disallowed_message(session: Session):
# unlock repeated backup by entering 3 of the 5 shares we have got
with session.test_ctx as client:
IF = InputFlowSlip39BasicRecoveryDryRun(
- session, mnemonics[:3], unlock_repeated_backup=True
+ session, mnemonics[:3], unlock_repeated_backup=True, method=backup_method
)
client.set_input_flow(IF.get())
- device.recover(session, type=messages.RecoveryType.UnlockRepeatedBackup)
+ device.recover(
+ session,
+ type=messages.RecoveryType.UnlockRepeatedBackup,
+ backup_method=backup_method,
+ )
assert (
session.features.backup_availability
== messages.BackupAvailability.Available
diff --git a/tests/input_flows.py b/tests/input_flows.py
index 4c606fef..822a0595 100644
--- a/tests/input_flows.py
+++ b/tests/input_flows.py
@@ -34,7 +34,13 @@ from .common import (
read_and_confirm_mnemonic,
swipe_if_necessary,
)
-from .input_flows_helpers import BackupFlow, EthereumFlow, PinFlow, RecoveryFlow
+from .input_flows_helpers import (
+ BackupFlow,
+ EthereumFlow,
+ PinFlow,
+ RecoveryFlow,
+ n4w1_handle_write,
+)
B = messages.ButtonRequestType
@@ -1958,32 +1964,44 @@ class InputFlowBip39ResetFailedCheck(InputFlowBase):
def load_N_shares(
debug: DebugLink,
n: int,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
) -> Generator[None, "messages.ButtonRequest", list[str]]:
mnemonics: list[str] = []
for _ in range(n):
- # Phrase screen
- mnemonic = yield from read_and_confirm_mnemonic(debug)
- assert mnemonic is not None
- mnemonics.append(mnemonic)
-
- br = yield # Confirm continue to next
- assert br.code == B.Success
- debug.press_yes()
+ if method is messages.BackupMethod.Display:
+ # Phrase screen
+ mnemonic = yield from read_and_confirm_mnemonic(debug)
+ assert mnemonic is not None
+ mnemonics.append(mnemonic)
+ br = yield # Confirm continue to next
+ assert br.code == B.Success
+ debug.press_yes()
+ elif method is messages.BackupMethod.N4W1:
+ assert (yield).name == "backup_write"
+ mnemonics.append(n4w1_handle_write(debug).decode())
+ else:
+ raise RuntimeError
return mnemonics
class InputFlowSlip39BasicBackup(InputFlowBase):
def __init__(
- self, client: Client | DebugSession, click_info: bool, repeated: bool = False
+ self,
+ client: Client | DebugSession,
+ click_info: bool,
+ repeated: bool = False,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
):
super().__init__(client)
self.mnemonics: list[str] = []
self.click_info = click_info
self.repeated = repeated
+ self.method = method
def input_flow_bolt(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
if self.repeated:
assert (yield).name == "confirm_repeated_backup"
self.debug.press_yes()
@@ -2017,6 +2035,7 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
def input_flow_caesar(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
if self.repeated:
# intro confirmation screen
yield
@@ -2049,6 +2068,7 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
def input_flow_delizia(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
if self.repeated:
# intro confirmation screen
assert (yield).name == "confirm_repeated_backup"
@@ -2081,13 +2101,19 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
def input_flow_eckhart(self) -> BRGeneratorType:
+ assert self.method in (
+ messages.BackupMethod.Display,
+ messages.BackupMethod.N4W1,
+ )
if self.repeated:
# intro confirmation screen
assert (yield).name == "confirm_repeated_backup"
self.debug.press_yes()
- assert (yield).name == "backup_intro"
- self.debug.press_yes()
+ if self.method is messages.BackupMethod.Display:
+ assert (yield).name == "backup_intro"
+ self.debug.press_yes()
+
assert (yield).name == "slip39_checklist"
self.debug.press_yes()
assert (yield).name == "slip39_shares"
@@ -2102,11 +2128,13 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
self.debug.press_yes()
assert (yield).name == "slip39_checklist"
self.debug.press_yes()
- assert (yield).name == "backup_warning"
- self.debug.press_yes()
+
+ if self.method is messages.BackupMethod.Display:
+ assert (yield).name == "backup_warning"
+ self.debug.press_yes()
# Mnemonic phrases
- self.mnemonics = yield from load_N_shares(self.debug, 5)
+ self.mnemonics = yield from load_N_shares(self.debug, 5, self.method)
br = yield # Confirm backup
assert br.code == B.Success
@@ -2114,11 +2142,17 @@ class InputFlowSlip39BasicBackup(InputFlowBase):
class InputFlowSlip39BasicResetRecovery(InputFlowBase):
- def __init__(self, client: Client | DebugSession):
+ def __init__(
+ self,
+ client: Client | DebugSession,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
+ ):
super().__init__(client)
self.mnemonics: list[str] = []
+ self.method = method
def input_flow_bolt(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
# 1. Confirm Reset
# 2. Backup your seed
# 3. Backup intro
@@ -2138,6 +2172,7 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
self.debug.press_yes()
def input_flow_caesar(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
yield # Confirm Reset
self.debug.press_yes()
yield # Backup your seed
@@ -2169,6 +2204,7 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
self.debug.press_yes()
def input_flow_delizia(self) -> BRGeneratorType:
+ assert self.method is messages.BackupMethod.Display
# 1. Confirm Reset
# 2. Wallet Created
# 3. Backup your seed
@@ -2189,6 +2225,10 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
self.debug.press_yes()
def input_flow_eckhart(self) -> BRGeneratorType:
+ num_screens = {
+ messages.BackupMethod.Display: 10,
+ messages.BackupMethod.N4W1: 8,
+ }[self.method]
# 1. Confirm Reset
# 2. Wallet Created
# 3. Backup your seed
@@ -2199,10 +2239,10 @@ class InputFlowSlip39BasicResetRecovery(InputFlowBase):
# 8. Confirm show seeds
# 9. Warning
# 10. Instructions
- yield from click_through(self.debug, screens=10, code=B.ResetDevice)
+ yield from click_through(self.debug, screens=num_screens, code=B.ResetDevice)
# Mnemonic phrases
- self.mnemonics = yield from load_N_shares(self.debug, 5)
+ self.mnemonics = yield from load_N_shares(self.debug, 5, self.method)
br = yield # success screen
assert br.code == B.Success
@@ -2773,21 +2813,27 @@ class InputFlowSlip39BasicRecoveryDryRun(InputFlowBase):
client: Client | DebugSession,
shares: list[str],
mismatch: bool = False,
- unlock_repeated_backup=False,
+ unlock_repeated_backup: bool = False,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
):
super().__init__(client)
self.shares = shares
self.mismatch = mismatch
self.unlock_repeated_backup = unlock_repeated_backup
- self.word_count = len(shares[0].split(" "))
+ self.method = method
+ if method is messages.BackupMethod.Display:
+ self.word_count = len(shares[0].split(" "))
+ else:
+ self.word_count = None
def input_flow_common(self) -> BRGeneratorType:
yield from self.REC.confirm_dry_run()
- if self.unlock_repeated_backup:
- yield from self.REC.setup_repeated_backup_recovery(self.word_count)
- else:
- yield from self.REC.setup_slip39_recovery(self.word_count)
- yield from self.REC.input_all_slip39_shares(self.shares)
+ if self.word_count is not None:
+ if self.unlock_repeated_backup:
+ yield from self.REC.setup_repeated_backup_recovery(self.word_count)
+ else:
+ yield from self.REC.setup_slip39_recovery(self.word_count)
+ yield from self.REC.input_all_slip39_shares(self.shares, method=self.method)
if self.mismatch:
yield from self.REC.warning_slip39_dryrun_mismatch()
elif not self.unlock_repeated_backup:
@@ -2800,18 +2846,25 @@ class InputFlowSlip39BasicRecovery(InputFlowBase):
client: Client | DebugSession,
shares: Sequence[str],
pin: str | None = None,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
):
super().__init__(client)
self.shares = shares
self.pin = pin
- self.word_count = len(shares[0].split(" "))
+ self.method = method
+ if self.method is messages.BackupMethod.Display:
+ self.word_count = len(shares[0].split(" "))
+ else:
+ self.word_count = None
def input_flow_common(self) -> BRGeneratorType:
yield from self.REC.confirm_recovery()
if self.pin is not None:
yield from self.PIN.setup_new_pin(self.pin)
- yield from self.REC.setup_slip39_recovery(self.word_count)
- yield from self.REC.input_all_slip39_shares(self.shares)
+ if self.word_count is not None:
+ yield from self.REC.setup_slip39_recovery(self.word_count)
+
+ yield from self.REC.input_all_slip39_shares(self.shares, method=self.method)
yield from self.REC.success_wallet_recovered()
diff --git a/tests/input_flows_helpers.py b/tests/input_flows_helpers.py
index 04ee2d20..c42981e0 100644
--- a/tests/input_flows_helpers.py
+++ b/tests/input_flows_helpers.py
@@ -1,7 +1,7 @@
import typing as t
from trezorlib import messages
-from trezorlib.debuglink import LayoutType
+from trezorlib.debuglink import DebugLink, LayoutType
from trezorlib.debuglink import TrezorTestContext as Client
from . import translations as TR
@@ -350,23 +350,35 @@ class RecoveryFlow:
if click_ok:
self.debug.press_yes()
- def input_mnemonic(self, mnemonic: list[str]) -> BRGeneratorType:
+ def input_mnemonic(
+ self,
+ mnemonic: list[str],
+ method: messages.BackupMethod = messages.BackupMethod.Display,
+ ) -> BRGeneratorType:
br = yield
- assert br.code == B.MnemonicInput
- assert br.name == "mnemonic"
- assert "MnemonicKeyboard" in self.debug.read_layout().all_components()
- for _, word in enumerate(mnemonic):
- self.debug.input(word)
+ if method is messages.BackupMethod.Display:
+ assert br.code == B.MnemonicInput
+ assert br.name == "mnemonic"
+ assert "MnemonicKeyboard" in self.debug.read_layout().all_components()
+ for _, word in enumerate(mnemonic):
+ self.debug.input(word)
+ elif method is messages.BackupMethod.N4W1:
+ assert br.code == B.Other
+ assert br.name == "backup_read"
+ n4w1_handle_read(self.debug, " ".join(mnemonic).encode())
+ else:
+ raise RuntimeError
def input_all_slip39_shares(
self,
shares: t.Sequence[str],
has_groups: bool = False,
click_info: bool = False,
+ method: messages.BackupMethod = messages.BackupMethod.Display,
) -> BRGeneratorType:
for index, share in enumerate(shares):
mnemonic = share.split(" ")
- yield from self.input_mnemonic(mnemonic)
+ yield from self.input_mnemonic(mnemonic, method)
# Caesar does not have the info button
if self.client.layout_type is LayoutType.Caesar:
@@ -727,3 +739,22 @@ class EthereumFlow:
else:
raise ValueError("Unknown model!")
+
+
+def n4w1_handle_write(debug: DebugLink) -> bytes:
+ assert debug._call(messages.DebugLinkN4W1Connected()) == messages.DebugLinkN4W1Read(
+ key="mnemonic"
+ )
+ write = debug._call(
+ messages.DebugLinkN4W1Response(), expect=messages.DebugLinkN4W1Write
+ )
+ assert write.key == "mnemonic" and write.value is not None
+ assert debug._call(messages.DebugLinkN4W1Response(), expect=messages.Success)
+ return write.value
+
+
+def n4w1_handle_read(debug: DebugLink, value: bytes | None) -> None:
+ assert debug._call(messages.DebugLinkN4W1Connected()) == messages.DebugLinkN4W1Read(
+ key="mnemonic"
+ )
+ debug._call(messages.DebugLinkN4W1Response(value=value), expect=messages.Success)
Why this scored 31/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.