package secretstore import ( "errors" "fmt" "io" "os" "path/filepath" "strings" ) const bitwardenSessionFileName = "bw-session" var bitwardenUserConfigDir = os.UserConfigDir type BitwardenSessionOptions struct { ServiceName string } type BitwardenLoginOptions struct { ServiceName string BitwardenCommand string BitwardenDebug bool Stdin io.Reader Stdout io.Writer Stderr io.Writer } func SaveBitwardenSession(options BitwardenSessionOptions, session string) (string, error) { trimmedSession := strings.TrimSpace(session) if trimmedSession == "" { return "", errors.New("bitwarden session must not be empty") } path, err := resolveBitwardenSessionPath(options) if err != nil { return "", err } dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return "", fmt.Errorf("create bitwarden session dir %q: %w", dir, err) } if err := os.Chmod(dir, 0o700); err != nil { return "", fmt.Errorf("set bitwarden session dir permissions %q: %w", dir, err) } tmpFile, err := os.CreateTemp(dir, "bw-session-*.tmp") if err != nil { return "", fmt.Errorf("create temp bitwarden session in %q: %w", dir, err) } tmpPath := tmpFile.Name() cleanup := true defer func() { _ = tmpFile.Close() if cleanup { _ = os.Remove(tmpPath) } }() if err := tmpFile.Chmod(0o600); err != nil { return "", fmt.Errorf("set bitwarden session temp file permissions %q: %w", tmpPath, err) } if _, err := tmpFile.WriteString(trimmedSession + "\n"); err != nil { return "", fmt.Errorf("write bitwarden session temp file %q: %w", tmpPath, err) } if err := tmpFile.Close(); err != nil { return "", fmt.Errorf("close bitwarden session temp file %q: %w", tmpPath, err) } if err := os.Rename(tmpPath, path); err != nil { return "", fmt.Errorf("replace bitwarden session file %q: %w", path, err) } if err := os.Chmod(path, 0o600); err != nil { return "", fmt.Errorf("set bitwarden session file permissions %q: %w", path, err) } cleanup = false return path, nil } func LoadBitwardenSession(options BitwardenSessionOptions) (string, error) { path, err := resolveBitwardenSessionPath(options) if err != nil { return "", err } data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return "", ErrNotFound } return "", fmt.Errorf("read bitwarden session file %q: %w", path, err) } session := strings.TrimSpace(string(data)) if session == "" { return "", ErrNotFound } return session, nil } func EnsureBitwardenSessionEnv(options BitwardenSessionOptions) (bool, error) { if session, ok := os.LookupEnv(bitwardenSessionEnvName); ok && strings.TrimSpace(session) != "" { return false, nil } session, err := LoadBitwardenSession(options) if err != nil { if errors.Is(err, ErrNotFound) { return false, nil } return false, err } if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { return false, fmt.Errorf("set %s from persisted bitwarden session: %w", bitwardenSessionEnvName, err) } return true, nil } func LoginBitwarden(options BitwardenLoginOptions) (string, error) { serviceName := strings.TrimSpace(options.ServiceName) if serviceName == "" { return "", errors.New("service name must not be empty") } command := strings.TrimSpace(options.BitwardenCommand) if command == "" { command = defaultBitwardenCommand } debugEnabled := isBitwardenDebugEnabled(options.BitwardenDebug) stdin := options.Stdin if stdin == nil { stdin = os.Stdin } stdout := options.Stdout if stdout == nil { stdout = os.Stdout } stderr := options.Stderr if stderr == nil { stderr = os.Stderr } status, err := readBitwardenStatus(command, debugEnabled) if err != nil { return "", err } switch status { case "unauthenticated": if _, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, stdout, stderr, "login"); err != nil { return "", fmt.Errorf("login to bitwarden CLI: %w", err) } case "locked", "unlocked": default: return "", fmt.Errorf("%w: unsupported bitwarden status %q", ErrBWUnavailable, status) } unlockOutput, err := runBitwardenInteractiveCommand(command, debugEnabled, stdin, nil, stderr, "unlock", "--raw") if err != nil { return "", fmt.Errorf("unlock bitwarden vault: %w", err) } session := strings.TrimSpace(string(unlockOutput)) if session == "" { return "", errors.New("bitwarden CLI returned an empty session after unlock") } if err := os.Setenv(bitwardenSessionEnvName, session); err != nil { return "", fmt.Errorf("set %s after bitwarden unlock: %w", bitwardenSessionEnvName, err) } if _, err := SaveBitwardenSession(BitwardenSessionOptions{ServiceName: serviceName}, session); err != nil { return "", fmt.Errorf("persist bitwarden session: %w", err) } return session, nil } func resolveBitwardenSessionPath(options BitwardenSessionOptions) (string, error) { serviceName := strings.TrimSpace(options.ServiceName) if serviceName == "" { return "", errors.New("service name must not be empty") } userConfigDir, err := bitwardenUserConfigDir() if err != nil { return "", fmt.Errorf("resolve user config dir for bitwarden session: %w", err) } return filepath.Join(userConfigDir, serviceName, bitwardenSessionFileName), nil }