345 lines
7.2 KiB
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
|
|
}
|