Move keychain fallback to keychain service (#22332)

This commit is contained in:
christine betts
2026-03-13 16:57:08 -04:00
committed by GitHub
parent 1836a3a452
commit 8300be1101
10 changed files with 314 additions and 716 deletions
+160
View File
@@ -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
});
+37 -12
View File
@@ -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;
}