* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
226 lines
6.3 KiB
TypeScript
226 lines
6.3 KiB
TypeScript
import { execaSync } from 'execa'
|
|
import { join } from 'path'
|
|
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
|
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
|
import {
|
|
CREDENTIALS_SERVICE_SUFFIX,
|
|
getSecureStorageServiceName,
|
|
getUsername,
|
|
} from './macOsKeychainHelpers.js'
|
|
import type { SecureStorage, SecureStorageData } from './index.js'
|
|
|
|
/**
|
|
* Windows-specific secure storage implementation using DPAPI for new writes,
|
|
* with best-effort reads/deletes from the legacy PasswordVault path.
|
|
*/
|
|
function escapePowerShellSingleQuoted(value: string): string {
|
|
return value.replace(/'/g, "''")
|
|
}
|
|
|
|
function getLegacyResourceName(): string {
|
|
return getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX)
|
|
}
|
|
|
|
function getWindowsSecureStorageEntropy(): string {
|
|
return `${getLegacyResourceName()}:${getUsername()}`
|
|
}
|
|
|
|
function getWindowsSecureStorageFilePath(): string {
|
|
const resourceName = getLegacyResourceName().replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`)
|
|
}
|
|
|
|
function runPowerShell(
|
|
script: string,
|
|
options?: { input?: string },
|
|
): ReturnType<typeof execaSync> | null {
|
|
try {
|
|
return execaSync('powershell.exe', ['-Command', script], {
|
|
input: options?.input,
|
|
reject: false,
|
|
})
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function getFailureWarning(
|
|
result: ReturnType<typeof execaSync> | null,
|
|
fallback: string,
|
|
): string {
|
|
const stderr = result?.stderr?.trim()
|
|
if (stderr) {
|
|
return stderr
|
|
}
|
|
|
|
if (typeof result?.exitCode === 'number' && result.exitCode !== 0) {
|
|
return `${fallback} (exit code ${result.exitCode}).`
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
function readLegacyPasswordVault(): SecureStorageData | null {
|
|
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
|
const username = getUsername().replace(/"/g, '`"')
|
|
const script = `
|
|
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
try {
|
|
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
|
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
|
$cred.FillPassword()
|
|
[Console]::Out.Write($cred.Password)
|
|
} catch {
|
|
exit 1
|
|
}
|
|
`
|
|
|
|
const result = runPowerShell(script)
|
|
if (result?.exitCode === 0 && result.stdout) {
|
|
try {
|
|
return jsonParse(result.stdout)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export const windowsCredentialStorage: SecureStorage = {
|
|
name: 'credential-locker-dpapi',
|
|
read(): SecureStorageData | null {
|
|
const filePath = escapePowerShellSingleQuoted(
|
|
getWindowsSecureStorageFilePath(),
|
|
)
|
|
const entropy = escapePowerShellSingleQuoted(
|
|
getWindowsSecureStorageEntropy(),
|
|
)
|
|
const script = `
|
|
try {
|
|
Add-Type -AssemblyName System.Security
|
|
$path = '${filePath}'
|
|
if (!(Test-Path -LiteralPath $path)) {
|
|
exit 1
|
|
}
|
|
|
|
$protectedBase64 = [System.IO.File]::ReadAllText(
|
|
$path,
|
|
[System.Text.Encoding]::UTF8
|
|
).Trim()
|
|
if (-not $protectedBase64) {
|
|
exit 1
|
|
}
|
|
|
|
$protectedBytes = [Convert]::FromBase64String($protectedBase64)
|
|
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
|
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
|
$protectedBytes,
|
|
$entropyBytes,
|
|
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
|
)
|
|
[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes))
|
|
} catch {
|
|
exit 1
|
|
}
|
|
`
|
|
|
|
const result = runPowerShell(script)
|
|
if (result?.exitCode === 0 && result.stdout) {
|
|
try {
|
|
return jsonParse(result.stdout)
|
|
} catch {
|
|
return readLegacyPasswordVault()
|
|
}
|
|
}
|
|
|
|
return readLegacyPasswordVault()
|
|
},
|
|
async readAsync(): Promise<SecureStorageData | null> {
|
|
return this.read()
|
|
},
|
|
update(data: SecureStorageData): { success: boolean; warning?: string } {
|
|
const filePath = escapePowerShellSingleQuoted(
|
|
getWindowsSecureStorageFilePath(),
|
|
)
|
|
const entropy = escapePowerShellSingleQuoted(
|
|
getWindowsSecureStorageEntropy(),
|
|
)
|
|
const payload = jsonStringify(data)
|
|
const script = `
|
|
try {
|
|
Add-Type -AssemblyName System.Security
|
|
$path = '${filePath}'
|
|
$directory = [System.IO.Path]::GetDirectoryName($path)
|
|
if ($directory) {
|
|
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
|
}
|
|
|
|
$payload = [Console]::In.ReadToEnd()
|
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
|
|
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
|
$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
|
|
$bytes,
|
|
$entropyBytes,
|
|
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
|
)
|
|
$protectedBase64 = [Convert]::ToBase64String($protectedBytes)
|
|
[System.IO.File]::WriteAllText(
|
|
$path,
|
|
$protectedBase64,
|
|
[System.Text.Encoding]::UTF8
|
|
)
|
|
} catch {
|
|
Write-Error $_.Exception.Message
|
|
exit 1
|
|
}
|
|
`
|
|
const result = runPowerShell(script, { input: payload })
|
|
if (result?.exitCode === 0) {
|
|
return { success: true }
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
warning: getFailureWarning(
|
|
result,
|
|
'Windows secure storage could not encrypt credentials with DPAPI',
|
|
),
|
|
}
|
|
},
|
|
delete(): boolean {
|
|
const filePath = escapePowerShellSingleQuoted(
|
|
getWindowsSecureStorageFilePath(),
|
|
)
|
|
const removeDpapiScript = `
|
|
try {
|
|
$path = '${filePath}'
|
|
if (Test-Path -LiteralPath $path) {
|
|
Remove-Item -LiteralPath $path -Force
|
|
}
|
|
} catch {
|
|
exit 1
|
|
}
|
|
`
|
|
const removeDpapiResult = runPowerShell(removeDpapiScript)
|
|
|
|
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
|
const username = getUsername().replace(/"/g, '`"')
|
|
const removeLegacyScript = `
|
|
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
|
try {
|
|
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
|
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
|
$vault.Remove($cred)
|
|
} catch {
|
|
exit 0
|
|
}
|
|
`
|
|
const removeLegacyResult = runPowerShell(removeLegacyScript)
|
|
|
|
void removeLegacyResult
|
|
|
|
return (removeDpapiResult?.exitCode ?? 1) === 0
|
|
},
|
|
}
|