// 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") } // SearchSecrets returns every decrypted item whose name, username, uri, notes, // password, or any custom field contains the query string (case-insensitive). // It is a substring search across ALL fields — unlike GetSecret which is limited // to the item name. Values are masked so the list can be returned safely. func (c *Client) SearchSecrets(query string) []map[string]interface{} { q := strings.ToLower(strings.TrimSpace(query)) if q == "" { return []map[string]interface{}{} } c.mu.RLock() defer c.mu.RUnlock() results := make([]map[string]interface{}, 0) for _, item := range c.items { if matchesAllFields(item, q) { m := item.ToMap() // Mask sensitive values in the listing. if v, ok := m["password"].(string); ok && v != "" { m["password"] = maskSecret(v) } if v, ok := m["value"].(string); ok && v != "" { m["value"] = maskSecret(v) } if v, ok := m["username"].(string); ok && v != "" { m["username"] = maskSecret(v) } results = append(results, m) } } return results } // matchesAllFields checks the query against every decryptable field of an item. func matchesAllFields(item DecryptedItem, q string) bool { if strings.Contains(strings.ToLower(item.Name), q) { return true } if strings.Contains(strings.ToLower(item.Username), q) { return true } if strings.Contains(strings.ToLower(item.URI), q) { return true } if strings.Contains(strings.ToLower(item.Notes), q) { return true } if strings.Contains(strings.ToLower(item.Password), q) { return true } for k, v := range item.Fields { if strings.Contains(strings.ToLower(k), q) || strings.Contains(strings.ToLower(v), q) { return true } } return false } // maskSecret reveals only the first 2 and last 2 characters of a value. func maskSecret(v string) string { r := []rune(v) if len(r) <= 4 { return "***" } return string(r[:2]) + "***" + string(r[len(r)-2:]) } // 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) } } // CreateSecretInput is the public input for writing a new secret. type CreateSecretInput struct { Type int // 1=Login, 2=SecureNote, 3=Card, 4=Identity Name string Notes string Username string Password string URI string Fields map[string]string FolderID string // optional folder UUID // Card (type 3) CardholderName string Brand string Number string ExpMonth string ExpYear string Code string // Identity (type 4) Title string FirstName string MiddleName string LastName string Address1 string Address2 string Address3 string City string State string PostalCode string Country string Company string Email string Phone string SSN string Passport string License string } // CreateSecret writes a new vault item and refreshes the local cache so it is // immediately readable via GetSecret. func (c *Client) CreateSecret(in CreateSecretInput) (string, error) { if c.api == nil { return "", fmt.Errorf("vault API client not configured") } id, err := c.api.CreateCipher(CipherInput{ Type: in.Type, Name: in.Name, Notes: in.Notes, Username: in.Username, Password: in.Password, URI: in.URI, Fields: in.Fields, FolderID: in.FolderID, CardholderName: in.CardholderName, Brand: in.Brand, Number: in.Number, ExpMonth: in.ExpMonth, ExpYear: in.ExpYear, Code: in.Code, Title: in.Title, FirstName: in.FirstName, MiddleName: in.MiddleName, LastName: in.LastName, Address1: in.Address1, Address2: in.Address2, Address3: in.Address3, City: in.City, State: in.State, PostalCode: in.PostalCode, Country: in.Country, Company: in.Company, Email: in.Email, Phone: in.Phone, SSN: in.SSN, Passport: in.Passport, License: in.License, }) if err != nil { return "", err } // Refresh cache so the new secret is immediately visible. c.ClearCache() return id, nil } // GetSecretID resolves a cipher ID from its name (case-insensitive exact/partial match). func (c *Client) GetSecretID(name string) (string, error) { item, err := c.getSecretItem(name, SecretFilter{}) if err != nil { return "", err } return item.ID, nil } // UpdateSecret edits an existing secret identified by name. It resolves the // cipher ID, then sends a PUT with the supplied fields. Empty fields in the // input are NOT merged with existing values — callers should pass the full // desired state (or fetch-then-merge if partial updates are needed). func (c *Client) UpdateSecret(name string, in CreateSecretInput) error { if c.api == nil { return fmt.Errorf("vault API client not configured") } id, err := c.GetSecretID(name) if err != nil { return err } if err := c.api.UpdateCipher(id, CipherInput{ Type: in.Type, Name: in.Name, Notes: in.Notes, Username: in.Username, Password: in.Password, URI: in.URI, Fields: in.Fields, FolderID: in.FolderID, CardholderName: in.CardholderName, Brand: in.Brand, Number: in.Number, ExpMonth: in.ExpMonth, ExpYear: in.ExpYear, Code: in.Code, Title: in.Title, FirstName: in.FirstName, MiddleName: in.MiddleName, LastName: in.LastName, Address1: in.Address1, Address2: in.Address2, Address3: in.Address3, City: in.City, State: in.State, PostalCode: in.PostalCode, Country: in.Country, Company: in.Company, Email: in.Email, Phone: in.Phone, SSN: in.SSN, Passport: in.Passport, License: in.License, }); err != nil { return err } c.ClearCache() return nil } // DeleteSecret removes a secret identified by name. func (c *Client) DeleteSecret(name string) error { if c.api == nil { return fmt.Errorf("vault API client not configured") } id, err := c.GetSecretID(name) if err != nil { return err } if err := c.api.DeleteCipher(id); err != nil { return err } c.ClearCache() return nil } // 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), } } // ListFolders returns decrypted folder name -> id mappings from the last sync. func (c *Client) ListFolders() map[string]string { c.mu.RLock() defer c.mu.RUnlock() return maps.Clone(c.nameMaps.Folders) } // 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 "" }