Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 75 additions & 6 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from "vscode"
import { ZodError } from "zod"
import * as crypto from "crypto"

import {
PROVIDER_SETTINGS_KEYS,
Expand Down Expand Up @@ -39,12 +40,39 @@ export class ContextProxy {
private stateCache: GlobalState
private secretCache: SecretState
private _isInitialized = false
private _profileId: string

constructor(context: vscode.ExtensionContext) {
this.originalContext = context
this.stateCache = {}
this.secretCache = {}
this._isInitialized = false
this._profileId = this.generateProfileId()
}

/**
* Generates a unique profile identifier based on VSCode's environment
* This ensures secrets are isolated per VSCode profile (local, WSL, etc.)
*/
private generateProfileId(): string {
// Use a combination of machineId and environment-specific identifiers
const machineId = vscode.env.machineId
const appName = vscode.env.appName
const uriScheme = vscode.env.uriScheme

// Create a hash of these identifiers to create a unique profile ID
const profileData = `${machineId}-${appName}-${uriScheme}`
const hash = crypto.createHash("sha256").update(profileData).digest("hex")

// Use first 16 characters for a shorter but still unique identifier
return hash.substring(0, 16)
}

/**
* Creates a profile-specific secret key to ensure secrets are isolated per VSCode profile
*/
private getProfileSpecificSecretKey(key: SecretStateKey): string {
return `${this._profileId}:${key}`
}

public get isInitialized() {
Expand All @@ -63,7 +91,24 @@ export class ContextProxy {

const promises = SECRET_STATE_KEYS.map(async (key) => {
try {
this.secretCache[key] = await this.originalContext.secrets.get(key)
// Use profile-specific key for secrets to ensure profile isolation
const profileSpecificKey = this.getProfileSpecificSecretKey(key)
let secretValue = await this.originalContext.secrets.get(profileSpecificKey)

// Migration: If profile-specific secret doesn't exist, check for legacy global secret
if (!secretValue) {
const legacyValue = await this.originalContext.secrets.get(key)
if (legacyValue) {
// Migrate legacy secret to profile-specific storage
await this.originalContext.secrets.store(profileSpecificKey, legacyValue)
// Clean up legacy secret to prevent cross-profile leakage
await this.originalContext.secrets.delete(key)
secretValue = legacyValue
logger.info(`Migrated secret ${key} to profile-specific storage`)
}
}

this.secretCache[key] = secretValue
} catch (error) {
logger.error(`Error loading secret ${key}: ${error instanceof Error ? error.message : String(error)}`)
}
Expand Down Expand Up @@ -141,10 +186,13 @@ export class ContextProxy {
// Update cache.
this.secretCache[key] = value

// Write directly to context.
// Use profile-specific key for secrets to ensure profile isolation
const profileSpecificKey = this.getProfileSpecificSecretKey(key)

// Write directly to context with profile-specific key.
return value === undefined
? this.originalContext.secrets.delete(key)
: this.originalContext.secrets.store(key, value)
? this.originalContext.secrets.delete(profileSpecificKey)
: this.originalContext.secrets.store(profileSpecificKey, value)
}

/**
Expand All @@ -154,7 +202,24 @@ export class ContextProxy {
async refreshSecrets(): Promise<void> {
const promises = SECRET_STATE_KEYS.map(async (key) => {
try {
this.secretCache[key] = await this.originalContext.secrets.get(key)
// Use profile-specific key for secrets to ensure profile isolation
const profileSpecificKey = this.getProfileSpecificSecretKey(key)
let secretValue = await this.originalContext.secrets.get(profileSpecificKey)

// Migration: If profile-specific secret doesn't exist, check for legacy global secret
if (!secretValue) {
const legacyValue = await this.originalContext.secrets.get(key)
if (legacyValue) {
// Migrate legacy secret to profile-specific storage
await this.originalContext.secrets.store(profileSpecificKey, legacyValue)
// Clean up legacy secret to prevent cross-profile leakage
await this.originalContext.secrets.delete(key)
secretValue = legacyValue
logger.info(`Migrated secret ${key} to profile-specific storage during refresh`)
}
}

this.secretCache[key] = secretValue
} catch (error) {
logger.error(
`Error refreshing secret ${key}: ${error instanceof Error ? error.message : String(error)}`,
Expand Down Expand Up @@ -284,7 +349,11 @@ export class ContextProxy {

await Promise.all([
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
...SECRET_STATE_KEYS.map((key) => {
// Use profile-specific key for secrets to ensure profile isolation
const profileSpecificKey = this.getProfileSpecificSecretKey(key)
return this.originalContext.secrets.delete(profileSpecificKey)
}),
])

await this.initialize()
Expand Down
162 changes: 151 additions & 11 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ vi.mock("vscode", () => ({
Production: 2,
Test: 3,
},
env: {
machineId: "test-machine-id",
appName: "Visual Studio Code",
uriScheme: "vscode",
},
}))

describe("ContextProxy", () => {
Expand Down Expand Up @@ -76,10 +81,11 @@ describe("ContextProxy", () => {
}
})

it("should initialize secret cache with all secret keys", () => {
it("should initialize secret cache with all secret keys using profile-specific keys", () => {
expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_STATE_KEYS.length)
for (const key of SECRET_STATE_KEYS) {
expect(mockSecrets.get).toHaveBeenCalledWith(key)
// Should use profile-specific key format: profileId:originalKey
expect(mockSecrets.get).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^[a-f0-9]{16}:${key}$`)))
}
})
})
Expand Down Expand Up @@ -191,22 +197,22 @@ describe("ContextProxy", () => {
})

describe("storeSecret", () => {
it("should store secret directly in original context", async () => {
it("should store secret directly in original context with profile-specific key", async () => {
await proxy.storeSecret("apiKey", "new-secret")

// Should have called original context
expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "new-secret")
// Should have called original context with profile-specific key
expect(mockSecrets.store).toHaveBeenCalledWith(expect.stringMatching(/^[a-f0-9]{16}:apiKey$/), "new-secret")

// Should have stored the value in cache
const storedValue = await proxy.getSecret("apiKey")
expect(storedValue).toBe("new-secret")
})

it("should handle undefined value for secret deletion", async () => {
it("should handle undefined value for secret deletion with profile-specific key", async () => {
await proxy.storeSecret("apiKey", undefined)

// Should have called delete on original context
expect(mockSecrets.delete).toHaveBeenCalledWith("apiKey")
// Should have called delete on original context with profile-specific key
expect(mockSecrets.delete).toHaveBeenCalledWith(expect.stringMatching(/^[a-f0-9]{16}:apiKey$/))

// Should have stored undefined in cache
const storedValue = await proxy.getSecret("apiKey")
Expand Down Expand Up @@ -391,17 +397,19 @@ describe("ContextProxy", () => {
expect(mockGlobalState.update).toHaveBeenCalledTimes(expectedUpdateCalls)
})

it("should delete all secrets", async () => {
it("should delete all secrets using profile-specific keys", async () => {
// Setup initial secrets
await proxy.storeSecret("apiKey", "test-api-key")
await proxy.storeSecret("openAiApiKey", "test-openai-key")

// Reset all state
await proxy.resetAllState()

// Should have called delete for each key
// Should have called delete for each key with profile-specific format
for (const key of SECRET_STATE_KEYS) {
expect(mockSecrets.delete).toHaveBeenCalledWith(key)
expect(mockSecrets.delete).toHaveBeenCalledWith(
expect.stringMatching(new RegExp(`^[a-f0-9]{16}:${key}$`)),
)
}

// Total calls should equal the number of secret keys
Expand All @@ -419,4 +427,136 @@ describe("ContextProxy", () => {
expect(initializeSpy).toHaveBeenCalledTimes(1)
})
})

describe("profile-aware secret storage", () => {
it("should generate consistent profile IDs", () => {
// Create multiple instances and verify they generate the same profile ID
const proxy1 = new ContextProxy(mockContext)
const proxy2 = new ContextProxy(mockContext)

// Access private method for testing
const profileId1 = (proxy1 as any).generateProfileId()
const profileId2 = (proxy2 as any).generateProfileId()

expect(profileId1).toBe(profileId2)
expect(profileId1).toMatch(/^[a-f0-9]{16}$/)
})

it("should create profile-specific secret keys", () => {
const profileSpecificKey = (proxy as any).getProfileSpecificSecretKey("codeIndexQdrantApiKey")

expect(profileSpecificKey).toMatch(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/)
})

it("should migrate legacy secrets to profile-specific storage during initialization", async () => {
// Setup mock to return legacy secret on first call, undefined on profile-specific call
mockSecrets.get
.mockImplementationOnce((key: string) => {
// Profile-specific key call returns undefined (no existing profile-specific secret)
if (key.includes(":")) return Promise.resolve(undefined)
// Legacy key call returns the legacy value
return Promise.resolve("legacy-qdrant-key")
})
.mockImplementation((key: string) => {
// Subsequent calls for legacy key return the legacy value
if (!key.includes(":")) return Promise.resolve("legacy-qdrant-key")
return Promise.resolve(undefined)
})

// Create new proxy to trigger initialization
const newProxy = new ContextProxy(mockContext)
await newProxy.initialize()

// Should have attempted to get profile-specific key first
expect(mockSecrets.get).toHaveBeenCalledWith(expect.stringMatching(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/))

// Should have attempted to get legacy key when profile-specific wasn't found
expect(mockSecrets.get).toHaveBeenCalledWith("codeIndexQdrantApiKey")

// Should have stored the migrated value with profile-specific key
expect(mockSecrets.store).toHaveBeenCalledWith(
expect.stringMatching(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/),
"legacy-qdrant-key",
)

// Should have deleted the legacy key
expect(mockSecrets.delete).toHaveBeenCalledWith("codeIndexQdrantApiKey")
})

it("should migrate legacy secrets during refreshSecrets", async () => {
// Setup mock to simulate legacy secret exists
mockSecrets.get.mockImplementation((key: string) => {
if (key.includes(":")) return Promise.resolve(undefined) // No profile-specific secret
if (key === "codeIndexQdrantApiKey") return Promise.resolve("legacy-refresh-key")
return Promise.resolve(undefined)
})

await proxy.refreshSecrets()

// Should have migrated the legacy secret
expect(mockSecrets.store).toHaveBeenCalledWith(
expect.stringMatching(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/),
"legacy-refresh-key",
)
expect(mockSecrets.delete).toHaveBeenCalledWith("codeIndexQdrantApiKey")
})

it("should not migrate when profile-specific secret already exists", async () => {
// Setup mock to return existing profile-specific secret
mockSecrets.get.mockImplementation((key: string) => {
if (key.includes(":codeIndexQdrantApiKey")) return Promise.resolve("existing-profile-key")
return Promise.resolve(undefined)
})

// Create new proxy to trigger initialization
const newProxy = new ContextProxy(mockContext)
await newProxy.initialize()

// Should not have attempted migration since profile-specific secret exists
expect(mockSecrets.store).not.toHaveBeenCalledWith(
expect.stringMatching(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/),
expect.any(String),
)
expect(mockSecrets.delete).not.toHaveBeenCalledWith("codeIndexQdrantApiKey")
})

it("should isolate secrets between different profile environments", () => {
// Mock different VSCode environments
const wslEnv = {
machineId: "test-machine-id",
appName: "Visual Studio Code",
uriScheme: "vscode-remote",
}

const localEnv = {
machineId: "test-machine-id",
appName: "Visual Studio Code",
uriScheme: "vscode",
}

// Mock vscode.env for different environments
vi.mocked(vscode.env).machineId = wslEnv.machineId
vi.mocked(vscode.env).appName = wslEnv.appName
vi.mocked(vscode.env).uriScheme = wslEnv.uriScheme

const wslProxy = new ContextProxy(mockContext)
const wslProfileId = (wslProxy as any).generateProfileId()

vi.mocked(vscode.env).uriScheme = localEnv.uriScheme

const localProxy = new ContextProxy(mockContext)
const localProfileId = (localProxy as any).generateProfileId()

// Profile IDs should be different for different environments
expect(wslProfileId).not.toBe(localProfileId)

// Secret keys should be different
const wslSecretKey = (wslProxy as any).getProfileSpecificSecretKey("codeIndexQdrantApiKey")
const localSecretKey = (localProxy as any).getProfileSpecificSecretKey("codeIndexQdrantApiKey")

expect(wslSecretKey).not.toBe(localSecretKey)
expect(wslSecretKey).toMatch(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/)
expect(localSecretKey).toMatch(/^[a-f0-9]{16}:codeIndexQdrantApiKey$/)
})
})
})
Loading