diff --git a/packages/core/src/mcp/token-storage/file-secret-storage.test.ts b/packages/core/src/mcp/token-storage/file-secret-storage.test.ts index fd0f98df38..c8e7f40414 100644 --- a/packages/core/src/mcp/token-storage/file-secret-storage.test.ts +++ b/packages/core/src/mcp/token-storage/file-secret-storage.test.ts @@ -102,7 +102,7 @@ describe('FileSecretStorage', () => { vi.clearAllMocks(); }); - it('should set and get a secret using deep hardware binding and double encryption', async () => { + it('should set and get a secret using stable hardware binding', async () => { const storedData = new Map(); mockFs.readFile.mockImplementation(async (filePath: string) => { const data = storedData.get(filePath); @@ -135,31 +135,26 @@ describe('FileSecretStorage', () => { await storage.setSecret('key1', 'value1'); + // Check v3 path const stealthPath = path.join( '/home/test', '.gemini', - '.sys-test-service-cache.db', + '.sys-test-service-v3.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 + // Re-read 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) + it('should migrate from previous unstable cache files', async () => { + // Legacy derivation for 'v1' const machineIdentifier = - 'os-uuid-hw-uuid-baseboard-serial-disk-serial-00:11:22:33:44:55-test-host-test-user'; + 'os-uuid|hw-uuid|baseboard-serial|test-host|test-user'; const password = 'gemini-cli-secret-v1-test-service-'; const salt = crypto .createHash('sha256') @@ -175,14 +170,15 @@ describe('FileSecretStorage', () => { const authTag = cipher.getAuthTag(); const v1Data = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; - const oldPath = path.join( + // Previous version paths + const v2Path = path.join( '/home/test', '.gemini', - 'secrets-test-service.json', + '.sys-test-service-cache.db', ); mockFs.readFile.mockImplementation(async (filePath: string) => { - if (filePath === oldPath) return v1Data; + 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'; diff --git a/packages/core/src/mcp/token-storage/file-secret-storage.ts b/packages/core/src/mcp/token-storage/file-secret-storage.ts index 0ab1a69ba4..90be134d98 100644 --- a/packages/core/src/mcp/token-storage/file-secret-storage.ts +++ b/packages/core/src/mcp/token-storage/file-secret-storage.ts @@ -36,23 +36,23 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException { * 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, Disk Serials, and Machine UUIDs. + * 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. 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. + * 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 | null = null; // Pepper to add extra entropy to the password @@ -62,13 +62,14 @@ export class FileSecretStorage implements SecretStorage { constructor(serviceName: string) { const configDir = path.join(homedir(), GEMINI_DIR); - const sanitizedService = serviceName.replace(/[^a-zA-Z0-9-_.]/g, '_'); + 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-${sanitizedService}-cache.db`, + `.sys-${this.sanitizedService}-v3.db`, ); - this.installationIdPath = path.join(homedir(), '.gemini_id'); + this.installationIdPath = path.join(configDir, 'installation_id'); this.serviceName = serviceName; } @@ -82,9 +83,12 @@ export class FileSecretStorage implements SecretStorage { } 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 to hardware-only + // Fallback } return newId; } @@ -106,7 +110,7 @@ export class FileSecretStorage implements SecretStorage { } private async getPersistentSystemSecret(): Promise { - const account = `gemini-cli-master-${this.serviceName}`; + const account = `gemini-cli-master-${this.sanitizedService}`; const service = 'gemini-cli-secret-storage'; try { @@ -128,7 +132,7 @@ export class FileSecretStorage implements SecretStorage { } private async setPersistentSystemSecret(secret: string): Promise { - const account = `gemini-cli-master-${this.serviceName}`; + const account = `gemini-cli-master-${this.sanitizedService}`; const service = 'gemini-cli-secret-storage'; try { @@ -147,47 +151,40 @@ export class FileSecretStorage implements SecretStorage { } /** - * Derives a strong encryption key with deep hardware binding. + * Derives a strong encryption key with stable hardware binding. */ private async deriveEncryptionKey( providedSalt?: Buffer, version: 'v1' | 'v2' = 'v2', ): Promise { if (!FileSecretStorage.machineIdentifierCache) { + let osUuid = ''; + let hwUuid = ''; + let baseboardSerial = ''; + 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('-'); + const uuid = await si.uuid(); + osUuid = uuid.os || ''; + hwUuid = uuid.hardware || ''; } catch { - FileSecretStorage.machineIdentifierCache = `${os.hostname()}-${os.userInfo().username}`; + /* 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; @@ -207,7 +204,7 @@ export class FileSecretStorage implements SecretStorage { const password = version === 'v2' - ? `${FileSecretStorage.PEPPER}-v3-${this.serviceName}-${machineIdentifier}-${systemSecret}-${installationId}-${userSecret}` + ? `${FileSecretStorage.PEPPER}-v4-${this.sanitizedService}-${machineIdentifier}-${systemSecret}-${installationId}-${userSecret}` : `gemini-cli-secret-v1-${this.serviceName}-${userSecret}`; let salt: Buffer; @@ -321,17 +318,23 @@ export class FileSecretStorage implements SecretStorage { 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`, + `secrets-${this.sanitizedService}.json`, ); data = await fs.readFile(oldPath, 'utf-8'); } catch { - return {}; + // 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; @@ -422,12 +425,6 @@ export class FileSecretStorage implements SecretStorage { // 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');