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@/" ] +}