File size: 7,746 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
/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

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';
import {
  type Keychain,
  KeychainSchema,
  KEYCHAIN_TEST_PREFIX,
} from './keychainTypes.js';
import { isRecord } from '../utils/markdownUtils.js';
import { FileKeychain } from './fileKeychain.js';

export const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';

/**
 * Service for interacting with OS-level secure storage (e.g. @github/keytar).
 */
export class KeychainService {
  // Track an ongoing initialization attempt to avoid race conditions.
  private initializationPromise?: Promise<Keychain | null>;

  /**
   * @param serviceName Unique identifier for the app in the OS keychain.
   */
  constructor(private readonly serviceName: string) {}

  async isAvailable(): Promise<boolean> {
    return (await this.getKeychain()) !== null;
  }

  /**
   * Returns true if the service is using the encrypted file fallback backend.
   */
  async isUsingFileFallback(): Promise<boolean> {
    const keychain = await this.getKeychain();
    return keychain instanceof FileKeychain;
  }

  /**
   * Retrieves a secret for the given account.
   * @throws Error if the keychain is unavailable.
   */
  async getPassword(account: string): Promise<string | null> {
    const keychain = await this.getKeychainOrThrow();
    return keychain.getPassword(this.serviceName, account);
  }

  /**
   * Securely stores a secret.
   * @throws Error if the keychain is unavailable.
   */
  async setPassword(account: string, value: string): Promise<void> {
    const keychain = await this.getKeychainOrThrow();
    await keychain.setPassword(this.serviceName, account, value);
  }

  /**
   * Removes a secret from the keychain.
   * @returns true if the secret was deleted, false otherwise.
   * @throws Error if the keychain is unavailable.
   */
  async deletePassword(account: string): Promise<boolean> {
    const keychain = await this.getKeychainOrThrow();
    return keychain.deletePassword(this.serviceName, account);
  }

  /**
   * Lists all account/secret pairs stored under this service.
   * @throws Error if the keychain is unavailable.
   */
  async findCredentials(): Promise<
    Array<{ account: string; password: string }>
  > {
    const keychain = await this.getKeychainOrThrow();
    return keychain.findCredentials(this.serviceName);
  }

  private async getKeychainOrThrow(): Promise<Keychain> {
    const keychain = await this.getKeychain();
    if (!keychain) {
      throw new Error('Keychain is not available');
    }
    return keychain;
  }

  private getKeychain(): Promise<Keychain | null> {
    return (this.initializationPromise ??= this.initializeKeychain());
  }

  // High-level orchestration of the loading and testing cycle.
  private async initializeKeychain(): Promise<Keychain | null> {
    const forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === 'true';

    // Try to get the native OS keychain unless file storage is requested.
    const nativeKeychain = forceFileStorage
      ? null
      : await this.getNativeKeychain();

    coreEvents.emitTelemetryKeychainAvailability(
      new KeychainAvailabilityEvent(nativeKeychain !== null),
    );

    if (nativeKeychain) {
      return nativeKeychain;
    }

    // If native failed or was skipped, return the secure file fallback.
    debugLogger.debug('Using FileKeychain fallback for secure storage.');
    return new FileKeychain();
  }

  /**
   * Attempts to load and verify the native keychain module (@github/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.debug(
          'MacOS default keychain not found; skipping functional verification.',
        );
        return null;
      }

      if (await this.isKeychainFunctional(keychainModule)) {
        return keychainModule;
      }

      debugLogger.debug('Keychain functional verification failed or timed out');
      return null;
    } catch (error) {
      // Avoid logging full error objects to prevent PII exposure.
      const message = error instanceof Error ? error.message : String(error);
      debugLogger.debug(
        'Keychain initialization encountered an error:',
        message,
      );
      return null;
    }
  }

  // Low-level dynamic loading and structural validation.
  private async loadKeychainModule(): Promise<Keychain | null> {
    const moduleName = '@github/keytar';
    const module: unknown = await import(moduleName);
    const potential = (isRecord(module) && module['default']) || module;

    const result = KeychainSchema.safeParse(potential);
    if (result.success) {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
      return potential as Keychain;
    }

    debugLogger.debug(
      'Keychain module failed structural validation:',
      result.error.flatten().fieldErrors,
    );
    return null;
  }

  // Performs a set-get-delete cycle to verify keychain functionality.
  // Capped with a 2s timeout so a non-responsive Secret Service (common on
  // headless Linux: WSL/SSH/Docker without gnome-keyring or D-Bus) falls back
  // to FileKeychain instead of hanging the CLI indefinitely.
  private async isKeychainFunctional(keychain: Keychain): Promise<boolean> {
    const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`;
    const testPassword = 'test';

    const probe = async (): Promise<boolean> => {
      await keychain.setPassword(this.serviceName, testAccount, testPassword);
      const retrieved = await keychain.getPassword(
        this.serviceName,
        testAccount,
      );
      const deleted = await keychain.deletePassword(
        this.serviceName,
        testAccount,
      );
      return deleted && retrieved === testPassword;
    };

    return Promise.race([
      probe(),
      new Promise<false>((resolve) =>
        setTimeout(() => resolve(false), 2000).unref(),
      ),
    ]);
  }

  /**
   * 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 @github/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);
  }
}