mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-17 15:23:08 -07:00
fix(core): add encrypted file fallback for secret storage
Implements FileSecretStorage and HybridSecretStorage to allow the CLI to fall back to a secure encrypted local file when the system keychain is unavailable (e.g. headless Linux). Updates Extension Settings to use this hybrid approach.
This commit is contained in:
@@ -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<Record<string, string>> {
|
||||
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, '');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> {
|
||||
const dir = path.dirname(this.secretFilePath);
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
private async loadSecrets(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const data = await fs.readFile(this.secretFilePath, 'utf-8');
|
||||
const decrypted = this.decrypt(data);
|
||||
return JSON.parse(decrypted) as Record<string, string>;
|
||||
} 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<string, string>): Promise<void> {
|
||||
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<void> {
|
||||
const secrets = await this.loadSecrets();
|
||||
secrets[key] = value;
|
||||
await this.saveSecrets(secrets);
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
const secrets = await this.loadSecrets();
|
||||
return secrets[key] ?? null;
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
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<string[]> {
|
||||
const secrets = await this.loadSecrets();
|
||||
return Object.keys(secrets);
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SecretStorage> | null = null;
|
||||
private readonly serviceName: string;
|
||||
|
||||
constructor(serviceName: string) {
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
private async initializeStorage(): Promise<SecretStorage> {
|
||||
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<SecretStorage> {
|
||||
if (this.storage !== null) {
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
if (!this.storageInitPromise) {
|
||||
this.storageInitPromise = this.initializeStorage();
|
||||
}
|
||||
|
||||
return this.storageInitPromise;
|
||||
}
|
||||
|
||||
async setSecret(key: string, value: string): Promise<void> {
|
||||
const storage = await this.getStorage();
|
||||
await storage.setSecret(key, value);
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
const storage = await this.getStorage();
|
||||
return storage.getSecret(key);
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
const storage = await this.getStorage();
|
||||
await storage.deleteSecret(key);
|
||||
}
|
||||
|
||||
async listSecrets(): Promise<string[]> {
|
||||
const storage = await this.getStorage();
|
||||
return storage.listSecrets();
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
// Hybrid storage is always available because it can fall back to file
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user