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

This commit is contained in:
Linus Rath
2026-11-03 17:08:02 +01:00
parent f21198b804
commit 3f264367af
71 changed files with 1826 additions and 142 deletions
+2 -1
View File
@@ -120,7 +120,6 @@ export default async function LocaleLayout({
</MainWindowOnly>
{/* Offline mode isn't in Lite (no service worker there). */}
{!IS_LITE && <OfflineHost />}
{!IS_LITE && <DeviceWipeHost />}
{!IS_LITE && <NotificationActionsHost />}
<DevicePolicyHost />
<MainWindowOnly>
@@ -135,6 +134,8 @@ export default async function LocaleLayout({
</CalendarAlertProvider>
</ThemeProvider>
</AppLockGate>
{/* Beside the gate, not under it: a stolen device at the lock screen still has to hear a wipe. */}
{!IS_LITE && <DeviceWipeHost />}
</IntlProvider>
);
}
+4
View File
@@ -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();
};
+4 -1
View File
@@ -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');
}
@@ -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<Channel>();
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(<LockScreen phase="locked" />);
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(<LockScreen phase="locking" />);
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(<AppLockSettings />);
expect(screen.getByText('protects_desc')).toBeInTheDocument();
});
it('won\'t save a PIN anyone would guess', async () => {
render(<AppLockSettings />);
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(<AppLockSettings />);
expect(screen.getByTestId('app-lock-switch')).toHaveAttribute('aria-checked', 'false');
+16 -6
View File
@@ -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<AppLockMessage>) => {
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 (
<Frame title={t("forgot_title")} body={t("forgot_body")}>
<p className="text-center text-sm text-muted-foreground">{t("forgot_provider")}</p>
<div className="flex flex-col gap-2" data-testid="app-lock-forget">
<Button
variant="destructive"
+5 -3
View File
@@ -3,7 +3,7 @@
import { useId } from "react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { pinLongEnough } from "@/lib/app-lock/crypto";
import { pinIsObvious, 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 }: {
@@ -17,6 +17,7 @@ export function NewPinFields({ pin, repeat, onPin, onRepeat }: {
const repeatId = useId();
const hintId = useId();
const tooShort = pin.length > 0 && !pinLongEnough(pin);
const obvious = pinLongEnough(pin) && pinIsObvious(pin);
const mismatch = repeat.length > 0 && repeat !== pin;
return (
<div className="space-y-3">
@@ -30,9 +31,10 @@ export function NewPinFields({ pin, repeat, onPin, onRepeat }: {
value={pin}
onChange={(event) => onPin(event.target.value)}
aria-describedby={hintId}
aria-invalid={tooShort || undefined}
aria-invalid={tooShort || obvious || undefined}
/>
<p id={hintId} className="text-xs text-muted-foreground">{t("pin_hint")}</p>
{obvious && <p role="alert" className="text-xs text-destructive">{t("pin_obvious")}</p>}
</div>
<div className="space-y-1">
<label htmlFor={repeatId} className="text-sm font-medium text-foreground">{t("pin_repeat")}</label>
@@ -52,7 +54,7 @@ export function NewPinFields({ pin, repeat, onPin, onRepeat }: {
}
export function newPinReady(pin: string, repeat: string): boolean {
return pinLongEnough(pin) && pin === repeat;
return pinLongEnough(pin) && !pinIsObvious(pin) && pin === repeat;
}
/** A PIN already set, asked again before something that needs it. */
@@ -72,6 +72,7 @@ export function AppLockSettings() {
if (name === "WrongPinError") return t("wrong_pin");
if (name === "PinBlockedError") return t("blocked_later");
if (name === "PasskeyNoPrfError") return t("passkey_no_prf");
if (name === "ObviousPinError") return t("pin_obvious");
return t("save_failed");
};
@@ -127,6 +128,7 @@ export function AppLockSettings() {
<SettingItem label={t("enable_label")} description={t("enable_desc")} locked={required}>
<ToggleSwitch checked={on || required} onChange={(next) => setPending(next ? "enable" : "disable")} testId="app-lock-switch" />
</SettingItem>
<p className="text-xs text-muted-foreground" data-testid="app-lock-protects">{t("protects_desc")}</p>
{on && (
<>
+15
View File
@@ -66,6 +66,21 @@ describe('useSidebarApps CSP cookie', () => {
expect(sessionStorage.getItem('bulwark:pending-inline-app')).toContain('app-1');
});
it('with the app lock on, opens beside the app instead: a reload would ask for the PIN', () => {
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: false }));
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
try {
useSettingsStore.setState({ sidebarApps: [app()] });
const { result } = renderHook(() => useSidebarApps());
act(() => result.current.handleInlineApp('app-1', 'https://board.example.com/x', 'Board'));
expect(reload).not.toHaveBeenCalled();
expect(open).toHaveBeenCalledWith('https://board.example.com/x', '_blank', 'noopener,noreferrer');
} finally {
open.mockRestore();
localStorage.removeItem('bulwark-app-lock');
}
});
it('reopens the pending app after that reload without reloading again', () => {
useSettingsStore.setState({ sidebarApps: [app()] });
const { result: first } = renderHook(() => useSidebarApps());
+3 -2
View File
@@ -15,6 +15,7 @@ import { playNotificationSound } from '@/lib/notification-sound';
import { lastingToastDuration } from '@/lib/toast-duration';
import { startTitleAlert } from '@/lib/title-alert';
import { getPathPrefix } from '@/lib/browser-navigation';
import { goInApp } from '@/lib/plugin-sandbox/page-navigation';
import type { CalendarEvent } from '@/lib/jmap/types';
import type { CalendarAlertView } from '@/lib/plugin-types';
import { calendarHooks } from '@/lib/plugin-hooks';
@@ -143,7 +144,7 @@ export function useCalendarAlerts() {
message,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId });
@@ -182,7 +183,7 @@ export function useCalendarAlerts() {
message: taskMsg,
duration: reminderToastDuration(),
onClick: () => {
window.location.href = `${getPathPrefix(locale)}/${locale}/calendar`;
goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`);
},
onDismiss: () => {
void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId });
+9
View File
@@ -1,3 +1,4 @@
import { isAppLockOn } from '@/lib/app-lock/state';
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps';
@@ -97,11 +98,19 @@ export function useSidebarApps() {
const origins = inlineAppFrameOrigins(sidebarApps);
if (!origins.includes(origin)) origins.push(origin);
if (writeAppFrameOrigins(origins)) {
// Only a new document gets the new CSP, and with the app lock on that would ask for
// the PIN just to open an app. So this once it opens beside the app, and in place
// from the next start on.
if (isAppLockOn()) {
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
try {
sessionStorage.setItem(PENDING_APP_KEY, JSON.stringify(app));
} catch {
// Private mode / storage full - the app just won't reopen itself.
}
// full-load: a document's frame-src can't change without a new document.
window.location.reload();
return;
}
+175 -25
View File
@@ -1,5 +1,5 @@
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 { ENT, ENT_PASSWORD, contextOptions, deliver, jmapAs, requireEntEnv, shot } from './helpers/ent';
import { accountSwitcher } from './helpers/app';
/**
@@ -14,7 +14,9 @@ import { accountSwitcher } from './helpers/app';
* - 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.
* - forgetting the PIN removes the data and signs out, also offline and with a server that hangs;
* - offline with a shell, a lock still goes through a reload, and memory with it;
* - a device at the lock screen hears a remote wipe.
*/
test.describe.configure({ mode: 'serial' });
@@ -30,8 +32,13 @@ test.beforeAll(async () => {
await deliver({ from: ENT.bob, to: [ENT.alice], subject: SECRET_SUBJECT, text: `Body of ${SECRET_SUBJECT}` });
});
async function enableOffline(page: Page): Promise<void> {
await page.goto('/settings/backup/offline');
type Go = (page: Page, path: string) => Promise<unknown>;
const hardGoto: Go = (page, path) => page.goto(path);
const softGoto: Go = (page, path) => page.evaluate((target) => (window as unknown as { next: { router: { push(path: string): void } } }).next.router.push(target), path);
async function enableOffline(page: Page, go = hardGoto): Promise<void> {
await go(page, '/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();
@@ -40,8 +47,8 @@ async function enableOffline(page: Page): Promise<void> {
await expect.poll(async () => (await keyRecords(page).catch(() => [])).length, { timeout: 60_000 }).toBeGreaterThan(0);
}
async function enableLock(page: Page, pin = PIN): Promise<void> {
await page.goto('/settings/general');
async function enableLock(page: Page, pin = PIN, go = hardGoto): Promise<void> {
await go(page, '/settings/general');
const toggle = page.getByTestId('app-lock-switch');
await expect(toggle).toBeVisible({ timeout: 30_000 });
await expect(toggle).toHaveAttribute('aria-checked', 'false');
@@ -179,9 +186,11 @@ test('locked: no mail in the page, its memory gone, the store closed with no key
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.
// What the app below the gate would start: from the very start of a locked page, 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.reload();
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
await page.waitForTimeout(1500);
expect(requests).toEqual([]);
await shot(page, 'app-lock-screen');
@@ -382,23 +391,24 @@ test('a passkey with no PRF is refused when added, and the PIN stays', async ({
});
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 context = await browser.newContext(contextOptions());
const page = await context.newPage();
// The server has the box switched off. This asks for it as a server with it on does: the rest is the real login.
await page.route('**/api/config', async (route) => {
const response = await route.fetch();
await route.fulfill({ response, json: { ...(await response.json()), loginShowRememberMe: true, rememberMeEnabled: true } });
});
await page.goto('/');
await expect(page.getByText('Remember me', { exact: true })).toBeVisible({ timeout: 30_000 });
await signIn(page);
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('account-registry') ?? '{}').state.accounts[0].rememberMe)).toBe(false);
// By the router: such a login doesn't survive a page load, with or without a lock.
await enableOffline(page, softGoto);
await enableLock(page, PIN, softGoto);
const before = await keyRecords(page);
expect(before.length).toBeGreaterThan(0);
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<Page['locator']>) => {
@@ -439,16 +449,156 @@ test('turning it off needs the PIN and takes the layer off', async ({ browser })
await context.close();
});
/** What must be gone once the PIN is forgotten, however the server behaved. */
async function expectNothingLeft(page: Page): Promise<void> {
expect(await page.evaluate(() => localStorage.getItem('bulwark-app-lock'))).toBeNull();
await expect.poll(async () => page.evaluate(async () => (await indexedDB.databases()).map((db) => db.name)), { timeout: 30_000 })
.not.toEqual(expect.arrayContaining(['bulwark-app-lock']));
await expect.poll(async () => page.evaluate(async () => (await indexedDB.databases()).map((db) => db.name)), { timeout: 30_000 })
.not.toEqual(expect.arrayContaining(['bulwark-store']));
// The encrypted files themselves, in the browser's private folder.
await expect.poll(() => page.evaluate(async () => {
const names: string[] = [];
for await (const name of (await navigator.storage.getDirectory() as unknown as { keys(): AsyncIterable<string> }).keys()) names.push(name);
return names;
}), { timeout: 30_000 }).toEqual([]);
expect(await page.evaluate(() => localStorage.getItem('account-registry') ?? '')).not.toContain('@');
}
const SESSION_COOKIE = /^jmap_/;
async function forgetPin(page: Page): Promise<void> {
await page.getByTestId('app-lock-forgot').click();
await page.getByRole('button', { name: 'Remove and sign out' }).click();
}
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 forgetPin(page);
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 expectNothingLeft(page);
await expect.poll(async () => (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name)).map((cookie) => cookie.name), { timeout: 30_000 }).toEqual([]);
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBeNull();
await context.close();
});
test('a forgotten PIN with a server that hangs: nothing waits for it, and nobody is signed in', async ({ browser }) => {
const { page, context } = await fresh(browser);
await enableOffline(page);
await enableLock(page);
await lockNow(page);
// Sign-out's calls never answer.
await context.route(/\/api\/(auth|settings|devices)/, () => { /* no answer */ });
const started = Date.now();
await forgetPin(page);
await page.waitForURL((url) => !url.pathname.includes('/settings'), { timeout: 60_000 });
expect(Date.now() - started, 'bounded, whatever the server does').toBeLessThan(45_000);
await expectNothingLeft(page);
// The session cookie stays, as the server never heard. The mark keeps it from signing anyone in.
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBe('1');
await context.unroute(/\/api\/(auth|settings|devices)/);
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 });
await expect(accountSwitcher(page)).toHaveCount(0);
await expect.poll(async () => (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name)).map((cookie) => cookie.name), { timeout: 30_000 }).toEqual([]);
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBeNull();
await context.close();
});
test('a forgotten PIN offline: the cookie that stays signs nobody in, then or when the network is back', async ({ browser }) => {
const { page, context } = await fresh(browser);
await enableOffline(page);
await enableLock(page);
await lockNow(page);
await context.setOffline(true);
await forgetPin(page);
// No network, so no way to delete an HttpOnly cookie, and the shell went with the rest.
await page.waitForURL((url) => !url.pathname.includes('/settings'), { timeout: 60_000 });
expect((await context.cookies()).some((cookie) => SESSION_COOKIE.test(cookie.name))).toBe(true);
const asked: string[] = [];
context.on('request', (request) => {
if (/\/api\/auth\/session/.test(request.url())) asked.push(request.method());
});
await context.setOffline(false);
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 60_000 });
await expect(accountSwitcher(page)).toHaveCount(0);
await expectNothingLeft(page);
await expect.poll(async () => (await context.cookies()).filter((cookie) => SESSION_COOKIE.test(cookie.name)).map((cookie) => cookie.name), { timeout: 30_000 }).toEqual([]);
expect(await page.evaluate(() => localStorage.getItem('bulwark-cookies-to-clear'))).toBeNull();
expect(asked[0], 'the DELETE goes before anything could adopt the cookie').toBe('DELETE');
await context.close();
});
test('locked offline, with the shell kept: the page still reloads, so nothing stays in memory', async ({ browser }) => {
const { page, context } = await fresh(browser);
await enableOffline(page);
await expect.poll(() => page.evaluate(async () => (await navigator.serviceWorker.getRegistration()) ? (await caches.keys()).filter((key) => key.startsWith('bulwark-shell-')).length : 0), { timeout: 60_000 }).toBeGreaterThan(0);
await enableLock(page);
await page.goto('/settings/general');
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
await unlock(page);
await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)), { timeout: 30_000 }).toBe(true);
await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; });
await context.setOffline(true);
await page.getByTestId('app-lock-now').click();
await expect(lockForm(page)).toBeVisible({ timeout: 60_000 });
expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before), 'a new document').toBeUndefined();
await context.close();
});
test('a server answering 5xx: the lock holds on the page, and the reload comes when it can', async ({ browser }) => {
const { page, context } = await fresh(browser);
await enableLock(page);
await page.evaluate(() => { (window as unknown as { __before: string }).__before = 'still here'; });
await context.route('**/settings/general*', (route) => route.fulfill({ status: 503, body: 'down' }));
await page.getByTestId('app-lock-now').click();
await expect(lockForm(page)).toBeVisible({ timeout: 60_000 });
expect(await page.evaluate(() => (window as unknown as { __before?: string }).__before), 'stayed').toBe('still here');
await expect(page.locator('body')).not.toContainText('Lock now');
await context.unroute('**/settings/general*');
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect.poll(() => page.evaluate(() => (window as unknown as { __before?: string }).__before), { timeout: 30_000 }).toBeUndefined();
await expect(lockForm(page)).toBeVisible({ timeout: 30_000 });
await context.close();
});
test('a device at the lock screen hears a remote wipe, and what it hears takes the data', async ({ browser }) => {
const { page, context } = await fresh(browser);
let wipeWaits = false;
let checkedInLocked = false;
// The registry itself is the remote wipe spec's to test: here it says what it says for a wipe.
await context.route('**/api/devices', async (route) => {
const body = route.request().postDataJSON() as { id?: string } | null;
if (wipeWaits && body?.id) {
checkedInLocked = true;
await route.fulfill({ json: { wipe: { requestedAt: '2026-11-03T00:00:00.000Z' } } });
return;
}
await route.continue();
});
await enableOffline(page);
await enableLock(page);
await expect.poll(() => page.evaluate(() => new Promise<number>((resolve) => {
const open = indexedDB.open('bulwark-store');
open.onsuccess = () => {
const all = open.result.transaction('keys').objectStore('keys').getAllKeys();
all.onsuccess = () => {
open.result.close();
resolve(all.result.filter((key) => String(key).startsWith('device:')).length);
};
};
})), { timeout: 60_000 }).toBeGreaterThan(0);
wipeWaits = true;
await page.getByTestId('app-lock-now').click();
// The lock screen comes first, and nobody unlocks.
await expect(page.locator('#username').or(lockForm(page)).first()).toBeVisible({ timeout: 30_000 });
await expect(page.locator('#username')).toBeVisible({ timeout: 90_000 });
expect(checkedInLocked).toBe(true);
await expectNothingLeft(page);
await context.close();
});
@@ -0,0 +1,31 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
// With the app lock on, a full page load in the tab that holds the key loses the key, and
// the user is asked for the PIN again. So the app's own moves use the router. A load that
// has to be one says why on the line before: `// full-load: the reason`.
const ROOTS = ['hooks', 'lib/plugin-sandbox'];
const LOAD = /location\.(assign|reload|replace)\(|location\.href\s*=[^=]/;
function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === '__tests__' ? [] : sources(full);
return /\.tsx?$/.test(name) ? [full] : [];
});
}
describe('full page loads', () => {
it('none without a reason in the paths the app moves around in', () => {
const unexplained: string[] = [];
for (const file of ROOTS.flatMap(sources)) {
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => {
if (LOAD.test(line) && !/full-load:/.test(lines[index - 1] ?? '')) unexplained.push(`${file}:${index + 1}`);
});
}
expect(unexplained).toEqual([]);
});
});
@@ -0,0 +1,18 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
// Nothing under the gate hydrates while locked. A stolen device at the lock screen still
// has to hear a wipe, so the host that checks in sits beside the gate, not under it.
describe('the wipe host', () => {
it('is mounted outside the app lock gate', () => {
const layout = readFileSync('app/(main)/[locale]/layout.tsx', 'utf8');
const closes = layout.indexOf('</AppLockGate>');
const opens = layout.indexOf('<AppLockGate>');
const host = layout.indexOf('<DeviceWipeHost');
expect(host).toBeGreaterThan(0);
expect(opens).toBeGreaterThan(0);
expect(host < opens || host > closes).toBe(true);
expect(layout.indexOf('</IntlProvider>')).toBeGreaterThan(host);
});
});
+10
View File
@@ -113,6 +113,16 @@ describe('server-side SSO', () => {
return res.json();
}
it('asks the provider for a real login after a forgotten app lock PIN, and only then', async () => {
const authorize = async (extra: Record<string, unknown>) => {
const started = await ssoStart(post('/api/auth/sso/start', { redirect_uri: 'https://webmail.example/en/auth/callback', ...extra }));
return new URL((await started.json()).authorize_url).searchParams.get('prompt');
};
expect(await authorize({})).toBeNull();
expect(await authorize({ fresh_login: true })).toBe('login');
expect(await authorize({ fresh_login: 'yes' }), 'only the page\'s own true').toBeNull();
});
it('carries an unticked box from start to complete', async () => {
const body = await signIn({ remember: false });
expect(body.remember).toBe(false);
+2 -2
View File
@@ -71,9 +71,9 @@ export function loadWorker(opts: {
await Promise.all(waits);
}
/** A fetch event: what the worker answered, or null when it left the request to the network. */
async function request(req: { url: string; method?: string; mode?: string }): Promise<Response | null> {
async function request(req: { url: string; method?: string; mode?: string; headers?: Headers }): Promise<Response | null> {
let answer: Promise<Response> | null = null;
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise<Response>) => { answer = p; } });
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', headers: new Headers(), ...req }, respondWith: (p: Promise<Response>) => { answer = p; } });
return answer ? await answer : null;
}
return { dispatch, listeners, request, shown, skipped: () => skipped };
+46
View File
@@ -20,6 +20,52 @@ async function shellOn(caches: FakeCaches, on: boolean) {
const settle = () => new Promise((r) => setTimeout(r, 0));
describe('the app lock\'s question whether a reload would land anywhere', () => {
const probe = new Headers({ 'x-bulwark-reload-probe': '1' });
it('is answered from the shell while offline: a reload would work', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
let online = true;
const fetchMock = vi.fn(async () => {
if (!online) throw new TypeError('offline');
return sameOrigin('<html>');
});
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
await settle();
await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' });
await settle();
online = false;
const answer = await worker.request({ url: `${ORIGIN}/mail`, headers: probe });
expect(answer?.status).toBe(200);
});
it('gives the 5xx through: a reload would land on that', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => new Response('', { status: 502 })) });
await settle();
const answer = await worker.request({ url: `${ORIGIN}/mail`, headers: probe });
expect(answer?.status).toBe(502);
});
it('fails with no shell and no network, so the page knows to stay', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => { throw new TypeError('offline'); }) });
await settle();
await expect(worker.request({ url: `${ORIGIN}/mail`, headers: probe })).rejects.toThrow();
});
it('leaves any other fetch of a page to the network', async () => {
const caches = new FakeCaches();
await shellOn(caches, true);
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: vi.fn(async () => sameOrigin('<html>')) });
await settle();
expect(await worker.request({ url: `${ORIGIN}/mail` })).toBeNull();
});
});
describe('service worker with offline mode off', () => {
it('leaves every request to the network and caches nothing', async () => {
const caches = new FakeCaches();
+277 -6
View File
@@ -7,6 +7,7 @@ import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store
import {
addOsUnlock,
appLockHold,
changeAppLockPin,
appLockKeyLayers,
bootAppLock,
disableAppLock,
@@ -17,12 +18,14 @@ import {
resetAppLockForTests,
setReloadForTests,
startAppLock,
STAY_RETRY_MS,
unlockWithOs,
unlockWithPin,
} from '../controller';
import { LOCK_LAYER } from '../crypto';
import { appLockRequired, effectiveTimeout } from '../policy';
import { APP_LOCK_KEY, isAppLockedNow, readAppLockFile, setLockedHere } from '../state';
import { deleteVault } from '../vault';
import { createVault, deleteVault, readVault, sealDraft, VaultExistsError } from '../vault';
const PIN = 'correct-pin';
@@ -85,6 +88,9 @@ function bridge(overrides: Record<string, unknown> = {}) {
osRemove: vi.fn(async () => {}),
osUnlock: vi.fn(async () => ({ ok: false as const, reason: 'denied' as const })),
onLockRequest: vi.fn(() => () => {}),
claim: vi.fn(() => ({ lock: appLock.lock, osUnlock: appLock.osUnlock })),
wrap: vi.fn(async () => null),
unwrap: vi.fn(async () => null),
...overrides,
};
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock };
@@ -205,10 +211,68 @@ describe('while locked', () => {
expect(holdDuringSave).toBeNull();
});
it('notifications go by it: the check the OS code asks says locked', async () => {
it('the check the OS code asks follows the lock both ways', async () => {
expect(isAppLocked()).toBe(false);
await enableAppLock(PIN);
asNewDocument();
expect(isAppLocked()).toBe(true);
await unlockWithPin(PIN);
expect(isAppLocked()).toBe(false);
});
it('a start that finds the app locked takes subjects out of storage too: a quit or a crash left them there', async () => {
await enableAppLock(PIN);
await lockedAndReloaded();
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}');
localStorage.setItem('contact-storage', '{"state":{"contacts":[{"name":"Secret Person"}]}}');
localStorage.setItem('notification-center', '[{"title":"Secret notice"}]');
asNewDocument();
await vi.waitFor(() => expect(JSON.stringify({ ...localStorage })).not.toContain('Secret'));
});
it('so does a start after a crash while unlocked, once it finds nobody holds the key', async () => {
await enableAppLock(PIN);
resetAppLockForTests();
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}');
localStorage.setItem('notification-center', '[{"title":"Secret notice"}]');
bootAppLock();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
await vi.waitFor(() => expect(JSON.stringify({ ...localStorage })).not.toContain('Secret'));
});
it('a start where another tab holds the key leaves storage alone', async () => {
await enableAppLock(PIN);
resetAppLockForTests();
localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Kept"}]}');
(window.navigator.locks.query as ReturnType<typeof vi.fn>).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] });
(window.navigator.locks.request as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise(() => {}));
bootAppLock();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('open'));
expect(localStorage.getItem('email-snapshot')).toContain('Kept');
});
it('contacts and notifications come back at unlock, sealed while locked', async () => {
await enableAppLock(PIN);
localStorage.setItem('contact-storage', '{"state":{"contacts":[{"name":"Secret Person"}]},"version":0}');
localStorage.setItem('notification-center', JSON.stringify([{ id: 'n', time: 1, level: 'info', title: 'Secret notice', body: '', read: false }]));
await lockedAndReloaded();
expect(JSON.stringify({ ...localStorage })).not.toContain('Secret');
await unlockWithPin(PIN);
expect(localStorage.getItem('contact-storage')).toContain('Secret Person');
expect(localStorage.getItem('notification-center')).toContain('Secret notice');
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
expect(useNotificationCenterStore.getState().entries.map((entry) => entry.title)).toContain('Secret notice');
});
it('a newer copy replaces the sealed one instead of piling up', async () => {
await enableAppLock(PIN);
localStorage.setItem('notification-center', '[{"id":"n","title":"Old notice"}]');
await lockedAndReloaded();
localStorage.setItem('notification-center', '[{"id":"n","title":"New notice"}]');
asNewDocument();
await vi.waitFor(() => expect(localStorage.getItem('notification-center')).toBeNull());
await unlockWithPin(PIN);
expect(localStorage.getItem('notification-center')).toContain('New notice');
});
it('a browser closed without locking counts as locked: nobody holds the key', async () => {
@@ -288,8 +352,62 @@ describe('locking', () => {
lockEverywhere();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay'));
expect(reloads).toBe(0);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 200 }));
window.dispatchEvent(new Event('online'));
expect(reloads).toBe(1);
await vi.waitFor(() => expect(reloads).toBe(1));
});
it('asks in a way the service worker can answer: a GET that it knows is this probe', async () => {
await enableAppLock(PIN);
await lockedAndReloaded();
const [, init] = (fetch as ReturnType<typeof vi.fn>).mock.calls.at(-1) as [string, RequestInit];
expect(init.method).toBe('GET');
expect(new Headers(init.headers).get('x-bulwark-reload-probe')).toBe('1');
});
it('a 5xx counts as out of reach: a reload would land on the error page', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 503 }));
await enableAppLock(PIN);
lockEverywhere();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay'));
expect(reloads).toBe(0);
});
it('keeps trying on a timer: nothing says "online" when only the server was down', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
try {
(fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('down'));
await enableAppLock(PIN);
lockEverywhere();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay'));
vi.advanceTimersByTime(STAY_RETRY_MS);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(reloads, 'still down').toBe(0);
(fetch as ReturnType<typeof vi.fn>).mockResolvedValue(new Response(null, { status: 200 }));
vi.advanceTimersByTime(STAY_RETRY_MS);
await vi.waitFor(() => expect(reloads).toBe(1));
} finally {
vi.useRealTimers();
}
});
it('a pop-out that unlocks with no main window leaves sealed drafts sealed, for the main window', async () => {
await enableAppLock(PIN);
await sealDraft({ subject: 'Sealed text' });
await lockedAndReloaded();
window.history.replaceState(null, '', '/popout/compose');
try {
asNewDocument();
await unlockWithPin(PIN);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionStorage.getItem('bulwark-rescued-drafts')).toBeNull();
} finally {
window.history.replaceState(null, '', '/');
}
lockEverywhere();
asNewDocument();
await unlockWithPin(PIN);
await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Sealed text'));
});
});
@@ -390,6 +508,108 @@ describe('the desktop app', () => {
expect(isAppLockedNow()).toBe(false);
});
it('a page takes the two calls a frame must not reach once, before any frame runs', async () => {
const appLock = bridge();
bootAppLock();
expect(appLock.claim).toHaveBeenCalledTimes(1);
await enableAppLock(PIN);
await lockedAndReloaded();
expect(appLock.lock).toHaveBeenCalled();
});
it('a claim someone else got first leaves this page with no OS unlock, and the PIN working', async () => {
const appLock = bridge({ claim: vi.fn(() => null) });
await enableAppLock(PIN);
await lockedAndReloaded();
expect(await unlockWithOs()).toBe(false);
expect(appLock.osUnlock).not.toHaveBeenCalled();
await unlockWithPin(PIN);
expect(isAppLockedNow()).toBe(false);
});
it('a no from main on a good PIN isn\'t ignored: it enrolls again, and is unlocked', async () => {
const appLock = bridge();
await enableAppLock(PIN);
await lockedAndReloaded();
appLock.enroll.mockClear();
appLock.prove.mockResolvedValue(false as never);
await unlockWithPin(PIN);
expect(appLock.enroll).toHaveBeenCalledTimes(1);
expect(isAppLockedNow()).toBe(false);
});
it('main keeps an older lock: it asks natively, and only a yes replaces it', async () => {
const appLock = bridge();
await enableAppLock(PIN);
await lockedAndReloaded();
appLock.prove.mockResolvedValue(false as never);
appLock.enroll.mockResolvedValueOnce(false as never);
appLock.forget.mockResolvedValueOnce(false as never);
await expect(unlockWithPin(PIN)).rejects.toThrow();
expect(isAppLockedNow(), 'main still locked, so the page is').toBe(true);
expect(appLock.enroll).toHaveBeenCalledTimes(2);
appLock.enroll.mockResolvedValueOnce(false as never);
appLock.forget.mockResolvedValueOnce(true as never);
appLock.enroll.mockResolvedValueOnce(true as never);
await unlockWithPin(PIN);
expect(isAppLockedNow()).toBe(false);
});
it('turning it off with main refusing the proof: it asks, and a no leaves everything on', async () => {
const appLock = bridge({ disable: vi.fn(async () => false), forget: vi.fn(async () => false) });
await openAccountKey('acc');
await enableAppLock(PIN);
await expect(disableAppLock(PIN)).rejects.toThrow();
expect(readAppLockFile().on).toBe(true);
expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']);
expect(await readVault()).not.toBeNull();
appLock.forget.mockResolvedValue(true);
await disableAppLock(PIN);
expect(readAppLockFile().on).toBe(false);
});
it('turning it off when main has no lock at all just goes on', async () => {
bridge({
disable: vi.fn(async () => false),
forget: vi.fn(async () => false),
status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false })),
});
await enableAppLock(PIN);
await disableAppLock(PIN);
expect(readAppLockFile().on).toBe(false);
});
it('a sign-out in the tab with the key shows main the proof, so nothing asks', async () => {
const appLock = bridge();
await enableAppLock(PIN);
const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array;
const { forgetAppLockAtSignOut } = await import('../sign-out');
await forgetAppLockAtSignOut();
expect(Array.from(appLock.forget.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof));
expect(await readVault()).toBeNull();
});
it('a record with main\'s safeStorage layer opens, and keeps it under the lock', async () => {
bridge({
status: vi.fn(async () => ({ locked: false, enrolled: true, os: null, osEnrolled: false, wrap: true })),
wrap: vi.fn(async (data: Uint8Array) => Uint8Array.from([7, ...data])),
unwrap: vi.fn(async (data: Uint8Array) => (data[0] === 7 ? data.slice(1) : null)),
});
const safe = {
name: 'safeStorage',
wrap: async (data: Uint8Array) => Uint8Array.from([7, ...data]),
unwrap: async (data: Uint8Array) => data.slice(1),
};
const { keyHex } = await openAccountKey('acc', [safe]);
await enableAppLock(PIN);
expect((await getAccountRecord('acc'))?.layers).toEqual(['safeStorage', LOCK_LAYER]);
await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex });
// Off, nothing asks main at a start, so main's wrap goes too.
await disableAppLock(PIN);
expect((await getAccountRecord('acc'))?.layers).toEqual([]);
});
it('forgetting asks main first: a no changes nothing', async () => {
const appLock = bridge({ forget: vi.fn(async () => false) });
await enableAppLock(PIN);
@@ -422,9 +642,10 @@ describe('policy', () => {
expect(appLockHold()).toBeInstanceOf(Promise);
});
it('a required lock doesn\'t stand in front of the login page', () => {
it('a required lock doesn\'t stand in front of the login page', async () => {
withPolicy({ value: true, locked: true });
bootAppLock();
await vi.waitFor(() => expect(appLockHold()).toBeNull());
expect(getAppLockPhase()).toBe('open');
});
@@ -461,11 +682,61 @@ describe('policy', () => {
});
});
describe('a lost lock file', () => {
it('a vault means on: the file going doesn\'t turn the lock off', async () => {
await enableAppLock(PIN);
await lockedAndReloaded();
localStorage.removeItem(APP_LOCK_KEY);
asNewDocument();
expect(appLockHold(), 'held back while it looks').toBeInstanceOf(Promise);
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
expect(readAppLockFile()).toMatchObject({ on: true, locked: true });
await unlockWithPin(PIN);
expect(isAppLockedNow()).toBe(false);
});
it('so does an unreadable one', async () => {
await enableAppLock(PIN);
await lockedAndReloaded();
localStorage.setItem(APP_LOCK_KEY, '{broken');
asNewDocument();
await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked'));
});
it('turning it on doesn\'t overwrite a vault that still holds a key', async () => {
await openAccountKey('acc');
await enableAppLock(PIN);
localStorage.removeItem(APP_LOCK_KEY);
const before = await readVault();
resetAppLockForTests();
await expect(enableAppLock('another-pin-82')).rejects.toBeInstanceOf(VaultExistsError);
expect((await readVault())?.pin.data).toEqual(before?.pin.data);
await expect(createVault('another-pin-82')).rejects.toBeInstanceOf(VaultExistsError);
});
});
describe('a PIN anyone would guess', () => {
it('is refused when set, and changing to one too', async () => {
await expect(enableAppLock('123456')).rejects.toMatchObject({ name: 'ObviousPinError' });
expect(await readVault()).toBeNull();
await enableAppLock(PIN);
await expect(changeAppLockPin(PIN, '000000')).rejects.toMatchObject({ name: 'ObviousPinError' });
await unlockAfterChangeRefused();
});
async function unlockAfterChangeRefused(): Promise<void> {
await lockedAndReloaded();
await unlockWithPin(PIN);
expect(isAppLockedNow()).toBe(false);
}
});
describe('off', () => {
it('is the default: nothing is held back and no layer is asked for', () => {
it('is the default: nothing is shown as locked and no layer is asked for', async () => {
bootAppLock();
await vi.waitFor(() => expect(appLockHold()).toBeNull());
expect(getAppLockPhase()).toBe('open');
expect(appLockHold()).toBeNull();
expect(isAppLockedNow()).toBe(false);
expect(appLockKeyLayers()).toEqual([]);
expect(localStorage.getItem(APP_LOCK_KEY)).toBeNull();
});
+121
View File
@@ -0,0 +1,121 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The way out when the PIN is gone. The order is what matters: nothing may be left
// signed in with no PIN, and no record may keep the layer with no vault, whatever
// the server does and wherever it stops.
const events = vi.hoisted(() => [] as string[]);
const logoutAll = vi.hoisted(() => vi.fn());
vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ logoutAll }) } }));
vi.mock('@/lib/store/client/purge', async () => {
const keys = await vi.importActual<typeof import('@/lib/store/client/keys')>('@/lib/store/client/keys');
return {
purgeStoreFiles: async () => {
events.push('keys');
keys.deleteKeyDatabase();
},
deleteStorePool: async () => {},
};
});
vi.mock('@/lib/offline/device-wipe', () => ({ forgetDeviceRows: async () => { events.push('rows'); } }));
import { getAccountRecord, listAccountRecords, openAccountKey } from '@/lib/store/client/keys';
import { enableAppLock, forgetEverything, lockEverywhere, resetAppLockForTests, setLeaveForTests, setReloadForTests } from '../controller';
import { readAppLockFile } from '../state';
import { deleteVault, readVault } from '../vault';
import { COOKIES_TO_CLEAR_KEY, FRESH_LOGIN_KEY } from '../sign-out';
const PIN = 'correct-pin';
class FakeChannel {
onmessage = null;
postMessage(): void {}
close(): void {}
}
let left = 0;
beforeEach(async () => {
events.length = 0;
logoutAll.mockReset();
left = 0;
localStorage.clear();
sessionStorage.clear();
vi.stubGlobal('BroadcastChannel', FakeChannel);
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
setReloadForTests(() => {});
setLeaveForTests(() => { left += 1; });
resetAppLockForTests();
await deleteVault();
localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } }));
await openAccountKey('a');
await enableAppLock(PIN);
lockEverywhere();
});
afterEach(() => {
vi.unstubAllGlobals();
setReloadForTests(null);
setLeaveForTests(null);
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
describe('forgetting the PIN', () => {
it('a server that hangs leaves nothing behind: no registry, no keys, no vault, no lock', async () => {
logoutAll.mockImplementation(() => new Promise(() => {}));
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
expect(await forgetEverything()).toBe(true);
expect(left).toBe(1);
expect(localStorage.getItem('account-registry')).toBeNull();
expect(await listAccountRecords()).toEqual([]);
expect(await readVault()).toBeNull();
expect(readAppLockFile().on).toBe(false);
}, 30_000);
it('removes the registry and the keys first, lifts the lock last, and only then logs out', async () => {
logoutAll.mockImplementation(async () => {
events.push('logout');
expect(localStorage.getItem('account-registry')).toBeNull();
expect(await getAccountRecord('a')).toBeNull();
expect(await readVault()).toBeNull();
expect(readAppLockFile().on).toBe(false);
});
expect(await forgetEverything()).toBe(true);
expect([...new Set(events)]).toEqual(['rows', 'keys', 'logout']);
});
it('a key database that won\'t go keeps the vault and the lock', async () => {
const keys = await import('@/lib/store/client/keys');
const stuck = vi.spyOn(keys, 'forgetAccountKey').mockRejectedValue(new Error('held'));
const list = vi.spyOn(keys, 'listAccountRecords').mockResolvedValue([{ account: 'a', layers: ['applock'] }] as never);
try {
expect(await forgetEverything()).toBe(false);
expect(left).toBe(0);
expect(await readVault()).not.toBeNull();
expect(readAppLockFile().on).toBe(true);
expect(logoutAll).not.toHaveBeenCalled();
} finally {
stuck.mockRestore();
list.mockRestore();
}
});
it('keeps a mark while the cookies couldn\'t be cleared, and asks for a real login next', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('offline'); }));
logoutAll.mockResolvedValue(undefined);
expect(await forgetEverything()).toBe(true);
expect(localStorage.getItem(COOKIES_TO_CLEAR_KEY)).toBe('1');
expect(localStorage.getItem(FRESH_LOGIN_KEY)).toBe('1');
});
it('takes the cookie mark off once the server said it cleared them', async () => {
logoutAll.mockResolvedValue(undefined);
expect(await forgetEverything()).toBe(true);
expect(localStorage.getItem(COOKIES_TO_CLEAR_KEY)).toBeNull();
expect(localStorage.getItem(FRESH_LOGIN_KEY), 'the provider\'s session is still open').toBe('1');
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls.map(([url, init]) => `${(init as RequestInit)?.method} ${url}`);
expect(calls).toContain('DELETE /api/auth/session?all=true');
expect(calls).toContain('DELETE /api/auth/token?all=true');
});
});
+75 -3
View File
@@ -1,8 +1,8 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, 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';
import { importLockKey, lockLayer, mainProof, PBKDF2_ITERATIONS, pinIsObvious, pinLongEnough } from '../crypto';
import { changeVaultPin, createVault, deleteVault, FREE_FAILURES, LONGEST_WAIT_MS, openSealedDrafts, openVaultWithPin, PinBlockedError, pinBlockedUntil, readVault, sealDraft, waitAfter, WrongPinError } from '../vault';
const PIN = 'correct-pin';
@@ -20,6 +20,7 @@ describe('the PIN', () => {
const first = await readVault();
expect(first?.pin.iterations).toBeGreaterThanOrEqual(600_000);
expect(PBKDF2_ITERATIONS).toBeGreaterThanOrEqual(600_000);
await deleteVault();
(await createVault(PIN)).fill(0);
const second = await readVault();
expect(hex(second!.pin.salt)).not.toBe(hex(first!.pin.salt));
@@ -39,12 +40,83 @@ describe('the PIN', () => {
expect(JSON.stringify(stored)).not.toContain(PIN);
});
it.each([
'000000', '999999', 'aaaaaa', '123456', '654321', '234567', '012345', '1234567890', 'abcdef', 'ABCDEF', 'fedcba',
'121212', '123123', 'abcabc', '112233', 'password', 'Password1', 'qwerty', 'qwertz', '147258', '159753', '1q2w3e', 'letmein',
])('refuses %s as too easy to guess', (pin) => {
expect(pinIsObvious(pin)).toBe(true);
});
it.each(['it-pin-4827', 'correct-pin', '482913', '135792', 'horse battery', '1234567a9'])('takes %s', (pin) => {
expect(pinIsObvious(pin)).toBe(false);
});
it('must be long enough, counted in characters', () => {
expect(pinLongEnough('12345')).toBe(false);
expect(pinLongEnough('123456')).toBe(true);
});
});
describe('in the desktop app, with main\'s safeStorage', () => {
function shell(overrides: Record<string, unknown> = {}) {
const appLock = {
status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false, wrap: true })),
wrap: vi.fn(async (data: Uint8Array) => Uint8Array.from([9, ...data])),
unwrap: vi.fn(async (data: Uint8Array) => (data[0] === 9 ? data.slice(1) : null)),
...overrides,
};
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock };
return appLock;
}
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
it('goes through main, and opens only through main', async () => {
const appLock = shell();
const raw = await createVault(PIN);
expect(appLock.wrap).toHaveBeenCalledTimes(1);
expect((await readVault())!.pin.shell).toBe(true);
expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw));
expect(appLock.unwrap).toHaveBeenCalledTimes(1);
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
await expect(openVaultWithPin(PIN)).rejects.toMatchObject({ name: 'VaultShellError' });
});
it('main saying no isn\'t a wrong PIN, and costs no try', async () => {
shell();
await createVault(PIN);
shell({ unwrap: vi.fn(async () => null) });
for (let i = 0; i < FREE_FAILURES + 2; i += 1) {
await expect(openVaultWithPin(PIN)).rejects.toMatchObject({ name: 'VaultShellError' });
}
shell();
await expect(openVaultWithPin(PIN)).resolves.toBeInstanceOf(Uint8Array);
});
it('a changed PIN goes through main too', async () => {
const appLock = shell();
await createVault(PIN);
await changeVaultPin(PIN, 'next-pin-5521');
expect(appLock.wrap).toHaveBeenCalledTimes(2);
expect((await readVault())!.pin.shell).toBe(true);
await expect(openVaultWithPin('next-pin-5521')).resolves.toBeInstanceOf(Uint8Array);
});
it('a shell whose storage only obfuscates: no wrap, and it says so in the record', async () => {
const appLock = shell({ status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false, wrap: false })) });
await createVault(PIN);
expect(appLock.wrap).not.toHaveBeenCalled();
expect((await readVault())!.pin.shell).toBeUndefined();
});
it('with no shell at all, a browser: nothing changes', async () => {
await createVault(PIN);
expect((await readVault())!.pin.shell).toBeUndefined();
});
});
describe('wrong PINs', () => {
const NOW = 1_700_000_000_000;
+286 -39
View File
@@ -24,14 +24,18 @@ 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 { forgetAccountKey, listAccountRecords, rewrapAccountKey, StoreKeyLayerMissingError, type AccountRecord, type WrapLayer } from '@/lib/store/client/keys';
import { purgeStoreFiles } from '@/lib/store/client/purge';
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { OWNER_LOCK } from '@/lib/store/worker/protocol';
import type { DesktopOsUnlockAnswer } from '@/lib/desktop/types';
import type { ComposerDraftData } from '@/components/email/email-composer';
import { importLockKey, LOCK_LAYER, lockLayer, mainProof } from './crypto';
import { importLockKey, LOCK_LAYER, lockLayer, mainProof, SHELL_LAYER } from './crypto';
import { watchIdle } from './idle';
import { createPasskey, passkeyPrf, PasskeyNoPrfError } from './passkey';
import { appLockRequired, effectiveTimeout, onLockPolicyChange } from './policy';
import { scrubBrowserStorage } from './scrub';
import { reloadSealedStores, restoreSealedStorage, scrubBrowserStorage } from './scrub';
import { clearPendingCookies, markCookiesToClear, rememberMainProof, requireFreshLogin } from './sign-out';
import {
APP_LOCK_CHANNEL,
APP_LOCK_HOLDER,
@@ -55,6 +59,7 @@ import {
readVault,
removeVaultPrf,
sealDraft,
vaultExists,
} from './vault';
export type AppLockPhase =
@@ -76,6 +81,12 @@ const STORE_CLOSE_MS = 5_000;
const OWNER_WAIT_MS = 5_000;
const HOLDER_WAIT_MS = 1_500;
const RELOAD_PROBE_MS = 2_000;
/** How long a start without the lock file holds the page for the look at the vault. */
const VAULT_LOOK_MS = 1_500;
/** Locked on a page that couldn't reload: how often it tries again. Only the server may have been down. */
export const STAY_RETRY_MS = 30_000;
/** How long a forgotten PIN waits for the sign-out, on top of the cookies' own wait. Nothing depends on it by then. */
const FORGET_LOGOUT_MS = 5_000;
const DRAFTS_SAVED_KEY = 'bulwark-app-lock-drafts-saved';
let booted = false;
@@ -88,6 +99,23 @@ let heldKey: CryptoKey | null = null;
let releaseHolder: (() => void) | null = null;
let holderWatch: AbortController | null = null;
let stopIdle: (() => void) | null = null;
let stopStay: (() => void) | null = null;
/** Setting up right now: the vault this tab just wrote says nothing about a lost file. */
let enabling = false;
/** Main's wrap for the keys, when it has a real one. Known once asked: unlocking and enabling ask first. */
let shellWraps = false;
/**
* The two calls a same-origin frame mustn't reach: a frame can get at the parent's bridge, and
* main can't tell those calls apart (the shell's ipc.ts). The preload hands them out once per
* document, and this takes them in the first render, before any frame exists.
*/
let controls: { lock: () => void; osUnlock: () => Promise<DesktopOsUnlockAnswer> } | null | undefined;
function desktopControls() {
if (controls === undefined) controls = getDesktopBridge()?.appLock?.claim() ?? null;
return controls;
}
function setPhase(next: AppLockPhase): void {
if (phase === next) return;
@@ -146,6 +174,7 @@ export function bootAppLock(): void {
if (booted || typeof window === 'undefined' || IS_LITE) return;
booted = true;
setAppLockCheck(isAppLockedNow);
desktopControls();
const file = readAppLockFile();
if (file.on) {
setLockedHere(true);
@@ -153,6 +182,7 @@ export function bootAppLock(): void {
if (file.locked) {
phase = 'locked';
tellDesktopLocked();
void scrubBrowserStorage();
} else {
phase = 'checking';
void settleCheck();
@@ -160,10 +190,72 @@ export function bootAppLock(): void {
return;
}
markDocument(false);
hold = newHold();
if (setupNeeded()) {
hold = newHold();
phase = 'setup';
} else {
setLockedHere(true);
phase = 'checking';
}
void settleNoFile();
}
function lockedFromNow(): void {
writeAppLockFile({ ...readAppLockFile(), on: true, locked: true });
setLockedHere(true);
markDocument(true);
hold ??= newHold();
tellDesktopLocked();
tellServiceWorker('locked');
void scrubBrowserStorage();
setPhase('locked');
}
/**
* No file says the lock is on. Most often that's the truth, and this takes one look at
* IndexedDB. But the file can go on its own (a cleared site setting, an extension, someone
* at the keyboard), and the vault is what really decides: it holds the key the store's
* keys depend on. A look that takes too long lets the page go on, and locks it if it ends in a vault.
*/
async function settleNoFile(): Promise<void> {
const looking = vaultExists();
let waited = false;
const found = await Promise.race([
looking,
new Promise<boolean>((resolve) => setTimeout(() => {
waited = true;
resolve(false);
}, VAULT_LOOK_MS)),
]);
if (readAppLockFile().on) {
// Another tab turned it on meanwhile.
if (phase === 'checking') void settleCheck();
return;
}
if (found && !enabling) {
lockedFromNow();
return;
}
if (phase === 'checking') {
setLockedHere(false);
const next = setupNeeded() ? 'setup' : 'open';
setPhase(next);
if (next === 'open') {
hold?.release();
hold = null;
}
}
if (waited) {
void looking.then((late) => {
if (late && !enabling && !readAppLockFile().on) void lockLate();
});
}
}
async function lockLate(): Promise<void> {
writeAppLockFile({ ...readAppLockFile(), on: true, locked: true });
postAppLockMessage({ type: 'lock' });
await applyLockHere();
}
async function holderAlive(): Promise<boolean> {
@@ -193,14 +285,11 @@ async function settleCheck(): Promise<void> {
return;
}
// Nobody holds the key: a crash, or the browser was closed without saying so.
writeAppLockFile({ ...readAppLockFile(), locked: true });
tellDesktopLocked();
tellServiceWorker('locked');
setPhase('locked');
lockedFromNow();
}
function tellDesktopLocked(): void {
getDesktopBridge()?.appLock?.lock();
desktopControls()?.lock();
}
/** The service worker shows push notifications with no page around. Kept across its restarts. */
@@ -268,12 +357,14 @@ async function becomeHolder(): Promise<void> {
function dropKey(): void {
heldKey = null;
rememberMainProof(null);
releaseHolder?.();
releaseHolder = null;
}
/** The app starts, or goes on, in this tab. */
function openUp(): void {
stopStay?.();
setLockedHere(false);
markDocument(false);
setPhase('open');
@@ -352,15 +443,46 @@ export function setReloadForTests(run: (() => void) | null): void {
reloadSeam = run;
}
/**
* Whether a reload would land on a page. The service worker answers this as it would answer
* the reload (public/sw.js), from its copy of the shell when offline, so a lock with no
* network still clears memory. Only a page with neither stays where it is.
*/
async function pageReachable(): Promise<boolean> {
try {
const response = await fetch(window.location.href, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(RELOAD_PROBE_MS) });
const response = await fetch(window.location.href, {
method: 'GET',
cache: 'no-store',
headers: { 'x-bulwark-reload-probe': '1' },
signal: AbortSignal.timeout(RELOAD_PROBE_MS),
});
void response.body?.cancel().catch(() => {});
return response.status < 500;
} catch {
return false;
}
}
/** Off the screen now, out of memory as soon as a reload can land somewhere. */
function stayUntilReachable(): void {
setPhase('locked-stay');
const tryNow = () => {
void pageReachable().then((reachable) => {
if (reachable && isAppLockedNow() && phase === 'locked-stay') {
stopStay?.();
reloadPage();
}
});
};
const timer = setInterval(tryNow, STAY_RETRY_MS);
window.addEventListener('online', tryNow);
stopStay = () => {
clearInterval(timer);
window.removeEventListener('online', tryNow);
stopStay = null;
};
}
async function applyLockHere(): Promise<void> {
if (phase === 'locking' || phase === 'locked' || phase === 'locked-stay') return;
const wasRunning = phase === 'open';
@@ -392,11 +514,8 @@ async function applyLockHere(): Promise<void> {
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();
});
// browser's error page, with no way to unlock.
stayUntilReachable();
}
function storeBackend(): StoreBackend {
@@ -413,19 +532,46 @@ async function lockStore(): Promise<void> {
// ---- Unlocking ----
/**
* Main has to agree before this tab opens: a page can't go on while main says locked. A no
* on a good PIN means main lost its record or keeps one from before. Having the proof shows
* the PIN was right, so it enrolls again. Main refuses that while it holds another lock, and
* then only the user can let that one go, natively.
*/
async function proveToMain(proof: Uint8Array): Promise<void> {
const member = getDesktopBridge()?.appLock;
if (!member) return;
if (await member.prove(proof).catch(() => false)) return;
if (await member.enroll(proof).catch(() => false)) return;
if (await member.forget().catch(() => false) && await member.enroll(proof).catch(() => false)) return;
throw new Error('The desktop app stays locked');
}
async function askShell(): Promise<void> {
const member = getDesktopBridge()?.appLock;
shellWraps = member?.wrap ? Boolean((await member.status().catch(() => null))?.wrap) : false;
}
async function holdAndOpen(raw: Uint8Array): Promise<void> {
try {
const proof = await mainProof(raw);
const key = await importLockKey(raw);
await getDesktopBridge()?.appLock?.prove(proof).catch(() => false);
await proveToMain(proof);
rememberMainProof(proof);
heldKey = key;
} finally {
raw.fill(0);
}
await askShell();
await becomeHolder();
const key = heldKey!;
await addLayerWhereMissing(key);
await storeBackend().unlock?.(OWNER_WAIT_MS);
// Before the app below starts, and before the others hear: they read it again when they do.
if (!isPopoutWindow()) {
await restoreSealedStorage(key);
await reloadSealedStores();
}
writeAppLockFile({ ...readAppLockFile(), on: true, locked: false });
postAppLockMessage({ type: 'unlocked' });
tellServiceWorker('unlocked');
@@ -433,12 +579,11 @@ async function holdAndOpen(raw: Uint8Array): Promise<void> {
await deliverSealedDrafts(key);
}
/** A pop-out is about to close and nothing listens for it with no main window: they wait for one. */
async function deliverSealedDrafts(key: CryptoKey): Promise<void> {
if (isPopoutWindow()) return;
try {
for (const state of await openSealedDrafts(key)) {
if (isPopoutWindow()) postAppLockMessage({ type: 'unsealed-draft', state });
else keepRescuedDraft(state as ComposerDraftData);
}
for (const state of await openSealedDrafts(key)) keepRescuedDraft(state as ComposerDraftData);
} catch {
// They stay sealed for the next unlock.
}
@@ -458,7 +603,7 @@ export async function unlockWithPasskey(): Promise<void> {
/** Windows Hello or Touch ID. Main asks, and only hands over the key after a yes. */
export async function unlockWithOs(): Promise<boolean> {
const member = getDesktopBridge()?.appLock;
const member = desktopControls();
if (!member) return false;
const answer = await member.osUnlock();
if (!answer.ok) return false;
@@ -471,23 +616,48 @@ export async function unlockWithOs(): Promise<boolean> {
/** A store record made when the lock was off, or before a crash: it gets the layer now. */
async function addLayerWhereMissing(key: CryptoKey): Promise<void> {
const layer = lockLayer(key);
for (const record of await listAccountRecords().catch(() => [])) {
if (!record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], [layer]);
const shell = shellLayer();
for (const record of await listAccountRecords().catch(() => [] as AccountRecord[])) {
if (record.layers.includes(LOCK_LAYER)) continue;
// Whatever wraps it already has to come off to get at the key, and goes back on under the lock.
await rewrapAccountKey(record.account, shell ? [shell] : [], shell ? [shell, layer] : [layer]).catch((error: unknown) => {
// One account's record that can't open here (a copied profile) doesn't keep the others shut.
console.warn('[app-lock] A record stays without the layer:', (error as Error)?.name);
});
}
}
/** Off again, and main's wrap with it: with the lock off nothing asks main, and a start must not need it. */
async function removeLayerEverywhere(key: CryptoKey): Promise<void> {
const layer = lockLayer(key);
for (const record of await listAccountRecords().catch(() => [])) {
if (record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, [layer], []);
const shell = shellLayer();
for (const record of await listAccountRecords().catch(() => [] as AccountRecord[])) {
if (record.layers.length > 0) await rewrapAccountKey(record.account, shell ? [shell, layer] : [layer], []);
}
}
/** The desktop's safeStorage around the database key (the build prompt's second layer), while the lock is on. */
function shellLayer(): WrapLayer | null {
const member = getDesktopBridge()?.appLock;
if (!shellWraps || !member?.wrap || !member.unwrap) return null;
const through = async (run: (data: Uint8Array) => Promise<Uint8Array | null>, data: Uint8Array) => {
const out = await run(data);
if (!out) throw new StoreKeyLayerMissingError(SHELL_LAYER);
return out;
};
return {
name: SHELL_LAYER,
wrap: (data) => through(member.wrap, data),
unwrap: (data) => through(member.unwrap, data),
};
}
/** 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)];
const shell = shellLayer();
return shell ? [shell, lockLayer(heldKey)] : [lockLayer(heldKey)];
}
/** This tab inherited the store and has no key for it. */
@@ -504,6 +674,15 @@ async function ownerIsElsewhere(): Promise<boolean> {
// ---- Settings ----
export async function enableAppLock(pin: string): Promise<void> {
enabling = true;
try {
await enable(pin);
} finally {
enabling = false;
}
}
async function enable(pin: string): Promise<void> {
const raw = await createVault(pin);
const proof = await mainProof(raw);
const member = getDesktopBridge()?.appLock;
@@ -518,6 +697,8 @@ export async function enableAppLock(pin: string): Promise<void> {
const key = await importLockKey(raw);
raw.fill(0);
heldKey = key;
rememberMainProof(proof);
await askShell();
await becomeHolder();
await addLayerWhereMissing(key);
const file: AppLockFile = { ...readAppLockFile(), on: true, locked: false };
@@ -541,11 +722,12 @@ export async function disableAppLock(pin: string): Promise<void> {
let key: CryptoKey;
try {
const proof = await mainProof(raw);
await getDesktopBridge()?.appLock?.disable(proof);
await letMainGo(proof);
key = await importLockKey(raw);
} finally {
raw.fill(0);
}
await askShell();
await removeLayerEverywhere(key);
await deleteVault();
writeAppLockFile({ on: false, locked: false });
@@ -554,7 +736,17 @@ export async function disableAppLock(pin: string): Promise<void> {
afterOff();
}
/** A no means main keeps a lock from before this PIN. Only the user can let that go, natively. */
async function letMainGo(proof: Uint8Array): Promise<void> {
const member = getDesktopBridge()?.appLock;
if (!member || await member.disable(proof).catch(() => false)) return;
const status = await member.status().catch(() => null);
if (status && !status.enrolled) return;
if (!(await member.forget().catch(() => false))) throw new Error('The desktop app keeps the lock');
}
function afterOff(): void {
stopStay?.();
stopIdle?.();
stopIdle = null;
holderWatch?.abort();
@@ -604,25 +796,76 @@ export async function addOsUnlock(pin: string): Promise<boolean> {
}
}
let leaveSeam: (() => void) | null = null;
/** Tests only: jsdom can't navigate. */
export function setLeaveForTests(run: (() => void) | null): void {
leaveSeam = run;
}
function leaveForLogin(): void {
if (leaveSeam) leaveSeam();
else window.location.replace(new URL(process.env.NEXT_PUBLIC_BASE_PATH || '/', window.location.origin).href);
}
/** The keys, for sure: with them gone the store's files can't be read, and no record is left wanting the vault. */
async function removeAccountKeys(): Promise<boolean> {
await purgeStoreFiles().catch(() => {});
const left = await listAccountRecords(true).catch(() => [] as AccountRecord[]);
for (const record of left) await forgetAccountKey(record.account).catch(() => {});
return left.length === 0 || (await listAccountRecords(true).catch(() => left)).length === 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.
*
* In an order that doesn't depend on the server. The registry and the keys go first, with
* no network, and the lock stays up meanwhile, so wherever this stops nobody is signed in
* without a PIN and no record wants a vault that's gone. The lock lifts last. Only then the
* sign-out, which needs the server and may hang: nothing waits for it beyond its time.
*
* What it leaves: the HttpOnly cookies when the server is out of reach, under a mark the
* start-up clears first (lib/app-lock/sign-out.ts). And the identity provider's session,
* which stays open: the next login asks the provider for a real one.
*/
export async function forgetEverything(): Promise<boolean> {
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(() => {});
markCookiesToClear();
requireFreshLogin();
try {
sessionStorage.setItem('signed_out', 'true');
} catch {
// Only the login page's automatic redirect goes by it.
}
const { forgetDeviceRows } = await import('@/lib/offline/device-wipe');
await forgetDeviceRows().catch(() => {});
const { useAccountStore } = await import('@/stores/account-store');
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null } as never);
try {
localStorage.removeItem('account-registry');
} catch {
// Storage blocked: nothing was kept there either.
}
if (!(await removeAccountKeys())) return false;
purgeSignedOutData({ keepAppLock: true });
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);
rememberMainProof(null);
const { useAuthStore } = await import('@/stores/auth-store');
await Promise.race([
Promise.resolve().then(() => useAuthStore.getState().logoutAll({ endProviderSession: false })).catch(() => {}),
new Promise((resolve) => setTimeout(resolve, FORGET_LOGOUT_MS)),
]);
await clearPendingCookies();
// This tab stays behind its lock screen until the new page: nothing of the app starts here.
leaveForLogin();
return true;
}
@@ -646,8 +889,10 @@ function onMessage(message: AppLockMessage): void {
break;
case 'unlocked':
if (phase === 'locked' || phase === 'checking' || phase === 'locked-stay') {
void desktopSaysLocked().then((locked) => {
if (!locked) openUp();
void desktopSaysLocked().then(async (locked) => {
if (locked) return;
await reloadSealedStores();
openUp();
});
}
break;
@@ -668,9 +913,6 @@ function onMessage(message: AppLockMessage): void {
case 'focus-main':
if (!isPopoutWindow()) window.focus();
break;
case 'unsealed-draft':
if (!isPopoutWindow()) keepRescuedDraft(message.state as ComposerDraftData);
break;
}
}
@@ -724,6 +966,11 @@ export function resetAppLockForTests(): void {
holderWatch = null;
stopIdle?.();
stopIdle = null;
stopStay?.();
controls = undefined;
enabling = false;
shellWraps = false;
rememberMainProof(null);
listeners.clear();
setLockedHere(false);
}
+37
View File
@@ -10,6 +10,7 @@
import type { WrapLayer } from '@/lib/store/client/keys';
export const LOCK_LAYER = 'applock';
export const SHELL_LAYER = 'safeStorage';
/** 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;
@@ -40,6 +41,42 @@ export function pinLongEnough(pin: string): boolean {
return [...pin.normalize('NFKC')].length >= MIN_PIN_LENGTH;
}
/** A few of what people pick most, as the check below reads them. Runs and repeats are found by rule. */
const COMMON_PINS = new Set([
'password', 'password1', 'password123', 'passwort', 'qwerty', 'qwertz', 'qwerty123', 'qwertyuiop', 'asdfgh', 'yxcvbn', 'zxcvbn',
'1q2w3e', '1q2w3e4r', 'q1w2e3', 'abc123', 'a1b2c3', '123abc', 'letmein', 'iloveyou', 'admin123', 'welcome', 'welcome1', 'monkey', 'dragon',
'147258', '159753', '258456', '789456', '102030', '101010', '696969', '111222', '121314', '131313', '123321', '112233',
]);
/** Digits go round, as on a keypad: 7890123. */
function allSameStep(chars: string[], step: number): boolean {
return chars.every((char, index) => {
if (index === 0) return true;
const distance = char.codePointAt(0)! - chars[index - 1].codePointAt(0)!;
const digits = /\d/.test(char) && /\d/.test(chars[index - 1]);
return distance === step || (digits && (distance + 10) % 10 === (step + 10) % 10);
});
}
/** Refused when a PIN is set. Someone with the disk tries exactly these first, and PBKDF2 can't help with those. */
export function pinIsObvious(pin: string): boolean {
const chars = [...pin.normalize('NFKC').toLowerCase()];
if (COMMON_PINS.has(chars.join(''))) return true;
if (allSameStep(chars, 0) || allSameStep(chars, 1) || allSameStep(chars, -1)) return true;
// 121212, 123123, abcabc: a short piece over and over.
for (let size = 1; size <= 3; size += 1) {
if (chars.length >= size * 2 && chars.every((char, index) => char === chars[index % size])) return true;
}
return false;
}
export class ObviousPinError extends Error {
constructor() {
super('That PIN is too easy to guess');
this.name = 'ObviousPinError';
}
}
/** Slow on purpose. Not extractable: it only ever wraps the lock key. */
export async function keyFromPin(pin: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
const base = await crypto.subtle.importKey('raw', buffer(pinBytes(pin)), 'PBKDF2', false, ['deriveKey']);
+34 -1
View File
@@ -4,10 +4,12 @@
* anywhere else is sealed first (see lib/app-lock/vault.ts).
*/
import { sealDraft } from './vault';
import { openSealedLocal, sealDraft, sealLocal } from './vault';
/** Subjects and senders: a list snapshot for a fast start, and the Pro tabs' names. */
const LOCAL_KEYS = ['email-snapshot', 'pro-tabs'];
/** People's names, and what notices said. Nothing fetches these back, so they wait sealed for the unlock. */
const SEALED_LOCAL_KEYS = ['contact-storage', 'notification-center'];
const SESSION_KEYS = ['bulwark-offline-lost'];
const SESSION_PREFIXES = ['bulwark-popout-compose:'];
const RESCUED_KEY = 'bulwark-rescued-drafts';
@@ -34,6 +36,16 @@ export async function scrubBrowserStorage(): Promise<void> {
} catch {
// Storage blocked: nothing was kept there either.
}
for (const key of SEALED_LOCAL_KEYS) {
try {
const value = localStorage.getItem(key);
if (value === null) continue;
await sealLocal(key, value).catch(() => false);
localStorage.removeItem(key);
} catch {
// As above.
}
}
for (const key of LOCAL_KEYS) {
try {
localStorage.removeItem(key);
@@ -42,3 +54,24 @@ export async function scrubBrowserStorage(): Promise<void> {
}
}
}
/** Puts back what a lock sealed. Only the tab with the lock key can. */
export async function restoreSealedStorage(lockKey: CryptoKey): Promise<void> {
try {
for (const [key, value] of await openSealedLocal(lockKey)) localStorage.setItem(key, value);
} catch {
// They stay sealed for the next unlock.
}
}
/** The stores read storage once, at import: before anyone unlocked. They read it again now. */
export async function reloadSealedStores(): Promise<void> {
try {
const { reloadNotificationCenter } = await import('@/stores/notification-center-store');
reloadNotificationCenter();
const { useContactStore } = await import('@/stores/contact-store');
await useContactStore.persist?.rehydrate();
} catch {
// What's on disk is there at the next load.
}
}
+84 -6
View File
@@ -1,15 +1,93 @@
/**
* 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.
* The sign-out's side of the lock, and what a forgotten PIN leaves to do. 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 { apiFetch } from '@/lib/browser-navigation';
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();
/** The session cookies are HttpOnly, so only the server can delete them, and it may be out of reach. */
export const COOKIES_TO_CLEAR_KEY = 'bulwark-cookies-to-clear';
/** The next login has to be a real one. See `requireFreshLogin`. */
export const FRESH_LOGIN_KEY = 'bulwark-fresh-login';
const COOKIE_CLEAR_MS = 3_000;
/** What shows main the tab really holds the key, in this tab's memory for as long as it does. */
let heldProof: Uint8Array | null = null;
export function rememberMainProof(proof: Uint8Array | null): void {
heldProof = proof;
}
/**
* Nobody is left to lock for. The tab with the key shows main its proof. Any other tab has
* none, and main asks the user natively: a word from a page can't take a lock away.
*/
export async function forgetAppLockAtSignOut(): Promise<void> {
const gone = deleteVault();
writeAppLockFile({ on: false, locked: false });
void getDesktopBridge()?.appLock?.forget().catch(() => false);
const proof = heldProof;
heldProof = null;
void getDesktopBridge()?.appLock?.forget(proof ?? undefined).catch(() => false);
await gone;
}
function mark(key: string, on: boolean): void {
try {
if (on) localStorage.setItem(key, '1');
else localStorage.removeItem(key);
} catch {
// Storage blocked: nothing was kept there either.
}
}
function marked(key: string): boolean {
try {
return localStorage.getItem(key) !== null;
} catch {
return false;
}
}
/** Before anything else in a forgotten PIN: from here on a leftover cookie mustn't sign anyone in. */
export function markCookiesToClear(): void {
mark(COOKIES_TO_CLEAR_KEY, true);
}
/**
* Tries the DELETE when a mark says it's due. True when nothing is left to clear. While it's
* false, a cookie may still be there, and nothing may adopt it (stores/auth-store.ts).
*/
export async function clearPendingCookies(): Promise<boolean> {
if (!marked(COOKIES_TO_CLEAR_KEY)) return true;
const asked = Promise.all(['/api/auth/session?all=true', '/api/auth/token?all=true'].map((path) => (
apiFetch(path, { method: 'DELETE', keepalive: true, signal: AbortSignal.timeout(COOKIE_CLEAR_MS) })
))).then((answers) => answers.every((answer) => answer.ok)).catch(() => false);
// A server that hangs mustn't keep a start or a way out waiting.
const cleared = await Promise.race([asked, new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COOKIE_CLEAR_MS))]);
if (cleared) mark(COOKIES_TO_CLEAR_KEY, false);
return cleared;
}
/**
* A forgotten PIN leaves the identity provider's session open: ending it takes the server
* and a page of the provider's, and it isn't only this app's to end. So anyone at this
* device could walk back in through it with no PIN and no password. The next login asks
* the provider for a real one (`prompt=login`, in the flows that go through it). A provider
* may ignore that, and the settings text doesn't promise more than asking.
*/
export function requireFreshLogin(): void {
mark(FRESH_LOGIN_KEY, true);
}
export function freshLoginRequired(): boolean {
return marked(FRESH_LOGIN_KEY);
}
export function freshLoginDone(): void {
mark(FRESH_LOGIN_KEY, false);
}
+4 -4
View File
@@ -2,7 +2,9 @@
* 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.
* run. Someone who can edit it can't unlock anything: the keys need the PIN. Someone
* who removes it gets nothing either: a start looks for the vault before it goes on
* (lib/app-lock/controller.ts).
*
* Imports nothing, so anything may import it: lib/popout/channel.ts, the store's
* flush and the auth store all ask here.
@@ -79,9 +81,7 @@ export type AppLockMessage =
/** 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 };
| { type: 'focus-main' };
export function postAppLockMessage(message: AppLockMessage): void {
if (typeof BroadcastChannel === 'undefined') return;
+132 -8
View File
@@ -6,11 +6,18 @@
* 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.
* way, which is why a PIN has a minimum length, letters are allowed and the obvious
* ones are refused.
*
* In the desktop app the PIN's wrap goes through main's safeStorage as well, so a copy of
* the disk can't be guessed at without this user's OS login. A browser has nothing like
* that, and the settings page says so. A shell whose storage only obfuscates (Linux with
* no secret service) says no, and the wrap stays as it is.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { sealFor, createSessionOpener, type SealedAnswer } from '@/lib/popout/sealed';
import { keyFromPin, keyFromPrf, open, PBKDF2_ITERATIONS, randomBytes, seal, type WrappedBytes } from './crypto';
import { keyFromPin, keyFromPrf, ObviousPinError, open, PBKDF2_ITERATIONS, pinIsObvious, randomBytes, seal, type WrappedBytes } from './crypto';
export const VAULT_DB = 'bulwark-app-lock';
const DB_VERSION = 1;
@@ -22,6 +29,8 @@ const ATTEMPTS_ID = 'attempts';
export interface PinWrap extends WrappedBytes {
salt: Uint8Array;
iterations: number;
/** `data` is wrapped once more by the desktop app's main process. */
shell?: true;
}
export interface PrfWrap extends WrappedBytes {
@@ -94,14 +103,60 @@ export function readVault(): Promise<VaultRecord | null> {
return readRecord<VaultRecord>(VAULT_ID);
}
/** Without creating the database by looking. A vault means the lock is on, whatever else says. */
export async function vaultExists(): Promise<boolean> {
if (typeof indexedDB === 'undefined') return false;
try {
if (typeof indexedDB.databases === 'function' && !(await indexedDB.databases()).some((db) => db.name === VAULT_DB)) return false;
return (await readVault()) !== null;
} catch {
return false;
}
}
/** A vault holds the lock key the store's keys depend on: a new one would lock them out for good. */
export class VaultExistsError extends Error {
constructor() {
super('There is a vault already');
this.name = 'VaultExistsError';
}
}
/** Main didn't open the PIN's wrap: another user, or a copied profile. Says nothing about the PIN. */
export class VaultShellError extends Error {
constructor() {
super('The desktop app could not open the PIN record');
this.name = 'VaultShellError';
}
}
async function throughShell(data: Uint8Array): Promise<{ data: Uint8Array; shell?: true }> {
const member = getDesktopBridge()?.appLock;
if (!member?.wrap) return { data };
const status = await member.status().catch(() => null);
if (!status?.wrap) return { data };
const wrapped = await member.wrap(data).catch(() => null);
return wrapped ? { data: wrapped, shell: true } : { data };
}
async function outOfShell(pin: PinWrap): Promise<Uint8Array> {
if (!pin.shell) return pin.data;
const data = await getDesktopBridge()?.appLock?.unwrap?.(pin.data).catch(() => null);
if (!data) throw new VaultShellError();
return data;
}
async function pinWrapOf(pin: string, raw: Uint8Array): Promise<PinWrap> {
const salt = randomBytes(32);
const key = await keyFromPin(pin, salt, PBKDF2_ITERATIONS);
return { salt, iterations: PBKDF2_ITERATIONS, ...(await seal(key, raw)) };
const sealed = await seal(key, raw);
return { salt, iterations: PBKDF2_ITERATIONS, iv: sealed.iv, ...(await throughShell(sealed.data)) };
}
/** 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<Uint8Array> {
if (pinIsObvious(pin)) throw new ObviousPinError();
if (await vaultExists()) throw new VaultExistsError();
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']);
@@ -156,11 +211,13 @@ export async function openVaultWithPin(pin: string, now = Date.now()): Promise<U
if (!vault) throw new WrongPinError();
const attempts = (await readRecord<AttemptsRecord>(ATTEMPTS_ID)) ?? { id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 };
if (attempts.blockedUntil > now) throw new PinBlockedError(attempts.blockedUntil);
// Before the count: main saying no costs nobody a try.
const data = await outOfShell(vault.pin);
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);
const raw = await open(key, { iv: vault.pin.iv, data });
await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 });
return raw;
} catch {
@@ -170,6 +227,7 @@ export async function openVaultWithPin(pin: string, now = Date.now()): Promise<U
/** Only ever the PIN's own wrap: the lock key and everything under it stay. */
export async function changeVaultPin(currentPin: string, newPin: string): Promise<void> {
if (pinIsObvious(newPin)) throw new ObviousPinError();
const raw = await openVaultWithPin(currentPin);
try {
const vault = await readVault();
@@ -219,6 +277,26 @@ export async function deleteVault(): Promise<void> {
interface SealedRow {
id?: number;
sealed: SealedAnswer;
/** The localStorage key this came out of. No key: a draft. */
local?: string;
}
function sealedRows(): Promise<SealedRow[]> {
return withDb((db) => promisify(db.transaction(SEALED, 'readonly').objectStore(SEALED).getAll())) as Promise<SealedRow[]>;
}
async function deleteSealedRows(rows: SealedRow[]): Promise<void> {
if (rows.length === 0) return;
await withDb(async (db) => {
const tx = db.transaction(SEALED, 'readwrite');
for (const row of rows) tx.objectStore(SEALED).delete(row.id!);
await done(tx);
});
}
async function openerFor(lockKey: CryptoKey, vault: VaultRecord) {
const jwk = JSON.parse(new TextDecoder().decode(await open(lockKey, vault.sealing.privateKey))) as JsonWebKey;
return createSessionOpener(jwk);
}
/** Works in any tab, with no key: the public half is enough to seal. */
@@ -238,10 +316,9 @@ export async function sealDraft(state: unknown): Promise<boolean> {
export async function openSealedDrafts(lockKey: CryptoKey): Promise<unknown[]> {
const vault = await readVault();
if (!vault) return [];
const rows = await withDb((db) => promisify(db.transaction(SEALED, 'readonly').objectStore(SEALED).getAll())) as SealedRow[];
const rows = (await sealedRows()).filter((row) => !row.local);
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 opener = await openerFor(lockKey, vault);
const out: unknown[] = [];
for (const row of rows) {
try {
@@ -250,10 +327,57 @@ export async function openSealedDrafts(lockKey: CryptoKey): Promise<unknown[]> {
// Sealed under an older key: nothing can read it, and it must not block the rest.
}
}
await deleteSealedRows(rows);
return out;
}
// ---- Stores that name people, taken out of plain storage while locked ----
/** Only the newest copy of a key stays. False with no vault to seal for. */
export async function sealLocal(key: string, value: string): Promise<boolean> {
const vault = await readVault();
if (!vault) return false;
const sealed = await sealFor(vault.sealing.publicKey, value);
const older = (await sealedRows()).filter((row) => row.local === key);
await deleteSealedRows(older);
await withDb(async (db) => {
const tx = db.transaction(SEALED, 'readwrite');
tx.objectStore(SEALED).add({ sealed, local: key } satisfies SealedRow);
await done(tx);
});
return true;
}
/** With the lock key. What's opened comes off the list: the caller puts it back in storage. */
export async function openSealedLocal(lockKey: CryptoKey): Promise<Array<[string, string]>> {
const vault = await readVault();
if (!vault) return [];
const rows = (await sealedRows()).filter((row) => row.local);
if (rows.length === 0) return [];
const opener = await openerFor(lockKey, vault);
const out: Array<[string, string]> = [];
for (const row of rows) {
try {
out.push([row.local!, await opener.open(row.sealed) as string]);
} catch {
// As with a draft above.
}
}
await deleteSealedRows(rows);
return out;
}
export async function countSealed(): Promise<number> {
if (!(await vaultExists())) return 0;
return (await sealedRows()).length;
}
/** A wipe: sealed text can't be told apart by account, and a device told to remove it keeps none. */
export async function clearSealed(): Promise<void> {
if (!(await vaultExists())) return;
await withDb(async (db) => {
const tx = db.transaction(SEALED, 'readwrite');
tx.objectStore(SEALED).clear();
await done(tx);
});
return out;
}
+3 -5
View File
@@ -1,11 +1,9 @@
/**
* 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.
* lib/app-lock/controller.ts calls setAppLockCheck once, at the start of each page, with
* a function that answers from its own state. Main keeps its own answer for the tray, the
* jump list and toasts (the shell's app-lock-gate.ts).
*/
let check: (() => boolean) | null = null;
+2 -2
View File
@@ -821,8 +821,8 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' },
AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
AppLockRequired: { pendingConsumer: 'step 7, app lock' },
AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' },
AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' },
TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' },
CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' },
QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
+13 -9
View File
@@ -284,29 +284,33 @@ export interface DesktopPolicyBridge {
export type DesktopOsUnlockKind = 'hello' | 'touchid';
export type DesktopOsUnlockAnswer = { ok: true; key: Uint8Array } | { ok: false; reason: 'unavailable' | 'denied' | 'not-locked' | 'failed' };
/**
* 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.
* main, and `forget` asks natively without the proof. A same-origin plugin frame can reach
* this through `parent`, so that has to hold on a call alone. What it mustn't reach at all,
* `lock` and `osUnlock`, the page takes once with `claim` before any frame exists.
*/
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;
status: () => Promise<{ locked: boolean; enrolled: boolean; os: DesktopOsUnlockKind | null; osEnrolled: boolean; wrap?: boolean }>;
/** Once per document: the calls only the page itself may make. Null for whoever comes second. */
claim: () => { lock: () => void; osUnlock: () => Promise<DesktopOsUnlockAnswer> } | null;
/** Sets up the proof. False when one exists. */
enroll: (proof: Uint8Array) => Promise<boolean>;
/** Lifts the lock when the proof is the right one. */
prove: (proof: Uint8Array) => Promise<boolean>;
/** The lock goes off for good. */
disable: (proof: Uint8Array) => Promise<boolean>;
/** The PIN is lost, or everyone signed out. While locked, main asks the user natively. */
forget: () => Promise<boolean>;
/** The PIN is lost, or everyone signed out. With no proof, main asks the user natively. */
forget: (proof?: Uint8Array) => Promise<boolean>;
/** Through safeStorage, for this origin only. Null where the OS has no real key store. */
wrap: (data: Uint8Array) => Promise<Uint8Array | null>;
unwrap: (data: Uint8Array) => Promise<Uint8Array | null>;
/** Asks Windows Hello or Touch ID, then keeps the key in the OS's encrypted storage. */
osEnroll: (proof: Uint8Array, lockKey: Uint8Array) => Promise<boolean>;
osRemove: () => Promise<void>;
/** 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;
}
+83
View File
@@ -129,6 +129,89 @@ function expectUntouched(): void {
expect(wipeState.readPendingWipes()).toEqual({});
}
describe('with the app lock', () => {
const vaultDb = 'bulwark-app-lock';
async function vaultOf(): Promise<typeof import('@/lib/app-lock/vault')> {
return import('@/lib/app-lock/vault');
}
it('a wipe of one account leaves the others their way in', async () => {
const vault = await vaultOf();
(await vault.createVault('correct-pin')).fill(0);
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
respond = () => noWipe();
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
await wipe.wipeAccountFromDevice(BOB, 'admin');
expect(await databases()).toContain(vaultDb);
expect(await vault.readVault()).not.toBeNull();
});
it('what was sealed goes with it: a device told to remove an account keeps none of its text', async () => {
const vault = await vaultOf();
(await vault.createVault('correct-pin')).fill(0);
await vault.sealDraft({ subject: 'Text of a wiped account' });
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
respond = () => noWipe();
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
await wipe.wipeAccountFromDevice(BOB, 'admin');
expect(await vault.countSealed()).toBe(0);
});
it('with no account left, the vault and the lock go too', async () => {
const vault = await vaultOf();
(await vault.createVault('correct-pin')).fill(0);
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
useAccountStore.setState({ accounts: [entry(ALICE, 0)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
respond = () => noWipe();
await keys.putDeviceRecord({ account: ALICE, id: ROW, secret: SECRET, createdAt: 1 });
await wipe.wipeAccountFromDevice(ALICE, 'admin');
await vi.waitFor(async () => expect(await databases()).not.toContain(vaultDb));
expect(localStorage.getItem('bulwark-app-lock')).toBeNull();
});
it('also when the last account isn\'t the active one, as at a lock screen where nothing has started', async () => {
const vault = await vaultOf();
(await vault.createVault('correct-pin')).fill(0);
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
wipe.setWipeReloadForTests(() => {});
useAccountStore.setState({ accounts: [entry(BOB, 1)], activeAccountId: null, defaultAccountId: null } as never);
useAuthStore.setState({ activeAccountId: null });
respond = () => noWipe();
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
await wipe.wipeAccountFromDevice(BOB, 'admin');
wipe.setWipeReloadForTests(null);
await vi.waitFor(async () => expect(await databases()).not.toContain(vaultDb));
expect(localStorage.getItem('bulwark-app-lock')).toBeNull();
});
it('a wipe of the whole device takes them as well', async () => {
const vault = await vaultOf();
(await vault.createVault('correct-pin')).fill(0);
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
respond = () => noWipe();
await wipe.wipeWholeDevice('user');
await vi.waitFor(async () => expect(await databases()).not.toContain(vaultDb));
expect(localStorage.getItem('bulwark-app-lock')).toBeNull();
});
it('a device at the lock screen hears a wipe: the check-in runs with no key and no session', async () => {
const vault = await vaultOf();
let reloads = 0;
wipe.setWipeReloadForTests(() => { reloads += 1; });
(await vault.createVault('correct-pin')).fill(0);
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)], activeAccountId: ALICE, defaultAccountId: ALICE } as never);
await keys.putDeviceRecord({ account: BOB, id: BOB_ROW, secret: SECRET, createdAt: 1 });
respond = (url, init) => (url.includes('/api/devices') && JSON.parse(String(init?.body ?? '{}')).id === BOB_ROW ? wipeNow() : noWipe());
await wipe.checkInDue(true);
await vi.waitFor(async () => expect(await keys.getDeviceRecord(BOB)).toBeNull());
expect(await vault.readVault(), 'Alice still has to get in').not.toBeNull();
await vi.waitFor(() => expect(reloads, 'nothing started for Alice stays in memory').toBe(1));
wipe.setWipeReloadForTests(null);
});
});
describe('wipe on the next connect', () => {
beforeEach(() => {
respond = (url) => (url.endsWith('/api/devices/wiped') ? json(200, { ok: true }) : wipeNow());
+38
View File
@@ -47,6 +47,9 @@ import { SW_SCOPE } from '@/lib/app-update';
import { devicesForAccount, devicesForNoOne } from '@/lib/device-policy';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out';
import { isAppLockedNow } from '@/lib/app-lock/state';
import { clearSealed, VAULT_DB } from '@/lib/app-lock/vault';
import { clearRescuedDrafts } from '@/lib/popout/rescued-drafts';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
@@ -110,6 +113,8 @@ const APP_DATABASES = new Set([
'bulwark-company-calendars',
'bulwark-mail-import',
'bulwark-offline',
// The other accounts' way in: it goes when no account is left, see `forgetLockIfNobodyLeft`.
VAULT_DB,
]);
/** Deleted by name too, for browsers without `indexedDB.databases()`. */
const KNOWN_PLUGIN_DATABASES = ['smime-plugin-store', 'bulwark-openpgp', 'smime-capability-probe', 'openpgp-capability-probe'];
@@ -261,6 +266,12 @@ async function endWebPushIfNobodyLeft(): Promise<void> {
await subscription?.unsubscribe();
}
/** With nobody left after this there's nothing to lock for, and a next person shouldn't meet it. */
async function forgetLockIfNobodyStays(leaving: string | null): Promise<void> {
if (leaving !== null && useAccountStore.getState().accounts.some((entry) => entry.id !== leaving)) return;
await forgetAppLockAtSignOut();
}
/** What's the account's own: a deletion after a refused login goes no further than this. */
async function scrubAccount(account: string): Promise<void> {
await runWipeCleaners(account);
@@ -284,6 +295,7 @@ async function scrubShared(): Promise<void> {
}
clearHandoffs();
clearRescuedDrafts();
await clearSealed().catch(() => {});
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
useNotificationCenterStore.getState().clear();
await closeShownNotifications().catch(() => {});
@@ -409,12 +421,29 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc
await putRowsBack(records, account);
await flushConfirmations(deleted ? account : undefined);
const wasLocked = isAppLockedNow();
if (!ownOnly) {
await forgetLockIfNobodyStays(account).catch(() => {});
await signOutAccount(account);
await endWebPushIfNobodyLeft().catch(() => {});
}
if (deleted) clearWipePending(account);
tellOtherWindows(account, 'end');
// Heard at the lock screen: signing out may have started the next account here, and with
// nobody left a new page is what takes the lock screen away.
if (wasLocked && !ownOnly) reloadPage();
}
let reloadSeam: (() => void) | null = null;
function reloadPage(): void {
if (reloadSeam) reloadSeam();
else window.location.reload();
}
/** Test helper: jsdom can't reload. */
export function setWipeReloadForTests(run: (() => void) | null): void {
reloadSeam = run;
}
/** Everything on this device, for everyone. The app's device preferences stay: they hold no mail. */
@@ -455,6 +484,7 @@ async function eraseDevice(reason: WipeReason): Promise<void> {
for (const account of Object.keys(readRefusedLogins())) dropRefusedLogin(account);
await flushConfirmations(deleted ? WHOLE_DEVICE : undefined);
signalSignedOut();
await forgetLockIfNobodyStays(null).catch(() => {});
await useAuthStore.getState().logoutAll({ endProviderSession: false });
await endWebPushIfNobodyLeft().catch(() => {});
if (deleted) clearWipePending(WHOLE_DEVICE);
@@ -467,6 +497,14 @@ function queueDrop(row: Pick<DeviceRecord, 'id' | 'secret'>): void {
queueWipeConfirmation({ id: row.id, secret: row.secret, reason: 'user', unsent: 0, drop: true });
}
/**
* A forgotten PIN is about to delete the key database with the rows in it: the instance hears
* at the next start that they're over. No network here, and that's what the order there needs.
*/
export async function forgetDeviceRows(): Promise<void> {
for (const row of await listDeviceRecords().catch(() => [] as DeviceRecord[])) queueDrop(row);
}
async function dropRows(scope: string): Promise<void> {
const records = await listDeviceRecords().catch(() => [] as DeviceRecord[]);
const rows = scope === 'all' ? records : records.filter((record) => record.account === scope);
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { goInApp, onPageNavigation, resetPageNavigation } from '../page-navigation';
describe('moving the tab', () => {
afterEach(() => {
resetPageNavigation();
vi.unstubAllGlobals();
});
it('goes through the router when there is one: a full load would ask for the app lock PIN', () => {
const assign = vi.fn();
vi.stubGlobal('location', { ...window.location, assign });
const pushed: string[] = [];
onPageNavigation((href) => pushed.push(href));
goInApp('/calendar', '/en/calendar');
expect(pushed).toEqual(['/calendar']);
expect(assign).not.toHaveBeenCalled();
});
it('loads the page only where nothing has a router', () => {
const assign = vi.fn();
vi.stubGlobal('location', { ...window.location, assign });
goInApp('/calendar', '/en/calendar');
expect(assign).toHaveBeenCalledWith('/en/calendar');
});
});
+2 -1
View File
@@ -3,6 +3,7 @@
// to press Send; drafts.create saves a draft to the Drafts folder without
// opening anything. Neither sends mail.
import { goInApp } from './page-navigation';
import type { ComposeInput, DraftInput, InstalledPlugin } from '../plugin-types';
import type { ParsedMailto } from '../protocol-handlers/mailto';
import { requestInternalCompose } from '../protocol-handlers/mailto';
@@ -96,7 +97,7 @@ export function doComposeOpen(plugin: InstalledPlugin, input: unknown): 'opened'
// incoming mailto: request when it mounts, and go there.
savePendingMailto(draft);
notifyPendingMailto();
window.location.assign(mailPageHref());
goInApp('/', mailPageHref());
return 'navigating';
}
+2 -1
View File
@@ -13,6 +13,7 @@
import type { Email } from '../jmap/types';
import type { InstalledPlugin, SearchResultEmail } from '../plugin-types';
import { appPath, buildMailPath } from '../deep-links';
import { goInApp } from './page-navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useProTabStore } from '@/stores/pro-tab-store';
import { readId, readObject, withinRate } from './host-api-input';
@@ -140,7 +141,7 @@ export async function requestOpenEmail(plugin: InstalledPlugin, input: unknown):
if (!withinRate('open-email', plugin.id, LINK_LOADS_PER_MINUTE)) {
throw new Error(`${method}: at most ${LINK_LOADS_PER_MINUTE} times a minute from outside the mail page`);
}
window.location.assign(appPath(messagePath(email)));
goInApp(messagePath(email), appPath(messagePath(email)));
}
/** Test helper. */
+10
View File
@@ -54,6 +54,16 @@ export function navigateInApp(href: string): boolean {
return true;
}
/**
* Moves the tab through the router, which keeps the page, and with it the app lock's key.
* A full load makes the user type the PIN again.
*/
export function goInApp(routerHref: string, fullHref: string): void {
if (navigateInApp(routerHref)) return;
// full-load: no router in this window, as in a pop-out or before the app has mounted.
window.location.assign(fullHref);
}
/** Test helper. */
export function resetPageNavigation(): void {
handler = null;
+3 -1
View File
@@ -64,6 +64,8 @@ export interface SignOutCleanupOptions {
* when an account that was dropped that way still has data here.
*/
keepLocalStore?: boolean;
/** A forgotten PIN goes through this with the lock still up: it lifts the lock itself, last. */
keepAppLock?: boolean;
}
/** Run `cleanup` when everyone signs out in this tab. */
@@ -145,7 +147,7 @@ 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();
if (!options.keepLocalStore && !options.keepAppLock) void forgetAppLockAtSignOut();
// Files and links on their way into a draft or an import.
clearHandoffs();
for (const cleanup of signedOutCleanups) {
+15 -7
View File
@@ -104,7 +104,8 @@ export async function getAccountRecord(account: string): Promise<AccountRecord |
});
}
export async function listAccountRecords(): Promise<AccountRecord[]> {
export async function listAccountRecords(onlyIfThere = false): Promise<AccountRecord[]> {
if (onlyIfThere && !(await keyDatabaseExists())) return [];
return withDb((db) => promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as Promise<AccountRecord[]>);
}
@@ -227,10 +228,17 @@ export class StoreKeyLayerMissingError extends Error {
}
/** Deletes every key at sign-out. Works with no worker running, on any page. */
export function deleteKeyDatabase(): void {
try {
indexedDB.deleteDatabase(DB_NAME);
} catch {
// Private mode or similar: nothing was stored either.
}
export function deleteKeyDatabase(): Promise<void> {
return new Promise((resolve) => {
try {
const request = indexedDB.deleteDatabase(DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
// A connection still open goes on its own: withDb closes after each use.
request.onblocked = () => resolve();
} catch {
// Private mode or similar: nothing was stored either.
resolve();
}
});
}
+3 -2
View File
@@ -50,7 +50,7 @@ export async function deleteStorePool(): Promise<void> {
* Started synchronously: the sign-out may navigate away right after.
*/
export function purgeStoreFiles(): Promise<void> {
deleteKeyDatabase();
const keysGone = deleteKeyDatabase();
// Deleted on purpose: no account lost anything. The next open is a first one.
try {
localStorage.removeItem(STORE_OPENED_KEY);
@@ -64,7 +64,8 @@ export function purgeStoreFiles(): Promise<void> {
} catch {
// No BroadcastChannel: no store either.
}
return deleteStorePool().catch(() => {
const poolGone = deleteStorePool().catch(() => {
// The key is gone, so what's left can't be read.
});
return Promise.all([keysGone, poolGone]).then(() => undefined);
}
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.",
"enable_label": "القفل برمز PIN",
"enable_desc": "متوقف افتراضيًا. يبقى رمز PIN على هذا الجهاز.",
"protects_desc": "ما الذي يحميه هذا: في المتصفح، يمنع رمز PIN أي شخص يستخدم هذا الجهاز من دخول التطبيق ويبقي البريد المخزَّن هنا مشفّرًا. لا يمكنه إيقاف برنامج يعمل على هذا الحاسوب، أو إضافة متصفح ثبّتّها، أو شخص ينسخ ملفات المتصفح ولديه وقت لتجربة رموز كثيرة، لذا يستحق الرمز الأطول العناء. في تطبيق سطح المكتب يرتبط رمز PIN أيضًا بتسجيل دخولك على هذا الحاسوب. لا يتأثر حسابك على الخادم.",
"enable_body": "اختر رمز PIN. ستحتاج إليه كلما قُفل التطبيق، وبعد إعادة التحميل أو بدء التشغيل من جديد.",
"set_pin_title": "اختر رمز PIN",
"change_pin": "تغيير رمز PIN…",
@@ -6657,6 +6658,7 @@
"pin_new": "رمز PIN الجديد",
"pin_repeat": "أعد إدخال رمز PIN",
"pin_hint": "6 أحرف على الأقل. يمكن استخدام الحروف أيضًا، والرمز الأطول أصعب في التخمين.",
"pin_obvious": "من السهل تخمين رمز PIN هذا. تجنّب التكرار والتسلسلات والاختيارات الشائعة.",
"pin_mismatch": "الرمزان غير متطابقين.",
"pin_recovery": "إذا نسيته، فالحل الوحيد هو إزالة البيانات من هذا الجهاز وتسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.",
"save": "حفظ",
@@ -6696,6 +6698,7 @@
"forgot": "هل نسيت رمز PIN؟",
"forgot_title": "إزالة البيانات المحلية وتسجيل الخروج؟",
"forgot_body": "لا يمكن استعادة رمز PIN المنسي. يزيل هذا البريد المحفوظ على هذا الجهاز ويسجل خروج كل الحسابات لتتمكن من تسجيل الدخول من جديد. يبقى بريدك على الخادم. تُفقد الرسائل التي لا تزال تنتظر الإرسال على هذا الجهاز.",
"forgot_provider": "إذا سجّلت الدخول عبر صفحة تسجيل الدخول الخاصة بمؤسستك، فسيبقى ذلك الدخول مفتوحًا هناك. سيطلب تسجيل الدخول التالي هنا من تلك الصفحة دخولًا جديدًا حيثما تسمح بذلك.",
"forgot_confirm": "إزالة وتسجيل الخروج",
"unlock_in_main": "افتح القفل في النافذة الرئيسية.",
"open_main": "الانتقال إلى النافذة الرئيسية",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "De què protegeix: en un navegador, el PIN impedeix que algú davant d'aquest dispositiu entri a l'aplicació i manté xifrat el correu desat aquí. No pot aturar un programa que s'executi en aquest ordinador, una extensió del navegador que hàgiu instal·lat o algú que copiï els fitxers del navegador i tingui temps de provar molts PIN, així que val la pena un PIN més llarg. A l'aplicació d'escriptori, el PIN també queda lligat al vostre inici de sessió en aquest ordinador. El vostre compte al servidor no es veu afectat.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Aquest PIN és massa fàcil d'endevinar. Eviteu repeticions, seqüències i opcions habituals.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Si heu iniciat la sessió a través de la pàgina d'inici de sessió de la vostra organització, aquesta sessió hi continua oberta. El proper inici de sessió aquí demanarà a aquella pàgina una sessió nova, si ho permet.",
"forgot_confirm": "Elimina i tanca la sessió",
"unlock_in_main": "Desbloquegeu a la finestra principal.",
"open_main": "Vés a la finestra principal",
+3
View File
@@ -6648,6 +6648,7 @@
"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í.",
"protects_desc": "Před čím to chrání: v prohlížeči PIN nepustí do aplikace nikoho u tohoto zařízení a pošta uložená zde zůstává šifrovaná. Nezastaví program běžící na tomto počítači, rozšíření prohlížeče, které jste nainstalovali, ani někoho, kdo zkopíruje soubory prohlížeče a má čas zkoušet mnoho PINů, proto se delší PIN vyplatí. V desktopové aplikaci je PIN navíc svázán s vaším přihlášením na tomto počítači. Váš účet na serveru to neovlivní.",
"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…",
@@ -6657,6 +6658,7 @@
"pin_new": "Nový PIN",
"pin_repeat": "Zopakujte PIN",
"pin_hint": "Alespoň 6 znaků. Fungují i písmena a delší PIN se hůř uhodne.",
"pin_obvious": "Tento PIN se dá snadno uhodnout. Vyhněte se opakování, řadám a běžným volbám.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Pokud jste se přihlásili přes přihlašovací stránku své organizace, zůstane tam přihlášení otevřené. Další přihlášení zde tuto stránku požádá o nové přihlášení, pokud to umožňuje.",
"forgot_confirm": "Odebrat a odhlásit",
"unlock_in_main": "Odemkněte v hlavním okně.",
"open_main": "Přejít do hlavního okna",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Hvad det beskytter mod: I en browser holder pinkoden andre ved denne enhed ude af appen og holder den mail, der er gemt her, krypteret. Den kan ikke stoppe et program på denne computer, en browserudvidelse, du har installeret, eller nogen, der kopierer browserens filer og har tid til at prøve mange pinkoder, så en længere pinkode er umagen værd. I desktop-appen er pinkoden også knyttet til dit login på denne computer. Din konto på serveren påvirkes ikke.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Den pinkode er for nem at gætte. Undgå gentagelser, rækkefølger og almindelige valg.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Hvis du loggede ind via din organisations loginside, forbliver det login åbent dér. Næste login her beder den side om et nyt login, hvor det er muligt.",
"forgot_confirm": "Fjern og log ud",
"unlock_in_main": "Lås op i hovedvinduet.",
"open_main": "Gå til hovedvinduet",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Wovor das schützt: Im Browser hält die PIN andere an diesem Gerät aus der App heraus, und die hier gespeicherten E-Mails bleiben verschlüsselt. Sie kann kein Programm auf diesem Computer aufhalten, keine von Ihnen installierte Browser-Erweiterung und niemanden, der die Dateien des Browsers kopiert und Zeit hat, viele PINs auszuprobieren. Eine längere PIN lohnt sich also. In der Desktop-App ist die PIN zusätzlich an Ihre Anmeldung an diesem Computer gebunden. Ihr Konto auf dem Server ist davon nicht betroffen.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Diese PIN ist zu leicht zu erraten. Vermeiden Sie Wiederholungen, Zahlenfolgen und gängige Kombinationen.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Wenn Sie sich über die Anmeldeseite Ihrer Organisation angemeldet haben, bleibt diese Anmeldung dort offen. Die nächste Anmeldung hier fordert auf dieser Seite eine neue Anmeldung an, sofern sie das zulässt.",
"forgot_confirm": "Entfernen und abmelden",
"unlock_in_main": "Entsperren Sie im Hauptfenster.",
"open_main": "Zum Hauptfenster",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "What this protects against: in a browser, the PIN keeps someone at this device out of the app and keeps the mail stored here encrypted. It can't stop a program running on this computer, a browser extension you installed, or someone who copies the browser's files and has time to try many PINs, so a longer PIN is worth it. In the desktop app the PIN is also tied to your login on this computer. Your account on the server isn't affected.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "That PIN is too easy to guess. Avoid repeats, runs and common choices.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "If you signed in through your organisation's login page, that login stays open there. The next sign-in here asks that page for a new login, where it allows that.",
"forgot_confirm": "Remove and sign out",
"unlock_in_main": "Unlock in the main window.",
"open_main": "Go to the main window",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "De qué protege: en un navegador, el PIN impide que alguien frente a este dispositivo entre en la aplicación y mantiene cifrado el correo guardado aquí. No puede detener un programa que se ejecute en este ordenador, una extensión del navegador que hayas instalado ni a alguien que copie los archivos del navegador y tenga tiempo de probar muchos PIN, así que merece la pena un PIN más largo. En la aplicación de escritorio, el PIN también queda ligado a tu inicio de sesión en este ordenador. Tu cuenta en el servidor no se ve afectada.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Ese PIN es demasiado fácil de adivinar. Evita repeticiones, secuencias y opciones habituales.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Si iniciaste sesión a través de la página de tu organización, esa sesión sigue abierta allí. El próximo inicio de sesión aquí pedirá a esa página uno nuevo, si lo permite.",
"forgot_confirm": "Eliminar y cerrar sesión",
"unlock_in_main": "Desbloquee en la ventana principal.",
"open_main": "Ir a la ventana principal",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Mille eest see kaitseb: brauseris hoiab PIN-kood selle seadme taga olija rakendusest eemal ja siia salvestatud kirjad krüpteerituna. See ei peata selles arvutis töötavat programmi, sinu paigaldatud brauserilaiendit ega kedagi, kes kopeerib brauseri failid ja saab rahulikult palju PIN-koode proovida, seega tasub valida pikem kood. Töölauarakenduses on PIN-kood seotud ka sinu sisselogimisega selles arvutis. Sinu kontot serveris see ei mõjuta.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Seda PIN-koodi on liiga lihtne ära arvata. Väldi kordusi, järjestusi ja tavalisi valikuid.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Kui logisid sisse oma organisatsiooni sisselogimislehe kaudu, jääb see sisselogimine seal avatuks. Järgmine sisselogimine siin küsib sellelt lehelt uut sisselogimist, kui see on lubatud.",
"forgot_confirm": "Eemalda ja logi välja",
"unlock_in_main": "Avage põhiaknas.",
"open_main": "Mine põhiaknasse",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "پس از مدتی بی‌استفاده، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، ایمیل‌های این دستگاه رمزگذاری‌شده می‌مانند و برنامه کلید آن‌ها را ندارد.",
"enable_label": "قفل با پین",
"enable_desc": "به‌طور پیش‌فرض خاموش است. پین روی همین دستگاه می‌ماند.",
"protects_desc": "در برابر چه چیزی محافظت می‌کند: در مرورگر، پین نمی‌گذارد کسی که پای این دستگاه است وارد برنامه شود و ایمیل‌های ذخیره‌شده در اینجا را رمزگذاری‌شده نگه می‌دارد. جلوی برنامه‌ای که روی این رایانه اجرا می‌شود، افزونه مرورگری که نصب کرده‌اید، یا کسی که فایل‌های مرورگر را کپی می‌کند و وقت امتحان کردن پین‌های زیادی دارد را نمی‌گیرد؛ پس پین طولانی‌تر ارزشش را دارد. در برنامه دسکتاپ، پین به ورود شما به این رایانه هم وابسته است. حساب شما روی سرور تغییری نمی‌کند.",
"enable_body": "یک پین انتخاب کنید. هر بار که برنامه قفل شود و پس از بارگذاری دوباره یا شروع تازه به آن نیاز دارید.",
"set_pin_title": "انتخاب پین",
"change_pin": "تغییر پین…",
@@ -6657,6 +6658,7 @@
"pin_new": "پین جدید",
"pin_repeat": "تکرار پین",
"pin_hint": "دست‌کم ۶ نویسه. حروف هم پذیرفته می‌شوند و حدس زدن پین بلندتر سخت‌تر است.",
"pin_obvious": "حدس زدن این پین خیلی آسان است. از تکرار، توالی و انتخاب‌های رایج پرهیز کنید.",
"pin_mismatch": "این دو پین یکسان نیستند.",
"pin_recovery": "اگر آن را فراموش کنید، تنها راه حذف داده‌های این دستگاه و ورود دوباره است. ایمیل‌های شما روی سرور می‌مانند. پیام‌هایی که هنوز در این دستگاه منتظر ارسال‌اند از بین می‌روند.",
"save": "ذخیره",
@@ -6696,6 +6698,7 @@
"forgot": "پین خود را فراموش کرده‌اید؟",
"forgot_title": "داده‌های محلی حذف و از حساب خارج شوید؟",
"forgot_body": "پین فراموش‌شده بازیابی نمی‌شود. این کار ایمیل‌های نگه‌داشته‌شده در این دستگاه را حذف می‌کند و از همه حساب‌ها خارج می‌شود تا بتوانید دوباره وارد شوید. ایمیل‌های شما روی سرور می‌مانند. پیام‌هایی که هنوز در این دستگاه منتظر ارسال‌اند از بین می‌روند.",
"forgot_provider": "اگر از طریق صفحه ورود سازمان خود وارد شده‌اید، آن ورود همان‌جا باز می‌ماند. ورود بعدی در اینجا، در صورت امکان، از آن صفحه ورود تازه‌ای می‌خواهد.",
"forgot_confirm": "حذف و خروج",
"unlock_in_main": "در پنجره اصلی قفل را باز کنید.",
"open_main": "رفتن به پنجره اصلی",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Contre quoi cela protège : dans un navigateur, le code PIN empêche une personne devant cet appareil d'entrer dans l'application et garde chiffrés les messages stockés ici. Il ne peut pas arrêter un programme exécuté sur cet ordinateur, une extension de navigateur que vous avez installée, ni quelqu'un qui copie les fichiers du navigateur et a le temps d'essayer de nombreux codes : un code plus long en vaut donc la peine. Dans l'application de bureau, le code PIN est aussi lié à votre session sur cet ordinateur. Votre compte sur le serveur n'est pas concerné.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Ce code PIN est trop facile à deviner. Évitez les répétitions, les suites et les choix courants.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Si vous vous êtes connecté via la page de connexion de votre organisation, cette connexion y reste ouverte. La prochaine connexion ici demandera à cette page une nouvelle connexion, si elle le permet.",
"forgot_confirm": "Supprimer et se déconnecter",
"unlock_in_main": "Déverrouillez dans la fenêtre principale.",
"open_main": "Aller à la fenêtre principale",
+3
View File
@@ -6649,6 +6649,7 @@
"section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.",
"enable_label": "נעילה עם קוד PIN",
"enable_desc": "כבוי כברירת מחדל. קוד ה-PIN נשאר במכשיר הזה.",
"protects_desc": "ממה זה מגן: בדפדפן, קוד ה-PIN מונע ממי שנמצא ליד המכשיר להיכנס לאפליקציה ושומר על הדואר המאוחסן כאן מוצפן. הוא לא יכול לעצור תוכנה שרצה במחשב הזה, תוסף דפדפן שהתקנתם, או מי שמעתיק את קובצי הדפדפן ויש לו זמן לנסות קודים רבים, ולכן כדאי קוד ארוך יותר. באפליקציית שולחן העבודה הקוד קשור גם להתחברות שלכם למחשב הזה. החשבון שלכם בשרת לא מושפע.",
"enable_body": "בחרו קוד PIN. תצטרכו אותו בכל פעם שהאפליקציה ננעלת, ואחרי טעינה מחדש או הפעלה חדשה.",
"set_pin_title": "בחירת קוד PIN",
"change_pin": "שינוי קוד PIN…",
@@ -6658,6 +6659,7 @@
"pin_new": "קוד PIN חדש",
"pin_repeat": "חזרה על קוד PIN",
"pin_hint": "לפחות 6 תווים. אפשר גם אותיות, וקוד ארוך יותר קשה יותר לניחוש.",
"pin_obvious": "קל מדי לנחש את קוד ה-PIN הזה. הימנעו מחזרות, רצפים ובחירות נפוצות.",
"pin_mismatch": "שני הקודים אינם זהים.",
"pin_recovery": "אם תשכחו אותו, הדרך היחידה היא להסיר את הנתונים מהמכשיר הזה ולהיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.",
"save": "שמירה",
@@ -6697,6 +6699,7 @@
"forgot": "שכחתם את קוד ה-PIN?",
"forgot_title": "להסיר נתונים מקומיים ולהתנתק?",
"forgot_body": "אי אפשר לשחזר קוד PIN שנשכח. פעולה זו מסירה את הדואר שנשמר במכשיר הזה ומנתקת את כל החשבונות, כדי שתוכלו להיכנס שוב. הדואר שלכם בשרת נשאר. הודעות שעדיין ממתינות לשליחה במכשיר הזה יאבדו.",
"forgot_provider": "אם התחברתם דרך דף ההתחברות של הארגון, ההתחברות נשארת פתוחה שם. ההתחברות הבאה כאן תבקש מהדף הזה התחברות חדשה, אם הוא מאפשר זאת.",
"forgot_confirm": "הסרה והתנתקות",
"unlock_in_main": "פתחו את הנעילה בחלון הראשי.",
"open_main": "מעבר לחלון הראשי",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Mi ellen véd: böngészőben a PIN-kód távol tartja az alkalmazástól az eszköznél lévőket, az itt tárolt levelek pedig titkosítva maradnak. Nem állít meg egy ezen a számítógépen futó programot, egy ön által telepített böngészőbővítményt, vagy valakit, aki lemásolja a böngésző fájljait, és van ideje sok kódot kipróbálni, ezért megéri a hosszabb PIN-kód. Az asztali alkalmazásban a PIN-kód ehhez a számítógépes bejelentkezéshez is kötődik. A szerveren lévő fiókját ez nem érinti.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Ezt a PIN-kódot túl könnyű kitalálni. Kerülje az ismétlést, a sorozatokat és a gyakori választásokat.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Ha a szervezete bejelentkezési oldalán keresztül lépett be, az a bejelentkezés ott nyitva marad. A következő bejelentkezés itt új bejelentkezést kér attól az oldaltól, ha az lehetővé teszi.",
"forgot_confirm": "Eltávolítás és kijelentkezés",
"unlock_in_main": "Oldja fel a főablakban.",
"open_main": "Ugrás a főablakra",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Da cosa protegge: in un browser, il PIN tiene fuori dall'app chi si trova davanti a questo dispositivo e mantiene cifrata la posta salvata qui. Non può fermare un programma in esecuzione su questo computer, un'estensione del browser che hai installato o chi copia i file del browser e ha il tempo di provare molti PIN, quindi conviene un PIN più lungo. Nell'app desktop, il PIN è legato anche al tuo accesso a questo computer. Il tuo account sul server non cambia.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Questo PIN è troppo facile da indovinare. Evita ripetizioni, sequenze e scelte comuni.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Se hai effettuato l'accesso dalla pagina della tua organizzazione, quell'accesso resta aperto lì. Il prossimo accesso qui chiederà a quella pagina un nuovo accesso, se lo consente.",
"forgot_confirm": "Rimuovi ed esci",
"unlock_in_main": "Sblocca nella finestra principale.",
"open_main": "Vai alla finestra principale",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。",
"enable_label": "PIN でロック",
"enable_desc": "既定ではオフです。PIN はこのデバイスにだけ保存されます。",
"protects_desc": "保護できる範囲: ブラウザーでは、PIN によりこのデバイスの前にいる人をアプリから締め出し、ここに保存されたメールを暗号化したままにします。このコンピューターで動くプログラム、インストールしたブラウザー拡張機能、ブラウザーのファイルをコピーして多くの PIN を試す時間がある人は止められないため、長い PIN をおすすめします。デスクトップアプリでは、PIN はこのコンピューターへのログインにも結び付きます。サーバー上のアカウントには影響しません。",
"enable_body": "PIN を選んでください。アプリがロックされるたび、また再読み込みや再起動の後に必要です。",
"set_pin_title": "PIN を選択",
"change_pin": "PIN を変更…",
@@ -6657,6 +6658,7 @@
"pin_new": "新しい PIN",
"pin_repeat": "PIN をもう一度",
"pin_hint": "6 文字以上。文字も使えます。長い PIN ほど推測されにくくなります。",
"pin_obvious": "この PIN は推測されやすすぎます。繰り返し、連番、よくある組み合わせは避けてください。",
"pin_mismatch": "2 つの PIN が一致しません。",
"pin_recovery": "忘れた場合は、このデバイスのデータを削除してサインインし直すしかありません。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。",
"save": "保存",
@@ -6696,6 +6698,7 @@
"forgot": "PIN を忘れましたか?",
"forgot_title": "ローカルデータを削除してサインアウトしますか?",
"forgot_body": "忘れた PIN は復元できません。このデバイスに保存されたメールを削除し、すべてのアカウントをサインアウトして、サインインし直せるようにします。サーバー上のメールは残ります。このデバイスで送信待ちのメッセージは失われます。",
"forgot_provider": "組織のログインページからサインインした場合、そのログインはそちらで開いたままです。次回ここでサインインするときは、可能であればそのページに新しいログインを求めます。",
"forgot_confirm": "削除してサインアウト",
"unlock_in_main": "メインウィンドウでロックを解除してください。",
"open_main": "メインウィンドウへ",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.",
"enable_label": "PIN으로 잠금",
"enable_desc": "기본적으로 꺼져 있습니다. PIN은 이 기기에만 남습니다.",
"protects_desc": "보호 범위: 브라우저에서 PIN은 이 기기 앞에 있는 사람이 앱에 들어오지 못하게 하고 여기에 저장된 메일을 암호화된 상태로 유지합니다. 이 컴퓨터에서 실행되는 프로그램, 직접 설치한 브라우저 확장 프로그램, 브라우저 파일을 복사해 많은 PIN을 시도할 시간이 있는 사람은 막을 수 없으므로 더 긴 PIN을 사용하는 것이 좋습니다. 데스크톱 앱에서는 PIN이 이 컴퓨터의 로그인에도 연결됩니다. 서버의 계정에는 영향이 없습니다.",
"enable_body": "PIN을 선택하세요. 앱이 잠길 때마다, 그리고 다시 로드하거나 새로 시작한 후에 필요합니다.",
"set_pin_title": "PIN 선택",
"change_pin": "PIN 변경…",
@@ -6657,6 +6658,7 @@
"pin_new": "새 PIN",
"pin_repeat": "PIN 다시 입력",
"pin_hint": "6자 이상. 문자도 사용할 수 있으며 PIN이 길수록 추측하기 어렵습니다.",
"pin_obvious": "이 PIN은 너무 쉽게 추측할 수 있습니다. 반복, 연속된 숫자, 흔한 조합은 피하세요.",
"pin_mismatch": "두 PIN이 같지 않습니다.",
"pin_recovery": "잊어버리면 이 기기의 데이터를 제거하고 다시 로그인하는 방법밖에 없습니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.",
"save": "저장",
@@ -6696,6 +6698,7 @@
"forgot": "PIN을 잊으셨나요?",
"forgot_title": "로컬 데이터를 제거하고 로그아웃하시겠습니까?",
"forgot_body": "잊어버린 PIN은 복구할 수 없습니다. 이 기기에 보관된 메일을 제거하고 모든 계정을 로그아웃하여 다시 로그인할 수 있게 합니다. 서버의 메일은 그대로 남습니다. 이 기기에서 아직 전송을 기다리는 메시지는 사라집니다.",
"forgot_provider": "조직의 로그인 페이지로 로그인했다면 그 로그인은 그곳에 열린 채로 남습니다. 다음에 여기서 로그인할 때 가능하면 그 페이지에 새 로그인을 요청합니다.",
"forgot_confirm": "제거 후 로그아웃",
"unlock_in_main": "기본 창에서 잠금을 해제하세요.",
"open_main": "기본 창으로 이동",
+3
View File
@@ -6648,6 +6648,7 @@
"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ē.",
"protects_desc": "No kā tas aizsargā: pārlūkā PIN neļauj kādam pie šīs ierīces iekļūt lietotnē, un šeit saglabātais pasts paliek šifrēts. Tas nevar apturēt programmu šajā datorā, jūsu instalētu pārlūka paplašinājumu vai kādu, kas nokopē pārlūka failus un var izmēģināt daudz PIN, tāpēc garāks PIN ir tā vērts. Darbvirsmas lietotnē PIN ir saistīts arī ar jūsu pieteikšanos šajā datorā. Jūsu kontu serverī tas neietekmē.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Šo PIN ir pārāk viegli uzminēt. Izvairieties no atkārtojumiem, secībām un biežām izvēlēm.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Ja pieteicāties, izmantojot savas organizācijas pieteikšanās lapu, šī pieteikšanās tur paliek atvērta. Nākamā pieteikšanās šeit prasīs šai lapai jaunu pieteikšanos, ja tas ir atļauts.",
"forgot_confirm": "Noņemt un izrakstīties",
"unlock_in_main": "Atbloķējiet galvenajā logā.",
"open_main": "Uz galveno logu",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.",
"enable_label": "ПИН кодоор түгжих",
"enable_desc": "Анхдагчаар унтраалттай. ПИН код энэ төхөөрөмж дээр үлдэнэ.",
"protects_desc": "Юунаас хамгаалдаг вэ: хөтөч дээр ПИН нь энэ төхөөрөмжийн дэргэд байгаа хүнийг аппликейшнд оруулахгүй бөгөөд энд хадгалсан захидлыг шифрлэгдсэн байдлаар хадгалдаг. Энэ компьютер дээр ажиллаж буй программ, таны суулгасан хөтчийн өргөтгөл, хөтчийн файлуудыг хуулж олон ПИН турших цагтай хүнийг зогсоож чадахгүй тул урт ПИН хэрэглэх нь зүйтэй. Десктоп аппликейшнд ПИН энэ компьютерийн нэвтрэлттэй мөн холбогдоно. Сервер дээрх таны бүртгэлд нөлөөлөхгүй.",
"enable_body": "ПИН код сонгоно уу. Апп түгжигдэх бүрт, мөн дахин ачаалсан эсвэл шинээр эхлүүлсний дараа хэрэгтэй.",
"set_pin_title": "ПИН код сонгох",
"change_pin": "ПИН код солих…",
@@ -6657,6 +6658,7 @@
"pin_new": "Шинэ ПИН код",
"pin_repeat": "ПИН кодоо давтах",
"pin_hint": "Дор хаяж 6 тэмдэгт. Үсэг ч болно, урт ПИН кодыг таахад хэцүү.",
"pin_obvious": "Энэ ПИН-ийг таахад хэт амархан. Давталт, дараалал, түгээмэл сонголтоос зайлсхий.",
"pin_mismatch": "Хоёр ПИН код ижил биш байна.",
"pin_recovery": "Мартвал энэ төхөөрөмжийн өгөгдлийг устгаад дахин нэвтрэхээс өөр арга байхгүй. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.",
"save": "Хадгалах",
@@ -6696,6 +6698,7 @@
"forgot": "ПИН кодоо мартсан уу?",
"forgot_title": "Дотоод өгөгдлийг устгаад гарах уу?",
"forgot_body": "Мартсан ПИН кодыг сэргээх боломжгүй. Энэ нь энэ төхөөрөмжид хадгалсан имэйлийг устгаж, дахин нэвтрэх боломжтой болгохын тулд бүх бүртгэлээс гаргана. Сервер дээрх имэйл тань үлдэнэ. Энэ төхөөрөмж дээр илгээгдэхийг хүлээж буй мессежүүд алдагдана.",
"forgot_provider": "Хэрэв та байгууллагынхаа нэвтрэх хуудсаар нэвтэрсэн бол тэр нэвтрэлт тэнд нээлттэй үлдэнэ. Дараагийн нэвтрэлт боломжтой бол тэр хуудаснаас шинэ нэвтрэлт хүснэ.",
"forgot_confirm": "Устгаад гарах",
"unlock_in_main": "Үндсэн цонхонд түгжээг тайлна уу.",
"open_main": "Үндсэн цонх руу очих",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Hva dette beskytter mot: I en nettleser holder PIN-koden andre ved denne enheten ute av appen og holder e-posten som er lagret her kryptert. Den kan ikke stoppe et program på denne datamaskinen, en nettleserutvidelse du har installert, eller noen som kopierer nettleserens filer og har tid til å prøve mange koder, så en lengre PIN-kode er verdt det. I skrivebordsappen er PIN-koden også knyttet til påloggingen din på denne datamaskinen. Kontoen din på serveren påvirkes ikke.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Den PIN-koden er for lett å gjette. Unngå gjentakelser, rekkefølger og vanlige valg.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Hvis du logget på via organisasjonens påloggingsside, forblir den påloggingen åpen der. Neste pålogging her ber den siden om en ny pålogging, der det er mulig.",
"forgot_confirm": "Fjern og logg ut",
"unlock_in_main": "Lås opp i hovedvinduet.",
"open_main": "Gå til hovedvinduet",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Waartegen dit beschermt: in een browser houdt de pincode iemand bij dit apparaat buiten de app en blijft de e-mail die hier is opgeslagen versleuteld. Hij kan geen programma op deze computer tegenhouden, geen browserextensie die je hebt geïnstalleerd en niemand die de bestanden van de browser kopieert en tijd heeft om veel pincodes te proberen, dus een langere pincode is de moeite waard. In de desktop-app is de pincode ook gekoppeld aan je aanmelding op deze computer. Je account op de server verandert niet.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Deze pincode is te makkelijk te raden. Vermijd herhalingen, reeksen en veelgebruikte keuzes.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Als je je hebt aangemeld via de aanmeldpagina van je organisatie, blijft die aanmelding daar open. De volgende aanmelding hier vraagt die pagina om een nieuwe aanmelding, als dat kan.",
"forgot_confirm": "Verwijderen en uitloggen",
"unlock_in_main": "Ontgrendel in het hoofdvenster.",
"open_main": "Naar het hoofdvenster",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Przed czym to chroni: w przeglądarce PIN nie wpuszcza do aplikacji osoby przy tym urządzeniu, a zapisana tu poczta pozostaje zaszyfrowana. Nie zatrzyma programu działającego na tym komputerze, zainstalowanego przez Ciebie rozszerzenia przeglądarki ani kogoś, kto skopiuje pliki przeglądarki i ma czas na wypróbowanie wielu kodów, więc warto wybrać dłuższy PIN. W aplikacji komputerowej PIN jest też powiązany z Twoim logowaniem na tym komputerze. Nie ma to wpływu na Twoje konto na serwerze.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Ten PIN jest zbyt łatwy do odgadnięcia. Unikaj powtórzeń, ciągów i popularnych wyborów.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Jeśli logujesz się przez stronę logowania swojej organizacji, to logowanie pozostaje tam otwarte. Następne logowanie tutaj poprosi tę stronę o nowe logowanie, jeśli to możliwe.",
"forgot_confirm": "Usuń i wyloguj",
"unlock_in_main": "Odblokuj w oknie głównym.",
"open_main": "Przejdź do okna głównego",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Contra o que isto protege: num navegador, o PIN impede que alguém junto a este dispositivo entre na aplicação e mantém encriptado o correio guardado aqui. Não consegue travar um programa em execução neste computador, uma extensão do navegador que tenha instalado nem alguém que copie os ficheiros do navegador e tenha tempo para experimentar muitos PIN, por isso vale a pena um PIN mais longo. Na aplicação de computador, o PIN também fica ligado ao seu início de sessão neste computador. A sua conta no servidor não é afetada.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Esse PIN é muito fácil de adivinhar. Evite repetições, sequências e escolhas comuns.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Se iniciou sessão pela página da sua organização, essa sessão continua aberta lá. O próximo início de sessão aqui pedirá a essa página uma nova sessão, se ela o permitir.",
"forgot_confirm": "Remover e terminar sessão",
"unlock_in_main": "Desbloqueie na janela principal.",
"open_main": "Ir para a janela principal",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "De ce protejează: într-un browser, PIN-ul ține în afara aplicației pe cineva aflat la acest dispozitiv și păstrează criptate e-mailurile stocate aici. Nu poate opri un program care rulează pe acest computer, o extensie de browser instalată de dvs. sau pe cineva care copiază fișierele browserului și are timp să încerce multe coduri, așa că merită un PIN mai lung. În aplicația desktop, PIN-ul este legat și de autentificarea dvs. pe acest computer. Contul de pe server nu este afectat.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Acest PIN este prea ușor de ghicit. Evitați repetițiile, secvențele și alegerile obișnuite.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Dacă v-ați autentificat prin pagina organizației, acea autentificare rămâne deschisă acolo. Următoarea autentificare de aici va cere acelei pagini o nouă autentificare, dacă permite.",
"forgot_confirm": "Eliminați și deconectați",
"unlock_in_main": "Deblocați în fereastra principală.",
"open_main": "Mergeți la fereastra principală",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.",
"enable_label": "Блокировать PIN-кодом",
"enable_desc": "По умолчанию выключено. PIN-код остаётся на этом устройстве.",
"protects_desc": "От чего это защищает: в браузере PIN-код не пускает в приложение того, кто находится у этого устройства, и хранит сохранённую здесь почту в зашифрованном виде. Он не остановит программу на этом компьютере, установленное вами расширение браузера или того, кто скопирует файлы браузера и сможет перебирать много кодов, поэтому стоит выбрать PIN-код подлиннее. В настольном приложении PIN-код также привязан к вашему входу на этом компьютере. Ваша учётная запись на сервере не затрагивается.",
"enable_body": "Выберите PIN-код. Он понадобится при каждой блокировке приложения, а также после перезагрузки или нового запуска.",
"set_pin_title": "Выберите PIN-код",
"change_pin": "Изменить PIN-код…",
@@ -6657,6 +6658,7 @@
"pin_new": "Новый PIN-код",
"pin_repeat": "Повторите PIN-код",
"pin_hint": "Не менее 6 символов. Можно использовать буквы, а длинный PIN-код труднее угадать.",
"pin_obvious": "Этот PIN-код слишком легко угадать. Избегайте повторов, последовательностей и распространённых вариантов.",
"pin_mismatch": "PIN-коды не совпадают.",
"pin_recovery": "Если вы его забудете, останется только удалить данные на этом устройстве и войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.",
"save": "Сохранить",
@@ -6696,6 +6698,7 @@
"forgot": "Забыли PIN-код?",
"forgot_title": "Удалить локальные данные и выйти?",
"forgot_body": "Забытый PIN-код нельзя восстановить. Будет удалена почта, хранящаяся на этом устройстве, и выполнен выход из всех учётных записей, чтобы вы могли войти снова. Ваша почта на сервере сохранится. Сообщения, ожидающие отправки на этом устройстве, будут потеряны.",
"forgot_provider": "Если вы вошли через страницу входа вашей организации, этот вход там остаётся открытым. Следующий вход здесь запросит у этой страницы новый вход, если она это позволяет.",
"forgot_confirm": "Удалить и выйти",
"unlock_in_main": "Разблокируйте в главном окне.",
"open_main": "Перейти в главное окно",
+3
View File
@@ -6648,6 +6648,7 @@
"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í.",
"protects_desc": "Pred čím to chráni: v prehliadači PIN nepustí do aplikácie nikoho pri tomto zariadení a pošta uložená tu zostáva šifrovaná. Nezastaví program bežiaci na tomto počítači, rozšírenie prehliadača, ktoré ste nainštalovali, ani niekoho, kto skopíruje súbory prehliadača a má čas skúšať veľa PIN-ov, preto sa dlhší PIN oplatí. V desktopovej aplikácii je PIN navyše viazaný na vaše prihlásenie na tomto počítači. Váš účet na serveri to neovplyvní.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Tento PIN sa dá príliš ľahko uhádnuť. Vyhnite sa opakovaniu, radám a bežným voľbám.",
"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ť",
@@ -6696,6 +6698,7 @@
"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_provider": "Ak ste sa prihlásili cez prihlasovaciu stránku svojej organizácie, prihlásenie tam zostane otvorené. Ďalšie prihlásenie tu požiada túto stránku o nové prihlásenie, ak to umožňuje.",
"forgot_confirm": "Odstrániť a odhlásiť",
"unlock_in_main": "Odomknite v hlavnom okne.",
"open_main": "Prejsť do hlavného okna",
+3
View File
@@ -6656,6 +6656,7 @@
"section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ",
"enable_label": "ล็อกด้วย PIN",
"enable_desc": "ปิดอยู่โดยค่าเริ่มต้น PIN จะอยู่บนอุปกรณ์นี้",
"protects_desc": "สิ่งที่ป้องกันได้: ในเบราว์เซอร์ PIN กันคนที่อยู่หน้าอุปกรณ์นี้ไม่ให้เข้าแอป และเก็บอีเมลที่บันทึกไว้ที่นี่แบบเข้ารหัส แต่หยุดโปรแกรมที่ทำงานบนคอมพิวเตอร์เครื่องนี้ ส่วนขยายเบราว์เซอร์ที่คุณติดตั้ง หรือคนที่คัดลอกไฟล์ของเบราว์เซอร์แล้วมีเวลาลอง PIN จำนวนมากไม่ได้ จึงควรใช้ PIN ที่ยาวขึ้น ในแอปเดสก์ท็อป PIN ยังผูกกับการเข้าสู่ระบบคอมพิวเตอร์เครื่องนี้ด้วย บัญชีของคุณบนเซิร์ฟเวอร์ไม่ได้รับผลกระทบ",
"enable_body": "เลือก PIN คุณต้องใช้ทุกครั้งที่แอปล็อก และหลังโหลดใหม่หรือเริ่มใหม่",
"set_pin_title": "เลือก PIN",
"change_pin": "เปลี่ยน PIN…",
@@ -6665,6 +6666,7 @@
"pin_new": "PIN ใหม่",
"pin_repeat": "ใส่ PIN อีกครั้ง",
"pin_hint": "อย่างน้อย 6 ตัวอักษร ใช้ตัวอักษรได้ และ PIN ที่ยาวกว่าเดาได้ยากกว่า",
"pin_obvious": "PIN นี้เดาง่ายเกินไป หลีกเลี่ยงการซ้ำ ตัวเลขเรียง และรหัสที่คนใช้บ่อย",
"pin_mismatch": "PIN ทั้งสองไม่ตรงกัน",
"pin_recovery": "หากลืม วิธีเดียวคือลบข้อมูลบนอุปกรณ์นี้แล้วลงชื่อเข้าใช้ใหม่ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป",
"save": "บันทึก",
@@ -6704,6 +6706,7 @@
"forgot": "ลืม PIN หรือไม่",
"forgot_title": "ลบข้อมูลในเครื่องและออกจากระบบหรือไม่",
"forgot_body": "PIN ที่ลืมไม่สามารถกู้คืนได้ การดำเนินการนี้จะลบอีเมลที่เก็บบนอุปกรณ์นี้และออกจากระบบทุกบัญชี เพื่อให้คุณลงชื่อเข้าใช้ใหม่ได้ อีเมลบนเซิร์ฟเวอร์ยังอยู่ ข้อความที่ยังรอส่งบนอุปกรณ์นี้จะหายไป",
"forgot_provider": "หากคุณลงชื่อเข้าใช้ผ่านหน้าเข้าสู่ระบบขององค์กร การเข้าสู่ระบบนั้นจะยังเปิดอยู่ที่นั่น การลงชื่อเข้าใช้ครั้งถัดไปที่นี่จะขอให้หน้านั้นเข้าสู่ระบบใหม่ หากหน้านั้นอนุญาต",
"forgot_confirm": "ลบและออกจากระบบ",
"unlock_in_main": "ปลดล็อกในหน้าต่างหลัก",
"open_main": "ไปที่หน้าต่างหลัก",
+3
View File
@@ -6648,6 +6648,7 @@
"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.",
"protects_desc": "Neye karşı korur: Tarayıcıda PIN, bu cihazın başındaki birini uygulamadan uzak tutar ve burada saklanan postayı şifreli tutar. Bu bilgisayarda çalışan bir programı, yüklediğiniz bir tarayıcı uzantısını veya tarayıcının dosyalarını kopyalayıp çok sayıda PIN deneyecek zamanı olan birini durduramaz, bu yüzden daha uzun bir PIN'e değer. Masaüstü uygulamasında PIN, bu bilgisayardaki oturumunuza da bağlıdır. Sunucudaki hesabınız etkilenmez.",
"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…",
@@ -6657,6 +6658,7 @@
"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_obvious": "Bu PIN'i tahmin etmek çok kolay. Tekrarlardan, sıralı dizilerden ve yaygın seçimlerden kaçının.",
"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",
@@ -6696,6 +6698,7 @@
"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_provider": "Kuruluşunuzun giriş sayfasından oturum açtıysanız o oturum orada açık kalır. Buradaki bir sonraki oturum açma, mümkünse o sayfadan yeni bir giriş ister.",
"forgot_confirm": "Kaldır ve oturumu kapat",
"unlock_in_main": "Ana pencerede kilidi açın.",
"open_main": "Ana pencereye git",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.",
"enable_label": "Блокувати PIN-кодом",
"enable_desc": "Типово вимкнено. PIN-код залишається на цьому пристрої.",
"protects_desc": "Від чого це захищає: у браузері PIN-код не пускає до застосунку того, хто біля цього пристрою, і зберігає пошту тут зашифрованою. Він не зупинить програму на цьому комп'ютері, встановлене вами розширення браузера або того, хто скопіює файли браузера і матиме час перебирати багато кодів, тому варто обрати довший PIN-код. У настільному застосунку PIN-код також прив'язаний до вашого входу на цьому комп'ютері. Ваш обліковий запис на сервері це не зачіпає.",
"enable_body": "Виберіть PIN-код. Він знадобиться щоразу, коли застосунок блокується, а також після перезавантаження чи нового запуску.",
"set_pin_title": "Виберіть PIN-код",
"change_pin": "Змінити PIN-код…",
@@ -6657,6 +6658,7 @@
"pin_new": "Новий PIN-код",
"pin_repeat": "Повторіть PIN-код",
"pin_hint": "Щонайменше 6 символів. Можна вводити літери, а довший PIN-код важче вгадати.",
"pin_obvious": "Цей PIN-код надто легко вгадати. Уникайте повторів, послідовностей і поширених варіантів.",
"pin_mismatch": "PIN-коди не збігаються.",
"pin_recovery": "Якщо ви його забудете, залишиться лише вилучити дані на цьому пристрої й увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.",
"save": "Зберегти",
@@ -6696,6 +6698,7 @@
"forgot": "Забули PIN-код?",
"forgot_title": "Вилучити локальні дані й вийти?",
"forgot_body": "Забутий PIN-код не можна відновити. Буде вилучено пошту, збережену на цьому пристрої, і виконано вихід з усіх облікових записів, щоб ви могли увійти знову. Ваша пошта на сервері збережеться. Повідомлення, що ще чекають надсилання на цьому пристрої, буде втрачено.",
"forgot_provider": "Якщо ви увійшли через сторінку входу вашої організації, цей вхід там залишається відкритим. Наступний вхід тут попросить цю сторінку про новий вхід, якщо вона це дозволяє.",
"forgot_confirm": "Вилучити й вийти",
"unlock_in_main": "Розблокуйте в головному вікні.",
"open_main": "Перейти до головного вікна",
+3
View File
@@ -6660,6 +6660,7 @@
"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.",
"protects_desc": "Điều này bảo vệ trước gì: trong trình duyệt, mã PIN ngăn người ở cạnh thiết bị này vào ứng dụng và giữ thư lưu ở đây được mã hóa. Nó không thể chặn một chương trình chạy trên máy tính này, tiện ích trình duyệt bạn đã cài, hoặc người sao chép tệp của trình duyệt và có thời gian thử nhiều mã, vì vậy mã PIN dài hơn là đáng giá. Trong ứng dụng máy tính, mã PIN còn gắn với thông tin đăng nhập của bạn trên máy tính này. Tài khoản của bạn trên máy chủ không bị ảnh hưởng.",
"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…",
@@ -6669,6 +6670,7 @@
"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_obvious": "Mã PIN này quá dễ đoán. Hãy tránh lặp lại, dãy số và các lựa chọn phổ biế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",
@@ -6708,6 +6710,7 @@
"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_provider": "Nếu bạn đăng nhập qua trang đăng nhập của tổ chức, phiên đó vẫn mở ở đó. Lần đăng nhập tiếp theo tại đây sẽ yêu cầu trang đó đăng nhập mới, nếu được phép.",
"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",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。",
"enable_label": "使用 PIN 鎖定",
"enable_desc": "預設關閉。PIN 只保存在此裝置上。",
"protects_desc": "它能防護什麼:在瀏覽器中,PIN 可阻止這台裝置前的人進入應用程式,並讓儲存在此處的郵件保持加密。它無法阻止在這台電腦上執行的程式、你安裝的瀏覽器擴充功能,或複製瀏覽器檔案並有時間嘗試大量 PIN 的人,因此較長的 PIN 更值得。在桌面應用程式中,PIN 也與你在這台電腦上的登入綁定。伺服器上的帳戶不受影響。",
"enable_body": "請選擇一個 PIN。每次應用程式鎖定時以及重新載入或重新啟動後都需要它。",
"set_pin_title": "選擇 PIN",
"change_pin": "變更 PIN…",
@@ -6657,6 +6658,7 @@
"pin_new": "新 PIN",
"pin_repeat": "重複 PIN",
"pin_hint": "至少 6 個字元。也可以使用字母,較長的 PIN 更難猜到。",
"pin_obvious": "這個 PIN 太容易被猜到。請避免重複、連續數字和常見組合。",
"pin_mismatch": "兩個 PIN 不一致。",
"pin_recovery": "如果忘記了,唯一的辦法是移除此裝置上的資料並重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。",
"save": "儲存",
@@ -6696,6 +6698,7 @@
"forgot": "忘記了 PIN?",
"forgot_title": "移除本機資料並登出?",
"forgot_body": "忘記的 PIN 無法復原。這會移除此裝置上保存的郵件並登出所有帳戶,讓您可以重新登入。伺服器上的郵件會保留。此裝置上仍在等待傳送的郵件將遺失。",
"forgot_provider": "如果你透過組織的登入頁面登入,該登入會在那裡保持開啟。下次在此登入時,會在允許的情況下要求該頁面重新登入。",
"forgot_confirm": "移除並登出",
"unlock_in_main": "請在主視窗中解鎖。",
"open_main": "前往主視窗",
+3
View File
@@ -6648,6 +6648,7 @@
"section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。",
"enable_label": "使用 PIN 锁定",
"enable_desc": "默认关闭。PIN 只保存在此设备上。",
"protects_desc": "它能防护什么:在浏览器中,PIN 可阻止这台设备前的人进入应用,并让存储在此处的邮件保持加密。它无法阻止在这台电脑上运行的程序、你安装的浏览器扩展,或复制浏览器文件并有时间尝试大量 PIN 的人,因此更长的 PIN 更值得。在桌面应用中,PIN 还与你在这台电脑上的登录绑定。服务器上的账户不受影响。",
"enable_body": "请选择一个 PIN。每次应用锁定时以及重新加载或重新启动后都需要它。",
"set_pin_title": "选择 PIN",
"change_pin": "更改 PIN…",
@@ -6657,6 +6658,7 @@
"pin_new": "新 PIN",
"pin_repeat": "重复 PIN",
"pin_hint": "至少 6 个字符。也可以使用字母,较长的 PIN 更难猜到。",
"pin_obvious": "这个 PIN 太容易被猜到。请避免重复、连续数字和常见组合。",
"pin_mismatch": "两个 PIN 不一致。",
"pin_recovery": "如果忘记了,唯一的办法是删除此设备上的数据并重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。",
"save": "保存",
@@ -6696,6 +6698,7 @@
"forgot": "忘记了 PIN?",
"forgot_title": "删除本地数据并退出登录?",
"forgot_body": "忘记的 PIN 无法恢复。这会删除此设备上保存的邮件并退出所有帐户,以便您重新登录。服务器上的邮件会保留。此设备上仍在等待发送的邮件将丢失。",
"forgot_provider": "如果你通过组织的登录页面登录,该登录会在那里保持打开。下次在此登录时,会在允许的情况下要求该页面重新登录。",
"forgot_confirm": "删除并退出登录",
"unlock_in_main": "请在主窗口中解锁。",
"open_main": "转到主窗口",
+2
View File
@@ -223,6 +223,8 @@ function offlineRoute(request) {
if (request.method !== "GET") return null;
const url = new URL(request.url);
if (request.mode === "navigate" && appPagePath(url) !== null) return respondWithDocument;
// The app lock asks whether a reload would land anywhere, and has to hear what a reload would.
if (request.headers.get("x-bulwark-reload-probe") && appPagePath(url) !== null) return respondWithDocument;
if (staticPath(url)) return respondWithStatic;
if (url.origin === self.location.origin && url.pathname === BASE_PATH + "/api/config") return respondWithConfig;
return null;
@@ -0,0 +1,77 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// A forgotten PIN offline: the HttpOnly session cookie couldn't be deleted. With no
// accounts left, a start would adopt that cookie and sign its login back in, with no PIN.
vi.mock('@/lib/store/client/purge', () => ({ purgeStoreFiles: async () => {}, deleteStorePool: async () => {} }));
let useAuthStore: typeof import('../auth-store').useAuthStore;
function calls(): string[] {
return (fetch as ReturnType<typeof vi.fn>).mock.calls.map(([url, init]) => `${(init as RequestInit)?.method ?? 'GET'} ${String(url)}`);
}
beforeEach(async () => {
vi.resetModules();
localStorage.clear();
sessionStorage.clear();
({ useAuthStore } = await import('../auth-store'));
const browserNavigation = await import('@/lib/browser-navigation');
vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
});
describe('the mark for cookies still to clear', () => {
it('blocks adoption while the DELETE keeps failing, and tries it first', async () => {
localStorage.setItem('bulwark-cookies-to-clear', '1');
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'DELETE') throw new TypeError('offline');
return new Response(JSON.stringify({ serverUrl: 'https://mail.example.test', username: 'alice', password: 'secret' }), { status: 200 });
}));
await useAuthStore.getState().checkAuth();
expect(calls()).toContain('DELETE /api/auth/session?all=true');
expect(calls()).not.toContain('PUT /api/auth/session');
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(localStorage.getItem('bulwark-cookies-to-clear')).toBe('1');
});
it('comes off when the DELETE worked, and nothing is adopted from what was cleared', async () => {
localStorage.setItem('bulwark-cookies-to-clear', '1');
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => (
init?.method === 'PUT' ? new Response('{}', { status: 401 }) : new Response('{}', { status: 200 })
)));
await useAuthStore.getState().checkAuth();
expect(localStorage.getItem('bulwark-cookies-to-clear')).toBeNull();
const list = calls();
expect(list.indexOf('DELETE /api/auth/session?all=true')).toBeGreaterThanOrEqual(0);
expect(list.indexOf('DELETE /api/auth/session?all=true')).toBeLessThan(list.indexOf('PUT /api/auth/session'));
});
it('isn\'t there normally: adoption goes on as before', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 401 })));
await useAuthStore.getState().checkAuth();
expect(calls()).toContain('PUT /api/auth/session');
expect(calls()).not.toContain('DELETE /api/auth/session?all=true');
});
});
describe('the app lock and the way to the login page', () => {
const stored = JSON.stringify({ state: { isAuthenticated: true }, version: 0 });
it('a page that starts behind the lock has shown nothing, so the login page opens in place', async () => {
vi.resetModules();
localStorage.setItem('auth-storage', stored);
localStorage.setItem('bulwark-app-lock', JSON.stringify({ on: true, locked: true }));
const auth = await import('../auth-store');
expect(auth.useAuthStore.getState().isAuthenticated, 'what storage said').toBe(true);
expect(auth.canEnterLoginInPlace()).toBe(true);
});
it('with no lock, a signed-in state from storage still counts as shown', async () => {
vi.resetModules();
localStorage.setItem('auth-storage', stored);
const auth = await import('../auth-store');
expect(auth.useAuthStore.getState().isAuthenticated).toBe(true);
expect(auth.canEnterLoginInPlace()).toBe(false);
});
});
+11 -4
View File
@@ -25,6 +25,7 @@ import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll
import { getDesktopPolicy, onDesktopPolicyChange } from '@/lib/desktop/policy';
import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '@/lib/desktop/refused-accounts';
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
import { clearPendingCookies, freshLoginDone } from '@/lib/app-lock/sign-out';
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';
@@ -2974,7 +2975,9 @@ export const useAuthStore = create<AuthState>()(
// registry so the normal restoration path picks it up. Without this
// the cookies sit unused and the SPA bounces to the login screen.
// (Not in Lite: nothing hands sessions over server-side.)
if (accounts.length === 0 && !IS_LITE) {
// Not while a forgotten PIN's cookies wait to be cleared: this would sign that
// login back in with no PIN. The DELETE goes first, and adoption waits for it.
if (accounts.length === 0 && !IS_LITE && await clearPendingCookies()) {
try {
const restore = await apiFetch('/api/auth/session', { method: 'PUT' });
if (restore.ok) {
@@ -3821,10 +3824,14 @@ if (typeof window !== 'undefined') {
onDesktopPolicyChange(applyDesktopServerPolicy);
}
// See canEnterLoginInPlace.
let signedInSincePageLoad = useAuthStore.getState().isAuthenticated;
// See canEnterLoginInPlace. A page that starts behind the app lock has shown nothing yet,
// whatever the stored state says, and a page load here would ask for the PIN again.
let signedInSincePageLoad = !isAppLockOn() && useAuthStore.getState().isAuthenticated;
useAuthStore.subscribe((state) => {
if (state.isAuthenticated) signedInSincePageLoad = true;
if (state.isAuthenticated) {
signedInSincePageLoad = true;
freshLoginDone();
}
});
// Another tab signed everyone out: this one's cookies and account list are
+1 -1
View File
@@ -125,7 +125,7 @@ export function recordNotification(input: CenterEntryInput): void {
}
}
/** Test helper: read the list back from storage as a fresh page load would. */
/** Reads the list back from storage as a fresh page load would: the app lock puts it back at unlock. */
export function reloadNotificationCenter(): void {
useNotificationCenterStore.setState({ entries: load() });
}