Skip to main content

bhwi_cli/
host.rs

1//! Interactive KeepKey PIN-position and recovery-character input.
2
3use std::{
4    cell::Cell,
5    io::{self, IsTerminal, Write},
6    rc::Rc,
7};
8
9use async_trait::async_trait;
10use bhwi::common::{self, HostRequest, HostResponse, PinMatrixRequestKind};
11use bhwi_async::{HostInteraction, device::HostInteractionFactory};
12
13use crate::hwi::PIN_MATRIX_DESCRIPTION;
14
15/// Creates host-interaction handlers that prompt on stderr and read stdin.
16///
17/// Terminal input is hidden. PIN responses are nonempty ASCII-digit strings of
18/// scrambled keypad positions, not literal PIN digits. Recovery accepts lowercase
19/// ASCII letters and `space`, `backspace`, or `done`. Only syntactically
20/// unrecognized input is requested again; recovery actions invalid at the current
21/// position propagate an error instead. End-of-input cancels the operation.
22/// Attach the factory before boxing a KeepKey device so commands can answer host
23/// requests while running.
24pub fn cli_host_interaction() -> HostInteractionFactory {
25    Rc::new(|| Box::new(CliHostInteraction))
26}
27
28struct CliHostInteraction;
29
30#[async_trait(?Send)]
31impl HostInteraction for CliHostInteraction {
32    async fn respond(&mut self, request: &HostRequest) -> Result<HostResponse, common::Error> {
33        let terminal = io::stdin().is_terminal();
34        read_host_response(
35            request,
36            || {
37                if terminal {
38                    read_hidden_line()
39                } else {
40                    let mut response = String::new();
41                    let read = io::stdin().read_line(&mut response)?;
42                    if read == 0 {
43                        Ok(None)
44                    } else {
45                        response.truncate(response.trim_end_matches(['\r', '\n']).len());
46                        Ok(Some(response))
47                    }
48                }
49            },
50            |prompt| {
51                eprint!("{prompt}");
52
53                io::stderr().flush()
54            },
55        )
56    }
57}
58struct HiddenOutput {
59    line_completed: Rc<Cell<bool>>,
60}
61
62impl Write for HiddenOutput {
63    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
64        if bytes.contains(&b'\n') {
65            self.line_completed.set(true);
66        }
67        io::stderr().write(bytes)
68    }
69
70    fn flush(&mut self) -> io::Result<()> {
71        io::stderr().flush()
72    }
73}
74
75fn read_hidden_line() -> io::Result<Option<String>> {
76    let line_completed = Rc::new(Cell::new(false));
77    let response = rpassword::read_password_with_config(
78        rpassword::ConfigBuilder::new()
79            .output_writer(HiddenOutput {
80                line_completed: Rc::clone(&line_completed),
81            })
82            .build(),
83    )?;
84    Ok(completed_hidden_line(response, line_completed.get()))
85}
86
87fn completed_hidden_line(response: String, line_completed: bool) -> Option<String> {
88    line_completed.then_some(response)
89}
90
91fn read_host_response(
92    request: &HostRequest,
93    mut read: impl FnMut() -> io::Result<Option<String>>,
94    mut write_prompt: impl FnMut(&str) -> io::Result<()>,
95) -> Result<HostResponse, common::Error> {
96    loop {
97        write_prompt(&host_prompt(request)).map_err(host_io_error)?;
98        let response = read()
99            .map_err(host_io_error)?
100            .ok_or_else(|| common::Error::new(common::ErrorKind::UserCancelled, ""))?;
101        if let Some(response) = parse_host_response(request, response) {
102            return Ok(response);
103        }
104    }
105}
106
107fn host_prompt(request: &HostRequest) -> String {
108    match request {
109        HostRequest::PinMatrix { kind } => match kind {
110            PinMatrixRequestKind::Current => {
111                format!("{PIN_MATRIX_DESCRIPTION}\nEnter current PIN positions:\n")
112            }
113            PinMatrixRequestKind::NewFirst => {
114                format!("{PIN_MATRIX_DESCRIPTION}\nEnter new PIN positions:\n")
115            }
116            PinMatrixRequestKind::NewSecond => {
117                format!("{PIN_MATRIX_DESCRIPTION}\nRe-enter new PIN positions:\n")
118            }
119            PinMatrixRequestKind::Unknown(code) => {
120                format!("{PIN_MATRIX_DESCRIPTION}\nEnter PIN positions for request {code}:\n")
121            }
122        },
123        HostRequest::RecoveryCharacter {
124            word_position,
125            character_position,
126        } => format!(
127            "Recovery word {word_position}, character {character_position} (letter/space/backspace/done):\n"
128        ),
129    }
130}
131
132fn parse_host_response(request: &HostRequest, response: String) -> Option<HostResponse> {
133    match request {
134        HostRequest::PinMatrix { .. }
135            if !response.is_empty() && response.bytes().all(|byte| byte.is_ascii_digit()) =>
136        {
137            Some(HostResponse::PinPositions(response))
138        }
139        HostRequest::RecoveryCharacter { .. } => match response.as_str() {
140            "space" => Some(HostResponse::RecoveryNextWord),
141            "backspace" => Some(HostResponse::RecoveryDelete),
142            "done" => Some(HostResponse::RecoveryDone),
143            _ if response.len() == 1 && response.as_bytes()[0].is_ascii_lowercase() => Some(
144                HostResponse::RecoveryCharacter(response.as_bytes()[0] as char),
145            ),
146            _ => None,
147        },
148        _ => None,
149    }
150}
151
152fn host_io_error(error: io::Error) -> common::Error {
153    if matches!(
154        error.kind(),
155        io::ErrorKind::UnexpectedEof | io::ErrorKind::Interrupted
156    ) {
157        common::Error::new(common::ErrorKind::UserCancelled, "")
158    } else {
159        common::Error::new(
160            common::ErrorKind::Rejected,
161            format!("host interaction failed: {error}"),
162        )
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn pin_input_repeats_until_nonempty_ascii_digits() {
172        let request = HostRequest::PinMatrix {
173            kind: PinMatrixRequestKind::NewFirst,
174        };
175        let mut lines = [Some(""), Some("12"), Some("12a"), Some("7913")].into_iter();
176        let mut prompts = Vec::new();
177        let response = read_host_response(
178            &request,
179            || Ok(lines.next().flatten().map(str::to_owned)),
180            |prompt| {
181                prompts.push(prompt.to_owned());
182                Ok(())
183            },
184        )
185        .unwrap();
186
187        assert_eq!(response, HostResponse::PinPositions("7913".to_owned()));
188        assert_eq!(prompts.len(), 4);
189        assert!(prompts.iter().all(|prompt| prompt.contains("new PIN")));
190    }
191
192    #[test]
193    fn recovery_input_accepts_only_cipher_letters_and_actions() {
194        let request = HostRequest::RecoveryCharacter {
195            word_position: 4,
196            character_position: 2,
197        };
198        for (input, expected) in [
199            ("q", HostResponse::RecoveryCharacter('q')),
200            ("space", HostResponse::RecoveryNextWord),
201            ("backspace", HostResponse::RecoveryDelete),
202            ("done", HostResponse::RecoveryDone),
203        ] {
204            assert_eq!(
205                parse_host_response(&request, input.to_owned()),
206                Some(expected)
207            );
208        }
209        for input in ["Q", "qq", "", "delete", " space"] {
210            assert_eq!(parse_host_response(&request, input.to_owned()), None);
211        }
212        let prompt = host_prompt(&request);
213        assert!(prompt.contains("word 4"));
214        assert!(prompt.contains("character 2"));
215    }
216
217    #[test]
218    fn hidden_input_distinguishes_blank_lines_from_eof() {
219        assert_eq!(
220            completed_hidden_line(String::new(), true),
221            Some(String::new())
222        );
223        assert_eq!(completed_hidden_line(String::new(), false), None);
224    }
225
226    #[test]
227    fn host_input_eof_is_user_cancellation() {
228        let request = HostRequest::PinMatrix {
229            kind: PinMatrixRequestKind::Current,
230        };
231        assert!(matches!(
232            read_host_response(&request, || Ok(None), |_| Ok(())),
233            Err(e) if e.kind() == common::ErrorKind::UserCancelled
234        ));
235    }
236}