fix(ext/sw): best-effort clearAll + setOrg frees displaced handle

clearAll is the security-zero path: each .free() in its own try/catch so a
single throwing handle can't leave the others un-zeroed; state is fully reset
regardless. setOrg frees any prior handle for the same org before overwriting
so a re-unlock can't orphan an un-zeroed WASM key. Tests updated to the
best-effort contract (session.test.ts) plus independent-mock, throwing-free,
and re-unlock cases (org-session.test.ts).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-25 21:40:37 -04:00
parent 6bdb7cf262
commit f85dc6729e
3 changed files with 70 additions and 13 deletions

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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';
}