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