diff --git a/extension/authenticator.html b/extension/authenticator.html index 5d6c2bb..940da53 100644 --- a/extension/authenticator.html +++ b/extension/authenticator.html @@ -5,36 +5,58 @@ WebAuthnLinux Authenticator -

WebAuthnLinux Authenticator

-
Waiting for requests...
-
- +
+
- +

Verify it's you

+

Touch the fingerprint reader…

+ - - \ No newline at end of file + diff --git a/extension/authenticator.js b/extension/authenticator.js index d835908..9005b62 100644 --- a/extension/authenticator.js +++ b/extension/authenticator.js @@ -2,7 +2,7 @@ * WebAuthnLinux Extension Authenticator Logic * * Original: Grammatopoulos Athanasios Vasileios (GramThanos) - * Modifications by Samveen + * Modifications by (see contributors) */ const BUILD_VERSION = "0.9.10"; console.log(`[Auth] Loaded WebAuthnLinux. Version: ${BUILD_VERSION}`); @@ -10,6 +10,10 @@ console.log(`[Auth] Loaded WebAuthnLinux. Version: ${BUILD_VERSION}`); // Polyfill window.authnTools = window.authnTools || {}; +const statusEl = document.getElementById('status'); +const iconEl = document.getElementById('icon'); +const retryBtn = document.getElementById('retry-btn'); + const initAuthenticator = async () => { const authenticator = new window.AuthnDevice(); // Override storage handler to use chrome.storage.local @@ -21,8 +25,6 @@ const initAuthenticator = async () => { console.log('Credentials saved to local storage'); return data; } else { - //const result = await chrome.storage.local.get('system_credentials'); - //return result.system_credentials || []; return this.storage; } }; @@ -76,14 +78,12 @@ const debugLog = (message, ...args) => { // NATIVE MESSAGING INTEGRATION // Instead of navigator.credentials, we talk to the native python host const getMasterKeyFromNativeHost = async () => { - const statusDiv = document.getElementById('status'); - statusDiv.textContent = "Connecting to System Fingerprint Service..."; console.log('[Auth] Connecting to Fingerprint Service: io.github.samveen.webauthnlinux'); return new Promise((resolve, reject) => { try { - // "webauthnlinux@samveen.github.io" must be allowed in the host manifest - // Host name defined in install.sh is "io.github.samveen.webauthnlinux" + // Host name defined in install.sh (must match exactly, and must + // be listed in that host's "allowed_extensions" manifest entry) const hostName = "io.github.samveen.webauthnlinux"; // Send unlock command @@ -98,14 +98,13 @@ const getMasterKeyFromNativeHost = async () => { debugLog('[Auth] Native Response:', response); if (response && response.status === "success" && response.key) { - statusDiv.textContent = "Fingerprint Verified (Native)."; + statusEl.textContent = "Fingerprint verified."; resolve("NativeSecure-" + response.key); } else { const msg = response ? response.message : "Unknown Error"; reject(new Error("Fingerprint Failed: " + msg)); } }); - } catch (e) { reject(e); } @@ -114,26 +113,92 @@ const getMasterKeyFromNativeHost = async () => { let deviceInstance = null; -const handleMessage = async (request, sender, sendResponse) => { +const MAX_ATTEMPTS = 5; +const RETRY_DELAY_MS = 500; +const DEVICE_BUSY_RETRY_DELAY_MS = 10000; +let countdownTimer = null; + +const clearCountdown = () => { + if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } +}; + +// fprintd reports this when the device is still claimed by another process +// (a lingering session, a stuck previous read, etc). Retrying immediately +// just fails again in a tight loop, so this case gets a longer backoff. +const isNoMatchError = (message) => /no-match/i.test(message || ''); + +// fprintd reports this when the device is still claimed by another process +// (a lingering session, a stuck previous read, etc). Retrying immediately +// just fails again in a tight loop, so this case gets a longer backoff. +const isDeviceBusyError = (message) => /AlreadyInUse|already claimed/i.test(message || ''); + +// Waits delayMs, updating statusEl with a live countdown, e.g. "No match - retrying in 2..." +const countdown = (message, delayMs = RETRY_DELAY_MS) => new Promise((resolve) => { + let msLeft = delayMs; + statusEl.classList.add('error'); + console.log(message); + statusEl.textContent = "${message} - Retrying..."; + countdownTimer = setInterval(() => { + msLeft -= 100; + let secondsLeft = Math.ceil(msLeft / 1000); + if (secondsLeft > 0) { + statusEl.textContent = `${message} - Waiting...`; + } else { + statusEl.textContent = "${message} - Retrying..."; + clearCountdown(); + resolve(); + } + }, 100); +}); + +// Retries the fingerprint read itself (no match, timeout, sensor error, etc.) +// up to MAX_ATTEMPTS times. Does NOT retry failures that happen after a +// successful fingerprint read (those are WebAuthn/logic errors, not +// fingerprint errors, and retrying the finger won't fix them). +const unlockWithRetry = async () => { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + clearCountdown(); + retryBtn.style.display = 'none'; + statusEl.classList.remove('error'); + iconEl.classList.add('pulse'); + statusEl.textContent = attempt === 1 + ? "Touch the fingerprint reader..." + : `Touch the fingerprint reader... (attempt ${attempt}/${MAX_ATTEMPTS})`; - const processRequest = async () => { - // ... (unchanged logic for WebAuthn processing) ... try { - let result; - if (request.type === 'create' || request.authn === 'create') { - debugLog('[Auth] Create Options (Raw):', request.options); + return await getMasterKeyFromNativeHost(); + } catch (e) { + iconEl.classList.remove('pulse'); + console.warn(`[Auth] Fingerprint attempt ${attempt}/${MAX_ATTEMPTS} failed:`, e); + if (attempt === MAX_ATTEMPTS) throw e; + const noMatch = isNoMatchError(e.message); + const busy = isDeviceBusyError(e.message); + var message = e.message; + if (noMatch) message = 'No match'; + if (busy) message = 'Fingerprint device busy'; + await countdown(message, busy ? DEVICE_BUSY_RETRY_DELAY_MS : RETRY_DELAY_MS); + } + } +}; + +const processRequest = async (request) => { + // ... (unchanged logic for WebAuthn processing) ... + try { + let result; + if (request.type === 'create' || request.authn === 'create') { + debugLog('[Auth] Create Options (Raw):', request.options); - let opts = request.options; + let opts = request.options; // Parse if string to ensure we check structure of object, not string properties - if (typeof opts === 'string') { - try { opts = JSON.parse(opts); } catch (e) { console.error("JSON parse error:", e); } - } + if (typeof opts === 'string') { + try { opts = JSON.parse(opts); } catch (e) { console.error("JSON parse error:", e); } + } - if (!opts.publicKey) opts = { publicKey: opts }; + if (!opts.publicKey) opts = { publicKey: opts }; - const deserializedOptions = window.authnTools.unserialize(JSON.stringify(opts)); - debugLog('[Auth] Create Options (Deserialized):', deserializedOptions); - result = await deviceInstance.create(deserializedOptions, request.url); + const deserializedOptions = window.authnTools.unserialize(JSON.stringify(opts)); + debugLog('[Auth] Create Options (Deserialized):', deserializedOptions); + result = await deviceInstance.create(deserializedOptions, request.url); // If create triggered a storage save, it might have returned a promise (if logic inside create awaits handleStorage) // But deviceInstance.create inside webauthn-authenticator.js awaits handleStorage ONLY if it was async. @@ -143,94 +208,92 @@ const handleMessage = async (request, sender, sendResponse) => { // So we need to manually ensure we save "authenticator.storage" if it changed? // UNLESS we explicit save here. - if (deviceInstance.storage) { - debugLog('[Auth] Manually ensuring storage save...'); - await new Promise(r => chrome.storage.local.set({ 'system_credentials': deviceInstance.storage }, r)); - debugLog('[Auth] Manual save complete.'); - } - - } else if (request.type === 'get' || request.authn === 'get') { - debugLog('[Auth] Get Options (Raw):', request.options); - - let opts = request.options; - if (typeof opts === 'string') { - try { opts = JSON.parse(opts); } catch (e) { console.error("JSON parse error:", e); } - } - - if (!opts.publicKey) opts = { publicKey: opts }; - - const deserializedOptions = window.authnTools.unserialize(JSON.stringify(opts)); - debugLog('[Auth] Get Options (Deserialized):', deserializedOptions); - debugLog('[Auth] Current Storage:', deviceInstance.storage); - result = await deviceInstance.get(deserializedOptions, request.url); + if (deviceInstance.storage) { + debugLog('[Auth] Manually ensuring storage save...'); + await new Promise(r => chrome.storage.local.set({ 'system_credentials': deviceInstance.storage }, r)); + debugLog('[Auth] Manual save complete.'); } + } else if (request.type === 'get' || request.authn === 'get') { + debugLog('[Auth] Get Options (Raw):', request.options); - if (result) { - const responsePayload = { - id: result.id, - // Use serialize to ensure ArrayBuffers are preserved for the client script - rawId: JSON.parse(window.authnTools.serialize(result.rawId)), - response: { clientDataJSON: JSON.parse(window.authnTools.serialize(result.response.clientDataJSON)) }, - type: result.type, - getClientExtensionResults: result.getClientExtensionResults() - }; - if (result.response.attestationObject) responsePayload.response.attestationObject = JSON.parse(window.authnTools.serialize(result.response.attestationObject)); - if (result.response.authenticatorData) responsePayload.response.authenticatorData = JSON.parse(window.authnTools.serialize(result.response.authenticatorData)); - if (result.response.signature) responsePayload.response.signature = JSON.parse(window.authnTools.serialize(result.response.signature)); - if (result.response.userHandle) responsePayload.response.userHandle = JSON.parse(window.authnTools.serialize(result.response.userHandle)); - - chrome.runtime.sendMessage({ id: request.id, status: 'completed', credential: JSON.stringify(responsePayload) }); - document.getElementById('status').textContent = "Operation Completed."; - setTimeout(() => { if (window) window.close(); }, 1500); + let opts = request.options; + if (typeof opts === 'string') { + try { opts = JSON.parse(opts); } catch (e) { console.error("JSON parse error:", e); } } - } catch (e) { - console.error(e); - statusDiv.textContent = `Error: ${e.message}`; - statusDiv.classList.add('error'); - chrome.runtime.sendMessage({ id: request.id, status: 'error', error: e.message }); + if (!opts.publicKey) opts = { publicKey: opts }; + + const deserializedOptions = window.authnTools.unserialize(JSON.stringify(opts)); + debugLog('[Auth] Get Options (Deserialized):', deserializedOptions); + debugLog('[Auth] Current Storage:', deviceInstance.storage); + result = await deviceInstance.get(deserializedOptions, request.url); } - }; - if (!deviceInstance) { - deviceInstance = await initAuthenticator(); + if (result) { + const responsePayload = { + id: result.id, + // Use serialize to ensure ArrayBuffers are preserved for the client script + rawId: JSON.parse(window.authnTools.serialize(result.rawId)), + response: { clientDataJSON: JSON.parse(window.authnTools.serialize(result.response.clientDataJSON)) }, + type: result.type, + getClientExtensionResults: result.getClientExtensionResults() + }; + if (result.response.attestationObject) responsePayload.response.attestationObject = JSON.parse(window.authnTools.serialize(result.response.attestationObject)); + if (result.response.authenticatorData) responsePayload.response.authenticatorData = JSON.parse(window.authnTools.serialize(result.response.authenticatorData)); + if (result.response.signature) responsePayload.response.signature = JSON.parse(window.authnTools.serialize(result.response.signature)); + if (result.response.userHandle) responsePayload.response.userHandle = JSON.parse(window.authnTools.serialize(result.response.userHandle)); + + chrome.runtime.sendMessage({ id: request.id, status: 'completed', credential: JSON.stringify(responsePayload) }); + statusEl.textContent = "Verified."; + iconEl.classList.remove('pulse'); + setTimeout(() => window.close(), 900); + } + } catch (e) { + console.error(e); + statusEl.textContent = "Error: " + e.message; + statusEl.classList.add('error'); + iconEl.classList.remove('pulse'); + retryBtn.style.display = 'block'; + chrome.runtime.sendMessage({ id: request.id, status: 'error', error: e.message }); } +}; - const unlockBtn = document.getElementById('unlock-btn'); - const statusDiv = document.getElementById('status'); - - statusDiv.textContent = "Authentication Required"; - unlockBtn.style.display = "inline-block"; - - const newBtn = unlockBtn.cloneNode(true); - unlockBtn.parentNode.replaceChild(newBtn, unlockBtn); - - newBtn.addEventListener('click', async () => { - console.log('[Auth] Unlock button clicked. Using Native Messaging.'); - newBtn.disabled = true; - try { - const masterKey = await getMasterKeyFromNativeHost(); - - console.log('[Auth] Keys obtained.'); - // deviceInstance.masterkeysalt is already set in initAuthenticator or from storage - deviceInstance.setMasterKey(masterKey); - - newBtn.style.display = "none"; - statusDiv.textContent = "Processing..."; - - await processRequest(); - - } catch (e) { - console.error('[Auth] Unlock process failed:', e); - statusDiv.textContent = "Error: " + e.message; - newBtn.disabled = false; - } - }); +const runFlow = async (request) => { + if (!deviceInstance) deviceInstance = await initAuthenticator(); + clearCountdown(); + retryBtn.style.display = 'none'; + statusEl.classList.remove('error'); + try { + const masterKey = await unlockWithRetry(); + + console.log('[Auth] Keys obtained.'); + // deviceInstance.masterkeysalt is already set in initAuthenticator or from storage + deviceInstance.setMasterKey(masterKey); + statusEl.textContent = "Verifying..."; + iconEl.classList.remove('pulse'); + await processRequest(request); + } catch (e) { + // Only reached once all MAX_ATTEMPTS fingerprint attempts are exhausted. + console.error('[Auth] Flow failed after retries:', e); + statusEl.textContent = `Failed after ${MAX_ATTEMPTS} attempts)`; + statusEl.classList.add('error'); + iconEl.classList.remove('pulse'); + retryBtn.style.display = 'block'; + chrome.runtime.sendMessage({ id: request.id, status: 'error', error: e.message }); + } }; +let lastRequest = null; + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - handleMessage(message); + lastRequest = message; + runFlow(message); sendResponse({ started: true }); return true; }); +retryBtn.addEventListener('click', () => { + if (lastRequest) runFlow(lastRequest); +}); + +// Ask background.js for the pending request and start immediately - no click needed. chrome.runtime.sendMessage({ type: 'authenticator_ready' }); diff --git a/extension/background.js b/extension/background.js index dac3ac9..e5fdc5d 100644 --- a/extension/background.js +++ b/extension/background.js @@ -36,7 +36,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { console.log("PARSED CREDENTIAL:", credential); } catch (e) { console.error("FAILED TO PARSE CREDENTIAL:", e); - } + } } // @@ -116,7 +116,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // Clear pending request after completion? // Maybe wait a bit or clear it now. Let's clear it to be clean. // Clear request after completion - pendingRequest = null; + pendingRequest = null; } else { // Do not fallback to active tab. // The active tab may not be the tab that initiated WebAuthn. @@ -134,16 +134,20 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { pendingRequest.requestingTabId = sender.tab.id; } - // Open the authenticator popup - chrome.windows.create({ - url: "authenticator.html", - type: "popup", - width: 400, - height: 600, - focused: true - }, (window) => { - if(window) - popupWindowId = window.id; + const tabId = sender.tab.id; + const windowId = sender.tab.windowId; + pendingRequest.requestingTabId = tabId; + + // Point the toolbar action's popup at the request bubble, only for this tab. + chrome.action.setPopup({ tabId, popup: 'authenticator.html' }); + chrome.action.setBadgeBackgroundColor({ tabId, color: '#f0a500' }); + chrome.action.setBadgeText({ tabId, text: ' ' }); + chrome.action.setTitle({ tabId, title: 'WebAuthnLinux: verification requested for ' + (sender.tab.url || '') }); + + // Open it without requiring a click (Firefox 149+; falls back to + // requiring the user to click the toolbar icon on older Firefox). + chrome.action.openPopup({ windowId }).catch((e) => { + console.warn('WebAuthnLinux: openPopup() failed - user must click the toolbar icon manually.', e); }); sendResponse({ started: true }); diff --git a/extension/fingerprint-webauthn.svg b/extension/fingerprint-webauthn.svg new file mode 100644 index 0000000..66603ed --- /dev/null +++ b/extension/fingerprint-webauthn.svg @@ -0,0 +1,91 @@ + +Silouette fingerprintGenerated with QtRoberto MetereSilouette fingerprint diff --git a/extension/icons/icon.png b/extension/icons/icon.png index 0892f76..419e847 100644 Binary files a/extension/icons/icon.png and b/extension/icons/icon.png differ diff --git a/extension/icons/icon_128.png b/extension/icons/icon_128.png index a040f7f..b25ad13 100644 Binary files a/extension/icons/icon_128.png and b/extension/icons/icon_128.png differ diff --git a/extension/icons/icon_48.png b/extension/icons/icon_48.png index 3c3cea7..1620189 100644 Binary files a/extension/icons/icon_48.png and b/extension/icons/icon_48.png differ diff --git a/extension/icons/logo.png b/extension/icons/logo.png index c8a5bc5..c916c90 100644 Binary files a/extension/icons/logo.png and b/extension/icons/logo.png differ diff --git a/extension/icons/store_icon_512.png b/extension/icons/store_icon_512.png index 03db6fc..ee90491 100644 Binary files a/extension/icons/store_icon_512.png and b/extension/icons/store_icon_512.png differ