Skip to content

Commit d7ca28a

Browse files
feat(secrets): S5 — keyring-backed secrets login with WSL2 env-var fallback (spec 04) (#59)
Completes M4's S5. Adds `internal/secrets/keyring.go`: - `Keyring` interface + `OSKeyring` (zalando/go-keyring: Secret Service / Keychain / Credential Manager, namespaced "devstack") + `MemKeyring` fallback; - `KeyringAvailable` probes by round-tripping a sentinel — any failure (notably WSL2 without D-Bus) → false; - `CredentialFor` resolves a provider credential in spec order: explicit env (`DEVSTACK_<PROVIDER>_TOKEN`) → managed keyring entry → "" (provider-native default). Providers (S3/S4) will consume this. CLI: real `secrets login|logout|status` (replacing the stub). `login` stores a credential via `--token`; on a keyring-less host it DEGRADES — warns, names the env var to set, exits 0 (tool keeps working). `status` reports keyring availability + per-provider source (env|keyring|none). An `openKeyring` seam makes both branches deterministically testable. All new deps (go-keyring, godbus, wincred) are pure-Go → CGO_ENABLED=0 build intact. govulncheck: only local go1.26 stdlib advisories (N/A to CI's Go 1.25.x); no module-level findings against the new deps. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1a95187 commit d7ca28a

6 files changed

Lines changed: 443 additions & 1 deletion

File tree

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ require (
1616
github.com/moby/moby/api v1.54.2
1717
github.com/moby/moby/client v0.4.1
1818
github.com/spf13/cobra v1.10.2
19+
github.com/zalando/go-keyring v0.2.8
1920
golang.org/x/mod v0.37.0
2021
golang.org/x/sync v0.20.0
2122
golang.org/x/term v0.44.0
@@ -37,6 +38,7 @@ require (
3738
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
3839
github.com/containerd/errdefs v1.0.0 // indirect
3940
github.com/containerd/errdefs/pkg v0.3.0 // indirect
41+
github.com/danieljoos/wincred v1.2.3 // indirect
4042
github.com/distribution/reference v0.6.0 // indirect
4143
github.com/docker/go-connections v0.7.0 // indirect
4244
github.com/docker/go-units v0.5.0 // indirect
@@ -48,6 +50,7 @@ require (
4850
github.com/go-playground/locales v0.14.1 // indirect
4951
github.com/go-playground/universal-translator v0.18.1 // indirect
5052
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
53+
github.com/godbus/dbus/v5 v5.2.2 // indirect
5154
github.com/google/uuid v1.6.0 // indirect
5255
github.com/inconshreveable/mousetrap v1.1.0 // indirect
5356
github.com/jackc/pgpassfile v1.0.0 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ
4141
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
4242
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
4343
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
44+
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
45+
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
4446
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4547
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4648
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -75,6 +77,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
7577
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
7678
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
7779
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
80+
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
81+
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
7882
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
7983
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
8084
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -143,6 +147,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
143147
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
144148
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
145149
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
150+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
151+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
146152
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
147153
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
148154
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -151,6 +157,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8
151157
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
152158
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
153159
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
160+
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
161+
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
154162
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
155163
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
156164
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=

internal/cli/secrets.go

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,137 @@ func newSecretsCmd(g *GlobalOpts) *cobra.Command {
2020
}
2121
cmd.AddCommand(
2222
newSecretsKeygenCmd(g),
23-
stub("login", "Authenticate a secrets provider (keyring) — S5", "M4"),
23+
newSecretsLoginCmd(g),
24+
newSecretsLogoutCmd(g),
25+
newSecretsStatusCmd(g),
2426
)
2527
return cmd
2628
}
2729

30+
// openKeyring builds the credential store: the OS keyring when usable, otherwise
31+
// an ephemeral in-memory fallback (WSL2 without D-Bus). The bool reports whether
32+
// the store persists across invocations. It's a package var so tests inject a
33+
// deterministic store.
34+
var openKeyring = func() (secrets.Keyring, bool) {
35+
k := secrets.OSKeyring{}
36+
if secrets.KeyringAvailable(k) {
37+
return k, true
38+
}
39+
return secrets.NewMemKeyring(), false
40+
}
41+
42+
// newSecretsLoginCmd wires `secrets login <provider>` (spec 04 §S5): store a
43+
// provider credential in the OS keyring. On a keyring-less host (WSL2 without
44+
// D-Bus) it degrades with a one-line warning naming the env var to set instead —
45+
// the tool keeps working in env-var mode.
46+
func newSecretsLoginCmd(g *GlobalOpts) *cobra.Command {
47+
var token string
48+
cmd := &cobra.Command{
49+
Use: "login <provider>",
50+
Short: "Store a secrets-provider credential in the OS keyring",
51+
Args: cobra.ExactArgs(1),
52+
RunE: func(cmd *cobra.Command, args []string) error {
53+
provider := args[0]
54+
if token == "" {
55+
return fmt.Errorf("provide the credential via --token (avoid shell history: read it from a file or a pipe)")
56+
}
57+
k, persistent := openKeyring()
58+
if !persistent {
59+
// Degrade, never fail: tell the user the env-var path and exit 0.
60+
msg := fmt.Sprintf("os keyring unavailable (no Secret Service / D-Bus — common on WSL2); not stored.\n"+
61+
"set %s in your environment instead.", secrets.CredEnvVar(provider))
62+
if g.JSON {
63+
return writeJSON(cmd, map[string]any{"provider": provider, "stored": false, "envVar": secrets.CredEnvVar(provider), "warning": msg})
64+
}
65+
fmt.Fprintf(cmd.ErrOrStderr(), "[warn] %s\n", msg)
66+
return nil
67+
}
68+
if err := k.Set(secrets.ProviderCredKey(provider), token); err != nil {
69+
return fmt.Errorf("store credential for %q: %w", provider, err)
70+
}
71+
if g.JSON {
72+
return writeJSON(cmd, map[string]any{"provider": provider, "stored": true})
73+
}
74+
fmt.Fprintf(cmd.OutOrStdout(), "stored credential for %q in the OS keyring\n", provider)
75+
return nil
76+
},
77+
}
78+
cmd.Flags().StringVar(&token, "token", "", "the credential value (required)")
79+
return cmd
80+
}
81+
82+
// newSecretsLogoutCmd wires `secrets logout <provider>` — remove a stored
83+
// credential. A no-op (success) when none exists or the keyring is unavailable.
84+
func newSecretsLogoutCmd(g *GlobalOpts) *cobra.Command {
85+
cmd := &cobra.Command{
86+
Use: "logout <provider>",
87+
Short: "Remove a stored secrets-provider credential from the OS keyring",
88+
Args: cobra.ExactArgs(1),
89+
RunE: func(cmd *cobra.Command, args []string) error {
90+
provider := args[0]
91+
k, persistent := openKeyring()
92+
if !persistent {
93+
if g.JSON {
94+
return writeJSON(cmd, map[string]any{"provider": provider, "removed": false, "reason": "keyring unavailable"})
95+
}
96+
fmt.Fprintf(cmd.ErrOrStderr(), "[warn] os keyring unavailable; nothing to remove\n")
97+
return nil
98+
}
99+
if err := k.Delete(secrets.ProviderCredKey(provider)); err != nil {
100+
return fmt.Errorf("remove credential for %q: %w", provider, err)
101+
}
102+
if g.JSON {
103+
return writeJSON(cmd, map[string]any{"provider": provider, "removed": true})
104+
}
105+
fmt.Fprintf(cmd.OutOrStdout(), "removed credential for %q\n", provider)
106+
return nil
107+
},
108+
}
109+
return cmd
110+
}
111+
112+
// newSecretsStatusCmd wires `secrets status` — report keyring availability and,
113+
// for any providers named as args, whether a credential resolves (env or keyring).
114+
func newSecretsStatusCmd(g *GlobalOpts) *cobra.Command {
115+
cmd := &cobra.Command{
116+
Use: "status [provider...]",
117+
Short: "Report keyring availability and per-provider credential source",
118+
RunE: func(cmd *cobra.Command, args []string) error {
119+
k, persistent := openKeyring()
120+
type provStatus struct {
121+
Provider string `json:"provider"`
122+
Source string `json:"source"` // env | keyring | none
123+
}
124+
var provs []provStatus
125+
for _, p := range args {
126+
src := "none"
127+
if os.Getenv(secrets.CredEnvVar(p)) != "" {
128+
src = "env"
129+
} else if persistent {
130+
if v, _ := k.Get(secrets.ProviderCredKey(p)); v != "" {
131+
src = "keyring"
132+
}
133+
}
134+
provs = append(provs, provStatus{Provider: p, Source: src})
135+
}
136+
if g.JSON {
137+
return writeJSON(cmd, map[string]any{"keyringPersistent": persistent, "providers": provs})
138+
}
139+
w := cmd.OutOrStdout()
140+
if persistent {
141+
fmt.Fprintln(w, "os keyring: available (credentials persist across invocations)")
142+
} else {
143+
fmt.Fprintln(w, "os keyring: UNAVAILABLE (no Secret Service / D-Bus); use env vars (DEVSTACK_<PROVIDER>_TOKEN)")
144+
}
145+
for _, p := range provs {
146+
fmt.Fprintf(w, " %-20s %s\n", p.Provider, p.Source)
147+
}
148+
return nil
149+
},
150+
}
151+
return cmd
152+
}
153+
28154
func newSecretsKeygenCmd(g *GlobalOpts) *cobra.Command {
29155
var output string
30156
cmd := &cobra.Command{

internal/cli/secrets_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"path/filepath"
66
"strings"
77
"testing"
8+
9+
"github.com/open-source-cloud/devstack/internal/secrets"
810
)
911

1012
func TestSecretsKeygenRegistered(t *testing.T) {
@@ -45,3 +47,83 @@ func TestSecretsKeygenWritesFile(t *testing.T) {
4547
t.Errorf("output should hint SOPS_AGE_KEY_FILE:\n%s", buf.String())
4648
}
4749
}
50+
51+
func TestSecretsLoginLogoutStatusRegistered(t *testing.T) {
52+
root := NewRootCmd(Options{})
53+
for _, sub := range []string{"login", "logout", "status"} {
54+
c, _, err := root.Find([]string{"secrets", sub})
55+
if err != nil || c.Name() != sub || c.RunE == nil {
56+
t.Fatalf("secrets %s not registered as a real command: %v", sub, err)
57+
}
58+
}
59+
}
60+
61+
// withKeyring swaps the openKeyring seam for the duration of a test.
62+
func withKeyring(t *testing.T, k secrets.Keyring, persistent bool) {
63+
t.Helper()
64+
prev := openKeyring
65+
openKeyring = func() (secrets.Keyring, bool) { return k, persistent }
66+
t.Cleanup(func() { openKeyring = prev })
67+
}
68+
69+
func runSecrets(t *testing.T, args ...string) (string, error) {
70+
t.Helper()
71+
var out strings.Builder
72+
root := NewRootCmd(Options{})
73+
root.SetArgs(args)
74+
root.SetOut(&out)
75+
root.SetErr(&out)
76+
err := root.Execute()
77+
return out.String(), err
78+
}
79+
80+
func TestSecretsLoginPersists(t *testing.T) {
81+
k := secrets.NewMemKeyring()
82+
withKeyring(t, k, true)
83+
84+
if _, err := runSecrets(t, "secrets", "login", "infisical", "--token", "tok-123"); err != nil {
85+
t.Fatalf("login: %v", err)
86+
}
87+
if v, _ := k.Get(secrets.ProviderCredKey("infisical")); v != "tok-123" {
88+
t.Errorf("stored token = %q, want tok-123", v)
89+
}
90+
91+
out, err := runSecrets(t, "secrets", "status", "infisical")
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if !strings.Contains(out, "available") || !strings.Contains(out, "keyring") {
96+
t.Errorf("status should show available + keyring source:\n%s", out)
97+
}
98+
99+
if _, err := runSecrets(t, "secrets", "logout", "infisical"); err != nil {
100+
t.Fatalf("logout: %v", err)
101+
}
102+
if v, _ := k.Get(secrets.ProviderCredKey("infisical")); v != "" {
103+
t.Errorf("after logout = %q, want empty", v)
104+
}
105+
}
106+
107+
func TestSecretsLoginDegradesWithoutKeyring(t *testing.T) {
108+
// WSL2 path: keyring unavailable → warn, name the env var, exit 0, store nothing.
109+
k := secrets.NewMemKeyring()
110+
withKeyring(t, k, false)
111+
112+
out, err := runSecrets(t, "secrets", "login", "aws", "--token", "x")
113+
if err != nil {
114+
t.Fatalf("login must degrade (exit 0), got %v", err)
115+
}
116+
if !strings.Contains(out, "DEVSTACK_AWS_TOKEN") {
117+
t.Errorf("degrade message must name the env var:\n%s", out)
118+
}
119+
if v, _ := k.Get(secrets.ProviderCredKey("aws")); v != "" {
120+
t.Error("nothing should be stored when the keyring is unavailable")
121+
}
122+
}
123+
124+
func TestSecretsLoginRequiresToken(t *testing.T) {
125+
withKeyring(t, secrets.NewMemKeyring(), true)
126+
if _, err := runSecrets(t, "secrets", "login", "aws"); err == nil {
127+
t.Fatal("login without --token must error")
128+
}
129+
}

0 commit comments

Comments
 (0)