diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts index bdbbdb2401..d8bd3c1552 100644 --- a/packages/cli/src/config/extensions/extensionSettings.test.ts +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -21,7 +21,7 @@ import { ExtensionStorage } from './storage.js'; import prompts from 'prompts'; import * as fsPromises from 'node:fs/promises'; import * as fs from 'node:fs'; -import { KeychainTokenStorage } from '@google/gemini-cli-core'; +import { HybridSecretStorage } from '@google/gemini-cli-core'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; vi.mock('prompts'); @@ -38,7 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - KeychainTokenStorage: vi.fn(), + HybridSecretStorage: vi.fn(), }; }); @@ -51,33 +51,29 @@ describe('extensionSettings', () => { beforeEach(() => { vi.clearAllMocks(); mockKeychainData = {}; - vi.mocked(KeychainTokenStorage).mockImplementation( - (serviceName: string) => { - if (!mockKeychainData[serviceName]) { - mockKeychainData[serviceName] = {}; - } - const keychainData = mockKeychainData[serviceName]; - return { - getSecret: vi - .fn() - .mockImplementation( - async (key: string) => keychainData[key] || null, - ), - setSecret: vi - .fn() - .mockImplementation(async (key: string, value: string) => { - keychainData[key] = value; - }), - deleteSecret: vi.fn().mockImplementation(async (key: string) => { - delete keychainData[key]; + vi.mocked(HybridSecretStorage).mockImplementation((serviceName: string) => { + if (!mockKeychainData[serviceName]) { + mockKeychainData[serviceName] = {}; + } + const keychainData = mockKeychainData[serviceName]; + return { + getSecret: vi + .fn() + .mockImplementation(async (key: string) => keychainData[key] || null), + setSecret: vi + .fn() + .mockImplementation(async (key: string, value: string) => { + keychainData[key] = value; }), - listSecrets: vi - .fn() - .mockImplementation(async () => Object.keys(keychainData)), - isAvailable: vi.fn().mockResolvedValue(true), - } as unknown as KeychainTokenStorage; - }, - ); + deleteSecret: vi.fn().mockImplementation(async (key: string) => { + delete keychainData[key]; + }), + listSecrets: vi + .fn() + .mockImplementation(async () => Object.keys(keychainData)), + isAvailable: vi.fn().mockResolvedValue(true), + } as unknown as HybridSecretStorage; + }); tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`; tempWorkspaceDir = path.join( os.tmpdir(), @@ -215,7 +211,7 @@ describe('extensionSettings', () => { VAR1: 'previous-VAR1', SENSITIVE_VAR: 'secret', }; - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); @@ -255,7 +251,7 @@ describe('extensionSettings', () => { settings: [], }; const previousSettings = { SENSITIVE_VAR: 'secret' }; - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'secret'); @@ -421,53 +417,11 @@ describe('extensionSettings', () => { undefined, ); - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull(); }); - - it('should not attempt to clear secrets if keychain is unavailable', async () => { - // Arrange - const mockIsAvailable = vi.fn().mockResolvedValue(false); - const mockListSecrets = vi.fn(); - - vi.mocked(KeychainTokenStorage).mockImplementation( - () => - ({ - isAvailable: mockIsAvailable, - listSecrets: mockListSecrets, - deleteSecret: vi.fn(), - getSecret: vi.fn(), - setSecret: vi.fn(), - }) as unknown as KeychainTokenStorage, - ); - - const config: ExtensionConfig = { - name: 'test-ext', - version: '1.0.0', - settings: [], // Empty settings triggers clearSettings - }; - - const previousConfig: ExtensionConfig = { - name: 'test-ext', - version: '1.0.0', - settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }], - }; - - // Act - await maybePromptForSettings( - config, - '12345', - mockRequestSetting, - previousConfig, - undefined, - ); - - // Assert - expect(mockIsAvailable).toHaveBeenCalled(); - expect(mockListSecrets).not.toHaveBeenCalled(); - }); }); describe('promptForSetting', () => { @@ -549,7 +503,7 @@ describe('extensionSettings', () => { it('should return combined contents from user .env and keychain for USER scope', async () => { const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME); await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1'); - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret'); @@ -573,7 +527,7 @@ describe('extensionSettings', () => { EXTENSION_SETTINGS_FILENAME, ); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); - const workspaceKeychain = new KeychainTokenStorage( + const workspaceKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); @@ -597,7 +551,7 @@ describe('extensionSettings', () => { EXTENSION_SETTINGS_FILENAME, ); fs.mkdirSync(workspaceEnvPath); - const workspaceKeychain = new KeychainTokenStorage( + const workspaceKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret'); @@ -634,7 +588,7 @@ describe('extensionSettings', () => { userEnvPath, 'VAR1=user-value1\nVAR3=user-value3', ); - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext ${extensionId}`, ); await userKeychain.setSecret('VAR2', 'user-secret2'); @@ -645,7 +599,7 @@ describe('extensionSettings', () => { EXTENSION_SETTINGS_FILENAME, ); await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1'); - const workspaceKeychain = new KeychainTokenStorage( + const workspaceKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`, ); await workspaceKeychain.setSecret('VAR2', 'workspace-secret2'); @@ -678,7 +632,7 @@ describe('extensionSettings', () => { beforeEach(async () => { const userEnvPath = path.join(extensionDir, '.env'); await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n'); - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.setSecret('VAR2', 'value2'); @@ -751,7 +705,7 @@ describe('extensionSettings', () => { tempWorkspaceDir, ); - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('VAR2')).toBe('new-value2'); @@ -769,7 +723,7 @@ describe('extensionSettings', () => { tempWorkspaceDir, ); - const workspaceKeychain = new KeychainTokenStorage( + const workspaceKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`, ); expect(await workspaceKeychain.getSecret('VAR2')).toBe( @@ -823,7 +777,7 @@ describe('extensionSettings', () => { tempWorkspaceDir, ); - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); expect(await userKeychain.getSecret('VAR2')).toBeNull(); @@ -849,7 +803,7 @@ describe('extensionSettings', () => { it('should not throw if deleting a non-existent sensitive setting with empty value', async () => { mockRequestSetting.mockResolvedValue(''); // Ensure it doesn't exist first - const userKeychain = new KeychainTokenStorage( + const userKeychain = new HybridSecretStorage( `Gemini CLI Extensions test-ext 12345`, ); await userKeychain.deleteSecret('VAR2'); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 700d854e20..f61ecf39cc 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -13,7 +13,7 @@ import { ExtensionStorage } from './storage.js'; import type { ExtensionConfig } from '../extension.js'; import prompts from 'prompts'; -import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core'; +import { debugLogger, HybridSecretStorage } from '@google/gemini-cli-core'; import { EXTENSION_SETTINGS_FILENAME } from './variables.js'; export enum ExtensionSettingScope { @@ -78,7 +78,7 @@ export async function maybePromptForSettings( // The user can change the scope later using the `settings set` command. const scope = ExtensionSettingScope.USER; const envFilePath = getEnvFilePath(extensionName, scope); - const keychain = new KeychainTokenStorage( + const keychain = new HybridSecretStorage( getKeychainStorageName(extensionName, extensionId, scope), ); @@ -176,7 +176,7 @@ export async function getScopedEnvContents( workspaceDir?: string, ): Promise> { const { name: extensionName } = extensionConfig; - const keychain = new KeychainTokenStorage( + const keychain = new HybridSecretStorage( getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir); @@ -250,7 +250,7 @@ export async function updateSetting( } const newValue = await requestSetting(settingToUpdate); - const keychain = new KeychainTokenStorage( + const keychain = new HybridSecretStorage( getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); @@ -339,7 +339,7 @@ function getSettingsChanges( async function clearSettings( envFilePath: string, - keychain: KeychainTokenStorage, + keychain: HybridSecretStorage, ) { if (fsSync.existsSync(envFilePath)) { const stat = fsSync.statSync(envFilePath); @@ -347,9 +347,6 @@ async function clearSettings( await fs.writeFile(envFilePath, ''); } } - if (!(await keychain.isAvailable())) { - return; - } const secrets = await keychain.listSecrets(); for (const secret of secrets) { await keychain.deleteSecret(secret); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b0f2c1d8cd..e7f9fa779b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -174,6 +174,8 @@ export type { OAuthCredentials, } from './mcp/token-storage/types.js'; export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js'; +export { HybridSecretStorage } from './mcp/token-storage/hybrid-secret-storage.js'; +export { KeychainTokenStorage } from './mcp/token-storage/keychain-token-storage.js'; export type { MCPOAuthConfig } from './mcp/oauth-provider.js'; export type { OAuthAuthorizationServerMetadata, diff --git a/packages/core/src/mcp/token-storage/encrypted-file-secret-storage.ts b/packages/core/src/mcp/token-storage/encrypted-file-secret-storage.ts new file mode 100644 index 0000000000..f38656b7f7 --- /dev/null +++ b/packages/core/src/mcp/token-storage/encrypted-file-secret-storage.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as crypto from 'node:crypto'; +import type { SecretStorage } from './types.js'; +import { GEMINI_DIR, homedir } from '../../utils/paths.js'; + +export class EncryptedFileSecretStorage implements SecretStorage { + private readonly tokenFilePath: string; + private readonly encryptionKey: Buffer; + private readonly serviceName: string; + + constructor(serviceName: string) { + this.serviceName = serviceName; + const configDir = path.join(homedir(), GEMINI_DIR); + this.tokenFilePath = path.join(configDir, 'extension-secrets-v1.json'); + this.encryptionKey = this.deriveEncryptionKey(); + } + + private deriveEncryptionKey(): Buffer { + const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`; + return crypto.scryptSync('gemini-cli-secrets', salt, 32); + } + + private encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted; + } + + private decrypt(encryptedData: string): string { + const parts = encryptedData.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted data format'); + } + + const iv = Buffer.from(parts[0], 'hex'); + const authTag = Buffer.from(parts[1], 'hex'); + const encrypted = parts[2]; + + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + this.encryptionKey, + iv, + ); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } + + private async ensureDirectoryExists(): Promise { + const dir = path.dirname(this.tokenFilePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + } + + private async loadSecrets(): Promise>> { + try { + const data = await fs.readFile(this.tokenFilePath, 'utf-8'); + const decrypted = this.decrypt(data); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(decrypted) as Record>; + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const err = error as NodeJS.ErrnoException & { message?: string }; + if (err.code === 'ENOENT') { + return {}; + } + if ( + err.message?.includes('Invalid encrypted data format') || + err.message?.includes( + 'Unsupported state or unable to authenticate data', + ) + ) { + throw new Error( + `Corrupted secret file detected at: ${this.tokenFilePath} +` + `Please delete or rename this file to resolve the issue.`, + ); + } + throw error; + } + } + + private async saveSecrets( + secrets: Record>, + ): Promise { + await this.ensureDirectoryExists(); + const json = JSON.stringify(secrets, null, 2); + const encrypted = this.encrypt(json); + await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 }); + } + + async setSecret(key: string, value: string): Promise { + const allSecrets = await this.loadSecrets(); + if (!allSecrets[this.serviceName]) { + allSecrets[this.serviceName] = {}; + } + allSecrets[this.serviceName][key] = value; + await this.saveSecrets(allSecrets); + } + + async getSecret(key: string): Promise { + const allSecrets = await this.loadSecrets(); + return allSecrets[this.serviceName]?.[key] || null; + } + + async deleteSecret(key: string): Promise { + const allSecrets = await this.loadSecrets(); + if (allSecrets[this.serviceName]?.[key]) { + delete allSecrets[this.serviceName][key]; + // Clean up empty service object + if (Object.keys(allSecrets[this.serviceName]).length === 0) { + delete allSecrets[this.serviceName]; + } + + if (Object.keys(allSecrets).length === 0) { + try { + await fs.unlink(this.tokenFilePath); + } catch (error: unknown) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + throw error; + } + } + } else { + await this.saveSecrets(allSecrets); + } + } else { + throw new Error(`No secret found for key: ${key}`); + } + } + + async listSecrets(): Promise { + const allSecrets = await this.loadSecrets(); + return Object.keys(allSecrets[this.serviceName] || {}); + } +} diff --git a/packages/core/src/mcp/token-storage/hybrid-secret-storage.ts b/packages/core/src/mcp/token-storage/hybrid-secret-storage.ts new file mode 100644 index 0000000000..4dad5ca3c6 --- /dev/null +++ b/packages/core/src/mcp/token-storage/hybrid-secret-storage.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { KeychainTokenStorage } from './keychain-token-storage.js'; +import { EncryptedFileSecretStorage } from './encrypted-file-secret-storage.js'; +import type { SecretStorage } from './types.js'; + +export class HybridSecretStorage implements SecretStorage { + private storage: SecretStorage | null = null; + private storageInitPromise: Promise | null = null; + private readonly serviceName: string; + + constructor(serviceName: string) { + this.serviceName = serviceName; + } + + private async initializeStorage(): Promise { + const keychainStorage = new KeychainTokenStorage(this.serviceName); + const isAvailable = await keychainStorage.isAvailable(); + if (isAvailable) { + this.storage = keychainStorage; + } else { + this.storage = new EncryptedFileSecretStorage(this.serviceName); + } + return this.storage; + } + + private async getStorage(): Promise { + if (this.storage !== null) { + return this.storage; + } + + if (!this.storageInitPromise) { + this.storageInitPromise = this.initializeStorage(); + } + + return this.storageInitPromise; + } + + async setSecret(key: string, value: string): Promise { + const storage = await this.getStorage(); + await storage.setSecret(key, value); + } + + async getSecret(key: string): Promise { + const storage = await this.getStorage(); + return storage.getSecret(key); + } + + async deleteSecret(key: string): Promise { + const storage = await this.getStorage(); + await storage.deleteSecret(key); + } + + async listSecrets(): Promise { + const storage = await this.getStorage(); + return storage.listSecrets(); + } +}