Skip to content

Commit bef6119

Browse files
fix(core): enforce explicit tag length and validation in file keychain (#28523)
1 parent b94c977 commit bef6119

2 files changed

Lines changed: 233 additions & 18 deletions

File tree

packages/core/src/services/fileKeychain.ts

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,15 @@ export class FileKeychain implements Keychain {
2727
}
2828

2929
private encrypt(text: string): string {
30-
const iv = crypto.randomBytes(16);
31-
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
30+
const iv = crypto.randomBytes(12);
31+
const cipher = crypto.createCipheriv(
32+
'aes-256-gcm',
33+
this.encryptionKey,
34+
iv,
35+
{
36+
authTagLength: 16,
37+
},
38+
);
3239

3340
let encrypted = cipher.update(text, 'utf8', 'hex');
3441
encrypted += cipher.final('hex');
@@ -48,10 +55,19 @@ export class FileKeychain implements Keychain {
4855
const authTag = Buffer.from(parts[1], 'hex');
4956
const encrypted = parts[2];
5057

58+
if (iv.length !== 12 && iv.length !== 16) {
59+
throw new Error('Invalid IV length: Must be 12 or 16 bytes');
60+
}
61+
62+
if (authTag.length !== 16) {
63+
throw new Error('Invalid authentication tag length: Must be 16 bytes');
64+
}
65+
5166
const decipher = crypto.createDecipheriv(
5267
'aes-256-gcm',
5368
this.encryptionKey,
5469
iv,
70+
{ authTagLength: 16 },
5571
);
5672
decipher.setAuthTag(authTag);
5773

@@ -67,30 +83,28 @@ export class FileKeychain implements Keychain {
6783
}
6884

6985
private async loadData(): Promise<Record<string, Record<string, string>>> {
86+
let data: string;
7087
try {
71-
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
72-
const decrypted = this.decrypt(data);
73-
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
74-
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
88+
data = await fs.readFile(this.tokenFilePath, 'utf-8');
7589
} catch (error: unknown) {
7690
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
77-
const err = error as NodeJS.ErrnoException & { message?: string };
91+
const err = error as NodeJS.ErrnoException;
7892
if (err.code === 'ENOENT') {
7993
return {};
8094
}
81-
if (
82-
err.message?.includes('Invalid encrypted data format') ||
83-
err.message?.includes(
84-
'Unsupported state or unable to authenticate data',
85-
)
86-
) {
87-
throw new Error(
88-
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
89-
`Please delete or rename this file to resolve the issue.`,
90-
);
91-
}
9295
throw error;
9396
}
97+
98+
try {
99+
const decrypted = this.decrypt(data);
100+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
101+
return JSON.parse(decrypted) as Record<string, Record<string, string>>;
102+
} catch {
103+
throw new Error(
104+
`Corrupted credentials file detected at: ${this.tokenFilePath}\n` +
105+
`Please delete or rename this file to resolve the issue.`,
106+
);
107+
}
94108
}
95109

96110
private async saveData(
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8+
import { promises as fs } from 'node:fs';
9+
import * as path from 'node:path';
10+
import * as os from 'node:os';
11+
import * as crypto from 'node:crypto';
12+
import { FileKeychain } from './fileKeychain.js';
13+
14+
describe('AES-GCM Tag Length Verification', () => {
15+
let tempDir: string;
16+
17+
beforeEach(async () => {
18+
// Create a unique temporary directory for test isolation
19+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-keychain-'));
20+
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
21+
});
22+
23+
afterEach(async () => {
24+
vi.unstubAllEnvs();
25+
// Clean up the temporary directory
26+
await fs.rm(tempDir, { recursive: true, force: true });
27+
});
28+
29+
it('should use a secure 128-bit (16-byte) AES-GCM authentication tag and standard 12-byte IV', async () => {
30+
const keychain = new FileKeychain();
31+
const service = 'test-service';
32+
const account = 'test-account';
33+
const password = 'secure-password-123';
34+
35+
// 1. Save credentials to trigger encryption and file write
36+
await keychain.setPassword(service, account, password);
37+
38+
// 2. Read the raw encrypted file from disk
39+
const credentialsFilePath = path.join(
40+
tempDir,
41+
'.gemini',
42+
'gemini-credentials.json',
43+
);
44+
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
45+
46+
// 3. Parse the encrypted data format (iv:authTag:encrypted)
47+
const parts = rawEncryptedData.split(':');
48+
expect(parts).toHaveLength(3);
49+
50+
const ivHex = parts[0];
51+
const authTagHex = parts[1];
52+
53+
// 4. Verify the lengths of the components
54+
const ivBuffer = Buffer.from(ivHex, 'hex');
55+
const authTagBuffer = Buffer.from(authTagHex, 'hex');
56+
57+
// IV should be exactly 12 bytes (96 bits) by default
58+
expect(ivBuffer.length).toBe(12);
59+
expect(ivHex.length).toBe(24);
60+
61+
// Authentication Tag should be exactly 16 bytes (128 bits)
62+
expect(authTagBuffer.length).toBe(16);
63+
expect(authTagHex.length).toBe(32); // 32 hex characters
64+
65+
// Assert that the tag is NOT truncated to 4 bytes (32 bits)
66+
expect(authTagBuffer.length).not.toBe(4);
67+
expect(authTagHex.length).not.toBe(8); // 8 hex characters
68+
69+
// 5. Verify that decryption works correctly with the 16-byte tag
70+
const decryptedPassword = await keychain.getPassword(service, account);
71+
expect(decryptedPassword).toBe(password);
72+
});
73+
74+
it('should support both 12-byte and 16-byte IVs for backward compatibility', async () => {
75+
const keychain = new FileKeychain();
76+
const service = 'test-service';
77+
const account = 'test-account';
78+
const password = 'secure-password-123';
79+
80+
// 1. Save credentials to trigger encryption and file write (generates 12-byte IV)
81+
await keychain.setPassword(service, account, password);
82+
83+
// 2. Verify 12-byte IV decryption works
84+
let decryptedPassword = await keychain.getPassword(service, account);
85+
expect(decryptedPassword).toBe(password);
86+
87+
// 3. Manually simulate a legacy 16-byte IV credentials file
88+
const credentialsFilePath = path.join(
89+
tempDir,
90+
'.gemini',
91+
'gemini-credentials.json',
92+
);
93+
const legacyIv = crypto.randomBytes(16);
94+
const encryptionKey = (keychain as unknown as { encryptionKey: Buffer })
95+
.encryptionKey;
96+
const cipher = crypto.createCipheriv(
97+
'aes-256-gcm',
98+
encryptionKey,
99+
legacyIv,
100+
{
101+
authTagLength: 16,
102+
},
103+
);
104+
105+
let encrypted = cipher.update(
106+
JSON.stringify({ [service]: { [account]: password } }),
107+
'utf8',
108+
'hex',
109+
);
110+
encrypted += cipher.final('hex');
111+
const authTag = cipher.getAuthTag();
112+
113+
const legacyPayload =
114+
legacyIv.toString('hex') +
115+
':' +
116+
authTag.toString('hex') +
117+
':' +
118+
encrypted;
119+
await fs.writeFile(credentialsFilePath, legacyPayload, 'utf-8');
120+
121+
// 4. Verify 16-byte IV decryption works successfully (backward compatibility)
122+
decryptedPassword = await keychain.getPassword(service, account);
123+
expect(decryptedPassword).toBe(password);
124+
});
125+
126+
it('should reject decryption of a credentials file with a truncated tag', async () => {
127+
const keychain = new FileKeychain();
128+
const service = 'test-service';
129+
const account = 'test-account';
130+
const password = 'secure-password-123';
131+
132+
// 1. Save credentials to trigger encryption and file write
133+
await keychain.setPassword(service, account, password);
134+
135+
// 2. Read the raw encrypted file from disk
136+
const credentialsFilePath = path.join(
137+
tempDir,
138+
'.gemini',
139+
'gemini-credentials.json',
140+
);
141+
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
142+
143+
// 3. Parse the encrypted data format (iv:authTag:encrypted)
144+
const parts = rawEncryptedData.split(':');
145+
expect(parts).toHaveLength(3);
146+
147+
const ivHex = parts[0];
148+
const authTagHex = parts[1];
149+
const encryptedHex = parts[2];
150+
151+
// 4. Create a truncated 4-byte tag (8 hex characters)
152+
const truncatedTagHex = authTagHex.substring(0, 8);
153+
const truncatedEncryptedData = `${ivHex}:${truncatedTagHex}:${encryptedHex}`;
154+
155+
// 5. Overwrite the credentials file with the truncated-tag payload
156+
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
157+
158+
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
159+
await expect(keychain.getPassword(service, account)).rejects.toThrow(
160+
'Corrupted credentials file detected',
161+
);
162+
});
163+
164+
it('should reject decryption of a credentials file with a truncated IV', async () => {
165+
const keychain = new FileKeychain();
166+
const service = 'test-service';
167+
const account = 'test-account';
168+
const password = 'secure-password-123';
169+
170+
// 1. Save credentials to trigger encryption and file write
171+
await keychain.setPassword(service, account, password);
172+
173+
// 2. Read the raw encrypted file from disk
174+
const credentialsFilePath = path.join(
175+
tempDir,
176+
'.gemini',
177+
'gemini-credentials.json',
178+
);
179+
const rawEncryptedData = await fs.readFile(credentialsFilePath, 'utf-8');
180+
181+
// 3. Parse the encrypted data format (iv:authTag:encrypted)
182+
const parts = rawEncryptedData.split(':');
183+
expect(parts).toHaveLength(3);
184+
185+
const ivHex = parts[0];
186+
const authTagHex = parts[1];
187+
const encryptedHex = parts[2];
188+
189+
// 4. Create a truncated 4-byte IV (8 hex characters)
190+
const truncatedIvHex = ivHex.substring(0, 8);
191+
const truncatedEncryptedData = `${truncatedIvHex}:${authTagHex}:${encryptedHex}`;
192+
193+
// 5. Overwrite the credentials file with the truncated-IV payload
194+
await fs.writeFile(credentialsFilePath, truncatedEncryptedData, 'utf-8');
195+
196+
// 6. Attempt to retrieve the password and verify it throws a clear, handled validation error
197+
await expect(keychain.getPassword(service, account)).rejects.toThrow(
198+
'Corrupted credentials file detected',
199+
);
200+
});
201+
});

0 commit comments

Comments
 (0)