From c6bc761907dd215454cc48bdcab57276715ae0b0 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 26 Feb 2025 21:15:39 +1100 Subject: [PATCH 1/6] Initial passkey implementation --- _raw/_locales/en/messages.json | 3 + _raw/_locales/ja/messages.json | 3 + _raw/_locales/ru/messages.json | 3 + _raw/_locales/zh_CN/messages.json | 3 + src/background/controller/wallet.ts | 188 ++++++- src/background/index.ts | 2 + src/background/service/index.ts | 1 + src/background/service/passkey.ts | 362 ++++++++++++ src/messages.json | 3 + src/shared/types/tracking-types.ts | 29 + src/ui/FRWComponent/Dialog.tsx | 61 ++ src/ui/FRWComponent/GlobalPasskeyPrompt.tsx | 21 + .../LandingPages/PasskeyLogin.tsx | 291 ++++++++++ src/ui/FRWComponent/PasskeyManagement.tsx | 525 ++++++++++++++++++ src/ui/FRWComponent/PasskeyPrompt.tsx | 169 ++++++ src/ui/FRWComponent/Snackbar.tsx | 87 +++ src/ui/FRWComponent/WarningSnackbar.tsx | 6 +- src/ui/utils/PasskeyPromptContext.tsx | 72 +++ src/ui/utils/index.ts | 3 + src/ui/utils/usePasskey.ts | 114 ++++ src/ui/views/Forgot/index.tsx | 9 +- src/ui/views/InnerRoute.tsx | 15 + src/ui/views/Setting/PasskeySettings.tsx | 113 ++++ src/ui/views/Setting/PasskeySetup.tsx | 487 ++++++++++++++++ src/ui/views/Setting/index.tsx | 28 + src/ui/views/Unlock/index.tsx | 396 ++++++++++--- src/ui/views/index.tsx | 8 +- 27 files changed, 2926 insertions(+), 76 deletions(-) create mode 100644 src/background/service/passkey.ts create mode 100644 src/ui/FRWComponent/Dialog.tsx create mode 100644 src/ui/FRWComponent/GlobalPasskeyPrompt.tsx create mode 100644 src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx create mode 100644 src/ui/FRWComponent/PasskeyManagement.tsx create mode 100644 src/ui/FRWComponent/PasskeyPrompt.tsx create mode 100644 src/ui/FRWComponent/Snackbar.tsx create mode 100644 src/ui/utils/PasskeyPromptContext.tsx create mode 100644 src/ui/utils/usePasskey.ts create mode 100644 src/ui/views/Setting/PasskeySettings.tsx create mode 100644 src/ui/views/Setting/PasskeySetup.tsx diff --git a/_raw/_locales/en/messages.json b/_raw/_locales/en/messages.json index 0398442f1..91d620f54 100644 --- a/_raw/_locales/en/messages.json +++ b/_raw/_locales/en/messages.json @@ -1000,6 +1000,9 @@ "Things_you_should_know": { "message": "Things you should know" }, + "Passkey_Authentication": { + "message": "Passkey Authentication" + }, "Linked_Account": { "message": "Linked Account" }, diff --git a/_raw/_locales/ja/messages.json b/_raw/_locales/ja/messages.json index bc165e974..d65e3ae12 100644 --- a/_raw/_locales/ja/messages.json +++ b/_raw/_locales/ja/messages.json @@ -1003,6 +1003,9 @@ "Things_you_should_know": { "message": "知っておくべきこと" }, + "Passkey_Authentication": { + "message": "パスキー認証" + }, "Linked_Account": { "message": "リンクされたアカウント" }, diff --git a/_raw/_locales/ru/messages.json b/_raw/_locales/ru/messages.json index b91997547..898ea38da 100644 --- a/_raw/_locales/ru/messages.json +++ b/_raw/_locales/ru/messages.json @@ -1003,6 +1003,9 @@ "Things_you_should_know": { "message": "Вещи, которые вы должны знать" }, + "Passkey_Authentication": { + "message": "Пассфайр аутентификация" + }, "Linked_Account": { "message": "Связанный аккаунт" }, diff --git a/_raw/_locales/zh_CN/messages.json b/_raw/_locales/zh_CN/messages.json index 1425ae2bb..d489810c8 100644 --- a/_raw/_locales/zh_CN/messages.json +++ b/_raw/_locales/zh_CN/messages.json @@ -1003,6 +1003,9 @@ "Things_you_should_know": { "message": "你应该知道的事情" }, + "Passkey_Authentication": { + "message": "Passkey 认证" + }, "Linked_Account": { "message": "关联账户" }, diff --git a/src/background/controller/wallet.ts b/src/background/controller/wallet.ts index 9d8f85a67..3169d99cf 100644 --- a/src/background/controller/wallet.ts +++ b/src/background/controller/wallet.ts @@ -53,6 +53,7 @@ import { newsService, mixpanelTrack, evmNftService, + passkeyService, } from 'background/service'; import i18n from 'background/service/i18n'; import { type DisplayedKeryring, KEYRING_CLASS } from 'background/service/keyring'; @@ -879,7 +880,7 @@ export class WalletController extends BaseController { return preferenceService.updateIsFirstOpen(); }; // userinfo - getUserInfo = async (forceRefresh: boolean) => { + getUserInfo = async (forceRefresh: boolean): Promise => { const data = await userInfoService.getUserInfo(); if (forceRefresh) { @@ -893,7 +894,7 @@ export class WalletController extends BaseController { return await this.fetchUserInfo(); }; - fetchUserInfo = async () => { + fetchUserInfo = async (): Promise => { const result = await openapiService.userInfo(); const info = result['data']; const avatar = this.addTokenForFirebaseImage(info.avatar); @@ -3847,6 +3848,189 @@ export class WalletController extends BaseController { clearEvmNFTList = async () => { await evmNftService.clearEvmNfts(); }; + + // === Passkey Related Methods === + + /** + * Check if passkeys are supported in the current browser environment + */ + checkPasskeySupport = async (): Promise => { + return await passkeyService.isPasskeySupported(); + }; + + /** + * Create a new passkey for the current user + */ + createPasskey = async (): Promise => { + try { + const result = await passkeyService.createPasskey(); + if (result) { + // Track successful passkey creation + mixpanelTrack.track('passkey_created_success', { source: 'wallet_controller' }); + } + return result; + } catch (error) { + console.error('Error creating passkey:', error); + mixpanelTrack.track('passkey_created_failed', { source: 'wallet_controller' }); + return false; + } + }; + + /** + * Sign in with a passkey + */ + signInWithPasskey = async (): Promise => { + try { + const result = await passkeyService.signInWithPasskey(); + return result; + } catch (error) { + console.error('Error signing in with passkey:', error); + return false; + } + }; + + /** + * Check if passkeys are enabled for the current user + */ + isPasskeyEnabled = async (): Promise => { + return passkeyService.store.enabled; + }; + + /** + * Get available passkeys for the current user + */ + getAvailablePasskeys = async () => { + return await passkeyService.getAvailablePasskeys(); + }; + + /** + * Delete a specific passkey + */ + deletePasskey = async (passkeyId: string): Promise => { + return await passkeyService.deletePasskey(passkeyId); + }; + + /** + * Register a passkey credential created in the UI + */ + registerPasskeyCredential = async ( + credentialId: string, + rawId: string, + password?: string + ): Promise => { + try { + // Create a registered credential object + const registeredCredential = { + id: credentialId, + rawId, + createdAt: new Date().toISOString(), + }; + + // Add to the passkey store + passkeyService.store.registeredCredentials.push(registeredCredential); + passkeyService.store.enabled = true; + passkeyService.store.lastUsed = new Date().toISOString(); + + // If password is provided, encrypt and store it + let passwordStored = false; + if (password) { + passwordStored = await passkeyService.storeEncryptedPassword(password); + } + + // Save to storage + await storage.set('passkey', passkeyService.store); + + // Track successful passkey registration + mixpanelTrack.track('passkey_created', { + success: true, + with_password: !!password && passwordStored, + source: 'wallet_controller', + }); + + return true; + } catch (error) { + console.error('Error registering passkey credential:', error); + mixpanelTrack.track('passkey_created', { + success: false, + error: error instanceof Error ? error.message : String(error), + source: 'wallet_controller', + }); + return false; + } + }; + + /** + * Verify a passkey credential from the UI + */ + verifyPasskeyCredential = async (credentialId: string): Promise => { + try { + // Check if the credential exists in our store + const matchedCredential = passkeyService.store.registeredCredentials.find( + (cred) => cred.id === credentialId + ); + + if (matchedCredential) { + // Update last used timestamp + passkeyService.store.lastUsed = new Date().toISOString(); + await storage.set('passkey', passkeyService.store); + + // Get the decrypted password if available + const password = await passkeyService.getDecryptedPassword(); + let passwordDecrypted = false; + + if (password) { + passwordDecrypted = true; + + // Follow the same flow as the unlock method + await keyringService.submitPassword(password); + + // Store the password temporarily in the password service + await passwordService.setPassword(password); + + // Get the public key and switch login + const pubKey = await this.getPubKey(); + await userWalletService.switchLogin(pubKey); + + // Broadcast the unlock event + sessionService.broadcastEvent('unlock'); + } else { + // If no password is available, fall back to just unlocking the keyring + keyringService.setUnlocked(); + + try { + const pubKey = await this.getPubKey(); + await userWalletService.switchLogin(pubKey); + } catch (error) { + console.error('Error switching login after passkey authentication:', error); + // Even if switching login fails, the wallet is still unlocked + } + } + + // Track successful passkey sign-in + mixpanelTrack.track('passkey_signin', { + success: true, + password_decrypted: passwordDecrypted, + }); + + return true; + } + + mixpanelTrack.track('passkey_signin', { + success: false, + reason: 'credential_not_found', + }); + + return false; + } catch (error) { + console.error('Error verifying passkey credential:', error); + mixpanelTrack.track('passkey_signin', { + success: false, + error: error instanceof Error ? error.message : String(error), + }); + + return false; + } + }; } export default new WalletController(); diff --git a/src/background/index.ts b/src/background/index.ts index 0f5ce6e5f..72af77156 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -33,6 +33,7 @@ import { evmNftService, googleSafeHostService, passwordService, + passkeyService, mixpanelTrack, } from './service'; import { getFirbaseConfig } from './utils/firebaseConfig'; @@ -95,6 +96,7 @@ async function restoreAppState() { keyringService.loadStore(keyringState); keyringService.store.subscribe((value) => storage.set('keyringState', value)); await openapiService.init(); + await passkeyService.init(); // clear premnemonic in storage storage.remove('premnemonic'); diff --git a/src/background/service/index.ts b/src/background/service/index.ts index 9d865c4f2..4bfb3bc40 100644 --- a/src/background/service/index.ts +++ b/src/background/service/index.ts @@ -12,6 +12,7 @@ export { default as userWalletService } from './userWallet'; export { default as proxyService } from './proxy'; export { default as transactionService } from './transaction'; export { default as passwordService } from './password'; +export { default as passkeyService } from './passkey'; export { default as nftService } from './nft'; export { default as evmNftService } from './evmNfts'; export { default as googleDriveService } from './googleDrive'; diff --git a/src/background/service/passkey.ts b/src/background/service/passkey.ts new file mode 100644 index 000000000..65fff46b3 --- /dev/null +++ b/src/background/service/passkey.ts @@ -0,0 +1,362 @@ +import { getApp } from 'firebase/app'; +import { getAuth } from 'firebase/auth/web-extension'; + +import { storage } from '@/background/webapi'; + +import { mixpanelTrack } from './mixpanel'; + +interface PasskeyStore { + enabled: boolean; + lastUsed: string | null; + registeredCredentials: RegisteredCredential[]; + encryptedPassword?: { + data: string; + iv: string; + encryptionKey: string; + }; +} + +interface RegisteredCredential { + id: string; + rawId: string; + createdAt: string; +} + +class PasskeyService { + store!: PasskeyStore; + + init = async () => { + // Initialize the passkey store + this.store = { + enabled: false, + lastUsed: null, + registeredCredentials: [], + }; + + // Load state from storage if available + const storedData = await storage.get('passkey'); + if (storedData) { + this.store = storedData; + } + }; + + /** + * Check if passkeys are available in the browser environment + */ + isPasskeySupported = async (): Promise => { + // More robust check for Chrome extension environment + try { + // Check basic WebAuthn API availability + if (typeof PublicKeyCredential === 'undefined') { + console.log('Passkey not supported: PublicKeyCredential is undefined'); + return false; + } + + // Check if platform authenticator is available + if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') { + const isPlatformAuthenticatorAvailable = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + + console.log('Platform authenticator available:', isPlatformAuthenticatorAvailable); + return isPlatformAuthenticatorAvailable; + } + + // If we can't check specifically, assume it's available + // The actual passkey creation will fail gracefully if not supported + console.log('Cannot determine platform authenticator availability; assuming supported'); + return true; + } catch (error) { + console.error('Error checking passkey support:', error); + // In case of errors, assume it's supported and let the actual operation handle failures + return true; + } + }; + + /** + * Creates a new passkey for the current user + * @returns Promise Success status + */ + createPasskey = async (): Promise => { + try { + const app = getApp(process.env.NODE_ENV!); + const auth = getAuth(app); + const user = auth.currentUser; + + if (!user || user.isAnonymous) { + throw new Error('User must be signed in to create a passkey'); + } + + // Create a new passkey using WebAuthn + const userId = user.uid; + const displayName = user.displayName || user.email || userId; + + // Generate a random challenge + const challenge = new Uint8Array(32); + globalThis.crypto.getRandomValues(challenge); + + // Create credential options + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { + challenge, + rp: { + name: 'FRW Extension Wallet', + id: globalThis.location.hostname, + }, + user: { + id: new TextEncoder().encode(userId), + name: user.email || userId, + displayName, + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + residentKey: 'required', + }, + timeout: 60000, + attestation: 'none', + }; + + // Create credential + const credential = (await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create passkey'); + } + + // Add credential to store + const credentialId = credential.id; + const rawId = Buffer.from(credential.rawId).toString('base64'); + const registeredCredential: RegisteredCredential = { + id: credentialId, + rawId, + createdAt: new Date().toISOString(), + }; + + this.store.registeredCredentials.push(registeredCredential); + this.store.enabled = true; + this.store.lastUsed = new Date().toISOString(); + + await storage.set('passkey', this.store); + + // Track successful passkey creation + mixpanelTrack.track('passkey_created', { + success: true, + }); + + return true; + } catch (error) { + console.error('Error creating passkey:', error); + mixpanelTrack.track('passkey_created', { + success: false, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + }; + + /** + * Encrypts and stores the user's password + * @param password The user's password to encrypt and store + * @returns Promise Success status + */ + storeEncryptedPassword = async (password: string): Promise => { + try { + // Generate a random encryption key + const encryptionKey = new Uint8Array(32); + globalThis.crypto.getRandomValues(encryptionKey); + + // Encrypt the password + const encoder = new TextEncoder(); + const passwordData = encoder.encode(password); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)); + + const encryptedPassword = await globalThis.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + }, + await globalThis.crypto.subtle.importKey('raw', encryptionKey, { name: 'AES-GCM' }, false, [ + 'encrypt', + ]), + passwordData + ); + + // Store the encrypted password and encryption materials + this.store.encryptedPassword = { + data: Buffer.from(encryptedPassword).toString('base64'), + iv: Buffer.from(iv).toString('base64'), + encryptionKey: Buffer.from(encryptionKey).toString('base64'), + }; + + await storage.set('passkey', this.store); + return true; + } catch (error) { + console.error('Error storing encrypted password:', error); + return false; + } + }; + + /** + * Decrypts and returns the user's password + * @returns Promise The decrypted password or null if not available + */ + getDecryptedPassword = async (): Promise => { + try { + if (!this.store.encryptedPassword) { + return null; + } + + const { data, iv, encryptionKey } = this.store.encryptedPassword; + + // Decrypt the password + const decryptedPassword = await globalThis.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: Buffer.from(iv, 'base64'), + }, + await globalThis.crypto.subtle.importKey( + 'raw', + Buffer.from(encryptionKey, 'base64'), + { name: 'AES-GCM' }, + false, + ['decrypt'] + ), + Buffer.from(data, 'base64') + ); + + const decoder = new TextDecoder(); + return decoder.decode(decryptedPassword); + } catch (error) { + console.error('Error decrypting password:', error); + return null; + } + }; + + /** + * Sign in using passkey + */ + signInWithPasskey = async (): Promise => { + try { + if (!this.store.enabled || this.store.registeredCredentials.length === 0) { + throw new Error('No passkeys available'); + } + + // Generate a random challenge + const challenge = new Uint8Array(32); + globalThis.crypto.getRandomValues(challenge); + + // Prepare allowed credentials from stored credentials + const allowCredentials = this.store.registeredCredentials.map((cred) => ({ + type: 'public-key' as const, + id: Uint8Array.from(atob(cred.rawId), (c) => c.charCodeAt(0)), + transports: ['internal'] as AuthenticatorTransport[], + })); + + // Create credential request options + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { + challenge, + allowCredentials, + timeout: 60000, + userVerification: 'required', + rpId: globalThis.location.hostname, + }; + + // Request credential + const credential = (await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to get passkey'); + } + + // Successful authentication + const matchedCredential = this.store.registeredCredentials.find( + (cred) => cred.id === credential.id + ); + + if (matchedCredential) { + this.store.lastUsed = new Date().toISOString(); + await storage.set('passkey', this.store); + + // At this point, the user has verified their identity with passkey + // You would typically use this to authenticate with Firebase + const app = getApp(process.env.NODE_ENV!); + const auth = getAuth(app); + + if (!auth.currentUser) { + // User is not logged in, would need to authenticate with Firebase + // This would typically involve calling a backend service to verify + // the passkey assertion and return a custom token for Firebase Auth + console.warn('Firebase authentication would be needed here'); + mixpanelTrack.track('passkey_signin', { + success: false, + reason: 'not_logged_in', + }); + return false; + } + + // Track successful passkey sign-in + mixpanelTrack.track('passkey_signin', { + success: true, + }); + return true; + } + + return false; + } catch (error) { + console.error('Error signing in with passkey:', error); + mixpanelTrack.track('passkey_signin', { + success: false, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + }; + + /** + * Get available passkeys for the current user + */ + getAvailablePasskeys = async () => { + return this.store.registeredCredentials; + }; + + /** + * Delete a specific passkey + * @param passkeyId The ID of the passkey to delete + */ + deletePasskey = async (passkeyId: string): Promise => { + try { + const initialCount = this.store.registeredCredentials.length; + this.store.registeredCredentials = this.store.registeredCredentials.filter( + (cred) => cred.id !== passkeyId + ); + + // Check if any were removed + if (this.store.registeredCredentials.length === initialCount) { + return false; + } + + // Update enabled status if no credentials remain + if (this.store.registeredCredentials.length === 0) { + this.store.enabled = false; + this.store.lastUsed = null; + // Also remove the encrypted password if no passkeys remain + delete this.store.encryptedPassword; + } + + await storage.set('passkey', this.store); + return true; + } catch (error) { + console.error('Error deleting passkey:', error); + return false; + } + }; +} + +export default new PasskeyService(); diff --git a/src/messages.json b/src/messages.json index 71318a13b..301e3e7b9 100644 --- a/src/messages.json +++ b/src/messages.json @@ -970,6 +970,9 @@ "Things_you_should_know": { "message": "Things you should know" }, + "Passkey_Authentication": { + "message": "Passkey Authentication" + }, "Linked_Account": { "message": "Linked Accounts" }, diff --git a/src/shared/types/tracking-types.ts b/src/shared/types/tracking-types.ts index a66bc8e8e..7e6ca3287 100644 --- a/src/shared/types/tracking-types.ts +++ b/src/shared/types/tracking-types.ts @@ -44,6 +44,35 @@ export type TrackingEvents = { type: 'biometric' | 'pin' | 'none'; }; + // Passkey Events + passkey_created: { + success: boolean; // Whether creating the passkey was successful or not + error?: string; // Error message if creation failed + source?: string; // Source of the passkey creation + with_password?: boolean; // Whether the passkey was created with a password + }; + passkey_created_success: { + source?: string; // Source of the passkey creation success + }; + passkey_created_failed: { + source?: string; // Source of the passkey creation failure + reason?: string; // Reason for the failure + }; + passkey_signin: { + success: boolean; // Whether signing in with passkey was successful + error?: string; // Error message if sign in failed + reason?: string; // Reason for failure + password_decrypted?: boolean; // Whether a password was successfully decrypted + }; + passkey_signin_success: { + source?: string; // Source of the passkey signin success + with_password?: boolean; // Whether the signin included a decrypted password + }; + passkey_signin_failed: { + source?: string; // Source of the passkey signin failure + reason?: string; // Reason for the failure + }; + // Backup Events multi_backup_created: { address: string; // Address of the account that set up multi-backup diff --git a/src/ui/FRWComponent/Dialog.tsx b/src/ui/FRWComponent/Dialog.tsx new file mode 100644 index 000000000..07aa09a71 --- /dev/null +++ b/src/ui/FRWComponent/Dialog.tsx @@ -0,0 +1,61 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + Box, +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import React from 'react'; + +const useStyles = makeStyles(() => ({ + successIcon: { + color: '#4CAF50', + fontSize: 48, + margin: '16px auto', + display: 'block', + }, + title: { + textAlign: 'center', + }, + content: { + textAlign: 'center', + }, + actions: { + justifyContent: 'center', + padding: '16px', + }, +})); + +interface SuccessDialogProps { + open: boolean; + title: string; + message: string; + onClose: () => void; +} + +export const SuccessDialog: React.FC = ({ open, title, message, onClose }) => { + const classes = useStyles(); + + return ( + + + + {title} + + {message} + + + + + + + ); +}; + +export default { SuccessDialog }; diff --git a/src/ui/FRWComponent/GlobalPasskeyPrompt.tsx b/src/ui/FRWComponent/GlobalPasskeyPrompt.tsx new file mode 100644 index 000000000..12d63c01d --- /dev/null +++ b/src/ui/FRWComponent/GlobalPasskeyPrompt.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { usePasskeyPrompt } from '@/ui/utils/PasskeyPromptContext'; + +import PasskeyPrompt from './PasskeyPrompt'; + +/** + * A global component that displays the passkey prompt when needed. + * This component should be mounted at the root level of the application. + */ +const GlobalPasskeyPrompt: React.FC = () => { + const { shouldShowPrompt, hidePasskeyPrompt } = usePasskeyPrompt(); + + if (!shouldShowPrompt) { + return null; + } + + return ; +}; + +export default GlobalPasskeyPrompt; diff --git a/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx b/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx new file mode 100644 index 000000000..cad32cf64 --- /dev/null +++ b/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx @@ -0,0 +1,291 @@ +import KeyIcon from '@mui/icons-material/Key'; +import LockPersonIcon from '@mui/icons-material/LockPerson'; +import { Box, Typography, Button, CircularProgress } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import React, { useState, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + +import WarningSnackbar from '@/ui/FRWComponent/WarningSnackbar'; +import { useWallet } from '@/ui/utils'; +import { usePasskey } from '@/ui/utils/usePasskey'; + +const useStyles = makeStyles(() => ({ + container: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '24px', + padding: '24px', + maxWidth: '400px', + margin: '0 auto', + }, + title: { + fontWeight: 600, + marginBottom: '8px', + }, + subtitle: { + color: '#666', + textAlign: 'center', + marginBottom: '16px', + }, + passkeyButton: { + width: '100%', + padding: '12px', + borderRadius: '12px', + marginTop: '16px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + }, + orDivider: { + display: 'flex', + alignItems: 'center', + width: '100%', + margin: '16px 0', + }, + dividerLine: { + flex: 1, + height: '1px', + backgroundColor: '#e0e0e0', + }, + orText: { + padding: '0 16px', + color: '#666', + }, + passwordButton: { + width: '100%', + padding: '12px', + borderRadius: '12px', + }, + passkeyIcon: { + width: '80px', + height: '80px', + backgroundColor: '#f5f5f5', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: '16px', + }, + helpText: { + fontSize: '14px', + color: '#666', + textAlign: 'center', + marginTop: '16px', + cursor: 'pointer', + textDecoration: 'underline', + }, +})); + +interface PasskeyLoginProps { + onSwitchToPassword: () => void; +} + +const PasskeyLogin: React.FC = ({ onSwitchToPassword }) => { + const classes = useStyles(); + const history = useHistory(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [showError, setShowError] = useState(false); + const wallet = useWallet(); + + // Use our unified passkey hook instead of direct checks + const { isSupported, isEnabled } = usePasskey(); + + const handleSwitchToPassword = () => { + // If onSwitchToPassword is provided, use it + if (onSwitchToPassword) { + onSwitchToPassword(); + } else { + // Otherwise, use URL parameter approach + history.push('/?login=password'); + } + }; + + const handleSignInWithPasskey = async () => { + try { + setIsLoading(true); + setShowError(false); + + // Get available passkeys from the wallet controller (background) + const passkeyInfo = await wallet.getAvailablePasskeys(); + console.log('Available passkeys:', passkeyInfo); + + if (!passkeyInfo || passkeyInfo.length === 0) { + throw new Error('No passkeys available for this account'); + } + + // Generate a random challenge in the UI context + const challenge = new Uint8Array(32); + window.crypto.getRandomValues(challenge); + + // Get the domain for the RP ID - use the effective domain + const rpId = window.location.hostname; + console.log('Using RP ID for authentication:', rpId); + + // Prepare allowed credentials from stored credentials + const allowCredentials = passkeyInfo.map((cred) => ({ + type: 'public-key' as const, + id: Uint8Array.from(atob(cred.rawId), (c) => c.charCodeAt(0)), + transports: ['internal'] as AuthenticatorTransport[], + })); + + // Create credential request options + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { + challenge, + allowCredentials, + timeout: 60000, + userVerification: 'required', + rpId: rpId, + }; + + console.log( + 'Requesting passkey authentication with options:', + publicKeyCredentialRequestOptions + ); + + try { + // Request credential in the UI context (WebAuthn API) + const credential = (await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to get passkey'); + } + + console.log('Passkey authentication successful in UI context:', credential); + + // Verify the credential with the wallet service in the background + // This will also decrypt and store the password if available, and unlock the wallet + console.log('Verifying credential with ID:', credential.id); + const success = await wallet.verifyPasskeyCredential(credential.id); + console.log('Verification result:', success); + + if (success) { + console.log('Passkey authentication complete, wallet unlocked'); + // Successful login, navigate to home + history.push('/'); + } else { + console.error('Passkey verification failed in the background'); + setError('Failed to verify passkey. Please try again.'); + setShowError(true); + } + } catch (credentialError: any) { + console.error('Error getting credential:', credentialError); + + // Provide more specific error messages based on the error + if (credentialError.name === 'NotAllowedError') { + setError( + 'Permission denied. You may have cancelled the authentication or your device denied the request.' + ); + } else if (credentialError.name === 'SecurityError') { + setError( + 'Security error. The operation is not allowed in this context or with these parameters.' + ); + } else { + setError( + `Failed to authenticate with passkey: ${credentialError.message || 'Unknown error'}` + ); + } + setShowError(true); + } + } catch (error: any) { + console.error('Error signing in with passkey:', error); + setError( + 'An error occurred during passkey authentication. Please try again or use your password.' + ); + setShowError(true); + } finally { + setIsLoading(false); + } + }; + + if (!isSupported) { + return ( + + + + + + Passkeys Not Supported + + + Your browser or device doesn't support passkeys. Please use password login instead. + + + + ); + } + + return ( + + + + + + Sign in with Passkey + + + Use your passkey to quickly and securely sign in to your wallet without entering a password. + + + + + {/* Only show password option if passkeys are not yet enabled */} + {!isEnabled && ( + <> + + + + OR + + + + + + + )} + + {/* Always show a way to access password login in case of issues */} + {isEnabled && ( + + Having trouble? Use password instead + + )} + + setShowError(false)} /> + + ); +}; + +export default PasskeyLogin; diff --git a/src/ui/FRWComponent/PasskeyManagement.tsx b/src/ui/FRWComponent/PasskeyManagement.tsx new file mode 100644 index 000000000..d2e790a72 --- /dev/null +++ b/src/ui/FRWComponent/PasskeyManagement.tsx @@ -0,0 +1,525 @@ +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import KeyIcon from '@mui/icons-material/Key'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; +import { + Box, + Typography, + Button, + CircularProgress, + Card, + CardContent, + List, + ListItem, + ListItemIcon, + ListItemText, + ListItemSecondaryAction, + IconButton, + Divider, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + TextField, + InputAdornment, +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import dayjs from 'dayjs'; +import React, { useState, useEffect, useCallback } from 'react'; + +import { useWallet } from '@/ui/utils'; +import { usePasskey } from '@/ui/utils/usePasskey'; + +import { SuccessDialog } from './Dialog'; +import WarningSnackbar from './WarningSnackbar'; + +const useStyles = makeStyles(() => ({ + container: { + padding: '16px', + maxWidth: '368px', // 400px - 16px padding on each side + margin: '0 auto', + }, + title: { + fontWeight: 600, + marginBottom: '12px', + fontSize: '20px', + }, + subtitle: { + color: '#666', + marginBottom: '16px', + fontSize: '14px', + }, + card: { + borderRadius: '12px', + marginBottom: '16px', + }, + cardContent: { + padding: '16px !important', + }, + passkeyIcon: { + width: '40px', + height: '40px', + backgroundColor: '#f5f5f5', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: '12px', + }, + headerContainer: { + display: 'flex', + alignItems: 'center', + marginBottom: '12px', + }, + actionButton: { + padding: '10px', + borderRadius: '12px', + marginTop: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + }, + passwordField: { + marginTop: '12px', + marginBottom: '12px', + width: '100%', + }, + noPasskeysMessage: { + textAlign: 'center', + padding: '16px', + color: '#666', + }, + listItem: { + borderRadius: '8px', + marginBottom: '4px', + padding: '4px 8px', + '&:hover': { + backgroundColor: '#f5f5f5', + }, + }, + createdDate: { + fontSize: '12px', + color: '#666', + }, +})); + +interface PasskeyInfo { + id: string; + rawId: string; + createdAt: string; +} + +const PasskeyManagement: React.FC = () => { + const classes = useStyles(); + const wallet = useWallet(); + const { isSupported, isEnabled, checkPasskeyStatus } = usePasskey(); + + const [passkeys, setPasskeys] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [showError, setShowError] = useState(false); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + // Password for recreating passkey + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [passwordError, setPasswordError] = useState(''); + + // Dialog states + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [recreateDialogOpen, setRecreateDialogOpen] = useState(false); + const [selectedPasskey, setSelectedPasskey] = useState(null); + + // Load passkeys + const loadPasskeys = useCallback(async () => { + try { + setIsLoading(true); + const passkeyInfo = await wallet.getAvailablePasskeys(); + setPasskeys(passkeyInfo || []); + } catch (error) { + console.error('Error loading passkeys:', error); + setError('Failed to load passkeys. Please try again.'); + setShowError(true); + } finally { + setIsLoading(false); + } + }, [wallet]); + + useEffect(() => { + loadPasskeys(); + }, [loadPasskeys]); + + const handlePasswordChange = (event: React.ChangeEvent) => { + setPassword(event.target.value); + setPasswordError(''); + }; + + const toggleShowPassword = () => { + setShowPassword(!showPassword); + }; + + const validatePassword = async () => { + if (!password) { + setPasswordError('Password is required'); + return false; + } + + try { + // Verify the password is correct + await wallet.verifyPassword(password); + return true; + } catch (error) { + setPasswordError('Incorrect password. Please try again.'); + return false; + } + }; + + // Handle delete passkey + const handleDeletePasskey = async () => { + if (!selectedPasskey) return; + + try { + setIsLoading(true); + const success = await wallet.deletePasskey(selectedPasskey.id); + + if (success) { + setSuccessMessage('Passkey deleted successfully'); + setShowSuccessDialog(true); + await loadPasskeys(); + await checkPasskeyStatus(); + } else { + setError('Failed to delete passkey. Please try again.'); + setShowError(true); + } + } catch (error) { + console.error('Error deleting passkey:', error); + setError('An error occurred while deleting the passkey. Please try again.'); + setShowError(true); + } finally { + setIsLoading(false); + setDeleteDialogOpen(false); + } + }; + + // Handle recreate passkey + const handleRecreatePasskey = async () => { + try { + // First validate the password + const isPasswordValid = await validatePassword(); + if (!isPasswordValid) { + return; + } + + setIsLoading(true); + + // Delete existing passkeys + for (const passkey of passkeys) { + await wallet.deletePasskey(passkey.id); + } + + // Create a new passkey using WebAuthn in the UI context + // Generate a random challenge + const challenge = new Uint8Array(32); + window.crypto.getRandomValues(challenge); + + // Get user info from wallet controller + const userInfo = await wallet.getUserInfo(false); + if (!userInfo || !userInfo.username) { + throw new Error('User information not available'); + } + + // Get the domain for the RP ID - use the effective domain + const rpId = window.location.hostname; + + // Create credential options + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { + challenge, + rp: { + name: 'FRW Extension Wallet', + id: rpId, + }, + user: { + id: new TextEncoder().encode(userInfo.username), + name: userInfo.username, + displayName: userInfo.nickname || userInfo.username, + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + residentKey: 'required', + }, + timeout: 60000, + attestation: 'none', + }; + + try { + // Create credential in the UI context + const credential = (await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create passkey'); + } + + // Extract credential data to send to the background + const credentialId = credential.id; + const rawId = Buffer.from(credential.rawId).toString('base64'); + + // Register the credential with the wallet service in the background + const success = await wallet.registerPasskeyCredential(credentialId, rawId, password); + + if (success) { + setSuccessMessage('Passkey recreated successfully'); + setShowSuccessDialog(true); + await loadPasskeys(); + await checkPasskeyStatus(); + } else { + setError('Failed to register new passkey. Please try again.'); + setShowError(true); + } + } catch (credentialError: any) { + console.error('Error creating credential:', credentialError); + + // Provide more specific error messages based on the error + if (credentialError.name === 'NotAllowedError') { + setError( + 'Permission denied. You may have cancelled the passkey creation or your device denied the request.' + ); + } else if (credentialError.name === 'SecurityError') { + setError( + 'Security error. The operation is not allowed in this context or with these parameters.' + ); + } else { + setError(`Failed to create passkey: ${credentialError.message || 'Unknown error'}`); + } + setShowError(true); + } + } catch (error) { + console.error('Error recreating passkey:', error); + setError('An error occurred while recreating the passkey. Please try again.'); + setShowError(true); + } finally { + setIsLoading(false); + setRecreateDialogOpen(false); + setPassword(''); + } + }; + + // Format date for display + const formatDate = (dateString: string) => { + try { + return dayjs(dateString).format('MMM D, YYYY h:mm A'); + } catch (error) { + return dateString; + } + }; + + if (!isSupported) { + return ( + + + Passkeys Not Supported + + + Your browser or device doesn't support passkeys. This feature requires a compatible + browser and platform authenticator. + + + ); + } + + return ( + + + + + +
+ + Manage Passkeys + + + View, recreate, or remove passkeys for your wallet + +
+
+ + + + + Your Passkeys + + + {isLoading ? ( + + + + ) : passkeys.length === 0 ? ( + + + You don't have any passkeys set up yet. + + + + ) : ( + <> + + {passkeys.map((passkey) => ( + + + + + + Created: {formatDate(passkey.createdAt)} + + } + /> + + { + setSelectedPasskey(passkey); + setDeleteDialogOpen(true); + }} + > + + + + + ))} + + + + + + + )} + + + + {/* Delete Passkey Dialog */} + setDeleteDialogOpen(false)} + aria-labelledby="delete-dialog-title" + > + Delete Passkey + + + Are you sure you want to delete this passkey? You will need to enter your password to + sign in until you set up a new passkey. + + + + + + + + + {/* Recreate Passkey Dialog */} + setRecreateDialogOpen(false)} + aria-labelledby="recreate-dialog-title" + > + Recreate Passkey + + + This will delete your existing passkey and create a new one. You'll need to enter your + current wallet password to continue. + + + + {showPassword ? : } + + + ), + }} + /> + + + + + + + + {/* Success Dialog */} + setShowSuccessDialog(false)} + /> + + {/* Error Snackbar */} + setShowError(false)} /> +
+ ); +}; + +export default PasskeyManagement; diff --git a/src/ui/FRWComponent/PasskeyPrompt.tsx b/src/ui/FRWComponent/PasskeyPrompt.tsx new file mode 100644 index 000000000..038d2c1aa --- /dev/null +++ b/src/ui/FRWComponent/PasskeyPrompt.tsx @@ -0,0 +1,169 @@ +import CloseIcon from '@mui/icons-material/Close'; +import KeyIcon from '@mui/icons-material/Key'; +import { Box, Typography, Button, Paper } from '@mui/material'; +import React, { useState, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { usePasskeyPrompt } from '@/ui/utils/PasskeyPromptContext'; + +interface PasskeyPromptProps { + onClose?: () => void; // Make onClose optional +} + +const PasskeyPrompt: React.FC = ({ onClose }) => { + const history = useHistory(); + const [isClosing, setIsClosing] = useState(false); + const [isVisible, setIsVisible] = useState(false); + const { hidePasskeyPrompt } = usePasskeyPrompt(); + + // Add a slight delay before showing the prompt to ensure it appears after login + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(true); + }, 500); + + return () => clearTimeout(timer); + }, []); + + const handleSetupPasskey = () => { + // Navigate to the passkey setup page + history.push('/dashboard/setting/passkey-setup'); + handleClose(); + }; + + const handleSkip = () => { + setIsClosing(true); + setTimeout(() => { + handleClose(); + }, 300); // Match animation duration + }; + + const handleClose = () => { + // Call both the local onClose and the global hidePasskeyPrompt + if (onClose) onClose(); + hidePasskeyPrompt(); + }; + + if (!isVisible) return null; + + return ( + + + + + + + + Set Up Passkey Authentication + + + Sign in faster and more securely with passkeys. Use your device's biometrics or PIN + instead of typing your password. + + + + + + + + + + ); +}; + +export default PasskeyPrompt; diff --git a/src/ui/FRWComponent/Snackbar.tsx b/src/ui/FRWComponent/Snackbar.tsx new file mode 100644 index 000000000..db5f6e494 --- /dev/null +++ b/src/ui/FRWComponent/Snackbar.tsx @@ -0,0 +1,87 @@ +import { Snackbar, Alert, type AlertProps } from '@mui/material'; +import React from 'react'; + +interface ErrorSnackbarProps { + open: boolean; + message: string; + onClose: () => void; + duration?: number; +} + +export const ErrorSnackbar: React.FC = ({ + open, + message, + onClose, + duration = 6000, +}) => { + return ( + + + {message} + + + ); +}; + +interface SuccessSnackbarProps { + open: boolean; + message: string; + onClose: () => void; + duration?: number; +} + +export const SuccessSnackbar: React.FC = ({ + open, + message, + onClose, + duration = 6000, +}) => { + return ( + + + {message} + + + ); +}; + +interface CustomSnackbarProps { + open: boolean; + message: React.ReactNode; + onClose: () => void; + severity?: AlertProps['severity']; + duration?: number; +} + +export const CustomSnackbar: React.FC = ({ + open, + message, + onClose, + severity = 'info', + duration = 6000, +}) => { + return ( + + + {message} + + + ); +}; + +export default { ErrorSnackbar, SuccessSnackbar, CustomSnackbar }; diff --git a/src/ui/FRWComponent/WarningSnackbar.tsx b/src/ui/FRWComponent/WarningSnackbar.tsx index b998f7e3e..7b2a670f0 100644 --- a/src/ui/FRWComponent/WarningSnackbar.tsx +++ b/src/ui/FRWComponent/WarningSnackbar.tsx @@ -3,10 +3,12 @@ import { Snackbar, Alert } from '@mui/material'; import React from 'react'; +import warningIcon from '../FRWAssets/svg/lowStorage.svg'; + interface WarningSnackbarProps { open: boolean; onClose: () => void; - alertIcon: string; + alertIcon?: string; message: string | React.ReactNode; sx?: object; } @@ -14,7 +16,7 @@ interface WarningSnackbarProps { export default function WarningSnackbar({ open, onClose, - alertIcon, + alertIcon = warningIcon, message, sx, }: WarningSnackbarProps) { diff --git a/src/ui/utils/PasskeyPromptContext.tsx b/src/ui/utils/PasskeyPromptContext.tsx new file mode 100644 index 000000000..69cec97aa --- /dev/null +++ b/src/ui/utils/PasskeyPromptContext.tsx @@ -0,0 +1,72 @@ +import React, { createContext, useState, useContext, useEffect } from 'react'; + +import { usePasskey } from './usePasskey'; + +interface PasskeyPromptContextType { + shouldShowPrompt: boolean; + showPasskeyPrompt: () => void; + hidePasskeyPrompt: () => void; +} + +const PasskeyPromptContext = createContext({ + shouldShowPrompt: false, + showPasskeyPrompt: () => {}, + hidePasskeyPrompt: () => {}, +}); + +export const usePasskeyPrompt = () => useContext(PasskeyPromptContext); + +export const PasskeyPromptProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [shouldShowPrompt, setShouldShowPrompt] = useState(false); + const { isSupported, isEnabled, isLoading } = usePasskey(); + + // Check if we should show the prompt when the passkey status changes + useEffect(() => { + // Only show the prompt if passkeys are supported but not enabled + if (isSupported && !isEnabled && !isLoading) { + // Get the last time we showed the prompt + const checkLastPrompt = async () => { + try { + const lastPrompt = await chrome.storage.local.get('lastPasskeyPrompt'); + const now = new Date().getTime(); + + // If we've never shown the prompt or it's been more than 7 days + if ( + !lastPrompt.lastPasskeyPrompt || + now - lastPrompt.lastPasskeyPrompt > 7 * 24 * 60 * 60 * 1000 + ) { + setShouldShowPrompt(true); + // Update the last prompt time + await chrome.storage.local.set({ lastPasskeyPrompt: now }); + } + } catch (error) { + console.error('Error checking last passkey prompt:', error); + } + }; + + checkLastPrompt(); + } + }, [isSupported, isEnabled, isLoading]); + + const showPasskeyPrompt = () => { + if (isSupported && !isEnabled) { + setShouldShowPrompt(true); + } + }; + + const hidePasskeyPrompt = () => { + setShouldShowPrompt(false); + }; + + return ( + + {children} + + ); +}; diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts index 1ed2bab5f..00715d0b7 100644 --- a/src/ui/utils/index.ts +++ b/src/ui/utils/index.ts @@ -164,3 +164,6 @@ export const returnFilteredCollections = (contractList, NFT) => { collection.contractName === searchName || collection.contract_name === searchName ); }; + +export { usePasskey } from './usePasskey'; +export { usePasskeyPrompt, PasskeyPromptProvider } from './PasskeyPromptContext'; diff --git a/src/ui/utils/usePasskey.ts b/src/ui/utils/usePasskey.ts new file mode 100644 index 000000000..787f60aea --- /dev/null +++ b/src/ui/utils/usePasskey.ts @@ -0,0 +1,114 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { useWallet } from './index'; + +/** + * Custom hook to check if passkeys are supported by the browser and enabled for the user + * + * @returns {Object} An object containing: + * - isSupported: boolean indicating if passkeys are supported by the browser + * - isEnabled: boolean indicating if passkeys are enabled for the user + * - isLoading: boolean indicating if the check is in progress + * - error: string containing any error message + * - checkPasskeyStatus: function to manually trigger a status check + */ +export const usePasskey = () => { + const wallet = useWallet(); + const [isSupported, setIsSupported] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [debugInfo, setDebugInfo] = useState({}); + + const checkPasskeyStatus = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + const debug: any = { + browserChecks: {}, + walletChecks: {}, + }; + + // First check browser support using the WebAuthn API directly + const browserSupported = + typeof window !== 'undefined' && + typeof window.PublicKeyCredential !== 'undefined' && + typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === + 'function'; + + debug.browserChecks.hasPublicKeyCredential = + typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined'; + debug.browserChecks.hasIsUserVerifyingPlatformAuthenticatorAvailable = + debug.browserChecks.hasPublicKeyCredential && + typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === + 'function'; + debug.browserChecks.browserSupported = browserSupported; + + let platformAuthenticatorAvailable = false; + + if (browserSupported) { + try { + platformAuthenticatorAvailable = + await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + debug.browserChecks.platformAuthenticatorAvailable = platformAuthenticatorAvailable; + } catch (err) { + console.error('Error checking platform authenticator availability:', err); + debug.browserChecks.platformAuthenticatorError = + err instanceof Error ? err.message : String(err); + // If there's an error checking, we'll assume it's not available + platformAuthenticatorAvailable = false; + } + } + + // Set support status based on browser capabilities + const supported = browserSupported && platformAuthenticatorAvailable; + setIsSupported(supported); + debug.supported = supported; + + // Only check if passkeys are enabled if the browser supports them + if (supported) { + try { + // Check if there are any available passkeys + const availablePasskeys = await wallet.getAvailablePasskeys(); + debug.walletChecks.availablePasskeys = availablePasskeys + ? { count: availablePasskeys.length } + : null; + + const enabled = Array.isArray(availablePasskeys) && availablePasskeys.length > 0; + setIsEnabled(enabled); + debug.enabled = enabled; + } catch (err) { + console.error('Error checking if passkeys are enabled:', err); + debug.walletChecks.error = err instanceof Error ? err.message : String(err); + setIsEnabled(false); + } + } else { + setIsEnabled(false); + } + + setDebugInfo(debug); + console.log('Passkey debug info:', debug); + } catch (err) { + console.error('Error checking passkey status:', err); + setError('Failed to check passkey status'); + setIsSupported(false); + setIsEnabled(false); + } finally { + setIsLoading(false); + } + }, [wallet]); + + useEffect(() => { + checkPasskeyStatus(); + }, [checkPasskeyStatus]); + + return { + isSupported, + isEnabled, + isLoading, + error, + checkPasskeyStatus, + debugInfo, + }; +}; diff --git a/src/ui/views/Forgot/index.tsx b/src/ui/views/Forgot/index.tsx index 09176b494..425434ae0 100644 --- a/src/ui/views/Forgot/index.tsx +++ b/src/ui/views/Forgot/index.tsx @@ -1,13 +1,18 @@ +import LockPersonIcon from '@mui/icons-material/LockPerson'; import { Typography, Button, CardMedia } from '@mui/material'; import { Box } from '@mui/system'; import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import recover from '@/ui/FRWAssets/svg/recover.svg'; import reset from '@/ui/FRWAssets/svg/resetarrow.svg'; import RegisterHeader from '@/ui/FRWComponent/LandingPages/RegisterHeader'; +import { usePasskey } from '@/ui/utils/usePasskey'; const Forgot = () => { + const history = useHistory(); + const { isEnabled: isPasskeyEnabled } = usePasskey(); + return ( <> { { + + + + + + + + + + + + diff --git a/src/ui/views/Setting/PasskeySettings.tsx b/src/ui/views/Setting/PasskeySettings.tsx new file mode 100644 index 000000000..f24828bd7 --- /dev/null +++ b/src/ui/views/Setting/PasskeySettings.tsx @@ -0,0 +1,113 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import KeyIcon from '@mui/icons-material/Key'; +import { + Typography, + List, + ListItemText, + ListItemIcon, + ListItem, + ListItemButton, + Divider, + Box, +} from '@mui/material'; +import React, { useEffect } from 'react'; +import { Link } from 'react-router-dom'; + +import { LLHeader } from '@/ui/FRWComponent/LLHeader'; +import { useWallet } from '@/ui/utils'; +import { usePasskey } from '@/ui/utils/usePasskey'; + +import IconEnd from '../../../components/iconfont/IconAVector11Stroke'; + +const PasskeySettings = () => { + const wallet = useWallet(); + const { isSupported, isEnabled, checkPasskeyStatus } = usePasskey(); + + useEffect(() => { + wallet.setDashIndex(3); + checkPasskeyStatus(); + }, [wallet, checkPasskeyStatus]); + + return ( +
+ + + {!isSupported ? ( + + + Passkeys Not Supported + + + Your browser or device doesn't support passkeys. This feature requires a compatible + browser and platform authenticator. + + + To use passkeys, you need: + + + + + + + + + + + ) : ( + <> + + + + + + + + + + + + + + + + {isEnabled && ( + + + Passkeys provide a more secure and convenient way to sign in to your wallet + without entering your password. + + + ✓ Passkey is set up and ready to use + + + )} + + )} + +
+ ); +}; + +export default PasskeySettings; diff --git a/src/ui/views/Setting/PasskeySetup.tsx b/src/ui/views/Setting/PasskeySetup.tsx new file mode 100644 index 000000000..9c6daadea --- /dev/null +++ b/src/ui/views/Setting/PasskeySetup.tsx @@ -0,0 +1,487 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import DevicesIcon from '@mui/icons-material/Devices'; +import KeyIcon from '@mui/icons-material/Key'; +import SecurityIcon from '@mui/icons-material/Security'; +import SpeedIcon from '@mui/icons-material/Speed'; +import Visibility from '@mui/icons-material/Visibility'; +import VisibilityOff from '@mui/icons-material/VisibilityOff'; +import { + Box, + Typography, + Button, + CircularProgress, + Card, + CardContent, + List, + ListItem, + ListItemIcon, + ListItemText, + Divider, + TextField, + InputAdornment, + IconButton, +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import React, { useState, useEffect } from 'react'; +import { Link, useHistory } from 'react-router-dom'; + +import { useWallet } from '@/ui/utils'; +import { usePasskey } from '@/ui/utils/usePasskey'; + +import { SuccessDialog } from '../../FRWComponent/Dialog'; +import { LLHeader } from '../../FRWComponent/LLHeader'; +import WarningSnackbar from '../../FRWComponent/WarningSnackbar'; +const useStyles = makeStyles(() => ({ + container: { + padding: '12px', + width: '100%', + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + }, + title: { + fontWeight: 600, + marginBottom: '4px', + fontSize: '18px', + color: '#fff', + }, + subtitle: { + color: 'rgba(255, 255, 255, 0.7)', + marginBottom: '12px', + fontSize: '13px', + }, + card: { + borderRadius: '12px', + marginBottom: '12px', + backgroundColor: '#2C2C2C', + color: '#fff', + }, + cardContent: { + padding: '12px !important', + }, + benefitsList: { + marginBottom: '12px', + padding: '0', + }, + benefitItem: { + marginBottom: '2px', + padding: '2px 0', + }, + setupButton: { + padding: '8px', + borderRadius: '12px', + marginTop: '12px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + }, + passkeyIcon: { + width: '32px', + height: '32px', + backgroundColor: '#3A3A3A', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginRight: '10px', + }, + headerContainer: { + display: 'flex', + alignItems: 'center', + marginBottom: '8px', + }, + statusSwitch: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: '8px', + }, + passwordField: { + marginTop: '8px', + marginBottom: '8px', + width: '100%', + '& .MuiOutlinedInput-root': { + backgroundColor: '#282828', + color: '#fff', + '& fieldset': { + borderColor: '#4C4C4C', + }, + '&:hover fieldset': { + borderColor: '#6C6C6C', + }, + '&.Mui-focused fieldset': { + borderColor: '#1976d2', + }, + }, + '& .MuiInputLabel-root': { + color: 'rgba(255, 255, 255, 0.7)', + fontSize: '14px', + }, + '& .MuiFormHelperText-root': { + color: '#f44336', + marginTop: '2px', + fontSize: '12px', + }, + }, + benefitPrimary: { + color: '#fff !important', + fontSize: '14px !important', + }, + benefitSecondary: { + color: 'rgba(255, 255, 255, 0.7) !important', + fontSize: '12px !important', + }, + listItemIcon: { + minWidth: '36px', + }, +})); + +const PasskeySetup: React.FC = () => { + const classes = useStyles(); + const [isLoading, setIsLoading] = useState(false); + const [passkeyEnabled, setPasskeyEnabled] = useState(false); + const [showSuccessDialog, setShowSuccessDialog] = useState(false); + const [error, setError] = useState(''); + const [showError, setShowError] = useState(false); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [passwordError, setPasswordError] = useState(''); + const history = useHistory(); + const wallet = useWallet(); + + // Use our new passkey hook + const { isSupported: isPasskeySupported, isEnabled, checkPasskeyStatus } = usePasskey(); + + // Update local state when passkey status changes + useEffect(() => { + setPasskeyEnabled(isEnabled); + }, [isEnabled]); + + const handlePasswordChange = (event: React.ChangeEvent) => { + setPassword(event.target.value); + setPasswordError(''); + }; + + const toggleShowPassword = () => { + setShowPassword(!showPassword); + }; + + const validatePassword = async () => { + if (!password) { + setPasswordError('Password is required to set up a passkey'); + return false; + } + + try { + // Verify the password is correct + await wallet.verifyPassword(password); + return true; + } catch (error) { + setPasswordError('Incorrect password. Please try again.'); + return false; + } + }; + + const handleSetupPasskey = async () => { + try { + // First validate the password + const isPasswordValid = await validatePassword(); + if (!isPasswordValid) { + return; + } + + setIsLoading(true); + setShowError(false); + + // Create a new passkey using WebAuthn in the UI context + // Generate a random challenge + const challenge = new Uint8Array(32); + window.crypto.getRandomValues(challenge); + + // Get user info from wallet controller + const userInfo = await wallet.getUserInfo(false); + if (!userInfo || !userInfo.username) { + throw new Error('User information not available'); + } + + // Get the domain for the RP ID - use the effective domain + const rpId = window.location.hostname; + + // Create credential options + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { + challenge, + rp: { + name: 'FRW Extension Wallet', + id: rpId, + }, + user: { + id: new TextEncoder().encode(userInfo.username), + name: userInfo.username, + displayName: userInfo.nickname || userInfo.username, + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + residentKey: 'required', + }, + timeout: 60000, + attestation: 'none', + }; + + try { + // Create credential in the UI context + const credential = (await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create passkey'); + } + + // Extract credential data to send to the background + const credentialId = credential.id; + const rawId = Buffer.from(credential.rawId).toString('base64'); + + // Register the credential with the wallet service in the background + // Pass the password for encryption + const success = await wallet.registerPasskeyCredential(credentialId, rawId, password); + + if (success) { + setPasskeyEnabled(true); + setShowSuccessDialog(true); + // Refresh passkey status + checkPasskeyStatus(); + } else { + setError('Failed to register passkey with wallet. Please try again.'); + setShowError(true); + } + } catch (credentialError: any) { + console.error('Error creating credential:', credentialError); + + // Provide more specific error messages based on the error + if (credentialError.name === 'NotAllowedError') { + setError('Permission denied. You may have cancelled the request.'); + } else if (credentialError.name === 'SecurityError') { + setError('Security error. Operation not allowed in this context.'); + } else { + setError(`Failed to create passkey: ${credentialError.message || 'Unknown error'}`); + } + setShowError(true); + } + } catch (error) { + console.error('Error setting up passkey:', error); + setError('An error occurred while trying to set up passkey.'); + setShowError(true); + } finally { + setIsLoading(false); + } + }; + + if (!isPasskeySupported) { + return ( + + + + + Passkeys Not Supported + + + Your browser or device doesn't support passkeys. + + + + + To use passkeys, you need: + + + + + + + + + + + + + ); + } + + return ( +
+ + + + + + Benefits of Passkeys + + + + + + + + + + + + + + + + + + + + + + + + + + + + {!passkeyEnabled && ( + <> + + Enter your current wallet password: + + + + + {showPassword ? ( + + ) : ( + + )} + + + ), + }} + /> + + )} + + {!passkeyEnabled && ( + + )} + + {passkeyEnabled && ( + <> + + + Passkey is set up and ready to use + + + + + )} + + + + setShowSuccessDialog(false)} + /> + + setShowError(false)} /> + +
+ ); +}; + +export default PasskeySetup; diff --git a/src/ui/views/Setting/index.tsx b/src/ui/views/Setting/index.tsx index dc00e0c8f..f4c214d9a 100644 --- a/src/ui/views/Setting/index.tsx +++ b/src/ui/views/Setting/index.tsx @@ -1,6 +1,8 @@ import AndroidIcon from '@mui/icons-material/Android'; import AppleIcon from '@mui/icons-material/Apple'; +import KeyIcon from '@mui/icons-material/Key'; import PhoneIphoneIcon from '@mui/icons-material/PhoneIphone'; +import SecurityIcon from '@mui/icons-material/Security'; import { Typography, List, @@ -18,6 +20,7 @@ import { Link } from 'react-router-dom'; import { LLHeader } from '@/ui/FRWComponent'; import { useWallet } from '@/ui/utils'; +import { usePasskey } from '@/ui/utils/usePasskey'; import Device from 'ui/FRWAssets/svg/device.svg'; import IconLink from 'ui/FRWAssets/svg/Iconlink.svg'; @@ -88,6 +91,7 @@ const SettingTab = () => { const usewallet = useWallet(); const [isActive, setIsActive] = useState(false); const [isKeyphrase, setIsKeyphrase] = useState(false); + const { isSupported: isPasskeySupported } = usePasskey(); const checkIsActive = useCallback(async () => { // setSending(true); @@ -210,6 +214,30 @@ const SettingTab = () => { )} + + {/* Add Passkey Settings if supported */} + {isPasskeySupported && ( + <> + + + + + + + + + + + + + + )} diff --git a/src/ui/views/Unlock/index.tsx b/src/ui/views/Unlock/index.tsx index 926a40162..dba9a4738 100644 --- a/src/ui/views/Unlock/index.tsx +++ b/src/ui/views/Unlock/index.tsx @@ -1,13 +1,20 @@ // import { useTranslation } from 'react-i18next'; -import { Input, Typography, Box, FormControl } from '@mui/material'; +import KeyIcon from '@mui/icons-material/Key'; +import { Input, Typography, Box, FormControl, Button, Divider } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; import { makeStyles } from '@mui/styles'; import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; import lilo from '@/ui/FRWAssets/image/lilo.png'; import { LLPrimaryButton, LLResetPopup } from '@/ui/FRWComponent'; +import PasskeyPrompt from '@/ui/FRWComponent/PasskeyPrompt'; import SlideRelative from '@/ui/FRWComponent/SlideRelative'; +import { ErrorSnackbar } from '@/ui/FRWComponent/Snackbar'; import { useProfiles } from '@/ui/hooks/useProfileHook'; import { useWallet, useApproval, useWalletRequest, useWalletLoaded } from '@/ui/utils'; +import { usePasskeyPrompt } from '@/ui/utils/PasskeyPromptContext'; +import { usePasskey } from '@/ui/utils/usePasskey'; import { openInternalPageInTab } from '@/ui/utils/webapi'; import CancelIcon from '../../../components/iconfont/IconClose'; @@ -33,6 +40,36 @@ const useStyles = makeStyles(() => ({ boxShadow: '0px 8px 12px 4px rgba(76, 76, 76, 0.24)', }, }, + passkeyButton: { + width: '100%', + padding: '12px', + borderRadius: '12px', + marginBottom: '16px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + backgroundColor: 'success.main', + color: '#fff', + '&:hover': { + backgroundColor: 'success.dark', + }, + }, + orDivider: { + display: 'flex', + alignItems: 'center', + width: '100%', + margin: '16px 0', + }, + dividerLine: { + flex: 1, + height: '1px', + backgroundColor: '#4C4C4C', + }, + orText: { + padding: '0 16px', + color: '#777', + }, })); const UsernameError: React.FC = () => ( @@ -59,12 +96,47 @@ const Unlock = () => { const classes = useStyles(); const [, resolveApproval] = useApproval(); const inputEl = useRef(null); + const location = useLocation(); + const history = useHistory(); // const { t } = useTranslation(); const [showError, setShowError] = useState(false); const [password, setPassword] = useState(DEFAULT_PASSWORD); const [resetPop, setResetPop] = useState(false); const { clearProfileData } = useProfiles(); + // Use our new passkey hook + const { + isSupported: isPasskeySupported, + isEnabled: isPasskeyEnabled, + isLoading: isPasskeyStatusLoading, + } = usePasskey(); + + // Passkey authentication state + const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); + const [passkeyError, setPasskeyError] = useState(''); + const [showPasskeyError, setShowPasskeyError] = useState(false); + + // State for showing the passkey prompt + const { showPasskeyPrompt } = usePasskeyPrompt(); + + // Check if we should force showing the password field + const searchParams = new URLSearchParams(location.search); + const forcePasswordLogin = searchParams.get('login') === 'password'; + + // Check passkey status on component mount + useEffect(() => { + console.log('Passkey status:', { + isPasskeySupported, + isPasskeyEnabled, + isPasskeyStatusLoading, + }); + }, [isPasskeySupported, isPasskeyEnabled, isPasskeyStatusLoading]); + + // Monitor the showPasskeyPrompt state + useEffect(() => { + console.log('showPasskeyPrompt state changed:', showPasskeyPrompt); + }, [showPasskeyPrompt]); + useEffect(() => { if (!inputEl.current) return; inputEl.current.focus(); @@ -74,11 +146,29 @@ const Unlock = () => { // setResetPop(true); await wallet.lockWallet(); clearProfileData(); - openInternalPageInTab('forgot'); - }, [wallet, clearProfileData]); + // If we're already using passkeys, navigate to the forgot page with a parameter + // to indicate we might want to use password login when we return + if (isPasskeyEnabled && isPasskeySupported && !forcePasswordLogin) { + openInternalPageInTab('forgot?return=password'); + } else { + openInternalPageInTab('forgot'); + } + }, [wallet, clearProfileData, isPasskeyEnabled, isPasskeySupported, forcePasswordLogin]); const [run] = useWalletRequest(wallet.unlock, { onSuccess() { + // If passkeys are supported but not enabled, trigger the global prompt + // Make sure to check this regardless of forcePasswordLogin + if (isPasskeySupported && !isPasskeyEnabled && !isPasskeyStatusLoading) { + console.log('Triggering global passkey setup prompt after successful login'); + showPasskeyPrompt(); + } else { + console.log('Not showing passkey prompt:', { + isPasskeySupported, + isPasskeyEnabled, + isPasskeyStatusLoading, + }); + } resolveApproval('unlocked'); }, onError(err) { @@ -100,6 +190,106 @@ const Unlock = () => { run(password); }, [run, password]); + // Handle passkey sign-in + const handlePasskeySignIn = async () => { + try { + setIsPasskeyLoading(true); + setShowPasskeyError(false); + + // Get available passkeys from the wallet controller (background) + const passkeyInfo = await wallet.getAvailablePasskeys(); + console.log('Available passkeys:', passkeyInfo); + + if (!passkeyInfo || passkeyInfo.length === 0) { + throw new Error('No passkeys available for this account'); + } + + // Generate a random challenge in the UI context + const challenge = new Uint8Array(32); + window.crypto.getRandomValues(challenge); + + // Get the domain for the RP ID - use the effective domain + const rpId = window.location.hostname; + console.log('Using RP ID for authentication:', rpId); + + // Prepare allowed credentials from stored credentials + const allowCredentials = passkeyInfo.map((cred) => ({ + type: 'public-key' as const, + id: Uint8Array.from(atob(cred.rawId), (c) => c.charCodeAt(0)), + transports: ['internal'] as AuthenticatorTransport[], + })); + + // Create credential request options + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { + challenge, + allowCredentials, + timeout: 60000, + userVerification: 'required', + rpId: rpId, + }; + + console.log( + 'Requesting passkey authentication with options:', + publicKeyCredentialRequestOptions + ); + + try { + // Request credential in the UI context (WebAuthn API) + const credential = (await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to get passkey'); + } + + console.log('Passkey authentication successful in UI context:', credential); + + // Verify the credential with the wallet service in the background + // This will also decrypt and store the password if available, and unlock the wallet + console.log('Verifying credential with ID:', credential.id); + const success = await wallet.verifyPasskeyCredential(credential.id); + console.log('Verification result:', success); + + if (success) { + console.log('Passkey authentication complete, wallet unlocked'); + // Successful login - the wallet is already unlocked by verifyPasskeyCredential + resolveApproval('unlocked'); + } else { + console.error('Passkey verification failed in the background'); + setPasskeyError('Failed to verify passkey. Please try again or use your password.'); + setShowPasskeyError(true); + } + } catch (credentialError: any) { + console.error('Error getting credential:', credentialError); + + // Provide more specific error messages based on the error + if (credentialError.name === 'NotAllowedError') { + setPasskeyError( + 'Permission denied. You may have cancelled the authentication or your device denied the request.' + ); + } else if (credentialError.name === 'SecurityError') { + setPasskeyError( + 'Security error. The operation is not allowed in this context or with these parameters.' + ); + } else { + setPasskeyError( + `Failed to authenticate with passkey: ${credentialError.message || 'Unknown error'}` + ); + } + setShowPasskeyError(true); + } + } catch (error) { + console.error('Error signing in with passkey:', error); + setPasskeyError( + 'An error occurred during passkey authentication. Please try again or use your password.' + ); + setShowPasskeyError(true); + } finally { + setIsPasskeyLoading(false); + } + }; + return ( { - - { - setShowError(false); - setPassword(event.target.value); - }} - onKeyDown={handleKeyDown} - /> + + {/* Passkey authentication button - only show if supported and enabled and not forcing password login */} + {isPasskeySupported && isPasskeyEnabled && !forcePasswordLogin && ( + <> + - - history.push('/unlock?login=password')} + sx={{ + fontSize: '14px', + fontFamily: 'Inter', + fontStyle: 'normal', + color: 'neutral1.main', + textAlign: 'center', + marginTop: '8px', + marginBottom: '16px', + cursor: 'pointer', + }} + > + Use password instead + + + )} + + + {/* Show password input if passkeys are not enabled or we're forcing password login */} + {(!isPasskeyEnabled || !isPasskeySupported || forcePasswordLogin) && ( + <> + - - - + { + setShowError(false); + setPassword(event.target.value); + }} + onKeyDown={handleKeyDown} + /> + + + + + + + + + + + + + + {chrome.i18n.getMessage('Forgot_password')} + - - - - - - - {chrome.i18n.getMessage('Forgot_password')} - - + + )} + + {/* Show "Forgot password" link even when using passkey, but only if not already showing password login */} + {isPasskeyEnabled && isPasskeySupported && !forcePasswordLogin && ( + + + {chrome.i18n.getMessage('Forgot_password')} + + + )} { setResetPop(false); }} /> + + {/* Error snackbar for passkey errors */} + setShowPasskeyError(false)} + />
); }; diff --git a/src/ui/views/index.tsx b/src/ui/views/index.tsx index d39257188..734fc1e72 100644 --- a/src/ui/views/index.tsx +++ b/src/ui/views/index.tsx @@ -3,8 +3,10 @@ import { createTheme, ThemeProvider } from '@mui/material/styles'; import React from 'react'; import { HashRouter as Router, Route, useLocation } from 'react-router-dom'; +import GlobalPasskeyPrompt from '@/ui/FRWComponent/GlobalPasskeyPrompt'; import themeOptions from '@/ui/style/LLTheme'; import { NewsProvider } from '@/ui/utils/NewsContext'; +import { PasskeyPromptProvider } from '@/ui/utils/PasskeyPromptContext'; import { PrivateRoute } from 'ui/component'; import { WalletProvider, useWallet } from 'ui/utils'; @@ -55,6 +57,8 @@ function Main() { return ( + {/* Render the global passkey prompt */} + ); } @@ -65,7 +69,9 @@ const App = ({ wallet }: { wallet: any }) => { -
+ +
+ From 0a1beacde4afb6f04248401e0366d56e840874bc Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 26 Feb 2025 21:27:14 +1100 Subject: [PATCH 2/6] Removed code that I didn't use --- src/background/controller/wallet.ts | 38 ----- src/background/service/passkey.ts | 206 ---------------------------- 2 files changed, 244 deletions(-) diff --git a/src/background/controller/wallet.ts b/src/background/controller/wallet.ts index 3169d99cf..2456f4bf8 100644 --- a/src/background/controller/wallet.ts +++ b/src/background/controller/wallet.ts @@ -3851,44 +3851,6 @@ export class WalletController extends BaseController { // === Passkey Related Methods === - /** - * Check if passkeys are supported in the current browser environment - */ - checkPasskeySupport = async (): Promise => { - return await passkeyService.isPasskeySupported(); - }; - - /** - * Create a new passkey for the current user - */ - createPasskey = async (): Promise => { - try { - const result = await passkeyService.createPasskey(); - if (result) { - // Track successful passkey creation - mixpanelTrack.track('passkey_created_success', { source: 'wallet_controller' }); - } - return result; - } catch (error) { - console.error('Error creating passkey:', error); - mixpanelTrack.track('passkey_created_failed', { source: 'wallet_controller' }); - return false; - } - }; - - /** - * Sign in with a passkey - */ - signInWithPasskey = async (): Promise => { - try { - const result = await passkeyService.signInWithPasskey(); - return result; - } catch (error) { - console.error('Error signing in with passkey:', error); - return false; - } - }; - /** * Check if passkeys are enabled for the current user */ diff --git a/src/background/service/passkey.ts b/src/background/service/passkey.ts index 65fff46b3..17ce2f715 100644 --- a/src/background/service/passkey.ts +++ b/src/background/service/passkey.ts @@ -1,10 +1,5 @@ -import { getApp } from 'firebase/app'; -import { getAuth } from 'firebase/auth/web-extension'; - import { storage } from '@/background/webapi'; -import { mixpanelTrack } from './mixpanel'; - interface PasskeyStore { enabled: boolean; lastUsed: string | null; @@ -40,125 +35,6 @@ class PasskeyService { } }; - /** - * Check if passkeys are available in the browser environment - */ - isPasskeySupported = async (): Promise => { - // More robust check for Chrome extension environment - try { - // Check basic WebAuthn API availability - if (typeof PublicKeyCredential === 'undefined') { - console.log('Passkey not supported: PublicKeyCredential is undefined'); - return false; - } - - // Check if platform authenticator is available - if (typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') { - const isPlatformAuthenticatorAvailable = - await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); - - console.log('Platform authenticator available:', isPlatformAuthenticatorAvailable); - return isPlatformAuthenticatorAvailable; - } - - // If we can't check specifically, assume it's available - // The actual passkey creation will fail gracefully if not supported - console.log('Cannot determine platform authenticator availability; assuming supported'); - return true; - } catch (error) { - console.error('Error checking passkey support:', error); - // In case of errors, assume it's supported and let the actual operation handle failures - return true; - } - }; - - /** - * Creates a new passkey for the current user - * @returns Promise Success status - */ - createPasskey = async (): Promise => { - try { - const app = getApp(process.env.NODE_ENV!); - const auth = getAuth(app); - const user = auth.currentUser; - - if (!user || user.isAnonymous) { - throw new Error('User must be signed in to create a passkey'); - } - - // Create a new passkey using WebAuthn - const userId = user.uid; - const displayName = user.displayName || user.email || userId; - - // Generate a random challenge - const challenge = new Uint8Array(32); - globalThis.crypto.getRandomValues(challenge); - - // Create credential options - const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { - challenge, - rp: { - name: 'FRW Extension Wallet', - id: globalThis.location.hostname, - }, - user: { - id: new TextEncoder().encode(userId), - name: user.email || userId, - displayName, - }, - pubKeyCredParams: [ - { type: 'public-key', alg: -7 }, // ES256 - { type: 'public-key', alg: -257 }, // RS256 - ], - authenticatorSelection: { - authenticatorAttachment: 'platform', - userVerification: 'required', - residentKey: 'required', - }, - timeout: 60000, - attestation: 'none', - }; - - // Create credential - const credential = (await navigator.credentials.create({ - publicKey: publicKeyCredentialCreationOptions, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error('Failed to create passkey'); - } - - // Add credential to store - const credentialId = credential.id; - const rawId = Buffer.from(credential.rawId).toString('base64'); - const registeredCredential: RegisteredCredential = { - id: credentialId, - rawId, - createdAt: new Date().toISOString(), - }; - - this.store.registeredCredentials.push(registeredCredential); - this.store.enabled = true; - this.store.lastUsed = new Date().toISOString(); - - await storage.set('passkey', this.store); - - // Track successful passkey creation - mixpanelTrack.track('passkey_created', { - success: true, - }); - - return true; - } catch (error) { - console.error('Error creating passkey:', error); - mixpanelTrack.track('passkey_created', { - success: false, - error: error instanceof Error ? error.message : String(error), - }); - return false; - } - }; - /** * Encrypts and stores the user's password * @param password The user's password to encrypt and store @@ -237,88 +113,6 @@ class PasskeyService { } }; - /** - * Sign in using passkey - */ - signInWithPasskey = async (): Promise => { - try { - if (!this.store.enabled || this.store.registeredCredentials.length === 0) { - throw new Error('No passkeys available'); - } - - // Generate a random challenge - const challenge = new Uint8Array(32); - globalThis.crypto.getRandomValues(challenge); - - // Prepare allowed credentials from stored credentials - const allowCredentials = this.store.registeredCredentials.map((cred) => ({ - type: 'public-key' as const, - id: Uint8Array.from(atob(cred.rawId), (c) => c.charCodeAt(0)), - transports: ['internal'] as AuthenticatorTransport[], - })); - - // Create credential request options - const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { - challenge, - allowCredentials, - timeout: 60000, - userVerification: 'required', - rpId: globalThis.location.hostname, - }; - - // Request credential - const credential = (await navigator.credentials.get({ - publicKey: publicKeyCredentialRequestOptions, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error('Failed to get passkey'); - } - - // Successful authentication - const matchedCredential = this.store.registeredCredentials.find( - (cred) => cred.id === credential.id - ); - - if (matchedCredential) { - this.store.lastUsed = new Date().toISOString(); - await storage.set('passkey', this.store); - - // At this point, the user has verified their identity with passkey - // You would typically use this to authenticate with Firebase - const app = getApp(process.env.NODE_ENV!); - const auth = getAuth(app); - - if (!auth.currentUser) { - // User is not logged in, would need to authenticate with Firebase - // This would typically involve calling a backend service to verify - // the passkey assertion and return a custom token for Firebase Auth - console.warn('Firebase authentication would be needed here'); - mixpanelTrack.track('passkey_signin', { - success: false, - reason: 'not_logged_in', - }); - return false; - } - - // Track successful passkey sign-in - mixpanelTrack.track('passkey_signin', { - success: true, - }); - return true; - } - - return false; - } catch (error) { - console.error('Error signing in with passkey:', error); - mixpanelTrack.track('passkey_signin', { - success: false, - error: error instanceof Error ? error.message : String(error), - }); - return false; - } - }; - /** * Get available passkeys for the current user */ From 8bf3902f4244f853b233d0fb79136ff20004d14c Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 26 Feb 2025 21:28:21 +1100 Subject: [PATCH 3/6] Moved hook to correct folder --- src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx | 2 +- src/ui/FRWComponent/PasskeyManagement.tsx | 2 +- src/ui/{utils => hooks}/usePasskey.ts | 2 +- src/ui/utils/PasskeyPromptContext.tsx | 2 +- src/ui/utils/index.ts | 2 +- src/ui/views/Forgot/index.tsx | 2 +- src/ui/views/Setting/PasskeySettings.tsx | 2 +- src/ui/views/Setting/PasskeySetup.tsx | 2 +- src/ui/views/Setting/index.tsx | 2 +- src/ui/views/Unlock/index.tsx | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) rename src/ui/{utils => hooks}/usePasskey.ts (98%) diff --git a/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx b/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx index cad32cf64..5ae7578a5 100644 --- a/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx +++ b/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx @@ -6,8 +6,8 @@ import React, { useState, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import WarningSnackbar from '@/ui/FRWComponent/WarningSnackbar'; +import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; -import { usePasskey } from '@/ui/utils/usePasskey'; const useStyles = makeStyles(() => ({ container: { diff --git a/src/ui/FRWComponent/PasskeyManagement.tsx b/src/ui/FRWComponent/PasskeyManagement.tsx index d2e790a72..c8be547b6 100644 --- a/src/ui/FRWComponent/PasskeyManagement.tsx +++ b/src/ui/FRWComponent/PasskeyManagement.tsx @@ -30,8 +30,8 @@ import { makeStyles } from '@mui/styles'; import dayjs from 'dayjs'; import React, { useState, useEffect, useCallback } from 'react'; +import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; -import { usePasskey } from '@/ui/utils/usePasskey'; import { SuccessDialog } from './Dialog'; import WarningSnackbar from './WarningSnackbar'; diff --git a/src/ui/utils/usePasskey.ts b/src/ui/hooks/usePasskey.ts similarity index 98% rename from src/ui/utils/usePasskey.ts rename to src/ui/hooks/usePasskey.ts index 787f60aea..332de86ec 100644 --- a/src/ui/utils/usePasskey.ts +++ b/src/ui/hooks/usePasskey.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; -import { useWallet } from './index'; +import { useWallet } from '../utils/index'; /** * Custom hook to check if passkeys are supported by the browser and enabled for the user diff --git a/src/ui/utils/PasskeyPromptContext.tsx b/src/ui/utils/PasskeyPromptContext.tsx index 69cec97aa..83202a17e 100644 --- a/src/ui/utils/PasskeyPromptContext.tsx +++ b/src/ui/utils/PasskeyPromptContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; -import { usePasskey } from './usePasskey'; +import { usePasskey } from '../hooks/usePasskey'; interface PasskeyPromptContextType { shouldShowPrompt: boolean; diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts index 00715d0b7..3c51bd676 100644 --- a/src/ui/utils/index.ts +++ b/src/ui/utils/index.ts @@ -165,5 +165,5 @@ export const returnFilteredCollections = (contractList, NFT) => { ); }; -export { usePasskey } from './usePasskey'; +export { usePasskey } from '../hooks/usePasskey'; export { usePasskeyPrompt, PasskeyPromptProvider } from './PasskeyPromptContext'; diff --git a/src/ui/views/Forgot/index.tsx b/src/ui/views/Forgot/index.tsx index 425434ae0..1c38c29b1 100644 --- a/src/ui/views/Forgot/index.tsx +++ b/src/ui/views/Forgot/index.tsx @@ -7,7 +7,7 @@ import { Link, useHistory } from 'react-router-dom'; import recover from '@/ui/FRWAssets/svg/recover.svg'; import reset from '@/ui/FRWAssets/svg/resetarrow.svg'; import RegisterHeader from '@/ui/FRWComponent/LandingPages/RegisterHeader'; -import { usePasskey } from '@/ui/utils/usePasskey'; +import { usePasskey } from '@/ui/hooks/usePasskey'; const Forgot = () => { const history = useHistory(); diff --git a/src/ui/views/Setting/PasskeySettings.tsx b/src/ui/views/Setting/PasskeySettings.tsx index f24828bd7..a789a1a94 100644 --- a/src/ui/views/Setting/PasskeySettings.tsx +++ b/src/ui/views/Setting/PasskeySettings.tsx @@ -14,8 +14,8 @@ import React, { useEffect } from 'react'; import { Link } from 'react-router-dom'; import { LLHeader } from '@/ui/FRWComponent/LLHeader'; +import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; -import { usePasskey } from '@/ui/utils/usePasskey'; import IconEnd from '../../../components/iconfont/IconAVector11Stroke'; diff --git a/src/ui/views/Setting/PasskeySetup.tsx b/src/ui/views/Setting/PasskeySetup.tsx index 9c6daadea..e8745085f 100644 --- a/src/ui/views/Setting/PasskeySetup.tsx +++ b/src/ui/views/Setting/PasskeySetup.tsx @@ -26,8 +26,8 @@ import { makeStyles } from '@mui/styles'; import React, { useState, useEffect } from 'react'; import { Link, useHistory } from 'react-router-dom'; +import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; -import { usePasskey } from '@/ui/utils/usePasskey'; import { SuccessDialog } from '../../FRWComponent/Dialog'; import { LLHeader } from '../../FRWComponent/LLHeader'; diff --git a/src/ui/views/Setting/index.tsx b/src/ui/views/Setting/index.tsx index f4c214d9a..5bf48a033 100644 --- a/src/ui/views/Setting/index.tsx +++ b/src/ui/views/Setting/index.tsx @@ -19,8 +19,8 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { LLHeader } from '@/ui/FRWComponent'; +import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; -import { usePasskey } from '@/ui/utils/usePasskey'; import Device from 'ui/FRWAssets/svg/device.svg'; import IconLink from 'ui/FRWAssets/svg/Iconlink.svg'; diff --git a/src/ui/views/Unlock/index.tsx b/src/ui/views/Unlock/index.tsx index dba9a4738..b0e44e9fb 100644 --- a/src/ui/views/Unlock/index.tsx +++ b/src/ui/views/Unlock/index.tsx @@ -11,10 +11,10 @@ import { LLPrimaryButton, LLResetPopup } from '@/ui/FRWComponent'; import PasskeyPrompt from '@/ui/FRWComponent/PasskeyPrompt'; import SlideRelative from '@/ui/FRWComponent/SlideRelative'; import { ErrorSnackbar } from '@/ui/FRWComponent/Snackbar'; +import { usePasskey } from '@/ui/hooks/usePasskey'; import { useProfiles } from '@/ui/hooks/useProfileHook'; import { useWallet, useApproval, useWalletRequest, useWalletLoaded } from '@/ui/utils'; import { usePasskeyPrompt } from '@/ui/utils/PasskeyPromptContext'; -import { usePasskey } from '@/ui/utils/usePasskey'; import { openInternalPageInTab } from '@/ui/utils/webapi'; import CancelIcon from '../../../components/iconfont/IconClose'; From 97b3c90191e0d347677d580e64de5b6122c89c0d Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Wed, 26 Feb 2025 21:31:06 +1100 Subject: [PATCH 4/6] Removed more unused code --- src/ui/utils/index.ts | 3 --- src/ui/views/Forgot/index.tsx | 9 ++------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts index 3c51bd676..1ed2bab5f 100644 --- a/src/ui/utils/index.ts +++ b/src/ui/utils/index.ts @@ -164,6 +164,3 @@ export const returnFilteredCollections = (contractList, NFT) => { collection.contractName === searchName || collection.contract_name === searchName ); }; - -export { usePasskey } from '../hooks/usePasskey'; -export { usePasskeyPrompt, PasskeyPromptProvider } from './PasskeyPromptContext'; diff --git a/src/ui/views/Forgot/index.tsx b/src/ui/views/Forgot/index.tsx index 1c38c29b1..09176b494 100644 --- a/src/ui/views/Forgot/index.tsx +++ b/src/ui/views/Forgot/index.tsx @@ -1,18 +1,13 @@ -import LockPersonIcon from '@mui/icons-material/LockPerson'; import { Typography, Button, CardMedia } from '@mui/material'; import { Box } from '@mui/system'; import React from 'react'; -import { Link, useHistory } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import recover from '@/ui/FRWAssets/svg/recover.svg'; import reset from '@/ui/FRWAssets/svg/resetarrow.svg'; import RegisterHeader from '@/ui/FRWComponent/LandingPages/RegisterHeader'; -import { usePasskey } from '@/ui/hooks/usePasskey'; const Forgot = () => { - const history = useHistory(); - const { isEnabled: isPasskeyEnabled } = usePasskey(); - return ( <> { Date: Tue, 4 Mar 2025 12:25:12 +1100 Subject: [PATCH 5/6] Moved passkey functions to utils file --- CLAUDE.md | 31 +++ .../LandingPages/PasskeyLogin.tsx | 99 +++------- src/ui/hooks/usePasskey.ts | 36 +--- src/ui/utils/passkey-utils.ts | 187 ++++++++++++++++++ src/ui/views/Setting/PasskeySetup.tsx | 105 ++++------ 5 files changed, 279 insertions(+), 179 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/ui/utils/passkey-utils.ts diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2e277640e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# FRW-Extension Development Guide + +## Commands + +- Build dev: `pnpm build:dev` +- Build production: `pnpm build:pro` +- Lint: `pnpm lint` or `pnpm lint:fix` +- Format: `pnpm format` or `pnpm format:fix` +- Test: `pnpm test` (watch mode) or `pnpm test:run` (single run) +- Run specific test: `pnpm test ` or `pnpm test --testNamePattern=""` +- E2E tests: `pnpm test:e2e` or `pnpm test:e2e:ui` (with UI) + +## Code Style + +- **File naming**: Use snake-case for source files +- **Components**: Use TitleCase (PascalCase) for component names +- **Imports**: Group imports (builtin → external → internal → parent → sibling → index) +- **Types**: Prefer type imports, strict typing, avoid explicit 'any' +- **React**: Functional components, avoid direct DOM manipulation +- **Error handling**: Use try/catch blocks with specific error types +- **Structure**: UI components in src/ui/FRWComponent, hooks in src/ui/hooks, background services in src/background/service + +This project is a Chrome extension Web3 wallet using React.js, TypeScript, MUI, and Firebase for authentication. Always use pnpm for package management. + +## Project Structure + +- src/ui: React components +- src/background: Background scripts and services +- src/content: Content scripts +- src/popup: Popup UI +- src/shared: Shared types andutilities diff --git a/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx b/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx index 5ae7578a5..609f5c5c7 100644 --- a/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx +++ b/src/ui/FRWComponent/LandingPages/PasskeyLogin.tsx @@ -8,6 +8,7 @@ import { useHistory } from 'react-router-dom'; import WarningSnackbar from '@/ui/FRWComponent/WarningSnackbar'; import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; +import { authenticateWithPasskey, parseWebAuthnError } from '@/ui/utils/passkey-utils'; const useStyles = makeStyles(() => ({ container: { @@ -116,86 +117,36 @@ const PasskeyLogin: React.FC = ({ onSwitchToPassword }) => { throw new Error('No passkeys available for this account'); } - // Generate a random challenge in the UI context - const challenge = new Uint8Array(32); - window.crypto.getRandomValues(challenge); + // Use our utility function to authenticate with passkey + const credential = await authenticateWithPasskey(passkeyInfo); - // Get the domain for the RP ID - use the effective domain - const rpId = window.location.hostname; - console.log('Using RP ID for authentication:', rpId); - - // Prepare allowed credentials from stored credentials - const allowCredentials = passkeyInfo.map((cred) => ({ - type: 'public-key' as const, - id: Uint8Array.from(atob(cred.rawId), (c) => c.charCodeAt(0)), - transports: ['internal'] as AuthenticatorTransport[], - })); - - // Create credential request options - const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { - challenge, - allowCredentials, - timeout: 60000, - userVerification: 'required', - rpId: rpId, - }; - - console.log( - 'Requesting passkey authentication with options:', - publicKeyCredentialRequestOptions - ); - - try { - // Request credential in the UI context (WebAuthn API) - const credential = (await navigator.credentials.get({ - publicKey: publicKeyCredentialRequestOptions, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error('Failed to get passkey'); - } - - console.log('Passkey authentication successful in UI context:', credential); - - // Verify the credential with the wallet service in the background - // This will also decrypt and store the password if available, and unlock the wallet - console.log('Verifying credential with ID:', credential.id); - const success = await wallet.verifyPasskeyCredential(credential.id); - console.log('Verification result:', success); - - if (success) { - console.log('Passkey authentication complete, wallet unlocked'); - // Successful login, navigate to home - history.push('/'); - } else { - console.error('Passkey verification failed in the background'); - setError('Failed to verify passkey. Please try again.'); - setShowError(true); - } - } catch (credentialError: any) { - console.error('Error getting credential:', credentialError); + if (!credential) { + throw new Error('Failed to get passkey'); + } - // Provide more specific error messages based on the error - if (credentialError.name === 'NotAllowedError') { - setError( - 'Permission denied. You may have cancelled the authentication or your device denied the request.' - ); - } else if (credentialError.name === 'SecurityError') { - setError( - 'Security error. The operation is not allowed in this context or with these parameters.' - ); - } else { - setError( - `Failed to authenticate with passkey: ${credentialError.message || 'Unknown error'}` - ); - } + console.log('Passkey authentication successful in UI context:', credential); + + // Verify the credential with the wallet service in the background + // This will also decrypt and store the password if available, and unlock the wallet + console.log('Verifying credential with ID:', credential.id); + const success = await wallet.verifyPasskeyCredential(credential.id); + console.log('Verification result:', success); + + if (success) { + console.log('Passkey authentication complete, wallet unlocked'); + // Successful login, navigate to home + history.push('/'); + } else { + console.error('Passkey verification failed in the background'); + setError('Failed to verify passkey. Please try again.'); setShowError(true); } } catch (error: any) { console.error('Error signing in with passkey:', error); - setError( - 'An error occurred during passkey authentication. Please try again or use your password.' - ); + + // Use our utility function to parse WebAuthn errors + const parsedError = parseWebAuthnError(error); + setError(parsedError.message); setShowError(true); } finally { setIsLoading(false); diff --git a/src/ui/hooks/usePasskey.ts b/src/ui/hooks/usePasskey.ts index 332de86ec..b57edfff6 100644 --- a/src/ui/hooks/usePasskey.ts +++ b/src/ui/hooks/usePasskey.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useWallet } from '../utils/index'; +import { isPasskeySupported } from '../utils/passkey-utils'; /** * Custom hook to check if passkeys are supported by the browser and enabled for the user @@ -30,39 +31,8 @@ export const usePasskey = () => { walletChecks: {}, }; - // First check browser support using the WebAuthn API directly - const browserSupported = - typeof window !== 'undefined' && - typeof window.PublicKeyCredential !== 'undefined' && - typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === - 'function'; - - debug.browserChecks.hasPublicKeyCredential = - typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined'; - debug.browserChecks.hasIsUserVerifyingPlatformAuthenticatorAvailable = - debug.browserChecks.hasPublicKeyCredential && - typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === - 'function'; - debug.browserChecks.browserSupported = browserSupported; - - let platformAuthenticatorAvailable = false; - - if (browserSupported) { - try { - platformAuthenticatorAvailable = - await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); - debug.browserChecks.platformAuthenticatorAvailable = platformAuthenticatorAvailable; - } catch (err) { - console.error('Error checking platform authenticator availability:', err); - debug.browserChecks.platformAuthenticatorError = - err instanceof Error ? err.message : String(err); - // If there's an error checking, we'll assume it's not available - platformAuthenticatorAvailable = false; - } - } - - // Set support status based on browser capabilities - const supported = browserSupported && platformAuthenticatorAvailable; + // Use our utility function to check browser support + const supported = await isPasskeySupported(); setIsSupported(supported); debug.supported = supported; diff --git a/src/ui/utils/passkey-utils.ts b/src/ui/utils/passkey-utils.ts new file mode 100644 index 000000000..683e83ae4 --- /dev/null +++ b/src/ui/utils/passkey-utils.ts @@ -0,0 +1,187 @@ +import { Buffer } from 'buffer'; + +/** + * Interface for passkey credential information + */ +export interface PasskeyCredential { + id: string; + rawId: string; + createdAt?: string; +} + +/** + * Checks if passkeys are supported by the current browser/device + * @returns Promise Whether passkeys are supported + */ +export const isPasskeySupported = async (): Promise => { + try { + // Check if the browser supports WebAuthn + const browserSupported = + typeof window !== 'undefined' && + typeof window.PublicKeyCredential !== 'undefined' && + typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === + 'function'; + + if (!browserSupported) { + return false; + } + + // Check if the device has a platform authenticator + const platformAuthenticatorAvailable = + await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + + return platformAuthenticatorAvailable; + } catch (error) { + console.error('Error checking passkey support:', error); + return false; + } +}; + +/** + * Creates a new passkey credential + * @param username The username to associate with the passkey + * @param displayName The display name for the user + * @param rpName The relying party name (usually the app name) + * @returns Promise The created credential or null if creation failed + */ +export const createPasskeyCredential = async ( + username: string, + displayName: string +): Promise => { + try { + // Generate a random challenge + const challenge = new Uint8Array(32); + window.crypto.getRandomValues(challenge); + + // Get the domain for the RP ID - use the effective domain + const rpId = window.location.hostname; + + // Create credential options + const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { + challenge, + rp: { + name: 'FRW Extension Wallet', + id: rpId, + }, + user: { + id: new TextEncoder().encode(username), + name: username, + displayName: displayName || username, + }, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: 'platform', + userVerification: 'required', + residentKey: 'required', + }, + timeout: 60000, + attestation: 'none', + }; + + // Create credential in the UI context + const credential = (await navigator.credentials.create({ + publicKey: publicKeyCredentialCreationOptions, + })) as PublicKeyCredential; + + return credential; + } catch (error) { + console.error('Error creating passkey credential:', error); + return null; + } +}; + +/** + * Authenticates with a passkey + * @param allowedCredentials Array of allowed credential IDs and rawIds + * @returns Promise The authenticated credential or null if authentication failed + */ +export const authenticateWithPasskey = async ( + allowedCredentials: PasskeyCredential[] +): Promise => { + try { + // Generate a random challenge + const challenge = new Uint8Array(32); + window.crypto.getRandomValues(challenge); + + // Get the domain for the RP ID - use the effective domain + const rpId = window.location.hostname; + + // Prepare allowed credentials from stored credentials + const allowCredentials = allowedCredentials.map((cred) => ({ + type: 'public-key' as const, + id: Uint8Array.from(atob(cred.rawId), (c) => c.charCodeAt(0)), + transports: ['internal'] as AuthenticatorTransport[], + })); + + // Create credential request options + const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { + challenge, + allowCredentials, + timeout: 60000, + userVerification: 'required', + rpId: rpId, + }; + + // Request credential in the UI context (WebAuthn API) + const credential = (await navigator.credentials.get({ + publicKey: publicKeyCredentialRequestOptions, + })) as PublicKeyCredential; + + return credential; + } catch (error) { + console.error('Error authenticating with passkey:', error); + return null; + } +}; + +/** + * Extracts credential data from a PublicKeyCredential + * @param credential The PublicKeyCredential to extract data from + * @returns Object containing the credential ID and rawId + */ +export const extractCredentialData = ( + credential: PublicKeyCredential +): { credentialId: string; rawId: string } => { + const credentialId = credential.id; + const rawId = Buffer.from(credential.rawId).toString('base64'); + + return { credentialId, rawId }; +}; + +/** + * Parses and categorizes WebAuthn errors + * @param error The error to parse + * @returns Object containing error information + */ +export const parseWebAuthnError = ( + error: any +): { message: string; code: string; isUserCancellation: boolean } => { + let message = 'An unknown error occurred during passkey operation'; + let code = 'unknown_error'; + let isUserCancellation = false; + + if (error instanceof Error) { + if (error.name === 'NotAllowedError') { + message = 'Permission denied. You may have cancelled the request.'; + code = 'user_cancelled'; + isUserCancellation = true; + } else if (error.name === 'SecurityError') { + message = 'Security error. Operation not allowed in this context.'; + code = 'security_error'; + } else if (error.name === 'NotSupportedError') { + message = 'This operation is not supported by your browser or device.'; + code = 'not_supported'; + } else if (error.name === 'InvalidStateError') { + message = 'The operation could not be completed in the current state.'; + code = 'invalid_state'; + } else { + message = error.message || 'An error occurred during passkey operation'; + code = error.name || 'error'; + } + } + + return { message, code, isUserCancellation }; +}; diff --git a/src/ui/views/Setting/PasskeySetup.tsx b/src/ui/views/Setting/PasskeySetup.tsx index e8745085f..a06541621 100644 --- a/src/ui/views/Setting/PasskeySetup.tsx +++ b/src/ui/views/Setting/PasskeySetup.tsx @@ -28,6 +28,11 @@ import { Link, useHistory } from 'react-router-dom'; import { usePasskey } from '@/ui/hooks/usePasskey'; import { useWallet } from '@/ui/utils'; +import { + createPasskeyCredential, + extractCredentialData, + parseWebAuthnError, +} from '@/ui/utils/passkey-utils'; import { SuccessDialog } from '../../FRWComponent/Dialog'; import { LLHeader } from '../../FRWComponent/LLHeader'; @@ -195,88 +200,44 @@ const PasskeySetup: React.FC = () => { setIsLoading(true); setShowError(false); - // Create a new passkey using WebAuthn in the UI context - // Generate a random challenge - const challenge = new Uint8Array(32); - window.crypto.getRandomValues(challenge); - // Get user info from wallet controller const userInfo = await wallet.getUserInfo(false); if (!userInfo || !userInfo.username) { throw new Error('User information not available'); } - // Get the domain for the RP ID - use the effective domain - const rpId = window.location.hostname; - - // Create credential options - const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { - challenge, - rp: { - name: 'FRW Extension Wallet', - id: rpId, - }, - user: { - id: new TextEncoder().encode(userInfo.username), - name: userInfo.username, - displayName: userInfo.nickname || userInfo.username, - }, - pubKeyCredParams: [ - { type: 'public-key', alg: -7 }, // ES256 - { type: 'public-key', alg: -257 }, // RS256 - ], - authenticatorSelection: { - authenticatorAttachment: 'platform', - userVerification: 'required', - residentKey: 'required', - }, - timeout: 60000, - attestation: 'none', - }; - - try { - // Create credential in the UI context - const credential = (await navigator.credentials.create({ - publicKey: publicKeyCredentialCreationOptions, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error('Failed to create passkey'); - } - - // Extract credential data to send to the background - const credentialId = credential.id; - const rawId = Buffer.from(credential.rawId).toString('base64'); - - // Register the credential with the wallet service in the background - // Pass the password for encryption - const success = await wallet.registerPasskeyCredential(credentialId, rawId, password); - - if (success) { - setPasskeyEnabled(true); - setShowSuccessDialog(true); - // Refresh passkey status - checkPasskeyStatus(); - } else { - setError('Failed to register passkey with wallet. Please try again.'); - setShowError(true); - } - } catch (credentialError: any) { - console.error('Error creating credential:', credentialError); - - // Provide more specific error messages based on the error - if (credentialError.name === 'NotAllowedError') { - setError('Permission denied. You may have cancelled the request.'); - } else if (credentialError.name === 'SecurityError') { - setError('Security error. Operation not allowed in this context.'); - } else { - setError(`Failed to create passkey: ${credentialError.message || 'Unknown error'}`); - } + // Create a new passkey using our utility function + const credential = await createPasskeyCredential( + userInfo.username, + userInfo.nickname || userInfo.username + ); + + if (!credential) { + throw new Error('Failed to create passkey'); + } + + // Extract credential data using our utility function + const { credentialId, rawId } = extractCredentialData(credential); + + // Register the credential with the wallet service in the background + // Pass the password for encryption + const success = await wallet.registerPasskeyCredential(credentialId, rawId, password); + + if (success) { + setPasskeyEnabled(true); + setShowSuccessDialog(true); + // Refresh passkey status + checkPasskeyStatus(); + } else { + setError('Failed to register passkey with wallet. Please try again.'); setShowError(true); } } catch (error) { console.error('Error setting up passkey:', error); - setError('An error occurred while trying to set up passkey.'); + + // Use our utility function to parse WebAuthn errors + const parsedError = parseWebAuthnError(error); + setError(parsedError.message); setShowError(true); } finally { setIsLoading(false); From 112c0bf3db2f7bdd8b7d9a1cc609131990559681 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 4 Mar 2025 14:09:42 +1100 Subject: [PATCH 6/6] Aligned to flow support passkey algorithms --- src/ui/utils/passkey-utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ui/utils/passkey-utils.ts b/src/ui/utils/passkey-utils.ts index 683e83ae4..0e0600e79 100644 --- a/src/ui/utils/passkey-utils.ts +++ b/src/ui/utils/passkey-utils.ts @@ -69,8 +69,8 @@ export const createPasskeyCredential = async ( displayName: displayName || username, }, pubKeyCredParams: [ - { type: 'public-key', alg: -7 }, // ES256 - { type: 'public-key', alg: -257 }, // RS256 + { type: 'public-key', alg: -7 }, // ES256 -7 ECDSA w/ SHA-256. + { type: 'public-key', alg: -47 }, // ES256K -47 ECDSA using secp256k1 curve and SHA-256 ], authenticatorSelection: { authenticatorAttachment: 'platform', @@ -157,7 +157,7 @@ export const extractCredentialData = ( * @returns Object containing error information */ export const parseWebAuthnError = ( - error: any + error: unknown ): { message: string; code: string; isUserCancellation: boolean } => { let message = 'An unknown error occurred during passkey operation'; let code = 'unknown_error';