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