From f08c72f01db16bfc3e23a1e2b59bcacc43c24b67 Mon Sep 17 00:00:00 2001 From: phelix001 Date: Mon, 16 Feb 2026 16:41:44 -0500 Subject: [PATCH 1/2] 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 2/2] 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