Add native Unchained wallet connection
What changed, and why it matters
This commit adds support for connecting the Passport hardware wallet to the Unchained multisig service. It introduces a new way to pass already-encoded data into the QR/UR encoder, plus a new wallet definition and CBOR encoding helper. The changes are mostly additive feature code; there is no vendor statement that this fixes a security bug, and no independent security disclosure is referenced.
Treat as a feature commit, not an emergency security patch. Review the raw UR encoder for memory-safety edge cases: confirm `ur_type_len` and `message_len` cannot exceed the static buffer capacities, ensure the type-string validator cannot be bypassed via UTF-8 multibyte sequences, and fuzz `ur_encoder_start_raw` with malformed inputs. Verify that `create_unchained_hdkey_cbor` cannot produce oversized CBOR and that the BIP45 derivation path is correct for Unchained.
Security signals we found
New unsafe FFI function `ur_encoder_start_raw` takes raw pointers and lengths from MicroPython
Static mutable buffer `UR_ENCODER_TYPE` added in SRAM section, filled from user-supplied type string
CBOR payload is copied into `UR_ENCODER_MESSAGE` without semantic validation
Type-string validation rejects uppercase and special characters but does not bound-check against UR_MAX_TYPE beyond a length comparison
New wallet export derives `m/45'` and packages public key + chain code as a UR; private key material is not exposed
No vendor security disclosure or CVE referenced in commit message or changelog
Evidence from the diff
The patch adds: (1) a new ur_encoder_start_raw Rust FFI function and RawValue MicroPython type that allow starting a UR encoder with a caller-supplied CBOR payload and type string, with validation that the type is ASCII lowercase/digits/hyphen and fits length limits; (2) a new wallets/unchained.py module that builds a crypto-hdkey UR for Unchained using BIP45 (m/45') and custom CBOR serialization; (3) registration of UnchainedWallet in the software-wallet list; (4) unit tests covering the raw UR encoder and Unchained wallet metadata. The raw-encoder path is a new attack surface: it copies attacker-influenced bytes into a static encoder buffer and validates only the type string, not the CBOR contents. However, the diff itself does not show a reachable vulnerability, and the feature appears intended for locally generated data.
Changed components
extmod/foundation-rust/src/ur/encoder.rsextmod/foundation-rust/include/foundation.hextmod/foundation/modfoundation-ur.hports/stm32/boards/Passport/modules/wallets/unchained.pyports/stm32/boards/Passport/modules/wallets/sw_wallets.pyports/stm32/boards/Passport/modules/tests/unit/unchained.pyports/stm32/boards/Passport/modules/tests/unit/foundation.pyInspect captured patch +368 / −7
### CHANGELOG.md
@@ -10,6 +10,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
- Validate the complete local xpub when importing multisig wallets
- Require confirmation before using PSBT-proposed multisig wallets with temporary seeds,
and cancel signing if import is declined
+- Added Unchained as a multisig Connect Wallet option
- Added Coconut Wallet as a single-sig Connect Wallet option
- Improved self-send transaction information formatting (PASS1-638)
- Added the key manager extension, compatible with BIP85 and Nostr (PASS1-24)
### extmod/foundation-rust/include/foundation.h
@@ -614,6 +614,21 @@ void ur_encoder_start(UR_Encoder *encoder,
const UR_Value *value,
size_t max_chars);
+/**
+ * Start the encoder with an already CBOR-encoded Uniform Resource.
+ *
+ * # Safety
+ *
+ * `ur_type` and `message` must be valid for reads of their respective
+ * lengths for the duration of this call.
+ */
+bool ur_encoder_start_raw(UR_Encoder *encoder,
+ const uint8_t *ur_type,
+ size_t ur_type_len,
+ const uint8_t *message,
+ size_t message_len,
+ size_t max_chars);
+
/**
* Returns the UR corresponding to the next fountain encoded part.
*
### extmod/foundation-rust/src/ur/encoder.rs
@@ -3,7 +3,7 @@
//! Encoder.
-use core::{ffi::c_char, fmt::Write, ptr};
+use core::{ffi::c_char, fmt::Write, ptr, slice, str};
use foundation_ur::{max_fragment_len, HeaplessEncoder};
use minicbor::{Encode, Encoder};
@@ -69,6 +69,12 @@ static mut UR_ENCODER_STRING: heapless::Vec<u8, UR_ENCODER_MAX_STRING> =
static mut UR_ENCODER_MESSAGE: heapless::Vec<u8, UR_ENCODER_MAX_MESSAGE_LEN> =
heapless::Vec::new();
+/// cbindgen:ignore
+#[used]
+#[cfg_attr(sram4, link_section = ".sram4")]
+static mut UR_ENCODER_TYPE: heapless::String<{ UR_MAX_TYPE.len() }> =
+ heapless::String::new();
+
/// Uniform Resource encoder.
pub struct UR_Encoder {
inner: HeaplessEncoder<
@@ -119,6 +125,57 @@ pub unsafe extern "C" fn ur_encoder_start(
);
}
+/// Start the encoder with an already CBOR-encoded Uniform Resource.
+///
+/// # Safety
+///
+/// `ur_type` and `message` must be valid for reads of their respective
+/// lengths for the duration of this call.
+#[no_mangle]
+pub unsafe extern "C" fn ur_encoder_start_raw(
+ encoder: &mut UR_Encoder,
+ ur_type: *const u8,
+ ur_type_len: usize,
+ message: *const u8,
+ message_len: usize,
+ max_chars: usize,
+) -> bool {
+ let ur_type = unsafe { slice::from_raw_parts(ur_type, ur_type_len) };
+ let message = unsafe { slice::from_raw_parts(message, message_len) };
+
+ let Ok(ur_type) = str::from_utf8(ur_type) else {
+ return false;
+ };
+ if ur_type.is_empty()
+ || ur_type.len() > UR_MAX_TYPE.len()
+ || !ur_type
+ .bytes()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-')
+ {
+ return false;
+ }
+
+ let encoder_type = unsafe { &mut *ptr::addr_of_mut!(UR_ENCODER_TYPE) };
+ encoder_type.clear();
+ if encoder_type.push_str(ur_type).is_err() {
+ return false;
+ }
+
+ let encoder_message =
+ unsafe { &mut *ptr::addr_of_mut!(UR_ENCODER_MESSAGE) };
+ encoder_message.clear();
+ if encoder_message.extend_from_slice(message).is_err() {
+ return false;
+ }
+
+ encoder.inner.start(
+ encoder_type.as_str(),
+ encoder_message,
+ max_fragment_len(UR_MAX_TYPE, usize::MAX, max_chars),
+ );
+ true
+}
+
/// Returns the UR corresponding to the next fountain encoded part.
///
/// # Safety
@@ -163,3 +220,33 @@ impl<'a, const N: usize> minicbor::encode::Write for Writer<'a, N> {
#[derive(Debug)]
struct EndOfSlice;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn raw_encoder_preserves_the_ur_type() {
+ let ur_type = b"crypto-hdkey";
+ let message = b"\xa0";
+
+ unsafe {
+ let encoder = &mut *ptr::addr_of_mut!(UR_ENCODER);
+ assert!(ur_encoder_start_raw(
+ encoder,
+ ur_type.as_ptr(),
+ ur_type.len(),
+ message.as_ptr(),
+ message.len(),
+ UR_ENCODER_MAX_STRING,
+ ));
+
+ let mut encoded = ptr::null();
+ let mut encoded_len = 0;
+ ur_encoder_next_part(encoder, &mut encoded, &mut encoded_len);
+ let encoded =
+ slice::from_raw_parts(encoded as *const u8, encoded_len);
+ assert!(encoded.starts_with(b"ur:crypto-hdkey/"));
+ }
+ }
+}
### extmod/foundation/modfoundation-ur.h
@@ -10,6 +10,7 @@
/// package: foundation.ur
STATIC const mp_obj_type_t mod_foundation_ur_Value_type;
+STATIC const mp_obj_type_t mod_foundation_ur_RawValue_type;
STATIC const mp_obj_type_t mod_foundation_ur_CoinType_type;
STATIC const mp_obj_type_t mod_foundation_ur_CoinInfo_type;
STATIC const mp_obj_type_t mod_foundation_ur_Keypath_type;
@@ -178,6 +179,30 @@ STATIC const mp_obj_type_t mod_foundation_ur_Value_type = {
.locals_dict = (mp_obj_dict_t *)&mod_foundation_ur_Value_locals_dict,
};
+/// class RawValue:
+/// """
+/// A Uniform Resource whose payload is already CBOR encoded.
+/// """
+typedef struct _mp_obj_RawValue_t {
+ mp_obj_base_t base;
+ mp_obj_t ur_type;
+ mp_obj_t cbor;
+} mp_obj_RawValue_t;
+
+STATIC void mod_foundation_ur_RawValue_print(const mp_print_t *print,
+ mp_obj_t o_in,
+ mp_print_kind_t kind) {
+ (void)o_in;
+ (void)kind;
+ mp_print_str(print, "UR_RawValue");
+}
+
+STATIC const mp_obj_type_t mod_foundation_ur_RawValue_type = {
+ { &mp_type_type },
+ .name = MP_QSTR_RawValue,
+ .print = mod_foundation_ur_RawValue_print,
+};
+
/// class CoinType:
/// """
/// """
@@ -347,6 +372,30 @@ STATIC mp_obj_t mod_foundation_ur_new_bytes(mp_obj_t data_in)
STATIC MP_DEFINE_CONST_FUN_OBJ_1(mod_foundation_ur_new_bytes_obj,
mod_foundation_ur_new_bytes);
+/// def new_raw(ur_type: str, cbor: bytes) -> RawValue:
+/// """
+/// Create a Uniform Resource from an already CBOR-encoded payload.
+/// """
+STATIC mp_obj_t mod_foundation_ur_new_raw(mp_obj_t ur_type_in,
+ mp_obj_t cbor_in)
+{
+ if (!mp_obj_is_str(ur_type_in)) {
+ mp_raise_msg(&mp_type_ValueError,
+ MP_ERROR_TEXT("ur_type should be a string"));
+ }
+
+ mp_buffer_info_t cbor = {0};
+ mp_get_buffer_raise(cbor_in, &cbor, MP_BUFFER_READ);
+
+ mp_obj_RawValue_t *o = m_new_obj(mp_obj_RawValue_t);
+ o->base.type = &mod_foundation_ur_RawValue_type;
+ o->ur_type = ur_type_in;
+ o->cbor = cbor_in;
+ return MP_OBJ_FROM_PTR(o);
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_2(mod_foundation_ur_new_raw_obj,
+ mod_foundation_ur_new_raw);
+
/// def new_derived_key(key_data=None,
/// is_private=False,
/// chain_code=None,
@@ -519,15 +568,30 @@ STATIC mp_obj_t mod_foundation_ur_encoder_start(mp_obj_t value_in,
mp_obj_Value_t *value = NULL;
mp_int_t max_fragment_len = 0;
- if (!mp_obj_is_type(value_in, &mod_foundation_ur_Value_type)) {
+ max_fragment_len = mp_obj_int_get_uint_checked(max_fragment_len_in);
+
+ if (mp_obj_is_type(value_in, &mod_foundation_ur_Value_type)) {
+ value = MP_OBJ_TO_PTR(value_in);
+ ur_encoder_start(&UR_ENCODER, &value->value, max_fragment_len);
+ } else if (mp_obj_is_type(value_in, &mod_foundation_ur_RawValue_type)) {
+ mp_obj_RawValue_t *raw = MP_OBJ_TO_PTR(value_in);
+ mp_buffer_info_t cbor = {0};
+ GET_STR_DATA_LEN(raw->ur_type, ur_type, ur_type_len);
+ mp_get_buffer_raise(raw->cbor, &cbor, MP_BUFFER_READ);
+
+ if (!ur_encoder_start_raw(&UR_ENCODER,
+ ur_type,
+ ur_type_len,
+ cbor.buf,
+ cbor.len,
+ max_fragment_len)) {
+ mp_raise_msg(&mp_type_ValueError,
+ MP_ERROR_TEXT("invalid raw Uniform Resource"));
+ }
+ } else {
mp_raise_msg(&mp_type_ValueError, MP_ERROR_TEXT("invalid type for value"));
- return mp_const_none;
}
- value = MP_OBJ_TO_PTR(value_in);
- max_fragment_len = mp_obj_int_get_uint_checked(max_fragment_len_in);
- ur_encoder_start(&UR_ENCODER, &value->value, max_fragment_len);
-
return mp_const_none;
}
STATIC MP_DEFINE_CONST_FUN_OBJ_2(mod_foundation_ur_encoder_start_obj,
@@ -683,6 +747,7 @@ STATIC const mp_rom_map_elem_t mod_foundation_ur_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR_Keypath), MP_ROM_PTR(&mod_foundation_ur_Keypath_type)},
{MP_ROM_QSTR(MP_QSTR_PassportRequest), MP_ROM_PTR(&mod_foundation_ur_PassportRequest_type)},
{MP_ROM_QSTR(MP_QSTR_new_bytes), MP_ROM_PTR(&mod_foundation_ur_new_bytes_obj)},
+ {MP_ROM_QSTR(MP_QSTR_new_raw), MP_ROM_PTR(&mod_foundation_ur_new_raw_obj)},
{MP_ROM_QSTR(MP_QSTR_new_derived_key), MP_ROM_PTR(&mod_foundation_ur_new_derived_key_obj)},
{MP_ROM_QSTR(MP_QSTR_new_psbt), MP_ROM_PTR(&mod_foundation_ur_new_psbt_obj)},
{MP_ROM_QSTR(MP_QSTR_new_passport_response), MP_ROM_PTR(&mod_foundation_ur_new_passport_response_obj)},
### ports/stm32/boards/Passport/manifest.py
@@ -364,6 +364,7 @@
'wallets/vault.py',
'wallets/keeper.py',
'wallets/theya.py',
+ 'wallets/unchained.py',
'wallets/zeus.py'))
# Extensions
### ports/stm32/boards/Passport/modules/tests/test_unit.py
@@ -50,3 +50,7 @@ def test_multisig_xpub_validation(test):
def test_psbt_fee(test):
assert test('psbt_fee.py') == b'OK'
+
+
+def test_unchained(test):
+ assert test('unchained.py') == b'OK'
### ports/stm32/boards/Passport/modules/tests/unit/foundation.py
@@ -39,6 +39,13 @@ def should_fail(f):
foundation.sha256('this is the message', digest)
assert digest == bytearray(b'\x131qZ\x1f\xfd\x04\xe6`\x04\x93\x1a\x8d\xbc6U\xebJR>\xd5\xece\xecm\x1c\xed\x93x+\xd3\xbd') # nopep8
+raw_ur = foundation.ur.new_raw('crypto-hdkey', b'\xa0')
+foundation.ur.encoder_start(raw_ur, 535)
+assert foundation.ur.encoder_next_part().startswith('ur:crypto-hdkey/')
+
+bad_raw_ur = foundation.ur.new_raw('CRYPTO-HDKEY', b'\xa0')
+should_fail(lambda: foundation.ur.encoder_start(bad_raw_ur, 535))
+
SAMPLE_HOR_RES = 10
SAMPLE_VER_RES = 10
SAMPLE_IMG = [0x04, 0x28, 0x40, 0x01, 0xfb, 0x05, 0xfa, 0x05, 0xfb, 0x05, 0xfa, 0x05,
### ports/stm32/boards/Passport/modules/tests/unit/unchained.py
@@ -0,0 +1,62 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from ubinascii import unhexlify
+
+from data_codecs.multisig_config_sampler import MultisigConfigSampler
+from data_codecs.qr_type import QRType
+from wallets.multisig_import import read_multisig_config_from_microsd, read_multisig_config_from_qr
+from wallets.sw_wallets import supported_software_wallets
+from wallets.unchained import UnchainedWallet, create_unchained_hdkey_cbor
+
+
+public_key = unhexlify('02' + '11' * 32)
+chain_code = unhexlify('22' * 32)
+fingerprint = 0xf23f9fd2
+
+cbor = create_unchained_hdkey_cbor(public_key,
+ chain_code,
+ fingerprint,
+ fingerprint,
+ False)
+expected = unhexlify(
+ 'a5'
+ '035821' + '02' + '11' * 32 +
+ '045820' + '22' * 32 +
+ '05d90131a0'
+ '06d90130a3'
+ '0182182df5'
+ '021af23f9fd2'
+ '0301'
+ '081af23f9fd2')
+assert cbor == expected
+
+testnet_cbor = create_unchained_hdkey_cbor(public_key, chain_code, 0, 0, True)
+assert testnet_cbor[0] == 0xa4
+assert b'\x05\xd9\x01\x31\xa1\x02\x01' in testnet_cbor
+assert b'\x06\xd9\x01\x30\xa2\x01\x82\x18\x2d\xf5\x03\x01' in testnet_cbor
+
+assert UnchainedWallet in supported_software_wallets
+assert UnchainedWallet['label'] == 'Unchained'
+assert len(UnchainedWallet['sig_types']) == 1
+
+sig_type = UnchainedWallet['sig_types'][0]
+assert sig_type['id'] == 'multisig'
+assert sig_type['import_qr'] is read_multisig_config_from_qr
+assert sig_type['import_microsd'] is read_multisig_config_from_microsd
+
+export_modes = UnchainedWallet['export_modes']
+assert export_modes[0]['id'] == 'qr'
+assert export_modes[0]['qr_type'] == QRType.UR2
+assert export_modes[1]['id'] == 'microsd'
+assert export_modes[1]['filename_pattern_multisig'] == '{xfp}-unchained-multisig.json'
+
+unchained_config = b'''# Coldcard Multisig setup file (exported from @caravan/wallets)
+Name: example-vault
+Policy: 2 of 3
+Derivation: m/48'/0'/0'/2'
+Format: P2WSH
+'''
+assert MultisigConfigSampler.sample(unchained_config)
+
+return_value.write(b'OK')
### ports/stm32/boards/Passport/modules/wallets/sw_wallets.py
@@ -25,6 +25,7 @@
from .sparrow import SparrowWallet
from .specter import SpecterWallet
from .theya import TheyaWallet
+from .unchained import UnchainedWallet
from .zeus import ZeusWallet
# Array of all supported software wallets and their attributes.
@@ -51,5 +52,6 @@
SparrowWallet,
SpecterWallet,
TheyaWallet,
+ UnchainedWallet,
ZeusWallet,
]
### ports/stm32/boards/Passport/modules/wallets/unchained.py
@@ -0,0 +1,117 @@
+# SPDX-FileCopyrightText: © 2026 Foundation Devices, Inc. <hello@foundation.xyz>
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# unchained.py - Unchained wallet support
+
+import chains
+import stash
+
+from common import settings
+from data_codecs.qr_type import QRType
+from foundation import ur
+from public_constants import AF_P2SH
+from utils import xfp2str
+
+from .multisig_import import read_multisig_config_from_microsd, read_multisig_config_from_qr
+from .multisig_json import create_multisig_json_wallet
+
+
+def _append_cbor_uint(result, value):
+ assert 0 <= value <= 0xffffffff
+
+ if value < 24:
+ result.append(value)
+ elif value <= 0xff:
+ result.extend((0x18, value))
+ elif value <= 0xffff:
+ result.extend((0x19, value >> 8, value & 0xff))
+ else:
+ result.extend((0x1a,
+ (value >> 24) & 0xff,
+ (value >> 16) & 0xff,
+ (value >> 8) & 0xff,
+ value & 0xff))
+
+
+def create_unchained_hdkey_cbor(public_key,
+ chain_code,
+ source_fingerprint,
+ parent_fingerprint,
+ is_testnet):
+ """Encode the BIP45 xpub in the crypto-hdkey form accepted by Unchained."""
+ assert len(public_key) == 33
+ assert len(chain_code) == 32
+
+ result = bytearray()
+ result.append(0xa4 + int(parent_fingerprint != 0))
+ result.extend(b'\x03\x58\x21')
+ result.extend(public_key)
+ result.extend(b'\x04\x58\x20')
+ result.extend(chain_code)
+
+ # Unchained's decoder currently requires the original BCR-2020 tags.
+ result.extend(b'\x05\xd9\x01\x31') # crypto-coin-info, tag 305
+ result.extend(b'\xa1\x02\x01' if is_testnet else b'\xa0')
+
+ result.extend(b'\x06\xd9\x01\x30') # crypto-keypath, tag 304
+ result.append(0xa2 + int(source_fingerprint != 0))
+ result.extend(b'\x01\x82\x18\x2d\xf5') # m/45'
+ if source_fingerprint:
+ result.append(0x02)
+ _append_cbor_uint(result, source_fingerprint)
+ result.extend(b'\x03\x01') # depth 1
+
+ if parent_fingerprint:
+ result.append(0x08)
+ _append_cbor_uint(result, parent_fingerprint)
+
+ return bytes(result)
+
+
+def create_unchained_export(sw_wallet=None,
+ addr_type=None,
+ acct_num=0,
+ multisig=False,
+ legacy=False,
+ export_mode='qr',
+ qr_type=QRType.UR2):
+ assert multisig
+
+ if export_mode != 'qr':
+ return create_multisig_json_wallet(sw_wallet=sw_wallet,
+ addr_type=addr_type,
+ acct_num=acct_num,
+ multisig=multisig,
+ legacy=legacy,
+ export_mode=export_mode,
+ qr_type=qr_type)
+
+ chain = chains.current_chain()
+ with stash.SensitiveValues() as sv:
+ node = sv.derive_path("m/45'")
+ source_fingerprint = int(xfp2str(settings.get('xfp')), 16)
+ cbor = create_unchained_hdkey_cbor(node.public_key(),
+ node.chain_code(),
+ source_fingerprint,
+ node.fingerprint(),
+ chain.ctype != 'BTC')
+
+ return (ur.new_raw('crypto-hdkey', cbor),
+ [{'fmt': AF_P2SH, 'deriv': "m/45'", 'acct': acct_num}])
+
+
+UnchainedWallet = {
+ 'label': 'Unchained',
+ 'sig_types': [
+ {'id': 'multisig', 'label': 'Multisig', 'addr_type': None,
+ 'create_wallet': create_unchained_export,
+ 'import_qr': read_multisig_config_from_qr,
+ 'import_microsd': read_multisig_config_from_microsd}
+ ],
+ 'export_modes': [
+ {'id': 'qr', 'label': 'QR Code', 'qr_type': QRType.UR2},
+ {'id': 'microsd', 'label': 'microSD',
+ 'filename_pattern': '{xfp}-unchained.json',
+ 'filename_pattern_multisig': '{xfp}-unchained-multisig.json'}
+ ]
+}Why this scored 24/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.