Add passkey proof-of-concept browser extension scaffolding (#520)

This commit is contained in:
Leendert de Borst
2025-09-25 15:50:04 +02:00
parent 181a27e94e
commit dbae407df6
9 changed files with 1066 additions and 1 deletions

View File

@@ -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) {

View File

@@ -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 };
}

View File

@@ -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));

View File

@@ -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;
}
}

View File

@@ -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(() => {

View File

@@ -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;

View File

@@ -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;

View 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');
});

View File

@@ -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'],