Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 16 additions & 42 deletions webext/add-on/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 21 additions & 14 deletions webext/add-on/content-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Expand Down
2 changes: 1 addition & 1 deletion webext/add-on/content-main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion webext/add-on/manifest.chromium.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
10 changes: 4 additions & 6 deletions webext/add-on/manifest.firefox.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -21,8 +21,7 @@
"content_scripts": [
{
"matches": [
"https://webauthn.io/*",
"https://demo.yubico.com/*"
"<all_urls>"
],
"js": [
"content-bridge.js"
Expand All @@ -32,8 +31,7 @@
},
{
"matches": [
"https://webauthn.io/*",
"https://demo.yubico.com/*"
"<all_urls>"
],
"js": [
"content-main.js"
Expand All @@ -48,4 +46,4 @@
"permissions": [
"nativeMessaging"
]
}
}
121 changes: 100 additions & 21 deletions webext/app/credential_manager_shim.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from enum import Enum
import json
import logging
import re
import secrets
import signal
import struct
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")


Expand Down