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();
  }
}