fix: protect the keys to the mail on this device for real

The review found the store key readable from a copy of the disk. The
desktop app only added its OS layer with the lock on, the record from
before a lock stayed in LevelDB and opened the mail with no PIN, any
frame could unwrap the PIN record and guess at it, a lock left the key
in the renderer's memory, and a store waiting after a refused login went
to whoever signed in next under the same username@host.

- The desktop app wraps the store key with safeStorage, lock or no lock.
  A record without the layer gets it at its next open. A refusal is said
  out loud, and only a computer with no key store goes without, which
  settings say. So do they for a browser, which has no such layer.
- A record that gained layers, and every record after a changed PIN, asks
  for a new key: `sqlite3_backup` into a new file under a new key while
  the store keeps working, one write to the record, then the old file
  goes. A changed PIN makes a new lock key, since the old PIN's record
  stays on disk too. The passkey and OS unlock go with the old key.
- `wrap` and `unwrap` come through `claim()`, and the key under the PIN
  takes a secret from main, one counted trip per guess.
- A lock on the desktop ends the renderer rather than reload in it.
- A sign-in compares the waiting store's JMAP account and server first,
  and wipes on a mismatch. The account id keeps its form: every store,
  setting and server record of an account is keyed by it.
- The 30 days also run in an app that stays open.

Changes an existing assertion: turning the lock off now keeps the
safeStorage layer, by decision, so that test expects it to stay.
This commit is contained in:
Linus Rath
2026-11-04 04:15:31 +01:00
parent 66c6b61853
commit 88d64b7f95
62 changed files with 1734 additions and 163 deletions
@@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
</SettingItem>
<p className="pt-2 text-xs text-muted-foreground">
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn&apos;t stop someone
who copies its files: use full-disk encryption. In the desktop app the key also takes that person&apos;s login on
that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with
a copy can guess at their own speed, so ask for a long one. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can&apos;t raise what another one lowered. The app holds itself to
them when it fetches the policy; the server can&apos;t check what is on a device. A sign-out someone asks for deletes everything.
One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
@@ -0,0 +1,50 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useOfflineStore } from '@/lib/offline/state';
import { KeyProtectionNotes } from '../key-protection-notes';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
function withShell(wrap: boolean): void {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'linux',
appLock: { status: async () => ({ wrap }) },
};
}
describe('what settings say about the key', () => {
beforeEach(() => useOfflineStore.setState({ keyUnprotected: false, rekey: {} }));
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
it('a browser says it has no layer of the OS', async () => {
await act(async () => { render(<KeyProtectionNotes browser />); });
expect(screen.getByText('browser_note')).toBeTruthy();
});
it('the desktop app with a real key store says nothing', async () => {
withShell(true);
await act(async () => { render(<KeyProtectionNotes browser />); });
expect(screen.queryByTestId('key-protection-notes')).toBeNull();
});
it('Linux with no secret service says secrets are only obfuscated', async () => {
withShell(false);
await act(async () => { render(<KeyProtectionNotes browser />); });
expect(screen.getByText('no_secure_storage')).toBeTruthy();
expect(screen.queryByText('browser_note')).toBeNull();
});
it('says out loud when the key store refused, and how a re-encryption goes', async () => {
withShell(true);
useOfflineStore.setState({ keyUnprotected: true, rekey: { a: 40, b: 'failed' } });
await act(async () => { render(<KeyProtectionNotes />); });
expect(screen.getByText('wrap_failed')).toBeTruthy();
expect(screen.getByText('rekey_failed')).toBeTruthy();
expect(screen.getByText('rekey_progress:{"percent":40}')).toBeTruthy();
});
});
@@ -0,0 +1,49 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { useOfflineStore } from '@/lib/offline/state';
/**
* What protects the key to the mail on this device, where that's less than one would think, and
* how a re-encryption is getting on. `browser`: also says what a browser can't do. The app lock's
* own settings have said that already.
*/
export function KeyProtectionNotes({ browser = false }: { browser?: boolean }) {
const t = useTranslations('keyProtection');
const keyUnprotected = useOfflineStore((state) => state.keyUnprotected);
const rekey = useOfflineStore((state) => state.rekey);
const [bridge, setBridge] = useState<'none' | 'wraps' | 'obfuscates'>('wraps');
useEffect(() => {
const member = getDesktopBridge()?.appLock;
if (!member) {
setBridge('none');
return;
}
let live = true;
void member.status().then((status) => {
if (live) setBridge(status.wrap ? 'wraps' : 'obfuscates');
}).catch(() => {});
return () => {
live = false;
};
}, []);
const states = Object.values(rekey);
const running = states.filter((state): state is number => typeof state === 'number');
const lines = [
bridge === 'obfuscates' ? t('no_secure_storage') : null,
bridge === 'none' && browser ? t('browser_note') : null,
keyUnprotected ? t('wrap_failed') : null,
states.includes('failed') ? t('rekey_failed') : null,
running.length > 0 ? t('rekey_progress', { percent: Math.min(...running) }) : null,
].filter((line): line is string => line !== null);
if (lines.length === 0) return null;
return (
<div className="space-y-1 rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="key-protection-notes">
{lines.map((line) => <p key={line}>{line}</p>)}
</div>
);
}
+9
View File
@@ -27,6 +27,9 @@ export function OfflineHost() {
const tStorage = useTranslations('offlineStorage');
const tStorageRef = useRef(tStorage);
tStorageRef.current = tStorage;
const tKey = useTranslations('keyProtection');
const tKeyRef = useRef(tKey);
tKeyRef.current = tKey;
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
@@ -89,6 +92,12 @@ export function OfflineHost() {
case 'store-unavailable':
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
break;
case 'key-unprotected':
toast.warning(tKeyRef.current('wrap_failed'), { duration: 0 });
break;
case 'rekey-failed':
toast.warning(tKeyRef.current('rekey_failed'), { duration: 0 });
break;
case 'refused-delete':
toast.warning(tr('toast.refused_delete'));
break;
+2
View File
@@ -15,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
import { KeyProtectionNotes } from './key-protection-notes';
import { WipeDeviceSection } from './wipe-device-section';
/**
@@ -65,6 +66,7 @@ export function OfflineSettings() {
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
</div>
<KeyProtectionNotes browser />
{enabledCount > 0 && persist === 'refused' && (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
<p className="min-w-0">{tStorage('persist_refused')}</p>
+9 -1
View File
@@ -24,6 +24,7 @@ import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
import { IS_LITE } from "@/lib/lite";
import { toast } from "@/stores/toast-store";
import { KeyProtectionNotes } from "@/components/offline/key-protection-notes";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null;
@@ -39,6 +40,7 @@ function useLockFacts() {
/** The app lock's section on the General page: it has to exist on any server. */
export function AppLockSettings() {
const t = useTranslations("appLock");
const tKey = useTranslations("keyProtection");
const { appName } = useConfig();
const { on, required, timeout, update } = useLockFacts();
const [pending, setPending] = useState<Pending>(null);
@@ -94,7 +96,12 @@ export function AppLockSettings() {
event.preventDefault();
if (busy) return;
if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin));
if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin));
if (pending === "change" && current && newPinReady(pin, repeat)) {
void run(async () => {
const { reenroll } = await changeAppLockPin(current, pin);
if (reenroll) toast.info(tKey("reenroll_note"), { duration: 0 });
});
}
if (pending === "disable" && current) void run(() => disableAppLock(current));
if (pending === "passkey" && current) {
void run(async () => {
@@ -129,6 +136,7 @@ export function AppLockSettings() {
<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>
<KeyProtectionNotes />
{on && (
<>
+100 -4
View File
@@ -19,13 +19,14 @@ import {
setReloadForTests,
startAppLock,
STAY_RETRY_MS,
storeKeyLayers,
unlockWithOs,
unlockWithPin,
} from '../controller';
import { LOCK_LAYER } from '../crypto';
import { importLockKey, LOCK_LAYER, lockLayer } from '../crypto';
import { appLockRequired, effectiveTimeout } from '../policy';
import { APP_LOCK_KEY, appLockBootScript, isAppLockedNow, readAppLockFile, setLockedHere } from '../state';
import { createVault, deleteVault, readVault, sealDraft, VaultExistsError } from '../vault';
import { createVault, deleteVault, openVaultWithPin, readVault, rotateVaultKey, sealDraft, VaultExistsError, WrongPinError } from '../vault';
const PIN = 'correct-pin';
@@ -438,6 +439,101 @@ describe('the store when a tab inherits it without the key', () => {
});
});
describe('the desktop app\'s safeStorage on the store\'s key', () => {
const shellBridge = () => bridge({
status: vi.fn(async () => ({ locked: false, enrolled: true, 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)),
});
it('wraps with the lock off too', async () => {
shellBridge();
const layers = await storeKeyLayers();
expect(layers.map((layer) => layer.name)).toEqual(['safeStorage']);
await openAccountKey('off', layers);
expect((await getAccountRecord('off'))?.layers).toEqual(['safeStorage']);
});
it('a browser has no such layer', async () => {
expect(await storeKeyLayers()).toEqual([]);
});
it('a shell whose storage only obfuscates has none either', async () => {
bridge({ status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false, wrap: false })) });
expect(await storeKeyLayers()).toEqual([]);
});
it('turning the lock on asks for the mail to be re-encrypted; off again keeps the OS\'s layer', async () => {
shellBridge();
await openAccountKey('acc', await storeKeyLayers());
await enableAppLock(PIN);
let stored = await getAccountRecord('acc');
expect(stored?.layers).toEqual(['safeStorage', 'applock']);
expect(stored?.rekey).toBe(true);
await disableAppLock(PIN);
stored = await getAccountRecord('acc');
expect(stored?.layers).toEqual(['safeStorage']);
});
});
describe('changing the PIN', () => {
it('makes a new lock key, so what an old copy of the PIN record opens opens nothing now', async () => {
await openAccountKey('acc');
await enableAppLock(PIN);
const before = await readVault();
const oldKey = await importLockKey(await openVaultWithPin(PIN));
await changeAppLockPin(PIN, 'next-pin-5521');
const record = await getAccountRecord('acc');
expect(record?.layers).toEqual(['applock']);
expect(record?.rekey).toBe(true);
// Under the old lock key the record opens no more.
await expect(lockLayer(oldKey).unwrap(record!.wrapped)).rejects.toBeDefined();
expect((await readVault())?.pin.data).not.toEqual(before?.pin.data);
expect((await readVault())?.previous).toBeUndefined();
await expect(openVaultWithPin(PIN)).rejects.toBeInstanceOf(WrongPinError);
// The store still opens, in this tab, with the key it holds now.
await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ fresh: false });
});
it('survives a crash halfway: a record under the old key moves over at the next unlock', async () => {
await openAccountKey('acc');
await enableAppLock(PIN);
const oldRaw = await openVaultWithPin(PIN);
await rotateVaultKey('next-pin-5521', oldRaw);
await lockedAndReloaded();
await unlockWithPin('next-pin-5521');
expect((await readVault())?.previous).toBeUndefined();
await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ fresh: false });
expect((await getAccountRecord('acc'))?.rekey).toBe(true);
});
it('the passkey and the OS\'s unlock wrapped the old key: they go, and the caller hears', async () => {
const appLock = bridge({ status: vi.fn(async () => ({ locked: false, enrolled: true, os: 'hello', osEnrolled: true, wrap: false })) });
await enableAppLock(PIN);
const { reenroll } = await changeAppLockPin(PIN, 'next-pin-5521');
expect(reenroll).toBe(true);
expect((await readVault())?.prf).toBeUndefined();
expect(appLock.disable).toHaveBeenCalledTimes(1);
expect(appLock.enroll).toHaveBeenCalledTimes(2);
});
});
describe('the renderer at a lock', () => {
it('is replaced in the desktop app: a reload stays in the process with the old heap', async () => {
const replaceRenderer = vi.fn();
const appLock = bridge();
appLock.claim = vi.fn(() => ({ lock: appLock.lock, osUnlock: appLock.osUnlock, replaceRenderer })) as never;
setReloadForTests(null);
const reload = vi.fn();
Object.defineProperty(window, 'location', { configurable: true, value: { ...window.location, reload, href: window.location.href, origin: window.location.origin } });
await enableAppLock(PIN);
stopTab = startAppLock();
lockEverywhere();
await vi.waitFor(() => expect(replaceRenderer).toHaveBeenCalledTimes(1));
expect(reload).not.toHaveBeenCalled();
});
});
describe('the desktop app', () => {
it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => {
const appLock = bridge();
@@ -622,9 +718,9 @@ describe('the desktop app', () => {
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.
// Off, only the lock's own layer goes: the OS's stays on with no lock too.
await disableAppLock(PIN);
expect((await getAccountRecord('acc'))?.layers).toEqual([]);
expect((await getAccountRecord('acc'))?.layers).toEqual(['safeStorage']);
});
it('forgetting asks main first: a no changes nothing', async () => {
+101 -4
View File
@@ -1,8 +1,8 @@
import 'fake-indexeddb/auto';
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, pinIsObvious, pinLongEnough } from '../crypto';
import { changeVaultPin, createVault, deleteVault, FREE_FAILURES, LONGEST_WAIT_MS, openSealedDrafts, openVaultWithPin, PinBlockedError, pinBlockedUntil, readVault, sealDraft, waitAfter, WrongPinError } from '../vault';
import { importLockKey, keyFromPin, lockLayer, mainProof, open, PBKDF2_ITERATIONS, pinIsObvious, pinLongEnough } from '../crypto';
import { rotateVaultKey, createVault, deleteVault, FREE_FAILURES, LONGEST_WAIT_MS, openSealedDrafts, openVaultWithPin, PinBlockedError, pinBlockedUntil, readVault, sealDraft, waitAfter, WrongPinError } from '../vault';
const PIN = 'correct-pin';
@@ -97,13 +97,110 @@ describe('in the desktop app, with main\'s safeStorage', () => {
it('a changed PIN goes through main too', async () => {
const appLock = shell();
await createVault(PIN);
await changeVaultPin(PIN, 'next-pin-5521');
const raw = await createVault(PIN);
await rotateVaultKey('next-pin-5521', raw);
expect(appLock.wrap).toHaveBeenCalledTimes(2);
expect((await readVault())!.pin.shell).toBe(true);
await expect(openVaultWithPin('next-pin-5521')).resolves.toBeInstanceOf(Uint8Array);
});
describe('with the calls handed out once, and each guess through main', () => {
/** What the real preload does: wrap, unwrap and a PIN step only for whoever claims first. */
function realShell(answer: (data: Uint8Array) => PinAnswer = stepWithSecret) {
let claimed = false;
const step = vi.fn(async (data: Uint8Array) => answer(data));
const appLock = {
status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false, wrap: true })),
prove: vi.fn(async () => true),
claim: vi.fn(() => {
if (claimed) return null;
claimed = true;
return {
lock: () => {},
osUnlock: async () => ({ ok: false }),
wrap: async (data: Uint8Array) => Uint8Array.from([9, ...data]),
unwrap: async (data: Uint8Array) => (data[0] === 9 ? data.slice(1) : null),
pinStep: step,
};
}),
};
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock };
return { appLock, step };
}
type PinAnswer = { ok: true; bytes: Uint8Array } | { ok: false; blockedUntil?: number };
/** Main's secret mixed in: the same guess gives the same bytes, and only main can make them. */
function stepWithSecret(data: Uint8Array): PinAnswer {
return { ok: true, bytes: data.map((byte) => byte ^ 0x5a) };
}
it('nothing on the bridge unwraps: a frame reaching it gets nothing', async () => {
realShell();
await createVault(PIN);
const bridge = (window as unknown as { bulwarkDesktop: { appLock: Record<string, unknown> & { claim(): unknown } } }).bulwarkDesktop.appLock;
expect(bridge.wrap).toBeUndefined();
expect(bridge.unwrap).toBeUndefined();
expect(bridge.claim()).toBeNull();
});
it('the PIN key takes main\'s secret, so the record alone can\'t be guessed at', async () => {
const { step } = realShell();
const raw = await createVault(PIN);
const vault = await readVault();
expect(vault?.pin.pepper).toBe(true);
expect(step).toHaveBeenCalledTimes(1);
await expect(openVaultWithPin('wrong-pin-1')).rejects.toBeInstanceOf(WrongPinError);
expect(step).toHaveBeenCalledTimes(2);
expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw));
expect(step).toHaveBeenCalledTimes(3);
// Without main's secret the right PIN opens nothing.
const plainKey = await keyFromPin(PIN, vault!.pin.salt, vault!.pin.iterations);
await expect(open(plainKey, { iv: vault!.pin.iv, data: vault!.pin.data.slice(1) })).rejects.toBeDefined();
});
it('main refusing a guess reads as blocked, not as a wrong PIN', async () => {
realShell();
await createVault(PIN);
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
const until = Date.now() + 60_000;
realShell(() => ({ ok: false, blockedUntil: until }));
await expect(openVaultWithPin(PIN)).rejects.toMatchObject({ name: 'PinBlockedError', until });
});
it('a secret main lost is the shell\'s trouble, not a wrong PIN', async () => {
realShell();
await createVault(PIN);
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
realShell(() => ({ ok: false }));
await expect(openVaultWithPin(PIN)).rejects.toMatchObject({ name: 'VaultShellError' });
});
it('a right PIN tells main, which alone can clear its count', async () => {
const { appLock } = realShell();
const raw = await createVault(PIN);
await openVaultWithPin(PIN);
expect(appLock.prove).toHaveBeenCalledTimes(1);
expect(Array.from(appLock.prove.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(await mainProof(raw)));
});
it('a record from before gets main\'s secret at its next right PIN', async () => {
shell();
const raw = await createVault(PIN);
expect((await readVault())?.pin.pepper).toBeUndefined();
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
realShell();
expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw));
expect((await readVault())?.pin.pepper).toBe(true);
expect(hex(await openVaultWithPin(PIN))).toBe(hex(raw));
});
it('a wrap main refuses stops the setup: the PIN record isn\'t left without the OS\'s layer', async () => {
shell({ wrap: vi.fn(async () => null) });
await expect(createVault(PIN)).rejects.toMatchObject({ name: 'VaultShellError' });
expect(await readVault()).toBeNull();
});
});
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);
+112 -33
View File
@@ -21,6 +21,7 @@ import { getDesktopBridge } from '@/lib/desktop/bridge';
import { setAppLockCheck } from '@/lib/desktop/app-lock-state';
import { restoreUrlFor } from '@/lib/desktop/window-restore';
import { IS_LITE } from '@/lib/lite';
import { useOfflineStore } from '@/lib/offline/state';
import { keepRescuedDraft } from '@/lib/popout/rescued-drafts';
import { closePopoutWindow, isPopoutWindow } from '@/lib/popout/window-role';
import { getStore, type StoreBackend } from '@/lib/store/client/api';
@@ -28,13 +29,13 @@ import { forgetAccountKey, listAccountRecords, rewrapAccountKey, StoreKeyLayerMi
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, SHELL_LAYER } from './crypto';
import { importLockKey, LOCK_LAYER, lockLayer, mainProof, ObviousPinError, pinIsObvious, randomBytes, SHELL_LAYER } from './crypto';
import { watchIdle } from './idle';
import { createPasskey, passkeyPrf, PasskeyNoPrfError } from './passkey';
import { appLockRequired, effectiveTimeout, onLockPolicyChange } from './policy';
import { reloadSealedStores, restoreSealedStorage, scrubBrowserStorage } from './scrub';
import { shellControls, shellHasWrap, shellUnwrapCall, shellWrapCall, resetShellForTests } from './shell';
import { clearPendingCookies, markCookiesToClear, rememberMainProof, requireFreshLogin } from './sign-out';
import {
APP_LOCK_CHANNEL,
@@ -51,14 +52,16 @@ import {
} from './state';
import {
addVaultPrf,
changeVaultPin,
clearVaultPrevious,
createVault,
deleteVault,
openSealedDrafts,
openVaultPrevious,
openVaultWithPin,
openVaultWithPrf,
readVault,
removeVaultPrf,
rotateVaultKey,
sealDraft,
vaultExists,
} from './vault';
@@ -105,18 +108,10 @@ let stopStay: (() => void) | null = null;
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;
let shellAsked = 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;
}
/** See lib/app-lock/shell.ts. Taken in the first render, before any frame exists. */
const desktopControls = shellControls;
function setPhase(next: AppLockPhase): void {
if (phase === next) return;
@@ -438,8 +433,18 @@ async function outboxAtRest(): Promise<void> {
let reloadSeam: (() => void) | null = null;
/**
* In the desktop app in a new renderer process: what JS can't overwrite is only gone with the
* process it's in, and a reload stays in the same one. A browser gives a page no such thing, so
* there the old heap is unreachable, not gone.
*/
function reloadPage(): void {
if (reloadSeam) reloadSeam();
if (reloadSeam) {
reloadSeam();
return;
}
const replace = desktopControls()?.replaceRenderer;
if (replace) replace();
else window.location.reload();
}
@@ -554,7 +559,8 @@ async function proveToMain(proof: Uint8Array): Promise<void> {
async function askShell(): Promise<void> {
const member = getDesktopBridge()?.appLock;
shellWraps = member?.wrap ? Boolean((await member.status().catch(() => null))?.wrap) : false;
shellWraps = member ? await shellHasWrap() : false;
shellAsked = true;
}
async function holdAndOpen(raw: Uint8Array): Promise<void> {
@@ -570,6 +576,7 @@ async function holdAndOpen(raw: Uint8Array): Promise<void> {
await askShell();
await becomeHolder();
const key = heldKey!;
await moveFromPreviousKey(key);
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.
@@ -618,33 +625,61 @@ export async function unlockWithOs(): Promise<boolean> {
// ---- The account keys ----
/** A store record made when the lock was off, or before a crash: it gets the layer now. */
/**
* A store record made when the lock was off, or before a crash: it gets the layer now. Whatever
* wraps it already has to come off to get at the key, and goes back on under the lock. One that
* can't (a copied profile) doesn't keep the others shut, and the app says so.
*/
async function addLayerWhereMissing(key: CryptoKey): Promise<void> {
const layer = lockLayer(key);
const shell = shellLayer();
const wanted = shell ? [shell, layer] : [layer];
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.
if (record.layers.join() === wanted.map((entry) => entry.name).join()) continue;
await rewrapAccountKey(record.account, wanted, wanted).catch((error: unknown) => {
console.warn('[app-lock] A record stays without the layer:', (error as Error)?.name);
useOfflineStore.setState({ keyUnprotected: true });
});
}
}
/** Off again, and main's wrap with it: with the lock off nothing asks main, and a start must not need it. */
/** Off again. The OS's layer stays: it's there with the lock off as well. */
async function removeLayerEverywhere(key: CryptoKey): Promise<void> {
const layer = lockLayer(key);
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], []);
if (record.layers.includes(LOCK_LAYER)) await rewrapAccountKey(record.account, shell ? [shell, layer] : [layer], shell ? [shell] : []);
}
}
/** The desktop's safeStorage around the database key (the build prompt's second layer), while the lock is on. */
/** A changed PIN made a new lock key. Every store key moves over before the old one goes. */
async function moveFromPreviousKey(key: CryptoKey): Promise<void> {
const old = await openVaultPrevious(key).catch(() => null);
if (!old) return;
const oldKey = await importLockKey(old);
old.fill(0);
const shell = shellLayer();
const layers = (lock: CryptoKey) => (shell ? [shell, lockLayer(lock)] : [lockLayer(lock)]);
let allMoved = true;
for (const record of await listAccountRecords().catch(() => [] as AccountRecord[])) {
if (!record.layers.includes(LOCK_LAYER)) continue;
try {
await rewrapAccountKey(record.account, layers(oldKey), layers(key), { rekey: true });
} catch {
// Under the new key already, from a try that stopped halfway? Then this goes through.
await rewrapAccountKey(record.account, layers(key), layers(key), { rekey: true }).catch(() => {
allMoved = false;
});
}
}
if (allMoved) await clearVaultPrevious();
}
/** The desktop's safeStorage around the database key (the build prompt's second layer), lock or no lock. */
function shellLayer(): WrapLayer | null {
const member = getDesktopBridge()?.appLock;
if (!shellWraps || !member?.wrap || !member.unwrap) return null;
const wrap = shellWrapCall();
const unwrap = shellUnwrapCall();
if (!shellWraps || !wrap || !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);
@@ -652,19 +687,25 @@ function shellLayer(): WrapLayer | null {
};
return {
name: SHELL_LAYER,
wrap: (data) => through(member.wrap, data),
unwrap: (data) => through(member.unwrap, data),
wrap: (data) => through(wrap, data),
unwrap: (data) => through(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);
const shell = shellLayer();
if (!readAppLockFile().on) return shell ? [shell] : [];
if (!heldKey) throw new StoreKeyLayerMissingError(LOCK_LAYER);
return shell ? [shell, lockLayer(heldKey)] : [lockLayer(heldKey)];
}
/** The same, for a start that hasn't asked main yet whether it has a real key store. */
export async function storeKeyLayers(): Promise<readonly WrapLayer[]> {
if (!shellAsked) await askShell();
return appLockKeyLayers();
}
/** This tab inherited the store and has no key for it. */
export function onStoreKeyMissing(): void {
lockEverywhere();
@@ -718,6 +759,7 @@ async function enable(pin: string): Promise<void> {
openUp();
// Another tab has the store open and no key for what comes next. Everyone starts over.
if (await ownerIsElsewhere()) lockEverywhere();
else void storeBackend().rekeyPending?.();
}
/** Off again. The PIN proves it's the owner of the lock, in this tab or any other. */
@@ -764,8 +806,44 @@ function afterOff(): void {
hold = null;
}
export function changeAppLockPin(currentPin: string, newPin: string): Promise<void> {
return changeVaultPin(currentPin, newPin);
/**
* A new PIN takes a new lock key and, in the background, new store keys: the old PIN's record can
* stay in the browser's files, and must not open anything any more. Main hears first, so that
* wherever this stops the vault can still be opened. `reenroll`: the passkey and the OS's unlock
* held the old key, and have to be set up again.
*/
export async function changeAppLockPin(currentPin: string, newPin: string): Promise<{ reenroll: boolean }> {
if (pinIsObvious(newPin)) throw new ObviousPinError();
const member = getDesktopBridge()?.appLock;
const hadOther = Boolean((await readVault())?.prf) || Boolean((await member?.status().catch(() => null))?.osEnrolled);
const oldRaw = await openVaultWithPin(currentPin);
const newRaw = randomBytes(32);
try {
const hadKey = heldKey !== null;
const newProof = await mainProof(newRaw);
if (member) {
await letMainGo(await mainProof(oldRaw));
if (!(await member.enroll(newProof))) throw new Error('The desktop app refused the lock');
}
await rotateVaultKey(newPin, oldRaw, newRaw);
// Setting the new PIN went through main's count too.
await member?.prove(newProof).catch(() => false);
const key = await importLockKey(newRaw);
await askShell();
await moveFromPreviousKey(key);
if (!hadKey) {
// Another tab holds the old key: everyone starts over with the new one.
lockEverywhere();
return { reenroll: hadOther };
}
heldKey = key;
rememberMainProof(newProof);
void storeBackend().rekeyPending?.();
return { reenroll: hadOther };
} finally {
oldRaw.fill(0);
newRaw.fill(0);
}
}
export function setAppLockTimeout(minutes: number): void {
@@ -972,9 +1050,10 @@ export function resetAppLockForTests(): void {
stopIdle?.();
stopIdle = null;
stopStay?.();
controls = undefined;
resetShellForTests();
enabling = false;
shellWraps = false;
shellAsked = false;
rememberMainProof(null);
listeners.clear();
setLockedHere(false);
+19
View File
@@ -18,6 +18,7 @@ export const MIN_PIN_LENGTH = 6;
const AES = { name: 'AES-GCM', length: 256 } as const;
const PRF_INFO = new TextEncoder().encode('bulwark-app-lock-prf-v1');
const PROOF_INFO = new TextEncoder().encode('bulwark-app-lock-main-proof-v1');
const PIN_STEP_INFO = new TextEncoder().encode('bulwark-app-lock-pin-step-v1');
export interface WrappedBytes {
iv: Uint8Array;
@@ -89,6 +90,24 @@ export async function keyFromPin(pin: string, salt: Uint8Array, iterations: numb
);
}
/** What goes to main for a PIN guess: the slow part, done here. Nobody needs to see the PIN itself. */
export async function bitsFromPin(pin: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
const base = await crypto.subtle.importKey('raw', buffer(pinBytes(pin)), 'PBKDF2', false, ['deriveBits']);
return new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: buffer(salt), iterations }, base, 256));
}
/** The key under the PIN from what main made of a guess, with a secret only it has. */
export async function keyFromPinStep(bytes: Uint8Array): Promise<CryptoKey> {
const base = await crypto.subtle.importKey('raw', buffer(bytes), 'HKDF', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new ArrayBuffer(0), info: PIN_STEP_INFO },
base,
AES,
false,
['encrypt', 'decrypt'],
);
}
/** A key from what an authenticator's PRF gave: 32 bytes that only it can make again. */
export async function keyFromPrf(output: ArrayBuffer | Uint8Array): Promise<CryptoKey> {
const bytes = output instanceof Uint8Array ? output : new Uint8Array(output);
+51
View File
@@ -0,0 +1,51 @@
/**
* The calls only the page itself may make to the desktop app's main process. A same-origin frame
* reaches `bulwarkDesktop` through `parent`, and main can't tell its calls from the page's. So the
* preload hands these out once per document, and the page takes them in its first render, before
* any frame exists. Imports next to nothing, for lib/app-lock/vault.ts and controller.ts alike.
*/
import { getDesktopBridge } from '@/lib/desktop/bridge';
import type { DesktopAppLockBridge, DesktopAppLockControls } from '@/lib/desktop/types';
let claimedFrom: DesktopAppLockBridge | null = null;
let claimed: DesktopAppLockControls | null = null;
export function shellControls(): DesktopAppLockControls | null {
const member = getDesktopBridge()?.appLock ?? null;
if (member !== claimedFrom) {
claimedFrom = member;
claimed = member?.claim?.() ?? null;
}
return claimed;
}
/** A shell older than the webmail it loads has these on the bridge. */
function olderShell(): Pick<DesktopAppLockControls, 'wrap' | 'unwrap'> {
return (getDesktopBridge()?.appLock ?? {}) as unknown as Pick<DesktopAppLockControls, 'wrap' | 'unwrap'>;
}
export function shellWrapCall(): DesktopAppLockControls['wrap'] {
return shellControls()?.wrap ?? olderShell().wrap;
}
export function shellUnwrapCall(): DesktopAppLockControls['unwrap'] {
return shellControls()?.unwrap ?? olderShell().unwrap;
}
/**
* Whether this computer has a real key store the app can use. No: a browser, or Linux with no secret
* service, and settings say so. A shell that doesn't answer isn't a no: this rejects, so nobody
* writes a key without the layer because of it.
*/
export async function shellHasWrap(): Promise<boolean> {
const member = getDesktopBridge()?.appLock;
if (!member || !shellWrapCall()) return false;
return Boolean((await member.status()).wrap);
}
/** Tests only. */
export function resetShellForTests(): void {
claimedFrom = null;
claimed = null;
}
+92 -29
View File
@@ -9,16 +9,19 @@
* 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.
* In the desktop app the PIN's wrap goes through main's safeStorage as well, and the key under
* the PIN takes a secret only main has, one counted trip per guess. So neither a copy of the disk
* without this user's OS login nor script in this page's storage can guess at it elsewhere. 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, which settings
* say too. Main refusing is another thing: that stops the setup rather than go on without.
*/
import { VAULT_DB } from './state';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { shellControls, shellHasWrap, shellUnwrapCall, shellWrapCall } from './shell';
import { sealFor, createSessionOpener, type SealedAnswer } from '@/lib/popout/sealed';
import { keyFromPin, keyFromPrf, ObviousPinError, open, PBKDF2_ITERATIONS, pinIsObvious, randomBytes, seal, type WrappedBytes } from './crypto';
import { bitsFromPin, keyFromPin, keyFromPinStep, keyFromPrf, mainProof, ObviousPinError, open, PBKDF2_ITERATIONS, pinIsObvious, randomBytes, seal, type WrappedBytes } from './crypto';
export { VAULT_DB };
const DB_VERSION = 1;
@@ -32,6 +35,8 @@ export interface PinWrap extends WrappedBytes {
iterations: number;
/** `data` is wrapped once more by the desktop app's main process. */
shell?: true;
/** The key takes main's secret as well: each guess is a trip through main. */
pepper?: true;
}
export interface PrfWrap extends WrappedBytes {
@@ -46,6 +51,11 @@ export interface VaultRecord {
prf?: PrfWrap;
/** For sealing drafts in tabs with no key: anyone may seal, only the lock key opens. */
sealing: { publicKey: JsonWebKey; privateKey: WrappedBytes };
/**
* The lock key before a changed PIN, under the new one, until every store key has moved over.
* Gone again in the same breath, unless the app stopped right then.
*/
previous?: WrappedBytes;
}
export interface AttemptsRecord {
@@ -131,27 +141,41 @@ export class VaultShellError extends Error {
}
}
async function hasShell(): Promise<boolean> {
return shellHasWrap().catch(() => {
throw new VaultShellError();
});
}
/** With no key store on this computer it stays as it is. A store that's there and says no stops here. */
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 };
if (!(await hasShell())) return { data };
const wrapped = await shellWrapCall()?.(data).catch(() => null);
if (!wrapped) throw new VaultShellError();
return { data: wrapped, shell: true };
}
/** One guess at the PIN through main, which counts it. */
async function keyThroughMain(pin: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
const answer = await shellControls()?.pinStep?.(await bitsFromPin(pin, salt, iterations)).catch(() => null);
if (answer?.ok) return keyFromPinStep(answer.bytes);
if (answer?.blockedUntil) throw new PinBlockedError(answer.blockedUntil);
throw new VaultShellError();
}
async function outOfShell(pin: PinWrap): Promise<Uint8Array> {
if (!pin.shell) return pin.data;
const data = await getDesktopBridge()?.appLock?.unwrap?.(pin.data).catch(() => null);
const data = await shellUnwrapCall()?.(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);
const pepper = Boolean(shellControls()?.pinStep) && await hasShell();
const key = pepper ? await keyThroughMain(pin, salt, PBKDF2_ITERATIONS) : await keyFromPin(pin, salt, PBKDF2_ITERATIONS);
const sealed = await seal(key, raw);
return { salt, iterations: PBKDF2_ITERATIONS, iv: sealed.iv, ...(await throughShell(sealed.data)) };
return { salt, iterations: PBKDF2_ITERATIONS, iv: sealed.iv, ...(await throughShell(sealed.data)), ...(pepper ? { pepper: true as const } : {}) };
}
/** A new lock key under this PIN. The caller holds the bytes only long enough to use them. */
@@ -212,31 +236,70 @@ 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.
// Before the count: main saying no costs nobody a try. With main's secret it counts there too.
const data = await outOfShell(vault.pin);
const fromMain = vault.pin.pepper ? await keyThroughMain(pin, vault.pin.salt, vault.pin.iterations) : null;
const failures = attempts.failures + 1;
await writeRecord({ id: ATTEMPTS_ID, failures, blockedUntil: now + waitAfter(failures) });
let raw: Uint8Array;
try {
const key = await keyFromPin(pin, vault.pin.salt, vault.pin.iterations);
const raw = await open(key, { iv: vault.pin.iv, data });
await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 });
return raw;
const key = fromMain ?? await keyFromPin(pin, vault.pin.salt, vault.pin.iterations);
raw = await open(key, { iv: vault.pin.iv, data });
} catch {
throw new WrongPinError();
}
await writeRecord({ id: ATTEMPTS_ID, failures: 0, blockedUntil: 0 });
await peppered(vault, pin, raw);
return raw;
}
/** 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();
if (!vault) throw new WrongPinError();
await writeRecord({ ...vault, pin: await pinWrapOf(newPin, raw) });
} finally {
raw.fill(0);
/** Main clears its count on the proof, and a record from before gets its secret now. */
async function peppered(vault: VaultRecord, pin: string, raw: Uint8Array): Promise<void> {
if (!shellControls()?.pinStep) return;
if (!vault.pin.pepper) {
try {
await writeRecord({ ...vault, pin: await pinWrapOf(pin, raw) });
} catch {
// Tried again at the next right PIN.
}
}
await getDesktopBridge()?.appLock?.prove(await mainProof(raw)).catch(() => false);
}
/**
* A new PIN takes a new lock key. The record under the old PIN can stay in the browser's own files
* long after it was overwritten, and with the same key under it the old PIN would go on opening
* everything. A passkey's wrap is under the old key and only its authenticator could make a new
* one, so it goes. The old key waits under the new one for the store keys to move over.
*/
export async function rotateVaultKey(newPin: string, oldRaw: Uint8Array, newRaw: Uint8Array = randomBytes(32)): Promise<Uint8Array> {
if (pinIsObvious(newPin)) throw new ObviousPinError();
const vault = await readVault();
if (!vault) throw new WrongPinError();
const importAes = (raw: Uint8Array) => crypto.subtle.importKey('raw', raw.slice().buffer as ArrayBuffer, 'AES-GCM', false, ['encrypt', 'decrypt']);
const oldKey = await importAes(oldRaw);
const newKey = await importAes(newRaw);
const { prf: _gone, ...rest } = vault;
await writeRecord({
...rest,
pin: await pinWrapOf(newPin, newRaw),
sealing: { publicKey: vault.sealing.publicKey, privateKey: await seal(newKey, await open(oldKey, vault.sealing.privateKey)) },
previous: await seal(newKey, oldRaw),
});
return newRaw;
}
/** The lock key from before a changed PIN, if the store keys haven't all moved over. */
export async function openVaultPrevious(lockKey: CryptoKey): Promise<Uint8Array | null> {
const vault = await readVault();
return vault?.previous ? open(lockKey, vault.previous) : null;
}
export async function clearVaultPrevious(): Promise<void> {
const vault = await readVault();
if (!vault?.previous) return;
const { previous: _done, ...rest } = vault;
await writeRecord(rest);
}
/** The passkey's way in. `prfOutput` is what the authenticator gave for `salt`. */
+16 -4
View File
@@ -355,10 +355,25 @@ export type DesktopOsUnlockAnswer = { ok: true; key: Uint8Array } | { ok: false;
* 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.
*/
/** A PIN guess through main. Blocked: too many wrong ones. Neither: main can't open its secret here. */
export type DesktopPinStepAnswer = { ok: true; bytes: Uint8Array } | { ok: false; blockedUntil?: number };
export interface DesktopAppLockControls {
lock: () => void;
osUnlock: () => Promise<DesktopOsUnlockAnswer>;
/** Through safeStorage, for this origin only. Null where the OS has no real key store. Absent on an older shell. */
wrap?: (data: Uint8Array) => Promise<Uint8Array | null>;
unwrap?: (data: Uint8Array) => Promise<Uint8Array | null>;
/** The key under the PIN takes a secret only main has, and every trip is counted there. */
pinStep?: (data: Uint8Array) => Promise<DesktopPinStepAnswer>;
/** Ends this page's renderer and loads the page in a new one. Only while main says locked. */
replaceRenderer?: () => void;
}
export interface DesktopAppLockBridge {
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;
claim: () => DesktopAppLockControls | null;
/** Sets up the proof. False when one exists. */
enroll: (proof: Uint8Array) => Promise<boolean>;
/** Lifts the lock when the proof is the right one. */
@@ -367,9 +382,6 @@ export interface DesktopAppLockBridge {
disable: (proof: Uint8Array) => 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>;
+38
View File
@@ -1261,6 +1261,44 @@ describe('what else goes with a wipe', () => {
});
});
describe('the HTTP cache in the desktop app', () => {
it('goes with a wipe, also of one account with another still signed in', async () => {
const clear = vi.fn(async () => {});
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone', httpCache: { clear } };
useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never);
try {
await wipe.wipeAccountFromDevice(ALICE, 'user');
expect(clear).toHaveBeenCalled();
} finally {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
}
});
});
describe('the 30 days', () => {
it('run out in an app that stays open too', async () => {
const offline = await import('../offline-mode');
const stop = offline.startOfflineMode();
try {
state.markEvicted(ALICE, Date.now() - 31 * 24 * 60 * 60 * 1000);
useAccountStore.setState({ accounts: [] } as never);
const before = purge.files;
expect(offline.EVICTED_SWEEP_MS).toBeLessThanOrEqual(60 * 60 * 1000);
const spy = vi.spyOn(globalThis, 'setInterval');
stop();
const again = offline.startOfflineMode();
const tick = spy.mock.calls.find((call) => call[1] === offline.EVICTED_SWEEP_MS)?.[0] as (() => void) | undefined;
expect(tick).toBeTypeOf('function');
spy.mockRestore();
tick!();
await vi.waitFor(() => expect(purge.files).toBeGreaterThan(before));
again();
} finally {
stop();
}
});
});
describe('a shell that never answers about shown notifications', () => {
// Electron has no answer to getNotifications(): the promise stays pending for good.
beforeEach(() => {
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { checkStoreOwner, markEvicted, isEvicted, onStoreOwnerChanged, readStoreOwners, useOfflineStore } from '../state';
const ACCOUNT = 'alice@example.org@mail.example.test';
const SERVER = 'https://mail.example.test';
describe('who signs in to a store that waits', () => {
let wiped: string[];
let stop: () => void;
beforeEach(() => {
localStorage.clear();
wiped = [];
useOfflineStore.setState({ enabled: [ACCOUNT] });
stop = onStoreOwnerChanged(async (account) => { wiped.push(account); });
});
afterEach(() => {
stop();
useOfflineStore.setState({ enabled: [] });
vi.restoreAllMocks();
});
it('the same account: nothing happens, and it is kept for next time', async () => {
await checkStoreOwner(ACCOUNT, 'ee', SERVER);
markEvicted(ACCOUNT);
await checkStoreOwner(ACCOUNT, 'ee', SERVER);
expect(wiped).toEqual([]);
expect(readStoreOwners()[ACCOUNT]).toEqual({ jmap: 'ee', server: SERVER });
});
it('another account under the same address: the old one\'s data goes before anyone gets its key', async () => {
await checkStoreOwner(ACCOUNT, 'ee', SERVER);
markEvicted(ACCOUNT);
await checkStoreOwner(ACCOUNT, 'ef', SERVER);
expect(wiped).toEqual([ACCOUNT]);
expect(readStoreOwners()[ACCOUNT]).toEqual({ jmap: 'ef', server: SERVER });
});
it('another server on the same host name, at another port', async () => {
await checkStoreOwner(ACCOUNT, 'ee', SERVER);
markEvicted(ACCOUNT);
await checkStoreOwner(ACCOUNT, 'ee', 'https://mail.example.test:8443');
expect(wiped).toEqual([ACCOUNT]);
});
it('nothing known from before: nothing to compare, and it is noted now', async () => {
markEvicted(ACCOUNT);
await checkStoreOwner(ACCOUNT, 'ee', SERVER);
expect(wiped).toEqual([]);
expect(isEvicted(ACCOUNT)).toBe(true);
expect(readStoreOwners()[ACCOUNT]?.jmap).toBe('ee');
});
it('a different id with nothing evicted is left alone: only a store nobody vouched for is at stake', async () => {
await checkStoreOwner(ACCOUNT, 'ee', SERVER);
await checkStoreOwner(ACCOUNT, 'ef', SERVER);
expect(wiped).toEqual([]);
});
});
+2 -1
View File
@@ -209,11 +209,12 @@ async function clearShellFiles(): Promise<void> {
const bridge = getDesktopBridge() as unknown as {
os?: { clearSurfaces?: () => void };
clipboard?: { clearFiles?: () => void | Promise<void> };
httpCache?: { clear?: () => Promise<void> };
} | null;
bridge?.os?.clearSurfaces?.();
let timer: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
Promise.resolve(bridge?.clipboard?.clearFiles?.()),
Promise.all([bridge?.clipboard?.clearFiles?.(), bridge?.httpCache?.clear?.()]),
new Promise((resolve) => { timer = setTimeout(resolve, SHELL_FILES_BUDGET_MS); }),
]).finally(() => clearTimeout(timer));
}
+28 -2
View File
@@ -22,7 +22,7 @@
* messages a sign-out deletes without the user's word are named (lost.ts).
*/
import { appLockKeyLayers, onStoreKeyMissing } from '@/lib/app-lock/controller';
import { onStoreKeyMissing, storeKeyLayers } from '@/lib/app-lock/controller';
import { IS_LITE } from '@/lib/lite';
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
import { devicesForAccount, devicesForNoOne, signedInAccountIds } from '@/lib/device-policy';
@@ -63,6 +63,7 @@ import { clearHandoffs } from '@/lib/launch-handoff';
import {
clearEvicted,
EVICTED_KEEP_MS,
onStoreOwnerChanged,
forgetAccountState,
forgetAllAccountState,
forgetMailSnapshot,
@@ -109,6 +110,8 @@ export type OfflineNotice =
| { kind: 'draft-conflict'; subject: string }
| { kind: 'storage-failed' }
| { kind: 'store-unavailable' }
| { kind: 'key-unprotected' }
| { kind: 'rekey-failed' }
| { kind: 'store-reset'; lost?: number }
| { kind: 'legacy-stranded' }
| { kind: 'default-on' }
@@ -222,7 +225,16 @@ function noteStoreUnavailable(): void {
}
const storeHooks: StorePageHooks = {
keyLayers: appLockKeyLayers,
keyLayers: storeKeyLayers,
onKeyWrapFailed() {
if (useOfflineStore.getState().keyUnprotected) return;
useOfflineStore.setState({ keyUnprotected: true });
notify({ kind: 'key-unprotected' });
},
onRekeyFailed(account) {
useOfflineStore.setState((s) => ({ rekey: { ...s.rekey, [account]: 'failed' } }));
notify({ kind: 'rekey-failed' });
},
onKeyLocked: onStoreKeyMissing,
inputs(account) {
const client = onlineClientFor(account);
@@ -276,6 +288,12 @@ const storeHooks: StorePageHooks = {
// A pass also sends what the calendar and contacts have waiting.
void refreshQueueState();
}
if (event.type === 'rekey') {
useOfflineStore.setState((s) => {
const { [event.account]: _before, ...rest } = s.rekey;
return { rekey: event.percent >= 100 ? rest : { ...rest, [event.account]: event.percent } };
});
}
if (event.type === 'progress') {
useOfflineStore.setState((s) => ({ progress: { ...s.progress, [event.account]: nextProgress(s.progress[event.account], event, Date.now()) } }));
}
@@ -1245,6 +1263,7 @@ const cleanups: Array<() => void> = [];
export function registerOfflineHooks(): void {
if (!offlineSupported()) return;
configureStore(storeHooks);
onStoreOwnerChanged((account) => wipeAccount(account));
setOfflineHooks(hooks);
setOutboxHooks(outbox);
void moveLegacyOutbox();
@@ -1335,6 +1354,9 @@ export function startOfflineMode(): () => void {
if (reason === 'explicit') void wipeAccount(id);
}));
void sweepAccountsGone();
// Also for an app that stays open past the 30 days.
const sweeping = setInterval(() => void sweepAccountsGone(), EVICTED_SWEEP_MS);
cleanups.push(() => clearInterval(sweeping));
void checkStorageAccess();
// Session, then a sync, for every account signed in with offline mode on.
@@ -1403,6 +1425,9 @@ export function startOfflineMode(): () => void {
};
}
/** How often a running app looks whether a dropped account's 30 days are over. */
export const EVICTED_SWEEP_MS = 60 * 60 * 1000;
/**
* Data no signed-in account vouches for. A dropped account's waits 30 days. One
* that left the registry on purpose before this ran (a login without "remember
@@ -1465,6 +1490,7 @@ onFullSignOut(({ keepLocalStore }) => {
health: keepLocalStore ? s.health : {},
outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, usage: {},
storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false,
keyUnprotected: false, rekey: {},
}));
});
+63
View File
@@ -69,6 +69,10 @@ interface OfflineState {
startedOffline: boolean;
/** The browser won't hand out the private file system here: a private window, say. */
storageBlocked: boolean;
/** This computer's key store is there and wouldn't take a store key. Settings say so. */
keyUnprotected: boolean;
/** Re-encryptions: how far each got, or that it didn't finish. */
rekey: Record<string, number | 'failed'>;
/**
* Accounts whose store came back empty since this page loaded: the browser
* cleared their key or file. Whatever warns about eviction can go by this.
@@ -137,6 +141,8 @@ export const useOfflineStore = create<OfflineState>()(() => ({
startedOffline: false,
storeLost: [],
storageBlocked: false,
keyUnprotected: false,
rekey: {},
outbox: [],
pendingChanges: 0,
conflicts: [],
@@ -254,6 +260,63 @@ export function clearEvicted(account: string): void {
writeEvicted(evicted);
}
/**
* localStorage: the JMAP account and the server each store belongs to. The app's account id is only
* `username@host`, and an address can move to another person, so that alone doesn't vouch for a
* store that waits for a sign-in.
*/
export const OFFLINE_OWNERS_KEY = 'bulwark-offline-owners';
export interface StoreOwner {
jmap: string;
server: string;
}
export function readStoreOwners(): Record<string, StoreOwner> {
return readRecord<StoreOwner>(OFFLINE_OWNERS_KEY);
}
let storeOwnerChanged: ((account: string) => Promise<void>) | null = null;
/** Lib/offline/offline-mode.ts takes the data away. No Lite, no store: nothing does. */
export function onStoreOwnerChanged(listener: (account: string) => Promise<void>): () => void {
storeOwnerChanged = listener;
return () => {
if (storeOwnerChanged === listener) storeOwnerChanged = null;
};
}
/**
* A sign-in, once the server has said who this is and before the account is added. A store waiting
* for this address that belongs to another JMAP account or another server isn't theirs: an address
* can be given to someone new, and a host can run a second server on another port. Its data goes
* first, and they start with nothing. The id itself doesn't include the port, because every store,
* setting and server-side record of an existing account is keyed by it.
*/
export async function checkStoreOwner(account: string, jmap: string, serverUrl: string): Promise<void> {
const known = readStoreOwners()[account];
const server = originOf(serverUrl);
if (known && isEvicted(account) && (known.jmap !== jmap || known.server !== server)) await storeOwnerChanged?.(account);
if (known?.jmap !== jmap || known?.server !== server) {
writeRecord(OFFLINE_OWNERS_KEY, { ...readStoreOwners(), [account]: { jmap, server } });
}
}
export function forgetStoreOwner(account: string): void {
const owners = readStoreOwners();
if (!(account in owners)) return;
delete owners[account];
writeRecord(OFFLINE_OWNERS_KEY, owners);
}
function originOf(url: string): string {
try {
return new URL(url).origin;
} catch {
return url.replace(/\/+$/, '').toLowerCase();
}
}
/** localStorage: who a dropped account was, only in the desktop app: its plugin secrets are on this device's server, under this. */
export const OFFLINE_EVICTED_OWNERS_KEY = 'bulwark-offline-evicted-owners';
+1
View File
@@ -42,6 +42,7 @@ const SIGNED_OUT_STORAGE_KEYS = [
// Which accounts' data waits for a new sign-in, and until when.
'bulwark-offline-evicted',
'bulwark-offline-evicted-owners',
'bulwark-offline-owners',
'bulwark-store-opened',
// What each account chose to keep, and what's known about its store.
'bulwark-offline-scope',
+84 -1
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { DEFAULT_SCOPE } from '../area';
import { mailArea } from '../mail';
import type { StorageAdapter } from '../storage';
import { StoreHost, type HostStorage } from '../worker/host';
import { StoreHost, type HostStorage, type StoreCopy } from '../worker/host';
import { FAKE_SESSION } from './helpers/fake-jmap';
import { openMemoryStore } from './helpers/node-adapter';
@@ -51,6 +51,89 @@ describe('opening an account', () => {
});
});
describe('re-encrypting an account', () => {
class FakeCopy implements StoreCopy {
left = 500;
total = 500;
steps: number[] = [];
log: string[] = [];
aborted = false;
finished = false;
failOnStep = 0;
step(pages: number): 'more' | 'done' | 'busy' {
this.steps.push(pages);
if (this.failOnStep && this.steps.length === this.failOnStep) throw new Error('The disk is full');
if (pages < 0) {
this.log.push('last step');
this.left = 0;
return 'done';
}
this.left -= pages;
if (this.left <= 0) {
this.left = 0;
return 'done';
}
return 'more';
}
remaining(): number { return this.left; }
pageCount(): number { return this.total; }
finish(): void { this.finished = true; this.log.push('finish'); }
abort(): void { this.aborted = true; this.log.push('abort'); }
}
class CopyingStorage extends FakeStorage {
copy = new FakeCopy();
started: Array<{ file: string; keyHex: string }> = [];
async startCopy(_source: StorageAdapter, file: string, keyHex: string): Promise<StoreCopy> {
this.started.push({ file, keyHex });
return this.copy;
}
}
const hostWith = (storage: CopyingStorage, events: unknown[] = []) => new StoreHost({
areas: [mailArea], storage, emit: (event) => events.push(event), needAuth: () => {},
});
const NEXT = 'd'.repeat(64);
it('copies in small steps, stops the engine before the last pages, and leaves the account closed', async () => {
const storage = new CopyingStorage();
const events: Array<{ type: string; percent?: number }> = [];
const running = hostWith(storage, events);
await running.openAccount(params('/a1.db'));
await running.rekey('a', '/a2.db', NEXT);
expect(storage.started).toEqual([{ file: '/a2.db', keyHex: NEXT }]);
// Never to the end while the engine runs: a write after the copy's end would be lost.
expect(storage.copy.steps.slice(0, -1).every((pages) => pages > 0)).toBe(true);
expect(storage.copy.steps.at(-1)).toBe(-1);
expect(storage.copy.log).toEqual(['last step', 'finish']);
const percents = events.filter((event) => event.type === 'rekey').map((event) => event.percent);
expect(percents.at(-1)).toBe(100);
expect(percents.length).toBeGreaterThan(1);
await expect(running.call('answer', { account: 'a', calls: [] })).rejects.toMatchObject({ name: 'StoreAccountClosedError' });
});
it('a failure on the way leaves the account open on its old file, and takes the copy away', async () => {
const storage = new CopyingStorage();
storage.copy.failOnStep = 3;
const running = hostWith(storage);
await running.openAccount(params('/a1.db'));
await expect(running.rekey('a', '/a2.db', NEXT)).rejects.toThrow('The disk is full');
expect(storage.copy.aborted).toBe(true);
await expect(running.call('status', { account: 'a' })).resolves.toMatchObject({ open: true });
});
it('closing the account for a lock stops the copy first: a database under a copy can\'t close', async () => {
const storage = new CopyingStorage();
const running = hostWith(storage);
await running.openAccount(params('/a1.db'));
const copying = running.rekey('a', '/a2.db', NEXT);
await new Promise((resolve) => setTimeout(resolve, 0));
await running.closeAll();
await expect(copying).rejects.toMatchObject({ name: 'StoreRekeyStoppedError' });
expect(storage.copy.log).toEqual(['abort']);
});
});
describe('removing an account', () => {
it('tells every tab, once the file is gone', async () => {
const storage = new FakeStorage();
+108
View File
@@ -0,0 +1,108 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it } from 'vitest';
import { deleteKeyDatabase, getAccountRecord, openAccountKey, replaceAccountKey, rewrapAccountKey, type WrapLayer } from '../client/keys';
/** A layer that shows on the bytes: a marker in front. */
function layer(name: string, marker: number): WrapLayer {
return {
name,
wrap: async (data) => Uint8Array.from([marker, ...data]),
unwrap: async (data) => {
if (data[0] !== marker) throw new Error('not mine');
return data.slice(1);
},
};
}
const broken: WrapLayer = {
name: 'safeStorage',
wrap: async () => { throw new Error('The OS said no'); },
unwrap: async () => { throw new Error('The OS said no'); },
};
const shell = layer('safeStorage', 7);
const lock = layer('applock', 8);
beforeEach(async () => {
await deleteKeyDatabase();
});
describe('a record with fewer layers than this app now puts on', () => {
it('gets them at its next open, keeps its key, and asks for the mail to be re-encrypted', async () => {
const { keyHex } = await openAccountKey('a');
const opened = await openAccountKey('a', [shell]);
expect(opened.keyHex).toBe(keyHex);
expect(opened.wrapFailed).toBeFalsy();
const stored = await getAccountRecord('a');
expect(stored?.layers).toEqual(['safeStorage']);
expect(stored?.wrapped[0]).toBe(7);
// The record before this still sits in the browser's own files: that key has to go.
expect(stored?.rekey).toBe(true);
expect((await openAccountKey('a', [shell])).keyHex).toBe(keyHex);
});
it('a new record has what it needs from the start, and nothing to re-encrypt', async () => {
await openAccountKey('a', [shell]);
const stored = await getAccountRecord('a');
expect(stored?.layers).toEqual(['safeStorage']);
expect(stored?.rekey).toBeUndefined();
});
it("says so when the OS won't take it, and still opens what's there", async () => {
const { keyHex } = await openAccountKey('a');
const opened = await openAccountKey('a', [broken]);
expect(opened.keyHex).toBe(keyHex);
expect(opened.wrapFailed).toBe(true);
expect((await getAccountRecord('a'))?.layers).toEqual([]);
});
it("won't make a new record without the layer", async () => {
await expect(openAccountKey('a', [broken])).rejects.toThrow('The OS said no');
expect(await getAccountRecord('a')).toBeNull();
});
it('a layer added under the lock goes in the right order', async () => {
const { keyHex } = await openAccountKey('a', [lock]);
const opened = await openAccountKey('a', [shell, lock]);
expect(opened.keyHex).toBe(keyHex);
const stored = await getAccountRecord('a');
expect(stored?.layers).toEqual(['safeStorage', 'applock']);
expect(stored?.wrapped[0]).toBe(8);
expect(stored?.wrapped[1]).toBe(7);
});
});
describe('re-wrapping', () => {
it('more layers ask for a re-encryption, fewer do not', async () => {
await openAccountKey('a', [shell]);
await rewrapAccountKey('a', [shell], [shell, lock]);
expect((await getAccountRecord('a'))?.rekey).toBe(true);
await replaceAccountKey('a', 'b'.repeat(64), '/anew.db', [shell, lock]);
await rewrapAccountKey('a', [shell, lock], [shell]);
expect((await getAccountRecord('a'))?.rekey).toBeUndefined();
});
it('can be told to, for a new lock key: the old one may still sit on disk', async () => {
await openAccountKey('a', [lock]);
const lock2 = layer('applock', 9);
await rewrapAccountKey('a', [lock], [lock2], { rekey: true });
expect((await getAccountRecord('a'))?.rekey).toBe(true);
});
});
describe('a new key and file', () => {
it('replaces the record whole, under the layers asked for', async () => {
const before = await openAccountKey('a', [shell]);
await rewrapAccountKey('a', [shell], [shell, lock]);
const next = 'c'.repeat(64);
await replaceAccountKey('a', next, '/anew.db', [shell, lock]);
const stored = await getAccountRecord('a');
expect(stored?.file).toBe('/anew.db');
expect(stored?.rekey).toBeUndefined();
expect(stored?.layers).toEqual(['safeStorage', 'applock']);
const opened = await openAccountKey('a', [shell, lock]);
expect(opened.keyHex).toBe(next);
expect(opened.keyHex).not.toBe(before.keyHex);
expect(opened.record.file).toBe('/anew.db');
});
});
+88
View File
@@ -74,6 +74,94 @@ describe('whether the store can run here', () => {
});
});
describe('re-encrypting a store', () => {
function ownerWithWorker() {
const failed: string[] = [];
configureStore({
inputs: () => ({ session: FAKE_SESSION, auth: 'x', invertedKeywordSort: null }),
refreshAuth: async () => null,
onEvent: () => {},
onRekeyFailed: (account) => failed.push(account),
});
Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async () => {} } });
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => ({}) } });
vi.stubGlobal('FileSystemFileHandle', class {});
return { failed };
}
afterEach(() => {
delete (window.navigator as { storage?: unknown }).storage;
delete (window.navigator as { locks?: unknown }).locks;
});
const settle = (worker: FakeWorker, kind: string, error?: string) => {
const message = worker.posted.find((entry) => entry.kind === kind && !(entry as { answered?: boolean }).answered);
if (!message) return false;
(message as { answered?: boolean }).answered = true;
worker.onmessage?.({ data: { kind: 'result', id: message.id, error } } as MessageEvent);
return true;
};
it('a record that asks for it gets a new key and file once the copy is done, and the old file goes', async () => {
ownerWithWorker();
const { openAccountKey, rewrapAccountKey } = await import('../client/keys');
const before = await openAccountKey('rk1');
await rewrapAccountKey('rk1', [], [], { rekey: true });
const store = getStore();
const opening = store.ensureOpen('rk1', FAKE_SESSION);
FakeWorker.all[0].becomeOwner();
await opening;
const worker = FakeWorker.all[0];
await vi.waitFor(() => expect(worker.posted.some((entry) => entry.kind === 'rekey')).toBe(true));
const asked = worker.posted.find((entry) => entry.kind === 'rekey') as { file: string; keyHex: string; account: string };
expect(asked.account).toBe('rk1');
expect(asked.file).not.toBe(before.record.file);
expect(asked.keyHex).not.toBe(before.keyHex);
// Nothing changes on disk until the copy is whole.
expect((await getAccountRecord('rk1'))?.file).toBe(before.record.file);
settle(worker, 'rekey');
await vi.waitFor(async () => expect((await getAccountRecord('rk1'))?.file).toBe(asked.file));
const stored = await getAccountRecord('rk1');
expect(stored?.rekey).toBeUndefined();
await vi.waitFor(() => {
const prunes = worker.posted.filter((entry) => entry.kind === 'prune-files') as unknown as Array<{ keep: string[] }>;
expect(prunes.at(-1)?.keep).toContain(asked.file);
expect(prunes.at(-1)?.keep).not.toContain(before.record.file);
});
// It opens again, on the new file, with the new key.
await vi.waitFor(() => {
const opens = worker.posted.filter((entry) => entry.kind === 'open') as unknown as Array<{ params: { file: string; keyHex: string } }>;
expect(opens.at(-1)?.params).toMatchObject({ file: asked.file, keyHex: asked.keyHex });
});
store.stop();
await forgetAccountKey('rk1');
});
it('a failed copy keeps the old key and file, says so, and asks to be tried again', async () => {
const { failed } = ownerWithWorker();
const { openAccountKey, rewrapAccountKey } = await import('../client/keys');
const before = await openAccountKey('rk2');
await rewrapAccountKey('rk2', [], [], { rekey: true });
const store = getStore();
const opening = store.ensureOpen('rk2', FAKE_SESSION);
FakeWorker.all[0].becomeOwner();
await opening;
const worker = FakeWorker.all[0];
await vi.waitFor(() => expect(worker.posted.some((entry) => entry.kind === 'rekey')).toBe(true));
settle(worker, 'rekey', 'The disk is full');
await vi.waitFor(() => expect(failed).toEqual(['rk2']));
const stored = await getAccountRecord('rk2');
expect(stored?.file).toBe(before.record.file);
expect(stored?.rekey).toBe(true);
// It opens again on the old file, and doesn't try again until the next start.
await vi.waitFor(() => expect(worker.posted.filter((entry) => entry.kind === 'open').length).toBe(2));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(worker.posted.filter((entry) => entry.kind === 'rekey').length).toBe(1);
store.stop();
await forgetAccountKey('rk2');
});
});
describe('a tab that starts after the owner', () => {
it('asks who owns the store, so its calls don\'t wait for an announcement that never comes', async () => {
const owner = new FakeChannel('bulwark-store-rpc');
+49 -2
View File
@@ -6,13 +6,15 @@
* - PRAGMA key is the very first thing on a connection. Reading first deletes
* a hot journal (#292).
* - The page size must be 8192. On 4096, rollback doesn't work (#293).
* - No PRAGMA rekey (#291, #294). The key is random and only ever re-wrapped.
* - No PRAGMA rekey (#291, #294). A new key means a new file: sqlite3_backup into a database
* opened under the new key (`startCopy`), which leaves the old file whole until it's done.
* - Always the multipleciphers VFS. On plain opfs-sahpool, PRAGMA key throws
* and the connection still writes in the clear if that's ignored.
*/
import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../storage';
import { POOL_DIRECTORY } from '../worker/protocol';
import type { StoreCopy } from '../worker/host';
const VFS = 'multipleciphers-opfs-sahpool';
const INIT_TIMEOUT_MS = 30_000;
@@ -61,6 +63,10 @@ class WasmAdapter implements StorageAdapter {
constructor(private readonly sqlite3: any, private readonly db: any) {}
get pointer(): number {
return this.db.pointer;
}
exec(sql: string): void {
this.db.exec(sql);
}
@@ -156,6 +162,47 @@ export class WasmStorage {
/** Opens one account's database. `keyHex` is 32 random bytes in hex. */
async open(file: string, keyHex: string): Promise<StorageAdapter> {
return new WasmAdapter(this.sqlite3, await this.openDb(file, keyHex));
}
/** Into a database opened under its own key. The source stays usable, and its writes reach the copy. */
async startCopy(source: StorageAdapter, file: string, keyHex: string): Promise<StoreCopy> {
const capi = this.sqlite3.capi;
const dst = await this.openDb(file, keyHex);
const backup = capi.sqlite3_backup_init(dst.pointer, 'main', (source as WasmAdapter).pointer, 'main');
if (!backup) {
const message = capi.sqlite3_errmsg(dst.pointer);
dst.close();
this.remove(file);
throw new Error(`The copy did not start: ${message}`);
}
let open = true;
const end = () => {
if (!open) return;
open = false;
capi.sqlite3_backup_finish(backup);
dst.close();
};
return {
step: (pages) => {
const rc = capi.sqlite3_backup_step(backup, pages);
if (rc === 0) return 'more';
if (rc === 101) return 'done';
// SQLITE_BUSY, SQLITE_LOCKED: the source is in a transaction.
if (rc === 5 || rc === 6) return 'busy';
throw new Error(`The copy failed: ${rc}`);
},
remaining: () => capi.sqlite3_backup_remaining(backup),
pageCount: () => capi.sqlite3_backup_pagecount(backup),
finish: end,
abort: () => {
end();
this.remove(file);
},
};
}
private async openDb(file: string, keyHex: string): Promise<any> {
if (!/^\/a[0-9a-f]+\.db$/.test(file)) throw new Error('Store file names carry no account data');
if (!/^[0-9a-f]{64}$/.test(keyHex)) throw new Error('A store key is 32 bytes of hex');
const needed = this.pool.getFileCount() + SLOTS_PER_DATABASE;
@@ -181,7 +228,7 @@ export class WasmStorage {
db.close();
throw error;
}
return new WasmAdapter(this.sqlite3, db);
return db;
}
remove(file: string): boolean {
+3 -1
View File
@@ -74,7 +74,9 @@ export type StoreEvent =
| { type: 'created'; account: string; jmap: string; collection: string; ids: string[] }
| { type: 'error'; account: string; area: string; message: string }
/** Its file and key are gone, whichever tab asked: opening it again makes a new one. */
| { type: 'removed'; account: string };
| { type: 'removed'; account: string }
/** How far a re-encryption got. Says nothing about the data. */
| { type: 'rekey'; account: string; percent: number };
export interface StoreContext {
account: string;
+70 -3
View File
@@ -7,7 +7,7 @@
import type { StoreEvent, StoreScope } from '../area';
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
import { forgetAccountKey, getAccountRecord, knownStoreFiles, listAccountRecords, openAccountKey, STORE_OPENED_KEY, type StoreKeyLayerMissingError, type WrapLayer } from './keys';
import { clearLeftover, forgetAccountKey, getAccountRecord, knownStoreFiles, listAccountRecords, newStoreKey, openAccountKey, replaceAccountKey, STORE_OPENED_KEY, type StoreKeyLayerMissingError, type WrapLayer } from './keys';
import { StoreClient } from './store-client';
export interface StoreAccountInputs {
@@ -25,7 +25,11 @@ export interface StorePageHooks {
refreshAuth(account: string): Promise<string | null>;
onEvent(event: StoreEvent): void;
/** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */
keyLayers?(): readonly WrapLayer[];
keyLayers?(): readonly WrapLayer[] | Promise<readonly WrapLayer[]>;
/** The OS's key store is there and wouldn't take this key: it isn't protected as it should be. */
onKeyWrapFailed?(account: string): void;
/** A re-encryption didn't finish. The old key and file stay. It's tried again at the next start. */
onRekeyFailed?(account: string): void;
/**
* This tab just took over the store. Resolves when it has heard the
* organisation's policy since: until then it sends no scope, which may be old.
@@ -63,6 +67,8 @@ export interface StoreBackend {
/** This tab has the key again: its worker starts first, and takes the store. */
unlock?(ownerWaitMs: number): Promise<void>;
ownsStoreHere?(): boolean;
/** Starts the re-encryption of what's open here and asks for it. */
rekeyPending?(): Promise<void>;
}
/** The app is locked: the store is closed, and its key isn't in memory anywhere. */
@@ -112,6 +118,11 @@ class PageStore implements StoreBackend {
private ownerReady: (() => void) | null = null;
/** False from taking over the store until this tab has heard the policy again. */
private scopeCurrent = true;
/** One at a time: each may double what's on disk for a while. */
private rekeyTurn: Promise<unknown> = Promise.resolve();
private readonly rekeyDone = new Set<string>();
/** Tried again at the next start, not in a loop. */
private readonly rekeyFailed = new Set<string>();
constructor(private readonly hooks: () => StorePageHooks | null) {}
@@ -168,9 +179,12 @@ class PageStore implements StoreBackend {
private async readKey(account: string): ReturnType<typeof openAccountKey> {
try {
return await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
const opened = await openAccountKey(account, await this.hooks()?.keyLayers?.() ?? []);
if (opened.wrapFailed) this.hooks()?.onKeyWrapFailed?.(account);
return opened;
} catch (error) {
if ((error as Error)?.name === 'StoreKeyLayerMissingError' && (error as StoreKeyLayerMissingError).layer === 'applock') this.hooks()?.onKeyLocked?.();
else if ((error as Error)?.name === 'StoreKeyLayerMissingError') this.hooks()?.onKeyWrapFailed?.(account);
throw error;
}
}
@@ -185,6 +199,8 @@ class PageStore implements StoreBackend {
if (fresh) this.keyJustMade.add(account);
// The old file, if the key alone went, can never be read again.
if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {});
// A re-encryption stopped right before it deleted the old file.
if (record.leftover) await this.deleteLeftover(client, account);
const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null };
const opened = await client.openAccount({
account,
@@ -200,12 +216,63 @@ class PageStore implements StoreBackend {
if (readOpenedAccounts().includes(account)) this.hooks()?.onStoreLost?.(account);
}
rememberOpened(account, true);
if (record.rekey) void this.rekey(account);
})();
this.opening.set(account, run);
run.catch(() => this.opening.delete(account));
return run;
}
private async deleteLeftover(client: StoreClient, account: string): Promise<void> {
await client.pruneFiles(await knownStoreFiles());
await clearLeftover(account);
}
/**
* A new key and a new file for an account whose record asks for it: an older copy of that record,
* with fewer layers, may still be in the browser's files, and only a new key makes it worthless.
* The worker copies while the store goes on working, the record changes in one write, then the old
* file goes. Wherever this stops, one whole file has a record, and the other goes at the next open.
*/
private rekey(account: string): Promise<void> {
if (this.rekeyDone.has(account) || this.rekeyFailed.has(account)) return Promise.resolve();
this.rekeyDone.add(account);
const turn = this.rekeyTurn.then(() => this.rekeyNow(account)).finally(() => this.rekeyDone.delete(account));
this.rekeyTurn = turn.catch(() => {});
return turn;
}
private async rekeyNow(account: string): Promise<void> {
const client = this.client;
const before = await getAccountRecord(account);
if (!client?.ownsStore || this.locked || !before?.rekey) return;
const { keyHex, file } = newStoreKey();
let copied = false;
try {
await client.pruneFiles(await knownStoreFiles());
await client.rekeyAccount(account, file, keyHex);
copied = true;
await replaceAccountKey(account, keyHex, file, await this.hooks()?.keyLayers?.() ?? []);
await this.deleteLeftover(client, account);
} catch (error) {
// A lock stopped it: the next unlock goes on from the start.
if (!this.locked && this.client === client && (error as Error)?.name !== 'StoreRekeyStoppedError') {
this.rekeyFailed.add(account);
this.hooks()?.onRekeyFailed?.(account);
if (copied) void client.pruneFiles(await knownStoreFiles()).catch(() => {});
}
}
if (this.locked || this.client !== client) return;
this.opening.delete(account);
if (this.wanted.has(account)) await this.openHere(account).catch(() => {});
}
async rekeyPending(): Promise<void> {
if (!this.client?.ownsStore || this.locked) return;
const records = await listAccountRecords().catch(() => []);
await Promise.all(records.filter((record) => record.rekey && this.wanted.has(record.account)).map((record) => this.rekey(record.account)));
}
async lock(closeMs: number): Promise<void> {
this.locked = true;
const client = this.client;
+93 -8
View File
@@ -3,8 +3,16 @@
*
* Each account's database has a random 32-byte key. It is only ever stored
* wrapped: AES-GCM with a non-extractable key that lives in IndexedDB, then
* any extra layers on top (the desktop app's OS keychain, app lock). Forgetting
* the record makes the file unreadable even if deleting it failed.
* any extra layers on top (the desktop app's OS keychain, app lock).
*
* That first key is non-extractable for script only: the browser keeps its bytes
* in plain form in its IndexedDB files, next to the record. So in a browser with no
* app lock, someone with a copy of those files can open the database. And a record
* deleted or overwritten here stays in those files until the browser compacts them.
* So forgetting a record stops the app, and script, from using the key. It doesn't
* make a file on disk safe from a copy of the disk: deleting the file does that. And
* more layers on a record only count for a key that never sat there with fewer,
* which is why those ask for a new key and file (`rekey`).
*/
/**
@@ -35,6 +43,10 @@ export interface AccountRecord {
/** Layers applied after the WebCrypto wrap, innermost first. */
layers: string[];
createdAt: number;
/** An older copy of this record, with fewer layers, may still be on disk: the key has to change. */
rekey?: true;
/** The file from before a new key, until it's known to be deleted. */
leftover?: string;
}
function promisify<T>(request: IDBRequest<T>): Promise<T> {
@@ -109,8 +121,20 @@ export async function listAccountRecords(onlyIfThere = false): Promise<AccountRe
return withDb((db) => promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).getAll()) as Promise<AccountRecord[]>);
}
/** The account's key in hex, created on first use. Returns the record too, for the file name. */
export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord; fresh: boolean }> {
function sameLayers(names: readonly string[], layers: readonly WrapLayer[]): boolean {
return names.length === layers.length && layers.every((layer, index) => layer.name === names[index]);
}
function hasMore(from: readonly string[], to: readonly string[]): boolean {
return to.some((name) => !from.includes(name));
}
/**
* The account's key in hex, created on first use. Returns the record too, for the file name.
* A record with other layers than these gets these. `wrapFailed`: it couldn't, the OS refused,
* and the key comes anyway since what's stored is no worse than it was. The caller says so.
*/
export async function openAccountKey(account: string, layers: readonly WrapLayer[] = []): Promise<{ keyHex: string; record: AccountRecord; fresh: boolean; wrapFailed?: boolean }> {
return withDb(async (db) => {
const wrapping = await wrapKey(db);
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
@@ -124,7 +148,17 @@ export async function openAccountKey(account: string, layers: readonly WrapLayer
const raw = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBuffer(found.iv) }, wrapping, asBuffer(data)));
const keyHex = toHex(raw);
raw.fill(0);
return { keyHex, record: found, fresh: false };
if (sameLayers(found.layers, layers)) return { keyHex, record: found, fresh: false };
let rewrapped = data;
try {
for (const layer of layers) rewrapped = new Uint8Array(await layer.wrap(rewrapped));
} catch {
return { keyHex, record: found, fresh: false, wrapFailed: true };
}
const names = layers.map((layer) => layer.name);
const record: AccountRecord = { ...found, wrapped: rewrapped, layers: names, ...(found.rekey || hasMore(found.layers, names) ? { rekey: true as const } : {}) };
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
return { keyHex, record, fresh: false };
}
const raw = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
@@ -145,11 +179,15 @@ export async function openAccountKey(account: string, layers: readonly WrapLayer
});
}
/** Adds or removes wrap layers without touching the database key. */
/**
* Adds or removes wrap layers without touching the database key. More layers than before ask for
* a new key (`rekey`), and so does the caller who knows better, for a lock key that changed.
*/
export async function rewrapAccountKey(
account: string,
from: readonly WrapLayer[],
to: readonly WrapLayer[],
options: { rekey?: boolean } = {},
): Promise<void> {
await withDb(async (db) => {
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
@@ -161,12 +199,54 @@ export async function rewrapAccountKey(
data = await layer.unwrap(data);
}
for (const layer of to) data = new Uint8Array(await layer.wrap(data));
const record: AccountRecord = { ...found, wrapped: data, layers: to.map((layer) => layer.name) };
const names = to.map((layer) => layer.name);
const { rekey: _before, ...rest } = found;
const record: AccountRecord = {
...rest,
wrapped: data,
layers: names,
...(options.rekey || found.rekey || hasMore(found.layers, names) ? { rekey: true as const } : {}),
};
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
});
}
/** Forgets the account's key. The database file can't be read after this. */
/** A new random key for a re-encryption, in hex, and a file for it. */
export function newStoreKey(): { keyHex: string; file: string } {
const raw = crypto.getRandomValues(new Uint8Array(32));
const keyHex = toHex(raw);
raw.fill(0);
return { keyHex, file: newStoreFileName() };
}
/** The account's data is whole under this key in this file now. One write, so a crash leaves one or the other. */
export async function replaceAccountKey(account: string, keyHex: string, file: string, layers: readonly WrapLayer[] = []): Promise<void> {
await withDb(async (db) => {
const wrapping = await wrapKey(db);
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
if (!found) return;
const raw = Uint8Array.from(keyHex.match(/../g)!.map((pair) => parseInt(pair, 16)));
const iv = crypto.getRandomValues(new Uint8Array(12));
let wrapped = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapping, raw));
raw.fill(0);
for (const layer of layers) wrapped = new Uint8Array(await layer.wrap(wrapped));
const { rekey: _done, ...rest } = found;
const record: AccountRecord = { ...rest, file, iv, wrapped, layers: layers.map((layer) => layer.name), leftover: found.file };
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(record));
});
}
/** The old file is gone. */
export async function clearLeftover(account: string): Promise<void> {
await withDb(async (db) => {
const found = await promisify(db.transaction(ACCOUNTS, 'readonly').objectStore(ACCOUNTS).get(account)) as AccountRecord | undefined;
if (!found?.leftover) return;
const { leftover: _gone, ...rest } = found;
await promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).put(rest));
});
}
/** Forgets the account's key. The app can't read the database file after this. */
export async function forgetAccountKey(account: string): Promise<void> {
await withDb((db) => promisify(db.transaction(ACCOUNTS, 'readwrite').objectStore(ACCOUNTS).delete(account)).then(() => undefined));
}
@@ -177,6 +257,11 @@ export async function forgetAccountKey(account: string): Promise<void> {
* database and nowhere else, because a start has to read it before any store
* opens, and a copy in the store could never outlive the key that opens it. A
* cleared database means a new device at the next registration.
*
* Not sealed. Sealing it with the wrap key would change nothing, since that sits beside
* it, and a device at the lock screen still has to hear a wipe. So someone with a copy of
* these files can act as this row at the instance: read whether a wipe waits, and report one
* as done. It reaches no mail. An admin's "wiped" means the device said so.
*/
export interface DeviceRecord {
account: string;
+7 -5
View File
@@ -38,15 +38,17 @@ export async function deleteStorePool(): Promise<void> {
await navigator.locks.request(OWNER_LOCK, { signal: AbortSignal.timeout(POOL_LOCK_WAIT_MS) }, removePool);
} catch (error) {
if ((error as Error)?.name !== 'TimeoutError' && (error as Error)?.name !== 'AbortError') throw error;
// A tab that won't let go, stuck or asleep: the key is gone, so what can be
// deleted goes now, and what it still holds can't be read anyway.
// A tab that won't let go, stuck or asleep: the app has no key any more, so what
// can be deleted goes now, and what it still holds goes at the next open.
await removePool();
}
}
/**
* Forgets every key first, which makes the files unreadable even if deleting
* them fails, then has every tab stop its worker and deletes the files.
* Forgets every key first, so the app can't read the files even if deleting them
* fails, then has every tab stop its worker and deletes the files. The files going is
* what counts against a copy of the disk: the browser's own files can hold a forgotten key
* for a while (see keys.ts). A slot that couldn't be deleted goes at the next open.
* Started synchronously: the sign-out may navigate away right after.
*/
export function purgeStoreFiles(): Promise<void> {
@@ -65,7 +67,7 @@ export function purgeStoreFiles(): Promise<void> {
// No BroadcastChannel: no store either.
}
const poolGone = deleteStorePool().catch(() => {
// The key is gone, so what's left can't be read.
// The app has no key for what's left, and the next open deletes it.
});
return Promise.all([keysGone, poolGone]).then(() => undefined);
}
+7 -2
View File
@@ -250,8 +250,8 @@ export class StoreClient {
return result;
}
private direct<T>(message: PageToWorker & { id: string }): Promise<T> {
const result = this.track<T>(message.id, CALL_TIMEOUT_MS);
private direct<T>(message: PageToWorker & { id: string }, timeoutMs = CALL_TIMEOUT_MS): Promise<T> {
const result = this.track<T>(message.id, timeoutMs);
this.post(message);
return result;
}
@@ -272,6 +272,11 @@ export class StoreClient {
return this.direct({ kind: 'prune-files', id: crypto.randomUUID(), keep });
}
/** Copies an open account into a new file under a new key. It ends with the account closed. */
rekeyAccount(account: string, file: string, keyHex: string): Promise<void> {
return this.direct({ kind: 'rekey', id: crypto.randomUUID(), account, file, keyHex }, SYNC_TIMEOUT_MS);
}
closeAccount(account: string): Promise<void> {
return this.direct({ kind: 'close-account', id: crypto.randomUUID(), account });
}
+104
View File
@@ -14,11 +14,29 @@ export function storeFilesToPrune(files: readonly string[], keep: readonly strin
return files.filter((file) => /^\/a[0-9a-f]+\.db$/.test(file) && !keep.includes(file));
}
/** A copy of a database under way, page by page, into a new file under a new key. */
export interface StoreCopy {
/** Negative: everything that's left. Busy: the source is in the middle of something. */
step(pages: number): 'more' | 'done' | 'busy';
remaining(): number;
pageCount(): number;
/** Done: the file stays. */
finish(): void;
/** Not done: the file goes. */
abort(): void;
}
export interface HostStorage {
open(file: string, keyHex: string): Promise<StorageAdapter>;
remove(file: string): boolean;
startCopy?(source: StorageAdapter, file: string, keyHex: string): Promise<StoreCopy>;
}
const COPY_PAGES_PER_STEP = 256;
/** What's left for after the engine has stopped: a write after a copy's end isn't in it. */
const COPY_LAST_PAGES = 64;
const COPY_BUSY_WAIT_MS = 50;
interface OpenAccount {
engine: AccountEngine;
db: StorageAdapter;
@@ -26,6 +44,8 @@ interface OpenAccount {
auth: string | null;
pendingAuth: Array<(header: string | null) => void>;
session: StoreSession;
/** A re-encryption under way: the database can't close under it. */
copying?: { stop: boolean; settled: Promise<void> };
}
export interface StoreHostOptions {
@@ -36,6 +56,12 @@ export interface StoreHostOptions {
}
/** The owner hasn't opened this account yet. The caller may try again shortly. */
function rekeyStopped(): Error {
const error = new Error('The re-encryption was stopped');
error.name = 'StoreRekeyStoppedError';
return error;
}
function accountClosed(): Error {
const error = new Error('The local store is not open for this account');
error.name = 'StoreAccountClosedError';
@@ -127,12 +153,90 @@ export class StoreHost {
async closeAccount(account: string): Promise<void> {
const open = this.accounts.get(account);
if (!open) return;
if (open.copying) {
open.copying.stop = true;
await open.copying.settled;
}
this.accounts.delete(account);
await open.engine.close();
for (const resolve of open.pendingAuth.splice(0)) resolve(null);
open.db.close();
}
/**
* Copies an open account into a new file under a new key, while it goes on working: the source's
* own writes reach the copy too. Then stops the engine for the last pages, and ends with the
* account closed and nothing written since, so the old file and the new one are the same data.
* On a failure the new file goes and the account stays open, if it can.
*/
async rekey(account: string, file: string, keyHex: string): Promise<void> {
const open = this.accounts.get(account);
if (!open) throw accountClosed();
if (!this.options.storage.startCopy) throw new Error('This storage can\'t re-encrypt');
if (open.copying) throw new Error('A re-encryption is under way');
let settle = () => {};
const copying = { stop: false, settled: new Promise<void>((resolve) => { settle = resolve; }) };
open.copying = copying;
let copy: StoreCopy | null = null;
const emit = (percent: number) => this.options.emit({ type: 'rekey', account, percent });
try {
copy = await this.options.storage.startCopy(open.db, file, keyHex);
const yieldNow = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
// One page first: only then does SQLite know how much there is.
let answer = copy.step(1);
for (;;) {
if (copying.stop) throw rekeyStopped();
if (answer === 'done') break;
if (answer === 'busy') {
await yieldNow(COPY_BUSY_WAIT_MS);
} else {
const left = copy.remaining();
if (left <= COPY_LAST_PAGES) break;
const total = copy.pageCount();
emit(Math.min(99, Math.floor(((total - left) / Math.max(total, 1)) * 100)));
await yieldNow();
}
if (copying.stop) throw rekeyStopped();
answer = copy.step(Math.min(COPY_PAGES_PER_STEP, Math.max(1, copy.remaining() - COPY_LAST_PAGES)));
}
if (copying.stop) throw rekeyStopped();
// Calls wait for the account to open again from here. Other tabs try again by themselves.
this.accounts.delete(account);
await open.engine.close();
for (const resolve of open.pendingAuth.splice(0)) resolve(null);
// Nothing writes now. A copy that was short enough to end above is whole only if nothing
// wrote after, so it goes round again.
let last = answer === 'done' ? 'again' : copy.step(-1);
if (last === 'again') {
copy.abort();
copy = await this.options.storage.startCopy(open.db, file, keyHex);
last = copy.step(-1);
}
for (let tries = 0; last === 'busy' && tries < 20; tries += 1) {
await yieldNow(COPY_BUSY_WAIT_MS);
last = copy.step(-1);
}
if (last !== 'done') throw new Error('The copy did not finish');
copy.finish();
copy = null;
open.db.close();
emit(100);
} catch (error) {
copy?.abort();
if (!this.accounts.has(account)) {
try {
open.db.close();
} catch {
// The page opens the old file again.
}
}
throw error;
} finally {
open.copying = undefined;
settle();
}
}
async removeAccount(account: string, file: string): Promise<boolean> {
await this.closeAccount(account);
this.synced.delete(account);
+2
View File
@@ -82,6 +82,8 @@ export type PageToWorker =
| { kind: 'set-auth'; account: string; auth: string | null }
| { kind: 'set-inverted'; account: string; inverted: boolean }
| { kind: 'set-session'; account: string; session: StoreSession }
/** Copies an open account into this file under this key. It ends with the account closed. */
| { kind: 'rekey'; id: string; account: string; file: string; keyHex: string }
| { kind: 'release'; id: string };
export type WorkerToPage =
+8
View File
@@ -131,6 +131,14 @@ scope.addEventListener('message', async (event) => {
replyError(message.id, error);
}
break;
case 'rekey':
try {
await requireHost().rekey(message.account, message.file, message.keyHex);
reply(message.id);
} catch (error) {
replyError(message.id, error);
}
break;
case 'set-auth':
host?.setAuth(message.account, message.auth);
break;
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "ما يحتفظ به هذا الجهاز",
"pin_not_ready": "هذا الملف ليس في قائمة هذا الجهاز بعد. جرّب مرة أخرى بعد قليل."
},
"keyProtection": {
"browser_note": "في المتصفح، يوجد مفتاح هذا البريد في ملف تعريف المتصفح نفسه الذي يوجد فيه البريد. ومن ينسخ ملفات الملف الشخصي يمكنه قراءته، لذا استخدم تشفير القرص.",
"no_secure_storage": "لا يحتوي هذا الكمبيوتر على مخزن مفاتيح آمن يمكن للتطبيق استخدامه، لذا فإن مفتاح هذا البريد مموه فقط على القرص. استخدم تشفير القرص.",
"wrap_failed": "لم يقبل مخزن المفاتيح الآمن في هذا الكمبيوتر مفتاح هذا البريد، لذا فهو غير محمي كما ينبغي. أعد تشغيل التطبيق واستخدم تشفير القرص.",
"rekey_progress": "تجري إعادة تشفير البريد على هذا الجهاز: {percent}%. يمكنك مواصلة العمل.",
"rekey_failed": "لم تكتمل إعادة تشفير البريد على هذا الجهاز، لذا لا يزال المفتاح القديم قادرًا على فتحه. سيعيد التطبيق المحاولة عند التشغيل التالي.",
"reenroll_note": "كان مفتاح المرور وفتح القفل عبر النظام يستخدمان مفتاح رمز PIN القديم، لذا أزيلا. أعد إعدادهما إذا كنت تستخدمهما."
},
"offlineDrafts": {
"title": "المسودات على هذا الجهاز",
"description": "هذه المسودات محفوظة على هذا الجهاز. ستنتقل إلى الخادم تلقائيًا فور إمكان الوصول إليه.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "قفل التطبيق",
"section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.",
"section_desc": "يطلب رمز PIN بعد فترة من عدم الاستخدام، في كل نافذة وعلامة تبويب. أثناء القفل، لا يستطيع التطبيق قراءة البريد المخزن على هذا الجهاز حتى يدخل أحدهم رمز PIN.",
"enable_label": "القفل برمز PIN",
"enable_desc": "متوقف افتراضيًا. يبقى رمز PIN على هذا الجهاز.",
"protects_desc": "ما الذي يحميه هذا: في المتصفح، يمنع رمز PIN أي شخص يستخدم هذا الجهاز من دخول التطبيق ويبقي البريد المخزَّن هنا مشفّرًا. لا يمكنه إيقاف برنامج يعمل على هذا الحاسوب، أو إضافة متصفح ثبّتّها، أو شخص ينسخ ملفات المتصفح ولديه وقت لتجربة رموز كثيرة، لذا يستحق الرمز الأطول العناء. في تطبيق سطح المكتب يرتبط رمز PIN أيضًا بتسجيل دخولك على هذا الحاسوب. لا يتأثر حسابك على الخادم.",
"protects_desc": "مما يحمي هذا: يمنع رمز PIN من يقف عند هذا الجهاز من دخول التطبيق. يحصل البريد المخزن هنا على مفتاح جديد عند تشغيل القفل أو تغيير الرمز، وقد يستغرق ذلك لحظة. لا يمكنه إيقاف برنامج يعمل على هذا الكمبيوتر، أو إضافة متصفح ثبّتها، أو شخص ينسخ ملفات التطبيق ولديه وقت لتجربة رموز كثيرة، لذا يستحق الرمز الأطول ذلك. في تطبيق سطح المكتب، تتطلب تجربة الرموز أيضًا تسجيل دخولك على هذا الكمبيوتر. لا يتأثر حسابك على الخادم.",
"enable_body": "اختر رمز PIN. ستحتاج إليه كلما قُفل التطبيق، وبعد إعادة التحميل أو بدء التشغيل من جديد.",
"set_pin_title": "اختر رمز PIN",
"change_pin": "تغيير رمز PIN…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Què desa aquest dispositiu",
"pin_not_ready": "Aquest fitxer encara no és a la llista d'aquest dispositiu. Torneu-ho a provar d'aquí a un moment."
},
"keyProtection": {
"browser_note": "En un navegador, la clau d'aquest correu és al mateix perfil del navegador que el correu. Qui copiï els fitxers del perfil el pot llegir, així que feu servir el xifratge del disc.",
"no_secure_storage": "Aquest ordinador no té un magatzem segur de claus que l'aplicació pugui fer servir, així que la clau d'aquest correu només està ofuscada al disc. Feu servir el xifratge del disc.",
"wrap_failed": "El magatzem segur de claus d'aquest ordinador no ha acceptat la clau d'aquest correu, així que no està protegida com caldria. Reinicieu l'aplicació i feu servir el xifratge del disc.",
"rekey_progress": "S'està tornant a xifrar el correu d'aquest dispositiu: {percent} %. Podeu continuar treballant.",
"rekey_failed": "El nou xifratge del correu d'aquest dispositiu no ha acabat, així que la clau antiga encara el pot obrir. L'aplicació ho tornarà a provar en el proper inici.",
"reenroll_note": "La clau d'accés i el desbloqueig del sistema feien servir la clau del PIN antic, així que s'han eliminat. Torneu-los a configurar si els feu servir."
},
"offlineDrafts": {
"title": "Esborranys en aquest dispositiu",
"description": "Aquests esborranys estan desats en aquest dispositiu. Aniran al servidor sols tan bon punt s'hi pugui connectar.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Bloqueig de l'app",
"section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.",
"section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, l'aplicació no pot llegir el correu desat en aquest dispositiu fins que algú introdueixi el PIN.",
"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.",
"protects_desc": "De què protegeix: el PIN impedeix que algú davant d'aquest dispositiu entri a l'aplicació. El correu desat aquí rep una clau nova quan activeu el bloqueig o canvieu el PIN, cosa que pot trigar una estona. No pot aturar un programa que s'executi en aquest ordinador, una extensió del navegador que hàgiu instal·lat ni algú que copiï els fitxers de l'aplicació i tingui temps de provar molts PIN, així que val la pena un PIN més llarg. A l'aplicació d'escriptori, provar PIN també requereix la vostra 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Co toto zařízení uchovává",
"pin_not_ready": "Tento soubor ještě není v seznamu tohoto zařízení. Zkuste to za chvíli znovu."
},
"keyProtection": {
"browser_note": "V prohlížeči je klíč k této poště ve stejném profilu prohlížeče jako pošta. Kdo zkopíruje soubory profilu, může ji číst, proto používejte šifrování disku.",
"no_secure_storage": "Tento počítač nemá zabezpečené úložiště klíčů, které by aplikace mohla použít, takže klíč k této poště je na disku jen zastřený. Používejte šifrování disku.",
"wrap_failed": "Zabezpečené úložiště klíčů tohoto počítače nepřijalo klíč k této poště, takže není chráněný, jak by měl být. Restartujte aplikaci a používejte šifrování disku.",
"rekey_progress": "Pošta v tomto zařízení se znovu šifruje: {percent} %. Můžete dál pracovat.",
"rekey_failed": "Nové šifrování pošty v tomto zařízení nebylo dokončeno, takže ji starý klíč stále otevře. Aplikace to zkusí znovu při dalším spuštění.",
"reenroll_note": "Přístupový klíč a odemknutí systémem používaly klíč starého PINu, proto byly odebrány. Pokud je používáte, nastavte je znovu."
},
"offlineDrafts": {
"title": "Koncepty v tomto zařízení",
"description": "Tyto koncepty jsou uložené v tomto zařízení. Na server se odešlou samy, jakmile bude dostupný.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Zámek aplikace",
"section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.",
"section_desc": "Po době nečinnosti žádá o PIN v každém okně a na každé kartě. Dokud je zamčeno, aplikace nemůže číst poštu uloženou v tomto zařízení, dokud někdo nezadá PIN.",
"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í.",
"protects_desc": "Před čím to chrání: PIN nepustí do aplikace nikoho, kdo je u tohoto zařízení. Pošta uložená zde dostane nový klíč, když zámek zapnete nebo změníte PIN, což může chvíli trvat. Nezastaví program spuštěný na tomto počítači, rozšíření prohlížeče, které jste nainstalovali, ani někoho, kdo zkopíruje soubory aplikace a má čas zkoušet mnoho PINů, takže delší PIN se vyplatí. V desktopové aplikaci je ke zkoušení PINů potřeba i vaše přihlášení k tomuto 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Det her beholder denne enhed",
"pin_not_ready": "Denne fil er ikke på denne enheds liste endnu. Prøv igen om lidt."
},
"keyProtection": {
"browser_note": "I en browser ligger nøglen til denne mail i den samme browserprofil som mailen. Den, der kopierer profilens filer, kan læse den, så brug diskkryptering.",
"no_secure_storage": "Denne computer har ikke et sikkert nøglelager, som appen kan bruge, så nøglen til denne mail er kun sløret på disken. Brug diskkryptering.",
"wrap_failed": "Computerens sikre nøglelager tog ikke imod nøglen til denne mail, så den er ikke beskyttet, som den skal være. Genstart appen, og brug diskkryptering.",
"rekey_progress": "Mailen på denne enhed krypteres igen: {percent} %. Du kan arbejde videre.",
"rekey_failed": "Den nye kryptering af mailen på denne enhed blev ikke færdig, så den gamle nøgle kan stadig åbne den. Appen prøver igen ved næste start.",
"reenroll_note": "Adgangsnøglen og systemoplåsningen brugte den gamle pinkodes nøgle, så de er fjernet. Konfigurer dem igen, hvis du bruger dem."
},
"offlineDrafts": {
"title": "Kladder på denne enhed",
"description": "Disse kladder er gemt på denne enhed. De sendes selv til serveren, så snart den kan nås.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Applås",
"section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.",
"section_desc": "Beder om en pinkode efter et stykke tid uden brug, i alle vinduer og faner. Mens den er låst, kan appen ikke læse den mail, der er gemt på denne enhed, før nogen indtaster pinkoden.",
"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.",
"protects_desc": "Det beskytter den mod: Pinkoden holder personer ved denne enhed ude af appen. Mailen, der er gemt her, får en ny nøgle, når du slår låsen til eller ændrer pinkoden, hvilket kan tage et øjeblik. Den kan ikke stoppe et program, der kører på denne computer, en browserudvidelse, du har installeret, eller en person, der kopierer appens filer og har tid til at prøve mange pinkoder, så en længere pinkode er det værd. I desktop-appen kræver det også dit login på denne computer at prøve pinkoder. 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Was dieses Gerät behält",
"pin_not_ready": "Diese Datei steht noch nicht auf der Liste dieses Geräts. Versuchen Sie es gleich noch einmal."
},
"keyProtection": {
"browser_note": "Im Browser liegt der Schlüssel zu dieser Mail im selben Browserprofil wie die Mail. Wer die Dateien des Profils kopiert, kann sie lesen. Nutzen Sie deshalb eine Festplattenverschlüsselung.",
"no_secure_storage": "Dieser Computer hat keinen sicheren Schlüsselspeicher, den die App nutzen kann. Der Schlüssel zu dieser Mail ist auf der Festplatte nur verschleiert. Nutzen Sie eine Festplattenverschlüsselung.",
"wrap_failed": "Der sichere Schlüsselspeicher dieses Computers hat den Schlüssel zu dieser Mail nicht angenommen, er ist also nicht so geschützt, wie er sein sollte. Starten Sie die App neu und nutzen Sie eine Festplattenverschlüsselung.",
"rekey_progress": "Die Mail auf diesem Gerät wird neu verschlüsselt: {percent} %. Sie können weiterarbeiten.",
"rekey_failed": "Die neue Verschlüsselung der Mail auf diesem Gerät wurde nicht fertig, der alte Schlüssel öffnet sie also noch. Die App versucht es beim nächsten Start erneut.",
"reenroll_note": "Passkey und Systementsperrung nutzten den Schlüssel der alten PIN und wurden deshalb entfernt. Richten Sie sie neu ein, wenn Sie sie nutzen."
},
"offlineDrafts": {
"title": "Entwürfe auf diesem Gerät",
"description": "Diese Entwürfe sind auf diesem Gerät gespeichert. Sie gehen von selbst an den Server, sobald er erreichbar ist.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "App-Sperre",
"section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.",
"section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, kann die App die auf diesem Gerät gespeicherte Mail nicht lesen, bis jemand die PIN eingibt.",
"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.",
"protects_desc": "Wovor das schützt: Die PIN hält Personen an diesem Gerät aus der App heraus. Die hier gespeicherte Mail bekommt einen neuen Schlüssel, wenn Sie die Sperre einschalten oder die PIN ändern. Das kann einen Moment dauern. Nicht verhindern kann sie ein Programm auf diesem Computer, eine von Ihnen installierte Browsererweiterung oder jemanden, der die Dateien der App kopiert und Zeit hat, viele PINs zu probieren. Eine längere PIN lohnt sich also. In der Desktop-App braucht das Probieren von PINs zusätzlich Ihre Anmeldung an diesem Computer. 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "What this device keeps",
"pin_not_ready": "This file isn't on this device's list yet. Try again in a moment."
},
"keyProtection": {
"browser_note": "In a browser, the key to this mail sits in the same browser profile as the mail. Someone who copies the profile's files can read it, so use disk encryption.",
"no_secure_storage": "This computer has no secure key storage the app can use, so the key to this mail is only obfuscated on disk. Use disk encryption.",
"wrap_failed": "This computer's secure key storage didn't take the key to this mail, so the key isn't protected as it should be. Restart the app, and use disk encryption.",
"rekey_progress": "Re-encrypting the mail on this device: {percent}%. You can keep working.",
"rekey_failed": "Re-encrypting the mail on this device didn't finish, so the old key can still open it. The app tries again at its next start.",
"reenroll_note": "The passkey and system unlock used the old PIN's key, so they were removed. Set them up again if you use them."
},
"offlineDrafts": {
"title": "Drafts on this device",
"description": "These drafts are saved on this device. They go to the server by themselves once it can be reached.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "App lock",
"section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.",
"section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, the app can't read the mail stored on this device until someone enters the PIN.",
"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.",
"protects_desc": "What this protects against: the PIN keeps someone at this device out of the app. The mail stored here gets a new key when you turn the lock on or change the PIN, which can take a moment. It can't stop a program running on this computer, a browser extension you installed, or someone who copies the app's files and has time to try many PINs, so a longer PIN is worth it. In the desktop app, trying PINs also takes 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Lo que guarda este dispositivo",
"pin_not_ready": "Este archivo aún no está en la lista de este dispositivo. Inténtalo de nuevo en un momento."
},
"keyProtection": {
"browser_note": "En un navegador, la clave de este correo está en el mismo perfil del navegador que el correo. Quien copie los archivos del perfil puede leerlo, así que usa el cifrado de disco.",
"no_secure_storage": "Este equipo no tiene un almacén seguro de claves que la aplicación pueda usar, así que la clave de este correo solo está ofuscada en el disco. Usa el cifrado de disco.",
"wrap_failed": "El almacén seguro de claves de este equipo no aceptó la clave de este correo, así que no está protegida como debería. Reinicia la aplicación y usa el cifrado de disco.",
"rekey_progress": "Volviendo a cifrar el correo de este dispositivo: {percent} %. Puedes seguir trabajando.",
"rekey_failed": "El nuevo cifrado del correo de este dispositivo no terminó, así que la clave antigua aún puede abrirlo. La aplicación lo intentará de nuevo en el próximo inicio.",
"reenroll_note": "La clave de acceso y el desbloqueo del sistema usaban la clave del PIN anterior, así que se han quitado. Configúralos de nuevo si los usas."
},
"offlineDrafts": {
"title": "Borradores en este dispositivo",
"description": "Estos borradores están guardados en este dispositivo. Irán al servidor por sí solos en cuanto se pueda conectar con él.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Bloqueo de la app",
"section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.",
"section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, la aplicación no puede leer el correo guardado en este dispositivo hasta que alguien introduzca el PIN.",
"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.",
"protects_desc": "De qué protege: el PIN impide que alguien que esté en este dispositivo entre en la aplicación. El correo guardado aquí recibe una clave nueva al activar el bloqueo o cambiar el PIN, lo que puede tardar un momento. No puede detener un programa que se ejecute en este equipo, una extensión de navegador que hayas instalado ni a alguien que copie los archivos de la aplicación y tenga tiempo de probar muchos PIN, así que conviene un PIN más largo. En la aplicación de escritorio, probar PIN también requiere tu sesión en este equipo. 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Mida see seade hoiab",
"pin_not_ready": "Seda faili ei ole selle seadme loendis veel. Proovi hetke pärast uuesti."
},
"keyProtection": {
"browser_note": "Brauseris asub selle kirja võti samas brauseriprofiilis kui kiri. Kes profiili failid kopeerib, saab seda lugeda, seega kasuta kettakrüpteerimist.",
"no_secure_storage": "Selles arvutis pole turvalist võtmehoidlat, mida rakendus saaks kasutada, seega on selle kirja võti kettal ainult hägustatud. Kasuta kettakrüpteerimist.",
"wrap_failed": "Selle arvuti turvaline võtmehoidla ei võtnud selle kirja võtit vastu, seega pole see kaitstud nii, nagu peaks. Taaskäivita rakendus ja kasuta kettakrüpteerimist.",
"rekey_progress": "Selle seadme kirju krüpteeritakse uuesti: {percent}%. Võid edasi töötada.",
"rekey_failed": "Selle seadme kirjade uus krüpteerimine ei lõppenud, seega saab vana võti neid endiselt avada. Rakendus proovib järgmisel käivitusel uuesti.",
"reenroll_note": "Pääsuvõti ja süsteemi avamine kasutasid vana PIN-koodi võtit, seega eemaldati need. Kui neid kasutad, seadista need uuesti."
},
"offlineDrafts": {
"title": "Mustandid selles seadmes",
"description": "Need mustandid on salvestatud selles seadmes. Need lähevad serverisse ise, kui server on kättesaadav.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Rakenduse lukk",
"section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.",
"section_desc": "Küsib pärast kasutuseta aega PIN-koodi igas aknas ja vahekaardil. Lukus olles ei saa rakendus selles seadmes hoitavat kirja lugeda enne, kui keegi PIN-koodi sisestab.",
"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.",
"protects_desc": "Mille eest see kaitseb: PIN-kood hoiab selle seadme juures oleva inimese rakendusest eemal. Siin hoitavad kirjad saavad uue võtme, kui lülitad luku sisse või muudad PIN-koodi, ja see võib veidi aega võtta. See ei peata selles arvutis töötavat programmi, sinu paigaldatud brauserilaiendit ega kedagi, kes kopeerib rakenduse failid ja kellel on aega proovida palju PIN-koode, seega tasub kasutada pikemat PIN-koodi. Töölauarakenduses on PIN-koodide proovimiseks vaja ka sinu sisselogimist 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "آنچه این دستگاه نگه می‌دارد",
"pin_not_ready": "این فایل هنوز در فهرست این دستگاه نیست. کمی بعد دوباره امتحان کنید."
},
"keyProtection": {
"browser_note": "در مرورگر، کلید این نامه‌ها در همان نمایه مرورگری است که نامه‌ها در آن هستند. هر کس فایل‌های نمایه را کپی کند می‌تواند آن‌ها را بخواند، پس از رمزگذاری دیسک استفاده کنید.",
"no_secure_storage": "این رایانه محل امن نگهداری کلید که برنامه بتواند از آن استفاده کند ندارد، پس کلید این نامه‌ها روی دیسک فقط پنهان‌سازی شده است. از رمزگذاری دیسک استفاده کنید.",
"wrap_failed": "محل امن نگهداری کلید این رایانه کلید این نامه‌ها را نپذیرفت، پس کلید آن‌طور که باید محافظت نشده است. برنامه را دوباره راه‌اندازی کنید و از رمزگذاری دیسک استفاده کنید.",
"rekey_progress": "در حال رمزگذاری دوباره نامه‌های این دستگاه: {percent}٪. می‌توانید به کار ادامه دهید.",
"rekey_failed": "رمزگذاری دوباره نامه‌های این دستگاه تمام نشد، پس کلید قدیمی هنوز می‌تواند آن‌ها را باز کند. برنامه در راه‌اندازی بعدی دوباره تلاش می‌کند.",
"reenroll_note": "گذرکلید و بازکردن قفل سیستم از کلید پین قدیمی استفاده می‌کردند، پس حذف شدند. اگر از آن‌ها استفاده می‌کنید دوباره تنظیمشان کنید."
},
"offlineDrafts": {
"title": "پیش‌نویس‌های این دستگاه",
"description": "این پیش‌نویس‌ها روی این دستگاه ذخیره شده‌اند. به‌محض دسترسی به سرور، خودکار به آن می‌روند.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "قفل برنامه",
"section_desc": "پس از مدتی بی‌استفاده، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، ایمیل‌های این دستگاه رمزگذاری‌شده می‌مانند و برنامه کلید آن‌ها را ندارد.",
"section_desc": "پس از مدتی استفاده نکردن، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، برنامه نمی‌تواند نامه‌های ذخیره‌شده در این دستگاه را بخواند تا کسی پین را وارد کند.",
"enable_label": "قفل با پین",
"enable_desc": "به‌طور پیش‌فرض خاموش است. پین روی همین دستگاه می‌ماند.",
"protects_desc": "در برابر چه چیزی محافظت می‌کند: در مرورگر، پین نمی‌گذارد کسی که پای این دستگاه است وارد برنامه شود و ایمیل‌های ذخیره‌شده در اینجا را رمزگذاری‌شده نگه می‌دارد. جلوی برنامه‌ای که روی این رایانه اجرا می‌شود، افزونه مرورگری که نصب کرده‌اید، یا کسی که فایل‌های مرورگر را کپی می‌کند و وقت امتحان کردن پین‌های زیادی دارد را نمی‌گیرد؛ پس پین طولانی‌تر ارزشش را دارد. در برنامه دسکتاپ، پین به ورود شما به این رایانه هم وابسته است. حساب شما روی سرور تغییری نمی‌کند.",
"protects_desc": "در برابر چه چیزی محافظت می‌کند: پین کسی را که پای این دستگاه است از برنامه بیرون نگه می‌دارد. نامه‌های ذخیره‌شده در اینجا وقتی قفل را روشن کنید یا پین را تغییر دهید کلید تازه می‌گیرند و این ممکن است کمی طول بکشد. نمی‌تواند برنامه‌ای را که روی این رایانه اجرا می‌شود، افزونه مرورگری را که نصب کرده‌اید، یا کسی را که فایل‌های برنامه را کپی می‌کند و وقت امتحان پین‌های زیادی دارد متوقف کند، پس پین طولانی‌تر ارزشش را دارد. در برنامه رومیزی، امتحان پین‌ها به ورود شما در این رایانه هم نیاز دارد. حساب شما روی سرور تغییری نمی‌کند.",
"enable_body": "یک پین انتخاب کنید. هر بار که برنامه قفل شود و پس از بارگذاری دوباره یا شروع تازه به آن نیاز دارید.",
"set_pin_title": "انتخاب پین",
"change_pin": "تغییر پین…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Ce que cet appareil garde",
"pin_not_ready": "Ce fichier n'est pas encore dans la liste de cet appareil. Réessayez dans un instant."
},
"keyProtection": {
"browser_note": "Dans un navigateur, la clé de ces messages se trouve dans le même profil de navigateur que les messages. Quiconque copie les fichiers du profil peut les lire : utilisez donc le chiffrement du disque.",
"no_secure_storage": "Cet ordinateur n'a pas de stockage sécurisé de clés utilisable par l'application : la clé de ces messages est seulement masquée sur le disque. Utilisez le chiffrement du disque.",
"wrap_failed": "Le stockage sécurisé de clés de cet ordinateur n'a pas accepté la clé de ces messages : elle n'est donc pas protégée comme elle le devrait. Redémarrez l'application et utilisez le chiffrement du disque.",
"rekey_progress": "Nouveau chiffrement des messages sur cet appareil : {percent} %. Vous pouvez continuer à travailler.",
"rekey_failed": "Le nouveau chiffrement des messages sur cet appareil n'a pas abouti : l'ancienne clé peut donc encore les ouvrir. L'application réessaiera au prochain démarrage.",
"reenroll_note": "La clé d'accès et le déverrouillage système utilisaient la clé de l'ancien code PIN : ils ont donc été supprimés. Configurez-les de nouveau si vous les utilisez."
},
"offlineDrafts": {
"title": "Brouillons sur cet appareil",
"description": "Ces brouillons sont enregistrés sur cet appareil. Ils partent seuls vers le serveur dès qu'il est joignable.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Verrouillage de l'app",
"section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.",
"section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que c'est verrouillé, l'application ne peut pas lire les messages stockés sur cet appareil avant la saisie du code PIN.",
"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é.",
"protects_desc": "Contre quoi cela protège : le code PIN empêche une personne devant cet appareil d'entrer dans l'application. Les messages stockés ici reçoivent une nouvelle clé quand vous activez le verrouillage ou changez le code PIN, ce qui peut prendre un moment. Cela 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 de l'application et a le temps d'essayer de nombreux codes : un code plus long est donc utile. Dans l'application de bureau, essayer des codes demande aussi 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…",
+10 -2
View File
@@ -5896,6 +5896,14 @@
"usage_label": "מה שהמכשיר הזה שומר",
"pin_not_ready": "הקובץ הזה עוד לא ברשימה של המכשיר הזה. נסה שוב בעוד רגע."
},
"keyProtection": {
"browser_note": "בדפדפן, המפתח לדואר הזה נמצא באותו פרופיל דפדפן שבו נמצא הדואר. מי שמעתיק את קובצי הפרופיל יכול לקרוא אותו, לכן השתמשו בהצפנת דיסק.",
"no_secure_storage": "במחשב הזה אין אחסון מפתחות מאובטח שהאפליקציה יכולה להשתמש בו, לכן המפתח לדואר הזה רק מוסווה בדיסק. השתמשו בהצפנת דיסק.",
"wrap_failed": "אחסון המפתחות המאובטח של המחשב הזה לא קיבל את המפתח לדואר הזה, לכן הוא לא מוגן כמו שצריך. הפעילו מחדש את האפליקציה והשתמשו בהצפנת דיסק.",
"rekey_progress": "הדואר במכשיר הזה מוצפן מחדש: {percent}%. אפשר להמשיך לעבוד.",
"rekey_failed": "ההצפנה מחדש של הדואר במכשיר הזה לא הסתיימה, לכן המפתח הישן עדיין יכול לפתוח אותו. האפליקציה תנסה שוב בהפעלה הבאה.",
"reenroll_note": "מפתח הגישה וביטול הנעילה של המערכת השתמשו במפתח של ה-PIN הישן, לכן הם הוסרו. הגדירו אותם מחדש אם אתם משתמשים בהם."
},
"offlineDrafts": {
"title": "טיוטות במכשיר זה",
"description": "הטיוטות האלה שמורות במכשיר זה. הן יעברו לשרת מעצמן ברגע שאפשר יהיה להגיע אליו.",
@@ -6807,10 +6815,10 @@
},
"appLock": {
"section_title": "נעילת אפליקציה",
"section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.",
"section_desc": "מבקש PIN אחרי זמן ללא שימוש, בכל חלון ולשונית. בזמן הנעילה האפליקציה לא יכולה לקרוא את הדואר השמור במכשיר הזה עד שמישהו מזין את ה-PIN.",
"enable_label": "נעילה עם קוד PIN",
"enable_desc": "כבוי כברירת מחדל. קוד ה-PIN נשאר במכשיר הזה.",
"protects_desc": "ממה זה מגן: בדפדפן, קוד ה-PIN מונע ממי שנמצא ליד המכשיר להיכנס לאפליקציה ושומר על הדואר המאוחסן כאן מוצפן. הוא לא יכול לעצור תוכנה שרצה במחשב הזה, תוסף דפדפן שהתקנתם, או מי שמעתיק את קובצי הדפדפן ויש לו זמן לנסות קודים רבים, ולכן כדאי קוד ארוך יותר. באפליקציית שולחן העבודה הקוד קשור גם להתחברות שלכם למחשב הזה. החשבון שלכם בשרת לא מושפע.",
"protects_desc": "ממה זה מגן: ה-PIN מונע ממי שנמצא ליד המכשיר הזה להיכנס לאפליקציה. הדואר השמור כאן מקבל מפתח חדש כשמפעילים את הנעילה או משנים את ה-PIN, וזה עשוי לקחת רגע. זה לא יכול לעצור תוכנה שרצה במחשב הזה, תוסף דפדפן שהתקנתם או מישהו שמעתיק את קובצי האפליקציה ויש לו זמן לנסות הרבה קודים, לכן כדאי PIN ארוך יותר. באפליקציית שולחן העבודה, ניסיון קודים דורש גם את הכניסה שלכם למחשב הזה. החשבון שלכם בשרת לא מושפע.",
"enable_body": "בחרו קוד PIN. תצטרכו אותו בכל פעם שהאפליקציה ננעלת, ואחרי טעינה מחדש או הפעלה חדשה.",
"set_pin_title": "בחירת קוד PIN",
"change_pin": "שינוי קוד PIN…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Mit tart meg ez az eszköz",
"pin_not_ready": "Ez a fájl még nincs rajta az eszköz listáján. Próbálja újra egy pillanat múlva."
},
"keyProtection": {
"browser_note": "Böngészőben ennek a levélnek a kulcsa ugyanabban a böngészőprofilban van, mint a levél. Aki lemásolja a profil fájljait, el tudja olvasni, ezért használjon lemeztitkosítást.",
"no_secure_storage": "Ezen a számítógépen nincs biztonságos kulcstár, amelyet az alkalmazás használhatna, ezért a levél kulcsa a lemezen csak el van rejtve. Használjon lemeztitkosítást.",
"wrap_failed": "A számítógép biztonságos kulcstára nem fogadta el a levél kulcsát, ezért az nincs úgy védve, ahogy kellene. Indítsa újra az alkalmazást, és használjon lemeztitkosítást.",
"rekey_progress": "Az eszközön lévő levelek újratitkosítása: {percent}%. Nyugodtan dolgozhat tovább.",
"rekey_failed": "Az eszközön lévő levelek újratitkosítása nem fejeződött be, így a régi kulcs még megnyitja őket. Az alkalmazás a következő indításkor újra megpróbálja.",
"reenroll_note": "A jelkulcs és a rendszeres feloldás a régi PIN kulcsát használta, ezért eltávolítottuk őket. Ha használja őket, állítsa be újra."
},
"offlineDrafts": {
"title": "Piszkozatok ezen az eszközön",
"description": "Ezek a piszkozatok ezen az eszközön vannak mentve. Maguktól a szerverre kerülnek, amint az elérhető.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Alkalmazászár",
"section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.",
"section_desc": "Egy idő után minden ablakban és lapon PIN-kódot kér. Zárolt állapotban az alkalmazás nem tudja olvasni az eszközön tárolt leveleket, amíg valaki meg nem adja a PIN-kódot.",
"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.",
"protects_desc": "Mi ellen véd: a PIN-kód kint tartja az alkalmazásból azt, aki ennél az eszköznél van. Az itt tárolt levelek új kulcsot kapnak, amikor bekapcsolja a zárolást vagy módosítja a PIN-kódot, ami eltarthat egy ideig. Nem tudja megállítani a számítógépen futó programot, az Ön által telepített böngészőbővítményt, sem azt, aki lemásolja az alkalmazás fájljait, és van ideje sok PIN-kódot kipróbálni, ezért érdemes hosszabb PIN-kódot választani. Az asztali alkalmazásban a PIN-kódok kipróbálásához az Ön bejelentkezése is kell ezen a számítógépen. A kiszolgálón 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Cosa conserva questo dispositivo",
"pin_not_ready": "Questo file non è ancora nell'elenco di questo dispositivo. Riprova tra un momento."
},
"keyProtection": {
"browser_note": "In un browser, la chiave di questa posta si trova nello stesso profilo del browser della posta. Chi copia i file del profilo può leggerla, quindi usa la crittografia del disco.",
"no_secure_storage": "Questo computer non ha un archivio sicuro di chiavi utilizzabile dall'app, quindi la chiave di questa posta è solo offuscata sul disco. Usa la crittografia del disco.",
"wrap_failed": "L'archivio sicuro di chiavi di questo computer non ha accettato la chiave di questa posta, quindi non è protetta come dovrebbe. Riavvia l'app e usa la crittografia del disco.",
"rekey_progress": "Nuova cifratura della posta su questo dispositivo: {percent}%. Puoi continuare a lavorare.",
"rekey_failed": "La nuova cifratura della posta su questo dispositivo non è terminata, quindi la vecchia chiave può ancora aprirla. L'app riproverà al prossimo avvio.",
"reenroll_note": "La passkey e lo sblocco di sistema usavano la chiave del vecchio PIN, quindi sono stati rimossi. Configurali di nuovo se li usi."
},
"offlineDrafts": {
"title": "Bozze su questo dispositivo",
"description": "Queste bozze sono salvate su questo dispositivo. Passano da sole al server appena è raggiungibile.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Blocco dell'app",
"section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.",
"section_desc": "Chiede un PIN dopo un periodo di inattività, in ogni finestra e scheda. Mentre è bloccata, l'app non può leggere la posta salvata su questo dispositivo finché qualcuno non inserisce il PIN.",
"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.",
"protects_desc": "Da cosa protegge: il PIN tiene fuori dall'app chi si trova davanti a questo dispositivo. La posta salvata qui riceve una nuova chiave quando attivi il blocco o cambi il PIN, e può volerci un momento. Non può fermare un programma in esecuzione su questo computer, un'estensione del browser che hai installato o chi copia i file dell'app e ha tempo di provare molti PIN, quindi conviene un PIN più lungo. Nell'app desktop, per provare i PIN serve anche il tuo accesso a questo computer. Il tuo account sul server non è interessato.",
"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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "このデバイスに保存しているもの",
"pin_not_ready": "このファイルはまだこのデバイスのリストにありません。しばらくしてからもう一度お試しください。"
},
"keyProtection": {
"browser_note": "ブラウザーでは、このメールの鍵はメールと同じブラウザープロファイルにあります。プロファイルのファイルをコピーした人はメールを読めるため、ディスク暗号化を使ってください。",
"no_secure_storage": "このコンピューターにはアプリが使える安全な鍵の保管場所がないため、このメールの鍵はディスク上で難読化されているだけです。ディスク暗号化を使ってください。",
"wrap_failed": "このコンピューターの安全な鍵の保管場所がこのメールの鍵を受け付けなかったため、鍵は本来の形で保護されていません。アプリを再起動し、ディスク暗号化を使ってください。",
"rekey_progress": "このデバイスのメールを再暗号化しています: {percent}%。作業は続けられます。",
"rekey_failed": "このデバイスのメールの再暗号化が完了しなかったため、古い鍵でまだ開けます。アプリは次回の起動時に再試行します。",
"reenroll_note": "パスキーとシステムのロック解除は古い PIN の鍵を使っていたため、削除されました。使う場合は設定し直してください。"
},
"offlineDrafts": {
"title": "このデバイスの下書き",
"description": "これらの下書きはこのデバイスに保存されています。サーバーに接続できるようになると自動で送られます。",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "アプリのロック",
"section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。",
"section_desc": "しばらく使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中は、誰かが PIN を入力するまで、アプリはこのデバイスに保存されたメールを読めません。",
"enable_label": "PIN でロック",
"enable_desc": "既定ではオフです。PIN はこのデバイスにだけ保存されます。",
"protects_desc": "保護できる範囲: ブラウザーでは、PIN によりこのデバイスの前にいる人をアプリから締め出し、ここに保存されたメールを暗号化したままにします。このコンピューターで動くプログラム、インストールしたブラウザー拡張機能、ブラウザーのファイルをコピーして多くの PIN を試す時間がある人は止められないため、長い PIN をおすすめします。デスクトップアプリでは、PIN はこのコンピューターへのログインにも結び付きます。サーバー上のアカウントには影響しません。",
"protects_desc": "保護の対象: PIN は、このデバイスの前にいる人をアプリに入れません。ここに保存されたメールは、ロックをオンにしたとき、または PIN を変更したときに新しい鍵に変わります。少し時間がかかることがあります。このコンピューターで動くプログラム、インストールしたブラウザー拡張機能、アプリのファイルをコピーして多くの PIN を試す時間がある人は止められないため、長めの PIN をお勧めします。デスクトップアプリでは、PIN を試すのにこのコンピューターへのログインも必要です。サーバー上のアカウントには影響しません。",
"enable_body": "PIN を選んでください。アプリがロックされるたび、また再読み込みや再起動の後に必要です。",
"set_pin_title": "PIN を選択",
"change_pin": "PIN を変更…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "이 기기에 보관된 항목",
"pin_not_ready": "이 파일은 아직 이 기기의 목록에 없습니다. 잠시 후 다시 시도하세요."
},
"keyProtection": {
"browser_note": "브라우저에서는 이 메일의 키가 메일과 같은 브라우저 프로필에 있습니다. 프로필 파일을 복사한 사람은 메일을 읽을 수 있으므로 디스크 암호화를 사용하세요.",
"no_secure_storage": "이 컴퓨터에는 앱이 사용할 수 있는 안전한 키 저장소가 없어서 이 메일의 키는 디스크에서 난독화만 되어 있습니다. 디스크 암호화를 사용하세요.",
"wrap_failed": "이 컴퓨터의 안전한 키 저장소가 이 메일의 키를 받지 않아 키가 제대로 보호되지 않습니다. 앱을 다시 시작하고 디스크 암호화를 사용하세요.",
"rekey_progress": "이 기기의 메일을 다시 암호화하는 중: {percent}%. 계속 작업할 수 있습니다.",
"rekey_failed": "이 기기의 메일 재암호화가 끝나지 않아 이전 키로 아직 열 수 있습니다. 앱이 다음 시작 때 다시 시도합니다.",
"reenroll_note": "패스키와 시스템 잠금 해제가 이전 PIN의 키를 사용했기 때문에 제거되었습니다. 사용한다면 다시 설정하세요."
},
"offlineDrafts": {
"title": "이 기기의 임시 보관함",
"description": "이 임시 보관 메일은 이 기기에 저장되어 있습니다. 서버에 연결되면 자동으로 올라갑니다.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "앱 잠금",
"section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.",
"section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 앱은 누군가 PIN을 입력할 때까지 이 기기에 저장된 메일을 읽을 수 없습니다.",
"enable_label": "PIN으로 잠금",
"enable_desc": "기본적으로 꺼져 있습니다. PIN은 이 기기에만 남습니다.",
"protects_desc": "보호 범위: 브라우저에서 PIN은 이 기기 앞에 있는 사람이 앱에 들어오지 못하게 하고 여기에 저장된 메일을 암호화된 상태로 유지합니다. 이 컴퓨터에서 실행되는 프로그램, 직접 설치한 브라우저 확장 프로그램, 브라우저 파일을 복사해 많은 PIN을 시도할 시간이 있는 사람은 막을 수 없으므로 더 긴 PIN을 사용하는 것이 좋습니다. 데스크톱 앱에서는 PIN이 이 컴퓨터의 로그인에도 연결됩니다. 서버의 계정에는 영향이 없습니다.",
"protects_desc": "보호 대상: PIN은 이 기기 앞에 있는 사람이 앱에 들어오지 못하게 합니다. 여기에 저장된 메일은 잠금을 켜거나 PIN을 변경할 때 새 키를 받으며, 잠시 걸릴 수 있습니다. 이 컴퓨터에서 실행되는 프로그램, 설치한 브라우저 확장 프로그램, 앱 파일을 복사해 많은 PIN을 시도할 시간이 있는 사람은 막을 수 없으므로 더 긴 PIN이 좋습니다. 데스크톱 앱에서는 PIN을 시도하려면 이 컴퓨터의 로그인도 필요합니다. 서버의 계정에는 영향이 없습니다.",
"enable_body": "PIN을 선택하세요. 앱이 잠길 때마다, 그리고 다시 로드하거나 새로 시작한 후에 필요합니다.",
"set_pin_title": "PIN 선택",
"change_pin": "PIN 변경…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Ko glabā šī ierīce",
"pin_not_ready": "Šī faila vēl nav šīs ierīces sarakstā. Mēģiniet vēlreiz pēc brīža."
},
"keyProtection": {
"browser_note": "Pārlūkā šī pasta atslēga atrodas tajā pašā pārlūka profilā, kur pasts. Kas nokopē profila failus, var to izlasīt, tāpēc izmantojiet diska šifrēšanu.",
"no_secure_storage": "Šajā datorā nav drošas atslēgu krātuves, ko lietotne varētu izmantot, tāpēc šī pasta atslēga diskā ir tikai aizmiglota. Izmantojiet diska šifrēšanu.",
"wrap_failed": "Šī datora drošā atslēgu krātuve nepieņēma šī pasta atslēgu, tāpēc tā nav aizsargāta, kā vajadzētu. Restartējiet lietotni un izmantojiet diska šifrēšanu.",
"rekey_progress": "Šīs ierīces pasts tiek šifrēts no jauna: {percent}%. Varat turpināt darbu.",
"rekey_failed": "Šīs ierīces pasta jaunā šifrēšana netika pabeigta, tāpēc vecā atslēga to joprojām var atvērt. Lietotne mēģinās vēlreiz nākamajā startā.",
"reenroll_note": "Piekļuves atslēga un sistēmas atbloķēšana izmantoja vecā PIN atslēgu, tāpēc tās ir noņemtas. Ja tās izmantojat, iestatiet tās no jauna."
},
"offlineDrafts": {
"title": "Melnraksti šajā ierīcē",
"description": "Šie melnraksti ir saglabāti šajā ierīcē. Tie paši nonāks serverī, tiklīdz tas būs sasniedzams.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Lietotnes bloķēšana",
"section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.",
"section_desc": "Pēc neizmantošanas laika prasa PIN katrā logā un cilnē. Kamēr ir bloķēts, lietotne nevar lasīt šajā ierīcē glabāto pastu, līdz kāds ievada PIN.",
"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ē.",
"protects_desc": "No kā tas aizsargā: PIN neielaiž lietotnē to, kurš ir pie šīs ierīces. Šeit glabātais pasts saņem jaunu atslēgu, kad ieslēdzat bloķēšanu vai maināt PIN, un tas var aizņemt brīdi. Tas nevar apturēt programmu, kas darbojas šajā datorā, jūsu instalētu pārlūka paplašinājumu vai kādu, kurš nokopē lietotnes failus un kuram ir laiks izmēģināt daudzus PIN, tāpēc garāks PIN ir tā vērts. Darbvirsmas lietotnē PIN izmēģināšanai vajag arī jūsu pieteikšanos šajā datorā. Jūsu konts serverī netiek ietekmēts.",
"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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Энэ төхөөрөмж юу хадгалдаг вэ",
"pin_not_ready": "Энэ файл төхөөрөмжийн жагсаалтад хараахан ороогүй байна. Түр хүлээгээд дахин оролдоно уу."
},
"keyProtection": {
"browser_note": "Хөтөч дээр энэ шуудангийн түлхүүр шуудантай ижил хөтчийн профайлд байрладаг. Профайлын файлуудыг хуулсан хүн уншиж чадах тул дискний шифрлэлт ашиглана уу.",
"no_secure_storage": "Энэ компьютерт аппын ашиглах аюулгүй түлхүүрийн сан байхгүй тул энэ шуудангийн түлхүүр диск дээр зөвхөн далдлагдсан байна. Дискний шифрлэлт ашиглана уу.",
"wrap_failed": "Энэ компьютерын аюулгүй түлхүүрийн сан энэ шуудангийн түлхүүрийг хүлээж аваагүй тул зохих ёсоор хамгаалагдаагүй байна. Аппыг дахин эхлүүлж, дискний шифрлэлт ашиглана уу.",
"rekey_progress": "Энэ төхөөрөмж дээрх шуудангийг дахин шифрлэж байна: {percent}%. Та үргэлжлүүлэн ажиллаж болно.",
"rekey_failed": "Энэ төхөөрөмж дээрх шуудангийн дахин шифрлэлт дуусаагүй тул хуучин түлхүүр нээж чадсаар байна. Апп дараагийн эхлэлтэд дахин оролдоно.",
"reenroll_note": "Нэвтрэх түлхүүр болон системийн түгжээ тайлалт хуучин ПИН-ийн түлхүүрийг ашиглаж байсан тул хасагдлаа. Ашигладаг бол дахин тохируулна уу."
},
"offlineDrafts": {
"title": "Энэ төхөөрөмж дээрх ноорог",
"description": "Эдгээр ноорог энэ төхөөрөмж дээр хадгалагдсан. Сервертэй холбогдмогц өөрсдөө илгээгдэнэ.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Аппын түгжээ",
"section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.",
"section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН асуудаг. Түгжээтэй үед хэн нэгэн ПИН оруулах хүртэл апп энэ төхөөрөмжид хадгалагдсан шууданг уншиж чадахгүй.",
"enable_label": "ПИН кодоор түгжих",
"enable_desc": "Анхдагчаар унтраалттай. ПИН код энэ төхөөрөмж дээр үлдэнэ.",
"protects_desc": "Юунаас хамгаалдаг вэ: хөтөч дээр ПИН нь энэ төхөөрөмжийн дэргэд байгаа хүнийг аппликейшнд оруулахгүй бөгөөд энд хадгалсан захидлыг шифрлэгдсэн байдлаар хадгалдаг. Энэ компьютер дээр ажиллаж буй программ, таны суулгасан хөтчийн өргөтгөл, хөтчийн файлуудыг хуулж олон ПИН турших цагтай хүнийг зогсоож чадахгүй тул урт ПИН хэрэглэх нь зүйтэй. Десктоп аппликейшнд ПИН энэ компьютерийн нэвтрэлттэй мөн холбогдоно. Сервер дээрх таны бүртгэлд нөлөөлөхгүй.",
"protects_desc": "Юунаас хамгаалдаг вэ: ПИН нь энэ төхөөрөмжийн дэргэдэх хүнийг аппаас хол байлгана. Энд хадгалагдсан шуудан таныг түгжээг асаах эсвэл ПИН солих үед шинэ түлхүүр авдаг бөгөөд энэ нь хэсэг хугацаа шаардаж болно. Энэ компьютер дээр ажиллаж буй программ, таны суулгасан хөтчийн өргөтгөл, эсвэл аппын файлуудыг хуулж олон ПИН турших цагтай хүнийг зогсоож чадахгүй тул урт ПИН ашигтай. Ширээний аппад ПИН турших нь энэ компьютер дээрх таны нэвтрэлтийг бас шаарддаг. Сервер дээрх таны бүртгэлд нөлөөлөхгүй.",
"enable_body": "ПИН код сонгоно уу. Апп түгжигдэх бүрт, мөн дахин ачаалсан эсвэл шинээр эхлүүлсний дараа хэрэгтэй.",
"set_pin_title": "ПИН код сонгох",
"change_pin": "ПИН код солих…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Dette beholder enheten",
"pin_not_ready": "Denne filen er ikke på listen til denne enheten ennå. Prøv igjen om litt."
},
"keyProtection": {
"browser_note": "I en nettleser ligger nøkkelen til denne e-posten i den samme nettleserprofilen som e-posten. Den som kopierer profilens filer, kan lese den, så bruk diskkryptering.",
"no_secure_storage": "Denne datamaskinen har ikke et sikkert nøkkellager appen kan bruke, så nøkkelen til denne e-posten er bare tilslørt på disken. Bruk diskkryptering.",
"wrap_failed": "Det sikre nøkkellageret på denne datamaskinen tok ikke imot nøkkelen til denne e-posten, så den er ikke beskyttet slik den skal være. Start appen på nytt, og bruk diskkryptering.",
"rekey_progress": "E-posten på denne enheten krypteres på nytt: {percent} %. Du kan jobbe videre.",
"rekey_failed": "Den nye krypteringen av e-posten på denne enheten ble ikke ferdig, så den gamle nøkkelen kan fortsatt åpne den. Appen prøver igjen ved neste start.",
"reenroll_note": "Tilgangsnøkkelen og systemopplåsingen brukte nøkkelen til den gamle PIN-koden, så de er fjernet. Sett dem opp igjen hvis du bruker dem."
},
"offlineDrafts": {
"title": "Utkast på denne enheten",
"description": "Disse utkastene er lagret på denne enheten. De går til serveren av seg selv så snart den kan nås.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Applås",
"section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.",
"section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, kan ikke appen lese e-posten som er lagret på denne enheten, før noen skriver inn PIN-koden.",
"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.",
"protects_desc": "Dette beskytter mot: PIN-koden holder folk ved denne enheten ute av appen. E-posten som er lagret her, får en ny nøkkel når du slår på låsen eller endrer PIN-koden, og det kan ta et øyeblikk. Den kan ikke stoppe et program som kjører på denne datamaskinen, en nettleserutvidelse du har installert, eller noen som kopierer appens filer og har tid til å prøve mange PIN-koder, så en lengre PIN-kode lønner seg. I skrivebordsappen krever det også påloggingen din på denne datamaskinen å prøve PIN-koder. Kontoen din på serveren berøres 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Wat dit apparaat bewaart",
"pin_not_ready": "Dit bestand staat nog niet op de lijst van dit apparaat. Probeer het zo meteen opnieuw."
},
"keyProtection": {
"browser_note": "In een browser staat de sleutel van deze mail in hetzelfde browserprofiel als de mail. Wie de bestanden van het profiel kopieert, kan de mail lezen, dus gebruik schijfversleuteling.",
"no_secure_storage": "Deze computer heeft geen veilige sleutelopslag die de app kan gebruiken, dus de sleutel van deze mail is op schijf alleen verhuld. Gebruik schijfversleuteling.",
"wrap_failed": "De veilige sleutelopslag van deze computer heeft de sleutel van deze mail niet aangenomen, dus die is niet beveiligd zoals het hoort. Start de app opnieuw en gebruik schijfversleuteling.",
"rekey_progress": "De mail op dit apparaat wordt opnieuw versleuteld: {percent}%. Je kunt gewoon doorwerken.",
"rekey_failed": "Het opnieuw versleutelen van de mail op dit apparaat is niet afgerond, dus de oude sleutel kan de mail nog openen. De app probeert het bij de volgende start opnieuw.",
"reenroll_note": "De passkey en systeemontgrendeling gebruikten de sleutel van de oude pincode en zijn daarom verwijderd. Stel ze opnieuw in als je ze gebruikt."
},
"offlineDrafts": {
"title": "Concepten op dit apparaat",
"description": "Deze concepten zijn op dit apparaat opgeslagen. Ze gaan vanzelf naar de server zodra die bereikbaar is.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "App-vergrendeling",
"section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.",
"section_desc": "Vraagt om een pincode na een tijd zonder gebruik, in elk venster en tabblad. Zolang de app vergrendeld is, kan die de mail op dit apparaat niet lezen totdat iemand de pincode invoert.",
"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.",
"protects_desc": "Waartegen dit beschermt: de pincode houdt iemand bij dit apparaat uit de app. De mail die hier staat krijgt een nieuwe sleutel als je de vergrendeling aanzet of de pincode wijzigt, wat even kan duren. Het kan niet tegenhouden: een programma op deze computer, een browserextensie die je hebt geïnstalleerd, of iemand die de bestanden van de app kopieert en tijd heeft om veel pincodes te proberen, dus een langere pincode is de moeite waard. In de desktop-app is voor het proberen van pincodes ook je aanmelding op deze computer nodig. Je account op de server verandert hierdoor 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Co przechowuje to urządzenie",
"pin_not_ready": "Tego pliku nie ma jeszcze na liście tego urządzenia. Spróbuj ponownie za chwilę."
},
"keyProtection": {
"browser_note": "W przeglądarce klucz do tej poczty znajduje się w tym samym profilu przeglądarki co poczta. Kto skopiuje pliki profilu, może ją przeczytać, więc używaj szyfrowania dysku.",
"no_secure_storage": "Ten komputer nie ma bezpiecznego magazynu kluczy, którego aplikacja mogłaby użyć, więc klucz do tej poczty jest na dysku tylko zaciemniony. Używaj szyfrowania dysku.",
"wrap_failed": "Bezpieczny magazyn kluczy tego komputera nie przyjął klucza do tej poczty, więc nie jest on chroniony tak, jak powinien. Uruchom aplikację ponownie i używaj szyfrowania dysku.",
"rekey_progress": "Ponowne szyfrowanie poczty na tym urządzeniu: {percent}%. Możesz dalej pracować.",
"rekey_failed": "Ponowne szyfrowanie poczty na tym urządzeniu nie zakończyło się, więc stary klucz nadal może ją otworzyć. Aplikacja spróbuje ponownie przy następnym uruchomieniu.",
"reenroll_note": "Klucz dostępu i odblokowywanie systemowe używały klucza starego PIN-u, więc zostały usunięte. Skonfiguruj je ponownie, jeśli ich używasz."
},
"offlineDrafts": {
"title": "Wersje robocze na tym urządzeniu",
"description": "Te wersje robocze są zapisane na tym urządzeniu. Trafią na serwer same, gdy tylko będzie dostępny.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Blokada aplikacji",
"section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.",
"section_desc": "Prosi o PIN po pewnym czasie bezczynności, w każdym oknie i na każdej karcie. Gdy jest zablokowana, aplikacja nie może czytać poczty zapisanej na tym urządzeniu, dopóki ktoś nie poda PIN-u.",
"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.",
"protects_desc": "Przed czym to chroni: PIN nie wpuszcza do aplikacji kogoś przy tym urządzeniu. Zapisana tu poczta dostaje nowy klucz, gdy włączysz blokadę lub zmienisz PIN, co może chwilę potrwać. Nie zatrzyma to programu działającego na tym komputerze, zainstalowanego przez Ciebie rozszerzenia przeglądarki ani kogoś, kto skopiuje pliki aplikacji i ma czas wypróbować wiele PIN-ów, więc warto użyć dłuższego PIN-u. W aplikacji komputerowej próbowanie PIN-ów wymaga też Twojego logowania 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "O que este dispositivo mantém",
"pin_not_ready": "Este ficheiro ainda não está na lista deste dispositivo. Tente novamente dentro de instantes."
},
"keyProtection": {
"browser_note": "Num navegador, a chave deste correio está no mesmo perfil do navegador que o correio. Quem copiar os ficheiros do perfil pode lê-lo, por isso use a encriptação do disco.",
"no_secure_storage": "Este computador não tem um armazenamento seguro de chaves que a aplicação possa usar, por isso a chave deste correio está apenas ofuscada no disco. Use a encriptação do disco.",
"wrap_failed": "O armazenamento seguro de chaves deste computador não aceitou a chave deste correio, por isso não está protegida como devia. Reinicie a aplicação e use a encriptação do disco.",
"rekey_progress": "A encriptar de novo o correio neste dispositivo: {percent}%. Pode continuar a trabalhar.",
"rekey_failed": "A nova encriptação do correio neste dispositivo não terminou, por isso a chave antiga ainda o pode abrir. A aplicação tenta de novo no próximo arranque.",
"reenroll_note": "A chave de acesso e o desbloqueio do sistema usavam a chave do PIN antigo, por isso foram removidos. Configure-os de novo se os usar."
},
"offlineDrafts": {
"title": "Rascunhos neste dispositivo",
"description": "Estes rascunhos estão guardados neste dispositivo. Vão para o servidor por si assim que for possível contactá-lo.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Bloqueio da app",
"section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.",
"section_desc": "Pede um PIN após algum tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, a aplicação não consegue ler o correio guardado neste dispositivo até alguém introduzir o PIN.",
"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.",
"protects_desc": "Contra o que protege: o PIN impede quem está neste dispositivo de entrar na aplicação. O correio guardado aqui recebe uma nova chave quando ativa o bloqueio ou muda o PIN, o que pode demorar um momento. Não consegue impedir um programa a correr neste computador, uma extensão de navegador que instalou nem alguém que copie os ficheiros da aplicação e tenha tempo para tentar muitos PIN, por isso vale a pena um PIN mais longo. Na aplicação de computador, tentar PIN também exige a sua 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Ce păstrează acest dispozitiv",
"pin_not_ready": "Acest fișier nu se află încă în lista acestui dispozitiv. Încercați din nou peste un moment."
},
"keyProtection": {
"browser_note": "Într-un browser, cheia acestor mesaje se află în același profil de browser ca mesajele. Cine copiază fișierele profilului le poate citi, așa că folosește criptarea discului.",
"no_secure_storage": "Acest computer nu are un spațiu securizat pentru chei pe care aplicația să îl poată folosi, așa că cheia acestor mesaje este doar mascată pe disc. Folosește criptarea discului.",
"wrap_failed": "Spațiul securizat pentru chei al acestui computer nu a acceptat cheia acestor mesaje, așa că nu este protejată cum ar trebui. Repornește aplicația și folosește criptarea discului.",
"rekey_progress": "Se recriptează mesajele de pe acest dispozitiv: {percent}%. Poți lucra în continuare.",
"rekey_failed": "Recriptarea mesajelor de pe acest dispozitiv nu s-a încheiat, așa că vechea cheie le mai poate deschide. Aplicația încearcă din nou la următoarea pornire.",
"reenroll_note": "Cheia de acces și deblocarea de sistem foloseau cheia vechiului PIN, așa că au fost eliminate. Configurează-le din nou dacă le folosești."
},
"offlineDrafts": {
"title": "Ciorne pe acest dispozitiv",
"description": "Aceste ciorne sunt salvate pe acest dispozitiv. Ajung singure pe server imediat ce poate fi contactat.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Blocarea aplicației",
"section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.",
"section_desc": "Cere un PIN după o perioadă fără utilizare, în fiecare fereastră și filă. Cât timp este blocată, aplicația nu poate citi mesajele stocate pe acest dispozitiv până când cineva introduce PIN-ul.",
"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.",
"protects_desc": "De ce protejează: PIN-ul ține în afara aplicației pe cineva aflat la acest dispozitiv. Mesajele stocate aici primesc o cheie nouă când activezi blocarea sau schimbi PIN-ul, ceea ce poate dura puțin. Nu poate opri un program care rulează pe acest computer, o extensie de browser instalată de tine sau pe cineva care copiază fișierele aplicației și are timp să încerce multe PIN-uri, așa că merită un PIN mai lung. În aplicația desktop, încercarea PIN-urilor cere și autentificarea ta pe acest computer. Contul tău 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Что хранит это устройство",
"pin_not_ready": "Этого файла ещё нет в списке этого устройства. Попробуйте ещё раз через минуту."
},
"keyProtection": {
"browser_note": "В браузере ключ к этой почте лежит в том же профиле браузера, что и почта. Тот, кто скопирует файлы профиля, сможет её прочитать, поэтому используйте шифрование диска.",
"no_secure_storage": "На этом компьютере нет защищённого хранилища ключей, которое могло бы использовать приложение, поэтому ключ к этой почте на диске лишь замаскирован. Используйте шифрование диска.",
"wrap_failed": "Защищённое хранилище ключей этого компьютера не приняло ключ к этой почте, поэтому он защищён не так, как должен. Перезапустите приложение и используйте шифрование диска.",
"rekey_progress": "Почта на этом устройстве шифруется заново: {percent} %. Можно продолжать работу.",
"rekey_failed": "Новое шифрование почты на этом устройстве не завершилось, поэтому старый ключ всё ещё может её открыть. Приложение повторит попытку при следующем запуске.",
"reenroll_note": "Ключ доступа и системная разблокировка использовали ключ старого PIN-кода, поэтому они удалены. Настройте их заново, если пользуетесь ими."
},
"offlineDrafts": {
"title": "Черновики на этом устройстве",
"description": "Эти черновики сохранены на этом устройстве. Они сами уйдут на сервер, как только он станет доступен.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Блокировка приложения",
"section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.",
"section_desc": "Запрашивает PIN-код после периода бездействия во всех окнах и вкладках. Пока приложение заблокировано, оно не может читать почту, сохранённую на этом устройстве, пока кто-нибудь не введёт PIN-код.",
"enable_label": "Блокировать PIN-кодом",
"enable_desc": "По умолчанию выключено. PIN-код остаётся на этом устройстве.",
"protects_desc": "От чего это защищает: в браузере PIN-код не пускает в приложение того, кто находится у этого устройства, и хранит сохранённую здесь почту в зашифрованном виде. Он не остановит программу на этом компьютере, установленное вами расширение браузера или того, кто скопирует файлы браузера и сможет перебирать много кодов, поэтому стоит выбрать PIN-код подлиннее. В настольном приложении PIN-код также привязан к вашему входу на этом компьютере. Ваша учётная запись на сервере не затрагивается.",
"protects_desc": "От чего это защищает: PIN-код не пускает в приложение того, кто находится у этого устройства. Сохранённая здесь почта получает новый ключ, когда вы включаете блокировку или меняете PIN-код, и это может занять некоторое время. Это не остановит программу, запущенную на этом компьютере, установленное вами расширение браузера или того, кто скопирует файлы приложения и успеет перебрать много PIN-кодов, поэтому длинный PIN-код стоит того. В настольном приложении для перебора PIN-кодов нужен ещё и ваш вход на этом компьютере. Ваша учётная запись на сервере не затрагивается.",
"enable_body": "Выберите PIN-код. Он понадобится при каждой блокировке приложения, а также после перезагрузки или нового запуска.",
"set_pin_title": "Выберите PIN-код",
"change_pin": "Изменить PIN-код…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Čo toto zariadenie uchováva",
"pin_not_ready": "Tento súbor ešte nie je v zozname tohto zariadenia. Skúste to o chvíľu znova."
},
"keyProtection": {
"browser_note": "V prehliadači je kľúč k tejto pošte v tom istom profile prehliadača ako pošta. Kto skopíruje súbory profilu, môže ju čítať, preto používajte šifrovanie disku.",
"no_secure_storage": "Tento počítač nemá zabezpečené úložisko kľúčov, ktoré by aplikácia mohla použiť, takže kľúč k tejto pošte je na disku len zastretý. Používajte šifrovanie disku.",
"wrap_failed": "Zabezpečené úložisko kľúčov tohto počítača neprijalo kľúč k tejto pošte, takže nie je chránený, ako by mal byť. Reštartujte aplikáciu a používajte šifrovanie disku.",
"rekey_progress": "Pošta v tomto zariadení sa znova šifruje: {percent} %. Môžete ďalej pracovať.",
"rekey_failed": "Nové šifrovanie pošty v tomto zariadení sa nedokončilo, takže ju starý kľúč stále otvorí. Aplikácia to skúsi znova pri ďalšom spustení.",
"reenroll_note": "Prístupový kľúč a odomknutie systémom používali kľúč starého PIN-u, preto boli odstránené. Ak ich používate, nastavte ich znova."
},
"offlineDrafts": {
"title": "Koncepty v tomto zariadení",
"description": "Tieto koncepty sú uložené v tomto zariadení. Na server sa odošlú samy, hneď ako bude dostupný.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Zámok aplikácie",
"section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.",
"section_desc": "Po čase nečinnosti žiada PIN v každom okne a na každej karte. Kým je zamknuté, aplikácia nemôže čítať poštu uloženú v tomto zariadení, kým niekto nezadá PIN.",
"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í.",
"protects_desc": "Pred čím to chráni: PIN nepustí do aplikácie nikoho, kto je pri tomto zariadení. Pošta uložená tu dostane nový kľúč, keď zámok zapnete alebo zmeníte PIN, čo môže chvíľu trvať. Nezastaví program spustený na tomto počítači, rozšírenie prehliadača, ktoré ste nainštalovali, ani niekoho, kto skopíruje súbory aplikácie a má čas skúšať veľa PIN-ov, takže dlhší PIN sa oplatí. V počítačovej aplikácii je na skúšanie PIN-ov potrebné aj 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…",
+10 -2
View File
@@ -2240,6 +2240,14 @@
"usage_label": "สิ่งที่อุปกรณ์นี้เก็บไว้",
"pin_not_ready": "ไฟล์นี้ยังไม่อยู่ในรายการของอุปกรณ์นี้ ลองอีกครั้งในอีกสักครู่"
},
"keyProtection": {
"browser_note": "ในเบราว์เซอร์ กุญแจของอีเมลนี้อยู่ในโปรไฟล์เบราว์เซอร์เดียวกับอีเมล ผู้ที่คัดลอกไฟล์ของโปรไฟล์จะอ่านอีเมลได้ จึงควรใช้การเข้ารหัสดิสก์",
"no_secure_storage": "คอมพิวเตอร์เครื่องนี้ไม่มีที่เก็บกุญแจแบบปลอดภัยที่แอปใช้ได้ กุญแจของอีเมลนี้จึงถูกทำให้อ่านยากบนดิสก์เท่านั้น โปรดใช้การเข้ารหัสดิสก์",
"wrap_failed": "ที่เก็บกุญแจแบบปลอดภัยของคอมพิวเตอร์เครื่องนี้ไม่รับกุญแจของอีเมลนี้ กุญแจจึงไม่ได้รับการปกป้องตามที่ควร โปรดรีสตาร์ตแอปและใช้การเข้ารหัสดิสก์",
"rekey_progress": "กำลังเข้ารหัสอีเมลบนอุปกรณ์นี้ใหม่: {percent}% คุณทำงานต่อได้",
"rekey_failed": "การเข้ารหัสอีเมลบนอุปกรณ์นี้ใหม่ยังไม่เสร็จ กุญแจเก่าจึงยังเปิดได้ แอปจะลองอีกครั้งเมื่อเริ่มครั้งถัดไป",
"reenroll_note": "พาสคีย์และการปลดล็อกของระบบใช้กุญแจของ PIN เก่า จึงถูกนำออกแล้ว โปรดตั้งค่าใหม่หากคุณใช้งาน"
},
"offlineDrafts": {
"title": "ฉบับร่างในอุปกรณ์นี้",
"description": "ฉบับร่างเหล่านี้บันทึกไว้ในอุปกรณ์นี้ และจะส่งไปยังเซิร์ฟเวอร์เองเมื่อเชื่อมต่อได้",
@@ -6814,10 +6822,10 @@
},
"appLock": {
"section_title": "ล็อกแอป",
"section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ",
"section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างที่ล็อกอยู่ แอปจะอ่านอีเมลที่เก็บบนอุปกรณ์นี้ไม่ได้จนกว่าจะมีผู้ป้อน PIN",
"enable_label": "ล็อกด้วย PIN",
"enable_desc": "ปิดอยู่โดยค่าเริ่มต้น PIN จะอยู่บนอุปกรณ์นี้",
"protects_desc": "สิ่งที่ป้องกันได้: ในเบราว์เซอร์ PIN กันคนที่อยู่หน้าอุปกรณ์นี้ไม่ให้เข้าแอป และเก็บอีเมลที่บันทึกไว้ที่นี่แบบเข้ารหัส แต่หยุดโปรแกรมที่ทำงานบนคอมพิวเตอร์เครื่องนี้ ส่วนขยายเบราว์เซอร์ที่คุณติดตั้ง หรือคนที่คัดลอกไฟล์ของเบราว์เซอร์แล้วมีเวลาลอง PIN จำนวนมากไม่ได้ จึงควรใช้ PIN ที่ยาวขึ้น ในแอปเดสก์ท็อป PIN ยังผูกกับการเข้าสู่ระบบคอมพิวเตอร์เครื่องนี้ด้วย บัญชีของคุณบนเซิร์ฟเวอร์ไม่ได้รับผลกระทบ",
"protects_desc": "สิ่งที่ป้องกันได้: PIN กันผู้ที่อยู่ตรงอุปกรณ์นี้ไม่ให้เข้าแอป อีเมลที่เก็บไว้ที่นี่จะได้กุญแจใหม่เมื่อคุณเปิดการล็อกหรือเปลี่ยน PIN ซึ่งอาจใช้เวลาสักครู่ แต่ไม่สามารถหยุดโปรแกรมที่ทำงานบนคอมพิวเตอร์เครื่องนี้ ส่วนขยายเบราว์เซอร์ที่คุณติดตั้ง หรือผู้ที่คัดลอกไฟล์ของแอปและมีเวลาลอง PIN จำนวนมาก PIN ที่ยาวขึ้นจึงคุ้มค่า ในแอปเดสก์ท็อป การลอง PIN ต้องใช้การเข้าสู่ระบบของคุณบนคอมพิวเตอร์เครื่องนี้ด้วย บัญชีของคุณบนเซิร์ฟเวอร์ไม่ได้รับผลกระทบ",
"enable_body": "เลือก PIN คุณต้องใช้ทุกครั้งที่แอปล็อก และหลังโหลดใหม่หรือเริ่มใหม่",
"set_pin_title": "เลือก PIN",
"change_pin": "เปลี่ยน PIN…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Bu cihazın tuttukları",
"pin_not_ready": "Bu dosya henüz bu cihazın listesinde değil. Biraz sonra yeniden deneyin."
},
"keyProtection": {
"browser_note": "Tarayıcıda bu postanın anahtarı, postayla aynı tarayıcı profilinde durur. Profil dosyalarını kopyalayan biri postayı okuyabilir, bu yüzden disk şifrelemesi kullanın.",
"no_secure_storage": "Bu bilgisayarda uygulamanın kullanabileceği güvenli bir anahtar deposu yok, bu yüzden bu postanın anahtarı diskte yalnızca gizlenmiş durumda. Disk şifrelemesi kullanın.",
"wrap_failed": "Bu bilgisayarın güvenli anahtar deposu bu postanın anahtarını kabul etmedi, bu yüzden anahtar olması gerektiği gibi korunmuyor. Uygulamayı yeniden başlatın ve disk şifrelemesi kullanın.",
"rekey_progress": "Bu cihazdaki posta yeniden şifreleniyor: %{percent}. Çalışmaya devam edebilirsiniz.",
"rekey_failed": "Bu cihazdaki postanın yeniden şifrelenmesi bitmedi, bu yüzden eski anahtar onu hâlâ açabilir. Uygulama bir sonraki başlangıçta yeniden dener.",
"reenroll_note": "Geçiş anahtarı ve sistem kilidi açma eski PIN'in anahtarını kullanıyordu, bu yüzden kaldırıldı. Kullanıyorsanız yeniden ayarlayın."
},
"offlineDrafts": {
"title": "Bu cihazdaki taslaklar",
"description": "Bu taslaklar bu cihaza kaydedildi. Sunucuya ulaşılır ulaşılmaz kendiliğinden gönderilir.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Uygulama kilidi",
"section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.",
"section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken uygulama, biri PIN'i girene kadar bu cihazda saklanan postayı okuyamaz.",
"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.",
"protects_desc": "Neye karşı korur: PIN, bu cihazın başındaki birini uygulamanın dışında tutar. Burada saklanan posta, kilidi açtığınızda veya PIN'i değiştirdiğinizde yeni bir anahtar alır; bu biraz sürebilir. Bu bilgisayarda çalışan bir programı, yüklediğiniz bir tarayıcı uzantısını veya uygulamanı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 denemek için bu bilgisayardaki oturumunuz da gerekir. Sunucudaki hesabınız bundan 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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "Що зберігає цей пристрій",
"pin_not_ready": "Цього файла ще немає у списку цього пристрою. Спробуйте ще раз за хвилину."
},
"keyProtection": {
"browser_note": "У браузері ключ до цієї пошти лежить у тому самому профілі браузера, що й пошта. Той, хто скопіює файли профілю, зможе її прочитати, тому використовуйте шифрування диска.",
"no_secure_storage": "На цьому комп'ютері немає захищеного сховища ключів, яке міг би використати застосунок, тому ключ до цієї пошти на диску лише замаскований. Використовуйте шифрування диска.",
"wrap_failed": "Захищене сховище ключів цього комп'ютера не прийняло ключ до цієї пошти, тому він захищений не так, як має бути. Перезапустіть застосунок і використовуйте шифрування диска.",
"rekey_progress": "Пошта на цьому пристрої шифрується наново: {percent} %. Можна працювати далі.",
"rekey_failed": "Нове шифрування пошти на цьому пристрої не завершилося, тому старий ключ досі може її відкрити. Застосунок спробує знову під час наступного запуску.",
"reenroll_note": "Ключ доступу та системне розблокування використовували ключ старого PIN-коду, тому їх вилучено. Налаштуйте їх знову, якщо користуєтеся ними."
},
"offlineDrafts": {
"title": "Чернетки на цьому пристрої",
"description": "Ці чернетки збережено на цьому пристрої. Вони самі підуть на сервер, щойно він стане доступним.",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "Блокування застосунку",
"section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.",
"section_desc": "Запитує PIN-код після періоду без використання в кожному вікні та вкладці. Поки застосунок заблоковано, він не може читати пошту, збережену на цьому пристрої, доки хтось не введе PIN-код.",
"enable_label": "Блокувати PIN-кодом",
"enable_desc": "Типово вимкнено. PIN-код залишається на цьому пристрої.",
"protects_desc": "Від чого це захищає: у браузері PIN-код не пускає до застосунку того, хто біля цього пристрою, і зберігає пошту тут зашифрованою. Він не зупинить програму на цьому комп'ютері, встановлене вами розширення браузера або того, хто скопіює файли браузера і матиме час перебирати багато кодів, тому варто обрати довший PIN-код. У настільному застосунку PIN-код також прив'язаний до вашого входу на цьому комп'ютері. Ваш обліковий запис на сервері це не зачіпає.",
"protects_desc": "Від чого це захищає: PIN-код не пускає до застосунку того, хто стоїть біля цього пристрою. Збережена тут пошта отримує новий ключ, коли ви вмикаєте блокування або змінюєте PIN-код, і це може тривати якийсь час. Це не зупинить програму, запущену на цьому комп'ютері, встановлене вами розширення браузера або того, хто скопіює файли застосунку й матиме час перебрати багато PIN-кодів, тому довший PIN-код вартий того. У застосунку для комп'ютера для перебору PIN-кодів потрібен ще й ваш вхід на цьому комп'ютері. Ваш обліковий запис на сервері це не зачіпає.",
"enable_body": "Виберіть PIN-код. Він знадобиться щоразу, коли застосунок блокується, а також після перезавантаження чи нового запуску.",
"set_pin_title": "Виберіть PIN-код",
"change_pin": "Змінити PIN-код…",
+10 -2
View File
@@ -5907,6 +5907,14 @@
"usage_label": "Những gì thiết bị này giữ",
"pin_not_ready": "Tệp này chưa có trong danh sách của thiết bị này. Hãy thử lại sau giây lát."
},
"keyProtection": {
"browser_note": "Trong trình duyệt, khóa của thư này nằm trong cùng hồ sơ trình duyệt với thư. Ai sao chép các tệp của hồ sơ đều có thể đọc được, vì vậy hãy dùng mã hóa ổ đĩa.",
"no_secure_storage": "Máy tính này không có kho khóa an toàn mà ứng dụng có thể dùng, nên khóa của thư này chỉ được làm rối trên ổ đĩa. Hãy dùng mã hóa ổ đĩa.",
"wrap_failed": "Kho khóa an toàn của máy tính này không nhận khóa của thư này, nên khóa không được bảo vệ như đáng lẽ phải có. Hãy khởi động lại ứng dụng và dùng mã hóa ổ đĩa.",
"rekey_progress": "Đang mã hóa lại thư trên thiết bị này: {percent}%. Bạn có thể tiếp tục làm việc.",
"rekey_failed": "Việc mã hóa lại thư trên thiết bị này chưa xong, nên khóa cũ vẫn có thể mở được. Ứng dụng sẽ thử lại ở lần khởi động sau.",
"reenroll_note": "Mã khóa truy cập và mở khóa hệ thống đã dùng khóa của mã PIN cũ nên đã bị gỡ bỏ. Hãy thiết lập lại nếu bạn dùng chúng."
},
"offlineDrafts": {
"title": "Bản nháp trên thiết bị này",
"description": "Các bản nháp này được lưu trên thiết bị này. Chúng sẽ tự chuyển lên máy chủ ngay khi kết nối được.",
@@ -6818,10 +6826,10 @@
},
"appLock": {
"section_title": "Khóa ứng dụng",
"section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.",
"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à tab. Khi đang khóa, ứng dụng không thể đọc thư lưu trên thiết bị này cho đến khi có người nhập mã PIN.",
"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.",
"protects_desc": "Điều này bảo vệ khỏi: mã PIN giữ người đang ở thiết bị này bên ngoài ứng dụng. Thư lưu ở đây nhận khóa mới khi bạn bật khóa hoặc đổi mã PIN, việc này có thể mất một lúc. Nó không thể ngăn một chương trình chạy trên máy tính này, một tiện ích trình duyệt bạn đã cài, hoặc người sao chép các tệp của ứng dụng và có thời gian thử nhiều mã PIN, vì vậy mã PIN dài hơn là đáng giá. Trong ứng dụng máy tính, việc thử mã PIN còn cần phiên đă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…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "此裝置保留的內容",
"pin_not_ready": "此檔案還不在此裝置的清單中。請稍後再試。"
},
"keyProtection": {
"browser_note": "在瀏覽器中,這些郵件的金鑰與郵件位於同一個瀏覽器設定檔中。任何複製該設定檔的人都能讀取郵件,因此請使用磁碟加密。",
"no_secure_storage": "這台電腦沒有應用程式可用的安全金鑰儲存區,因此這些郵件的金鑰在磁碟上只是經過混淆。請使用磁碟加密。",
"wrap_failed": "這台電腦的安全金鑰儲存區沒有接受這些郵件的金鑰,因此金鑰沒有得到應有的保護。請重新啟動應用程式,並使用磁碟加密。",
"rekey_progress": "正在重新加密此裝置上的郵件:{percent}%。你可以繼續工作。",
"rekey_failed": "此裝置上郵件的重新加密沒有完成,因此舊金鑰仍能開啟郵件。應用程式會在下次啟動時重試。",
"reenroll_note": "通行密鑰和系統解鎖使用的是舊 PIN 的金鑰,因此已被移除。如果你要使用,請重新設定。"
},
"offlineDrafts": {
"title": "此裝置上的草稿",
"description": "這些草稿儲存在此裝置上。一旦能連線到伺服器,就會自動上傳。",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "應用程式鎖定",
"section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。",
"section_desc": "在一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,在有人輸入 PIN 之前,應用程式無法讀取儲存在此裝置上的郵件。",
"enable_label": "使用 PIN 鎖定",
"enable_desc": "預設關閉。PIN 只保存在此裝置上。",
"protects_desc": "它能防護什麼:在瀏覽器中,PIN 可阻止這台裝置前的人進入應用程式,並讓儲存在此處的郵件保持加密。它無法阻止在這台電腦上執行的程式、你安裝的瀏覽器擴充功能,或複製瀏覽器檔案並有時間嘗試大量 PIN 的人,因此較長的 PIN 更值得。在桌面應用程式中,PIN 也與你在這台電腦上的登入綁定。伺服器上的帳戶不受影響。",
"protects_desc": "它能防範什麼:PIN 讓此裝置旁的人無法進入應用程式。當你開啟鎖定或變更 PIN 時,這裡儲存的郵件會取得新金鑰,這可能需要一點時間。它無法阻止在這台電腦上執行的程式、你安裝的瀏覽器擴充功能,也無法阻止複製應用程式檔案並有時間嘗試大量 PIN 的人,因此較長的 PIN 是值得的。在桌面應用程式中,嘗試 PIN 還需要你在這台電腦上的登入。你在伺服器上的帳戶不受影響。",
"enable_body": "請選擇一個 PIN。每次應用程式鎖定時以及重新載入或重新啟動後都需要它。",
"set_pin_title": "選擇 PIN",
"change_pin": "變更 PIN…",
+10 -2
View File
@@ -5895,6 +5895,14 @@
"usage_label": "此设备保留的内容",
"pin_not_ready": "此文件还不在此设备的列表中。请稍后再试。"
},
"keyProtection": {
"browser_note": "在浏览器中,这些邮件的密钥与邮件位于同一个浏览器配置文件中。任何复制该配置文件的人都能读取邮件,因此请使用磁盘加密。",
"no_secure_storage": "这台电脑没有应用可用的安全密钥存储,因此这些邮件的密钥在磁盘上只是经过混淆。请使用磁盘加密。",
"wrap_failed": "这台电脑的安全密钥存储没有接受这些邮件的密钥,因此密钥没有得到应有的保护。请重启应用,并使用磁盘加密。",
"rekey_progress": "正在重新加密此设备上的邮件:{percent}%。你可以继续工作。",
"rekey_failed": "此设备上邮件的重新加密没有完成,因此旧密钥仍能打开邮件。应用会在下次启动时重试。",
"reenroll_note": "通行密钥和系统解锁使用的是旧 PIN 的密钥,因此已被移除。如果你要使用,请重新设置。"
},
"offlineDrafts": {
"title": "此设备上的草稿",
"description": "这些草稿保存在此设备上。一旦能连接服务器,就会自动上传。",
@@ -6806,10 +6814,10 @@
},
"appLock": {
"section_title": "应用锁",
"section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。",
"section_desc": "在一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,在有人输入 PIN 之前,应用无法读取存储在此设备上的邮件。",
"enable_label": "使用 PIN 锁定",
"enable_desc": "默认关闭。PIN 只保存在此设备上。",
"protects_desc": "它能防护什么:在浏览器中,PIN 可阻止这台设备前的人进入应用,并让存储在此处的邮件保持加密。它无法阻止在这台电脑上运行的程序、你安装的浏览器扩展,或复制浏览器文件并有时间尝试大量 PIN 的人,因此更长的 PIN 更值得。在桌面应用中,PIN 还与你在这台电脑上的登录绑定。服务器上的账户不受影响。",
"protects_desc": "它能防范什么:PIN 让此设备旁的人无法进入应用。当你开启锁定或更改 PIN 时,这里存储的邮件会获得新密钥,这可能需要一点时间。它无法阻止在这台电脑上运行的程序、你安装的浏览器扩展,也无法阻止复制应用文件并有时间尝试大量 PIN 的人,因此较长的 PIN 是值得的。在桌面应用中,尝试 PIN 还需要你在这台电脑上的登录。你在服务器上的账户不受影响。",
"enable_body": "请选择一个 PIN。每次应用锁定时以及重新加载或重新启动后都需要它。",
"set_pin_title": "选择 PIN",
"change_pin": "更改 PIN…",
+5 -1
View File
@@ -44,7 +44,7 @@ import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/ch
import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role';
import { noteCredentialsRejected, type ForcedSignOutKind } from '@/lib/offline/credentials-rejected';
import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state';
import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state';
import { checkStoreOwner, markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state';
import { toAsciiEmail } from '@/lib/idn';
import { flushComposers } from '@/lib/compose-flush-registry';
import { sortIdentities } from '@/lib/identity-order';
@@ -1805,6 +1805,7 @@ export const useAuthStore = create<AuthState>()(
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
initializeFeatureStores(client);
await checkStoreOwner(accountId, client.getAccountId(), serverUrl);
// Store client in multi-account map
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
@@ -1947,6 +1948,7 @@ export const useAuthStore = create<AuthState>()(
const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
initializeFeatureStores(client);
await checkStoreOwner(accountId, client.getAccountId(), serverUrl);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
@@ -2163,6 +2165,7 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
}
await checkStoreOwner(accountId, client.getAccountId(), serverUrl);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);
@@ -2312,6 +2315,7 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
}
await checkStoreOwner(accountId, client.getAccountId(), ssoServerUrl);
holdClient(accountId, client);
bindClientStatusHandlers(client, set, get, accountId);