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 }