├── .gitignore ├── logo.png ├── Cargo.toml ├── coldcard-cli ├── upgrade.gif ├── 51-coinkite.rules ├── Cargo.toml ├── LICENSE ├── src │ ├── xpub_version.rs │ ├── fw_upgrade.rs │ └── main.rs └── README.md ├── coldcard ├── src │ ├── constants.rs │ ├── util.rs │ ├── protocol │ │ └── derivation_path.rs │ ├── firmware.rs │ ├── protocol.rs │ └── lib.rs ├── Cargo.toml ├── LICENSE └── README.md ├── 51-coinkite.rules ├── .github └── workflows │ └── rust.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfred-hodler/rust-coldcard/HEAD/logo.png -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "3" 3 | members = ["coldcard", "coldcard-cli"] 4 | -------------------------------------------------------------------------------- /coldcard-cli/upgrade.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alfred-hodler/rust-coldcard/HEAD/coldcard-cli/upgrade.gif -------------------------------------------------------------------------------- /coldcard/src/constants.rs: -------------------------------------------------------------------------------- 1 | //! Various constants used by the Coldcard. 2 | pub const AFC_PUBKEY: u8 = 0x01; 3 | pub const AFC_SEGWIT: u8 = 0x02; 4 | pub const AFC_BECH32: u8 = 0x04; 5 | pub const AFC_SCRIPT: u8 = 0x08; 6 | pub const AFC_WRAPPED: u8 = 0x10; 7 | 8 | pub const USER_AUTH_SHOW_QR: u8 = 0x80; 9 | 10 | pub const CHUNK_SIZE: usize = 63; 11 | 12 | pub const MAX_BLK_LEN: usize = 2048; 13 | pub const MAX_MSG_LEN: usize = 4 + 4 + 4 + MAX_BLK_LEN; 14 | 15 | pub const STXN_FINALIZE: u32 = 0x01; 16 | pub const STXN_VISUALIZE: u32 = 0x02; 17 | pub const STXN_SIGNED: u32 = 0x04; 18 | -------------------------------------------------------------------------------- /51-coinkite.rules: -------------------------------------------------------------------------------- 1 | # Linux udev support file. 2 | # 3 | # This is a example udev file for HIDAPI devices which changes the permissions 4 | # to 0666 (world readable/writable) for a specific device on Linux systems. 5 | # 6 | # - Copy this file into /etc/udev/rules.d and unplug and re-plug your Coldcard. 7 | # - Udev does not have to be restarted. 8 | # 9 | 10 | # probably not needed: 11 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666" 12 | 13 | # required: 14 | # from 15 | KERNEL=="hidraw*", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666" 16 | 17 | -------------------------------------------------------------------------------- /coldcard-cli/51-coinkite.rules: -------------------------------------------------------------------------------- 1 | # Linux udev support file. 2 | # 3 | # This is a example udev file for HIDAPI devices which changes the permissions 4 | # to 0666 (world readable/writable) for a specific device on Linux systems. 5 | # 6 | # - Copy this file into /etc/udev/rules.d and unplug and re-plug your Coldcard. 7 | # - Udev does not have to be restarted. 8 | # 9 | 10 | # probably not needed: 11 | SUBSYSTEMS=="usb", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666" 12 | 13 | # required: 14 | # from 15 | KERNEL=="hidraw*", ATTRS{idVendor}=="d13e", ATTRS{idProduct}=="cc10", GROUP="plugdev", MODE="0666" 16 | 17 | -------------------------------------------------------------------------------- /coldcard/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "coldcard" 3 | version = "0.13.0" 4 | edition = "2024" 5 | authors = ["Alfred Hodler "] 6 | license = "MIT" 7 | repository = "https://github.com/alfred-hodler/rust-coldcard/" 8 | description = "Coldcard Wallet Interface Library in Rust" 9 | keywords = ["coldcard", "bitcoin", "wallet"] 10 | categories = ["command-line-utilities", "cryptography::cryptocurrencies", "hardware-support"] 11 | 12 | [features] 13 | default = ["linux-static-hidraw"] 14 | linux-static-hidraw = ["hidapi/linux-static-hidraw"] 15 | linux-static-libusb = ["hidapi/linux-static-libusb"] 16 | 17 | [dependencies] 18 | aes = "0.8.3" 19 | base58 = "0.2.0" 20 | bitcoin_hashes = "0.13.0" 21 | ctr = "0.9.2" 22 | hidapi = { version = "2.5.1", default-features = false } 23 | k256 = { version = "0.13.3", features = ["arithmetic"] } 24 | log = { version = "0.4.20", optional = true } 25 | rand = "0.8.5" 26 | -------------------------------------------------------------------------------- /coldcard-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "coldcard-cli" 3 | version = "0.13.0" 4 | edition = "2024" 5 | authors = ["Alfred Hodler "] 6 | license = "MIT" 7 | repository = "https://github.com/alfred-hodler/rust-coldcard/" 8 | description = "Coldcard Wallet CLI Tool" 9 | keywords = ["coldcard", "bitcoin", "wallet"] 10 | categories = ["command-line-utilities", "cryptography::cryptocurrencies", "hardware-support"] 11 | 12 | [features] 13 | default = ["coldcard/default", "coldcard/log"] 14 | 15 | [[bin]] 16 | name = "coldcard" 17 | path = "src/main.rs" 18 | 19 | [dependencies] 20 | coldcard = { version = "0.13.0", path = "../coldcard" } 21 | base58 = "0.2.0" 22 | base64 = "0.21.7" 23 | clap = { version = "3.2.22", features = ["derive"] } 24 | hex = "0.4.3" 25 | hmac-sha256 = "1.1.7" 26 | indicatif = "0.17.7" 27 | json = "0.12.4" 28 | rpassword = "7.3.1" 29 | env_logger = "0.11.1" 30 | regex = "1.10.3" 31 | ureq = "2.9.4" 32 | semver = "1.0.21" 33 | console = "0.15.8" 34 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: awalsh128/cache-apt-pkgs-action@latest 18 | with: 19 | packages: libudev-dev 20 | version: 1.0 21 | - name: Build 22 | run: cargo build --verbose 23 | - name: Run tests 24 | run: RUST_LOG=trace cargo test --verbose 25 | 26 | format: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - name: Format 31 | run: cargo fmt --all --check 32 | 33 | clippy: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v3 37 | - uses: awalsh128/cache-apt-pkgs-action@latest 38 | with: 39 | packages: libudev-dev 40 | version: 1.0 41 | - name: Run Clippy 42 | run: cargo clippy 43 | -------------------------------------------------------------------------------- /coldcard/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alfred Hodler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /coldcard-cli/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alfred Hodler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust Coldcard Interface Project 2 | 3 | [![Documentation](https://img.shields.io/docsrs/coldcard)](https://docs.rs/coldcard/latest/coldcard/) 4 | [![Crates.io](https://img.shields.io/crates/v/coldcard.svg)](https://crates.io/crates/coldcard) 5 | [![License](https://img.shields.io/crates/l/coldcard.svg)](https://github.com/alfred-hodler/rust-coldcard/blob/master/coldcard/LICENSE) 6 | [![Test Status](https://github.com/alfred-hodler/rust-coldcard/actions/workflows/rust.yml/badge.svg?branch=master)](https://github.com/alfred-hodler/rust-coldcard/actions) 7 | 8 | This is a workspace that contains crates used for interfacing with the [Coldcard](https://coldcard.com/) hardware wallet over USB. 9 | 10 | ![Project Logo](logo.png) 11 | 12 | The crates are as follows: 13 | 14 | `coldcard` - the library for integration with Rust projects 15 | 16 | `coldcard-cli` - the CLI tool for upgrading and interfacing with Coldcard devices 17 | 18 | See each crate's `README.md` file for detailed information. 19 | 20 | ## Contributing 21 | 22 | Contributions are welcome. Before making large changes, please open an issue first. 23 | 24 | ## Disclaimer 25 | 26 | This is not an official project and comes with no warranty whatsoever. 27 | -------------------------------------------------------------------------------- /coldcard/README.md: -------------------------------------------------------------------------------- 1 | # Coldcard Interface Library 2 | 3 | `coldcard` is a library for interfacing with the [Coldcard](https://coldcard.com/) hardware wallet over USB. 4 | 5 | ## Usage 6 | 7 | ```rust 8 | use coldcard::protocol; 9 | 10 | // create an API instance 11 | let mut api = coldcard::Api::new()?; 12 | 13 | // detect all connected Coldcards 14 | let serials = api.detect()?; 15 | 16 | // get the first serial and open it 17 | let (mut cc, master_xpub) = api.open(&serials[0], None)?; 18 | 19 | // set a passphrase 20 | cc.set_passphrase(protocol::Passphrase::new("secret")?)?; 21 | 22 | // after the user confirms 23 | let xpub = cc.get_passphrase_done()?; 24 | 25 | if let Some(xpub) = xpub { 26 | println!("The new XPUB is: {}", xpub); 27 | } 28 | 29 | // secure logout 30 | cc.logout()?; 31 | ``` 32 | 33 | ## Linux Specific Instructions 34 | 35 | In order to be able to detect a Coldcard device on a Linux system, [51-coinkite.rules](../51-coinkite.rules) must be placed in `/etc/udev/rules.d/`. This can be installed using the CLI tool. 36 | 37 | Two mutually exclusive HID backends are supported and can be turned on using the following features: 38 | 39 | * `linux-static-hidraw` (default) 40 | * `linux-static-libusb` (potential issues with [unclear error messages](https://github.com/libusb/hidapi/blob/f2e2b5b4d4caa9942ad2cd594da00956b51f0ca6/libusb/hid.c#L1637)) 41 | 42 | ## Logging 43 | 44 | The `log` feature enables logging using the `log` crate. Disabled by default. Use judiciously as logging can leak details into the environment. 45 | 46 | ## CLI 47 | 48 | This project also offers a CLI tool. See the crate's own README for more information. 49 | 50 | Install it with: 51 | 52 | ```bash 53 | $ cargo install coldcard-cli 54 | ``` 55 | 56 | ## Contributing 57 | 58 | Contributions are welcome. Before making large changes, please open an issue first. 59 | 60 | ## Disclaimer 61 | 62 | This is not an official project and comes with no warranty whatsoever. 63 | -------------------------------------------------------------------------------- /coldcard/src/util.rs: -------------------------------------------------------------------------------- 1 | //! Miscellaneous utility functions. 2 | 3 | use bitcoin_hashes as hashes; 4 | use bitcoin_hashes::{Hash, HashEngine}; 5 | 6 | /// Computes a one-off SHA256 hash. 7 | pub fn sha256(data: &[u8]) -> [u8; 32] { 8 | hashes::sha256::Hash::hash(data).to_byte_array() 9 | } 10 | 11 | /// Computes a one-off RIPEMD160 hash. 12 | pub fn ripemd160(data: &[u8]) -> [u8; 20] { 13 | hashes::ripemd160::Hash::hash(data).to_byte_array() 14 | } 15 | 16 | /// Allows the computation of a SHA256 hash using multiple updates. 17 | #[derive(Default)] 18 | pub struct Sha256Engine(hashes::sha256::HashEngine); 19 | 20 | impl Sha256Engine { 21 | /// Updates the engine with data. 22 | pub fn update(&mut self, data: &[u8]) { 23 | self.0.input(data); 24 | } 25 | 26 | /// Consumes the engine and returns the hash. 27 | pub fn finalize(self) -> [u8; 32] { 28 | hashes::sha256::Hash::from_engine(self.0).to_byte_array() 29 | } 30 | } 31 | 32 | /// Decodes a B58 encoded xpub and returns the inner public key. 33 | pub fn decode_xpub(xpub: &str) -> Option { 34 | use base58::FromBase58; 35 | let decoded_xpub = xpub.from_base58().ok()?; 36 | k256::PublicKey::from_sec1_bytes(&decoded_xpub[45..45 + 33]).ok() 37 | } 38 | 39 | /// Calculates the fingerprint of a public key per BIP32. 40 | pub fn xfp(pk: &k256::PublicKey) -> [u8; 4] { 41 | let hash = ripemd160(&sha256(&pk.to_sec1_bytes())); 42 | hash.as_slice()[..4].try_into().expect("cannot fail") 43 | } 44 | 45 | /// Wraps an instance that's either owned or borrowed. 46 | pub enum MaybeOwned<'a, T> { 47 | Owned(T), 48 | Borrowed(&'a mut T), 49 | } 50 | 51 | impl AsRef for MaybeOwned<'_, T> { 52 | fn as_ref(&self) -> &T { 53 | match self { 54 | MaybeOwned::Owned(owned) => owned, 55 | MaybeOwned::Borrowed(borrowed) => borrowed, 56 | } 57 | } 58 | } 59 | 60 | impl AsMut for MaybeOwned<'_, T> { 61 | fn as_mut(&mut self) -> &mut T { 62 | match self { 63 | MaybeOwned::Owned(owned) => owned, 64 | MaybeOwned::Borrowed(borrowed) => borrowed, 65 | } 66 | } 67 | } 68 | 69 | pub fn parse_string_vec(response: &str) -> Vec { 70 | // expected input kind: ["Liana-rkkrtqy6", "Liana-947xsd0w"] 71 | let resp = response.replace("\"", ""); 72 | let end = resp.len() - 1; 73 | let resp = resp[1..end].to_string(); 74 | resp.split(",").map(|s| s.trim().to_string()).collect() 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use super::*; 80 | 81 | #[test] 82 | fn test_parse_string_vec() { 83 | // 2 entries 84 | let response = "[\"Liana-rkkrtqy6\", \"Liana-947xsd0w\"]"; 85 | let parsed = parse_string_vec(response); 86 | assert_eq!(parsed, vec!["Liana-rkkrtqy6", "Liana-947xsd0w"]); 87 | 88 | // 1 entry 89 | let response = "[\"solo\"]"; 90 | let parsed = parse_string_vec(response); 91 | assert_eq!(parsed, vec!["solo"]); 92 | 93 | // empty entry 94 | let response = "[]"; 95 | let parsed = parse_string_vec(response); 96 | assert_eq!(parsed, vec![""]); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /coldcard-cli/src/xpub_version.rs: -------------------------------------------------------------------------------- 1 | use base58::FromBase58; 2 | use base58::ToBase58; 3 | use coldcard::util::sha256; 4 | 5 | /// Some of the possible extended key version bytes. 6 | #[derive(Debug, Clone, Copy)] 7 | pub enum Version { 8 | Xpub, 9 | Ypub, 10 | Zpub, 11 | Tpub, 12 | Upub, 13 | Vpub, 14 | } 15 | 16 | impl Version { 17 | /// Returns the version bytes for a particular exended key version. 18 | fn bytes(&self) -> [u8; 4] { 19 | match self { 20 | Version::Xpub => XPUB, 21 | Version::Ypub => YPUB, 22 | Version::Zpub => ZPUB, 23 | Version::Tpub => TPUB, 24 | Version::Upub => UPUB, 25 | Version::Vpub => VPUB, 26 | } 27 | } 28 | } 29 | 30 | const XPUB: [u8; 4] = [0x04, 0x88, 0xB2, 0x1E]; 31 | const YPUB: [u8; 4] = [0x04, 0x9D, 0x7C, 0xB2]; 32 | const ZPUB: [u8; 4] = [0x04, 0xB2, 0x47, 0x46]; 33 | const TPUB: [u8; 4] = [0x04, 0x35, 0x87, 0xCF]; 34 | const UPUB: [u8; 4] = [0x04, 0x4A, 0x52, 0x62]; 35 | const VPUB: [u8; 4] = [0x04, 0x5F, 0x1C, 0xF6]; 36 | 37 | /// Converts an extended key to a different version. 38 | pub fn convert_bytes(s: &str, to: Version) -> Result { 39 | let mut decoded = s.from_base58().map_err(|_| Error::InvalidBase58)?; 40 | if decoded.len() != 82 { 41 | return Err(Error::InvalidLength); 42 | } 43 | 44 | decoded[0..4].copy_from_slice(&to.bytes()); 45 | let checksum = sha256(&sha256(&decoded[0..78])); 46 | decoded[78..82].copy_from_slice(&checksum[0..4]); 47 | Ok(decoded.to_base58()) 48 | } 49 | 50 | #[derive(Debug, Clone, Copy)] 51 | pub enum Error { 52 | InvalidBase58, 53 | InvalidLength, 54 | } 55 | 56 | #[cfg(test)] 57 | mod test { 58 | use crate::xpub_version::Version; 59 | 60 | use super::convert_bytes; 61 | 62 | #[test] 63 | fn version_conversion() { 64 | let xpub = "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8"; 65 | 66 | let zpub = convert_bytes(xpub, Version::Zpub).unwrap(); 67 | assert_eq!( 68 | zpub, 69 | "zpub6jftahH18ngZxUuv6oSniLNrBCSSE1B4EEU59bwTCEt8x6aS6b2mdfLxbS4QS53g85SWWP6wexqeer516433gYpZQoJie2tcMYdJ1SYYYAL" 70 | ); 71 | 72 | let ypub = convert_bytes(xpub, Version::Ypub).unwrap(); 73 | assert_eq!( 74 | ypub, 75 | "ypub6QqdH2c5z7967BioGSfAWFHM1EHzHPBZK7wrND3ZpEWFtzmCqvsD1bgpaE6pSAPkiSKhkuWPCJV6mZTSNMd2tK8xYTcJ48585pZecmSUzWp" 76 | ); 77 | 78 | let tpub = convert_bytes(xpub, Version::Tpub).unwrap(); 79 | assert_eq!( 80 | tpub, 81 | "tpubD6NzVbkrYhZ4XgiXtGrdW5XDAPFCL9h7we1vwNCpn8tGbBcgfVYjXyhWo4E1xkh56hjod1RhGjxbaTLV3X4FyWuejifB9jusQ46QzG87VKp" 82 | ); 83 | 84 | let vpub = convert_bytes(xpub, Version::Vpub).unwrap(); 85 | assert_eq!( 86 | vpub, 87 | "vpub5SLqN2bLY4WeZJ9SmNJHsyzqVKreTXD4ZnPC22MugDNcjhKX5xNX9QiQWcE4SSRzVWyHWUihpKRT7hckDGNzVc69wSX2JPcfGeNiT5c2XZy" 88 | ); 89 | 90 | let orig_xpub = convert_bytes(&zpub, Version::Xpub).unwrap(); 91 | assert_eq!(xpub, orig_xpub); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /coldcard-cli/README.md: -------------------------------------------------------------------------------- 1 | # Coldcard CLI 2 | 3 | `coldcard-cli` is a firmware upgrade and general management tool for the [Coldcard](https://coldcard.com/) hardware wallet. 4 | 5 | Install it with: 6 | 7 | ```bash 8 | $ cargo install coldcard-cli 9 | ``` 10 | 11 | ![Demo](upgrade.gif) 12 | 13 | Usage: 14 | ```bash 15 | $ coldcard --help 16 | 17 | coldcard-cli 0.12.0 18 | Coldcard Wallet CLI Tool 19 | 20 | USAGE: 21 | coldcard [OPTIONS] 22 | 23 | OPTIONS: 24 | -h, --help Print help information 25 | --serial The Coldcard serial number to operate on (default: first one found) 26 | -V, --version Print version information 27 | --xpub Perform a MITM check against an xpub 28 | 29 | SUBCOMMANDS: 30 | address Show the address for a derivation path 31 | auth-token Authenticate a specific user using a 6-digit token (for HSM) 32 | backup Initiate the backup process and create an encrypted 7z file 33 | bag Show the bag number the Coldcard arrived in 34 | chain Show the configured blockchain 35 | delete-user Delete a specific HSM user 36 | help Print this message or the help of the given subcommand(s) 37 | hsm Show the current HSM policy 38 | hsm-start Starts the HSM mode (with a specific policy) 39 | install-udev-rules Installs the udev file required to detect Coldcards on Linux 40 | list List the serial numbers of connected Coldcards 41 | local-conf Generate a 6-digit code for PSBT signing in HSM mode 42 | locker Get the hex contents of the storage locker (HSM mode only) 43 | logout Securely log out of the Coldcard 44 | message Sign a text message with a specific derivation path 45 | passphrase Set a BIP39 passphrase 46 | pubkey Show the pubkey for a derivation path 47 | reboot Reboot the Coldcard 48 | sign Sign a spending PSBT transaction 49 | test Test USB connection 50 | upgrade Download and upgrade to the latest firmware, or upgrade from file 51 | user Create a new HSM user. The secret is generated on the device 52 | version Show the version information of this Coldcard 53 | xfp Show the master fingerprint for this wallet 54 | xpub Show the xpub (default: master) 55 | ``` 56 | 57 | ## Linux Specific Instructions 58 | 59 | In order to be able to detect a Coldcard device on a Linux system, [51-coinkite.rules](../51-coinkite.rules) must be placed in `/etc/udev/rules.d/`. This can also be achieved using the `install-udev-rules` command. 60 | 61 | Two mutually exclusive HID backends are supported and can be turned on using the following features: 62 | 63 | * `coldcard/linux-static-hidraw` (default) 64 | * `coldcard/linux-static-libusb` (potential issues with [unclear error messages](https://github.com/libusb/hidapi/blob/f2e2b5b4d4caa9942ad2cd594da00956b51f0ca6/libusb/hid.c#L1637)) 65 | 66 | ## Logging 67 | 68 | To see log output, run the program with the `RUST_LOG=$level` environment variable. This uses the `env_logger` crate. 69 | 70 | ## Library 71 | 72 | This project also offers a Rust library. See the `coldcard` crate for more information. 73 | 74 | ## Contributing 75 | 76 | Contributions are welcome. Before making large changes, please open an issue first. 77 | 78 | ## Disclaimer 79 | 80 | This is not an official project and comes with no warranty whatsoever. 81 | -------------------------------------------------------------------------------- /coldcard/src/protocol/derivation_path.rs: -------------------------------------------------------------------------------- 1 | //! Derivation path module. 2 | use std::fmt::Write; 3 | 4 | /// BIP32 derivation path. 5 | #[derive(Debug, Default)] 6 | pub struct DerivationPath(Box<[Child]>); 7 | 8 | impl DerivationPath { 9 | pub fn new(value: &str) -> Result { 10 | let mut segments = value.split('/'); 11 | match segments.next() { 12 | Some("m") => (), 13 | _ => return Err(Error::InvalidFormat), 14 | } 15 | 16 | let children: Box<[Child]> = segments.map(|c| c.parse()).collect::>()?; 17 | 18 | if children.len() > 12 { 19 | return Err(Error::TooDeep); 20 | } 21 | 22 | Ok(Self(children)) 23 | } 24 | 25 | pub fn children(&self) -> &[Child] { 26 | &self.0 27 | } 28 | } 29 | 30 | impl std::fmt::Display for DerivationPath { 31 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 | write!(f, "m")?; 33 | for child in self.children() { 34 | write!(f, "/{}", child)?; 35 | } 36 | Ok(()) 37 | } 38 | } 39 | 40 | impl TryFrom> for DerivationPath { 41 | type Error = Error; 42 | 43 | fn try_from(value: Vec) -> Result { 44 | if value.len() > 12 { 45 | Err(Error::TooDeep) 46 | } else { 47 | Ok(DerivationPath(value.into())) 48 | } 49 | } 50 | } 51 | 52 | /// Derivation path child segment. 53 | #[derive(Debug, PartialEq, Eq)] 54 | pub enum Child { 55 | Normal(u32), 56 | Hardened(u32), 57 | } 58 | 59 | impl Child { 60 | pub fn value(&self) -> u32 { 61 | match self { 62 | Child::Normal(i) => *i, 63 | Child::Hardened(i) => i | (1 << 31), 64 | } 65 | } 66 | } 67 | 68 | impl std::str::FromStr for Child { 69 | type Err = Error; 70 | 71 | fn from_str(c: &str) -> Result { 72 | let is_hardened = c.chars().last().is_some_and(|l| l == '\'' || l == 'h'); 73 | let i: u32 = (if is_hardened { &c[0..c.len() - 1] } else { c }) 74 | .parse() 75 | .map_err(|_| Error::InvalidChild)?; 76 | 77 | if i & (1 << 31) == 0 { 78 | if is_hardened { 79 | Ok(Child::Hardened(i)) 80 | } else { 81 | Ok(Child::Normal(i)) 82 | } 83 | } else { 84 | Err(Error::InvalidChild) 85 | } 86 | } 87 | } 88 | 89 | impl std::fmt::Display for Child { 90 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 | match self { 92 | Child::Normal(i) => std::fmt::Display::fmt(&i, f), 93 | Child::Hardened(i) => { 94 | std::fmt::Display::fmt(&i, f)?; 95 | f.write_char('\'') 96 | } 97 | } 98 | } 99 | } 100 | 101 | /// Derivation path error. 102 | #[derive(Debug)] 103 | pub enum Error { 104 | InvalidChild, 105 | InvalidFormat, 106 | TooDeep, 107 | } 108 | 109 | #[cfg(test)] 110 | mod test { 111 | use super::*; 112 | 113 | #[test] 114 | fn parse() { 115 | let path = DerivationPath::new("m/44'/1'/2'/3/4").unwrap(); 116 | let mut path = path.0.iter(); 117 | assert_eq!(Some(&Child::Hardened(44)), path.next()); 118 | assert_eq!(Some(&Child::Hardened(1)), path.next()); 119 | assert_eq!(Some(&Child::Hardened(2)), path.next()); 120 | assert_eq!(Some(&Child::Normal(3)), path.next()); 121 | assert_eq!(Some(&Child::Normal(4)), path.next()); 122 | assert_eq!(None, path.next()); 123 | } 124 | 125 | #[test] 126 | fn display() { 127 | let path = DerivationPath::new("m/44'/1'/2'/3/4").unwrap(); 128 | assert_eq!(path.to_string(), "m/44'/1'/2'/3/4"); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /coldcard/src/firmware.rs: -------------------------------------------------------------------------------- 1 | //! Firmware and upgrade related module. 2 | use std::fs::File; 3 | use std::io::{self, SeekFrom}; 4 | use std::path::Path; 5 | 6 | pub const FW_HEADER_SIZE: u64 = 128; 7 | pub const FW_HEADER_OFFSET: u64 = 0x4000 - FW_HEADER_SIZE; 8 | pub const FW_HEADER_MAGIC: u32 = 0xCC001234; 9 | 10 | /// Firmware bytes, ready to upload to Coldcard. 11 | #[derive(Debug)] 12 | pub struct Firmware(Vec); 13 | 14 | impl Firmware { 15 | /// Loads a DFU file and parses it into a ready-to-upload Coldcard firmware. 16 | pub fn load_dfu(path: &Path) -> Result { 17 | let mut file = File::open(path)?; 18 | 19 | Self::parse_dfu(&mut file) 20 | } 21 | 22 | /// Parses DFU formatted bytes into a ready-to-upload Coldcard firmware. 23 | pub fn parse_dfu(stream: &mut T) -> Result { 24 | let mut prefix = [0_u8; 11]; 25 | stream.read_exact(&mut prefix)?; 26 | 27 | let signature = &prefix[0..5]; 28 | let _version = prefix[5]; 29 | let _size = &prefix[6..10]; 30 | let targets = prefix[10]; 31 | 32 | if signature != b"DfuSe" { 33 | return Err(Error::NotDFU); 34 | } 35 | 36 | for _ in 0..targets { 37 | let mut tprefix = [0_u8; 274]; 38 | stream.read_exact(&mut tprefix)?; 39 | 40 | let signature = &tprefix[0..6]; 41 | let _altsetting = tprefix[6]; 42 | let _named = &tprefix[7..11]; 43 | let _name = &tprefix[11..266]; 44 | let _size = &tprefix[266..270]; 45 | let elements = &tprefix[270..274]; 46 | 47 | if signature != b"Target" { 48 | return Err(Error::NotDFU); 49 | } 50 | 51 | let elements = decode_u32(Some(elements))?; 52 | 53 | if (0..elements).next().is_some() { 54 | let mut eprefix = [0_u8; 8]; 55 | stream.read_exact(&mut eprefix)?; 56 | let address = decode_u32(eprefix.get(0..4))?; 57 | let size = decode_u32(eprefix.get(4..8))?; 58 | 59 | if address < 0x8008000 { 60 | return Err(Error::BadAddress); 61 | } 62 | if size % 256 != 0 { 63 | return Err(Error::UnalignedSize); 64 | } 65 | 66 | let offset = stream.stream_position()?; 67 | stream.seek(SeekFrom::Start(offset + FW_HEADER_OFFSET))?; 68 | let mut header = [0_u8; FW_HEADER_SIZE as usize]; 69 | stream.read_exact(&mut header)?; 70 | 71 | let magic = decode_u32(header.get(0..4))?; 72 | if magic != FW_HEADER_MAGIC { 73 | return Err(Error::BadHeaderMagic); 74 | } 75 | 76 | stream.seek(SeekFrom::Start(offset))?; 77 | let mut data = vec![0_u8; size as usize]; 78 | stream.read_exact(&mut data)?; 79 | data.extend_from_slice(&header); 80 | 81 | return Ok(Firmware(data)); 82 | } 83 | } 84 | 85 | Err(Error::UnknownFirmwareOffset) 86 | } 87 | 88 | /// Firmware bytes after DFU parsing. 89 | pub fn bytes(&self) -> &[u8] { 90 | &self.0 91 | } 92 | } 93 | 94 | /// Firmware error. 95 | #[derive(Debug)] 96 | pub enum Error { 97 | IO(std::io::Error), 98 | DecodeFromBytes(&'static str), 99 | NotDFU, 100 | BadAddress, 101 | UnknownFirmwareOffset, 102 | BadHeaderMagic, 103 | UnalignedSize, 104 | } 105 | 106 | impl From for Error { 107 | fn from(error: std::io::Error) -> Self { 108 | Self::IO(error) 109 | } 110 | } 111 | 112 | fn decode_u32(bytes: Option<&[u8]>) -> Result { 113 | match bytes { 114 | Some(bytes) if bytes.len() == 4 => Ok(u32::from_le_bytes( 115 | bytes 116 | .try_into() 117 | .map_err(|_| Error::DecodeFromBytes("u32"))?, 118 | )), 119 | _ => Err(Error::DecodeFromBytes("u32")), 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /coldcard-cli/src/fw_upgrade.rs: -------------------------------------------------------------------------------- 1 | use coldcard::util; 2 | use regex::Regex; 3 | use semver::Version; 4 | 5 | pub const DOWNLOADS: &str = "https://www.coldcard.com/downloads"; 6 | 7 | /// A single firmware release. 8 | #[derive(Debug)] 9 | pub struct Release { 10 | /// The raw name of the firmware file as defined by the vendor. 11 | pub name: String, 12 | /// The version of the release in the semver format. 13 | pub version: Version, 14 | /// Whether the release is "edge" (experimental). 15 | pub is_edge: bool, 16 | } 17 | 18 | impl Release { 19 | /// Attempts to find a list of firmware releases on the official website. 20 | pub fn find() -> Result, ureq::Error> { 21 | let page = fetch_download_page()?; 22 | 23 | let mut found: Vec<_> = firmware_regex() 24 | .captures_iter(&page) 25 | .map(|m| { 26 | let name = m.get(0).unwrap(); 27 | let version = m.get(1).unwrap(); 28 | let edge_marker = version.as_str().find('X'); 29 | let (version, is_edge) = match edge_marker { 30 | Some(pos) => (Version::parse(&version.as_str()[1..pos]), true), 31 | None => (Version::parse(&version.as_str()[1..]), false), 32 | }; 33 | 34 | Release { 35 | name: name.as_str().to_owned(), 36 | version: version.unwrap(), 37 | is_edge, 38 | } 39 | }) 40 | .collect(); 41 | 42 | found.sort_by(|a, b| a.version.cmp(&b.version)); 43 | found.dedup_by(|a, b| a.name == b.name); 44 | found.reverse(); 45 | 46 | Ok(found) 47 | } 48 | 49 | /// Downloads a firmware release. 50 | pub fn download( 51 | &self, 52 | mut progress: F, 53 | ) -> Result, ureq::Error> { 54 | let url = format!("{DOWNLOADS}/{}", self.name); 55 | let response = ureq::get(&url).call()?; 56 | 57 | let size: usize = response 58 | .header("Content-Length") 59 | .and_then(|h| h.parse().ok()) 60 | .unwrap_or_default(); 61 | 62 | let mut reader = response.into_reader(); 63 | let mut downloaded = 0; 64 | let mut bytes = Vec::with_capacity(size); 65 | let mut buffer = [0_u8; 4096]; 66 | 67 | while downloaded < 20 * 1024 * 1024 { 68 | let read = reader.read(&mut buffer)?; 69 | if read == 0 { 70 | break; 71 | } 72 | downloaded += read; 73 | bytes.extend_from_slice(&buffer[..read]); 74 | progress(downloaded, size); 75 | } 76 | 77 | Ok(bytes) 78 | } 79 | 80 | /// Verifies the checksum of the downloaded firmware release. 81 | pub fn verify(&self, dl: &[u8]) -> Result<(), &'static str> { 82 | let sigs = fetch_signatures().map_err(|_| "Cannot fetch signature file")?; 83 | let line = sigs 84 | .lines() 85 | .find(|l| l.ends_with(&self.name)) 86 | .ok_or("Cannot find checksum")?; 87 | let checksum = &line[0..64]; 88 | 89 | if checksum == hex::encode(util::sha256(dl)) { 90 | Ok(()) 91 | } else { 92 | Err("Checksum mismatch") 93 | } 94 | } 95 | } 96 | 97 | pub fn best_match<'a>(releases: &'a [Release], our_model: Option<&str>) -> Option<&'a Release> { 98 | releases 99 | .iter() 100 | .filter(|r| { 101 | let mk2_or_mk3 = r.version.major == 4 && matches!(our_model, Some("mk2" | "mk3")); 102 | let mk4 = r.version.major == 5 && matches!(our_model, Some("mk4")); 103 | 104 | !r.is_edge && (mk2_or_mk3 || mk4) 105 | }) 106 | .max_by(|a, b| a.version.cmp(&b.version)) 107 | } 108 | 109 | fn fetch_download_page() -> Result { 110 | ureq::get(DOWNLOADS) 111 | .call() 112 | .map(|r| r.into_string().expect("bad utf-8")) 113 | } 114 | 115 | fn fetch_signatures() -> Result { 116 | ureq::get("https://raw.githubusercontent.com/Coldcard/firmware/master/releases/signatures.txt") 117 | .call() 118 | .map(|r| r.into_string().expect("bad utf-8")) 119 | } 120 | 121 | fn firmware_regex() -> Regex { 122 | Regex::new(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]+-(v[0-9]+\.[0-9]+\.[0-9]+X?)(-mk.)?-coldcard.dfu") 123 | .unwrap() 124 | } 125 | -------------------------------------------------------------------------------- /coldcard/src/protocol.rs: -------------------------------------------------------------------------------- 1 | //! Codec and protocol module. 2 | pub mod derivation_path; 3 | 4 | pub use derivation_path::DerivationPath; 5 | 6 | use crate::constants::*; 7 | 8 | macro_rules! impl_new_with_range { 9 | ($thing:ident, $range:expr_2021) => { 10 | impl_new_with_range!($thing, $range, 0_u8..); 11 | }; 12 | ($thing:ident, $range:expr_2021, $valid_char_range:expr_2021) => { 13 | impl $thing { 14 | pub fn new(value: impl AsRef<[u8]>) -> Result { 15 | let value = value.as_ref(); 16 | for c in value { 17 | if !$valid_char_range.contains(c) { 18 | return Err(EncodeError::InvalidCharValue); 19 | } 20 | } 21 | let type_name = std::any::type_name::<$thing>(); 22 | #[allow(unused_comparisons)] 23 | if value.len() < $range.start || value.len() > $range.end { 24 | return Err(EncodeError::LengthMismatch(type_name, value.len())); 25 | } 26 | Ok(Self(value.to_owned())) 27 | } 28 | } 29 | }; 30 | } 31 | 32 | pub struct DescriptorName(pub(crate) Vec); 33 | pub struct Upload(Vec); 34 | pub struct Message(Vec); 35 | pub struct Username(Vec); 36 | pub struct AuthToken(Vec); 37 | 38 | #[derive(Default)] 39 | pub struct Secret(Vec); 40 | pub struct RedeemScript(Vec); 41 | pub struct Passphrase(Vec); 42 | 43 | pub struct HsmStartParams { 44 | pub length: u32, 45 | pub file_sha: [u8; 32], 46 | } 47 | 48 | pub struct XfpPath { 49 | pub fingerprint: u32, 50 | pub path: DerivationPath, 51 | } 52 | 53 | #[repr(u32)] 54 | #[derive(Copy, Clone, Default)] 55 | pub enum FileNo { 56 | Zero = 0, 57 | #[default] 58 | One = 1, 59 | } 60 | 61 | impl_new_with_range!(DescriptorName, 2..40); 62 | impl_new_with_range!(Upload, 1..MAX_BLK_LEN); 63 | impl_new_with_range!(Message, 1..240); 64 | impl_new_with_range!(Username, 1..16); 65 | impl_new_with_range!(AuthToken, 6..32); 66 | impl_new_with_range!(RedeemScript, 30..520); 67 | impl_new_with_range!(Passphrase, 0..100, 32..=126); 68 | 69 | impl Secret { 70 | pub fn new(value: impl AsRef<[u8]>) -> Result { 71 | let value = value.as_ref(); 72 | match value.len() { 73 | 0 | 10 | 20 | 32 => Ok(Self(value.to_owned())), 74 | _ => Err(EncodeError::LengthNotOneOf("Secret", vec![0, 10, 20, 32])), 75 | } 76 | } 77 | } 78 | 79 | #[allow(non_camel_case_types)] 80 | #[repr(u8)] 81 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 82 | pub enum AddressFormat { 83 | P2PKH = AFC_PUBKEY, 84 | P2SH = AFC_SCRIPT, 85 | P2WPKH = AFC_PUBKEY | AFC_SEGWIT | AFC_BECH32, 86 | P2WSH = AFC_SCRIPT | AFC_SEGWIT | AFC_BECH32, 87 | P2WPKH_P2SH = AFC_WRAPPED | AFC_PUBKEY | AFC_SEGWIT, 88 | P2WSH_P2SH = AFC_WRAPPED | AFC_SCRIPT | AFC_SEGWIT, 89 | } 90 | 91 | #[repr(u8)] 92 | #[derive(Debug, Clone, Copy)] 93 | pub enum AuthMode { 94 | TOTP = 0x01, 95 | HOTP = 0x02, 96 | HMAC = 0x03, 97 | } 98 | 99 | /// Request variants that can be sent to a Coldcard. 100 | pub(crate) enum Request { 101 | Logout, 102 | Reboot, 103 | Version, 104 | Ping(Vec), 105 | Bip39Passphrase(Passphrase), 106 | GetPassphraseDone, 107 | CheckMitm, 108 | StartBackup, 109 | RestoreBackup { 110 | length: u32, 111 | file_sha: [u8; 32], 112 | //custom_pwd: (bool) .7z encrypted with custom password 113 | custom_pwd: bool, 114 | //plaintext: (bool) clear-text (dev) 115 | plaintext: bool, 116 | //tmp (bool) force load as tmp, effective only on seed-less CC 117 | tmp: bool, 118 | }, 119 | EncryptStart { 120 | device_pubkey: [u8; 64], 121 | version: Option, 122 | }, 123 | Upload { 124 | offset: u32, 125 | total_size: u32, 126 | data: Upload, 127 | }, 128 | Download { 129 | offset: u32, 130 | length: u32, 131 | file_number: FileNo, 132 | }, 133 | Sha256, 134 | SignTransaction { 135 | length: u32, 136 | file_sha: [u8; 32], 137 | flags: Option, 138 | descriptor_name: Option, 139 | }, 140 | SignMessage { 141 | raw_msg: Message, 142 | subpath: Option, 143 | addr_fmt: AddressFormat, 144 | }, 145 | GetSignedMessage, 146 | GetBackupFile, 147 | GetSignedTransaction, 148 | MiniscriptPolicy { 149 | descriptor_name: DescriptorName, 150 | }, 151 | MiniscriptAddress { 152 | descriptor_name: DescriptorName, 153 | change: bool, 154 | index: u32, 155 | }, 156 | MiniscriptEnroll { 157 | length: u32, 158 | file_sha: [u8; 32], 159 | }, 160 | MiniscriptList, 161 | MiniscriptDelete { 162 | descriptor_name: DescriptorName, 163 | }, 164 | MiniscriptGetDescriptor { 165 | descriptor_name: DescriptorName, 166 | }, 167 | MultisigEnroll { 168 | length: u32, 169 | file_sha: [u8; 32], 170 | }, 171 | MultiSigCheck { 172 | m: u32, 173 | n: u32, 174 | xfp_xor: u32, 175 | }, 176 | GetXPub(Option), 177 | ShowAddress { 178 | subpath: DerivationPath, 179 | addr_fmt: AddressFormat, 180 | }, 181 | ShowP2SHAddress { 182 | min_signers: u8, 183 | xfp_paths: Vec, 184 | redeem_script: RedeemScript, 185 | address_format: AddressFormat, 186 | }, 187 | Blockchain, 188 | BagNumber(Option), 189 | HsmStart(Option), 190 | HsmStatus, 191 | CreateUser { 192 | username: Username, 193 | auth_mode: AuthMode, 194 | secret: Option, 195 | show_qr: bool, 196 | }, 197 | DeleteUser(Username), 198 | UserAuth { 199 | username: Username, 200 | token: AuthToken, 201 | totp_time: u32, 202 | }, 203 | GetStorageLocker, 204 | } 205 | 206 | impl Request { 207 | /// Encodes a `Request` into a byte vector. 208 | pub fn encode(self) -> Vec { 209 | match self { 210 | Request::Logout => cmd("logo"), 211 | 212 | Request::Reboot => cmd("rebo"), 213 | 214 | Request::Version => cmd("vers"), 215 | 216 | Request::Ping(msg) => { 217 | let mut buf = cmd("ping"); 218 | buf.extend(msg); 219 | buf 220 | } 221 | 222 | Request::Bip39Passphrase(pw) => { 223 | let mut buf = cmd("pass"); 224 | buf.extend(pw.0); 225 | buf 226 | } 227 | 228 | Request::GetPassphraseDone => cmd("pwok"), 229 | 230 | Request::CheckMitm => cmd("mitm"), 231 | 232 | Request::StartBackup => cmd("back"), 233 | 234 | Request::EncryptStart { 235 | device_pubkey, 236 | version, 237 | } => { 238 | let mut buf = cmd("ncry"); 239 | buf.extend(version.unwrap_or(1).to_le_bytes()); 240 | buf.extend(device_pubkey); 241 | buf 242 | } 243 | 244 | Request::Upload { 245 | offset, 246 | total_size, 247 | data, 248 | } => { 249 | let mut buf = cmd("upld"); 250 | buf.extend(offset.to_le_bytes()); 251 | buf.extend(total_size.to_le_bytes()); 252 | buf.extend(data.0); 253 | buf 254 | } 255 | 256 | Request::Download { 257 | offset, 258 | length, 259 | file_number, 260 | } => { 261 | let mut buf = cmd("dwld"); 262 | buf.extend(offset.to_le_bytes()); 263 | buf.extend(length.to_le_bytes()); 264 | buf.extend((file_number as u32).to_le_bytes()); 265 | buf 266 | } 267 | 268 | Request::Sha256 => cmd("sha2"), 269 | 270 | Request::SignTransaction { 271 | length, 272 | file_sha, 273 | flags, 274 | descriptor_name: miniscript_name, 275 | } => { 276 | let mut buf = cmd("stxn"); 277 | let flags = flags.unwrap_or_default(); 278 | buf.extend(length.to_le_bytes()); 279 | buf.extend(flags.to_le_bytes()); 280 | buf.extend(file_sha); 281 | if let Some(name) = miniscript_name { 282 | buf.extend((name.0.len() as u8).to_le_bytes()); 283 | buf.extend(name.0); 284 | } 285 | buf 286 | } 287 | 288 | Request::SignMessage { 289 | raw_msg, 290 | subpath, 291 | addr_fmt, 292 | } => { 293 | let subpath = subpath.unwrap_or_default().to_string(); 294 | let mut buf = cmd("smsg"); 295 | buf.extend((addr_fmt as u32).to_le_bytes()); 296 | buf.extend((subpath.len() as u32).to_le_bytes()); 297 | buf.extend((raw_msg.0.len() as u32).to_le_bytes()); 298 | buf.extend(subpath.as_bytes()); 299 | buf.extend(raw_msg.0); 300 | buf 301 | } 302 | Request::GetSignedMessage => cmd("smok"), 303 | 304 | Request::GetBackupFile => cmd("bkok"), 305 | 306 | Request::GetSignedTransaction => cmd("stok"), 307 | 308 | Request::MiniscriptAddress { 309 | descriptor_name, 310 | change, 311 | index, 312 | } => { 313 | let mut buf = cmd("msas"); 314 | buf.extend((change as u32).to_le_bytes()); 315 | buf.extend(index.to_le_bytes()); 316 | buf.extend(descriptor_name.0); 317 | buf 318 | } 319 | 320 | Request::MiniscriptEnroll { length, file_sha } => { 321 | let mut buf = cmd("mins"); 322 | buf.extend(length.to_le_bytes()); 323 | buf.extend(file_sha); 324 | buf 325 | } 326 | 327 | Request::MiniscriptGetDescriptor { descriptor_name } => { 328 | let mut buf = cmd("msgt"); 329 | buf.extend(descriptor_name.0); 330 | buf 331 | } 332 | 333 | Request::MultisigEnroll { length, file_sha } => { 334 | let mut buf = cmd("enrl"); 335 | buf.extend(length.to_le_bytes()); 336 | buf.extend(file_sha); 337 | buf 338 | } 339 | 340 | Request::MultiSigCheck { m, n, xfp_xor } => { 341 | let mut buf = cmd("msck"); 342 | buf.extend(m.to_le_bytes()); 343 | buf.extend(n.to_le_bytes()); 344 | buf.extend(xfp_xor.to_le_bytes()); 345 | buf 346 | } 347 | 348 | Request::GetXPub(subpath) => { 349 | let mut buf = cmd("xpub"); 350 | buf.extend(subpath.unwrap_or_default().to_string().as_bytes()); 351 | buf 352 | } 353 | 354 | Request::ShowAddress { subpath, addr_fmt } => { 355 | let mut buf = cmd("show"); 356 | buf.extend((addr_fmt as u32).to_le_bytes()); 357 | buf.extend(subpath.to_string().as_bytes()); 358 | buf 359 | } 360 | 361 | Request::ShowP2SHAddress { 362 | min_signers, 363 | xfp_paths, 364 | redeem_script, 365 | address_format, 366 | } => { 367 | let mut buf = cmd("p2sh"); 368 | buf.extend((address_format as u32).to_le_bytes()); 369 | buf.extend(min_signers.to_le_bytes()); 370 | buf.extend((xfp_paths.len() as u8).to_le_bytes()); 371 | buf.extend((redeem_script.0.len() as u16).to_le_bytes()); 372 | buf.extend(redeem_script.0); 373 | 374 | for XfpPath { fingerprint, path } in xfp_paths { 375 | buf.push((1 + path.children().len()) as u8); 376 | buf.extend(fingerprint.to_le_bytes()); 377 | for child in path.children().iter() { 378 | buf.extend(child.value().to_le_bytes()); 379 | } 380 | } 381 | 382 | buf 383 | } 384 | 385 | Request::Blockchain => cmd("blkc"), 386 | 387 | Request::BagNumber(number) => { 388 | let mut buf = cmd("bagi"); 389 | if let Some(number) = number { 390 | buf.extend(number.as_bytes()); 391 | } 392 | buf 393 | } 394 | 395 | Request::HsmStart(params) => { 396 | let mut buf = cmd("hsms"); 397 | if let Some(HsmStartParams { length, file_sha }) = params { 398 | buf.extend(length.to_le_bytes()); 399 | buf.extend(file_sha); 400 | } 401 | buf 402 | } 403 | 404 | Request::HsmStatus => cmd("hsts"), 405 | 406 | Request::CreateUser { 407 | username, 408 | auth_mode, 409 | secret, 410 | show_qr, 411 | } => { 412 | let secret = secret.unwrap_or_default(); 413 | let mut buf = cmd("nwur"); 414 | buf.push((auth_mode as u8) | if show_qr { USER_AUTH_SHOW_QR } else { 0x00 }); 415 | buf.push(username.0.len() as u8); 416 | buf.push(secret.0.len() as u8); 417 | buf.extend(username.0); 418 | buf.extend(secret.0); 419 | buf 420 | } 421 | 422 | Request::DeleteUser(username) => { 423 | let mut buf = cmd("rmur"); 424 | buf.push(username.0.len() as u8); 425 | buf.extend(username.0); 426 | buf 427 | } 428 | 429 | Request::UserAuth { 430 | username, 431 | token, 432 | totp_time, 433 | } => { 434 | let mut buf = cmd("user"); 435 | buf.extend(totp_time.to_le_bytes()); 436 | buf.push(username.0.len() as u8); 437 | buf.push(token.0.len() as u8); 438 | buf.extend(username.0); 439 | buf.extend(token.0); 440 | buf 441 | } 442 | 443 | Request::GetStorageLocker => cmd("gslr"), 444 | Request::MiniscriptPolicy { 445 | descriptor_name: name, 446 | } => { 447 | let mut buf = cmd("mspl"); 448 | buf.extend(name.0); 449 | buf 450 | } 451 | Request::RestoreBackup { 452 | length, 453 | file_sha, 454 | custom_pwd, 455 | plaintext, 456 | tmp, 457 | } => { 458 | let mut flags = 0u8; 459 | if custom_pwd { 460 | flags |= 1; 461 | } 462 | if plaintext { 463 | flags |= 2; 464 | } 465 | if tmp { 466 | flags |= 4; 467 | } 468 | let mut buf = cmd("rest"); 469 | buf.extend(length.to_le_bytes()); 470 | buf.extend(file_sha); 471 | buf.push(flags); 472 | 473 | buf 474 | } 475 | Request::MiniscriptDelete { descriptor_name } => { 476 | let mut buf = cmd("msdl"); 477 | buf.extend(descriptor_name.0); 478 | buf 479 | } 480 | Request::MiniscriptList => cmd("msls"), 481 | } 482 | } 483 | } 484 | 485 | /// Error variants that can occur while encoding. 486 | #[derive(Debug)] 487 | pub enum EncodeError { 488 | LengthMismatch(&'static str, usize), 489 | LengthNotOneOf(&'static str, Vec), 490 | InvalidCharValue, 491 | } 492 | 493 | fn cmd(name: &str) -> Vec { 494 | name.as_bytes().to_owned() 495 | } 496 | 497 | /// Response variants that can be read from a Coldcard. 498 | #[derive(Debug)] 499 | pub enum Response { 500 | Ok, 501 | Refused, 502 | Busy, 503 | Binary(Vec), 504 | Int1(u32), 505 | Int2(u64), 506 | Int3(u128), 507 | MyPub { 508 | dev_pubkey: [u8; 64], 509 | xpub_fingerprint: [u8; 4], 510 | xpub: Option, 511 | }, 512 | Ascii(String), 513 | TxSigned { 514 | length: u32, 515 | sha256: [u8; 32], 516 | }, 517 | MessageSigned { 518 | address: String, 519 | signature: [u8; 65], 520 | }, 521 | } 522 | 523 | impl Response { 524 | /// Attempts to decode a byte slice into a `Response`. 525 | pub fn decode(payload: &[u8]) -> Result { 526 | let (command, data) = split(payload, 4)?; 527 | match command { 528 | b"okay" => Ok(Response::Ok), 529 | b"refu" => Ok(Response::Refused), 530 | b"busy" => Ok(Response::Busy), 531 | b"biny" => Ok(Response::Binary(data.to_owned())), 532 | b"int1" => decode_u32(data.get(0..4)).map(Response::Int1), 533 | b"int2" => Ok(Response::Int2(decode_u64(data.get(0..8))?)), 534 | b"int3" => Ok(Response::Int3(decode_u128(data.get(0..12))?)), 535 | b"mypb" => { 536 | let (dev_pubkey, data) = split(data, 64)?; 537 | let dev_pubkey = dev_pubkey 538 | .try_into() 539 | .map_err(|_| DecodeError::Decode("pubkey"))?; 540 | let xpub_fingerprint = data 541 | .get(0..4) 542 | .ok_or(DecodeError::Decode("xfp wants 4 bytes"))? 543 | .try_into() 544 | .expect("infallible"); 545 | let xpub_len = decode_u32(data.get(4..8))? as usize; 546 | let xpub = if xpub_len > 0 { 547 | data.get(8..8 + xpub_len) 548 | .map(|d| String::from_utf8(d.to_owned())) 549 | .transpose() 550 | .map_err(DecodeError::Utf8)? 551 | } else { 552 | None 553 | }; 554 | Ok(Response::MyPub { 555 | dev_pubkey, 556 | xpub_fingerprint, 557 | xpub, 558 | }) 559 | } 560 | b"asci" => Ok(Response::Ascii(bytes_as_string(data).map(str::to_owned)?)), 561 | b"strx" => { 562 | let (length, sha256) = split(data, 4)?; 563 | let length = decode_u32(Some(length))?; 564 | let sha256 = sha256 565 | .try_into() 566 | .map_err(|_| DecodeError::Decode("checksum"))?; 567 | Ok(Response::TxSigned { length, sha256 }) 568 | } 569 | b"smrx" => { 570 | let (addr_len, address_and_sig) = split(data, 4)?; 571 | let addr_len = decode_u32(Some(addr_len))?; 572 | let (address, sig) = split(address_and_sig, addr_len as usize)?; 573 | let address = bytes_as_string(address)?.to_owned(); 574 | let signature: [u8; 65] = sig.try_into().map_err(|_| DecodeError::Decode("sig"))?; 575 | Ok(Response::MessageSigned { address, signature }) 576 | } 577 | b"err_" => Err(DecodeError::Protocol( 578 | bytes_as_string(data).map(str::to_owned)?, 579 | )), 580 | b"fram" => Err(DecodeError::Framing( 581 | bytes_as_string(data).map(str::to_owned)?, 582 | )), 583 | _ => Err(DecodeError::UnknownFrame(command.to_owned())), 584 | } 585 | } 586 | 587 | /// Check for an `Ok` response. 588 | pub fn is_ok(&self) -> bool { 589 | matches!(self, Self::Ok) 590 | } 591 | 592 | /// Extract `Int1` value. 593 | pub fn into_int1(self) -> Result { 594 | if let Self::Int1(i) = self { 595 | Ok(i) 596 | } else { 597 | Err(self) 598 | } 599 | } 600 | 601 | /// Extract ASCII value. 602 | pub fn into_ascii(self) -> Result { 603 | if let Self::Ascii(s) = self { 604 | Ok(s) 605 | } else { 606 | Err(self) 607 | } 608 | } 609 | 610 | /// Extract binary value. 611 | pub fn into_binary(self) -> Result, Self> { 612 | if let Self::Binary(v) = self { 613 | Ok(v) 614 | } else { 615 | Err(self) 616 | } 617 | } 618 | 619 | /// Extract public key value. 620 | #[allow(clippy::type_complexity)] 621 | pub fn into_my_pub(self) -> Result<([u8; 64], [u8; 4], Option), Self> { 622 | if let Self::MyPub { 623 | dev_pubkey, 624 | xpub_fingerprint, 625 | xpub, 626 | } = self 627 | { 628 | Ok((dev_pubkey, xpub_fingerprint, xpub)) 629 | } else { 630 | Err(self) 631 | } 632 | } 633 | 634 | /// Attempts to convert the response into the Ok variant. Returns the Err 635 | /// variant with the response if something else. 636 | pub fn into_ok(self) -> Result<(), Response> { 637 | if self.is_ok() { Ok(()) } else { Err(self) } 638 | } 639 | } 640 | 641 | /// Safely decodes a possible 4 byte slice into an `u32`. 642 | fn decode_u32(bytes: Option<&[u8]>) -> Result { 643 | match bytes { 644 | Some(bytes) if bytes.len() == 4 => Ok(u32::from_le_bytes( 645 | bytes.try_into().map_err(|_| DecodeError::Decode("u32"))?, 646 | )), 647 | _ => Err(DecodeError::Decode("u32")), 648 | } 649 | } 650 | 651 | /// Safely decodes a possible 8 byte slice into an `u64`. 652 | fn decode_u64(bytes: Option<&[u8]>) -> Result { 653 | match bytes { 654 | Some(bytes) if bytes.len() == 8 => Ok(u64::from_le_bytes( 655 | bytes.try_into().map_err(|_| DecodeError::Decode("u64"))?, 656 | )), 657 | _ => Err(DecodeError::Decode("u64")), 658 | } 659 | } 660 | 661 | /// Safely decodes a possible 12 byte slice into an `u128`. 662 | fn decode_u128(bytes: Option<&[u8]>) -> Result { 663 | match bytes { 664 | Some(bytes) if bytes.len() == 12 => Ok(u128::from_le_bytes( 665 | bytes 666 | .iter() 667 | .chain(&[0, 0, 0, 0]) 668 | .cloned() 669 | .collect::>() 670 | .try_into() 671 | .map_err(|_| DecodeError::Decode("u128"))?, 672 | )), 673 | _ => Err(DecodeError::Decode("u128")), 674 | } 675 | } 676 | 677 | /// Safely splits a slice at `mid`. Returns an error if `bytes.len() < mid`. 678 | fn split(bytes: &[u8], mid: usize) -> Result<(&[u8], &[u8]), DecodeError> { 679 | match bytes.len().cmp(&mid) { 680 | std::cmp::Ordering::Less => Err(DecodeError::Decode("unexpected slice length")), 681 | _ => Ok(bytes.split_at(mid)), 682 | } 683 | } 684 | 685 | /// Safely interprets a byte slice as a `String`. 686 | fn bytes_as_string(bytes: &[u8]) -> Result<&str, DecodeError> { 687 | use std::str; 688 | str::from_utf8(bytes).map_err(|_| DecodeError::Decode("utf8")) 689 | } 690 | 691 | /// Error variants that can occur while decoding. 692 | #[derive(Debug)] 693 | pub enum DecodeError { 694 | UnknownFrame(Vec), 695 | Framing(String), 696 | Decode(&'static str), 697 | Protocol(String), 698 | Utf8(std::string::FromUtf8Error), 699 | } 700 | 701 | #[cfg(test)] 702 | mod tests { 703 | use super::*; 704 | 705 | #[test] 706 | fn encode_test() { 707 | encode_eq(b"logo", Request::Logout); 708 | 709 | encode_eq(b"rebo", Request::Reboot); 710 | 711 | encode_eq(b"vers", Request::Version); 712 | 713 | encode_eq(b"pingHello", Request::Ping("Hello".as_bytes().to_owned())); 714 | 715 | encode_eq( 716 | b"pass123", 717 | Request::Bip39Passphrase(Passphrase::new("123").unwrap()), 718 | ); 719 | 720 | encode_eq( 721 | b"pass", 722 | Request::Bip39Passphrase(Passphrase::new("").unwrap()), 723 | ); 724 | 725 | encode_eq(b"pwok", Request::GetPassphraseDone); 726 | 727 | encode_eq(b"mitm", Request::CheckMitm); 728 | 729 | encode_eq(b"back", Request::StartBackup); 730 | 731 | encode_eq( 732 | &[ 733 | 110, 99, 114, 121, 1, 0, 0, 0, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 734 | 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 107, 735 | 237, 24, 89, 172, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 736 | 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 737 | 172, 738 | ], 739 | Request::EncryptStart { 740 | device_pubkey: BYTES_64.to_owned(), 741 | version: None, 742 | }, 743 | ); 744 | 745 | encode_eq( 746 | b"upld\x05\x00\x00\x00\x07\x00\x00\x00data 123", 747 | Request::Upload { 748 | offset: 5, 749 | total_size: 7, 750 | data: Upload::new("data 123".as_bytes()).unwrap(), 751 | }, 752 | ); 753 | 754 | encode_eq( 755 | b"dwld\x05\x00\x00\x00\x07\x00\x00\x00\x01\x00\x00\x00", 756 | Request::Download { 757 | offset: 5, 758 | length: 7, 759 | file_number: FileNo::One, 760 | }, 761 | ); 762 | 763 | encode_eq(b"sha2", Request::Sha256); 764 | 765 | encode_eq( 766 | &[ 767 | 115, 116, 120, 110, 89, 1, 0, 0, 7, 0, 0, 0, 82, 246, 129, 254, 167, 146, 135, 174, 768 | 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 769 | 111, 107, 237, 24, 89, 172, 770 | ], 771 | Request::SignTransaction { 772 | length: 345, 773 | file_sha: BYTES_32.to_owned(), 774 | flags: Some(STXN_FINALIZE | STXN_SIGNED | STXN_VISUALIZE), 775 | descriptor_name: None, 776 | }, 777 | ); 778 | 779 | encode_eq( 780 | &[ 781 | 115, 116, 120, 110, 89, 1, 0, 0, 7, 0, 0, 0, 82, 246, 129, 254, 167, 146, 135, 174, 782 | 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 783 | 111, 107, 237, 24, 89, 172, 4, 116, 101, 115, 116, 784 | ], 785 | Request::SignTransaction { 786 | length: 345, 787 | file_sha: BYTES_32.to_owned(), 788 | flags: Some(STXN_FINALIZE | STXN_SIGNED | STXN_VISUALIZE), 789 | descriptor_name: Some(DescriptorName::new("test").unwrap()), 790 | }, 791 | ); 792 | 793 | encode_eq( 794 | &[ 795 | 115, 109, 115, 103, 19, 0, 0, 0, 1, 0, 0, 0, 11, 0, 0, 0, 109, 72, 101, 108, 108, 796 | 111, 32, 87, 111, 114, 108, 100, 797 | ], 798 | Request::SignMessage { 799 | raw_msg: Message::new("Hello World").unwrap(), 800 | subpath: None, 801 | addr_fmt: AddressFormat::P2WPKH_P2SH, 802 | }, 803 | ); 804 | 805 | encode_eq(b"smok", Request::GetSignedMessage); 806 | 807 | encode_eq(b"bkok", Request::GetBackupFile); 808 | 809 | encode_eq(b"stok", Request::GetSignedTransaction); 810 | 811 | encode_eq( 812 | &[ 813 | 101, 110, 114, 108, 99, 0, 0, 0, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 814 | 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 815 | 107, 237, 24, 89, 172, 816 | ], 817 | Request::MultisigEnroll { 818 | length: 99, 819 | file_sha: BYTES_32.to_owned(), 820 | }, 821 | ); 822 | 823 | encode_eq( 824 | b"msckd\x00\x00\x00\xc8\x00\x00\x00,\x01\x00\x00", 825 | Request::MultiSigCheck { 826 | m: 100, 827 | n: 200, 828 | xfp_xor: 300, 829 | }, 830 | ); 831 | 832 | encode_eq(b"xpubm", Request::GetXPub(None)); 833 | encode_eq( 834 | b"xpubm/44'/0'/0'/0/0", 835 | Request::GetXPub(DerivationPath::new("m/44'/0'/0'/0/0").ok()), 836 | ); 837 | 838 | encode_eq( 839 | b"show\x07\x00\x00\x00m/44'/0'/0'/0/0", 840 | Request::ShowAddress { 841 | subpath: DerivationPath::new("m/44'/0'/0'/0/0").unwrap(), 842 | addr_fmt: AddressFormat::P2WPKH, 843 | }, 844 | ); 845 | 846 | encode_eq( 847 | &[ 848 | 112, 50, 115, 104, 8, 0, 0, 0, 5, 2, 32, 0, 82, 246, 129, 254, 167, 146, 135, 174, 849 | 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 850 | 111, 107, 237, 24, 89, 172, 4, 67, 105, 5, 15, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 851 | 4, 81, 37, 21, 158, 4, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 852 | ], 853 | Request::ShowP2SHAddress { 854 | min_signers: 5, 855 | redeem_script: RedeemScript::new(BYTES_32).unwrap(), 856 | xfp_paths: vec![ 857 | XfpPath { 858 | fingerprint: 252012867, 859 | path: DerivationPath::new("m/1/0/0").unwrap(), 860 | }, 861 | XfpPath { 862 | fingerprint: 2652185937, 863 | path: DerivationPath::new("m/4/4/0").unwrap(), 864 | }, 865 | ], 866 | address_format: AddressFormat::P2SH, 867 | }, 868 | ); 869 | 870 | encode_eq(b"blkc", Request::Blockchain); 871 | 872 | encode_eq(b"bagi", Request::BagNumber(None)); 873 | encode_eq( 874 | b"bagi123abc", 875 | Request::BagNumber(Some("123abc".to_string())), 876 | ); 877 | 878 | encode_eq(b"hsms", Request::HsmStart(None)); 879 | encode_eq( 880 | &[ 881 | 104, 115, 109, 115, 89, 1, 0, 0, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 882 | 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 883 | 107, 237, 24, 89, 172, 884 | ], 885 | Request::HsmStart(Some(HsmStartParams { 886 | length: 345, 887 | file_sha: BYTES_32.to_owned(), 888 | })), 889 | ); 890 | 891 | encode_eq(b"hsts", Request::HsmStatus); 892 | 893 | encode_eq( 894 | b"nwur\x02\x05\x00user1", 895 | Request::CreateUser { 896 | auth_mode: AuthMode::HOTP, 897 | username: Username::new("user1").unwrap(), 898 | secret: None, 899 | show_qr: false, 900 | }, 901 | ); 902 | encode_eq( 903 | b"nwur\x82\x05\nuser1secret1234", 904 | Request::CreateUser { 905 | auth_mode: AuthMode::HOTP, 906 | username: Username::new("user1").unwrap(), 907 | secret: Secret::new("secret1234").ok(), 908 | show_qr: true, 909 | }, 910 | ); 911 | 912 | encode_eq( 913 | b"rmur\x05user1", 914 | Request::DeleteUser(Username::new("user1").unwrap()), 915 | ); 916 | 917 | encode_eq( 918 | b"user\x0c\x00\x00\x00\x05\tuser1token1234", 919 | Request::UserAuth { 920 | username: Username::new("user1").unwrap(), 921 | token: AuthToken::new("token1234").unwrap(), 922 | totp_time: 12, 923 | }, 924 | ); 925 | 926 | encode_eq(b"gslr", Request::GetStorageLocker); 927 | } 928 | 929 | #[test] 930 | fn decode_test() { 931 | assert!(matches!( 932 | Response::decode(b"abcd"), 933 | Err(DecodeError::UnknownFrame(name)) if name == [97, 98, 99, 100])); 934 | 935 | assert!(matches!( 936 | Response::decode(b"fram1234"), 937 | Err(DecodeError::Framing(text)) if text == "1234", 938 | )); 939 | 940 | assert!(matches!( 941 | Response::decode(b"err_1234"), 942 | Err(DecodeError::Protocol(text)) if text == "1234", 943 | )); 944 | 945 | assert!(matches!(Response::decode(b"refu"), Ok(Response::Refused))); 946 | 947 | assert!(matches!(Response::decode(b"busy"), Ok(Response::Busy))); 948 | 949 | assert!( 950 | matches!(Response::decode(b"biny1234"), Ok(Response::Binary(data)) if data == [49, 50, 51, 52]) 951 | ); 952 | 953 | assert!( 954 | matches!(Response::decode(b"int1\xFE\xFF\xFF\xFF"), Ok(Response::Int1(i)) if i == u32::MAX - 1) 955 | ); 956 | 957 | assert!( 958 | matches!(Response::decode(b"int2\xFE\xFF\xFF\xFF\xFF\xFF\xFF\xFF"), 959 | Ok(Response::Int2(i)) if i == u64::MAX - 1) 960 | ); 961 | 962 | assert!( 963 | matches!(Response::decode(b"int3\xFE\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF"), 964 | Ok(Response::Int3(i)) if i == 79228162514264337593543950334_u128) 965 | ); 966 | 967 | assert!(matches!( 968 | Response::decode(&[ 969 | 109, 121, 112, 98, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 167, 53, 970 | 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 172, 82, 246, 971 | 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 972 | 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 172, 64, 226, 1, 0, 111, 0, 0, 0, 116, 112, 973 | 117, 98, 68, 65, 101, 110, 102, 119, 78, 117, 53, 71, 121, 67, 74, 87, 118, 56, 111, 113, 974 | 82, 65, 99, 107, 100, 75, 77, 83, 85, 111, 90, 106, 103, 86, 70, 53, 112, 56, 87, 118, 81, 975 | 119, 72, 81, 101, 88, 106, 68, 104, 65, 72, 109, 71, 114, 80, 97, 52, 97, 52, 121, 50, 70, 976 | 110, 55, 72, 70, 50, 110, 102, 67, 76, 101, 102, 74, 97, 110, 72, 86, 51, 110, 121, 49, 85, 977 | 89, 50, 53, 77, 82, 86, 111, 103, 105, 122, 66, 50, 122, 82, 85, 100, 65, 111, 55, 84, 114, 978 | 57, 88, 65, 106, 109, 979 | ]), 980 | Ok(Response::MyPub { 981 | dev_pubkey, 982 | xpub_fingerprint, 983 | xpub: Some(xpub) 984 | }) if &dev_pubkey == BYTES_64 && xpub_fingerprint == [64, 226, 1, 0] && 985 | xpub == XPUB 986 | 987 | 988 | )); 989 | 990 | assert!(matches!(Response::decode(b"ascihello"), 991 | Ok(Response::Ascii(s)) if &s == "hello")); 992 | 993 | assert!(matches!( 994 | Response::decode(&[ 995 | 115, 109, 114, 120, 34, 0, 0, 0, 49, 81, 50, 84, 87, 72, 69, 51, 71, 77, 100, 66, 996 | 54, 66, 90, 75, 97, 102, 113, 119, 120, 88, 116, 87, 65, 87, 103, 70, 116, 53, 74, 997 | 118, 109, 51, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 167, 998 | 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 172, 999 | 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 167, 53, 120, 248, 1000 | 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 172, 55 1001 | ]), 1002 | // we just added a single 55_u8 to our byte array for easier testing 1003 | Ok(Response::MessageSigned { address, signature }) 1004 | if &address.to_string() == "1Q2TWHE3GMdB6BZKafqwxXtWAWgFt5Jvm3" 1005 | && &signature[0..64] == BYTES_64 && signature[64] == 55 1006 | )); 1007 | 1008 | assert!(matches!( 1009 | Response::decode(&[ 1010 | 115, 116, 114, 120, 89, 1, 0, 0, 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 1011 | 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 189, 111, 1012 | 107, 237, 24, 89, 172 1013 | ]), 1014 | Ok(Response::TxSigned { length, sha256 }) if length == 345 && &sha256 == BYTES_32)); 1015 | } 1016 | 1017 | fn encode_eq(a: &'static [u8], b: Request) { 1018 | assert_eq!(a, b.encode().as_slice()); 1019 | } 1020 | 1021 | const BYTES_32: &[u8; 32] = &[ 1022 | 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 1023 | 213, 131, 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 172, 1024 | ]; 1025 | 1026 | const BYTES_64: &[u8; 64] = &[ 1027 | 82, 246, 129, 254, 167, 146, 135, 174, 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 1028 | 213, 131, 160, 94, 44, 58, 189, 111, 107, 237, 24, 89, 172, 82, 246, 129, 254, 167, 146, 1029 | 135, 174, 60, 60, 152, 151, 192, 167, 53, 120, 248, 31, 108, 213, 131, 160, 94, 44, 58, 1030 | 189, 111, 107, 237, 24, 89, 172, 1031 | ]; 1032 | 1033 | const XPUB: &str = "tpubDAenfwNu5GyCJWv8oqRAckdKMSUoZjgVF5p8WvQwHQeXjDhAHmGrPa4a4y2Fn7HF2nfCLefJanHV3ny1UY25MRVogizB2zRUdAo7Tr9XAjm"; 1034 | } 1035 | -------------------------------------------------------------------------------- /coldcard-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::result_large_err)] 2 | 3 | use std::fs::File; 4 | use std::io::{Read, Seek, SeekFrom, Write}; 5 | use std::path::PathBuf; 6 | 7 | use coldcard::protocol::{self, DescriptorName, Response, derivation_path}; 8 | use coldcard::{Backup, Options, SignedMessage, firmware}; 9 | use coldcard::{XpubInfo, util}; 10 | 11 | use clap::Parser; 12 | 13 | mod fw_upgrade; 14 | mod xpub_version; 15 | 16 | #[derive(clap::Parser)] 17 | #[clap(author, version, about)] 18 | #[clap(propagate_version = true)] 19 | struct Cli { 20 | /// The main command to execute 21 | #[clap(subcommand)] 22 | command: Command, 23 | 24 | /// The Coldcard serial number to operate on (default: first one found) 25 | #[clap(long)] 26 | serial: Option, 27 | 28 | /// Perform a MITM check against an xpub 29 | #[clap(long)] 30 | xpub: Option, 31 | } 32 | 33 | #[derive(clap::Subcommand)] 34 | enum Command { 35 | /// Show the address for a derivation path 36 | Address { 37 | path: String, 38 | #[clap(arg_enum)] 39 | format: AddressFormat, 40 | }, 41 | 42 | /// Authenticate a specific user using a 6-digit token (for HSM) 43 | AuthToken { 44 | /// The username to authenticate with 45 | username: String, 46 | /// The 6-digit token to authenticate with. Will be prompted if missing. 47 | token: Option, 48 | }, 49 | 50 | /// Initiate the backup process and create an encrypted 7z file 51 | Backup { 52 | /// The path to the file where the backup should be saved, 53 | /// including the filename 54 | path: PathBuf, 55 | }, 56 | 57 | /// Restore a backup from file 58 | Restore { 59 | path: PathBuf, 60 | /// Backup is encrypted with a custom password 61 | #[clap(short, long, action = clap::ArgAction::SetTrue)] 62 | password: bool, 63 | /// Backup is clear-text (dev) 64 | #[clap(short, long, action = clap::ArgAction::SetTrue)] 65 | plaintext: bool, 66 | /// force load as tmp, effective only on seed-less CC 67 | #[clap(short, long, action = clap::ArgAction::SetTrue)] 68 | tmp: bool, 69 | }, 70 | 71 | /// Show the bag number the Coldcard arrived in 72 | Bag, 73 | 74 | /// Show the configured blockchain 75 | Chain, 76 | 77 | /// Delete a specific HSM user 78 | DeleteUser { username: String }, 79 | 80 | /// Show the current HSM policy 81 | Hsm, 82 | 83 | /// Starts the HSM mode (with a specific policy) 84 | HsmStart { 85 | /// The path to the new policy file. If missing, 86 | /// the existing policy is started. 87 | path: Option, 88 | }, 89 | 90 | /// Installs the udev file required to detect Coldcards on Linux. 91 | #[cfg(target_os = "linux")] 92 | InstallUdevRules, 93 | 94 | /// List the serial numbers of connected Coldcards 95 | List, 96 | 97 | /// Generate a 6-digit code for PSBT signing in HSM mode 98 | LocalConf { 99 | /// The path to the PSBT file 100 | psbt: PathBuf, 101 | /// The next code to use (default: get from device) 102 | next_code: Option, 103 | }, 104 | 105 | /// Get the hex contents of the storage locker (HSM mode only). 106 | Locker { 107 | /// Encode the output as UTF-8. This can fail if not UTF-8. 108 | #[clap(long)] 109 | utf8: bool, 110 | }, 111 | 112 | /// Securely log out of the Coldcard 113 | Logout, 114 | 115 | /// Sign a text message with a specific derivation path 116 | Message { 117 | /// The message to sign 118 | message: String, 119 | /// The address format 120 | #[clap(arg_enum)] 121 | address_format: AddressFormat, 122 | /// The optional derivation path to use (default: master) 123 | path: Option, 124 | /// Wrap the signature in ASCII armor 125 | #[clap(long)] 126 | armor: bool, 127 | }, 128 | 129 | /// Set a BIP39 passphrase. 130 | Passphrase { 131 | /// Read the passphrase from stdin instead of console. Leading and trailing 132 | /// newlines and whitespaces are trimmed off. 133 | #[clap(long)] 134 | stdin: bool, 135 | }, 136 | 137 | /// Show the pubkey for a derivation path 138 | Pubkey { path: String }, 139 | 140 | /// Reboot the Coldcard 141 | Reboot, 142 | 143 | /// Sign a spending PSBT transaction 144 | Sign { 145 | /// The path to the PSBT file to sign 146 | psbt_in: PathBuf, 147 | /// The signing mode to use 148 | #[clap(arg_enum)] 149 | mode: SignMode, 150 | /// Output in base64 (default: hex) 151 | #[clap(long)] 152 | base64: bool, 153 | /// The optional path where to write out the signed tx (default: stdout) 154 | psbt_out: Option, 155 | /// Optional miniscript wallet name 156 | #[clap(long)] 157 | miniscript: Option, 158 | }, 159 | 160 | /// Test USB connection 161 | Test, 162 | 163 | /// Download and upgrade to the latest firmware, or upgrade from file. 164 | Upgrade { 165 | /// The path to the firmware file. If none, runs in the interactive (auto download) mode 166 | /// trying to find the best match on the official website. 167 | path: Option, 168 | }, 169 | 170 | /// Create a new HSM user. The secret is generated on the device 171 | User { 172 | /// The username to create 173 | username: String, 174 | /// The authentication mode to use 175 | #[clap(arg_enum)] 176 | auth_mode: AuthMode, 177 | /// Show the secret on standard output instead of the device. NOT RECOMMENDED! 178 | #[clap(long)] 179 | stdout: bool, 180 | }, 181 | 182 | /// Show the version information of this Coldcard 183 | Version, 184 | 185 | /// Show the master fingerprint for this wallet 186 | Xfp, 187 | 188 | /// Show the xpub (default: master) 189 | Xpub { 190 | /// The optional derivation path 191 | path: Option, 192 | 193 | #[clap(arg_enum)] 194 | /// The extended key version to optionally convert to. 195 | version: Option, 196 | 197 | /// Include the fingerprint. The output will be two lines. 198 | #[clap(long)] 199 | xfp: bool, 200 | }, 201 | 202 | /// List miniscript descriptors 203 | MiniscriptList, 204 | /// Delete a registered miniscript policy 205 | MiniscriptDelete { name: String }, 206 | /// Get a registered miniscript policy by its name 207 | MiniscriptPolicy { name: String }, 208 | } 209 | 210 | #[derive(clap::ArgEnum, Clone)] 211 | enum AddressFormat { 212 | Legacy, 213 | Wrapped, 214 | Segwit, 215 | } 216 | 217 | impl From for protocol::AddressFormat { 218 | fn from(value: AddressFormat) -> Self { 219 | match value { 220 | AddressFormat::Legacy => protocol::AddressFormat::P2PKH, 221 | AddressFormat::Wrapped => protocol::AddressFormat::P2WPKH_P2SH, 222 | AddressFormat::Segwit => protocol::AddressFormat::P2WPKH, 223 | } 224 | } 225 | } 226 | 227 | #[derive(clap::ArgEnum, Clone)] 228 | #[allow(clippy::upper_case_acronyms)] 229 | enum AuthMode { 230 | TOTP, 231 | HOTP, 232 | HMAC, 233 | } 234 | 235 | #[derive(clap::ArgEnum, Clone)] 236 | enum SignMode { 237 | /// Visualize only, no signing 238 | Visualize, 239 | /// Visualize with signature 240 | VisualizeSigned, 241 | /// Finalize the transaction 242 | Finalize, 243 | /// Sign the transaction 244 | Sign, 245 | } 246 | 247 | impl From for protocol::AuthMode { 248 | fn from(mode: AuthMode) -> Self { 249 | match mode { 250 | AuthMode::TOTP => protocol::AuthMode::TOTP, 251 | AuthMode::HOTP => protocol::AuthMode::HOTP, 252 | AuthMode::HMAC => protocol::AuthMode::HMAC, 253 | } 254 | } 255 | } 256 | 257 | impl From<&SignMode> for coldcard::SignMode { 258 | fn from(mode: &SignMode) -> Self { 259 | match mode { 260 | SignMode::Visualize => coldcard::SignMode::Visualize, 261 | SignMode::VisualizeSigned => coldcard::SignMode::VisualizeSigned, 262 | SignMode::Finalize => coldcard::SignMode::Finalize, 263 | SignMode::Sign => coldcard::SignMode::Signed, 264 | } 265 | } 266 | } 267 | 268 | #[derive(Clone, clap::ArgEnum)] 269 | enum XpubVersion { 270 | Xpub, 271 | Ypub, 272 | Zpub, 273 | Tpub, 274 | Upub, 275 | Vpub, 276 | } 277 | 278 | impl From for xpub_version::Version { 279 | fn from(value: XpubVersion) -> Self { 280 | match value { 281 | XpubVersion::Xpub => Self::Xpub, 282 | XpubVersion::Ypub => Self::Ypub, 283 | XpubVersion::Zpub => Self::Zpub, 284 | XpubVersion::Tpub => Self::Tpub, 285 | XpubVersion::Upub => Self::Upub, 286 | XpubVersion::Vpub => Self::Vpub, 287 | } 288 | } 289 | } 290 | 291 | fn main() -> Result<(), Error> { 292 | env_logger::init(); 293 | 294 | let cli = Cli::parse(); 295 | 296 | handle(cli) 297 | } 298 | 299 | fn handle(cli: Cli) -> Result<(), Error> { 300 | let mut api = coldcard::Api::new()?; 301 | let serials = api.detect()?; 302 | 303 | // Commands we can handle without a Coldcard connection. 304 | match cli.command { 305 | Command::List => { 306 | for cc in serials { 307 | println!("{}", cc.as_ref()); 308 | } 309 | 310 | return Ok(()); 311 | } 312 | 313 | #[cfg(target_os = "linux")] 314 | Command::InstallUdevRules => { 315 | const UDEV_FILE: &str = "/etc/udev/rules.d/51-coinkite.rules"; 316 | 317 | if std::path::Path::new(UDEV_FILE).exists() { 318 | eprintln!("udev rules already installed"); 319 | } else { 320 | match std::fs::File::create(UDEV_FILE) { 321 | Ok(mut file) => { 322 | file.write_all(include_bytes!("../51-coinkite.rules"))?; 323 | eprintln!("udev rules installed"); 324 | } 325 | Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { 326 | eprintln!("Permission denied. Try with sudo?"); 327 | } 328 | Err(err) => eprintln!("error: {}", err), 329 | } 330 | } 331 | 332 | return Ok(()); 333 | } 334 | 335 | _ => {} 336 | } 337 | 338 | let sn = match cli.serial { 339 | Some(sn) => serials.into_iter().find(|dev_sn| sn == dev_sn.as_ref()), 340 | None => serials.into_iter().next(), 341 | } 342 | .ok_or(Error::NoColdcardDetected)?; 343 | 344 | let (mut cc, xpub_info) = api.open( 345 | sn, 346 | Some(Options { 347 | resync_on_open: true, 348 | ..Default::default() 349 | }), 350 | )?; 351 | 352 | // check for MITM if requested 353 | let expected_xpub = cli.xpub; 354 | match ( 355 | expected_xpub.as_deref(), 356 | xpub_info.as_ref().map(|x| &x.xpub), 357 | ) { 358 | (Some(expected), Some(actual)) => { 359 | if expected != actual { 360 | eprintln!("The expected xpub does not match the one on the device"); 361 | return Err(Error::MitmInProgress); 362 | } else { 363 | let mitm = cc.check_mitm(expected)?; 364 | if mitm { 365 | eprintln!("WARNING - POSSIBLE MITM IN PROGRESS"); 366 | return Err(Error::MitmInProgress); 367 | } 368 | } 369 | } 370 | (Some(_), None) => { 371 | eprintln!( 372 | "An xpub was passed but there is no secret on the device yet; MITM check not possible" 373 | ); 374 | } 375 | _ => {} 376 | } 377 | 378 | match cli.command { 379 | Command::Address { path, format } => { 380 | let path = protocol::DerivationPath::new(&path)?; 381 | let address = cc.address(path, format.into())?; 382 | println!("{}", address); 383 | } 384 | 385 | Command::AuthToken { username, token } => { 386 | let totp_time = (now() / 30) as u32; 387 | let username = protocol::Username::new(username)?; 388 | let token = match token { 389 | Some(token) => parse_6_digit_token(&token), 390 | None => parse_6_digit_token( 391 | &rpassword::prompt_password("Enter a 6-digit code:\n").unwrap(), 392 | ), 393 | }?; 394 | 395 | cc.user_auth(username, token, totp_time)?; 396 | eprintln!("OK"); 397 | } 398 | 399 | Command::Backup { path } => { 400 | cc.start_backup()?; 401 | print_waiting(); 402 | 403 | let Backup { data, sha256 } = loop { 404 | sleep(); 405 | let backup = cc.get_backup()?; 406 | match backup { 407 | Some(backup) => break backup, 408 | None => continue, 409 | } 410 | }; 411 | 412 | let mut file = File::create(&path)?; 413 | file.write_all(&data)?; 414 | 415 | eprintln!( 416 | "Saved the backup to {};\nchecksum: {}", 417 | path.to_str().unwrap_or("Path error"), 418 | hex::encode(sha256) 419 | ); 420 | } 421 | 422 | Command::Bag => { 423 | let bag = cc.bag_number()?; 424 | println!("{}", bag); 425 | } 426 | 427 | Command::Chain => { 428 | let blockchain = cc.blockchain()?; 429 | println!("{}", blockchain); 430 | } 431 | 432 | Command::DeleteUser { username } => { 433 | let username = protocol::Username::new(username)?; 434 | cc.delete_username(username)?; 435 | eprintln!("OK - deleted if it was there"); 436 | } 437 | 438 | Command::Hsm => { 439 | let policy = cc.hsm_policy()?; 440 | println!("{}", policy); 441 | } 442 | 443 | Command::HsmStart { path: Some(path) } => { 444 | let mut file = File::open(path)?; 445 | let mut policy = vec![]; 446 | file.read_to_end(&mut policy)?; 447 | 448 | cc.hsm_start(Some(&policy))?; 449 | eprintln!("OK"); 450 | } 451 | 452 | Command::HsmStart { path: None } => { 453 | cc.hsm_start(None)?; 454 | eprintln!("OK"); 455 | } 456 | 457 | #[cfg(target_os = "linux")] 458 | Command::InstallUdevRules => unreachable!("handled earlier"), 459 | 460 | Command::List => unreachable!("handled earlier if no command"), 461 | 462 | Command::LocalConf { psbt, next_code } => { 463 | let data = load_psbt(&psbt)?; 464 | let psbt_checksum = util::sha256(&data); 465 | 466 | let next_code = match next_code { 467 | Some(next_code) => next_code, 468 | None => { 469 | let policy = cc.hsm_policy()?; 470 | let policy = json::parse(&policy).unwrap(); 471 | policy["next_local_code"].as_str().unwrap().to_owned() 472 | } 473 | }; 474 | 475 | let code = calc_local_pincode(&psbt_checksum, &next_code)?; 476 | println!("{}", code); 477 | } 478 | 479 | Command::Locker { utf8 } => { 480 | let data = cc.locker()?; 481 | let encoded = if utf8 { 482 | String::from_utf8(data).expect("The locker contents are not valid UTF-8") 483 | } else { 484 | hex::encode(&data) 485 | }; 486 | println!("{}", encoded); 487 | } 488 | 489 | Command::Logout => { 490 | cc.logout()?; 491 | eprintln!("OK"); 492 | } 493 | 494 | Command::Message { 495 | message, 496 | path, 497 | address_format, 498 | armor, 499 | } => { 500 | let raw_msg = protocol::Message::new(&message)?; 501 | let path = path 502 | .as_ref() 503 | .map(|p| protocol::DerivationPath::new(p)) 504 | .transpose()?; 505 | 506 | print_waiting(); 507 | cc.sign_message(raw_msg, path, address_format.into())?; 508 | 509 | let (signature, address) = loop { 510 | sleep(); 511 | let signature = cc.get_signed_message()?; 512 | match signature { 513 | Some(SignedMessage { signature, address }) => break (signature, address), 514 | None => continue, 515 | } 516 | }; 517 | 518 | let encoded_sig = b64_encode(&signature); 519 | if armor { 520 | let armor = format!( 521 | "\ 522 | -----BEGIN BITCOIN SIGNED MESSAGE-----\n\ 523 | {message}\n\ 524 | -----BEGIN SIGNATURE-----\n\ 525 | {address}\n\ 526 | {encoded_sig}\n\ 527 | -----END BITCOIN SIGNED MESSAGE-----" 528 | ); 529 | println!("{}", armor); 530 | } else { 531 | println!("{}", encoded_sig); 532 | } 533 | } 534 | 535 | Command::Passphrase { stdin } => { 536 | let pass = if stdin { 537 | let mut pass = String::new(); 538 | std::io::stdin().read_line(&mut pass)?; 539 | pass.trim().to_owned() 540 | } else { 541 | println!("Enter the passphrase (input will not be shown):"); 542 | rpassword::read_password()? 543 | }; 544 | 545 | print_waiting(); 546 | let passphrase = protocol::Passphrase::new(pass)?; 547 | cc.set_passphrase(passphrase)?; 548 | 549 | let xpub = loop { 550 | sleep(); 551 | let xpub = cc.get_passphrase_done()?; 552 | match xpub { 553 | Some(xpub) => break xpub, 554 | None => continue, 555 | } 556 | }; 557 | 558 | eprintln!("The new xpub is:"); 559 | println!("{}", xpub); 560 | } 561 | 562 | Command::Pubkey { path } => { 563 | let path = protocol::DerivationPath::new(&path)?; 564 | let xpub = cc.xpub(Some(path))?; 565 | let pk = util::decode_xpub(&xpub).expect("Unable to decode xpub; Coldcard error"); 566 | let encoded = hex::encode(pk.to_sec1_bytes()); 567 | println!("{encoded}"); 568 | } 569 | 570 | Command::Reboot => { 571 | cc.reboot()?; 572 | eprintln!("Rebooting..."); 573 | } 574 | 575 | Command::Sign { 576 | psbt_in, 577 | psbt_out, 578 | mode, 579 | base64, 580 | miniscript, 581 | } => { 582 | let psbt = load_psbt(&psbt_in)?; 583 | let sign_mode = (&mode).into(); 584 | let miniscript = if let Some(name) = miniscript { 585 | match DescriptorName::new(name) { 586 | Ok(m) => Some(m), 587 | Err(e) => { 588 | eprintln!("ERROR: Invalid miniscript descriptor name: {e:?}."); 589 | return Ok(()); 590 | } 591 | } 592 | } else { 593 | None 594 | }; 595 | 596 | cc.sign_psbt_miniscript(&psbt, sign_mode, miniscript)?; 597 | 598 | let tx = loop { 599 | sleep(); 600 | let tx = cc.get_signed_tx()?; 601 | match tx { 602 | Some(tx) => break tx, 603 | None => continue, 604 | } 605 | }; 606 | 607 | let tx_string = match mode { 608 | SignMode::Visualize => String::from_utf8(tx).unwrap(), 609 | SignMode::VisualizeSigned => String::from_utf8(tx).unwrap(), 610 | SignMode::Finalize if base64 => b64_encode(&tx), 611 | SignMode::Finalize => hex::encode(&tx), 612 | SignMode::Sign => hex::encode(tx), 613 | }; 614 | 615 | if let Some(psbt_out) = psbt_out { 616 | let mut out = File::create(psbt_out)?; 617 | out.write_all(tx_string.as_bytes())?; 618 | } else { 619 | println!("{}", tx_string); 620 | } 621 | } 622 | 623 | Command::Test => { 624 | eprintln!("Testing the connection..."); 625 | cc.test()?; 626 | eprintln!("OK") 627 | } 628 | 629 | Command::Upgrade { path: Some(path) } => { 630 | let firmware = firmware::Firmware::load_dfu(&path)?; 631 | ProgressBar::new(1, 2, "📁 Read ").finish(); 632 | complete_upgrade(cc, firmware, 2)?; 633 | } 634 | 635 | Command::Upgrade { path: None } => { 636 | eprintln!( 637 | "Searching {} for available firmwares...", 638 | console::Style::new() 639 | .underlined() 640 | .bold() 641 | .apply_to(fw_upgrade::DOWNLOADS) 642 | ); 643 | 644 | let releases = match fw_upgrade::Release::find() { 645 | Ok(releases) => releases, 646 | Err(_) => { 647 | eprintln!("Cannot fetch available releases."); 648 | return Ok(()); 649 | } 650 | }; 651 | 652 | let cc_info = cc.version()?; 653 | 654 | let our_fw = cc_info 655 | .lines() 656 | .nth(1) 657 | .and_then(|v| v.parse::().ok()); 658 | let our_model = cc_info.lines().nth(4); 659 | 660 | let bold = console::Style::new().bold(); 661 | 662 | let mode = prompt( 663 | "Auto determine the best firmware or select manually?", 664 | &["auto", "manual"], 665 | ); 666 | 667 | eprintln!( 668 | "Our model and firmware: {} - {}", 669 | bold.apply_to(our_model.unwrap_or("unknown")), 670 | bold.apply_to( 671 | our_fw 672 | .as_ref() 673 | .map(|v| format!("v{}", v)) 674 | .unwrap_or("unknown".to_owned()) 675 | ) 676 | ); 677 | 678 | let release = if mode == "auto" { 679 | match fw_upgrade::best_match(&releases, our_model) { 680 | Some(release) => { 681 | eprintln!( 682 | "Best firmware match: {} ({}) ", 683 | bold.apply_to(format!("v{}", release.version)), 684 | release.name 685 | ); 686 | 687 | if Some(&release.version) <= our_fw.as_ref() { 688 | eprintln!("The device already has the latest firmware."); 689 | return Ok(()); 690 | } 691 | 692 | release 693 | } 694 | None => { 695 | warn( 696 | "Cannot determine a good firmware match for your device. Proceed manually.", 697 | ); 698 | return Ok(()); 699 | } 700 | } 701 | } else { 702 | eprintln!(); 703 | for (i, r) in releases.iter().enumerate() { 704 | eprintln!( 705 | "({i})\t{}\t{}", 706 | r.name, 707 | if r.is_edge { "(* experimental)" } else { "" } 708 | ); 709 | } 710 | 711 | eprintln!(); 712 | warn("Ensure that the chosen firmware is appropriate for your device."); 713 | eprint!( 714 | "Enter a number next to the wanted firmware (0-{}): ", 715 | releases.len() - 1 716 | ); 717 | 718 | let mut choice = String::new(); 719 | std::io::stdin().read_line(&mut choice).unwrap(); 720 | 721 | match choice.trim().parse::() { 722 | Ok(i) if i < releases.len() => { 723 | let r = &releases[i]; 724 | eprintln!("Selected firmware: {} ({}) ", r.version, r.name); 725 | r 726 | } 727 | _ => { 728 | eprintln!("ERROR: Invalid choice."); 729 | return Ok(()); 730 | } 731 | } 732 | }; 733 | 734 | if release.is_edge { 735 | warn("The selected release is \"edge\" (experimental)."); 736 | } 737 | 738 | let choice = prompt("Proceed?", &["Yes", "no"]); 739 | if choice != "Yes" { 740 | eprintln!("Aborted"); 741 | return Ok(()); 742 | } 743 | 744 | let pb = ProgressBar::new(1, 2, "⬇️ Download"); 745 | 746 | let fw_bytes = release 747 | .download(|downloaded, total| { 748 | pb.update(downloaded as u64, total as u64); 749 | }) 750 | .unwrap(); 751 | 752 | pb.finish(); 753 | 754 | let pb = ProgressBar::new(2, 3, "🔒 Verify "); 755 | 756 | match release.verify(&fw_bytes) { 757 | Ok(_) => pb.finish(), 758 | Err(err) => { 759 | pb.finish_with_err(err); 760 | return Ok(()); 761 | } 762 | } 763 | 764 | let firmware = firmware::Firmware::parse_dfu(&mut std::io::Cursor::new(fw_bytes))?; 765 | 766 | complete_upgrade(cc, firmware, 3)?; 767 | } 768 | 769 | Command::User { 770 | username, 771 | auth_mode, 772 | stdout, 773 | } => { 774 | let validated_username = protocol::Username::new(&username)?; 775 | let auth_mode: protocol::AuthMode = auth_mode.into(); 776 | 777 | let secret = cc.create_username(validated_username, auth_mode, !stdout)?; 778 | 779 | if let Some(secret) = secret { 780 | let serial = cc.serial_number(); 781 | let mode = match auth_mode { 782 | protocol::AuthMode::TOTP => "totp", 783 | protocol::AuthMode::HOTP => "hotp", 784 | protocol::AuthMode::HMAC => "hmac", 785 | }; 786 | match auth_mode { 787 | protocol::AuthMode::HMAC => println!("{}", secret), 788 | _ => println!( 789 | "otpauth://{mode}/{username}?secret={secret}&issuer=Coldcard%20{serial}" 790 | ), 791 | } 792 | } else { 793 | eprintln!("OK - the secret is shown on the device"); 794 | } 795 | } 796 | 797 | Command::Version => { 798 | let version = cc.version()?; 799 | println!("{}", version); 800 | } 801 | 802 | Command::Xfp => { 803 | if let Some(XpubInfo { fingerprint, .. }) = xpub_info { 804 | let hex = hex::encode_upper(fingerprint); 805 | println!("{}", hex); 806 | } 807 | } 808 | 809 | Command::Xpub { path, version, xfp } => { 810 | let path = path 811 | .map(|p| protocol::DerivationPath::new(&p)) 812 | .transpose()?; 813 | let mut xpub = cc.xpub(path)?; 814 | 815 | if xfp { 816 | let pk = util::decode_xpub(&xpub).expect("Unable to decode xpub; Coldcard error"); 817 | let xfp = util::xfp(&pk); 818 | let hex = hex::encode_upper(xfp); 819 | println!("{}", hex); 820 | } 821 | 822 | if let Some(version) = version { 823 | xpub = xpub_version::convert_bytes(&xpub, version.into())?; 824 | } 825 | println!("{}", xpub); 826 | } 827 | Command::MiniscriptList => { 828 | let resp = cc.miniscript_list()?; 829 | for m in resp { 830 | println!("{m}"); 831 | } 832 | } 833 | Command::MiniscriptDelete { name } => { 834 | let descriptor_name = match DescriptorName::new(name.clone()) { 835 | Ok(n) => n, 836 | Err(e) => { 837 | eprintln!("ERROR: Invalid miniscript descriptor name: {e:?}."); 838 | return Ok(()); 839 | } 840 | }; 841 | if let Err(e) = cc.delete_miniscript(descriptor_name) { 842 | eprintln!("ERROR: Fail to delete descriptor {name}: {e:?}") 843 | } 844 | } 845 | Command::MiniscriptPolicy { name } => { 846 | let descriptor_name = match DescriptorName::new(name.clone()) { 847 | Ok(n) => n, 848 | Err(e) => { 849 | eprintln!("ERROR: Invalid miniscript descriptor name: {e:?}."); 850 | return Ok(()); 851 | } 852 | }; 853 | match cc.bip388_policy_get(descriptor_name) { 854 | Ok(Some(p)) => println!("{p}"), 855 | Err(e) => { 856 | eprintln!("ERROR: Fail to get miniscript policy for name {name}: {e:?}") 857 | } 858 | _ => { 859 | eprintln!("ERROR: No descriptor found with name {name}"); 860 | } 861 | } 862 | } 863 | Command::Restore { 864 | password, 865 | path, 866 | plaintext, 867 | tmp, 868 | } => { 869 | if !path.exists() { 870 | eprintln!("ERROR: Path {} does not exists", path.to_str().unwrap()); 871 | return Ok(()); 872 | } 873 | if !path.is_file() { 874 | eprintln!("ERROR: Path {} is not a file", path.to_str().unwrap()); 875 | return Ok(()); 876 | } 877 | let mut file = match File::open(path.clone()) { 878 | Ok(f) => f, 879 | Err(e) => { 880 | eprintln!("ERROR: Fail to open file {}: {e}", path.to_str().unwrap()); 881 | return Ok(()); 882 | } 883 | }; 884 | let mut data = vec![]; 885 | let _ = file.read_to_end(&mut data); 886 | 887 | cc.restore_backup(&data, password, plaintext, tmp)? 888 | } 889 | } 890 | 891 | Ok(()) 892 | } 893 | 894 | fn now() -> u64 { 895 | use std::time::SystemTime; 896 | use std::time::UNIX_EPOCH; 897 | let now = SystemTime::now(); 898 | let now = now.duration_since(UNIX_EPOCH).expect("Time error"); 899 | now.as_secs() 900 | } 901 | 902 | fn calc_local_pincode(psbt_checksum: &[u8; 32], next_code: &str) -> Result { 903 | let key = b64_decode(next_code).map_err(|_| Error::InvalidBase64)?; 904 | let digest = hmac_sha256::HMAC::mac(psbt_checksum, key); 905 | let last = digest[28..32].try_into().expect("cannot fail"); 906 | let num = (u32::from_be_bytes(last) & 0x7FFFFFFF) % 1000000; 907 | Ok(format!("{:#06}", num)) 908 | } 909 | 910 | fn sleep() { 911 | std::thread::sleep(std::time::Duration::from_millis(250)); 912 | } 913 | 914 | fn parse_6_digit_token(s: &str) -> Result { 915 | if s.len() == 6 && s.chars().all(|c| c.is_ascii_digit()) { 916 | Ok(protocol::AuthToken::new(s)?) 917 | } else { 918 | Err(Error::NotAuthToken) 919 | } 920 | } 921 | 922 | fn load_psbt(path: &PathBuf) -> Result, Error> { 923 | let mut file = File::open(path)?; 924 | let mut header = vec![0_u8; 10]; 925 | file.read_exact(&mut header)?; 926 | file.seek(SeekFrom::Start(0))?; 927 | let mut data = vec![]; 928 | file.read_to_end(&mut data)?; 929 | 930 | fn trimmed(d: &mut Vec) { 931 | // the danger here is the user will paste into something like Vim 932 | // which will append a newline (invalid in both b64 or hex) 933 | while let Some(c) = d.last() { 934 | if c == &b'\n' || c == &b'\r' { 935 | d.pop(); 936 | } else { 937 | break; 938 | } 939 | } 940 | } 941 | 942 | if &header[..10] == b"70736274ff" || &header[..10] == b"70736274FF" { 943 | trimmed(&mut data); 944 | hex::decode(&data).map_err(|_| Error::InvalidPSBT) 945 | } else if &header[..6] == b"cHNidP" { 946 | trimmed(&mut data); 947 | b64_decode(&data).map_err(|_| Error::InvalidPSBT) 948 | } else if &header[..5] == b"psbt\xff" { 949 | Ok(data) 950 | } else { 951 | Err(Error::InvalidPSBT) 952 | } 953 | } 954 | 955 | fn complete_upgrade( 956 | mut cc: coldcard::Coldcard, 957 | firmware: firmware::Firmware, 958 | step: u16, 959 | ) -> Result<(), Error> { 960 | let pb = ProgressBar::new(step, step, "💾 Flash "); 961 | let size = firmware.bytes().len(); 962 | 963 | cc.upgrade(firmware, |uploaded, _| { 964 | pb.update(uploaded as u64, size as u64); 965 | })?; 966 | 967 | pb.finish(); 968 | 969 | eprintln!("Proceed on the Coldcard to complete the process."); 970 | cc.reboot()?; 971 | 972 | Ok(()) 973 | } 974 | 975 | fn print_waiting() { 976 | eprintln!("Waiting for OK on the Coldcard..."); 977 | } 978 | 979 | fn warn(text: &str) { 980 | let prefix = console::Style::new().color256(202).bold(); 981 | eprintln!("{} {}", prefix.apply_to("WARNING:"), text); 982 | } 983 | 984 | #[allow(dead_code)] 985 | #[derive(Debug)] 986 | enum Error { 987 | Coldcard(coldcard::Error), 988 | Derivation(derivation_path::Error), 989 | Encode(protocol::EncodeError), 990 | Firmware(firmware::Error), 991 | UnexpectedResponse(protocol::Response), 992 | MitmInProgress, 993 | Io(std::io::Error), 994 | InvalidBase64, 995 | NotAuthToken, 996 | InvalidPSBT, 997 | NoColdcardDetected, 998 | VersionConvert(xpub_version::Error), 999 | } 1000 | 1001 | impl From for Error { 1002 | fn from(error: coldcard::Error) -> Self { 1003 | Self::Coldcard(error) 1004 | } 1005 | } 1006 | 1007 | impl From for Error { 1008 | fn from(error: derivation_path::Error) -> Self { 1009 | Self::Derivation(error) 1010 | } 1011 | } 1012 | 1013 | impl From for Error { 1014 | fn from(error: firmware::Error) -> Self { 1015 | Self::Firmware(error) 1016 | } 1017 | } 1018 | 1019 | impl From for Error { 1020 | fn from(response: Response) -> Self { 1021 | Self::UnexpectedResponse(response) 1022 | } 1023 | } 1024 | 1025 | impl From for Error { 1026 | fn from(error: protocol::EncodeError) -> Self { 1027 | Self::Encode(error) 1028 | } 1029 | } 1030 | 1031 | impl From for Error { 1032 | fn from(error: std::io::Error) -> Self { 1033 | Self::Io(error) 1034 | } 1035 | } 1036 | 1037 | impl From for Error { 1038 | fn from(error: xpub_version::Error) -> Self { 1039 | Self::VersionConvert(error) 1040 | } 1041 | } 1042 | 1043 | fn prompt<'a>(text: &'static str, choices: &'a [&str]) -> &'a str { 1044 | let bold = console::Style::new().bold(); 1045 | 1046 | let formatted = choices 1047 | .iter() 1048 | .map(|c| { 1049 | let (first, rest) = c.split_at(1); 1050 | format!("[{}]{}", bold.apply_to(first), rest) 1051 | }) 1052 | .collect::>() 1053 | .join("/"); 1054 | 1055 | let mut input = String::new(); 1056 | loop { 1057 | eprint!("{text} {formatted} "); 1058 | input.clear(); 1059 | std::io::stdin().read_line(&mut input).unwrap(); 1060 | let choice = input.trim().chars().next(); 1061 | if let Some(i) = choices.iter().position(|c| c.chars().next() == choice) { 1062 | return choices[i]; 1063 | } 1064 | } 1065 | } 1066 | 1067 | struct ProgressBar { 1068 | pb: indicatif::ProgressBar, 1069 | } 1070 | 1071 | impl ProgressBar { 1072 | pub fn new(step: u16, steps: u16, action: &'static str) -> Self { 1073 | const PROG_TEMPLATE: &str = "{prefix:.bold}: {spinner:.green} [{bar:40.green}] [{percent}%] ({bytes}/{total_bytes})"; 1074 | 1075 | Self { 1076 | pb: indicatif::ProgressBar::new(100) 1077 | .with_style(indicatif::ProgressStyle::with_template(PROG_TEMPLATE).unwrap()) 1078 | .with_finish(indicatif::ProgressFinish::Abandon) 1079 | .with_prefix(format!("[{step}/{steps}] {action}")), 1080 | } 1081 | } 1082 | 1083 | fn update(&self, pos: u64, length: u64) { 1084 | self.pb.update(|s| { 1085 | s.set_pos(pos); 1086 | s.set_len(length); 1087 | }); 1088 | } 1089 | 1090 | pub fn finish(self) { 1091 | const FIN_TEMPLATE: &str = "{prefix:.bold}: Complete ✅"; 1092 | 1093 | self.pb 1094 | .set_style(indicatif::ProgressStyle::with_template(FIN_TEMPLATE).unwrap()); 1095 | self.pb.tick(); 1096 | } 1097 | 1098 | pub fn finish_with_err(self, err: &str) { 1099 | let err = format!("{{prefix:.bold}}: {} ❌", err); 1100 | 1101 | self.pb 1102 | .set_style(indicatif::ProgressStyle::with_template(&err).unwrap()); 1103 | self.pb.tick(); 1104 | } 1105 | } 1106 | 1107 | fn b64_encode(data: &[u8]) -> String { 1108 | use base64::Engine; 1109 | base64::engine::general_purpose::STANDARD.encode(data) 1110 | } 1111 | 1112 | fn b64_decode(data: impl AsRef<[u8]>) -> Result, base64::DecodeError> { 1113 | use base64::Engine; 1114 | base64::engine::general_purpose::STANDARD.decode(data) 1115 | } 1116 | -------------------------------------------------------------------------------- /coldcard/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Coldcard interface library in Rust. 2 | //! 3 | //! This library provides interfacing functionality for the Coldcard hardware wallet. 4 | //! It automatically sets up an encrypted communication channel using ECDH 5 | //! that cannot be turned off, so MITM mitigation is possible out of the box 6 | //! using the `check_mitm` method. 7 | //! 8 | //! It does not validate that a particular function is available on a particular 9 | //! Coldcard (due to firmware differences), so that is left to the user to explore. 10 | //! 11 | //! ```no_run 12 | //! use coldcard::protocol; 13 | //! 14 | //! # fn main() -> Result<(), coldcard::Error> { 15 | //! // create an API instance 16 | //! let mut api = coldcard::Api::new()?; 17 | //! 18 | //! // detect all connected Coldcards 19 | //! let serials = api.detect()?; 20 | //! 21 | //! // get the first serial and open it 22 | //! let (mut cc, master_xpub) = api.open(&serials[0], None)?; 23 | //! 24 | //! // set a passphrase 25 | //! cc.set_passphrase(protocol::Passphrase::new("secret")?)?; 26 | //! 27 | //! // after the user confirms 28 | //! let xpub = cc.get_passphrase_done()?; 29 | //! 30 | //! if let Some(xpub) = xpub { 31 | //! println!("The new XPUB is: {}", xpub); 32 | //! } 33 | //! 34 | //! // secure logout 35 | //! cc.logout()?; 36 | //! 37 | //! # Ok(()) 38 | //! # } 39 | //! ``` 40 | pub mod constants; 41 | pub mod firmware; 42 | pub mod protocol; 43 | pub mod util; 44 | 45 | use protocol::{DerivationPath, DescriptorName, Request, Response, Username}; 46 | use util::{MaybeOwned, parse_string_vec}; 47 | 48 | type Aes256Ctr = ctr::Ctr64BE; 49 | 50 | /// Coinkite's HID vendor id. 51 | pub const COINKITE_VID: u16 = 0xd13e; 52 | /// Coldcard's HID product id. 53 | pub const CKCC_PID: u16 = 0xcc10; 54 | 55 | /// API for interacting with Coldcard devices. 56 | pub struct Api<'a>(MaybeOwned<'a, hidapi::HidApi>); 57 | 58 | impl<'a> Api<'a> { 59 | /// Creates a new API. 60 | /// 61 | /// It is safe to call this multiple times since each call will create a new backend with its 62 | /// own device list (using the same hidapi backend). 63 | pub fn new() -> Result { 64 | Ok(Self(MaybeOwned::Owned(hidapi::HidApi::new()?))) 65 | } 66 | 67 | /// Creates a new API from a borrowed `HidApi` instance. 68 | /// 69 | /// This is useful in scenarios where an external `HidApi` already exists and the caller 70 | /// wishes to reuse it. 71 | pub fn from_borrowed(api: &'a mut hidapi::HidApi) -> Self { 72 | Self(MaybeOwned::Borrowed(api)) 73 | } 74 | 75 | /// Detects connected Coldcard devices and returns a vector of their serial numbers. 76 | /// 77 | /// **If a Coldcard isn't being detected on Linux, check the udev instructions.** 78 | pub fn detect(&mut self) -> Result, Error> { 79 | self.0.as_mut().refresh_devices()?; 80 | 81 | let serials = self 82 | .0 83 | .as_ref() 84 | .device_list() 85 | .filter(|dev| { 86 | #[cfg(feature = "log")] 87 | log::trace!( 88 | "Detected HID device: vid={} pid={} vendor={} sn={}", 89 | dev.vendor_id(), 90 | dev.product_id(), 91 | dev.manufacturer_string().unwrap_or_default(), 92 | dev.serial_number().unwrap_or_default() 93 | ); 94 | 95 | dev.vendor_id() == COINKITE_VID && dev.product_id() == CKCC_PID 96 | }) 97 | .map(|cc| SerialNumber(cc.serial_number().unwrap_or_default().to_owned())) 98 | .collect(); 99 | 100 | Ok(serials) 101 | } 102 | 103 | /// Opens a Coldcard with a particular serial number and optionally some options. 104 | /// If no serial number is known, use the `Api::detect()` method to detect connected 105 | /// Coldcard devices. Returns an optional `XpubInfo` in case the device is 106 | /// already initialized with a secret. 107 | pub fn open( 108 | &self, 109 | sn: impl AsRef, 110 | opts: Option, 111 | ) -> Result<(Coldcard, Option), Error> { 112 | Coldcard::open(self, sn, opts) 113 | } 114 | 115 | /// Checks whether a Coldcard with a particular serial number is present. 116 | /// 117 | /// This is useful when the wanted serial is already known. 118 | pub fn is_present(&mut self, sn: impl AsRef) -> Result { 119 | self.0.as_mut().refresh_devices()?; 120 | 121 | Ok(self.0.as_ref().device_list().any(|dev| { 122 | dev.serial_number() == Some(sn.as_ref()) 123 | && dev.vendor_id() == COINKITE_VID 124 | && dev.product_id() == CKCC_PID 125 | })) 126 | } 127 | } 128 | 129 | impl AsRef for Api<'_> { 130 | fn as_ref(&self) -> &hidapi::HidApi { 131 | self.0.as_ref() 132 | } 133 | } 134 | 135 | /// Specifies various options that a Coldcard can be opened with. 136 | #[derive(Debug)] 137 | pub struct Options { 138 | pub encrypt_version: u32, 139 | pub resync_on_open: bool, 140 | } 141 | 142 | impl Default for Options { 143 | fn default() -> Self { 144 | Self { 145 | encrypt_version: 1, 146 | resync_on_open: false, 147 | } 148 | } 149 | } 150 | 151 | /// Represents a particular Coldcard serial number. 152 | #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] 153 | pub struct SerialNumber(String); 154 | 155 | impl AsRef for SerialNumber { 156 | fn as_ref(&self) -> &str { 157 | &self.0 158 | } 159 | } 160 | 161 | /// B58 encoded xpub and its fingerprint. 162 | #[derive(Debug)] 163 | pub struct XpubInfo { 164 | pub xpub: String, 165 | pub fingerprint: [u8; 4], 166 | } 167 | 168 | /// Signed message (binary) and the address that signed it. 169 | #[derive(Debug)] 170 | pub struct SignedMessage { 171 | pub signature: [u8; 65], 172 | pub address: String, 173 | } 174 | 175 | /// Backup bytes and their checksum as calculated by Coldcard. 176 | #[derive(Debug)] 177 | pub struct Backup { 178 | pub data: Vec, 179 | pub sha256: [u8; 32], 180 | } 181 | 182 | /// Signing mode for PSBT. 183 | #[derive(Debug)] 184 | #[repr(u32)] 185 | pub enum SignMode { 186 | Visualize = constants::STXN_VISUALIZE, 187 | VisualizeSigned = constants::STXN_VISUALIZE | constants::STXN_SIGNED, 188 | Signed = constants::STXN_SIGNED, 189 | Finalize = constants::STXN_FINALIZE, 190 | } 191 | 192 | /// Connected and initialized Coldcard device ready for use. 193 | pub struct Coldcard { 194 | cc: hidapi::HidDevice, 195 | session_key: [u8; 32], 196 | encrypt: Aes256Ctr, 197 | decrypt: Aes256Ctr, 198 | sn: String, 199 | 200 | // performance helpers 201 | read_buf: [u8; 64], 202 | send_buf: [u8; 2 + constants::CHUNK_SIZE], 203 | } 204 | 205 | impl Coldcard { 206 | /// Opens a Coldcard with a particular serial number and optionally some options. 207 | /// If no serial number is known, use the `Api::detect()` method to detect connected 208 | /// Coldcard devices. Returns an optional `XpubInfo` in case the device is 209 | /// already initialized with a secret. 210 | pub fn open( 211 | api: impl AsRef, 212 | sn: impl AsRef, 213 | opts: Option, 214 | ) -> Result<(Self, Option), Error> { 215 | let mut cc = api 216 | .as_ref() 217 | .open_serial(COINKITE_VID, CKCC_PID, sn.as_ref())?; 218 | 219 | #[cfg(feature = "log")] 220 | log::info!("opened SN {} with opts: {:?}", sn.as_ref(), opts); 221 | let opts = opts.unwrap_or_default(); 222 | 223 | let mut read_buf = [0_u8; 64]; 224 | let mut send_buf = [0_u8; 2 + constants::CHUNK_SIZE]; 225 | 226 | if opts.resync_on_open { 227 | resync(&mut cc, &mut read_buf)?; 228 | } 229 | 230 | let mut rng = rand::rngs::ThreadRng::default(); 231 | let our_sk = k256::SecretKey::random(&mut rng); 232 | let our_pk = our_sk.public_key(); 233 | 234 | use k256::elliptic_curve::sec1::ToEncodedPoint; 235 | let encrypt_start = Request::EncryptStart { 236 | device_pubkey: our_pk.to_encoded_point(false).as_bytes()[1..] 237 | .try_into() 238 | .map_err(|_| k256::elliptic_curve::Error)?, 239 | version: Some(opts.encrypt_version), 240 | }; 241 | 242 | send(encrypt_start, &mut cc, None, &mut send_buf)?; 243 | let (cc_pk, xpub_fingerprint, xpub) = recv(&mut cc, None, &mut read_buf)?.into_my_pub()?; 244 | 245 | // this is because the Coldcard returns a 64 byte pk (no sec1 0x04 prefix) 246 | let mut prefixed_cc_pk = [0_u8; 65]; 247 | prefixed_cc_pk[0] = 0x04; 248 | prefixed_cc_pk[1..].copy_from_slice(&cc_pk); 249 | 250 | let cc_pk = k256::PublicKey::from_sec1_bytes(&prefixed_cc_pk)?; 251 | let session_key = session_key(our_sk, cc_pk)?; 252 | 253 | #[allow(deprecated)] 254 | let (encrypt, decrypt) = { 255 | use aes::cipher::{KeyIvInit, generic_array::GenericArray}; 256 | 257 | let key = GenericArray::from_slice(&session_key); 258 | let nonce = GenericArray::from_slice(&[0_u8; 16]); 259 | 260 | (Aes256Ctr::new(key, nonce), Aes256Ctr::new(key, nonce)) 261 | }; 262 | 263 | cc.set_blocking_mode(true)?; 264 | 265 | let cc = Self { 266 | cc, 267 | session_key, 268 | encrypt, 269 | decrypt, 270 | read_buf, 271 | send_buf, 272 | sn: sn.as_ref().to_owned(), 273 | }; 274 | 275 | Ok(( 276 | cc, 277 | xpub.map(|xpub| XpubInfo { 278 | xpub, 279 | fingerprint: xpub_fingerprint, 280 | }), 281 | )) 282 | } 283 | 284 | /// Sends a request and immediately reads a response. 285 | fn send(&mut self, request: Request) -> Result { 286 | send( 287 | request, 288 | &mut self.cc, 289 | Some(&mut self.encrypt), 290 | &mut self.send_buf, 291 | )?; 292 | recv(&mut self.cc, Some(&mut self.decrypt), &mut self.read_buf) 293 | } 294 | 295 | /// Checks if the communication line is undergoing a MITM attack. 296 | /// Returns `Ok(true)` if MITM is in progress or `Ok(false)` if not. 297 | pub fn check_mitm(&mut self, expected_xpub: &str) -> Result { 298 | use k256::ecdsa::Signature; 299 | use k256::ecdsa::signature::hazmat::PrehashVerifier; 300 | 301 | let pk = util::decode_xpub(expected_xpub).ok_or(Error::NoSecretOnDevice)?; 302 | let verifying_key = k256::ecdsa::VerifyingKey::from(pk); 303 | 304 | let (r, s): ([u8; 32], [u8; 32]) = match self.send(Request::CheckMitm)? { 305 | Response::Binary(sig) if sig.len() == 65 => { 306 | let (r, s) = sig[1..].split_at(32); 307 | Ok((r.try_into().unwrap(), s.try_into().unwrap())) 308 | } 309 | _ => Err(Error::NoSecretOnDevice), 310 | }?; 311 | 312 | let sig = Signature::from_scalars(r, s).map_err(|_| k256::elliptic_curve::Error)?; 313 | 314 | let verified = verifying_key.verify_prehash(&self.session_key, &sig); 315 | 316 | Ok(verified.is_err()) 317 | } 318 | 319 | /// Uploads a file and verifies the checksum. Returns the checksum 320 | /// calculated by the device. Fails on checksum verification failure. 321 | /// 322 | /// `progress` is a closure whose first argument is the number of bytes that have been uploaded 323 | /// and the second one is the total number of bytes. 324 | pub fn upload( 325 | &mut self, 326 | data: &[u8], 327 | mut progress: F, 328 | ) -> Result<[u8; 32], Error> { 329 | let checksum = util::sha256(data); 330 | let mut uploaded = 0; 331 | 332 | for (i, blk) in data.chunks(constants::MAX_BLK_LEN).enumerate() { 333 | let blk_offset = (i * constants::MAX_BLK_LEN) as u32; 334 | let pos = self 335 | .send(Request::Upload { 336 | offset: blk_offset, 337 | total_size: data.len() as u32, 338 | data: protocol::Upload::new(blk)?, 339 | })? 340 | .into_int1()?; 341 | 342 | if pos != blk_offset { 343 | return Err(Error::TransmissionFailed); 344 | } 345 | 346 | uploaded += blk.len(); 347 | progress(uploaded, data.len()); 348 | } 349 | 350 | let uploaded_checksum = self.send(Request::Sha256)?.into_binary()?; 351 | if checksum != uploaded_checksum.as_slice() { 352 | return Err(Error::ChecksumMismatch); 353 | } 354 | 355 | Ok(uploaded_checksum 356 | .try_into() 357 | .expect("Did not get a 32 byte checksum back; Coldcard error")) 358 | } 359 | 360 | /// Downloads a single file with a known checksum. Fails on checksum 361 | /// verification failure. 362 | pub fn download( 363 | &mut self, 364 | length: u32, 365 | checksum: &[u8], 366 | file_number: protocol::FileNo, 367 | ) -> Result, Error> { 368 | let mut data = Vec::with_capacity(length as usize); 369 | 370 | let mut hash_engine = util::Sha256Engine::default(); 371 | 372 | let mut pos = 0; 373 | while pos < length { 374 | let blk_len = constants::MAX_BLK_LEN.min((length - pos) as usize) as u32; 375 | let here = self 376 | .send(Request::Download { 377 | offset: pos, 378 | length: blk_len, 379 | file_number, 380 | })? 381 | .into_binary()?; 382 | 383 | data.extend_from_slice(here.as_slice()); 384 | hash_engine.update(here.as_slice()); 385 | pos += here.len() as u32; 386 | if here.is_empty() { 387 | return Err(Error::TransmissionFailed); 388 | } 389 | } 390 | 391 | let actual_checksum = hash_engine.finalize(); 392 | if actual_checksum == checksum { 393 | Ok(data) 394 | } else { 395 | Err(Error::ChecksumMismatch) 396 | } 397 | } 398 | 399 | /// Resyncs the Coldcard by sending a magic packet and discarding 400 | /// data until it is ready for use again. Normally no need to use this. 401 | pub fn resync(&mut self) -> Result<(), Error> { 402 | resync(&mut self.cc, &mut self.read_buf) 403 | } 404 | 405 | // Regular operations follow. 406 | 407 | /// Gets an address given a derivation path and address format. 408 | pub fn address( 409 | &mut self, 410 | subpath: protocol::DerivationPath, 411 | addr_fmt: protocol::AddressFormat, 412 | ) -> Result { 413 | self.send(Request::ShowAddress { subpath, addr_fmt })? 414 | .into_ascii() 415 | .map_err(Error::from) 416 | } 417 | 418 | /// Gets the bag number the Coldcard arrived in. 419 | pub fn bag_number(&mut self) -> Result { 420 | self.send(Request::BagNumber(None))? 421 | .into_ascii() 422 | .map_err(Error::from) 423 | } 424 | 425 | /// Gets the BIP-0388 wallet policy of a given wallet 426 | pub fn bip388_policy_get( 427 | &mut self, 428 | descriptor_name: DescriptorName, 429 | ) -> Result, Error> { 430 | let response = match self.send(Request::MiniscriptPolicy { descriptor_name }) { 431 | Ok(response) => response, 432 | Err(Error::Decoding(protocol::DecodeError::Protocol(e))) => { 433 | // FIXME: 434 | if e == "Miniscript wallet not found" { 435 | return Ok(None); 436 | } else { 437 | return Err(Error::Decoding(protocol::DecodeError::Protocol(e))); 438 | } 439 | } 440 | Err(e) => { 441 | return Err(e); 442 | } 443 | }; 444 | response.into_ascii().map(Some).map_err(Error::from) 445 | } 446 | 447 | /// Gets the name of the blockchain the Colcard is set to operate on. 448 | pub fn blockchain(&mut self) -> Result { 449 | self.send(Request::Blockchain)? 450 | .into_ascii() 451 | .map_err(Error::from) 452 | } 453 | 454 | /// Creates a new username on the Coldcard. If a QR code is requested, the generated 455 | /// secret is displayed only on the device. Otherwise it is returned in the `Ok(...)` variant. 456 | pub fn create_username( 457 | &mut self, 458 | username: Username, 459 | auth_mode: protocol::AuthMode, 460 | show_qr: bool, 461 | ) -> Result, Error> { 462 | let secret = self 463 | .send(Request::CreateUser { 464 | username, 465 | auth_mode, 466 | secret: None, 467 | show_qr, 468 | })? 469 | .into_ascii()?; 470 | 471 | Ok((!secret.is_empty()).then_some(secret)) 472 | } 473 | 474 | /// Delete a registered miniscript descriptor 475 | pub fn delete_miniscript(&mut self, descriptor_name: DescriptorName) -> Result<(), Error> { 476 | if descriptor_name.0.len() > 40 || !descriptor_name.0.is_ascii() { 477 | return Err(Error::DescriptorName); 478 | } 479 | self.send(Request::MiniscriptDelete { descriptor_name })? 480 | .into_ok() 481 | .map_err(Error::from) 482 | } 483 | 484 | /// Deletes a username, if one exists on the Coldcard. Returns `Ok(())` even 485 | /// if one did not exist. 486 | pub fn delete_username(&mut self, username: Username) -> Result<(), Error> { 487 | self.send(Request::DeleteUser(username))? 488 | .into_ok() 489 | .map_err(Error::from) 490 | } 491 | 492 | /// Gets a backup from the Coldcard, if one was previously initiated. If the result is 493 | /// `Ok(None)`, it can mean that the user has not approved yet or the backup has not completed. 494 | pub fn get_backup(&mut self) -> Result, Error> { 495 | let backup = self.send(Request::GetBackupFile)?; 496 | match backup { 497 | Response::Ok => Ok(None), 498 | Response::TxSigned { length, sha256 } => { 499 | let data = self.download(length, &sha256, protocol::FileNo::Zero)?; 500 | Ok(Some(Backup { data, sha256 })) 501 | } 502 | response => Err(response.into()), 503 | } 504 | } 505 | 506 | /// Gets the new xpub from the Coldcard, upon setting a passphrase. If the result is 507 | /// `Ok(None)`, it can mean that the user has not approved yet. 508 | pub fn get_passphrase_done(&mut self) -> Result, Error> { 509 | let xpub = self.send(Request::GetPassphraseDone)?; 510 | match xpub { 511 | Response::Ok => Ok(None), 512 | Response::Ascii(xpub) => Ok(Some(xpub)), 513 | response => Err(response.into()), 514 | } 515 | } 516 | 517 | /// Gets a signed message from the Coldcard, if any. If the result is `Ok(None)`, it 518 | /// can mean that the user has not approved yet. 519 | pub fn get_signed_message(&mut self) -> Result, Error> { 520 | let sig = self.send(Request::GetSignedMessage)?; 521 | match sig { 522 | Response::Ok => Ok(None), 523 | Response::MessageSigned { address, signature } => { 524 | Ok(Some(SignedMessage { address, signature })) 525 | } 526 | response => Err(response.into()), 527 | } 528 | } 529 | 530 | /// Gets a signed transaction from the Coldcard, if one was previously created. If the result is 531 | /// `Ok(None)`, it can mean that the user has not approved yet or the signing has not completed. 532 | pub fn get_signed_tx(&mut self) -> Result>, Error> { 533 | let tx = self.send(Request::GetSignedTransaction)?; 534 | match tx { 535 | Response::Ok => Ok(None), 536 | Response::TxSigned { length, sha256 } => { 537 | let data = self.download(length, &sha256, protocol::FileNo::One)?; 538 | Ok(Some(data)) 539 | } 540 | response => Err(response.into()), 541 | } 542 | } 543 | 544 | /// Starts the HSM mode given a policy. If the policy is `None`, 545 | /// starts the existing policy already on the device. 546 | pub fn hsm_start(&mut self, policy: Option<&[u8]>) -> Result<(), Error> { 547 | if let Some(policy) = policy { 548 | self.upload(policy, |_, _| {})?; 549 | self.send(Request::HsmStart(Some(protocol::HsmStartParams { 550 | file_sha: util::sha256(policy), 551 | length: policy.len() as u32, 552 | })))? 553 | .into_ok() 554 | } else { 555 | self.send(Request::HsmStart(None))?.into_ok() 556 | } 557 | .map_err(Error::from) 558 | } 559 | 560 | /// Gets the HSM policy file in the JSON format. 561 | pub fn hsm_policy(&mut self) -> Result { 562 | self.send(Request::HsmStatus)? 563 | .into_ascii() 564 | .map_err(Error::from) 565 | } 566 | 567 | /// Gets the value in the storage locker (HSM use). 568 | pub fn locker(&mut self) -> Result, Error> { 569 | self.send(Request::GetStorageLocker)? 570 | .into_binary() 571 | .map_err(Error::from) 572 | } 573 | 574 | /// Securely logs out of the Coldcard. Requires a power cycle to use again. 575 | pub fn logout(mut self) -> Result<(), Error> { 576 | self.send(Request::Logout)?.into_ok().map_err(Error::from) 577 | } 578 | 579 | /// Show miniscript address. 580 | pub fn miniscript_address( 581 | &mut self, 582 | descriptor_name: DescriptorName, 583 | change: bool, 584 | index: u32, 585 | ) -> Result { 586 | self.send(Request::MiniscriptAddress { 587 | descriptor_name, 588 | change, 589 | index, 590 | })? 591 | .into_ascii() 592 | .map_err(Error::from) 593 | } 594 | 595 | /// Enroll miniscript file. 596 | pub fn miniscript_enroll(&mut self, descriptor: &[u8]) -> Result<(), Error> { 597 | let file_sha = self.upload(descriptor, |_, _| {})?; 598 | 599 | self.send(Request::MiniscriptEnroll { 600 | length: descriptor.len() as u32, 601 | file_sha, 602 | })? 603 | .into_ok() 604 | .map_err(Error::from) 605 | } 606 | 607 | /// Get registered descriptor by name. 608 | pub fn miniscript_get( 609 | &mut self, 610 | descriptor_name: DescriptorName, 611 | ) -> Result, Error> { 612 | let response = match self.send(Request::MiniscriptGetDescriptor { descriptor_name }) { 613 | Ok(response) => response, 614 | Err(Error::Decoding(protocol::DecodeError::Protocol(e))) => { 615 | if e == "Miniscript wallet not found" { 616 | return Ok(None); 617 | } else { 618 | return Err(Error::Decoding(protocol::DecodeError::Protocol(e))); 619 | } 620 | } 621 | Err(e) => { 622 | return Err(e); 623 | } 624 | }; 625 | response.into_ascii().map(Some).map_err(Error::from) 626 | } 627 | 628 | /// List miniscript descriptors registered on the device 629 | pub fn miniscript_list(&mut self) -> Result, Error> { 630 | let resp = self.send(Request::MiniscriptList)?.into_ascii()?; 631 | let miniscripts = parse_string_vec(&resp); 632 | Ok(miniscripts) 633 | } 634 | 635 | /// Is there a wallet already that matches M+N and xor(*xfps)? 636 | pub fn multisig_check(&mut self, m: u32, n: u32, xfp_xor: u32) -> Result { 637 | self.send(Request::MultiSigCheck { m, n, xfp_xor })? 638 | .into_int1() 639 | .map(|value| value != 0) 640 | .map_err(Error::from) 641 | } 642 | 643 | /// Start multisig enrollment (multisig details must already be uploaded, 644 | /// this just starts the approval process). 645 | pub fn multisig_enroll(&mut self, length: u32, file_sha: [u8; 32]) -> Result<(), Error> { 646 | self.send(Request::MultisigEnroll { length, file_sha })? 647 | .into_ok() 648 | .map_err(Error::from) 649 | } 650 | 651 | /// Reboots the Coldcard. 652 | pub fn reboot(mut self) -> Result<(), Error> { 653 | self.send(Request::Reboot)?.into_ok().map_err(Error::from) 654 | } 655 | 656 | /// Restore a backup 657 | pub fn restore_backup( 658 | &mut self, 659 | data: &[u8], 660 | // Backup is .7z encrypted with custom password 661 | custom_pwd: bool, 662 | // Backup is clear-text (dev) 663 | plaintext: bool, 664 | // force load as tmp, effective only on seed-less CC 665 | tmp: bool, 666 | ) -> Result<(), Error> { 667 | if custom_pwd && plaintext { 668 | return Err(Error::RestoreBackupFlags); 669 | } 670 | let file_sha = self.upload(data, |_, _| {})?; 671 | 672 | self.send(Request::RestoreBackup { 673 | length: data.len() as u32, 674 | file_sha, 675 | custom_pwd, 676 | plaintext, 677 | tmp, 678 | })? 679 | .into_ok() 680 | .map_err(Error::from) 681 | } 682 | 683 | /// Returns the serial number of this Coldcard. 684 | pub fn serial_number(&self) -> &str { 685 | &self.sn 686 | } 687 | 688 | /// Sets a BIP39 passphrase. Provide an empty passphrase to remove. This does 689 | /// not immediately return the new xpub, use `get_passphrase_done` for that. 690 | pub fn set_passphrase(&mut self, passphrase: protocol::Passphrase) -> Result<(), Error> { 691 | self.send(Request::Bip39Passphrase(passphrase))? 692 | .into_ok() 693 | .map_err(Error::from) 694 | } 695 | 696 | /// Shows a P2SH address for a multisig scenario. 697 | /// The order of xfp paths must match the order of pubkeys in 698 | /// redeem script (after BIP67 sort). This allows for duplicate xfp values. 699 | pub fn show_p2sh_address( 700 | &mut self, 701 | min_signers: u8, 702 | xfp_paths: Vec, 703 | redeem_script: protocol::RedeemScript, 704 | address_format: protocol::AddressFormat, 705 | ) -> Result { 706 | self.send(Request::ShowP2SHAddress { 707 | min_signers, 708 | xfp_paths, 709 | redeem_script, 710 | address_format, 711 | })? 712 | .into_ascii() 713 | .map_err(Error::from) 714 | } 715 | 716 | /// Initiates message signing and causes the Coldcard to prompt the user to confirm. 717 | /// This does not immediately return a signature, use `get_signed_message` for that. 718 | pub fn sign_message( 719 | &mut self, 720 | raw_msg: protocol::Message, 721 | subpath: Option, 722 | addr_fmt: protocol::AddressFormat, 723 | ) -> Result<(), Error> { 724 | let request = Request::SignMessage { 725 | raw_msg, 726 | subpath, 727 | addr_fmt, 728 | }; 729 | 730 | self.send(request)?.into_ok().map_err(Error::from) 731 | } 732 | 733 | /// Initiates PSBT signing and causes the Coldcard to prompt the user to confirm. 734 | /// This does not immediately return a signed tx, use `get_signed_tx` for that. 735 | pub fn sign_psbt(&mut self, psbt: &[u8], sign_mode: SignMode) -> Result<(), Error> { 736 | self.sign_psbt_miniscript(psbt, sign_mode, None) 737 | } 738 | 739 | /// Initiates PSBT signing for a miniscript wallet and causes the Coldcard to 740 | /// prompt the user to confirm. This does not immediately return a signed tx, 741 | /// use `get_signed_tx` for that. 742 | pub fn sign_psbt_miniscript( 743 | &mut self, 744 | psbt: &[u8], 745 | sign_mode: SignMode, 746 | descriptor_name: Option, 747 | ) -> Result<(), Error> { 748 | let file_sha = self.upload(psbt, |_, _| {})?; 749 | 750 | self.send(Request::SignTransaction { 751 | length: psbt.len() as u32, 752 | file_sha, 753 | flags: Some(sign_mode as u32), 754 | descriptor_name, 755 | })? 756 | .into_ok() 757 | .map_err(Error::from) 758 | } 759 | 760 | /// Initiates a backup and causes the Coldcard to prompt the user to confirm. 761 | /// This does not immediately return a backup, use `get_backup` for that. 762 | pub fn start_backup(&mut self) -> Result<(), Error> { 763 | self.send(Request::StartBackup)? 764 | .into_ok() 765 | .map_err(Error::from) 766 | } 767 | 768 | /// Tests the Coldcard and the USB connection by sending predefined data packets. 769 | pub fn test(&mut self) -> Result<(), Error> { 770 | let lengths: Vec = (55..66) 771 | .chain(1013..1024) 772 | .chain(constants::MAX_MSG_LEN - 10..constants::MAX_MSG_LEN - 4) 773 | .collect(); 774 | 775 | use rand::RngCore; 776 | let mut rng = rand::thread_rng(); 777 | 778 | for len in lengths { 779 | let mut ping = vec![0; len]; 780 | rng.fill_bytes(&mut ping); 781 | let pong = self.send(Request::Ping(ping.clone()))?.into_binary()?; 782 | if ping != pong { 783 | return Err(Error::TestFailureWithLength(len)); 784 | } 785 | } 786 | 787 | Ok(()) 788 | } 789 | 790 | /// Upgrades the firmware on the Coldcard. It does not reboot automatically. Reboot must be 791 | /// called to finish the process. 792 | /// 793 | /// `progress` is a closure whose first argument is the number of bytes that have been uploaded 794 | /// and the second one is the total number of bytes. 795 | pub fn upgrade( 796 | &mut self, 797 | firmware: firmware::Firmware, 798 | progress: F, 799 | ) -> Result<(), Error> { 800 | self.upload(firmware.bytes(), progress)?; 801 | Ok(()) 802 | } 803 | 804 | /// Authenticates a user (for HSM). 805 | pub fn user_auth( 806 | &mut self, 807 | username: Username, 808 | token: protocol::AuthToken, 809 | totp_time: u32, 810 | ) -> Result<(), Error> { 811 | let response = self.send(Request::UserAuth { 812 | username, 813 | token, 814 | totp_time, 815 | })?; 816 | 817 | match response { 818 | Response::Ascii(s) if s.is_empty() => Ok(()), 819 | r => Err(r.into()), 820 | } 821 | } 822 | 823 | /// Gets the static version string from the Coldcard. 824 | pub fn version(&mut self) -> Result { 825 | self.send(Request::Version)? 826 | .into_ascii() 827 | .map_err(Error::from) 828 | } 829 | 830 | /// Gets a B58 encoded xpub at some derivation path. Master level if `None`. 831 | pub fn xpub(&mut self, path: Option) -> Result { 832 | self.send(Request::GetXPub(path))? 833 | .into_ascii() 834 | .map_err(Error::from) 835 | } 836 | } 837 | 838 | impl std::fmt::Debug for Coldcard { 839 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 840 | f.debug_struct("Coldcard").field("sn", &self.sn).finish() 841 | } 842 | } 843 | 844 | /// Computes a shared session key using ECDH. 845 | fn session_key(sk: k256::SecretKey, pk: k256::PublicKey) -> Result<[u8; 32], Error> { 846 | use k256::elliptic_curve::sec1::ToEncodedPoint; 847 | 848 | let tweaked_pk = *pk.as_affine() * *sk.to_nonzero_scalar(); 849 | let tweaked_pk = k256::PublicKey::from_affine(tweaked_pk.to_affine())?; 850 | let pt = tweaked_pk.to_encoded_point(false); 851 | 852 | Ok(util::sha256(&pt.as_bytes()[1..])) 853 | } 854 | 855 | /// Sends a request to a Coldcard. 856 | fn send( 857 | request: Request, 858 | cc: &mut hidapi::HidDevice, 859 | cipher: Option<&mut Aes256Ctr>, 860 | send_buf: &mut [u8; 2 + constants::CHUNK_SIZE], 861 | ) -> Result<(), Error> { 862 | let mut data = request.encode(); 863 | let encrypt = cipher.is_some(); 864 | 865 | #[cfg(feature = "log")] 866 | if let Ok(cmd) = std::str::from_utf8(&data[..4]) { 867 | log::debug!( 868 | "sending: command={}, encrypt={}, req_size={}", 869 | cmd, 870 | encrypt, 871 | data.len() 872 | ); 873 | } 874 | 875 | if let Some(cipher) = cipher { 876 | use aes::cipher::StreamCipher; 877 | cipher.apply_keystream(&mut data); 878 | } 879 | 880 | let chunks = data.chunks(constants::CHUNK_SIZE); 881 | let n_chunks = chunks.len(); 882 | for (i, chunk) in chunks.enumerate() { 883 | let is_last = i == n_chunks - 1; 884 | let byte_1 = (chunk.len() as u8) 885 | | if is_last { 886 | 0x80 | if encrypt { 0x40 } else { 0x00 } 887 | } else { 888 | 0x00 889 | }; 890 | 891 | send_buf[0] = 0; 892 | send_buf[1] = byte_1; 893 | send_buf[2..2 + chunk.len()].copy_from_slice(chunk); 894 | 895 | #[cfg(feature = "log")] 896 | log::trace!("writing packet..."); 897 | cc.write(send_buf)?; 898 | 899 | #[cfg(feature = "log")] 900 | log::debug!("packet #{} written out", i); 901 | } 902 | 903 | Ok(()) 904 | } 905 | 906 | /// Reads a response from a Coldcard. 907 | fn recv( 908 | cc: &mut hidapi::HidDevice, 909 | cipher: Option<&mut Aes256Ctr>, 910 | read_buf: &mut [u8; 64], 911 | ) -> Result { 912 | let mut data: Vec = Vec::new(); 913 | let mut packet_no = 0_u32; 914 | 915 | let (data, is_encrypted) = loop { 916 | #[cfg(feature = "log")] 917 | log::trace!("reading packet..."); 918 | let read = cc.read(read_buf)?; 919 | 920 | if read != read_buf.len() { 921 | return Err(Error::TransmissionFailed); 922 | } 923 | let flag = read_buf[0]; 924 | let is_last = flag & 0x80 != 0; 925 | let is_fram = &read_buf[1..5] == b"fram" && packet_no == 0; 926 | // firmware bug mitigation: `fram` responses are one packet but forget to set 0x80 927 | let is_last = is_last || is_fram; 928 | let is_encrypted = flag & 0x40 != 0; 929 | let length = (flag & 0x3f) as usize; 930 | 931 | #[cfg(feature = "log")] 932 | log::debug!("packet #{} read ({} bytes)", packet_no, length); 933 | 934 | // this is a small optimization to avoid vector allocation 935 | // when a response is sufficiently small to fit the buffer 936 | if data.is_empty() && is_last { 937 | break (&mut read_buf[1..1 + length], is_encrypted); 938 | } else { 939 | data.extend(&read_buf[1..1 + length]); 940 | if is_last { 941 | break (&mut data, is_encrypted); 942 | } 943 | } 944 | 945 | packet_no += 1; 946 | }; 947 | 948 | if is_encrypted { 949 | if let Some(cipher) = cipher { 950 | use aes::cipher::StreamCipher; 951 | cipher.apply_keystream(data); 952 | } else { 953 | return Err(Error::EncryptionNotSetUp); 954 | } 955 | } 956 | 957 | #[cfg(feature = "log")] 958 | { 959 | match std::str::from_utf8(&data[..4]) { 960 | Ok(cmd) => { 961 | log::debug!( 962 | "received: cmd={}, encrypt={}, resp_size={}", 963 | cmd, 964 | is_encrypted, 965 | data.len() 966 | ) 967 | } 968 | Err(_) => log::warn!( 969 | "received: unknown frame, encrypt={}, resp_size{}", 970 | is_encrypted, 971 | data.len() 972 | ), 973 | } 974 | } 975 | 976 | Response::decode(data).map_err(Error::Decoding) 977 | } 978 | 979 | /// Resyncs a Coldcard. Can block for short periods of time. 980 | fn resync(cc: &mut hidapi::HidDevice, read_buf: &mut [u8; 64]) -> Result<(), Error> { 981 | #[cfg(feature = "log")] 982 | log::debug!("resyncing"); 983 | fn read_junk( 984 | cc: &mut hidapi::HidDevice, 985 | read_buf: &mut [u8; 64], 986 | ) -> Result<(), hidapi::HidError> { 987 | loop { 988 | let read = cc.read_timeout(read_buf, 100)?; 989 | if read == 0 { 990 | break; 991 | } 992 | } 993 | Ok(()) 994 | } 995 | 996 | read_junk(cc, read_buf)?; 997 | 998 | let mut special_packet = [0xff_u8, 65]; 999 | special_packet[0] = 0x00; 1000 | special_packet[1] = 0x80; 1001 | cc.write(&special_packet)?; 1002 | 1003 | read_junk(cc, read_buf)?; 1004 | 1005 | Ok(()) 1006 | } 1007 | 1008 | /// Any type of error that can occur while a Coldcard is being used. 1009 | #[derive(Debug)] 1010 | pub enum Error { 1011 | UnexpectedResponse(Response), 1012 | Encoding(protocol::EncodeError), 1013 | Decoding(protocol::DecodeError), 1014 | DerivationPath(protocol::derivation_path::Error), 1015 | Hid(hidapi::HidError), 1016 | EncryptionNotSetUp, 1017 | Secp256k1, 1018 | NoSecretOnDevice, 1019 | ChecksumMismatch, 1020 | TransmissionFailed, 1021 | TestFailureWithLength(usize), 1022 | RestoreBackupFlags, 1023 | DescriptorName, 1024 | } 1025 | 1026 | impl std::fmt::Display for Error { 1027 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 1028 | write!(f, "{:?}", self) 1029 | } 1030 | } 1031 | 1032 | impl std::error::Error for Error { 1033 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 1034 | None 1035 | } 1036 | } 1037 | 1038 | impl From for Error { 1039 | fn from(error: Response) -> Self { 1040 | Self::UnexpectedResponse(error) 1041 | } 1042 | } 1043 | 1044 | impl From for Error { 1045 | fn from(error: protocol::EncodeError) -> Self { 1046 | Self::Encoding(error) 1047 | } 1048 | } 1049 | 1050 | impl From for Error { 1051 | fn from(error: protocol::derivation_path::Error) -> Self { 1052 | Self::DerivationPath(error) 1053 | } 1054 | } 1055 | 1056 | impl From for Error { 1057 | fn from(error: hidapi::HidError) -> Self { 1058 | Error::Hid(error) 1059 | } 1060 | } 1061 | 1062 | impl From for Error { 1063 | fn from(_: k256::elliptic_curve::Error) -> Self { 1064 | Error::Secp256k1 1065 | } 1066 | } 1067 | 1068 | #[cfg(test)] 1069 | mod tests { 1070 | use super::*; 1071 | 1072 | #[test] 1073 | fn session_key_test() { 1074 | // Test vectors generated using Python's ECDSA library. 1075 | 1076 | let sk = k256::SecretKey::from_slice(&[ 1077 | 54, 87, 69, 21, 237, 128, 12, 240, 76, 202, 164, 71, 187, 45, 83, 164, 166, 220, 223, 1078 | 141, 45, 194, 122, 194, 238, 254, 252, 128, 11, 241, 248, 173, 1079 | ]) 1080 | .unwrap(); 1081 | 1082 | let pk = sk.public_key(); 1083 | 1084 | let key = session_key(sk, pk).unwrap(); 1085 | 1086 | assert!(matches!( 1087 | key, 1088 | [ 1089 | 97, 10, 203, 217, 188, 148, 215, 133, 15, 230, 124, 53, 141, 69, 124, 66, 67, 92, 1090 | 157, 16, 21, 21, 229, 234, 131, 191, 156, 46, 47, 231, 92, 40 1091 | ] 1092 | )); 1093 | } 1094 | } 1095 | --------------------------------------------------------------------------------