diff --git a/Cargo.lock b/Cargo.lock index 45007448614bcd..72df4f04cea202 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + [[package]] name = "aead" version = "0.5.2" @@ -1333,6 +1339,21 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin_hashes" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7066118b13d4b20b23645932dfb3a81ce7e29f95726c2036fa33cd7b092501" +dependencies = [ + "bitcoin-private", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1478,6 +1499,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ + "sha2 0.10.9", "tinyvec", ] @@ -2024,6 +2046,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239fa3ae9b63c2dc74bd3fa852d4792b8b305ae64eeede946265b6af62f1fff3" +dependencies = [ + "memchr", +] + [[package]] name = "core_affinity" version = "0.5.10" @@ -2045,6 +2076,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.2.1" @@ -4321,6 +4367,27 @@ version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +[[package]] +name = "libflate" +version = "1.3.0" +source = "git+https://github.com/KeystoneHQ/libflate.git?tag=1.3.1#e6236f7417b9bd34dbbd4b3c821be10299c44a73" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "1.2.0" +source = "git+https://github.com/KeystoneHQ/libflate.git?tag=1.3.1#e6236f7417b9bd34dbbd4b3c821be10299c44a73" +dependencies = [ + "core2", + "hashbrown 0.13.2", + "rle-decode-fast", +] + [[package]] name = "libloading" version = "0.7.4" @@ -4586,6 +4653,26 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2687e6cf9c00f48e9284cf9fd15f2ef341d03cc7743abf9df4c5f07fdee50b18" +[[package]] +name = "minicbor" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7005aaf257a59ff4de471a9d5538ec868a21586534fff7f85dd97d4043a6139" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1154809406efdb7982841adb6311b3d095b46f78342dd646736122fe6b19e267" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "minimal-lexical" version = "0.1.4" @@ -5084,9 +5171,9 @@ dependencies = [ [[package]] name = "paste" -version = "1.0.9" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1de2e551fb905ac83f73f7aedf2f0cb4a0da7e35efa24a202a936269f1f18e1" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" [[package]] name = "pbkdf2" @@ -5200,6 +5287,48 @@ dependencies = [ "indexmap 1.9.3", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "pickledb" version = "0.5.1" @@ -6069,6 +6198,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + [[package]] name = "rocksdb" version = "0.23.0" @@ -9874,6 +10009,7 @@ dependencies = [ "assert_matches", "console 0.16.0", "dialoguer", + "hex", "hidapi", "log", "num-derive", @@ -9881,11 +10017,14 @@ dependencies = [ "parking_lot 0.12.3", "qstring", "semver 1.0.26", + "serde_json", "solana-derivation-path", "solana-offchain-message", "solana-pubkey", "solana-signature", "solana-signer", + "ur-parse-lib", + "ur-registry", "thiserror 2.0.16", "uriparse", ] @@ -13200,6 +13339,47 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "461d0c5956fcc728ecc03a3a961e4adc9a7975d86f6f8371389a289517c02ca9" +[[package]] +name = "ur" +version = "0.3.0" +source = "git+https://github.com/KeystoneHQ/ur-rs?tag=0.3.3#81b8bb3b6b3a823128489c81ffee5bb4001ba2ae" +dependencies = [ + "bitcoin_hashes", + "crc", + "minicbor", + "phf", + "rand_xoshiro", +] + +[[package]] +name = "ur-parse-lib" +version = "0.2.0" +source = "git+https://github.com/KeystoneHQ/keystone-sdk-rust.git?tag=0.0.50#537284b85a92d9683361dd217ca11cab42cbdac6" +dependencies = [ + "hex", + "ur", + "ur-registry", +] + +[[package]] +name = "ur-registry" +version = "0.1.1" +source = "git+https://github.com/KeystoneHQ/keystone-sdk-rust.git?tag=0.0.50#537284b85a92d9683361dd217ca11cab42cbdac6" +dependencies = [ + "bs58", + "core2", + "hex", + "libflate", + "minicbor", + "paste", + "prost", + "prost-build", + "prost-types", + "serde", + "thiserror 1.0.69", + "ur", +] + [[package]] name = "uriparse" version = "0.6.4" diff --git a/clap-utils/src/keypair.rs b/clap-utils/src/keypair.rs index 421d1a2df2ebe7..e1d14856c85b75 100644 --- a/clap-utils/src/keypair.rs +++ b/clap-utils/src/keypair.rs @@ -28,9 +28,10 @@ use { solana_presigner::Presigner, solana_pubkey::Pubkey, solana_remote_wallet::{ + errors::RemoteWalletError, locator::{Locator as RemoteWalletLocator, LocatorError as RemoteWalletLocatorError}, remote_keypair::generate_remote_keypair, - remote_wallet::{maybe_wallet_manager, RemoteWalletError, RemoteWalletManager}, + remote_wallet::{maybe_wallet_manager, RemoteWalletManager}, }, solana_seed_phrase::generate_seed_from_seed_phrase_and_passphrase, solana_signature::Signature, diff --git a/clap-v3-utils/src/keypair.rs b/clap-v3-utils/src/keypair.rs index 4022775b88ea7d..9222f8a6a217eb 100644 --- a/clap-v3-utils/src/keypair.rs +++ b/clap-v3-utils/src/keypair.rs @@ -27,8 +27,9 @@ use { solana_presigner::Presigner, solana_pubkey::Pubkey, solana_remote_wallet::{ + errors::RemoteWalletError, remote_keypair::generate_remote_keypair, - remote_wallet::{maybe_wallet_manager, RemoteWalletError, RemoteWalletManager}, + remote_wallet::{maybe_wallet_manager, RemoteWalletManager}, }, solana_seed_derivable::SeedDerivable, solana_seed_phrase::generate_seed_from_seed_phrase_and_passphrase, diff --git a/remote-wallet/Cargo.toml b/remote-wallet/Cargo.toml index 01698749762f57..7217b73eba1960 100644 --- a/remote-wallet/Cargo.toml +++ b/remote-wallet/Cargo.toml @@ -36,6 +36,10 @@ solana-signature = { workspace = true, features = ["std"] } solana-signer = { workspace = true } thiserror = { workspace = true } uriparse = { workspace = true } +serde_json = { workspace = true } +hex = { workspace = true } +ur-registry = { git = "https://github.com/KeystoneHQ/keystone-sdk-rust.git", tag = "0.0.50", default-features = false, features = ["std"] } +ur-parse-lib = { git = "https://github.com/KeystoneHQ/keystone-sdk-rust.git", tag = "0.0.50" } [dev-dependencies] assert_matches = { workspace = true } diff --git a/remote-wallet/README.md b/remote-wallet/README.md index 1bbad3e0666229..aab1f02e925438 100644 --- a/remote-wallet/README.md +++ b/remote-wallet/README.md @@ -1,10 +1,158 @@ -Solana Remote Wallet -=== +# Solana Remote Wallet -Library for interacting with "remote" wallets, meaning any wallet where the private key bytes are not directly available, -such as Ledger devices. +A Rust library for interacting with hardware wallets in the Solana ecosystem. This library provides a unified interface for discovering, connecting to, and performing operations with various hardware wallet devices. -## Ledger udev-rules +## Features -In order to use a Ledger device on Linux machines, users must apply certain udev rules. These are available at the -[udev-rules repository](https://github.com/LedgerHQ/udev-rules) maintained by the Ledger team. +- **Multi-Wallet Support**: Supports Ledger and Keystone hardware wallets, with extensible architecture for additional wallet types +- **USB HID Communication**: Secure communication with hardware wallets via USB HID protocol +- **Device Discovery**: Automatic detection and connection to connected hardware wallets +- **Transaction Signing**: Sign Solana transactions and messages using hardware wallet private keys +- **Public Key Derivation**: Derive Solana public keys from hardware wallet derivation paths +- **Cross-Platform**: Works on Linux, macOS, and Windows +- **Thread-Safe**: Concurrent access to wallet manager with proper synchronization + +## Supported Hardware Wallets + +### Ledger +- **Protocol**: USB HID with APDU commands +- **Features**: Transaction signing, message signing, public key derivation +- **Ledger udev-rules**: In order to use a Ledger device on Linux machines, users must apply certain udev rules. These are available at the [udev-rules repository](https://github.com/LedgerHQ/udev-rules) maintained by the Ledger team. + +### Keystone +- **Protocol**: USB HID with UR (Uniform Resource) protocol +- **Features**: Transaction signing, message signing, public key derivation, QR code support +- **Device Support**: Keystone 3 Pro +- **UR Protocol**: Uses UR (Uniform Resource) protocol for secure communication with QR code capabilities + +## Architecture + +The library is organized into several key modules: + +``` +remote-wallet/ +├── src/ +│ ├── lib.rs # Main library entry point +│ ├── errors.rs # Error types and conversions +│ ├── remote_wallet.rs # Core wallet manager and traits +│ ├── remote_keypair.rs # Remote keypair implementation +│ ├── locator.rs # Device locator and URI parsing +│ ├── wallet/ # Wallet implementations +│ │ ├── mod.rs # Wallet module definitions +│ │ ├── types.rs # Common wallet types +│ │ ├── errors.rs # Wallet-specific errors +│ │ ├── ledger/ # Ledger wallet implementation +│ │ │ ├── mod.rs # Ledger module +│ │ │ ├── ledger.rs # Ledger wallet logic +│ │ │ └── error.rs # Ledger-specific errors +│ │ └── keystone/ # Keystone wallet implementation +│ │ ├── mod.rs # Keystone module +│ │ ├── keystone.rs # Keystone wallet logic +│ │ └── error.rs # Keystone-specific errors +│ └── transport/ # Communication layer +│ ├── mod.rs # Transport module +│ ├── transport_trait.rs # Transport trait definition +│ ├── hid_transport.rs # HID transport implementation +│ └── common.rs # Common transport utilities +``` + +### Core Components + +#### RemoteWalletManager +The main entry point for hardware wallet operations. It manages: +- Device discovery and connection +- Device lifecycle management +- Concurrent access to multiple devices + +#### RemoteWallet Trait +Defines the interface that all hardware wallet implementations must provide: +- Device initialization and information reading +- Public key derivation +- Transaction and message signing + +#### WalletProbe Trait +Handles device discovery and connection for specific wallet types: +- Device identification +- Connection establishment +- Device validation + +#### Transport Layer +Abstracts the communication protocol: +- USB HID transport for Ledger devices +- Extensible for other communication methods + +### Device Locators + +Hardware wallets are identified using URI-style locators: +``` +usb://ledger +usb://ledger?key=0 +usb://keystone +usb://keystone?key=0 +``` + +### Security Considerations + +- **Private Keys**: Private keys never leave the hardware wallet +- **User Confirmation**: Critical operations require physical confirmation on the device +- **Secure Communication**: All communication uses encrypted USB HID protocol +- **Input Validation**: All inputs are validated before being sent to the device + +## Development + +### Building + +```bash +# Build the library +cargo build + +# Build with specific features +cargo build --features "hidapi,linux-static-hidraw" + +# Run tests +cargo test + +# Build documentation +cargo doc --open +``` + +### Adding New Wallet Types + +To add support for a new hardware wallet: + +1. Create a new module in `src/wallet/` +2. Implement the `RemoteWallet` trait +3. Implement the `WalletProbe` trait +4. Add the wallet type to `RemoteWalletType` enum +5. Register the probe in `create_wallet_probes()` +6. Update error handling and keypair generation + +Example structure for a new wallet: + +```rust +// src/wallet/new_wallet/mod.rs +pub mod wallet; +pub mod probe; +pub mod error; + +// src/wallet/new_wallet/wallet.rs +pub struct NewWallet { + // Implementation +} + +impl RemoteWallet for NewWallet { + // Implement required methods +} + +// src/wallet/new_wallet/probe.rs +pub struct NewWalletProbe; + +impl WalletProbe for NewWalletProbe { + // Implement device discovery +} +``` + +### Current Implementations + +The library currently includes complete implementations for: +- **Keystone**: Full support for Keystone hardware wallets with UR protocol diff --git a/remote-wallet/src/errors.rs b/remote-wallet/src/errors.rs new file mode 100644 index 00000000000000..2cba5e5f8d8b77 --- /dev/null +++ b/remote-wallet/src/errors.rs @@ -0,0 +1,75 @@ +use crate::locator::LocatorError; +use crate::wallet::keystone::error::KeystoneError; +use crate::wallet::ledger::error::LedgerError; +use solana_derivation_path::DerivationPathError; +use solana_signer::SignerError; +use thiserror::Error; + +/// Remote wallet error. +#[derive(Error, Debug, Clone)] +pub enum RemoteWalletError { + #[error("hidapi error")] + Hid(String), + + #[error("device type mismatch")] + DeviceTypeMismatch, + + #[error("device with non-supported product ID or vendor ID was detected")] + InvalidDevice, + + #[error(transparent)] + DerivationPathError(#[from] DerivationPathError), + + #[error("invalid input: {0}")] + InvalidInput(String), + + #[error("invalid path: {0}")] + InvalidPath(String), + + #[error(transparent)] + LedgerError(#[from] LedgerError), + + #[error(transparent)] + KeystoneError(#[from] KeystoneError), + + #[error("no device found")] + NoDeviceFound, + + #[error("protocol error: {0}")] + Protocol(&'static str), + + #[error("pubkey not found for given address")] + PubkeyNotFound, + + #[error("remote wallet operation rejected by the user")] + UserCancel, + + #[error(transparent)] + LocatorError(#[from] LocatorError), +} + +#[cfg(feature = "hidapi")] +impl From for RemoteWalletError { + fn from(err: hidapi::HidError) -> RemoteWalletError { + RemoteWalletError::Hid(err.to_string()) + } +} + +impl From for SignerError { + fn from(err: RemoteWalletError) -> SignerError { + match err { + RemoteWalletError::Hid(hid_error) => SignerError::Connection(hid_error), + RemoteWalletError::DeviceTypeMismatch => SignerError::Connection(err.to_string()), + RemoteWalletError::InvalidDevice => SignerError::Connection(err.to_string()), + RemoteWalletError::InvalidInput(input) => SignerError::InvalidInput(input), + RemoteWalletError::LedgerError(e) => SignerError::Protocol(e.to_string()), + RemoteWalletError::KeystoneError(e) => SignerError::Protocol(e.to_string()), + RemoteWalletError::NoDeviceFound => SignerError::NoDeviceFound, + RemoteWalletError::Protocol(e) => SignerError::Protocol(e.to_string()), + RemoteWalletError::UserCancel => { + SignerError::UserCancel("remote wallet operation rejected by the user".to_string()) + } + _ => SignerError::Custom(err.to_string()), + } + } +} diff --git a/remote-wallet/src/lib.rs b/remote-wallet/src/lib.rs index 2400850734b1c1..25ebd97f78a6bd 100644 --- a/remote-wallet/src/lib.rs +++ b/remote-wallet/src/lib.rs @@ -1,7 +1,14 @@ #![allow(clippy::arithmetic_side_effects)] #![allow(dead_code)] -pub mod ledger; -pub mod ledger_error; + +pub mod errors; pub mod locator; pub mod remote_keypair; pub mod remote_wallet; +pub mod transport; +pub mod wallet; + +pub trait Transport: Send { + fn write(&self, data: &[u8]) -> Result; + fn read(&self) -> Result, String>; +} diff --git a/remote-wallet/src/locator.rs b/remote-wallet/src/locator.rs index a54a210a7759bd..ee00b686919665 100644 --- a/remote-wallet/src/locator.rs +++ b/remote-wallet/src/locator.rs @@ -12,6 +12,7 @@ use { pub enum Manufacturer { Unknown, Ledger, + Keystone, } impl Default for Manufacturer { @@ -22,6 +23,7 @@ impl Default for Manufacturer { const MANUFACTURER_UNKNOWN: &str = "unknown"; const MANUFACTURER_LEDGER: &str = "ledger"; +const MANUFACTURER_KEYSTONE: &str = "keystone"; #[derive(Clone, Debug, Error, PartialEq, Eq)] #[error("not a manufacturer")] @@ -39,6 +41,7 @@ impl FromStr for Manufacturer { let s = s.to_ascii_lowercase(); match s.as_str() { MANUFACTURER_LEDGER => Ok(Self::Ledger), + MANUFACTURER_KEYSTONE => Ok(Self::Keystone), _ => Err(ManufacturerError), } } @@ -56,6 +59,7 @@ impl AsRef for Manufacturer { match self { Self::Unknown => MANUFACTURER_UNKNOWN, Self::Ledger => MANUFACTURER_LEDGER, + Self::Keystone => MANUFACTURER_KEYSTONE, } } } diff --git a/remote-wallet/src/remote_keypair.rs b/remote-wallet/src/remote_keypair.rs index 06e79d05ab2773..c67b70eaffe1fc 100644 --- a/remote-wallet/src/remote_keypair.rs +++ b/remote-wallet/src/remote_keypair.rs @@ -1,10 +1,11 @@ use { crate::{ - ledger::get_ledger_from_info, + errors::RemoteWalletError, locator::{Locator, Manufacturer}, - remote_wallet::{ - RemoteWallet, RemoteWalletError, RemoteWalletInfo, RemoteWalletManager, - RemoteWalletType, + remote_wallet::{RemoteWallet, RemoteWalletInfo, RemoteWalletManager}, + wallet::{ + keystone::keystone::get_keystone_from_info, ledger::ledger::get_ledger_from_info, + types::RemoteWalletType, }, }, solana_derivation_path::DerivationPath, @@ -29,6 +30,9 @@ impl RemoteKeypair { ) -> Result { let pubkey = match &wallet_type { RemoteWalletType::Ledger(wallet) => wallet.get_pubkey(&derivation_path, confirm_key)?, + RemoteWalletType::Keystone(wallet) => { + wallet.get_pubkey(&derivation_path, confirm_key)? + } }; Ok(Self { @@ -50,6 +54,9 @@ impl Signer for RemoteKeypair { RemoteWalletType::Ledger(wallet) => wallet .sign_message(&self.derivation_path, message) .map_err(|e| e.into()), + RemoteWalletType::Keystone(wallet) => wallet + .sign_message(&self.derivation_path, message) + .map_err(|e| e.into()), } } @@ -75,6 +82,15 @@ pub fn generate_remote_keypair( confirm_key, path, )?) + } else if remote_wallet_info.manufacturer == Manufacturer::Keystone { + let keystone = get_keystone_from_info(remote_wallet_info, keypair_name, wallet_manager)?; + let path = format!("{}{}", keystone.pretty_path, derivation_path.get_query()); + Ok(RemoteKeypair::new( + RemoteWalletType::Keystone(keystone), + derivation_path, + confirm_key, + path, + )?) } else { Err(RemoteWalletError::DeviceTypeMismatch) } diff --git a/remote-wallet/src/remote_wallet.rs b/remote-wallet/src/remote_wallet.rs index 489e5fa3782e86..3520e822fb50ed 100644 --- a/remote-wallet/src/remote_wallet.rs +++ b/remote-wallet/src/remote_wallet.rs @@ -1,93 +1,46 @@ +use crate::transport::transport_trait::Transport; #[cfg(feature = "hidapi")] -use {crate::ledger::is_valid_ledger, parking_lot::Mutex, std::sync::Arc}; use { + crate::errors::RemoteWalletError, crate::{ - ledger::LedgerWallet, - ledger_error::LedgerError, - locator::{Locator, LocatorError, Manufacturer}, + locator::{Locator, Manufacturer}, + wallet::{ + keystone::keystone::KeystoneWallet, + ledger::ledger::LedgerWallet, + types::{Device, RemoteWalletType}, + WalletProbe, + }, }, log::*, parking_lot::RwLock, - solana_derivation_path::{DerivationPath, DerivationPathError}, + solana_derivation_path::DerivationPath, solana_pubkey::Pubkey, solana_signature::Signature, - solana_signer::SignerError, std::{ rc::Rc, time::{Duration, Instant}, }, - thiserror::Error, }; +#[cfg(feature = "hidapi")] +use {hidapi::DeviceInfo, parking_lot::Mutex, std::sync::Arc}; const HID_GLOBAL_USAGE_PAGE: u16 = 0xFF00; const HID_USB_DEVICE_CLASS: u8 = 0; -/// Remote wallet error. -#[derive(Error, Debug, Clone)] -pub enum RemoteWalletError { - #[error("hidapi error")] - Hid(String), - - #[error("device type mismatch")] - DeviceTypeMismatch, - - #[error("device with non-supported product ID or vendor ID was detected")] - InvalidDevice, - - #[error(transparent)] - DerivationPathError(#[from] DerivationPathError), - - #[error("invalid input: {0}")] - InvalidInput(String), - - #[error("invalid path: {0}")] - InvalidPath(String), +// Error messages +const ERROR_HIDAPI_DISABLED: &str = "hidapi crate compilation disabled in solana-remote-wallet."; - #[error(transparent)] - LedgerError(#[from] LedgerError), - - #[error("no device found")] - NoDeviceFound, - - #[error("protocol error: {0}")] - Protocol(&'static str), - - #[error("pubkey not found for given address")] - PubkeyNotFound, - - #[error("remote wallet operation rejected by the user")] - UserCancel, - - #[error(transparent)] - LocatorError(#[from] LocatorError), -} +// Logging messages +const LOG_DEVICE_SINGULAR: &str = ""; +const LOG_DEVICE_PLURAL: &str = "s"; -#[cfg(feature = "hidapi")] -impl From for RemoteWalletError { - fn from(err: hidapi::HidError) -> RemoteWalletError { - RemoteWalletError::Hid(err.to_string()) - } -} - -impl From for SignerError { - fn from(err: RemoteWalletError) -> SignerError { - match err { - RemoteWalletError::Hid(hid_error) => SignerError::Connection(hid_error), - RemoteWalletError::DeviceTypeMismatch => SignerError::Connection(err.to_string()), - RemoteWalletError::InvalidDevice => SignerError::Connection(err.to_string()), - RemoteWalletError::InvalidInput(input) => SignerError::InvalidInput(input), - RemoteWalletError::LedgerError(e) => SignerError::Protocol(e.to_string()), - RemoteWalletError::NoDeviceFound => SignerError::NoDeviceFound, - RemoteWalletError::Protocol(e) => SignerError::Protocol(e.to_string()), - RemoteWalletError::UserCancel => { - SignerError::UserCancel("remote wallet operation rejected by the user".to_string()) - } - _ => SignerError::Custom(err.to_string()), - } - } -} - -/// Collection of connected RemoteWallets +/// Manager for hardware wallet devices +/// +/// This struct manages the discovery, connection, and interaction with hardware wallets. +/// It maintains a collection of connected devices and provides methods to access them. +/// +/// The manager supports multiple hardware wallet types (Ledger, Keystone) and handles +/// USB HID communication through the hidapi library. pub struct RemoteWalletManager { #[cfg(feature = "hidapi")] usb: Arc>, @@ -104,61 +57,85 @@ impl RemoteWalletManager { }) } - /// Repopulate device list - /// Note: this method iterates over and updates all devices + /// Repopulate device list by scanning for connected hardware wallets + /// + /// This method refreshes the USB device list and attempts to connect to all + /// supported hardware wallets (Ledger and Keystone). It updates the internal + /// device collection and returns the number of newly discovered devices. + /// + /// Returns the number of devices added (can be negative if devices were removed) #[cfg(feature = "hidapi")] pub fn update_devices(&self) -> Result { let mut usb = self.usb.lock(); usb.refresh_devices()?; - let devices = usb.device_list(); - let num_prev_devices = self.devices.read().len(); - - let mut detected_devices = vec![]; - let mut errors = vec![]; - for device_info in devices.filter(|&device_info| { - is_valid_hid_device(device_info.usage_page(), device_info.interface_number()) - && is_valid_ledger(device_info.vendor_id(), device_info.product_id()) - }) { - match usb.open_path(device_info.path()) { - Ok(device) => { - let mut ledger = LedgerWallet::new(device); - let result = ledger.read_device(device_info); - match result { - Ok(info) => { - ledger.pretty_path = info.get_pretty_path(); - let path = device_info.path().to_str().unwrap().to_string(); - trace!("Found device: {info:?}"); - detected_devices.push(Device { - path, - info, - wallet_type: RemoteWalletType::Ledger(Rc::new(ledger)), - }) - } - Err(err) => { - error!("Error connecting to ledger device to read info: {err}"); - errors.push(err) - } - } - } - Err(err) => error!("Error connecting to ledger device to read info: {err}"), - } - } - let num_curr_devices = detected_devices.len(); - *self.devices.write() = detected_devices; + let prev_device_count = self.devices.read().len(); + + // Initialize supported wallet probes + let probes = self.create_wallet_probes(); - if num_curr_devices == 0 && !errors.is_empty() { - return Err(errors[0].clone()); + // Filter HID devices and attempt to connect + let new_devices = self.discover_and_connect_devices(&mut usb, &probes)?; + + // Update device list + *self.devices.write() = new_devices; + + Ok(self.devices.read().len() - prev_device_count) + } + + /// Create wallet probes for supported hardware wallets + #[cfg(feature = "hidapi")] + fn create_wallet_probes(&self) -> Vec> { + use crate::wallet::{keystone::keystone::KeystoneProbe, ledger::ledger::LedgerProbe}; + vec![Box::new(LedgerProbe), Box::new(KeystoneProbe)] + } + + /// Discover and connect to supported hardware wallet devices + #[cfg(feature = "hidapi")] + fn discover_and_connect_devices( + &self, + usb: &mut hidapi::HidApi, + probes: &[Box], + ) -> Result, RemoteWalletError> { + let valid_devices: Vec = usb + .device_list() + .filter(|d| is_valid_hid_device(d.usage_page(), d.interface_number())) + .cloned() + .collect(); + + let connection_results: Vec> = valid_devices + .into_iter() + .filter_map(|devinfo| { + probes + .iter() + .find(|p| p.is_supported_device(&devinfo)) + .map(|p| p.open(usb, devinfo)) + }) + .collect(); + + // Log connection errors for debugging + let (successful_devices, failed_connections): (Vec<_>, Vec<_>) = + connection_results.into_iter().partition(Result::is_ok); + + if !failed_connections.is_empty() { + debug!( + "Failed to connect to {} device(s)", + failed_connections.len() + ); + for (i, err) in failed_connections.iter().enumerate() { + debug!("Connection error {}: {:?}", i + 1, err); + } } - Ok(num_curr_devices - num_prev_devices) + Ok(successful_devices + .into_iter() + .filter_map(Result::ok) + .collect()) } #[cfg(not(feature = "hidapi"))] pub fn update_devices(&self) -> Result { - Err(RemoteWalletError::Hid( - "hidapi crate compilation disabled in solana-remote-wallet.".to_string(), - )) + Err(RemoteWalletError::Hid(ERROR_HIDAPI_DISABLED.to_string())) } /// List connected and acknowledged wallets @@ -166,59 +143,171 @@ impl RemoteWalletManager { self.devices.read().iter().map(|d| d.info.clone()).collect() } - /// Get a particular wallet - #[allow(unreachable_patterns)] - pub fn get_ledger( + /// Get a particular wallet by host device path and extract wallet type + fn get_wallet_by_path( &self, host_device_path: &str, - ) -> Result, RemoteWalletError> { + extractor: F, + ) -> Result + where + F: FnOnce(&RemoteWalletType) -> Result, + { self.devices .read() .iter() .find(|device| device.info.host_device_path == host_device_path) .ok_or(RemoteWalletError::PubkeyNotFound) - .and_then(|device| match &device.wallet_type { - RemoteWalletType::Ledger(ledger) => Ok(ledger.clone()), - _ => Err(RemoteWalletError::DeviceTypeMismatch), - }) + .and_then(|device| extractor(&device.wallet_type)) } - /// Get wallet info. + /// Get a particular Ledger wallet + #[allow(unreachable_patterns)] + pub fn get_ledger( + &self, + host_device_path: &str, + ) -> Result, RemoteWalletError> { + self.get_wallet_by_path(host_device_path, |wallet_type| match wallet_type { + RemoteWalletType::Ledger(ledger) => Ok(ledger.clone()), + _ => Err(RemoteWalletError::DeviceTypeMismatch), + }) + } + + /// Get a particular Keystone wallet + pub fn get_keystone( + &self, + host_device_path: &str, + ) -> Result, RemoteWalletError> { + self.get_wallet_by_path(host_device_path, |wallet_type| match wallet_type { + RemoteWalletType::Keystone(keystone) => Ok(keystone.clone()), + _ => Err(RemoteWalletError::DeviceTypeMismatch), + }) + } + + /// Get wallet information by public key + /// + /// Searches through connected devices to find one with the specified public key. + /// Returns the device information if found, or `None` if no matching device exists. pub fn get_wallet_info(&self, pubkey: &Pubkey) -> Option { self.devices .read() .iter() - .find(|d| &d.info.pubkey == pubkey) - .map(|d| d.info.clone()) + .find(|device| &device.info.pubkey == pubkey) + .map(|device| device.info.clone()) + } + + /// Get the total number of connected devices + pub fn device_count(&self) -> usize { + self.devices.read().len() + } + + /// Check if any devices are connected + pub fn has_devices(&self) -> bool { + !self.devices.read().is_empty() + } + + /// Get devices by manufacturer + pub fn get_devices_by_manufacturer( + &self, + manufacturer: &Manufacturer, + ) -> Vec { + self.devices + .read() + .iter() + .filter(|device| &device.info.manufacturer == manufacturer) + .map(|device| device.info.clone()) + .collect() + } + + /// Get the first available wallet of any type + /// + /// This is a convenience method that returns the first connected wallet, + /// regardless of its type. Useful when you just need any available wallet. + pub fn get_first_available_wallet(&self) -> Option { + self.devices + .read() + .first() + .map(|device| device.info.clone()) } - /// Update devices in maximum `max_polling_duration` if it doesn't succeed + /// Attempt to connect to hardware wallets with polling within a time limit + /// + /// This method will continuously attempt to discover and connect to hardware wallets + /// until either devices are found or the maximum polling duration is exceeded. + /// + /// Returns `true` if at least one device was successfully connected, `false` otherwise pub fn try_connect_polling(&self, max_polling_duration: &Duration) -> bool { let start_time = Instant::now(); + let mut last_device_count = self.devices.read().len(); + while start_time.elapsed() <= *max_polling_duration { - if let Ok(num_devices) = self.update_devices() { - let plural = if num_devices == 1 { "" } else { "s" }; - trace!("{num_devices} Remote Wallet{plural} found"); - return true; + match self.update_devices() { + Ok(new_device_count) => { + let current_total = self.devices.read().len(); + if current_total > 0 { + let plural = if current_total == 1 { + LOG_DEVICE_SINGULAR + } else { + LOG_DEVICE_PLURAL + }; + trace!("{current_total} Remote Wallet{plural} found"); + return true; + } + last_device_count = current_total; + } + Err(err) => { + debug!("Error during device discovery: {:?}", err); + // Continue trying despite errors + } } + + // Small delay to avoid excessive polling + std::thread::sleep(Duration::from_millis(100)); } + + debug!( + "Polling timeout reached. No devices found after {:?}", + max_polling_duration + ); false } } -/// `RemoteWallet` trait +/// Trait for hardware wallet implementations +/// +/// This trait defines the interface that all hardware wallet implementations must provide. +/// It includes methods for device initialization, public key derivation, and message signing. +/// +/// # Type Parameters +/// * `T` - The device info type (typically `hidapi::DeviceInfo`) #[allow(unused_variables)] pub trait RemoteWallet { + /// Get the human-readable name of this wallet implementation fn name(&self) -> &str { "unimplemented" } - /// Parse device info and get device base pubkey + /// Parse device information and initialize the wallet + /// + /// This method is called during device discovery to read basic information + /// from the hardware wallet and establish communication. + /// + /// # Arguments + /// * `dev_info` - Device information from the USB subsystem + /// + /// # Returns + /// A `RemoteWalletInfo` structure containing device details and base public key fn read_device(&mut self, dev_info: &T) -> Result { unimplemented!(); } - /// Get solana pubkey from a RemoteWallet + /// Derive a public key from the hardware wallet + /// + /// # Arguments + /// * `derivation_path` - The BIP32/BIP44 derivation path + /// * `confirm_key` - Whether to require user confirmation on the device + /// + /// # Returns + /// The derived Solana public key fn get_pubkey( &self, derivation_path: &DerivationPath, @@ -227,8 +316,17 @@ pub trait RemoteWallet { unimplemented!(); } - /// Sign transaction data with wallet managing pubkey at derivation path - /// `m/44'/501'/'/'`. + /// Sign transaction data with the hardware wallet + /// + /// Signs raw transaction data using the private key at the specified derivation path. + /// The path follows the Solana convention: `m/44'/501'/'/'`. + /// + /// # Arguments + /// * `derivation_path` - The BIP32/BIP44 derivation path for the signing key + /// * `data` - The raw transaction data to sign + /// + /// # Returns + /// The signature produced by the hardware wallet fn sign_message( &self, derivation_path: &DerivationPath, @@ -237,8 +335,17 @@ pub trait RemoteWallet { unimplemented!(); } - /// Sign off-chain message with wallet managing pubkey at derivation path - /// `m/44'/501'/'/'`. + /// Sign an off-chain message with the hardware wallet + /// + /// Signs arbitrary off-chain data using the private key at the specified derivation path. + /// This is used for message signing that doesn't involve blockchain transactions. + /// + /// # Arguments + /// * `derivation_path` - The BIP32/BIP44 derivation path for the signing key + /// * `message` - The off-chain message data to sign + /// + /// # Returns + /// The signature produced by the hardware wallet fn sign_offchain_message( &self, derivation_path: &DerivationPath, @@ -248,38 +355,29 @@ pub trait RemoteWallet { } } -/// `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(Rc), -} - -/// Remote wallet information. +/// Information about a connected hardware wallet device +/// +/// This structure contains metadata about a hardware wallet device that has been +/// discovered and connected. It includes device identification, connection details, +/// and the device's base public key. #[derive(Debug, Default, Clone)] pub struct RemoteWalletInfo { - /// RemoteWallet device model + /// Device model name (e.g., "Nano S", "Keystone Pro") pub model: String, - /// RemoteWallet device manufacturer + /// Device manufacturer (Ledger, Keystone, etc.) pub manufacturer: Manufacturer, - /// RemoteWallet device serial number + /// Device serial number for identification pub serial: String, - /// RemoteWallet host device path + /// Host system path to the USB device pub host_device_path: String, - /// Base pubkey of device at Solana derivation path + /// Base public key derived from the device's default derivation path pub pubkey: Pubkey, - /// Initial read error + /// Error encountered during device initialization, if any pub error: Option, } impl RemoteWalletInfo { + /// Create RemoteWalletInfo from a Locator pub fn parse_locator(locator: Locator) -> Self { RemoteWalletInfo { manufacturer: locator.manufacturer, @@ -288,43 +386,106 @@ impl RemoteWalletInfo { } } + /// Get a human-readable path string for this device + /// + /// Returns a string in the format "usb://manufacturer/pubkey" that can be + /// used to identify this device in user interfaces or configuration files. pub fn get_pretty_path(&self) -> String { - format!("usb://{}/{:?}", self.manufacturer, self.pubkey,) + format!("usb://{}/{:?}", self.manufacturer, self.pubkey) } + /// Check if this device matches another device for identification purposes + /// + /// Two devices are considered matching if they have the same manufacturer and + /// either the same public key or at least one has a default (unset) public key. + /// This allows for flexible matching during device discovery. pub(crate) fn matches(&self, other: &Self) -> bool { self.manufacturer == other.manufacturer && (self.pubkey == other.pubkey || self.pubkey == Pubkey::default() || other.pubkey == Pubkey::default()) } + + /// Check if this device has an error + pub fn has_error(&self) -> bool { + self.error.is_some() + } + + /// Get the error message if any + pub fn error_message(&self) -> Option { + self.error.as_ref().map(|e| e.to_string()) + } + + /// Check if this device is ready for use (no error and valid pubkey) + pub fn is_ready(&self) -> bool { + self.error.is_none() && self.pubkey != Pubkey::default() + } } -/// Helper to determine if a device is a valid HID +/// Helper to determine if a device is a valid HID device for hardware wallets +/// +/// This function checks if a USB device has the correct HID characteristics +/// that are commonly used by hardware wallets. It validates either the +/// HID usage page or the USB device class. +/// +/// # Arguments +/// * `usage_page` - The HID usage page identifier +/// * `interface_number` - The USB interface number/class +/// +/// # Returns +/// `true` if the device appears to be a valid HID device for hardware wallets 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 +/// Initialize the hardware wallet manager +/// +/// This function creates a new RemoteWalletManager instance with HID API support. +/// The manager can be used to discover, connect to, and interact with hardware wallets. +/// +/// # Returns +/// A reference-counted RemoteWalletManager instance, or an error if initialization fails +/// +/// # Errors +/// Returns an error if HID API initialization fails or if the hidapi feature is disabled #[cfg(feature = "hidapi")] pub fn initialize_wallet_manager() -> Result, RemoteWalletError> { let hidapi = Arc::new(Mutex::new(hidapi::HidApi::new()?)); Ok(RemoteWalletManager::new(hidapi)) } + +/// Initialize the hardware wallet manager (hidapi disabled) +/// +/// This version is compiled when the hidapi feature is disabled and always returns an error. #[cfg(not(feature = "hidapi"))] pub fn initialize_wallet_manager() -> Result, RemoteWalletError> { - Err(RemoteWalletError::Hid( - "hidapi crate compilation disabled in solana-remote-wallet.".to_string(), - )) + Err(RemoteWalletError::Hid(ERROR_HIDAPI_DISABLED.to_string())) } +/// Create a wallet manager only if hardware wallets are detected +/// +/// This function initializes a wallet manager and performs an initial device scan. +/// If no devices are found, it returns `None` to avoid keeping an empty manager. +/// This is useful for applications that only need wallet functionality when hardware +/// wallets are actually connected. +/// +/// Returns `Some(manager)` if devices are found, `None` if no devices are detected pub fn maybe_wallet_manager() -> Result>, RemoteWalletError> { let wallet_manager = initialize_wallet_manager()?; - let device_count = wallet_manager.update_devices()?; - if device_count > 0 { + let _total_devices = wallet_manager.devices.read().len(); + + // Perform initial device scan + wallet_manager.update_devices()?; + let found_devices = wallet_manager.devices.read().len(); + + if found_devices > 0 { + debug!( + "Wallet manager initialized with {} device(s)", + found_devices + ); Ok(Some(wallet_manager)) } else { - drop(wallet_manager); + debug!("No hardware wallets detected, returning None"); Ok(None) } } diff --git a/remote-wallet/src/transport/hid_transport.rs b/remote-wallet/src/transport/hid_transport.rs new file mode 100644 index 00000000000000..f99800f7c54b76 --- /dev/null +++ b/remote-wallet/src/transport/hid_transport.rs @@ -0,0 +1,40 @@ +use super::transport_trait::Transport; +use crate::errors::RemoteWalletError; + +pub struct HidTransport { + pub device: hidapi::HidDevice, +} + +impl HidTransport { + pub fn new(device: hidapi::HidDevice) -> Self { + Self { device } + } +} + +impl Transport for HidTransport { + fn connect(&mut self) -> Result<(), RemoteWalletError> { + Ok(()) + } + + fn disconnect(&mut self) {} + + fn is_connected(&self) -> bool { + true + } + + fn write(&self, data: &[u8]) -> Result { + self.device + .write(data) + .map_err(|e| RemoteWalletError::Hid(e.to_string())) + } + + fn read(&self) -> Result, RemoteWalletError> { + let mut buf = vec![0u8; 64]; + let len = self + .device + .read(&mut buf) + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; + buf.truncate(len); + Ok(buf) + } +} diff --git a/remote-wallet/src/transport/mod.rs b/remote-wallet/src/transport/mod.rs new file mode 100644 index 00000000000000..35fbc99dd9da24 --- /dev/null +++ b/remote-wallet/src/transport/mod.rs @@ -0,0 +1,2 @@ +pub mod hid_transport; +pub mod transport_trait; diff --git a/remote-wallet/src/transport/transport_trait.rs b/remote-wallet/src/transport/transport_trait.rs new file mode 100644 index 00000000000000..1be78047c5d27f --- /dev/null +++ b/remote-wallet/src/transport/transport_trait.rs @@ -0,0 +1,12 @@ +use crate::errors::RemoteWalletError; +pub trait Transport: Send { + fn connect(&mut self) -> Result<(), RemoteWalletError>; + + fn disconnect(&mut self); + + fn is_connected(&self) -> bool; + + fn write(&self, data: &[u8]) -> Result; + + fn read(&self) -> Result, RemoteWalletError>; +} diff --git a/remote-wallet/src/wallet/errors.rs b/remote-wallet/src/wallet/errors.rs new file mode 100644 index 00000000000000..4a3bdabda1a590 --- /dev/null +++ b/remote-wallet/src/wallet/errors.rs @@ -0,0 +1,6 @@ +use std::error::Error; + +pub trait HardwareWalletError: Error { + fn code(&self) -> u16; + fn description(&self) -> String; +} diff --git a/remote-wallet/src/wallet/keystone/error.rs b/remote-wallet/src/wallet/keystone/error.rs new file mode 100644 index 00000000000000..4548cd1d001a6d --- /dev/null +++ b/remote-wallet/src/wallet/keystone/error.rs @@ -0,0 +1,216 @@ +use {num_traits::FromPrimitive, std::str::FromStr, thiserror::Error}; + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +#[repr(u16)] +pub enum KeystoneError { + #[error("no app response")] + NoAppResponse = 0x6700, + + #[error("Previous request not finished")] + PreviousRequestNotFinished = 0x6701, + + #[error("Invalid JSON response")] + InvalidJson = 0x6702, + + #[error("Key packet size mismatch")] + KeySizeMismatch = 0x6703, + + #[error("Device not connected")] + DeviceNotConnected = 0x6704, + + #[error("User rejected the request")] + UserRejected = 0x6705, + + #[error("Invalid derivation path")] + InvalidDerivationPath = 0x6706, + + #[error("Transaction signing failed")] + TransactionSigningFailed = 0x6707, + + #[error("Message signing failed")] + MessageSigningFailed = 0x6708, + + #[error("Device timeout")] + DeviceTimeout = 0x6709, + + #[error("Invalid transaction data")] + InvalidTransactionData = 0x670A, + + #[error("Unsupported operation")] + UnsupportedOperation = 0x670B, + + #[error("Device locked")] + DeviceLocked = 0x670C, + + #[error("Invalid signature")] + InvalidSignature = 0x670D, + + #[error("UR parsing rejected")] + URParsingRejected = 0x670E, + + #[error("Device error: {0}")] + DeviceError(String), + + #[error("Communication error: {message}")] + CommunicationError { message: String }, + + #[error("Unknown error: {code}")] + Unknown { code: u16 }, +} + +impl FromPrimitive for KeystoneError { + fn from_u64(n: u64) -> Option { + match n { + 0x6700 => Some(KeystoneError::NoAppResponse), + 0x6701 => Some(KeystoneError::PreviousRequestNotFinished), + 0x6702 => Some(KeystoneError::InvalidJson), + 0x6703 => Some(KeystoneError::KeySizeMismatch), + 0x6704 => Some(KeystoneError::DeviceNotConnected), + 0x6705 => Some(KeystoneError::UserRejected), + 0x6706 => Some(KeystoneError::InvalidDerivationPath), + 0x6707 => Some(KeystoneError::TransactionSigningFailed), + 0x6708 => Some(KeystoneError::MessageSigningFailed), + 0x6709 => Some(KeystoneError::DeviceTimeout), + 0x670A => Some(KeystoneError::InvalidTransactionData), + 0x670B => Some(KeystoneError::UnsupportedOperation), + 0x670C => Some(KeystoneError::DeviceLocked), + 0x670D => Some(KeystoneError::InvalidSignature), + _ => None, + } + } + + fn from_i64(n: i64) -> Option { + if n >= 0 { + Self::from_u64(n as u64) + } else { + None + } + } +} + +impl FromStr for KeystoneError { + type Err = String; + + fn from_str(s: &str) -> Result { + let s_lower = s.to_lowercase(); + + match s_lower.as_str() { + "previous request is not finished" | "previous request not finished" => { + Ok(KeystoneError::PreviousRequestNotFinished) + } + "solana app not open on keystone device" | "no app response" => { + Ok(KeystoneError::NoAppResponse) + } + "invalid json response" | "invalid json" => Ok(KeystoneError::InvalidJson), + "key packet size mismatch" | "key size mismatch" => Ok(KeystoneError::KeySizeMismatch), + "device not connected" => Ok(KeystoneError::DeviceNotConnected), + "user rejected the request" | "user rejected" => Ok(KeystoneError::UserRejected), + "invalid derivation path" => Ok(KeystoneError::InvalidDerivationPath), + "transaction signing failed" => Ok(KeystoneError::TransactionSigningFailed), + "message signing failed" => Ok(KeystoneError::MessageSigningFailed), + "device timeout" => Ok(KeystoneError::DeviceTimeout), + "invalid transaction data" => Ok(KeystoneError::InvalidTransactionData), + "unsupported operation" => Ok(KeystoneError::UnsupportedOperation), + "device locked" => Ok(KeystoneError::DeviceLocked), + "invalid signature" => Ok(KeystoneError::InvalidSignature), + "UR parsing rejected" => Ok(KeystoneError::URParsingRejected), + "export address is just allowed on specific pages" => Ok(KeystoneError::DeviceError( + "Export address is just allowed on specific pages".to_string(), + )), + + s if s.contains("previous request") && s.contains("not finished") => { + Ok(KeystoneError::PreviousRequestNotFinished) + } + s if s.contains("communication error") => Ok(KeystoneError::CommunicationError { + message: s.to_string(), + }), + s if s.contains("unknown error") => { + // Try to extract error code + if let Some(code_str) = s.split(':').last() { + if let Ok(code) = code_str.trim().parse::() { + return Ok(KeystoneError::Unknown { code }); + } + } + Ok(KeystoneError::Unknown { code: 0xFFFF }) + } + + _ => Err(format!("Unknown Keystone error: {}", s)), + } + } +} + +impl KeystoneError { + pub fn from_string(s: &str) -> Result { + s.parse() + } + + pub fn from_error_message(message: &str) -> Self { + let message_lower = message.to_lowercase(); + + if message_lower.contains("previous request") && message_lower.contains("not finished") { + KeystoneError::PreviousRequestNotFinished + } else if message_lower.contains("device not connected") + || message_lower.contains("connection failed") + { + KeystoneError::DeviceNotConnected + } else if message_lower.contains("user rejected") || message_lower.contains("cancelled") { + KeystoneError::UserRejected + } else if message_lower.contains("timeout") { + KeystoneError::DeviceTimeout + } else if message_lower.contains("locked") { + KeystoneError::DeviceLocked + } else if message_lower.contains("invalid signature") { + KeystoneError::InvalidSignature + } else if message_lower.contains("invalid json") { + KeystoneError::InvalidJson + } else if message_lower.contains("communication error") { + KeystoneError::CommunicationError { + message: message.to_string(), + } + } else if message_lower.contains("ur parsing rejected") { + KeystoneError::URParsingRejected + } else if message_lower.contains("export address is just allowed on specific pages") { + KeystoneError::DeviceError( + "Export address is just allowed on specific pages".to_string(), + ) + } else { + KeystoneError::CommunicationError { + message: message.to_string(), + } + } + } + + pub fn from_usize(status: usize) -> Option { + Self::from_u64(status as u64) + } +} + +use crate::wallet::errors::HardwareWalletError; + +impl HardwareWalletError for KeystoneError { + fn code(&self) -> u16 { + match self { + KeystoneError::NoAppResponse => 0x6700, + KeystoneError::PreviousRequestNotFinished => 0x6701, + KeystoneError::InvalidJson => 0x6702, + KeystoneError::KeySizeMismatch => 0x6703, + KeystoneError::DeviceNotConnected => 0x6704, + KeystoneError::UserRejected => 0x6705, + KeystoneError::InvalidDerivationPath => 0x6706, + KeystoneError::TransactionSigningFailed => 0x6707, + KeystoneError::MessageSigningFailed => 0x6708, + KeystoneError::DeviceTimeout => 0x6709, + KeystoneError::InvalidTransactionData => 0x670A, + KeystoneError::UnsupportedOperation => 0x670B, + KeystoneError::DeviceLocked => 0x670C, + KeystoneError::InvalidSignature => 0x670D, + KeystoneError::URParsingRejected => 0x670E, + KeystoneError::CommunicationError { .. } => 0x67FF, + KeystoneError::DeviceError(_) => 0x67FE, + KeystoneError::Unknown { code } => *code, + } + } + fn description(&self) -> String { + self.to_string() + } +} diff --git a/remote-wallet/src/wallet/keystone/keystone.rs b/remote-wallet/src/wallet/keystone/keystone.rs new file mode 100644 index 00000000000000..1679bad985f5b4 --- /dev/null +++ b/remote-wallet/src/wallet/keystone/keystone.rs @@ -0,0 +1,740 @@ +use { + super::error::KeystoneError, + crate::transport::hid_transport::HidTransport, + crate::transport::transport_trait::Transport, + crate::{ + errors::RemoteWalletError, + remote_wallet::{RemoteWallet, RemoteWalletInfo, RemoteWalletManager}, + wallet::{types::Device, WalletProbe}, + }, + console::Emoji, + dialoguer::{theme::ColorfulTheme, Select}, + hex, + semver::Version as FirmwareVersion, + serde_json, + solana_derivation_path::DerivationPath, + std::{fmt, rc::Rc}, + ur_parse_lib::keystone_ur_decoder::{probe_decode, URParseResult}, + ur_parse_lib::keystone_ur_encoder::probe_encode, + ur_registry::crypto_key_path::{CryptoKeyPath, PathComponent}, + ur_registry::extend::crypto_multi_accounts::CryptoMultiAccounts, + ur_registry::extend::key_derivation::KeyDerivationCall, + ur_registry::extend::key_derivation_schema::{Curve, KeyDerivationSchema}, + ur_registry::extend::qr_hardware_call::{ + CallParams, CallType, HardWareCallVersion, QRHardwareCall, + }, + ur_registry::solana::sol_sign_request::{SignType, SolSignRequest}, + ur_registry::solana::sol_signature::SolSignature, + ur_registry::traits::RegistryItem, +}; +#[cfg(feature = "hidapi")] +use { + crate::locator::Manufacturer, + log::*, + solana_pubkey::Pubkey, + solana_signature::Signature, + std::{cmp::min, convert::TryFrom}, +}; + +static CHECK_MARK: Emoji = Emoji("✅ ", ""); + +const REQUEST_ID: u16 = 0xACE0; +const HID_TAG: u8 = 0xAA; + +/// Keystone vendor ID +const KEYSTONE_VID: u16 = 0x1209; +/// Keystone product IDs +const KEYSTONE_PID: u16 = 0x3001; +const KEYSTONE_TRANSPORT_HEADER_LEN: usize = 5; + +const HID_PACKET_SIZE: usize = 64 + HID_PREFIX_ZERO; + +#[cfg(not(windows))] +const HID_PREFIX_ZERO: usize = 0; + +// JSON response field names +const JSON_FIELD_PUBKEY: &str = "pubkey"; +const JSON_FIELD_PAYLOAD: &str = "payload"; +const JSON_FIELD_FIRMWARE_VERSION: &str = "firmwareVersion"; +const JSON_FIELD_WALLET_MFP: &str = "walletMFP"; + +// Path validation constants +const CACHED_ACCOUNT_RANGE: u32 = 49; +const CACHED_CHANGE_RANGE: u32 = 49; +const CACHED_FIXED_ACCOUNT: u32 = 0; + +// Error messages +const ERROR_INVALID_JSON: &str = "Invalid JSON response"; +const ERROR_MISSING_FIELD: &str = "Missing required field"; +const ERROR_INVALID_HEX: &str = "Invalid hex data"; +const ERROR_SIGNATURE_SIZE: &str = "Signature packet size mismatch"; +const ERROR_KEY_SIZE: &str = "Key packet size mismatch"; +const ERROR_MFP_NOT_SET: &str = "MFP is not set"; +const ERROR_JSON_PARSE: &str = "JSON parse error"; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum CommandType { + CMD_ECHO_TEST = 0x01, + CMD_RESOLVE_UR = 0x02, + CMD_CHECK_LOCK_STATUS = 0x03, + CMD_EXPORT_ADDRESS = 0x04, + CMD_GET_DEVICE_INFO = 0x05, + CMD_GET_DEVICE_USB_PUBKEY = 0x06, +} + +impl CommandType { + const VALID_COMMANDS: &'static [u16] = &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06]; +} + +impl CommandType { + /// Check if a u16 value corresponds to a valid CommandType + fn is_valid_command(value: u16) -> bool { + Self::VALID_COMMANDS.contains(&value) + } + + /// Try to convert u16 to CommandType + fn from_u16(value: u16) -> Option { + match value { + 0x01 => Some(CommandType::CMD_ECHO_TEST), + 0x02 => Some(CommandType::CMD_RESOLVE_UR), + 0x03 => Some(CommandType::CMD_CHECK_LOCK_STATUS), + 0x04 => Some(CommandType::CMD_EXPORT_ADDRESS), + 0x05 => Some(CommandType::CMD_GET_DEVICE_INFO), + 0x06 => Some(CommandType::CMD_GET_DEVICE_USB_PUBKEY), + _ => None, + } + } +} + +/// Keystone Wallet device +pub struct KeystoneWallet { + pub transport: Box, + pub pretty_path: String, + pub version: FirmwareVersion, + pub mfp: Option<[u8; 4]>, +} + +impl fmt::Debug for KeystoneWallet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "KeystoneWallet") + } +} +#[derive(Debug, Clone)] +pub struct EAPDUFrame { + cla: u8, + ins: CommandType, + p1: u16, + p2: u16, + lc: u16, + data: Vec, + data_len: u32, +} + +#[cfg(feature = "hidapi")] +impl KeystoneWallet { + pub fn new(transport: Box) -> Self { + Self { + transport, + pretty_path: String::default(), + version: FirmwareVersion::new(0, 0, 0), + mfp: None, + } + } + + // Transport Protocol: + // * header (2 bytes) + // * command (2 bytes) + // * total_packets (2 bytes) + // * sequence_number (2 bytes) + // * request_id (2 bytes) + // * tag (1 byte) + // * size (1 byte) + // * data (Variable) + // * Payload (Optional) + // + // Payload + // * APDU Total Length (2 bytes) + // * APDU_CLA (1 byte) + // * APDU_INS (1 byte) + // * APDU_P1 (1 byte) + // * APDU_P2 (1 byte) + // * APDU_LENGTH (1 byte (2 bytes DEPRECATED)) + // * APDU_Payload (Variable) + // + fn write(&self, command: CommandType, 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]; + let total_packets = if data_len > 12 { + if data_len % (64 - 12) == 0 { + data_len / (64 - 12) + } else { + data_len / (64 - 12) + 1 + } + } else { + 1 + }; + let request_id = REQUEST_ID; + + while sequence_number == 0 || offset < data_len { + hid_chunk.fill(0); + let header = 12; + let size = min(64 - header, data_len - offset); + let tag = ((HID_TAG as u16 + request_id as u16 + size as u16) & 0xff) as u8; + { + let chunk = &mut hid_chunk[HID_PREFIX_ZERO..]; + chunk[0..2].copy_from_slice(&[0x00, 0x00]); + chunk[2..12].copy_from_slice(&[ + (command as u16 >> 8) as u8, + (command as u16 & 0xff) as u8, + (total_packets >> 8) as u8, + (total_packets & 0xff) as u8, + (sequence_number >> 8) as u8, + (sequence_number & 0xff) as u8, + (request_id >> 8) as u8, + (request_id & 0xff) as u8, + tag, + size as u8, + ]); + + chunk[header..header + size].copy_from_slice(&data[offset..offset + size]); + } + trace!("Keystone write {:?}", &hid_chunk[..]); + let n = self.transport.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) + // * Command Tag (1 byte) + // * Packet Sequence ID (2 bytes) + // * Payload (Optional) + // + // Payload + // * APDU_LENGTH (1 byte) + // * APDU_Payload (Variable) + // + fn read(&self) -> Result, RemoteWalletError> { + let mut result_data = Vec::new(); + let mut sequence_number = 0u16; + let mut total_length = 0usize; + + loop { + // Read HID packet + let chunk = self.transport.read()?; + if chunk.len() < KEYSTONE_TRANSPORT_HEADER_LEN { + return Err(RemoteWalletError::Protocol("Invalid HID packet size")); + } + + let packet = &chunk[HID_PREFIX_ZERO..chunk.len()]; + let packet = { + let mut end = packet.len(); + while end > 0 && packet[end - 1] == 0x00 { + end -= 1; + } + &packet[..end] + }; + // Parse transport header + let _cla = packet[0]; + let command = u16::from_be_bytes([packet[1], packet[2]]); + let total_packets = u16::from_be_bytes([packet[3], packet[4]]); + let packet_seq = u16::from_be_bytes([packet[5], packet[6]]); + let _request_id = u16::from_be_bytes([packet[7], packet[8]]); + let packet_data = &packet[9..]; + // Check if command is valid + if !CommandType::is_valid_command(command) { + return Err(RemoteWalletError::Protocol("Invalid command")); + } + + // Optionally, convert to CommandType enum for type safety + let _command_type = CommandType::from_u16(command) + .ok_or(RemoteWalletError::Protocol("Invalid command type"))?; + + if packet_seq != sequence_number { + return Err(RemoteWalletError::Protocol("Invalid packet sequence")); + } + + sequence_number += 1; + total_length += packet_data.len(); + result_data.extend_from_slice(packet_data); + + // Check if we have received all data + if sequence_number == total_packets { + break; + } + + if sequence_number >= 0xffff { + return Err(RemoteWalletError::Protocol( + "Maximum sequence number reached", + )); + } + } + + // Truncate to exact length + result_data.truncate(total_length); + + // Parse status code from last 2 bytes + if result_data.len() < 2 { + return Err(RemoteWalletError::Protocol("Response too short")); + } + + // Remove status code from result (status code validation is handled elsewhere) + result_data.truncate(result_data.len() - 2); + + Ok(result_data) + } + + fn _send_apdu(&self, command: CommandType, data: &[u8]) -> Result { + self.write(command, data)?; + let message = self.read()?; + let message_str = String::from_utf8_lossy(&message); + if let (Some(start), Some(end)) = (message_str.find('{'), message_str.rfind('}')) { + if start < end { + let json_str = &message_str[start..=end]; + return Ok(json_str.to_string()); + } + } + Ok(message_str.to_string()) + } + + fn send_apdu(&self, command: CommandType, data: &[u8]) -> Result { + self._send_apdu(command, data) + } + + fn get_firmware_version( + &self, + ) -> Result<(FirmwareVersion, Option<[u8; 4]>), RemoteWalletError> { + self.get_device_info() + } + + /// Generate a hardware call request for key derivation + fn generate_hardware_call( + &self, + derivation_path: &DerivationPath, + ) -> Result { + let key_path = parse_crypto_key_path(derivation_path, self.mfp); + let schema = KeyDerivationSchema::new(key_path, Some(Curve::Ed25519), None, None); + let schemas = vec![schema]; + let call = QRHardwareCall::new( + CallType::KeyDerivation, + CallParams::KeyDerivation(KeyDerivationCall::new(schemas)), + None, + HardWareCallVersion::V1, + ); + let bytes: Vec = call.try_into().unwrap(); + let res = + probe_encode(&bytes, 400, QRHardwareCall::get_registry_type().get_type()).unwrap(); + Ok(res.data) + } + + /// Generate a Solana sign request for transaction signing + fn generate_sol_sign_request( + &self, + derivation_path: &DerivationPath, + sign_data: &[u8], + ) -> Result { + let crypto_key_path = parse_crypto_key_path(derivation_path, self.mfp); + let request_id = [0u8; 16].to_vec(); + let sol_sign_request = SolSignRequest::new( + Some(request_id), + sign_data.to_vec(), + crypto_key_path, + None, + Some("solana cli".to_string()), + SignType::Transaction, + ); + let bytes: Vec = sol_sign_request.try_into().unwrap(); + Ok(probe_encode( + &bytes, + 0xFFFFFFF, + SolSignRequest::get_registry_type().get_type(), + ) + .unwrap() + .data) + } + + /// Parse a public key from UR (Uniform Resource) format + fn parse_ur_pubkey(&self, ur: &str) -> Result, RemoteWalletError> { + let result: URParseResult = + probe_decode(ur.to_string().to_lowercase()).unwrap(); + + Ok(result.data.unwrap().get_keys().get(0).unwrap().get_key()) + } + + /// Parse a signature from UR (Uniform Resource) format + fn parse_ur_signature(&self, ur: &str) -> Result, RemoteWalletError> { + let result: URParseResult = + probe_decode(ur.to_string().to_lowercase()).unwrap(); + Ok(result.data.unwrap().get_signature().to_vec()) + } + + fn parse_json_field( + &self, + json_str: &str, + field_name: &str, + ) -> Result { + let json = serde_json::from_str::(json_str) + .map_err(|_| RemoteWalletError::Protocol(ERROR_INVALID_JSON))?; + + json.get(field_name) + .and_then(|v| v.as_str()) + .ok_or(RemoteWalletError::Protocol(ERROR_MISSING_FIELD)) + .map(String::from) + } + + fn get_device_info(&self) -> Result<(FirmwareVersion, Option<[u8; 4]>), RemoteWalletError> { + let json_str = self._send_apdu(CommandType::CMD_GET_DEVICE_INFO, &[])?; + let mut version = FirmwareVersion::new(0, 0, 0); + let mut mfp = None; + match serde_json::from_str::(&json_str) { + Ok(json) => { + if let Some(firmware_version) = json + .get(JSON_FIELD_FIRMWARE_VERSION) + .and_then(|v| v.as_str()) + { + // Parse version string like "12.1.2" + let parts: Vec<&str> = firmware_version.split('.').collect(); + if parts.len() >= 3 { + if let (Ok(major), Ok(minor), Ok(patch)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + version = FirmwareVersion::new(major, minor, patch); + } + } + } + + if let Some(mfp_str) = json.get(JSON_FIELD_WALLET_MFP).and_then(|v| v.as_str()) { + if let Ok(mfp_bytes) = hex::decode(mfp_str) { + if mfp_bytes.len() == 4 { + mfp = Some([mfp_bytes[0], mfp_bytes[1], mfp_bytes[2], mfp_bytes[3]]); + } + } + } + } + Err(_) => { + return Err(RemoteWalletError::Protocol(ERROR_JSON_PARSE)); + } + } + Ok((version, mfp)) + } + + /// Internal method to handle the actual signing request + fn sign_with_request( + &self, + derivation_path: &DerivationPath, + data: &[u8], + ) -> Result { + if self.mfp.is_none() { + return Err(RemoteWalletError::Protocol(ERROR_MFP_NOT_SET)); + } + + let result = self.generate_sol_sign_request(derivation_path, data)?; + let key = self.send_apdu(CommandType::CMD_RESOLVE_UR, result.as_bytes())?; + let payload = self.parse_json_field(&key, JSON_FIELD_PAYLOAD)?; + + println!( + "Waiting for your approval on {} {}", + self.name(), + self.pretty_path + ); + let keystone_error = KeystoneError::from_error_message(&payload); + if !matches!(keystone_error, KeystoneError::CommunicationError { .. }) { + return Err(keystone_error.into()); + } + println!("{CHECK_MARK}Approved"); + let signature = self.parse_ur_signature(&payload)?; + + Signature::try_from(signature) + .map_err(|_| RemoteWalletError::Protocol(ERROR_SIGNATURE_SIZE)) + } +} + +use crate::wallet::types::RemoteWalletType; +use hidapi::{DeviceInfo, HidApi}; + +pub struct KeystoneProbe; +#[cfg(not(feature = "hidapi"))] +impl WalletProbe for KeystoneProbe {} +#[cfg(feature = "hidapi")] +impl WalletProbe for KeystoneProbe { + fn is_supported_device(&self, device_info: &hidapi::DeviceInfo) -> bool { + device_info.product_id() == KEYSTONE_PID && device_info.vendor_id() == KEYSTONE_VID + } + + fn open(&self, usb: &mut HidApi, devinfo: DeviceInfo) -> Result { + let handle = usb + .open_path(devinfo.path()) + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; + let mut wallet = KeystoneWallet::new(Box::new(HidTransport::new(handle))); + let info = wallet + .read_device(&devinfo) + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; + wallet.pretty_path = info.get_pretty_path(); + Ok(Device { + path: devinfo.path().to_string_lossy().into_owned(), + info, + wallet_type: RemoteWalletType::Keystone(Rc::new(wallet)), + }) + } +} + +#[cfg(not(feature = "hidapi"))] +impl RemoteWallet for KeystoneWallet {} +#[cfg(feature = "hidapi")] +impl RemoteWallet for KeystoneWallet { + fn name(&self) -> &str { + "Keystone hardware wallet" + } + + fn read_device( + &mut self, + dev_info: &hidapi::DeviceInfo, + ) -> Result { + let manufacturer = dev_info + .manufacturer_string() + .and_then(|s| Manufacturer::try_from(s).ok()) + .unwrap_or_default(); + let model = dev_info + .product_string() + .unwrap_or("Unknown") + .to_lowercase() + .replace(' ', "-"); + let serial = dev_info.serial_number().unwrap_or("Unknown").to_string(); + let host_device_path = dev_info.path().to_string_lossy().to_string(); + let (version, mfp) = self.get_device_info()?; + self.version = version; + self.mfp = mfp; + let pubkey_result = self.get_pubkey(&DerivationPath::default(), false); + let (pubkey, error) = match pubkey_result { + Ok(pubkey) => (pubkey, None), + Err(err) => (Pubkey::default(), Some(err)), + }; + Ok(RemoteWalletInfo { + model, + manufacturer, + serial, + host_device_path, + pubkey, + error, + }) + } + + fn get_pubkey( + &self, + derivation_path: &DerivationPath, + _confirm_key: bool, + ) -> Result { + let pubkey = if is_path_in_cached_range(derivation_path) { + let data = extend_and_serialize(derivation_path); + let key = self.send_apdu(CommandType::CMD_GET_DEVICE_USB_PUBKEY, data.as_slice())?; + let payload = self.parse_json_field(&key, JSON_FIELD_PUBKEY)?; + + let keystone_error = KeystoneError::from_error_message(&payload); + if !matches!(keystone_error, KeystoneError::CommunicationError { .. }) { + return Err(keystone_error.into()); + } + + hex::decode(payload).map_err(|_| RemoteWalletError::Protocol(ERROR_INVALID_HEX))? + } else { + let key = self.send_apdu( + CommandType::CMD_RESOLVE_UR, + self.generate_hardware_call(derivation_path)?.as_bytes(), + )?; + let payload = self.parse_json_field(&key, JSON_FIELD_PAYLOAD)?; + + let keystone_error = KeystoneError::from_error_message(&payload); + // Only allow CommunicationError to proceed, all other errors should be returned + match keystone_error { + KeystoneError::CommunicationError { .. } => { + // This is expected for successful operations + } + _ => { + return Err(keystone_error.into()); + } + } + + self.parse_ur_pubkey(&payload)? + }; + + Pubkey::try_from(pubkey).map_err(|_| RemoteWalletError::Protocol(ERROR_KEY_SIZE)) + } + + fn sign_message( + &self, + derivation_path: &DerivationPath, + data: &[u8], + ) -> Result { + // If the first byte of the data is 0xff then it is an off-chain message + // because it starts with the Domain Specifier b"\xffsolana offchain". + // On-chain messages, in contrast, start with either 0x80 (MESSAGE_VERSION_PREFIX) + // or the number of signatures (0x00 - 0x13). + if !data.is_empty() && data[0] == 0xff { + return self.sign_offchain_message(derivation_path, data); + } + + self.sign_with_request(derivation_path, data) + } + + fn sign_offchain_message( + &self, + derivation_path: &DerivationPath, + data: &[u8], + ) -> Result { + self.sign_with_request(derivation_path, data) + } +} + +/// Convert a Solana DerivationPath to a CryptoKeyPath for Keystone hardware wallet +fn parse_crypto_key_path(derivation_path: &DerivationPath, mfp: Option<[u8; 4]>) -> CryptoKeyPath { + let mut path_components = vec![ + PathComponent::new(Some(44), true).unwrap(), // BIP44 purpose + PathComponent::new(Some(501), true).unwrap(), // Solana coin type + ]; + + if let Some(account) = derivation_path.account() { + let account_index = account.to_u32(); + path_components.push(PathComponent::new(Some(account_index), true).unwrap()); + } + + if let Some(change) = derivation_path.change() { + let change_index = change.to_u32(); + path_components.push(PathComponent::new(Some(change_index), true).unwrap()); + } + + CryptoKeyPath::new(path_components, mfp, None) +} + +/// Build the derivation path byte array from a DerivationPath selection +/// +/// Format: [depth_byte, 4-byte indices...] +/// - depth_byte: 2 for m/44'/501', 3 for m/44'/501'/account', 4 for m/44'/501'/account'/change' +/// - Each index is serialized as 4 bytes in big-endian format with hardened bit set +fn extend_and_serialize(derivation_path: &DerivationPath) -> Vec { + let depth_byte: u8 = if derivation_path.change().is_some() { + 4 // m/44'/501'/account'/change' + } else if derivation_path.account().is_some() { + 3 // m/44'/501'/account' + } else { + 2 // m/44'/501' + }; + + let mut concat_derivation = vec![depth_byte]; + for index in derivation_path.path() { + concat_derivation.extend_from_slice(&index.to_bits().to_be_bytes()); + } + concat_derivation +} + +/// Build the derivation path byte array for multiple paths +/// +/// Format: [count, path1_serialized, path2_serialized, ...] +/// where each path is serialized using extend_and_serialize +fn extend_and_serialize_multiple(derivation_paths: &[&DerivationPath]) -> Vec { + let mut concat_derivation = vec![derivation_paths.len() as u8]; + for derivation_path in derivation_paths { + concat_derivation.append(&mut extend_and_serialize(derivation_path)); + } + concat_derivation +} + +/// Choose a Keystone wallet based on matching info fields +pub fn get_keystone_from_info( + info: RemoteWalletInfo, + keypair_name: &str, + wallet_manager: &RemoteWalletManager, +) -> Result, RemoteWalletError> { + let devices = wallet_manager.list_devices(); + let mut matches = devices + .iter() + .filter(|&device_info| device_info.matches(&info)); + if matches + .clone() + .all(|device_info| device_info.error.is_some()) + { + let first_device = matches.next(); + if let Some(device) = first_device { + return Err(device.error.clone().unwrap()); + } + } + let mut matches: Vec<(String, String)> = matches + .filter(|&device_info| device_info.error.is_none()) + .map(|device_info| { + let query_item = format!("{} ({})", device_info.get_pretty_path(), device_info.model,); + (device_info.host_device_path.clone(), query_item) + }) + .collect(); + if matches.is_empty() { + return Err(RemoteWalletError::NoDeviceFound); + } + matches.sort_by(|a, b| a.1.cmp(&b.1)); + let (host_device_paths, items): (Vec, Vec) = matches.into_iter().unzip(); + + let wallet_host_device_path = if host_device_paths.len() > 1 { + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(format!( + "Multiple hardware wallets found. Please select a device for {keypair_name:?}" + )) + .default(0) + .items(&items[..]) + .interact() + .unwrap(); + &host_device_paths[selection] + } else { + &host_device_paths[0] + }; + wallet_manager.get_keystone(wallet_host_device_path) +} + +/// Check if a derivation path is within the cached range supported by the hardware wallet +/// +/// Keystone hardware wallet pre-caches a limited number of derivation paths to avoid +/// requiring user password input. The cached ranges are: +/// - m/44'/501' (base path) +/// - m/44'/501'/0' to m/44'/501'/49' (50 account paths) +/// - m/44'/501'/0'/0' to m/44'/501'/0'/49' (50 change paths for account 0) +/// +/// Paths outside these ranges require user password confirmation. +fn is_path_in_cached_range(derivation_path: &DerivationPath) -> bool { + let path = derivation_path.path(); + + // Must have at least m/44'/501' + if path.len() < 2 { + return false; + } + + // Must be BIP44 Solana path + if path[0].to_u32() != 44 || path[1].to_u32() != 501 { + return false; + } + + match path.len() { + 2 => true, // m/44'/501' + 3 => { + // m/44'/501'/account' where account <= 49 + let account = path[2].to_u32(); + account <= CACHED_ACCOUNT_RANGE + } + 4 => { + // m/44'/501'/account'/change' where account == 0 and change <= 49 + let account = path[2].to_u32(); + let change = path[3].to_u32(); + account == CACHED_FIXED_ACCOUNT && change <= CACHED_CHANGE_RANGE + } + + _ => false, + } +} diff --git a/remote-wallet/src/wallet/keystone/mod.rs b/remote-wallet/src/wallet/keystone/mod.rs new file mode 100644 index 00000000000000..00c550d6a8536b --- /dev/null +++ b/remote-wallet/src/wallet/keystone/mod.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod keystone; diff --git a/remote-wallet/src/ledger_error.rs b/remote-wallet/src/wallet/ledger/error.rs similarity index 57% rename from remote-wallet/src/ledger_error.rs rename to remote-wallet/src/wallet/ledger/error.rs index 8075a1fac16ba9..6feafd3f352d4b 100644 --- a/remote-wallet/src/ledger_error.rs +++ b/remote-wallet/src/wallet/ledger/error.rs @@ -92,3 +92,45 @@ pub enum LedgerError { #[error("Ledger received invalid CLA")] InvalidCla = 0x6e00, } + +use crate::wallet::errors::HardwareWalletError; + +impl HardwareWalletError for LedgerError { + fn code(&self) -> u16 { + match self { + LedgerError::NoAppResponse => 0x6700, + LedgerError::SdkException => 0x6801, + LedgerError::SdkInvalidParameter => 0x6802, + LedgerError::SdkExceptionOverflow => 0x6803, + LedgerError::SdkExceptionSecurity => 0x6804, + LedgerError::SdkInvalidCrc => 0x6805, + LedgerError::SdkInvalidChecksum => 0x6806, + LedgerError::SdkInvalidCounter => 0x6807, + LedgerError::SdkNotSupported => 0x6808, + LedgerError::SdkInvalidState => 0x6809, + LedgerError::SdkTimeout => 0x6810, + LedgerError::SdkExceptionPic => 0x6811, + LedgerError::SdkExceptionAppExit => 0x6812, + LedgerError::SdkExceptionIoOverflow => 0x6813, + LedgerError::SdkExceptionIoHeader => 0x6814, + LedgerError::SdkExceptionIoState => 0x6815, + LedgerError::SdkExceptionIoReset => 0x6816, + LedgerError::SdkExceptionCxPort => 0x6817, + LedgerError::SdkExceptionSystem => 0x6818, + LedgerError::SdkNotEnoughSpace => 0x6819, + LedgerError::NoApduReceived => 0x6982, + LedgerError::UserCancel => 0x6985, + LedgerError::SolanaInvalidMessage => 0x6a80, + LedgerError::SolanaInvalidMessageHeader => 0x6a81, + LedgerError::SolanaInvalidMessageFormat => 0x6a82, + LedgerError::SolanaInvalidMessageSize => 0x6a83, + LedgerError::SolanaSummaryFinalizeFailed => 0x6f00, + LedgerError::SolanaSummaryUpdateFailed => 0x6f01, + LedgerError::UnimplementedInstruction => 0x6d00, + LedgerError::InvalidCla => 0x6e00, + } + } + fn description(&self) -> String { + self.to_string() + } +} diff --git a/remote-wallet/src/ledger.rs b/remote-wallet/src/wallet/ledger/ledger.rs similarity index 90% rename from remote-wallet/src/ledger.rs rename to remote-wallet/src/wallet/ledger/ledger.rs index e6cf3b1bc0e912..c989205daae958 100644 --- a/remote-wallet/src/ledger.rs +++ b/remote-wallet/src/wallet/ledger/ledger.rs @@ -1,6 +1,14 @@ +use crate::transport::hid_transport::HidTransport; use { - crate::remote_wallet::{ - RemoteWallet, RemoteWalletError, RemoteWalletInfo, RemoteWalletManager, + super::error::LedgerError, + crate::{ + errors::RemoteWalletError, + remote_wallet::{RemoteWallet, RemoteWalletInfo, RemoteWalletManager}, + transport::transport_trait::Transport, + wallet::{ + types::{Device, RemoteWalletType}, + WalletProbe, + }, }, console::Emoji, dialoguer::{theme::ColorfulTheme, Select}, @@ -10,7 +18,7 @@ use { }; #[cfg(feature = "hidapi")] use { - crate::{ledger_error::LedgerError, locator::Manufacturer}, + crate::locator::Manufacturer, log::*, num_traits::FromPrimitive, solana_pubkey::Pubkey, @@ -100,23 +108,22 @@ pub struct LedgerSettings { /// Ledger Wallet device pub struct LedgerWallet { - #[cfg(feature = "hidapi")] - pub device: hidapi::HidDevice, + pub transport: Box, pub pretty_path: String, pub version: FirmwareVersion, } impl fmt::Debug for LedgerWallet { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "HidDevice") + write!(f, "LedgerWallet") } } #[cfg(feature = "hidapi")] impl LedgerWallet { - pub fn new(device: hidapi::HidDevice) -> Self { + pub fn new(transport: Box) -> Self { Self { - device, + transport, pretty_path: String::default(), version: FirmwareVersion::new(0, 0, 0), } @@ -201,9 +208,12 @@ impl LedgerWallet { chunk[header..header + size].copy_from_slice(&data[offset..offset + size]); } trace!("Ledger write {:?}", &hid_chunk[..]); - let n = self.device.write(&hid_chunk[..])?; + let n = self + .transport + .write(&hid_chunk[..]) + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; if n < size + header { - return Err(RemoteWalletError::Protocol("Write data size mismatch")); + return Err(RemoteWalletError::Protocol("Incomplete write")); } offset += size; sequence_number += 1; @@ -232,17 +242,19 @@ impl LedgerWallet { // 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)?; + let chunk = self + .transport + .read() + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; trace!("Ledger read {:?}", &chunk[..]); - if chunk_size < LEDGER_TRANSPORT_HEADER_LEN + if chunk.len() < 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); + let seq = (chunk[3] as usize) << 8 | (chunk[4] as usize); if seq != chunk_index { return Err(RemoteWalletError::Protocol("Unexpected chunk header")); } @@ -250,13 +262,13 @@ impl LedgerWallet { let mut offset = 5; if seq == 0 { // Read message size and status word. - if chunk_size < 7 { + if chunk.len() < 7 { return Err(RemoteWalletError::Protocol("Unexpected chunk header")); } - message_size = ((chunk[5] as usize) << 8) | (chunk[6] as usize); + message_size = (chunk[5] as usize) << 8 | (chunk[6] as usize); offset += 2; } - message.extend_from_slice(&chunk[offset..chunk_size]); + message.extend_from_slice(&chunk[offset..chunk.len()]); message.truncate(message_size); if message.len() == message_size { break; @@ -266,7 +278,7 @@ impl LedgerWallet { return Err(RemoteWalletError::Protocol("No status word")); } let status = - ((message[message.len() - 2] as usize) << 8) | (message[message.len() - 1] as usize); + (message[message.len() - 2] as usize) << 8 | (message[message.len() - 1] as usize); trace!("Read status {status:x}"); Self::parse_status(status)?; let new_len = message.len() - 2; @@ -370,6 +382,35 @@ impl LedgerWallet { } } +use hidapi::{DeviceInfo, HidApi}; + +pub struct LedgerProbe; + +#[cfg(not(feature = "hidapi"))] +impl WalletProbe for LedgerProbe {} +#[cfg(feature = "hidapi")] +impl WalletProbe for LedgerProbe { + fn is_supported_device(&self, device_info: &hidapi::DeviceInfo) -> bool { + is_valid_ledger(device_info.vendor_id(), device_info.product_id()) + } + + fn open(&self, usb: &mut HidApi, devinfo: DeviceInfo) -> Result { + let handle = usb + .open_path(devinfo.path()) + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; + let mut wallet = LedgerWallet::new(Box::new(HidTransport::new(handle))); + let info = wallet + .read_device(&devinfo) + .map_err(|e| RemoteWalletError::Hid(e.to_string()))?; + wallet.pretty_path = info.get_pretty_path(); + Ok(Device { + path: devinfo.path().to_string_lossy().into_owned(), + info, + wallet_type: RemoteWalletType::Ledger(Rc::new(wallet)), + }) + } +} + #[cfg(not(feature = "hidapi"))] impl RemoteWallet for LedgerWallet {} #[cfg(feature = "hidapi")] @@ -451,7 +492,7 @@ impl RemoteWallet for LedgerWallet { } else { extend_and_serialize_multiple(&[derivation_path]) }; - if data.len() > u16::MAX as usize { + if data.len() > u16::max_value() as usize { return Err(RemoteWalletError::InvalidInput( "Message to sign is too long".to_string(), )); diff --git a/remote-wallet/src/wallet/ledger/mod.rs b/remote-wallet/src/wallet/ledger/mod.rs new file mode 100644 index 00000000000000..2e5b0ef44d9fc4 --- /dev/null +++ b/remote-wallet/src/wallet/ledger/mod.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod ledger; +pub use ledger::LedgerWallet; diff --git a/remote-wallet/src/wallet/mod.rs b/remote-wallet/src/wallet/mod.rs new file mode 100644 index 00000000000000..1752ab5c2417d1 --- /dev/null +++ b/remote-wallet/src/wallet/mod.rs @@ -0,0 +1,15 @@ +pub mod errors; +pub mod keystone; +pub mod ledger; +pub mod types; + +use crate::errors::RemoteWalletError; +use hidapi::{DeviceInfo, HidApi}; + +use types::Device; + +pub trait WalletProbe { + fn is_supported_device(&self, device_info: &hidapi::DeviceInfo) -> bool; + + fn open(&self, usb: &mut HidApi, devinfo: DeviceInfo) -> Result; +} diff --git a/remote-wallet/src/wallet/types.rs b/remote-wallet/src/wallet/types.rs new file mode 100644 index 00000000000000..0237ba3cbd9c43 --- /dev/null +++ b/remote-wallet/src/wallet/types.rs @@ -0,0 +1,17 @@ +use crate::remote_wallet::RemoteWalletInfo; +use crate::wallet::keystone::keystone::KeystoneWallet; +use crate::wallet::ledger::ledger::LedgerWallet; +use std::rc::Rc; + +#[derive(Debug)] +pub struct Device { + pub(crate) path: String, + pub(crate) info: RemoteWalletInfo, + pub wallet_type: RemoteWalletType, +} + +#[derive(Debug)] +pub enum RemoteWalletType { + Ledger(Rc), + Keystone(Rc), +}