Merge pull request #6 from str4d/generation-command
Implement --generate command
This commit is contained in:
Generated
+23
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
+153
@@ -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
@@ -2,17 +2,23 @@ use std::fmt;
|
||||
use std::io;
|
||||
use yubikey_piv::{key::RetiredSlotId, Serial};
|
||||
|
||||
use crate::USABLE_SLOTS;
|
||||
use crate::util::slot_to_ui;
|
||||
|
||||
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<yubikey_piv::error::Error> 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.")?
|
||||
}
|
||||
@@ -61,7 +84,12 @@ impl fmt::Debug for Error {
|
||||
Error::SlotHasNoIdentity(slot) => writeln!(
|
||||
f,
|
||||
"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 => {
|
||||
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;
|
||||
|
||||
+48
-12
@@ -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<String>,
|
||||
|
||||
#[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<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
|
||||
@@ -77,19 +90,43 @@ struct PluginOptions {
|
||||
no_short
|
||||
)]
|
||||
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> {
|
||||
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 slot = opts.slot.map(util::ui_to_slot).transpose()?;
|
||||
|
||||
let mut yubikey = yubikey::open(serial)?;
|
||||
|
||||
@@ -187,8 +224,7 @@ fn list(all: bool) -> Result<(), Error> {
|
||||
println!(
|
||||
"# Serial: {}, Slot: {}",
|
||||
yubikey.serial(),
|
||||
// Use 1-indexing in the UI for niceness
|
||||
USABLE_SLOTS.iter().position(|s| s == &slot).unwrap() + 1,
|
||||
util::slot_to_ui(&slot),
|
||||
);
|
||||
println!("# Name: {}", name);
|
||||
println!("# Created: {}", created);
|
||||
@@ -232,7 +268,7 @@ fn main() -> Result<(), Error> {
|
||||
)?;
|
||||
Ok(())
|
||||
} else if opts.generate {
|
||||
todo!()
|
||||
generate(opts)
|
||||
} else if opts.identity {
|
||||
identity(opts)
|
||||
} else if opts.list {
|
||||
|
||||
+34
-2
@@ -1,12 +1,44 @@
|
||||
use x509_parser::{certificate::X509Certificate, der_parser::oid::Oid};
|
||||
use yubikey_piv::{
|
||||
key::RetiredSlotId,
|
||||
policy::{PinPolicy, TouchPolicy},
|
||||
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 {
|
||||
match policy {
|
||||
|
||||
+53
-1
@@ -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<Serial>) -> Result<YubiKey, Error> {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user