diff --git a/CHANGELOG.md b/CHANGELOG.md index aab39ee..5b5d255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set `algorithms`, `firmware_version` and `remaining_discoverable_credentials` in `get_info` and add `firmware_version` to `Config`. - Implement these new extensions: - `credBlob` + - `hmac-secret-mc` - `minPinLength` - Implement the `alwaysUv` feature. - Implement the `config` command with these subcommands: diff --git a/src/ctap2.rs b/src/ctap2.rs index c366d9b..018fd1d 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -6,6 +6,7 @@ use ctap_types::{ self, client_pin::Permissions, config::{MAX_MIN_PIN_LENGTH_RP_IDS, MAX_RP_ID_LENGTH}, + get_assertion::HmacSecretInput, AttestationFormatsPreference, AttestationStatement, AttestationStatementFormat, Authenticator, NoneAttestationStatement, PackedAttestationStatement, VendorOperation, }, @@ -62,11 +63,16 @@ impl Authenticator for crate::Authenti } versions.push(Version::Fido2_0).unwrap(); versions.push(Version::Fido2_1).unwrap(); + // CTAP 2.3 §6.4: "The string 'FIDO_2_2' was not defined for CTAP2.2 + // and MUST not be present in versions member." CTAP 2.2 was an + // addendum; 2.2-level features (e.g. hmac-secret-mc) are still + // discoverable via the extensions list. let mut extensions = Vec::new(); extensions.push(Extension::CredProtect).unwrap(); extensions.push(Extension::CredBlob).unwrap(); extensions.push(Extension::HmacSecret).unwrap(); + extensions.push(Extension::HmacSecretMc).unwrap(); if self.config.supports_large_blobs() { extensions.push(Extension::LargeBlobKey).unwrap(); } @@ -339,6 +345,17 @@ impl Authenticator for crate::Authenti } } + let hmac_secret_mc_input = parameters + .extensions + .as_ref() + .and_then(|ext| ext.hmac_secret_mc.as_ref()); + + // CTAP 2.2 §11.4.5: hmac-secret-mc requires hmac-secret=true on the + // same request (it evaluates hmac-secret at MakeCredential time). + if hmac_secret_mc_input.is_some() && hmac_secret_requested != Some(true) { + return Err(Error::MissingParameter); + } + // debug_now!("hmac-secret = {:?}, credProtect = {:?}", hmac_secret_requested, cred_protect_requested); // 10. get UP, if denied error OperationDenied @@ -353,6 +370,19 @@ impl Authenticator for crate::Authenti let private_key = algorithm.generate_private_key(&mut self.trussed, location); let cose_public_key = algorithm.derive_public_key(&mut self.trussed, private_key); + // 11.b CTAP 2.2 hmac-secret-mc: evaluate hmac-secret at MakeCredential + // time so the platform can capture salts atomically with credential + // creation. Same wire format as GA's hmac-secret output. + let hmac_secret_mc_output: Option> = if let Some(hmac_secret) = + hmac_secret_mc_input.as_ref() + { + let output = + self.process_hmac_secret_extension(false, hmac_secret, private_key, uv_performed)?; + Some(output) + } else { + None + }; + // 12. if `rk` is set, store or overwrite key pair, if full error KeyStoreFull // 12.a generate credential @@ -473,6 +503,7 @@ impl Authenticator for crate::Authenti || cred_protect_requested.is_some() || cred_blob_requested || min_pin_length_to_emit.is_some() + || hmac_secret_mc_output.is_some() { flags |= Flags::EXTENSION_DATA; } @@ -497,6 +528,7 @@ impl Authenticator for crate::Authenti || cred_protect_requested.is_some() || cred_blob_requested || min_pin_length_to_emit.is_some() + || hmac_secret_mc_output.is_some() { let mut extensions = ctap2::make_credential::ExtensionsOutput::default(); extensions.cred_protect = parameters.extensions.as_ref().unwrap().cred_protect; @@ -508,6 +540,9 @@ impl Authenticator for crate::Authenti extensions.cred_blob = Some(cred_blob_to_store.is_some()); } extensions.min_pin_length = min_pin_length_to_emit; + if let Some(out) = hmac_secret_mc_output { + extensions.hmac_secret_mc = Some(out); + } Some(extensions) } else { None @@ -1199,12 +1234,8 @@ impl Authenticator for crate::Authenti // Note: If allowList is passed, credential is Some(credential) // If no allowList is passed, credential is None and the retrieved credentials // are stored in state.runtime.credential_heap - let (credential, num_credentials) = self - .prepare_credentials(&rp_id_hash, ¶meters.allow_list, uv_performed)? - .ok_or(Error::NoCredentials)?; - - info_now!("found {:?} applicable credentials", num_credentials); - info_now!("{:?}", &credential); + let prepared = + self.prepare_credentials(&rp_id_hash, ¶meters.allow_list, uv_performed)?; // 6. process any options present @@ -1225,7 +1256,9 @@ impl Authenticator for crate::Authenti true }; - // 7. collect user presence + // 7. collect user presence — MUST happen before returning + // NoCredentials per CTAP 2.0 §5.2 step 2 (privacy: don't reveal + // credential existence without UP). let up_performed = if do_up { if !self.skip_up_check() { info_now!("asking for up"); @@ -1238,6 +1271,12 @@ impl Authenticator for crate::Authenti false }; + // 8. Now safe to bail with NoCredentials (UP collected). + let (credential, num_credentials) = prepared.ok_or(Error::NoCredentials)?; + + info_now!("found {:?} applicable credentials", num_credentials); + info_now!("{:?}", &credential); + let multiple_credentials = num_credentials > 1; self.state.runtime.active_get_assertion = Some(state::ActiveGetAssertionData { rp_id_hash: { @@ -1883,70 +1922,13 @@ impl crate::Authenticator { let mut output = ctap2::get_assertion::ExtensionsOutput::default(); if let Some(hmac_secret) = &extensions.hmac_secret { - let pin_protocol = hmac_secret - .pin_protocol - .map(|i| self.parse_pin_protocol(i)) - .transpose()? - .unwrap_or(PinProtocolVersion::V1); - - if !get_assertion_state.up_performed { - return Err(Error::UnsupportedOption); - } - - // We derive credRandom as an hmac of the existing private key. - // UV is used as input data since credRandom should depend UV - // i.e. credRandom = HMAC(private_key, uv) - let cred_random = syscall!(self.trussed.derive_key( - Mechanism::HmacSha256, + let hmac_secret_output = self.process_hmac_secret_extension( + !get_assertion_state.up_performed, + hmac_secret, credential_key, - Some(Bytes::from(&[get_assertion_state.uv_performed as u8])), - StorageAttributes::new().set_persistence(Location::Volatile) - )) - .key; - - // Verify the auth tag, which uses the same process as the pinAuth - let mut pin_protocol = self.pin_protocol(pin_protocol); - let shared_secret = pin_protocol.shared_secret(&hmac_secret.key_agreement)?; - pin_protocol.verify_pin_auth( - &shared_secret, - &hmac_secret.salt_enc, - &hmac_secret.salt_auth, + get_assertion_state.uv_performed, )?; - - // decrypt input salt_enc to get salt1 or (salt1 || salt2) - let salts = shared_secret - .decrypt(&mut self.trussed, &hmac_secret.salt_enc) - .ok_or(Error::InvalidOption)?; - - if salts.len() != 32 && salts.len() != 64 { - debug_now!("invalid hmac-secret length"); - return Err(Error::InvalidLength); - } - - let mut salt_output: Bytes<64> = Bytes::new(); - - // output1 = hmac_sha256(credRandom, salt1) - let output1 = - syscall!(self.trussed.sign_hmacsha256(cred_random, &salts[0..32])).signature; - - salt_output.extend_from_slice(&output1).unwrap(); - - if salts.len() == 64 { - // output2 = hmac_sha256(credRandom, salt2) - let output2 = - syscall!(self.trussed.sign_hmacsha256(cred_random, &salts[32..64])).signature; - - salt_output.extend_from_slice(&output2).unwrap(); - } - - syscall!(self.trussed.delete(cred_random)); - - // output_enc = aes256-cbc(sharedSecret, IV=0, output1 || output2) - let output_enc = shared_secret.encrypt(&mut self.trussed, &salt_output); - - shared_secret.delete(&mut self.trussed); - - output.hmac_secret = Some(Bytes::try_from(&*output_enc).unwrap()); + output.hmac_secret = Some(hmac_secret_output); } if extensions.third_party_payment.unwrap_or_default() { @@ -1962,6 +1944,88 @@ impl crate::Authenticator { Ok(output.is_set().then_some(output)) } + #[inline(never)] + fn process_hmac_secret_extension( + &mut self, + return_unsupported_option: bool, + hmac_secret: &HmacSecretInput, + private_key: KeyId, + uv_performed: bool, + ) -> Result> { + let pin_protocol = hmac_secret + .pin_protocol + .map(|i| self.parse_pin_protocol(i)) + .transpose()? + .unwrap_or(PinProtocolVersion::V1); + + if return_unsupported_option { + return Err(Error::UnsupportedOption); + } + + // We derive credRandom as an hmac of the existing private key. + // UV is used as input data since credRandom should depend UV + // i.e. credRandom = HMAC(private_key, uv) + let cred_random = syscall!(self.trussed.derive_key( + Mechanism::HmacSha256, + private_key, + Some(Bytes::from(&[uv_performed as u8])), + StorageAttributes::new().set_persistence(Location::Volatile), + )) + .key; + + // Every error path below must delete cred_random and (once + // allocated) shared_secret before returning, else volatile FS + // entries leak and starve the next shared_secret_impl call. + let mut pin_protocol = self.pin_protocol(pin_protocol); + let shared_secret = match pin_protocol.shared_secret(&hmac_secret.key_agreement) { + Ok(s) => s, + Err(e) => { + syscall!(self.trussed.delete(cred_random)); + return Err(e); + } + }; + if let Err(e) = pin_protocol.verify_pin_auth( + &shared_secret, + &hmac_secret.salt_enc, + &hmac_secret.salt_auth, + ) { + shared_secret.delete(&mut self.trussed); + syscall!(self.trussed.delete(cred_random)); + return Err(e); + } + + let salts = match shared_secret.decrypt(&mut self.trussed, &hmac_secret.salt_enc) { + Some(s) => s, + None => { + shared_secret.delete(&mut self.trussed); + syscall!(self.trussed.delete(cred_random)); + return Err(Error::InvalidOption); + } + }; + if salts.len() != 32 && salts.len() != 64 { + debug_now!("invalid hmac-secret salt length"); + shared_secret.delete(&mut self.trussed); + syscall!(self.trussed.delete(cred_random)); + return Err(Error::InvalidLength); + } + + let mut salt_output: Bytes<64> = Bytes::new(); + let output1 = syscall!(self.trussed.sign_hmacsha256(cred_random, &salts[0..32])).signature; + salt_output.extend_from_slice(&output1).unwrap(); + if salts.len() == 64 { + let output2 = + syscall!(self.trussed.sign_hmacsha256(cred_random, &salts[32..64])).signature; + salt_output.extend_from_slice(&output2).unwrap(); + } + + syscall!(self.trussed.delete(cred_random)); + + let output_enc = shared_secret.encrypt(&mut self.trussed, &salt_output); + shared_secret.delete(&mut self.trussed); + + Bytes::try_from(&*output_enc).map_err(|_| Error::Other) + } + #[inline(never)] fn assert_with_credential( &mut self, diff --git a/tests/basic.rs b/tests/basic.rs index c7cb266..eb8eee8 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -749,6 +749,7 @@ impl From for MakeCredentialExtensionsI fn from(input: ExhaustiveMakeCredentialExtensionsInput) -> Self { Self { hmac_secret: input.hmac_secret, + hmac_secret_mc: None, third_party_payment: input.third_party_payment, cred_blob: if input.cred_blob { let mut v = vec![0x00; 32]; @@ -2466,3 +2467,205 @@ fn test_get_assertion_with_allow_list_non_rk_no_user_field() { ); }) } + +// ---------------------------------------------------------------------------- +// hmac-secret-mc extension (CTAP 2.2 §11.4.5) +// ---------------------------------------------------------------------------- + +/// GetInfo advertises the `hmac-secret-mc` extension and the device does NOT +/// advertise the legacy `FIDO_2_2` version string (CTAP 2.3 §6.4: "The +/// string 'FIDO_2_2' was not defined for CTAP2.2 and MUST not be present in +/// versions member"). +#[test] +fn test_hmac_secret_mc_advertised_in_get_info() { + virt::run_ctap2(|device| { + let reply = device.exec(GetInfo).unwrap(); + // CTAP 2.3 §6.4: `FIDO_2_2` is NOT a valid version string. + assert!(!reply.versions.contains(&"FIDO_2_2".to_owned())); + let extensions = reply.extensions.expect("extensions list missing"); + assert!( + extensions.contains(&"hmac-secret-mc".to_owned()), + "hmac-secret-mc not advertised: {:?}", + extensions + ); + }) +} + +/// MakeCredential with `hmac-secret-mc` returns an output blob that decrypts +/// to either a 32-byte HMAC output (one salt) or 64-byte (two salts). The +/// authenticator data's ED flag MUST be set. +#[test] +fn test_make_credential_with_hmac_secret_mc_returns_output() { + let key_agreement_key = KeyAgreementKey::generate(); + let rp_id = "example.com"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + + // Single-salt input (32 bytes → expected 32-byte HMAC output). + let mut salt = [0xffu8; 32]; + rand::thread_rng().fill_bytes(&mut salt[..31]); + let salt_enc = shared_secret.encrypt(&salt); + let salt_auth = shared_secret.authenticate(&salt_enc); + + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash, + Rp::new(rp_id), + User::new(vec![1; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + mc.extensions = Some(MakeCredentialExtensionsInput { + hmac_secret: Some(true), + hmac_secret_mc: Some(HmacSecretInput { + key_agreement: key_agreement_key.public_key(), + salt_enc, + salt_auth, + pin_protocol: Some(2), + }), + ..Default::default() + }); + let reply = device.exec(mc).unwrap(); + + // ED flag must be set when extensions are returned. + assert!(reply.auth_data.ed_flag(), "ED flag missing"); + + let extensions = reply.auth_data.extensions.expect("extensions missing"); + let raw = extensions + .get("hmac-secret-mc") + .expect("hmac-secret-mc absent from extensions") + .as_bytes() + .unwrap(); + let output = shared_secret.decrypt(raw); + assert_eq!(output.len(), 32, "single-salt output must be 32 bytes"); + }) +} + +/// Two-salt hmac-secret-mc input (64 bytes encrypted) yields a 64-byte +/// output (two concatenated HMAC values). +#[test] +fn test_make_credential_with_hmac_secret_mc_two_salts() { + let key_agreement_key = KeyAgreementKey::generate(); + let rp_id = "example.com"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + + let mut salts = [0xffu8; 64]; + rand::thread_rng().fill_bytes(&mut salts[..63]); + let salt_enc = shared_secret.encrypt(&salts); + let salt_auth = shared_secret.authenticate(&salt_enc); + + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash, + Rp::new(rp_id), + User::new(vec![2; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + mc.extensions = Some(MakeCredentialExtensionsInput { + hmac_secret: Some(true), + hmac_secret_mc: Some(HmacSecretInput { + key_agreement: key_agreement_key.public_key(), + salt_enc, + salt_auth, + pin_protocol: Some(2), + }), + ..Default::default() + }); + let reply = device.exec(mc).unwrap(); + let extensions = reply.auth_data.extensions.expect("extensions missing"); + let raw = extensions + .get("hmac-secret-mc") + .unwrap() + .as_bytes() + .unwrap(); + let output = shared_secret.decrypt(raw); + assert_eq!(output.len(), 64, "two-salt output must be 64 bytes"); + }) +} + +/// hmac-secret-mc with a forged `salt_auth` MUST be rejected +/// (CTAP 2.1 / 2.2 §6.5.5.7 `verify_pin_auth`). +#[test] +fn test_make_credential_hmac_secret_mc_bad_auth_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let rp_id = "example.com"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + + let mut salt = [0xffu8; 32]; + rand::thread_rng().fill_bytes(&mut salt[..31]); + let salt_enc = shared_secret.encrypt(&salt); + // Forge the auth tag (all zeros — should not match HMAC output). + let salt_auth = [0u8; 32]; + + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash, + Rp::new(rp_id), + User::new(vec![3; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + mc.extensions = Some(MakeCredentialExtensionsInput { + hmac_secret: Some(true), + hmac_secret_mc: Some(HmacSecretInput { + key_agreement: key_agreement_key.public_key(), + salt_enc, + salt_auth, + pin_protocol: Some(2), + }), + ..Default::default() + }); + let result = device.exec(mc); + // PinAuthInvalid (0x33) — `verify_pin_auth` returns it on HMAC + // mismatch regardless of which input triggered the path. + assert_eq!(result.err(), Some(Ctap2Error(0x33))); + }) +} + +/// CTAP 2.2 §11.4.5 hmac-secret-mc: the decrypted `saltEnc` MUST be either +/// 32 bytes (one salt) or 64 bytes (two salts). Any other length is a +/// protocol violation; the authenticator returns CTAP1_ERR_INVALID_LENGTH +/// (0x03). We test with a 48-byte salt (still passes the AES-CBC block +/// constraint since 48 is a multiple of 16, but is not 32 or 64). +#[test] +fn test_make_credential_hmac_secret_mc_invalid_salt_length_rejected() { + let key_agreement_key = KeyAgreementKey::generate(); + let rp_id = "example.com"; + virt::run_ctap2(|device| { + let shared_secret = get_shared_secret(&device, &key_agreement_key); + + // 48-byte plaintext salt → 48-byte ciphertext (after AES-CBC, plus + // 16-byte IV inside `encrypt` ↦ 64-byte salt_enc on the wire). The + // device decrypts the IV+ciphertext, ends up with 48 bytes of + // plaintext, and must reject it. + let mut salt = [0xffu8; 48]; + rand::thread_rng().fill_bytes(&mut salt[..47]); + let salt_enc = shared_secret.encrypt(&salt); + let salt_auth = shared_secret.authenticate(&salt_enc); + + let client_data_hash = vec![0u8; 32]; + let mut mc = MakeCredential::new( + client_data_hash, + Rp::new(rp_id), + User::new(vec![4; 16]), + vec![PubKeyCredParam::new("public-key", -7)], + ); + mc.options = Some(MakeCredentialOptions::default().rk(true)); + mc.extensions = Some(MakeCredentialExtensionsInput { + hmac_secret: Some(true), + hmac_secret_mc: Some(HmacSecretInput { + key_agreement: key_agreement_key.public_key(), + salt_enc, + salt_auth, + pin_protocol: Some(2), + }), + ..Default::default() + }); + let result = device.exec(mc); + // CTAP1_ERR_INVALID_LENGTH = 0x03. + assert_eq!(result.err(), Some(Ctap2Error(0x03))); + }) +} diff --git a/tests/webauthn/mod.rs b/tests/webauthn/mod.rs index f90e8e4..635a12d 100644 --- a/tests/webauthn/mod.rs +++ b/tests/webauthn/mod.rs @@ -469,6 +469,11 @@ impl From for Value { #[derive(Clone, Debug, Default)] pub struct MakeCredentialExtensionsInput { pub hmac_secret: Option, + /// CTAP 2.2 §11.4.5: `hmac-secret-mc` allows the platform to evaluate + /// hmac-secret at MakeCredential time. Same payload shape as the + /// GetAssertion hmac-secret input (key_agreement + salt_enc + salt_auth + /// + pin_protocol). + pub hmac_secret_mc: Option, pub third_party_payment: Option, pub cred_blob: Option>, pub min_pin_length: Option, @@ -498,6 +503,9 @@ impl From for Value { if let Some(min_pin_length) = extensions.min_pin_length { map.push("minPinLength", min_pin_length); } + if let Some(hmac_secret_mc) = extensions.hmac_secret_mc { + map.push("hmac-secret-mc", hmac_secret_mc); + } if let Some(third_party_payment) = extensions.third_party_payment { map.push("thirdPartyPayment", third_party_payment); } @@ -965,6 +973,7 @@ impl Request for GetInfo { pub struct GetInfoReply { pub versions: Vec, + pub extensions: Option>, pub aaguid: Value, pub options: Option>, pub pin_protocols: Option>, @@ -978,6 +987,8 @@ impl From for GetInfoReply { let mut map: BTreeMap = value.deserialized().unwrap(); Self { versions: map.remove(&1).unwrap().deserialized().unwrap(), + // 0x02: extensions (CTAP 2.0+) + extensions: map.remove(&2).map(|value| value.deserialized().unwrap()), aaguid: map.remove(&3).unwrap().deserialized().unwrap(), options: map.remove(&4).map(|value| value.deserialized().unwrap()), pin_protocols: map.remove(&6).map(|value| value.deserialized().unwrap()),