mcp-framework/secretstore/bitwarden.go

547 lines
13 KiB
Go

package secretstore
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
)
const (
defaultBitwardenCommand = "bw"
bitwardenSessionEnvName = "BW_SESSION"
bitwardenSecretFieldName = "mcp-secret"
bitwardenServiceFieldName = "mcp-service"
bitwardenSecretNameFieldName = "mcp-secret-name"
)
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"`
}
type bitwardenStatusOutput struct {
Status string `json:"status"`
}
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),
)
}
if err := EnsureBitwardenReady(Options{
BitwardenCommand: command,
LookupEnv: options.LookupEnv,
}); err != nil {
return nil, fmt.Errorf(
"secret backend policy %q cannot use bitwarden CLI command %q right now: %w",
policy,
command,
errors.Join(ErrBackendUnavailable, err),
)
}
return store, nil
}
func EnsureBitwardenReady(options Options) error {
command := strings.TrimSpace(options.BitwardenCommand)
if command == "" {
command = defaultBitwardenCommand
}
lookupEnv := options.LookupEnv
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
output, err := runBitwardenCLI(command, nil, "status")
if err != nil {
return fmt.Errorf("check bitwarden CLI status: %w", err)
}
trimmed := strings.TrimSpace(string(output))
if trimmed == "" {
return fmt.Errorf("%w: bitwarden CLI returned an empty status", ErrBWUnavailable)
}
var status bitwardenStatusOutput
if err := json.Unmarshal([]byte(trimmed), &status); err != nil {
return fmt.Errorf("decode bitwarden CLI status: %w", errors.Join(ErrBWUnavailable, err))
}
switch strings.ToLower(strings.TrimSpace(status.Status)) {
case "unauthenticated":
return fmt.Errorf("%w: run `bw login` then retry", ErrBWNotLoggedIn)
case "locked":
return fmt.Errorf(
"%w: run `export %s=\"$(bw unlock --raw)\"` then retry",
ErrBWLocked,
bitwardenSessionEnvName,
)
case "unlocked":
session, ok := lookupEnv(bitwardenSessionEnvName)
if !ok || strings.TrimSpace(session) == "" {
return fmt.Errorf(
"%w: environment variable %q is missing; run `export %s=\"$(bw unlock --raw)\"` then retry",
ErrBWLocked,
bitwardenSessionEnvName,
bitwardenSessionEnvName,
)
}
return nil
default:
return fmt.Errorf(
"%w: unsupported bitwarden status %q",
ErrBWUnavailable,
strings.TrimSpace(status.Status),
)
}
}
func (s *bitwardenStore) SetSecret(name, label, secret string) error {
secretName := s.scopedName(name)
item, err := s.findItem(secretName, name)
switch {
case errors.Is(err, ErrNotFound):
template, err := s.itemTemplate()
if err != nil {
return err
}
setBitwardenSecretPayload(template, s.serviceName, name, 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, s.serviceName, name, 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, name)
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, name)
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, rawSecretName 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
}
if strings.TrimSpace(string(output)) == "" {
return bitwardenListItem{}, ErrNotFound
}
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)
}
if len(matches) == 0 {
return bitwardenListItem{}, ErrNotFound
}
markedMatches := make([]bitwardenListItem, 0, len(matches))
legacyMatches := make([]bitwardenListItem, 0, len(matches))
for _, item := range matches {
payload, err := s.itemByID(item.ID)
if err != nil {
return bitwardenListItem{}, err
}
if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) {
markedMatches = append(markedMatches, item)
continue
}
legacyMatches = append(legacyMatches, item)
}
switch len(markedMatches) {
case 0:
switch len(legacyMatches) {
case 0:
return bitwardenListItem{}, ErrNotFound
case 1:
return legacyMatches[0], nil
default:
return bitwardenListItem{}, fmt.Errorf(
"multiple legacy bitwarden items match secret %q for service %q",
secretName,
s.serviceName,
)
}
case 1:
return markedMatches[0], nil
default:
return bitwardenListItem{}, fmt.Errorf(
"multiple bitwarden items share marker for secret %q and 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, serviceName, rawSecretName, 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,
},
{
"name": bitwardenServiceFieldName,
"value": strings.TrimSpace(serviceName),
"type": 0,
},
{
"name": bitwardenSecretNameFieldName,
"value": strings.TrimSpace(rawSecretName),
"type": 0,
},
}
}
func readBitwardenSecret(payload map[string]any) (string, bool) {
return readBitwardenField(payload, bitwardenSecretFieldName)
}
func readBitwardenField(payload map[string]any, fieldName string) (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) != strings.TrimSpace(fieldName) {
continue
}
value, ok := field["value"].(string)
if !ok {
return "", false
}
return value, true
}
return "", false
}
func bitwardenItemMatchesMarkers(payload map[string]any, serviceName, secretName string) bool {
markedService, ok := readBitwardenField(payload, bitwardenServiceFieldName)
if !ok {
return false
}
markedSecretName, ok := readBitwardenField(payload, bitwardenSecretNameFieldName)
if !ok {
return false
}
return strings.TrimSpace(markedService) == strings.TrimSpace(serviceName) &&
strings.TrimSpace(markedSecretName) == strings.TrimSpace(secretName)
}
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 {
return nil, normalizeBitwardenExecutionError(err, stderr.String(), stdout.String())
}
return stdout.Bytes(), nil
}
func normalizeBitwardenExecutionError(err error, stderrText, stdoutText string) error {
detail := sanitizeBitwardenErrorDetail(stderrText, stdoutText)
classification := classifyBitwardenError(detail)
if classification == nil {
classification = ErrBWUnavailable
}
wrapped := errors.Join(classification, err)
if strings.TrimSpace(detail) == "" {
return wrapped
}
return fmt.Errorf("%w: %s", wrapped, detail)
}
func sanitizeBitwardenErrorDetail(stderrText, stdoutText string) string {
raw := strings.TrimSpace(stderrText)
if raw == "" {
raw = strings.TrimSpace(stdoutText)
}
if raw == "" {
return ""
}
lines := strings.Split(raw, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(trimmed, "at ") ||
strings.HasPrefix(lower, "node:internal") ||
strings.HasPrefix(lower, "internal/") ||
strings.HasPrefix(lower, "npm ") {
continue
}
cleaned = append(cleaned, trimmed)
}
if len(cleaned) == 0 {
return ""
}
if len(cleaned) == 1 {
return cleaned[0]
}
return cleaned[0] + " | " + cleaned[1]
}
func classifyBitwardenError(detail string) error {
lower := strings.ToLower(strings.TrimSpace(detail))
switch {
case strings.Contains(lower, "not logged in"), strings.Contains(lower, "unauthenticated"):
return ErrBWNotLoggedIn
case strings.Contains(lower, "vault is locked"), strings.Contains(lower, "is locked"):
return ErrBWLocked
case strings.Contains(lower, "failed to fetch"),
strings.Contains(lower, "econnrefused"),
strings.Contains(lower, "etimedout"),
strings.Contains(lower, "unable to connect"),
strings.Contains(lower, "network"):
return ErrBWUnavailable
default:
return nil
}
}