mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 17:40:44 -07:00
feat(core): enhance FileSecretStorage with deep hardware binding and double encryption
Implements elite security features for file-based secret storage fallback: - Deep Hardware Binding: Cryptographically ties secrets to Baseboard, Disk, and MAC serials. - Secret-Level Double-Encryption: Individually encrypts each secret within the vault. - Multi-Factor Sharding: Incorporates a hidden installation ID (~/.gemini_id) as a physical shard. - Atomic Operations: Prevents file corruption using temp-write and atomic rename. - Stealth Obfuscation: Uses binary-like naming and random padding to hide data length. - Graceful Degradation: Automatically handles headless environments without D-Bus. - Full backward compatibility with automatic upgrade from v1. - Removes noisy console.error in Keychain availability check.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 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 crypto from 'node:crypto';
|
||||
import * as path from 'node:path';
|
||||
import { FileSecretStorage } from './file-secret-storage.js';
|
||||
|
||||
vi.mock('node:fs', () => {
|
||||
const actualFs = vi.importActual('node:fs');
|
||||
return {
|
||||
...actualFs,
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
},
|
||||
statsSync: vi.fn((_p: string) => ({
|
||||
ino: 12345,
|
||||
birthtimeMs: 67890,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: vi.fn(
|
||||
(
|
||||
_cmd: string,
|
||||
cb: (err: Error | null, result: { stdout: string }) => void,
|
||||
) => cb(null, { stdout: '' }),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(() => '/home/test'),
|
||||
hostname: vi.fn(() => 'test-host'),
|
||||
userInfo: vi.fn(
|
||||
() =>
|
||||
({ username: 'test-user' }) as unknown as ReturnType<
|
||||
typeof actual.userInfo
|
||||
>,
|
||||
),
|
||||
platform: vi.fn(
|
||||
() => 'darwin' as unknown as ReturnType<typeof actual.platform>,
|
||||
),
|
||||
default: {
|
||||
...actual,
|
||||
homedir: vi.fn(() => '/home/test'),
|
||||
hostname: vi.fn(() => 'test-host'),
|
||||
userInfo: vi.fn(
|
||||
() =>
|
||||
({ username: 'test-user' }) as unknown as ReturnType<
|
||||
typeof actual.userInfo
|
||||
>,
|
||||
),
|
||||
platform: vi.fn(
|
||||
() => 'darwin' as unknown as ReturnType<typeof actual.platform>,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('systeminformation', () => ({
|
||||
default: {
|
||||
uuid: vi.fn(async () => ({
|
||||
os: 'os-uuid',
|
||||
hardware: 'hw-uuid',
|
||||
})),
|
||||
baseboard: vi.fn(async () => ({
|
||||
serial: 'baseboard-serial',
|
||||
})),
|
||||
diskLayout: vi.fn(async () => [{ serialNum: 'disk-serial' }]),
|
||||
networkInterfaces: vi.fn(async () => [{ mac: '00:11:22:33:44:55' }]),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FileSecretStorage', () => {
|
||||
let storage: FileSecretStorage;
|
||||
const mockFs = fs as unknown as {
|
||||
readFile: ReturnType<typeof vi.fn>;
|
||||
writeFile: ReturnType<typeof vi.fn>;
|
||||
unlink: ReturnType<typeof vi.fn>;
|
||||
mkdir: ReturnType<typeof vi.fn>;
|
||||
rename: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
storage = new FileSecretStorage('test-service');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set and get a secret using deep hardware binding and double encryption', async () => {
|
||||
const storedData = new Map<string, string>();
|
||||
mockFs.readFile.mockImplementation(async (filePath: string) => {
|
||||
const data = storedData.get(filePath);
|
||||
if (data === undefined) {
|
||||
const error = new Error(`File not found: ${filePath}`);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
mockFs.writeFile.mockImplementation(
|
||||
async (filePath: string, data: unknown) => {
|
||||
if (typeof data === 'string') {
|
||||
storedData.set(filePath, data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
mockFs.rename.mockImplementation(
|
||||
async (oldPath: string, newPath: string) => {
|
||||
const data = storedData.get(oldPath);
|
||||
if (data !== undefined) {
|
||||
storedData.set(newPath, data);
|
||||
storedData.delete(oldPath);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await storage.setSecret('key1', 'value1');
|
||||
|
||||
const stealthPath = path.join(
|
||||
'/home/test',
|
||||
'.gemini',
|
||||
'.sys-test-service-cache.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
|
||||
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)
|
||||
const machineIdentifier =
|
||||
'os-uuid-hw-uuid-baseboard-serial-disk-serial-00:11:22:33:44:55-test-host-test-user';
|
||||
const password = 'gemini-cli-secret-v1-test-service-';
|
||||
const salt = crypto
|
||||
.createHash('sha256')
|
||||
.update(`salt-${machineIdentifier}`)
|
||||
.digest();
|
||||
const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const json = JSON.stringify({ oldKey: 'oldValue' });
|
||||
let encrypted = cipher.update(json, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const authTag = cipher.getAuthTag();
|
||||
const v1Data = `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||
|
||||
const oldPath = path.join(
|
||||
'/home/test',
|
||||
'.gemini',
|
||||
'secrets-test-service.json',
|
||||
);
|
||||
|
||||
mockFs.readFile.mockImplementation(async (filePath: string) => {
|
||||
if (filePath === oldPath) return v1Data;
|
||||
const error = new Error('File not found');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).code = 'ENOENT';
|
||||
throw error;
|
||||
});
|
||||
|
||||
const value = await storage.getSecret('oldKey');
|
||||
expect(value).toBe('oldValue');
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,18 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { promises as fs, statSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import si from 'systeminformation';
|
||||
import type { SecretStorage } from './types.js';
|
||||
import { GEMINI_DIR, homedir } from '../../utils/paths.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Type guard for NodeJS.ErrnoException
|
||||
*/
|
||||
@@ -31,79 +35,211 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
*
|
||||
* Security features:
|
||||
* 1. AES-256-GCM encryption for authenticated encryption.
|
||||
* 2. Key derivation using scrypt with machine-unique salt.
|
||||
* 3. Incorporates hardware identifiers (Machine ID, Disk UUID).
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export class FileSecretStorage implements SecretStorage {
|
||||
private readonly secretFilePath: string;
|
||||
private readonly installationIdPath: string;
|
||||
private encryptionKey: Buffer | null = null;
|
||||
private readonly serviceName: string;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
// Pepper to add extra entropy to the password
|
||||
private static readonly PEPPER = '334994f0-3773-45a8-940a-5d468134703b';
|
||||
|
||||
private static machineIdentifierCache: string | null = null;
|
||||
|
||||
constructor(serviceName: string) {
|
||||
const configDir = path.join(homedir(), GEMINI_DIR);
|
||||
const sanitizedService = serviceName.replace(/[^a-zA-Z0-9-_.]/g, '_');
|
||||
|
||||
this.secretFilePath = path.join(
|
||||
configDir,
|
||||
`secrets-${sanitizedService}.json`,
|
||||
`.sys-${sanitizedService}-cache.db`,
|
||||
);
|
||||
this.installationIdPath = path.join(homedir(), '.gemini_id');
|
||||
this.serviceName = serviceName;
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
/**
|
||||
* Retrieves or creates a unique installation ID.
|
||||
*/
|
||||
private async getInstallationId(): Promise<string> {
|
||||
try {
|
||||
const id = await fs.readFile(this.installationIdPath, 'utf-8');
|
||||
return id.trim();
|
||||
} catch {
|
||||
const newId = crypto.randomBytes(32).toString('hex');
|
||||
try {
|
||||
await fs.writeFile(this.installationIdPath, newId, { mode: 0o600 });
|
||||
} catch {
|
||||
// Fallback to hardware-only
|
||||
}
|
||||
return newId;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInitialized(
|
||||
salt?: Buffer,
|
||||
version: 'v1' | 'v2' = 'v2',
|
||||
): Promise<void> {
|
||||
if (this.encryptionKey) {
|
||||
return;
|
||||
}
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = (async () => {
|
||||
this.encryptionKey = await this.deriveEncryptionKey();
|
||||
this.encryptionKey = await this.deriveEncryptionKey(salt, version);
|
||||
})();
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a strong encryption key using:
|
||||
* - Machine-specific identifiers (Machine ID, UUIDs)
|
||||
* - User-specific identifiers (Username)
|
||||
* - Optional user-provided master key (GEMINI_MASTER_KEY)
|
||||
*/
|
||||
private async deriveEncryptionKey(): Promise<Buffer> {
|
||||
let machineIdentifier = '';
|
||||
private async getPersistentSystemSecret(): Promise<string | null> {
|
||||
const account = `gemini-cli-master-${this.serviceName}`;
|
||||
const service = 'gemini-cli-secret-storage';
|
||||
|
||||
try {
|
||||
// Collect multiple hardware-bound identifiers
|
||||
const uuid = await si.uuid();
|
||||
machineIdentifier = [
|
||||
uuid.os,
|
||||
uuid.hardware,
|
||||
os.hostname(),
|
||||
os.userInfo().username,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
if (os.platform() === 'darwin') {
|
||||
const { stdout } = await execAsync(
|
||||
`security find-generic-password -s "${service}" -a "${account}" -w`,
|
||||
);
|
||||
return stdout.trim();
|
||||
} else if (os.platform() === 'linux') {
|
||||
const { stdout } = await execAsync(
|
||||
`secret-tool lookup service "${service}" account "${account}"`,
|
||||
);
|
||||
return stdout.trim() || null;
|
||||
}
|
||||
} catch {
|
||||
// Fallback if systeminformation fails
|
||||
machineIdentifier = `${os.hostname()}-${os.userInfo().username}`;
|
||||
// Not found
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async setPersistentSystemSecret(secret: string): Promise<void> {
|
||||
const account = `gemini-cli-master-${this.serviceName}`;
|
||||
const service = 'gemini-cli-secret-storage';
|
||||
|
||||
try {
|
||||
if (os.platform() === 'darwin') {
|
||||
await execAsync(
|
||||
`security add-generic-password -s "${service}" -a "${account}" -w "${secret}" -U`,
|
||||
);
|
||||
} else if (os.platform() === 'linux') {
|
||||
await execAsync(
|
||||
`echo "${secret}" | secret-tool store --label="Gemini CLI Secret Key" service "${service}" account "${account}"`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Failed to store
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a strong encryption key with deep hardware binding.
|
||||
*/
|
||||
private async deriveEncryptionKey(
|
||||
providedSalt?: Buffer,
|
||||
version: 'v1' | 'v2' = 'v2',
|
||||
): Promise<Buffer> {
|
||||
if (!FileSecretStorage.machineIdentifierCache) {
|
||||
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('-');
|
||||
} catch {
|
||||
FileSecretStorage.machineIdentifierCache = `${os.hostname()}-${os.userInfo().username}`;
|
||||
}
|
||||
}
|
||||
const machineIdentifier = FileSecretStorage.machineIdentifierCache;
|
||||
|
||||
const userSecret = process.env['GEMINI_MASTER_KEY'] || '';
|
||||
|
||||
let fsBinding = '';
|
||||
if (version === 'v2') {
|
||||
try {
|
||||
const stats = statSync(this.secretFilePath);
|
||||
fsBinding = `${stats.ino}-${stats.birthtimeMs}`;
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
}
|
||||
|
||||
// Allow user to provide extra entropy via environment variable
|
||||
const userSecret = process.env['GEMINI_MASTER_KEY'] || '';
|
||||
const password = `gemini-cli-secret-v1-${this.serviceName}-${userSecret}`;
|
||||
const salt = crypto
|
||||
.createHash('sha256')
|
||||
.update(`salt-${machineIdentifier}`)
|
||||
.digest();
|
||||
let systemSecret = '';
|
||||
if (version === 'v2') {
|
||||
systemSecret = (await this.getPersistentSystemSecret()) || '';
|
||||
if (!systemSecret && !providedSalt) {
|
||||
systemSecret = crypto.randomBytes(32).toString('hex');
|
||||
await this.setPersistentSystemSecret(systemSecret);
|
||||
}
|
||||
}
|
||||
|
||||
const installationId =
|
||||
version === 'v2' ? await this.getInstallationId() : '';
|
||||
|
||||
const password =
|
||||
version === 'v2'
|
||||
? `${FileSecretStorage.PEPPER}-v2-${this.serviceName}-${machineIdentifier}-${fsBinding}-${systemSecret}-${installationId}-${userSecret}`
|
||||
: `gemini-cli-secret-v1-${this.serviceName}-${userSecret}`;
|
||||
|
||||
let salt: Buffer;
|
||||
if (providedSalt) {
|
||||
salt = providedSalt;
|
||||
} else {
|
||||
salt = crypto
|
||||
.createHash('sha256')
|
||||
.update(`salt-${machineIdentifier}`)
|
||||
.digest();
|
||||
}
|
||||
|
||||
const N = version === 'v2' ? 65536 : 16384;
|
||||
const r = 8;
|
||||
const p = 1;
|
||||
|
||||
// Use strong scrypt parameters
|
||||
// N=16384 (cost), r=8 (block size), p=1 (parallelization)
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(
|
||||
password,
|
||||
salt,
|
||||
32,
|
||||
{ N: 16384, r: 8, p: 1 },
|
||||
{ N, r, p, maxmem: 128 * 1024 * 1024 },
|
||||
(err, derivedKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
@@ -115,12 +251,12 @@ export class FileSecretStorage implements SecretStorage {
|
||||
});
|
||||
}
|
||||
|
||||
private encrypt(text: string): string {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Storage not initialized');
|
||||
}
|
||||
/**
|
||||
* Primary AES-256-GCM encryption
|
||||
*/
|
||||
private encryptBlob(text: string, key: Buffer): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
@@ -130,10 +266,10 @@ export class FileSecretStorage implements SecretStorage {
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
private decrypt(encryptedData: string): string {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Storage not initialized');
|
||||
}
|
||||
/**
|
||||
* Primary AES-256-GCM decryption
|
||||
*/
|
||||
private decryptBlob(encryptedData: string, key: Buffer): string {
|
||||
const parts = encryptedData.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted data format');
|
||||
@@ -143,11 +279,7 @@ export class FileSecretStorage implements SecretStorage {
|
||||
const authTag = Buffer.from(parts[1], 'hex');
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.encryptionKey,
|
||||
iv,
|
||||
);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
@@ -156,40 +288,133 @@ export class FileSecretStorage implements SecretStorage {
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret-Level Inner Encryption (Double-Lock)
|
||||
* Derives a unique key for EACH secret based on its name.
|
||||
*/
|
||||
private async innerEncrypt(key: string, value: string): Promise<string> {
|
||||
if (!this.encryptionKey) throw new Error('Not initialized');
|
||||
|
||||
// Quick HKDF-like derivation for inner key
|
||||
const innerKey = crypto
|
||||
.createHmac('sha256', this.encryptionKey)
|
||||
.update(`inner-key-${key}`)
|
||||
.digest();
|
||||
|
||||
return this.encryptBlob(value, innerKey);
|
||||
}
|
||||
|
||||
private async innerDecrypt(
|
||||
key: string,
|
||||
encryptedValue: string,
|
||||
): Promise<string> {
|
||||
if (!this.encryptionKey) throw new Error('Not initialized');
|
||||
|
||||
const innerKey = crypto
|
||||
.createHmac('sha256', this.encryptionKey)
|
||||
.update(`inner-key-${key}`)
|
||||
.digest();
|
||||
|
||||
return this.decryptBlob(encryptedValue, innerKey);
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists(): Promise<void> {
|
||||
const dir = path.dirname(this.secretFilePath);
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
private async loadSecrets(): Promise<Record<string, string>> {
|
||||
await this.ensureInitialized();
|
||||
let data: string;
|
||||
try {
|
||||
const data = await fs.readFile(this.secretFilePath, 'utf-8');
|
||||
const decrypted = this.decrypt(data);
|
||||
const parsed = JSON.parse(decrypted) as unknown;
|
||||
data = await fs.readFile(this.secretFilePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
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`,
|
||||
);
|
||||
data = await fs.readFile(oldPath, 'utf-8');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed === null || typeof parsed !== 'object') {
|
||||
let decrypted: string;
|
||||
try {
|
||||
if (data.startsWith('v2:')) {
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 6) throw new Error('Invalid v2 format');
|
||||
|
||||
const salt = Buffer.from(parts[1], 'hex');
|
||||
await this.ensureInitialized(salt, 'v2');
|
||||
|
||||
if (!this.encryptionKey) throw new Error('Init failed');
|
||||
decrypted = this.decryptBlob(
|
||||
`${parts[2]}:${parts[3]}:${parts[4]}`,
|
||||
this.encryptionKey,
|
||||
);
|
||||
} else {
|
||||
await this.ensureInitialized(undefined, 'v1');
|
||||
if (!this.encryptionKey) throw new Error('Init failed');
|
||||
decrypted = this.decryptBlob(data, this.encryptionKey);
|
||||
}
|
||||
|
||||
let json = decrypted;
|
||||
const isV2 = data.startsWith('v2:');
|
||||
|
||||
if (isV2) {
|
||||
try {
|
||||
const parsedWrapper = JSON.parse(decrypted) as unknown;
|
||||
if (
|
||||
parsedWrapper &&
|
||||
typeof parsedWrapper === 'object' &&
|
||||
'data' in parsedWrapper &&
|
||||
typeof parsedWrapper.data === 'string'
|
||||
) {
|
||||
json = parsedWrapper.data;
|
||||
}
|
||||
} catch {
|
||||
// No padding
|
||||
}
|
||||
}
|
||||
|
||||
const parsedBlob = JSON.parse(json) as unknown;
|
||||
if (parsedBlob === null || typeof parsedBlob !== 'object') {
|
||||
throw new Error('Secret file content is not a valid object');
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
|
||||
// Perform inner decryption for each secret
|
||||
for (const [key, value] of Object.entries(parsedBlob)) {
|
||||
if (typeof value === 'string') {
|
||||
result[key] = value;
|
||||
try {
|
||||
// v2 files use inner encryption. v1 files are plaintext inside the blob.
|
||||
result[key] = isV2 ? await this.innerDecrypt(key, value) : value;
|
||||
} catch {
|
||||
// If inner decrypt fails, maybe it's a v1-to-v2 migration mid-step
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isErrnoException(error) && error.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
|
||||
if (
|
||||
message.includes('Invalid encrypted data format') ||
|
||||
message.includes('Unsupported state or unable to authenticate data')
|
||||
message.includes('Invalid') ||
|
||||
message.includes('Unsupported state') ||
|
||||
message.includes('authTag') ||
|
||||
message.includes('mac check failed')
|
||||
) {
|
||||
throw new Error('Secret file corrupted or master key incorrect');
|
||||
}
|
||||
@@ -198,24 +423,69 @@ export class FileSecretStorage implements SecretStorage {
|
||||
}
|
||||
|
||||
private async saveSecrets(secrets: Record<string, string>): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await this.ensureDirectoryExists();
|
||||
|
||||
const json = JSON.stringify(secrets, null, 2);
|
||||
const encrypted = this.encrypt(json);
|
||||
const salt = crypto.randomBytes(32);
|
||||
this.encryptionKey = null;
|
||||
this.initPromise = null;
|
||||
|
||||
await fs.writeFile(this.secretFilePath, encrypted, { mode: 0o600 });
|
||||
// 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');
|
||||
|
||||
// Inner encryption for each secret
|
||||
const innerEncryptedSecrets: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(secrets)) {
|
||||
innerEncryptedSecrets[key] = await this.innerEncrypt(key, value);
|
||||
}
|
||||
|
||||
const json = JSON.stringify(innerEncryptedSecrets);
|
||||
const wrapper = {
|
||||
data: json,
|
||||
noise: crypto
|
||||
.randomBytes(32 + Math.floor(Math.random() * 128))
|
||||
.toString('hex'),
|
||||
};
|
||||
|
||||
const encryptedBlob = this.encryptBlob(
|
||||
JSON.stringify(wrapper),
|
||||
this.encryptionKey,
|
||||
);
|
||||
const [iv, authTag, ciphertext] = encryptedBlob.split(':');
|
||||
|
||||
const dataToHash = `${salt.toString('hex')}:${iv}:${authTag}:${ciphertext}`;
|
||||
const checksum = crypto
|
||||
.createHash('sha256')
|
||||
.update(dataToHash)
|
||||
.digest('hex')
|
||||
.substring(0, 8);
|
||||
const finalData = `v2:${salt.toString('hex')}:${iv}:${authTag}:${ciphertext}:${checksum}`;
|
||||
|
||||
// Atomic Write: Save to temp and rename
|
||||
await fs.writeFile(tempPath, finalData, { mode: 0o600 });
|
||||
await fs.rename(tempPath, this.secretFilePath);
|
||||
}
|
||||
|
||||
async setSecret(key: string, value: string): Promise<void> {
|
||||
const secrets = await this.loadSecrets();
|
||||
secrets[key] = value;
|
||||
await this.saveSecrets(secrets);
|
||||
this.wipeKey();
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
const secrets = await this.loadSecrets();
|
||||
return secrets[key] ?? null;
|
||||
const result = secrets[key] ?? null;
|
||||
this.wipeKey();
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
@@ -225,21 +495,31 @@ export class FileSecretStorage implements SecretStorage {
|
||||
}
|
||||
delete secrets[key];
|
||||
await this.saveSecrets(secrets);
|
||||
this.wipeKey();
|
||||
}
|
||||
|
||||
async listSecrets(): Promise<string[]> {
|
||||
const secrets = await this.loadSecrets();
|
||||
return Object.keys(secrets);
|
||||
const keys = Object.keys(secrets);
|
||||
this.wipeKey();
|
||||
return keys;
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
this.wipeKey();
|
||||
try {
|
||||
await fs.unlink(this.secretFilePath);
|
||||
} catch (error: unknown) {
|
||||
if (isErrnoException(error) && error.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
if (isErrnoException(error) && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private wipeKey(): void {
|
||||
if (this.encryptionKey) {
|
||||
this.encryptionKey.fill(0);
|
||||
this.encryptionKey = null;
|
||||
}
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,14 +281,10 @@ export class KeychainTokenStorage
|
||||
);
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
this.keychainAvailable = false;
|
||||
|
||||
// Log the error for debugging purposes, especially on Linux
|
||||
console.error('Keychain availability check failed:', error);
|
||||
|
||||
// Do not log the raw error message to avoid potential PII leaks
|
||||
// (e.g. from OS-level error messages containing file paths)
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(false),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user