Merge pull request #665 from Foundation-Devices/casa-crypto-account-export
What changed, and why it matters
This commit adds a new feature to Foundation's Passport hardware wallet that exports two cryptographic public keys for Casa wallet registration: the master extended public key and a separate Casa-specific key derived at path m/45'. The keys can be exported either as a QR code or saved to a microSD card. The change is a feature addition, not a bug fix, and there is no evidence in the commit that it addresses a security vulnerability. However, exporting additional key material always slightly increases the attack surface if the exported data is mishandled.
Treat this as a routine feature merge, not a security patch. Reviewers may optionally verify that the Casa `m/45'` derivation is non-hardened public-key derivation as expected, that the exported UR is only emitted after the user confirms the Casa export flow, and that the microSD/QR output is not cached or logged. No urgent action is required.
Security signals we found
New key-export surface: two public keys (master + m/45' derived) are now exported together
Sensitive material is public-key/chain-code only; no private keys are exported
Export channels remain QR and microSD, unchanged from prior Casa export behavior
No input sanitization changes beyond existing length checks (33-byte key data, 32-byte chain code)
No vendor security disclosure or bug-fix language present in commit
Evidence from the diff
The merge adds a crypto-account Uniform Resource (UR) encoder for Casa pairing. It introduces UR_CryptoAccount in Rust/C bindings and a MicroPython wrapper ur.new_crypto_account(...), then updates wallets/casa.py to derive a m/45' public key and bundle it with the master public key. The QR and microSD export paths now output both keys. The implementation includes a pinned wire-format test. No memory-safety bugs, buffer overflows, or authentication bypasses are visible in the diff. The change is additive and does not modify existing security boundaries.
Changed components
extmod/foundation-rust/src/ur/registry.rsextmod/foundation-rust/src/ur/encoder.rsextmod/foundation/modfoundation-ur.hextmod/foundation-rust/include/foundation.hports/stm32/boards/Passport/modules/wallets/casa.pyInspect captured patch +291 / −31
### 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
@@ -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,38 +36,32 @@ 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(CASA_PATH)
+ 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 = '''\
# 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}
- '''.format(nb=chain.name, xpub=chain.serialize_public(sv.node),
- sym=chain.ctype, ct=chain.b44_cointype, xfp=xfp2str(settings.get('xfp')))
+
+ # Casa extended public key ("m/45'"):
+
+ {casa_xpub}
+ '''.format(xpub=chain.serialize_public(sv.node),
+ casa_xpub=chain.serialize_public(sv.derive_path(CASA_PATH)),
+ xfp=xfp2str(settings.get('xfp')))
return (s, None) # No 'acct_info'
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.