Skip to content

Commit 0541aa4

Browse files
Merge pull request #34 from priyanshujain/cross-platform-keyring
refactor(provider): use go-keyring for cross-platform credential storage
2 parents 7bd4e3a + d615702 commit 0541aa4

File tree

8 files changed

+134
-141
lines changed

8 files changed

+134
-141
lines changed

docs/credential-storage.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Credential Storage
2+
3+
## How it works
4+
5+
API keys are stored in the OS-native keyring via [`zalando/go-keyring`](https://github.com/zalando/go-keyring):
6+
7+
| Platform | Backend |
8+
|----------|---------|
9+
| macOS | Keychain |
10+
| Linux | Secret Service (GNOME Keyring / KDE Wallet) |
11+
| Windows | Credential Manager (WinCred) |
12+
13+
For headless/CI/Docker environments where no keyring daemon is available, use environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`). `ResolveAPIKey` checks the keyring first, then falls back to env vars.
14+
15+
## Credential ref format
16+
17+
Stored in config as `api_key_ref: "keychain:obk/<provider>"`. The `keychain:` prefix is a historical name — it works on all platforms, not just macOS Keychain.
18+
19+
## Design decisions and learnings
20+
21+
### Why keyring-only, no file fallback
22+
23+
Early versions silently fell back to plain-text files (`~/.obk/secrets/`) when the keyring was unavailable. We removed this after researching how production CLI tools handle it:
24+
25+
- **GitHub CLI (`gh`)** does the same silent file fallback and [their team now considers it a mistake](https://github.com/cli/cli/issues/10108). Users unknowingly store tokens in plain text.
26+
- **aws-vault** never auto-falls back. Users must explicitly choose a backend (`--backend=file`).
27+
- **docker-credential-helpers** errors out if the configured helper fails.
28+
29+
**The pattern:** Keyring succeeds or errors. Env vars cover headless. No silent degradation to insecure storage.
30+
31+
### Why `zalando/go-keyring` over `99designs/keyring`
32+
33+
- Pure Go on all platforms — works with `CGO_ENABLED=0` for cross-compilation
34+
- Simple API (`Get`/`Set`/`Delete`) — we don't need backend selection, encrypted file fallback, or kwallet/pass/keyctl support
35+
- Same library used by `gh` (GitHub CLI) and `chezmoi`
36+
- `99designs/keyring` is more powerful but pulls in more dependencies and complexity (backend selection, encrypted file backend, passphrase prompts) that we don't need
37+
38+
### Why errors propagate directly
39+
40+
Keyring errors (locked keychain, unsupported platform, data too big) are returned to the caller. This means:
41+
42+
- Users see the real problem instead of a misleading "file not found" from a hidden fallback
43+
- `obk setup` fails visibly if the keyring isn't working, rather than silently writing secrets to disk
44+
- Debugging is straightforward — the error message tells you what happened
45+
46+
### Backward compatibility
47+
48+
- `go-keyring` can read credentials stored by the old hand-rolled `security` CLI code on macOS (it checks for encoding prefixes and returns raw values as-is)
49+
- New credentials stored by `go-keyring` are base64-encoded in the keychain — this is a one-way migration (no downgrade path, which is fine)
50+
- The `keychain:` ref prefix in config files is preserved

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/riverqueue/river/riverdriver/riversqlite v0.31.0
1111
github.com/riverqueue/river/rivertype v0.31.0
1212
github.com/spf13/cobra v1.9.1
13+
github.com/zalando/go-keyring v0.2.6
1314
go.mau.fi/whatsmeow v0.0.0-20260227112304-c9652e4448a2
1415
golang.org/x/oauth2 v0.35.0
1516
golang.org/x/time v0.14.0
@@ -20,6 +21,7 @@ require (
2021
)
2122

2223
require (
24+
al.essio.dev/pkg/shellescape v1.5.1 // indirect
2325
cloud.google.com/go/auth v0.18.2 // indirect
2426
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
2527
cloud.google.com/go/compute/metadata v0.9.0 // indirect
@@ -38,13 +40,15 @@ require (
3840
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
3941
github.com/charmbracelet/x/term v0.2.1 // indirect
4042
github.com/coder/websocket v1.8.14 // indirect
43+
github.com/danieljoos/wincred v1.2.2 // indirect
4144
github.com/davecgh/go-spew v1.1.1 // indirect
4245
github.com/dustin/go-humanize v1.0.1 // indirect
4346
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
4447
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
4548
github.com/felixge/httpsnoop v1.0.4 // indirect
4649
github.com/go-logr/logr v1.4.3 // indirect
4750
github.com/go-logr/stdr v1.2.2 // indirect
51+
github.com/godbus/dbus/v5 v5.1.0 // indirect
4852
github.com/google/s2a-go v0.1.9 // indirect
4953
github.com/google/uuid v1.6.0 // indirect
5054
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect

go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
2+
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
13
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
24
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
35
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
@@ -60,6 +62,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV
6062
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
6163
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
6264
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
65+
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
66+
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
6367
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6468
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6569
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -77,6 +81,8 @@ github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ4
7781
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
7882
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
7983
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
84+
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
85+
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
8086
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
8187
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
8288
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -85,6 +91,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
8591
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
8692
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
8793
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
94+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
95+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
8896
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8997
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
9098
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
@@ -173,6 +181,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
173181
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
174182
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
175183
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
184+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
185+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
176186
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
177187
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
178188
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -192,6 +202,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
192202
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
193203
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
194204
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
205+
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
206+
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
195207
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
196208
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
197209
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=

internal/cli/setup_models.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ func configureAPIKey(pcfg *config.ModelProviderConfig, name string, existing con
272272
apiKey = cleanPath(apiKey)
273273
if apiKey != "" {
274274
ref := fmt.Sprintf("keychain:obk/%s", name)
275-
if err := provider.KeychainStore(ref, apiKey); err != nil {
275+
if err := provider.StoreCredential(ref, apiKey); err != nil {
276276
return fmt.Errorf("store API key: %w", err)
277277
}
278278
pcfg.APIKeyRef = ref

provider/credential.go

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,43 @@ package provider
33
import (
44
"fmt"
55
"os"
6-
"path/filepath"
76
"strings"
7+
8+
"github.com/zalando/go-keyring"
89
)
910

10-
// KeychainLoad retrieves an API key from the platform credential store.
11+
// LoadCredential retrieves an API key from the OS keyring.
1112
// The ref format is "keychain:<service>/<account>", e.g. "keychain:obk/anthropic".
12-
func KeychainLoad(ref string) (string, error) {
13+
func LoadCredential(ref string) (string, error) {
1314
service, account, err := parseCredentialRef(ref)
1415
if err != nil {
1516
return "", err
1617
}
17-
return credentialLoad(service, account)
18+
val, err := keyring.Get(service, account)
19+
if err != nil {
20+
return "", fmt.Errorf("load credential %s/%s: %w", service, account, err)
21+
}
22+
return val, nil
1823
}
1924

20-
// KeychainStore saves an API key to the platform credential store.
21-
func KeychainStore(ref, value string) error {
25+
// StoreCredential saves an API key to the OS keyring.
26+
func StoreCredential(ref, value string) error {
2227
service, account, err := parseCredentialRef(ref)
2328
if err != nil {
2429
return err
2530
}
26-
return credentialStore(service, account, value)
31+
if err := keyring.Set(service, account, value); err != nil {
32+
return fmt.Errorf("store credential %s/%s: %w", service, account, err)
33+
}
34+
return nil
2735
}
2836

29-
// ResolveAPIKey resolves an API key from either a credential store reference
30-
// or an environment variable fallback.
37+
// ResolveAPIKey resolves an API key from a keyring reference or
38+
// an environment variable. For headless/CI environments where no
39+
// keyring is available, set the environment variable directly.
3140
func ResolveAPIKey(ref, envVar string) (string, error) {
3241
if ref != "" && strings.HasPrefix(ref, "keychain:") {
33-
key, err := KeychainLoad(ref)
42+
key, err := LoadCredential(ref)
3443
if err == nil && key != "" {
3544
return key, nil
3645
}
@@ -53,51 +62,3 @@ func parseCredentialRef(ref string) (service, account string, err error) {
5362
}
5463
return parts[0], parts[1], nil
5564
}
56-
57-
func secretsDir() (string, error) {
58-
home, err := os.UserHomeDir()
59-
if err != nil {
60-
return "", fmt.Errorf("get home dir: %w", err)
61-
}
62-
return filepath.Join(home, ".obk", "secrets"), nil
63-
}
64-
65-
func secretPath(service, account string) (string, error) {
66-
dir, err := secretsDir()
67-
if err != nil {
68-
return "", err
69-
}
70-
return filepath.Join(dir, service+"-"+account), nil
71-
}
72-
73-
// loadFromFile reads a credential from the file-based store.
74-
func loadFromFile(service, account string) (string, error) {
75-
path, err := secretPath(service, account)
76-
if err != nil {
77-
return "", err
78-
}
79-
data, err := os.ReadFile(path)
80-
if err != nil {
81-
return "", fmt.Errorf("credential lookup failed for %s/%s: %w", service, account, err)
82-
}
83-
return strings.TrimSpace(string(data)), nil
84-
}
85-
86-
// storeToFile writes a credential to the file-based store with 0600 permissions.
87-
func storeToFile(service, account, value string) error {
88-
dir, err := secretsDir()
89-
if err != nil {
90-
return err
91-
}
92-
if err := os.MkdirAll(dir, 0700); err != nil {
93-
return fmt.Errorf("create secrets dir: %w", err)
94-
}
95-
path, err := secretPath(service, account)
96-
if err != nil {
97-
return err
98-
}
99-
if err := os.WriteFile(path, []byte(value), 0600); err != nil {
100-
return fmt.Errorf("store credential: %w", err)
101-
}
102-
return nil
103-
}

provider/credential_darwin.go

Lines changed: 0 additions & 32 deletions
This file was deleted.

provider/credential_other.go

Lines changed: 0 additions & 11 deletions
This file was deleted.

0 commit comments

Comments
 (0)