mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-13 06:40:33 -07:00
feat(core): implement unified KeychainService and migrate token storage (#21344)
This commit is contained in:
183
packages/core/src/services/keychainService.test.ts
Normal file
183
packages/core/src/services/keychainService.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { KeychainService } from './keychainService.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
type MockKeychain = {
|
||||
getPassword: Mock | undefined;
|
||||
setPassword: Mock | undefined;
|
||||
deletePassword: Mock | undefined;
|
||||
findCredentials: Mock | undefined;
|
||||
};
|
||||
|
||||
const mockKeytar: MockKeychain = {
|
||||
getPassword: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
deletePassword: vi.fn(),
|
||||
findCredentials: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('keytar', () => ({ default: mockKeytar }));
|
||||
|
||||
vi.mock('../utils/events.js', () => ({
|
||||
coreEvents: { emitTelemetryKeychainAvailability: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: { log: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('KeychainService', () => {
|
||||
let service: KeychainService;
|
||||
const SERVICE_NAME = 'test-service';
|
||||
let passwords: Record<string, string> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new KeychainService(SERVICE_NAME);
|
||||
passwords = {};
|
||||
|
||||
// Stateful mock implementation to verify behavioral correctness
|
||||
mockKeytar.setPassword?.mockImplementation((_svc, acc, val) => {
|
||||
passwords[acc] = val;
|
||||
return Promise.resolve();
|
||||
});
|
||||
mockKeytar.getPassword?.mockImplementation((_svc, acc) =>
|
||||
Promise.resolve(passwords[acc] ?? null),
|
||||
);
|
||||
mockKeytar.deletePassword?.mockImplementation((_svc, acc) => {
|
||||
const exists = !!passwords[acc];
|
||||
delete passwords[acc];
|
||||
return Promise.resolve(exists);
|
||||
});
|
||||
mockKeytar.findCredentials?.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
Object.entries(passwords).map(([account, password]) => ({
|
||||
account,
|
||||
password,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true and emit telemetry on successful functional test', async () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(mockKeytar.setPassword).toHaveBeenCalled();
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false, log error, and emit telemetry on failed functional test', async () => {
|
||||
mockKeytar.setPassword?.mockRejectedValue(new Error('locked'));
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(false);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('encountered an error'),
|
||||
'locked',
|
||||
);
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false, 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(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('failed structural validation'),
|
||||
expect.objectContaining({ getPassword: expect.any(Array) }),
|
||||
);
|
||||
expect(coreEvents.emitTelemetryKeychainAvailability).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ available: false }),
|
||||
);
|
||||
|
||||
mockKeytar.getPassword = originalMock;
|
||||
});
|
||||
|
||||
it('should log failure if functional test cycle returns false', async () => {
|
||||
mockKeytar.getPassword?.mockResolvedValue('wrong-password');
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(false);
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('functional verification failed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cache the result and handle concurrent initialization attempts once', async () => {
|
||||
await Promise.all([
|
||||
service.isAvailable(),
|
||||
service.isAvailable(),
|
||||
service.isAvailable(),
|
||||
]);
|
||||
|
||||
expect(mockKeytar.setPassword).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Operations', () => {
|
||||
beforeEach(async () => {
|
||||
await service.isAvailable();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should store, retrieve, and delete passwords correctly', async () => {
|
||||
await service.setPassword('acc1', 'secret1');
|
||||
await service.setPassword('acc2', 'secret2');
|
||||
|
||||
expect(await service.getPassword('acc1')).toBe('secret1');
|
||||
expect(await service.getPassword('acc2')).toBe('secret2');
|
||||
|
||||
const creds = await service.findCredentials();
|
||||
expect(creds).toHaveLength(2);
|
||||
expect(creds).toContainEqual({ account: 'acc1', password: 'secret1' });
|
||||
|
||||
expect(await service.deletePassword('acc1')).toBe(true);
|
||||
expect(await service.getPassword('acc1')).toBeNull();
|
||||
expect(await service.findCredentials()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getPassword should return null if key is missing', async () => {
|
||||
expect(await service.getPassword('missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
147
packages/core/src/services/keychainService.ts
Normal file
147
packages/core/src/services/keychainService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
38
packages/core/src/services/keychainTypes.ts
Normal file
38
packages/core/src/services/keychainTypes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Interface for OS-level secure storage operations.
|
||||
* Note: Method names must match the underlying library (e.g. keytar)
|
||||
* to support correct dynamic loading and schema validation.
|
||||
*/
|
||||
export interface Keychain {
|
||||
getPassword(service: string, account: string): Promise<string | null>;
|
||||
setPassword(
|
||||
service: string,
|
||||
account: string,
|
||||
password: string,
|
||||
): Promise<void>;
|
||||
deletePassword(service: string, account: string): Promise<boolean>;
|
||||
findCredentials(
|
||||
service: string,
|
||||
): Promise<Array<{ account: string; password: string }>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema to validate that a module satisfies the Keychain interface.
|
||||
*/
|
||||
export const KeychainSchema = z.object({
|
||||
getPassword: z.function(),
|
||||
setPassword: z.function(),
|
||||
deletePassword: z.function(),
|
||||
findCredentials: z.function(),
|
||||
});
|
||||
|
||||
export const KEYCHAIN_TEST_PREFIX = '__keychain_test__';
|
||||
export const SECRET_PREFIX = '__secret__';
|
||||
Reference in New Issue
Block a user