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; } }