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_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
|
||||||
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
|
||||||
- `[environment].known` : variables d'environnement connues du projet.
|
- `[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].default` : profil recommandé par défaut.
|
||||||
- `[profiles].known` : profils connus du projet.
|
- `[profiles].known` : profils connus du projet.
|
||||||
- `[bootstrap].description` : description CLI utilisée par le bootstrap.
|
- `[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
|
- `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
|
- `keyring-any` : impose l'utilisation d'un backend keyring disponible
|
||||||
- `env-only` : lecture seule depuis les variables d'environnement
|
- `env-only` : lecture seule depuis les variables d'environnement
|
||||||
|
- `bitwarden-cli` : utilise le CLI Bitwarden (`bw`) comme backend de vault
|
||||||
|
|
||||||
Backends keyring typiques :
|
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 :
|
Pour stocker un secret structuré en JSON :
|
||||||
|
|
||||||
```go
|
```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)
|
||||||
|
}
|
||||||
|
|
@ -20,18 +20,20 @@ var ErrInvalidBackendPolicy = errors.New("invalid secret backend policy")
|
||||||
type BackendPolicy string
|
type BackendPolicy string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
BackendAuto BackendPolicy = "auto"
|
BackendAuto BackendPolicy = "auto"
|
||||||
BackendKWalletOnly BackendPolicy = "kwallet-only"
|
BackendKWalletOnly BackendPolicy = "kwallet-only"
|
||||||
BackendKeyringAny BackendPolicy = "keyring-any"
|
BackendKeyringAny BackendPolicy = "keyring-any"
|
||||||
BackendEnvOnly BackendPolicy = "env-only"
|
BackendEnvOnly BackendPolicy = "env-only"
|
||||||
|
BackendBitwardenCLI BackendPolicy = "bitwarden-cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
ServiceName string
|
ServiceName string
|
||||||
BackendPolicy BackendPolicy
|
BackendPolicy BackendPolicy
|
||||||
LookupEnv func(string) (string, bool)
|
LookupEnv func(string) (string, bool)
|
||||||
KWalletAppID string
|
KWalletAppID string
|
||||||
KWalletFolder string
|
KWalletFolder string
|
||||||
|
BitwardenCommand string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
|
@ -92,6 +94,10 @@ func Open(options Options) (Store, error) {
|
||||||
return nil, errors.New("service name must not be empty")
|
return nil, errors.New("service name must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if policy == BackendBitwardenCLI {
|
||||||
|
return newBitwardenStore(options, policy, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
available := availableKeyringPolicy()
|
available := availableKeyringPolicy()
|
||||||
allowed, err := allowedBackends(policy, available)
|
allowed, err := allowedBackends(policy, available)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -263,7 +269,7 @@ func normalizeBackendPolicy(policy BackendPolicy) (BackendPolicy, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch trimmed {
|
switch trimmed {
|
||||||
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly:
|
case BackendAuto, BackendKWalletOnly, BackendKeyringAny, BackendEnvOnly, BackendBitwardenCLI:
|
||||||
return trimmed, nil
|
return trimmed, nil
|
||||||
default:
|
default:
|
||||||
return "", invalidBackendPolicyError(trimmed)
|
return "", invalidBackendPolicyError(trimmed)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue