diff --git a/src/main.rs b/src/main.rs index 16d4027..37db34f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ use age_plugin::run_state_machine; +use dialoguer::{Confirm, Select}; use gumdrop::Options; use log::warn; use yubikey_piv::{ certificate::PublicKeyInfo, key::{RetiredSlotId, SlotId}, + policy::{PinPolicy, TouchPolicy}, Key, Readers, }; @@ -276,7 +278,194 @@ fn main() -> Result<(), Error> { } else if opts.list_all { list(true) } else { - // TODO: CLI identity generation + eprintln!("✨ Let's get your YubiKey set up for age! ✨"); + eprintln!(""); + eprintln!("This tool can create a new age identity in a free slot of your YubiKey."); + eprintln!("It will generate an identity file that you can use with an age client,"); + eprintln!("along with the corresponding recipient."); + eprintln!(""); + eprintln!("If you are already using a YubiKey with age, you can select an existing"); + eprintln!("slot to recreate its corresponding identity file and recipient."); + eprintln!(""); + eprintln!("When asked below to select an option, use the up/down arrow keys to"); + eprintln!("make your choice, or press [Esc] or [q] to quit."); + eprintln!(""); + + if Readers::open()?.iter()?.len() == 0 { + eprintln!("⏳ Please insert the YubiKey you want to set up."); + }; + let mut readers = yubikey::wait_for_readers()?; + + // Filter out readers we can't connect to. + let readers_list: Vec<_> = readers + .iter()? + .filter_map(|reader| match reader.open() { + Ok(_) => Some(reader), + Err(e) => { + use std::error::Error; + let reason = if let Some(inner) = e.source() { + format!("{}: {}", e, inner) + } else { + e.to_string() + }; + warn!("Ignoring {}: {}", reader.name(), reason); + None + } + }) + .collect(); + + let reader_names = readers_list + .iter() + .map(|reader| { + reader + .open() + .map(|yk| format!("{} (Serial: {})", reader.name(), yk.serial())) + }) + .collect::, _>>()?; + let mut yubikey = match Select::new() + .with_prompt("🔑 Select a YubiKey") + .items(&reader_names) + .default(0) + .interact_opt()? + { + Some(yk) => readers_list[yk].open()?, + None => return Ok(()), + }; + + let keys = Key::list(&mut yubikey)?; + + // Identify slots that we can't allow the user to select. + let slot_details: Vec<_> = USABLE_SLOTS + .iter() + .map(|&slot| { + keys.iter() + .find(|key| key.slot() == SlotId::Retired(slot)) + .map(|key| match key.certificate().subject_pki() { + PublicKeyInfo::EcP256(pubkey) => { + p256::Recipient::from_pubkey(*pubkey).map(|_| { + // Cache the details we need to display to the user. + let (_, cert) = + x509_parser::parse_x509_certificate(key.certificate().as_ref()) + .unwrap(); + let (name, _) = util::extract_name(&cert, true).unwrap(); + let created = cert.validity().not_before.to_rfc2822(); + + format!("{}, created: {}", name, created) + }) + } + _ => None, + }) + }) + .collect(); + + let slots: Vec<_> = slot_details + .iter() + .enumerate() + .map(|(i, occupied)| { + // Use 1-indexing in the UI for niceness + let i = i + 1; + + match occupied { + Some(Some(name)) => format!("Slot {} ({})", i, name), + Some(None) => format!("Slot {} (Unusable)", i), + None => format!("Slot {} (Empty)", i), + } + }) + .collect(); + + let (stub, recipient, created) = { + let (slot_index, slot) = loop { + match Select::new() + .with_prompt("🕳️ Select a slot for your age identity") + .items(&slots) + .default(0) + .interact_opt()? + { + Some(slot) => { + if let Some(None) = slot_details[slot] { + } else { + break (slot + 1, USABLE_SLOTS[slot]); + } + } + None => return Ok(()), + } + }; + + if let Some(key) = keys.iter().find(|key| key.slot() == SlotId::Retired(slot)) { + let recipient = match key.certificate().subject_pki() { + PublicKeyInfo::EcP256(pubkey) => { + p256::Recipient::from_pubkey(*pubkey).expect("We checked this above") + } + _ => unreachable!(), + }; + + if Confirm::new() + .with_prompt(&format!("Use existing identity in slot {}?", slot_index)) + .interact()? + { + let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient); + let (_, cert) = + x509_parser::parse_x509_certificate(key.certificate().as_ref()).unwrap(); + let created = cert.validity().not_before.to_rfc2822(); + + (stub, recipient, created) + } else { + return Ok(()); + } + } else { + let pin_policy = match Select::new() + .with_prompt("🔤 Select a PIN policy") + .items(&[ + "Always (A PIN is required for every decryption, if set)", + "Once (A PIN is required once per session, if set)", + "Never (A PIN is NOT required to decrypt)", + ]) + .default(1) + .interact_opt()? + { + Some(0) => PinPolicy::Always, + Some(1) => PinPolicy::Once, + Some(2) => PinPolicy::Never, + Some(_) => unreachable!(), + None => return Ok(()), + }; + + let touch_policy = match Select::new() + .with_prompt("👆 Select a touch policy") + .items(&[ + "Always (A physical touch is required for every decryption)", + "Cached (A physical touch is required for decryption, and is cached for 15 seconds)", + "Never (A physical touch is NOT required to decrypt)", + ]) + .default(0) + .interact_opt()? + { + Some(0) => TouchPolicy::Always, + Some(1) => TouchPolicy::Cached, + Some(2) => TouchPolicy::Never, + Some(_) => unreachable!(), + None => return Ok(()), + }; + + if Confirm::new() + .with_prompt(&format!("Generate new identity in slot {}?", slot_index)) + .interact()? + { + eprintln!(); + builder::IdentityBuilder::new(Some(slot)) + .with_name(opts.name) + .with_pin_policy(Some(pin_policy)) + .with_touch_policy(Some(touch_policy)) + .force(opts.force) + .build(&mut yubikey)? + } else { + return Ok(()); + } + } + }; + + util::print_identity(stub, recipient, &created); + Ok(()) } }