fix(core): enforce explicit tag length and validation in file keychain (#28523)

This commit is contained in:
luisfelipe-alt
2026-07-27 10:36:48 -07:00
committed by GitHub
parent b94c9775b1
commit bef6119500
2 changed files with 233 additions and 18 deletions
+32 -18
View File
@@ -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<Record<string, Record<string, string>>> {
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<string, Record<string, string>>;
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<string, Record<string, string>>;
} 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(
@@ -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',
);
});
});