Merge pull request #712 from Foundation-Devices/sft-8229-replace-every-mp_check_self-guard-in-extmodfoundation-with
What changed, and why it matters
This commit is a hardening and code-quality improvement for Foundation's Passport firmware. It replaces a low-level MicroPython self-check macro with explicit, user-friendly error messages when Python code passes the wrong type of value into certain firmware functions. It also adds automated tests and a lint rule to keep the old pattern from coming back. The change makes bugs easier to diagnose and reduces the chance that a wrong argument is silently accepted or crashes the device, but it does not by itself fix a known exploitable vulnerability.
Treat as a defensive hardening commit. Reviewers should verify that every replaced `mp_check_self` site now has an equivalent explicit check with the correct exception type, that the new unit tests exercise both the failure and success paths, and that the CI lint correctly catches future regressions. No urgent security patch is required unless additional analysis shows one of the previous `mp_check_self` sites was reachable with attacker-controlled input and caused unsafe behavior.
Security signals we found
Replaces implicit type/variant guards with explicit, typed MicroPython exceptions
Adds regression tests that pin expected TypeError and ValueError behavior
Adds CI lint rule to prevent reintroduction of the discouraged pattern in extmod/foundation/
Touches BIP-39 and UR (Uniform Resources) bindings used for seed phrases and PSBT/crypto-request parsing
Evidence from the diff
The patch removes uses of mp_check_self() in extmod/foundation/ and replaces them with explicit mp_raise_TypeError() or mp_raise_msg(&mp_type_ValueError, ...) checks. Affected C bindings include modfoundation-bip39.h (get_words_matching_prefix, mnemonic_to_bits) and modfoundation-ur.h (unwrap_psbt, unwrap_passport_request, scv_challenge_id, scv_challenge_signature, decoder_receive, validate, decode_single_part). A new unit test file pins the expected exception types, and a GitHub Actions lint job forbids reintroducing mp_check_self in that directory. mp_check_self is normally a type guard for instance methods; in these module-level or variant-specific functions it was being used informally for argument/variant validation, so the replacement is a correctness and maintainability improvement.
Changed components
extmod/foundation/modfoundation-bip39.hextmod/foundation/modfoundation-ur.hports/stm32/boards/Passport/modules/tests/unit/binding_argument_checks.py.github/workflows/lint.yamlInspect captured patch +124 / −10
### .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
@@ -145,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);
}
@@ -157,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));
}
@@ -330,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);
}
@@ -340,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);
}
@@ -694,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;
@@ -771,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;
@@ -786,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/tests/test_unit.py
@@ -64,6 +64,10 @@ 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')Why this scored 36/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.