From 8d52e8aad734ad6a95ed843d9b92889c4319560a Mon Sep 17 00:00:00 2001 From: Fabian Kaczmarczyck <kaczmarczyck@google.com> Date: Mon, 9 Mar 2020 20:06:06 +0100 Subject: [PATCH] adding HMAC-secret support --- src/ctap/data_formats.rs | 96 ++++++++++++- src/ctap/mod.rs | 282 +++++++++++++++++++++++++++++++++++++-- src/ctap/storage.rs | 2 + 3 files changed, 365 insertions(+), 15 deletions(-) diff --git a/src/ctap/data_formats.rs b/src/ctap/data_formats.rs index eae2948..bc60102 100644 --- a/src/ctap/data_formats.rs +++ b/src/ctap/data_formats.rs @@ -220,6 +220,78 @@ impl TryFrom<&cbor::Value> for Extensions { } } +impl From<Extensions> for cbor::Value { + fn from(extensions: Extensions) -> Self { + cbor_map_btree!(extensions + .0 + .iter() + .map(|(key, value)| (cbor_text!(key), value.clone())) + .collect()) + } +} + +impl Extensions { + #[cfg(test)] + pub fn new(extension_map: BTreeMap<String, cbor::Value>) -> Self { + Extensions(extension_map) + } + + pub fn has_make_credential_hmac_secret(&self) -> Result<bool, Ctap2StatusCode> { + self.0 + .get("hmac-secret") + .map(read_bool) + .unwrap_or(Ok(false)) + } + + pub fn get_assertion_hmac_secret( + &self, + ) -> Option<Result<GetAssertionHmacSecretInput, Ctap2StatusCode>> { + self.0 + .get("hmac-secret") + .map(GetAssertionHmacSecretInput::try_from) + } +} + +#[cfg_attr(any(test, feature = "debug_ctap"), derive(Debug, PartialEq))] +pub struct GetAssertionHmacSecretInput { + pub key_agreement: CoseKey, + pub salt_enc: Vec<u8>, + pub salt_auth: Vec<u8>, +} + +impl TryFrom<&cbor::Value> for GetAssertionHmacSecretInput { + type Error = Ctap2StatusCode; + + fn try_from(cbor_value: &cbor::Value) -> Result<Self, Ctap2StatusCode> { + let input_map = read_map(cbor_value)?; + let cose_key = read_map(ok_or_missing(input_map.get(&cbor_unsigned!(1)))?)?; + let salt_enc = read_byte_string(ok_or_missing(input_map.get(&cbor_unsigned!(2)))?)?; + let salt_auth = read_byte_string(ok_or_missing(input_map.get(&cbor_unsigned!(3)))?)?; + Ok(Self { + key_agreement: CoseKey(cose_key.clone()), + salt_enc, + salt_auth, + }) + } +} + +#[cfg_attr(any(test, feature = "debug_ctap"), derive(Debug, PartialEq))] +pub struct GetAssertionHmacSecretOutput(Vec<u8>); + +impl From<GetAssertionHmacSecretOutput> for cbor::Value { + fn from(message: GetAssertionHmacSecretOutput) -> cbor::Value { + cbor_bytes!(message.0) + } +} + +impl TryFrom<&cbor::Value> for GetAssertionHmacSecretOutput { + type Error = Ctap2StatusCode; + + fn try_from(cbor_value: &cbor::Value) -> Result<Self, Ctap2StatusCode> { + Ok(GetAssertionHmacSecretOutput(read_byte_string(cbor_value)?)) + } +} + // Even though options are optional, we can use the default if not present. #[cfg_attr(any(test, feature = "debug_ctap"), derive(Debug, PartialEq))] pub struct MakeCredentialOptions { @@ -314,6 +386,7 @@ pub struct PublicKeyCredentialSource { pub rp_id: String, pub user_handle: Vec<u8>, // not optional, but nullable pub other_ui: Option<String>, + pub cred_random: Option<Vec<u8>>, } impl From<PublicKeyCredentialSource> for cbor::Value { @@ -324,12 +397,17 @@ impl From<PublicKeyCredentialSource> for cbor::Value { None => cbor_null!(), Some(other_ui) => cbor_text!(other_ui), }; + let cred_random = match credential.cred_random { + None => cbor_null!(), + Some(cred_random) => cbor_bytes!(cred_random), + }; cbor_array! { credential.credential_id, private_key, credential.rp_id, credential.user_handle, other_ui, + cred_random, } } } @@ -341,7 +419,7 @@ impl TryFrom<cbor::Value> for PublicKeyCredentialSource { use cbor::{SimpleValue, Value}; let fields = read_array(&cbor_value)?; - if fields.len() != 5 { + if fields.len() != 6 { return Err(Ctap2StatusCode::CTAP2_ERR_INVALID_CBOR); } let credential_id = read_byte_string(&fields[0])?; @@ -357,6 +435,10 @@ impl TryFrom<cbor::Value> for PublicKeyCredentialSource { Value::Simple(SimpleValue::NullValue) => None, cbor_value => Some(read_text_string(cbor_value)?), }; + let cred_random = match &fields[5] { + Value::Simple(SimpleValue::NullValue) => None, + cbor_value => Some(read_byte_string(cbor_value)?), + }; Ok(PublicKeyCredentialSource { key_type: PublicKeyCredentialType::PublicKey, credential_id, @@ -364,6 +446,7 @@ impl TryFrom<cbor::Value> for PublicKeyCredentialSource { rp_id, user_handle, other_ui, + cred_random, }) } } @@ -993,6 +1076,7 @@ mod test { rp_id: "example.com".to_string(), user_handle: b"foo".to_vec(), other_ui: None, + cred_random: None, }; assert_eq!( @@ -1005,6 +1089,16 @@ mod test { ..credential }; + assert_eq!( + PublicKeyCredentialSource::try_from(cbor::Value::from(credential.clone())), + Ok(credential.clone()) + ); + + let credential = PublicKeyCredentialSource { + cred_random: Some([0x00; 32].to_vec()), + ..credential + }; + assert_eq!( PublicKeyCredentialSource::try_from(cbor::Value::from(credential.clone())), Ok(credential) diff --git a/src/ctap/mod.rs b/src/ctap/mod.rs index e444df8..1e23394 100644 --- a/src/ctap/mod.rs +++ b/src/ctap/mod.rs @@ -28,9 +28,9 @@ use self::command::{ AuthenticatorMakeCredentialParameters, Command, }; use self::data_formats::{ - ClientPinSubCommand, CoseKey, PackedAttestationStatement, PublicKeyCredentialDescriptor, - PublicKeyCredentialSource, PublicKeyCredentialType, PublicKeyCredentialUserEntity, - SignatureAlgorithm, + ClientPinSubCommand, CoseKey, GetAssertionHmacSecretInput, PackedAttestationStatement, + PublicKeyCredentialDescriptor, PublicKeyCredentialSource, PublicKeyCredentialType, + PublicKeyCredentialUserEntity, SignatureAlgorithm, }; use self::hid::ChannelID; use self::key_material::{AAGUID, ATTESTATION_CERTIFICATE, ATTESTATION_PRIVATE_KEY}; @@ -84,6 +84,7 @@ pub const ENCRYPTED_CREDENTIAL_ID_SIZE: usize = 112; const UP_FLAG: u8 = 0x01; const UV_FLAG: u8 = 0x04; const AT_FLAG: u8 = 0x40; +const ED_FLAG: u8 = 0x80; pub const TOUCH_TIMEOUT_MS: isize = 30000; #[cfg(feature = "with_ctap1")] @@ -105,6 +106,63 @@ fn check_pin_auth(hmac_key: &[u8], hmac_contents: &[u8], pin_auth: &[u8]) -> boo ) } +// Decrypts the HMAC secret salt(s) that were encrypted with the shared secret. +// The credRandom is used as a secret to HMAC those salts. +// The last step is to re-encrypt the outputs. +pub fn encrypt_hmac_secret_output( + shared_secret: &[u8; 32], + salt_enc: Vec<u8>, + cred_random: &[u8], +) -> Result<Vec<u8>, Ctap2StatusCode> { + if salt_enc.len() != 32 && salt_enc.len() != 64 { + return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION); + } + if cred_random.len() != 32 { + // We are strict here. We need at least 32 byte, but expect exactly 32. + return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION); + } + let aes_enc_key = crypto::aes256::EncryptionKey::new(shared_secret); + let aes_dec_key = crypto::aes256::DecryptionKey::new(&aes_enc_key); + // The specification specifically asks for a zero IV. + let iv = [0; 16]; + + let mut cred_random_secret = [0; 32]; + cred_random_secret.clone_from_slice(cred_random); + + // Initialization of 4 blocks in any case makes this function more readable. + let mut blocks = [[0u8; 16]; 4]; + let block_len = salt_enc.len() / 16; + for i in 0..block_len { + blocks[i].copy_from_slice(&salt_enc[16 * i..16 * (i + 1)]); + } + cbc_decrypt(&aes_dec_key, iv, &mut blocks[..block_len]); + + let mut decrypted_salt1 = [0; 32]; + decrypted_salt1[..16].clone_from_slice(&blocks[0]); + let output1 = hmac_256::<Sha256>(&cred_random_secret, &decrypted_salt1[..]); + decrypted_salt1[16..].clone_from_slice(&blocks[1]); + for i in 0..2 { + blocks[i].copy_from_slice(&output1[16 * i..16 * (i + 1)]); + } + + if block_len == 4 { + let mut decrypted_salt2 = [0; 32]; + decrypted_salt2[..16].clone_from_slice(&blocks[2]); + decrypted_salt2[16..].clone_from_slice(&blocks[3]); + let output2 = hmac_256::<Sha256>(&cred_random_secret, &decrypted_salt2[..]); + for i in 0..2 { + blocks[i + 2].copy_from_slice(&output2[16 * i..16 * (i + 1)]); + } + } + + cbc_encrypt(&aes_enc_key, iv, &mut blocks[..block_len]); + let mut encrypted_output = Vec::with_capacity(salt_enc.len()); + for b in &blocks[..block_len] { + encrypted_output.extend(b); + } + Ok(encrypted_output) +} + // This function is adapted from https://doc.rust-lang.org/nightly/src/core/str/mod.rs.html#2110 // (as of 2020-01-20) and truncates to "max" bytes, not breaking the encoding. // We change the return value, since we don't need the bool. @@ -261,6 +319,7 @@ where rp_id: String::from(""), user_handle: vec![], other_ui: None, + cred_random: None, }) } @@ -324,10 +383,10 @@ where user, pub_key_cred_params, exclude_list, + extensions, options, pin_uv_auth_param, pin_uv_auth_protocol, - .. } = make_credential_params; if let Some(auth_param) = &pin_uv_auth_param { @@ -362,6 +421,22 @@ where return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_ALGORITHM); } + let use_hmac_extension = if let Some(extensions) = extensions { + extensions.has_make_credential_hmac_secret()? + } else { + false + }; + if use_hmac_extension && !options.rk { + // The extension is actually supported, but we need resident keys. + return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION); + } + let cred_random = if use_hmac_extension { + Some(self.rng.gen_uniform_u8x32().to_vec()) + } else { + None + }; + let ed_flag = if use_hmac_extension { ED_FLAG } else { 0 }; + let rp_id = rp.rp_id; if let Some(exclude_list) = exclude_list { for cred_desc in exclude_list { @@ -389,7 +464,7 @@ where if !check_pin_auth(&self.pin_uv_auth_token, &client_data_hash, &pin_auth) { return Err(Ctap2StatusCode::CTAP2_ERR_PIN_AUTH_INVALID); } - UP_FLAG | UV_FLAG | AT_FLAG + UP_FLAG | UV_FLAG | AT_FLAG | ed_flag } None => { if self.persistent_store.pin_hash().is_some() { @@ -398,7 +473,7 @@ where if options.uv { return Err(Ctap2StatusCode::CTAP2_ERR_INVALID_OPTION); } - UP_FLAG | AT_FLAG + UP_FLAG | AT_FLAG | ed_flag } }; @@ -421,6 +496,7 @@ where other_ui: user .user_display_name .map(|s| truncate_to_char_boundary(&s, 64).to_string()), + cred_random, }; self.persistent_store.store_credential(credential_source)?; random_id @@ -441,6 +517,14 @@ where None => return Err(Ctap2StatusCode::CTAP2_ERR_VENDOR_RESPONSE_CANNOT_WRITE_CBOR), }; auth_data.extend(cose_key); + if use_hmac_extension { + let extensions = cbor_map! { + "hmac-secret" => true, + }; + if !cbor::write(extensions, &mut auth_data) { + return Err(Ctap2StatusCode::CTAP2_ERR_VENDOR_RESPONSE_CANNOT_WRITE_CBOR); + } + } let mut signature_data = auth_data.clone(); signature_data.extend(client_data_hash); @@ -481,10 +565,10 @@ where rp_id, client_data_hash, allow_list, + extensions, options, pin_uv_auth_param, pin_uv_auth_protocol, - .. } = get_assertion_params; if let Some(auth_param) = &pin_uv_auth_param { @@ -527,6 +611,16 @@ where } } + let get_assertion_hmac_secret_input = if let Some(extensions) = extensions { + extensions.get_assertion_hmac_secret().transpose()? + } else { + None + }; + if get_assertion_hmac_secret_input.is_some() && !options.up { + // The extension is actually supported, but we need user presence. + return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION); + } + // The user verification bit depends on the existance of PIN auth, whereas // user presence is requested as an option. let mut flags = match pin_uv_auth_param { @@ -551,6 +645,9 @@ where if options.up { flags |= UP_FLAG; } + if get_assertion_hmac_secret_input.is_some() { + flags |= ED_FLAG; + } let rp_id_hash = Sha256::hash(rp_id.as_bytes()); let mut decrypted_credential = None; @@ -590,7 +687,37 @@ where self.increment_global_signature_counter(); - let auth_data = self.generate_auth_data(&rp_id_hash, flags); + let mut auth_data = self.generate_auth_data(&rp_id_hash, flags); + // Process extensions. + if let Some(get_assertion_hmac_secret_input) = get_assertion_hmac_secret_input { + let GetAssertionHmacSecretInput { + key_agreement, + salt_enc, + salt_auth, + } = get_assertion_hmac_secret_input; + let pk: crypto::ecdh::PubKey = CoseKey::try_into(key_agreement)?; + let shared_secret = self.key_agreement_key.exchange_x_sha256(&pk); + // HMAC-secret does the same 16 byte truncated check. + if !check_pin_auth(&shared_secret, &salt_enc, &salt_auth) { + // Again, hard to tell what the correct error code here is. + return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION); + } + + let encrypted_output = if let Some(cred_random) = &credential.cred_random { + encrypt_hmac_secret_output(&shared_secret, salt_enc, cred_random)? + } else { + // This happens because the credential was not created with HMAC-secret. + return Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION); + }; + + let extensions = cbor_map! { + "hmac-secret" => encrypted_output, + }; + if !cbor::write(extensions, &mut auth_data) { + return Err(Ctap2StatusCode::CTAP2_ERR_VENDOR_RESPONSE_CANNOT_WRITE_CBOR); + } + } + let mut signature_data = auth_data.clone(); signature_data.extend(client_data_hash); let signature = credential @@ -639,7 +766,7 @@ where String::from(U2F_VERSION_STRING), String::from(FIDO2_VERSION_STRING), ], - extensions: Some(vec![]), + extensions: Some(vec![String::from("hmac-secret")]), aaguid: *AAGUID, options: Some(options_map), max_msg_size: Some(1024), @@ -948,7 +1075,7 @@ where #[cfg(test)] mod test { use super::data_formats::{ - GetAssertionOptions, MakeCredentialOptions, PublicKeyCredentialRpEntity, + Extensions, GetAssertionOptions, MakeCredentialOptions, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, }; use super::*; @@ -970,13 +1097,15 @@ mod test { let mut expected_response = vec![0x00, 0xA6, 0x01]; // The difference here is a longer array of supported versions. #[cfg(not(feature = "with_ctap1"))] - expected_response.extend(&[ - 0x81, 0x68, 0x46, 0x49, 0x44, 0x4F, 0x5F, 0x32, 0x5F, 0x30, 0x02, 0x80, 0x03, 0x50, - ]); + expected_response.extend(&[0x81, 0x68, 0x46, 0x49, 0x44, 0x4F, 0x5F, 0x32, 0x5F, 0x30]); #[cfg(feature = "with_ctap1")] expected_response.extend(&[ 0x82, 0x66, 0x55, 0x32, 0x46, 0x5F, 0x56, 0x32, 0x68, 0x46, 0x49, 0x44, 0x4F, 0x5F, - 0x32, 0x5F, 0x30, 0x02, 0x80, 0x03, 0x50, + 0x32, 0x5F, 0x30, + ]); + expected_response.extend(&[ + 0x02, 0x81, 0x6B, 0x68, 0x6D, 0x61, 0x63, 0x2D, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, + 0x03, 0x50, ]); expected_response.extend(AAGUID); expected_response.extend(&[ @@ -1130,6 +1259,7 @@ mod test { rp_id: String::from("example.com"), user_handle: vec![], other_ui: None, + cred_random: None, }; assert!(ctap_state .persistent_store @@ -1153,6 +1283,54 @@ mod test { ); } + #[test] + fn test_process_make_credential_hmac_secret() { + let mut rng = ThreadRng256 {}; + let user_immediately_present = |_| Ok(()); + let mut ctap_state = CtapState::new(&mut rng, user_immediately_present); + + let mut extension_map = BTreeMap::new(); + extension_map.insert("hmac-secret".to_string(), cbor_bool!(true)); + let extensions = Some(Extensions::new(extension_map)); + let mut make_credential_params = create_minimal_make_credential_parameters(); + make_credential_params.extensions = extensions; + let make_credential_response = + ctap_state.process_make_credential(make_credential_params, DUMMY_CHANNEL_ID); + + match make_credential_response.unwrap() { + ResponseData::AuthenticatorMakeCredential(make_credential_response) => { + let AuthenticatorMakeCredentialResponse { + fmt, + auth_data, + att_stmt, + } = make_credential_response; + // The expected response is split to only assert the non-random parts. + assert_eq!(fmt, "packed"); + let mut expected_auth_data = vec![ + 0xA3, 0x79, 0xA6, 0xF6, 0xEE, 0xAF, 0xB9, 0xA5, 0x5E, 0x37, 0x8C, 0x11, 0x80, + 0x34, 0xE2, 0x75, 0x1E, 0x68, 0x2F, 0xAB, 0x9F, 0x2D, 0x30, 0xAB, 0x13, 0xD2, + 0x12, 0x55, 0x86, 0xCE, 0x19, 0x47, 0xC1, 0x00, 0x00, 0x00, 0x00, + ]; + expected_auth_data.extend(AAGUID); + expected_auth_data.extend(&[0x00, 0x20]); + assert_eq!( + auth_data[0..expected_auth_data.len()], + expected_auth_data[..] + ); + let expected_extension_cbor = vec![ + 0xA1, 0x6B, 0x68, 0x6D, 0x61, 0x63, 0x2D, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, + 0xF5, + ]; + assert_eq!( + auth_data[auth_data.len() - expected_extension_cbor.len()..auth_data.len()], + expected_extension_cbor[..] + ); + assert_eq!(att_stmt.alg, SignatureAlgorithm::ES256 as i64); + } + _ => panic!("Invalid response type"), + } + } + #[test] fn test_process_make_credential_cancelled() { let mut rng = ThreadRng256 {}; @@ -1216,6 +1394,53 @@ mod test { } } + #[test] + fn test_residential_process_get_assertion_hmac_secret() { + let mut rng = ThreadRng256 {}; + let sk = crypto::ecdh::SecKey::gensk(&mut rng); + let user_immediately_present = |_| Ok(()); + let mut ctap_state = CtapState::new(&mut rng, user_immediately_present); + + let mut extension_map = BTreeMap::new(); + extension_map.insert("hmac-secret".to_string(), cbor_bool!(true)); + let make_extensions = Some(Extensions::new(extension_map)); + let mut make_credential_params = create_minimal_make_credential_parameters(); + make_credential_params.extensions = make_extensions; + assert!(ctap_state + .process_make_credential(make_credential_params, DUMMY_CHANNEL_ID) + .is_ok()); + + let pk = sk.genpk(); + let hmac_secret_parameters = cbor_map! { + 1 => cbor::Value::Map(CoseKey::from(pk).0), + 2 => vec![0; 32], + 3 => vec![0; 16], + }; + let mut extension_map = BTreeMap::new(); + extension_map.insert("hmac-secret".to_string(), hmac_secret_parameters); + + let get_extensions = Some(Extensions::new(extension_map)); + let get_assertion_params = AuthenticatorGetAssertionParameters { + rp_id: String::from("example.com"), + client_data_hash: vec![0xCD], + allow_list: None, + extensions: get_extensions, + options: GetAssertionOptions { + up: false, + uv: false, + }, + pin_uv_auth_param: None, + pin_uv_auth_protocol: None, + }; + let get_assertion_response = + ctap_state.process_get_assertion(get_assertion_params, DUMMY_CHANNEL_ID); + + assert_eq!( + get_assertion_response, + Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION) + ); + } + #[test] fn test_process_reset() { let mut rng = ThreadRng256 {}; @@ -1231,6 +1456,7 @@ mod test { rp_id: String::from("example.com"), user_handle: vec![], other_ui: None, + cred_random: None, }; assert!(ctap_state .persistent_store @@ -1294,4 +1520,32 @@ mod test { .is_none()); } } + + #[test] + fn test_encrypt_hmac_secret_output() { + let shared_secret = [0x55; 32]; + let salt_enc = vec![0x5E; 32]; + let cred_random = vec![0xC9; 32]; + let output = encrypt_hmac_secret_output(&shared_secret, salt_enc, &cred_random); + assert_eq!(output.unwrap().len(), 32); + + let salt_enc = vec![0x5E; 48]; + let output = encrypt_hmac_secret_output(&shared_secret, salt_enc, &cred_random); + assert_eq!( + output, + Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION) + ); + + let salt_enc = vec![0x5E; 64]; + let output = encrypt_hmac_secret_output(&shared_secret, salt_enc, &cred_random); + assert_eq!(output.unwrap().len(), 64); + + let salt_enc = vec![0x5E; 32]; + let cred_random = vec![0xC9; 33]; + let output = encrypt_hmac_secret_output(&shared_secret, salt_enc, &cred_random); + assert_eq!( + output, + Err(Ctap2StatusCode::CTAP2_ERR_UNSUPPORTED_EXTENSION) + ); + } } diff --git a/src/ctap/storage.rs b/src/ctap/storage.rs index 7569c1b..8f2c2b5 100644 --- a/src/ctap/storage.rs +++ b/src/ctap/storage.rs @@ -445,6 +445,7 @@ mod test { rp_id: String::from(rp_id), user_handle, other_ui: None, + cred_random: None, } } @@ -613,6 +614,7 @@ mod test { rp_id: String::from("example.com"), user_handle: vec![0x00], other_ui: None, + cred_random: None, }; assert_eq!(found_credential, Some(expected_credential)); } -- GitLab