source dump of claude code
at main 231 lines 8.3 kB view raw
1import { execaSync } from 'execa' 2import { logForDebugging } from '../debug.js' 3import { execFileNoThrow } from '../execFileNoThrow.js' 4import { execSyncWithDefaults_DEPRECATED } from '../execFileNoThrowPortable.js' 5import { jsonParse, jsonStringify } from '../slowOperations.js' 6import { 7 CREDENTIALS_SERVICE_SUFFIX, 8 clearKeychainCache, 9 getMacOsKeychainStorageServiceName, 10 getUsername, 11 KEYCHAIN_CACHE_TTL_MS, 12 keychainCacheState, 13} from './macOsKeychainHelpers.js' 14import 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. 24const SECURITY_STDIN_LINE_LIMIT = 4096 - 64 25 26export 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 178async 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 198let 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 */ 211export 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}