feat(secretstore): add bitwarden CLI backend support
This commit is contained in:
parent
973770ed78
commit
bba7aacedf
5 changed files with 715 additions and 11 deletions
|
|
@ -59,7 +59,7 @@ Champs supportés :
|
|||
- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
|
||||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
||||
- `[environment].known` : variables d'environnement connues du projet.
|
||||
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`).
|
||||
- `[secret_store].backend_policy` : politique de secret store (`auto`, `kwallet-only`, `keyring-any`, `env-only`, `bitwarden-cli`).
|
||||
- `[profiles].default` : profil recommandé par défaut.
|
||||
- `[profiles].known` : profils connus du projet.
|
||||
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Le package `secretstore` supporte plusieurs politiques de backend :
|
|||
- `kwallet-only` : impose KWallet et retourne une erreur explicite si KWallet n'est pas disponible
|
||||
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
|
||||
- `env-only` : lecture seule depuis les variables d'environnement
|
||||
- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault
|
||||
|
||||
Backends keyring typiques :
|
||||
|
||||
|
|
@ -60,6 +61,17 @@ store, err := secretstore.Open(secretstore.Options{
|
|||
})
|
||||
```
|
||||
|
||||
Pour imposer Bitwarden via son CLI :
|
||||
|
||||
```go
|
||||
store, err := secretstore.Open(secretstore.Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: secretstore.BackendBitwardenCLI,
|
||||
// Optionnel si `bw` n'est pas dans le PATH :
|
||||
// BitwardenCommand: "/usr/local/bin/bw",
|
||||
})
|
||||
```
|
||||
|
||||
Pour stocker un secret structuré en JSON :
|
||||
|
||||
```go
|
||||
|
|
|
|||
345
secretstore/bitwarden.go
Normal file
345
secretstore/bitwarden.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
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
|
||||
}
|
||||
341
secretstore/bitwarden_test.go
Normal file
341
secretstore/bitwarden_test.go
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
package secretstore
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lclr.dev/AI/mcp-framework/manifest"
|
||||
)
|
||||
|
||||
func TestOpenSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := store.(*bitwardenStore); !ok {
|
||||
t.Fatalf("store type = %T, want *bitwardenStore", store)
|
||||
}
|
||||
if !fakeCLI.versionChecked {
|
||||
t.Fatal("expected bitwarden CLI version check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBitwardenStoreSetGetDeleteSecret(t *testing.T) {
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := Open(Options{
|
||||
ServiceName: "graylog-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SetSecret("api-token", "API token", "secret-v1"); err != nil {
|
||||
t.Fatalf("SetSecret returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "secret-v1" {
|
||||
t.Fatalf("GetSecret = %q, want secret-v1", value)
|
||||
}
|
||||
|
||||
if err := store.SetSecret("api-token", "API token", "secret-v2"); err != nil {
|
||||
t.Fatalf("SetSecret (update) returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err = store.GetSecret("api-token")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret (updated) returned error: %v", err)
|
||||
}
|
||||
if value != "secret-v2" {
|
||||
t.Fatalf("GetSecret (updated) = %q, want secret-v2", value)
|
||||
}
|
||||
|
||||
if err := store.DeleteSecret("api-token"); err != nil {
|
||||
t.Fatalf("DeleteSecret returned error: %v", err)
|
||||
}
|
||||
|
||||
_, err = store.GetSecret("api-token")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("GetSecret after delete error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBitwardenCLIReturnsUnavailableWhenCommandIsMissing(t *testing.T) {
|
||||
withBitwardenRunner(t, func(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
return nil, &exec.Error{Name: command, Err: exec.ErrNotFound}
|
||||
})
|
||||
|
||||
_, err := Open(Options{
|
||||
ServiceName: "email-mcp",
|
||||
BackendPolicy: BackendBitwardenCLI,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, ErrBackendUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrBackendUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFromManifestSupportsBitwardenCLIBackendPolicy(t *testing.T) {
|
||||
fakeCLI := newFakeBitwardenCLI("bw")
|
||||
withBitwardenRunner(t, fakeCLI.run)
|
||||
|
||||
store, err := OpenFromManifest(OpenFromManifestOptions{
|
||||
ServiceName: "email-mcp",
|
||||
ExecutableResolver: func() (string, error) {
|
||||
return filepath.Join(string(filepath.Separator), "opt", "email-mcp", "bin", "email-mcp"), nil
|
||||
},
|
||||
ManifestLoader: func(startDir string) (manifest.File, string, error) {
|
||||
return manifest.File{
|
||||
SecretStore: manifest.SecretStore{BackendPolicy: string(BackendBitwardenCLI)},
|
||||
}, filepath.Join(startDir, manifest.DefaultFile), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFromManifest returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SetSecret("smtp-password", "SMTP password", "super-secret"); err != nil {
|
||||
t.Fatalf("SetSecret returned error: %v", err)
|
||||
}
|
||||
|
||||
value, err := store.GetSecret("smtp-password")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSecret returned error: %v", err)
|
||||
}
|
||||
if value != "super-secret" {
|
||||
t.Fatalf("GetSecret = %q, want super-secret", value)
|
||||
}
|
||||
}
|
||||
|
||||
func withBitwardenRunner(
|
||||
t *testing.T,
|
||||
runner func(command string, stdin []byte, args ...string) ([]byte, error),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
previous := runBitwardenCLI
|
||||
runBitwardenCLI = runner
|
||||
t.Cleanup(func() {
|
||||
runBitwardenCLI = previous
|
||||
})
|
||||
}
|
||||
|
||||
type fakeBitwardenCLI struct {
|
||||
command string
|
||||
itemsByID map[string]fakeBitwardenItem
|
||||
nextID int
|
||||
versionChecked bool
|
||||
}
|
||||
|
||||
type fakeBitwardenItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Notes string
|
||||
Secret string
|
||||
}
|
||||
|
||||
func newFakeBitwardenCLI(command string) *fakeBitwardenCLI {
|
||||
return &fakeBitwardenCLI{
|
||||
command: strings.TrimSpace(command),
|
||||
itemsByID: map[string]fakeBitwardenItem{},
|
||||
nextID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBitwardenCLI) run(command string, stdin []byte, args ...string) ([]byte, error) {
|
||||
if strings.TrimSpace(command) != f.command {
|
||||
return nil, fmt.Errorf("unexpected command %q", command)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return nil, errors.New("missing bitwarden CLI arguments")
|
||||
}
|
||||
|
||||
if len(args) == 1 && args[0] == "--version" {
|
||||
f.versionChecked = true
|
||||
return []byte("2026.1.0\n"), nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(args) == 4 && args[0] == "list" && args[1] == "items" && args[2] == "--search":
|
||||
return f.handleListItems(args[3])
|
||||
case len(args) == 3 && args[0] == "get" && args[1] == "item":
|
||||
return f.handleGetItem(args[2])
|
||||
case len(args) == 3 && args[0] == "get" && args[1] == "template" && args[2] == "item":
|
||||
return []byte(`{"type":2,"name":"","notes":"","secureNote":{"type":0},"fields":[]}`), nil
|
||||
case len(args) == 1 && args[0] == "encode":
|
||||
return []byte(base64.StdEncoding.EncodeToString(stdin)), nil
|
||||
case len(args) == 3 && args[0] == "create" && args[1] == "item":
|
||||
return f.handleCreateItem(args[2])
|
||||
case len(args) == 4 && args[0] == "edit" && args[1] == "item":
|
||||
return f.handleEditItem(args[2], args[3])
|
||||
case len(args) == 3 && args[0] == "delete" && args[1] == "item":
|
||||
delete(f.itemsByID, strings.TrimSpace(args[2]))
|
||||
return []byte(`{"success":true}`), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported bitwarden CLI invocation: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBitwardenCLI) handleListItems(search string) ([]byte, error) {
|
||||
needle := strings.TrimSpace(search)
|
||||
items := make([]map[string]any, 0)
|
||||
for _, item := range f.itemsByID {
|
||||
if !strings.Contains(item.Name, needle) {
|
||||
continue
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": item.ID,
|
||||
"name": item.Name,
|
||||
"notes": item.Notes,
|
||||
"fields": []map[string]any{
|
||||
{"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (f *fakeBitwardenCLI) handleGetItem(id string) ([]byte, error) {
|
||||
item, ok := f.itemsByID[strings.TrimSpace(id)]
|
||||
if !ok {
|
||||
return nil, errors.New("item not found")
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": item.ID,
|
||||
"name": item.Name,
|
||||
"notes": item.Notes,
|
||||
"fields": []map[string]any{
|
||||
{"name": bitwardenSecretFieldName, "value": item.Secret, "type": 1},
|
||||
},
|
||||
"secureNote": map[string]any{"type": 0},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (f *fakeBitwardenCLI) handleCreateItem(encoded string) ([]byte, error) {
|
||||
payload, err := decodeBitwardenPayload(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item := fakeBitwardenItem{
|
||||
ID: fmt.Sprintf("item-%d", f.nextID),
|
||||
Name: readString(payload, "name"),
|
||||
Notes: readString(payload, "notes"),
|
||||
Secret: readFakeBitwardenSecret(payload),
|
||||
}
|
||||
f.nextID++
|
||||
f.itemsByID[item.ID] = item
|
||||
|
||||
payload["id"] = item.ID
|
||||
encodedPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodedPayload, nil
|
||||
}
|
||||
|
||||
func (f *fakeBitwardenCLI) handleEditItem(id, encoded string) ([]byte, error) {
|
||||
trimmedID := strings.TrimSpace(id)
|
||||
if _, ok := f.itemsByID[trimmedID]; !ok {
|
||||
return nil, errors.New("item not found")
|
||||
}
|
||||
|
||||
payload, err := decodeBitwardenPayload(encoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item := fakeBitwardenItem{
|
||||
ID: trimmedID,
|
||||
Name: readString(payload, "name"),
|
||||
Notes: readString(payload, "notes"),
|
||||
Secret: readFakeBitwardenSecret(payload),
|
||||
}
|
||||
f.itemsByID[trimmedID] = item
|
||||
|
||||
payload["id"] = trimmedID
|
||||
encodedPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodedPayload, nil
|
||||
}
|
||||
|
||||
func decodeBitwardenPayload(encoded string) (map[string]any, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode encoded payload: %w", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, fmt.Errorf("decode payload JSON: %w", err)
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func readFakeBitwardenSecret(payload map[string]any) string {
|
||||
rawFields, ok := payload["fields"]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
fields, ok := rawFields.([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, rawField := range fields {
|
||||
field, ok := rawField.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(readString(field, "name"))
|
||||
if name != bitwardenSecretFieldName {
|
||||
continue
|
||||
}
|
||||
|
||||
return readString(field, "value")
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func readString(payload map[string]any, key string) string {
|
||||
value, _ := payload[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ const (
|
|||
BackendKWalletOnly BackendPolicy = "kwallet-only"
|
||||
BackendKeyringAny BackendPolicy = "keyring-any"
|
||||
BackendEnvOnly BackendPolicy = "env-only"
|
||||
BackendBitwardenCLI BackendPolicy = "bitwarden-cli"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
|
|
@ -32,6 +33,7 @@ type Options struct {
|
|||
LookupEnv func(string) (string, bool)
|
||||
KWalletAppID string
|
||||
KWalletFolder string
|
||||
BitwardenCommand string
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
|
|
@ -92,6 +94,10 @@ func Open(options Options) (Store, error) {
|
|||
return nil, errors.New("service name must not be empty")
|
||||
}
|
||||
|
||||
if policy == BackendBitwardenCLI {
|
||||
return newBitwardenStore(options, policy, serviceName)
|
||||
}
|
||||
|
||||
available := availableKeyringPolicy()
|
||||
allowed, err := allowedBackends(policy, available)
|
||||
if err != nil {
|
||||
|
|
@ -263,7 +269,7 @@ func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
|
|||
}
|
||||
|
||||
switch trimmed {
|
||||
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
|
||||
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI:
|
||||
return trimmed, nil
|
||||
default:
|
||||
return "", invalidBackendPolicyError(trimmed)
|
||||
|
|
|
|||
Loading…
Reference in a new issue