From 0daeaae77f55ee9f8eb9a35fb162496aaaf6831e Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 13 Aug 2026 00:01:15 +0200 Subject: [PATCH 1/3] Export Casa pairing as a crypto-account QR --- extmod/foundation-rust/include/foundation.h | 30 ++++ extmod/foundation-rust/src/ur/encoder.rs | 22 ++- extmod/foundation-rust/src/ur/registry.rs | 163 ++++++++++++++++++ extmod/foundation/modfoundation-ur.h | 65 +++++++ .../boards/Passport/modules/wallets/casa.py | 21 ++- 5 files changed, 282 insertions(+), 19 deletions(-) diff --git a/extmod/foundation-rust/include/foundation.h b/extmod/foundation-rust/include/foundation.h index 584199d16..3544cb639 100644 --- a/extmod/foundation-rust/include/foundation.h +++ b/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; @@ -649,6 +668,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. */ diff --git a/extmod/foundation-rust/src/ur/encoder.rs b/extmod/foundation-rust/src/ur/encoder.rs index 1b8486ed5..2e2612321 100644 --- a/extmod/foundation-rust/src/ur/encoder.rs +++ b/extmod/foundation-rust/src/ur/encoder.rs @@ -99,21 +99,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), ); diff --git a/extmod/foundation-rust/src/ur/registry.rs b/extmod/foundation-rust/src/ur/registry.rs index 955d7a92e..f119cd6b2 100644 --- a/extmod/foundation-rust/src/ur/registry.rs +++ b/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( + &self, + e: &mut Encoder, + key_data: &[u8; 33], + chain_code: &[u8; 32], + casa_key: bool, + ) -> Result<(), minicbor::encode::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 Encode for UR_CryptoAccount { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut C, + ) -> Result<(), minicbor::encode::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])); + } + } +} diff --git a/extmod/foundation/modfoundation-ur.h b/extmod/foundation/modfoundation-ur.h index 9ac5cfb71..c7d44fad5 100644 --- a/extmod/foundation/modfoundation-ur.h +++ b/extmod/foundation/modfoundation-ur.h @@ -91,6 +91,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; @@ -427,6 +430,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: /// """ /// """ @@ -684,6 +748,7 @@ STATIC const mp_rom_map_elem_t mod_foundation_ur_globals_table[] = { {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_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)}, diff --git a/ports/stm32/boards/Passport/modules/wallets/casa.py b/ports/stm32/boards/Passport/modules/wallets/casa.py index 97c03d1fb..cfc95534c 100644 --- a/ports/stm32/boards/Passport/modules/wallets/casa.py +++ b/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 = '''\ From 21f1e1cd21550a59d96bffec1cf5833d02914f33 Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 13 Aug 2026 00:23:28 +0200 Subject: [PATCH 2/3] Export both Casa keys to microSD --- ports/stm32/boards/Passport/modules/wallets/casa.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ports/stm32/boards/Passport/modules/wallets/casa.py b/ports/stm32/boards/Passport/modules/wallets/casa.py index cfc95534c..bf81ed464 100644 --- a/ports/stm32/boards/Passport/modules/wallets/casa.py +++ b/ports/stm32/boards/Passport/modules/wallets/casa.py @@ -11,6 +11,8 @@ from data_codecs.qr_type import QRType from foundation import ur +CASA_PATH = "m/45'" + def create_casa_export(sw_wallet=None, addr_type=None, @@ -34,7 +36,7 @@ def create_casa_export(sw_wallet=None, is_mainnet = chain.ctype == 'BTC' network = ur.NETWORK_MAINNET if is_mainnet else ur.NETWORK_TESTNET - casa_node = sv.derive_path("m/45'") + casa_node = sv.derive_path(CASA_PATH) account = ur.new_crypto_account( sv.node.public_key(), sv.node.chain_code(), @@ -63,7 +65,12 @@ def create_casa_export(sw_wallet=None, # Top-level, 'master' extended public key ('m/'): {xpub} + + # Casa extended public key ("m/45'"): + + {casa_xpub} '''.format(nb=chain.name, xpub=chain.serialize_public(sv.node), + casa_xpub=chain.serialize_public(sv.derive_path(CASA_PATH)), sym=chain.ctype, ct=chain.b44_cointype, xfp=xfp2str(settings.get('xfp'))) return (s, None) # No 'acct_info' From af41e63810753b1738ad3e9e38eee274c95513ed Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 13 Aug 2026 10:00:00 +0200 Subject: [PATCH 3/3] Simplify Casa microSD pairing file --- .../stm32/boards/Passport/modules/wallets/casa.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/ports/stm32/boards/Passport/modules/wallets/casa.py b/ports/stm32/boards/Passport/modules/wallets/casa.py index bf81ed464..b97b64b50 100644 --- a/ports/stm32/boards/Passport/modules/wallets/casa.py +++ b/ports/stm32/boards/Passport/modules/wallets/casa.py @@ -52,16 +52,6 @@ def create_casa_export(sw_wallet=None, # Passport Summary File # For wallet with master key fingerprint: {xfp} - Wallet operates on blockchain: {nb} - - For BIP44, this is coin_type '{ct}', and internally we use - symbol {sym} for this blockchain. - - # IMPORTANT WARNING - - Do **not** deposit to any address in this file unless you have a working - wallet system that is ready to handle the funds at that address! - # Top-level, 'master' extended public key ('m/'): {xpub} @@ -69,9 +59,9 @@ def create_casa_export(sw_wallet=None, # Casa extended public key ("m/45'"): {casa_xpub} - '''.format(nb=chain.name, xpub=chain.serialize_public(sv.node), + '''.format(xpub=chain.serialize_public(sv.node), casa_xpub=chain.serialize_public(sv.derive_path(CASA_PATH)), - sym=chain.ctype, ct=chain.b44_cointype, xfp=xfp2str(settings.get('xfp'))) + xfp=xfp2str(settings.get('xfp'))) return (s, None) # No 'acct_info'