Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
25 changes: 25 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 107 additions & 10 deletions internal/secretstore/store_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,62 @@ 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)
}
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()
Expand All @@ -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
Expand All @@ -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")
}
Loading