Ledger hardware wallet integration (#8068)

* Initial remote wallet module

* Add clap derivation tooling

* Add remote-wallet path apis

* Implement remote-wallet in solana-keygen

* Implement remote-wallet in cli for read-only pubkey usage

* Linux: Use udev backend; add udev rules tool

* Ignore Ledger live test

* Cli api adjustments
This commit is contained in:
Tyera Eulberg
2020-02-07 11:26:56 -07:00
committed by GitHub
parent 8b5598fabd
commit ed0c1d3b52
16 changed files with 1152 additions and 30 deletions

30
remote-wallet/Cargo.toml Normal file
View File

@@ -0,0 +1,30 @@
[package]
authors = ["Solana Maintainers <maintainers@solana.com>"]
edition = "2018"
name = "solana-remote-wallet"
description = "Blockchain, Rebuilt for Scale"
version = "0.24.0"
repository = "https://github.com/solana-labs/solana"
license = "Apache-2.0"
homepage = "https://solana.com/"
[dependencies]
base32 = "0.4.0"
dialoguer = "0.5.0"
hidapi = { version = "1.1.1", default-features = false }
log = "0.4.8"
parking_lot = "0.7"
semver = "0.9"
solana-sdk = { path = "../sdk", version = "0.24.0" }
thiserror = "1.0"
[features]
default = ["linux-static-hidraw"]
linux-static-libusb = ["hidapi/linux-static-libusb"]
linux-static-hidraw = ["hidapi/linux-static-hidraw"]
linux-shared-libusb = ["hidapi/linux-shared-libusb"]
linux-shared-hidraw = ["hidapi/linux-shared-hidraw"]
[[bin]]
name = "solana-ledger-udev"
path = "src/bin/ledger-udev.rs"

View File

@@ -0,0 +1,51 @@
/// Implements udev rules on Linux for supported Ledger devices
/// This script must be run with sudo privileges
use std::{
error,
fs::{File, OpenOptions},
io::{Read, Write},
path::Path,
process::Command,
};
const LEDGER_UDEV_RULES: &str = r#"# Nano S
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0001|1000|1001|1002|1003|1004|1005|1006|1007|1008|1009|100a|100b|100c|100d|100e|100f|1010|1011|1012|1013|1014|1015|1016|1017|1018|1019|101a|101b|101c|101d|101e|101f", TAG+="uaccess", TAG+="udev-acl", MODE="0666"
# Nano X
SUBSYSTEMS=="usb", ATTRS{idVendor}=="2c97", ATTRS{idProduct}=="0004|4000|4001|4002|4003|4004|4005|4006|4007|4008|4009|400a|400b|400c|400d|400e|400f|4010|4011|4012|4013|4014|4015|4016|4017|4018|4019|401a|401b|401c|401d|401e|401f", TAG+="uaccess", TAG+="udev-acl", MODE="0666""#;
const LEDGER_UDEV_RULES_LOCATION: &str = "/etc/udev/rules.d/20-hw1.rules";
fn main() -> Result<(), Box<dyn error::Error>> {
if cfg!(target_os = "linux") {
let mut contents = String::new();
if Path::new("/etc/udev/rules.d/20-hw1.rules").exists() {
let mut file = File::open(LEDGER_UDEV_RULES_LOCATION)?;
file.read_to_string(&mut contents)?;
}
if !contents.contains(LEDGER_UDEV_RULES) {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(LEDGER_UDEV_RULES_LOCATION)
.map_err(|e| {
println!("Could not write to file; this script requires sudo privileges");
e
})?;
file.write_all(LEDGER_UDEV_RULES.as_bytes())?;
Command::new("udevadm").arg("trigger").output().unwrap();
Command::new("udevadm")
.args(&["control", "--reload-rules"])
.output()
.unwrap();
println!("Ledger udev rules written");
} else {
println!("Ledger udev rules already in place");
}
} else {
println!("Mismatched target_os; udev rules only required on linux os");
}
Ok(())
}

442
remote-wallet/src/ledger.rs Normal file
View File

@@ -0,0 +1,442 @@
use crate::remote_wallet::{
initialize_wallet_manager, DerivationPath, RemoteWallet, RemoteWalletError, RemoteWalletInfo,
};
use dialoguer::{theme::ColorfulTheme, Select};
use log::*;
use semver::Version as FirmwareVersion;
use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction};
use std::{cmp::min, fmt, sync::Arc};
const APDU_TAG: u8 = 0x05;
const APDU_CLA: u8 = 0xe0;
const APDU_PAYLOAD_HEADER_LEN: usize = 7;
const SOL_DERIVATION_PATH_BE: [u8; 8] = [0x80, 0, 0, 44, 0x80, 0, 0x01, 0xF5]; // 44'/501', Solana
// const SOL_DERIVATION_PATH_BE: [u8; 8] = [0x80, 0, 0, 44, 0x80, 0, 0x00, 0x94]; // 44'/148', Stellar
/// Ledger vendor ID
const LEDGER_VID: u16 = 0x2c97;
/// Ledger product IDs: Nano S and Nano X
const LEDGER_NANO_S_PIDS: [u16; 33] = [
0x0001, 0x1000, 0x1001, 0x1002, 0x1003, 0x1004, 0x1005, 0x1006, 0x1007, 0x1008, 0x1009, 0x100a,
0x100b, 0x100c, 0x100d, 0x100e, 0x100f, 0x1010, 0x1011, 0x1012, 0x1013, 0x1014, 0x1015, 0x1016,
0x1017, 0x1018, 0x1019, 0x101a, 0x101b, 0x101c, 0x101d, 0x101e, 0x101f,
];
const LEDGER_NANO_X_PIDS: [u16; 33] = [
0x0004, 0x4000, 0x4001, 0x4002, 0x4003, 0x4004, 0x4005, 0x4006, 0x4007, 0x4008, 0x4009, 0x400a,
0x400b, 0x400c, 0x400d, 0x400e, 0x400f, 0x4010, 0x4011, 0x4012, 0x4013, 0x4014, 0x4015, 0x4016,
0x4017, 0x4018, 0x4019, 0x401a, 0x401b, 0x401c, 0x401d, 0x401e, 0x401f,
];
const LEDGER_TRANSPORT_HEADER_LEN: usize = 5;
const MAX_CHUNK_SIZE: usize = 255;
const HID_PACKET_SIZE: usize = 64 + HID_PREFIX_ZERO;
#[cfg(windows)]
const HID_PREFIX_ZERO: usize = 1;
#[cfg(not(windows))]
const HID_PREFIX_ZERO: usize = 0;
mod commands {
pub const GET_APP_CONFIGURATION: u8 = 0x06;
pub const GET_SOL_PUBKEY: u8 = 0x02;
pub const SIGN_SOL_TRANSACTION: u8 = 0x04;
}
/// Ledger Wallet device
pub struct LedgerWallet {
pub device: hidapi::HidDevice,
}
impl fmt::Debug for LedgerWallet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "HidDevice")
}
}
impl LedgerWallet {
pub fn new(device: hidapi::HidDevice) -> Self {
Self { device }
}
// Transport Protocol:
// * Communication Channel Id (2 bytes big endian )
// * Command Tag (1 byte)
// * Packet Sequence ID (2 bytes big endian)
// * Payload (Optional)
//
// Payload
// * APDU Total Length (2 bytes big endian)
// * APDU_CLA (1 byte)
// * APDU_INS (1 byte)
// * APDU_P1 (1 byte)
// * APDU_P2 (1 byte)
// * APDU_LENGTH (1 byte)
// * APDU_Payload (Variable)
//
fn write(&self, command: u8, p1: u8, p2: u8, data: &[u8]) -> Result<(), RemoteWalletError> {
let data_len = data.len();
let mut offset = 0;
let mut sequence_number = 0;
let mut hid_chunk = [0_u8; HID_PACKET_SIZE];
while sequence_number == 0 || offset < data_len {
let header = if sequence_number == 0 {
LEDGER_TRANSPORT_HEADER_LEN + APDU_PAYLOAD_HEADER_LEN
} else {
LEDGER_TRANSPORT_HEADER_LEN
};
let size = min(64 - header, data_len - offset);
{
let chunk = &mut hid_chunk[HID_PREFIX_ZERO..];
chunk[0..5].copy_from_slice(&[
0x01,
0x01,
APDU_TAG,
(sequence_number >> 8) as u8,
(sequence_number & 0xff) as u8,
]);
if sequence_number == 0 {
let data_len = data.len() + 5;
chunk[5..12].copy_from_slice(&[
(data_len >> 8) as u8,
(data_len & 0xff) as u8,
APDU_CLA,
command,
p1,
p2,
data.len() as u8,
]);
}
chunk[header..header + size].copy_from_slice(&data[offset..offset + size]);
}
trace!("Ledger write {:?}", &hid_chunk[..]);
let n = self.device.write(&hid_chunk[..])?;
if n < size + header {
return Err(RemoteWalletError::Protocol("Write data size mismatch"));
}
offset += size;
sequence_number += 1;
if sequence_number >= 0xffff {
return Err(RemoteWalletError::Protocol(
"Maximum sequence number reached",
));
}
}
Ok(())
}
// Transport Protocol:
// * Communication Channel Id (2 bytes big endian )
// * Command Tag (1 byte)
// * Packet Sequence ID (2 bytes big endian)
// * Payload (Optional)
//
// Payload
// * APDU Total Length (2 bytes big endian)
// * APDU_CLA (1 byte)
// * APDU_INS (1 byte)
// * APDU_P1 (1 byte)
// * APDU_P2 (1 byte)
// * APDU_LENGTH (1 byte)
// * APDU_Payload (Variable)
//
fn read(&self) -> Result<Vec<u8>, RemoteWalletError> {
let mut message_size = 0;
let mut message = Vec::new();
// terminate the loop if `sequence_number` reaches its max_value and report error
for chunk_index in 0..=0xffff {
let mut chunk: [u8; HID_PACKET_SIZE] = [0; HID_PACKET_SIZE];
let chunk_size = self.device.read(&mut chunk)?;
trace!("Ledger read {:?}", &chunk[..]);
if chunk_size < LEDGER_TRANSPORT_HEADER_LEN
|| chunk[0] != 0x01
|| chunk[1] != 0x01
|| chunk[2] != APDU_TAG
{
return Err(RemoteWalletError::Protocol("Unexpected chunk header"));
}
let seq = (chunk[3] as usize) << 8 | (chunk[4] as usize);
if seq != chunk_index {
return Err(RemoteWalletError::Protocol("Unexpected chunk header"));
}
let mut offset = 5;
if seq == 0 {
// Read message size and status word.
if chunk_size < 7 {
return Err(RemoteWalletError::Protocol("Unexpected chunk header"));
}
message_size = (chunk[5] as usize) << 8 | (chunk[6] as usize);
offset += 2;
}
message.extend_from_slice(&chunk[offset..chunk_size]);
message.truncate(message_size);
if message.len() == message_size {
break;
}
}
if message.len() < 2 {
return Err(RemoteWalletError::Protocol("No status word"));
}
let status =
(message[message.len() - 2] as usize) << 8 | (message[message.len() - 1] as usize);
trace!("Read status {:x}", status);
#[allow(clippy::match_overlapping_arm)]
match status {
// These need to be aligned with solana Ledger app error codes, and clippy allowance removed
0x6700 => Err(RemoteWalletError::Protocol("Incorrect length")),
0x6982 => Err(RemoteWalletError::Protocol(
"Security status not satisfied (Canceled by user)",
)),
0x6a80 => Err(RemoteWalletError::Protocol("Invalid data")),
0x6a82 => Err(RemoteWalletError::Protocol("File not found")),
0x6a85 => Err(RemoteWalletError::UserCancel),
0x6b00 => Err(RemoteWalletError::Protocol("Incorrect parameters")),
0x6d00 => Err(RemoteWalletError::Protocol(
"Not implemented. Make sure the Ledger Solana Wallet app is running.",
)),
0x6faa => Err(RemoteWalletError::Protocol(
"Your Ledger needs to be unplugged",
)),
0x6f00..=0x6fff => Err(RemoteWalletError::Protocol("Internal error")),
0x9000 => Ok(()),
_ => Err(RemoteWalletError::Protocol("Unknown error")),
}?;
let new_len = message.len() - 2;
message.truncate(new_len);
Ok(message)
}
fn send_apdu(
&self,
command: u8,
p1: u8,
p2: u8,
data: &[u8],
) -> Result<Vec<u8>, RemoteWalletError> {
self.write(command, p1, p2, data)?;
self.read()
}
fn get_firmware_version(&self) -> Result<FirmwareVersion, RemoteWalletError> {
let ver = self.send_apdu(commands::GET_APP_CONFIGURATION, 0, 0, &[])?;
if ver.len() != 4 {
return Err(RemoteWalletError::Protocol("Version packet size mismatch"));
}
Ok(FirmwareVersion::new(
ver[1].into(),
ver[2].into(),
ver[3].into(),
))
}
}
impl RemoteWallet for LedgerWallet {
fn read_device(
&self,
dev_info: &hidapi::HidDeviceInfo,
) -> Result<RemoteWalletInfo, RemoteWalletError> {
let manufacturer = dev_info
.manufacturer_string
.clone()
.unwrap_or_else(|| "Unknown".to_owned())
.to_lowercase()
.replace(" ", "-");
let model = dev_info
.product_string
.clone()
.unwrap_or_else(|| "Unknown".to_owned())
.to_lowercase()
.replace(" ", "-");
let serial = dev_info
.serial_number
.clone()
.unwrap_or_else(|| "Unknown".to_owned());
self.get_pubkey(DerivationPath::default())
.map(|pubkey| RemoteWalletInfo {
model,
manufacturer,
serial,
pubkey,
})
}
fn get_pubkey(&self, derivation: DerivationPath) -> Result<Pubkey, RemoteWalletError> {
let derivation_path = get_derivation_path(derivation);
let key = self.send_apdu(commands::GET_SOL_PUBKEY, 0, 0, &derivation_path)?;
if key.len() != 32 {
return Err(RemoteWalletError::Protocol("Key packet size mismatch"));
}
Ok(Pubkey::new(&key))
}
fn sign_transaction(
&self,
derivation: DerivationPath,
transaction: Transaction,
) -> Result<Signature, RemoteWalletError> {
let mut chunk = [0_u8; MAX_CHUNK_SIZE];
let derivation_path = get_derivation_path(derivation);
let data = transaction.message_data();
let _firmware_version = self.get_firmware_version();
// Copy the address of the key (only done once)
chunk[0..derivation_path.len()].copy_from_slice(&derivation_path);
let key_length = derivation_path.len();
let max_payload_size = MAX_CHUNK_SIZE - key_length;
let data_len = data.len();
let mut result = Vec::new();
let mut offset = 0;
while offset < data_len {
let p1 = if offset == 0 { 0 } else { 0x80 };
let take = min(max_payload_size, data_len - offset);
// Fetch piece of data and copy it!
{
let (_key, d) = &mut chunk.split_at_mut(key_length);
let (dst, _rem) = &mut d.split_at_mut(take);
dst.copy_from_slice(&data[offset..(offset + take)]);
}
result = self.send_apdu(
commands::SIGN_SOL_TRANSACTION,
p1,
0,
&chunk[0..(key_length + take)],
)?;
offset += take;
}
if result.len() != 64 {
return Err(RemoteWalletError::Protocol(
"Signature packet size mismatch",
));
}
Ok(Signature::new(&result))
}
}
/// Check if the detected device is a valid `Ledger device` by checking both the product ID and the vendor ID
pub fn is_valid_ledger(vendor_id: u16, product_id: u16) -> bool {
vendor_id == LEDGER_VID
&& (LEDGER_NANO_S_PIDS.contains(&product_id) || LEDGER_NANO_X_PIDS.contains(&product_id))
}
/// Build the derivation path byte array from a DerivationPath selection
fn get_derivation_path(derivation: DerivationPath) -> Vec<u8> {
let byte = if derivation.change.is_some() { 4 } else { 3 };
let mut concat_derivation = vec![byte];
concat_derivation.extend_from_slice(&SOL_DERIVATION_PATH_BE);
concat_derivation.extend_from_slice(&[0x80, 0]);
concat_derivation.extend_from_slice(&derivation.account.to_be_bytes());
if let Some(change) = derivation.change {
concat_derivation.extend_from_slice(&[0x80, 0]);
concat_derivation.extend_from_slice(&change.to_be_bytes());
}
concat_derivation
}
/// Choose a Ledger wallet based on matching info fields
pub fn get_ledger_from_info(
info: RemoteWalletInfo,
) -> Result<Arc<LedgerWallet>, RemoteWalletError> {
let wallet_manager = initialize_wallet_manager();
let _device_count = wallet_manager.update_devices()?;
let devices = wallet_manager.list_devices();
let (pubkeys, device_paths): (Vec<Pubkey>, Vec<String>) = devices
.iter()
.filter(|&device_info| device_info.matches(&info))
.map(|device_info| (device_info.pubkey, device_info.get_pretty_path()))
.unzip();
if pubkeys.is_empty() {
return Err(RemoteWalletError::NoDeviceFound);
}
let wallet_base_pubkey = if pubkeys.len() > 1 {
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Multiple hardware wallets found. Please select a device")
.default(0)
.items(&device_paths[..])
.interact()
.unwrap();
pubkeys[selection]
} else {
pubkeys[0]
};
wallet_manager.get_ledger(&wallet_base_pubkey)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::remote_wallet::initialize_wallet_manager;
use std::collections::HashSet;
/// This test can't be run without an actual ledger device connected with the `Ledger Wallet Solana application` running
#[test]
#[ignore]
fn ledger_pubkey_test() {
let wallet_manager = initialize_wallet_manager();
// Update device list
wallet_manager.update_devices().expect("No Ledger found, make sure you have a unlocked Ledger connected with the Ledger Wallet Solana running");
assert!(wallet_manager.list_devices().len() > 0);
// Fetch the base pubkey of a connected ledger device
let ledger_base_pubkey = wallet_manager
.list_devices()
.iter()
.filter(|d| d.manufacturer == "ledger".to_string())
.nth(0)
.map(|d| d.pubkey.clone())
.expect("No ledger device detected");
let ledger = wallet_manager
.get_ledger(&ledger_base_pubkey)
.expect("get device");
let mut pubkey_set = HashSet::new();
pubkey_set.insert(ledger_base_pubkey);
let pubkey_0_0 = ledger
.get_pubkey(DerivationPath {
account: 0,
change: Some(0),
})
.expect("get pubkey");
pubkey_set.insert(pubkey_0_0);
let pubkey_0_1 = ledger
.get_pubkey(DerivationPath {
account: 0,
change: Some(1),
})
.expect("get pubkey");
pubkey_set.insert(pubkey_0_1);
let pubkey_1 = ledger
.get_pubkey(DerivationPath {
account: 1,
change: None,
})
.expect("get pubkey");
pubkey_set.insert(pubkey_1);
let pubkey_1_0 = ledger
.get_pubkey(DerivationPath {
account: 1,
change: Some(0),
})
.expect("get pubkey");
pubkey_set.insert(pubkey_1_0);
assert_eq!(pubkey_set.len(), 5); // Ensure keys at various derivation paths are unique
}
}

2
remote-wallet/src/lib.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod ledger;
pub mod remote_wallet;

View File

@@ -0,0 +1,358 @@
use crate::ledger::{is_valid_ledger, LedgerWallet};
use log::*;
use parking_lot::{Mutex, RwLock};
use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction};
use std::{
fmt,
str::FromStr,
sync::Arc,
time::{Duration, Instant},
};
use thiserror::Error;
const HID_GLOBAL_USAGE_PAGE: u16 = 0xFF00;
const HID_USB_DEVICE_CLASS: u8 = 0;
/// Remote wallet error.
#[derive(Error, Debug)]
pub enum RemoteWalletError {
#[error("hidapi error")]
Hid(#[from] hidapi::HidError),
#[error("device type mismatch")]
DeviceTypeMismatch,
#[error("device with non-supported product ID or vendor ID was detected")]
InvalidDevice,
#[error("invalid derivation path: {0}")]
InvalidDerivationPath(String),
#[error("invalid path: {0}")]
InvalidPath(String),
#[error("no device found")]
NoDeviceFound,
#[error("protocol error: {0}")]
Protocol(&'static str),
#[error("pubkey not found for given address")]
PubkeyNotFound,
#[error("operation has been cancelled")]
UserCancel,
}
/// Collection of conntected RemoteWallets
pub struct RemoteWalletManager {
usb: Arc<Mutex<hidapi::HidApi>>,
devices: RwLock<Vec<Device>>,
}
impl RemoteWalletManager {
/// Create a new instance.
pub fn new(usb: Arc<Mutex<hidapi::HidApi>>) -> Arc<Self> {
Arc::new(Self {
usb,
devices: RwLock::new(Vec::new()),
})
}
/// Repopulate device list
/// Note: this method iterates over and updates all devices
pub fn update_devices(&self) -> Result<usize, RemoteWalletError> {
let mut usb = self.usb.lock();
usb.refresh_devices()?;
let devices = usb.devices();
let num_prev_devices = self.devices.read().len();
let detected_devices = devices
.iter()
.filter(|&device_info| {
is_valid_hid_device(device_info.usage_page, device_info.interface_number)
})
.fold(Vec::new(), |mut v, device_info| {
if is_valid_ledger(device_info.vendor_id, device_info.product_id) {
match usb.open_path(&device_info.path) {
Ok(device) => {
let ledger = LedgerWallet::new(device);
if let Ok(info) = ledger.read_device(&device_info) {
let path = device_info.path.to_str().unwrap().to_string();
trace!("Found device: {:?}", info);
v.push(Device {
path,
info,
wallet_type: RemoteWalletType::Ledger(Arc::new(ledger)),
})
}
}
Err(e) => error!("Error connecting to ledger device to read info: {}", e),
}
}
v
});
let num_curr_devices = detected_devices.len();
*self.devices.write() = detected_devices;
Ok(num_curr_devices - num_prev_devices)
}
/// List connected and acknowledged wallets
pub fn list_devices(&self) -> Vec<RemoteWalletInfo> {
self.devices.read().iter().map(|d| d.info.clone()).collect()
}
/// Get a particular wallet
#[allow(unreachable_patterns)]
pub fn get_ledger(&self, pubkey: &Pubkey) -> Result<Arc<LedgerWallet>, RemoteWalletError> {
self.devices
.read()
.iter()
.find(|device| &device.info.pubkey == pubkey)
.ok_or(RemoteWalletError::PubkeyNotFound)
.and_then(|device| match &device.wallet_type {
RemoteWalletType::Ledger(ledger) => Ok(ledger.clone()),
_ => Err(RemoteWalletError::DeviceTypeMismatch),
})
}
/// Get wallet info.
pub fn get_wallet_info(&self, pubkey: &Pubkey) -> Option<RemoteWalletInfo> {
self.devices
.read()
.iter()
.find(|d| &d.info.pubkey == pubkey)
.map(|d| d.info.clone())
}
/// Update devices in maximum `max_polling_duration` if it doesn't succeed
pub fn try_connect_polling(&self, max_polling_duration: &Duration) -> bool {
let start_time = Instant::now();
while start_time.elapsed() <= *max_polling_duration {
if let Ok(num_devices) = self.update_devices() {
let plural = if num_devices == 1 { "" } else { "s" };
trace!("{} Remote Wallet{} found", num_devices, plural);
return true;
}
}
false
}
}
/// `RemoteWallet` trait
pub trait RemoteWallet {
/// Parse device info and get device base pubkey
fn read_device(
&self,
dev_info: &hidapi::HidDeviceInfo,
) -> Result<RemoteWalletInfo, RemoteWalletError>;
/// Get solana pubkey from a RemoteWallet
fn get_pubkey(&self, derivation: DerivationPath) -> Result<Pubkey, RemoteWalletError>;
/// Sign transaction data with wallet managing pubkey at derivation path m/44'/501'/<account>'/<change>'.
fn sign_transaction(
&self,
derivation: DerivationPath,
transaction: Transaction,
) -> Result<Signature, RemoteWalletError>;
}
/// `RemoteWallet` device
#[derive(Debug)]
pub struct Device {
pub(crate) path: String,
pub(crate) info: RemoteWalletInfo,
pub wallet_type: RemoteWalletType,
}
/// Remote wallet convenience enum to hold various wallet types
#[derive(Debug)]
pub enum RemoteWalletType {
Ledger(Arc<LedgerWallet>),
}
/// Remote wallet information.
#[derive(Debug, Default, Clone)]
pub struct RemoteWalletInfo {
/// RemoteWallet device model
pub model: String,
/// RemoteWallet device manufacturer
pub manufacturer: String,
/// RemoteWallet device serial number
pub serial: String,
/// Base pubkey of device at Solana derivation path
pub pubkey: Pubkey,
}
impl RemoteWalletInfo {
pub fn parse_path(mut path: String) -> Result<(Self, DerivationPath), RemoteWalletError> {
let mut path = path.split_off(6);
if path.ends_with('/') {
path.pop();
}
let mut parts = path.split('/');
let mut wallet_info = RemoteWalletInfo::default();
let manufacturer = parts.next().unwrap();
wallet_info.manufacturer = manufacturer.to_string();
wallet_info.model = parts.next().unwrap_or("").to_string();
wallet_info.pubkey = parts
.next()
.and_then(|pubkey_str| Pubkey::from_str(pubkey_str).ok())
.unwrap_or_default();
let mut derivation_path = DerivationPath::default();
if let Some(purpose) = parts.next() {
if purpose.replace("'", "") != "44" {
return Err(RemoteWalletError::InvalidDerivationPath(format!(
"Incorrect purpose number, found: {}, must be 44",
purpose
)));
}
if let Some(coin) = parts.next() {
if coin.replace("'", "") != "501" {
return Err(RemoteWalletError::InvalidDerivationPath(format!(
"Incorrect coin number, found: {}, must be 501",
coin
)));
}
if let Some(account) = parts.next() {
derivation_path.account = account.replace("'", "").parse::<u16>().unwrap();
derivation_path.change = parts
.next()
.and_then(|change| change.replace("'", "").parse::<u16>().ok());
}
} else {
return Err(RemoteWalletError::InvalidDerivationPath(
"Derivation path too short, missing coin number".to_string(),
));
}
}
Ok((wallet_info, derivation_path))
}
pub fn get_pretty_path(&self) -> String {
format!(
"usb://{}/{}/{:?}",
self.manufacturer, self.model, self.pubkey,
)
}
pub(crate) fn matches(&self, other: &Self) -> bool {
self.manufacturer == other.manufacturer
&& (self.model == other.model || self.model == "" || other.model == "")
&& (self.pubkey == other.pubkey
|| self.pubkey == Pubkey::default()
|| other.pubkey == Pubkey::default())
}
}
#[derive(Default, PartialEq, Clone)]
pub struct DerivationPath {
pub account: u16,
pub change: Option<u16>,
}
impl fmt::Debug for DerivationPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let change = if let Some(change) = self.change {
format!("/{:?}'", change)
} else {
"".to_string()
};
write!(f, "m/44'/501'/{:?}'{}", self.account, change)
}
}
/// Helper to determine if a device is a valid HID
pub fn is_valid_hid_device(usage_page: u16, interface_number: i32) -> bool {
usage_page == HID_GLOBAL_USAGE_PAGE || interface_number == HID_USB_DEVICE_CLASS as i32
}
/// Helper to initialize hidapi and RemoteWalletManager
pub fn initialize_wallet_manager() -> Arc<RemoteWalletManager> {
let hidapi = Arc::new(Mutex::new(hidapi::HidApi::new().unwrap()));
RemoteWalletManager::new(hidapi)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_path() {
let pubkey = Pubkey::new_rand();
let (wallet_info, derivation_path) =
RemoteWalletInfo::parse_path(format!("usb://ledger/nano-s/{:?}/44/501/1/2", pubkey))
.unwrap();
assert!(wallet_info.matches(&RemoteWalletInfo {
model: "nano-s".to_string(),
manufacturer: "ledger".to_string(),
serial: "".to_string(),
pubkey,
}));
assert_eq!(
derivation_path,
DerivationPath {
account: 1,
change: Some(2),
}
);
let (wallet_info, derivation_path) = RemoteWalletInfo::parse_path(format!(
"usb://ledger/nano-s/{:?}/44'/501'/1'/2'",
pubkey
))
.unwrap();
assert!(wallet_info.matches(&RemoteWalletInfo {
model: "nano-s".to_string(),
manufacturer: "ledger".to_string(),
serial: "".to_string(),
pubkey,
}));
assert_eq!(
derivation_path,
DerivationPath {
account: 1,
change: Some(2),
}
);
assert!(RemoteWalletInfo::parse_path(format!(
"usb://ledger/nano-s/{:?}/43/501/1/2",
pubkey
))
.is_err());
assert!(RemoteWalletInfo::parse_path(format!(
"usb://ledger/nano-s/{:?}/44/500/1/2",
pubkey
))
.is_err());
}
#[test]
fn test_remote_wallet_info_matches() {
let pubkey = Pubkey::new_rand();
let info = RemoteWalletInfo {
manufacturer: "Ledger".to_string(),
model: "Nano S".to_string(),
serial: "0001".to_string(),
pubkey: pubkey.clone(),
};
let mut test_info = RemoteWalletInfo::default();
test_info.manufacturer = "Not Ledger".to_string();
assert!(!info.matches(&test_info));
test_info.manufacturer = "Ledger".to_string();
assert!(info.matches(&test_info));
test_info.model = "Other".to_string();
assert!(!info.matches(&test_info));
test_info.model = "Nano S".to_string();
assert!(info.matches(&test_info));
let another_pubkey = Pubkey::new_rand();
test_info.pubkey = another_pubkey;
assert!(!info.matches(&test_info));
test_info.pubkey = pubkey;
assert!(info.matches(&test_info));
}
}