import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { composeSnapshots, registerComposeFlush, registerComposeSnapshot, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; import { isAppLocked, setAppLockCheck } from '@/lib/desktop/app-lock-state'; import { configureStore, getStore, setStoreBackend, type StorePageHooks } from '@/lib/store/client/api'; import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys'; import { addOsUnlock, appLockHold, changeAppLockPin, appLockKeyLayers, bootAppLock, disableAppLock, enableAppLock, forgetEverything, getAppLockPhase, lockEverywhere, resetAppLockForTests, setReloadForTests, startAppLock, STAY_RETRY_MS, unlockWithOs, unlockWithPin, } from '../controller'; import { LOCK_LAYER } 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'; const PIN = 'correct-pin'; class FakeChannel { static open = new Set(); onmessage: ((event: MessageEvent) => void) | null = null; constructor(readonly name: string) { FakeChannel.open.add(this); } postMessage(data: unknown): void { for (const channel of FakeChannel.open) { if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent); } } close(): void { FakeChannel.open.delete(this); } } class FakeWorker { static all: FakeWorker[] = []; onmessage: ((event: MessageEvent) => void) | null = null; posted: Array> = []; terminated = false; constructor() { FakeWorker.all.push(this); // As the real one does with no other tab around. queueMicrotask(() => this.onmessage?.({ data: { kind: 'owner' } } as MessageEvent)); } postMessage(message: Record): void { this.posted.push(message); if (message.kind === 'open' || message.kind === 'release' || message.kind === 'prune-files') { queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: message.kind === 'open' ? { created: false } : undefined } } as MessageEvent)); } } terminate(): void { this.terminated = true; } } const hooks: StorePageHooks = { inputs: () => ({ session: { apiUrl: 'x' } as never, auth: 'Bearer token', invertedKeywordSort: null }), refreshAuth: async () => null, onEvent: () => {}, keyLayers: appLockKeyLayers, }; let reloads = 0; let stopTab: () => void = () => {}; function bridge(overrides: Record = {}) { const appLock = { status: vi.fn(async () => ({ locked: false, enrolled: true, os: 'hello', osEnrolled: true })), lock: vi.fn(), enroll: vi.fn(async () => true), prove: vi.fn(async () => true), disable: vi.fn(async () => true), forget: vi.fn(async () => true), clearOrphan: vi.fn(async () => true), osEnroll: vi.fn(async () => true), osRemove: vi.fn(async () => {}), osUnlock: vi.fn(async () => ({ ok: false as const, reason: 'denied' as const })), onLockRequest: vi.fn(() => () => {}), claim: vi.fn(() => ({ lock: appLock.lock, osUnlock: appLock.osUnlock })), wrap: vi.fn(async () => null), unwrap: vi.fn(async () => null), ...overrides, }; (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', appLock }; return appLock; } beforeEach(async () => { FakeChannel.open.clear(); FakeWorker.all = []; vi.stubGlobal('BroadcastChannel', FakeChannel); vi.stubGlobal('Worker', FakeWorker); vi.stubGlobal('FileSystemFileHandle', class {}); vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 200 }))); Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => ({}) } }); Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: vi.fn(async (_name: string, ...rest: unknown[]) => { const run = rest[rest.length - 1] as () => unknown; return run(); }), query: vi.fn(async () => ({ held: [], pending: [] })), }, }); localStorage.clear(); sessionStorage.clear(); reloads = 0; setReloadForTests(() => { reloads += 1; }); resetAppLockForTests(); resetComposeFlushRegistry(); setStoreBackend(null); configureStore(hooks); await deleteVault(); deleteKeyDatabase(); }); afterEach(() => { stopTab(); stopTab = () => {}; vi.unstubAllGlobals(); setReloadForTests(null); setAppLockCheck(null); delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; delete (window.navigator as { storage?: unknown }).storage; delete (window.navigator as { locks?: unknown }).locks; setStoreBackend(null); configureStore(null); setLockedHere(false); }); /** A store open under the lock, as a running tab has it. */ async function runningLockedStore(account = 'acc') { await enableAppLock(PIN); await getStore().ensureOpen(account, { apiUrl: 'x' } as never); await vi.waitFor(() => expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(true)); return lastWorker(); } function lastWorker(): FakeWorker { return FakeWorker.all[FakeWorker.all.length - 1]; } async function lockedAndReloaded(): Promise { lockEverywhere(); await vi.waitFor(() => expect(reloads).toBe(1)); } /** The page a reload gives: new module state, the same storage. */ function asNewDocument(): void { resetAppLockForTests(); bootAppLock(); } describe('while locked', () => { it('the worker with the key is gone, the key with it, and reads fail until unlock', async () => { const worker = await runningLockedStore(); const opened = worker.posted.find((m) => m.kind === 'open') as { params: { keyHex: string } }; expect(opened.params.keyHex).toMatch(/^[0-9a-f]{64}$/); await lockedAndReloaded(); expect(worker.posted.some((m) => m.kind === 'release')).toBe(true); expect(worker.terminated).toBe(true); const workers = FakeWorker.all.length; await expect(getStore().answer('acc', [])).rejects.toMatchObject({ name: 'StoreLockedError' }); await expect(getStore().area('acc', 'x.y')).rejects.toMatchObject({ name: 'StoreLockedError' }); expect(FakeWorker.all.length, 'no new worker while locked').toBe(workers); // In this tab's memory too: nothing gives the layer now. expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); await expect(openAccountKey('acc')).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' }); await unlockWithPin(PIN); expect(isAppLockedNow()).toBe(false); await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex: opened.params.keyHex }); }); it('a stuck close doesn\'t keep the key: the worker is stopped after the wait', async () => { const worker = await runningLockedStore(); worker.postMessage = function postMessage(message) { this.posted.push(message); }; await getStore().lock?.(10); expect(worker.terminated).toBe(true); }); it('a new start stays closed: the app is held back and nothing has the key', async () => { await enableAppLock(PIN); await lockedAndReloaded(); asNewDocument(); expect(getAppLockPhase()).toBe('locked'); expect(appLockHold()).toBeInstanceOf(Promise); expect(isAppLockedNow()).toBe(true); }); it('a running page isn\'t held back while it saves: composers stay mounted until the reload', async () => { await enableAppLock(PIN); let holdDuringSave: Promise | null | undefined; registerComposeFlush(async () => { holdDuringSave = appLockHold(); }); await lockedAndReloaded(); expect(holdDuringSave).toBeNull(); }); it('the check the OS code asks follows the lock both ways', async () => { expect(isAppLocked()).toBe(false); await enableAppLock(PIN); asNewDocument(); expect(isAppLocked()).toBe(true); await unlockWithPin(PIN); expect(isAppLocked()).toBe(false); }); it('a start that finds the app locked takes subjects out of storage too: a quit or a crash left them there', async () => { await enableAppLock(PIN); await lockedAndReloaded(); localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}'); localStorage.setItem('contact-storage', '{"state":{"contacts":[{"name":"Secret Person"}]}}'); localStorage.setItem('notification-center', '[{"title":"Secret notice"}]'); asNewDocument(); await vi.waitFor(() => expect(JSON.stringify({ ...localStorage })).not.toContain('Secret')); }); it('so does a start after a crash while unlocked, once it finds nobody holds the key', async () => { await enableAppLock(PIN); resetAppLockForTests(); localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}'); localStorage.setItem('notification-center', '[{"title":"Secret notice"}]'); bootAppLock(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); await vi.waitFor(() => expect(JSON.stringify({ ...localStorage })).not.toContain('Secret')); }); it('a start where another tab holds the key leaves storage alone', async () => { await enableAppLock(PIN); resetAppLockForTests(); localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Kept"}]}'); (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); bootAppLock(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); expect(localStorage.getItem('email-snapshot')).toContain('Kept'); }); it('contacts and notifications come back at unlock, sealed while locked', async () => { await enableAppLock(PIN); localStorage.setItem('contact-storage', '{"state":{"contacts":[{"name":"Secret Person"}]},"version":0}'); localStorage.setItem('notification-center', JSON.stringify([{ id: 'n', time: 1, level: 'info', title: 'Secret notice', body: '', read: false }])); await lockedAndReloaded(); expect(JSON.stringify({ ...localStorage })).not.toContain('Secret'); await unlockWithPin(PIN); expect(localStorage.getItem('contact-storage')).toContain('Secret Person'); expect(localStorage.getItem('notification-center')).toContain('Secret notice'); const { useNotificationCenterStore } = await import('@/stores/notification-center-store'); expect(useNotificationCenterStore.getState().entries.map((entry) => entry.title)).toContain('Secret notice'); }); it('a newer copy replaces the sealed one instead of piling up', async () => { await enableAppLock(PIN); localStorage.setItem('notification-center', '[{"id":"n","title":"Old notice"}]'); await lockedAndReloaded(); localStorage.setItem('notification-center', '[{"id":"n","title":"New notice"}]'); asNewDocument(); await vi.waitFor(() => expect(localStorage.getItem('notification-center')).toBeNull()); await unlockWithPin(PIN); expect(localStorage.getItem('notification-center')).toContain('New notice'); }); it('a browser closed without locking counts as locked: nobody holds the key', async () => { await enableAppLock(PIN); resetAppLockForTests(); expect(readAppLockFile().locked).toBe(false); bootAppLock(); expect(getAppLockPhase()).toBe('checking'); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); expect(readAppLockFile().locked).toBe(true); }); it('another tab holding the key lets this one start without any key', async () => { await enableAppLock(PIN); resetAppLockForTests(); (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); // Held elsewhere: a request for it waits. (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); bootAppLock(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); expect(() => appLockKeyLayers()).toThrow(expect.objectContaining({ name: 'StoreKeyLayerMissingError' })); }); }); describe('locking', () => { it('saves what is being written before the store closes', async () => { const worker = await runningLockedStore(); const order: string[] = []; registerComposeFlush(async () => { order.push(worker.terminated ? 'saved after close' : 'saved'); }); await lockedAndReloaded(); expect(order).toEqual(['saved']); }); it('seals text the server didn\'t take, and it comes back only after unlock', async () => { await enableAppLock(PIN); registerComposeFlush(async () => { throw new Error('offline'); }); registerComposeSnapshot({ ownWindow: false, state: () => ({ subject: 'Unsaved secret', unsaved: true }) as never, draft: () => null }); await lockedAndReloaded(); expect(JSON.stringify({ ...localStorage })).not.toContain('Unsaved secret'); expect(JSON.stringify({ ...sessionStorage })).not.toContain('Unsaved secret'); await unlockWithPin(PIN); await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Unsaved secret')); }); it('takes subjects out of plain browser storage', async () => { await enableAppLock(PIN); localStorage.setItem('email-snapshot', '{"emails":[{"subject":"Secret"}]}'); localStorage.setItem('pro-tabs', '{"tabs":[{"title":"Secret"}]}'); sessionStorage.setItem('bulwark-popout-compose:h1', '{"initialData":{"subject":"Secret"}}'); await lockedAndReloaded(); expect(localStorage.getItem('email-snapshot')).toBeNull(); expect(localStorage.getItem('pro-tabs')).toBeNull(); expect(sessionStorage.getItem('bulwark-popout-compose:h1')).toBeNull(); }); it('points a composer window at its saved draft, so nothing of the text stays in its address or session', async () => { await enableAppLock(PIN); registerComposeSnapshot({ ownWindow: true, state: () => ({ subject: 'x' }) as never, draft: () => ({ draftId: 'd7', localAccountId: 'alice' }) }); await lockedAndReloaded(); expect(window.location.search).toContain('draft=d7'); expect(sessionStorage.getItem('bulwark-app-lock-drafts-saved')).toBe('1'); expect(composeSnapshots()).toHaveLength(1); window.history.replaceState(null, '', '/'); }); it('reaches this tab from another, by the channel', async () => { await enableAppLock(PIN); stopTab = startAppLock(); new FakeChannel('bulwark:app-lock').postMessage({ type: 'lock' }); await vi.waitFor(() => expect(reloads).toBe(1)); expect(isAppLockedNow()).toBe(true); }); it('when the server is out of reach it stays on the page, off the screen, and reloads when it can', async () => { (fetch as ReturnType).mockRejectedValue(new Error('offline')); await enableAppLock(PIN); lockEverywhere(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay')); expect(reloads).toBe(0); (fetch as ReturnType).mockResolvedValue(new Response(null, { status: 200 })); window.dispatchEvent(new Event('online')); await vi.waitFor(() => expect(reloads).toBe(1)); }); it('asks in a way the service worker can answer: a GET that it knows is this probe', async () => { await enableAppLock(PIN); await lockedAndReloaded(); const [, init] = (fetch as ReturnType).mock.calls.at(-1) as [string, RequestInit]; expect(init.method).toBe('GET'); expect(new Headers(init.headers).get('x-bulwark-reload-probe')).toBe('1'); }); it('a 5xx counts as out of reach: a reload would land on the error page', async () => { (fetch as ReturnType).mockResolvedValue(new Response(null, { status: 503 })); await enableAppLock(PIN); lockEverywhere(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay')); expect(reloads).toBe(0); }); it('keeps trying on a timer: nothing says "online" when only the server was down', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); try { (fetch as ReturnType).mockRejectedValue(new Error('down')); await enableAppLock(PIN); lockEverywhere(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked-stay')); vi.advanceTimersByTime(STAY_RETRY_MS); await new Promise((resolve) => setTimeout(resolve, 10)); expect(reloads, 'still down').toBe(0); (fetch as ReturnType).mockResolvedValue(new Response(null, { status: 200 })); vi.advanceTimersByTime(STAY_RETRY_MS); await vi.waitFor(() => expect(reloads).toBe(1)); } finally { vi.useRealTimers(); } }); it('a pop-out that unlocks with no main window leaves sealed drafts sealed, for the main window', async () => { await enableAppLock(PIN); await sealDraft({ subject: 'Sealed text' }); await lockedAndReloaded(); window.history.replaceState(null, '', '/popout/compose'); try { asNewDocument(); await unlockWithPin(PIN); await new Promise((resolve) => setTimeout(resolve, 20)); expect(sessionStorage.getItem('bulwark-rescued-drafts')).toBeNull(); } finally { window.history.replaceState(null, '', '/'); } lockEverywhere(); asNewDocument(); await unlockWithPin(PIN); await vi.waitFor(() => expect(sessionStorage.getItem('bulwark-rescued-drafts')).toContain('Sealed text')); }); }); describe('the store when a tab inherits it without the key', () => { it('asks for the lock instead of opening, or writing a key outside the layer', async () => { await enableAppLock(PIN); // This tab has no key: the one that had it went. resetAppLockForTests(); (window.navigator.locks.query as ReturnType).mockResolvedValue({ held: [{ name: 'bulwark-app-lock-holder' }], pending: [] }); (window.navigator.locks.request as ReturnType).mockImplementation(() => new Promise(() => {})); bootAppLock(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('open')); const onKeyLocked = vi.fn(); configureStore({ ...hooks, onKeyLocked }); await expect(getStore().ensureOpen('new-account', { apiUrl: 'x' } as never).then(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); await getStore().ensureOpen('new-account'); })).rejects.toMatchObject({ name: 'StoreKeyLayerMissingError' }); expect(onKeyLocked).toHaveBeenCalled(); expect(await getAccountRecord('new-account')).toBeNull(); expect(lastWorker().posted.some((m) => m.kind === 'open')).toBe(false); }); it('a new account after unlock gets its key under the lock', async () => { await enableAppLock(PIN); await getStore().ensureOpen('new-account', { apiUrl: 'x' } as never); await vi.waitFor(async () => expect((await getAccountRecord('new-account'))?.layers).toEqual(['applock'])); }); }); describe('the desktop app', () => { const settled = () => new Promise((resolve) => setTimeout(resolve, 50)); it('asks main to clear a lock this profile has no PIN for', async () => { const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); bootAppLock(); await settled(); expect(appLock.clearOrphan).toHaveBeenCalledTimes(1); expect(appLock.forget).not.toHaveBeenCalled(); }); it('leaves main alone when there is a vault, so a real lock is never questioned', async () => { const raw = await createVault(PIN); raw.fill(0); const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); bootAppLock(); await settled(); expect(appLock.clearOrphan).not.toHaveBeenCalled(); }); it('asks nothing when main has no lock', async () => { const appLock = bridge({ status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false })) }); bootAppLock(); await settled(); expect(appLock.clearOrphan).not.toHaveBeenCalled(); }); it('a PIN unlock proves itself to main, and nothing can just say unlocked', async () => { const appLock = bridge(); await enableAppLock(PIN); expect(appLock.enroll).toHaveBeenCalledTimes(1); const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array; await lockedAndReloaded(); expect(appLock.lock).toHaveBeenCalled(); await unlockWithPin(PIN); expect(appLock.prove).toHaveBeenCalledTimes(1); expect(Array.from(appLock.prove.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof)); }); it('the screen locking locks the app', async () => { let request: () => void = () => {}; bridge({ onLockRequest: vi.fn((listener: () => void) => { request = listener; return () => {}; }) }); await enableAppLock(PIN); stopTab = startAppLock(); request(); await vi.waitFor(() => expect(reloads).toBe(1)); expect(readAppLockFile().locked).toBe(true); }); it('a page in a tab can\'t lift a lock main still holds', async () => { const appLock = bridge({ status: vi.fn(async () => ({ locked: true, enrolled: true, os: null, osEnrolled: false })) }); await enableAppLock(PIN); resetAppLockForTests(); bootAppLock(); stopTab = startAppLock(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); new FakeChannel('bulwark:app-lock').postMessage({ type: 'unlocked' }); await new Promise((resolve) => setTimeout(resolve, 20)); expect(appLock.status).toHaveBeenCalled(); expect(getAppLockPhase()).toBe('locked'); }); it('Windows Hello\'s yes opens with the key main hands over', async () => { let held: Uint8Array | null = null; const appLock = bridge({ osEnroll: vi.fn(async (_proof: Uint8Array, key: Uint8Array) => { held = key.slice(); return true; }), osUnlock: vi.fn(async () => ({ ok: true as const, key: held! })), }); await runningLockedStore(); expect(await addOsUnlock(PIN)).toBe(true); await lockedAndReloaded(); expect(await unlockWithOs()).toBe(true); expect(appLock.osUnlock).toHaveBeenCalledTimes(1); expect(isAppLockedNow()).toBe(false); await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toBeDefined(); }); it('wrong bytes from main open nothing', async () => { bridge({ osUnlock: vi.fn(async () => ({ ok: true as const, key: new Uint8Array(32).fill(1) })) }); await runningLockedStore(); await lockedAndReloaded(); await unlockWithOs().catch(() => false); await expect(openAccountKey('acc', appLockKeyLayers())).rejects.toBeDefined(); }); it('Windows Hello\'s no leaves it locked, and the PIN still works', async () => { const appLock = bridge(); await enableAppLock(PIN); await lockedAndReloaded(); expect(await unlockWithOs()).toBe(false); expect(appLock.osUnlock).toHaveBeenCalled(); expect(isAppLockedNow()).toBe(true); await unlockWithPin(PIN); expect(isAppLockedNow()).toBe(false); }); it('tells the OS integration when it locks, and its check says so until the unlock', async () => { bridge(); const setLocked = vi.fn(); (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.os = { setLocked }; await enableAppLock(PIN); expect(setLocked).not.toHaveBeenCalled(); await lockedAndReloaded(); expect(setLocked).toHaveBeenCalledWith(true); setLocked.mockClear(); asNewDocument(); expect(setLocked, 'a new page behind the lock says so at once').toHaveBeenCalledWith(true); expect(isAppLocked()).toBe(true); await unlockWithPin(PIN); // What components/desktop/desktop-os-host.tsx tells main as the app starts. expect(isAppLocked()).toBe(false); }); it('a page takes the two calls a frame must not reach once, before any frame runs', async () => { const appLock = bridge(); bootAppLock(); expect(appLock.claim).toHaveBeenCalledTimes(1); await enableAppLock(PIN); await lockedAndReloaded(); expect(appLock.lock).toHaveBeenCalled(); }); it('a claim someone else got first leaves this page with no OS unlock, and the PIN working', async () => { const appLock = bridge({ claim: vi.fn(() => null) }); await enableAppLock(PIN); await lockedAndReloaded(); expect(await unlockWithOs()).toBe(false); expect(appLock.osUnlock).not.toHaveBeenCalled(); await unlockWithPin(PIN); expect(isAppLockedNow()).toBe(false); }); it('a no from main on a good PIN isn\'t ignored: it enrolls again, and is unlocked', async () => { const appLock = bridge(); await enableAppLock(PIN); await lockedAndReloaded(); appLock.enroll.mockClear(); appLock.prove.mockResolvedValue(false as never); await unlockWithPin(PIN); expect(appLock.enroll).toHaveBeenCalledTimes(1); expect(isAppLockedNow()).toBe(false); }); it('main keeps an older lock: it asks natively, and only a yes replaces it', async () => { const appLock = bridge(); await enableAppLock(PIN); await lockedAndReloaded(); appLock.prove.mockResolvedValue(false as never); appLock.enroll.mockResolvedValueOnce(false as never); appLock.forget.mockResolvedValueOnce(false as never); await expect(unlockWithPin(PIN)).rejects.toThrow(); expect(isAppLockedNow(), 'main still locked, so the page is').toBe(true); expect(appLock.enroll).toHaveBeenCalledTimes(2); appLock.enroll.mockResolvedValueOnce(false as never); appLock.forget.mockResolvedValueOnce(true as never); appLock.enroll.mockResolvedValueOnce(true as never); await unlockWithPin(PIN); expect(isAppLockedNow()).toBe(false); }); it('turning it off with main refusing the proof: it asks, and a no leaves everything on', async () => { const appLock = bridge({ disable: vi.fn(async () => false), forget: vi.fn(async () => false) }); await openAccountKey('acc'); await enableAppLock(PIN); await expect(disableAppLock(PIN)).rejects.toThrow(); expect(readAppLockFile().on).toBe(true); expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']); expect(await readVault()).not.toBeNull(); appLock.forget.mockResolvedValue(true); await disableAppLock(PIN); expect(readAppLockFile().on).toBe(false); }); it('turning it off when main has no lock at all just goes on', async () => { bridge({ disable: vi.fn(async () => false), forget: vi.fn(async () => false), status: vi.fn(async () => ({ locked: false, enrolled: false, os: null, osEnrolled: false })), }); await enableAppLock(PIN); await disableAppLock(PIN); expect(readAppLockFile().on).toBe(false); }); it('a sign-out in the tab with the key shows main the proof, so nothing asks', async () => { const appLock = bridge(); await enableAppLock(PIN); const proof = appLock.enroll.mock.calls[0][0 as never] as unknown as Uint8Array; const { forgetAppLockAtSignOut } = await import('../sign-out'); await forgetAppLockAtSignOut(); expect(Array.from(appLock.forget.mock.calls[0][0 as never] as unknown as Uint8Array)).toEqual(Array.from(proof)); expect(await readVault()).toBeNull(); }); it('a record with main\'s safeStorage layer opens, and keeps it under the lock', async () => { bridge({ status: vi.fn(async () => ({ locked: false, enrolled: true, os: null, osEnrolled: false, wrap: true })), wrap: vi.fn(async (data: Uint8Array) => Uint8Array.from([7, ...data])), unwrap: vi.fn(async (data: Uint8Array) => (data[0] === 7 ? data.slice(1) : null)), }); const safe = { name: 'safeStorage', wrap: async (data: Uint8Array) => Uint8Array.from([7, ...data]), unwrap: async (data: Uint8Array) => data.slice(1), }; const { keyHex } = await openAccountKey('acc', [safe]); await enableAppLock(PIN); expect((await getAccountRecord('acc'))?.layers).toEqual(['safeStorage', LOCK_LAYER]); await expect(openAccountKey('acc', appLockKeyLayers())).resolves.toMatchObject({ keyHex }); // Off, nothing asks main at a start, so main's wrap goes too. await disableAppLock(PIN); expect((await getAccountRecord('acc'))?.layers).toEqual([]); }); it('forgetting asks main first: a no changes nothing', async () => { const appLock = bridge({ forget: vi.fn(async () => false) }); await enableAppLock(PIN); await lockedAndReloaded(); expect(await forgetEverything()).toBe(false); expect(appLock.forget).toHaveBeenCalled(); expect(readAppLockFile()).toMatchObject({ on: true, locked: true }); }); }); describe('a start with no lock file', () => { /** Runs the head script of app/(main)/layout.tsx and waits until its look has answered. */ async function headScript(): Promise { const databases = vi.spyOn(indexedDB, 'databases'); new Function(appLockBootScript())(); await databases.mock.results[0]?.value; await Promise.resolve(); databases.mockRestore(); } afterEach(() => { delete (window as unknown as Record).__bulwarkNoAppLockVault; }); it('holds nothing back once the head script found no vault: the app starts in the first render', async () => { await headScript(); bootAppLock(); expect(appLockHold()).toBeNull(); expect(getAppLockPhase()).toBe('open'); expect(isAppLockedNow()).toBe(false); }); it('still waits for the look when the head script has no answer yet', async () => { bootAppLock(); expect(appLockHold()).toBeInstanceOf(Promise); expect(getAppLockPhase()).toBe('checking'); await vi.waitFor(() => expect(appLockHold()).toBeNull()); expect(getAppLockPhase()).toBe('open'); }); it('a lost file with a vault still locks: the head script doesn\'t let that start through', async () => { await enableAppLock(PIN); localStorage.removeItem(APP_LOCK_KEY); resetAppLockForTests(); await headScript(); bootAppLock(); expect(appLockHold()).toBeInstanceOf(Promise); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); expect(readAppLockFile().on).toBe(true); expect(isAppLockedNow()).toBe(true); }); it('a lock file on means no look at all: the first frame already knows', async () => { localStorage.setItem(APP_LOCK_KEY, JSON.stringify({ on: true, locked: true })); const databases = vi.spyOn(indexedDB, 'databases'); new Function(appLockBootScript())(); expect(databases).not.toHaveBeenCalled(); expect(document.documentElement.hasAttribute('data-app-locked')).toBe(true); databases.mockRestore(); document.documentElement.removeAttribute('data-app-locked'); }); it('a look that fails says nothing, and the start waits for its own', async () => { const databases = vi.spyOn(indexedDB, 'databases').mockRejectedValue(new Error('blocked')); new Function(appLockBootScript())(); await databases.mock.results[0].value.catch(() => {}); databases.mockRestore(); bootAppLock(); expect(appLockHold()).toBeInstanceOf(Promise); await vi.waitFor(() => expect(appLockHold()).toBeNull()); }); it('a required lock still waits for its PIN, vault or no vault', async () => { await headScript(); bridge(); (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.policy = { get: () => ({ AppLockRequired: { value: true, locked: true, source: 'forced-machine' }, AppLockTimeoutMinutes: { value: null, locked: false, source: 'default' }, }), onChange: () => () => {}, }; localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); bootAppLock(); expect(getAppLockPhase()).toBe('setup'); expect(appLockHold()).toBeInstanceOf(Promise); }); }); describe('policy', () => { function withPolicy(required: { value: boolean; locked: boolean }, minutes: { value: number | null; locked: boolean } = { value: null, locked: false }) { const appLock = bridge(); (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.policy = { get: () => ({ AppLockRequired: { ...required, source: 'forced-machine' }, AppLockTimeoutMinutes: { ...minutes, source: minutes.locked ? 'forced-machine' : 'default' }, }), onChange: () => () => {}, }; return appLock; } it('a forced policy requires it: a signed-in start waits for a PIN', () => { withPolicy({ value: true, locked: true }); localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); expect(appLockRequired()).toBe(true); bootAppLock(); expect(getAppLockPhase()).toBe('setup'); expect(appLockHold()).toBeInstanceOf(Promise); }); it('a required lock doesn\'t stand in front of the login page', async () => { withPolicy({ value: true, locked: true }); bootAppLock(); await vi.waitFor(() => expect(appLockHold()).toBeNull()); expect(getAppLockPhase()).toBe('open'); }); it('a recommended policy requires nothing', () => { withPolicy({ value: true, locked: false }); expect(appLockRequired()).toBe(false); }); it('a required lock can\'t be turned off', async () => { await enableAppLock(PIN); withPolicy({ value: true, locked: true }); await expect(disableAppLock(PIN)).rejects.toThrow(); expect(readAppLockFile().on).toBe(true); }); it('setting up the required PIN lets the app go on', async () => { withPolicy({ value: true, locked: true }); localStorage.setItem('account-registry', JSON.stringify({ state: { accounts: [{ id: 'a' }] } })); bootAppLock(); const hold = appLockHold(); await enableAppLock(PIN); await expect(hold).resolves.toBeUndefined(); expect(getAppLockPhase()).toBe('open'); }); it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => { withPolicy({ value: false, locked: false }, { value: 3, locked: true }); expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true }); withPolicy({ value: false, locked: false }, { value: 3, locked: false }); expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false }); expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false }); delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; expect(effectiveTimeout(undefined)).toEqual({ minutes: 10, locked: false }); }); }); describe('a lost lock file', () => { it('a vault means on: the file going doesn\'t turn the lock off', async () => { await enableAppLock(PIN); await lockedAndReloaded(); localStorage.removeItem(APP_LOCK_KEY); asNewDocument(); expect(appLockHold(), 'held back while it looks').toBeInstanceOf(Promise); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); expect(readAppLockFile()).toMatchObject({ on: true, locked: true }); await unlockWithPin(PIN); expect(isAppLockedNow()).toBe(false); }); it('so does an unreadable one', async () => { await enableAppLock(PIN); await lockedAndReloaded(); localStorage.setItem(APP_LOCK_KEY, '{broken'); asNewDocument(); await vi.waitFor(() => expect(getAppLockPhase()).toBe('locked')); }); it('turning it on doesn\'t overwrite a vault that still holds a key', async () => { await openAccountKey('acc'); await enableAppLock(PIN); localStorage.removeItem(APP_LOCK_KEY); const before = await readVault(); resetAppLockForTests(); await expect(enableAppLock('another-pin-82')).rejects.toBeInstanceOf(VaultExistsError); expect((await readVault())?.pin.data).toEqual(before?.pin.data); await expect(createVault('another-pin-82')).rejects.toBeInstanceOf(VaultExistsError); }); }); describe('a PIN anyone would guess', () => { it('is refused when set, and changing to one too', async () => { await expect(enableAppLock('123456')).rejects.toMatchObject({ name: 'ObviousPinError' }); expect(await readVault()).toBeNull(); await enableAppLock(PIN); await expect(changeAppLockPin(PIN, '000000')).rejects.toMatchObject({ name: 'ObviousPinError' }); await unlockAfterChangeRefused(); }); async function unlockAfterChangeRefused(): Promise { await lockedAndReloaded(); await unlockWithPin(PIN); expect(isAppLockedNow()).toBe(false); } }); describe('off', () => { it('is the default: nothing is shown as locked and no layer is asked for', async () => { bootAppLock(); await vi.waitFor(() => expect(appLockHold()).toBeNull()); expect(getAppLockPhase()).toBe('open'); expect(isAppLockedNow()).toBe(false); expect(appLockKeyLayers()).toEqual([]); expect(localStorage.getItem(APP_LOCK_KEY)).toBeNull(); }); it('turning it off takes the layer off again', async () => { await openAccountKey('acc'); await enableAppLock(PIN); expect((await getAccountRecord('acc'))?.layers).toEqual(['applock']); await disableAppLock(PIN); expect((await getAccountRecord('acc'))?.layers).toEqual([]); expect(readAppLockFile().on).toBe(false); }); it('a wrong PIN doesn\'t turn it off', async () => { await enableAppLock(PIN); await expect(disableAppLock('wrong-pin')).rejects.toMatchObject({ name: 'WrongPinError' }); expect(readAppLockFile().on).toBe(true); }); });