From ed0c1d3b52a8611ce2355cdec973a0919c5a4368 Mon Sep 17 00:00:00 2001 From: Tyera Eulberg Date: Fri, 7 Feb 2020 11:26:56 -0700 Subject: [PATCH] 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 --- Cargo.lock | 45 +++ Cargo.toml | 1 + clap-utils/Cargo.toml | 1 + clap-utils/src/input_parsers.rs | 47 +++ clap-utils/src/input_validators.rs | 52 +++- clap-utils/src/keypair.rs | 9 +- cli/Cargo.toml | 1 + cli/src/cli.rs | 38 ++- cli/src/main.rs | 38 ++- keygen/Cargo.toml | 1 + keygen/src/keygen.rs | 66 +++- remote-wallet/Cargo.toml | 30 ++ remote-wallet/src/bin/ledger-udev.rs | 51 ++++ remote-wallet/src/ledger.rs | 442 +++++++++++++++++++++++++++ remote-wallet/src/lib.rs | 2 + remote-wallet/src/remote_wallet.rs | 358 ++++++++++++++++++++++ 16 files changed, 1152 insertions(+), 30 deletions(-) create mode 100644 remote-wallet/Cargo.toml create mode 100644 remote-wallet/src/bin/ledger-udev.rs create mode 100644 remote-wallet/src/ledger.rs create mode 100644 remote-wallet/src/lib.rs create mode 100644 remote-wallet/src/remote_wallet.rs diff --git a/Cargo.lock b/Cargo.lock index 0a7573931..17cd4b7e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,11 @@ dependencies = [ "libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "base64" version = "0.10.1" @@ -789,6 +794,16 @@ dependencies = [ "syn 0.15.42 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "dialoguer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "console 0.9.2 (registry+https://github.com/rust-lang/crates.io-index)", + "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "diff" version = "0.1.11" @@ -1406,6 +1421,16 @@ name = "hex_fmt" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "hidapi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cc 1.0.49 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.66 (registry+https://github.com/rust-lang/crates.io-index)", + "pkg-config 0.3.17 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "histogram" version = "0.6.9" @@ -3672,6 +3697,7 @@ dependencies = [ "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", "rpassword 4.0.5 (registry+https://github.com/rust-lang/crates.io-index)", "semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", + "solana-remote-wallet 0.24.0", "solana-sdk 0.24.0", "tiny-bip39 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", "url 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3707,6 +3733,7 @@ dependencies = [ "solana-faucet 0.24.0", "solana-logger 0.24.0", "solana-net-utils 0.24.0", + "solana-remote-wallet 0.24.0", "solana-runtime 0.24.0", "solana-sdk 0.24.0", "solana-stake-program 0.24.0", @@ -3985,6 +4012,7 @@ dependencies = [ "num_cpus 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)", "solana-clap-utils 0.24.0", "solana-cli-config 0.24.0", + "solana-remote-wallet 0.24.0", "solana-sdk 0.24.0", "tiny-bip39 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -4266,6 +4294,20 @@ dependencies = [ "smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "solana-remote-wallet" +version = "0.24.0" +dependencies = [ + "base32 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "dialoguer 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "hidapi 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", + "parking_lot 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)", + "semver 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", + "solana-sdk 0.24.0", + "thiserror 1.0.10 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "solana-runtime" version = "0.24.0" @@ -5954,6 +5996,7 @@ dependencies = [ "checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" "checksum backtrace 0.3.40 (registry+https://github.com/rust-lang/crates.io-index)" = "924c76597f0d9ca25d762c25a4d369d51267536465dc5064bdf0eb073ed477ea" "checksum backtrace-sys 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)" = "5d6575f128516de27e3ce99689419835fce9643a9b215a14d2b5b685be018491" +"checksum base32 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" "checksum base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)" = "0b25d992356d2eb0ed82172f5248873db5560c4721f564b13cb5193bda5e668e" "checksum base64 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7" "checksum bech32 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "58946044516aa9dc922182e0d6e9d124a31aafe6b421614654eb27cf90cec09c" @@ -6028,6 +6071,7 @@ dependencies = [ "checksum curve25519-dalek 1.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "8b7dcd30ba50cdf88b55b033456138b7c0ac4afdc436d82e1b79f370f24cc66d" "checksum data-encoding 2.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f4f47ca1860a761136924ddd2422ba77b2ea54fe8cc75b9040804a0d9d32ad97" "checksum derivative 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "942ca430eef7a3806595a6737bc388bf51adb888d3fc0dd1b50f1c170167ee3a" +"checksum dialoguer 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "94616e25d2c04fc97253d145f6ca33ad84a584258dc70c4e621cc79a57f903b6" "checksum diff 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "3c2b69f912779fbb121ceb775d74d51e915af17aaebc38d28a592843a2dd0a3a" "checksum difference 2.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" "checksum digest 0.7.6 (registry+https://github.com/rust-lang/crates.io-index)" = "03b072242a8cbaf9c145665af9d250c59af3b958f83ed6824e13533cf76d5b90" @@ -6101,6 +6145,7 @@ dependencies = [ "checksum hex-literal 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "961de220ec9a91af2e1e5bd80d02109155695e516771762381ef8581317066e0" "checksum hex-literal-impl 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "06095d08c7c05760f11a071b3e1d4c5b723761c01bd8d7201c30a9536668a612" "checksum hex_fmt 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" +"checksum hidapi 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "289d0fb85e9ea18785708d4d735eca4f480b533b005e6f8aa2ffba0207800c75" "checksum histogram 0.6.9 (registry+https://github.com/rust-lang/crates.io-index)" = "12cb882ccb290b8646e554b157ab0b71e64e8d5bef775cd66b6531e52d302669" "checksum hmac 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "5dcb5e64cda4c23119ab41ba960d1e170a774c8e4b9d9e6a9bc18aabf5e59695" "checksum http 0.1.21 (registry+https://github.com/rust-lang/crates.io-index)" = "d6ccf5ede3a895d8856620237b2f02972c1bbc78d2965ad7fe8838d4a0ed41f0" diff --git a/Cargo.toml b/Cargo.toml index 3d38eb9a3..58011c8bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ members = [ "archiver", "archiver-lib", "archiver-utils", + "remote-wallet", "runtime", "sdk", "sdk-c", diff --git a/clap-utils/Cargo.toml b/clap-utils/Cargo.toml index e75d7a8c1..ea3d42343 100644 --- a/clap-utils/Cargo.toml +++ b/clap-utils/Cargo.toml @@ -12,6 +12,7 @@ edition = "2018" clap = "2.33.0" rpassword = "4.0" semver = "0.9.0" +solana-remote-wallet = { path = "../remote-wallet", version = "0.24.0" } solana-sdk = { path = "../sdk", version = "0.24.0" } tiny-bip39 = "0.7.0" url = "2.1.0" diff --git a/clap-utils/src/input_parsers.rs b/clap-utils/src/input_parsers.rs index cf12d5c7e..08c3e119a 100644 --- a/clap-utils/src/input_parsers.rs +++ b/clap-utils/src/input_parsers.rs @@ -1,6 +1,7 @@ use crate::keypair::{keypair_from_seed_phrase, ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG}; use chrono::DateTime; use clap::ArgMatches; +use solana_remote_wallet::remote_wallet::DerivationPath; use solana_sdk::{ clock::UnixTimestamp, native_token::sol_to_lamports, @@ -100,6 +101,16 @@ pub fn amount_of(matches: &ArgMatches<'_>, name: &str, unit: &str) -> Option, name: &str) -> Option { + matches.value_of(name).map(|derivation_str| { + let derivation_str = derivation_str.replace("'", ""); + let mut parts = derivation_str.split('/'); + let account = parts.next().unwrap().parse::().unwrap(); + let change = parts.next().map(|change| change.parse::().unwrap()); + DerivationPath { account, change } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -277,4 +288,40 @@ mod tests { .get_matches_from(vec!["test", "--single", "1.5", "--unit", "lamports"]); assert_eq!(amount_of(&matches, "single", "unit"), None); } + + #[test] + fn test_derivation_of() { + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "2/3"]); + assert_eq!( + derivation_of(&matches, "single"), + Some(DerivationPath { + account: 2, + change: Some(3) + }) + ); + assert_eq!(derivation_of(&matches, "another"), None); + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "2"]); + assert_eq!( + derivation_of(&matches, "single"), + Some(DerivationPath { + account: 2, + change: None + }) + ); + assert_eq!(derivation_of(&matches, "another"), None); + let matches = app() + .clone() + .get_matches_from(vec!["test", "--single", "2'/3'"]); + assert_eq!( + derivation_of(&matches, "single"), + Some(DerivationPath { + account: 2, + change: Some(3) + }) + ); + } } diff --git a/clap-utils/src/input_validators.rs b/clap-utils/src/input_validators.rs index 948765b2d..2b49c4f42 100644 --- a/clap-utils/src/input_validators.rs +++ b/clap-utils/src/input_validators.rs @@ -1,8 +1,10 @@ use crate::keypair::ASK_KEYWORD; use chrono::DateTime; -use solana_sdk::hash::Hash; -use solana_sdk::pubkey::Pubkey; -use solana_sdk::signature::{read_keypair_file, Signature}; +use solana_sdk::{ + hash::Hash, + pubkey::Pubkey, + signature::{read_keypair_file, Signature}, +}; use std::str::FromStr; // Return an error if a pubkey cannot be parsed. @@ -141,3 +143,47 @@ pub fn is_rfc3339_datetime(value: String) -> Result<(), String> { .map(|_| ()) .map_err(|e| format!("{:?}", e)) } + +pub fn is_derivation(value: String) -> Result<(), String> { + let value = value.replace("'", ""); + let mut parts = value.split('/'); + let account = parts.next().unwrap(); + account + .parse::() + .map_err(|e| { + format!( + "Unable to parse derivation, provided: {}, err: {:?}", + account, e + ) + }) + .and_then(|_| { + if let Some(change) = parts.next() { + change.parse::().map_err(|e| { + format!( + "Unable to parse derivation, provided: {}, err: {:?}", + change, e + ) + }) + } else { + Ok(0) + } + }) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_derivation() { + assert_eq!(is_derivation("2".to_string()), Ok(())); + assert_eq!(is_derivation("0".to_string()), Ok(())); + assert_eq!(is_derivation("0/2".to_string()), Ok(())); + assert_eq!(is_derivation("0'/2'".to_string()), Ok(())); + assert!(is_derivation("a".to_string()).is_err()); + assert!(is_derivation("65537".to_string()).is_err()); + assert!(is_derivation("a/b".to_string()).is_err()); + assert!(is_derivation("0/65537".to_string()).is_err()); + } +} diff --git a/clap-utils/src/keypair.rs b/clap-utils/src/keypair.rs index 93bcb77db..34cf248fe 100644 --- a/clap-utils/src/keypair.rs +++ b/clap-utils/src/keypair.rs @@ -32,8 +32,8 @@ pub const SKIP_SEED_PHRASE_VALIDATION_ARG: ArgConstant<'static> = ArgConstant { #[derive(Debug, PartialEq)] pub enum Source { - File, Generated, + Path, SeedPhrase, } @@ -131,7 +131,12 @@ pub fn keypair_input( keypair_from_seed_phrase(keypair_name, skip_validation, true) .map(|keypair| KeypairWithSource::new(keypair, Source::SeedPhrase)) } else if let Some(keypair_file) = matches.value_of(keypair_match_name) { - read_keypair_file(keypair_file).map(|keypair| KeypairWithSource::new(keypair, Source::File)) + if keypair_file.starts_with("usb://") { + Ok(KeypairWithSource::new(Keypair::new(), Source::Path)) + } else { + read_keypair_file(keypair_file) + .map(|keypair| KeypairWithSource::new(keypair, Source::Path)) + } } else { Ok(KeypairWithSource::new(Keypair::new(), Source::Generated)) } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 90d57121a..7a3a4f260 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -34,6 +34,7 @@ solana-config-program = { path = "../programs/config", version = "0.24.0" } solana-faucet = { path = "../faucet", version = "0.24.0" } solana-logger = { path = "../logger", version = "0.24.0" } solana-net-utils = { path = "../net-utils", version = "0.24.0" } +solana-remote-wallet = { path = "../remote-wallet", version = "0.24.0" } solana-runtime = { path = "../runtime", version = "0.24.0" } solana-sdk = { path = "../sdk", version = "0.24.0" } solana-stake-program = { path = "../programs/stake", version = "0.24.0" } diff --git a/cli/src/cli.rs b/cli/src/cli.rs index 5f63b234e..4c541102d 100644 --- a/cli/src/cli.rs +++ b/cli/src/cli.rs @@ -20,6 +20,10 @@ use solana_client::{client_error::ClientError, rpc_client::RpcClient}; use solana_faucet::faucet::request_airdrop_transaction; #[cfg(test)] use solana_faucet::faucet_mock::request_airdrop_transaction; +use solana_remote_wallet::{ + ledger::get_ledger_from_info, + remote_wallet::{DerivationPath, RemoteWallet, RemoteWalletInfo}, +}; use solana_sdk::{ bpf_loader, clock::{Epoch, Slot}, @@ -439,6 +443,7 @@ pub struct CliConfig { pub json_rpc_url: String, pub keypair: Keypair, pub keypair_path: Option, + pub derivation_path: Option, pub rpc_client: Option, pub verbose: bool, } @@ -453,6 +458,22 @@ impl CliConfig { pub fn default_json_rpc_url() -> String { "http://127.0.0.1:8899".to_string() } + + pub(crate) fn pubkey(&self) -> Result> { + if let Some(path) = &self.keypair_path { + if path.starts_with("usb://") { + let (remote_wallet_info, mut derivation_path) = + RemoteWalletInfo::parse_path(path.to_string())?; + if let Some(derivation) = &self.derivation_path { + let derivation = derivation.clone(); + derivation_path = derivation; + } + let ledger = get_ledger_from_info(remote_wallet_info)?; + return Ok(ledger.get_pubkey(derivation_path)?); + } + } + Ok(self.keypair.pubkey()) + } } impl Default for CliConfig { @@ -465,6 +486,7 @@ impl Default for CliConfig { json_rpc_url: Self::default_json_rpc_url(), keypair: Keypair::new(), keypair_path: Some(Self::default_keypair_path()), + derivation_path: None, rpc_client: None, verbose: false, } @@ -845,7 +867,7 @@ fn process_create_address_with_seed( seed: &str, program_id: &Pubkey, ) -> ProcessResult { - let config_pubkey = config.keypair.pubkey(); + let config_pubkey = config.pubkey()?; let from_pubkey = from_pubkey.unwrap_or(&config_pubkey); let address = create_address_with_seed(from_pubkey, seed, program_id)?; Ok(address.to_string()) @@ -858,12 +880,13 @@ fn process_airdrop( lamports: u64, use_lamports_unit: bool, ) -> ProcessResult { + let pubkey = config.pubkey()?; println!( "Requesting airdrop of {} from {}", build_balance_message(lamports, use_lamports_unit, true), faucet_addr ); - let previous_balance = match rpc_client.retry_get_balance(&config.keypair.pubkey(), 5)? { + let previous_balance = match rpc_client.retry_get_balance(&pubkey, 5)? { Some(lamports) => lamports, None => { return Err(CliError::RpcRequestError( @@ -873,10 +896,10 @@ fn process_airdrop( } }; - request_and_confirm_airdrop(&rpc_client, faucet_addr, &config.keypair.pubkey(), lamports)?; + request_and_confirm_airdrop(&rpc_client, faucet_addr, &pubkey, lamports)?; let current_balance = rpc_client - .retry_get_balance(&config.keypair.pubkey(), 5)? + .retry_get_balance(&pubkey, 5)? .unwrap_or(previous_balance); Ok(build_balance_message( @@ -892,7 +915,7 @@ fn process_balance( pubkey: &Option, use_lamports_unit: bool, ) -> ProcessResult { - let pubkey = pubkey.unwrap_or(config.keypair.pubkey()); + let pubkey = pubkey.unwrap_or(config.pubkey()?); let balance = rpc_client.retry_get_balance(&pubkey, 5)?; match balance { Some(lamports) => Ok(build_balance_message(lamports, use_lamports_unit, true)), @@ -1260,6 +1283,9 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { println_name_value("RPC URL:", &config.json_rpc_url); if let Some(keypair_path) = &config.keypair_path { println_name_value("Keypair Path:", keypair_path); + if keypair_path.starts_with("usb://") { + println_name_value("Pubkey:", &format!("{:?}", config.pubkey()?)); + } } } @@ -1275,7 +1301,7 @@ pub fn process_command(config: &CliConfig) -> ProcessResult { match &config.command { // Cluster Query Commands // Get address of this client - CliCommand::Address => Ok(format!("{}", config.keypair.pubkey())), + CliCommand::Address => Ok(format!("{}", config.pubkey()?)), // Return software version of solana-cli and cluster entrypoint node CliCommand::Catchup { node_pubkey } => process_catchup(&rpc_client, node_pubkey), diff --git a/cli/src/main.rs b/cli/src/main.rs index 2c8c4c5f7..fd5e99e30 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -2,7 +2,8 @@ use clap::{crate_description, crate_name, AppSettings, Arg, ArgGroup, ArgMatches use console::style; use solana_clap_utils::{ - input_validators::is_url, + input_parsers::derivation_of, + input_validators::{is_derivation, is_url}, keypair::{ self, keypair_input, KeypairWithSource, ASK_SEED_PHRASE_ARG, SKIP_SEED_PHRASE_VALIDATION_ARG, @@ -25,7 +26,7 @@ fn parse_settings(matches: &ArgMatches<'_>) -> Result ("RPC Url", config.url, CliConfig::default_json_rpc_url()), + "url" => ("RPC URL", config.url, CliConfig::default_json_rpc_url()), "keypair" => ( "Key Path", config.keypair_path, @@ -37,12 +38,12 @@ fn parse_settings(matches: &ArgMatches<'_>) -> Result) -> Result ( + keypair::Source::Path => ( keypair, Some(matches.value_of("keypair").unwrap().to_string()), ), @@ -126,12 +127,16 @@ pub fn parse_args(matches: &ArgMatches<'_>) -> Result) -> Result Result<(), Box> { .value_name("PATH") .global(true) .takes_value(true) - .help("/path/to/id.json"), + .help("/path/to/id.json or usb://remote/wallet/path"), + ) + .arg( + Arg::with_name("derivation_path") + .long("derivation-path") + .value_name("ACCOUNT or ACCOUNT/CHANGE") + .takes_value(true) + .validator(is_derivation) + .help("Derivation path to use: m/44'/501'/ACCOUNT'/CHANGE'; default key is device base pubkey: m/44'/501'/0'") ) .arg( Arg::with_name("verbose") diff --git a/keygen/Cargo.toml b/keygen/Cargo.toml index 30fff9648..71b90a873 100644 --- a/keygen/Cargo.toml +++ b/keygen/Cargo.toml @@ -15,6 +15,7 @@ dirs = "2.0.2" num_cpus = "1.12.0" solana-clap-utils = { path = "../clap-utils", version = "0.24.0" } solana-cli-config = { path = "../cli-config", version = "0.24.0" } +solana-remote-wallet = { path = "../remote-wallet", version = "0.24.0" } solana-sdk = { path = "../sdk", version = "0.24.0" } tiny-bip39 = "0.7.0" diff --git a/keygen/src/keygen.rs b/keygen/src/keygen.rs index 0ee3ed33b..96aaef3d2 100644 --- a/keygen/src/keygen.rs +++ b/keygen/src/keygen.rs @@ -5,12 +5,20 @@ use clap::{ SubCommand, }; use num_cpus; -use solana_clap_utils::keypair::{ - keypair_from_seed_phrase, prompt_passphrase, ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG, +use solana_clap_utils::{ + input_parsers::derivation_of, + input_validators::is_derivation, + keypair::{ + keypair_from_seed_phrase, prompt_passphrase, ASK_KEYWORD, SKIP_SEED_PHRASE_VALIDATION_ARG, + }, }; use solana_cli_config::config::{Config, CONFIG_FILE}; +use solana_remote_wallet::{ + ledger::get_ledger_from_info, + remote_wallet::{RemoteWallet, RemoteWalletInfo}, +}; use solana_sdk::{ - pubkey::write_pubkey_file, + pubkey::{write_pubkey_file, Pubkey}, signature::{ keypair_from_seed, read_keypair, read_keypair_file, write_keypair, write_keypair_file, Keypair, KeypairUtil, Signature, @@ -65,11 +73,47 @@ fn get_keypair_from_matches( } else if keypair == ASK_KEYWORD { let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); keypair_from_seed_phrase("pubkey recovery", skip_validation, false) + } else if keypair.starts_with("usb://") { + Err(String::from("Remote wallet signing not yet implemented").into()) } else { read_keypair_file(keypair) } } +fn get_pubkey_from_matches( + matches: &ArgMatches, + config: Config, +) -> Result> { + let mut path = dirs::home_dir().expect("home directory"); + let keypair = if matches.is_present("keypair") { + matches.value_of("keypair").unwrap() + } else if config.keypair_path != "" { + &config.keypair_path + } else { + path.extend(&[".config", "solana", "id.json"]); + path.to_str().unwrap() + }; + + if keypair == "-" { + let mut stdin = std::io::stdin(); + read_keypair(&mut stdin).map(|keypair| keypair.pubkey()) + } else if keypair == ASK_KEYWORD { + let skip_validation = matches.is_present(SKIP_SEED_PHRASE_VALIDATION_ARG.name); + keypair_from_seed_phrase("pubkey recovery", skip_validation, false) + .map(|keypair| keypair.pubkey()) + } else if keypair.starts_with("usb://") { + let (remote_wallet_info, mut derivation_path) = + RemoteWalletInfo::parse_path(keypair.to_string())?; + if let Some(derivation) = derivation_of(matches, "derivation_path") { + derivation_path = derivation; + } + let ledger = get_ledger_from_info(remote_wallet_info)?; + Ok(ledger.get_pubkey(derivation_path)?) + } else { + read_keypair_file(keypair).map(|keypair| keypair.pubkey()) + } +} + fn output_keypair( keypair: &Keypair, outfile: &str, @@ -326,7 +370,7 @@ fn main() -> Result<(), Box> { .index(1) .value_name("PATH") .takes_value(true) - .help("Path to keypair file"), + .help("Path to keypair file or remote wallet"), ) .arg( Arg::with_name(SKIP_SEED_PHRASE_VALIDATION_ARG.name) @@ -346,6 +390,14 @@ fn main() -> Result<(), Box> { .short("f") .long("force") .help("Overwrite the output file if it exists"), + ) + .arg( + Arg::with_name("derivation_path") + .long("derivation-path") + .value_name("ACCOUNT or ACCOUNT/CHANGE") + .takes_value(true) + .validator(is_derivation) + .help("Derivation path to use: m/44'/501'/ACCOUNT'/CHANGE'; default key is device base pubkey: m/44'/501'/0'") ), ) .subcommand( @@ -382,14 +434,14 @@ fn main() -> Result<(), Box> { match matches.subcommand() { ("pubkey", Some(matches)) => { - let keypair = get_keypair_from_matches(matches, config)?; + let pubkey = get_pubkey_from_matches(matches, config)?; if matches.is_present("outfile") { let outfile = matches.value_of("outfile").unwrap(); check_for_overwrite(&outfile, &matches); - write_pubkey_file(outfile, keypair.pubkey())?; + write_pubkey_file(outfile, pubkey)?; } else { - println!("{}", keypair.pubkey()); + println!("{}", pubkey); } } ("new", Some(matches)) => { diff --git a/remote-wallet/Cargo.toml b/remote-wallet/Cargo.toml new file mode 100644 index 000000000..d6e4627cd --- /dev/null +++ b/remote-wallet/Cargo.toml @@ -0,0 +1,30 @@ +[package] +authors = ["Solana Maintainers "] +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" diff --git a/remote-wallet/src/bin/ledger-udev.rs b/remote-wallet/src/bin/ledger-udev.rs new file mode 100644 index 000000000..d5f4b8573 --- /dev/null +++ b/remote-wallet/src/bin/ledger-udev.rs @@ -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> { + 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(()) +} diff --git a/remote-wallet/src/ledger.rs b/remote-wallet/src/ledger.rs new file mode 100644 index 000000000..9de9a61d0 --- /dev/null +++ b/remote-wallet/src/ledger.rs @@ -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, 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, RemoteWalletError> { + self.write(command, p1, p2, data)?; + self.read() + } + + fn get_firmware_version(&self) -> Result { + 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 { + 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 { + 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 { + 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 { + 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, 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, Vec) = 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 + } +} diff --git a/remote-wallet/src/lib.rs b/remote-wallet/src/lib.rs new file mode 100644 index 000000000..313dde888 --- /dev/null +++ b/remote-wallet/src/lib.rs @@ -0,0 +1,2 @@ +pub mod ledger; +pub mod remote_wallet; diff --git a/remote-wallet/src/remote_wallet.rs b/remote-wallet/src/remote_wallet.rs new file mode 100644 index 000000000..7e55f50d6 --- /dev/null +++ b/remote-wallet/src/remote_wallet.rs @@ -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>, + devices: RwLock>, +} + +impl RemoteWalletManager { + /// Create a new instance. + pub fn new(usb: Arc>) -> Arc { + 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 { + 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 { + 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, 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 { + 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; + + /// Get solana pubkey from a RemoteWallet + fn get_pubkey(&self, derivation: DerivationPath) -> Result; + + /// Sign transaction data with wallet managing pubkey at derivation path m/44'/501'/'/'. + fn sign_transaction( + &self, + derivation: DerivationPath, + transaction: Transaction, + ) -> Result; +} + +/// `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), +} + +/// 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::().unwrap(); + derivation_path.change = parts + .next() + .and_then(|change| change.replace("'", "").parse::().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, +} + +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 { + 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)); + } +}