From f08c72f01db16bfc3e23a1e2b59bcacc43c24b67 Mon Sep 17 00:00:00 2001 From: phelix001 Date: Mon, 16 Feb 2026 16:41:44 -0500 Subject: [PATCH 01/33] feat: add Edge/Chromium web extension port Port the Firefox web extension to Edge/Chromium (MV3, Chrome 111+). Key architectural differences from Firefox version: - Two content scripts: MAIN world (overrides navigator.credentials) and ISOLATED world (bridges to background via chrome.runtime) - window.postMessage bridge between MAIN and ISOLATED worlds (Firefox uses exportFunction/cloneInto which don't exist in Chromium) - Base64url encoding via btoa/atob helpers instead of Uint8Array.toBase64/fromBase64 (not available in Chromium) - Service worker background script instead of persistent background page - chrome.* namespace instead of browser.* New files: - webext/add-on-edge/ - Complete Edge/Chromium extension - webext/app/credential_manager_shim_edge.json.in - Native messaging manifest template for Chromium-based browsers Updated README with Edge/Chromium setup instructions. Co-Authored-By: Claude Opus 4.6 --- webext/README.md | 46 +++- webext/add-on-edge/background.js | 107 ++++++++ webext/add-on-edge/content-bridge.js | 33 +++ webext/add-on-edge/content-main.js | 249 ++++++++++++++++++ webext/add-on-edge/icons/logo.svg | 71 +++++ webext/add-on-edge/manifest.json | 34 +++ .../app/credential_manager_shim_edge.json.in | 7 + 7 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 webext/add-on-edge/background.js create mode 100644 webext/add-on-edge/content-bridge.js create mode 100644 webext/add-on-edge/content-main.js create mode 100644 webext/add-on-edge/icons/logo.svg create mode 100644 webext/add-on-edge/manifest.json create mode 100644 webext/app/credential_manager_shim_edge.json.in diff --git a/webext/README.md b/webext/README.md index 83be2fb..5d8de1a 100644 --- a/webext/README.md +++ b/webext/README.md @@ -1,8 +1,9 @@ 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. +Two variants are provided: +- `add-on/` - Firefox (MV3, requires Firefox 140+) +- `add-on-edge/` - Edge/Chromium (MV3, requires Chrome 111+ or Edge 111+) This requires some setup to make it work: @@ -48,11 +49,11 @@ 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`. @@ -64,3 +65,40 @@ couple of options: - `./build/credentialsd/target/debug/credentialsd` 7. Navigate to [https://webauthn.io](). 8. 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. Open Edge and go to `edge://extensions` (or `chrome://extensions` for Chrome). +3. Enable "Developer mode" (toggle in top right). +4. Click "Load unpacked" and select the `webext/add-on-edge/` directory. +5. Note the extension ID shown on the extensions page (e.g., `abcdefghijklmnop...`). +6. 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 5. +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` +8. Navigate to [https://webauthn.io](). +9. Run through the registration and creation process. diff --git a/webext/add-on-edge/background.js b/webext/add-on-edge/background.js new file mode 100644 index 0000000..44bd596 --- /dev/null +++ b/webext/add-on-edge/background.js @@ -0,0 +1,107 @@ +/** + * Background service worker for Edge/Chromium. + * Bridges content script messages to the native messaging host. + */ + +let contentPort; +let nativePort; + +function arrayBufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function base64urlToBytes(str) { + if (!str) return null; + const padded = str.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function connected(port) { + console.log('[credentialsd] received connection from content script'); + contentPort = port; + + // Connect to native messaging host + nativePort = chrome.runtime.connectNative('xyz.iinuwa.credentialsd_helper'); + if (chrome.runtime.lastError) { + console.error('[credentialsd] native connect error:', chrome.runtime.lastError.message); + return; + } + console.log('[credentialsd] connected to native app'); + + contentPort.onMessage.addListener(rcvFromContent); + nativePort.onMessage.addListener(rcvFromNative); + + nativePort.onDisconnect.addListener(() => { + if (chrome.runtime.lastError) { + console.error('[credentialsd] native port disconnected:', chrome.runtime.lastError.message); + } + }); +} + +function rcvFromContent(msg) { + const { requestId, cmd, options } = msg; + const origin = contentPort.sender.origin; + const topOrigin = new URL(contentPort.sender.tab.url).origin; + + if (options) { + const serializedOptions = serializeRequest(options); + console.debug('[credentialsd] forwarding', cmd, 'to native app'); + nativePort.postMessage({ requestId, cmd, options: serializedOptions, origin, topOrigin }); + } else { + console.debug('[credentialsd] forwarding', cmd, '(no options) to native app'); + nativePort.postMessage({ requestId, cmd, origin, topOrigin }); + } +} + +function rcvFromNative(msg) { + console.log('[credentialsd] received from native, forwarding to content'); + contentPort.postMessage(msg); +} + +function serializeBytes(buffer) { + if (buffer && buffer.__b64url__) { + // Already base64url-encoded by the MAIN world script + return buffer.__b64url__; + } + if (buffer instanceof ArrayBuffer || ArrayBuffer.isView(buffer)) { + return arrayBufferToBase64url(buffer); + } + if (typeof buffer === 'string') { + return buffer; + } + return buffer; +} + +function serializeRequest(options) { + const clone = JSON.parse(JSON.stringify(options)); + + // The MAIN world script serialized ArrayBuffers as { __b64url__: "..." } + // Unwrap these for the native host + function unwrapB64url(obj) { + if (obj === null || obj === undefined) return obj; + if (typeof obj !== 'object') return obj; + if (obj.__b64url__) return obj.__b64url__; + if (Array.isArray(obj)) return obj.map(unwrapB64url); + const result = {}; + for (const key of Object.keys(obj)) { + result[key] = unwrapB64url(obj[key]); + } + return result; + } + + return unwrapB64url(clone); +} + +// Listen for connections from content script +console.log('[credentialsd] background service worker starting (Edge/Chromium)'); +chrome.runtime.onConnect.addListener(connected); diff --git a/webext/add-on-edge/content-bridge.js b/webext/add-on-edge/content-bridge.js new file mode 100644 index 0000000..0f4149d --- /dev/null +++ b/webext/add-on-edge/content-bridge.js @@ -0,0 +1,33 @@ +/** + * Content script running in ISOLATED world. + * Bridges window.postMessage from the MAIN world content script + * to the background service worker via chrome.runtime.connect. + */ + +const port = chrome.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 (Edge/Chromium)'); diff --git a/webext/add-on-edge/content-main.js b/webext/add-on-edge/content-main.js new file mode 100644 index 0000000..2e0b379 --- /dev/null +++ b/webext/add-on-edge/content-main.js @@ -0,0 +1,249 @@ +/** + * Content script running in MAIN world (page context). + * Overrides navigator.credentials.create/get and communicates + * with the ISOLATED world bridge script via window.postMessage. + */ + +let requestCounter = 0; +const pendingRequests = {}; + +// Base64url helpers (Chromium doesn't have Uint8Array.toBase64/fromBase64) +function arrayBufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function base64urlToArrayBuffer(str) { + if (!str) return null; + const padded = str.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +// 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) { + const clone = JSON.parse(JSON.stringify(options, (key, value) => { + if (value instanceof ArrayBuffer) { + return { __b64url__: arrayBufferToBase64url(value) }; + } + if (ArrayBuffer.isView(value)) { + return { __b64url__: arrayBufferToBase64url(value.buffer) }; + } + return value; + })); + return clone; +} + +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] : []; + const authenticatorData = base64urlToArrayBuffer(credential.response.authenticatorData); + response.authenticatorData = 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; + }; + + const origGetClientCapabilities = PublicKeyCredential.getClientCapabilities; + 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 (Edge/Chromium)'); diff --git a/webext/add-on-edge/icons/logo.svg b/webext/add-on-edge/icons/logo.svg new file mode 100644 index 0000000..a7695f4 --- /dev/null +++ b/webext/add-on-edge/icons/logo.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + diff --git a/webext/add-on-edge/manifest.json b/webext/add-on-edge/manifest.json new file mode 100644 index 0000000..6019d9b --- /dev/null +++ b/webext/add-on-edge/manifest.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/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@/" ] +} From c40bce0b1717a67f016264e9d86b0e958755e62b Mon Sep 17 00:00:00 2001 From: phelix001 Date: Fri, 20 Feb 2026 05:42:30 -0500 Subject: [PATCH 02/33] refactor: merge Firefox and Chromium add-ons into unified folder Address PR review feedback to eliminate code duplication between webext/add-on/ (Firefox) and webext/add-on-edge/ (Chromium). Key changes: - Unified architecture: both browsers now use MAIN + ISOLATED world content scripts with window.postMessage bridge, eliminating the need for Firefox-specific cloneInto()/exportFunction() APIs - Use native Uint8Array.toBase64()/fromBase64() for base64url encoding/decoding (supported in both Firefox 140+ and Chrome 111+) - Simplified background.js: ArrayBuffer serialization now happens in content-main.js, so background just forwards messages - Browser-specific manifests: manifest.firefox.json (background scripts) and manifest.chromium.json (service worker) - Browser API detection via globalThis.browser || globalThis.chrome in content-bridge.js and background.js Co-Authored-By: Claude Opus 4.6 --- webext/add-on-edge/background.js | 107 --------- webext/add-on-edge/icons/logo.svg | 71 ------ webext/add-on/background.js | 138 ++++-------- .../{add-on-edge => add-on}/content-bridge.js | 9 +- .../{add-on-edge => add-on}/content-main.js | 42 ++-- webext/add-on/content.js | 203 ------------------ .../manifest.chromium.json} | 0 .../{manifest.json => manifest.firefox.json} | 13 +- webext/add-on/meson.build | 15 +- 9 files changed, 79 insertions(+), 519 deletions(-) delete mode 100644 webext/add-on-edge/background.js delete mode 100644 webext/add-on-edge/icons/logo.svg rename webext/{add-on-edge => add-on}/content-bridge.js (73%) rename webext/{add-on-edge => add-on}/content-main.js (88%) delete mode 100644 webext/add-on/content.js rename webext/{add-on-edge/manifest.json => add-on/manifest.chromium.json} (100%) rename webext/add-on/{manifest.json => manifest.firefox.json} (66%) diff --git a/webext/add-on-edge/background.js b/webext/add-on-edge/background.js deleted file mode 100644 index 44bd596..0000000 --- a/webext/add-on-edge/background.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Background service worker for Edge/Chromium. - * Bridges content script messages to the native messaging host. - */ - -let contentPort; -let nativePort; - -function arrayBufferToBase64url(buffer) { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -function base64urlToBytes(str) { - if (!str) return null; - const padded = str.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -function connected(port) { - console.log('[credentialsd] received connection from content script'); - contentPort = port; - - // Connect to native messaging host - nativePort = chrome.runtime.connectNative('xyz.iinuwa.credentialsd_helper'); - if (chrome.runtime.lastError) { - console.error('[credentialsd] native connect error:', chrome.runtime.lastError.message); - return; - } - console.log('[credentialsd] connected to native app'); - - contentPort.onMessage.addListener(rcvFromContent); - nativePort.onMessage.addListener(rcvFromNative); - - nativePort.onDisconnect.addListener(() => { - if (chrome.runtime.lastError) { - console.error('[credentialsd] native port disconnected:', chrome.runtime.lastError.message); - } - }); -} - -function rcvFromContent(msg) { - const { requestId, cmd, options } = msg; - const origin = contentPort.sender.origin; - const topOrigin = new URL(contentPort.sender.tab.url).origin; - - if (options) { - const serializedOptions = serializeRequest(options); - console.debug('[credentialsd] forwarding', cmd, 'to native app'); - nativePort.postMessage({ requestId, cmd, options: serializedOptions, origin, topOrigin }); - } else { - console.debug('[credentialsd] forwarding', cmd, '(no options) to native app'); - nativePort.postMessage({ requestId, cmd, origin, topOrigin }); - } -} - -function rcvFromNative(msg) { - console.log('[credentialsd] received from native, forwarding to content'); - contentPort.postMessage(msg); -} - -function serializeBytes(buffer) { - if (buffer && buffer.__b64url__) { - // Already base64url-encoded by the MAIN world script - return buffer.__b64url__; - } - if (buffer instanceof ArrayBuffer || ArrayBuffer.isView(buffer)) { - return arrayBufferToBase64url(buffer); - } - if (typeof buffer === 'string') { - return buffer; - } - return buffer; -} - -function serializeRequest(options) { - const clone = JSON.parse(JSON.stringify(options)); - - // The MAIN world script serialized ArrayBuffers as { __b64url__: "..." } - // Unwrap these for the native host - function unwrapB64url(obj) { - if (obj === null || obj === undefined) return obj; - if (typeof obj !== 'object') return obj; - if (obj.__b64url__) return obj.__b64url__; - if (Array.isArray(obj)) return obj.map(unwrapB64url); - const result = {}; - for (const key of Object.keys(obj)) { - result[key] = unwrapB64url(obj[key]); - } - return result; - } - - return unwrapB64url(clone); -} - -// Listen for connections from content script -console.log('[credentialsd] background service worker starting (Edge/Chromium)'); -chrome.runtime.onConnect.addListener(connected); diff --git a/webext/add-on-edge/icons/logo.svg b/webext/add-on-edge/icons/logo.svg deleted file mode 100644 index a7695f4..0000000 --- a/webext/add-on-edge/icons/logo.svg +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - 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-edge/content-bridge.js b/webext/add-on/content-bridge.js similarity index 73% rename from webext/add-on-edge/content-bridge.js rename to webext/add-on/content-bridge.js index 0f4149d..d17bada 100644 --- a/webext/add-on-edge/content-bridge.js +++ b/webext/add-on/content-bridge.js @@ -1,10 +1,13 @@ /** * Content script running in ISOLATED world. * Bridges window.postMessage from the MAIN world content script - * to the background service worker via chrome.runtime.connect. + * to the background script via runtime.connect. + * + * Works in both Firefox and Chromium browsers. */ -const port = chrome.runtime.connect({ name: 'credentialsd-helper' }); +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) => { @@ -30,4 +33,4 @@ window.addEventListener('message', (event) => { port.postMessage({ requestId, cmd, options }); }); -console.log('[credentialsd] content bridge active (Edge/Chromium)'); +console.log('[credentialsd] content bridge active'); diff --git a/webext/add-on-edge/content-main.js b/webext/add-on/content-main.js similarity index 88% rename from webext/add-on-edge/content-main.js rename to webext/add-on/content-main.js index 2e0b379..d6ac674 100644 --- a/webext/add-on-edge/content-main.js +++ b/webext/add-on/content-main.js @@ -2,31 +2,15 @@ * 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 = {}; -// Base64url helpers (Chromium doesn't have Uint8Array.toBase64/fromBase64) -function arrayBufferToBase64url(buffer) { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -function base64urlToArrayBuffer(str) { - if (!str) return null; - const padded = str.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} +const b64urlEncodeOpts = { alphabet: "base64url", omitPadding: true }; +const b64urlDecodeOpts = { alphabet: "base64url" }; // Listen for responses from the bridge script window.addEventListener('message', (event) => { @@ -54,16 +38,20 @@ function startRequest() { } function serializePublicKeyOptions(options) { - const clone = JSON.parse(JSON.stringify(options, (key, value) => { + return JSON.parse(JSON.stringify(options, (key, value) => { if (value instanceof ArrayBuffer) { - return { __b64url__: arrayBufferToBase64url(value) }; + return new Uint8Array(value).toBase64(b64urlEncodeOpts); } if (ArrayBuffer.isView(value)) { - return { __b64url__: arrayBufferToBase64url(value.buffer) }; + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).toBase64(b64urlEncodeOpts); } return value; })); - return clone; +} + +function base64urlToArrayBuffer(str) { + if (!str) return null; + return Uint8Array.fromBase64(str, b64urlDecodeOpts).buffer; } function reconstructCredentialResponse(credential) { @@ -78,8 +66,7 @@ function reconstructCredentialResponse(credential) { response.clientDataJSON = base64urlToArrayBuffer(credential.response.clientDataJSON); response.attestationObject = base64urlToArrayBuffer(credential.response.attestationObject); response.transports = credential.response.transports ? [...credential.response.transports] : []; - const authenticatorData = base64urlToArrayBuffer(credential.response.authenticatorData); - response.authenticatorData = authenticatorData; + response.authenticatorData = base64urlToArrayBuffer(credential.response.authenticatorData); response.getAuthenticatorData = function() { return this.authenticatorData; }; response.getPublicKeyAlgorithm = function() { return credential.response.publicKeyAlgorithm; }; if (credential.response.publicKey) { @@ -231,7 +218,6 @@ if (typeof PublicKeyCredential !== 'undefined') { return true; }; - const origGetClientCapabilities = PublicKeyCredential.getClientCapabilities; PublicKeyCredential.getClientCapabilities = function() { console.log('[credentialsd] intercepting PublicKeyCredential.getClientCapabilities'); const { requestId, promise } = startRequest(); @@ -246,4 +232,4 @@ if (typeof PublicKeyCredential !== 'undefined') { }; } -console.log('[credentialsd] WebAuthn credential override active (Edge/Chromium)'); +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-edge/manifest.json b/webext/add-on/manifest.chromium.json similarity index 100% rename from webext/add-on-edge/manifest.json rename to webext/add-on/manifest.chromium.json diff --git a/webext/add-on/manifest.json b/webext/add-on/manifest.firefox.json similarity index 66% rename from webext/add-on/manifest.json rename to webext/add-on/manifest.firefox.json index f2e6635..c14771d 100644 --- a/webext/add-on/manifest.json +++ b/webext/add-on/manifest.firefox.json @@ -19,9 +19,16 @@ }, "content_scripts": [ { - "matches": ["https://webauthn.io/*", "https://demo.yubico.com/*"], - "js": ["content.js"], - "run_at": "document_start" + "matches": [""], + "js": ["content-bridge.js"], + "run_at": "document_start", + "world": "ISOLATED" + }, + { + "matches": [""], + "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..536408f 100644 --- a/webext/add-on/meson.build +++ b/webext/add-on/meson.build @@ -1,11 +1,16 @@ zip = find_program('zip') addon_dir = datadir / 'credentialsd' -xpi_files = ['manifest.json', 'background.js', 'content.js', 'icons' / 'logo.svg'] + +# Shared JavaScript files used by both Firefox and Chromium builds +shared_js = ['background.js', 'content-bridge.js', 'content-main.js'] + +# Firefox XPI +firefox_files = ['manifest.firefox.json'] + shared_js + ['icons' / 'logo.svg'] custom_target( 'xpi', output: 'credentialsd-firefox-helper.xpi', - input: xpi_files, + input: firefox_files, command: [ 'pwd', '&&', @@ -15,9 +20,11 @@ custom_target( zip, '-r', '-FS', meson.project_build_root() / '@OUTPUT@', - xpi_files, + firefox_files, '--exclude', 'icons/LICENSE', ], install: true, install_dir: addon_dir, -) \ No newline at end of file +) + +# TODO: Add Chromium build target using manifest.chromium.json \ No newline at end of file From 58d581e23b74b2a3f5466a1f2ffea1f9f8657f25 Mon Sep 17 00:00:00 2001 From: phelix001 Date: Tue, 24 Feb 2026 17:42:11 -0500 Subject: [PATCH 03/33] docs: update READMEs for unified browser extension - Update README to document Edge/Chromium support alongside Firefox - Fix webext/README references to deleted add-on-edge/ directory - Add manifest.json copy step for both Firefox and Chromium dev workflows - Add webext/add-on/manifest.json to .gitignore (generated for local dev) Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ README.md | 24 +++++++++++++++++------- webext/README.md | 43 ++++++++++++++++++++++++++----------------- 3 files changed, 46 insertions(+), 24 deletions(-) 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/README.md b/README.md index 66bbf63..dd14851 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/webext/README.md b/webext/README.md index 5d8de1a..a03ccb2 100644 --- a/webext/README.md +++ b/webext/README.md @@ -1,9 +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. -Two variants are provided: -- `add-on/` - Firefox (MV3, requires Firefox 140+) -- `add-on-edge/` - Edge/Chromium (MV3, requires Chrome 111+ or Edge 111+) +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: @@ -58,13 +59,17 @@ couple of options: 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) @@ -73,11 +78,15 @@ couple of options: 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. Open Edge and go to `edge://extensions` (or `chrome://extensions` for Chrome). -3. Enable "Developer mode" (toggle in top right). -4. Click "Load unpacked" and select the `webext/add-on-edge/` directory. -5. Note the extension ID shown on the extensions page (e.g., `abcdefghijklmnop...`). -6. Create the native messaging manifest: +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 @@ -96,9 +105,9 @@ couple of options: } EOF ``` - Replace `YOUR_EXTENSION_ID` with the extension ID from step 5. -7. Build with `ninja -C ./build` and run the D-Bus services: + 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` -8. Navigate to [https://webauthn.io](). -9. Run through the registration and creation process. +9. Navigate to [https://webauthn.io](). +10. Run through the registration and creation process. From 6ba8ab76b75ae09e0254b09cb03e463d419fb1d5 Mon Sep 17 00:00:00 2001 From: phelix001 Date: Wed, 11 Mar 2026 19:40:58 -0400 Subject: [PATCH 04/33] webext: address review feedback on manifest and meson build - Revert Firefox content_scripts matches from back to restricted URL list to avoid triggering extra Firefox store review - Apply reviewer's meson.build suggestions: rename shared file list, update Firefox target to use cp/mv pattern, add full Chromium build target Co-Authored-By: Claude Opus 4.6 --- webext/add-on/manifest.firefox.json | 4 +-- webext/add-on/meson.build | 49 +++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/webext/add-on/manifest.firefox.json b/webext/add-on/manifest.firefox.json index c14771d..8b23ef7 100644 --- a/webext/add-on/manifest.firefox.json +++ b/webext/add-on/manifest.firefox.json @@ -19,13 +19,13 @@ }, "content_scripts": [ { - "matches": [""], + "matches": ["https://webauthn.io/*", "https://demo.yubico.com/*"], "js": ["content-bridge.js"], "run_at": "document_start", "world": "ISOLATED" }, { - "matches": [""], + "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 536408f..3849cf9 100644 --- a/webext/add-on/meson.build +++ b/webext/add-on/meson.build @@ -2,29 +2,60 @@ zip = find_program('zip') addon_dir = datadir / 'credentialsd' -# Shared JavaScript files used by both Firefox and Chromium builds -shared_js = ['background.js', 'content-bridge.js', 'content-main.js'] +# 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_files = ['manifest.firefox.json'] + shared_js + ['icons' / 'logo.svg'] +firefox_addon_files = ['manifest.firefox.json'] + shared_addon_files custom_target( 'xpi', output: 'credentialsd-firefox-helper.xpi', - input: firefox_files, + input: firefox_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@', - firefox_files, - '--exclude', 'icons/LICENSE', + '.', ], install: true, install_dir: addon_dir, ) -# TODO: Add Chromium build target using manifest.chromium.json \ No newline at end of file +# 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: [ + '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, +) From 93d7eca6aea745c53bfdf2167230df8af12b2b72 Mon Sep 17 00:00:00 2001 From: phelix001 Date: Thu, 12 Mar 2026 06:57:53 -0400 Subject: [PATCH 05/33] webext: use service_worker background in Firefox manifest Switch Firefox from background.scripts to background.service_worker to match the Chromium manifest, per reviewer feedback. Co-Authored-By: Claude Opus 4.6 --- webext/add-on/manifest.firefox.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webext/add-on/manifest.firefox.json b/webext/add-on/manifest.firefox.json index 8b23ef7..b700d9f 100644 --- a/webext/add-on/manifest.firefox.json +++ b/webext/add-on/manifest.firefox.json @@ -15,7 +15,7 @@ }, "background": { - "scripts": ["background.js"] + "service_worker": "background.js" }, "content_scripts": [ { From 70ba6f52a14a3c24739b718ae0fbe2cc22aac705 Mon Sep 17 00:00:00 2001 From: Ekaterine Papava Date: Mon, 20 Apr 2026 07:19:17 +0200 Subject: [PATCH 06/33] po: Add Georgian language support --- credentialsd-ui/po/LINGUAS | 1 + 1 file changed, 1 insertion(+) 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 From 08a3c7eb3a5a84d34fbf825ec4b4bbeee104e20b Mon Sep 17 00:00:00 2001 From: Ekaterine Papava Date: Mon, 20 Apr 2026 09:19:33 +0400 Subject: [PATCH 07/33] po: Add Georgian language support --- credentialsd-ui/po/ka_GE.po | 275 ++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 credentialsd-ui/po/ka_GE.po 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 "" +"რაღაც არასწორად წავიდა. სცადეთ მოგვიანებით კიდევ ერთხელ, ან გამოიყენეთ სხვა " +"ავთენტიკატორი." From 9e1e2dd31e232551e153ea45c73f05947b8b1bed Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 21 Feb 2026 09:25:15 -0600 Subject: [PATCH 08/33] Include devices in initial request --- credentialsd-common/src/server.rs | 9 +++++++++ credentialsd-ui/src/gui/view_model/mod.rs | 14 ++++---------- credentialsd/src/credential_service/mod.rs | 5 +++++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index 8096634..7eb656e 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -584,10 +584,19 @@ where #[derive(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, } diff --git a/credentialsd-ui/src/gui/view_model/mod.rs b/credentialsd-ui/src/gui/view_model/mod.rs index d3113ef..4472afa 100644 --- a/credentialsd-ui/src/gui/view_model/mod.rs +++ b/credentialsd-ui/src/gui/view_model/mod.rs @@ -61,6 +61,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 +73,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, @@ -125,14 +126,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())) @@ -201,7 +195,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; diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 09b593b..1ab7979 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -131,10 +131,15 @@ impl< CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), }; + let initial_devices = self + .get_available_public_key_devices() + .await + .unwrap_or_default(); let view_request = ViewRequest { operation, id: request_id, rp_id, + initial_devices, requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer window_handle: window_handle.into(), }; From cf503696c4e4e2c2536b609228d06a362fa7cf58 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 21 Feb 2026 09:25:15 -0600 Subject: [PATCH 09/33] Make ViewRequest Clone --- credentialsd-common/src/server.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index 6a3ad4d..e91e223 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -584,7 +584,7 @@ where .map_err(|err| D::Error::custom(format!("could not deserialize from structure: {err}"))) } -#[derive(Serialize, Deserialize, Type)] +#[derive(Clone, Debug, Serialize, Deserialize, Type)] pub struct ViewRequest { pub operation: Operation, @@ -604,7 +604,7 @@ pub struct ViewRequest { pub window_handle: Optional, } -#[derive(Type, PartialEq, Debug)] +#[derive(Clone, Debug, PartialEq, Type)] #[zvariant(signature = "s")] pub enum WindowHandle { Wayland(String), From 094430c5effe9b2a9201e397ac2aaa05d048d6f6 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 21 Feb 2026 09:25:15 -0600 Subject: [PATCH 10/33] Add FlowControlClient. This will be a replacement of the FlowController trait. We temporarily need two different implementations of dyn-compatible trait, but in order to make an async fn, we use a struct that forwards over a channel. --- credentialsd-common/src/model.rs | 35 +++++++++++++++++++ credentialsd-ui/src/client.rs | 59 ++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index 92e8019..b986714 100644 --- a/credentialsd-common/src/model.rs +++ b/credentialsd-common/src/model.rs @@ -255,6 +255,41 @@ pub enum NfcState { Failed(Error), } +pub enum BackendRequest { + /// Start Hybrid discovery + GetHybridCredential, + + /// Start USB discovery + GetUsbCredential, + + /// Start NFC discovery + GetNfcCredential, + + /// Send client PIN + EnterClientPin(String), + + /// Select a credential by credential ID + SelectCredential(String), + + CancelRequest(RequestId), +} + +impl std::fmt::Debug for BackendRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::GetHybridCredential => write!(f, "GetHybridCredential"), + Self::GetUsbCredential => write!(f, "GetUsbCredential"), + Self::GetNfcCredential => write!(f, "GetNfcCredential"), + Self::EnterClientPin(_) => f + .debug_tuple("EnterClientPin") + .field(&"******".to_string()) + .finish(), + Self::SelectCredential(arg0) => f.debug_tuple("SelectCredential").field(arg0).finish(), + Self::CancelRequest(arg0) => f.debug_tuple("CancelRequest").field(arg0).finish(), + } + } +} + #[derive(Clone, Debug)] pub enum BackgroundEvent { UsbStateChanged(UsbState), diff --git a/credentialsd-ui/src/client.rs b/credentialsd-ui/src/client.rs index 34a0a90..42ce450 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, model::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::GetHybridCredential).await + } + + pub async fn discover_nfc_authenticators(&mut self) -> Result<(), ()> { + self.send(BackendRequest::GetNfcCredential).await + } + + pub async fn discover_usb_authenticators(&mut self) -> Result<(), ()> { + self.send(BackendRequest::GetUsbCredential).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, request_id: RequestId) -> Result<(), ()> { + self.send(BackendRequest::CancelRequest(request_id)).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(()), + } + } +} From 040b3d3b3d8a8761e96170dc32077a4c66a901c5 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 21 Feb 2026 09:25:15 -0600 Subject: [PATCH 11/33] Inject flow control client at request time instead of at startup. --- credentialsd-ui/src/dbus.rs | 73 ++++++++++++++++++++--- credentialsd-ui/src/gui/mod.rs | 23 ++++--- credentialsd-ui/src/gui/view_model/mod.rs | 21 ++++--- credentialsd-ui/src/main.rs | 2 +- 4 files changed, 88 insertions(+), 31 deletions(-) diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index 9ab511b..b7da0ea 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -1,9 +1,15 @@ -use async_std::channel::Sender; +use std::sync::Arc; + +use async_std::{channel::Sender, stream::StreamExt, sync::Mutex as AsyncMutex}; +use zbus::{Connection, fdo, interface, proxy}; + use credentialsd_common::{ - model::{BackgroundEvent, Device}, - server::{RequestId, ViewRequest}, + client::FlowController, + model::{BackendRequest, BackgroundEvent, Device, RequestId}, + server::ViewRequest, }; -use zbus::{fdo, interface, proxy}; + +use crate::client::{DbusCredentialClient, FlowControlClient}; #[proxy( gen_blocking = false, @@ -31,16 +37,69 @@ 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::GetHybridCredential => client.get_hybrid_credential().await, + BackendRequest::GetNfcCredential => client.get_nfc_credential().await, + BackendRequest::GetUsbCredential => 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(request_id) => { + 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())) } diff --git a/credentialsd-ui/src/gui/mod.rs b/credentialsd-ui/src/gui/mod.rs index bd65aed..170313c 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() diff --git a/credentialsd-ui/src/gui/view_model/mod.rs b/credentialsd-ui/src/gui/view_model/mod.rs index 4472afa..a556b85 100644 --- a/credentialsd-ui/src/gui/view_model/mod.rs +++ b/credentialsd-ui/src/gui/view_model/mod.rs @@ -21,12 +21,11 @@ use credentialsd_common::{ }, }; +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 +46,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 { @@ -163,15 +162,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!() diff --git a/credentialsd-ui/src/main.rs b/credentialsd-ui/src/main.rs index 701e0ba..6a006f8 100644 --- a/credentialsd-ui/src/main.rs +++ b/credentialsd-ui/src/main.rs @@ -21,7 +21,7 @@ async fn run() -> Result<(), Box> { // 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"); From adbfd8923c30e2243da8ae3235bfa401f7edb866 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 21 Feb 2026 09:25:15 -0600 Subject: [PATCH 12/33] wip: Introduce FlowObject pattern --- credentialsd-ui/src/dbus.rs | 74 +++++++++++++++++++++- credentialsd-ui/src/gui/view_model/mod.rs | 13 ++-- credentialsd-ui/src/main.rs | 4 +- credentialsd/src/credential_service/mod.rs | 67 ++++++++++++++++---- credentialsd/src/dbus/ui_control.rs | 73 +++++++++++++++++++-- 5 files changed, 203 insertions(+), 28 deletions(-) diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index b7da0ea..898bb5f 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use async_std::{channel::Sender, stream::StreamExt, sync::Mutex as AsyncMutex}; -use zbus::{Connection, fdo, interface, proxy}; +use zbus::{ + Connection, ObjectServer, fdo, interface, message::Header, names::OwnedUniqueName, + object_server::SignalEmitter, proxy, zvariant::ObjectPath, +}; use credentialsd_common::{ client::FlowController, @@ -104,3 +107,72 @@ impl UiControlService { .map_err(|_| fdo::Error::Failed("UI failed to launch".to_string())) } } + +pub struct CredentialPortalBackend { + pub request_tx: Sender, +} + +/// 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, + request: ViewRequest, + ) -> 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 flow_object = FlowObject { + request, + request_tx: self.request_tx.clone(), + return_address: sender.to_owned().into(), + }; + object_server.at(object_path.clone(), flow_object).await?; + tracing::debug!("Received UI launch request"); + Ok(object_path) + } +} + +pub struct FlowObject { + request: ViewRequest, + pub request_tx: Sender, + pub return_address: OwnedUniqueName, +} + +#[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(&self) -> fdo::Result<()> { + if let Err(err) = self.request_tx.send(self.request.clone()).await { + 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<()> { + todo!() + } + + async fn cancel( + &self, + #[zbus(header)] header: Header<'_>, + #[zbus(object_server)] object_server: &ObjectServer, + ) -> fdo::Result<()> { + 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<'_>) -> zbus::Result<()>; +} diff --git a/credentialsd-ui/src/gui/view_model/mod.rs b/credentialsd-ui/src/gui/view_model/mod.rs index a556b85..073a321 100644 --- a/credentialsd-ui/src/gui/view_model/mod.rs +++ b/credentialsd-ui/src/gui/view_model/mod.rs @@ -13,12 +13,9 @@ 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; @@ -233,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 6a006f8..d1482ba 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::UiControlService; fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); @@ -19,8 +19,6 @@ 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)?; println!(" ✅"); diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 3539e3b..6ee7998 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -17,15 +17,16 @@ use libwebauthn::{ ops::webauthn::{GetAssertionResponse, MakeCredentialResponse}, }; use nfc::{NfcEvent, NfcHandler, NfcState, NfcStateInternal}; -use tokio::sync::oneshot::Sender; +use tokio::sync::{mpsc, oneshot}; use credentialsd_common::{ model::{ - Device, Error as CredentialServiceError, Operation, RequestId, RequestingApplication, - Transport, + BackendRequest, Device, Error as CredentialServiceError, Operation, RequestId, + RequestingApplication, Transport, }, server::{ViewRequest, WindowHandle}, }; +use zbus::zvariant::OwnedObjectPath; use crate::{ credential_service::{hybrid::HybridEvent, usb::UsbEvent}, @@ -45,12 +46,22 @@ pub trait UiController { &self, request: ViewRequest, ) -> impl Future>> + Send; + + fn initialize( + &self, + request: ViewRequest, + ) -> impl Future>> + Send; + + fn start( + &self, + request_id: RequestId, + ) -> impl Future>> + Send; } #[derive(Debug)] struct RequestContext { request: CredentialRequest, - response_channel: Sender>, + response_channel: oneshot::Sender>, request_id: RequestId, } @@ -105,7 +116,7 @@ impl< request: &CredentialRequest, requesting_app: Option, window_handle: Option, - tx: Sender>, + tx: oneshot::Sender>, ) { let request_id = { let mut cred_request = self.ctx.lock().unwrap(); @@ -117,6 +128,23 @@ impl< return; } 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 (ui_request_tx, ui_request_rx) = mpsc::channel(32); + tokio::spawn(async move { + while let Some(ui_request) = ui_request_rx.recv().await { + match ui_request { + BackendRequest::GetHybridCredential => {} + BackendRequest::GetUsbCredential => { + let stream = self.get_hybrid_credential(); + } + BackendRequest::GetNfcCredential => todo!(), + BackendRequest::EnterClientPin(_) => todo!(), + BackendRequest::SelectCredential(_) => todo!(), + BackendRequest::CancelRequest(_) => todo!(), + } + } + }); let ctx = RequestContext { request: request.clone(), response_channel: tx, @@ -143,23 +171,34 @@ impl< id: request_id, rp_id, initial_devices, - requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer + requesting_app: requesting_app.unwrap_or_default(), 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"); - } + */ + + let path = match self.ui_control_client.initialize(view_request).await { + Ok(path) => path, + //if let Err(err) = launch_ui_response { + Err(err) => { + tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); + let err = Err(CredentialServiceError::Internal(err.to_string())); + let ctx = self.ctx.lock().unwrap().take().unwrap(); + ctx.response_channel + .send(err) + .expect("Request handler to be listening"); + return; + } + }; + // Here, subscribe to signals, and forward them to the receiver in the request context. + // self.ui_control_client.subscribe().await; + self.ui_control_client.start(request_id).await; tracing::debug!("Finished setting up request {request_id}"); } diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 8fca6b3..6247a4d 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -2,9 +2,18 @@ use std::error::Error; -use zbus::{fdo, proxy, Connection}; +use zbus::{ + fdo, + object_server::SignalEmitter, + proxy, + zvariant::{ObjectPath, OwnedObjectPath}, + Connection, +}; -use credentialsd_common::{model::RequestId, server::ViewRequest}; +use credentialsd_common::{ + model::{BackgroundEvent, RequestId}, + server::ViewRequest, +}; use crate::credential_service::UiController; @@ -19,6 +28,31 @@ trait UiControlService { fn cancel_request(&self, request_id: RequestId) -> fdo::Result<()>; } +#[proxy( + gen_blocking = false, + interface = "org.freedesktop.impl.portal.experimental.Credential", + default_service = "org.freedesktop.impl.portal.experimental.Credential", + default_path = "/org/freedesktop/portal/desktop" +)] +trait UiControlService2 { + fn initialize(&self, request: ViewRequest) -> fdo::Result; +} + +#[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() -> zbus::Result<()>; +} + #[derive(Debug)] pub struct UiControlServiceClient { conn: Connection, @@ -32,7 +66,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 +91,18 @@ impl UiController for UiControlServiceClient { .await .map_err(|err| err.into()) } + + async fn initialize(&self, request: ViewRequest) -> Result> { + let path = self.proxy2().await?.initialize(request).await?; + tracing::debug!(?path, "Path initialized"); + Ok(path) + } } #[cfg(test)] pub mod test { use std::{ + error::Error, fmt::Debug, sync::{ atomic::{AtomicBool, Ordering}, @@ -61,6 +118,7 @@ pub mod test { mpsc::{self, Receiver, Sender}, Mutex as AsyncMutex, Notify, }; + use zbus::zvariant::OwnedObjectPath; use super::UiController; @@ -70,7 +128,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 +140,13 @@ pub mod test { ); Ok(()) } + + async fn initialize( + &self, + request: ViewRequest, + ) -> Result> { + unimplemented!() + } } pub struct DummyUiServer @@ -211,7 +276,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" From c394020d1fd2a16dd4f92bb0d29f3b8c884c5065 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Mon, 23 Feb 2026 23:08:15 -0600 Subject: [PATCH 13/33] wip: Serialize BackendRequest, reorder generics on CredentialService, other stuff --- credentialsd-common/src/model.rs | 20 ++--- credentialsd-common/src/server.rs | 94 +++++++++++++++++++++- credentialsd-ui/src/client.rs | 10 +-- credentialsd-ui/src/dbus.rs | 10 +-- credentialsd-ui/src/gui/mod.rs | 6 +- credentialsd/src/credential_service/mod.rs | 90 ++++++++++----------- credentialsd/src/dbus/flow_control.rs | 46 +++++------ credentialsd/src/dbus/ui_control.rs | 48 ++++++++--- credentialsd/src/main.rs | 2 +- 9 files changed, 217 insertions(+), 109 deletions(-) diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index b986714..aa5319a 100644 --- a/credentialsd-common/src/model.rs +++ b/credentialsd-common/src/model.rs @@ -257,13 +257,13 @@ pub enum NfcState { pub enum BackendRequest { /// Start Hybrid discovery - GetHybridCredential, - - /// Start USB discovery - GetUsbCredential, + StartHybridDiscovery, /// Start NFC discovery - GetNfcCredential, + StartNfcDiscovery, + + /// Start USB discovery + StartUsbDiscovery, /// Send client PIN EnterClientPin(String), @@ -271,21 +271,21 @@ pub enum BackendRequest { /// Select a credential by credential ID SelectCredential(String), - CancelRequest(RequestId), + CancelRequest, } impl std::fmt::Debug for BackendRequest { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::GetHybridCredential => write!(f, "GetHybridCredential"), - Self::GetUsbCredential => write!(f, "GetUsbCredential"), - Self::GetNfcCredential => write!(f, "GetNfcCredential"), + 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(arg0) => f.debug_tuple("CancelRequest").field(arg0).finish(), + Self::CancelRequest => write!(f, "CancelRequest"), } } } diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index e91e223..66815d6 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -8,15 +8,105 @@ use serde::{ }; use zvariant::{ self, Array, DeserializeDict, DynamicDeserialize, NoneValue, Optional, OwnedValue, - SerializeDict, Signature, Structure, StructureBuilder, Type, Value, signature::Fields, + SerializeDict, Signature, Str, Structure, StructureBuilder, Type, Value, signature::Fields, }; -use crate::model::{BackgroundEvent, Device, Operation, RequestId, RequestingApplication}; +use crate::model::{ + BackendRequest, BackgroundEvent, Device, Operation, RequestId, RequestingApplication, +}; const TAG_VALUE_SIGNATURE: &Signature = &Signature::Structure(Fields::Static { fields: &[&Signature::U8, &Signature::Variant], }); +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; } diff --git a/credentialsd-ui/src/client.rs b/credentialsd-ui/src/client.rs index 42ce450..7642596 100644 --- a/credentialsd-ui/src/client.rs +++ b/credentialsd-ui/src/client.rs @@ -134,15 +134,15 @@ pub struct FlowControlClient { impl FlowControlClient { pub async fn discover_hybrid_authenticators(&self) -> Result<(), ()> { - self.send(BackendRequest::GetHybridCredential).await + self.send(BackendRequest::StartHybridDiscovery).await } pub async fn discover_nfc_authenticators(&mut self) -> Result<(), ()> { - self.send(BackendRequest::GetNfcCredential).await + self.send(BackendRequest::StartNfcDiscovery).await } pub async fn discover_usb_authenticators(&mut self) -> Result<(), ()> { - self.send(BackendRequest::GetUsbCredential).await + self.send(BackendRequest::StartUsbDiscovery).await } pub async fn enter_client_pin(&mut self, pin: String) -> Result<(), ()> { @@ -154,8 +154,8 @@ impl FlowControlClient { .await } - pub async fn cancel_request(&self, request_id: RequestId) -> Result<(), ()> { - self.send(BackendRequest::CancelRequest(request_id)).await + pub async fn cancel_request(&self) -> Result<(), ()> { + self.send(BackendRequest::CancelRequest).await } /// Returns a channel for background events. diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index 898bb5f..7718847 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -78,18 +78,16 @@ impl UiControlService { while let Ok(msg) = fc_rx.recv().await { // UI doesn't get an error if these fail... let result = match &msg { - BackendRequest::GetHybridCredential => client.get_hybrid_credential().await, - BackendRequest::GetNfcCredential => client.get_nfc_credential().await, - BackendRequest::GetUsbCredential => client.get_usb_credential().await, + 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(request_id) => { - client.cancel_request(*request_id).await - } + BackendRequest::CancelRequest => client.cancel_request(request.id).await, }; if let Err(err) = result { tracing::error!("Failed to send {msg:?} to frontend: {err:?}"); diff --git a/credentialsd-ui/src/gui/mod.rs b/credentialsd-ui/src/gui/mod.rs index 170313c..f8dacba 100644 --- a/credentialsd-ui/src/gui/mod.rs +++ b/credentialsd-ui/src/gui/mod.rs @@ -42,11 +42,7 @@ fn run_gui(flow_controller: Arc>, request: ViewReq 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; }); view_model::gtk::start_gtk_app(parent_window, tx_event, rx_update); diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 6ee7998..bb9dc48 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -26,7 +26,6 @@ use credentialsd_common::{ }, server::{ViewRequest, WindowHandle}, }; -use zbus::zvariant::OwnedObjectPath; use crate::{ credential_service::{hybrid::HybridEvent, usb::UsbEvent}, @@ -50,12 +49,7 @@ pub trait UiController { fn initialize( &self, request: ViewRequest, - ) -> impl Future>> + Send; - - fn start( - &self, - request_id: RequestId, - ) -> impl Future>> + Send; + ) -> impl Future, Box>> + Send; } #[derive(Debug)] @@ -76,36 +70,36 @@ impl RequestContext { } #[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, + hybrid_handler: Mutex, + nfc_handler: Mutex, + usb_handler: Mutex, ui_control_client: Arc, } impl< - H: HybridHandler + Debug, - U: UsbHandler + Debug, + H: HybridHandler + Debug + Sync, N: NfcHandler + Debug, + U: UsbHandler + Debug, UC: UiController + Debug, - > CredentialService + > CredentialService { pub fn new( hybrid_handler: H, - usb_handler: U, nfc_handler: N, + usb_handler: U, ui_control_client: Arc, ) -> Self { Self { ctx: Arc::new(Mutex::new(None)), - hybrid_handler, - usb_handler, - nfc_handler, + hybrid_handler: Mutex::new(hybrid_handler), + nfc_handler: Mutex::new(nfc_handler), + usb_handler: Mutex::new(usb_handler), ui_control_client, } @@ -130,21 +124,6 @@ impl< 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 (ui_request_tx, ui_request_rx) = mpsc::channel(32); - tokio::spawn(async move { - while let Some(ui_request) = ui_request_rx.recv().await { - match ui_request { - BackendRequest::GetHybridCredential => {} - BackendRequest::GetUsbCredential => { - let stream = self.get_hybrid_credential(); - } - BackendRequest::GetNfcCredential => todo!(), - BackendRequest::EnterClientPin(_) => todo!(), - BackendRequest::SelectCredential(_) => todo!(), - BackendRequest::CancelRequest(_) => todo!(), - } - } - }); let ctx = RequestContext { request: request.clone(), response_channel: tx, @@ -171,7 +150,7 @@ impl< id: request_id, rp_id, initial_devices, - requesting_app: requesting_app.unwrap_or_default(), + 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(), }; @@ -183,8 +162,8 @@ impl< .map_err(|err| err.to_string()); */ - let path = match self.ui_control_client.initialize(view_request).await { - Ok(path) => path, + let mut ui_request_rx = match self.ui_control_client.initialize(view_request).await { + Ok(rx) => rx, //if let Err(err) = launch_ui_response { Err(err) => { tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); @@ -196,9 +175,24 @@ impl< return; } }; - // Here, subscribe to signals, and forward them to the receiver in the request context. - // self.ui_control_client.subscribe().await; - self.ui_control_client.start(request_id).await; + tokio::spawn(async move { + while let Some(ui_request) = ui_request_rx.recv().await { + match ui_request { + BackendRequest::StartHybridDiscovery => { + let stream = self.get_hybrid_credential().await; + } + BackendRequest::StartNfcDiscovery => { + // let stream = self.get_nfc_credential().await; + } + BackendRequest::StartUsbDiscovery => { + // let stream = self.get_usb_credential().await; + } + BackendRequest::EnterClientPin(_) => todo!(), + BackendRequest::SelectCredential(_) => todo!(), + BackendRequest::CancelRequest => todo!(), + } + } + }); tracing::debug!("Finished setting up request {request_id}"); } @@ -242,12 +236,12 @@ impl< Ok(devices) } - pub fn get_hybrid_credential( + pub 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 { @@ -258,10 +252,12 @@ impl< } } - pub fn get_usb_credential(&self) -> Pin + Send + 'static>> { + pub 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 { @@ -272,10 +268,12 @@ impl< } } - pub fn get_nfc_credential(&self) -> Pin + Send + 'static>> { + pub 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 { @@ -473,8 +471,8 @@ mod test { let user = ui_server.clone(); let cred_service = Arc::new(AsyncMutex::new(CredentialService::new( hybrid_handler, - usb_handler, nfc_handler, + usb_handler, Arc::new(ui_client), ))); let (mut flow_server, flow_client) = DummyFlowServer::new(cred_service.clone()); diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index 9f825af..1ff6b94 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -40,11 +40,11 @@ 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, + U: UsbHandler + Debug + Send + Sync + 'static, UC: UiController + Debug + Send + Sync + 'static, >( - credential_service: CredentialService, + credential_service: CredentialService, ) -> zbus::Result<( Connection, Sender<( @@ -85,9 +85,9 @@ pub async fn start_flow_control_service< Ok((conn, initiator_tx)) } -struct FlowControlService { +struct FlowControlService { signal_state: Arc>, - svc: Arc>>, + svc: Arc>>, pin_tx: Arc>>>, cred_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -106,11 +106,11 @@ struct FlowControlService FlowControlService +impl FlowControlService where H: HybridHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, N: NfcHandler + Debug + Send + Sync + 'static, + U: UsbHandler + Debug + Send + Sync + 'static, UC: UiController + Debug + Send + Sync + 'static, { async fn subscribe( @@ -149,11 +149,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 { @@ -190,13 +190,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 { @@ -240,13 +240,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 { @@ -544,15 +544,15 @@ 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, + U: UsbHandler + Debug + Send + Sync, UC: UiController + Debug + Send + Sync, { rx: mpsc::Receiver<(DummyFlowRequest, oneshot::Sender)>, - svc: Arc>>, + svc: Arc>>, bg_event_tx: Option>, pin_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -562,10 +562,10 @@ pub mod test { impl< H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, N: NfcHandler + Debug + Send + Sync, + U: UsbHandler + Debug + Send + Sync, UC: UiController + Debug + Send + Sync, - > DummyFlowServer + > DummyFlowServer { /* async fn send(&self, request: ManagementRequest) -> Result { @@ -588,7 +588,7 @@ pub mod test { } */ pub fn new( - svc: Arc>>, + svc: Arc>>, ) -> (Self, DummyFlowClient) { let (request_tx, request_rx) = mpsc::channel(32); let server = Self { @@ -652,7 +652,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 +694,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 +733,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 { @@ -804,10 +804,10 @@ pub mod test { impl< H: HybridHandler + Debug + Send + Sync, - U: UsbHandler + Debug + Send + Sync, N: NfcHandler + Debug + Send + Sync, + U: UsbHandler + Debug + Send + Sync, UC: UiController + Debug + Send + Sync, - > Drop for DummyFlowServer + > Drop for DummyFlowServer { fn drop(&mut self) { if let Some(task) = self.usb_event_forwarder_task.lock().unwrap().take() { diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 6247a4d..9453a54 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -2,16 +2,16 @@ use std::error::Error; +use futures_lite::StreamExt; +use tokio::sync::mpsc::{self, Receiver}; use zbus::{ - fdo, - object_server::SignalEmitter, - proxy, + fdo, proxy, zvariant::{ObjectPath, OwnedObjectPath}, Connection, }; use credentialsd_common::{ - model::{BackgroundEvent, RequestId}, + model::{BackendRequest, BackgroundEvent, RequestId}, server::ViewRequest, }; @@ -50,7 +50,7 @@ trait FlowObject { async fn cancel(&self) -> fdo::Result<()>; #[zbus(signal)] - async fn user_interacted() -> zbus::Result<()>; + async fn user_interacted(&self, update: BackendRequest) -> zbus::Result<()>; } #[derive(Debug)] @@ -92,11 +92,36 @@ impl UiController for UiControlServiceClient { .map_err(|err| err.into()) } - async fn initialize(&self, request: ViewRequest) -> Result> { + async fn initialize( + &self, + request: ViewRequest, + ) -> Result, Box> { let path = self.proxy2().await?.initialize(request).await?; tracing::debug!(?path, "Path initialized"); - Ok(path) + 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); + }); + // Mark as ready to receive messages. + flow_object.start().await?; + Ok(from_ui_rx) + } +} + +async fn forward_ui_events( + mut ui_event_stream: UserInteractedStream, + tx: mpsc::Sender, +) -> Result<(), Box> { + while let Some(signal) = ui_event_stream.next().await { + 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; + } } + Ok(()) } #[cfg(test)] @@ -111,14 +136,15 @@ pub mod test { }; use credentialsd_common::{ - client::FlowController, model::BackgroundEvent, server::ViewRequest, + client::FlowController, + model::{BackendRequest, BackgroundEvent}, + server::ViewRequest, }; use futures_lite::StreamExt; use tokio::sync::{ mpsc::{self, Receiver, Sender}, Mutex as AsyncMutex, Notify, }; - use zbus::zvariant::OwnedObjectPath; use super::UiController; @@ -143,8 +169,8 @@ pub mod test { async fn initialize( &self, - request: ViewRequest, - ) -> Result> { + _request: ViewRequest, + ) -> Result, Box> { unimplemented!() } } diff --git a/credentialsd/src/main.rs b/credentialsd/src/main.rs index ea920e8..971bbe6 100644 --- a/credentialsd/src/main.rs +++ b/credentialsd/src/main.rs @@ -36,8 +36,8 @@ async fn run() -> Result<(), Box> { let ui_controller = UiControlServiceClient::new(dbus_client_conn); let credential_service = CredentialService::new( InternalHybridHandler::new(), - InProcessUsbHandler {}, InProcessNfcHandler {}, + InProcessUsbHandler {}, Arc::new(ui_controller), ); let (_flow_control_conn, initiator) = From f0971dd3409f3b5b15b04a57e29f29b006b72c0b Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Wed, 25 Feb 2026 21:31:02 -0600 Subject: [PATCH 14/33] wip: transfer responsibility to talk to UI Control service to Flow Control Service --- credentialsd/src/credential_service/mod.rs | 101 ++++-------------- credentialsd/src/dbus/flow_control.rs | 113 ++++++++++++++++++--- 2 files changed, 118 insertions(+), 96 deletions(-) diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index bb9dc48..39f20e9 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -111,89 +111,24 @@ impl< requesting_app: Option, window_handle: Option, tx: oneshot::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(); - // 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); - request_id - } - }; - let operation = match &request { - CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::Create, - CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, - }; - let rp_id = match &request { - CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), - CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), - }; - let initial_devices = self - .get_available_public_key_devices() - .await - .unwrap_or_default(); - let view_request = ViewRequest { - operation, - id: request_id, - rp_id, - initial_devices, - requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer - window_handle: window_handle.into(), - }; - - /* - let launch_ui_response = self - .ui_control_client - .launch_ui(view_request) - .await - .map_err(|err| err.to_string()); - */ - - let mut ui_request_rx = match self.ui_control_client.initialize(view_request).await { - Ok(rx) => rx, - //if let Err(err) = launch_ui_response { - Err(err) => { - tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); - let err = Err(CredentialServiceError::Internal(err.to_string())); - let ctx = self.ctx.lock().unwrap().take().unwrap(); - ctx.response_channel - .send(err) - .expect("Request handler to be listening"); - return; - } - }; - tokio::spawn(async move { - while let Some(ui_request) = ui_request_rx.recv().await { - match ui_request { - BackendRequest::StartHybridDiscovery => { - let stream = self.get_hybrid_credential().await; - } - BackendRequest::StartNfcDiscovery => { - // let stream = self.get_nfc_credential().await; - } - BackendRequest::StartUsbDiscovery => { - // let stream = self.get_usb_credential().await; - } - BackendRequest::EnterClientPin(_) => todo!(), - BackendRequest::SelectCredential(_) => todo!(), - BackendRequest::CancelRequest => todo!(), - } - } - }); - tracing::debug!("Finished setting up request {request_id}"); + ) -> 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) + } } pub async fn cancel_request(&self, request_id: RequestId) { diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index 1ff6b94..632cc22 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -5,10 +5,10 @@ use std::{collections::VecDeque, fmt::Debug, sync::Arc}; use async_trait::async_trait; use credentialsd_common::model::{ - BackgroundEvent, Device, Error as CredentialServiceError, RequestId, RequestingApplication, - WebAuthnError, + BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, RequestId, + RequestingApplication, WebAuthnError, }; -use credentialsd_common::server::WindowHandle; +use credentialsd_common::server::{ViewRequest, WindowHandle}; use futures_lite::StreamExt; use tokio::sync::oneshot; use tokio::{ @@ -60,7 +60,7 @@ pub async fn start_flow_control_service< .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)), @@ -76,16 +76,90 @@ pub async fn start_flow_control_service< 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; + match handle(svc, msg, requesting_app, window_handle) { + Ok(response) => tx.send(response), + Err(err) => tx.send(response), + } } }); Ok((conn, initiator_tx)) } -struct FlowControlService { +async fn handle>( + svc: Arc>, + msg: CredentialRequest, + requesting_app: RequestingApplication, + window_handle: WindowHandle, +) -> Result { + let request_id = svc + .lock() + .await + .init_request(&msg, requesting_app, window_handle) + .await?; + let operation = match &request { + CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::Create, + CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, + }; + let rp_id = match &request { + CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), + CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), + }; + let initial_devices = svc + .get_available_public_key_devices() + .await + .unwrap_or_default(); + let view_request = ViewRequest { + operation, + id: request_id, + rp_id, + initial_devices, + requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer + window_handle: window_handle.into(), + }; + + /* + let launch_ui_response = self + .ui_control_client + .launch_ui(view_request) + .await + .map_err(|err| err.to_string()); + */ + + let mut ui_request_rx = match self.ui_control_client.initialize(view_request).await { + Ok(rx) => rx, + //if let Err(err) = launch_ui_response { + Err(err) => { + tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); + let err = Err(CredentialServiceError::Internal(err.to_string())); + let ctx = self.ctx.lock().unwrap().take().unwrap(); + ctx.response_channel + .send(err) + .expect("Request handler to be listening"); + return; + } + }; + tokio::spawn(async move { + while let Some(ui_request) = ui_request_rx.recv().await { + match ui_request { + BackendRequest::StartHybridDiscovery => { + let stream = self.get_hybrid_credential().await; + } + BackendRequest::StartNfcDiscovery => { + // let stream = self.get_nfc_credential().await; + } + BackendRequest::StartUsbDiscovery => { + // let stream = self.get_usb_credential().await; + } + BackendRequest::EnterClientPin(_) => todo!(), + BackendRequest::SelectCredential(_) => todo!(), + BackendRequest::CancelRequest => todo!(), + } + } + }); + tracing::debug!("Finished setting up request {request_id}"); +} + +struct FlowControlService { signal_state: Arc>, svc: Arc>>, pin_tx: Arc>>>, @@ -95,6 +169,19 @@ struct FlowControlService>>, } +struct FlowControlDbusService { + svc: Arc>>, + + signal_state: Arc>, + + cred_tx: Arc>>>, + pin_tx: Arc>>>, + + nfc_event_forwarder_task: Arc>>, + hybrid_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. @@ -106,7 +193,7 @@ struct FlowControlService FlowControlService +impl FlowControlDbusService where H: HybridHandler + Debug + Send + Sync + 'static, N: NfcHandler + Debug + Send + Sync + 'static, @@ -153,7 +240,7 @@ where 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 { @@ -196,7 +283,7 @@ where 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 { @@ -246,7 +333,7 @@ where 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 { From 2fb414410ffdabeb49d2653619c49cd4375368ea Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Sat, 28 Feb 2026 07:39:38 -0600 Subject: [PATCH 15/33] wip: daemon: move UiController out of CredentialService --- credentialsd/src/credential_service/mod.rs | 21 ++---- credentialsd/src/dbus/flow_control.rs | 75 +++++++++++++--------- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 39f20e9..1f808c1 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -70,38 +70,25 @@ impl RequestContext { } #[derive(Debug)] -pub struct CredentialService { +pub struct CredentialService { /// Current request and channel to respond to caller. ctx: Arc>>, hybrid_handler: Mutex, nfc_handler: Mutex, usb_handler: Mutex, - - ui_control_client: Arc, } -impl< - H: HybridHandler + Debug + Sync, - N: NfcHandler + Debug, - U: UsbHandler + Debug, - UC: UiController + Debug, - > CredentialService +impl + CredentialService { - pub fn new( - hybrid_handler: H, - nfc_handler: N, - usb_handler: U, - 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: Mutex::new(hybrid_handler), nfc_handler: Mutex::new(nfc_handler), usb_handler: Mutex::new(usb_handler), - - ui_control_client, } } diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index 632cc22..7558dc9 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -5,7 +5,7 @@ use std::{collections::VecDeque, fmt::Debug, sync::Arc}; use async_trait::async_trait; use credentialsd_common::model::{ - BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, RequestId, + BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, Operation, RequestId, RequestingApplication, WebAuthnError, }; use credentialsd_common::server::{ViewRequest, WindowHandle}; @@ -25,6 +25,7 @@ use zbus::{ ObjectServer, }; +use crate::dbus::UiControlServiceClient; use crate::{ credential_service::{ hybrid::{HybridHandler, HybridState}, @@ -42,15 +43,14 @@ pub async fn start_flow_control_service< H: HybridHandler + Debug + Send + Sync + 'static, N: NfcHandler + Debug + Send + Sync + 'static, U: UsbHandler + Debug + Send + Sync + 'static, - UC: UiController + Debug + Send + Sync + 'static, >( - credential_service: CredentialService, + credential_service: CredentialService, ) -> zbus::Result<( Connection, Sender<( CredentialRequest, - Option, // Application name sending the request - Option, // Client window handle + RequestingApplication, + Option, // Client window handle oneshot::Sender>, )>, )> { @@ -72,39 +72,51 @@ 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); tokio::spawn(async move { let svc = svc2; while let Some((msg, requesting_app, window_handle, tx)) = initiator_rx.recv().await { - match handle(svc, msg, requesting_app, window_handle) { - Ok(response) => tx.send(response), - Err(err) => tx.send(response), - } + let ui_control_client = UiControlServiceClient::new(conn.clone()); + tx.send(handle(svc, ui_control_client, msg, requesting_app, window_handle).await); } }); Ok((conn, initiator_tx)) } -async fn handle>( - svc: Arc>, +async fn handle< + H: HybridHandler + Debug + Sync, + N: NfcHandler + Debug, + U: UsbHandler + Debug, + UC: UiController + Debug, +>( + svc: Arc>>, + ui_control_client: UC, msg: CredentialRequest, requesting_app: RequestingApplication, - window_handle: WindowHandle, + window_handle: Option, ) -> Result { + let (request_tx, request_rx) = oneshot::channel(); let request_id = svc .lock() .await - .init_request(&msg, requesting_app, window_handle) + .init_request(&msg, Some(requesting_app), window_handle, request_tx) .await?; - let operation = match &request { + let operation = match &msg { CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::Create, CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, }; - let rp_id = match &request { + let rp_id = match &msg { CredentialRequest::CreatePublicKeyCredentialRequest(r) => r.relying_party.id.clone(), CredentialRequest::GetPublicKeyCredentialRequest(r) => r.relying_party_id.clone(), }; let initial_devices = svc + .lock() + .await .get_available_public_key_devices() .await .unwrap_or_default(); @@ -113,7 +125,7 @@ async fn handle>( id: request_id, rp_id, initial_devices, - requesting_app: requesting_app.unwrap_or_default(), // We can't send Options, so we send an empty string instead, if we don't know the peer + requesting_app, window_handle: window_handle.into(), }; @@ -125,24 +137,18 @@ async fn handle>( .map_err(|err| err.to_string()); */ - let mut ui_request_rx = match self.ui_control_client.initialize(view_request).await { + let mut ui_request_rx = match ui_control_client.initialize(view_request).await { Ok(rx) => rx, - //if let Err(err) = launch_ui_response { Err(err) => { tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); - let err = Err(CredentialServiceError::Internal(err.to_string())); - let ctx = self.ctx.lock().unwrap().take().unwrap(); - ctx.response_channel - .send(err) - .expect("Request handler to be listening"); - return; + return Err(CredentialServiceError::Internal(err.to_string())); } }; tokio::spawn(async move { while let Some(ui_request) = ui_request_rx.recv().await { match ui_request { BackendRequest::StartHybridDiscovery => { - let stream = self.get_hybrid_credential().await; + let stream = svc.lock().await.get_hybrid_credential().await; } BackendRequest::StartNfcDiscovery => { // let stream = self.get_nfc_credential().await; @@ -157,11 +163,16 @@ async fn handle>( } }); 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 } -struct FlowControlService { - signal_state: Arc>, +struct FlowControlService { svc: Arc>>, + signal_state: Arc>, pin_tx: Arc>>>, cred_tx: Arc>>>, usb_event_forwarder_task: Arc>>, @@ -169,6 +180,12 @@ struct FlowControlService { hybrid_event_forwarder_task: Arc>>, } +impl + FlowControlService +{ + fn send_update(&self); +} + struct FlowControlDbusService { svc: Arc>>, @@ -177,8 +194,8 @@ struct FlowControlDbusService>>>, pin_tx: Arc>>>, - nfc_event_forwarder_task: Arc>>, hybrid_event_forwarder_task: Arc>>, + nfc_event_forwarder_task: Arc>>, usb_event_forwarder_task: Arc>>, } From f133a76c6d22530262d2ee5233da38274c6aed2b Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 04:49:02 -0400 Subject: [PATCH 16/33] wip: make CredentialService async trait --- credentialsd/src/credential_service/mod.rs | 48 ++++++++---- credentialsd/src/dbus/flow_control.rs | 88 +++++++--------------- credentialsd/src/main.rs | 1 - 3 files changed, 59 insertions(+), 78 deletions(-) diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 1f808c1..55485c1 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -11,6 +11,7 @@ use std::{ task::Poll, }; +use async_trait::async_trait; use futures_lite::{FutureExt, Stream, StreamExt}; use libwebauthn::{ self, @@ -69,6 +70,23 @@ 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 { /// Current request and channel to respond to caller. @@ -79,7 +97,7 @@ pub struct CredentialService { usb_handler: Mutex, } -impl +impl CredentialService { pub fn new(hybrid_handler: H, nfc_handler: N, usb_handler: U) -> Self { @@ -91,12 +109,15 @@ impl ManageDevice + for CredentialService +{ + async fn init_request( &self, request: &CredentialRequest, - requesting_app: Option, - window_handle: Option, tx: oneshot::Sender>, ) -> Result { let mut cred_request = self.ctx.lock().unwrap(); @@ -118,7 +139,7 @@ impl 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![ @@ -158,7 +179,7 @@ impl Pin + Send + 'static>> { let guard = self.ctx.lock().unwrap(); @@ -174,9 +195,7 @@ impl 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.lock().unwrap().start(request); @@ -190,9 +209,7 @@ impl 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.lock().unwrap().start(request); @@ -365,7 +382,7 @@ mod test { use super::{ hybrid::{test::DummyHybridHandler, HybridStateInternal}, nfc::InProcessNfcHandler, - AuthenticatorResponse, CredentialService, + AuthenticatorResponse, CredentialService, ManageDevice, }; #[test] @@ -395,7 +412,6 @@ mod test { hybrid_handler, nfc_handler, usb_handler, - Arc::new(ui_client), ))); let (mut flow_server, flow_client) = DummyFlowServer::new(cred_service.clone()); ui_server.init(flow_client).await; @@ -405,7 +421,7 @@ mod test { cred_service .lock() .await - .init_request(&request, None, None, request_tx) + .init_request(&request, request_tx) .await; user.request_hybrid_credential().await; tokio::time::timeout(Duration::from_secs(5), request_rx) diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index 7558dc9..5720266 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -25,6 +25,7 @@ use zbus::{ ObjectServer, }; +use crate::credential_service::ManageDevice; use crate::dbus::UiControlServiceClient; use crate::{ credential_service::{ @@ -39,12 +40,8 @@ use crate::{ 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, - N: NfcHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, ->( - credential_service: CredentialService, +pub async fn start_flow_control_service( + device_manager: M, ) -> zbus::Result<( Connection, Sender<( @@ -54,7 +51,7 @@ pub async fn start_flow_control_service< 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)? @@ -88,24 +85,15 @@ pub async fn start_flow_control_service< Ok((conn, initiator_tx)) } -async fn handle< - H: HybridHandler + Debug + Sync, - N: NfcHandler + Debug, - U: UsbHandler + Debug, - UC: UiController + Debug, ->( - svc: Arc>>, +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, Some(requesting_app), window_handle, request_tx) - .await?; + let request_id = svc.lock().await.init_request(&msg, request_tx).await?; let operation = match &msg { CredentialRequest::CreatePublicKeyCredentialRequest(_) => Operation::Create, CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, @@ -170,8 +158,8 @@ async fn handle< f } -struct FlowControlService { - svc: Arc>>, +struct FlowControlService { + svc: Arc>, signal_state: Arc>, pin_tx: Arc>>>, cred_tx: Arc>>>, @@ -180,14 +168,12 @@ struct FlowControlService>>, } -impl - FlowControlService -{ +impl FlowControlService { fn send_update(&self); } -struct FlowControlDbusService { - svc: Arc>>, +struct FlowControlDbusService { + svc: Arc>, signal_state: Arc>, @@ -210,12 +196,9 @@ struct FlowControlDbusService FlowControlDbusService +impl FlowControlDbusService where - H: HybridHandler + Debug + Send + Sync + 'static, - N: NfcHandler + Debug + Send + Sync + 'static, - U: UsbHandler + Debug + Send + Sync + 'static, - UC: UiController + Debug + Send + Sync + 'static, + M: ManageDevice + Debug + Send + Sync + 'static, { async fn subscribe( &self, @@ -257,7 +240,7 @@ where 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 { @@ -300,7 +283,7 @@ where 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 { @@ -350,7 +333,7 @@ where 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 { @@ -461,8 +444,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>, )>, } @@ -517,7 +500,7 @@ pub mod test { hybrid::{HybridHandler, HybridState}, nfc::{NfcHandler, NfcState}, usb::UsbHandler, - CredentialService, UiController, UsbState, + CredentialService, ManageDevice, UiController, UsbState, }; #[allow(clippy::enum_variant_names)] @@ -648,15 +631,12 @@ pub mod test { } #[derive(Debug)] - pub struct DummyFlowServer + pub struct DummyFlowServer where - H: HybridHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - U: UsbHandler + 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>>, @@ -664,13 +644,7 @@ pub mod test { hybrid_event_forwarder_task: Arc>>, } - impl< - H: HybridHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - U: UsbHandler + 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(); @@ -691,13 +665,11 @@ pub mod test { } } */ - pub fn new( - svc: Arc>>, - ) -> (Self, DummyFlowClient) { + pub fn new(svc: M) -> (Self, DummyFlowClient) { let (request_tx, request_rx) = mpsc::channel(32); let server = Self { rx: request_rx, - svc, + svc: Arc::new(AsyncMutex::new(svc)), bg_event_tx: None, pin_tx: Arc::new(AsyncMutex::new(None)), usb_event_forwarder_task: Arc::new(Mutex::new(None)), @@ -906,13 +878,7 @@ pub mod test { } } - impl< - H: HybridHandler + Debug + Send + Sync, - N: NfcHandler + Debug + Send + Sync, - U: UsbHandler + 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/main.rs b/credentialsd/src/main.rs index 971bbe6..4f0888e 100644 --- a/credentialsd/src/main.rs +++ b/credentialsd/src/main.rs @@ -38,7 +38,6 @@ async fn run() -> Result<(), Box> { InternalHybridHandler::new(), InProcessNfcHandler {}, InProcessUsbHandler {}, - Arc::new(ui_controller), ); let (_flow_control_conn, initiator) = dbus::start_flow_control_service(credential_service).await?; From f08f8d33478a9e60c74fce7bfe6e0d45831af791 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 06:25:59 -0400 Subject: [PATCH 17/33] wip: clean up flow control stuff --- credentialsd/src/credential_service/mod.rs | 13 ----- credentialsd/src/dbus/flow_control.rs | 42 ++++++++--------- credentialsd/src/dbus/ui_control.rs | 55 ++++++++++++++++------ credentialsd/src/gateway/mod.rs | 4 +- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 55485c1..24ae7ee 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -40,19 +40,6 @@ 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; - - fn initialize( - &self, - request: ViewRequest, - ) -> impl Future, Box>> + Send; -} - #[derive(Debug)] struct RequestContext { request: CredentialRequest, diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index 5720266..6115560 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -28,12 +28,8 @@ use zbus::{ use crate::credential_service::ManageDevice; 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}, }; @@ -75,11 +71,18 @@ pub async fn start_flow_control_service, 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 { - let ui_control_client = UiControlServiceClient::new(conn.clone()); - tx.send(handle(svc, ui_control_client, msg, requesting_app, window_handle).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)) @@ -125,7 +128,7 @@ async fn handle rx, Err(err) => { tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); @@ -133,7 +136,7 @@ async fn handle { let stream = svc.lock().await.get_hybrid_credential().await; @@ -169,7 +172,7 @@ struct FlowControlService { } impl FlowControlService { - fn send_update(&self); + fn send_update(&self) {} } struct FlowControlDbusService { @@ -435,7 +438,7 @@ enum SignalState { pub trait CredentialRequestController { async fn request_credential( &self, - requesting_app: Option, + requesting_app: RequestingApplication, request: CredentialRequest, window_handle: Option, ) -> Result; @@ -454,7 +457,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 { @@ -496,12 +499,7 @@ pub mod test { 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, ManageDevice, UiController, UsbState, - }; + use crate::credential_service::{hybrid::HybridState, nfc::NfcState, ManageDevice, UsbState}; #[allow(clippy::enum_variant_names)] #[derive(Debug)] @@ -665,11 +663,11 @@ pub mod test { } } */ - pub fn new(svc: M) -> (Self, DummyFlowClient) { + pub fn new(svc: Arc>) -> (Self, DummyFlowClient) { let (request_tx, request_rx) = mpsc::channel(32); let server = Self { rx: request_rx, - svc: Arc::new(AsyncMutex::new(svc)), + svc, bg_event_tx: None, pin_tx: Arc::new(AsyncMutex::new(None)), usb_event_forwarder_task: Arc::new(Mutex::new(None)), diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 9453a54..667c6be 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -1,6 +1,6 @@ //! These methods are called by the flow controller to launch the trusted UI. -use std::error::Error; +use std::{error::Error, future::Future}; use futures_lite::StreamExt; use tokio::sync::mpsc::{self, Receiver}; @@ -15,7 +15,18 @@ use credentialsd_common::{ server::ViewRequest, }; -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, + request: ViewRequest, + ) -> impl Future, Box>> + Send; +} #[proxy( gen_blocking = false, @@ -38,6 +49,25 @@ trait UiControlService2 { fn initialize(&self, request: ViewRequest) -> fdo::Result; } +pub struct Flow<'a> { + proxy: FlowObjectProxy<'a>, + pub ui_events_rx: Receiver, +} + +impl Flow<'_> { + 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", @@ -92,10 +122,7 @@ impl UiController for UiControlServiceClient { .map_err(|err| err.into()) } - async fn initialize( - &self, - request: ViewRequest, - ) -> Result, Box> { + async fn initialize(&self, request: ViewRequest) -> Result, Box> { let path = self.proxy2().await?.initialize(request).await?; tracing::debug!(?path, "Path initialized"); let flow_object = FlowObjectProxy::new(&self.conn, path).await?; @@ -106,7 +133,10 @@ impl UiController for UiControlServiceClient { }); // Mark as ready to receive messages. flow_object.start().await?; - Ok(from_ui_rx) + Ok(Flow { + proxy: flow_object, + ui_events_rx: from_ui_rx, + }) } } @@ -136,9 +166,7 @@ pub mod test { }; use credentialsd_common::{ - client::FlowController, - model::{BackendRequest, BackgroundEvent}, - server::ViewRequest, + client::FlowController, model::BackgroundEvent, server::ViewRequest, }; use futures_lite::StreamExt; use tokio::sync::{ @@ -146,6 +174,8 @@ pub mod test { Mutex as AsyncMutex, Notify, }; + use crate::dbus::ui_control::Flow; + use super::UiController; #[derive(Debug)] @@ -167,10 +197,7 @@ pub mod test { Ok(()) } - async fn initialize( - &self, - _request: ViewRequest, - ) -> Result, Box> { + async fn initialize(&self, _request: ViewRequest) -> Result, Box> { unimplemented!() } } diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index d982909..dbfd7d9 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 { From da36a20fe1685b48f57b78992bc54f05b3ee61be Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:11:43 -0400 Subject: [PATCH 18/33] daemon: Improve logging of trusted caller checks --- credentialsd/src/gateway/dbus.rs | 5 ++++- credentialsd/src/gateway/mod.rs | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 94f9846..92a9a37 100644 --- a/credentialsd/src/gateway/dbus.rs +++ b/credentialsd/src/gateway/dbus.rs @@ -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 dbfd7d9..c64fffc 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -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,11 @@ 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 true; } fn check_origin_from_app( From cc7c7206c791b1b2d714122c3fe278391704f956 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:11:43 -0400 Subject: [PATCH 19/33] squash --- credentialsd/src/credential_service/mod.rs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 24ae7ee..3c84cc0 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -3,9 +3,7 @@ pub mod nfc; pub mod usb; use std::{ - error::Error, fmt::Debug, - future::Future, pin::Pin, sync::{Arc, Mutex}, task::Poll, @@ -18,15 +16,9 @@ use libwebauthn::{ ops::webauthn::{GetAssertionResponse, MakeCredentialResponse}, }; use nfc::{NfcEvent, NfcHandler, NfcState, NfcStateInternal}; -use tokio::sync::{mpsc, oneshot}; - -use credentialsd_common::{ - model::{ - BackendRequest, Device, Error as CredentialServiceError, Operation, RequestId, - RequestingApplication, Transport, - }, - server::{ViewRequest, WindowHandle}, -}; +use tokio::sync::oneshot; + +use credentialsd_common::model::{Device, Error as CredentialServiceError, RequestId, Transport}; use crate::{ credential_service::{hybrid::HybridEvent, usb::UsbEvent}, @@ -392,7 +384,7 @@ 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( @@ -405,11 +397,12 @@ mod test { 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, request_tx) - .await; + .await + .unwrap(); user.request_hybrid_credential().await; tokio::time::timeout(Duration::from_secs(5), request_rx) .await From 5ce707de62e353531cde2d1610e90458156b9b69 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:11:43 -0400 Subject: [PATCH 20/33] wip: ui: Receive events from frontend --- credentialsd-ui/src/dbus.rs | 2 +- credentialsd/src/dbus/flow_control.rs | 49 +++++++++++++++++++++++---- credentialsd/src/dbus/ui_control.rs | 32 ++++++++++------- 3 files changed, 64 insertions(+), 19 deletions(-) diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index 7718847..c97ba83 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -118,7 +118,7 @@ impl CredentialPortalBackend { #[zbus(header)] header: Header<'_>, #[zbus(object_server)] object_server: &ObjectServer, request: ViewRequest, - ) -> fdo::Result { + ) -> fdo::Result> { let Some(sender) = header.sender() else { return Err(fdo::Error::BadAddress("Sender not found".to_string())); }; diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index 6115560..a6039d0 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -9,7 +9,7 @@ use credentialsd_common::model::{ RequestingApplication, WebAuthnError, }; use credentialsd_common::server::{ViewRequest, WindowHandle}; -use futures_lite::StreamExt; +use futures_lite::{Stream, StreamExt}; use tokio::sync::oneshot; use tokio::{ sync::{ @@ -26,6 +26,7 @@ use zbus::{ }; use crate::credential_service::ManageDevice; +use crate::dbus::ui_control::Flow; use crate::dbus::UiControlServiceClient; use crate::{ credential_service::{hybrid::HybridState, nfc::NfcState, UsbState}, @@ -128,7 +129,7 @@ async fn handle rx, Err(err) => { tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); @@ -136,16 +137,37 @@ async fn handle { - let stream = svc.lock().await.get_hybrid_credential().await; + let stream = svc + .lock() + .await + .get_hybrid_credential() + .await + .map(|state| BackgroundEvent::HybridQrStateChanged(state.into())); + let flow = flow.clone(); + forward_background_events(flow, stream); } BackendRequest::StartNfcDiscovery => { - // let stream = self.get_nfc_credential().await; + 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 stream = self.get_usb_credential().await; + let stream = svc + .lock() + .await + .get_usb_credential() + .await + .map(|usb_state| BackgroundEvent::UsbStateChanged(usb_state.into())); + let flow = flow.clone(); + forward_background_events(flow, stream); } BackendRequest::EnterClientPin(_) => todo!(), BackendRequest::SelectCredential(_) => todo!(), @@ -161,6 +183,21 @@ async fn handle + 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>, diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 667c6be..7c429cd 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -1,9 +1,12 @@ //! These methods are called by the flow controller to launch the trusted UI. -use std::{error::Error, future::Future}; +use std::{error::Error, future::Future, sync::Arc}; use futures_lite::StreamExt; -use tokio::sync::mpsc::{self, Receiver}; +use tokio::sync::{ + mpsc::{self, Receiver}, + Mutex as AsyncMutex, +}; use zbus::{ fdo, proxy, zvariant::{ObjectPath, OwnedObjectPath}, @@ -25,7 +28,7 @@ pub trait UiController { fn initialize( &self, request: ViewRequest, - ) -> impl Future, Box>> + Send; + ) -> impl Future>> + Send; } #[proxy( @@ -49,13 +52,18 @@ trait UiControlService2 { fn initialize(&self, request: ViewRequest) -> fdo::Result; } -pub struct Flow<'a> { - proxy: FlowObjectProxy<'a>, - pub ui_events_rx: Receiver, +#[derive(Clone, Debug)] +pub struct Flow { + proxy: Arc>, + ui_events_rx: Arc>>, } -impl Flow<'_> { - async fn send_state_update(&self, event: BackgroundEvent) -> Result<(), ()> { +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) => { @@ -122,7 +130,7 @@ impl UiController for UiControlServiceClient { .map_err(|err| err.into()) } - async fn initialize(&self, request: ViewRequest) -> Result, Box> { + async fn initialize(&self, request: ViewRequest) -> Result> { let path = self.proxy2().await?.initialize(request).await?; tracing::debug!(?path, "Path initialized"); let flow_object = FlowObjectProxy::new(&self.conn, path).await?; @@ -134,8 +142,8 @@ impl UiController for UiControlServiceClient { // Mark as ready to receive messages. flow_object.start().await?; Ok(Flow { - proxy: flow_object, - ui_events_rx: from_ui_rx, + proxy: Arc::new(flow_object), + ui_events_rx: Arc::new(AsyncMutex::new(from_ui_rx)), }) } } @@ -197,7 +205,7 @@ pub mod test { Ok(()) } - async fn initialize(&self, _request: ViewRequest) -> Result, Box> { + async fn initialize(&self, _request: ViewRequest) -> Result> { unimplemented!() } } From 31aa360d5811480cbc76047e1a7a3ab71db36e4f Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:11:43 -0400 Subject: [PATCH 21/33] ui: start portal impl --- credentialsd-ui/src/main.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/credentialsd-ui/src/main.rs b/credentialsd-ui/src/main.rs index d1482ba..6379461 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::dbus::UiControlService; +use crate::dbus::{CredentialPortalBackend, UiControlService}; fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); @@ -23,7 +23,10 @@ async fn run() -> Result<(), Box> { 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()? From a8541e478d25f576cbf9cfbfc6416a8c5c646c1c Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:11:43 -0400 Subject: [PATCH 22/33] abandon: add xdg-desktop-portal build as trusted caller --- .vscode/launch.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 42702bf..12acd15 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "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", + "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": [ From 1ea199abdfed52d630d8b1b90452f4861a42748b Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:20:40 -0400 Subject: [PATCH 23/33] serve credential portal backend and credentialsd-ui simultaneously --- credentialsd-ui/src/dbus.rs | 2 +- credentialsd-ui/src/main.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index c97ba83..6a5d704 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -107,7 +107,7 @@ impl UiControlService { } pub struct CredentialPortalBackend { - pub request_tx: Sender, + pub request_tx: Sender<(ViewRequest, Arc>)>, } /// These methods are called by the credential service to control the UI. diff --git a/credentialsd-ui/src/main.rs b/credentialsd-ui/src/main.rs index 6379461..5afe797 100644 --- a/credentialsd-ui/src/main.rs +++ b/credentialsd-ui/src/main.rs @@ -32,6 +32,10 @@ async fn run() -> Result<(), Box> { let _server_conn = zbus::connection::Builder::session()? .name(service)? .serve_at(path, interface)? + .serve_at( + "/xyz/iinuwa/credentialsd/UiControl", + portal_backend_interface, + )? .build() .await?; println!(" ✅"); From 4ddf577ed9829a3aef3b6939257b88fb91593824 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:13:34 -0500 Subject: [PATCH 24/33] squash to trusted callers --- credentialsd/src/gateway/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index c64fffc..f01a8b1 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -310,8 +310,10 @@ async fn should_trust_app_id(pid: u32) -> bool { 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; } - return true; } fn check_origin_from_app( From a56b19ccf5b11d36ad728ba448c9054ecde3240f Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:13:34 -0500 Subject: [PATCH 25/33] ui: change name of impl portal interface --- credentialsd-ui/src/main.rs | 5 +---- credentialsd/src/dbus/ui_control.rs | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/credentialsd-ui/src/main.rs b/credentialsd-ui/src/main.rs index 5afe797..6aa2aa6 100644 --- a/credentialsd-ui/src/main.rs +++ b/credentialsd-ui/src/main.rs @@ -32,10 +32,7 @@ async fn run() -> Result<(), Box> { let _server_conn = zbus::connection::Builder::session()? .name(service)? .serve_at(path, interface)? - .serve_at( - "/xyz/iinuwa/credentialsd/UiControl", - portal_backend_interface, - )? + .serve_at("/org/freedesktop/portal/desktop", portal_backend_interface)? .build() .await?; println!(" ✅"); diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 7c429cd..3fba965 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -45,7 +45,7 @@ trait UiControlService { #[proxy( gen_blocking = false, interface = "org.freedesktop.impl.portal.experimental.Credential", - default_service = "org.freedesktop.impl.portal.experimental.Credential", + default_service = "xyz.iinuwa.credentialsd.UiControl", default_path = "/org/freedesktop/portal/desktop" )] trait UiControlService2 { From bdef84812a8aa7e7598fa6b234dbdab6a6f972e3 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 08:13:34 -0500 Subject: [PATCH 26/33] ui: send events to frontend --- credentialsd-ui/src/dbus.rs | 80 +++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index 6a5d704..f220064 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -1,6 +1,11 @@ use std::sync::Arc; -use async_std::{channel::Sender, stream::StreamExt, sync::Mutex as AsyncMutex}; +use async_std::{ + channel::{self, Receiver, Sender}, + stream::StreamExt, + sync::Mutex as AsyncMutex, + task::JoinHandle, +}; use zbus::{ Connection, ObjectServer, fdo, interface, message::Header, names::OwnedUniqueName, object_server::SignalEmitter, proxy, zvariant::ObjectPath, @@ -130,6 +135,8 @@ impl CredentialPortalBackend { request, 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"); @@ -139,16 +146,60 @@ impl CredentialPortalBackend { pub struct FlowObject { request: ViewRequest, - pub request_tx: Sender, + 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(&self) -> fdo::Result<()> { - if let Err(err) = self.request_tx.send(self.request.clone()).await { + 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.into_owned(); + let ui_events_task = async_std::task::spawn(async move { + while let Ok(ui_event) = ui_events_rx.recv().await { + 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 = ( + self.request.clone(), + 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())); } @@ -156,14 +207,26 @@ impl FlowObject { } async fn notify_state_changed(&self, event: BackgroundEvent) -> fdo::Result<()> { - todo!() + 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( - &self, + &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?; @@ -172,5 +235,8 @@ impl FlowObject { } #[zbus(signal)] - async fn user_interacted(emitter: SignalEmitter<'_>) -> zbus::Result<()>; + async fn user_interacted( + emitter: SignalEmitter<'_>, + event: &BackendRequest, + ) -> zbus::Result<()>; } From 6075cb9e3e7d11d6fbec00f0956c5d623a4a471d Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 11:07:46 -0500 Subject: [PATCH 27/33] It works!!! --- .vscode/launch.json | 4 +- credentialsd-ui/src/dbus.rs | 13 ++++-- credentialsd/src/dbus/flow_control.rs | 65 ++++++++++++++++++++++----- credentialsd/src/dbus/ui_control.rs | 5 ++- credentialsd/src/gateway/dbus.rs | 2 +- 5 files changed, 70 insertions(+), 19 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 12acd15..f5eb641 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -11,7 +11,7 @@ "program": "${workspaceFolder}/build/credentialsd/src/credentialsd", "args": [], "env": { - "RUST_LOG": "credentialsd=debug,libwebauthn=debug,libwebauthn::webauthn=debug,libwebauthn=warn,libwebauthn::proto::ctap2::preflight=debug,libwebauthn::transport::channel=debug,zbus::object_server::debug,zbus=debug", + "RUST_LOG": "credentialsd=trace,libwebauthn=debug,libwebauthn::webauthn=debug,libwebauthn=warn,libwebauthn::proto::ctap2::preflight=debug,libwebauthn::transport::channel=debug,zbus::object_server::debug,zbus=debug", "CREDSD_TRUSTED_CALLERS": "/usr/bin/python3.14,/home/isaiah/Development/portal/xdg-desktop-portal/build/src/xdg-desktop-portal", "CREDSD_TRUSTED_APP_IDS": "app:xyz.iinuwa.credentialsd.DemoCredentialsUi", }, @@ -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/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index f220064..b90e984 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -7,8 +7,12 @@ use async_std::{ task::JoinHandle, }; use zbus::{ - Connection, ObjectServer, fdo, interface, message::Header, names::OwnedUniqueName, - object_server::SignalEmitter, proxy, zvariant::ObjectPath, + Connection, ObjectServer, fdo, interface, + message::Header, + names::{BusName, OwnedUniqueName}, + object_server::SignalEmitter, + proxy, + zvariant::ObjectPath, }; use credentialsd_common::{ @@ -168,9 +172,12 @@ impl FlowObject { }; self.bg_events_tx = Some(bg_events_tx); - let emitter = emitter.into_owned(); + 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 diff --git a/credentialsd/src/dbus/flow_control.rs b/credentialsd/src/dbus/flow_control.rs index a6039d0..f735ba7 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -1,6 +1,7 @@ //! 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; @@ -137,6 +138,8 @@ async fn handle>>> = 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 => { @@ -147,7 +150,7 @@ async fn handle { let stream = svc @@ -160,18 +163,56 @@ async fn handle { - let stream = svc - .lock() - .await - .get_usb_credential() - .await - .map(|usb_state| BackgroundEvent::UsbStateChanged(usb_state.into())); + 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_events(flow, stream); + 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; } - BackendRequest::EnterClientPin(_) => todo!(), - BackendRequest::SelectCredential(_) => todo!(), - BackendRequest::CancelRequest => todo!(), } } }); @@ -183,7 +224,7 @@ async fn handle + Send + Unpin + 'static, ) { diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 3fba965..302ae33 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -137,7 +137,7 @@ impl UiController for UiControlServiceClient { 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); + _ = forward_ui_events(ui_event_stream, from_ui_tx).await; }); // Mark as ready to receive messages. flow_object.start().await?; @@ -152,13 +152,16 @@ 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(()) } diff --git a/credentialsd/src/gateway/dbus.rs b/credentialsd/src/gateway/dbus.rs index 92a9a37..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, From c63fe4bf4d3fecb2396293605777c5e45487e274 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 13:20:41 -0500 Subject: [PATCH 28/33] Export docs --- doc/xyz.iinuwa.credentialsd.FlowControl.xml | 55 +++++++++++++++++++++ doc/xyz.iinuwa.credentialsd.UiControl.xml | 45 +++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 doc/xyz.iinuwa.credentialsd.FlowControl.xml create mode 100644 doc/xyz.iinuwa.credentialsd.UiControl.xml 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..5941d59 --- /dev/null +++ b/doc/xyz.iinuwa.credentialsd.UiControl.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 3c3bb9aaf09114ce87d73ab0ca0d20ba959047c2 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 14:38:30 -0500 Subject: [PATCH 29/33] wip: move backend request to top-level parameters --- credentialsd-common/src/model.rs | 13 +++- credentialsd-ui/src/dbus.rs | 63 +++++++++++++++-- credentialsd-ui/src/gui/view_model/mod.rs | 8 +-- credentialsd/src/credential_service/mod.rs | 2 +- credentialsd/src/dbus/flow_control.rs | 62 +++++++++++------ credentialsd/src/dbus/ui_control.rs | 80 +++++++++++++++++++--- credentialsd/src/gateway/util.rs | 5 +- credentialsd/src/webauthn.rs | 8 +-- 8 files changed, 192 insertions(+), 49 deletions(-) diff --git a/credentialsd-common/src/model.rs b/credentialsd-common/src/model.rs index aa5319a..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)] diff --git a/credentialsd-ui/src/dbus.rs b/credentialsd-ui/src/dbus.rs index b90e984..a588a83 100644 --- a/credentialsd-ui/src/dbus.rs +++ b/credentialsd-ui/src/dbus.rs @@ -17,8 +17,11 @@ use zbus::{ use credentialsd_common::{ client::FlowController, - model::{BackendRequest, BackgroundEvent, Device, RequestId}, - server::ViewRequest, + model::{ + BackendRequest, BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId, + RequestingApplication, + }, + server::{ViewRequest, WindowHandle}, }; use crate::client::{DbusCredentialClient, FlowControlClient}; @@ -119,6 +122,20 @@ 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 { @@ -126,17 +143,38 @@ impl CredentialPortalBackend { &self, #[zbus(header)] header: Header<'_>, #[zbus(object_server)] object_server: &ObjectServer, - request: ViewRequest, + 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 + 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 { - request, + ui_context, request_tx: self.request_tx.clone(), return_address: sender.to_owned().into(), ui_events_forwarder_task: None, @@ -149,7 +187,7 @@ impl CredentialPortalBackend { } pub struct FlowObject { - request: ViewRequest, + ui_context: UiContext, pub request_tx: Sender<(ViewRequest, Arc>)>, pub return_address: OwnedUniqueName, ui_events_forwarder_task: Option>, @@ -203,7 +241,18 @@ impl FlowObject { })? .to_string(); let req = ( - self.request.clone(), + 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() { diff --git a/credentialsd-ui/src/gui/view_model/mod.rs b/credentialsd-ui/src/gui/view_model/mod.rs index 073a321..8eb8bcc 100644 --- a/credentialsd-ui/src/gui/view_model/mod.rs +++ b/credentialsd-ui/src/gui/view_model/mod.rs @@ -78,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") } @@ -91,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 diff --git a/credentialsd/src/credential_service/mod.rs b/credentialsd/src/credential_service/mod.rs index 3c84cc0..92c5c12 100644 --- a/credentialsd/src/credential_service/mod.rs +++ b/credentialsd/src/credential_service/mod.rs @@ -416,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 f735ba7..39b6f39 100644 --- a/credentialsd/src/dbus/flow_control.rs +++ b/credentialsd/src/dbus/flow_control.rs @@ -6,8 +6,8 @@ use std::{collections::VecDeque, fmt::Debug, sync::Arc}; use async_trait::async_trait; use credentialsd_common::model::{ - BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, Operation, RequestId, - RequestingApplication, WebAuthnError, + BackendRequest, BackgroundEvent, Device, Error as CredentialServiceError, Operation, + PortalBackendOptions, RequestId, RequestingApplication, WebAuthnError, }; use credentialsd_common::server::{ViewRequest, WindowHandle}; use futures_lite::{Stream, StreamExt}; @@ -100,37 +100,59 @@ async fn handle Operation::Create, - CredentialRequest::GetPublicKeyCredentialRequest(_) => Operation::Get, + 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 view_request = ViewRequest { - operation, - id: request_id, - rp_id, - initial_devices, - requesting_app, - window_handle: window_handle.into(), - }; - /* - let launch_ui_response = self - .ui_control_client - .launch_ui(view_request) + 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 - .map_err(|err| err.to_string()); - */ - - let flow = match ui_control_client.initialize(view_request).await { + { Ok(rx) => rx, Err(err) => { tracing::error!("Failed to launch UI for credentials: {err}. Cancelling request."); diff --git a/credentialsd/src/dbus/ui_control.rs b/credentialsd/src/dbus/ui_control.rs index 302ae33..77ad4cf 100644 --- a/credentialsd/src/dbus/ui_control.rs +++ b/credentialsd/src/dbus/ui_control.rs @@ -9,13 +9,13 @@ use tokio::sync::{ }; use zbus::{ fdo, proxy, - zvariant::{ObjectPath, OwnedObjectPath}, + zvariant::{ObjectPath, Optional, OwnedObjectPath}, Connection, }; use credentialsd_common::{ - model::{BackendRequest, BackgroundEvent, RequestId}, - server::ViewRequest, + model::{BackendRequest, BackgroundEvent, Device, Operation, PortalBackendOptions, RequestId}, + server::{ViewRequest, WindowHandle}, }; /// Used by the credential service to control the UI. @@ -27,7 +27,16 @@ pub trait UiController { fn initialize( &self, - request: ViewRequest, + 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; } @@ -49,7 +58,19 @@ trait UiControlService { default_path = "/org/freedesktop/portal/desktop" )] trait UiControlService2 { - fn initialize(&self, request: ViewRequest) -> fdo::Result; + 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)] @@ -130,8 +151,35 @@ impl UiController for UiControlServiceClient { .map_err(|err| err.into()) } - async fn initialize(&self, request: ViewRequest) -> Result> { - let path = self.proxy2().await?.initialize(request).await?; + 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); @@ -177,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::{ @@ -208,7 +258,19 @@ pub mod test { Ok(()) } - async fn initialize(&self, _request: ViewRequest) -> Result> { + 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!() } } 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/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(), From 846b582f107e72771a2d8c6c9528af513cddcfbe Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 22:14:46 -0500 Subject: [PATCH 30/33] wip: start flattening BackgroundEvent and BackendRequest --- credentialsd-common/src/server.rs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/credentialsd-common/src/server.rs b/credentialsd-common/src/server.rs index 66815d6..b071dc2 100644 --- a/credentialsd-common/src/server.rs +++ b/credentialsd-common/src/server.rs @@ -19,6 +19,45 @@ 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; } From e75a70ad341e7f20e7ae59f18224b9d5ad2181a5 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 22:14:46 -0500 Subject: [PATCH 31/33] ui: update docs --- doc/xyz.iinuwa.credentialsd.UiControl.xml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/doc/xyz.iinuwa.credentialsd.UiControl.xml b/doc/xyz.iinuwa.credentialsd.UiControl.xml index 5941d59..e2357b0 100644 --- a/doc/xyz.iinuwa.credentialsd.UiControl.xml +++ b/doc/xyz.iinuwa.credentialsd.UiControl.xml @@ -37,7 +37,16 @@ - + + + + + + + + + + From 128e494e9fd41515acd2e2ea8c5a9ae527d04852 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Thu, 23 Apr 2026 22:14:46 -0500 Subject: [PATCH 32/33] ui: add TODO to clean up request objects --- credentialsd-ui/src/gui/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/credentialsd-ui/src/gui/mod.rs b/credentialsd-ui/src/gui/mod.rs index f8dacba..e7e3e10 100644 --- a/credentialsd-ui/src/gui/mod.rs +++ b/credentialsd-ui/src/gui/mod.rs @@ -43,6 +43,7 @@ fn run_gui(flow_controller: Arc>, request: ViewReq tracing::debug!("Finishing user request."); // If cancellation fails, that's fine. 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); From 77543e0294fa7fc49a8cef92ed323bb53da46f36 Mon Sep 17 00:00:00 2001 From: Isaiah Inuwa Date: Fri, 24 Apr 2026 22:49:16 -0500 Subject: [PATCH 33/33] ui: Add portal configuration file --- meson.build | 9 +++++++-- portal/credentialsd.portal | 3 +++ portal/meson.build | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 portal/credentialsd.portal create mode 100644 portal/meson.build 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