package secretstore import ( "bytes" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "time" ) const ( defaultBitwardenCommand = "bw" bitwardenDebugEnvName = "MCP_FRAMEWORK_BITWARDEN_DEBUG" bitwardenSessionEnvName = "BW_SESSION" bitwardenSecretFieldName = "mcp-secret" bitwardenServiceFieldName = "mcp-service" bitwardenSecretNameFieldName = "mcp-secret-name" bitwardenLoaderMessage = "Waiting BitWarden..." bitwardenLoaderInterval = 90 * time.Millisecond bitwardenLoaderColorBase = "\033[38;5;39m" bitwardenLoaderColorWave = "\033[38;5;81m" bitwardenLoaderColorFocus = "\033[38;5;117m" bitwardenLoaderResetColor = "\033[0m" bitwardenLoaderClearLine = "\r\033[2K" ) type bitwardenRunner func(command string, stdin []byte, args ...string) ([]byte, error) type bitwardenInteractiveRunner func( command string, stdin io.Reader, stdout, stderr io.Writer, args ...string, ) ([]byte, error) var runBitwardenCLI bitwardenRunner = executeBitwardenCLI var runBitwardenInteractiveCLI bitwardenInteractiveRunner = executeBitwardenCLIInteractive var startBitwardenLoaderFunc = startBitwardenLoader var bitwardenLoaderActive atomic.Bool var bitwardenDebugOutput io.Writer = os.Stderr type bitwardenStore struct { command string serviceName string debug bool lookupEnv func(string) (string, bool) shell string cache *bitwardenCache } 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 } debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) if _, err := EnsureBitwardenSessionEnv(BitwardenSessionOptions{ ServiceName: serviceName, }); err != nil { return nil, fmt.Errorf( "secret backend policy %q cannot load persisted bitwarden session for service %q: %w", policy, serviceName, errors.Join(ErrBackendUnavailable, err), ) } session, _ := os.LookupEnv(bitwardenSessionEnvName) cacheEnabled := !options.DisableBitwardenCache && !bitwardenCacheDisabledByEnv() store := &bitwardenStore{ command: command, serviceName: serviceName, debug: debugEnabled, lookupEnv: options.LookupEnv, shell: options.Shell, cache: newBitwardenCache(bitwardenCacheOptions{ ServiceName: serviceName, Session: session, TTL: defaultBitwardenCacheTTL, CacheDir: resolveBitwardenCacheDir(serviceName), Enabled: cacheEnabled, }), } return store, nil } func verifyBitwardenCLIReady(options Options) error { command := strings.TrimSpace(options.BitwardenCommand) if command == "" { command = defaultBitwardenCommand } debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) if _, err := runBitwardenCommand(command, debugEnabled, nil, "--version"); err != nil { if errors.Is(err, exec.ErrNotFound) { return fmt.Errorf( "requires bitwarden CLI command %q in PATH: %w", command, errors.Join(ErrBackendUnavailable, err), ) } return fmt.Errorf( "cannot verify bitwarden CLI command %q: %w", command, errors.Join(ErrBackendUnavailable, err), ) } if err := EnsureBitwardenReady(options); err != nil { return fmt.Errorf( "cannot use bitwarden CLI command %q right now: %w", command, errors.Join(ErrBackendUnavailable, err), ) } return nil } func EnsureBitwardenReady(options Options) error { command := strings.TrimSpace(options.BitwardenCommand) if command == "" { command = defaultBitwardenCommand } debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) unlockCommand := bitwardenUnlockRemediation(command, options.Shell) status, err := readBitwardenStatus(command, debugEnabled) if err != nil { return err } switch 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": lookupEnv := options.LookupEnv if lookupEnv == nil { lookupEnv = os.LookupEnv } session, ok := lookupEnv(bitwardenSessionEnvName) if !ok || strings.TrimSpace(session) == "" { session, ok = os.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, status, ) } } func readBitwardenStatus(command string, debug bool) (string, error) { output, err := runBitwardenCommand(command, debug, 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)) } return strings.ToLower(strings.TrimSpace(status.Status)), nil } 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) if err := s.ensureReady(); err != nil { return fmt.Errorf("prepare bitwarden CLI for saving secret %q: %w", name, err) } item, payload, 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 } if s.cache != nil { s.cache.invalidate(name, secretName) s.cache.store(name, secretName, secret) } return nil case 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 } if s.cache != nil { s.cache.invalidate(name, secretName) s.cache.store(name, secretName, secret) } return nil } func (s *bitwardenStore) GetSecret(name string) (string, error) { secretName := s.scopedName(name) if s.cache != nil { if secret, ok := s.cache.load(name, secretName); ok { return secret, nil } } if err := s.ensureReady(); err != nil { return "", fmt.Errorf("prepare bitwarden CLI for reading secret %q: %w", name, err) } _, payload, err := s.findItem(secretName, name) 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) } if s.cache != nil { s.cache.store(name, secretName, secret) } return secret, nil } func (s *bitwardenStore) DeleteSecret(name string) error { secretName := s.scopedName(name) if err := s.ensureReady(); err != nil { return fmt.Errorf("prepare bitwarden CLI for deleting secret %q: %w", name, err) } item, _, err := s.findItem(secretName, name) if errors.Is(err, ErrNotFound) { if s.cache != nil { s.cache.invalidate(name, secretName) } 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 } if s.cache != nil { s.cache.invalidate(name, secretName) } return nil } func (s *bitwardenStore) scopedName(name string) string { return fmt.Sprintf("%s/%s", s.serviceName, name) } func (s *bitwardenStore) ensureReady() error { s.refreshSessionEnv() return verifyBitwardenCLIReady(Options{ BitwardenCommand: s.command, BitwardenDebug: s.debug, LookupEnv: s.lookupEnv, Shell: s.shell, }) } func (s *bitwardenStore) refreshSessionEnv() { session, err := LoadBitwardenSession(BitwardenSessionOptions{ServiceName: bitwardenSharedSessionName}) if err != nil || strings.TrimSpace(session) == "" { return } _ = os.Setenv(bitwardenSessionEnvName, strings.TrimSpace(session)) } type bitwardenResolvedItem struct { item bitwardenListItem payload map[string]any } func (s *bitwardenStore) findItem(secretName, rawSecretName string) (bitwardenListItem, map[string]any, error) { output, err := s.execute( fmt.Sprintf("list bitwarden items for secret %q", secretName), nil, "list", "items", "--search", secretName, ) if err != nil { return bitwardenListItem{}, nil, err } if strings.TrimSpace(string(output)) == "" { return bitwardenListItem{}, nil, ErrNotFound } var items []bitwardenListItem if err := json.Unmarshal(output, &items); err != nil { return bitwardenListItem{}, nil, 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{}, nil, ErrNotFound } markedMatches := make([]bitwardenResolvedItem, 0, len(matches)) legacyMatches := make([]bitwardenResolvedItem, 0, len(matches)) for _, item := range matches { payload, err := s.itemByID(item.ID) if err != nil { return bitwardenListItem{}, nil, err } resolved := bitwardenResolvedItem{ item: item, payload: payload, } if bitwardenItemMatchesMarkers(payload, s.serviceName, rawSecretName) { markedMatches = append(markedMatches, resolved) continue } legacyMatches = append(legacyMatches, resolved) } switch len(markedMatches) { case 0: switch len(legacyMatches) { case 0: return bitwardenListItem{}, nil, ErrNotFound case 1: return legacyMatches[0].item, legacyMatches[0].payload, nil default: return bitwardenListItem{}, nil, fmt.Errorf( "multiple legacy bitwarden items match secret %q for service %q", secretName, s.serviceName, ) } case 1: return markedMatches[0].item, markedMatches[0].payload, nil default: return bitwardenListItem{}, nil, 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 := runBitwardenCommand(s.command, s.debug, 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 runBitwardenCommand(command string, debug bool, stdin []byte, args ...string) ([]byte, error) { if debug { logBitwardenCommand(command, args...) } return runBitwardenCLI(command, stdin, args...) } func runBitwardenInteractiveCommand( command string, debug bool, stdin io.Reader, stdout, stderr io.Writer, args ...string, ) ([]byte, error) { if debug { logBitwardenCommand(command, args...) } return runBitwardenInteractiveCLI(command, stdin, stdout, stderr, args...) } func isBitwardenDebugEnabled(explicit bool) bool { if explicit { return true } raw, ok := os.LookupEnv(bitwardenDebugEnvName) if !ok { return false } switch strings.ToLower(strings.TrimSpace(raw)) { case "1", "true", "yes", "y", "on": return true default: return false } } func logBitwardenCommand(command string, args ...string) { writer := bitwardenDebugOutput if writer == nil { return } renderedArgs := sanitizeBitwardenDebugArgs(args) if len(renderedArgs) == 0 { _, _ = fmt.Fprintf(writer, "[bitwarden debug] %s\n", strings.TrimSpace(command)) return } _, _ = fmt.Fprintf( writer, "[bitwarden debug] %s %s\n", strings.TrimSpace(command), strings.Join(renderedArgs, " "), ) } func sanitizeBitwardenDebugArgs(args []string) []string { if len(args) == 0 { return nil } rendered := make([]string, len(args)) for idx, arg := range args { rendered[idx] = strings.TrimSpace(arg) } if len(rendered) >= 3 && rendered[0] == "create" && rendered[1] == "item" { rendered[2] = "" } if len(rendered) >= 4 && rendered[0] == "edit" && rendered[1] == "item" { rendered[3] = "" } return rendered } func executeBitwardenCLI(command string, stdin []byte, args ...string) ([]byte, error) { stopLoader := startBitwardenLoaderFunc() defer stopLoader() 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 executeBitwardenCLIInteractive( command string, stdin io.Reader, stdout, stderr io.Writer, args ...string, ) ([]byte, error) { cmd := exec.Command(command, args...) if stdin != nil { cmd.Stdin = stdin } var stdoutBuffer bytes.Buffer if stdout == nil { cmd.Stdout = &stdoutBuffer } else { cmd.Stdout = io.MultiWriter(stdout, &stdoutBuffer) } var stderrBuffer bytes.Buffer if stderr == nil { cmd.Stderr = &stderrBuffer } else { cmd.Stderr = io.MultiWriter(stderr, &stderrBuffer) } if err := cmd.Run(); err != nil { return nil, normalizeBitwardenExecutionError(err, stderrBuffer.String(), stdoutBuffer.String()) } return stdoutBuffer.Bytes(), nil } func startBitwardenLoader() func() { if !shouldShowBitwardenLoader() { return func() {} } if !bitwardenLoaderActive.CompareAndSwap(false, true) { return func() {} } done := make(chan struct{}) stopped := make(chan struct{}) go func() { defer close(stopped) ticker := time.NewTicker(bitwardenLoaderInterval) defer ticker.Stop() phase := 0 for { _, _ = fmt.Fprint(os.Stdout, bitwardenLoaderFrame(phase)) phase++ select { case <-done: _, _ = fmt.Fprint(os.Stdout, bitwardenLoaderClearLine) return case <-ticker.C: } } }() var stopOnce sync.Once return func() { stopOnce.Do(func() { close(done) <-stopped bitwardenLoaderActive.Store(false) }) } } func shouldShowBitwardenLoader() bool { term := strings.TrimSpace(os.Getenv("TERM")) if term == "" || strings.EqualFold(term, "dumb") { return false } info, err := os.Stdout.Stat() if err != nil { return false } return (info.Mode() & os.ModeCharDevice) != 0 } func bitwardenLoaderFrame(phase int) string { chars := []rune(bitwardenLoaderMessage) if len(chars) == 0 { return bitwardenLoaderClearLine } waveIndex := phase % len(chars) if waveIndex < 0 { waveIndex += len(chars) } var frame strings.Builder frame.Grow(len(chars)*14 + len(bitwardenLoaderClearLine) + len(bitwardenLoaderResetColor)) frame.WriteString(bitwardenLoaderClearLine) for idx, char := range chars { frame.WriteString(bitwardenLoaderColorForIndex(idx, waveIndex, len(chars))) frame.WriteRune(char) } frame.WriteString(bitwardenLoaderResetColor) return frame.String() } func bitwardenLoaderColorForIndex(idx, waveIndex, length int) string { if length <= 1 { return bitwardenLoaderColorFocus } distance := idx - waveIndex if distance < 0 { distance = -distance } if wrapped := length - distance; wrapped < distance { distance = wrapped } switch distance { case 0: return bitwardenLoaderColorFocus case 1: return bitwardenLoaderColorWave default: return bitwardenLoaderColorBase } } 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 } }