From 54167203d6a9295e85d6a2b82e6e529432238189 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 23 Jul 2026 15:07:56 +0200 Subject: [PATCH 1/2] Harden backup restore settings --- .../modules/tasks/restore_backup_task.py | 28 ++++++++++----- .../Passport/modules/tests/test_unit.py | 4 +++ .../modules/tests/unit/restore_backup.py | 36 +++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py diff --git a/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py b/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py index 5c170db47..4aa1dcef2 100644 --- a/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py +++ b/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py @@ -20,6 +20,20 @@ from constants import MAX_BACKUP_FILE_SIZE from pincodes import SE_SECRET_LEN +DERIVED_BACKUP_SETTINGS = ('xfp', 'xpub', 'root_xfp') + + +def restore_settings_from_backup(vals, settings): + for k in vals: + if not k.startswith('setting.'): + continue + + setting_key = k[8:] + if setting_key in DERIVED_BACKUP_SETTINGS: + continue + + settings.set(setting_key, vals[k]) + async def restore_backup_task(on_done, decryption_password, backup_file_path): from common import pa, settings @@ -114,17 +128,15 @@ async def restore_backup_task(on_done, decryption_password, backup_file_path): await pa.new_main_secret(raw, chain) # Finally, restore the settings - for idx, k in enumerate(vals): - if not k.startswith('setting.'): - continue - - if k == 'xfp' or k == 'xpub': - continue - - settings.set(k[8:], vals[k]) + restore_settings_from_backup(vals, settings) # This would be true in the old backup, but false for this new device settings.set('backup_quiz', False) + # Wallet identity metadata is derived from the restored secret, never trusted from the backup. + with stash.SensitiveValues(raw) as sv: + sv.chain = chain + sv.capture_xpub(save=True) + # Success! await on_done(None) diff --git a/ports/stm32/boards/Passport/modules/tests/test_unit.py b/ports/stm32/boards/Passport/modules/tests/test_unit.py index a088f75fb..3acf2050d 100644 --- a/ports/stm32/boards/Passport/modules/tests/test_unit.py +++ b/ports/stm32/boards/Passport/modules/tests/test_unit.py @@ -26,3 +26,7 @@ def test_ui(test): def test_foundation(test): assert test('foundation.py') == b'OK' + + +def test_restore_backup(test): + assert test('restore_backup.py') == b'OK' diff --git a/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py b/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py new file mode 100644 index 000000000..2a3fa6f72 --- /dev/null +++ b/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Test backup restore settings filtering. + +from tasks.restore_backup_task import restore_settings_from_backup + + +class FakeSettings: + def __init__(self): + self.values = {} + + def set(self, key, value): + self.values[key] = value + + +vals = { + 'chain': 'BTC', + 'xfp': 'top-level metadata is ignored here', + 'setting.xfp': 0x11111111, + 'setting.xpub': 'attacker-xpub', + 'setting.root_xfp': 0x22222222, + 'setting.units': 'sats', + 'setting.backup_quiz': True, +} + +settings = FakeSettings() +restore_settings_from_backup(vals, settings) + +assert settings.values == { + 'units': 'sats', + 'backup_quiz': True, +} + +return_value.write(b'OK') From a146b22578c9dfe6abaf76364256ffd99f2eefcd Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 23 Jul 2026 16:35:36 +0200 Subject: [PATCH 2/2] Ignore runtime-only backup settings --- .../boards/Passport/modules/tasks/restore_backup_task.py | 4 ++-- .../boards/Passport/modules/tests/unit/restore_backup.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py b/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py index 4aa1dcef2..5f71c9629 100644 --- a/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py +++ b/ports/stm32/boards/Passport/modules/tasks/restore_backup_task.py @@ -20,7 +20,7 @@ from constants import MAX_BACKUP_FILE_SIZE from pincodes import SE_SECRET_LEN -DERIVED_BACKUP_SETTINGS = ('xfp', 'xpub', 'root_xfp') +NON_RESTORABLE_BACKUP_SETTINGS = ('bip39_passphrase', 'root_xfp', 'xfp', 'xpub') def restore_settings_from_backup(vals, settings): @@ -29,7 +29,7 @@ def restore_settings_from_backup(vals, settings): continue setting_key = k[8:] - if setting_key in DERIVED_BACKUP_SETTINGS: + if setting_key in NON_RESTORABLE_BACKUP_SETTINGS: continue settings.set(setting_key, vals[k]) diff --git a/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py b/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py index 2a3fa6f72..5e03a6084 100644 --- a/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py +++ b/ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py @@ -21,6 +21,7 @@ def set(self, key, value): 'setting.xfp': 0x11111111, 'setting.xpub': 'attacker-xpub', 'setting.root_xfp': 0x22222222, + 'setting.bip39_passphrase': 'runtime-only', 'setting.units': 'sats', 'setting.backup_quiz': True, }