diff --git a/.vscode/launch.json b/.vscode/launch.json index e6743d4..f5eb641 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,9 +11,13 @@ "program": "${workspaceFolder}/build/credentialsd/src/credentialsd", "args": [], "env": { - "RUST_LOG": "credentialsd=debug,libwebauthn=debug,libwebauthn::webauthn=debug,libwebauthn=warn,libwebauthn::proto::ctap2::preflight=debug,libwebauthn::transport::channel=debug,zbus::object_server::debug,zbus=debug" + "RUST_LOG": "credentialsd=trace,libwebauthn=debug,libwebauthn::webauthn=debug,libwebauthn=warn,libwebauthn::proto::ctap2::preflight=debug,libwebauthn::transport::channel=debug,zbus::object_server::debug,zbus=debug", + "CREDSD_TRUSTED_CALLERS": "/usr/bin/python3.14,/home/isaiah/Development/portal/xdg-desktop-portal/build/src/xdg-desktop-portal", + "CREDSD_TRUSTED_APP_IDS": "app:xyz.iinuwa.credentialsd.DemoCredentialsUi", }, - "sourceLanguages": ["rust"], + "sourceLanguages": [ + "rust" + ], "cwd": "${workspaceFolder}", "preLaunchTask": "Meson: Build Daemon" }, @@ -25,9 +29,11 @@ "args": [], "env": { "GSETTINGS_SCHEMA_DIR": "${workspaceFolder}/build/credentialsd-ui/data", - "RUST_LOG": "credentialsd_ui=debug,zbus::trace,zbus::object_server::debug" + "RUST_LOG": "credentialsd_ui=trace,zbus::trace,zbus::object_server::debug" }, - "sourceLanguages": ["rust"], + "sourceLanguages": [ + "rust" + ], "cwd": "${workspaceFolder}", "preLaunchTask": "Meson: Build UI" }, @@ -35,7 +41,10 @@ "compounds": [ { "name": "Server/Client", - "configurations": ["Debug UI (credentialsd-ui)", "Debug Daemon (credentialsd)"] + "configurations": [ + "Debug UI (credentialsd-ui)", + "Debug Daemon (credentialsd)" + ] } ] -} +} \ No newline at end of file diff --git a/credentialsd-common/src/client.rs b/credentialsd-common/src/client.rs index 1bff01d..28312bb 100644 --- a/credentialsd-common/src/client.rs +++ b/credentialsd-common/src/client.rs @@ -2,10 +2,7 @@ use std::pin::Pin; use futures_lite::Stream; -use crate::{ - model::{BackgroundEvent, Device}, - server::RequestId, -}; +use crate::model::{BackgroundEvent, Device, RequestId}; /// Used for communication from trusted UI to credential service pub trait FlowController { diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index 7c2519b..7ddc30c 100644 --- a/credentialsd-common/src/model.rs +++ b/credentialsd-common/src/model.rs @@ -10,6 +10,8 @@ pub struct Credential { pub username: Option, } +/// Client Capabilities, as defined in +/// [WebAuthn](https://www.w3.org/TR/webauthn-3/#enumdef-clientcapability). #[derive(SerializeDict, Type)] #[zvariant(signature = "dict", rename_all = "camelCase")] pub struct GetClientCapabilitiesResponse { @@ -38,8 +40,17 @@ pub struct Device { #[derive(Clone, Debug, Serialize, Deserialize, Type)] pub enum Operation { - Create, - Get, + PublicKeyCreate, + PublicKeyGet, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +pub struct PortalBackendOptions { + /// Top-level origin of the request if different from the origin. + pub top_origin: Optional, + + /// RP ID of the request. Required for WebAuthn/PublicKey requests. + pub rp_id: Optional, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -110,7 +121,7 @@ pub struct RequestingApplication { /// The name of the application. pub name: Optional, - /// The PID of the applicatoin + /// The PID of the application pub pid: u32, } @@ -120,6 +131,9 @@ pub struct RequestingParty { pub origin: String, } +/// Identifier for a request to be used for cancellation. +pub type RequestId = u32; + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ViewUpdate { SetTitle((String, String)), @@ -250,6 +264,41 @@ pub enum NfcState { Failed(Error), } +pub enum BackendRequest { + /// Start Hybrid discovery + StartHybridDiscovery, + + /// Start NFC discovery + StartNfcDiscovery, + + /// Start USB discovery + StartUsbDiscovery, + + /// Send client PIN + EnterClientPin(String), + + /// Select a credential by credential ID + SelectCredential(String), + + CancelRequest, +} + +impl std::fmt::Debug for BackendRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StartHybridDiscovery => write!(f, "StartHybridDiscovery"), + Self::StartNfcDiscovery => write!(f, "StartNfcDiscovery"), + Self::StartUsbDiscovery => write!(f, "StartUsbDiscovery"), + Self::EnterClientPin(_) => f + .debug_tuple("EnterClientPin") + .field(&"******".to_string()) + .finish(), + Self::SelectCredential(arg0) => f.debug_tuple("SelectCredential").field(arg0).finish(), + Self::CancelRequest => write!(f, "CancelRequest"), + } + } +} + #[derive(Clone, Debug)] pub enum BackgroundEvent { UsbStateChanged(UsbState), diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index 7eb656e..b071dc2 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -1,6 +1,6 @@ //! Types for serializing across D-Bus instances -use std::fmt::Display; +use std::{collections::HashMap, fmt::Display}; use serde::{ Deserialize, Serialize, @@ -8,15 +8,144 @@ use serde::{ }; use zvariant::{ self, Array, DeserializeDict, DynamicDeserialize, NoneValue, Optional, OwnedValue, - SerializeDict, Signature, Structure, StructureBuilder, Type, Value, signature::Fields, + SerializeDict, Signature, Str, Structure, StructureBuilder, Type, Value, signature::Fields, }; -use crate::model::{BackgroundEvent, Device, Operation, RequestingApplication}; +use crate::model::{ + BackendRequest, BackgroundEvent, Device, Operation, RequestId, RequestingApplication, +}; const TAG_VALUE_SIGNATURE: &Signature = &Signature::Structure(Fields::Static { fields: &[&Signature::U8, &Signature::Variant], }); +/// Ceremony completed successfully +const BACKGROUND_EVENT_CEREMONY_COMPLETED: u32 = 0x01; +/// Device needs the client PIN to be entered. The backend should collect the +/// PIN and send it back with `EnterClientPin` event of `UserInteracted` signal. +const BACKGROUND_EVENT_NEEDS_PIN: u32 = 0x10; +const BACKGROUND_EVENT_NEEDS_USER_VERIFICATION: u32 = 0x11; +const BACKGROUND_EVENT_NEEDS_PRESENCE: u32 = 0x12; +const BACKGROUND_EVENT_SELECTING_CREDENTIAL: u32 = 0x13; + +const BACKGROUND_EVENT_HYBRID_IDLE: u32 = 0x20; +const BACKGROUND_EVENT_HYBRID_STARTED: u32 = 0x21; +const BACKGROUND_EVENT_HYBRID_CONNECTING: u32 = 0x22; +const BACKGROUND_EVENT_HYBRID_CONNECTED: u32 = 0x23; + +const BACKGROUND_EVENT_NFC_IDLE: u32 = 0x30; +const BACKGROUND_EVENT_NFC_WAITING: u32 = 0x31; +const BACKGROUND_EVENT_NFC_SELECTING_DEVICE: u32 = 0x32; +const BACKGROUND_EVENT_NFC_CONNECTED: u32 = 0x33; + +const BACKGROUND_EVENT_USB_IDLE: u32 = 0x41; +const BACKGROUND_EVENT_USB_WAITING: u32 = 0x42; +const BACKGROUND_EVENT_USB_SELECTING_DEVICE: u32 = 0x43; +const BACKGROUND_EVENT_USB_CONNECTED: u32 = 0x44; + +const BACKGROUND_EVENT_ERROR_AUTHENTICATOR: u32 = 0x80000001; +const BACKGROUND_EVENT_ERROR_NO_CREDENTIALS: u32 = 0x80000002; +const BACKGROUND_EVENT_ERROR_PIN_ATTEMPTS_EXHAUSTED: u32 = 0x80000003; +const BACKGROUND_EVENT_ERROR_INTERNAL: u32 = 0x80000004; +const BACKGROUND_EVENT_ERROR_TIMED_OUT: u32 = 0x80000005; +const BACKGROUND_EVENT_ERROR_CANCELLED: u32 = 0x80000006; + +// BackendRequest +const BACKEND_REQUEST_START_HYBRID_DISCOVERY: u32 = 0x01; +const BACKEND_REQUEST_START_USB_DISCOVERY: u32 = 0x02; +const BACKEND_REQUEST_START_NFC_DISCOVERY: u32 = 0x03; +const BACKEND_REQUEST_ENTER_CLIENT_PIN: u32 = 0x04; +const BACKEND_REQUEST_SELECT_CREDENTIAL: u32 = 0x05; +const BACKEND_REQUEST_CANCEL_REQUEST: u32 = 0x06; + +impl Type for BackendRequest { + const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; +} + +impl From<&BackendRequest> for Structure<'_> { + fn from(value: &BackendRequest) -> Self { + match value { + BackendRequest::StartHybridDiscovery => tag_value_to_struct(0x01, None), + BackendRequest::StartNfcDiscovery => tag_value_to_struct(0x02, None), + BackendRequest::StartUsbDiscovery => tag_value_to_struct(0x03, None), + BackendRequest::EnterClientPin(pin) => { + tag_value_to_struct(0x04, Some(Value::Str(pin.into()))) + } + BackendRequest::SelectCredential(credential_id) => { + tag_value_to_struct(0x05, Some(Value::Str(credential_id.into()))) + } + BackendRequest::CancelRequest => tag_value_to_struct(0x06, None), + } + } +} + +impl TryFrom<&Structure<'_>> for BackendRequest { + type Error = zvariant::Error; + + fn try_from(value: &Structure<'_>) -> Result { + let (tag, value) = parse_tag_value_struct(value)?; + + match tag { + 0x01 => Ok(BackendRequest::StartHybridDiscovery), + 0x02 => Ok(BackendRequest::StartNfcDiscovery), + 0x03 => Ok(BackendRequest::StartUsbDiscovery), + 0x04 => { + let s: Str = value.downcast_ref()?; + if s.is_empty() { + return Err(zvariant::Error::invalid_length( + s.len(), + &"a non-empty string", + )); + } + Ok(BackendRequest::EnterClientPin(s.as_str().to_string())) + } + 0x05 => { + let s: Str = value.downcast_ref()?; + if s.is_empty() { + return Err(zvariant::Error::invalid_length( + s.len(), + &"a non-empty string", + )); + } + Ok(BackendRequest::SelectCredential(s.as_str().to_string())) + } + 0x06 => Ok(BackendRequest::CancelRequest), + _ => Err(zvariant::Error::Message(format!( + "Unknown BackendRequest tag : {tag}" + ))), + } + } +} + +impl Serialize for BackendRequest { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let structure: Structure = self.into(); + structure.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for BackendRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let d = Structure::deserializer_for_signature(TAG_VALUE_SIGNATURE).map_err(|err| { + D::Error::custom(format!( + "could not create deserializer for tag-value struct: {err}" + )) + })?; + let structure = d.deserialize(deserializer)?; + (&structure).try_into().map_err(|err| { + D::Error::custom(format!( + "could not deserialize structure into BackendRequest: {err}" + )) + }) + } +} + impl Type for BackgroundEvent { const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; } @@ -113,6 +242,14 @@ pub struct CreateCredentialResponse { public_key: Option, } +impl NoneValue for CreateCredentialResponse { + type NoneType = HashMap; + + fn null_value() -> Self::NoneType { + HashMap::new() + } +} + #[derive(Clone, Debug, DeserializeDict, Type)] #[zvariant(signature = "dict")] pub struct CreatePublicKeyCredentialRequest { @@ -197,8 +334,6 @@ impl TryFrom<&Value<'_>> for crate::model::Error { pub struct GetCredentialRequest { pub origin: Option, pub is_same_origin: Option, - #[zvariant(rename = "type")] - pub r#type: String, #[zvariant(rename = "publicKey")] pub public_key: Option, } @@ -302,9 +437,6 @@ impl Type for crate::model::HybridState { const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; } -/// Identifier for a request to be used for cancellation. -pub type RequestId = u32; - impl Type for crate::model::UsbState { const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; } @@ -581,7 +713,7 @@ where .map_err(|err| D::Error::custom(format!("could not deserialize from structure: {err}"))) } -#[derive(Serialize, Deserialize, Type)] +#[derive(Clone, Debug, Serialize, Deserialize, Type)] pub struct ViewRequest { pub operation: Operation, @@ -601,7 +733,7 @@ pub struct ViewRequest { pub window_handle: Optional, } -#[derive(Type, PartialEq, Debug)] +#[derive(Clone, Debug, PartialEq, Type)] #[zvariant(signature = "s")] pub enum WindowHandle { Wayland(String), diff --git a/credentialsd-ui/src/client.rs b/credentialsd-ui/src/client.rs index 0f2184d..7642596 100644 --- a/credentialsd-ui/src/client.rs +++ b/credentialsd-ui/src/client.rs @@ -1,5 +1,12 @@ -use async_std::stream::Stream; -use credentialsd_common::{client::FlowController, server::RequestId}; +use async_std::{ + channel::{Receiver, Sender}, + stream::Stream, + sync::Mutex as AsyncMutex, +}; +use credentialsd_common::{ + client::FlowController, + model::{BackendRequest, BackgroundEvent, RequestId}, +}; use futures_lite::StreamExt; use zbus::Connection; @@ -118,3 +125,51 @@ impl FlowController for DbusCredentialClient { Ok(()) } } + +#[derive(Debug)] +pub struct FlowControlClient { + pub tx: Sender, + pub rx: AsyncMutex>>, +} + +impl FlowControlClient { + pub async fn discover_hybrid_authenticators(&self) -> Result<(), ()> { + self.send(BackendRequest::StartHybridDiscovery).await + } + + pub async fn discover_nfc_authenticators(&mut self) -> Result<(), ()> { + self.send(BackendRequest::StartNfcDiscovery).await + } + + pub async fn discover_usb_authenticators(&mut self) -> Result<(), ()> { + self.send(BackendRequest::StartUsbDiscovery).await + } + + pub async fn enter_client_pin(&mut self, pin: String) -> Result<(), ()> { + self.send(BackendRequest::EnterClientPin(pin)).await + } + + pub async fn select_credential(&self, credential_id: String) -> Result<(), ()> { + self.send(BackendRequest::SelectCredential(credential_id)) + .await + } + + pub async fn cancel_request(&self) -> Result<(), ()> { + self.send(BackendRequest::CancelRequest).await + } + + /// Returns a channel for background events. + /// Can only be called once; returns an error if the subscription has already been taken. + pub async fn subscribe(&mut self) -> Result, ()> { + self.rx.lock().await.take().ok_or_else(|| { + tracing::error!("Subscribe has already been called."); + }) + } + + async fn send(&self, request: BackendRequest) -> Result<(), ()> { + match self.tx.send(request).await { + Ok(_) => Ok(()), + Err(_) => Err(()), + } + } +} diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index 9ab511b..a588a83 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -1,9 +1,30 @@ -use async_std::channel::Sender; +use std::sync::Arc; + +use async_std::{ + channel::{self, Receiver, Sender}, + stream::StreamExt, + sync::Mutex as AsyncMutex, + task::JoinHandle, +}; +use zbus::{ + Connection, ObjectServer, fdo, interface, + message::Header, + names::{BusName, OwnedUniqueName}, + object_server::SignalEmitter, + proxy, + zvariant::ObjectPath, +}; + use credentialsd_common::{ - model::{BackgroundEvent, Device}, - server::{RequestId, ViewRequest}, + client::FlowController, + model::{ + BackendRequest, BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId, + RequestingApplication, + }, + server::{ViewRequest, WindowHandle}, }; -use zbus::{fdo, interface, proxy}; + +use crate::client::{DbusCredentialClient, FlowControlClient}; #[proxy( gen_blocking = false, @@ -31,17 +52,247 @@ pub trait FlowControlService { } pub struct UiControlService { - pub request_tx: Sender, + pub request_tx: Sender<(ViewRequest, Arc>)>, } /// These methods are called by the credential service to control the UI. #[interface(name = "xyz.iinuwa.credentialsd.UiControl1")] impl UiControlService { - async fn launch_ui(&self, request: ViewRequest) -> fdo::Result<()> { + async fn launch_ui( + &self, + #[zbus(connection)] conn: &Connection, + request: ViewRequest, + ) -> fdo::Result<()> { tracing::debug!("Received UI launch request"); + let mut client = DbusCredentialClient::new(conn.clone()); + let (fc_tx, fc_rx) = async_std::channel::unbounded(); + let (bg_tx, bg_rx) = async_std::channel::unbounded(); + match client.subscribe().await { + Ok(mut bg_event_stream) => async_std::task::spawn(async move { + while let Some(bg_event) = bg_event_stream.next().await { + if let Err(_) = bg_tx.send(bg_event).await { + tracing::debug!("Background event receiver dropped. Stopping."); + break; + } + } + }), + Err(_) => { + tracing::error!( + ?request, + "Failed to subscribe to background events for request" + ); + return Err(fdo::Error::Failed( + "Failed to subscribe to background events for request".to_string(), + )); + } + }; + async_std::task::spawn(async move { + while let Ok(msg) = fc_rx.recv().await { + // UI doesn't get an error if these fail... + let result = match &msg { + BackendRequest::StartHybridDiscovery => client.get_hybrid_credential().await, + BackendRequest::StartNfcDiscovery => client.get_nfc_credential().await, + BackendRequest::StartUsbDiscovery => client.get_usb_credential().await, + BackendRequest::EnterClientPin(pin) => { + client.enter_client_pin(pin.to_string()).await + } + BackendRequest::SelectCredential(cred_id) => { + client.select_credential(cred_id.to_string()).await + } + BackendRequest::CancelRequest => client.cancel_request(request.id).await, + }; + if let Err(err) = result { + tracing::error!("Failed to send {msg:?} to frontend: {err:?}"); + } + } + client + }); + let flow_control_client = FlowControlClient { + tx: fc_tx, + rx: AsyncMutex::new(Some(bg_rx)), + }; self.request_tx - .send(request) + .send((request, Arc::new(AsyncMutex::new(flow_control_client)))) .await .map_err(|_| fdo::Error::Failed("UI failed to launch".to_string())) } } + +pub struct CredentialPortalBackend { + pub request_tx: Sender<(ViewRequest, Arc>)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct UiContext { + parent_window: WindowHandle, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, +} + +/// These methods are called by the credential service to control the UI. +#[interface(name = "org.freedesktop.impl.portal.experimental.Credential")] +impl CredentialPortalBackend { + async fn initialize( + &self, + #[zbus(header)] header: Header<'_>, + #[zbus(object_server)] object_server: &ObjectServer, + parent_window: WindowHandle, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> fdo::Result> { + let Some(sender) = header.sender() else { + return Err(fdo::Error::BadAddress("Sender not found".to_string())); + }; + let object_path = ObjectPath::from_string_unchecked(format!( + "/org/freedesktop/portal/Credential/{}", + request_id + )); + let ui_context = UiContext { + parent_window, + origin, + r#type, + request_id, + devices, + app_id, + app_display_name, + app_pid, + app_path, + options, + }; + let flow_object = FlowObject { + ui_context, + request_tx: self.request_tx.clone(), + return_address: sender.to_owned().into(), + ui_events_forwarder_task: None, + bg_events_tx: None, + }; + object_server.at(object_path.clone(), flow_object).await?; + tracing::debug!("Received UI launch request"); + Ok(object_path) + } +} + +pub struct FlowObject { + ui_context: UiContext, + pub request_tx: Sender<(ViewRequest, Arc>)>, + pub return_address: OwnedUniqueName, + ui_events_forwarder_task: Option>, + bg_events_tx: Option>, +} + +#[interface(name = "org.freedesktop.impl.portal.experimental.Credential.FlowObject")] +impl FlowObject { + /// Start the UI flow with an initial set of available credential interfaces. + /// Call this method after subscribing to the signals. + async fn start( + &mut self, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> fdo::Result<()> { + let (ui_events_tx, ui_events_rx) = channel::bounded(32); + let (bg_events_tx, bg_events_rx) = channel::bounded(32); + let flow_control_client = FlowControlClient { + tx: ui_events_tx, + rx: AsyncMutex::new(Some(bg_events_rx)), + }; + self.bg_events_tx = Some(bg_events_tx); + + let emitter = emitter + .set_destination(BusName::Unique((&self.return_address).into())) + .to_owned(); + let ui_events_task = async_std::task::spawn(async move { + while let Ok(ui_event) = ui_events_rx.recv().await { + tracing::trace!(?ui_event, "Sending UI event signal to portal"); + if emitter.user_interacted(&ui_event).await.is_err() { + tracing::error!("Failed to send UI event signal."); + // TODO: we need to cancel the request here, so we need a + // channel back to the flow object to send the cancellation. + break; + } + } + }); + self.ui_events_forwarder_task = Some(ui_events_task); + + // Assuming this is a PublicKey request, require the rp_id + let rp_id = self + .ui_context + .options + .rp_id + .as_ref() + .ok_or_else(|| { + { + fdo::Error::InvalidArgs( + "rp_id is required for public key credential requests".to_string(), + ) + } + })? + .to_string(); + let req = ( + ViewRequest { + operation: self.ui_context.r#type.clone(), + id: self.ui_context.request_id, + rp_id, + requesting_app: RequestingApplication { + path_or_app_id: self.ui_context.app_id.clone(), + name: Some(self.ui_context.app_display_name.clone()).into(), + pid: self.ui_context.app_pid, + }, + initial_devices: self.ui_context.devices.clone(), + window_handle: Some(self.ui_context.parent_window.clone()).into(), + }, + Arc::new(AsyncMutex::new(flow_control_client)), + ); + if self.request_tx.send(req).await.is_err() { + tracing::error!("Received message to start flow, but GUI thread is not listening."); + return Err(fdo::Error::Failed("Failed to start GUI".to_string())); + } + Ok(()) + } + + async fn notify_state_changed(&self, event: BackgroundEvent) -> fdo::Result<()> { + tracing::trace!(?event, "Received background event"); + if let Some(tx) = &self.bg_events_tx { + if tx.send(event).await.is_ok() { + return Ok(()); + } + tracing::error!("Failed to send event to GUI thread"); + } else { + tracing::error!("Flow was not properly initialized before receiving events."); + } + return Err(fdo::Error::Failed("Failed to handle event".to_string())); + } + + async fn cancel( + &mut self, + #[zbus(header)] header: Header<'_>, + #[zbus(object_server)] object_server: &ObjectServer, + ) -> fdo::Result<()> { + if let Some(task) = self.ui_events_forwarder_task.take() { + task.cancel().await; + } + if let Some(path) = header.path() { + // TODO: Send clean up task to GUI thread. + object_server.remove::(path).await?; + } + Ok(()) + } + + #[zbus(signal)] + async fn user_interacted( + emitter: SignalEmitter<'_>, + event: &BackendRequest, + ) -> zbus::Result<()>; +} diff --git a/credentialsd-ui/src/gui/mod.rs b/credentialsd-ui/src/gui/mod.rs index bd65aed..e7e3e10 100644 --- a/credentialsd-ui/src/gui/mod.rs +++ b/credentialsd-ui/src/gui/mod.rs @@ -5,28 +5,27 @@ use std::{sync::Arc, thread::JoinHandle}; use async_std::{channel::Receiver, sync::Mutex as AsyncMutex}; -use credentialsd_common::server::{ViewRequest, WindowHandle}; -use credentialsd_common::{client::FlowController, model::ViewUpdate}; +use credentialsd_common::{ + model::ViewUpdate, + server::{ViewRequest, WindowHandle}, +}; + +use crate::client::FlowControlClient; use view_model::ViewEvent; -pub(super) fn start_gui_thread( - rx: Receiver, - flow_controller: F, +pub(super) fn start_gui_thread( + rx: Receiver<(ViewRequest, Arc>)>, ) -> Result, std::io::Error> { thread::Builder::new().name("gui".into()).spawn(move || { - let flow_controller = Arc::new(AsyncMutex::new(flow_controller)); // D-Bus received a request and needs a window open - while let Ok(view_request) = rx.recv_blocking() { - run_gui(flow_controller.clone(), view_request); + while let Ok((view_request, flow_controller)) = rx.recv_blocking() { + run_gui(flow_controller, view_request); } }) } -fn run_gui( - flow_controller: Arc>, - request: ViewRequest, -) { +fn run_gui(flow_controller: Arc>, request: ViewRequest) { let parent_window: Option = request.window_handle.as_ref().and_then(|h| { h.to_string() .try_into() @@ -43,11 +42,8 @@ fn run_gui( vm.start_event_loop().await; tracing::debug!("Finishing user request."); // If cancellation fails, that's fine. - let _ = flow_controller - .lock() - .await - .cancel_request(request_id) - .await; + let _ = flow_controller.lock().await.cancel_request().await; + // TODO: Clean up flow_object when request completes }); view_model::gtk::start_gtk_app(parent_window, tx_event, rx_update); diff --git a/credentialsd-ui/src/gui/view_model/mod.rs b/credentialsd-ui/src/gui/view_model/mod.rs index 4472afa..8eb8bcc 100644 --- a/credentialsd-ui/src/gui/view_model/mod.rs +++ b/credentialsd-ui/src/gui/view_model/mod.rs @@ -13,20 +13,16 @@ use gettextrs::gettext; use serde::{Deserialize, Serialize}; use tracing::{error, info}; -use credentialsd_common::{ - client::FlowController, - model::{ - BackgroundEvent, Credential, Device, Error, HybridState, NfcState, Operation, Transport, - UsbState, ViewUpdate, - }, +use credentialsd_common::model::{ + BackgroundEvent, Credential, Device, Error, HybridState, NfcState, Operation, Transport, + UsbState, ViewUpdate, }; +use crate::client::FlowControlClient; + #[derive(Debug)] -pub(crate) struct ViewModel -where - F: FlowController + Send, -{ - flow_controller: Arc>, +pub(crate) struct ViewModel { + flow_controller: Arc>, tx_update: Sender, rx_event: Receiver, title: String, @@ -47,10 +43,10 @@ where // hybrid_linked_state: HybridState, } -impl ViewModel { +impl ViewModel { pub(crate) fn new( request: ViewRequest, - flow_controller: Arc>, + flow_controller: Arc>, rx_event: Receiver, tx_update: Sender, ) -> Self { @@ -82,11 +78,11 @@ impl ViewModel { async fn update_title(&mut self) { let mut title = match self.operation { - Operation::Create => { + Operation::PublicKeyCreate => { // TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from gettext("Create a passkey for %s1") } - Operation::Get => { + Operation::PublicKeyGet => { // TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from gettext("Use a passkey for %s1") } @@ -95,14 +91,14 @@ impl ViewModel { title = title.replace("%s1", &self.rp_id); let mut subtitle = match self.operation { - Operation::Create => { + Operation::PublicKeyCreate => { // TRANSLATORS: %s1 is the "relying party" (e.g.: domain name) where the request is coming from // TRANSLATORS: %s2 is the application name (e.g.: firefox) where the request is coming from, must be left untouched to make the name bold // TRANSLATORS: %i1 is the process ID of the requesting application // TRANSLATORS: %s3 is the absolute path (think: /usr/bin/firefox) of the requesting application gettext("\"%s2\" (process ID: %i1, binary: %s3) is asking to create a credential to register at \"%s1\". Only proceed if you trust this process.") } - Operation::Get => { + Operation::PublicKeyGet => { // TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from // TRANSLATORS: %s2 is the application name (e.g.: firefox) where the request is coming from, must be left untouched to make the name bold // TRANSLATORS: %i1 is the process ID of the requesting application @@ -163,15 +159,15 @@ impl ViewModel { match device.transport { Transport::Usb => { let mut cred_service = self.flow_controller.lock().await; - (*cred_service).get_usb_credential().await.unwrap(); + (*cred_service).discover_usb_authenticators().await.unwrap(); } Transport::Nfc => { let mut cred_service = self.flow_controller.lock().await; - (*cred_service).get_nfc_credential().await.unwrap(); + (*cred_service).discover_nfc_authenticators().await.unwrap(); } Transport::HybridQr => { - let mut cred_service = self.flow_controller.lock().await; - cred_service.get_hybrid_credential().await.unwrap(); + let cred_service = self.flow_controller.lock().await; + cred_service.discover_hybrid_authenticators().await.unwrap(); } _ => { todo!() @@ -234,6 +230,10 @@ impl ViewModel { break; } + // TODO: Add this event + // Event::Background(BackgroundEvent::DevicesUpdated(devices)) => { + // self.update_devices(devices).await + // } Event::Background(BackgroundEvent::UsbStateChanged(state)) => { match state { UsbState::Connected => { diff --git a/credentialsd-ui/src/main.rs b/credentialsd-ui/src/main.rs index 701e0ba..6aa2aa6 100644 --- a/credentialsd-ui/src/main.rs +++ b/credentialsd-ui/src/main.rs @@ -6,7 +6,7 @@ mod gui; use std::error::Error; -use crate::{client::DbusCredentialClient, dbus::UiControlService}; +use crate::dbus::{CredentialPortalBackend, UiControlService}; fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); @@ -19,18 +19,20 @@ async fn run() -> Result<(), Box> { let (request_tx, request_rx) = async_std::channel::bounded(2); // this allows the D-Bus service to signal to the GUI to draw a window for // executing the credential flow. - let client_conn = zbus::connection::Builder::session()?.build().await?; - let cred_client = DbusCredentialClient::new(client_conn); - let _handle = gui::start_gui_thread(request_rx, cred_client)?; + let _handle = gui::start_gui_thread(request_rx)?; println!(" ✅"); print!("Starting UI Control listener...\t"); - let interface = UiControlService { request_tx }; + let interface = UiControlService { + request_tx: request_tx.clone(), + }; + let portal_backend_interface = CredentialPortalBackend { request_tx }; let path = "/xyz/iinuwa/credentialsd/UiControl"; let service = "xyz.iinuwa.credentialsd.UiControl"; let _server_conn = zbus::connection::Builder::session()? .name(service)? .serve_at(path, interface)? + .serve_at("/org/freedesktop/portal/desktop", portal_backend_interface)? .build() .await?; println!(" ✅"); diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 1ab7979..92c5c12 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -3,26 +3,22 @@ pub mod nfc; pub mod usb; use std::{ - error::Error, fmt::Debug, - future::Future, pin::Pin, sync::{Arc, Mutex}, task::Poll, }; +use async_trait::async_trait; use futures_lite::{FutureExt, Stream, StreamExt}; use libwebauthn::{ self, ops::webauthn::{GetAssertionResponse, MakeCredentialResponse}, }; use nfc::{NfcEvent, NfcHandler, NfcState, NfcStateInternal}; -use tokio::sync::oneshot::Sender; +use tokio::sync::oneshot; -use credentialsd_common::{ - model::{Device, Error as CredentialServiceError, Operation, RequestingApplication, Transport}, - server::{RequestId, ViewRequest, WindowHandle}, -}; +use credentialsd_common::model::{Device, Error as CredentialServiceError, RequestId, Transport}; use crate::{ credential_service::{hybrid::HybridEvent, usb::UsbEvent}, @@ -36,18 +32,10 @@ use self::{ pub use usb::UsbState; -/// Used by the credential service to control the UI. -pub trait UiController { - fn launch_ui( - &self, - request: ViewRequest, - ) -> impl Future>> + Send; -} - #[derive(Debug)] struct RequestContext { request: CredentialRequest, - response_channel: Sender>, + response_channel: oneshot::Sender>, request_id: RequestId, } @@ -61,106 +49,76 @@ impl RequestContext { } } +/// Manages request to authenticator devices. +#[async_trait] +pub trait ManageDevice { + async fn init_request( + &self, + request: &CredentialRequest, + tx: oneshot::Sender>, + ) -> Result; + async fn cancel_request(&self, request_id: RequestId); + async fn get_available_public_key_devices(&self) -> Result, ()>; + async fn get_hybrid_credential( + &self, + ) -> Pin + Send + 'static>>; + async fn get_nfc_credential(&self) -> Pin + Send + 'static>>; + async fn get_usb_credential(&self) -> Pin + Send + 'static>>; +} + #[derive(Debug)] -pub struct CredentialService { +pub struct CredentialService { /// Current request and channel to respond to caller. ctx: Arc>>, - hybrid_handler: H, - usb_handler: U, - nfc_handler: N, - - ui_control_client: Arc, + hybrid_handler: Mutex, + nfc_handler: Mutex, + usb_handler: Mutex, } -impl< - H: HybridHandler + Debug, - U: UsbHandler + Debug, - N: NfcHandler + Debug, - UC: UiController + Debug, - > CredentialService +impl + CredentialService { - pub fn new( - hybrid_handler: H, - usb_handler: U, - nfc_handler: N, - ui_control_client: Arc, - ) -> Self { + pub fn new(hybrid_handler: H, nfc_handler: N, usb_handler: U) -> Self { Self { ctx: Arc::new(Mutex::new(None)), - hybrid_handler, - usb_handler, - nfc_handler, - - ui_control_client, + hybrid_handler: Mutex::new(hybrid_handler), + nfc_handler: Mutex::new(nfc_handler), + usb_handler: Mutex::new(usb_handler), } } +} - pub async fn init_request( +#[async_trait] +impl ManageDevice + for CredentialService +{ + async fn init_request( &self, request: &CredentialRequest, - requesting_app: Option, - window_handle: Option, - tx: Sender>, - ) { - let request_id = { - let mut cred_request = self.ctx.lock().unwrap(); - if cred_request.is_some() { - tx.send(Err(CredentialServiceError::Internal( - "Already a request in progress.".to_string(), - ))) - .expect("Send to local receiver to succeed"); - return; - } else { - let request_id: RequestId = rand::random(); - let ctx = RequestContext { - request: request.clone(), - response_channel: tx, - request_id, - }; - _ = cred_request.insert(ctx); - request_id - } - }; - let operation = match &request { - CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::Create, - CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, - }; - let rp_id = match &request { - CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), - CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), - }; - let initial_devices = self - .get_available_public_key_devices() - .await - .unwrap_or_default(); - let view_request = ViewRequest { - operation, - id: request_id, - rp_id, - initial_devices, - requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer - window_handle: window_handle.into(), - }; - - let launch_ui_response = self - .ui_control_client - .launch_ui(view_request) - .await - .map_err(|err| err.to_string()); - if let Err(err) = launch_ui_response { - tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); - let err = Err(CredentialServiceError::Internal(err)); - let ctx = self.ctx.lock().unwrap().take().unwrap(); - ctx.response_channel - .send(err) - .expect("Request handler to be listening"); + tx: oneshot::Sender>, + ) -> Result { + let mut cred_request = self.ctx.lock().unwrap(); + if cred_request.is_some() { + Err(CredentialServiceError::Internal( + "Already a request in progress.".to_string(), + )) + } else { + let request_id: RequestId = rand::random(); + // TODO: Spawn a task here that will listen to the signals from ui_control_client. + // Move the get_*_credential(), etc. from gateway to here. + let ctx = RequestContext { + request: request.clone(), + response_channel: tx, + request_id, + }; + _ = cred_request.insert(ctx); + Ok(request_id) } - tracing::debug!("Finished setting up request {request_id}"); } - pub async fn cancel_request(&self, request_id: RequestId) { + async fn cancel_request(&self, request_id: RequestId) { let mut guard = self.ctx.lock().expect("Lock to be taken"); if let Some(ctx) = guard.take_if(|ctx| ctx.request_id == request_id) { if request_id == ctx.request_id { @@ -178,7 +136,7 @@ impl< } } - pub async fn get_available_public_key_devices(&self) -> Result, ()> { + async fn get_available_public_key_devices(&self) -> Result, ()> { // We create the list new for each call, in case someone plugs in // an NFC-reader in the middle of an auth-flow let mut devices = vec![ @@ -200,12 +158,12 @@ impl< Ok(devices) } - pub fn get_hybrid_credential( + async fn get_hybrid_credential( &self, ) -> Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); if let Some(RequestContext { ref request, .. }) = *guard { - let stream = self.hybrid_handler.start(request); + let stream = self.hybrid_handler.lock().unwrap().start(request); let ctx = self.ctx.clone(); Box::pin(HybridStateStream { inner: stream, ctx }) } else { @@ -216,10 +174,10 @@ impl< } } - pub fn get_usb_credential(&self) -> Pin + Send + 'static>> { + async fn get_usb_credential(&self) -> Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); if let Some(RequestContext { ref request, .. }) = *guard { - let stream = self.usb_handler.start(request); + let stream = self.usb_handler.lock().unwrap().start(request); let ctx = self.ctx.clone(); Box::pin(UsbStateStream { inner: stream, ctx }) } else { @@ -230,10 +188,10 @@ impl< } } - pub fn get_nfc_credential(&self) -> Pin + Send + 'static>> { + async fn get_nfc_credential(&self) -> Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); if let Some(RequestContext { ref request, .. }) = *guard { - let stream = self.nfc_handler.start(request); + let stream = self.nfc_handler.lock().unwrap().start(request); let ctx = self.ctx.clone(); Box::pin(NfcStateStream { inner: stream, ctx }) } else { @@ -403,7 +361,7 @@ mod test { use super::{ hybrid::{test::DummyHybridHandler, HybridStateInternal}, nfc::InProcessNfcHandler, - AuthenticatorResponse, CredentialService, + AuthenticatorResponse, CredentialService, ManageDevice, }; #[test] @@ -426,25 +384,25 @@ mod test { ]); let usb_handler = InProcessUsbHandler {}; let nfc_handler = InProcessNfcHandler {}; - let (ui_server, ui_client) = DummyUiServer::new(Vec::new()); + let (ui_server, _ui_client) = DummyUiServer::new(Vec::new()); let ui_server = Arc::new(ui_server); let user = ui_server.clone(); let cred_service = Arc::new(AsyncMutex::new(CredentialService::new( hybrid_handler, - usb_handler, nfc_handler, - Arc::new(ui_client), + usb_handler, ))); let (mut flow_server, flow_client) = DummyFlowServer::new(cred_service.clone()); ui_server.init(flow_client).await; tokio::spawn(async move { ui_server.run().await }); tokio::spawn(async move { flow_server.run().await }); - cred_service + _ = cred_service .lock() .await - .init_request(&request, None, None, request_tx) - .await; + .init_request(&request, request_tx) + .await + .unwrap(); user.request_hybrid_credential().await; tokio::time::timeout(Duration::from_secs(5), request_rx) .await @@ -458,7 +416,7 @@ mod test { let challenge = "Ox0AXQz7WUER7BGQFzvVrQbReTkS3sepVGj26qfUhhrWSarkDbGF4T4NuCY1aAwHYzOzKMJJ2YRSatetl0D9bQ"; let origin = NavigationContext::SameOrigin("https://webauthn.io".parse().unwrap()); let client_data_json = - webauthn::format_client_data_json(Operation::Create, challenge, &origin); + webauthn::format_client_data_json(Operation::PublicKeyCreate, challenge, &origin); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); let make_request = MakeCredentialRequest { hash: client_data_hash, diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index a6ed555..39b6f39 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -1,14 +1,16 @@ //! This module implements the service to allow the user to control the flow of //! the credential request through the trusted UI. +use std::sync::Mutex; use std::{collections::VecDeque, fmt::Debug, sync::Arc}; use async_trait::async_trait; use credentialsd_common::model::{ - BackgroundEvent, Device, Error as CredentialServiceError, RequestingApplication, WebAuthnError, + BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, Operation, + PortalBackendOptions, RequestId, RequestingApplication, WebAuthnError, }; -use credentialsd_common::server::{RequestId, WindowHandle}; -use futures_lite::StreamExt; +use credentialsd_common::server::{ViewRequest, WindowHandle}; +use futures_lite::{Stream, StreamExt}; use tokio::sync::oneshot; use tokio::{ sync::{ @@ -24,42 +26,36 @@ use zbus::{ ObjectServer, }; +use crate::credential_service::ManageDevice; +use crate::dbus::ui_control::Flow; +use crate::dbus::UiControlServiceClient; use crate::{ - credential_service::{ - hybrid::{HybridHandler, HybridState}, - nfc::{NfcHandler, NfcState}, - usb::UsbHandler, - CredentialService, UiController, UsbState, - }, + credential_service::{hybrid::HybridState, nfc::NfcState, UsbState}, + dbus::ui_control::UiController, model::{CredentialRequest, CredentialResponse}, }; pub const SERVICE_PATH: &str = "/xyz/iinuwa/credentialsd/FlowControl"; pub const SERVICE_NAME: &str = "xyz.iinuwa.credentialsd.FlowControl"; -pub async fn start_flow_control_service< - H: HybridHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, - N: NfcHandler + Debug + Send + Sync + 'static, - UC: UiController + Debug + Send + Sync + 'static, ->( - credential_service: CredentialService, +pub async fn start_flow_control_service( + device_manager: M, ) -> zbus::Result<( Connection, Sender<( CredentialRequest, - Option, // Application name sending the request - Option, // Client window handle + RequestingApplication, + Option, // Client window handle oneshot::Sender>, )>, )> { - let svc = Arc::new(AsyncMutex::new(credential_service)); + let svc = Arc::new(AsyncMutex::new(device_manager)); let svc2 = svc.clone(); let conn = Builder::session()? .name(SERVICE_NAME)? .serve_at( SERVICE_PATH, - FlowControlService { + FlowControlDbusService { signal_state: Arc::new(AsyncMutex::new(SignalState::Idle)), svc, pin_tx: Arc::new(AsyncMutex::new(None)), @@ -71,22 +67,203 @@ pub async fn start_flow_control_service< )? .build() .await?; - let (initiator_tx, mut initiator_rx) = mpsc::channel(2); + let (initiator_tx, mut initiator_rx) = mpsc::channel::<( + CredentialRequest, + RequestingApplication, + Option, + oneshot::Sender>, + )>(2); + let conn2 = conn.clone(); tokio::spawn(async move { - let svc = svc2; while let Some((msg, requesting_app, window_handle, tx)) = initiator_rx.recv().await { - svc.lock() - .await - .init_request(&msg, requesting_app, window_handle, tx) - .await; + let svc = svc2.clone(); + let ui_control_client = UiControlServiceClient::new(conn2.clone()); + if let Err(_) = + tx.send(handle(svc, ui_control_client, msg, requesting_app, window_handle).await) + { + tracing::error!( + "Received response to credential request, but failed to forward it to gateway" + ); + } } }); Ok((conn, initiator_tx)) } -struct FlowControlService { +async fn handle( + svc: Arc>, + ui_control_client: UC, + msg: CredentialRequest, + requesting_app: RequestingApplication, + window_handle: Option, +) -> Result { + let (request_tx, request_rx) = oneshot::channel(); + let request_id = svc.lock().await.init_request(&msg, request_tx).await?; + let operation = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::PublicKeyCreate, + CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::PublicKeyGet, + }; + let rp_id = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), + CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), + }; + + // TODO: pass origin to this method so we can do this correctly. + let origin = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.origin.clone(), + CredentialRequest::GetPublicKeyCredentialRequest(r) => { + format!("https://{}", r.relying_party_id.clone()) + } + }; + + // TODO: pass top_origin to this method so we can do this correctly. + let top_origin = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => None, + CredentialRequest::GetPublicKeyCredentialRequest(r) => None, + }; + let initial_devices = svc + .lock() + .await + .get_available_public_key_devices() + .await + .unwrap_or_default(); + + let RequestingApplication { + path_or_app_id, + name: app_name, + pid: app_pid, + } = requesting_app; + let app_name = Option::from(app_name).unwrap_or_else(|| "TODO: Require app name".to_string()); + let flow = match ui_control_client + .initialize( + window_handle, + origin, + operation, + request_id, + initial_devices, + path_or_app_id.clone(), + app_name, + app_pid, + // TODO: Make path and app ID separate. + path_or_app_id, + PortalBackendOptions { + top_origin: top_origin.into(), + rp_id: Some(rp_id).into(), + }, + ) + .await + { + Ok(rx) => rx, + Err(err) => { + tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); + return Err(CredentialServiceError::Internal(err.to_string())); + } + }; + tokio::spawn(async move { + let client_pin_tx: Arc>>> = Arc::new(Mutex::new(None)); + let cred_selector_tx = Arc::new(Mutex::new(None)); + while let Some(ui_request) = flow.receive_ui_event().await { + match ui_request { + BackendRequest::StartHybridDiscovery => { + let stream = svc + .lock() + .await + .get_hybrid_credential() + .await + .map(|state| BackgroundEvent::HybridQrStateChanged(state.into())); + let flow = flow.clone(); + forward_background_event_stream(flow, stream); + } + BackendRequest::StartNfcDiscovery => { + let stream = svc + .lock() + .await + .get_nfc_credential() + .await + .map(|state| BackgroundEvent::NfcStateChanged(state.into())); + let flow = flow.clone(); + forward_background_event_stream(flow, stream); + } + BackendRequest::StartUsbDiscovery => { + let client_pin_tx = client_pin_tx.clone(); + let cred_selector_tx = cred_selector_tx.clone(); + let stream = + svc.lock() + .await + .get_usb_credential() + .await + .map(move |usb_state| { + match &usb_state { + UsbState::NeedsPin { pin_tx, .. } => { + *client_pin_tx.lock().unwrap() = Some(pin_tx.clone()); + } + UsbState::SelectingCredential { cred_tx, .. } => { + *cred_selector_tx.lock().unwrap() = Some(cred_tx.clone()); + } + _ => {} + } + BackgroundEvent::UsbStateChanged(usb_state.into()) + }); + let flow = flow.clone(); + forward_background_event_stream(flow, stream); + } + BackendRequest::EnterClientPin(pin) => { + let tx = { client_pin_tx.lock().unwrap().take() }; + if let Some(tx) = tx { + if tx.send(pin).await.is_err() { + tracing::error!("Failed to send client PIN to device"); + } + } else { + tracing::error!( + "Invalid state: received a client PIN with no pending request." + ); + } + } + BackendRequest::SelectCredential(id) => { + let tx = { cred_selector_tx.lock().unwrap().take() }; + if let Some(tx) = tx { + if tx.send(id).await.is_err() { + tracing::error!("Failed to send credential selection to device"); + } + } else { + tracing::error!( + "Invalid state: received a credential selection ID with no pending request." + ); + } + } + BackendRequest::CancelRequest => { + tracing::debug!(%request_id, "Cancelling request"); + svc.lock().await.cancel_request(request_id).await; + } + } + } + }); + tracing::debug!("Finished setting up request {request_id}"); + let cred_response = request_rx + .await + .expect("Credential service not to drop request channel before responding."); + let f = cred_response.map_err(|err| err.into()); + f +} + +fn forward_background_event_stream( + flow: Flow, + mut stream: impl Stream + Send + Unpin + 'static, +) { + tokio::spawn(async move { + while let Some(event) = stream.next().await { + let send_result = flow.send_state_update(event).await; + if send_result.is_err() { + tracing::error!("Failed to send state update event to backend. Stopping flow"); + break; + } + } + }); +} + +struct FlowControlService { + svc: Arc>, signal_state: Arc>, - svc: Arc>>, pin_tx: Arc>>>, cred_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -94,6 +271,23 @@ struct FlowControlService>>, } +impl FlowControlService { + fn send_update(&self) {} +} + +struct FlowControlDbusService { + svc: Arc>, + + signal_state: Arc>, + + cred_tx: Arc>>>, + pin_tx: Arc>>>, + + hybrid_event_forwarder_task: Arc>>, + nfc_event_forwarder_task: Arc>>, + usb_event_forwarder_task: Arc>>, +} + /// The following methods are for communication between the [trusted] /// UI and the credential service, and should not be called by arbitrary /// clients. @@ -105,12 +299,9 @@ struct FlowControlService FlowControlService +impl FlowControlDbusService where - H: HybridHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, - N: NfcHandler + Debug + Send + Sync + 'static, - UC: UiController + Debug + Send + Sync + 'static, + M: ManageDevice + Debug + Send + Sync + 'static, { async fn subscribe( &self, @@ -148,11 +339,11 @@ where #[zbus(object_server)] object_server: &ObjectServer, ) -> fdo::Result<()> { let svc = self.svc.lock().await; - let mut stream = svc.get_hybrid_credential(); + let mut stream = svc.get_hybrid_credential().await; let signal_state = self.signal_state.clone(); let object_server = object_server.clone(); let task = tokio::spawn(async move { - let interface: zbus::Result>> = + let interface: zbus::Result>> = object_server.interface(SERVICE_PATH).await; let emitter = match interface { @@ -189,13 +380,13 @@ where &self, #[zbus(object_server)] object_server: &ObjectServer, ) -> fdo::Result<()> { - let mut stream = self.svc.lock().await.get_usb_credential(); + let mut stream = self.svc.lock().await.get_usb_credential().await; let usb_pin_tx = self.pin_tx.clone(); let usb_cred_tx = self.cred_tx.clone(); let signal_state = self.signal_state.clone(); let object_server = object_server.clone(); let task = tokio::spawn(async move { - let interface: zbus::Result>> = + let interface: zbus::Result>> = object_server.interface(SERVICE_PATH).await; let emitter = match interface { @@ -239,13 +430,13 @@ where &self, #[zbus(object_server)] object_server: &ObjectServer, ) -> fdo::Result<()> { - let mut stream = self.svc.lock().await.get_nfc_credential(); + let mut stream = self.svc.lock().await.get_nfc_credential().await; let nfc_pin_tx = self.pin_tx.clone(); let nfc_cred_tx = self.cred_tx.clone(); let signal_state = self.signal_state.clone(); let object_server = object_server.clone(); let task = tokio::spawn(async move { - let interface: zbus::Result>> = + let interface: zbus::Result>> = object_server.interface(SERVICE_PATH).await; let emitter = match interface { @@ -347,7 +538,7 @@ enum SignalState { pub trait CredentialRequestController { async fn request_credential( &self, - requesting_app: Option, + requesting_app: RequestingApplication, request: CredentialRequest, window_handle: Option, ) -> Result; @@ -356,8 +547,8 @@ pub trait CredentialRequestController { pub struct CredentialRequestControllerClient { pub initiator: Sender<( CredentialRequest, - Option, // Application name sending the request - Option, // Client window handle, + RequestingApplication, // Application name sending the request + Option, // Client window handle, oneshot::Sender>, )>, } @@ -366,7 +557,7 @@ pub struct CredentialRequestControllerClient { impl CredentialRequestController for CredentialRequestControllerClient { async fn request_credential( &self, - requesting_app: Option, + requesting_app: RequestingApplication, request: CredentialRequest, window_handle: Option, ) -> Result { @@ -403,18 +594,12 @@ pub mod test { use credentialsd_common::{ client::FlowController, - model::{BackgroundEvent, Device}, - server::RequestId, + model::{BackgroundEvent, Device, RequestId}, }; use futures_lite::{Stream, StreamExt}; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; - use crate::credential_service::{ - hybrid::{HybridHandler, HybridState}, - nfc::{NfcHandler, NfcState}, - usb::UsbHandler, - CredentialService, UiController, UsbState, - }; + use crate::credential_service::{hybrid::HybridState, nfc::NfcState, ManageDevice, UsbState}; #[allow(clippy::enum_variant_names)] #[derive(Debug)] @@ -544,15 +729,12 @@ pub mod test { } #[derive(Debug)] - pub struct DummyFlowServer + pub struct DummyFlowServer where - H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - UC: UiController + Debug + Send + Sync, + M: ManageDevice, { rx: mpsc::Receiver<(DummyFlowRequest, oneshot::Sender)>, - svc: Arc>>, + svc: Arc>, bg_event_tx: Option>, pin_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -560,13 +742,7 @@ pub mod test { hybrid_event_forwarder_task: Arc>>, } - impl< - H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - UC: UiController + Debug + Send + Sync, - > DummyFlowServer - { + impl DummyFlowServer { /* async fn send(&self, request: ManagementRequest) -> Result { let (response_tx, response_rx) = oneshot::channel(); @@ -587,9 +763,7 @@ pub mod test { } } */ - pub fn new( - svc: Arc>>, - ) -> (Self, DummyFlowClient) { + pub fn new(svc: Arc>) -> (Self, DummyFlowClient) { let (request_tx, request_rx) = mpsc::channel(32); let server = Self { rx: request_rx, @@ -652,7 +826,7 @@ pub mod test { async fn get_hybrid_credential(&mut self) -> Result<(), ()> { let svc = self.svc.lock().await; - let mut stream = svc.get_hybrid_credential(); + let mut stream = svc.get_hybrid_credential().await; tracing::debug!(target: "DummyFlowServer", "Subscribing to hybrid credential state changes"); if let Some(tx_weak) = self.bg_event_tx.as_ref().map(|t| t.clone().downgrade()) { let task = tokio::spawn(async move { @@ -694,7 +868,7 @@ pub mod test { } async fn get_usb_credential(&mut self) -> Result<(), ()> { - let mut stream = self.svc.lock().await.get_usb_credential(); + let mut stream = self.svc.lock().await.get_usb_credential().await; if let Some(tx_weak) = self.bg_event_tx.as_ref().map(|t| t.clone().downgrade()) { let usb_pin_tx = self.pin_tx.clone(); let task = tokio::spawn(async move { @@ -733,7 +907,7 @@ pub mod test { } async fn get_nfc_credential(&mut self) -> Result<(), ()> { - let mut stream = self.svc.lock().await.get_nfc_credential(); + let mut stream = self.svc.lock().await.get_nfc_credential().await; if let Some(tx_weak) = self.bg_event_tx.as_ref().map(|t| t.clone().downgrade()) { let nfc_pin_tx = self.pin_tx.clone(); let task = tokio::spawn(async move { @@ -802,13 +976,7 @@ pub mod test { } } - impl< - H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - UC: UiController + Debug + Send + Sync, - > Drop for DummyFlowServer - { + impl Drop for DummyFlowServer { fn drop(&mut self) { if let Some(task) = self.usb_event_forwarder_task.lock().unwrap().take() { task.abort(); diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index c3eb35c..77ad4cf 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -1,12 +1,44 @@ //! These methods are called by the flow controller to launch the trusted UI. -use std::error::Error; +use std::{error::Error, future::Future, sync::Arc}; -use zbus::{fdo, proxy, Connection}; +use futures_lite::StreamExt; +use tokio::sync::{ + mpsc::{self, Receiver}, + Mutex as AsyncMutex, +}; +use zbus::{ + fdo, proxy, + zvariant::{ObjectPath, Optional, OwnedObjectPath}, + Connection, +}; -use credentialsd_common::server::{RequestId, ViewRequest}; +use credentialsd_common::{ + model::{BackendRequest, BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId}, + server::{ViewRequest, WindowHandle}, +}; -use crate::credential_service::UiController; +/// Used by the credential service to control the UI. +pub trait UiController { + fn launch_ui( + &self, + request: ViewRequest, + ) -> impl Future>> + Send; + + fn initialize( + &self, + parent_window: Option, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> impl Future>> + Send; +} #[proxy( gen_blocking = false, @@ -19,6 +51,67 @@ trait UiControlService { fn cancel_request(&self, request_id: RequestId) -> fdo::Result<()>; } +#[proxy( + gen_blocking = false, + interface = "org.freedesktop.impl.portal.experimental.Credential", + default_service = "xyz.iinuwa.credentialsd.UiControl", + default_path = "/org/freedesktop/portal/desktop" +)] +trait UiControlService2 { + fn initialize( + &self, + parent_window: Optional, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> fdo::Result; +} + +#[derive(Clone, Debug)] +pub struct Flow { + proxy: Arc>, + ui_events_rx: Arc>>, +} + +impl Flow { + pub async fn receive_ui_event(&self) -> Option { + self.ui_events_rx.lock().await.recv().await + } + + pub async fn send_state_update(&self, event: BackgroundEvent) -> Result<(), ()> { + if let Err(err) = self.proxy.notify_state_changed(event).await { + match err { + fdo::Error::UnknownObject(description) => { + tracing::error!(%description, "Flow D-Bus object no longer available at path"); + } + _ => tracing::error!(%err, "Failed to send update to backend"), + } + return Err(()); + } + Ok(()) + } +} +#[proxy( + gen_blocking = false, + interface = "org.freedesktop.impl.portal.experimental.Credential.FlowObject", + default_service = "xyz.iinuwa.credentialsd.UiControl" +)] +trait FlowObject { + async fn start(&self) -> fdo::Result<()>; + async fn notify_state_changed(&self, event: BackgroundEvent) -> fdo::Result<()>; + + async fn cancel(&self) -> fdo::Result<()>; + + #[zbus(signal)] + async fn user_interacted(&self, update: BackendRequest) -> zbus::Result<()>; +} + #[derive(Debug)] pub struct UiControlServiceClient { conn: Connection, @@ -32,7 +125,23 @@ impl UiControlServiceClient { async fn proxy(&self) -> Result, zbus::Error> { UiControlServiceProxy::new(&self.conn).await } + + async fn proxy2(&self) -> Result, zbus::Error> { + UiControlService2Proxy::new(&self.conn).await + } + + async fn request_proxy( + &self, + request_id: RequestId, + ) -> Result, zbus::Error> { + let object_path = ObjectPath::from_string_unchecked(format!( + "/org/freedesktop/portal/Credential/{}", + request_id + )); + FlowObjectProxy::new(&self.conn, object_path).await + } } + impl UiController for UiControlServiceClient { async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { self.proxy() @@ -41,11 +150,73 @@ impl UiController for UiControlServiceClient { .await .map_err(|err| err.into()) } + + async fn initialize( + &self, + parent_window: Option, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> Result> { + let path = self + .proxy2() + .await? + .initialize( + parent_window.into(), + origin, + r#type, + request_id, + devices, + app_id, + app_display_name, + app_pid, + app_path, + options, + ) + .await?; + tracing::debug!(?path, "Path initialized"); + let flow_object = FlowObjectProxy::new(&self.conn, path).await?; + let (from_ui_tx, from_ui_rx) = mpsc::channel(32); + let ui_event_stream = flow_object.receive_user_interacted().await?; + tokio::task::spawn(async move { + _ = forward_ui_events(ui_event_stream, from_ui_tx).await; + }); + // Mark as ready to receive messages. + flow_object.start().await?; + Ok(Flow { + proxy: Arc::new(flow_object), + ui_events_rx: Arc::new(AsyncMutex::new(from_ui_rx)), + }) + } +} + +async fn forward_ui_events( + mut ui_event_stream: UserInteractedStream, + tx: mpsc::Sender, +) -> Result<(), Box> { + tracing::debug!("Listening for events from UI"); + while let Some(signal) = ui_event_stream.next().await { + tracing::trace!(?signal, "Received event from UI"); + let event = signal.args()?.update; + if let Err(_) = tx.send(event).await { + tracing::trace!("credential service event listener stopped listening for UI events. Ending event stream listener"); + break; + } + } + tracing::trace!("Stopping UI event forwarder"); + Ok(()) } #[cfg(test)] pub mod test { use std::{ + error::Error, fmt::Debug, sync::{ atomic::{AtomicBool, Ordering}, @@ -54,7 +225,9 @@ pub mod test { }; use credentialsd_common::{ - client::FlowController, model::BackgroundEvent, server::ViewRequest, + client::FlowController, + model::{BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId}, + server::{ViewRequest, WindowHandle}, }; use futures_lite::StreamExt; use tokio::sync::{ @@ -62,6 +235,8 @@ pub mod test { Mutex as AsyncMutex, Notify, }; + use crate::dbus::ui_control::Flow; + use super::UiController; #[derive(Debug)] @@ -70,7 +245,7 @@ pub mod test { } impl UiController for DummyUiClient { - async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { + async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { tracing::debug!( target: "DummyUiClient", "Sending launch_ui() request" @@ -82,6 +257,22 @@ pub mod test { ); Ok(()) } + + async fn initialize( + &self, + _parent_window: Option, + _origin: String, + _type: Operation, + _request_id: RequestId, + _devices: Vec, + _app_id: String, + _app_display_name: String, + _app_pid: u32, + _app_path: String, + _options: PortalBackendOptions, + ) -> Result> { + unimplemented!() + } } pub struct DummyUiServer @@ -211,7 +402,7 @@ pub mod test { ); } - async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { + async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { tracing::debug!( target: "DummyUiServer", "Received launch_ui() request" diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 8bf7e7d..8652dea 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -1,31 +1,32 @@ -use std::{os::fd::AsRawFd, sync::Arc}; +use std::{collections::HashMap, fmt::Display, os::fd::AsRawFd, sync::Arc}; +use serde::{ser::SerializeTuple, Deserialize, Serialize}; use tokio::sync::Mutex as AsyncMutex; use zbus::{ fdo, interface, message::Header, names::{BusName, UniqueName}, - zvariant::Optional, + zvariant::{DeserializeDict, Optional, Type, Value}, Connection, DBusError, }; use credentialsd_common::{ model::{GetClientCapabilitiesResponse, RequestingApplication, WebAuthnError}, server::{ - CreateCredentialRequest, CreateCredentialResponse, GetCredentialRequest, - GetCredentialResponse, WindowHandle, + CreateCredentialRequest, CreateCredentialResponse, CreatePublicKeyCredentialRequest, + GetCredentialRequest, GetCredentialResponse, GetPublicKeyCredentialRequest, WindowHandle, }, }; -use crate::webauthn::{AppId, NavigationContext, Origin}; +use crate::webauthn::{AppId, Origin}; use super::{ - check_origin_from_app, check_origin_from_privileged_client, get_app_info_from_pid, - GatewayService, + check_origin_from_app, get_app_info_from_pid, GatewayService, RequestContext, RequestKind, }; pub const SERVICE_NAME: &str = "xyz.iinuwa.credentialsd.Credentials"; pub const SERVICE_PATH: &str = "/xyz/iinuwa/credentialsd/Credentials"; +pub const PORTAL_SERVICE_PATH: &str = "/org/freedesktop/portal/desktop"; pub(super) async fn start_dbus_gateway( svc: Arc>, @@ -41,11 +42,17 @@ pub(super) async fn start_dbus_gateway( gateway_service: svc.clone(), }, )? + .serve_at( + PORTAL_SERVICE_PATH, + CredentialPortalGateway { + gateway_service: svc, + }, + )? .build() .await } -/// Struct to hold state for the D-Bus interface. +/// Struct to hold state for the privileged D-Bus interface. struct CredentialGateway { /// Service responsible for processing credential requests. gateway_service: Arc>, @@ -66,44 +73,19 @@ impl CredentialGateway { parent_window: Optional, request: CreateCredentialRequest, ) -> Result { - // TODO: Add authorization check for privileged client. - let top_origin = if request.is_same_origin.unwrap_or_default() { - None - } else { - // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. - // We should still reject cross-origin requests for conditionally-mediated requests. - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError.into()); - }; - let Some(origin) = request - .origin - .as_ref() - .map(|o| { - o.parse::().map_err(|_| { - tracing::warn!("Invalid origin specified: {:?}", request.origin); - Error::SecurityError - }) - }) - .transpose()? - else { - tracing::warn!( - "Caller requested implicit origin, which is not yet implemented. Rejecting request." - ); - return Err(Error::SecurityError); - }; - let request_environment = check_origin_from_privileged_client(origin, top_origin)?; - // Find out where this request is coming from (which application is requesting this) - let requesting_app = query_connection_peer_binary(header, connection).await; + let context = extract_client_details( + header, + connection, + request.origin.as_ref().cloned(), + request.is_same_origin.unwrap_or_default(), + ) + .await?; + let response = self .gateway_service .lock() .await - .handle_create_credential( - request, - request_environment, - requesting_app, - parent_window.into(), - ) + .handle_create_credential(request, context, parent_window.into()) .await?; Ok(response) } @@ -115,44 +97,19 @@ impl CredentialGateway { parent_window: Optional, request: GetCredentialRequest, ) -> Result { - // TODO: Add authorization check for privileged client. - let top_origin = if request.is_same_origin.unwrap_or_default() { - None - } else { - // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. - // We should still reject cross-origin requests for conditionally-mediated requests. - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError.into()); - }; - let Some(origin) = request - .origin - .as_ref() - .map(|o| { - o.parse::().map_err(|_| { - tracing::warn!("Invalid origin specified: {:?}", request.origin); - Error::SecurityError - }) - }) - .transpose()? - else { - tracing::warn!( - "Caller requested implicit origin, which is not yet implemented. Rejecting request." - ); - return Err(Error::SecurityError); - }; - let request_environment = check_origin_from_privileged_client(origin, top_origin)?; - // Find out where this request is coming from (which application is requesting this) - let requesting_app = query_connection_peer_binary(header, connection).await; + let context = extract_client_details( + header, + connection, + request.origin.as_ref().cloned(), + request.is_same_origin.unwrap_or_default(), + ) + .await?; + let response = self .gateway_service .lock() .await - .handle_get_credential( - request, - request_environment, - requesting_app, - parent_window.into(), - ) + .handle_get_credential(request, context, parent_window.into()) .await?; Ok(response) } @@ -167,6 +124,245 @@ impl CredentialGateway { } } +/// Returns contextual details about the client and the request needed for +/// authorization. +async fn extract_client_details( + header: Header<'_>, + connection: &Connection, + origin: Option, + is_same_origin: bool, +) -> Result { + let top_origin = if is_same_origin { + None + } else { + // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. + // We should still reject cross-origin requests for conditionally-mediated requests. + tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); + return Err(WebAuthnError::NotAllowedError.into()); + }; + /* + let top_origin = + top_origin.as_ref() + .map(|o| o.parse::()) + .transpose() + .map_err(|err| { + tracing::warn!(%err, "Invalid top origin specified: {:?}", client_details.top_origin); + WebAuthnError::SecurityError + })?; + */ + + let Some(origin) = origin.as_ref().cloned() else { + tracing::warn!( + "Caller requested implicit origin, which is not yet implemented. Rejecting request." + ); + return Err(Error::SecurityError); + }; + let origin = origin.parse::().map_err(|err| { + tracing::warn!(%err, "Invalid origin specified: {:?}", origin); + WebAuthnError::SecurityError + })?; + + // Find out where this request is coming from (which application is requesting this) + let requesting_app = query_connection_peer_binary(header, connection) + .await + .ok_or_else(|| { + tracing::error!("Could not retrieve client details from D-Bus connection"); + Error::SecurityError + })?; + Ok(RequestContext { + app_id: "xyz.iinuwa.credentialsd.CredentialGateway".parse().unwrap(), // hardcoding this for now; this will be obsolete soon + app_name: requesting_app.name.as_ref().unwrap().clone(), + pid: requesting_app.pid, + request_kind: RequestKind::Privileged { origin, top_origin }, + }) +} + +/// Struct to hold state for the portal D-Bus interface. +struct CredentialPortalGateway { + /// Service responsible for processing credential requests. + gateway_service: Arc>, +} + +/// These are public methods that can be called by arbitrary clients to begin a +/// credential flow. +/// +/// The D-Bus interface is responsible for authorizing the client and collecting +/// the contextual information about the client to pass onto the GatewayService +/// for evaluation. +#[interface(name = "org.freedesktop.handler.portal.experimental.Credential")] +impl CredentialPortalGateway { + #[zbus(out_args("response", "results"))] + async fn create_credential( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + parent_window: Optional, + origin: String, + cred_type: CredentialType, + options: CreateCredentialPortalOptions, + claimed_app_id: String, + claimed_app_display_name: Optional, + ) -> PortalResult { + let CreateCredentialPortalOptions { + activation_token: _, + top_origin, + public_key, + } = options; + + let request_json = match (&cred_type, public_key) { + (CredentialType::PublicKey, Some(json)) => json, + (CredentialType::PublicKey, None) => { + tracing::warn!("Client did not send `public_key` request with type `publicKey`"); + return Err(Error::TypeError).into(); + } + }; + + let app_validation_result = validate_app_details( + connection, + &header, + claimed_app_id, + claimed_app_display_name.into(), + origin.clone(), + top_origin.clone().into(), + ) + .await; + let context = match app_validation_result { + Ok(context) => context, + Err(err) => return Err(err).into(), + }; + + tracing::debug!( + ?context, + ?request_json, + ?parent_window, + "Received request for creating credential" + ); + + let request = CreateCredentialRequest { + origin: Some(origin.clone()), + is_same_origin: Some(top_origin.is_none()), + r#type: cred_type.to_string(), + public_key: Some(CreatePublicKeyCredentialRequest { request_json }), + }; + + let response = self + .gateway_service + .lock() + .await + .handle_create_credential(request, context, parent_window.into()) + .await + .map_err(Error::from); + + response.into() + } + + #[zbus(out_args("response", "results"))] + async fn get_credential( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + parent_window: Optional, + origin: String, + options: GetCredentialPortalOptions, + claimed_app_id: String, + claimed_app_display_name: Optional, + ) -> PortalResult { + let GetCredentialPortalOptions { + activation_token: _, + top_origin, + public_key, + } = options; + let app_validation_result = validate_app_details( + connection, + &header, + claimed_app_id, + claimed_app_display_name.into(), + origin.clone(), + top_origin.clone().into(), + ) + .await; + + let Some(request_json) = public_key else { + tracing::warn!("Client did not send parameters for any valid credential type."); + return Err(Error::TypeError).into(); + }; + + let context = match app_validation_result { + Ok(context) => context, + Err(err) => return Err(err).into(), + }; + + tracing::trace!( + ?context, + %request_json, + ?parent_window, + "Received request for retrieving credential" + ); + + let request = GetCredentialRequest { + origin: Some(origin), + is_same_origin: Some(top_origin.is_none()), + public_key: Some(GetPublicKeyCredentialRequest { request_json }), + }; + + let response = self + .gateway_service + .lock() + .await + .handle_get_credential(request, context, parent_window.into()) + .await + .map_err(Error::from); + response.into() + } +} + +#[derive(Debug, Deserialize, Type)] +#[zvariant(signature = "s")] +enum CredentialType { + #[serde(rename = "publicKey")] + PublicKey, +} + +impl Display for CredentialType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CredentialType::PublicKey => f.write_str("publicKey"), + } + } +} + +#[derive(Debug, DeserializeDict, Type)] +#[zvariant(signature = "dict")] +struct CreateCredentialPortalOptions { + /// A token that can be used to activate the UI window. + activation_token: Option, + + /// The top-level origin of the client window for cross-origin requests. + /// If omitted, denotes a same-origin request. + top_origin: Option, + + /// A string of JSON that corresponds to the WebAuthn + /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) + /// type. + public_key: Option, +} + +#[derive(Debug, DeserializeDict, Type)] +#[zvariant(signature = "dict")] +struct GetCredentialPortalOptions { + /// A token that can be used to activate the UI window. + activation_token: Option, + + /// The top-level origin of the client window for cross-origin requests. + /// If omitted, denotes a same-origin request. + top_origin: Option, + + /// A string of JSON that corresponds to the WebAuthn + /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) + /// type. + public_key: Option, +} + #[allow(clippy::enum_variant_names)] #[derive(DBusError, Debug)] #[zbus(prefix = "xyz.iinuwa.credentialsd")] @@ -224,14 +420,61 @@ impl From for Error { } } +#[repr(u32)] +#[derive(Serialize)] +enum PortalResponse { + Success = 0, + Cancelled = 1, + Other = 2, +} + +#[derive(Type)] +#[zvariant(signature = "ua{sv}")] +struct PortalResult { + inner: Result, +} + +impl Serialize for PortalResult +where + T: Serialize + Type, + E: std::error::Error, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_tuple(2)?; + match &self.inner { + Err(err) => { + map.serialize_element(&(PortalResponse::Other as u32))?; + map.serialize_element(&HashMap::<&str, Value<'_>>::from([( + "error", + Value::Str(err.to_string().into()), + )]))?; + } + Ok(response) => { + map.serialize_element(&(PortalResponse::Success as u32))?; + map.serialize_element(&response)?; + } + }; + map.end() + } +} + +impl From> for PortalResult { + fn from(value: Result) -> Self { + PortalResult { inner: value } + } +} + async fn validate_app_details( connection: &Connection, header: &Header<'_>, claimed_app_id: String, claimed_app_display_name: Option, - claimed_origin: Option, + claimed_origin: String, claimed_top_origin: Option, -) -> Result<(RequestingApplication, NavigationContext), Error> { +) -> Result { let Some(unique_name) = header.sender() else { return Err(Error::SecurityError); }; @@ -241,7 +484,10 @@ async fn validate_app_details( }; if claimed_app_id.is_empty() || !super::should_trust_app_id(pid).await { - tracing::warn!("App ID could not be determined. Rejecting request."); + tracing::warn!( + ?claimed_app_id, + "App ID could not be verified. Rejecting request." + ); return Err(Error::SecurityError); } // Now we can trust these app detail parameters. @@ -252,7 +498,11 @@ async fn validate_app_details( let display_name = claimed_app_display_name.unwrap_or_default(); // Verify that the origin is valid for the given app ID. - let claimed_origin = claimed_origin + let claimed_origin = claimed_origin.parse().map_err(|err| { + tracing::warn!(%err, "Invalid origin passed: {claimed_origin}"); + Error::SecurityError + })?; + let claimed_top_origin = claimed_top_origin .map(|o| { o.parse().map_err(|_| { tracing::warn!("Invalid origin passed: {o}"); @@ -260,25 +510,14 @@ async fn validate_app_details( }) }) .transpose()?; - let request_env = if let Some(claimed_origin) = claimed_origin { - let claimed_top_origin = claimed_top_origin - .map(|o| { - o.parse().map_err(|_| { - tracing::warn!("Invalid origin passed: {o}"); - Error::SecurityError - }) - }) - .transpose()?; - check_origin_from_app(&app_id, claimed_origin, claimed_top_origin)? - } else { - NavigationContext::SameOrigin(Origin::AppId(app_id)) - }; - let app_details = RequestingApplication { - name: Some(display_name).into(), - path_or_app_id: claimed_app_id, + let request_kind = check_origin_from_app(&app_id, claimed_origin, claimed_top_origin)?; + + Ok(RequestContext { + app_id, + app_name: display_name, pid, - }; - Ok((app_details, request_env)) + request_kind, + }) } async fn query_peer_pid_via_fdinfo( diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 6c06f70..f01a8b1 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -35,6 +35,38 @@ pub async fn start_gateway, + }, + /// Unprivileged clients may only set an origin, which will be verified + /// against a static list of allowed origins for the client. + Unprivileged(Origin), +} + +/// Details about the credential request and the client making it. +#[derive(Debug)] +struct RequestContext { + app_id: AppId, + app_name: String, + pid: u32, + request_kind: RequestKind, +} + +impl From for RequestingApplication { + fn from(value: RequestContext) -> Self { + RequestingApplication { + path_or_app_id: value.app_id.as_ref().to_string(), + name: Some(value.app_name).into(), + pid: value.pid, + } + } +} + /// Service responsible for processing credential requests received from various /// client interfaces. struct GatewayService { @@ -47,10 +79,11 @@ impl GatewayService { async fn handle_create_credential( &self, request: CreateCredentialRequest, - request_environment: NavigationContext, - requesting_app: Option, + context: RequestContext, parent_window: Option, ) -> Result { + let request_environment = validate_request(&context)?; + if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { // TODO: assert that RP ID is bound to origin: // - if RP ID is not set, set the RP ID to the origin's effective domain @@ -74,7 +107,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(requesting_app, cred_request, parent_window) + .request_credential(context.into(), cred_request, parent_window) .await?; if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { @@ -104,11 +137,12 @@ impl GatewayService { async fn handle_get_credential( &self, request: GetCredentialRequest, - request_environment: NavigationContext, - requesting_app: Option, + context: RequestContext, parent_window: Option, ) -> Result { - if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { + let request_environment = validate_request(&context)?; + + if request.public_key.is_some() { // Setup request // TODO: assert that RP ID is bound to origin: @@ -128,7 +162,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(requesting_app, cred_request, parent_window) + .request_credential(context.into(), cred_request, parent_window) .await?; if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { @@ -150,7 +184,7 @@ impl GatewayService { Err(WebAuthnError::NotAllowedError) } } else { - tracing::error!("Unknown credential type request: {}", request.r#type); + tracing::error!("Request did not match any known credential types. Supported types: [`public_key`]."); Err(WebAuthnError::TypeError) } } @@ -160,7 +194,7 @@ impl GatewayService { conditional_create: false, conditional_get: false, hybrid_transport: true, - passkey_platform_authenticator: false, + passkey_platform_authenticator: true, user_verifying_platform_authenticator: false, related_origins: false, signal_all_accepted_credentials: false, @@ -170,6 +204,29 @@ impl GatewayService { } } +/// Verifies that the calling client is able to request credentials for the +/// given origin, then returns the origin. +fn validate_request(context: &RequestContext) -> Result { + let request_environment = match &context.request_kind { + RequestKind::Privileged { origin, top_origin } => { + check_origin_from_privileged_client(origin, top_origin.as_ref())? + } + RequestKind::Unprivileged(origin) => { + let origin_allowed_for_app_id = true; + if origin_allowed_for_app_id { + NavigationContext::SameOrigin(origin.clone()) + } else { + tracing::warn!( + "App ID {:?} is not allowed for origin {origin}", + context.app_id + ); + return Err(WebAuthnError::SecurityError); + } + } + }; + Ok(request_environment) +} + fn get_app_info_from_pid(pid: u32) -> Option { // Get binary path via PID from /proc file-system // TODO: To be REALLY sure, we may want to look at /proc/PID/exe instead. It is a symlink to @@ -223,7 +280,7 @@ async fn should_trust_app_id(pid: u32) -> bool { return false; }; tracing::debug!( - "mount namespace:\n ours:\t{:?}\n theirs:\t{:?}", + "mount namespace:\n ours: {:?}\n theirs: {:?}", my_mnt_ns, peer_mnt_ns ); @@ -233,12 +290,14 @@ async fn should_trust_app_id(pid: u32) -> bool { } let Ok(exe_path) = tokio::fs::read_link(format!("/proc/{pid}/exe")).await else { + tracing::warn!("Cannot read executable name from procfs"); return false; }; // The target binaries are hard-coded to valid UTF-8, so it's acceptable to // lose some data here. let Some(exe_path) = exe_path.to_str() else { + tracing::warn!("Could not read executable path from procfs"); return false; }; tracing::debug!(?exe_path, %pid, "Found executable path:"); @@ -248,37 +307,61 @@ async fn should_trust_app_id(pid: u32) -> bool { } else { vec!["/usr/bin/xdg-desktop-portal".to_string()] }; - trusted_callers.as_slice().contains(&exe_path.to_string()) + tracing::debug!(?trusted_callers, %exe_path, "Testing whether request is from trusted caller"); + if !trusted_callers.as_slice().contains(&exe_path.to_string()) { + tracing::warn!(%exe_path, "Request received from untrusted caller"); + return false; + } else { + return true; + } } fn check_origin_from_app( app_id: &AppId, origin: Origin, top_origin: Option, -) -> Result { - let trusted_clients = [ - "org.mozilla.firefox", - "xyz.iinuwa.credentialsd.DemoCredentialsUi", - ]; - let is_privileged_client = trusted_clients.contains(&app_id.as_ref()); +) -> Result { + let is_privileged_client = { + let trusted_clients = [ + "org.mozilla.firefox", + "xyz.iinuwa.credentialsd.DemoCredentialsUi", + ]; + let mut privileged = trusted_clients.contains(&app_id.as_ref()); + if cfg!(debug_assertions) && !privileged { + let trusted_clients_env = std::env::var("CREDSD_TRUSTED_APP_IDS").unwrap_or_default(); + privileged = trusted_clients_env + .split(',') + .map(String::from) + .any(|c| app_id.as_ref() == c); + } + privileged + }; if is_privileged_client { - check_origin_from_privileged_client(origin, top_origin) + let (origin, top_origin) = + match check_origin_from_privileged_client(&origin, top_origin.as_ref())? { + NavigationContext::SameOrigin(origin) => (origin, None), + NavigationContext::CrossOrigin((origin, top_origin)) => (origin, Some(top_origin)), + }; + Ok(RequestKind::Privileged { origin, top_origin }) } else { - Ok(NavigationContext::SameOrigin(Origin::AppId(app_id.clone()))) + Ok(RequestKind::Unprivileged(origin)) } } fn check_origin_from_privileged_client( - origin: Origin, - top_origin: Option, + origin: &Origin, + top_origin: Option<&Origin>, ) -> Result { match (origin, top_origin) { - (origin @ Origin::Https { .. }, None) => Ok(NavigationContext::SameOrigin(origin)), + (origin @ Origin::Https { .. }, None) => Ok(NavigationContext::SameOrigin(origin.clone())), (origin @ Origin::Https { .. }, Some(top_origin @ Origin::Https { .. })) => { if origin == top_origin { - Ok(NavigationContext::SameOrigin(origin)) + Ok(NavigationContext::SameOrigin(origin.clone())) } else { - Ok(NavigationContext::CrossOrigin((origin, top_origin))) + Ok(NavigationContext::CrossOrigin(( + origin.clone(), + top_origin.clone(), + ))) } } _ => { @@ -297,7 +380,7 @@ mod test { use super::check_origin_from_privileged_client; fn check_same_origin(origin: &str) -> Result { let origin = origin.parse().unwrap(); - check_origin_from_privileged_client(origin, None) + check_origin_from_privileged_client(&origin, None) } #[test] diff --git a/credentialsd/src/gateway/util.rs b/credentialsd/src/gateway/util.rs index 57e1524..a367986 100644 --- a/credentialsd/src/gateway/util.rs +++ b/credentialsd/src/gateway/util.rs @@ -183,7 +183,8 @@ pub(super) fn create_credential_request_try_into_ctap2( .filter_map(|e| e.ok()) .collect() }); - let client_data_json = webauthn::format_client_data_json(Operation::Create, &challenge, origin); + let client_data_json = + webauthn::format_client_data_json(Operation::PublicKeyCreate, &challenge, origin); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); Ok(( MakeCredentialRequest { @@ -283,7 +284,7 @@ pub(super) fn get_credential_request_try_into_ctap2( } let client_data_json = - webauthn::format_client_data_json(Operation::Get, &options.challenge, request_env); + webauthn::format_client_data_json(Operation::PublicKeyGet, &options.challenge, request_env); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); // TODO: actually calculate correct effective domain, and use fallback to related origin requests to fill this in. For now, just default to origin. let user_verification = match options diff --git a/credentialsd/src/main.rs b/credentialsd/src/main.rs index ea920e8..4f0888e 100644 --- a/credentialsd/src/main.rs +++ b/credentialsd/src/main.rs @@ -36,9 +36,8 @@ async fn run() -> Result<(), Box> { let ui_controller = UiControlServiceClient::new(dbus_client_conn); let credential_service = CredentialService::new( InternalHybridHandler::new(), - InProcessUsbHandler {}, InProcessNfcHandler {}, - Arc::new(ui_controller), + InProcessUsbHandler {}, ); let (_flow_control_conn, initiator) = dbus::start_flow_control_service(credential_service).await?; diff --git a/credentialsd/src/webauthn.rs b/credentialsd/src/webauthn.rs index 905fccf..2a0ba0e 100644 --- a/credentialsd/src/webauthn.rs +++ b/credentialsd/src/webauthn.rs @@ -680,8 +680,8 @@ pub fn format_client_data_json( origin: &NavigationContext, ) -> String { let op_str = match op { - Operation::Create => "webauthn.create", - Operation::Get => "webauthn.get", + Operation::PublicKeyCreate => "webauthn.create", + Operation::PublicKeyGet => "webauthn.get", }; let mut client_data_json = format!( r#"{{"type":"{}","challenge":"{}","origin":"{}""#, @@ -746,7 +746,7 @@ impl FromStr for AppId { } /// The origin of the client for the request. -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub(crate) enum Origin { Https { host: String, port: Option }, AppId(AppId), @@ -868,7 +868,7 @@ mod tests { fn test_same_origin_client_data_json_str() { let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":false}"#; let json = format_client_data_json( - Operation::Create, + Operation::PublicKeyCreate, "abcd", &NavigationContext::SameOrigin("https://example.com".parse().unwrap()), ); @@ -879,7 +879,7 @@ mod tests { fn test_cross_origin_client_data_json_str() { let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":true,"topOrigin":"https://example.org"}"#; let json = format_client_data_json( - Operation::Create, + Operation::PublicKeyCreate, "abcd", &NavigationContext::CrossOrigin(( "https://example.com".parse().unwrap(), diff --git a/credentialsd/tests/dbus.rs b/credentialsd/tests/dbus.rs index 2b03aed..d41e4b0 100644 --- a/credentialsd/tests/dbus.rs +++ b/credentialsd/tests/dbus.rs @@ -1,3 +1,4 @@ +#![expect(unused)] #[rustfmt::skip] mod config; @@ -6,35 +7,6 @@ use std::collections::HashMap; use client::DbusClient; use zbus::zvariant::Value; -#[test] -fn test_client_capabilities() { - let client = DbusClient::new(); - let msg = client.call_method("GetClientCapabilities", &()).unwrap(); - let body = msg.body(); - let rsp: HashMap = body - .deserialize::>() - .unwrap() - .into_iter() - .map(|(k, v)| (k, v.try_into().unwrap())) - .collect(); - - let capabilities = HashMap::from([ - ("conditionalCreate", false), - ("conditionalGet", false), - ("hybridTransport", true), - ("passkeyPlatformAuthenticator", false), - ("userVerifyingPlatformAuthenticator", false), - ("relatedOrigins", false), - ("signalAllAcceptedCredentials", false), - ("signalCurrentUserDetails", false), - ("signalUnknownCredential", false), - ]); - for (key, expected) in capabilities.iter() { - let actual = rsp.get(*key).unwrap(); - assert_eq!(*expected, *actual); - } -} - mod client { use crate::config::{INTERFACE, PATH, SERVICE_DIR, SERVICE_NAME}; use gio::{TestDBus, TestDBusFlags}; diff --git a/demo_client/gui.py b/demo_client/gui.py index f9e01a7..e21c940 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 +from asyncio.futures import Future +import asyncio from contextlib import closing -import functools import json import math import os @@ -9,19 +10,25 @@ import secrets import sqlite3 import sys +import threading import time -from typing import Optional +from typing import Optional, Coroutine import uuid -from dbus_next.glib import MessageBus, ProxyInterface +from dbus_next.aio import MessageBus, ProxyInterface +from dbus_next.constants import ErrorType +from dbus_next.proxy_object import BaseProxyInterface from dbus_next import DBusError, Message, MessageType, Variant import gi +from gi.events import GLibEventLoop + gi.require_version("Gtk", "4.0") gi.require_version("GdkWayland", "4.0") gi.require_version("Adw", "1") -from gi.repository import GdkWayland, Gio, GObject, Gtk, Adw # noqa: E402,F401 +from gi.repository import GdkWayland, Gio, GLib, GObject, Gtk, Adw # noqa: E402,F401 # ty: ignore[unresolved-import] + import webauthn # noqa: E402 import util # noqa: E402 @@ -32,18 +39,124 @@ def dbus_error_from_message(msg: Message): return DBusError(msg.error_name, msg.body[0] if msg.body else None, reply=msg) -DBusError._from_message = dbus_error_from_message +DBusError._from_message = dbus_error_from_message # ty: ignore[invalid-assignment] + + +@staticmethod +def dbus_proxy_object_check_method_return(msg, signature=None): + if msg.message_type == MessageType.ERROR: + raise DBusError._from_message(msg) + elif msg.message_type != MessageType.METHOD_RETURN: + raise DBusError( + ErrorType.CLIENT_ERROR, "method call didnt return a method return", msg + ) + elif signature is not None and msg.signature != signature: + raise DBusError( + ErrorType.CLIENT_ERROR, + f'method call returned unexpected signature: "{msg.signature}", expected {signature}', + msg, + ) + + +BaseProxyInterface._check_method_return = dbus_proxy_object_check_method_return -INTERFACE = None -DB = None -KEY = None +APP_ID = "xyz.iinuwa.credentialsd.DemoCredentialsUi" +APP_NAME = "Demo UI" # TODO: This should be looked up from .desktop file. +LOOP: asyncio.AbstractEventLoop = None # ty: ignore[invalid-assignment] +INTERFACE: ProxyInterface = None # ty: ignore[invalid-assignment] +DB: sqlite3.Connection = None # ty: ignore[invalid-assignment] RESOURCE_FILE = Gio.Resource.load( f"{os.path.dirname(os.path.realpath(__file__))}/resources.gresource" ) Gio.resources_register(RESOURCE_FILE) +def task_spawn(coro: Coroutine, callback): + fut = asyncio.run_coroutine_threadsafe(coro, LOOP) + + def call_when_done(): + if callback: + callback(fut.result()) + else: + fut.result() + + fut.add_done_callback(lambda _: GLib.idle_add(call_when_done)) + + +async def get_surface_handle(toplevel) -> str: + # Ensure it's a Wayland toplevel + if not isinstance(toplevel, GdkWayland.WaylandToplevel): + # X11 toplevel is synchronous + return toplevel.export_handle() + + loop = asyncio.get_running_loop() + future = loop.create_future() + + def on_handle_exported(_toplevel, handle): + loop.call_soon_threadsafe(future.set_result, handle) + + toplevel.export_handle(on_handle_exported) + + handle = await future + return handle + + +class PortalRequest[T]: + def __init__(self, token: str, fut: Future): + self.token: str = token + self._fut: Future = fut + + async def wait(self) -> T: + return await self._fut + + +def create_portal_request_message_handler(bus: MessageBus) -> PortalRequest: + loop = asyncio.get_running_loop() + future = loop.create_future() + if not bus.connected or bus.unique_name is None: + raise Exception("Bus is not connected") + unique_name = bus.unique_name[1:].replace(".", "_") + token = secrets.token_hex(16) + object_path = f"/org/freedesktop/portal/desktop/request/{unique_name}/{token}" + + def message_handler(msg: Message): + if future.done(): + return False + + message_matches = ( + msg.path == object_path + and msg.message_type == MessageType.SIGNAL + and msg.destination == bus.unique_name + and msg.interface == "org.freedesktop.portal.Request" + and msg.member == "Response" + ) + if not message_matches: + return False + + [code, value] = msg.body + if code == 0: + future.set_result(value) + elif code == 1: + future.set_exception(Exception("Portal request cancelled")) + raise + elif code == 2 and "error" in value: + future.set_exception( + Exception(f"Portal returned an error: {value['error'].value}") + ) + else: + future.set_exception(Exception("Portal returned an unknown error")) + return True + + def when_done(_fut): + bus.remove_message_handler(message_handler) + + future.add_done_callback(when_done) + bus.add_message_handler(message_handler) + print(f"Listening for {object_path}") + return PortalRequest(token, future) + + @Gtk.Template(resource_path="/xyz/iinuwa/credentialsd/DemoCredentialsUi/window.ui") class MainWindow(Gtk.ApplicationWindow): __gtype_name__ = "MyAppWindow" @@ -57,7 +170,7 @@ class MainWindow(Gtk.ApplicationWindow): origin = "https://example.com" interface = None - def on_activate(self, app): + def on_activate(self, _app): # Create a Builder builder = Gtk.Builder() builder.add_from_file("build/window.ui") @@ -71,8 +184,12 @@ def on_activate(self, app): @Gtk.Template.Callback() def on_register(self, *args): print("register clicked") + task_spawn(self.register_passkey(), None) + + async def register_passkey(self): now = math.floor(time.time()) - cur = DB.cursor() + db = connect_db() + cur = db.cursor() username = self.username.get_text() if not username: print("Username is required") @@ -93,50 +210,56 @@ def on_register(self, *args): options = self._get_registration_options(user_handle, username) print(f"registration options: {options}") - def cb(user_id, toplevel, handle): - cur = DB.cursor() - window_handle = f"wayland:{handle}" - print(window_handle) - auth_data = create_passkey( - INTERFACE, window_handle, self.origin, self.origin, options + toplevel = self.get_surface() + handle = await get_surface_handle(toplevel) + + window_handle = f"wayland:{handle}" + print(window_handle) + auth_data = await create_passkey( + INTERFACE, window_handle, self.origin, self.origin, options + ) + + try: + handle = window_handle[window_handle.find(":") + 1 :] + toplevel.unexport_handle(handle) + except Exception as err: + print(err) + + if not user_id: + cur.execute( + "insert into users (username, user_handle, created_time) values (?, ?, ?)", + (username, user_handle, now), ) - if not user_id: - cur.execute( - "insert into users (username, user_handle, created_time) values (?, ?, ?)", - (username, user_handle, now), - ) - user_id = cur.lastrowid - params = { - "user_handle": user_handle, - "cred_id": auth_data.cred_id, - "aaguid": str(uuid.UUID(bytes=bytes(auth_data.aaguid))), - "sign_count": None - if auth_data.sign_count == 0 - else auth_data.sign_count, - "backup_eligible": 1 if "BE" in auth_data.flags else 0, - "backup_state": 1 if "BS" in auth_data.flags else 0, - "uv_initialized": 1 if "UV" in auth_data.flags else 0, - "cose_pub_key": auth_data.pub_key_bytes, - "created_time": now, - } - - add_passkey_sql = """ - insert into user_passkeys - (user_handle, cred_id, aaguid, sign_count, backup_eligible, backup_state, uv_initialized, cose_pub_key, created_time) - values - (:user_handle, :cred_id, :aaguid, :sign_count, :backup_eligible, :backup_state, :uv_initialized, :cose_pub_key, :created_time) - """ - cur.execute(add_passkey_sql, params) - print("Added passkey") - DB.commit() - cur.close() + user_id = cur.lastrowid + params = { + "user_handle": user_handle, + "cred_id": auth_data.cred_id, + "aaguid": str(uuid.UUID(bytes=auth_data.aaguid)), + "sign_count": None if auth_data.sign_count == 0 else auth_data.sign_count, + "backup_eligible": 1 if "BE" in auth_data.flags else 0, + "backup_state": 1 if "BS" in auth_data.flags else 0, + "uv_initialized": 1 if "UV" in auth_data.flags else 0, + "cose_pub_key": auth_data.pub_key_bytes, + "created_time": now, + } - toplevel = self.get_surface() - toplevel.export_handle(functools.partial(cb, user_id)) + add_passkey_sql = """ + insert into user_passkeys + (user_handle, cred_id, aaguid, sign_count, backup_eligible, backup_state, uv_initialized, cose_pub_key, created_time) + values + (:user_handle, :cred_id, :aaguid, :sign_count, :backup_eligible, :backup_state, :uv_initialized, :cose_pub_key, :created_time) + """ + cur.execute(add_passkey_sql, params) + print("Added passkey") + db.commit() cur.close() @Gtk.Template.Callback() def on_authenticate(self, *args): + print("authenticate clicked") + task_spawn(self.assert_passkey(), None) + + async def assert_passkey(self): username = self.username.get_text() if username: print(f"Using username-flow: {username}") @@ -146,7 +269,8 @@ def on_authenticate(self, *args): inner join users u on u.user_handle = p.user_handle where u.username = ? """ - with closing(DB.cursor()) as cur: + db = connect_db() + with closing(db.cursor()) as cur: cur.execute(sql, (username,)) user_creds = [] for row in cur.fetchall(): @@ -178,7 +302,8 @@ def on_authenticate(self, *args): def retrieve_user_cred( user_handle: Optional[bytes], cred_id: bytes ) -> Optional[dict]: - with closing(DB.cursor()) as cur: + db = connect_db() + with closing(db.cursor()) as cur: if username: print("using cached user creds") return next( @@ -221,26 +346,25 @@ def retrieve_user_cred( else: return None - def cb(toplevel, window_handle): - print(f"received window handle: {window_handle}") - window_handle = f"wayland:{window_handle}" - - auth_data = get_passkey( - INTERFACE, - window_handle, - self.origin, - self.origin, - self.rp_id, - cred_ids, - retrieve_user_cred, - ) - print("Received passkey:") - pprint(auth_data) - toplevel = self.get_surface() - print(type(toplevel)) - toplevel.export_handle(cb) - print("Waiting for handle to complete") + window_handle = await get_surface_handle(toplevel) + + print(f"received window handle: {window_handle}") + window_handle = f"wayland:{window_handle}" + print(window_handle) + + auth_data = await get_passkey( + INTERFACE, + window_handle, + self.origin, + self.origin, + self.rp_id, + cred_ids, + retrieve_user_cred, + ) + print("Received passkey:") + pprint(auth_data) + # event.wait() @GObject.Property(type=Gtk.StringList) @@ -309,7 +433,7 @@ def on_activate(self, app): self.win.present() -def create_passkey( +async def create_passkey( interface: ProxyInterface, window_handle: str, origin: str, @@ -323,30 +447,38 @@ def create_passkey( # pprint(options) print() + request_event = create_portal_request_message_handler(interface.bus) + + # Construct request + cred_type = "publicKey" req_json = json.dumps(options) req = { - "type": Variant("s", "publicKey"), - "origin": Variant("s", origin), - "is_same_origin": Variant("b", is_same_origin), - "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), + "handle_token": Variant("s", request_event.token), + "public_key": Variant("s", req_json), } + if top_origin != origin: + req["top_origin"] = Variant("s", top_origin) - rsp = interface.call_create_credential_sync([window_handle, req]) + print("Calling D-Bus") + rsp = await interface.call_create_credential(window_handle, origin, cred_type, req) + print(rsp) + print("waiting for response") + result = await request_event.wait() - # print("Received response") - # pprint(rsp) - if rsp["type"].value != "public-key": + print("Received response") + + if result["type"].value != "public-key": raise Exception( - f"Invalid credential type received: expected 'public-key', received {rsp['type'.value]}" + f"Invalid credential type received: expected 'public-key', received {result['type'].value}" ) response_json = json.loads( - rsp["public_key"].value["registration_response_json"].value + result["public_key"].value["registration_response_json"].value ) return webauthn.verify_create_response(response_json, options, origin) -def get_passkey( +async def get_passkey( interface, window_handle, origin, top_origin, rp_id, cred_ids, cred_lookup_fn ): is_same_origin = origin == top_origin @@ -364,24 +496,25 @@ def get_passkey( # pprint(options) print() + request_event = create_portal_request_message_handler(interface.bus) + + print(request_event.token) + # Construct request req_json = json.dumps(options) - req = { - "type": Variant("s", "publicKey"), - "origin": Variant("s", origin), - "is_same_origin": Variant("b", is_same_origin), - "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), + portal_options = { + "handle_token": Variant("s", request_event.token), + "public_key": Variant("s", req_json), } + if top_origin != origin: + portal_options["top_origin"] = Variant("s", top_origin) - rsp = interface.call_get_credential_sync([window_handle, req]) - # print("Received response") + _ = await interface.call_get_credential(window_handle, origin, portal_options) + result = await request_event.wait() + print("Received response") # pprint(rsp) - if rsp["type"].value != "public-key": - raise Exception( - f"Invalid credential type received: expected 'public-key', received {rsp['type'.value]}" - ) response_json = json.loads( - rsp["public_key"].value["authentication_response_json"].value + result["public_key"].value["authentication_response_json"].value ) response_json["rawId"] = util.b64_decode(response_json["rawId"]) if user_handle := response_json["response"].get("userHandle"): @@ -390,9 +523,9 @@ def get_passkey( return webauthn.verify_get_response(response_json, options, origin, cred_lookup_fn) -def connect_to_bus(): +async def connect_to_bus(): global INTERFACE - bus = MessageBus().connect_sync() + bus = await MessageBus().connect() with open( f"{os.path.dirname(os.path.realpath(__file__))}/xyz.iinuwa.credentialsd.Credentials.xml", @@ -400,16 +533,18 @@ def connect_to_bus(): ) as f: introspection = f.read() + service_name = "org.freedesktop.portal.Desktop" + path = "/org/freedesktop/portal/desktop" + interface = "org.freedesktop.portal.experimental.Credential" proxy_object = bus.get_proxy_object( - "xyz.iinuwa.credentialsd.Credentials", - "/xyz/iinuwa/credentialsd/Credentials", + service_name, + path, introspection, ) - INTERFACE = proxy_object.get_interface("xyz.iinuwa.credentialsd.Credentials1") + INTERFACE = proxy_object.get_interface(interface) -def setup_db(): - global DB +def connect_db() -> sqlite3.Connection: # This is just for testing/temporary use, so put it in cache db_path = ( Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) @@ -418,7 +553,13 @@ def setup_db(): ) db_path.parent.mkdir(exist_ok=True) - DB = sqlite3.connect(db_path) + return sqlite3.connect(db_path) + + +def setup_db(): + global DB + + DB = connect_db() DB.execute("pragma foreign_keys = on") user_table_sql = """ create table if not exists users ( @@ -451,14 +592,21 @@ def setup_db(): cur.close() -def main(): - connect_to_bus() +async def main(): setup_db() - - app = MyApp(application_id="xyz.iinuwa.credentialsd.DemoCredentialsUi") + app = MyApp(application_id=APP_ID) app.run(sys.argv) DB.close() if __name__ == "__main__": - main() + done = asyncio.Event() + LOOP = GLibEventLoop(GLib.MainContext()) + LOOP.run_until_complete(connect_to_bus()) + + def background_loop(): + LOOP.run_until_complete(done.wait()) + + threading.Thread(target=background_loop, daemon=True).start() + asyncio.run(main(), loop_factory=lambda: GLibEventLoop(GLib.MainContext())) + done.set() diff --git a/demo_client/webauthn.py b/demo_client/webauthn.py index a974d0e..4bb6b44 100644 --- a/demo_client/webauthn.py +++ b/demo_client/webauthn.py @@ -1,3 +1,4 @@ +from typing_extensions import Union from dataclasses import dataclass import hashlib import hmac @@ -140,7 +141,7 @@ def verify_create_response(response, create_request, expected_origin): # strip first two header bytes for OCTET STRING of length 16 assert cert_aaguid_der[:2] == b"\x04\x10" cert_aaguid = cert_aaguid_der[2:] - assert auth_data.aaguid.tobytes() == cert_aaguid + assert auth_data.aaguid == cert_aaguid except x509.ExtensionNotFound: # no FIDO OID found in cert. pass @@ -200,8 +201,10 @@ def verify_create_response(response, create_request, expected_origin): ) # Extract the claimed rpIdHash from authenticatorData, and the claimed credentialId and credentialPublicKey from authenticatorData.attestedCredentialData. - expected_rp_id_hash, cred_pub_key - credential_id = auth_data.cred_id + if auth_data.cred_id is None: + raise Exception("No credential ID returned in attestation object.") + else: + credential_id = auth_data.cred_id # Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of [RFC9052]) to Raw ANSI X9.62 public key format # (see ALG_KEY_ECC_X962_RAW in Section 3.6.2 Public Key Representation Formats of [FIDO-Registry]). @@ -359,7 +362,7 @@ def verify_get_response(credential, options, expected_origin, cred_lookup_fn): # If C.crossOrigin is present and set to true, verify that the Relying Party # expects this credential to be used within an iframe that is not # same-origin with its ancestors. - if C.get("crossOrigin") == True: + if C.get("crossOrigin") is True: # TODO: pass cross-origin policy as parameter pass @@ -545,7 +548,7 @@ def _cose_verify(cose_key: bytes, signature: bytes, data: bytes): crv = ec.SECP256R1() alg = ec.ECDSA(hashes.SHA256()) else: - raise Exception(f"Unsupported COSE ECDSA curve specified: {crv}") + raise Exception(f"Unsupported COSE ECDSA curve specified: {cose_crv}") # WebAuthn uses uncompressed points only. pub_key_bytes = bytes(b"\x04" + x + y) @@ -569,7 +572,27 @@ def _cose_verify(cose_key: bytes, signature: bytes, data: bytes): raise Exception(f"Unsupported COSE key algorithm specified: {cose_alg}") -def _parse_authenticator_data(auth_data): +@dataclass +class AuthenticatorData: + rp_id_hash: bytes + flags: set + sign_count: int + aaguid: Optional[bytes] + cred_id: Optional[bytes] + pub_key_bytes: Optional[bytes] + extensions: Optional[dict] + + def get_pub_key(self): + if self.pub_key_bytes: + return cbor.loads(self.pub_key_bytes) + + def has_flag(self, flag): + return flag in self.flags + + +def _parse_authenticator_data( + auth_data: Union[bytes | memoryview], +) -> AuthenticatorData: client_rp_id_hash = auth_data[:32] # Verify that the User Present bit of the flags in authData is set. @@ -605,29 +628,11 @@ def _parse_authenticator_data(auth_data): else: extensions = None return AuthenticatorData( - rp_id_hash=client_rp_id_hash, + rp_id_hash=bytes(client_rp_id_hash), flags=flags, sign_count=sign_count, - aaguid=aaguid, - cred_id=cred_id, - pub_key_bytes=cose_key_bytes, + aaguid=bytes(aaguid) if aaguid else None, + cred_id=bytes(cred_id) if cred_id else None, + pub_key_bytes=bytes(cose_key_bytes) if cose_key_bytes else None, extensions=extensions, ) - - -@dataclass -class AuthenticatorData: - rp_id_hash: bytes - flags: set - sign_count: int - aaguid: Optional[bytes] - cred_id: Optional[bytes] - pub_key_bytes: Optional[bytes] - extensions: Optional[dict] - - def get_pub_key(self): - if self.pub_key_bytes: - return cbor.loads(self.pub_key_bytes) - - def has_flag(self, flag): - return flag in self.flags diff --git a/doc/api.md b/doc/api.md index 0a750a6..115f420 100644 --- a/doc/api.md +++ b/doc/api.md @@ -50,6 +50,10 @@ sequenceDiagram - (UI Controller): Renamed `InitiateEventStream()` to `Subscribe()` - (UI Controller): Serialize enums (including BackgroundEvent, HybridState and UsbState) as (yv) structs instead for a{sv} dicts +- (Gateway): Flatten `request` parameters into options. +- (Gateway): Make `origin` and `type` a required method parameter. +- (Gateway): Flatten nested D-Bus struct with `request_json` on CreateCredential and GetCredential +- (Gateway): Remove Client Capabilities method from Gateway API until further notice. ### Improvements @@ -85,8 +89,6 @@ A single null byte (`\0`) is sent for unused enum values. ## D-Bus/JSON serialization -> TODO: rename fields to snake_case so that this note is true in all cases. - This API is modelled after the [Credential Management API][credman-api]. The top-level fields corresponding to `navigator.credentials.create()` and `get()` are passed as fields in D-Bus dictionaries using snake_case, according to D-Bus @@ -106,9 +108,12 @@ this API takes: ``` [a{sv}] { - origin: Variant(""), - top_origin: Variant(""), // topOrigin is changed to top_origin - password: Variant(true), + IN origin s = "https://example.com", + IN type = "password", + options a{sv} = { + top_origin: Variant("https://example.com"), // topOrigin is changed to top_origin + password: Variant(true), + } } ``` @@ -123,8 +128,8 @@ So if a client passed this in JavaScript: ```javascript { - "origin": "example.com", - "topOrigin": "example.com", + "origin": "https://example.com", + "topOrigin": "https://example.com", "publicKey": { "challenge": new Uint8Array([97, 32, 99, 104, 97, 108, 108, 101, 110, 103, 101]), "excludeCredentials": [ @@ -138,18 +143,21 @@ So if a client passed this in JavaScript: it would pass this request to this API: ``` -[a{sv}] { - origin: Variant(''), - top_origin: Variant(''), // top-level fields topOrigin and publicKey are - public_key: Variant([a{sv}] { // changed to snake_case - registration_request_json: [s] '{ // <- JSON-encoded string - "challenge": "YSBjaGFsbGVuZ2U", // buffer is encoded as base64url without padding - "excludeCredentials": [ // "excludeCredentials" is not changed to snake_case - {"type": "public-key", "alg": -7} // "public-key" is not changed to snake_case +CreateCredential( + // ... + IN origin s = "https://example.com", + IN type s = "publicKey", + IN options a{sv} { + top_origin: Variant("https://example.com"), // top-level fields topOrigin and publicKey are + // changed to snake_case, JSON-encoded string + public_key: [s] = "{ // `public_key` is a JSON-encoded string, snake_case field name + \"challenge\": \"YSBjaGFsbGVuZ2U\", // "challenge" buffer is encoded as base64url without padding + \"excludeCredentials\": [ // "excludeCredentials" is not changed to snake_case within the JSON + {\"type\": \"public-key\", \"alg\": -7} // "public-key" is not changed to snake_case within the JSON string ] // ... - }' - }) + }" + } } ``` @@ -191,12 +199,15 @@ for what kind of credential the client would like to create. ``` CreateCredentialRequest( IN parent_window s, + IN origin s, + IN type CredentialType, IN options a{sv} { - origin: string - is_same_origin: string - type: CredentialType - - } + activation_token: s + top_origin: s + + }, + IN app_id s, + IN app_display_name s ) ``` @@ -212,15 +223,11 @@ CredentialType[s] [ #### Request context -> TODO: replace is_same_origin with topOrigin, required if origin is set. - -> TODO: Should we say that `origin` will be optional in the future? - > TODO: Define methods for safe comparison of hosts Punycode origins. -`origin` and `is_same_origin` define the request context. Both are required. A -request is considered to be a cross-origin request if `is_same_origin` is -`false`. For certain credentials, cross-origin requests are not allowed and +`origin` and `options.top_origin` define the request context. `origin` is required. A +request is considered to be a cross-origin request if `options.top_origin` is +specified. For certain credentials, cross-origin requests are not allowed and will be denied. At this time, only [web origins][web-origins] with HTTPS schemes are permitted @@ -233,28 +240,30 @@ suffix, as defined by the [Public Suffix List][PSL]. [web-origins]: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin-tuple [PSL]: https://github.com/publicsuffix/list -#### Credential Types +#### Credential Request Types -> TODO: decide on case of strings (snake_case like D-Bus or camelCase like JS?) +##### WebAuthn Credential Request Currently, there is only one supported type of `CreateCredentialRequest`, `CreatePublicKeyCredentialRequest`, identified by `type: "publicKey"` and -corresponds to WebAuthn credentials: - - CreatePublicKeyCredentialRequest[a{sv}] : CreateCredentialRequest { - origin: string - is_same_origin: string - type: "publicKey" - publicKey: CreatePublicKeyCredentialOptions[a{sv}] { - // WebAuthn credential attestation JSON - request_json: String - } - } - -`request_json` is a string of JSON that corresponds to the WebAuthn +corresponds to WebAuthn credentials. It extends the `options` parameter +with a field `public_key`, which is a string of JSON that corresponds to the +WebAuthn [`PublicKeyCredentialCreationOptions`][def-pubkeycred-creation-options] type. + CreatePublicKeyCredentialRequest: CreateCredentialRequest ( + IN parent_window s, + IN origin s, + IN type s = "publicKey", + options a{sv} { + , + public_key: s // WebAuthn credential attestation JSON + }, + IN app_id s, + IN app_display_name s + ) + ### Response > TODO: Should we group common types in their own section for reference? @@ -263,24 +272,26 @@ type. `CreateCredentialResponse` is a polymorphic type that depends on the type of the request sent. Its `type` field is a string specifies what kind of -credential it is, and what `` should be expected. +credential it is, and what `` should be expected. ``` CreateCredentialResponse[a{sv}] { type: CredentialType - + } ``` `CredentialType` is defined above. +#### WebAuthn Credential Response + As the only supported request is `CreatePublicKeyCredentialRequest`, the only type of response is `CreateCredentialResponse` is `CreatePublicKeyResponse`, also denoted by `type: "publicKey"`: CreatePublicKeyResponse { - type: "publicKey" - registration_response_json: String + type: s = "publicKey" + registration_response_json: s } `registration_response_json` is a JSON string that corresponds to the WebAuthn @@ -308,12 +319,16 @@ credentials the client will accept. ``` GetCredentialRequest ( - IN parent_window s + IN parent_window s, + IN origin s, IN options a{sv} { - origin: string - is_same_origin: string - publicKey: GetPublicKeyCredentialOptions? - } + activation_token: s + top_origin: s + + public_key: s + }, + IN app_id s, + IN app_display_name s ) ``` @@ -326,29 +341,20 @@ request multiple different types of credentials at once, and it can expect the returned credential to be any one of those credential types. Because of that, there is no `type` field, and credential types are specified using the optional fields. + #### Request Context -The `GetCredential()` `origin` and `is_same_origin` have the same semantics and +The `GetCredential()` `origin` and `options.top_origin` have the same semantics and restrictions as in `CreateCredential()` described above. When multiple credential types are specified, the request context applies to all credentials. -#### Credential Types +#### Credential Request Types -> TODO: decide on case of strings (snake_case like D-Bus or camelCase like JS?) +##### WebAuthn Credential Request -Currently, there is only one supported type of credential, specified by the -`publicKey` field, which corresponds to WebAuthn credentials and takes a -`GetPublicKeyCredentialOptions`: - -``` -GetPublicKeyCredentialOptions[a{sv}] { - request_json: string -} -``` - -`request_json` is a string of JSON that corresponds to the WebAuthn +Currently, there is only one supported type of credential, a WebAuthn PublicKeyCredential. A WebAuthn credential can be requested using the `options.public_key` field, which is a string of JSON that corresponds to the WebAuthn [`PublicKeyCredentialRequestOptions`][def-pubkeycred-request-options]. [def-pubkeycred-request-options]: https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions @@ -361,27 +367,27 @@ GetPublicKeyCredentialOptions[a{sv}] { `GetCredentialResponse` is a polymorphic type that depends on the type of the request sent. Its `type` field is a string specifies what kind of credential it -is, and what `` should be expected. +is, and what `` should be expected. ``` GetCredentialResponse[a{sv}] { type: CredentialType - + } ``` `CredentialType` is defined above. -As the only supported request is `CreatePublicKeyCredentialRequest`, the only -type of response is CreateCredentialResponse is CreatePublicKeyResponse, also + +#### WebAuthn Credential Response + +As the only supported request is `GetPublicKeyCredentialRequest`, the only +type of response is `GetCredentialResponse` is `GetPublicKeyCredentialResponse`, also denoted by `type: "publicKey"`: GetPublicKeyCredentialRepsonse { - type: "publicKey" - publicKey: { - // WebAuthn credential assertion response JSON - authentication_response_json: string - } + type: s = "publicKey" + authentication_response_json: s // WebAuthn credential assertion response JSON } `authentication_response_json` is a JSON string that corresponds to the WebAuthn @@ -399,31 +405,6 @@ denoted by `type: "publicKey"`: - `TypeError`: An invalid request is made. - `NotAllowedError`: catch-all error. -## `GetClientCapabilities() -> GetClientCapabilitiesResponse` - -Analogous to WebAuthn Level 3's [`getClientCapabilities()`][def-getClientCapabilities] method. - -### Response - -`GetClientCapabilitiesResponse` is a set of boolean flags indicating what features this client supports. - - GetClientCapabilitiesResponse[a{sb}] { - conditional_create: bool, - conditional_get: bool, - hybrid_transport: bool, - passkey_platform_authenticator: bool, - user_verifying_platform_authenticator: bool, - related_origins: bool, - signal_all_accepted_credentials: bool, - signal_current_user_details: bool, - signal_unknown_credential: bool, - } - -See the WebAuthn spec for meanings of the [client capability keys][def-client-capabilitities]. - -[def-client-capabilities]: https://www.w3.org/TR/webauthn-3/#enumdef-clientcapability -[def-getClientCapabilities]: https://w3c.github.io/webauthn/#sctn-getClientCapabilities - # Flow Control API The Flow Control API is used by the UI to pass user interactions through the diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index be1cac7..3d95735 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -20,13 +20,104 @@ - - + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/xyz.iinuwa.credentialsd.FlowControl.xml b/doc/xyz.iinuwa.credentialsd.FlowControl.xml new file mode 100644 index 0000000..1557b2f --- /dev/null +++ b/doc/xyz.iinuwa.credentialsd.FlowControl.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/xyz.iinuwa.credentialsd.UiControl.xml b/doc/xyz.iinuwa.credentialsd.UiControl.xml new file mode 100644 index 0000000..e2357b0 --- /dev/null +++ b/doc/xyz.iinuwa.credentialsd.UiControl.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/meson.build b/meson.build index d9a9913..d075ed4 100644 --- a/meson.build +++ b/meson.build @@ -28,11 +28,16 @@ meson.add_dist_script( meson.project_source_root(), ) +# Libs and executables subdir('credentialsd-common') subdir('credentialsd') subdir('credentialsd-ui') + +# Data files +subdir('doc') subdir('dbus') +subdir('portal') subdir('systemd') subdir('webext') -subdir('doc') -subdir('demo_client') + +subdir('demo_client') \ No newline at end of file diff --git a/portal/credentialsd.portal b/portal/credentialsd.portal new file mode 100644 index 0000000..938145c --- /dev/null +++ b/portal/credentialsd.portal @@ -0,0 +1,3 @@ +[portal] +DBusName=xyz.iinuwa.credentialsd.UiControl +Interfaces=org.freedesktop.impl.portal.experimental.Credential diff --git a/portal/meson.build b/portal/meson.build new file mode 100644 index 0000000..ea237c5 --- /dev/null +++ b/portal/meson.build @@ -0,0 +1,4 @@ +install_data( + 'credentialsd.portal', + install_dir: datadir / 'xdg-desktop-portal/portals/', +) \ No newline at end of file