diff --git a/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui b/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui index 77fe2e7c..3a554c77 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui +++ b/xyz-iinuwa-credential-manager-portal-gtk/data/resources/ui/window.ui @@ -173,7 +173,7 @@ vertical - Credentials + Choose credential diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs index 803453e9..998424bf 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/mod.rs @@ -17,10 +17,7 @@ use libwebauthn::{ use crate::{ credential_service::{hybrid::HybridEvent, usb::UsbEvent}, - dbus::{ - CredentialRequest, CredentialResponse, GetAssertionResponseInternal, - MakeCredentialResponseInternal, - }, + dbus::{CredentialRequest, CredentialResponse}, view_model::{Device, Transport}, }; @@ -132,7 +129,25 @@ where Poll::Pending => Poll::Pending, Poll::Ready(Some(HybridEvent { state })) => { if let HybridStateInternal::Completed(hybrid_response) = &state { - let response = hybrid_response.as_cred_response(&["hybrid"], "cross-platform"); + let response = match hybrid_response { + AuthenticatorResponse::CredentialCreated(make_credential_response) => { + CredentialResponse::from_make_credential( + make_credential_response, + &["hybrid"], + "cross-platform", + ) + } + AuthenticatorResponse::CredentialsAsserted(get_assertion_response) => { + CredentialResponse::from_get_assertion( + // When doing hybrid, the authenticator is capable of displaying it's own UI. + // So we assume here, it only ever returns one assertion. + // In case this doesn't hold true, we have to implement credential selection here, + // as is done for USB. + &get_assertion_response.assertions[0], + "cross-platform", + ) + } + }; let mut cred_response = cred_response.lock().unwrap(); cred_response.replace(response); } @@ -163,9 +178,8 @@ where Poll::Pending => Poll::Pending, Poll::Ready(Some(UsbEvent { state })) => { if let UsbStateInternal::Completed(response) = &state { - let response = response.as_cred_response(&["usb"], "cross-platform"); let mut cred_response = cred_response.lock().unwrap(); - cred_response.replace(response); + cred_response.replace(response.clone()); } Poll::Ready(Some(state.into())) } @@ -179,32 +193,6 @@ enum AuthenticatorResponse { CredentialCreated(MakeCredentialResponse), CredentialsAsserted(GetAssertionResponse), } -impl AuthenticatorResponse { - fn as_cred_response(&self, transports: &[&str], modality: &str) -> CredentialResponse { - match self { - AuthenticatorResponse::CredentialCreated(make_response) => { - CredentialResponse::CreatePublicKeyCredentialResponse( - MakeCredentialResponseInternal::new( - make_response.clone(), - transports.iter().map(|s| s.to_string()).collect(), - modality.to_string(), - ), - ) - } - AuthenticatorResponse::CredentialsAsserted(GetAssertionResponse { assertions }) - if assertions.len() == 1 => - { - CredentialResponse::GetPublicKeyCredentialResponse( - GetAssertionResponseInternal::new(assertions[0].clone(), modality.to_string()), - ) - } - AuthenticatorResponse::CredentialsAsserted(GetAssertionResponse { assertions }) => { - assert!(!assertions.is_empty()); - todo!("need to support selection from multiple credentials"); - } - } - } -} impl From for AuthenticatorResponse { fn from(value: MakeCredentialResponse) -> Self { @@ -298,9 +286,7 @@ mod test { origin: Some("webauthn.io".to_string()), is_same_origin: Some(true), r#type: "public-key".to_string(), - public_key: Some(CreatePublicKeyCredentialRequest { - request_json: request_json, - }), + public_key: Some(CreatePublicKeyCredentialRequest { request_json }), } .try_into_ctap2_request() .unwrap(); diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/usb.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/usb.rs index 6c8685f6..eb4d0d2a 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/usb.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/credential_service/usb.rs @@ -1,8 +1,10 @@ use std::time::Duration; use async_stream::stream; +use base64::{self, engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use futures_lite::Stream; use libwebauthn::{ + ops::webauthn::GetAssertionResponse, transport::{hid::HidDevice, Device}, webauthn::{Error as WebAuthnError, WebAuthn}, UxUpdate, @@ -10,9 +12,12 @@ use libwebauthn::{ use tokio::sync::mpsc::{self, Receiver, Sender, WeakSender}; use tracing::{debug, warn}; -use crate::dbus::CredentialRequest; +use crate::{ + dbus::{CredentialRequest, GetAssertionResponseInternal}, + view_model::Credential, +}; -use super::AuthenticatorResponse; +use super::{AuthenticatorResponse, CredentialResponse}; pub(crate) trait UsbHandler { fn start( @@ -31,6 +36,7 @@ impl InProcessUsbHandler { ) -> Result<(), String> { let mut state = UsbStateInternal::Idle; let (signal_tx, mut signal_rx) = mpsc::channel(256); + let (cred_tx, mut cred_rx) = mpsc::channel(1); debug!("polling for USB status"); loop { tracing::debug!("current usb state: {:?}", state); @@ -109,9 +115,32 @@ impl InProcessUsbHandler { Some(Ok(UsbUvMessage::NeedsUserPresence)) => { Ok(UsbStateInternal::NeedsUserPresence) } - Some(Ok(UsbUvMessage::ReceivedCredential(response))) => { - Ok(UsbStateInternal::Completed(response.clone())) - } + Some(Ok(UsbUvMessage::ReceivedCredentials(response))) => match response { + AuthenticatorResponse::CredentialCreated(make_credential_response) => { + Ok(UsbStateInternal::Completed( + CredentialResponse::from_make_credential( + &make_credential_response, + &["usb"], + "cross-platform", + ), + )) + } + AuthenticatorResponse::CredentialsAsserted(get_assertion_response) => { + if get_assertion_response.assertions.len() == 1 { + Ok(UsbStateInternal::Completed( + CredentialResponse::from_get_assertion( + &get_assertion_response.assertions[0], + "cross-platform", + ), + )) + } else { + Ok(UsbStateInternal::SelectCredential { + response: get_assertion_response, + cred_tx: cred_tx.clone(), + }) + } + } + }, Some(Err(err)) => Err(err.clone()), None => Err("Channel disconnected".to_string()), } @@ -140,13 +169,76 @@ impl InProcessUsbHandler { Ok(UsbStateInternal::NeedsUserVerification { attempts_left }) } UsbUvMessage::NeedsUserPresence => Ok(UsbStateInternal::NeedsUserPresence), - UsbUvMessage::ReceivedCredential(response) => { - Ok(UsbStateInternal::Completed(response.clone())) - } + UsbUvMessage::ReceivedCredentials(response) => match response { + AuthenticatorResponse::CredentialCreated(make_credential_response) => { + Ok(UsbStateInternal::Completed( + CredentialResponse::from_make_credential( + &make_credential_response, + &["usb"], + "cross-platform", + ), + )) + } + AuthenticatorResponse::CredentialsAsserted(get_assertion_response) => { + if get_assertion_response.assertions.len() == 1 { + Ok(UsbStateInternal::Completed( + CredentialResponse::from_get_assertion( + &get_assertion_response.assertions[0], + "cross-platform", + ), + )) + } else { + Ok(UsbStateInternal::SelectCredential { + response: get_assertion_response, + cred_tx: cred_tx.clone(), + }) + } + } + }, }, None => Err("USB UV handler channel closed".to_string()), }, UsbStateInternal::Completed(_) => Ok(prev_usb_state), + UsbStateInternal::SelectCredential { + response, + cred_tx: _, + } => match cred_rx.recv().await { + Some(cred_id) => { + let assertion = response + .assertions + .iter() + .find(|c| { + c.credential_id + .as_ref() + .map(|c| { + // In order to not expose the credential ID to the untrusted UI component, + // we hashed it, before sending it. So we have to re-hash all our credential + // IDs to identify the selected one. + URL_SAFE_NO_PAD.encode(ring::digest::digest( + &ring::digest::SHA256, + &c.id, + )) == cred_id + }) + .unwrap_or_default() + }) + .cloned(); + match assertion { + Some(assertion) => Ok(UsbStateInternal::Completed( + CredentialResponse::GetPublicKeyCredentialResponse( + GetAssertionResponseInternal::new( + assertion, + "cross-platform".to_string(), + ), + ), + )), + None => Err("Selected credential not found.".to_string()), + } + } + None => { + tracing::debug!("cred channel closed before receiving cred from client."); + Err("Cred channel disconnected".to_string()) + } + }, }; state = next_usb_state?; tx.send(state.clone()) @@ -219,7 +311,7 @@ async fn notify_ceremony_completed( response: AuthenticatorResponse, ) { signal_tx - .send(Ok(UsbUvMessage::ReceivedCredential(response))) + .send(Ok(UsbUvMessage::ReceivedCredentials(response))) .await .unwrap(); } @@ -278,8 +370,14 @@ pub(super) enum UsbStateInternal { /// The device needs evidence of user presence (e.g. touch) to release the credential. NeedsUserPresence, + // Multiple credentials have been found and the user has to select which to use + SelectCredential { + response: GetAssertionResponse, + cred_tx: mpsc::Sender, + }, + /// USB tapped, received credential - Completed(AuthenticatorResponse), + Completed(CredentialResponse), // TODO: implement cancellation // This isn't actually sent from the server. //UserCancelled, @@ -324,6 +422,13 @@ pub enum UsbState { // When we encounter multiple devices, we let all of them blink and continue // with the one that was tapped. SelectingDevice, + + // Multiple credentials have been found and the user has to select which to use + // List of user-identities to decide which to use. + SelectCredential { + creds: Vec, + cred_tx: mpsc::Sender, + }, } impl From for UsbState { @@ -346,6 +451,38 @@ impl From for UsbState { UsbStateInternal::Completed(_) => UsbState::Completed, // UsbStateInternal::UserCancelled => UsbState:://UserCancelled, UsbStateInternal::SelectingDevice(_) => UsbState::SelectingDevice, + UsbStateInternal::SelectCredential { response, cred_tx } => { + UsbState::SelectCredential { + creds: response + .assertions + .iter() + .map(|x| Credential { + id: x + .credential_id + .as_ref() + .map(|i| { + // In order to not expose the credential ID to the untrusted UI components, + // we hash and then encode it into a String. + URL_SAFE_NO_PAD + .encode(ring::digest::digest(&ring::digest::SHA256, &i.id)) + }) + .unwrap(), + + name: x + .user + .as_ref() + .and_then(|u| u.name.clone()) + .unwrap_or_else(|| String::from("")), + username: x + .user + .as_ref() + .map(|u| u.display_name.clone()) + .unwrap_or_default(), + }) + .collect(), + cred_tx, + } + } } } } @@ -408,5 +545,5 @@ enum UsbUvMessage { attempts_left: Option, }, NeedsUserPresence, - ReceivedCredential(AuthenticatorResponse), + ReceivedCredentials(AuthenticatorResponse), } diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs index 0de17fd5..5cd437e4 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/dbus.rs @@ -240,6 +240,27 @@ pub(crate) enum CredentialResponse { GetPublicKeyCredentialResponse(GetAssertionResponseInternal), } +impl CredentialResponse { + pub(crate) fn from_make_credential( + response: &MakeCredentialResponse, + transports: &[&str], + modality: &str, + ) -> CredentialResponse { + CredentialResponse::CreatePublicKeyCredentialResponse(MakeCredentialResponseInternal::new( + response.clone(), + transports.iter().map(|s| s.to_string()).collect(), + modality.to_string(), + )) + } + + pub(crate) fn from_get_assertion(assertion: &Assertion, modality: &str) -> CredentialResponse { + CredentialResponse::GetPublicKeyCredentialResponse(GetAssertionResponseInternal::new( + assertion.clone(), + modality.to_string(), + )) + } +} + #[derive(Clone, Debug)] pub(crate) struct MakeCredentialResponseInternal { ctap: MakeCredentialResponse, diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs index 8e3555fa..171ddfa8 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/gtk/mod.rs @@ -39,9 +39,6 @@ mod imp { #[property(get, set)] pub selected_device: RefCell>, - #[property(get, set)] - pub selected_credential: RefCell>, - #[property(get, set)] pub usb_pin_entry_visible: RefCell, @@ -124,9 +121,6 @@ impl ViewModel { ViewUpdate::WaitingForDevice(device) => { view_model.waiting_for_device(&device) } - ViewUpdate::SelectCredential(cred_id) => { - view_model.select_credential(cred_id) - } ViewUpdate::UsbNeedsPin { attempts_left } => { let prompt = match attempts_left { Some(1) => { @@ -258,7 +252,11 @@ impl ViewModel { .orientation(gtk::Orientation::Horizontal) .build(); let icon = gtk::Image::builder().icon_name(icon_name).build(); - let label = gtk::Label::builder().label(credential.name()).build(); + let mut display_label = credential.name().to_string(); + if let Some(username) = credential.username() { + display_label += &format!(" ({username})"); + } + let label = gtk::Label::builder().label(display_label).build(); b.append(&icon); b.append(&label); @@ -294,17 +292,12 @@ impl ViewModel { } let device_object: DeviceObject = device.into(); self.set_selected_device(device_object); - self.set_selected_credential(""); } fn selecting_device(&self) { self.set_prompt("Multiple devices found. Please select with which to proceed."); } - fn select_credential(&self, cred_id: String) { - self.set_selected_credential(cred_id); - } - pub async fn send_thingy(&self) { self.send_event(ViewEvent::ButtonClicked).await; } diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs index 7ce38ff4..27ee2657 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/view_model/mod.rs @@ -28,11 +28,11 @@ where // This includes devices like platform authenticator, USB, hybrid devices: Vec, selected_device: Option, - selected_credential: Option, providers: Vec, usb_pin_tx: Option>>>, + usb_cred_tx: Option>>>, hybrid_qr_state: HybridState, hybrid_qr_code_data: Option>, @@ -58,9 +58,9 @@ impl ViewModel { title: String::default(), devices: Vec::new(), selected_device: None, - selected_credential: None, providers: Vec::new(), usb_pin_tx: None, + usb_cred_tx: None, hybrid_qr_state: HybridState::default(), hybrid_qr_code_data: None, hybrid_linked_state: HybridState::default(), @@ -152,11 +152,6 @@ impl ViewModel { todo!(); } }; - // Remove the attribute below when we implement cancellation for at least one transport. - #[allow(unreachable_code)] - { - self.selected_credential = None; - } } // start discovery for newly selected device @@ -278,11 +273,12 @@ impl ViewModel { "Credential selected: {:?}. Current Device: {:?}", cred_id, self.selected_device ); - self.selected_credential = Some(cred_id.clone()); - self.tx_update - .send(ViewUpdate::SelectCredential(cred_id)) - .await - .unwrap(); + + if let Some(cred_tx) = self.usb_cred_tx.take() { + if cred_tx.lock().await.send(cred_id.clone()).await.is_err() { + error!("Failed to send selected credential to device"); + } + } } Event::Background(BackgroundEvent::UsbPressed) => { @@ -327,6 +323,13 @@ impl ViewModel { .unwrap(); } UsbState::Idle | UsbState::Waiting => {} + UsbState::SelectCredential { creds, cred_tx } => { + let _ = self.usb_cred_tx.insert(Arc::new(Mutex::new(cred_tx))); + self.tx_update + .send(ViewUpdate::SetCredentials(creds)) + .await + .unwrap(); + } } } Event::Background(BackgroundEvent::HybridQrStateChanged(state)) => { @@ -384,7 +387,6 @@ pub enum ViewUpdate { SetDevices(Vec), SetCredentials(Vec), WaitingForDevice(Device), - SelectCredential(String), UsbNeedsPin { attempts_left: Option }, UsbNeedsUserVerification { attempts_left: Option }, UsbNeedsUserPresence, @@ -409,9 +411,9 @@ pub enum Event { #[derive(Clone, Debug, Default)] pub struct Credential { - id: String, - name: String, - username: Option, + pub(crate) id: String, + pub(crate) name: String, + pub(crate) username: Option, } #[derive(Debug, Default)] diff --git a/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs b/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs index ea70f5c1..2ec2f531 100644 --- a/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs +++ b/xyz-iinuwa-credential-manager-portal-gtk/src/window.rs @@ -163,9 +163,6 @@ impl ExampleApplicationWindow { .and_downcast_ref::() .expect("selected device to exist at notify"); match d.transport().try_into() { - // TODO: Can multiple resident_keys exist on USB for same origin? - // If so, we need to transition this to choose_credential as well. - // For now, we'll skip it. Ok(Transport::Usb) => stack.set_visible_child_name("usb"), Ok(Transport::HybridQr) => stack.set_visible_child_name("hybrid_qr"), _ => {} @@ -183,34 +180,21 @@ impl ExampleApplicationWindow { } )); - view_model.connect_selected_credential_notify(clone!( + view_model.connect_completed_notify(clone!( #[weak] stack, move |vm| { - let c = vm.selected_credential(); - if c.is_none() || c.unwrap().is_empty() { - return; + if vm.completed() { + stack.set_visible_child_name("completed"); } - - let d = vm.selected_device(); - let d = d - .and_downcast_ref::() - .expect("selected device to exist at notify"); - match d.transport().try_into() { - Ok(Transport::Usb) => stack.set_visible_child_name("usb"), - Ok(Transport::HybridQr) => stack.set_visible_child_name("hybrid_qr"), - _ => {} - }; } )); - view_model.connect_completed_notify(clone!( + view_model.connect_credentials_notify(clone!( #[weak] stack, - move |vm| { - if vm.completed() { - stack.set_visible_child_name("completed"); - } + move |_vm| { + stack.set_visible_child_name("choose_credential"); } )); }