diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 06e4f49db4..a8885ba1a3 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), ); @@ -167,7 +167,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); @@ -238,7 +238,7 @@ export async function updateSetting( } const newValue = await requestSetting(settingToUpdate); - const keychain = new KeychainTokenStorage( + const keychain = new HybridSecretStorage( getKeychainStorageName(extensionName, extensionId, scope, workspaceDir), ); @@ -321,7 +321,7 @@ function getSettingsChanges( async function clearSettings( envFilePath: string, - keychain: KeychainTokenStorage, + keychain: HybridSecretStorage, ) { if (fsSync.existsSync(envFilePath)) { await fs.writeFile(envFilePath, ''); diff --git a/packages/core/index.ts b/packages/core/index.ts index 1d5dce60d3..c4cd59bbaa 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -41,7 +41,7 @@ export { makeFakeConfig } from './src/test-utils/config.js'; export * from './src/utils/pathReader.js'; export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js'; export { logModelSlashCommand } from './src/telemetry/loggers.js'; -export { KeychainTokenStorage } from './src/mcp/token-storage/keychain-token-storage.js'; +export * from './src/mcp/token-storage/index.js'; export * from './src/utils/googleQuotaErrors.js'; export type { GoogleApiError } from './src/utils/googleErrors.js'; export { getCodeAssistServer } from './src/code_assist/codeAssist.js'; diff --git a/packages/core/src/mcp/token-storage/file-secret-storage.ts b/packages/core/src/mcp/token-storage/file-secret-storage.ts new file mode 100644 index 0000000000..9ce2d51063 --- /dev/null +++ b/packages/core/src/mcp/token-storage/file-secret-storage.ts @@ -0,0 +1,143 @@ +/** + * @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'; + +/** + * Encrypted file-based storage for secrets, used as a fallback + * when a system keychain (like keytar) is unavailable. + */ +export class FileSecretStorage implements SecretStorage { + private readonly secretFilePath: string; + private readonly encryptionKey: Buffer; + + constructor(serviceName: string) { + const configDir = path.join(homedir(), GEMINI_DIR); + // Sanitize service name for filename + const sanitizedService = serviceName.replace(/[^a-zA-Z0-9-_.]/g, '_'); + this.secretFilePath = path.join(configDir, `secrets-${sanitizedService}.json`); + this.encryptionKey = this.deriveEncryptionKey(); + } + + private deriveEncryptionKey(): Buffer { + const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli-secrets`; + 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.secretFilePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + } + + private async loadSecrets(): Promise> { + try { + const data = await fs.readFile(this.secretFilePath, 'utf-8'); + const decrypted = this.decrypt(data); + 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('Secret file corrupted'); + } + 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.secretFilePath, encrypted, { mode: 0o600 }); + } + + async setSecret(key: string, value: string): Promise { + const secrets = await this.loadSecrets(); + secrets[key] = value; + await this.saveSecrets(secrets); + } + + async getSecret(key: string): Promise { + const secrets = await this.loadSecrets(); + return secrets[key] ?? null; + } + + async deleteSecret(key: string): Promise { + const secrets = await this.loadSecrets(); + if (!(key in secrets)) { + throw new Error(`No secret found for key: ${key}`); + } + delete secrets[key]; + await this.saveSecrets(secrets); + } + + async listSecrets(): Promise { + const secrets = await this.loadSecrets(); + return Object.keys(secrets); + } + + async clearAll(): Promise { + try { + await fs.unlink(this.secretFilePath); + } 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; + } + } + } +} 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..74a23fcac6 --- /dev/null +++ b/packages/core/src/mcp/token-storage/hybrid-secret-storage.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FileSecretStorage } from './file-secret-storage.js'; +import type { SecretStorage } from './types.js'; + +const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE'; + +/** + * A secret storage implementation that attempts to use the system keychain + * first, falling back to an encrypted local file if the keychain is + * unavailable or fails to initialize. + */ +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 forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true'; + + if (!forceFileStorage) { + try { + const { KeychainTokenStorage } = await import( + './keychain-token-storage.js' + ); + const keychainStorage = new KeychainTokenStorage(this.serviceName); + + const isAvailable = await keychainStorage.isAvailable(); + if (isAvailable) { + this.storage = keychainStorage; + return this.storage; + } + } catch (_e) { + // Fallback to file storage if keychain fails to initialize + } + } + + this.storage = new FileSecretStorage(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(); + } + + async isAvailable(): Promise { + // Hybrid storage is always available because it can fall back to file + return true; + } +} diff --git a/packages/core/src/mcp/token-storage/index.ts b/packages/core/src/mcp/token-storage/index.ts index 0b48a933a9..598f6d60de 100644 --- a/packages/core/src/mcp/token-storage/index.ts +++ b/packages/core/src/mcp/token-storage/index.ts @@ -7,8 +7,7 @@ export * from './types.js'; export * from './base-token-storage.js'; export * from './file-token-storage.js'; +export * from './keychain-token-storage.js'; export * from './hybrid-token-storage.js'; - -export const DEFAULT_SERVICE_NAME = 'gemini-cli-oauth'; -export const FORCE_ENCRYPTED_FILE_ENV_VAR = - 'GEMINI_FORCE_ENCRYPTED_FILE_STORAGE'; +export * from './file-secret-storage.js'; +export * from './hybrid-secret-storage.js';