Files
vw-api/internal/vaultwarden/client.go
T
Thijs Herman 9f8ebf2408 feat: replace Bitwarden CLI with native Go API client (#2)
BREAKING: Removes Node.js/Bitwarden CLI dependency entirely.

## What changed
- New native HTTP client talks directly to Vaultwarden API
- Bitwarden-compatible crypto in pure Go:
  - AES-256-CBC + HMAC-SHA256 decryption
  - PBKDF2-SHA256 and Argon2id key derivation
  - HKDF key stretching (with legacy fallback)
- Auto token refresh (never expires)
- Background vault sync (configurable interval, default 5min)
- Docker image: ~233MB → ~15MB (pure Alpine, no Node.js)

## New env vars
- VAULTWARDEN_EMAIL (replaces CLIENT_ID/CLIENT_SECRET)
- SYNC_INTERVAL (default: 5m)

## Removed
- internal/vaultwarden/client_cli.go (CLI interaction)
- Bitwarden CLI dependency (Node.js + npm package)
- VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET env vars

## Added
- internal/vaultwarden/crypto.go (encryption/decryption)
- internal/vaultwarden/crypto_test.go (11 unit tests)
- internal/vaultwarden/api_client.go (HTTP client)

## Unchanged
- External API surface (GET /secret/:name, POST /refresh, GET /health)
- All security middleware (auth, IP whitelist, rate limiting)
2026-02-20 13:28:38 +01:00

166 lines
3.7 KiB
Go

// Package vaultwarden provides a production-ready client for retrieving secrets
// from Vaultwarden using native Go HTTP and crypto (no CLI dependency).
package vaultwarden
import (
"fmt"
"strings"
"sync"
"time"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
// Client manages vault access, caching, and background sync.
type Client struct {
api *APIClient
cacheTTL time.Duration
syncEvery time.Duration
mu sync.RWMutex
items map[string]DecryptedItem // keyed by lowercase name
byID map[string]DecryptedItem // keyed by ID
stopSync chan struct{}
}
// NewClient creates a new vault client backed by the native API client.
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration) *Client {
return &Client{
api: api,
cacheTTL: cacheTTL,
syncEvery: syncInterval,
items: make(map[string]DecryptedItem),
byID: make(map[string]DecryptedItem),
stopSync: make(chan struct{}),
}
}
// Initialize authenticates and performs the initial vault sync.
func (c *Client) Initialize() error {
if err := c.api.Authenticate(); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
if err := c.syncVault(); err != nil {
return fmt.Errorf("initial sync: %w", err)
}
// Start background sync.
go c.backgroundSync()
return nil
}
// GetSecret retrieves a decrypted secret by name.
// It searches by exact name (case-insensitive), then falls back to partial match.
func (c *Client) GetSecret(name string) (string, error) {
if name == "" {
return "", fmt.Errorf("secret name cannot be empty")
}
c.mu.RLock()
defer c.mu.RUnlock()
key := strings.ToLower(name)
// Exact match.
if item, ok := c.items[key]; ok {
return extractSecret(item), nil
}
// Partial match (search).
for _, item := range c.items {
if strings.Contains(strings.ToLower(item.Name), key) {
logger.Debug.Printf("Partial match found for secret lookup")
return extractSecret(item), nil
}
}
return "", fmt.Errorf("secret not found")
}
// ClearCache triggers a fresh vault sync.
func (c *Client) ClearCache() {
if err := c.syncVault(); err != nil {
logger.Error.Printf("Cache refresh sync failed: %v", err)
}
}
// Stop stops the background sync goroutine.
func (c *Client) Stop() {
close(c.stopSync)
}
// syncVault fetches and decrypts all items from the vault.
func (c *Client) syncVault() error {
items, err := c.api.Sync()
if err != nil {
return err
}
newItems := make(map[string]DecryptedItem, len(items))
newByID := make(map[string]DecryptedItem, len(items))
for _, item := range items {
key := strings.ToLower(item.Name)
newItems[key] = item
newByID[item.ID] = item
}
c.mu.Lock()
c.items = newItems
c.byID = newByID
c.mu.Unlock()
return nil
}
// backgroundSync periodically syncs the vault to pick up changes.
func (c *Client) backgroundSync() {
ticker := time.NewTicker(c.syncEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if err := c.syncVault(); err != nil {
logger.Warn.Printf("Background sync failed: %v", err)
} else {
logger.Debug.Println("Background vault sync completed")
}
case <-c.stopSync:
logger.Info.Println("Background sync stopped")
return
}
}
}
// extractSecret extracts the most relevant secret value from a decrypted item.
// Priority: password > field named "value"/"secret"/"api_key" > notes > first field.
func extractSecret(item DecryptedItem) string {
if item.Password != "" {
return item.Password
}
// Check custom fields by priority.
for _, name := range []string{"value", "secret", "api_key", "apikey", "token"} {
if v, ok := item.Fields[name]; ok && v != "" {
return v
}
}
if item.Notes != "" {
return item.Notes
}
// Return first non-empty field value.
for _, v := range item.Fields {
if v != "" {
return v
}
}
return ""
}