From bba7aacedfce4d3774334db847d8b011aa9e7d72 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 08:30:35 +0200 Subject: [PATCH] feat(secretstore): add bitwarden CLI backend support --- docs/manifest.md | 2 +- docs/secrets.md | 12 ++ secretstore/bitwarden.go | 345 ++++++++++++++++++++++++++++++++++ secretstore/bitwarden_test.go | 341 +++++++++++++++++++++++++++++++++ secretstore/store.go | 26 ++- 5 files changed, 715 insertions(+), 11 deletions(-) create mode 100644 secretstore/bitwarden.go create mode 100644 secretstore/bitwarden_test.go diff --git a/docs/manifest.md b/docs/manifest.md index 0e3b064..5868c40 100644 --- a/docs/manifest.md +++ b/docs/manifest.md @@ -59,7 +59,7 @@ Champs supportés : - `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. - `[environment].known` : variables d'environnement connues du projet. -- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`). +- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`). - `[profiles].default` : profil recommandé par défaut. - `[profiles].known` : profils connus du projet. - `[bootstrap].description` : description CLI utilisée par le bootstrap. diff --git a/docs/secrets.md b/docs/secrets.md index 522d48d..9fcbf66 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -6,6 +6,7 @@ Le package `secretstore` supporte plusieurs politiques de backend : - `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 +- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault Backends keyring typiques : @@ -60,6 +61,17 @@ store, err := secretstore.Open(secretstore.Options{ }) ``` +Pour imposer Bitwarden via son CLI : + +```go +store, err := secretstore.Open(secretstore.Options{ + ServiceName: "email-mcp", + BackendPolicy: secretstore.BackendBitwardenCLI, + // Optionnel si `bw` n'est pas dans le PATH : + // BitwardenCommand: "/usr/local/bin/bw", +}) +``` + Pour stocker un secret structuré en JSON : ```go diff --git a/secretstore/bitwarden.go b/secretstore/bitwarden.go new file mode 100644 index 0000000..55497c7 --- /dev/null +++ b/secretstore/bitwarden.go @@ -0,0 +1,345 @@ +package secretstore + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" +) + +const ( + defaultBitwardenCommand = "bw" + bitwardenSecretFieldName = "mcp-secret" +) + +type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) + +var runBitwardenCLI bitwardenRunner = executeBitwardenCLI + +type bitwardenStore struct { + command string + serviceName string +} + +type bitwardenListItem struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func newBitwardenStore(options Options, policy BackendPolicy, serviceName string) (Store, error) { + command := strings.TrimSpace(options.BitwardenCommand) + if command == "" { + command = defaultBitwardenCommand + } + + store := &bitwardenStore{ + command: command, + serviceName: serviceName, + } + + if _, err := store.execute("verify bitwarden CLI availability", nil, "--version"); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return nil, fmt.Errorf( + "secret backend policy %q requires bitwarden CLI command %q in PATH: %w", + policy, + command, + ErrBackendUnavailable, + ) + } + + return nil, fmt.Errorf( + "secret backend policy %q cannot verify bitwarden CLI command %q: %w", + policy, + command, + errors.Join(ErrBackendUnavailable, err), + ) + } + + return store, nil +} + +func (s *bitwardenStore) SetSecret(name, label, secret string) error { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + switch { + case errors.Is(err, ErrNotFound): + template, err := s.itemTemplate() + if err != nil { + return err + } + + setBitwardenSecretPayload(template, secretName, label, secret) + encoded, err := s.encodePayload(template) + if err != nil { + return err + } + + if _, err := s.execute( + fmt.Sprintf("create bitwarden item for secret %q", name), + nil, + "create", + "item", + encoded, + ); err != nil { + return err + } + return nil + case err != nil: + return err + } + + payload, err := s.itemByID(item.ID) + if err != nil { + return err + } + + setBitwardenSecretPayload(payload, secretName, label, secret) + encoded, err := s.encodePayload(payload) + if err != nil { + return err + } + + if _, err := s.execute( + fmt.Sprintf("update bitwarden item for secret %q", name), + nil, + "edit", + "item", + item.ID, + encoded, + ); err != nil { + return err + } + + return nil +} + +func (s *bitwardenStore) GetSecret(name string) (string, error) { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + if err != nil { + return "", err + } + + payload, err := s.itemByID(item.ID) + if err != nil { + return "", err + } + + secret, ok := readBitwardenSecret(payload) + if !ok { + return "", fmt.Errorf("bitwarden item for secret %q does not contain field %q", name, bitwardenSecretFieldName) + } + + return secret, nil +} + +func (s *bitwardenStore) DeleteSecret(name string) error { + secretName := s.scopedName(name) + item, err := s.findItem(secretName) + if errors.Is(err, ErrNotFound) { + return nil + } + if err != nil { + return err + } + + if _, err := s.execute( + fmt.Sprintf("delete bitwarden item for secret %q", name), + nil, + "delete", + "item", + item.ID, + ); err != nil { + return err + } + + return nil +} + +func (s *bitwardenStore) scopedName(name string) string { + return fmt.Sprintf("%s/%s", s.serviceName, name) +} + +func (s *bitwardenStore) findItem(secretName string) (bitwardenListItem, error) { + output, err := s.execute( + fmt.Sprintf("list bitwarden items for secret %q", secretName), + nil, + "list", + "items", + "--search", + secretName, + ) + if err != nil { + return bitwardenListItem{}, err + } + + var items []bitwardenListItem + if err := json.Unmarshal(output, &items); err != nil { + return bitwardenListItem{}, fmt.Errorf("decode bitwarden items for secret %q: %w", secretName, err) + } + + matches := make([]bitwardenListItem, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Name) != secretName { + continue + } + if strings.TrimSpace(item.ID) == "" { + continue + } + matches = append(matches, item) + } + + switch len(matches) { + case 0: + return bitwardenListItem{}, ErrNotFound + case 1: + return matches[0], nil + default: + return bitwardenListItem{}, fmt.Errorf( + "multiple bitwarden items match secret %q for service %q", + secretName, + s.serviceName, + ) + } +} + +func (s *bitwardenStore) itemTemplate() (map[string]any, error) { + output, err := s.execute("load bitwarden item template", nil, "get", "template", "item") + if err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal(output, &payload); err != nil { + return nil, fmt.Errorf("decode bitwarden item template: %w", err) + } + + return payload, nil +} + +func (s *bitwardenStore) itemByID(id string) (map[string]any, error) { + trimmedID := strings.TrimSpace(id) + if trimmedID == "" { + return nil, errors.New("bitwarden item id must not be empty") + } + + output, err := s.execute( + fmt.Sprintf("read bitwarden item %q", trimmedID), + nil, + "get", + "item", + trimmedID, + ) + if err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal(output, &payload); err != nil { + return nil, fmt.Errorf("decode bitwarden item %q: %w", trimmedID, err) + } + + return payload, nil +} + +func (s *bitwardenStore) encodePayload(payload map[string]any) (string, error) { + raw, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("encode bitwarden payload: %w", err) + } + + output, err := s.execute("encode bitwarden payload", raw, "encode") + if err != nil { + return "", err + } + + encoded := strings.TrimSpace(string(output)) + if encoded == "" { + return "", errors.New("bitwarden CLI returned an empty encoded payload") + } + + return encoded, nil +} + +func (s *bitwardenStore) execute(operation string, stdin []byte, args ...string) ([]byte, error) { + output, err := runBitwardenCLI(s.command, stdin, args...) + if err != nil { + return nil, fmt.Errorf("%s: %w", operation, err) + } + + return output, nil +} + +func setBitwardenSecretPayload(payload map[string]any, secretName, label, secret string) { + payload["type"] = 2 + payload["name"] = secretName + payload["notes"] = strings.TrimSpace(label) + payload["secureNote"] = map[string]any{"type": 0} + payload["fields"] = []map[string]any{ + { + "name": bitwardenSecretFieldName, + "value": secret, + "type": 1, + }, + } +} + +func readBitwardenSecret(payload map[string]any) (string, bool) { + rawFields, ok := payload["fields"] + if !ok { + return "", false + } + + fields, ok := rawFields.([]any) + if !ok { + return "", false + } + + for _, rawField := range fields { + field, ok := rawField.(map[string]any) + if !ok { + continue + } + + name, _ := field["name"].(string) + if strings.TrimSpace(name) != bitwardenSecretFieldName { + continue + } + + value, ok := field["value"].(string) + if !ok { + return "", false + } + + return value, true + } + + return "", false +} + +func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { + cmd := exec.Command(command, args...) + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail == "" { + return nil, err + } + return nil, fmt.Errorf("%w: %s", err, detail) + } + + return stdout.Bytes(), nil +} diff --git a/secretstore/bitwarden_test.go b/secretstore/bitwarden_test.go new file mode 100644 index 0000000..8f6e2f0 --- /dev/null +++ b/secretstore/bitwarden_test.go @@ -0,0 +1,341 @@ +package secretstore + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gitea.lclr.dev/AI/mcp-framework/manifest" +) + +func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if _, ok := store.(*bitwardenStore); !ok { + t.Fatalf("store type = %T, want *bitwardenStore", store) + } + if !fakeCLI.versionChecked { + t.Fatal("expected bitwarden CLI version check") + } +} + +func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := Open(Options{ + ServiceName: "graylog-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err != nil { + t.Fatalf("Open returned error: %v", err) + } + + if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "secret-v1" { + t.Fatalf("GetSecret = %q, want secret-v1", value) + } + + if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil { + t.Fatalf("SetSecret (update) returned error: %v", err) + } + + value, err = store.GetSecret("api-token") + if err != nil { + t.Fatalf("GetSecret (updated) returned error: %v", err) + } + if value != "secret-v2" { + t.Fatalf("GetSecret (updated) = %q, want secret-v2", value) + } + + if err := store.DeleteSecret("api-token"); err != nil { + t.Fatalf("DeleteSecret returned error: %v", err) + } + + _, err = store.GetSecret("api-token") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err) + } +} + +func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) { + withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) { + return nil, &exec.Error{Name: command, Err: exec.ErrNotFound} + }) + + _, err := Open(Options{ + ServiceName: "email-mcp", + BackendPolicy: BackendBitwardenCLI, + }) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrBackendUnavailable) { + t.Fatalf("error = %v, want ErrBackendUnavailable", err) + } +} + +func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) { + fakeCLI := newFakeBitwardenCLI("bw") + withBitwardenRunner(t, fakeCLI.run) + + store, err := OpenFromManifest(OpenFromManifestOptions{ + ServiceName: "email-mcp", + ExecutableResolver: func() (string, error) { + return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil + }, + ManifestLoader: func(startDir string) (manifest.File, string, error) { + return manifest.File{ + SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)}, + }, filepath.Join(startDir, manifest.DefaultFile), nil + }, + }) + if err != nil { + t.Fatalf("OpenFromManifest returned error: %v", err) + } + + if err := store.SetSecret("smtp-password", "SMTP password", "super-secret"); err != nil { + t.Fatalf("SetSecret returned error: %v", err) + } + + value, err := store.GetSecret("smtp-password") + if err != nil { + t.Fatalf("GetSecret returned error: %v", err) + } + if value != "super-secret" { + t.Fatalf("GetSecret = %q, want super-secret", value) + } +} + +func withBitwardenRunner( + t *testing.T, + runner func(command string, stdin []byte, args ...string) ([]byte, error), +) { + t.Helper() + + previous := runBitwardenCLI + runBitwardenCLI = runner + t.Cleanup(func() { + runBitwardenCLI = previous + }) +} + +type fakeBitwardenCLI struct { + command string + itemsByID map[string]fakeBitwardenItem + nextID int + versionChecked bool +} + +type fakeBitwardenItem struct { + ID string + Name string + Notes string + Secret string +} + +func newFakeBitwardenCLI(command string) *fakeBitwardenCLI { + return &fakeBitwardenCLI{ + command: strings.TrimSpace(command), + itemsByID: map[string]fakeBitwardenItem{}, + nextID: 1, + } +} + +func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]byte, error) { + if strings.TrimSpace(command) != f.command { + return nil, fmt.Errorf("unexpected command %q", command) + } + if len(args) == 0 { + return nil, errors.New("missing bitwarden CLI arguments") + } + + if len(args) == 1 && args[0] == "--version" { + f.versionChecked = true + return []byte("2026.1.0\n"), nil + } + + switch { + case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search": + return f.handleListItems(args[3]) + case len(args) == 3 && args[0] == "get" && args[1] == "item": + return f.handleGetItem(args[2]) + case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item": + return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil + case len(args) == 1 && args[0] == "encode": + return []byte(base64.StdEncoding.EncodeToString(stdin)), nil + case len(args) == 3 && args[0] == "create" && args[1] == "item": + return f.handleCreateItem(args[2]) + case len(args) == 4 && args[0] == "edit" && args[1] == "item": + return f.handleEditItem(args[2], args[3]) + case len(args) == 3 && args[0] == "delete" && args[1] == "item": + delete(f.itemsByID, strings.TrimSpace(args[2])) + return []byte(`{"success":true}`), nil + default: + return nil, fmt.Errorf("unsupported bitwarden CLI invocation: %v", args) + } +} + +func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) { + needle := strings.TrimSpace(search) + items := make([]map[string]any, 0) + for _, item := range f.itemsByID { + if !strings.Contains(item.Name, needle) { + continue + } + items = append(items, map[string]any{ + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": []map[string]any{ + {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, + }, + }) + } + + payload, err := json.Marshal(items) + if err != nil { + return nil, err + } + return payload, nil +} + +func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) { + item, ok := f.itemsByID[strings.TrimSpace(id)] + if !ok { + return nil, errors.New("item not found") + } + + payload, err := json.Marshal(map[string]any{ + "id": item.ID, + "name": item.Name, + "notes": item.Notes, + "fields": []map[string]any{ + {"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1}, + }, + "secureNote": map[string]any{"type": 0}, + }) + if err != nil { + return nil, err + } + + return payload, nil +} + +func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) { + payload, err := decodeBitwardenPayload(encoded) + if err != nil { + return nil, err + } + + item := fakeBitwardenItem{ + ID: fmt.Sprintf("item-%d", f.nextID), + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenSecret(payload), + } + f.nextID++ + f.itemsByID[item.ID] = item + + payload["id"] = item.ID + encodedPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return encodedPayload, nil +} + +func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) { + trimmedID := strings.TrimSpace(id) + if _, ok := f.itemsByID[trimmedID]; !ok { + return nil, errors.New("item not found") + } + + payload, err := decodeBitwardenPayload(encoded) + if err != nil { + return nil, err + } + + item := fakeBitwardenItem{ + ID: trimmedID, + Name: readString(payload, "name"), + Notes: readString(payload, "notes"), + Secret: readFakeBitwardenSecret(payload), + } + f.itemsByID[trimmedID] = item + + payload["id"] = trimmedID + encodedPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return encodedPayload, nil +} + +func decodeBitwardenPayload(encoded string) (map[string]any, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("decode encoded payload: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("decode payload JSON: %w", err) + } + + return payload, nil +} + +func readFakeBitwardenSecret(payload map[string]any) string { + rawFields, ok := payload["fields"] + if !ok { + return "" + } + + fields, ok := rawFields.([]any) + if !ok { + return "" + } + + for _, rawField := range fields { + field, ok := rawField.(map[string]any) + if !ok { + continue + } + + name := strings.TrimSpace(readString(field, "name")) + if name != bitwardenSecretFieldName { + continue + } + + return readString(field, "value") + } + + return "" +} + +func readString(payload map[string]any, key string) string { + value, _ := payload[key].(string) + return strings.TrimSpace(value) +} diff --git a/secretstore/store.go b/secretstore/store.go index 1cd61d1..d957562 100644 --- a/secretstore/store.go +++ b/secretstore/store.go @@ -20,18 +20,20 @@ var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy") type BackendPolicy string const ( - BackendAuto BackendPolicy = "auto" - BackendKWalletOnly BackendPolicy = "kwallet-only" - BackendKeyringAny BackendPolicy = "keyring-any" - BackendEnvOnly BackendPolicy = "env-only" + 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 + ServiceName string + BackendPolicy BackendPolicy + LookupEnv func(string) (string, bool) + KWalletAppID string + KWalletFolder string + BitwardenCommand string } type Store interface { @@ -92,6 +94,10 @@ func Open(options Options) (Store, error) { 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 { @@ -263,7 +269,7 @@ func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) { } switch trimmed { - case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly: + case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI: return trimmed, nil default: return "", invalidBackendPolicyError(trimmed)