diff --git a/Cargo.lock b/Cargo.lock index 117b2c8..1c46d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,13 +44,18 @@ dependencies = [ "age-core", "age-plugin", "bech32", + "chrono", "console", + "dialoguer", "elliptic-curve", "env_logger", "gumdrop", + "hex", "log", "p256", + "rand 0.8.3", "sha2", + "x509", "x509-parser", "yubikey-piv", ] @@ -299,6 +304,18 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "dialoguer" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9dd058f8b65922819fabb4a41e7d1964e56344042c26efbccd465202c23fa0c" +dependencies = [ + "console", + "lazy_static", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -443,6 +460,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index b34369c..b9fc361 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,13 +14,18 @@ edition = "2018" age-core = "0.5" age-plugin = "0.0" bech32 = "0.8" +chrono = "0.4" console = "0.14" +dialoguer = "0.8" elliptic-curve = "0.8" env_logger = "0.8" gumdrop = "0.8" +hex = "0.4" log = "0.4" p256 = "0.7" +rand = "0.8" sha2 = "0.9" +x509 = "0.2" x509-parser = "0.9" yubikey-piv = { version = "0.3", features = ["untested"] } diff --git a/src/builder.rs b/src/builder.rs new file mode 100644 index 0000000..7f05680 --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,153 @@ +use rand::{rngs::OsRng, RngCore}; +use x509::RelativeDistinguishedName; +use yubikey_piv::{ + certificate::{Certificate, PublicKeyInfo}, + key::{generate as yubikey_generate, AlgorithmId, RetiredSlotId, SlotId}, + policy::{PinPolicy, TouchPolicy}, + Key, YubiKey, +}; + +use crate::{ + error::Error, + p256::Recipient, + util::POLICY_EXTENSION_OID, + yubikey::{self, Stub}, + PLUGIN_NAME, USABLE_SLOTS, +}; + +const DEFAULT_PIN_POLICY: PinPolicy = PinPolicy::Once; +const DEFAULT_TOUCH_POLICY: TouchPolicy = TouchPolicy::Always; + +pub(crate) struct IdentityBuilder { + slot: Option, + force: bool, + name: Option, + pin_policy: Option, + touch_policy: Option, +} + +impl IdentityBuilder { + pub(crate) fn new(slot: Option) -> Self { + IdentityBuilder { + slot, + name: None, + pin_policy: None, + touch_policy: None, + force: false, + } + } + + pub(crate) fn with_name(mut self, name: Option) -> Self { + self.name = name; + self + } + + pub(crate) fn with_pin_policy(mut self, pin_policy: Option) -> Self { + self.pin_policy = pin_policy; + self + } + + pub(crate) fn with_touch_policy(mut self, touch_policy: Option) -> Self { + self.touch_policy = touch_policy; + self + } + + pub(crate) fn force(mut self, force: bool) -> Self { + self.force = force; + self + } + + pub(crate) fn build(self, yubikey: &mut YubiKey) -> Result<(Stub, Recipient, String), Error> { + let slot = match self.slot { + Some(slot) => { + if !self.force { + // Check that the slot is empty. + if Key::list(yubikey)? + .into_iter() + .any(|key| key.slot() == SlotId::Retired(slot)) + { + return Err(Error::SlotIsNotEmpty(slot)); + } + } + + // Now either the slot is empty, or --force is specified. + slot + } + None => { + // Use the first empty slot. + let keys = Key::list(yubikey)?; + USABLE_SLOTS + .iter() + .find(|&&slot| { + keys.iter() + .find(|key| key.slot() == SlotId::Retired(slot)) + .is_none() + }) + .cloned() + .ok_or(Error::NoEmptySlots(yubikey.serial()))? + } + }; + + let pin_policy = self.pin_policy.unwrap_or(DEFAULT_PIN_POLICY); + let touch_policy = self.touch_policy.unwrap_or(DEFAULT_TOUCH_POLICY); + + // No need to ask for users to enter their PIN if the PIN policy requires it, + // because here we _always_ require them to enter their PIN in order to access the + // protected management key (which is necessary in order to generate identities). + yubikey::manage(yubikey)?; + + if let TouchPolicy::Never = touch_policy { + // No need to touch YubiKey + } else { + eprintln!("👆 Please touch the YubiKey"); + } + + // Generate a new key in the selected slot. + let generated = yubikey_generate( + yubikey, + SlotId::Retired(slot), + AlgorithmId::EccP256, + pin_policy, + touch_policy, + )?; + + let recipient = match &generated { + PublicKeyInfo::EcP256(pubkey) => { + Recipient::from_pubkey(*pubkey).expect("YubiKey generates a valid pubkey") + } + _ => unreachable!(), + }; + let stub = Stub::new(yubikey.serial(), slot, &recipient); + + // Pick a random serial for the new self-signed certificate. + let mut serial = [0; 20]; + OsRng.fill_bytes(&mut serial); + + let name = self + .name + .unwrap_or(format!("age identity {}", hex::encode(stub.tag))); + + Certificate::generate_self_signed( + yubikey, + SlotId::Retired(slot), + serial, + None, + &[ + RelativeDistinguishedName::organization(PLUGIN_NAME), + RelativeDistinguishedName::organizational_unit(env!("CARGO_PKG_VERSION")), + RelativeDistinguishedName::common_name(&name), + ], + generated, + &[x509::Extension::regular( + POLICY_EXTENSION_OID, + &[pin_policy.into(), touch_policy.into()], + )], + )?; + + Ok(( + Stub::new(yubikey.serial(), slot, &recipient), + recipient, + chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + )) + } +} diff --git a/src/error.rs b/src/error.rs index 6f2a5dc..6662137 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,14 +5,20 @@ use yubikey_piv::{key::RetiredSlotId, Serial}; use crate::USABLE_SLOTS; pub enum Error { + CustomManagementKey, + InvalidPinLength, + InvalidPinPolicy(String), InvalidSlot(u8), + InvalidTouchPolicy(String), Io(io::Error), MultipleCommands, MultipleIdentities, MultipleYubiKeys, + NoEmptySlots(Serial), NoIdentities, NoMatchingSerial(Serial), SlotHasNoIdentity(RetiredSlotId), + SlotIsNotEmpty(RetiredSlotId), TimedOut, YubiKey(yubikey_piv::Error), } @@ -34,11 +40,25 @@ impl From for Error { impl fmt::Debug for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Error::CustomManagementKey => { + writeln!(f, "Custom unprotected management keys are not supported.")? + } + Error::InvalidPinLength => writeln!(f, "The PIN needs to be 1-8 characters.")?, + Error::InvalidPinPolicy(s) => writeln!( + f, + "Invalid PIN policy '{}' (expected [always, once, never]).", + s + )?, Error::InvalidSlot(slot) => writeln!( f, "Invalid slot '{}' (expected number between 1 and 20).", slot )?, + Error::InvalidTouchPolicy(s) => writeln!( + f, + "Invalid touch policy '{}' (expected [always, cached, never]).", + s + )?, Error::Io(e) => writeln!(f, "Failed to set up YubiKey: {}", e)?, Error::MultipleCommands => writeln!( f, @@ -52,6 +72,9 @@ impl fmt::Debug for Error { f, "Multiple YubiKeys are plugged in. Use --serial to select a single YubiKey." )?, + Error::NoEmptySlots(serial) => { + writeln!(f, "YubiKey with serial {} has no empty slots.", serial)? + } Error::NoIdentities => { writeln!(f, "This YubiKey does not contain any age identities.")? } @@ -63,6 +86,11 @@ impl fmt::Debug for Error { "Slot {} does not contain an age identity or compatible key.", USABLE_SLOTS.iter().position(|s| s == slot).unwrap() + 1 )?, + Error::SlotIsNotEmpty(slot) => writeln!( + f, + "Slot {} is not empty. Use --force to overwrite the slot.", + USABLE_SLOTS.iter().position(|s| s == slot).unwrap() + 1 + )?, Error::TimedOut => { writeln!(f, "Timed out while waiting for a YubiKey to be inserted.")? } @@ -70,6 +98,11 @@ impl fmt::Debug for Error { yubikey_piv::error::Error::NotFound => { writeln!(f, "Please insert the YubiKey you want to set up")? } + yubikey_piv::error::Error::WrongPin { tries } => writeln!( + f, + "Invalid PIN ({} tries remaining before it is blocked)", + tries + )?, e => { writeln!(f, "Error while communicating with YubiKey: {}", e)?; use std::error::Error; diff --git a/src/main.rs b/src/main.rs index fa128c0..e6b9088 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use yubikey_piv::{ Key, Readers, }; +mod builder; mod error; mod p256; mod plugin; @@ -54,6 +55,9 @@ struct PluginOptions { )] age_plugin: Option, + #[options(help = "Force --generate to overwrite a filled slot.")] + force: bool, + #[options(help = "Generate a new YubiKey identity.")] generate: bool, @@ -66,6 +70,15 @@ struct PluginOptions { #[options(help = "List all YubiKey keys that are compatible with age.", no_short)] list_all: bool, + #[options( + help = "Name for the generated identity. Defaults to 'age identity HEX_TAG'.", + no_short + )] + name: Option, + + #[options(help = "One of [always, once, never]. Defaults to 'once'.", no_short)] + pin_policy: Option, + #[options( help = "Specify which YubiKey to use, if more than one is plugged in.", no_short @@ -77,6 +90,46 @@ struct PluginOptions { no_short )] slot: Option, + + #[options( + help = "One of [always, cached, never]. Defaults to 'always'.", + no_short + )] + touch_policy: Option, +} + +fn generate(opts: PluginOptions) -> Result<(), Error> { + let serial = opts.serial.map(|s| s.into()); + let slot = opts + .slot + .map(|slot| { + USABLE_SLOTS + .get(slot as usize - 1) + .cloned() + .ok_or(Error::InvalidSlot(slot)) + }) + .transpose()?; + let pin_policy = opts + .pin_policy + .map(util::pin_policy_from_string) + .transpose()?; + let touch_policy = opts + .touch_policy + .map(util::touch_policy_from_string) + .transpose()?; + + let mut yubikey = yubikey::open(serial)?; + + let (stub, recipient, created) = builder::IdentityBuilder::new(slot) + .with_name(opts.name) + .with_pin_policy(pin_policy) + .with_touch_policy(touch_policy) + .force(opts.force) + .build(&mut yubikey)?; + + util::print_identity(stub, recipient, &created); + + Ok(()) } fn identity(opts: PluginOptions) -> Result<(), Error> { @@ -232,7 +285,7 @@ fn main() -> Result<(), Error> { )?; Ok(()) } else if opts.generate { - todo!() + generate(opts) } else if opts.identity { identity(opts) } else if opts.list { diff --git a/src/util.rs b/src/util.rs index c296e08..0a027a1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,9 +4,27 @@ use yubikey_piv::{ Key, YubiKey, }; -use crate::{p256::Recipient, yubikey::Stub, PLUGIN_NAME}; +use crate::{error::Error, p256::Recipient, yubikey::Stub, PLUGIN_NAME}; -const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8]; +pub(crate) const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8]; + +pub(crate) fn pin_policy_from_string(s: String) -> Result { + match s.as_str() { + "always" => Ok(PinPolicy::Always), + "once" => Ok(PinPolicy::Once), + "never" => Ok(PinPolicy::Never), + _ => Err(Error::InvalidPinPolicy(s)), + } +} + +pub(crate) fn touch_policy_from_string(s: String) -> Result { + match s.as_str() { + "always" => Ok(TouchPolicy::Always), + "cached" => Ok(TouchPolicy::Cached), + "never" => Ok(TouchPolicy::Never), + _ => Err(Error::InvalidTouchPolicy(s)), + } +} pub(crate) fn pin_policy_to_str(policy: Option) -> &'static str { match policy { diff --git a/src/yubikey.rs b/src/yubikey.rs index 19cf4ca..43c84d9 100644 --- a/src/yubikey.rs +++ b/src/yubikey.rs @@ -1,10 +1,11 @@ //! Structs for handling YubiKeys. use bech32::{ToBase32, Variant}; +use dialoguer::Password; use std::fmt; use std::thread::sleep; use std::time::{Duration, SystemTime}; -use yubikey_piv::{key::RetiredSlotId, yubikey::Serial, Readers, YubiKey}; +use yubikey_piv::{key::RetiredSlotId, yubikey::Serial, MgmKey, Readers, YubiKey}; use crate::{ error::Error, @@ -69,6 +70,57 @@ pub(crate) fn open(serial: Option) -> Result { Ok(yubikey) } +pub(crate) fn manage(yubikey: &mut YubiKey) -> Result<(), Error> { + eprintln!(""); + let pin = Password::new() + .with_prompt(&format!( + "Enter PIN for YubiKey with serial {} (default is 123456)", + yubikey.serial(), + )) + .interact()?; + yubikey.verify_pin(pin.as_bytes())?; + + // If the user is using the default PIN, help them to change it. + if pin == "123456" { + eprintln!(""); + eprintln!("✨ Your key is using the default PIN. Let's change it!"); + eprintln!("✨ We'll also set the PUK equal to the PIN."); + eprintln!(""); + eprintln!("🔐 The PIN is up to 8 numbers, letters, or symbols. Not just numbers!"); + eprintln!( + "❌ Your keys will be lost if the PIN and PUK are locked after 3 incorrect tries." + ); + eprintln!(""); + let current_puk = Password::new() + .with_prompt("Enter current PUK (default is 12345678)") + .interact()?; + let new_pin = Password::new() + .with_prompt("Choose a new PIN/PUK") + .with_confirmation("Repeat the PIN/PUK", "PINs don't match") + .interact()?; + if new_pin.len() > 8 { + return Err(Error::InvalidPinLength); + } + yubikey.change_puk(current_puk.as_bytes(), new_pin.as_bytes())?; + yubikey.change_pin(pin.as_bytes(), new_pin.as_bytes())?; + } + + if let Ok(mgm_key) = MgmKey::get_protected(yubikey) { + yubikey.authenticate(mgm_key)?; + } else { + // Try to authenticate with the default management key. + yubikey + .authenticate(MgmKey::default()) + .map_err(|_| Error::CustomManagementKey)?; + + // Migrate to a PIN-protected management key. + let mgm_key = MgmKey::generate()?; + mgm_key.set_protected(yubikey)?; + } + + Ok(()) +} + /// A reference to an age key stored in a YubiKey. #[derive(Debug)] pub struct Stub {