From bef6119500b0238ad84f6396d2a6cabda9991554 Mon Sep 17 00:00:00 2001 From: luisfelipe-alt Date: Mon, 27 Jul 2026 10:36:48 -0700 Subject: [PATCH] fix(core): enforce explicit tag length and validation in file keychain (#28523) --- packages/core/src/services/fileKeychain.ts | 50 +++-- .../gcmTruncationReproduction.test.ts | 201 ++++++++++++++++++ 2 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/services/gcmTruncationReproduction.test.ts diff --git a/packages/core/src/services/fileKeychain.ts b/packages/core/src/services/fileKeychain.ts index 57341a59f2..e358face62 100644 --- a/packages/core/src/services/fileKeychain.ts +++ b/packages/core/src/services/fileKeychain.ts @@ -27,8 +27,15 @@ export class FileKeychain implements Keychain { } private encrypt(text: string): string { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + this.encryptionKey, + iv, + { + authTagLength: 16, + }, + ); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); @@ -48,10 +55,19 @@ export class FileKeychain implements Keychain { const authTag = Buffer.from(parts[1], 'hex'); const encrypted = parts[2]; + if (iv.length !== 12 && iv.length !== 16) { + throw new Error('Invalid IV length: Must be 12 or 16 bytes'); + } + + if (authTag.length !== 16) { + throw new Error('Invalid authentication tag length: Must be 16 bytes'); + } + const decipher = crypto.createDecipheriv( 'aes-256-gcm', this.encryptionKey, iv, + { authTagLength: 16 }, ); decipher.setAuthTag(authTag); @@ -67,30 +83,28 @@ export class FileKeychain implements Keychain { } private async loadData(): Promise>> { + let data: string; try { - const data = await fs.readFile(this.tokenFilePath, 'utf-8'); - const decrypted = this.decrypt(data); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(decrypted) as Record>; + data = await fs.readFile(this.tokenFilePath, 'utf-8'); } catch (error: unknown) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const err = error as NodeJS.ErrnoException & { message?: string }; + const err = error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { return {}; } - if ( - err.message?.includes('Invalid encrypted data format') || - err.message?.includes( - 'Unsupported state or unable to authenticate data', - ) - ) { - throw new Error( - `Corrupted credentials file detected at: ${this.tokenFilePath}\n` + - `Please delete or rename this file to resolve the issue.`, - ); - } throw error; } + + try { + const decrypted = this.decrypt(data); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(decrypted) as Record>; + } catch { + throw new Error( + `Corrupted credentials file detected at: ${this.tokenFilePath}\n` + + `Please delete or rename this file to resolve the issue.`, + ); + } } private async saveData( diff --git a/packages/core/src/services/gcmTruncationReproduction.test.ts b/packages/core/src/services/gcmTruncationReproduction.test.ts new file mode 100644 index 0000000000..84bc11f622 --- /dev/null +++ b/packages/core/src/services/gcmTruncationReproduction.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2026 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 path from 'node:path'; +import * as os from 'node:os'; +import * as crypto from 'node:crypto'; +import { FileKeychain } from './fileKeychain.js'; + +describe('AES-GCM Tag Length Verification', () => { + let tempDir: string; + + beforeEach(async () => { + // Create a unique temporary directory for test isolation + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-keychain-')); + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + // Clean up the temporary directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should use a secure 128-bit (16-byte) AES-GCM authentication tag and standard 12-byte IV', async () => { + const keychain = new FileKeychain(); + const service = 'test-service'; + const account = 'test-account'; + const password = 'secure-password-123'; + + // 1. Save credentials to trigger encryption and file write + await keychain.setPassword(service, account, password); + + // 2. Read the raw encrypted file from disk + const credentialsFilePath = path.join( + tempDir, + '.gemini', + 'gemini-credentials.json', + ); + const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8'); + + // 3. Parse the encrypted data format (iv:authTag:encrypted) + const parts = rawEncryptedData.split(':'); + expect(parts).toHaveLength(3); + + const ivHex = parts[0]; + const authTagHex = parts[1]; + + // 4. Verify the lengths of the components + const ivBuffer = Buffer.from(ivHex, 'hex'); + const authTagBuffer = Buffer.from(authTagHex, 'hex'); + + // IV should be exactly 12 bytes (96 bits) by default + expect(ivBuffer.length).toBe(12); + expect(ivHex.length).toBe(24); + + // Authentication Tag should be exactly 16 bytes (128 bits) + expect(authTagBuffer.length).toBe(16); + expect(authTagHex.length).toBe(32); // 32 hex characters + + // Assert that the tag is NOT truncated to 4 bytes (32 bits) + expect(authTagBuffer.length).not.toBe(4); + expect(authTagHex.length).not.toBe(8); // 8 hex characters + + // 5. Verify that decryption works correctly with the 16-byte tag + const decryptedPassword = await keychain.getPassword(service, account); + expect(decryptedPassword).toBe(password); + }); + + it('should support both 12-byte and 16-byte IVs for backward compatibility', async () => { + const keychain = new FileKeychain(); + const service = 'test-service'; + const account = 'test-account'; + const password = 'secure-password-123'; + + // 1. Save credentials to trigger encryption and file write (generates 12-byte IV) + await keychain.setPassword(service, account, password); + + // 2. Verify 12-byte IV decryption works + let decryptedPassword = await keychain.getPassword(service, account); + expect(decryptedPassword).toBe(password); + + // 3. Manually simulate a legacy 16-byte IV credentials file + const credentialsFilePath = path.join( + tempDir, + '.gemini', + 'gemini-credentials.json', + ); + const legacyIv = crypto.randomBytes(16); + const encryptionKey = (keychain as unknown as { encryptionKey: Buffer }) + .encryptionKey; + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + encryptionKey, + legacyIv, + { + authTagLength: 16, + }, + ); + + let encrypted = cipher.update( + JSON.stringify({ [service]: { [account]: password } }), + 'utf8', + 'hex', + ); + encrypted += cipher.final('hex'); + const authTag = cipher.getAuthTag(); + + const legacyPayload = + legacyIv.toString('hex') + + ':' + + authTag.toString('hex') + + ':' + + encrypted; + await fs.writeFile(credentialsFilePath, legacyPayload, 'utf-8'); + + // 4. Verify 16-byte IV decryption works successfully (backward compatibility) + decryptedPassword = await keychain.getPassword(service, account); + expect(decryptedPassword).toBe(password); + }); + + it('should reject decryption of a credentials file with a truncated tag', async () => { + const keychain = new FileKeychain(); + const service = 'test-service'; + const account = 'test-account'; + const password = 'secure-password-123'; + + // 1. Save credentials to trigger encryption and file write + await keychain.setPassword(service, account, password); + + // 2. Read the raw encrypted file from disk + const credentialsFilePath = path.join( + tempDir, + '.gemini', + 'gemini-credentials.json', + ); + const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8'); + + // 3. Parse the encrypted data format (iv:authTag:encrypted) + const parts = rawEncryptedData.split(':'); + expect(parts).toHaveLength(3); + + const ivHex = parts[0]; + const authTagHex = parts[1]; + const encryptedHex = parts[2]; + + // 4. Create a truncated 4-byte tag (8 hex characters) + const truncatedTagHex = authTagHex.substring(0, 8); + const truncatedEncryptedData = `${ivHex}:${truncatedTagHex}:${encryptedHex}`; + + // 5. Overwrite the credentials file with the truncated-tag payload + await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8'); + + // 6. Attempt to retrieve the password and verify it throws a clear, handled validation error + await expect(keychain.getPassword(service, account)).rejects.toThrow( + 'Corrupted credentials file detected', + ); + }); + + it('should reject decryption of a credentials file with a truncated IV', async () => { + const keychain = new FileKeychain(); + const service = 'test-service'; + const account = 'test-account'; + const password = 'secure-password-123'; + + // 1. Save credentials to trigger encryption and file write + await keychain.setPassword(service, account, password); + + // 2. Read the raw encrypted file from disk + const credentialsFilePath = path.join( + tempDir, + '.gemini', + 'gemini-credentials.json', + ); + const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8'); + + // 3. Parse the encrypted data format (iv:authTag:encrypted) + const parts = rawEncryptedData.split(':'); + expect(parts).toHaveLength(3); + + const ivHex = parts[0]; + const authTagHex = parts[1]; + const encryptedHex = parts[2]; + + // 4. Create a truncated 4-byte IV (8 hex characters) + const truncatedIvHex = ivHex.substring(0, 8); + const truncatedEncryptedData = `${truncatedIvHex}:${authTagHex}:${encryptedHex}`; + + // 5. Overwrite the credentials file with the truncated-IV payload + await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8'); + + // 6. Attempt to retrieve the password and verify it throws a clear, handled validation error + await expect(keychain.getPassword(service, account)).rejects.toThrow( + 'Corrupted credentials file detected', + ); + }); +});