#[cfg(feature = "bitbox")]
use crate::bitbox;
#[cfg(feature = "coldcard")]
use crate::coldcard;
#[cfg(feature = "jade")]
use crate::jade;
#[cfg(feature = "keepkey")]
use crate::keepkey;
#[cfg(feature = "ledger")]
use crate::ledger;
use crate::miniscript::descriptor::{DescriptorPublicKey, WalletPolicy};
#[cfg(feature = "specter")]
use crate::specter;
#[cfg(feature = "trezor")]
use crate::trezor;
use bitcoin::Network;
use bitcoin::address::AddressType;
use bitcoin::bip32::{DerivationPath, Fingerprint, Xpub};
use bitcoin::psbt::Psbt;
use bitcoin::secp256k1::ecdsa::Signature;
mod adapters;
#[derive(Default)]
pub struct UnlockOptions {
pub network: Option<Network>,
}
#[derive(Clone, Debug, Default)]
pub struct SetupOptions {
pub label: String,
pub backup_passphrase: String,
}
#[derive(Clone, Debug)]
pub struct RestoreOptions {
pub label: String,
pub word_count: i32,
}
impl Default for RestoreOptions {
fn default() -> Self {
Self {
label: String::new(),
word_count: 24,
}
}
}
#[derive(Clone, Debug)]
pub enum DisplayAddress {
ByPath {
path: DerivationPath,
display: bool,
address_format: Option<AddressType>,
},
ByDescriptor {
index: u32,
change: bool,
display: bool,
descriptor_name: String,
},
ByMultisig(
MultisigDisplayAddress,
),
}
#[derive(Clone, Debug)]
pub struct MultisigDisplayAddress {
pub threshold: u8,
pub address_type: MultisigAddressType,
pub sorted: bool,
pub keys: Vec<DescriptorPublicKey>,
}
#[derive(Clone, Copy, Debug)]
pub enum MultisigAddressType {
Legacy,
ShWit,
Wit,
}
#[allow(clippy::large_enum_variant)]
pub enum Command {
Backup,
Setup(
SetupOptions,
Option<DeviceContext>,
),
Wipe,
Restore(
RestoreOptions,
Option<DeviceContext>,
),
TogglePassphrase,
GetMasterFingerprint,
GetVersion,
GetXpub {
path: DerivationPath,
display: bool,
},
DisplayAddress(
DisplayAddress,
Option<DeviceContext>,
),
RegisterWallet {
name: String,
policy: WalletPolicy,
},
SignTx(
Psbt,
Option<DeviceContext>,
),
SignMessage {
message: Vec<u8>,
path: DerivationPath,
},
Unlock {
options: UnlockOptions,
},
PromptPin,
SendPin(
Option<DeviceContext>,
),
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug)]
pub enum DeviceContext {
#[cfg(feature = "ledger")]
Ledger {
wallet_policy: ledger::LedgerWalletPolicy,
wallet_hmac: Option<[u8; 32]>,
},
#[cfg(feature = "bitbox")]
BitBox {
policy: WalletPolicy,
},
#[cfg(feature = "bitbox")]
BitBoxManagement(
bitbox::ManagementContext,
),
#[cfg(feature = "trezor")]
TrezorManagement(
trezor::ManagementContext,
),
#[cfg(feature = "keepkey")]
KeepKeyManagement(
keepkey::ManagementContext,
),
#[cfg(feature = "specter")]
Specter {
policy: WalletPolicy,
},
}
pub enum Response {
Backup(
DeviceBackup,
),
DeviceAction(
bool,
),
TaskDone,
TaskBusy,
Info(
Info,
),
MasterFingerprint(
Fingerprint,
),
Xpub(
Xpub,
),
EncryptionKey(
[u8; 64],
),
Signature(
u8,
Signature,
),
SignedPsbt(
Psbt,
),
Address(
String,
),
WalletRegistration(
WalletRegistration,
),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WalletRegistration {
Complete {
hmac: Option<[u8; 32]>,
},
PendingUserConfirmation,
}
impl WalletRegistration {
pub fn hmac(self) -> Option<[u8; 32]> {
match self {
Self::Complete { hmac } => hmac,
Self::PendingUserConfirmation => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DeviceBackup {
Complete,
File(
Vec<u8>,
),
}
#[derive(Debug, Clone, Default)]
pub struct Info {
pub version: String,
pub networks: Vec<Network>,
pub firmware: Option<String>,
pub initialized: Option<bool>,
pub label: Option<String>,
pub on_device_passphrase_entry: Option<bool>,
pub needs_pin_sent: Option<bool>,
pub needs_passphrase_sent: Option<bool>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum HostRequest {
PinMatrix {
kind: PinMatrixRequestKind,
},
RecoveryCharacter {
word_position: u32,
character_position: u32,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PinMatrixRequestKind {
Current,
NewFirst,
NewSecond,
Unknown(
i32,
),
}
#[derive(Eq, PartialEq)]
pub enum HostResponse {
PinPositions(
String,
),
RecoveryCharacter(
char,
),
RecoveryDelete,
RecoveryNextWord,
RecoveryDone,
}
impl core::fmt::Debug for HostResponse {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::PinPositions(_) => f.write_str("PinPositions(<redacted>)"),
Self::RecoveryCharacter(_) => f.write_str("RecoveryCharacter(<redacted>)"),
Self::RecoveryDelete => f.write_str("RecoveryDelete"),
Self::RecoveryNextWord => f.write_str("RecoveryNextWord"),
Self::RecoveryDone => f.write_str("RecoveryDone"),
}
}
}
fn zeroize_string(value: &mut String) {
zeroize::Zeroize::zeroize(value);
}
impl HostResponse {
pub fn into_bytes_for(self, request: &HostRequest) -> Result<Vec<u8>, Error> {
match (request, self) {
(HostRequest::PinMatrix { .. }, Self::PinPositions(mut positions)) => {
if positions.is_empty() || !positions.bytes().all(|byte| byte.is_ascii_digit()) {
zeroize_string(&mut positions);
return Err(Error::new(
ErrorKind::InvalidInput,
"PIN positions must contain ASCII digits",
));
}
Ok(positions.into_bytes())
}
(HostRequest::RecoveryCharacter { .. }, Self::RecoveryCharacter(character)) => {
if !character.is_ascii_lowercase() {
return Err(Error::new(
ErrorKind::InvalidInput,
"recovery cipher response must be one lowercase ASCII character",
));
}
Ok(vec![character as u8])
}
(
HostRequest::RecoveryCharacter {
word_position,
character_position,
},
action,
) => {
let byte = match action {
Self::RecoveryDelete if *word_position != 0 || *character_position != 0 => 0x08,
Self::RecoveryNextWord if *character_position >= 3 => b' ',
Self::RecoveryDone
if matches!(*word_position, 11 | 17 | 23) && *character_position >= 3 =>
{
b'\n'
}
Self::RecoveryDelete | Self::RecoveryNextWord | Self::RecoveryDone => {
return Err(Error::new(
ErrorKind::InvalidInput,
"recovery action is not valid at this position",
));
}
Self::PinPositions(mut positions) => {
zeroize_string(&mut positions);
return Err(Error::new(
ErrorKind::InvalidInput,
"host response does not match request",
));
}
Self::RecoveryCharacter(_) => {
return Err(Error::new(
ErrorKind::InvalidInput,
"host response does not match request",
));
}
};
Ok(vec![byte])
}
_ => Err(Error::new(
ErrorKind::InvalidInput,
"host response does not match request",
)),
}
}
}
pub enum Recipient {
Device,
PinServer {
url: String,
},
Host(
HostRequest,
),
}
pub struct Transmit {
pub recipient: Recipient,
pub payload: Vec<u8>,
pub encrypted: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorKind {
UserCancelled,
AuthenticationRefused,
HostUnavailable,
Locked,
NotReady,
AlreadyUnlocked,
NotInitialized,
AlreadyInitialized,
WrongPin,
WrongNetwork,
Duplicate,
Unsupported,
UnsupportedDisplayAddress,
MissingContext,
InvalidInput,
Rejected,
DeviceFailure,
Protocol,
UnexpectedResponse,
Serialization,
Encryption,
Transport,
Disconnected,
Other,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DeviceCode {
Ledger(u16),
Trezor(i32),
KeepKey(i32),
Jade(i32),
BitBox(i32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
kind: ErrorKind,
device_code: Option<DeviceCode>,
message: String,
data: Option<Vec<u8>>,
}
impl Error {
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
device_code: None,
message: message.into(),
data: None,
}
}
pub fn with_device_code(mut self, code: DeviceCode) -> Self {
self.device_code = Some(code);
self
}
pub fn with_data(mut self, data: Vec<u8>) -> Self {
self.data = Some(data);
self
}
pub fn kind(&self) -> ErrorKind {
self.kind
}
pub fn device_code(&self) -> Option<DeviceCode> {
self.device_code
}
pub fn message(&self) -> &str {
&self.message
}
pub fn data(&self) -> Option<&[u8]> {
self.data.as_deref()
}
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
if self.message.is_empty() {
write!(f, "[{:?}]", self.kind)
} else {
write!(f, "[{:?}] {}", self.kind, self.message)
}
}
}
impl std::error::Error for Error {}
#[cfg(feature = "bitbox")]
pub type BitBoxInterpreter<'a> = bitbox::BitBoxInterpreter<'a, Command, Transmit, Response, Error>;
#[cfg(feature = "coldcard")]
pub type ColdcardInterpreter<'a> =
coldcard::ColdcardInterpreter<'a, Command, Transmit, Response, Error>;
#[cfg(feature = "jade")]
pub type JadeInterpreter = jade::JadeInterpreter<Command, Transmit, Response, Error>;
#[cfg(feature = "ledger")]
pub type LedgerInterpreter = ledger::LedgerInterpreter<Command, Transmit, Response, Error>;
#[cfg(feature = "trezor")]
pub type TrezorInterpreter = trezor::TrezorInterpreter<Command, Transmit, Response, Error>;
#[cfg(feature = "keepkey")]
pub type KeepKeyInterpreter = keepkey::KeepKeyInterpreter<Command, Transmit, Response, Error>;
#[cfg(feature = "specter")]
pub type SpecterInterpreter = specter::SpecterInterpreter<Command, Transmit, Response, Error>;
impl From<Vec<u8>> for Transmit {
fn from(payload: Vec<u8>) -> Transmit {
Transmit {
recipient: Recipient::Device,
payload,
encrypted: false,
}
}
}
impl From<HostRequest> for Transmit {
fn from(request: HostRequest) -> Self {
Self {
recipient: Recipient::Host(request),
payload: Vec::new(),
encrypted: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Interpreter;
fn assert_interpreter<I>()
where
I: Interpreter<
Command = super::Command,
Transmit = super::Transmit,
Response = super::Response,
Error = super::Error,
>,
{
}
#[test]
fn common_interpreters_are_satisfied() {
#[cfg(feature = "bitbox")]
assert_interpreter::<BitBoxInterpreter<'static>>();
#[cfg(feature = "coldcard")]
assert_interpreter::<ColdcardInterpreter<'static>>();
#[cfg(feature = "jade")]
assert_interpreter::<JadeInterpreter>();
#[cfg(feature = "keepkey")]
assert_interpreter::<KeepKeyInterpreter>();
#[cfg(feature = "specter")]
assert_interpreter::<SpecterInterpreter>();
#[cfg(feature = "ledger")]
assert_interpreter::<LedgerInterpreter>();
#[cfg(feature = "trezor")]
assert_interpreter::<TrezorInterpreter>();
}
#[test]
fn host_response_validation_and_encoding() {
let pin = HostRequest::PinMatrix {
kind: PinMatrixRequestKind::Current,
};
assert_eq!(
HostResponse::PinPositions("123".into())
.into_bytes_for(&pin)
.unwrap(),
b"123"
);
assert!(matches!(
HostResponse::PinPositions("".into()).into_bytes_for(&pin),
Err(e) if e.kind() == ErrorKind::InvalidInput
&& e.message() == "PIN positions must contain ASCII digits"
));
assert!(matches!(
HostResponse::PinPositions("123".into()).into_bytes_for(&pin),
Err(e) if e.kind() == ErrorKind::InvalidInput
&& e.message() == "PIN positions must contain ASCII digits"
));
let first = HostRequest::RecoveryCharacter {
word_position: 0,
character_position: 0,
};
assert_eq!(
HostResponse::RecoveryCharacter('a')
.into_bytes_for(&first)
.unwrap(),
b"a"
);
assert!(matches!(
HostResponse::RecoveryCharacter('A').into_bytes_for(&first),
Err(e) if e.kind() == ErrorKind::InvalidInput
&& e.message()
== "recovery cipher response must be one lowercase ASCII character"
));
assert!(matches!(
HostResponse::RecoveryDelete.into_bytes_for(&first),
Err(e) if e.kind() == ErrorKind::InvalidInput
&& e.message() == "recovery action is not valid at this position"
));
for request in [
HostRequest::RecoveryCharacter {
word_position: 0,
character_position: 1,
},
HostRequest::RecoveryCharacter {
word_position: 1,
character_position: 0,
},
] {
assert_eq!(
HostResponse::RecoveryDelete
.into_bytes_for(&request)
.unwrap(),
[0x08]
);
}
let middle = HostRequest::RecoveryCharacter {
word_position: 1,
character_position: 3,
};
assert_eq!(
HostResponse::RecoveryDelete
.into_bytes_for(&middle)
.unwrap(),
[0x08]
);
assert_eq!(
HostResponse::RecoveryNextWord
.into_bytes_for(&middle)
.unwrap(),
b" "
);
assert!(HostResponse::RecoveryDone.into_bytes_for(&middle).is_err());
let too_early = HostRequest::RecoveryCharacter {
word_position: 1,
character_position: 2,
};
assert!(matches!(
HostResponse::RecoveryNextWord.into_bytes_for(&too_early),
Err(e) if e.kind() == ErrorKind::InvalidInput
&& e.message() == "recovery action is not valid at this position"
));
let last = HostRequest::RecoveryCharacter {
word_position: 11,
character_position: 3,
};
assert_eq!(
HostResponse::RecoveryDone.into_bytes_for(&last).unwrap(),
b"\n"
);
assert!(matches!(
HostResponse::PinPositions("1".into()).into_bytes_for(&last),
Err(e) if e.kind() == ErrorKind::InvalidInput
&& e.message() == "host response does not match request"
));
}
#[test]
fn host_response_debug_is_redacted() {
assert_eq!(
format!("{:?}", HostResponse::PinPositions("8675309".into())),
"PinPositions(<redacted>)"
);
assert_eq!(
format!("{:?}", HostResponse::RecoveryCharacter('q')),
"RecoveryCharacter(<redacted>)"
);
}
}