/** * @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 { 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 { 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 { 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 { 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 { // 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 { 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 = 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 { 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 { 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 { return this.keyManager.getSecretKey(); } }