cardano: harden Cardano host xpub derivation checks
What changed, and why it matters
This commit hardens how the BitBox02 firmware derives and exposes Cardano public keys (xpubs) by computing them twice and comparing the results. This matches a safety pattern already used for Bitcoin, meant to catch rare hardware errors or malicious bit flips that could silently produce a wrong xpub. A wrong xpub could lead a wallet app to generate addresses the user does not actually control, so the change is a defensive security improvement.
Treat as a security-hardening fix and include in firmware release notes. Users relying on Cardano should update when available. No immediate emergency response is warranted because the issue is a missing defensive check rather than an actively exploitable remote vulnerability.
Security signals we found
Defensive bitflip / fault-injection mitigation added to xpub derivation
Commit message explicitly describes security relevance ('bitflip-mitigation pattern', 'critical Bitcoin xpub operations')
Cardano host-facing xpub derivation now double-computed and compared
No CVE, advisory, or researcher attribution present in commit materials
Evidence from the diff
The change introduces get_xpub_twice() in src/rust/bitbox02-rust/src/keystore/ed25519.rs, which calls get_xpub() twice and returns the result only if both the public key bytes and chain code match; otherwise it returns Err. The Cardano xpub and address APIs are switched from get_xpub() to get_xpub_twice(). The commit message explicitly frames this as applying the existing ‘bitflip-mitigation pattern’ to Cardano host-facing xpub operations. Tests are updated and a new test for get_xpub_twice() is added.
Changed components
src/rust/bitbox02-rust/src/keystore/ed25519.rssrc/rust/bitbox02-rust/src/hww/api/cardano/xpubs.rssrc/rust/bitbox02-rust/src/hww/api/cardano/address.rsInspect captured patch +85 / −25
diff --git a/src/rust/bitbox02-rust/src/hww/api/cardano/address.rs b/src/rust/bitbox02-rust/src/hww/api/cardano/address.rs
index 9669dc0..cc846b8 100644
--- a/src/rust/bitbox02-rust/src/hww/api/cardano/address.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/cardano/address.rs
@@ -305,7 +305,7 @@ pub fn pubkey_hash_at_keypath(
hal: &mut impl crate::hal::Hal,
keypath: &[u32],
) -> Result<[u8; ADDRESS_HASH_SIZE], ()> {
- let xpub = crate::keystore::ed25519::get_xpub(hal, keypath)?;
+ let xpub = crate::keystore::ed25519::get_xpub_twice(hal, keypath)?;
let pubkey_bytes = xpub.pubkey_bytes();
let mut hasher = Blake2bVar::new(ADDRESS_HASH_SIZE).unwrap();
hasher.update(pubkey_bytes);
diff --git a/src/rust/bitbox02-rust/src/hww/api/cardano/xpubs.rs b/src/rust/bitbox02-rust/src/hww/api/cardano/xpubs.rs
index c13dd84..630da62 100644
--- a/src/rust/bitbox02-rust/src/hww/api/cardano/xpubs.rs
+++ b/src/rust/bitbox02-rust/src/hww/api/cardano/xpubs.rs
@@ -20,7 +20,7 @@ pub fn process(
for pb::Keypath { keypath } in &request.keypaths {
validate_account_shelley(keypath)?;
- let xpub = crate::keystore::ed25519::get_xpub(hal, keypath)?;
+ let xpub = crate::keystore::ed25519::get_xpub_twice(hal, keypath)?;
let mut xpub_bytes = Vec::with_capacity(64);
xpub_bytes.extend_from_slice(xpub.pubkey_bytes());
xpub_bytes.extend_from_slice(xpub.chain_code());
@@ -34,6 +34,7 @@ mod tests {
use super::*;
use crate::keystore::testing::mock_unlocked;
+ use hex_lit::hex;
use util::bip32::HARDENED;
#[test]
@@ -77,18 +78,14 @@ mod tests {
),
Ok(Response::Xpubs(pb::CardanoXpubsResponse {
xpubs: vec![
- vec![
- 135, 93, 21, 165, 177, 234, 235, 114, 75, 217, 61, 109, 54, 203, 75, 97,
- 188, 69, 219, 186, 120, 219, 156, 176, 139, 147, 231, 40, 146, 89, 211,
- 216, 174, 223, 100, 1, 197, 31, 45, 152, 27, 1, 127, 215, 4, 53, 226, 217,
- 223, 160, 215, 78, 124, 206, 75, 146, 6, 29, 251, 8, 139, 95, 8, 206
- ],
- vec![
- 205, 217, 152, 187, 63, 149, 35, 26, 115, 72, 234, 223, 192, 248, 151, 77,
- 20, 221, 211, 158, 71, 189, 60, 40, 26, 217, 150, 150, 122, 49, 129, 126,
- 93, 199, 240, 91, 226, 212, 218, 106, 29, 25, 36, 178, 129, 146, 0, 184,
- 113, 4, 22, 225, 46, 250, 1, 192, 77, 21, 220, 167, 234, 215, 191, 233
- ]
+ hex!(
+ "875d15a5b1eaeb724bd93d6d36cb4b61bc45dbba78db9cb08b93e7289259d3d8aedf6401c51f2d981b017fd70435e2d9dfa0d74e7cce4b92061dfb088b5f08ce"
+ )
+ .to_vec(),
+ hex!(
+ "cdd998bb3f95231a7348eadfc0f8974d14ddd39e47bd3c281ad996967a31817e5dc7f05be2d4da6a1d1924b2819200b8710416e12efa01c04d15dca7ead7bfe9"
+ )
+ .to_vec(),
],
})),
);
diff --git a/src/rust/bitbox02-rust/src/keystore/ed25519.rs b/src/rust/bitbox02-rust/src/keystore/ed25519.rs
index 67cac57..db61d1f 100644
--- a/src/rust/bitbox02-rust/src/keystore/ed25519.rs
+++ b/src/rust/bitbox02-rust/src/keystore/ed25519.rs
@@ -59,6 +59,16 @@ pub fn get_xpub(hal: &mut impl crate::hal::Hal, keypath: &[u32]) -> Result<Xpub<
Ok(get_xprv(hal, keypath)?.public())
}
+pub fn get_xpub_twice(hal: &mut impl crate::hal::Hal, keypath: &[u32]) -> Result<Xpub<Sha512>, ()> {
+ let xpub = get_xpub(hal, keypath)?;
+ let xpub2 = get_xpub(hal, keypath)?;
+ if xpub.pubkey_bytes() == xpub2.pubkey_bytes() && xpub.chain_code() == xpub2.chain_code() {
+ Ok(xpub)
+ } else {
+ Err(())
+ }
+}
+
pub struct SignResult {
pub signature: [u8; 64],
pub public_key: ed25519_dalek::VerifyingKey,
@@ -87,6 +97,7 @@ mod tests {
use crate::keystore::testing::{mock_unlocked, mock_unlocked_using_mnemonic};
use bip32_ed25519::HARDENED_OFFSET;
use digest::Digest;
+ use hex_lit::hex;
#[test]
fn test_sha512() {
@@ -119,7 +130,9 @@ mod tests {
);
assert_eq!(
get_seed(&mut mock_hal).unwrap().as_slice(),
- b"\xa0\x8c\xf8\x5b\x56\x4e\xcf\x3b\x94\x7d\x8d\x43\x21\xfb\x96\xd7\x0e\xe7\xbb\x76\x08\x77\xe3\x71\x89\x9b\x14\xe2\xcc\xf8\x86\x58\x10\x4b\x88\x46\x82\xb5\x7e\xfd\x97\xde\xcb\xb3\x18\xa4\x5c\x05\xa5\x27\xb9\xcc\x5c\x2f\x64\xf7\x35\x29\x35\xa0\x49\xce\xea\x60\x68\x0d\x52\x30\x81\x94\xcc\xef\x2a\x18\xe6\x81\x2b\x45\x2a\x58\x15\xfb\xd7\xf5\xba\xbc\x08\x38\x56\x91\x9a\xaf\x66\x8f\xe7\xe4",
+ &hex!(
+ "a08cf85b564ecf3b947d8d4321fb96d70ee7bb760877e371899b14e2ccf88658104b884682b57efd97decbb318a45c05a527b9cc5c2f64f7352935a049ceea60680d52308194ccef2a18e6812b452a5815fbd7f5babc083856919aaf668fe7e4"
+ ),
);
// Multiple loop iterations.
@@ -129,7 +142,9 @@ mod tests {
);
assert_eq!(
get_seed(&mut mock_hal).unwrap().as_slice(),
- b"\x58\x7c\x67\x74\x35\x7e\xcb\xf8\x40\xd4\xdb\x64\x04\xff\x7a\xf0\x16\xda\xce\x04\x00\x76\x97\x51\xad\x2a\xbf\xc7\x7b\x9a\x38\x44\xcc\x71\x70\x25\x20\xef\x1a\x4d\x1b\x68\xb9\x11\x87\x78\x7a\x9b\x8f\xaa\xb0\xa9\xbb\x6b\x16\x0d\xe5\x41\xb6\xee\x62\x46\x99\x01\xfc\x0b\xed\xa0\x97\x5f\xe4\x76\x3b\xea\xbd\x83\xb7\x05\x1a\x5f\xd5\xcb\xce\x5b\x88\xe8\x2c\x4b\xba\xca\x26\x50\x14\xe5\x24\xbd",
+ &hex!(
+ "587c6774357ecbf840d4db6404ff7af016dace0400769751ad2abfc77b9a3844cc71702520ef1a4d1b68b91187787a9b8faab0a9bb6b160de541b6ee62469901fc0beda0975fe4763beabd83b7051a5fd5cbce5b88e82c4bbaca265014e524bd"
+ ),
);
mock_unlocked_using_mnemonic(
@@ -138,7 +153,9 @@ mod tests {
);
assert_eq!(
get_seed(&mut mock_hal).unwrap().as_slice(),
- b"\xf0\x53\xa1\xe7\x52\xde\x5c\x26\x19\x7b\x60\xf0\x32\xa4\x80\x9f\x08\xbb\x3e\x5d\x90\x48\x4f\xe4\x20\x24\xbe\x31\xef\xcb\xa7\x57\x8d\x91\x4d\x3f\xf9\x92\xe2\x16\x52\xfe\xe6\xa4\xd9\x9f\x60\x91\x00\x69\x38\xfa\xc2\xc0\xc0\xf9\xd2\xde\x0b\xa6\x4b\x75\x4e\x92\xa4\xf3\x72\x3f\x23\x47\x20\x77\xaa\x4c\xd4\xdd\x8a\x8a\x17\x5d\xba\x07\xea\x18\x52\xda\xd1\xcf\x26\x8c\x61\xa2\x67\x9c\x38\x90",
+ &hex!(
+ "f053a1e752de5c26197b60f032a4809f08bb3e5d90484fe42024be31efcba7578d914d3ff992e21652fee6a4d99f6091006938fac2c0c0f9d2de0ba64b754e92a4f3723f23472077aa4cd4dd8a8a175dba07ea1852dad1cf268c61a2679c3890"
+ ),
);
}
@@ -153,12 +170,43 @@ mod tests {
mock_unlocked();
let xpub = get_xpub(&mut mock_hal, &[]).unwrap();
- assert_eq!(xpub.pubkey_bytes(), b"\x1c\xc2\xc8\x0d\x6f\xb0\x3e\xc0\x9e\x8a\x26\x8b\xaa\x45\xd4\xca\x2a\xfe\x5c\x5a\xc4\xdb\x3e\xe2\x9c\x7a\xd2\x37\x55\xab\xdc\x14");
- assert_eq!(xpub.chain_code(), b"\xf0\xa5\x91\x06\x42\xd0\x77\x98\x17\x40\x2e\x5e\x7a\x75\x54\x95\xe7\x44\xf5\x5c\xf1\x1e\x49\xee\xfd\x22\xa4\x60\xe9\xb2\xf7\x53");
+ assert_eq!(
+ xpub.pubkey_bytes(),
+ &hex!("1cc2c80d6fb03ec09e8a268baa45d4ca2afe5c5ac4db3ee29c7ad23755abdc14")
+ );
+ assert_eq!(
+ xpub.chain_code(),
+ &hex!("f0a5910642d0779817402e5e7a755495e744f55cf11e49eefd22a460e9b2f753")
+ );
let xpub = get_xpub(&mut mock_hal, &[10 + HARDENED_OFFSET, 10]).unwrap();
- assert_eq!(xpub.pubkey_bytes(), b"\xab\x58\xbd\x94\x7e\x2b\xf6\x64\xa7\xc0\x66\xde\x2e\xf0\x24\x0e\xfc\x24\xf3\x6e\xfd\x50\x2d\xf8\x83\x93\xe1\x96\xaf\x3c\x91\x8e");
- assert_eq!(xpub.chain_code(), b"\xf2\x00\x13\x38\x58\x02\xa6\xf9\xc0\x5e\xe7\xb0\x36\x16\xad\xf6\x9f\x5f\x9e\xc4\x32\x53\xa5\xd0\x8b\xe9\x65\x79\x81\x90\x83\xbb");
+ assert_eq!(
+ xpub.pubkey_bytes(),
+ &hex!("ab58bd947e2bf664a7c066de2ef0240efc24f36efd502df88393e196af3c918e")
+ );
+ assert_eq!(
+ xpub.chain_code(),
+ &hex!("f20013385802a6f9c05ee7b03616adf69f5f9ec43253a5d08be96579819083bb")
+ );
+ }
+
+ #[test]
+ fn test_get_xpub_twice() {
+ crate::keystore::lock();
+ let mut mock_hal = crate::hal::testing::TestingHal::new();
+
+ assert!(get_xpub_twice(&mut mock_hal, &[]).is_err());
+
+ mock_unlocked();
+ let xpub = get_xpub_twice(&mut mock_hal, &[]).unwrap();
+ assert_eq!(
+ xpub.pubkey_bytes(),
+ &hex!("1cc2c80d6fb03ec09e8a268baa45d4ca2afe5c5ac4db3ee29c7ad23755abdc14")
+ );
+ assert_eq!(
+ xpub.chain_code(),
+ &hex!("f0a5910642d0779817402e5e7a755495e744f55cf11e49eefd22a460e9b2f753")
+ );
}
#[test]
@@ -171,10 +219,20 @@ mod tests {
mock_unlocked();
let xprv = get_xprv(&mut mock_hal, &[]).unwrap();
- assert_eq!(xprv.expanded_secret_key().as_slice(), b"\xf8\xcb\x28\x85\x37\x60\x2b\x90\xd1\x29\x75\x4b\xdd\x0e\x4b\xed\xf9\xe2\x92\x3a\x04\xb6\x86\x7e\xdb\xeb\xc7\x93\xa7\x17\x6f\x5d\xca\xc5\xc9\x5d\x5f\xd2\x3a\x8e\x01\x6c\x95\x57\x69\x0e\xad\x1f\x00\x2b\x0f\x35\xd7\x06\xff\x8e\x59\x84\x1c\x09\xe0\xb6\xbb\x23");
+ assert_eq!(
+ xprv.expanded_secret_key().as_slice(),
+ &hex!(
+ "f8cb288537602b90d129754bdd0e4bedf9e2923a04b6867edbebc793a7176f5dcac5c95d5fd23a8e016c9557690ead1f002b0f35d706ff8e59841c09e0b6bb23"
+ )
+ );
let xprv = get_xprv(&mut mock_hal, &[10 + HARDENED_OFFSET, 10]).unwrap();
- assert_eq!(xprv.expanded_secret_key().as_slice(), b"\x00\x28\x46\xb1\xeb\x06\x66\xff\x4e\xf1\x66\xde\x37\x80\xdf\xe1\x95\xed\x6f\xfd\xce\x41\x18\x09\x9d\x9d\x80\x85\xaa\x17\x6f\x5d\x1f\xcf\xf9\x55\x2e\xe4\xc0\xcb\x03\xaa\x42\x1a\xe8\x2f\x98\xa0\x0a\xfc\x65\xb6\x84\x66\x31\xaa\x41\x8e\x6d\x5a\x62\x6e\x75\xf4");
+ assert_eq!(
+ xprv.expanded_secret_key().as_slice(),
+ &hex!(
+ "002846b1eb0666ff4ef166de3780dfe195ed6ffdce4118099d9d8085aa176f5d1fcff9552ee4c0cb03aa421ae82f98a00afc65b6846631aa418e6d5a626e75f4"
+ )
+ );
}
#[test]
@@ -197,10 +255,15 @@ mod tests {
msg,
)
.unwrap();
- assert_eq!(sig.public_key.as_ref(), b"\xab\x58\xbd\x94\x7e\x2b\xf6\x64\xa7\xc0\x66\xde\x2e\xf0\x24\x0e\xfc\x24\xf3\x6e\xfd\x50\x2d\xf8\x83\x93\xe1\x96\xaf\x3c\x91\x8e");
+ assert_eq!(
+ sig.public_key.as_ref(),
+ &hex!("ab58bd947e2bf664a7c066de2ef0240efc24f36efd502df88393e196af3c918e")
+ );
assert_eq!(
sig.signature,
- *b"\x6c\x9b\xc4\x0e\x34\xe2\xa9\xb7\x88\x5e\xec\x72\xc0\x60\xba\x76\x9f\xe3\xa7\x4c\x9b\x14\x4b\xbf\x63\xf4\xd5\x4e\xa6\x66\x04\x31\x34\x25\x0e\xb2\x7d\xd3\x42\x28\x47\x5d\x7c\x6b\x54\x32\xd7\x37\x42\xf4\xb5\xa0\x98\xf4\x65\xba\x10\x1e\x90\xd1\x00\x35\x68\x01"
+ hex!(
+ "6c9bc40e34e2a9b7885eec72c060ba769fe3a74c9b144bbf63f4d54ea666043134250eb27dd34228475d7c6b5432d73742f4b5a098f465ba101e90d100356801"
+ )
);
}
}
Why this scored 58/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.