diff --git a/.gitignore b/.gitignore index c9022e5..519d807 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.idea/ + # ---> macOS # General .DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..86804c6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pinentry-cli" +version = "0.1.0" +edition = "2021" + +[dependencies] +aes-gcm-stream = "0.2.3" +clap = { version = "4.5.8", features = ["derive"] } +hex = "0.4.3" +pinentry = "0.5.0" +rand = "0.8.5" +rpassword = "7.3.1" +secrecy = "0.8.0" +serde = { version = "1.0.203", features = ["serde_derive"] } +serde_json = "1.0.118" +zeroize = "1.8.1" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ab4dd7e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,150 @@ +use std::{env, fs}; +use std::process::exit; + +use aes_gcm_stream::Aes256GcmStreamEncryptor; +use clap::Parser; +use pinentry::PassphraseInput; +use rand::random; +use secrecy::ExposeSecret; +use serde::Serialize; + +#[derive(Debug, Parser)] +#[command(name = "pinentry-cli")] +#[command(about = "PIN entry command line", long_about = None)] +struct Cli { + /// pinentry command location + /// e.g. in MacOS maybe /usr/local/MacGPG2/libexec/pinentry-mac.app/Contents/MacOS/pinentry-mac + /// , or set environment export PIN_ENTRY_CMD=pinentry-cmd + #[arg(long, short = 'e')] + pub pin_entry: Option, + /// Description in pinentry + #[arg(long, short = 'd')] + pub description: Option, + // Prompt in pinentry + #[arg(long)] + pub prompt: Option, + // Encryption key, must be 32 bytes and in HEX + #[arg(long, short = 'k')] + pub encryption_key: String, + // Disable fallback to rpassword + #[arg(long, short = 'C')] + pub disable_fallback_cli: Option, +} + +#[derive(Serialize)] +struct PinResult { + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin: Option, +} + +impl PinResult { + pub fn new_pin(key: &[u8; 32], pin: String) -> PinResult { + PinResult { + success: true, + error: None, + pin: Some(encrypt(key, pin)), + } + } + + pub fn new_error(error: String) -> PinResult { + PinResult { + success: false, + error: Some(error), + pin: None, + } + } +} + +const PIN_ENTRY_ENV: &str = "PIN_ENTRY_CMD"; +const PIN_ENTRY_1: &str = "/usr/local/MacGPG2/libexec/pinentry-mac.app/Contents/MacOS/pinentry-mac"; +const PIN_ENTRY_DEFAULT: &str = "pinentry"; + +fn main() { + let args = Cli::parse(); + let pin_result = get_pin(args); + let result = serde_json::to_string(&pin_result).unwrap(); + + println!("{}", &result); + exit(if pin_result.success { 0 } else { -1 }); +} + +fn get_pin_entry(args: &Cli) -> String { + args.pin_entry.clone().unwrap_or_else(|| { + if let Ok(pin_entry) = env::var(PIN_ENTRY_ENV) { + return pin_entry; + } + if let Ok(m) = fs::metadata(PIN_ENTRY_1) { + if m.is_file() { + return PIN_ENTRY_1.to_string(); + } + } + PIN_ENTRY_DEFAULT.to_string() + }) +} + +fn get_encryption_key(args: &Cli) -> Result<[u8; 32], PinResult> { + let mut encryption_key = [0_u8; 32]; + + match hex::decode(&args.encryption_key) { + Ok(key) => { + if key.len() != 32 { + return Err(PinResult::new_error(format!("Bad encryption key length, expected 32, actual: {}", key.len()))); + } + encryption_key.copy_from_slice(&key[..32]); + } + Err(e) => return Err(PinResult::new_error(format!("{}", e))) + }; + Ok(encryption_key) +} + +fn get_pin(args: Cli) -> PinResult { + let pin_entry = get_pin_entry(&args); + let description = args.description.as_deref().unwrap_or("Please input PIN"); + let prompt = args.prompt.as_deref().unwrap_or("PIN: "); + let fallback_cli = !args.disable_fallback_cli.unwrap_or(false); + let encryption_key = match get_encryption_key(&args) { + Ok(encryption_key) => encryption_key, + Err(pin_result) => return pin_result, + }; + + if let Some(mut input) = PassphraseInput::with_binary(pin_entry) { + let secret = input + .with_description(&format!("{}.", description)) + .with_prompt(prompt) + .interact(); + + match secret { + Ok(secret_string) => { + PinResult::new_pin(&encryption_key, secret_string.expose_secret().to_string()) + } + Err(e) => { + PinResult::new_error(format!("{}", e)) + } + } + } else if fallback_cli { + match rpassword::prompt_password(format!("{}: ", description)) { + Ok(pin) => { + PinResult::new_pin(&encryption_key, pin) + } + Err(e) => { + PinResult::new_error(format!("{}", e)) + } + } + } else { + PinResult::new_error(String::from("pinentry not found and --disable-fallback-cli is turned on.")) + } +} + +fn encrypt(key: &[u8; 32], pin: String) -> String { + let nonce = random::<[u8; 12]>(); + let mut encrypted = vec![]; + let mut encryptor = Aes256GcmStreamEncryptor::new(*key, &nonce); + encrypted.extend_from_slice(&encryptor.update(pin.as_bytes())); + let (c, t) = encryptor.finalize(); + encrypted.extend_from_slice(&c); + encrypted.extend_from_slice(&t); + format!("{}.{}", hex::encode(nonce), hex::encode(&encrypted)) +}