diff --git a/.gitignore b/.gitignore index 5c29f7c..c992dc2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ __pycache__ /.flatpak/ /vendor +# Web extension (generated for local dev) +webext/add-on/manifest.json + # IDE /.vscode/settings.json .idea diff --git a/.vscode/launch.json b/.vscode/launch.json index 42702bf..f5eb641 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,8 +11,8 @@ "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", - "CREDSD_TRUSTED_CALLERS": "/usr/bin/python3.14", + "RUST_LOG": "credentialsd=trace,libwebauthn=debug,libwebauthn::webauthn=debug,libwebauthn=warn,libwebauthn::proto::ctap2::preflight=debug,libwebauthn::transport::channel=debug,zbus::object_server::debug,zbus=debug", + "CREDSD_TRUSTED_CALLERS": "/usr/bin/python3.14,/home/isaiah/Development/portal/xdg-desktop-portal/build/src/xdg-desktop-portal", "CREDSD_TRUSTED_APP_IDS": "app:xyz.iinuwa.credentialsd.DemoCredentialsUi", }, "sourceLanguages": [ @@ -29,7 +29,7 @@ "args": [], "env": { "GSETTINGS_SCHEMA_DIR": "${workspaceFolder}/build/credentialsd-ui/data", - "RUST_LOG": "credentialsd_ui=debug,zbus::trace,zbus::object_server::debug" + "RUST_LOG": "credentialsd_ui=trace,zbus::trace,zbus::object_server::debug" }, "sourceLanguages": [ "rust" diff --git a/README.md b/README.md index 21f42ad..d501a8c 100644 --- a/README.md +++ b/README.md @@ -36,16 +36,26 @@ Alternatively, you can build the project yourself using the instructions in ## How to use -Right now, there are two ways to use this service. +Right now, there are three ways to use this service. -### Experimental Firefox Add-On +### Experimental Browser Extension -There is an add-on that you can install in Firefox 140+ that allows you to test -`credentialsd` without a custom Firefox build. You can get the XPI from the -[releases page][release-page] for the corresponding version of -`credentialsd-webextension` package that you installed. +There is a browser extension that allows you to test `credentialsd` without a +custom browser build. It overrides `navigator.credentials.create()` and +`navigator.credentials.get()` to route WebAuthn requests through the +credentialsd D-Bus service. -Currently, this add-on only works for https://webauthn.io and +Two browsers are supported from a single unified codebase: + +- **Firefox 140+** — Install the XPI from the [releases page][release-page] for + the corresponding version of `credentialsd-webextension` package that you + installed. +- **Edge/Chromium (Chrome 111+, Edge 111+)** — Load as an unpacked extension + from `webext/add-on/` using the Chromium manifest. See + [`webext/README.md`](/webext/README.md#for-development-edgechromium) for + setup instructions. + +Currently, the extension only works for https://webauthn.io and https://demo.yubico.com, but can be used to test various WebAuthn options and hardware. diff --git a/credentialsd-common/src/client.rs b/credentialsd-common/src/client.rs index 1bff01d..28312bb 100644 --- a/credentialsd-common/src/client.rs +++ b/credentialsd-common/src/client.rs @@ -2,10 +2,7 @@ use std::pin::Pin; use futures_lite::Stream; -use crate::{ - model::{BackgroundEvent, Device}, - server::RequestId, -}; +use crate::model::{BackgroundEvent, Device, RequestId}; /// Used for communication from trusted UI to credential service pub trait FlowController { diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index dd6fb7c..7ddc30c 100644 --- a/credentialsd-common/src/model.rs +++ b/credentialsd-common/src/model.rs @@ -40,8 +40,17 @@ pub struct Device { #[derive(Clone, Debug, Serialize, Deserialize, Type)] pub enum Operation { - Create, - Get, + PublicKeyCreate, + PublicKeyGet, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +pub struct PortalBackendOptions { + /// Top-level origin of the request if different from the origin. + pub top_origin: Optional, + + /// RP ID of the request. Required for WebAuthn/PublicKey requests. + pub rp_id: Optional, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -122,6 +131,9 @@ pub struct RequestingParty { pub origin: String, } +/// Identifier for a request to be used for cancellation. +pub type RequestId = u32; + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ViewUpdate { SetTitle((String, String)), @@ -252,6 +264,41 @@ pub enum NfcState { Failed(Error), } +pub enum BackendRequest { + /// Start Hybrid discovery + StartHybridDiscovery, + + /// Start NFC discovery + StartNfcDiscovery, + + /// Start USB discovery + StartUsbDiscovery, + + /// Send client PIN + EnterClientPin(String), + + /// Select a credential by credential ID + SelectCredential(String), + + CancelRequest, +} + +impl std::fmt::Debug for BackendRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StartHybridDiscovery => write!(f, "StartHybridDiscovery"), + Self::StartNfcDiscovery => write!(f, "StartNfcDiscovery"), + Self::StartUsbDiscovery => write!(f, "StartUsbDiscovery"), + Self::EnterClientPin(_) => f + .debug_tuple("EnterClientPin") + .field(&"******".to_string()) + .finish(), + Self::SelectCredential(arg0) => f.debug_tuple("SelectCredential").field(arg0).finish(), + Self::CancelRequest => write!(f, "CancelRequest"), + } + } +} + #[derive(Clone, Debug)] pub enum BackgroundEvent { UsbStateChanged(UsbState), diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index fa5848d..b071dc2 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -8,15 +8,144 @@ use serde::{ }; use zvariant::{ self, Array, DeserializeDict, DynamicDeserialize, NoneValue, Optional, OwnedValue, - SerializeDict, Signature, Structure, StructureBuilder, Type, Value, signature::Fields, + SerializeDict, Signature, Str, Structure, StructureBuilder, Type, Value, signature::Fields, }; -use crate::model::{BackgroundEvent, Operation, RequestingApplication}; +use crate::model::{ + BackendRequest, BackgroundEvent, Device, Operation, RequestId, RequestingApplication, +}; const TAG_VALUE_SIGNATURE: &Signature = &Signature::Structure(Fields::Static { fields: &[&Signature::U8, &Signature::Variant], }); +/// Ceremony completed successfully +const BACKGROUND_EVENT_CEREMONY_COMPLETED: u32 = 0x01; +/// Device needs the client PIN to be entered. The backend should collect the +/// PIN and send it back with `EnterClientPin` event of `UserInteracted` signal. +const BACKGROUND_EVENT_NEEDS_PIN: u32 = 0x10; +const BACKGROUND_EVENT_NEEDS_USER_VERIFICATION: u32 = 0x11; +const BACKGROUND_EVENT_NEEDS_PRESENCE: u32 = 0x12; +const BACKGROUND_EVENT_SELECTING_CREDENTIAL: u32 = 0x13; + +const BACKGROUND_EVENT_HYBRID_IDLE: u32 = 0x20; +const BACKGROUND_EVENT_HYBRID_STARTED: u32 = 0x21; +const BACKGROUND_EVENT_HYBRID_CONNECTING: u32 = 0x22; +const BACKGROUND_EVENT_HYBRID_CONNECTED: u32 = 0x23; + +const BACKGROUND_EVENT_NFC_IDLE: u32 = 0x30; +const BACKGROUND_EVENT_NFC_WAITING: u32 = 0x31; +const BACKGROUND_EVENT_NFC_SELECTING_DEVICE: u32 = 0x32; +const BACKGROUND_EVENT_NFC_CONNECTED: u32 = 0x33; + +const BACKGROUND_EVENT_USB_IDLE: u32 = 0x41; +const BACKGROUND_EVENT_USB_WAITING: u32 = 0x42; +const BACKGROUND_EVENT_USB_SELECTING_DEVICE: u32 = 0x43; +const BACKGROUND_EVENT_USB_CONNECTED: u32 = 0x44; + +const BACKGROUND_EVENT_ERROR_AUTHENTICATOR: u32 = 0x80000001; +const BACKGROUND_EVENT_ERROR_NO_CREDENTIALS: u32 = 0x80000002; +const BACKGROUND_EVENT_ERROR_PIN_ATTEMPTS_EXHAUSTED: u32 = 0x80000003; +const BACKGROUND_EVENT_ERROR_INTERNAL: u32 = 0x80000004; +const BACKGROUND_EVENT_ERROR_TIMED_OUT: u32 = 0x80000005; +const BACKGROUND_EVENT_ERROR_CANCELLED: u32 = 0x80000006; + +// BackendRequest +const BACKEND_REQUEST_START_HYBRID_DISCOVERY: u32 = 0x01; +const BACKEND_REQUEST_START_USB_DISCOVERY: u32 = 0x02; +const BACKEND_REQUEST_START_NFC_DISCOVERY: u32 = 0x03; +const BACKEND_REQUEST_ENTER_CLIENT_PIN: u32 = 0x04; +const BACKEND_REQUEST_SELECT_CREDENTIAL: u32 = 0x05; +const BACKEND_REQUEST_CANCEL_REQUEST: u32 = 0x06; + +impl Type for BackendRequest { + const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; +} + +impl From<&BackendRequest> for Structure<'_> { + fn from(value: &BackendRequest) -> Self { + match value { + BackendRequest::StartHybridDiscovery => tag_value_to_struct(0x01, None), + BackendRequest::StartNfcDiscovery => tag_value_to_struct(0x02, None), + BackendRequest::StartUsbDiscovery => tag_value_to_struct(0x03, None), + BackendRequest::EnterClientPin(pin) => { + tag_value_to_struct(0x04, Some(Value::Str(pin.into()))) + } + BackendRequest::SelectCredential(credential_id) => { + tag_value_to_struct(0x05, Some(Value::Str(credential_id.into()))) + } + BackendRequest::CancelRequest => tag_value_to_struct(0x06, None), + } + } +} + +impl TryFrom<&Structure<'_>> for BackendRequest { + type Error = zvariant::Error; + + fn try_from(value: &Structure<'_>) -> Result { + let (tag, value) = parse_tag_value_struct(value)?; + + match tag { + 0x01 => Ok(BackendRequest::StartHybridDiscovery), + 0x02 => Ok(BackendRequest::StartNfcDiscovery), + 0x03 => Ok(BackendRequest::StartUsbDiscovery), + 0x04 => { + let s: Str = value.downcast_ref()?; + if s.is_empty() { + return Err(zvariant::Error::invalid_length( + s.len(), + &"a non-empty string", + )); + } + Ok(BackendRequest::EnterClientPin(s.as_str().to_string())) + } + 0x05 => { + let s: Str = value.downcast_ref()?; + if s.is_empty() { + return Err(zvariant::Error::invalid_length( + s.len(), + &"a non-empty string", + )); + } + Ok(BackendRequest::SelectCredential(s.as_str().to_string())) + } + 0x06 => Ok(BackendRequest::CancelRequest), + _ => Err(zvariant::Error::Message(format!( + "Unknown BackendRequest tag : {tag}" + ))), + } + } +} + +impl Serialize for BackendRequest { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let structure: Structure = self.into(); + structure.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for BackendRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let d = Structure::deserializer_for_signature(TAG_VALUE_SIGNATURE).map_err(|err| { + D::Error::custom(format!( + "could not create deserializer for tag-value struct: {err}" + )) + })?; + let structure = d.deserialize(deserializer)?; + (&structure).try_into().map_err(|err| { + D::Error::custom(format!( + "could not deserialize structure into BackendRequest: {err}" + )) + }) + } +} + impl Type for BackgroundEvent { const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; } @@ -308,9 +437,6 @@ impl Type for crate::model::HybridState { const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; } -/// Identifier for a request to be used for cancellation. -pub type RequestId = u32; - impl Type for crate::model::UsbState { const SIGNATURE: &'static Signature = TAG_VALUE_SIGNATURE; } @@ -587,18 +713,27 @@ where .map_err(|err| D::Error::custom(format!("could not deserialize from structure: {err}"))) } -#[derive(Serialize, Deserialize, Type)] +#[derive(Clone, Debug, Serialize, Deserialize, Type)] pub struct ViewRequest { pub operation: Operation, + + /// ID of the request. pub id: RequestId, + + /// The RP ID pub rp_id: String, + + /// Details about the application requesting credentials. pub requesting_app: RequestingApplication, + /// Initial list of device interfaces that may provide credentials. + pub initial_devices: Vec, + /// Client window handle. pub window_handle: Optional, } -#[derive(Type, PartialEq, Debug)] +#[derive(Clone, Debug, PartialEq, Type)] #[zvariant(signature = "s")] pub enum WindowHandle { Wayland(String), diff --git a/credentialsd-ui/po/LINGUAS b/credentialsd-ui/po/LINGUAS index 845d78b..e10b2e3 100644 --- a/credentialsd-ui/po/LINGUAS +++ b/credentialsd-ui/po/LINGUAS @@ -1,2 +1,3 @@ en_US de_DE +ka_GE diff --git a/credentialsd-ui/po/ka_GE.po b/credentialsd-ui/po/ka_GE.po new file mode 100644 index 0000000..ea8acf0 --- /dev/null +++ b/credentialsd-ui/po/ka_GE.po @@ -0,0 +1,275 @@ +# Georgian translation for credentialsd-ui. +# Copyright (C) 2026 "The Credentials for Linux Project" +# This file is distributed under the same license as the credentialsd-ui package. +# Ekaterine Papava , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: credentialsd-ui\n" +"Report-Msgid-Bugs-To: \"https://github.com/linux-credentials/credentialsd/" +"issues\"\n" +"POT-Creation-Date: 2026-02-03 10:40+0100\n" +"PO-Revision-Date: 2026-04-20 07:16+0200\n" +"Last-Translator: Ekaterine Papava \n" +"Language-Team: \n" +"Language: ka\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.9\n" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.desktop.in.in:2 +#: data/xyz.iinuwa.credentialsd.CredentialsUi.metainfo.xml.in.in:8 +#: src/gui/view_model/gtk/mod.rs:385 +msgid "Credential Manager" +msgstr "ავტორიზაციის დეტალების მმართველი" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.desktop.in.in:3 +#: data/xyz.iinuwa.credentialsd.CredentialsUi.metainfo.xml.in.in:9 +msgid "Write a GTK + Rust application" +msgstr "დაწერეთ GTK+ Rust აპლიკაცია" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.desktop.in.in:9 +msgid "Gnome;GTK;" +msgstr "Gnome;GTK;" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.gschema.xml.in:6 +msgid "Window width" +msgstr "ფანჯრის სიგანე" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.gschema.xml.in:10 +msgid "Window height" +msgstr "ფანჯრის სიმაღლე" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.gschema.xml.in:14 +msgid "Window maximized state" +msgstr "ფანჯრის სრულად გაშლილი მდგომარეობა" + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.metainfo.xml.in.in:11 +msgid "" +"A boilerplate template for GTK + Rust. It uses Meson as a build system and " +"has flatpak support by default." +msgstr "" +"გაზიარებული კოდის ნიმუში GTK+ Rust-ისთვის. ის აგების სისტემად Meson-ის " +"იყენებს და flatpak-ის მხარდაჭერა ნაგულისხმევად აქვს." + +#: data/xyz.iinuwa.credentialsd.CredentialsUi.metainfo.xml.in.in:16 +msgid "Registering a credential" +msgstr "ავტორიზაციის დეტალების რეგისტრაცია" + +#. developer_name tag deprecated with Appstream 1.0 +#: data/xyz.iinuwa.credentialsd.CredentialsUi.metainfo.xml.in.in:34 +msgid "Isaiah Inuwa" +msgstr "Isaiah Inuwa" + +#: data/resources/ui/shortcuts.ui:11 +msgctxt "shortcut window" +msgid "General" +msgstr "ზოგადი" + +#: data/resources/ui/shortcuts.ui:14 +msgctxt "shortcut window" +msgid "Show Shortcuts" +msgstr "მალსახმობების ჩვენება" + +#: data/resources/ui/shortcuts.ui:20 +msgctxt "shortcut window" +msgid "Quit" +msgstr "გასვლა" + +#: data/resources/ui/window.ui:6 +msgid "_Preferences" +msgstr "_მორგება" + +#: data/resources/ui/window.ui:10 +msgid "_Keyboard Shortcuts" +msgstr "_კლავიატურის მალსახმობები" + +#: data/resources/ui/window.ui:68 +msgid "Choose device" +msgstr "აირჩიეთ მოწყობილობა" + +#: data/resources/ui/window.ui:74 +msgid "Devices" +msgstr "მოწყობილობები" + +#: data/resources/ui/window.ui:98 +msgid "Connect a security key" +msgstr "უსაფრთხოების გასაღების დაკავშირება" + +#: data/resources/ui/window.ui:139 +msgid "Scan the QR code to connect your device" +msgstr "დაასკანირეთ QR კოდი თქვენი მოწყობილობის დასაკავშირებლად" + +#: data/resources/ui/window.ui:184 data/resources/ui/window.ui:190 +msgid "Choose credential" +msgstr "ავტორიზაციის დეტალების არჩევა" + +#: data/resources/ui/window.ui:214 +msgid "Complete" +msgstr "დასრულება" + +#: data/resources/ui/window.ui:220 +msgid "Done!" +msgstr "მზადაა!" + +#: data/resources/ui/window.ui:231 +msgid "Something went wrong." +msgstr "რაღაც მოხდა." + +#: data/resources/ui/window.ui:244 src/gui/view_model/mod.rs:290 +msgid "" +"Something went wrong while retrieving a credential. Please try again later " +"or use a different authenticator." +msgstr "" +"ავტორიზაციის დეტალების მიღებისას რაღაც არასწორად წავიდა. სცადეთ თავიდან " +"მოგვიანებით, ან გამოიყენეთ სხვა ავთენტიკატორი." + +#: src/gui/view_model/gtk/mod.rs:147 +msgid "Enter your PIN. One attempt remaining." +msgid_plural "Enter your PIN. %d attempts remaining." +msgstr[0] "შეიყვანეთ თქვენი PIN-კოდი. დარჩენილია ერთი მცდელობა." +msgstr[1] "შეიყვანეთ თქვენი PIN-კოდი. დარჩენილია %d მცდელობა." + +#: src/gui/view_model/gtk/mod.rs:153 +msgid "Enter your PIN." +msgstr "შეიყვანეთ PIN-კოდი." + +#: src/gui/view_model/gtk/mod.rs:163 +msgid "Touch your device again. One attempt remaining." +msgid_plural "Touch your device again. %d attempts remaining." +msgstr[0] "შეეხეთ თქვენს მოწყობილობას კიდევ ერთხელ. დარჩენილია ერთი მცდელობა." +msgstr[1] "შეეხეთ თქვენს მოწყობილობას კიდევ ერთხელ. დარჩენილია %d მცდელობა." + +#: src/gui/view_model/gtk/mod.rs:169 +msgid "Touch your device." +msgstr "შეეხეთ თქვენს მოწყობილობას." + +#: src/gui/view_model/gtk/mod.rs:174 +msgid "Touch your device" +msgstr "შეეხეთ თქვენს მოწყობილობას" + +#: src/gui/view_model/gtk/mod.rs:177 +msgid "Scan the QR code with your device to begin authentication." +msgstr "ავთენტიკაციის დასაწყებად დაასკანერეთ QR კოდი თქვენი მოწყობილობით." + +#: src/gui/view_model/gtk/mod.rs:187 +msgid "" +"Connecting to your device. Make sure both devices are near each other and " +"have Bluetooth enabled." +msgstr "" +"მიმდინარეობს თქვენს მოწყობილობასთან დაკავშირება. დარწმუნდით, რომ ორივე " +"მოწყობილობა ახლოსაა ერთმანეთთან და რომ ბლუთუზი ჩართულია." + +#: src/gui/view_model/gtk/mod.rs:195 +msgid "Device connected. Follow the instructions on your device" +msgstr "მოწყობილობა დაკავშირებულია. მიჰყევით ინსტრუქციებს თქვენს მოწყობილობაზე" + +#: src/gui/view_model/gtk/mod.rs:321 +msgid "Insert your security key." +msgstr "შეერთეთ თქვენი უსაფრთხოების გასაღები." + +#: src/gui/view_model/gtk/mod.rs:340 +msgid "Multiple devices found. Please select with which to proceed." +msgstr "" +"აღმოჩენილია ერთზე მეტი მოწყობილობა. აირჩიეთ, რომელი გნებავთ, გამოიყენოთ." + +#: src/gui/view_model/gtk/device.rs:57 +msgid "A Bluetooth device" +msgstr "ბლუთუზის მოწყობილობა" + +#: src/gui/view_model/gtk/device.rs:58 +msgid "This device" +msgstr "ეს მოწყობილობა" + +#: src/gui/view_model/gtk/device.rs:59 +msgid "A mobile device" +msgstr "მობილური მოწყობილობა" + +#: src/gui/view_model/gtk/device.rs:60 +msgid "Linked Device" +msgstr "დაკავშირებული მოწყობილობა" + +#: src/gui/view_model/gtk/device.rs:61 +msgid "An security key or card (NFC)" +msgstr "უსაფრთხოების გასაღები, ან ბარათი (NFC)" + +#: src/gui/view_model/gtk/device.rs:62 +msgid "A security key (USB)" +msgstr "უსაფრთხოების გასაღები (USB)" + +#: src/gui/view_model/mod.rs:75 +msgid "unknown application" +msgstr "უცნობი აპლიკაცია" + +#. TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from +#: src/gui/view_model/mod.rs:80 +msgid "Create a passkey for %s1" +msgstr "საკვანძო გასაღების შექმნა %1-ისთვის" + +#. TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from +#: src/gui/view_model/mod.rs:84 +msgid "Use a passkey for %s1" +msgstr "საკვანძო გასაღების გამოყენება %1-ისთვის" + +#. TRANSLATORS: %s1 is the "relying party" (e.g.: domain name) where the request is coming from +#. TRANSLATORS: %s2 is the application name (e.g.: firefox) where the request is coming from, must be left untouched to make the name bold +#. TRANSLATORS: %i1 is the process ID of the requesting application +#. TRANSLATORS: %s3 is the absolute path (think: /usr/bin/firefox) of the requesting application +#: src/gui/view_model/mod.rs:96 +msgid "" +"\"%s2\" (process ID: %i1, binary: %s3) is asking to create a " +"credential to register at \"%s1\". Only proceed if you trust this process." +msgstr "" +"\"%s2\" (პროცესის ID: %i1, გამშვები ფაილი: %s3) ითხოვს ავტორიზაციის " +"დეტალების შექმნას \"%s1\"-ზე რეგისტრაციისთვის. გააგრძელეთ, მხოლოდ, მაშინ, თუ " +"ენდობით ამ პროცესს." + +#. TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from +#. TRANSLATORS: %s2 is the application name (e.g.: firefox) where the request is coming from, must be left untouched to make the name bold +#. TRANSLATORS: %i1 is the process ID of the requesting application +#. TRANSLATORS: %s3 is the absolute path (think: /usr/bin/firefox) of the requesting application +#: src/gui/view_model/mod.rs:103 +msgid "" +"\"%s2\" (process ID: %i1, binary: %s3) is asking to use a credential " +"to sign in to \"%s1\". Only proceed if you trust this process." +msgstr "" +"\"%s2\" (პროცესის ID: %i1, გამშვები ფაილი: %s3) ითხოვს ავტორიზაციის " +"დეტალების გამოყენებას \"%s1\"-ზე შესასვლელად. გააგრძელეთ, მხოლოდ, მაშინ, თუ " +"ენდობით ამ პროცესს." + +#: src/gui/view_model/mod.rs:227 +msgid "Failed to select credential from device." +msgstr "ავტორიზაციის დეტალების არჩევა მოწყობილობიდან ჩავარდა." + +#: src/gui/view_model/mod.rs:281 +msgid "No matching credentials found on this authenticator." +msgstr "ამ ავთენტიკატორში შესაბამისი ავტორიზაციის დეტალები აღმოჩენილი არაა." + +#: src/gui/view_model/mod.rs:284 +msgid "" +"No more PIN attempts allowed. Try removing your device and plugging it back " +"in." +msgstr "" +"მეტი PIN-კოდი დაშვებული აღარაა. სცადეთ, გამოაძროთ თქვენი მოწყობილობა და ისევ " +"შეაერთოთ." + +#: src/gui/view_model/mod.rs:287 +msgid "" +"This server requires your device to have additional protection like a PIN, " +"which is not set. Please set a PIN for this device and try again." +msgstr "" +"ეს სერვერი ითხოვს, რომ თქვენს მოწყობილობას ჰქონდეს დამატებითი დაცვა, " +"როგორიცაა PIN-კოდი, რომელიც დაყენებული არაა. დააყენეთ PIN-კოდი ამ " +"მოწყობილობისთვის და თავიდან სცადეთ." + +#: src/gui/view_model/mod.rs:293 +msgid "This credential is already registered on this authenticator." +msgstr "ეს ავტორიზაციის დეტალი უკვე რეგისტრირებულია ამ ავთენტიკატორზე." + +#: src/gui/view_model/mod.rs:395 +msgid "Something went wrong. Try again later or use a different authenticator." +msgstr "" +"რაღაც არასწორად წავიდა. სცადეთ მოგვიანებით კიდევ ერთხელ, ან გამოიყენეთ სხვა " +"ავთენტიკატორი." diff --git a/credentialsd-ui/src/client.rs b/credentialsd-ui/src/client.rs index 0f2184d..7642596 100644 --- a/credentialsd-ui/src/client.rs +++ b/credentialsd-ui/src/client.rs @@ -1,5 +1,12 @@ -use async_std::stream::Stream; -use credentialsd_common::{client::FlowController, server::RequestId}; +use async_std::{ + channel::{Receiver, Sender}, + stream::Stream, + sync::Mutex as AsyncMutex, +}; +use credentialsd_common::{ + client::FlowController, + model::{BackendRequest, BackgroundEvent, RequestId}, +}; use futures_lite::StreamExt; use zbus::Connection; @@ -118,3 +125,51 @@ impl FlowController for DbusCredentialClient { Ok(()) } } + +#[derive(Debug)] +pub struct FlowControlClient { + pub tx: Sender, + pub rx: AsyncMutex>>, +} + +impl FlowControlClient { + pub async fn discover_hybrid_authenticators(&self) -> Result<(), ()> { + self.send(BackendRequest::StartHybridDiscovery).await + } + + pub async fn discover_nfc_authenticators(&mut self) -> Result<(), ()> { + self.send(BackendRequest::StartNfcDiscovery).await + } + + pub async fn discover_usb_authenticators(&mut self) -> Result<(), ()> { + self.send(BackendRequest::StartUsbDiscovery).await + } + + pub async fn enter_client_pin(&mut self, pin: String) -> Result<(), ()> { + self.send(BackendRequest::EnterClientPin(pin)).await + } + + pub async fn select_credential(&self, credential_id: String) -> Result<(), ()> { + self.send(BackendRequest::SelectCredential(credential_id)) + .await + } + + pub async fn cancel_request(&self) -> Result<(), ()> { + self.send(BackendRequest::CancelRequest).await + } + + /// Returns a channel for background events. + /// Can only be called once; returns an error if the subscription has already been taken. + pub async fn subscribe(&mut self) -> Result, ()> { + self.rx.lock().await.take().ok_or_else(|| { + tracing::error!("Subscribe has already been called."); + }) + } + + async fn send(&self, request: BackendRequest) -> Result<(), ()> { + match self.tx.send(request).await { + Ok(_) => Ok(()), + Err(_) => Err(()), + } + } +} diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index 9ab511b..a588a83 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -1,9 +1,30 @@ -use async_std::channel::Sender; +use std::sync::Arc; + +use async_std::{ + channel::{self, Receiver, Sender}, + stream::StreamExt, + sync::Mutex as AsyncMutex, + task::JoinHandle, +}; +use zbus::{ + Connection, ObjectServer, fdo, interface, + message::Header, + names::{BusName, OwnedUniqueName}, + object_server::SignalEmitter, + proxy, + zvariant::ObjectPath, +}; + use credentialsd_common::{ - model::{BackgroundEvent, Device}, - server::{RequestId, ViewRequest}, + client::FlowController, + model::{ + BackendRequest, BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId, + RequestingApplication, + }, + server::{ViewRequest, WindowHandle}, }; -use zbus::{fdo, interface, proxy}; + +use crate::client::{DbusCredentialClient, FlowControlClient}; #[proxy( gen_blocking = false, @@ -31,17 +52,247 @@ pub trait FlowControlService { } pub struct UiControlService { - pub request_tx: Sender, + pub request_tx: Sender<(ViewRequest, Arc>)>, } /// These methods are called by the credential service to control the UI. #[interface(name = "xyz.iinuwa.credentialsd.UiControl1")] impl UiControlService { - async fn launch_ui(&self, request: ViewRequest) -> fdo::Result<()> { + async fn launch_ui( + &self, + #[zbus(connection)] conn: &Connection, + request: ViewRequest, + ) -> fdo::Result<()> { tracing::debug!("Received UI launch request"); + let mut client = DbusCredentialClient::new(conn.clone()); + let (fc_tx, fc_rx) = async_std::channel::unbounded(); + let (bg_tx, bg_rx) = async_std::channel::unbounded(); + match client.subscribe().await { + Ok(mut bg_event_stream) => async_std::task::spawn(async move { + while let Some(bg_event) = bg_event_stream.next().await { + if let Err(_) = bg_tx.send(bg_event).await { + tracing::debug!("Background event receiver dropped. Stopping."); + break; + } + } + }), + Err(_) => { + tracing::error!( + ?request, + "Failed to subscribe to background events for request" + ); + return Err(fdo::Error::Failed( + "Failed to subscribe to background events for request".to_string(), + )); + } + }; + async_std::task::spawn(async move { + while let Ok(msg) = fc_rx.recv().await { + // UI doesn't get an error if these fail... + let result = match &msg { + BackendRequest::StartHybridDiscovery => client.get_hybrid_credential().await, + BackendRequest::StartNfcDiscovery => client.get_nfc_credential().await, + BackendRequest::StartUsbDiscovery => client.get_usb_credential().await, + BackendRequest::EnterClientPin(pin) => { + client.enter_client_pin(pin.to_string()).await + } + BackendRequest::SelectCredential(cred_id) => { + client.select_credential(cred_id.to_string()).await + } + BackendRequest::CancelRequest => client.cancel_request(request.id).await, + }; + if let Err(err) = result { + tracing::error!("Failed to send {msg:?} to frontend: {err:?}"); + } + } + client + }); + let flow_control_client = FlowControlClient { + tx: fc_tx, + rx: AsyncMutex::new(Some(bg_rx)), + }; self.request_tx - .send(request) + .send((request, Arc::new(AsyncMutex::new(flow_control_client)))) .await .map_err(|_| fdo::Error::Failed("UI failed to launch".to_string())) } } + +pub struct CredentialPortalBackend { + pub request_tx: Sender<(ViewRequest, Arc>)>, +} + +#[derive(Debug, Clone)] +pub(crate) struct UiContext { + parent_window: WindowHandle, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, +} + +/// These methods are called by the credential service to control the UI. +#[interface(name = "org.freedesktop.impl.portal.experimental.Credential")] +impl CredentialPortalBackend { + async fn initialize( + &self, + #[zbus(header)] header: Header<'_>, + #[zbus(object_server)] object_server: &ObjectServer, + parent_window: WindowHandle, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> fdo::Result> { + let Some(sender) = header.sender() else { + return Err(fdo::Error::BadAddress("Sender not found".to_string())); + }; + let object_path = ObjectPath::from_string_unchecked(format!( + "/org/freedesktop/portal/Credential/{}", + request_id + )); + let ui_context = UiContext { + parent_window, + origin, + r#type, + request_id, + devices, + app_id, + app_display_name, + app_pid, + app_path, + options, + }; + let flow_object = FlowObject { + ui_context, + request_tx: self.request_tx.clone(), + return_address: sender.to_owned().into(), + ui_events_forwarder_task: None, + bg_events_tx: None, + }; + object_server.at(object_path.clone(), flow_object).await?; + tracing::debug!("Received UI launch request"); + Ok(object_path) + } +} + +pub struct FlowObject { + ui_context: UiContext, + pub request_tx: Sender<(ViewRequest, Arc>)>, + pub return_address: OwnedUniqueName, + ui_events_forwarder_task: Option>, + bg_events_tx: Option>, +} + +#[interface(name = "org.freedesktop.impl.portal.experimental.Credential.FlowObject")] +impl FlowObject { + /// Start the UI flow with an initial set of available credential interfaces. + /// Call this method after subscribing to the signals. + async fn start( + &mut self, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + ) -> fdo::Result<()> { + let (ui_events_tx, ui_events_rx) = channel::bounded(32); + let (bg_events_tx, bg_events_rx) = channel::bounded(32); + let flow_control_client = FlowControlClient { + tx: ui_events_tx, + rx: AsyncMutex::new(Some(bg_events_rx)), + }; + self.bg_events_tx = Some(bg_events_tx); + + let emitter = emitter + .set_destination(BusName::Unique((&self.return_address).into())) + .to_owned(); + let ui_events_task = async_std::task::spawn(async move { + while let Ok(ui_event) = ui_events_rx.recv().await { + tracing::trace!(?ui_event, "Sending UI event signal to portal"); + if emitter.user_interacted(&ui_event).await.is_err() { + tracing::error!("Failed to send UI event signal."); + // TODO: we need to cancel the request here, so we need a + // channel back to the flow object to send the cancellation. + break; + } + } + }); + self.ui_events_forwarder_task = Some(ui_events_task); + + // Assuming this is a PublicKey request, require the rp_id + let rp_id = self + .ui_context + .options + .rp_id + .as_ref() + .ok_or_else(|| { + { + fdo::Error::InvalidArgs( + "rp_id is required for public key credential requests".to_string(), + ) + } + })? + .to_string(); + let req = ( + ViewRequest { + operation: self.ui_context.r#type.clone(), + id: self.ui_context.request_id, + rp_id, + requesting_app: RequestingApplication { + path_or_app_id: self.ui_context.app_id.clone(), + name: Some(self.ui_context.app_display_name.clone()).into(), + pid: self.ui_context.app_pid, + }, + initial_devices: self.ui_context.devices.clone(), + window_handle: Some(self.ui_context.parent_window.clone()).into(), + }, + Arc::new(AsyncMutex::new(flow_control_client)), + ); + if self.request_tx.send(req).await.is_err() { + tracing::error!("Received message to start flow, but GUI thread is not listening."); + return Err(fdo::Error::Failed("Failed to start GUI".to_string())); + } + Ok(()) + } + + async fn notify_state_changed(&self, event: BackgroundEvent) -> fdo::Result<()> { + tracing::trace!(?event, "Received background event"); + if let Some(tx) = &self.bg_events_tx { + if tx.send(event).await.is_ok() { + return Ok(()); + } + tracing::error!("Failed to send event to GUI thread"); + } else { + tracing::error!("Flow was not properly initialized before receiving events."); + } + return Err(fdo::Error::Failed("Failed to handle event".to_string())); + } + + async fn cancel( + &mut self, + #[zbus(header)] header: Header<'_>, + #[zbus(object_server)] object_server: &ObjectServer, + ) -> fdo::Result<()> { + if let Some(task) = self.ui_events_forwarder_task.take() { + task.cancel().await; + } + if let Some(path) = header.path() { + // TODO: Send clean up task to GUI thread. + object_server.remove::(path).await?; + } + Ok(()) + } + + #[zbus(signal)] + async fn user_interacted( + emitter: SignalEmitter<'_>, + event: &BackendRequest, + ) -> zbus::Result<()>; +} diff --git a/credentialsd-ui/src/gui/mod.rs b/credentialsd-ui/src/gui/mod.rs index bd65aed..e7e3e10 100644 --- a/credentialsd-ui/src/gui/mod.rs +++ b/credentialsd-ui/src/gui/mod.rs @@ -5,28 +5,27 @@ use std::{sync::Arc, thread::JoinHandle}; use async_std::{channel::Receiver, sync::Mutex as AsyncMutex}; -use credentialsd_common::server::{ViewRequest, WindowHandle}; -use credentialsd_common::{client::FlowController, model::ViewUpdate}; +use credentialsd_common::{ + model::ViewUpdate, + server::{ViewRequest, WindowHandle}, +}; + +use crate::client::FlowControlClient; use view_model::ViewEvent; -pub(super) fn start_gui_thread( - rx: Receiver, - flow_controller: F, +pub(super) fn start_gui_thread( + rx: Receiver<(ViewRequest, Arc>)>, ) -> Result, std::io::Error> { thread::Builder::new().name("gui".into()).spawn(move || { - let flow_controller = Arc::new(AsyncMutex::new(flow_controller)); // D-Bus received a request and needs a window open - while let Ok(view_request) = rx.recv_blocking() { - run_gui(flow_controller.clone(), view_request); + while let Ok((view_request, flow_controller)) = rx.recv_blocking() { + run_gui(flow_controller, view_request); } }) } -fn run_gui( - flow_controller: Arc>, - request: ViewRequest, -) { +fn run_gui(flow_controller: Arc>, request: ViewRequest) { let parent_window: Option = request.window_handle.as_ref().and_then(|h| { h.to_string() .try_into() @@ -43,11 +42,8 @@ fn run_gui( vm.start_event_loop().await; tracing::debug!("Finishing user request."); // If cancellation fails, that's fine. - let _ = flow_controller - .lock() - .await - .cancel_request(request_id) - .await; + let _ = flow_controller.lock().await.cancel_request().await; + // TODO: Clean up flow_object when request completes }); view_model::gtk::start_gtk_app(parent_window, tx_event, rx_update); diff --git a/credentialsd-ui/src/gui/view_model/mod.rs b/credentialsd-ui/src/gui/view_model/mod.rs index d3113ef..8eb8bcc 100644 --- a/credentialsd-ui/src/gui/view_model/mod.rs +++ b/credentialsd-ui/src/gui/view_model/mod.rs @@ -13,20 +13,16 @@ use gettextrs::gettext; use serde::{Deserialize, Serialize}; use tracing::{error, info}; -use credentialsd_common::{ - client::FlowController, - model::{ - BackgroundEvent, Credential, Device, Error, HybridState, NfcState, Operation, Transport, - UsbState, ViewUpdate, - }, +use credentialsd_common::model::{ + BackgroundEvent, Credential, Device, Error, HybridState, NfcState, Operation, Transport, + UsbState, ViewUpdate, }; +use crate::client::FlowControlClient; + #[derive(Debug)] -pub(crate) struct ViewModel -where - F: FlowController + Send, -{ - flow_controller: Arc>, +pub(crate) struct ViewModel { + flow_controller: Arc>, tx_update: Sender, rx_event: Receiver, title: String, @@ -47,10 +43,10 @@ where // hybrid_linked_state: HybridState, } -impl ViewModel { +impl ViewModel { pub(crate) fn new( request: ViewRequest, - flow_controller: Arc>, + flow_controller: Arc>, rx_event: Receiver, tx_update: Sender, ) -> Self { @@ -61,6 +57,7 @@ impl ViewModel { } = request.requesting_app; let app_name: Option = app_name.into(); + let devices = request.initial_devices; Self { flow_controller, rx_event, @@ -72,7 +69,7 @@ impl ViewModel { app_pid: pid, title: String::default(), subtitle: String::default(), - devices: Vec::new(), + devices, selected_device: None, hybrid_qr_state: HybridState::default(), hybrid_qr_code_data: None, @@ -81,11 +78,11 @@ impl ViewModel { async fn update_title(&mut self) { let mut title = match self.operation { - Operation::Create => { + Operation::PublicKeyCreate => { // TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from gettext("Create a passkey for %s1") } - Operation::Get => { + Operation::PublicKeyGet => { // TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from gettext("Use a passkey for %s1") } @@ -94,14 +91,14 @@ impl ViewModel { title = title.replace("%s1", &self.rp_id); let mut subtitle = match self.operation { - Operation::Create => { + Operation::PublicKeyCreate => { // TRANSLATORS: %s1 is the "relying party" (e.g.: domain name) where the request is coming from // TRANSLATORS: %s2 is the application name (e.g.: firefox) where the request is coming from, must be left untouched to make the name bold // TRANSLATORS: %i1 is the process ID of the requesting application // TRANSLATORS: %s3 is the absolute path (think: /usr/bin/firefox) of the requesting application gettext("\"%s2\" (process ID: %i1, binary: %s3) is asking to create a credential to register at \"%s1\". Only proceed if you trust this process.") } - Operation::Get => { + Operation::PublicKeyGet => { // TRANSLATORS: %s1 is the "relying party" (think: domain name) where the request is coming from // TRANSLATORS: %s2 is the application name (e.g.: firefox) where the request is coming from, must be left untouched to make the name bold // TRANSLATORS: %i1 is the process ID of the requesting application @@ -125,14 +122,7 @@ impl ViewModel { .unwrap(); } - async fn update_devices(&mut self) { - let devices = self - .flow_controller - .lock() - .await - .get_available_public_key_devices() - .await - .unwrap(); + async fn update_devices(&mut self, devices: Vec) { self.devices = devices; self.tx_update .send(ViewUpdate::SetDevices(self.devices.to_owned())) @@ -169,15 +159,15 @@ impl ViewModel { match device.transport { Transport::Usb => { let mut cred_service = self.flow_controller.lock().await; - (*cred_service).get_usb_credential().await.unwrap(); + (*cred_service).discover_usb_authenticators().await.unwrap(); } Transport::Nfc => { let mut cred_service = self.flow_controller.lock().await; - (*cred_service).get_nfc_credential().await.unwrap(); + (*cred_service).discover_nfc_authenticators().await.unwrap(); } Transport::HybridQr => { - let mut cred_service = self.flow_controller.lock().await; - cred_service.get_hybrid_credential().await.unwrap(); + let cred_service = self.flow_controller.lock().await; + cred_service.discover_hybrid_authenticators().await.unwrap(); } _ => { todo!() @@ -201,7 +191,7 @@ impl ViewModel { match event { Event::View(ViewEvent::Initiated) => { self.update_title().await; - self.update_devices().await; + self.update_devices(self.devices.clone()).await; } Event::View(ViewEvent::DeviceSelected(id)) => { self.select_device(&id).await; @@ -240,6 +230,10 @@ impl ViewModel { break; } + // TODO: Add this event + // Event::Background(BackgroundEvent::DevicesUpdated(devices)) => { + // self.update_devices(devices).await + // } Event::Background(BackgroundEvent::UsbStateChanged(state)) => { match state { UsbState::Connected => { diff --git a/credentialsd-ui/src/main.rs b/credentialsd-ui/src/main.rs index 701e0ba..6aa2aa6 100644 --- a/credentialsd-ui/src/main.rs +++ b/credentialsd-ui/src/main.rs @@ -6,7 +6,7 @@ mod gui; use std::error::Error; -use crate::{client::DbusCredentialClient, dbus::UiControlService}; +use crate::dbus::{CredentialPortalBackend, UiControlService}; fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); @@ -19,18 +19,20 @@ async fn run() -> Result<(), Box> { let (request_tx, request_rx) = async_std::channel::bounded(2); // this allows the D-Bus service to signal to the GUI to draw a window for // executing the credential flow. - let client_conn = zbus::connection::Builder::session()?.build().await?; - let cred_client = DbusCredentialClient::new(client_conn); - let _handle = gui::start_gui_thread(request_rx, cred_client)?; + let _handle = gui::start_gui_thread(request_rx)?; println!(" ✅"); print!("Starting UI Control listener...\t"); - let interface = UiControlService { request_tx }; + let interface = UiControlService { + request_tx: request_tx.clone(), + }; + let portal_backend_interface = CredentialPortalBackend { request_tx }; let path = "/xyz/iinuwa/credentialsd/UiControl"; let service = "xyz.iinuwa.credentialsd.UiControl"; let _server_conn = zbus::connection::Builder::session()? .name(service)? .serve_at(path, interface)? + .serve_at("/org/freedesktop/portal/desktop", portal_backend_interface)? .build() .await?; println!(" ✅"); diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 09b593b..92c5c12 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -3,26 +3,22 @@ pub mod nfc; pub mod usb; use std::{ - error::Error, fmt::Debug, - future::Future, pin::Pin, sync::{Arc, Mutex}, task::Poll, }; +use async_trait::async_trait; use futures_lite::{FutureExt, Stream, StreamExt}; use libwebauthn::{ self, ops::webauthn::{GetAssertionResponse, MakeCredentialResponse}, }; use nfc::{NfcEvent, NfcHandler, NfcState, NfcStateInternal}; -use tokio::sync::oneshot::Sender; +use tokio::sync::oneshot; -use credentialsd_common::{ - model::{Device, Error as CredentialServiceError, Operation, RequestingApplication, Transport}, - server::{RequestId, ViewRequest, WindowHandle}, -}; +use credentialsd_common::model::{Device, Error as CredentialServiceError, RequestId, Transport}; use crate::{ credential_service::{hybrid::HybridEvent, usb::UsbEvent}, @@ -36,18 +32,10 @@ use self::{ pub use usb::UsbState; -/// Used by the credential service to control the UI. -pub trait UiController { - fn launch_ui( - &self, - request: ViewRequest, - ) -> impl Future>> + Send; -} - #[derive(Debug)] struct RequestContext { request: CredentialRequest, - response_channel: Sender>, + response_channel: oneshot::Sender>, request_id: RequestId, } @@ -61,101 +49,76 @@ impl RequestContext { } } +/// Manages request to authenticator devices. +#[async_trait] +pub trait ManageDevice { + async fn init_request( + &self, + request: &CredentialRequest, + tx: oneshot::Sender>, + ) -> Result; + async fn cancel_request(&self, request_id: RequestId); + async fn get_available_public_key_devices(&self) -> Result, ()>; + async fn get_hybrid_credential( + &self, + ) -> Pin + Send + 'static>>; + async fn get_nfc_credential(&self) -> Pin + Send + 'static>>; + async fn get_usb_credential(&self) -> Pin + Send + 'static>>; +} + #[derive(Debug)] -pub struct CredentialService { +pub struct CredentialService { /// Current request and channel to respond to caller. ctx: Arc>>, - hybrid_handler: H, - usb_handler: U, - nfc_handler: N, - - ui_control_client: Arc, + hybrid_handler: Mutex, + nfc_handler: Mutex, + usb_handler: Mutex, } -impl< - H: HybridHandler + Debug, - U: UsbHandler + Debug, - N: NfcHandler + Debug, - UC: UiController + Debug, - > CredentialService +impl + CredentialService { - pub fn new( - hybrid_handler: H, - usb_handler: U, - nfc_handler: N, - ui_control_client: Arc, - ) -> Self { + pub fn new(hybrid_handler: H, nfc_handler: N, usb_handler: U) -> Self { Self { ctx: Arc::new(Mutex::new(None)), - hybrid_handler, - usb_handler, - nfc_handler, - - ui_control_client, + hybrid_handler: Mutex::new(hybrid_handler), + nfc_handler: Mutex::new(nfc_handler), + usb_handler: Mutex::new(usb_handler), } } +} - pub async fn init_request( +#[async_trait] +impl ManageDevice + for CredentialService +{ + async fn init_request( &self, request: &CredentialRequest, - requesting_app: Option, - window_handle: Option, - tx: Sender>, - ) { - let request_id = { - let mut cred_request = self.ctx.lock().unwrap(); - if cred_request.is_some() { - tx.send(Err(CredentialServiceError::Internal( - "Already a request in progress.".to_string(), - ))) - .expect("Send to local receiver to succeed"); - return; - } else { - let request_id: RequestId = rand::random(); - let ctx = RequestContext { - request: request.clone(), - response_channel: tx, - request_id, - }; - _ = cred_request.insert(ctx); - request_id - } - }; - let operation = match &request { - CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::Create, - CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, - }; - let rp_id = match &request { - CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), - CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), - }; - let view_request = ViewRequest { - operation, - id: request_id, - rp_id, - requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer - window_handle: window_handle.into(), - }; - - let launch_ui_response = self - .ui_control_client - .launch_ui(view_request) - .await - .map_err(|err| err.to_string()); - if let Err(err) = launch_ui_response { - tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); - let err = Err(CredentialServiceError::Internal(err)); - let ctx = self.ctx.lock().unwrap().take().unwrap(); - ctx.response_channel - .send(err) - .expect("Request handler to be listening"); + tx: oneshot::Sender>, + ) -> Result { + let mut cred_request = self.ctx.lock().unwrap(); + if cred_request.is_some() { + Err(CredentialServiceError::Internal( + "Already a request in progress.".to_string(), + )) + } else { + let request_id: RequestId = rand::random(); + // TODO: Spawn a task here that will listen to the signals from ui_control_client. + // Move the get_*_credential(), etc. from gateway to here. + let ctx = RequestContext { + request: request.clone(), + response_channel: tx, + request_id, + }; + _ = cred_request.insert(ctx); + Ok(request_id) } - tracing::debug!("Finished setting up request {request_id}"); } - pub async fn cancel_request(&self, request_id: RequestId) { + async fn cancel_request(&self, request_id: RequestId) { let mut guard = self.ctx.lock().expect("Lock to be taken"); if let Some(ctx) = guard.take_if(|ctx| ctx.request_id == request_id) { if request_id == ctx.request_id { @@ -173,7 +136,7 @@ impl< } } - pub async fn get_available_public_key_devices(&self) -> Result, ()> { + async fn get_available_public_key_devices(&self) -> Result, ()> { // We create the list new for each call, in case someone plugs in // an NFC-reader in the middle of an auth-flow let mut devices = vec![ @@ -195,12 +158,12 @@ impl< Ok(devices) } - pub fn get_hybrid_credential( + async fn get_hybrid_credential( &self, ) -> Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); if let Some(RequestContext { ref request, .. }) = *guard { - let stream = self.hybrid_handler.start(request); + let stream = self.hybrid_handler.lock().unwrap().start(request); let ctx = self.ctx.clone(); Box::pin(HybridStateStream { inner: stream, ctx }) } else { @@ -211,10 +174,10 @@ impl< } } - pub fn get_usb_credential(&self) -> Pin + Send + 'static>> { + async fn get_usb_credential(&self) -> Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); if let Some(RequestContext { ref request, .. }) = *guard { - let stream = self.usb_handler.start(request); + let stream = self.usb_handler.lock().unwrap().start(request); let ctx = self.ctx.clone(); Box::pin(UsbStateStream { inner: stream, ctx }) } else { @@ -225,10 +188,10 @@ impl< } } - pub fn get_nfc_credential(&self) -> Pin + Send + 'static>> { + async fn get_nfc_credential(&self) -> Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); if let Some(RequestContext { ref request, .. }) = *guard { - let stream = self.nfc_handler.start(request); + let stream = self.nfc_handler.lock().unwrap().start(request); let ctx = self.ctx.clone(); Box::pin(NfcStateStream { inner: stream, ctx }) } else { @@ -398,7 +361,7 @@ mod test { use super::{ hybrid::{test::DummyHybridHandler, HybridStateInternal}, nfc::InProcessNfcHandler, - AuthenticatorResponse, CredentialService, + AuthenticatorResponse, CredentialService, ManageDevice, }; #[test] @@ -421,25 +384,25 @@ mod test { ]); let usb_handler = InProcessUsbHandler {}; let nfc_handler = InProcessNfcHandler {}; - let (ui_server, ui_client) = DummyUiServer::new(Vec::new()); + let (ui_server, _ui_client) = DummyUiServer::new(Vec::new()); let ui_server = Arc::new(ui_server); let user = ui_server.clone(); let cred_service = Arc::new(AsyncMutex::new(CredentialService::new( hybrid_handler, - usb_handler, nfc_handler, - Arc::new(ui_client), + usb_handler, ))); let (mut flow_server, flow_client) = DummyFlowServer::new(cred_service.clone()); ui_server.init(flow_client).await; tokio::spawn(async move { ui_server.run().await }); tokio::spawn(async move { flow_server.run().await }); - cred_service + _ = cred_service .lock() .await - .init_request(&request, None, None, request_tx) - .await; + .init_request(&request, request_tx) + .await + .unwrap(); user.request_hybrid_credential().await; tokio::time::timeout(Duration::from_secs(5), request_rx) .await @@ -453,7 +416,7 @@ mod test { let challenge = "Ox0AXQz7WUER7BGQFzvVrQbReTkS3sepVGj26qfUhhrWSarkDbGF4T4NuCY1aAwHYzOzKMJJ2YRSatetl0D9bQ"; let origin = NavigationContext::SameOrigin("https://webauthn.io".parse().unwrap()); let client_data_json = - webauthn::format_client_data_json(Operation::Create, challenge, &origin); + webauthn::format_client_data_json(Operation::PublicKeyCreate, challenge, &origin); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); let make_request = MakeCredentialRequest { hash: client_data_hash, diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index a6ed555..39b6f39 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -1,14 +1,16 @@ //! This module implements the service to allow the user to control the flow of //! the credential request through the trusted UI. +use std::sync::Mutex; use std::{collections::VecDeque, fmt::Debug, sync::Arc}; use async_trait::async_trait; use credentialsd_common::model::{ - BackgroundEvent, Device, Error as CredentialServiceError, RequestingApplication, WebAuthnError, + BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, Operation, + PortalBackendOptions, RequestId, RequestingApplication, WebAuthnError, }; -use credentialsd_common::server::{RequestId, WindowHandle}; -use futures_lite::StreamExt; +use credentialsd_common::server::{ViewRequest, WindowHandle}; +use futures_lite::{Stream, StreamExt}; use tokio::sync::oneshot; use tokio::{ sync::{ @@ -24,42 +26,36 @@ use zbus::{ ObjectServer, }; +use crate::credential_service::ManageDevice; +use crate::dbus::ui_control::Flow; +use crate::dbus::UiControlServiceClient; use crate::{ - credential_service::{ - hybrid::{HybridHandler, HybridState}, - nfc::{NfcHandler, NfcState}, - usb::UsbHandler, - CredentialService, UiController, UsbState, - }, + credential_service::{hybrid::HybridState, nfc::NfcState, UsbState}, + dbus::ui_control::UiController, model::{CredentialRequest, CredentialResponse}, }; pub const SERVICE_PATH: &str = "/xyz/iinuwa/credentialsd/FlowControl"; pub const SERVICE_NAME: &str = "xyz.iinuwa.credentialsd.FlowControl"; -pub async fn start_flow_control_service< - H: HybridHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, - N: NfcHandler + Debug + Send + Sync + 'static, - UC: UiController + Debug + Send + Sync + 'static, ->( - credential_service: CredentialService, +pub async fn start_flow_control_service( + device_manager: M, ) -> zbus::Result<( Connection, Sender<( CredentialRequest, - Option, // Application name sending the request - Option, // Client window handle + RequestingApplication, + Option, // Client window handle oneshot::Sender>, )>, )> { - let svc = Arc::new(AsyncMutex::new(credential_service)); + let svc = Arc::new(AsyncMutex::new(device_manager)); let svc2 = svc.clone(); let conn = Builder::session()? .name(SERVICE_NAME)? .serve_at( SERVICE_PATH, - FlowControlService { + FlowControlDbusService { signal_state: Arc::new(AsyncMutex::new(SignalState::Idle)), svc, pin_tx: Arc::new(AsyncMutex::new(None)), @@ -71,22 +67,203 @@ pub async fn start_flow_control_service< )? .build() .await?; - let (initiator_tx, mut initiator_rx) = mpsc::channel(2); + let (initiator_tx, mut initiator_rx) = mpsc::channel::<( + CredentialRequest, + RequestingApplication, + Option, + oneshot::Sender>, + )>(2); + let conn2 = conn.clone(); tokio::spawn(async move { - let svc = svc2; while let Some((msg, requesting_app, window_handle, tx)) = initiator_rx.recv().await { - svc.lock() - .await - .init_request(&msg, requesting_app, window_handle, tx) - .await; + let svc = svc2.clone(); + let ui_control_client = UiControlServiceClient::new(conn2.clone()); + if let Err(_) = + tx.send(handle(svc, ui_control_client, msg, requesting_app, window_handle).await) + { + tracing::error!( + "Received response to credential request, but failed to forward it to gateway" + ); + } } }); Ok((conn, initiator_tx)) } -struct FlowControlService { +async fn handle( + svc: Arc>, + ui_control_client: UC, + msg: CredentialRequest, + requesting_app: RequestingApplication, + window_handle: Option, +) -> Result { + let (request_tx, request_rx) = oneshot::channel(); + let request_id = svc.lock().await.init_request(&msg, request_tx).await?; + let operation = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::PublicKeyCreate, + CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::PublicKeyGet, + }; + let rp_id = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), + CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), + }; + + // TODO: pass origin to this method so we can do this correctly. + let origin = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.origin.clone(), + CredentialRequest::GetPublicKeyCredentialRequest(r) => { + format!("https://{}", r.relying_party_id.clone()) + } + }; + + // TODO: pass top_origin to this method so we can do this correctly. + let top_origin = match &msg { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => None, + CredentialRequest::GetPublicKeyCredentialRequest(r) => None, + }; + let initial_devices = svc + .lock() + .await + .get_available_public_key_devices() + .await + .unwrap_or_default(); + + let RequestingApplication { + path_or_app_id, + name: app_name, + pid: app_pid, + } = requesting_app; + let app_name = Option::from(app_name).unwrap_or_else(|| "TODO: Require app name".to_string()); + let flow = match ui_control_client + .initialize( + window_handle, + origin, + operation, + request_id, + initial_devices, + path_or_app_id.clone(), + app_name, + app_pid, + // TODO: Make path and app ID separate. + path_or_app_id, + PortalBackendOptions { + top_origin: top_origin.into(), + rp_id: Some(rp_id).into(), + }, + ) + .await + { + Ok(rx) => rx, + Err(err) => { + tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); + return Err(CredentialServiceError::Internal(err.to_string())); + } + }; + tokio::spawn(async move { + let client_pin_tx: Arc>>> = Arc::new(Mutex::new(None)); + let cred_selector_tx = Arc::new(Mutex::new(None)); + while let Some(ui_request) = flow.receive_ui_event().await { + match ui_request { + BackendRequest::StartHybridDiscovery => { + let stream = svc + .lock() + .await + .get_hybrid_credential() + .await + .map(|state| BackgroundEvent::HybridQrStateChanged(state.into())); + let flow = flow.clone(); + forward_background_event_stream(flow, stream); + } + BackendRequest::StartNfcDiscovery => { + let stream = svc + .lock() + .await + .get_nfc_credential() + .await + .map(|state| BackgroundEvent::NfcStateChanged(state.into())); + let flow = flow.clone(); + forward_background_event_stream(flow, stream); + } + BackendRequest::StartUsbDiscovery => { + let client_pin_tx = client_pin_tx.clone(); + let cred_selector_tx = cred_selector_tx.clone(); + let stream = + svc.lock() + .await + .get_usb_credential() + .await + .map(move |usb_state| { + match &usb_state { + UsbState::NeedsPin { pin_tx, .. } => { + *client_pin_tx.lock().unwrap() = Some(pin_tx.clone()); + } + UsbState::SelectingCredential { cred_tx, .. } => { + *cred_selector_tx.lock().unwrap() = Some(cred_tx.clone()); + } + _ => {} + } + BackgroundEvent::UsbStateChanged(usb_state.into()) + }); + let flow = flow.clone(); + forward_background_event_stream(flow, stream); + } + BackendRequest::EnterClientPin(pin) => { + let tx = { client_pin_tx.lock().unwrap().take() }; + if let Some(tx) = tx { + if tx.send(pin).await.is_err() { + tracing::error!("Failed to send client PIN to device"); + } + } else { + tracing::error!( + "Invalid state: received a client PIN with no pending request." + ); + } + } + BackendRequest::SelectCredential(id) => { + let tx = { cred_selector_tx.lock().unwrap().take() }; + if let Some(tx) = tx { + if tx.send(id).await.is_err() { + tracing::error!("Failed to send credential selection to device"); + } + } else { + tracing::error!( + "Invalid state: received a credential selection ID with no pending request." + ); + } + } + BackendRequest::CancelRequest => { + tracing::debug!(%request_id, "Cancelling request"); + svc.lock().await.cancel_request(request_id).await; + } + } + } + }); + tracing::debug!("Finished setting up request {request_id}"); + let cred_response = request_rx + .await + .expect("Credential service not to drop request channel before responding."); + let f = cred_response.map_err(|err| err.into()); + f +} + +fn forward_background_event_stream( + flow: Flow, + mut stream: impl Stream + Send + Unpin + 'static, +) { + tokio::spawn(async move { + while let Some(event) = stream.next().await { + let send_result = flow.send_state_update(event).await; + if send_result.is_err() { + tracing::error!("Failed to send state update event to backend. Stopping flow"); + break; + } + } + }); +} + +struct FlowControlService { + svc: Arc>, signal_state: Arc>, - svc: Arc>>, pin_tx: Arc>>>, cred_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -94,6 +271,23 @@ struct FlowControlService>>, } +impl FlowControlService { + fn send_update(&self) {} +} + +struct FlowControlDbusService { + svc: Arc>, + + signal_state: Arc>, + + cred_tx: Arc>>>, + pin_tx: Arc>>>, + + hybrid_event_forwarder_task: Arc>>, + nfc_event_forwarder_task: Arc>>, + usb_event_forwarder_task: Arc>>, +} + /// The following methods are for communication between the [trusted] /// UI and the credential service, and should not be called by arbitrary /// clients. @@ -105,12 +299,9 @@ struct FlowControlService FlowControlService +impl FlowControlDbusService where - H: HybridHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, - N: NfcHandler + Debug + Send + Sync + 'static, - UC: UiController + Debug + Send + Sync + 'static, + M: ManageDevice + Debug + Send + Sync + 'static, { async fn subscribe( &self, @@ -148,11 +339,11 @@ where #[zbus(object_server)] object_server: &ObjectServer, ) -> fdo::Result<()> { let svc = self.svc.lock().await; - let mut stream = svc.get_hybrid_credential(); + let mut stream = svc.get_hybrid_credential().await; let signal_state = self.signal_state.clone(); let object_server = object_server.clone(); let task = tokio::spawn(async move { - let interface: zbus::Result>> = + let interface: zbus::Result>> = object_server.interface(SERVICE_PATH).await; let emitter = match interface { @@ -189,13 +380,13 @@ where &self, #[zbus(object_server)] object_server: &ObjectServer, ) -> fdo::Result<()> { - let mut stream = self.svc.lock().await.get_usb_credential(); + let mut stream = self.svc.lock().await.get_usb_credential().await; let usb_pin_tx = self.pin_tx.clone(); let usb_cred_tx = self.cred_tx.clone(); let signal_state = self.signal_state.clone(); let object_server = object_server.clone(); let task = tokio::spawn(async move { - let interface: zbus::Result>> = + let interface: zbus::Result>> = object_server.interface(SERVICE_PATH).await; let emitter = match interface { @@ -239,13 +430,13 @@ where &self, #[zbus(object_server)] object_server: &ObjectServer, ) -> fdo::Result<()> { - let mut stream = self.svc.lock().await.get_nfc_credential(); + let mut stream = self.svc.lock().await.get_nfc_credential().await; let nfc_pin_tx = self.pin_tx.clone(); let nfc_cred_tx = self.cred_tx.clone(); let signal_state = self.signal_state.clone(); let object_server = object_server.clone(); let task = tokio::spawn(async move { - let interface: zbus::Result>> = + let interface: zbus::Result>> = object_server.interface(SERVICE_PATH).await; let emitter = match interface { @@ -347,7 +538,7 @@ enum SignalState { pub trait CredentialRequestController { async fn request_credential( &self, - requesting_app: Option, + requesting_app: RequestingApplication, request: CredentialRequest, window_handle: Option, ) -> Result; @@ -356,8 +547,8 @@ pub trait CredentialRequestController { pub struct CredentialRequestControllerClient { pub initiator: Sender<( CredentialRequest, - Option, // Application name sending the request - Option, // Client window handle, + RequestingApplication, // Application name sending the request + Option, // Client window handle, oneshot::Sender>, )>, } @@ -366,7 +557,7 @@ pub struct CredentialRequestControllerClient { impl CredentialRequestController for CredentialRequestControllerClient { async fn request_credential( &self, - requesting_app: Option, + requesting_app: RequestingApplication, request: CredentialRequest, window_handle: Option, ) -> Result { @@ -403,18 +594,12 @@ pub mod test { use credentialsd_common::{ client::FlowController, - model::{BackgroundEvent, Device}, - server::RequestId, + model::{BackgroundEvent, Device, RequestId}, }; use futures_lite::{Stream, StreamExt}; use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; - use crate::credential_service::{ - hybrid::{HybridHandler, HybridState}, - nfc::{NfcHandler, NfcState}, - usb::UsbHandler, - CredentialService, UiController, UsbState, - }; + use crate::credential_service::{hybrid::HybridState, nfc::NfcState, ManageDevice, UsbState}; #[allow(clippy::enum_variant_names)] #[derive(Debug)] @@ -544,15 +729,12 @@ pub mod test { } #[derive(Debug)] - pub struct DummyFlowServer + pub struct DummyFlowServer where - H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - UC: UiController + Debug + Send + Sync, + M: ManageDevice, { rx: mpsc::Receiver<(DummyFlowRequest, oneshot::Sender)>, - svc: Arc>>, + svc: Arc>, bg_event_tx: Option>, pin_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -560,13 +742,7 @@ pub mod test { hybrid_event_forwarder_task: Arc>>, } - impl< - H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - UC: UiController + Debug + Send + Sync, - > DummyFlowServer - { + impl DummyFlowServer { /* async fn send(&self, request: ManagementRequest) -> Result { let (response_tx, response_rx) = oneshot::channel(); @@ -587,9 +763,7 @@ pub mod test { } } */ - pub fn new( - svc: Arc>>, - ) -> (Self, DummyFlowClient) { + pub fn new(svc: Arc>) -> (Self, DummyFlowClient) { let (request_tx, request_rx) = mpsc::channel(32); let server = Self { rx: request_rx, @@ -652,7 +826,7 @@ pub mod test { async fn get_hybrid_credential(&mut self) -> Result<(), ()> { let svc = self.svc.lock().await; - let mut stream = svc.get_hybrid_credential(); + let mut stream = svc.get_hybrid_credential().await; tracing::debug!(target: "DummyFlowServer", "Subscribing to hybrid credential state changes"); if let Some(tx_weak) = self.bg_event_tx.as_ref().map(|t| t.clone().downgrade()) { let task = tokio::spawn(async move { @@ -694,7 +868,7 @@ pub mod test { } async fn get_usb_credential(&mut self) -> Result<(), ()> { - let mut stream = self.svc.lock().await.get_usb_credential(); + let mut stream = self.svc.lock().await.get_usb_credential().await; if let Some(tx_weak) = self.bg_event_tx.as_ref().map(|t| t.clone().downgrade()) { let usb_pin_tx = self.pin_tx.clone(); let task = tokio::spawn(async move { @@ -733,7 +907,7 @@ pub mod test { } async fn get_nfc_credential(&mut self) -> Result<(), ()> { - let mut stream = self.svc.lock().await.get_nfc_credential(); + let mut stream = self.svc.lock().await.get_nfc_credential().await; if let Some(tx_weak) = self.bg_event_tx.as_ref().map(|t| t.clone().downgrade()) { let nfc_pin_tx = self.pin_tx.clone(); let task = tokio::spawn(async move { @@ -802,13 +976,7 @@ pub mod test { } } - impl< - H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - UC: UiController + Debug + Send + Sync, - > Drop for DummyFlowServer - { + impl Drop for DummyFlowServer { fn drop(&mut self) { if let Some(task) = self.usb_event_forwarder_task.lock().unwrap().take() { task.abort(); diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index c3eb35c..77ad4cf 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -1,12 +1,44 @@ //! These methods are called by the flow controller to launch the trusted UI. -use std::error::Error; +use std::{error::Error, future::Future, sync::Arc}; -use zbus::{fdo, proxy, Connection}; +use futures_lite::StreamExt; +use tokio::sync::{ + mpsc::{self, Receiver}, + Mutex as AsyncMutex, +}; +use zbus::{ + fdo, proxy, + zvariant::{ObjectPath, Optional, OwnedObjectPath}, + Connection, +}; -use credentialsd_common::server::{RequestId, ViewRequest}; +use credentialsd_common::{ + model::{BackendRequest, BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId}, + server::{ViewRequest, WindowHandle}, +}; -use crate::credential_service::UiController; +/// Used by the credential service to control the UI. +pub trait UiController { + fn launch_ui( + &self, + request: ViewRequest, + ) -> impl Future>> + Send; + + fn initialize( + &self, + parent_window: Option, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> impl Future>> + Send; +} #[proxy( gen_blocking = false, @@ -19,6 +51,67 @@ trait UiControlService { fn cancel_request(&self, request_id: RequestId) -> fdo::Result<()>; } +#[proxy( + gen_blocking = false, + interface = "org.freedesktop.impl.portal.experimental.Credential", + default_service = "xyz.iinuwa.credentialsd.UiControl", + default_path = "/org/freedesktop/portal/desktop" +)] +trait UiControlService2 { + fn initialize( + &self, + parent_window: Optional, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> fdo::Result; +} + +#[derive(Clone, Debug)] +pub struct Flow { + proxy: Arc>, + ui_events_rx: Arc>>, +} + +impl Flow { + pub async fn receive_ui_event(&self) -> Option { + self.ui_events_rx.lock().await.recv().await + } + + pub async fn send_state_update(&self, event: BackgroundEvent) -> Result<(), ()> { + if let Err(err) = self.proxy.notify_state_changed(event).await { + match err { + fdo::Error::UnknownObject(description) => { + tracing::error!(%description, "Flow D-Bus object no longer available at path"); + } + _ => tracing::error!(%err, "Failed to send update to backend"), + } + return Err(()); + } + Ok(()) + } +} +#[proxy( + gen_blocking = false, + interface = "org.freedesktop.impl.portal.experimental.Credential.FlowObject", + default_service = "xyz.iinuwa.credentialsd.UiControl" +)] +trait FlowObject { + async fn start(&self) -> fdo::Result<()>; + async fn notify_state_changed(&self, event: BackgroundEvent) -> fdo::Result<()>; + + async fn cancel(&self) -> fdo::Result<()>; + + #[zbus(signal)] + async fn user_interacted(&self, update: BackendRequest) -> zbus::Result<()>; +} + #[derive(Debug)] pub struct UiControlServiceClient { conn: Connection, @@ -32,7 +125,23 @@ impl UiControlServiceClient { async fn proxy(&self) -> Result, zbus::Error> { UiControlServiceProxy::new(&self.conn).await } + + async fn proxy2(&self) -> Result, zbus::Error> { + UiControlService2Proxy::new(&self.conn).await + } + + async fn request_proxy( + &self, + request_id: RequestId, + ) -> Result, zbus::Error> { + let object_path = ObjectPath::from_string_unchecked(format!( + "/org/freedesktop/portal/Credential/{}", + request_id + )); + FlowObjectProxy::new(&self.conn, object_path).await + } } + impl UiController for UiControlServiceClient { async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { self.proxy() @@ -41,11 +150,73 @@ impl UiController for UiControlServiceClient { .await .map_err(|err| err.into()) } + + async fn initialize( + &self, + parent_window: Option, + origin: String, + r#type: Operation, + request_id: RequestId, + devices: Vec, + app_id: String, + app_display_name: String, + app_pid: u32, + app_path: String, + options: PortalBackendOptions, + ) -> Result> { + let path = self + .proxy2() + .await? + .initialize( + parent_window.into(), + origin, + r#type, + request_id, + devices, + app_id, + app_display_name, + app_pid, + app_path, + options, + ) + .await?; + tracing::debug!(?path, "Path initialized"); + let flow_object = FlowObjectProxy::new(&self.conn, path).await?; + let (from_ui_tx, from_ui_rx) = mpsc::channel(32); + let ui_event_stream = flow_object.receive_user_interacted().await?; + tokio::task::spawn(async move { + _ = forward_ui_events(ui_event_stream, from_ui_tx).await; + }); + // Mark as ready to receive messages. + flow_object.start().await?; + Ok(Flow { + proxy: Arc::new(flow_object), + ui_events_rx: Arc::new(AsyncMutex::new(from_ui_rx)), + }) + } +} + +async fn forward_ui_events( + mut ui_event_stream: UserInteractedStream, + tx: mpsc::Sender, +) -> Result<(), Box> { + tracing::debug!("Listening for events from UI"); + while let Some(signal) = ui_event_stream.next().await { + tracing::trace!(?signal, "Received event from UI"); + let event = signal.args()?.update; + if let Err(_) = tx.send(event).await { + tracing::trace!("credential service event listener stopped listening for UI events. Ending event stream listener"); + break; + } + } + tracing::trace!("Stopping UI event forwarder"); + Ok(()) } #[cfg(test)] pub mod test { use std::{ + error::Error, fmt::Debug, sync::{ atomic::{AtomicBool, Ordering}, @@ -54,7 +225,9 @@ pub mod test { }; use credentialsd_common::{ - client::FlowController, model::BackgroundEvent, server::ViewRequest, + client::FlowController, + model::{BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId}, + server::{ViewRequest, WindowHandle}, }; use futures_lite::StreamExt; use tokio::sync::{ @@ -62,6 +235,8 @@ pub mod test { Mutex as AsyncMutex, Notify, }; + use crate::dbus::ui_control::Flow; + use super::UiController; #[derive(Debug)] @@ -70,7 +245,7 @@ pub mod test { } impl UiController for DummyUiClient { - async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { + async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { tracing::debug!( target: "DummyUiClient", "Sending launch_ui() request" @@ -82,6 +257,22 @@ pub mod test { ); Ok(()) } + + async fn initialize( + &self, + _parent_window: Option, + _origin: String, + _type: Operation, + _request_id: RequestId, + _devices: Vec, + _app_id: String, + _app_display_name: String, + _app_pid: u32, + _app_path: String, + _options: PortalBackendOptions, + ) -> Result> { + unimplemented!() + } } pub struct DummyUiServer @@ -211,7 +402,7 @@ pub mod test { ); } - async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { + async fn launch_ui(&self, request: ViewRequest) -> Result<(), Box> { tracing::debug!( target: "DummyUiServer", "Received launch_ui() request" diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 94f9846..8652dea 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -292,7 +292,7 @@ impl CredentialPortalGateway { Err(err) => return Err(err).into(), }; - tracing::debug!( + tracing::trace!( ?context, %request_json, ?parent_window, @@ -484,7 +484,10 @@ async fn validate_app_details( }; if claimed_app_id.is_empty() || !super::should_trust_app_id(pid).await { - tracing::warn!("App ID could not be determined. Rejecting request."); + tracing::warn!( + ?claimed_app_id, + "App ID could not be verified. Rejecting request." + ); return Err(Error::SecurityError); } // Now we can trust these app detail parameters. diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index d982909..f01a8b1 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -107,7 +107,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(Some(context.into()), cred_request, parent_window) + .request_credential(context.into(), cred_request, parent_window) .await?; if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { @@ -162,7 +162,7 @@ impl GatewayService { let response = self .request_controller - .request_credential(Some(context.into()), cred_request, parent_window) + .request_credential(context.into(), cred_request, parent_window) .await?; if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { @@ -290,12 +290,14 @@ async fn should_trust_app_id(pid: u32) -> bool { } let Ok(exe_path) = tokio::fs::read_link(format!("/proc/{pid}/exe")).await else { + tracing::warn!("Cannot read executable name from procfs"); return false; }; // The target binaries are hard-coded to valid UTF-8, so it's acceptable to // lose some data here. let Some(exe_path) = exe_path.to_str() else { + tracing::warn!("Could not read executable path from procfs"); return false; }; tracing::debug!(?exe_path, %pid, "Found executable path:"); @@ -305,7 +307,13 @@ async fn should_trust_app_id(pid: u32) -> bool { } else { vec!["/usr/bin/xdg-desktop-portal".to_string()] }; - trusted_callers.as_slice().contains(&exe_path.to_string()) + tracing::debug!(?trusted_callers, %exe_path, "Testing whether request is from trusted caller"); + if !trusted_callers.as_slice().contains(&exe_path.to_string()) { + tracing::warn!(%exe_path, "Request received from untrusted caller"); + return false; + } else { + return true; + } } fn check_origin_from_app( diff --git a/credentialsd/src/gateway/util.rs b/credentialsd/src/gateway/util.rs index 57e1524..a367986 100644 --- a/credentialsd/src/gateway/util.rs +++ b/credentialsd/src/gateway/util.rs @@ -183,7 +183,8 @@ pub(super) fn create_credential_request_try_into_ctap2( .filter_map(|e| e.ok()) .collect() }); - let client_data_json = webauthn::format_client_data_json(Operation::Create, &challenge, origin); + let client_data_json = + webauthn::format_client_data_json(Operation::PublicKeyCreate, &challenge, origin); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); Ok(( MakeCredentialRequest { @@ -283,7 +284,7 @@ pub(super) fn get_credential_request_try_into_ctap2( } let client_data_json = - webauthn::format_client_data_json(Operation::Get, &options.challenge, request_env); + webauthn::format_client_data_json(Operation::PublicKeyGet, &options.challenge, request_env); let client_data_hash = webauthn::create_client_data_hash(&client_data_json); // TODO: actually calculate correct effective domain, and use fallback to related origin requests to fill this in. For now, just default to origin. let user_verification = match options diff --git a/credentialsd/src/main.rs b/credentialsd/src/main.rs index ea920e8..4f0888e 100644 --- a/credentialsd/src/main.rs +++ b/credentialsd/src/main.rs @@ -36,9 +36,8 @@ async fn run() -> Result<(), Box> { let ui_controller = UiControlServiceClient::new(dbus_client_conn); let credential_service = CredentialService::new( InternalHybridHandler::new(), - InProcessUsbHandler {}, InProcessNfcHandler {}, - Arc::new(ui_controller), + InProcessUsbHandler {}, ); let (_flow_control_conn, initiator) = dbus::start_flow_control_service(credential_service).await?; diff --git a/credentialsd/src/webauthn.rs b/credentialsd/src/webauthn.rs index a539e82..2a0ba0e 100644 --- a/credentialsd/src/webauthn.rs +++ b/credentialsd/src/webauthn.rs @@ -680,8 +680,8 @@ pub fn format_client_data_json( origin: &NavigationContext, ) -> String { let op_str = match op { - Operation::Create => "webauthn.create", - Operation::Get => "webauthn.get", + Operation::PublicKeyCreate => "webauthn.create", + Operation::PublicKeyGet => "webauthn.get", }; let mut client_data_json = format!( r#"{{"type":"{}","challenge":"{}","origin":"{}""#, @@ -868,7 +868,7 @@ mod tests { fn test_same_origin_client_data_json_str() { let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":false}"#; let json = format_client_data_json( - Operation::Create, + Operation::PublicKeyCreate, "abcd", &NavigationContext::SameOrigin("https://example.com".parse().unwrap()), ); @@ -879,7 +879,7 @@ mod tests { fn test_cross_origin_client_data_json_str() { let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":true,"topOrigin":"https://example.org"}"#; let json = format_client_data_json( - Operation::Create, + Operation::PublicKeyCreate, "abcd", &NavigationContext::CrossOrigin(( "https://example.com".parse().unwrap(), diff --git a/doc/xyz.iinuwa.credentialsd.FlowControl.xml b/doc/xyz.iinuwa.credentialsd.FlowControl.xml new file mode 100644 index 0000000..1557b2f --- /dev/null +++ b/doc/xyz.iinuwa.credentialsd.FlowControl.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/xyz.iinuwa.credentialsd.UiControl.xml b/doc/xyz.iinuwa.credentialsd.UiControl.xml new file mode 100644 index 0000000..e2357b0 --- /dev/null +++ b/doc/xyz.iinuwa.credentialsd.UiControl.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/meson.build b/meson.build index d9a9913..d075ed4 100644 --- a/meson.build +++ b/meson.build @@ -28,11 +28,16 @@ meson.add_dist_script( meson.project_source_root(), ) +# Libs and executables subdir('credentialsd-common') subdir('credentialsd') subdir('credentialsd-ui') + +# Data files +subdir('doc') subdir('dbus') +subdir('portal') subdir('systemd') subdir('webext') -subdir('doc') -subdir('demo_client') + +subdir('demo_client') \ No newline at end of file diff --git a/portal/credentialsd.portal b/portal/credentialsd.portal new file mode 100644 index 0000000..938145c --- /dev/null +++ b/portal/credentialsd.portal @@ -0,0 +1,3 @@ +[portal] +DBusName=xyz.iinuwa.credentialsd.UiControl +Interfaces=org.freedesktop.impl.portal.experimental.Credential diff --git a/portal/meson.build b/portal/meson.build new file mode 100644 index 0000000..ea237c5 --- /dev/null +++ b/portal/meson.build @@ -0,0 +1,4 @@ +install_data( + 'credentialsd.portal', + install_dir: datadir / 'xdg-desktop-portal/portals/', +) \ No newline at end of file diff --git a/webext/README.md b/webext/README.md index 83be2fb..a03ccb2 100644 --- a/webext/README.md +++ b/webext/README.md @@ -1,8 +1,10 @@ This is a web extension that allows browsers to connect to the D-Bus service provided by this project. It can be used for testing. -Currently, this is written only for Firefox; there will be some slight API -tweaks required to make this work in Chrome. +Both Firefox and Edge/Chromium are supported from a unified codebase in `add-on/` +with browser-specific manifests: +- `manifest.firefox.json` — Firefox (MV3, requires Firefox 140+) +- `manifest.chromium.json` — Edge/Chromium (MV3, requires Chrome 111+ or Edge 111+) This requires some setup to make it work: @@ -48,19 +50,64 @@ couple of options: 4. Navigate to [https://webauthn.io](). 5. Run through the registration and creation process. -## For Development +## For Development (Firefox) (Note: Paths are relative to root of this repository) -1. Copy `webext/app/credential_manager_shim.json` to `~/.mozilla/native-messaging-hosts/credential_manager_shim.json`. +1. Copy `webext/app/credential_manager_shim.json` to `~/.mozilla/native-messaging-hosts/xyz.iinuwa.credentialsd_helper.json`. 2. In `webext/app/credential_manager_shim.py`, point the `DBUS_DOC_FILE` variable to the absolute path to `doc/xyz.iinuwa.credentialsd.Credentials.xml`. 3. In the copied file, replace the `path` key with the absolute path to `webext/app/credential_manager_shim.py` -4. Open Firefox and go to `about:debugging` -5. Click "This Firefox" > Load Temporary Extension. Select `webext/add-on/manifest.json` -6. Build with `ninja -C ./build` and run the following binaries binary to start the D-Bus services. +4. Copy the Firefox manifest into place: + ```shell + cp webext/add-on/manifest.firefox.json webext/add-on/manifest.json + ``` +5. Open Firefox and go to `about:debugging` +6. Click "This Firefox" > Load Temporary Extension. Select `webext/add-on/manifest.json` +7. Build with `ninja -C ./build` and run the D-Bus services: - `GSCHEMA_SCHEMA_DIR=build/credentialsd-ui/data ./build/credentialsd-ui/target/debug/credentialsd-ui` - `./build/credentialsd/target/debug/credentialsd` -7. Navigate to [https://webauthn.io](). -8. Run through the registration and creation process. +8. Navigate to [https://webauthn.io](). +9. Run through the registration and creation process. + +## For Development (Edge/Chromium) + +(Note: Paths are relative to root of this repository) + +1. In `webext/app/credential_manager_shim.py`, point the `DBUS_DOC_FILE` + variable to the absolute path to + `doc/xyz.iinuwa.credentialsd.Credentials.xml`. +2. Copy the Chromium manifest into place (Edge/Chrome require `manifest.json`): + ```shell + cp webext/add-on/manifest.chromium.json webext/add-on/manifest.json + ``` +3. Open Edge and go to `edge://extensions` (or `chrome://extensions` for Chrome). +4. Enable "Developer mode" (toggle in top right). +5. Click "Load unpacked" and select the `webext/add-on/` directory. +6. Note the extension ID shown on the extensions page (e.g., `abcdefghijklmnop...`). +7. Create the native messaging manifest: + ```shell + # For Edge: + mkdir -p ~/.config/microsoft-edge/NativeMessagingHosts + # For Chrome: + # mkdir -p ~/.config/google-chrome/NativeMessagingHosts + # For Chromium: + # mkdir -p ~/.config/chromium/NativeMessagingHosts + + cat > ~/.config/microsoft-edge/NativeMessagingHosts/xyz.iinuwa.credentialsd_helper.json << EOF + { + "name": "xyz.iinuwa.credentialsd_helper", + "description": "Helper for integrating browser with credentialsd project", + "path": "$(readlink -f webext/app/credential_manager_shim.py)", + "type": "stdio", + "allowed_origins": [ "chrome-extension://YOUR_EXTENSION_ID/" ] + } + EOF + ``` + Replace `YOUR_EXTENSION_ID` with the extension ID from step 6. +8. Build with `ninja -C ./build` and run the D-Bus services: + - `GSCHEMA_SCHEMA_DIR=build/credentialsd-ui/data ./build/credentialsd-ui/target/debug/credentialsd-ui` + - `./build/credentialsd/target/debug/credentialsd` +9. Navigate to [https://webauthn.io](). +10. Run through the registration and creation process. diff --git a/webext/add-on/background.js b/webext/add-on/background.js index 4d15342..053f03a 100644 --- a/webext/add-on/background.js +++ b/webext/add-on/background.js @@ -1,125 +1,63 @@ -/* -On startup, connect to the "credential_shim" app. -*/ +/** + * Background script that bridges content script messages + * to the native messaging host. + * + * Works in both Firefox (background script) and Chromium (service worker). + * ArrayBuffer serialization is handled by the MAIN world content script, + * so this script simply forwards messages between content and native. + */ + +const browserAPI = globalThis.browser || globalThis.chrome; + let contentPort; let nativePort; function connected(port) { - console.log("received connection from content script"); - - // initialize content port + console.log('[credentialsd] received connection from content script'); contentPort = port; - console.log(contentPort); - // Initialize native port - nativePort = browser.runtime.connectNative("xyz.iinuwa.credentialsd_helper"); - console.debug(nativePort); - if (nativePort.error !== null) { - console.error(nativePort.error) - throw nativePort.error + // Connect to native messaging host + nativePort = browserAPI.runtime.connectNative('xyz.iinuwa.credentialsd_helper'); + + // Check for connection errors (browser-specific patterns) + const connectError = nativePort.error || browserAPI.runtime.lastError; + if (connectError) { + console.error('[credentialsd] native connect error:', connectError.message || connectError); + return; } - console.log(`connected to native app`) - console.log(nativePort) - // Set up content port listener - contentPort.onMessage.addListener(rcvFromContent) + console.log('[credentialsd] connected to native app'); - // Set up native port listener - console.log("setting up native port response listener") - nativePort.onMessage.addListener(rcvFromNative); + contentPort.onMessage.addListener(rcvFromContent); + nativePort.onMessage.addListener(rcvFromNative); + nativePort.onDisconnect.addListener(() => { + const error = browserAPI.runtime.lastError; + if (error) { + console.error('[credentialsd] native port disconnected:', error.message); + } + }); } function rcvFromContent(msg) { const { requestId, cmd, options } = msg; - const origin = contentPort.sender.origin - const topOrigin = new URL(contentPort.sender.tab.url).origin - // const isCrossOrigin = origin === topOrigin - // const isTopLevel = contentPort.sender.frameId === 0; + const origin = contentPort.sender.origin; + const topOrigin = new URL(contentPort.sender.tab.url).origin; if (options) { - const serializedOptions = serializeRequest(options) - - console.debug(options.publicKey.challenge) - console.debug("background script received options, passing onto native app") - nativePort.postMessage({ requestId, cmd, options: serializedOptions, origin, topOrigin }) + console.debug('[credentialsd] forwarding', cmd, 'to native app'); + nativePort.postMessage({ requestId, cmd, options, origin, topOrigin }); } else { - console.debug("background script received message without arguments, passing onto native app") - nativePort.postMessage({ requestId, cmd, origin, topOrigin }) + console.debug('[credentialsd] forwarding', cmd, '(no options) to native app'); + nativePort.postMessage({ requestId, cmd, origin, topOrigin }); } } function rcvFromNative(msg) { - console.log("Received (native -> background): " + msg); - console.log("forwarding to content script"); - const { requestId, data, error } = msg; + console.log('[credentialsd] received from native, forwarding to content'); contentPort.postMessage(msg); } -function serializeBytes(buffer) { - const options = {alphabet: "base64url", omitPadding: true}; - return new Uint8Array(buffer).toBase64(options) -} - -function deserializeBytes(base64str) { - const options = {alphabet: "base64url"} - return Uint8Array.fromBase64(base64str, options) -} - -function serializeRequest(options) { - // Serialize ArrayBuffers - const clone = structuredClone(options) - clone.publicKey.challenge = serializeBytes(clone.publicKey.challenge) - if (clone.publicKey.user) { - clone.publicKey.user.id = serializeBytes(clone.publicKey.user.id) - } - if (clone.publicKey.excludeCredentials) { - for (const cred of clone.publicKey.excludeCredentials) { - cred.id = serializeBytes(cred.id) - } - } - if (clone.publicKey.allowCredentials) { - for (const cred of clone.publicKey.allowCredentials) { - cred.id = serializeBytes(cred.id); - } - } - if (clone.publicKey.extensions && clone.publicKey.extensions.prf) { - if (clone.publicKey.extensions.prf.eval) { - clone.publicKey.extensions.prf.eval.first = serializeBytes(clone.publicKey.extensions.prf.eval.first); - if (clone.publicKey.extensions.prf.eval.second) { - clone.publicKey.extensions.prf.eval.second = serializeBytes(clone.publicKey.extensions.prf.eval.second); - } - } - if (clone.publicKey.extensions.prf.evalByCredential) { - const evalByCredential = clone.publicKey.extensions.prf.evalByCredential; - - // Iterate over all credentialIDs, serialize the first/second bytebuffer and replace the original evalByCredential map - const result = {}; - for (const credId in evalByCredentialData) { - const prfValue = evalByCredentialData[credId]; - - if (prfValue && prfValue.first) { - const newPrfValue = { - first: serializeBytes(prfValue.first) - }; - - if (prfValue.second) { - newPrfValue.second = serializeBytes(prfValue.second); - } - result[credId] = newPrfValue; - }; - } - clone.publicKey.extensions.prf.evalByCredential = result; - } - - if (clone.publicKey.extensions && clone.publicKey.extensions.credBlob) { - clone.publicKey.extensions.credBlob = serializeBytes(clone.publicKey.extensions.credBlob); - } - } - return clone -} - - // Listen for connections from content script -console.log("Starting up credential_manager_shim background script") -browser.runtime.onConnect.addListener(connected); +console.log('[credentialsd] background script starting'); +browserAPI.runtime.onConnect.addListener(connected); diff --git a/webext/add-on/content-bridge.js b/webext/add-on/content-bridge.js new file mode 100644 index 0000000..d17bada --- /dev/null +++ b/webext/add-on/content-bridge.js @@ -0,0 +1,36 @@ +/** + * Content script running in ISOLATED world. + * Bridges window.postMessage from the MAIN world content script + * to the background script via runtime.connect. + * + * Works in both Firefox and Chromium browsers. + */ + +const browserAPI = globalThis.browser || globalThis.chrome; +const port = browserAPI.runtime.connect({ name: 'credentialsd-helper' }); + +// Forward responses from background back to page context +port.onMessage.addListener((msg) => { + const { requestId, data, error } = msg; + window.postMessage({ + type: 'credentialsd-response', + requestId, + data, + error, + }, '*'); +}); + +port.onDisconnect.addListener(() => { + console.warn('[credentialsd] background port disconnected'); +}); + +// Listen for requests from the MAIN world content script +window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.type !== 'credentialsd-request') return; + + const { requestId, cmd, options } = event.data; + port.postMessage({ requestId, cmd, options }); +}); + +console.log('[credentialsd] content bridge active'); diff --git a/webext/add-on/content-main.js b/webext/add-on/content-main.js new file mode 100644 index 0000000..d6ac674 --- /dev/null +++ b/webext/add-on/content-main.js @@ -0,0 +1,235 @@ +/** + * Content script running in MAIN world (page context). + * Overrides navigator.credentials.create/get and communicates + * with the ISOLATED world bridge script via window.postMessage. + * + * Works in both Firefox and Chromium browsers. + */ + +let requestCounter = 0; +const pendingRequests = {}; + +const b64urlEncodeOpts = { alphabet: "base64url", omitPadding: true }; +const b64urlDecodeOpts = { alphabet: "base64url" }; + +// Listen for responses from the bridge script +window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.type !== 'credentialsd-response') return; + + const { requestId, data, error } = event.data; + const request = pendingRequests[requestId]; + if (!request) return; + delete pendingRequests[requestId]; + + if (error) { + request.reject(new DOMException(error.message || 'WebAuthn operation failed', error.name || 'NotAllowedError')); + } else { + request.resolve(data); + } +}); + +function startRequest() { + const requestId = requestCounter++; + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + pendingRequests[requestId] = { resolve, reject }; + return { requestId, promise }; +} + +function serializePublicKeyOptions(options) { + return JSON.parse(JSON.stringify(options, (key, value) => { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value).toBase64(b64urlEncodeOpts); + } + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).toBase64(b64urlEncodeOpts); + } + return value; + })); +} + +function base64urlToArrayBuffer(str) { + if (!str) return null; + return Uint8Array.fromBase64(str, b64urlDecodeOpts).buffer; +} + +function reconstructCredentialResponse(credential) { + const obj = {}; + obj.id = credential.id; + obj.rawId = base64urlToArrayBuffer(credential.rawId); + obj.authenticatorAttachment = credential.authenticatorAttachment; + const response = {}; + + // Registration response + if (credential.response.attestationObject) { + response.clientDataJSON = base64urlToArrayBuffer(credential.response.clientDataJSON); + response.attestationObject = base64urlToArrayBuffer(credential.response.attestationObject); + response.transports = credential.response.transports ? [...credential.response.transports] : []; + response.authenticatorData = base64urlToArrayBuffer(credential.response.authenticatorData); + response.getAuthenticatorData = function() { return this.authenticatorData; }; + response.getPublicKeyAlgorithm = function() { return credential.response.publicKeyAlgorithm; }; + if (credential.response.publicKey) { + response.publicKey = base64urlToArrayBuffer(credential.response.publicKey); + } + response.getPublicKey = function() { return this.publicKey || null; }; + response.getTransports = function() { return this.transports; }; + + if (typeof AuthenticatorAttestationResponse !== 'undefined') { + Object.setPrototypeOf(response, AuthenticatorAttestationResponse.prototype); + } + } + // Assertion response + else if (credential.response.signature) { + response.clientDataJSON = base64urlToArrayBuffer(credential.response.clientDataJSON); + response.authenticatorData = base64urlToArrayBuffer(credential.response.authenticatorData); + response.signature = base64urlToArrayBuffer(credential.response.signature); + response.userHandle = credential.response.userHandle + ? base64urlToArrayBuffer(credential.response.userHandle) + : null; + + if (typeof AuthenticatorAssertionResponse !== 'undefined') { + Object.setPrototypeOf(response, AuthenticatorAssertionResponse.prototype); + } + } else { + throw new Error('Unknown credential response type received'); + } + + // Client extension results + const extensions = {}; + if (credential.clientExtensionResults) { + if (credential.clientExtensionResults.hmacGetSecret) { + extensions.hmacGetSecret = {}; + extensions.hmacGetSecret.output1 = base64urlToArrayBuffer(credential.clientExtensionResults.hmacGetSecret.output1); + if (credential.clientExtensionResults.hmacGetSecret.output2) { + extensions.hmacGetSecret.output2 = base64urlToArrayBuffer(credential.clientExtensionResults.hmacGetSecret.output2); + } + } + if (credential.clientExtensionResults.prf) { + extensions.prf = {}; + if (credential.clientExtensionResults.prf.results) { + extensions.prf.results = {}; + extensions.prf.results.first = base64urlToArrayBuffer(credential.clientExtensionResults.prf.results.first); + if (credential.clientExtensionResults.prf.results.second) { + extensions.prf.results.second = base64urlToArrayBuffer(credential.clientExtensionResults.prf.results.second); + } + } + if (credential.clientExtensionResults.prf.enabled !== undefined) { + extensions.prf.enabled = credential.clientExtensionResults.prf.enabled; + } + } + if (credential.clientExtensionResults.largeBlob) { + extensions.largeBlob = {}; + if (credential.clientExtensionResults.largeBlob.blob) { + extensions.largeBlob.blob = base64urlToArrayBuffer(credential.clientExtensionResults.largeBlob.blob); + } + } + if (credential.clientExtensionResults.credProps) { + extensions.credProps = credential.clientExtensionResults.credProps; + } + } + + obj.response = response; + obj.clientExtensionResults = extensions; + obj.getClientExtensionResults = function() { return this.clientExtensionResults; }; + obj.type = 'public-key'; + + obj.toJSON = function() { + const json = {}; + json.id = this.id; + json.rawId = this.id; + json.response = {}; + if (credential.response.attestationObject) { + json.response.clientDataJSON = credential.response.clientDataJSON; + json.response.authenticatorData = credential.response.authenticatorData; + json.response.transports = this.response.transports; + json.response.publicKey = credential.response.publicKey; + json.response.publicKeyAlgorithm = credential.response.publicKeyAlgorithm; + json.response.attestationObject = credential.response.attestationObject; + } else if (credential.response.signature) { + json.response.clientDataJSON = credential.response.clientDataJSON; + json.response.authenticatorData = credential.response.authenticatorData; + json.response.signature = credential.response.signature; + json.response.userHandle = credential.response.userHandle; + } + json.authenticatorAttachment = this.authenticatorAttachment; + json.clientExtensionResults = this.clientExtensionResults; + json.type = this.type; + return json; + }; + + if (typeof PublicKeyCredential !== 'undefined') { + Object.setPrototypeOf(obj, PublicKeyCredential.prototype); + } + + return obj; +} + +// Override navigator.credentials +if (navigator.credentials) { + const originalCreate = navigator.credentials.create?.bind(navigator.credentials); + const originalGet = navigator.credentials.get?.bind(navigator.credentials); + + navigator.credentials.create = function(options) { + if (!options || !options.publicKey) { + if (originalCreate) return originalCreate(options); + return Promise.reject(new DOMException('Not supported', 'NotSupportedError')); + } + + console.log('[credentialsd] intercepting navigator.credentials.create'); + const { signal, ...rest } = options; + const { requestId, promise } = startRequest(); + const serialized = serializePublicKeyOptions(rest); + + window.postMessage({ + type: 'credentialsd-request', + requestId, + cmd: 'create', + options: serialized, + }, '*'); + + return promise.then(reconstructCredentialResponse); + }; + + navigator.credentials.get = function(options) { + if (!options || !options.publicKey) { + if (originalGet) return originalGet(options); + return Promise.reject(new DOMException('Not supported', 'NotSupportedError')); + } + + console.log('[credentialsd] intercepting navigator.credentials.get'); + const { signal, ...rest } = options; + const { requestId, promise } = startRequest(); + const serialized = serializePublicKeyOptions(rest); + + window.postMessage({ + type: 'credentialsd-request', + requestId, + cmd: 'get', + options: serialized, + }, '*'); + + return promise.then(reconstructCredentialResponse); + }; +} + +if (typeof PublicKeyCredential !== 'undefined') { + PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = async function() { + return true; + }; + + PublicKeyCredential.getClientCapabilities = function() { + console.log('[credentialsd] intercepting PublicKeyCredential.getClientCapabilities'); + const { requestId, promise } = startRequest(); + + window.postMessage({ + type: 'credentialsd-request', + requestId, + cmd: 'getClientCapabilities', + }, '*'); + + return promise; + }; +} + +console.log('[credentialsd] WebAuthn credential override active'); diff --git a/webext/add-on/content.js b/webext/add-on/content.js deleted file mode 100644 index b150b78..0000000 --- a/webext/add-on/content.js +++ /dev/null @@ -1,203 +0,0 @@ -let requestCounter = 0; -const pendingRequests = {} -var webauthnPort = browser.runtime.connect({ name: "credentialsd-helper" }); -console.log("loading content") - -webauthnPort.onMessage.addListener(({ requestId, data, error }) => { - console.log('received message from background script:') - console.log(data); - endRequest(requestId, data, error); -}); - -console.log("overriding navigator.credentials in content script"); -exportFunction(createCredential, navigator.credentials, { defineAs: "create"}) -exportFunction(getCredential, navigator.credentials, { defineAs: "get"}) - - -if (window.PublicKeyCredential) { - console.log("overriding PublicKeyCredential.getClientCapabilities() in content script"); - exportFunction(getClientCapabilities, PublicKeyCredential, { defineAs: "getClientCapabilities"}) -} - -function startRequest() { - const requestId = requestCounter++; - const {promise, resolve, reject } = window.Promise.withResolvers(); - pendingRequests[requestId] = { resolve, reject } - return { requestId, promise } -} - -function endRequest(requestId, data, error) { - const request = pendingRequests[requestId] - if (error) { - request.reject(error) - } else { - request.resolve(data) - } -} - -async function cloneCredentialResponse(credential) { - try { - const options = { alphabet: "base64url" } - const obj = {} - obj.id = credential.id; - obj.rawId = cloneInto(Uint8Array.fromBase64(credential.rawId, options), obj) - obj.authenticatorAttachment = credential.authenticatorAttachment; - const response = {} - // credential registration response - if (credential.response.attestationObject) { - const clientDataJSON = credential.response.clientDataJSON - response.clientDataJSON = Uint8Array.fromBase64(clientDataJSON, options) - const attestationObject = credential.response.attestationObject - response.attestationObject = Uint8Array.fromBase64(attestationObject, options) - response.transports = [...credential.response.transports] - const authenticatorData = Uint8Array.fromBase64(credential.response.authenticatorData, options) - response.authenticatorData = cloneInto(authenticatorData, response) - response.getAuthenticatorData = function() { - return this.authenticatorData - } - response.getPublicKeyAlgorithm = function() { - const publicKeyAlgorithm = credential.response.publicKeyAlgorithm - return publicKeyAlgorithm - } - const publicKey = Uint8Array.fromBase64(credential.response.publicKey, options) - response.publicKey = cloneInto(publicKey, response) - response.getPublicKey = function() { - return this.publicKey - } - response.getTransports = function() { - return this.transports - } - - } - // credential attestation response - else if (credential.response.signature) { - const clientDataJSON = credential.response.clientDataJSON - response.clientDataJSON = Uint8Array.fromBase64(clientDataJSON, options) - const authenticatorData = Uint8Array.fromBase64(credential.response.authenticatorData, options) - response.authenticatorData = cloneInto(authenticatorData, response) - const signature = Uint8Array.fromBase64(credential.response.signature, options) - response.signature = cloneInto(signature, response) - if (credential.response.userHandle) { - const userHandle = Uint8Array.fromBase64(credential.response.userHandle, options) - response.userHandle = cloneInto(userHandle, response) - } - else { - response.userHandle = null - } - } - else { - throw cloneInto(new Error("Unknown credential response type received"), window) - } - - // Unlike CreatePublicKey, for GetPublicKey, we have a lot of Byte arrays, - // so we need a lot of deconstructions. So no: obj.clientExtensionResults = cloneInto(credential.clientExtensionResults, obj); - const extensions = {} - if (credential.clientExtensionResults) { - if (credential.clientExtensionResults.hmacGetSecret) { - extensions.hmacGetSecret = {} - extensions.hmacGetSecret.output1 = Uint8Array.fromBase64(credential.clientExtensionResults.hmacGetSecret.output1, options); - if (credential.clientExtensionResults.hmacGetSecret.output2) { - extensions.hmacGetSecret.output2 = Uint8Array.fromBase64(credential.clientExtensionResults.hmacGetSecret.output2, options); - } - } - - if (credential.clientExtensionResults.prf) { - extensions.prf = {} - if (credential.clientExtensionResults.prf.results) { - extensions.prf.results = {} - extensions.prf.results.first = Uint8Array.fromBase64(credential.clientExtensionResults.prf.results.first, options); - if (credential.clientExtensionResults.prf.results.second) { - extensions.prf.results.second = Uint8Array.fromBase64(credential.clientExtensionResults.prf.results.second, options); - } - } - if (credential.clientExtensionResults.prf.enabled) { - extensions.prf.enabled = cloneInto(credential.clientExtensionResults.prf.enabled, extensions.prf) - } - } - - if (credential.clientExtensionResults.largeBlob) { - extensions.largeBlob = {} - if (credential.clientExtensionResults.largeBlob.blob) { - extensions.largeBlob.blob = Uint8Array.fromBase64(credential.clientExtensionResults.largeBlob.blob, options); - } - } - - if (credential.clientExtensionResults.credProps) { - extensions.credProps = cloneInto(credential.clientExtensionResults.credProps, extensions) - } - } - obj.response = cloneInto(response, obj, { cloneFunctions: true }) - obj.clientExtensionResults = extensions; - obj.getClientExtensionResults = function() { - return this.clientExtensionResults; - } - obj.type = "public-key" - - obj.toJSON = function() { - json = new window.Object(); - json.id = this.id - json.rawId = this.id - - json.response = new window.Object() - // credential registration response - if (credential.response.attestationObject) { - json.response.clientDataJSON = credential.response.clientDataJSON - json.response.authenticatorData = credential.response.authenticatorData - json.response.transports = this.transports - json.response.publicKey = credential.response.publicKey - json.response.publicKeyAlgorithm = credential.response.publicKeyAlgorithm - json.response.attestationObject = credential.response.attestationObject - } - // credential attestation response - else if (credential.response.signature) { - json.response.clientDataJSON = credential.response.clientDataJSON - json.response.authenticatorData = credential.response.authenticatorData - json.response.signature = credential.response.signature - json.response.userHandle = credential.response.userHandle - } - else { - throw cloneInto(new Error("Unknown credential type received"), window) - } - - json.authenticatorAttachment = this.authenticatorAttachment; - json.clientExtensionResults = this.clientExtensionResults; - json.type = this.type - return json - } - return cloneInto(obj, window, { cloneFunctions: true }) - } - catch (error) { - console.error(error) - throw cloneInto(error, window) - } -} - -function createCredential(request) { - console.log("forwarding create call from content script to background script") - console.log(webauthnPort) - console.log(request) - - // the signal object can't be sent to background script, so omit it - const { signal, ...options} = request - - const { requestId, promise } = startRequest(); - webauthnPort.postMessage({ requestId, cmd: 'create', options, }) - return promise.then(cloneCredentialResponse) -} - -function getCredential(request) { - console.log("forwarding get call from content script to background script") - // the signal object can't be sent to background script, so omit it - const { /** @type {AbortSignal} */signal, ...options} = request - - const { requestId, promise } = startRequest(); - webauthnPort.postMessage({ requestId, cmd: 'get', options, }) - return promise.then(cloneCredentialResponse) -}; - -function getClientCapabilities() { - console.log("forwarding getClientCapabilities call from content script to background script") - const { requestId, promise } = startRequest(); - webauthnPort.postMessage({ requestId, cmd: 'getClientCapabilities', }) - return promise.then((capabilities) => cloneInto(capabilities, window)) -}; diff --git a/webext/add-on/manifest.chromium.json b/webext/add-on/manifest.chromium.json new file mode 100644 index 0000000..6019d9b --- /dev/null +++ b/webext/add-on/manifest.chromium.json @@ -0,0 +1,34 @@ +{ + "description": "Helper to integrate credentialsd with the browser", + "manifest_version": 3, + "name": "credentialsd-helper", + "version": "0.1.0", + "icons": { + "48": "icons/logo.svg" + }, + + "background": { + "service_worker": "background.js" + }, + + "content_scripts": [ + { + "matches": [""], + "js": ["content-bridge.js"], + "run_at": "document_start", + "world": "ISOLATED" + }, + { + "matches": [""], + "js": ["content-main.js"], + "run_at": "document_start", + "world": "MAIN" + } + ], + + "action": { + "default_icon": "icons/logo.svg" + }, + + "permissions": ["nativeMessaging"] +} diff --git a/webext/add-on/manifest.json b/webext/add-on/manifest.firefox.json similarity index 56% rename from webext/add-on/manifest.json rename to webext/add-on/manifest.firefox.json index f2e6635..b700d9f 100644 --- a/webext/add-on/manifest.json +++ b/webext/add-on/manifest.firefox.json @@ -15,13 +15,20 @@ }, "background": { - "scripts": ["background.js"] + "service_worker": "background.js" }, "content_scripts": [ { - "matches": ["https://webauthn.io/*", "https://demo.yubico.com/*"], - "js": ["content.js"], - "run_at": "document_start" + "matches": ["https://webauthn.io/*", "https://demo.yubico.com/*"], + "js": ["content-bridge.js"], + "run_at": "document_start", + "world": "ISOLATED" + }, + { + "matches": ["https://webauthn.io/*", "https://demo.yubico.com/*"], + "js": ["content-main.js"], + "run_at": "document_start", + "world": "MAIN" } ], diff --git a/webext/add-on/meson.build b/webext/add-on/meson.build index 1520a14..3849cf9 100644 --- a/webext/add-on/meson.build +++ b/webext/add-on/meson.build @@ -1,23 +1,61 @@ zip = find_program('zip') addon_dir = datadir / 'credentialsd' -xpi_files = ['manifest.json', 'background.js', 'content.js', 'icons' / 'logo.svg'] + +# Shared files used by both Firefox and Chromium builds +shared_addon_files = ['icons' / 'logo.svg', 'background.js', 'content-bridge.js', 'content-main.js'] + +# Firefox XPI +firefox_addon_files = ['manifest.firefox.json'] + shared_addon_files custom_target( 'xpi', output: 'credentialsd-firefox-helper.xpi', - input: xpi_files, + input: firefox_addon_files, + command: [ + 'cp', '-r', '@INPUT@', '@PRIVATE_DIR@', + '&&', + 'cd', + '@PRIVATE_DIR@', + '&&', + 'mv', '@PLAINNAME0@', 'manifest.json', + '&&', + 'mkdir', '-p', 'icons', + '&&', + 'mv', '@PLAINNAME1@', 'icons/', + '&&', + zip, + '-r', + '-FS', meson.project_build_root() / '@OUTPUT@', + '.', + ], + install: true, + install_dir: addon_dir, +) + +# Chromium extension (unsigned) +# TODO: Wrap this in a self-signed CRX in CI +chromium_addon_files = ['manifest.chromium.json'] + shared_addon_files +custom_target( + 'chromium-extension', + output: 'credentialsd-chromium-helper.zip', + input: chromium_addon_files, command: [ - 'pwd', + 'cp', '-r', '@INPUT@', '@PRIVATE_DIR@', '&&', 'cd', - '@CURRENT_SOURCE_DIR@', + '@PRIVATE_DIR@', + '&&', + 'mv', '@PLAINNAME0@', 'manifest.json', + '&&', + 'mkdir', '-p', 'icons', + '&&', + 'mv', '@PLAINNAME1@', 'icons/', '&&', zip, '-r', '-FS', meson.project_build_root() / '@OUTPUT@', - xpi_files, - '--exclude', 'icons/LICENSE', + '.', ], install: true, install_dir: addon_dir, -) \ No newline at end of file +) diff --git a/webext/app/credential_manager_shim_edge.json.in b/webext/app/credential_manager_shim_edge.json.in new file mode 100644 index 0000000..dbf7e06 --- /dev/null +++ b/webext/app/credential_manager_shim_edge.json.in @@ -0,0 +1,7 @@ +{ + "name": "xyz.iinuwa.credentialsd_helper", + "description": "Helper for integrating browser with credentialsd project", + "path": "@SHIM_SCRIPT@", + "type": "stdio", + "allowed_origins": [ "chrome-extension://@EXTENSION_ID@/" ] +}