diff --git a/extension/src/service-worker/__tests__/org-session.test.ts b/extension/src/service-worker/__tests__/org-session.test.ts index 277f2a8..b1d8efe 100644 --- a/extension/src/service-worker/__tests__/org-session.test.ts +++ b/extension/src/service-worker/__tests__/org-session.test.ts @@ -1,17 +1,50 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { SessionHandle } from '../../../wasm/relicario_wasm'; import * as session from '../session'; describe('multi-context session', () => { - it('clearAll frees personal and every org handle', () => { - const free = vi.fn(); - const mk = (id: number) => ({ value: id, free } as unknown as SessionHandle); - session.setPersonal(mk(1)); - session.setOrg('org-a', mk(2)); - session.setOrg('org-b', mk(3)); + beforeEach(() => { + // Reset module-scope state between tests via the security-zero path. session.clearAll(); - expect(free).toHaveBeenCalledTimes(3); + }); + + it('clearAll frees personal and every org handle (independent mocks)', () => { + const fp = vi.fn(), fa = vi.fn(), fb = vi.fn(); + session.setPersonal({ value: 1, free: fp } as unknown as SessionHandle); + session.setOrg('org-a', { value: 2, free: fa } as unknown as SessionHandle); + session.setOrg('org-b', { value: 3, free: fb } as unknown as SessionHandle); + session.clearAll(); + expect(fp).toHaveBeenCalledTimes(1); + expect(fa).toHaveBeenCalledTimes(1); + expect(fb).toHaveBeenCalledTimes(1); expect(session.getPersonal()).toBeNull(); expect(session.getOrg('org-a')).toBeNull(); + expect(session.getOrg('org-b')).toBeNull(); + expect(session.currentContext()).toBe('personal'); + }); + + it('clearAll is best-effort: a throwing personal.free() still frees every org handle', () => { + const fp = vi.fn(() => { throw new Error('boom'); }); + const fa = vi.fn(), fb = vi.fn(); + session.setPersonal({ value: 1, free: fp } as unknown as SessionHandle); + session.setOrg('org-a', { value: 2, free: fa } as unknown as SessionHandle); + session.setOrg('org-b', { value: 3, free: fb } as unknown as SessionHandle); + expect(() => session.clearAll()).not.toThrow(); + expect(fa).toHaveBeenCalledTimes(1); + expect(fb).toHaveBeenCalledTimes(1); + expect(session.getPersonal()).toBeNull(); + expect(session.getOrg('org-a')).toBeNull(); + expect(session.getOrg('org-b')).toBeNull(); + }); + + it('setOrg frees the displaced handle when an org is re-unlocked', () => { + // Re-unlocking an org must zero the prior handle's WASM key, not orphan it. + const first = vi.fn(), second = vi.fn(); + session.setOrg('org-a', { value: 1, free: first } as unknown as SessionHandle); + session.setOrg('org-a', { value: 2, free: second } as unknown as SessionHandle); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + // The new handle is the one that stays live. + expect((session.getOrg('org-a') as unknown as { value: number }).value).toBe(2); }); }); diff --git a/extension/src/service-worker/__tests__/session.test.ts b/extension/src/service-worker/__tests__/session.test.ts index e83f5d4..9b97190 100644 --- a/extension/src/service-worker/__tests__/session.test.ts +++ b/extension/src/service-worker/__tests__/session.test.ts @@ -28,10 +28,14 @@ describe('session', () => { expect(getCurrent()).toBeNull(); }); - it('clearCurrent() propagates exceptions from free()', () => { + it('clearCurrent() is best-effort: a throwing free() is swallowed, state is still cleared', () => { + // The security-zero path (clearAll) supersedes the old propagation + // expectation: one throwing handle must not abort zeroing the rest. const free = vi.fn(() => { throw new Error('boom'); }); setCurrent({ free, value: 2 } as unknown as SessionHandle); - expect(() => clearCurrent()).toThrow(/boom/); + expect(() => clearCurrent()).not.toThrow(); + expect(free).toHaveBeenCalledTimes(1); + expect(getCurrent()).toBeNull(); }); }); diff --git a/extension/src/service-worker/session.ts b/extension/src/service-worker/session.ts index f47ad69..0ad0436 100644 --- a/extension/src/service-worker/session.ts +++ b/extension/src/service-worker/session.ts @@ -24,7 +24,16 @@ let context: 'personal' | string = 'personal'; export function setPersonal(h: SessionHandle): void { personal = h; } export function getPersonal(): SessionHandle | null { return personal; } -export function setOrg(orgId: string, h: SessionHandle): void { orgs.set(orgId, h); } +export function setOrg(orgId: string, h: SessionHandle): void { + // Free any prior handle for this org before overwriting — otherwise a + // re-unlock of the same org would orphan the displaced handle, leaving its + // WASM key un-zeroed (the key only dies when .free() runs). + const prior = orgs.get(orgId); + if (prior && prior !== h) { + try { prior.free(); } catch { /* best-effort: keep going so the new handle still lands */ } + } + orgs.set(orgId, h); +} export function getOrg(orgId: string): SessionHandle | null { return orgs.get(orgId) ?? null; } export function setContext(c: 'personal' | string): void { context = c; } @@ -36,9 +45,20 @@ export function requireCurrentHandle(): SessionHandle { return h; } +// The security-zero path. A vault lock or inactivity-timeout MUST zero every +// handle it can, even if one .free() throws — so a single misbehaving handle +// can never leave the others un-zeroed. Each .free() is wrapped in its own +// try/catch (swallow + continue), and state is fully reset (personal nulled, +// orgs cleared, context reset) REGARDLESS of any throw. This function never +// throws. export function clearAll(): void { - if (personal) { personal.free(); personal = null; } - for (const [, h] of orgs) h.free(); + if (personal) { + try { personal.free(); } catch { /* best-effort zero: keep going */ } + personal = null; + } + for (const [, h] of orgs) { + try { h.free(); } catch { /* best-effort zero: keep going */ } + } orgs.clear(); context = 'personal'; }