Files
gemini-cli/packages/core/src/services/keychainService.ts

148 lines
4.6 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import { coreEvents } from '../utils/events.js';
import { KeychainAvailabilityEvent } from '../telemetry/types.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
type Keychain,
KeychainSchema,
KEYCHAIN_TEST_PREFIX,
} from './keychainTypes.js';
/**
* Service for interacting with OS-level secure storage (e.g. keytar).
*/
export class KeychainService {
// Track an ongoing initialization attempt to avoid race conditions.
private initializationPromise?: Promise<Keychain | null>;
/**
* @param serviceName Unique identifier for the app in the OS keychain.
*/
constructor(private readonly serviceName: string) {}
async isAvailable(): Promise<boolean> {
return (await this.getKeychain()) !== null;
}
/**
* Retrieves a secret for the given account.
* @throws Error if the keychain is unavailable.
*/
async getPassword(account: string): Promise<string | null> {
const keychain = await this.getKeychainOrThrow();
return keychain.getPassword(this.serviceName, account);
}
/**
* Securely stores a secret.
* @throws Error if the keychain is unavailable.
*/
async setPassword(account: string, value: string): Promise<void> {
const keychain = await this.getKeychainOrThrow();
await keychain.setPassword(this.serviceName, account, value);
}
/**
* Removes a secret from the keychain.
* @returns true if the secret was deleted, false otherwise.
* @throws Error if the keychain is unavailable.
*/
async deletePassword(account: string): Promise<boolean> {
const keychain = await this.getKeychainOrThrow();
return keychain.deletePassword(this.serviceName, account);
}
/**
* Lists all account/secret pairs stored under this service.
* @throws Error if the keychain is unavailable.
*/
async findCredentials(): Promise<
Array<{ account: string; password: string }>
> {
const keychain = await this.getKeychainOrThrow();
return keychain.findCredentials(this.serviceName);
}
private async getKeychainOrThrow(): Promise<Keychain> {
const keychain = await this.getKeychain();
if (!keychain) {
throw new Error('Keychain is not available');
}
return keychain;
}
private getKeychain(): Promise<Keychain | null> {
return (this.initializationPromise ??= this.initializeKeychain());
}
// High-level orchestration of the loading and testing cycle.
private async initializeKeychain(): Promise<Keychain | null> {
let resultKeychain: Keychain | null = null;
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);
}
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(resultKeychain !== null),
);
return resultKeychain;
}
// Low-level dynamic loading and structural validation.
private async loadKeychainModule(): Promise<Keychain | null> {
const moduleName = 'keytar';
const module: unknown = await import(moduleName);
const potential = (this.isRecord(module) && module['default']) || module;
const result = KeychainSchema.safeParse(potential);
if (result.success) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return potential as Keychain;
}
debugLogger.log(
'Keychain module failed structural validation:',
result.error.flatten().fieldErrors,
);
return null;
}
private isRecord(obj: unknown): obj is Record<string, unknown> {
return typeof obj === 'object' && obj !== null;
}
// Performs a set-get-delete cycle to verify keychain functionality.
private async isKeychainFunctional(keychain: Keychain): Promise<boolean> {
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
const testPassword = 'test';
await keychain.setPassword(this.serviceName, testAccount, testPassword);
const retrieved = await keychain.getPassword(this.serviceName, testAccount);
const deleted = await keychain.deletePassword(
this.serviceName,
testAccount,
);
return deleted && retrieved === testPassword;
}
}