Files
vw-api/internal/vaultwarden/client.go
T
Hermes 51a71739c9
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled
feat: add /search?q= endpoint — substring search across all vault fields
2026-08-08 02:03:49 +02:00

538 lines
14 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 (
"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 ""
}