fix(ext/sw): free org handle on openOrg error path (no org-key leak past lock)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014s527M917W47LDrfQ4t47g
This commit is contained in:
adlee-was-taken
2026-06-27 11:05:58 -04:00
parent f9b8f3821d
commit 7913e3ad84
2 changed files with 30 additions and 0 deletions

View File

@@ -370,4 +370,28 @@ describe('org-vault', () => {
// No cachedManifest → must rethrow // No cachedManifest → must rethrow
await expect(orgVault.openOrg(ORG_CFG, w)).rejects.toThrow('Failed to fetch'); 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);
});
}); });

View File

@@ -109,6 +109,12 @@ export async function openOrg(
manifest = cachedManifest; manifest = cachedManifest;
offline = true; offline = true;
} else { } 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; throw e;
} }
} }