Add fallback to encrypted file storage when keychain is not available

This commit is contained in:
Christine Betts
2026-02-24 15:11:58 -05:00
parent 6510347d5b
commit 3bbd1da6a1
5 changed files with 258 additions and 91 deletions
+2
View File
@@ -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,
@@ -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<void> {
const dir = path.dirname(this.tokenFilePath);
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
}
private async loadSecrets(): Promise<Record<string, Record<string, string>>> {
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<string, 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(
`Corrupted secret file detected at: ${this.tokenFilePath}
` + `Please delete or rename this file to resolve the issue.`,
);
}
throw error;
}
}
private async saveSecrets(
secrets: Record<string, Record<string, string>>,
): Promise<void> {
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<void> {
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<string | null> {
const allSecrets = await this.loadSecrets();
return allSecrets[this.serviceName]?.[key] || null;
}
async deleteSecret(key: string): Promise<void> {
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<string[]> {
const allSecrets = await this.loadSecrets();
return Object.keys(allSecrets[this.serviceName] || {});
}
}
@@ -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<SecretStorage> | null = null;
private readonly serviceName: string;
constructor(serviceName: string) {
this.serviceName = serviceName;
}
private async initializeStorage(): Promise<SecretStorage> {
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<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();
}
}