diff --git a/keyfork-prompt/src/lib.rs b/keyfork-prompt/src/lib.rs index da6eb37..81e2ff1 100644 --- a/keyfork-prompt/src/lib.rs +++ b/keyfork-prompt/src/lib.rs @@ -20,6 +20,8 @@ mod raw_mode; use alternate_screen::AlternateScreen; use raw_mode::RawMode; +pub mod validators; + #[cfg(feature = "qrencode")] pub mod qrencode; @@ -28,6 +30,9 @@ pub enum Error { #[error("The given handler is not a TTY")] NotATTY, + #[error("Validation of the input failed after {0} retries (last error: {1})")] + Validation(u8, String), + #[error("IO Error: {0}")] IO(#[from] std::io::Error), } @@ -80,6 +85,37 @@ where Ok(line) } + #[cfg(feature = "mnemonic")] + pub fn prompt_validated_wordlist( + &mut self, + prompt: &str, + wordlist: &Wordlist, + retries: u8, + validator_fn: F, + ) -> Result + where + F: Fn(String) -> Result, + E: std::error::Error, + { + let mut last_error = None; + for _ in 0..retries { + let s = self.prompt_wordlist(prompt, wordlist)?; + match validator_fn(s) { + Ok(v) => return Ok(v), + Err(e) => { + self.prompt_message(&Message::Text(format!("Error validating wordlist: {e}")))?; + let _ = last_error.insert(e); + } + } + } + Err(Error::Validation( + retries, + last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "Unknown".to_string()), + )) + } + // TODO: create a wrapper for bracketed paste similar to RawMode #[cfg(feature = "mnemonic")] #[allow(clippy::too_many_lines)] @@ -204,6 +240,36 @@ where Ok(input) } + #[cfg(feature = "mnemonic")] + pub fn prompt_validated_passphrase( + &mut self, + prompt: &str, + retries: u8, + validator_fn: F, + ) -> Result + where + F: Fn(String) -> Result, + E: std::error::Error, + { + let mut last_error = None; + for _ in 0..retries { + let s = self.prompt_passphrase(prompt)?; + match validator_fn(s) { + Ok(v) => return Ok(v), + Err(e) => { + self.prompt_message(&Message::Text(format!("Error validating passphrase: {e}")))?; + let _ = last_error.insert(e); + } + } + } + Err(Error::Validation( + retries, + last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "Unknown".to_string()), + )) + } + // TODO: return secrecy::Secret pub fn prompt_passphrase(&mut self, prompt: &str) -> Result { let mut terminal = AlternateScreen::new(&mut self.write)?; diff --git a/keyfork-prompt/src/validators.rs b/keyfork-prompt/src/validators.rs new file mode 100644 index 0000000..d602a05 --- /dev/null +++ b/keyfork-prompt/src/validators.rs @@ -0,0 +1,54 @@ +#![allow(clippy::type_complexity)] + +pub trait Validator { + type Output; + type Error; + + fn to_fn(&self) -> Box Result>; +} + +#[derive(thiserror::Error, Debug)] +pub enum PinError { + #[error("PIN too short: {0} < {1}")] + TooShort(usize, usize), + + #[error("PIN too long: {0} > {1}")] + TooLong(usize, usize), + + #[error("PIN contained invalid characters (found {0} at position {1})")] + InvalidCharacters(char, usize), +} + +#[derive(Default, Clone)] +pub struct PinValidator { + pub min_length: Option, + pub max_length: Option, + pub range: Option>, +} + +impl Validator for PinValidator { + type Output = String; + type Error = PinError; + + fn to_fn(&self) -> Box Result> { + let min_len = self.min_length.unwrap_or(usize::MIN); + let max_len = self.max_length.unwrap_or(usize::MAX); + let range = self.range.clone().unwrap_or('0'..='9'); + Box::new(move |mut s: String| { + s.truncate(s.trim_end().len()); + let len = s.len(); + if len < min_len { + return Err(PinError::TooShort(len, min_len)); + } + if len > max_len { + return Err(PinError::TooLong(len, max_len)); + } + for (index, ch) in s.chars().enumerate() { + if !range.contains(&ch) { + return Err(PinError::InvalidCharacters(ch, index)); + } + } + Ok(s) + }) + } +} diff --git a/keyfork-shard/src/openpgp/smartcard.rs b/keyfork-shard/src/openpgp/smartcard.rs index 5bef729..a4b0039 100644 --- a/keyfork-shard/src/openpgp/smartcard.rs +++ b/keyfork-shard/src/openpgp/smartcard.rs @@ -1,6 +1,10 @@ use std::collections::{HashMap, HashSet}; -use keyfork_prompt::{default_prompt_manager, DefaultPromptManager, Error as PromptError, Message}; +use keyfork_prompt::{ + default_prompt_manager, + validators::{PinValidator, Validator}, + DefaultPromptManager, Error as PromptError, Message, +}; use super::openpgp::{ self, @@ -222,6 +226,11 @@ impl DecryptionHelper for &mut SmartcardManager { .context("Could not load application identifier")? .ident(); let mut pin = self.pin_cache.get(&fp).cloned(); + let pin_validator = PinValidator { + min_length: Some(6), + ..Default::default() + } + .to_fn(); while transaction .pw_status_bytes() .map_err(Error::PwStatusBytes)? @@ -240,7 +249,9 @@ impl DecryptionHelper for &mut SmartcardManager { } else { format!("Unlock card {card_id} ({cardholder_name})\n{rpea}: {attempts}\n\nPIN: ") }; - let temp_pin = self.pm.prompt_passphrase(&message)?; + let temp_pin = self + .pm + .prompt_validated_passphrase(&message, 3, &pin_validator)?; let verification_status = transaction.verify_user_pin(temp_pin.as_str().trim()); match verification_status { #[allow(clippy::ignored_unit_patterns)] @@ -248,8 +259,11 @@ impl DecryptionHelper for &mut SmartcardManager { self.pin_cache.insert(fp.clone(), temp_pin.clone()); pin.replace(temp_pin); } + // NOTE: This should not be hit, because of the above validator. Err(CardError::CardStatus(StatusBytes::IncorrectParametersCommandDataField)) => { - self.pm.prompt_message(&Message::Text("Invalid PIN length entered.".to_string()))?; + self.pm.prompt_message(&Message::Text( + "Invalid PIN length entered.".to_string(), + ))?; } Err(_) => {} } diff --git a/keyfork/src/cli/wizard.rs b/keyfork/src/cli/wizard.rs index bfde049..568d6b4 100644 --- a/keyfork/src/cli/wizard.rs +++ b/keyfork/src/cli/wizard.rs @@ -10,7 +10,14 @@ use keyfork_derive_util::{ request::{DerivationAlgorithm, DerivationRequest}, DerivationIndex, DerivationPath, }; -use keyfork_prompt::{Message, PromptManager}; +use keyfork_prompt::{ + validators::{PinValidator, Validator}, + Message, PromptManager, +}; + +#[derive(thiserror::Error, Debug)] +#[error("Invalid PIN length: {0}")] +pub struct PinLength(usize); type Result> = std::result::Result; @@ -105,6 +112,17 @@ fn generate_shard_secret(threshold: u8, max: u8, keys_per_shard: u8) -> Result<( "not printing shard to terminal, redirect output" ); + let user_pin_validator = PinValidator { + min_length: Some(6), + ..Default::default() + } + .to_fn(); + let admin_pin_validator = PinValidator { + min_length: Some(8), + ..Default::default() + } + .to_fn(); + for index in 0..max { let cert = derive_key(&seed, index)?; for i in 0..keys_per_shard { @@ -113,10 +131,16 @@ fn generate_shard_secret(threshold: u8, max: u8, keys_per_shard: u8) -> Result<( i + 1, index + 1, )))?; - // TODO: add a second prompt for verification, perhaps as an argument to - // prompt_passphrase - let user_pin = pm.prompt_passphrase("Please enter the new smartcard User PIN: ")?; - let admin_pin = pm.prompt_passphrase("Please enter the new smartcard Admin PIN: ")?; + let user_pin = pm.prompt_validated_passphrase( + "Please enter the new smartcard User PIN: ", + 3, + &user_pin_validator, + )?; + let admin_pin = pm.prompt_validated_passphrase( + "Please enter the new smartcard Admin PIN: ", + 3, + &admin_pin_validator, + )?; factory_reset_current_card(&mut seen_cards, user_pin.trim(), admin_pin.trim(), &cert)?; } certs.push(cert);