From fb3c82eed52b1a691d20b238096665ad6547bd6a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:08:37 +0100 Subject: [PATCH 1/8] feat: lock the app with a PIN, a passkey or Windows Hello, with mail out of memory and the local store closed The lock is off by default, and an OS policy can require it and set the timeout. It locks after a time without use counted across all windows and tabs, on OS screen lock in the desktop app, and from Lock now. Locking saves open composers, stops the outbox at its next message, closes the store and stops its worker with every key, then reloads the page, so nothing of the old page stays in reach. The new page holds the app back at a Suspense boundary until someone unlocks. Pop-outs get no session while locked, push notifications name nobody, and subjects leave plain storage. One random lock key wraps each account's key as a layer. The PIN wraps the lock key with PBKDF2 at 600,000 iterations, with a wait after wrong tries that a reload doesn't reset. A passkey with PRF and the desktop's OS storage wrap it too. Details and limits are in the decisions file. --- app/(main)/[locale]/layout.tsx | 3 + app/(main)/layout.tsx | 3 + app/app-lock.css | 9 + .../app-lock/__tests__/lock-screen.test.tsx | 141 ++++ components/app-lock/app-lock-gate.tsx | 61 ++ components/app-lock/lock-screen.tsx | 265 +++++++ components/app-lock/pin-fields.tsx | 77 ++ components/contacts/contacts-app.tsx | 6 +- components/email/email-composer.tsx | 14 +- .../popout/__tests__/popout-host.test.tsx | 25 + components/popout/popout-host.tsx | 5 +- components/settings/app-lock-settings.tsx | 204 +++++ components/settings/general-settings.tsx | 3 + integration/tests/24-do-app-lock.spec.ts | 411 ++++++++++ lib/__tests__/sw-push-app-lock.test.ts | 66 ++ lib/app-lock/__tests__/controller.test.ts | 471 +++++++++++ lib/app-lock/__tests__/idle.test.ts | 89 +++ lib/app-lock/__tests__/vault.test.ts | 158 ++++ lib/app-lock/controller.ts | 730 ++++++++++++++++++ lib/app-lock/crypto.ts | 115 +++ lib/app-lock/idle.ts | 85 ++ lib/app-lock/passkey.ts | 81 ++ lib/app-lock/policy.ts | 64 ++ lib/app-lock/scrub.ts | 44 ++ lib/app-lock/sign-out.ts | 15 + lib/app-lock/state.ts | 103 +++ lib/app-lock/vault.ts | 262 +++++++ lib/compose-flush-registry.ts | 27 + lib/desktop/app-lock-state.ts | 19 + lib/desktop/types.ts | 30 + lib/offline/__tests__/outbox.test.ts | 40 + lib/offline/flush.ts | 3 + lib/offline/offline-mode.ts | 3 + lib/popout/channel.ts | 8 +- lib/popout/sealed.ts | 13 +- lib/settings-search.ts | 4 +- lib/sign-out-cleanup.ts | 3 + lib/store/client/api.ts | 76 +- locales/ar/common.json | 65 ++ locales/ca/common.json | 65 ++ locales/cs/common.json | 65 ++ locales/da/common.json | 65 ++ locales/de/common.json | 65 ++ locales/en/common.json | 65 ++ locales/es/common.json | 65 ++ locales/et/common.json | 65 ++ locales/fa/common.json | 65 ++ locales/fr/common.json | 65 ++ locales/he/common.json | 65 ++ locales/hu/common.json | 65 ++ locales/it/common.json | 65 ++ locales/ja/common.json | 65 ++ locales/ko/common.json | 65 ++ locales/lv/common.json | 65 ++ locales/mn/common.json | 65 ++ locales/nb/common.json | 65 ++ locales/nl/common.json | 65 ++ locales/pl/common.json | 65 ++ locales/pt/common.json | 65 ++ locales/ro/common.json | 65 ++ locales/ru/common.json | 65 ++ locales/sk/common.json | 65 ++ locales/th/common.json | 65 ++ locales/tr/common.json | 65 ++ locales/uk/common.json | 65 ++ locales/vi/common.json | 65 ++ locales/zh-TW/common.json | 65 ++ locales/zh/common.json | 65 ++ public/sw.js | 53 +- .../__tests__/auth-store-keep-local.test.ts | 13 + stores/auth-store.ts | 7 +- stores/email-store.ts | 3 + 72 files changed, 5748 insertions(+), 14 deletions(-) create mode 100644 app/app-lock.css create mode 100644 components/app-lock/__tests__/lock-screen.test.tsx create mode 100644 components/app-lock/app-lock-gate.tsx create mode 100644 components/app-lock/lock-screen.tsx create mode 100644 components/app-lock/pin-fields.tsx create mode 100644 components/settings/app-lock-settings.tsx create mode 100644 integration/tests/24-do-app-lock.spec.ts create mode 100644 lib/__tests__/sw-push-app-lock.test.ts create mode 100644 lib/app-lock/__tests__/controller.test.ts create mode 100644 lib/app-lock/__tests__/idle.test.ts create mode 100644 lib/app-lock/__tests__/vault.test.ts create mode 100644 lib/app-lock/controller.ts create mode 100644 lib/app-lock/crypto.ts create mode 100644 lib/app-lock/idle.ts create mode 100644 lib/app-lock/passkey.ts create mode 100644 lib/app-lock/policy.ts create mode 100644 lib/app-lock/scrub.ts create mode 100644 lib/app-lock/sign-out.ts create mode 100644 lib/app-lock/state.ts create mode 100644 lib/app-lock/vault.ts create mode 100644 lib/desktop/app-lock-state.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 762f30ab9..68886353b 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -1,4 +1,5 @@ import { notFound } from "next/navigation"; +import { AppLockGate } from "@/components/app-lock/app-lock-gate"; import { IntlProvider } from "@/components/providers/intl-provider"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; @@ -76,6 +77,7 @@ export default async function LocaleLayout({ return ( + @@ -130,6 +132,7 @@ export default async function LocaleLayout({ + ); } diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index e2704bcc8..429353ae5 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -25,6 +25,8 @@ import "../globals.css"; import "../print.css"; import "../desktop.css"; import { desktopBootScript } from "@/lib/desktop/boot"; +import { appLockBootScript } from "@/lib/app-lock/state"; +import "../app-lock.css"; // This layout renders and sits ABOVE the [locale] segment, so // next-intl's getLocale() returns the default locale here - emitting @@ -312,6 +314,7 @@ export default async function RootLayout({ __html: ` (function() {${IS_LITE ? liteLocaleBootstrap() : ""} ${desktopBootScript()} + ${IS_LITE ? "" : appLockBootScript()} try { const stored = localStorage.getItem('theme-storage'); const theme = stored ? JSON.parse(stored).state.theme : 'system'; diff --git a/app/app-lock.css b/app/app-lock.css new file mode 100644 index 000000000..b62a0201c --- /dev/null +++ b/app/app-lock.css @@ -0,0 +1,9 @@ +/* + * While the app lock is on and it isn't known yet to be unlocked, nothing under the + * lock screen shows, from the first frame. The inline script in app/(main)/layout.tsx + * sets the attribute before the first paint, and lib/app-lock/controller.ts takes it away. + * The window's drag strip stays: it shows the app's name and nothing else. + */ +html[data-app-locked] body > *:not([data-app-lock-overlay]):not(.window-title-bar) { + visibility: hidden !important; +} diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx new file mode 100644 index 000000000..175e0994b --- /dev/null +++ b/components/app-lock/__tests__/lock-screen.test.tsx @@ -0,0 +1,141 @@ +import 'fake-indexeddb/auto'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AppLockSettings } from '@/components/settings/app-lock-settings'; +import { LockScreen } from '../lock-screen'; +import { createVault, deleteVault, FREE_FAILURES } from '@/lib/app-lock/vault'; +import { writeAppLockFile } from '@/lib/app-lock/state'; + +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); + +const unlockWithPin = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/app-lock/controller', async (original) => ({ + ...(await original()), + unlockWithPin, +})); + +function setPolicy(required: boolean, minutes: number | null, locked: boolean): void { + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + policy: { + get: () => ({ + AppLockRequired: { value: required, locked: required, source: required ? 'forced-machine' : 'default' }, + AppLockTimeoutMinutes: { value: minutes, locked, source: locked ? 'forced-machine' : 'default' }, + }), + onChange: () => () => {}, + }, + }; +} + +beforeEach(async () => { + await deleteVault(); + localStorage.clear(); + unlockWithPin.mockReset(); +}); + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +function typeAndSubmit(pin: string): void { + fireEvent.change(screen.getByTestId('app-lock-pin'), { target: { value: pin } }); + fireEvent.submit(screen.getByTestId('app-lock-form')); +} + +describe('the lock screen', () => { + it('passes the PIN on and shows nothing but the lock', async () => { + (await createVault('correct-pin')).fill(0); + unlockWithPin.mockResolvedValue(undefined); + render(); + typeAndSubmit('correct-pin'); + await waitFor(() => expect(unlockWithPin).toHaveBeenCalledWith('correct-pin')); + expect(screen.getByRole('dialog')).toHaveAttribute('data-app-lock-overlay'); + }); + + it('says a wrong PIN was wrong, and empties the field', async () => { + (await createVault('correct-pin')).fill(0); + unlockWithPin.mockRejectedValue(Object.assign(new Error('x'), { name: 'WrongPinError' })); + render(); + typeAndSubmit('nope-nope'); + await waitFor(() => expect(screen.getByTestId('app-lock-message')).toHaveTextContent('wrong_pin')); + expect(screen.getByTestId('app-lock-pin')).toHaveValue(''); + }); + + it('comes up blocked after a reload, from what is on disk', async () => { + (await createVault('correct-pin')).fill(0); + const { openVaultWithPin } = await import('@/lib/app-lock/vault'); + for (let i = 0; i <= FREE_FAILURES; i += 1) await openVaultWithPin('wrong-pin').catch(() => {}); + await act(async () => { + render(); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + await waitFor(() => expect(screen.getByTestId('app-lock-pin')).toBeDisabled()); + expect(screen.getByTestId('app-lock-message').textContent).toMatch(/blocked_/); + expect(screen.getByTestId('app-lock-unlock')).toBeDisabled(); + }); + + it('offers no passkey or OS button when none is set up, and always the way out', async () => { + (await createVault('correct-pin')).fill(0); + render(); + await waitFor(() => expect(screen.getByTestId('app-lock-forgot')).toBeInTheDocument()); + expect(screen.queryByTestId('app-lock-passkey')).toBeNull(); + expect(screen.queryByTestId('app-lock-os')).toBeNull(); + }); + + it('says it\'s saving rather than ask for anything while it locks', () => { + render(); + expect(screen.queryByTestId('app-lock-pin')).toBeNull(); + expect(screen.getByRole('dialog')).toHaveTextContent('locking'); + }); + + it('asks for a PIN when an organisation requires it, and only a good one', () => { + render(); + expect(screen.getByRole('dialog')).toHaveTextContent('setup_required_title'); + const save = screen.getByRole('button', { name: 'save' }); + expect(save).toBeDisabled(); + 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' } }); + expect(save).toBeDisabled(); + fireEvent.change(screen.getByTestId('app-lock-repeat-pin'), { target: { value: '123456' } }); + expect(save).toBeEnabled(); + }); +}); + +describe('the settings', () => { + it('are off by default, with nothing else shown', () => { + render(); + expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByTestId('app-lock-now')).toBeNull(); + expect(screen.queryByTestId('app-lock-timeout')).toBeNull(); + }); + + it('show the switch as set by the organisation when a policy requires it', () => { + setPolicy(true, null, false); + writeAppLockFile({ on: true, locked: false }); + const { container } = render(); + const toggle = screen.getByTestId('app-lock-switch'); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(toggle.closest('[inert]')).not.toBeNull(); + expect(container.textContent).toContain('org_locked'); + }); + + it('show a forced timeout as set by the organisation, with its value', () => { + setPolicy(false, 3, true); + writeAppLockFile({ on: true, locked: false, timeoutMinutes: 60 }); + render(); + const select = screen.getByTestId('app-lock-timeout'); + expect(select).toHaveValue('3'); + expect(select.closest('[inert]')).not.toBeNull(); + }); + + it('leave the timeout to the user otherwise', () => { + writeAppLockFile({ on: true, locked: false, timeoutMinutes: 30 }); + render(); + const select = screen.getByTestId('app-lock-timeout'); + expect(select).toHaveValue('30'); + expect(select.closest('[inert]')).toBeNull(); + }); +}); diff --git a/components/app-lock/app-lock-gate.tsx b/components/app-lock/app-lock-gate.tsx new file mode 100644 index 000000000..13d2c9559 --- /dev/null +++ b/components/app-lock/app-lock-gate.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Suspense, use, useEffect, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { LockScreen } from "./lock-screen"; +import { appLockHold, bootAppLock, getAppLockPhase, recheckAppLockSetup, startAppLock, subscribeAppLockPhase, takeDraftsSavedNotice, type AppLockPhase } from "@/lib/app-lock/controller"; +import { IS_LITE } from "@/lib/lite"; +import { useAccountStore } from "@/stores/account-store"; +import { toast } from "@/stores/toast-store"; +import { useTranslations } from "next-intl"; + +const serverPhase = (): AppLockPhase => "open"; + +/** + * Suspends the app below while the lock holds. In the first render of a page that is + * locked, that keeps the server's markup inert: nothing below hydrates, so no effect runs, + * no store fills and no plugin frame starts, until someone unlocks in this same document. + * The server's markup is the empty skeleton, never mail. + */ +function Hold() { + if (typeof window === "undefined") return null; + bootAppLock(); + const hold = appLockHold(); + if (hold) use(hold); + return null; +} + +/** Above everything that touches mail, and in every window: main, pop-outs, composers. */ +export function AppLockGate({ children }: { children: React.ReactNode }) { + const phase = useSyncExternalStore(subscribeAppLockPhase, getAppLockPhase, serverPhase); + const t = useTranslations("appLock"); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + return startAppLock(); + }, []); + + // Signing in while an organisation requires the lock: it's due now. + useEffect(() => { + if (IS_LITE) return; + recheckAppLockSetup(); + return useAccountStore.subscribe(recheckAppLockSetup); + }, []); + + useEffect(() => { + if (phase === "open" && takeDraftsSavedNotice()) toast.info(t("draft_saved_notice")); + }, [phase, t]); + + if (IS_LITE) return <>{children}; + + return ( + <> + + + {phase === "locked-stay" ? null : children} + + {mounted && phase !== "open" && phase !== "checking" && createPortal(, document.body)} + + ); +} diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx new file mode 100644 index 000000000..2dc827000 --- /dev/null +++ b/components/app-lock/lock-screen.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { Lock } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { Input } from "@/components/ui/input"; +import { useConfig } from "@/hooks/use-config"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { NewPinFields, newPinReady } from "./pin-fields"; +import { enableAppLock, forgetEverything, unlockWithOs, unlockWithPasskey, unlockWithPin, type AppLockPhase } from "@/lib/app-lock/controller"; +import { passkeysPossible } from "@/lib/app-lock/passkey"; +import { APP_LOCK_CHANNEL, postAppLockMessage, type AppLockMessage } from "@/lib/app-lock/state"; +import { pinBlockedUntil, readVault } from "@/lib/app-lock/vault"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; +import { isPopoutWindow } from "@/lib/popout/window-role"; + +/** How long a pop-out waits to hear from a main window before it asks for the PIN itself. */ +const MAIN_ANSWER_MS = 500; + +function secondsLeft(until: number): number { + return Math.max(0, Math.ceil((until - Date.now()) / 1000)); +} + +function useBlockedUntil(): [number, (until: number) => void] { + const [until, setUntil] = useState(0); + const [, tick] = useState(0); + useEffect(() => { + void pinBlockedUntil().then(setUntil).catch(() => {}); + }, []); + useEffect(() => { + if (until <= Date.now()) return; + const timer = setInterval(() => tick((n) => n + 1), 1000); + return () => clearInterval(timer); + }, [until]); + return [until > Date.now() ? until : 0, setUntil]; +} + +/** + * 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. + */ +function useMainWindowThere(): boolean { + const [there, setThere] = useState(false); + useEffect(() => { + if (!isPopoutWindow() || typeof BroadcastChannel === "undefined") return; + const channel = new BroadcastChannel(APP_LOCK_CHANNEL); + channel.onmessage = (event: MessageEvent) => { + if (event.data.type === "main-pong") setThere(true); + }; + postAppLockMessage({ type: "main-ping" }); + const timer = setTimeout(() => postAppLockMessage({ type: "main-ping" }), MAIN_ANSWER_MS); + return () => { + clearTimeout(timer); + channel.close(); + }; + }, []); + return there; +} + +function Frame({ title, body, children }: { title: string; body?: string; children: React.ReactNode }) { + const titleId = useId(); + return ( +
+
+
+ + +

{title}

+ {body &&

{body}

} +
+ {children} +
+
+ ); +} + +function SetupForm() { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + const [pin, setPin] = useState(""); + const [repeat, setRepeat] = useState(""); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!newPinReady(pin, repeat) || busy) return; + setBusy(true); + setFailed(false); + try { + await enableAppLock(pin); + } catch { + setFailed(true); + setBusy(false); + } + }; + return ( + +
+ + {failed &&

{t("save_failed")}

} + + + + ); +} + +function UnlockForm() { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + const { dialogProps, confirm } = useConfirmDialog(); + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + const [blockedUntil, setBlockedUntil] = useBlockedUntil(); + const [hasPasskey, setHasPasskey] = useState(false); + const [osKind, setOsKind] = useState(null); + const mainThere = useMainWindowThere(); + const inputRef = useRef(null); + const pinId = useId(); + + useEffect(() => { + void readVault().then((vault) => setHasPasskey(Boolean(vault?.prf) && passkeysPossible())).catch(() => {}); + void getDesktopBridge()?.appLock?.status().then((status) => setOsKind(status.osEnrolled ? status.os : null)).catch(() => {}); + }, []); + + const blocked = blockedUntil > 0; + const left = secondsLeft(blockedUntil); + + const submit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + if (!pin || busy || blocked) return; + setBusy(true); + setMessage(null); + try { + await unlockWithPin(pin); + } catch (error) { + const name = (error as Error)?.name; + setBusy(false); + setPin(""); + inputRef.current?.focus(); + if (name === "PinBlockedError") setBlockedUntil((error as Error & { until: number }).until); + else if (name === "WrongPinError") { + setMessage(t("wrong_pin")); + void pinBlockedUntil().then(setBlockedUntil).catch(() => {}); + } else setMessage(t("unlock_failed")); + } + }, [blocked, busy, pin, setBlockedUntil, t]); + + const withOs = async () => { + setBusy(true); + setMessage(null); + try { + if (await unlockWithOs()) return; + } catch { + // The PIN below still works. + } + setBusy(false); + setMessage(t("os_failed")); + }; + + const withPasskey = async () => { + setBusy(true); + setMessage(null); + try { + await unlockWithPasskey(); + } catch { + setBusy(false); + setMessage(t("passkey_failed")); + } + }; + + const forgot = async () => { + const ok = await confirm({ + title: t("forgot_title"), + message: t("forgot_body"), + confirmText: t("forgot_confirm"), + variant: "destructive", + }); + if (ok) await forgetEverything(); + }; + + if (mainThere) { + return ( + + + + ); + } + + return ( + +
+ + setPin(event.target.value)} + placeholder={t("pin_label")} + disabled={blocked} + /> +
+ {blocked + ? (left >= 60 ? t("blocked_minutes", { count: Math.ceil(left / 60) }) : t("blocked_seconds", { count: left })) + : message} +
+ + {osKind && ( + + )} + {hasPasskey && ( + + )} + +
+ + + ); +} + +/** What covers a window while the app is locked. Reads nothing but the lock's own records. */ +export function LockScreen({ phase }: { phase: AppLockPhase }) { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + if (phase === "setup") return ; + if (phase === "locking") { + return ( + + + + ); + } + return ; +} diff --git a/components/app-lock/pin-fields.tsx b/components/app-lock/pin-fields.tsx new file mode 100644 index 000000000..21470aeaf --- /dev/null +++ b/components/app-lock/pin-fields.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useId } from "react"; +import { useTranslations } from "next-intl"; +import { Input } from "@/components/ui/input"; +import { pinLongEnough } from "@/lib/app-lock/crypto"; + +/** The new PIN, twice. Says what's wrong only once there's something to say. */ +export function NewPinFields({ pin, repeat, onPin, onRepeat }: { + pin: string; + repeat: string; + onPin: (value: string) => void; + onRepeat: (value: string) => void; +}) { + const t = useTranslations("appLock"); + const pinId = useId(); + const repeatId = useId(); + const hintId = useId(); + const tooShort = pin.length > 0 && !pinLongEnough(pin); + const mismatch = repeat.length > 0 && repeat !== pin; + return ( +
+
+ + onPin(event.target.value)} + aria-describedby={hintId} + aria-invalid={tooShort || undefined} + /> +

{t("pin_hint")}

+
+
+ + onRepeat(event.target.value)} + aria-invalid={mismatch || undefined} + /> + {mismatch &&

{t("pin_mismatch")}

} +
+
+ ); +} + +export function newPinReady(pin: string, repeat: string): boolean { + return pinLongEnough(pin) && pin === repeat; +} + +/** A PIN already set, asked again before something that needs it. */ +export function CurrentPinField({ value, onChange, error }: { value: string; onChange: (value: string) => void; error?: string | null }) { + const t = useTranslations("appLock"); + const id = useId(); + return ( +
+ + onChange(event.target.value)} + aria-invalid={error ? true : undefined} + /> + {error &&

{error}

} +
+ ); +} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index fcf3b0afd..a28c84ea1 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -1,5 +1,6 @@ "use client"; +import { isAppLockedNow } from "@/lib/app-lock/state"; import { AppMain, SkipLink } from "@/components/layout/app-landmarks"; import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; @@ -231,7 +232,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // Leaving the page or going Back asks too. useEffect(() => { if (!editorDirty) return; - const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault(); + const onBeforeUnload = (e: BeforeUnloadEvent) => { + // The app lock asks nobody: the page is going to the lock screen. + if (!isAppLockedNow()) e.preventDefault(); + }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, [editorDirty]); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 67be255df..db59d734e 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -168,7 +168,8 @@ import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; -import { registerComposeFlush } from "@/lib/compose-flush-registry"; +import { isAppLockedNow } from "@/lib/app-lock/state"; +import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; @@ -1958,6 +1959,8 @@ export function EmailComposer({ ownWindowRef.current = ownWindow; useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { + // The lock saved what it could and is on its way to the lock screen: no prompt, and no copy of the text. + if (isAppLockedNow()) return; const own = ownWindowRef.current; if (!own) { if (isDirtyRef.current) { @@ -3142,6 +3145,15 @@ export function EmailComposer({ if (saveFailedRef.current) throw new Error('The draft was not saved'); }), []); + // Where this composer's text is, for the app lock. + useEffect(() => registerComposeSnapshot({ + ownWindow: !!ownWindowRef.current, + state: () => (sentRef.current || uneditedRef.current ? null : snapshotStateRef.current()), + draft: () => (draftIdRef.current && !sentRef.current + ? { draftId: draftIdRef.current, localAccountId: draftLocalAccountIdRef.current, ownerAccountId: draftAccountIdRef.current } + : null), + }), []); + // The draft as plugins see it: onDraftChange, the frames above and below // the body, and clicks on plugin entries in the Send menu. const draftSnapshot = () => ({ diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index 4defd25c8..239f3045c 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -1,6 +1,7 @@ import { act, cleanup, render } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-channel'; +import { setLockedHere } from '@/lib/app-lock/state'; import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; import { createSessionOpener } from '@/lib/popout/sealed'; @@ -121,6 +122,30 @@ describe('PopoutHost', () => { expect(JSON.stringify(heard)).not.toContain('Bearer main'); }); + it('gives no credentials while the app is locked, also to a pop-out that never heard of the lock', async () => { + render(); + await settle(); + setLockedHere(true); + const channel = openPopoutChannel(() => {}); + const opener = await createSessionOpener(); + channel?.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey }); + await settle(); + channel?.close(); + expect(answers()).toEqual([]); + await expect(requestWindowSession({}, 100)).resolves.toBeNull(); + setLockedHere(false); + await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ activeAccountId: 'alice' }); + }); + + it('a locked pop-out takes no session from the cookies either', async () => { + setLockedHere(true); + try { + await expect(useAuthStore.getState().startSharedSession({})).resolves.toBe('no-session'); + } finally { + setLockedHere(false); + } + }); + it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => { useAuthStore.setState({ isAuthenticated: false }); render(); diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx index 54bf13630..7cf2ab953 100644 --- a/components/popout/popout-host.tsx +++ b/components/popout/popout-host.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; +import { isAppLockedNow } from "@/lib/app-lock/state"; import { usePopoutsAvailable } from "@/hooks/use-popouts-available"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -76,6 +77,8 @@ export function PopoutHost() { const answerSessionRequest = async (message: Extract) => { const { accountId, rejectedFingerprint } = message; + // Locked: nobody gets a credential, whatever they ask. + if (isAppLockedNow()) return; const auth = useAuthStore.getState(); // Only a window holding the login answers: the first answer has to be usable. if (accountId && !shareableSessions().some((session) => session.accountId === accountId)) return; @@ -86,7 +89,7 @@ export function PopoutHost() { if (current && await credentialFingerprint(current) === rejectedFingerprint) await auth.refreshAccessToken({ accountId }); } const sessions = shareableSessions(); - if (sessions.length === 0) return; + if (sessions.length === 0 || isAppLockedNow()) return; const sealed = await sealFor(message.publicKey, { activeAccountId: useAuthStore.getState().activeAccountId, sessions }); channel?.post({ type: "session-answer", requestId: message.requestId, sealed }); }; diff --git a/components/settings/app-lock-settings.tsx b/components/settings/app-lock-settings.tsx new file mode 100644 index 000000000..44bb39588 --- /dev/null +++ b/components/settings/app-lock-settings.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useCallback, useEffect, useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { CurrentPinField, NewPinFields, newPinReady } from "@/components/app-lock/pin-fields"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { useConfig } from "@/hooks/use-config"; +import { + addOsUnlock, + addPasskey, + changeAppLockPin, + disableAppLock, + enableAppLock, + hasPasskey, + lockEverywhere, + removePasskey, + setAppLockTimeout, +} from "@/lib/app-lock/controller"; +import { passkeysPossible } from "@/lib/app-lock/passkey"; +import { appLockRequired, effectiveTimeout, onLockPolicyChange } from "@/lib/app-lock/policy"; +import { readAppLockFile, TIMEOUT_CHOICES } from "@/lib/app-lock/state"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsUnlockKind } from "@/lib/desktop/types"; +import { IS_LITE } from "@/lib/lite"; +import { toast } from "@/stores/toast-store"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null; + +function useLockFacts() { + const [, refresh] = useState(0); + const update = useCallback(() => refresh((n) => n + 1), []); + useEffect(() => onLockPolicyChange(update), [update]); + const file = readAppLockFile(); + return { on: file.on, required: appLockRequired(), timeout: effectiveTimeout(file.timeoutMinutes), update }; +} + +/** The app lock's section on the General page: it has to exist on any server. */ +export function AppLockSettings() { + const t = useTranslations("appLock"); + const { appName } = useConfig(); + const { on, required, timeout, update } = useLockFacts(); + const [pending, setPending] = useState(null); + const [pin, setPin] = useState(""); + const [repeat, setRepeat] = useState(""); + const [current, setCurrent] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [passkey, setPasskey] = useState(false); + const [os, setOs] = useState<{ kind: DesktopOsUnlockKind; enrolled: boolean } | null>(null); + + const refreshMethods = useCallback(() => { + void hasPasskey().then(setPasskey).catch(() => {}); + void getDesktopBridge()?.appLock?.status() + .then((status) => setOs(status.os ? { kind: status.os, enrolled: status.osEnrolled } : null)) + .catch(() => {}); + }, []); + useEffect(refreshMethods, [refreshMethods, on]); + + const close = () => { + setPending(null); + setPin(""); + setRepeat(""); + setCurrent(""); + setError(null); + setBusy(false); + }; + + const describeFailure = (failure: unknown): string => { + const name = (failure as Error)?.name; + if (name === "WrongPinError") return t("wrong_pin"); + if (name === "PinBlockedError") return t("blocked_later"); + if (name === "PasskeyNoPrfError") return t("passkey_no_prf"); + return t("save_failed"); + }; + + const run = async (work: () => Promise) => { + setBusy(true); + setError(null); + try { + await work(); + close(); + update(); + refreshMethods(); + } catch (failure) { + setBusy(false); + setError(describeFailure(failure)); + } + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (busy) return; + if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin)); + if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin)); + if (pending === "disable" && current) void run(() => disableAppLock(current)); + if (pending === "passkey" && current) { + void run(async () => { + await addPasskey(current, appName); + toast.success(t("passkey_added")); + }); + } + if (pending === "os" && current) { + void run(async () => { + if (!(await addOsUnlock(current))) throw new Error("Not set up"); + }); + } + }; + + if (IS_LITE) return null; + + const osName = os?.kind === "touchid" ? t("use_touchid_name") : t("use_hello_name"); + const asksNew = pending === "enable" || pending === "change"; + const asksCurrent = pending === "disable" || pending === "change" || pending === "passkey" || pending === "os"; + const ready = (!asksNew || newPinReady(pin, repeat)) && (!asksCurrent || current.length > 0); + const titles: Record, string> = { + enable: t("set_pin_title"), + change: t("change_pin_title"), + disable: t("disable_title"), + passkey: t("passkey_add_title"), + os: t("os_add_title", { method: osName }), + }; + + return ( + + + setPending(next ? "enable" : "disable")} testId="app-lock-switch" /> + + + {on && ( + <> + + + + + + + + + + + + + {passkeysPossible() && ( + + {passkey ? ( + + ) : ( + + )} + + )} + + {os && ( + + {os.enrolled ? ( + + ) : ( + + )} + + )} + + )} + + 0 || current.length > 0} + testId="app-lock-dialog" + footer={( + <> + + + + )} + > +
+ {asksCurrent && } + {asksNew && } + {!asksCurrent && error &&

{error}

} +
+
+
+ ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 370c76b2e..f0bf21780 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; +import { AppLockSettings } from './app-lock-settings'; import { DesktopKeystoreSettings } from './desktop-keystore-settings'; import { ProtocolHandlerSettings } from './protocol-handler-settings'; import { useTour } from '@/components/tour/tour-provider'; @@ -26,6 +27,8 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
+ + {!IS_LITE && } diff --git a/integration/tests/24-do-app-lock.spec.ts b/integration/tests/24-do-app-lock.spec.ts new file mode 100644 index 000000000..9b76d81ac --- /dev/null +++ b/integration/tests/24-do-app-lock.spec.ts @@ -0,0 +1,411 @@ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, ENT_PASSWORD, contextOptions, deliver, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent'; +import { accountSwitcher } from './helpers/app'; + +/** + * App lock (D8), through the real UI on a production build, on both test Stalwarts with + * the throwaway ent-* accounts: + * - off by default; on with a PIN in Settings > General; + * - "Lock now": a lock screen, no mail in the page, its memory gone, the store's worker + * and key gone, no plugin frames, no subjects in browser storage; + * - a wrong PIN, the wait after too many that a reload doesn't reset; + * - every tab locks and unlocks, one key: only the tab that unlocked holds it; + * - an open composer's text is on the server as a draft, and not readable meanwhile; + * - time without use across tabs; + * - a new tab, a pop-out address and a fresh start stay locked; + * - a passkey with PRF through a virtual authenticator, and one without, which keeps the PIN; + * - forgetting the PIN removes the data and signs out. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const RUN = Date.now().toString(36); +const PIN = 'it-pin-4827'; +const lockForm = (page: Page) => page.getByTestId('app-lock-form'); +const SECRET_SUBJECT = `applock-secret-${RUN}`; + +test.beforeAll(async () => { + requireEntEnv(); + await deliver({ from: ENT.bob, to: [ENT.alice], subject: SECRET_SUBJECT, text: `Body of ${SECRET_SUBJECT}` }); +}); + +async function enableOffline(page: Page): Promise { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); +} + +async function enableLock(page: Page, pin = PIN): Promise { + await page.goto('/settings/general'); + const toggle = page.getByTestId('app-lock-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await page.getByTestId('app-lock-new-pin').fill(pin); + await page.getByTestId('app-lock-repeat-pin').fill(pin); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true', { timeout: 30_000 }); +} + +async function lockNow(page: Page): Promise { + await page.goto('/settings/general'); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); +} + +async function unlock(page: Page, pin = PIN): Promise { + await page.getByTestId('app-lock-pin').fill(pin); + await page.getByTestId('app-lock-unlock').click(); + await expect(lockForm(page)).toBeHidden({ timeout: 60_000 }); +} + +async function toMail(page: Page): Promise { + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); +} + +async function heldLocks(page: Page): Promise { + return page.evaluate(async () => ((await navigator.locks.query()).held ?? []).map((lock) => lock.name ?? '')); +} + +/** Everything a person or a script could read in plain browser storage. */ +async function browserStorage(page: Page): Promise { + return page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); +} + +/** The account key records as they are on disk. */ +async function keyRecords(page: Page): Promise> { + return page.evaluate(() => new Promise((resolve, reject) => { + const open = indexedDB.open('bulwark-store'); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const all = open.result.transaction('accounts').objectStore('accounts').getAll(); + all.onsuccess = () => { + open.result.close(); + resolve((all.result as Array<{ account: string; layers: string[] }>).map(({ account, layers }) => ({ account, layers }))); + }; + all.onerror = () => reject(all.error); + }; + })); +} + +async function serverDraftBodies(subject: string): Promise { + const client = await jmapAs(ENT.alice); + const r = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { subject }, limit: 10 }, '0'], + ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject', 'keywords', 'bodyValues', 'textBody'], fetchTextBodyValues: true }, '1'], + ]); + const list = r.methodResponses[1][1].list as Array<{ keywords: Record; bodyValues: Record }>; + return list.filter((email) => email.keywords.$draft).map((email) => Object.values(email.bodyValues).map((part) => part.value).join('\n')); +} + +/** Signed in with "Remember me", so a reload finds the session again. */ +async function signIn(page: Page): Promise { + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.getByText('Remember me', { exact: true }).click(); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); +} + +async function fresh(browser: Browser): Promise<{ page: Page; context: BrowserContext }> { + const context = await browser.newContext(contextOptions()); + const page = await context.newPage(); + await page.goto('/'); + await signIn(page); + return { page, context }; +} + +test('off by default: nothing changes and no record is wrapped', async ({ browser }) => { + const { page, context } = await fresh(browser); + await page.goto('/settings/general'); + await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + expect(await page.evaluate(() => document.documentElement.hasAttribute('data-app-locked'))).toBe(false); + expect(await heldLocks(page)).not.toContain('bulwark-app-lock-holder'); + await context.close(); +}); + +test('locked: no mail in the page, its memory gone, the store closed with no key, and the PIN opens it again', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + + const records = await keyRecords(page); + expect(records.length).toBeGreaterThan(0); + for (const record of records) expect(record.layers).toEqual(['applock']); + + await toMail(page); + await expect(page.getByText(SECRET_SUBJECT).first()).toBeVisible({ timeout: 60_000 }); + await page.getByText(SECRET_SUBJECT).first().click(); + await expect(page.getByRole('heading', { name: SECRET_SUBJECT }).first()).toBeVisible({ timeout: 20_000 }); + expect(await heldLocks(page)).toContain('bulwark-store-owner'); + expect(await heldLocks(page)).toContain('bulwark-app-lock-holder'); + + await page.goto('/settings/general'); + await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; }); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + + expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before)).toBeUndefined(); + await expect(page.locator('body')).not.toContainText(SECRET_SUBJECT); + expect(await page.content()).not.toContain(SECRET_SUBJECT); + expect(await browserStorage(page)).not.toContain(SECRET_SUBJECT); + expect(await page.title()).not.toContain(SECRET_SUBJECT); + expect(await page.locator('iframe').count(), 'no frames, plugin frames included').toBe(0); + const held = await heldLocks(page); + expect(held, 'no worker holds the store').not.toContain('bulwark-store-owner'); + expect(held, 'nobody holds the key').not.toContain('bulwark-app-lock-holder'); + // What the app below the gate would have started: nothing asks for mail. + const requests: string[] = []; + page.on('request', (request) => { if (/\/jmap|\/api\/auth\/(session|token)/.test(request.url())) requests.push(request.url()); }); + await page.waitForTimeout(1500); + expect(requests).toEqual([]); + await shot(page, 'app-lock-screen'); + + await unlock(page); + await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 }); + expect(await heldLocks(page)).toContain('bulwark-app-lock-holder'); + await expect.poll(() => heldLocks(page), { timeout: 30_000 }).toContain('bulwark-store-owner'); + await context.close(); +}); + +test('a wrong PIN says so, and after too many the wait survives a reload', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableLock(page); + await lockNow(page); + const message = page.getByTestId('app-lock-message'); + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(page.getByTestId('app-lock-pin')).toBeEnabled({ timeout: 30_000 }); + await page.getByTestId('app-lock-pin').fill(`wrong-pin-${attempt}`); + await page.getByTestId('app-lock-unlock').click(); + await expect(page.getByTestId('app-lock-pin')).toHaveValue('', { timeout: 30_000 }); + } + await expect(message).toContainText('Too many tries', { timeout: 15_000 }); + await expect(page.getByTestId('app-lock-pin')).toBeDisabled(); + + await page.reload(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('app-lock-message')).toContainText('Too many tries', { timeout: 15_000 }); + await expect(page.getByTestId('app-lock-pin')).toBeDisabled(); + + await expect(page.getByTestId('app-lock-pin')).toBeEnabled({ timeout: 40_000 }); + await unlock(page); + await context.close(); +}); + +test('all tabs lock together, and a new tab or pop-out address stays locked; the one that unlocks holds the key', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + const second = await context.newPage(); + await second.goto('/mail'); + await expect(second.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + await page.goto('/settings/general'); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await expect(lockForm(second)).toBeVisible({ timeout: 30_000 }); + await expect(second.locator('body')).not.toContainText(SECRET_SUBJECT); + + const third = await context.newPage(); + await third.goto('/mail'); + await expect(lockForm(third)).toBeVisible({ timeout: 30_000 }); + + // A pop-out address: the lock screen sends people to the main window. + const popout = await context.newPage(); + await popout.goto('/popout/compose'); + await expect(popout.getByRole('dialog').first()).toBeVisible({ timeout: 30_000 }); + await expect(popout.locator('[data-testid="email-composer"]')).toHaveCount(0); + await expect(popout.getByRole('button', { name: 'Go to the main window' })).toBeVisible({ timeout: 10_000 }); + await popout.close(); + + await unlock(second); + await expect(lockForm(page)).toBeHidden({ timeout: 30_000 }); + await expect(lockForm(third)).toBeHidden({ timeout: 30_000 }); + await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 }); + await expect(third.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + // The key was never passed on: when the tab that has it goes, the others have to ask. + await second.close(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await expect(lockForm(third)).toBeVisible({ timeout: 30_000 }); + await unlock(page); + await context.close(); +}); + +test('an open composer\'s text is saved first, is unreadable while locked, and nothing prompts', async ({ browser }) => { + const { page, context } = await fresh(browser); + const subject = `applock-draft-${RUN}`; + const words = `words only the draft has ${RUN}`; + await enableLock(page); + await toMail(page); + const prompts: string[] = []; + page.on('dialog', (dialog) => { prompts.push(dialog.type()); void dialog.accept(); }); + await page.keyboard.press('c'); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.waitFor({ state: 'visible', timeout: 15_000 }); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.type(words); + + // As the timer does it, with the composer open. + await page.evaluate(() => { + const raw = JSON.parse(localStorage.getItem('bulwark-app-lock') ?? '{}'); + localStorage.setItem('bulwark-app-lock', JSON.stringify({ ...raw, locked: true })); + new BroadcastChannel('bulwark:app-lock').postMessage({ type: 'lock' }); + }); + await expect(lockForm(page)).toBeVisible({ timeout: 60_000 }); + expect(prompts).toEqual([]); + await expect(page.locator('body')).not.toContainText(words); + expect(await browserStorage(page)).not.toContain(words); + + await expect.poll(async () => (await serverDraftBodies(subject)).join('\n'), { timeout: 60_000 }).toContain(words); + + await unlock(page); + await expect(page.getByRole('status').filter({ hasText: 'saved to Drafts' }).or(page.getByText('saved to Drafts')).first()).toBeVisible({ timeout: 30_000 }); + await context.close(); +}); + +test('time without use locks every tab, and use in another tab counts', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableLock(page); + await page.goto('/settings/general'); + await page.getByTestId('app-lock-timeout').selectOption('1'); + const other = await context.newPage(); + await other.goto('/mail'); + await expect(other.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + // 40 s of use in the other tab, then nothing. + for (let i = 0; i < 4; i += 1) { + await other.mouse.move(100 + i * 5, 100); + await other.waitForTimeout(10_000); + await other.mouse.move(120 + i * 5, 120); + } + await page.waitForTimeout(25_000); + await expect(lockForm(page), 'used 25 s ago in the other tab').toBeHidden(); + + await expect(lockForm(page)).toBeVisible({ timeout: 90_000 }); + await expect(lockForm(other)).toBeVisible({ timeout: 30_000 }); + await context.close(); +}); + +test('a browser closed while unlocked starts locked', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableLock(page); + const state = await context.storageState(); + // Web Locks and memory don't survive this. IndexedDB doesn't come along either, so localStorage alone decides here. + await context.close(); + const reopened = await browser.newContext({ ...contextOptions(), storageState: state }); + const again = await reopened.newPage(); + await again.goto('/mail'); + await expect(lockForm(again)).toBeVisible({ timeout: 30_000 }); + await expect(again.locator('[data-testid="email-list-item"]')).toHaveCount(0); + await reopened.close(); +}); + +async function virtualAuthenticator(page: Page, hasPrf: boolean): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable', { enableUI: false }); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + ctap2Version: 'ctap2_1', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + hasPrf, + }, + }); +} + +test('a passkey with PRF unlocks without the PIN, and the PIN keeps working', async ({ browser }) => { + const { page, context } = await fresh(browser); + await virtualAuthenticator(page, true); + await enableOffline(page); + await enableLock(page); + await page.getByTestId('app-lock-add-passkey').click(); + await page.getByTestId('app-lock-current-pin').fill(PIN); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-dialog')).toBeHidden({ timeout: 60_000 }); + + await lockNow(page); + await page.getByTestId('app-lock-passkey').click(); + await expect(lockForm(page)).toBeHidden({ timeout: 60_000 }); + await expect(accountSwitcher(page)).toBeVisible({ timeout: 60_000 }); + await expect.poll(() => heldLocks(page), { timeout: 30_000 }).toContain('bulwark-store-owner'); + + await lockNow(page); + await unlock(page); + await context.close(); +}); + +test('a passkey with no PRF is refused when added, and the PIN stays', async ({ browser }) => { + const { page, context } = await fresh(browser); + await virtualAuthenticator(page, false); + await enableLock(page); + await page.getByTestId('app-lock-add-passkey').click(); + await page.getByTestId('app-lock-current-pin').fill(PIN); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-dialog')).toContainText("can't protect the key", { timeout: 60_000 }); + await page.getByRole('button', { name: 'Cancel' }).click(); + + await lockNow(page); + await expect(page.getByTestId('app-lock-passkey')).toHaveCount(0); + await unlock(page); + await context.close(); +}); + +test('a login without "Remember me" has to sign in again after a lock, and its local data waits', async ({ browser }) => { + const page = await userPage(browser, ENT.alice); + const context = page.context(); + await enableOffline(page); + await enableLock(page); + const before = await keyRecords(page); + expect(before.length).toBeGreaterThan(0); + await lockNow(page); + await page.getByTestId('app-lock-pin').fill(PIN); + await page.getByTestId('app-lock-unlock').click(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + // Not what a page reload does to such a login with the lock off: that one wipes. + expect((await keyRecords(page)).map((record) => record.account)).toEqual(before.map((record) => record.account)); + await signIn(page); + await expect.poll(() => heldLocks(page), { timeout: 60_000 }).toContain('bulwark-store-owner'); + await context.close(); +}); + +test('turning it off needs the PIN and takes the layer off', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + await page.getByTestId('app-lock-switch').click(); + await page.getByTestId('app-lock-current-pin').fill('wrong-pin-1'); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-dialog')).toContainText('not right', { timeout: 30_000 }); + await page.getByTestId('app-lock-current-pin').fill(PIN); + await page.getByTestId('app-lock-dialog-save').click(); + await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + for (const record of await keyRecords(page)) expect(record.layers).toEqual([]); + await page.reload(); + await expect(accountSwitcher(page).or(page.getByTestId('app-lock-switch')).first()).toBeVisible({ timeout: 60_000 }); + await context.close(); +}); + +test('a forgotten PIN: local data goes, everyone signs out, and the login page is next', async ({ browser }) => { + const { page, context } = await fresh(browser); + await enableOffline(page); + await enableLock(page); + await lockNow(page); + await page.getByTestId('app-lock-forgot').click(); + await page.getByRole('button', { name: 'Remove and sign out' }).click(); + await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + expect(await page.evaluate(() => localStorage.getItem('bulwark-app-lock'))).toBeNull(); + const databases = await page.evaluate(async () => (await indexedDB.databases()).map((db) => db.name)); + expect(databases).not.toContain('bulwark-app-lock'); + await context.close(); +}); diff --git a/lib/__tests__/sw-push-app-lock.test.ts b/lib/__tests__/sw-push-app-lock.test.ts new file mode 100644 index 000000000..8e795a1b7 --- /dev/null +++ b/lib/__tests__/sw-push-app-lock.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// App lock: while locked a notification names no sender and no subject, and the +// worker doesn't even ask the server for them. It can't hear a browser closing, so +// "unlocked" only counts with a window open. + +const PREVIEW = { unreadTotal: 1, slot: 0, email: { id: 'e1', threadId: 't1', subject: 'Secret subject', from: [{ name: 'Secret sender', email: 's@example.org' }] } }; +const delivery = { kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }; + +function setup(clients: Array<{ id: string }>, notifications: Array> = []) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(PREVIEW), { status: 200 })); + const worker = loadWorker({ buildId: 'b', caches: new FakeCaches(), clients, fetch: fetchMock, notifications }); + return { worker, fetchMock }; +} + +describe('a push while the app lock is on', () => { + it('names the sender with no lock at all', async () => { + const { worker, fetchMock } = setup([]); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(fetchMock).toHaveBeenCalled(); + expect(worker.shown[0].title).toContain('Secret sender'); + }); + + it('says nothing about the message while locked, and asks nothing', async () => { + const { worker, fetchMock } = setup([{ id: 'c' }]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.stringify(worker.shown)).not.toContain('Secret'); + expect(worker.shown[0].title).toBe('New mail'); + }); + + it('names it again after unlock, with a window open', async () => { + const { worker } = setup([{ id: 'c' }]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'unlocked' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(worker.shown[0].title).toContain('Secret sender'); + }); + + it('counts as locked with no window open: the key is in a window or nowhere', async () => { + const { worker, fetchMock } = setup([]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'unlocked' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(JSON.stringify(worker.shown)).not.toContain('Secret'); + }); + + it('names it again once the lock is off', async () => { + const { worker } = setup([]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'off' } }); + await worker.dispatch('push', { data: { json: () => delivery } }); + expect(worker.shown[0].title).toContain('Secret sender'); + }); + + it('closes what\'s already on screen when it locks', async () => { + const close = vi.fn(); + const { worker } = setup([{ id: 'c' }], [ + { tag: 'bulwark-mail:a', title: 'Secret sender', close }, + ]); + await worker.dispatch('message', { data: { type: 'app-lock', state: 'locked' } }); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts new file mode 100644 index 000000000..1c6a03da4 --- /dev/null +++ b/lib/app-lock/__tests__/controller.test.ts @@ -0,0 +1,471 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { composeSnapshots, registerComposeFlush, registerComposeSnapshot, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; +import { isAppLocked, setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import { configureStore, getStore, setStoreBackend, type StorePageHooks } from '@/lib/store/client/api'; +import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys'; +import { + addOsUnlock, + appLockHold, + appLockKeyLayers, + bootAppLock, + disableAppLock, + enableAppLock, + forgetEverything, + getAppLockPhase, + lockEverywhere, + resetAppLockForTests, + setReloadForTests, + startAppLock, + unlockWithOs, + unlockWithPin, +} from '../controller'; +import { appLockRequired, effectiveTimeout } from '../policy'; +import { APP_LOCK_KEY, isAppLockedNow, readAppLockFile, setLockedHere } from '../state'; +import { deleteVault } from '../vault'; + +const PIN = 'correct-pin'; + +class FakeChannel { + static open = new Set(); + onmessage: ((event: MessageEvent) => void) | null = null; + constructor(readonly name: string) { + FakeChannel.open.add(this); + } + postMessage(data: unknown): void { + for (const channel of FakeChannel.open) { + if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent); + } + } + close(): void { + FakeChannel.open.delete(this); + } +} + +class FakeWorker { + static all: FakeWorker[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + posted: Array> = []; + terminated = false; + constructor() { + FakeWorker.all.push(this); + // As the real one does with no other tab around. + queueMicrotask(() => this.onmessage?.({ data: { kind: 'owner' } } as MessageEvent)); + } + postMessage(message: Record): void { + this.posted.push(message); + if (message.kind === 'open' || message.kind === 'release' || message.kind === 'prune-files') { + queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: message.kind === 'open' ? { created: false } : undefined } } as MessageEvent)); + } + } + terminate(): void { + this.terminated = true; + } +} + +const hooks: StorePageHooks = { + inputs: () => ({ session: { apiUrl: 'x' } as never, auth: 'Bearer token', invertedKeywordSort: null }), + refreshAuth: async () => null, + onEvent: () => {}, + keyLayers: appLockKeyLayers, +}; + +let reloads = 0; +let stopTab: () => void = () => {}; + +function bridge(overrides: Record = {}) { + const appLock = { + status: vi.fn(async () => ({ locked: false, enrolled: true, os: 'hello', osEnrolled: true })), + lock: vi.fn(), + enroll: vi.fn(async () => true), + prove: vi.fn(async () => true), + disable: vi.fn(async () => true), + forget: vi.fn(async () => true), + osEnroll: vi.fn(async () => true), + osRemove: vi.fn(async () => {}), + osUnlock: vi.fn(async () => ({ ok: false as const, reason: 'denied' as const })), + onLockRequest: vi.fn(() => () => {}), + ...overrides, + }; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock }; + return appLock; +} + +beforeEach(async () => { + FakeChannel.open.clear(); + FakeWorker.all = []; + vi.stubGlobal('BroadcastChannel', FakeChannel); + vi.stubGlobal('Worker', FakeWorker); + vi.stubGlobal('FileSystemFileHandle', class {}); + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200 }))); + Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => ({}) } }); + Object.defineProperty(window.navigator, 'locks', { + configurable: true, + value: { + request: vi.fn(async (_name: string, ...rest: unknown[]) => { + const run = rest[rest.length - 1] as () => unknown; + return run(); + }), + query: vi.fn(async () => ({ held: [], pending: [] })), + }, + }); + localStorage.clear(); + sessionStorage.clear(); + reloads = 0; + setReloadForTests(() => { reloads += 1; }); + resetAppLockForTests(); + resetComposeFlushRegistry(); + setStoreBackend(null); + configureStore(hooks); + await deleteVault(); + deleteKeyDatabase(); +}); + +afterEach(() => { + stopTab(); + stopTab = () => {}; + vi.unstubAllGlobals(); + setReloadForTests(null); + setAppLockCheck(null); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + delete (window.navigator as { storage?: unknown }).storage; + delete (window.navigator as { locks?: unknown }).locks; + setStoreBackend(null); + configureStore(null); + setLockedHere(false); +}); + +/** A store open under the lock, as a running tab has it. */ +async function runningLockedStore(account = 'acc') { + await enableAppLock(PIN); + await getStore().ensureOpen(account, { apiUrl: 'x' } as never); + await vi.waitFor(() => expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(true)); + return lastWorker(); +} + +function lastWorker(): FakeWorker { + return FakeWorker.all[FakeWorker.all.length - 1]; +} + +async function lockedAndReloaded(): Promise { + lockEverywhere(); + await vi.waitFor(() => expect(reloads).toBe(1)); +} + +/** The page a reload gives: new module state, the same storage. */ +function asNewDocument(): void { + resetAppLockForTests(); + bootAppLock(); +} + +describe('while locked', () => { + it('the worker with the key is gone, the key with it, and reads fail until unlock', async () => { + const worker = await runningLockedStore(); + const opened = worker.posted.find((m) => m.kind === 'open') as { params: { keyHex: string } }; + expect(opened.params.keyHex).toMatch(/^[0-9a-f]{64}$/); + + await lockedAndReloaded(); + + expect(worker.posted.some((m) => m.kind === 'release')).toBe(true); + expect(worker.terminated).toBe(true); + const workers = FakeWorker.all.length; + await expect(getStore().answer('acc', [])).rejects.toMatchObject({ name: 'StoreLockedError' }); + await expect(getStore().area('acc', 'x.y')).rejects.toMatchObject({ name: 'StoreLockedError' }); + expect(FakeWorker.all.length, 'no new worker while locked').toBe(workers); + // In this tab's memory too: nothing gives the layer now. + expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); + await expect(openAccountKey('acc')).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' }); + + await unlockWithPin(PIN); + expect(isAppLockedNow()).toBe(false); + await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex: opened.params.keyHex }); + }); + + it('a stuck close doesn\'t keep the key: the worker is stopped after the wait', async () => { + const worker = await runningLockedStore(); + worker.postMessage = function postMessage(message) { this.posted.push(message); }; + await getStore().lock?.(10); + expect(worker.terminated).toBe(true); + }); + + it('a new start stays closed: the app is held back and nothing has the key', async () => { + await enableAppLock(PIN); + await lockedAndReloaded(); + asNewDocument(); + expect(getAppLockPhase()).toBe('locked'); + expect(appLockHold()).toBeInstanceOf(Promise); + expect(isAppLockedNow()).toBe(true); + }); + + it('notifications go by it: the check the OS code asks says locked', async () => { + await enableAppLock(PIN); + asNewDocument(); + expect(isAppLocked()).toBe(true); + }); + + it('a browser closed without locking counts as locked: nobody holds the key', async () => { + await enableAppLock(PIN); + resetAppLockForTests(); + expect(readAppLockFile().locked).toBe(false); + bootAppLock(); + expect(getAppLockPhase()).toBe('checking'); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); + expect(readAppLockFile().locked).toBe(true); + }); + + it('another tab holding the key lets this one start without any key', async () => { + await enableAppLock(PIN); + resetAppLockForTests(); + (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); + // Held elsewhere: a request for it waits. + (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); + bootAppLock(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); + expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); + }); +}); + +describe('locking', () => { + it('saves what is being written before the store closes', async () => { + const worker = await runningLockedStore(); + const order: string[] = []; + registerComposeFlush(async () => { order.push(worker.terminated ? 'saved after close' : 'saved'); }); + await lockedAndReloaded(); + expect(order).toEqual(['saved']); + }); + + it('seals text the server didn\'t take, and it comes back only after unlock', async () => { + await enableAppLock(PIN); + registerComposeFlush(async () => { throw new Error('offline'); }); + registerComposeSnapshot({ ownWindow: false, state: () => ({ subject: 'Unsaved secret', unsaved: true }) as never, draft: () => null }); + await lockedAndReloaded(); + expect(JSON.stringify({ ...localStorage })).not.toContain('Unsaved secret'); + expect(JSON.stringify({ ...sessionStorage })).not.toContain('Unsaved secret'); + await unlockWithPin(PIN); + await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Unsaved secret')); + }); + + it('takes subjects out of plain browser storage', async () => { + await enableAppLock(PIN); + localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}'); + localStorage.setItem('pro-tabs', '{"tabs":[{"title":"Secret"}]}'); + sessionStorage.setItem('bulwark-popout-compose:h1', '{"initialData":{"subject":"Secret"}}'); + await lockedAndReloaded(); + expect(localStorage.getItem('email-snapshot')).toBeNull(); + expect(localStorage.getItem('pro-tabs')).toBeNull(); + expect(sessionStorage.getItem('bulwark-popout-compose:h1')).toBeNull(); + }); + + it('points a composer window at its saved draft, so nothing of the text stays in its address or session', async () => { + await enableAppLock(PIN); + registerComposeSnapshot({ ownWindow: true, state: () => ({ subject: 'x' }) as never, draft: () => ({ draftId: 'd7', localAccountId: 'alice' }) }); + await lockedAndReloaded(); + expect(window.location.search).toContain('draft=d7'); + expect(sessionStorage.getItem('bulwark-app-lock-drafts-saved')).toBe('1'); + expect(composeSnapshots()).toHaveLength(1); + window.history.replaceState(null, '', '/'); + }); + + it('reaches this tab from another, by the channel', async () => { + await enableAppLock(PIN); + stopTab = startAppLock(); + new FakeChannel('bulwark:app-lock').postMessage({ type: 'lock' }); + await vi.waitFor(() => expect(reloads).toBe(1)); + expect(isAppLockedNow()).toBe(true); + }); + + it('when the server is out of reach it stays on the page, off the screen, and reloads when it can', async () => { + (fetch as ReturnType).mockRejectedValue(new Error('offline')); + await enableAppLock(PIN); + lockEverywhere(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay')); + expect(reloads).toBe(0); + window.dispatchEvent(new Event('online')); + expect(reloads).toBe(1); + }); +}); + +describe('the store when a tab inherits it without the key', () => { + it('locks everywhere instead of opening or writing an unwrapped key', async () => { + await enableAppLock(PIN); + resetAppLockForTests(); + bootAppLock(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); + const layers = () => appLockKeyLayers(); + expect(layers).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); + // A new account can't get a key record outside the layer either. + expect(await getAccountRecord('new-account')).toBeNull(); + }); + + it('a new account after unlock gets its key under the lock', async () => { + await enableAppLock(PIN); + await getStore().ensureOpen('new-account', { apiUrl: 'x' } as never); + await vi.waitFor(async () => expect((await getAccountRecord('new-account'))?.layers).toEqual(['applock'])); + }); +}); + +describe('the desktop app', () => { + it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => { + const appLock = bridge(); + await enableAppLock(PIN); + expect(appLock.enroll).toHaveBeenCalledTimes(1); + const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array; + await lockedAndReloaded(); + expect(appLock.lock).toHaveBeenCalled(); + await unlockWithPin(PIN); + expect(appLock.prove).toHaveBeenCalledTimes(1); + expect(Array.from(appLock.prove.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof)); + }); + + it('the screen locking locks the app', async () => { + let request: () => void = () => {}; + bridge({ onLockRequest: vi.fn((listener: () => void) => { request = listener; return () => {}; }) }); + await enableAppLock(PIN); + stopTab = startAppLock(); + request(); + await vi.waitFor(() => expect(reloads).toBe(1)); + expect(readAppLockFile().locked).toBe(true); + }); + + it('a page in a tab can\'t lift a lock main still holds', async () => { + const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); + await enableAppLock(PIN); + resetAppLockForTests(); + bootAppLock(); + stopTab = startAppLock(); + await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); + new FakeChannel('bulwark:app-lock').postMessage({ type: 'unlocked' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(appLock.status).toHaveBeenCalled(); + expect(getAppLockPhase()).toBe('locked'); + }); + + it('Windows Hello\'s yes opens with the key main hands over', async () => { + let held: Uint8Array | null = null; + const appLock = bridge({ + osEnroll: vi.fn(async (_proof: Uint8Array, key: Uint8Array) => { held = key.slice(); return true; }), + osUnlock: vi.fn(async () => ({ ok: true as const, key: held! })), + }); + await runningLockedStore(); + expect(await addOsUnlock(PIN)).toBe(true); + await lockedAndReloaded(); + expect(await unlockWithOs()).toBe(true); + expect(appLock.osUnlock).toHaveBeenCalledTimes(1); + expect(isAppLockedNow()).toBe(false); + await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toBeDefined(); + }); + + it('wrong bytes from main open nothing', async () => { + bridge({ osUnlock: vi.fn(async () => ({ ok: true as const, key: new Uint8Array(32).fill(1) })) }); + await runningLockedStore(); + await lockedAndReloaded(); + await unlockWithOs().catch(() => false); + await expect(openAccountKey('acc', appLockKeyLayers())).rejects.toBeDefined(); + }); + + it('Windows Hello\'s no leaves it locked, and the PIN still works', async () => { + const appLock = bridge(); + await enableAppLock(PIN); + await lockedAndReloaded(); + expect(await unlockWithOs()).toBe(false); + expect(appLock.osUnlock).toHaveBeenCalled(); + expect(isAppLockedNow()).toBe(true); + await unlockWithPin(PIN); + expect(isAppLockedNow()).toBe(false); + }); + + it('forgetting asks main first: a no changes nothing', async () => { + const appLock = bridge({ forget: vi.fn(async () => false) }); + await enableAppLock(PIN); + await lockedAndReloaded(); + expect(await forgetEverything()).toBe(false); + expect(appLock.forget).toHaveBeenCalled(); + expect(readAppLockFile()).toMatchObject({ on: true, locked: true }); + }); +}); + +describe('policy', () => { + function withPolicy(required: { value: boolean; locked: boolean }, minutes: { value: number | null; locked: boolean } = { value: null, locked: false }) { + const appLock = bridge(); + (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.policy = { + get: () => ({ + AppLockRequired: { ...required, source: 'forced-machine' }, + AppLockTimeoutMinutes: { ...minutes, source: minutes.locked ? 'forced-machine' : 'default' }, + }), + onChange: () => () => {}, + }; + return appLock; + } + + it('a forced policy requires it: a signed-in start waits for a PIN', () => { + withPolicy({ value: true, locked: true }); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); + expect(appLockRequired()).toBe(true); + bootAppLock(); + expect(getAppLockPhase()).toBe('setup'); + expect(appLockHold()).toBeInstanceOf(Promise); + }); + + it('a required lock doesn\'t stand in front of the login page', () => { + withPolicy({ value: true, locked: true }); + bootAppLock(); + expect(getAppLockPhase()).toBe('open'); + }); + + it('a recommended policy requires nothing', () => { + withPolicy({ value: true, locked: false }); + expect(appLockRequired()).toBe(false); + }); + + it('a required lock can\'t be turned off', async () => { + await enableAppLock(PIN); + withPolicy({ value: true, locked: true }); + await expect(disableAppLock(PIN)).rejects.toThrow(); + expect(readAppLockFile().on).toBe(true); + }); + + it('setting up the required PIN lets the app go on', async () => { + withPolicy({ value: true, locked: true }); + localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); + bootAppLock(); + const hold = appLockHold(); + await enableAppLock(PIN); + await expect(hold).resolves.toBeUndefined(); + expect(getAppLockPhase()).toBe('open'); + }); + + it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => { + withPolicy({ value: false, locked: false }, { value: 3, locked: true }); + expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true }); + withPolicy({ value: false, locked: false }, { value: 3, locked: false }); + expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false }); + expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false }); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + expect(effectiveTimeout(undefined)).toEqual({ minutes: 10, locked: false }); + }); +}); + +describe('off', () => { + it('is the default: nothing is held back and no layer is asked for', () => { + bootAppLock(); + expect(getAppLockPhase()).toBe('open'); + expect(appLockHold()).toBeNull(); + expect(appLockKeyLayers()).toEqual([]); + expect(localStorage.getItem(APP_LOCK_KEY)).toBeNull(); + }); + + it('turning it off takes the layer off again', async () => { + await openAccountKey('acc'); + await enableAppLock(PIN); + expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']); + await disableAppLock(PIN); + expect((await getAccountRecord('acc'))?.layers).toEqual([]); + expect(readAppLockFile().on).toBe(false); + }); + + it('a wrong PIN doesn\'t turn it off', async () => { + await enableAppLock(PIN); + await expect(disableAppLock('wrong-pin')).rejects.toMatchObject({ name: 'WrongPinError' }); + expect(readAppLockFile().on).toBe(true); + }); +}); diff --git a/lib/app-lock/__tests__/idle.test.ts b/lib/app-lock/__tests__/idle.test.ts new file mode 100644 index 000000000..7075fa931 --- /dev/null +++ b/lib/app-lock/__tests__/idle.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { lastActivity, noteActivity, watchIdle } from '../idle'; + +const MINUTE = 60_000; + +describe('time without use', () => { + let stop: () => void = () => {}; + + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + + afterEach(() => { + stop(); + vi.useRealTimers(); + }); + + function watch(minutes: number) { + const onIdle = vi.fn(); + stop = watchIdle({ timeoutMs: () => minutes * MINUTE, onIdle }); + return onIdle; + } + + it('locks once the time has passed with nothing happening', () => { + const onIdle = watch(5); + vi.advanceTimersByTime(5 * MINUTE - 20_000); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(30_000); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('using this window starts it over', () => { + const onIdle = watch(5); + vi.advanceTimersByTime(4 * MINUTE); + window.dispatchEvent(new KeyboardEvent('keydown')); + vi.advanceTimersByTime(4 * MINUTE); + expect(onIdle).not.toHaveBeenCalled(); + }); + + it('using another window or tab counts too', () => { + const onIdle = watch(5); + vi.advanceTimersByTime(4 * MINUTE); + // Another tab writes the shared key. + noteActivity(Date.now()); + vi.advanceTimersByTime(4 * MINUTE); + expect(onIdle).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2 * MINUTE); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('tells the other tabs about use here, but not on every mouse move', () => { + watch(5); + const before = lastActivity(); + vi.advanceTimersByTime(1000); + window.dispatchEvent(new Event('pointermove')); + expect(lastActivity()).toBe(before); + vi.advanceTimersByTime(5000); + window.dispatchEvent(new Event('pointermove')); + expect(lastActivity()).toBe(Date.now()); + }); + + it('coming back after the time counts as too late, not as use', () => { + const onIdle = watch(5); + // The machine slept: no timer ran, and the clock jumped. + vi.setSystemTime(Date.now() + 6 * MINUTE); + window.dispatchEvent(new KeyboardEvent('keydown')); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('a changed timeout applies at once', () => { + let minutes = 60; + const onIdle = vi.fn(); + stop = watchIdle({ timeoutMs: () => minutes * MINUTE, onIdle }); + vi.advanceTimersByTime(2 * MINUTE); + expect(onIdle).not.toHaveBeenCalled(); + minutes = 1; + vi.advanceTimersByTime(10_000); + expect(onIdle).toHaveBeenCalledTimes(1); + }); + + it('fires once and stops listening', () => { + const onIdle = watch(1); + vi.advanceTimersByTime(2 * MINUTE); + vi.advanceTimersByTime(10 * MINUTE); + window.dispatchEvent(new KeyboardEvent('keydown')); + expect(onIdle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/app-lock/__tests__/vault.test.ts b/lib/app-lock/__tests__/vault.test.ts new file mode 100644 index 000000000..f1173575d --- /dev/null +++ b/lib/app-lock/__tests__/vault.test.ts @@ -0,0 +1,158 @@ +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAccountRecord, openAccountKey, rewrapAccountKey, StoreKeyLayerMissingError } from '@/lib/store/client/keys'; +import { importLockKey, lockLayer, mainProof, PBKDF2_ITERATIONS, pinLongEnough } from '../crypto'; +import { createVault, deleteVault, FREE_FAILURES, LONGEST_WAIT_MS, openSealedDrafts, openVaultWithPin, PinBlockedError, pinBlockedUntil, readVault, sealDraft, waitAfter, WrongPinError } from '../vault'; + +const PIN = 'correct-pin'; + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +beforeEach(async () => { + await deleteVault(); +}); + +describe('the PIN', () => { + it('uses PBKDF2 with at least 600,000 iterations and a random salt', async () => { + (await createVault(PIN)).fill(0); + const first = await readVault(); + expect(first?.pin.iterations).toBeGreaterThanOrEqual(600_000); + expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000); + (await createVault(PIN)).fill(0); + const second = await readVault(); + expect(hex(second!.pin.salt)).not.toBe(hex(first!.pin.salt)); + }); + + it('opens the lock key with the right PIN and not with a wrong one', async () => { + const raw = await createVault(PIN); + expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw)); + await expect(openVaultWithPin('wrong-pin')).rejects.toBeInstanceOf(WrongPinError); + }); + + it('keeps nothing readable: the lock key isn\'t in what is stored', async () => { + const raw = await createVault(PIN); + const stored = await readVault(); + const bytes = hex(stored!.pin.data); + expect(bytes).not.toContain(hex(raw)); + expect(JSON.stringify(stored)).not.toContain(PIN); + }); + + it('must be long enough, counted in characters', () => { + expect(pinLongEnough('12345')).toBe(false); + expect(pinLongEnough('123456')).toBe(true); + }); +}); + +describe('wrong PINs', () => { + const NOW = 1_700_000_000_000; + + async function failTimes(count: number, now = NOW): Promise { + for (let i = 0; i < count; i += 1) { + await openVaultWithPin('wrong-pin', now + i).catch((error) => { + if ((error as Error).name !== 'WrongPinError') throw error; + }); + } + } + + it('cost nothing at first, then a growing wait, up to a long one and no wipe', () => { + expect(waitAfter(FREE_FAILURES)).toBe(0); + expect(waitAfter(FREE_FAILURES + 1)).toBe(30_000); + expect(waitAfter(FREE_FAILURES + 2)).toBe(60_000); + expect(waitAfter(1000)).toBe(LONGEST_WAIT_MS); + }); + + it('block the next try even with the right PIN', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES + 1); + await expect(openVaultWithPin(PIN, NOW + FREE_FAILURES + 2)).rejects.toBeInstanceOf(PinBlockedError); + }); + + it('are still counted after a reload', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES + 1); + vi.resetModules(); + const fresh = await import('../vault'); + await expect(fresh.openVaultWithPin(PIN, NOW + 100)).rejects.toMatchObject({ name: 'PinBlockedError' }); + expect(await fresh.pinBlockedUntil(NOW + 100)).toBeGreaterThan(NOW + 100); + }); + + it('are counted before the check, so leaving during one gains nothing', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES); + // A check that never finishes: the page was closed while PBKDF2 ran. + const derive = vi.spyOn(crypto.subtle, 'deriveKey').mockImplementationOnce(() => new Promise(() => {})); + void openVaultWithPin('wrong-pin', NOW + 50); + await vi.waitFor(() => expect(derive).toHaveBeenCalled()); + derive.mockRestore(); + expect(await pinBlockedUntil(NOW + 51)).toBeGreaterThan(NOW + 51); + }); + + it('are forgotten after the right PIN', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES); + await openVaultWithPin(PIN, NOW + 100); + expect(await pinBlockedUntil(NOW + 200)).toBe(0); + await failTimes(FREE_FAILURES, NOW + 200); + expect(await pinBlockedUntil(NOW + 300)).toBe(0); + }); + + it('count again after the wait, for longer', async () => { + await createVault(PIN); + await failTimes(FREE_FAILURES + 1); + const later = NOW + 31_000; + await expect(openVaultWithPin('wrong-pin', later)).rejects.toBeInstanceOf(WrongPinError); + expect(await pinBlockedUntil(later)).toBe(later + 60_000); + }); +}); + +describe('the account keys under the lock', () => { + it('can\'t be read from disk without the lock key, and read back the same with it', async () => { + const { keyHex } = await openAccountKey('acc-layer'); + const raw = await createVault(PIN); + const layer = lockLayer(await importLockKey(raw)); + await rewrapAccountKey('acc-layer', [layer], [layer]); + + const record = await getAccountRecord('acc-layer'); + expect(record?.layers).toEqual(['applock']); + expect(hex(record!.wrapped)).not.toContain(keyHex); + await expect(openAccountKey('acc-layer')).rejects.toBeInstanceOf(StoreKeyLayerMissingError); + + const other = lockLayer(await importLockKey(new Uint8Array(32).fill(9))); + await expect(openAccountKey('acc-layer', [other])).rejects.toBeDefined(); + + expect((await openAccountKey('acc-layer', [layer])).keyHex).toBe(keyHex); + }); + + it('come off the layer again without changing the database key', async () => { + const { keyHex } = await openAccountKey('acc-off'); + const raw = await createVault(PIN); + const layer = lockLayer(await importLockKey(raw)); + await rewrapAccountKey('acc-off', [layer], [layer]); + await rewrapAccountKey('acc-off', [layer], []); + expect((await getAccountRecord('acc-off'))?.layers).toEqual([]); + expect((await openAccountKey('acc-off')).keyHex).toBe(keyHex); + }); +}); + +describe('a draft a locking window couldn\'t save', () => { + it('can be sealed with no key, and only the lock key opens it', async () => { + const raw = await createVault(PIN); + const key = await importLockKey(raw); + await sealDraft({ subject: 'Secret plan' }); + await expect(openSealedDrafts(await importLockKey(new Uint8Array(32).fill(1)))).rejects.toBeDefined(); + expect(await openSealedDrafts(key)).toEqual([{ subject: 'Secret plan' }]); + expect(await openSealedDrafts(key)).toEqual([]); + }); +}); + +describe('the proof for the desktop app', () => { + it('follows from the lock key and isn\'t the lock key', async () => { + const raw = new Uint8Array(32).fill(3); + const proof = await mainProof(raw); + expect(hex(proof)).not.toBe(hex(raw)); + expect(hex(await mainProof(raw))).toBe(hex(proof)); + expect(hex(await mainProof(new Uint8Array(32).fill(4)))).not.toBe(hex(proof)); + }); +}); diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts new file mode 100644 index 000000000..46c71533c --- /dev/null +++ b/lib/app-lock/controller.ts @@ -0,0 +1,730 @@ +/** + * App lock in one tab: when it locks, what locking does, what unlocking does. + * + * Locking saves what's being written, closes the store, drops the keys, then reloads + * the page. The reload is how memory gets cleared: no list of stores to reset can + * be as complete as a new document, and it takes the plugin frames, the client's + * credentials and every module cache along. The new document stops at the lock screen + * (components/app-lock/app-lock-gate.tsx), so nothing starts until someone unlocks. + * + * The lock key lives in the memory of the tab that unlocked, and in no other. It + * never goes over a channel. That tab holds a Web Lock for as long as it lives. When it + * goes, whichever tab inherits the store couldn't open it, so every tab locks. + * + * In the desktop app, main has the last word on whether the app is locked (see + * the shell's src/main/app-lock-gate.ts). A page can tell it to lock and can prove + * the real unlock, and has no way to just say "unlocked". + */ + +import { flushComposers, composeSnapshots } from '@/lib/compose-flush-registry'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import { restoreUrlFor } from '@/lib/desktop/window-restore'; +import { IS_LITE } from '@/lib/lite'; +import { keepRescuedDraft } from '@/lib/popout/rescued-drafts'; +import { closePopoutWindow, isPopoutWindow } from '@/lib/popout/window-role'; +import { getStore, type StoreBackend } from '@/lib/store/client/api'; +import { listAccountRecords, rewrapAccountKey, StoreKeyLayerMissingError, type WrapLayer } from '@/lib/store/client/keys'; +import { OWNER_LOCK } from '@/lib/store/worker/protocol'; +import type { ComposerDraftData } from '@/components/email/email-composer'; +import { importLockKey, LOCK_LAYER, lockLayer, mainProof } from './crypto'; +import { watchIdle } from './idle'; +import { createPasskey, passkeyPrf, PasskeyNoPrfError } from './passkey'; +import { appLockRequired, effectiveTimeout, onLockPolicyChange } from './policy'; +import { scrubBrowserStorage } from './scrub'; +import { + APP_LOCK_CHANNEL, + APP_LOCK_HOLDER, + APP_LOCK_KEY, + isAppLockedNow, + postAppLockMessage, + readAppLockFile, + setLockedHere, + writeAppLockFile, + type AppLockFile, + type AppLockMessage, +} from './state'; +import { + addVaultPrf, + changeVaultPin, + createVault, + deleteVault, + openSealedDrafts, + openVaultWithPin, + openVaultWithPrf, + readVault, + removeVaultPrf, + sealDraft, +} from './vault'; + +export type AppLockPhase = + /** Off, or on and unlocked. */ + | 'open' + /** On, and this tab asks whether another tab holds the key. */ + | 'checking' + /** An organisation requires it and no PIN exists yet. */ + | 'setup' + /** On its way to the lock screen: saving, closing. */ + | 'locking' + | 'locked' + /** Locked, in a page that couldn't reload: the app is gone from the screen. */ + | 'locked-stay'; + +/** Long enough for a draft save, and for a send under way to end. The screen is covered by then. */ +const LOCK_FLUSH_MS = 15_000; +const STORE_CLOSE_MS = 5_000; +const OWNER_WAIT_MS = 5_000; +const HOLDER_WAIT_MS = 1_500; +const RELOAD_PROBE_MS = 2_000; +const DRAFTS_SAVED_KEY = 'bulwark-app-lock-drafts-saved'; + +let booted = false; +let phase: AppLockPhase = 'open'; +const listeners = new Set<() => void>(); +let hold: { promise: Promise; release: () => void } | null = null; + +/** Only ever in this tab's memory. Can't be read back out. */ +let heldKey: CryptoKey | null = null; +let releaseHolder: (() => void) | null = null; +let holderWatch: AbortController | null = null; +let stopIdle: (() => void) | null = null; + +function setPhase(next: AppLockPhase): void { + if (phase === next) return; + phase = next; + for (const listener of [...listeners]) listener(); +} + +export function getAppLockPhase(): AppLockPhase { + return phase; +} + +export function subscribeAppLockPhase(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function newHold(): { promise: Promise; release: () => void } { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +/** While this is pending, the app under the gate doesn't hydrate. */ +export function appLockHold(): Promise | null { + return hold?.promise ?? null; +} + +function markDocument(locked: boolean): void { + if (typeof document === 'undefined') return; + if (locked) document.documentElement.setAttribute('data-app-locked', ''); + else document.documentElement.removeAttribute('data-app-locked'); +} + +function hasAccounts(): boolean { + try { + const parsed = JSON.parse(localStorage.getItem('account-registry') ?? 'null') as { state?: { accounts?: unknown[] } } | null; + return (parsed?.state?.accounts?.length ?? 0) > 0; + } catch { + return false; + } +} + +function setupNeeded(): boolean { + return !readAppLockFile().on && appLockRequired() && hasAccounts(); +} + +/** + * Decides this tab's start, synchronously, in the first client render. A tab that + * finds the lock on doesn't start the app until it knows a tab holds the key. + */ +export function bootAppLock(): void { + if (booted || typeof window === 'undefined' || IS_LITE) return; + booted = true; + setAppLockCheck(isAppLockedNow); + const file = readAppLockFile(); + if (file.on) { + setLockedHere(true); + hold = newHold(); + if (file.locked) { + phase = 'locked'; + tellDesktopLocked(); + } else { + phase = 'checking'; + void settleCheck(); + } + return; + } + markDocument(false); + if (setupNeeded()) { + hold = newHold(); + phase = 'setup'; + } +} + +async function holderAlive(): Promise { + if (!navigator.locks?.query) return false; + try { + const { held } = await navigator.locks.query(); + return (held ?? []).some((lock) => lock.name === APP_LOCK_HOLDER); + } catch { + return false; + } +} + +async function desktopSaysLocked(): Promise { + const member = getDesktopBridge()?.appLock; + if (!member) return false; + try { + return (await member.status()).locked; + } catch { + return true; + } +} + +async function settleCheck(): Promise { + if (phase !== 'checking') return; + if (await holderAlive() && !(await desktopSaysLocked())) { + openUp(); + return; + } + // Nobody holds the key: a crash, or the browser was closed without saying so. + writeAppLockFile({ ...readAppLockFile(), locked: true }); + tellDesktopLocked(); + tellServiceWorker('locked'); + setPhase('locked'); +} + +function tellDesktopLocked(): void { + getDesktopBridge()?.appLock?.lock(); +} + +/** The service worker shows push notifications with no page around. Kept across its restarts. */ +function tellServiceWorker(state: 'locked' | 'unlocked' | 'off'): void { + try { + const message = { type: 'app-lock', state }; + const worker = navigator.serviceWorker; + if (!worker) return; + if (worker.controller) worker.controller.postMessage(message); + else void worker.ready.then((registration) => registration.active?.postMessage(message)); + } catch { + // No service worker, no push notifications. + } +} + +function startIdle(): void { + stopIdle?.(); + const file = readAppLockFile(); + if (!file.on) { + stopIdle = null; + return; + } + stopIdle = watchIdle({ + timeoutMs: () => effectiveTimeout(readAppLockFile().timeoutMinutes).minutes * 60_000, + onIdle: () => lockEverywhere(), + }); +} + +/** + * A tab with no key watches for the one that has it to go. The request waits as long + * as the holder lives. Being granted means it's gone. + */ +function watchHolder(): void { + holderWatch?.abort(); + holderWatch = null; + if (!navigator.locks?.request || releaseHolder) return; + const watch = new AbortController(); + holderWatch = watch; + void navigator.locks.request(APP_LOCK_HOLDER, { signal: watch.signal }, () => { + if (heldKey) return keepHolder(); + if (readAppLockFile().on && !readAppLockFile().locked) lockEverywhere(); + return undefined; + }).catch(() => {}); +} + +function keepHolder(): Promise { + return new Promise((resolve) => { + releaseHolder = resolve; + }); +} + +async function becomeHolder(): Promise { + holderWatch?.abort(); + holderWatch = null; + if (releaseHolder || !navigator.locks?.request) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, HOLDER_WAIT_MS); + void navigator.locks.request(APP_LOCK_HOLDER, () => { + clearTimeout(timer); + resolve(); + return keepHolder(); + }).catch(() => resolve()); + }); +} + +function dropKey(): void { + heldKey = null; + releaseHolder?.(); + releaseHolder = null; +} + +/** The app starts, or goes on, in this tab. */ +function openUp(): void { + setLockedHere(false); + markDocument(false); + setPhase('open'); + startIdle(); + if (readAppLockFile().on && !heldKey) watchHolder(); + hold?.release(); + hold = null; +} + +// ---- Locking ---- + +/** Every window and tab. Safe to call at any time; it only ever makes things more locked. */ +export function lockEverywhere(): void { + const file = readAppLockFile(); + if (!file.on) return; + writeAppLockFile({ ...file, locked: true }); + postAppLockMessage({ type: 'lock' }); + void applyLockHere(); +} + +async function saveComposers(): Promise<{ closing: boolean; saved: number }> { + const snapshots = composeSnapshots(); + const flushed = await flushComposers(LOCK_FLUSH_MS); + const after = composeSnapshots(); + let closing = false; + let saved = 0; + const trouble = flushed.failed > 0 || flushed.timedOut; + for (const snapshot of after) { + const state = snapshot.state(); + const draft = snapshot.draft(); + if (trouble && state) { + // Not on the server. Sealed, it waits for the lock key. + await sealDraft(state).catch(() => {}); + if (snapshot.ownWindow) closing = true; + continue; + } + if (draft) saved += 1; + if (!snapshot.ownWindow) continue; + if (draft) { + // This window's address names no text of its own after a reload: it opens this draft. + window.history.replaceState(window.history.state, '', restoreUrlFor(draft)); + } else { + closing = true; + } + } + if (snapshots.length > 0 && saved > 0) { + try { + sessionStorage.setItem(DRAFTS_SAVED_KEY, '1'); + } catch { + // Only the notice goes. + } + } + return { closing, saved }; +} + +/** A flush under way stops at its next item (lib/offline/flush.ts). This waits for that item. */ +async function outboxAtRest(): Promise { + if (!navigator.locks?.query) return; + const until = Date.now() + LOCK_FLUSH_MS; + while (Date.now() < until) { + const { held } = await navigator.locks.query(); + if (!(held ?? []).some((lock) => lock.name?.startsWith('bulwark-offline-flush:'))) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +let reloadSeam: (() => void) | null = null; + +function reloadPage(): void { + if (reloadSeam) reloadSeam(); + else window.location.reload(); +} + +/** Tests only: jsdom can't reload. */ +export function setReloadForTests(run: (() => void) | null): void { + reloadSeam = run; +} + +async function pageReachable(): Promise { + try { + const response = await fetch(window.location.href, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(RELOAD_PROBE_MS) }); + return response.status < 500; + } catch { + return false; + } +} + +async function applyLockHere(): Promise { + if (phase === 'locking' || phase === 'locked' || phase === 'locked-stay') return; + const wasRunning = phase === 'open'; + setLockedHere(true); + markDocument(true); + hold ??= newHold(); + setPhase('locking'); + tellDesktopLocked(); + tellServiceWorker('locked'); + stopIdle?.(); + stopIdle = null; + holderWatch?.abort(); + holderWatch = null; + + let closing = false; + if (wasRunning) { + try { + ({ closing } = await saveComposers()); + await outboxAtRest(); + } catch { + // The lock goes on: what's on screen is covered either way. + } + await scrubBrowserStorage(); + } + await lockStore(); + dropKey(); + if (closing) closePopoutWindow(); + if (await pageReachable()) { + reloadPage(); + return; + } + // The server is out of reach and there's no copy of the page: a reload would land on the + // browser's error page, with no way to unlock. Off the screen now, out of memory when it's back. + setPhase('locked-stay'); + window.addEventListener('online', () => { + if (isAppLockedNow()) reloadPage(); + }); +} + +function storeBackend(): StoreBackend { + return getStore(); +} + +async function lockStore(): Promise { + try { + await storeBackend().lock?.(STORE_CLOSE_MS); + } catch { + // Its worker is stopped either way. + } +} + +// ---- Unlocking ---- + +async function holdAndOpen(raw: Uint8Array): Promise { + try { + const proof = await mainProof(raw); + const key = await importLockKey(raw); + await getDesktopBridge()?.appLock?.prove(proof).catch(() => false); + heldKey = key; + } finally { + raw.fill(0); + } + await becomeHolder(); + const key = heldKey!; + await addLayerWhereMissing(key); + await storeBackend().unlock?.(OWNER_WAIT_MS); + writeAppLockFile({ ...readAppLockFile(), on: true, locked: false }); + postAppLockMessage({ type: 'unlocked' }); + tellServiceWorker('unlocked'); + openUp(); + await deliverSealedDrafts(key); +} + +async function deliverSealedDrafts(key: CryptoKey): Promise { + try { + for (const state of await openSealedDrafts(key)) { + if (isPopoutWindow()) postAppLockMessage({ type: 'unsealed-draft', state }); + else keepRescuedDraft(state as ComposerDraftData); + } + } catch { + // They stay sealed for the next unlock. + } +} + +export async function unlockWithPin(pin: string): Promise { + await holdAndOpen(await openVaultWithPin(pin)); +} + +export async function unlockWithPasskey(): Promise { + const vault = await readVault(); + if (!vault?.prf) throw new PasskeyNoPrfError(); + const output = await passkeyPrf(vault.prf.credentialId, vault.prf.salt); + if (!output) throw new PasskeyNoPrfError(); + await holdAndOpen(await openVaultWithPrf(output)); +} + +/** Windows Hello or Touch ID. Main asks, and only hands over the key after a yes. */ +export async function unlockWithOs(): Promise { + const member = getDesktopBridge()?.appLock; + if (!member) return false; + const answer = await member.osUnlock(); + if (!answer.ok) return false; + await holdAndOpen(new Uint8Array(answer.key)); + return true; +} + +// ---- The account keys ---- + +/** A store record made when the lock was off, or before a crash: it gets the layer now. */ +async function addLayerWhereMissing(key: CryptoKey): Promise { + const layer = lockLayer(key); + for (const record of await listAccountRecords().catch(() => [])) { + if (!record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], [layer]); + } +} + +async function removeLayerEverywhere(key: CryptoKey): Promise { + const layer = lockLayer(key); + for (const record of await listAccountRecords().catch(() => [])) { + if (record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], []); + } +} + +/** What the store's key handling uses. Throws while this tab has no key, so nothing opens or gets written unwrapped. */ +export function appLockKeyLayers(): readonly WrapLayer[] { + if (!readAppLockFile().on) return []; + if (!heldKey) throw new StoreKeyLayerMissingError(LOCK_LAYER); + return [lockLayer(heldKey)]; +} + +/** This tab inherited the store and has no key for it. */ +export function onStoreKeyMissing(): void { + lockEverywhere(); +} + +async function ownerIsElsewhere(): Promise { + if (!navigator.locks?.query) return false; + const { held } = await navigator.locks.query(); + return (held ?? []).some((lock) => lock.name === OWNER_LOCK) && !storeBackend().ownsStoreHere?.(); +} + +// ---- Settings ---- + +export async function enableAppLock(pin: string): Promise { + const raw = await createVault(pin); + const proof = await mainProof(raw); + const member = getDesktopBridge()?.appLock; + if (member && !(await member.enroll(proof))) { + // Main still knows an earlier lock this profile lost. It asks the user natively. + if (!(await member.forget()) || !(await member.enroll(proof))) { + raw.fill(0); + await deleteVault(); + throw new Error('The desktop app refused the lock'); + } + } + const key = await importLockKey(raw); + raw.fill(0); + heldKey = key; + await becomeHolder(); + await addLayerWhereMissing(key); + const file: AppLockFile = { ...readAppLockFile(), on: true, locked: false }; + writeAppLockFile(file); + try { + localStorage.removeItem('email-snapshot'); + } catch { + // Storage blocked: there's no snapshot either. + } + postAppLockMessage({ type: 'enabled' }); + tellServiceWorker('unlocked'); + openUp(); + // Another tab has the store open and no key for what comes next. Everyone starts over. + if (await ownerIsElsewhere()) lockEverywhere(); +} + +/** Off again. The PIN proves it's the owner of the lock, in this tab or any other. */ +export async function disableAppLock(pin: string): Promise { + if (appLockRequired()) throw new Error('The organisation requires the app lock'); + const raw = await openVaultWithPin(pin); + let key: CryptoKey; + try { + const proof = await mainProof(raw); + await getDesktopBridge()?.appLock?.disable(proof); + key = await importLockKey(raw); + } finally { + raw.fill(0); + } + await removeLayerEverywhere(key); + await deleteVault(); + writeAppLockFile({ on: false, locked: false }); + postAppLockMessage({ type: 'off' }); + tellServiceWorker('off'); + afterOff(); +} + +function afterOff(): void { + stopIdle?.(); + stopIdle = null; + holderWatch?.abort(); + holderWatch = null; + dropKey(); + setLockedHere(false); + markDocument(false); + setPhase('open'); + hold?.release(); + hold = null; +} + +export function changeAppLockPin(currentPin: string, newPin: string): Promise { + return changeVaultPin(currentPin, newPin); +} + +export function setAppLockTimeout(minutes: number): void { + writeAppLockFile({ ...readAppLockFile(), timeoutMinutes: minutes }); +} + +export async function addPasskey(pin: string, appName: string): Promise { + const passkey = await createPasskey(appName); + const raw = await openVaultWithPin(pin); + try { + await addVaultPrf(raw, passkey.credentialId, passkey.salt, passkey.prfOutput); + } finally { + raw.fill(0); + } +} + +export function removePasskey(): Promise { + return removeVaultPrf(); +} + +export async function hasPasskey(): Promise { + return Boolean((await readVault())?.prf); +} + +export async function addOsUnlock(pin: string): Promise { + const member = getDesktopBridge()?.appLock; + if (!member) return false; + const raw = await openVaultWithPin(pin); + try { + return await member.osEnroll(await mainProof(raw), raw); + } finally { + raw.fill(0); + } +} + +/** + * The way out when the PIN is gone: all local data, and every login on this device. The + * server keeps everything except what waited in the outbox here. + */ +export async function forgetEverything(): Promise { + const member = getDesktopBridge()?.appLock; + // Native: a page's word alone can't lift the desktop's lock. + if (member && !(await member.forget())) return false; + const { useAuthStore } = await import('@/stores/auth-store'); + await Promise.race([ + useAuthStore.getState().logoutAll({ endProviderSession: false }), + new Promise((resolve) => setTimeout(resolve, STORE_CLOSE_MS)), + ]).catch(() => {}); + await deleteVault(); + writeAppLockFile({ on: false, locked: false }); + postAppLockMessage({ type: 'off' }); + tellServiceWorker('off'); + afterOff(); + window.location.replace(new URL(process.env.NEXT_PUBLIC_BASE_PATH || '/', window.location.origin).href); + return true; +} + +/** A saved draft's notice, once, after the lock that saved it. */ +export function takeDraftsSavedNotice(): boolean { + try { + if (sessionStorage.getItem(DRAFTS_SAVED_KEY) === null) return false; + sessionStorage.removeItem(DRAFTS_SAVED_KEY); + return true; + } catch { + return false; + } +} + +// ---- The tab's life ---- + +function onMessage(message: AppLockMessage): void { + switch (message.type) { + case 'lock': + void applyLockHere(); + break; + case 'unlocked': + if (phase === 'locked' || phase === 'checking' || phase === 'locked-stay') { + void desktopSaysLocked().then((locked) => { + if (!locked) openUp(); + }); + } + break; + case 'enabled': + if (phase === 'setup') { + openUp(); + break; + } + startIdle(); + if (!heldKey) watchHolder(); + break; + case 'off': + afterOff(); + break; + case 'main-ping': + if (!isPopoutWindow()) postAppLockMessage({ type: 'main-pong' }); + break; + case 'focus-main': + if (!isPopoutWindow()) window.focus(); + break; + case 'unsealed-draft': + if (!isPopoutWindow()) keepRescuedDraft(message.state as ComposerDraftData); + break; + } +} + +/** Runs for as long as the tab does, above the part of the page that the lock holds back. */ +export function startAppLock(): () => void { + if (IS_LITE || typeof window === 'undefined') return () => {}; + bootAppLock(); + let channel: BroadcastChannel | null = null; + if (typeof BroadcastChannel !== 'undefined') { + channel = new BroadcastChannel(APP_LOCK_CHANNEL); + channel.onmessage = (event: MessageEvent) => onMessage(event.data); + } + const onStorage = (event: StorageEvent) => { + if (event.key !== APP_LOCK_KEY) return; + const file = readAppLockFile(); + if (file.on && file.locked && phase === 'open') void applyLockHere(); + }; + window.addEventListener('storage', onStorage); + const stopDesktop = getDesktopBridge()?.appLock?.onLockRequest(() => lockEverywhere()); + const reevaluate = () => { + if (phase === 'open' && setupNeeded()) setPhase('setup'); + }; + const stopPolicy = onLockPolicyChange(reevaluate); + if (phase === 'open' && readAppLockFile().on) { + startIdle(); + if (!heldKey) watchHolder(); + } + tellServiceWorker(readAppLockFile().on ? (isAppLockedNow() ? 'locked' : 'unlocked') : 'off'); + return () => { + channel?.close(); + window.removeEventListener('storage', onStorage); + stopDesktop?.(); + stopPolicy(); + stopIdle?.(); + stopIdle = null; + }; +} + +/** An account appeared in a tab that started with none: a required lock is due now. */ +export function recheckAppLockSetup(): void { + if (phase === 'open' && setupNeeded()) setPhase('setup'); +} + +/** Tests only. */ +export function resetAppLockForTests(): void { + booted = false; + phase = 'open'; + hold = null; + heldKey = null; + releaseHolder = null; + holderWatch = null; + stopIdle?.(); + stopIdle = null; + listeners.clear(); + setLockedHere(false); +} diff --git a/lib/app-lock/crypto.ts b/lib/app-lock/crypto.ts new file mode 100644 index 000000000..3bc79134a --- /dev/null +++ b/lib/app-lock/crypto.ts @@ -0,0 +1,115 @@ +/** + * The keys behind app lock, WebCrypto only. + * + * One random lock key wraps every account's database key (a layer in + * lib/store/client/keys.ts). The lock key itself is only ever stored wrapped: once + * under the PIN, and once for each other way in (a passkey's PRF, the desktop app's + * OS storage). So a new account needs no PIN, and a changed PIN touches one record. + */ + +import type { WrapLayer } from '@/lib/store/client/keys'; + +export const LOCK_LAYER = 'applock'; +/** OWASP's floor for PBKDF2-HMAC-SHA256. A wrong guess costs this much, online or off. */ +export const PBKDF2_ITERATIONS = 600_000; +export const MIN_PIN_LENGTH = 6; + +const AES = { name: 'AES-GCM', length: 256 } as const; +const PRF_INFO = new TextEncoder().encode('bulwark-app-lock-prf-v1'); +const PROOF_INFO = new TextEncoder().encode('bulwark-app-lock-main-proof-v1'); + +export interface WrappedBytes { + iv: Uint8Array; + data: Uint8Array; +} + +function buffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function randomBytes(length: number): Uint8Array { + return crypto.getRandomValues(new Uint8Array(length)); +} + +/** The same PIN typed on another keyboard or OS gives the same bytes. */ +export function pinBytes(pin: string): Uint8Array { + return new TextEncoder().encode(pin.normalize('NFKC')); +} + +export function pinLongEnough(pin: string): boolean { + return [...pin.normalize('NFKC')].length >= MIN_PIN_LENGTH; +} + +/** Slow on purpose. Not extractable: it only ever wraps the lock key. */ +export async function keyFromPin(pin: string, salt: Uint8Array, iterations: number): Promise { + const base = await crypto.subtle.importKey('raw', buffer(pinBytes(pin)), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', hash: 'SHA-256', salt: buffer(salt), iterations }, + base, + AES, + false, + ['encrypt', 'decrypt'], + ); +} + +/** A key from what an authenticator's PRF gave: 32 bytes that only it can make again. */ +export async function keyFromPrf(output: ArrayBuffer | Uint8Array): Promise { + const bytes = output instanceof Uint8Array ? output : new Uint8Array(output); + const base = await crypto.subtle.importKey('raw', buffer(bytes), 'HKDF', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PRF_INFO }, + base, + AES, + false, + ['encrypt', 'decrypt'], + ); +} + +export async function seal(key: CryptoKey, plain: Uint8Array): Promise { + const iv = randomBytes(12); + const data = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buffer(iv) }, key, buffer(plain))); + return { iv, data }; +} + +/** Throws when the key is the wrong one: that's how a wrong PIN shows. */ +export async function open(key: CryptoKey, wrapped: WrappedBytes): Promise { + return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buffer(wrapped.iv) }, key, buffer(wrapped.data))); +} + +/** + * What shows the desktop app's main process that someone really unlocked: only the lock + * key's bytes give it, and they exist only just after a real unlock. Main keeps a hash. + */ +export async function mainProof(raw: Uint8Array): Promise { + const base = await crypto.subtle.importKey('raw', buffer(raw), 'HKDF', false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PROOF_INFO }, + base, + 256, + ); + return new Uint8Array(bits); +} + +/** The lock key for use. It can wrap and unwrap, and can't be read back out. */ +export function importLockKey(raw: Uint8Array): Promise { + return crypto.subtle.importKey('raw', buffer(raw), AES, false, ['encrypt', 'decrypt']); +} + +/** The layer around each account's database key. The IV goes in front of the bytes. */ +export function lockLayer(key: CryptoKey): WrapLayer { + return { + name: LOCK_LAYER, + async wrap(data) { + const iv = randomBytes(12); + const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: buffer(iv) }, key, buffer(data))); + const out = new Uint8Array(iv.length + sealed.length); + out.set(iv); + out.set(sealed, iv.length); + return out; + }, + async unwrap(data) { + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: buffer(data.slice(0, 12)) }, key, buffer(data.slice(12))); + return new Uint8Array(plain); + }, + }; +} diff --git a/lib/app-lock/idle.ts b/lib/app-lock/idle.ts new file mode 100644 index 000000000..9a622b5c8 --- /dev/null +++ b/lib/app-lock/idle.ts @@ -0,0 +1,85 @@ +/** + * Time without use, counted across all windows and tabs: every one of them writes + * to the same localStorage key and reads from it. Clocks jump over sleep, which is + * what's wanted: a laptop opened after the timeout locks at once. + */ + +import { APP_LOCK_ACTIVITY_KEY } from './state'; + +const WRITE_EVERY_MS = 5_000; +const CHECK_EVERY_MS = 10_000; +const EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart', 'scroll'] as const; + +export function lastActivity(): number { + try { + const at = Number(localStorage.getItem(APP_LOCK_ACTIVITY_KEY)); + return Number.isFinite(at) ? at : 0; + } catch { + return 0; + } +} + +export function noteActivity(now = Date.now()): void { + try { + localStorage.setItem(APP_LOCK_ACTIVITY_KEY, String(now)); + } catch { + // This tab's own timer then runs from when it started. + } +} + +export interface IdleWatchOptions { + timeoutMs: () => number; + onIdle: () => void; + now?: () => number; +} + +/** + * Calls `onIdle` once the time has passed. A touch after it has passed locks rather + * than counts: coming back to the machine must not undo the timeout. Returns the stop. + */ +export function watchIdle(options: IdleWatchOptions): () => void { + const now = options.now ?? Date.now; + let last = now(); + noteActivity(last); + let written = last; + let stopped = false; + + const expired = (at: number) => at - Math.max(last, lastActivity()) >= options.timeoutMs(); + const fire = () => { + if (stopped) return; + stop(); + options.onIdle(); + }; + const check = () => { + if (expired(now())) fire(); + }; + const onUse = () => { + const at = now(); + if (expired(at)) { + fire(); + return; + } + last = at; + if (at - written >= WRITE_EVERY_MS) { + written = at; + noteActivity(at); + } + }; + const onVisible = () => { + if (document.visibilityState === 'visible') check(); + }; + + for (const event of EVENTS) window.addEventListener(event, onUse, { capture: true, passive: true }); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', check); + const timer = setInterval(check, CHECK_EVERY_MS); + + function stop() { + stopped = true; + clearInterval(timer); + for (const event of EVENTS) window.removeEventListener(event, onUse, { capture: true }); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', check); + } + return stop; +} diff --git a/lib/app-lock/passkey.ts b/lib/app-lock/passkey.ts new file mode 100644 index 000000000..535ab2170 --- /dev/null +++ b/lib/app-lock/passkey.ts @@ -0,0 +1,81 @@ +/** + * WebAuthn with PRF, to wrap the lock key. There is no server in this: nothing checks + * the assertion, and the challenge is only there because the API asks for one. What + * counts is the 32 bytes the authenticator derives, without which the wrap can't open. + * + * A browser can't say ahead of time whether a new passkey has PRF, so it is tried right + * after creating it. The PIN stays either way. + */ + +import { randomBytes } from './crypto'; + +export class PasskeyNoPrfError extends Error { + constructor() { + super('This passkey has no PRF'); + this.name = 'PasskeyNoPrfError'; + } +} + +interface PrfResults { + enabled?: boolean; + results?: { first?: ArrayBuffer }; +} + +function buffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; +} + +export function passkeysPossible(): boolean { + return typeof window !== 'undefined' && window.isSecureContext && typeof window.PublicKeyCredential === 'function' && !!navigator.credentials; +} + +function prfOf(credential: PublicKeyCredential): PrfResults | undefined { + return (credential.getClientExtensionResults() as { prf?: PrfResults }).prf; +} + +/** The authenticator's answer for `salt`. Null when this one has no PRF. */ +export async function passkeyPrf(credentialId: Uint8Array, salt: Uint8Array): Promise { + const credential = await navigator.credentials.get({ + publicKey: { + challenge: buffer(randomBytes(32)), + rpId: location.hostname, + allowCredentials: [{ type: 'public-key', id: buffer(credentialId) }], + userVerification: 'required', + timeout: 120_000, + extensions: { prf: { eval: { first: buffer(salt) } } } as AuthenticationExtensionsClientInputs, + }, + }) as PublicKeyCredential | null; + if (!credential) return null; + return prfOf(credential)?.results?.first ?? null; +} + +export interface NewPasskey { + credentialId: Uint8Array; + salt: Uint8Array; + prfOutput: ArrayBuffer; +} + +/** Throws PasskeyNoPrfError when the new passkey turns out to have no PRF. */ +export async function createPasskey(appName: string): Promise { + const salt = randomBytes(32); + const credential = await navigator.credentials.create({ + publicKey: { + rp: { name: appName, id: location.hostname }, + user: { id: buffer(randomBytes(16)), name: appName, displayName: appName }, + challenge: buffer(randomBytes(32)), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], + authenticatorSelection: { userVerification: 'required', residentKey: 'discouraged' }, + timeout: 120_000, + // Some authenticators only say they have PRF when asked with an input. + extensions: { prf: { eval: { first: buffer(salt) } } } as AuthenticationExtensionsClientInputs, + }, + }) as PublicKeyCredential | null; + if (!credential) throw new PasskeyNoPrfError(); + const credentialId = new Uint8Array(credential.rawId); + const made = prfOf(credential); + if (made?.enabled === false) throw new PasskeyNoPrfError(); + // Windows Hello and others only give the bytes on `get`, so ask once to be sure. + const prfOutput = made?.results?.first ?? await passkeyPrf(credentialId, salt); + if (!prfOutput) throw new PasskeyNoPrfError(); + return { credentialId, salt, prfOutput }; +} diff --git a/lib/app-lock/policy.ts b/lib/app-lock/policy.ts new file mode 100644 index 000000000..718e2b27a --- /dev/null +++ b/lib/app-lock/policy.ts @@ -0,0 +1,64 @@ +/** + * The desktop app's OS policies for the lock, read through the bridge. In a browser + * there's no bridge and nothing is required. The bridge's `policy` member comes from + * another package, so it is feature detected, as lib/offline/scope.ts does. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { DEFAULT_TIMEOUT_MINUTES } from './state'; + +interface PolicyEntry { + value: T; + locked: boolean; + source: string; +} + +interface LockPolicies { + AppLockRequired?: PolicyEntry; + AppLockTimeoutMinutes?: PolicyEntry; +} + +interface PolicyBridge { + get?: () => LockPolicies | null | undefined; + onChange?: (listener: (snapshot: LockPolicies) => void) => () => void; +} + +function policyBridge(): PolicyBridge | undefined { + return (getDesktopBridge() as { policy?: PolicyBridge } | null)?.policy; +} + +function read(): LockPolicies { + try { + return policyBridge()?.get?.() ?? {}; + } catch { + return {}; + } +} + +/** Only a forced value requires it: a recommended one can't choose a PIN for anyone. */ +export function appLockRequired(): boolean { + const entry = read().AppLockRequired; + return entry?.locked === true && entry.value === true; +} + +export interface EffectiveTimeout { + minutes: number; + /** Set by the organisation: the user's choice doesn't apply. */ + locked: boolean; +} + +/** Forced, then the user's choice, then recommended, then the default. */ +export function effectiveTimeout(choice: number | undefined): EffectiveTimeout { + const entry = read().AppLockTimeoutMinutes; + const value = typeof entry?.value === 'number' && entry.value > 0 ? entry.value : null; + if (entry?.locked && value !== null) return { minutes: value, locked: true }; + return { minutes: choice ?? value ?? DEFAULT_TIMEOUT_MINUTES, locked: false }; +} + +export function onLockPolicyChange(listener: () => void): () => void { + try { + return policyBridge()?.onChange?.(() => listener()) ?? (() => {}); + } catch { + return () => {}; + } +} diff --git a/lib/app-lock/scrub.ts b/lib/app-lock/scrub.ts new file mode 100644 index 000000000..1e6dfa26d --- /dev/null +++ b/lib/app-lock/scrub.ts @@ -0,0 +1,44 @@ +/** + * What the app keeps in plain browser storage that names mail. A lock must not leave + * it readable, so it goes before the lock screen comes up. Text that could not be saved + * anywhere else is sealed first (see lib/app-lock/vault.ts). + */ + +import { sealDraft } from './vault'; + +/** Subjects and senders: a list snapshot for a fast start, and the Pro tabs' names. */ +const LOCAL_KEYS = ['email-snapshot', 'pro-tabs']; +const SESSION_KEYS = ['bulwark-offline-lost']; +const SESSION_PREFIXES = ['bulwark-popout-compose:']; +const RESCUED_KEY = 'bulwark-rescued-drafts'; + +function removeSessionKeys(): void { + for (const key of SESSION_KEYS) sessionStorage.removeItem(key); + for (let index = sessionStorage.length - 1; index >= 0; index -= 1) { + const key = sessionStorage.key(index); + if (key && SESSION_PREFIXES.some((prefix) => key.startsWith(prefix))) sessionStorage.removeItem(key); + } +} + +export async function scrubBrowserStorage(): Promise { + try { + const raw = sessionStorage.getItem(RESCUED_KEY); + const rescued: unknown = raw ? JSON.parse(raw) : []; + if (Array.isArray(rescued)) for (const draft of rescued) await sealDraft(draft); + sessionStorage.removeItem(RESCUED_KEY); + } catch { + // Nothing readable to keep. + } + try { + removeSessionKeys(); + } catch { + // Storage blocked: nothing was kept there either. + } + for (const key of LOCAL_KEYS) { + try { + localStorage.removeItem(key); + } catch { + // As above. + } + } +} diff --git a/lib/app-lock/sign-out.ts b/lib/app-lock/sign-out.ts new file mode 100644 index 000000000..881338326 --- /dev/null +++ b/lib/app-lock/sign-out.ts @@ -0,0 +1,15 @@ +/** + * The sign-out's side of the lock. Apart from controller.ts because lib/sign-out-cleanup.ts + * is in every page's bundle and is imported by what the controller imports. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { writeAppLockFile } from './state'; +import { deleteVault } from './vault'; + +/** Nobody is left to lock for. Main only lets go without asking while it's unlocked. */ +export function forgetAppLockAtSignOut(): void { + void deleteVault(); + writeAppLockFile({ on: false, locked: false }); + void getDesktopBridge()?.appLock?.forget().catch(() => false); +} diff --git a/lib/app-lock/state.ts b/lib/app-lock/state.ts new file mode 100644 index 000000000..f662838ea --- /dev/null +++ b/lib/app-lock/state.ts @@ -0,0 +1,103 @@ +/** + * App lock's plain facts, and how tabs tell each other. No secrets and no mail: + * whether the lock is on, whether it is locked now, and the timeout. This stays + * in localStorage because the first frame has to know, before anything async can + * run. Someone who can edit it can't unlock anything: the keys need the PIN. + * + * Imports nothing, so anything may import it: lib/popout/channel.ts, the store's + * flush and the auth store all ask here. + */ + +export const APP_LOCK_KEY = 'bulwark-app-lock'; +/** When someone last used a window or tab. Shared by all of them. */ +export const APP_LOCK_ACTIVITY_KEY = 'bulwark-app-lock-activity'; +export const APP_LOCK_CHANNEL = 'bulwark:app-lock'; +/** Web Lock held by the tab that has the lock key, for as long as it lives. */ +export const APP_LOCK_HOLDER = 'bulwark-app-lock-holder'; + +export const DEFAULT_TIMEOUT_MINUTES = 10; +export const TIMEOUT_CHOICES = [1, 5, 10, 15, 30, 60, 240] as const; + +export interface AppLockFile { + on: boolean; + locked: boolean; + /** The user's choice. Absent until they make one. */ + timeoutMinutes?: number; +} + +const OFF: AppLockFile = { on: false, locked: false }; + +export function readAppLockFile(): AppLockFile { + try { + const parsed = JSON.parse(localStorage.getItem(APP_LOCK_KEY) ?? 'null') as Partial | null; + if (!parsed || typeof parsed !== 'object' || parsed.on !== true) return OFF; + return { + on: true, + // Anything but a clear "no" counts as locked. + locked: parsed.locked !== false, + ...(typeof parsed.timeoutMinutes === 'number' ? { timeoutMinutes: parsed.timeoutMinutes } : {}), + }; + } catch { + return OFF; + } +} + +export function writeAppLockFile(next: AppLockFile): void { + try { + if (next.on) localStorage.setItem(APP_LOCK_KEY, JSON.stringify(next)); + else localStorage.removeItem(APP_LOCK_KEY); + } catch { + // Storage blocked: this tab's memory below still says what it knows. + } +} + +/** Set while this tab is on its way to locked, or shows the lock screen. */ +let lockedHere = false; + +export function setLockedHere(locked: boolean): void { + lockedHere = locked; +} + +/** Nothing with mail in it may show, go out or be read now. */ +export function isAppLockedNow(): boolean { + if (lockedHere) return true; + if (typeof window === 'undefined') return false; + const file = readAppLockFile(); + return file.on && file.locked; +} + +export function isAppLockOn(): boolean { + return typeof window !== 'undefined' && readAppLockFile().on; +} + +export type AppLockMessage = + | { type: 'lock' } + | { type: 'unlocked' } + /** The lock was switched on in another tab. */ + | { type: 'enabled' } + | { type: 'off' } + /** Asks whether a main window is there: a pop-out sends people to it to unlock. */ + | { type: 'main-ping' } + | { type: 'main-pong' } + | { type: 'focus-main' } + /** A draft a locking pop-out couldn't save, unsealed: a main window keeps it. */ + | { type: 'unsealed-draft'; state: unknown }; + +export function postAppLockMessage(message: AppLockMessage): void { + if (typeof BroadcastChannel === 'undefined') return; + try { + const channel = new BroadcastChannel(APP_LOCK_CHANNEL); + channel.postMessage(message); + channel.close(); + } catch { + // The `storage` event on the file still reaches the other tabs. + } +} + +/** Runs in the head script, before the first paint. Self-contained: it is stringified. */ +export function appLockBootScript(): string { + return `try { + var appLock = JSON.parse(localStorage.getItem(${JSON.stringify(APP_LOCK_KEY)}) || 'null'); + if (appLock && appLock.on === true) document.documentElement.setAttribute('data-app-locked', ''); + } catch (e) {}`; +} diff --git a/lib/app-lock/vault.ts b/lib/app-lock/vault.ts new file mode 100644 index 000000000..92a751347 --- /dev/null +++ b/lib/app-lock/vault.ts @@ -0,0 +1,262 @@ +/** + * What app lock keeps on disk, in its own IndexedDB database: the lock key wrapped + * under the PIN and the other ways in, the count of wrong PINs, and drafts sealed + * on their way to the lock screen. + * + * The count is written before a PIN is checked, so reloading during the check or + * after a wrong one gains nothing. This slows guessing through the app. Someone + * with the disk can skip it, and then only the PIN's length and PBKDF2 stand in the + * way, which is why a PIN has a minimum length and letters are allowed. + */ + +import { sealFor, createSessionOpener, type SealedAnswer } from '@/lib/popout/sealed'; +import { keyFromPin, keyFromPrf, open, PBKDF2_ITERATIONS, randomBytes, seal, type WrappedBytes } from './crypto'; + +export const VAULT_DB = 'bulwark-app-lock'; +const DB_VERSION = 1; +const RECORDS = 'records'; +const SEALED = 'sealed'; +const VAULT_ID = 'vault'; +const ATTEMPTS_ID = 'attempts'; + +export interface PinWrap extends WrappedBytes { + salt: Uint8Array; + iterations: number; +} + +export interface PrfWrap extends WrappedBytes { + credentialId: Uint8Array; + /** What the authenticator's PRF is asked about. */ + salt: Uint8Array; +} + +export interface VaultRecord { + id: typeof VAULT_ID; + pin: PinWrap; + prf?: PrfWrap; + /** For sealing drafts in tabs with no key: anyone may seal, only the lock key opens. */ + sealing: { publicKey: JsonWebKey; privateKey: WrappedBytes }; +} + +export interface AttemptsRecord { + id: typeof ATTEMPTS_ID; + failures: number; + blockedUntil: number; +} + +function promisify(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function done(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + tx.onerror = () => reject(tx.error); + }); +} + +function openDb(): Promise { + const request = indexedDB.open(VAULT_DB, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS, { keyPath: 'id' }); + if (!db.objectStoreNames.contains(SEALED)) db.createObjectStore(SEALED, { keyPath: 'id', autoIncrement: true }); + }; + return promisify(request); +} + +async function withDb(run: (db: IDBDatabase) => Promise): Promise { + const db = await openDb(); + try { + return await run(db); + } finally { + db.close(); + } +} + +async function readRecord(id: string): Promise { + return withDb(async (db) => (await promisify(db.transaction(RECORDS, 'readonly').objectStore(RECORDS).get(id)) as T | undefined) ?? null); +} + +async function writeRecord(record: VaultRecord | AttemptsRecord): Promise { + await withDb(async (db) => { + const tx = db.transaction(RECORDS, 'readwrite'); + tx.objectStore(RECORDS).put(record); + await done(tx); + }); +} + +export function readVault(): Promise { + return readRecord(VAULT_ID); +} + +async function pinWrapOf(pin: string, raw: Uint8Array): Promise { + const salt = randomBytes(32); + const key = await keyFromPin(pin, salt, PBKDF2_ITERATIONS); + return { salt, iterations: PBKDF2_ITERATIONS, ...(await seal(key, raw)) }; +} + +/** A new lock key under this PIN. The caller holds the bytes only long enough to use them. */ +export async function createVault(pin: string): Promise { + const raw = randomBytes(32); + const lockKey = await crypto.subtle.importKey('raw', raw.slice().buffer as ArrayBuffer, 'AES-GCM', false, ['encrypt']); + const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']); + const privateJwk = new TextEncoder().encode(JSON.stringify(await crypto.subtle.exportKey('jwk', pair.privateKey))); + const record: VaultRecord = { + id: VAULT_ID, + pin: await pinWrapOf(pin, raw), + sealing: { + publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), + privateKey: await seal(lockKey, privateJwk), + }, + }; + await writeRecord(record); + await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 }); + return raw; +} + +/** Wrong PINs that cost nothing. A typo or two shouldn't make anyone wait. */ +export const FREE_FAILURES = 4; +const FIRST_WAIT_MS = 30_000; +/** A long wait, and no wipe: forgetting a PIN must not be the way mail gets deleted. */ +export const LONGEST_WAIT_MS = 60 * 60_000; + +/** How long after this many wrong tries the next is allowed. */ +export function waitAfter(failures: number): number { + if (failures <= FREE_FAILURES) return 0; + return Math.min(FIRST_WAIT_MS * 2 ** (failures - FREE_FAILURES - 1), LONGEST_WAIT_MS); +} + +export class PinBlockedError extends Error { + constructor(readonly until: number) { + super('Too many wrong PINs'); + this.name = 'PinBlockedError'; + } +} + +export class WrongPinError extends Error { + constructor() { + super('Wrong PIN'); + this.name = 'WrongPinError'; + } +} + +export async function pinBlockedUntil(now = Date.now()): Promise { + const attempts = await readRecord(ATTEMPTS_ID); + return attempts && attempts.blockedUntil > now ? attempts.blockedUntil : 0; +} + +/** The lock key's bytes for the right PIN. Counts first, checks after. */ +export async function openVaultWithPin(pin: string, now = Date.now()): Promise { + const vault = await readVault(); + if (!vault) throw new WrongPinError(); + const attempts = (await readRecord(ATTEMPTS_ID)) ?? { id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 }; + if (attempts.blockedUntil > now) throw new PinBlockedError(attempts.blockedUntil); + const failures = attempts.failures + 1; + await writeRecord({ id: ATTEMPTS_ID, failures, blockedUntil: now + waitAfter(failures) }); + try { + const key = await keyFromPin(pin, vault.pin.salt, vault.pin.iterations); + const raw = await open(key, vault.pin); + await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 }); + return raw; + } catch { + throw new WrongPinError(); + } +} + +/** Only ever the PIN's own wrap: the lock key and everything under it stay. */ +export async function changeVaultPin(currentPin: string, newPin: string): Promise { + const raw = await openVaultWithPin(currentPin); + try { + const vault = await readVault(); + if (!vault) throw new WrongPinError(); + await writeRecord({ ...vault, pin: await pinWrapOf(newPin, raw) }); + } finally { + raw.fill(0); + } +} + +/** The passkey's way in. `prfOutput` is what the authenticator gave for `salt`. */ +export async function addVaultPrf(raw: Uint8Array, credentialId: Uint8Array, salt: Uint8Array, prfOutput: ArrayBuffer | Uint8Array): Promise { + const vault = await readVault(); + if (!vault) throw new Error('The app lock is not set up'); + await writeRecord({ ...vault, prf: { credentialId, salt, ...(await seal(await keyFromPrf(prfOutput), raw)) } }); +} + +export async function removeVaultPrf(): Promise { + const vault = await readVault(); + if (!vault?.prf) return; + const { prf: _gone, ...rest } = vault; + await writeRecord(rest); +} + +export async function openVaultWithPrf(prfOutput: ArrayBuffer | Uint8Array): Promise { + const vault = await readVault(); + if (!vault?.prf) throw new WrongPinError(); + return open(await keyFromPrf(prfOutput), vault.prf); +} + +/** Deleted at sign-out and when the lock goes off. The store's keys can't depend on it then. */ +export async function deleteVault(): Promise { + await new Promise((resolve) => { + try { + const request = indexedDB.deleteDatabase(VAULT_DB); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + request.onblocked = () => resolve(); + } catch { + resolve(); + } + }); +} + +// ---- Drafts a locking window couldn't save ---- + +interface SealedRow { + id?: number; + sealed: SealedAnswer; +} + +/** Works in any tab, with no key: the public half is enough to seal. */ +export async function sealDraft(state: unknown): Promise { + const vault = await readVault(); + if (!vault) return false; + const sealed = await sealFor(vault.sealing.publicKey, state); + await withDb(async (db) => { + const tx = db.transaction(SEALED, 'readwrite'); + tx.objectStore(SEALED).add({ sealed } satisfies SealedRow); + await done(tx); + }); + return true; +} + +/** Only with the lock key's bytes. Takes the drafts off the list. */ +export async function openSealedDrafts(lockKey: CryptoKey): Promise { + const vault = await readVault(); + if (!vault) return []; + const rows = await withDb((db) => promisify(db.transaction(SEALED, 'readonly').objectStore(SEALED).getAll())) as SealedRow[]; + if (rows.length === 0) return []; + const jwk = JSON.parse(new TextDecoder().decode(await open(lockKey, vault.sealing.privateKey))) as JsonWebKey; + const opener = await createSessionOpener(jwk); + const out: unknown[] = []; + for (const row of rows) { + try { + out.push(await opener.open(row.sealed)); + } catch { + // Sealed under an older key: nothing can read it, and it must not block the rest. + } + } + await withDb(async (db) => { + const tx = db.transaction(SEALED, 'readwrite'); + tx.objectStore(SEALED).clear(); + await done(tx); + }); + return out; +} + +export { keyFromPrf }; +export type { SealedAnswer }; diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts index b0215caab..97dc7ab6f 100644 --- a/lib/compose-flush-registry.ts +++ b/lib/compose-flush-registry.ts @@ -10,7 +10,9 @@ * waits at most `timeoutMs` for all of them together. */ +import type { ComposerDraftData } from '@/components/email/email-composer'; import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { OpenDraft } from '@/lib/desktop/window-restore'; export type ComposeFlush = () => Promise; @@ -103,8 +105,33 @@ export async function flushComposers(timeoutMs: number): Promise { return { total: pending.length, settled, failed, timedOut }; } +/** What an open composer holds, for the app lock: where its text is, and the text if it's nowhere else. */ +export interface ComposeSnapshot { + /** In its own window, whose address has to say what comes back after the lock. */ + ownWindow: boolean; + /** The composer's state, when it has anything worth keeping. */ + state(): ComposerDraftData | null; + /** The saved draft it's on. */ + draft(): OpenDraft | null; +} + +const snapshots = new Map(); + +export function registerComposeSnapshot(snapshot: ComposeSnapshot): () => void { + const id = nextId++; + snapshots.set(id, snapshot); + return () => { + snapshots.delete(id); + }; +} + +export function composeSnapshots(): ComposeSnapshot[] { + return [...snapshots.values()]; +} + /** Test helper: forget every registration. */ export function resetComposeFlushRegistry(): void { flushes.clear(); waiters.clear(); + snapshots.clear(); } diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts new file mode 100644 index 000000000..72432d62b --- /dev/null +++ b/lib/desktop/app-lock-state.ts @@ -0,0 +1,19 @@ +/** + * Whether the app is locked, for everything that would show mail outside the app. + * + * App lock isn't built yet. When it is, its code calls setAppLockCheck once with a + * function that answers from its own state, and calls bridge.os.setLocked on every + * change: main then clears the tray, the jump list and delivered toasts, and strips + * anything that still comes through. Nothing else needs to change. Until then it + * answers false. + */ + +let check: (() => boolean) | null = null; + +export function setAppLockCheck(next: (() => boolean) | null): void { + check = next; +} + +export function isAppLocked(): boolean { + return check?.() === true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index f9eb6109b..94204e1fa 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -272,6 +272,35 @@ export interface DesktopKeystoreBridge { revokeAccess: () => Promise; } +export type DesktopOsUnlockKind = 'hello' | 'touchid'; + +/** + * App lock in the desktop app. Main holds whether it's locked, and nothing a page says can + * lift that: `prove` needs what only a real unlock gives, Windows Hello and Touch ID run in + * main, and `forget` asks natively while locked. A same-origin plugin frame can reach all + * of this through `parent`, so that has to hold on a call alone. + */ +export interface DesktopAppLockBridge { + status: () => Promise<{ locked: boolean; enrolled: boolean; os: DesktopOsUnlockKind | null; osEnrolled: boolean }>; + /** Locks. Anyone may: it only ever makes things more locked. */ + lock: () => void; + /** Sets up the proof. False when one exists. */ + enroll: (proof: Uint8Array) => Promise; + /** Lifts the lock when the proof is the right one. */ + prove: (proof: Uint8Array) => Promise; + /** The lock goes off for good. */ + disable: (proof: Uint8Array) => Promise; + /** The PIN is lost, or everyone signed out. While locked, main asks the user natively. */ + forget: () => Promise; + /** Asks Windows Hello or Touch ID, then keeps the key in the OS's encrypted storage. */ + osEnroll: (proof: Uint8Array, lockKey: Uint8Array) => Promise; + osRemove: () => Promise; + /** Only while locked. Main asks, and after a yes lifts its lock and hands over the key. */ + osUnlock: () => Promise<{ ok: true; key: Uint8Array } | { ok: false; reason: 'unavailable' | 'denied' | 'failed' | 'not-locked' }>; + /** The screen locked, or "Lock now" in the menu or tray. Returns the unsubscribe. */ + onLockRequest: (listener: () => void) => () => void; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -287,6 +316,7 @@ export interface BulwarkDesktopBridge { auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; keystore?: DesktopKeystoreBridge; + appLock?: DesktopAppLockBridge; } declare global { diff --git a/lib/offline/__tests__/outbox.test.ts b/lib/offline/__tests__/outbox.test.ts index 59fe37929..3fa2b552f 100644 --- a/lib/offline/__tests__/outbox.test.ts +++ b/lib/offline/__tests__/outbox.test.ts @@ -10,6 +10,7 @@ import { setStoreBackend } from '@/lib/store/client/api'; import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail'; import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; import { enforcePolicy, startOfflineMode, wipeEverything } from '../offline-mode'; +import { setLockedHere } from '@/lib/app-lock/state'; import { flushQueue } from '../flush'; import { holdFile, readQueue } from '../queue'; import { forgetLegacyMove } from '../migrate-legacy'; @@ -253,6 +254,45 @@ describe('the outbox', () => { }); }); +describe('the app locking during a flush', () => { + it('stops before the next message, and the rest goes after the unlock', async () => { + const sent: unknown[] = []; + const client = fakeClient({ + sendEmail: vi.fn(async (...args: unknown[]) => { + sent.push(args); + // The timer fires while the first send is under way. + if (sent.length === 1) setLockedHere(true); + return { scheduled: false, emailId: `sent-${sent.length}`, emailSubmissionId: `sub-${sent.length}` }; + }), + }); + useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); + writeEnabled(['acc']); + stop = startOfflineMode(); + setOnline(false); + await sendArgs(client, { outbox: true }); + await sendArgs(client, { outbox: true }); + // Only the flush below: nothing starts one of its own when the network comes back. + stop(); + stop = () => {}; + setOnline(true); + expect((await readQueue('acc')).outbox).toHaveLength(2); + (client.sendEmail as ReturnType).mockClear(); + try { + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + } finally { + setLockedHere(false); + } + expect(sent).toHaveLength(1); + const waiting = (await readQueue('acc')).outbox; + expect(waiting).toHaveLength(1); + expect(waiting[0].status).toBe('waiting'); + + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(sent).toHaveLength(2); + expect((await readQueue('acc')).outbox).toHaveLength(0); + }); +}); + describe('a send whose response got lost', () => { async function uncertainSend(client: ReturnType) { useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 812d91ddd..1c4729d67 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -26,6 +26,7 @@ * (a label now required, a send check now required) is held for the user. */ +import { isAppLockedNow } from '@/lib/app-lock/state'; import type { JMAPClient } from '@/lib/jmap/client'; import { isNetworkFailure } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; @@ -363,6 +364,8 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps, continue; } // Another tab took it, or it was deleted meanwhile. + // Locked: the rest goes after the unlock, when the app starts again. + if (isAppLockedNow()) return; if (!(await claimForSending(account, entry.id))) continue; let result: SendEmailResult; try { diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 33b5fff76..883bd4f9e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -22,6 +22,7 @@ * (lost.ts). */ +import { appLockKeyLayers, onStoreKeyMissing } from '@/lib/app-lock/controller'; import { IS_LITE } from '@/lib/lite'; import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount } from '@/lib/device-policy'; @@ -173,6 +174,8 @@ function noteStoreUnavailable(): void { } const storeHooks: StorePageHooks = { + keyLayers: appLockKeyLayers, + onKeyLocked: onStoreKeyMissing, inputs(account) { const client = onlineClientFor(account); return { diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts index eb3c78134..eab983baf 100644 --- a/lib/popout/channel.ts +++ b/lib/popout/channel.ts @@ -12,6 +12,7 @@ */ import type { ComposerDraftData } from '@/components/email/email-composer'; +import { isAppLockedNow } from '@/lib/app-lock/state'; import type { MessagePriority } from '@/lib/message-priority'; import type { ComposePopoutPayload } from './compose-payload'; import { canSealSessions, createSessionOpener, credentialFingerprint, type SealedAnswer } from './sealed'; @@ -154,7 +155,7 @@ export async function requestWindowSession( options: { accountId?: string; rejectedAuthHeader?: string } = {}, timeoutMs: number = SESSION_ANSWER_MS, ): Promise { - if (!canSealSessions()) return null; + if (!canSealSessions() || isAppLockedNow()) return null; const opener = await createSessionOpener(); const rejectedFingerprint = options.rejectedAuthHeader ? await credentialFingerprint(options.rejectedAuthHeader) : undefined; return new Promise((resolve) => { @@ -167,6 +168,11 @@ export async function requestWindowSession( }; const channel = openPopoutChannel((message) => { if (message.type !== 'session-answer' || message.requestId !== requestId) return; + // Locked while the answer was on its way: it is not opened. + if (isAppLockedNow()) { + finish(null); + return; + } void opener.open(message.sealed).then(finish, () => finish(null)); }); if (!channel) { diff --git a/lib/popout/sealed.ts b/lib/popout/sealed.ts index 42fa55e5c..342d01a0a 100644 --- a/lib/popout/sealed.ts +++ b/lib/popout/sealed.ts @@ -46,10 +46,15 @@ async function deriveKey(privateKey: CryptoKey, peer: JsonWebKey): Promise { - const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']); - const publicKey = await crypto.subtle.exportKey('jwk', pair.publicKey); +/** + * The asking window's side: a key to send along, and what opens the answer. With + * `stored`, a private key kept elsewhere (app lock's sealed drafts) opens instead. + */ +export async function createSessionOpener(stored?: JsonWebKey): Promise { + const pair = stored + ? { privateKey: await crypto.subtle.importKey('jwk', stored, ECDH, false, ['deriveKey']), publicKey: null } + : await crypto.subtle.generateKey(ECDH, false, ['deriveKey']); + const publicKey = pair.publicKey ? await crypto.subtle.exportKey('jwk', pair.publicKey) : {}; return { publicKey, async open(sealed: SealedAnswer): Promise { diff --git a/lib/settings-search.ts b/lib/settings-search.ts index c3924f6d7..982b15062 100644 --- a/lib/settings-search.ts +++ b/lib/settings-search.ts @@ -71,6 +71,8 @@ export const tabSearchEntries: Record = { { label: 'settings.language_region.time_format.label' }, { label: 'settings.language_region.time_zone.label' }, { label: 'settings.language_region.first_day.label' }, + { label: 'appLock.section_title' }, + { label: 'appLock.enable_label' }, { label: 'protocol_handlers.title' }, { label: 'protocol_handlers.mailto_label' }, { label: 'protocol_handlers.webcal_label' }, @@ -373,7 +375,7 @@ export const ARCHIVE_ACTION_SEARCH_PATHS: ReadonlySet = new Set(['settin export const tabKeywords: Record = { account: 'profile email user signin signout reorder rearrange drag dropdown switcher multi-account identities from address signature plus addressing unified mailbox shared group', - general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding', + general: 'language locale region timezone date time format mailto webcal links default app protocol handler tour onboarding lock pin passkey windows hello touch id', notifications: 'sound alert push badge favicon tab icon unread count background relay device', appearance: 'theme dark light font size accent color animation density custom theme css skin toolbar sidebar account switcher icons rail layout reading pane', accessibility: 'a11y accessible vision blind low vision screen reader contrast motion animation reduce zoom text size font dyslexia spacing keyboard shortcuts focus outline cursor pointer targets colour blind color sound visual alert hearing deaf simple plain language read aloud', diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 531c99f7f..aff8c65e1 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -1,3 +1,4 @@ +import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out'; import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; @@ -136,6 +137,8 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { } forgetOfflineShell(); } + // With no store left, the app lock has nothing to guard, and a next person shouldn't meet it. + if (!options.keepLocalStore) forgetAppLockAtSignOut(); // Files and links on their way into a draft or an import. clearHandoffs(); for (const cleanup of signedOutCleanups) { diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index d339ace69..151818095 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -7,7 +7,7 @@ import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area'; import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport'; -import { forgetAccountKey, getAccountRecord, knownStoreFiles, openAccountKey, type WrapLayer } from './keys'; +import { forgetAccountKey, getAccountRecord, knownStoreFiles, listAccountRecords, openAccountKey, type StoreKeyLayerMissingError, type WrapLayer } from './keys'; import { StoreClient } from './store-client'; export interface StoreAccountInputs { @@ -27,6 +27,8 @@ export interface StorePageHooks { keyLayers?(): readonly WrapLayer[]; /** An account this device kept mail for has an empty store now: its key or its file went. */ onStoreLost?(account: string): void; + /** This tab became the owner and has no lock key: the app lock has to ask again. */ + onKeyLocked?(): void; } /** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */ @@ -45,6 +47,22 @@ export interface StoreBackend { remove(account: string): Promise; /** Stops this tab's worker at once (a sign-out; the files go separately). */ stop(): void; + /** + * App lock: closes the databases and stops this tab's worker, with every key it had. + * Waits for a clean close at most `closeMs`. Until `unlock`, every call is refused. + */ + lock?(closeMs: number): Promise; + /** This tab has the key again: its worker starts first, and takes the store. */ + unlock?(ownerWaitMs: number): Promise; + ownsStoreHere?(): boolean; +} + +/** The app is locked: the store is closed, and its key isn't in memory anywhere. */ +export class StoreLockedError extends Error { + constructor() { + super('The local store is closed while the app is locked'); + this.name = 'StoreLockedError'; + } } /** How long a call waits for the owner to have opened an account. */ @@ -88,6 +106,8 @@ class PageStore implements StoreBackend { private readonly keyJustMade = new Set(); /** What the owner's engine was last told to keep. */ private readonly sentScope = new Map(); + private locked = false; + private ownerReady: (() => void) | null = null; constructor(private readonly hooks: () => StorePageHooks | null) {} @@ -96,6 +116,7 @@ class PageStore implements StoreBackend { } private started(): StoreClient { + if (this.locked) throw new StoreLockedError(); if (this.client) return this.client; this.broken = false; const client = new StoreClient({ @@ -106,6 +127,7 @@ class PageStore implements StoreBackend { this.hooks()?.onEvent(event); }, onOwner: () => { + this.ownerReady?.(); this.opening.clear(); for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {}); }, @@ -129,13 +151,22 @@ class PageStore implements StoreBackend { this.broken = false; } + private async readKey(account: string): ReturnType { + try { + return await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + } catch (error) { + if ((error as Error)?.name === 'StoreKeyLayerMissingError' && (error as StoreKeyLayerMissingError).layer === 'applock') this.hooks()?.onKeyLocked?.(); + throw error; + } + } + /** Only the owner may open: the key goes to this tab's own worker, never over the channel. */ private openHere(account: string): Promise { const running = this.opening.get(account); if (running) return running; const run = (async () => { const client = this.started(); - const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + const { keyHex, record, fresh } = await this.readKey(account); if (fresh) this.keyJustMade.add(account); // The old file, if the key alone went, can never be read again. if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {}); @@ -161,7 +192,45 @@ class PageStore implements StoreBackend { return run; } + async lock(closeMs: number): Promise { + this.locked = true; + const client = this.client; + this.client = null; + this.opening.clear(); + this.sentScope.clear(); + this.keyJustMade.clear(); + if (!client) return; + let timer: ReturnType | undefined; + await Promise.race([ + client.release().catch(() => {}), + new Promise((resolve) => { timer = setTimeout(resolve, closeMs); }), + ]); + clearTimeout(timer); + // Past the wait a sync may still be running. Stopping the worker ends it, as a crash would. + client.halt(); + } + + async unlock(ownerWaitMs: number): Promise { + this.locked = false; + if (this.client?.ownsStore || !StoreClient.supported()) return; + if ((await listAccountRecords().catch(() => [])).length === 0) return; + await new Promise((resolve) => { + const timer = setTimeout(resolve, ownerWaitMs); + this.ownerReady = () => { + clearTimeout(timer); + this.ownerReady = null; + resolve(); + }; + this.started(); + }); + } + + ownsStoreHere(): boolean { + return this.client?.ownsStore ?? false; + } + async ensureOpen(account: string, session?: StoreSession | null): Promise { + if (this.locked) throw new StoreLockedError(); this.wanted.set(account, session ?? this.wanted.get(account) ?? null); const client = this.started(); if (client.ownsStore) await this.openHere(account); @@ -186,6 +255,7 @@ class PageStore implements StoreBackend { /** A call for an account: the owner may still be opening it, in this tab or another. */ private async whenOpen(account: string, call: (client: StoreClient) => Promise): Promise { + if (this.locked) throw new StoreLockedError(); if (this.broken) throw new Error('The local store could not start'); await this.ensureOpen(account); for (let attempt = 0; ; attempt += 1) { @@ -215,7 +285,7 @@ class PageStore implements StoreBackend { } async setOnline(online: boolean): Promise { - if (!this.client || this.broken) return; + if (this.locked || !this.client || this.broken) return; await this.client.setOnline(online); } diff --git a/locales/ar/common.json b/locales/ar/common.json index b19d3aeeb..6bcc9416a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "تعذّر نسخ الملف." } }, + "appLock": { + "section_title": "قفل التطبيق", + "section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.", + "enable_label": "القفل برمز PIN", + "enable_desc": "متوقف افتراضيًا. يبقى رمز PIN على هذا الجهاز.", + "enable_body": "اختر رمز PIN. ستحتاج إليه كلما قُفل التطبيق، وبعد إعادة التحميل أو بدء التشغيل من جديد.", + "set_pin_title": "اختر رمز PIN", + "change_pin": "تغيير رمز PIN…", + "change_pin_title": "تغيير رمز PIN", + "pin_label": "رمز PIN", + "pin_current": "رمز PIN الحالي", + "pin_new": "رمز PIN الجديد", + "pin_repeat": "أعد إدخال رمز PIN", + "pin_hint": "6 أحرف على الأقل. يمكن استخدام الحروف أيضًا، والرمز الأطول أصعب في التخمين.", + "pin_mismatch": "الرمزان غير متطابقين.", + "pin_recovery": "إذا نسيته، فالحل الوحيد هو إزالة البيانات من هذا الجهاز وتسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.", + "save": "حفظ", + "cancel": "إلغاء", + "save_failed": "لم ينجح ذلك. حاول مرة أخرى.", + "timeout_label": "القفل بعد", + "timeout_desc": "الوقت دون استخدام في أي نافذة أو علامة تبويب.", + "lock_now": "القفل الآن", + "lock_now_desc": "يقفل كل النوافذ وعلامات التبويب فورًا.", + "disable_title": "إيقاف قفل التطبيق؟", + "disable_body": "أدخل رمز PIN لإيقافه.", + "disable_confirm": "إيقاف", + "passkey_label": "مفتاح المرور", + "passkey_desc": "افتح القفل بمفتاح مرور أو مفتاح أمان يستطيع حماية المفتاح. لا تستطيع كلها ذلك، ويبقى رمز PIN صالحًا.", + "passkey_add": "إضافة مفتاح مرور…", + "passkey_add_title": "إضافة مفتاح مرور", + "passkey_remove": "إزالة", + "passkey_added": "تمت إضافة مفتاح المرور.", + "passkey_no_prf": "لا يستطيع مفتاح المرور هذا حماية المفتاح. يبقى رمز PIN صالحًا.", + "passkey_failed": "لم يعمل مفتاح المرور. أدخل رمز PIN.", + "use_passkey": "استخدام مفتاح مرور", + "use_hello": "استخدام Windows Hello", + "use_touchid": "استخدام Touch ID", + "os_add": "تشغيل…", + "os_add_title": "استخدام {method}", + "os_remove": "إيقاف", + "os_desc": "{method} فحص يجريه التطبيق. بعد الموافقة يقرأ التطبيق المفتاح من التخزين المشفر لهذا الحاسوب. يمكن لبرنامج يعمل تحت حساب المستخدم الخاص بك قراءته هناك دون {method}، لذلك يحمي رمز PIN أكثر. يبقى رمز PIN صالحًا.", + "os_failed": "لم ينجح ذلك. أدخل رمز PIN.", + "lock_title": "{app} مقفل", + "locking": "جارٍ القفل…", + "pin_prompt": "أدخل رمز PIN للمتابعة.", + "unlock": "فتح القفل", + "unlocking": "جارٍ فتح القفل…", + "wrong_pin": "رمز PIN هذا غير صحيح.", + "unlock_failed": "تعذر فتح القفل. حاول مرة أخرى.", + "blocked_later": "محاولات كثيرة جدًا. حاول لاحقًا.", + "forgot": "هل نسيت رمز PIN؟", + "forgot_title": "إزالة البيانات المحلية وتسجيل الخروج؟", + "forgot_body": "لا يمكن استعادة رمز PIN المنسي. يزيل هذا البريد المحفوظ على هذا الجهاز ويسجل خروج كل الحسابات لتتمكن من تسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.", + "forgot_confirm": "إزالة وتسجيل الخروج", + "unlock_in_main": "افتح القفل في النافذة الرئيسية.", + "open_main": "الانتقال إلى النافذة الرئيسية", + "setup_required_title": "تشترط مؤسستك قفل التطبيق", + "setup_required_body": "اختر رمز PIN. ستحتاج إليه لفتح {app} بعد قفله.", + "draft_saved_notice": "حُفظ ما كنت تكتبه في المسودات قبل القفل.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتان} few {# دقائق} many {# دقيقة} other {# دقيقة}}", + "blocked_seconds": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# ثانية} one {ثانية واحدة} two {ثانيتين} few {# ثوانٍ} many {# ثانية} other {# ثانية}}.", + "blocked_minutes": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}." + }, "desktop_sign_in": { "browser_button": "سجّل الدخول باستخدام المتصفح", "browser_waiting": "أكمل تسجيل الدخول في المتصفح", diff --git a/locales/ca/common.json b/locales/ca/common.json index b4c960e21..45c349593 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "No s’ha pogut copiar el fitxer." } }, + "appLock": { + "section_title": "Bloqueig de l'app", + "section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.", + "enable_label": "Bloqueja amb un PIN", + "enable_desc": "Desactivat per defecte. El PIN es queda en aquest dispositiu.", + "enable_body": "Trieu un PIN. El necessitareu cada vegada que l'app es bloquegi, i després de recarregar o tornar a iniciar.", + "set_pin_title": "Trieu un PIN", + "change_pin": "Canvia el PIN…", + "change_pin_title": "Canvia el PIN", + "pin_label": "PIN", + "pin_current": "PIN actual", + "pin_new": "PIN nou", + "pin_repeat": "Repetiu el PIN", + "pin_hint": "Almenys 6 caràcters. També serveixen lletres, i un PIN més llarg és més difícil d'endevinar.", + "pin_mismatch": "Els dos PIN no coincideixen.", + "pin_recovery": "Si l'oblideu, l'única sortida és eliminar les dades d'aquest dispositiu i tornar a iniciar la sessió. El correu al servidor es manté. Els missatges que encara esperen l'enviament en aquest dispositiu es perden.", + "save": "Desa", + "cancel": "Cancel·la", + "save_failed": "No ha funcionat. Torneu-ho a provar.", + "timeout_label": "Bloqueja després de", + "timeout_desc": "Temps sense ús en qualsevol finestra o pestanya.", + "lock_now": "Bloqueja ara", + "lock_now_desc": "Bloqueja totes les finestres i pestanyes de cop.", + "disable_title": "Voleu desactivar el bloqueig?", + "disable_body": "Escriviu el PIN per desactivar-lo.", + "disable_confirm": "Desactiva", + "passkey_label": "Clau d'accés", + "passkey_desc": "Desbloquegeu amb una clau d'accés o de seguretat que pugui protegir la clau. No totes poden, i el PIN continua funcionant.", + "passkey_add": "Afegeix una clau d'accés…", + "passkey_add_title": "Afegeix una clau d'accés", + "passkey_remove": "Elimina", + "passkey_added": "S'ha afegit la clau d'accés.", + "passkey_no_prf": "Aquesta clau d'accés no pot protegir la clau. El PIN continua funcionant.", + "passkey_failed": "La clau d'accés no ha funcionat. Escriviu el PIN.", + "use_passkey": "Utilitza una clau d'accés", + "use_hello": "Utilitza Windows Hello", + "use_touchid": "Utilitza Touch ID", + "os_add": "Activa…", + "os_add_title": "Utilitza {method}", + "os_remove": "Desactiva", + "os_desc": "{method} és una comprovació de l'app. Després d'un sí, l'app llegeix la clau de l'emmagatzematge xifrat d'aquest ordinador. Un programa que s'executi amb el vostre compte d'usuari la podria llegir allà sense {method}, així que el PIN protegeix més. El PIN continua funcionant.", + "os_failed": "No ha funcionat. Escriviu el PIN.", + "lock_title": "{app} està bloquejada", + "locking": "S'està bloquejant…", + "pin_prompt": "Escriviu el PIN per continuar.", + "unlock": "Desbloqueja", + "unlocking": "S'està desbloquejant…", + "wrong_pin": "Aquest PIN no és correcte.", + "unlock_failed": "No s'ha pogut desbloquejar. Torneu-ho a provar.", + "blocked_later": "Massa intents. Torneu-ho a provar més tard.", + "forgot": "Heu oblidat el PIN?", + "forgot_title": "Voleu eliminar les dades locals i tancar la sessió?", + "forgot_body": "Un PIN oblidat no es pot recuperar. Això elimina el correu desat en aquest dispositiu i tanca la sessió de tots els comptes, perquè pugueu tornar a entrar. El correu al servidor es manté. Els missatges que encara esperen l'enviament en aquest dispositiu es perden.", + "forgot_confirm": "Elimina i tanca la sessió", + "unlock_in_main": "Desbloquegeu a la finestra principal.", + "open_main": "Vés a la finestra principal", + "setup_required_title": "La vostra organització exigeix un bloqueig de l'app", + "setup_required_body": "Trieu un PIN. El necessitareu per obrir {app} quan es bloquegi.", + "draft_saved_notice": "El que escrivíeu s'ha desat a Esborranys abans del bloqueig.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minut} other {# minuts}}", + "blocked_seconds": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# segon} other {# segons}}.", + "blocked_minutes": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# minut} other {# minuts}}." + }, "desktop_sign_in": { "browser_button": "Inicia la sessió amb el navegador", "browser_waiting": "Acaba d'iniciar la sessió al navegador", diff --git a/locales/cs/common.json b/locales/cs/common.json index 28c8b0968..ae73e3d6b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Soubor se nepodařilo zkopírovat." } }, + "appLock": { + "section_title": "Zámek aplikace", + "section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.", + "enable_label": "Zamykat kódem PIN", + "enable_desc": "Ve výchozím stavu vypnuto. PIN zůstává na tomto zařízení.", + "enable_body": "Zvolte PIN. Budete ho potřebovat při každém zamčení aplikace a po načtení znovu nebo novém spuštění.", + "set_pin_title": "Zvolte PIN", + "change_pin": "Změnit PIN…", + "change_pin_title": "Změnit PIN", + "pin_label": "PIN", + "pin_current": "Současný PIN", + "pin_new": "Nový PIN", + "pin_repeat": "Zopakujte PIN", + "pin_hint": "Alespoň 6 znaků. Fungují i písmena a delší PIN se hůř uhodne.", + "pin_mismatch": "Oba kódy PIN nejsou stejné.", + "pin_recovery": "Pokud ho zapomenete, jedinou cestou je odebrat data z tohoto zařízení a znovu se přihlásit. Vaše pošta na serveru zůstává. Zprávy čekající na odeslání na tomto zařízení se ztratí.", + "save": "Uložit", + "cancel": "Zrušit", + "save_failed": "Nepodařilo se to. Zkuste to znovu.", + "timeout_label": "Zamknout po", + "timeout_desc": "Čas bez použití v kterémkoli okně nebo kartě.", + "lock_now": "Zamknout teď", + "lock_now_desc": "Zamkne hned všechna okna a karty.", + "disable_title": "Vypnout zámek aplikace?", + "disable_body": "Pro vypnutí zadejte PIN.", + "disable_confirm": "Vypnout", + "passkey_label": "Přístupový klíč", + "passkey_desc": "Odemkněte přístupovým nebo bezpečnostním klíčem, který umí chránit klíč. Ne všechny to umí a PIN dál funguje.", + "passkey_add": "Přidat přístupový klíč…", + "passkey_add_title": "Přidat přístupový klíč", + "passkey_remove": "Odebrat", + "passkey_added": "Přístupový klíč byl přidán.", + "passkey_no_prf": "Tento přístupový klíč nemůže chránit klíč. PIN dál funguje.", + "passkey_failed": "Přístupový klíč nefungoval. Zadejte PIN.", + "use_passkey": "Použít přístupový klíč", + "use_hello": "Použít Windows Hello", + "use_touchid": "Použít Touch ID", + "os_add": "Zapnout…", + "os_add_title": "Použít {method}", + "os_remove": "Vypnout", + "os_desc": "{method} je kontrola, kterou provádí aplikace. Po potvrzení aplikace přečte klíč ze šifrovaného úložiště tohoto počítače. Program běžící pod vaším uživatelským účtem by ho tam mohl přečíst i bez toho, takže PIN chrání víc. PIN dál funguje.", + "os_failed": "Nepodařilo se to. Zadejte PIN.", + "lock_title": "{app} je zamčená", + "locking": "Zamykání…", + "pin_prompt": "Pokračujte zadáním kódu PIN.", + "unlock": "Odemknout", + "unlocking": "Odemykání…", + "wrong_pin": "Tento PIN není správný.", + "unlock_failed": "Odemknutí se nepodařilo. Zkuste to znovu.", + "blocked_later": "Příliš mnoho pokusů. Zkuste to později.", + "forgot": "Zapomněli jste PIN?", + "forgot_title": "Odebrat místní data a odhlásit se?", + "forgot_body": "Zapomenutý PIN nelze obnovit. Tím se odebere pošta uložená na tomto zařízení a odhlásí se všechny účty, abyste se mohli znovu přihlásit. Vaše pošta na serveru zůstává. Zprávy čekající na odeslání na tomto zařízení se ztratí.", + "forgot_confirm": "Odebrat a odhlásit", + "unlock_in_main": "Odemkněte v hlavním okně.", + "open_main": "Přejít do hlavního okna", + "setup_required_title": "Vaše organizace vyžaduje zámek aplikace", + "setup_required_body": "Zvolte PIN. Budete ho potřebovat k otevření {app} po zamčení.", + "draft_saved_notice": "To, co jste psali, se před zamčením uložilo do konceptů.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuta} few {# minuty} many {# minuty} other {# minut}}", + "blocked_seconds": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# sekundu} few {# sekundy} many {# sekundy} other {# sekund}}.", + "blocked_minutes": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# minutu} few {# minuty} many {# minuty} other {# minut}}." + }, "desktop_sign_in": { "browser_button": "Přihlásit se v prohlížeči", "browser_waiting": "Dokončete přihlášení v prohlížeči", diff --git a/locales/da/common.json b/locales/da/common.json index e47034d0e..0eeda37fb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Filen kunne ikke kopieres." } }, + "appLock": { + "section_title": "Applås", + "section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.", + "enable_label": "Lås med en pinkode", + "enable_desc": "Slået fra som standard. Pinkoden bliver på denne enhed.", + "enable_body": "Vælg en pinkode. Du skal bruge den, hver gang appen låser, og efter en genindlæsning eller ny start.", + "set_pin_title": "Vælg en pinkode", + "change_pin": "Skift pinkode…", + "change_pin_title": "Skift pinkoden", + "pin_label": "Pinkode", + "pin_current": "Nuværende pinkode", + "pin_new": "Ny pinkode", + "pin_repeat": "Gentag pinkoden", + "pin_hint": "Mindst 6 tegn. Bogstaver virker også, og en længere pinkode er sværere at gætte.", + "pin_mismatch": "De to pinkoder er ikke ens.", + "pin_recovery": "Hvis du glemmer den, er eneste udvej at fjerne dataene på denne enhed og logge ind igen. Din mail på serveren bliver. Beskeder, der stadig venter på at blive sendt fra denne enhed, går tabt.", + "save": "Gem", + "cancel": "Annuller", + "save_failed": "Det virkede ikke. Prøv igen.", + "timeout_label": "Lås efter", + "timeout_desc": "Tid uden brug i et vindue eller en fane.", + "lock_now": "Lås nu", + "lock_now_desc": "Låser alle vinduer og faner med det samme.", + "disable_title": "Slå applåsen fra?", + "disable_body": "Indtast din pinkode for at slå den fra.", + "disable_confirm": "Slå fra", + "passkey_label": "Adgangsnøgle", + "passkey_desc": "Lås op med en adgangsnøgle eller sikkerhedsnøgle, der kan beskytte nøglen. Det kan ikke alle, og pinkoden virker stadig.", + "passkey_add": "Tilføj en adgangsnøgle…", + "passkey_add_title": "Tilføj en adgangsnøgle", + "passkey_remove": "Fjern", + "passkey_added": "Adgangsnøglen er tilføjet.", + "passkey_no_prf": "Denne adgangsnøgle kan ikke beskytte nøglen. Din pinkode virker stadig.", + "passkey_failed": "Adgangsnøglen virkede ikke. Indtast din pinkode.", + "use_passkey": "Brug en adgangsnøgle", + "use_hello": "Brug Windows Hello", + "use_touchid": "Brug Touch ID", + "os_add": "Slå til…", + "os_add_title": "Brug {method}", + "os_remove": "Slå fra", + "os_desc": "{method} er en kontrol, appen foretager. Efter et ja læser appen nøglen fra computerens krypterede lager. Et program under din brugerkonto kan læse den der uden {method}, så pinkoden beskytter mere. Pinkoden virker stadig.", + "os_failed": "Det virkede ikke. Indtast din pinkode.", + "lock_title": "{app} er låst", + "locking": "Låser…", + "pin_prompt": "Indtast din pinkode for at fortsætte.", + "unlock": "Lås op", + "unlocking": "Låser op…", + "wrong_pin": "Den pinkode er ikke rigtig.", + "unlock_failed": "Oplåsning virkede ikke. Prøv igen.", + "blocked_later": "For mange forsøg. Prøv igen senere.", + "forgot": "Glemt din pinkode?", + "forgot_title": "Fjern lokale data og log ud?", + "forgot_body": "En glemt pinkode kan ikke gendannes. Dette fjerner mail gemt på denne enhed og logger alle konti ud, så du kan logge ind igen. Din mail på serveren bliver. Beskeder, der stadig venter på at blive sendt fra denne enhed, går tabt.", + "forgot_confirm": "Fjern og log ud", + "unlock_in_main": "Lås op i hovedvinduet.", + "open_main": "Gå til hovedvinduet", + "setup_required_title": "Din organisation kræver en applås", + "setup_required_body": "Vælg en pinkode. Du skal bruge den for at åbne {app}, når den er låst.", + "draft_saved_notice": "Det, du skrev, blev gemt i Kladder, før appen låste.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minut} other {# minutter}}", + "blocked_seconds": "For mange forsøg. Prøv igen om {count, plural, one {# sekund} other {# sekunder}}.", + "blocked_minutes": "For mange forsøg. Prøv igen om {count, plural, one {# minut} other {# minutter}}." + }, "desktop_sign_in": { "browser_button": "Log ind med din browser", "browser_waiting": "Gør login færdigt i din browser", diff --git a/locales/de/common.json b/locales/de/common.json index ca3134e5d..558ac8c90 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Die Datei konnte nicht kopiert werden." } }, + "appLock": { + "section_title": "App-Sperre", + "section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.", + "enable_label": "Mit PIN sperren", + "enable_desc": "Standardmäßig aus. Die PIN bleibt auf diesem Gerät.", + "enable_body": "Wählen Sie eine PIN. Sie brauchen sie jedes Mal, wenn die App sperrt, und nach einem Neuladen oder Neustart.", + "set_pin_title": "PIN wählen", + "change_pin": "PIN ändern…", + "change_pin_title": "PIN ändern", + "pin_label": "PIN", + "pin_current": "Aktuelle PIN", + "pin_new": "Neue PIN", + "pin_repeat": "PIN wiederholen", + "pin_hint": "Mindestens 6 Zeichen. Buchstaben gehen auch, und eine längere PIN ist schwerer zu erraten.", + "pin_mismatch": "Die beiden PINs sind nicht gleich.", + "pin_recovery": "Wenn Sie sie vergessen, müssen Sie die Daten auf diesem Gerät entfernen und sich neu anmelden. Ihre E-Mails auf dem Server bleiben. Nachrichten, die auf diesem Gerät noch auf den Versand warten, gehen verloren.", + "save": "Speichern", + "cancel": "Abbrechen", + "save_failed": "Das hat nicht geklappt. Versuchen Sie es noch einmal.", + "timeout_label": "Sperren nach", + "timeout_desc": "Zeit ohne Nutzung in irgendeinem Fenster oder Tab.", + "lock_now": "Jetzt sperren", + "lock_now_desc": "Sperrt alle Fenster und Tabs sofort.", + "disable_title": "App-Sperre ausschalten?", + "disable_body": "Geben Sie Ihre PIN ein, um sie auszuschalten.", + "disable_confirm": "Ausschalten", + "passkey_label": "Passkey", + "passkey_desc": "Entsperren mit einem Passkey oder Sicherheitsschlüssel, der den Schlüssel schützen kann. Das können nicht alle, und die PIN funktioniert weiter.", + "passkey_add": "Passkey hinzufügen…", + "passkey_add_title": "Passkey hinzufügen", + "passkey_remove": "Entfernen", + "passkey_added": "Passkey hinzugefügt.", + "passkey_no_prf": "Dieser Passkey kann den Schlüssel nicht schützen. Ihre PIN funktioniert weiter.", + "passkey_failed": "Der Passkey hat nicht funktioniert. Geben Sie stattdessen Ihre PIN ein.", + "use_passkey": "Passkey verwenden", + "use_hello": "Windows Hello verwenden", + "use_touchid": "Touch ID verwenden", + "os_add": "Einschalten…", + "os_add_title": "{method} verwenden", + "os_remove": "Ausschalten", + "os_desc": "{method} ist eine Prüfung durch die App. Nach einem Ja liest die App den Schlüssel aus dem verschlüsselten Speicher dieses Computers. Ein Programm unter Ihrem Benutzerkonto könnte ihn dort auch ohne {method} lesen, die PIN schützt also mehr. Die PIN funktioniert weiter.", + "os_failed": "Das hat nicht geklappt. Geben Sie stattdessen Ihre PIN ein.", + "lock_title": "{app} ist gesperrt", + "locking": "Wird gesperrt…", + "pin_prompt": "Geben Sie Ihre PIN ein, um fortzufahren.", + "unlock": "Entsperren", + "unlocking": "Wird entsperrt…", + "wrong_pin": "Diese PIN stimmt nicht.", + "unlock_failed": "Das Entsperren hat nicht geklappt. Versuchen Sie es noch einmal.", + "blocked_later": "Zu viele Versuche. Versuchen Sie es später noch einmal.", + "forgot": "PIN vergessen?", + "forgot_title": "Lokale Daten entfernen und abmelden?", + "forgot_body": "Eine vergessene PIN lässt sich nicht wiederherstellen. Dies entfernt die E-Mails auf diesem Gerät und meldet alle Konten ab, damit Sie sich neu anmelden können. Ihre E-Mails auf dem Server bleiben. Nachrichten, die auf diesem Gerät noch auf den Versand warten, gehen verloren.", + "forgot_confirm": "Entfernen und abmelden", + "unlock_in_main": "Entsperren Sie im Hauptfenster.", + "open_main": "Zum Hauptfenster", + "setup_required_title": "Ihre Organisation verlangt eine App-Sperre", + "setup_required_body": "Wählen Sie eine PIN. Sie brauchen sie, um {app} nach dem Sperren zu öffnen.", + "draft_saved_notice": "Was Sie geschrieben haben, wurde vor dem Sperren in den Entwürfen gespeichert.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# Minute} other {# Minuten}}", + "blocked_seconds": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Sekunde} other {# Sekunden}} noch einmal.", + "blocked_minutes": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Minute} other {# Minuten}} noch einmal." + }, "desktop_sign_in": { "browser_button": "Mit dem Browser anmelden", "browser_waiting": "Schließen Sie die Anmeldung im Browser ab", diff --git a/locales/en/common.json b/locales/en/common.json index e178107a0..b45b905f7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "The file could not be copied." } }, + "appLock": { + "section_title": "App lock", + "section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.", + "enable_label": "Lock with a PIN", + "enable_desc": "Off by default. The PIN stays on this device.", + "enable_body": "Choose a PIN. You need it each time the app locks, and after a reload or a new start.", + "set_pin_title": "Choose a PIN", + "change_pin": "Change PIN…", + "change_pin_title": "Change the PIN", + "pin_label": "PIN", + "pin_current": "Current PIN", + "pin_new": "New PIN", + "pin_repeat": "Repeat the PIN", + "pin_hint": "At least 6 characters. Letters work too, and a longer PIN is harder to guess.", + "pin_mismatch": "The two PINs are not the same.", + "pin_recovery": "If you forget it, the only way back is to remove the data on this device and sign in again. Your mail on the server stays. Messages still waiting to be sent on this device are lost.", + "save": "Save", + "cancel": "Cancel", + "save_failed": "That did not work. Try again.", + "timeout_label": "Lock after", + "timeout_desc": "Time without use in any window or tab.", + "lock_now": "Lock now", + "lock_now_desc": "Locks all windows and tabs at once.", + "disable_title": "Turn off the app lock?", + "disable_body": "Enter your PIN to turn it off.", + "disable_confirm": "Turn off", + "passkey_label": "Passkey", + "passkey_desc": "Unlock with a passkey or security key that can protect the key. Not all can, and the PIN keeps working.", + "passkey_add": "Add a passkey…", + "passkey_add_title": "Add a passkey", + "passkey_remove": "Remove", + "passkey_added": "Passkey added.", + "passkey_no_prf": "This passkey can't protect the key. Your PIN keeps working.", + "passkey_failed": "The passkey did not work. Enter your PIN instead.", + "use_passkey": "Use a passkey", + "use_hello": "Use Windows Hello", + "use_touchid": "Use Touch ID", + "os_add": "Turn on…", + "os_add_title": "Use {method}", + "os_remove": "Turn off", + "os_desc": "{method} is a check by the app. After a yes, the app reads the key from this computer's encrypted storage. A program running under your user account could read it there without {method}, so the PIN protects more. The PIN keeps working.", + "os_failed": "That did not work. Enter your PIN instead.", + "lock_title": "{app} is locked", + "locking": "Locking…", + "pin_prompt": "Enter your PIN to continue.", + "unlock": "Unlock", + "unlocking": "Unlocking…", + "wrong_pin": "That PIN is not right.", + "unlock_failed": "Unlocking did not work. Try again.", + "blocked_later": "Too many tries. Try again later.", + "forgot": "Forgot your PIN?", + "forgot_title": "Remove local data and sign out?", + "forgot_body": "A forgotten PIN can't be recovered. This removes the mail kept on this device and signs out all accounts, so you can sign in again. Your mail on the server stays. Messages still waiting to be sent on this device are lost.", + "forgot_confirm": "Remove and sign out", + "unlock_in_main": "Unlock in the main window.", + "open_main": "Go to the main window", + "setup_required_title": "Your organisation requires an app lock", + "setup_required_body": "Choose a PIN. You need it to open {app} after it locks.", + "draft_saved_notice": "What you were writing was saved to Drafts before the app locked.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minute} other {# minutes}}", + "blocked_seconds": "Too many tries. Try again in {count, plural, one {# second} other {# seconds}}.", + "blocked_minutes": "Too many tries. Try again in {count, plural, one {# minute} other {# minutes}}." + }, "desktop_sign_in": { "browser_button": "Sign in with your browser", "browser_waiting": "Finish signing in in your browser", diff --git a/locales/es/common.json b/locales/es/common.json index 92ae5cfc1..9169ff9e2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "No se pudo copiar el archivo." } }, + "appLock": { + "section_title": "Bloqueo de la app", + "section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.", + "enable_label": "Bloquear con un PIN", + "enable_desc": "Desactivado de forma predeterminada. El PIN se queda en este dispositivo.", + "enable_body": "Elija un PIN. Lo necesitará cada vez que la app se bloquee, y tras recargar o volver a iniciar.", + "set_pin_title": "Elegir un PIN", + "change_pin": "Cambiar PIN…", + "change_pin_title": "Cambiar el PIN", + "pin_label": "PIN", + "pin_current": "PIN actual", + "pin_new": "PIN nuevo", + "pin_repeat": "Repetir el PIN", + "pin_hint": "Al menos 6 caracteres. También valen letras, y un PIN más largo es más difícil de adivinar.", + "pin_mismatch": "Los dos PIN no coinciden.", + "pin_recovery": "Si lo olvida, la única salida es eliminar los datos de este dispositivo y volver a iniciar sesión. Su correo en el servidor se mantiene. Los mensajes que aún esperan envío en este dispositivo se pierden.", + "save": "Guardar", + "cancel": "Cancelar", + "save_failed": "No ha funcionado. Inténtelo de nuevo.", + "timeout_label": "Bloquear tras", + "timeout_desc": "Tiempo sin uso en cualquier ventana o pestaña.", + "lock_now": "Bloquear ahora", + "lock_now_desc": "Bloquea todas las ventanas y pestañas a la vez.", + "disable_title": "¿Desactivar el bloqueo?", + "disable_body": "Escriba su PIN para desactivarlo.", + "disable_confirm": "Desactivar", + "passkey_label": "Llave de acceso", + "passkey_desc": "Desbloquee con una llave de acceso o de seguridad que pueda proteger la clave. No todas pueden, y el PIN sigue funcionando.", + "passkey_add": "Añadir una llave de acceso…", + "passkey_add_title": "Añadir una llave de acceso", + "passkey_remove": "Quitar", + "passkey_added": "Llave de acceso añadida.", + "passkey_no_prf": "Esta llave de acceso no puede proteger la clave. Su PIN sigue funcionando.", + "passkey_failed": "La llave de acceso no ha funcionado. Escriba su PIN.", + "use_passkey": "Usar una llave de acceso", + "use_hello": "Usar Windows Hello", + "use_touchid": "Usar Touch ID", + "os_add": "Activar…", + "os_add_title": "Usar {method}", + "os_remove": "Desactivar", + "os_desc": "{method} es una comprobación de la app. Tras un sí, la app lee la clave del almacenamiento cifrado de este equipo. Un programa que se ejecute con su cuenta de usuario podría leerla allí sin {method}, así que el PIN protege más. El PIN sigue funcionando.", + "os_failed": "No ha funcionado. Escriba su PIN.", + "lock_title": "{app} está bloqueada", + "locking": "Bloqueando…", + "pin_prompt": "Escriba su PIN para continuar.", + "unlock": "Desbloquear", + "unlocking": "Desbloqueando…", + "wrong_pin": "Ese PIN no es correcto.", + "unlock_failed": "No se ha podido desbloquear. Inténtelo de nuevo.", + "blocked_later": "Demasiados intentos. Inténtelo más tarde.", + "forgot": "¿Ha olvidado su PIN?", + "forgot_title": "¿Eliminar los datos locales y cerrar sesión?", + "forgot_body": "Un PIN olvidado no se puede recuperar. Esto elimina el correo guardado en este dispositivo y cierra la sesión de todas las cuentas, para que pueda volver a entrar. Su correo en el servidor se mantiene. Los mensajes que aún esperan envío en este dispositivo se pierden.", + "forgot_confirm": "Eliminar y cerrar sesión", + "unlock_in_main": "Desbloquee en la ventana principal.", + "open_main": "Ir a la ventana principal", + "setup_required_title": "Su organización exige un bloqueo de la app", + "setup_required_body": "Elija un PIN. Lo necesitará para abrir {app} cuando se bloquee.", + "draft_saved_notice": "Lo que estaba escribiendo se guardó en Borradores antes del bloqueo.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuto} other {# minutos}}", + "blocked_seconds": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# segundo} other {# segundos}}.", + "blocked_minutes": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# minuto} other {# minutos}}." + }, "desktop_sign_in": { "browser_button": "Iniciar sesión con el navegador", "browser_waiting": "Termina de iniciar sesión en el navegador", diff --git a/locales/et/common.json b/locales/et/common.json index af5abde97..b6d233a3e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Faili ei saanud kopeerida." } }, + "appLock": { + "section_title": "Rakenduse lukk", + "section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.", + "enable_label": "Lukusta PIN-koodiga", + "enable_desc": "Vaikimisi väljas. PIN-kood jääb sellesse seadmesse.", + "enable_body": "Valige PIN-kood. Seda on vaja iga kord, kui rakendus lukustub, ja pärast uuesti laadimist või käivitamist.", + "set_pin_title": "Valige PIN-kood", + "change_pin": "Muuda PIN-koodi…", + "change_pin_title": "Muuda PIN-koodi", + "pin_label": "PIN-kood", + "pin_current": "Praegune PIN-kood", + "pin_new": "Uus PIN-kood", + "pin_repeat": "Korrake PIN-koodi", + "pin_hint": "Vähemalt 6 märki. Sobivad ka tähed ja pikemat PIN-koodi on raskem ära arvata.", + "pin_mismatch": "Need kaks PIN-koodi ei ole samad.", + "pin_recovery": "Kui selle unustate, on ainus võimalus kustutada selle seadme andmed ja uuesti sisse logida. Teie e-post serveris jääb alles. Selles seadmes saatmist ootavad kirjad lähevad kaotsi.", + "save": "Salvesta", + "cancel": "Tühista", + "save_failed": "See ei õnnestunud. Proovige uuesti.", + "timeout_label": "Lukusta pärast", + "timeout_desc": "Kasutamata aeg mis tahes aknas või vahekaardil.", + "lock_now": "Lukusta kohe", + "lock_now_desc": "Lukustab kõik aknad ja vahekaardid korraga.", + "disable_title": "Kas lülitada rakenduse lukk välja?", + "disable_body": "Väljalülitamiseks sisestage PIN-kood.", + "disable_confirm": "Lülita välja", + "passkey_label": "Pääsukood", + "passkey_desc": "Avage pääsukoodi või turvavõtmega, mis suudab võtit kaitsta. Kõik ei suuda ja PIN-kood töötab edasi.", + "passkey_add": "Lisa pääsukood…", + "passkey_add_title": "Lisa pääsukood", + "passkey_remove": "Eemalda", + "passkey_added": "Pääsukood on lisatud.", + "passkey_no_prf": "See pääsukood ei suuda võtit kaitsta. PIN-kood töötab edasi.", + "passkey_failed": "Pääsukood ei töötanud. Sisestage PIN-kood.", + "use_passkey": "Kasuta pääsukoodi", + "use_hello": "Kasuta Windows Hellot", + "use_touchid": "Kasuta Touch ID-d", + "os_add": "Lülita sisse…", + "os_add_title": "Kasuta: {method}", + "os_remove": "Lülita välja", + "os_desc": "{method} on rakenduse tehtav kontroll. Pärast jah-vastust loeb rakendus võtme selle arvuti krüpteeritud hoidlast. Teie kasutajakonto all töötav programm võiks selle sealt lugeda ka ilma, seega kaitseb PIN-kood rohkem. PIN-kood töötab edasi.", + "os_failed": "See ei õnnestunud. Sisestage PIN-kood.", + "lock_title": "{app} on lukus", + "locking": "Lukustamine…", + "pin_prompt": "Jätkamiseks sisestage PIN-kood.", + "unlock": "Ava", + "unlocking": "Avamine…", + "wrong_pin": "See PIN-kood ei ole õige.", + "unlock_failed": "Avamine ei õnnestunud. Proovige uuesti.", + "blocked_later": "Liiga palju katseid. Proovige hiljem uuesti.", + "forgot": "Unustasite PIN-koodi?", + "forgot_title": "Kas eemaldada kohalikud andmed ja logida välja?", + "forgot_body": "Unustatud PIN-koodi ei saa taastada. See eemaldab selles seadmes hoitud e-posti ja logib kõik kontod välja, et saaksite uuesti sisse logida. Teie e-post serveris jääb alles. Selles seadmes saatmist ootavad kirjad lähevad kaotsi.", + "forgot_confirm": "Eemalda ja logi välja", + "unlock_in_main": "Avage põhiaknas.", + "open_main": "Mine põhiaknasse", + "setup_required_title": "Teie organisatsioon nõuab rakenduse lukku", + "setup_required_body": "Valige PIN-kood. Seda on vaja {app} avamiseks pärast lukustumist.", + "draft_saved_notice": "Kirjutatu salvestati enne lukustumist mustanditesse.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuti} other {# minuti}}", + "blocked_seconds": "Liiga palju katseid. Proovige uuesti {count, plural, one {# sekundi} other {# sekundi}} pärast.", + "blocked_minutes": "Liiga palju katseid. Proovige uuesti {count, plural, one {# minuti} other {# minuti}} pärast." + }, "desktop_sign_in": { "browser_button": "Logi sisse brauseriga", "browser_waiting": "Lõpeta sisselogimine brauseris", diff --git a/locales/fa/common.json b/locales/fa/common.json index b25da5a9b..070b1b485 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "فایل کپی نشد." } }, + "appLock": { + "section_title": "قفل برنامه", + "section_desc": "پس از مدتی بی‌استفاده، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، ایمیل‌های این دستگاه رمزگذاری‌شده می‌مانند و برنامه کلید آن‌ها را ندارد.", + "enable_label": "قفل با پین", + "enable_desc": "به‌طور پیش‌فرض خاموش است. پین روی همین دستگاه می‌ماند.", + "enable_body": "یک پین انتخاب کنید. هر بار که برنامه قفل شود و پس از بارگذاری دوباره یا شروع تازه به آن نیاز دارید.", + "set_pin_title": "انتخاب پین", + "change_pin": "تغییر پین…", + "change_pin_title": "تغییر پین", + "pin_label": "پین", + "pin_current": "پین فعلی", + "pin_new": "پین جدید", + "pin_repeat": "تکرار پین", + "pin_hint": "دست‌کم ۶ نویسه. حروف هم پذیرفته می‌شوند و حدس زدن پین بلندتر سخت‌تر است.", + "pin_mismatch": "این دو پین یکسان نیستند.", + "pin_recovery": "اگر آن را فراموش کنید، تنها راه حذف داده‌های این دستگاه و ورود دوباره است. ایمیل‌های شما روی سرور می‌مانند. پیام‌هایی که هنوز در این دستگاه منتظر ارسال‌اند از بین می‌روند.", + "save": "ذخیره", + "cancel": "لغو", + "save_failed": "انجام نشد. دوباره تلاش کنید.", + "timeout_label": "قفل پس از", + "timeout_desc": "زمان بی‌استفاده در هر پنجره یا زبانه.", + "lock_now": "اکنون قفل کن", + "lock_now_desc": "همه پنجره‌ها و زبانه‌ها را فوراً قفل می‌کند.", + "disable_title": "قفل برنامه خاموش شود؟", + "disable_body": "برای خاموش کردن، پین خود را وارد کنید.", + "disable_confirm": "خاموش کردن", + "passkey_label": "کلید عبور", + "passkey_desc": "با کلید عبور یا کلید امنیتی که بتواند از کلید محافظت کند باز کنید. همه نمی‌توانند و پین همچنان کار می‌کند.", + "passkey_add": "افزودن کلید عبور…", + "passkey_add_title": "افزودن کلید عبور", + "passkey_remove": "حذف", + "passkey_added": "کلید عبور افزوده شد.", + "passkey_no_prf": "این کلید عبور نمی‌تواند از کلید محافظت کند. پین شما همچنان کار می‌کند.", + "passkey_failed": "کلید عبور کار نکرد. پین خود را وارد کنید.", + "use_passkey": "استفاده از کلید عبور", + "use_hello": "استفاده از Windows Hello", + "use_touchid": "استفاده از Touch ID", + "os_add": "روشن کردن…", + "os_add_title": "استفاده از {method}", + "os_remove": "خاموش کردن", + "os_desc": "{method} بررسی‌ای است که برنامه انجام می‌دهد. پس از تأیید، برنامه کلید را از فضای رمزگذاری‌شده این رایانه می‌خواند. برنامه‌ای که زیر حساب کاربری شما اجرا شود می‌تواند آن را بدون {method} هم بخواند، پس پین محافظت بیشتری دارد. پین همچنان کار می‌کند.", + "os_failed": "انجام نشد. پین خود را وارد کنید.", + "lock_title": "{app} قفل است", + "locking": "در حال قفل کردن…", + "pin_prompt": "برای ادامه، پین خود را وارد کنید.", + "unlock": "باز کردن قفل", + "unlocking": "در حال باز کردن قفل…", + "wrong_pin": "این پین درست نیست.", + "unlock_failed": "باز کردن قفل انجام نشد. دوباره تلاش کنید.", + "blocked_later": "تلاش‌های زیاد. بعداً دوباره تلاش کنید.", + "forgot": "پین خود را فراموش کرده‌اید؟", + "forgot_title": "داده‌های محلی حذف و از حساب خارج شوید؟", + "forgot_body": "پین فراموش‌شده بازیابی نمی‌شود. این کار ایمیل‌های نگه‌داشته‌شده در این دستگاه را حذف می‌کند و از همه حساب‌ها خارج می‌شود تا بتوانید دوباره وارد شوید. ایمیل‌های شما روی سرور می‌مانند. پیام‌هایی که هنوز در این دستگاه منتظر ارسال‌اند از بین می‌روند.", + "forgot_confirm": "حذف و خروج", + "unlock_in_main": "در پنجره اصلی قفل را باز کنید.", + "open_main": "رفتن به پنجره اصلی", + "setup_required_title": "سازمان شما قفل برنامه را الزامی کرده است", + "setup_required_body": "یک پین انتخاب کنید. برای باز کردن {app} پس از قفل شدن به آن نیاز دارید.", + "draft_saved_notice": "آنچه می‌نوشتید پیش از قفل شدن در پیش‌نویس‌ها ذخیره شد.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# دقیقه} other {# دقیقه}}", + "blocked_seconds": "تلاش‌های زیاد. {count, plural, one {# ثانیه} other {# ثانیه}} دیگر دوباره تلاش کنید.", + "blocked_minutes": "تلاش‌های زیاد. {count, plural, one {# دقیقه} other {# دقیقه}} دیگر دوباره تلاش کنید." + }, "desktop_sign_in": { "browser_button": "ورود با مرورگر", "browser_waiting": "ورود را در مرورگر کامل کنید", diff --git a/locales/fr/common.json b/locales/fr/common.json index a283bf2a2..859ff160f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Impossible de copier le fichier." } }, + "appLock": { + "section_title": "Verrouillage de l'app", + "section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.", + "enable_label": "Verrouiller avec un code PIN", + "enable_desc": "Désactivé par défaut. Le code PIN reste sur cet appareil.", + "enable_body": "Choisissez un code PIN. Il vous sera demandé à chaque verrouillage, et après un rechargement ou un nouveau démarrage.", + "set_pin_title": "Choisir un code PIN", + "change_pin": "Changer le code PIN…", + "change_pin_title": "Changer le code PIN", + "pin_label": "Code PIN", + "pin_current": "Code PIN actuel", + "pin_new": "Nouveau code PIN", + "pin_repeat": "Répéter le code PIN", + "pin_hint": "Au moins 6 caractères. Les lettres sont acceptées, et un code plus long est plus difficile à deviner.", + "pin_mismatch": "Les deux codes PIN ne sont pas identiques.", + "pin_recovery": "Si vous l'oubliez, la seule solution est de supprimer les données de cet appareil et de vous reconnecter. Vos e-mails sur le serveur restent. Les messages encore en attente d'envoi sur cet appareil sont perdus.", + "save": "Enregistrer", + "cancel": "Annuler", + "save_failed": "Cela n'a pas fonctionné. Réessayez.", + "timeout_label": "Verrouiller après", + "timeout_desc": "Temps sans utilisation dans une fenêtre ou un onglet.", + "lock_now": "Verrouiller maintenant", + "lock_now_desc": "Verrouille toutes les fenêtres et tous les onglets immédiatement.", + "disable_title": "Désactiver le verrouillage ?", + "disable_body": "Saisissez votre code PIN pour le désactiver.", + "disable_confirm": "Désactiver", + "passkey_label": "Clé d'accès", + "passkey_desc": "Déverrouillez avec une clé d'accès ou une clé de sécurité capable de protéger la clé. Toutes n'en sont pas capables, et le code PIN continue de fonctionner.", + "passkey_add": "Ajouter une clé d'accès…", + "passkey_add_title": "Ajouter une clé d'accès", + "passkey_remove": "Supprimer", + "passkey_added": "Clé d'accès ajoutée.", + "passkey_no_prf": "Cette clé d'accès ne peut pas protéger la clé. Votre code PIN continue de fonctionner.", + "passkey_failed": "La clé d'accès n'a pas fonctionné. Saisissez plutôt votre code PIN.", + "use_passkey": "Utiliser une clé d'accès", + "use_hello": "Utiliser Windows Hello", + "use_touchid": "Utiliser Touch ID", + "os_add": "Activer…", + "os_add_title": "Utiliser {method}", + "os_remove": "Désactiver", + "os_desc": "{method} est une vérification faite par l'app. Après un oui, l'app lit la clé dans le stockage chiffré de cet ordinateur. Un programme lancé sous votre compte utilisateur pourrait l'y lire sans {method} : le code PIN protège donc davantage. Le code PIN continue de fonctionner.", + "os_failed": "Cela n'a pas fonctionné. Saisissez plutôt votre code PIN.", + "lock_title": "{app} est verrouillé", + "locking": "Verrouillage…", + "pin_prompt": "Saisissez votre code PIN pour continuer.", + "unlock": "Déverrouiller", + "unlocking": "Déverrouillage…", + "wrong_pin": "Ce code PIN n'est pas correct.", + "unlock_failed": "Le déverrouillage n'a pas fonctionné. Réessayez.", + "blocked_later": "Trop d'essais. Réessayez plus tard.", + "forgot": "Code PIN oublié ?", + "forgot_title": "Supprimer les données locales et se déconnecter ?", + "forgot_body": "Un code PIN oublié ne peut pas être récupéré. Cela supprime les e-mails gardés sur cet appareil et déconnecte tous les comptes, pour que vous puissiez vous reconnecter. Vos e-mails sur le serveur restent. Les messages encore en attente d'envoi sur cet appareil sont perdus.", + "forgot_confirm": "Supprimer et se déconnecter", + "unlock_in_main": "Déverrouillez dans la fenêtre principale.", + "open_main": "Aller à la fenêtre principale", + "setup_required_title": "Votre organisation exige un verrouillage de l'app", + "setup_required_body": "Choisissez un code PIN. Il vous faudra pour ouvrir {app} après le verrouillage.", + "draft_saved_notice": "Ce que vous écriviez a été enregistré dans les brouillons avant le verrouillage.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minute} other {# minutes}}", + "blocked_seconds": "Trop d'essais. Réessayez dans {count, plural, one {# seconde} other {# secondes}}.", + "blocked_minutes": "Trop d'essais. Réessayez dans {count, plural, one {# minute} other {# minutes}}." + }, "desktop_sign_in": { "browser_button": "Se connecter avec le navigateur", "browser_waiting": "Terminez la connexion dans votre navigateur", diff --git a/locales/he/common.json b/locales/he/common.json index 8ca0148b4..851cf6085 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6561,6 +6561,71 @@ "copy_failed": "לא ניתן היה להעתיק את הקובץ." } }, + "appLock": { + "section_title": "נעילת אפליקציה", + "section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.", + "enable_label": "נעילה עם קוד PIN", + "enable_desc": "כבוי כברירת מחדל. קוד ה-PIN נשאר במכשיר הזה.", + "enable_body": "בחרו קוד PIN. תצטרכו אותו בכל פעם שהאפליקציה ננעלת, ואחרי טעינה מחדש או הפעלה חדשה.", + "set_pin_title": "בחירת קוד PIN", + "change_pin": "שינוי קוד PIN…", + "change_pin_title": "שינוי קוד PIN", + "pin_label": "קוד PIN", + "pin_current": "קוד PIN נוכחי", + "pin_new": "קוד PIN חדש", + "pin_repeat": "חזרה על קוד PIN", + "pin_hint": "לפחות 6 תווים. אפשר גם אותיות, וקוד ארוך יותר קשה יותר לניחוש.", + "pin_mismatch": "שני הקודים אינם זהים.", + "pin_recovery": "אם תשכחו אותו, הדרך היחידה היא להסיר את הנתונים מהמכשיר הזה ולהיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.", + "save": "שמירה", + "cancel": "ביטול", + "save_failed": "זה לא הצליח. נסו שוב.", + "timeout_label": "נעילה אחרי", + "timeout_desc": "זמן ללא שימוש בכל חלון או כרטיסייה.", + "lock_now": "נעילה עכשיו", + "lock_now_desc": "נועל מיד את כל החלונות והכרטיסיות.", + "disable_title": "לכבות את נעילת האפליקציה?", + "disable_body": "הזינו את קוד ה-PIN כדי לכבות אותה.", + "disable_confirm": "כיבוי", + "passkey_label": "מפתח גישה", + "passkey_desc": "פתיחה עם מפתח גישה או מפתח אבטחה שיכול להגן על המפתח. לא כולם יכולים, וקוד ה-PIN ממשיך לעבוד.", + "passkey_add": "הוספת מפתח גישה…", + "passkey_add_title": "הוספת מפתח גישה", + "passkey_remove": "הסרה", + "passkey_added": "מפתח הגישה נוסף.", + "passkey_no_prf": "מפתח הגישה הזה לא יכול להגן על המפתח. קוד ה-PIN ממשיך לעבוד.", + "passkey_failed": "מפתח הגישה לא עבד. הזינו את קוד ה-PIN.", + "use_passkey": "שימוש במפתח גישה", + "use_hello": "שימוש ב-Windows Hello", + "use_touchid": "שימוש ב-Touch ID", + "os_add": "הפעלה…", + "os_add_title": "שימוש ב-{method}", + "os_remove": "כיבוי", + "os_desc": "{method} היא בדיקה של האפליקציה. אחרי אישור, האפליקציה קוראת את המפתח מהאחסון המוצפן של המחשב הזה. תוכנה שרצה תחת חשבון המשתמש שלכם יכולה לקרוא אותו שם גם בלי {method}, ולכן קוד ה-PIN מגן יותר. קוד ה-PIN ממשיך לעבוד.", + "os_failed": "זה לא הצליח. הזינו את קוד ה-PIN.", + "lock_title": "{app} נעולה", + "locking": "נועל…", + "pin_prompt": "הזינו את קוד ה-PIN כדי להמשיך.", + "unlock": "פתיחה", + "unlocking": "פותח…", + "wrong_pin": "קוד ה-PIN הזה שגוי.", + "unlock_failed": "הפתיחה לא הצליחה. נסו שוב.", + "blocked_later": "יותר מדי ניסיונות. נסו מאוחר יותר.", + "forgot": "שכחתם את קוד ה-PIN?", + "forgot_title": "להסיר נתונים מקומיים ולהתנתק?", + "forgot_body": "אי אפשר לשחזר קוד PIN שנשכח. פעולה זו מסירה את הדואר שנשמר במכשיר הזה ומנתקת את כל החשבונות, כדי שתוכלו להיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.", + "forgot_confirm": "הסרה והתנתקות", + "unlock_in_main": "פתחו את הנעילה בחלון הראשי.", + "open_main": "מעבר לחלון הראשי", + "setup_required_title": "הארגון שלכם דורש נעילת אפליקציה", + "setup_required_body": "בחרו קוד PIN. תצטרכו אותו כדי לפתוח את {app} אחרי הנעילה.", + "draft_saved_notice": "מה שכתבתם נשמר בטיוטות לפני הנעילה.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}", + "blocked_seconds": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {שנייה אחת} two {שתי שניות} other {# שניות}}.", + "blocked_minutes": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}." + }, "desktop_sign_in": { "browser_button": "כניסה באמצעות הדפדפן", "browser_waiting": "יש להשלים את הכניסה בדפדפן", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0459a57b5..e7a53cba0 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "A fájlt nem sikerült másolni." } }, + "appLock": { + "section_title": "Alkalmazászár", + "section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.", + "enable_label": "Zárolás PIN-kóddal", + "enable_desc": "Alapból ki van kapcsolva. A PIN-kód ezen az eszközön marad.", + "enable_body": "Válasszon PIN-kódot. Minden zároláskor, valamint újratöltés vagy új indítás után szükség lesz rá.", + "set_pin_title": "PIN-kód választása", + "change_pin": "PIN-kód módosítása…", + "change_pin_title": "A PIN-kód módosítása", + "pin_label": "PIN-kód", + "pin_current": "Jelenlegi PIN-kód", + "pin_new": "Új PIN-kód", + "pin_repeat": "PIN-kód ismét", + "pin_hint": "Legalább 6 karakter. Betűk is jók, és a hosszabb PIN-kódot nehezebb kitalálni.", + "pin_mismatch": "A két PIN-kód nem egyezik.", + "pin_recovery": "Ha elfelejti, az egyetlen megoldás az eszközön lévő adatok eltávolítása és az újbóli bejelentkezés. A szerveren lévő levelei megmaradnak. Az ezen az eszközön küldésre váró üzenetek elvesznek.", + "save": "Mentés", + "cancel": "Mégse", + "save_failed": "Ez nem sikerült. Próbálja újra.", + "timeout_label": "Zárolás ennyi után", + "timeout_desc": "Használat nélküli idő bármelyik ablakban vagy lapon.", + "lock_now": "Zárolás most", + "lock_now_desc": "Azonnal zárolja az összes ablakot és lapot.", + "disable_title": "Kikapcsolja az alkalmazászárat?", + "disable_body": "A kikapcsoláshoz írja be a PIN-kódját.", + "disable_confirm": "Kikapcsolás", + "passkey_label": "Jelkulcs", + "passkey_desc": "Feloldás olyan jelkulccsal vagy biztonsági kulccsal, amely védeni tudja a kulcsot. Nem mindegyik tudja, és a PIN-kód továbbra is működik.", + "passkey_add": "Jelkulcs hozzáadása…", + "passkey_add_title": "Jelkulcs hozzáadása", + "passkey_remove": "Eltávolítás", + "passkey_added": "A jelkulcs hozzáadva.", + "passkey_no_prf": "Ez a jelkulcs nem tudja védeni a kulcsot. A PIN-kód továbbra is működik.", + "passkey_failed": "A jelkulcs nem működött. Írja be a PIN-kódját.", + "use_passkey": "Jelkulcs használata", + "use_hello": "Windows Hello használata", + "use_touchid": "Touch ID használata", + "os_add": "Bekapcsolás…", + "os_add_title": "{method} használata", + "os_remove": "Kikapcsolás", + "os_desc": "A(z) {method} az alkalmazás ellenőrzése. Igen után az alkalmazás kiolvassa a kulcsot a számítógép titkosított tárolójából. Egy az Ön felhasználói fiókjában futó program enélkül is kiolvashatná onnan, ezért a PIN-kód többet véd. A PIN-kód továbbra is működik.", + "os_failed": "Ez nem sikerült. Írja be a PIN-kódját.", + "lock_title": "A(z) {app} zárolva van", + "locking": "Zárolás…", + "pin_prompt": "A folytatáshoz írja be a PIN-kódját.", + "unlock": "Feloldás", + "unlocking": "Feloldás…", + "wrong_pin": "Ez a PIN-kód nem helyes.", + "unlock_failed": "A feloldás nem sikerült. Próbálja újra.", + "blocked_later": "Túl sok próbálkozás. Próbálja később.", + "forgot": "Elfelejtette a PIN-kódot?", + "forgot_title": "Eltávolítja a helyi adatokat és kijelentkezik?", + "forgot_body": "Az elfelejtett PIN-kód nem állítható vissza. Ez eltávolítja az eszközön tárolt leveleket és minden fiókot kijelentkeztet, hogy újra be tudjon jelentkezni. A szerveren lévő levelei megmaradnak. Az ezen az eszközön küldésre váró üzenetek elvesznek.", + "forgot_confirm": "Eltávolítás és kijelentkezés", + "unlock_in_main": "Oldja fel a főablakban.", + "open_main": "Ugrás a főablakra", + "setup_required_title": "A szervezete alkalmazászárat követel meg", + "setup_required_body": "Válasszon PIN-kódot. A(z) {app} megnyitásához kell majd zárolás után.", + "draft_saved_notice": "Amit írt, zárolás előtt a Piszkozatok közé mentődött.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# perc} other {# perc}}", + "blocked_seconds": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# másodperc} other {# másodperc}} múlva.", + "blocked_minutes": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# perc} other {# perc}} múlva." + }, "desktop_sign_in": { "browser_button": "Bejelentkezés böngészővel", "browser_waiting": "Fejezze be a bejelentkezést a böngészőben", diff --git a/locales/it/common.json b/locales/it/common.json index 95db2212c..f3d65219d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Impossibile copiare il file." } }, + "appLock": { + "section_title": "Blocco dell'app", + "section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.", + "enable_label": "Blocca con un PIN", + "enable_desc": "Disattivato per impostazione predefinita. Il PIN resta su questo dispositivo.", + "enable_body": "Scegli un PIN. Ti servirà ogni volta che l'app si blocca, e dopo un ricaricamento o un nuovo avvio.", + "set_pin_title": "Scegli un PIN", + "change_pin": "Cambia PIN…", + "change_pin_title": "Cambia il PIN", + "pin_label": "PIN", + "pin_current": "PIN attuale", + "pin_new": "Nuovo PIN", + "pin_repeat": "Ripeti il PIN", + "pin_hint": "Almeno 6 caratteri. Vanno bene anche le lettere, e un PIN più lungo è più difficile da indovinare.", + "pin_mismatch": "I due PIN non coincidono.", + "pin_recovery": "Se lo dimentichi, l'unica strada è rimuovere i dati da questo dispositivo e accedere di nuovo. La tua posta sul server resta. I messaggi ancora in attesa di invio su questo dispositivo vanno persi.", + "save": "Salva", + "cancel": "Annulla", + "save_failed": "Non ha funzionato. Riprova.", + "timeout_label": "Blocca dopo", + "timeout_desc": "Tempo senza utilizzo in qualsiasi finestra o scheda.", + "lock_now": "Blocca ora", + "lock_now_desc": "Blocca subito tutte le finestre e le schede.", + "disable_title": "Disattivare il blocco?", + "disable_body": "Inserisci il PIN per disattivarlo.", + "disable_confirm": "Disattiva", + "passkey_label": "Passkey", + "passkey_desc": "Sblocca con una passkey o una chiave di sicurezza in grado di proteggere la chiave. Non tutte lo sono, e il PIN continua a funzionare.", + "passkey_add": "Aggiungi una passkey…", + "passkey_add_title": "Aggiungi una passkey", + "passkey_remove": "Rimuovi", + "passkey_added": "Passkey aggiunta.", + "passkey_no_prf": "Questa passkey non può proteggere la chiave. Il PIN continua a funzionare.", + "passkey_failed": "La passkey non ha funzionato. Inserisci il PIN.", + "use_passkey": "Usa una passkey", + "use_hello": "Usa Windows Hello", + "use_touchid": "Usa Touch ID", + "os_add": "Attiva…", + "os_add_title": "Usa {method}", + "os_remove": "Disattiva", + "os_desc": "{method} è un controllo fatto dall'app. Dopo un sì, l'app legge la chiave dall'archivio cifrato di questo computer. Un programma avviato con il tuo account utente potrebbe leggerla lì senza {method}, quindi il PIN protegge di più. Il PIN continua a funzionare.", + "os_failed": "Non ha funzionato. Inserisci il PIN.", + "lock_title": "{app} è bloccata", + "locking": "Blocco in corso…", + "pin_prompt": "Inserisci il PIN per continuare.", + "unlock": "Sblocca", + "unlocking": "Sblocco in corso…", + "wrong_pin": "Questo PIN non è corretto.", + "unlock_failed": "Lo sblocco non ha funzionato. Riprova.", + "blocked_later": "Troppi tentativi. Riprova più tardi.", + "forgot": "PIN dimenticato?", + "forgot_title": "Rimuovere i dati locali e uscire?", + "forgot_body": "Un PIN dimenticato non si può recuperare. Questo rimuove la posta salvata su questo dispositivo e disconnette tutti gli account, così puoi accedere di nuovo. La tua posta sul server resta. I messaggi ancora in attesa di invio su questo dispositivo vanno persi.", + "forgot_confirm": "Rimuovi ed esci", + "unlock_in_main": "Sblocca nella finestra principale.", + "open_main": "Vai alla finestra principale", + "setup_required_title": "La tua organizzazione richiede un blocco dell'app", + "setup_required_body": "Scegli un PIN. Ti servirà per aprire {app} dopo il blocco.", + "draft_saved_notice": "Quello che stavi scrivendo è stato salvato nelle bozze prima del blocco.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuto} other {# minuti}}", + "blocked_seconds": "Troppi tentativi. Riprova tra {count, plural, one {# secondo} other {# secondi}}.", + "blocked_minutes": "Troppi tentativi. Riprova tra {count, plural, one {# minuto} other {# minuti}}." + }, "desktop_sign_in": { "browser_button": "Accedi con il browser", "browser_waiting": "Completa l'accesso nel browser", diff --git a/locales/ja/common.json b/locales/ja/common.json index 78070a2f8..f29230c00 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "ファイルをコピーできませんでした。" } }, + "appLock": { + "section_title": "アプリのロック", + "section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。", + "enable_label": "PIN でロック", + "enable_desc": "既定ではオフです。PIN はこのデバイスにだけ保存されます。", + "enable_body": "PIN を選んでください。アプリがロックされるたび、また再読み込みや再起動の後に必要です。", + "set_pin_title": "PIN を選択", + "change_pin": "PIN を変更…", + "change_pin_title": "PIN を変更", + "pin_label": "PIN", + "pin_current": "現在の PIN", + "pin_new": "新しい PIN", + "pin_repeat": "PIN をもう一度", + "pin_hint": "6 文字以上。文字も使えます。長い PIN ほど推測されにくくなります。", + "pin_mismatch": "2 つの PIN が一致しません。", + "pin_recovery": "忘れた場合は、このデバイスのデータを削除してサインインし直すしかありません。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。", + "save": "保存", + "cancel": "キャンセル", + "save_failed": "うまくいきませんでした。もう一度お試しください。", + "timeout_label": "ロックまでの時間", + "timeout_desc": "どのウィンドウやタブでも使っていない時間です。", + "lock_now": "今すぐロック", + "lock_now_desc": "すべてのウィンドウとタブをすぐにロックします。", + "disable_title": "アプリのロックをオフにしますか?", + "disable_body": "オフにするには PIN を入力してください。", + "disable_confirm": "オフにする", + "passkey_label": "パスキー", + "passkey_desc": "鍵を保護できるパスキーやセキュリティキーでロックを解除します。すべてが対応しているわけではなく、PIN は引き続き使えます。", + "passkey_add": "パスキーを追加…", + "passkey_add_title": "パスキーを追加", + "passkey_remove": "削除", + "passkey_added": "パスキーを追加しました。", + "passkey_no_prf": "このパスキーでは鍵を保護できません。PIN は引き続き使えます。", + "passkey_failed": "パスキーが機能しませんでした。PIN を入力してください。", + "use_passkey": "パスキーを使う", + "use_hello": "Windows Hello を使う", + "use_touchid": "Touch ID を使う", + "os_add": "オンにする…", + "os_add_title": "{method} を使う", + "os_remove": "オフにする", + "os_desc": "{method} はアプリが行う確認です。確認後、アプリはこのコンピューターの暗号化ストレージから鍵を読み取ります。あなたのユーザーアカウントで動くプログラムは {method} なしでも読み取れるため、PIN のほうが強く保護します。PIN は引き続き使えます。", + "os_failed": "うまくいきませんでした。PIN を入力してください。", + "lock_title": "{app} はロックされています", + "locking": "ロックしています…", + "pin_prompt": "続けるには PIN を入力してください。", + "unlock": "ロック解除", + "unlocking": "ロックを解除しています…", + "wrong_pin": "この PIN は正しくありません。", + "unlock_failed": "ロックを解除できませんでした。もう一度お試しください。", + "blocked_later": "試行回数が多すぎます。後でもう一度お試しください。", + "forgot": "PIN を忘れましたか?", + "forgot_title": "ローカルデータを削除してサインアウトしますか?", + "forgot_body": "忘れた PIN は復元できません。このデバイスに保存されたメールを削除し、すべてのアカウントをサインアウトして、サインインし直せるようにします。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。", + "forgot_confirm": "削除してサインアウト", + "unlock_in_main": "メインウィンドウでロックを解除してください。", + "open_main": "メインウィンドウへ", + "setup_required_title": "組織がアプリのロックを必須にしています", + "setup_required_body": "PIN を選んでください。ロック後に {app} を開くときに必要です。", + "draft_saved_notice": "書いていた内容はロックの前に下書きに保存しました。", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# 分}}", + "blocked_seconds": "試行回数が多すぎます。{count, plural, other {# 秒}}後にもう一度お試しください。", + "blocked_minutes": "試行回数が多すぎます。{count, plural, other {# 分}}後にもう一度お試しください。" + }, "desktop_sign_in": { "browser_button": "ブラウザでサインイン", "browser_waiting": "ブラウザでサインインを完了してください", diff --git a/locales/ko/common.json b/locales/ko/common.json index 96a87153f..8470bf4a9 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "파일을 복사할 수 없습니다." } }, + "appLock": { + "section_title": "앱 잠금", + "section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.", + "enable_label": "PIN으로 잠금", + "enable_desc": "기본적으로 꺼져 있습니다. PIN은 이 기기에만 남습니다.", + "enable_body": "PIN을 선택하세요. 앱이 잠길 때마다, 그리고 다시 로드하거나 새로 시작한 후에 필요합니다.", + "set_pin_title": "PIN 선택", + "change_pin": "PIN 변경…", + "change_pin_title": "PIN 변경", + "pin_label": "PIN", + "pin_current": "현재 PIN", + "pin_new": "새 PIN", + "pin_repeat": "PIN 다시 입력", + "pin_hint": "6자 이상. 문자도 사용할 수 있으며 PIN이 길수록 추측하기 어렵습니다.", + "pin_mismatch": "두 PIN이 같지 않습니다.", + "pin_recovery": "잊어버리면 이 기기의 데이터를 제거하고 다시 로그인하는 방법밖에 없습니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.", + "save": "저장", + "cancel": "취소", + "save_failed": "실패했습니다. 다시 시도하세요.", + "timeout_label": "잠금 시간", + "timeout_desc": "창이나 탭 어디에서도 사용하지 않은 시간입니다.", + "lock_now": "지금 잠금", + "lock_now_desc": "모든 창과 탭을 즉시 잠급니다.", + "disable_title": "앱 잠금을 끄시겠습니까?", + "disable_body": "끄려면 PIN을 입력하세요.", + "disable_confirm": "끄기", + "passkey_label": "패스키", + "passkey_desc": "키를 보호할 수 있는 패스키나 보안 키로 잠금을 해제합니다. 모두 가능한 것은 아니며 PIN은 계속 작동합니다.", + "passkey_add": "패스키 추가…", + "passkey_add_title": "패스키 추가", + "passkey_remove": "제거", + "passkey_added": "패스키를 추가했습니다.", + "passkey_no_prf": "이 패스키는 키를 보호할 수 없습니다. PIN은 계속 작동합니다.", + "passkey_failed": "패스키가 작동하지 않았습니다. PIN을 입력하세요.", + "use_passkey": "패스키 사용", + "use_hello": "Windows Hello 사용", + "use_touchid": "Touch ID 사용", + "os_add": "켜기…", + "os_add_title": "{method} 사용", + "os_remove": "끄기", + "os_desc": "{method}은(는) 앱이 하는 확인입니다. 확인되면 앱이 이 컴퓨터의 암호화된 저장소에서 키를 읽습니다. 사용자 계정으로 실행되는 프로그램은 {method} 없이도 읽을 수 있으므로 PIN이 더 강하게 보호합니다. PIN은 계속 작동합니다.", + "os_failed": "실패했습니다. PIN을 입력하세요.", + "lock_title": "{app}이(가) 잠겨 있습니다", + "locking": "잠그는 중…", + "pin_prompt": "계속하려면 PIN을 입력하세요.", + "unlock": "잠금 해제", + "unlocking": "잠금 해제 중…", + "wrong_pin": "PIN이 올바르지 않습니다.", + "unlock_failed": "잠금을 해제하지 못했습니다. 다시 시도하세요.", + "blocked_later": "시도 횟수가 너무 많습니다. 나중에 다시 시도하세요.", + "forgot": "PIN을 잊으셨나요?", + "forgot_title": "로컬 데이터를 제거하고 로그아웃하시겠습니까?", + "forgot_body": "잊어버린 PIN은 복구할 수 없습니다. 이 기기에 보관된 메일을 제거하고 모든 계정을 로그아웃하여 다시 로그인할 수 있게 합니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.", + "forgot_confirm": "제거 후 로그아웃", + "unlock_in_main": "기본 창에서 잠금을 해제하세요.", + "open_main": "기본 창으로 이동", + "setup_required_title": "조직에서 앱 잠금을 요구합니다", + "setup_required_body": "PIN을 선택하세요. 잠긴 후 {app}을(를) 열 때 필요합니다.", + "draft_saved_notice": "작성 중이던 내용은 잠기기 전에 임시 보관함에 저장되었습니다.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {#분}}", + "blocked_seconds": "시도 횟수가 너무 많습니다. {count, plural, other {#초}} 후에 다시 시도하세요.", + "blocked_minutes": "시도 횟수가 너무 많습니다. {count, plural, other {#분}} 후에 다시 시도하세요." + }, "desktop_sign_in": { "browser_button": "브라우저로 로그인", "browser_waiting": "브라우저에서 로그인을 마치세요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 1ca156d61..ba8d362d5 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Failu neizdevās nokopēt." } }, + "appLock": { + "section_title": "Lietotnes bloķēšana", + "section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.", + "enable_label": "Bloķēt ar PIN kodu", + "enable_desc": "Pēc noklusējuma izslēgts. PIN kods paliek šajā ierīcē.", + "enable_body": "Izvēlieties PIN kodu. Tas būs vajadzīgs katru reizi, kad lietotne bloķējas, un pēc pārlādes vai jaunas palaišanas.", + "set_pin_title": "Izvēlieties PIN kodu", + "change_pin": "Mainīt PIN kodu…", + "change_pin_title": "Mainīt PIN kodu", + "pin_label": "PIN kods", + "pin_current": "Pašreizējais PIN kods", + "pin_new": "Jauns PIN kods", + "pin_repeat": "Atkārtojiet PIN kodu", + "pin_hint": "Vismaz 6 rakstzīmes. Der arī burti, un garāku PIN kodu ir grūtāk uzminēt.", + "pin_mismatch": "Abi PIN kodi nesakrīt.", + "pin_recovery": "Ja to aizmirsīsit, vienīgā iespēja ir noņemt datus šajā ierīcē un pieteikties vēlreiz. Jūsu e-pasts serverī paliek. Ziņojumi, kas šajā ierīcē vēl gaida sūtīšanu, tiek zaudēti.", + "save": "Saglabāt", + "cancel": "Atcelt", + "save_failed": "Tas neizdevās. Mēģiniet vēlreiz.", + "timeout_label": "Bloķēt pēc", + "timeout_desc": "Laiks bez lietošanas jebkurā logā vai cilnē.", + "lock_now": "Bloķēt tagad", + "lock_now_desc": "Uzreiz bloķē visus logus un cilnes.", + "disable_title": "Vai izslēgt lietotnes bloķēšanu?", + "disable_body": "Lai to izslēgtu, ievadiet PIN kodu.", + "disable_confirm": "Izslēgt", + "passkey_label": "Piekļuves atslēga", + "passkey_desc": "Atbloķējiet ar piekļuves vai drošības atslēgu, kas var aizsargāt atslēgu. Ne visas var, un PIN kods turpina darboties.", + "passkey_add": "Pievienot piekļuves atslēgu…", + "passkey_add_title": "Pievienot piekļuves atslēgu", + "passkey_remove": "Noņemt", + "passkey_added": "Piekļuves atslēga pievienota.", + "passkey_no_prf": "Šī piekļuves atslēga nevar aizsargāt atslēgu. PIN kods turpina darboties.", + "passkey_failed": "Piekļuves atslēga nedarbojās. Ievadiet PIN kodu.", + "use_passkey": "Izmantot piekļuves atslēgu", + "use_hello": "Izmantot Windows Hello", + "use_touchid": "Izmantot Touch ID", + "os_add": "Ieslēgt…", + "os_add_title": "Izmantot {method}", + "os_remove": "Izslēgt", + "os_desc": "{method} ir lietotnes veikta pārbaude. Pēc apstiprinājuma lietotne nolasa atslēgu no šī datora šifrētās krātuves. Programma, kas darbojas jūsu lietotāja kontā, varētu to nolasīt arī bez {method}, tāpēc PIN kods aizsargā vairāk. PIN kods turpina darboties.", + "os_failed": "Tas neizdevās. Ievadiet PIN kodu.", + "lock_title": "{app} ir bloķēta", + "locking": "Bloķē…", + "pin_prompt": "Lai turpinātu, ievadiet PIN kodu.", + "unlock": "Atbloķēt", + "unlocking": "Atbloķē…", + "wrong_pin": "Šis PIN kods nav pareizs.", + "unlock_failed": "Atbloķēšana neizdevās. Mēģiniet vēlreiz.", + "blocked_later": "Pārāk daudz mēģinājumu. Mēģiniet vēlāk.", + "forgot": "Aizmirsāt PIN kodu?", + "forgot_title": "Vai noņemt vietējos datus un izrakstīties?", + "forgot_body": "Aizmirstu PIN kodu nevar atgūt. Tas noņem šajā ierīcē glabāto e-pastu un izraksta visus kontus, lai varētu pieteikties vēlreiz. Jūsu e-pasts serverī paliek. Ziņojumi, kas šajā ierīcē vēl gaida sūtīšanu, tiek zaudēti.", + "forgot_confirm": "Noņemt un izrakstīties", + "unlock_in_main": "Atbloķējiet galvenajā logā.", + "open_main": "Uz galveno logu", + "setup_required_title": "Jūsu organizācija pieprasa lietotnes bloķēšanu", + "setup_required_body": "Izvēlieties PIN kodu. Tas būs vajadzīgs, lai atvērtu {app} pēc bloķēšanas.", + "draft_saved_notice": "Rakstītais pirms bloķēšanas tika saglabāts melnrakstos.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, zero {# minūtes} one {# minūtes} other {# minūtēm}}", + "blocked_seconds": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# sekundēm} one {# sekundes} other {# sekundēm}}.", + "blocked_minutes": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# minūtēm} one {# minūtes} other {# minūtēm}}." + }, "desktop_sign_in": { "browser_button": "Pierakstīties ar pārlūku", "browser_waiting": "Pabeidziet pierakstīšanos pārlūkā", diff --git a/locales/mn/common.json b/locales/mn/common.json index 52cc39b48..08d62630f 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Файлыг хуулж чадсангүй." } }, + "appLock": { + "section_title": "Аппын түгжээ", + "section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.", + "enable_label": "ПИН кодоор түгжих", + "enable_desc": "Анхдагчаар унтраалттай. ПИН код энэ төхөөрөмж дээр үлдэнэ.", + "enable_body": "ПИН код сонгоно уу. Апп түгжигдэх бүрт, мөн дахин ачаалсан эсвэл шинээр эхлүүлсний дараа хэрэгтэй.", + "set_pin_title": "ПИН код сонгох", + "change_pin": "ПИН код солих…", + "change_pin_title": "ПИН код солих", + "pin_label": "ПИН код", + "pin_current": "Одоогийн ПИН код", + "pin_new": "Шинэ ПИН код", + "pin_repeat": "ПИН кодоо давтах", + "pin_hint": "Дор хаяж 6 тэмдэгт. Үсэг ч болно, урт ПИН кодыг таахад хэцүү.", + "pin_mismatch": "Хоёр ПИН код ижил биш байна.", + "pin_recovery": "Мартвал энэ төхөөрөмжийн өгөгдлийг устгаад дахин нэвтрэхээс өөр арга байхгүй. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.", + "save": "Хадгалах", + "cancel": "Цуцлах", + "save_failed": "Болсонгүй. Дахин оролдоно уу.", + "timeout_label": "Түгжих хугацаа", + "timeout_desc": "Аль ч цонх эсвэл табад ашиглаагүй хугацаа.", + "lock_now": "Одоо түгжих", + "lock_now_desc": "Бүх цонх, табыг шууд түгжинэ.", + "disable_title": "Аппын түгжээг унтраах уу?", + "disable_body": "Унтраахын тулд ПИН кодоо оруулна уу.", + "disable_confirm": "Унтраах", + "passkey_label": "Нэвтрэх түлхүүр", + "passkey_desc": "Түлхүүрийг хамгаалж чадах нэвтрэх эсвэл аюулгүйн түлхүүрээр нээнэ. Бүгд чадахгүй бөгөөд ПИН код ажилласаар байна.", + "passkey_add": "Нэвтрэх түлхүүр нэмэх…", + "passkey_add_title": "Нэвтрэх түлхүүр нэмэх", + "passkey_remove": "Хасах", + "passkey_added": "Нэвтрэх түлхүүр нэмэгдлээ.", + "passkey_no_prf": "Энэ нэвтрэх түлхүүр түлхүүрийг хамгаалж чадахгүй. ПИН код ажилласаар байна.", + "passkey_failed": "Нэвтрэх түлхүүр ажилласангүй. ПИН кодоо оруулна уу.", + "use_passkey": "Нэвтрэх түлхүүр ашиглах", + "use_hello": "Windows Hello ашиглах", + "use_touchid": "Touch ID ашиглах", + "os_add": "Асаах…", + "os_add_title": "{method} ашиглах", + "os_remove": "Унтраах", + "os_desc": "{method} бол аппын хийдэг шалгалт юм. Зөвшөөрсний дараа апп түлхүүрийг энэ компьютерын шифрлэгдсэн санаас уншина. Таны хэрэглэгчийн бүртгэлд ажиллаж буй програм {method}-гүйгээр ч уншиж чадах тул ПИН код илүү хамгаална. ПИН код ажилласаар байна.", + "os_failed": "Болсонгүй. ПИН кодоо оруулна уу.", + "lock_title": "{app} түгжээтэй байна", + "locking": "Түгжиж байна…", + "pin_prompt": "Үргэлжлүүлэхийн тулд ПИН кодоо оруулна уу.", + "unlock": "Түгжээ тайлах", + "unlocking": "Түгжээ тайлж байна…", + "wrong_pin": "Энэ ПИН код буруу байна.", + "unlock_failed": "Түгжээ тайлж чадсангүй. Дахин оролдоно уу.", + "blocked_later": "Хэт олон оролдлого. Дараа дахин оролдоно уу.", + "forgot": "ПИН кодоо мартсан уу?", + "forgot_title": "Дотоод өгөгдлийг устгаад гарах уу?", + "forgot_body": "Мартсан ПИН кодыг сэргээх боломжгүй. Энэ нь энэ төхөөрөмжид хадгалсан имэйлийг устгаж, дахин нэвтрэх боломжтой болгохын тулд бүх бүртгэлээс гаргана. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.", + "forgot_confirm": "Устгаад гарах", + "unlock_in_main": "Үндсэн цонхонд түгжээг тайлна уу.", + "open_main": "Үндсэн цонх руу очих", + "setup_required_title": "Танай байгууллага аппын түгжээ шаардаж байна", + "setup_required_body": "ПИН код сонгоно уу. Түгжигдсэний дараа {app}-ыг нээхэд хэрэгтэй.", + "draft_saved_notice": "Бичиж байсан зүйлсийг түгжихээс өмнө ноорогт хадгалсан.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# минут} other {# минут}}", + "blocked_seconds": "Хэт олон оролдлого. {count, plural, one {# секундын} other {# секундын}} дараа дахин оролдоно уу.", + "blocked_minutes": "Хэт олон оролдлого. {count, plural, one {# минутын} other {# минутын}} дараа дахин оролдоно уу." + }, "desktop_sign_in": { "browser_button": "Хөтчөөр нэвтрэх", "browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу", diff --git a/locales/nb/common.json b/locales/nb/common.json index 0d7083171..9a35dda25 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Filen kunne ikke kopieres." } }, + "appLock": { + "section_title": "Applås", + "section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.", + "enable_label": "Lås med en PIN-kode", + "enable_desc": "Av som standard. PIN-koden blir på denne enheten.", + "enable_body": "Velg en PIN-kode. Du trenger den hver gang appen låses, og etter en ny lasting eller ny start.", + "set_pin_title": "Velg en PIN-kode", + "change_pin": "Endre PIN-kode…", + "change_pin_title": "Endre PIN-koden", + "pin_label": "PIN-kode", + "pin_current": "Nåværende PIN-kode", + "pin_new": "Ny PIN-kode", + "pin_repeat": "Gjenta PIN-koden", + "pin_hint": "Minst 6 tegn. Bokstaver går også, og en lengre PIN-kode er vanskeligere å gjette.", + "pin_mismatch": "De to PIN-kodene er ikke like.", + "pin_recovery": "Hvis du glemmer den, er eneste utvei å fjerne dataene på denne enheten og logge på igjen. E-posten din på serveren blir værende. Meldinger som fortsatt venter på sending fra denne enheten, går tapt.", + "save": "Lagre", + "cancel": "Avbryt", + "save_failed": "Det virket ikke. Prøv igjen.", + "timeout_label": "Lås etter", + "timeout_desc": "Tid uten bruk i et vindu eller en fane.", + "lock_now": "Lås nå", + "lock_now_desc": "Låser alle vinduer og faner med en gang.", + "disable_title": "Slå av applåsen?", + "disable_body": "Skriv inn PIN-koden for å slå den av.", + "disable_confirm": "Slå av", + "passkey_label": "Tilgangsnøkkel", + "passkey_desc": "Lås opp med en tilgangsnøkkel eller sikkerhetsnøkkel som kan beskytte nøkkelen. Ikke alle kan det, og PIN-koden virker fortsatt.", + "passkey_add": "Legg til en tilgangsnøkkel…", + "passkey_add_title": "Legg til en tilgangsnøkkel", + "passkey_remove": "Fjern", + "passkey_added": "Tilgangsnøkkelen er lagt til.", + "passkey_no_prf": "Denne tilgangsnøkkelen kan ikke beskytte nøkkelen. PIN-koden virker fortsatt.", + "passkey_failed": "Tilgangsnøkkelen virket ikke. Skriv inn PIN-koden.", + "use_passkey": "Bruk en tilgangsnøkkel", + "use_hello": "Bruk Windows Hello", + "use_touchid": "Bruk Touch ID", + "os_add": "Slå på…", + "os_add_title": "Bruk {method}", + "os_remove": "Slå av", + "os_desc": "{method} er en kontroll appen gjør. Etter et ja leser appen nøkkelen fra datamaskinens krypterte lager. Et program under brukerkontoen din kan lese den der uten {method}, så PIN-koden beskytter mer. PIN-koden virker fortsatt.", + "os_failed": "Det virket ikke. Skriv inn PIN-koden.", + "lock_title": "{app} er låst", + "locking": "Låser…", + "pin_prompt": "Skriv inn PIN-koden for å fortsette.", + "unlock": "Lås opp", + "unlocking": "Låser opp…", + "wrong_pin": "Den PIN-koden er ikke riktig.", + "unlock_failed": "Opplåsing virket ikke. Prøv igjen.", + "blocked_later": "For mange forsøk. Prøv igjen senere.", + "forgot": "Glemt PIN-koden?", + "forgot_title": "Fjerne lokale data og logge ut?", + "forgot_body": "En glemt PIN-kode kan ikke gjenopprettes. Dette fjerner e-post lagret på denne enheten og logger ut alle kontoer, slik at du kan logge på igjen. E-posten din på serveren blir værende. Meldinger som fortsatt venter på sending fra denne enheten, går tapt.", + "forgot_confirm": "Fjern og logg ut", + "unlock_in_main": "Lås opp i hovedvinduet.", + "open_main": "Gå til hovedvinduet", + "setup_required_title": "Organisasjonen din krever en applås", + "setup_required_body": "Velg en PIN-kode. Du trenger den for å åpne {app} når den er låst.", + "draft_saved_notice": "Det du skrev, ble lagret i Utkast før appen ble låst.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minutt} other {# minutter}}", + "blocked_seconds": "For mange forsøk. Prøv igjen om {count, plural, one {# sekund} other {# sekunder}}.", + "blocked_minutes": "For mange forsøk. Prøv igjen om {count, plural, one {# minutt} other {# minutter}}." + }, "desktop_sign_in": { "browser_button": "Logg på med nettleseren", "browser_waiting": "Fullfør påloggingen i nettleseren", diff --git a/locales/nl/common.json b/locales/nl/common.json index b0e75ada3..f079b04e7 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Het bestand kon niet worden gekopieerd." } }, + "appLock": { + "section_title": "App-vergrendeling", + "section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.", + "enable_label": "Vergrendelen met een pincode", + "enable_desc": "Standaard uit. De pincode blijft op dit apparaat.", + "enable_body": "Kies een pincode. Die heeft u nodig telkens als de app vergrendelt, en na herladen of opnieuw starten.", + "set_pin_title": "Kies een pincode", + "change_pin": "Pincode wijzigen…", + "change_pin_title": "De pincode wijzigen", + "pin_label": "Pincode", + "pin_current": "Huidige pincode", + "pin_new": "Nieuwe pincode", + "pin_repeat": "Herhaal de pincode", + "pin_hint": "Minstens 6 tekens. Letters mogen ook, en een langere pincode is moeilijker te raden.", + "pin_mismatch": "De twee pincodes zijn niet gelijk.", + "pin_recovery": "Als u hem vergeet, kunt u alleen de gegevens op dit apparaat verwijderen en opnieuw inloggen. Uw mail op de server blijft. Berichten die op dit apparaat nog op verzending wachten, gaan verloren.", + "save": "Opslaan", + "cancel": "Annuleren", + "save_failed": "Dat is niet gelukt. Probeer het opnieuw.", + "timeout_label": "Vergrendelen na", + "timeout_desc": "Tijd zonder gebruik in een venster of tabblad.", + "lock_now": "Nu vergrendelen", + "lock_now_desc": "Vergrendelt alle vensters en tabbladen meteen.", + "disable_title": "App-vergrendeling uitzetten?", + "disable_body": "Voer uw pincode in om die uit te zetten.", + "disable_confirm": "Uitzetten", + "passkey_label": "Passkey", + "passkey_desc": "Ontgrendel met een passkey of beveiligingssleutel die de sleutel kan beschermen. Dat kunnen ze niet allemaal, en de pincode blijft werken.", + "passkey_add": "Passkey toevoegen…", + "passkey_add_title": "Passkey toevoegen", + "passkey_remove": "Verwijderen", + "passkey_added": "Passkey toegevoegd.", + "passkey_no_prf": "Deze passkey kan de sleutel niet beschermen. Uw pincode blijft werken.", + "passkey_failed": "De passkey werkte niet. Voer uw pincode in.", + "use_passkey": "Passkey gebruiken", + "use_hello": "Windows Hello gebruiken", + "use_touchid": "Touch ID gebruiken", + "os_add": "Aanzetten…", + "os_add_title": "{method} gebruiken", + "os_remove": "Uitzetten", + "os_desc": "{method} is een controle door de app. Na een ja leest de app de sleutel uit de versleutelde opslag van deze computer. Een programma onder uw gebruikersaccount kan hem daar ook zonder {method} lezen, dus de pincode beschermt meer. De pincode blijft werken.", + "os_failed": "Dat is niet gelukt. Voer uw pincode in.", + "lock_title": "{app} is vergrendeld", + "locking": "Vergrendelen…", + "pin_prompt": "Voer uw pincode in om verder te gaan.", + "unlock": "Ontgrendelen", + "unlocking": "Ontgrendelen…", + "wrong_pin": "Die pincode klopt niet.", + "unlock_failed": "Ontgrendelen is niet gelukt. Probeer het opnieuw.", + "blocked_later": "Te veel pogingen. Probeer het later opnieuw.", + "forgot": "Pincode vergeten?", + "forgot_title": "Lokale gegevens verwijderen en uitloggen?", + "forgot_body": "Een vergeten pincode kan niet worden hersteld. Dit verwijdert de mail op dit apparaat en logt alle accounts uit, zodat u opnieuw kunt inloggen. Uw mail op de server blijft. Berichten die op dit apparaat nog op verzending wachten, gaan verloren.", + "forgot_confirm": "Verwijderen en uitloggen", + "unlock_in_main": "Ontgrendel in het hoofdvenster.", + "open_main": "Naar het hoofdvenster", + "setup_required_title": "Uw organisatie vereist een app-vergrendeling", + "setup_required_body": "Kies een pincode. Die heeft u nodig om {app} na het vergrendelen te openen.", + "draft_saved_notice": "Wat u aan het schrijven was, is voor het vergrendelen opgeslagen in Concepten.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuut} other {# minuten}}", + "blocked_seconds": "Te veel pogingen. Probeer het over {count, plural, one {# seconde} other {# seconden}} opnieuw.", + "blocked_minutes": "Te veel pogingen. Probeer het over {count, plural, one {# minuut} other {# minuten}} opnieuw." + }, "desktop_sign_in": { "browser_button": "Inloggen met je browser", "browser_waiting": "Rond het inloggen af in je browser", diff --git a/locales/pl/common.json b/locales/pl/common.json index eb4f9f8fc..b057fd16c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Nie udało się skopiować pliku." } }, + "appLock": { + "section_title": "Blokada aplikacji", + "section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.", + "enable_label": "Blokuj kodem PIN", + "enable_desc": "Domyślnie wyłączone. PIN pozostaje na tym urządzeniu.", + "enable_body": "Wybierz PIN. Będzie potrzebny za każdym razem, gdy aplikacja się zablokuje, oraz po przeładowaniu lub ponownym uruchomieniu.", + "set_pin_title": "Wybierz PIN", + "change_pin": "Zmień PIN…", + "change_pin_title": "Zmień PIN", + "pin_label": "PIN", + "pin_current": "Obecny PIN", + "pin_new": "Nowy PIN", + "pin_repeat": "Powtórz PIN", + "pin_hint": "Co najmniej 6 znaków. Można też użyć liter, a dłuższy PIN trudniej odgadnąć.", + "pin_mismatch": "Te dwa kody PIN nie są takie same.", + "pin_recovery": "Jeśli go zapomnisz, jedynym wyjściem jest usunięcie danych z tego urządzenia i ponowne zalogowanie. Twoja poczta na serwerze zostaje. Wiadomości czekające na wysłanie na tym urządzeniu przepadną.", + "save": "Zapisz", + "cancel": "Anuluj", + "save_failed": "To się nie udało. Spróbuj ponownie.", + "timeout_label": "Blokuj po", + "timeout_desc": "Czas bez użycia w dowolnym oknie lub karcie.", + "lock_now": "Zablokuj teraz", + "lock_now_desc": "Blokuje od razu wszystkie okna i karty.", + "disable_title": "Wyłączyć blokadę aplikacji?", + "disable_body": "Wpisz PIN, aby ją wyłączyć.", + "disable_confirm": "Wyłącz", + "passkey_label": "Klucz dostępu", + "passkey_desc": "Odblokuj kluczem dostępu lub kluczem bezpieczeństwa, który potrafi chronić klucz. Nie każdy to potrafi, a PIN nadal działa.", + "passkey_add": "Dodaj klucz dostępu…", + "passkey_add_title": "Dodaj klucz dostępu", + "passkey_remove": "Usuń", + "passkey_added": "Dodano klucz dostępu.", + "passkey_no_prf": "Ten klucz dostępu nie może chronić klucza. PIN nadal działa.", + "passkey_failed": "Klucz dostępu nie zadziałał. Wpisz PIN.", + "use_passkey": "Użyj klucza dostępu", + "use_hello": "Użyj Windows Hello", + "use_touchid": "Użyj Touch ID", + "os_add": "Włącz…", + "os_add_title": "Użyj: {method}", + "os_remove": "Wyłącz", + "os_desc": "{method} to sprawdzenie wykonywane przez aplikację. Po potwierdzeniu aplikacja odczytuje klucz z zaszyfrowanego magazynu tego komputera. Program działający na Twoim koncie użytkownika mógłby go tam odczytać także bez tego, więc PIN chroni lepiej. PIN nadal działa.", + "os_failed": "To się nie udało. Wpisz PIN.", + "lock_title": "{app} jest zablokowana", + "locking": "Blokowanie…", + "pin_prompt": "Wpisz PIN, aby kontynuować.", + "unlock": "Odblokuj", + "unlocking": "Odblokowywanie…", + "wrong_pin": "Ten PIN jest nieprawidłowy.", + "unlock_failed": "Odblokowanie się nie udało. Spróbuj ponownie.", + "blocked_later": "Zbyt wiele prób. Spróbuj później.", + "forgot": "Nie pamiętasz kodu PIN?", + "forgot_title": "Usunąć dane lokalne i wylogować?", + "forgot_body": "Zapomnianego kodu PIN nie da się odzyskać. To usunie pocztę zapisaną na tym urządzeniu i wyloguje wszystkie konta, aby można było zalogować się ponownie. Twoja poczta na serwerze zostaje. Wiadomości czekające na wysłanie na tym urządzeniu przepadną.", + "forgot_confirm": "Usuń i wyloguj", + "unlock_in_main": "Odblokuj w oknie głównym.", + "open_main": "Przejdź do okna głównego", + "setup_required_title": "Twoja organizacja wymaga blokady aplikacji", + "setup_required_body": "Wybierz PIN. Będzie potrzebny do otwarcia {app} po zablokowaniu.", + "draft_saved_notice": "To, co pisano, zapisano w wersjach roboczych przed zablokowaniem.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuta} few {# minuty} many {# minut} other {# minuty}}", + "blocked_seconds": "Zbyt wiele prób. Spróbuj za {count, plural, one {# sekundę} few {# sekundy} many {# sekund} other {# sekundy}}.", + "blocked_minutes": "Zbyt wiele prób. Spróbuj za {count, plural, one {# minutę} few {# minuty} many {# minut} other {# minuty}}." + }, "desktop_sign_in": { "browser_button": "Zaloguj się w przeglądarce", "browser_waiting": "Dokończ logowanie w przeglądarce", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5235f5317..7f5008dbe 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Não foi possível copiar o ficheiro." } }, + "appLock": { + "section_title": "Bloqueio da app", + "section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.", + "enable_label": "Bloquear com um PIN", + "enable_desc": "Desativado por predefinição. O PIN fica neste dispositivo.", + "enable_body": "Escolha um PIN. Vai precisar dele sempre que a app bloquear, e depois de recarregar ou iniciar de novo.", + "set_pin_title": "Escolher um PIN", + "change_pin": "Alterar PIN…", + "change_pin_title": "Alterar o PIN", + "pin_label": "PIN", + "pin_current": "PIN atual", + "pin_new": "Novo PIN", + "pin_repeat": "Repetir o PIN", + "pin_hint": "Pelo menos 6 caracteres. Também pode usar letras, e um PIN mais longo é mais difícil de adivinhar.", + "pin_mismatch": "Os dois PIN não são iguais.", + "pin_recovery": "Se o esquecer, a única saída é remover os dados deste dispositivo e voltar a iniciar sessão. O seu correio no servidor mantém-se. As mensagens ainda à espera de envio neste dispositivo perdem-se.", + "save": "Guardar", + "cancel": "Cancelar", + "save_failed": "Não funcionou. Tente novamente.", + "timeout_label": "Bloquear após", + "timeout_desc": "Tempo sem uso em qualquer janela ou separador.", + "lock_now": "Bloquear agora", + "lock_now_desc": "Bloqueia todas as janelas e separadores de imediato.", + "disable_title": "Desativar o bloqueio?", + "disable_body": "Introduza o seu PIN para o desativar.", + "disable_confirm": "Desativar", + "passkey_label": "Chave de acesso", + "passkey_desc": "Desbloqueie com uma chave de acesso ou de segurança capaz de proteger a chave. Nem todas conseguem, e o PIN continua a funcionar.", + "passkey_add": "Adicionar uma chave de acesso…", + "passkey_add_title": "Adicionar uma chave de acesso", + "passkey_remove": "Remover", + "passkey_added": "Chave de acesso adicionada.", + "passkey_no_prf": "Esta chave de acesso não consegue proteger a chave. O seu PIN continua a funcionar.", + "passkey_failed": "A chave de acesso não funcionou. Introduza o seu PIN.", + "use_passkey": "Usar uma chave de acesso", + "use_hello": "Usar o Windows Hello", + "use_touchid": "Usar o Touch ID", + "os_add": "Ativar…", + "os_add_title": "Usar {method}", + "os_remove": "Desativar", + "os_desc": "{method} é uma verificação feita pela app. Depois de um sim, a app lê a chave do armazenamento cifrado deste computador. Um programa a correr na sua conta de utilizador poderia lê-la ali sem {method}, por isso o PIN protege mais. O PIN continua a funcionar.", + "os_failed": "Não funcionou. Introduza o seu PIN.", + "lock_title": "{app} está bloqueada", + "locking": "A bloquear…", + "pin_prompt": "Introduza o seu PIN para continuar.", + "unlock": "Desbloquear", + "unlocking": "A desbloquear…", + "wrong_pin": "Esse PIN não está certo.", + "unlock_failed": "O desbloqueio não funcionou. Tente novamente.", + "blocked_later": "Demasiadas tentativas. Tente mais tarde.", + "forgot": "Esqueceu-se do PIN?", + "forgot_title": "Remover os dados locais e terminar sessão?", + "forgot_body": "Um PIN esquecido não pode ser recuperado. Isto remove o correio guardado neste dispositivo e termina a sessão de todas as contas, para que possa entrar de novo. O seu correio no servidor mantém-se. As mensagens ainda à espera de envio neste dispositivo perdem-se.", + "forgot_confirm": "Remover e terminar sessão", + "unlock_in_main": "Desbloqueie na janela principal.", + "open_main": "Ir para a janela principal", + "setup_required_title": "A sua organização exige um bloqueio da app", + "setup_required_body": "Escolha um PIN. Vai precisar dele para abrir {app} depois de bloquear.", + "draft_saved_notice": "O que estava a escrever foi guardado nos Rascunhos antes do bloqueio.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minuto} other {# minutos}}", + "blocked_seconds": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# segundo} other {# segundos}}.", + "blocked_minutes": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# minuto} other {# minutos}}." + }, "desktop_sign_in": { "browser_button": "Entrar com o navegador", "browser_waiting": "Termine de entrar no navegador", diff --git a/locales/ro/common.json b/locales/ro/common.json index 5076823a5..e0bff076a 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Fișierul nu a putut fi copiat." } }, + "appLock": { + "section_title": "Blocarea aplicației", + "section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.", + "enable_label": "Blochează cu un PIN", + "enable_desc": "Dezactivat implicit. PIN-ul rămâne pe acest dispozitiv.", + "enable_body": "Alegeți un PIN. Veți avea nevoie de el de fiecare dată când aplicația se blochează, și după o reîncărcare sau o repornire.", + "set_pin_title": "Alegeți un PIN", + "change_pin": "Schimbați PIN-ul…", + "change_pin_title": "Schimbați PIN-ul", + "pin_label": "PIN", + "pin_current": "PIN-ul actual", + "pin_new": "PIN nou", + "pin_repeat": "Repetați PIN-ul", + "pin_hint": "Cel puțin 6 caractere. Merg și literele, iar un PIN mai lung e mai greu de ghicit.", + "pin_mismatch": "Cele două PIN-uri nu sunt la fel.", + "pin_recovery": "Dacă îl uitați, singura soluție este să eliminați datele de pe acest dispozitiv și să vă conectați din nou. E-mailurile de pe server rămân. Mesajele care încă așteaptă trimiterea pe acest dispozitiv se pierd.", + "save": "Salvați", + "cancel": "Anulați", + "save_failed": "Nu a mers. Încercați din nou.", + "timeout_label": "Blochează după", + "timeout_desc": "Timp fără utilizare în orice fereastră sau filă.", + "lock_now": "Blochează acum", + "lock_now_desc": "Blochează imediat toate ferestrele și filele.", + "disable_title": "Dezactivați blocarea?", + "disable_body": "Introduceți PIN-ul pentru a o dezactiva.", + "disable_confirm": "Dezactivați", + "passkey_label": "Cheie de acces", + "passkey_desc": "Deblocați cu o cheie de acces sau de securitate care poate proteja cheia. Nu toate pot, iar PIN-ul funcționează în continuare.", + "passkey_add": "Adăugați o cheie de acces…", + "passkey_add_title": "Adăugați o cheie de acces", + "passkey_remove": "Eliminați", + "passkey_added": "Cheia de acces a fost adăugată.", + "passkey_no_prf": "Această cheie de acces nu poate proteja cheia. PIN-ul funcționează în continuare.", + "passkey_failed": "Cheia de acces nu a mers. Introduceți PIN-ul.", + "use_passkey": "Folosiți o cheie de acces", + "use_hello": "Folosiți Windows Hello", + "use_touchid": "Folosiți Touch ID", + "os_add": "Activați…", + "os_add_title": "Folosiți {method}", + "os_remove": "Dezactivați", + "os_desc": "{method} este o verificare făcută de aplicație. După un da, aplicația citește cheia din spațiul criptat al acestui computer. Un program care rulează sub contul dvs. ar putea-o citi acolo fără {method}, deci PIN-ul protejează mai mult. PIN-ul funcționează în continuare.", + "os_failed": "Nu a mers. Introduceți PIN-ul.", + "lock_title": "{app} este blocată", + "locking": "Se blochează…", + "pin_prompt": "Introduceți PIN-ul pentru a continua.", + "unlock": "Deblocați", + "unlocking": "Se deblochează…", + "wrong_pin": "Acest PIN nu este corect.", + "unlock_failed": "Deblocarea nu a mers. Încercați din nou.", + "blocked_later": "Prea multe încercări. Încercați mai târziu.", + "forgot": "Ați uitat PIN-ul?", + "forgot_title": "Eliminați datele locale și vă deconectați?", + "forgot_body": "Un PIN uitat nu poate fi recuperat. Astfel se elimină e-mailurile păstrate pe acest dispozitiv și toate conturile sunt deconectate, ca să vă puteți conecta din nou. E-mailurile de pe server rămân. Mesajele care încă așteaptă trimiterea pe acest dispozitiv se pierd.", + "forgot_confirm": "Eliminați și deconectați", + "unlock_in_main": "Deblocați în fereastra principală.", + "open_main": "Mergeți la fereastra principală", + "setup_required_title": "Organizația dvs. cere o blocare a aplicației", + "setup_required_body": "Alegeți un PIN. Veți avea nevoie de el pentru a deschide {app} după blocare.", + "draft_saved_notice": "Ce scriați a fost salvat în Ciorne înainte de blocare.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minut} few {# minute} other {# de minute}}", + "blocked_seconds": "Prea multe încercări. Încercați din nou peste {count, plural, one {# secundă} few {# secunde} other {# de secunde}}.", + "blocked_minutes": "Prea multe încercări. Încercați din nou peste {count, plural, one {# minut} few {# minute} other {# de minute}}." + }, "desktop_sign_in": { "browser_button": "Conectează-te cu browserul", "browser_waiting": "Finalizează conectarea în browser", diff --git a/locales/ru/common.json b/locales/ru/common.json index 05ce8b7b8..45ed49d6f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Не удалось скопировать файл." } }, + "appLock": { + "section_title": "Блокировка приложения", + "section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.", + "enable_label": "Блокировать PIN-кодом", + "enable_desc": "По умолчанию выключено. PIN-код остаётся на этом устройстве.", + "enable_body": "Выберите PIN-код. Он понадобится при каждой блокировке приложения, а также после перезагрузки или нового запуска.", + "set_pin_title": "Выберите PIN-код", + "change_pin": "Изменить PIN-код…", + "change_pin_title": "Изменить PIN-код", + "pin_label": "PIN-код", + "pin_current": "Текущий PIN-код", + "pin_new": "Новый PIN-код", + "pin_repeat": "Повторите PIN-код", + "pin_hint": "Не менее 6 символов. Можно использовать буквы, а длинный PIN-код труднее угадать.", + "pin_mismatch": "PIN-коды не совпадают.", + "pin_recovery": "Если вы его забудете, останется только удалить данные на этом устройстве и войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.", + "save": "Сохранить", + "cancel": "Отмена", + "save_failed": "Не получилось. Попробуйте ещё раз.", + "timeout_label": "Блокировать через", + "timeout_desc": "Время бездействия в любом окне или вкладке.", + "lock_now": "Заблокировать сейчас", + "lock_now_desc": "Сразу блокирует все окна и вкладки.", + "disable_title": "Выключить блокировку приложения?", + "disable_body": "Введите PIN-код, чтобы выключить её.", + "disable_confirm": "Выключить", + "passkey_label": "Ключ доступа", + "passkey_desc": "Разблокировка ключом доступа или ключом безопасности, способным защитить ключ. Не все на это способны, а PIN-код продолжает работать.", + "passkey_add": "Добавить ключ доступа…", + "passkey_add_title": "Добавить ключ доступа", + "passkey_remove": "Удалить", + "passkey_added": "Ключ доступа добавлен.", + "passkey_no_prf": "Этот ключ доступа не может защитить ключ. PIN-код продолжает работать.", + "passkey_failed": "Ключ доступа не сработал. Введите PIN-код.", + "use_passkey": "Использовать ключ доступа", + "use_hello": "Использовать Windows Hello", + "use_touchid": "Использовать Touch ID", + "os_add": "Включить…", + "os_add_title": "Использовать {method}", + "os_remove": "Выключить", + "os_desc": "{method} — это проверка, которую выполняет приложение. После подтверждения приложение читает ключ из зашифрованного хранилища этого компьютера. Программа, запущенная под вашей учётной записью, могла бы прочитать его там и без {method}, поэтому PIN-код защищает лучше. PIN-код продолжает работать.", + "os_failed": "Не получилось. Введите PIN-код.", + "lock_title": "{app}: заблокировано", + "locking": "Блокировка…", + "pin_prompt": "Введите PIN-код, чтобы продолжить.", + "unlock": "Разблокировать", + "unlocking": "Разблокировка…", + "wrong_pin": "Этот PIN-код неверный.", + "unlock_failed": "Не удалось разблокировать. Попробуйте ещё раз.", + "blocked_later": "Слишком много попыток. Попробуйте позже.", + "forgot": "Забыли PIN-код?", + "forgot_title": "Удалить локальные данные и выйти?", + "forgot_body": "Забытый PIN-код нельзя восстановить. Будет удалена почта, хранящаяся на этом устройстве, и выполнен выход из всех учётных записей, чтобы вы могли войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.", + "forgot_confirm": "Удалить и выйти", + "unlock_in_main": "Разблокируйте в главном окне.", + "open_main": "Перейти в главное окно", + "setup_required_title": "Ваша организация требует блокировку приложения", + "setup_required_body": "Выберите PIN-код. Он понадобится, чтобы открыть {app} после блокировки.", + "draft_saved_notice": "Написанное было сохранено в черновиках перед блокировкой.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# минута} few {# минуты} many {# минут} other {# минуты}}", + "blocked_seconds": "Слишком много попыток. Повторите через {count, plural, one {# секунду} few {# секунды} many {# секунд} other {# секунды}}.", + "blocked_minutes": "Слишком много попыток. Повторите через {count, plural, one {# минуту} few {# минуты} many {# минут} other {# минуты}}." + }, "desktop_sign_in": { "browser_button": "Войти через браузер", "browser_waiting": "Завершите вход в браузере", diff --git a/locales/sk/common.json b/locales/sk/common.json index 392915139..0f201b60d 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Súbor sa nepodarilo skopírovať." } }, + "appLock": { + "section_title": "Zámok aplikácie", + "section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.", + "enable_label": "Zamykať kódom PIN", + "enable_desc": "V predvolenom stave vypnuté. PIN zostáva na tomto zariadení.", + "enable_body": "Zvoľte PIN. Budete ho potrebovať pri každom zamknutí aplikácie a po opätovnom načítaní alebo novom spustení.", + "set_pin_title": "Zvoľte PIN", + "change_pin": "Zmeniť PIN…", + "change_pin_title": "Zmeniť PIN", + "pin_label": "PIN", + "pin_current": "Súčasný PIN", + "pin_new": "Nový PIN", + "pin_repeat": "Zopakujte PIN", + "pin_hint": "Aspoň 6 znakov. Fungujú aj písmená a dlhší PIN sa ťažšie uhádne.", + "pin_mismatch": "Oba kódy PIN nie sú rovnaké.", + "pin_recovery": "Ak ho zabudnete, jedinou cestou je odstrániť údaje z tohto zariadenia a znova sa prihlásiť. Vaša pošta na serveri zostáva. Správy čakajúce na odoslanie na tomto zariadení sa stratia.", + "save": "Uložiť", + "cancel": "Zrušiť", + "save_failed": "Nepodarilo sa to. Skúste to znova.", + "timeout_label": "Zamknúť po", + "timeout_desc": "Čas bez používania v ktoromkoľvek okne alebo karte.", + "lock_now": "Zamknúť teraz", + "lock_now_desc": "Zamkne hneď všetky okná a karty.", + "disable_title": "Vypnúť zámok aplikácie?", + "disable_body": "Na vypnutie zadajte PIN.", + "disable_confirm": "Vypnúť", + "passkey_label": "Prístupový kľúč", + "passkey_desc": "Odomknite prístupovým alebo bezpečnostným kľúčom, ktorý vie chrániť kľúč. Nie všetky to vedia a PIN ďalej funguje.", + "passkey_add": "Pridať prístupový kľúč…", + "passkey_add_title": "Pridať prístupový kľúč", + "passkey_remove": "Odstrániť", + "passkey_added": "Prístupový kľúč bol pridaný.", + "passkey_no_prf": "Tento prístupový kľúč nemôže chrániť kľúč. PIN ďalej funguje.", + "passkey_failed": "Prístupový kľúč nefungoval. Zadajte PIN.", + "use_passkey": "Použiť prístupový kľúč", + "use_hello": "Použiť Windows Hello", + "use_touchid": "Použiť Touch ID", + "os_add": "Zapnúť…", + "os_add_title": "Použiť {method}", + "os_remove": "Vypnúť", + "os_desc": "{method} je kontrola, ktorú robí aplikácia. Po potvrdení aplikácia prečíta kľúč zo šifrovaného úložiska tohto počítača. Program bežiaci pod vaším používateľským účtom by ho tam mohol prečítať aj bez toho, takže PIN chráni viac. PIN ďalej funguje.", + "os_failed": "Nepodarilo sa to. Zadajte PIN.", + "lock_title": "{app} je zamknutá", + "locking": "Zamyká sa…", + "pin_prompt": "Pokračujte zadaním kódu PIN.", + "unlock": "Odomknúť", + "unlocking": "Odomyká sa…", + "wrong_pin": "Tento PIN nie je správny.", + "unlock_failed": "Odomknutie sa nepodarilo. Skúste to znova.", + "blocked_later": "Príliš veľa pokusov. Skúste to neskôr.", + "forgot": "Zabudli ste PIN?", + "forgot_title": "Odstrániť miestne údaje a odhlásiť sa?", + "forgot_body": "Zabudnutý PIN sa nedá obnoviť. Odstráni sa tým pošta uložená na tomto zariadení a odhlásia sa všetky účty, aby ste sa mohli znova prihlásiť. Vaša pošta na serveri zostáva. Správy čakajúce na odoslanie na tomto zariadení sa stratia.", + "forgot_confirm": "Odstrániť a odhlásiť", + "unlock_in_main": "Odomknite v hlavnom okne.", + "open_main": "Prejsť do hlavného okna", + "setup_required_title": "Vaša organizácia vyžaduje zámok aplikácie", + "setup_required_body": "Zvoľte PIN. Budete ho potrebovať na otvorenie {app} po zamknutí.", + "draft_saved_notice": "To, čo ste písali, sa pred zamknutím uložilo do konceptov.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# minúta} few {# minúty} many {# minúty} other {# minút}}", + "blocked_seconds": "Príliš veľa pokusov. Skúste to o {count, plural, one {# sekundu} few {# sekundy} many {# sekundy} other {# sekúnd}}.", + "blocked_minutes": "Príliš veľa pokusov. Skúste to o {count, plural, one {# minútu} few {# minúty} many {# minúty} other {# minút}}." + }, "desktop_sign_in": { "browser_button": "Prihlásiť sa v prehliadači", "browser_waiting": "Dokončite prihlásenie v prehliadači", diff --git a/locales/th/common.json b/locales/th/common.json index 0c34470ff..b81114f0f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6568,6 +6568,71 @@ "copy_failed": "คัดลอกไฟล์ไม่ได้" } }, + "appLock": { + "section_title": "ล็อกแอป", + "section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ", + "enable_label": "ล็อกด้วย PIN", + "enable_desc": "ปิดอยู่โดยค่าเริ่มต้น PIN จะอยู่บนอุปกรณ์นี้", + "enable_body": "เลือก PIN คุณต้องใช้ทุกครั้งที่แอปล็อก และหลังโหลดใหม่หรือเริ่มใหม่", + "set_pin_title": "เลือก PIN", + "change_pin": "เปลี่ยน PIN…", + "change_pin_title": "เปลี่ยน PIN", + "pin_label": "PIN", + "pin_current": "PIN ปัจจุบัน", + "pin_new": "PIN ใหม่", + "pin_repeat": "ใส่ PIN อีกครั้ง", + "pin_hint": "อย่างน้อย 6 ตัวอักษร ใช้ตัวอักษรได้ และ PIN ที่ยาวกว่าเดาได้ยากกว่า", + "pin_mismatch": "PIN ทั้งสองไม่ตรงกัน", + "pin_recovery": "หากลืม วิธีเดียวคือลบข้อมูลบนอุปกรณ์นี้แล้วลงชื่อเข้าใช้ใหม่ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป", + "save": "บันทึก", + "cancel": "ยกเลิก", + "save_failed": "ไม่สำเร็จ ลองอีกครั้ง", + "timeout_label": "ล็อกหลังจาก", + "timeout_desc": "เวลาที่ไม่ได้ใช้งานในหน้าต่างหรือแท็บใดก็ตาม", + "lock_now": "ล็อกเดี๋ยวนี้", + "lock_now_desc": "ล็อกทุกหน้าต่างและแท็บทันที", + "disable_title": "ปิดการล็อกแอปหรือไม่", + "disable_body": "ใส่ PIN เพื่อปิด", + "disable_confirm": "ปิด", + "passkey_label": "พาสคีย์", + "passkey_desc": "ปลดล็อกด้วยพาสคีย์หรือคีย์ความปลอดภัยที่ปกป้องกุญแจได้ ไม่ใช่ทุกอันที่ทำได้ และ PIN ยังใช้ได้", + "passkey_add": "เพิ่มพาสคีย์…", + "passkey_add_title": "เพิ่มพาสคีย์", + "passkey_remove": "ลบ", + "passkey_added": "เพิ่มพาสคีย์แล้ว", + "passkey_no_prf": "พาสคีย์นี้ปกป้องกุญแจไม่ได้ PIN ยังใช้ได้", + "passkey_failed": "พาสคีย์ใช้ไม่ได้ ใส่ PIN แทน", + "use_passkey": "ใช้พาสคีย์", + "use_hello": "ใช้ Windows Hello", + "use_touchid": "ใช้ Touch ID", + "os_add": "เปิด…", + "os_add_title": "ใช้ {method}", + "os_remove": "ปิด", + "os_desc": "{method} เป็นการตรวจสอบโดยแอป หลังจากยืนยัน แอปจะอ่านกุญแจจากที่เก็บแบบเข้ารหัสของคอมพิวเตอร์นี้ โปรแกรมที่ทำงานภายใต้บัญชีผู้ใช้ของคุณอาจอ่านได้โดยไม่ต้องใช้ {method} ดังนั้น PIN จึงปกป้องได้มากกว่า PIN ยังใช้ได้", + "os_failed": "ไม่สำเร็จ ใส่ PIN แทน", + "lock_title": "{app} ถูกล็อก", + "locking": "กำลังล็อก…", + "pin_prompt": "ใส่ PIN เพื่อดำเนินการต่อ", + "unlock": "ปลดล็อก", + "unlocking": "กำลังปลดล็อก…", + "wrong_pin": "PIN นี้ไม่ถูกต้อง", + "unlock_failed": "ปลดล็อกไม่สำเร็จ ลองอีกครั้ง", + "blocked_later": "ลองหลายครั้งเกินไป ลองอีกครั้งภายหลัง", + "forgot": "ลืม PIN หรือไม่", + "forgot_title": "ลบข้อมูลในเครื่องและออกจากระบบหรือไม่", + "forgot_body": "PIN ที่ลืมไม่สามารถกู้คืนได้ การดำเนินการนี้จะลบอีเมลที่เก็บบนอุปกรณ์นี้และออกจากระบบทุกบัญชี เพื่อให้คุณลงชื่อเข้าใช้ใหม่ได้ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป", + "forgot_confirm": "ลบและออกจากระบบ", + "unlock_in_main": "ปลดล็อกในหน้าต่างหลัก", + "open_main": "ไปที่หน้าต่างหลัก", + "setup_required_title": "องค์กรของคุณกำหนดให้ใช้การล็อกแอป", + "setup_required_body": "เลือก PIN คุณต้องใช้เพื่อเปิด {app} หลังจากล็อก", + "draft_saved_notice": "สิ่งที่คุณเขียนถูกบันทึกในแบบร่างก่อนล็อก", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# นาที}}", + "blocked_seconds": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# วินาที}}", + "blocked_minutes": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# นาที}}" + }, "desktop_sign_in": { "browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์", "browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", diff --git a/locales/tr/common.json b/locales/tr/common.json index 5e5365633..28f0c7260 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Dosya kopyalanamadı." } }, + "appLock": { + "section_title": "Uygulama kilidi", + "section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.", + "enable_label": "PIN ile kilitle", + "enable_desc": "Varsayılan olarak kapalı. PIN bu cihazda kalır.", + "enable_body": "Bir PIN seçin. Uygulama her kilitlendiğinde ve yeniden yükleme ya da yeni başlatma sonrasında gerekir.", + "set_pin_title": "PIN seçin", + "change_pin": "PIN'i değiştir…", + "change_pin_title": "PIN'i değiştir", + "pin_label": "PIN", + "pin_current": "Geçerli PIN", + "pin_new": "Yeni PIN", + "pin_repeat": "PIN'i tekrarlayın", + "pin_hint": "En az 6 karakter. Harfler de olur ve daha uzun bir PIN'i tahmin etmek daha zordur.", + "pin_mismatch": "İki PIN aynı değil.", + "pin_recovery": "Unutursanız tek çözüm bu cihazdaki verileri kaldırıp yeniden oturum açmaktır. Sunucudaki e-postalarınız kalır. Bu cihazda hâlâ gönderilmeyi bekleyen iletiler kaybolur.", + "save": "Kaydet", + "cancel": "İptal", + "save_failed": "Olmadı. Yeniden deneyin.", + "timeout_label": "Şu süre sonra kilitle", + "timeout_desc": "Herhangi bir pencerede veya sekmede kullanılmayan süre.", + "lock_now": "Şimdi kilitle", + "lock_now_desc": "Tüm pencereleri ve sekmeleri hemen kilitler.", + "disable_title": "Uygulama kilidi kapatılsın mı?", + "disable_body": "Kapatmak için PIN'inizi girin.", + "disable_confirm": "Kapat", + "passkey_label": "Geçiş anahtarı", + "passkey_desc": "Anahtarı koruyabilen bir geçiş anahtarı veya güvenlik anahtarıyla açın. Hepsi koruyamaz ve PIN çalışmaya devam eder.", + "passkey_add": "Geçiş anahtarı ekle…", + "passkey_add_title": "Geçiş anahtarı ekle", + "passkey_remove": "Kaldır", + "passkey_added": "Geçiş anahtarı eklendi.", + "passkey_no_prf": "Bu geçiş anahtarı anahtarı koruyamaz. PIN'iniz çalışmaya devam eder.", + "passkey_failed": "Geçiş anahtarı çalışmadı. PIN'inizi girin.", + "use_passkey": "Geçiş anahtarı kullan", + "use_hello": "Windows Hello kullan", + "use_touchid": "Touch ID kullan", + "os_add": "Aç…", + "os_add_title": "{method} kullan", + "os_remove": "Kapat", + "os_desc": "{method}, uygulamanın yaptığı bir denetimdir. Onaydan sonra uygulama anahtarı bu bilgisayarın şifreli deposundan okur. Kullanıcı hesabınızda çalışan bir program onu orada {method} olmadan da okuyabilir, bu yüzden PIN daha çok korur. PIN çalışmaya devam eder.", + "os_failed": "Olmadı. PIN'inizi girin.", + "lock_title": "{app} kilitli", + "locking": "Kilitleniyor…", + "pin_prompt": "Devam etmek için PIN'inizi girin.", + "unlock": "Kilidi aç", + "unlocking": "Kilit açılıyor…", + "wrong_pin": "Bu PIN doğru değil.", + "unlock_failed": "Kilit açılamadı. Yeniden deneyin.", + "blocked_later": "Çok fazla deneme. Daha sonra yeniden deneyin.", + "forgot": "PIN'inizi mi unuttunuz?", + "forgot_title": "Yerel veriler kaldırılıp oturum kapatılsın mı?", + "forgot_body": "Unutulan PIN kurtarılamaz. Bu işlem bu cihazda tutulan e-postaları kaldırır ve yeniden oturum açabilmeniz için tüm hesapların oturumunu kapatır. Sunucudaki e-postalarınız kalır. Bu cihazda hâlâ gönderilmeyi bekleyen iletiler kaybolur.", + "forgot_confirm": "Kaldır ve oturumu kapat", + "unlock_in_main": "Ana pencerede kilidi açın.", + "open_main": "Ana pencereye git", + "setup_required_title": "Kuruluşunuz uygulama kilidi istiyor", + "setup_required_body": "Bir PIN seçin. {app} kilitlendikten sonra açmak için gerekir.", + "draft_saved_notice": "Yazdıklarınız kilitlenmeden önce Taslaklar'a kaydedildi.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# dakika} other {# dakika}}", + "blocked_seconds": "Çok fazla deneme. {count, plural, one {# saniye} other {# saniye}} sonra yeniden deneyin.", + "blocked_minutes": "Çok fazla deneme. {count, plural, one {# dakika} other {# dakika}} sonra yeniden deneyin." + }, "desktop_sign_in": { "browser_button": "Tarayıcıyla oturum aç", "browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın", diff --git a/locales/uk/common.json b/locales/uk/common.json index 9d6aaed35..cb8f53185 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "Не вдалося скопіювати файл." } }, + "appLock": { + "section_title": "Блокування застосунку", + "section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.", + "enable_label": "Блокувати PIN-кодом", + "enable_desc": "Типово вимкнено. PIN-код залишається на цьому пристрої.", + "enable_body": "Виберіть PIN-код. Він знадобиться щоразу, коли застосунок блокується, а також після перезавантаження чи нового запуску.", + "set_pin_title": "Виберіть PIN-код", + "change_pin": "Змінити PIN-код…", + "change_pin_title": "Змінити PIN-код", + "pin_label": "PIN-код", + "pin_current": "Поточний PIN-код", + "pin_new": "Новий PIN-код", + "pin_repeat": "Повторіть PIN-код", + "pin_hint": "Щонайменше 6 символів. Можна вводити літери, а довший PIN-код важче вгадати.", + "pin_mismatch": "PIN-коди не збігаються.", + "pin_recovery": "Якщо ви його забудете, залишиться лише вилучити дані на цьому пристрої й увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.", + "save": "Зберегти", + "cancel": "Скасувати", + "save_failed": "Не вдалося. Спробуйте ще раз.", + "timeout_label": "Блокувати через", + "timeout_desc": "Час без використання в будь-якому вікні чи вкладці.", + "lock_now": "Заблокувати зараз", + "lock_now_desc": "Одразу блокує всі вікна й вкладки.", + "disable_title": "Вимкнути блокування застосунку?", + "disable_body": "Введіть PIN-код, щоб вимкнути його.", + "disable_confirm": "Вимкнути", + "passkey_label": "Ключ доступу", + "passkey_desc": "Розблокування ключем доступу або ключем безпеки, що може захистити ключ. Не всі можуть, а PIN-код продовжує працювати.", + "passkey_add": "Додати ключ доступу…", + "passkey_add_title": "Додати ключ доступу", + "passkey_remove": "Вилучити", + "passkey_added": "Ключ доступу додано.", + "passkey_no_prf": "Цей ключ доступу не може захистити ключ. PIN-код продовжує працювати.", + "passkey_failed": "Ключ доступу не спрацював. Введіть PIN-код.", + "use_passkey": "Використати ключ доступу", + "use_hello": "Використати Windows Hello", + "use_touchid": "Використати Touch ID", + "os_add": "Увімкнути…", + "os_add_title": "Використати {method}", + "os_remove": "Вимкнути", + "os_desc": "{method} — це перевірка, яку виконує застосунок. Після підтвердження застосунок читає ключ із зашифрованого сховища цього комп'ютера. Програма під вашим обліковим записом могла б прочитати його там і без {method}, тому PIN-код захищає краще. PIN-код продовжує працювати.", + "os_failed": "Не вдалося. Введіть PIN-код.", + "lock_title": "{app}: заблоковано", + "locking": "Блокування…", + "pin_prompt": "Введіть PIN-код, щоб продовжити.", + "unlock": "Розблокувати", + "unlocking": "Розблокування…", + "wrong_pin": "Цей PIN-код неправильний.", + "unlock_failed": "Не вдалося розблокувати. Спробуйте ще раз.", + "blocked_later": "Забагато спроб. Спробуйте пізніше.", + "forgot": "Забули PIN-код?", + "forgot_title": "Вилучити локальні дані й вийти?", + "forgot_body": "Забутий PIN-код не можна відновити. Буде вилучено пошту, збережену на цьому пристрої, і виконано вихід з усіх облікових записів, щоб ви могли увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.", + "forgot_confirm": "Вилучити й вийти", + "unlock_in_main": "Розблокуйте в головному вікні.", + "open_main": "Перейти до головного вікна", + "setup_required_title": "Ваша організація вимагає блокування застосунку", + "setup_required_body": "Виберіть PIN-код. Він знадобиться, щоб відкрити {app} після блокування.", + "draft_saved_notice": "Написане було збережено в чернетках перед блокуванням.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, one {# хвилина} few {# хвилини} many {# хвилин} other {# хвилини}}", + "blocked_seconds": "Забагато спроб. Повторіть через {count, plural, one {# секунду} few {# секунди} many {# секунд} other {# секунди}}.", + "blocked_minutes": "Забагато спроб. Повторіть через {count, plural, one {# хвилину} few {# хвилини} many {# хвилин} other {# хвилини}}." + }, "desktop_sign_in": { "browser_button": "Увійти через браузер", "browser_waiting": "Завершіть вхід у браузері", diff --git a/locales/vi/common.json b/locales/vi/common.json index 2076cdb65..c955ba865 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6572,6 +6572,71 @@ "copy_failed": "Không thể sao chép tệp." } }, + "appLock": { + "section_title": "Khóa ứng dụng", + "section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.", + "enable_label": "Khóa bằng mã PIN", + "enable_desc": "Mặc định tắt. Mã PIN chỉ ở trên thiết bị này.", + "enable_body": "Hãy chọn một mã PIN. Bạn cần nó mỗi khi ứng dụng khóa, và sau khi tải lại hoặc khởi động lại.", + "set_pin_title": "Chọn mã PIN", + "change_pin": "Đổi mã PIN…", + "change_pin_title": "Đổi mã PIN", + "pin_label": "Mã PIN", + "pin_current": "Mã PIN hiện tại", + "pin_new": "Mã PIN mới", + "pin_repeat": "Nhập lại mã PIN", + "pin_hint": "Ít nhất 6 ký tự. Có thể dùng cả chữ cái, và mã dài hơn sẽ khó đoán hơn.", + "pin_mismatch": "Hai mã PIN không giống nhau.", + "pin_recovery": "Nếu quên, cách duy nhất là xóa dữ liệu trên thiết bị này và đăng nhập lại. Thư của bạn trên máy chủ vẫn còn. Các thư đang chờ gửi trên thiết bị này sẽ bị mất.", + "save": "Lưu", + "cancel": "Hủy", + "save_failed": "Không thành công. Hãy thử lại.", + "timeout_label": "Khóa sau", + "timeout_desc": "Thời gian không dùng ở bất kỳ cửa sổ hoặc thẻ nào.", + "lock_now": "Khóa ngay", + "lock_now_desc": "Khóa ngay mọi cửa sổ và thẻ.", + "disable_title": "Tắt khóa ứng dụng?", + "disable_body": "Nhập mã PIN để tắt.", + "disable_confirm": "Tắt", + "passkey_label": "Khóa truy cập", + "passkey_desc": "Mở khóa bằng khóa truy cập hoặc khóa bảo mật có thể bảo vệ khóa. Không phải loại nào cũng làm được, và mã PIN vẫn dùng được.", + "passkey_add": "Thêm khóa truy cập…", + "passkey_add_title": "Thêm khóa truy cập", + "passkey_remove": "Gỡ bỏ", + "passkey_added": "Đã thêm khóa truy cập.", + "passkey_no_prf": "Khóa truy cập này không thể bảo vệ khóa. Mã PIN vẫn dùng được.", + "passkey_failed": "Khóa truy cập không hoạt động. Hãy nhập mã PIN.", + "use_passkey": "Dùng khóa truy cập", + "use_hello": "Dùng Windows Hello", + "use_touchid": "Dùng Touch ID", + "os_add": "Bật…", + "os_add_title": "Dùng {method}", + "os_remove": "Tắt", + "os_desc": "{method} là bước kiểm tra do ứng dụng thực hiện. Sau khi xác nhận, ứng dụng đọc khóa từ bộ nhớ mã hóa của máy tính này. Một chương trình chạy dưới tài khoản người dùng của bạn có thể đọc nó ở đó mà không cần {method}, nên mã PIN bảo vệ tốt hơn. Mã PIN vẫn dùng được.", + "os_failed": "Không thành công. Hãy nhập mã PIN.", + "lock_title": "{app} đang khóa", + "locking": "Đang khóa…", + "pin_prompt": "Nhập mã PIN để tiếp tục.", + "unlock": "Mở khóa", + "unlocking": "Đang mở khóa…", + "wrong_pin": "Mã PIN này không đúng.", + "unlock_failed": "Không mở khóa được. Hãy thử lại.", + "blocked_later": "Quá nhiều lần thử. Hãy thử lại sau.", + "forgot": "Quên mã PIN?", + "forgot_title": "Xóa dữ liệu cục bộ và đăng xuất?", + "forgot_body": "Mã PIN đã quên không thể khôi phục. Thao tác này xóa thư lưu trên thiết bị này và đăng xuất mọi tài khoản để bạn có thể đăng nhập lại. Thư của bạn trên máy chủ vẫn còn. Các thư đang chờ gửi trên thiết bị này sẽ bị mất.", + "forgot_confirm": "Xóa và đăng xuất", + "unlock_in_main": "Hãy mở khóa trong cửa sổ chính.", + "open_main": "Đến cửa sổ chính", + "setup_required_title": "Tổ chức của bạn yêu cầu khóa ứng dụng", + "setup_required_body": "Hãy chọn một mã PIN. Bạn cần nó để mở {app} sau khi khóa.", + "draft_saved_notice": "Nội dung bạn đang viết đã được lưu vào Thư nháp trước khi khóa.", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# phút}}", + "blocked_seconds": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# giây}}.", + "blocked_minutes": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# phút}}." + }, "desktop_sign_in": { "browser_button": "Đăng nhập bằng trình duyệt", "browser_waiting": "Hoàn tất đăng nhập trong trình duyệt", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 53eefab4f..b26f4da00 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "無法複製檔案。" } }, + "appLock": { + "section_title": "應用程式鎖定", + "section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。", + "enable_label": "使用 PIN 鎖定", + "enable_desc": "預設關閉。PIN 只保存在此裝置上。", + "enable_body": "請選擇一個 PIN。每次應用程式鎖定時以及重新載入或重新啟動後都需要它。", + "set_pin_title": "選擇 PIN", + "change_pin": "變更 PIN…", + "change_pin_title": "變更 PIN", + "pin_label": "PIN", + "pin_current": "目前的 PIN", + "pin_new": "新 PIN", + "pin_repeat": "重複 PIN", + "pin_hint": "至少 6 個字元。也可以使用字母,較長的 PIN 更難猜到。", + "pin_mismatch": "兩個 PIN 不一致。", + "pin_recovery": "如果忘記了,唯一的辦法是移除此裝置上的資料並重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。", + "save": "儲存", + "cancel": "取消", + "save_failed": "未成功。請再試一次。", + "timeout_label": "鎖定時間", + "timeout_desc": "任一視窗或分頁未使用的時間。", + "lock_now": "立即鎖定", + "lock_now_desc": "立即鎖定所有視窗和分頁。", + "disable_title": "關閉應用程式鎖定?", + "disable_body": "輸入 PIN 以將其關閉。", + "disable_confirm": "關閉", + "passkey_label": "密碼金鑰", + "passkey_desc": "使用能夠保護金鑰的密碼金鑰或安全金鑰解鎖。並非全部都支援,PIN 仍然可用。", + "passkey_add": "新增密碼金鑰…", + "passkey_add_title": "新增密碼金鑰", + "passkey_remove": "移除", + "passkey_added": "已新增密碼金鑰。", + "passkey_no_prf": "此密碼金鑰無法保護金鑰。PIN 仍然可用。", + "passkey_failed": "密碼金鑰無法運作。請輸入 PIN。", + "use_passkey": "使用密碼金鑰", + "use_hello": "使用 Windows Hello", + "use_touchid": "使用 Touch ID", + "os_add": "開啟…", + "os_add_title": "使用 {method}", + "os_remove": "關閉", + "os_desc": "{method} 是應用程式進行的檢查。確認後,應用程式從此電腦的加密儲存空間讀取金鑰。在您的使用者帳戶下執行的程式不需 {method} 也能讀取,因此 PIN 的保護更強。PIN 仍然可用。", + "os_failed": "未成功。請輸入 PIN。", + "lock_title": "{app} 已鎖定", + "locking": "正在鎖定…", + "pin_prompt": "輸入 PIN 以繼續。", + "unlock": "解鎖", + "unlocking": "正在解鎖…", + "wrong_pin": "此 PIN 不正確。", + "unlock_failed": "解鎖未成功。請再試一次。", + "blocked_later": "嘗試次數過多。請稍後再試。", + "forgot": "忘記了 PIN?", + "forgot_title": "移除本機資料並登出?", + "forgot_body": "忘記的 PIN 無法復原。這會移除此裝置上保存的郵件並登出所有帳戶,讓您可以重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。", + "forgot_confirm": "移除並登出", + "unlock_in_main": "請在主視窗中解鎖。", + "open_main": "前往主視窗", + "setup_required_title": "您的組織要求使用應用程式鎖定", + "setup_required_body": "請選擇一個 PIN。鎖定後開啟 {app} 時需要它。", + "draft_saved_notice": "您正在撰寫的內容已在鎖定前儲存到草稿。", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# 分鐘}}", + "blocked_seconds": "嘗試次數過多。請在 {count, plural, other {# 秒}}後再試。", + "blocked_minutes": "嘗試次數過多。請在 {count, plural, other {# 分鐘}}後再試。" + }, "desktop_sign_in": { "browser_button": "使用瀏覽器登入", "browser_waiting": "請在瀏覽器中完成登入", diff --git a/locales/zh/common.json b/locales/zh/common.json index 770f679eb..f58b041f6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6560,6 +6560,71 @@ "copy_failed": "无法复制文件。" } }, + "appLock": { + "section_title": "应用锁", + "section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。", + "enable_label": "使用 PIN 锁定", + "enable_desc": "默认关闭。PIN 只保存在此设备上。", + "enable_body": "请选择一个 PIN。每次应用锁定时以及重新加载或重新启动后都需要它。", + "set_pin_title": "选择 PIN", + "change_pin": "更改 PIN…", + "change_pin_title": "更改 PIN", + "pin_label": "PIN", + "pin_current": "当前 PIN", + "pin_new": "新 PIN", + "pin_repeat": "重复 PIN", + "pin_hint": "至少 6 个字符。也可以使用字母,较长的 PIN 更难猜到。", + "pin_mismatch": "两个 PIN 不一致。", + "pin_recovery": "如果忘记了,唯一的办法是删除此设备上的数据并重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。", + "save": "保存", + "cancel": "取消", + "save_failed": "未成功。请重试。", + "timeout_label": "锁定时间", + "timeout_desc": "任一窗口或标签页未使用的时间。", + "lock_now": "立即锁定", + "lock_now_desc": "立即锁定所有窗口和标签页。", + "disable_title": "关闭应用锁?", + "disable_body": "输入 PIN 以将其关闭。", + "disable_confirm": "关闭", + "passkey_label": "通行密钥", + "passkey_desc": "使用能够保护密钥的通行密钥或安全密钥解锁。并非全部都支持,PIN 仍然可用。", + "passkey_add": "添加通行密钥…", + "passkey_add_title": "添加通行密钥", + "passkey_remove": "移除", + "passkey_added": "已添加通行密钥。", + "passkey_no_prf": "此通行密钥无法保护密钥。PIN 仍然可用。", + "passkey_failed": "通行密钥不起作用。请输入 PIN。", + "use_passkey": "使用通行密钥", + "use_hello": "使用 Windows Hello", + "use_touchid": "使用 Touch ID", + "os_add": "打开…", + "os_add_title": "使用 {method}", + "os_remove": "关闭", + "os_desc": "{method} 是应用进行的检查。确认后,应用从此计算机的加密存储中读取密钥。在您的用户帐户下运行的程序无需 {method} 也能读取,因此 PIN 的保护更强。PIN 仍然可用。", + "os_failed": "未成功。请输入 PIN。", + "lock_title": "{app} 已锁定", + "locking": "正在锁定…", + "pin_prompt": "输入 PIN 以继续。", + "unlock": "解锁", + "unlocking": "正在解锁…", + "wrong_pin": "此 PIN 不正确。", + "unlock_failed": "解锁未成功。请重试。", + "blocked_later": "尝试次数过多。请稍后再试。", + "forgot": "忘记了 PIN?", + "forgot_title": "删除本地数据并退出登录?", + "forgot_body": "忘记的 PIN 无法恢复。这会删除此设备上保存的邮件并退出所有帐户,以便您重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。", + "forgot_confirm": "删除并退出登录", + "unlock_in_main": "请在主窗口中解锁。", + "open_main": "转到主窗口", + "setup_required_title": "您的组织要求使用应用锁", + "setup_required_body": "请选择一个 PIN。锁定后打开 {app} 时需要它。", + "draft_saved_notice": "您正在撰写的内容已在锁定前保存到草稿。", + "use_hello_name": "Windows Hello", + "use_touchid_name": "Touch ID", + "timeout_option": "{count, plural, other {# 分钟}}", + "blocked_seconds": "尝试次数过多。请在 {count, plural, other {# 秒}}后重试。", + "blocked_minutes": "尝试次数过多。请在 {count, plural, other {# 分钟}}后重试。" + }, "desktop_sign_in": { "browser_button": "使用浏览器登录", "browser_waiting": "请在浏览器中完成登录", diff --git a/public/sw.js b/public/sw.js index 22e4174ba..8ee09656c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -506,6 +506,10 @@ self.addEventListener("message", (event) => { event.waitUntil(recordClientLogins(event.source && event.source.id, data.logins)); return; } + if (data.type === "app-lock") { + event.waitUntil(writeAppLockState(data.state)); + return; + } if (data.type === "offline-shell") { // The switch is kept before the event ends. The shell's files are // fetched without holding the event open: a worker busy with an event @@ -614,7 +618,9 @@ async function handlePush(event) { // a generic "New mail" so the user still sees something. let preview = null; let previewOk = false; - try { + // Locked: not a word about the message, so it isn't even looked up. + const lockHidesContent = await appLockHidesContent(); + if (!lockHidesContent) try { const query = new URLSearchParams(); if (accountId) query.set("accountId", accountId); if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]); @@ -777,6 +783,49 @@ async function showWithoutAlert(groupTag) { // every so often. const QUIET_WINDOW_MS = 30_000; +// ---- App lock: while locked a notification says nothing about the message ---- + +function appLockStateKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/app-lock`; +} + +async function writeAppLockState(state) { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(appLockStateKey(), new Response(JSON.stringify({ state }), { headers: { "Content-Type": "application/json" } })); + if (state === "locked") await closeMailNotifications(); +} + +/** What's on screen from before the lock names senders and subjects. */ +async function closeMailNotifications() { + try { + const shown = await self.registration.getNotifications(); + for (const notification of shown) { + if (typeof notification.tag === "string" && notification.tag.startsWith("bulwark-mail:")) notification.close(); + } + } catch (_) { + // Nothing more a worker can do. + } +} + +/** + * The page says "locked" when it locks. It can't say so when the browser just closes, so + * "unlocked" only counts with a window open: the key is in a window's memory or nowhere. + */ +async function appLockHidesContent() { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(appLockStateKey()); + const stored = res ? await res.json() : null; + if (!stored || stored.state === "off") return false; + if (stored.state !== "unlocked") return true; + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + return windows.length === 0; + } catch (_) { + // Can't tell: a generic notification loses little. + return true; + } +} + function lastAlertKey() { return `${self.location.origin}${BASE_PATH}/__push-state/last-alert`; } @@ -1362,6 +1411,8 @@ async function postNotificationAction(action, data) { /** True when the action is done and nothing has to open. */ async function runNotificationAction(action, data, reply, allClients) { + // An old notification's button doesn't act on mail while the app is locked. + if (await appLockHidesContent()) return false; const text = action === "reply" && typeof reply === "string" ? reply.trim() : ""; const { result, client } = await askOpenClients({ kind: "notification-action", diff --git a/stores/__tests__/auth-store-keep-local.test.ts b/stores/__tests__/auth-store-keep-local.test.ts index 99ba77f90..4779e56bd 100644 --- a/stores/__tests__/auth-store-keep-local.test.ts +++ b/stores/__tests__/auth-store-keep-local.test.ts @@ -222,4 +222,17 @@ describe('a login without "remember me"', () => { await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); expect(lost.consumeLost()).toEqual(['Unsent']); }); + + it('keeps its data when the reload was the app lock\'s: it has to sign in again, and loses nothing', async () => { + localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: false })); + useAccountStore.setState({ accounts: [{ ...entry(ALICE, 0), rememberMe: false }] } as never); + tokenAnswers(() => answer(200)); + await useAuthStore.getState().checkAuth(); + expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined(); + expect(state.isEvicted(ALICE)).toBe(true); + offline.startOfflineMode(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(purge.files).toBe(0); + expect(lost.consumeLost()).toEqual([]); + }); }); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9fc0c7ca3..e8616d04d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -22,6 +22,7 @@ import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; +import { isAppLockedNow, isAppLockOn } from '@/lib/app-lock/state'; import { broadcastSignOut, onSignedOutElsewhere, passSignOutGates, purgeSignedOutData, runBeforeSignOut, type SignOutCleanupOptions } from '@/lib/sign-out-cleanup'; import { forgetPrincipals } from '@/lib/delegation/known-principals'; import { useSearchHistoryStore } from './search-history-store'; @@ -2967,7 +2968,9 @@ export const useAuthStore = create()( forgottenAccounts += 1; evictAccount(account.id); // The user's choice on what may be a shared computer: its local data goes too. - accountStore.removeAccount(account.id, 'explicit'); + // Not when the app lock is on: its reload ended this page, not the session, so + // the mail and the outbox wait for the next sign-in. + accountStore.removeAccount(account.id, isAppLockOn() ? 'evicted' : 'explicit'); if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true); return; } @@ -3484,6 +3487,8 @@ export const useAuthStore = create()( }, startSharedSession: async (options) => { + // Locked: not from another window, and not from the cookies either. + if (isAppLockedNow()) return 'no-session'; set({ isLoading: true, client: null }); const accountStore = useAccountStore.getState(); const answer = await requestWindowSession(options?.accountId ? { accountId: options.accountId } : {}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 8bff6f55d..8354976a4 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -38,6 +38,7 @@ import { SendCancelledCleanupError } from "@/lib/send-failure-message"; import type { StorageQuota } from "@/lib/quota"; import { isArchiveActionEnabled } from "@/lib/archive-gate"; import { getOutboxHooks } from "@/lib/offline/outbox-hooks"; +import { isAppLockOn } from "@/lib/app-lock/state"; import { useOfflineStore } from "@/lib/offline/state"; import { READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection, @@ -8734,6 +8735,8 @@ if (typeof window !== 'undefined') { if (typeof account !== 'string' || !account) return; // Its mail lives encrypted in the store, and not in plain localStorage too. if (useOfflineStore.getState().enabled.includes(account)) return; + // Nor with an app lock, which must not leave subjects readable on disk. + if (isAppLockOn()) return; try { window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({ v: EMAIL_SNAPSHOT_VERSION, From 9c36801f5e4a717c300af52cc545be87f321b0ff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:37:15 +0100 Subject: [PATCH 2/8] fix: keep composers mounted while the app lock saves them, and tell the service worker nothing with the lock off --- .../popout/__tests__/popout-host.test.tsx | 25 ++++++++++++------ lib/app-lock/__tests__/controller.test.ts | 26 +++++++++++++++---- lib/app-lock/controller.ts | 3 +-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index 239f3045c..dcdb2e036 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -122,28 +122,37 @@ describe('PopoutHost', () => { expect(JSON.stringify(heard)).not.toContain('Bearer main'); }); - it('gives no credentials while the app is locked, also to a pop-out that never heard of the lock', async () => { + it('gives no credentials while the app is locked, to a pop-out that never heard of the lock', async () => { render(); await settle(); setLockedHere(true); - const channel = openPopoutChannel(() => {}); const opener = await createSessionOpener(); - channel?.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey }); + popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey }); await settle(); - channel?.close(); expect(answers()).toEqual([]); - await expect(requestWindowSession({}, 100)).resolves.toBeNull(); setLockedHere(false); - await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({ activeAccountId: 'alice' }); + popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey }); + await vi.waitFor(() => expect(answers()).toHaveLength(1)); }); - it('a locked pop-out takes no session from the cookies either', async () => { + it('a locked pop-out doesn\'t ask for a session', async () => { + setLockedHere(true); + await expect(requestWindowSession({}, 100)).resolves.toBeNull(); + setLockedHere(false); + expect(heard.filter((message) => message.type === 'session-request')).toEqual([]); + }); + + it('a locked pop-out takes no session from the cookies either, and asks nobody', async () => { + const fetched = vi.fn(async () => new Response('{}')); + vi.stubGlobal('fetch', fetched); setLockedHere(true); try { - await expect(useAuthStore.getState().startSharedSession({})).resolves.toBe('no-session'); + await expect(useAuthStore.getState().startSharedSession({ accountId: 'alice' })).resolves.toBe('no-session'); } finally { setLockedHere(false); } + expect(fetched).not.toHaveBeenCalled(); + expect(useAuthStore.getState().client).not.toBeNull(); }); it('says nothing while it has no session to lend, so a pop-out goes on to the cookies', async () => { diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts index 1c6a03da4..958875a38 100644 --- a/lib/app-lock/__tests__/controller.test.ts +++ b/lib/app-lock/__tests__/controller.test.ts @@ -197,6 +197,14 @@ describe('while locked', () => { expect(isAppLockedNow()).toBe(true); }); + it('a running page isn\'t held back while it saves: composers stay mounted until the reload', async () => { + await enableAppLock(PIN); + let holdDuringSave: Promise | null | undefined; + registerComposeFlush(async () => { holdDuringSave = appLockHold(); }); + await lockedAndReloaded(); + expect(holdDuringSave).toBeNull(); + }); + it('notifications go by it: the check the OS code asks says locked', async () => { await enableAppLock(PIN); asNewDocument(); @@ -286,15 +294,23 @@ describe('locking', () => { }); describe('the store when a tab inherits it without the key', () => { - it('locks everywhere instead of opening or writing an unwrapped key', async () => { + it('asks for the lock instead of opening, or writing a key outside the layer', async () => { await enableAppLock(PIN); + // This tab has no key: the one that had it went. resetAppLockForTests(); + (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); + (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); bootAppLock(); - await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); - const layers = () => appLockKeyLayers(); - expect(layers).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); - // A new account can't get a key record outside the layer either. + await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); + const onKeyLocked = vi.fn(); + configureStore({ ...hooks, onKeyLocked }); + await expect(getStore().ensureOpen('new-account', { apiUrl: 'x' } as never).then(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + await getStore().ensureOpen('new-account'); + })).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' }); + expect(onKeyLocked).toHaveBeenCalled(); expect(await getAccountRecord('new-account')).toBeNull(); + expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(false); }); it('a new account after unlock gets its key under the lock', async () => { diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts index 46c71533c..cf1b5fb69 100644 --- a/lib/app-lock/controller.ts +++ b/lib/app-lock/controller.ts @@ -366,7 +366,6 @@ async function applyLockHere(): Promise { const wasRunning = phase === 'open'; setLockedHere(true); markDocument(true); - hold ??= newHold(); setPhase('locking'); tellDesktopLocked(); tellServiceWorker('locked'); @@ -699,7 +698,7 @@ export function startAppLock(): () => void { startIdle(); if (!heldKey) watchHolder(); } - tellServiceWorker(readAppLockFile().on ? (isAppLockedNow() ? 'locked' : 'unlocked') : 'off'); + if (readAppLockFile().on) tellServiceWorker(isAppLockedNow() ? 'locked' : 'unlocked'); return () => { channel?.close(); window.removeEventListener('storage', onStorage); From 7173178ded137a9054ebe829290e59d72d28d0b9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:04:32 +0100 Subject: [PATCH 3/8] fix: name the product on the lock screen, not the default app name --- components/app-lock/lock-screen.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx index 2dc827000..20f37bc3d 100644 --- a/components/app-lock/lock-screen.tsx +++ b/components/app-lock/lock-screen.tsx @@ -16,6 +16,7 @@ import { pinBlockedUntil, readVault } from "@/lib/app-lock/vault"; import { getDesktopBridge } from "@/lib/desktop/bridge"; 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; @@ -86,7 +87,7 @@ function Frame({ title, body, children }: { title: string; body?: string; childr function SetupForm() { const t = useTranslations("appLock"); - const { appName } = useConfig(); + const appName = productName(useConfig().appName); const [pin, setPin] = useState(""); const [repeat, setRepeat] = useState(""); const [busy, setBusy] = useState(false); @@ -116,7 +117,7 @@ function SetupForm() { function UnlockForm() { const t = useTranslations("appLock"); - const { appName } = useConfig(); + const appName = productName(useConfig().appName); const { dialogProps, confirm } = useConfirmDialog(); const [pin, setPin] = useState(""); const [busy, setBusy] = useState(false); @@ -252,7 +253,7 @@ function UnlockForm() { /** What covers a window while the app is locked. Reads nothing but the lock's own records. */ export function LockScreen({ phase }: { phase: AppLockPhase }) { const t = useTranslations("appLock"); - const { appName } = useConfig(); + const appName = productName(useConfig().appName); if (phase === "setup") return ; if (phase === "locking") { return ( From 88cd1e4cc4969cd6e238980ee04ea4d942606b08 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:18:58 +0100 Subject: [PATCH 4/8] fix: ask about a forgotten PIN on the lock screen itself, where it can be seen --- .../app-lock/__tests__/lock-screen.test.tsx | 25 ++++++ components/app-lock/lock-screen.tsx | 41 ++++++---- integration/tests/24-do-app-lock.spec.ts | 77 +++++++++++++++---- lib/app-lock/vault.ts | 3 - 4 files changed, 112 insertions(+), 34 deletions(-) diff --git a/components/app-lock/__tests__/lock-screen.test.tsx b/components/app-lock/__tests__/lock-screen.test.tsx index 175e0994b..b2be7f72e 100644 --- a/components/app-lock/__tests__/lock-screen.test.tsx +++ b/components/app-lock/__tests__/lock-screen.test.tsx @@ -9,9 +9,11 @@ import { writeAppLockFile } from '@/lib/app-lock/state'; vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) })); const unlockWithPin = vi.hoisted(() => vi.fn()); +const forgetEverything = vi.hoisted(() => vi.fn()); vi.mock('@/lib/app-lock/controller', async (original) => ({ ...(await original()), unlockWithPin, + forgetEverything, })); function setPolicy(required: boolean, minutes: number | null, locked: boolean): void { @@ -32,6 +34,7 @@ beforeEach(async () => { await deleteVault(); localStorage.clear(); unlockWithPin.mockReset(); + forgetEverything.mockReset(); }); afterEach(() => { @@ -83,6 +86,28 @@ describe('the lock screen', () => { expect(screen.queryByTestId('app-lock-os')).toBeNull(); }); + it('asks about a forgotten PIN inside the lock screen: everything on beside it is hidden', async () => { + (await createVault('correct-pin')).fill(0); + render(); + fireEvent.click(await screen.findByTestId('app-lock-forgot')); + const confirm = screen.getByRole('button', { name: 'forgot_confirm' }); + expect(confirm.closest('[data-app-lock-overlay]')).not.toBeNull(); + expect(screen.getByRole('dialog')).toHaveTextContent('forgot_body'); + fireEvent.click(screen.getByRole('button', { name: 'cancel' })); + expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument(); + expect(forgetEverything).not.toHaveBeenCalled(); + }); + + it('forgets only after the yes, and comes back when main says no', async () => { + (await createVault('correct-pin')).fill(0); + forgetEverything.mockResolvedValue(false); + render(); + fireEvent.click(await screen.findByTestId('app-lock-forgot')); + fireEvent.click(screen.getByRole('button', { name: 'forgot_confirm' })); + await waitFor(() => expect(forgetEverything).toHaveBeenCalledTimes(1)); + expect(await screen.findByTestId('app-lock-pin')).toBeInTheDocument(); + }); + it('says it\'s saving rather than ask for anything while it locks', () => { render(); expect(screen.queryByTestId('app-lock-pin')).toBeNull(); diff --git a/components/app-lock/lock-screen.tsx b/components/app-lock/lock-screen.tsx index 20f37bc3d..9d69b3529 100644 --- a/components/app-lock/lock-screen.tsx +++ b/components/app-lock/lock-screen.tsx @@ -4,10 +4,8 @@ import { useCallback, useEffect, useId, useRef, useState, type FormEvent } from import { useTranslations } from "next-intl"; import { Lock } from "@/components/icons"; import { Button } from "@/components/ui/button"; -import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Input } from "@/components/ui/input"; import { useConfig } from "@/hooks/use-config"; -import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { NewPinFields, newPinReady } from "./pin-fields"; import { enableAppLock, forgetEverything, unlockWithOs, unlockWithPasskey, unlockWithPin, type AppLockPhase } from "@/lib/app-lock/controller"; import { passkeysPossible } from "@/lib/app-lock/passkey"; @@ -118,7 +116,7 @@ function SetupForm() { function UnlockForm() { const t = useTranslations("appLock"); const appName = productName(useConfig().appName); - const { dialogProps, confirm } = useConfirmDialog(); + const [forgetting, setForgetting] = useState(false); const [pin, setPin] = useState(""); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); @@ -180,15 +178,31 @@ function UnlockForm() { } }; - const forgot = async () => { - const ok = await confirm({ - title: t("forgot_title"), - message: t("forgot_body"), - confirmText: t("forgot_confirm"), - variant: "destructive", - }); - if (ok) await forgetEverything(); - }; + // Here and not in a dialog: a dialog is a portal on , which the lock keeps hidden. + if (forgetting) { + return ( + +
+ + +
+ + ); + } if (mainThere) { return ( @@ -241,11 +255,10 @@ function UnlockForm() { {t("use_passkey")} )} - - ); } diff --git a/integration/tests/24-do-app-lock.spec.ts b/integration/tests/24-do-app-lock.spec.ts index 9b76d81ac..bf4b3f85e 100644 --- a/integration/tests/24-do-app-lock.spec.ts +++ b/integration/tests/24-do-app-lock.spec.ts @@ -36,7 +36,8 @@ async function enableOffline(page: Page): Promise { await expect(toggle).toBeVisible({ timeout: 30_000 }); if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); await expect(toggle).toHaveAttribute('aria-checked', 'true'); - await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); + // The store is open once its key record is there. A full first sync isn't what's tested. + await expect.poll(async () => (await keyRecords(page).catch(() => [])).length, { timeout: 60_000 }).toBeGreaterThan(0); } async function enableLock(page: Page, pin = PIN): Promise { @@ -52,7 +53,7 @@ async function enableLock(page: Page, pin = PIN): Promise { } async function lockNow(page: Page): Promise { - await page.goto('/settings/general'); + if (!page.url().includes('/settings/general')) await gotoUnlocked(page, '/settings/general'); await page.getByTestId('app-lock-now').click(); await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); } @@ -63,8 +64,18 @@ async function unlock(page: Page, pin = PIN): Promise { await expect(lockForm(page)).toBeHidden({ timeout: 60_000 }); } +/** + * A full page load in the tab that holds the key loses the key, so it asks again. That's + * the design: the key isn't kept anywhere that a reload could find it. + */ +async function gotoUnlocked(page: Page, path: string): Promise { + await page.goto(path); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await unlock(page); +} + async function toMail(page: Page): Promise { - await page.goto('/mail'); + await gotoUnlocked(page, '/mail'); await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); } @@ -79,10 +90,20 @@ async function browserStorage(page: Page): Promise { /** The account key records as they are on disk. */ async function keyRecords(page: Page): Promise> { - return page.evaluate(() => new Promise((resolve, reject) => { + return page.evaluate(async () => new Promise(async (resolve, reject) => { + // Opening one that doesn't exist would create it, empty, and break the app's own open. + if (!(await indexedDB.databases()).some((db) => db.name === 'bulwark-store')) { + resolve([]); + return; + } const open = indexedDB.open('bulwark-store'); open.onerror = () => reject(open.error); open.onsuccess = () => { + if (!open.result.objectStoreNames.contains('accounts')) { + open.result.close(); + resolve([]); + return; + } const all = open.result.transaction('accounts').objectStore('accounts').getAll(); all.onsuccess = () => { open.result.close(); @@ -103,11 +124,10 @@ async function serverDraftBodies(subject: string): Promise { return list.filter((email) => email.keywords.$draft).map((email) => Object.values(email.bodyValues).map((part) => part.value).join('\n')); } -/** Signed in with "Remember me", so a reload finds the session again. */ +/** On this rig the box isn't offered, and a login is remembered, so a reload finds the session again. */ async function signIn(page: Page): Promise { await page.fill('#username', ENT.alice); await page.fill('#password', ENT_PASSWORD); - await page.getByText('Remember me', { exact: true }).click(); await page.click('button[type="submit"]'); await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); } @@ -145,7 +165,7 @@ test('locked: no mail in the page, its memory gone, the store closed with no key expect(await heldLocks(page)).toContain('bulwark-store-owner'); expect(await heldLocks(page)).toContain('bulwark-app-lock-holder'); - await page.goto('/settings/general'); + await gotoUnlocked(page, '/settings/general'); await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; }); await page.getByTestId('app-lock-now').click(); await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); @@ -205,7 +225,7 @@ test('all tabs lock together, and a new tab or pop-out address stays locked; the await second.goto('/mail'); await expect(second.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); - await page.goto('/settings/general'); + // This tab holds the key: no new load here, or the others would lock for that. await page.getByTestId('app-lock-now').click(); await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); await expect(lockForm(second)).toBeVisible({ timeout: 30_000 }); @@ -230,9 +250,10 @@ test('all tabs lock together, and a new tab or pop-out address stays locked; the await expect(third.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); // The key was never passed on: when the tab that has it goes, the others have to ask. + // Up to a store call's wait for the owner that went, then the reload. await second.close(); - await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); - await expect(lockForm(third)).toBeVisible({ timeout: 30_000 }); + await expect(lockForm(page)).toBeVisible({ timeout: 90_000 }); + await expect(lockForm(third)).toBeVisible({ timeout: 90_000 }); await unlock(page); await context.close(); }); @@ -273,7 +294,6 @@ test('an open composer\'s text is saved first, is unreadable while locked, and n test('time without use locks every tab, and use in another tab counts', async ({ browser }) => { const { page, context } = await fresh(browser); await enableLock(page); - await page.goto('/settings/general'); await page.getByTestId('app-lock-timeout').selectOption('1'); const other = await context.newPage(); await other.goto('/mail'); @@ -368,13 +388,34 @@ test('a login without "Remember me" has to sign in again after a lock, and its l await enableLock(page); const before = await keyRecords(page); expect(before.length).toBeGreaterThan(0); - await lockNow(page); - await page.getByTestId('app-lock-pin').fill(PIN); - await page.getByTestId('app-lock-unlock').click(); - await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 }); + await page.getByTestId('app-lock-now').click(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + // As a login with the box left unticked is written down. This rig doesn't offer the box. + await page.evaluate(() => { + const registry = JSON.parse(localStorage.getItem('account-registry') ?? '{}'); + for (const account of registry.state.accounts) account.rememberMe = false; + localStorage.setItem('account-registry', JSON.stringify(registry)); + }); + await context.clearCookies(); + await page.reload(); + await expect(lockForm(page)).toBeVisible({ timeout: 30_000 }); + await unlock(page); + // The way to the login page and back from it are full page loads, and each asks again. + const pastLock = async (target: ReturnType) => { + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(target.or(lockForm(page)).first()).toBeVisible({ timeout: 60_000 }); + if (await target.first().isVisible()) return; + await unlock(page); + } + throw new Error('still at the lock screen'); + }; + await pastLock(page.locator('#username')); // Not what a page reload does to such a login with the lock off: that one wipes. expect((await keyRecords(page)).map((record) => record.account)).toEqual(before.map((record) => record.account)); - await signIn(page); + await page.fill('#username', ENT.alice); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await pastLock(accountSwitcher(page)); await expect.poll(() => heldLocks(page), { timeout: 60_000 }).toContain('bulwark-store-owner'); await context.close(); }); @@ -391,8 +432,10 @@ test('turning it off needs the PIN and takes the layer off', async ({ browser }) await page.getByTestId('app-lock-dialog-save').click(); await expect(page.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); for (const record of await keyRecords(page)) expect(record.layers).toEqual([]); + // Off: a reload asks for nothing. await page.reload(); - await expect(accountSwitcher(page).or(page.getByTestId('app-lock-switch')).first()).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('app-lock-switch')).toBeVisible({ timeout: 60_000 }); + await expect(lockForm(page)).toHaveCount(0); await context.close(); }); diff --git a/lib/app-lock/vault.ts b/lib/app-lock/vault.ts index 92a751347..5b41b3915 100644 --- a/lib/app-lock/vault.ts +++ b/lib/app-lock/vault.ts @@ -257,6 +257,3 @@ export async function openSealedDrafts(lockKey: CryptoKey): Promise { }); return out; } - -export { keyFromPrf }; -export type { SealedAnswer }; From b94bf27b30cbcafa3d6a499430ab487f47723950 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:36:06 +0100 Subject: [PATCH 5/8] test: check that a locked main window renews no token and answers nothing --- .../popout/__tests__/popout-host.test.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx index dcdb2e036..09f6824b8 100644 --- a/components/popout/__tests__/popout-host.test.tsx +++ b/components/popout/__tests__/popout-host.test.tsx @@ -4,7 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch import { setLockedHere } from '@/lib/app-lock/state'; import { openPopoutChannel, requestWindowSession, watchMainWindow, askRescuedDraftId, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel'; import { takeRescuedDraft } from '@/lib/popout/rescued-drafts'; -import { createSessionOpener } from '@/lib/popout/sealed'; +import { createSessionOpener, credentialFingerprint } from '@/lib/popout/sealed'; import { useAccountStore } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; @@ -133,6 +133,45 @@ describe('PopoutHost', () => { setLockedHere(false); popout.post({ type: 'session-request', requestId: 'r2', publicKey: opener.publicKey }); await vi.waitFor(() => expect(answers()).toHaveLength(1)); + expect(answers().map((message) => (message as { requestId: string }).requestId)).toEqual(['r2']); + }); + + it('renews no token for a pop-out while the app is locked', async () => { + const refresh = vi.fn(async () => 'next'); + useAuthStore.setState({ refreshAccessToken: refresh as never }); + render(); + await settle(); + setLockedHere(true); + try { + const opener = await createSessionOpener(); + const rejectedFingerprint = await credentialFingerprint('Bearer main'); + popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint }); + await settle(40); + expect(refresh).not.toHaveBeenCalled(); + expect(answers()).toEqual([]); + } finally { + setLockedHere(false); + } + }); + + it('answers nothing when the lock comes during the renewal', async () => { + const refresh = vi.fn(async () => { + setLockedHere(true); + return 'next'; + }); + useAuthStore.setState({ refreshAccessToken: refresh as never }); + render(); + await settle(); + try { + const opener = await createSessionOpener(); + const rejectedFingerprint = await credentialFingerprint('Bearer main'); + popout.post({ type: 'session-request', requestId: 'r1', publicKey: opener.publicKey, accountId: 'alice', rejectedFingerprint }); + await vi.waitFor(() => expect(refresh).toHaveBeenCalled()); + await settle(40); + expect(answers()).toEqual([]); + } finally { + setLockedHere(false); + } }); it('a locked pop-out doesn\'t ask for a session', async () => { 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 6/8] 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")}