File size: 8,271 Bytes
064bfd6 | 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 | import { execaSync } from 'execa'
import { logForDebugging } from '../debug.js'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { execSyncWithDefaults_DEPRECATED } from '../execFileNoThrowPortable.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import {
CREDENTIALS_SERVICE_SUFFIX,
clearKeychainCache,
getMacOsKeychainStorageServiceName,
getUsername,
KEYCHAIN_CACHE_TTL_MS,
keychainCacheState,
} from './macOsKeychainHelpers.js'
import type { SecureStorage, SecureStorageData } from './types.js'
// `security -i` reads stdin with a 4096-byte fgets() buffer (BUFSIZ on darwin).
// A command line longer than this is truncated mid-argument: the first 4096
// bytes are consumed as one command (unterminated quote → fails), the overflow
// is interpreted as a second unknown command. Net: non-zero exit with NO data
// written, but the *previous* keychain entry is left intact — which fallback
// storage then reads as stale. See #30337.
// Headroom of 64B below the limit guards against edge-case line-terminator
// accounting differences.
const SECURITY_STDIN_LINE_LIMIT = 4096 - 64
export const macOsKeychainStorage = {
name: 'keychain',
read(): SecureStorageData | null {
const prev = keychainCacheState.cache
if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
return prev.data
}
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
const result = execSyncWithDefaults_DEPRECATED(
`security find-generic-password -a "${username}" -w -s "${storageServiceName}"`,
)
if (result) {
const data = jsonParse(result)
keychainCacheState.cache = { data, cachedAt: Date.now() }
return data
}
} catch (_e) {
// fall through
}
// Stale-while-error: if we had a value before and the refresh failed,
// keep serving the stale value rather than caching null. Since #23192
// clears the upstream memoize on every API request (macOS path), a
// single transient `security` spawn failure would otherwise poison the
// cache and surface as "Not logged in" across all subsystems until the
// next user interaction. clearKeychainCache() sets data=null, so
// explicit invalidation (logout, delete) still reads through.
if (prev.data !== null) {
logForDebugging('[keychain] read failed; serving stale cache', {
level: 'warn',
})
keychainCacheState.cache = { data: prev.data, cachedAt: Date.now() }
return prev.data
}
keychainCacheState.cache = { data: null, cachedAt: Date.now() }
return null
},
async readAsync(): Promise<SecureStorageData | null> {
const prev = keychainCacheState.cache
if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
return prev.data
}
if (keychainCacheState.readInFlight) {
return keychainCacheState.readInFlight
}
const gen = keychainCacheState.generation
const promise = doReadAsync().then(data => {
// If the cache was invalidated or updated while we were reading,
// our subprocess result is stale — don't overwrite the newer entry.
if (gen === keychainCacheState.generation) {
// Stale-while-error — mirror read() above.
if (data === null && prev.data !== null) {
logForDebugging('[keychain] readAsync failed; serving stale cache', {
level: 'warn',
})
}
const next = data ?? prev.data
keychainCacheState.cache = { data: next, cachedAt: Date.now() }
keychainCacheState.readInFlight = null
return next
}
return data
})
keychainCacheState.readInFlight = promise
return promise
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
// Invalidate cache before update
clearKeychainCache()
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
const jsonString = jsonStringify(data)
// Convert to hexadecimal to avoid any escaping issues
const hexValue = Buffer.from(jsonString, 'utf-8').toString('hex')
// Prefer stdin (`security -i`) so process monitors (CrowdStrike et al.)
// see only "security -i", not the payload (INC-3028).
// When the payload would overflow the stdin line buffer, fall back to
// argv. Hex in argv is recoverable by a determined observer but defeats
// naive plaintext-grep rules, and the alternative — silent credential
// corruption — is strictly worse. ARG_MAX on darwin is 1MB so argv has
// effectively no size limit for our purposes.
const command = `add-generic-password -U -a "${username}" -s "${storageServiceName}" -X "${hexValue}"\n`
let result
if (command.length <= SECURITY_STDIN_LINE_LIMIT) {
result = execaSync('security', ['-i'], {
input: command,
stdio: ['pipe', 'pipe', 'pipe'],
reject: false,
})
} else {
logForDebugging(
`Keychain payload (${jsonString.length}B JSON) exceeds security -i stdin limit; using argv`,
{ level: 'warn' },
)
result = execaSync(
'security',
[
'add-generic-password',
'-U',
'-a',
username,
'-s',
storageServiceName,
'-X',
hexValue,
],
{ stdio: ['ignore', 'pipe', 'pipe'], reject: false },
)
}
if (result.exitCode !== 0) {
return { success: false }
}
// Update cache with new data on success
keychainCacheState.cache = { data, cachedAt: Date.now() }
return { success: true }
} catch (_e) {
return { success: false }
}
},
delete(): boolean {
// Invalidate cache before delete
clearKeychainCache()
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
execSyncWithDefaults_DEPRECATED(
`security delete-generic-password -a "${username}" -s "${storageServiceName}"`,
)
return true
} catch (_e) {
return false
}
},
} satisfies SecureStorage
async function doReadAsync(): Promise<SecureStorageData | null> {
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
const { stdout, code } = await execFileNoThrow(
'security',
['find-generic-password', '-a', username, '-w', '-s', storageServiceName],
{ useCwd: false, preserveOutputOnError: false },
)
if (code === 0 && stdout) {
return jsonParse(stdout.trim())
}
} catch (_e) {
// fall through
}
return null
}
let keychainLockedCache: boolean | undefined
/**
* Checks if the macOS keychain is locked.
* Returns true if on macOS and keychain is locked (exit code 36 from security show-keychain-info).
* This commonly happens in SSH sessions where the keychain isn't automatically unlocked.
*
* Cached for process lifetime — execaSync('security', ...) is a ~27ms sync
* subprocess spawn, and this is called from render (AssistantTextMessage).
* During virtual-scroll remounts on sessions with "Not logged in" messages,
* each remount re-spawned security(1), adding 27ms/message to the commit.
* Keychain lock state doesn't change during a CLI session.
*/
export function isMacOsKeychainLocked(): boolean {
if (keychainLockedCache !== undefined) return keychainLockedCache
// Only check on macOS
if (process.platform !== 'darwin') {
keychainLockedCache = false
return false
}
try {
const result = execaSync('security', ['show-keychain-info'], {
reject: false,
stdio: ['ignore', 'pipe', 'pipe'],
})
// Exit code 36 indicates the keychain is locked
keychainLockedCache = result.exitCode === 36
} catch {
// If the command fails for any reason, assume keychain is not locked
keychainLockedCache = false
}
return keychainLockedCache
}
|