From 3f264367afd1805755253be34bb6f8b3bb884505 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:08:02 +0100 Subject: [PATCH] fix: harden the app lock: forget in an order that can't leave a sign-in without a PIN, hear wipes and clear subjects while locked, and refuse obvious PINs --- app/(main)/[locale]/layout.tsx | 3 +- app/(main)/[locale]/login/page.tsx | 4 + app/api/auth/sso/start/route.ts | 5 +- .../app-lock/__tests__/lock-screen.test.tsx | 55 ++- components/app-lock/lock-screen.tsx | 22 +- components/app-lock/pin-fields.tsx | 8 +- components/settings/app-lock-settings.tsx | 2 + hooks/__tests__/use-sidebar-apps.test.tsx | 15 + hooks/use-calendar-alerts.ts | 5 +- hooks/use-sidebar-apps.ts | 9 + integration/tests/24-do-app-lock.spec.ts | 200 +++++++++-- .../app-lock-full-page-loads.test.ts | 31 ++ .../app-lock-wipe-host-mount.test.ts | 18 + lib/__tests__/oauth-remember-me.test.ts | 10 + lib/__tests__/sw-harness.ts | 4 +- lib/__tests__/sw-offline-shell.test.ts | 46 +++ lib/app-lock/__tests__/controller.test.ts | 283 ++++++++++++++- lib/app-lock/__tests__/forget.test.ts | 121 +++++++ lib/app-lock/__tests__/vault.test.ts | 78 ++++- lib/app-lock/controller.ts | 325 +++++++++++++++--- lib/app-lock/crypto.ts | 37 ++ lib/app-lock/scrub.ts | 35 +- lib/app-lock/sign-out.ts | 90 ++++- lib/app-lock/state.ts | 8 +- lib/app-lock/vault.ts | 140 +++++++- lib/desktop/app-lock-state.ts | 8 +- lib/desktop/policy-schema.ts | 4 +- lib/desktop/types.ts | 22 +- lib/offline/__tests__/device-wipe.test.ts | 83 +++++ lib/offline/device-wipe.ts | 38 ++ .../__tests__/go-in-app.test.ts | 26 ++ lib/plugin-sandbox/host-api-compose.ts | 3 +- lib/plugin-sandbox/open-email.ts | 3 +- lib/plugin-sandbox/page-navigation.ts | 10 + lib/sign-out-cleanup.ts | 4 +- lib/store/client/keys.ts | 22 +- lib/store/client/purge.ts | 5 +- locales/ar/common.json | 3 + locales/ca/common.json | 3 + locales/cs/common.json | 3 + locales/da/common.json | 3 + locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/et/common.json | 3 + locales/fa/common.json | 3 + locales/fr/common.json | 3 + locales/he/common.json | 3 + locales/hu/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/ko/common.json | 3 + locales/lv/common.json | 3 + locales/mn/common.json | 3 + locales/nb/common.json | 3 + locales/nl/common.json | 3 + locales/pl/common.json | 3 + locales/pt/common.json | 3 + locales/ro/common.json | 3 + locales/ru/common.json | 3 + locales/sk/common.json | 3 + locales/th/common.json | 3 + locales/tr/common.json | 3 + locales/uk/common.json | 3 + locales/vi/common.json | 3 + locales/zh-TW/common.json | 3 + locales/zh/common.json | 3 + public/sw.js | 2 + .../auth-store-cookies-to-clear.test.ts | 77 +++++ stores/auth-store.ts | 15 +- stores/notification-center-store.ts | 2 +- 71 files changed, 1826 insertions(+), 142 deletions(-) create mode 100644 lib/__tests__/app-lock-full-page-loads.test.ts create mode 100644 lib/__tests__/app-lock-wipe-host-mount.test.ts create mode 100644 lib/app-lock/__tests__/forget.test.ts create mode 100644 lib/plugin-sandbox/__tests__/go-in-app.test.ts create mode 100644 stores/__tests__/auth-store-cookies-to-clear.test.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index c3d3f0c46..7819c93d2 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -120,7 +120,6 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } - {!IS_LITE && } {!IS_LITE && } @@ -135,6 +134,8 @@ export default async function LocaleLayout({ + {/* Beside the gate, not under it: a stolen device at the lock screen still has to hear a wipe. */} + {!IS_LITE && } ); } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index fa1d82e00..df771c490 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { freshLoginRequired } from "@/lib/app-lock/sign-out"; import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react"; import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge"; @@ -678,6 +679,7 @@ function LoginPageContent() { locale: params.locale, server_id: selectedServer?.id, remember: persistProviderSession, + ...(freshLoginRequired() ? { fresh_login: true } : {}), ...(isMobileHandoff ? { mobile_redirect_uri: mobileRedirectUri, @@ -931,6 +933,7 @@ function LoginPageContent() { if (isAddAccountMode) { authUrl.searchParams.set("prompt", "select_account"); } + if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login"); window.location.href = authUrl.toString(); }; @@ -1029,6 +1032,7 @@ function LoginPageContent() { if (isAddAccountMode) { authUrl.searchParams.set("prompt", "select_account"); } + if (freshLoginRequired()) authUrl.searchParams.set("prompt", "login"); window.location.href = authUrl.toString(); }; diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts index be4666a94..6a8963af8 100644 --- a/app/api/auth/sso/start/route.ts +++ b/app/api/auth/sso/start/route.ts @@ -55,6 +55,7 @@ export async function POST(request: NextRequest) { purpose: rawPurpose, slot: rawSlot, remember, + fresh_login: freshLogin, } = await request.json(); // `reauth` drives the step-up flow for device pairing: it forces a fresh @@ -178,7 +179,9 @@ export async function POST(request: NextRequest) { // depends on the IdP supporting this OIDC param. Deliberately no max_age=0: // some IdPs (Authelia) re-evaluate max_age at the consent step and loop // back to login forever, and nothing on our side reads auth_time. - if (isReauth) { + // The same after a forgotten app lock PIN: the provider's session outlived it, and + // someone at the device shouldn't walk back in through it (lib/app-lock/sign-out.ts). + if (isReauth || freshLogin === true) { authUrl.searchParams.set('prompt', 'login'); } diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx index b2be7f72e..6316958fa 100644 --- a/components/app-lock/__tests__/lock-screen.test.tsx +++ b/components/app-lock/__tests__/lock-screen.test.tsx @@ -108,6 +108,43 @@ describe('the lock screen', () => { expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument(); }); + describe('in a pop-out', () => { + class Channel { + static open = new Set(); + onmessage: ((event: MessageEvent) => void) | null = null; + constructor() { Channel.open.add(this); } + postMessage(data: unknown): void { + for (const channel of Channel.open) if (channel !== this) channel.onmessage?.({ data } as MessageEvent); + } + close(): void { Channel.open.delete(this); } + } + + beforeEach(() => { + Channel.open.clear(); + vi.stubGlobal('BroadcastChannel', Channel); + window.history.replaceState(null, '', '/popout/compose'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + window.history.replaceState(null, '', '/'); + }); + + it('sends people to the main window, and asks for the PIN itself once that has closed', async () => { + (await createVault('correct-pin')).fill(0); + const main = new Channel(); + let mainOpen = true; + main.onmessage = (event) => { + if (mainOpen && (event.data as { type: string }).type === 'main-ping') main.postMessage({ type: 'main-pong' }); + }; + render(); + expect(await screen.findByRole('button', { name: 'open_main' })).toBeInTheDocument(); + mainOpen = false; + main.close(); + expect(await screen.findByTestId('app-lock-pin', {}, { timeout: 6000 })).toBeInTheDocument(); + }, 15_000); + }); + it('says it\'s saving rather than ask for anything while it locks', () => { render(); expect(screen.queryByTestId('app-lock-pin')).toBeNull(); @@ -122,14 +159,28 @@ describe('the lock screen', () => { fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '12345' } }); fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '12345' } }); expect(save).toBeDisabled(); - fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '123456' } }); + fireEvent.change(screen.getByTestId('app-lock-new-pin'), { target: { value: '482913' } }); expect(save).toBeDisabled(); - fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } }); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '482913' } }); expect(save).toBeEnabled(); }); }); describe('the settings', () => { + it('say what a PIN protects against here', () => { + render(); + expect(screen.getByText('protects_desc')).toBeInTheDocument(); + }); + + it('won\'t save a PIN anyone would guess', async () => { + render(); + fireEvent.click(screen.getByTestId('app-lock-switch')); + fireEvent.change(await screen.findByTestId('app-lock-new-pin'), { target: { value: '123456' } }); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } }); + expect(screen.getByText('pin_obvious')).toBeInTheDocument(); + expect(screen.getByTestId('app-lock-dialog-save')).toBeDisabled(); + }); + it('are off by default, with nothing else shown', () => { render(); expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false'); diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx index 9d69b3529..b18523ef1 100644 --- a/components/app-lock/lock-screen.tsx +++ b/components/app-lock/lock-screen.tsx @@ -16,8 +16,9 @@ import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; import { isPopoutWindow } from "@/lib/popout/window-role"; import { productName } from "@/lib/product-name"; -/** How long a pop-out waits to hear from a main window before it asks for the PIN itself. */ -const MAIN_ANSWER_MS = 500; +/** How often a pop-out asks whether a main window is there, and how long a silence means it's gone. */ +const MAIN_PING_MS = 1_000; +const MAIN_GONE_MS = 2_500; function secondsLeft(until: number): number { return Math.max(0, Math.ceil((until - Date.now()) / 1000)); @@ -39,20 +40,28 @@ function useBlockedUntil(): [number, (until: number) => void] { /** * Whether a main window is there. The tab that unlocks keeps the key for as long as it lives, - * and a pop-out is about to close: so a pop-out sends people to the main window. + * and a pop-out is about to close: so a pop-out sends people to the main window. It keeps + * asking, because a main window that closes or crashes says nothing, and with none left the + * pop-out has to take the PIN itself or nobody can get in. */ function useMainWindowThere(): boolean { const [there, setThere] = useState(false); useEffect(() => { if (!isPopoutWindow() || typeof BroadcastChannel === "undefined") return; const channel = new BroadcastChannel(APP_LOCK_CHANNEL); + let heardAt = 0; channel.onmessage = (event: MessageEvent) => { - if (event.data.type === "main-pong") setThere(true); + if (event.data.type !== "main-pong") return; + heardAt = Date.now(); + setThere(true); }; postAppLockMessage({ type: "main-ping" }); - const timer = setTimeout(() => postAppLockMessage({ type: "main-ping" }), MAIN_ANSWER_MS); + const timer = setInterval(() => { + if (Date.now() - heardAt > MAIN_GONE_MS) setThere(false); + postAppLockMessage({ type: "main-ping" }); + }, MAIN_PING_MS); return () => { - clearTimeout(timer); + clearInterval(timer); channel.close(); }; }, []); @@ -182,6 +191,7 @@ function UnlockForm() { if (forgetting) { return ( +

{t("forgot_provider")}