Skip to content

Commit db3b158

Browse files
mauriciocoderMauricio BonettiJAORMX
authored
Issues/1449 - Improve Linux Keyring Support with Keyctl Fallback (#1451)
Co-authored-by: Mauricio Bonetti <[email protected]> Co-authored-by: Juan Antonio Osorio <[email protected]>
1 parent 16972b2 commit db3b158

File tree

8 files changed

+694
-27
lines changed

8 files changed

+694
-27
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,6 @@ require (
299299
go.opentelemetry.io/otel/trace v1.37.0
300300
golang.org/x/crypto v0.41.0 // indirect
301301
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
302-
golang.org/x/sys v0.35.0 // indirect
302+
golang.org/x/sys v0.35.0
303303
k8s.io/client-go v0.33.4
304304
)

pkg/secrets/factory.go

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import (
88
"errors"
99
"fmt"
1010
"os"
11+
"sync"
1112

1213
"github.com/adrg/xdg"
13-
"github.com/zalando/go-keyring"
1414
"golang.org/x/term"
1515

1616
"github.com/stacklok/toolhive/pkg/logger"
1717
"github.com/stacklok/toolhive/pkg/process"
18+
"github.com/stacklok/toolhive/pkg/secrets/keyring"
1819
)
1920

2021
const (
@@ -27,6 +28,18 @@ const (
2728
keyringService = "toolhive"
2829
)
2930

31+
var (
32+
keyringProvider keyring.Provider
33+
keyringOnce sync.Once
34+
)
35+
36+
func getKeyringProvider() keyring.Provider {
37+
keyringOnce.Do(func() {
38+
keyringProvider = keyring.NewCompositeProvider()
39+
})
40+
return keyringProvider
41+
}
42+
3043
// ProviderType represents an enum of the types of available secrets providers.
3144
type ProviderType string
3245

@@ -157,20 +170,10 @@ var ErrKeyringNotAvailable = errors.New("OS keyring is not available. " +
157170
"Please use a different secrets provider (e.g., 1password) " +
158171
"or ensure your system has a keyring service available")
159172

160-
// IsKeyringAvailable tests if the OS keyring is available by attempting to set and delete a test value.
173+
// IsKeyringAvailable tests if any keyring backend is available
161174
func IsKeyringAvailable() bool {
162-
testKey := "toolhive-keyring-test"
163-
testValue := "test"
164-
165-
// Try to set a test value
166-
if err := keyring.Set(keyringService, testKey, testValue); err != nil {
167-
return false
168-
}
169-
170-
// Clean up the test value
171-
_ = keyring.Delete(keyringService, testKey)
172-
173-
return true
175+
provider := getKeyringProvider()
176+
return provider.IsAvailable()
174177
}
175178

176179
// CreateSecretProvider creates the specified type of secrets provider.
@@ -214,14 +217,15 @@ func CreateSecretProviderWithPassword(managerType ProviderType, password string)
214217
// If optionalPassword is provided and keyring is not yet setup, it uses that password and stores it.
215218
// Otherwise, it uses the current functionality (read from keyring or stdin).
216219
func GetSecretsPassword(optionalPassword string) ([]byte, error) {
217-
// Attempt to load the password from the OS keyring.
218-
keyringSecret, err := keyring.Get(keyringService, keyringService)
220+
provider := getKeyringProvider()
221+
222+
// Attempt to load the password from the keyring
223+
keyringSecret, err := provider.Get(keyringService, keyringService)
219224
if err == nil {
220225
return []byte(keyringSecret), nil
221226
}
222227

223-
// We need to determine if the error is due to a lack of keyring on the
224-
// system or if the keyring is available but nothing was stored.
228+
// Handle key not found
225229
if errors.Is(err, keyring.ErrNotFound) {
226230
var password []byte
227231

@@ -233,9 +237,6 @@ func GetSecretsPassword(optionalPassword string) ([]byte, error) {
233237
password = []byte(optionalPassword)
234238
} else {
235239
// Keyring is available but no password stored - this should only happen during setup
236-
// We cannot ask for a password in a detached process.
237-
// We should never trigger this, but this ensures that if there's a bug
238-
// then it's easier to find.
239240
if process.IsDetached() {
240241
return nil, fmt.Errorf("detached process detected, cannot ask for password")
241242
}
@@ -248,10 +249,9 @@ func GetSecretsPassword(optionalPassword string) ([]byte, error) {
248249
}
249250
}
250251

251-
// TODO GET function should not be saving anything into keyring
252252
// Store the password in the keyring for future use
253-
logger.Info("writing password to os keyring")
254-
err = keyring.Set(keyringService, keyringService, string(password))
253+
logger.Info(fmt.Sprintf("writing password to %s", provider.Name()))
254+
err = provider.Set(keyringService, keyringService, string(password))
255255
if err != nil {
256256
return nil, fmt.Errorf("failed to store password in keyring: %w", err)
257257
}
@@ -260,7 +260,7 @@ func GetSecretsPassword(optionalPassword string) ([]byte, error) {
260260
}
261261

262262
// Assume any other keyring error means keyring is not available
263-
return nil, fmt.Errorf("OS keyring is not available: %w", err)
263+
return nil, fmt.Errorf("keyring is not available: %w", err)
264264
}
265265

266266
func readPasswordStdin() ([]byte, error) {
@@ -281,7 +281,8 @@ func readPasswordStdin() ([]byte, error) {
281281

282282
// ResetKeyringSecret clears out the secret from the keystore (if present).
283283
func ResetKeyringSecret() error {
284-
return keyring.DeleteAll(keyringService)
284+
provider := getKeyringProvider()
285+
return provider.DeleteAll(keyringService)
285286
}
286287

287288
// GenerateSecurePassword generates a cryptographically secure random password

pkg/secrets/keyring/composite.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Package keyring provides a composite keyring provider that supports multiple backends.
2+
// It supports macOS Keychain, Windows Credential Manager, and Linux D-Bus Secret Service,
3+
// with keyctl as a fallback on Linux systems.
4+
package keyring
5+
6+
import (
7+
"fmt"
8+
"runtime"
9+
10+
"github.com/stacklok/toolhive/pkg/logger"
11+
)
12+
13+
const linuxOS = "linux"
14+
15+
type compositeProvider struct {
16+
providers []Provider
17+
active Provider
18+
}
19+
20+
// NewCompositeProvider creates a new composite keyring provider that combines multiple backends.
21+
// It uses zalando/go-keyring as the primary provider and keyctl as a fallback on Linux.
22+
func NewCompositeProvider() Provider {
23+
var providers []Provider
24+
25+
// Add zalando/go-keyring as primary provider
26+
// Handles macOS, Windows, and Linux D-Bus natively
27+
zkProvider := NewZalandoKeyringProvider()
28+
providers = append(providers, zkProvider)
29+
30+
// Add keyctl provider as fallback ONLY on Linux
31+
if runtime.GOOS == linuxOS {
32+
if keyctl, err := NewKeyctlProvider(); err == nil {
33+
providers = append(providers, keyctl)
34+
}
35+
}
36+
37+
return &compositeProvider{
38+
providers: providers,
39+
}
40+
}
41+
42+
func (c *compositeProvider) getActiveProvider() Provider {
43+
if c.active != nil && c.active.IsAvailable() {
44+
return c.active
45+
}
46+
47+
for _, provider := range c.providers {
48+
if provider.IsAvailable() {
49+
if c.active == nil || c.active.Name() != provider.Name() {
50+
// Log provider selection if logger is available
51+
// Use fmt.Printf as fallback since logger might not be initialized in tests
52+
c.logProviderSelection(provider.Name())
53+
}
54+
c.active = provider
55+
return provider
56+
}
57+
}
58+
59+
return nil
60+
}
61+
62+
// logProviderSelection safely logs the provider selection
63+
func (*compositeProvider) logProviderSelection(providerName string) {
64+
// Try to use the logger, but don't panic if it's not available
65+
defer func() {
66+
if r := recover(); r != nil {
67+
// Logger not available, use fallback
68+
fmt.Printf("Using keyring provider: %s\n", providerName)
69+
}
70+
}()
71+
72+
logger.Info(fmt.Sprintf("Using keyring provider: %s", providerName))
73+
}
74+
75+
func (c *compositeProvider) Set(service, key, value string) error {
76+
provider := c.getActiveProvider()
77+
if provider == nil {
78+
return fmt.Errorf("no keyring provider available")
79+
}
80+
return provider.Set(service, key, value)
81+
}
82+
83+
func (c *compositeProvider) Get(service, key string) (string, error) {
84+
provider := c.getActiveProvider()
85+
if provider == nil {
86+
return "", fmt.Errorf("no keyring provider available")
87+
}
88+
return provider.Get(service, key)
89+
}
90+
91+
func (c *compositeProvider) Delete(service, key string) error {
92+
provider := c.getActiveProvider()
93+
if provider == nil {
94+
return fmt.Errorf("no keyring provider available")
95+
}
96+
return provider.Delete(service, key)
97+
}
98+
99+
func (c *compositeProvider) DeleteAll(service string) error {
100+
provider := c.getActiveProvider()
101+
if provider == nil {
102+
return fmt.Errorf("no keyring provider available")
103+
}
104+
return provider.DeleteAll(service)
105+
}
106+
107+
func (c *compositeProvider) IsAvailable() bool {
108+
return c.getActiveProvider() != nil
109+
}
110+
111+
func (c *compositeProvider) Name() string {
112+
if provider := c.getActiveProvider(); provider != nil {
113+
return provider.Name()
114+
}
115+
return "None Available"
116+
}

0 commit comments

Comments
 (0)