mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-04-08 08:38:04 -04:00
Add passkey proof-of-concept browser extension scaffolding (#520)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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<string, IPasskeyData>();
|
||||
|
||||
// Pending popup requests
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: any) => 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<any> {
|
||||
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<any> {
|
||||
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<string, IPasskeyData> = 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<string, IPasskeyData> = 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<void> {
|
||||
const storedPasskeys: Record<string, IPasskeyData> = 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 };
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await sendMessage('GET_WEBAUTHN_SETTINGS', {}, 'background');
|
||||
return response.enabled ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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: <LanguageSettings />, showBackButton: true, title: t('settings.language') },
|
||||
{ path: '/settings/auto-lock', element: <AutoLockSettings />, showBackButton: true, title: t('settings.autoLockTimeout') },
|
||||
{ path: '/logout', element: <Logout />, showBackButton: false },
|
||||
{ path: '/passkeys/create', element: <PasskeyCreate />, showBackButton: true, title: 'Create Passkey' },
|
||||
{ path: '/passkeys/authenticate', element: <PasskeyAuthenticate />, showBackButton: true, title: 'Sign in with Passkey' },
|
||||
], [t]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<IPasskeyRequest | null>(null);
|
||||
const [selectedPasskey, setSelectedPasskey] = useState<string | null>(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 (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Sign in with Passkey
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Sign in with passkey for <strong>{request.origin}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{request.passkeys && request.passkeys.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Select a passkey:
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto border rounded-lg p-2 bg-gray-50 dark:bg-gray-800">
|
||||
{request.passkeys.map((pk) => (
|
||||
<div
|
||||
key={pk.id}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedPasskey === pk.id
|
||||
? 'bg-blue-50 border-blue-200 dark:bg-blue-900 dark:border-blue-700'
|
||||
: 'bg-white border-gray-200 hover:bg-gray-50 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => setSelectedPasskey(pk.id)}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm">
|
||||
{pk.displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
Last used: {pk.lastUsed || 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
No passkeys found for this site
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{request.passkeys && request.passkeys.length > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUsePasskey}
|
||||
disabled={loading || !selectedPasskey}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Use Selected Passkey'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Use Browser Passkey
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyAuthenticate;
|
||||
@@ -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<IPasskeyRequest | null>(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 (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Create Passkey
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a new passkey for <strong>{request.origin}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormInput
|
||||
id="displayName"
|
||||
label="Display Name"
|
||||
value={displayName}
|
||||
onChange={setDisplayName}
|
||||
placeholder="Enter a name for this passkey"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
disabled={loading || !displayName.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Passkey'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleFallback}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Use Browser Passkey
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyCreate;
|
||||
221
apps/browser-extension/src/entrypoints/webauthn-inject.ts
Normal file
221
apps/browser-extension/src/entrypoints/webauthn-inject.ts
Normal file
@@ -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');
|
||||
});
|
||||
@@ -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: ["<all_urls>"]
|
||||
}]
|
||||
};
|
||||
},
|
||||
modules: ['@wxt-dev/module-react'],
|
||||
|
||||
Reference in New Issue
Block a user