Merge pull request #711 from Foundation-Devices/SFT-8212-ur-type-checking
What changed, and why it matters
This commit hardens how Passport firmware handles QR-code and UR (Uniform Resource) data when importing multisig wallet configurations. It replaces low-level 'mp_check_self' type assertions with explicit Python-style error checks, adds a new QR-scan flow that only accepts expected UR types, and adds unit tests to make sure wrong types are rejected. The changes reduce the chance that a malformed or unexpected QR/UR could crash the device or be misinterpreted as wallet data.
Treat as a defensive hardening/fix commit. Review whether the new ScanQRFlow type restrictions are applied consistently across all QR/UR import flows, and verify the new unit tests run in CI. No immediate incident response is indicated by the diff alone.
Security signals we found
Replaces low-level assertions with explicit typed exceptions, reducing crash surface from malformed inputs
Adds UR type filtering (ur.Value.BYTES only) for multisig QR imports
Adds regression tests for invalid argument types and wrong UR variant unwraps
Adds CI rule to enforce explicit validation in foundation bindings
Fixes handling of QR scan errors (Error.QR_TOO_LARGE, Error.PSBT_OVERSIZED) in connect wallet flow
Evidence from the diff
The patch removes use of MicroPython’s mp_check_self() for argument/variant validation in extmod/foundation bindings (bip39 and ur) and replaces it with explicit mp_raise_TypeError/mp_raise_msg calls. It also updates connect_wallet_flow.py to handle the new QRScanResult object and unwrap bytes URs safely, and changes wallets/multisig_import.py to use ScanQRFlow restricted to QRType.QR/QRType.UR2 and ur.Value.BYTES. New unit tests pin the expected exception types for invalid arguments and UR variants. A CI lint job is added to prevent reintroduction of mp_check_self in extmod/foundation/.
Changed components
extmod/foundation/modfoundation-bip39.hextmod/foundation/modfoundation-ur.hports/stm32/boards/Passport/modules/flows/connect_wallet_flow.pyports/stm32/boards/Passport/modules/wallets/multisig_import.pyports/stm32/boards/Passport/modules/tests/unit/binding_argument_checks.pyports/stm32/boards/Passport/modules/tests/unit/multisig_qr_import.py.github/workflows/lint.yamlInspect captured patch +265 / −23
### .github/workflows/lint.yaml
@@ -18,6 +18,26 @@ jobs:
- uses: actions/checkout@v6
- run: make -C extmod/quirc/tests test
+ # The foundation bindings validate their arguments with explicit raises.
+ # mp_check_self is not the mechanism for that here: it addresses a different
+ # case, which the interpreter already covers for us. Keep it out of this tree.
+ #
+ # Scoped to our own bindings on purpose. Uses under extmod/trezor-firmware/
+ # are upstream's and are correct for what they do.
+ bindings-validate-explicitly:
+ name: Do the foundation bindings validate explicitly?
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - run: |
+ if grep -rn 'mp_check_self' extmod/foundation/; then
+ echo "::error::Validate explicitly in extmod/foundation/:"\
+ "mp_raise_TypeError() for a wrong argument type,"\
+ "mp_raise_msg(&mp_type_ValueError, ...) for a wrong variant or"\
+ "an absent optional field. See SFT-8229."
+ exit 1
+ fi
+
rust-code-compiles:
name: Rust code compiles?
runs-on: ubuntu-latest
### extmod/foundation/modfoundation-bip39.h
@@ -25,7 +25,9 @@ extern word_info_t bytewords_word_info[]; // TODO: Restructure this so bip39 and
/// '''
STATIC mp_obj_t mod_foundation_bip39_get_words_matching_prefix(size_t n_args, const mp_obj_t *args)
{
- mp_check_self(mp_obj_is_str_or_bytes(args[0]));
+ if (!mp_obj_is_str_or_bytes(args[0])) {
+ mp_raise_TypeError(MP_ERROR_TEXT("prefix must be a string"));
+ }
GET_STR_DATA_LEN(args[0], prefix_str, prefix_len);
mp_int_t max_matches = mp_obj_get_int(args[1]);
@@ -36,7 +38,9 @@ STATIC mp_obj_t mod_foundation_bip39_get_words_matching_prefix(size_t n_args, co
}
// Must be "bip39" or "bytewords"
- mp_check_self(mp_obj_is_str_or_bytes(args[2]));
+ if (!mp_obj_is_str_or_bytes(args[2])) {
+ mp_raise_TypeError(MP_ERROR_TEXT("word_list must be a string"));
+ }
GET_STR_DATA_LEN(args[2], word_list_str, word_list_len);
const word_info_t *word_info = NULL;
@@ -81,7 +85,9 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(mod_foundation_bip39_get_words_matchi
/// '''
STATIC mp_obj_t mod_foundation_bip39_mnemonic_to_bits(mp_obj_t mnemonic, mp_obj_t entropy)
{
- mp_check_self(mp_obj_is_str_or_bytes(mnemonic));
+ if (!mp_obj_is_str_or_bytes(mnemonic)) {
+ mp_raise_TypeError(MP_ERROR_TEXT("mnemonic must be a string"));
+ }
GET_STR_DATA_LEN(mnemonic, mnemonic_str, mnemonic_len);
mp_buffer_info_t entropy_info;
mp_get_buffer_raise(entropy, &entropy_info, MP_BUFFER_WRITE);
### extmod/foundation/modfoundation-ur.h
@@ -132,7 +132,9 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_Value_ur_type_obj, mod_founda
/// """
STATIC mp_obj_t mod_foundation_ur_Value_unwrap_bytes(mp_obj_t self_in) {
mp_obj_Value_t *self = MP_OBJ_TO_PTR(self_in);
- mp_check_self(self->value.tag == Bytes);
+ if (self->value.tag != Bytes) {
+ mp_raise_msg(&mp_type_ValueError, MP_ERROR_TEXT("expected bytes UR"));
+ }
return mp_obj_new_bytearray_by_ref(self->value.bytes.len, (void *)self->value.bytes.data);
}
STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_Value_unwrap_bytes_obj,
@@ -143,7 +145,9 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_Value_unwrap_bytes_obj,
/// """
STATIC mp_obj_t mod_foundation_ur_Value_unwrap_psbt(mp_obj_t self_in) {
mp_obj_Value_t *self = MP_OBJ_TO_PTR(self_in);
- mp_check_self(self->value.tag == Psbt);
+ if (self->value.tag != Psbt) {
+ mp_raise_msg(&mp_type_ValueError, MP_ERROR_TEXT("expected psbt UR"));
+ }
return mp_obj_new_bytearray_by_ref(self->value.psbt.len, (void *)self->value.psbt.data);
}
@@ -155,7 +159,9 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_Value_unwrap_psbt_obj,
/// """
STATIC mp_obj_t mod_foundation_ur_Value_unwrap_passport_request(mp_obj_t self_in) {
mp_obj_Value_t *self = MP_OBJ_TO_PTR(self_in);
- mp_check_self(self->value.tag == PassportRequest);
+ if (self->value.tag != PassportRequest) {
+ mp_raise_msg(&mp_type_ValueError, MP_ERROR_TEXT("expected passport-request UR"));
+ }
return MP_OBJ_FROM_PTR(mod_foundation_ur_PassportRequest_new(&self->value.passport_request));
}
@@ -328,8 +334,10 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_PassportRequest_uuid_obj,
STATIC mp_obj_t mod_foundation_ur_PassportRequest_scv_challenge_id(mp_obj_t self_in)
{
- mp_check_self(self->passport_request.has_scv_challenge);
mp_obj_PassportRequest_t *self = MP_OBJ_TO_PTR(self_in);
+ if (!self->passport_request.has_scv_challenge) {
+ mp_raise_msg(&mp_type_ValueError, MP_ERROR_TEXT("request has no SCV challenge"));
+ }
return mp_obj_new_bytes(self->passport_request.scv_challenge.id, 32);
}
@@ -338,8 +346,10 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_PassportRequest_scv_challenge
STATIC mp_obj_t mod_foundation_ur_PassportRequest_scv_challenge_signature(mp_obj_t self_in)
{
- mp_check_self(self->passport_request.has_scv_challenge);
mp_obj_PassportRequest_t *self = MP_OBJ_TO_PTR(self_in);
+ if (!self->passport_request.has_scv_challenge) {
+ mp_raise_msg(&mp_type_ValueError, MP_ERROR_TEXT("request has no SCV challenge"));
+ }
return mp_obj_new_bytes(self->passport_request.scv_challenge.signature, 64);
}
@@ -692,7 +702,9 @@ STATIC mp_obj_t mod_foundation_ur_decoder_receive(mp_obj_t ur_obj)
{
UR_Error error = {0};
- mp_check_self(mp_obj_is_str(ur_obj));
+ if (!mp_obj_is_str(ur_obj)) {
+ mp_raise_TypeError(MP_ERROR_TEXT("ur must be a string"));
+ }
GET_STR_DATA_LEN(ur_obj, ur, ur_len);
uint32_t num_frames = 0;
@@ -769,7 +781,9 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_0(mod_foundation_ur_decoder_decode_message_obj,
/// """
STATIC mp_obj_t mod_foundation_ur_validate(mp_obj_t ur_obj)
{
- mp_check_self(mp_obj_is_str(ur_obj));
+ if (!mp_obj_is_str(ur_obj)) {
+ mp_raise_TypeError(MP_ERROR_TEXT("ur must be a string"));
+ }
GET_STR_DATA_LEN(ur_obj, ur, ur_len);
return ur_validate(ur, ur_len) ? mp_const_true : mp_const_false;
@@ -784,7 +798,9 @@ STATIC mp_obj_t mod_foundation_ur_decode_single_part(mp_obj_t ur_obj)
UR_Error error = {0};
UR_Value value = {0};
- mp_check_self(mp_obj_is_str(ur_obj));
+ if (!mp_obj_is_str(ur_obj)) {
+ mp_raise_TypeError(MP_ERROR_TEXT("ur must be a string"));
+ }
GET_STR_DATA_LEN(ur_obj, ur, ur_len);
if (!ur_decode_single_part(ur, ur_len, &value, &error)) {
### ports/stm32/boards/Passport/modules/flows/connect_wallet_flow.py
@@ -399,20 +399,15 @@ async def import_multisig_config_from_qr(self):
# Retry
return
- if scan_result.error is not None:
- # Show error
+ if scan_result in (Error.QR_TOO_LARGE, Error.PSBT_OVERSIZED):
self.set_result(False)
return
try:
- # Mulitsig config should be a bytes-like object that we decode to a string
- if isinstance(scan_result.data, ur.Value):
- self.multisig_import_data = scan_result.data.unwrap_bytes().decode('utf-8')
- elif isinstance(scan_result.data, str):
- self.multisig_import_data = scan_result.data
-
- # from utils import to_str
- # print('MS Data: {}'.format(to_str(self.multisig_import_data)))
+ if isinstance(scan_result, ur.Value):
+ self.multisig_import_data = scan_result.unwrap_bytes().decode('utf-8')
+ elif isinstance(scan_result, str):
+ self.multisig_import_data = scan_result
except BaseException as e:
await ErrorPage(text='Unexpected data format: {}'.format(e)).show()
return
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -60,6 +60,14 @@ def test_ur_derived_key(test):
assert test('ur_derived_key.py') == b'OK'
+def test_multisig_qr_import(test):
+ assert test('multisig_qr_import.py') == b'OK'
+
+
+def test_binding_argument_checks(test):
+ assert test('binding_argument_checks.py') == b'OK'
+
+
def test_psbt_multisig_approval(test):
assert test('psbt_multisig_approval.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/binding_argument_checks.py
@@ -0,0 +1,70 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# The foundation bindings validate their arguments with explicit raises. This
+# pins each one: the error type for an invalid argument, and the valid call
+# beside it so the checks cannot pass by refusing everything. See SFT-8229.
+
+from data_codecs.ur2_codec import UR2Decoder
+from foundation import bip39, ur
+
+CONFIG = b'Name: Test wallet\n'
+
+
+def must_raise(exc_type, call, what):
+ try:
+ call()
+ except exc_type:
+ return
+ except BaseException as other:
+ raise RuntimeError('{}: expected {}, got {}'.format(
+ what, exc_type.__name__, type(other).__name__))
+
+ raise RuntimeError('{}: expected {}, nothing raised'.format(what, exc_type.__name__))
+
+
+# Each unwrap accepts only its own variant.
+bytes_ur = ur.new_bytes(CONFIG)
+psbt_ur = ur.new_psbt(CONFIG)
+
+must_raise(ValueError, lambda: psbt_ur.unwrap_bytes(), 'unwrap_bytes on a psbt UR')
+must_raise(ValueError, lambda: bytes_ur.unwrap_psbt(), 'unwrap_psbt on a bytes UR')
+must_raise(ValueError, lambda: bytes_ur.unwrap_passport_request(),
+ 'unwrap_passport_request on a bytes UR')
+
+# The matching variant still works, so the checks are not simply refusing.
+assert bytes_ur.unwrap_bytes() == CONFIG
+assert psbt_ur.unwrap_psbt() == CONFIG
+
+# A crypto-request with only a UUID and a model has no SCV challenge, so the
+# challenge accessors have nothing to return.
+request_cbor = b'\xa2\x01\xd8\x25\x50' + bytes(16) + b'\x03\xd9\x02\xd0\xf5'
+ur.encoder_start(ur.new_raw('crypto-request', request_cbor), 535)
+decoder = UR2Decoder()
+decoder.add_data(ur.encoder_next_part())
+assert decoder.is_complete()
+request = decoder.decode().unwrap_passport_request()
+
+must_raise(ValueError, lambda: request.scv_challenge_id(), 'scv_challenge_id without a challenge')
+must_raise(ValueError, lambda: request.scv_challenge_signature(),
+ 'scv_challenge_signature without a challenge')
+
+# These three require a string argument.
+for name, call in (
+ ('ur.validate', lambda: ur.validate(123)),
+ ('ur.decode_single_part', lambda: ur.decode_single_part(123)),
+ ('ur.decoder_receive', lambda: ur.decoder_receive(123))):
+ must_raise(TypeError, call, name)
+
+# Same again in the bip39 bindings.
+must_raise(TypeError, lambda: bip39.get_words_matching_prefix(123, 5, 'bip39'),
+ 'get_words_matching_prefix with a non-string prefix')
+must_raise(TypeError, lambda: bip39.get_words_matching_prefix('2', 5, 123),
+ 'get_words_matching_prefix with a non-string word list')
+must_raise(TypeError, lambda: bip39.mnemonic_to_bits(123, bytearray(33)),
+ 'mnemonic_to_bits with a non-string mnemonic')
+
+# And the valid calls beside them are unaffected.
+assert len(bip39.get_words_matching_prefix('2226366', 5, 'bip39')) > 0
+
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/tests/unit/multisig_qr_import.py
@@ -0,0 +1,122 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import uasyncio as asyncio
+import flows.scan_qr_flow as scan_module
+import flows.connect_wallet_flow as connect_module
+from foundation import ur
+from data_codecs.ur2_codec import UR2Decoder
+from pages.scan_qr_page import QRScanResult
+from wallets.multisig_import import read_multisig_config_from_qr
+
+
+class MockPage:
+ result = None
+ messages = []
+
+ def __init__(self, **kwargs):
+ if 'text' in kwargs:
+ self.messages.append(kwargs['text'])
+
+ async def show(self, **kwargs):
+ return self.result
+
+
+class MockInfoPage(MockPage):
+ result = True
+
+
+class MockConnectFlow:
+ sw_wallet = {'label': 'test wallet'}
+ do_multisig_config_import = 'import'
+
+ def __init__(self):
+ self.sig_type = {'import_qr': read_multisig_config_from_qr}
+ self.multisig_import_data = None
+ self.next_state = None
+ self.result = None
+
+ def get_custom_text(self, key, default):
+ return default
+
+ def goto(self, state):
+ self.next_state = state
+
+ def set_result(self, result):
+ self.result = result
+
+
+async def import_result(result):
+ MockPage.result = result
+ MockPage.messages = []
+ flow = MockConnectFlow()
+ await connect_module.ConnectWalletFlow.import_multisig_config_from_qr(flow)
+ return flow
+
+
+async def run_tests():
+ original_scan = scan_module.ScanQRPage
+ original_error = scan_module.ErrorPage
+ original_long_error = scan_module.LongErrorPage
+ original_info = connect_module.InfoPage
+ original_connect_error = connect_module.ErrorPage
+ scan_module.ScanQRPage = MockPage
+ scan_module.ErrorPage = MockPage
+ scan_module.LongErrorPage = MockPage
+ connect_module.InfoPage = MockInfoPage
+ connect_module.ErrorPage = MockPage
+ try:
+ config = 'Name: Test wallet\nPolicy: 2 of 3\n'
+ for data in (config, ur.new_bytes(config.encode())):
+ flow = await import_result(QRScanResult(data=data))
+ assert flow.multisig_import_data == config
+ assert flow.next_state == 'import'
+ assert len(MockPage.messages) == 1
+
+ # A model request exercises the UUID-bearing union variant from the report.
+ request_cbor = b'\xa2\x01\xd8\x25\x50' + bytes(16) + b'\x03\xd9\x02\xd0\xf5'
+ ur.encoder_start(ur.new_raw('crypto-request', request_cbor), 535)
+ decoder = UR2Decoder()
+ decoder.add_data(ur.encoder_next_part())
+ assert decoder.is_complete()
+ request = decoder.decode()
+ assert request.ur_type() == ur.Value.PASSPORT_REQUEST
+ for wrong_type in (ur.new_psbt(b'not a multisig configuration'), request):
+ try:
+ wrong_type.unwrap_bytes()
+ except ValueError:
+ pass
+ else:
+ raise AssertionError('wrong UR tag accepted by unwrap_bytes')
+
+ flow = await import_result(QRScanResult(data=wrong_type))
+ assert flow.next_state is None
+ assert flow.multisig_import_data is None
+ assert 'This type of UR is not expected' in MockPage.messages[-1]
+
+ flow = await import_result(None)
+ assert flow.next_state is None
+ assert flow.multisig_import_data is None
+ assert len(MockPage.messages) == 1
+
+ flow = await import_result(QRScanResult(data=ur.new_bytes(b'\xff')))
+ assert flow.next_state is None
+ assert 'Unexpected data format' in MockPage.messages[-1]
+
+ flow = await import_result(QRScanResult(error=ur.TooBigError()))
+ assert flow.next_state is None
+ assert flow.result is False
+
+ flow = await import_result(QRScanResult(error=ValueError('invalid QR')))
+ assert flow.next_state is None
+ assert 'Unable to scan QR code' in MockPage.messages[-1]
+ finally:
+ scan_module.ScanQRPage = original_scan
+ scan_module.ErrorPage = original_error
+ scan_module.LongErrorPage = original_long_error
+ connect_module.InfoPage = original_info
+ connect_module.ErrorPage = original_connect_error
+
+
+asyncio.run(run_tests())
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/wallets/multisig_import.py
@@ -15,8 +15,13 @@
# connect_wallet_flow.py, and that probably needs to be modified during the refactoring.
async def read_multisig_config_from_qr():
- from pages import ScanQRPage
- return await ScanQRPage().show()
+ from data_codecs.qr_type import QRType
+ from flows import ScanQRFlow
+ from foundation import ur
+
+ return await ScanQRFlow(qr_types=[QRType.QR, QRType.UR2],
+ ur_types=[ur.Value.BYTES],
+ data_description='a multisig wallet configuration file').run()
async def read_multisig_config_from_microsd():Why this scored 47/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.