From 7913e3ad84d983ec396639f7b888ae2fbef3f84c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 11:05:58 -0400 Subject: [PATCH] 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; } }