File size: 7,746 Bytes
7a1ad33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { spawnSync } from 'node:child_process';
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';
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. @github/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;
}
/**
* 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.
*/
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> {
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
// Try to get the native OS keychain unless file storage is requested.
const nativeKeychain = forceFileStorage
? null
: await this.getNativeKeychain();
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(nativeKeychain !== null),
);
if (nativeKeychain) {
return nativeKeychain;
}
// If native failed or was skipped, return the secure file fallback.
debugLogger.debug('Using FileKeychain fallback for secure storage.');
return new FileKeychain();
}
/**
* Attempts to load and verify the native keychain module (@github/keytar).
*/
private async getNativeKeychain(): Promise<Keychain | null> {
try {
const keychainModule = await this.loadKeychainModule();
if (!keychainModule) {
return null;
}
// Probing macOS prevents process-blocking popups when no keychain exists.
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
debugLogger.debug(
'MacOS default keychain not found; skipping functional verification.',
);
return null;
}
if (await this.isKeychainFunctional(keychainModule)) {
return keychainModule;
}
debugLogger.debug('Keychain functional verification failed or timed out');
return null;
} catch (error) {
// Avoid logging full error objects to prevent PII exposure.
const message = error instanceof Error ? error.message : String(error);
debugLogger.debug(
'Keychain initialization encountered an error:',
message,
);
return null;
}
}
// Low-level dynamic loading and structural validation.
private async loadKeychainModule(): Promise<Keychain | null> {
const moduleName = '@github/keytar';
const module: unknown = await import(moduleName);
const potential = (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.debug(
'Keychain module failed structural validation:',
result.error.flatten().fieldErrors,
);
return null;
}
// Performs a set-get-delete cycle to verify keychain functionality.
// Capped with a 2s timeout so a non-responsive Secret Service (common on
// headless Linux: WSL/SSH/Docker without gnome-keyring or D-Bus) falls back
// to FileKeychain instead of hanging the CLI indefinitely.
private async isKeychainFunctional(keychain: Keychain): Promise<boolean> {
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
const testPassword = 'test';
const probe = async (): Promise<boolean> => {
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;
};
return Promise.race([
probe(),
new Promise<false>((resolve) =>
setTimeout(() => resolve(false), 2000).unref(),
),
]);
}
/**
* MacOS-specific check to detect if a default keychain is available.
*/
private isMacOSKeychainAvailable(): boolean {
// Probing via the `security` CLI avoids a blocking OS-level popup that
// occurs when calling @github/keytar without a configured keychain.
const result = spawnSync('security', ['default-keychain'], {
encoding: 'utf8',
// We pipe stdout to read the path, but ignore stderr to suppress
// "keychain not found" errors from polluting the terminal.
stdio: ['ignore', 'pipe', 'ignore'],
});
// If the command fails or lacks output, no default keychain is configured.
if (result.error || result.status !== 0 || !result.stdout) {
return false;
}
// Validate that the returned path string is not empty.
const trimmed = result.stdout.trim();
if (!trimmed) {
return false;
}
// The output usually contains the path wrapped in double quotes.
const match = trimmed.match(/"(.*)"/);
const keychainPath = match ? match[1] : trimmed;
// Finally, verify the path exists on disk to ensure it's not a stale reference.
return !!keychainPath && fs.existsSync(keychainPath);
}
}
|