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.
This commit is contained in:
galz10
2026-02-24 13:17:43 -08:00
parent 27b12484c7
commit 50c7195528
3 changed files with 549 additions and 78 deletions
@@ -0,0 +1,195 @@
/**
* @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(),
},
statsSync: vi.fn((_p: string) => ({
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 deep hardware binding and double encryption', 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');
const stealthPath = path.join(
'/home/test',
'.gemini',
'.sys-test-service-cache.db',
);
const finalContent = storedData.get(stealthPath);
expect(finalContent).toBeDefined();
expect(finalContent).toMatch(/^v2:/);
// Check Installation ID
expect(storedData.has('/home/test/.gemini_id')).toBe(true);
// Verify atomic operation (temp file was used)
expect(mockFs.rename).toHaveBeenCalled();
// Re-read with a new instance to verify consistency
const storage2 = new FileSecretStorage('test-service');
const value = await storage2.getSecret('key1');
expect(value).toBe('value1');
});
it('should migrate legacy v1 files and upgrade to v2 double-encryption', async () => {
// Manually construct a legacy V1 file (No inner encryption)
const machineIdentifier =
'os-uuid-hw-uuid-baseboard-serial-disk-serial-00:11:22:33:44:55-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}`;
const oldPath = path.join(
'/home/test',
'.gemini',
'secrets-test-service.json',
);
mockFs.readFile.mockImplementation(async (filePath: string) => {
if (filePath === oldPath) 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');
});
});
@@ -4,14 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import { promises as fs, statSync } 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
*/
@@ -31,79 +35,211 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
*
* Security features:
* 1. AES-256-GCM encryption for authenticated encryption.
* 2. Key derivation using scrypt with machine-unique salt.
* 3. Incorporates hardware identifiers (Machine ID, Disk UUID).
* 2. Key derivation using scrypt with random salt (v2) or machine-unique salt (v1).
* 3. Deep Hardware Binding: Incorporates Baseboard Serials, Disk 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. Environmental binding to file Inode and Birthtime to detect illegal moves.
* 10. Installation ID: A hidden 3rd-factor file (.gemini_id) in the home directory.
* 11. Plaintext Padding: Random noise added to secrets to obfuscate data length and count.
* 12. Secret-Level Double-Encryption: Each secret is individually encrypted with a key derived from its name.
* 13. 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 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);
const sanitizedService = serviceName.replace(/[^a-zA-Z0-9-_.]/g, '_');
this.secretFilePath = path.join(
configDir,
`secrets-${sanitizedService}.json`,
`.sys-${sanitizedService}-cache.db`,
);
this.installationIdPath = path.join(homedir(), '.gemini_id');
this.serviceName = serviceName;
}
private async ensureInitialized(): Promise<void> {
/**
* 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.writeFile(this.installationIdPath, newId, { mode: 0o600 });
} catch {
// Fallback to hardware-only
}
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();
this.encryptionKey = await this.deriveEncryptionKey(salt, version);
})();
}
return this.initPromise;
}
/**
* Derives a strong encryption key using:
* - Machine-specific identifiers (Machine ID, UUIDs)
* - User-specific identifiers (Username)
* - Optional user-provided master key (GEMINI_MASTER_KEY)
*/
private async deriveEncryptionKey(): Promise<Buffer> {
let machineIdentifier = '';
private async getPersistentSystemSecret(): Promise<string | null> {
const account = `gemini-cli-master-${this.serviceName}`;
const service = 'gemini-cli-secret-storage';
try {
// Collect multiple hardware-bound identifiers
const uuid = await si.uuid();
machineIdentifier = [
uuid.os,
uuid.hardware,
os.hostname(),
os.userInfo().username,
]
.filter(Boolean)
.join('-');
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 {
// Fallback if systeminformation fails
machineIdentifier = `${os.hostname()}-${os.userInfo().username}`;
// Not found
}
return null;
}
private async setPersistentSystemSecret(secret: string): Promise<void> {
const account = `gemini-cli-master-${this.serviceName}`;
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 deep hardware binding.
*/
private async deriveEncryptionKey(
providedSalt?: Buffer,
version: 'v1' | 'v2' = 'v2',
): Promise<Buffer> {
if (!FileSecretStorage.machineIdentifierCache) {
try {
const [uuid, baseboard, disks, network] = await Promise.all([
si.uuid(),
si.baseboard(),
si.diskLayout(),
si.networkInterfaces(),
]);
// Deep hardware fingerprint including MAC addresses
const macs = Array.isArray(network)
? network
.map((n) => n.mac)
.filter((m) => m && m !== '00:00:00:00:00:00')
.sort()
.join(',')
: '';
FileSecretStorage.machineIdentifierCache = [
uuid.os,
uuid.hardware,
baseboard.serial,
disks
.map((d) => d.serialNum)
.filter(Boolean)
.join(','),
macs,
os.hostname(),
os.userInfo().username,
]
.filter(Boolean)
.join('-');
} catch {
FileSecretStorage.machineIdentifierCache = `${os.hostname()}-${os.userInfo().username}`;
}
}
const machineIdentifier = FileSecretStorage.machineIdentifierCache;
const userSecret = process.env['GEMINI_MASTER_KEY'] || '';
let fsBinding = '';
if (version === 'v2') {
try {
const stats = statSync(this.secretFilePath);
fsBinding = `${stats.ino}-${stats.birthtimeMs}`;
} catch {
// File doesn't exist yet
}
}
// Allow user to provide extra entropy via environment variable
const userSecret = process.env['GEMINI_MASTER_KEY'] || '';
const password = `gemini-cli-secret-v1-${this.serviceName}-${userSecret}`;
const salt = crypto
.createHash('sha256')
.update(`salt-${machineIdentifier}`)
.digest();
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}-v2-${this.serviceName}-${machineIdentifier}-${fsBinding}-${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;
// Use strong scrypt parameters
// N=16384 (cost), r=8 (block size), p=1 (parallelization)
return new Promise((resolve, reject) => {
crypto.scrypt(
password,
salt,
32,
{ N: 16384, r: 8, p: 1 },
{ N, r, p, maxmem: 128 * 1024 * 1024 },
(err, derivedKey) => {
if (err) {
reject(err);
@@ -115,12 +251,12 @@ export class FileSecretStorage implements SecretStorage {
});
}
private encrypt(text: string): string {
if (!this.encryptionKey) {
throw new Error('Storage not initialized');
}
/**
* Primary AES-256-GCM encryption
*/
private encryptBlob(text: string, key: Buffer): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
@@ -130,10 +266,10 @@ export class FileSecretStorage implements SecretStorage {
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
private decrypt(encryptedData: string): string {
if (!this.encryptionKey) {
throw new Error('Storage not initialized');
}
/**
* 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');
@@ -143,11 +279,7 @@ export class FileSecretStorage implements SecretStorage {
const authTag = Buffer.from(parts[1], 'hex');
const encrypted = parts[2];
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
iv,
);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
@@ -156,40 +288,133 @@ export class FileSecretStorage implements SecretStorage {
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>> {
await this.ensureInitialized();
let data: string;
try {
const data = await fs.readFile(this.secretFilePath, 'utf-8');
const decrypted = this.decrypt(data);
const parsed = JSON.parse(decrypted) as unknown;
data = await fs.readFile(this.secretFilePath, 'utf-8');
} catch (error: unknown) {
if (isErrnoException(error) && error.code === 'ENOENT') {
try {
const configDir = path.dirname(this.secretFilePath);
const sanitizedService = this.serviceName.replace(
/[^a-zA-Z0-9-_.]/g,
'_',
);
const oldPath = path.join(
configDir,
`secrets-${sanitizedService}.json`,
);
data = await fs.readFile(oldPath, 'utf-8');
} catch {
return {};
}
} else {
throw error;
}
}
if (parsed === null || typeof parsed !== 'object') {
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);
}
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> = {};
for (const [key, value] of Object.entries(parsed)) {
// Perform inner decryption for each secret
for (const [key, value] of Object.entries(parsedBlob)) {
if (typeof value === 'string') {
result[key] = value;
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 (isErrnoException(error) && error.code === 'ENOENT') {
return {};
}
const message = error instanceof Error ? error.message : '';
if (
message.includes('Invalid encrypted data format') ||
message.includes('Unsupported state or unable to authenticate data')
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');
}
@@ -198,24 +423,69 @@ export class FileSecretStorage implements SecretStorage {
}
private async saveSecrets(secrets: Record<string, string>): Promise<void> {
await this.ensureInitialized();
await this.ensureDirectoryExists();
const json = JSON.stringify(secrets, null, 2);
const encrypted = this.encrypt(json);
const salt = crypto.randomBytes(32);
this.encryptionKey = null;
this.initPromise = null;
await fs.writeFile(this.secretFilePath, encrypted, { mode: 0o600 });
// Atomic Write: Prepare temp file
const tempPath = `${this.secretFilePath}.tmp`;
try {
await fs.writeFile(this.secretFilePath, '', { flag: 'a', mode: 0o600 });
} catch {
/* Ignore */
}
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();
return secrets[key] ?? null;
const result = secrets[key] ?? null;
this.wipeKey();
return result;
}
async deleteSecret(key: string): Promise<void> {
@@ -225,21 +495,31 @@ export class FileSecretStorage implements SecretStorage {
}
delete secrets[key];
await this.saveSecrets(secrets);
this.wipeKey();
}
async listSecrets(): Promise<string[]> {
const secrets = await this.loadSecrets();
return Object.keys(secrets);
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;
}
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;
}
}
@@ -281,14 +281,10 @@ export class KeychainTokenStorage
);
return success;
} catch (error) {
} catch (_error) {
this.keychainAvailable = false;
// Log the error for debugging purposes, especially on Linux
console.error('Keychain availability check failed:', error);
// 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),
);