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:
1 parent
e320f95d9f
commit
9f8ebf2408
14 files changed
+1527
-964
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user