diff --git a/Cargo.lock b/Cargo.lock index 129874c..3eb2b67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,7 @@ version = "0.0.0" dependencies = [ "age-core", "age-plugin", + "base64", "bech32", "chrono", "console", @@ -52,7 +53,8 @@ dependencies = [ "hex", "log", "p256", - "rand 0.8.3", + "rand 0.7.3", + "secrecy", "sha2", "x509", "x509-parser", diff --git a/Cargo.toml b/Cargo.toml index 4914055..67e299b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ edition = "2018" [dependencies] age-core = "0.5" age-plugin = "0.0" +base64 = "0.13" bech32 = "0.8" chrono = "0.4" console = "0.14" @@ -21,8 +22,9 @@ env_logger = "0.8" gumdrop = "0.8" hex = "0.4" log = "0.4" -p256 = "0.7" -rand = "0.8" +p256 = { version = "0.7", features = ["ecdh"] } +rand = "0.7" +secrecy = "0.7" sha2 = "0.9" x509 = "0.2" x509-parser = "0.9" diff --git a/src/format.rs b/src/format.rs new file mode 100644 index 0000000..723abcd --- /dev/null +++ b/src/format.rs @@ -0,0 +1,129 @@ +use age_core::{ + format::{FileKey, Stanza}, + primitives::{aead_encrypt, hkdf}, +}; +use p256::{ecdh::EphemeralSecret, elliptic_curve::sec1::ToEncodedPoint}; +use rand::rngs::OsRng; +use secrecy::ExposeSecret; +use std::convert::TryInto; + +use crate::{p256::Recipient, STANZA_TAG}; + +pub(crate) const STANZA_KEY_LABEL: &[u8] = b"age-encryption.org/v1/piv-p256"; + +const TAG_BYTES: usize = 4; +const EPK_BYTES: usize = 33; +const ENCRYPTED_FILE_KEY_BYTES: usize = 32; + +/// The ephemeral key bytes in a piv-p256 stanza. +/// +/// The bytes contain a compressed SEC-1 encoding of a valid point. +#[derive(Debug)] +pub(crate) struct EphemeralKeyBytes(p256::EncodedPoint); + +impl EphemeralKeyBytes { + fn from_bytes(bytes: [u8; EPK_BYTES]) -> Option { + let encoded = p256::EncodedPoint::from_bytes(&bytes).ok()?; + if encoded.is_compressed() && encoded.decompress().is_some() { + Some(EphemeralKeyBytes(encoded)) + } else { + None + } + } + + fn from_public_key(epk: &p256::PublicKey) -> Self { + EphemeralKeyBytes(epk.to_encoded_point(true)) + } + + pub(crate) fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + pub(crate) fn decompress(&self) -> p256::EncodedPoint { + self.0 + .decompress() + .expect("EphemeralKeyBytes is a valid compressed encoding by construction") + } +} + +#[derive(Debug)] +pub(crate) struct RecipientLine { + pub(crate) tag: [u8; TAG_BYTES], + pub(crate) epk_bytes: EphemeralKeyBytes, + pub(crate) encrypted_file_key: [u8; ENCRYPTED_FILE_KEY_BYTES], +} + +impl From for Stanza { + fn from(r: RecipientLine) -> Self { + Stanza { + tag: STANZA_TAG.to_owned(), + args: vec![ + base64::encode_config(&r.tag, base64::STANDARD_NO_PAD), + base64::encode_config(r.epk_bytes.as_bytes(), base64::STANDARD_NO_PAD), + ], + body: r.encrypted_file_key.to_vec(), + } + } +} + +impl RecipientLine { + pub(super) fn from_stanza(s: &Stanza) -> Option> { + if s.tag != STANZA_TAG { + return None; + } + + fn base64_arg, B: AsMut<[u8]>>(arg: &A, mut buf: B) -> Option { + if arg.as_ref().len() != ((4 * buf.as_mut().len()) + 2) / 3 { + return None; + } + + base64::decode_config_slice(arg, base64::STANDARD_NO_PAD, buf.as_mut()) + .ok() + .map(|_| buf) + } + + let (tag, epk_bytes) = match &s.args[..] { + [tag, epk_bytes] => ( + base64_arg(tag, [0; TAG_BYTES]), + base64_arg(epk_bytes, [0; EPK_BYTES]).and_then(EphemeralKeyBytes::from_bytes), + ), + _ => (None, None), + }; + + Some(match (tag, epk_bytes, s.body[..].try_into()) { + (Some(tag), Some(epk_bytes), Ok(encrypted_file_key)) => Ok(RecipientLine { + tag, + epk_bytes, + encrypted_file_key, + }), + // Anything else indicates a structurally-invalid stanza. + _ => Err(()), + }) + } + + pub(crate) fn wrap_file_key(file_key: &FileKey, pk: &Recipient) -> Self { + let esk = EphemeralSecret::random(OsRng); + let epk = esk.public_key(); + let epk_bytes = EphemeralKeyBytes::from_public_key(&epk); + + let shared_secret = esk.diffie_hellman(&pk.public_key()); + + let mut salt = vec![]; + salt.extend_from_slice(epk_bytes.as_bytes()); + salt.extend_from_slice(pk.to_encoded().as_bytes()); + + let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_bytes()); + + let encrypted_file_key = { + let mut key = [0; ENCRYPTED_FILE_KEY_BYTES]; + key.copy_from_slice(&aead_encrypt(&enc_key, file_key.expose_secret())); + key + }; + + RecipientLine { + tag: pk.tag(), + epk_bytes, + encrypted_file_key, + } + } +} diff --git a/src/main.rs b/src/main.rs index 55543e1..a5fb74c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use yubikey_piv::{ mod builder; mod error; +mod format; mod p256; mod plugin; mod util; @@ -21,6 +22,7 @@ use error::Error; const PLUGIN_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, diff --git a/src/p256.rs b/src/p256.rs index 583403c..6054af7 100644 --- a/src/p256.rs +++ b/src/p256.rs @@ -33,6 +33,16 @@ impl fmt::Display for Recipient { } impl Recipient { + /// Attempts to parse a valid YubiKey recipient from its compressed SEC-1 byte encoding. + pub(crate) fn from_bytes(bytes: &[u8]) -> Option { + let encoded = p256::EncodedPoint::from_bytes(bytes).ok()?; + if encoded.is_compressed() { + Self::from_encoded(&encoded) + } else { + None + } + } + /// Attempts to parse a valid YubiKey recipient from its SEC-1 encoding. /// /// This accepts both compressed (as used by the plugin) and uncompressed (as used in @@ -50,4 +60,9 @@ impl Recipient { let tag = Sha256::digest(self.to_string().as_bytes()); (&tag[0..TAG_BYTES]).try_into().expect("length is correct") } + + /// Exposes the wrapped public key. + pub(crate) fn public_key(&self) -> &p256::PublicKey { + &self.0 + } } diff --git a/src/plugin.rs b/src/plugin.rs index 6938311..8e76b22 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -4,45 +4,171 @@ use age_plugin::{ recipient::{self, RecipientPluginV1}, Callbacks, }; +use bech32::{FromBase32, Variant}; use std::collections::HashMap; use std::io; +use crate::{format, p256::Recipient, yubikey, IDENTITY_PREFIX, RECIPIENT_PREFIX}; + #[derive(Debug, Default)] -pub(crate) struct RecipientPlugin {} +pub(crate) struct RecipientPlugin { + recipients: Vec, + yubikeys: Vec, +} impl RecipientPluginV1 for RecipientPlugin { fn add_recipients<'a, I: Iterator>( &mut self, recipients: I, ) -> Result<(), Vec> { - todo!() + let errors: Vec<_> = recipients + .enumerate() + .filter_map(|(index, recipient)| { + if let Some(pk) = bech32::decode(recipient) + .ok() + .and_then(|(hrp, data, variant)| { + if hrp == RECIPIENT_PREFIX && variant == Variant::Bech32 { + Some(data) + } else { + None + } + }) + .and_then(|data| Vec::from_base32(&data).ok()) + .and_then(|bytes| Recipient::from_bytes(&bytes)) + { + self.recipients.push(pk); + None + } else { + Some(recipient::Error::Recipient { + index, + message: "Invalid recipient".to_owned(), + }) + } + }) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } } fn add_identities<'a, I: Iterator>( &mut self, identities: I, ) -> Result<(), Vec> { - todo!() + let errors: Vec<_> = identities + .enumerate() + .filter_map(|(index, identity)| { + if let Some(stub) = bech32::decode(identity) + .ok() + .and_then(|(hrp, data, variant)| { + if hrp == IDENTITY_PREFIX.to_lowercase() && variant == Variant::Bech32 { + Some(data) + } else { + None + } + }) + .and_then(|data| Vec::from_base32(&data).ok()) + .and_then(|bytes| yubikey::Stub::from_bytes(&bytes, index)) + { + self.yubikeys.push(stub); + None + } else { + Some(recipient::Error::Identity { + index, + message: "Invalid Yubikey stub".to_owned(), + }) + } + }) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } } fn wrap_file_keys( &mut self, file_keys: Vec, - callbacks: impl Callbacks, + mut callbacks: impl Callbacks, ) -> io::Result>, Vec>> { - todo!() + // Connect to any listed YubiKey identities to obtain the corresponding recipients. + let mut yk_recipients = vec![]; + let mut yk_errors = vec![]; + for stub in &self.yubikeys { + match stub.connect(&mut callbacks)? { + Ok(conn) => yk_recipients.push(conn.recipient().clone()), + Err(e) => yk_errors.push(match e { + identity::Error::Identity { index, message } => { + recipient::Error::Identity { index, message } + } + // stub.connect() only returns identity::Error::Identity + _ => unreachable!(), + }), + } + } + + // If any errors occurred while fetching recipients from YubiKeys, don't encrypt + // the file to any of the other recipients. + Ok(if yk_errors.is_empty() { + Ok(file_keys + .into_iter() + .map(|file_key| { + self.recipients + .iter() + .chain(yk_recipients.iter()) + .map(|pk| format::RecipientLine::wrap_file_key(&file_key, &pk).into()) + .collect() + }) + .collect()) + } else { + Err(yk_errors) + }) } } #[derive(Debug, Default)] -pub(crate) struct IdentityPlugin {} +pub(crate) struct IdentityPlugin { + yubikeys: Vec, +} impl IdentityPluginV1 for IdentityPlugin { fn add_identities<'a, I: Iterator>( &mut self, identities: I, ) -> Result<(), Vec> { - todo!() + let errors: Vec<_> = identities + .enumerate() + .filter_map(|(index, identity)| { + if let Some(stub) = bech32::decode(identity) + .ok() + .and_then(|(hrp, data, variant)| { + if hrp == IDENTITY_PREFIX.to_lowercase() && variant == Variant::Bech32 { + Some(data) + } else { + None + } + }) + .and_then(|data| Vec::from_base32(&data).ok()) + .and_then(|bytes| yubikey::Stub::from_bytes(&bytes, index)) + { + self.yubikeys.push(stub); + None + } else { + Some(identity::Error::Identity { + index, + message: "Invalid Yubikey stub".to_owned(), + }) + } + }) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } } fn unwrap_file_keys( @@ -50,6 +176,113 @@ impl IdentityPluginV1 for IdentityPlugin { files: Vec>, mut callbacks: impl Callbacks, ) -> io::Result>>> { - todo!() + let mut file_keys = HashMap::with_capacity(files.len()); + + // Filter to files / stanzas for which we have matching YubiKeys + let mut candidate_stanzas: Vec<( + &yubikey::Stub, + HashMap>, + )> = self + .yubikeys + .iter() + .map(|stub| (stub, HashMap::new())) + .collect(); + + for (file, stanzas) in files.iter().enumerate() { + for (stanza_index, stanza) in stanzas.iter().enumerate() { + match ( + format::RecipientLine::from_stanza(&stanza).map(|res| { + res.map_err(|_| identity::Error::Stanza { + file_index: file, + stanza_index, + message: "Invalid yubikey stanza".to_owned(), + }) + }), + file_keys.contains_key(&file), + ) { + // Only record candidate stanzas for files without structural errors. + (Some(Ok(line)), false) => { + // A line will match at most one YubiKey. + if let Some(files) = + candidate_stanzas.iter_mut().find_map(|(stub, files)| { + if stub.matches(&line) { + Some(files) + } else { + None + } + }) + { + files.entry(file).or_default().push(line); + } + } + (Some(Err(e)), _) => { + // This is a structurally-invalid stanza, so we MUST return errors + // and MUST NOT unwrap any stanzas in the same file. Let's collect + // these errors to return to the client. + match file_keys.entry(file).or_insert_with(|| Err(vec![])) { + Err(errors) => errors.push(e), + Ok(_) => unreachable!(), + } + // Drop any existing candidate stanzas from this file. + for (_, candidates) in candidate_stanzas.iter_mut() { + candidates.remove(&file); + } + } + _ => (), + } + } + } + + // Sort by effectiveness (YubiKey that can trial-decrypt the most stanzas) + candidate_stanzas.sort_by_key(|(_, files)| { + files + .iter() + .map(|(_, stanzas)| stanzas.len()) + .sum::() + }); + candidate_stanzas.reverse(); + // Remove any YubiKeys without stanzas. + candidate_stanzas.retain(|(_, files)| { + files + .iter() + .map(|(_, stanzas)| stanzas.len()) + .sum::() + > 0 + }); + + for (stub, files) in candidate_stanzas.iter() { + let mut conn = match stub.connect(&mut callbacks)? { + Ok(conn) => conn, + Err(e) => { + callbacks.error(e)?.unwrap(); + continue; + } + }; + + for (&file_index, stanzas) in files { + if file_keys.contains_key(&file_index) { + // We decrypted this file with an earlier YubiKey. + continue; + } + + for (stanza_index, line) in stanzas.iter().enumerate() { + match conn.unwrap_file_key(&line) { + Ok(file_key) => { + // We've managed to decrypt this file! + file_keys.entry(file_index).or_insert(Ok(file_key)); + break; + } + Err(_) => callbacks + .error(identity::Error::Stanza { + file_index, + stanza_index, + message: "Failed to decrypt YubiKey stanza".to_owned(), + })? + .unwrap(), + } + } + } + } + Ok(file_keys) } } diff --git a/src/yubikey.rs b/src/yubikey.rs index 8d45963..1bb9816 100644 --- a/src/yubikey.rs +++ b/src/yubikey.rs @@ -1,14 +1,28 @@ //! Structs for handling YubiKeys. +use age_core::{ + format::{FileKey, FILE_KEY_BYTES}, + primitives::{aead_decrypt, hkdf}, +}; +use age_plugin::{identity, Callbacks}; use bech32::{ToBase32, Variant}; use dialoguer::Password; +use secrecy::ExposeSecret; +use std::convert::TryInto; use std::fmt; +use std::io; use std::thread::sleep; use std::time::{Duration, SystemTime}; -use yubikey_piv::{key::RetiredSlotId, yubikey::Serial, MgmKey, Readers, YubiKey}; +use yubikey_piv::{ + certificate::{Certificate, PublicKeyInfo}, + key::{decrypt_data, AlgorithmId, RetiredSlotId, SlotId}, + yubikey::Serial, + MgmKey, Readers, YubiKey, +}; use crate::{ error::Error, + format::{RecipientLine, STANZA_KEY_LABEL}, p256::{Recipient, TAG_BYTES}, IDENTITY_PREFIX, }; @@ -145,6 +159,12 @@ impl fmt::Display for Stub { } } +impl PartialEq for Stub { + fn eq(&self, other: &Self) -> bool { + self.to_bytes().eq(&other.to_bytes()) + } +} + impl Stub { /// Returns a key stub and recipient for this `(Serial, SlotId, PublicKey)` tuple. /// @@ -159,6 +179,17 @@ impl Stub { } } + pub(crate) fn from_bytes(bytes: &[u8], identity_index: usize) -> Option { + let serial = Serial::from(u32::from_le_bytes(bytes[0..4].try_into().unwrap())); + let slot: RetiredSlotId = bytes[4].try_into().ok()?; + Some(Stub { + serial, + slot, + tag: bytes[5..9].try_into().unwrap(), + identity_index, + }) + } + fn to_bytes(&self) -> Vec { let mut bytes = Vec::with_capacity(9); bytes.extend_from_slice(&self.serial.0.to_le_bytes()); @@ -166,4 +197,176 @@ impl Stub { bytes.extend_from_slice(&self.tag); bytes } + + pub(crate) fn matches(&self, line: &RecipientLine) -> bool { + self.tag == line.tag + } + + pub(crate) fn connect( + &self, + callbacks: &mut dyn Callbacks, + ) -> io::Result> { + let mut yubikey = match YubiKey::open_by_serial(self.serial) { + Ok(yk) => yk, + Err(yubikey_piv::Error::NotFound) => { + if callbacks + .message(&format!( + "Please insert YubiKey with serial {}", + self.serial + ))? + .is_err() + { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: format!("Could not find YubiKey with serial {}", self.serial), + })); + } + + // Start a 15-second timer waiting for the YubiKey to be inserted + let start = SystemTime::now(); + loop { + match YubiKey::open_by_serial(self.serial) { + Ok(yubikey) => break yubikey, + Err(yubikey_piv::Error::NotFound) => (), + Err(_) => { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: format!( + "Could not open YubiKey with serial {}", + self.serial + ), + })); + } + } + + match SystemTime::now().duration_since(start) { + Ok(end) if end >= FIFTEEN_SECONDS => { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: format!( + "Timed out while waiting for YubiKey with serial {} to be inserted", + self.serial + ), + })) + } + _ => sleep(ONE_SECOND), + } + } + } + Err(_) => { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: format!("Could not open YubiKey with serial {}", self.serial), + })) + } + }; + + // Read the pubkey from the YubiKey slot and check it still matches. + let pk = match Certificate::read(&mut yubikey, SlotId::Retired(self.slot)) + .ok() + .and_then(|cert| match cert.subject_pki() { + PublicKeyInfo::EcP256(pubkey) => { + Recipient::from_encoded(pubkey).filter(|pk| pk.tag() == self.tag) + } + _ => None, + }) { + Some(pk) => pk, + None => { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: "A YubiKey stub did not match the YubiKey".to_owned(), + })) + } + }; + + let pin = match callbacks.request_secret(&format!( + "Enter PIN for YubiKey with serial {}", + self.serial + ))? { + Ok(pin) => pin, + Err(_) => { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: format!("A PIN is required for YubiKey with serial {}", self.serial), + })) + } + }; + if yubikey.verify_pin(pin.expose_secret().as_bytes()).is_err() { + return Ok(Err(identity::Error::Identity { + index: self.identity_index, + message: "Invalid YubiKey PIN".to_owned(), + })); + } + + Ok(Ok(Connection { + yubikey, + pk, + slot: self.slot, + tag: self.tag, + })) + } +} + +pub(crate) struct Connection { + yubikey: YubiKey, + pk: Recipient, + slot: RetiredSlotId, + tag: [u8; 4], +} + +impl Connection { + pub(crate) fn recipient(&self) -> &Recipient { + &self.pk + } + + pub(crate) fn unwrap_file_key(&mut self, line: &RecipientLine) -> Result { + assert_eq!(self.tag, line.tag); + + // The YubiKey API for performing scalar multiplication takes the point in its + // uncompressed SEC-1 encoding. + let shared_secret = match decrypt_data( + &mut self.yubikey, + line.epk_bytes.decompress().as_bytes(), + AlgorithmId::EccP256, + SlotId::Retired(self.slot), + ) { + Ok(res) => res, + Err(_) => return Err(()), + }; + + let mut salt = vec![]; + salt.extend_from_slice(line.epk_bytes.as_bytes()); + salt.extend_from_slice(self.pk.to_encoded().as_bytes()); + + let enc_key = hkdf(&salt, STANZA_KEY_LABEL, shared_secret.as_ref()); + + // A failure to decrypt is fatal, because we assume that we won't + // encounter 32-bit collisions on the key tag embedded in the header. + match aead_decrypt(&enc_key, FILE_KEY_BYTES, &line.encrypted_file_key) { + Ok(pt) => Ok(TryInto::<[u8; FILE_KEY_BYTES]>::try_into(&pt[..]) + .unwrap() + .into()), + Err(_) => Err(()), + } + } +} + +#[cfg(test)] +mod tests { + use yubikey_piv::{key::RetiredSlotId, Serial}; + + use super::Stub; + + #[test] + fn stub_round_trip() { + let stub = Stub { + serial: Serial::from(42), + slot: RetiredSlotId::R1, + tag: [7; 4], + identity_index: 0, + }; + + let encoded = stub.to_bytes(); + assert_eq!(Stub::from_bytes(&encoded, 0), Some(stub)); + } }