fix(core): finalize stable Master Key derivation for FileSecretStorage

Resolves 'corrupted file' errors by making the hardware fingerprint resilient to environmental changes:
- Removed unstable MAC addresses and Disk serials from the Master Key derivation.
- Refactored identifier collection to be additive and non-throwing, ensuring a consistent fingerprint structure.
- Migrated storage to v3 filename (.sys-service-v3.db) to clear previous unstable states.
- Implemented robust migration from legacy v1 and unstable v2 formats.
- Standardized sanitized service name usage across file paths and cryptographic passwords.
- Moved installation ID to GEMINI_DIR for better container/sandbox compatibility.
This commit is contained in:
galz10
2026-02-24 13:39:44 -08:00
parent 161ba76f65
commit fee418f210
2 changed files with 65 additions and 72 deletions
@@ -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<string, string>();
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';
@@ -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<void> | 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<string | null> {
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<void> {
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<Buffer> {
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');