// Package vaultwarden provides a production-ready client for retrieving secrets // from Vaultwarden using native Go HTTP and crypto (no CLI dependency). package vaultwarden import ( "encoding/json" "fmt" "maps" "strings" "sync" "time" "github.com/Turbootzz/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 cipher id // nameMaps from the last successful sync (for resolving filter names to UUIDs). nameMaps SyncNameMaps stopSync chan struct{} } // ClientOption configures NewClient. type ClientOption func(*Client) // WithState preloads decrypted items and name maps (e.g. unit tests with api set to nil). func WithState(items map[string]DecryptedItem, nameMaps SyncNameMaps) ClientOption { return func(c *Client) { if items != nil { c.items = items } c.nameMaps = nameMaps } } // NewClient creates a vault client. Pass WithState to preload cache data without calling Initialize. func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration, opts ...ClientOption) *Client { c := &Client{ api: api, cacheTTL: cacheTTL, syncEvery: syncInterval, items: make(map[string]DecryptedItem), nameMaps: emptySyncNameMaps(), stopSync: make(chan struct{}), } for _, opt := range opts { opt(c) } return c } // 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 } // SecretFilter limits lookup by vault placement. Empty fields are ignored (no constraint). // // The singular fields are client-supplied query filters (use at most one of id vs // name per dimension, enforced at the HTTP layer). The plural fields are server-set // from the authenticated key's scope and act as a hard boundary: a request must match // both the client filter and the scope, so a client filter can only narrow within the // scope, never widen beyond it. Empty plural slices impose no constraint. type SecretFilter struct { OrganizationID string CollectionID string FolderID string OrganizationIDs []string CollectionIDs []string } func containsFold(ids []string, target string) bool { for _, id := range ids { if strings.EqualFold(id, target) { return true } } return false } func intersectsFold(a, b []string) bool { for _, id := range a { if containsFold(b, id) { return true } } return false } func matchesSecretFilter(item DecryptedItem, f SecretFilter) bool { if f.OrganizationID != "" && !strings.EqualFold(item.OrganizationID, f.OrganizationID) { return false } if f.CollectionID != "" && !containsFold(item.CollectionIDs, f.CollectionID) { return false } if f.FolderID != "" && !strings.EqualFold(item.FolderID, f.FolderID) { return false } if len(f.OrganizationIDs) > 0 && !containsFold(f.OrganizationIDs, item.OrganizationID) { return false } if len(f.CollectionIDs) > 0 && !intersectsFold(item.CollectionIDs, f.CollectionIDs) { return false } return true } // GetSecret retrieves a decrypted secret by name and returns the full item // (all fields) so the caller can see username/uri/notes/custom fields, not // just the primary value. Searches by exact name (case-insensitive), then // falls back to partial match. func (c *Client) GetSecret(name string, filter SecretFilter) (DecryptedItem, error) { item, err := c.getSecretItem(name, filter) if err != nil { return DecryptedItem{}, err } return item, nil } // getSecretItem finds the matching decrypted item without extracting a single value. func (c *Client) getSecretItem(name string, filter SecretFilter) (DecryptedItem, error) { if name == "" { return DecryptedItem{}, fmt.Errorf("secret name cannot be empty") } c.mu.RLock() defer c.mu.RUnlock() key := strings.ToLower(name) candidates := make([]DecryptedItem, 0, len(c.items)) for _, item := range c.items { if matchesSecretFilter(item, filter) { candidates = append(candidates, item) } } // Case 1: Exact match. for _, item := range candidates { if strings.EqualFold(item.Name, name) { return item, nil } } // Case 2: Partial match for _, item := range candidates { if strings.Contains(strings.ToLower(item.Name), key) { logger.Debug.Printf("Partial match found for secret lookup") return item, nil } } return DecryptedItem{}, fmt.Errorf("secret not found") } // ToMap renders the decrypted item as a flat map for JSON output. // The "value" key holds the same primary value extractSecret() would return, // while the other keys expose every decrypted field. func (item DecryptedItem) ToMap() map[string]interface{} { out := map[string]interface{}{ "name": item.Name, "type": item.Type, "username": item.Username, "password": item.Password, "uri": item.URI, "notes": item.Notes, "fields": item.Fields, } out["value"] = extractSecret(item) return out } // 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) } // NameMaps returns a copy of decrypted organization, folder, and collection names // from the last successful vault sync. func (c *Client) NameMaps() SyncNameMaps { c.mu.RLock() defer c.mu.RUnlock() return SyncNameMaps{ Organizations: maps.Clone(c.nameMaps.Organizations), Folders: maps.Clone(c.nameMaps.Folders), Collections: maps.Clone(c.nameMaps.Collections), } } // syncVault fetches and decrypts all items from the vault. func (c *Client) syncVault() error { items, nameMaps, err := c.api.Sync() if err != nil { return err } newItems := make(map[string]DecryptedItem, len(items)) for _, item := range items { if item.ID == "" { continue } newItems[item.ID] = item } c.mu.Lock() c.items = newItems c.nameMaps = nameMaps 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. // For Login: password > field named "value"/"secret"/"api_key" > notes > first field. // For SecureNote: notes (the whole note body). // For Card / Identity: a compact JSON of all decrypted fields. func extractSecret(item DecryptedItem) string { switch item.Type { case CipherTypeSecureNote: if item.Notes != "" { return item.Notes } case CipherTypeCard, CipherTypeIdentity: if len(item.Fields) > 0 { b, err := json.Marshal(item.Fields) if err == nil { return string(b) } } } 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 "" }