bhwi-cli 0.1.0

Command-line tool to talk to Bitcoin hardware wallets, plus an hwi parity binary
Documentation
//! Interactive KeepKey PIN-position and recovery-character input.

use std::{
    cell::Cell,
    io::{self, IsTerminal, Write},
    rc::Rc,
};

use async_trait::async_trait;
use bhwi::common::{self, HostRequest, HostResponse, PinMatrixRequestKind};
use bhwi_async::{HostInteraction, device::HostInteractionFactory};

use crate::hwi::PIN_MATRIX_DESCRIPTION;

/// Creates host-interaction handlers that prompt on stderr and read stdin.
///
/// Terminal input is hidden. PIN responses are nonempty ASCII-digit strings of
/// scrambled keypad positions, not literal PIN digits. Recovery accepts lowercase
/// ASCII letters and `space`, `backspace`, or `done`. Only syntactically
/// unrecognized input is requested again; recovery actions invalid at the current
/// position propagate an error instead. End-of-input cancels the operation.
/// Attach the factory before boxing a KeepKey device so commands can answer host
/// requests while running.
pub fn cli_host_interaction() -> HostInteractionFactory {
    Rc::new(|| Box::new(CliHostInteraction))
}

struct CliHostInteraction;

#[async_trait(?Send)]
impl HostInteraction for CliHostInteraction {
    async fn respond(&mut self, request: &HostRequest) -> Result<HostResponse, common::Error> {
        let terminal = io::stdin().is_terminal();
        read_host_response(
            request,
            || {
                if terminal {
                    read_hidden_line()
                } else {
                    let mut response = String::new();
                    let read = io::stdin().read_line(&mut response)?;
                    if read == 0 {
                        Ok(None)
                    } else {
                        response.truncate(response.trim_end_matches(['\r', '\n']).len());
                        Ok(Some(response))
                    }
                }
            },
            |prompt| {
                eprint!("{prompt}");

                io::stderr().flush()
            },
        )
    }
}
struct HiddenOutput {
    line_completed: Rc<Cell<bool>>,
}

impl Write for HiddenOutput {
    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
        if bytes.contains(&b'\n') {
            self.line_completed.set(true);
        }
        io::stderr().write(bytes)
    }

    fn flush(&mut self) -> io::Result<()> {
        io::stderr().flush()
    }
}

fn read_hidden_line() -> io::Result<Option<String>> {
    let line_completed = Rc::new(Cell::new(false));
    let response = rpassword::read_password_with_config(
        rpassword::ConfigBuilder::new()
            .output_writer(HiddenOutput {
                line_completed: Rc::clone(&line_completed),
            })
            .build(),
    )?;
    Ok(completed_hidden_line(response, line_completed.get()))
}

fn completed_hidden_line(response: String, line_completed: bool) -> Option<String> {
    line_completed.then_some(response)
}

fn read_host_response(
    request: &HostRequest,
    mut read: impl FnMut() -> io::Result<Option<String>>,
    mut write_prompt: impl FnMut(&str) -> io::Result<()>,
) -> Result<HostResponse, common::Error> {
    loop {
        write_prompt(&host_prompt(request)).map_err(host_io_error)?;
        let response = read()
            .map_err(host_io_error)?
            .ok_or_else(|| common::Error::new(common::ErrorKind::UserCancelled, ""))?;
        if let Some(response) = parse_host_response(request, response) {
            return Ok(response);
        }
    }
}

fn host_prompt(request: &HostRequest) -> String {
    match request {
        HostRequest::PinMatrix { kind } => match kind {
            PinMatrixRequestKind::Current => {
                format!("{PIN_MATRIX_DESCRIPTION}\nEnter current PIN positions:\n")
            }
            PinMatrixRequestKind::NewFirst => {
                format!("{PIN_MATRIX_DESCRIPTION}\nEnter new PIN positions:\n")
            }
            PinMatrixRequestKind::NewSecond => {
                format!("{PIN_MATRIX_DESCRIPTION}\nRe-enter new PIN positions:\n")
            }
            PinMatrixRequestKind::Unknown(code) => {
                format!("{PIN_MATRIX_DESCRIPTION}\nEnter PIN positions for request {code}:\n")
            }
        },
        HostRequest::RecoveryCharacter {
            word_position,
            character_position,
        } => format!(
            "Recovery word {word_position}, character {character_position} (letter/space/backspace/done):\n"
        ),
    }
}

fn parse_host_response(request: &HostRequest, response: String) -> Option<HostResponse> {
    match request {
        HostRequest::PinMatrix { .. }
            if !response.is_empty() && response.bytes().all(|byte| byte.is_ascii_digit()) =>
        {
            Some(HostResponse::PinPositions(response))
        }
        HostRequest::RecoveryCharacter { .. } => match response.as_str() {
            "space" => Some(HostResponse::RecoveryNextWord),
            "backspace" => Some(HostResponse::RecoveryDelete),
            "done" => Some(HostResponse::RecoveryDone),
            _ if response.len() == 1 && response.as_bytes()[0].is_ascii_lowercase() => Some(
                HostResponse::RecoveryCharacter(response.as_bytes()[0] as char),
            ),
            _ => None,
        },
        _ => None,
    }
}

fn host_io_error(error: io::Error) -> common::Error {
    if matches!(
        error.kind(),
        io::ErrorKind::UnexpectedEof | io::ErrorKind::Interrupted
    ) {
        common::Error::new(common::ErrorKind::UserCancelled, "")
    } else {
        common::Error::new(
            common::ErrorKind::Rejected,
            format!("host interaction failed: {error}"),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pin_input_repeats_until_nonempty_ascii_digits() {
        let request = HostRequest::PinMatrix {
            kind: PinMatrixRequestKind::NewFirst,
        };
        let mut lines = [Some(""), Some("12"), Some("12a"), Some("7913")].into_iter();
        let mut prompts = Vec::new();
        let response = read_host_response(
            &request,
            || Ok(lines.next().flatten().map(str::to_owned)),
            |prompt| {
                prompts.push(prompt.to_owned());
                Ok(())
            },
        )
        .unwrap();

        assert_eq!(response, HostResponse::PinPositions("7913".to_owned()));
        assert_eq!(prompts.len(), 4);
        assert!(prompts.iter().all(|prompt| prompt.contains("new PIN")));
    }

    #[test]
    fn recovery_input_accepts_only_cipher_letters_and_actions() {
        let request = HostRequest::RecoveryCharacter {
            word_position: 4,
            character_position: 2,
        };
        for (input, expected) in [
            ("q", HostResponse::RecoveryCharacter('q')),
            ("space", HostResponse::RecoveryNextWord),
            ("backspace", HostResponse::RecoveryDelete),
            ("done", HostResponse::RecoveryDone),
        ] {
            assert_eq!(
                parse_host_response(&request, input.to_owned()),
                Some(expected)
            );
        }
        for input in ["Q", "qq", "", "delete", " space"] {
            assert_eq!(parse_host_response(&request, input.to_owned()), None);
        }
        let prompt = host_prompt(&request);
        assert!(prompt.contains("word 4"));
        assert!(prompt.contains("character 2"));
    }

    #[test]
    fn hidden_input_distinguishes_blank_lines_from_eof() {
        assert_eq!(
            completed_hidden_line(String::new(), true),
            Some(String::new())
        );
        assert_eq!(completed_hidden_line(String::new(), false), None);
    }

    #[test]
    fn host_input_eof_is_user_cancellation() {
        let request = HostRequest::PinMatrix {
            kind: PinMatrixRequestKind::Current,
        };
        assert!(matches!(
            read_host_response(&request, || Ok(None), |_| Ok(())),
            Err(e) if e.kind() == common::ErrorKind::UserCancelled
        ));
    }
}