Skip to content

Commit 89f6b40

Browse files
committed
feat(core): add N4W1 backup/recovery flows
Can be manually tested by invoking: ``` $ core/emu.py -ea -c trezorctl device setup -b shamir # will run multi-share backup $ core/tools/n1w1-emu.py run 127.0.0.1:21325 /tmp/tagN # simulate tag connection and I/O ``` Enabled N4W1-based backup/recovery device tests for SLIP-39 single group scenarios. Other device & click tests will be added in subsequent PRs. [no changelog]
1 parent 2d9bfd2 commit 89f6b40

18 files changed

Lines changed: 685 additions & 131 deletions

.github/workflows/core.yml

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,28 +47,34 @@ jobs:
4747
cat $GITHUB_OUTPUT
4848
4949
core_firmware:
50-
name: Build firmware (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }})
50+
name: Build firmware (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}${{matrix.n4w1 && ', n4w1' || '' }})
5151
runs-on: ubuntu-latest
5252
strategy:
5353
fail-fast: false
5454
matrix:
5555
model: ${{ fromJSON(github.event_name == 'push' && '["T2B1", "T2T1", "T3B1", "T3T1", "T3W1"]' || '["T2T1", "T3B1", "T3T1", "T3W1"]') }}
5656
coins: [universal, btconly]
5757
type: ${{ fromJSON(github.event_name == 'schedule' && '["normal", "debuglink", "production"]' || '["normal", "debuglink"]') }}
58+
n4w1: [false]
5859
include:
5960
- model: D001
6061
coins: universal
6162
type: normal
6263
- model: T2B1
6364
coins: universal
6465
type: normal
66+
- model: T3W1
67+
coins: universal
68+
type: debuglink
69+
n4w1: true # currently N4W1 is not supported for normal builds
6570
exclude:
6671
- model: T3W1
6772
type: production
6873
env:
6974
TREZOR_MODEL: ${{ matrix.model }}
7075
BITCOIN_ONLY: ${{ matrix.coins == 'universal' && '0' || '1' }}
7176
PYOPT: ${{ matrix.type == 'debuglink' && '0' || '1' }}
77+
N4W1: ${{ matrix.n4w1 && '1' || '0' }}
7278
PRODUCTION: ${{ matrix.type == 'production' && '1' || '0' }}
7379
BOOTLOADER_DEVEL: ${{ matrix.model == 'T3W1' && '1' || '0' }}
7480
QUIET_MODE: 1
@@ -96,7 +102,7 @@ jobs:
96102
if: matrix.coins == 'btconly' && matrix.type != 'debuglink'
97103
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
98104
with:
99-
name: core-firmware-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}
105+
name: core-firmware-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}${{ matrix.n4w1 && '-n4w1' || '' }}
100106
path: |
101107
core/build/boardloader/*.bin
102108
core/build/bootloader/*.bin
@@ -109,7 +115,7 @@ jobs:
109115
retention-days: 7
110116

111117
core_emu:
112-
name: Build emu (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}, ${{ matrix.asan }})
118+
name: Build emu (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}, ${{ matrix.asan }}${{matrix.n4w1 && ', n4w1' || '' }})
113119
runs-on: ubuntu-latest
114120
needs: param
115121
strategy:
@@ -120,13 +126,21 @@ jobs:
120126
# type: [normal, debuglink]
121127
type: ${{ fromJSON(github.event_name == 'schedule' && '["normal", "debuglink"]' || '["debuglink"]') }}
122128
asan: ${{ fromJSON(needs.param.outputs.asan) }}
129+
n4w1: [false]
123130
exclude:
124131
- type: normal
125132
asan: asan
133+
include:
134+
- model: T3W1
135+
coins: universal
136+
type: debuglink
137+
asan: noasan
138+
n4w1: true # currently N4W1 is not supported for normal builds
126139
env:
127140
TREZOR_MODEL: ${{ matrix.model }}
128141
BITCOIN_ONLY: ${{ matrix.coins == 'universal' && '0' || '1' }}
129142
PYOPT: ${{ matrix.type == 'debuglink' && '0' || '1' }}
143+
N4W1: ${{ matrix.n4w1 && '1' || '0' }}
130144
ADDRESS_SANITIZER: ${{ matrix.asan == 'asan' && '1' || '0' }}
131145
LSAN_OPTIONS: "suppressions=../../asan_suppressions.txt"
132146
QUIET_MODE: 1
@@ -141,10 +155,10 @@ jobs:
141155
- run: nix-shell --run "uv run make -C core build_unix_frozen"
142156
- 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 &"
143157
- run: nix-shell --run "uv run make -C core test_emu_sanity"
144-
- run: cp core/build/unix/trezor-emu-core core/build/unix/trezor-emu-core-${{ matrix.model }}-${{ matrix.coins }}
158+
- run: cp core/build/unix/trezor-emu-core core/build/unix/trezor-emu-core-${{ matrix.model }}-${{ matrix.coins }}${{ matrix.n4w1 && '-n4w1' || '' }}
145159
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
146160
with:
147-
name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}
161+
name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
148162
path: |
149163
core/build/unix/trezor-emu-core*
150164
core/build/bootloader_emu/bootloader.elf
@@ -153,7 +167,7 @@ jobs:
153167

154168
core_emu_arm:
155169
if: github.event_name == 'schedule'
156-
name: Build emu arm
170+
name: Build emu ARM (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.type }}, ${{ matrix.asan }}${{matrix.n4w1 && ', n4w1' || '' }})
157171
runs-on: ubuntu-latest-arm64
158172
needs: param
159173
strategy:
@@ -163,10 +177,18 @@ jobs:
163177
coins: [universal]
164178
type: [debuglink]
165179
asan: [noasan]
180+
n4w1: [false]
181+
include:
182+
- model: T3W1
183+
coins: universal
184+
type: debuglink
185+
asan: noasan
186+
n4w1: true # currently N4W1 is not supported for normal builds
166187
env:
167188
TREZOR_MODEL: ${{ matrix.model }}
168189
BITCOIN_ONLY: ${{ matrix.coins == 'universal' && '0' || '1' }}
169190
PYOPT: ${{ matrix.type == 'debuglink' && '0' || '1' }}
191+
N4W1: ${{ matrix.n4w1 && '1' || '0' }}
170192
ADDRESS_SANITIZER: ${{ matrix.asan == 'asan' && '1' || '0' }}
171193
LSAN_OPTIONS: "suppressions=../../asan_suppressions.txt"
172194
QUIET_MODE: 1
@@ -178,10 +200,10 @@ jobs:
178200
- run: nix-shell --run "uv run make -C core build_bootloader_emu"
179201
if: matrix.coins == 'universal'
180202
- run: nix-shell --run "uv run make -C core build_unix_frozen"
181-
- run: mv core/build/unix/trezor-emu-core core/build/unix/trezor-emu-arm-core-${{ matrix.model }}-${{ matrix.coins }}
203+
- run: mv core/build/unix/trezor-emu-core core/build/unix/trezor-emu-arm-core-${{ matrix.model }}-${{ matrix.coins }}${{ matrix.n4w1 && '-n4w1' || '' }}
182204
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
183205
with:
184-
name: core-emu-arm-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}
206+
name: core-emu-arm-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.type }}-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
185207
path: |
186208
core/build/unix/trezor-emu-*
187209
core/build/bootloader_emu/bootloader.elf
@@ -268,7 +290,7 @@ jobs:
268290
# See artifacts for a comprehensive report of UI.
269291
# See [docs/tests/ui-tests](../tests/ui-tests.md) for more info.
270292
core_device_test:
271-
name: Device tests (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.asan }}, ${{ matrix.lang }})
293+
name: Device tests (${{ matrix.model }}, ${{ matrix.coins }}, ${{ matrix.asan }}, ${{ matrix.lang }}${{ matrix.n4w1 && ', n4w1' || '' }})
272294
runs-on: ubuntu-latest
273295
needs:
274296
- param
@@ -280,6 +302,13 @@ jobs:
280302
coins: [universal, btconly]
281303
asan: ${{ fromJSON(needs.param.outputs.asan) }}
282304
lang: ${{ fromJSON(needs.param.outputs.test_lang) }}
305+
n4w1: [false]
306+
include:
307+
- model: T3W1
308+
coins: universal
309+
asan: noasan
310+
lang: en
311+
n4w1: true
283312
env:
284313
TREZOR_PROFILING: ${{ matrix.asan == 'noasan' && '1' || '0' }}
285314
TREZOR_MODEL: ${{ matrix.model }}
@@ -295,19 +324,24 @@ jobs:
295324
submodules: recursive
296325
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
297326
with:
298-
name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-debuglink-${{ matrix.asan }}
327+
name: core-emu-${{ matrix.model }}-${{ matrix.coins }}-debuglink-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
299328
path: core/build
300329
- run: chmod +x core/build/unix/trezor-emu-core*
301330
- uses: ./.github/actions/environment
302331
- name: Start Tropic model
303332
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
304333
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 &"
305-
- run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_ui_multicore' || 'test_emu' }}"
334+
- name: Run device tests
335+
if: ${{ !matrix.n4w1 }}
336+
run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_ui_multicore' || 'test_emu' }}"
337+
- name: Run device tests (N4W1)
338+
if: ${{ matrix.n4w1 }} # TODO: test N4W1 UI fixtures as well
339+
run: nix-shell --run "uv run make -C core ${{ env.ACTIONS_DO_UI_TEST == 'true' && 'test_emu_multicore' || 'test_emu' }}"
306340
- run: tail -v -n50 tests/trezor*.log || true
307341
if: failure()
308342
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # actions/upload-artifact@v7.0.0
309343
with:
310-
name: core-test-device-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.lang }}-${{ matrix.asan }}
344+
name: core-test-device-${{ matrix.model }}-${{ matrix.coins }}-${{ matrix.lang }}-${{ matrix.asan }}${{ matrix.n4w1 && '-n4w1' || '' }}
311345
path: tests/trezor*.log
312346
retention-days: 7
313347
if: always()
@@ -316,9 +350,10 @@ jobs:
316350
model: ${{ matrix.model }}
317351
lang: ${{ matrix.lang }}
318352
status: ${{ job.status }}
319-
if: ${{ always() && env.ACTIONS_DO_UI_TEST == 'true' }}
353+
if: ${{ always() && env.ACTIONS_DO_UI_TEST == 'true' && !matrix.n4w1 }}
320354
continue-on-error: true
321355
- uses: ./.github/actions/upload-coverage
356+
if: ${{ !matrix.n4w1 }}
322357

323358
# Click tests - UI.
324359
# See [docs/tests/click-tests](../tests/click-tests.md) for more info.
@@ -769,7 +804,7 @@ jobs:
769804
steps:
770805
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
771806
with:
772-
pattern: core-emu*debuglink-noasan
807+
pattern: core-emu*debuglink-noasan*
773808
merge-multiple: true
774809
- name: Configure aws credentials
775810
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # aws-actions/configure-aws-credentials@v6.0.0
@@ -792,7 +827,7 @@ jobs:
792827
steps:
793828
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # actions/download-artifact@v8.0.0
794829
with:
795-
pattern: core-emu*debuglink-noasan
830+
pattern: core-emu*debuglink-noasan*
796831
merge-multiple: true
797832
- name: Configure aws credentials
798833
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # aws-actions/configure-aws-credentials@v6.0.0

core/src/apps/management/recovery_device/layout.py

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import storage.recovery as storage_recovery
44
import storage.recovery_shares as storage_recovery_shares
5-
from trezor import TR
5+
from trezor import TR, utils
66
from trezor.ui.layouts.recovery import ( # noqa: F401
77
request_word_count,
88
show_already_added,
@@ -278,12 +278,141 @@ async def request_mnemonic(self) -> str | None:
278278
return None
279279

280280

281-
async def choose_handler(method: BackupMethod | None) -> type[RecoveryHandler]:
282-
from trezor.enums import BackupMethod
281+
if not utils.USE_N4W1:
283282

284-
if method is not BackupMethod.Display and __debug__:
285-
from trezor import log
283+
async def choose_handler(method: BackupMethod | None) -> type[RecoveryHandler]:
284+
from trezor.enums import BackupMethod
286285

287-
log.warning(__name__, "Unsupported backup method: %s", method)
286+
if method is not BackupMethod.Display and __debug__:
287+
from trezor import log
288288

289-
return _DisplayHandler
289+
log.warning(__name__, "Unsupported backup method: %s", method)
290+
291+
return _DisplayHandler
292+
293+
else:
294+
295+
if TYPE_CHECKING:
296+
from trezor.messages import BackupMethod
297+
298+
from .recover import Slip39State
299+
300+
async def choose_handler(method: BackupMethod | None) -> type[RecoveryHandler]:
301+
from trezor.enums import BackupMethod
302+
303+
if method is None:
304+
from trezor.ui.layouts.recovery import choose_method
305+
306+
method = await choose_method(TR.recovery__title, TR.backup__type_have)
307+
308+
if method is BackupMethod.N4W1:
309+
return _N4W1Handler
310+
311+
if method not in (None, BackupMethod.Display):
312+
from trezor import log
313+
314+
if __debug__:
315+
log.warning(__name__, "Unsupported backup method: %s", method)
316+
317+
return _DisplayHandler
318+
319+
class RetryRead(Exception):
320+
def __init__(self, msg: str) -> None:
321+
self.msg = msg
322+
323+
async def _read_share() -> str:
324+
from apps.debug import n4w1_mock
325+
326+
with n4w1_mock.ctx as ctx:
327+
# returns `None` on cancellation or retriable error.
328+
await ctx.confirm_connect(
329+
title=TR.recovery__title,
330+
description=TR.n4w1__hold_next,
331+
button=TR.n4w1__footer_next,
332+
br_name="backup_read",
333+
)
334+
# continue N4W1 communication (the tag is connected)
335+
336+
# TODO: animate during read?
337+
if (blob := await ctx.read(key="mnemonic")) is None:
338+
raise RetryRead(TR.n4w1__err_empty)
339+
340+
# TODO: use protobuf?
341+
blob = bytes(blob)
342+
try:
343+
return blob.decode()
344+
except ValueError:
345+
raise RetryRead(TR.n4w1__err_damaged)
346+
347+
class _N4W1Handler:
348+
def __init__(
349+
self,
350+
recovery_type: RecoveryType,
351+
slip39_state: Slip39State | None,
352+
) -> None:
353+
super().__init__()
354+
self.recovery_type = recovery_type
355+
# `slip39_state is None` indicates that we are (re)starting the first recovery step.
356+
self.slip39_state = slip39_state
357+
358+
@classmethod
359+
async def load(cls, recovery_type: RecoveryType) -> "RecoveryHandler":
360+
return cls(recovery_type, load_slip39_state())
361+
362+
async def show_state(self, is_retry: bool) -> None:
363+
if is_retry or self.slip39_state is None:
364+
# don't show recovery state on retries and before the first share is entered
365+
return
366+
word_count = self.slip39_state[0]
367+
await _request_share_first_screen(word_count, self.recovery_type)
368+
369+
async def request_mnemonic(self) -> str | None:
370+
"""Return the mnemonic or `None` on cancellation/validation error."""
371+
import trezorui_api
372+
from trezor.ui.layouts.common import raise_if_not_confirmed
373+
374+
while True:
375+
try:
376+
share = await _read_share()
377+
break
378+
except RetryRead as exc:
379+
await raise_if_not_confirmed(
380+
trezorui_api.show_warning(
381+
title=TR.words__important,
382+
button=TR.buttons__continue,
383+
description=exc.msg,
384+
danger=True,
385+
),
386+
br_name="recovery_retry",
387+
)
388+
# wait for a new N4W1 tag
389+
continue
390+
391+
return await self.check_words(share)
392+
393+
async def check_words(self, share: str) -> str | None:
394+
from trezor.ui.layouts.progress import progress
395+
396+
from .word_validity import WordValidityResult, check
397+
398+
# Can be `None` when checking the first share.
399+
backup_type = self.slip39_state and self.slip39_state[1]
400+
share_words = share.split(" ")
401+
402+
progress_obj = progress(description=TR.n4w1__reading)
403+
progress_obj.start()
404+
405+
try:
406+
# Re-verify mnemonic prefixes:
407+
steps = len(share_words)
408+
for prefix_len in range(1, 1 + steps):
409+
progress_obj.report((1000 * prefix_len) // steps)
410+
check(backup_type, partial_mnemonic=share_words[:prefix_len])
411+
412+
return share
413+
except WordValidityResult as exc:
414+
# if they were invalid or some checks failed we continue and request them again
415+
await exc.show_error()
416+
return None
417+
finally:
418+
progress_obj.stop()

0 commit comments

Comments
 (0)