From 4aa4bf1a582f71404beff28b64bac56e7d95de7d Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 21 Aug 2026 12:04:30 +0800 Subject: [PATCH 1/5] fix: support secure WSL credential storage --- README.md | 6 +- docs/architecture.md | 3 +- docs/compatibility.md | 19 +++ docs/security.md | 11 +- docs/troubleshooting.md | 25 +++ internal/secretstore/store_linux.go | 117 +++++++++++-- internal/secretstore/store_linux_test.go | 155 ++++++++++++++++++ internal/secretstore/store_wsl_bridge.go | 155 ++++++++++++++++++ .../store_wsl_bridge_windows_test.go | 38 +++++ 9 files changed, 516 insertions(+), 13 deletions(-) create mode 100644 internal/secretstore/store_linux_test.go create mode 100644 internal/secretstore/store_wsl_bridge.go create mode 100644 internal/secretstore/store_wsl_bridge_windows_test.go diff --git a/README.md b/README.md index 6b68d00..529e2d2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ atomically projects the selected profile into the existing Codex home. ## Security model - A random 256-bit vault key is stored in macOS Keychain, Windows Credential - Manager, or Linux Secret Service. + Manager, Linux Secret Service, or Windows DPAPI when running inside WSL2. - Account bundles are encrypted at rest with XChaCha20-Poly1305. - Only the active account is present in the Codex plaintext file store. - Tokens are never printed by commands, JSON output, or diagnostics. @@ -41,6 +41,10 @@ go install github.com/SilkageNet/codex-switch/cmd/codex-switch@latest Release archives for macOS, Linux, and Windows are published on GitHub. +WSL2 is supported by the Linux archive. It uses the Windows user's DPAPI +protection through the built-in `powershell.exe`; a Linux desktop Secret Service +session is not required. + ## Quick start ```bash diff --git a/docs/architecture.md b/docs/architecture.md index 0c09bdd..ddcf92c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,7 +16,8 @@ - `codexlogin` runs official login in a temporary `CODEX_HOME` configured for file storage, then imports the resulting document. - `secretstore` protects a small random vault key with the operating-system - credential store. + credential store. WSL uses a Windows PowerShell bridge to protect the key with + current-user DPAPI and store only ciphertext in HKCU. - `vault` encrypts all saved account profiles with XChaCha20-Poly1305. - `switcher` reconciles a live Codex refresh generation, prepares a journal, performs compare-before-replace, and records the selected profile. diff --git a/docs/compatibility.md b/docs/compatibility.md index 8e7156b..8dd2b88 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -25,6 +25,25 @@ codex-switch init --enable-file-store The command creates a timestamped backup before making a surgical top-level `config.toml` edit. Normal account switches do not edit `config.toml`. +## WSL2 + +The Linux build detects WSL through the standard WSL environment and Microsoft +kernel markers. On WSL it prefers Windows PowerShell and current-user DPAPI over +Linux Secret Service: + +- the generated vault key is encrypted for the current Windows user; +- only the DPAPI ciphertext is stored under + `HKCU\Software\SilkageNet\codex-switch\secrets`; +- the key is sent to the static PowerShell bridge over standard input and is not + placed in command-line arguments; +- no plaintext fallback file is created in the WSL filesystem. + +Windows interoperability and the default `/mnt/c` mount must be enabled. Both +Windows PowerShell 5.1 (`powershell.exe`) and PowerShell 7 (`pwsh.exe`) are +recognized. If `secret-tool` is also available, it remains a compatibility +fallback so vaults created by earlier Linux builds can still be read and +rotated. + ## Codex releases Development began against Codex CLI `0.148.0-alpha.15`; isolated account-usage diff --git a/docs/security.md b/docs/security.md index 9db9294..e0010fb 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,6 +11,12 @@ Saved profiles are encrypted with XChaCha20-Poly1305. A separate random key is stored in macOS Keychain, Windows Credential Manager, or Linux Secret Service. The key is never stored next to the ciphertext. +On WSL2, where a Linux desktop Secret Service is commonly unavailable, the key +is protected by Windows DPAPI for the current Windows user. The resulting +ciphertext is stored in HKCU, separate from the encrypted vault in the WSL +filesystem. The embedded PowerShell bridge is static; secret values travel over +standard input, never process arguments, and bridge diagnostics are redacted. + The active profile must be readable by Codex and is therefore projected into the officially supported plaintext file store. That file is created with mode `0600` on Unix. On Windows it lives in the current user's profile and is @@ -41,6 +47,8 @@ focuses on: compare-before-replace check under the shared operation lock. - Real credentials are forbidden in tests and fixtures. - The Linux desktop implementation fails closed when Secret Service is absent. + WSL fails closed when neither the Windows DPAPI bridge nor Secret Service is + available; it never creates a plaintext key fallback. - Portable backups require a passphrase of at least 12 characters and use Argon2id before XChaCha20-Poly1305 encryption. - Authentication documents larger than the configured limit are rejected. @@ -54,4 +62,5 @@ than reading standard input, the adapter supplies the vault key directly to `-w`. The value can therefore be visible briefly to processes running as the same operating-system user, which is inside this project's trust boundary. Linux sends values to `secret-tool` over standard input. Windows calls the -native Credential Manager API directly. +native Credential Manager API directly. WSL invokes Windows PowerShell without +a profile and uses current-user DPAPI plus a hashed registry value name. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1a0c6b5..54b5e17 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -19,6 +19,31 @@ codex-switch init --enable-file-store A timestamped `config.toml.codex-switch.bak.*` file is created first. +## WSL reports that secret-tool is unavailable + +Update to a WSL-capable release and rerun initialization: + +```bash +codex-switch update +codex-switch init --enable-file-store +``` + +WSL2 does not need `secret-tool`. `codex-switch` uses Windows DPAPI through the +Windows PowerShell executable and stores only encrypted bytes in the current +Windows user's registry. + +If the updated command reports that PowerShell is unavailable, verify WSL +interoperability: + +```bash +powershell.exe -NoLogo -NoProfile -Command '$PSVersionTable.PSVersion' +``` + +If that executable cannot run, enable Windows interoperability and the `/mnt/c` +mount in WSL, restart the distribution with `wsl.exe --shutdown` from Windows, +and retry. Installing `libsecret-tools` alone is not sufficient unless the WSL +distribution also runs a working Secret Service and DBus session. + ## Codex is still running Quit the desktop app and stop active `codex` CLI processes. The tool refuses the diff --git a/internal/secretstore/store_linux.go b/internal/secretstore/store_linux.go index 264782d..3781936 100644 --- a/internal/secretstore/store_linux.go +++ b/internal/secretstore/store_linux.go @@ -4,25 +4,50 @@ package secretstore import ( "bytes" + "errors" "fmt" + "os" "os/exec" + "path/filepath" "strings" ) -type linuxStore struct{} +type linuxStore struct { + binary string +} + +type wslStore struct { + primary Store + fallback Store +} func Open() (Store, error) { - if _, err := exec.LookPath("secret-tool"); err != nil { - return nil, fmt.Errorf("secret-tool client for Secret Service is unavailable: %w", err) + secretTool, secretToolErr := exec.LookPath("secret-tool") + if isWSL() { + powershell, powershellErr := findWindowsPowerShell() + if powershellErr == nil { + store := wslStore{primary: powershellStore{binary: powershell}} + if secretToolErr == nil { + store.fallback = linuxStore{binary: secretTool} + } + return store, nil + } + if secretToolErr == nil { + return linuxStore{binary: secretTool}, nil + } + return nil, fmt.Errorf("WSL was detected, but neither Windows PowerShell nor secret-tool is available: %w", powershellErr) + } + if secretToolErr != nil { + return nil, fmt.Errorf("secret-tool client for Secret Service is unavailable; install the libsecret command-line tools: %w", secretToolErr) } - return linuxStore{}, nil + return linuxStore{binary: secretTool}, nil } -func (linuxStore) Set(key, value string) error { +func (store linuxStore) Set(key, value string) error { if err := validateKey(key); err != nil { return err } - command := exec.Command("secret-tool", "store", "--label", "codex-switch "+key, "service", "codex-switch", "target", target(key)) + command := exec.Command(store.binary, "store", "--label", "codex-switch "+key, "service", "codex-switch", "target", target(key)) command.Stdin = strings.NewReader(value) if output, err := command.CombinedOutput(); err != nil { return fmt.Errorf("write Secret Service entry: %s: %w", strings.TrimSpace(string(output)), err) @@ -30,11 +55,11 @@ func (linuxStore) Set(key, value string) error { return nil } -func (linuxStore) Get(key string) (string, error) { +func (store linuxStore) Get(key string) (string, error) { if err := validateKey(key); err != nil { return "", err } - command := exec.Command("secret-tool", "lookup", "service", "codex-switch", "target", target(key)) + command := exec.Command(store.binary, "lookup", "service", "codex-switch", "target", target(key)) var stderr bytes.Buffer command.Stderr = &stderr output, err := command.Output() @@ -50,11 +75,11 @@ func (linuxStore) Get(key string) (string, error) { return strings.TrimRight(string(output), "\r\n"), nil } -func (linuxStore) Delete(key string) error { +func (store linuxStore) Delete(key string) error { if err := validateKey(key); err != nil { return err } - command := exec.Command("secret-tool", "clear", "service", "codex-switch", "target", target(key)) + command := exec.Command(store.binary, "clear", "service", "codex-switch", "target", target(key)) if output, err := command.CombinedOutput(); err != nil { if strings.TrimSpace(string(output)) == "" { return ErrNotFound @@ -63,3 +88,75 @@ func (linuxStore) Delete(key string) error { } return nil } + +func (store wslStore) Set(key, value string) error { + primaryErr := store.primary.Set(key, value) + if primaryErr == nil || store.fallback == nil { + return primaryErr + } + fallbackErr := store.fallback.Set(key, value) + if fallbackErr == nil { + return nil + } + return fmt.Errorf("write WSL credential store: %w", errors.Join(primaryErr, fallbackErr)) +} + +func (store wslStore) Get(key string) (string, error) { + value, primaryErr := store.primary.Get(key) + if primaryErr == nil || store.fallback == nil { + return value, primaryErr + } + value, fallbackErr := store.fallback.Get(key) + if fallbackErr == nil { + return value, nil + } + if errors.Is(primaryErr, ErrNotFound) && errors.Is(fallbackErr, ErrNotFound) { + return "", ErrNotFound + } + return "", fmt.Errorf("read WSL credential store: %w", errors.Join(primaryErr, fallbackErr)) +} + +func (store wslStore) Delete(key string) error { + primaryErr := store.primary.Delete(key) + if store.fallback == nil { + return primaryErr + } + fallbackErr := store.fallback.Delete(key) + if primaryErr == nil || fallbackErr == nil { + return nil + } + if errors.Is(primaryErr, ErrNotFound) && errors.Is(fallbackErr, ErrNotFound) { + return ErrNotFound + } + return fmt.Errorf("delete WSL credential store: %w", errors.Join(primaryErr, fallbackErr)) +} + +func isWSL() bool { + if os.Getenv("WSL_DISTRO_NAME") != "" || os.Getenv("WSL_INTEROP") != "" { + return true + } + for _, path := range []string{"/proc/sys/kernel/osrelease", "/proc/version"} { + data, err := os.ReadFile(path) + if err == nil && strings.Contains(strings.ToLower(string(data)), "microsoft") { + return true + } + } + return false +} + +func findWindowsPowerShell() (string, error) { + for _, name := range []string{"powershell.exe", "pwsh.exe"} { + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + } + for _, path := range []string{ + "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + "/mnt/c/Program Files/PowerShell/7/pwsh.exe", + } { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + return filepath.Clean(path), nil + } + } + return "", errors.New("windows PowerShell executable was not found; enable WSL interoperability and Windows drive mounting") +} diff --git a/internal/secretstore/store_linux_test.go b/internal/secretstore/store_linux_test.go new file mode 100644 index 0000000..e8d752c --- /dev/null +++ b/internal/secretstore/store_linux_test.go @@ -0,0 +1,155 @@ +//go:build linux + +package secretstore + +import ( + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf16" +) + +func TestOpenUsesWindowsDPAPIOnWSLWithoutSecretTool(t *testing.T) { + directory := t.TempDir() + powershell := filepath.Join(directory, "powershell.exe") + if err := os.WriteFile(powershell, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", directory) + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + store, err := Open() + if err != nil { + t.Fatal(err) + } + bridge, ok := store.(wslStore) + if !ok { + t.Fatalf("expected WSL store, got %T", store) + } + if bridge.fallback != nil { + t.Fatalf("unexpected Secret Service fallback: %T", bridge.fallback) + } + primary, ok := bridge.primary.(powershellStore) + if !ok || primary.binary != powershell { + t.Fatalf("unexpected WSL primary store: %#v", bridge.primary) + } +} + +func TestPowerShellStoreKeepsSecretOutOfArguments(t *testing.T) { + store, argumentsPath, inputPath := testPowerShellStore(t) + t.Setenv("CODEX_SWITCH_BRIDGE_MODE", "set") + if err := store.Set("master-key/test", "generated-vault-key"); err != nil { + t.Fatal(err) + } + arguments, err := os.ReadFile(argumentsPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(arguments), "generated-vault-key") { + t.Fatal("secret was exposed in process arguments") + } + input, err := os.ReadFile(inputPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(input), "generated-vault-key") { + t.Fatal("secret was not encoded in the stdin request") + } + var request powershellRequest + if err := json.Unmarshal(input, &request); err != nil { + t.Fatal(err) + } + decoded, err := base64.StdEncoding.DecodeString(request.Value) + if err != nil || string(decoded) != "generated-vault-key" { + t.Fatalf("unexpected bridge request: %#v, %v", request, err) + } +} + +func TestPowerShellStoreGetAndNotFound(t *testing.T) { + store, _, _ := testPowerShellStore(t) + t.Setenv("CODEX_SWITCH_BRIDGE_MODE", "get") + value, err := store.Get("master-key/test") + if err != nil { + t.Fatal(err) + } + if value != "stored-vault-key" { + t.Fatalf("unexpected stored value %q", value) + } + + t.Setenv("CODEX_SWITCH_BRIDGE_MODE", "missing") + if err := store.Delete("master-key/test"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestPowerShellStoreRedactsDiagnostics(t *testing.T) { + store, _, _ := testPowerShellStore(t) + t.Setenv("CODEX_SWITCH_BRIDGE_MODE", "fail") + _, err := store.Get("master-key/test") + if err == nil || strings.Contains(err.Error(), "sensitive diagnostic") || !strings.Contains(err.Error(), "redacted") { + t.Fatalf("unexpected bridge error: %v", err) + } +} + +func TestWSLStoreFallsBackToSecretService(t *testing.T) { + fallback := NewMemoryStore() + if err := fallback.Set("master-key/test", "legacy-value"); err != nil { + t.Fatal(err) + } + store := wslStore{primary: errorStore{err: ErrNotFound}, fallback: fallback} + value, err := store.Get("master-key/test") + if err != nil || value != "legacy-value" { + t.Fatalf("fallback read failed: %q, %v", value, err) + } +} + +func TestEncodePowerShellUsesUTF16LE(t *testing.T) { + encoded, err := base64.StdEncoding.DecodeString(encodePowerShell("A中")) + if err != nil { + t.Fatal(err) + } + units := make([]uint16, len(encoded)/2) + for index := range units { + units[index] = uint16(encoded[index*2]) | uint16(encoded[index*2+1])<<8 + } + if decoded := string(utf16.Decode(units)); decoded != "A中" { + t.Fatalf("unexpected encoded command %q", decoded) + } +} + +type errorStore struct { + err error +} + +func (store errorStore) Set(string, string) error { return store.err } + +func (store errorStore) Get(string) (string, error) { return "", store.err } + +func (store errorStore) Delete(string) error { return store.err } + +func testPowerShellStore(t *testing.T) (powershellStore, string, string) { + t.Helper() + directory := t.TempDir() + argumentsPath := filepath.Join(directory, "arguments") + inputPath := filepath.Join(directory, "input") + powershell := filepath.Join(directory, "powershell.exe") + stub := `#!/bin/sh +printf '%s\n' "$@" > "$CODEX_SWITCH_ARGUMENTS" +cat > "$CODEX_SWITCH_INPUT" +case "$CODEX_SWITCH_BRIDGE_MODE" in + get) printf 'c3RvcmVkLXZhdWx0LWtleQ==' ;; + missing) exit 44 ;; + fail) printf 'sensitive diagnostic' >&2; exit 1 ;; +esac +` + if err := os.WriteFile(powershell, []byte(stub), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("CODEX_SWITCH_ARGUMENTS", argumentsPath) + t.Setenv("CODEX_SWITCH_INPUT", inputPath) + return powershellStore{binary: powershell}, argumentsPath, inputPath +} diff --git a/internal/secretstore/store_wsl_bridge.go b/internal/secretstore/store_wsl_bridge.go new file mode 100644 index 0000000..a8b4d31 --- /dev/null +++ b/internal/secretstore/store_wsl_bridge.go @@ -0,0 +1,155 @@ +//go:build linux || windows + +package secretstore + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "unicode/utf16" +) + +type powershellStore struct { + binary string +} + +type powershellRequest struct { + Operation string `json:"operation"` + Target string `json:"target"` + Value string `json:"value,omitempty"` +} + +func (store powershellStore) Set(key, value string) error { + if err := validateKey(key); err != nil { + return err + } + request := powershellRequest{ + Operation: "set", + Target: target(key), + Value: base64.StdEncoding.EncodeToString([]byte(value)), + } + _, err := store.run(request) + return err +} + +func (store powershellStore) Get(key string) (string, error) { + if err := validateKey(key); err != nil { + return "", err + } + output, err := store.run(powershellRequest{Operation: "get", Target: target(key)}) + if err != nil { + return "", err + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(output))) + if err != nil { + return "", errors.New("windows DPAPI returned an invalid credential payload") + } + return string(decoded), nil +} + +func (store powershellStore) Delete(key string) error { + if err := validateKey(key); err != nil { + return err + } + _, err := store.run(powershellRequest{Operation: "delete", Target: target(key)}) + return err +} + +func (store powershellStore) run(request powershellRequest) ([]byte, error) { + input, err := json.Marshal(request) + if err != nil { + return nil, err + } + command := exec.Command( + store.binary, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encodePowerShell(wslPowerShellScript), + ) + command.Stdin = bytes.NewReader(input) + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 44 { + return nil, ErrNotFound + } + message := fmt.Sprintf("run Windows DPAPI credential bridge for %s: %v", request.Operation, err) + if strings.TrimSpace(stderr.String()) != "" { + message += " (PowerShell diagnostics redacted)" + } + return nil, errors.New(message) + } + return stdout.Bytes(), nil +} + +func encodePowerShell(script string) string { + encodedRunes := utf16.Encode([]rune(script)) + encodedBytes := make([]byte, len(encodedRunes)*2) + for index, value := range encodedRunes { + encodedBytes[index*2] = byte(value) + encodedBytes[index*2+1] = byte(value >> 8) + } + return base64.StdEncoding.EncodeToString(encodedBytes) +} + +const wslPowerShellScript = `$ErrorActionPreference = 'Stop' +try { + $request = [Console]::In.ReadToEnd() | ConvertFrom-Json + $targetBytes = [Text.Encoding]::UTF8.GetBytes([string]$request.target) + $sha = [Security.Cryptography.SHA256]::Create() + try { + $property = -join ($sha.ComputeHash($targetBytes) | ForEach-Object { $_.ToString('x2') }) + } finally { + $sha.Dispose() + } + $root = 'HKCU:\Software\SilkageNet\codex-switch\secrets' + $entropy = [Text.Encoding]::UTF8.GetBytes('codex-switch:wsl-dpapi:v1') + $scope = [Security.Cryptography.DataProtectionScope]::CurrentUser + + switch ([string]$request.operation) { + 'set' { + $plain = [Convert]::FromBase64String([string]$request.value) + try { + $cipher = [Security.Cryptography.ProtectedData]::Protect($plain, $entropy, $scope) + if (-not (Test-Path -LiteralPath $root)) { + New-Item -Path $root -Force | Out-Null + } + New-ItemProperty -LiteralPath $root -Name $property -PropertyType Binary -Value $cipher -Force | Out-Null + } finally { + if ($null -ne $plain) { [Array]::Clear($plain, 0, $plain.Length) } + } + } + 'get' { + if (-not (Test-Path -LiteralPath $root)) { exit 44 } + $item = Get-Item -LiteralPath $root + if ($item.GetValueNames() -notcontains $property) { exit 44 } + $cipher = [byte[]]$item.GetValue($property) + $plain = [Security.Cryptography.ProtectedData]::Unprotect($cipher, $entropy, $scope) + try { + [Console]::Out.Write([Convert]::ToBase64String($plain)) + } finally { + [Array]::Clear($plain, 0, $plain.Length) + } + } + 'delete' { + if (-not (Test-Path -LiteralPath $root)) { exit 44 } + $item = Get-Item -LiteralPath $root + if ($item.GetValueNames() -notcontains $property) { exit 44 } + Remove-ItemProperty -LiteralPath $root -Name $property + } + default { throw 'unsupported credential operation' } + } +} catch { + [Console]::Error.Write('codex-switch WSL credential bridge failed') + exit 1 +} +` diff --git a/internal/secretstore/store_wsl_bridge_windows_test.go b/internal/secretstore/store_wsl_bridge_windows_test.go new file mode 100644 index 0000000..94ce614 --- /dev/null +++ b/internal/secretstore/store_wsl_bridge_windows_test.go @@ -0,0 +1,38 @@ +//go:build windows + +package secretstore + +import ( + "errors" + "fmt" + "os/exec" + "testing" + "time" +) + +func TestWindowsPowerShellDPAPIBridgeRoundTrip(t *testing.T) { + powershell, err := exec.LookPath("powershell.exe") + if err != nil { + t.Skip("Windows PowerShell is unavailable") + } + store := powershellStore{binary: powershell} + key := fmt.Sprintf("integration-test/%d", time.Now().UnixNano()) + defer func() { _ = store.Delete(key) }() + + if err := store.Set(key, "temporary-test-secret"); err != nil { + t.Fatal(err) + } + value, err := store.Get(key) + if err != nil { + t.Fatal(err) + } + if value != "temporary-test-secret" { + t.Fatalf("unexpected DPAPI round trip value %q", value) + } + if err := store.Delete(key); err != nil { + t.Fatal(err) + } + if _, err := store.Get(key); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected deleted DPAPI value to be missing, got %v", err) + } +} From 7a307485a8d6794c2145670ad0d27ed9033dfab1 Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 21 Aug 2026 12:07:40 +0800 Subject: [PATCH 2/5] fix: use native registry api for WSL bridge --- internal/secretstore/store_wsl_bridge.go | 34 +++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/internal/secretstore/store_wsl_bridge.go b/internal/secretstore/store_wsl_bridge.go index a8b4d31..6c93fa1 100644 --- a/internal/secretstore/store_wsl_bridge.go +++ b/internal/secretstore/store_wsl_bridge.go @@ -111,7 +111,7 @@ try { } finally { $sha.Dispose() } - $root = 'HKCU:\Software\SilkageNet\codex-switch\secrets' + $root = 'Software\SilkageNet\codex-switch\secrets' $entropy = [Text.Encoding]::UTF8.GetBytes('codex-switch:wsl-dpapi:v1') $scope = [Security.Cryptography.DataProtectionScope]::CurrentUser @@ -120,19 +120,25 @@ try { $plain = [Convert]::FromBase64String([string]$request.value) try { $cipher = [Security.Cryptography.ProtectedData]::Protect($plain, $entropy, $scope) - if (-not (Test-Path -LiteralPath $root)) { - New-Item -Path $root -Force | Out-Null + $registryKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($root) + try { + $registryKey.SetValue($property, $cipher, [Microsoft.Win32.RegistryValueKind]::Binary) + } finally { + if ($null -ne $registryKey) { $registryKey.Dispose() } } - New-ItemProperty -LiteralPath $root -Name $property -PropertyType Binary -Value $cipher -Force | Out-Null } finally { if ($null -ne $plain) { [Array]::Clear($plain, 0, $plain.Length) } } } 'get' { - if (-not (Test-Path -LiteralPath $root)) { exit 44 } - $item = Get-Item -LiteralPath $root - if ($item.GetValueNames() -notcontains $property) { exit 44 } - $cipher = [byte[]]$item.GetValue($property) + $registryKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($root, $false) + if ($null -eq $registryKey) { exit 44 } + try { + if ($registryKey.GetValueNames() -notcontains $property) { exit 44 } + $cipher = [byte[]]$registryKey.GetValue($property) + } finally { + $registryKey.Dispose() + } $plain = [Security.Cryptography.ProtectedData]::Unprotect($cipher, $entropy, $scope) try { [Console]::Out.Write([Convert]::ToBase64String($plain)) @@ -141,10 +147,14 @@ try { } } 'delete' { - if (-not (Test-Path -LiteralPath $root)) { exit 44 } - $item = Get-Item -LiteralPath $root - if ($item.GetValueNames() -notcontains $property) { exit 44 } - Remove-ItemProperty -LiteralPath $root -Name $property + $registryKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($root, $true) + if ($null -eq $registryKey) { exit 44 } + try { + if ($registryKey.GetValueNames() -notcontains $property) { exit 44 } + $registryKey.DeleteValue($property, $false) + } finally { + $registryKey.Dispose() + } } default { throw 'unsupported credential operation' } } From a6421214e4fcc1a9e1510aa393cc8ec505055dd5 Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 21 Aug 2026 12:11:27 +0800 Subject: [PATCH 3/5] test: expose sanitized WSL bridge stages --- internal/secretstore/store_linux_test.go | 17 +++++++++ internal/secretstore/store_wsl_bridge.go | 44 ++++++++++++++++++++++-- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/secretstore/store_linux_test.go b/internal/secretstore/store_linux_test.go index e8d752c..ca9a83a 100644 --- a/internal/secretstore/store_linux_test.go +++ b/internal/secretstore/store_linux_test.go @@ -95,6 +95,21 @@ func TestPowerShellStoreRedactsDiagnostics(t *testing.T) { } } +func TestPowerShellStoreAllowsOnlySanitizedDiagnostics(t *testing.T) { + store, _, _ := testPowerShellStore(t) + t.Setenv("CODEX_SWITCH_BRIDGE_MODE", "safe-fail") + _, err := store.Get("master-key/test") + if err == nil || !strings.Contains(err.Error(), "at protect-value (TypeLoadException)") { + t.Fatalf("unexpected bridge error: %v", err) + } + + t.Setenv("CODEX_SWITCH_BRIDGE_MODE", "unsafe-fail") + _, err = store.Get("master-key/test") + if err == nil || strings.Contains(err.Error(), "secret") || !strings.Contains(err.Error(), "redacted") { + t.Fatalf("unexpected unsafe bridge error: %v", err) + } +} + func TestWSLStoreFallsBackToSecretService(t *testing.T) { fallback := NewMemoryStore() if err := fallback.Set("master-key/test", "legacy-value"); err != nil { @@ -144,6 +159,8 @@ case "$CODEX_SWITCH_BRIDGE_MODE" in get) printf 'c3RvcmVkLXZhdWx0LWtleQ==' ;; missing) exit 44 ;; fail) printf 'sensitive diagnostic' >&2; exit 1 ;; + safe-fail) printf 'CODEX_SWITCH_BRIDGE_ERROR:protect-value:TypeLoadException' >&2; exit 1 ;; + unsafe-fail) printf 'CODEX_SWITCH_BRIDGE_ERROR:protect-value:secret value' >&2; exit 1 ;; esac ` if err := os.WriteFile(powershell, []byte(stub), 0o700); err != nil { diff --git a/internal/secretstore/store_wsl_bridge.go b/internal/secretstore/store_wsl_bridge.go index 6c93fa1..2f015f1 100644 --- a/internal/secretstore/store_wsl_bridge.go +++ b/internal/secretstore/store_wsl_bridge.go @@ -84,13 +84,41 @@ func (store powershellStore) run(request powershellRequest) ([]byte, error) { } message := fmt.Sprintf("run Windows DPAPI credential bridge for %s: %v", request.Operation, err) if strings.TrimSpace(stderr.String()) != "" { - message += " (PowerShell diagnostics redacted)" + message += bridgeDiagnostic(stderr.String()) } return nil, errors.New(message) } return stdout.Bytes(), nil } +func bridgeDiagnostic(stderr string) string { + const prefix = "CODEX_SWITCH_BRIDGE_ERROR:" + start := strings.Index(stderr, prefix) + if start < 0 { + return " (PowerShell diagnostics redacted)" + } + fields := strings.SplitN(stderr[start+len(prefix):], ":", 3) + if len(fields) < 2 || !safeDiagnosticToken(fields[0]) || !safeDiagnosticToken(fields[1]) { + return " (PowerShell diagnostics redacted)" + } + return fmt.Sprintf(" at %s (%s)", fields[0], fields[1]) +} + +func safeDiagnosticToken(value string) bool { + if value == "" || len(value) > 64 { + return false + } + for _, character := range value { + if (character < 'a' || character > 'z') && + (character < 'A' || character > 'Z') && + (character < '0' || character > '9') && + character != '-' && character != '_' && character != '.' { + return false + } + } + return true +} + func encodePowerShell(script string) string { encodedRunes := utf16.Encode([]rune(script)) encodedBytes := make([]byte, len(encodedRunes)*2) @@ -102,8 +130,10 @@ func encodePowerShell(script string) string { } const wslPowerShellScript = `$ErrorActionPreference = 'Stop' +$stage = 'read-request' try { $request = [Console]::In.ReadToEnd() | ConvertFrom-Json + $stage = 'hash-target' $targetBytes = [Text.Encoding]::UTF8.GetBytes([string]$request.target) $sha = [Security.Cryptography.SHA256]::Create() try { @@ -113,15 +143,20 @@ try { } $root = 'Software\SilkageNet\codex-switch\secrets' $entropy = [Text.Encoding]::UTF8.GetBytes('codex-switch:wsl-dpapi:v1') + $stage = 'resolve-dpapi' $scope = [Security.Cryptography.DataProtectionScope]::CurrentUser switch ([string]$request.operation) { 'set' { + $stage = 'decode-value' $plain = [Convert]::FromBase64String([string]$request.value) try { + $stage = 'protect-value' $cipher = [Security.Cryptography.ProtectedData]::Protect($plain, $entropy, $scope) + $stage = 'open-registry-write' $registryKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($root) try { + $stage = 'write-registry' $registryKey.SetValue($property, $cipher, [Microsoft.Win32.RegistryValueKind]::Binary) } finally { if ($null -ne $registryKey) { $registryKey.Dispose() } @@ -131,14 +166,17 @@ try { } } 'get' { + $stage = 'open-registry-read' $registryKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($root, $false) if ($null -eq $registryKey) { exit 44 } try { + $stage = 'read-registry' if ($registryKey.GetValueNames() -notcontains $property) { exit 44 } $cipher = [byte[]]$registryKey.GetValue($property) } finally { $registryKey.Dispose() } + $stage = 'unprotect-value' $plain = [Security.Cryptography.ProtectedData]::Unprotect($cipher, $entropy, $scope) try { [Console]::Out.Write([Convert]::ToBase64String($plain)) @@ -147,9 +185,11 @@ try { } } 'delete' { + $stage = 'open-registry-delete' $registryKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($root, $true) if ($null -eq $registryKey) { exit 44 } try { + $stage = 'delete-registry' if ($registryKey.GetValueNames() -notcontains $property) { exit 44 } $registryKey.DeleteValue($property, $false) } finally { @@ -159,7 +199,7 @@ try { default { throw 'unsupported credential operation' } } } catch { - [Console]::Error.Write('codex-switch WSL credential bridge failed') + [Console]::Error.Write(('CODEX_SWITCH_BRIDGE_ERROR:{0}:{1}' -f $stage, $_.Exception.GetType().Name)) exit 1 } ` From 3e8c6eee9a4db1ff610d550231c95a00d1acea0b Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 21 Aug 2026 12:14:31 +0800 Subject: [PATCH 4/5] fix: parse wrapped PowerShell diagnostics --- internal/secretstore/store_linux_test.go | 2 +- internal/secretstore/store_wsl_bridge.go | 43 ++++++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/internal/secretstore/store_linux_test.go b/internal/secretstore/store_linux_test.go index ca9a83a..451c984 100644 --- a/internal/secretstore/store_linux_test.go +++ b/internal/secretstore/store_linux_test.go @@ -159,7 +159,7 @@ case "$CODEX_SWITCH_BRIDGE_MODE" in get) printf 'c3RvcmVkLXZhdWx0LWtleQ==' ;; missing) exit 44 ;; fail) printf 'sensitive diagnostic' >&2; exit 1 ;; - safe-fail) printf 'CODEX_SWITCH_BRIDGE_ERROR:protect-value:TypeLoadException' >&2; exit 1 ;; + safe-fail) printf 'CODEX_SWITCH_BRIDGE_ERROR:protect-value:TypeLoadException\nCLIXML wrapper' >&2; exit 1 ;; unsafe-fail) printf 'CODEX_SWITCH_BRIDGE_ERROR:protect-value:secret value' >&2; exit 1 ;; esac ` diff --git a/internal/secretstore/store_wsl_bridge.go b/internal/secretstore/store_wsl_bridge.go index 2f015f1..713d3b0 100644 --- a/internal/secretstore/store_wsl_bridge.go +++ b/internal/secretstore/store_wsl_bridge.go @@ -97,11 +97,36 @@ func bridgeDiagnostic(stderr string) string { if start < 0 { return " (PowerShell diagnostics redacted)" } - fields := strings.SplitN(stderr[start+len(prefix):], ":", 3) - if len(fields) < 2 || !safeDiagnosticToken(fields[0]) || !safeDiagnosticToken(fields[1]) { + remainder := stderr[start+len(prefix):] + stage, remainder, found := strings.Cut(remainder, ":") + if !found || !safeDiagnosticStage(stage) { return " (PowerShell diagnostics redacted)" } - return fmt.Sprintf(" at %s (%s)", fields[0], fields[1]) + exceptionType := leadingDiagnosticToken(remainder) + if !safeDiagnosticToken(exceptionType) || !strings.HasSuffix(exceptionType, "Exception") { + return " (PowerShell diagnostics redacted)" + } + return fmt.Sprintf(" at %s (%s)", stage, exceptionType) +} + +func safeDiagnosticStage(stage string) bool { + switch stage { + case "read-request", "hash-target", "resolve-dpapi", "decode-value", "protect-value", + "open-registry-write", "write-registry", "open-registry-read", "read-registry", + "unprotect-value", "open-registry-delete", "delete-registry": + return true + default: + return false + } +} + +func leadingDiagnosticToken(value string) string { + for index, character := range value { + if !safeDiagnosticCharacter(character) { + return value[:index] + } + } + return value } func safeDiagnosticToken(value string) bool { @@ -109,16 +134,20 @@ func safeDiagnosticToken(value string) bool { return false } for _, character := range value { - if (character < 'a' || character > 'z') && - (character < 'A' || character > 'Z') && - (character < '0' || character > '9') && - character != '-' && character != '_' && character != '.' { + if !safeDiagnosticCharacter(character) { return false } } return true } +func safeDiagnosticCharacter(character rune) bool { + return (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character == '-' || character == '_' || character == '.' +} + func encodePowerShell(script string) string { encodedRunes := utf16.Encode([]rune(script)) encodedBytes := make([]byte, len(encodedRunes)*2) From 936d02304c6d1287f950d83f7b8214cbd765bb89 Mon Sep 17 00:00:00 2001 From: Silkage Date: Fri, 21 Aug 2026 12:21:15 +0800 Subject: [PATCH 5/5] fix: load DPAPI assemblies in PowerShell --- internal/secretstore/store_wsl_bridge.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/secretstore/store_wsl_bridge.go b/internal/secretstore/store_wsl_bridge.go index 713d3b0..a2e602f 100644 --- a/internal/secretstore/store_wsl_bridge.go +++ b/internal/secretstore/store_wsl_bridge.go @@ -111,7 +111,7 @@ func bridgeDiagnostic(stderr string) string { func safeDiagnosticStage(stage string) bool { switch stage { - case "read-request", "hash-target", "resolve-dpapi", "decode-value", "protect-value", + case "read-request", "hash-target", "load-dpapi", "resolve-dpapi", "decode-value", "protect-value", "open-registry-write", "write-registry", "open-registry-read", "read-registry", "unprotect-value", "open-registry-delete", "delete-registry": return true @@ -172,6 +172,12 @@ try { } $root = 'Software\SilkageNet\codex-switch\secrets' $entropy = [Text.Encoding]::UTF8.GetBytes('codex-switch:wsl-dpapi:v1') + $stage = 'load-dpapi' + try { + Add-Type -AssemblyName System.Security -ErrorAction Stop + } catch { + Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop + } $stage = 'resolve-dpapi' $scope = [Security.Cryptography.DataProtectionScope]::CurrentUser