Skip to content

Commit 5373f05

Browse files
IvMisticosclaude
andauthored
feat(kcc-cache): pluggable credential storage backends (keyring/file) (#3)
Add a Backend abstraction so cached credentials no longer have to live as plaintext on disk. Two backends are available, selected via KUBE_CREDENTIAL_CACHE_BACKEND: - keyring: store credentials in the OS secret store (macOS Keychain, Linux Secret Service, Windows Credential Manager) via go-keyring. Encryption-at-rest and access control are handled by the OS. - file: the previous behaviour - a single plaintext JSON cache file protected only by 0600/0700 filesystem permissions. When the backend is unset, keyring is used if the OS secret store is reachable, otherwise it falls back to file (for headless/CI hosts). Add round-trip tests for both backends and document the new option. Claude-Session: https://claude.ai/code/session_01GVF3MxTbUtp3wCV2GCiS4C Co-authored-by: Claude <noreply@anthropic.com>
1 parent af7a6a4 commit 5373f05

5 files changed

Lines changed: 283 additions & 83 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Work as caching proxy of [ExecCredential](https://kubernetes.io/docs/reference/c
3030
- kcc-cache
3131
- [x] Cache [ExecCredential](https://kubernetes.io/docs/reference/config-api/client-authentication.v1/#client-authentication-k8s-io-v1-ExecCredential) object
3232
- [x] Concern Command, Args, Env as cache-key
33-
- [ ] Cache file encryption
33+
- [x] Store credentials in the OS secret store (no plaintext on disk) via the `keyring` backend
3434
- [ ] kubeconfig automated maintenance
3535
- kcc-injector
3636
- [x] kubeconfig optimize (inject kcc-cache command automatically)
@@ -166,10 +166,33 @@ The cause is unknown. However, we ignore error by recreating the cache currently
166166

167167
| Environment variable | default | description |
168168
|-----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|
169-
| KUBE_CREDENTIAL_CACHE_FILE | macOS:</br>`~/Library/Caches/kube-credential-cache/cache.json`</br>Linux:</br>`$XDG_CACHE_HOME/kube-credential-cache/cache.json`</br>`~/.cache/kube-credential-cache/cache.json`</br>Windows:</br>`%AppData%\kube-credential-cache\cache.json` | path of Cache file |
169+
| KUBE_CREDENTIAL_CACHE_BACKEND | _auto_ (`keyring` if the OS secret store is reachable, otherwise `file`) | storage backend: `keyring` or `file` |
170+
| KUBE_CREDENTIAL_CACHE_FILE | macOS:</br>`~/Library/Caches/kube-credential-cache/cache.json`</br>Linux:</br>`$XDG_CACHE_HOME/kube-credential-cache/cache.json`</br>`~/.cache/kube-credential-cache/cache.json`</br>Windows:</br>`%AppData%\kube-credential-cache\cache.json` | path of Cache file (`file` backend only) |
170171
| KUBE_CREDENTIAL_CACHE_REFRESH_MARGIN | `30s` | margin of credential refresh |
171172
| KUBE_CREDENTIAL_CACHE_CACHEKEY_ENV_LIST | `KUBE_CREDENTIAL_CACHE_USER,AWS_PROFILE,AWS_REGION,AWS_VAULT` | comma separated env names for additional cache-key |
172173

174+
#### Storage backends
175+
176+
By default credentials are kept **out of plaintext on disk** by storing them in the
177+
operating system's secret store, and fall back to a plaintext file only when no
178+
secret store is reachable (e.g. headless servers or CI).
179+
180+
- `keyring` — store credentials in the OS secret store. The store handles
181+
encryption-at-rest and ties access to your login session:
182+
- **macOS**: Keychain
183+
- **Linux**: Secret Service (GNOME Keyring / KWallet, via D-Bus)
184+
- **Windows**: Credential Manager
185+
- `file` — store all credentials in a single plaintext JSON file
186+
(`KUBE_CREDENTIAL_CACHE_FILE`), protected only by filesystem permissions
187+
(`0600` file / `0700` directory).
188+
189+
Set `KUBE_CREDENTIAL_CACHE_BACKEND` explicitly to force a specific backend.
190+
191+
> :information_source: To clear cached credentials in the `keyring` backend,
192+
> delete the `kube-credential-cache` entries from your OS secret store
193+
> (Keychain Access on macOS, `secret-tool` / Seahorse on Linux, Credential
194+
> Manager on Windows). For the `file` backend, remove the cache file.
195+
173196
### kcc-injector
174197

175198
```sh

cmd/kcc-cache/main.go

Lines changed: 165 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ package main
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
6-
"io"
77
"os"
88
"os/exec"
99
"path"
1010
"runtime"
1111
"runtime/debug"
1212
"strings"
1313
"time"
14+
15+
"github.com/zalando/go-keyring"
1416
)
1517

1618
type CacheFile struct {
@@ -29,22 +31,29 @@ type ClientAuthentication struct {
2931
} `json:"status"`
3032
}
3133

34+
// keyringService is the service name used for entries stored in the OS secret store.
35+
const keyringService = "kube-credential-cache"
36+
37+
// Backend abstracts where cached credentials are persisted between invocations.
38+
//
39+
// kcc-cache is a short-lived process (spawned by kubectl on every API call), so
40+
// there is no in-process memory that survives between calls. A Backend therefore
41+
// persists credentials somewhere external: either the OS secret store ("keyring")
42+
// or a plaintext JSON file ("file").
43+
type Backend interface {
44+
// Get returns the cached credential for key. ok is false when there is no
45+
// (valid) entry for key.
46+
Get(key string) (cred ClientAuthentication, ok bool, err error)
47+
// Set stores cred under key.
48+
Set(key string, cred ClientAuthentication) error
49+
}
50+
3251
func main() {
3352
// configuration
3453
var (
35-
cacheFilepath string
3654
refreshMargin = time.Second * 30
3755
cacheKeyEnvlist = []string{"KUBE_CREDENTIAL_CACHE_USER", "AWS_PROFILE", "AWS_REGION", "AWS_VAULT"}
3856
)
39-
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_FILE"); e != "" {
40-
cacheFilepath = e
41-
} else {
42-
cacheDir, err := os.UserCacheDir()
43-
if err != nil {
44-
fatal("can't find CacheDir. fix error or set 'KUBE_CREDENTIAL_CACHE_FILE': %s", err)
45-
}
46-
cacheFilepath = path.Join(cacheDir, "kube-credential-cache", "cache.json")
47-
}
4857
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_REFRESH_MARGIN"); e != "" {
4958
d, err := time.ParseDuration(e)
5059
if err != nil {
@@ -101,75 +110,16 @@ func main() {
101110
}
102111
}
103112

104-
// open file
105-
f, err := os.OpenFile(cacheFilepath, os.O_RDWR|os.O_CREATE, 0600)
106-
if err != nil {
107-
if os.IsNotExist(err) {
108-
if err := os.MkdirAll(path.Dir(cacheFilepath), 0700); err != nil {
109-
fatal("mkdir failed: %s", err)
110-
}
111-
f, err = os.OpenFile(cacheFilepath, os.O_RDWR|os.O_CREATE, 0600)
112-
if err != nil {
113-
fatal("file open failed(after mkdir): %s", err)
114-
}
115-
} else {
116-
fatal("file open failed: %s", err)
117-
}
118-
}
119-
defer func() {
120-
if err := f.Close(); err != nil {
121-
log("file close failed: %s", err)
122-
}
123-
}()
124-
125-
// read file
126-
updated := false
127-
cacheFile := CacheFile{}
128-
bytes, err := io.ReadAll(f)
129-
if err != nil {
130-
fatal("file read failed: %s", err)
131-
}
132-
if len(bytes) > 0 {
133-
if err := json.Unmarshal(bytes, &cacheFile); err != nil {
134-
log("json.Unmarshal() failed(read cache file): %s\n...Corruption detected, recreate cache file", err)
135-
updated = true
136-
}
137-
}
138-
defer func() {
139-
// update cache file
140-
if updated {
141-
qpanic := func(err error) {
142-
if err != nil {
143-
panic(err)
144-
}
145-
}
146-
147-
// cleanup
148-
for k, v := range cacheFile.Credentials {
149-
if time.Now().After(v.Status.ExpirationTimestamp) {
150-
delete(cacheFile.Credentials, k)
151-
}
152-
}
153-
154-
// update
155-
err := f.Truncate(0)
156-
qpanic(err)
157-
bytes, err := json.Marshal(cacheFile)
158-
qpanic(err)
159-
_, err = f.WriteAt(bytes, 0)
160-
qpanic(err)
161-
}
162-
}()
113+
// select storage backend
114+
backend := newBackend(os.Getenv("KUBE_CREDENTIAL_CACHE_BACKEND"))
163115

164116
// check cache
165-
if len(cacheFile.Credentials) == 0 {
166-
cacheFile.Credentials = map[string]ClientAuthentication{}
117+
cache, ok, err := backend.Get(cacheKey)
118+
if err != nil {
119+
fatal("cache read failed: %s", err)
167120
}
168-
cache, ok := cacheFile.Credentials[cacheKey]
169-
if !ok || ok && time.Until(cache.Status.ExpirationTimestamp) < refreshMargin {
121+
if !ok || time.Until(cache.Status.ExpirationTimestamp) < refreshMargin {
170122
// refresh
171-
tmpCache := ClientAuthentication{}
172-
173123
if len(os.Args) < 2 {
174124
fatal("not enough command at args")
175125
}
@@ -188,22 +138,157 @@ func main() {
188138
fatal("empty stdout, but without error")
189139
}
190140

191-
if err := json.Unmarshal(bytes, &tmpCache); err != nil {
141+
cache = ClientAuthentication{}
142+
if err := json.Unmarshal(bytes, &cache); err != nil {
192143
fatal("json.Unmarshal() failed(read command output): %s\nactual stdout: %s", err, string(bytes))
193144
}
194145

195-
cacheFile.Credentials[cacheKey] = tmpCache
196-
updated = true
146+
if err := backend.Set(cacheKey, cache); err != nil {
147+
fatal("cache write failed: %s", err)
148+
}
197149
}
198150

199151
// print
200-
output, err := json.Marshal(cacheFile.Credentials[cacheKey])
152+
output, err := json.Marshal(cache)
201153
if err != nil {
202154
fatal("json.Marshal() failed: %s", err)
203155
}
204156
fmt.Println(string(output))
205157
}
206158

159+
// newBackend selects a storage backend.
160+
//
161+
// "keyring" -> OS secret store (macOS Keychain / Linux Secret Service / Windows Credential Manager)
162+
// "file" -> plaintext JSON cache file
163+
// "" -> keyring if the OS secret store is reachable, otherwise file (for headless/CI hosts)
164+
func newBackend(name string) Backend {
165+
switch name {
166+
case "file":
167+
return fileBackend{path: resolveCacheFilepath()}
168+
case "keyring":
169+
return keyringBackend{}
170+
case "":
171+
if keyringAvailable() {
172+
return keyringBackend{}
173+
}
174+
return fileBackend{path: resolveCacheFilepath()}
175+
default:
176+
fatal("invalid environment variable 'KUBE_CREDENTIAL_CACHE_BACKEND': %q (expected \"keyring\" or \"file\")", name)
177+
return nil // unreachable
178+
}
179+
}
180+
181+
// keyringAvailable reports whether the OS secret store can be reached. The probe
182+
// is non-destructive: a working store returns ErrNotFound for a missing entry,
183+
// while an unavailable store (e.g. no Secret Service / D-Bus) returns another error.
184+
func keyringAvailable() bool {
185+
_, err := keyring.Get(keyringService, "__kcc_probe__")
186+
return err == nil || errors.Is(err, keyring.ErrNotFound)
187+
}
188+
189+
// keyringBackend stores each credential as a JSON value in the OS secret store,
190+
// keyed by the cache key. Nothing is written to disk in plaintext.
191+
type keyringBackend struct{}
192+
193+
func (keyringBackend) Get(key string) (ClientAuthentication, bool, error) {
194+
s, err := keyring.Get(keyringService, key)
195+
if err != nil {
196+
if errors.Is(err, keyring.ErrNotFound) {
197+
return ClientAuthentication{}, false, nil
198+
}
199+
return ClientAuthentication{}, false, err
200+
}
201+
var cred ClientAuthentication
202+
if err := json.Unmarshal([]byte(s), &cred); err != nil {
203+
// treat corruption as a cache miss; it will be overwritten on refresh
204+
log("json.Unmarshal() failed(read keyring entry): %s\n...Corruption detected, refreshing credential", err)
205+
return ClientAuthentication{}, false, nil
206+
}
207+
return cred, true, nil
208+
}
209+
210+
func (keyringBackend) Set(key string, cred ClientAuthentication) error {
211+
bytes, err := json.Marshal(cred)
212+
if err != nil {
213+
return err
214+
}
215+
return keyring.Set(keyringService, key, string(bytes))
216+
}
217+
218+
// fileBackend stores all credentials as a single plaintext JSON file, protected
219+
// only by filesystem permissions (0600 file, 0700 directory).
220+
type fileBackend struct {
221+
path string
222+
}
223+
224+
func (b fileBackend) load() (CacheFile, error) {
225+
cf := CacheFile{Credentials: map[string]ClientAuthentication{}}
226+
bytes, err := os.ReadFile(b.path)
227+
if err != nil {
228+
if os.IsNotExist(err) {
229+
return cf, nil
230+
}
231+
return cf, err
232+
}
233+
if len(bytes) > 0 {
234+
if err := json.Unmarshal(bytes, &cf); err != nil {
235+
// recreate on corruption, matching previous behaviour
236+
log("json.Unmarshal() failed(read cache file): %s\n...Corruption detected, recreate cache file", err)
237+
return CacheFile{Credentials: map[string]ClientAuthentication{}}, nil
238+
}
239+
}
240+
if cf.Credentials == nil {
241+
cf.Credentials = map[string]ClientAuthentication{}
242+
}
243+
return cf, nil
244+
}
245+
246+
func (b fileBackend) Get(key string) (ClientAuthentication, bool, error) {
247+
cf, err := b.load()
248+
if err != nil {
249+
return ClientAuthentication{}, false, err
250+
}
251+
cred, ok := cf.Credentials[key]
252+
return cred, ok, nil
253+
}
254+
255+
func (b fileBackend) Set(key string, cred ClientAuthentication) error {
256+
cf, err := b.load()
257+
if err != nil {
258+
return err
259+
}
260+
261+
// cleanup expired entries
262+
for k, v := range cf.Credentials {
263+
if time.Now().After(v.Status.ExpirationTimestamp) {
264+
delete(cf.Credentials, k)
265+
}
266+
}
267+
cf.Credentials[key] = cred
268+
269+
bytes, err := json.Marshal(cf)
270+
if err != nil {
271+
return err
272+
}
273+
if err := os.MkdirAll(path.Dir(b.path), 0700); err != nil {
274+
return fmt.Errorf("mkdir failed: %w", err)
275+
}
276+
return os.WriteFile(b.path, bytes, 0600)
277+
}
278+
279+
// resolveCacheFilepath returns the path of the plaintext cache file used by the
280+
// file backend.
281+
func resolveCacheFilepath() string {
282+
if e := os.Getenv("KUBE_CREDENTIAL_CACHE_FILE"); e != "" {
283+
return e
284+
}
285+
cacheDir, err := os.UserCacheDir()
286+
if err != nil {
287+
fatal("can't find CacheDir. fix error or set 'KUBE_CREDENTIAL_CACHE_FILE': %s", err)
288+
}
289+
return path.Join(cacheDir, "kube-credential-cache", "cache.json")
290+
}
291+
207292
func fatal(format string, v ...any) {
208293
log(format, v...)
209294

0 commit comments

Comments
 (0)