Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions biometricLock/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof biometricLockSettingsSchema>;

// 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<typeof biometricLockStateSchema>;
80 changes: 80 additions & 0 deletions biometricLock/storage.ts
Original file line number Diff line number Diff line change
@@ -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<BiometricLockSettings | null> {
try {
const jsonOrNull = await storage.getItem<string>(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<void> {
await storage.setItem<string>(
BIOMETRIC_SETTINGS_KEY,
JSON.stringify(settings)
);
}

export async function clearBiometricLockSettings(): Promise<void> {
await storage.removeItem(BIOMETRIC_SETTINGS_KEY);
await storage.removeItem(LOCK_STATE_KEY);
}

export async function getLockState(): Promise<BiometricLockState> {
try {
const jsonOrNull = await storage.getItem<string>(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<void> {
await storage.setItem<string>(LOCK_STATE_KEY, JSON.stringify(state));
}

export async function isBiometricLockEnabled(): Promise<boolean> {
const settings = await getBiometricLockSettings();
return settings?.isEnabled ?? false;
}
48 changes: 48 additions & 0 deletions biometricLock/webauthn-local-client.d.ts
Original file line number Diff line number Diff line change
@@ -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<RegResult>;
export function auth(options: AuthOptions): Promise<AuthResult>;
}
96 changes: 96 additions & 0 deletions biometricLock/webauthn.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<RegistrationResult> {
// 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<boolean> {
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;
}
}
73 changes: 73 additions & 0 deletions entrypoints/background/index.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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();
});
Loading