forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmacOsKeychainStorage.ts
More file actions
231 lines (216 loc) · 8.08 KB
/
Copy pathmacOsKeychainStorage.ts
File metadata and controls
231 lines (216 loc) · 8.08 KB
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
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
}