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..451c984 --- /dev/null +++ b/internal/secretstore/store_linux_test.go @@ -0,0 +1,172 @@ +//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 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 { + 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 ;; + 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 +` + 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..a2e602f --- /dev/null +++ b/internal/secretstore/store_wsl_bridge.go @@ -0,0 +1,240 @@ +//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 += 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)" + } + remainder := stderr[start+len(prefix):] + stage, remainder, found := strings.Cut(remainder, ":") + if !found || !safeDiagnosticStage(stage) { + return " (PowerShell diagnostics redacted)" + } + 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", "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 + 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 { + if value == "" || len(value) > 64 { + return false + } + for _, character := range value { + 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) + 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' +$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 { + $property = -join ($sha.ComputeHash($targetBytes) | ForEach-Object { $_.ToString('x2') }) + } finally { + $sha.Dispose() + } + $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 + + 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() } + } + } finally { + if ($null -ne $plain) { [Array]::Clear($plain, 0, $plain.Length) } + } + } + '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)) + } finally { + [Array]::Clear($plain, 0, $plain.Length) + } + } + '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 { + $registryKey.Dispose() + } + } + default { throw 'unsupported credential operation' } + } +} catch { + [Console]::Error.Write(('CODEX_SWITCH_BRIDGE_ERROR:{0}:{1}' -f $stage, $_.Exception.GetType().Name)) + 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) + } +}