Export Casa pairing as a crypto-account QR
What changed, and why it matters
This commit adds a new way for the Passport hardware wallet to export wallet pairing information to Casa, a Bitcoin custody service. Instead of exporting a single key, it now exports a 'crypto-account' QR code containing two related public keys: the wallet's root public key and a separate Casa-specific public key derived from path m/45'. The change is a feature addition; there is no direct evidence in the commit that it fixes a security vulnerability, but it does change what key material is exposed during pairing and how it is encoded.
Review the Casa pairing UX to ensure the user is clearly informed that two public keys (root and m/45') are being exported. Verify that the `m/45'` derivation is the intended path for Casa registration and that the CBOR tags and output descriptors cannot be misinterpreted by Casa or other scanners. Consider whether the new `new_crypto_account` binding needs additional validation or access controls before release.
Security signals we found
New key export surface: adds `new_crypto_account` API that exports two public keys (root + m/45' derived) instead of one
CBOR/UR encoding changes in Rust firmware with custom tag handling
Sensitive key derivation path m/45' introduced for Casa pairing
No input sanitization beyond length checks on the 33-byte public keys and 32-byte chain codes
No explicit security claim or CVE reference in commit message or diff
Evidence from the diff
The patch introduces a new UR (Uniform Resource) type, crypto-account, for Casa wallet registration. It adds Rust and MicroPython bindings (ur_registry_new_crypto_account, mod_foundation_ur_new_crypto_account) and a custom CBOR encoder in extmod/foundation-rust/src/ur/registry.rs. The Casa wallet flow in ports/stm32/boards/Passport/modules/wallets/casa.py is updated to call ur.new_crypto_account() with the root public key/chain code and a derived m/45' public key/chain code, replacing the previous single hdkey export. The encoder uses legacy BCUR tags (303, 304, 305) and newer tags (308, 400, 404) to construct two crypto-output entries inside a crypto-account map. A unit test pins the expected CBOR wire format.
Changed components
extmod/foundation-rust/src/ur/registry.rsextmod/foundation-rust/include/foundation.hextmod/foundation/modfoundation-ur.hports/stm32/boards/Passport/modules/wallets/casa.pyInspect captured patch +282 / −19
### extmod/foundation-rust/include/foundation.h
@@ -421,6 +421,18 @@ typedef struct {
bool has_passport_firmware_version;
} UR_PassportResponse;
+/**
+ * A Casa `crypto-account` containing both supported registration keys.
+ */
+typedef struct {
+ uint32_t master_fingerprint;
+ uint64_t network;
+ uint8_t root_key_data[33];
+ uint8_t root_chain_code[32];
+ uint8_t casa_key_data[33];
+ uint8_t casa_chain_code[32];
+} UR_CryptoAccount;
+
/**
* A uniform resource.
*/
@@ -445,6 +457,10 @@ typedef enum {
* Passport custom `x-passport-response`.
*/
PassportResponse,
+ /**
+ * Casa wallet-registration `crypto-account`.
+ */
+ CryptoAccount,
} UR_Value_Tag;
typedef struct {
@@ -471,6 +487,9 @@ typedef struct {
struct {
UR_PassportResponse passport_response;
};
+ struct {
+ UR_CryptoAccount crypto_account;
+ };
};
} UR_Value;
@@ -665,6 +684,17 @@ void ur_registry_new_derived_key(UR_Value *value,
const UR_Keypath *origin,
uint32_t parent_fingerprint);
+/**
+ * Create the Casa wallet-registration `crypto-account` UR.
+ */
+void ur_registry_new_crypto_account(UR_Value *value,
+ const uint8_t (*root_key_data)[33],
+ const uint8_t (*root_chain_code)[32],
+ const uint8_t (*casa_key_data)[33],
+ const uint8_t (*casa_chain_code)[32],
+ uint32_t master_fingerprint,
+ uint64_t network);
+
/**
* Create a new `psbt` UR.
*/
### extmod/foundation-rust/src/ur/encoder.rs
@@ -107,21 +107,27 @@ pub unsafe extern "C" fn ur_encoder_start(
value: &UR_Value,
max_chars: usize,
) {
- // SAFETY: The UR_Value can contain some raw pointers which need to be
- // accessed in order to convert it to a `ur::registry::BaseValue` which
- // is then encoded below, so the pointers lifetime only need to be valid
- // for the scope of this function.
- let value = unsafe { value.to_value() };
-
// SAFETY: This code assumes that runs on a single thread.
let message = unsafe { &mut *ptr::addr_of_mut!(UR_ENCODER_MESSAGE) };
message.clear();
let mut e = Encoder::new(Writer(message));
- value.encode(&mut e, &mut ()).expect("Couldn't encode UR");
+ let ur_type = match value {
+ UR_Value::CryptoAccount(account) => {
+ account.encode(&mut e, &mut ()).expect("Couldn't encode UR");
+ crate::ur::registry::UR_CryptoAccount::UR_TYPE
+ }
+ _ => {
+ // SAFETY: Other UR values may contain pointers which remain valid
+ // for this call, as required by this function's contract.
+ let value = unsafe { value.to_value() };
+ value.encode(&mut e, &mut ()).expect("Couldn't encode UR");
+ value.ur_type()
+ }
+ };
encoder.inner.start(
- value.ur_type(),
+ ur_type,
message,
max_fragment_len(UR_MAX_TYPE, usize::MAX, max_chars),
);
### extmod/foundation-rust/src/ur/registry.rs
@@ -14,6 +14,7 @@ use foundation_urtypes::{
value,
value::Value,
};
+use minicbor::{data::Tag, encode::Write, Encode, Encoder};
use uuid::Uuid;
@@ -38,6 +39,8 @@ pub enum UR_Value {
PassportRequest(UR_PassportRequest),
/// Passport custom `x-passport-response`.
PassportResponse(UR_PassportResponse),
+ /// Casa wallet-registration `crypto-account`.
+ CryptoAccount(UR_CryptoAccount),
}
impl UR_Value {
@@ -90,6 +93,9 @@ impl UR_Value {
Value::Psbt(buf)
}
UR_Value::HDKey(v) => Value::HDKey(v.into()),
+ UR_Value::CryptoAccount(_) => panic!(
+ "CryptoAccount is encoded directly. Should be unreachable"
+ ),
UR_Value::PassportRequest(_) => panic!(
"Not implemented as it isn't needed. Should be unreachable"
),
@@ -98,6 +104,99 @@ impl UR_Value {
}
}
+/// A Casa `crypto-account` containing both supported registration keys.
+#[derive(Debug)]
+#[repr(C)]
+pub struct UR_CryptoAccount {
+ pub master_fingerprint: u32,
+ pub network: u64,
+ pub root_key_data: [u8; 33],
+ pub root_chain_code: [u8; 32],
+ pub casa_key_data: [u8; 33],
+ pub casa_chain_code: [u8; 32],
+}
+
+impl UR_CryptoAccount {
+ pub const UR_TYPE: &'static str = "crypto-account";
+
+ const TAG_CRYPTO_OUTPUT: Tag = Tag::new(308);
+ const TAG_SCRIPT_HASH: Tag = Tag::new(400);
+ const TAG_WITNESS_PUBLIC_KEY_HASH: Tag = Tag::new(404);
+ const TAG_HDKEY_LEGACY: Tag = Tag::new(303);
+ const TAG_KEYPATH_LEGACY: Tag = Tag::new(304);
+ const TAG_COIN_INFO_LEGACY: Tag = Tag::new(305);
+
+ fn encode_output<W: Write>(
+ &self,
+ e: &mut Encoder<W>,
+ key_data: &[u8; 33],
+ chain_code: &[u8; 32],
+ casa_key: bool,
+ ) -> Result<(), minicbor::encode::Error<W::Error>> {
+ e.tag(Self::TAG_CRYPTO_OUTPUT)?
+ .tag(Self::TAG_SCRIPT_HASH)?
+ .tag(Self::TAG_WITNESS_PUBLIC_KEY_HASH)?
+ .tag(Self::TAG_HDKEY_LEGACY)?
+ .map(if casa_key { 5 } else { 4 })?
+ .u8(3)?
+ .bytes(key_data)?
+ .u8(4)?
+ .bytes(chain_code)?
+ .u8(5)?
+ .tag(Self::TAG_COIN_INFO_LEGACY)?;
+
+ if self.network == UR_NETWORK_MAINNET as u64 {
+ e.map(0)?;
+ } else {
+ e.map(1)?.u8(2)?.u64(self.network)?;
+ }
+
+ e.u8(6)?.tag(Self::TAG_KEYPATH_LEGACY)?.map(3)?.u8(1)?;
+ if casa_key {
+ e.array(2)?.u32(45)?.bool(true)?;
+ } else {
+ e.array(0)?;
+ }
+ e.u8(2)?
+ .u32(self.master_fingerprint)?
+ .u8(3)?
+ .u8(u8::from(casa_key))?;
+
+ if casa_key {
+ e.u8(8)?.u32(self.master_fingerprint)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl<C> Encode<C> for UR_CryptoAccount {
+ fn encode<W: Write>(
+ &self,
+ e: &mut Encoder<W>,
+ _ctx: &mut C,
+ ) -> Result<(), minicbor::encode::Error<W::Error>> {
+ e.map(2)?
+ .u8(1)?
+ .u32(self.master_fingerprint)?
+ .u8(2)?
+ .array(2)?;
+ self.encode_output(
+ e,
+ &self.root_key_data,
+ &self.root_chain_code,
+ false,
+ )?;
+ self.encode_output(
+ e,
+ &self.casa_key_data,
+ &self.casa_chain_code,
+ true,
+ )?;
+ Ok(())
+ }
+}
+
/// A `hdkey`.
#[repr(C)]
pub enum UR_HDKey {
@@ -445,6 +544,27 @@ pub extern "C" fn ur_registry_new_derived_key(
}));
}
+/// Create the Casa wallet-registration `crypto-account` UR.
+#[no_mangle]
+pub extern "C" fn ur_registry_new_crypto_account(
+ value: &mut UR_Value,
+ root_key_data: &[u8; 33],
+ root_chain_code: &[u8; 32],
+ casa_key_data: &[u8; 33],
+ casa_chain_code: &[u8; 32],
+ master_fingerprint: u32,
+ network: u64,
+) {
+ *value = UR_Value::CryptoAccount(UR_CryptoAccount {
+ master_fingerprint,
+ network,
+ root_key_data: *root_key_data,
+ root_chain_code: *root_chain_code,
+ casa_key_data: *casa_key_data,
+ casa_chain_code: *casa_chain_code,
+ });
+}
+
/// Create a new `psbt` UR.
#[no_mangle]
pub extern "C" fn ur_registry_new_psbt(
@@ -476,3 +596,46 @@ pub extern "C" fn ur_registry_new_passport_response(
has_passport_firmware_version: true,
})
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use minicbor::encode::write::Cursor;
+
+ #[test]
+ fn casa_crypto_account_wire_format_is_pinned() {
+ let account = UR_CryptoAccount {
+ master_fingerprint: 0x1234_5678,
+ network: UR_NETWORK_MAINNET as u64,
+ root_key_data: [2; 33],
+ root_chain_code: [3; 32],
+ casa_key_data: [4; 33],
+ casa_chain_code: [5; 32],
+ };
+ let mut output = Cursor::new([0u8; 256]);
+ account
+ .encode(&mut Encoder::new(&mut output), &mut ())
+ .unwrap();
+
+ let expected = concat!(
+ "a2011a123456780282d90134d90190d90194d9012fa40358210202020202020202020202020202020202020202",
+ "020202020202020202020202020458200303030303030303030303030303030303030303030303030303030303",
+ "03030305d90131a006d90130a30180021a123456780300d90134d90190d90194d9012fa5035821040404040404",
+ "040404040404040404040404040404040404040404040404040404045820050505050505050505050505050505",
+ "050505050505050505050505050505050505d90131a006d90130a30182182df5021a123456780301081a123456",
+ "78",
+ );
+ let encoded = &output.get_ref()[..output.position()];
+ assert_eq!(encoded.len() * 2, expected.len());
+ for (actual, expected) in
+ encoded.iter().zip(expected.as_bytes().chunks_exact(2))
+ {
+ let nibble = |byte| match byte {
+ b'0'..=b'9' => byte - b'0',
+ b'a'..=b'f' => byte - b'a' + 10,
+ _ => unreachable!(),
+ };
+ assert_eq!(*actual, nibble(expected[0]) << 4 | nibble(expected[1]));
+ }
+ }
+}
### extmod/foundation/modfoundation-ur.h
@@ -92,6 +92,9 @@ STATIC void mod_foundation_ur_Value_print(const mp_print_t *print,
case HDKey:
mp_print_str(print, "UR_Value::HDKey");
break;
+ case CryptoAccount:
+ mp_print_str(print, "UR_Value::CryptoAccount");
+ break;
case Psbt:
mp_print_str(print, "UR_Value::Psbt");
break;
@@ -476,6 +479,67 @@ STATIC MP_DEFINE_CONST_FUN_OBJ_KW(mod_foundation_ur_new_derived_key_obj,
1,
mod_foundation_ur_new_derived_key);
+/// def new_crypto_account(root_key_data,
+/// root_chain_code,
+/// casa_key_data,
+/// casa_chain_code,
+/// master_fingerprint,
+/// network) -> Value:
+/// """
+/// Create Casa's two-key wallet-registration payload.
+/// """
+STATIC mp_obj_t mod_foundation_ur_new_crypto_account(size_t n_args,
+ const mp_obj_t *pos_args,
+ mp_map_t *kw_args)
+{
+ mp_buffer_info_t root_key_data = {0};
+ mp_buffer_info_t root_chain_code = {0};
+ mp_buffer_info_t casa_key_data = {0};
+ mp_buffer_info_t casa_chain_code = {0};
+ UR_Value value = {0};
+
+ static const mp_arg_t allowed_args[] = {
+ { MP_QSTR_root_key_data, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
+ { MP_QSTR_root_chain_code, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
+ { MP_QSTR_casa_key_data, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
+ { MP_QSTR_casa_chain_code, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
+ { MP_QSTR_master_fingerprint, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
+ { MP_QSTR_network, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} },
+ };
+
+ mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];
+ mp_arg_parse_all(n_args, pos_args, kw_args,
+ MP_ARRAY_SIZE(allowed_args), allowed_args, args);
+
+ mp_get_buffer_raise(args[0].u_obj, &root_key_data, MP_BUFFER_READ);
+ mp_get_buffer_raise(args[1].u_obj, &root_chain_code, MP_BUFFER_READ);
+ mp_get_buffer_raise(args[2].u_obj, &casa_key_data, MP_BUFFER_READ);
+ mp_get_buffer_raise(args[3].u_obj, &casa_chain_code, MP_BUFFER_READ);
+
+ if (root_key_data.len != 33 || casa_key_data.len != 33) {
+ mp_raise_msg(&mp_type_ValueError,
+ MP_ERROR_TEXT("key data should be 33 bytes"));
+ }
+ if (root_chain_code.len != 32 || casa_chain_code.len != 32) {
+ mp_raise_msg(&mp_type_ValueError,
+ MP_ERROR_TEXT("chain code should be 32 bytes"));
+ }
+
+ ur_registry_new_crypto_account(
+ &value,
+ root_key_data.buf,
+ root_chain_code.buf,
+ casa_key_data.buf,
+ casa_chain_code.buf,
+ mp_obj_int_get_uint_checked(args[4].u_obj),
+ mp_obj_int_get_uint_checked(args[5].u_obj));
+
+ return MP_OBJ_FROM_PTR(mod_foundation_ur_Value_new(&value));
+}
+STATIC MP_DEFINE_CONST_FUN_OBJ_KW(mod_foundation_ur_new_crypto_account_obj,
+ 4,
+ mod_foundation_ur_new_crypto_account);
+
/// def new_psbt(data: bytes) -> Value:
/// """
/// """
@@ -749,6 +813,7 @@ STATIC const mp_rom_map_elem_t mod_foundation_ur_globals_table[] = {
{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_crypto_account), MP_ROM_PTR(&mod_foundation_ur_new_crypto_account_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/modules/wallets/casa.py
@@ -34,17 +34,16 @@ def create_casa_export(sw_wallet=None,
is_mainnet = chain.ctype == 'BTC'
network = ur.NETWORK_MAINNET if is_mainnet else ur.NETWORK_TESTNET
- use_info = ur.CoinInfo(ur.CoinType.BTC, network)
- origin = ur.Keypath(source_fingerprint=int(xfp2str(settings.get('xfp')), 16),
- depth=0)
-
- hdkey = ur.new_derived_key(sv.node.public_key(),
- is_private=False,
- chain_code=sv.node.chain_code(),
- use_info=use_info,
- origin=origin)
-
- return (hdkey, None)
+ casa_node = sv.derive_path("m/45'")
+ account = ur.new_crypto_account(
+ sv.node.public_key(),
+ sv.node.chain_code(),
+ casa_node.public_key(),
+ casa_node.chain_code(),
+ master_fingerprint=int(xfp2str(settings.get('xfp')), 16),
+ network=network)
+
+ return (account, None)
else:
with stash.SensitiveValues() as sv:
s = '''\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.