mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
feat(extensions): implement cryptographic integrity verification for extension updates (#21772)
This commit is contained in:
@@ -32,3 +32,9 @@ export const DEFAULT_FILE_FILTERING_OPTIONS: FileFilteringOptions = {
|
||||
|
||||
// Generic exclusion file name
|
||||
export const GEMINI_IGNORE_FILE_NAME = '.geminiignore';
|
||||
|
||||
// Extension integrity constants
|
||||
export const INTEGRITY_FILENAME = 'extension_integrity.json';
|
||||
export const INTEGRITY_KEY_FILENAME = 'integrity.key';
|
||||
export const KEYCHAIN_SERVICE_NAME = 'gemini-cli-extension-integrity';
|
||||
export const SECRET_KEY_ACCOUNT = 'secret-key';
|
||||
|
||||
203
packages/core/src/config/extensions/integrity.test.ts
Normal file
203
packages/core/src/config/extensions/integrity.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { ExtensionIntegrityManager, IntegrityDataStatus } from './integrity.js';
|
||||
import type { ExtensionInstallMetadata } from '../config.js';
|
||||
|
||||
const mockKeychainService = {
|
||||
isAvailable: vi.fn(),
|
||||
getPassword: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../../services/keychainService.js', () => ({
|
||||
KeychainService: vi.fn().mockImplementation(() => mockKeychainService),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/paths.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../utils/paths.js')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => '/mock/home',
|
||||
GEMINI_DIR: '.gemini',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
promises: {
|
||||
...actual.promises,
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionIntegrityManager', () => {
|
||||
let manager: ExtensionIntegrityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
manager = new ExtensionIntegrityManager();
|
||||
mockKeychainService.isAvailable.mockResolvedValue(true);
|
||||
mockKeychainService.getPassword.mockResolvedValue('test-key');
|
||||
mockKeychainService.setPassword.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('getSecretKey', () => {
|
||||
it('should retrieve key from keychain if available', async () => {
|
||||
const key = await manager.getSecretKey();
|
||||
expect(key).toBe('test-key');
|
||||
expect(mockKeychainService.getPassword).toHaveBeenCalledWith(
|
||||
'secret-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate and store key in keychain if not exists', async () => {
|
||||
mockKeychainService.getPassword.mockResolvedValue(null);
|
||||
const key = await manager.getSecretKey();
|
||||
expect(key).toHaveLength(64);
|
||||
expect(mockKeychainService.setPassword).toHaveBeenCalledWith(
|
||||
'secret-key',
|
||||
key,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to file-based key if keychain is unavailable', async () => {
|
||||
mockKeychainService.isAvailable.mockResolvedValue(false);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValueOnce('file-key');
|
||||
|
||||
const key = await manager.getSecretKey();
|
||||
expect(key).toBe('file-key');
|
||||
});
|
||||
|
||||
it('should generate and store file-based key if not exists', async () => {
|
||||
mockKeychainService.isAvailable.mockResolvedValue(false);
|
||||
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error(), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
const key = await manager.getSecretKey();
|
||||
expect(key).toBeDefined();
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(
|
||||
path.join('/mock/home', '.gemini', 'integrity.key'),
|
||||
key,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store and verify', () => {
|
||||
const metadata: ExtensionInstallMetadata = {
|
||||
source: 'https://github.com/user/ext',
|
||||
type: 'git',
|
||||
};
|
||||
|
||||
let storedContent = '';
|
||||
|
||||
beforeEach(() => {
|
||||
storedContent = '';
|
||||
|
||||
const isIntegrityStore = (p: unknown) =>
|
||||
typeof p === 'string' &&
|
||||
(p.endsWith('extension_integrity.json') ||
|
||||
p.endsWith('extension_integrity.json.tmp'));
|
||||
|
||||
vi.mocked(fs.promises.writeFile).mockImplementation(
|
||||
async (p, content) => {
|
||||
if (isIntegrityStore(p)) {
|
||||
storedContent = content as string;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
vi.mocked(fs.promises.readFile).mockImplementation(async (p) => {
|
||||
if (isIntegrityStore(p)) {
|
||||
if (!storedContent) {
|
||||
throw Object.assign(new Error('File not found'), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}
|
||||
return storedContent;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
vi.mocked(fs.promises.rename).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should store and verify integrity successfully', async () => {
|
||||
await manager.store('ext-name', metadata);
|
||||
const result = await manager.verify('ext-name', metadata);
|
||||
expect(result).toBe(IntegrityDataStatus.VERIFIED);
|
||||
expect(fs.promises.rename).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return MISSING if metadata record is missing from store', async () => {
|
||||
const result = await manager.verify('unknown-ext', metadata);
|
||||
expect(result).toBe(IntegrityDataStatus.MISSING);
|
||||
});
|
||||
|
||||
it('should return INVALID if metadata content changes', async () => {
|
||||
await manager.store('ext-name', metadata);
|
||||
const modifiedMetadata: ExtensionInstallMetadata = {
|
||||
...metadata,
|
||||
source: 'https://github.com/attacker/ext',
|
||||
};
|
||||
const result = await manager.verify('ext-name', modifiedMetadata);
|
||||
expect(result).toBe(IntegrityDataStatus.INVALID);
|
||||
});
|
||||
|
||||
it('should return INVALID if store signature is modified', async () => {
|
||||
await manager.store('ext-name', metadata);
|
||||
|
||||
const data = JSON.parse(storedContent);
|
||||
data.signature = 'invalid-signature';
|
||||
storedContent = JSON.stringify(data);
|
||||
|
||||
const result = await manager.verify('ext-name', metadata);
|
||||
expect(result).toBe(IntegrityDataStatus.INVALID);
|
||||
});
|
||||
|
||||
it('should return INVALID if signature length mismatches (e.g. truncated data)', async () => {
|
||||
await manager.store('ext-name', metadata);
|
||||
|
||||
const data = JSON.parse(storedContent);
|
||||
data.signature = 'abc';
|
||||
storedContent = JSON.stringify(data);
|
||||
|
||||
const result = await manager.verify('ext-name', metadata);
|
||||
expect(result).toBe(IntegrityDataStatus.INVALID);
|
||||
});
|
||||
|
||||
it('should throw error in store if existing store is modified', async () => {
|
||||
await manager.store('ext-name', metadata);
|
||||
|
||||
const data = JSON.parse(storedContent);
|
||||
data.store['another-ext'] = { hash: 'fake', signature: 'fake' };
|
||||
storedContent = JSON.stringify(data);
|
||||
|
||||
await expect(manager.store('other-ext', metadata)).rejects.toThrow(
|
||||
'Extension integrity store cannot be verified',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error in store if store file is corrupted', async () => {
|
||||
storedContent = 'not-json';
|
||||
|
||||
await expect(manager.store('other-ext', metadata)).rejects.toThrow(
|
||||
'Failed to parse extension integrity store',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
324
packages/core/src/config/extensions/integrity.ts
Normal file
324
packages/core/src/config/extensions/integrity.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import {
|
||||
INTEGRITY_FILENAME,
|
||||
INTEGRITY_KEY_FILENAME,
|
||||
KEYCHAIN_SERVICE_NAME,
|
||||
SECRET_KEY_ACCOUNT,
|
||||
} from '../constants.js';
|
||||
import { type ExtensionInstallMetadata } from '../config.js';
|
||||
import { KeychainService } from '../../services/keychainService.js';
|
||||
import { isNodeError, getErrorMessage } from '../../utils/errors.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { homedir, GEMINI_DIR } from '../../utils/paths.js';
|
||||
import stableStringify from 'json-stable-stringify';
|
||||
import {
|
||||
type IExtensionIntegrity,
|
||||
IntegrityDataStatus,
|
||||
type ExtensionIntegrityMap,
|
||||
type IntegrityStore,
|
||||
IntegrityStoreSchema,
|
||||
} from './integrityTypes.js';
|
||||
|
||||
export * from './integrityTypes.js';
|
||||
|
||||
/**
|
||||
* Manages the secret key used for signing integrity data.
|
||||
* Attempts to use the OS keychain, falling back to a restricted local file.
|
||||
* @internal
|
||||
*/
|
||||
class IntegrityKeyManager {
|
||||
private readonly fallbackKeyPath: string;
|
||||
private readonly keychainService: KeychainService;
|
||||
private cachedSecretKey: string | null = null;
|
||||
|
||||
constructor() {
|
||||
const configDir = path.join(homedir(), GEMINI_DIR);
|
||||
this.fallbackKeyPath = path.join(configDir, INTEGRITY_KEY_FILENAME);
|
||||
this.keychainService = new KeychainService(KEYCHAIN_SERVICE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves or generates the master secret key.
|
||||
*/
|
||||
async getSecretKey(): Promise<string> {
|
||||
if (this.cachedSecretKey) {
|
||||
return this.cachedSecretKey;
|
||||
}
|
||||
|
||||
if (await this.keychainService.isAvailable()) {
|
||||
try {
|
||||
this.cachedSecretKey = await this.getSecretKeyFromKeychain();
|
||||
return this.cachedSecretKey;
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`Keychain access failed, falling back to file-based key: ${getErrorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedSecretKey = await this.getSecretKeyFromFile();
|
||||
return this.cachedSecretKey;
|
||||
}
|
||||
|
||||
private async getSecretKeyFromKeychain(): Promise<string> {
|
||||
let key = await this.keychainService.getPassword(SECRET_KEY_ACCOUNT);
|
||||
if (!key) {
|
||||
// Generate a fresh 256-bit key if none exists.
|
||||
key = randomBytes(32).toString('hex');
|
||||
await this.keychainService.setPassword(SECRET_KEY_ACCOUNT, key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private async getSecretKeyFromFile(): Promise<string> {
|
||||
try {
|
||||
const key = await fs.promises.readFile(this.fallbackKeyPath, 'utf-8');
|
||||
return key.trim();
|
||||
} catch (e) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
// Lazily create the config directory if it doesn't exist.
|
||||
const configDir = path.dirname(this.fallbackKeyPath);
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Generate a fresh 256-bit key for the local fallback.
|
||||
const key = randomBytes(32).toString('hex');
|
||||
|
||||
// Store with restricted permissions (read/write for owner only).
|
||||
await fs.promises.writeFile(this.fallbackKeyPath, key, { mode: 0o600 });
|
||||
return key;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the persistence and signature verification of the integrity store.
|
||||
* The entire store is signed to detect manual tampering of the JSON file.
|
||||
* @internal
|
||||
*/
|
||||
class ExtensionIntegrityStore {
|
||||
private readonly integrityStorePath: string;
|
||||
|
||||
constructor(private readonly keyManager: IntegrityKeyManager) {
|
||||
const configDir = path.join(homedir(), GEMINI_DIR);
|
||||
this.integrityStorePath = path.join(configDir, INTEGRITY_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the integrity map from disk, verifying the store-wide signature.
|
||||
*/
|
||||
async load(): Promise<ExtensionIntegrityMap> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.promises.readFile(this.integrityStorePath, 'utf-8');
|
||||
} catch (e) {
|
||||
if (isNodeError(e) && e.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const resetInstruction = `Please delete ${this.integrityStorePath} to reset it.`;
|
||||
|
||||
// Parse and validate the store structure.
|
||||
let rawStore: IntegrityStore;
|
||||
try {
|
||||
rawStore = IntegrityStoreSchema.parse(JSON.parse(content));
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
`Failed to parse extension integrity store. ${resetInstruction}}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { store, signature: actualSignature } = rawStore;
|
||||
|
||||
// Re-generate the expected signature for the store content.
|
||||
const storeContent = stableStringify(store) ?? '';
|
||||
const expectedSignature = await this.generateSignature(storeContent);
|
||||
|
||||
// Verify the store hasn't been tampered with.
|
||||
if (!this.verifyConstantTime(actualSignature, expectedSignature)) {
|
||||
throw new Error(
|
||||
`Extension integrity store cannot be verified. ${resetInstruction}`,
|
||||
);
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the integrity map to disk with a fresh store-wide signature.
|
||||
*/
|
||||
async save(store: ExtensionIntegrityMap): Promise<void> {
|
||||
// Generate a signature for the entire map to prevent manual tampering.
|
||||
const storeContent = stableStringify(store) ?? '';
|
||||
const storeSignature = await this.generateSignature(storeContent);
|
||||
|
||||
const finalData: IntegrityStore = {
|
||||
store,
|
||||
signature: storeSignature,
|
||||
};
|
||||
|
||||
// Ensure parent directory exists before writing.
|
||||
const configDir = path.dirname(this.integrityStorePath);
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Use a 'write-then-rename' pattern for an atomic update.
|
||||
// Restrict file permissions to owner only (0o600).
|
||||
const tmpPath = `${this.integrityStorePath}.tmp`;
|
||||
await fs.promises.writeFile(tmpPath, JSON.stringify(finalData, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.promises.rename(tmpPath, this.integrityStorePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic SHA-256 hash of the metadata.
|
||||
*/
|
||||
generateHash(metadata: ExtensionInstallMetadata): string {
|
||||
const content = stableStringify(metadata) ?? '';
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an HMAC-SHA256 signature using the master secret key.
|
||||
*/
|
||||
async generateSignature(data: string): Promise<string> {
|
||||
const secretKey = await this.keyManager.getSecretKey();
|
||||
return createHmac('sha256', secretKey).update(data).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison to prevent timing attacks.
|
||||
*/
|
||||
verifyConstantTime(actual: string, expected: string): boolean {
|
||||
const actualBuffer = Buffer.from(actual, 'hex');
|
||||
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||
|
||||
// timingSafeEqual requires buffers of the same length.
|
||||
if (actualBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of IExtensionIntegrity that persists data to disk.
|
||||
*/
|
||||
export class ExtensionIntegrityManager implements IExtensionIntegrity {
|
||||
private readonly keyManager: IntegrityKeyManager;
|
||||
private readonly integrityStore: ExtensionIntegrityStore;
|
||||
private writeLock: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor() {
|
||||
this.keyManager = new IntegrityKeyManager();
|
||||
this.integrityStore = new ExtensionIntegrityStore(this.keyManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the provided metadata against the recorded integrity data.
|
||||
*/
|
||||
async verify(
|
||||
extensionName: string,
|
||||
metadata: ExtensionInstallMetadata | undefined,
|
||||
): Promise<IntegrityDataStatus> {
|
||||
if (!metadata) {
|
||||
return IntegrityDataStatus.MISSING;
|
||||
}
|
||||
|
||||
try {
|
||||
const storeMap = await this.integrityStore.load();
|
||||
const extensionRecord = storeMap[extensionName];
|
||||
|
||||
if (!extensionRecord) {
|
||||
return IntegrityDataStatus.MISSING;
|
||||
}
|
||||
|
||||
// Verify the hash (metadata content) matches the recorded value.
|
||||
const actualHash = this.integrityStore.generateHash(metadata);
|
||||
const isHashValid = this.integrityStore.verifyConstantTime(
|
||||
actualHash,
|
||||
extensionRecord.hash,
|
||||
);
|
||||
|
||||
if (!isHashValid) {
|
||||
debugLogger.warn(
|
||||
`Integrity mismatch for "${extensionName}": Hash mismatch.`,
|
||||
);
|
||||
return IntegrityDataStatus.INVALID;
|
||||
}
|
||||
|
||||
// Verify the signature (authenticity) using the master secret key.
|
||||
const actualSignature =
|
||||
await this.integrityStore.generateSignature(actualHash);
|
||||
const isSignatureValid = this.integrityStore.verifyConstantTime(
|
||||
actualSignature,
|
||||
extensionRecord.signature,
|
||||
);
|
||||
|
||||
if (!isSignatureValid) {
|
||||
debugLogger.warn(
|
||||
`Integrity mismatch for "${extensionName}": Signature mismatch.`,
|
||||
);
|
||||
return IntegrityDataStatus.INVALID;
|
||||
}
|
||||
|
||||
return IntegrityDataStatus.VERIFIED;
|
||||
} catch (e) {
|
||||
debugLogger.warn(
|
||||
`Error verifying integrity for "${extensionName}": ${getErrorMessage(e)}`,
|
||||
);
|
||||
return IntegrityDataStatus.INVALID;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the integrity data for an extension.
|
||||
* Uses a promise chain to serialize concurrent store operations.
|
||||
*/
|
||||
async store(
|
||||
extensionName: string,
|
||||
metadata: ExtensionInstallMetadata,
|
||||
): Promise<void> {
|
||||
const operation = (async () => {
|
||||
await this.writeLock;
|
||||
|
||||
// Generate integrity data for the new metadata.
|
||||
const hash = this.integrityStore.generateHash(metadata);
|
||||
const signature = await this.integrityStore.generateSignature(hash);
|
||||
|
||||
// Update the store map and persist to disk.
|
||||
const storeMap = await this.integrityStore.load();
|
||||
storeMap[extensionName] = { hash, signature };
|
||||
await this.integrityStore.save(storeMap);
|
||||
})();
|
||||
|
||||
// Update the lock to point to the latest operation, ensuring they are serialized.
|
||||
this.writeLock = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves or generates the master secret key.
|
||||
* @internal visible for testing
|
||||
*/
|
||||
async getSecretKey(): Promise<string> {
|
||||
return this.keyManager.getSecretKey();
|
||||
}
|
||||
}
|
||||
79
packages/core/src/config/extensions/integrityTypes.ts
Normal file
79
packages/core/src/config/extensions/integrityTypes.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { type ExtensionInstallMetadata } from '../config.js';
|
||||
|
||||
/**
|
||||
* Zod schema for a single extension's integrity data.
|
||||
*/
|
||||
export const ExtensionIntegrityDataSchema = z.object({
|
||||
hash: z.string(),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for the map of extension names to integrity data.
|
||||
*/
|
||||
export const ExtensionIntegrityMapSchema = z.record(
|
||||
z.string(),
|
||||
ExtensionIntegrityDataSchema,
|
||||
);
|
||||
|
||||
/**
|
||||
* Zod schema for the full integrity store file structure.
|
||||
*/
|
||||
export const IntegrityStoreSchema = z.object({
|
||||
store: ExtensionIntegrityMapSchema,
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The integrity data for a single extension.
|
||||
*/
|
||||
export type ExtensionIntegrityData = z.infer<
|
||||
typeof ExtensionIntegrityDataSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* A map of extension names to their corresponding integrity data.
|
||||
*/
|
||||
export type ExtensionIntegrityMap = z.infer<typeof ExtensionIntegrityMapSchema>;
|
||||
|
||||
/**
|
||||
* The full structure of the integrity store as persisted on disk.
|
||||
*/
|
||||
export type IntegrityStore = z.infer<typeof IntegrityStoreSchema>;
|
||||
|
||||
/**
|
||||
* Result status of an extension integrity verification.
|
||||
*/
|
||||
export enum IntegrityDataStatus {
|
||||
VERIFIED = 'verified',
|
||||
MISSING = 'missing',
|
||||
INVALID = 'invalid',
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for managing extension integrity.
|
||||
*/
|
||||
export interface IExtensionIntegrity {
|
||||
/**
|
||||
* Verifies the integrity of an extension's installation metadata.
|
||||
*/
|
||||
verify(
|
||||
extensionName: string,
|
||||
metadata: ExtensionInstallMetadata | undefined,
|
||||
): Promise<IntegrityDataStatus>;
|
||||
|
||||
/**
|
||||
* Signs and stores the extension's installation metadata.
|
||||
*/
|
||||
store(
|
||||
extensionName: string,
|
||||
metadata: ExtensionInstallMetadata,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export * from './policy/policy-engine.js';
|
||||
export * from './policy/toml-loader.js';
|
||||
export * from './policy/config.js';
|
||||
export * from './policy/integrity.js';
|
||||
export * from './config/extensions/integrity.js';
|
||||
export * from './config/extensions/integrityTypes.js';
|
||||
export * from './billing/index.js';
|
||||
export * from './confirmation-bus/types.js';
|
||||
export * from './confirmation-bus/message-bus.js';
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { KeychainService } from './keychainService.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -53,6 +56,21 @@ vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: { log: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return { ...actual, platform: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return { ...actual, spawnSync: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, existsSync: vi.fn(), promises: { ...actual.promises } };
|
||||
});
|
||||
|
||||
describe('KeychainService', () => {
|
||||
let service: KeychainService;
|
||||
const SERVICE_NAME = 'test-service';
|
||||
@@ -65,6 +83,9 @@ describe('KeychainService', () => {
|
||||
service = new KeychainService(SERVICE_NAME);
|
||||
passwords = {};
|
||||
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
// Stateful mock implementation for native keychain
|
||||
mockKeytar.setPassword?.mockImplementation((_svc, acc, val) => {
|
||||
passwords[acc] = val;
|
||||
@@ -197,6 +218,90 @@ describe('KeychainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('macOS Keychain Probing', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
});
|
||||
|
||||
it('should skip functional test and fallback if security default-keychain fails', async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 1,
|
||||
stderr: 'not found',
|
||||
stdout: '',
|
||||
output: [],
|
||||
pid: 123,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(vi.mocked(spawnSync)).toHaveBeenCalledWith(
|
||||
'security',
|
||||
['default-keychain'],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockKeytar.setPassword).not.toHaveBeenCalled();
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('MacOS default keychain not found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip functional test and fallback if security default-keychain returns non-existent path', async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: ' "/non/existent/path" \n',
|
||||
stderr: '',
|
||||
output: [],
|
||||
pid: 123,
|
||||
signal: null,
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/non/existent/path');
|
||||
expect(mockKeytar.setPassword).not.toHaveBeenCalled();
|
||||
expect(FileKeychain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should proceed with functional test if valid default keychain is found', async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: '"/path/to/valid.keychain"',
|
||||
stderr: '',
|
||||
output: [],
|
||||
pid: 123,
|
||||
signal: null,
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
expect(mockKeytar.setPassword).toHaveBeenCalled();
|
||||
expect(FileKeychain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle unquoted paths from security output', async () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: ' /path/to/valid.keychain \n',
|
||||
stderr: '',
|
||||
output: [],
|
||||
pid: 123,
|
||||
signal: null,
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
await service.isAvailable();
|
||||
|
||||
expect(fs.existsSync).toHaveBeenCalledWith('/path/to/valid.keychain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Password Operations', () => {
|
||||
beforeEach(async () => {
|
||||
await service.isAvailable();
|
||||
@@ -223,6 +328,4 @@ describe('KeychainService', () => {
|
||||
expect(await service.getPassword('missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Removing 'When Unavailable' tests since the service is always available via fallback
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { KeychainAvailabilityEvent } from '../telemetry/types.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
@@ -95,42 +98,56 @@ export class KeychainService {
|
||||
|
||||
// High-level orchestration of the loading and testing cycle.
|
||||
private async initializeKeychain(): Promise<Keychain | null> {
|
||||
let resultKeychain: Keychain | null = null;
|
||||
const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';
|
||||
|
||||
if (!forceFileStorage) {
|
||||
try {
|
||||
const keychainModule = await this.loadKeychainModule();
|
||||
if (keychainModule) {
|
||||
if (await this.isKeychainFunctional(keychainModule)) {
|
||||
resultKeychain = keychainModule;
|
||||
} else {
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.log(
|
||||
'Keychain initialization encountered an error:',
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Try to get the native OS keychain unless file storage is requested.
|
||||
const nativeKeychain = forceFileStorage
|
||||
? null
|
||||
: await this.getNativeKeychain();
|
||||
|
||||
coreEvents.emitTelemetryKeychainAvailability(
|
||||
new KeychainAvailabilityEvent(
|
||||
resultKeychain !== null && !forceFileStorage,
|
||||
),
|
||||
new KeychainAvailabilityEvent(nativeKeychain !== null),
|
||||
);
|
||||
|
||||
// Fallback to FileKeychain if native keychain is unavailable or file storage is forced
|
||||
if (!resultKeychain) {
|
||||
resultKeychain = new FileKeychain();
|
||||
debugLogger.log('Using FileKeychain fallback for secure storage.');
|
||||
if (nativeKeychain) {
|
||||
return nativeKeychain;
|
||||
}
|
||||
|
||||
return resultKeychain;
|
||||
// If native failed or was skipped, return the secure file fallback.
|
||||
debugLogger.log('Using FileKeychain fallback for secure storage.');
|
||||
return new FileKeychain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load and verify the native keychain module (keytar).
|
||||
*/
|
||||
private async getNativeKeychain(): Promise<Keychain | null> {
|
||||
try {
|
||||
const keychainModule = await this.loadKeychainModule();
|
||||
if (!keychainModule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Probing macOS prevents process-blocking popups when no keychain exists.
|
||||
if (os.platform() === 'darwin' && !this.isMacOSKeychainAvailable()) {
|
||||
debugLogger.log(
|
||||
'MacOS default keychain not found; skipping functional verification.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await this.isKeychainFunctional(keychainModule)) {
|
||||
return keychainModule;
|
||||
}
|
||||
|
||||
debugLogger.log('Keychain functional verification failed');
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Avoid logging full error objects to prevent PII exposure.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.log('Keychain initialization encountered an error:', message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Low-level dynamic loading and structural validation.
|
||||
@@ -166,4 +183,36 @@ export class KeychainService {
|
||||
|
||||
return deleted && retrieved === testPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* MacOS-specific check to detect if a default keychain is available.
|
||||
*/
|
||||
private isMacOSKeychainAvailable(): boolean {
|
||||
// Probing via the `security` CLI avoids a blocking OS-level popup that
|
||||
// occurs when calling keytar without a configured keychain.
|
||||
const result = spawnSync('security', ['default-keychain'], {
|
||||
encoding: 'utf8',
|
||||
// We pipe stdout to read the path, but ignore stderr to suppress
|
||||
// "keychain not found" errors from polluting the terminal.
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
|
||||
// If the command fails or lacks output, no default keychain is configured.
|
||||
if (result.error || result.status !== 0 || !result.stdout) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate that the returned path string is not empty.
|
||||
const trimmed = result.stdout.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The output usually contains the path wrapped in double quotes.
|
||||
const match = trimmed.match(/"(.*)"/);
|
||||
const keychainPath = match ? match[1] : trimmed;
|
||||
|
||||
// Finally, verify the path exists on disk to ensure it's not a stale reference.
|
||||
return !!keychainPath && fs.existsSync(keychainPath);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user