/ utils / secureStorage / macOsKeychainStorage.ts
macOsKeychainStorage.ts
  1  import { execaSync } from 'execa'
  2  import { logForDebugging } from '../debug.js'
  3  import { execFileNoThrow } from '../execFileNoThrow.js'
  4  import { execSyncWithDefaults_DEPRECATED } from '../execFileNoThrowPortable.js'
  5  import { jsonParse, jsonStringify } from '../slowOperations.js'
  6  import {
  7    CREDENTIALS_SERVICE_SUFFIX,
  8    clearKeychainCache,
  9    getMacOsKeychainStorageServiceName,
 10    getUsername,
 11    KEYCHAIN_CACHE_TTL_MS,
 12    keychainCacheState,
 13  } from './macOsKeychainHelpers.js'
 14  import type { SecureStorage, SecureStorageData } from './types.js'
 15  
 16  // `security -i` reads stdin with a 4096-byte fgets() buffer (BUFSIZ on darwin).
 17  // A command line longer than this is truncated mid-argument: the first 4096
 18  // bytes are consumed as one command (unterminated quote → fails), the overflow
 19  // is interpreted as a second unknown command. Net: non-zero exit with NO data
 20  // written, but the *previous* keychain entry is left intact — which fallback
 21  // storage then reads as stale. See #30337.
 22  // Headroom of 64B below the limit guards against edge-case line-terminator
 23  // accounting differences.
 24  const SECURITY_STDIN_LINE_LIMIT = 4096 - 64
 25  
 26  export const macOsKeychainStorage = {
 27    name: 'keychain',
 28    read(): SecureStorageData | null {
 29      const prev = keychainCacheState.cache
 30      if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
 31        return prev.data
 32      }
 33  
 34      try {
 35        const storageServiceName = getMacOsKeychainStorageServiceName(
 36          CREDENTIALS_SERVICE_SUFFIX,
 37        )
 38        const username = getUsername()
 39        const result = execSyncWithDefaults_DEPRECATED(
 40          `security find-generic-password -a "${username}" -w -s "${storageServiceName}"`,
 41        )
 42        if (result) {
 43          const data = jsonParse(result)
 44          keychainCacheState.cache = { data, cachedAt: Date.now() }
 45          return data
 46        }
 47      } catch (_e) {
 48        // fall through
 49      }
 50      // Stale-while-error: if we had a value before and the refresh failed,
 51      // keep serving the stale value rather than caching null. Since #23192
 52      // clears the upstream memoize on every API request (macOS path), a
 53      // single transient `security` spawn failure would otherwise poison the
 54      // cache and surface as "Not logged in" across all subsystems until the
 55      // next user interaction. clearKeychainCache() sets data=null, so
 56      // explicit invalidation (logout, delete) still reads through.
 57      if (prev.data !== null) {
 58        logForDebugging('[keychain] read failed; serving stale cache', {
 59          level: 'warn',
 60        })
 61        keychainCacheState.cache = { data: prev.data, cachedAt: Date.now() }
 62        return prev.data
 63      }
 64      keychainCacheState.cache = { data: null, cachedAt: Date.now() }
 65      return null
 66    },
 67    async readAsync(): Promise<SecureStorageData | null> {
 68      const prev = keychainCacheState.cache
 69      if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
 70        return prev.data
 71      }
 72      if (keychainCacheState.readInFlight) {
 73        return keychainCacheState.readInFlight
 74      }
 75  
 76      const gen = keychainCacheState.generation
 77      const promise = doReadAsync().then(data => {
 78        // If the cache was invalidated or updated while we were reading,
 79        // our subprocess result is stale — don't overwrite the newer entry.
 80        if (gen === keychainCacheState.generation) {
 81          // Stale-while-error — mirror read() above.
 82          if (data === null && prev.data !== null) {
 83            logForDebugging('[keychain] readAsync failed; serving stale cache', {
 84              level: 'warn',
 85            })
 86          }
 87          const next = data ?? prev.data
 88          keychainCacheState.cache = { data: next, cachedAt: Date.now() }
 89          keychainCacheState.readInFlight = null
 90          return next
 91        }
 92        return data
 93      })
 94      keychainCacheState.readInFlight = promise
 95      return promise
 96    },
 97    update(data: SecureStorageData): { success: boolean; warning?: string } {
 98      // Invalidate cache before update
 99      clearKeychainCache()
100  
101      try {
102        const storageServiceName = getMacOsKeychainStorageServiceName(
103          CREDENTIALS_SERVICE_SUFFIX,
104        )
105        const username = getUsername()
106        const jsonString = jsonStringify(data)
107  
108        // Convert to hexadecimal to avoid any escaping issues
109        const hexValue = Buffer.from(jsonString, 'utf-8').toString('hex')
110  
111        // Prefer stdin (`security -i`) so process monitors (CrowdStrike et al.)
112        // see only "security -i", not the payload (INC-3028).
113        // When the payload would overflow the stdin line buffer, fall back to
114        // argv. Hex in argv is recoverable by a determined observer but defeats
115        // naive plaintext-grep rules, and the alternative — silent credential
116        // corruption — is strictly worse. ARG_MAX on darwin is 1MB so argv has
117        // effectively no size limit for our purposes.
118        const command = `add-generic-password -U -a "${username}" -s "${storageServiceName}" -X "${hexValue}"\n`
119  
120        let result
121        if (command.length <= SECURITY_STDIN_LINE_LIMIT) {
122          result = execaSync('security', ['-i'], {
123            input: command,
124            stdio: ['pipe', 'pipe', 'pipe'],
125            reject: false,
126          })
127        } else {
128          logForDebugging(
129            `Keychain payload (${jsonString.length}B JSON) exceeds security -i stdin limit; using argv`,
130            { level: 'warn' },
131          )
132          result = execaSync(
133            'security',
134            [
135              'add-generic-password',
136              '-U',
137              '-a',
138              username,
139              '-s',
140              storageServiceName,
141              '-X',
142              hexValue,
143            ],
144            { stdio: ['ignore', 'pipe', 'pipe'], reject: false },
145          )
146        }
147  
148        if (result.exitCode !== 0) {
149          return { success: false }
150        }
151  
152        // Update cache with new data on success
153        keychainCacheState.cache = { data, cachedAt: Date.now() }
154        return { success: true }
155      } catch (_e) {
156        return { success: false }
157      }
158    },
159    delete(): boolean {
160      // Invalidate cache before delete
161      clearKeychainCache()
162  
163      try {
164        const storageServiceName = getMacOsKeychainStorageServiceName(
165          CREDENTIALS_SERVICE_SUFFIX,
166        )
167        const username = getUsername()
168        execSyncWithDefaults_DEPRECATED(
169          `security delete-generic-password -a "${username}" -s "${storageServiceName}"`,
170        )
171        return true
172      } catch (_e) {
173        return false
174      }
175    },
176  } satisfies SecureStorage
177  
178  async function doReadAsync(): Promise<SecureStorageData | null> {
179    try {
180      const storageServiceName = getMacOsKeychainStorageServiceName(
181        CREDENTIALS_SERVICE_SUFFIX,
182      )
183      const username = getUsername()
184      const { stdout, code } = await execFileNoThrow(
185        'security',
186        ['find-generic-password', '-a', username, '-w', '-s', storageServiceName],
187        { useCwd: false, preserveOutputOnError: false },
188      )
189      if (code === 0 && stdout) {
190        return jsonParse(stdout.trim())
191      }
192    } catch (_e) {
193      // fall through
194    }
195    return null
196  }
197  
198  let keychainLockedCache: boolean | undefined
199  
200  /**
201   * Checks if the macOS keychain is locked.
202   * Returns true if on macOS and keychain is locked (exit code 36 from security show-keychain-info).
203   * This commonly happens in SSH sessions where the keychain isn't automatically unlocked.
204   *
205   * Cached for process lifetime — execaSync('security', ...) is a ~27ms sync
206   * subprocess spawn, and this is called from render (AssistantTextMessage).
207   * During virtual-scroll remounts on sessions with "Not logged in" messages,
208   * each remount re-spawned security(1), adding 27ms/message to the commit.
209   * Keychain lock state doesn't change during a CLI session.
210   */
211  export function isMacOsKeychainLocked(): boolean {
212    if (keychainLockedCache !== undefined) return keychainLockedCache
213    // Only check on macOS
214    if (process.platform !== 'darwin') {
215      keychainLockedCache = false
216      return false
217    }
218  
219    try {
220      const result = execaSync('security', ['show-keychain-info'], {
221        reject: false,
222        stdio: ['ignore', 'pipe', 'pipe'],
223      })
224      // Exit code 36 indicates the keychain is locked
225      keychainLockedCache = result.exitCode === 36
226    } catch {
227      // If the command fails for any reason, assume keychain is not locked
228      keychainLockedCache = false
229    }
230    return keychainLockedCache
231  }