security update
This commit is contained in:
1 parent
5b9f19e237
commit
4131345add
11 files changed
+424
-44
No files matched your search
@@ -47,3 +47,8 @@ Dockerfile
|
|||||||
|
|
||||||
# Make
|
# Make
|
||||||
Makefile
|
Makefile
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs/
|
||||||
|
CLAUDE.md
|
||||||
|
settings.local.json
|
||||||
+13
-10
@@ -1,26 +1,29 @@
|
|||||||
# Server Configuration
|
# Server Configuration
|
||||||
API_PORT=8080
|
API_PORT=8080
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=production # IMPORTANT: Set to 'production' in production!
|
||||||
|
|
||||||
# Security - REQUIRED
|
# Security - REQUIRED
|
||||||
# Generate a strong random API key (e.g., using: openssl rand -base64 32)
|
API_KEY=CHANGE-THIS-USE-32-CHARS-MINIMUM!!
|
||||||
API_KEY=your-super-secret-api-key-here
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
# Vaultwarden Configuration - REQUIRED
|
# Vaultwarden Configuration - REQUIRED
|
||||||
# URL of your Vaultwarden instance (no trailing slash)
|
# URL of your Vaultwarden instance (no trailing slash)
|
||||||
VAULTWARDEN_URL=https://vault.yourdomain.com
|
VAULTWARDEN_URL=https://vault.yourdomain.com
|
||||||
|
|
||||||
# Vaultwarden API access token
|
# Authentication Method 1: API Keys (RECOMMENDED - doesn't expire)
|
||||||
# Get this from: Vaultwarden > Settings > Security > API Key
|
VAULTWARDEN_CLIENT_ID=user.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
# Or create via CLI: vaultwarden hash --preset <preset>
|
VAULTWARDEN_CLIENT_SECRET=your-long-client-secret-here
|
||||||
VAULTWARDEN_ACCESS_TOKEN=your-vaultwarden-access-token
|
|
||||||
|
# Authentication Method 2: Session Token (LEGACY - expires after some time)
|
||||||
|
# VAULTWARDEN_ACCESS_TOKEN=your-session-token-here
|
||||||
|
|
||||||
# Performance & Caching
|
# Performance & Caching
|
||||||
# How long to cache secrets in memory (reduces API calls)
|
# How long to cache secrets in memory (reduces API calls)
|
||||||
# Set to 0 to disable caching
|
# Set to 0 to disable caching (more secure but slower)
|
||||||
CACHE_TTL=5m
|
CACHE_TTL=5m
|
||||||
|
|
||||||
# Timeouts
|
# Timeouts
|
||||||
READ_TIMEOUT=10s
|
READ_TIMEOUT=10s
|
||||||
WRITE_TIMEOUT=10s
|
WRITE_TIMEOUT=10s
|
||||||
|
|
||||||
+3
-1
@@ -58,4 +58,6 @@ temp/
|
|||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Documentation artifacts
|
# Documentation artifacts
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
docs/
|
||||||
|
settings.local.json
|
||||||
+5
-2
@@ -27,8 +27,11 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
|||||||
# Final stage - minimal runtime image
|
# Final stage - minimal runtime image
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19
|
||||||
|
|
||||||
# Install CA certificates for HTTPS and wget for healthcheck
|
# Install CA certificates for HTTPS, wget for healthcheck, and Bitwarden CLI
|
||||||
RUN apk --no-cache add ca-certificates wget
|
RUN apk --no-cache add ca-certificates wget nodejs npm && \
|
||||||
|
npm install -g @bitwarden/cli && \
|
||||||
|
apk del npm && \
|
||||||
|
rm -rf /root/.npm
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN addgroup -g 1000 appuser && \
|
RUN addgroup -g 1000 appuser && \
|
||||||
|
|||||||
+20
-10
@@ -29,7 +29,14 @@ func main() {
|
|||||||
logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment)
|
logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment)
|
||||||
|
|
||||||
// Initialize Vaultwarden client
|
// Initialize Vaultwarden client
|
||||||
vaultClient := vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL)
|
var vaultClient *vaultwarden.Client
|
||||||
|
if cfg.VaultwardenClientID != "" && cfg.VaultwardenSecret != "" {
|
||||||
|
logger.Info.Println("Using API key authentication (recommended)")
|
||||||
|
vaultClient = vaultwarden.NewClientWithAuth(cfg.VaultwardenURL, cfg.VaultwardenClientID, cfg.VaultwardenSecret, cfg.CacheTTL)
|
||||||
|
} else {
|
||||||
|
logger.Warn.Println("Using session token authentication (legacy - token will expire!)")
|
||||||
|
vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
h := handlers.NewHandler(vaultClient)
|
h := handlers.NewHandler(vaultClient)
|
||||||
@@ -46,23 +53,26 @@ func main() {
|
|||||||
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Security middleware
|
app.Use(helmet.New())
|
||||||
app.Use(helmet.New()) // Security headers
|
app.Use(recover.New())
|
||||||
app.Use(recover.New()) // Recover from panics
|
|
||||||
app.Use(compress.New(compress.Config{
|
app.Use(compress.New(compress.Config{
|
||||||
Level: compress.LevelBestSpeed,
|
Level: compress.LevelBestSpeed,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// CORS configuration - adjust as needed
|
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*", // Restrict this in production to specific domains
|
AllowOrigins: cfg.CORSAllowedOrigins,
|
||||||
AllowMethods: "GET,POST",
|
AllowMethods: "GET,POST",
|
||||||
AllowHeaders: "Authorization,Content-Type",
|
AllowHeaders: "Authorization,Content-Type",
|
||||||
|
AllowCredentials: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Rate limiting to prevent abuse
|
|
||||||
app.Use(limiter.New(limiter.Config{
|
app.Use(limiter.New(limiter.Config{
|
||||||
Max: 100, // 100 requests per minute per IP
|
Max: 30,
|
||||||
|
LimitReached: func(c *fiber.Ctx) error {
|
||||||
|
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
||||||
|
"error": "too many requests, please slow down",
|
||||||
|
})
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Public routes (no authentication required)
|
// Public routes (no authentication required)
|
||||||
|
|||||||
+21
-14
@@ -19,12 +19,13 @@ type Config struct {
|
|||||||
// Security
|
// Security
|
||||||
APIKey string
|
APIKey string
|
||||||
|
|
||||||
// Vaultwarden settings
|
VaultwardenURL string
|
||||||
VaultwardenURL string
|
VaultwardenToken string
|
||||||
VaultwardenToken string
|
VaultwardenClientID string
|
||||||
|
VaultwardenSecret string
|
||||||
|
|
||||||
// Cache settings
|
CacheTTL time.Duration
|
||||||
CacheTTL time.Duration
|
CORSAllowedOrigins string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables
|
// Load reads configuration from environment variables
|
||||||
@@ -34,26 +35,32 @@ func Load() (*Config, error) {
|
|||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
Environment: getEnv("ENVIRONMENT", "development"),
|
||||||
APIKey: os.Getenv("API_KEY"),
|
APIKey: os.Getenv("API_KEY"),
|
||||||
|
|
||||||
VaultwardenURL: os.Getenv("VAULTWARDEN_URL"),
|
VaultwardenURL: os.Getenv("VAULTWARDEN_URL"),
|
||||||
VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"),
|
VaultwardenToken: os.Getenv("VAULTWARDEN_ACCESS_TOKEN"),
|
||||||
|
VaultwardenClientID: os.Getenv("VAULTWARDEN_CLIENT_ID"),
|
||||||
|
VaultwardenSecret: os.Getenv("VAULTWARDEN_CLIENT_SECRET"),
|
||||||
|
|
||||||
ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")),
|
ReadTimeout: parseDuration(getEnv("READ_TIMEOUT", "10s")),
|
||||||
WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")),
|
WriteTimeout: parseDuration(getEnv("WRITE_TIMEOUT", "10s")),
|
||||||
CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")),
|
CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m")),
|
||||||
|
CORSAllowedOrigins: getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:3000"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, fmt.Errorf("API_KEY is required")
|
return nil, fmt.Errorf("API_KEY is required")
|
||||||
}
|
}
|
||||||
if len(cfg.APIKey) < 16 {
|
if len(cfg.APIKey) < 32 {
|
||||||
return nil, fmt.Errorf("API_KEY must be at least 16 characters for security")
|
return nil, fmt.Errorf("API_KEY must be at least 32 characters for security (run: openssl rand -base64 32)")
|
||||||
}
|
}
|
||||||
if cfg.VaultwardenURL == "" {
|
if cfg.VaultwardenURL == "" {
|
||||||
return nil, fmt.Errorf("VAULTWARDEN_URL is required")
|
return nil, fmt.Errorf("VAULTWARDEN_URL is required")
|
||||||
}
|
}
|
||||||
if cfg.VaultwardenToken == "" {
|
|
||||||
return nil, fmt.Errorf("VAULTWARDEN_ACCESS_TOKEN is required")
|
hasAPIKeys := cfg.VaultwardenClientID != "" && cfg.VaultwardenSecret != ""
|
||||||
|
hasSessionToken := cfg.VaultwardenToken != ""
|
||||||
|
if !hasAPIKeys && !hasSessionToken {
|
||||||
|
return nil, fmt.Errorf("either VAULTWARDEN_CLIENT_ID+VAULTWARDEN_CLIENT_SECRET or VAULTWARDEN_ACCESS_TOKEN is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and normalize URL
|
// Validate and normalize URL
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/thijsherman/vaultwarden-api/internal/validators"
|
||||||
"github.com/thijsherman/vaultwarden-api/internal/vaultwarden"
|
"github.com/thijsherman/vaultwarden-api/internal/vaultwarden"
|
||||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||||
)
|
)
|
||||||
@@ -37,10 +38,15 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch secret from Vaultwarden
|
if !validators.IsValidSecretName(secretName) {
|
||||||
|
logger.Warn.Printf("Invalid secret name attempted: %s", secretName)
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"error": "invalid secret name format",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
value, err := h.vaultClient.GetSecret(secretName)
|
value, err := h.vaultClient.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Don't expose internal error details to client
|
|
||||||
logger.Error.Printf("Failed to fetch secret '%s': %v", secretName, err)
|
logger.Error.Printf("Failed to fetch secret '%s': %v", secretName, err)
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
"error": "secret not found",
|
"error": "secret not found",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package validators
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const SecretNameMaxLength = 255
|
||||||
|
|
||||||
|
var SecretNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\./]*[a-zA-Z0-9]$`)
|
||||||
|
|
||||||
|
func IsValidSecretName(name string) bool {
|
||||||
|
if len(name) == 0 || len(name) > SecretNameMaxLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(name, "..") || strings.Contains(name, "\x00") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range name {
|
||||||
|
if ch < 32 || ch > 126 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SecretNamePattern.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SanitizeSecretName(name string) (string, bool) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
|
cleaned := strings.Map(func(r rune) rune {
|
||||||
|
if r >= 32 && r <= 126 {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, name)
|
||||||
|
|
||||||
|
if IsValidSecretName(cleaned) {
|
||||||
|
return cleaned, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package vaultwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthManager handles authentication with Vaultwarden
|
||||||
|
type AuthManager struct {
|
||||||
|
baseURL string
|
||||||
|
clientID string
|
||||||
|
clientSecret string
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
// Token management
|
||||||
|
mu sync.RWMutex
|
||||||
|
accessToken string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse represents the OAuth token response from Vaultwarden
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthManager creates a new authentication manager
|
||||||
|
func NewAuthManager(baseURL, clientID, clientSecret string) *AuthManager {
|
||||||
|
return &AuthManager{
|
||||||
|
baseURL: baseURL,
|
||||||
|
clientID: clientID,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessToken returns a valid access token, refreshing if necessary
|
||||||
|
func (am *AuthManager) GetAccessToken() (string, error) {
|
||||||
|
am.mu.RLock()
|
||||||
|
// Check if we have a valid token
|
||||||
|
if am.accessToken != "" && time.Now().Before(am.tokenExpiry) {
|
||||||
|
token := am.accessToken
|
||||||
|
am.mu.RUnlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
am.mu.RUnlock()
|
||||||
|
|
||||||
|
// Token expired or doesn't exist, get a new one
|
||||||
|
return am.refreshAccessToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshAccessToken obtains a new access token from Vaultwarden
|
||||||
|
func (am *AuthManager) refreshAccessToken() (string, error) {
|
||||||
|
am.mu.Lock()
|
||||||
|
defer am.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check after acquiring write lock
|
||||||
|
if am.accessToken != "" && time.Now().Before(am.tokenExpiry) {
|
||||||
|
return am.accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info.Println("Obtaining new access token from Vaultwarden...")
|
||||||
|
|
||||||
|
// Prepare the token request
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("grant_type", "client_credentials")
|
||||||
|
data.Set("scope", "api")
|
||||||
|
data.Set("client_id", am.clientID)
|
||||||
|
data.Set("client_secret", am.clientSecret)
|
||||||
|
|
||||||
|
// Generate device identifier (required by Bitwarden protocol)
|
||||||
|
deviceID := am.generateDeviceID()
|
||||||
|
data.Set("deviceIdentifier", deviceID)
|
||||||
|
data.Set("deviceType", "14") // SDK type
|
||||||
|
data.Set("deviceName", "Vaultwarden-API")
|
||||||
|
|
||||||
|
// Create request
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tokenURL := fmt.Sprintf("%s/identity/connect/token", am.baseURL)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", tokenURL, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
resp, err := am.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute token request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// SECURITY: Do not log response body - may contain sensitive information
|
||||||
|
logger.Error.Printf("Token request failed with status %d", resp.StatusCode)
|
||||||
|
return "", fmt.Errorf("token request failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var tokenResp TokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the token
|
||||||
|
am.accessToken = tokenResp.AccessToken
|
||||||
|
// Refresh 5 minutes before expiry to avoid edge cases
|
||||||
|
am.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second)
|
||||||
|
|
||||||
|
logger.Info.Printf("Successfully obtained access token (expires in %d seconds)", tokenResp.ExpiresIn)
|
||||||
|
|
||||||
|
return am.accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateDeviceID creates a consistent device identifier
|
||||||
|
func (am *AuthManager) generateDeviceID() string {
|
||||||
|
// Create a hash of client_id for consistent device ID
|
||||||
|
hash := sha256.Sum256([]byte(am.clientID))
|
||||||
|
return base64.StdEncoding.EncodeToString(hash[:16])
|
||||||
|
}
|
||||||
@@ -13,10 +13,11 @@ import (
|
|||||||
|
|
||||||
// Client handles communication with Vaultwarden API
|
// Client handles communication with Vaultwarden API
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
token string
|
token string // Legacy: session token for CLI fallback
|
||||||
httpClient *http.Client
|
authManager *AuthManager
|
||||||
cache *secretCache
|
httpClient *http.Client
|
||||||
|
cache *secretCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretCache provides a simple in-memory cache with TTL
|
// secretCache provides a simple in-memory cache with TTL
|
||||||
@@ -55,6 +56,9 @@ type CipherResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Vaultwarden client
|
// NewClient creates a new Vaultwarden client
|
||||||
|
// token can be either:
|
||||||
|
// - A session token (from bw unlock) for CLI-based access
|
||||||
|
// - A client_id for API-based access (requires clientSecret via NewClientWithAuth)
|
||||||
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
||||||
cache := &secretCache{
|
cache := &secretCache{
|
||||||
items: make(map[string]*cacheItem),
|
items: make(map[string]*cacheItem),
|
||||||
@@ -77,6 +81,30 @@ func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewClientWithAuth creates a client with API key authentication (recommended)
|
||||||
|
// Use this method when you have client_id and client_secret from Vaultwarden
|
||||||
|
func NewClientWithAuth(baseURL, clientID, clientSecret string, cacheTTL time.Duration) *Client {
|
||||||
|
cache := &secretCache{
|
||||||
|
items: make(map[string]*cacheItem),
|
||||||
|
ttl: cacheTTL,
|
||||||
|
enabled: cacheTTL > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cache cleanup goroutine if caching is enabled
|
||||||
|
if cacheTTL > 0 {
|
||||||
|
go cache.startCleanup(cacheTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
authManager: NewAuthManager(baseURL, clientID, clientSecret),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetSecret retrieves a secret by name from Vaultwarden
|
// GetSecret retrieves a secret by name from Vaultwarden
|
||||||
// It first checks the cache, then queries the API if needed
|
// It first checks the cache, then queries the API if needed
|
||||||
func (c *Client) GetSecret(name string) (string, error) {
|
func (c *Client) GetSecret(name string) (string, error) {
|
||||||
@@ -110,10 +138,20 @@ func (c *Client) GetSecret(name string) (string, error) {
|
|||||||
|
|
||||||
// fetchSecret queries the Vaultwarden API for a secret
|
// fetchSecret queries the Vaultwarden API for a secret
|
||||||
func (c *Client) fetchSecret(name string) (string, error) {
|
func (c *Client) fetchSecret(name string) (string, error) {
|
||||||
|
// First, try using the Bitwarden CLI if available
|
||||||
|
// This is more reliable for Vaultwarden instances
|
||||||
|
if value, err := c.FetchSecretViaCLI(name); err == nil {
|
||||||
|
return value, nil
|
||||||
|
} else {
|
||||||
|
logger.Warn.Printf("CLI method failed, trying API: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to API method
|
||||||
// Build the API request with context for timeout
|
// Build the API request with context for timeout
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Using the Bitwarden public API endpoint for list items
|
||||||
url := fmt.Sprintf("%s/api/ciphers", c.baseURL)
|
url := fmt.Sprintf("%s/api/ciphers", c.baseURL)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -121,8 +159,23 @@ func (c *Client) fetchSecret(name string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set authentication header
|
// Set authentication header
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token))
|
var token string
|
||||||
|
var tokenErr error
|
||||||
|
|
||||||
|
if c.authManager != nil {
|
||||||
|
// Use API key authentication (preferred)
|
||||||
|
token, tokenErr = c.authManager.GetAccessToken()
|
||||||
|
if tokenErr != nil {
|
||||||
|
return "", fmt.Errorf("failed to get access token: %w", tokenErr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to session token
|
||||||
|
token = c.token
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Device-Type", "14") // SDK type
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package vaultwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"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, "--session", 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, "--session", 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first matching item
|
||||||
|
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", "--session", 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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user