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:
@@ -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 type { SessionHandle } from '../../../wasm/relicario_wasm';
|
||||||
import * as session from '../session';
|
import * as session from '../session';
|
||||||
|
|
||||||
describe('multi-context session', () => {
|
describe('multi-context session', () => {
|
||||||
it('clearAll frees personal and every org handle', () => {
|
beforeEach(() => {
|
||||||
const free = vi.fn();
|
// Reset module-scope state between tests via the security-zero path.
|
||||||
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));
|
|
||||||
session.clearAll();
|
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.getPersonal()).toBeNull();
|
||||||
expect(session.getOrg('org-a')).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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ describe('session', () => {
|
|||||||
expect(getCurrent()).toBeNull();
|
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'); });
|
const free = vi.fn(() => { throw new Error('boom'); });
|
||||||
setCurrent({ free, value: 2 } as unknown as SessionHandle);
|
setCurrent({ free, value: 2 } as unknown as SessionHandle);
|
||||||
|
|
||||||
expect(() => clearCurrent()).toThrow(/boom/);
|
expect(() => clearCurrent()).not.toThrow();
|
||||||
|
expect(free).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getCurrent()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ let context: 'personal' | string = 'personal';
|
|||||||
export function setPersonal(h: SessionHandle): void { personal = h; }
|
export function setPersonal(h: SessionHandle): void { personal = h; }
|
||||||
export function getPersonal(): SessionHandle | null { return personal; }
|
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 getOrg(orgId: string): SessionHandle | null { return orgs.get(orgId) ?? null; }
|
||||||
|
|
||||||
export function setContext(c: 'personal' | string): void { context = c; }
|
export function setContext(c: 'personal' | string): void { context = c; }
|
||||||
@@ -36,9 +45,20 @@ export function requireCurrentHandle(): SessionHandle {
|
|||||||
return h;
|
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 {
|
export function clearAll(): void {
|
||||||
if (personal) { personal.free(); personal = null; }
|
if (personal) {
|
||||||
for (const [, h] of orgs) h.free();
|
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();
|
orgs.clear();
|
||||||
context = 'personal';
|
context = 'personal';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user