Merge pull request #707 from Foundation-Devices/SFT-7360-supply-chain-boundary-hardening
What changed, and why it matters
This commit hardens the Passport hardware wallet's supply-chain verification feature by adding strict length checks before passing buffers to cryptographic functions. Without these checks, a malformed or short challenge could be silently padded with zeros, potentially allowing an attacker to bypass the security check that proves the device is genuine. The patch also removes an unused random-hex helper and adds automated tests that exercise the boundary conditions.
Review the supply-chain validation user flow end-to-end to confirm no other entry point accepts short challenges, and ensure the new unit test is run in CI. Consider whether the removed `random_hex` function was referenced anywhere outside the changed import in `connect_wallet_flow.py`.
Security signals we found
Buffer-length validation added before fixed-size HMAC and ECDSA operations
Removal of silent zero-padding for short supply-chain challenge buffers
New unit test specifically targeting supply-chain buffer bounds
Pull request title and branch name explicitly reference supply-chain boundary hardening
Evidence from the diff
The patch adds explicit length validation at three native/firmware boundaries: (1) dispatch.c now rejects CMD_GET_SUPPLY_CHAIN_VALIDATION_WORDS when len_in < 32, arg2 < 32, or arg2 > len_in before calling se_hmac32, which reads/writes 32 bytes regardless of the supplied length; (2) modpassport.c now returns mp_const_false in mod_passport_supply_chain_challenge unless the challenge is exactly 32 bytes and the response buffer is at least 32 bytes, and in mod_passport_verify_supply_chain_server_signature unless the hash is exactly 32 bytes and the signature is exactly 64 bytes; (3) scv_flow.py rejects QR challenges shorter than 32 bytes with a user-facing error. pincodes.py removes zero-padding of short buffers, and a new unit test compiles the native entry points with stubbed hardware to verify the bounds. The random_hex removal in utils.py is a dead-code cleanup with no direct security relevance.
Changed components
ports/stm32/boards/Passport/dispatch.cports/stm32/boards/Passport/modpassport.cports/stm32/boards/Passport/modules/flows/scv_flow.pyports/stm32/boards/Passport/modules/pincodes.pyports/stm32/boards/Passport/modules/tests/test_supply_chain_bounds.pyports/stm32/boards/Passport/modules/utils.pyInspect captured patch +138 / −15
### ports/stm32/boards/Passport/dispatch.c
@@ -118,7 +118,11 @@ int se_dispatch(
}
case CMD_GET_SUPPLY_CHAIN_VALIDATION_WORDS: {
- // Provide a hash to use for the supply chain validation words'
+ // se_hmac32 reads and writes 32 bytes regardless of data_len.
+ if (len_in < 32 || arg2 < 32 || arg2 > (uint32_t)len_in) {
+ rv = ERANGE;
+ break;
+ }
if (supply_chain_validation_words((char*)buf_io, arg2, (uint32_t*)buf_io)) {
rv = EIO;
}
### ports/stm32/boards/Passport/modpassport.c
@@ -40,6 +40,10 @@ STATIC mp_obj_t mod_passport_supply_chain_challenge(mp_obj_t challenge_obj, mp_o
mp_get_buffer_raise(challenge_obj, &challenge_info, MP_BUFFER_READ);
mp_get_buffer_raise(response_obj, &response_info, MP_BUFFER_WRITE);
+ if (challenge_info.len != 32 || response_info.len < 32) {
+ return mp_const_false;
+ }
+
se_pair_unlock();
int rc = se_hmac32(KEYNUM_supply_chain, challenge_info.buf, response_info.buf);
if (rc == 0) {
@@ -62,6 +66,12 @@ STATIC mp_obj_t mod_passport_verify_supply_chain_server_signature(mp_obj_t hash_
mp_get_buffer_raise(hash_obj, &hash_info, MP_BUFFER_READ);
mp_get_buffer_raise(signature_obj, &signature_info, MP_BUFFER_READ);
+ // The SCV protocol signs a SHA-256 digest with a raw secp256k1 signature.
+ // micro-ecc assumes the signature buffer contains both 32-byte scalars.
+ if (hash_info.len != 32 || signature_info.len != 64) {
+ return mp_const_false;
+ }
+
rc = uECC_verify(supply_chain_validation_server_pubkey, hash_info.buf, hash_info.len, signature_info.buf,
uECC_secp256k1());
return rc == 0 ? mp_const_false : mp_const_true;
### ports/stm32/boards/Passport/modules/flows/connect_wallet_flow.py
@@ -28,7 +28,7 @@
from public_constants import MUSIG_SKIP, MARGIN_FOR_ADDRESSES
from wallets.constants import EXPORT_MODE_MICROSD, EXPORT_MODE_QR
from wallets.sw_wallets import supported_software_wallets
-from utils import random_hex, spinner_task, stylize_address
+from utils import spinner_task, stylize_address
from foundation import ur
import common
import microns
### ports/stm32/boards/Passport/modules/flows/scv_flow.py
@@ -80,6 +80,12 @@ async def scan_qr_challenge(self):
await self.show_error('Security Check QR code is invalid.\n')
return
+ # The secure element HMAC consumes 32 challenge bytes. Reject short
+ # inputs here as well as at the native boundary, with a useful error.
+ if len(scv_id) < 32:
+ await self.show_error('Security Check challenge is too short.')
+ return
+
id_hash = bytearray(32)
foundation.sha256(b2a_hex(scv_id), id_hash)
### ports/stm32/boards/Passport/modules/pincodes.py
@@ -277,9 +277,6 @@ def supply_chain_validation_words(challenge_str):
# Get a mnemonic from the 32 bytes in the buffer
buf = buf[:32]
- if len(buf) < 32:
- padding = 32 - len(buf)
- buf = buf + b'\0' * padding
s = trezorcrypto.bip39.from_data(buf)
rv = s.split()
### ports/stm32/boards/Passport/modules/tests/test_supply_chain_bounds.py
@@ -0,0 +1,116 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Compile actual native SCV entry points with instrumented hardware stand-ins."""
+
+import os
+import shlex
+import subprocess
+from pathlib import Path
+
+
+BOARD = Path(__file__).resolve().parents[2]
+
+
+def extract_between(source, start, end, filename):
+ _, found_start, remainder = source.partition(start)
+ assert found_start, '{}: missing harness start marker {!r}'.format(filename, start)
+ extracted, found_end, _ = remainder.partition(end)
+ assert found_end, '{}: missing harness end marker {!r} after {!r}'.format(filename, end, start)
+ return extracted
+
+
+def test_native_supply_chain_buffer_bounds(tmp_path):
+ native = (BOARD / 'modpassport.c').read_text()
+ functions = []
+ for name in ['mod_passport_supply_chain_challenge', 'mod_passport_verify_supply_chain_server_signature']:
+ prefix = 'STATIC mp_obj_t ' + name + '('
+ functions.append(prefix + extract_between(
+ native, prefix, '\nSTATIC MP_DEFINE_CONST_FUN_OBJ_2', 'modpassport.c'))
+ dispatch = (BOARD / 'dispatch.c').read_text()
+ case = extract_between(dispatch, 'case CMD_GET_SUPPLY_CHAIN_VALIDATION_WORDS:',
+ 'case CMD_GET_RANDOM_BYTES:', 'dispatch.c')
+ source = r'''
+#include <assert.h>
+#include <errno.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+#define STATIC static
+#define MP_BUFFER_READ 0
+#define MP_BUFFER_WRITE 1
+#define KEYNUM_supply_chain 1
+#define mp_const_false ((void*)0)
+#define mp_const_true ((void*)1)
+typedef void* mp_obj_t;
+typedef struct { void* buf; size_t len; } mp_buffer_info_t;
+static int verify_calls, hmac_calls, unlock_calls, crypto_result;
+static uint8_t supply_chain_validation_server_pubkey[64];
+static void mp_get_buffer_raise(mp_obj_t obj, mp_buffer_info_t* info, int flags) {
+ (void)flags;
+ *info = *(mp_buffer_info_t*)obj;
+}
+static int se_pair_unlock(void) { unlock_calls++; return 0; }
+static int se_hmac32(int slot, void* challenge, void* response) {
+ (void)slot;
+ hmac_calls++;
+ memmove(response, challenge, 32);
+ return 0;
+}
+static int uECC_secp256k1(void) { return 1; }
+static int uECC_verify(void* key, void* hash, size_t len, void* sig, int curve) {
+ (void)key; (void)hash; (void)sig; (void)curve;
+ assert(len == 32);
+ verify_calls++;
+ return crypto_result;
+}
+static int supply_chain_validation_words(char* data, int len, uint32_t* result) {
+ assert(len >= 32);
+ return se_hmac32(1, data, result);
+}
+''' + '\n'.join(functions) + r'''
+static int dispatch_words(uint8_t* buf_io, int len_in, uint32_t arg2) {
+ int rv = 0;
+ switch (1) { case 1:
+''' + case + r'''
+ }
+ return rv;
+}
+int main(void) {
+ uint8_t input[80] = {0}, output[80] = {0};
+ mp_buffer_info_t hash = {input, 32}, signature = {output, 64};
+ // Exercise both sides of each boundary, including zero-length buffers.
+ size_t lengths[] = {0, 1, 16, 31, 32, 33, 63, 64, 65};
+ for (unsigned i = 0; i < sizeof(lengths)/sizeof(lengths[0]); i++) {
+ for (unsigned j = 0; j < sizeof(lengths)/sizeof(lengths[0]); j++) {
+ hash.len = lengths[i]; signature.len = lengths[j];
+ verify_calls = hmac_calls = unlock_calls = 0;
+ crypto_result = 1;
+ int valid_sig = hash.len == 32 && signature.len == 64;
+ assert(mod_passport_verify_supply_chain_server_signature(&hash, &signature)
+ == (valid_sig ? mp_const_true : mp_const_false));
+ assert(verify_calls == valid_sig);
+ int valid_hmac = hash.len == 32 && signature.len >= 32;
+ assert(mod_passport_supply_chain_challenge(&hash, &signature)
+ == (valid_hmac ? mp_const_true : mp_const_false));
+ assert(hmac_calls == valid_hmac && unlock_calls == valid_hmac);
+ hmac_calls = 0;
+ int valid_dispatch = lengths[i] >= 32 && lengths[j] >= 32 && lengths[j] <= lengths[i];
+ assert(dispatch_words(input, lengths[i], lengths[j]) == (valid_dispatch ? 0 : ERANGE));
+ assert(hmac_calls == valid_dispatch);
+ }
+ }
+ hmac_calls = 0;
+ assert(dispatch_words(input, -1, 32) == ERANGE);
+ assert(hmac_calls == 0);
+ hash.len = 32; signature.len = 64; crypto_result = 0;
+ assert(mod_passport_verify_supply_chain_server_signature(&hash, &signature) == mp_const_false);
+ return 0;
+}
+'''
+ test_source = tmp_path / 'supply_chain.c'
+ test_source.write_text(source)
+ executable = tmp_path / 'supply_chain'
+ subprocess.run(shlex.split(os.environ.get('CC', 'cc')) +
+ ['-std=c99', '-Wall', '-Wextra', '-Werror', str(test_source), '-o', str(executable)], check=True)
+ subprocess.run([str(executable)], check=True)
### ports/stm32/boards/Passport/modules/utils.py
@@ -766,16 +766,6 @@ def to_str(o):
return lines
-def random_hex(num_chars):
- import urandom
-
- rand = bytearray((num_chars + 1) // 2)
- for i in range(len(rand)):
- rand[i] = urandom.randint(0, 255)
- s = b2a_hex(rand).decode('utf-8').upper()
- return s[:num_chars]
-
-
def recolor(color, text):
# Recolor a fragment of text
h = '{0:0{1}x}'.format(color, 6)Why this scored 62/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.