Merge pull request #6 from str4d/generation-command

Implement --generate command
This commit is contained in:
str4d
2021-03-29 21:11:00 +13:00
committed by GitHub
7 changed files with 351 additions and 17 deletions
Generated
+23
View File
@@ -44,13 +44,18 @@ dependencies = [
"age-core", "age-core",
"age-plugin", "age-plugin",
"bech32", "bech32",
"chrono",
"console", "console",
"dialoguer",
"elliptic-curve", "elliptic-curve",
"env_logger", "env_logger",
"gumdrop", "gumdrop",
"hex",
"log", "log",
"p256", "p256",
"rand 0.8.3",
"sha2", "sha2",
"x509",
"x509-parser", "x509-parser",
"yubikey-piv", "yubikey-piv",
] ]
@@ -299,6 +304,18 @@ dependencies = [
"opaque-debug", "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]] [[package]]
name = "digest" name = "digest"
version = "0.9.0" version = "0.9.0"
@@ -443,6 +460,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.10.0" version = "0.10.0"
+5
View File
@@ -14,13 +14,18 @@ edition = "2018"
age-core = "0.5" age-core = "0.5"
age-plugin = "0.0" age-plugin = "0.0"
bech32 = "0.8" bech32 = "0.8"
chrono = "0.4"
console = "0.14" console = "0.14"
dialoguer = "0.8"
elliptic-curve = "0.8" elliptic-curve = "0.8"
env_logger = "0.8" env_logger = "0.8"
gumdrop = "0.8" gumdrop = "0.8"
hex = "0.4"
log = "0.4" log = "0.4"
p256 = "0.7" p256 = "0.7"
rand = "0.8"
sha2 = "0.9" sha2 = "0.9"
x509 = "0.2"
x509-parser = "0.9" x509-parser = "0.9"
yubikey-piv = { version = "0.3", features = ["untested"] } yubikey-piv = { version = "0.3", features = ["untested"] }
+153
View File
@@ -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<RetiredSlotId>,
force: bool,
name: Option<String>,
pin_policy: Option<PinPolicy>,
touch_policy: Option<TouchPolicy>,
}
impl IdentityBuilder {
pub(crate) fn new(slot: Option<RetiredSlotId>) -> Self {
IdentityBuilder {
slot,
name: None,
pin_policy: None,
touch_policy: None,
force: false,
}
}
pub(crate) fn with_name(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
pub(crate) fn with_pin_policy(mut self, pin_policy: Option<PinPolicy>) -> Self {
self.pin_policy = pin_policy;
self
}
pub(crate) fn with_touch_policy(mut self, touch_policy: Option<TouchPolicy>) -> 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),
))
}
}
+35 -2
View File
@@ -2,17 +2,23 @@ use std::fmt;
use std::io; use std::io;
use yubikey_piv::{key::RetiredSlotId, Serial}; use yubikey_piv::{key::RetiredSlotId, Serial};
use crate::USABLE_SLOTS; use crate::util::slot_to_ui;
pub enum Error { pub enum Error {
CustomManagementKey,
InvalidPinLength,
InvalidPinPolicy(String),
InvalidSlot(u8), InvalidSlot(u8),
InvalidTouchPolicy(String),
Io(io::Error), Io(io::Error),
MultipleCommands, MultipleCommands,
MultipleIdentities, MultipleIdentities,
MultipleYubiKeys, MultipleYubiKeys,
NoEmptySlots(Serial),
NoIdentities, NoIdentities,
NoMatchingSerial(Serial), NoMatchingSerial(Serial),
SlotHasNoIdentity(RetiredSlotId), SlotHasNoIdentity(RetiredSlotId),
SlotIsNotEmpty(RetiredSlotId),
TimedOut, TimedOut,
YubiKey(yubikey_piv::Error), YubiKey(yubikey_piv::Error),
} }
@@ -34,11 +40,25 @@ impl From<yubikey_piv::error::Error> for Error {
impl fmt::Debug for Error { impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { 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!( Error::InvalidSlot(slot) => writeln!(
f, f,
"Invalid slot '{}' (expected number between 1 and 20).", "Invalid slot '{}' (expected number between 1 and 20).",
slot 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::Io(e) => writeln!(f, "Failed to set up YubiKey: {}", e)?,
Error::MultipleCommands => writeln!( Error::MultipleCommands => writeln!(
f, f,
@@ -52,6 +72,9 @@ impl fmt::Debug for Error {
f, f,
"Multiple YubiKeys are plugged in. Use --serial to select a single YubiKey." "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 => { Error::NoIdentities => {
writeln!(f, "This YubiKey does not contain any age identities.")? writeln!(f, "This YubiKey does not contain any age identities.")?
} }
@@ -61,7 +84,12 @@ impl fmt::Debug for Error {
Error::SlotHasNoIdentity(slot) => writeln!( Error::SlotHasNoIdentity(slot) => writeln!(
f, f,
"Slot {} does not contain an age identity or compatible key.", "Slot {} does not contain an age identity or compatible key.",
USABLE_SLOTS.iter().position(|s| s == slot).unwrap() + 1 slot_to_ui(slot)
)?,
Error::SlotIsNotEmpty(slot) => writeln!(
f,
"Slot {} is not empty. Use --force to overwrite the slot.",
slot_to_ui(slot)
)?, )?,
Error::TimedOut => { Error::TimedOut => {
writeln!(f, "Timed out while waiting for a YubiKey to be inserted.")? 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 => { yubikey_piv::error::Error::NotFound => {
writeln!(f, "Please insert the YubiKey you want to set up")? 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 => { e => {
writeln!(f, "Error while communicating with YubiKey: {}", e)?; writeln!(f, "Error while communicating with YubiKey: {}", e)?;
use std::error::Error; use std::error::Error;
+48 -12
View File
@@ -7,6 +7,7 @@ use yubikey_piv::{
Key, Readers, Key, Readers,
}; };
mod builder;
mod error; mod error;
mod p256; mod p256;
mod plugin; mod plugin;
@@ -54,6 +55,9 @@ struct PluginOptions {
)] )]
age_plugin: Option<String>, age_plugin: Option<String>,
#[options(help = "Force --generate to overwrite a filled slot.")]
force: bool,
#[options(help = "Generate a new YubiKey identity.")] #[options(help = "Generate a new YubiKey identity.")]
generate: bool, generate: bool,
@@ -66,6 +70,15 @@ struct PluginOptions {
#[options(help = "List all YubiKey keys that are compatible with age.", no_short)] #[options(help = "List all YubiKey keys that are compatible with age.", no_short)]
list_all: bool, 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( #[options(
help = "Specify which YubiKey to use, if more than one is plugged in.", help = "Specify which YubiKey to use, if more than one is plugged in.",
no_short no_short
@@ -77,19 +90,43 @@ struct PluginOptions {
no_short no_short
)] )]
slot: Option<u8>, slot: Option<u8>,
#[options(
help = "One of [always, cached, never]. Defaults to 'always'.",
no_short
)]
touch_policy: Option<String>,
}
fn generate(opts: PluginOptions) -> Result<(), 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()?;
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> { fn identity(opts: PluginOptions) -> Result<(), Error> {
let serial = opts.serial.map(|s| s.into()); let serial = opts.serial.map(|s| s.into());
let slot = opts let slot = opts.slot.map(util::ui_to_slot).transpose()?;
.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 yubikey = yubikey::open(serial)?;
@@ -187,8 +224,7 @@ fn list(all: bool) -> Result<(), Error> {
println!( println!(
"# Serial: {}, Slot: {}", "# Serial: {}, Slot: {}",
yubikey.serial(), yubikey.serial(),
// Use 1-indexing in the UI for niceness util::slot_to_ui(&slot),
USABLE_SLOTS.iter().position(|s| s == &slot).unwrap() + 1,
); );
println!("# Name: {}", name); println!("# Name: {}", name);
println!("# Created: {}", created); println!("# Created: {}", created);
@@ -232,7 +268,7 @@ fn main() -> Result<(), Error> {
)?; )?;
Ok(()) Ok(())
} else if opts.generate { } else if opts.generate {
todo!() generate(opts)
} else if opts.identity { } else if opts.identity {
identity(opts) identity(opts)
} else if opts.list { } else if opts.list {
+34 -2
View File
@@ -1,12 +1,44 @@
use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid}; use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid};
use yubikey_piv::{ use yubikey_piv::{
key::RetiredSlotId,
policy::{PinPolicy, TouchPolicy}, policy::{PinPolicy, TouchPolicy},
Key, YubiKey, Key, YubiKey,
}; };
use crate::{p256::Recipient, yubikey::Stub, PLUGIN_NAME}; use crate::{error::Error, p256::Recipient, yubikey::Stub, PLUGIN_NAME, USABLE_SLOTS};
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 ui_to_slot(slot: u8) -> Result<RetiredSlotId, Error> {
// Use 1-indexing in the UI for niceness
USABLE_SLOTS
.get(slot as usize - 1)
.cloned()
.ok_or(Error::InvalidSlot(slot))
}
pub(crate) fn slot_to_ui(slot: &RetiredSlotId) -> u8 {
// Use 1-indexing in the UI for niceness
USABLE_SLOTS.iter().position(|s| s == slot).unwrap() as u8 + 1
}
pub(crate) fn pin_policy_from_string(s: String) -> Result<PinPolicy, Error> {
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<TouchPolicy, Error> {
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<PinPolicy>) -> &'static str { pub(crate) fn pin_policy_to_str(policy: Option<PinPolicy>) -> &'static str {
match policy { match policy {
+53 -1
View File
@@ -1,10 +1,11 @@
//! Structs for handling YubiKeys. //! Structs for handling YubiKeys.
use bech32::{ToBase32, Variant}; use bech32::{ToBase32, Variant};
use dialoguer::Password;
use std::fmt; use std::fmt;
use std::thread::sleep; use std::thread::sleep;
use std::time::{Duration, SystemTime}; 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::{ use crate::{
error::Error, error::Error,
@@ -69,6 +70,57 @@ pub(crate) fn open(serial: Option<Serial>) -> Result<YubiKey, Error> {
Ok(yubikey) 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. /// A reference to an age key stored in a YubiKey.
#[derive(Debug)] #[derive(Debug)]
pub struct Stub { pub struct Stub {