mcp-framework/secretstore/bitwarden.go

345 lines
7.2 KiB
Go

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
}