Merge remote-tracking branch 'origin/dev-v2.4.0' into taproot-bip322-message-signing
What changed, and why it matters
This merge commit pulls in several defensive fixes for the Passport hardware wallet. The most important changes reduce the maximum passphrase length from 1000 to 256 characters so passphrases are not silently truncated when deriving a wallet, prevent backup files from restoring sensitive identity values (like the wallet fingerprint and extended public key) that could otherwise be forged, and fix a typo in the error-code list that was accidentally joining two error names together. It also adds missing early returns in backup verification so the same result is not reported twice. These are hardening fixes rather than a single obvious exploit, but they close real security-relevant bugs.
Treat this merge as a security-hardening release. Review the new unit tests for completeness, ensure the UI enforces the 256-character passphrase cap consistently, and verify that backup files created before this change cannot still influence xfp/xpub/root_xfp during restore. Consider whether any other settings should be added to the non-restorable list.
Security signals we found
Passphrase length capped to match KDF input limit, preventing silent truncation of BIP39 passphrases
Backup restore now refuses to restore wallet identity metadata (xfp, xpub, root_xfp) from backup and re-derives it from the restored secret
Missing comma in error-code tuple fixed; the bug had caused two error names to merge into one and become unreachable
Missing early returns added in verify_backup_task so error paths do not also emit a success callback
New unit tests explicitly model attacker-supplied xfp/xpub in a backup and assert they are not persisted
Evidence from the diff
The commit merges dev-v2.4.0 into the taproot-bip322 branch and brings in four security-relevant code changes: (1) MAX_PASSPHRASE_LENGTH lowered from 1000 to 256 because mnemonic_to_seed() only reads up to 256 bytes via strnlen, so longer user passphrases would be silently truncated and produce a non-portable/non-reproducible BIP39 seed; (2) restore_backup_task.py now filters out ‘bip39_passphrase’, ‘root_xfp’, ‘xfp’, and ‘xpub’ from restored settings and re-derives xfp/xpub from the restored secret with capture_xpub(save=True), preventing a malicious backup from overriding wallet identity metadata; (3) errors.py gets a missing comma restored between MULTISIG_STORAGE_IDX_ERROR and NOT_BIP39_MODE, which had silently concatenated the two names; (4) verify_backup_task.py adds return statements after error callbacks so on_done is not invoked twice. Unit tests are added for all four behaviors.
Changed components
ports/stm32/boards/Passport/modules/constants.pyports/stm32/boards/Passport/modules/tasks/restore_backup_task.pyports/stm32/boards/Passport/modules/tasks/verify_backup_task.pyports/stm32/boards/Passport/modules/errors.pyports/stm32/boards/Passport/modules/tests/unit/passphrase_length.pyports/stm32/boards/Passport/modules/tests/unit/restore_backup.pyports/stm32/boards/Passport/modules/tests/unit/error_codes.pyports/stm32/boards/Passport/modules/tests/test_verify_backup_task.pyInspect captured patch +416 / −18
### flake.lock
@@ -8,11 +8,11 @@
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
- "lastModified": 1771917501,
- "narHash": "sha256-pyjD5s19JzH/aw6OnjyxYddoQWD/F1ygj9bku9td+MI=",
+ "lastModified": 1790509533,
+ "narHash": "sha256-T53k4TC2+SAVcOVGuxiug6Z+1eXzYRsOIk9kTIA/FhU=",
"owner": "nix-community",
"repo": "fenix",
- "rev": "1d6ea44fd28bd5ad7cfd4bb39a7a225225484593",
+ "rev": "17e878932850137e2689686b809e973d36ee3f3e",
"type": "github"
},
"original": {
@@ -46,11 +46,11 @@
"rust-analyzer-src": {
"flake": false,
"locked": {
- "lastModified": 1771830288,
- "narHash": "sha256-Key/V7kjPYdPFeAw6cPS9M//6hGb6l1pLJroy7aUcfU=",
+ "lastModified": 1790305964,
+ "narHash": "sha256-2qzlUJB59Rq0XIjVGf42XqqaTwXoRLIFX4S7sW1t2iw=",
"owner": "rust-lang",
"repo": "rust-analyzer",
- "rev": "05da4cf3c4dfb8aa3430343f41e73c4cfad46bd5",
+ "rev": "1ad44dc58e65304b594063e70c144ecb58643671",
"type": "github"
},
"original": {
### flake.nix
@@ -102,7 +102,7 @@
fi
'';
}
- // lib.optionalAttrs pkgs.stdenv.isLinux {
+ // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
QT_QPA_PLATFORM = "xcb";
SDL_RENDER_DRIVER = "software";
}
### ports/stm32/boards/Passport/modules/constants.py
@@ -58,7 +58,13 @@
FLASH_CACHE_END_OLD = None
# Other constants
-MAX_PASSPHRASE_LENGTH = 1000
+
+# This is what mnemonic_to_seed() feeds to the KDF - it reads the passphrase with
+# strnlen(passphrase, 256) into a salt of 8 + 256 bytes. Entry is ASCII only, so
+# characters and bytes are the same count here. Do not raise it past what the KDF
+# reads: anything beyond would be dropped without the user being told, and the
+# wallet they got here would not be reproducible on any other BIP39 wallet.
+MAX_PASSPHRASE_LENGTH = 256
MAX_TEXT_INPUT_LENGTH = 1000
MAX_ACCOUNT_NAME_LEN = 20
MAX_MULTISIG_NAME_LEN = 20
### ports/stm32/boards/Passport/modules/errors.py
@@ -15,7 +15,7 @@
'INVALID_BACKUP_FILE_HEADER',
'MICROSD_FORMAT_ERROR',
'MICROSD_CARD_MISSING',
- 'MULTISIG_STORAGE_IDX_ERROR'
+ 'MULTISIG_STORAGE_IDX_ERROR',
'NOT_BIP39_MODE',
'OUT_OF_MEMORY_ERROR',
'PSBT_FATAL_ERROR',
### 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
+NON_RESTORABLE_BACKUP_SETTINGS = ('bip39_passphrase', 'root_xfp', 'xfp', 'xpub')
+
+
+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 NON_RESTORABLE_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)
### ports/stm32/boards/Passport/modules/tasks/verify_backup_task.py
@@ -39,7 +39,9 @@ async def verify_backup_task(on_done, backup_file_path):
fd.close()
except CardMissingError:
await on_done(Error.MICROSD_CARD_MISSING)
+ return
except Exception as e:
- await on_done(Error.FILE_READ_ERRROR)
+ await on_done(Error.FILE_READ_ERROR)
+ return
await on_done(None)
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -16,10 +16,18 @@ def doit(file):
return doit
+def test_error_codes(test):
+ assert test('error_codes.py') == b'OK'
+
+
def test_ext_settings(test):
assert test('ext_settings.py') == b'OK'
+def test_passphrase_length(test):
+ assert test('passphrase_length.py') == b'OK'
+
+
def test_psbt_multisig_approval(test):
assert test('psbt_multisig_approval.py') == b'OK'
@@ -56,5 +64,9 @@ def test_unchained(test):
assert test('unchained.py') == b'OK'
+def test_restore_backup(test):
+ assert test('restore_backup.py') == b'OK'
+
+
def test_bip322(test):
assert test('bip322.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/test_verify_backup_task.py
@@ -0,0 +1,107 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import asyncio
+import builtins
+import importlib.util
+import os
+import sys
+import types
+
+
+MODULES_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+TASK_PATH = os.path.join(MODULES_DIR, 'tasks', 'verify_backup_task.py')
+sys.path.insert(1, MODULES_DIR)
+
+from errors import Error
+
+
+class CardMissingError(Exception):
+ pass
+
+
+class CardSlot:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ return False
+
+
+def load_task(monkeypatch, card_slot=CardSlot, check_headers=None, files=None):
+ compat7z = types.ModuleType('compat7z')
+ compat7z.check_file_headers = check_headers or (lambda _fd: None)
+
+ class Builder:
+ def verify_file_crc(self, _fd, _max_size):
+ return files or [('passport-backup.txt', 1)]
+
+ compat7z.Builder = Builder
+
+ files_module = types.ModuleType('files')
+ files_module.CardSlot = card_slot
+ files_module.CardMissingError = CardMissingError
+
+ constants = types.ModuleType('constants')
+ constants.MAX_BACKUP_FILE_SIZE = 1024
+
+ monkeypatch.setitem(sys.modules, 'compat7z', compat7z)
+ monkeypatch.setitem(sys.modules, 'files', files_module)
+ monkeypatch.setitem(sys.modules, 'constants', constants)
+
+ spec = importlib.util.spec_from_file_location('verify_backup_task_under_test', TASK_PATH)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module.verify_backup_task
+
+
+def run_task(task):
+ results = []
+
+ async def on_done(error):
+ results.append(error)
+
+ asyncio.run(task(on_done, 'backup.7z'))
+ return results
+
+
+def test_success_reports_once(monkeypatch):
+ fd = types.SimpleNamespace(close=lambda: None)
+ monkeypatch.setattr(builtins, 'open', lambda *_args, **_kwargs: fd)
+
+ assert run_task(load_task(monkeypatch)) == [None]
+
+
+def test_card_removal_reports_once(monkeypatch):
+ class MissingCardSlot:
+ def __enter__(self):
+ raise CardMissingError
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ return False
+
+ assert run_task(load_task(monkeypatch, card_slot=MissingCardSlot)) == [Error.MICROSD_CARD_MISSING]
+
+
+def test_file_read_failure_reports_once(monkeypatch):
+ def fail_open(*_args, **_kwargs):
+ raise OSError('read failed')
+
+ monkeypatch.setattr(builtins, 'open', fail_open)
+
+ assert run_task(load_task(monkeypatch)) == [Error.FILE_READ_ERROR]
+
+
+def test_invalid_header_reports_once(monkeypatch):
+ fd = types.SimpleNamespace(close=lambda: None)
+ monkeypatch.setattr(builtins, 'open', lambda *_args, **_kwargs: fd)
+
+ def reject_header(_fd):
+ raise ValueError('invalid header')
+
+ assert run_task(load_task(monkeypatch, check_headers=reject_header)) == [Error.INVALID_BACKUP_FILE_HEADER]
+
+
+def test_related_error_members_are_available():
+ assert Error.MULTISIG_STORAGE_IDX_ERROR is not None
+ assert Error.NOT_BIP39_MODE is not None
### ports/stm32/boards/Passport/modules/tests/unit/error_codes.py
@@ -0,0 +1,19 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Error codes are built from a tuple of names, so a missing comma silently
+# concatenates two of them into one member. The intended names then don't
+# exist, and every reference to them raises AttributeError at runtime.
+
+from errors import Error
+
+
+for name in ('MULTISIG_STORAGE_IDX_ERROR', 'NOT_BIP39_MODE'):
+ assert hasattr(Error, name), name
+
+assert Error.MULTISIG_STORAGE_IDX_ERROR != Error.NOT_BIP39_MODE
+
+# The exact symptom of the missing comma this test was added for.
+assert not hasattr(Error, 'MULTISIG_STORAGE_IDX_ERRORNOT_BIP39_MODE')
+
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/tests/unit/passphrase_length.py
@@ -0,0 +1,48 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# MAX_PASSPHRASE_LENGTH is what the passphrase entry page is capped at, and it has
+# to match what mnemonic_to_seed() actually feeds to the KDF. A passphrase longer
+# than that derives a wallet no other BIP39 wallet would reproduce, so the two
+# numbers are pinned to each other here rather than left to drift.
+
+import trezorcrypto
+
+from constants import MAX_PASSPHRASE_LENGTH
+
+MNEMONIC = trezorcrypto.bip39.from_data(bytes(16))
+
+
+def seed_for(passphrase):
+ return trezorcrypto.bip39.seed(MNEMONIC, passphrase)
+
+
+at_cap = 'a' * MAX_PASSPHRASE_LENGTH
+one_short = 'a' * (MAX_PASSPHRASE_LENGTH - 1)
+
+# Every byte up to the cap reaches the KDF, so changing the last one changes the
+# seed. If the cap were above what the KDF reads, this would not hold.
+assert seed_for(at_cap) != seed_for(one_short + 'b')
+assert seed_for(at_cap) != seed_for(one_short)
+
+# One byte past the cap does not reach it, and nothing tells the user. That is the
+# whole reason entry is capped where it is.
+assert seed_for(at_cap) == seed_for(at_cap + 'b')
+assert seed_for(at_cap) == seed_for(at_cap + 'bbbbbbbbbb')
+
+# Ordinary passphrases are unaffected, including the empty one.
+assert seed_for('') != seed_for('a')
+assert seed_for('correct horse') != seed_for('correct horse ')
+
+# Entry is ASCII only - lower, upper, digits, space and the symbol picker - so a
+# character of input is always a byte of passphrase, and the cap can be counted in
+# either. Guard that, since a cap in characters over a KDF that reads bytes would
+# be the same defect again.
+KEYBOARD = ('abcdefghijklmnopqrstuvwxyz'
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+ '0123456789 '
+ '!@#$%^&*+/-=\\?|~_"`\',.:;()[]{}<>')
+
+assert len(KEYBOARD.encode()) == len(KEYBOARD)
+
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/tests/unit/restore_backup.py
@@ -0,0 +1,192 @@
+# SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+#
+# 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.bip39_passphrase': 'runtime-only',
+ 'setting.units': 'sats',
+ 'setting.backup_quiz': True,
+}
+
+settings = FakeSettings()
+restore_settings_from_backup(vals, settings)
+
+assert settings.values == {
+ 'units': 'sats',
+ 'backup_quiz': True,
+}
+
+
+# Restore the whole task and read back what was persisted, which the filtering
+# test above cannot do: capture_xpub() shadows xfp/xpub with volatile values, so
+# only clearing those and reading the persisted store shows whether save=True
+# actually wrote the derived identity.
+
+import chains
+import common
+import stash
+import trezorcrypto
+import uasyncio as asyncio
+
+import tasks.restore_backup_task as restore_module
+from tasks.restore_backup_task import restore_backup_task
+from ubinascii import hexlify as b2a_hex
+
+
+ATTACKER_XFP = 0x11111111
+ATTACKER_XPUB = 'attacker-xpub'
+
+seed_bits = bytes(range(16))
+secret = stash.SecretStash.encode(seed_bits=seed_bits)
+
+# Derive the expected identity here rather than reusing capture_xpub(), so the
+# assertions below fail if the task persists anything other than the identity
+# belonging to the restored secret.
+expected_node = trezorcrypto.bip32.from_seed(
+ trezorcrypto.bip39.seed(trezorcrypto.bip39.from_data(seed_bits), ''), 'secp256k1')
+expected_xfp = expected_node.my_fingerprint()
+expected_xpub = chains.get_chain('BTC').serialize_public(expected_node)
+
+assert expected_xfp != ATTACKER_XFP
+
+BACKUP_LINES = (
+ '# Passport backup file',
+ 'raw_secret = "%s"' % b2a_hex(secret).decode(),
+ 'chain = "BTC"',
+ 'setting.xfp = 286331153', # 0x11111111
+ 'setting.xpub = "attacker-xpub"',
+ 'setting.root_xfp = 572662306', # 0x22222222
+ 'setting.bip39_passphrase = "runtime-only"',
+ 'setting.units = "sats"',
+ 'setting.backup_quiz = true',
+)
+BACKUP_CONTENTS = ('\n'.join(BACKUP_LINES) + '\n').encode()
+
+
+class StoredSettings:
+ # Models the split the real settings object has: set() persists, and
+ # set_volatile() shadows it until the overrides are cleared.
+ def __init__(self):
+ self.persisted = {}
+ self.volatile = {}
+ self.saves = 0
+
+ def get(self, key, default=None):
+ if key in self.volatile:
+ return self.volatile[key]
+ return self.persisted.get(key, default)
+
+ def set(self, key, value):
+ self.persisted[key] = value
+
+ def set_volatile(self, key, value):
+ self.volatile[key] = value
+
+ def save(self):
+ self.saves += 1
+
+ def clear_volatile(self):
+ self.volatile.clear()
+
+
+class FakePa:
+ def __init__(self):
+ self.calls = []
+
+ def change(self, new_secret=None):
+ self.calls.append('change')
+
+ async def new_main_secret(self, raw, chain):
+ self.calls.append('new_main_secret')
+
+
+class FakeCardSlot:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_args):
+ return False
+
+
+class FakeFile:
+ def close(self):
+ pass
+
+
+class FakeBuilder:
+ def read_file(self, fd, password, maxsize, progress_fcn=None):
+ return ('backup.txt', BACKUP_CONTENTS)
+
+
+class FakeCompat7z:
+ Builder = FakeBuilder
+
+ @staticmethod
+ def check_file_headers(fd):
+ pass
+
+
+async def run_restore():
+ results = []
+
+ async def on_done(error):
+ results.append(error)
+
+ await restore_backup_task(on_done, 'password', '/sd/backup.7z')
+ return results
+
+
+stored = StoredSettings()
+pa = FakePa()
+
+# Set the module-level open() before the try, so the finally can always undo it.
+restore_module.open = lambda path, mode: FakeFile()
+originals = (common.settings, common.pa, restore_module.compat7z, restore_module.CardSlot)
+try:
+ common.settings = stored
+ common.pa = pa
+ restore_module.compat7z = FakeCompat7z
+ restore_module.CardSlot = FakeCardSlot
+
+ assert asyncio.run(run_restore()) == [None]
+finally:
+ common.settings, common.pa, restore_module.compat7z, restore_module.CardSlot = originals
+ del restore_module.open
+
+assert pa.calls == ['change', 'new_main_secret']
+
+# The volatile copies hide whether anything was persisted, so drop them first.
+stored.clear_volatile()
+
+# Identity comes from the restored secret, never from the backup.
+assert stored.persisted['xfp'] == expected_xfp
+assert stored.persisted['xpub'] == expected_xpub
+assert stored.persisted['xfp'] != ATTACKER_XFP
+assert stored.persisted['xpub'] != ATTACKER_XPUB
+assert stored.saves >= 1
+
+# Ordinary settings survive, the quiz is reset, and runtime-only values are dropped.
+assert stored.persisted['units'] == 'sats'
+assert stored.persisted['backup_quiz'] is False
+assert 'root_xfp' not in stored.persisted
+assert 'bip39_passphrase' not in stored.persisted
+
+return_value.write(b'OK')Why this scored 61/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.