feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware, cache cleared on lock)

Wires 4 SW messages to the org-vault.ts read functions (Task 5b):
- org_switch: opens org vault via openOrg(), caches OrgHandleState, sets
  session context; switching to 'personal' resets context without openOrg.
  Offline-aware: if openOrg throws a network error and a prior cached state
  exists, reuses it read-only (offline:true) via exported isNetworkError.
- org_list_items: returns cached, grant-filtered OrgManifestEntry[] (not
  personal ManifestEntry[]) via listOrgItems(requireCurrentOrgState()).
- org_get_item: decrypts a single org item via getOrgItem.
- org_list_collections: returns granted Collection[] via listOrgCollections.

Three-place rule satisfied for all 4 messages:
1. messages.ts PopupMessage union (4 variants)
2. messages.ts POPUP_ONLY_TYPES (4 entries)
3. popup-only.ts switch (4 case arms)

Cache lifecycle / use-after-free guard:
- clearOrgCache() added to org-handlers.ts; exports.
- Called in popup-only.ts 'lock' handler (before session.clearCurrent).
- Called in index.ts inactivity-timer onExpired (before clearCurrent).

Plan doc fixes:
- Line ~410: org_list_items return type corrected to OrgManifestEntry[].
- Line ~47: org_unwrap_key signature updated (no device_private_key_base64
  param — reads DEVICE_STATE internally).

9 new tests in org-handlers.test.ts (TDD: all watched fail before passing).
Full SW suite: 167 tests (17 files) green. build:all type-checks clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-26 22:45:09 -04:00
parent 2eb49ac217
commit f9b8f3821d
7 changed files with 350 additions and 8 deletions

View File

@@ -44,7 +44,7 @@
**Interfaces:**
- Consumes: `relicario_core::org::unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8;32]>) -> Result<Zeroizing<[u8;32]>>` (`crates/relicario-core/src/org.rs:299`); `session::insert(master_key, image_secret) -> u32` (`crates/relicario-wasm/src/session.rs`).
- Produces: `org_unwrap_key(keys_blob: &[u8], device_private_key_base64: &str) -> Result<SessionHandle, JsError>`. The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it.
- Produces: `org_unwrap_key(keys_blob: &[u8]) -> Result<SessionHandle, JsError>` (reads `DEVICE_STATE` internally — no private-key param crosses JS). The returned handle is an ordinary `SessionHandle` — callers use the existing `item_decrypt`/`item_encrypt`/`manifest_decrypt`/`manifest_encrypt` with it.
- [ ] **Step 1: Confirm the device-key form.** Read how `device_private_key` is produced — `crates/relicario-wasm/src/lib.rs` `register_device`/`generate_device_keypair` and `crates/relicario-core/src/device.rs`. Determine whether `private_key_base64` is the raw 32-byte ed25519 seed or an OpenSSH blob, and write `org_unwrap_key` to decode it to the 32-byte seed `Zeroizing<[u8;32]>` that `unwrap_org_key` expects. Note the finding in a code comment.
@@ -407,7 +407,7 @@ git commit -m "feat(ext/sw): org-vault — unwrap, fetch, grant-filter manifest"
- Test: `extension/src/service-worker/__tests__/org-vault.test.ts`
**Interfaces:**
- Produces SW messages: `org_switch {context}``{ ok, data: { context, offline } }`; `org_list_items``{ ok, data: ManifestEntry[] }`; `org_get_item {id}``{ ok, data: Item }`; `org_list_collections``{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only.
- Produces SW messages: `org_switch {context}``{ ok, data: { context, offline } }`; `org_list_items``{ ok, data: OrgManifestEntry[] }`; `org_get_item {id}``{ ok, data: Item }`; `org_list_collections``{ ok, data: Collection[] }`. On a git network error during switch, set `offline: true` and serve the last-cached manifest read-only.
- [ ] **Step 1: Write the failing test**

View File

@@ -0,0 +1,225 @@
/// Tests for org-handlers.ts (Task 6): org_switch + 3 read handlers + cache lifecycle.
///
/// Tests at the handler-function level, not via popup-only.handle(). Thrown
/// errors (e.g. not_in_org_context) surface in production via index.ts's outer
/// .catch; here we assert them directly with expect(() => ...).toThrow().
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as session from '../session';
// --- Mocks (hoisted) ---
vi.mock('../org-vault', () => ({
openOrg: vi.fn(),
listOrgItems: vi.fn(),
getOrgItem: vi.fn(),
listOrgCollections: vi.fn(),
isNetworkError: vi.fn(),
}));
vi.mock('../org-config', () => ({
loadOrgConfigs: vi.fn(),
}));
import * as orgVaultMod from '../org-vault';
import * as orgConfigMod from '../org-config';
import {
handleOrgSwitch,
handleOrgListItems,
handleOrgGetItem,
handleOrgListCollections,
clearOrgCache,
} from '../router/org-handlers';
import type { OrgHandleState } from '../org-vault';
import type { OrgManifestEntry, Collection, Item } from '../../shared/types';
// --- Test data ---
const ORG_ID = 'org-abc123';
const ORG_CFG = {
orgId: ORG_ID,
displayName: 'Test Org',
hostType: 'gitea' as const,
hostUrl: 'https://git.example',
repoPath: 'org/vault',
apiToken: 'tok-abc',
memberId: 'member001',
};
const FILTERED_ENTRIES: OrgManifestEntry[] = [
{ id: 'item001', type: 'login', title: 'Login A', tags: [], modified: 1000, collection: 'col-a' },
{ id: 'item002', type: 'secure_note', title: 'Note B', tags: [], modified: 1001, collection: 'col-b' },
];
const ORG_MANIFEST = { schema_version: 1, entries: FILTERED_ENTRIES };
const GRANTED_COLLECTIONS: Collection[] = [
{ slug: 'col-a', display_name: 'Collection A' },
{ slug: 'col-b', display_name: 'Collection B' },
];
const DECRYPTED_ITEM: Item = {
id: 'item001', title: 'Login A', type: 'login', tags: [], favorite: false,
created: 100, modified: 1000,
core: { type: 'login', username: 'user', password: 'pass' },
sections: [], attachments: [], field_history: {},
};
const FAKE_WASM = { label: 'fake-wasm-module' };
function makeFakeHandle() {
return { value: 42, free: vi.fn() };
}
function makeFakeOrgState(overrides: Partial<OrgHandleState> = {}): OrgHandleState {
return {
orgId: ORG_ID,
host: {} as never,
handle: makeFakeHandle() as never,
grants: ['col-a', 'col-b'],
manifest: ORG_MANIFEST as never,
offline: false,
...overrides,
};
}
// --- Suite ---
describe('org-handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset real session module state (calls .free() on any stored handles).
session.clearAll();
// Reset the module-level org cache in org-handlers.
clearOrgCache();
// Default: one org configured, isNetworkError returns false.
vi.mocked(orgConfigMod.loadOrgConfigs).mockResolvedValue([ORG_CFG]);
vi.mocked(orgVaultMod.isNetworkError).mockReturnValue(false);
});
// Test 1: org_switch to an orgId — openOrg called, session wired, offline:false
it('org_switch to an org: calls openOrg, wires setOrg + setContext, returns { context, offline:false }', async () => {
const fakeState = makeFakeOrgState({ offline: false });
vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState);
const result = await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
expect(result).toEqual({ ok: true, data: { context: ORG_ID, offline: false } });
// openOrg called with the config + wasm module; no prior manifest (first switch)
expect(orgVaultMod.openOrg).toHaveBeenCalledWith(ORG_CFG, FAKE_WASM, undefined);
// Session context set to the org
expect(session.currentContext()).toBe(ORG_ID);
// Handle from org state registered in session
expect(session.getOrg(ORG_ID)).toBe(fakeState.handle);
});
// Test 2: org_switch to 'personal' — no openOrg call, context reset to 'personal'
it("org_switch to 'personal': sets personal context, returns { context:'personal', offline:false }, openOrg not called", async () => {
const result = await handleOrgSwitch({ context: 'personal' }, FAKE_WASM);
expect(result).toEqual({ ok: true, data: { context: 'personal', offline: false } });
expect(orgVaultMod.openOrg).not.toHaveBeenCalled();
expect(session.currentContext()).toBe('personal');
});
// Test 3: offline fallback — openOrg throws network error + prior cached state exists → offline:true
it('org_switch: openOrg throws network error + prior cached state → returns offline:true (reuses cache, no error)', async () => {
// Populate cache with a successful first switch.
const fakeState = makeFakeOrgState();
vi.mocked(orgVaultMod.openOrg).mockResolvedValueOnce(fakeState);
await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
// Second switch: network error + isNetworkError returns true.
const networkErr = new TypeError('Failed to fetch');
vi.mocked(orgVaultMod.openOrg).mockRejectedValueOnce(networkErr);
vi.mocked(orgVaultMod.isNetworkError).mockReturnValue(true);
const result = await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
expect(result).toEqual({ ok: true, data: { context: ORG_ID, offline: true } });
// openOrg was called with the prior cached manifest as offline fallback
expect(orgVaultMod.openOrg).toHaveBeenLastCalledWith(ORG_CFG, FAKE_WASM, fakeState.manifest);
// Context still set to the org
expect(session.currentContext()).toBe(ORG_ID);
});
// Test 4: org_switch to unconfigured orgId → { ok:false, error:'org_not_configured' }
it('org_switch to unconfigured orgId: returns { ok:false, error:org_not_configured }', async () => {
vi.mocked(orgConfigMod.loadOrgConfigs).mockResolvedValue([]); // no configs
const result = await handleOrgSwitch({ context: 'org-does-not-exist' }, FAKE_WASM);
expect(result).toEqual({ ok: false, error: 'org_not_configured' });
expect(orgVaultMod.openOrg).not.toHaveBeenCalled();
});
// Test 5: org_list_items after a switch → returns cached filtered OrgManifestEntry[]
it('org_list_items after org_switch: returns cached filtered OrgManifestEntry[] (not personal ManifestEntry[])', async () => {
const fakeState = makeFakeOrgState();
vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState);
vi.mocked(orgVaultMod.listOrgItems).mockReturnValue(FILTERED_ENTRIES);
await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
const result = handleOrgListItems();
expect(result).toEqual({ ok: true, data: FILTERED_ENTRIES });
// listOrgItems called with the cached OrgHandleState
expect(orgVaultMod.listOrgItems).toHaveBeenCalledWith(fakeState);
// The data entries have collection fields (OrgManifestEntry, not ManifestEntry)
expect((result.data as OrgManifestEntry[])[0]).toHaveProperty('collection', 'col-a');
});
// Test 6: org_get_item → decrypted Item
it('org_get_item: calls getOrgItem with cached state + id + wasm, returns decrypted Item', async () => {
const fakeState = makeFakeOrgState();
vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState);
vi.mocked(orgVaultMod.getOrgItem).mockResolvedValue(DECRYPTED_ITEM);
await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
const result = await handleOrgGetItem({ id: 'item001' }, FAKE_WASM);
expect(result).toEqual({ ok: true, data: DECRYPTED_ITEM });
expect(orgVaultMod.getOrgItem).toHaveBeenCalledWith(fakeState, 'item001', FAKE_WASM);
});
// Test 7: org_list_collections → granted Collection[]
it('org_list_collections: calls listOrgCollections with cached state, returns granted Collection[]', async () => {
const fakeState = makeFakeOrgState();
vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState);
vi.mocked(orgVaultMod.listOrgCollections).mockResolvedValue(GRANTED_COLLECTIONS);
await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
const result = await handleOrgListCollections();
expect(result).toEqual({ ok: true, data: GRANTED_COLLECTIONS });
expect(orgVaultMod.listOrgCollections).toHaveBeenCalledWith(fakeState);
});
// Test 8: org_list_items with 'personal' context (no org switch) → throws not_in_org_context
it("org_list_items with no org switch (context is 'personal'): throws not_in_org_context", () => {
// Default context is 'personal' (set by session.clearAll() in beforeEach).
expect(session.currentContext()).toBe('personal');
expect(() => handleOrgListItems()).toThrow('not_in_org_context');
});
// Test 9: clearOrgCache — empties the map; org read throws not_in_org_context after clear
it('clearOrgCache: empties the org state cache; org read after clear throws not_in_org_context', async () => {
// Populate cache.
const fakeState = makeFakeOrgState();
vi.mocked(orgVaultMod.openOrg).mockResolvedValue(fakeState);
vi.mocked(orgVaultMod.listOrgItems).mockReturnValue(FILTERED_ENTRIES);
await handleOrgSwitch({ context: ORG_ID }, FAKE_WASM);
// Confirm we can read (cache is populated, context is ORG_ID).
expect(() => handleOrgListItems()).not.toThrow();
// Clear (simulates lock handler calling clearOrgCache).
clearOrgCache();
// Context is still ORG_ID but cache is empty → requireCurrentOrgState throws.
expect(session.currentContext()).toBe(ORG_ID);
expect(() => handleOrgListItems()).toThrow('not_in_org_context');
});
});

View File

@@ -8,6 +8,7 @@ import { encodeBinary, decodeBinary } from '../shared/message-binary';
import * as vault from './vault';
import { clearCurrent } from './session';
import * as sessionTimer from './session-timer';
import { clearOrgCache } from './router/org-handlers';
import { READ_ONLY_CONTENT_CALLABLE } from './session-timer';
// @ts-ignore TS2307 — resolved by webpack alias / copy
@@ -52,6 +53,7 @@ const state: RouterState = {
sessionTimer.onExpired(() => {
// eslint-disable-next-line no-console
console.log('[relicario sw] session expired — locking vault');
clearOrgCache(); // prevent use-after-free: clear org cache BEFORE freeing handles
clearCurrent();
state.manifest = null;
// Plan C Phase 5: don't leak the cached git-host client across a lock.

View File

@@ -38,7 +38,7 @@ export interface OrgHandleState {
/// Detect a network-level failure (fetch() rejects with TypeError; no connectivity).
/// Does NOT classify HTTP non-2xx errors (those are application-level, not network).
function isNetworkError(e: unknown): boolean {
export function isNetworkError(e: unknown): boolean {
// fetch() rejects with TypeError when the request cannot be made at all
// (DNS failure, no connectivity, CORS preflight failure, etc.).
if (e instanceof TypeError) return true;

View File

@@ -1,11 +1,85 @@
/// Org message handler arms — kept separate so popup-only.ts doesn't bloat.
///
/// Security invariant: handlers here must NEVER return full OrgConfig objects
/// to the popup. Always project to OrgConfigSummary (id + displayName only).
/// Security invariants:
/// - Handlers here must NEVER return full OrgConfig objects to the popup.
/// Always project to OrgConfigSummary (id + displayName only).
/// - clearOrgCache() MUST be called on every lock / inactivity-timeout path
/// (alongside session.clearAll). The orgStates Map holds OrgHandleState
/// objects that reference SessionHandles; session.clearAll() frees those
/// handles in WASM. Reusing a cached state after a lock would be a
/// use-after-free of a freed WASM handle.
import { openOrg, listOrgItems, getOrgItem, listOrgCollections, isNetworkError } from '../org-vault';
import type { OrgHandleState } from '../org-vault';
import { loadOrgConfigs } from '../org-config';
import * as session from '../session';
import type { OrgConfigSummary } from '../../shared/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type WasmModule = any;
// Cached open-org state per orgId (host + grants + filtered manifest). The
// SessionHandle is also held by session.setOrg; this Map holds the rest.
const orgStates = new Map<string, OrgHandleState>();
// MUST be called on lock / inactivity-timeout (alongside session.clearCurrent/clearAll):
// the cached states reference SessionHandles that clearAll() frees — reusing one
// after a lock would be a use-after-free. Clearing the cache prevents that.
export function clearOrgCache(): void { orgStates.clear(); }
function requireCurrentOrgState(): OrgHandleState {
const ctx = session.currentContext();
const state = ctx === 'personal' ? undefined : orgStates.get(ctx);
if (!state) throw new Error('not_in_org_context');
return state;
}
/** Handle org_switch: open the org vault and cache its state, or restore personal context. */
export async function handleOrgSwitch(
{ context }: { context: string },
w: WasmModule,
): Promise<{ ok: true; data: { context: string; offline: boolean } } | { ok: false; error: string }> {
if (context === 'personal') {
session.setContext('personal');
return { ok: true, data: { context: 'personal', offline: false } };
}
const cfgs = await loadOrgConfigs();
const cfg = cfgs.find((c) => c.orgId === context);
if (!cfg) return { ok: false, error: 'org_not_configured' };
const prev = orgStates.get(context);
try {
const state = await openOrg(cfg, w, prev?.manifest);
orgStates.set(context, state);
session.setOrg(context, state.handle); // frees a displaced prior handle (Task 3)
session.setContext(context);
return { ok: true, data: { context, offline: state.offline } };
} catch (e) {
// Full-outage fallback: a network error with a prior cached state → reuse it read-only.
if (prev && isNetworkError(e)) {
session.setContext(context);
return { ok: true, data: { context, offline: true } };
}
return { ok: false, error: e instanceof Error ? e.message : 'org_switch_failed' };
}
}
/** Handle org_list_items: returns cached, grant-filtered OrgManifestEntry[]. */
export function handleOrgListItems() {
return { ok: true as const, data: listOrgItems(requireCurrentOrgState()) };
}
/** Handle org_get_item: fetch and decrypt a single org item. */
export async function handleOrgGetItem({ id }: { id: string }, w: WasmModule) {
return { ok: true as const, data: await getOrgItem(requireCurrentOrgState(), id, w) };
}
/** Handle org_list_collections: returns granted Collection[] only. */
export async function handleOrgListCollections() {
return { ok: true as const, data: await listOrgCollections(requireCurrentOrgState()) };
}
/** Handle org_list_configs: returns only { orgId, displayName } per org. */
export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfigSummary[] }> {
const cfgs = await loadOrgConfigs();

View File

@@ -14,7 +14,14 @@ import * as session from '../session';
import * as devices from '../devices';
import * as sessionTimer from '../session-timer';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
import { handleOrgListConfigs } from './org-handlers';
import {
handleOrgListConfigs,
handleOrgSwitch,
handleOrgListItems,
handleOrgGetItem,
handleOrgListCollections,
clearOrgCache,
} from './org-handlers';
// --- Shared ambient state owned by the SW module ---
//
@@ -73,6 +80,7 @@ export async function handle(
}
case 'lock':
clearOrgCache(); // prevent use-after-free: clear org cache BEFORE freeing handles
session.clearCurrent();
state.manifest = null;
// Don't leak the cached git-host (incl. lastSyncAt) across a lock —
@@ -663,6 +671,18 @@ export async function handle(
case 'org_list_configs':
return handleOrgListConfigs();
case 'org_switch':
return handleOrgSwitch(msg, state.wasm);
case 'org_list_items':
return handleOrgListItems();
case 'org_get_item':
return handleOrgGetItem(msg, state.wasm);
case 'org_list_collections':
return handleOrgListCollections();
default:
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
}

View File

@@ -1,7 +1,7 @@
import type {
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device,
FieldHistoryView, OrgConfigSummary,
FieldHistoryView, OrgConfigSummary, OrgManifestEntry, Collection,
} from './types';
// --- Session timeout config ---
@@ -69,7 +69,11 @@ export type PopupMessage =
| { type: 'attach_vault'; config: VaultConfig; passphrase: string;
referenceImageBytes: ArrayBuffer; deviceName: string }
| { type: 'get_vault_status' }
| { type: 'org_list_configs' };
| { type: 'org_list_configs' }
| { type: 'org_switch'; context: string }
| { type: 'org_list_items' }
| { type: 'org_get_item'; id: string }
| { type: 'org_list_collections' };
// --- Messages a content script may send ---
@@ -184,6 +188,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'generate_recovery_qr', 'unwrap_recovery_qr',
'create_vault', 'attach_vault', 'get_vault_status',
'org_list_configs',
'org_switch', 'org_list_items', 'org_get_item', 'org_list_collections',
] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {
@@ -227,6 +232,22 @@ export interface OrgListConfigsResponse extends Extract<Response, { ok: true }>
data: OrgConfigSummary[];
}
export interface OrgSwitchResponse extends Extract<Response, { ok: true }> {
data: { context: string; offline: boolean };
}
export interface OrgListItemsResponse extends Extract<Response, { ok: true }> {
data: OrgManifestEntry[];
}
export interface OrgGetItemResponse extends Extract<Response, { ok: true }> {
data: Item;
}
export interface OrgListCollectionsResponse extends Extract<Response, { ok: true }> {
data: Collection[];
}
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
'capture_save_login',