mcp-framework/secretstore/store.go

318 lines
7.8 KiB
Go

package secretstore
import (
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
"slices"
"strings"
"github.com/99designs/keyring"
)
var ErrNotFound = errors.New("secret not found")
var ErrBackendUnavailable = errors.New("secret backend unavailable")
var ErrReadOnly = errors.New("secret backend is read-only")
var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
var ErrBWNotLoggedIn = errors.New("bitwarden is not logged in")
var ErrBWLocked = errors.New("bitwarden vault is locked or BW_SESSION is missing")
var ErrBWUnavailable = errors.New("bitwarden CLI unavailable")
type BackendPolicy string
const (
BackendAuto BackendPolicy = "auto"
BackendKWalletOnly BackendPolicy = "kwallet-only"
BackendKeyringAny BackendPolicy = "keyring-any"
BackendEnvOnly BackendPolicy = "env-only"
BackendBitwardenCLI BackendPolicy = "bitwarden-cli"
)
type Options struct {
ServiceName string
BackendPolicy BackendPolicy
LookupEnv func(string) (string, bool)
KWalletAppID string
KWalletFolder string
BitwardenCommand string
}
type Store interface {
SetSecret(name, label, secret string) error
GetSecret(name string) (string, error)
DeleteSecret(name string) error
}
type BackendUnavailableError struct {
Policy BackendPolicy
Required string
Available []string
}
func (e *BackendUnavailableError) Error() string {
if len(e.Available) == 0 {
return fmt.Sprintf("secret backend policy %q requires %s, but no compatible backend is available", e.Policy, e.Required)
}
return fmt.Sprintf(
"secret backend policy %q requires %s, but only [%s] are available",
e.Policy,
e.Required,
strings.Join(e.Available, ", "),
)
}
func (e *BackendUnavailableError) Unwrap() error {
return ErrBackendUnavailable
}
type keyringStore struct {
ring keyring.Keyring
serviceName string
}
type envStore struct {
lookupEnv func(string) (string, bool)
}
var (
openKeyring = keyring.Open
availableKeyringPolicy = keyring.AvailableBackends
)
func Open(options Options) (Store, error) {
policy, err := normalizeBackendPolicy(options.BackendPolicy)
if err != nil {
return nil, err
}
if policy == BackendEnvOnly {
return newEnvStore(options), nil
}
serviceName := strings.TrimSpace(options.ServiceName)
if serviceName == "" {
return nil, errors.New("service name must not be empty")
}
if policy == BackendBitwardenCLI {
return newBitwardenStore(options, policy, serviceName)
}
available := availableKeyringPolicy()
allowed, err := allowedBackends(policy, available)
if err != nil {
if errors.Is(err, ErrBackendUnavailable) && policy == BackendAuto && options.LookupEnv != nil {
return newEnvStore(options), nil
}
return nil, err
}
ring, err := openKeyring(keyring.Config{
ServiceName: serviceName,
AllowedBackends: allowed,
KWalletAppID: strings.TrimSpace(options.KWalletAppID),
KWalletFolder: strings.TrimSpace(options.KWalletFolder),
})
if err == nil {
return &keyringStore{
ring: ring,
serviceName: serviceName,
}, nil
}
if policy == BackendAuto && options.LookupEnv != nil {
return newEnvStore(options), nil
}
return nil, fmt.Errorf("open secret backend for service %q with policy %q: %w", serviceName, policy, err)
}
func BackendName() string {
switch runtime.GOOS {
case "darwin":
return "macOS Keychain"
case "windows":
return "Windows Credential Manager"
case "linux":
return "Linux Secret Service or KWallet"
default:
return "system wallet"
}
}
func EffectiveBackendPolicy(store Store) BackendPolicy {
switch store.(type) {
case *bitwardenStore:
return BackendBitwardenCLI
case *envStore:
return BackendEnvOnly
case *keyringStore:
return BackendKeyringAny
default:
return ""
}
}
func SetSecretVerified(store Store, name, label, secret string) error {
if store == nil {
return errors.New("secret store must not be nil")
}
if err := store.SetSecret(name, label, secret); err != nil {
return err
}
verified, err := store.GetSecret(name)
if err != nil {
return fmt.Errorf("verify secret %q after write: %w", name, err)
}
if verified != secret {
return fmt.Errorf("verify secret %q after write: read-back mismatch", name)
}
return nil
}
func SetJSON[T any](store Store, name, label string, value T) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("encode structured secret %q as JSON: %w", name, err)
}
return store.SetSecret(name, label, string(data))
}
func GetJSON[T any](store Store, name string) (T, error) {
var value T
if err := GetJSONInto(store, name, &value); err != nil {
var zero T
return zero, err
}
return value, nil
}
func GetJSONInto(store Store, name string, target any) error {
secret, err := store.GetSecret(name)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(secret), target); err != nil {
return fmt.Errorf("decode structured secret %q from JSON: %w", name, err)
}
return nil
}
func (s *keyringStore) SetSecret(name, label, secret string) error {
if err := s.ring.Set(keyring.Item{
Key: name,
Label: label,
Data: []byte(secret),
}); err != nil {
return fmt.Errorf("save secret %q in OS wallet for service %q: %w", name, s.serviceName, err)
}
return nil
}
func (s *keyringStore) GetSecret(name string) (string, error) {
item, err := s.ring.Get(name)
if err != nil {
if errors.Is(err, keyring.ErrKeyNotFound) {
return "", ErrNotFound
}
return "", fmt.Errorf("read secret %q from OS wallet for service %q: %w", name, s.serviceName, err)
}
return string(item.Data), nil
}
func (s *keyringStore) DeleteSecret(name string) error {
if err := s.ring.Remove(name); err != nil && !errors.Is(err, keyring.ErrKeyNotFound) {
return fmt.Errorf("delete secret %q from OS wallet for service %q: %w", name, s.serviceName, err)
}
return nil
}
func (s *envStore) SetSecret(name, label, secret string) error {
return fmt.Errorf("save secret %q in environment backend: %w", name, ErrReadOnly)
}
func (s *envStore) GetSecret(name string) (string, error) {
value, ok := s.lookupEnv(name)
if !ok {
return "", ErrNotFound
}
return value, nil
}
func (s *envStore) DeleteSecret(name string) error {
return fmt.Errorf("delete secret %q from environment backend: %w", name, ErrReadOnly)
}
func newEnvStore(options Options) Store {
lookupEnv := options.LookupEnv
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
return &envStore{lookupEnv: lookupEnv}
}
func allowedBackends(policy BackendPolicy, available []keyring.BackendType) ([]keyring.BackendType, error) {
switch policy {
case BackendAuto, BackendKeyringAny:
if len(available) == 0 {
return nil, &BackendUnavailableError{
Policy: policy,
Required: "any keyring backend",
Available: nil,
}
}
return slices.Clone(available), nil
case BackendKWalletOnly:
if !slices.Contains(available, keyring.KWalletBackend) {
return nil, &BackendUnavailableError{
Policy: policy,
Required: string(keyring.KWalletBackend),
Available: backendNames(available),
}
}
return []keyring.BackendType{keyring.KWalletBackend}, nil
default:
return nil, invalidBackendPolicyError(policy)
}
}
func backendNames(backends []keyring.BackendType) []string {
names := make([]string, 0, len(backends))
for _, backend := range backends {
names = append(names, string(backend))
}
return names
}
func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
trimmed := BackendPolicy(strings.TrimSpace(string(policy)))
if trimmed == "" {
return BackendAuto, nil
}
switch trimmed {
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI:
return trimmed, nil
default:
return "", invalidBackendPolicyError(trimmed)
}
}
func invalidBackendPolicyError(policy BackendPolicy) error {
return fmt.Errorf("%w %q", ErrInvalidBackendPolicy, policy)
}