| 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' |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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) { |
| |
| } |
| |
| |
| |
| |
| |
| |
| |
| 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 (gen === keychainCacheState.generation) { |
| |
| 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 } { |
| |
| clearKeychainCache() |
|
|
| try { |
| const storageServiceName = getMacOsKeychainStorageServiceName( |
| CREDENTIALS_SERVICE_SUFFIX, |
| ) |
| const username = getUsername() |
| const jsonString = jsonStringify(data) |
|
|
| |
| const hexValue = Buffer.from(jsonString, 'utf-8').toString('hex') |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 } |
| } |
|
|
| |
| keychainCacheState.cache = { data, cachedAt: Date.now() } |
| return { success: true } |
| } catch (_e) { |
| return { success: false } |
| } |
| }, |
| delete(): boolean { |
| |
| 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) { |
| |
| } |
| return null |
| } |
|
|
| let keychainLockedCache: boolean | undefined |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function isMacOsKeychainLocked(): boolean { |
| if (keychainLockedCache !== undefined) return keychainLockedCache |
| |
| if (process.platform !== 'darwin') { |
| keychainLockedCache = false |
| return false |
| } |
|
|
| try { |
| const result = execaSync('security', ['show-keychain-info'], { |
| reject: false, |
| stdio: ['ignore', 'pipe', 'pipe'], |
| }) |
| |
| keychainLockedCache = result.exitCode === 36 |
| } catch { |
| |
| keychainLockedCache = false |
| } |
| return keychainLockedCache |
| } |
|
|