From d808026eecb2f5db33b89d456bb6a2610f41a02e Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Fri, 18 Sep 2026 17:25:17 +0200 Subject: [PATCH] feat(credentials): add scoped credential helpers --- scripts/commands/add-credentials.ps1 | 39 +++++++++ scripts/commands/add-credentials.sh | 28 ++++++ scripts/lib/credentials.ps1 | 125 +++++++++++++++++++++++++++ scripts/lib/with-credential.sh | 15 ++++ 4 files changed, 207 insertions(+) create mode 100644 scripts/commands/add-credentials.ps1 create mode 100644 scripts/commands/add-credentials.sh create mode 100644 scripts/lib/credentials.ps1 create mode 100644 scripts/lib/with-credential.sh diff --git a/scripts/commands/add-credentials.ps1 b/scripts/commands/add-credentials.ps1 new file mode 100644 index 0000000..8b7b5e3 --- /dev/null +++ b/scripts/commands/add-credentials.ps1 @@ -0,0 +1,39 @@ +[CmdletBinding()] +param( + [ValidateSet('Codex', 'Claude', 'Both')][string]$Client, + [string]$Key, + [switch]$ValueFromStdin, + [string]$HomePath = [Environment]::GetFolderPath('UserProfile') +) +$ErrorActionPreference = 'Stop' +$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +. (Join-Path $root 'scripts/lib/install-targets.ps1') +$selectedClient = $Client +$selectedKey = $Key +. (Join-Path $root 'scripts/lib/credentials.ps1') +$Client = $selectedClient +$Key = $selectedKey + +if (-not $Client) { $Client = Read-InstallClient -Client $Client } +if (-not $Key) { $Key = Read-Host 'Credential key' } +Assert-CredentialKey $Key +if (-not [System.IO.Path]::IsPathRooted($HomePath)) { throw 'HomePath must be absolute.' } +if ($ValueFromStdin) { + $text = [Console]::In.ReadToEnd().TrimEnd("`r", "`n") + $Value = ConvertTo-SecureString $text -AsPlainText -Force + $text = $null +} else { + $Value = Read-Host 'Credential value' -AsSecureString +} + +$bin = Join-Path $HomePath '.my-ai-configuration/bin' +New-Item -ItemType Directory -Path $bin -Force | Out-Null +foreach ($source in @('credentials.ps1', 'with-credential.sh')) { + $destination = Join-Path $bin $(if ($source -eq 'credentials.ps1') { 'with-credential.ps1' } else { $source }) + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item (Join-Path $root "scripts/lib/$source") $destination } +} +foreach ($target in $(if ($Client -eq 'Both') { @('Codex', 'Claude') } else { @($Client) })) { + Set-ManagedCredential $target $Key $Value + Write-Output "Credential stored for $($target.ToLowerInvariant())." +} +$Value = $null diff --git a/scripts/commands/add-credentials.sh b/scripts/commands/add-credentials.sh new file mode 100644 index 0000000..152c33c --- /dev/null +++ b/scripts/commands/add-credentials.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +client= +key= +home_path=${HOME:?HOME is required} +while [ $# -gt 0 ]; do + case "$1" in + --client) client=${2:?--client requires a value}; shift 2 ;; + --key) key=${2:?--key requires a value}; shift 2 ;; + --home) home_path=${2:?--home requires a value}; shift 2 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; + esac +done + +[ -n "$client" ] || read -r -p 'Select client (codex, claude, or both) ' client +[ -n "$key" ] || read -r -p 'Credential key ' key +case "$client" in codex|Codex|claude|Claude|both|Both) ;; *) printf 'Unknown --client value: %s\n' "$client" >&2; exit 1 ;; esac +case "$key" in [A-Za-z_][A-Za-z0-9_]* ) ;; *) printf 'Credential key must be an environment-variable-compatible name.\n' >&2; exit 1 ;; esac +case "$home_path" in /*|[A-Za-z]:[\\/]*) ;; *) printf 'Home path must be absolute.\n' >&2; exit 1 ;; esac + +IFS= read -r -s -p 'Credential value ' value +printf '\n' +powershell_command=$(command -v powershell.exe || command -v powershell || command -v pwsh || true) +[ -n "$powershell_command" ] || { printf 'PowerShell is required to store credentials in the Windows credential store.\n' >&2; exit 1; } +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +printf '%s' "$value" | "$powershell_command" -NoProfile -ExecutionPolicy Bypass -File "$script_dir/add-credentials.ps1" -Client "$client" -Key "$key" -ValueFromStdin -HomePath "$home_path" +unset value diff --git a/scripts/lib/credentials.ps1 b/scripts/lib/credentials.ps1 new file mode 100644 index 0000000..53dee52 --- /dev/null +++ b/scripts/lib/credentials.ps1 @@ -0,0 +1,125 @@ +[CmdletBinding()] +param( + [ValidateSet('Codex', 'Claude')][string]$Client, + [string]$Key, + [Parameter(Position = 2)][string]$FilePath, + [Parameter(Position = 3, ValueFromRemainingArguments = $true)][string[]]$ArgumentList +) + +$ErrorActionPreference = 'Stop' + +function Assert-CredentialKey { + param([string]$Value) + if ($Value -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') { throw 'Credential key must be an environment-variable-compatible name.' } +} + +function Get-CredentialTarget { + param([string]$Client, [string]$Key) + Assert-CredentialKey $Key + return "MyAiConfiguration/$($Client.ToLowerInvariant())/$Key" +} + +function Initialize-NativeCredentialApi { + if ('MyAiConfiguration.NativeCredential' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +namespace MyAiConfiguration { + public static class NativeCredential { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public UInt32 Flags; + public UInt32 Type; + public IntPtr TargetName; + public IntPtr Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public UInt32 CredentialBlobSize; + public IntPtr CredentialBlob; + public UInt32 Persist; + public UInt32 AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; + } + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredWrite(ref CREDENTIAL credential, UInt32 flags); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredRead(string target, UInt32 type, UInt32 flags, out IntPtr credential); + [DllImport("Advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredDelete(string target, UInt32 type, UInt32 flags); + [DllImport("Advapi32.dll", SetLastError = true)] + public static extern void CredFree(IntPtr buffer); + } +} +'@ +} + +function Set-ManagedCredential { + param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key, [securestring]$Value) + if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw 'The OS credential store is currently supported on Windows only.' } + Initialize-NativeCredentialApi + $plain = [System.Net.NetworkCredential]::new('', $Value).Password + $bytes = [Text.Encoding]::UTF8.GetBytes($plain) + if ($bytes.Length -eq 0 -or $bytes.Length -gt 2560) { throw 'Credential value must contain between 1 and 2560 UTF-8 bytes.' } + $native = New-Object MyAiConfiguration.NativeCredential+CREDENTIAL + $native.Type = 1 + $native.Persist = 2 + $native.TargetName = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni((Get-CredentialTarget $Client $Key)) + $native.UserName = [Runtime.InteropServices.Marshal]::StringToCoTaskMemUni($Key) + $native.CredentialBlobSize = $bytes.Length + $native.CredentialBlob = [Runtime.InteropServices.Marshal]::AllocCoTaskMem($bytes.Length) + try { + [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $native.CredentialBlob, $bytes.Length) + if (-not [MyAiConfiguration.NativeCredential]::CredWrite([ref]$native, 0)) { throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) } + } finally { + [Array]::Clear($bytes, 0, $bytes.Length) + $plain = $null + if ($native.TargetName -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeCoTaskMem($native.TargetName) } + if ($native.UserName -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeCoTaskMem($native.UserName) } + if ($native.CredentialBlob -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::FreeCoTaskMem($native.CredentialBlob) } + } +} + +function Get-ManagedCredential { + param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key) + if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw 'The OS credential store is currently supported on Windows only.' } + Initialize-NativeCredentialApi + $pointer = [IntPtr]::Zero + if (-not [MyAiConfiguration.NativeCredential]::CredRead((Get-CredentialTarget $Client $Key), 1, 0, [ref]$pointer)) { throw 'Credential was not found.' } + try { + $native = [Runtime.InteropServices.Marshal]::PtrToStructure($pointer, [type]'MyAiConfiguration.NativeCredential+CREDENTIAL') + $bytes = New-Object byte[] $native.CredentialBlobSize + [Runtime.InteropServices.Marshal]::Copy($native.CredentialBlob, $bytes, 0, $bytes.Length) + try { return [Text.Encoding]::UTF8.GetString($bytes) } finally { [Array]::Clear($bytes, 0, $bytes.Length) } + } finally { + [MyAiConfiguration.NativeCredential]::CredFree($pointer) + } +} + +function Remove-ManagedCredential { + param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key) + if (-not $IsWindows -and $PSVersionTable.PSVersion.Major -ge 6) { throw 'The OS credential store is currently supported on Windows only.' } + Initialize-NativeCredentialApi + if (-not [MyAiConfiguration.NativeCredential]::CredDelete((Get-CredentialTarget $Client $Key), 1, 0)) { throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) } +} + +function Invoke-WithManagedCredential { + param([ValidateSet('Codex', 'Claude')][string]$Client, [string]$Key, [string]$FilePath, [string[]]$ArgumentList) + Assert-CredentialKey $Key + if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { throw 'Child command was not found.' } + $prior = [Environment]::GetEnvironmentVariable($Key, 'Process') + $value = Get-ManagedCredential $Client $Key + try { + [Environment]::SetEnvironmentVariable($Key, $value, 'Process') + & $FilePath @ArgumentList + return $LASTEXITCODE + } finally { + [Environment]::SetEnvironmentVariable($Key, $prior, 'Process') + $value = $null + } +} + +if ($FilePath) { + if (-not $Client -or -not $Key) { throw 'Client and key are required.' } + exit (Invoke-WithManagedCredential $Client $Key $FilePath $ArgumentList) +} diff --git a/scripts/lib/with-credential.sh b/scripts/lib/with-credential.sh new file mode 100644 index 0000000..be3a66c --- /dev/null +++ b/scripts/lib/with-credential.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +client=${1:?client is required} +key=${2:?credential key is required} +shift 2 +[ $# -gt 0 ] || { printf 'Child command is required.\n' >&2; exit 1; } + +case "$client" in codex|Codex) client=Codex ;; claude|Claude) client=Claude ;; *) printf 'Unknown client: %s\n' "$client" >&2; exit 1 ;; esac +case "$key" in [A-Za-z_][A-Za-z0-9_]* ) ;; *) printf 'Credential key must be an environment-variable-compatible name.\n' >&2; exit 1 ;; esac + +powershell_command=$(command -v powershell.exe || command -v powershell || command -v pwsh || true) +[ -n "$powershell_command" ] || { printf 'PowerShell is required to read the Windows credential store.\n' >&2; exit 1; } +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec "$powershell_command" -NoProfile -ExecutionPolicy Bypass -File "$script_dir/with-credential.ps1" -Client "$client" -Key "$key" -FilePath "$1" "${@:2}"