Skip to content

Commit 90bacf7

Browse files
CopilotSteake
andauthored
Integrate hardware wallet support (Ledger & Trezor) (#101)
* Initial plan * Add hardware wallet support - Ledger and Trezor integration Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Address code review feedback - fix unused variables and security warnings Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Add implementation summary documentation Co-authored-by: Steake <530040+Steake@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
1 parent 401c02c commit 90bacf7

8 files changed

Lines changed: 1956 additions & 70 deletions

File tree

crates/bitcell-wallet/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,17 @@ clap = { version = "4", features = ["derive"] }
4747
zeroize.workspace = true
4848
parking_lot.workspace = true
4949

50+
# Hardware wallet support (optional)
51+
ledger-transport-hid = { version = "0.10", optional = true }
52+
ledger-apdu = { version = "0.10", optional = true }
53+
hidapi = { version = "1.4", optional = true }
54+
5055
[dev-dependencies]
5156
proptest.workspace = true
5257

5358
[features]
5459
default = []
5560
bitcoin = []
5661
ethereum = []
62+
ledger = ["ledger-transport-hid", "ledger-apdu", "hidapi"]
63+
trezor = ["hidapi"]
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
//! Ledger hardware wallet integration
2+
//!
3+
//! This module provides support for Ledger Nano S/X devices.
4+
//!
5+
//! # Device Requirements
6+
//! - Ledger device with BitCell app installed (falls back to generic Ethereum app)
7+
//! - USB connection
8+
//! - Device unlocked (PIN entered)
9+
//!
10+
//! # Security
11+
//! - All signing operations require physical confirmation on device
12+
//! - Private keys never leave the device
13+
//! - Derivation paths are displayed on device screen
14+
15+
use crate::{Chain, Error, Result, Transaction};
16+
use bitcell_crypto::{Hash256, PublicKey, Signature};
17+
use super::{ConnectionStatus, HardwareWalletDevice, HardwareWalletType};
18+
19+
#[cfg(feature = "ledger")]
20+
use ledger_transport_hid::{TransportNativeHID, hidapi::HidApi};
21+
#[cfg(feature = "ledger")]
22+
use ledger_apdu::{APDUCommand, APDUAnswer};
23+
24+
/// Ledger APDU instruction codes
25+
const INS_GET_PUBLIC_KEY: u8 = 0x02;
26+
const INS_SIGN: u8 = 0x04;
27+
const INS_GET_APP_CONFIGURATION: u8 = 0x06;
28+
29+
/// Ledger device implementation
30+
pub struct LedgerDevice {
31+
#[cfg(feature = "ledger")]
32+
transport: TransportNativeHID,
33+
connected: bool,
34+
}
35+
36+
impl LedgerDevice {
37+
/// Connect to a Ledger device
38+
pub fn connect() -> Result<Self> {
39+
#[cfg(feature = "ledger")]
40+
{
41+
let hidapi = HidApi::new()
42+
.map_err(|e| Error::HardwareWallet(format!("Failed to initialize HID API: {}", e)))?;
43+
44+
let transport = TransportNativeHID::new(&hidapi)
45+
.map_err(|e| Error::HardwareWallet(format!("Failed to connect to Ledger device: {}. Is the device connected and unlocked?", e)))?;
46+
47+
Ok(Self {
48+
transport,
49+
connected: true,
50+
})
51+
}
52+
53+
#[cfg(not(feature = "ledger"))]
54+
{
55+
Err(Error::HardwareWallet(
56+
"Ledger support not compiled in. Enable the 'ledger' feature.".into()
57+
))
58+
}
59+
}
60+
61+
/// Verify device is running the correct app
62+
#[cfg(feature = "ledger")]
63+
pub fn verify_app(&self) -> Result<String> {
64+
// Get app configuration to verify correct app is running
65+
let command = APDUCommand {
66+
cla: 0xe0,
67+
ins: INS_GET_APP_CONFIGURATION,
68+
p1: 0x00,
69+
p2: 0x00,
70+
data: vec![],
71+
};
72+
73+
let response = self.transport.exchange(&command)
74+
.map_err(|e| Error::HardwareWallet(format!("Failed to get app configuration: {}", e)))?;
75+
76+
if response.retcode() != 0x9000 {
77+
return Err(Error::HardwareWallet(
78+
format!("Device returned error code: 0x{:04x}", response.retcode())
79+
));
80+
}
81+
82+
// Parse app version from response
83+
let data = response.data();
84+
if data.len() >= 4 {
85+
let version = format!("{}.{}.{}", data[1], data[2], data[3]);
86+
Ok(version)
87+
} else {
88+
Ok("unknown".to_string())
89+
}
90+
}
91+
92+
/// Parse BIP44 derivation path into bytes
93+
fn serialize_path(path: &str) -> Result<Vec<u8>> {
94+
// Parse "m/44'/9999'/0'/0/0" format
95+
let parts: Vec<&str> = path.trim_start_matches("m/").split('/').collect();
96+
let mut result = vec![parts.len() as u8];
97+
98+
for part in parts {
99+
let hardened = part.ends_with('\'');
100+
let num_str = part.trim_end_matches('\'');
101+
let mut num: u32 = num_str.parse()
102+
.map_err(|_| Error::InvalidDerivationPath(format!("Invalid number in path: {}", num_str)))?;
103+
104+
if hardened {
105+
num |= 0x8000_0000;
106+
}
107+
108+
result.extend_from_slice(&num.to_be_bytes());
109+
}
110+
111+
Ok(result)
112+
}
113+
114+
/// Get public key from device at derivation path
115+
#[cfg(feature = "ledger")]
116+
fn get_pubkey_from_device(&self, path: &str) -> Result<Vec<u8>> {
117+
let path_bytes = Self::serialize_path(path)?;
118+
119+
let command = APDUCommand {
120+
cla: 0xe0,
121+
ins: INS_GET_PUBLIC_KEY,
122+
p1: 0x00, // No display
123+
p2: 0x00, // No chain code
124+
data: path_bytes,
125+
};
126+
127+
let response = self.transport.exchange(&command)
128+
.map_err(|e| Error::HardwareWallet(format!("Failed to get public key: {}", e)))?;
129+
130+
if response.retcode() != 0x9000 {
131+
return Err(Error::HardwareWallet(
132+
format!("Device returned error code: 0x{:04x}. Make sure the correct app is open.", response.retcode())
133+
));
134+
}
135+
136+
let data = response.data();
137+
if data.is_empty() {
138+
return Err(Error::HardwareWallet("Empty response from device".into()));
139+
}
140+
141+
// First byte is the public key length
142+
let pubkey_len = data[0] as usize;
143+
if data.len() < 1 + pubkey_len {
144+
return Err(Error::HardwareWallet("Invalid public key response".into()));
145+
}
146+
147+
Ok(data[1..1+pubkey_len].to_vec())
148+
}
149+
150+
/// Sign a hash with the device
151+
#[cfg(feature = "ledger")]
152+
fn sign_hash_with_device(&self, path: &str, hash: &[u8]) -> Result<Vec<u8>> {
153+
let path_bytes = Self::serialize_path(path)?;
154+
155+
// Construct signing payload: path_length + path + hash
156+
let mut data = path_bytes;
157+
data.extend_from_slice(hash);
158+
159+
let command = APDUCommand {
160+
cla: 0xe0,
161+
ins: INS_SIGN,
162+
p1: 0x00,
163+
p2: 0x00,
164+
data,
165+
};
166+
167+
let response = self.transport.exchange(&command)
168+
.map_err(|e| Error::HardwareWallet(format!("Failed to sign: {}. User may have rejected the transaction.", e)))?;
169+
170+
if response.retcode() == 0x6985 {
171+
return Err(Error::HardwareWallet("User rejected the transaction on device".into()));
172+
}
173+
174+
if response.retcode() != 0x9000 {
175+
return Err(Error::HardwareWallet(
176+
format!("Device returned error code: 0x{:04x}", response.retcode())
177+
));
178+
}
179+
180+
Ok(response.data().to_vec())
181+
}
182+
}
183+
184+
impl HardwareWalletDevice for LedgerDevice {
185+
fn device_type(&self) -> HardwareWalletType {
186+
HardwareWalletType::Ledger
187+
}
188+
189+
fn status(&self) -> ConnectionStatus {
190+
if self.connected {
191+
ConnectionStatus::Connected
192+
} else {
193+
ConnectionStatus::Disconnected
194+
}
195+
}
196+
197+
fn get_public_key(&self, derivation_path: &str) -> Result<PublicKey> {
198+
#[cfg(feature = "ledger")]
199+
{
200+
let pubkey_bytes = self.get_pubkey_from_device(derivation_path)?;
201+
PublicKey::from_bytes(&pubkey_bytes)
202+
.map_err(|e| Error::Crypto(format!("Invalid public key from device: {}", e)))
203+
}
204+
205+
#[cfg(not(feature = "ledger"))]
206+
{
207+
let _ = derivation_path;
208+
Err(Error::HardwareWallet("Ledger support not compiled in".into()))
209+
}
210+
}
211+
212+
fn get_address(&self, derivation_path: &str, chain: Chain) -> Result<String> {
213+
let pubkey = self.get_public_key(derivation_path)?;
214+
215+
// Derive address from public key based on chain
216+
let hash = Hash256::hash(pubkey.as_bytes());
217+
let prefix = match chain {
218+
Chain::BitCell => "BC1",
219+
Chain::Bitcoin | Chain::BitcoinTestnet => "bc1",
220+
Chain::Ethereum | Chain::EthereumSepolia => "0x",
221+
Chain::Custom(_) => "CUST",
222+
};
223+
224+
Ok(format!("{}{}", prefix, hex::encode(&hash.as_bytes()[..20])))
225+
}
226+
227+
fn sign_hash(&self, derivation_path: &str, hash: &Hash256) -> Result<Signature> {
228+
#[cfg(feature = "ledger")]
229+
{
230+
let sig_bytes = self.sign_hash_with_device(derivation_path, hash.as_bytes())?;
231+
Signature::from_bytes(&sig_bytes)
232+
.map_err(|e| Error::Crypto(format!("Invalid signature from device: {}", e)))
233+
}
234+
235+
#[cfg(not(feature = "ledger"))]
236+
{
237+
let _ = (derivation_path, hash);
238+
Err(Error::HardwareWallet("Ledger support not compiled in".into()))
239+
}
240+
}
241+
242+
fn sign_transaction(&self, derivation_path: &str, tx: &Transaction) -> Result<Signature> {
243+
// For transactions, we sign the transaction hash
244+
let hash = tx.hash();
245+
self.sign_hash(derivation_path, &hash)
246+
}
247+
}
248+
249+
#[cfg(all(test, feature = "ledger"))]
250+
mod tests {
251+
use super::*;
252+
253+
#[test]
254+
fn test_serialize_path() {
255+
// Test normal path
256+
let path = "m/44'/9999'/0'/0/0";
257+
let result = LedgerDevice::serialize_path(path).unwrap();
258+
259+
// Should be: [5, 0x8000002c, 0x8000270f, 0x80000000, 0x00000000, 0x00000000]
260+
assert_eq!(result[0], 5); // 5 components
261+
262+
// Test another path
263+
let path2 = "m/44'/60'/0'/0/5";
264+
let result2 = LedgerDevice::serialize_path(path2).unwrap();
265+
assert_eq!(result2[0], 5);
266+
}
267+
268+
#[test]
269+
fn test_invalid_path() {
270+
let path = "m/invalid/path";
271+
assert!(LedgerDevice::serialize_path(path).is_err());
272+
}
273+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//! Mock hardware wallet for testing
2+
3+
use crate::{Chain, Error, Result, Transaction};
4+
use bitcell_crypto::{Hash256, PublicKey, Signature};
5+
use super::{ConnectionStatus, HardwareWalletDevice, HardwareWalletType};
6+
7+
/// Mock hardware wallet for testing
8+
pub struct MockHardwareWallet {
9+
secret_key: bitcell_crypto::SecretKey,
10+
connected: bool,
11+
}
12+
13+
impl MockHardwareWallet {
14+
pub fn new() -> Self {
15+
Self {
16+
secret_key: bitcell_crypto::SecretKey::generate(),
17+
connected: true,
18+
}
19+
}
20+
}
21+
22+
impl HardwareWalletDevice for MockHardwareWallet {
23+
fn device_type(&self) -> HardwareWalletType {
24+
HardwareWalletType::Mock
25+
}
26+
27+
fn status(&self) -> ConnectionStatus {
28+
if self.connected {
29+
ConnectionStatus::Connected
30+
} else {
31+
ConnectionStatus::Disconnected
32+
}
33+
}
34+
35+
fn get_public_key(&self, _derivation_path: &str) -> Result<PublicKey> {
36+
Ok(self.secret_key.public_key())
37+
}
38+
39+
fn get_address(&self, derivation_path: &str, chain: Chain) -> Result<String> {
40+
let pk = self.get_public_key(derivation_path)?;
41+
// Simple address derivation for testing
42+
let hash = Hash256::hash(pk.as_bytes());
43+
let prefix = match chain {
44+
Chain::BitCell => "BC1",
45+
Chain::Bitcoin | Chain::BitcoinTestnet => "bc1",
46+
Chain::Ethereum | Chain::EthereumSepolia => "0x",
47+
Chain::Custom(_) => "CUST",
48+
};
49+
Ok(format!("{}{}", prefix, hex::encode(&hash.as_bytes()[..20])))
50+
}
51+
52+
fn sign_hash(&self, _derivation_path: &str, hash: &Hash256) -> Result<Signature> {
53+
Ok(self.secret_key.sign(hash.as_bytes()))
54+
}
55+
56+
fn sign_transaction(&self, derivation_path: &str, tx: &Transaction) -> Result<Signature> {
57+
self.sign_hash(derivation_path, &tx.hash())
58+
}
59+
}

0 commit comments

Comments
 (0)