diff --git a/libwebauthn/src/management/credential_management.rs b/libwebauthn/src/management/credential_management.rs index 43b881d4..5fab620a 100644 --- a/libwebauthn/src/management/credential_management.rs +++ b/libwebauthn/src/management/credential_management.rs @@ -111,6 +111,7 @@ where req ) }?; + self.set_cred_mgmt_preview(req.use_legacy_preview); Ok(( Ctap2RPData::new( unwrap_field!(resp.rp), @@ -122,24 +123,8 @@ where async fn enumerate_rps_next_rp(&mut self, timeout: Duration) -> Result { let mut req = Ctap2CredentialManagementRequest::new_enumerate_rps_next_rp(); - let resp = loop { - let uv_auth_used = user_verification( - self, - UserVerificationRequirement::Preferred, - &mut req, - timeout, - ) - .await?; - - // On success, this is an all-empty Ctap2AuthenticatorConfigResponse - handle_errors!( - self, - self.ctap2_credential_management(&req, timeout).await, - uv_auth_used, - timeout, - req - ) - }?; + req.use_legacy_preview = self.cred_mgmt_preview(); + let resp = self.ctap2_credential_management(&req, timeout).await?; Ok(Ctap2RPData::new( unwrap_field!(resp.rp), unwrap_field!(resp.rp_id_hash).into_vec(), @@ -170,6 +155,7 @@ where req ) }?; + self.set_cred_mgmt_preview(req.use_legacy_preview); let cred = Ctap2CredentialData::new( unwrap_field!(resp.user), unwrap_field!(resp.credential_id), @@ -186,24 +172,8 @@ where timeout: Duration, ) -> Result { let mut req = Ctap2CredentialManagementRequest::new_enumerate_credentials_next(); - let resp = loop { - let uv_auth_used = user_verification( - self, - UserVerificationRequirement::Preferred, - &mut req, - timeout, - ) - .await?; - - // On success, this is an all-empty Ctap2AuthenticatorConfigResponse - handle_errors!( - self, - self.ctap2_credential_management(&req, timeout).await, - uv_auth_used, - timeout, - req - ) - }?; + req.use_legacy_preview = self.cred_mgmt_preview(); + let resp = self.ctap2_credential_management(&req, timeout).await?; let cred = Ctap2CredentialData::new( unwrap_field!(resp.user), unwrap_field!(resp.credential_id), @@ -367,9 +337,11 @@ mod test { use crate::proto::ctap2::cbor::{self, CborRequest, CborResponse}; use crate::proto::ctap2::{ Ctap2CommandCode, Ctap2CredentialManagementRequest, Ctap2GetInfoResponse, - Ctap2PublicKeyCredentialRpEntity, + Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, + Ctap2PublicKeyCredentialType, Ctap2PublicKeyCredentialUserEntity, }; use crate::transport::mock::channel::MockChannel; + use std::collections::{BTreeMap, HashMap}; const TIMEOUT: Duration = Duration::from_secs(1); @@ -413,4 +385,135 @@ mod test { assert_eq!(rp_data.rp_id_hash.len(), 32); assert_eq!(rp_data.rp_id_hash, hash.to_vec()); } + + // GetNextRP returns only rp (0x03) and rpIDHash (0x04). + #[derive(SerializeIndexed)] + struct EnumerateRpsNextResponse { + #[serde(index = 0x03)] + rp: Ctap2PublicKeyCredentialRpEntity, + #[serde(index = 0x04)] + rp_id_hash: ByteBuf, + } + + // GetNextCredential returns user (0x06), credentialID (0x07), publicKey (0x08), credProtect (0x0A). + #[derive(SerializeIndexed)] + struct EnumerateCredsNextResponse { + #[serde(index = 0x06)] + user: Ctap2PublicKeyCredentialUserEntity, + #[serde(index = 0x07)] + credential_id: Ctap2PublicKeyCredentialDescriptor, + #[serde(index = 0x08)] + public_key: BTreeMap, + #[serde(index = 0x0A)] + cred_protect: u64, + } + + #[tokio::test] + async fn enumerate_rps_next_rp_sends_only_the_subcommand() { + let req = Ctap2CredentialManagementRequest::new_enumerate_rps_next_rp(); + let cbor_req: CborRequest = (&req).try_into().unwrap(); + assert_eq!( + cbor_req.command, + Ctap2CommandCode::AuthenticatorCredentialManagement + ); + // CTAP 2.1 §6.8.3: {subCommand: enumerateRPsGetNextRP}, no pinUvAuth keys. + assert_eq!(cbor_req.encoded_data, vec![0xA1, 0x01, 0x03]); + + let hash = [0xCD_u8; 32]; + let fixture = EnumerateRpsNextResponse { + rp: Ctap2PublicKeyCredentialRpEntity::new("example.org", "Example"), + rp_id_hash: ByteBuf::from(hash.to_vec()), + }; + let resp = CborResponse::new_success_from_slice(&cbor::to_vec(&fixture).unwrap()); + // Queue only the GetNext exchange: any interleaved command panics the mock. + let mut channel = MockChannel::new(); + channel.push_command_pair(cbor_req, resp); + + let rp_data = channel.enumerate_rps_next_rp(TIMEOUT).await.unwrap(); + assert_eq!(rp_data.rp_id_hash, hash.to_vec()); + } + + #[tokio::test] + async fn enumerate_credentials_next_sends_only_the_subcommand() { + let req = Ctap2CredentialManagementRequest::new_enumerate_credentials_next(); + let cbor_req: CborRequest = (&req).try_into().unwrap(); + assert_eq!( + cbor_req.command, + Ctap2CommandCode::AuthenticatorCredentialManagement + ); + // CTAP 2.1 §6.8.4: {subCommand: enumerateCredentialsGetNextCredential}, no pinUvAuth keys. + assert_eq!(cbor_req.encoded_data, vec![0xA1, 0x01, 0x05]); + + let fixture = EnumerateCredsNextResponse { + user: Ctap2PublicKeyCredentialUserEntity::new(&[0x0B; 16], "bob", "bob"), + credential_id: Ctap2PublicKeyCredentialDescriptor { + id: ByteBuf::from(vec![0x1D; 32]), + r#type: Ctap2PublicKeyCredentialType::PublicKey, + transports: None, + }, + public_key: BTreeMap::from([(1, 2), (3, -7)]), + cred_protect: 1, + }; + let resp = CborResponse::new_success_from_slice(&cbor::to_vec(&fixture).unwrap()); + let mut channel = MockChannel::new(); + channel.push_command_pair(cbor_req, resp); + + let cred = channel.enumerate_credentials_next(TIMEOUT).await.unwrap(); + assert_eq!(cred.user.id, ByteBuf::from(vec![0x0B; 16])); + assert_eq!(cred.cred_protect, 1); + assert!(cred.large_blob_key.is_none()); + } + + #[tokio::test] + async fn get_next_reuses_preview_command_resolved_by_begin() { + let mut channel = MockChannel::new(); + + // Device advertises credentialMgmtPreview only: Begin must resolve 0x41. + let info = Ctap2GetInfoResponse { + options: Some(HashMap::from([("credentialMgmtPreview".to_string(), true)])), + ..Default::default() + }; + let info_req = CborRequest::new(Ctap2CommandCode::AuthenticatorGetInfo); + let info_resp = CborResponse::new_success_from_slice(&cbor::to_vec(&info).unwrap()); + channel.push_command_pair(info_req, info_resp); + + let mut begin_req = Ctap2CredentialManagementRequest::new_enumerate_rps_begin(); + begin_req.use_legacy_preview = true; + let begin_cbor: CborRequest = (&begin_req).try_into().unwrap(); + assert_eq!( + begin_cbor.command, + Ctap2CommandCode::AuthenticatorCredentialManagementPreview + ); + let begin_fixture = EnumerateRpsResponse { + rp: Ctap2PublicKeyCredentialRpEntity::new("example.com", "Example"), + rp_id_hash: ByteBuf::from(vec![0xEF; 32]), + total_rps: 2, + }; + channel.push_command_pair( + begin_cbor, + CborResponse::new_success_from_slice(&cbor::to_vec(&begin_fixture).unwrap()), + ); + + let mut next_req = Ctap2CredentialManagementRequest::new_enumerate_rps_next_rp(); + next_req.use_legacy_preview = true; + let next_cbor: CborRequest = (&next_req).try_into().unwrap(); + assert_eq!( + next_cbor.command, + Ctap2CommandCode::AuthenticatorCredentialManagementPreview + ); + assert_eq!(next_cbor.encoded_data, vec![0xA1, 0x01, 0x03]); + let next_fixture = EnumerateRpsNextResponse { + rp: Ctap2PublicKeyCredentialRpEntity::new("example.org", "Example Two"), + rp_id_hash: ByteBuf::from(vec![0x11; 32]), + }; + channel.push_command_pair( + next_cbor, + CborResponse::new_success_from_slice(&cbor::to_vec(&next_fixture).unwrap()), + ); + + let (_, total) = channel.enumerate_rps_begin(TIMEOUT).await.unwrap(); + assert_eq!(total, 2); + let rp_data = channel.enumerate_rps_next_rp(TIMEOUT).await.unwrap(); + assert_eq!(rp_data.rp_id_hash, vec![0x11; 32]); + } } diff --git a/libwebauthn/src/transport/ble/channel.rs b/libwebauthn/src/transport/ble/channel.rs index 2b577f60..5ccc3dce 100644 --- a/libwebauthn/src/transport/ble/channel.rs +++ b/libwebauthn/src/transport/ble/channel.rs @@ -33,6 +33,7 @@ pub struct BleChannel<'a> { connection: Connection, revision: FidoRevision, auth_token_data: Option, + cred_mgmt_preview: bool, persistent_token_store: Option>, ux_update_sender: broadcast::Sender, } @@ -57,6 +58,7 @@ impl<'a> BleChannel<'a> { connection, revision, auth_token_data: None, + cred_mgmt_preview: false, persistent_token_store: settings.persistent_token_store, ux_update_sender, }; @@ -197,6 +199,14 @@ impl Ctap2AuthTokenStore for BleChannel<'_> { self.auth_token_data = None; } + fn set_cred_mgmt_preview(&mut self, uses_preview: bool) { + self.cred_mgmt_preview = uses_preview; + } + + fn cred_mgmt_preview(&self) -> bool { + self.cred_mgmt_preview + } + fn persistent_token_store(&self) -> Option> { self.persistent_token_store.clone() } diff --git a/libwebauthn/src/transport/cable/channel.rs b/libwebauthn/src/transport/cable/channel.rs index 0212138a..58176e92 100644 --- a/libwebauthn/src/transport/cable/channel.rs +++ b/libwebauthn/src/transport/cable/channel.rs @@ -202,6 +202,12 @@ impl Ctap2AuthTokenStore for CableChannel { fn clear_uv_auth_token_store(&mut self) {} + fn set_cred_mgmt_preview(&mut self, _uses_preview: bool) {} + + fn cred_mgmt_preview(&self) -> bool { + false + } + fn persistent_token_store(&self) -> Option> { self.persistent_token_store.clone() } diff --git a/libwebauthn/src/transport/channel.rs b/libwebauthn/src/transport/channel.rs index 2db66c32..5860755f 100644 --- a/libwebauthn/src/transport/channel.rs +++ b/libwebauthn/src/transport/channel.rs @@ -159,6 +159,10 @@ pub trait Ctap2AuthTokenStore { fn store_auth_data(&mut self, auth_token_data: AuthTokenData); fn get_auth_data(&self) -> Option<&AuthTokenData>; fn clear_uv_auth_token_store(&mut self); + /// Command set resolved by the last credMgmt state-initializing request, so + /// stateful GetNext continuations reuse it without re-fetching getInfo. + fn set_cred_mgmt_preview(&mut self, uses_preview: bool); + fn cred_mgmt_preview(&self) -> bool; fn get_uv_auth_token(&self, requested_permission: &Ctap2AuthTokenPermission) -> Option<&[u8]> { if let Some(stored_data) = self.get_auth_data() { if let Some(permission) = &stored_data.permission { diff --git a/libwebauthn/src/transport/hid/channel.rs b/libwebauthn/src/transport/hid/channel.rs index 552a0948..3cfbb5ab 100644 --- a/libwebauthn/src/transport/hid/channel.rs +++ b/libwebauthn/src/transport/hid/channel.rs @@ -82,6 +82,7 @@ pub struct HidChannel<'d> { open_device: OpenHidDevice, init: InitResponse, auth_token_data: Option, + cred_mgmt_preview: bool, persistent_token_store: Option>, ux_update_sender: broadcast::Sender, handle: HidChannelHandle, @@ -113,6 +114,7 @@ impl<'d> HidChannel<'d> { }, init: InitResponse::default(), auth_token_data: None, + cred_mgmt_preview: false, persistent_token_store: settings.persistent_token_store, ux_update_sender, handle, @@ -615,6 +617,14 @@ impl Ctap2AuthTokenStore for HidChannel<'_> { self.auth_token_data = None; } + fn set_cred_mgmt_preview(&mut self, uses_preview: bool) { + self.cred_mgmt_preview = uses_preview; + } + + fn cred_mgmt_preview(&self) -> bool { + self.cred_mgmt_preview + } + fn persistent_token_store(&self) -> Option> { self.persistent_token_store.clone() } diff --git a/libwebauthn/src/transport/mock/channel.rs b/libwebauthn/src/transport/mock/channel.rs index 9e94f5f7..884af746 100644 --- a/libwebauthn/src/transport/mock/channel.rs +++ b/libwebauthn/src/transport/mock/channel.rs @@ -21,6 +21,7 @@ pub struct MockChannel { expected_requests: VecDeque, responses: VecDeque, auth_token_data: Option, + cred_mgmt_preview: bool, persistent_token_store: Option>, ux_update_sender: broadcast::Sender, pre_send_delay: Option, @@ -39,6 +40,7 @@ impl MockChannel { expected_requests: VecDeque::new(), responses: VecDeque::new(), auth_token_data: None, + cred_mgmt_preview: false, persistent_token_store: None, ux_update_sender, pre_send_delay: None, @@ -73,6 +75,14 @@ impl Ctap2AuthTokenStore for MockChannel { self.auth_token_data = None; } + fn set_cred_mgmt_preview(&mut self, uses_preview: bool) { + self.cred_mgmt_preview = uses_preview; + } + + fn cred_mgmt_preview(&self) -> bool { + self.cred_mgmt_preview + } + fn persistent_token_store(&self) -> Option> { self.persistent_token_store.clone() } diff --git a/libwebauthn/src/transport/nfc/channel.rs b/libwebauthn/src/transport/nfc/channel.rs index 3a81fade..33cf532e 100644 --- a/libwebauthn/src/transport/nfc/channel.rs +++ b/libwebauthn/src/transport/nfc/channel.rs @@ -103,6 +103,7 @@ where { delegate: Box + Send + Sync>, auth_token_data: Option, + cred_mgmt_preview: bool, persistent_token_store: Option>, ux_update_sender: broadcast::Sender, handle: NfcChannelHandle, @@ -137,6 +138,7 @@ where NfcChannel { delegate, auth_token_data: None, + cred_mgmt_preview: false, persistent_token_store: settings.persistent_token_store, ux_update_sender, handle, @@ -364,6 +366,14 @@ where self.auth_token_data = None; } + fn set_cred_mgmt_preview(&mut self, uses_preview: bool) { + self.cred_mgmt_preview = uses_preview; + } + + fn cred_mgmt_preview(&self) -> bool { + self.cred_mgmt_preview + } + fn persistent_token_store(&self) -> Option> { self.persistent_token_store.clone() }