From becdc0e51369e321426a16e2851a71c0781214a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 23:53:56 +0000 Subject: [PATCH 01/12] Add implementation plan for biometric lock feature Detailed plan for adding Touch ID / Windows Hello authentication to protect the extension UI. Includes: - WebAuthn integration via @lo-fi/webauthn-local-client - Lock screen UI component - Settings route for enabling/disabling - 30-minute inactivity timer - Browser restart detection Relates to #1 --- docs/BIOMETRIC_LOCK_PLAN.md | 509 ++++++++++++++++++++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 docs/BIOMETRIC_LOCK_PLAN.md diff --git a/docs/BIOMETRIC_LOCK_PLAN.md b/docs/BIOMETRIC_LOCK_PLAN.md new file mode 100644 index 0000000..22c12c6 --- /dev/null +++ b/docs/BIOMETRIC_LOCK_PLAN.md @@ -0,0 +1,509 @@ +# Biometric Lock Implementation Plan + +This document details the implementation of a biometric lock feature (Touch ID/Windows Hello) using WebAuthn for the Orbit Chrome extension. + +## Requirements Summary + +| Requirement | Decision | +|-------------|----------| +| **What to protect** | Extension UI (sidepanel routes), but NOT the Connect screen | +| **Lock triggers** | After 30 minutes of inactivity OR browser restart | +| **Auth method** | Biometric only via WebAuthn (Touch ID, Windows Hello, etc.) | +| **Fallback** | None - if biometrics unavailable, feature is unavailable | +| **Recovery** | Data becomes inaccessible if biometrics fail; user can re-import | +| **Settings location** | New Settings route | +| **Disable lock** | Requires biometric auth first | + +--- + +## Phase 1: Core Infrastructure + +### 1.1 Add Dependencies + +**File:** `package.json` + +```json +"dependencies": { + "@lo-fi/webauthn-local-client": "^0.15.0" +} +``` + +### 1.2 Create Biometric Lock Storage Module + +**New File:** `biometricLock/storage.ts` + +```typescript +import { storage } from "#imports"; + +// Storage keys +const BIOMETRIC_SETTINGS_KEY = "local:biometricLockSettings"; +const LOCK_STATE_KEY = "session:biometricLockState"; + +// Types +interface BiometricLockSettings { + isEnabled: boolean; + credentialId: string | null; // Base64 encoded credential ID + publicKey: string | null; // Base64 encoded public key + relyingPartyId: string; +} + +interface BiometricLockState { + isLocked: boolean; + lastActivityTimestamp: number; // Unix timestamp +} + +// Functions to implement +export async function getBiometricLockSettings(): Promise; +export async function saveBiometricLockSettings(settings: BiometricLockSettings): Promise; +export async function clearBiometricLockSettings(): Promise; +export async function getLockState(): Promise; +export async function setLockState(state: BiometricLockState): Promise; +export async function updateLastActivity(): Promise; +export async function isBiometricLockEnabled(): Promise; +``` + +### 1.3 Create WebAuthn Wrapper Module + +**New File:** `biometricLock/webauthn.ts` + +```typescript +import { supportsWebAuthn, register, auth } from "@lo-fi/webauthn-local-client"; + +export interface RegistrationResult { + credentialId: string; + publicKey: string; +} + +export async function checkBiometricSupport(): Promise; +export async function registerBiometric(userId: string): Promise; +export async function authenticateBiometric(credentialId: string): Promise; +``` + +**Implementation Notes:** +- Use `supportsWebAuthn` to check device capability +- Set `userVerification: "required"` to force biometric (not just PIN) +- Handle errors: `NotAllowedError` (cancelled), `NotSupportedError`, `SecurityError` + +### 1.4 Create Zod Schemas + +**New File:** `biometricLock/schemas.ts` + +```typescript +import { z } from "zod"; + +export const biometricLockSettingsSchema = z.object({ + isEnabled: z.boolean(), + credentialId: z.string().nullable(), + publicKey: z.string().nullable(), + relyingPartyId: z.string(), +}); + +export type BiometricLockSettings = z.infer; +``` + +--- + +## Phase 2: Lock Screen UI + +### 2.1 Create Lock Screen Component + +**New File:** `entrypoints/sidepanel/components/LockScreen.tsx` + +```tsx +import { Stack, Button, Text, Image, Center } from "@mantine/core"; +import { IconFingerprint } from "@tabler/icons-react"; + +interface LockScreenProps { + onUnlock: () => void; + isAuthenticating: boolean; + error?: string; +} + +export default function LockScreen({ onUnlock, isAuthenticating, error }: LockScreenProps) { + return ( +
+ + Orbit + Orbit is locked + + {error && {error}} + +
+ ); +} +``` + +--- + +## Phase 3: Settings Route + +### 3.1 Create Settings Route + +**New File:** `entrypoints/sidepanel/routes/Settings.tsx` + +```tsx +import { Form, useLoaderData, Link } from "react-router-dom"; +import { Button, Stack, Title, Group, Text, Switch, Card } from "@mantine/core"; +import { IconArrowLeft, IconFingerprint } from "@tabler/icons-react"; + +export async function loader() { + const isSupported = await checkBiometricSupport(); + const settings = await getBiometricLockSettings(); + return { + isSupported, + isEnabled: settings?.isEnabled ?? false + }; +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "enable") { + const result = await registerBiometric("orbit-user"); + await saveBiometricLockSettings({ + isEnabled: true, + credentialId: result.credentialId, + publicKey: result.publicKey, + relyingPartyId: window.location.hostname + }); + return { success: true }; + } + + if (intent === "disable") { + // Called after successful auth verification in component + await clearBiometricLockSettings(); + return { success: true }; + } + + return null; +} + +export default function Settings() { + const { isSupported, isEnabled } = useLoaderData(); + + return ( + + + + + + Settings + + + + + + + + Biometric Lock + + {isSupported ? ( +
+ + e.target.form?.requestSubmit()} /> + + ) : ( + Not available + )} +
+ + {isSupported + ? "Require Touch ID or Windows Hello to access Orbit" + : "Biometric authentication is not available on this device" + } + +
+
+
+ ); +} +``` + +### 3.2 Update Home Settings Menu + +**Modify:** `entrypoints/sidepanel/routes/Home.tsx` + +Add Settings link to the menu dropdown: + +```tsx + + + Settings + + + Export + + + Import + + +``` + +--- + +## Phase 4: Router Integration + +### 4.1 Create Lock Guard Component + +**New File:** `entrypoints/sidepanel/components/LockGuard.tsx` + +```tsx +import { useState, useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import LockScreen from "./LockScreen"; + +interface LockGuardProps { + children: React.ReactNode; +} + +export default function LockGuard({ children }: LockGuardProps) { + const [isLocked, setIsLocked] = useState(null); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [error, setError] = useState(); + const location = useLocation(); + + // Skip lock for Connect route (read-only, low risk) + const isConnectRoute = location.pathname.startsWith("/accounts/connect"); + + useEffect(() => { + checkLockState(); + }, []); + + // Update activity timestamp on navigation + useEffect(() => { + if (!isLocked) { + updateLastActivity(); + } + }, [location.pathname]); + + async function checkLockState() { + const enabled = await isBiometricLockEnabled(); + if (!enabled) { + setIsLocked(false); + return; + } + const state = await getLockState(); + setIsLocked(state.isLocked); + } + + async function handleUnlock() { + setIsAuthenticating(true); + setError(undefined); + try { + const settings = await getBiometricLockSettings(); + const success = await authenticateBiometric(settings.credentialId); + if (success) { + await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + setIsLocked(false); + } + } catch (e) { + setError("Authentication failed. Please try again."); + } finally { + setIsAuthenticating(false); + } + } + + if (isLocked === null) return null; // Loading + if (isConnectRoute) return <>{children}; + if (isLocked) return ; + + return <>{children}; +} +``` + +### 4.2 Update Layout + +**Modify:** `entrypoints/sidepanel/layout.tsx` + +```tsx +import { Box } from "@mantine/core"; +import { Outlet } from "react-router-dom"; +import LockGuard from "./components/LockGuard"; + +export default function Layout() { + return ( + + + + + + ); +} +``` + +### 4.3 Add Settings Route to Router + +**Modify:** `entrypoints/sidepanel/main.tsx` + +```tsx +import Settings, { loader as settingsLoader, action as settingsAction } from './routes/Settings'; + +// Add to router children under accounts: +{ + path: "settings", + loader: settingsLoader, + action: settingsAction, + element: , +} +``` + +--- + +## Phase 5: Inactivity Timer & Browser Restart + +### 5.1 Activity Tracker + +**New File:** `biometricLock/activityTracker.ts` + +```typescript +import { updateLastActivity, isBiometricLockEnabled } from "./storage"; + +let isTracking = false; + +export function startActivityTracking() { + if (isTracking) return; + isTracking = true; + + const events = ["click", "keydown", "scroll", "focus"]; + + const handler = debounce(async () => { + const enabled = await isBiometricLockEnabled(); + if (enabled) { + await updateLastActivity(); + } + }, 1000); + + events.forEach(event => { + document.addEventListener(event, handler, { passive: true }); + }); +} + +function debounce any>(fn: T, delay: number) { + let timeoutId: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; +} +``` + +### 5.2 Background Script Updates + +**Modify:** `entrypoints/background/index.ts` + +```typescript +const LOCK_ALARM_NAME = "biometricLockAlarm"; +const INACTIVITY_TIMEOUT_MINUTES = 30; + +// Lock on browser startup +browser.runtime.onStartup.addListener(async () => { + const enabled = await isBiometricLockEnabled(); + if (enabled) { + await setLockState({ isLocked: true, lastActivityTimestamp: 0 }); + } +}); + +// Set up inactivity check alarm (every 5 minutes) +browser.alarms.create(LOCK_ALARM_NAME, { periodInMinutes: 5 }); + +browser.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === LOCK_ALARM_NAME) { + const enabled = await isBiometricLockEnabled(); + if (!enabled) return; + + const state = await getLockState(); + if (state.isLocked) return; + + const inactiveMs = Date.now() - state.lastActivityTimestamp; + if (inactiveMs >= INACTIVITY_TIMEOUT_MINUTES * 60 * 1000) { + await setLockState({ isLocked: true, lastActivityTimestamp: state.lastActivityTimestamp }); + } + } +}); +``` + +--- + +## Phase 6: Permissions + +**Modify:** `wxt.config.ts` + +```typescript +manifest: { + permissions: ["sidePanel", "storage", "downloads", "alarms"], +}, +``` + +--- + +## File Summary + +### New Files + +| File | Purpose | +|------|---------| +| `biometricLock/storage.ts` | Lock settings and state persistence | +| `biometricLock/webauthn.ts` | WebAuthn wrapper for biometric ops | +| `biometricLock/schemas.ts` | Zod schemas for validation | +| `biometricLock/activityTracker.ts` | User activity tracking | +| `entrypoints/sidepanel/components/LockScreen.tsx` | Lock screen UI | +| `entrypoints/sidepanel/routes/Settings.tsx` | Settings route | + +### Modified Files + +| File | Changes | +|------|---------| +| `package.json` | Add `@lo-fi/webauthn-local-client` | +| `wxt.config.ts` | Add `alarms` permission | +| `entrypoints/sidepanel/main.tsx` | Add Settings route, init activity tracking | +| `entrypoints/sidepanel/layout.tsx` | Wrap with LockGuard | +| `entrypoints/sidepanel/routes/Home.tsx` | Add Settings menu item | +| `entrypoints/background/index.ts` | Inactivity timer, browser startup handler | + +--- + +## Implementation Order + +1. **Phase 1** - Core infrastructure (storage, webauthn, schemas) +2. **Phase 2** - Lock screen UI +3. **Phase 3** - Settings route + Home menu update +4. **Phase 4** - Router integration (LockGuard) +5. **Phase 5** - Inactivity timer + browser restart detection +6. **Phase 6** - Permission updates + +--- + +## Edge Cases & Considerations + +### Session vs Local Storage +- `session:` for lock state (clears on browser close = re-auth required) +- `local:` for settings/credentials (persists) + +### Connect Route Exemption +The Connect route remains accessible when locked because Orbit is read-only. + +### WebAuthn Errors to Handle +- `NotAllowedError` - User cancelled or timeout +- `NotSupportedError` - Feature not supported +- `SecurityError` - Invalid origin + +### Credential Invalidation +If user removes biometrics from device settings, auth will fail. User must re-import data. + +--- + +## Resources + +- [@lo-fi/webauthn-local-client](https://github.com/mylofi/webauthn-local-client) +- [Web Authentication API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) From c31e7938f5d21fd82a3ad986336e2c4a136ce5ad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 23:59:34 +0000 Subject: [PATCH 02/12] Add biometric lock core infrastructure (Phase 1) - Add @lo-fi/webauthn-local-client dependency for client-side WebAuthn - Create biometricLock/schemas.ts with Zod schemas for settings and state - Create biometricLock/storage.ts for persisting lock settings and state - Create biometricLock/webauthn.ts wrapper for biometric registration/auth - Add TypeScript declarations for webauthn-local-client --- biometricLock/schemas.ts | 17 ++++ biometricLock/storage.ts | 106 +++++++++++++++++++++++ biometricLock/webauthn-local-client.d.ts | 43 +++++++++ biometricLock/webauthn.ts | 82 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 53 ++++++++++++ 6 files changed, 302 insertions(+) create mode 100644 biometricLock/schemas.ts create mode 100644 biometricLock/storage.ts create mode 100644 biometricLock/webauthn-local-client.d.ts create mode 100644 biometricLock/webauthn.ts diff --git a/biometricLock/schemas.ts b/biometricLock/schemas.ts new file mode 100644 index 0000000..8e8612a --- /dev/null +++ b/biometricLock/schemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const biometricLockSettingsSchema = z.object({ + isEnabled: z.boolean(), + credentialId: z.string().nullable(), + publicKey: z.string().nullable(), + relyingPartyId: z.string(), +}); + +export type BiometricLockSettings = z.infer; + +export const biometricLockStateSchema = z.object({ + isLocked: z.boolean(), + lastActivityTimestamp: z.number(), +}); + +export type BiometricLockState = z.infer; diff --git a/biometricLock/storage.ts b/biometricLock/storage.ts new file mode 100644 index 0000000..f02977b --- /dev/null +++ b/biometricLock/storage.ts @@ -0,0 +1,106 @@ +import { storage } from "#imports"; +import { + BiometricLockSettings, + BiometricLockState, + biometricLockSettingsSchema, + biometricLockStateSchema, +} from "./schemas"; + +// local: persists across browser sessions +// session: clears on browser close (forces re-auth on restart) +const BIOMETRIC_SETTINGS_KEY = "local:biometricLockSettings"; +const LOCK_STATE_KEY = "session:biometricLockState"; + +export async function getBiometricLockSettings(): Promise { + try { + const jsonOrNull = await storage.getItem(BIOMETRIC_SETTINGS_KEY); + if (!jsonOrNull) { + return null; + } + + const parsed = JSON.parse(jsonOrNull); + const result = biometricLockSettingsSchema.safeParse(parsed); + + if (result.success) { + return result.data; + } + + console.error("Invalid biometric lock settings:", result.error); + return null; + } catch (error) { + console.error("Error reading biometric lock settings:", error); + return null; + } +} + +export async function saveBiometricLockSettings( + settings: BiometricLockSettings +): Promise { + try { + await storage.setItem( + BIOMETRIC_SETTINGS_KEY, + JSON.stringify(settings) + ); + } catch (error) { + console.error("Error saving biometric lock settings:", error); + } +} + +export async function clearBiometricLockSettings(): Promise { + try { + await storage.removeItem(BIOMETRIC_SETTINGS_KEY); + await storage.removeItem(LOCK_STATE_KEY); + } catch (error) { + console.error("Error clearing biometric lock settings:", error); + } +} + +export async function getLockState(): Promise { + try { + const jsonOrNull = await storage.getItem(LOCK_STATE_KEY); + if (!jsonOrNull) { + // Default to locked if no state exists (e.g., after browser restart) + return { isLocked: true, lastActivityTimestamp: 0 }; + } + + const parsed = JSON.parse(jsonOrNull); + const result = biometricLockStateSchema.safeParse(parsed); + + if (result.success) { + return result.data; + } + + // Invalid state, default to locked + return { isLocked: true, lastActivityTimestamp: 0 }; + } catch (error) { + console.error("Error reading lock state:", error); + return { isLocked: true, lastActivityTimestamp: 0 }; + } +} + +export async function setLockState(state: BiometricLockState): Promise { + try { + await storage.setItem(LOCK_STATE_KEY, JSON.stringify(state)); + } catch (error) { + console.error("Error saving lock state:", error); + } +} + +export async function updateLastActivity(): Promise { + try { + const currentState = await getLockState(); + if (!currentState.isLocked) { + await setLockState({ + ...currentState, + lastActivityTimestamp: Date.now(), + }); + } + } catch (error) { + console.error("Error updating last activity:", error); + } +} + +export async function isBiometricLockEnabled(): Promise { + const settings = await getBiometricLockSettings(); + return settings?.isEnabled ?? false; +} diff --git a/biometricLock/webauthn-local-client.d.ts b/biometricLock/webauthn-local-client.d.ts new file mode 100644 index 0000000..227a185 --- /dev/null +++ b/biometricLock/webauthn-local-client.d.ts @@ -0,0 +1,43 @@ +declare module "@lo-fi/webauthn-local-client" { + export const supportsWebAuthn: boolean; + + export interface RegOptions { + relyingPartyName: string; + user: { + id: string; + name: string; + displayName: string; + }; + authenticatorSelection?: { + userVerification?: "required" | "preferred" | "discouraged"; + authenticatorAttachment?: "platform" | "cross-platform"; + residentKey?: "required" | "preferred" | "discouraged"; + }; + } + + export interface RegResult { + response: { + credentialId: string; + publicKey: string; + }; + } + + export interface AuthOptions { + allowCredentials?: Array<{ + type: "public-key"; + id: string; + }>; + userVerification?: "required" | "preferred" | "discouraged"; + } + + export interface AuthResult { + response: { + credentialId: string; + }; + } + + export function regDefaults(options: RegOptions): RegOptions; + export function authDefaults(options: AuthOptions): AuthOptions; + export function register(options: RegOptions): Promise; + export function auth(options: AuthOptions): Promise; +} diff --git a/biometricLock/webauthn.ts b/biometricLock/webauthn.ts new file mode 100644 index 0000000..d1d769a --- /dev/null +++ b/biometricLock/webauthn.ts @@ -0,0 +1,82 @@ +import { + supportsWebAuthn, + register, + auth, + regDefaults, + authDefaults, +} from "@lo-fi/webauthn-local-client"; + +export interface RegistrationResult { + credentialId: string; + publicKey: string; +} + +/** + * Check if WebAuthn biometric authentication is available on this device + */ +export async function checkBiometricSupport(): Promise { + return supportsWebAuthn; +} + +/** + * Register a new biometric credential + * This will prompt the user for Touch ID / Windows Hello / fingerprint + */ +export async function registerBiometric( + userId: string +): Promise { + const regOptions = regDefaults({ + relyingPartyName: "Orbit", + user: { + id: userId, + name: userId, + displayName: "Orbit User", + }, + }); + + // Force biometric verification (not just device PIN) + regOptions.authenticatorSelection = { + ...regOptions.authenticatorSelection, + userVerification: "required", + authenticatorAttachment: "platform", // Use built-in authenticator (Touch ID, Windows Hello) + residentKey: "preferred", + }; + + const regResult = await register(regOptions); + + return { + credentialId: regResult.response.credentialId, + publicKey: regResult.response.publicKey, + }; +} + +/** + * Authenticate using a previously registered biometric credential + * Returns true if authentication was successful + */ +export async function authenticateBiometric( + credentialId: string +): Promise { + try { + const authOptions = authDefaults({ + allowCredentials: [ + { + type: "public-key", + id: credentialId, + }, + ], + }); + + // Force biometric verification + authOptions.userVerification = "required"; + + const authResult = await auth(authOptions); + + // If we get a result without throwing, authentication succeeded + return !!authResult; + } catch (error) { + // User cancelled, timeout, or other error + console.error("Biometric authentication failed:", error); + return false; + } +} diff --git a/package.json b/package.json index 8e61030..8bb70e5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "postinstall": "wxt prepare" }, "dependencies": { + "@lo-fi/webauthn-local-client": "^0.4000.0", "@mantine/core": "^8.3.3", "@mantine/hooks": "^8.3.3", "@mantine/notifications": "^8.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf4c085..2d4c91f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: .: dependencies: + '@lo-fi/webauthn-local-client': + specifier: ^0.4000.0 + version: 0.4000.0 '@mantine/core': specifier: ^8.3.3 version: 8.3.4(@mantine/hooks@8.3.4(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -405,6 +408,14 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lo-fi/webauthn-local-client@0.4000.0': + resolution: {integrity: sha512-TGEagk+M6vUIPrdMBqTbC++041aAICONsghSQ6gacJUR/2dU9Cq/6rDeRCymQ3ATGFOqOka5XsLOIVp+AZlupw==} + peerDependencies: + html-webpack-plugin: ~5.6.0 + peerDependenciesMeta: + html-webpack-plugin: + optional: true + '@mantine/core@8.3.4': resolution: {integrity: sha512-RJ5QUe2FLLJ1uF8xWUpNhDqRFbaOn4S5yTjqLuaurqtZvzee85O/T90dRcR8UNDuE8e/Qqie/jsF/G9RiSxC6g==} peerDependencies: @@ -571,6 +582,12 @@ packages: cpu: [x64] os: [win32] + '@root/asn1@1.0.2': + resolution: {integrity: sha512-v9TNX5n06882Z+TSkXrY2jCvoEHlrPB2Nnto5hhi3wRZrsR8XumdgpPDk/XCI33tA8XpxmCCS2kh0ZDwd3AIlg==} + + '@root/encoding@1.0.1': + resolution: {integrity: sha512-OaEub02ufoU038gy6bsNHQOjIn8nUjGiLcaRmJ40IUykneJkIW5fxDqKxQx48cszuNflYldsJLPPXCrGfHs8yQ==} + '@solana/addresses@4.0.0': resolution: {integrity: sha512-1OS4nU0HFZxHRxgUb6A72Qg0QbIz6Vu2AbB0j/YSxN4EI+S2BftA83Y6uXhTFDQjKuA+MtHjxe6edB3cs1Pqxw==} engines: {node: '>=20.18.0'} @@ -672,6 +689,9 @@ packages: '@types/react@18.3.26': resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==} + '@types/root__asn1@1.0.5': + resolution: {integrity: sha512-halz3HrALf1N4pUJrtQrH6mdWPczXwshoWjuxmx49riKMJv2MZGKXnauk0RjlnRzM0rlwiAJRlMT9FtSjnF2kw==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -848,6 +868,9 @@ packages: caniuse-lite@1.0.30001750: resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==} + cbor-js@0.1.0: + resolution: {integrity: sha512-7sQ/TvDZPl7csT1Sif9G0+MA0I0JOVah8+wWlJVQdVEgIbCzlN/ab3x+uvMNsc34TUvO6osQTAmB2ls80JX6tw==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1441,6 +1464,12 @@ packages: resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} engines: {node: '>=18'} + libsodium-wrappers@0.7.16: + resolution: {integrity: sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==} + + libsodium@0.7.16: + resolution: {integrity: sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -2608,6 +2637,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lo-fi/webauthn-local-client@0.4000.0': + dependencies: + '@root/asn1': 1.0.2 + cbor-js: 0.1.0 + libsodium: 0.7.16 + libsodium-wrappers: 0.7.16 + '@mantine/core@8.3.4(@mantine/hooks@8.3.4(react@18.3.1))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react': 0.27.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2733,6 +2769,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.4': optional: true + '@root/asn1@1.0.2': + dependencies: + '@root/encoding': 1.0.1 + '@types/root__asn1': 1.0.5 + + '@root/encoding@1.0.1': {} + '@solana/addresses@4.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)': dependencies: '@solana/assertions': 4.0.0(typescript@5.9.3) @@ -2842,6 +2885,8 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.1.3 + '@types/root__asn1@1.0.5': {} + '@types/yauzl@2.10.3': dependencies: '@types/node': 24.7.2 @@ -3030,6 +3075,8 @@ snapshots: caniuse-lite@1.0.30001750: {} + cbor-js@0.1.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3546,6 +3593,12 @@ snapshots: dependencies: package-json: 10.0.1 + libsodium-wrappers@0.7.16: + dependencies: + libsodium: 0.7.16 + + libsodium@0.7.16: {} + lie@3.3.0: dependencies: immediate: 3.0.6 From 7e2123a7c6be69fae32651060fd125ba89eff866 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 00:00:13 +0000 Subject: [PATCH 03/12] Add lock screen UI component (Phase 2) Simple branded lock screen with: - Orbit logo - "Orbit is locked" message - Unlock button with fingerprint icon - Error message display --- .../sidepanel/components/LockScreen.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 entrypoints/sidepanel/components/LockScreen.tsx diff --git a/entrypoints/sidepanel/components/LockScreen.tsx b/entrypoints/sidepanel/components/LockScreen.tsx new file mode 100644 index 0000000..504f687 --- /dev/null +++ b/entrypoints/sidepanel/components/LockScreen.tsx @@ -0,0 +1,39 @@ +import { Stack, Button, Text, Image, Center } from "@mantine/core"; +import { IconFingerprint } from "@tabler/icons-react"; + +interface LockScreenProps { + onUnlock: () => void; + isAuthenticating: boolean; + error?: string; +} + +export default function LockScreen({ + onUnlock, + isAuthenticating, + error, +}: LockScreenProps) { + return ( +
+ + Orbit + + Orbit is locked + + + {error && ( + + {error} + + )} + +
+ ); +} From e09fd05fc83d25620c36d31db524a11b4ec1d02e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 00:01:37 +0000 Subject: [PATCH 04/12] Add Settings route with biometric lock toggle (Phase 3) - Create Settings route with biometric lock enable/disable functionality - Add Settings menu item to Home page menu (with divider) - Register Settings route in main.tsx router config - Disabling lock requires biometric auth first --- entrypoints/sidepanel/main.tsx | 7 + entrypoints/sidepanel/routes/Home.tsx | 4 + entrypoints/sidepanel/routes/Settings.tsx | 186 ++++++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 entrypoints/sidepanel/routes/Settings.tsx diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx index 555c97b..e1bc9f4 100644 --- a/entrypoints/sidepanel/main.tsx +++ b/entrypoints/sidepanel/main.tsx @@ -17,6 +17,7 @@ import ViewAccount, { loader as viewAccountLoader } from './routes/ViewAccount' import { loader as filteredAccountsLoader } from './routes/FilteredAccounts' import Layout from './layout' import Connect, { action as connectAction, loader as connectLoader } from './routes/Connect' +import Settings, { action as settingsAction, loader as settingsLoader } from './routes/Settings' import { createTheme, MantineProvider, rem } from '@mantine/core' import { Notifications } from '@mantine/notifications' @@ -60,6 +61,12 @@ const router = createBrowserRouter([ path: "home", element: , }, + { + path: "settings", + loader: settingsLoader, + action: settingsAction, + element: , + }, { path: "new", action: createAccountAction, diff --git a/entrypoints/sidepanel/routes/Home.tsx b/entrypoints/sidepanel/routes/Home.tsx index e6fb20e..7a4baa2 100644 --- a/entrypoints/sidepanel/routes/Home.tsx +++ b/entrypoints/sidepanel/routes/Home.tsx @@ -44,6 +44,10 @@ export default function Home() { + + Settings + + Export diff --git a/entrypoints/sidepanel/routes/Settings.tsx b/entrypoints/sidepanel/routes/Settings.tsx new file mode 100644 index 0000000..3ddab61 --- /dev/null +++ b/entrypoints/sidepanel/routes/Settings.tsx @@ -0,0 +1,186 @@ +import { useState } from "react"; +import { + ActionFunctionArgs, + NavLink, + useActionData, + useLoaderData, + useSubmit, +} from "react-router-dom"; +import { + Button, + Stack, + Title, + Group, + Text, + Switch, + Card, + Anchor, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconFingerprint } from "@tabler/icons-react"; +import { checkBiometricSupport, registerBiometric, authenticateBiometric } from "~/biometricLock/webauthn"; +import { + getBiometricLockSettings, + saveBiometricLockSettings, + clearBiometricLockSettings, + setLockState, +} from "~/biometricLock/storage"; + +export async function loader() { + const isSupported = await checkBiometricSupport(); + const settings = await getBiometricLockSettings(); + return { + isSupported, + isEnabled: settings?.isEnabled ?? false, + credentialId: settings?.credentialId ?? null, + }; +} + +export async function action({ request }: ActionFunctionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent") as string; + + if (intent === "enable") { + try { + const result = await registerBiometric("orbit-user"); + await saveBiometricLockSettings({ + isEnabled: true, + credentialId: result.credentialId, + publicKey: result.publicKey, + relyingPartyId: window.location.hostname, + }); + // Start unlocked after enabling + await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + return { success: true, action: "enabled" }; + } catch (error) { + console.error("Failed to enable biometric lock:", error); + return { error: "Failed to enable biometric lock. Please try again." }; + } + } + + if (intent === "disable") { + try { + await clearBiometricLockSettings(); + return { success: true, action: "disabled" }; + } catch (error) { + console.error("Failed to disable biometric lock:", error); + return { error: "Failed to disable biometric lock." }; + } + } + + return null; +} + +type LoaderData = Awaited>; +type ActionData = { success: boolean; action: string } | { error: string } | null; + +export default function Settings() { + const { isSupported, isEnabled, credentialId } = useLoaderData() as LoaderData; + const actionData = useActionData() as ActionData; + const submit = useSubmit(); + const [isAuthenticating, setIsAuthenticating] = useState(false); + + // Show notifications based on action result + if (actionData && "success" in actionData) { + if (actionData.action === "enabled") { + notifications.show({ + title: "Biometric lock enabled", + message: "Orbit will now require authentication to access", + color: "green", + }); + } else if (actionData.action === "disabled") { + notifications.show({ + title: "Biometric lock disabled", + message: "Orbit no longer requires authentication", + color: "blue", + }); + } + } else if (actionData && "error" in actionData) { + notifications.show({ + title: "Error", + message: actionData.error, + color: "red", + }); + } + + async function handleToggle() { + if (isEnabled) { + // Disabling requires biometric auth first + if (!credentialId) { + // No credential stored, just disable + submit({ intent: "disable" }, { method: "post" }); + return; + } + + setIsAuthenticating(true); + try { + const success = await authenticateBiometric(credentialId); + if (success) { + submit({ intent: "disable" }, { method: "post" }); + } else { + notifications.show({ + title: "Authentication required", + message: "Please authenticate to disable biometric lock", + color: "red", + }); + } + } catch (error) { + notifications.show({ + title: "Authentication failed", + message: "Could not verify your identity", + color: "red", + }); + } finally { + setIsAuthenticating(false); + } + } else { + // Enabling - just submit the form to trigger registration + submit({ intent: "enable" }, { method: "post" }); + } + } + + return ( + + + + + + + + Settings + + + + + + + + + Biometric Lock + + {isSupported + ? "Require Touch ID or Windows Hello to access Orbit" + : "Biometric authentication is not available on this device"} + + + + {isSupported && ( + + )} + + {isEnabled && ( + + Note: If you lose access to biometrics, you'll need to + re-import your accounts from a backup. + + )} + + + + ); +} From 055cfc7164556925b679f72ca04fdc6792217783 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 00:02:25 +0000 Subject: [PATCH 05/12] Add LockGuard component for route protection (Phase 4) - Create LockGuard component that checks lock state on mount - Wrap Layout with LockGuard to protect all routes - Connect route (/accounts/connect) bypasses lock check - Update activity timestamp on navigation when unlocked --- .../sidepanel/components/LockGuard.tsx | 99 +++++++++++++++++++ entrypoints/sidepanel/layout.tsx | 9 +- 2 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 entrypoints/sidepanel/components/LockGuard.tsx diff --git a/entrypoints/sidepanel/components/LockGuard.tsx b/entrypoints/sidepanel/components/LockGuard.tsx new file mode 100644 index 0000000..dfaefb9 --- /dev/null +++ b/entrypoints/sidepanel/components/LockGuard.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import LockScreen from "./LockScreen"; +import { + getBiometricLockSettings, + getLockState, + isBiometricLockEnabled, + setLockState, + updateLastActivity, +} from "~/biometricLock/storage"; +import { authenticateBiometric } from "~/biometricLock/webauthn"; + +interface LockGuardProps { + children: React.ReactNode; +} + +export default function LockGuard({ children }: LockGuardProps) { + const [isLocked, setIsLocked] = useState(null); // null = loading + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [error, setError] = useState(); + const location = useLocation(); + + // Connect route should remain accessible when locked + // (Orbit is read-only, so dApp connections are safe) + const isConnectRoute = location.pathname.startsWith("/accounts/connect"); + + useEffect(() => { + checkLockState(); + }, []); + + // Update activity timestamp on navigation (when unlocked) + useEffect(() => { + if (isLocked === false) { + updateLastActivity(); + } + }, [location.pathname, isLocked]); + + async function checkLockState() { + const enabled = await isBiometricLockEnabled(); + if (!enabled) { + setIsLocked(false); + return; + } + + const state = await getLockState(); + setIsLocked(state.isLocked); + } + + async function handleUnlock() { + setIsAuthenticating(true); + setError(undefined); + + try { + const settings = await getBiometricLockSettings(); + if (!settings?.credentialId) { + // No credential, something is wrong - unlock anyway + await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + setIsLocked(false); + return; + } + + const success = await authenticateBiometric(settings.credentialId); + if (success) { + await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + setIsLocked(false); + } else { + setError("Authentication failed. Please try again."); + } + } catch (e) { + setError("Authentication failed. Please try again."); + } finally { + setIsAuthenticating(false); + } + } + + // Still loading lock state + if (isLocked === null) { + return null; + } + + // Connect route bypasses lock + if (isConnectRoute) { + return <>{children}; + } + + // Show lock screen if locked + if (isLocked) { + return ( + + ); + } + + // Unlocked - render children + return <>{children}; +} diff --git a/entrypoints/sidepanel/layout.tsx b/entrypoints/sidepanel/layout.tsx index fcee5b7..610aabd 100644 --- a/entrypoints/sidepanel/layout.tsx +++ b/entrypoints/sidepanel/layout.tsx @@ -1,10 +1,13 @@ import { Box } from "@mantine/core"; import { Outlet } from "react-router-dom"; +import LockGuard from "./components/LockGuard"; export default function Layout() { return ( - - - + + + + + ) } From f9b19baf7bd6f3303ba4ab6d11a28b3c5666ac0d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 00:03:29 +0000 Subject: [PATCH 06/12] Add inactivity timer and browser restart detection (Phase 5) - Create activity tracker that monitors user interactions - Update last activity timestamp on click, keydown, scroll, focus - Add browser startup handler to lock when browser restarts - Add alarm that checks every 5 minutes for 30-minute inactivity - Initialize activity tracking in main.tsx --- biometricLock/activityTracker.ts | 42 ++++++++++++++++++++++ entrypoints/background/index.ts | 62 ++++++++++++++++++++++++++++++++ entrypoints/sidepanel/main.tsx | 4 +++ 3 files changed, 108 insertions(+) create mode 100644 biometricLock/activityTracker.ts diff --git a/biometricLock/activityTracker.ts b/biometricLock/activityTracker.ts new file mode 100644 index 0000000..0fdab06 --- /dev/null +++ b/biometricLock/activityTracker.ts @@ -0,0 +1,42 @@ +import { updateLastActivity, isBiometricLockEnabled } from "./storage"; + +let isTracking = false; + +/** + * Debounce function to limit how often a function is called + */ +function debounce unknown>( + fn: T, + delay: number +): (...args: Parameters) => void { + let timeoutId: ReturnType; + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; +} + +/** + * Start tracking user activity in the sidepanel. + * Updates the last activity timestamp when user interacts with the UI. + * Call this once on app initialization. + */ +export function startActivityTracking(): void { + if (isTracking) return; + isTracking = true; + + // Track meaningful user interactions + const events = ["click", "keydown", "scroll", "focus"]; + + // Debounce to avoid excessive storage writes + const handler = debounce(async () => { + const enabled = await isBiometricLockEnabled(); + if (enabled) { + await updateLastActivity(); + } + }, 1000); + + events.forEach((event) => { + document.addEventListener(event, handler, { passive: true }); + }); +} diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index 967a47e..6f0e3aa 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -1,6 +1,14 @@ import type { InjectedEvent } from "../injected/events"; import { SidePanelEvent } from "../sidepanel/events"; import { makeConnectionSubmitForwardedEvent } from "./events"; +import { + isBiometricLockEnabled, + setLockState, + getLockState, +} from "~/biometricLock/storage"; + +const LOCK_ALARM_NAME = "biometricLockAlarm"; +const INACTIVITY_TIMEOUT_MINUTES = 30; type SidePanel = { setOptions({ @@ -89,6 +97,60 @@ function main() { .catch((error) => console.error(error)); } +/** + * Lock the extension on browser startup if biometric lock is enabled. + * This ensures users must authenticate when they restart their browser. + */ +async function handleBrowserStartup() { + const enabled = await isBiometricLockEnabled(); + if (enabled) { + await setLockState({ isLocked: true, lastActivityTimestamp: 0 }); + } +} + +/** + * Check for inactivity and lock if the timeout has been exceeded. + */ +async function checkInactivity() { + const enabled = await isBiometricLockEnabled(); + if (!enabled) return; + + const state = await getLockState(); + if (state.isLocked) return; // Already locked + + const now = Date.now(); + const inactiveMs = now - state.lastActivityTimestamp; + const timeoutMs = INACTIVITY_TIMEOUT_MINUTES * 60 * 1000; + + if (inactiveMs >= timeoutMs) { + await setLockState({ + isLocked: true, + lastActivityTimestamp: state.lastActivityTimestamp, + }); + } +} + +/** + * Set up the inactivity check alarm. + * Checks every 5 minutes for inactivity. + */ +function setupInactivityAlarm() { + browser.alarms.create(LOCK_ALARM_NAME, { periodInMinutes: 5 }); +} + export default defineBackground(function () { main(); + + // Lock on browser startup + browser.runtime.onStartup.addListener(handleBrowserStartup); + + // Check for inactivity periodically + browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === LOCK_ALARM_NAME) { + checkInactivity(); + } + }); + + // Set up the inactivity alarm + setupInactivityAlarm(); }); diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx index e1bc9f4..e92b4de 100644 --- a/entrypoints/sidepanel/main.tsx +++ b/entrypoints/sidepanel/main.tsx @@ -24,6 +24,10 @@ import { Notifications } from '@mantine/notifications' import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './styles/global.css'; +import { startActivityTracking } from '~/biometricLock/activityTracker'; + +// Start tracking user activity for biometric lock inactivity timer +startActivityTracking(); const router = createBrowserRouter([ { From 7e696aa023ae2e5677f6d60320516bb048ca3145 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 00:04:06 +0000 Subject: [PATCH 07/12] Add alarms permission for inactivity timer (Phase 6) Add "alarms" to manifest permissions to enable the periodic inactivity check that locks the extension after 30 minutes. --- wxt.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wxt.config.ts b/wxt.config.ts index d45477c..3f25cad 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -24,6 +24,6 @@ export default defineConfig({ default_title: "Orbit", default_panel: "/sidepanel.html", }, - permissions: ["sidePanel", "storage", "downloads"], + permissions: ["sidePanel", "storage", "downloads", "alarms"], }, }); From aee4f2f266dee22094a3e13d3c91422e00489af0 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 21 Jan 2026 23:11:52 +0000 Subject: [PATCH 08/12] Fix bugs with webauthn loading --- biometricLock/schemas.ts | 4 +- biometricLock/webauthn-local-client.d.ts | 11 +- biometricLock/webauthn.ts | 20 +- entrypoints/sidepanel/index.html | 3 + package.json | 1 + pnpm-lock.yaml | 15 + public/vendor/asn1.all.min.js | 12 + public/vendor/cbor.js | 406 +++++++++++++++++++++++ 8 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 public/vendor/asn1.all.min.js create mode 100644 public/vendor/cbor.js diff --git a/biometricLock/schemas.ts b/biometricLock/schemas.ts index 8e8612a..dc28ef0 100644 --- a/biometricLock/schemas.ts +++ b/biometricLock/schemas.ts @@ -2,8 +2,8 @@ import { z } from "zod"; export const biometricLockSettingsSchema = z.object({ isEnabled: z.boolean(), - credentialId: z.string().nullable(), - publicKey: z.string().nullable(), + credentialId: z.string(), + publicKey: z.string(), relyingPartyId: z.string(), }); diff --git a/biometricLock/webauthn-local-client.d.ts b/biometricLock/webauthn-local-client.d.ts index 227a185..159f5e9 100644 --- a/biometricLock/webauthn-local-client.d.ts +++ b/biometricLock/webauthn-local-client.d.ts @@ -4,7 +4,7 @@ declare module "@lo-fi/webauthn-local-client" { export interface RegOptions { relyingPartyName: string; user: { - id: string; + id: BufferSource; // WebAuthn requires ArrayBuffer or ArrayBufferView name: string; displayName: string; }; @@ -17,8 +17,13 @@ declare module "@lo-fi/webauthn-local-client" { export interface RegResult { response: { - credentialId: string; - publicKey: string; + credentialID: string; // Note: uppercase ID + publicKey: { + algoCOSE: number; + algoOID: string; + spki: Uint8Array; + raw: Uint8Array; + }; }; } diff --git a/biometricLock/webauthn.ts b/biometricLock/webauthn.ts index d1d769a..5791c0d 100644 --- a/biometricLock/webauthn.ts +++ b/biometricLock/webauthn.ts @@ -5,6 +5,11 @@ import { regDefaults, authDefaults, } from "@lo-fi/webauthn-local-client"; +import _sodium from "libsodium-wrappers"; + +// Initialize libsodium - required by webauthn-local-client +await _sodium.ready; +(globalThis as any).sodium = _sodium; export interface RegistrationResult { credentialId: string; @@ -25,10 +30,14 @@ export async function checkBiometricSupport(): Promise { export async function registerBiometric( userId: string ): Promise { + // Convert userId string to ArrayBuffer as required by WebAuthn API + const encoder = new TextEncoder(); + const userIdBuffer = encoder.encode(userId); + const regOptions = regDefaults({ relyingPartyName: "Orbit", user: { - id: userId, + id: userIdBuffer, name: userId, displayName: "Orbit User", }, @@ -44,9 +53,14 @@ export async function registerBiometric( const regResult = await register(regOptions); + // Convert publicKey raw bytes to base64 string for storage + const publicKeyBase64 = btoa( + String.fromCharCode(...regResult.response.publicKey.raw) + ); + return { - credentialId: regResult.response.credentialId, - publicKey: regResult.response.publicKey, + credentialId: regResult.response.credentialID, + publicKey: publicKeyBase64, }; } diff --git a/entrypoints/sidepanel/index.html b/entrypoints/sidepanel/index.html index ed4cb94..a2a0455 100644 --- a/entrypoints/sidepanel/index.html +++ b/entrypoints/sidepanel/index.html @@ -8,6 +8,9 @@
+ + + diff --git a/package.json b/package.json index 8bb70e5..a0318de 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@solana/wallet-standard-features": "^1.2.0", "@tabler/icons-react": "^3.35.0", "@wallet-standard/core": "^1.0.3", + "libsodium-wrappers": "^0.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d4c91f..3649706 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@wallet-standard/core': specifier: ^1.0.3 version: 1.1.1 + libsodium-wrappers: + specifier: ^0.8.1 + version: 0.8.1 react: specifier: ^18.2.0 version: 18.3.1 @@ -1467,9 +1470,15 @@ packages: libsodium-wrappers@0.7.16: resolution: {integrity: sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg==} + libsodium-wrappers@0.8.1: + resolution: {integrity: sha512-UOqD9M4HhAhwEo2foIgnUrpqXCj2VKdndZXM3PZ0zGe++9808TzAFSp4o2mOgPM/DNUnUqncR3G0xqiKDoAQXQ==} + libsodium@0.7.16: resolution: {integrity: sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q==} + libsodium@0.8.1: + resolution: {integrity: sha512-V70In9cepY7MMoKyEvnrojqgQN5yQM/8i8kGh4n/IC1g3thA5nonK0d84e0fR86tFOeAQsOdaL5ONuR93ru25A==} + lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} @@ -3597,8 +3606,14 @@ snapshots: dependencies: libsodium: 0.7.16 + libsodium-wrappers@0.8.1: + dependencies: + libsodium: 0.8.1 + libsodium@0.7.16: {} + libsodium@0.8.1: {} + lie@3.3.0: dependencies: immediate: 3.0.6 diff --git a/public/vendor/asn1.all.min.js b/public/vendor/asn1.all.min.js new file mode 100644 index 0000000..d2c8c8b --- /dev/null +++ b/public/vendor/asn1.all.min.js @@ -0,0 +1,12 @@ +/*! + Copyright: + AJ ONeal (https://coolaj86.com/) + Root (https://therootcompany.com/) + 2018-2019 + License: + MPL-2.0 + + https://www.npmjs.com/package/@root/asn1 + https://github.com/therootcompany/asn1.js +*/ +(function(){"use strict";var Enc=window.Encoding={};Enc.bufToBase64=function(u8){var bin="";u8.forEach(function(i){bin+=String.fromCharCode(i)});return btoa(bin)};Enc.strToBase64=function(str){return btoa(Enc.strToBin(str))};function _base64ToBin(b64){return atob(Enc.urlBase64ToBase64(b64))}Enc._base64ToBin=_base64ToBin;Enc.base64ToBuf=function(b64){return Enc.binToBuf(_base64ToBin(b64))};Enc.base64ToStr=function(b64){return Enc.binToStr(_base64ToBin(b64))};Enc.urlBase64ToBase64=function(u64){var r=u64%4;if(2===r){u64+="=="}else if(3===r){u64+="="}return u64.replace(/-/g,"+").replace(/_/g,"/")};Enc.base64ToUrlBase64=function(b64){return b64.replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")};Enc.bufToUrlBase64=function(buf){return Enc.base64ToUrlBase64(Enc.bufToBase64(buf))};Enc.strToUrlBase64=function(str){return Enc.bufToUrlBase64(Enc.strToBuf(str))};Enc.bufToHex=function(u8){var hex=[];var i,h;var len=u8.byteLength||u8.length;for(i=0;i=ASN1.EDEEPN){throw new Error(ASN1.EDEEP)}var index=2;var asn1={type:buf[0],lengthSize:0,length:buf[1]};var child;var iters=0;var adjust=0;var adjustedLen;if(128&asn1.length){asn1.lengthSize=127&asn1.length;asn1.length=parseInt(Enc.bufToHex(buf.slice(index,index+asn1.lengthSize)),16);index+=asn1.lengthSize}if(0===buf[index]&&(2===asn1.type||3===asn1.type)){if(asn1.length>1){index+=1;adjust=-1}}adjustedLen=asn1.length+adjust;function parseChildren(eager){asn1.children=[];while(iters2+asn1.lengthSize+asn1.length){throw new Error("Parse error: child value length ("+child.length+") is greater than remaining parent length ("+(asn1.length-index)+" = "+asn1.length+" - "+index+")")}asn1.children.push(child)}if(index!==2+asn1.lengthSize+asn1.length){throw new Error("premature end-of-file")}if(iters>=ASN1.ELOOPN){throw new Error(ASN1.ELOOP)}delete asn1.value;return asn1}if(-1!==ASN1.CTYPES.indexOf(asn1.type)){return parseChildren(eager)}asn1.value=buf.slice(index,index+adjustedLen);if(opts.json){asn1.value=Enc.bufToHex(asn1.value)}if(-1!==ASN1.VTYPES.indexOf(asn1.type)){return asn1}try{return parseChildren(true)}catch(e){asn1.children.length=0;return asn1}}var asn1=parseAsn1(buf,[]);var len=buf.byteLength||buf.length;if(len!==2+asn1.lengthSize+asn1.length){throw new Error("Length of buffer does not match length of ASN.1 sequence.")}return asn1};ASN1._toArray=function toArray(next,opts){var typ=opts.json?Enc.numToHex(next.type):next.type;var val=next.value;if(val){if("string"!==typeof val&&opts.json){val=Enc.bufToHex(val)}return[typ,val]}return[typ,next.children.map(function(child){return toArray(child,opts)})]};ASN1.parse=function(opts){var opts2={json:false!==opts.json};var verbose=ASN1.parseVerbose(opts.der,opts2);if(opts.verbose){return verbose}return ASN1._toArray(verbose,opts2)};ASN1._replacer=function(k,v){if("type"===k){return"0x"+Enc.numToHex(v)}if(v&&"value"===k){return"0x"+Enc.bufToHex(v.data||v)}return v};function Any(){var args=Array.prototype.slice.call(arguments);var typ=args.shift();var str=args.join("").replace(/\s+/g,"").toLowerCase();var len=str.length/2;var lenlen=0;var hex=typ;if("number"===typeof hex){hex=Enc.numToHex(hex)}if(len!==Math.round(len)){throw new Error("invalid hex")}if(len>127){lenlen+=1;while(len>255){lenlen+=1;len=len>>8}}if(lenlen){hex+=Enc.numToHex(128+lenlen)}return hex+Enc.numToHex(str.length/2)+str}ASN1.Any=Any;ASN1.UInt=function UINT(){var str=Array.prototype.slice.call(arguments).join("");var first=parseInt(str.slice(0,2),16);if(128&first){str="00"+str}return Any("02",str)};ASN1.BitStr=function BITSTR(){var str=Array.prototype.slice.call(arguments).join("");return Any("03","00"+str)};ASN1._toArray=function toArray(next,opts){var typ=opts.json?Enc.numToHex(next.type):next.type;var val=next.value;if(val){if("string"!==typeof val&&opts.json){val=Enc.bufToHex(val)}return[typ,val]}return[typ,next.children.map(function(child){return toArray(child,opts)})]};ASN1._pack=function(arr){var typ=arr[0];if("number"===typeof arr[0]){typ=Enc.numToHex(arr[0])}var str="";if(Array.isArray(arr[1])){arr[1].forEach(function(a){str+=ASN1._pack(a)})}else if("string"===typeof arr[1]){str=arr[1]}else if(arr[1].byteLength){str=Enc.bufToHex(arr[1])}else{throw new Error("unexpected array")}if("03"===typ){return ASN1.BitStr(str)}else if("02"===typ){return ASN1.UInt(str)}else{return Any(typ,str)}};ASN1.pack=function(asn1,opts){if(!opts){opts={}}if(!Array.isArray(asn1)){asn1=ASN1._toArray(asn1,{json:true})}var result=ASN1._pack(asn1);if(opts.json){return result}return Enc.hexToBuf(result)}})(); \ No newline at end of file diff --git a/public/vendor/cbor.js b/public/vendor/cbor.js new file mode 100644 index 0000000..3a8e669 --- /dev/null +++ b/public/vendor/cbor.js @@ -0,0 +1,406 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2014 Patrick Gansterer + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +(function(global, undefined) { "use strict"; +var POW_2_24 = Math.pow(2, -24), + POW_2_32 = Math.pow(2, 32), + POW_2_53 = Math.pow(2, 53); + +function encode(value) { + var data = new ArrayBuffer(256); + var dataView = new DataView(data); + var lastLength; + var offset = 0; + + function ensureSpace(length) { + var newByteLength = data.byteLength; + var requiredLength = offset + length; + while (newByteLength < requiredLength) + newByteLength *= 2; + if (newByteLength !== data.byteLength) { + var oldDataView = dataView; + data = new ArrayBuffer(newByteLength); + dataView = new DataView(data); + var uint32count = (offset + 3) >> 2; + for (var i = 0; i < uint32count; ++i) + dataView.setUint32(i * 4, oldDataView.getUint32(i * 4)); + } + + lastLength = length; + return dataView; + } + function write() { + offset += lastLength; + } + function writeFloat64(value) { + write(ensureSpace(8).setFloat64(offset, value)); + } + function writeUint8(value) { + write(ensureSpace(1).setUint8(offset, value)); + } + function writeUint8Array(value) { + var dataView = ensureSpace(value.length); + for (var i = 0; i < value.length; ++i) + dataView.setUint8(offset + i, value[i]); + write(); + } + function writeUint16(value) { + write(ensureSpace(2).setUint16(offset, value)); + } + function writeUint32(value) { + write(ensureSpace(4).setUint32(offset, value)); + } + function writeUint64(value) { + var low = value % POW_2_32; + var high = (value - low) / POW_2_32; + var dataView = ensureSpace(8); + dataView.setUint32(offset, high); + dataView.setUint32(offset + 4, low); + write(); + } + function writeTypeAndLength(type, length) { + if (length < 24) { + writeUint8(type << 5 | length); + } else if (length < 0x100) { + writeUint8(type << 5 | 24); + writeUint8(length); + } else if (length < 0x10000) { + writeUint8(type << 5 | 25); + writeUint16(length); + } else if (length < 0x100000000) { + writeUint8(type << 5 | 26); + writeUint32(length); + } else { + writeUint8(type << 5 | 27); + writeUint64(length); + } + } + + function encodeItem(value) { + var i; + + if (value === false) + return writeUint8(0xf4); + if (value === true) + return writeUint8(0xf5); + if (value === null) + return writeUint8(0xf6); + if (value === undefined) + return writeUint8(0xf7); + + switch (typeof value) { + case "number": + if (Math.floor(value) === value) { + if (0 <= value && value <= POW_2_53) + return writeTypeAndLength(0, value); + if (-POW_2_53 <= value && value < 0) + return writeTypeAndLength(1, -(value + 1)); + } + writeUint8(0xfb); + return writeFloat64(value); + + case "string": + var utf8data = []; + for (i = 0; i < value.length; ++i) { + var charCode = value.charCodeAt(i); + if (charCode < 0x80) { + utf8data.push(charCode); + } else if (charCode < 0x800) { + utf8data.push(0xc0 | charCode >> 6); + utf8data.push(0x80 | charCode & 0x3f); + } else if (charCode < 0xd800) { + utf8data.push(0xe0 | charCode >> 12); + utf8data.push(0x80 | (charCode >> 6) & 0x3f); + utf8data.push(0x80 | charCode & 0x3f); + } else { + charCode = (charCode & 0x3ff) << 10; + charCode |= value.charCodeAt(++i) & 0x3ff; + charCode += 0x10000; + + utf8data.push(0xf0 | charCode >> 18); + utf8data.push(0x80 | (charCode >> 12) & 0x3f); + utf8data.push(0x80 | (charCode >> 6) & 0x3f); + utf8data.push(0x80 | charCode & 0x3f); + } + } + + writeTypeAndLength(3, utf8data.length); + return writeUint8Array(utf8data); + + default: + var length; + if (Array.isArray(value)) { + length = value.length; + writeTypeAndLength(4, length); + for (i = 0; i < length; ++i) + encodeItem(value[i]); + } else if (value instanceof Uint8Array) { + writeTypeAndLength(2, value.length); + writeUint8Array(value); + } else { + var keys = Object.keys(value); + length = keys.length; + writeTypeAndLength(5, length); + for (i = 0; i < length; ++i) { + var key = keys[i]; + encodeItem(key); + encodeItem(value[key]); + } + } + } + } + + encodeItem(value); + + if ("slice" in data) + return data.slice(0, offset); + + var ret = new ArrayBuffer(offset); + var retView = new DataView(ret); + for (var i = 0; i < offset; ++i) + retView.setUint8(i, dataView.getUint8(i)); + return ret; +} + +function decode(data, tagger, simpleValue) { + var dataView = new DataView(data); + var offset = 0; + + if (typeof tagger !== "function") + tagger = function(value) { return value; }; + if (typeof simpleValue !== "function") + simpleValue = function() { return undefined; }; + + function read(value, length) { + offset += length; + return value; + } + function readArrayBuffer(length) { + return read(new Uint8Array(data, offset, length), length); + } + function readFloat16() { + var tempArrayBuffer = new ArrayBuffer(4); + var tempDataView = new DataView(tempArrayBuffer); + var value = readUint16(); + + var sign = value & 0x8000; + var exponent = value & 0x7c00; + var fraction = value & 0x03ff; + + if (exponent === 0x7c00) + exponent = 0xff << 10; + else if (exponent !== 0) + exponent += (127 - 15) << 10; + else if (fraction !== 0) + return fraction * POW_2_24; + + tempDataView.setUint32(0, sign << 16 | exponent << 13 | fraction << 13); + return tempDataView.getFloat32(0); + } + function readFloat32() { + return read(dataView.getFloat32(offset), 4); + } + function readFloat64() { + return read(dataView.getFloat64(offset), 8); + } + function readUint8() { + return read(dataView.getUint8(offset), 1); + } + function readUint16() { + return read(dataView.getUint16(offset), 2); + } + function readUint32() { + return read(dataView.getUint32(offset), 4); + } + function readUint64() { + return readUint32() * POW_2_32 + readUint32(); + } + function readBreak() { + if (dataView.getUint8(offset) !== 0xff) + return false; + offset += 1; + return true; + } + function readLength(additionalInformation) { + if (additionalInformation < 24) + return additionalInformation; + if (additionalInformation === 24) + return readUint8(); + if (additionalInformation === 25) + return readUint16(); + if (additionalInformation === 26) + return readUint32(); + if (additionalInformation === 27) + return readUint64(); + if (additionalInformation === 31) + return -1; + throw "Invalid length encoding"; + } + function readIndefiniteStringLength(majorType) { + var initialByte = readUint8(); + if (initialByte === 0xff) + return -1; + var length = readLength(initialByte & 0x1f); + if (length < 0 || (initialByte >> 5) !== majorType) + throw "Invalid indefinite length element"; + return length; + } + + function appendUtf16data(utf16data, length) { + for (var i = 0; i < length; ++i) { + var value = readUint8(); + if (value & 0x80) { + if (value < 0xe0) { + value = (value & 0x1f) << 6 + | (readUint8() & 0x3f); + length -= 1; + } else if (value < 0xf0) { + value = (value & 0x0f) << 12 + | (readUint8() & 0x3f) << 6 + | (readUint8() & 0x3f); + length -= 2; + } else { + value = (value & 0x0f) << 18 + | (readUint8() & 0x3f) << 12 + | (readUint8() & 0x3f) << 6 + | (readUint8() & 0x3f); + length -= 3; + } + } + + if (value < 0x10000) { + utf16data.push(value); + } else { + value -= 0x10000; + utf16data.push(0xd800 | (value >> 10)); + utf16data.push(0xdc00 | (value & 0x3ff)); + } + } + } + + function decodeItem() { + var initialByte = readUint8(); + var majorType = initialByte >> 5; + var additionalInformation = initialByte & 0x1f; + var i; + var length; + + if (majorType === 7) { + switch (additionalInformation) { + case 25: + return readFloat16(); + case 26: + return readFloat32(); + case 27: + return readFloat64(); + } + } + + length = readLength(additionalInformation); + if (length < 0 && (majorType < 2 || 6 < majorType)) + throw "Invalid length"; + + switch (majorType) { + case 0: + return length; + case 1: + return -1 - length; + case 2: + if (length < 0) { + var elements = []; + var fullArrayLength = 0; + while ((length = readIndefiniteStringLength(majorType)) >= 0) { + fullArrayLength += length; + elements.push(readArrayBuffer(length)); + } + var fullArray = new Uint8Array(fullArrayLength); + var fullArrayOffset = 0; + for (i = 0; i < elements.length; ++i) { + fullArray.set(elements[i], fullArrayOffset); + fullArrayOffset += elements[i].length; + } + return fullArray; + } + return readArrayBuffer(length); + case 3: + var utf16data = []; + if (length < 0) { + while ((length = readIndefiniteStringLength(majorType)) >= 0) + appendUtf16data(utf16data, length); + } else + appendUtf16data(utf16data, length); + return String.fromCharCode.apply(null, utf16data); + case 4: + var retArray; + if (length < 0) { + retArray = []; + while (!readBreak()) + retArray.push(decodeItem()); + } else { + retArray = new Array(length); + for (i = 0; i < length; ++i) + retArray[i] = decodeItem(); + } + return retArray; + case 5: + var retObject = {}; + for (i = 0; i < length || length < 0 && !readBreak(); ++i) { + var key = decodeItem(); + retObject[key] = decodeItem(); + } + return retObject; + case 6: + return tagger(decodeItem(), length); + case 7: + switch (length) { + case 20: + return false; + case 21: + return true; + case 22: + return null; + case 23: + return undefined; + default: + return simpleValue(length); + } + } + } + + var ret = decodeItem(); + if (offset !== data.byteLength) + throw "Remaining bytes"; + return ret; +} + +var obj = { encode: encode, decode: decode }; + +if (typeof define === "function" && define.amd) + define("cbor/cbor", obj); +else if (typeof module !== 'undefined' && module.exports) + module.exports = obj; +else if (!global.CBOR) + global.CBOR = obj; + +})(this); From bdbf6e26013f2f304523ebdf30da38ee8eda0ecb Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 21 Jan 2026 23:25:30 +0000 Subject: [PATCH 09/12] Improve settings styles --- entrypoints/sidepanel/routes/Settings.tsx | 97 +++++++++++------------ 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/entrypoints/sidepanel/routes/Settings.tsx b/entrypoints/sidepanel/routes/Settings.tsx index 3ddab61..76dd7e9 100644 --- a/entrypoints/sidepanel/routes/Settings.tsx +++ b/entrypoints/sidepanel/routes/Settings.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { ActionFunctionArgs, NavLink, @@ -13,7 +13,6 @@ import { Group, Text, Switch, - Card, Anchor, } from "@mantine/core"; import { notifications } from "@mantine/notifications"; @@ -81,27 +80,29 @@ export default function Settings() { const [isAuthenticating, setIsAuthenticating] = useState(false); // Show notifications based on action result - if (actionData && "success" in actionData) { - if (actionData.action === "enabled") { - notifications.show({ - title: "Biometric lock enabled", - message: "Orbit will now require authentication to access", - color: "green", - }); - } else if (actionData.action === "disabled") { + useEffect(() => { + if (actionData && "success" in actionData) { + if (actionData.action === "enabled") { + notifications.show({ + title: "Biometric lock enabled", + message: "Orbit will now require authentication to access", + color: "green", + }); + } else if (actionData.action === "disabled") { + notifications.show({ + title: "Biometric lock disabled", + message: "Orbit no longer requires authentication", + color: "blue", + }); + } + } else if (actionData && "error" in actionData) { notifications.show({ - title: "Biometric lock disabled", - message: "Orbit no longer requires authentication", - color: "blue", + title: "Error", + message: actionData.error, + color: "red", }); } - } else if (actionData && "error" in actionData) { - notifications.show({ - title: "Error", - message: actionData.error, - color: "red", - }); - } + }, [actionData]); async function handleToggle() { if (isEnabled) { @@ -150,37 +151,35 @@ export default function Settings() { Settings - - - - - - - Biometric Lock - - {isSupported - ? "Require Touch ID or Windows Hello to access Orbit" - : "Biometric authentication is not available on this device"} - - - - {isSupported && ( - - )} + + + + + + Enable Lock + + {isSupported + ? "Require TouchID or similar to access Orbit" + : "Passkey authentication is not available on this device"} + + - {isEnabled && ( - - Note: If you lose access to biometrics, you'll need to - re-import your accounts from a backup. - + {isSupported && ( + )} - - + + {isEnabled && ( + + Note: If you lose access to your passkey, you'll need to + re-import your accounts from a backup. + + )} + ); } From 988f83860fce774cea8a32b3373e4b2e72d6b3e1 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 22 Jan 2026 19:41:20 +0000 Subject: [PATCH 10/12] Simplify to only use last unlock time, instead of activity --- biometricLock/activityTracker.ts | 42 ------------------- biometricLock/schemas.ts | 4 +- biometricLock/storage.ts | 20 ++------- entrypoints/background/index.ts | 11 ++--- .../sidepanel/components/LockGuard.tsx | 12 +----- entrypoints/sidepanel/main.tsx | 4 -- entrypoints/sidepanel/routes/Settings.tsx | 2 +- 7 files changed, 15 insertions(+), 80 deletions(-) delete mode 100644 biometricLock/activityTracker.ts diff --git a/biometricLock/activityTracker.ts b/biometricLock/activityTracker.ts deleted file mode 100644 index 0fdab06..0000000 --- a/biometricLock/activityTracker.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { updateLastActivity, isBiometricLockEnabled } from "./storage"; - -let isTracking = false; - -/** - * Debounce function to limit how often a function is called - */ -function debounce unknown>( - fn: T, - delay: number -): (...args: Parameters) => void { - let timeoutId: ReturnType; - return (...args: Parameters) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => fn(...args), delay); - }; -} - -/** - * Start tracking user activity in the sidepanel. - * Updates the last activity timestamp when user interacts with the UI. - * Call this once on app initialization. - */ -export function startActivityTracking(): void { - if (isTracking) return; - isTracking = true; - - // Track meaningful user interactions - const events = ["click", "keydown", "scroll", "focus"]; - - // Debounce to avoid excessive storage writes - const handler = debounce(async () => { - const enabled = await isBiometricLockEnabled(); - if (enabled) { - await updateLastActivity(); - } - }, 1000); - - events.forEach((event) => { - document.addEventListener(event, handler, { passive: true }); - }); -} diff --git a/biometricLock/schemas.ts b/biometricLock/schemas.ts index dc28ef0..824be91 100644 --- a/biometricLock/schemas.ts +++ b/biometricLock/schemas.ts @@ -9,9 +9,11 @@ export const biometricLockSettingsSchema = z.object({ export type BiometricLockSettings = z.infer; +// Tracks when the extension was last unlocked (not continuous activity) +// Used to lock after 30 minutes since unlock export const biometricLockStateSchema = z.object({ isLocked: z.boolean(), - lastActivityTimestamp: z.number(), + lastUnlockTimestamp: z.number(), }); export type BiometricLockState = z.infer; diff --git a/biometricLock/storage.ts b/biometricLock/storage.ts index f02977b..16cd0b5 100644 --- a/biometricLock/storage.ts +++ b/biometricLock/storage.ts @@ -60,7 +60,7 @@ export async function getLockState(): Promise { const jsonOrNull = await storage.getItem(LOCK_STATE_KEY); if (!jsonOrNull) { // Default to locked if no state exists (e.g., after browser restart) - return { isLocked: true, lastActivityTimestamp: 0 }; + return { isLocked: true, lastUnlockTimestamp: 0 }; } const parsed = JSON.parse(jsonOrNull); @@ -71,10 +71,10 @@ export async function getLockState(): Promise { } // Invalid state, default to locked - return { isLocked: true, lastActivityTimestamp: 0 }; + return { isLocked: true, lastUnlockTimestamp: 0 }; } catch (error) { console.error("Error reading lock state:", error); - return { isLocked: true, lastActivityTimestamp: 0 }; + return { isLocked: true, lastUnlockTimestamp: 0 }; } } @@ -86,20 +86,6 @@ export async function setLockState(state: BiometricLockState): Promise { } } -export async function updateLastActivity(): Promise { - try { - const currentState = await getLockState(); - if (!currentState.isLocked) { - await setLockState({ - ...currentState, - lastActivityTimestamp: Date.now(), - }); - } - } catch (error) { - console.error("Error updating last activity:", error); - } -} - export async function isBiometricLockEnabled(): Promise { const settings = await getBiometricLockSettings(); return settings?.isEnabled ?? false; diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index 6f0e3aa..2b178cd 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -104,12 +104,13 @@ function main() { async function handleBrowserStartup() { const enabled = await isBiometricLockEnabled(); if (enabled) { - await setLockState({ isLocked: true, lastActivityTimestamp: 0 }); + await setLockState({ isLocked: true, lastUnlockTimestamp: 0 }); } } /** - * Check for inactivity and lock if the timeout has been exceeded. + * Check if lock timeout has been exceeded since last unlock. + * Locks the extension if 30+ minutes have passed since unlock. */ async function checkInactivity() { const enabled = await isBiometricLockEnabled(); @@ -119,13 +120,13 @@ async function checkInactivity() { if (state.isLocked) return; // Already locked const now = Date.now(); - const inactiveMs = now - state.lastActivityTimestamp; + const timeSinceUnlock = now - state.lastUnlockTimestamp; const timeoutMs = INACTIVITY_TIMEOUT_MINUTES * 60 * 1000; - if (inactiveMs >= timeoutMs) { + if (timeSinceUnlock >= timeoutMs) { await setLockState({ isLocked: true, - lastActivityTimestamp: state.lastActivityTimestamp, + lastUnlockTimestamp: state.lastUnlockTimestamp, }); } } diff --git a/entrypoints/sidepanel/components/LockGuard.tsx b/entrypoints/sidepanel/components/LockGuard.tsx index dfaefb9..9d2e63e 100644 --- a/entrypoints/sidepanel/components/LockGuard.tsx +++ b/entrypoints/sidepanel/components/LockGuard.tsx @@ -6,7 +6,6 @@ import { getLockState, isBiometricLockEnabled, setLockState, - updateLastActivity, } from "~/biometricLock/storage"; import { authenticateBiometric } from "~/biometricLock/webauthn"; @@ -28,13 +27,6 @@ export default function LockGuard({ children }: LockGuardProps) { checkLockState(); }, []); - // Update activity timestamp on navigation (when unlocked) - useEffect(() => { - if (isLocked === false) { - updateLastActivity(); - } - }, [location.pathname, isLocked]); - async function checkLockState() { const enabled = await isBiometricLockEnabled(); if (!enabled) { @@ -54,14 +46,14 @@ export default function LockGuard({ children }: LockGuardProps) { const settings = await getBiometricLockSettings(); if (!settings?.credentialId) { // No credential, something is wrong - unlock anyway - await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + await setLockState({ isLocked: false, lastUnlockTimestamp: Date.now() }); setIsLocked(false); return; } const success = await authenticateBiometric(settings.credentialId); if (success) { - await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + await setLockState({ isLocked: false, lastUnlockTimestamp: Date.now() }); setIsLocked(false); } else { setError("Authentication failed. Please try again."); diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx index e92b4de..e1bc9f4 100644 --- a/entrypoints/sidepanel/main.tsx +++ b/entrypoints/sidepanel/main.tsx @@ -24,10 +24,6 @@ import { Notifications } from '@mantine/notifications' import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import './styles/global.css'; -import { startActivityTracking } from '~/biometricLock/activityTracker'; - -// Start tracking user activity for biometric lock inactivity timer -startActivityTracking(); const router = createBrowserRouter([ { diff --git a/entrypoints/sidepanel/routes/Settings.tsx b/entrypoints/sidepanel/routes/Settings.tsx index 76dd7e9..0b1838b 100644 --- a/entrypoints/sidepanel/routes/Settings.tsx +++ b/entrypoints/sidepanel/routes/Settings.tsx @@ -49,7 +49,7 @@ export async function action({ request }: ActionFunctionArgs) { relyingPartyId: window.location.hostname, }); // Start unlocked after enabling - await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); + await setLockState({ isLocked: false, lastUnlockTimestamp: Date.now() }); return { success: true, action: "enabled" }; } catch (error) { console.error("Failed to enable biometric lock:", error); From 0f5a1d391616a69c5563f39fb5a4e82ab32477e5 Mon Sep 17 00:00:00 2001 From: Callum Date: Thu, 22 Jan 2026 19:43:17 +0000 Subject: [PATCH 11/12] Remove outdated lock plan --- docs/BIOMETRIC_LOCK_PLAN.md | 509 ------------------------------------ 1 file changed, 509 deletions(-) delete mode 100644 docs/BIOMETRIC_LOCK_PLAN.md diff --git a/docs/BIOMETRIC_LOCK_PLAN.md b/docs/BIOMETRIC_LOCK_PLAN.md deleted file mode 100644 index 22c12c6..0000000 --- a/docs/BIOMETRIC_LOCK_PLAN.md +++ /dev/null @@ -1,509 +0,0 @@ -# Biometric Lock Implementation Plan - -This document details the implementation of a biometric lock feature (Touch ID/Windows Hello) using WebAuthn for the Orbit Chrome extension. - -## Requirements Summary - -| Requirement | Decision | -|-------------|----------| -| **What to protect** | Extension UI (sidepanel routes), but NOT the Connect screen | -| **Lock triggers** | After 30 minutes of inactivity OR browser restart | -| **Auth method** | Biometric only via WebAuthn (Touch ID, Windows Hello, etc.) | -| **Fallback** | None - if biometrics unavailable, feature is unavailable | -| **Recovery** | Data becomes inaccessible if biometrics fail; user can re-import | -| **Settings location** | New Settings route | -| **Disable lock** | Requires biometric auth first | - ---- - -## Phase 1: Core Infrastructure - -### 1.1 Add Dependencies - -**File:** `package.json` - -```json -"dependencies": { - "@lo-fi/webauthn-local-client": "^0.15.0" -} -``` - -### 1.2 Create Biometric Lock Storage Module - -**New File:** `biometricLock/storage.ts` - -```typescript -import { storage } from "#imports"; - -// Storage keys -const BIOMETRIC_SETTINGS_KEY = "local:biometricLockSettings"; -const LOCK_STATE_KEY = "session:biometricLockState"; - -// Types -interface BiometricLockSettings { - isEnabled: boolean; - credentialId: string | null; // Base64 encoded credential ID - publicKey: string | null; // Base64 encoded public key - relyingPartyId: string; -} - -interface BiometricLockState { - isLocked: boolean; - lastActivityTimestamp: number; // Unix timestamp -} - -// Functions to implement -export async function getBiometricLockSettings(): Promise; -export async function saveBiometricLockSettings(settings: BiometricLockSettings): Promise; -export async function clearBiometricLockSettings(): Promise; -export async function getLockState(): Promise; -export async function setLockState(state: BiometricLockState): Promise; -export async function updateLastActivity(): Promise; -export async function isBiometricLockEnabled(): Promise; -``` - -### 1.3 Create WebAuthn Wrapper Module - -**New File:** `biometricLock/webauthn.ts` - -```typescript -import { supportsWebAuthn, register, auth } from "@lo-fi/webauthn-local-client"; - -export interface RegistrationResult { - credentialId: string; - publicKey: string; -} - -export async function checkBiometricSupport(): Promise; -export async function registerBiometric(userId: string): Promise; -export async function authenticateBiometric(credentialId: string): Promise; -``` - -**Implementation Notes:** -- Use `supportsWebAuthn` to check device capability -- Set `userVerification: "required"` to force biometric (not just PIN) -- Handle errors: `NotAllowedError` (cancelled), `NotSupportedError`, `SecurityError` - -### 1.4 Create Zod Schemas - -**New File:** `biometricLock/schemas.ts` - -```typescript -import { z } from "zod"; - -export const biometricLockSettingsSchema = z.object({ - isEnabled: z.boolean(), - credentialId: z.string().nullable(), - publicKey: z.string().nullable(), - relyingPartyId: z.string(), -}); - -export type BiometricLockSettings = z.infer; -``` - ---- - -## Phase 2: Lock Screen UI - -### 2.1 Create Lock Screen Component - -**New File:** `entrypoints/sidepanel/components/LockScreen.tsx` - -```tsx -import { Stack, Button, Text, Image, Center } from "@mantine/core"; -import { IconFingerprint } from "@tabler/icons-react"; - -interface LockScreenProps { - onUnlock: () => void; - isAuthenticating: boolean; - error?: string; -} - -export default function LockScreen({ onUnlock, isAuthenticating, error }: LockScreenProps) { - return ( -
- - Orbit - Orbit is locked - - {error && {error}} - -
- ); -} -``` - ---- - -## Phase 3: Settings Route - -### 3.1 Create Settings Route - -**New File:** `entrypoints/sidepanel/routes/Settings.tsx` - -```tsx -import { Form, useLoaderData, Link } from "react-router-dom"; -import { Button, Stack, Title, Group, Text, Switch, Card } from "@mantine/core"; -import { IconArrowLeft, IconFingerprint } from "@tabler/icons-react"; - -export async function loader() { - const isSupported = await checkBiometricSupport(); - const settings = await getBiometricLockSettings(); - return { - isSupported, - isEnabled: settings?.isEnabled ?? false - }; -} - -export async function action({ request }: ActionFunctionArgs) { - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "enable") { - const result = await registerBiometric("orbit-user"); - await saveBiometricLockSettings({ - isEnabled: true, - credentialId: result.credentialId, - publicKey: result.publicKey, - relyingPartyId: window.location.hostname - }); - return { success: true }; - } - - if (intent === "disable") { - // Called after successful auth verification in component - await clearBiometricLockSettings(); - return { success: true }; - } - - return null; -} - -export default function Settings() { - const { isSupported, isEnabled } = useLoaderData(); - - return ( - - - - - - Settings - - - - - - - - Biometric Lock - - {isSupported ? ( -
- - e.target.form?.requestSubmit()} /> - - ) : ( - Not available - )} -
- - {isSupported - ? "Require Touch ID or Windows Hello to access Orbit" - : "Biometric authentication is not available on this device" - } - -
-
-
- ); -} -``` - -### 3.2 Update Home Settings Menu - -**Modify:** `entrypoints/sidepanel/routes/Home.tsx` - -Add Settings link to the menu dropdown: - -```tsx - - - Settings - - - Export - - - Import - - -``` - ---- - -## Phase 4: Router Integration - -### 4.1 Create Lock Guard Component - -**New File:** `entrypoints/sidepanel/components/LockGuard.tsx` - -```tsx -import { useState, useEffect } from "react"; -import { useLocation } from "react-router-dom"; -import LockScreen from "./LockScreen"; - -interface LockGuardProps { - children: React.ReactNode; -} - -export default function LockGuard({ children }: LockGuardProps) { - const [isLocked, setIsLocked] = useState(null); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [error, setError] = useState(); - const location = useLocation(); - - // Skip lock for Connect route (read-only, low risk) - const isConnectRoute = location.pathname.startsWith("/accounts/connect"); - - useEffect(() => { - checkLockState(); - }, []); - - // Update activity timestamp on navigation - useEffect(() => { - if (!isLocked) { - updateLastActivity(); - } - }, [location.pathname]); - - async function checkLockState() { - const enabled = await isBiometricLockEnabled(); - if (!enabled) { - setIsLocked(false); - return; - } - const state = await getLockState(); - setIsLocked(state.isLocked); - } - - async function handleUnlock() { - setIsAuthenticating(true); - setError(undefined); - try { - const settings = await getBiometricLockSettings(); - const success = await authenticateBiometric(settings.credentialId); - if (success) { - await setLockState({ isLocked: false, lastActivityTimestamp: Date.now() }); - setIsLocked(false); - } - } catch (e) { - setError("Authentication failed. Please try again."); - } finally { - setIsAuthenticating(false); - } - } - - if (isLocked === null) return null; // Loading - if (isConnectRoute) return <>{children}; - if (isLocked) return ; - - return <>{children}; -} -``` - -### 4.2 Update Layout - -**Modify:** `entrypoints/sidepanel/layout.tsx` - -```tsx -import { Box } from "@mantine/core"; -import { Outlet } from "react-router-dom"; -import LockGuard from "./components/LockGuard"; - -export default function Layout() { - return ( - - - - - - ); -} -``` - -### 4.3 Add Settings Route to Router - -**Modify:** `entrypoints/sidepanel/main.tsx` - -```tsx -import Settings, { loader as settingsLoader, action as settingsAction } from './routes/Settings'; - -// Add to router children under accounts: -{ - path: "settings", - loader: settingsLoader, - action: settingsAction, - element: , -} -``` - ---- - -## Phase 5: Inactivity Timer & Browser Restart - -### 5.1 Activity Tracker - -**New File:** `biometricLock/activityTracker.ts` - -```typescript -import { updateLastActivity, isBiometricLockEnabled } from "./storage"; - -let isTracking = false; - -export function startActivityTracking() { - if (isTracking) return; - isTracking = true; - - const events = ["click", "keydown", "scroll", "focus"]; - - const handler = debounce(async () => { - const enabled = await isBiometricLockEnabled(); - if (enabled) { - await updateLastActivity(); - } - }, 1000); - - events.forEach(event => { - document.addEventListener(event, handler, { passive: true }); - }); -} - -function debounce any>(fn: T, delay: number) { - let timeoutId: ReturnType; - return (...args: Parameters) => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => fn(...args), delay); - }; -} -``` - -### 5.2 Background Script Updates - -**Modify:** `entrypoints/background/index.ts` - -```typescript -const LOCK_ALARM_NAME = "biometricLockAlarm"; -const INACTIVITY_TIMEOUT_MINUTES = 30; - -// Lock on browser startup -browser.runtime.onStartup.addListener(async () => { - const enabled = await isBiometricLockEnabled(); - if (enabled) { - await setLockState({ isLocked: true, lastActivityTimestamp: 0 }); - } -}); - -// Set up inactivity check alarm (every 5 minutes) -browser.alarms.create(LOCK_ALARM_NAME, { periodInMinutes: 5 }); - -browser.alarms.onAlarm.addListener(async (alarm) => { - if (alarm.name === LOCK_ALARM_NAME) { - const enabled = await isBiometricLockEnabled(); - if (!enabled) return; - - const state = await getLockState(); - if (state.isLocked) return; - - const inactiveMs = Date.now() - state.lastActivityTimestamp; - if (inactiveMs >= INACTIVITY_TIMEOUT_MINUTES * 60 * 1000) { - await setLockState({ isLocked: true, lastActivityTimestamp: state.lastActivityTimestamp }); - } - } -}); -``` - ---- - -## Phase 6: Permissions - -**Modify:** `wxt.config.ts` - -```typescript -manifest: { - permissions: ["sidePanel", "storage", "downloads", "alarms"], -}, -``` - ---- - -## File Summary - -### New Files - -| File | Purpose | -|------|---------| -| `biometricLock/storage.ts` | Lock settings and state persistence | -| `biometricLock/webauthn.ts` | WebAuthn wrapper for biometric ops | -| `biometricLock/schemas.ts` | Zod schemas for validation | -| `biometricLock/activityTracker.ts` | User activity tracking | -| `entrypoints/sidepanel/components/LockScreen.tsx` | Lock screen UI | -| `entrypoints/sidepanel/routes/Settings.tsx` | Settings route | - -### Modified Files - -| File | Changes | -|------|---------| -| `package.json` | Add `@lo-fi/webauthn-local-client` | -| `wxt.config.ts` | Add `alarms` permission | -| `entrypoints/sidepanel/main.tsx` | Add Settings route, init activity tracking | -| `entrypoints/sidepanel/layout.tsx` | Wrap with LockGuard | -| `entrypoints/sidepanel/routes/Home.tsx` | Add Settings menu item | -| `entrypoints/background/index.ts` | Inactivity timer, browser startup handler | - ---- - -## Implementation Order - -1. **Phase 1** - Core infrastructure (storage, webauthn, schemas) -2. **Phase 2** - Lock screen UI -3. **Phase 3** - Settings route + Home menu update -4. **Phase 4** - Router integration (LockGuard) -5. **Phase 5** - Inactivity timer + browser restart detection -6. **Phase 6** - Permission updates - ---- - -## Edge Cases & Considerations - -### Session vs Local Storage -- `session:` for lock state (clears on browser close = re-auth required) -- `local:` for settings/credentials (persists) - -### Connect Route Exemption -The Connect route remains accessible when locked because Orbit is read-only. - -### WebAuthn Errors to Handle -- `NotAllowedError` - User cancelled or timeout -- `NotSupportedError` - Feature not supported -- `SecurityError` - Invalid origin - -### Credential Invalidation -If user removes biometrics from device settings, auth will fail. User must re-import data. - ---- - -## Resources - -- [@lo-fi/webauthn-local-client](https://github.com/mylofi/webauthn-local-client) -- [Web Authentication API - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) From 92bad30c19fa10420b94d08d33713b4688abcc24 Mon Sep 17 00:00:00 2001 From: Callum Date: Fri, 23 Jan 2026 22:49:05 +0000 Subject: [PATCH 12/12] Improve error handling in lock] storage --- biometricLock/storage.ts | 26 +++++++------------------- entrypoints/background/index.ts | 20 +++++++++++++++----- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/biometricLock/storage.ts b/biometricLock/storage.ts index 16cd0b5..3e3e085 100644 --- a/biometricLock/storage.ts +++ b/biometricLock/storage.ts @@ -36,23 +36,15 @@ export async function getBiometricLockSettings(): Promise { - try { - await storage.setItem( - BIOMETRIC_SETTINGS_KEY, - JSON.stringify(settings) - ); - } catch (error) { - console.error("Error saving biometric lock settings:", error); - } + await storage.setItem( + BIOMETRIC_SETTINGS_KEY, + JSON.stringify(settings) + ); } export async function clearBiometricLockSettings(): Promise { - try { - await storage.removeItem(BIOMETRIC_SETTINGS_KEY); - await storage.removeItem(LOCK_STATE_KEY); - } catch (error) { - console.error("Error clearing biometric lock settings:", error); - } + await storage.removeItem(BIOMETRIC_SETTINGS_KEY); + await storage.removeItem(LOCK_STATE_KEY); } export async function getLockState(): Promise { @@ -79,11 +71,7 @@ export async function getLockState(): Promise { } export async function setLockState(state: BiometricLockState): Promise { - try { - await storage.setItem(LOCK_STATE_KEY, JSON.stringify(state)); - } catch (error) { - console.error("Error saving lock state:", error); - } + await storage.setItem(LOCK_STATE_KEY, JSON.stringify(state)); } export async function isBiometricLockEnabled(): Promise { diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index 2b178cd..c2b9b6a 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -104,7 +104,12 @@ function main() { async function handleBrowserStartup() { const enabled = await isBiometricLockEnabled(); if (enabled) { - await setLockState({ isLocked: true, lastUnlockTimestamp: 0 }); + try { + await setLockState({ isLocked: true, lastUnlockTimestamp: 0 }); + } catch (error) { + console.error("Failed to lock on browser startup:", error); + // Continue - extension can still function, user will need to unlock on first access + } } } @@ -124,10 +129,15 @@ async function checkInactivity() { const timeoutMs = INACTIVITY_TIMEOUT_MINUTES * 60 * 1000; if (timeSinceUnlock >= timeoutMs) { - await setLockState({ - isLocked: true, - lastUnlockTimestamp: state.lastUnlockTimestamp, - }); + try { + await setLockState({ + isLocked: true, + lastUnlockTimestamp: state.lastUnlockTimestamp, + }); + } catch (error) { + console.error("Failed to lock after inactivity timeout:", error); + // Continue - will retry on next check (every 5 minutes) + } } }