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-common/src/model.rs b/credentialsd-common/src/model.rs index 7c2519b..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 { @@ -110,7 +112,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-common/src/server.rs b/credentialsd-common/src/server.rs index 8096634..fa5848d 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 { @@ -197,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/dbus.rs b/credentialsd/src/gateway/dbus.rs index 8bf7e7d..94f9846 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -1,31 +1,32 @@ -use std::{os::fd::AsRawFd, sync::Arc}; +use std::{collections::HashMap, fmt::Display, os::fd::AsRawFd, sync::Arc}; +use serde::{ser::SerializeTuple, Deserialize, Serialize}; use tokio::sync::Mutex as AsyncMutex; use zbus::{ fdo, interface, message::Header, names::{BusName, UniqueName}, - zvariant::Optional, + zvariant::{DeserializeDict, Optional, Type, Value}, Connection, DBusError, }; use credentialsd_common::{ model::{GetClientCapabilitiesResponse, RequestingApplication, WebAuthnError}, server::{ - CreateCredentialRequest, CreateCredentialResponse, GetCredentialRequest, - GetCredentialResponse, WindowHandle, + CreateCredentialRequest, CreateCredentialResponse, CreatePublicKeyCredentialRequest, + GetCredentialRequest, GetCredentialResponse, GetPublicKeyCredentialRequest, WindowHandle, }, }; -use crate::webauthn::{AppId, NavigationContext, Origin}; +use crate::webauthn::{AppId, Origin}; use super::{ - check_origin_from_app, check_origin_from_privileged_client, get_app_info_from_pid, - GatewayService, + check_origin_from_app, get_app_info_from_pid, GatewayService, RequestContext, RequestKind, }; pub const SERVICE_NAME: &str = "xyz.iinuwa.credentialsd.Credentials"; pub const SERVICE_PATH: &str = "/xyz/iinuwa/credentialsd/Credentials"; +pub const PORTAL_SERVICE_PATH: &str = "/org/freedesktop/portal/desktop"; pub(super) async fn start_dbus_gateway( svc: Arc>, @@ -41,11 +42,17 @@ pub(super) async fn start_dbus_gateway( gateway_service: svc.clone(), }, )? + .serve_at( + PORTAL_SERVICE_PATH, + CredentialPortalGateway { + gateway_service: svc, + }, + )? .build() .await } -/// Struct to hold state for the D-Bus interface. +/// Struct to hold state for the privileged D-Bus interface. struct CredentialGateway { /// Service responsible for processing credential requests. gateway_service: Arc>, @@ -66,44 +73,19 @@ impl CredentialGateway { parent_window: Optional, request: CreateCredentialRequest, ) -> Result { - // TODO: Add authorization check for privileged client. - let top_origin = if request.is_same_origin.unwrap_or_default() { - None - } else { - // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. - // We should still reject cross-origin requests for conditionally-mediated requests. - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError.into()); - }; - let Some(origin) = request - .origin - .as_ref() - .map(|o| { - o.parse::().map_err(|_| { - tracing::warn!("Invalid origin specified: {:?}", request.origin); - Error::SecurityError - }) - }) - .transpose()? - else { - tracing::warn!( - "Caller requested implicit origin, which is not yet implemented. Rejecting request." - ); - return Err(Error::SecurityError); - }; - let request_environment = check_origin_from_privileged_client(origin, top_origin)?; - // Find out where this request is coming from (which application is requesting this) - let requesting_app = query_connection_peer_binary(header, connection).await; + let context = extract_client_details( + header, + connection, + request.origin.as_ref().cloned(), + request.is_same_origin.unwrap_or_default(), + ) + .await?; + let response = self .gateway_service .lock() .await - .handle_create_credential( - request, - request_environment, - requesting_app, - parent_window.into(), - ) + .handle_create_credential(request, context, parent_window.into()) .await?; Ok(response) } @@ -115,44 +97,19 @@ impl CredentialGateway { parent_window: Optional, request: GetCredentialRequest, ) -> Result { - // TODO: Add authorization check for privileged client. - let top_origin = if request.is_same_origin.unwrap_or_default() { - None - } else { - // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. - // We should still reject cross-origin requests for conditionally-mediated requests. - tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); - return Err(WebAuthnError::NotAllowedError.into()); - }; - let Some(origin) = request - .origin - .as_ref() - .map(|o| { - o.parse::().map_err(|_| { - tracing::warn!("Invalid origin specified: {:?}", request.origin); - Error::SecurityError - }) - }) - .transpose()? - else { - tracing::warn!( - "Caller requested implicit origin, which is not yet implemented. Rejecting request." - ); - return Err(Error::SecurityError); - }; - let request_environment = check_origin_from_privileged_client(origin, top_origin)?; - // Find out where this request is coming from (which application is requesting this) - let requesting_app = query_connection_peer_binary(header, connection).await; + let context = extract_client_details( + header, + connection, + request.origin.as_ref().cloned(), + request.is_same_origin.unwrap_or_default(), + ) + .await?; + let response = self .gateway_service .lock() .await - .handle_get_credential( - request, - request_environment, - requesting_app, - parent_window.into(), - ) + .handle_get_credential(request, context, parent_window.into()) .await?; Ok(response) } @@ -167,6 +124,245 @@ impl CredentialGateway { } } +/// Returns contextual details about the client and the request needed for +/// authorization. +async fn extract_client_details( + header: Header<'_>, + connection: &Connection, + origin: Option, + is_same_origin: bool, +) -> Result { + let top_origin = if is_same_origin { + None + } else { + // TODO: Once we modify the models to convey the top-origin in cross origin requests to the UI, we can remove this error message. + // We should still reject cross-origin requests for conditionally-mediated requests. + tracing::warn!("Client attempted to issue cross-origin request for credentials, which are not supported by this platform."); + return Err(WebAuthnError::NotAllowedError.into()); + }; + /* + let top_origin = + top_origin.as_ref() + .map(|o| o.parse::()) + .transpose() + .map_err(|err| { + tracing::warn!(%err, "Invalid top origin specified: {:?}", client_details.top_origin); + WebAuthnError::SecurityError + })?; + */ + + let Some(origin) = origin.as_ref().cloned() else { + tracing::warn!( + "Caller requested implicit origin, which is not yet implemented. Rejecting request." + ); + return Err(Error::SecurityError); + }; + let origin = origin.parse::().map_err(|err| { + tracing::warn!(%err, "Invalid origin specified: {:?}", origin); + WebAuthnError::SecurityError + })?; + + // Find out where this request is coming from (which application is requesting this) + let requesting_app = query_connection_peer_binary(header, connection) + .await + .ok_or_else(|| { + tracing::error!("Could not retrieve client details from D-Bus connection"); + Error::SecurityError + })?; + Ok(RequestContext { + app_id: "xyz.iinuwa.credentialsd.CredentialGateway".parse().unwrap(), // hardcoding this for now; this will be obsolete soon + app_name: requesting_app.name.as_ref().unwrap().clone(), + pid: requesting_app.pid, + request_kind: RequestKind::Privileged { origin, top_origin }, + }) +} + +/// Struct to hold state for the portal D-Bus interface. +struct CredentialPortalGateway { + /// Service responsible for processing credential requests. + gateway_service: Arc>, +} + +/// These are public methods that can be called by arbitrary clients to begin a +/// credential flow. +/// +/// The D-Bus interface is responsible for authorizing the client and collecting +/// the contextual information about the client to pass onto the GatewayService +/// for evaluation. +#[interface(name = "org.freedesktop.handler.portal.experimental.Credential")] +impl CredentialPortalGateway { + #[zbus(out_args("response", "results"))] + async fn create_credential( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + parent_window: Optional, + origin: String, + cred_type: CredentialType, + options: CreateCredentialPortalOptions, + claimed_app_id: String, + claimed_app_display_name: Optional, + ) -> PortalResult { + let CreateCredentialPortalOptions { + activation_token: _, + top_origin, + public_key, + } = options; + + let request_json = match (&cred_type, public_key) { + (CredentialType::PublicKey, Some(json)) => json, + (CredentialType::PublicKey, None) => { + tracing::warn!("Client did not send `public_key` request with type `publicKey`"); + return Err(Error::TypeError).into(); + } + }; + + let app_validation_result = validate_app_details( + connection, + &header, + claimed_app_id, + claimed_app_display_name.into(), + origin.clone(), + top_origin.clone().into(), + ) + .await; + let context = match app_validation_result { + Ok(context) => context, + Err(err) => return Err(err).into(), + }; + + tracing::debug!( + ?context, + ?request_json, + ?parent_window, + "Received request for creating credential" + ); + + let request = CreateCredentialRequest { + origin: Some(origin.clone()), + is_same_origin: Some(top_origin.is_none()), + r#type: cred_type.to_string(), + public_key: Some(CreatePublicKeyCredentialRequest { request_json }), + }; + + let response = self + .gateway_service + .lock() + .await + .handle_create_credential(request, context, parent_window.into()) + .await + .map_err(Error::from); + + response.into() + } + + #[zbus(out_args("response", "results"))] + async fn get_credential( + &self, + #[zbus(connection)] connection: &Connection, + #[zbus(header)] header: Header<'_>, + parent_window: Optional, + origin: String, + options: GetCredentialPortalOptions, + claimed_app_id: String, + claimed_app_display_name: Optional, + ) -> PortalResult { + let GetCredentialPortalOptions { + activation_token: _, + top_origin, + public_key, + } = options; + let app_validation_result = validate_app_details( + connection, + &header, + claimed_app_id, + claimed_app_display_name.into(), + origin.clone(), + top_origin.clone().into(), + ) + .await; + + let Some(request_json) = public_key else { + tracing::warn!("Client did not send parameters for any valid credential type."); + return Err(Error::TypeError).into(); + }; + + let context = match app_validation_result { + Ok(context) => context, + Err(err) => return Err(err).into(), + }; + + tracing::debug!( + ?context, + %request_json, + ?parent_window, + "Received request for retrieving credential" + ); + + let request = GetCredentialRequest { + origin: Some(origin), + is_same_origin: Some(top_origin.is_none()), + public_key: Some(GetPublicKeyCredentialRequest { request_json }), + }; + + let response = self + .gateway_service + .lock() + .await + .handle_get_credential(request, context, parent_window.into()) + .await + .map_err(Error::from); + response.into() + } +} + +#[derive(Debug, Deserialize, Type)] +#[zvariant(signature = "s")] +enum CredentialType { + #[serde(rename = "publicKey")] + PublicKey, +} + +impl Display for CredentialType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CredentialType::PublicKey => f.write_str("publicKey"), + } + } +} + +#[derive(Debug, DeserializeDict, Type)] +#[zvariant(signature = "dict")] +struct CreateCredentialPortalOptions { + /// A token that can be used to activate the UI window. + activation_token: Option, + + /// The top-level origin of the client window for cross-origin requests. + /// If omitted, denotes a same-origin request. + top_origin: Option, + + /// A string of JSON that corresponds to the WebAuthn + /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) + /// type. + public_key: Option, +} + +#[derive(Debug, DeserializeDict, Type)] +#[zvariant(signature = "dict")] +struct GetCredentialPortalOptions { + /// A token that can be used to activate the UI window. + activation_token: Option, + + /// The top-level origin of the client window for cross-origin requests. + /// If omitted, denotes a same-origin request. + top_origin: Option, + + /// A string of JSON that corresponds to the WebAuthn + /// [PublicKeyCredentialRequestOptions](https://www.w3.org/TR/webauthn-3/#publickeycredential) + /// type. + public_key: Option, +} + #[allow(clippy::enum_variant_names)] #[derive(DBusError, Debug)] #[zbus(prefix = "xyz.iinuwa.credentialsd")] @@ -224,14 +420,61 @@ impl From for Error { } } +#[repr(u32)] +#[derive(Serialize)] +enum PortalResponse { + Success = 0, + Cancelled = 1, + Other = 2, +} + +#[derive(Type)] +#[zvariant(signature = "ua{sv}")] +struct PortalResult { + inner: Result, +} + +impl Serialize for PortalResult +where + T: Serialize + Type, + E: std::error::Error, +{ + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_tuple(2)?; + match &self.inner { + Err(err) => { + map.serialize_element(&(PortalResponse::Other as u32))?; + map.serialize_element(&HashMap::<&str, Value<'_>>::from([( + "error", + Value::Str(err.to_string().into()), + )]))?; + } + Ok(response) => { + map.serialize_element(&(PortalResponse::Success as u32))?; + map.serialize_element(&response)?; + } + }; + map.end() + } +} + +impl From> for PortalResult { + fn from(value: Result) -> Self { + PortalResult { inner: value } + } +} + async fn validate_app_details( connection: &Connection, header: &Header<'_>, claimed_app_id: String, claimed_app_display_name: Option, - claimed_origin: Option, + claimed_origin: String, claimed_top_origin: Option, -) -> Result<(RequestingApplication, NavigationContext), Error> { +) -> Result { let Some(unique_name) = header.sender() else { return Err(Error::SecurityError); }; @@ -252,7 +495,11 @@ async fn validate_app_details( let display_name = claimed_app_display_name.unwrap_or_default(); // Verify that the origin is valid for the given app ID. - let claimed_origin = claimed_origin + let claimed_origin = claimed_origin.parse().map_err(|err| { + tracing::warn!(%err, "Invalid origin passed: {claimed_origin}"); + Error::SecurityError + })?; + let claimed_top_origin = claimed_top_origin .map(|o| { o.parse().map_err(|_| { tracing::warn!("Invalid origin passed: {o}"); @@ -260,25 +507,14 @@ async fn validate_app_details( }) }) .transpose()?; - let request_env = if let Some(claimed_origin) = claimed_origin { - let claimed_top_origin = claimed_top_origin - .map(|o| { - o.parse().map_err(|_| { - tracing::warn!("Invalid origin passed: {o}"); - Error::SecurityError - }) - }) - .transpose()?; - check_origin_from_app(&app_id, claimed_origin, claimed_top_origin)? - } else { - NavigationContext::SameOrigin(Origin::AppId(app_id)) - }; - let app_details = RequestingApplication { - name: Some(display_name).into(), - path_or_app_id: claimed_app_id, + let request_kind = check_origin_from_app(&app_id, claimed_origin, claimed_top_origin)?; + + Ok(RequestContext { + app_id, + app_name: display_name, pid, - }; - Ok((app_details, request_env)) + request_kind, + }) } async fn query_peer_pid_via_fdinfo( diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 6c06f70..d982909 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -35,6 +35,38 @@ pub async fn start_gateway, + }, + /// Unprivileged clients may only set an origin, which will be verified + /// against a static list of allowed origins for the client. + Unprivileged(Origin), +} + +/// Details about the credential request and the client making it. +#[derive(Debug)] +struct RequestContext { + app_id: AppId, + app_name: String, + pid: u32, + request_kind: RequestKind, +} + +impl From for RequestingApplication { + fn from(value: RequestContext) -> Self { + RequestingApplication { + path_or_app_id: value.app_id.as_ref().to_string(), + name: Some(value.app_name).into(), + pid: value.pid, + } + } +} + /// Service responsible for processing credential requests received from various /// client interfaces. struct GatewayService { @@ -47,10 +79,11 @@ impl GatewayService { async fn handle_create_credential( &self, request: CreateCredentialRequest, - request_environment: NavigationContext, - requesting_app: Option, + context: RequestContext, parent_window: Option, ) -> Result { + let request_environment = validate_request(&context)?; + if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { // TODO: assert that RP ID is bound to origin: // - if RP ID is not set, set the RP ID to the origin's effective domain @@ -74,7 +107,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(requesting_app, cred_request, parent_window) + .request_credential(Some(context.into()), cred_request, parent_window) .await?; if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { @@ -104,11 +137,12 @@ impl GatewayService { async fn handle_get_credential( &self, request: GetCredentialRequest, - request_environment: NavigationContext, - requesting_app: Option, + context: RequestContext, parent_window: Option, ) -> Result { - if let ("publicKey", Some(_)) = (request.r#type.as_ref(), &request.public_key) { + let request_environment = validate_request(&context)?; + + if request.public_key.is_some() { // Setup request // TODO: assert that RP ID is bound to origin: @@ -128,7 +162,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(requesting_app, cred_request, parent_window) + .request_credential(Some(context.into()), cred_request, parent_window) .await?; if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { @@ -150,7 +184,7 @@ impl GatewayService { Err(WebAuthnError::NotAllowedError) } } else { - tracing::error!("Unknown credential type request: {}", request.r#type); + tracing::error!("Request did not match any known credential types. Supported types: [`public_key`]."); Err(WebAuthnError::TypeError) } } @@ -160,7 +194,7 @@ impl GatewayService { conditional_create: false, conditional_get: false, hybrid_transport: true, - passkey_platform_authenticator: false, + passkey_platform_authenticator: true, user_verifying_platform_authenticator: false, related_origins: false, signal_all_accepted_credentials: false, @@ -170,6 +204,29 @@ impl GatewayService { } } +/// Verifies that the calling client is able to request credentials for the +/// given origin, then returns the origin. +fn validate_request(context: &RequestContext) -> Result { + let request_environment = match &context.request_kind { + RequestKind::Privileged { origin, top_origin } => { + check_origin_from_privileged_client(origin, top_origin.as_ref())? + } + RequestKind::Unprivileged(origin) => { + let origin_allowed_for_app_id = true; + if origin_allowed_for_app_id { + NavigationContext::SameOrigin(origin.clone()) + } else { + tracing::warn!( + "App ID {:?} is not allowed for origin {origin}", + context.app_id + ); + return Err(WebAuthnError::SecurityError); + } + } + }; + Ok(request_environment) +} + fn get_app_info_from_pid(pid: u32) -> Option { // Get binary path via PID from /proc file-system // TODO: To be REALLY sure, we may want to look at /proc/PID/exe instead. It is a symlink to @@ -223,7 +280,7 @@ async fn should_trust_app_id(pid: u32) -> bool { return false; }; tracing::debug!( - "mount namespace:\n ours:\t{:?}\n theirs:\t{:?}", + "mount namespace:\n ours: {:?}\n theirs: {:?}", my_mnt_ns, peer_mnt_ns ); @@ -255,30 +312,48 @@ fn check_origin_from_app( app_id: &AppId, origin: Origin, top_origin: Option, -) -> Result { - let trusted_clients = [ - "org.mozilla.firefox", - "xyz.iinuwa.credentialsd.DemoCredentialsUi", - ]; - let is_privileged_client = trusted_clients.contains(&app_id.as_ref()); +) -> Result { + let is_privileged_client = { + let trusted_clients = [ + "org.mozilla.firefox", + "xyz.iinuwa.credentialsd.DemoCredentialsUi", + ]; + let mut privileged = trusted_clients.contains(&app_id.as_ref()); + if cfg!(debug_assertions) && !privileged { + let trusted_clients_env = std::env::var("CREDSD_TRUSTED_APP_IDS").unwrap_or_default(); + privileged = trusted_clients_env + .split(',') + .map(String::from) + .any(|c| app_id.as_ref() == c); + } + privileged + }; if is_privileged_client { - check_origin_from_privileged_client(origin, top_origin) + let (origin, top_origin) = + match check_origin_from_privileged_client(&origin, top_origin.as_ref())? { + NavigationContext::SameOrigin(origin) => (origin, None), + NavigationContext::CrossOrigin((origin, top_origin)) => (origin, Some(top_origin)), + }; + Ok(RequestKind::Privileged { origin, top_origin }) } else { - Ok(NavigationContext::SameOrigin(Origin::AppId(app_id.clone()))) + Ok(RequestKind::Unprivileged(origin)) } } fn check_origin_from_privileged_client( - origin: Origin, - top_origin: Option, + origin: &Origin, + top_origin: Option<&Origin>, ) -> Result { match (origin, top_origin) { - (origin @ Origin::Https { .. }, None) => Ok(NavigationContext::SameOrigin(origin)), + (origin @ Origin::Https { .. }, None) => Ok(NavigationContext::SameOrigin(origin.clone())), (origin @ Origin::Https { .. }, Some(top_origin @ Origin::Https { .. })) => { if origin == top_origin { - Ok(NavigationContext::SameOrigin(origin)) + Ok(NavigationContext::SameOrigin(origin.clone())) } else { - Ok(NavigationContext::CrossOrigin((origin, top_origin))) + Ok(NavigationContext::CrossOrigin(( + origin.clone(), + top_origin.clone(), + ))) } } _ => { @@ -297,7 +372,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), diff --git a/credentialsd/tests/dbus.rs b/credentialsd/tests/dbus.rs index 2b03aed..d41e4b0 100644 --- a/credentialsd/tests/dbus.rs +++ b/credentialsd/tests/dbus.rs @@ -1,3 +1,4 @@ +#![expect(unused)] #[rustfmt::skip] mod config; @@ -6,35 +7,6 @@ use std::collections::HashMap; use client::DbusClient; use zbus::zvariant::Value; -#[test] -fn test_client_capabilities() { - let client = DbusClient::new(); - let msg = client.call_method("GetClientCapabilities", &()).unwrap(); - let body = msg.body(); - let rsp: HashMap = body - .deserialize::>() - .unwrap() - .into_iter() - .map(|(k, v)| (k, v.try_into().unwrap())) - .collect(); - - let capabilities = HashMap::from([ - ("conditionalCreate", false), - ("conditionalGet", false), - ("hybridTransport", true), - ("passkeyPlatformAuthenticator", false), - ("userVerifyingPlatformAuthenticator", false), - ("relatedOrigins", false), - ("signalAllAcceptedCredentials", false), - ("signalCurrentUserDetails", false), - ("signalUnknownCredential", false), - ]); - for (key, expected) in capabilities.iter() { - let actual = rsp.get(*key).unwrap(); - assert_eq!(*expected, *actual); - } -} - mod client { use crate::config::{INTERFACE, PATH, SERVICE_DIR, SERVICE_NAME}; use gio::{TestDBus, TestDBusFlags}; diff --git a/demo_client/gui.py b/demo_client/gui.py index f9e01a7..e21c940 100755 --- a/demo_client/gui.py +++ b/demo_client/gui.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 +from asyncio.futures import Future +import asyncio from contextlib import closing -import functools import json import math import os @@ -9,19 +10,25 @@ import secrets import sqlite3 import sys +import threading import time -from typing import Optional +from typing import Optional, Coroutine import uuid -from dbus_next.glib import MessageBus, ProxyInterface +from dbus_next.aio import MessageBus, ProxyInterface +from dbus_next.constants import ErrorType +from dbus_next.proxy_object import BaseProxyInterface from dbus_next import DBusError, Message, MessageType, Variant import gi +from gi.events import GLibEventLoop + gi.require_version("Gtk", "4.0") gi.require_version("GdkWayland", "4.0") gi.require_version("Adw", "1") -from gi.repository import GdkWayland, Gio, GObject, Gtk, Adw # noqa: E402,F401 +from gi.repository import GdkWayland, Gio, GLib, GObject, Gtk, Adw # noqa: E402,F401 # ty: ignore[unresolved-import] + import webauthn # noqa: E402 import util # noqa: E402 @@ -32,18 +39,124 @@ def dbus_error_from_message(msg: Message): return DBusError(msg.error_name, msg.body[0] if msg.body else None, reply=msg) -DBusError._from_message = dbus_error_from_message +DBusError._from_message = dbus_error_from_message # ty: ignore[invalid-assignment] + + +@staticmethod +def dbus_proxy_object_check_method_return(msg, signature=None): + if msg.message_type == MessageType.ERROR: + raise DBusError._from_message(msg) + elif msg.message_type != MessageType.METHOD_RETURN: + raise DBusError( + ErrorType.CLIENT_ERROR, "method call didnt return a method return", msg + ) + elif signature is not None and msg.signature != signature: + raise DBusError( + ErrorType.CLIENT_ERROR, + f'method call returned unexpected signature: "{msg.signature}", expected {signature}', + msg, + ) + + +BaseProxyInterface._check_method_return = dbus_proxy_object_check_method_return -INTERFACE = None -DB = None -KEY = None +APP_ID = "xyz.iinuwa.credentialsd.DemoCredentialsUi" +APP_NAME = "Demo UI" # TODO: This should be looked up from .desktop file. +LOOP: asyncio.AbstractEventLoop = None # ty: ignore[invalid-assignment] +INTERFACE: ProxyInterface = None # ty: ignore[invalid-assignment] +DB: sqlite3.Connection = None # ty: ignore[invalid-assignment] RESOURCE_FILE = Gio.Resource.load( f"{os.path.dirname(os.path.realpath(__file__))}/resources.gresource" ) Gio.resources_register(RESOURCE_FILE) +def task_spawn(coro: Coroutine, callback): + fut = asyncio.run_coroutine_threadsafe(coro, LOOP) + + def call_when_done(): + if callback: + callback(fut.result()) + else: + fut.result() + + fut.add_done_callback(lambda _: GLib.idle_add(call_when_done)) + + +async def get_surface_handle(toplevel) -> str: + # Ensure it's a Wayland toplevel + if not isinstance(toplevel, GdkWayland.WaylandToplevel): + # X11 toplevel is synchronous + return toplevel.export_handle() + + loop = asyncio.get_running_loop() + future = loop.create_future() + + def on_handle_exported(_toplevel, handle): + loop.call_soon_threadsafe(future.set_result, handle) + + toplevel.export_handle(on_handle_exported) + + handle = await future + return handle + + +class PortalRequest[T]: + def __init__(self, token: str, fut: Future): + self.token: str = token + self._fut: Future = fut + + async def wait(self) -> T: + return await self._fut + + +def create_portal_request_message_handler(bus: MessageBus) -> PortalRequest: + loop = asyncio.get_running_loop() + future = loop.create_future() + if not bus.connected or bus.unique_name is None: + raise Exception("Bus is not connected") + unique_name = bus.unique_name[1:].replace(".", "_") + token = secrets.token_hex(16) + object_path = f"/org/freedesktop/portal/desktop/request/{unique_name}/{token}" + + def message_handler(msg: Message): + if future.done(): + return False + + message_matches = ( + msg.path == object_path + and msg.message_type == MessageType.SIGNAL + and msg.destination == bus.unique_name + and msg.interface == "org.freedesktop.portal.Request" + and msg.member == "Response" + ) + if not message_matches: + return False + + [code, value] = msg.body + if code == 0: + future.set_result(value) + elif code == 1: + future.set_exception(Exception("Portal request cancelled")) + raise + elif code == 2 and "error" in value: + future.set_exception( + Exception(f"Portal returned an error: {value['error'].value}") + ) + else: + future.set_exception(Exception("Portal returned an unknown error")) + return True + + def when_done(_fut): + bus.remove_message_handler(message_handler) + + future.add_done_callback(when_done) + bus.add_message_handler(message_handler) + print(f"Listening for {object_path}") + return PortalRequest(token, future) + + @Gtk.Template(resource_path="/xyz/iinuwa/credentialsd/DemoCredentialsUi/window.ui") class MainWindow(Gtk.ApplicationWindow): __gtype_name__ = "MyAppWindow" @@ -57,7 +170,7 @@ class MainWindow(Gtk.ApplicationWindow): origin = "https://example.com" interface = None - def on_activate(self, app): + def on_activate(self, _app): # Create a Builder builder = Gtk.Builder() builder.add_from_file("build/window.ui") @@ -71,8 +184,12 @@ def on_activate(self, app): @Gtk.Template.Callback() def on_register(self, *args): print("register clicked") + task_spawn(self.register_passkey(), None) + + async def register_passkey(self): now = math.floor(time.time()) - cur = DB.cursor() + db = connect_db() + cur = db.cursor() username = self.username.get_text() if not username: print("Username is required") @@ -93,50 +210,56 @@ def on_register(self, *args): options = self._get_registration_options(user_handle, username) print(f"registration options: {options}") - def cb(user_id, toplevel, handle): - cur = DB.cursor() - window_handle = f"wayland:{handle}" - print(window_handle) - auth_data = create_passkey( - INTERFACE, window_handle, self.origin, self.origin, options + toplevel = self.get_surface() + handle = await get_surface_handle(toplevel) + + window_handle = f"wayland:{handle}" + print(window_handle) + auth_data = await create_passkey( + INTERFACE, window_handle, self.origin, self.origin, options + ) + + try: + handle = window_handle[window_handle.find(":") + 1 :] + toplevel.unexport_handle(handle) + except Exception as err: + print(err) + + if not user_id: + cur.execute( + "insert into users (username, user_handle, created_time) values (?, ?, ?)", + (username, user_handle, now), ) - if not user_id: - cur.execute( - "insert into users (username, user_handle, created_time) values (?, ?, ?)", - (username, user_handle, now), - ) - user_id = cur.lastrowid - params = { - "user_handle": user_handle, - "cred_id": auth_data.cred_id, - "aaguid": str(uuid.UUID(bytes=bytes(auth_data.aaguid))), - "sign_count": None - if auth_data.sign_count == 0 - else auth_data.sign_count, - "backup_eligible": 1 if "BE" in auth_data.flags else 0, - "backup_state": 1 if "BS" in auth_data.flags else 0, - "uv_initialized": 1 if "UV" in auth_data.flags else 0, - "cose_pub_key": auth_data.pub_key_bytes, - "created_time": now, - } - - add_passkey_sql = """ - insert into user_passkeys - (user_handle, cred_id, aaguid, sign_count, backup_eligible, backup_state, uv_initialized, cose_pub_key, created_time) - values - (:user_handle, :cred_id, :aaguid, :sign_count, :backup_eligible, :backup_state, :uv_initialized, :cose_pub_key, :created_time) - """ - cur.execute(add_passkey_sql, params) - print("Added passkey") - DB.commit() - cur.close() + user_id = cur.lastrowid + params = { + "user_handle": user_handle, + "cred_id": auth_data.cred_id, + "aaguid": str(uuid.UUID(bytes=auth_data.aaguid)), + "sign_count": None if auth_data.sign_count == 0 else auth_data.sign_count, + "backup_eligible": 1 if "BE" in auth_data.flags else 0, + "backup_state": 1 if "BS" in auth_data.flags else 0, + "uv_initialized": 1 if "UV" in auth_data.flags else 0, + "cose_pub_key": auth_data.pub_key_bytes, + "created_time": now, + } - toplevel = self.get_surface() - toplevel.export_handle(functools.partial(cb, user_id)) + add_passkey_sql = """ + insert into user_passkeys + (user_handle, cred_id, aaguid, sign_count, backup_eligible, backup_state, uv_initialized, cose_pub_key, created_time) + values + (:user_handle, :cred_id, :aaguid, :sign_count, :backup_eligible, :backup_state, :uv_initialized, :cose_pub_key, :created_time) + """ + cur.execute(add_passkey_sql, params) + print("Added passkey") + db.commit() cur.close() @Gtk.Template.Callback() def on_authenticate(self, *args): + print("authenticate clicked") + task_spawn(self.assert_passkey(), None) + + async def assert_passkey(self): username = self.username.get_text() if username: print(f"Using username-flow: {username}") @@ -146,7 +269,8 @@ def on_authenticate(self, *args): inner join users u on u.user_handle = p.user_handle where u.username = ? """ - with closing(DB.cursor()) as cur: + db = connect_db() + with closing(db.cursor()) as cur: cur.execute(sql, (username,)) user_creds = [] for row in cur.fetchall(): @@ -178,7 +302,8 @@ def on_authenticate(self, *args): def retrieve_user_cred( user_handle: Optional[bytes], cred_id: bytes ) -> Optional[dict]: - with closing(DB.cursor()) as cur: + db = connect_db() + with closing(db.cursor()) as cur: if username: print("using cached user creds") return next( @@ -221,26 +346,25 @@ def retrieve_user_cred( else: return None - def cb(toplevel, window_handle): - print(f"received window handle: {window_handle}") - window_handle = f"wayland:{window_handle}" - - auth_data = get_passkey( - INTERFACE, - window_handle, - self.origin, - self.origin, - self.rp_id, - cred_ids, - retrieve_user_cred, - ) - print("Received passkey:") - pprint(auth_data) - toplevel = self.get_surface() - print(type(toplevel)) - toplevel.export_handle(cb) - print("Waiting for handle to complete") + window_handle = await get_surface_handle(toplevel) + + print(f"received window handle: {window_handle}") + window_handle = f"wayland:{window_handle}" + print(window_handle) + + auth_data = await get_passkey( + INTERFACE, + window_handle, + self.origin, + self.origin, + self.rp_id, + cred_ids, + retrieve_user_cred, + ) + print("Received passkey:") + pprint(auth_data) + # event.wait() @GObject.Property(type=Gtk.StringList) @@ -309,7 +433,7 @@ def on_activate(self, app): self.win.present() -def create_passkey( +async def create_passkey( interface: ProxyInterface, window_handle: str, origin: str, @@ -323,30 +447,38 @@ def create_passkey( # pprint(options) print() + request_event = create_portal_request_message_handler(interface.bus) + + # Construct request + cred_type = "publicKey" req_json = json.dumps(options) req = { - "type": Variant("s", "publicKey"), - "origin": Variant("s", origin), - "is_same_origin": Variant("b", is_same_origin), - "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), + "handle_token": Variant("s", request_event.token), + "public_key": Variant("s", req_json), } + if top_origin != origin: + req["top_origin"] = Variant("s", top_origin) - rsp = interface.call_create_credential_sync([window_handle, req]) + print("Calling D-Bus") + rsp = await interface.call_create_credential(window_handle, origin, cred_type, req) + print(rsp) + print("waiting for response") + result = await request_event.wait() - # print("Received response") - # pprint(rsp) - if rsp["type"].value != "public-key": + print("Received response") + + if result["type"].value != "public-key": raise Exception( - f"Invalid credential type received: expected 'public-key', received {rsp['type'.value]}" + f"Invalid credential type received: expected 'public-key', received {result['type'].value}" ) response_json = json.loads( - rsp["public_key"].value["registration_response_json"].value + result["public_key"].value["registration_response_json"].value ) return webauthn.verify_create_response(response_json, options, origin) -def get_passkey( +async def get_passkey( interface, window_handle, origin, top_origin, rp_id, cred_ids, cred_lookup_fn ): is_same_origin = origin == top_origin @@ -364,24 +496,25 @@ def get_passkey( # pprint(options) print() + request_event = create_portal_request_message_handler(interface.bus) + + print(request_event.token) + # Construct request req_json = json.dumps(options) - req = { - "type": Variant("s", "publicKey"), - "origin": Variant("s", origin), - "is_same_origin": Variant("b", is_same_origin), - "publicKey": Variant("a{sv}", {"request_json": Variant("s", req_json)}), + portal_options = { + "handle_token": Variant("s", request_event.token), + "public_key": Variant("s", req_json), } + if top_origin != origin: + portal_options["top_origin"] = Variant("s", top_origin) - rsp = interface.call_get_credential_sync([window_handle, req]) - # print("Received response") + _ = await interface.call_get_credential(window_handle, origin, portal_options) + result = await request_event.wait() + print("Received response") # pprint(rsp) - if rsp["type"].value != "public-key": - raise Exception( - f"Invalid credential type received: expected 'public-key', received {rsp['type'.value]}" - ) response_json = json.loads( - rsp["public_key"].value["authentication_response_json"].value + result["public_key"].value["authentication_response_json"].value ) response_json["rawId"] = util.b64_decode(response_json["rawId"]) if user_handle := response_json["response"].get("userHandle"): @@ -390,9 +523,9 @@ def get_passkey( return webauthn.verify_get_response(response_json, options, origin, cred_lookup_fn) -def connect_to_bus(): +async def connect_to_bus(): global INTERFACE - bus = MessageBus().connect_sync() + bus = await MessageBus().connect() with open( f"{os.path.dirname(os.path.realpath(__file__))}/xyz.iinuwa.credentialsd.Credentials.xml", @@ -400,16 +533,18 @@ def connect_to_bus(): ) as f: introspection = f.read() + service_name = "org.freedesktop.portal.Desktop" + path = "/org/freedesktop/portal/desktop" + interface = "org.freedesktop.portal.experimental.Credential" proxy_object = bus.get_proxy_object( - "xyz.iinuwa.credentialsd.Credentials", - "/xyz/iinuwa/credentialsd/Credentials", + service_name, + path, introspection, ) - INTERFACE = proxy_object.get_interface("xyz.iinuwa.credentialsd.Credentials1") + INTERFACE = proxy_object.get_interface(interface) -def setup_db(): - global DB +def connect_db() -> sqlite3.Connection: # This is just for testing/temporary use, so put it in cache db_path = ( Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) @@ -418,7 +553,13 @@ def setup_db(): ) db_path.parent.mkdir(exist_ok=True) - DB = sqlite3.connect(db_path) + return sqlite3.connect(db_path) + + +def setup_db(): + global DB + + DB = connect_db() DB.execute("pragma foreign_keys = on") user_table_sql = """ create table if not exists users ( @@ -451,14 +592,21 @@ def setup_db(): cur.close() -def main(): - connect_to_bus() +async def main(): setup_db() - - app = MyApp(application_id="xyz.iinuwa.credentialsd.DemoCredentialsUi") + app = MyApp(application_id=APP_ID) app.run(sys.argv) DB.close() if __name__ == "__main__": - main() + done = asyncio.Event() + LOOP = GLibEventLoop(GLib.MainContext()) + LOOP.run_until_complete(connect_to_bus()) + + def background_loop(): + LOOP.run_until_complete(done.wait()) + + threading.Thread(target=background_loop, daemon=True).start() + asyncio.run(main(), loop_factory=lambda: GLibEventLoop(GLib.MainContext())) + done.set() diff --git a/demo_client/webauthn.py b/demo_client/webauthn.py index a974d0e..4bb6b44 100644 --- a/demo_client/webauthn.py +++ b/demo_client/webauthn.py @@ -1,3 +1,4 @@ +from typing_extensions import Union from dataclasses import dataclass import hashlib import hmac @@ -140,7 +141,7 @@ def verify_create_response(response, create_request, expected_origin): # strip first two header bytes for OCTET STRING of length 16 assert cert_aaguid_der[:2] == b"\x04\x10" cert_aaguid = cert_aaguid_der[2:] - assert auth_data.aaguid.tobytes() == cert_aaguid + assert auth_data.aaguid == cert_aaguid except x509.ExtensionNotFound: # no FIDO OID found in cert. pass @@ -200,8 +201,10 @@ def verify_create_response(response, create_request, expected_origin): ) # Extract the claimed rpIdHash from authenticatorData, and the claimed credentialId and credentialPublicKey from authenticatorData.attestedCredentialData. - expected_rp_id_hash, cred_pub_key - credential_id = auth_data.cred_id + if auth_data.cred_id is None: + raise Exception("No credential ID returned in attestation object.") + else: + credential_id = auth_data.cred_id # Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of [RFC9052]) to Raw ANSI X9.62 public key format # (see ALG_KEY_ECC_X962_RAW in Section 3.6.2 Public Key Representation Formats of [FIDO-Registry]). @@ -359,7 +362,7 @@ def verify_get_response(credential, options, expected_origin, cred_lookup_fn): # If C.crossOrigin is present and set to true, verify that the Relying Party # expects this credential to be used within an iframe that is not # same-origin with its ancestors. - if C.get("crossOrigin") == True: + if C.get("crossOrigin") is True: # TODO: pass cross-origin policy as parameter pass @@ -545,7 +548,7 @@ def _cose_verify(cose_key: bytes, signature: bytes, data: bytes): crv = ec.SECP256R1() alg = ec.ECDSA(hashes.SHA256()) else: - raise Exception(f"Unsupported COSE ECDSA curve specified: {crv}") + raise Exception(f"Unsupported COSE ECDSA curve specified: {cose_crv}") # WebAuthn uses uncompressed points only. pub_key_bytes = bytes(b"\x04" + x + y) @@ -569,7 +572,27 @@ def _cose_verify(cose_key: bytes, signature: bytes, data: bytes): raise Exception(f"Unsupported COSE key algorithm specified: {cose_alg}") -def _parse_authenticator_data(auth_data): +@dataclass +class AuthenticatorData: + rp_id_hash: bytes + flags: set + sign_count: int + aaguid: Optional[bytes] + cred_id: Optional[bytes] + pub_key_bytes: Optional[bytes] + extensions: Optional[dict] + + def get_pub_key(self): + if self.pub_key_bytes: + return cbor.loads(self.pub_key_bytes) + + def has_flag(self, flag): + return flag in self.flags + + +def _parse_authenticator_data( + auth_data: Union[bytes | memoryview], +) -> AuthenticatorData: client_rp_id_hash = auth_data[:32] # Verify that the User Present bit of the flags in authData is set. @@ -605,29 +628,11 @@ def _parse_authenticator_data(auth_data): else: extensions = None return AuthenticatorData( - rp_id_hash=client_rp_id_hash, + rp_id_hash=bytes(client_rp_id_hash), flags=flags, sign_count=sign_count, - aaguid=aaguid, - cred_id=cred_id, - pub_key_bytes=cose_key_bytes, + aaguid=bytes(aaguid) if aaguid else None, + cred_id=bytes(cred_id) if cred_id else None, + pub_key_bytes=bytes(cose_key_bytes) if cose_key_bytes else None, extensions=extensions, ) - - -@dataclass -class AuthenticatorData: - rp_id_hash: bytes - flags: set - sign_count: int - aaguid: Optional[bytes] - cred_id: Optional[bytes] - pub_key_bytes: Optional[bytes] - extensions: Optional[dict] - - def get_pub_key(self): - if self.pub_key_bytes: - return cbor.loads(self.pub_key_bytes) - - def has_flag(self, flag): - return flag in self.flags diff --git a/doc/api.md b/doc/api.md index 0a750a6..115f420 100644 --- a/doc/api.md +++ b/doc/api.md @@ -50,6 +50,10 @@ sequenceDiagram - (UI Controller): Renamed `InitiateEventStream()` to `Subscribe()` - (UI Controller): Serialize enums (including BackgroundEvent, HybridState and UsbState) as (yv) structs instead for a{sv} dicts +- (Gateway): Flatten `request` parameters into options. +- (Gateway): Make `origin` and `type` a required method parameter. +- (Gateway): Flatten nested D-Bus struct with `request_json` on CreateCredential and GetCredential +- (Gateway): Remove Client Capabilities method from Gateway API until further notice. ### Improvements @@ -85,8 +89,6 @@ A single null byte (`\0`) is sent for unused enum values. ## D-Bus/JSON serialization -> TODO: rename fields to snake_case so that this note is true in all cases. - This API is modelled after the [Credential Management API][credman-api]. The top-level fields corresponding to `navigator.credentials.create()` and `get()` are passed as fields in D-Bus dictionaries using snake_case, according to D-Bus @@ -106,9 +108,12 @@ this API takes: ``` [a{sv}] { - origin: Variant(""), - top_origin: Variant(""), // topOrigin is changed to top_origin - password: Variant(true), + IN origin s = "https://example.com", + IN type = "password", + options a{sv} = { + top_origin: Variant("https://example.com"), // topOrigin is changed to top_origin + password: Variant(true), + } } ``` @@ -123,8 +128,8 @@ So if a client passed this in JavaScript: ```javascript { - "origin": "example.com", - "topOrigin": "example.com", + "origin": "https://example.com", + "topOrigin": "https://example.com", "publicKey": { "challenge": new Uint8Array([97, 32, 99, 104, 97, 108, 108, 101, 110, 103, 101]), "excludeCredentials": [ @@ -138,18 +143,21 @@ So if a client passed this in JavaScript: it would pass this request to this API: ``` -[a{sv}] { - origin: Variant(''), - top_origin: Variant(''), // top-level fields topOrigin and publicKey are - public_key: Variant([a{sv}] { // changed to snake_case - registration_request_json: [s] '{ // <- JSON-encoded string - "challenge": "YSBjaGFsbGVuZ2U", // buffer is encoded as base64url without padding - "excludeCredentials": [ // "excludeCredentials" is not changed to snake_case - {"type": "public-key", "alg": -7} // "public-key" is not changed to snake_case +CreateCredential( + // ... + IN origin s = "https://example.com", + IN type s = "publicKey", + IN options a{sv} { + top_origin: Variant("https://example.com"), // top-level fields topOrigin and publicKey are + // changed to snake_case, JSON-encoded string + public_key: [s] = "{ // `public_key` is a JSON-encoded string, snake_case field name + \"challenge\": \"YSBjaGFsbGVuZ2U\", // "challenge" buffer is encoded as base64url without padding + \"excludeCredentials\": [ // "excludeCredentials" is not changed to snake_case within the JSON + {\"type\": \"public-key\", \"alg\": -7} // "public-key" is not changed to snake_case within the JSON string ] // ... - }' - }) + }" + } } ``` @@ -191,12 +199,15 @@ for what kind of credential the client would like to create. ``` CreateCredentialRequest( IN parent_window s, + IN origin s, + IN type CredentialType, IN options a{sv} { - origin: string - is_same_origin: string - type: CredentialType - - } + activation_token: s + top_origin: s + + }, + IN app_id s, + IN app_display_name s ) ``` @@ -212,15 +223,11 @@ CredentialType[s] [ #### Request context -> TODO: replace is_same_origin with topOrigin, required if origin is set. - -> TODO: Should we say that `origin` will be optional in the future? - > TODO: Define methods for safe comparison of hosts Punycode origins. -`origin` and `is_same_origin` define the request context. Both are required. A -request is considered to be a cross-origin request if `is_same_origin` is -`false`. For certain credentials, cross-origin requests are not allowed and +`origin` and `options.top_origin` define the request context. `origin` is required. A +request is considered to be a cross-origin request if `options.top_origin` is +specified. For certain credentials, cross-origin requests are not allowed and will be denied. At this time, only [web origins][web-origins] with HTTPS schemes are permitted @@ -233,28 +240,30 @@ suffix, as defined by the [Public Suffix List][PSL]. [web-origins]: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin-tuple [PSL]: https://github.com/publicsuffix/list -#### Credential Types +#### Credential Request Types -> TODO: decide on case of strings (snake_case like D-Bus or camelCase like JS?) +##### WebAuthn Credential Request Currently, there is only one supported type of `CreateCredentialRequest`, `CreatePublicKeyCredentialRequest`, identified by `type: "publicKey"` and -corresponds to WebAuthn credentials: - - CreatePublicKeyCredentialRequest[a{sv}] : CreateCredentialRequest { - origin: string - is_same_origin: string - type: "publicKey" - publicKey: CreatePublicKeyCredentialOptions[a{sv}] { - // WebAuthn credential attestation JSON - request_json: String - } - } - -`request_json` is a string of JSON that corresponds to the WebAuthn +corresponds to WebAuthn credentials. It extends the `options` parameter +with a field `public_key`, which is a string of JSON that corresponds to the +WebAuthn [`PublicKeyCredentialCreationOptions`][def-pubkeycred-creation-options] type. + CreatePublicKeyCredentialRequest: CreateCredentialRequest ( + IN parent_window s, + IN origin s, + IN type s = "publicKey", + options a{sv} { + , + public_key: s // WebAuthn credential attestation JSON + }, + IN app_id s, + IN app_display_name s + ) + ### Response > TODO: Should we group common types in their own section for reference? @@ -263,24 +272,26 @@ type. `CreateCredentialResponse` is a polymorphic type that depends on the type of the request sent. Its `type` field is a string specifies what kind of -credential it is, and what `` should be expected. +credential it is, and what `` should be expected. ``` CreateCredentialResponse[a{sv}] { type: CredentialType - + } ``` `CredentialType` is defined above. +#### WebAuthn Credential Response + As the only supported request is `CreatePublicKeyCredentialRequest`, the only type of response is `CreateCredentialResponse` is `CreatePublicKeyResponse`, also denoted by `type: "publicKey"`: CreatePublicKeyResponse { - type: "publicKey" - registration_response_json: String + type: s = "publicKey" + registration_response_json: s } `registration_response_json` is a JSON string that corresponds to the WebAuthn @@ -308,12 +319,16 @@ credentials the client will accept. ``` GetCredentialRequest ( - IN parent_window s + IN parent_window s, + IN origin s, IN options a{sv} { - origin: string - is_same_origin: string - publicKey: GetPublicKeyCredentialOptions? - } + activation_token: s + top_origin: s + + public_key: s + }, + IN app_id s, + IN app_display_name s ) ``` @@ -326,29 +341,20 @@ request multiple different types of credentials at once, and it can expect the returned credential to be any one of those credential types. Because of that, there is no `type` field, and credential types are specified using the optional fields. + #### Request Context -The `GetCredential()` `origin` and `is_same_origin` have the same semantics and +The `GetCredential()` `origin` and `options.top_origin` have the same semantics and restrictions as in `CreateCredential()` described above. When multiple credential types are specified, the request context applies to all credentials. -#### Credential Types +#### Credential Request Types -> TODO: decide on case of strings (snake_case like D-Bus or camelCase like JS?) +##### WebAuthn Credential Request -Currently, there is only one supported type of credential, specified by the -`publicKey` field, which corresponds to WebAuthn credentials and takes a -`GetPublicKeyCredentialOptions`: - -``` -GetPublicKeyCredentialOptions[a{sv}] { - request_json: string -} -``` - -`request_json` is a string of JSON that corresponds to the WebAuthn +Currently, there is only one supported type of credential, a WebAuthn PublicKeyCredential. A WebAuthn credential can be requested using the `options.public_key` field, which is a string of JSON that corresponds to the WebAuthn [`PublicKeyCredentialRequestOptions`][def-pubkeycred-request-options]. [def-pubkeycred-request-options]: https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions @@ -361,27 +367,27 @@ GetPublicKeyCredentialOptions[a{sv}] { `GetCredentialResponse` is a polymorphic type that depends on the type of the request sent. Its `type` field is a string specifies what kind of credential it -is, and what `` should be expected. +is, and what `` should be expected. ``` GetCredentialResponse[a{sv}] { type: CredentialType - + } ``` `CredentialType` is defined above. -As the only supported request is `CreatePublicKeyCredentialRequest`, the only -type of response is CreateCredentialResponse is CreatePublicKeyResponse, also + +#### WebAuthn Credential Response + +As the only supported request is `GetPublicKeyCredentialRequest`, the only +type of response is `GetCredentialResponse` is `GetPublicKeyCredentialResponse`, also denoted by `type: "publicKey"`: GetPublicKeyCredentialRepsonse { - type: "publicKey" - publicKey: { - // WebAuthn credential assertion response JSON - authentication_response_json: string - } + type: s = "publicKey" + authentication_response_json: s // WebAuthn credential assertion response JSON } `authentication_response_json` is a JSON string that corresponds to the WebAuthn @@ -399,31 +405,6 @@ denoted by `type: "publicKey"`: - `TypeError`: An invalid request is made. - `NotAllowedError`: catch-all error. -## `GetClientCapabilities() -> GetClientCapabilitiesResponse` - -Analogous to WebAuthn Level 3's [`getClientCapabilities()`][def-getClientCapabilities] method. - -### Response - -`GetClientCapabilitiesResponse` is a set of boolean flags indicating what features this client supports. - - GetClientCapabilitiesResponse[a{sb}] { - conditional_create: bool, - conditional_get: bool, - hybrid_transport: bool, - passkey_platform_authenticator: bool, - user_verifying_platform_authenticator: bool, - related_origins: bool, - signal_all_accepted_credentials: bool, - signal_current_user_details: bool, - signal_unknown_credential: bool, - } - -See the WebAuthn spec for meanings of the [client capability keys][def-client-capabilitities]. - -[def-client-capabilities]: https://www.w3.org/TR/webauthn-3/#enumdef-clientcapability -[def-getClientCapabilities]: https://w3c.github.io/webauthn/#sctn-getClientCapabilities - # Flow Control API The Flow Control API is used by the UI to pass user interactions through the diff --git a/doc/xyz.iinuwa.credentialsd.Credentials.xml b/doc/xyz.iinuwa.credentialsd.Credentials.xml index be1cac7..3d95735 100644 --- a/doc/xyz.iinuwa.credentialsd.Credentials.xml +++ b/doc/xyz.iinuwa.credentialsd.Credentials.xml @@ -20,13 +20,104 @@ - - + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +