Skip to content
Merged
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
183 changes: 183 additions & 0 deletions apps/web/e2e/browser-lock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,189 @@ test("the passphrase setting can lock the browser whenever the tab loses focus",
await expect.poll(() => hasStoredMasterKey(page)).toBe(false);
});

const IDLE_USER = { ...user, lockBrowserOn: "inactivity" };
const ONE_HOUR_MS = 60 * 60 * 1000;

async function openUnlockedApp(page: Page) {
await page.goto(`${APP}/app/project`, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { name: /Name your first/i }),
).toBeVisible();
}

function unlockDialog(page: Page) {
return page.getByRole("dialog", { name: "Unlock this browser" });
}

async function setLastActive(page: Page, msAgo: number) {
await page.evaluate((ago) => {
localStorage.setItem("cryptly:last-active-at", String(Date.now() - ago));
}, msAgo);
}

test("an hour without activity locks the browser and wipes local keys", async ({
page,
}) => {
await page.clock.install();
await mockApi(page, { user: IDLE_USER });
await seedBrowserState(page);
await openUnlockedApp(page);

await page.clock.runFor(ONE_HOUR_MS + 60 * 1000);

await expect(unlockDialog(page)).toBeVisible();
await expect.poll(() => hasStoredMasterKey(page)).toBe(false);
});

test("activity in any tab keeps every tab unlocked", async ({ browser }) => {
const context = await browser.newContext();
await context.clock.install();
const idleTab = await context.newPage();
await mockApi(idleTab, { user: IDLE_USER });
await seedBrowserState(idleTab);
await openUnlockedApp(idleTab);
const activeTab = await context.newPage();
await mockApi(activeTab, { user: IDLE_USER });
await openUnlockedApp(activeTab);

await context.clock.runFor(50 * 60 * 1000);
await activeTab.mouse.move(200, 200);
await context.clock.runFor(50 * 60 * 1000);

await expect(unlockDialog(idleTab)).toHaveCount(0);
expect(await hasStoredMasterKey(idleTab)).toBe(true);
await context.close();
});

test("the first input after a long absence locks instead of renewing the session", async ({
page,
}) => {
await page.clock.install();
await mockApi(page, { user: IDLE_USER });
await seedBrowserState(page);
await openUnlockedApp(page);
await page.clock.fastForward(10 * 1000);

await setLastActive(page, 2 * ONE_HOUR_MS);
await page.mouse.move(300, 300);

await expect(unlockDialog(page)).toBeVisible();
await expect(unlockDialog(page)).toContainText(
"locked after 1 hour of inactivity",
);
await expect.poll(() => hasStoredMasterKey(page)).toBe(false);
});

test("reloading after a long absence still locks", async ({ page }) => {
await mockApi(page, { user: IDLE_USER, userDelayMs: 500 });
await seedBrowserState(page);
await openUnlockedApp(page);

await setLastActive(page, 2 * ONE_HOUR_MS);
await page.reload({ waitUntil: "domcontentloaded" });
await page.mouse.move(300, 300);

await expect(unlockDialog(page)).toBeVisible();
await expect(unlockDialog(page)).toContainText(
"locked after 1 hour of inactivity",
);
await expect.poll(() => hasStoredMasterKey(page)).toBe(false);
});

test("unlocking after an idle lock stays unlocked, the setting can be chosen, and a later lock from another tab does not blame inactivity", async ({
page,
}) => {
const passphrase = "correct horse battery staple";
const fixtures = await createUnlockedFixtures(page, passphrase);
let currentUser = { ...fixtures.user, lockBrowserOn: "tab-close" };
await mockApi(page, { user: currentUser, projects: [fixtures.project] });
await page.route("**/users/me", async (route) => {
if (route.request().method() === "PATCH") {
currentUser = { ...currentUser, ...route.request().postDataJSON() };
}
await route.fulfill({ json: currentUser });
});
await seedAuthOnly(page);
await page.goto(`${APP}/app/project/project-eval`, {
waitUntil: "domcontentloaded",
});
const unlock = async () => {
await page.getByRole("textbox", { name: "Passphrase" }).fill(passphrase);
await page.getByRole("button", { name: "Unlock" }).click();
await expect(unlockDialog(page)).toHaveCount(0);
};
await unlock();

await page.getByRole("button", { name: /Eval User/ }).click();
const inactivity = page.getByRole("menuitemradio", {
name: "After 1 hour of inactivity",
});
await inactivity.click();
await expect(inactivity).toHaveAttribute("aria-checked", "true");
expect(currentUser.lockBrowserOn).toBe("inactivity");

await setLastActive(page, 2 * ONE_HOUR_MS);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(unlockDialog(page)).toBeVisible();

await unlock();
await expect(unlockDialog(page)).toHaveCount(0);
await expect.poll(() => hasStoredMasterKey(page)).toBe(true);

await page.evaluate((key) => {
window.dispatchEvent(
new StorageEvent("storage", { key, newValue: String(Date.now()) }),
);
}, LOCKED_AT_STORAGE_KEY);
await expect(unlockDialog(page)).toBeVisible();
await expect(unlockDialog(page)).not.toContainText("inactivity");
});

test("the user menu is fully visible on a phone", async ({ page }) => {
const fixtures = await createUnlockedFixtures(page, "correct horse");
await page.setViewportSize({ width: 390, height: 844 });
await mockApi(page, { user: fixtures.user, projects: [fixtures.project] });
await seedAuthOnly(page);
await page.goto(`${APP}/app/project/project-eval`, {
waitUntil: "domcontentloaded",
});
await page.getByRole("textbox", { name: "Passphrase" }).fill("correct horse");
await page.getByRole("button", { name: "Unlock" }).click();
await expect(unlockDialog(page)).toHaveCount(0);

await page.getByRole("button", { name: "User menu" }).click();
for (const option of await page.getByRole("menuitemradio").all()) {
await option.click({ trial: true, timeout: 5000 });
}
await expect(page.getByRole("menuitemradio")).toHaveCount(3);
});

test("an idle locked tab stops reconnecting until someone returns", async ({
page,
}) => {
await page.clock.install();
await mockApi(page, { user: IDLE_USER });
await seedBrowserState(page);
let requesterStreamRequests = 0;
page.on("request", (request) => {
if (request.url().includes("device-flow/messages?role=requester")) {
requesterStreamRequests += 1;
}
});
await openUnlockedApp(page);
await page.clock.runFor(ONE_HOUR_MS + 60 * 1000);
await expect(unlockDialog(page)).toBeVisible();

const requestsWhenIdle = requesterStreamRequests;
await page.clock.runFor(10 * 60 * 1000);
expect(requesterStreamRequests).toBe(requestsWhenIdle);

await page.mouse.move(300, 300);
await expect
.poll(() => requesterStreamRequests)
.toBeGreaterThan(requestsWhenIdle);
});

test("unsaved edits survive tab switches and project switches go through the guard", async ({
page,
}) => {
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/lib/keys/activity.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { browser } from "$app/environment";

const IDLE_LIMIT_MS = 60 * 60 * 1000;
const CHECK_INTERVAL_MS = 30 * 1000;
const RECORD_THROTTLE_MS = 5 * 1000;
const LAST_ACTIVE_STORAGE_KEY = "cryptly:last-active-at";
const ACTIVITY_EVENTS = [
"pointerdown",
"pointermove",
"keydown",
"wheel",
"scroll",
"focus",
];

export const activity = $state({ idle: false });

let onIdle: (() => void) | null = null;
let lastHandledAt = 0;

function readLastActive(): number | null {
const value = Number(localStorage.getItem(LAST_ACTIVE_STORAGE_KEY));
return value > 0 ? value : null;
}

function checkIdle() {
const lastActive = readLastActive();
const idle = lastActive !== null && Date.now() - lastActive >= IDLE_LIMIT_MS;
activity.idle = idle;
if (idle) onIdle?.();
}

export function recordActivity() {
if (!browser) return;
localStorage.setItem(LAST_ACTIVE_STORAGE_KEY, String(Date.now()));
activity.idle = false;
}

function handleActivity() {
const now = Date.now();
if (now - lastHandledAt < RECORD_THROTTLE_MS) return;
lastHandledAt = now;
// Timers stop while a device sleeps, so the input that ends an absence must still lock.
checkIdle();
recordActivity();
}

function handleVisibilityChange() {
if (document.visibilityState === "visible") handleActivity();
}

export function trackActivity() {
if (!browser) return;
if (readLastActive() === null) recordActivity();
checkIdle();

const interval = setInterval(checkIdle, CHECK_INTERVAL_MS);
const options = { capture: true, passive: true };
for (const event of ACTIVITY_EVENTS) {
window.addEventListener(event, handleActivity, options);
}
document.addEventListener("visibilitychange", handleVisibilityChange);

return () => {
clearInterval(interval);
for (const event of ACTIVITY_EVENTS) {
window.removeEventListener(event, handleActivity, options);
}
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}

export function whenIdle(listener: () => void) {
onIdle = listener;
checkIdle();
return () => {
onIdle = null;
};
}
10 changes: 10 additions & 0 deletions apps/web/src/lib/keys/lock-sync.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { browser } from "$app/environment";
import { keystore } from "$lib/auth/keystore";
import { whenIdle } from "$lib/keys/activity.svelte";
import {
broadcastKeyLock,
KEY_LOCKED_AT_STORAGE_KEY,
markKeyLocked,
noteLockedByInactivity,
} from "$lib/stores/key-state.svelte";

let lockPromise: Promise<void> | null = null;
Expand Down Expand Up @@ -44,6 +46,14 @@ export function followKeyLocks() {
return () => window.removeEventListener("storage", handleStorage);
}

export function lockOnInactivity() {
if (!browser) return;
return whenIdle(() => {
noteLockedByInactivity();
lockEveryTab();
});
}

/** Locks every tab as soon as this one loses focus. */
export function lockOnFocusLoss() {
if (!browser) return;
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/lib/keys/ui/UnlockBrowserDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
deviceFlowRequester,
requestUnlockFromDevice,
} from "$lib/device-flow/device-flow-requester.svelte";
import { activity } from "$lib/keys/activity.svelte";
import { auth, logout } from "$lib/stores/auth.svelte";
import { hydrateMasterKey, keyAuth, unlock } from "$lib/stores/key.svelte";
import { wasLockedByInactivity } from "$lib/stores/key-state.svelte";

let passphrase = $state("");
let showPassphrase = $state(false);
Expand All @@ -43,6 +45,8 @@
),
);

const lockedByInactivity = $derived(open && wasLockedByInactivity());

$effect(() => {
if (open) return;
passphrase = "";
Expand All @@ -61,8 +65,9 @@
}
});

const wantsApprovers = $derived(open && !activity.idle);
$effect(() => {
if (!open) {
if (!wantsApprovers) {
detachDeviceFlowRequester();
return;
}
Expand Down Expand Up @@ -118,6 +123,9 @@
Unlock this browser
</h2>
<p id="unlock-description" class="text-sm text-muted-foreground">
{#if lockedByInactivity}
Cryptly locked after 1 hour of inactivity.
{/if}
Enter your account passphrase.
</p>
<button
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/lib/projects/ui/ProjectPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1586,10 +1586,11 @@
value: LockBrowserOn;
label: string;
}[] = [
{ value: "inactivity", label: "After 1 hour of inactivity" },
{ value: "tab-close", label: "After closing the tab" },
{ value: "focus-loss", label: "Whenever the tab loses focus" },
];
const lockBrowserOn = $derived(auth.userData?.lockBrowserOn ?? "tab-close");
const lockBrowserOn = $derived(auth.userData?.lockBrowserOn ?? "inactivity");

function closeUserMenu() {
showUserMenu = false;
Expand Down Expand Up @@ -2726,7 +2727,7 @@
</div>
{/if}
<div
class={`${isSearching ? "hidden" : "flex"} shrink-0 flex-col border-b border-border/50 bg-card/20 backdrop-blur-sm md:hidden`}
class={`${isSearching ? "hidden" : "flex"} relative z-20 shrink-0 flex-col border-b border-border/50 bg-card/20 backdrop-blur-sm md:hidden`}
>
<div class="flex items-center gap-2 px-3 py-2">
<a
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/lib/stores/auth.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
} from "$lib/auth/kea-storage-keys";
import { keystore } from "$lib/auth/keystore";
import { UserApi, type User } from "$lib/auth/user.api";
import { broadcastKeyLock, markKeyLocked } from "$lib/stores/key-state.svelte";
import {
broadcastKeyLock,
forgetLockReason,
markKeyLocked,
} from "$lib/stores/key-state.svelte";

function readKey(storageKey: string): string | null {
if (!browser) return null;
Expand Down Expand Up @@ -155,6 +159,7 @@ export async function logout() {
try {
await keystore.wipeAll();
} finally {
forgetLockReason();
markKeyLocked();
broadcastKeyLock();
}
Expand Down
Loading
Loading