Files
age-plugin-yubikey/src/main.rs
T
2021-05-02 08:48:05 +12:00

479 lines
16 KiB
Rust

use std::convert::{TryFrom, TryInto};
use age_plugin::run_state_machine;
use dialoguer::{Confirm, Input, Select};
use gumdrop::Options;
use yubikey_piv::{
certificate::PublicKeyInfo,
key::{RetiredSlotId, SlotId},
policy::{PinPolicy, TouchPolicy},
Key, Readers, Serial,
};
mod builder;
mod error;
mod format;
mod p256;
mod plugin;
mod util;
mod yubikey;
use error::Error;
const PLUGIN_NAME: &str = "yubikey";
const BINARY_NAME: &str = "age-plugin-yubikey";
const RECIPIENT_PREFIX: &str = "age1yubikey";
const IDENTITY_PREFIX: &str = "age-plugin-yubikey-";
const STANZA_TAG: &str = "piv-p256";
const USABLE_SLOTS: [RetiredSlotId; 20] = [
RetiredSlotId::R1,
RetiredSlotId::R2,
RetiredSlotId::R3,
RetiredSlotId::R4,
RetiredSlotId::R5,
RetiredSlotId::R6,
RetiredSlotId::R7,
RetiredSlotId::R8,
RetiredSlotId::R9,
RetiredSlotId::R10,
RetiredSlotId::R11,
RetiredSlotId::R12,
RetiredSlotId::R13,
RetiredSlotId::R14,
RetiredSlotId::R15,
RetiredSlotId::R16,
RetiredSlotId::R17,
RetiredSlotId::R18,
RetiredSlotId::R19,
RetiredSlotId::R20,
];
#[derive(Debug, Options)]
struct PluginOptions {
#[options(help = "Print this help message and exit.")]
help: bool,
#[options(help = "Print version info and exit.", short = "V")]
version: bool,
#[options(
help = "Run the given age plugin state machine. Internal use only.",
meta = "STATE-MACHINE",
no_short
)]
age_plugin: Option<String>,
#[options(help = "Force --generate to overwrite a filled slot.")]
force: bool,
#[options(help = "Generate a new YubiKey identity.")]
generate: bool,
#[options(help = "Print the identity stored in a YubiKey slot.")]
identity: bool,
#[options(help = "List all age identities in connected YubiKeys.")]
list: bool,
#[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<String>,
#[options(help = "One of [always, once, never]. Defaults to 'once'.", no_short)]
pin_policy: Option<String>,
#[options(
help = "Specify which YubiKey to use, if more than one is plugged in.",
no_short
)]
serial: Option<u32>,
#[options(
help = "Specify which slot to use. Defaults to first usable slot.",
no_short
)]
slot: Option<u8>,
#[options(
help = "One of [always, cached, never]. Defaults to 'always'.",
no_short
)]
touch_policy: Option<String>,
}
struct PluginFlags {
serial: Option<Serial>,
slot: Option<RetiredSlotId>,
name: Option<String>,
pin_policy: Option<PinPolicy>,
touch_policy: Option<TouchPolicy>,
force: bool,
}
impl TryFrom<PluginOptions> for PluginFlags {
type Error = Error;
fn try_from(opts: PluginOptions) -> Result<Self, Self::Error> {
let serial = opts.serial.map(|s| s.into());
let slot = opts.slot.map(util::ui_to_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()?;
Ok(PluginFlags {
serial,
slot,
name: opts.name,
pin_policy,
touch_policy,
force: opts.force,
})
}
}
fn generate(flags: PluginFlags) -> Result<(), Error> {
let mut yubikey = yubikey::open(flags.serial)?;
let (stub, recipient, metadata) = builder::IdentityBuilder::new(flags.slot)
.with_name(flags.name)
.with_pin_policy(flags.pin_policy)
.with_touch_policy(flags.touch_policy)
.force(flags.force)
.build(&mut yubikey)?;
util::print_identity(stub, recipient, metadata);
Ok(())
}
fn identity(flags: PluginFlags) -> Result<(), Error> {
let mut yubikey = yubikey::open(flags.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_encoded(pubkey).map(|r| (key, slot, r))
}
_ => None,
}
});
let (key, slot, recipient) = if let Some(slot) = flags.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(BINARY_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 metadata = x509_parser::parse_x509_certificate(key.certificate().as_ref())
.ok()
.and_then(|(_, cert)| util::Metadata::extract(&mut yubikey, slot, &cert, true))
.unwrap();
util::print_identity(stub, recipient, metadata);
Ok(())
}
fn list(all: bool) -> Result<(), Error> {
let mut readers = Readers::open()?;
for reader in readers.iter()?.filter(yubikey::filter_connected) {
let mut yubikey = reader.open()?;
for key in Key::list(&mut yubikey)? {
// We only use the retired slots.
let slot = match key.slot() {
SlotId::Retired(slot) => slot,
_ => continue,
};
// Only P-256 keys are compatible with us.
let recipient = match key.certificate().subject_pki() {
PublicKeyInfo::EcP256(pubkey) => match p256::Recipient::from_encoded(pubkey) {
Some(recipient) => recipient,
None => continue,
},
_ => continue,
};
let metadata = match x509_parser::parse_x509_certificate(key.certificate().as_ref())
.ok()
.and_then(|(_, cert)| util::Metadata::extract(&mut yubikey, slot, &cert, all))
{
Some(res) => res,
None => continue,
};
println!("{}", metadata);
println!("{}", recipient.to_string());
println!();
}
println!();
}
Ok(())
}
fn main() -> Result<(), Error> {
env_logger::builder()
.format_timestamp(None)
.filter_level(log::LevelFilter::Off)
.parse_default_env()
.init();
let opts = PluginOptions::parse_args_default_or_exit();
if [opts.generate, opts.identity, opts.list, opts.list_all]
.iter()
.filter(|&&b| b)
.count()
> 1
{
return Err(Error::MultipleCommands);
}
if let Some(state_machine) = opts.age_plugin {
run_state_machine(
&state_machine,
plugin::RecipientPlugin::default,
plugin::IdentityPlugin::default,
)?;
Ok(())
} else if opts.version {
println!("age-plugin-yubikey {}", env!("CARGO_PKG_VERSION"));
Ok(())
} else if opts.generate {
generate(opts.try_into()?)
} else if opts.identity {
identity(opts.try_into()?)
} else if opts.list {
list(false)
} else if opts.list_all {
list(true)
} else {
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. You can also do this directly");
eprintln!("with:");
eprintln!(" age-plugin-yubikey --generate");
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. You can");
eprintln!("also obtain this directly with:");
eprintln!(" age-plugin-yubikey --identity");
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()?.any(yubikey::is_connected) {
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(yubikey::filter_connected).collect();
let reader_names = readers_list
.iter()
.map(|reader| {
reader
.open()
.map(|yk| format!("{} (Serial: {})", reader.name(), yk.serial()))
})
.collect::<Result<Vec<_>, _>>()?;
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_encoded(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, metadata) = {
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_encoded(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 metadata =
util::Metadata::extract(&mut yubikey, slot, &cert, true).unwrap();
(stub, recipient, metadata)
} else {
return Ok(());
}
} else {
let name = match Input::<String>::new()
.with_prompt(format!(
"📛 Name this identity [{}]",
opts.name.as_deref().unwrap_or("age identity TAG_HEX")
))
.allow_empty(true)
.interact_text()?
{
s if s.is_empty() => opts.name,
s => Some(s),
};
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(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, metadata);
Ok(())
}
}