package secretstore import ( "bytes" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "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, Shell: options.Shell, }); 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 } unlockCommand := bitwardenUnlockRemediation(command, options.Shell) 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 `%s` then retry", ErrBWLocked, unlockCommand, ) case "unlocked": session, ok := lookupEnv(bitwardenSessionEnvName) if !ok || strings.TrimSpace(session) == "" { return fmt.Errorf( "%w: environment variable %q is missing; run `%s` then retry", ErrBWLocked, bitwardenSessionEnvName, unlockCommand, ) } return nil default: return fmt.Errorf( "%w: unsupported bitwarden status %q", ErrBWUnavailable, strings.TrimSpace(status.Status), ) } } func bitwardenUnlockRemediation(command, shellHint string) string { unlockCommand := fmt.Sprintf("%s unlock --raw", strings.TrimSpace(command)) switch detectShellFlavor(shellHint) { case "fish": return fmt.Sprintf("set -x %s (%s)", bitwardenSessionEnvName, unlockCommand) case "powershell": return fmt.Sprintf("$env:%s = (%s)", bitwardenSessionEnvName, unlockCommand) case "cmd": return fmt.Sprintf( "for /f \"usebackq delims=\" %%i in (`%s`) do set %s=%%i", unlockCommand, bitwardenSessionEnvName, ) default: return fmt.Sprintf("export %s=\"$(%s)\"", bitwardenSessionEnvName, unlockCommand) } } func detectShellFlavor(shellHint string) string { raw := strings.TrimSpace(shellHint) if raw == "" { raw = strings.TrimSpace(os.Getenv("SHELL")) } if raw == "" { raw = strings.TrimSpace(os.Getenv("COMSPEC")) } if raw == "" && runtime.GOOS == "windows" { return "powershell" } lower := strings.ToLower(strings.TrimSpace(raw)) base := strings.ToLower(filepath.Base(lower)) switch { case strings.Contains(lower, "powershell"), strings.Contains(lower, "pwsh"), base == "powershell", base == "powershell.exe", base == "pwsh", base == "pwsh.exe": return "powershell" case strings.Contains(lower, "fish"), base == "fish": return "fish" case strings.Contains(lower, "cmd.exe"), base == "cmd", base == "cmd.exe": return "cmd" default: return "posix" } } 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 } }