mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 17:40:44 -07:00
Move keychain fallback to keychain service (#22332)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 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 { Keychain } from './keychainTypes.js';
|
||||
import { GEMINI_DIR, homedir } from '../utils/paths.js';
|
||||
|
||||
export class FileKeychain implements Keychain {
|
||||
private readonly tokenFilePath: string;
|
||||
private readonly encryptionKey: Buffer;
|
||||
|
||||
constructor() {
|
||||
const configDir = path.join(homedir(), GEMINI_DIR);
|
||||
this.tokenFilePath = path.join(configDir, 'gemini-credentials.json');
|
||||
this.encryptionKey = this.deriveEncryptionKey();
|
||||
}
|
||||
|
||||
private deriveEncryptionKey(): Buffer {
|
||||
const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
|
||||
return crypto.scryptSync('gemini-cli-oauth', 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 loadData(): 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 credentials file detected at: ${this.tokenFilePath}\n` +
|
||||
`Please delete or rename this file to resolve the issue.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData(
|
||||
data: Record<string, Record<string, string>>,
|
||||
): Promise<void> {
|
||||
await this.ensureDirectoryExists();
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const encrypted = this.encrypt(json);
|
||||
await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });
|
||||
}
|
||||
|
||||
async getPassword(service: string, account: string): Promise<string | null> {
|
||||
const data = await this.loadData();
|
||||
return data[service]?.[account] ?? null;
|
||||
}
|
||||
|
||||
async setPassword(
|
||||
service: string,
|
||||
account: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const data = await this.loadData();
|
||||
if (!data[service]) {
|
||||
data[service] = {};
|
||||
}
|
||||
data[service][account] = password;
|
||||
await this.saveData(data);
|
||||
}
|
||||
|
||||
async deletePassword(service: string, account: string): Promise<boolean> {
|
||||
const data = await this.loadData();
|
||||
if (data[service] && account in data[service]) {
|
||||
delete data[service][account];
|
||||
|
||||
if (Object.keys(data[service]).length === 0) {
|
||||
delete data[service];
|
||||
}
|
||||
|
||||
if (Object.keys(data).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.saveData(data);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async findCredentials(
|
||||
service: string,
|
||||
): Promise<Array<{ account: string; password: string }>> {
|
||||
const data = await this.loadData();
|
||||
const serviceData = data[service] || {};
|
||||
return Object.entries(serviceData).map(([account, password]) => ({
|
||||
account,
|
||||
password,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { KeychainService } from './keychainService.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { FileKeychain } from './fileKeychain.js';
|
||||
|
||||
type MockKeychain = {
|
||||
getPassword: Mock | undefined;
|
||||
@@ -23,8 +32,19 @@ const mockKeytar: MockKeychain = {
|
||||
findCredentials: vi.fn(),
|
||||
};
|
||||
|
||||
const mockFileKeychain: MockKeychain = {
|
||||
getPassword: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
deletePassword: vi.fn(),
|
||||
findCredentials: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('keytar', () => ({ default: mockKeytar }));
|
||||
|
||||
vi.mock('./fileKeychain.js', () => ({
|
||||
FileKeychain: vi.fn(() => mockFileKeychain),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/events.js', () => ({
|
||||
coreEvents: { emitTelemetryKeychainAvailability: vi.fn() },
|
||||
}));
|
||||
@@ -37,13 +57,15 @@ describe('KeychainService', () => {
|
||||
let service: KeychainService;
|
||||
const SERVICE_NAME = 'test-service';
|
||||
let passwords: Record<string, string> = {};
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env = { ...originalEnv };
|
||||
service = new KeychainService(SERVICE_NAME);
|
||||
passwords = {};
|
||||
|
||||
// Stateful mock implementation to verify behavioral correctness
|
||||
// Stateful mock implementation for native keychain
|
||||
mockKeytar.setPassword?.mockImplementation((_svc, acc, val) => {
|
||||
passwords[acc] = val;
|
||||
return Promise.resolve();
|
||||
@@ -64,10 +86,36 @@ describe('KeychainService', () => {
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
// Stateful mock implementation for fallback file keychain
|
||||
mockFileKeychain.setPassword?.mockImplementation((_svc, acc, val) => {
|
||||
passwords[acc] = val;
|
||||
return Promise.resolve();
|
||||
});
|
||||
mockFileKeychain.getPassword?.mockImplementation((_svc, acc) =>
|
||||
Promise.resolve(passwords[acc] ?? null),
|
||||
);
|
||||
mockFileKeychain.deletePassword?.mockImplementation((_svc, acc) => {
|
||||
const exists = !!passwords[acc];
|
||||
delete passwords[acc];
|
||||
return Promise.resolve(exists);
|
||||
});
|
||||
mockFileKeychain.findCredentials?.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
Object.entries(passwords).map(([account, password]) => ({
|
||||
account,
|
||||
password,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true and emit telemetry on successful functional test', async () => {
|
||||
it('should return true and emit telemetry on successful functional test with native keychain', async () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
@@ -77,12 +125,13 @@ describe('KeychainService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false, log error, and emit telemetry on failed functional test', async () => {
|
||||
it('should return true (via fallback), log error, and emit telemetry indicating native is unavailable on failed functional test', async () => {
|
||||
mockKeytar.setPassword?.mockRejectedValue(new Error('locked'));
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(false);
|
||||
// Because it falls back to FileKeychain, it is always available.
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('encountered an error'),
|
||||
'locked',
|
||||
@@ -90,15 +139,19 @@ describe('KeychainService', () => {
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Using FileKeychain fallback'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false, log validation error, and emit telemetry on module load failure', async () => {
|
||||
it('should return true (via fallback), log validation error, and emit telemetry on module load failure', async () => {
|
||||
const originalMock = mockKeytar.getPassword;
|
||||
mockKeytar.getPassword = undefined; // Break schema
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(false);
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed structural validation'),
|
||||
expect.objectContaining({ getPassword: expect.any(Array) }),
|
||||
@@ -106,19 +159,31 @@ describe('KeychainService', () => {
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
|
||||
mockKeytar.getPassword = originalMock;
|
||||
});
|
||||
|
||||
it('should log failure if functional test cycle returns false', async () => {
|
||||
it('should log failure if functional test cycle returns false, then fallback', async () => {
|
||||
mockKeytar.getPassword?.mockResolvedValue('wrong-password');
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(false);
|
||||
expect(available).toBe(true);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('functional verification failed'),
|
||||
);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to FileKeychain when GEMINI_FORCE_FILE_STORAGE is true', async () => {
|
||||
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(true);
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cache the result and handle concurrent initialization attempts once', async () => {
|
||||
@@ -159,25 +224,5 @@ describe('KeychainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('When Unavailable', () => {
|
||||
beforeEach(() => {
|
||||
mockKeytar.setPassword?.mockRejectedValue(new Error('Unavailable'));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ method: 'getPassword', args: ['acc'] },
|
||||
{ method: 'setPassword', args: ['acc', 'val'] },
|
||||
{ method: 'deletePassword', args: ['acc'] },
|
||||
{ method: 'findCredentials', args: [] },
|
||||
])('$method should throw a consistent error', async ({ method, args }) => {
|
||||
await expect(
|
||||
(
|
||||
service as unknown as Record<
|
||||
string,
|
||||
(...args: unknown[]) => Promise<unknown>
|
||||
>
|
||||
)[method](...args),
|
||||
).rejects.toThrow('Keychain is not available');
|
||||
});
|
||||
});
|
||||
// Removing 'When Unavailable' tests since the service is always available via fallback
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
KEYCHAIN_TEST_PREFIX,
|
||||
} from './keychainTypes.js';
|
||||
import { isRecord } from '../utils/markdownUtils.js';
|
||||
import { FileKeychain } from './fileKeychain.js';
|
||||
|
||||
export const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
|
||||
|
||||
/**
|
||||
* Service for interacting with OS-level secure storage (e.g. keytar).
|
||||
@@ -31,6 +34,14 @@ export class KeychainService {
|
||||
return (await this.getKeychain()) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the service is using the encrypted file fallback backend.
|
||||
*/
|
||||
async isUsingFileFallback(): Promise<boolean> {
|
||||
const keychain = await this.getKeychain();
|
||||
return keychain instanceof FileKeychain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a secret for the given account.
|
||||
* @throws Error if the keychain is unavailable.
|
||||
@@ -85,26 +96,40 @@ export class KeychainService {
|
||||
// High-level orchestration of the loading and testing cycle.
|
||||
private async initializeKeychain(): Promise<Keychain | null> {
|
||||
let resultKeychain: Keychain | null = null;
|
||||
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
|
||||
|
||||
try {
|
||||
const keychainModule = await this.loadKeychainModule();
|
||||
if (keychainModule) {
|
||||
if (await this.isKeychainFunctional(keychainModule)) {
|
||||
resultKeychain = keychainModule;
|
||||
} else {
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
if (!forceFileStorage) {
|
||||
try {
|
||||
const keychainModule = await this.loadKeychainModule();
|
||||
if (keychainModule) {
|
||||
if (await this.isKeychainFunctional(keychainModule)) {
|
||||
resultKeychain = keychainModule;
|
||||
} else {
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.log(
|
||||
'Keychain initialization encountered an error:',
|
||||
message,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.log('Keychain initialization encountered an error:', message);
|
||||
}
|
||||
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(resultKeychain !== null),
|
||||
new KeychainAvailabilityEvent(
|
||||
resultKeychain !== null && !forceFileStorage,
|
||||
),
|
||||
);
|
||||
|
||||
// Fallback to FileKeychain if native keychain is unavailable or file storage is forced
|
||||
if (!resultKeychain) {
|
||||
resultKeychain = new FileKeychain();
|
||||
debugLogger.log('Using FileKeychain fallback for secure storage.');
|
||||
}
|
||||
|
||||
return resultKeychain;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user