diff --git a/README.md b/README.md index 9554e19..e84e406 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,14 @@ Notes : ## Secrets -Le package `secretstore` s'appuie sur le wallet natif du système : +Le package `secretstore` supporte plusieurs politiques de backend : + +- `auto` : comportement par défaut, utilise un backend keyring disponible et peut retomber sur l'environnement si `LookupEnv` est fourni +- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible +- `keyring-any` : impose l'utilisation d'un backend keyring disponible +- `env-only` : lecture seule depuis les variables d'environnement + +Backends keyring typiques : - macOS : Keychain - Linux : Secret Service ou KWallet selon l'environnement @@ -125,7 +132,8 @@ Exemple : ```go store, err := secretstore.Open(secretstore.Options{ - ServiceName: "my-mcp", + ServiceName: "my-mcp", + BackendPolicy: secretstore.BackendAuto, }) if err != nil { return err @@ -146,6 +154,42 @@ default: } ``` +Pour imposer KWallet sur Linux : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: secretstore.BackendKWalletOnly, +}) +``` + +Pour stocker un secret structuré en JSON : + +```go +type Credentials struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` +} + +err = secretstore.SetJSON(store, "imap-credentials", "IMAP credentials", Credentials{ + Host: "imap.example.com", + Username: "alice", + Password: token, +}) +if err != nil { + return err +} + +creds, err := secretstore.GetJSON[Credentials](store, "imap-credentials") +if err != nil { + return err +} +``` + +En mode `env-only`, `GetSecret("API_TOKEN")` lit la variable d'environnement `API_TOKEN`. +Les opérations d'écriture et de suppression retournent `secretstore.ErrReadOnly`. + ## Helpers CLI `cli` fournit des helpers simples pour les assistants interactifs : diff --git a/secretstore/store.go b/secretstore/store.go index 09c45a4..3cb7e6f 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -1,18 +1,36 @@ 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") + +type BackendPolicy string + +const ( + BackendAuto BackendPolicy = "auto" + BackendKWalletOnly BackendPolicy = "kwallet-only" + BackendKeyringAny BackendPolicy = "keyring-any" + BackendEnvOnly BackendPolicy = "env-only" +) type Options struct { - ServiceName string + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string } type Store interface { @@ -21,28 +39,91 @@ type Store interface { 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 := options.BackendPolicy + if policy == "" { + policy = BackendAuto + } + + switch policy { + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + default: + return nil, fmt.Errorf("invalid secret backend policy %q", policy) + } + + if policy == BackendEnvOnly { + return newEnvStore(options), nil + } + serviceName := strings.TrimSpace(options.ServiceName) if serviceName == "" { return nil, errors.New("service name must not be empty") } - ring, err := keyring.Open(keyring.Config{ - ServiceName: serviceName, - }) + available := availableKeyringPolicy() + allowed, err := allowedBackends(policy, available) if err != nil { - return nil, fmt.Errorf("open OS wallet backend %q for service %q: %w", BackendName(), serviceName, err) + if errors.Is(err, ErrBackendUnavailable) && policy == BackendAuto && options.LookupEnv != nil { + return newEnvStore(options), nil + } + return nil, err } - return &keyringStore{ - ring: ring, - serviceName: serviceName, - }, nil + 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 { @@ -58,6 +139,38 @@ func BackendName() string { } } +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, @@ -88,3 +201,62 @@ func (s *keyringStore) DeleteSecret(name string) error { } 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, fmt.Errorf("invalid secret backend policy %q", policy) + } +} + +func backendNames(backends []keyring.BackendType) []string { + names := make([]string, 0, len(backends)) + for _, backend := range backends { + names = append(names, string(backend)) + } + return names +} diff --git a/secretstore/store_test.go b/secretstore/store_test.go new file mode 100644 index 0000000..c7a2f68 --- /dev/null +++ b/secretstore/store_test.go @@ -0,0 +1,247 @@ +package secretstore + +import ( + "errors" + "testing" + + "github.com/99designs/keyring" +) + +type stubKeyring struct { + items map[string]keyring.Item +} + +func (s *stubKeyring) Get(key string) (keyring.Item, error) { + item, ok := s.items[key] + if !ok { + return keyring.Item{}, keyring.ErrKeyNotFound + } + + return item, nil +} + +func (s *stubKeyring) GetMetadata(key string) (keyring.Metadata, error) { + item, err := s.Get(key) + if err != nil { + return keyring.Metadata{}, err + } + + return keyring.Metadata{Item: &item}, nil +} + +func (s *stubKeyring) Set(item keyring.Item) error { + if s.items == nil { + s.items = map[string]keyring.Item{} + } + + s.items[item.Key] = item + return nil +} + +func (s *stubKeyring) Remove(key string) error { + delete(s.items, key) + return nil +} + +func (s *stubKeyring) Keys() ([]string, error) { + keys := make([]string, 0, len(s.items)) + for key := range s.items { + keys = append(keys, key) + } + + return keys, nil +} + +func withKeyringHooks( + t *testing.T, + available []keyring.BackendType, + opener func(cfg keyring.Config) (keyring.Keyring, error), +) { + t.Helper() + + prevAvailable := availableKeyringPolicy + prevOpen := openKeyring + availableKeyringPolicy = func() []keyring.BackendType { + return available + } + openKeyring = opener + + t.Cleanup(func() { + availableKeyringPolicy = prevAvailable + openKeyring = prevOpen + }) +} + +func TestOpenRejectsInvalidPolicy(t *testing.T) { + _, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendPolicy("invalid"), + }) + if err == nil { + t.Fatal("expected error") + } +} + +func TestOpenAutoUsesAvailableKeyringBackends(t *testing.T) { + var gotAllowed []keyring.BackendType + ring := &stubKeyring{} + + withKeyringHooks(t, []keyring.BackendType{ + keyring.SecretServiceBackend, + keyring.KWalletBackend, + }, func(cfg keyring.Config) (keyring.Keyring, error) { + gotAllowed = append([]keyring.BackendType(nil), cfg.AllowedBackends...) + return ring, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendAuto, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("token", "API token", "secret-value"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-value" { + t.Fatalf("GetSecret = %q, want secret-value", value) + } + + if len(gotAllowed) != 2 { + t.Fatalf("allowed backends = %v, want two entries", gotAllowed) + } +} + +func TestOpenAutoFallsBackToEnvironmentWhenNoKeyringBackendIsAvailable(t *testing.T) { + withKeyringHooks(t, nil, func(cfg keyring.Config) (keyring.Keyring, error) { + t.Fatal("unexpected keyring open call") + return nil, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendAuto, + LookupEnv: func(name string) (string, bool) { + if name == "EMAIL_CREDENTIALS" { + return `{"username":"alice"}`, true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("EMAIL_CREDENTIALS") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != `{"username":"alice"}` { + t.Fatalf("GetSecret = %q", value) + } +} + +func TestOpenKeyringAnyReturnsExplicitUnavailableError(t *testing.T) { + withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { + t.Fatal("unexpected keyring open call") + return nil, nil + }) + + _, err := Open(Options{ + ServiceName: "mcp-framework-test", + BackendPolicy: BackendKWalletOnly, + }) + if err == nil { + t.Fatal("expected error") + } + + var backendErr *BackendUnavailableError + if !errors.As(err, &backendErr) { + t.Fatalf("error = %v, want BackendUnavailableError", err) + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } + if backendErr.Required != "kwallet" { + t.Fatalf("required backend = %q, want kwallet", backendErr.Required) + } +} + +func TestOpenEnvOnlyIsReadOnlyAndUsesLookupEnv(t *testing.T) { + store, err := Open(Options{ + BackendPolicy: BackendEnvOnly, + LookupEnv: func(name string) (string, bool) { + if name == "API_TOKEN" { + return "from-env", true + } + return "", false + }, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + value, err := store.GetSecret("API_TOKEN") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "from-env" { + t.Fatalf("GetSecret = %q, want from-env", value) + } + + err = store.SetSecret("API_TOKEN", "API token", "new-value") + if !errors.Is(err, ErrReadOnly) { + t.Fatalf("SetSecret error = %v, want ErrReadOnly", err) + } + + err = store.DeleteSecret("API_TOKEN") + if !errors.Is(err, ErrReadOnly) { + t.Fatalf("DeleteSecret error = %v, want ErrReadOnly", err) + } +} + +func TestJSONHelpersRoundTrip(t *testing.T) { + ring := &stubKeyring{} + withKeyringHooks(t, []keyring.BackendType{keyring.SecretServiceBackend}, func(cfg keyring.Config) (keyring.Keyring, error) { + return ring, nil + }) + + store, err := Open(Options{ + ServiceName: "mcp-framework-test", + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + type credentials struct { + Host string `json:"host"` + Username string `json:"username"` + Password string `json:"password"` + } + + input := credentials{ + Host: "imap.example.com", + Username: "alice", + Password: "s3cr3t", + } + + if err := SetJSON(store, "imap-credentials", "IMAP credentials", input); err != nil { + t.Fatalf("SetJSON returned error: %v", err) + } + + output, err := GetJSON[credentials](store, "imap-credentials") + if err != nil { + t.Fatalf("GetJSON returned error: %v", err) + } + + if output != input { + t.Fatalf("GetJSON = %#v, want %#v", output, input) + } +}