mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 16:50:59 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4d3deb759 | |||
| fee418f210 | |||
| 161ba76f65 | |||
| 50c7195528 | |||
| 27b12484c7 | |||
| d0dc83aa64 | |||
| 508774fa20 | |||
| 0cce8082cf | |||
| b83b6a2210 |
Generated
+27
-1
@@ -2129,6 +2129,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -2271,6 +2272,7 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2451,6 +2453,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2500,6 +2503,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2874,6 +2878,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2907,6 +2912,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2961,6 +2967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -4124,6 +4131,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4398,6 +4406,7 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5323,6 +5332,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7890,6 +7900,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8410,6 +8421,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9706,6 +9718,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10006,6 +10019,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -13694,6 +13708,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13704,6 +13719,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15757,6 +15773,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15980,7 +15997,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -15988,6 +16006,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16148,6 +16167,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16355,6 +16375,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16468,6 +16489,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16480,6 +16502,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -17111,6 +17134,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17448,6 +17472,7 @@
|
||||
"shell-quote": "^1.8.3",
|
||||
"simple-git": "^3.28.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"systeminformation": "^5.25.11",
|
||||
"tree-sitter-bash": "^0.25.0",
|
||||
"undici": "^7.10.0",
|
||||
@@ -17648,6 +17673,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ExtensionStorage } from './storage.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
import prompts from 'prompts';
|
||||
import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
|
||||
import { debugLogger, HybridSecretStorage } from '@google/gemini-cli-core';
|
||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||
|
||||
export enum ExtensionSettingScope {
|
||||
@@ -78,7 +78,7 @@ export async function maybePromptForSettings(
|
||||
// The user can change the scope later using the `settings set` command.
|
||||
const scope = ExtensionSettingScope.USER;
|
||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
const keychain = new HybridSecretStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope),
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export async function getScopedEnvContents(
|
||||
workspaceDir?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const { name: extensionName } = extensionConfig;
|
||||
const keychain = new KeychainTokenStorage(
|
||||
const keychain = new HybridSecretStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||
);
|
||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||
@@ -238,7 +238,7 @@ export async function updateSetting(
|
||||
}
|
||||
|
||||
const newValue = await requestSetting(settingToUpdate);
|
||||
const keychain = new KeychainTokenStorage(
|
||||
const keychain = new HybridSecretStorage(
|
||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||
);
|
||||
|
||||
@@ -321,7 +321,7 @@ function getSettingsChanges(
|
||||
|
||||
async function clearSettings(
|
||||
envFilePath: string,
|
||||
keychain: KeychainTokenStorage,
|
||||
keychain: HybridSecretStorage,
|
||||
) {
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
|
||||
@@ -41,7 +41,7 @@ export { makeFakeConfig } from './src/test-utils/config.js';
|
||||
export * from './src/utils/pathReader.js';
|
||||
export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js';
|
||||
export { logModelSlashCommand } from './src/telemetry/loggers.js';
|
||||
export { KeychainTokenStorage } from './src/mcp/token-storage/keychain-token-storage.js';
|
||||
export * from './src/mcp/token-storage/index.js';
|
||||
export * from './src/utils/googleQuotaErrors.js';
|
||||
export type { GoogleApiError } from './src/utils/googleErrors.js';
|
||||
export { getCodeAssistServer } from './src/code_assist/codeAssist.js';
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as path from 'node:path';
|
||||
import { FileSecretStorage } from './file-secret-storage.js';
|
||||
|
||||
vi.mock('node:fs', () => {
|
||||
const actualFs = vi.importActual('node:fs');
|
||||
return {
|
||||
...actualFs,
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
},
|
||||
statSync: vi.fn(() => ({
|
||||
ino: 12345,
|
||||
birthtimeMs: 67890,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => cb(null, { stdout: '' }),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(() => '/home/test'),
|
||||
hostname: vi.fn(() => 'test-host'),
|
||||
userInfo: vi.fn(
|
||||
() =>
|
||||
({ username: 'test-user' }) as unknown as ReturnType<
|
||||
typeof actual.userInfo
|
||||
>,
|
||||
),
|
||||
platform: vi.fn(
|
||||
() => 'darwin' as unknown as ReturnType<typeof actual.platform>,
|
||||
),
|
||||
default: {
|
||||
...actual,
|
||||
homedir: vi.fn(() => '/home/test'),
|
||||
hostname: vi.fn(() => 'test-host'),
|
||||
userInfo: vi.fn(
|
||||
() =>
|
||||
({ username: 'test-user' }) as unknown as ReturnType<
|
||||
typeof actual.userInfo
|
||||
>,
|
||||
),
|
||||
platform: vi.fn(
|
||||
() => 'darwin' as unknown as ReturnType<typeof actual.platform>,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('systeminformation', () => ({
|
||||
default: {
|
||||
uuid: vi.fn(async () => ({
|
||||
os: 'os-uuid',
|
||||
hardware: 'hw-uuid',
|
||||
})),
|
||||
baseboard: vi.fn(async () => ({
|
||||
serial: 'baseboard-serial',
|
||||
})),
|
||||
diskLayout: vi.fn(async () => [{ serialNum: 'disk-serial' }]),
|
||||
networkInterfaces: vi.fn(async () => [{ mac: '00:11:22:33:44:55' }]),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FileSecretStorage', () => {
|
||||
let storage: FileSecretStorage;
|
||||
const mockFs = fs as unknown as {
|
||||
readFile: ReturnType<typeof vi.fn>;
|
||||
writeFile: ReturnType<typeof vi.fn>;
|
||||
unlink: ReturnType<typeof vi.fn>;
|
||||
mkdir: ReturnType<typeof vi.fn>;
|
||||
rename: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
storage = new FileSecretStorage('test-service');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set and get a secret using stable hardware binding', async () => {
|
||||
const storedData = new Map<string, string>();
|
||||
mockFs.readFile.mockImplementation(async (filePath: string) => {
|
||||
const data = storedData.get(filePath);
|
||||
if (data === undefined) {
|
||||
const error = new Error(`File not found: ${filePath}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
mockFs.writeFile.mockImplementation(
|
||||
async (filePath: string, data: unknown) => {
|
||||
if (typeof data === 'string') {
|
||||
storedData.set(filePath, data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mockFs.rename.mockImplementation(
|
||||
async (oldPath: string, newPath: string) => {
|
||||
const data = storedData.get(oldPath);
|
||||
if (data !== undefined) {
|
||||
storedData.set(newPath, data);
|
||||
storedData.delete(oldPath);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await storage.setSecret('key1', 'value1');
|
||||
|
||||
// Check v3 path
|
||||
const stealthPath = path.join(
|
||||
'/home/test',
|
||||
'.gemini',
|
||||
'.sys-test-service-v3.db',
|
||||
);
|
||||
const finalContent = storedData.get(stealthPath);
|
||||
expect(finalContent).toBeDefined();
|
||||
expect(finalContent).toMatch(/^v2:/);
|
||||
|
||||
// Re-read
|
||||
const storage2 = new FileSecretStorage('test-service');
|
||||
const value = await storage2.getSecret('key1');
|
||||
expect(value).toBe('value1');
|
||||
});
|
||||
|
||||
it('should migrate from previous unstable cache files', async () => {
|
||||
// Legacy derivation for 'v1'
|
||||
const machineIdentifier =
|
||||
'os-uuid|hw-uuid|baseboard-serial|test-host|test-user';
|
||||
const password = 'gemini-cli-secret-v1-test-service-';
|
||||
const salt = crypto
|
||||
.createHash('sha256')
|
||||
.update(`salt-${machineIdentifier}`)
|
||||
.digest();
|
||||
const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const json = JSON.stringify({ oldKey: 'oldValue' });
|
||||
let encrypted = cipher.update(json, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
const v1Data = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
|
||||
// Previous version paths
|
||||
const v2Path = path.join(
|
||||
'/home/test',
|
||||
'.gemini',
|
||||
'.sys-test-service-cache.db',
|
||||
);
|
||||
|
||||
mockFs.readFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === v2Path) return v1Data;
|
||||
const error = new Error('File not found');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
});
|
||||
|
||||
const value = await storage.getSecret('oldKey');
|
||||
expect(value).toBe('oldValue');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 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 { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import si from 'systeminformation';
|
||||
import type { SecretStorage } from './types.js';
|
||||
import { GEMINI_DIR, homedir } from '../../utils/paths.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Type guard for NodeJS.ErrnoException
|
||||
*/
|
||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
('code' in error ||
|
||||
'errno' in error ||
|
||||
'path' in error ||
|
||||
'syscall' in error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted file-based storage for secrets, used as a fallback
|
||||
* when a system keychain (like keytar) is unavailable.
|
||||
*
|
||||
* Security features:
|
||||
* 1. AES-256-GCM encryption for authenticated encryption.
|
||||
* 2. Key derivation using scrypt with random salt (v2) or machine-unique salt (v1).
|
||||
* 3. Deep Hardware Binding: Incorporates Baseboard Serials and Machine UUIDs.
|
||||
* 4. Supports optional user-provided master key via GEMINI_MASTER_KEY.
|
||||
* 5. Strict file system permissions (0600).
|
||||
* 6. Hardcoded pepper for key derivation.
|
||||
* 7. Increased scrypt cost parameters (N=65536).
|
||||
* 8. System Keychain CLI (security/secret-tool) integration for Master Key storage.
|
||||
* 9. Installation ID: A hidden 3rd-factor file (.gemini_id) in the home directory.
|
||||
* 10. Plaintext Padding: Random noise added to secrets to obfuscate data length and count.
|
||||
* 11. Secret-Level Double-Encryption: Each secret is individually encrypted with a key derived from its name.
|
||||
* 12. Atomic Writes: Uses temporary files and renames to prevent data corruption.
|
||||
*/
|
||||
export class FileSecretStorage implements SecretStorage {
|
||||
private readonly secretFilePath: string;
|
||||
private readonly installationIdPath: string;
|
||||
private encryptionKey: Buffer | null = null;
|
||||
private readonly serviceName: string;
|
||||
private readonly sanitizedService: string;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
// Pepper to add extra entropy to the password
|
||||
private static readonly PEPPER = '334994f0-3773-45a8-940a-5d468134703b';
|
||||
|
||||
private static machineIdentifierCache: string | null = null;
|
||||
|
||||
constructor(serviceName: string) {
|
||||
const configDir = path.join(homedir(), GEMINI_DIR);
|
||||
this.sanitizedService = serviceName.replace(/[^a-zA-Z0-9-_.]/g, '_');
|
||||
|
||||
// v3 filename to clear unstable previous versions and fix atomic write corruption
|
||||
this.secretFilePath = path.join(
|
||||
configDir,
|
||||
`.sys-${this.sanitizedService}-v3.db`,
|
||||
);
|
||||
this.installationIdPath = path.join(configDir, 'installation_id');
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves or creates a unique installation ID.
|
||||
*/
|
||||
private async getInstallationId(): Promise<string> {
|
||||
try {
|
||||
const id = await fs.readFile(this.installationIdPath, 'utf-8');
|
||||
return id.trim();
|
||||
} catch {
|
||||
const newId = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.installationIdPath), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(this.installationIdPath, newId, { mode: 0o600 });
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInitialized(
|
||||
salt?: Buffer,
|
||||
version: 'v1' | 'v2' = 'v2',
|
||||
): Promise<void> {
|
||||
if (this.encryptionKey) {
|
||||
return;
|
||||
}
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = (async () => {
|
||||
this.encryptionKey = await this.deriveEncryptionKey(salt, version);
|
||||
})();
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async getPersistentSystemSecret(): Promise<string | null> {
|
||||
const account = `gemini-cli-master-${this.sanitizedService}`;
|
||||
const service = 'gemini-cli-secret-storage';
|
||||
|
||||
try {
|
||||
if (os.platform() === 'darwin') {
|
||||
const { stdout } = await execAsync(
|
||||
`security find-generic-password -s "${service}" -a "${account}" -w`,
|
||||
);
|
||||
return stdout.trim();
|
||||
} else if (os.platform() === 'linux') {
|
||||
const { stdout } = await execAsync(
|
||||
`secret-tool lookup service "${service}" account "${account}"`,
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
}
|
||||
} catch {
|
||||
// Not found
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async setPersistentSystemSecret(secret: string): Promise<void> {
|
||||
const account = `gemini-cli-master-${this.sanitizedService}`;
|
||||
const service = 'gemini-cli-secret-storage';
|
||||
|
||||
try {
|
||||
if (os.platform() === 'darwin') {
|
||||
await execAsync(
|
||||
`security add-generic-password -s "${service}" -a "${account}" -w "${secret}" -U`,
|
||||
);
|
||||
} else if (os.platform() === 'linux') {
|
||||
await execAsync(
|
||||
`echo "${secret}" | secret-tool store --label="Gemini CLI Secret Key" service "${service}" account "${account}"`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Failed to store
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a strong encryption key with stable hardware binding.
|
||||
*/
|
||||
private async deriveEncryptionKey(
|
||||
providedSalt?: Buffer,
|
||||
version: 'v1' | 'v2' = 'v2',
|
||||
): Promise<Buffer> {
|
||||
if (!FileSecretStorage.machineIdentifierCache) {
|
||||
let osUuid = '';
|
||||
let hwUuid = '';
|
||||
let baseboardSerial = '';
|
||||
|
||||
try {
|
||||
const uuid = await si.uuid();
|
||||
osUuid = uuid.os || '';
|
||||
hwUuid = uuid.hardware || '';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
const baseboard = await si.baseboard();
|
||||
baseboardSerial = baseboard.serial || '';
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Fingerprint structure MUST be stable even if values are missing
|
||||
FileSecretStorage.machineIdentifierCache = [
|
||||
osUuid,
|
||||
hwUuid,
|
||||
baseboardSerial,
|
||||
os.hostname() || 'unknown-host',
|
||||
os.userInfo().username || 'unknown-user',
|
||||
].join('|');
|
||||
}
|
||||
const machineIdentifier = FileSecretStorage.machineIdentifierCache;
|
||||
|
||||
const userSecret = process.env['GEMINI_MASTER_KEY'] || '';
|
||||
|
||||
let systemSecret = '';
|
||||
if (version === 'v2') {
|
||||
systemSecret = (await this.getPersistentSystemSecret()) || '';
|
||||
if (!systemSecret && !providedSalt) {
|
||||
systemSecret = crypto.randomBytes(32).toString('hex');
|
||||
await this.setPersistentSystemSecret(systemSecret);
|
||||
}
|
||||
}
|
||||
|
||||
const installationId =
|
||||
version === 'v2' ? await this.getInstallationId() : '';
|
||||
|
||||
const password =
|
||||
version === 'v2'
|
||||
? `${FileSecretStorage.PEPPER}-v4-${this.sanitizedService}-${machineIdentifier}-${systemSecret}-${installationId}-${userSecret}`
|
||||
: `gemini-cli-secret-v1-${this.serviceName}-${userSecret}`;
|
||||
|
||||
let salt: Buffer;
|
||||
if (providedSalt) {
|
||||
salt = providedSalt;
|
||||
} else {
|
||||
salt = crypto
|
||||
.createHash('sha256')
|
||||
.update(`salt-${machineIdentifier}`)
|
||||
.digest();
|
||||
}
|
||||
|
||||
const N = version === 'v2' ? 65536 : 16384;
|
||||
const r = 8;
|
||||
const p = 1;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(
|
||||
password,
|
||||
salt,
|
||||
32,
|
||||
{ N, r, p, maxmem: 128 * 1024 * 1024 },
|
||||
(err, derivedKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(derivedKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary AES-256-GCM encryption
|
||||
*/
|
||||
private encryptBlob(text: string, key: Buffer): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary AES-256-GCM decryption
|
||||
*/
|
||||
private decryptBlob(encryptedData: string, key: Buffer): 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', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret-Level Inner Encryption (Double-Lock)
|
||||
* Derives a unique key for EACH secret based on its name.
|
||||
*/
|
||||
private async innerEncrypt(key: string, value: string): Promise<string> {
|
||||
if (!this.encryptionKey) throw new Error('Not initialized');
|
||||
|
||||
// Quick HKDF-like derivation for inner key
|
||||
const innerKey = crypto
|
||||
.createHmac('sha256', this.encryptionKey)
|
||||
.update(`inner-key-${key}`)
|
||||
.digest();
|
||||
|
||||
return this.encryptBlob(value, innerKey);
|
||||
}
|
||||
|
||||
private async innerDecrypt(
|
||||
key: string,
|
||||
encryptedValue: string,
|
||||
): Promise<string> {
|
||||
if (!this.encryptionKey) throw new Error('Not initialized');
|
||||
|
||||
const innerKey = crypto
|
||||
.createHmac('sha256', this.encryptionKey)
|
||||
.update(`inner-key-${key}`)
|
||||
.digest();
|
||||
|
||||
return this.decryptBlob(encryptedValue, innerKey);
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists(): Promise<void> {
|
||||
const dir = path.dirname(this.secretFilePath);
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
private async loadSecrets(): Promise<Record<string, string>> {
|
||||
let data: string;
|
||||
let isMigration = false;
|
||||
try {
|
||||
data = await fs.readFile(this.secretFilePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
if (isErrnoException(error) && error.code === 'ENOENT') {
|
||||
isMigration = true;
|
||||
try {
|
||||
const configDir = path.dirname(this.secretFilePath);
|
||||
const oldPath = path.join(
|
||||
configDir,
|
||||
`secrets-${this.sanitizedService}.json`,
|
||||
);
|
||||
data = await fs.readFile(oldPath, 'utf-8');
|
||||
} catch {
|
||||
// Check for previous unstable cache files
|
||||
try {
|
||||
const configDir = path.dirname(this.secretFilePath);
|
||||
const unstablePath = path.join(
|
||||
configDir,
|
||||
`.sys-${this.sanitizedService}-cache.db`,
|
||||
);
|
||||
data = await fs.readFile(unstablePath, 'utf-8');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let decrypted: string;
|
||||
try {
|
||||
if (data.startsWith('v2:')) {
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 6) throw new Error('Invalid v2 format');
|
||||
|
||||
const salt = Buffer.from(parts[1], 'hex');
|
||||
await this.ensureInitialized(salt, 'v2');
|
||||
|
||||
if (!this.encryptionKey) throw new Error('Init failed');
|
||||
decrypted = this.decryptBlob(
|
||||
`${parts[2]}:${parts[3]}:${parts[4]}`,
|
||||
this.encryptionKey,
|
||||
);
|
||||
} else {
|
||||
await this.ensureInitialized(undefined, 'v1');
|
||||
if (!this.encryptionKey) throw new Error('Init failed');
|
||||
decrypted = this.decryptBlob(data, this.encryptionKey);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isMigration) {
|
||||
// If migration fails, start fresh instead of blocking
|
||||
return {};
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (
|
||||
message.includes('Invalid') ||
|
||||
message.includes('Unsupported state') ||
|
||||
message.includes('authTag') ||
|
||||
message.includes('mac check failed')
|
||||
) {
|
||||
throw new Error('Secret file corrupted or master key incorrect');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
let json = decrypted;
|
||||
const isV2 = data.startsWith('v2:');
|
||||
|
||||
if (isV2) {
|
||||
try {
|
||||
const parsedWrapper = JSON.parse(decrypted) as unknown;
|
||||
if (
|
||||
parsedWrapper &&
|
||||
typeof parsedWrapper === 'object' &&
|
||||
'data' in parsedWrapper &&
|
||||
typeof parsedWrapper.data === 'string'
|
||||
) {
|
||||
json = parsedWrapper.data;
|
||||
}
|
||||
} catch {
|
||||
// No padding
|
||||
}
|
||||
}
|
||||
|
||||
const parsedBlob = JSON.parse(json) as unknown;
|
||||
if (parsedBlob === null || typeof parsedBlob !== 'object') {
|
||||
throw new Error('Secret file content is not a valid object');
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
// Perform inner decryption for each secret
|
||||
for (const [key, value] of Object.entries(parsedBlob)) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
// v2 files use inner encryption. v1 files are plaintext inside the blob.
|
||||
result[key] = isV2 ? await this.innerDecrypt(key, value) : value;
|
||||
} catch {
|
||||
// If inner decrypt fails, maybe it's a v1-to-v2 migration mid-step
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isMigration) return {};
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (
|
||||
message.includes('Invalid') ||
|
||||
message.includes('Unsupported state') ||
|
||||
message.includes('authTag') ||
|
||||
message.includes('mac check failed')
|
||||
) {
|
||||
throw new Error('Secret file corrupted or master key incorrect');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSecrets(secrets: Record<string, string>): Promise<void> {
|
||||
await this.ensureDirectoryExists();
|
||||
|
||||
const salt = crypto.randomBytes(32);
|
||||
this.encryptionKey = null;
|
||||
this.initPromise = null;
|
||||
|
||||
// Atomic Write: Prepare temp file
|
||||
const tempPath = `${this.secretFilePath}.tmp`;
|
||||
|
||||
await this.ensureInitialized(salt, 'v2');
|
||||
if (!this.encryptionKey) throw new Error('Init failed');
|
||||
|
||||
// Inner encryption for each secret
|
||||
const innerEncryptedSecrets: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
innerEncryptedSecrets[key] = await this.innerEncrypt(key, value);
|
||||
}
|
||||
|
||||
const json = JSON.stringify(innerEncryptedSecrets);
|
||||
const wrapper = {
|
||||
data: json,
|
||||
noise: crypto
|
||||
.randomBytes(32 + Math.floor(Math.random() * 128))
|
||||
.toString('hex'),
|
||||
};
|
||||
|
||||
const encryptedBlob = this.encryptBlob(
|
||||
JSON.stringify(wrapper),
|
||||
this.encryptionKey,
|
||||
);
|
||||
const [iv, authTag, ciphertext] = encryptedBlob.split(':');
|
||||
|
||||
const dataToHash = `${salt.toString('hex')}:${iv}:${authTag}:${ciphertext}`;
|
||||
const checksum = crypto
|
||||
.createHash('sha256')
|
||||
.update(dataToHash)
|
||||
.digest('hex')
|
||||
.substring(0, 8);
|
||||
const finalData = `v2:${salt.toString('hex')}:${iv}:${authTag}:${ciphertext}:${checksum}`;
|
||||
|
||||
// Atomic Write: Save to temp and rename
|
||||
await fs.writeFile(tempPath, finalData, { mode: 0o600 });
|
||||
await fs.rename(tempPath, this.secretFilePath);
|
||||
}
|
||||
|
||||
async setSecret(key: string, value: string): Promise<void> {
|
||||
const secrets = await this.loadSecrets();
|
||||
secrets[key] = value;
|
||||
await this.saveSecrets(secrets);
|
||||
this.wipeKey();
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
const secrets = await this.loadSecrets();
|
||||
const result = secrets[key] ?? null;
|
||||
this.wipeKey();
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
const secrets = await this.loadSecrets();
|
||||
if (!(key in secrets)) {
|
||||
throw new Error(`No secret found for key: ${key}`);
|
||||
}
|
||||
delete secrets[key];
|
||||
await this.saveSecrets(secrets);
|
||||
this.wipeKey();
|
||||
}
|
||||
|
||||
async listSecrets(): Promise<string[]> {
|
||||
const secrets = await this.loadSecrets();
|
||||
const keys = Object.keys(secrets);
|
||||
this.wipeKey();
|
||||
return keys;
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
this.wipeKey();
|
||||
try {
|
||||
await fs.unlink(this.secretFilePath);
|
||||
} catch (error: unknown) {
|
||||
if (isErrnoException(error) && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private wipeKey(): void {
|
||||
if (this.encryptionKey) {
|
||||
this.encryptionKey.fill(0);
|
||||
this.encryptionKey = null;
|
||||
}
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { FileSecretStorage } from './file-secret-storage.js';
|
||||
import type { SecretStorage } from './types.js';
|
||||
|
||||
const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
|
||||
|
||||
/**
|
||||
* A secret storage implementation that attempts to use the system keychain
|
||||
* first, falling back to an encrypted local file if the keychain is
|
||||
* unavailable or fails to initialize.
|
||||
*/
|
||||
export class HybridSecretStorage implements SecretStorage {
|
||||
private storage: SecretStorage | null = null;
|
||||
private storageInitPromise: Promise<SecretStorage> | null = null;
|
||||
private readonly serviceName: string;
|
||||
|
||||
constructor(serviceName: string) {
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
private async initializeStorage(): Promise<SecretStorage> {
|
||||
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
|
||||
|
||||
if (!forceFileStorage) {
|
||||
try {
|
||||
const { KeychainTokenStorage } = await import(
|
||||
'./keychain-token-storage.js'
|
||||
);
|
||||
const keychainStorage = new KeychainTokenStorage(this.serviceName);
|
||||
|
||||
const isAvailable = await keychainStorage.isAvailable();
|
||||
if (isAvailable) {
|
||||
this.storage = keychainStorage;
|
||||
return this.storage;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to file storage if keychain fails to initialize
|
||||
}
|
||||
}
|
||||
|
||||
this.storage = new FileSecretStorage(this.serviceName);
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
private async getStorage(): Promise<SecretStorage> {
|
||||
if (this.storage !== null) {
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
if (!this.storageInitPromise) {
|
||||
this.storageInitPromise = this.initializeStorage();
|
||||
}
|
||||
|
||||
return this.storageInitPromise;
|
||||
}
|
||||
|
||||
async setSecret(key: string, value: string): Promise<void> {
|
||||
const storage = await this.getStorage();
|
||||
await storage.setSecret(key, value);
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
const storage = await this.getStorage();
|
||||
return storage.getSecret(key);
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
const storage = await this.getStorage();
|
||||
await storage.deleteSecret(key);
|
||||
}
|
||||
|
||||
async listSecrets(): Promise<string[]> {
|
||||
const storage = await this.getStorage();
|
||||
return storage.listSecrets();
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
// Hybrid storage is always available because it can fall back to file
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
export * from './types.js';
|
||||
export * from './base-token-storage.js';
|
||||
export * from './file-token-storage.js';
|
||||
export * from './keychain-token-storage.js';
|
||||
export * from './hybrid-token-storage.js';
|
||||
export * from './file-secret-storage.js';
|
||||
export * from './hybrid-secret-storage.js';
|
||||
|
||||
export const DEFAULT_SERVICE_NAME = 'gemini-cli-oauth';
|
||||
export const FORCE_ENCRYPTED_FILE_ENV_VAR =
|
||||
'GEMINI_FORCE_ENCRYPTED_FILE_STORAGE';
|
||||
export const DEFAULT_SERVICE_NAME = 'gemini-cli-mcp';
|
||||
export const FORCE_ENCRYPTED_FILE_ENV_VAR = 'GEMINI_FORCE_ENCRYPTED_FILE';
|
||||
|
||||
@@ -32,27 +32,30 @@ export class KeychainTokenStorage
|
||||
{
|
||||
private keychainAvailable: boolean | null = null;
|
||||
private keytarModule: Keytar | null = null;
|
||||
private keytarLoadAttempted = false;
|
||||
private keytarPromise: Promise<Keytar | null> | null = null;
|
||||
private availabilityPromise: Promise<boolean> | null = null;
|
||||
|
||||
async getKeytar(): Promise<Keytar | null> {
|
||||
// If we've already tried loading (successfully or not), return the result
|
||||
if (this.keytarLoadAttempted) {
|
||||
return this.keytarModule;
|
||||
if (this.keytarPromise) {
|
||||
return this.keytarPromise;
|
||||
}
|
||||
|
||||
this.keytarLoadAttempted = true;
|
||||
this.keytarPromise = (async () => {
|
||||
try {
|
||||
// Try to import keytar without any timeout - let the OS handle it
|
||||
const moduleName = 'keytar';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const module = await import(moduleName);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
this.keytarModule = module.default || module;
|
||||
return this.keytarModule;
|
||||
} catch (_) {
|
||||
//Keytar is optional so we shouldn't raise an error of log anything.
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
// Try to import keytar without any timeout - let the OS handle it
|
||||
const moduleName = 'keytar';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const module = await import(moduleName);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
this.keytarModule = module.default || module;
|
||||
} catch (_) {
|
||||
//Keytar is optional so we shouldn't raise an error of log anything.
|
||||
}
|
||||
return this.keytarModule;
|
||||
return this.keytarPromise;
|
||||
}
|
||||
|
||||
async getCredentials(serverName: string): Promise<OAuthCredentials | null> {
|
||||
@@ -240,49 +243,59 @@ export class KeychainTokenStorage
|
||||
}
|
||||
}
|
||||
|
||||
// Checks whether or not a set-get-delete cycle with the keychain works.
|
||||
// Returns false if any operation fails.
|
||||
async checkKeychainAvailability(): Promise<boolean> {
|
||||
if (this.keychainAvailable !== null) {
|
||||
return this.keychainAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const keytar = await this.getKeytar();
|
||||
if (!keytar) {
|
||||
this.keychainAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
|
||||
const testPassword = 'test';
|
||||
|
||||
await keytar.setPassword(this.serviceName, testAccount, testPassword);
|
||||
const retrieved = await keytar.getPassword(this.serviceName, testAccount);
|
||||
const deleted = await keytar.deletePassword(
|
||||
this.serviceName,
|
||||
testAccount,
|
||||
);
|
||||
|
||||
const success = deleted && retrieved === testPassword;
|
||||
this.keychainAvailable = success;
|
||||
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(success),
|
||||
);
|
||||
|
||||
return success;
|
||||
} catch (_error) {
|
||||
this.keychainAvailable = false;
|
||||
|
||||
// Do not log the raw error message to avoid potential PII leaks
|
||||
// (e.g. from OS-level error messages containing file paths)
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(false),
|
||||
);
|
||||
|
||||
return false;
|
||||
if (this.availabilityPromise) {
|
||||
return this.availabilityPromise;
|
||||
}
|
||||
|
||||
this.availabilityPromise = (async () => {
|
||||
try {
|
||||
const keytar = await this.getKeytar();
|
||||
if (!keytar) {
|
||||
this.keychainAvailable = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
|
||||
const testPassword = 'test';
|
||||
|
||||
await keytar.setPassword(this.serviceName, testAccount, testPassword);
|
||||
const retrieved = await keytar.getPassword(
|
||||
this.serviceName,
|
||||
testAccount,
|
||||
);
|
||||
const deleted = await keytar.deletePassword(
|
||||
this.serviceName,
|
||||
testAccount,
|
||||
);
|
||||
|
||||
const success = deleted && retrieved === testPassword;
|
||||
this.keychainAvailable = success;
|
||||
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(success),
|
||||
);
|
||||
|
||||
return success;
|
||||
} catch (_error) {
|
||||
this.keychainAvailable = false;
|
||||
|
||||
// Do not log the raw error message to avoid potential PII leaks
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(false),
|
||||
);
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
this.availabilityPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.availabilityPromise;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
|
||||
Reference in New Issue
Block a user