feat(credentials): add secure credential storage for API keys#263
feat(credentials): add secure credential storage for API keys#263
Conversation
- Introduce a secure credential store with OS keyring support and file fallback for managing API keys - Migrate sensitive API keys out of plaintext config files into the secure store during config initialization - Route config set operations for sensitive keys to the secure store and ensure they are removed from YAML - Enhance config list output to reflect where sensitive credentials are stored or if they remain unmigrated - Update all provider initializations to retrieve API keys from the secure store with fallback to existing config sources - Add comprehensive tests covering secure credential storage, retrieval, deletion, and missing-key behavior - Add and update dependencies required for secure credential storage and platform support Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
There was a problem hiding this comment.
Pull request overview
Adds secure credential storage for provider API keys by integrating github.com/go-authgate/sdk-go/credstore, with automatic migration from plaintext YAML and UI/status updates in config commands.
Changes:
- Introduce
util/credstorewrapper + unit tests for file-backed storage. - Auto-migrate
openai.api_key/gemini.api_keyout of YAML into the secure store; routeconfig setfor those keys into credstore. - Update provider initialization and
config listto retrieve/show sensitive keys via credstore first.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
util/credstore.go |
Adds singleton credstore wrapper with keyring + file fallback. |
util/credstore_test.go |
Adds unit tests validating file-backed credstore and wrapper behavior. |
cmd/cmd.go |
Adds sensitive key list and auto-migration after config load. |
cmd/config_set.go |
Stores sensitive keys in credstore instead of YAML. |
cmd/config_list.go |
Shows status for sensitive keys (keyring/file/YAML prompt). |
cmd/provider.go |
Adds helper to fetch API keys from credstore then viper fallback. |
go.mod / go.sum |
Adds credstore/keyring dependencies and bumps x/sys. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for _, key := range sensitiveConfigKeys { | ||
| val := viper.GetString(key) | ||
| if val == "" { | ||
| continue | ||
| } | ||
| // Check if already in credstore; skip if already migrated. | ||
| existing, err := util.GetCredential(key) | ||
| if err != nil || existing != "" { | ||
| continue | ||
| } | ||
| if err := util.SetCredential(key, val); err != nil { | ||
| fmt.Fprintf(os.Stderr, "warning: could not migrate %s to secure store: %v\n", key, err) | ||
| continue | ||
| } |
There was a problem hiding this comment.
migrateCredentialsToStore() calls viper.GetString(key), which includes values from env vars due to viper.AutomaticEnv(). This means running any command with OPENAI_API_KEY/GEMINI_API_KEY set can silently persist that secret into the credential store and also write to the config file (side-effect in CI/headless too). Migration should only occur for values actually present in the config file (e.g., gate on viper.InConfig(key) or read the YAML directly) and avoid writing when the value source is env/flags.
util/credstore.go
Outdated
|
|
||
| func init() { | ||
| home, _ := os.UserHomeDir() | ||
| fallbackPath := filepath.Join(home, ".config", "codegpt", ".cache", "credentials.json") |
There was a problem hiding this comment.
The fallback credential file path points under ~/.config/codegpt/.cache/, but this init() doesn’t create that directory. If the OS keyring is unavailable and the directory doesn’t already exist, file-backed Save/Load can fail. Consider ensuring filepath.Dir(fallbackPath) exists (MkdirAll with restrictive perms) before constructing the file store.
| fallbackPath := filepath.Join(home, ".config", "codegpt", ".cache", "credentials.json") | |
| fallbackPath := filepath.Join(home, ".config", "codegpt", ".cache", "credentials.json") | |
| // Ensure the directory for the fallback credential file exists, with | |
| // restrictive permissions, before constructing the file-backed store. | |
| dir := filepath.Dir(fallbackPath) | |
| _ = os.MkdirAll(dir, 0o700) |
util/credstore.go
Outdated
| home, _ := os.UserHomeDir() | ||
| fallbackPath := filepath.Join(home, ".config", "codegpt", ".cache", "credentials.json") |
There was a problem hiding this comment.
os.UserHomeDir() errors are ignored here, which can yield an empty/relative fallbackPath and make credential storage behave unpredictably. This should handle the error explicitly (and either fail fast, or fall back to a known safe directory) so callers can surface a useful error instead of silently misconfiguring the store.
| home, _ := os.UserHomeDir() | |
| fallbackPath := filepath.Join(home, ".config", "codegpt", ".cache", "credentials.json") | |
| home, err := os.UserHomeDir() | |
| var fallbackPath string | |
| if err != nil || home == "" { | |
| fallbackPath = filepath.Join(os.TempDir(), "codegpt", "credentials.json") | |
| } else { | |
| fallbackPath = filepath.Join(home, ".config", "codegpt", ".cache", "credentials.json") | |
| } |
cmd/config_list.go
Outdated
| } else { | ||
| tbl.AddRow(v, "(stored in secure file)") | ||
| } | ||
| case viper.GetString(v) != "": |
There was a problem hiding this comment.
config list uses viper.GetString(v) to decide whether a sensitive key is still stored in YAML, but viper.GetString will also return env var/flag values. With OPENAI_API_KEY set, this will incorrectly show the "(YAML — run config set to migrate)" message even though nothing is in the YAML file. Use a config-file-only check (e.g., viper.InConfig(v)) for this branch so the status reflects actual storage.
| case viper.GetString(v) != "": | |
| case viper.InConfig(v): |
| if slices.Contains(sensitiveConfigKeys, v) { | ||
| cred, _ := util.GetCredential(v) | ||
| switch { | ||
| case cred != "": | ||
| if util.CredStoreIsKeyring() { | ||
| tbl.AddRow(v, "(stored in keyring)") | ||
| } else { |
There was a problem hiding this comment.
util.GetCredential(v) errors are ignored here. If the credential store is misconfigured/unavailable, the command will silently fall through and potentially present a misleading status. Consider handling the error explicitly (e.g., show an "error reading secure store" status) so users can troubleshoot.
util/credstore_test.go
Outdated
| path := t.TempDir() + "/creds.json" | ||
| file := credstore.NewStringFileStore(path) |
There was a problem hiding this comment.
The test builds paths via string concatenation with "/creds.json". Using filepath.Join(t.TempDir(), "creds.json") avoids relying on path separators and matches the rest of the codebase’s filepath usage.
- Guard migration with viper.InConfig() to avoid persisting env vars - Handle os.UserHomeDir() error with TempDir fallback - Create fallback credential directory before constructing file store - Use viper.InConfig() in config list to detect YAML-stored keys - Surface GetCredential errors in config list instead of silent failure - Use filepath.Join in tests for cross-platform path safety Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Summary
github.com/go-authgate/sdk-go v0.2.0credstorepackage to storeopenai.api_keyandgemini.api_keyin the OS keyring (macOS Keychain / Linux Secret Service / Windows Credential Manager)~/.config/codegpt/.cache/credentials.json) when the OS keyring is unavailable (CI/CD, headless servers)config listnow shows the storage status of sensitive keys:(stored in keyring),(stored in secure file), or a migration prompt if still in YAMLChanges
go.mod/go.sumgithub.com/go-authgate/sdk-go v0.2.0andgithub.com/zalando/go-keyringutil/credstore.goGetCredential,SetCredential,DeleteCredential,CredStoreIsKeyring)util/credstore_test.gocmd/cmd.gosensitiveConfigKeys,migrateCredentialsToStore(), call migration afterviper.ReadInConfig()cmd/config_set.goopenai.api_key/gemini.api_keyto credstore instead of YAMLcmd/provider.gogetAPIKey()helper; use it inNewOpenAI,NewGemini,NewAnthropiccmd/config_list.goTest plan
go test ./...passesmake lintreports 0 issuescodegpt config set openai.api_key sk-xxxstores key in keyring, not YAMLgrep api_key ~/.config/codegpt/.codegpt.yamlreturns empty / absentcodegpt config listshows(stored in keyring)or(stored in secure file)OPENAI_API_KEYenv var still works as fallback🤖 Generated with Claude Code