feat: replace Bitwarden CLI with native Go API client (#2)

BREAKING: Removes Node.js/Bitwarden CLI dependency entirely.

## What changed
- New native HTTP client talks directly to Vaultwarden API
- Bitwarden-compatible crypto in pure Go:
  - AES-256-CBC + HMAC-SHA256 decryption
  - PBKDF2-SHA256 and Argon2id key derivation
  - HKDF key stretching (with legacy fallback)
- Auto token refresh (never expires)
- Background vault sync (configurable interval, default 5min)
- Docker image: ~233MB → ~15MB (pure Alpine, no Node.js)

## New env vars
- VAULTWARDEN_EMAIL (replaces CLIENT_ID/CLIENT_SECRET)
- SYNC_INTERVAL (default: 5m)

## Removed
- internal/vaultwarden/client_cli.go (CLI interaction)
- Bitwarden CLI dependency (Node.js + npm package)
- VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET env vars

## Added
- internal/vaultwarden/crypto.go (encryption/decryption)
- internal/vaultwarden/crypto_test.go (11 unit tests)
- internal/vaultwarden/api_client.go (HTTP client)

## Unchanged
- External API surface (GET /secret/:name, POST /refresh, GET /health)
- All security middleware (auth, IP whitelist, rate limiting)
This commit is contained in:
Thijs Herman authored and GitHub committed 2026-02-20 13:28:38 +01:00
1 parent e320f95d9f
commit 9f8ebf2408
14 files changed
+1527 -964

No files matched your search

+8 -9
View File
@@ -1,4 +1,4 @@
// Package handlers provides HTTP request handlers for the API
// Package handlers provides HTTP request handlers for the API.
package handlers
import (
@@ -8,27 +8,27 @@ import (
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
// Handler contains all HTTP handlers
// Handler contains all HTTP handlers.
type Handler struct {
vaultClient *vaultwarden.Client
}
// NewHandler creates a new handler instance
// NewHandler creates a new handler instance.
func NewHandler(vaultClient *vaultwarden.Client) *Handler {
return &Handler{
vaultClient: vaultClient,
}
}
// HealthCheck handles GET /health
// HealthCheck handles GET /health.
func (h *Handler) HealthCheck(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"status": "ok",
"service": "vaultwarden-api",
})
}
// GetSecret handles GET /secret/:name
// GetSecret handles GET /secret/:name.
func (h *Handler) GetSecret(c *fiber.Ctx) error {
secretName := c.Params("name")
@@ -48,20 +48,19 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
value, err := h.vaultClient.GetSecret(secretName)
if err != nil {
logger.Error.Printf("Failed to fetch secret (requested by IP: %s): %v", c.IP(), err)
logger.Error.Printf("Failed to fetch secret (requested by IP: %s)", c.IP())
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": "secret not found",
})
}
// Return the secret value
return c.JSON(fiber.Map{
"name": secretName,
"value": value,
})
}
// RefreshCache handles POST /refresh
// RefreshCache handles POST /refresh.
func (h *Handler) RefreshCache(c *fiber.Ctx) error {
h.vaultClient.ClearCache()
+495
View File
@@ -0,0 +1,495 @@
package vaultwarden
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
// PreloginResponse contains the KDF parameters from the server.
type PreloginResponse struct {
KDF int `json:"kdf"`
KDFIterations int `json:"kdfIterations"`
KDFMemory *int `json:"kdfMemory"`
KDFParallelism *int `json:"kdfParallelism"`
}
// TokenResponse is returned by the /identity/connect/token endpoint.
type TokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
Key string `json:"Key"`
PrivateKey string `json:"PrivateKey"`
}
// SyncResponse contains the full vault sync data.
type SyncResponse struct {
Profile SyncProfile `json:"profile"`
Ciphers []SyncCipher `json:"ciphers"`
}
// SyncProfile contains user profile info.
type SyncProfile struct {
ID string `json:"id"`
Email string `json:"email"`
Key string `json:"key"`
}
// SyncCipher represents an encrypted vault item from the sync response.
type SyncCipher struct {
ID string `json:"id"`
Type int `json:"type"`
Name string `json:"name"`
Notes *string `json:"notes"`
Login *SyncLogin `json:"login"`
Card *SyncCard `json:"card"`
Fields []SyncField `json:"fields"`
}
// SyncLogin contains encrypted login data.
type SyncLogin struct {
Username *string `json:"username"`
Password *string `json:"password"`
URI *string `json:"uri"`
URIs []struct {
URI *string `json:"uri"`
} `json:"uris"`
}
// SyncCard contains encrypted card data.
type SyncCard struct {
CardholderName *string `json:"cardholderName"`
Number *string `json:"number"`
Code *string `json:"code"`
}
// SyncField contains encrypted custom field data.
type SyncField struct {
Name *string `json:"name"`
Value *string `json:"value"`
Type int `json:"type"`
}
// Bitwarden cipher types.
const (
CipherTypeLogin = 1
CipherTypeSecureNote = 2
CipherTypeCard = 3
CipherTypeIdentity = 4
)
// APIClient communicates directly with the Vaultwarden HTTP API.
type APIClient struct {
baseURL string
email string
password string
clientID string // Optional: for API key login (bypasses 2FA)
clientSecret string // Optional: for API key login (bypasses 2FA)
httpClient *http.Client
deviceID string
mu sync.RWMutex
accessToken string
refreshToken string
tokenExpiry time.Time
symKey SymmetricKey
}
// NewAPIClient creates a new Vaultwarden API client.
// clientID and clientSecret are optional — if provided, API key login is used (bypasses 2FA).
func NewAPIClient(baseURL, email, password, clientID, clientSecret string) *APIClient {
return &APIClient{
baseURL: strings.TrimSuffix(baseURL, "/"),
email: email,
password: password,
clientID: clientID,
clientSecret: clientSecret,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
deviceID: uuid.New().String(),
}
}
// Authenticate performs the full login flow.
// If API key credentials are set, uses client_credentials grant (bypasses 2FA).
// Otherwise, uses password grant (requires 2FA to be disabled or handled).
func (ac *APIClient) Authenticate() error {
// Step 1: Get KDF parameters.
prelogin, err := ac.prelogin()
if err != nil {
return fmt.Errorf("prelogin: %w", err)
}
logger.Info.Printf("KDF type: %d, iterations: %d", prelogin.KDF, prelogin.KDFIterations)
// Step 2: Derive master key (always needed for decryption).
masterKey, err := MakeMasterKey(ac.password, ac.email, prelogin.KDF, prelogin.KDFIterations, prelogin.KDFMemory, prelogin.KDFParallelism)
if err != nil {
return fmt.Errorf("derive master key: %w", err)
}
// Step 3: Login.
var tokenResp *TokenResponse
if ac.clientID != "" && ac.clientSecret != "" {
// API key login — bypasses 2FA.
logger.Info.Println("Using API key authentication (2FA bypass)")
tokenResp, err = ac.loginWithAPIKey()
} else {
// Password login — requires no 2FA or 2FA handling.
hashedPassword := HashPassword(ac.password, masterKey)
tokenResp, err = ac.loginWithPassword(hashedPassword)
}
if err != nil {
return fmt.Errorf("login: %w", err)
}
// Step 4: Get the encrypted symmetric key.
// API key login doesn't return the Key in the token response,
// so we get it from the sync/profile endpoint.
encryptedKey := tokenResp.Key
if encryptedKey == "" {
// Fetch from sync profile.
ac.mu.Lock()
ac.accessToken = tokenResp.AccessToken
ac.refreshToken = tokenResp.RefreshToken
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
ac.mu.Unlock()
encryptedKey, err = ac.fetchProfileKey()
if err != nil {
return fmt.Errorf("fetch profile key: %w", err)
}
} else {
ac.mu.Lock()
ac.accessToken = tokenResp.AccessToken
ac.refreshToken = tokenResp.RefreshToken
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
ac.mu.Unlock()
}
// Step 5: Decrypt the symmetric key.
symKey, err := DecryptSymmetricKey(encryptedKey, masterKey)
if err != nil {
return fmt.Errorf("decrypt symmetric key: %w", err)
}
ac.mu.Lock()
ac.symKey = symKey
ac.mu.Unlock()
logger.Info.Println("Authentication successful")
return nil
}
// RefreshAccessToken uses the refresh token to get a new access token.
func (ac *APIClient) RefreshAccessToken() error {
ac.mu.RLock()
rt := ac.refreshToken
ac.mu.RUnlock()
if rt == "" {
return fmt.Errorf("no refresh token available, re-authentication required")
}
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {rt},
"client_id": {"web"},
}
resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data)
if err != nil {
return fmt.Errorf("refresh request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return fmt.Errorf("decode refresh response: %w", err)
}
ac.mu.Lock()
ac.accessToken = tokenResp.AccessToken
if tokenResp.RefreshToken != "" {
ac.refreshToken = tokenResp.RefreshToken
}
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
ac.mu.Unlock()
logger.Debug.Println("Token refreshed successfully")
return nil
}
// EnsureValidToken refreshes the access token if it's expired or about to expire.
func (ac *APIClient) EnsureValidToken() error {
ac.mu.RLock()
expiry := ac.tokenExpiry
ac.mu.RUnlock()
// Refresh 60 seconds before actual expiry.
if time.Now().After(expiry.Add(-60 * time.Second)) {
logger.Debug.Println("Token expiring soon, refreshing...")
if err := ac.RefreshAccessToken(); err != nil {
// If refresh fails, try full re-authentication.
logger.Warn.Println("Token refresh failed, attempting full re-authentication")
return ac.Authenticate()
}
}
return nil
}
// Sync fetches and decrypts all vault items.
func (ac *APIClient) Sync() ([]DecryptedItem, error) {
if err := ac.EnsureValidToken(); err != nil {
return nil, fmt.Errorf("ensure valid token: %w", err)
}
ac.mu.RLock()
token := ac.accessToken
key := ac.symKey
ac.mu.RUnlock()
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
if err != nil {
return nil, fmt.Errorf("create sync request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ac.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sync request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
// Token might be invalid, try to refresh and retry once.
if err := ac.RefreshAccessToken(); err != nil {
return nil, fmt.Errorf("sync auth failed, refresh failed: %w", err)
}
ac.mu.RLock()
token = ac.accessToken
ac.mu.RUnlock()
req.Header.Set("Authorization", "Bearer "+token)
resp, err = ac.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("sync retry: %w", err)
}
defer resp.Body.Close()
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var syncResp SyncResponse
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
return nil, fmt.Errorf("decode sync response: %w", err)
}
// Decrypt all ciphers.
items := make([]DecryptedItem, 0, len(syncResp.Ciphers))
for _, c := range syncResp.Ciphers {
item, err := decryptCipher(c, key)
if err != nil {
logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err)
continue
}
items = append(items, item)
}
logger.Info.Printf("Synced and decrypted %d vault items", len(items))
return items, nil
}
// DecryptedItem is a decrypted vault item ready for cache lookup.
type DecryptedItem struct {
ID string
Type int
Name string
Username string
Password string
Notes string
URI string
Fields map[string]string
}
// decryptCipher decrypts a single vault cipher into a DecryptedItem.
func decryptCipher(c SyncCipher, key SymmetricKey) (DecryptedItem, error) {
item := DecryptedItem{
ID: c.ID,
Type: c.Type,
Fields: make(map[string]string),
}
var err error
item.Name, err = DecryptStr(c.Name, key)
if err != nil {
return item, fmt.Errorf("decrypt name: %w", err)
}
if c.Notes != nil {
item.Notes, _ = DecryptStr(*c.Notes, key)
}
if c.Login != nil {
if c.Login.Username != nil {
item.Username, _ = DecryptStr(*c.Login.Username, key)
}
if c.Login.Password != nil {
item.Password, _ = DecryptStr(*c.Login.Password, key)
}
if c.Login.URI != nil {
item.URI, _ = DecryptStr(*c.Login.URI, key)
}
if item.URI == "" && len(c.Login.URIs) > 0 && c.Login.URIs[0].URI != nil {
item.URI, _ = DecryptStr(*c.Login.URIs[0].URI, key)
}
}
for _, f := range c.Fields {
var name, value string
if f.Name != nil {
name, _ = DecryptStr(*f.Name, key)
}
if f.Value != nil {
value, _ = DecryptStr(*f.Value, key)
}
if name != "" {
item.Fields[name] = value
}
}
return item, nil
}
// prelogin fetches KDF parameters for the given email.
func (ac *APIClient) prelogin() (*PreloginResponse, error) {
body := fmt.Sprintf(`{"email":"%s"}`, ac.email)
resp, err := ac.httpClient.Post(
ac.baseURL+"/identity/accounts/prelogin",
"application/json",
strings.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("prelogin request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("prelogin failed (HTTP %d): %s", resp.StatusCode, string(respBody))
}
var result PreloginResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode prelogin: %w", err)
}
return &result, nil
}
// loginWithPassword authenticates with email + hashed password (requires no 2FA or 2FA handling).
func (ac *APIClient) loginWithPassword(hashedPassword string) (*TokenResponse, error) {
data := url.Values{
"grant_type": {"password"},
"username": {ac.email},
"password": {hashedPassword},
"scope": {"api offline_access"},
"client_id": {"web"},
"deviceType": {"10"},
"deviceIdentifier": {ac.deviceID},
"deviceName": {"vaultwarden-api"},
}
return ac.doTokenRequest(data)
}
// loginWithAPIKey authenticates with API key (client_credentials). Bypasses 2FA.
func (ac *APIClient) loginWithAPIKey() (*TokenResponse, error) {
data := url.Values{
"grant_type": {"client_credentials"},
"client_id": {ac.clientID},
"client_secret": {ac.clientSecret},
"scope": {"api"},
"deviceType": {"10"},
"deviceIdentifier": {ac.deviceID},
"deviceName": {"vaultwarden-api"},
}
return ac.doTokenRequest(data)
}
// doTokenRequest sends a token request and parses the response.
func (ac *APIClient) doTokenRequest(data url.Values) (*TokenResponse, error) {
resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data)
if err != nil {
return nil, fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, fmt.Errorf("decode login response: %w", err)
}
return &tokenResp, nil
}
// fetchProfileKey gets the encrypted symmetric key from the user's profile.
// Used when API key login doesn't return the Key in the token response.
func (ac *APIClient) fetchProfileKey() (string, error) {
ac.mu.RLock()
token := ac.accessToken
ac.mu.RUnlock()
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
if err != nil {
return "", fmt.Errorf("create sync request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := ac.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("sync request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
}
var syncResp SyncResponse
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
return "", fmt.Errorf("decode sync: %w", err)
}
if syncResp.Profile.Key == "" {
return "", fmt.Errorf("profile key is empty")
}
return syncResp.Profile.Key, nil
}
+124 -111
View File
@@ -1,152 +1,165 @@
// Package vaultwarden provides a client for interacting with Vaultwarden via CLI
// Package vaultwarden provides a production-ready client for retrieving secrets
// from Vaultwarden using native Go HTTP and crypto (no CLI dependency).
package vaultwarden
import (
"fmt"
"strings"
"sync"
"time"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
// Client handles communication with Vaultwarden via CLI
// Client manages vault access, caching, and background sync.
type Client struct {
baseURL string
token string // Session token from CLI authentication
cache *secretCache
api *APIClient
cacheTTL time.Duration
syncEvery time.Duration
mu sync.RWMutex
items map[string]DecryptedItem // keyed by lowercase name
byID map[string]DecryptedItem // keyed by ID
stopSync chan struct{}
}
// secretCache provides a simple in-memory cache with TTL
type secretCache struct {
mu sync.RWMutex
items map[string]*cacheItem
ttl time.Duration
enabled bool
}
type cacheItem struct {
value string
expiresAt time.Time
}
// NewClient creates a new Vaultwarden client with CLI session token
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
cache := &secretCache{
items: make(map[string]*cacheItem),
ttl: cacheTTL,
enabled: cacheTTL > 0,
}
if cacheTTL > 0 {
go cache.startCleanup(cacheTTL)
}
// NewClient creates a new vault client backed by the native API client.
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration) *Client {
return &Client{
baseURL: baseURL,
token: token,
cache: cache,
api: api,
cacheTTL: cacheTTL,
syncEvery: syncInterval,
items: make(map[string]DecryptedItem),
byID: make(map[string]DecryptedItem),
stopSync: make(chan struct{}),
}
}
// GetSecret retrieves a secret by name from Vaultwarden
// It first checks the cache, then queries the API if needed
// 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
}
// GetSecret retrieves a decrypted secret by name.
// It searches by exact name (case-insensitive), then falls back to partial match.
func (c *Client) GetSecret(name string) (string, error) {
// Validate input
if name == "" {
return "", fmt.Errorf("secret name cannot be empty")
}
// Check cache first
if c.cache.enabled {
if value, found := c.cache.get(name); found {
logger.Debug.Printf("Cache hit for secret")
return value, nil
c.mu.RLock()
defer c.mu.RUnlock()
key := strings.ToLower(name)
// Exact match.
if item, ok := c.items[key]; ok {
return extractSecret(item), nil
}
// Partial match (search).
for _, item := range c.items {
if strings.Contains(strings.ToLower(item.Name), key) {
logger.Debug.Printf("Partial match found for secret lookup")
return extractSecret(item), nil
}
}
// Cache miss - fetch from API
logger.Debug.Printf("Fetching secret from Vaultwarden (cache miss)")
value, err := c.fetchSecret(name)
if err != nil {
return "", err
}
// Store in cache
if c.cache.enabled {
c.cache.set(name, value)
}
return value, nil
return "", fmt.Errorf("secret not found")
}
// fetchSecret queries the Vaultwarden for a secret via CLI
func (c *Client) fetchSecret(name string) (string, error) {
return c.FetchSecretViaCLI(name)
}
// ClearCache clears all cached secrets
// ClearCache triggers a fresh vault sync.
func (c *Client) ClearCache() {
if c.cache.enabled {
c.cache.clear()
logger.Info.Println("Cache cleared")
if err := c.syncVault(); err != nil {
logger.Error.Printf("Cache refresh sync failed: %v", err)
}
}
// Cache methods
func (sc *secretCache) get(key string) (string, bool) {
sc.mu.RLock()
defer sc.mu.RUnlock()
item, found := sc.items[key]
if !found {
return "", false
}
// Check if expired
if time.Now().After(item.expiresAt) {
return "", false
}
return item.value, true
// Stop stops the background sync goroutine.
func (c *Client) Stop() {
close(c.stopSync)
}
func (sc *secretCache) set(key, value string) {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.items[key] = &cacheItem{
value: value,
expiresAt: time.Now().Add(sc.ttl),
// syncVault fetches and decrypts all items from the vault.
func (c *Client) syncVault() error {
items, err := c.api.Sync()
if err != nil {
return err
}
newItems := make(map[string]DecryptedItem, len(items))
newByID := make(map[string]DecryptedItem, len(items))
for _, item := range items {
key := strings.ToLower(item.Name)
newItems[key] = item
newByID[item.ID] = item
}
c.mu.Lock()
c.items = newItems
c.byID = newByID
c.mu.Unlock()
return nil
}
func (sc *secretCache) clear() {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.items = make(map[string]*cacheItem)
}
// startCleanup runs a background goroutine to periodically remove expired cache entries
// This prevents memory leaks from accumulating expired items
func (sc *secretCache) startCleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
// backgroundSync periodically syncs the vault to pick up changes.
func (c *Client) backgroundSync() {
ticker := time.NewTicker(c.syncEvery)
defer ticker.Stop()
for range ticker.C {
sc.removeExpired()
}
}
// removeExpired removes all expired items from the cache
func (sc *secretCache) removeExpired() {
sc.mu.Lock()
defer sc.mu.Unlock()
now := time.Now()
for key, item := range sc.items {
if now.After(item.expiresAt) {
delete(sc.items, key)
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.
// Priority: password > field named "value"/"secret"/"api_key" > notes > first field.
func extractSecret(item DecryptedItem) string {
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 ""
}
-116
View File
@@ -1,116 +0,0 @@
// Package vaultwarden provides CLI-based secret retrieval
package vaultwarden
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
type BitwardenItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Login *struct {
Username string `json:"username"`
Password string `json:"password"`
} `json:"login,omitempty"`
Notes string `json:"notes,omitempty"`
Fields []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"fields,omitempty"`
}
func (c *Client) FetchSecretViaCLI(name string) (string, error) {
if len(name) == 0 || len(name) > 255 {
return "", fmt.Errorf("invalid secret name length")
}
for _, ch := range name {
if !((ch >= 'a' && ch <= 'z') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') ||
ch == '-' || ch == '_' || ch == '.' || ch == '/') {
return "", fmt.Errorf("invalid character in secret name")
}
}
cmd := exec.Command("bw", "get", "item", name)
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
output, err := cmd.Output()
if err != nil {
logger.Info.Printf("Exact match failed, searching for: %s", name)
searchCmd := exec.Command("bw", "list", "items", "--search", name)
searchCmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
searchOutput, searchErr := searchCmd.Output()
if searchErr != nil {
return "", fmt.Errorf("failed to search for item: %w", searchErr)
}
var items []BitwardenItem
if err := json.Unmarshal(searchOutput, &items); err != nil {
return "", fmt.Errorf("failed to parse search results: %w", err)
}
if len(items) == 0 {
return "", fmt.Errorf("secret not found: %s", name)
}
if len(items) > 1 {
logger.Warn.Printf("Multiple items found for '%s', using first match: '%s' (ID: %s)", name, items[0].Name, items[0].ID)
}
output, err = json.Marshal(items[0])
if err != nil {
return "", fmt.Errorf("failed to marshal item: %w", err)
}
}
var item BitwardenItem
if err := json.Unmarshal(output, &item); err != nil {
return "", fmt.Errorf("failed to parse item: %w", err)
}
return c.extractValueFromItem(item)
}
func (c *Client) extractValueFromItem(item BitwardenItem) (string, error) {
if item.Login != nil && item.Login.Password != "" {
return item.Login.Password, nil
}
for _, field := range item.Fields {
fieldName := strings.ToLower(field.Name)
if fieldName == "value" || fieldName == "secret" || fieldName == "api_key" {
return field.Value, nil
}
}
if item.Notes != "" {
return item.Notes, nil
}
if len(item.Fields) > 0 {
return item.Fields[0].Value, nil
}
return "", fmt.Errorf("no secret value found in item")
}
func (c *Client) SyncVault() error {
cmd := exec.Command("bw", "sync")
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to sync vault: %w, output: %s", err, output)
}
logger.Info.Println("Vault synced successfully")
return nil
}
+275
View File
@@ -0,0 +1,275 @@
// Package vaultwarden provides native Bitwarden-compatible encryption and decryption.
// This replaces the Bitwarden CLI dependency with pure Go crypto.
package vaultwarden
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf"
"golang.org/x/crypto/pbkdf2"
)
// Encryption types as defined by the Bitwarden protocol.
const (
EncTypeAesCbc256_B64 = 0
EncTypeAesCbc128_HmacSha256_B64 = 1
EncTypeAesCbc256_HmacSha256_B64 = 2
EncTypeRsa2048_OaepSha256_B64 = 3
EncTypeRsa2048_OaepSha1_B64 = 4
)
// KDF types.
const (
KdfPBKDF2 = 0
KdfArgon2id = 1
)
// SymmetricKey holds the encryption and MAC keys for AES-CBC + HMAC-SHA256.
type SymmetricKey struct {
EncKey []byte // 32 bytes for AES-256
MacKey []byte // 32 bytes for HMAC-SHA256
}
// CipherString represents an encrypted Bitwarden value.
// Format: "<encType>.<iv>|<ciphertext>|<mac>"
type CipherString struct {
Type int
IV []byte
CT []byte
MAC []byte
}
// ParseCipherString parses a Bitwarden encrypted string.
func ParseCipherString(s string) (*CipherString, error) {
if s == "" {
return nil, errors.New("empty cipher string")
}
parts := strings.SplitN(s, ".", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid cipher string: missing type separator")
}
encType, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid encryption type: %w", err)
}
cs := &CipherString{Type: encType}
pieces := strings.Split(parts[1], "|")
switch encType {
case EncTypeAesCbc256_B64:
if len(pieces) != 2 {
return nil, fmt.Errorf("AesCbc256_B64 expects 2 parts, got %d", len(pieces))
}
if cs.IV, err = base64.StdEncoding.DecodeString(pieces[0]); err != nil {
return nil, fmt.Errorf("invalid IV: %w", err)
}
if cs.CT, err = base64.StdEncoding.DecodeString(pieces[1]); err != nil {
return nil, fmt.Errorf("invalid ciphertext: %w", err)
}
case EncTypeAesCbc256_HmacSha256_B64:
if len(pieces) != 3 {
return nil, fmt.Errorf("AesCbc256_HmacSha256_B64 expects 3 parts, got %d", len(pieces))
}
if cs.IV, err = base64.StdEncoding.DecodeString(pieces[0]); err != nil {
return nil, fmt.Errorf("invalid IV: %w", err)
}
if cs.CT, err = base64.StdEncoding.DecodeString(pieces[1]); err != nil {
return nil, fmt.Errorf("invalid ciphertext: %w", err)
}
if cs.MAC, err = base64.StdEncoding.DecodeString(pieces[2]); err != nil {
return nil, fmt.Errorf("invalid MAC: %w", err)
}
default:
return nil, fmt.Errorf("unsupported encryption type: %d", encType)
}
return cs, nil
}
// Decrypt decrypts a CipherString using the provided symmetric key.
func (cs *CipherString) Decrypt(key SymmetricKey) ([]byte, error) {
if len(cs.IV) != aes.BlockSize {
return nil, fmt.Errorf("invalid IV length: %d", len(cs.IV))
}
if len(cs.CT) == 0 || len(cs.CT)%aes.BlockSize != 0 {
return nil, fmt.Errorf("invalid ciphertext length: %d", len(cs.CT))
}
// Verify MAC if present (type 2).
if cs.Type == EncTypeAesCbc256_HmacSha256_B64 {
if len(key.MacKey) == 0 {
return nil, errors.New("MAC key required for type 2 decryption")
}
mac := hmac.New(sha256.New, key.MacKey)
mac.Write(cs.IV)
mac.Write(cs.CT)
expectedMAC := mac.Sum(nil)
if !hmac.Equal(expectedMAC, cs.MAC) {
return nil, errors.New("MAC verification failed")
}
}
// AES-CBC decrypt.
block, err := aes.NewCipher(key.EncKey)
if err != nil {
return nil, fmt.Errorf("aes cipher: %w", err)
}
plaintext := make([]byte, len(cs.CT))
mode := cipher.NewCBCDecrypter(block, cs.IV)
mode.CryptBlocks(plaintext, cs.CT)
// Remove PKCS7 padding.
plaintext, err = pkcs7Unpad(plaintext, aes.BlockSize)
if err != nil {
return nil, fmt.Errorf("pkcs7 unpad: %w", err)
}
return plaintext, nil
}
// DecryptToString decrypts and returns the plaintext as a string.
func (cs *CipherString) DecryptToString(key SymmetricKey) (string, error) {
b, err := cs.Decrypt(key)
if err != nil {
return "", err
}
return string(b), nil
}
// DecryptStr is a convenience function to parse and decrypt a cipher string in one call.
func DecryptStr(s string, key SymmetricKey) (string, error) {
if s == "" {
return "", nil
}
cs, err := ParseCipherString(s)
if err != nil {
return "", err
}
return cs.DecryptToString(key)
}
// MakeMasterKey derives the master key from the user's password and email.
func MakeMasterKey(password, email string, kdfType, iterations int, memory, parallelism *int) ([]byte, error) {
salt := []byte(strings.ToLower(strings.TrimSpace(email)))
switch kdfType {
case KdfPBKDF2:
if iterations < 1 {
return nil, fmt.Errorf("PBKDF2 iterations must be >= 1, got %d", iterations)
}
return pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New), nil
case KdfArgon2id:
mem := 64 * 1024 // default 64 MiB
par := 4 // default parallelism
if memory != nil {
mem = *memory * 1024 // API returns MiB, argon2 wants KiB
}
if parallelism != nil {
par = *parallelism
}
return argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(mem), uint8(par), 32), nil
default:
return nil, fmt.Errorf("unsupported KDF type: %d", kdfType)
}
}
// HashPassword creates the password hash for authentication.
// hash = Base64(PBKDF2(masterKey, password, 1, 32, SHA256))
func HashPassword(password string, masterKey []byte) string {
hash := pbkdf2.Key(masterKey, []byte(password), 1, 32, sha256.New)
return base64.StdEncoding.EncodeToString(hash)
}
// StretchKey expands a 32-byte master key into a 64-byte stretched key
// using HKDF-Expand with SHA-256. Returns (encKey, macKey).
func StretchKey(masterKey []byte) (SymmetricKey, error) {
encKey := make([]byte, 32)
r := hkdf.Expand(sha256.New, masterKey, []byte("enc"))
if _, err := io.ReadFull(r, encKey); err != nil {
return SymmetricKey{}, fmt.Errorf("hkdf expand enc: %w", err)
}
macKey := make([]byte, 32)
r = hkdf.Expand(sha256.New, masterKey, []byte("mac"))
if _, err := io.ReadFull(r, macKey); err != nil {
return SymmetricKey{}, fmt.Errorf("hkdf expand mac: %w", err)
}
return SymmetricKey{EncKey: encKey, MacKey: macKey}, nil
}
// DecryptSymmetricKey decrypts the user's encrypted symmetric key from the login response.
// It tries HKDF-stretched key first, then falls back to legacy (unstretched) key.
func DecryptSymmetricKey(encryptedKey string, masterKey []byte) (SymmetricKey, error) {
cs, err := ParseCipherString(encryptedKey)
if err != nil {
return SymmetricKey{}, fmt.Errorf("parse encrypted key: %w", err)
}
// Try modern approach: HKDF-stretched key.
stretched, err := StretchKey(masterKey)
if err != nil {
return SymmetricKey{}, fmt.Errorf("stretch key: %w", err)
}
decrypted, err := cs.Decrypt(stretched)
if err != nil {
// Fallback: legacy mode (master key used directly as enc key, no MAC).
legacy := SymmetricKey{EncKey: masterKey}
decrypted, err = cs.Decrypt(legacy)
if err != nil {
return SymmetricKey{}, fmt.Errorf("decrypt symmetric key (tried stretched + legacy): %w", err)
}
}
// The decrypted key is 64 bytes: first 32 = encKey, last 32 = macKey.
if len(decrypted) != 64 {
return SymmetricKey{}, fmt.Errorf("unexpected symmetric key length: %d (expected 64)", len(decrypted))
}
return SymmetricKey{
EncKey: decrypted[:32],
MacKey: decrypted[32:],
}, nil
}
// pkcs7Unpad removes PKCS#7 padding.
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if len(data) == 0 {
return nil, errors.New("empty data")
}
if len(data)%blockSize != 0 {
return nil, errors.New("data not block-aligned")
}
padLen := int(data[len(data)-1])
if padLen == 0 || padLen > blockSize {
return nil, fmt.Errorf("invalid padding length: %d", padLen)
}
for i := len(data) - padLen; i < len(data); i++ {
if data[i] != byte(padLen) {
return nil, errors.New("invalid PKCS7 padding")
}
}
return data[:len(data)-padLen], nil
}
+321
View File
@@ -0,0 +1,321 @@
package vaultwarden
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strings"
"testing"
"golang.org/x/crypto/pbkdf2"
)
func TestParseCipherString_Type2(t *testing.T) {
// Build a known cipher string of type 2 (AES-CBC-256 + HMAC-SHA256).
iv := make([]byte, 16)
ct := make([]byte, 32) // 2 blocks
mac := make([]byte, 32)
for i := range iv {
iv[i] = byte(i)
}
for i := range ct {
ct[i] = byte(i + 16)
}
for i := range mac {
mac[i] = byte(i + 48)
}
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
base64.StdEncoding.EncodeToString(ct) + "|" +
base64.StdEncoding.EncodeToString(mac)
cs, err := ParseCipherString(s)
if err != nil {
t.Fatalf("ParseCipherString failed: %v", err)
}
if cs.Type != 2 {
t.Errorf("expected type 2, got %d", cs.Type)
}
if len(cs.IV) != 16 {
t.Errorf("expected IV length 16, got %d", len(cs.IV))
}
if len(cs.CT) != 32 {
t.Errorf("expected CT length 32, got %d", len(cs.CT))
}
if len(cs.MAC) != 32 {
t.Errorf("expected MAC length 32, got %d", len(cs.MAC))
}
}
func TestParseCipherString_Type0(t *testing.T) {
iv := make([]byte, 16)
ct := make([]byte, 32)
s := "0." + base64.StdEncoding.EncodeToString(iv) + "|" +
base64.StdEncoding.EncodeToString(ct)
cs, err := ParseCipherString(s)
if err != nil {
t.Fatalf("ParseCipherString failed: %v", err)
}
if cs.Type != 0 {
t.Errorf("expected type 0, got %d", cs.Type)
}
if cs.MAC != nil {
t.Error("expected nil MAC for type 0")
}
}
func TestParseCipherString_Invalid(t *testing.T) {
tests := []struct {
name string
input string
}{
{"empty", ""},
{"no type", "abcdef"},
{"bad type", "x.abc|def|ghi"},
{"type 2 missing mac", "2.abc|def"},
{"type 0 extra parts", "0.abc|def|ghi"},
{"unsupported type", "5.abc|def"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseCipherString(tt.input)
if err == nil {
t.Error("expected error, got nil")
}
})
}
}
func TestPKCS7Unpad(t *testing.T) {
tests := []struct {
name string
input []byte
want []byte
wantErr bool
}{
{
name: "1 byte padding",
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x01},
want: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F},
},
{
name: "full block padding",
input: append(make([]byte, 0), 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10),
want: []byte{},
},
{
name: "empty input",
input: []byte{},
wantErr: true,
},
{
name: "zero padding byte",
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x00},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := pkcs7Unpad(tt.input, 16)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Errorf("length mismatch: got %d, want %d", len(got), len(tt.want))
}
})
}
}
func TestDecrypt_Type2_RoundTrip(t *testing.T) {
// Create a known key, encrypt some data, then decrypt it.
encKey := make([]byte, 32)
macKey := make([]byte, 32)
for i := range encKey {
encKey[i] = byte(i)
}
for i := range macKey {
macKey[i] = byte(i + 32)
}
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
plaintext := []byte("Hello, Vaultwarden!")
// Pad the plaintext with PKCS7.
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
padded := make([]byte, len(plaintext)+padLen)
copy(padded, plaintext)
for i := len(plaintext); i < len(padded); i++ {
padded[i] = byte(padLen)
}
// Encrypt with AES-CBC.
iv := make([]byte, 16)
for i := range iv {
iv[i] = byte(i + 100)
}
block, err := aes.NewCipher(encKey)
if err != nil {
t.Fatal(err)
}
ct := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
// Compute MAC.
mac := hmac.New(sha256.New, macKey)
mac.Write(iv)
mac.Write(ct)
macBytes := mac.Sum(nil)
// Build cipher string.
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
base64.StdEncoding.EncodeToString(ct) + "|" +
base64.StdEncoding.EncodeToString(macBytes)
cs, err := ParseCipherString(s)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := cs.DecryptToString(key)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if result != "Hello, Vaultwarden!" {
t.Errorf("got %q, want %q", result, "Hello, Vaultwarden!")
}
}
func TestDecrypt_MACVerificationFails(t *testing.T) {
encKey := make([]byte, 32)
macKey := make([]byte, 32)
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
// Create a valid ciphertext but with a wrong MAC.
iv := make([]byte, 16)
ct := make([]byte, 16)
wrongMAC := make([]byte, 32)
for i := range wrongMAC {
wrongMAC[i] = 0xFF
}
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
base64.StdEncoding.EncodeToString(ct) + "|" +
base64.StdEncoding.EncodeToString(wrongMAC)
cs, err := ParseCipherString(s)
if err != nil {
t.Fatal(err)
}
_, err = cs.Decrypt(key)
if err == nil {
t.Error("expected MAC verification error")
}
if !strings.Contains(err.Error(), "MAC verification failed") {
t.Errorf("unexpected error: %v", err)
}
}
func TestMakeMasterKey_PBKDF2(t *testing.T) {
key, err := MakeMasterKey("password123", "user@example.com", KdfPBKDF2, 600000, nil, nil)
if err != nil {
t.Fatalf("MakeMasterKey failed: %v", err)
}
if len(key) != 32 {
t.Errorf("expected 32 bytes, got %d", len(key))
}
// Verify it matches raw PBKDF2.
expected := pbkdf2.Key([]byte("password123"), []byte("user@example.com"), 600000, 32, sha256.New)
for i := range key {
if key[i] != expected[i] {
t.Fatalf("key mismatch at byte %d", i)
}
}
}
func TestMakeMasterKey_Argon2id(t *testing.T) {
mem := 64
par := 4
key, err := MakeMasterKey("password123", "user@example.com", KdfArgon2id, 3, &mem, &par)
if err != nil {
t.Fatalf("MakeMasterKey failed: %v", err)
}
if len(key) != 32 {
t.Errorf("expected 32 bytes, got %d", len(key))
}
}
func TestHashPassword(t *testing.T) {
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
hash := HashPassword("password123", masterKey)
if hash == "" {
t.Error("empty hash")
}
// Should be valid base64.
_, err := base64.StdEncoding.DecodeString(hash)
if err != nil {
t.Errorf("hash is not valid base64: %v", err)
}
}
func TestStretchKey(t *testing.T) {
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
key, err := StretchKey(masterKey)
if err != nil {
t.Fatalf("StretchKey failed: %v", err)
}
if len(key.EncKey) != 32 {
t.Errorf("expected 32-byte enc key, got %d", len(key.EncKey))
}
if len(key.MacKey) != 32 {
t.Errorf("expected 32-byte mac key, got %d", len(key.MacKey))
}
// Enc and Mac keys should be different.
same := true
for i := range key.EncKey {
if key.EncKey[i] != key.MacKey[i] {
same = false
break
}
}
if same {
t.Error("enc and mac keys should differ")
}
}
func TestDecryptStr_Empty(t *testing.T) {
key := SymmetricKey{EncKey: make([]byte, 32), MacKey: make([]byte, 32)}
result, err := DecryptStr("", key)
if err != nil {
t.Fatalf("DecryptStr empty should not error: %v", err)
}
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
}
+22 -96
View File
@@ -1,114 +1,40 @@
package vaultwarden
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/thijsherman/vaultwarden-api/pkg/logger"
)
var bitwardenInitMutex sync.Mutex
// InitializeClient creates and initializes a fully authenticated vault client.
// clientID and clientSecret are optional — if provided, API key login is used (bypasses 2FA).
func InitializeClient(serverURL, email, password, clientID, clientSecret string, cacheTTL, syncInterval time.Duration) (*Client, error) {
logger.Info.Println("Initializing Vaultwarden native API client...")
type BitwardenStatus struct {
Status string `json:"status"`
}
api := NewAPIClient(serverURL, email, password, clientID, clientSecret)
client := NewClient(api, cacheTTL, syncInterval)
func InitializeBitwardenCLI(serverURL, clientID, clientSecret, password string) (string, error) {
bitwardenInitMutex.Lock()
defer bitwardenInitMutex.Unlock()
// Authenticate and perform initial sync with retry.
maxRetries := 3
var lastErr error
logger.Info.Println("Initializing Bitwarden CLI...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
cmd := exec.CommandContext(ctx, "bw", "config", "server", serverURL)
if output, err := cmd.CombinedOutput(); err != nil {
cancel()
return "", fmt.Errorf("config server failed: %w - %s", err, output)
}
cancel()
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, "bw", "status")
statusOutput, err := cmd.CombinedOutput()
if err != nil {
logger.Warn.Printf("Failed to check status: %v", err)
}
var status BitwardenStatus
isLoggedIn := false
if err := json.Unmarshal(statusOutput, &status); err == nil {
isLoggedIn = status.Status == "unlocked" || status.Status == "locked"
}
if !isLoggedIn {
logger.Info.Println("Logging in to Bitwarden...")
maxRetries := 3
var loginErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt) * 5 * time.Second
logger.Info.Printf("Retry attempt %d/%d after %v...", attempt, maxRetries, backoff)
time.Sleep(backoff)
}
ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
cmd = exec.CommandContext(ctx, "bw", "login", "--apikey")
cmd.Env = append(os.Environ(),
fmt.Sprintf("BW_CLIENTID=%s", clientID),
fmt.Sprintf("BW_CLIENTSECRET=%s", clientSecret),
)
output, err := cmd.CombinedOutput()
cancel()
outputStr := strings.TrimSpace(string(output))
if err == nil || strings.Contains(outputStr, "You are logged in!") {
logger.Info.Println("Login successful")
loginErr = nil
break
}
if strings.Contains(outputStr, "Rate limit") {
logger.Warn.Printf("Rate limited (attempt %d/%d)", attempt, maxRetries)
loginErr = fmt.Errorf("rate limited: %s", outputStr)
} else {
logger.Warn.Printf("Login failed (attempt %d/%d): %v - %s", attempt, maxRetries, err, outputStr)
loginErr = fmt.Errorf("login failed: %w - %s", err, outputStr)
}
if attempt < maxRetries {
continue
}
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt) * 5 * time.Second
logger.Info.Printf("Retry attempt %d/%d after %v...", attempt, maxRetries, backoff)
time.Sleep(backoff)
}
if loginErr != nil {
return "", loginErr
if err := client.Initialize(); err != nil {
logger.Warn.Printf("Initialization failed (attempt %d/%d): %v", attempt, maxRetries, err)
lastErr = err
continue
}
} else {
logger.Info.Println("Already logged in, skipping login")
logger.Info.Println("Vaultwarden client initialized successfully")
return client, nil
}
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd = exec.CommandContext(ctx, "bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw")
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_PASSWORD=%s", password))
sessionToken, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("unlock failed: %w - %s", err, sessionToken)
}
token := strings.TrimSpace(string(sessionToken))
if token == "" {
return "", fmt.Errorf("received empty session token")
}
logger.Info.Println("Bitwarden CLI initialized successfully")
return token, nil
return nil, fmt.Errorf("failed to initialize after %d attempts: %w", maxRetries, lastErr)
}