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.