diff --git a/apps/browser-extension/src/entrypoints/background.ts b/apps/browser-extension/src/entrypoints/background.ts index d6912301e..7f9a9016b 100644 --- a/apps/browser-extension/src/entrypoints/background.ts +++ b/apps/browser-extension/src/entrypoints/background.ts @@ -3,6 +3,7 @@ import { onMessage, sendMessage } from "webext-bridge/background"; import { handleResetAutoLockTimer, handlePopupHeartbeat, handleSetAutoLockTimeout } from '@/entrypoints/background/AutolockTimeoutHandler'; import { handleClipboardCopied, handleCancelClipboardClear, handleGetClipboardClearTimeout, handleSetClipboardClearTimeout, handleGetClipboardCountdownState } from '@/entrypoints/background/ClipboardClearHandler'; import { setupContextMenus } from '@/entrypoints/background/ContextMenu'; +import { handleGetWebAuthnSettings, handleWebAuthnCreate, handleWebAuthnGet, handleStorePasskey, handleUpdatePasskeyLastUsed, handleClearAllPasskeys, handlePasskeyPopupResponse, initializePasskeys } from '@/entrypoints/background/PasskeyHandler'; import { handleOpenPopup, handlePopupWithCredential, handleOpenPopupCreateCredential, handleToggleContextMenu } from '@/entrypoints/background/PopupMessageHandler'; import { handleCheckAuthStatus, handleClearPersistedFormValues, handleClearVault, handleCreateIdentity, handleGetCredentials, handleGetDefaultEmailDomain, handleGetDefaultIdentitySettings, handleGetEncryptionKey, handleGetEncryptionKeyDerivationParams, handleGetPasswordSettings, handleGetPersistedFormValues, handleGetVault, handlePersistFormValues, handleStoreEncryptionKey, handleStoreEncryptionKeyDerivationParams, handleStoreVault, handleSyncVault, handleUploadVault } from '@/entrypoints/background/VaultMessageHandler'; @@ -62,6 +63,18 @@ export default defineBackground({ // Handle clipboard copied from context menu onMessage('CLIPBOARD_COPIED_FROM_CONTEXT', () => handleClipboardCopied()); + // Passkey/WebAuthn management messages + onMessage('GET_WEBAUTHN_SETTINGS', () => handleGetWebAuthnSettings()); + onMessage('WEBAUTHN_CREATE', ({ data }) => handleWebAuthnCreate(data)); + onMessage('WEBAUTHN_GET', ({ data }) => handleWebAuthnGet(data)); + onMessage('STORE_PASSKEY', ({ data }) => handleStorePasskey(data)); + onMessage('UPDATE_PASSKEY_LAST_USED', ({ data }) => handleUpdatePasskeyLastUsed(data)); + onMessage('CLEAR_ALL_PASSKEYS', () => handleClearAllPasskeys()); + onMessage('PASSKEY_POPUP_RESPONSE', ({ data }) => handlePasskeyPopupResponse(data)); + + // Initialize passkeys from storage TODO: remove this once proper vault integration is added + await initializePasskeys(); + // Setup context menus const isContextMenuEnabled = await storage.getItem(GLOBAL_CONTEXT_MENU_ENABLED_KEY) ?? true; if (isContextMenuEnabled) { diff --git a/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts b/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts new file mode 100644 index 000000000..82cb945c7 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts @@ -0,0 +1,305 @@ +/** + * PasskeyHandler - Handles passkey storage and management in background + * TODO: review this file + */ + +import { storage, browser } from '#imports'; + +interface IPasskeyData { + id: string; + rpId: string; + credentialId: string; + displayName: string; + publicKey: unknown; + createdAt: number; + updatedAt: number; + lastUsedAt: number | null; + signCount: number; +} + +// In-memory session storage for passkeys (for POC) +const sessionPasskeys = new Map(); + +// Pending popup requests +const pendingRequests = new Map void; + reject: (error: any) => void; +}>(); + +/** + * Handle WebAuthn settings request + */ +export async function handleGetWebAuthnSettings(): Promise<{ enabled: boolean }> { + /* + * For POC, always enabled. In production, this would be a user setting + * const settings = await storage.getItem('local:webauthn_enabled'); + * return { enabled: settings !== false }; + */ + return { enabled: true }; +} + +/** + * Handle WebAuthn create (registration) request + */ +export async function handleWebAuthnCreate(data: { + publicKey: unknown; + origin: string; +}): Promise { + const { publicKey, origin } = data; + const requestId = Math.random().toString(36).substr(2, 9); + + // Create popup using main popup with hash navigation + const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/create?' + new URLSearchParams({ + request: encodeURIComponent(JSON.stringify({ + type: 'create', + requestId, + origin, + publicKey + })) + }).toString(); + + try { + const popup = await browser.windows.create({ + url: popupUrl, + type: 'popup', + width: 450, + height: 600, + focused: true + }); + + // Wait for response from popup + return new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + + // Clean up if popup is closed without response + const checkClosed = setInterval(async () => { + try { + if (popup.id) { + const window = await browser.windows.get(popup.id); + // Window still exists, continue waiting + } + } catch { + // Window no longer exists + clearInterval(checkClosed); + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + resolve({ cancelled: true }); + } + } + }, 1000); + }); + } catch (error) { + return { error: 'Failed to create popup window' }; + } +} + +interface IPasskeyDisplay { + id: string; + displayName: string; + lastUsed: string | null; +} + +/** + * Handle WebAuthn get (authentication) request + */ +export async function handleWebAuthnGet(data: { + publicKey: { allowCredentials?: Array<{ id: string }> }; + origin: string; +}): Promise { + const { publicKey, origin } = data; + const requestId = Math.random().toString(36).substr(2, 9); + + // Get passkeys for this origin + const passkeys = getPasskeysForOrigin(origin); + + // Filter by allowCredentials if specified + let filteredPasskeys = passkeys; + if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) { + const allowedIds = new Set(publicKey.allowCredentials.map(c => c.id)); + filteredPasskeys = passkeys.filter(pk => allowedIds.has(pk.credentialId)); + } + + const passkeyList = filteredPasskeys.map(pk => ({ + id: pk.credentialId, + displayName: pk.displayName, + lastUsed: pk.lastUsedAt ? new Date(pk.lastUsedAt).toLocaleDateString() : null + })); + + // Create popup using main popup with hash navigation + const popupUrl = browser.runtime.getURL('/popup.html') + '#/passkeys/authenticate?' + new URLSearchParams({ + request: encodeURIComponent(JSON.stringify({ + type: 'get', + requestId, + origin, + publicKey, + passkeys: passkeyList + })) + }).toString(); + + try { + const popup = await browser.windows.create({ + url: popupUrl, + type: 'popup', + width: 450, + height: Math.min(600, 400 + passkeyList.length * 60), + focused: true + }); + + // Wait for response from popup + return new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + + // Clean up if popup is closed without response + const checkClosed = setInterval(async () => { + try { + if (popup.id) { + const window = await browser.windows.get(popup.id); + // Window still exists, continue waiting + } + } catch { + // Window no longer exists + clearInterval(checkClosed); + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + resolve({ cancelled: true }); + } + } + }, 1000); + }); + } catch (error) { + return { error: 'Failed to create popup window' }; + } +} + +/** + * Store a new passkey + */ +export async function handleStorePasskey(data: { + rpId: string; + credentialId: string; + displayName: string; + publicKey: unknown; +}): Promise<{ success: boolean }> { + const { rpId, credentialId, displayName, publicKey } = data; + + const passkey: IPasskeyData = { + id: Date.now().toString(), + rpId: rpId.replace(/^https?:\/\//, '').split('/')[0], // Extract domain + credentialId, + displayName, + publicKey, + createdAt: Date.now(), + updatedAt: Date.now(), + lastUsedAt: null, + signCount: 0 + }; + + // Store in session memory + const key = `${passkey.rpId}:${credentialId}`; + sessionPasskeys.set(key, passkey); + + /* + * In production, this would be stored in the vault database + * For now, also store in local storage for persistence across reloads + */ + const storedPasskeys: Record = await storage.getItem('local:passkeys') || {}; + storedPasskeys[key] = passkey; + await storage.setItem('local:passkeys', storedPasskeys); + + return { success: true }; +} + +/** + * Update passkey last used time + */ +export async function handleUpdatePasskeyLastUsed(data: { + credentialId: string; +}): Promise<{ success: boolean }> { + const { credentialId } = data; + + // Find and update the passkey + for (const [key, passkey] of sessionPasskeys.entries()) { + if (passkey.credentialId === credentialId) { + passkey.lastUsedAt = Date.now(); + passkey.signCount++; + sessionPasskeys.set(key, passkey); + + // Update in storage too + const storedPasskeys: Record = await storage.getItem('local:passkeys') || {}; + if (storedPasskeys[key]) { + storedPasskeys[key] = passkey; + await storage.setItem('local:passkeys', storedPasskeys); + } + return { success: true }; + } + } + + return { success: false }; +} + +/** + * Get passkeys for a specific origin + */ +function getPasskeysForOrigin(origin: string): IPasskeyData[] { + const rpId = origin.replace(/^https?:\/\//, '').split('/')[0]; + const passkeys: IPasskeyData[] = []; + + for (const [_key, passkey] of sessionPasskeys.entries()) { + if (passkey.rpId === rpId || passkey.rpId === `.${rpId}`) { + passkeys.push(passkey); + } + } + + return passkeys; +} + +/** + * Initialize passkeys from storage on startup + */ +export async function initializePasskeys(): Promise { + const storedPasskeys: Record = await storage.getItem('local:passkeys') || {}; + + for (const [key, passkey] of Object.entries(storedPasskeys)) { + sessionPasskeys.set(key, passkey as IPasskeyData); + } +} + +/** + * Handle response from passkey popup + */ +export async function handlePasskeyPopupResponse(data: { + requestId: string; + credential?: any; + fallback?: boolean; + cancelled?: boolean; +}): Promise<{ success: boolean }> { + const { requestId, credential, fallback, cancelled } = data; + const request = pendingRequests.get(requestId); + + if (!request) { + return { success: false }; + } + + pendingRequests.delete(requestId); + + if (cancelled) { + request.resolve({ cancelled: true }); + } else if (fallback) { + request.resolve({ fallback: true }); + } else if (credential) { + request.resolve({ credential }); + } else { + request.resolve({ cancelled: true }); + } + + return { success: true }; +} + +/** + * Clear all passkeys (for development) + */ +export async function handleClearAllPasskeys(): Promise<{ success: boolean }> { + sessionPasskeys.clear(); + await storage.removeItem('local:passkeys'); + return { success: true }; +} diff --git a/apps/browser-extension/src/entrypoints/content.ts b/apps/browser-extension/src/entrypoints/content.ts index 4371b62fe..dbd800b9f 100644 --- a/apps/browser-extension/src/entrypoints/content.ts +++ b/apps/browser-extension/src/entrypoints/content.ts @@ -3,6 +3,7 @@ import { onMessage } from "webext-bridge/content-script"; import { injectIcon, popupDebounceTimeHasPassed, validateInputField } from '@/entrypoints/contentScript/Form'; import { isAutoShowPopupEnabled, openAutofillPopup, removeExistingPopup, createUpgradeRequiredPopup } from '@/entrypoints/contentScript/Popup'; +import { initializeWebAuthnInterceptor, isWebAuthnInterceptionEnabled } from '@/entrypoints/contentScript/WebAuthnInterceptor'; import { FormDetector } from '@/utils/formDetector/FormDetector'; import { BoolResponse as messageBoolResponse } from '@/utils/types/messaging/BoolResponse'; @@ -26,6 +27,14 @@ export default defineContentScript({ return; } + // Initialize WebAuthn interceptor early (before page scripts run) + const webAuthnEnabled = await isWebAuthnInterceptionEnabled(); + console.log('[AliasVault] Content: WebAuthn interception enabled:', webAuthnEnabled); + if (webAuthnEnabled) { + console.log('[AliasVault] Content: Initializing WebAuthn interceptor'); + await initializeWebAuthnInterceptor(ctx); + } + // Wait for 750ms to give the host page time to load and to increase the chance that the body is available and ready. await new Promise(resolve => setTimeout(resolve, 750)); diff --git a/apps/browser-extension/src/entrypoints/contentScript/WebAuthnInterceptor.ts b/apps/browser-extension/src/entrypoints/contentScript/WebAuthnInterceptor.ts new file mode 100644 index 000000000..6fb4c519f --- /dev/null +++ b/apps/browser-extension/src/entrypoints/contentScript/WebAuthnInterceptor.ts @@ -0,0 +1,113 @@ +/** + * WebAuthn Interceptor - Handles communication between page and extension + * TODO: review this file + */ + +import { sendMessage } from 'webext-bridge/content-script'; + +import { browser } from '#imports'; + +let interceptorInitialized = false; + +/** + * Initialize the WebAuthn interceptor + */ +export async function initializeWebAuthnInterceptor(_ctx: any): Promise { + console.log('[AliasVault] WebAuthnInterceptor: Initializing, interceptorInitialized:', interceptorInitialized); + if (interceptorInitialized) { + return; + } + + // Listen for WebAuthn create events from the page + window.addEventListener('aliasVault:webauthn:create', async (event: any) => { + const { requestId, publicKey, origin } = event.detail; + console.log('[AliasVault] WebAuthnInterceptor: Received webauthn:create event', { requestId, origin }); + + try { + // Send to background script to handle + const result = await sendMessage('WEBAUTHN_CREATE', { + publicKey, + origin + }, 'background'); + + console.log('[AliasVault] WebAuthnInterceptor: Background response for create', result); + + // Send response back to page + window.dispatchEvent(new CustomEvent('aliasVault:webauthn:create:response', { + detail: { + requestId, + ...result + } + })); + } catch (error: any) { + window.dispatchEvent(new CustomEvent('aliasVault:webauthn:create:response', { + detail: { + requestId, + error: error.message + } + })); + } + }); + + // Listen for WebAuthn get events from the page + window.addEventListener('aliasVault:webauthn:get', async (event: any) => { + const { requestId, publicKey, origin } = event.detail; + + try { + // Send to background script to handle + const result = await sendMessage('WEBAUTHN_GET', { + publicKey, + origin + }, 'background'); + + // Send response back to page + window.dispatchEvent(new CustomEvent('aliasVault:webauthn:get:response', { + detail: { + requestId, + ...result + } + })); + } catch (error: any) { + window.dispatchEvent(new CustomEvent('aliasVault:webauthn:get:response', { + detail: { + requestId, + error: error.message + } + })); + } + }); + + // Inject the page script + const script = document.createElement('script'); + script.src = browser.runtime.getURL('/webauthn-inject.js'); + script.async = true; + (document.head || document.documentElement).appendChild(script); + /** + * + */ + script.onload = () => { + console.log('[AliasVault] WebAuthnInterceptor: Injected script loaded successfully'); + script.remove(); + }; + /** + * + */ + script.onerror = () => { + console.error('[AliasVault] WebAuthnInterceptor: Failed to load injected script'); + }; + + interceptorInitialized = true; + console.log('[AliasVault] WebAuthnInterceptor: Initialization complete'); +} + +/** + * Check if WebAuthn interception is enabled + */ +export async function isWebAuthnInterceptionEnabled(): Promise { + try { + const response = await sendMessage('GET_WEBAUTHN_SETTINGS', {}, 'background'); + return response.enabled ?? false; + } catch { + return false; + } +} diff --git a/apps/browser-extension/src/entrypoints/popup/App.tsx b/apps/browser-extension/src/entrypoints/popup/App.tsx index 86f0d794c..a4b0d82b1 100644 --- a/apps/browser-extension/src/entrypoints/popup/App.tsx +++ b/apps/browser-extension/src/entrypoints/popup/App.tsx @@ -23,6 +23,8 @@ import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsLi import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails'; import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList'; import Index from '@/entrypoints/popup/pages/Index'; +import PasskeyAuthenticate from '@/entrypoints/popup/pages/passkeys/PasskeyAuthenticate'; +import PasskeyCreate from '@/entrypoints/popup/pages/passkeys/PasskeyCreate'; import Reinitialize from '@/entrypoints/popup/pages/Reinitialize'; import AutofillSettings from '@/entrypoints/popup/pages/settings/AutofillSettings'; import AutoLockSettings from '@/entrypoints/popup/pages/settings/AutoLockSettings'; @@ -78,6 +80,8 @@ const App: React.FC = () => { { path: '/settings/language', element: , showBackButton: true, title: t('settings.language') }, { path: '/settings/auto-lock', element: , showBackButton: true, title: t('settings.autoLockTimeout') }, { path: '/logout', element: , showBackButton: false }, + { path: '/passkeys/create', element: , showBackButton: true, title: 'Create Passkey' }, + { path: '/passkeys/authenticate', element: , showBackButton: true, title: 'Sign in with Passkey' }, ], [t]); useEffect(() => { diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx new file mode 100644 index 000000000..30bae48bf --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx @@ -0,0 +1,211 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { sendMessage } from 'webext-bridge/popup'; + +import Button from '@/entrypoints/popup/components/Button'; +import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; +import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; + +interface IPasskeyRequest { + type: 'get'; + requestId: string; + origin: string; + publicKey: any; + passkeys?: Array<{ + id: string; + displayName: string; + lastUsed: string | null; + }>; +} + +/** + * TODO: review this file + */ +const PasskeyAuthenticate: React.FC = () => { + const location = useLocation(); + const { setIsInitialLoading } = useLoading(); + const [request, setRequest] = useState(null); + const [selectedPasskey, setSelectedPasskey] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + // Get the request data from hash + const hash = location.hash.substring(1); // Remove '#' + const params = new URLSearchParams(hash.split('?')[1] || ''); + const requestData = params.get('request'); + + if (requestData) { + try { + const parsed = JSON.parse(decodeURIComponent(requestData)); + setRequest(parsed); + } catch (error) { + console.error('Failed to parse request data:', error); + } + } + + // Mark initial loading as complete + setIsInitialLoading(false); + }, [location, setIsInitialLoading]); + + /** + * + */ + const handleUsePasskey = async () => { + if (!request || !selectedPasskey) { + return; + } + + setLoading(true); + + // Generate mock assertion for POC + const credential = { + id: selectedPasskey, + rawId: selectedPasskey, + clientDataJSON: btoa(JSON.stringify({ + type: 'webauthn.get', + challenge: request.publicKey.challenge, + origin: request.origin + })), + authenticatorData: btoa('mock_authenticator_data'), + signature: btoa('mock_signature'), + userHandle: btoa('user_handle') + }; + + // Update last used + await sendMessage('UPDATE_PASSKEY_LAST_USED', { + credentialId: selectedPasskey + }, 'background'); + + // Send response back + await sendMessage('PASSKEY_POPUP_RESPONSE', { + requestId: request.requestId, + credential + }, 'background'); + + window.close(); + }; + + /** + * + */ + const handleFallback = async () => { + if (!request) { + return; + } + + // Tell background to use native implementation + await sendMessage('PASSKEY_POPUP_RESPONSE', { + requestId: request.requestId, + fallback: true + }, 'background'); + + window.close(); + }; + + /** + * + */ + const handleCancel = async () => { + if (!request) { + return; + } + + // Tell background user cancelled + await sendMessage('PASSKEY_POPUP_RESPONSE', { + requestId: request.requestId, + cancelled: true + }, 'background'); + + window.close(); + }; + + if (!request) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Sign in with Passkey +

+

+ Sign in with passkey for {request.origin} +

+
+ +
+ {request.passkeys && request.passkeys.length > 0 ? ( +
+ +
+ {request.passkeys.map((pk) => ( +
setSelectedPasskey(pk.id)} + > +
+ {pk.displayName} +
+
+ Last used: {pk.lastUsed || 'Never'} +
+
+ ))} +
+
+ ) : ( +
+

+ No passkeys found for this site +

+
+ )} +
+ +
+ {request.passkeys && request.passkeys.length > 0 && ( + + )} + + + + +
+
+ ); +}; + +export default PasskeyAuthenticate; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx new file mode 100644 index 000000000..06238ab25 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx @@ -0,0 +1,185 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { sendMessage } from 'webext-bridge/popup'; + +import Button from '@/entrypoints/popup/components/Button'; +import { FormInput } from '@/entrypoints/popup/components/FormInput'; +import LoadingSpinner from '@/entrypoints/popup/components/LoadingSpinner'; +import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; + +interface IPasskeyRequest { + type: 'create'; + requestId: string; + origin: string; + publicKey: any; +} + +/** + * + */ +const PasskeyCreate: React.FC = () => { + const location = useLocation(); + const { setIsInitialLoading } = useLoading(); + const [request, setRequest] = useState(null); + const [displayName, setDisplayName] = useState('My Passkey'); + const [loading, setLoading] = useState(false); + + useEffect(() => { + // Get the request data from hash + const hash = location.hash.substring(1); // Remove '#' + const params = new URLSearchParams(hash.split('?')[1] || ''); + const requestData = params.get('request'); + + if (requestData) { + try { + const parsed = JSON.parse(decodeURIComponent(requestData)); + setRequest(parsed); + + if (parsed.publicKey?.user?.displayName) { + setDisplayName(parsed.publicKey.user.displayName); + } + } catch (error) { + console.error('Failed to parse request data:', error); + } + } + + // Mark initial loading as complete + setIsInitialLoading(false); + }, [location, setIsInitialLoading]); + + /** + * + */ + const handleCreate = async () => { + if (!request) { + return; + } + + setLoading(true); + + // Generate mock credential for POC + const credentialId = btoa(Math.random().toString()); + const credential = { + id: credentialId, + rawId: credentialId, + clientDataJSON: btoa(JSON.stringify({ + type: 'webauthn.create', + challenge: request.publicKey.challenge, + origin: request.origin + })), + attestationObject: btoa('mock_attestation_object') + }; + + // Store passkey + await sendMessage('STORE_PASSKEY', { + rpId: request.origin, + credentialId, + displayName, + publicKey: request.publicKey + }, 'background'); + + // Send response back + await sendMessage('PASSKEY_POPUP_RESPONSE', { + requestId: request.requestId, + credential + }, 'background'); + + window.close(); + }; + + /** + * + */ + const handleFallback = async () => { + if (!request) { + return; + } + + // Tell background to use native implementation + await sendMessage('PASSKEY_POPUP_RESPONSE', { + requestId: request.requestId, + fallback: true + }, 'background'); + + window.close(); + }; + + /** + * + */ + const handleCancel = async () => { + if (!request) { + return; + } + + // Tell background user cancelled + await sendMessage('PASSKEY_POPUP_RESPONSE', { + requestId: request.requestId, + cancelled: true + }, 'background'); + + window.close(); + }; + + if (!request) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Create Passkey +

+

+ Create a new passkey for {request.origin} +

+
+ +
+ +
+ +
+ + + + + +
+
+ ); +}; + +export default PasskeyCreate; diff --git a/apps/browser-extension/src/entrypoints/webauthn-inject.ts b/apps/browser-extension/src/entrypoints/webauthn-inject.ts new file mode 100644 index 000000000..566e539d8 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/webauthn-inject.ts @@ -0,0 +1,221 @@ +/** + * WebAuthn injection script - runs in page context to intercept credentials API + * This is a public entry point that will be included in web_accessible_resources + */ + +import { defineUnlistedScript } from '#imports'; + +export default defineUnlistedScript(() => { + // Only run once + if ((window as any).__aliasVaultWebAuthnIntercepted) { + return; + } + (window as any).__aliasVaultWebAuthnIntercepted = true; + + const originalCreate = navigator.credentials.create.bind(navigator.credentials); + const originalGet = navigator.credentials.get.bind(navigator.credentials); + + // Helper to convert ArrayBuffer to base64 + /** + * + */ + function bufferToBase64(buffer: ArrayBuffer | ArrayBufferView): string { + const bytes = buffer instanceof ArrayBuffer + ? new Uint8Array(buffer) + : new Uint8Array(buffer.buffer, (buffer as any).byteOffset, (buffer as any).byteLength); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + + // Helper to convert base64 to ArrayBuffer + /** + * + */ + function base64ToBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } + + // Override credentials.create + /** + * + */ + navigator.credentials.create = async function(options?: CredentialCreationOptions) { + console.log('[AliasVault] Page: Intercepted credentials.create', options); + + if (!options?.publicKey) { + return originalCreate(options); + } + + // Send event to content script + const requestId = Math.random().toString(36).substr(2, 9); + const event = new CustomEvent('aliasVault:webauthn:create', { + detail: { + requestId, + publicKey: { + ...options.publicKey, + challenge: bufferToBase64(options.publicKey.challenge), + user: { + ...options.publicKey.user, + id: bufferToBase64(options.publicKey.user.id) + }, + excludeCredentials: options.publicKey.excludeCredentials?.map(cred => ({ + ...cred, + id: bufferToBase64(cred.id) + })) + }, + origin: window.location.origin + } + }); + window.dispatchEvent(event); + + // Wait for response + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + // Timeout - fall back to native + console.log('[AliasVault] Page: Timeout, falling back to native'); + originalCreate(options).then(resolve).catch(reject); + }, 30000); // 30 second timeout + + /** + * + */ + function cleanup() { + clearTimeout(timeout); + window.removeEventListener('aliasVault:webauthn:create:response', handler); + } + + /** + * + */ + function handler(e: any) { + if (e.detail.requestId !== requestId) { + return; + } + + cleanup(); + + if (e.detail.fallback) { + // User chose to use native implementation + console.log('[AliasVault] Page: User chose native, falling back'); + originalCreate(options).then(resolve).catch(reject); + } else if (e.detail.error) { + reject(new Error(e.detail.error)); + } else if (e.detail.credential) { + // Mock credential for POC + const cred = e.detail.credential; + resolve({ + id: cred.id, + type: 'public-key', + rawId: base64ToBuffer(cred.rawId), + response: { + clientDataJSON: base64ToBuffer(cred.clientDataJSON), + attestationObject: base64ToBuffer(cred.attestationObject) + } + } as any); + } else { + // Cancelled + resolve(null); + } + } + + window.addEventListener('aliasVault:webauthn:create:response', handler); + }); + }; + + // Override credentials.get + /** + * + */ + navigator.credentials.get = async function(options?: CredentialRequestOptions) { + console.log('[AliasVault] Page: Intercepted credentials.get', options); + + if (!options?.publicKey) { + return originalGet(options); + } + + // Send event to content script + const requestId = Math.random().toString(36).substr(2, 9); + const event = new CustomEvent('aliasVault:webauthn:get', { + detail: { + requestId, + publicKey: { + ...options.publicKey, + challenge: bufferToBase64(options.publicKey.challenge), + allowCredentials: options.publicKey.allowCredentials?.map(cred => ({ + ...cred, + id: bufferToBase64(cred.id) + })) + }, + origin: window.location.origin + } + }); + window.dispatchEvent(event); + + // Wait for response + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + // Timeout - fall back to native + console.log('[AliasVault] Page: Timeout, falling back to native'); + originalGet(options).then(resolve).catch(reject); + }, 30000); + + /** + * + */ + function cleanup() { + clearTimeout(timeout); + window.removeEventListener('aliasVault:webauthn:get:response', handler); + } + + /** + * + */ + function handler(e: any) { + if (e.detail.requestId !== requestId) { + return; + } + + cleanup(); + + if (e.detail.fallback) { + // User chose to use native implementation + console.log('[AliasVault] Page: User chose native, falling back'); + originalGet(options).then(resolve).catch(reject); + } else if (e.detail.error) { + reject(new Error(e.detail.error)); + } else if (e.detail.credential) { + // Mock credential for POC + const cred = e.detail.credential; + resolve({ + id: cred.id, + type: 'public-key', + rawId: base64ToBuffer(cred.rawId), + response: { + clientDataJSON: base64ToBuffer(cred.clientDataJSON), + authenticatorData: base64ToBuffer(cred.authenticatorData), + signature: base64ToBuffer(cred.signature), + userHandle: cred.userHandle ? base64ToBuffer(cred.userHandle) : null + } + } as any); + } else { + // Cancelled + resolve(null); + } + } + + window.addEventListener('aliasVault:webauthn:get:response', handler); + }); + }; + + console.log('[AliasVault] WebAuthn interceptor initialized in page context'); +}); diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts index fcdc0fa3b..598647630 100644 --- a/apps/browser-extension/wxt.config.ts +++ b/apps/browser-extension/wxt.config.ts @@ -35,7 +35,11 @@ export default defineConfig({ "show-autofill-popup": { description: "Show the autofill popup (while focusing an input field)" } - } + }, + web_accessible_resources: [{ + resources: ["webauthn-inject.js"], + matches: [""] + }] }; }, modules: ['@wxt-dev/module-react'],