Validate SeedQR payloads before word lookup
What changed, and why it matters
This commit tightens how Passport firmware decodes SeedQR codes—QR codes that encode a wallet recovery phrase as numbers. Before the fix, the decoder would try to look up BIP-39 words using raw 4-digit indices before confirming the input was valid. The new code first checks that the payload length is a multiple of 4, that the number of words is a supported seed length, that every character is a digit, and that each index is within the valid BIP-39 word range. This prevents malformed or out-of-range SeedQR payloads from being partially processed or causing unexpected behavior during word lookup.
Treat this as a defensive hardening fix. Review whether any prior firmware version could be induced to accept a malformed SeedQR (e.g., via camera scan or imported image) and confirm the decoder returns None safely. Consider whether the broad try/except should log or surface specific failure modes for debugging without exposing sensitive data.
Security signals we found
Input validation added before cryptographic/word-list lookup
Out-of-range BIP-39 index now rejected explicitly
Non-digit and malformed-length SeedQR payloads now rejected
Unit tests added for both valid and invalid SeedQR decoding
Evidence from the diff
SeedQRDecoder.decode() in seedqr_codec.py was refactored to validate the payload before iterating over word indices. Added checks: len(data) % 4 == 0; num_words in SEED_LENGTHS; all characters are decimal digits; each 4-digit index < SEED_WORD_LIST_LENGTH (2048). The previous code computed num_words, looked up words, and only then checked whether the resulting seed length was valid, and it relied on a broad try/except for error handling. The patch also adds unit tests covering valid 12- and 24-word SeedQRs and several malformed inputs (out-of-range index, non-digit characters, bad lengths).
Changed components
ports/stm32/boards/Passport/modules/data_codecs/seedqr_codec.pyports/stm32/boards/Passport/modules/tests/unit/seedqr_codec.pyports/stm32/boards/Passport/modules/tests/test_unit.pyInspect captured patch +56 / −6
### ports/stm32/boards/Passport/modules/data_codecs/seedqr_codec.py
@@ -13,7 +13,7 @@
from .data_decoder import DataDecoder
from .data_sampler import DataSampler
from .qr_type import QRType
-from public_constants import SEED_LENGTHS
+from public_constants import SEED_LENGTHS, SEED_WORD_LIST_LENGTH
class SeedQRDecoder(DataDecoder):
@@ -33,16 +33,28 @@ def decode(self, **kwargs):
import trezorcrypto
try:
+ if len(self.data) % 4 != 0:
+ return None
+
+ num_words = len(self.data) // 4
+ if num_words not in SEED_LENGTHS:
+ return None
+
+ if any(c < '0' or c > '9' for c in self.data):
+ return None
+
seed_phrase = []
- num_words = int(len(self.data) / 4)
for i in range(0, num_words):
index = int(self.data[i * 4: (i * 4) + 4])
+ if index >= SEED_WORD_LIST_LENGTH:
+ return None
+
word = trezorcrypto.bip39.get_word(index)
+ if word is None:
+ return None
+
seed_phrase.append(word)
- if len(seed_phrase) in SEED_LENGTHS:
- return seed_phrase
- else:
- return None
+ return seed_phrase
except Exception as e:
return None
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -24,6 +24,10 @@ def test_psbt_multisig_approval(test):
assert test('psbt_multisig_approval.py') == b'OK'
+def test_seedqr_codec(test):
+ assert test('seedqr_codec.py') == b'OK'
+
+
def test_ui(test):
assert test('ui.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/seedqr_codec.py
@@ -0,0 +1,34 @@
+# SPDX-FileCopyrightText: 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from data_codecs.seedqr_codec import SeedQRDecoder
+
+
+def decode(data):
+ decoder = SeedQRDecoder()
+ decoder.add_data(data)
+ return decoder.decode()
+
+
+valid_12 = '0000' * 12
+valid_24 = '2047' * 24
+
+assert decode(valid_12) == ['abandon'] * 12
+assert decode(valid_24) == ['zoo'] * 24
+
+malformed = (
+ '2048' + ('0000' * 11),
+ '9999' + ('0000' * 11),
+ 'abcd' + ('0000' * 11),
+ '-001' + ('0000' * 11),
+ ' 001' + ('0000' * 11),
+ valid_12 + '0',
+ valid_12 + '000',
+ valid_24 + '0',
+)
+
+for data in malformed:
+ assert decode(data) is None
+
+return_value.write(b'OK')Why this scored 59/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.