From 482437a7ad2f8d97a41e37b9868ddd54a5df8efc Mon Sep 17 00:00:00 2001 From: Arthur Gautier Date: Thu, 27 Apr 2023 14:16:55 -0700 Subject: [PATCH] Adds support for YubiHSM Auth This adds support for the YubiHSM Auth protocol as described in https://docs.yubico.com/yesdk/users-manual/application-yubihsm-auth/interacting-yubihsm-2.html This protocol ensure the derivation password for the authentication keys are kept in secure devices. --- .github/workflows/ci.yml | 26 +++- Cargo.toml | 2 + src/client.rs | 29 ++++ src/session.rs | 120 ++++++++++++++- src/session/securechannel.rs | 203 +++++++++++++++++-------- src/session/securechannel/challenge.rs | 40 +++++ src/session/securechannel/context.rs | 7 + 7 files changed, 346 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e283f2e3..3b03cf99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,22 @@ env: jobs: build: - runs-on: ubuntu-latest strategy: matrix: - platform: - - ubuntu-latest - - macos-latest - toolchain: - - stable - - 1.67.0 # MSRV + include: + - platform: ubuntu-latest + toolchain: stable + deps: sudo apt-get install libpcsclite-dev + - platform: macos-latest + toolchain: stable + deps: true + - platform: ubuntu-latest + toolchain: 1.67.0 # MSRV + deps: sudo apt-get install libpcsclite-dev + - platform: macos-latest + toolchain: 1.67.0 # MSRV + deps: true + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v1 - name: cache .cargo/registry @@ -41,14 +48,15 @@ jobs: with: toolchain: ${{ matrix.toolchain }} override: true + - run: ${{ matrix.deps }} - run: cargo build --release - run: cargo build --release --no-default-features - run: cargo build --release --no-default-features --features=passwords - run: cargo build --release --features=usb + - run: cargo build --release --features=yubihsm-auth - run: cargo build --benches test: - runs-on: ubuntu-latest strategy: matrix: platform: @@ -57,6 +65,7 @@ jobs: toolchain: - stable - 1.67.0 # MSRV + runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v1 - name: cache .cargo/registry @@ -103,6 +112,7 @@ jobs: toolchain: 1.71.0 # pinned to prevent CI breakages components: clippy override: true + - run: sudo apt-get install libpcsclite-dev - uses: actions-rs/cargo@v1 with: command: clippy diff --git a/Cargo.toml b/Cargo.toml index 690af5fc..fa726d1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ pbkdf2 = { version = "0.12", optional = true, default-features = false, features serde_json = { version = "1", optional = true } rusb = { version = "0.9", optional = true } tiny_http = { version = "0.12", optional = true } +yubikey = { git = "https://github.com/baloo/yubikey.rs", branch = "baloo/yubihsm-auth", optional = true } [dev-dependencies] ed25519-dalek = "2" @@ -66,6 +67,7 @@ secp256k1 = ["k256"] setup = ["passwords", "serde_json", "uuid/serde"] untested = [] usb = ["rusb"] +yubihsm-auth = ["yubikey"] [package.metadata.docs.rs] all-features = true diff --git a/src/client.rs b/src/client.rs index c62b4db5..878172cf 100644 --- a/src/client.rs +++ b/src/client.rs @@ -43,6 +43,9 @@ use std::{ #[cfg(feature = "passwords")] use std::{thread, time::SystemTime}; +#[cfg(feature = "yubihsm-auth")] +use crate::session::PendingSession; + #[cfg(feature = "untested")] use { crate::{ @@ -106,6 +109,20 @@ impl Client { Ok(client) } + /// Open session with YubiHSM Auth scheme + #[cfg(feature = "yubihsm-auth")] + pub fn yubihsm_auth( + connector: Connector, + authentication_key_id: object::Id, + host_challenge: session::securechannel::Challenge, + ) -> Result { + let timeout = session::Timeout::default(); + + let session = + PendingSession::new(connector, timeout, authentication_key_id, host_challenge)?; + Ok(session) + } + /// Borrow this client's YubiHSM connector (which is `Clone`able) pub fn connector(&self) -> &Connector { &self.connector @@ -1165,3 +1182,15 @@ impl Client { .0) } } + +impl From for Client { + fn from(session: Session) -> Self { + let connector = session.connector(); + let session = Arc::new(Mutex::new(Some(session))); + Self { + connector, + session, + credentials: None, + } + } +} diff --git a/src/session.rs b/src/session.rs index 6bd14231..883c4c7d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -17,6 +17,7 @@ pub use self::{ error::{Error, ErrorKind}, guard::Guard, id::Id, + securechannel::{Challenge, Context, SessionKeys}, timeout::Timeout, }; @@ -30,6 +31,9 @@ use crate::{ }; use std::time::{Duration, Instant}; +#[cfg(feature = "yubihsm-auth")] +use crate::object; + /// Timeout fuzz factor: to avoid races/skew with the YubiHSM's clock, /// we consider sessions to be timed out slightly earlier than the actual /// timeout. This should (hopefully) ensure we always time out first, @@ -37,6 +41,98 @@ use std::time::{Duration, Instant}; /// than opaque "lost connection to HSM"-style errors. const TIMEOUT_FUZZ_FACTOR: Duration = Duration::from_secs(1); +/// Session created on the device for which we do not +/// have credentials for yet. +/// +/// This is used for YubiHSM Auth scheme support. +#[cfg(feature = "yubihsm-auth")] +pub struct PendingSession { + ///// HSM Public key + //card_public_key: PublicKey, + /// Connector which communicates with the HSM (HTTP or USB) + connector: Connector, + + /// Session creation timestamp + created_at: Instant, + + /// Timestamp when this session was last active + last_active: Instant, + + /// Inactivity timeout for this session + timeout: Timeout, + + /// Challenge generate by the HSM. + hsm_challenge: Challenge, + + /// ID for this session + id: Id, + + context: Context, +} + +#[cfg(feature = "yubihsm-auth")] +impl PendingSession { + /// Creates a new session with the device. + pub fn new( + connector: Connector, + timeout: Timeout, + authentication_key_id: object::Id, + host_challenge: Challenge, + ) -> Result { + let (id, session_response) = + SecureChannel::create(&connector, authentication_key_id, host_challenge)?; + + let hsm_challenge = session_response.card_challenge; + let context = Context::from_challenges(host_challenge, hsm_challenge); + + let created_at = Instant::now(); + let last_active = Instant::now(); + + Ok(PendingSession { + id, + connector, + created_at, + last_active, + timeout, + context, + hsm_challenge, + }) + } + + /// Create the session with the provided session keys + pub fn realize(self, session_keys: SessionKeys) -> Result { + let secure_channel = Some(SecureChannel::with_session_keys( + self.id, + self.context, + session_keys, + )); + + let mut session = Session { + id: self.id, + secure_channel, + connector: self.connector, + created_at: self.created_at, + last_active: self.last_active, + timeout: self.timeout, + }; + + let response = session.start_authenticate()?; + session.finish_authenticate_session(&response)?; + + Ok(session) + } + + /// Return the challenge emitted by the HSM when opening the session + pub fn get_challenge(&self) -> Challenge { + self.hsm_challenge + } + + /// Return the id of the session + pub fn id(&self) -> Id { + self.id + } +} + /// Authenticated and encrypted (SCP03) `Session` with the HSM. A `Session` is /// needed to perform any command. /// @@ -247,13 +343,9 @@ impl Session { credentials.authentication_key_id ); - let command = self.secure_channel()?.authenticate_session()?; - let response = self.send_message(command)?; + let response = self.start_authenticate()?; - if let Err(e) = self - .secure_channel()? - .finish_authenticate_session(&response) - { + if let Err(e) = self.finish_authenticate_session(&response) { session_error!( self, "failed={:?} key={} err={:?}", @@ -269,10 +361,26 @@ impl Session { Ok(()) } + /// Send the message to the card to start authentication + fn start_authenticate(&mut self) -> Result { + let command = self.secure_channel()?.authenticate_session()?; + self.send_message(command) + } + + /// Read authenticate session message from the card + fn finish_authenticate_session(&mut self, response: &response::Message) -> Result<(), Error> { + self.secure_channel()?.finish_authenticate_session(response) + } + /// Get the underlying channel or return an error fn secure_channel(&mut self) -> Result<&mut SecureChannel, Error> { self.secure_channel .as_mut() .ok_or_else(|| format_err!(ErrorKind::ClosedError, "session is already closed").into()) } + + /// Get the underlying connector used by this session + pub(crate) fn connector(&self) -> Connector { + self.connector.clone() + } } diff --git a/src/session/securechannel.rs b/src/session/securechannel.rs index 05c2e146..95dc3ea1 100644 --- a/src/session/securechannel.rs +++ b/src/session/securechannel.rs @@ -24,9 +24,9 @@ mod cryptogram; mod kdf; mod mac; +pub use self::{challenge::Challenge, context::Context}; pub(crate) use self::{ - challenge::{Challenge, CHALLENGE_SIZE}, - context::Context, + challenge::CHALLENGE_SIZE, cryptogram::{Cryptogram, CRYPTOGRAM_SIZE}, mac::Mac, }; @@ -35,7 +35,7 @@ use crate::{ authentication::{self, Credentials}, command, connector::Connector, - device, response, + device, object, response, serialization::deserialize, session::{self, ErrorKind}, }; @@ -47,6 +47,7 @@ use aes::{ Aes128, }; use cmac::{digest::Mac as _, Cmac}; +use serde::Serialize; use subtle::ConstantTimeEq; use zeroize::{Zeroize, Zeroizing}; @@ -70,6 +71,42 @@ const AES_BLOCK_SIZE: usize = 16; type Aes128CbcEnc = cbc::Encryptor; type Aes128CbcDec = cbc::Decryptor; +/// SCP03 AES Session Keys +#[derive(Serialize)] +pub struct SessionKeys { + /// Session encryption key (S-ENC) + pub enc_key: [u8; KEY_SIZE], + + /// Session Command MAC key (S-MAC) + pub mac_key: [u8; KEY_SIZE], + + /// Session Respose MAC key (S-RMAC) + pub rmac_key: [u8; KEY_SIZE], +} + +impl Zeroize for SessionKeys { + fn zeroize(&mut self) { + self.enc_key.zeroize(); + self.mac_key.zeroize(); + self.rmac_key.zeroize(); + } +} + +#[cfg(feature = "yubihsm-auth")] +impl From for SessionKeys { + fn from(keys: yubikey::hsmauth::SessionKeys) -> Self { + let enc_key = *keys.enc_key; + let mac_key = *keys.mac_key; + let rmac_key = *keys.rmac_key; + + Self { + enc_key, + mac_key, + rmac_key, + } + } +} + /// SCP03 Secure Channel pub(crate) struct SecureChannel { /// ID of this channel (a.k.a. session ID) @@ -85,14 +122,8 @@ pub(crate) struct SecureChannel { /// Context (card + host challenges) context: Context, - /// Session encryption key (S-ENC) - enc_key: [u8; KEY_SIZE], - - /// Session Command MAC key (S-MAC) - mac_key: [u8; KEY_SIZE], - - /// Session Respose MAC key (S-RMAC) - rmac_key: [u8; KEY_SIZE], + /// Session keys + session_keys: SessionKeys, /// Chaining value to be included when computing MACs mac_chaining_value: [u8; Mac::BYTE_SIZE * 2], @@ -107,45 +138,8 @@ impl SecureChannel { ) -> Result { let host_challenge = Challenge::new(); - let command_message = command::Message::from(&CreateSessionCommand { - authentication_key_id: credentials.authentication_key_id, - host_challenge, - }); - - let uuid = command_message.uuid; - let response_body = connector.send_message(uuid, command_message.into())?; - let response_message = response::Message::parse(response_body)?; - - if response_message.is_err() { - match device::ErrorKind::from_response_message(&response_message) { - Some(device::ErrorKind::ObjectNotFound) => fail!( - ErrorKind::AuthenticationError, - "auth key not found: 0x{:04x}", - credentials.authentication_key_id - ), - Some(kind) => return Err(kind.into()), - None => fail!( - ErrorKind::ResponseError, - "HSM error: {:?}", - response_message.code - ), - } - } - - if response_message.command().unwrap() != command::Code::CreateSession { - fail!( - ErrorKind::ProtocolError, - "command type mismatch: expected {:?}, got {:?}", - command::Code::CreateSession, - response_message.command().unwrap() - ); - } - - let id = response_message - .session_id - .ok_or_else(|| format_err!(ErrorKind::CreateFailed, "no session ID in response"))?; - - let session_response: CreateSessionResponse = deserialize(response_message.data.as_ref())?; + let (id, session_response) = + Self::create(connector, credentials.authentication_key_id, host_challenge)?; // Derive session keys from the combination of host and card challenges. // If either of them are incorrect (indicating a key mismatch) it will @@ -185,6 +179,20 @@ impl SecureChannel { let enc_key = derive_key(authentication_key.enc_key(), 0b100, &context); let mac_key = derive_key(authentication_key.mac_key(), 0b110, &context); let rmac_key = derive_key(authentication_key.mac_key(), 0b111, &context); + + let session_keys = SessionKeys { + enc_key, + mac_key, + rmac_key, + }; + Self::with_session_keys(id, context, session_keys) + } + + pub(crate) fn with_session_keys( + id: session::Id, + context: Context, + session_keys: SessionKeys, + ) -> Self { let mac_chaining_value = [0u8; Mac::BYTE_SIZE * 2]; Self { @@ -192,13 +200,62 @@ impl SecureChannel { counter: 0, security_level: SecurityLevel::None, context, - enc_key, - mac_key, - rmac_key, + session_keys, mac_chaining_value, } } + /// Open a SecureChannel with the HSM. This will not complete authentication. + /// + /// This will return the session id as well as the card challenge. + pub(crate) fn create( + connector: &Connector, + authentication_key_id: object::Id, + host_challenge: Challenge, + ) -> Result<(session::Id, CreateSessionResponse), session::Error> { + let command_message = command::Message::from(&CreateSessionCommand { + authentication_key_id, //: credentials.authentication_key_id, + host_challenge, + }); + + let uuid = command_message.uuid; + let response_body = connector.send_message(uuid, command_message.into())?; + let response_message = response::Message::parse(response_body)?; + + if response_message.is_err() { + match device::ErrorKind::from_response_message(&response_message) { + Some(device::ErrorKind::ObjectNotFound) => fail!( + ErrorKind::AuthenticationError, + "auth key not found: 0x{:04x}", + authentication_key_id + ), + Some(kind) => return Err(kind.into()), + None => fail!( + ErrorKind::ResponseError, + "HSM error: {:?}", + response_message.code + ), + } + } + + if response_message.command().unwrap() != command::Code::CreateSession { + fail!( + ErrorKind::ProtocolError, + "command type mismatch: expected {:?}, got {:?}", + command::Code::CreateSession, + response_message.command().unwrap() + ); + } + + let id = response_message + .session_id + .ok_or_else(|| format_err!(ErrorKind::CreateFailed, "no session ID in response"))?; + + let session_response: CreateSessionResponse = deserialize(response_message.data.as_ref())?; + + Ok((id, session_response)) + } + /// Get the channel (i.e. session) ID pub fn id(&self) -> session::Id { self.id @@ -207,14 +264,24 @@ impl SecureChannel { /// Calculate the card's cryptogram for this session pub fn card_cryptogram(&self) -> Cryptogram { let mut result_bytes = Zeroizing::new([0u8; CRYPTOGRAM_SIZE]); - kdf::derive(&self.mac_key, 0, &self.context, result_bytes.as_mut()); + kdf::derive( + &self.session_keys.mac_key, + 0, + &self.context, + result_bytes.as_mut(), + ); Cryptogram::from_slice(result_bytes.as_ref()) } /// Calculate the host's cryptogram for this session pub fn host_cryptogram(&self) -> Cryptogram { let mut result_bytes = Zeroizing::new([0u8; CRYPTOGRAM_SIZE]); - kdf::derive(&self.mac_key, 1, &self.context, result_bytes.as_mut()); + kdf::derive( + &self.session_keys.mac_key, + 1, + &self.context, + result_bytes.as_mut(), + ); Cryptogram::from_slice(result_bytes.as_ref()) } @@ -233,7 +300,8 @@ impl SecureChannel { ); } - let mut mac = as KeyInit>::new_from_slice(self.mac_key.as_ref()).unwrap(); + let mut mac = + as KeyInit>::new_from_slice(self.session_keys.mac_key.as_ref()).unwrap(); mac.update(&self.mac_chaining_value); mac.update(&[command_type.to_u8()]); @@ -298,7 +366,7 @@ impl SecureChannel { // Provide space at the end of the vec for the padding message.extend_from_slice(&[0u8; AES_BLOCK_SIZE]); - let cipher = Aes128::new_from_slice(&self.enc_key).unwrap(); + let cipher = Aes128::new_from_slice(&self.session_keys.enc_key).unwrap(); let icv = compute_icv(&cipher, self.counter); let cbc_encryptor = Aes128CbcEnc::inner_iv_init(cipher, &icv); let ciphertext = cbc_encryptor @@ -315,7 +383,7 @@ impl SecureChannel { ) -> Result { assert_eq!(self.security_level, SecurityLevel::Authenticated); - let cipher = Aes128::new_from_slice(&self.enc_key).unwrap(); + let cipher = Aes128::new_from_slice(&self.session_keys.enc_key).unwrap(); let icv = compute_icv(&cipher, self.counter); self.verify_response_mac(&encrypted_response)?; @@ -364,7 +432,8 @@ impl SecureChannel { ); } - let mut mac = as KeyInit>::new_from_slice(self.rmac_key.as_ref()).unwrap(); + let mut mac = + as KeyInit>::new_from_slice(self.session_keys.rmac_key.as_ref()).unwrap(); mac.update(&self.mac_chaining_value); mac.update(&[response.code.to_u8()]); @@ -441,12 +510,12 @@ impl SecureChannel { ) -> Result { assert_eq!(self.security_level, SecurityLevel::Authenticated); - let cipher = Aes128::new_from_slice(&self.enc_key).unwrap(); + let cipher = Aes128::new_from_slice(&self.session_keys.enc_key).unwrap(); let icv = compute_icv(&cipher, self.counter); self.verify_command_mac(&encrypted_command)?; - let cipher = Aes128::new_from_slice(&self.enc_key).unwrap(); + let cipher = Aes128::new_from_slice(&self.session_keys.enc_key).unwrap(); let cbc_decryptor = Aes128CbcDec::inner_iv_init(cipher, &icv); let mut command_data = encrypted_command.data; @@ -479,7 +548,8 @@ impl SecureChannel { command.session_id ); - let mut mac = as KeyInit>::new_from_slice(self.mac_key.as_ref()).unwrap(); + let mut mac = + as KeyInit>::new_from_slice(self.session_keys.mac_key.as_ref()).unwrap(); mac.update(&self.mac_chaining_value); mac.update(&[command.command_type.to_u8()]); @@ -519,7 +589,7 @@ impl SecureChannel { // Provide space at the end of the vec for the padding message.extend_from_slice(&[0u8; AES_BLOCK_SIZE]); - let cipher = Aes128::new_from_slice(&self.enc_key).unwrap(); + let cipher = Aes128::new_from_slice(&self.session_keys.enc_key).unwrap(); let icv = compute_icv(&cipher, self.counter); let cbc_encryptor = Aes128CbcEnc::inner_iv_init(cipher, &icv); @@ -548,7 +618,8 @@ impl SecureChannel { assert_eq!(self.security_level, SecurityLevel::Authenticated); let body = response_data.into(); - let mut mac = as KeyInit>::new_from_slice(self.rmac_key.as_ref()).unwrap(); + let mut mac = + as KeyInit>::new_from_slice(self.session_keys.rmac_key.as_ref()).unwrap(); mac.update(&self.mac_chaining_value); mac.update(&[code.to_u8()]); @@ -584,9 +655,7 @@ impl SecureChannel { /// Terminate the session fn terminate(&mut self) { self.security_level = SecurityLevel::Terminated; - self.enc_key.zeroize(); - self.mac_key.zeroize(); - self.rmac_key.zeroize(); + self.session_keys.zeroize(); } } diff --git a/src/session/securechannel/challenge.rs b/src/session/securechannel/challenge.rs index dc03c4ee..3069bb8f 100644 --- a/src/session/securechannel/challenge.rs +++ b/src/session/securechannel/challenge.rs @@ -3,6 +3,9 @@ use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; +#[cfg(feature = "yubihsm-auth")] +use crate::session::error::{Error, ErrorKind}; + /// Size of a challenge message pub const CHALLENGE_SIZE: usize = 8; @@ -35,4 +38,41 @@ impl Challenge { pub fn as_slice(&self) -> &[u8] { &self.0 } + + /// Creates `Challenge` from a `yubikey::hsmauth::Challenge`. + /// + /// `YubiKey` firmware 5.4.3 will generate an empty challenge, this will + /// generate one from RNG if we're provided an empty challenge + // Note(baloo): because of the side-effect described above, this is not + // made a regular From. + #[cfg(feature = "yubihsm-auth")] + pub fn from_yubikey_challenge(yc: yubikey::hsmauth::Challenge) -> Self { + if yc.is_empty() { + Self::new() + } else { + let mut challenge = [0u8; CHALLENGE_SIZE]; + challenge.copy_from_slice(yc.as_slice()); + Challenge(challenge) + } + } +} + +#[cfg(feature = "yubihsm-auth")] +impl TryFrom for yubikey::hsmauth::Challenge { + type Error = Error; + + fn try_from(c: Challenge) -> Result { + let mut challenge = yubikey::hsmauth::Challenge::default(); + challenge + .copy_from_slice(c.as_slice()) + .map_err(|e| Error::from(ErrorKind::ProtocolError.context(e)))?; + + Ok(challenge) + } +} + +impl Default for Challenge { + fn default() -> Self { + Self::new() + } } diff --git a/src/session/securechannel/context.rs b/src/session/securechannel/context.rs index 1a50769c..e84a34d1 100644 --- a/src/session/securechannel/context.rs +++ b/src/session/securechannel/context.rs @@ -22,3 +22,10 @@ impl Context { &self.0 } } + +#[cfg(feature = "yubihsm-auth")] +impl From for yubikey::hsmauth::Context { + fn from(context: Context) -> Self { + Self::from_buf(context.0) + } +}