feat(ext/sw): multi-context session (personal + orgs), clearAll zeroes all

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:29:08 -04:00
parent 92febf38f1
commit 6bdb7cf262
2 changed files with 61 additions and 23 deletions

View File

@@ -0,0 +1,17 @@
import { describe, expect, it, vi } 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));
session.clearAll();
expect(free).toHaveBeenCalledTimes(3);
expect(session.getPersonal()).toBeNull();
expect(session.getOrg('org-a')).toBeNull();
});
});

View File

@@ -1,34 +1,55 @@
/// Single module-scope "current" SessionHandle. /// Multi-context SW session: one personal handle + a Map of org handles.
/// ///
/// α assumes one vault per extension install. The master key lives only /// The master key for every context lives only inside WASM linear memory
/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module /// (wrapped in Zeroizing<[u8;32]>); this module holds the opaque SessionHandle
/// just holds the opaque handle that names it. /// references that name them. Calling `.free()` on a SessionHandle removes
/// the entry from the WASM SESSIONS registry and zeroizes master_key and
/// image_secret.
/// ///
/// Future multi-vault (β+) would replace `current` with /// Security invariant: a vault lock OR an inactivity-timer expiry must zero
/// `Map<vaultId, SessionHandle>` and thread `vaultId` through every /// EVERY handle — personal AND every org handle. `clearAll()` is the
/// handler. Deliberate α simplicity — not an oversight. /// canonical zero-all path. `clearCurrent()` is aliased to `clearAll()` so
/// the existing lock handler (popup-only.ts) and inactivity timer (index.ts)
/// automatically zero org handles without code changes.
/// ///
/// As of Phase 1 of the security-polish series, `impl Drop for SessionHandle` /// The org master key MUST NEVER be written to localStorage, IndexedDB, or
/// on the Rust side makes `.free()` the meaningful cleanup call: it removes /// any persistent store. Only in-memory SessionHandle references live here.
/// the entry from the SESSIONS registry and zeroizes `master_key` and
/// `image_secret`. Calling `wasm.lock(handle.value)` before `.free()` would
/// be redundant belt-and-suspenders; this module intentionally does not.
import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { SessionHandle } from '../../wasm/relicario_wasm';
let current: SessionHandle | null = null; let personal: SessionHandle | null = null;
const orgs = new Map<string, SessionHandle>();
let context: 'personal' | string = 'personal';
export function setCurrent(h: SessionHandle): void { current = h; } export function setPersonal(h: SessionHandle): void { personal = h; }
export function getPersonal(): SessionHandle | null { return personal; }
export function getCurrent(): SessionHandle | null { return current; } export function setOrg(orgId: string, h: SessionHandle): void { 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; }
export function currentContext(): 'personal' | string { return context; }
export function requireCurrentHandle(): SessionHandle {
const h = context === 'personal' ? personal : orgs.get(context) ?? null;
if (!h) throw new Error('vault_locked');
return h;
}
export function clearAll(): void {
if (personal) { personal.free(); personal = null; }
for (const [, h] of orgs) h.free();
orgs.clear();
context = 'personal';
}
// Back-compat wrappers so existing personal-vault callers compile unchanged.
// clearCurrent() aliases clearAll() so the lock handler and inactivity timer
// in popup-only.ts and index.ts zero EVERY handle (personal + all orgs).
export function setCurrent(h: SessionHandle): void { setPersonal(h); }
export function getCurrent(): SessionHandle | null { return getPersonal(); }
export function requireCurrent(): SessionHandle { export function requireCurrent(): SessionHandle {
if (!current) throw new Error('vault_locked'); if (!personal) throw new Error('vault_locked');
return current; return personal;
}
export function clearCurrent(): void {
if (!current) return;
current.free();
current = null;
} }
export function clearCurrent(): void { clearAll(); }