Files
vw-api/internal/vaultwarden/client.go
T
Hermes 0ede8f145d
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled
feat: write Card (3) and Identity (4) ciphers
Add card/identity sub-objects (camelCase serde per Vaultwarden API).
brand/expMonth/expYear sent plaintext; other fields encrypted.
Verified end-to-end: create + read decrypt correctly.
2026-08-02 19:12:57 +02:00

385 lines
9.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 (
"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)
}
}
// 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
// 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,
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
}
// 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 ""
}