mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87bbc97bbf | |||
| 3bbd1da6a1 |
@@ -21,7 +21,7 @@ import { ExtensionStorage } from './storage.js';
|
|||||||
import prompts from 'prompts';
|
import prompts from 'prompts';
|
||||||
import * as fsPromises from 'node:fs/promises';
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { KeychainTokenStorage } from '@google/gemini-cli-core';
|
import { HybridSecretStorage } from '@google/gemini-cli-core';
|
||||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||||
|
|
||||||
vi.mock('prompts');
|
vi.mock('prompts');
|
||||||
@@ -38,7 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
KeychainTokenStorage: vi.fn(),
|
HybridSecretStorage: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,33 +51,29 @@ describe('extensionSettings', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockKeychainData = {};
|
mockKeychainData = {};
|
||||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
vi.mocked(HybridSecretStorage).mockImplementation((serviceName: string) => {
|
||||||
(serviceName: string) => {
|
if (!mockKeychainData[serviceName]) {
|
||||||
if (!mockKeychainData[serviceName]) {
|
mockKeychainData[serviceName] = {};
|
||||||
mockKeychainData[serviceName] = {};
|
}
|
||||||
}
|
const keychainData = mockKeychainData[serviceName];
|
||||||
const keychainData = mockKeychainData[serviceName];
|
return {
|
||||||
return {
|
getSecret: vi
|
||||||
getSecret: vi
|
.fn()
|
||||||
.fn()
|
.mockImplementation(async (key: string) => keychainData[key] || null),
|
||||||
.mockImplementation(
|
setSecret: vi
|
||||||
async (key: string) => keychainData[key] || null,
|
.fn()
|
||||||
),
|
.mockImplementation(async (key: string, value: string) => {
|
||||||
setSecret: vi
|
keychainData[key] = value;
|
||||||
.fn()
|
|
||||||
.mockImplementation(async (key: string, value: string) => {
|
|
||||||
keychainData[key] = value;
|
|
||||||
}),
|
|
||||||
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
|
||||||
delete keychainData[key];
|
|
||||||
}),
|
}),
|
||||||
listSecrets: vi
|
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
|
||||||
.fn()
|
delete keychainData[key];
|
||||||
.mockImplementation(async () => Object.keys(keychainData)),
|
}),
|
||||||
isAvailable: vi.fn().mockResolvedValue(true),
|
listSecrets: vi
|
||||||
} as unknown as KeychainTokenStorage;
|
.fn()
|
||||||
},
|
.mockImplementation(async () => Object.keys(keychainData)),
|
||||||
);
|
isAvailable: vi.fn().mockResolvedValue(true),
|
||||||
|
} as unknown as HybridSecretStorage;
|
||||||
|
});
|
||||||
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
|
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
|
||||||
tempWorkspaceDir = path.join(
|
tempWorkspaceDir = path.join(
|
||||||
os.tmpdir(),
|
os.tmpdir(),
|
||||||
@@ -215,7 +211,7 @@ describe('extensionSettings', () => {
|
|||||||
VAR1: 'previous-VAR1',
|
VAR1: 'previous-VAR1',
|
||||||
SENSITIVE_VAR: 'secret',
|
SENSITIVE_VAR: 'secret',
|
||||||
};
|
};
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
||||||
@@ -255,7 +251,7 @@ describe('extensionSettings', () => {
|
|||||||
settings: [],
|
settings: [],
|
||||||
};
|
};
|
||||||
const previousSettings = { SENSITIVE_VAR: 'secret' };
|
const previousSettings = { SENSITIVE_VAR: 'secret' };
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
|
||||||
@@ -421,53 +417,11 @@ describe('extensionSettings', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not attempt to clear secrets if keychain is unavailable', async () => {
|
|
||||||
// Arrange
|
|
||||||
const mockIsAvailable = vi.fn().mockResolvedValue(false);
|
|
||||||
const mockListSecrets = vi.fn();
|
|
||||||
|
|
||||||
vi.mocked(KeychainTokenStorage).mockImplementation(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
isAvailable: mockIsAvailable,
|
|
||||||
listSecrets: mockListSecrets,
|
|
||||||
deleteSecret: vi.fn(),
|
|
||||||
getSecret: vi.fn(),
|
|
||||||
setSecret: vi.fn(),
|
|
||||||
}) as unknown as KeychainTokenStorage,
|
|
||||||
);
|
|
||||||
|
|
||||||
const config: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '1.0.0',
|
|
||||||
settings: [], // Empty settings triggers clearSettings
|
|
||||||
};
|
|
||||||
|
|
||||||
const previousConfig: ExtensionConfig = {
|
|
||||||
name: 'test-ext',
|
|
||||||
version: '1.0.0',
|
|
||||||
settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
await maybePromptForSettings(
|
|
||||||
config,
|
|
||||||
'12345',
|
|
||||||
mockRequestSetting,
|
|
||||||
previousConfig,
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(mockIsAvailable).toHaveBeenCalled();
|
|
||||||
expect(mockListSecrets).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('promptForSetting', () => {
|
describe('promptForSetting', () => {
|
||||||
@@ -549,7 +503,7 @@ describe('extensionSettings', () => {
|
|||||||
it('should return combined contents from user .env and keychain for USER scope', async () => {
|
it('should return combined contents from user .env and keychain for USER scope', async () => {
|
||||||
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
|
||||||
await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1');
|
await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1');
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret');
|
await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret');
|
||||||
@@ -573,7 +527,7 @@ describe('extensionSettings', () => {
|
|||||||
EXTENSION_SETTINGS_FILENAME,
|
EXTENSION_SETTINGS_FILENAME,
|
||||||
);
|
);
|
||||||
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
||||||
const workspaceKeychain = new KeychainTokenStorage(
|
const workspaceKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||||
);
|
);
|
||||||
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
||||||
@@ -597,7 +551,7 @@ describe('extensionSettings', () => {
|
|||||||
EXTENSION_SETTINGS_FILENAME,
|
EXTENSION_SETTINGS_FILENAME,
|
||||||
);
|
);
|
||||||
fs.mkdirSync(workspaceEnvPath);
|
fs.mkdirSync(workspaceEnvPath);
|
||||||
const workspaceKeychain = new KeychainTokenStorage(
|
const workspaceKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||||
);
|
);
|
||||||
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
|
||||||
@@ -634,7 +588,7 @@ describe('extensionSettings', () => {
|
|||||||
userEnvPath,
|
userEnvPath,
|
||||||
'VAR1=user-value1\nVAR3=user-value3',
|
'VAR1=user-value1\nVAR3=user-value3',
|
||||||
);
|
);
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext ${extensionId}`,
|
`Gemini CLI Extensions test-ext ${extensionId}`,
|
||||||
);
|
);
|
||||||
await userKeychain.setSecret('VAR2', 'user-secret2');
|
await userKeychain.setSecret('VAR2', 'user-secret2');
|
||||||
@@ -645,7 +599,7 @@ describe('extensionSettings', () => {
|
|||||||
EXTENSION_SETTINGS_FILENAME,
|
EXTENSION_SETTINGS_FILENAME,
|
||||||
);
|
);
|
||||||
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
|
||||||
const workspaceKeychain = new KeychainTokenStorage(
|
const workspaceKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`,
|
`Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`,
|
||||||
);
|
);
|
||||||
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
|
await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
|
||||||
@@ -678,7 +632,7 @@ describe('extensionSettings', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const userEnvPath = path.join(extensionDir, '.env');
|
const userEnvPath = path.join(extensionDir, '.env');
|
||||||
await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n');
|
await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n');
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
await userKeychain.setSecret('VAR2', 'value2');
|
await userKeychain.setSecret('VAR2', 'value2');
|
||||||
@@ -751,7 +705,7 @@ describe('extensionSettings', () => {
|
|||||||
tempWorkspaceDir,
|
tempWorkspaceDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
|
expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
|
||||||
@@ -769,7 +723,7 @@ describe('extensionSettings', () => {
|
|||||||
tempWorkspaceDir,
|
tempWorkspaceDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceKeychain = new KeychainTokenStorage(
|
const workspaceKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
|
||||||
);
|
);
|
||||||
expect(await workspaceKeychain.getSecret('VAR2')).toBe(
|
expect(await workspaceKeychain.getSecret('VAR2')).toBe(
|
||||||
@@ -823,7 +777,7 @@ describe('extensionSettings', () => {
|
|||||||
tempWorkspaceDir,
|
tempWorkspaceDir,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
expect(await userKeychain.getSecret('VAR2')).toBeNull();
|
||||||
@@ -849,7 +803,7 @@ describe('extensionSettings', () => {
|
|||||||
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
|
||||||
mockRequestSetting.mockResolvedValue('');
|
mockRequestSetting.mockResolvedValue('');
|
||||||
// Ensure it doesn't exist first
|
// Ensure it doesn't exist first
|
||||||
const userKeychain = new KeychainTokenStorage(
|
const userKeychain = new HybridSecretStorage(
|
||||||
`Gemini CLI Extensions test-ext 12345`,
|
`Gemini CLI Extensions test-ext 12345`,
|
||||||
);
|
);
|
||||||
await userKeychain.deleteSecret('VAR2');
|
await userKeychain.deleteSecret('VAR2');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ExtensionStorage } from './storage.js';
|
|||||||
import type { ExtensionConfig } from '../extension.js';
|
import type { ExtensionConfig } from '../extension.js';
|
||||||
|
|
||||||
import prompts from 'prompts';
|
import prompts from 'prompts';
|
||||||
import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
|
import { debugLogger, HybridSecretStorage } from '@google/gemini-cli-core';
|
||||||
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
|
||||||
|
|
||||||
export enum ExtensionSettingScope {
|
export enum ExtensionSettingScope {
|
||||||
@@ -78,7 +78,7 @@ export async function maybePromptForSettings(
|
|||||||
// The user can change the scope later using the `settings set` command.
|
// The user can change the scope later using the `settings set` command.
|
||||||
const scope = ExtensionSettingScope.USER;
|
const scope = ExtensionSettingScope.USER;
|
||||||
const envFilePath = getEnvFilePath(extensionName, scope);
|
const envFilePath = getEnvFilePath(extensionName, scope);
|
||||||
const keychain = new KeychainTokenStorage(
|
const keychain = new HybridSecretStorage(
|
||||||
getKeychainStorageName(extensionName, extensionId, scope),
|
getKeychainStorageName(extensionName, extensionId, scope),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ export async function getScopedEnvContents(
|
|||||||
workspaceDir?: string,
|
workspaceDir?: string,
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
const { name: extensionName } = extensionConfig;
|
const { name: extensionName } = extensionConfig;
|
||||||
const keychain = new KeychainTokenStorage(
|
const keychain = new HybridSecretStorage(
|
||||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||||
);
|
);
|
||||||
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
|
||||||
@@ -250,7 +250,7 @@ export async function updateSetting(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newValue = await requestSetting(settingToUpdate);
|
const newValue = await requestSetting(settingToUpdate);
|
||||||
const keychain = new KeychainTokenStorage(
|
const keychain = new HybridSecretStorage(
|
||||||
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ function getSettingsChanges(
|
|||||||
|
|
||||||
async function clearSettings(
|
async function clearSettings(
|
||||||
envFilePath: string,
|
envFilePath: string,
|
||||||
keychain: KeychainTokenStorage,
|
keychain: HybridSecretStorage,
|
||||||
) {
|
) {
|
||||||
if (fsSync.existsSync(envFilePath)) {
|
if (fsSync.existsSync(envFilePath)) {
|
||||||
const stat = fsSync.statSync(envFilePath);
|
const stat = fsSync.statSync(envFilePath);
|
||||||
@@ -347,9 +347,6 @@ async function clearSettings(
|
|||||||
await fs.writeFile(envFilePath, '');
|
await fs.writeFile(envFilePath, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!(await keychain.isAvailable())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const secrets = await keychain.listSecrets();
|
const secrets = await keychain.listSecrets();
|
||||||
for (const secret of secrets) {
|
for (const secret of secrets) {
|
||||||
await keychain.deleteSecret(secret);
|
await keychain.deleteSecret(secret);
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ export type {
|
|||||||
OAuthCredentials,
|
OAuthCredentials,
|
||||||
} from './mcp/token-storage/types.js';
|
} from './mcp/token-storage/types.js';
|
||||||
export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js';
|
export { MCPOAuthTokenStorage } from './mcp/oauth-token-storage.js';
|
||||||
|
export { HybridSecretStorage } from './mcp/token-storage/hybrid-secret-storage.js';
|
||||||
|
export { KeychainTokenStorage } from './mcp/token-storage/keychain-token-storage.js';
|
||||||
export type { MCPOAuthConfig } from './mcp/oauth-provider.js';
|
export type { MCPOAuthConfig } from './mcp/oauth-provider.js';
|
||||||
export type {
|
export type {
|
||||||
OAuthAuthorizationServerMetadata,
|
OAuthAuthorizationServerMetadata,
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 type { SecretStorage } from './types.js';
|
||||||
|
import { GEMINI_DIR, homedir } from '../../utils/paths.js';
|
||||||
|
|
||||||
|
export class EncryptedFileSecretStorage implements SecretStorage {
|
||||||
|
private readonly tokenFilePath: string;
|
||||||
|
private readonly encryptionKey: Buffer;
|
||||||
|
private readonly serviceName: string;
|
||||||
|
|
||||||
|
constructor(serviceName: string) {
|
||||||
|
this.serviceName = serviceName;
|
||||||
|
const configDir = path.join(homedir(), GEMINI_DIR);
|
||||||
|
this.tokenFilePath = path.join(configDir, 'extension-secrets-v1.json');
|
||||||
|
this.encryptionKey = this.deriveEncryptionKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveEncryptionKey(): Buffer {
|
||||||
|
const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
|
||||||
|
return crypto.scryptSync('gemini-cli-secrets', salt, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encrypt(text: string): string {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decrypt(encryptedData: string): string {
|
||||||
|
const parts = encryptedData.split(':');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('Invalid encrypted data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(parts[0], 'hex');
|
||||||
|
const authTag = Buffer.from(parts[1], 'hex');
|
||||||
|
const encrypted = parts[2];
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
'aes-256-gcm',
|
||||||
|
this.encryptionKey,
|
||||||
|
iv,
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDirectoryExists(): Promise<void> {
|
||||||
|
const dir = path.dirname(this.tokenFilePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSecrets(): Promise<Record<string, Record<string, string>>> {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(this.tokenFilePath, 'utf-8');
|
||||||
|
const decrypted = this.decrypt(data);
|
||||||
|
const parsed: unknown = JSON.parse(decrypted);
|
||||||
|
const result: Record<string, Record<string, string>> = {};
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
for (const [service, secrets] of Object.entries(parsed)) {
|
||||||
|
if (typeof secrets === 'object' && secrets !== null) {
|
||||||
|
result[service] = {};
|
||||||
|
for (const [key, value] of Object.entries(secrets)) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
result[service][key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
|
error.code === 'ENOENT'
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const errMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
if (
|
||||||
|
errMessage.includes('Invalid encrypted data format') ||
|
||||||
|
errMessage.includes('Unsupported state or unable to authenticate data')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Corrupted secret file detected at: ${this.tokenFilePath}
|
||||||
|
` + `Please delete or rename this file to resolve the issue.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveSecrets(
|
||||||
|
secrets: Record<string, Record<string, string>>,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.ensureDirectoryExists();
|
||||||
|
const json = JSON.stringify(secrets, null, 2);
|
||||||
|
const encrypted = this.encrypt(json);
|
||||||
|
await fs.writeFile(this.tokenFilePath, encrypted, { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSecret(key: string, value: string): Promise<void> {
|
||||||
|
const allSecrets = await this.loadSecrets();
|
||||||
|
if (!allSecrets[this.serviceName]) {
|
||||||
|
allSecrets[this.serviceName] = {};
|
||||||
|
}
|
||||||
|
allSecrets[this.serviceName][key] = value;
|
||||||
|
await this.saveSecrets(allSecrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecret(key: string): Promise<string | null> {
|
||||||
|
const allSecrets = await this.loadSecrets();
|
||||||
|
return allSecrets[this.serviceName]?.[key] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSecret(key: string): Promise<void> {
|
||||||
|
const allSecrets = await this.loadSecrets();
|
||||||
|
if (allSecrets[this.serviceName]?.[key]) {
|
||||||
|
delete allSecrets[this.serviceName][key];
|
||||||
|
if (Object.keys(allSecrets[this.serviceName]).length === 0) {
|
||||||
|
delete allSecrets[this.serviceName];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(allSecrets).length === 0) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(this.tokenFilePath);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
'code' in error &&
|
||||||
|
error.code !== 'ENOENT'
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.saveSecrets(allSecrets);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`No secret found for key: ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSecrets(): Promise<string[]> {
|
||||||
|
const allSecrets = await this.loadSecrets();
|
||||||
|
return Object.keys(allSecrets[this.serviceName] || {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { KeychainTokenStorage } from './keychain-token-storage.js';
|
||||||
|
import { EncryptedFileSecretStorage } from './encrypted-file-secret-storage.js';
|
||||||
|
import type { SecretStorage } from './types.js';
|
||||||
|
|
||||||
|
export class HybridSecretStorage implements SecretStorage {
|
||||||
|
private storage: SecretStorage | null = null;
|
||||||
|
private storageInitPromise: Promise<SecretStorage> | null = null;
|
||||||
|
private readonly serviceName: string;
|
||||||
|
|
||||||
|
constructor(serviceName: string) {
|
||||||
|
this.serviceName = serviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeStorage(): Promise<SecretStorage> {
|
||||||
|
const keychainStorage = new KeychainTokenStorage(this.serviceName);
|
||||||
|
const isAvailable = await keychainStorage.isAvailable();
|
||||||
|
if (isAvailable) {
|
||||||
|
this.storage = keychainStorage;
|
||||||
|
} else {
|
||||||
|
this.storage = new EncryptedFileSecretStorage(this.serviceName);
|
||||||
|
}
|
||||||
|
return this.storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStorage(): Promise<SecretStorage> {
|
||||||
|
if (this.storage !== null) {
|
||||||
|
return this.storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.storageInitPromise) {
|
||||||
|
this.storageInitPromise = this.initializeStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.storageInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSecret(key: string, value: string): Promise<void> {
|
||||||
|
const storage = await this.getStorage();
|
||||||
|
await storage.setSecret(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSecret(key: string): Promise<string | null> {
|
||||||
|
const storage = await this.getStorage();
|
||||||
|
return storage.getSecret(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSecret(key: string): Promise<void> {
|
||||||
|
const storage = await this.getStorage();
|
||||||
|
await storage.deleteSecret(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSecrets(): Promise<string[]> {
|
||||||
|
const storage = await this.getStorage();
|
||||||
|
return storage.listSecrets();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user