feat(secretstore): add bitwarden CLI backend support

This commit is contained in:
thibaud-lclr 2026-04-20 08:30:35 +02:00
parent 973770ed78
commit bba7aacedf
5 changed files with 715 additions and 11 deletions

View file

@ -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.

View file

@ -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

345
secretstore/bitwarden.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -24,6 +24,7 @@ const (
BackendKWalletOnly BackendPolicy = "kwallet-only"
BackendKeyringAny BackendPolicy = "keyring-any"
BackendEnvOnly BackendPolicy = "env-only"
BackendBitwardenCLI BackendPolicy = "bitwarden-cli"
)
type Options struct {
@ -32,6 +33,7 @@ type Options struct {
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)