diff --git a/Cargo.lock b/Cargo.lock index 9c56763..5ebe693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,9 +43,11 @@ dependencies = [ "age-core", "age-plugin", "bech32", + "console", "elliptic-curve", "gumdrop", "p256", + "sha2", "x509-parser 0.9.0", "yubikey-piv", ] @@ -186,6 +188,21 @@ dependencies = [ "generic-array", ] +[[package]] +name = "console" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc80946b3480f421c2f17ed1cb841753a371c7c5104f51d507e13f532c856aa" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "regex", + "terminal_size", + "unicode-width", + "winapi", +] + [[package]] name = "const-oid" version = "0.1.0" @@ -313,6 +330,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "ff" version = "0.8.0" @@ -909,6 +932,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36474e732d1affd3a6ed582781b3683df3d0563714c59c39591e8ff707cf078e" +[[package]] +name = "terminal_size" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd2d183bd3fac5f5fe38ddbeb4dc9aec4a39a9d7d59e7491d900302da01cbe1" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.23" @@ -946,6 +979,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "373c8a200f9e67a0c95e62a4f52fbf80c23b4381c05a17845531982fa99e6b33" +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + [[package]] name = "unicode-xid" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7ea6af1..b1b9aa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,11 @@ edition = "2018" age-core = "0.5" age-plugin = "0.0" bech32 = "0.7.2" +console = "0.14" elliptic-curve = "0.6" gumdrop = "0.8" p256 = "0.5" +sha2 = "0.9" x509-parser = "0.9" yubikey-piv = { version = "0.1", features = ["untested"] } diff --git a/src/error.rs b/src/error.rs index 6c04059..6f2a5dc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,9 +1,19 @@ use std::fmt; use std::io; +use yubikey_piv::{key::RetiredSlotId, Serial}; + +use crate::USABLE_SLOTS; pub enum Error { + InvalidSlot(u8), Io(io::Error), MultipleCommands, + MultipleIdentities, + MultipleYubiKeys, + NoIdentities, + NoMatchingSerial(Serial), + SlotHasNoIdentity(RetiredSlotId), + TimedOut, YubiKey(yubikey_piv::Error), } @@ -24,11 +34,38 @@ impl From for Error { impl fmt::Debug for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Error::InvalidSlot(slot) => writeln!( + f, + "Invalid slot '{}' (expected number between 1 and 20).", + slot + )?, Error::Io(e) => writeln!(f, "Failed to set up YubiKey: {}", e)?, Error::MultipleCommands => writeln!( f, "Only one of --generate, --identity, --list, --list-all can be specified." )?, + Error::MultipleIdentities => writeln!( + f, + "This YubiKey has multiple age identities. Use --slot to select a single identity." + )?, + Error::MultipleYubiKeys => writeln!( + f, + "Multiple YubiKeys are plugged in. Use --serial to select a single YubiKey." + )?, + Error::NoIdentities => { + writeln!(f, "This YubiKey does not contain any age identities.")? + } + Error::NoMatchingSerial(serial) => { + writeln!(f, "Could not find YubiKey with serial {}.", serial)? + } + Error::SlotHasNoIdentity(slot) => writeln!( + f, + "Slot {} does not contain an age identity or compatible key.", + USABLE_SLOTS.iter().position(|s| s == slot).unwrap() + 1 + )?, + Error::TimedOut => { + writeln!(f, "Timed out while waiting for a YubiKey to be inserted.")? + } Error::YubiKey(e) => match e { yubikey_piv::error::Error::NotFound => { writeln!(f, "Please insert the YubiKey you want to set up")? diff --git a/src/main.rs b/src/main.rs index 233ed9d..10246e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,11 +10,13 @@ mod error; mod p256; mod plugin; mod util; +mod yubikey; use error::Error; const PLUGIN_NAME: &str = "age-plugin-yubikey"; const RECIPIENT_PREFIX: &str = "age1yubikey"; +const IDENTITY_PREFIX: &str = "age-plugin-yubikey-"; const USABLE_SLOTS: [RetiredSlotId; 20] = [ RetiredSlotId::R1, @@ -62,6 +64,78 @@ struct PluginOptions { #[options(help = "List all YubiKey keys that are compatible with age.", no_short)] list_all: bool, + + #[options( + help = "Specify which YubiKey to use, if more than one is plugged in.", + no_short + )] + serial: Option, + + #[options( + help = "Specify which slot to use. Defaults to first usable slot.", + no_short + )] + slot: Option, +} + +fn identity(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 mut yubikey = yubikey::open(serial)?; + + let mut keys = Key::list(&mut yubikey)?.into_iter().filter_map(|key| { + // - We only use the retired slots. + // - Only P-256 keys are compatible with us. + match (key.slot(), key.certificate().subject_pki()) { + (SlotId::Retired(slot), PublicKeyInfo::EcP256(pubkey)) => { + p256::Recipient::from_pubkey(*pubkey).map(|r| (key, slot, r)) + } + _ => None, + } + }); + + let (key, slot, recipient) = if let Some(slot) = slot { + keys.find(|(_, s, _)| s == &slot) + .ok_or(Error::SlotHasNoIdentity(slot)) + } else { + let mut keys = keys.filter(|(key, _, _)| { + let cert = x509_parser::parse_x509_certificate(key.certificate().as_ref()) + .map(|(_, cert)| cert) + .ok(); + match cert + .as_ref() + .and_then(|cert| cert.subject().iter_organization().next()) + { + Some(org) => org.as_str() == Ok(PLUGIN_NAME), + _ => false, + } + }); + match (keys.next(), keys.next()) { + (None, None) => Err(Error::NoIdentities), + (Some(key), None) => Ok(key), + (Some(_), Some(_)) => Err(Error::MultipleIdentities), + (None, Some(_)) => unreachable!(), + } + }?; + + let stub = yubikey::Stub::new(yubikey.serial(), slot, &recipient); + let created = x509_parser::parse_x509_certificate(key.certificate().as_ref()) + .ok() + .map(|(_, cert)| cert.validity().not_before.to_rfc2822()) + .unwrap_or_else(|| "Unknown".to_owned()); + + util::print_identity(stub, recipient, &created); + + Ok(()) } fn list(all: bool) -> Result<(), Error> { @@ -141,7 +215,7 @@ fn main() -> Result<(), Error> { } else if opts.generate { todo!() } else if opts.identity { - todo!() + identity(opts) } else if opts.list { list(false) } else if opts.list_all { diff --git a/src/p256.rs b/src/p256.rs index 0e7817f..5f6534b 100644 --- a/src/p256.rs +++ b/src/p256.rs @@ -1,10 +1,14 @@ use bech32::ToBase32; use elliptic_curve::sec1::EncodedPoint; use p256::NistP256; +use sha2::{Digest, Sha256}; +use std::convert::TryInto; use std::fmt; use crate::RECIPIENT_PREFIX; +pub(crate) const TAG_BYTES: usize = 4; + /// Wrapper around a compressed secp256r1 curve point. #[derive(Clone)] pub struct Recipient(EncodedPoint); @@ -43,4 +47,9 @@ impl Recipient { pub(crate) fn as_bytes(&self) -> &[u8] { self.0.as_bytes() } + + pub(crate) fn tag(&self) -> [u8; TAG_BYTES] { + let tag = Sha256::digest(self.to_string().as_bytes()); + (&tag[0..TAG_BYTES]).try_into().expect("length is correct") + } } diff --git a/src/util.rs b/src/util.rs index 84e62fe..c296e08 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,7 +4,7 @@ use yubikey_piv::{ Key, YubiKey, }; -use crate::PLUGIN_NAME; +use crate::{p256::Recipient, yubikey::Stub, PLUGIN_NAME}; const POLICY_EXTENSION_OID: &[u64] = &[1, 3, 6, 1, 4, 1, 41482, 3, 8]; @@ -111,3 +111,14 @@ pub(crate) fn extract_name_and_policies( } }) } + +pub(crate) fn print_identity(stub: Stub, recipient: Recipient, created: &str) { + let recipient = recipient.to_string(); + if !console::user_attended() { + eprintln!("Recipient: {}", recipient); + } + + println!("# created: {}", created); + println!("# recipient: {}", recipient); + println!("{}", stub.to_string()); +} diff --git a/src/yubikey.rs b/src/yubikey.rs new file mode 100644 index 0000000..937a9bc --- /dev/null +++ b/src/yubikey.rs @@ -0,0 +1,113 @@ +//! Structs for handling YubiKeys. + +use bech32::ToBase32; +use std::fmt; +use std::thread::sleep; +use std::time::{Duration, SystemTime}; +use yubikey_piv::{key::RetiredSlotId, yubikey::Serial, Readers, YubiKey}; + +use crate::{ + error::Error, + p256::{Recipient, TAG_BYTES}, + IDENTITY_PREFIX, +}; + +const ONE_SECOND: Duration = Duration::from_secs(1); +const FIFTEEN_SECONDS: Duration = Duration::from_secs(15); + +pub(crate) fn wait_for_readers() -> Result { + // Start a 15-second timer waiting for a YubiKey to be inserted (if necessary). + let start = SystemTime::now(); + loop { + let mut readers = Readers::open()?; + if readers.iter()?.len() > 0 { + break Ok(readers); + } + + match SystemTime::now().duration_since(start) { + Ok(end) if end >= FIFTEEN_SECONDS => return Err(Error::TimedOut), + _ => sleep(ONE_SECOND), + } + } +} + +pub(crate) fn open(serial: Option) -> Result { + if Readers::open()?.iter()?.len() == 0 { + if let Some(serial) = serial { + eprintln!("⏳ Please insert the YubiKey with serial {}.", serial); + } else { + eprintln!("⏳ Please insert the YubiKey."); + } + } + let mut readers = wait_for_readers()?; + let mut readers_iter = readers.iter()?; + + // --serial selects the YubiKey to use. If not provided, and more than one YubiKey is + // connected, an error is returned. + let yubikey = match (readers_iter.len(), serial) { + (0, _) => unreachable!(), + (1, None) => readers_iter.next().unwrap().open()?, + (1, Some(serial)) => { + let yubikey = readers_iter.next().unwrap().open()?; + if yubikey.serial() != serial { + return Err(Error::NoMatchingSerial(serial)); + } + yubikey + } + (_, Some(serial)) => { + let reader = readers_iter + .find(|reader| match reader.open() { + Ok(yk) => yk.serial() == serial, + _ => false, + }) + .ok_or(Error::NoMatchingSerial(serial))?; + reader.open()? + } + (_, None) => return Err(Error::MultipleYubiKeys), + }; + + Ok(yubikey) +} + +/// A reference to an age key stored in a YubiKey. +#[derive(Debug)] +pub struct Stub { + pub(crate) serial: Serial, + pub(crate) slot: RetiredSlotId, + pub(crate) tag: [u8; TAG_BYTES], + identity_index: usize, +} + +impl fmt::Display for Stub { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str( + bech32::encode(IDENTITY_PREFIX, self.to_bytes().to_base32()) + .expect("HRP is valid") + .to_uppercase() + .as_str(), + ) + } +} + +impl Stub { + /// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple. + /// + /// Does not check that the `PublicKey` matches the given `(Serial, SlotId)` tuple; + /// this is checked at decryption time. + pub(crate) fn new(serial: Serial, slot: RetiredSlotId, recipient: &Recipient) -> Self { + Stub { + serial, + slot, + tag: recipient.tag(), + identity_index: 0, + } + } + + fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(9); + bytes.extend_from_slice(&self.serial.0.to_le_bytes()); + bytes.push(self.slot.into()); + bytes.extend_from_slice(&self.tag); + bytes + } +}