bhwi 0.1.0

Sans-IO Bitcoin hardware wallet interpreters and their common command model
Documentation
use bitcoin::address::AddressType;
use bitcoin::hashes::{Hash, sha256};
use miniscript::descriptor::{DescriptorPublicKey, Wildcard};

use crate::common::{
    Command, DeviceCode, DisplayAddress, Error, ErrorKind, Info, MultisigAddressType,
    MultisigDisplayAddress, Recipient, Response, Transmit, WalletRegistration,
};
use crate::jade::api;
use crate::jade::{
    JadeCommand, JadeError, JadeRecipient, JadeResponse, JadeTransmit, ReceiveAddress,
};

impl TryFrom<Command> for JadeCommand {
    type Error = Error;

    fn try_from(command: Command) -> Result<Self, Self::Error> {
        match command {
            Command::Setup(..) => Err(Error::new(
                ErrorKind::Unsupported,
                "Setup not supported by Jade",
            )),
            Command::Wipe => Err(Error::new(
                ErrorKind::Unsupported,
                "Wipe not supported by Jade",
            )),
            Command::Restore(..) => Err(Error::new(
                ErrorKind::Unsupported,
                "Restore not supported by Jade",
            )),
            Command::TogglePassphrase => Err(Error::new(
                ErrorKind::Unsupported,
                "Toggle passphrase not supported by Jade",
            )),
            Command::PromptPin | Command::SendPin(_) => Err(Error::new(
                ErrorKind::Unsupported,
                "PIN entry from the host not needed by Jade",
            )),
            Command::Backup => Err(Error::new(
                ErrorKind::Unsupported,
                "Backup not supported by Jade",
            )),
            Command::Unlock { .. } => Ok(Self::Auth),
            Command::GetMasterFingerprint => Ok(Self::GetMasterFingerprint),
            Command::GetXpub { path, .. } => Ok(Self::GetXpub(path)),
            Command::DisplayAddress(
                DisplayAddress::ByDescriptor {
                    index,
                    change,
                    descriptor_name,
                    ..
                },
                _,
            ) => Ok(Self::GetReceiveAddress(ReceiveAddress::Descriptor {
                index,
                change,
                descriptor_name,
            })),
            Command::DisplayAddress(
                DisplayAddress::ByPath {
                    path,
                    address_format,
                    ..
                },
                _,
            ) => Ok(Self::GetReceiveAddress(ReceiveAddress::Path {
                path,
                variant: jade_path_variant(address_format)?,
            })),
            Command::DisplayAddress(DisplayAddress::ByMultisig(address), _) => {
                jade_multisig_command(address)
            }
            Command::SignMessage { message, path } => Ok(Self::SignMessage { message, path }),
            Command::GetVersion => Ok(Self::GetInfo),
            Command::RegisterWallet { name, policy } => {
                let (descriptor, keys) = crate::policy::extract_parts(&policy)
                    .map_err(|error| Error::new(ErrorKind::Serialization, error.to_string()))?;
                // Jade requires the explicit multipath spelling instead of the
                // BIP-388 wallet-policy shorthand.
                let descriptor = descriptor.replace("/**", "/<0;1>/*");
                let datavalues = keys
                    .iter()
                    .enumerate()
                    .map(|(index, key)| (format!("@{index}"), crate::policy::format_key_info(key)))
                    .collect();
                Ok(Self::RegisterDescriptor {
                    descriptor_name: name,
                    descriptor,
                    datavalues,
                })
            }
            Command::SignTx(psbt, _) => Ok(Self::SignPsbt { psbt }),
        }
    }
}

fn jade_multisig_command(address: MultisigDisplayAddress) -> Result<JadeCommand, Error> {
    let variant = match address.address_type {
        MultisigAddressType::Legacy => "sh(multi(k))",
        MultisigAddressType::Wit => "wsh(multi(k))",
        MultisigAddressType::ShWit => "sh(wsh(multi(k)))",
    };
    let mut signer_origins = Vec::with_capacity(address.keys.len());
    let mut signers = Vec::with_capacity(address.keys.len());
    let mut paths = Vec::with_capacity(address.keys.len());

    for key in address.keys {
        let DescriptorPublicKey::XPub(key) = key else {
            return Err(Error::new(
                ErrorKind::InvalidInput,
                "Jade multisig display requires extended public keys",
            ));
        };
        if key.wildcard != Wildcard::None {
            return Err(Error::new(
                ErrorKind::InvalidInput,
                "Jade multisig display requires concrete key derivation paths",
            ));
        }
        let (fingerprint, origin_path) = key.origin.ok_or_else(|| {
            Error::new(
                ErrorKind::InvalidInput,
                "Jade multisig display requires key origin information",
            )
        })?;
        let origin = origin_path.to_u32_vec();
        signer_origins.push((fingerprint.to_bytes(), origin.clone()));
        signers.push(api::MultisigSigner {
            fingerprint: fingerprint.to_bytes().to_vec(),
            derivation: origin,
            xpub: key.xkey.to_string(),
            path: Vec::new(),
        });
        paths.push(key.derivation_path.to_u32_vec());
    }

    signer_origins.sort();
    let mut summary = format!("{variant}|{}|", address.threshold);
    for (fingerprint, path) in signer_origins {
        summary.push_str(&hex::encode(fingerprint));
        summary.push('|');
        summary.push_str(&format!("{path:?}"));
        summary.push('|');
    }
    let digest = sha256::Hash::hash(summary.as_bytes());
    let multisig_name = format!("hwi{}", &digest.to_string()[..12]);

    Ok(JadeCommand::RegisterMultisig {
        multisig_name,
        descriptor: api::MultisigDescriptor {
            variant: variant.to_string(),
            sorted: address.sorted,
            threshold: address.threshold,
            signers,
            master_blinding_key: None,
        },
        paths,
    })
}

fn jade_path_variant(address_format: Option<AddressType>) -> Result<&'static str, Error> {
    match address_format.unwrap_or(AddressType::P2wpkh) {
        AddressType::P2pkh => Ok("pkh(k)"),
        AddressType::P2sh => Ok("sh(wpkh(k))"),
        AddressType::P2wpkh => Ok("wpkh(k)"),
        AddressType::P2wsh | AddressType::P2tr => Err(Error::new(
            ErrorKind::UnsupportedDisplayAddress,
            "Jade does not support this path address format",
        )),
        _ => Err(Error::new(
            ErrorKind::UnsupportedDisplayAddress,
            "Jade does not support this path address format",
        )),
    }
}

impl From<JadeResponse> for Response {
    fn from(response: JadeResponse) -> Self {
        match response {
            JadeResponse::TaskDone => Self::TaskDone,
            JadeResponse::MasterFingerprint(fingerprint) => Self::MasterFingerprint(fingerprint),
            JadeResponse::Xpub(xpub) => Self::Xpub(xpub),
            JadeResponse::Signature(header, signature) => Self::Signature(header, signature),
            JadeResponse::GetInfo(info) => Self::Info(Info {
                version: info.jade_version,
                networks: info.jade_networks.into(),
                firmware: None,
                initialized: Some(info.jade_state != api::JadeState::Uninit),
                label: None,
                on_device_passphrase_entry: None,
                needs_pin_sent: None,
                needs_passphrase_sent: None,
            }),
            JadeResponse::Address(address) => Self::Address(address),
            JadeResponse::RegisteredDescriptor => {
                Self::WalletRegistration(WalletRegistration::Complete { hmac: None })
            }
            JadeResponse::SignedPsbt(psbt) => Self::SignedPsbt(psbt),
        }
    }
}

impl From<JadeRecipient> for Recipient {
    fn from(recipient: JadeRecipient) -> Self {
        match recipient {
            JadeRecipient::Device => Self::Device,
            JadeRecipient::PinServer { url } => Self::PinServer { url },
        }
    }
}

impl From<JadeTransmit> for Transmit {
    fn from(transmit: JadeTransmit) -> Self {
        Self {
            recipient: transmit.recipient.into(),
            payload: transmit.payload,
            encrypted: false,
        }
    }
}

fn rpc_kind(code: i32) -> ErrorKind {
    use api::ErrorCode;
    match code {
        code if code == ErrorCode::UserCancelled as i32 => ErrorKind::UserCancelled,
        code if code == ErrorCode::HwLocked as i32 => ErrorKind::Locked,
        code if code == ErrorCode::NetworkMismatch as i32 => ErrorKind::WrongNetwork,
        code if code == ErrorCode::UnknownMethod as i32 => ErrorKind::Unsupported,
        code if code == ErrorCode::BadParameters as i32 => ErrorKind::InvalidInput,
        code if code == ErrorCode::InternalError as i32 => ErrorKind::DeviceFailure,
        code if code == ErrorCode::InvalidRequest as i32
            || code == ErrorCode::ProtocolError as i32 =>
        {
            ErrorKind::Protocol
        }
        _ => ErrorKind::Other,
    }
}

impl From<JadeError> for Error {
    fn from(error: JadeError) -> Self {
        match error {
            JadeError::Cbor => Self::new(ErrorKind::Serialization, "invalid CBOR reply"),
            JadeError::NoErrorOrResult => {
                Self::new(ErrorKind::UnexpectedResponse, "no error or result returned")
            }
            JadeError::Rpc(error) => {
                let code = DeviceCode::Jade(error.code);
                let kind = rpc_kind(error.code);
                let message = error
                    .message
                    .filter(|message| !message.is_empty())
                    .unwrap_or_default();
                let converted = Self::new(kind, message).with_device_code(code);
                match error.data {
                    Some(data) => converted.with_data(data),
                    None => converted,
                }
            }
            JadeError::Serialization(error) => Self::new(ErrorKind::Serialization, error),
            JadeError::UnexpectedResult(message) => Self::new(
                ErrorKind::UnexpectedResponse,
                format!("unexpected response: {message}"),
            ),
            JadeError::HandshakeRefused => Self::new(ErrorKind::AuthenticationRefused, ""),
            JadeError::UnsupportedDisplayAddress => Self::new(
                ErrorKind::UnsupportedDisplayAddress,
                "unsupported display address on Jade",
            ),
        }
    }
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use super::*;

    #[test]
    fn path_display_rejects_unsupported_jade_address_format() {
        let result = JadeCommand::try_from(Command::DisplayAddress(
            DisplayAddress::ByPath {
                path: "m/86'/1'/0'/0/0".parse().unwrap(),
                display: true,
                address_format: Some(AddressType::P2tr),
            },
            None,
        ));

        assert!(matches!(result, Err(e) if e.kind() == ErrorKind::UnsupportedDisplayAddress));
    }

    #[test]
    fn multisig_display_uses_upstream_hwi_registration_shape_and_name() {
        let keys = [
            "[f5acc2fd/48'/1'/0'/2']tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/0/7",
            "[00000000/48'/1'/0'/2']tpubDDtb2WPYwEWw2WWDV7reLV348iJHw2HmhzvPysKKrJw3hYmvrd4jasyoioVPdKGQqjyaBMEvTn1HvHWDSVqQ6amyyxRZ5YjpPBBGjJ8yu8S/0/7",
        ]
        .map(|key| DescriptorPublicKey::from_str(key).unwrap())
        .to_vec();

        let command = jade_multisig_command(MultisigDisplayAddress {
            threshold: 2,
            address_type: MultisigAddressType::Wit,
            sorted: true,
            keys,
        })
        .unwrap();

        let JadeCommand::RegisterMultisig {
            multisig_name,
            descriptor,
            paths,
        } = command
        else {
            panic!("expected multisig registration");
        };
        assert_eq!(multisig_name, "hwi78631c5c8b92");
        assert_eq!(descriptor.variant, "wsh(multi(k))");
        assert!(descriptor.sorted);
        assert_eq!(descriptor.threshold, 2);
        assert_eq!(descriptor.signers.len(), 2);
        assert!(
            descriptor
                .signers
                .iter()
                .all(|signer| signer.path.is_empty())
        );
        assert_eq!(paths, vec![vec![0, 7], vec![0, 7]]);
    }

    #[test]
    fn rpc_user_cancel_frame_maps_to_user_cancelled() {
        use crate::Interpreter;
        use crate::common::JadeInterpreter;

        let mut interpreter = JadeInterpreter::default();
        interpreter
            .start(Command::SignMessage {
                message: b"hello".to_vec(),
                path: "m/84'/1'/0'/0/0".parse().unwrap(),
            })
            .unwrap();
        let payload = serde_cbor::to_vec(&api::Response::<String> {
            id: "1".into(),
            seqlen: None,
            seqnum: None,
            result: None,
            error: Some(api::Error {
                code: api::ErrorCode::UserCancelled as i32,
                message: Some("user declined".into()),
                data: None,
            }),
        })
        .unwrap();
        let err = match interpreter.exchange(payload) {
            Err(err) => err,
            Ok(_) => panic!("expected an error"),
        };
        assert_eq!(err.kind(), ErrorKind::UserCancelled, "{err:?}");
    }

    #[test]
    fn other_rpc_codes_keep_the_rpc_error() {
        let err = Error::from(JadeError::Rpc(api::Error {
            code: api::ErrorCode::BadParameters as i32,
            message: Some("bad params".into()),
            data: None,
        }));
        assert_eq!(err.device_code(), Some(DeviceCode::Jade(-32602)), "{err:?}");
    }

    #[test]
    fn pin_server_transmit_maps_recipient() {
        let transmit = Transmit::from(JadeTransmit {
            recipient: JadeRecipient::PinServer {
                url: "https://pin.example".into(),
            },
            payload: vec![1, 2, 3],
        });

        assert!(matches!(
            transmit.recipient,
            Recipient::PinServer { ref url } if url == "https://pin.example"
        ));
        assert!(!transmit.encrypted);
    }
}