diff --git a/biometricLock/schemas.ts b/biometricLock/schemas.ts new file mode 100644 index 0000000..824be91 --- /dev/null +++ b/biometricLock/schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const biometricLockSettingsSchema = z.object({ + isEnabled: z.boolean(), + credentialId: z.string(), + publicKey: z.string(), + relyingPartyId: z.string(), +}); + +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(), + lastUnlockTimestamp: z.number(), +}); + +export type BiometricLockState = z.infer; diff --git a/biometricLock/storage.ts b/biometricLock/storage.ts new file mode 100644 index 0000000..3e3e085 --- /dev/null +++ b/biometricLock/storage.ts @@ -0,0 +1,80 @@ +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 { + await storage.setItem( + BIOMETRIC_SETTINGS_KEY, + JSON.stringify(settings) + ); +} + +export async function clearBiometricLockSettings(): Promise { + await storage.removeItem(BIOMETRIC_SETTINGS_KEY); + await storage.removeItem(LOCK_STATE_KEY); +} + +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, lastUnlockTimestamp: 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, lastUnlockTimestamp: 0 }; + } catch (error) { + console.error("Error reading lock state:", error); + return { isLocked: true, lastUnlockTimestamp: 0 }; + } +} + +export async function setLockState(state: BiometricLockState): Promise { + await storage.setItem(LOCK_STATE_KEY, JSON.stringify(state)); +} + +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..159f5e9 --- /dev/null +++ b/biometricLock/webauthn-local-client.d.ts @@ -0,0 +1,48 @@ +declare module "@lo-fi/webauthn-local-client" { + export const supportsWebAuthn: boolean; + + export interface RegOptions { + relyingPartyName: string; + user: { + id: BufferSource; // WebAuthn requires ArrayBuffer or ArrayBufferView + name: string; + displayName: string; + }; + authenticatorSelection?: { + userVerification?: "required" | "preferred" | "discouraged"; + authenticatorAttachment?: "platform" | "cross-platform"; + residentKey?: "required" | "preferred" | "discouraged"; + }; + } + + export interface RegResult { + response: { + credentialID: string; // Note: uppercase ID + publicKey: { + algoCOSE: number; + algoOID: string; + spki: Uint8Array; + raw: Uint8Array; + }; + }; + } + + 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..5791c0d --- /dev/null +++ b/biometricLock/webauthn.ts @@ -0,0 +1,96 @@ +import { + supportsWebAuthn, + register, + auth, + 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; + 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 { + // 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: userIdBuffer, + 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); + + // Convert publicKey raw bytes to base64 string for storage + const publicKeyBase64 = btoa( + String.fromCharCode(...regResult.response.publicKey.raw) + ); + + return { + credentialId: regResult.response.credentialID, + publicKey: publicKeyBase64, + }; +} + +/** + * 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/entrypoints/background/index.ts b/entrypoints/background/index.ts index 967a47e..c2b9b6a 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,71 @@ 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) { + 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 + } + } +} + +/** + * 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(); + if (!enabled) return; + + const state = await getLockState(); + if (state.isLocked) return; // Already locked + + const now = Date.now(); + const timeSinceUnlock = now - state.lastUnlockTimestamp; + const timeoutMs = INACTIVITY_TIMEOUT_MINUTES * 60 * 1000; + + if (timeSinceUnlock >= timeoutMs) { + 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) + } + } +} + +/** + * 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/components/LockGuard.tsx b/entrypoints/sidepanel/components/LockGuard.tsx new file mode 100644 index 0000000..9d2e63e --- /dev/null +++ b/entrypoints/sidepanel/components/LockGuard.tsx @@ -0,0 +1,91 @@ +import { useState, useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import LockScreen from "./LockScreen"; +import { + getBiometricLockSettings, + getLockState, + isBiometricLockEnabled, + setLockState, +} 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(); + }, []); + + 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, lastUnlockTimestamp: Date.now() }); + setIsLocked(false); + return; + } + + const success = await authenticateBiometric(settings.credentialId); + if (success) { + await setLockState({ isLocked: false, lastUnlockTimestamp: 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/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} + + )} + +
+ ); +} 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/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 ( - - - + + + + + ) } 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..0b1838b --- /dev/null +++ b/entrypoints/sidepanel/routes/Settings.tsx @@ -0,0 +1,185 @@ +import { useState, useEffect } from "react"; +import { + ActionFunctionArgs, + NavLink, + useActionData, + useLoaderData, + useSubmit, +} from "react-router-dom"; +import { + Button, + Stack, + Title, + Group, + Text, + Switch, + 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, lastUnlockTimestamp: 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 + 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: "Error", + message: actionData.error, + color: "red", + }); + } + }, [actionData]); + + 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 + + + + + + + + Enable Lock + + {isSupported + ? "Require TouchID or similar to access Orbit" + : "Passkey authentication is not available on this device"} + + + + {isSupported && ( + + )} + + {isEnabled && ( + + Note: If you lose access to your passkey, you'll need to + re-import your accounts from a backup. + + )} + + + ); +} diff --git a/package.json b/package.json index 8e61030..a0318de 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", @@ -23,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 bf4c085..3649706 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) @@ -38,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 @@ -405,6 +411,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 +585,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 +692,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 +871,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 +1467,18 @@ packages: resolution: {integrity: sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==} engines: {node: '>=18'} + 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==} @@ -2608,6 +2646,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 +2778,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 +2894,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 +3084,8 @@ snapshots: caniuse-lite@1.0.30001750: {} + cbor-js@0.1.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3546,6 +3602,18 @@ snapshots: dependencies: package-json: 10.0.1 + libsodium-wrappers@0.7.16: + 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); 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"], }, });