Merge pull request #702 from Foundation-Devices/SFT-7378-passphrase-length-cap
What changed, and why it matters
This commit fixes a design flaw in the Passport hardware wallet where users could enter a passphrase longer than the wallet's key-derivation function actually reads. Previously, extra characters were silently ignored, meaning two different long passphrases could unlock the same wallet. The fix caps passphrase entry at 256 characters and adds a test to ensure the cap matches what the cryptographic code reads. It is a correctness and interoperability fix rather than a remote hack, but it prevents users from accidentally creating wallets that cannot be reproduced on other BIP39 wallets.
Treat this as a security-hardening fix and ship it in the next firmware release. Notify users who may have created passphrases longer than 256 characters that only the first 256 characters were used, and advise them to migrate funds to a wallet derived from a passphrase within the supported length. No immediate remote exploitation is indicated.
Security signals we found
silent truncation of user-controlled secret input
BIP39 seed derivation mismatch with other wallets
UI input limit did not match cryptographic read limit
added regression test pinning MAX_PASSPHRASE_LENGTH to KDF behavior
Evidence from the diff
MAX_PASSPHRASE_LENGTH was reduced from 1000 to 256 because mnemonic_to_seed() internally reads the passphrase with strnlen(passphrase, 256) into an 8+256 byte salt. Any characters beyond 256 were dropped without warning, so a user typing a 257+ character passphrase would derive the same seed as one typing only the first 256 characters. The patch aligns the UI entry cap with the KDF read length and adds a unit test proving that (a) every byte up to the cap affects the seed, (b) bytes beyond the cap do not, and (c) ASCII-only entry keeps character count equal to byte count.
Changed components
Passport firmware passphrase entry UIBIP39 mnemonic-to-seed key derivationconstants.MAX_PASSPHRASE_LENGTHunit test suiteInspect captured patch +59 / −1
### 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/tests/test_unit.py
@@ -24,6 +24,10 @@ 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'
### 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')Why this scored 60/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.