File size: 10,111 Bytes
7a1ad33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | /**
* @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();
}
}
|