diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c9831132e517..406828b91fb57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ Release channels have their own copy of this changelog: * `--disable-accounts-disk-index` #### Deprecations * Using `mmap` for `--accounts-db-access-storages-method` is now deprecated. +### CLI +#### Changes +* Support Trezor hardware wallets using `usb://trezor` ## 3.1.0 ### RPC diff --git a/Cargo.lock b/Cargo.lock index d2525464b9c3ab..36d482ee0ea216 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4535,6 +4535,18 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.3" @@ -5684,6 +5696,17 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + [[package]] name = "protobuf-src" version = "1.1.0+21.5" @@ -5693,6 +5716,15 @@ dependencies = [ "autotools", ] +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "protosol" version = "2.0.0" @@ -6258,6 +6290,16 @@ dependencies = [ "libc", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -9996,12 +10038,14 @@ dependencies = [ "parking_lot 0.12.3", "qstring", "semver 1.0.27", + "serial_test", "solana-derivation-path", "solana-offchain-message", "solana-pubkey 3.0.0", "solana-signature", "solana-signer", "thiserror 2.0.17", + "trezor-client", "uriparse", ] @@ -13197,6 +13241,20 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de5f738ceab88e2491a94ddc33c3feeadfa95fedc60363ef110845df12f3878" +[[package]] +name = "trezor-client" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87873db279766278a7e56b01139943e00a45afc079fc8fa6651e949f2234c3f6" +dependencies = [ + "byteorder", + "hex", + "protobuf", + "rusb", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "try-lock" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index d1ca470a12a875..884fe7c4db26db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -627,6 +627,7 @@ tower = "0.5.2" tracing = "0.1" trait-set = "0.3.0" trees = "0.4.2" +trezor-client = { version = "0.1.5", default-features = false, features = ["solana"] } tungstenite = "0.28.0" unwrap_none = "0.1.2" uriparse = "0.6.4" diff --git a/dev-bins/Cargo.lock b/dev-bins/Cargo.lock index 3a2dd4881bb086..d8f2f88b0e2b69 100644 --- a/dev-bins/Cargo.lock +++ b/dev-bins/Cargo.lock @@ -2962,6 +2962,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "histogram" version = "0.6.9" @@ -3856,6 +3862,18 @@ dependencies = [ "libsecp256k1-core", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.22" @@ -4871,6 +4889,17 @@ dependencies = [ "prost", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + [[package]] name = "protobuf-src" version = "1.1.0+21.5" @@ -4880,6 +4909,15 @@ dependencies = [ "autotools", ] +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "qstring" version = "0.7.2" @@ -5365,6 +5403,16 @@ dependencies = [ "libc", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -8357,6 +8405,7 @@ dependencies = [ "solana-signature", "solana-signer", "thiserror 2.0.17", + "trezor-client", "uriparse", ] @@ -10965,6 +11014,20 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de5f738ceab88e2491a94ddc33c3feeadfa95fedc60363ef110845df12f3878" +[[package]] +name = "trezor-client" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87873db279766278a7e56b01139943e00a45afc079fc8fa6651e949f2234c3f6" +dependencies = [ + "byteorder", + "hex", + "protobuf", + "rusb", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/docs/src/cli/wallets/hardware/index.md b/docs/src/cli/wallets/hardware/index.md index 30f53f86d3a3d8..c2491e4b3de457 100644 --- a/docs/src/cli/wallets/hardware/index.md +++ b/docs/src/cli/wallets/hardware/index.md @@ -22,7 +22,8 @@ hardware wallet. The Solana CLI supports the following hardware wallets: -- [Ledger Nano S and Ledger Nano X](./ledger.md) +- [Ledger Nano S, Nano S Plus, and Nano X](./ledger.md) +- [Trezor Model T, Safe 3, and Safe 5](./trezor.md) ## Specify a Keypair URL diff --git a/docs/src/cli/wallets/hardware/trezor.md b/docs/src/cli/wallets/hardware/trezor.md new file mode 100644 index 00000000000000..4f372ca93e0ae8 --- /dev/null +++ b/docs/src/cli/wallets/hardware/trezor.md @@ -0,0 +1,118 @@ +--- +title: Using Trezor Hardware Wallets in the Solana CLI +pagination_label: "Hardware Wallets in the Solana CLI: Trezor" +sidebar_label: Trezor +--- + +This page describes how to use a Trezor Model T, Safe 3, or Safe 5 device to +interact with Solana using the command line tools. + +## Before You Begin + +- [Install the Solana command-line tools](../../install.md) +- [Review Trezor and BIP-32](https://trezor.io/learn/a/what-is-bip32) +- [Review Trezor and BIP-44](https://trezor.io/learn/a/what-is-bip44) + +## Use Trezor Model T, Safe 3, or Safe 5 with Solana CLI + +1. Plug your Trezor device into your computer's USB port +2. Tap to connect the device +3. Enter your pin +3. Ensure the screen reads the name of your device + +### View your Wallet Addresses + +On your computer, run: + +```bash +solana-keygen pubkey usb://trezor?key=0/0 +``` + +This confirms your Trezor device is connected properly and in the correct state +to interact with the Solana CLI. The command returns your Trezor device's first +Solana account's external (receiving) wallet address using the +[BIP-32](https://trezor.io/learn/a/what-is-bip32) derivation path +`m/44'/501'/0'/0'`. + +Your Trezor device supports an arbitrary number of valid wallet addresses and signers. To +view any address, use the `solana-keygen pubkey` command, as shown below, +followed by a valid [keypair URL](./index.md#specify-a-keypair-url). + +Multiple wallet addresses can be useful if you want to transfer tokens between +your own accounts for different purposes, or use different keypairs on the +device as signing authorities for a stake account, for example. + +All of the following commands will display different addresses, associated with +the keypair path given. Try them out! + +```bash +solana-keygen pubkey usb://trezor?key=0/0 +solana-keygen pubkey usb://trezor?key=0/1 +solana-keygen pubkey usb://trezor?key=1/0 +solana-keygen pubkey usb://trezor?key=1/1 +``` + +- NOTE: keypair url parameters are ignored in **zsh** +  [see troubleshooting for more info](#troubleshooting) + +You can use other values for the number after `key=` as well. Any of the +addresses displayed by these commands are valid Solana wallet addresses. The +private portion associated with each address is stored securely on the Trezor device, and +is used to sign transactions from this address. Just make a note of which +keypair URL you used to derive any address you will be using to receive tokens. + +If you are only planning to use a single address/keypair on your device, a good +easy-to-remember path might be to use the address at `key=0/`. View this address +with: + +```bash +solana-keygen pubkey usb://trezor?key=0/0 +solana-keygen pubkey usb://trezor?key=0/1 +``` + +Now you have a wallet address (or multiple addresses), you can share any of +these addresses publicly to act as a receiving address, and you can use the +associated keypair URL as the signer for transactions from that address. + +### Wallet Operations + +To use the device for wallet operations, such as balance fetching or +transferring SOL, follow the guides for +[viewing balance](./ledger.md#view-your-balance) or +[sending SOL](./ledger.md#send-sol-from-a-nano), substituting `ledger` with +`trezor` and your key path. + +## Troubleshooting + +### Keypair URL parameters are ignored in zsh + +The question mark character is a special character in zsh. If that's not a +feature you use, add the following line to your `~/.zshrc` to treat it as a +normal character: + +```bash +unsetopt nomatch +``` + +Then either restart your shell window or run `~/.zshrc`: + +```bash +source ~/.zshrc +``` + +If you would prefer not to disable zsh's special handling of the question mark +character, you can disable it explicitly with a backslash in your keypair URLs. +For example: + +```bash +solana-keygen pubkey usb://trezor\?key=0/0 +``` + +## Support + +You can find additional support and get help on the +[Solana StackExchange](https://solana.stackexchange.com). + +Read more about [sending and receiving tokens](../../examples/transfer-tokens.md) and +[delegating stake](../../examples/delegate-stake.md). You can use your Ledger keypair +URL anywhere you see an option or argument that accepts a ``. diff --git a/docs/src/cli/wallets/index.md b/docs/src/cli/wallets/index.md index 9643ef61ec13eb..33fc0f28488d80 100644 --- a/docs/src/cli/wallets/index.md +++ b/docs/src/cli/wallets/index.md @@ -54,7 +54,8 @@ some interface for signing transactions. ### Hardware Wallet Security A hardware wallet, such as the -[Ledger hardware wallet](https://www.ledger.com/), offers a great blend of +[Ledger hardware wallet](https://www.ledger.com/) or +[Trezor hardware wallet](https://trezor.io/), offers a great blend of security and convenience for cryptocurrencies. It effectively automates the process of offline signing while retaining nearly all the convenience of a file system wallet. diff --git a/programs/sbf/Cargo.lock b/programs/sbf/Cargo.lock index 692f59aa03ebc4..0a8a9579517364 100644 --- a/programs/sbf/Cargo.lock +++ b/programs/sbf/Cargo.lock @@ -2866,6 +2866,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f154ce46856750ed433c8649605bf7ed2de3bc35fd9d2a9f30cddd873c80cb08" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "histogram" version = "0.6.9" @@ -3822,6 +3828,18 @@ dependencies = [ "libsecp256k1-core 0.3.0", ] +[[package]] +name = "libusb1-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-sys" version = "1.1.16" @@ -4842,6 +4860,17 @@ dependencies = [ "prost", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + [[package]] name = "protobuf-src" version = "1.1.0+21.5" @@ -4851,6 +4880,15 @@ dependencies = [ "autotools", ] +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "qstring" version = "0.7.2" @@ -5334,6 +5372,16 @@ dependencies = [ "libc", ] +[[package]] +name = "rusb" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4" +dependencies = [ + "libc", + "libusb1-sys", +] + [[package]] name = "rustc-demangle" version = "0.1.16" @@ -8115,6 +8163,7 @@ dependencies = [ "solana-signature", "solana-signer", "thiserror 2.0.17", + "trezor-client", "uriparse", ] @@ -11476,6 +11525,20 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de5f738ceab88e2491a94ddc33c3feeadfa95fedc60363ef110845df12f3878" +[[package]] +name = "trezor-client" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87873db279766278a7e56b01139943e00a45afc079fc8fa6651e949f2234c3f6" +dependencies = [ + "byteorder 1.5.0", + "hex", + "protobuf", + "rusb", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "try-lock" version = "0.2.2" diff --git a/remote-wallet/Cargo.toml b/remote-wallet/Cargo.toml index e7ea04635242aa..c08f3ef1f71c17 100644 --- a/remote-wallet/Cargo.toml +++ b/remote-wallet/Cargo.toml @@ -36,8 +36,10 @@ solana-pubkey = { workspace = true, features = ["std"] } solana-signature = { workspace = true, features = ["std"] } solana-signer = { workspace = true } thiserror = { workspace = true } +trezor-client = { workspace = true } uriparse = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } +serial_test = { workspace = true } solana-pubkey = { workspace = true, features = ["rand"] } diff --git a/remote-wallet/src/ledger.rs b/remote-wallet/src/ledger.rs index e6cf3b1bc0e912..8935cb7078acaf 100644 --- a/remote-wallet/src/ledger.rs +++ b/remote-wallet/src/ledger.rs @@ -1,12 +1,12 @@ use { crate::remote_wallet::{ - RemoteWallet, RemoteWalletError, RemoteWalletInfo, RemoteWalletManager, + Device, RemoteWallet, RemoteWalletError, RemoteWalletInfo, RemoteWalletManager, }, console::Emoji, dialoguer::{theme::ColorfulTheme, Select}, semver::Version as FirmwareVersion, solana_derivation_path::DerivationPath, - std::{fmt, rc::Rc}, + std::fmt, }; #[cfg(feature = "hidapi")] use { @@ -601,29 +601,24 @@ fn extend_and_serialize_multiple(derivation_paths: &[&DerivationPath]) -> Vec Result, RemoteWalletError> { +) -> Result { 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 mut matches = devices.iter().filter(|&device| device.info.matches(&info)); + if matches.clone().all(|device| device.info.error.is_some()) { let first_device = matches.next(); if let Some(device) = first_device { - return Err(device.error.clone().unwrap()); + return Err(device.info.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) + .filter(|&device| device.info.error.is_none()) + .map(|device| { + 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() { @@ -645,7 +640,7 @@ pub fn get_ledger_from_info( } else { &host_device_paths[0] }; - wallet_manager.get_ledger(wallet_host_device_path) + wallet_manager.get_wallet(wallet_host_device_path) } // diff --git a/remote-wallet/src/lib.rs b/remote-wallet/src/lib.rs index 5b23fed4f114f9..c43f540a9e8e61 100644 --- a/remote-wallet/src/lib.rs +++ b/remote-wallet/src/lib.rs @@ -14,3 +14,4 @@ pub mod ledger_error; pub mod locator; pub mod remote_keypair; pub mod remote_wallet; +pub mod trezor; diff --git a/remote-wallet/src/locator.rs b/remote-wallet/src/locator.rs index e4a48872819c7e..aa299ea54acf96 100644 --- a/remote-wallet/src/locator.rs +++ b/remote-wallet/src/locator.rs @@ -13,10 +13,12 @@ pub enum Manufacturer { #[default] Unknown, Ledger, + Trezor, } const MANUFACTURER_UNKNOWN: &str = "unknown"; const MANUFACTURER_LEDGER: &str = "ledger"; +const MANUFACTURER_TREZOR: &str = "trezor"; #[derive(Clone, Debug, Error, PartialEq, Eq)] #[error("not a manufacturer")] @@ -34,6 +36,7 @@ impl FromStr for Manufacturer { let s = s.to_ascii_lowercase(); match s.as_str() { MANUFACTURER_LEDGER => Ok(Self::Ledger), + MANUFACTURER_TREZOR => Ok(Self::Trezor), _ => Err(ManufacturerError), } } @@ -51,6 +54,7 @@ impl AsRef for Manufacturer { match self { Self::Unknown => MANUFACTURER_UNKNOWN, Self::Ledger => MANUFACTURER_LEDGER, + Self::Trezor => MANUFACTURER_TREZOR, } } } @@ -168,7 +172,11 @@ mod tests { matches!(Manufacturer::from_str(MANUFACTURER_LEDGER), Ok(v) if v == Manufacturer::Ledger) ); assert_eq!(Manufacturer::Ledger.as_ref(), MANUFACTURER_LEDGER); - + assert_eq!(MANUFACTURER_TREZOR.try_into(), Ok(Manufacturer::Trezor)); + assert!( + matches!(Manufacturer::from_str(MANUFACTURER_TREZOR), Ok(v) if v == Manufacturer::Trezor) + ); + assert_eq!(Manufacturer::Trezor.as_ref(), MANUFACTURER_TREZOR); assert!( matches!(Manufacturer::from_str("bad-manufacturer"), Err(e) if e == ManufacturerError) ); @@ -207,6 +215,35 @@ mod tests { Ok(e) if e == expect ); + let manufacturer = Manufacturer::Trezor; + let manufacturer_str = "trezor"; + + let expect = Locator { + manufacturer, + pubkey: None, + }; + assert_matches!( + Locator::new_from_parts(manufacturer, None::), + Ok(e) if e == expect + ); + assert_matches!( + Locator::new_from_parts(manufacturer_str, None::), + Ok(e) if e == expect + ); + + let expect = Locator { + manufacturer, + pubkey: Some(pubkey), + }; + assert_matches!( + Locator::new_from_parts(manufacturer, Some(pubkey)), + Ok(e) if e == expect + ); + assert_matches!( + Locator::new_from_parts(manufacturer_str, Some(pubkey_str.as_str())), + Ok(e) if e == expect + ); + assert_matches!( Locator::new_from_parts("bad-manufacturer", None::), Err(LocatorError::ManufacturerError(e)) if e == ManufacturerError @@ -304,27 +341,110 @@ mod tests { Err(LocatorError::UnimplementedScheme) ); - // usb://bad-manufacturer + // usb://ledger/bad-pubkey let mut builder = URIReferenceBuilder::new(); builder .try_scheme(Some("usb")) .unwrap() - .try_authority(Some("bad-manufacturer")) + .try_authority(Some(Manufacturer::Ledger.as_ref())) + .unwrap() + .try_path("bad-pubkey") + .unwrap(); + let uri = builder.build().unwrap(); + assert_eq!( + Locator::new_from_uri(&uri), + Err(LocatorError::PubkeyError(ParsePubkeyError::Invalid)) + ); + + let manufacturer = Manufacturer::Trezor; + + // usb://trezor/{PUBKEY}?key=0/0 + let mut builder = URIReferenceBuilder::new(); + builder + .try_scheme(Some("usb")) + .unwrap() + .try_authority(Some(Manufacturer::Trezor.as_ref())) + .unwrap() + .try_path(pubkey_str.as_str()) + .unwrap() + .try_query(Some("key=0/0")) + .unwrap(); + let uri = builder.build().unwrap(); + let expect = Locator { + manufacturer, + pubkey: Some(pubkey), + }; + assert_eq!(Locator::new_from_uri(&uri), Ok(expect)); + + // usb://trezor/{PUBKEY} + let mut builder = URIReferenceBuilder::new(); + builder + .try_scheme(Some("usb")) + .unwrap() + .try_authority(Some(Manufacturer::Trezor.as_ref())) + .unwrap() + .try_path(pubkey_str.as_str()) + .unwrap(); + let uri = builder.build().unwrap(); + let expect = Locator { + manufacturer, + pubkey: Some(pubkey), + }; + assert_eq!(Locator::new_from_uri(&uri), Ok(expect)); + + // usb://trezor + let mut builder = URIReferenceBuilder::new(); + builder + .try_scheme(Some("usb")) + .unwrap() + .try_authority(Some(Manufacturer::Trezor.as_ref())) + .unwrap() + .try_path("") + .unwrap(); + let uri = builder.build().unwrap(); + let expect = Locator { + manufacturer, + pubkey: None, + }; + assert_eq!(Locator::new_from_uri(&uri), Ok(expect)); + + // usb://trezor/ + let mut builder = URIReferenceBuilder::new(); + builder + .try_scheme(Some("usb")) + .unwrap() + .try_authority(Some(Manufacturer::Trezor.as_ref())) + .unwrap() + .try_path("/") + .unwrap(); + let uri = builder.build().unwrap(); + let expect = Locator { + manufacturer, + pubkey: None, + }; + assert_eq!(Locator::new_from_uri(&uri), Ok(expect)); + + // bad-scheme://trezor + let mut builder = URIReferenceBuilder::new(); + builder + .try_scheme(Some("bad-scheme")) + .unwrap() + .try_authority(Some(Manufacturer::Trezor.as_ref())) .unwrap() .try_path("") .unwrap(); let uri = builder.build().unwrap(); assert_eq!( Locator::new_from_uri(&uri), - Err(LocatorError::ManufacturerError(ManufacturerError)) + Err(LocatorError::UnimplementedScheme) ); - // usb://ledger/bad-pubkey + // usb://trezor/bad-pubkey let mut builder = URIReferenceBuilder::new(); builder .try_scheme(Some("usb")) .unwrap() - .try_authority(Some(Manufacturer::Ledger.as_ref())) + .try_authority(Some(Manufacturer::Trezor.as_ref())) .unwrap() .try_path("bad-pubkey") .unwrap(); @@ -333,6 +453,21 @@ mod tests { Locator::new_from_uri(&uri), Err(LocatorError::PubkeyError(ParsePubkeyError::Invalid)) ); + + // usb://bad-manufacturer + let mut builder = URIReferenceBuilder::new(); + builder + .try_scheme(Some("usb")) + .unwrap() + .try_authority(Some("bad-manufacturer")) + .unwrap() + .try_path("") + .unwrap(); + let uri = builder.build().unwrap(); + assert_eq!( + Locator::new_from_uri(&uri), + Err(LocatorError::ManufacturerError(ManufacturerError)) + ); } #[test] @@ -381,18 +516,68 @@ mod tests { Err(LocatorError::UnimplementedScheme) ); - // usb://bad-manufacturer - let path = "usb://bad-manufacturer"; + // usb://ledger/bad-pubkey + let path = "usb://ledger/bad-pubkey"; assert_eq!( Locator::new_from_path(path), - Err(LocatorError::ManufacturerError(ManufacturerError)) + Err(LocatorError::PubkeyError(ParsePubkeyError::Invalid)) ); - // usb://ledger/bad-pubkey - let path = "usb://ledger/bad-pubkey"; + let manufacturer = Manufacturer::Trezor; + let path = format!("usb://trezor/{pubkey}?key=0/0"); + Locator::new_from_path(path).unwrap(); + + // usb://trezor/{PUBKEY}?key=0'/0' + let path = format!("usb://trezor/{pubkey}?key=0'/0'"); + let expect = Locator { + manufacturer, + pubkey: Some(pubkey), + }; + assert_eq!(Locator::new_from_path(path), Ok(expect)); + + // usb://trezor/{PUBKEY} + let path = format!("usb://trezor/{pubkey}"); + let expect = Locator { + manufacturer, + pubkey: Some(pubkey), + }; + assert_eq!(Locator::new_from_path(path), Ok(expect)); + + // usb://trezor + let path = "usb://trezor"; + let expect = Locator { + manufacturer, + pubkey: None, + }; + assert_eq!(Locator::new_from_path(path), Ok(expect)); + + // usb://trezor/ + let path = "usb://trezor/"; + let expect = Locator { + manufacturer, + pubkey: None, + }; + assert_eq!(Locator::new_from_path(path), Ok(expect)); + + // bad-scheme://trezor + let path = "bad-scheme://trezor"; + assert_eq!( + Locator::new_from_path(path), + Err(LocatorError::UnimplementedScheme) + ); + + // usb://trezor/bad-pubkey + let path = "usb://trezor/bad-pubkey"; assert_eq!( Locator::new_from_path(path), Err(LocatorError::PubkeyError(ParsePubkeyError::Invalid)) ); + + // usb://bad-manufacturer + let path = "usb://bad-manufacturer"; + assert_eq!( + Locator::new_from_path(path), + Err(LocatorError::ManufacturerError(ManufacturerError)) + ); } } diff --git a/remote-wallet/src/remote_keypair.rs b/remote-wallet/src/remote_keypair.rs index 06e79d05ab2773..5cce82980ff28d 100644 --- a/remote-wallet/src/remote_keypair.rs +++ b/remote-wallet/src/remote_keypair.rs @@ -1,7 +1,7 @@ use { crate::{ - ledger::get_ledger_from_info, - locator::{Locator, Manufacturer}, + ledger::get_wallet_from_info, + locator::Locator, remote_wallet::{ RemoteWallet, RemoteWalletError, RemoteWalletInfo, RemoteWalletManager, RemoteWalletType, @@ -29,6 +29,7 @@ impl RemoteKeypair { ) -> Result { let pubkey = match &wallet_type { RemoteWalletType::Ledger(wallet) => wallet.get_pubkey(&derivation_path, confirm_key)?, + RemoteWalletType::Trezor(wallet) => wallet.get_pubkey(&derivation_path, confirm_key)?, }; Ok(Self { @@ -50,6 +51,9 @@ impl Signer for RemoteKeypair { RemoteWalletType::Ledger(wallet) => wallet .sign_message(&self.derivation_path, message) .map_err(|e| e.into()), + RemoteWalletType::Trezor(wallet) => wallet + .sign_message(&self.derivation_path, message) + .map_err(|e| e.into()), } } @@ -66,16 +70,12 @@ pub fn generate_remote_keypair( keypair_name: &str, ) -> Result { let remote_wallet_info = RemoteWalletInfo::parse_locator(locator); - if remote_wallet_info.manufacturer == Manufacturer::Ledger { - let ledger = get_ledger_from_info(remote_wallet_info, keypair_name, wallet_manager)?; - let path = format!("{}{}", ledger.pretty_path, derivation_path.get_query()); - Ok(RemoteKeypair::new( - RemoteWalletType::Ledger(ledger), - derivation_path, - confirm_key, - path, - )?) - } else { - Err(RemoteWalletError::DeviceTypeMismatch) - } + let remote_wallet = get_wallet_from_info(remote_wallet_info, keypair_name, wallet_manager)?; + let path = format!("{}{}", remote_wallet.path, derivation_path.get_query()); + RemoteKeypair::new( + remote_wallet.wallet_type, + derivation_path, + confirm_key, + path, + ) } diff --git a/remote-wallet/src/remote_wallet.rs b/remote-wallet/src/remote_wallet.rs index 69703344eb00ba..2de1f99465dcda 100644 --- a/remote-wallet/src/remote_wallet.rs +++ b/remote-wallet/src/remote_wallet.rs @@ -5,6 +5,7 @@ use { ledger::LedgerWallet, ledger_error::LedgerError, locator::{Locator, LocatorError, Manufacturer}, + trezor::TrezorWallet, }, log::*, parking_lot::RwLock, @@ -17,6 +18,7 @@ use { time::{Duration, Instant}, }, thiserror::Error, + trezor_client::{self, error::Error as TrezorClientError}, }; const HID_GLOBAL_USAGE_PAGE: u16 = 0xFF00; @@ -55,6 +57,9 @@ pub enum RemoteWalletError { #[error("pubkey not found for given address")] PubkeyNotFound, + #[error("trezor error: {0}")] + TrezorError(String), + #[error("remote wallet operation rejected by the user")] UserCancel, @@ -77,6 +82,7 @@ impl From for SignerError { RemoteWalletError::InvalidDevice => SignerError::Connection(err.to_string()), RemoteWalletError::InvalidInput(input) => SignerError::InvalidInput(input), RemoteWalletError::LedgerError(e) => SignerError::Protocol(e.to_string()), + RemoteWalletError::TrezorError(e) => SignerError::Protocol(e), RemoteWalletError::NoDeviceFound => SignerError::NoDeviceFound, RemoteWalletError::Protocol(e) => SignerError::Protocol(e.to_string()), RemoteWalletError::UserCancel => { @@ -87,6 +93,12 @@ impl From for SignerError { } } +impl From for RemoteWalletError { + fn from(err: TrezorClientError) -> RemoteWalletError { + RemoteWalletError::TrezorError(err.to_string()) + } +} + /// Collection of connected RemoteWallets pub struct RemoteWalletManager { #[cfg(feature = "hidapi")] @@ -149,6 +161,23 @@ impl RemoteWalletManager { } } + for device in trezor_client::find_devices(false) { + let mut trezor = device.connect().expect("connection error"); + trezor.init_device(None)?; + let wallet = TrezorWallet::new(trezor); + let pubkey = wallet.get_pubkey(&DerivationPath::default(), false).ok(); + let locator = Locator { + manufacturer: Manufacturer::Trezor, + pubkey, + }; + let info = RemoteWalletInfo::parse_locator(locator); + let path = info.get_pretty_path(); + detected_devices.push(Device { + path: path.clone(), + info, + wallet_type: RemoteWalletType::Trezor(Rc::new(wallet)), + }); + } let num_curr_devices = detected_devices.len(); *self.devices.write() = detected_devices; @@ -167,25 +196,18 @@ impl RemoteWalletManager { } /// List connected and acknowledged wallets - pub fn list_devices(&self) -> Vec { - self.devices.read().iter().map(|d| d.info.clone()).collect() + pub fn list_devices(&self) -> Vec { + self.devices.read().iter().cloned().collect() } /// Get a particular wallet - #[allow(unreachable_patterns)] - pub fn get_ledger( - &self, - host_device_path: &str, - ) -> Result, RemoteWalletError> { + pub fn get_wallet(&self, host_device_path: &str) -> 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), - }) + .cloned() } /// Get wallet info. @@ -254,7 +276,7 @@ pub trait RemoteWallet { } /// `RemoteWallet` device -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Device { pub(crate) path: String, pub(crate) info: RemoteWalletInfo, @@ -262,9 +284,10 @@ pub struct Device { } /// Remote wallet convenience enum to hold various wallet types -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum RemoteWalletType { Ledger(Rc), + Trezor(Rc), } /// Remote wallet information. @@ -369,6 +392,35 @@ mod tests { pubkey: Pubkey::default(), error: None, })); + + let locator = Locator { + manufacturer: Manufacturer::Trezor, + pubkey: Some(pubkey), + }; + let wallet_info = RemoteWalletInfo::parse_locator(locator); + assert!(wallet_info.matches(&RemoteWalletInfo { + model: "T".to_string(), + manufacturer: Manufacturer::Trezor, + serial: "".to_string(), + host_device_path: "/host/device/path".to_string(), + pubkey, + error: None, + })); + + // Test that pubkey need not be populated + let locator = Locator { + manufacturer: Manufacturer::Trezor, + pubkey: None, + }; + let wallet_info = RemoteWalletInfo::parse_locator(locator); + assert!(wallet_info.matches(&RemoteWalletInfo { + model: "T".to_string(), + manufacturer: Manufacturer::Trezor, + serial: "".to_string(), + host_device_path: "/host/device/path".to_string(), + pubkey: Pubkey::default(), + error: None, + })); } #[test] @@ -400,6 +452,33 @@ mod tests { assert!(!info.matches(&test_info)); test_info.pubkey = pubkey; assert!(info.matches(&test_info)); + + let info = RemoteWalletInfo { + manufacturer: Manufacturer::Trezor, + model: "T".to_string(), + serial: "".to_string(), + host_device_path: "/host/device/path".to_string(), + pubkey, + error: None, + }; + let mut test_info = RemoteWalletInfo { + manufacturer: Manufacturer::Unknown, + ..RemoteWalletInfo::default() + }; + assert!(!info.matches(&test_info)); + test_info.manufacturer = Manufacturer::Trezor; + assert!(info.matches(&test_info)); + test_info.model = "Other".to_string(); + assert!(info.matches(&test_info)); + test_info.model = "T".to_string(); + assert!(info.matches(&test_info)); + test_info.host_device_path = "/host/device/path".to_string(); + assert!(info.matches(&test_info)); + let another_pubkey = solana_pubkey::new_rand(); + test_info.pubkey = another_pubkey; + assert!(!info.matches(&test_info)); + test_info.pubkey = pubkey; + assert!(info.matches(&test_info)); } #[test] @@ -418,5 +497,18 @@ mod tests { remote_wallet_info.get_pretty_path(), format!("usb://ledger/{pubkey_str}") ); + + let remote_wallet_info = RemoteWalletInfo { + model: "T".to_string(), + manufacturer: Manufacturer::Trezor, + serial: "".to_string(), + host_device_path: "/host/device/path".to_string(), + pubkey, + error: None, + }; + assert_eq!( + remote_wallet_info.get_pretty_path(), + format!("usb://trezor/{pubkey_str}") + ); } } diff --git a/remote-wallet/src/trezor.rs b/remote-wallet/src/trezor.rs new file mode 100644 index 00000000000000..e04d54b9fc115b --- /dev/null +++ b/remote-wallet/src/trezor.rs @@ -0,0 +1,230 @@ +use { + crate::{ + locator::Manufacturer, + remote_wallet::{RemoteWallet, RemoteWalletError, RemoteWalletInfo}, + }, + console::Emoji, + solana_derivation_path::DerivationPath, + solana_pubkey::Pubkey, + solana_signature::Signature, + std::{cell::RefCell, fmt, rc::Rc}, + trezor_client::{ + client::common::handle_interaction, + protos::{SolanaGetPublicKey, SolanaPublicKey, SolanaSignTx, SolanaTxSignature}, + Trezor, + }, +}; + +static CHECK_MARK: Emoji = Emoji("✅ ", ""); + +/// Trezor Wallet device +pub struct TrezorWallet { + pub trezor_client: Rc>, +} + +impl fmt::Debug for TrezorWallet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "trezor_client") + } +} + +impl TrezorWallet { + pub fn new(trezor_client: Trezor) -> Self { + Self { + trezor_client: Rc::new(RefCell::new(trezor_client)), + } + } +} + +#[cfg(test)] +fn get_firmware_version(dev_info: &Trezor) -> Result { + let features = dev_info + .features() + .ok_or(RemoteWalletError::NoDeviceFound)?; + Ok(semver::Version::new( + features.major_version().into(), + features.minor_version().into(), + features.patch_version().into(), + )) +} + +fn get_model(dev_info: &Trezor) -> Result { + let features = dev_info + .features() + .ok_or(RemoteWalletError::NoDeviceFound)?; + Ok(features.model().to_string()) +} + +fn get_device_id(dev_info: &Trezor) -> Result { + let features = dev_info + .features() + .ok_or(RemoteWalletError::NoDeviceFound)?; + Ok(features.device_id().to_string()) +} + +impl RemoteWallet for TrezorWallet { + fn name(&self) -> &str { + "Trezor hardware wallet" + } + + /// Parse device info and get device base pubkey + fn read_device(&mut self, dev_info: &Trezor) -> Result { + let model = get_model(dev_info)?; + let serial = get_device_id(dev_info)?; + 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: Manufacturer::Trezor, + serial, + host_device_path: String::new(), + pubkey, + error, + }) + } + + /// Get solana pubkey from a RemoteWallet + fn get_pubkey( + &self, + derivation_path: &DerivationPath, + confirm_key: bool, + ) -> Result { + let address_n = derivation_path.into_iter().map(|i| i.to_bits()).collect(); + let solana_get_pubkey = SolanaGetPublicKey { + address_n, + show_display: Some(confirm_key), + ..SolanaGetPublicKey::default() + }; + if confirm_key { + println!("Waiting for your approval on {}", self.name()); + } + let pubkey = handle_interaction( + self.trezor_client + .borrow_mut() + .call(solana_get_pubkey, Box::new(|_, m: SolanaPublicKey| Ok(m)))?, + )?; + if confirm_key { + println!("{CHECK_MARK}Approved"); + } + Pubkey::try_from(pubkey.public_key()) + .map_err(|_| RemoteWalletError::Protocol("Key packet size mismatch")) + } + + /// Sign transaction data with wallet managing pubkey at derivation path + /// `m/44'/501'/'/'`. + fn sign_message( + &self, + derivation_path: &DerivationPath, + data: &[u8], + ) -> Result { + let address_n = derivation_path.into_iter().map(|i| i.to_bits()).collect(); + let solana_sign_tx = SolanaSignTx { + address_n, + serialized_tx: Some(data.to_vec()), + ..SolanaSignTx::default() + }; + let solana_tx_signature = handle_interaction( + self.trezor_client + .borrow_mut() + .call(solana_sign_tx, Box::new(|_, m: SolanaTxSignature| Ok(m)))?, + )?; + Signature::try_from(solana_tx_signature.signature()) + .map_err(|_e| RemoteWalletError::Protocol("Signature packet size mismatch")) + } + + /// Sign off-chain message with wallet managing pubkey at derivation path + /// `m/44'/501'/'/'`. + fn sign_offchain_message( + &self, + derivation_path: &DerivationPath, + message: &[u8], + ) -> Result { + Self::sign_message(self, derivation_path, message) + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + serial_test::serial, + trezor_client::{find_devices, Model}, + }; + + fn init_emulator() -> Trezor { + let mut emulator = find_devices(false) + .into_iter() + .find(|t| t.model == Model::TrezorEmulator) + .expect("An emulator should be found") + .connect() + .expect("Connection to the emulator should succeed"); + emulator + .init_device(None) + .expect("Initialization of device should succeed"); + emulator + } + + #[test] + #[serial] + #[ignore] + fn test_emulator_find() { + let trezors = find_devices(false); + assert!(!trezors.is_empty()); + assert!(trezors.iter().any(|t| t.model == Model::TrezorEmulator)); + } + + #[test] + #[serial] + #[ignore] + fn test_solana_pubkey() { + let mut emulator = init_emulator(); + let derivation_path_str = "m/44'/501'/0'/0'"; + let derivation_path = DerivationPath::from_absolute_path_str(derivation_path_str).unwrap(); + let address_n = derivation_path.into_iter().map(|i| i.to_bits()).collect(); + let solana_get_pubkey = SolanaGetPublicKey { + address_n, + show_display: Some(false), + ..SolanaGetPublicKey::default() + }; + let pubkey = handle_interaction( + emulator + .call(solana_get_pubkey, Box::new(|_, m: SolanaPublicKey| Ok(m))) + .expect( + "Trezor client (the emulator) has been initialized and SolanaGetPublicKey is \ + initialized correctly", + ), + ) + .expect( + "Trezor client (the emulator) has been initialized and SolanaGetPublicKey is \ + initialized correctly", + ); + assert!(Pubkey::try_from(pubkey.public_key()).is_ok()); + } + + #[test] + #[serial] + #[ignore] + fn test_trezor_wallet() { + let emulator = init_emulator(); + let model = + get_model(&emulator).expect("Trezor client (the emulator) has been initialized"); + let device_id = + get_device_id(&emulator).expect("Trezor client (the emulator) has been initialized"); + assert!(!device_id.is_empty()); + let firmware_version = get_firmware_version(&emulator); + assert!(firmware_version.is_ok()); + + let trezor_wallet = TrezorWallet::new(emulator); + let expected_model = "T".to_string(); + assert_eq!(expected_model, model); + + let derivation_path = DerivationPath::new_bip44(Some(0), Some(0)); + let pubkey = trezor_wallet + .get_pubkey(&derivation_path, false) + .expect("Trezor client (the emulator) has been initialized"); + assert!(!pubkey.to_string().is_empty()); + } +}