From 78599b0341013ea02bd0fa3e7ffccf63186b42c1 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 13 Mar 2026 17:22:57 -0500 Subject: [PATCH 01/22] daemon: Move request validation to gateway service This will be shared by both D-Bus interfaces, so we should consolidate it there. --- credentialsd-common/src/model.rs | 2 +- credentialsd/src/gateway/dbus.rs | 142 ++++++++++++++++--------------- credentialsd/src/gateway/mod.rs | 84 +++++++++++++++--- credentialsd/src/webauthn.rs | 2 +- 4 files changed, 145 insertions(+), 85 deletions(-) diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index 7c2519b..a450e6d 100644 --- a/credentialsd-common/src/model.rs +++ b/credentialsd-common/src/model.rs @@ -110,7 +110,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, } diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 8bf7e7d..f72fa86 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -20,8 +20,7 @@ use credentialsd_common::{ use crate::webauthn::{AppId, NavigationContext, 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"; @@ -66,44 +65,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 +89,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 +116,59 @@ 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 }, + }) +} + #[allow(clippy::enum_variant_names)] #[derive(DBusError, Debug)] #[zbus(prefix = "xyz.iinuwa.credentialsd")] diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 6c06f70..cf66493 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -35,6 +35,36 @@ 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. +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 +77,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 +105,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(requesting_app, cred_request, parent_window) + .request_credential(Some(context.into()), cred_request, parent_window) .await?; if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { @@ -104,10 +135,11 @@ impl GatewayService { async fn handle_get_credential( &self, request: GetCredentialRequest, - 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) { // Setup request @@ -128,7 +160,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(requesting_app, cred_request, parent_window) + .request_credential(Some(context.into()), cred_request, parent_window) .await?; if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { @@ -170,6 +202,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 @@ -262,23 +317,26 @@ fn check_origin_from_app( ]; let is_privileged_client = trusted_clients.contains(&app_id.as_ref()); if is_privileged_client { - check_origin_from_privileged_client(origin, top_origin) + check_origin_from_privileged_client(&origin, top_origin.as_ref()) } else { Ok(NavigationContext::SameOrigin(Origin::AppId(app_id.clone()))) } } 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 +355,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/webauthn.rs b/credentialsd/src/webauthn.rs index 905fccf..a539e82 100644 --- a/credentialsd/src/webauthn.rs +++ b/credentialsd/src/webauthn.rs @@ -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), From ace4ffcb883085267c03875572f48a041147b5fe Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 11 Mar 2026 22:55:38 -0500 Subject: [PATCH 02/22] daemon: Allow manually specifying trusted clients for development --- .vscode/launch.json | 19 ++++++++++++++----- credentialsd/src/gateway/mod.rs | 20 +++++++++++++++----- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index e6743d4..42702bf 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=debug,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", + "CREDSD_TRUSTED_APP_IDS": "app:xyz.iinuwa.credentialsd.DemoCredentialsUi", }, - "sourceLanguages": ["rust"], + "sourceLanguages": [ + "rust" + ], "cwd": "${workspaceFolder}", "preLaunchTask": "Meson: Build Daemon" }, @@ -27,7 +31,9 @@ "GSETTINGS_SCHEMA_DIR": "${workspaceFolder}/build/credentialsd-ui/data", "RUST_LOG": "credentialsd_ui=debug,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/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index cf66493..53d35f0 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -311,11 +311,21 @@ fn check_origin_from_app( 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()); + 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.as_ref()) } else { From e1de3939de5c61b5b1084c79c3587de42dba6895 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 11 Mar 2026 22:55:38 -0500 Subject: [PATCH 03/22] daemon: Add portal as alternative entrypoint --- credentialsd-common/src/server.rs | 12 +- credentialsd/src/gateway/dbus.rs | 217 ++++++++++++++++++++++++++---- credentialsd/src/gateway/mod.rs | 13 +- 3 files changed, 212 insertions(+), 30 deletions(-) diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index 8096634..c27b1fd 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, @@ -11,7 +11,7 @@ use zvariant::{ SerializeDict, Signature, Structure, StructureBuilder, Type, Value, signature::Fields, }; -use crate::model::{BackgroundEvent, Device, Operation, RequestingApplication}; +use crate::model::{BackgroundEvent, Operation, RequestingApplication}; const TAG_VALUE_SIGNATURE: &Signature = &Signature::Structure(Fields::Static { fields: &[&Signature::U8, &Signature::Variant], @@ -113,6 +113,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 { diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index f72fa86..cf27d62 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -1,11 +1,12 @@ -use std::{os::fd::AsRawFd, sync::Arc}; +use std::{collections::HashMap, os::fd::AsRawFd, sync::Arc}; +use serde::{ser::SerializeTuple, Serialize}; use tokio::sync::Mutex as AsyncMutex; use zbus::{ fdo, interface, message::Header, names::{BusName, UniqueName}, - zvariant::Optional, + zvariant::{ObjectPath, Optional, OwnedValue, Type, Value}, Connection, DBusError, }; @@ -17,7 +18,7 @@ use credentialsd_common::{ }, }; -use crate::webauthn::{AppId, NavigationContext, Origin}; +use crate::webauthn::{AppId, Origin}; use super::{ check_origin_from_app, get_app_info_from_pid, GatewayService, RequestContext, RequestKind, @@ -25,6 +26,7 @@ use super::{ 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>, @@ -40,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>, @@ -169,6 +177,123 @@ async fn extract_client_details( }) } +/// 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.impl.portal.experimental.Credential")] +impl CredentialPortalGateway { + #[zbus(out_args("response", "results"))] + async fn create_credential( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + portal_request_handle: ObjectPath<'_>, + claimed_app_id: String, + claimed_app_display_name: Optional, + parent_window: Optional, + claimed_origin: String, + claimed_top_origin: Optional, + request: CreateCredentialRequest, + _options: HashMap, + ) -> PortalResult { + let app_validation_result = validate_app_details( + connection, + &header, + claimed_app_id, + claimed_app_display_name.into(), + claimed_origin, + claimed_top_origin.into(), + ) + .await; + let context = match app_validation_result { + Ok(context) => context, + Err(err) => return Err(err).into(), + }; + + tracing::debug!( + ?context, + ?request, + ?parent_window, + ?portal_request_handle, + "Received request for creating credential" + ); + + 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<'_>, + portal_request_handle: ObjectPath<'_>, + parent_window: Optional, + claimed_app_id: String, + claimed_app_display_name: Optional, + claimed_origin: String, + claimed_top_origin: Optional, + request: GetCredentialRequest, + _options: HashMap, + ) -> PortalResult { + let app_validation_result = validate_app_details( + connection, + &header, + claimed_app_id, + claimed_app_display_name.into(), + claimed_origin, + claimed_top_origin.into(), + ) + .await; + let context = match app_validation_result { + Ok(context) => context, + Err(err) => return Err(err).into(), + }; + + tracing::debug!( + ?context, + ?request, + ?parent_window, + ?portal_request_handle, + "Received request for retrieving credential" + ); + + let response = self + .gateway_service + .lock() + .await + .handle_get_credential(request, context, parent_window.into()) + .await + .map_err(Error::from); + response.into() + } + + async fn get_client_capabilities(&self) -> fdo::Result { + let capabilities = self + .gateway_service + .lock() + .await + .handle_get_client_capabilities(); + Ok(capabilities) + } +} + #[allow(clippy::enum_variant_names)] #[derive(DBusError, Debug)] #[zbus(prefix = "xyz.iinuwa.credentialsd")] @@ -226,14 +351,63 @@ 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.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); }; @@ -254,7 +428,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}"); @@ -262,25 +440,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 53d35f0..0558d88 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -36,6 +36,7 @@ pub async fn start_gateway, -) -> Result { +) -> Result { let is_privileged_client = { let trusted_clients = [ "org.mozilla.firefox", @@ -327,9 +329,14 @@ fn check_origin_from_app( privileged }; if is_privileged_client { - check_origin_from_privileged_client(&origin, top_origin.as_ref()) + 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::AppId(app_id.clone()))) } } From 956566142a51129c034e6e06a92b850f2acea564 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 13 Mar 2026 07:29:53 -0500 Subject: [PATCH 04/22] wip: Return PortalResult as single struct This isn't clean, but I'll have to see how it works on in the xdp code to see if this is desirable or not. Maybe we'll wind up splitting into two parameters. --- credentialsd/src/gateway/dbus.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index cf27d62..8671af1 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -191,7 +191,7 @@ struct CredentialPortalGateway { /// for evaluation. #[interface(name = "org.freedesktop.impl.portal.experimental.Credential")] impl CredentialPortalGateway { - #[zbus(out_args("response", "results"))] + // #[zbus(out_args("response", "results"))] async fn create_credential( &self, #[zbus(connection)] connection: &Connection, @@ -204,7 +204,7 @@ impl CredentialPortalGateway { claimed_top_origin: Optional, request: CreateCredentialRequest, _options: HashMap, - ) -> PortalResult { + ) -> (PortalResult,) { let app_validation_result = validate_app_details( connection, &header, @@ -216,7 +216,7 @@ impl CredentialPortalGateway { .await; let context = match app_validation_result { Ok(context) => context, - Err(err) => return Err(err).into(), + Err(err) => return (Err(err).into(),), }; tracing::debug!( @@ -235,10 +235,10 @@ impl CredentialPortalGateway { .await .map_err(Error::from); - response.into() + (response.into(),) } - #[zbus(out_args("response", "results"))] + //#[zbus(out_args("response", "results"))] async fn get_credential( &self, #[zbus(connection)] connection: &Connection, @@ -251,7 +251,7 @@ impl CredentialPortalGateway { claimed_top_origin: Optional, request: GetCredentialRequest, _options: HashMap, - ) -> PortalResult { + ) -> (PortalResult,) { let app_validation_result = validate_app_details( connection, &header, @@ -263,7 +263,7 @@ impl CredentialPortalGateway { .await; let context = match app_validation_result { Ok(context) => context, - Err(err) => return Err(err).into(), + Err(err) => return (Err(err).into(),), }; tracing::debug!( @@ -281,7 +281,7 @@ impl CredentialPortalGateway { .handle_get_credential(request, context, parent_window.into()) .await .map_err(Error::from); - response.into() + (response.into(),) } async fn get_client_capabilities(&self) -> fdo::Result { @@ -386,8 +386,6 @@ where Ok(response) => { map.serialize_element(&(PortalResponse::Success as u32))?; map.serialize_element(&response)?; - map.serialize_element(&(PortalResponse::Success as u32))?; - map.serialize_element(&response)?; } }; map.end() From be6ee427c843b48742e2d7184de06f97f37de9ce Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 13 Mar 2026 07:29:53 -0500 Subject: [PATCH 05/22] Reorder parent window arguments --- credentialsd/src/gateway/dbus.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 8671af1..69913fe 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -197,9 +197,9 @@ impl CredentialPortalGateway { #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, portal_request_handle: ObjectPath<'_>, + parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, - parent_window: Optional, claimed_origin: String, claimed_top_origin: Optional, request: CreateCredentialRequest, From 7aec096b588ef90b4d4095e123122bb86f2c74c5 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 13 Mar 2026 07:29:53 -0500 Subject: [PATCH 06/22] demo_client: Update client to point to portal interface --- demo_client/gui.py | 94 ++++++++++++++++----- demo_client/webauthn.py | 61 +++++++------ doc/xyz.iinuwa.credentialsd.Credentials.xml | 28 +++++- 3 files changed, 132 insertions(+), 51 deletions(-) diff --git a/demo_client/gui.py b/demo_client/gui.py index f9e01a7..29e9356 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +from dbus_next.constants import ErrorType from contextlib import closing import functools import json @@ -14,6 +15,7 @@ import uuid from dbus_next.glib import MessageBus, ProxyInterface +from dbus_next.proxy_object import BaseProxyInterface from dbus_next import DBusError, Message, MessageType, Variant import gi @@ -21,7 +23,7 @@ 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, GObject, Gtk, Adw # noqa: E402,F401 # ty: ignore[unresolved-import] import webauthn # noqa: E402 import util # noqa: E402 @@ -32,11 +34,32 @@ 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] -INTERFACE = None -DB = None -KEY = None + +@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 + +APP_ID = "xyz.iinuwa.credentialsd.DemoCredentialsUi" +APP_NAME = "Demo UI" # TODO: This should be looked up from .desktop file. + +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" @@ -109,7 +132,7 @@ def cb(user_id, toplevel, handle): params = { "user_handle": user_handle, "cred_id": auth_data.cred_id, - "aaguid": str(uuid.UUID(bytes=bytes(auth_data.aaguid))), + "aaguid": str(uuid.UUID(bytes=auth_data.aaguid)), "sign_count": None if auth_data.sign_count == 0 else auth_data.sign_count, @@ -224,6 +247,7 @@ def retrieve_user_cred( def cb(toplevel, window_handle): print(f"received window handle: {window_handle}") window_handle = f"wayland:{window_handle}" + print(window_handle) auth_data = get_passkey( INTERFACE, @@ -331,17 +355,31 @@ def create_passkey( "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), } - rsp = interface.call_create_credential_sync([window_handle, req]) + unique_name = interface.bus.unique_name[1:].replace(".", "_") + object_path = f"/org/freedesktop/portal/request/{unique_name}/CREATE_REQUEST" + rsp = interface.call_create_credential_sync( + object_path, window_handle, APP_ID, APP_NAME, origin, top_origin, req, {} + ) - # print("Received response") + print("Received response") # pprint(rsp) - if rsp["type"].value != "public-key": + [code, value] = rsp + if code == 0: + result = value + elif code == 1: + raise Exception("Portal request cancelled") + elif code == 2 and "error" in value: + raise Exception(f"Portal returned an error: {value['error'].value}") + else: + raise Exception("Portal returned an unknown error") + + 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) @@ -372,16 +410,32 @@ def get_passkey( "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), } - rsp = interface.call_get_credential_sync([window_handle, req]) - # print("Received response") + unique_name = interface.bus.unique_name[1:].replace(".", "_") + object_path = f"/org/freedesktop/portal/request/{unique_name}/GET_REQUEST" + print(window_handle) + rsp = interface.call_get_credential_sync( + object_path, window_handle, APP_ID, APP_NAME, origin, top_origin, req, {} + ) + print("Received response") # pprint(rsp) - if rsp["type"].value != "public-key": + + [code, value] = rsp + if code == 0: + result = value + elif code == 1: + raise Exception("Portal request cancelled") + elif code == 2 and "error" in value: + raise Exception(f"Portal returned an error: {value['error'].value}") + else: + raise Exception("Portal returned an unknown error") + + 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["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"): @@ -402,10 +456,12 @@ def connect_to_bus(): proxy_object = bus.get_proxy_object( "xyz.iinuwa.credentialsd.Credentials", - "/xyz/iinuwa/credentialsd/Credentials", + "/org/freedesktop/portal/desktop", introspection, ) - INTERFACE = proxy_object.get_interface("xyz.iinuwa.credentialsd.Credentials1") + INTERFACE = proxy_object.get_interface( + "org.freedesktop.impl.portal.experimental.Credential" + ) def setup_db(): @@ -455,7 +511,7 @@ def main(): connect_to_bus() setup_db() - app = MyApp(application_id="xyz.iinuwa.credentialsd.DemoCredentialsUi") + app = MyApp(application_id=APP_ID) app.run(sys.argv) DB.close() 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/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index be1cac7..a0846b2 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -20,11 +20,31 @@ - - + + + + + + + + + + + - - + + + + + + + + + + + + + From 53465d97e945aa1d61031b20592a816054b5e69e Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 13 Mar 2026 07:29:53 -0500 Subject: [PATCH 07/22] squash: daemon: fix response/result serialization --- credentialsd/src/gateway/dbus.rs | 16 ++++++++-------- doc/xyz.iinuwa.credentialsd.Credentials.xml | 6 ++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 69913fe..b64c914 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -191,7 +191,7 @@ struct CredentialPortalGateway { /// for evaluation. #[interface(name = "org.freedesktop.impl.portal.experimental.Credential")] impl CredentialPortalGateway { - // #[zbus(out_args("response", "results"))] + #[zbus(out_args("response", "results"))] async fn create_credential( &self, #[zbus(connection)] connection: &Connection, @@ -204,7 +204,7 @@ impl CredentialPortalGateway { claimed_top_origin: Optional, request: CreateCredentialRequest, _options: HashMap, - ) -> (PortalResult,) { + ) -> PortalResult { let app_validation_result = validate_app_details( connection, &header, @@ -216,7 +216,7 @@ impl CredentialPortalGateway { .await; let context = match app_validation_result { Ok(context) => context, - Err(err) => return (Err(err).into(),), + Err(err) => return Err(err).into(), }; tracing::debug!( @@ -235,10 +235,10 @@ impl CredentialPortalGateway { .await .map_err(Error::from); - (response.into(),) + response.into() } - //#[zbus(out_args("response", "results"))] + #[zbus(out_args("response", "results"))] async fn get_credential( &self, #[zbus(connection)] connection: &Connection, @@ -251,7 +251,7 @@ impl CredentialPortalGateway { claimed_top_origin: Optional, request: GetCredentialRequest, _options: HashMap, - ) -> (PortalResult,) { + ) -> PortalResult { let app_validation_result = validate_app_details( connection, &header, @@ -263,7 +263,7 @@ impl CredentialPortalGateway { .await; let context = match app_validation_result { Ok(context) => context, - Err(err) => return (Err(err).into(),), + Err(err) => return Err(err).into(), }; tracing::debug!( @@ -281,7 +281,7 @@ impl CredentialPortalGateway { .handle_get_credential(request, context, parent_window.into()) .await .map_err(Error::from); - (response.into(),) + response.into() } async fn get_client_capabilities(&self) -> fdo::Result { diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index a0846b2..1950cc5 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -30,7 +30,8 @@ - + + @@ -41,7 +42,8 @@ - + + From 3d93ca005a72e37205a7081b789da6b06eb86eab Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 13 Mar 2026 07:29:53 -0500 Subject: [PATCH 08/22] daemon: Rename portal interface with "handler" terminology --- credentialsd/src/gateway/dbus.rs | 2 +- demo_client/gui.py | 2 +- doc/xyz.iinuwa.credentialsd.Credentials.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index b64c914..f3715fc 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -189,7 +189,7 @@ struct CredentialPortalGateway { /// 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.impl.portal.experimental.Credential")] +#[interface(name = "org.freedesktop.handler.portal.experimental.Credential")] impl CredentialPortalGateway { #[zbus(out_args("response", "results"))] async fn create_credential( diff --git a/demo_client/gui.py b/demo_client/gui.py index 29e9356..ad8a0aa 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -460,7 +460,7 @@ def connect_to_bus(): introspection, ) INTERFACE = proxy_object.get_interface( - "org.freedesktop.impl.portal.experimental.Credential" + "org.freedesktop.handler.portal.experimental.Credential" ) diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index 1950cc5..e19d30c 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -20,7 +20,7 @@ - + From eafb1c0b530a30643e6ea62349c4c61c003dc0d5 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 16 Apr 2026 05:14:31 -0500 Subject: [PATCH 09/22] daemon: Don't restrict GetCredential to a single credential type --- credentialsd-common/src/server.rs | 2 -- credentialsd/src/gateway/mod.rs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index c27b1fd..fa5848d 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -205,8 +205,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, } diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 0558d88..fe87869 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -142,7 +142,7 @@ impl GatewayService { ) -> Result { let request_environment = validate_request(&context)?; - if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { + if request.public_key.is_some() { // Setup request // TODO: assert that RP ID is bound to origin: @@ -184,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) } } From 642443705828427e794fff2447c16447e3e26a71 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 16 Apr 2026 05:14:31 -0500 Subject: [PATCH 10/22] daemon: Remove unused request handle --- credentialsd/src/gateway/dbus.rs | 4 ---- doc/xyz.iinuwa.credentialsd.Credentials.xml | 2 -- 2 files changed, 6 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index f3715fc..716d8f4 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -196,7 +196,6 @@ impl CredentialPortalGateway { &self, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, - portal_request_handle: ObjectPath<'_>, parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, @@ -223,7 +222,6 @@ impl CredentialPortalGateway { ?context, ?request, ?parent_window, - ?portal_request_handle, "Received request for creating credential" ); @@ -243,7 +241,6 @@ impl CredentialPortalGateway { &self, #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, - portal_request_handle: ObjectPath<'_>, parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, @@ -270,7 +267,6 @@ impl CredentialPortalGateway { ?context, ?request, ?parent_window, - ?portal_request_handle, "Received request for retrieving credential" ); diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index e19d30c..e294569 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -22,7 +22,6 @@ - @@ -34,7 +33,6 @@ - From 49d7384c38981cd40089cf8259fc696be0e736ee Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 16 Apr 2026 05:14:31 -0500 Subject: [PATCH 11/22] demo_client: Point to Credential portal interface --- demo_client/gui.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/demo_client/gui.py b/demo_client/gui.py index ad8a0aa..85defb9 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -356,7 +356,16 @@ def create_passkey( } unique_name = interface.bus.unique_name[1:].replace(".", "_") - object_path = f"/org/freedesktop/portal/request/{unique_name}/CREATE_REQUEST" + token = secrets.token_hex(16) + object_path = f"/org/freedesktop/portal/request/{unique_name}/{token}" + def message_handler(msg: Message) -> bool: + if msg.path != object_path: + return False + + print(msg.message_type, msg.body, msg.error_name) + return True + interface.bus.add_message_handler(message_handler) + rsp = interface.call_create_credential_sync( object_path, window_handle, APP_ID, APP_NAME, origin, top_origin, req, {} ) @@ -412,6 +421,18 @@ def get_passkey( unique_name = interface.bus.unique_name[1:].replace(".", "_") object_path = f"/org/freedesktop/portal/request/{unique_name}/GET_REQUEST" + def message_handler(msg: Message) -> bool: + conn_name = bus.unique_name[1:].replace(".", "_") + expected = f"/org/freedesktop/portal/desktop/request/{conn_name}/{token}" + if msg.path != expected: + return False + + print(msg.message_type, msg.body, msg.error_name) + bus.disconnect() + return True + + bus.add_message_handler(message_handler) + print(window_handle) rsp = interface.call_get_credential_sync( object_path, window_handle, APP_ID, APP_NAME, origin, top_origin, req, {} @@ -455,12 +476,13 @@ def connect_to_bus(): introspection = f.read() proxy_object = bus.get_proxy_object( - "xyz.iinuwa.credentialsd.Credentials", + "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", introspection, ) + bus. INTERFACE = proxy_object.get_interface( - "org.freedesktop.handler.portal.experimental.Credential" + "org.freedesktop.portal.desktop.CredentialsX" ) From cf92712ad4d074b89787dc3ccde65d9e6c2190be Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 17 Apr 2026 16:51:02 -0500 Subject: [PATCH 12/22] demo_client: Convert to asyncio and point to xdp interface --- demo_client/gui.py | 362 ++++++++++++-------- doc/xyz.iinuwa.credentialsd.Credentials.xml | 28 ++ 2 files changed, 253 insertions(+), 137 deletions(-) diff --git a/demo_client/gui.py b/demo_client/gui.py index 85defb9..7e8f54c 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -from dbus_next.constants import ErrorType +from asyncio.futures import Future +import asyncio from contextlib import closing -import functools import json import math import os @@ -10,20 +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 # ty: ignore[unresolved-import] +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 @@ -57,16 +62,101 @@ def dbus_proxy_object_check_method_return(msg, signature=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" @@ -80,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") @@ -94,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") @@ -116,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=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}") @@ -169,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(): @@ -201,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( @@ -244,27 +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}" - print(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) @@ -333,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, @@ -355,32 +455,31 @@ def create_passkey( "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), } - unique_name = interface.bus.unique_name[1:].replace(".", "_") - token = secrets.token_hex(16) - object_path = f"/org/freedesktop/portal/request/{unique_name}/{token}" - def message_handler(msg: Message) -> bool: - if msg.path != object_path: - return False - - print(msg.message_type, msg.body, msg.error_name) - return True - interface.bus.add_message_handler(message_handler) + request_event = create_portal_request_message_handler(interface.bus) - rsp = interface.call_create_credential_sync( - object_path, window_handle, APP_ID, APP_NAME, origin, top_origin, req, {} + print("Calling D-Bus") + rsp = await interface.call_create_credential( + window_handle, + origin, + top_origin, + req, + {"handle_token": Variant("s", request_event.token)}, ) + print(rsp) + print("waiting for response") + result = await request_event.wait() print("Received response") # pprint(rsp) - [code, value] = rsp - if code == 0: - result = value - elif code == 1: - raise Exception("Portal request cancelled") - elif code == 2 and "error" in value: - raise Exception(f"Portal returned an error: {value['error'].value}") - else: - raise Exception("Portal returned an unknown error") + # [code, value] = rsp + # if code == 0: + # result = value + # elif code == 1: + # raise Exception("Portal request cancelled") + # elif code == 2 and "error" in value: + # raise Exception(f"Portal returned an error: {value['error'].value}") + # else: + # raise Exception("Portal returned an unknown error") if result["type"].value != "public-key": raise Exception( @@ -393,7 +492,7 @@ def message_handler(msg: Message) -> bool: 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 @@ -419,42 +518,19 @@ def get_passkey( "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), } - unique_name = interface.bus.unique_name[1:].replace(".", "_") - object_path = f"/org/freedesktop/portal/request/{unique_name}/GET_REQUEST" - def message_handler(msg: Message) -> bool: - conn_name = bus.unique_name[1:].replace(".", "_") - expected = f"/org/freedesktop/portal/desktop/request/{conn_name}/{token}" - if msg.path != expected: - return False - - print(msg.message_type, msg.body, msg.error_name) - bus.disconnect() - return True + request_event = create_portal_request_message_handler(interface.bus) - bus.add_message_handler(message_handler) - - print(window_handle) - rsp = interface.call_get_credential_sync( - object_path, window_handle, APP_ID, APP_NAME, origin, top_origin, req, {} + _ = await interface.call_get_credential( + window_handle, + origin, + top_origin, + req, + {"handle_token": Variant("s", request_event.token)}, ) + result = await request_event.wait() print("Received response") # pprint(rsp) - [code, value] = rsp - if code == 0: - result = value - elif code == 1: - raise Exception("Portal request cancelled") - elif code == 2 and "error" in value: - raise Exception(f"Portal returned an error: {value['error'].value}") - else: - raise Exception("Portal returned an unknown error") - - if result["type"].value != "public-key": - raise Exception( - f"Invalid credential type received: expected 'public-key', received {result['type'].value}" - ) - response_json = json.loads( result["public_key"].value["authentication_response_json"].value ) @@ -465,9 +541,9 @@ def message_handler(msg: Message) -> bool: 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", @@ -475,19 +551,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.CredentialsX" proxy_object = bus.get_proxy_object( - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", + service_name, + path, introspection, ) - bus. - INTERFACE = proxy_object.get_interface( - "org.freedesktop.portal.desktop.CredentialsX" - ) + 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")) @@ -496,7 +571,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 ( @@ -529,14 +610,21 @@ def setup_db(): cur.close() -def main(): - connect_to_bus() +async def main(): setup_db() - 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/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index e294569..c1509d2 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -47,6 +47,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0c489f7eaa8a526b1ad670831c313b992cb2c583 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 17 Apr 2026 16:51:02 -0500 Subject: [PATCH 13/22] daemon: Use HTTPS origin for apps too --- credentialsd/src/gateway/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index fe87869..bdaac07 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -280,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 ); @@ -336,7 +336,7 @@ fn check_origin_from_app( }; Ok(RequestKind::Privileged { origin, top_origin }) } else { - Ok(RequestKind::Unprivileged(Origin::AppId(app_id.clone()))) + Ok(RequestKind::Unprivileged(origin)) } } From fae740f1aef5812aacce39b1de10d9a9b6fdcc1d Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 18 Apr 2026 09:11:50 -0500 Subject: [PATCH 14/22] Remove client capapbilities from handler; let xdp handle it directly for now --- credentialsd-common/src/model.rs | 2 + credentialsd/src/gateway/dbus.rs | 11 +--- credentialsd/src/gateway/mod.rs | 2 +- doc/xyz.iinuwa.credentialsd.Credentials.xml | 66 +++++++++++++++++++-- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index a450e6d..dd6fb7c 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 { diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 716d8f4..a81e373 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -6,7 +6,7 @@ use zbus::{ fdo, interface, message::Header, names::{BusName, UniqueName}, - zvariant::{ObjectPath, Optional, OwnedValue, Type, Value}, + zvariant::{Optional, OwnedValue, Type, Value}, Connection, DBusError, }; @@ -279,15 +279,6 @@ impl CredentialPortalGateway { .map_err(Error::from); response.into() } - - async fn get_client_capabilities(&self) -> fdo::Result { - let capabilities = self - .gateway_service - .lock() - .await - .handle_get_client_capabilities(); - Ok(capabilities) - } } #[allow(clippy::enum_variant_names)] diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index bdaac07..d982909 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -194,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, diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index c1509d2..08d8b5e 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -68,11 +68,67 @@ - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + From 0cacb0491d4eac3e2d819e74a6c92a6469596483 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 18 Apr 2026 10:51:15 -0500 Subject: [PATCH 15/22] daemon: Move portal request parameters into options method parameter --- credentialsd/src/gateway/dbus.rs | 103 ++++++++++++++++---- demo_client/gui.py | 51 ++++------ doc/xyz.iinuwa.credentialsd.Credentials.xml | 20 +--- 3 files changed, 102 insertions(+), 72 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index a81e373..ed6826c 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -1,20 +1,20 @@ -use std::{collections::HashMap, os::fd::AsRawFd, sync::Arc}; +use std::{collections::HashMap, fmt::Display, os::fd::AsRawFd, sync::Arc}; -use serde::{ser::SerializeTuple, Serialize}; +use serde::{ser::SerializeTuple, Deserialize, Serialize}; use tokio::sync::Mutex as AsyncMutex; use zbus::{ fdo, interface, message::Header, names::{BusName, UniqueName}, - zvariant::{Optional, OwnedValue, Type, Value}, + 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, }, }; @@ -199,18 +199,21 @@ impl CredentialPortalGateway { parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, - claimed_origin: String, - claimed_top_origin: Optional, - request: CreateCredentialRequest, - _options: HashMap, + cred_type: CredentialType, + options: CreateCredentialPortalOptions, ) -> PortalResult { + let CreateCredentialPortalOptions { + origin, + top_origin, + public_key: request_json, + } = options; let app_validation_result = validate_app_details( connection, &header, claimed_app_id, claimed_app_display_name.into(), - claimed_origin, - claimed_top_origin.into(), + origin.clone(), + top_origin.clone().into(), ) .await; let context = match app_validation_result { @@ -220,11 +223,18 @@ impl CredentialPortalGateway { tracing::debug!( ?context, - ?request, + ?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() @@ -244,18 +254,20 @@ impl CredentialPortalGateway { parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, - claimed_origin: String, - claimed_top_origin: Optional, - request: GetCredentialRequest, - _options: HashMap, + options: GetCredentialPortalOptions, ) -> PortalResult { + let GetCredentialPortalOptions { + origin, + top_origin, + public_key: request_json, + } = options; let app_validation_result = validate_app_details( connection, &header, claimed_app_id, claimed_app_display_name.into(), - claimed_origin, - claimed_top_origin.into(), + origin.clone(), + top_origin.clone().into(), ) .await; let context = match app_validation_result { @@ -265,11 +277,17 @@ impl CredentialPortalGateway { tracing::debug!( ?context, - ?request, + %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() @@ -281,6 +299,53 @@ impl CredentialPortalGateway { } } +#[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 { + /// The origin of the request. Must be a valid HTTPS origin. + origin: String, + + /// 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: String, +} + +#[derive(Debug, DeserializeDict, Type)] +#[zvariant(signature = "dict")] +struct GetCredentialPortalOptions { + /// The origin of the request. Must be a valid HTTPS origin. + origin: String, + + /// 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: String, +} + #[allow(clippy::enum_variant_names)] #[derive(DBusError, Debug)] #[zbus(prefix = "xyz.iinuwa.credentialsd")] diff --git a/demo_client/gui.py b/demo_client/gui.py index 7e8f54c..99b5216 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -447,39 +447,26 @@ async 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"), + "handle_token": Variant("s", request_event.token), "origin": Variant("s", origin), - "is_same_origin": Variant("b", is_same_origin), - "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), + "public_key": Variant("s", req_json), } - - request_event = create_portal_request_message_handler(interface.bus) + if top_origin != origin: + req["top_origin"] = Variant("s", top_origin) print("Calling D-Bus") - rsp = await interface.call_create_credential( - window_handle, - origin, - top_origin, - req, - {"handle_token": Variant("s", request_event.token)}, - ) + rsp = await interface.call_create_credential(window_handle, cred_type, req) print(rsp) print("waiting for response") result = await request_event.wait() print("Received response") - # pprint(rsp) - # [code, value] = rsp - # if code == 0: - # result = value - # elif code == 1: - # raise Exception("Portal request cancelled") - # elif code == 2 and "error" in value: - # raise Exception(f"Portal returned an error: {value['error'].value}") - # else: - # raise Exception("Portal returned an unknown error") if result["type"].value != "public-key": raise Exception( @@ -510,23 +497,19 @@ async def get_passkey( # pprint(options) print() + request_event = create_portal_request_message_handler(interface.bus) + + # Construct request req_json = json.dumps(options) req = { - "type": Variant("s", "publicKey"), + "handle_token": Variant("s", request_event.token), "origin": Variant("s", origin), - "is_same_origin": Variant("b", is_same_origin), - "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), + "public_key": Variant("s", req_json), } + if top_origin != origin: + req["top_origin"] = Variant("s", top_origin) - request_event = create_portal_request_message_handler(interface.bus) - - _ = await interface.call_get_credential( - window_handle, - origin, - top_origin, - req, - {"handle_token": Variant("s", request_event.token)}, - ) + _ = await interface.call_get_credential(window_handle, req) result = await request_event.wait() print("Received response") # pprint(rsp) diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index 08d8b5e..8d0ed35 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -25,9 +25,6 @@ - - - @@ -36,35 +33,20 @@ - - - - - - - - - - - + - - - - - From 2462b3e3776801d71f349b3ff0bf470d8669ec5f Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 18 Apr 2026 13:01:57 -0500 Subject: [PATCH 16/22] demo_client: Rename portal interface --- demo_client/gui.py | 2 +- doc/xyz.iinuwa.credentialsd.Credentials.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/demo_client/gui.py b/demo_client/gui.py index 99b5216..be17843 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -536,7 +536,7 @@ async def connect_to_bus(): service_name = "org.freedesktop.portal.Desktop" path = "/org/freedesktop/portal/desktop" - interface = "org.freedesktop.portal.CredentialsX" + interface = "org.freedesktop.portal.experimental.Credential" proxy_object = bus.get_proxy_object( service_name, path, diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index 8d0ed35..6c044f3 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -38,7 +38,7 @@ - + From 8d86c5debf24f9a4f46f4e8d206567d6318b5806 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sun, 19 Apr 2026 08:34:46 -0500 Subject: [PATCH 17/22] docs: Update docs to reflect portal gateway API changes --- doc/api.md | 151 +++++++++++++++++++++++++++-------------------------- 1 file changed, 78 insertions(+), 73 deletions(-) diff --git a/doc/api.md b/doc/api.md index 0a750a6..9abea4a 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,11 +199,14 @@ for what kind of credential the client would like to create. ``` CreateCredentialRequest( IN parent_window s, + IN app_id s, + IN app_display_name s, + IN origin s, + IN type CredentialType, IN options a{sv} { - origin: string - is_same_origin: string - type: CredentialType - + handle_token: s + top_origin: 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 app_id s, + IN app_display_name s, + IN origin s + IN type s = "publicKey" + options a{sv} { + , + public_key: s // WebAuthn credential attestation JSON + } + ) + ### 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 @@ -309,10 +320,13 @@ credentials the client will accept. ``` GetCredentialRequest ( IN parent_window s + IN app_id s, + IN app_display_name s, + IN origin s IN options a{sv} { - origin: string - is_same_origin: string - publicKey: GetPublicKeyCredentialOptions? + top_origin: s + + public_key: s } ) ``` @@ -326,29 +340,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?) - -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 -} -``` +##### WebAuthn Credential Request -`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 +366,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 From 6f427396c96a6fe73f96178b772ad3ff6619e679 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sun, 19 Apr 2026 08:38:12 -0500 Subject: [PATCH 18/22] Remove Client Capabilities method from API doc --- doc/api.md | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/doc/api.md b/doc/api.md index 9abea4a..ad8e912 100644 --- a/doc/api.md +++ b/doc/api.md @@ -404,31 +404,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 From 504b2b0d3642ea5faefe28bc51ef5c5f54b23018 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Mon, 20 Apr 2026 22:42:21 -0500 Subject: [PATCH 19/22] daemon: Move origin and type to top-level parameter --- credentialsd/src/gateway/dbus.rs | 33 +++++++++++++-------- demo_client/gui.py | 11 ++++--- doc/xyz.iinuwa.credentialsd.Credentials.xml | 5 ++++ 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index ed6826c..143f028 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -199,14 +199,23 @@ impl CredentialPortalGateway { parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, + origin: String, cred_type: CredentialType, options: CreateCredentialPortalOptions, ) -> PortalResult { let CreateCredentialPortalOptions { - origin, top_origin, - public_key: request_json, + 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, @@ -254,12 +263,12 @@ impl CredentialPortalGateway { parent_window: Optional, claimed_app_id: String, claimed_app_display_name: Optional, + origin: String, options: GetCredentialPortalOptions, ) -> PortalResult { let GetCredentialPortalOptions { - origin, top_origin, - public_key: request_json, + public_key, } = options; let app_validation_result = validate_app_details( connection, @@ -270,6 +279,12 @@ impl CredentialPortalGateway { 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(), @@ -317,9 +332,6 @@ impl Display for CredentialType { #[derive(Debug, DeserializeDict, Type)] #[zvariant(signature = "dict")] struct CreateCredentialPortalOptions { - /// The origin of the request. Must be a valid HTTPS origin. - origin: String, - /// The top-level origin of the client window for cross-origin requests. /// If omitted, denotes a same-origin request. top_origin: Option, @@ -327,15 +339,12 @@ struct CreateCredentialPortalOptions { /// A string of JSON that corresponds to the WebAuthn /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) /// type. - public_key: String, + public_key: Option, } #[derive(Debug, DeserializeDict, Type)] #[zvariant(signature = "dict")] struct GetCredentialPortalOptions { - /// The origin of the request. Must be a valid HTTPS origin. - origin: String, - /// The top-level origin of the client window for cross-origin requests. /// If omitted, denotes a same-origin request. top_origin: Option, @@ -343,7 +352,7 @@ struct GetCredentialPortalOptions { /// A string of JSON that corresponds to the WebAuthn /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) /// type. - public_key: String, + public_key: Option, } #[allow(clippy::enum_variant_names)] diff --git a/demo_client/gui.py b/demo_client/gui.py index be17843..e21c940 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -454,14 +454,13 @@ async def create_passkey( req_json = json.dumps(options) req = { "handle_token": Variant("s", request_event.token), - "origin": Variant("s", origin), "public_key": Variant("s", req_json), } if top_origin != origin: req["top_origin"] = Variant("s", top_origin) print("Calling D-Bus") - rsp = await interface.call_create_credential(window_handle, cred_type, req) + rsp = await interface.call_create_credential(window_handle, origin, cred_type, req) print(rsp) print("waiting for response") result = await request_event.wait() @@ -499,17 +498,17 @@ async def get_passkey( request_event = create_portal_request_message_handler(interface.bus) + print(request_event.token) # Construct request req_json = json.dumps(options) - req = { + portal_options = { "handle_token": Variant("s", request_event.token), - "origin": Variant("s", origin), "public_key": Variant("s", req_json), } if top_origin != origin: - req["top_origin"] = Variant("s", top_origin) + portal_options["top_origin"] = Variant("s", top_origin) - _ = await interface.call_get_credential(window_handle, req) + _ = await interface.call_get_credential(window_handle, origin, portal_options) result = await request_event.wait() print("Received response") # pprint(rsp) diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index 6c044f3..e69e01f 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -25,6 +25,8 @@ + + @@ -33,6 +35,7 @@ + @@ -41,12 +44,14 @@ + + From 0f01e7959dd3731cbe1fc7950f911370c0f065fa Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Mon, 20 Apr 2026 22:50:23 -0500 Subject: [PATCH 20/22] daemon: append portal handler-specific parameters to request --- credentialsd/src/gateway/dbus.rs | 8 +++--- doc/api.md | 30 ++++++++++----------- doc/xyz.iinuwa.credentialsd.Credentials.xml | 8 +++--- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 143f028..c7a527a 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -197,11 +197,11 @@ impl CredentialPortalGateway { #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, parent_window: Optional, - claimed_app_id: String, - claimed_app_display_name: Optional, origin: String, cred_type: CredentialType, options: CreateCredentialPortalOptions, + claimed_app_id: String, + claimed_app_display_name: Optional, ) -> PortalResult { let CreateCredentialPortalOptions { top_origin, @@ -261,10 +261,10 @@ impl CredentialPortalGateway { #[zbus(connection)] connection: &Connection, #[zbus(header)] header: Header<'_>, parent_window: Optional, - claimed_app_id: String, - claimed_app_display_name: Optional, origin: String, options: GetCredentialPortalOptions, + claimed_app_id: String, + claimed_app_display_name: Optional, ) -> PortalResult { let GetCredentialPortalOptions { top_origin, diff --git a/doc/api.md b/doc/api.md index ad8e912..970fc72 100644 --- a/doc/api.md +++ b/doc/api.md @@ -109,7 +109,7 @@ this API takes: ``` [a{sv}] { IN origin s = "https://example.com", - IN type = "password" + IN type = "password", options a{sv} = { top_origin: Variant("https://example.com"), // topOrigin is changed to top_origin password: Variant(true), @@ -199,15 +199,15 @@ for what kind of credential the client would like to create. ``` CreateCredentialRequest( IN parent_window s, - IN app_id s, - IN app_display_name s, IN origin s, IN type CredentialType, IN options a{sv} { handle_token: s top_origin: s - } + }, + IN app_id s, + IN app_display_name s ) ``` @@ -253,15 +253,15 @@ WebAuthn type. CreatePublicKeyCredentialRequest: CreateCredentialRequest ( - IN parent_window s - IN app_id s, - IN app_display_name s, - IN origin s - IN type s = "publicKey" + 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 @@ -319,15 +319,15 @@ credentials the client will accept. ``` GetCredentialRequest ( - IN parent_window s - IN app_id s, - IN app_display_name s, - IN origin s + IN parent_window s, + IN origin s, IN options a{sv} { top_origin: s public_key: s - } + }, + IN app_id s, + IN app_display_name s ) ``` diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index e69e01f..3d95735 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -23,20 +23,20 @@ - - + + - - + + From 33312a6a12f342da1d153c28824cc73455dc4398 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Mon, 20 Apr 2026 23:22:00 -0500 Subject: [PATCH 21/22] daemon: reference activation_token --- credentialsd/src/gateway/dbus.rs | 8 ++++++++ doc/api.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index c7a527a..94f9846 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -204,6 +204,7 @@ impl CredentialPortalGateway { claimed_app_display_name: Optional, ) -> PortalResult { let CreateCredentialPortalOptions { + activation_token: _, top_origin, public_key, } = options; @@ -267,6 +268,7 @@ impl CredentialPortalGateway { claimed_app_display_name: Optional, ) -> PortalResult { let GetCredentialPortalOptions { + activation_token: _, top_origin, public_key, } = options; @@ -332,6 +334,9 @@ impl Display for CredentialType { #[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, @@ -345,6 +350,9 @@ struct CreateCredentialPortalOptions { #[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, diff --git a/doc/api.md b/doc/api.md index 970fc72..115f420 100644 --- a/doc/api.md +++ b/doc/api.md @@ -202,7 +202,7 @@ CreateCredentialRequest( IN origin s, IN type CredentialType, IN options a{sv} { - handle_token: s + activation_token: s top_origin: s }, @@ -322,6 +322,7 @@ GetCredentialRequest ( IN parent_window s, IN origin s, IN options a{sv} { + activation_token: s top_origin: s public_key: s From d784de78f0ea9c1d1f26be1539ead60ee1e93629 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Tue, 21 Apr 2026 04:32:26 -0500 Subject: [PATCH 22/22] Remove client capabilities test --- credentialsd/tests/dbus.rs | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) 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};