From d55861ffd87edc77441455d2167b1d47d101b20e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:02:54 -0400 Subject: [PATCH 01/25] =?UTF-8?q?feat(wasm):=20org=5Funwrap=5Fkey=20?= =?UTF-8?q?=E2=80=94=20ECIES=20unwrap=20into=20a=20session=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose org_unwrap_key(keys_blob, device_private_openssh) to let the extension SW unwrap a member's ECIES-wrapped org master key into an ordinary Zeroizing session handle. The returned handle is key-agnostic and works with the existing item_decrypt/manifest_decrypt AEAD. Key-form finding: device private keys are OpenSSH PEM blobs produced by relicario_core::device::generate_keypair(), not base64 raw bytes. The plan's "device_private_key_base64" parameter name was misleading; all code and the .d.ts declaration use "device_private_openssh" to match the spec and reality. Adds ssh-key dep to relicario-wasm for PEM parsing in decode_device_seed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- Cargo.lock | 1 + crates/relicario-wasm/Cargo.toml | 1 + crates/relicario-wasm/src/lib.rs | 100 +++++++++++++++++++++++++++++++ extension/src/wasm.d.ts | 2 + 4 files changed, 104 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index d352788..b2d222f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2248,6 +2248,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "ssh-key", "wasm-bindgen", "wasm-bindgen-test", "zeroize", diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index ffd5fae..1c49e95 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -21,6 +21,7 @@ base64 = "0.22" hex = "0.4" rand = "0.8" once_cell = "1" +ssh-key = { version = "0.6", features = ["ed25519", "std"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index f6c6b80..3a114d9 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -568,6 +568,106 @@ pub fn wasm_unwrap_recovery_qr( Ok(recovered.to_vec()) } +// ── Org vault WASM bridge ──────────────────────────────────────────────────── + +/// Decode an OpenSSH ed25519 private key string into a Zeroizing 32-byte seed. +/// +/// **Key-form finding (Step 1 of the task brief):** +/// `relicario_core::device::generate_keypair()` returns the private key as an +/// OpenSSH PEM blob — the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` format, +/// the same form the `ssh_key` crate emits via `PrivateKey::to_openssh()`. It is +/// NOT a raw-bytes base64 string. The plan's `device_private_key_base64` parameter +/// name is therefore misleading; the spec name `device_private_openssh` matches +/// reality. The SW stores this blob in WASM `DEVICE_STATE` and never exposes it +/// to JS. If it were ever serialised for cross-session persistence (future work), +/// it would travel as this same PEM string. +/// +/// This mirrors the first half of `relicario_core::device::sign()`. +fn decode_device_seed(private_key_openssh: &str) -> Result, String> { + use ssh_key::PrivateKey; + let private = PrivateKey::from_openssh(private_key_openssh) + .map_err(|e| format!("parse private key: {e}"))?; + let key_data = private + .key_data() + .ed25519() + .ok_or_else(|| "not an ed25519 key".to_string())?; + let secret_slice: &[u8] = key_data.private.as_ref(); + let secret_bytes: [u8; 32] = secret_slice + .try_into() + .map_err(|_| "invalid ed25519 seed length".to_string())?; + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(&secret_bytes); + Ok(seed) +} + +/// Unwrap a member's ECIES-wrapped org master key into a session handle. +/// +/// `keys_blob` is the raw wrapped-key blob at `keys/.enc` in the org +/// repo — produced by `relicario_core::org::wrap_org_key`. `device_private_openssh` +/// is the device's ed25519 private key in OpenSSH PEM format (held in WASM memory +/// via `crates/relicario-wasm/src/device.rs`; never exposed to JS). +/// +/// The org key is held in the same Zeroizing WASM session registry as the personal +/// master key; org items share the personal `.enc` AEAD format, so the returned +/// handle works with `item_decrypt`/`manifest_decrypt` unchanged. +#[wasm_bindgen] +pub fn org_unwrap_key( + keys_blob: &[u8], + device_private_openssh: &str, +) -> Result { + let seed = decode_device_seed(device_private_openssh) + .map_err(|e| JsError::new(&format!("bad device key: {e}")))?; + let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) + .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; + // image_secret slot is unused for org sessions; store a zeroized placeholder. + let handle = session::insert(org_key, Zeroizing::new([0u8; 32])); + Ok(SessionHandle(handle)) +} + +#[cfg(test)] +mod org_tests { + use super::*; + use relicario_core::org::wrap_org_key; + use zeroize::Zeroizing; + + /// Returns (public_openssh, private_openssh) using the real core keypair generator. + /// + /// Device private keys are produced by `relicario_core::device::generate_keypair()` + /// in OpenSSH PEM format ("-----BEGIN OPENSSH PRIVATE KEY-----..."), + /// NOT base64-encoded raw bytes. The plan parameter name "device_private_key_base64" + /// is misleading; the spec name "device_private_openssh" matches the actual format. + /// We call the real generator here (not a hand-rolled helper) so the test exercises + /// the exact production key format and cannot silently drift. + fn test_device_keypair() -> (String, String) { + let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap(); + (pub_openssh, priv_openssh.to_string()) + } + + #[test] + fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() { + // Generate a device keypair, wrap a known org key to it, unwrap via the WASM + // path, then encrypt+decrypt an item through the returned handle to assert + // round-trip. + let org_key = Zeroizing::new([7u8; 32]); + let (pub_openssh, priv_openssh) = test_device_keypair(); + let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap(); + + let handle = org_unwrap_key(&wrapped, &priv_openssh).unwrap(); + // item_encrypt is native-safe: returns Vec; JsError is only reachable on + // the error path, which is not exercised here. + let ct = item_encrypt( + &handle, + r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, + ) + .unwrap(); + // item_decrypt calls serde_wasm_bindgen::Serializer which panics off-wasm. + // Use relicario_core::decrypt_item directly, mirroring the + // manifest_round_trip_via_handle approach in session_tests. + let pt: relicario_core::Item = relicario_core::decrypt_item(&ct, &org_key).unwrap(); + assert!(format!("{:?}", pt.core).contains("SecureNote")); + } +} + #[cfg(test)] mod session_tests { use super::*; diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2553999..47feb4f 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,6 +87,8 @@ declare module 'relicario-wasm' { export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string; export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; + export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle; + export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void; } From 986c5e1cb059c2b0d5c8f87f3fa5e9f3ee7db455 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:09:56 -0400 Subject: [PATCH 02/25] test(ext): failing-test scaffold for org read UI (Plan B Tasks 1-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing tests written against Dev-A's published org SW message contract (org_list_configs / org_switch / org_list_items / org_get_item / org_list_collections), plus compile-clean stubs. vitest: 6 fail / 3 pass — the 3 passes are A-independent (currentContext default + org reflect, personal "+ new" kept); the 6 fails are exactly the org behaviors HELD until Dev-A merges to main. Integration (flip-on at Dev-A merge): - org-context messageForList/messageForGet org branches (org_list_items/org_get_item) - org-switcher org_list_configs fetch + org_switch dispatch + list reload + offline banner - item-list org initial-load-on-render (org_list_items) + hide "+ new" in org context - collection facet populate from org_list_collections + click-to-filter Scaffold contents: - shared/org-context.ts (+__tests__): currentContext complete; messageFor* personal-only (HOLD) - shared/popup-state.ts: optional orgContext / orgOffline fields (non-breaking; A-typed orgConfigs/orgCollections join at integration) - popup/components/org-switcher.ts (+__tests__): select shell only (HOLD switch + Task 5 banner) - popup/components/__tests__/item-list.test.ts: org load + read-only (+ new hidden) assertions - vault/vault-sidebar.ts renderCollectionFacet (+__tests__): empty container (HOLD) Read-only this plan: write affordances stay hidden (Dev-C adds them). No org-message-type references in src — branch stays compile-clean; build:all type-check is the integration gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg --- .../components/__tests__/item-list.test.ts | 63 +++++++++++++++++++ .../components/__tests__/org-switcher.test.ts | 62 ++++++++++++++++++ .../src/popup/components/org-switcher.ts | 25 ++++++++ .../src/shared/__tests__/org-context.test.ts | 37 +++++++++++ extension/src/shared/org-context.ts | 28 +++++++++ extension/src/shared/popup-state.ts | 7 +++ .../vault/__tests__/collection-facet.test.ts | 41 ++++++++++++ extension/src/vault/vault-sidebar.ts | 23 +++++++ 8 files changed, 286 insertions(+) create mode 100644 extension/src/popup/components/__tests__/item-list.test.ts create mode 100644 extension/src/popup/components/__tests__/org-switcher.test.ts create mode 100644 extension/src/popup/components/org-switcher.ts create mode 100644 extension/src/shared/__tests__/org-context.test.ts create mode 100644 extension/src/shared/org-context.ts create mode 100644 extension/src/vault/__tests__/collection-facet.test.ts diff --git a/extension/src/popup/components/__tests__/item-list.test.ts b/extension/src/popup/components/__tests__/item-list.test.ts new file mode 100644 index 0000000..cd8fcd7 --- /dev/null +++ b/extension/src/popup/components/__tests__/item-list.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// item-list.ts imports getState/setState/sendMessage/navigate/escapeHtml/openVaultTab +// from shared/state — mock the whole module (the established component-test idiom). +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', entries: [], searchQuery: '', selectedIndex: 0 })), + setState: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ ok: true, data: { items: [] } }), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderItemList } from '../item-list'; +import { getState, sendMessage } from '../../../shared/state'; + +const mockGetState = getState as ReturnType; +const send = sendMessage as ReturnType; + +describe('popup/item-list in org context', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + }); + + // Task 3 — list loads via the context-aware message helper. In an org + // context the initial render must populate the list from `org_list_items` + // (the personal entries don't carry over across an org_switch). + it('loads org items (grant-filtered) when context is an org', async () => { + mockGetState.mockReturnValue({ orgContext: 'org-1', entries: [], searchQuery: '', selectedIndex: 0 }); + send.mockResolvedValue({ ok: true, data: [{ id: 'a', title: 'db', collection: 'prod-infra', modified: 1 }] }); + + renderItemList(document.getElementById('app')!); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ type: 'org_list_items' }); + }); + + // Task 3 — read-only this plan: no add affordance in org context. Dev-C's + // write plan re-introduces it. + it('hides the "+ new" affordance in org context', () => { + mockGetState.mockReturnValue({ orgContext: 'org-1', entries: [], searchQuery: '', selectedIndex: 0 }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.querySelector('#new-btn')).toBeNull(); + }); + + // Personal path is unchanged: render shows the add affordance and does not + // auto-fire a list load. + it('keeps the "+ new" affordance in personal context', () => { + mockGetState.mockReturnValue({ orgContext: 'personal', entries: [], searchQuery: '', selectedIndex: 0 }); + const app = document.getElementById('app')!; + + renderItemList(app); + + expect(app.querySelector('#new-btn')).not.toBeNull(); + }); +}); diff --git a/extension/src/popup/components/__tests__/org-switcher.test.ts b/extension/src/popup/components/__tests__/org-switcher.test.ts new file mode 100644 index 0000000..89b4069 --- /dev/null +++ b/extension/src/popup/components/__tests__/org-switcher.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgConfigs: [], orgOffline: false })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderOrgSwitcher } from '../org-switcher'; +import { sendMessage, navigate } from '../../../shared/state'; + +const send = sendMessage as ReturnType; +const nav = navigate as ReturnType; + +describe('popup/org-switcher', () => { + let host: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + host = document.getElementById('host')!; + }); + + // Task 2 — switching contexts. + it('switching to an org sends org_switch and reloads the list', async () => { + send.mockImplementation(async (req: { type: string }) => { + if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] }; + if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } }; + return { ok: true, data: [] }; + }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ type: 'org_switch', context: 'org-1' }); + expect(nav).toHaveBeenCalledWith('list', expect.anything()); + }); + + // Task 5 — offline read-only banner. + it('offline org_switch renders the writes-disabled banner', async () => { + send.mockImplementation(async (req: { type: string }) => + req.type === 'org_switch' ? { ok: true, data: { context: 'org-1', offline: true } } + : req.type === 'org_list_configs' ? { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] } + : { ok: true, data: [] }); + + await renderOrgSwitcher(host); + const sel = host.querySelector('select') as HTMLSelectElement; + sel.value = 'org-1'; + sel.dispatchEvent(new Event('change')); + await Promise.resolve(); + + expect(host.textContent).toContain('org offline — writes disabled'); + }); +}); diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts new file mode 100644 index 0000000..2aca8db --- /dev/null +++ b/extension/src/popup/components/org-switcher.ts @@ -0,0 +1,25 @@ +// Org context switcher + offline banner (Plan B Tasks 2 & 5). +// +// A Personal / ' + + '' + + ''; +} + +export function teardown(): void { + // HOLD(dev-a): detach the change listener once renderOrgSwitcher wires it. +} diff --git a/extension/src/shared/__tests__/org-context.test.ts b/extension/src/shared/__tests__/org-context.test.ts new file mode 100644 index 0000000..fe6ee28 --- /dev/null +++ b/extension/src/shared/__tests__/org-context.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Drive getState() so we can flip the current context per assertion. The +// `vi.mock` idiom (vs vi.spyOn on an ESM namespace) is the established +// component-test pattern in this codebase and is reliable across modules. +vi.mock('../state', () => ({ + getState: vi.fn(), +})); + +import { currentContext, messageForList, messageForGet } from '../org-context'; +import { getState } from '../state'; + +const mockGetState = getState as ReturnType; + +describe('shared/org-context: context-aware SW message selection', () => { + beforeEach(() => vi.clearAllMocks()); + + it('currentContext defaults to personal when orgContext is unset', () => { + mockGetState.mockReturnValue({}); + expect(currentContext()).toBe('personal'); + }); + + it('currentContext reflects the active org', () => { + mockGetState.mockReturnValue({ orgContext: 'org-1' }); + expect(currentContext()).toBe('org-1'); + }); + + it('messageForList/Get pick personal vs org by current context', () => { + mockGetState.mockReturnValue({ orgContext: 'personal' }); + expect(messageForList()).toEqual({ type: 'list_items' }); + expect(messageForGet('x')).toEqual({ type: 'get_item', id: 'x' }); + + mockGetState.mockReturnValue({ orgContext: 'org-1' }); + expect(messageForList()).toEqual({ type: 'org_list_items' }); + expect(messageForGet('x')).toEqual({ type: 'org_get_item', id: 'x' }); + }); +}); diff --git a/extension/src/shared/org-context.ts b/extension/src/shared/org-context.ts new file mode 100644 index 0000000..388745c --- /dev/null +++ b/extension/src/shared/org-context.ts @@ -0,0 +1,28 @@ +// Context-aware SW message selection (Plan B Task 1). +// +// Single source of truth for "which SW message does the current context use": +// the personal vault sends `list_items`/`get_item`; an org context sends the +// org-scoped equivalents. The list + detail views consume these so neither has +// to branch on context itself. +// +// SCAFFOLD STATE (HOLD until Dev-A merges): `currentContext()` is complete. +// `messageForList`/`messageForGet` return the PERSONAL message only; the org +// branch is held until Dev-A lands `org_list_items` / `org_get_item` in the +// `shared/messages.ts` Request union. Flip the two HOLD lines at integration. + +import { getState } from './state'; +import type { Request } from './messages'; + +export function currentContext(): 'personal' | string { + return getState().orgContext ?? 'personal'; +} + +export function messageForList(): Request { + // HOLD(dev-a): in org context return { type: 'org_list_items' }. + return { type: 'list_items' }; +} + +export function messageForGet(id: string): Request { + // HOLD(dev-a): in org context return { type: 'org_get_item', id }. + return { type: 'get_item', id }; +} diff --git a/extension/src/shared/popup-state.ts b/extension/src/shared/popup-state.ts index 09e94e7..dfee3e6 100644 --- a/extension/src/shared/popup-state.ts +++ b/extension/src/shared/popup-state.ts @@ -47,6 +47,13 @@ export interface PopupState { vaultSettings: VaultSettings | null; generatorDefaults: GeneratorRequest | null; historyItemId: ItemId | null; + // Org (enterprise) context — Plan B (v0.9.0). `orgContext` is 'personal' or an + // orgId; `orgOffline` flags a degraded read-only org (set by org_switch). Optional + // so existing personal-only state constructions need no change; currentContext() + // (shared/org-context.ts) falls back to 'personal'. orgConfigs/orgCollections + // (Dev-A's OrgConfigSummary[]/Collection[]) join at org-track integration. + orgContext?: 'personal' | string; + orgOffline?: boolean; // Vault-tab-only fields. The popup surface leaves these at their defaults // (unlocked=false implicit via separate lock-screen view, drawer/panel false). // Kept on the shared shape so VaultState satisfies StateHost.getState() diff --git a/extension/src/vault/__tests__/collection-facet.test.ts b/extension/src/vault/__tests__/collection-facet.test.ts new file mode 100644 index 0000000..9702df8 --- /dev/null +++ b/extension/src/vault/__tests__/collection-facet.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// renderCollectionFacet reads getState().orgCollections (shared/state). vault-sidebar +// pulls in vault-context/vault-status/glyphs transitively; none have import-time +// side effects, so importing it under happy-dom is safe. +vi.mock('../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgCollections: [] })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { renderCollectionFacet } from '../vault-sidebar'; +import { getState } from '../../shared/state'; + +const mockGetState = getState as ReturnType; + +describe('vault collection facet (org context)', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = '
'; + }); + + // Task 4 — a collection nav parallel to the type-category nav, populated from + // org_list_collections, shown only in org context. + it('renders a collection facet from org_list_collections', () => { + mockGetState.mockReturnValue({ + orgContext: 'org-1', + orgCollections: [{ slug: 'prod-infra', display_name: 'Production Infra' }], + }); + const el = document.getElementById('facet')!; + + renderCollectionFacet(el); + + expect(el.textContent).toContain('Production Infra'); + }); +}); diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts index 3004467..aebeb48 100644 --- a/extension/src/vault/vault-sidebar.ts +++ b/extension/src/vault/vault-sidebar.ts @@ -13,6 +13,7 @@ import { renderStatusIndicator, type VaultStatus } from './vault-status'; import { type VaultController, typeIcon, typeLabel, getFilteredEntries, } from './vault-context'; +import { getState } from '../shared/state'; const SEARCH_DEBOUNCE_MS = 80; @@ -188,3 +189,25 @@ export function renderSidebarCategories(ctx: VaultController): void { }); }); } + +// --------------------------------------------------------------------------- +// Org collection facet (Plan B Task 4) +// --------------------------------------------------------------------------- + +/// Render a collection nav (parallel to the type-category nav) into `host`, +/// shown only in org context. Selecting a collection filters the org list to +/// that slug. +/// +/// SCAFFOLD STATE (HOLD until Dev-A merges): renders the empty container only. +/// Populating from getState().orgCollections, the click-to-filter wiring, and +/// the org_list_collections fetch on org_switch are held until Dev-A's org +/// messages land. +export function renderCollectionFacet(host: HTMLElement): void { + // Inline shape until orgCollections joins PopupState at integration (it needs + // Dev-A's Collection type). HOLD(dev-a): render each as a vault-category-row + // mirroring renderSidebarCategories, wired to set a collection filter. + const collections = + (getState() as { orgCollections?: Array<{ slug: string; display_name: string }> }).orgCollections ?? []; + host.innerHTML = + ``; +} From 92febf38f11d12620b8d75b5f41c40c100903e08 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:13:49 -0400 Subject: [PATCH 03/25] refactor(core,wasm): zeroized ed25519 seed decode in core; org_unwrap_key reuses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for Task 1 (org_unwrap_key). Public signature unchanged. - Add device::extract_ed25519_seed -> Zeroizing<[u8;32]>: length-checks the slice then copy_from_slice straight into the Zeroizing buffer, so the raw seed never lands in a plain [u8;32] (Important #1). - Refactor device::sign() to call the new helper (pure refactor; device/sign roundtrip tests unchanged and green) (Important #2). - org_unwrap_key now calls relicario_core::device::extract_ed25519_seed directly; drop the duplicated local decode_device_seed helper and remove the redundant ssh-key dep from relicario-wasm (core already pins it) (Important #2). - Device-key-form finding (OpenSSH PEM, not base64; spec name matches reality) moved onto the core helper, condensed copy kept on org_unwrap_key. - Test helper keeps the private key as Zeroizing and passes &priv (derefs to &str) — no plain-String copy (Minor #3). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- Cargo.lock | 1 - crates/relicario-core/src/device.rs | 35 ++++++++++++++----- crates/relicario-core/src/lib.rs | 2 +- crates/relicario-wasm/Cargo.toml | 1 - crates/relicario-wasm/src/lib.rs | 52 +++++++++-------------------- 5 files changed, 44 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2d222f..d352788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2248,7 +2248,6 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", - "ssh-key", "wasm-bindgen", "wasm-bindgen-test", "zeroize", diff --git a/crates/relicario-core/src/device.rs b/crates/relicario-core/src/device.rs index f2ce780..12dd42d 100644 --- a/crates/relicario-core/src/device.rs +++ b/crates/relicario-core/src/device.rs @@ -54,10 +54,20 @@ pub fn generate_keypair() -> Result<(Zeroizing, String)> { Ok((Zeroizing::new(private_pem.to_string()), public_line)) } -/// Sign data with an OpenSSH private key, returning base64 signature. -pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { - use base64::Engine; - +/// Extract the raw 32-byte ed25519 seed from an OpenSSH PEM private key. +/// +/// **Device-key form (verified against the producers):** `generate_keypair()` +/// emits the private key as an OpenSSH PEM blob — the multiline +/// `-----BEGIN OPENSSH PRIVATE KEY-----...` form produced by +/// `ssh_key::PrivateKey::to_openssh()` — NOT a base64-encoded raw-bytes string. +/// The org plan's `device_private_key_base64` parameter name was therefore +/// misleading; the design spec's `device_private_openssh` matches reality. The +/// extension SW holds this blob in WASM `DEVICE_STATE` and never exposes it to JS. +/// +/// The seed bytes are copied straight into a `Zeroizing<[u8; 32]>` — no +/// intermediate plain `[u8; 32]` of secret material is ever materialized — so +/// the seed is wiped on drop. +pub fn extract_ed25519_seed(private_key_openssh: &str) -> Result> { let private = PrivateKey::from_openssh(private_key_openssh) .map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?; @@ -67,11 +77,20 @@ pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { .ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?; let secret_slice: &[u8] = key_data.private.as_ref(); - let secret_bytes: [u8; 32] = secret_slice - .try_into() - .map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?; + if secret_slice.len() != 32 { + return Err(RelicarioError::DeviceKey("invalid key length".into())); + } + let mut seed = Zeroizing::new([0u8; 32]); + seed.copy_from_slice(secret_slice); + Ok(seed) +} - let signing_key = SigningKey::from_bytes(&secret_bytes); +/// Sign data with an OpenSSH private key, returning base64 signature. +pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { + use base64::Engine; + + let seed = extract_ed25519_seed(private_key_openssh)?; + let signing_key = SigningKey::from_bytes(&seed); let signature = signing_key.sign(data); Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes())) } diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 5c1e607..4dce7a7 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -91,7 +91,7 @@ pub mod import_lastpass; pub use import_lastpass::{parse_lastpass_csv, ImportWarning}; pub mod device; -pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify}; +pub use device::{fingerprint, DeviceEntry, RevokedEntry, extract_ed25519_seed, generate_keypair, sign, verify}; pub mod org; pub use org::{ diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index 1c49e95..ffd5fae 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -21,7 +21,6 @@ base64 = "0.22" hex = "0.4" rand = "0.8" once_cell = "1" -ssh-key = { version = "0.6", features = ["ed25519", "std"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 3a114d9..9e2b438 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -570,42 +570,20 @@ pub fn wasm_unwrap_recovery_qr( // ── Org vault WASM bridge ──────────────────────────────────────────────────── -/// Decode an OpenSSH ed25519 private key string into a Zeroizing 32-byte seed. -/// -/// **Key-form finding (Step 1 of the task brief):** -/// `relicario_core::device::generate_keypair()` returns the private key as an -/// OpenSSH PEM blob — the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` format, -/// the same form the `ssh_key` crate emits via `PrivateKey::to_openssh()`. It is -/// NOT a raw-bytes base64 string. The plan's `device_private_key_base64` parameter -/// name is therefore misleading; the spec name `device_private_openssh` matches -/// reality. The SW stores this blob in WASM `DEVICE_STATE` and never exposes it -/// to JS. If it were ever serialised for cross-session persistence (future work), -/// it would travel as this same PEM string. -/// -/// This mirrors the first half of `relicario_core::device::sign()`. -fn decode_device_seed(private_key_openssh: &str) -> Result, String> { - use ssh_key::PrivateKey; - let private = PrivateKey::from_openssh(private_key_openssh) - .map_err(|e| format!("parse private key: {e}"))?; - let key_data = private - .key_data() - .ed25519() - .ok_or_else(|| "not an ed25519 key".to_string())?; - let secret_slice: &[u8] = key_data.private.as_ref(); - let secret_bytes: [u8; 32] = secret_slice - .try_into() - .map_err(|_| "invalid ed25519 seed length".to_string())?; - let mut seed = Zeroizing::new([0u8; 32]); - seed.copy_from_slice(&secret_bytes); - Ok(seed) -} - /// Unwrap a member's ECIES-wrapped org master key into a session handle. /// /// `keys_blob` is the raw wrapped-key blob at `keys/.enc` in the org /// repo — produced by `relicario_core::org::wrap_org_key`. `device_private_openssh` -/// is the device's ed25519 private key in OpenSSH PEM format (held in WASM memory -/// via `crates/relicario-wasm/src/device.rs`; never exposed to JS). +/// is the device's ed25519 private key. +/// +/// **Device-key form (Step 1 finding):** the key arrives as an OpenSSH PEM blob — +/// the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` form emitted by +/// `relicario_core::device::generate_keypair()`, NOT a base64-encoded raw-bytes +/// string. The plan's `device_private_key_base64` name was misleading; the spec +/// name `device_private_openssh` matches reality. The SW holds this blob in WASM +/// `DEVICE_STATE` and never exposes it to JS. Decoding to the zeroized 32-byte +/// seed is delegated to `relicario_core::device::extract_ed25519_seed`, the same +/// path `device::sign()` uses, so the secret never lands in a plain `[u8; 32]`. /// /// The org key is held in the same Zeroizing WASM session registry as the personal /// master key; org items share the personal `.enc` AEAD format, so the returned @@ -615,7 +593,7 @@ pub fn org_unwrap_key( keys_blob: &[u8], device_private_openssh: &str, ) -> Result { - let seed = decode_device_seed(device_private_openssh) + let seed = relicario_core::device::extract_ed25519_seed(device_private_openssh) .map_err(|e| JsError::new(&format!("bad device key: {e}")))?; let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; @@ -637,10 +615,11 @@ mod org_tests { /// NOT base64-encoded raw bytes. The plan parameter name "device_private_key_base64" /// is misleading; the spec name "device_private_openssh" matches the actual format. /// We call the real generator here (not a hand-rolled helper) so the test exercises - /// the exact production key format and cannot silently drift. - fn test_device_keypair() -> (String, String) { + /// the exact production key format and cannot silently drift. The private key is + /// kept in its `Zeroizing` wrapper — never copied into a plain `String`. + fn test_device_keypair() -> (String, Zeroizing) { let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap(); - (pub_openssh, priv_openssh.to_string()) + (pub_openssh, priv_openssh) } #[test] @@ -652,6 +631,7 @@ mod org_tests { let (pub_openssh, priv_openssh) = test_device_keypair(); let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap(); + // &priv_openssh derefs Zeroizing → &str; no plain-String copy. let handle = org_unwrap_key(&wrapped, &priv_openssh).unwrap(); // item_encrypt is native-safe: returns Vec; JsError is only reachable on // the error path, which is not exercised here. From 20357d698ef7d2d1a9d53006aae05e531a23e07b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:28:57 -0400 Subject: [PATCH 04/25] test(ext): refine org_list_items fixture to OrgManifestEntry shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM contract refinement: org_list_items returns a DEDICATED OrgManifestEntry[] (collection REQUIRED), not the personal ManifestEntry-with-optional-collection. Dev-A is mirroring OrgManifest/OrgManifestEntry into shared/types.ts — the list imports that type at integration (no hand-rolling). Behaviors unaffected; scaffold stays RED (6 fail / 3 pass). Fixture-only change, no src. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg --- extension/src/popup/components/__tests__/item-list.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/__tests__/item-list.test.ts b/extension/src/popup/components/__tests__/item-list.test.ts index cd8fcd7..58cbb74 100644 --- a/extension/src/popup/components/__tests__/item-list.test.ts +++ b/extension/src/popup/components/__tests__/item-list.test.ts @@ -30,7 +30,12 @@ describe('popup/item-list in org context', () => { // (the personal entries don't carry over across an org_switch). it('loads org items (grant-filtered) when context is an org', async () => { mockGetState.mockReturnValue({ orgContext: 'org-1', entries: [], searchQuery: '', selectedIndex: 0 }); - send.mockResolvedValue({ ok: true, data: [{ id: 'a', title: 'db', collection: 'prod-infra', modified: 1 }] }); + // org_list_items returns OrgManifestEntry[] (dedicated org type; collection is + // REQUIRED, not the personal ManifestEntry's optional one) — Dev-A mirrors + // OrgManifestEntry into shared/types.ts; the list consumes it at integration. + send.mockResolvedValue({ ok: true, data: [ + { id: 'a', type: 'login', title: 'db', tags: [], collection: 'prod-infra', modified: 1 }, + ]}); renderItemList(document.getElementById('app')!); await Promise.resolve(); From 6bdb7cf262d7a4d2f362bdddd9a07c1bc10d539e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:29:08 -0400 Subject: [PATCH 05/25] feat(ext/sw): multi-context session (personal + orgs), clearAll zeroes all Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../__tests__/org-session.test.ts | 17 +++++ extension/src/service-worker/session.ts | 67 ++++++++++++------- 2 files changed, 61 insertions(+), 23 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-session.test.ts diff --git a/extension/src/service-worker/__tests__/org-session.test.ts b/extension/src/service-worker/__tests__/org-session.test.ts new file mode 100644 index 0000000..277f2a8 --- /dev/null +++ b/extension/src/service-worker/__tests__/org-session.test.ts @@ -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(); + }); +}); diff --git a/extension/src/service-worker/session.ts b/extension/src/service-worker/session.ts index 4859646..f47ad69 100644 --- a/extension/src/service-worker/session.ts +++ b/extension/src/service-worker/session.ts @@ -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 -/// inside WASM linear memory (wrapped in Zeroizing<[u8;32]>); this module -/// just holds the opaque handle that names it. +/// The master key for every context lives only inside WASM linear memory +/// (wrapped in Zeroizing<[u8;32]>); this module holds the opaque SessionHandle +/// 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 -/// `Map` and thread `vaultId` through every -/// handler. Deliberate α simplicity — not an oversight. +/// Security invariant: a vault lock OR an inactivity-timer expiry must zero +/// EVERY handle — personal AND every org handle. `clearAll()` is the +/// 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` -/// on the Rust side makes `.free()` the meaningful cleanup call: it removes -/// 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. +/// The org master key MUST NEVER be written to localStorage, IndexedDB, or +/// any persistent store. Only in-memory SessionHandle references live here. import type { SessionHandle } from '../../wasm/relicario_wasm'; -let current: SessionHandle | null = null; +let personal: SessionHandle | null = null; +const orgs = new Map(); +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 { - if (!current) throw new Error('vault_locked'); - return current; -} - -export function clearCurrent(): void { - if (!current) return; - current.free(); - current = null; + if (!personal) throw new Error('vault_locked'); + return personal; } +export function clearCurrent(): void { clearAll(); } From f85dc6729e1465f6bfcb676606471e9c02fa0418 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 21:40:37 -0400 Subject: [PATCH 06/25] 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 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../__tests__/org-session.test.ts | 49 ++++++++++++++++--- .../service-worker/__tests__/session.test.ts | 8 ++- extension/src/service-worker/session.ts | 26 ++++++++-- 3 files changed, 70 insertions(+), 13 deletions(-) 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'; } From 2ecd98208d0cd1b28068c74a40dab3b66dc05ff2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:33:29 -0400 Subject: [PATCH 07/25] feat(wasm,ext): org_manifest_decrypt/encrypt over WASM + faithful OrgManifest TS mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `org_manifest_decrypt` + `org_manifest_encrypt` to relicario-wasm/src/lib.rs, placed after `org_unwrap_key`; mirror the existing manifest_decrypt/manifest_encrypt pattern exactly (need_key guard, session::with, js_value_for). Core import path: relicario_core::decrypt_org_manifest / relicario_core::encrypt_org_manifest (crate root). - Add round-trip test `org_manifest_round_trip_via_handle` in org_tests: encrypt via WASM wrapper, decrypt via core directly (avoids serde_wasm_bindgen off-wasm). Asserts collection/title/tags/schema_version survive the round-trip; asserts nonce uniqueness. TDD: RED (compile error) → GREEN confirmed. - Declare org_manifest_decrypt/org_manifest_encrypt in extension/src/wasm.d.ts. - Add faithful/lean OrgManifestEntry + OrgManifest TS interfaces to extension/src/shared/types.ts: {id, type, title, tags[], modified, trashed_at?, collection} — no personal-only fields; collection required. Mirrors org.rs:184-202. - Plan-doc: rewrite Task 2 body (re-scope rationale + checked steps), fix File Structure + Architecture lines, amend Hand-off contract (org_list_items -> OrgManifestEntry[]; org items remain personal Item). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- crates/relicario-wasm/src/lib.rs | 61 ++++++++++++++++ .../2026-06-20-v0.9.0-org-a-foundation.md | 73 ++++++++----------- extension/src/shared/types.ts | 19 +++++ extension/src/wasm.d.ts | 2 + 4 files changed, 112 insertions(+), 43 deletions(-) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 9e2b438..279acf0 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -602,12 +602,73 @@ pub fn org_unwrap_key( Ok(SessionHandle(handle)) } +/// Decrypt an org manifest blob (manifest.enc in the org repo) with an org +/// session handle. The org manifest is a dedicated OrgManifest type (NOT the +/// personal Manifest); core's decrypt_org_manifest deserializes it. +#[wasm_bindgen] +pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result { + need_key(handle)?; + let out = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + js_value_for(&out) +} + +/// Encrypt an OrgManifest (JSON) with an org session handle, for the write track +/// (Dev-C never touches WASM — this is exposed here so they don't have to). +#[wasm_bindgen] +pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError> { + need_key(handle)?; + let m: relicario_core::OrgManifest = serde_json::from_str(manifest_json) + .map_err(|e| JsError::new(&format!("org manifest json: {e}")))?; + session::with(handle.0, |k| relicario_core::encrypt_org_manifest(&m, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + #[cfg(test)] mod org_tests { use super::*; use relicario_core::org::wrap_org_key; use zeroize::Zeroizing; + #[test] + fn org_manifest_round_trip_via_handle() { + use relicario_core::{OrgManifest, OrgManifestEntry, ItemType, ItemId, decrypt_org_manifest}; + session::clear(); + let key_bytes = [0xABu8; 32]; + let h = session::insert(Zeroizing::new(key_bytes), Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + let key = Zeroizing::new(key_bytes); + + let mut manifest = OrgManifest::new(); + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: ItemType::SecureNote, + title: "Org Test Item".into(), + tags: vec!["tag1".into()], + modified: 1_234_567_890, + trashed_at: None, + collection: "prod".into(), + }); + + let manifest_json = serde_json::to_string(&manifest).unwrap(); + let encrypted = org_manifest_encrypt(&handle, &manifest_json).unwrap(); + assert!(!encrypted.is_empty()); + + // Decrypt via core directly (avoids js-sys/serde_wasm_bindgen on native). + let parsed: OrgManifest = decrypt_org_manifest(&encrypted, &key).unwrap(); + assert_eq!(parsed.entries.len(), 1); + assert_eq!(parsed.entries[0].collection, "prod"); + assert_eq!(parsed.entries[0].title, "Org Test Item"); + assert_eq!(parsed.entries[0].tags, vec!["tag1".to_string()]); + assert_eq!(parsed.schema_version, 1); + + // Random nonces: two encryptions of the same plaintext must differ. + let encrypted2 = org_manifest_encrypt(&handle, &manifest_json).unwrap(); + assert_ne!(encrypted, encrypted2, "nonces must differ"); + } + /// Returns (public_openssh, private_openssh) using the real core keypair generator. /// /// Device private keys are produced by `relicario_core::device::generate_keypair()` diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md index 094a813..ae0528d 100644 --- a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md @@ -4,7 +4,7 @@ **Goal:** Give the extension service worker the data layer to switch into an org vault, unwrap the org master key into a Zeroizing WASM handle, and serve a grant-filtered org manifest — no UI. -**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`) and the existing `item_decrypt`/`manifest_decrypt` AEAD (org items share the personal `.enc` format, org key used directly). The only new WASM function is `org_unwrap_key`. In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM. +**Architecture:** Org reuses the existing key-agnostic WASM session registry (`relicario-wasm/src/session.rs`). Org ITEMS share the personal `.enc` format and reuse `item_decrypt`/`item_encrypt` on the org handle directly. The org MANIFEST is a dedicated `OrgManifest` type (NOT the personal `Manifest`) — it uses `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2, mirroring `manifest_decrypt`/`manifest_encrypt` in pattern but wrapping core's dedicated org manifest AEAD). New WASM functions: `org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). In the SW, a new multi-context session replaces the single-handle model, and a new `org-vault.ts` module mirrors `vault.ts` for org reads. Plans 2 (read UI) and 3 (write) consume the SW message contract this plan produces — they never touch WASM. **Tech Stack:** Rust (relicario-core/wasm), wasm-bindgen, TypeScript (extension service worker), vitest + happy-dom. @@ -21,8 +21,8 @@ ## File Structure -- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key`. (Reuses `session::insert`; reuses existing `manifest_decrypt`/`item_decrypt`/`item_encrypt`/`manifest_encrypt` on the returned handle.) -- `crates/relicario-core/src/manifest.rs` — ensure a `ManifestEntry` carries an optional `collection: Option` so the org manifest round-trips through the existing manifest (de)serialization. (Verify first; only add if absent.) +- `crates/relicario-wasm/src/lib.rs` — add `#[wasm_bindgen] org_unwrap_key` (Task 1) + `org_manifest_decrypt`/`org_manifest_encrypt` (Task 2). Org items reuse `item_decrypt`/`item_encrypt` on the returned handle. Org manifest uses the dedicated `org_manifest_decrypt`/`org_manifest_encrypt` (NOT `manifest_decrypt`/`manifest_encrypt` — the org manifest is a different type `OrgManifest`). +- ~~`crates/relicario-core/src/manifest.rs`~~ — NOT modified. The org manifest is a dedicated `OrgManifest` type, not the personal `Manifest`. Core already ships its AEAD wrappers. No change to `ManifestEntry` was needed or made. - `extension/src/wasm.d.ts` — declare `org_unwrap_key`. - `extension/src/service-worker/session.ts` — replace single-handle model with a context map (personal + orgs); zero ALL on lock/expiry. - `extension/src/service-worker/org-config.ts` *(new)* — `orgConfigs` read/write over `chrome.storage.local`. @@ -120,55 +120,42 @@ git commit -m "feat(wasm): org_unwrap_key — ECIES unwrap into a session handle --- -### Task 2: Org manifest `collection` field round-trips +### Task 2: Org manifest over WASM + faithful OrgManifest/OrgManifestEntry TS mirror + +> **Re-scoped after investigation.** The original Task 2 ("add collection to the personal +> ManifestEntry") was based on a wrong assumption and was rejected. Investigation confirmed +> that the org manifest is a DEDICATED type (`OrgManifest`/`OrgManifestEntry`), NOT the +> personal `Manifest`. Core already ships `encrypt_org_manifest`/`decrypt_org_manifest` +> AEAD wrappers (`crates/relicario-core/src/vault.rs:56,62`, re-exported at crate root). +> Adding `collection` to the personal `ManifestEntry` would be wrong and was not done. +> The PM ratified this re-scope (Option A). This task exposes the existing core wrappers +> over WASM and mirrors the org manifest types into TS. **Files:** -- Modify: `crates/relicario-core/src/manifest.rs` -- Modify: `extension/src/shared/types.ts` -- Test: `crates/relicario-core/tests/format_v2.rs` (or the manifest test module) +- Modify: `crates/relicario-wasm/src/lib.rs` — add `org_manifest_decrypt` + `org_manifest_encrypt` after `org_unwrap_key`; add round-trip test in `org_tests`. +- Modify: `extension/src/wasm.d.ts` — declare both new functions. +- Modify: `extension/src/shared/types.ts` — add `OrgManifestEntry` + `OrgManifest` interfaces (faithful/lean; no personal-only fields). **Interfaces:** -- Produces: `ManifestEntry.collection: Option` (serde `skip_serializing_if = "Option::is_none"`), mirrored in TS as `collection?: string`. +- Produces: `org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result` — mirrors `manifest_decrypt`; org items remain personal Items. +- Produces: `org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Result, JsError>` — mirrors `manifest_encrypt`. +- Produces: `OrgManifestEntry` TS interface: `{id, type, title, tags[], modified, trashed_at?, collection}`. `collection` is REQUIRED. No personal-only fields (no attachment_summaries/icon_hint/favorite/group). +- Produces: `OrgManifest` TS interface: `{schema_version, entries: OrgManifestEntry[]}`. +- Core import path confirmed: `relicario_core::decrypt_org_manifest` / `relicario_core::encrypt_org_manifest` / `relicario_core::OrgManifest` — all re-exported at crate root (`lib.rs:81-83`, `lib.rs:99`). -- [ ] **Step 1: Check current state.** Grep `crates/relicario-core/src/manifest.rs` for `collection`. If the org manifest already round-trips (org CLI works, so it likely uses a dedicated type or already has the field), this task is a no-op verification — confirm with a test and skip to commit. If `ManifestEntry` lacks `collection`, proceed. +- [x] **Step 1 (TDD RED):** Added test `org_manifest_round_trip_via_handle` in `org_tests` before implementing the fns. Confirmed compile error: `cannot find function 'org_manifest_encrypt' in this scope`. -- [ ] **Step 2: Write the failing test** +- [x] **Step 2 (Implement):** Added `org_manifest_decrypt` + `org_manifest_encrypt` in `lib.rs` right after `org_unwrap_key`, mirroring the existing `manifest_decrypt`/`manifest_encrypt` pattern exactly. Same `need_key(handle)?` guard, same `session::with`, same `js_value_for(&out)`. Swapped type + core fn. -```rust -#[test] -fn manifest_entry_round_trips_collection_slug() { - let json = r#"{"id":"a1","title":"db","collection":"prod-infra","modified":1}"#; - let entry: ManifestEntry = serde_json::from_str(json).unwrap(); - assert_eq!(entry.collection.as_deref(), Some("prod-infra")); - let back = serde_json::to_string(&entry).unwrap(); - assert!(back.contains("prod-infra")); -} -``` +- [x] **Step 3 (TDD GREEN):** `cargo test -p relicario-wasm org_manifest` → 1 passed. -- [ ] **Step 3: Run to verify it fails** +- [x] **Step 4 (TS types):** Added `OrgManifestEntry` + `OrgManifest` to `extension/src/shared/types.ts` (faithful/lean). Added `org_manifest_decrypt`/`org_manifest_encrypt` declarations to `extension/src/wasm.d.ts`. -Run: `cargo test -p relicario-core manifest_entry_round_trips_collection_slug` -Expected: FAIL (unknown field or missing accessor) — or PASS immediately if the field already exists (then this task is verification-only). - -- [ ] **Step 4: Add the field if absent** - -```rust -#[serde(skip_serializing_if = "Option::is_none", default)] -pub collection: Option, -``` - -- [ ] **Step 5: Run to verify it passes** - -Run: `cargo test -p relicario-core manifest` -Expected: PASS, no other manifest test regressed. - -- [ ] **Step 6: Mirror in TS + commit** - -Add `collection?: string;` to the `ManifestEntry` interface in `extension/src/shared/types.ts`. +- [x] **Step 5 (gates):** All five gates green (see commit). ```bash -git add crates/relicario-core/src/manifest.rs extension/src/shared/types.ts -git commit -m "feat(core): ManifestEntry carries optional collection slug" +git add crates/relicario-wasm/src/lib.rs extension/src/wasm.d.ts extension/src/shared/types.ts +git commit -m "feat(wasm,ext): org_manifest_decrypt/encrypt over WASM + faithful OrgManifest TS mirror" ``` --- @@ -460,8 +447,8 @@ Plans 2 and 3 are UI-only and talk to the SW exclusively through these messages - `org_list_configs` → `{ ok, data: OrgConfigSummary[] }` where `OrgConfigSummary = { orgId, displayName }` - `org_switch { context: 'personal' | }` → `{ ok, data: { context, offline: boolean } }` -- `org_list_items` → `{ ok, data: ManifestEntry[] }` (already grant-filtered; entries carry `collection`) -- `org_get_item { id }` → `{ ok, data: Item }` +- `org_list_items` → `{ ok, data: OrgManifestEntry[] }` (already grant-filtered; `collection` required — entries are the lean org shape, NOT personal ManifestEntry) +- `org_get_item { id }` → `{ ok, data: Item }` (org items are personal `Item`s encrypted with the org key; `item_decrypt` is unchanged) - `org_list_collections` → `{ ok, data: Collection[] }` where `Collection = { slug, display_name }` The SW holds the org context after `org_switch`; subsequent `org_list_items`/`org_get_item` operate on the current context until the next `org_switch` (including back to `'personal'`). Plan 3 adds `org_add_item`/`org_update_item`/`org_delete_item` against this same context model. diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 565afcf..5a23673 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -206,6 +206,25 @@ export interface ManifestEntry { attachment_summaries: AttachmentSummary[]; } +// --- Org manifest --- +// Org manifest — a dedicated, leaner shape than the personal ManifestEntry. +// Faithful to relicario-core OrgManifestEntry; collection is REQUIRED. + +export interface OrgManifestEntry { + id: ItemId; + type: ItemType; // Rust r#type → JSON key "type" + title: string; + tags: string[]; + modified: number; + trashed_at?: number; + collection: string; +} + +export interface OrgManifest { + schema_version: number; + entries: OrgManifestEntry[]; +} + // --- Vault settings --- // Full shape lives on the Rust side and in docs/superpowers/specs/2026-04-18-relicario-typed-items-design.md // β₂ tightens retention + generator_defaults; γ owns attachment_caps. diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 47feb4f..89dc5d6 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -88,6 +88,8 @@ declare module 'relicario-wasm' { export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle; + export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; + export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; export default function init(module_or_path?: unknown): Promise; export function initSync(args: { module: WebAssembly.Module }): void; From f5336e352fd9a94b0fffb5db5e4284c3f8d429f6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 25 Jun 2026 23:40:44 -0400 Subject: [PATCH 08/25] feat(ext/sw): org config storage + org_list_configs message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces OrgConfig/OrgConfigSummary types, chrome.storage.local backing via org-config.ts, and the first org SW message (org_list_configs) wired in all three required places: PopupMessage union, POPUP_ONLY_TYPES set, and a case arm in popup-only.ts. Handler projects to OrgConfigSummary — apiToken, hostUrl, repoPath, memberId never leave the SW. 3 new TDD tests (RED→GREEN). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../__tests__/org-config.test.ts | 47 +++++++++++++++++++ extension/src/service-worker/org-config.ts | 17 +++++++ .../src/service-worker/router/org-handlers.ts | 16 +++++++ .../src/service-worker/router/popup-only.ts | 4 ++ extension/src/shared/messages.ts | 10 +++- extension/src/shared/types.ts | 19 ++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-config.test.ts create mode 100644 extension/src/service-worker/org-config.ts create mode 100644 extension/src/service-worker/router/org-handlers.ts diff --git a/extension/src/service-worker/__tests__/org-config.test.ts b/extension/src/service-worker/__tests__/org-config.test.ts new file mode 100644 index 0000000..f9a87af --- /dev/null +++ b/extension/src/service-worker/__tests__/org-config.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handleOrgListConfigs } from '../router/org-handlers'; + +function mockChromeStorage(data: Record) { + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: vi.fn().mockResolvedValue(data), + }, + }, + } as never; +} + +describe('org-config / org_list_configs', () => { + beforeEach(() => { + mockChromeStorage({}); + }); + + it('org_list_configs returns id+displayName only (no tokens)', async () => { + chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [ + { orgId: 'o1', displayName: 'Acme', hostType: 'gitea', hostUrl: 'h', repoPath: 'r', apiToken: 'SECRET', memberId: 'm1' }, + ]}); + const resp = await handleOrgListConfigs(); + expect(resp).toEqual({ ok: true, data: [{ orgId: 'o1', displayName: 'Acme' }] }); + }); + + it('returns empty array when no orgConfigs stored', async () => { + chrome.storage.local.get = vi.fn().mockResolvedValue({}); + const resp = await handleOrgListConfigs(); + expect(resp).toEqual({ ok: true, data: [] }); + }); + + it('does not expose apiToken in any entry', async () => { + chrome.storage.local.get = vi.fn().mockResolvedValue({ orgConfigs: [ + { orgId: 'o2', displayName: 'Corp', hostType: 'github', hostUrl: 'https://github.com', repoPath: 'corp/vault', apiToken: 'ghp_SUPERSECRET', memberId: 'm2' }, + ]}); + const resp = await handleOrgListConfigs(); + expect(resp.ok).toBe(true); + if (resp.ok) { + const entry = resp.data[0]; + expect('apiToken' in entry).toBe(false); + expect('hostUrl' in entry).toBe(false); + expect('repoPath' in entry).toBe(false); + expect('memberId' in entry).toBe(false); + } + }); +}); diff --git a/extension/src/service-worker/org-config.ts b/extension/src/service-worker/org-config.ts new file mode 100644 index 0000000..457cffc --- /dev/null +++ b/extension/src/service-worker/org-config.ts @@ -0,0 +1,17 @@ +/// Org config storage — device-local chrome.storage.local backing for +/// configured org vaults. The full OrgConfig (including apiToken) lives only +/// in the SW; only OrgConfigSummary (id + displayName) is ever returned to the +/// popup. See shared/types.ts for the type definitions. + +import type { OrgConfig } from '../shared/types'; + +/** Load all org configs from chrome.storage.local. Returns [] if none stored. */ +export async function loadOrgConfigs(): Promise { + const { orgConfigs } = await chrome.storage.local.get('orgConfigs'); + return (orgConfigs as OrgConfig[] | undefined) ?? []; +} + +/** Persist the full org config list to chrome.storage.local. */ +export async function saveOrgConfigs(configs: OrgConfig[]): Promise { + await chrome.storage.local.set({ orgConfigs: configs }); +} diff --git a/extension/src/service-worker/router/org-handlers.ts b/extension/src/service-worker/router/org-handlers.ts new file mode 100644 index 0000000..42d0e66 --- /dev/null +++ b/extension/src/service-worker/router/org-handlers.ts @@ -0,0 +1,16 @@ +/// 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). + +import { loadOrgConfigs } from '../org-config'; +import type { OrgConfigSummary } from '../../shared/types'; + +/** Handle org_list_configs: returns only { orgId, displayName } per org. */ +export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfigSummary[] }> { + const cfgs = await loadOrgConfigs(); + return { + ok: true as const, + data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })), + }; +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index bbc6036..a18deee 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -14,6 +14,7 @@ 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'; // --- Shared ambient state owned by the SW module --- // @@ -642,6 +643,9 @@ export async function handle( case 'get_vault_status': return vault.handleGetVaultStatus(state); + case 'org_list_configs': + return handleOrgListConfigs(); + default: return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 3c59bc4..21d492a 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,7 +1,7 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device, - FieldHistoryView, + FieldHistoryView, OrgConfigSummary, } from './types'; // --- Session timeout config --- @@ -68,7 +68,8 @@ export type PopupMessage = carrierImageBytes: ArrayBuffer; deviceName: string } | { type: 'attach_vault'; config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string } - | { type: 'get_vault_status' }; + | { type: 'get_vault_status' } + | { type: 'org_list_configs' }; // --- Messages a content script may send --- @@ -182,6 +183,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'preview_totp_from_secret', 'generate_recovery_qr', 'unwrap_recovery_qr', 'create_vault', 'attach_vault', 'get_vault_status', + 'org_list_configs', ] as PopupMessage['type'][]); export interface ExportBackupResponse extends Extract { @@ -221,6 +223,10 @@ export interface GetVaultStatusResponse extends Extract pendingItems: number }; } +export interface OrgListConfigsResponse extends Extract { + data: OrgConfigSummary[]; +} + export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', 'capture_save_login', diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 5a23673..84fe3b6 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -206,6 +206,25 @@ export interface ManifestEntry { attachment_summaries: AttachmentSummary[]; } +// --- Org config (device-local storage) --- + +/** Full org config stored in the SW only — never sent to the popup. */ +export interface OrgConfig { + orgId: string; + displayName: string; + hostType: 'gitea' | 'github'; + hostUrl: string; + repoPath: string; + apiToken: string; + memberId: string; +} + +/** Safe subset returned by org_list_configs — no tokens, no remote details. */ +export interface OrgConfigSummary { + orgId: string; + displayName: string; +} + // --- Org manifest --- // Org manifest — a dedicated, leaner shape than the personal ManifestEntry. // Faithful to relicario-core OrgManifestEntry; collection is REQUIRED. From be619f8ed0a4982ce755c5423c01665a339f3b0e Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 21:53:24 -0400 Subject: [PATCH 09/25] feat(wasm): persist/restore device key under master key; org_unwrap_key reads DEVICE_STATE - device.rs: add PersistedDeviceState (serde mirror), export_state_bytes (plaintext JSON in Zeroizing>, NOT a wasm_bindgen export), import_state_bytes (repopulates DEVICE_STATE with Zeroizing keys), signing_seed (extracts ed25519 seed via core::device::extract_ed25519_seed) - lib.rs: add persist_device_key (#[wasm_bindgen], encrypts state bytes under master key via relicario_core::crypto::encrypt; ciphertext only reaches JS), restore_device_key (#[wasm_bindgen], decrypts inside WASM then calls import_state_bytes; plaintext never leaves WASM) - lib.rs: refactor org_unwrap_key to drop device_private_openssh param; reads seed from DEVICE_STATE via device::signing_seed() - lib.rs: update org_unwrap_key_yields_a_session_that_decrypts_org_blobs to new signature (register_device first, use signing_pub for wrap_org_key) - lib.rs: add device_persist_tests module (4 tests: round-trip, org-unwrap post-restore, ciphertext-not-plaintext, no-device-error) - lib.rs: add DEVICE_TEST_LOCK static to serialize DEVICE_STATE-touching tests - lib.rs: add #[derive(Debug)] to SessionHandle - wasm.d.ts: update org_unwrap_key sig; declare persist_device_key + restore_device_key All gates: cargo test -p relicario-wasm (15/15), cargo test -p relicario-core (no regression), cargo build --target wasm32-unknown-unknown (clean), clippy -D warnings (clean). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- crates/relicario-wasm/src/device.rs | 85 +++++++++++ crates/relicario-wasm/src/lib.rs | 222 +++++++++++++++++++++++----- extension/src/wasm.d.ts | 15 +- 3 files changed, 281 insertions(+), 41 deletions(-) diff --git a/crates/relicario-wasm/src/device.rs b/crates/relicario-wasm/src/device.rs index 68fafc4..3ecef05 100644 --- a/crates/relicario-wasm/src/device.rs +++ b/crates/relicario-wasm/src/device.rs @@ -3,6 +3,7 @@ use std::sync::Mutex; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use relicario_core::device as core_device; @@ -20,6 +21,27 @@ struct DeviceState { deploy_public: String, } +/// Serialization-safe mirror of `DeviceState`. +/// +/// Fields are plain `String` (not `Zeroizing`) because serde requires `Sized` +/// owned types. The byte vector produced by `export_state_bytes` is wrapped in +/// `Zeroizing>`; `import_state_bytes` re-wraps the private key fields +/// back into `Zeroizing` on decode. +/// +/// # Security note +/// +/// This struct itself is NOT zeroed on drop. The invariant that matters is: +/// (a) `persist_device_key` in lib.rs encrypts the bytes before returning to JS, +/// (b) `export_state_bytes` is NOT a `#[wasm_bindgen]` export. +#[derive(Serialize, Deserialize)] +struct PersistedDeviceState { + name: String, + signing_private: String, + signing_public: String, + deploy_private: String, + deploy_public: String, +} + /// Register a new device, storing the keypairs internally and returning /// only the public keys. Private keys never leave WASM memory. pub fn register_device(name: &str) -> Result<(String, String), String> { @@ -69,3 +91,66 @@ pub fn get_device_info() -> Option<(String, String, String)> { pub fn clear_device() { *DEVICE_STATE.lock().unwrap() = None; } + +/// Serialize the current `DEVICE_STATE` to JSON bytes, wrapped in `Zeroizing`. +/// +/// # Security +/// +/// The returned bytes are **plaintext secret material** — they contain the +/// ed25519 private key in OpenSSH PEM format. The caller in `lib.rs` encrypts +/// them immediately under the vault master key before returning anything to JS. +/// This function **must never** be exposed as a `#[wasm_bindgen]` export. +/// +/// # Errors +/// +/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty. +pub fn export_state_bytes() -> Result>, String> { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?; + let persisted = PersistedDeviceState { + name: state.name.clone(), + signing_private: state.signing_private.as_str().to_owned(), + signing_public: state.signing_public.clone(), + deploy_private: state.deploy_private.as_str().to_owned(), + deploy_public: state.deploy_public.clone(), + }; + let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?; + Ok(Zeroizing::new(bytes)) +} + +/// Deserialize a `PersistedDeviceState` from raw bytes and repopulate +/// `DEVICE_STATE`. Private key fields are re-wrapped in `Zeroizing`. +/// +/// # Errors +/// +/// Returns `Err(...)` if `bytes` is not valid JSON matching `PersistedDeviceState`. +pub fn import_state_bytes(bytes: &[u8]) -> Result<(), String> { + let persisted: PersistedDeviceState = + serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + let state = DeviceState { + name: persisted.name, + signing_private: Zeroizing::new(persisted.signing_private), + signing_public: persisted.signing_public, + deploy_private: Zeroizing::new(persisted.deploy_private), + deploy_public: persisted.deploy_public, + }; + *DEVICE_STATE.lock().unwrap() = Some(state); + Ok(()) +} + +/// Extract the raw 32-byte ed25519 seed from the registered device's signing key. +/// +/// Used by `org_unwrap_key` (which calls `relicario_core::org::unwrap_org_key`) +/// so the private key never has to cross to JS. The seed is wrapped in +/// `Zeroizing<[u8; 32]>` and wiped on drop. +/// +/// # Errors +/// +/// Returns `Err("no device registered")` if `DEVICE_STATE` is empty, or an +/// error from `extract_ed25519_seed` if the stored key can't be parsed. +pub fn signing_seed() -> Result, String> { + let guard = DEVICE_STATE.lock().unwrap(); + let state = guard.as_ref().ok_or_else(|| "no device registered".to_string())?; + core_device::extract_ed25519_seed(state.signing_private.as_str()) + .map_err(|e| e.to_string()) +} diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index e16be9f..ebb7215 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -19,6 +19,7 @@ use relicario_core::{derive_master_key, imgsecret, KdfParams}; /// `lock(handle)` remains available as the explicit early-cleanup path; the /// `Drop` impl is the safety net that catches code paths which forget to call /// `lock` before letting the handle go out of scope. +#[derive(Debug)] #[wasm_bindgen] pub struct SessionHandle(u32); @@ -609,31 +610,49 @@ pub fn wasm_unwrap_recovery_qr( // ── Org vault WASM bridge ──────────────────────────────────────────────────── -/// Unwrap a member's ECIES-wrapped org master key into a session handle. +/// Encrypt the registered device key under the current vault master key and +/// return CIPHERTEXT for JS to persist (e.g. `chrome.storage.local.device_key_enc`). +/// +/// The device private key NEVER crosses to JS — only this encrypted blob does. +/// On the decrypt side, `restore_device_key` decrypts inside WASM and repopulates +/// `DEVICE_STATE` without ever returning plaintext to JS. +#[wasm_bindgen] +pub fn persist_device_key(handle: &SessionHandle) -> Result, JsError> { + need_key(handle)?; + let plain = device::export_state_bytes().map_err(|e| JsError::new(&e))?; + session::with(handle.0, |k| relicario_core::crypto::encrypt(k, &plain)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string())) +} + +/// Decrypt a persisted device-key blob under the master key and repopulate +/// `DEVICE_STATE` (decryption happens INSIDE WASM; plaintext never reaches JS). +/// Call this at unlock time after `restore_device_key` to restore device signing +/// capability across service-worker restarts. +#[wasm_bindgen] +pub fn restore_device_key(handle: &SessionHandle, encrypted: &[u8]) -> Result<(), JsError> { + need_key(handle)?; + let plain = session::with(handle.0, |k| relicario_core::crypto::decrypt(k, encrypted)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + let plain = Zeroizing::new(plain); + device::import_state_bytes(&plain).map_err(|e| JsError::new(&e)) +} + +/// Unwrap a member's ECIES-wrapped org master key into a session handle, using +/// the registered device key held in `DEVICE_STATE` (restored at unlock via +/// `restore_device_key`). The device private key never crosses to JS. /// /// `keys_blob` is the raw wrapped-key blob at `keys/.enc` in the org -/// repo — produced by `relicario_core::org::wrap_org_key`. `device_private_openssh` -/// is the device's ed25519 private key. -/// -/// **Device-key form (Step 1 finding):** the key arrives as an OpenSSH PEM blob — -/// the multiline `-----BEGIN OPENSSH PRIVATE KEY-----...` form emitted by -/// `relicario_core::device::generate_keypair()`, NOT a base64-encoded raw-bytes -/// string. The plan's `device_private_key_base64` name was misleading; the spec -/// name `device_private_openssh` matches reality. The SW holds this blob in WASM -/// `DEVICE_STATE` and never exposes it to JS. Decoding to the zeroized 32-byte -/// seed is delegated to `relicario_core::device::extract_ed25519_seed`, the same -/// path `device::sign()` uses, so the secret never lands in a plain `[u8; 32]`. +/// repo — produced by `relicario_core::org::wrap_org_key`. /// /// The org key is held in the same Zeroizing WASM session registry as the personal /// master key; org items share the personal `.enc` AEAD format, so the returned /// handle works with `item_decrypt`/`manifest_decrypt` unchanged. #[wasm_bindgen] -pub fn org_unwrap_key( - keys_blob: &[u8], - device_private_openssh: &str, -) -> Result { - let seed = relicario_core::device::extract_ed25519_seed(device_private_openssh) - .map_err(|e| JsError::new(&format!("bad device key: {e}")))?; +pub fn org_unwrap_key(keys_blob: &[u8]) -> Result { + let seed = device::signing_seed() + .map_err(|e| JsError::new(&format!("device key unavailable: {e}")))?; let org_key = relicario_core::org::unwrap_org_key(keys_blob, &seed) .map_err(|e| JsError::new(&format!("org unwrap failed: {e}")))?; // image_secret slot is unused for org sessions; store a zeroized placeholder. @@ -665,6 +684,12 @@ pub fn org_manifest_encrypt(handle: &SessionHandle, manifest_json: &str) -> Resu .map_err(|e| JsError::new(&e.to_string())) } +/// Tests that modify DEVICE_STATE must serialize to prevent races. +/// Acquired by any test in org_tests or device_persist_tests that +/// calls register_device / clear_device / persist_device_key / restore_device_key. +#[cfg(test)] +static DEVICE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] mod org_tests { use super::*; @@ -708,31 +733,20 @@ mod org_tests { assert_ne!(encrypted, encrypted2, "nonces must differ"); } - /// Returns (public_openssh, private_openssh) using the real core keypair generator. - /// - /// Device private keys are produced by `relicario_core::device::generate_keypair()` - /// in OpenSSH PEM format ("-----BEGIN OPENSSH PRIVATE KEY-----..."), - /// NOT base64-encoded raw bytes. The plan parameter name "device_private_key_base64" - /// is misleading; the spec name "device_private_openssh" matches the actual format. - /// We call the real generator here (not a hand-rolled helper) so the test exercises - /// the exact production key format and cannot silently drift. The private key is - /// kept in its `Zeroizing` wrapper — never copied into a plain `String`. - fn test_device_keypair() -> (String, Zeroizing) { - let (priv_openssh, pub_openssh) = relicario_core::generate_keypair().unwrap(); - (pub_openssh, priv_openssh) - } - #[test] fn org_unwrap_key_yields_a_session_that_decrypts_org_blobs() { - // Generate a device keypair, wrap a known org key to it, unwrap via the WASM - // path, then encrypt+decrypt an item through the returned handle to assert - // round-trip. - let org_key = Zeroizing::new([7u8; 32]); - let (pub_openssh, priv_openssh) = test_device_keypair(); - let wrapped = wrap_org_key(&org_key, &pub_openssh).unwrap(); + // Serialize with all tests that touch DEVICE_STATE. + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + device::clear_device(); - // &priv_openssh derefs Zeroizing → &str; no plain-String copy. - let handle = org_unwrap_key(&wrapped, &priv_openssh).unwrap(); + // Register a device so DEVICE_STATE is populated; use its signing public key + // to wrap a known org key. org_unwrap_key now reads the seed from DEVICE_STATE + // — the device_private_openssh param is gone in the 4.5a refactor. + let org_key = Zeroizing::new([7u8; 32]); + let (signing_pub, _deploy_pub) = device::register_device("test-dev").unwrap(); + let wrapped = wrap_org_key(&org_key, &signing_pub).unwrap(); + + let handle = org_unwrap_key(&wrapped).unwrap(); // item_encrypt is native-safe: returns Vec; JsError is only reachable on // the error path, which is not exercised here. let ct = item_encrypt( @@ -748,6 +762,134 @@ mod org_tests { } } +#[cfg(test)] +mod device_persist_tests { + use super::*; + + /// 1. Persist → restore round-trip. + /// + /// After persisting the device state and simulating a SW restart (clear_device), + /// restoring must bring back the exact same name and signing_public key. + #[test] + fn persist_restore_round_trip() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + // A master-key session (no Argon2id needed — we construct the key directly). + let master_key = Zeroizing::new([0x42u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + // Register a device; capture name + signing public key before persist. + device::register_device("my-laptop").unwrap(); + let (name_before, signing_pub_before, _) = device::get_device_info().unwrap(); + + // Encrypt the device state under the master key. + let enc = persist_device_key(&handle).unwrap(); + + // Simulate a SW restart: wipe DEVICE_STATE in-memory. + device::clear_device(); + assert!(device::get_device_info().is_none(), "DEVICE_STATE must be cleared"); + + // Decrypt and repopulate. + restore_device_key(&handle, &enc).unwrap(); + + // Name and signing_public must survive the full round-trip. + let (name_after, signing_pub_after, _) = device::get_device_info().unwrap(); + assert_eq!(name_before, name_after, "device name must survive persist/restore"); + assert_eq!( + signing_pub_before, signing_pub_after, + "signing_public must survive persist/restore" + ); + } + + /// 2. org_unwrap_key works post-restore. + /// + /// After a persist→clear→restore cycle, org_unwrap_key must be able to unwrap + /// an org key that was wrapped to the device's signing public key. + #[test] + fn org_unwrap_key_works_after_restore() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + let master_key = Zeroizing::new([0x43u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + // Register device; capture signing_pub for key wrapping. + let (signing_pub, _deploy_pub) = device::register_device("my-laptop").unwrap(); + + // Full persist → wipe → restore cycle (simulates SW restart). + let enc = persist_device_key(&handle).unwrap(); + device::clear_device(); + restore_device_key(&handle, &enc).unwrap(); + + // Wrap a known org key to the device's signing public key. + let known_org_key = Zeroizing::new([0x77u8; 32]); + let wrapped = relicario_core::org::wrap_org_key(&known_org_key, &signing_pub).unwrap(); + + // Unwrap using the refactored org_unwrap_key (reads seed from DEVICE_STATE). + let org_handle = org_unwrap_key(&wrapped).unwrap(); + + // Encrypt an item under the org session, then decrypt with the known key + // directly (avoids js-sys/serde_wasm_bindgen on native — mirrors existing org test). + let ct = item_encrypt( + &org_handle, + r#"{"id":"a1b2c3d4e5f6a7b8","title":"Test","type":"secure_note","created":0,"modified":0,"core":{"type":"secure_note","body":"x"}}"#, + ) + .unwrap(); + let pt: relicario_core::Item = + relicario_core::decrypt_item(&ct, &known_org_key).unwrap(); + assert!(format!("{:?}", pt.core).contains("SecureNote")); + } + + /// 3. Ciphertext is NOT plaintext. + /// + /// The blob returned by persist_device_key must not contain the raw PEM private + /// key header — proves the device key is encrypted, never handed to JS in clear. + #[test] + fn persist_output_is_ciphertext_not_plaintext() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + session::clear(); + device::clear_device(); + + let master_key = Zeroizing::new([0x44u8; 32]); + let h = session::insert(master_key, Zeroizing::new([0u8; 32])); + let handle = SessionHandle(h); + + device::register_device("my-laptop").unwrap(); + let enc = persist_device_key(&handle).unwrap(); + + // Encrypted bytes must NOT contain the OpenSSH PEM header verbatim. + // The XChaCha20-Poly1305 ciphertext is randomized; the ASCII string + // "BEGIN OPENSSH PRIVATE KEY" cannot appear in it. + let as_lossy = String::from_utf8_lossy(&enc); + assert!( + !as_lossy.contains("BEGIN OPENSSH PRIVATE KEY"), + "persist_device_key must return ciphertext, not the raw PEM private key (device privkey must never reach JS)" + ); + } + + /// 4. org_unwrap_key errors when no device is registered. + /// + /// `org_unwrap_key` wraps its error in `JsError`, which panics on non-wasm + /// targets (established constraint in this codebase — see session_tests comment). + /// We test `device::signing_seed` directly instead: that is the codepath + /// `org_unwrap_key` calls first, and it is what produces the "no device + /// registered" error that `org_unwrap_key` surfaces as "device key unavailable". + #[test] + fn org_unwrap_key_errors_when_no_device_registered() { + let _guard = super::DEVICE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + device::clear_device(); + + let err = device::signing_seed() + .expect_err("signing_seed must fail when no device is registered"); + assert_eq!(err, "no device registered", "error message must match"); + } +} + #[cfg(test)] mod session_tests { use super::*; diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 2e90ad3..cad0669 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -87,9 +87,22 @@ declare module 'relicario-wasm' { export function wasm_generate_recovery_qr(handle: SessionHandle, passphrase: string): string; export function wasm_unwrap_recovery_qr(payload_b64: string, passphrase: string): Uint8Array; - export function org_unwrap_key(keys_blob: Uint8Array, device_private_openssh: string): SessionHandle; + /** Unwrap an ECIES-wrapped org master key using the registered device key in DEVICE_STATE. */ + export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle; export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; + /** + * Encrypt the registered device key under the vault master key. + * Returns CIPHERTEXT — the device private key never crosses to JS. + * Store the returned bytes in chrome.storage.local as `device_key_enc`. + */ + export function persist_device_key(handle: SessionHandle): Uint8Array; + /** + * Decrypt a persisted device-key blob under the master key and repopulate + * DEVICE_STATE. Decryption happens inside WASM; plaintext never reaches JS. + * Call at unlock time with the `device_key_enc` blob from chrome.storage.local. + */ + export function restore_device_key(handle: SessionHandle, encrypted: Uint8Array): void; // Pluggable second factor: key-file bindings (Task 3) export function unlock_with_secret(passphrase: string, secret: Uint8Array, salt: Uint8Array, params_json: string): SessionHandle; export function keyfile_encode(secret: Uint8Array): Uint8Array; From e6ec210736315521a58b546a225c9487f071948f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:01:00 -0400 Subject: [PATCH 10/25] fix(wasm): zeroize transient device-private-key copy in export_state_bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fix (Important secret-hygiene finding): export_state_bytes built a PersistedDeviceState via state.signing_private.as_str().to_owned(), creating non-zeroized heap copies of the device ed25519 private keys (signing + deploy) that lingered as plaintext residue after the struct dropped. - Cargo.toml: enable zeroize "derive" feature (zeroize_derive already in lock tree) - device.rs: PersistedDeviceState now derives Zeroize + ZeroizeOnDrop, wiping all String fields on every drop path (success, serde_json::to_vec ? error path, panic) - device.rs: import_state_bytes uses std::mem::take per field (ZeroizeOnDrop adds a Drop impl, so fields can no longer be moved out — E0509); mem::take transfers the heap buffers without copying, leaving empty Strings the drop-zeroize no-ops - doc-comments updated to describe the ZeroizeOnDrop / mem::take hygiene Hygiene-only; no behavior change. Gates: cargo test -p relicario-wasm 15/15, clippy -D warnings clean, build --target wasm32-unknown-unknown clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- crates/relicario-wasm/Cargo.toml | 2 +- crates/relicario-wasm/src/device.rs | 46 +++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/relicario-wasm/Cargo.toml b/crates/relicario-wasm/Cargo.toml index ffd5fae..08065cc 100644 --- a/crates/relicario-wasm/Cargo.toml +++ b/crates/relicario-wasm/Cargo.toml @@ -14,7 +14,7 @@ wasm-bindgen = "0.2" serde-wasm-bindgen = "0.6" serde_json = "1" serde = { version = "1", features = ["derive"] } -zeroize = "1" +zeroize = { version = "1", features = ["derive"] } getrandom = { version = "0.2", features = ["js"] } ed25519-dalek = { version = "2", features = ["rand_core"] } base64 = "0.22" diff --git a/crates/relicario-wasm/src/device.rs b/crates/relicario-wasm/src/device.rs index 3ecef05..347b2a7 100644 --- a/crates/relicario-wasm/src/device.rs +++ b/crates/relicario-wasm/src/device.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use zeroize::Zeroizing; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use relicario_core::device as core_device; @@ -30,10 +30,17 @@ struct DeviceState { /// /// # Security note /// -/// This struct itself is NOT zeroed on drop. The invariant that matters is: +/// `ZeroizeOnDrop` wipes every `String` field (including the `signing_private` / +/// `deploy_private` device PRIVATE keys) when the struct drops, on EVERY path — +/// success, the `?` error path of `serde_json::to_vec`, and panics — so the +/// transient plaintext copy created in `export_state_bytes` leaves no residue in +/// heap memory. Because the derived `ZeroizeOnDrop` adds a `Drop` impl, fields +/// cannot be moved out (Rust E0509); `import_state_bytes` uses `mem::take` to +/// transfer the private-key Strings into `Zeroizing` without copying, +/// leaving empty Strings that the drop-zeroize no-ops. The remaining invariants: /// (a) `persist_device_key` in lib.rs encrypts the bytes before returning to JS, /// (b) `export_state_bytes` is NOT a `#[wasm_bindgen]` export. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] struct PersistedDeviceState { name: String, signing_private: String, @@ -97,9 +104,15 @@ pub fn clear_device() { /// # Security /// /// The returned bytes are **plaintext secret material** — they contain the -/// ed25519 private key in OpenSSH PEM format. The caller in `lib.rs` encrypts -/// them immediately under the vault master key before returning anything to JS. -/// This function **must never** be exposed as a `#[wasm_bindgen]` export. +/// ed25519 private keys (signing + deploy) in OpenSSH PEM format. The caller in +/// `lib.rs` encrypts them immediately under the vault master key before returning +/// anything to JS. This function **must never** be exposed as a `#[wasm_bindgen]` +/// export. +/// +/// The transient `persisted` clone holds plaintext private-key copies, but +/// `PersistedDeviceState` derives `ZeroizeOnDrop`, so those copies are wiped when +/// `persisted` drops — on the success path, the `?` error path of +/// `serde_json::to_vec`, and on any panic — leaving no heap residue. /// /// # Errors /// @@ -116,23 +129,32 @@ pub fn export_state_bytes() -> Result>, String> { }; let bytes = serde_json::to_vec(&persisted).map_err(|e| e.to_string())?; Ok(Zeroizing::new(bytes)) + // `persisted` (and its plaintext private-key copies) is zeroized here on drop. } /// Deserialize a `PersistedDeviceState` from raw bytes and repopulate /// `DEVICE_STATE`. Private key fields are re-wrapped in `Zeroizing`. /// +/// # Security +/// +/// `PersistedDeviceState` derives `ZeroizeOnDrop`, which adds a `Drop` impl, so +/// its fields cannot be moved out (Rust E0509). We use `mem::take` to transfer +/// each `String` into the new `DeviceState` (wrapping the private keys in +/// `Zeroizing`) — this moves the heap buffer without copying, leaving an +/// empty `String` behind whose drop-zeroize is a no-op. No secret copy survives. +/// /// # Errors /// /// Returns `Err(...)` if `bytes` is not valid JSON matching `PersistedDeviceState`. pub fn import_state_bytes(bytes: &[u8]) -> Result<(), String> { - let persisted: PersistedDeviceState = + let mut persisted: PersistedDeviceState = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; let state = DeviceState { - name: persisted.name, - signing_private: Zeroizing::new(persisted.signing_private), - signing_public: persisted.signing_public, - deploy_private: Zeroizing::new(persisted.deploy_private), - deploy_public: persisted.deploy_public, + name: std::mem::take(&mut persisted.name), + signing_private: Zeroizing::new(std::mem::take(&mut persisted.signing_private)), + signing_public: std::mem::take(&mut persisted.signing_public), + deploy_private: Zeroizing::new(std::mem::take(&mut persisted.deploy_private)), + deploy_public: std::mem::take(&mut persisted.deploy_public), }; *DEVICE_STATE.lock().unwrap() = Some(state); Ok(()) From 0d56b3333bf064770e9377abe5eaea44b74666d9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:13:50 -0400 Subject: [PATCH 11/25] feat(ext/sw): persist device key at register, restore at unlock (+ migration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `persistDeviceKey(w, handle)` to vault.ts: calls `w.persist_device_key(handle)` and stores the ciphertext as base64 in chrome.storage.local `device_key_enc`. - Wire into `registerDeviceAndPersistConfig` (create + attach flows) so the key is persisted immediately after every `register_device`. - Wire into `register_this_device` handler via `session.requireCurrent()`. - Restore at unlock: if `device_key_enc` present, call `w.restore_device_key(handle, blob)`; failure is caught + logged but must not block unlock. - Migration for pre-4.5b installs (absent `device_key_enc`): re-register, persist new key, addDevice to remote (best-effort, must not block). New device identity — org members must be re-granted to new pubkey. - 4 new tests in device-key-persist.test.ts; existing 146 tests updated and kept green; router.test.ts register_this_device stub extended. --- .../__tests__/device-key-persist.test.ts | 287 ++++++++++++++++++ .../service-worker/__tests__/vault.test.ts | 1 + .../router/__tests__/router.test.ts | 2 + .../src/service-worker/router/popup-only.ts | 48 +++ extension/src/service-worker/vault.ts | 25 +- 5 files changed, 358 insertions(+), 5 deletions(-) create mode 100644 extension/src/service-worker/__tests__/device-key-persist.test.ts diff --git a/extension/src/service-worker/__tests__/device-key-persist.test.ts b/extension/src/service-worker/__tests__/device-key-persist.test.ts new file mode 100644 index 0000000..2d581e9 --- /dev/null +++ b/extension/src/service-worker/__tests__/device-key-persist.test.ts @@ -0,0 +1,287 @@ +/** + * Task 4.5b — device-key persist / restore / migration wiring. + * + * Tests the popup-only handler directly (not the full router) to verify: + * 1. Unlock WITH device_key_enc → restore_device_key called with decoded blob; + * register_device NOT called. + * 2. register_this_device → persist_device_key called; device_key_enc written. + * 3. Unlock with NO device_key_enc (migration) → register_device + persist + + * addDevice called; device_key_enc written. + * 4. restore_device_key throwing does NOT block unlock (still returns ok:true). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handle as popupHandle } from '../router/popup-only'; +import type { PopupState } from '../router/popup-only'; +import * as session from '../session'; +import * as devices from '../devices'; +import { uint8ArrayToBase64 } from '../git-host'; + +// --- Module mocks ------------------------------------------------------- +// Vitest hoists vi.mock() calls, so declare them before any imports. + +vi.mock('../git-host', async () => { + const actual = await vi.importActual('../git-host'); + return { + ...actual, // preserves real uint8ArrayToBase64 / base64ToUint8Array + createGitHost: vi.fn().mockReturnValue({ + readFile: vi.fn().mockImplementation(async (path: string) => { + if (path === '.relicario/salt') return new Uint8Array(32); + if (path === '.relicario/params.json') + return new TextEncoder().encode('{"argon2_m":256,"argon2_t":1,"argon2_p":1}'); + throw new Error(`404: ${path}`); + }), + writeFile: vi.fn(), + writeFileCreateOnly: vi.fn(), + deleteFile: vi.fn(), + listDir: vi.fn().mockResolvedValue([]), + lastCommit: vi.fn().mockResolvedValue(null), + putBlob: vi.fn(), + getBlob: vi.fn(), + deleteBlob: vi.fn(), + }), + }; +}); + +vi.mock('../session', () => ({ + setCurrent: vi.fn(), + getCurrent: vi.fn(), + clearCurrent: vi.fn(), + requireCurrent: vi.fn(), +})); + +vi.mock('../devices', () => ({ + readDevices: vi.fn(), + addDevice: vi.fn().mockResolvedValue(undefined), + revokeDevice: vi.fn(), +})); + +vi.mock('../vault', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, // preserves real persistDeviceKey + fetchVaultMeta: vi.fn().mockResolvedValue({ + salt: new Uint8Array(32), + paramsJson: '{"argon2_m":256,"argon2_t":1,"argon2_p":1}', + }), + fetchAndDecryptManifest: vi.fn().mockResolvedValue({ schema_version: 2, items: {} }), + }; +}); + +// --- Chrome storage mock ------------------------------------------------ + +let store: Record = {}; + +function mockChromeStorage(initial: Record = {}): void { + store = { ...initial }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).chrome = { + runtime: { id: 'test-relicario-id' }, + storage: { + local: { + get: vi.fn((keys: string | string[]) => { + const arr = Array.isArray(keys) ? keys : [keys]; + const out: Record = {}; + for (const k of arr) if (k in store) out[k] = store[k]; + return Promise.resolve(out); + }), + set: vi.fn((kv: Record) => { + Object.assign(store, kv); + return Promise.resolve(); + }), + }, + }, + }; +} + +// --- Helpers ------------------------------------------------------------ + +const VAULT_CONFIG = { + hostType: 'gitea' as const, + hostUrl: 'https://git.example', + repoPath: 'alice/vault', + apiToken: 'tok', +}; + +// Valid base64 for 3 zero bytes — used as a trivial imageBase64 in unlock flow. +const IMAGE_B64 = 'AAAA'; + +function makePopupSender(): chrome.runtime.MessageSender { + return { + url: 'chrome-extension://test-relicario-id/popup.html', + id: 'test-relicario-id', + }; +} + +function makeFakeHandle() { + return { free: vi.fn() }; +} + +function makeWasm(overrides: Record = {}) { + const fakeHandle = makeFakeHandle(); + return { + _handle: fakeHandle, + unlock: vi.fn(() => fakeHandle), + persist_device_key: vi.fn(() => new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF])), + restore_device_key: vi.fn(), + register_device: vi.fn(() => ({ + signing_public_key: 'pk-new', + deploy_public_key: 'dk-new', + })), + ...overrides, + }; +} + +function makeState(wasm: ReturnType): PopupState { + return { manifest: null, gitHost: null, wasm }; +} + +// --- Global setup / teardown -------------------------------------------- + +beforeEach(() => { + vi.mocked(session.setCurrent).mockImplementation(() => {}); + vi.mocked(session.requireCurrent).mockReturnValue(makeFakeHandle() as never); + vi.mocked(devices.addDevice).mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +// ======================================================================== +// Test 1: Unlock WITH device_key_enc → restore called; register NOT called +// ======================================================================== + +describe('unlock WITH device_key_enc stored', () => { + it('calls restore_device_key with the decoded blob; does NOT call register_device', async () => { + const CIPHER = new Uint8Array([0x01, 0x02, 0x03]); + const B64 = uint8ArrayToBase64(CIPHER); + + mockChromeStorage({ + vaultConfig: VAULT_CONFIG, + imageBase64: IMAGE_B64, + device_key_enc: B64, + }); + + const wasm = makeWasm(); + const state = makeState(wasm); + + const resp = await popupHandle( + { type: 'unlock', passphrase: 'hunter2' }, + state, + makePopupSender(), + ); + + expect(resp.ok).toBe(true); + expect(wasm.restore_device_key).toHaveBeenCalledTimes(1); + + // The Uint8Array decoded from B64 must equal CIPHER byte-for-byte. + const [, restoredBytes] = wasm.restore_device_key.mock.calls[0] as [unknown, Uint8Array]; + expect(Array.from(restoredBytes)).toEqual(Array.from(CIPHER)); + + expect(wasm.register_device).not.toHaveBeenCalled(); + }); +}); + +// ======================================================================== +// Test 2: register_this_device → persist_device_key called; storage written +// ======================================================================== + +describe('register_this_device flow', () => { + it('calls persist_device_key and writes device_key_enc (base64 of ciphertext) to storage', async () => { + const CIPHER = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]); + + mockChromeStorage({}); + + const wasm = makeWasm({ + persist_device_key: vi.fn(() => CIPHER), + }); + const state = makeState(wasm); + // Vault must appear unlocked (gitHost set) so the handler doesn't short-circuit. + state.gitHost = {} as never; + + const resp = await popupHandle( + { type: 'register_this_device', name: 'My Browser' }, + state, + makePopupSender(), + ); + + expect(resp.ok).toBe(true); + expect(wasm.persist_device_key).toHaveBeenCalledTimes(1); + // device_key_enc in storage must be the base64-encoded ciphertext. + expect(store.device_key_enc).toBe(uint8ArrayToBase64(CIPHER)); + }); +}); + +// ======================================================================== +// Test 3: Unlock with NO device_key_enc → migration path +// ======================================================================== + +describe('unlock with NO device_key_enc (migration)', () => { + it('calls register_device + persist_device_key; writes device_key_enc; calls addDevice', async () => { + const CIPHER = new Uint8Array([0xCA, 0xFE]); + + mockChromeStorage({ + vaultConfig: VAULT_CONFIG, + imageBase64: IMAGE_B64, + device_name: 'old device', + // device_key_enc intentionally absent — triggers migration + }); + + const wasm = makeWasm({ + persist_device_key: vi.fn(() => CIPHER), + }); + const state = makeState(wasm); + + const resp = await popupHandle( + { type: 'unlock', passphrase: 'hunter2' }, + state, + makePopupSender(), + ); + + expect(resp.ok).toBe(true); + + // Migration must register a new device identity. + expect(wasm.register_device).toHaveBeenCalledTimes(1); + + // Migration must persist the new encrypted key. + expect(wasm.persist_device_key).toHaveBeenCalledTimes(1); + expect(store.device_key_enc).toBe(uint8ArrayToBase64(CIPHER)); + + // Migration must push the new pubkey to the remote (gitHost was set by unlock). + expect(devices.addDevice).toHaveBeenCalledWith( + expect.anything(), // the gitHost (set by createGitHost mock during unlock) + expect.objectContaining({ name: 'old device', public_key: 'pk-new' }), + ); + }); +}); + +// ======================================================================== +// Test 4: restore_device_key throwing must NOT block unlock +// ======================================================================== + +describe('restore_device_key failure does not block unlock', () => { + it('swallows the error and still returns ok:true', async () => { + const B64 = uint8ArrayToBase64(new Uint8Array([0x01])); + + mockChromeStorage({ + vaultConfig: VAULT_CONFIG, + imageBase64: IMAGE_B64, + device_key_enc: B64, + }); + + const wasm = makeWasm({ + restore_device_key: vi.fn(() => { throw new Error('WASM panic in restore'); }), + }); + const state = makeState(wasm); + + const resp = await popupHandle( + { type: 'unlock', passphrase: 'hunter2' }, + state, + makePopupSender(), + ); + + expect(resp.ok).toBe(true); + expect(wasm.restore_device_key).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extension/src/service-worker/__tests__/vault.test.ts b/extension/src/service-worker/__tests__/vault.test.ts index 7de9a8c..f5f7fec 100644 --- a/extension/src/service-worker/__tests__/vault.test.ts +++ b/extension/src/service-worker/__tests__/vault.test.ts @@ -100,6 +100,7 @@ function makeWasm(overrides: Record = {}) { default_vault_settings_json: vi.fn(() => '{}'), settings_encrypt: vi.fn(() => new Uint8Array([8])), register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })), + persist_device_key: vi.fn(() => new Uint8Array([5])), lock: vi.fn(), ...overrides, }; diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 6775a01..3515675 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -437,6 +437,8 @@ describe('register_this_device', () => { signing_public_key: 'aa'.repeat(32), deploy_public_key: 'bb'.repeat(32), }); + // persist_device_key is called after register_device (Task 4.5b); stub it out. + state.wasm.persist_device_key = () => new Uint8Array([1]); vi.mocked(devices.addDevice).mockClear(); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index a18deee..417b450 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -53,6 +53,21 @@ export async function handle( session.setCurrent(handle); (msg as { passphrase: string }).passphrase = ''; + // Restore device key so org_unwrap_key can access DEVICE_STATE this session. + const storedKey = await chrome.storage.local.get('device_key_enc'); + if (storedKey.device_key_enc) { + try { + w.restore_device_key(handle, base64ToUint8Array(storedKey.device_key_enc as string)); + } catch (e) { + // Must NOT block unlock — log and continue; org features will be unavailable. + console.warn('[relicario sw] device key restore failed', e); + } + } else { + // MIGRATION: pre-4.5b install with no persisted device key. Best-effort; + // must NOT block unlock even if migration itself errors. + await migrateDeviceKey(w, handle, state); + } + state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); return { ok: true }; } @@ -383,6 +398,8 @@ export async function handle( public_key: keys.signing_public_key, added_at: Math.floor(Date.now() / 1000), }); + // Persist the encrypted device key so it survives SW restarts. + await vault.persistDeviceKey(state.wasm, session.requireCurrent()); return { ok: true }; } @@ -688,6 +705,37 @@ async function handleFillCredentials( return { ok: true }; } +// --- Device-key migration (pre-4.5b installs) --- +// +// Existing installs have a device registered in devices.json but NO persisted +// device_key_enc — the in-memory private key was irrecoverably lost on a prior +// SW restart. Re-register so the device can function going forward and persist +// the new key blob. +// +// WARNING: this gives the device a NEW identity. The old pubkey entry in +// devices.json no longer matches any private key; any org grants tied to the +// old pubkey are invalid. Org members must be re-granted by an admin to the +// new pubkey. This migration is one-time: once device_key_enc exists, restore +// runs on subsequent unlocks instead. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function migrateDeviceKey(w: any, handle: any, state: PopupState): Promise { + try { + const stored = await chrome.storage.local.get('device_name'); + const name = (stored.device_name as string) ?? 'this device'; + const keys = w.register_device(name) as { signing_public_key: string }; + await vault.persistDeviceKey(w, handle); + if (state.gitHost) { + await devices.addDevice(state.gitHost, { + name, + public_key: keys.signing_public_key, + added_at: Math.floor(Date.now() / 1000), + }); + } + } catch (e) { + console.warn('[relicario sw] device key migration failed', e); + } +} + // --- chrome.storage.local helpers (module-scoped so all handlers share) --- async function loadConfig(): Promise { diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index b5a1a9e..4454207 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -21,11 +21,24 @@ function requireWasm(): any { return wasm; } +/// Encrypt the in-WASM device private key under the current master key and +/// persist the ciphertext as base64 in chrome.storage.local (key: device_key_enc). +/// Call after every register_device while the vault handle is live. The private +/// key stays in WASM; only the opaque ciphertext crosses to JS / storage. +export async function persistDeviceKey( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + w: any, + handle: SessionHandle, +): Promise { + const enc: Uint8Array = w.persist_device_key(handle); // ciphertext — master key stays in WASM + await chrome.storage.local.set({ device_key_enc: uint8ArrayToBase64(enc) }); +} + const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}'; -/// Register this device on the remote (devices.json) and persist the vault -/// config + reference image locally so future unlocks work. Shared by the -/// create and attach flows — both finish with this identical tail. +/// Register this device on the remote (devices.json), persist the encrypted +/// device key to chrome.storage.local, and store the vault config + reference +/// image locally so future unlocks work. Shared by the create and attach flows. async function registerDeviceAndPersistConfig( // eslint-disable-next-line @typescript-eslint/no-explicit-any w: any, @@ -33,8 +46,10 @@ async function registerDeviceAndPersistConfig( config: VaultConfig, referenceImageBytes: Uint8Array, deviceName: string, + handle: SessionHandle, ): Promise { const keys = w.register_device(deviceName) as { signing_public_key: string }; + await persistDeviceKey(w, handle); await devices.addDevice(git, { name: deviceName, public_key: keys.signing_public_key, @@ -86,7 +101,7 @@ export async function handleCreateVault( await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest'); await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings'); - await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); + await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName, h); // SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable). session.setCurrent(h); @@ -129,7 +144,7 @@ export async function handleAttachVault( // manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure. const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest; - await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName); + await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName, h); // SW now owns the unlocked session — transfer ownership to the session. session.setCurrent(h); From 288956c08951be416c547356f79a8e4f62eb96ef Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:19:55 -0400 Subject: [PATCH 12/25] feat(core,wasm): filter_by_collections single-source + org_manifest_decrypt_filtered Add OrgManifest::filter_by_collections(&[String]) as the single source of grant-filter logic. Refactor filter_for_member to delegate to it (no duplicated loop). Add org_manifest_decrypt_filtered WASM binding that decrypts + filters in core so ungranted entries never cross to JS (phase-1 SOFT/UX filter; per-collection crypto isolation is phase-2). Declare the binding in extension/src/wasm.d.ts. Vec compiled cleanly for wasm32-unknown-unknown. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- crates/relicario-core/src/org.rs | 57 +++++++++++++++++++++++++++++--- crates/relicario-wasm/src/lib.rs | 20 +++++++++++ extension/src/wasm.d.ts | 8 +++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/crates/relicario-core/src/org.rs b/crates/relicario-core/src/org.rs index caceaf2..43094ef 100644 --- a/crates/relicario-core/src/org.rs +++ b/crates/relicario-core/src/org.rs @@ -206,18 +206,27 @@ impl OrgManifest { Self { schema_version: 1, entries: Vec::new() } } - /// Return only entries whose collection is in `member.collections`. - pub fn filter_for_member(&self, member: &OrgMember) -> Self { - let granted: std::collections::HashSet<&str> = - member.collections.iter().map(|s| s.as_str()).collect(); + /// Keep only entries whose collection slug is in `granted`. + /// + /// NOTE: phase-1 grant filtering is a SOFT/UX filter — the org member holds + /// the single org key and can decrypt everything; per-collection crypto + /// isolation is phase-2. This hides ungranted entries from the UI; it is not + /// a cryptographic boundary. + pub fn filter_by_collections(&self, granted: &[String]) -> Self { + let set: std::collections::HashSet<&str> = granted.iter().map(|s| s.as_str()).collect(); Self { schema_version: self.schema_version, entries: self.entries.iter() - .filter(|e| granted.contains(e.collection.as_str())) + .filter(|e| set.contains(e.collection.as_str())) .cloned() .collect(), } } + + /// Return only entries whose collection is in `member.collections`. + pub fn filter_for_member(&self, member: &OrgMember) -> Self { + self.filter_by_collections(&member.collections) // delegate — single source + } } impl Default for OrgManifest { @@ -418,6 +427,44 @@ mod tests { assert_eq!(filtered.entries[0].collection, "prod"); } + #[test] + fn filter_by_collections_keeps_only_granted_entries() { + let mut manifest = OrgManifest::new(); + for (title, coll) in [("A", "prod"), ("B", "dev"), ("C", "secret")] { + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: crate::item_types::ItemType::SecureNote, + title: title.into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: coll.into(), + }); + } + + let filtered = manifest.filter_by_collections(&["prod".into(), "dev".into()]); + assert_eq!(filtered.entries.len(), 2); + assert!(filtered.entries.iter().all(|e| e.collection != "secret")); + assert_eq!(filtered.schema_version, manifest.schema_version); + } + + #[test] + fn filter_by_collections_empty_granted_returns_empty() { + let mut manifest = OrgManifest::new(); + manifest.entries.push(OrgManifestEntry { + id: ItemId::new(), + r#type: crate::item_types::ItemType::SecureNote, + title: "X".into(), + tags: vec![], + modified: 0, + trashed_at: None, + collection: "prod".into(), + }); + + let filtered = manifest.filter_by_collections(&[]); + assert_eq!(filtered.entries.len(), 0); + } + #[test] fn generate_org_key_is_32_bytes() { let key = generate_org_key(); diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index ebb7215..1b412c2 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -672,6 +672,26 @@ pub fn org_manifest_decrypt(handle: &SessionHandle, encrypted: &[u8]) -> Result< js_value_for(&out) } +/// Decrypt an org manifest blob AND grant-filter it to `granted` collection slugs, +/// for the READ path. Filtering happens in core (`filter_by_collections`), so +/// ungranted entries never cross to JS. (SOFT phase-1 UX filter — see core +/// doc-comment; the member holds the org key, this is not crypto isolation.) +/// Dev-C writes use the UNFILTERED `org_manifest_decrypt` instead (re-encrypting a +/// filtered subset would wipe ungranted entries). +#[wasm_bindgen] +pub fn org_manifest_decrypt_filtered( + handle: &SessionHandle, + encrypted: &[u8], + granted: Vec, +) -> Result { + need_key(handle)?; + let manifest = session::with(handle.0, |k| relicario_core::decrypt_org_manifest(encrypted, k)) + .unwrap() + .map_err(|e| JsError::new(&e.to_string()))?; + let filtered = manifest.filter_by_collections(&granted); + js_value_for(&filtered) +} + /// Encrypt an OrgManifest (JSON) with an org session handle, for the write track /// (Dev-C never touches WASM — this is exposed here so they don't have to). #[wasm_bindgen] diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index cad0669..9bd58a1 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -91,6 +91,14 @@ declare module 'relicario-wasm' { export function org_unwrap_key(keys_blob: Uint8Array): SessionHandle; export function org_manifest_decrypt(handle: SessionHandle, encrypted: Uint8Array): unknown; export function org_manifest_encrypt(handle: SessionHandle, manifest_json: string): Uint8Array; + /** + * Decrypt an org manifest AND filter it to the granted collection slugs. + * Filtering happens in core (SOFT/UX filter — phase-1 only; the member holds + * the org key and can decrypt everything; per-collection crypto isolation is + * phase-2). Use for READ paths only; writes must use org_manifest_decrypt to + * avoid wiping ungranted collections on re-encrypt. + */ + export function org_manifest_decrypt_filtered(handle: SessionHandle, encrypted: Uint8Array, granted: string[]): unknown; /** * Encrypt the registered device key under the vault master key. * Returns CIPHERTEXT — the device private key never crosses to JS. From 2eb49ac21743953d358c14a95f881befff138a45 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:30:39 -0400 Subject: [PATCH 13/25] feat(ext/sw): org-vault read core (openOrg, grant-filtered list, collection-scoped get, offline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add extension/src/service-worker/org-vault.ts: openOrg (members.json fingerprint match → org_unwrap_key → org_manifest_decrypt_filtered), listOrgItems, getOrgItem (collection-scoped path: items//.enc), listOrgCollections (grants filter). - Add OrgMember, OrgMembers, CollectionDef, OrgCollections, Collection types to shared/types.ts (mirrors relicario-core org.rs shapes). - Add 8-test suite covering: fingerprint match, not_an_org_member, listOrgItems passthrough, collection-scoped item path, listOrgCollections grant filter, storage.set never called, offline fallback (TypeError → cached manifest), offline with no cache → rethrow. - Fix plan doc line ~326: wasm.manifest_decrypt → wasm.org_manifest_decrypt_filtered. Gate results: 158/158 SW tests pass; npm run build:all clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../2026-06-20-v0.9.0-org-a-foundation.md | 2 +- .../__tests__/org-vault.test.ts | 373 ++++++++++++++++++ extension/src/service-worker/org-vault.ts | 147 +++++++ extension/src/shared/types.ts | 39 ++ 4 files changed, 560 insertions(+), 1 deletion(-) create mode 100644 extension/src/service-worker/__tests__/org-vault.test.ts create mode 100644 extension/src/service-worker/org-vault.ts diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md index ae0528d..0b47a60 100644 --- a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md @@ -323,7 +323,7 @@ git commit -m "feat(ext/sw): org config storage + org_list_configs message" - Test: `extension/src/service-worker/__tests__/org-vault.test.ts` **Interfaces:** -- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `chrome.storage.local.device_private_key`; `wasm.manifest_decrypt` (existing). +- Consumes: `createGitHost` (`service-worker/git-host.ts`); `org_unwrap_key` (Task 1); device key from `DEVICE_STATE` via `w.get_device_info()`; `wasm.org_manifest_decrypt_filtered` (Task 5a). - Produces: `openOrg(cfg: OrgConfig): Promise` where `OrgHandleState = { handle: SessionHandle; grants: string[]; offline: boolean }`; `listOrgItems(state): ManifestEntry[]` (filtered to `grants`); `getOrgItem(state, id): Promise`; `listOrgCollections(state): Collection[]`. - [ ] **Step 1: Write the failing test** (mock the GitHost + wasm boundary as `router.test.ts` does) diff --git a/extension/src/service-worker/__tests__/org-vault.test.ts b/extension/src/service-worker/__tests__/org-vault.test.ts new file mode 100644 index 0000000..5beff08 --- /dev/null +++ b/extension/src/service-worker/__tests__/org-vault.test.ts @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GitHost } from '../git-host'; +import * as gitHostMod from '../git-host'; +import * as sshFingerprintMod from '../../shared/ssh-fingerprint'; + +// --- Mocks --- + +// Mock createGitHost so we can inject a fake GitHost per test. +vi.mock('../git-host', async () => { + const actual = await vi.importActual('../git-host'); + return { + ...actual, + createGitHost: vi.fn(), + }; +}); + +// Mock sshFingerprint so fingerprint matching is fully controllable. +vi.mock('../../shared/ssh-fingerprint', () => ({ + sshFingerprint: vi.fn(), +})); + +// --- Test data --- + +const MY_PK = 'ssh-ed25519 AAAAB3NzaC1yZDI1NTE5AAAAIdevice testdevice'; +const OTHER_PK = 'ssh-ed25519 AAAAB3NzaC1yZDI1NTE5AAAAIother otherdevice'; +const MY_FP = 'SHA256:myDeviceFingerprint'; +const OTHER_FP = 'SHA256:otherDeviceFingerprint'; + +const MEMBER_ID = 'aabbccdd11223344'; +const OTHER_MEMBER_ID = 'deadbeefcafe0001'; + +const MEMBERS_JSON = { + schema_version: 1, + members: [ + { + member_id: OTHER_MEMBER_ID, + display_name: 'Other User', + role: 'member', + ed25519_pubkey: OTHER_PK, + collections: ['other-col'], + added_at: 1000, + added_by: OTHER_MEMBER_ID, + }, + { + member_id: MEMBER_ID, + display_name: 'Test User', + role: 'member', + ed25519_pubkey: MY_PK, + collections: ['prod-infra', 'staging'], + added_at: 1001, + added_by: MEMBER_ID, + }, + ], +}; + +const FILTERED_MANIFEST = { + schema_version: 1, + entries: [ + { id: 'item001', type: 'login', title: 'My Login', tags: [], modified: 1000, collection: 'prod-infra' }, + { id: 'item002', type: 'secure_note', title: 'A Note', tags: [], modified: 1001, collection: 'staging' }, + ], +}; + +const ORG_CFG = { + orgId: 'org-test', + displayName: 'Test Org', + hostType: 'gitea' as const, + hostUrl: 'https://git.example', + repoPath: 'org/vault', + apiToken: 'tok-abc', + memberId: MEMBER_ID, +}; + +// --- Helpers --- + +function enc(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +/** Build a fake GitHost whose readFile returns different payloads per path. */ +function makeOrgHost( + files: Record Promise)>, + extraMethods: Partial = {}, +): GitHost { + return { + readFile: vi.fn().mockImplementation(async (path: string) => { + const v = files[path]; + if (v === undefined) throw new Error(`404: ${path}`); + if (typeof v === 'function') return v(); + return v; + }), + writeFile: vi.fn().mockResolvedValue(undefined), + writeFileCreateOnly: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + listDir: vi.fn().mockResolvedValue([]), + lastCommit: vi.fn().mockResolvedValue(null), + putBlob: vi.fn().mockResolvedValue(''), + getBlob: vi.fn().mockResolvedValue(new Uint8Array()), + deleteBlob: vi.fn().mockResolvedValue(undefined), + lastSyncAt: null, + ahead: 0, + behind: 0, + ...extraMethods, + }; +} + +const WRAPPED_KEY_BYTES = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); +const MANIFEST_ENC_BYTES = new Uint8Array([0x01, 0x02, 0x03]); + +function makeOrgHostDefault(): GitHost { + return makeOrgHost({ + 'members.json': enc(JSON.stringify(MEMBERS_JSON)), + [`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES, + 'manifest.enc': MANIFEST_ENC_BYTES, + 'collections.json': enc(JSON.stringify({ + schema_version: 1, + collections: [ + { slug: 'prod-infra', display_name: 'Production Infrastructure', created_by: MEMBER_ID, created_at: 100 }, + { slug: 'staging', display_name: 'Staging', created_by: MEMBER_ID, created_at: 101 }, + { slug: 'secret-ops', display_name: 'Secret Ops', created_by: OTHER_MEMBER_ID, created_at: 102 }, + ], + })), + }); +} + +function makeFakeHandle() { + return { value: 42, free: vi.fn() }; +} + +/** Build a minimal wasm mock for org tests. */ +function makeOrgWasm(overrides: Record = {}) { + const fakeHandle = makeFakeHandle(); + return { + _handle: fakeHandle, + get_device_info: vi.fn(() => ({ + name: 'Test Device', + signing_public_key: MY_PK, + deploy_public_key: 'ssh-ed25519 AAAAB3Nzdeploy', + })), + org_unwrap_key: vi.fn(() => fakeHandle), + org_manifest_decrypt_filtered: vi.fn(() => FILTERED_MANIFEST), + item_decrypt: vi.fn(() => ({ + id: 'item001', + title: 'My Login', + type: 'login', + tags: [], + favorite: false, + created: 100, + modified: 1000, + core: { type: 'login', username: 'user', password: 'pass' }, + sections: [], + attachments: [], + field_history: {}, + })), + ...overrides, + }; +} + +/** Setup the sshFingerprint mock to return fingerprints by pubkey. */ +function mockFingerprints(map: Record) { + vi.mocked(sshFingerprintMod.sshFingerprint).mockImplementation(async (key: string) => { + return key in map ? map[key] : null; + }); +} + +// Import under test (after mocks are declared). +import * as orgVault from '../org-vault'; + +// --- Chrome storage mock --- +function mockChromeStorage() { + const store: Record = {}; + const setSpy = vi.fn().mockImplementation((kv: Record) => { + Object.assign(store, kv); + return Promise.resolve(); + }); + const getSpy = vi.fn().mockResolvedValue({}); + (global as { chrome: unknown }).chrome = { + storage: { local: { get: getSpy, set: setSpy } }, + } as never; + return { setSpy, getSpy, store }; +} + +// --- Tests --- + +describe('org-vault', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default fingerprint mapping: MY_PK → MY_FP, OTHER_PK → OTHER_FP + mockFingerprints({ [MY_PK]: MY_FP, [OTHER_PK]: OTHER_FP }); + }); + + // Test 1: openOrg — happy path: fingerprint match → grants, unwrap, filtered manifest + it('openOrg: matches device by ed25519 fingerprint → grants; calls org_unwrap_key + org_manifest_decrypt_filtered', async () => { + const host = makeOrgHostDefault(); + vi.mocked(gitHostMod.createGitHost).mockReturnValue(host); + const w = makeOrgWasm(); + + const state = await orgVault.openOrg(ORG_CFG, w); + + // Fingerprint called for both: device key + each member pubkey + expect(sshFingerprintMod.sshFingerprint).toHaveBeenCalledWith(MY_PK); + + // Correct member matched: MEMBER_ID (has MY_PK) + expect(state.grants).toEqual(['prod-infra', 'staging']); + + // org_unwrap_key called with the wrapped key bytes + expect(w.org_unwrap_key).toHaveBeenCalledWith(WRAPPED_KEY_BYTES); + + // org_manifest_decrypt_filtered called with handle, enc, grants + expect(w.org_manifest_decrypt_filtered).toHaveBeenCalledWith( + w._handle, + MANIFEST_ENC_BYTES, + ['prod-infra', 'staging'], + ); + + // Returned state + expect(state.orgId).toBe('org-test'); + expect(state.offline).toBe(false); + expect(state.manifest).toBe(FILTERED_MANIFEST); + expect(state.handle).toBe(w._handle); + }); + + // Test 2: openOrg — device fingerprint absent → throws not_an_org_member + it('openOrg: device not in members.json → throws not_an_org_member', async () => { + // No member matches MY_PK + const membersNoMatch = { + schema_version: 1, + members: [ + { + member_id: OTHER_MEMBER_ID, + display_name: 'Other', + role: 'member', + ed25519_pubkey: OTHER_PK, + collections: ['other-col'], + added_at: 1000, + added_by: OTHER_MEMBER_ID, + }, + ], + }; + const host = makeOrgHost({ + 'members.json': enc(JSON.stringify(membersNoMatch)), + [`keys/${OTHER_MEMBER_ID}.enc`]: new Uint8Array([0x00]), + 'manifest.enc': MANIFEST_ENC_BYTES, + }); + vi.mocked(gitHostMod.createGitHost).mockReturnValue(host); + const w = makeOrgWasm(); + + await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('not_an_org_member'); + // Should never reach org_unwrap_key + expect(w.org_unwrap_key).not.toHaveBeenCalled(); + }); + + // Test 3: listOrgItems — returns cached filtered entries directly (no re-filter) + it('listOrgItems: returns state.manifest.entries unchanged', () => { + const state: orgVault.OrgHandleState = { + orgId: 'org-test', + host: makeOrgHostDefault(), + handle: makeFakeHandle() as never, + grants: ['prod-infra'], + manifest: FILTERED_MANIFEST as never, + offline: false, + }; + + const items = orgVault.listOrgItems(state); + + // Returns ALL entries from the manifest — filtering was done in core + expect(items).toBe(FILTERED_MANIFEST.entries); + expect(items).toHaveLength(2); + }); + + // Test 4: getOrgItem — reads items//.enc (collection-scoped path) + it('getOrgItem: reads items//.enc and returns decrypted item', async () => { + const ITEM_ENC = new Uint8Array([0xaa, 0xbb]); + const host = makeOrgHost({ + 'items/prod-infra/item001.enc': ITEM_ENC, + }); + const w = makeOrgWasm(); + const state: orgVault.OrgHandleState = { + orgId: 'org-test', + host, + handle: w._handle as never, + grants: ['prod-infra'], + manifest: FILTERED_MANIFEST as never, + offline: false, + }; + + const item = await orgVault.getOrgItem(state, 'item001', w); + + // Path must be collection-scoped + expect(host.readFile).toHaveBeenCalledWith('items/prod-infra/item001.enc'); + + // item_decrypt called with the correct handle and ciphertext + expect(w.item_decrypt).toHaveBeenCalledWith(w._handle, ITEM_ENC); + + // Returns the decrypted item + expect(item.id).toBe('item001'); + }); + + // Test 5: listOrgCollections — returns ONLY granted collections (ungranted excluded) + it('listOrgCollections: filters to grants, maps to { slug, display_name }', async () => { + const host = makeOrgHostDefault(); + const state: orgVault.OrgHandleState = { + orgId: 'org-test', + host, + handle: makeFakeHandle() as never, + grants: ['prod-infra', 'staging'], + manifest: FILTERED_MANIFEST as never, + offline: false, + }; + + const collections = await orgVault.listOrgCollections(state); + + // secret-ops must be excluded (not in grants) + expect(collections).toHaveLength(2); + expect(collections.map((c) => c.slug)).toContain('prod-infra'); + expect(collections.map((c) => c.slug)).toContain('staging'); + expect(collections.map((c) => c.slug)).not.toContain('secret-ops'); + + // Shape: { slug, display_name } only + expect(collections[0]).toHaveProperty('slug'); + expect(collections[0]).toHaveProperty('display_name'); + expect(Object.keys(collections[0])).toEqual(expect.arrayContaining(['slug', 'display_name'])); + }); + + // Test 6: openOrg NEVER writes the org key to storage + it('openOrg: never writes org key or handle to chrome.storage.local', async () => { + const { setSpy } = mockChromeStorage(); + const host = makeOrgHostDefault(); + vi.mocked(gitHostMod.createGitHost).mockReturnValue(host); + const w = makeOrgWasm(); + + await orgVault.openOrg(ORG_CFG, w); + + // chrome.storage.local.set must never be called at all by openOrg + expect(setSpy).not.toHaveBeenCalled(); + }); + + // Test 7a: offline fallback — network error + cachedManifest → offline:true, no throw + it('openOrg: manifest.enc network error + cachedManifest → offline:true, returns cached manifest', async () => { + const networkError = new TypeError('Failed to fetch'); + const host = makeOrgHost({ + 'members.json': enc(JSON.stringify(MEMBERS_JSON)), + [`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES, + // manifest.enc throws network error + 'manifest.enc': () => Promise.reject(networkError), + }); + vi.mocked(gitHostMod.createGitHost).mockReturnValue(host); + const w = makeOrgWasm(); + const cached = { schema_version: 1, entries: [{ id: 'cached-item', type: 'login' as const, title: 'Cached', tags: [], modified: 999, collection: 'prod-infra' }] }; + + const state = await orgVault.openOrg(ORG_CFG, w, cached); + + expect(state.offline).toBe(true); + expect(state.manifest).toBe(cached); + // org_manifest_decrypt_filtered must not be called (we used the cache) + expect(w.org_manifest_decrypt_filtered).not.toHaveBeenCalled(); + }); + + // Test 7b: offline fallback — network error WITHOUT cachedManifest → rethrows + it('openOrg: manifest.enc network error without cachedManifest → rethrows', async () => { + const networkError = new TypeError('Failed to fetch'); + const host = makeOrgHost({ + 'members.json': enc(JSON.stringify(MEMBERS_JSON)), + [`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES, + 'manifest.enc': () => Promise.reject(networkError), + }); + vi.mocked(gitHostMod.createGitHost).mockReturnValue(host); + const w = makeOrgWasm(); + + // No cachedManifest → must rethrow + await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('Failed to fetch'); + }); +}); diff --git a/extension/src/service-worker/org-vault.ts b/extension/src/service-worker/org-vault.ts new file mode 100644 index 0000000..c080c5b --- /dev/null +++ b/extension/src/service-worker/org-vault.ts @@ -0,0 +1,147 @@ +/// Org vault read core — mirrors vault.ts but for an org vault. +/// Pure functions over a GitHost + the WASM org bridge. +/// NO SW message handlers (Task 6 wires those), NO UI. +/// +/// Security invariants: +/// - The org master key lives ONLY inside the WASM session (org_unwrap_key). +/// This module NEVER writes the key or any SessionHandle to chrome.storage.local, +/// IndexedDB, or any other persistent store. +/// - Grant filtering is done in core (org_manifest_decrypt_filtered); listOrgItems +/// returns the already-filtered manifest.entries without any TS-side re-filter. + +import type { SessionHandle } from '../../wasm/relicario_wasm'; +import type { GitHost } from './git-host'; +import { createGitHost } from './git-host'; +import { sshFingerprint } from '../shared/ssh-fingerprint'; +import type { + OrgConfig, + OrgMembers, + OrgMember, + OrgManifest, + OrgManifestEntry, + OrgCollections, + Collection, + Item, +} from '../shared/types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type WasmModule = any; + +export interface OrgHandleState { + orgId: string; + host: GitHost; + handle: SessionHandle; // from org_unwrap_key; NOT persisted; Task 6 calls session.setOrg + grants: string[]; // member.collections + manifest: OrgManifest; // cached, grant-filtered + offline: boolean; +} + +/// 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 { + // 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; + // Some environments produce an Error with a recognisable message instead. + if (e instanceof Error) { + const msg = e.message.toLowerCase(); + return ( + msg.includes('failed to fetch') || + msg.includes('networkerror') || + msg.includes('network error') + ); + } + return false; +} + +/// Open an org vault: +/// 1. Read public members.json +/// 2. Match THIS device by ed25519 fingerprint +/// 3. Derive grants from the member's collections +/// 4. Read + unwrap the member's encrypted org key (keys/.enc) +/// 5. Fetch + grant-filter the manifest (offline fallback if cachedManifest supplied) +/// +/// The returned handle is NOT persisted here. Task 6 hands it to session.setOrg. +export async function openOrg( + cfg: OrgConfig, + w: WasmModule, + cachedManifest?: OrgManifest, +): Promise { + // 1. Build the GitHost for the org repo. + const host = createGitHost(cfg.hostType, cfg.hostUrl, cfg.repoPath, cfg.apiToken); + + // 2. Read public members.json. + const membersRaw = await host.readFile('members.json'); + const members = JSON.parse(new TextDecoder().decode(membersRaw)) as OrgMembers; + + // 3. Get this device's ed25519 signing key and compute its fingerprint. + const info = w.get_device_info() as { name: string; signing_public_key: string; deploy_public_key: string } | null; + if (!info) throw new Error('device_key_unavailable'); + const myFp = await sshFingerprint(info.signing_public_key); + + // 4. Find this device's member entry — await in a for-loop (not .find, which can't await). + let me: OrgMember | undefined; + for (const m of members.members) { + if (await sshFingerprint(m.ed25519_pubkey) === myFp) { + me = m; + break; + } + } + if (!me) throw new Error('not_an_org_member'); + + // 5. Member's grants. + const grants = me.collections; + + // 6. Read this member's wrapped org key. + const wrapped = await host.readFile(`keys/${me.member_id}.enc`); + + // 7. Unwrap the org key inside WASM (reads DEVICE_STATE internally — no key param crosses JS). + const handle = w.org_unwrap_key(wrapped) as SessionHandle; + + // 8. Fetch + grant-filter the manifest; fall back to the cached copy on network failure. + let manifest: OrgManifest; + let offline = false; + try { + const enc = await host.readFile('manifest.enc'); + manifest = w.org_manifest_decrypt_filtered(handle, enc, grants) as OrgManifest; + } catch (e) { + if (cachedManifest && isNetworkError(e)) { + manifest = cachedManifest; + offline = true; + } else { + throw e; + } + } + + return { orgId: cfg.orgId, host, handle, grants, manifest, offline }; +} + +/// Return the cached, already-grant-filtered manifest entries. +/// Grant filtering happened in core (org_manifest_decrypt_filtered) — no TS-side filter. +export function listOrgItems(state: OrgHandleState): OrgManifestEntry[] { + return state.manifest.entries; +} + +/// Fetch and decrypt a single org item. +/// Org items are collection-scoped: path = items//.enc +/// (differs from personal flat items/.enc). +export async function getOrgItem( + state: OrgHandleState, + id: string, + w: WasmModule, +): Promise { + const entry = state.manifest.entries.find((e) => e.id === id); + if (!entry) throw new Error('item_not_found'); + const ct = await state.host.readFile(`items/${entry.collection}/${id}.enc`); + return w.item_decrypt(state.handle, ct) as Item; +} + +/// Return the list of collections granted to this member. +/// Reads public collections.json, filters to state.grants, maps to { slug, display_name }. +export async function listOrgCollections(state: OrgHandleState): Promise { + const raw = await state.host.readFile('collections.json'); + const orgCollections = JSON.parse(new TextDecoder().decode(raw)) as OrgCollections; + return orgCollections.collections + .filter((c) => state.grants.includes(c.slug)) + .map((c) => ({ slug: c.slug, display_name: c.display_name })); +} diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 84fe3b6..60ab81e 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -225,6 +225,45 @@ export interface OrgConfigSummary { displayName: string; } +// --- Org members + collections (mirror Rust org.rs shapes) --- + +export type OrgRole = 'owner' | 'admin' | 'member'; + +export interface OrgMember { + member_id: string; + display_name: string; + role: OrgRole; + /** SSH public key string (openssh format: "ssh-ed25519 AAAA...") */ + ed25519_pubkey: string; + /** Collection slugs this member can access. */ + collections: string[]; + added_at: number; + added_by: string; +} + +export interface OrgMembers { + schema_version: number; + members: OrgMember[]; +} + +export interface CollectionDef { + slug: string; + display_name: string; + created_by: string; + created_at: number; +} + +export interface OrgCollections { + schema_version: number; + collections: CollectionDef[]; +} + +/** Public shape returned by listOrgCollections — slug + display_name only. */ +export interface Collection { + slug: string; + display_name: string; +} + // --- Org manifest --- // Org manifest — a dedicated, leaner shape than the personal ManifestEntry. // Faithful to relicario-core OrgManifestEntry; collection is REQUIRED. From f9b8f3821d927e5b4c3998d6d440addf9ec1093f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 22:45:09 -0400 Subject: [PATCH 14/25] feat(ext/sw): org_switch + org read messages (grant-filtered, offline-aware, cache cleared on lock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../2026-06-20-v0.9.0-org-a-foundation.md | 4 +- .../__tests__/org-handlers.test.ts | 225 ++++++++++++++++++ extension/src/service-worker/index.ts | 2 + extension/src/service-worker/org-vault.ts | 2 +- .../src/service-worker/router/org-handlers.ts | 78 +++++- .../src/service-worker/router/popup-only.ts | 22 +- extension/src/shared/messages.ts | 25 +- 7 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-handlers.test.ts diff --git a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md index 0b47a60..94c273e 100644 --- a/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md +++ b/docs/superpowers/plans/2026-06-20-v0.9.0-org-a-foundation.md @@ -44,7 +44,7 @@ **Interfaces:** - Consumes: `relicario_core::org::unwrap_org_key(wrapped: &[u8], ed25519_seed: &Zeroizing<[u8;32]>) -> Result>` (`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`. 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` (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** diff --git a/extension/src/service-worker/__tests__/org-handlers.test.ts b/extension/src/service-worker/__tests__/org-handlers.test.ts new file mode 100644 index 0000000..b9cbd6a --- /dev/null +++ b/extension/src/service-worker/__tests__/org-handlers.test.ts @@ -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 { + 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'); + }); +}); diff --git a/extension/src/service-worker/index.ts b/extension/src/service-worker/index.ts index 295b920..4d8de08 100644 --- a/extension/src/service-worker/index.ts +++ b/extension/src/service-worker/index.ts @@ -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. diff --git a/extension/src/service-worker/org-vault.ts b/extension/src/service-worker/org-vault.ts index c080c5b..4cba003 100644 --- a/extension/src/service-worker/org-vault.ts +++ b/extension/src/service-worker/org-vault.ts @@ -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; diff --git a/extension/src/service-worker/router/org-handlers.ts b/extension/src/service-worker/router/org-handlers.ts index 42d0e66..2a0f5e4 100644 --- a/extension/src/service-worker/router/org-handlers.ts +++ b/extension/src/service-worker/router/org-handlers.ts @@ -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(); + +// 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(); diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 417b450..c7e3917 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -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}` }; } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 21d492a..23a51ed 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -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 = 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 { @@ -227,6 +232,22 @@ export interface OrgListConfigsResponse extends Extract data: OrgConfigSummary[]; } +export interface OrgSwitchResponse extends Extract { + data: { context: string; offline: boolean }; +} + +export interface OrgListItemsResponse extends Extract { + data: OrgManifestEntry[]; +} + +export interface OrgGetItemResponse extends Extract { + data: Item; +} + +export interface OrgListCollectionsResponse extends Extract { + data: Collection[]; +} + export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', 'capture_save_login', From 7913e3ad84d983ec396639f7b888ae2fbef3f84c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 11:05:58 -0400 Subject: [PATCH 15/25] fix(ext/sw): free org handle on openOrg error path (no org-key leak past lock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review I1: openOrg's org_unwrap_key inserts the org master key into the WASM session at step 7, but on the non-cached manifest-error path the handle was never returned / registered via session.setOrg — so session.clearAll() (lock + inactivity timeout) could not see it, and the org key would outlive a failed open (GC-backstopped, non-deterministic). Triggers on a corrupt manifest.enc or a first-switch network error with no cache. Free the handle before rethrowing. Adds a test asserting the error path frees the handle. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g --- .../__tests__/org-vault.test.ts | 24 +++++++++++++++++++ extension/src/service-worker/org-vault.ts | 6 +++++ 2 files changed, 30 insertions(+) diff --git a/extension/src/service-worker/__tests__/org-vault.test.ts b/extension/src/service-worker/__tests__/org-vault.test.ts index 5beff08..2da0133 100644 --- a/extension/src/service-worker/__tests__/org-vault.test.ts +++ b/extension/src/service-worker/__tests__/org-vault.test.ts @@ -370,4 +370,28 @@ describe('org-vault', () => { // No cachedManifest → must rethrow await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('Failed to fetch'); }); + + // Test 8 (I1): a non-cached error path must FREE the unwrapped handle before + // rethrowing — otherwise the org master key (inserted into the WASM session by + // org_unwrap_key) is never registered via session.setOrg, so clearAll() can't + // see it and the key would outlive a subsequent lock/timeout. + it('openOrg: a non-network manifest error frees the unwrapped handle before rethrowing', async () => { + const host = makeOrgHost({ + 'members.json': enc(JSON.stringify(MEMBERS_JSON)), + [`keys/${MEMBER_ID}.enc`]: WRAPPED_KEY_BYTES, + 'manifest.enc': MANIFEST_ENC_BYTES, + }); + vi.mocked(gitHostMod.createGitHost).mockReturnValue(host); + const w = makeOrgWasm({ + // Decrypt throws a non-network error (e.g. corrupt manifest.enc) → else branch. + org_manifest_decrypt_filtered: vi.fn(() => { throw new Error('corrupt manifest'); }), + }); + + await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('corrupt manifest'); + + // The handle was created (org key live in WASM) ... + expect(w.org_unwrap_key).toHaveBeenCalledWith(WRAPPED_KEY_BYTES); + // ... and must be freed on the error path so the org key does not persist. + expect(w._handle.free).toHaveBeenCalledTimes(1); + }); }); diff --git a/extension/src/service-worker/org-vault.ts b/extension/src/service-worker/org-vault.ts index 4cba003..63d39c7 100644 --- a/extension/src/service-worker/org-vault.ts +++ b/extension/src/service-worker/org-vault.ts @@ -109,6 +109,12 @@ export async function openOrg( manifest = cachedManifest; offline = true; } else { + // The org master key is live in the WASM session (org_unwrap_key inserted + // it at step 7), but this handle was never returned / registered via + // session.setOrg — so session.clearAll() (lock + inactivity timeout) cannot + // see it, and the key would outlive a failed open. Free it before + // propagating so the org key never persists past this error path. + try { handle.free(); } catch { /* best-effort: still propagate the original error */ } throw e; } } From aceeb30af95b1f7631c58e62b5f44dc33c9ca55a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 11:21:31 -0400 Subject: [PATCH 16/25] feat(ext): PopupState org fields + context-aware message helper org branches --- extension/src/shared/org-context.ts | 11 ++--------- extension/src/shared/popup-state.ts | 7 +++++-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/extension/src/shared/org-context.ts b/extension/src/shared/org-context.ts index 388745c..c30daba 100644 --- a/extension/src/shared/org-context.ts +++ b/extension/src/shared/org-context.ts @@ -4,11 +4,6 @@ // the personal vault sends `list_items`/`get_item`; an org context sends the // org-scoped equivalents. The list + detail views consume these so neither has // to branch on context itself. -// -// SCAFFOLD STATE (HOLD until Dev-A merges): `currentContext()` is complete. -// `messageForList`/`messageForGet` return the PERSONAL message only; the org -// branch is held until Dev-A lands `org_list_items` / `org_get_item` in the -// `shared/messages.ts` Request union. Flip the two HOLD lines at integration. import { getState } from './state'; import type { Request } from './messages'; @@ -18,11 +13,9 @@ export function currentContext(): 'personal' | string { } export function messageForList(): Request { - // HOLD(dev-a): in org context return { type: 'org_list_items' }. - return { type: 'list_items' }; + return currentContext() === 'personal' ? { type: 'list_items' } : { type: 'org_list_items' }; } export function messageForGet(id: string): Request { - // HOLD(dev-a): in org context return { type: 'org_get_item', id }. - return { type: 'get_item', id }; + return currentContext() === 'personal' ? { type: 'get_item', id } : { type: 'org_get_item', id }; } diff --git a/extension/src/shared/popup-state.ts b/extension/src/shared/popup-state.ts index dfee3e6..777d2bc 100644 --- a/extension/src/shared/popup-state.ts +++ b/extension/src/shared/popup-state.ts @@ -2,11 +2,13 @@ // shared/state.ts can import without creating a popup→shared circular dep. import type { + Collection, Item, ItemId, ItemType, ManifestEntry, GeneratorRequest, + OrgConfigSummary, VaultSettings, } from './types'; @@ -50,10 +52,11 @@ export interface PopupState { // Org (enterprise) context — Plan B (v0.9.0). `orgContext` is 'personal' or an // orgId; `orgOffline` flags a degraded read-only org (set by org_switch). Optional // so existing personal-only state constructions need no change; currentContext() - // (shared/org-context.ts) falls back to 'personal'. orgConfigs/orgCollections - // (Dev-A's OrgConfigSummary[]/Collection[]) join at org-track integration. + // (shared/org-context.ts) falls back to 'personal'. orgContext?: 'personal' | string; orgOffline?: boolean; + orgConfigs?: OrgConfigSummary[]; + orgCollections?: Collection[]; // Vault-tab-only fields. The popup surface leaves these at their defaults // (unlocked=false implicit via separate lock-screen view, drawer/panel false). // Kept on the shared shape so VaultState satisfies StateHost.getState() From 37fb76ab97f7cbcb5455d076fa6f805eb1e68926 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 11:31:20 -0400 Subject: [PATCH 17/25] feat(ext): org context switcher (popup header + vault sidebar) + offline read-only banner --- .../src/popup/components/org-switcher.ts | 69 ++++++++++++++++--- extension/src/popup/index.html | 1 + extension/src/popup/popup.ts | 5 ++ extension/src/vault/vault-shell.ts | 2 + extension/src/vault/vault-sidebar.ts | 1 + 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts index 2aca8db..443059c 100644 --- a/extension/src/popup/components/org-switcher.ts +++ b/extension/src/popup/components/org-switcher.ts @@ -5,21 +5,68 @@ // (and its offline flag) into shared state, and reloads the list. An offline // org renders a read-only banner; Dev-C's write UI reads `orgOffline` to // disable writes. -// -// SCAFFOLD STATE (HOLD until Dev-A merges): renders the select shell only. The -// `org_list_configs` fetch, the `org_switch` dispatch + list reload, and the -// offline banner are held until Dev-A's org messages land in the Request union. + +import type { OrgConfigSummary } from '../../shared/types'; +import { sendMessage, setState, navigate, getState } from '../../shared/state'; + +// Module-level teardown (one instance per browser context; popup and vault +// each have their own module scope). +let _cleanupListener: (() => void) | null = null; export async function renderOrgSwitcher(host: HTMLElement): Promise { - // HOLD(dev-a): fetch org_list_configs -> append an ' + - ''; + ``; + + // 4. Wire the change event. + const select = host.querySelector('select') as HTMLSelectElement; + + const handler = async (_e: Event) => { + const context = select.value; + const switchResp = await sendMessage({ type: 'org_switch', context }); + if (!switchResp.ok) return; + const data = switchResp.data as { context: string; offline: boolean }; + setState({ orgContext: data.context, orgOffline: data.offline }); + + // Offline banner — toggle inside host alongside the select. + const existing = host.querySelector('.org-offline-banner'); + if (data.offline) { + if (!existing) { + const banner = document.createElement('div'); + banner.className = 'org-offline-banner'; + banner.textContent = 'org offline — writes disabled'; + host.appendChild(banner); + } + } else { + existing?.remove(); + } + + navigate('list', {}); + }; + + select.addEventListener('change', handler); + _cleanupListener = () => select.removeEventListener('change', handler); } export function teardown(): void { - // HOLD(dev-a): detach the change listener once renderOrgSwitcher wires it. + _cleanupListener?.(); + _cleanupListener = null; } diff --git a/extension/src/popup/index.html b/extension/src/popup/index.html index 0175394..303d275 100644 --- a/extension/src/popup/index.html +++ b/extension/src/popup/index.html @@ -7,6 +7,7 @@ Relicario +
diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index a2ad8a2..93e4f86 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -14,6 +14,7 @@ import { renderItemDetail } from './components/item-detail'; import { renderItemForm } from './components/item-form'; import { renderSettings, teardownSettings } from './components/settings'; import { renderVaultSettings } from './components/settings-vault'; +import { renderOrgSwitcher } from './components/org-switcher'; import { renderTrash } from './components/trash'; import { renderDevices } from './components/devices'; import { renderFieldHistory } from './components/field-history'; @@ -245,6 +246,10 @@ async function init(): Promise { currentState.generatorDefaults = vs.generator_defaults; } + // Mount the org context switcher in the persistent header slot (lives + // outside #app so it survives view swaps). + void renderOrgSwitcher(document.getElementById('popup-header')!); + // Check URL params for deep linking (when opened in tab) const urlParams = parseUrlParams(); if (urlParams) { diff --git a/extension/src/vault/vault-shell.ts b/extension/src/vault/vault-shell.ts index a7e1b7a..706ba74 100644 --- a/extension/src/vault/vault-shell.ts +++ b/extension/src/vault/vault-shell.ts @@ -11,6 +11,7 @@ import { type VaultController, escapeHtml, typeIcon, } from './vault-context'; import { renderSidebarShell } from './vault-sidebar'; +import { renderOrgSwitcher } from '../popup/components/org-switcher'; // --------------------------------------------------------------------------- // Type picker (right side panel) @@ -118,6 +119,7 @@ export function renderShell(ctx: VaultController, app: HTMLElement): void { `; ctx.wireSidebar(); wireTypePanel(ctx); + void renderOrgSwitcher(document.getElementById('org-switcher-slot')!); } applyShellViewClass(ctx); diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts index aebeb48..5158997 100644 --- a/extension/src/vault/vault-sidebar.ts +++ b/extension/src/vault/vault-sidebar.ts @@ -27,6 +27,7 @@ export function renderSidebarShell(): string {
Relicario +
+
@@ -189,6 +190,14 @@ export function renderSidebarCategories(ctx: VaultController): void { ctx.closeDrawer(); }); }); + + // Keep the collection facet in sync — it must (re)render whenever + // orgCollections / orgContext / collectionFilter change, and + // renderSidebarCategories is already the re-render hook that runs after + // each of those state mutations (via the vault host's setState → renderPane + // → render → renderShell → ctx.renderSidebarCategories chain). + const collSlot = document.getElementById('vault-collections'); + if (collSlot) renderCollectionFacet(collSlot, ctx); } // --------------------------------------------------------------------------- @@ -197,18 +206,52 @@ export function renderSidebarCategories(ctx: VaultController): void { /// Render a collection nav (parallel to the type-category nav) into `host`, /// shown only in org context. Selecting a collection filters the org list to -/// that slug. +/// that slug. Hidden (host cleared) in personal context. /// -/// SCAFFOLD STATE (HOLD until Dev-A merges): renders the empty container only. -/// Populating from getState().orgCollections, the click-to-filter wiring, and -/// the org_list_collections fetch on org_switch are held until Dev-A's org -/// messages land. -export function renderCollectionFacet(host: HTMLElement): void { - // Inline shape until orgCollections joins PopupState at integration (it needs - // Dev-A's Collection type). HOLD(dev-a): render each as a vault-category-row - // mirroring renderSidebarCategories, wired to set a collection filter. - const collections = - (getState() as { orgCollections?: Array<{ slug: string; display_name: string }> }).orgCollections ?? []; - host.innerHTML = - ``; +/// `ctx` is provided when called from renderSidebarCategories (production); it +/// is absent in tests that mount the facet in isolation via happy-dom. +export function renderCollectionFacet(host: HTMLElement, ctx?: VaultController): void { + const s = getState(); + const orgContext = s.orgContext; + const orgCollections = s.orgCollections ?? []; + const collectionFilter = s.collectionFilter; + + if (!orgContext || orgContext === 'personal') { + host.innerHTML = ''; + return; + } + + const isAllActive = !collectionFilter; + let html = ` + `; + + for (const col of orgCollections) { + const isActive = collectionFilter === col.slug; + html += ` + `; + } + + host.innerHTML = html; + + host.querySelectorAll('.vault-category-row').forEach((btn) => { + btn.addEventListener('click', () => { + const slug = btn.dataset.slug || undefined; + setState({ collectionFilter: slug }); + if (ctx) { + // Re-render categories (updates type counts for the new filter) which + // in turn re-renders the facet to update the active highlight. + renderSidebarCategories(ctx); + ctx.renderListPane(); + } else { + // Test path (no ctx): just re-render the facet for the highlight update. + renderCollectionFacet(host, ctx); + } + }); + }); } From 11e3aa41d18560cfde8a85a6d33cf1fb299e2991 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 12:23:57 -0400 Subject: [PATCH 22/25] feat(ext/sw): org_add_config message + handler (device-local org config write) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg --- .../__tests__/org-add-config.test.ts | 79 +++++++++++++++++++ .../src/service-worker/router/org-handlers.ts | 16 +++- .../src/service-worker/router/popup-only.ts | 4 + extension/src/shared/error-copy.ts | 4 + extension/src/shared/messages.ts | 5 +- 5 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-add-config.test.ts diff --git a/extension/src/service-worker/__tests__/org-add-config.test.ts b/extension/src/service-worker/__tests__/org-add-config.test.ts new file mode 100644 index 0000000..d4d243e --- /dev/null +++ b/extension/src/service-worker/__tests__/org-add-config.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handleOrgAddConfig } from '../router/org-handlers'; +import type { OrgConfig } from '../../shared/types'; + +const BASE_CONFIG: OrgConfig = { + orgId: 'org-1', + displayName: 'Acme Corp', + hostType: 'gitea', + hostUrl: 'https://git.acme.example', + repoPath: 'acme/vault', + apiToken: 'token-abc', + memberId: 'member-1', +}; + +function mockStorage(initial: OrgConfig[] = []) { + const store: Record = initial.length + ? { orgConfigs: initial } + : {}; + + const setMock = vi.fn().mockResolvedValue(undefined); + const getMock = vi.fn().mockResolvedValue({ ...store }); + + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: getMock, + set: setMock, + }, + }, + } as never; + + return { getMock, setMock }; +} + +describe('handleOrgAddConfig', () => { + beforeEach(() => { + mockStorage([]); + }); + + it('appends the new config and returns { ok: true }', async () => { + const { setMock } = mockStorage([]); + const resp = await handleOrgAddConfig({ config: BASE_CONFIG }); + expect(resp).toEqual({ ok: true }); + expect(setMock).toHaveBeenCalledOnce(); + const saved = (setMock.mock.calls[0][0] as { orgConfigs: OrgConfig[] }).orgConfigs; + expect(saved).toHaveLength(1); + expect(saved[0].orgId).toBe('org-1'); + }); + + it('preserves existing configs when appending', async () => { + const existing: OrgConfig = { ...BASE_CONFIG, orgId: 'org-0', displayName: 'Pre-existing' }; + const newCfg: OrgConfig = { ...BASE_CONFIG, orgId: 'org-2', displayName: 'New Org' }; + const { setMock } = mockStorage([existing]); + const resp = await handleOrgAddConfig({ config: newCfg }); + expect(resp).toEqual({ ok: true }); + const saved = (setMock.mock.calls[0][0] as { orgConfigs: OrgConfig[] }).orgConfigs; + expect(saved).toHaveLength(2); + expect(saved.map((c: OrgConfig) => c.orgId)).toEqual(['org-0', 'org-2']); + }); + + it('returns { ok: false, error: "org_already_configured" } for duplicate orgId', async () => { + mockStorage([BASE_CONFIG]); + const resp = await handleOrgAddConfig({ config: BASE_CONFIG }); + expect(resp).toEqual({ ok: false, error: 'org_already_configured' }); + }); + + it('does NOT call saveOrgConfigs when rejecting a duplicate', async () => { + const { setMock } = mockStorage([BASE_CONFIG]); + await handleOrgAddConfig({ config: BASE_CONFIG }); + expect(setMock).not.toHaveBeenCalled(); + }); + + it('rejects duplicate even when display name differs', async () => { + mockStorage([BASE_CONFIG]); + const renamed = { ...BASE_CONFIG, displayName: 'Acme Corp Renamed' }; + const resp = await handleOrgAddConfig({ config: renamed }); + expect(resp).toEqual({ ok: false, error: 'org_already_configured' }); + }); +}); diff --git a/extension/src/service-worker/router/org-handlers.ts b/extension/src/service-worker/router/org-handlers.ts index 2a0f5e4..fa14f83 100644 --- a/extension/src/service-worker/router/org-handlers.ts +++ b/extension/src/service-worker/router/org-handlers.ts @@ -11,9 +11,9 @@ import { openOrg, listOrgItems, getOrgItem, listOrgCollections, isNetworkError } from '../org-vault'; import type { OrgHandleState } from '../org-vault'; -import { loadOrgConfigs } from '../org-config'; +import { loadOrgConfigs, saveOrgConfigs } from '../org-config'; import * as session from '../session'; -import type { OrgConfigSummary } from '../../shared/types'; +import type { OrgConfig, OrgConfigSummary } from '../../shared/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type WasmModule = any; @@ -88,3 +88,15 @@ export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfi data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })), }; } + +/** Handle org_add_config: append a new device-local org config (reject duplicate orgId). */ +export async function handleOrgAddConfig( + { config }: { config: OrgConfig }, +): Promise<{ ok: true } | { ok: false; error: string }> { + const cfgs = await loadOrgConfigs(); + if (cfgs.some(c => c.orgId === config.orgId)) { + return { ok: false, error: 'org_already_configured' }; + } + await saveOrgConfigs([...cfgs, config]); + return { ok: true }; +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index c7e3917..c569782 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -16,6 +16,7 @@ import * as sessionTimer from '../session-timer'; import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; import { handleOrgListConfigs, + handleOrgAddConfig, handleOrgSwitch, handleOrgListItems, handleOrgGetItem, @@ -671,6 +672,9 @@ export async function handle( case 'org_list_configs': return handleOrgListConfigs(); + case 'org_add_config': + return handleOrgAddConfig(msg); + case 'org_switch': return handleOrgSwitch(msg, state.wasm); diff --git a/extension/src/shared/error-copy.ts b/extension/src/shared/error-copy.ts index bd5377b..6792dff 100644 --- a/extension/src/shared/error-copy.ts +++ b/extension/src/shared/error-copy.ts @@ -101,6 +101,10 @@ export const ERROR_COPY: Record = { cta: { label: 'Open setup', action: 'open_setup' }, }, // --- Org (enterprise) context errors (v0.9.0 Plan B) --- + org_already_configured: { + title: 'Already added', + body: 'That organization is already configured on this device.', + }, org_not_configured: { title: 'Org not set up here', body: 'This organization is not configured on this device yet. Add it from the vault switcher before browsing its items.', diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 23a51ed..b97d184 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,7 +1,7 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device, - FieldHistoryView, OrgConfigSummary, OrgManifestEntry, Collection, + FieldHistoryView, OrgConfig, OrgConfigSummary, OrgManifestEntry, Collection, } from './types'; // --- Session timeout config --- @@ -70,6 +70,7 @@ export type PopupMessage = referenceImageBytes: ArrayBuffer; deviceName: string } | { type: 'get_vault_status' } | { type: 'org_list_configs' } + | { type: 'org_add_config'; config: OrgConfig } | { type: 'org_switch'; context: string } | { type: 'org_list_items' } | { type: 'org_get_item'; id: string } @@ -187,7 +188,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'preview_totp_from_secret', 'generate_recovery_qr', 'unwrap_recovery_qr', 'create_vault', 'attach_vault', 'get_vault_status', - 'org_list_configs', + 'org_list_configs', 'org_add_config', 'org_switch', 'org_list_items', 'org_get_item', 'org_list_collections', ] as PopupMessage['type'][]); From 59b0fcd9855824f14c9b3ade6b338b1670e005f8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 12:28:24 -0400 Subject: [PATCH 23/25] feat(ext): add-org form + switcher trigger (org_add_config UI) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg --- .../components/__tests__/org-add-form.test.ts | 174 ++++++++++++++++++ .../src/popup/components/org-add-form.ts | 128 +++++++++++++ .../src/popup/components/org-switcher.ts | 11 +- 3 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 extension/src/popup/components/__tests__/org-add-form.test.ts create mode 100644 extension/src/popup/components/org-add-form.ts diff --git a/extension/src/popup/components/__tests__/org-add-form.test.ts b/extension/src/popup/components/__tests__/org-add-form.test.ts new file mode 100644 index 0000000..419264b --- /dev/null +++ b/extension/src/popup/components/__tests__/org-add-form.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../shared/state', () => ({ + getState: vi.fn(() => ({ orgContext: 'personal', orgConfigs: [], orgOffline: false })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), +})); + +import { openOrgAddForm } from '../org-add-form'; +import { sendMessage } from '../../../shared/state'; + +const send = sendMessage as ReturnType; + +/** Fill all 7 form fields with the given config values. */ +function fillForm(config: { + orgId: string; displayName: string; hostType: string; + hostUrl: string; repoPath: string; apiToken: string; memberId: string; +}): void { + const f = (name: string) => + document.querySelector(`[name="${name}"]`)!; + (f('orgId') as HTMLInputElement).value = config.orgId; + (f('displayName') as HTMLInputElement).value = config.displayName; + (f('hostType') as HTMLSelectElement).value = config.hostType; + (f('hostUrl') as HTMLInputElement).value = config.hostUrl; + (f('repoPath') as HTMLInputElement).value = config.repoPath; + (f('apiToken') as HTMLInputElement).value = config.apiToken; + (f('memberId') as HTMLInputElement).value = config.memberId; +} + +const SAMPLE_CONFIG = { + orgId: 'acme', + displayName: 'Acme Corp', + hostType: 'gitea', + hostUrl: 'https://git.acme.com', + repoPath: 'acme/vault', + apiToken: 'tok-secret', + memberId: 'alice', +}; + +describe('org-add-form', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('renders all 7 fields when opened', () => { + openOrgAddForm(); + + expect(document.querySelector('[name="orgId"]')).toBeTruthy(); + expect(document.querySelector('[name="displayName"]')).toBeTruthy(); + expect(document.querySelector('[name="hostType"]')).toBeTruthy(); + expect(document.querySelector('[name="hostUrl"]')).toBeTruthy(); + expect(document.querySelector('[name="repoPath"]')).toBeTruthy(); + expect(document.querySelector('[name="apiToken"]')).toBeTruthy(); + expect(document.querySelector('[name="memberId"]')).toBeTruthy(); + }); + + it('apiToken field is type=password', () => { + openOrgAddForm(); + const input = document.querySelector('[name="apiToken"]')!; + expect(input.type).toBe('password'); + }); + + it('renders the submit and cancel buttons with stable ids', () => { + openOrgAddForm(); + expect(document.querySelector('#org-add-submit')).toBeTruthy(); + expect(document.querySelector('#org-add-cancel')).toBeTruthy(); + }); + + it('renders an (initially empty) error slot', () => { + openOrgAddForm(); + const slot = document.querySelector('.org-add-error'); + expect(slot).toBeTruthy(); + expect(slot!.textContent).toBe(''); + }); + + it('hostType select has gitea and github options', () => { + openOrgAddForm(); + const sel = document.querySelector('[name="hostType"]')!; + const values = Array.from(sel.options).map((o) => o.value); + expect(values).toContain('gitea'); + expect(values).toContain('github'); + }); + + it('filling fields + clicking Add sends org_add_config with all 7 values', async () => { + send.mockResolvedValueOnce({ ok: true }); + const onAdded = vi.fn(); + + openOrgAddForm(onAdded); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(send).toHaveBeenCalledWith({ + type: 'org_add_config', + config: SAMPLE_CONFIG, + }); + }); + + it('on ok:true removes the overlay and calls onAdded with the orgId', async () => { + send.mockResolvedValueOnce({ ok: true }); + const onAdded = vi.fn(); + + openOrgAddForm(onAdded); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(onAdded).toHaveBeenCalledWith('acme'); + }); + + it('on ok:false keeps the overlay open and shows error copy in the error slot', async () => { + send.mockResolvedValueOnce({ ok: false, error: 'org_already_configured' }); + + openOrgAddForm(); + fillForm(SAMPLE_CONFIG); + + const form = document.querySelector('#org-add-form')!; + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await Promise.resolve(); + await Promise.resolve(); + + // Overlay must still be in the DOM. + expect(document.querySelector('#org-add-overlay')).toBeTruthy(); + + // The friendly copy for org_already_configured. + const slot = document.querySelector('.org-add-error')!; + expect(slot.textContent).toContain('already'); + }); + + it('Cancel button removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + const cancel = document.querySelector('#org-add-cancel')!; + cancel.click(); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('clicking the backdrop removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + const overlay = document.querySelector('#org-add-overlay')!; + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); + + it('Esc key removes the overlay without calling sendMessage', async () => { + openOrgAddForm(); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await Promise.resolve(); + + expect(document.querySelector('#org-add-overlay')).toBeNull(); + expect(send).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/org-add-form.ts b/extension/src/popup/components/org-add-form.ts new file mode 100644 index 0000000..e7d486a --- /dev/null +++ b/extension/src/popup/components/org-add-form.ts @@ -0,0 +1,128 @@ +// org-add-form.ts — Modal form for adding a new org config. +// +// Self-contained: appended to document.body so it works in both the popup +// and the vault sidebar without touching either surface's router. +// +// Sends `org_add_config { config: OrgConfig }` on submit and calls the +// optional `onAdded` callback with the new orgId on success. + +import type { OrgConfig } from '../../shared/types'; +import { sendMessage } from '../../shared/state'; +import { lookupErrorCopy } from '../../shared/error-copy'; + +export function openOrgAddForm(onAdded?: (orgId: string) => void): void { + // Build the overlay + form. + const overlay = document.createElement('div'); + overlay.id = 'org-add-overlay'; + overlay.style.cssText = + 'position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;' + + 'align-items:center;justify-content:center;z-index:9999;'; + + const form = document.createElement('form'); + form.id = 'org-add-form'; + form.style.cssText = + 'background:#fff;border-radius:8px;padding:1.5rem;min-width:320px;' + + 'display:flex;flex-direction:column;gap:.75rem;'; + + form.innerHTML = ` +

Add organization

+ + + + + + + + + + + + + + + +
+ +
+ + +
+ `; + + overlay.appendChild(form); + document.body.appendChild(overlay); + + const errorSlot = form.querySelector('.org-add-error') as HTMLElement; + + function close(): void { + overlay.remove(); + } + + // Cancel button. + form.querySelector('#org-add-cancel')!.addEventListener('click', close); + + // Backdrop click. + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + // Esc key. + function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + close(); + document.removeEventListener('keydown', onKeydown); + } + } + document.addEventListener('keydown', onKeydown); + + // Clean up the keydown listener when the overlay is removed (cancel/success). + const observer = new MutationObserver(() => { + if (!document.body.contains(overlay)) { + document.removeEventListener('keydown', onKeydown); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true }); + + // Submit. + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorSlot.textContent = ''; + + const data = new FormData(form); + const config: OrgConfig = { + orgId: (data.get('orgId') as string).trim(), + displayName: (data.get('displayName') as string).trim(), + hostType: (data.get('hostType') as 'gitea' | 'github'), + hostUrl: (data.get('hostUrl') as string).trim(), + repoPath: (data.get('repoPath') as string).trim(), + apiToken: (data.get('apiToken') as string).trim(), + memberId: (data.get('memberId') as string).trim(), + }; + + const resp = await sendMessage({ type: 'org_add_config', config }); + if (resp.ok) { + close(); + onAdded?.(config.orgId); + } else { + errorSlot.textContent = lookupErrorCopy(resp.error ?? '').body; + } + }); +} diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts index a2e5a0c..1598aaa 100644 --- a/extension/src/popup/components/org-switcher.ts +++ b/extension/src/popup/components/org-switcher.ts @@ -9,6 +9,7 @@ import type { OrgConfigSummary, OrgManifestEntry, ManifestEntry, ItemId, Collection } from '../../shared/types'; import { sendMessage, setState, navigate, getState } from '../../shared/state'; import { messageForList, normalizeOrgEntries } from '../../shared/org-context'; +import { openOrgAddForm } from './org-add-form'; // Module-level teardown (one instance per browser context; popup and vault // each have their own module scope). @@ -35,7 +36,8 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { optionsHtml += ``; } host.innerHTML = - ``; + `` + + ``; // 4. Wire the change event. const select = host.querySelector('select') as HTMLSelectElement; @@ -87,6 +89,13 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { }; select.addEventListener('change', handler); + + // 5. Wire the "Add organization" button. + const addBtn = host.querySelector('.org-add-btn') as HTMLButtonElement; + addBtn.addEventListener('click', () => { + openOrgAddForm((_orgId) => { void renderOrgSwitcher(host); }); + }); + _cleanupListener = () => select.removeEventListener('change', handler); } From cc4d4e8f36f415adaf37996e73a11ac70422eaf6 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 12:52:36 -0400 Subject: [PATCH 24/25] fix(ext): enforce org read-only in detail/drawer + context-aware vault drawer + escape switcher options --- .../src/popup/components/org-add-form.ts | 5 + .../src/popup/components/org-switcher.ts | 16 ++- .../types/__tests__/detail-read-only.test.ts | 101 ++++++++++++++++++ extension/src/popup/components/types/card.ts | 11 +- .../src/popup/components/types/document.ts | 11 +- .../src/popup/components/types/identity.ts | 11 +- extension/src/popup/components/types/key.ts | 11 +- extension/src/popup/components/types/login.ts | 8 +- .../src/popup/components/types/secure-note.ts | 11 +- extension/src/popup/components/types/totp.ts | 11 +- extension/src/vault/vault-drawer.ts | 6 +- extension/src/vault/vault-sidebar.ts | 7 +- 12 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 extension/src/popup/components/types/__tests__/detail-read-only.test.ts diff --git a/extension/src/popup/components/org-add-form.ts b/extension/src/popup/components/org-add-form.ts index e7d486a..d9ee5a2 100644 --- a/extension/src/popup/components/org-add-form.ts +++ b/extension/src/popup/components/org-add-form.ts @@ -117,6 +117,11 @@ export function openOrgAddForm(onAdded?: (orgId: string) => void): void { memberId: (data.get('memberId') as string).trim(), }; + if (!config.orgId) { + errorSlot.textContent = 'Org ID is required'; + return; + } + const resp = await sendMessage({ type: 'org_add_config', config }); if (resp.ok) { close(); diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts index 1598aaa..ccba402 100644 --- a/extension/src/popup/components/org-switcher.ts +++ b/extension/src/popup/components/org-switcher.ts @@ -7,7 +7,7 @@ // disable writes. import type { OrgConfigSummary, OrgManifestEntry, ManifestEntry, ItemId, Collection } from '../../shared/types'; -import { sendMessage, setState, navigate, getState } from '../../shared/state'; +import { sendMessage, setState, navigate, getState, escapeHtml } from '../../shared/state'; import { messageForList, normalizeOrgEntries } from '../../shared/org-context'; import { openOrgAddForm } from './org-add-form'; @@ -33,7 +33,7 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { ``; for (const org of orgConfigs) { const sel = currentContext === org.orgId ? ' selected' : ''; - optionsHtml += ``; + optionsHtml += ``; } host.innerHTML = `` + @@ -62,7 +62,13 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { existing?.remove(); } - // Load the new context's entries into shared state before navigating. + // Fire the collection fetch immediately so it overlaps with the list fetch + // (both are independent after org_switch above). We await them sequentially + // below so setState({ entries }) still happens before setState({ orgCollections }). + const colPromise = data.context !== 'personal' + ? sendMessage({ type: 'org_list_collections' }) + : null; + // messageForList() reads the just-set orgContext so it picks the right SW // message (list_items for personal, org_list_items for org). Both popup's // renderItemList and vault's renderListPane read state.entries, so updating @@ -81,8 +87,8 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { if (data.context === 'personal') { setState({ orgCollections: [], collectionFilter: undefined }); } else { - const colResp = await sendMessage({ type: 'org_list_collections' }); - if (colResp.ok) setState({ orgCollections: colResp.data as Collection[] }); + const colResp = colPromise ? await colPromise : null; + if (colResp?.ok) setState({ orgCollections: colResp.data as Collection[] }); } navigate('list', {}); diff --git a/extension/src/popup/components/types/__tests__/detail-read-only.test.ts b/extension/src/popup/components/types/__tests__/detail-read-only.test.ts new file mode 100644 index 0000000..723655f --- /dev/null +++ b/extension/src/popup/components/types/__tests__/detail-read-only.test.ts @@ -0,0 +1,101 @@ +// Regression test: in org context the detail view must NOT render edit or +// trash buttons (Finding #1 from the whole-branch code review). + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock shared/org-context so currentContext() returns a non-personal org. +vi.mock('../../../../shared/org-context', () => ({ + currentContext: vi.fn(() => 'acme-corp'), + messageForGet: vi.fn((id: string) => ({ type: 'org_get_item', id })), + messageForList: vi.fn(() => ({ type: 'org_list_items' })), + normalizeOrgEntries: vi.fn((e: unknown[]) => e), +})); + +vi.mock('../../../../shared/state', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'detail', entries: [], selectedId: 'abc123', selectedItem: null, + selectedIndex: 0, searchQuery: '', activeGroup: null, error: null, + loading: false, capturedTabId: null, capturedUrl: '', newType: null, + orgContext: 'acme-corp', orgOffline: false, + })); + const escapeHtml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { + navigate, setState, sendMessage, getState, escapeHtml, + popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(), + }; +}); + +vi.mock('../../../../setup/setup-helpers', () => ({ + scheduleRate: vi.fn(), + STRENGTH_LABELS: {}, + entropyText: vi.fn(() => ''), +})); + +import { renderDetail } from '../login'; +import type { Item } from '../../../../shared/types'; + +const ORG_LOGIN_ITEM: Item = { + id: 'aabbccdd00000001', + title: 'Acme Intranet', + type: 'login', + tags: [], + favorite: false, + created: 0, + modified: 0, + trashed_at: undefined, + notes: undefined, + group: undefined, + core: { + type: 'login', + username: 'alice', + password: 's3cr3t', + url: 'https://intranet.acme.com', + totp: undefined, + }, + sections: [], + attachments: [], + field_history: {}, +}; + +describe('login renderDetail — org context read-only gate', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + // Minimal chrome stub for wireAttachmentsDisclosure + wireFieldHandlers + (globalThis as Record).chrome = { + storage: { + local: { + get: vi.fn((_k: unknown, cb: (v: Record) => void) => cb({})), + set: vi.fn((_o: unknown, cb?: () => void) => cb?.()), + }, + }, + runtime: { sendMessage: vi.fn() }, + }; + }); + + it('renders no edit or trash buttons in org context', async () => { + const app = document.getElementById('app')!; + await renderDetail(app, ORG_LOGIN_ITEM); + + expect(document.getElementById('edit-btn')).toBeNull(); + expect(document.getElementById('trash-btn')).toBeNull(); + }); + + it('keeps the back button (read affordance) in org context', async () => { + const app = document.getElementById('app')!; + await renderDetail(app, ORG_LOGIN_ITEM); + + expect(document.getElementById('back-btn')).not.toBeNull(); + }); + + it('keeps the fill/autofill button (read affordance) in org context', async () => { + const app = document.getElementById('app')!; + await renderDetail(app, ORG_LOGIN_ITEM); + + expect(document.getElementById('fill-btn')).not.toBeNull(); + }); +}); diff --git a/extension/src/popup/components/types/card.ts b/extension/src/popup/components/types/card.ts index 9413011..127ef0b 100644 --- a/extension/src/popup/components/types/card.ts +++ b/extension/src/popup/components/types/card.ts @@ -2,6 +2,7 @@ /// Detail view has a styled card-silhouette signature block. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { currentContext } from '../../../shared/org-context'; import { renderFormHeader } from '../form-header'; import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, CardKind, Section, AttachmentRef } from '../../../shared/types'; @@ -58,6 +59,7 @@ function formatExpiry(e: { month: number; year: number } | undefined): string { export async function renderDetail(app: HTMLElement, item: Item): Promise { if (item.core.type !== 'card') return; + const isPersonal = currentContext() === 'personal'; const c = item.core; const number = c.number ?? ''; const brand = brandFromNumber(number); @@ -94,8 +96,8 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
- - + ${isPersonal ? '' : ''} + ${isPersonal ? '' : ''}
`; @@ -127,8 +129,11 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise } switch (e.key) { case 'Escape': teardown(); navigate('list'); break; - case 'e': teardown(); navigate('edit'); break; + case 'e': + if (!isPersonal) break; + teardown(); navigate('edit'); break; case 'd': + if (!isPersonal) break; e.preventDefault(); if (confirm(`Move "${item.title}" to trash?`)) { teardown(); diff --git a/extension/src/popup/components/types/document.ts b/extension/src/popup/components/types/document.ts index a60cf5e..4f05e72 100644 --- a/extension/src/popup/components/types/document.ts +++ b/extension/src/popup/components/types/document.ts @@ -3,6 +3,7 @@ /// Primary attachment is referenced by ID from the item's attachments array. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { currentContext } from '../../../shared/org-context'; import { renderFormHeader } from '../form-header'; import { REQUIRED_PILL_HTML, GLYPH_TYPE_DOCUMENT, GLYPH_PREVIEW } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; @@ -264,6 +265,7 @@ async function saveDocument( export async function renderDetail(app: HTMLElement, item: Item): Promise { teardown(); if (item.core.type !== 'document') return; + const isPersonal = currentContext() === 'personal'; const c = item.core; const primaryRef = item.attachments.find((a) => a.id === c.primary_attachment); if (!primaryRef) { @@ -301,8 +303,8 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise
- - + ${isPersonal ? '' : ''} + ${isPersonal ? '' : ''}
`; @@ -381,8 +383,11 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise } switch (e.key) { case 'Escape': teardown(); navigate('list'); break; - case 'e': teardown(); navigate('edit'); break; + case 'e': + if (!isPersonal) break; + teardown(); navigate('edit'); break; case 'd': + if (!isPersonal) break; e.preventDefault(); if (confirm(`Move "${item.title}" to trash?`)) { teardown(); diff --git a/extension/src/popup/components/types/identity.ts b/extension/src/popup/components/types/identity.ts index f9776c1..6bc8a33 100644 --- a/extension/src/popup/components/types/identity.ts +++ b/extension/src/popup/components/types/identity.ts @@ -2,6 +2,7 @@ /// Detail view shows a "profile card" signature block + plain rows. import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab } from '../../../shared/state'; +import { currentContext } from '../../../shared/org-context'; import { renderFormHeader } from '../form-header'; import { REQUIRED_PILL_HTML } from '../../../shared/glyphs'; import type { Item, ItemId, ManifestEntry, Section, AttachmentRef } from '../../../shared/types'; @@ -48,6 +49,7 @@ function formatDate(iso: string | undefined): string { export async function renderDetail(app: HTMLElement, item: Item): Promise { if (item.core.type !== 'identity') return; + const isPersonal = currentContext() === 'personal'; const c = item.core; const sigInner = `
@@ -72,8 +74,8 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise ${renderAttachmentsDisclosure({ itemId: item.id, attachments: item.attachments, mode: 'view' })}
- - + ${isPersonal ? '' : ''} + ${isPersonal ? '' : ''}
`; @@ -103,8 +105,11 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise } switch (e.key) { case 'Escape': teardown(); navigate('list'); break; - case 'e': teardown(); navigate('edit'); break; + case 'e': + if (!isPersonal) break; + teardown(); navigate('edit'); break; case 'd': + if (!isPersonal) break; e.preventDefault(); if (confirm(`Move "${item.title}" to trash?`)) { teardown(); diff --git a/extension/src/popup/components/types/key.ts b/extension/src/popup/components/types/key.ts index 36cb3ba..d62b2b6 100644 --- a/extension/src/popup/components/types/key.ts +++ b/extension/src/popup/components/types/key.ts @@ -3,6 +3,7 @@ /// since