diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe5e044..b98337f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ run xdg-desktop-portal while we work on upstreaming the changes. - ui: Reorganize credential selection screen to promote hybrid QR code. - ui: Convert UI templates to Blueprint. - webext: Ignore conditional mediation requests. +- webext: Fix request routing issues when multiple tabs are active. +- webext: Only start extension during WebAuthn calls for performance. +- webext: Load Firefox extension on all sites # [0.2.0] - 2025-02-18 diff --git a/webext/add-on/background.js b/webext/add-on/background.js index 053f03a7..afca0c84 100644 --- a/webext/add-on/background.js +++ b/webext/add-on/background.js @@ -9,53 +9,27 @@ const browserAPI = globalThis.browser || globalThis.chrome; -let contentPort; -let nativePort; - function connected(port) { - console.log('[credentialsd] received connection from content script'); - contentPort = port; - - // 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('[credentialsd] connected to native app'); - - 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); - } - }); + const portId = port.sender.tab.id; + console.log('[credentialsd] received connection from content script', portId); + port.onMessage.addListener((msg) => rcvFromContent(msg, port)); } -function rcvFromContent(msg) { - const { requestId, cmd, options } = msg; - const origin = contentPort.sender.origin; - const topOrigin = new URL(contentPort.sender.tab.url).origin; +async function rcvFromContent(msg, port) { + const { requestId, cmd, options = null } = msg; + console.debug('[credentialsd] forwarding', cmd, 'to native app'); - if (options) { - console.debug('[credentialsd] forwarding', cmd, 'to native app'); - nativePort.postMessage({ requestId, cmd, options, origin, topOrigin }); - } else { - console.debug('[credentialsd] forwarding', cmd, '(no options) to native app'); - nativePort.postMessage({ requestId, cmd, origin, topOrigin }); - } -} + const origin = port.sender.origin; + const topOrigin = new URL(port.sender.tab.url).origin; + const request = { requestId, cmd, options, origin, topOrigin }; -function rcvFromNative(msg) { - console.log('[credentialsd] received from native, forwarding to content'); - contentPort.postMessage(msg); + try { + const response = await browserAPI.runtime.sendNativeMessage('xyz.iinuwa.credentialsd_helper', request); + console.log('[credentialsd] received from native, forwarding to content'); + port.postMessage(response); + } catch (error) { + console.error('[credentialsd] Error sending message to native app', error.message); + } } // Listen for connections from content script diff --git a/webext/add-on/content-bridge.js b/webext/add-on/content-bridge.js index d17bada8..2d96d1b5 100644 --- a/webext/add-on/content-bridge.js +++ b/webext/add-on/content-bridge.js @@ -7,28 +7,35 @@ */ const browserAPI = globalThis.browser || globalThis.chrome; -const port = browserAPI.runtime.connect({ name: 'credentialsd-helper' }); +let mainPort = null; -// 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, - }, '*'); -}); +function connectToBackground() { + mainPort = browserAPI.runtime.connect({ name: 'credentialsd-helper' }); + // Forward responses from background back to page context + mainPort.onMessage.addListener((msg) => { + const { requestId, data = undefined, error = undefined } = msg; + window.postMessage({ + type: 'credentialsd-response', + requestId, + data, + error, + }, '*'); + }); -port.onDisconnect.addListener(() => { - console.warn('[credentialsd] background port disconnected'); -}); + mainPort.onDisconnect.addListener(() => { + console.warn('[credentialsd] background port disconnected'); + mainPort = null; + setTimeout(connectToBackground, 1000); + }); + return mainPort; +} // 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 port = mainPort || connectToBackground(); const { requestId, cmd, options } = event.data; port.postMessage({ requestId, cmd, options }); }); diff --git a/webext/add-on/content-main.js b/webext/add-on/content-main.js index 1cdbb3a8..8e3e3450 100644 --- a/webext/add-on/content-main.js +++ b/webext/add-on/content-main.js @@ -17,7 +17,7 @@ window.addEventListener('message', (event) => { if (event.source !== window) return; if (event.data?.type !== 'credentialsd-response') return; - const { requestId, data, error } = event.data; + const { requestId, data = undefined, error = undefined } = event.data; const request = pendingRequests[requestId]; if (!request) return; delete pendingRequests[requestId]; diff --git a/webext/add-on/manifest.chromium.json b/webext/add-on/manifest.chromium.json index 6019d9bd..3fe32aaf 100644 --- a/webext/add-on/manifest.chromium.json +++ b/webext/add-on/manifest.chromium.json @@ -2,7 +2,7 @@ "description": "Helper to integrate credentialsd with the browser", "manifest_version": 3, "name": "credentialsd-helper", - "version": "0.1.0", + "version": "0.2.0", "icons": { "48": "icons/logo.svg" }, diff --git a/webext/add-on/manifest.firefox.json b/webext/add-on/manifest.firefox.json index cbc62d0d..04e78665 100644 --- a/webext/add-on/manifest.firefox.json +++ b/webext/add-on/manifest.firefox.json @@ -2,7 +2,7 @@ "description": "Helper to integrate credentialsd with the browser", "manifest_version": 3, "name": "credentialsd-helper", - "version": "0.1.0", + "version": "0.2.0", "icons": { "48": "icons/logo.svg" }, @@ -21,8 +21,7 @@ "content_scripts": [ { "matches": [ - "https://webauthn.io/*", - "https://demo.yubico.com/*" + "" ], "js": [ "content-bridge.js" @@ -32,8 +31,7 @@ }, { "matches": [ - "https://webauthn.io/*", - "https://demo.yubico.com/*" + "" ], "js": [ "content-main.js" @@ -48,4 +46,4 @@ "permissions": [ "nativeMessaging" ] -} \ No newline at end of file +} diff --git a/webext/app/credential_manager_shim.py b/webext/app/credential_manager_shim.py index abdbf6b8..e1b5fff2 100755 --- a/webext/app/credential_manager_shim.py +++ b/webext/app/credential_manager_shim.py @@ -8,6 +8,7 @@ from enum import Enum import json import logging +import re import secrets import signal import struct @@ -52,6 +53,58 @@ def getMessage(): raise e +class PortalError(Exception): + def from_string(s: str): + m = re.match(r"xyz.iinuwa.credentialsd.(\w+): (?:no description|(.*))", s) + if m: + (name, message) = m.groups() + match name: + case "AbortError": + return AbortError + case "ConstraintError": + return ConstraintError + case "InvalidStateError": + return InvalidStateError + case "NotSupportedError": + return NotSupportedError + case "SecurityError": + return SecurityError + case "NotAllowedError": + return NotAllowedError + case "TypeError": + return TypeError + case _: + return PortalError() + + +class AbortError(PortalError): + pass + + +class ConstraintError(PortalError): + pass + + +class InvalidStateError(PortalError): + pass + + +class NotSupportedError(PortalError): + pass + + +class SecurityError(PortalError): + pass + + +class NotAllowedError(PortalError): + pass + + +class TypeError(PortalError): + pass + + # Encode a message for transmission, # given its content. def encodeMessage(messageContent): @@ -113,14 +166,15 @@ def message_handler(msg: Message): if code == 0: future.set_result(value) elif code == 1: - future.set_exception(Exception("Portal request cancelled")) + logging.error("Request cancelled") + future.set_exception(AbortError()) raise elif code == 2 and "error" in value: - future.set_exception( - Exception(f"Portal returned an error: {value['error'].value}") - ) + logging.error(value["error"].value) + future.set_exception(PortalError.from_string(value["error"].value)) else: - future.set_exception(Exception("Portal returned an unknown error")) + logging.error("Portal returned an unknown error") + future.set_exception(PortalError()) return True def when_done(_fut): @@ -459,9 +513,8 @@ async def get_interface(): return INTERFACE -async def run(cmd, options, origin, top_origin): +async def run(interface, cmd, options, origin, top_origin): logging.debug("Executing command") - interface = await get_interface() if cmd == "create": if "publicKey" in options: @@ -519,22 +572,48 @@ async def run(cmd, options, origin, top_origin): async def main(): logging.info("starting credential_manager_shim") + cancel_task = asyncio.create_task(quit.wait()) - while not quit.is_set(): - logging.debug("starting event loop message") - receivedMessage = getMessage() - request_id = receivedMessage["requestId"] - try: - cmd = receivedMessage["cmd"] - options = receivedMessage.get("options", None) - origin = receivedMessage["origin"] - top_origin = receivedMessage["topOrigin"] - auth_data = await run(cmd, options, origin, top_origin) + receivedMessage = getMessage() + request_id = receivedMessage["requestId"] + try: + interface = await get_interface() + cmd = receivedMessage["cmd"] + options = receivedMessage.get("options", None) + origin = receivedMessage["origin"] + top_origin = receivedMessage["topOrigin"] + credentialsd_task = asyncio.create_task( + run(interface, cmd, options, origin, top_origin) + ) + timeout = options.get("timeout", 5 * 60 * 1000) // 1000 + done, pending = await asyncio.wait( + {credentialsd_task, cancel_task}, + timeout=timeout, + return_when=asyncio.FIRST_COMPLETED, + ) + if credentialsd_task in done: + logging.info("got a response from credentialsd") + auth_data = credentialsd_task.result() sendMessage(encodeMessage({"requestId": request_id, "data": auth_data})) - except Exception as e: - logging.error("Failed to send message", exc_info=e) - sendMessage(encodeMessage({"requestId": request_id, "error": str(e)})) - logging.debug("Sent error message") + elif cancel_task in done: + logging.info("cancelled") + raise asyncio.CancelledError("Cancelled") + else: + logging.info("timed out") + raise TimeoutError("Timed out") + interface.bus.disconnect() + + except Exception as e: + logging.error("Failed to send message", exc_info=e) + sendMessage( + encodeMessage( + { + "requestId": request_id, + "error": {"name": type(e).__name__, "message": str(e)}, + } + ) + ) + logging.debug("Sent error message") logging.info("quitting credential_manager_shim")