Compare commits

...

9 Commits

Author SHA1 Message Date
galz10 f4d3deb759 fix(core): implement Graceful Reset for failed secret migrations
Ensures that if an old or unstable secret file cannot be decrypted (e.g. due to the previous unstable fingerprint logic), the system will automatically start fresh instead of blocking the user with an error.

Throwing remains active for the current (v3) storage format to maintain security integrity.
2026-02-24 13:41:39 -08:00
galz10 fee418f210 fix(core): finalize stable Master Key derivation for FileSecretStorage
Resolves 'corrupted file' errors by making the hardware fingerprint resilient to environmental changes:
- Removed unstable MAC addresses and Disk serials from the Master Key derivation.
- Refactored identifier collection to be additive and non-throwing, ensuring a consistent fingerprint structure.
- Migrated storage to v3 filename (.sys-service-v3.db) to clear previous unstable states.
- Implemented robust migration from legacy v1 and unstable v2 formats.
- Standardized sanitized service name usage across file paths and cryptographic passwords.
- Moved installation ID to GEMINI_DIR for better container/sandbox compatibility.
2026-02-24 13:39:44 -08:00
galz10 161ba76f65 fix(core): remove filesystem binding for secret storage stability
Removes Inode and Birthtime from the Master Key derivation shards.
These identifiers were too volatile for atomic write operations (which change inodes) and did not survive extension re-installs or file moves.

Master Key security remains extremely high, relying on:
- OS Keychain Shard (Tier 1)
- Physical Shard (~/.gemini_id) (Tier 2)
- Deep Hardware Binding (Motherboard, Disk, MAC) (Tier 3)
- Cryptographic Pepper (Tier 4)
2026-02-24 13:33:06 -08:00
galz10 50c7195528 feat(core): enhance FileSecretStorage with deep hardware binding and double encryption
Implements elite security features for file-based secret storage fallback:
- Deep Hardware Binding: Cryptographically ties secrets to Baseboard, Disk, and MAC serials.
- Secret-Level Double-Encryption: Individually encrypts each secret within the vault.
- Multi-Factor Sharding: Incorporates a hidden installation ID (~/.gemini_id) as a physical shard.
- Atomic Operations: Prevents file corruption using temp-write and atomic rename.
- Stealth Obfuscation: Uses binary-like naming and random padding to hide data length.
- Graceful Degradation: Automatically handles headless environments without D-Bus.
- Full backward compatibility with automatic upgrade from v1.
- Removes noisy console.error in Keychain availability check.
2026-02-24 13:17:43 -08:00
galz10 27b12484c7 chore: update encryption to AES-256-GCM 2026-02-24 11:55:17 -08:00
galz10 d0dc83aa64 chore: fix lint errors 2026-02-24 11:50:29 -08:00
galz10 508774fa20 fix(core): add encrypted file fallback for secret storage
Implements FileSecretStorage and HybridSecretStorage to allow the CLI
to fall back to a secure encrypted local file when the system keychain
is unavailable (e.g. headless Linux). Updates Extension Settings to
use this hybrid approach.
2026-02-24 11:44:50 -08:00
galz10 0cce8082cf wip 2026-02-24 11:35:07 -08:00
galz10 b83b6a2210 fix(core): resolve race conditions in KeychainTokenStorage loading
Ensures that multiple concurrent calls to getKeytar or checkKeychainAvailability
wait for a single operation to complete using promise-based guards. This
prevents scenarios where a slow module load or availability check could result
in a permanently cached 'unavailable' state.
2026-02-24 11:24:18 -08:00
8 changed files with 913 additions and 63 deletions
+27 -1
View File
@@ -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, '');
+1 -1
View File
@@ -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;
}
}
+5 -3
View File
@@ -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> {