From b9da47164043d34b7dc0574185fac4c266d06f21 Mon Sep 17 00:00:00 2001 From: Arthur Gautier Date: Sat, 18 Mar 2023 12:36:08 -0700 Subject: [PATCH] implement yubihsm auth This is used to calculate session keys for Yubico HSM. --- src/apdu.rs | 16 +++ src/consts.rs | 5 + src/hsmauth.rs | 269 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/transaction.rs | 205 +++++++++++++++++++++++++++------- src/yubikey.rs | 46 +++++++- 6 files changed, 499 insertions(+), 43 deletions(-) create mode 100644 src/hsmauth.rs diff --git a/src/apdu.rs b/src/apdu.rs index d8942a32..43036457 100644 --- a/src/apdu.rs +++ b/src/apdu.rs @@ -198,6 +198,15 @@ pub enum Ins { /// Get slot metadata GetMetadata, + /// YubiHSM Auth // Calculate session keys + Calculate, + + /// YubiHSM Auth // Get challenge + GetHostChallenge, + + /// YubiHSM Auth // List credentials + ListCredentials, + /// Other/unrecognized instruction codes Other(u8), } @@ -223,6 +232,10 @@ impl Ins { Ins::Attest => 0xf9, Ins::GetSerial => 0xf8, Ins::GetMetadata => 0xf7, + // Yubihsm auth + Ins::Calculate => 0x03, + Ins::GetHostChallenge => 0x04, + Ins::ListCredentials => 0x05, Ins::Other(code) => code, } } @@ -231,6 +244,9 @@ impl Ins { impl From for Ins { fn from(code: u8) -> Self { match code { + 0x03 => Ins::Calculate, + 0x04 => Ins::GetHostChallenge, + 0x05 => Ins::ListCredentials, 0x20 => Ins::Verify, 0x24 => Ins::ChangeReference, 0x2c => Ins::ResetRetry, diff --git a/src/consts.rs b/src/consts.rs index 03242ab8..5e06591b 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -18,3 +18,8 @@ pub(crate) const TAG_ADMIN_TIMESTAMP: u8 = 0x83; // Protected tags pub(crate) const TAG_PROTECTED_FLAGS_1: u8 = 0x81; pub(crate) const TAG_PROTECTED_MGM: u8 = 0x89; + +// YubiHSM Auth +pub(crate) const TAG_LABEL: u8 = 0x71; +pub(crate) const TAG_PW: u8 = 0x73; +pub(crate) const TAG_CONTEXT: u8 = 0x77; diff --git a/src/hsmauth.rs b/src/hsmauth.rs new file mode 100644 index 00000000..4079222f --- /dev/null +++ b/src/hsmauth.rs @@ -0,0 +1,269 @@ +//! YubiHSM Auth protocol +//! +//! YubiHSM Auth is a YubiKey CCID application that stores the long-lived +//! credentials used to establish secure sessions with a YubiHSM 2. The secure +//! session protocol is based on Secure Channel Protocol 3 (SCP03). +use crate::{ + error::{Error, Result}, + transaction::Transaction, + YubiKey, +}; +use nom::{ + bytes::complete::{tag, take}, + combinator::eof, + error::{Error as NumError, ErrorKind}, + multi::many0, + number::complete::u8, + IResult, +}; +use std::{fmt, str::FromStr}; +use zeroize::Zeroizing; + +/// Yubikey HSM Auth Applet ID +pub(crate) const APPLET_ID: &[u8] = &[0xa0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x07, 0x01]; +/// Yubikey HSM Auth Applet Name +pub(crate) const APPLET_NAME: &str = "YubiHSM"; + +/// AES key size in bytes. SCP03 theoretically supports other key sizes, but +/// the YubiHSM 2 does not. Since this crate is somewhat specialized to the `YubiHSM 2` (at least for now) +/// we hardcode to 128-bit for simplicity. +pub(crate) const KEY_SIZE: usize = 16; + +/// Password to authenticate to the Yubikey HSM Auth Applet has a max length of 16 +pub(crate) const PW_LEN: usize = 16; + +/// Label associated with a secret on the Yubikey. +#[derive(Clone)] +pub struct Label(pub(crate) Vec); + +impl FromStr for Label { + type Err = Error; + + fn from_str(input: &str) -> Result { + let buf = input.as_bytes(); + + if (1..=64).contains(&buf.len()) { + Ok(Self(buf.to_vec())) + } else { + Err(Error::ParseError) + } + } +} + +/// [`Context`] holds the various challenges used for the authentication. +/// +/// This is used as part of the key derivation for the session keys. +pub struct Context(pub(crate) [u8; 16]); + +impl Context { + /// Creates a [`Context`] from its components + pub fn new(host_challenge: &Challenge, hsm_challenge: &Challenge) -> Self { + let mut out = Self::zeroed(); + out.0[..8].copy_from_slice(host_challenge.as_slice()); + out.0[8..].copy_from_slice(hsm_challenge.as_slice()); + + out + } + + fn zeroed() -> Self { + Self([0u8; 16]) + } + + /// Build `Context` from the provided buffer + pub fn from_buf(buf: [u8; 16]) -> Self { + Self(buf) + } +} + +/// Exclusive access to the Hsmauth applet. +pub struct HsmAuth { + client: YubiKey, +} + +impl HsmAuth { + pub(crate) fn new(mut client: YubiKey) -> Result { + Transaction::new(&mut client.card)?.select_application( + APPLET_ID, + APPLET_NAME, + "failed selecting YkHSM auth application", + )?; + + Ok(Self { client }) + } + + /// Calculate session key with the specified key. + pub fn calculate( + &mut self, + label: Label, + context: Context, + password: &[u8], + ) -> Result { + Transaction::new(&mut self.client.card)?.calculate( + self.client.version, + label, + context, + password, + ) + } + + /// Get YubiKey Challenge + pub fn get_challenge(&mut self, label: Label) -> Result { + Transaction::new(&mut self.client.card)?.get_host_challenge(self.client.version, label) + } + + /// List credentials + pub fn list_credentials(&mut self) -> Result> { + Transaction::new(&mut self.client.card)?.list_credentials(self.client.version) + } + + /// Retun the inner `YubiKey` + pub fn into_inner(mut self) -> Result { + Transaction::new(&mut self.client.card)?.select_piv_application()?; + Ok(self.client) + } +} + +#[derive(Debug)] +/// Algorithm for the credentials +pub enum Algorithm { + /// AES 128 keys + Aes128, + /// EC P256 + EcP256, +} + +impl fmt::Display for Algorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Algorithm::Aes128 => write!(f, "AES128"), + Algorithm::EcP256 => write!(f, "ECP256"), + } + } +} + +impl Algorithm { + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, value) = u8(input)?; + match value { + 38 => Ok((input, Algorithm::Aes128)), + 39 => Ok((input, Algorithm::EcP256)), + _ => Err(nom::Err::Error(NumError::new(input, ErrorKind::Tag))), + } + } +} + +/// YubiHSM Auth credential store in the Application +#[derive(Debug)] +pub struct Credential { + /// Algorithm of the key + pub algorithm: Algorithm, + /// Is touch required when using this credential + pub touch: bool, + /// Remaining attempts to authenticate to this credential + pub remaining_attempts: u8, + /// Label for the credential + pub label: Vec, +} + +impl Credential { + /// Parse a buffer holding a single credential + fn parse(input: &[u8]) -> IResult<&[u8], Self> { + let (input, _list) = tag(b"\x72")(input)?; + + let (input, buf_len) = u8(input)?; + let buf_len = if buf_len < 3 { + return Err(nom::Err::Error(NumError::new( + input, + ErrorKind::LengthValue, + ))); + } else { + (buf_len - 3) as usize + }; + + let (input, algorithm) = Algorithm::parse(input)?; + + let (input, touch) = u8(input)?; + let touch = match touch { + 0 => false, + 1 => true, + _ => return Err(nom::Err::Error(NumError::new(input, ErrorKind::Tag))), + }; + + let (input, label) = take(buf_len)(input)?; + + let (input, remaining_attempts) = u8(input)?; + + Ok(( + input, + Credential { + algorithm, + touch, + label: label.to_vec(), + remaining_attempts, + }, + )) + } + + /// Parse a buffer holding a list of `Credential` + pub fn parse_list(input: &[u8]) -> Result> { + let (input, credentials) = + many0(Credential::parse)(input).map_err(|_| Error::ParseError)?; + let (_input, _) = eof(input).map_err(|_: nom::Err>| Error::ParseError)?; + + Ok(credentials) + } +} + +/// The sessions keys after negociation via SCP03. +#[derive(Default, Debug)] +pub struct SessionKeys { + /// Session encryption key (S-ENC) + pub enc_key: Zeroizing<[u8; KEY_SIZE]>, + /// Session Command MAC key (S-MAC) + pub mac_key: Zeroizing<[u8; KEY_SIZE]>, + /// Session Respose MAC key (S-RMAC) + pub rmac_key: Zeroizing<[u8; KEY_SIZE]>, +} + +/// Host challenge used for session keys calculation +#[derive(Debug, Clone)] +pub struct Challenge { + buffer: Zeroizing<[u8; Self::SIZE]>, + length: usize, +} + +impl Challenge { + /// Challenge used in SCP03 computation + /// Can be as long as a P256 PUBKEY (for SCP11 support). + pub(crate) const SIZE: usize = 65; + + /// Returns a slice containing the entire array. + pub fn as_slice(&self) -> &[u8] { + &self.buffer.as_slice()[..self.length] + } + + /// Returns true if the challenge has a length of 0. + pub fn is_empty(&self) -> bool { + self.length == 0 + } + + /// Copy data from provided slice + pub fn copy_from_slice(&mut self, data: &[u8]) -> Result<()> { + self.length = data.len(); + if self.length > Self::SIZE { + Err(Error::SizeError) + } else { + self.buffer[..self.length].copy_from_slice(data); + Ok(()) + } + } +} + +impl Default for Challenge { + fn default() -> Self { + Self { + buffer: Zeroizing::new([0u8; Self::SIZE]), + length: 0, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 571712ee..1fada138 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ mod chuid; mod config; mod consts; mod error; +pub mod hsmauth; mod metadata; mod mgm; #[cfg(feature = "untested")] diff --git a/src/transaction.rs b/src/transaction.rs index 13987416..d60366e6 100644 --- a/src/transaction.rs +++ b/src/transaction.rs @@ -3,8 +3,9 @@ use crate::{ apdu::Response, apdu::{Apdu, Ins, StatusWords}, - consts::{CB_BUF_MAX, CB_OBJ_MAX}, + consts::{CB_BUF_MAX, CB_OBJ_MAX, TAG_CONTEXT, TAG_LABEL, TAG_PW}, error::{Error, Result}, + hsmauth::{self, Challenge, Context, Credential, Label, SessionKeys}, otp, piv::{self, AlgorithmId, SlotId}, serialization::*, @@ -61,10 +62,24 @@ impl<'tx> Transaction<'tx> { } /// Select application. - pub fn select_application(&self) -> Result<()> { + pub fn select_piv_application(&self) -> Result<()> { + self.select_application( + piv::APPLET_ID, + piv::APPLET_NAME, + "failed selecting application", + ) + } + + /// Select application. + pub(crate) fn select_application( + &self, + applet: &[u8], + applet_name: &'static str, + error: &'static str, + ) -> Result<()> { let response = Apdu::new(Ins::SelectApplication) .p1(0x04) - .data(piv::APPLET_ID) + .data(applet) .transmit(self, 0xFF) .map_err(|e| { error!("failed communicating with card: '{}'", e); @@ -72,14 +87,9 @@ impl<'tx> Transaction<'tx> { })?; if !response.is_success() { - error!( - "failed selecting application: {:04x}", - response.status_words().code() - ); + error!("{}: {:04x}", error, response.status_words().code()); return Err(match response.status_words() { - StatusWords::NotFoundError => Error::AppletNotFound { - applet_name: piv::APPLET_NAME, - }, + StatusWords::NotFoundError => Error::AppletNotFound { applet_name }, _ => Error::GenericError, }); } @@ -108,21 +118,11 @@ impl<'tx> Transaction<'tx> { match version.major { // YK4 requires switching to the YK applet to retrieve the serial 4 => { - let sw = Apdu::new(Ins::SelectApplication) - .p1(0x04) - .data(otp::APPLET_ID) - .transmit(self, 0xFF)? - .status_words(); - - if !sw.is_success() { - error!("failed selecting yk application: {:04x}", sw.code()); - return Err(match sw { - StatusWords::NotFoundError => Error::AppletNotFound { - applet_name: otp::APPLET_NAME, - }, - _ => Error::GenericError, - }); - } + self.select_application( + otp::APPLET_ID, + otp::APPLET_NAME, + "failed selecting yk application", + )?; let response = Apdu::new(0x01).p1(0x10).transmit(self, 0xFF)?; @@ -136,21 +136,11 @@ impl<'tx> Transaction<'tx> { } // reselect the PIV applet - let sw = Apdu::new(Ins::SelectApplication) - .p1(0x04) - .data(piv::APPLET_ID) - .transmit(self, 0xFF)? - .status_words(); - - if !sw.is_success() { - error!("failed selecting application: {:04x}", sw.code()); - return Err(match sw { - StatusWords::NotFoundError => Error::AppletNotFound { - applet_name: piv::APPLET_NAME, - }, - _ => Error::GenericError, - }); - } + self.select_application( + piv::APPLET_ID, + piv::APPLET_NAME, + "failed selecting application", + )?; response.data().try_into() } @@ -515,4 +505,139 @@ impl<'tx> Transaction<'tx> { _ => Err(Error::GenericError), } } + + /// Get YubiKey Host challenge + pub fn get_host_challenge(&mut self, version: Version, label: Label) -> Result { + // YubiHSM was introduced by firmware 5.4.3 + // https://docs.yubico.com/yesdk/users-manual/application-yubihsm-auth/yubihsm-auth-overview.html + if version + < (Version { + major: 5, + minor: 4, + patch: 3, + }) + { + return Err(Error::NotSupported); + } + + let mut data = [0u8; CB_BUF_MAX]; + let mut len = data.len(); + let mut data_remaining = &mut data[..]; + + let offset = Tlv::write(data_remaining, TAG_LABEL, &label.0)?; + data_remaining = &mut data_remaining[offset..]; + + len -= data_remaining.len(); + let response = Apdu::new(Ins::GetHostChallenge) + .params(0x00, 0x00) + .data(&data[..len]) + .transmit(self, (Challenge::SIZE) + 2)?; + + // TODO: do we need to check response.data() is the size we asked? + let data = response.data(); + let mut host_challenge = Challenge::default(); + + host_challenge.copy_from_slice(data)?; + + Ok(host_challenge) + } + + /// Get AES-128 session keys + /// + /// Get the SCP03 session keys from an AES-128 credential. + pub fn calculate( + &mut self, + version: Version, + label: Label, + context: Context, + password: &[u8], + ) -> Result { + // YubiHSM was introduced by firmware 5.4.3 + // https://docs.yubico.com/yesdk/users-manual/application-yubihsm-auth/yubihsm-auth-overview.html + if version + < (Version { + major: 5, + minor: 4, + patch: 3, + }) + { + return Err(Error::NotSupported); + } + + let mut data = [0u8; CB_BUF_MAX]; + let mut len = data.len(); + let mut data_remaining = &mut data[..]; + + let offset = Tlv::write(data_remaining, TAG_LABEL, &label.0)?; + data_remaining = &mut data_remaining[offset..]; + + let offset = Tlv::write(data_remaining, TAG_CONTEXT, &context.0)?; + data_remaining = &mut data_remaining[offset..]; + + let mut password = password.to_vec(); + password.resize(hsmauth::PW_LEN, 0); + + let offset = Tlv::write(data_remaining, TAG_PW, &password)?; + data_remaining = &mut data_remaining[offset..]; + len -= data_remaining.len(); + + let response = Apdu::new(Ins::Calculate) + .params(0x00, 0x00) + .data(&data[..len]) + .transmit(self, (hsmauth::KEY_SIZE * 3) + 2)?; + + if !response.is_success() { + error!( + "failed calculating the session secret: {:04x}", + response.status_words().code() + ); + return Err(Error::GenericError); + } + + let data = response.data(); + + // TODO: check length is long enough (KEY_SIZE*3) + let mut session_keys = SessionKeys::default(); + session_keys + .enc_key + .copy_from_slice(&data[..hsmauth::KEY_SIZE]); + session_keys + .mac_key + .copy_from_slice(&data[hsmauth::KEY_SIZE..hsmauth::KEY_SIZE * 2]); + session_keys + .rmac_key + .copy_from_slice(&data[hsmauth::KEY_SIZE * 2..]); + + Ok(session_keys) + } + + /// Get AES-128 session keys + /// + /// Get the SCP03 session keys from an AES-128 credential. + pub fn list_credentials(&mut self, version: Version) -> Result> { + // YubiHSM was introduced by firmware 5.4.3 + // https://docs.yubico.com/yesdk/users-manual/application-yubihsm-auth/yubihsm-auth-overview.html + if version + < (Version { + major: 5, + minor: 4, + patch: 3, + }) + { + return Err(Error::NotSupported); + } + + let mut data = [0u8; CB_BUF_MAX]; + let mut len = data.len(); + let data_remaining = &mut data[..]; + + len -= data_remaining.len(); + let response = Apdu::new(Ins::ListCredentials) + .params(0x00, 0x00) + .data(&data[..len]) + .transmit(self, (Challenge::SIZE) + 2)?; + + let data = response.data(); + Credential::parse_list(data) + } } diff --git a/src/yubikey.rs b/src/yubikey.rs index 6a699e31..95adf13b 100644 --- a/src/yubikey.rs +++ b/src/yubikey.rs @@ -36,6 +36,7 @@ use crate::{ chuid::ChuId, config::Config, error::{Error, Result}, + hsmauth::HsmAuth, mgm::MgmKey, piv, reader::{Context, Reader}, @@ -45,6 +46,7 @@ use log::{error, info}; use pcsc::{Card, Disposition}; use rand_core::{OsRng, RngCore}; use std::{ + cmp::{Ord, Ordering}, fmt::{self, Display}, str::FromStr, }; @@ -145,6 +147,39 @@ impl Version { } } +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + if self.major > other.major { + return Ordering::Greater; + } + if self.major < other.major { + return Ordering::Less; + } + + if self.minor > other.minor { + return Ordering::Greater; + } + if self.minor < other.minor { + return Ordering::Less; + } + + if self.patch > other.patch { + return Ordering::Greater; + } + if self.patch < other.patch { + return Ordering::Less; + } + + Ordering::Equal + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + impl Display for Version { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) @@ -259,7 +294,7 @@ impl YubiKey { .map(|p| Buffer::new(p.expose_secret().clone())); let txn = Transaction::new(&mut self.card)?; - txn.select_application()?; + txn.select_piv_application()?; if let Some(p) = &pin { txn.verify_pin(p)?; @@ -444,7 +479,7 @@ impl YubiKey { // Force a re-select to unverify, because once verified the spec dictates that // subsequent verify calls will return a "verification not needed" instead of // the number of tries left... - txn.select_application()?; + txn.select_piv_application()?; // WRONG_PIN is expected on successful query. match txn.verify_pin(&[]) { @@ -690,6 +725,11 @@ impl YubiKey { Ok(()) } + + /// Creates a client for the YubiHSM AUth + pub fn hsmauth(self) -> Result { + HsmAuth::new(self) + } } impl<'a> TryFrom<&'a Reader<'_>> for YubiKey { @@ -705,7 +745,7 @@ impl<'a> TryFrom<&'a Reader<'_>> for YubiKey { let mut app_version_serial = || -> Result<(Version, Serial)> { let txn = Transaction::new(&mut card)?; - txn.select_application()?; + txn.select_piv_application()?; let v = txn.get_version()?; let s = txn.get_serial(v)?;