From 9f8ebf2408dc3c9716e67db2cf6d3b2959d11aab Mon Sep 17 00:00:00 2001 From: Thijs Herman <64640608+Turbootzz@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:28:38 +0100 Subject: [PATCH] feat: replace Bitwarden CLI with native Go API client (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 82 ++-- Dockerfile | 39 +- README.md | 616 ++++++++-------------------- cmd/api/main.go | 115 +++--- docker-compose.yml | 47 +-- go.mod | 9 +- go.sum | 6 +- internal/handlers/handlers.go | 17 +- internal/vaultwarden/api_client.go | 495 ++++++++++++++++++++++ internal/vaultwarden/client.go | 235 ++++++----- internal/vaultwarden/client_cli.go | 116 ------ internal/vaultwarden/crypto.go | 275 +++++++++++++ internal/vaultwarden/crypto_test.go | 321 +++++++++++++++ internal/vaultwarden/init.go | 118 +----- 14 files changed, 1527 insertions(+), 964 deletions(-) create mode 100644 internal/vaultwarden/api_client.go delete mode 100644 internal/vaultwarden/client_cli.go create mode 100644 internal/vaultwarden/crypto.go create mode 100644 internal/vaultwarden/crypto_test.go diff --git a/.env.example b/.env.example index d571581..13888f4 100644 --- a/.env.example +++ b/.env.example @@ -1,58 +1,40 @@ -# Server Configuration -API_PORT=8080 -ENVIRONMENT=development # IMPORTANT: Change to 'production' for production deployments! +# =========================================== +# Vaultwarden API Configuration +# =========================================== +# Copy this file to .env and fill in your values. +# NEVER commit your actual .env file! -# Security - REQUIRED -API_KEY=CHANGE-THIS-USE-32-CHARS-MINIMUM!! +# --- Required --- -# IP Whitelist (Optional but RECOMMENDED for production) -# Comma-separated list of allowed IPs or CIDR ranges -# Leave empty to allow all IPs (NOT recommended for production) -# Examples: -# Single IP: ALLOWED_IPS=203.0.113.50 -# Multiple IPs: ALLOWED_IPS=203.0.113.50,198.51.100.25 -# CIDR range: ALLOWED_IPS=192.168.1.0/24 -# Mix: ALLOWED_IPS=203.0.113.50,192.168.1.0/24,10.0.0.0/8 -ALLOWED_IPS= - -# Enable GitHub Actions IP ranges (automatically whitelists GitHub's IPs) -# Set to 'true' to allow GitHub Actions to access your API -# Updates every 24 hours automatically -ENABLE_GITHUB_IP_RANGES=false - -# Trusted Proxy Configuration -# Specify the IP of your reverse proxy (Nginx Proxy Manager, Traefik, etc.) -# Default: only localhost (127.0.0.1, ::1) -# Note: The actual client IP is extracted from X-Forwarded-For header -# Examples: -# Docker networks: TRUSTED_PROXY_IP=172.16.0.0/12 -# Single proxy: TRUSTED_PROXY_IP=172.18.0.5 -# Multiple proxies: TRUSTED_PROXY_IP=172.18.0.5,192.168.1.10 -TRUSTED_PROXY_IP= - -# CORS Configuration -# IMPORTANT: Update this for production deployments -CORS_ALLOWED_ORIGINS=http://localhost:3000 - -# Vaultwarden Configuration - REQUIRED -# URL of your Vaultwarden instance (no trailing slash) +# Your Vaultwarden instance URL VAULTWARDEN_URL=https://vault.yourdomain.com -# Authentication - Choose ONE method: -# Method 1: API Key (Recommended - bypasses 2FA, auto-login on startup) -# Get your API key from: Account Settings -> Security -> Keys -> View API Key -VAULTWARDEN_CLIENT_ID=user.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -VAULTWARDEN_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Your Vaultwarden login email +VAULTWARDEN_EMAIL=you@example.com + +# Your Vaultwarden master password VAULTWARDEN_PASSWORD=your-master-password -# Method 2: Pre-generated session token (Alternative - will expire) -# VAULTWARDEN_ACCESS_TOKEN=your-session-token-here +# API key for authenticating to this service (min 32 chars) +# Generate one: openssl rand -base64 32 +API_KEY= -# Performance & Caching -# How long to cache secrets in memory (reduces API calls) -# Set to 0 to disable caching (more secure but slower) -CACHE_TTL=5m +# --- Optional --- -# Timeouts -READ_TIMEOUT=10s -WRITE_TIMEOUT=10s \ No newline at end of file +# Restrict access to specific IPs/CIDRs +# ALLOWED_IPS=192.168.1.0/24,10.0.0.1 + +# Auto-whitelist GitHub Actions IPs (for CI/CD) +# ENABLE_GITHUB_IP_RANGES=true + +# Trusted reverse proxy IPs (for correct client IP detection) +# TRUSTED_PROXY_IP=172.16.0.0/12 + +# How often to re-sync the vault (default: 5m) +# SYNC_INTERVAL=5m + +# Environment (development shows detailed errors, production hides them) +# ENVIRONMENT=production + +# Enable debug logging (shows secret names in logs β€” NOT for production!) +# DEBUG=false diff --git a/Dockerfile b/Dockerfile index 1c482e8..6c147e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,58 +1,31 @@ # Build stage FROM golang:1.25-alpine AS builder -# Install build dependencies -RUN apk add --no-cache git ca-certificates tzdata +RUN apk add --no-cache git ca-certificates -# Set working directory WORKDIR /build - -# Copy go mod files COPY go.mod go.sum ./ - -# Download dependencies RUN go mod download - -# Copy source code COPY . . -# Build the binary -# CGO_ENABLED=0 for static binary (no C dependencies) -# -ldflags="-s -w" strips debug info to reduce binary size RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -ldflags="-s -w" \ -o /build/vaultwarden-api \ ./cmd/api -# Final stage - minimal runtime image -FROM node:24-alpine +# Runtime stage β€” pure Alpine, no Node.js +FROM alpine:3.21 -# Install CA certificates and wget RUN apk --no-cache add ca-certificates wget && \ - npm install -g @bitwarden/cli && \ - npm cache clean --force + adduser -D -H appuser -# Set working directory WORKDIR /app - -# Copy binary from builder COPY --from=builder /build/vaultwarden-api . -# Change ownership to existing node user -RUN chown -R node:node /app - -# Set Bitwarden CLI data directory to /tmp (writable) -ENV BITWARDENCLI_APPDATA_DIR=/tmp/.bitwarden - -# Switch to non-root user -USER node - -# Expose port +USER appuser EXPOSE 8080 -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 -# Run the application ENTRYPOINT ["/app/vaultwarden-api"] diff --git a/README.md b/README.md index 7401d5b..ee5b941 100644 --- a/README.md +++ b/README.md @@ -1,513 +1,239 @@ -# Vaultwarden API Proxy +# πŸ” Vaultwarden API -A secure, lightweight Go API that acts as a proxy between your local apps and Vaultwarden (self-hosted Bitwarden server). Use it as a replacement for `.env` files in development and production environments. +> **Stop using `.env` files.** Fetch secrets directly from your self-hosted [Vaultwarden](https://github.com/dani-garcia/vaultwarden) instance at runtime. -## Features +A lightweight, production-ready Go API that acts as a secrets bridge between your apps and Vaultwarden. No more scattered `.env` files, no more accidentally committed credentials. -- **Secure**: API key authentication, IP whitelisting, constant-time comparison, no sensitive logging -- **Lightweight**: ~15MB Docker image, minimal resource usage -- **Fast**: Built-in caching with configurable TTL -- **Simple**: Clean architecture, easy to deploy and maintain -- **Production-ready**: Rate limiting, health checks, graceful shutdown, security headers -- **IP Whitelisting**: Restrict access by IP/CIDR with optional GitHub Actions support +## ✨ Highlights + +- **πŸš€ Zero external dependencies** β€” Pure Go binary, no Node.js, no Bitwarden CLI +- **πŸ”’ Native Bitwarden crypto** β€” AES-256-CBC + HMAC-SHA256, PBKDF2/Argon2id key derivation +- **♻️ Auto token refresh** β€” Never worry about expired sessions again +- **πŸ“¦ ~15MB Docker image** β€” Alpine-based, runs as non-root +- **πŸ›‘οΈ Defense in depth** β€” API key auth, IP whitelisting, rate limiting, security headers +- **⚑ Background vault sync** β€” Secrets always up-to-date (configurable interval) +- **🏭 Production-ready** β€” Health checks, graceful shutdown, structured logging ## Architecture ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” HTTPS + API Key β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Your App β”‚ ───────────────────────> β”‚ Vaultwarden API β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Proxy β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β”‚ Vaultwarden API - β–Ό - β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ Vaultwarden β”‚ - β”‚ Server β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” HTTPS + API Key β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Native Go β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Your App β”‚ ────────────────────> β”‚ Vaultwarden API β”‚ ──────────────> β”‚ Vaultwarden β”‚ +β”‚ (any lang) β”‚ <──────────────────── β”‚ (Go, ~15MB image) β”‚ <────────────── β”‚ Server β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ JSON response β”‚ β”‚ Encrypted API β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β€’ Auto token refreshβ”‚ + β”‚ β€’ Background sync β”‚ + β”‚ β€’ In-memory cache β”‚ + β”‚ β€’ IP whitelisting β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -## Project Structure - -``` -. -β”œβ”€β”€ cmd/ -β”‚ └── api/ -β”‚ └── main.go # Application entry point -β”œβ”€β”€ internal/ -β”‚ β”œβ”€β”€ auth/ -β”‚ β”‚ └── middleware.go # API key authentication -β”‚ β”œβ”€β”€ config/ -β”‚ β”‚ └── config.go # Configuration management -β”‚ β”œβ”€β”€ handlers/ -β”‚ β”‚ └── handlers.go # HTTP handlers -β”‚ β”œβ”€β”€ ipwhitelist/ -β”‚ β”‚ └── ipwhitelist.go # IP-based access control -β”‚ β”œβ”€β”€ validators/ -β”‚ β”‚ └── validators.go # Input validation -β”‚ └── vaultwarden/ -β”‚ β”œβ”€β”€ client.go # Vaultwarden client -β”‚ β”œβ”€β”€ client_cli.go # CLI-based secret retrieval -β”‚ └── init.go # Bitwarden CLI initialization -β”œβ”€β”€ pkg/ -β”‚ └── logger/ -β”‚ └── logger.go # Secure logging -β”œβ”€β”€ .env.example # Environment variables template -β”œβ”€β”€ .gitignore # Git ignore rules -β”œβ”€β”€ Dockerfile # Multi-stage Docker build -β”œβ”€β”€ docker-compose.yml # Docker Compose configuration -β”œβ”€β”€ go.mod # Go module definition -β”œβ”€β”€ go.sum # Go module checksums -β”œβ”€β”€ Makefile # Build automation -└── README.md # This file -``` +**How it works under the hood:** +1. Authenticates with Vaultwarden using your master password (same as the web vault) +2. Derives encryption keys using PBKDF2-SHA256 or Argon2id (server-negotiated) +3. Decrypts your vault items in-memory using AES-256-CBC +4. Serves secrets via a simple REST API with API key authentication +5. Periodically re-syncs the vault and auto-refreshes auth tokens ## Quick Start -### Prerequisites +### 1. Pull and run with Docker -- Go 1.25+ (for local development) -- Docker & Docker Compose (for containerized deployment) -- Vaultwarden instance with API access - -### Local Development - -1. **Clone the repository** - ```bash - git clone https://github.com/turbootzz/vaultwarden-api.git - cd vaultwarden-api - ``` - -2. **Create environment file** - ```bash - cp .env.example .env - ``` - -3. **Configure your `.env` file** - ```bash - # Generate a strong API key - make generate-api-key - - # Edit .env and add: - # - API_KEY (generated above) - # - VAULTWARDEN_URL (your Vaultwarden instance URL) - # - VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET, VAULTWARDEN_PASSWORD - # (from Vaultwarden Account Settings β†’ Security β†’ Keys β†’ View API Key) - # - ALLOWED_IPS (your IP address or CIDR range for security) - ``` - -4. **Run the application** - ```bash - make run - ``` - -### Docker Deployment - -1. **Build the Docker image** - ```bash - make docker-build - ``` - -2. **Run with Docker Compose** - ```bash - make docker-compose-up - ``` - -## API Endpoints - -### Health Check ```bash -curl http://localhost:8080/health -``` -**Response:** -```json -{ - "status": "ok", - "service": "vaultwarden-api" -} +docker run -d \ + --name vaultwarden-api \ + -p 8080:8080 \ + -e VAULTWARDEN_URL=https://vault.yourdomain.com \ + -e VAULTWARDEN_EMAIL=you@example.com \ + -e VAULTWARDEN_PASSWORD=your-master-password \ + -e API_KEY=$(openssl rand -base64 32) \ + ghcr.io/turbootzz/vaultwarden-api:latest ``` -### Get Secret +### 2. Fetch a secret + ```bash curl -H "Authorization: Bearer YOUR_API_KEY" \ http://localhost:8080/secret/DATABASE_URL ``` -**Response:** + ```json { "name": "DATABASE_URL", - "value": "postgresql://user:pass@localhost:5432/db" + "value": "postgresql://user:pass@db:5432/myapp" } ``` -### Refresh Cache -```bash -curl -X POST \ - -H "Authorization: Bearer YOUR_API_KEY" \ - http://localhost:8080/refresh -``` -**Response:** -```json -{ - "status": "ok", - "message": "cache cleared successfully" -} -``` +That's it. Your app reads secrets from the API instead of `.env` files. + +## API Endpoints + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/health` | No | Health check | +| `GET` | `/secret/:name` | API Key | Fetch a secret by name | +| `POST` | `/refresh` | API Key | Force vault re-sync | ## Configuration -All configuration is done via environment variables: - | Variable | Required | Default | Description | |----------|----------|---------|-------------| -| `API_PORT` | No | `8080` | Port the API listens on | -| `ENVIRONMENT` | No | `development` | Environment (development/production) | -| `API_KEY` | **Yes** | - | API key for authentication (min 32 chars) | -| `VAULTWARDEN_URL` | **Yes** | - | Vaultwarden instance URL | -| `VAULTWARDEN_CLIENT_ID` | **Yes*** | - | Vaultwarden API client ID | -| `VAULTWARDEN_CLIENT_SECRET` | **Yes*** | - | Vaultwarden API client secret | -| `VAULTWARDEN_PASSWORD` | **Yes*** | - | Your Vaultwarden master password | -| `VAULTWARDEN_ACCESS_TOKEN` | **Yes*** | - | Alternative: pre-generated session token | -| `ALLOWED_IPS` | No | - | Comma-separated IPs/CIDRs to whitelist | -| `ENABLE_GITHUB_IP_RANGES` | No | `false` | Allow GitHub Actions IPs (updates every 24h) | -| `TRUSTED_PROXY_IP` | No | `localhost` | Trusted reverse proxy IPs/CIDRs | -| `CORS_ALLOWED_ORIGINS` | No | `localhost:3000` | CORS allowed origins | -| `CACHE_TTL` | No | `5m` | Cache duration (0 to disable) | -| `READ_TIMEOUT` | No | `10s` | HTTP read timeout | -| `WRITE_TIMEOUT` | No | `10s` | HTTP write timeout | +| `VAULTWARDEN_URL` | **Yes** | β€” | Your Vaultwarden instance URL | +| `VAULTWARDEN_EMAIL` | **Yes** | β€” | Your Vaultwarden email | +| `VAULTWARDEN_PASSWORD` | **Yes** | β€” | Your master password | +| `API_KEY` | **Yes** | β€” | API key for this service (min 32 chars) | +| `ALLOWED_IPS` | No | (all) | Comma-separated IPs/CIDRs to whitelist | +| `ENABLE_GITHUB_IP_RANGES` | No | `false` | Auto-whitelist GitHub Actions IPs | +| `SYNC_INTERVAL` | No | `5m` | How often to re-sync the vault | +| `CACHE_TTL` | No | `5m` | Secret cache duration | +| `TRUSTED_PROXY_IP` | No | `localhost` | Trusted reverse proxy IPs | +| `ENVIRONMENT` | No | `development` | Set to `production` to hide errors | | `DEBUG` | No | `false` | Enable debug logging | -**\*Note:** Either use `VAULTWARDEN_CLIENT_ID` + `VAULTWARDEN_CLIENT_SECRET` + `VAULTWARDEN_PASSWORD` (recommended) OR `VAULTWARDEN_ACCESS_TOKEN` (will expire). +## Docker Compose -## Getting Vaultwarden API Credentials - -### Recommended Method: API Key Authentication - -1. **Get API Credentials from Vaultwarden Web UI**: - - Log into your Vaultwarden instance - - Go to **Account Settings** β†’ **Security** β†’ **Keys** - - Click **View API Key** - - You'll see: - - `client_id` (starts with `user.`) - - `client_secret` (long random string) - - Add these to your `.env` as `VAULTWARDEN_CLIENT_ID` and `VAULTWARDEN_CLIENT_SECRET` - - Add your master password as `VAULTWARDEN_PASSWORD` - -2. **Benefits**: - - βœ… Bypasses 2FA - - βœ… Automatically logs in on startup - - βœ… Never expires - - βœ… More secure than session tokens - -### Alternative Method: Session Token - -1. **Generate a session token manually**: - ```bash - # Login and unlock your vault - bw config server https://vault.yourdomain.com - bw login - SESSION_TOKEN=$(bw unlock --raw) - echo $SESSION_TOKEN - ``` - -2. **Add to `.env`**: - ```bash - VAULTWARDEN_ACCESS_TOKEN=your-session-token-here - ``` - -3. **Limitations**: - - ⚠️ Tokens expire after inactivity - - ⚠️ Requires manual regeneration - - ⚠️ Not recommended for production - -## IP Whitelisting - -The API supports IP-based access control for enhanced security. - -### Configuration - -**Allow specific IPs:** -```bash -ALLOWED_IPS=203.0.113.50 +```yaml +services: + vaultwarden-api: + image: ghcr.io/turbootzz/vaultwarden-api:latest + container_name: vaultwarden-api + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + environment: + - VAULTWARDEN_URL=${VAULTWARDEN_URL} + - VAULTWARDEN_EMAIL=${VAULTWARDEN_EMAIL} + - VAULTWARDEN_PASSWORD=${VAULTWARDEN_PASSWORD} + - API_KEY=${API_KEY} + - ENVIRONMENT=production + - ALLOWED_IPS=${ALLOWED_IPS} + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"] + interval: 30s + timeout: 3s + retries: 3 ``` -**Allow multiple IPs:** -```bash -ALLOWED_IPS=203.0.113.50,198.51.100.25 -``` - -**Allow CIDR ranges:** -```bash -ALLOWED_IPS=192.168.1.0/24 -``` - -**Mix IPs and CIDRs:** -```bash -ALLOWED_IPS=203.0.113.50,192.168.1.0/24,10.0.0.0/8 -``` - -**Enable GitHub Actions (optional):** -```bash -ENABLE_GITHUB_IP_RANGES=true -``` -This automatically whitelists all GitHub Actions IP ranges and updates them every 24 hours. - -### Trusted Proxies - -If you're running behind a reverse proxy (Nginx Proxy Manager, Traefik, Cloudflare), configure trusted proxies: - -**Single proxy IP:** -```bash -TRUSTED_PROXY_IP=172.18.0.5 -``` - -**Docker network range:** -```bash -TRUSTED_PROXY_IP=172.16.0.0/12 -``` - -**Multiple proxies:** -```bash -TRUSTED_PROXY_IP=172.18.0.5,192.168.1.10 -``` - -**Important Notes:** -- Invalid IPs/CIDRs are logged and ignored -- Duplicates are automatically removed -- The API extracts real client IPs from `X-Forwarded-For` header -- If no whitelist is configured, all IPs are allowed (not recommended for production) - -## Deployment Guide - -### Deploy to Portainer - -1. **Prepare your environment** - - Ensure Vaultwarden is accessible from the Portainer host - - Have your API credentials ready - -2. **Create a new Stack in Portainer** - - Navigate to Stacks β†’ Add Stack - - Name it `vaultwarden-api` - - Paste the contents of `docker-compose.yml` - -3. **Set environment variables in Portainer** - - Click "Add environment variable" - - Add all required variables (see Configuration table) - - **RECOMMENDED**: Add `ALLOWED_IPS` with your IP address - - **IMPORTANT**: Never hardcode secrets in the stack file - -4. **Deploy the stack** - - Click "Deploy the stack" - - Monitor logs for any errors - -5. **Set up reverse proxy** - - Use Nginx Proxy Manager, Traefik, or similar - - Configure HTTPS with Let's Encrypt - - Point to `vaultwarden-api:8080` - -### Deploy Behind Nginx Proxy Manager - -1. **Add a new Proxy Host** - - Domain: `api.yourdomain.com` - - Forward to: `vaultwarden-api:8080` - - Enable "Block Common Exploits" - - Enable "Websockets Support" - -2. **Configure SSL** - - Request a new SSL certificate (Let's Encrypt) - - Force SSL - - Enable HTTP/2 - -3. **Add custom headers** (optional) - ``` - X-Real-IP $remote_addr - X-Forwarded-For $proxy_add_x_forwarded_for - X-Forwarded-Proto $scheme - ``` - -## Security Best Practices - -### For Public Repositories - -- βœ… Use `.env.example` (committed) for documentation -- βœ… Add `.env` to `.gitignore` (already done) -- βœ… Never commit actual credentials -- βœ… Use Portainer's environment variable feature -- βœ… Rotate API keys regularly -- βœ… Use strong, randomly generated API keys - -### For Private Repositories - -- βœ… Still use `.env.example` for documentation -- βœ… Still never commit `.env` files -- βœ… Use secret managers (e.g., Docker secrets, Vault) -- βœ… Implement additional authentication if needed -- βœ… Monitor access logs - -### Production Deployment - -1. **Always use HTTPS** via reverse proxy -2. **Enable IP whitelisting** - Set `ALLOWED_IPS` to your trusted IPs/networks -3. **Configure trusted proxies** - Set `TRUSTED_PROXY_IP` to your reverse proxy range -4. **Restrict CORS** to specific domains via `CORS_ALLOWED_ORIGINS` -5. **Set ENVIRONMENT=production** to hide detailed error messages -6. **Use API key authentication** (not session tokens) for reliability -7. **Enable rate limiting** (already configured: 30 req/min) -8. **Use read-only filesystem** (already in docker-compose) -9. **Run as non-root user** (already in Dockerfile) -10. **Monitor logs** for suspicious activity and blocked IPs -11. **Use Docker secrets** instead of environment variables when possible -12. **Regular security updates** (rebuild images monthly) -13. **Enable DEBUG=false** in production (default) - ## Usage Examples -### From a Shell Script +### Shell / CI Pipeline ```bash -#!/bin/bash - -API_KEY="your-api-key" -API_URL="https://api.yourdomain.com" - -# Fetch database URL -DB_URL=$(curl -s -H "Authorization: Bearer $API_KEY" \ - "$API_URL/secret/DATABASE_URL" | jq -r '.value') - -echo "Database URL: $DB_URL" +DB_URL=$(curl -sf -H "Authorization: Bearer $API_KEY" \ + https://api.yourdomain.com/secret/DATABASE_URL | jq -r '.value') ``` -### From Python +### Python ```python import requests -API_KEY = "your-api-key" -API_URL = "https://api.yourdomain.com" - def get_secret(name): - headers = {"Authorization": f"Bearer {API_KEY}"} - response = requests.get(f"{API_URL}/secret/{name}", headers=headers) - return response.json()["value"] + r = requests.get(f"https://api.yourdomain.com/secret/{name}", + headers={"Authorization": f"Bearer {API_KEY}"}) + return r.json()["value"] -# Usage db_url = get_secret("DATABASE_URL") -print(f"Database URL: {db_url}") ``` -### From Node.js +### Node.js ```javascript -const axios = require('axios'); - -const API_KEY = 'your-api-key'; -const API_URL = 'https://api.yourdomain.com'; - -async function getSecret(name) { - const response = await axios.get(`${API_URL}/secret/${name}`, { - headers: { 'Authorization': `Bearer ${API_KEY}` } - }); - return response.data.value; -} - -// Usage -(async () => { - const dbUrl = await getSecret('DATABASE_URL'); - console.log(`Database URL: ${dbUrl}`); -})(); +const res = await fetch(`https://api.yourdomain.com/secret/DATABASE_URL`, { + headers: { Authorization: `Bearer ${API_KEY}` }, +}); +const { value } = await res.json(); ``` -## Makefile Commands +### Go +```go +req, _ := http.NewRequest("GET", "https://api.yourdomain.com/secret/DATABASE_URL", nil) +req.Header.Set("Authorization", "Bearer "+apiKey) +resp, _ := http.DefaultClient.Do(req) +``` + +### GitHub Actions +```yaml +- name: Fetch secrets + run: | + export DB_URL=$(curl -sf -H "Authorization: Bearer ${{ secrets.VAULT_API_KEY }}" \ + https://api.yourdomain.com/secret/DATABASE_URL | jq -r '.value') +``` + +## Security + +- **API key authentication** with constant-time comparison (timing-attack resistant) +- **IP whitelisting** with CIDR support + optional GitHub Actions IP auto-import +- **Rate limiting** (30 requests/minute per IP) +- **Read-only filesystem** in Docker (only `/tmp` writable) +- **Non-root user** in container +- **No capabilities** (`cap_drop: ALL`) +- **Security headers** via Helmet middleware +- **No secret names in production logs** (only at debug level) +- Secrets are **decrypted in-memory only** β€” never written to disk + +## Project Structure + +``` +β”œβ”€β”€ cmd/api/main.go # Entry point +β”œβ”€β”€ internal/ +β”‚ β”œβ”€β”€ auth/middleware.go # API key authentication +β”‚ β”œβ”€β”€ config/config.go # Configuration +β”‚ β”œβ”€β”€ handlers/handlers.go # HTTP handlers +β”‚ β”œβ”€β”€ ipwhitelist/ipwhitelist.go # IP access control +β”‚ β”œβ”€β”€ validators/validators.go # Input validation +β”‚ └── vaultwarden/ +β”‚ β”œβ”€β”€ api_client.go # Native HTTP client for Vaultwarden +β”‚ β”œβ”€β”€ crypto.go # Bitwarden-compatible encryption +β”‚ β”œβ”€β”€ crypto_test.go # Crypto unit tests +β”‚ β”œβ”€β”€ client.go # Secret lookup + caching +β”‚ └── init.go # Initialization with retry +β”œβ”€β”€ pkg/logger/logger.go # Structured logging +β”œβ”€β”€ Dockerfile # Multi-stage build (~15MB image) +β”œβ”€β”€ docker-compose.yml # Production-ready compose +└── go.mod +``` + +## Building from Source ```bash -make help # Show available commands -make build # Build the binary -make run # Run locally (requires .env) -make dev # Run with hot reload -make test # Run tests -make clean # Clean build artifacts -make docker-build # Build Docker image -make docker-run # Run Docker container -make docker-push # Push to Docker Hub -make generate-api-key # Generate a random API key +# Build +go build -o vaultwarden-api ./cmd/api + +# Test +go test ./... + +# Docker +docker build -t vaultwarden-api . ``` -## Troubleshooting +## How Secrets are Matched -### "Failed to load configuration: API_KEY is required" -- Make sure you have a `.env` file in the project root -- Copy `.env.example` to `.env` and fill in all required values +When you request `/secret/DATABASE_URL`, the API: -### "Secret not found" -- Verify the secret name matches exactly (case-sensitive) -- Check that your Vaultwarden token has access to the vault -- Use the Vaultwarden web UI to confirm the secret exists +1. **Exact match** (case-insensitive) against vault item names +2. **Partial match** if no exact match is found +3. Returns the most relevant value: password β†’ custom field β†’ notes -### "Vaultwarden api returned status 401" -- Your credentials are invalid or expired -- For API key method: Check `VAULTWARDEN_CLIENT_ID`, `VAULTWARDEN_CLIENT_SECRET`, `VAULTWARDEN_PASSWORD` -- For session token: Generate a new `VAULTWARDEN_ACCESS_TOKEN` - -### "IP blocked (not whitelisted)" -- Your IP is not in the `ALLOWED_IPS` list -- Check your current public IP: `curl ifconfig.me` -- Add your IP to `ALLOWED_IPS` in `.env` or Portainer -- If behind a proxy, ensure `TRUSTED_PROXY_IP` is set correctly -- Enable `DEBUG=true` to see which IP is being detected - -### "Failed to initialize Bitwarden CLI" -- Ensure Bitwarden CLI is installed in the Docker container (already done) -- Check that `VAULTWARDEN_URL` is accessible from the container -- Verify your master password is correct -- Check logs for specific error messages - -### Docker build fails -- Ensure you have the latest Go version in Dockerfile -- Run `go mod tidy` before building -- Check for network issues when downloading dependencies - -## Recent Enhancements - -- βœ… **IP Whitelisting** - CIDR support, GitHub Actions IPs, automatic validation -- βœ… **CLI Authentication** - Bitwarden CLI integration with API key support -- βœ… **Trusted Proxies** - Proper client IP detection behind reverse proxies -- βœ… **Input Validation** - Secure secret name validation -- βœ… **Debug Logging** - Optional debug mode for troubleshooting - -## Future Enhancements - -- [ ] Support for multiple Vaultwarden instances -- [ ] JWT-based authentication (in addition to API keys) -- [ ] Token rotation mechanism -- [ ] Metrics and monitoring (Prometheus) -- [ ] Support for secret versioning -- [ ] Webhook notifications for secret changes -- [ ] Redis-based distributed cache -- [ ] Rate limiting per API key -- [ ] Audit logging -- [ ] GraphQL API +This means you can name your Vaultwarden items naturally (e.g., "Database URL") and fetch them with any casing. ## Contributing -Contributions are welcome! Please: - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests if applicable -5. Submit a pull request +Contributions welcome! Fork β†’ branch β†’ PR. ## License -MIT License - see LICENSE file for details - -## Disclaimer - -This is a security-sensitive tool. Please: -- Review the code before deploying to production -- Keep your API keys and tokens secure -- Monitor access logs regularly -- Report security issues privately - - +MIT β€” see [LICENSE](LICENSE) --- -**Made by Thijs Herman** \ No newline at end of file +**Built by [Thijs Herman](https://github.com/Turbootzz)** β€” because `.env` files are so 2020. diff --git a/cmd/api/main.go b/cmd/api/main.go index 4283ec7..c982606 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -24,7 +24,7 @@ import ( ) func main() { - // Load configuration + // Load configuration. cfg, err := config.Load() if err != nil { logger.Error.Fatalf("Failed to load configuration: %v", err) @@ -32,59 +32,57 @@ func main() { logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment) - // Initialize Vaultwarden client - var vaultClient *vaultwarden.Client + // Initialize Vaultwarden client. + email := os.Getenv("VAULTWARDEN_EMAIL") + password := os.Getenv("VAULTWARDEN_PASSWORD") + clientID := os.Getenv("VAULTWARDEN_CLIENT_ID") + clientSecret := os.Getenv("VAULTWARDEN_CLIENT_SECRET") - vaultwardenClientID := os.Getenv("VAULTWARDEN_CLIENT_ID") - vaultwardenClientSecret := os.Getenv("VAULTWARDEN_CLIENT_SECRET") - vaultwardenPassword := os.Getenv("VAULTWARDEN_PASSWORD") - - if vaultwardenClientID != "" && vaultwardenClientSecret != "" && vaultwardenPassword != "" { - logger.Info.Println("Initializing Bitwarden CLI with API key") - sessionToken, err := vaultwarden.InitializeBitwardenCLI(cfg.VaultwardenURL, vaultwardenClientID, vaultwardenClientSecret, vaultwardenPassword) - if err != nil { - logger.Error.Fatalf("Failed to initialize Bitwarden CLI: %v", err) - } - vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, sessionToken, cfg.CacheTTL) - } else if cfg.VaultwardenToken != "" { - logger.Warn.Println("Using provided session token (will expire)") - vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL) - } else { - logger.Error.Fatal("No authentication configured. Set either (VAULTWARDEN_CLIENT_ID + VAULTWARDEN_CLIENT_SECRET + VAULTWARDEN_PASSWORD) or VAULTWARDEN_ACCESS_TOKEN") + if email == "" || password == "" { + logger.Error.Fatal("VAULTWARDEN_EMAIL and VAULTWARDEN_PASSWORD are required") } - // Initialize handlers + syncInterval := parseDurationEnv("SYNC_INTERVAL", "5m") + + vaultClient, err := vaultwarden.InitializeClient( + cfg.VaultwardenURL, + email, + password, + clientID, + clientSecret, + cfg.CacheTTL, + syncInterval, + ) + if err != nil { + logger.Error.Fatalf("Failed to initialize Vaultwarden client: %v", err) + } + + // Initialize handlers. h := handlers.NewHandler(vaultClient) - // Initialize IP whitelist + // Initialize IP whitelist. ipWhitelist, err := ipwhitelist.New(cfg.AllowedIPs, cfg.EnableGitHubIPRanges) if err != nil { logger.Error.Fatalf("Failed to initialize IP whitelist: %v", err) } - // Start periodic GitHub IP range updates (every 24 hours) + // Start periodic GitHub IP range updates. var stopIPUpdate func() if cfg.EnableGitHubIPRanges { stopIPUpdate = ipWhitelist.StartPeriodicUpdate(24 * time.Hour) } - // Create Fiber app with security configurations + // Create Fiber app with security configurations. app := fiber.New(fiber.Config{ - AppName: "Vaultwarden API v1.0", + AppName: "Vaultwarden API v2.0", DisableStartupMessage: false, ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout, - // Disable server header for security - ServerHeader: "", - // Don't expose stack traces in production - ErrorHandler: customErrorHandler(cfg.IsProd()), - // Enable trusted proxy check to prevent IP spoofing + ServerHeader: "", + ErrorHandler: customErrorHandler(cfg.IsProd()), EnableTrustedProxyCheck: true, - // IMPORTANT: Only trust your actual reverse proxy IP - // Default to localhost - update TRUSTED_PROXY_IP env var with your Nginx Proxy Manager IP - TrustedProxies: getTrustedProxies(), - // Use X-Forwarded-For header to get real client IP - ProxyHeader: fiber.HeaderXForwardedFor, + TrustedProxies: getTrustedProxies(), + ProxyHeader: fiber.HeaderXForwardedFor, }) app.Use(helmet.New()) @@ -100,10 +98,10 @@ func main() { AllowCredentials: false, })) - // Public routes (no authentication or IP whitelist required) + // Public routes. app.Get("/health", h.HealthCheck) - // Protected routes (IP whitelist + API key required) + // Protected routes. api := app.Group("/") api.Use(ipWhitelist.Middleware()) api.Use(limiter.New(limiter.Config{ @@ -119,7 +117,7 @@ func main() { api.Get("/secret/:name", h.GetSecret) api.Post("/refresh", h.RefreshCache) - // Graceful shutdown + // Graceful shutdown. go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) @@ -127,7 +125,8 @@ func main() { logger.Info.Println("Shutting down gracefully...") - // Stop IP update goroutine if running + vaultClient.Stop() + if stopIPUpdate != nil { stopIPUpdate() } @@ -137,10 +136,9 @@ func main() { } }() - // Start server + // Start server. addr := fmt.Sprintf(":%s", cfg.Port) if err := app.Listen(addr); err != nil { - // Clean up before exit if stopIPUpdate != nil { stopIPUpdate() } @@ -149,33 +147,40 @@ func main() { } } -// getTrustedProxies returns the list of trusted proxy IPs with validation +// parseDurationEnv reads a duration from an env var with a fallback. +func parseDurationEnv(key, fallback string) time.Duration { + s := os.Getenv(key) + if s == "" { + s = fallback + } + d, err := time.ParseDuration(s) + if err != nil { + return 5 * time.Minute + } + return d +} + +// getTrustedProxies returns the list of trusted proxy IPs. func getTrustedProxies() []string { seen := make(map[string]bool) result := []string{} - // Add defaults for _, ip := range []string{"127.0.0.1", "::1"} { result = append(result, ip) seen[ip] = true } - // Add from environment variable if proxyIP := os.Getenv("TRUSTED_PROXY_IP"); proxyIP != "" { proxies := strings.Split(proxyIP, ",") - for _, proxy := range proxies { trimmed := strings.TrimSpace(proxy) if trimmed == "" || seen[trimmed] { continue } - - // Validate: check if it's a valid IP or CIDR if err := validateIPOrCIDR(trimmed); err != nil { logger.Warn.Printf("Ignoring invalid IP/CIDR in TRUSTED_PROXY_IP: %s (%v)", trimmed, err) continue } - result = append(result, trimmed) seen[trimmed] = true } @@ -184,38 +189,28 @@ func getTrustedProxies() []string { return result } -// validateIPOrCIDR validates if a string is a valid IP address or CIDR range +// validateIPOrCIDR validates an IP or CIDR string. func validateIPOrCIDR(s string) error { - // Check if it's a CIDR range if strings.Contains(s, "/") { _, _, err := net.ParseCIDR(s) - if err != nil { - return fmt.Errorf("invalid CIDR: %w", err) - } - return nil + return err } - - // Check if it's a valid IP if net.ParseIP(s) == nil { return fmt.Errorf("invalid IP address") } - return nil } -// customErrorHandler creates a custom error handler +// customErrorHandler creates a custom error handler. func customErrorHandler(isProd bool) fiber.ErrorHandler { return func(c *fiber.Ctx, err error) error { code := fiber.StatusInternalServerError - if e, ok := err.(*fiber.Error); ok { code = e.Code } - // Log the error logger.Error.Printf("Request error (status %d): %v", code, err) - // Don't expose internal errors in production message := "Internal Server Error" if !isProd { message = err.Error() diff --git a/docker-compose.yml b/docker-compose.yml index 9517752..c96b4aa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: vaultwarden-api: build: @@ -10,37 +8,27 @@ services: ports: - "127.0.0.1:8080:8080" environment: - # Server configuration - API_PORT=8080 - ENVIRONMENT=production - - # Security - REQUIRED - # IMPORTANT: Change these values before deploying! - API_KEY=${API_KEY} - - # Vaultwarden configuration - VAULTWARDEN_URL=${VAULTWARDEN_URL} - - VAULTWARDEN_ACCESS_TOKEN=${VAULTWARDEN_ACCESS_TOKEN} - - # Performance + - VAULTWARDEN_EMAIL=${VAULTWARDEN_EMAIL} + - VAULTWARDEN_PASSWORD=${VAULTWARDEN_PASSWORD} + - ALLOWED_IPS=${ALLOWED_IPS} + - ENABLE_GITHUB_IP_RANGES=${ENABLE_GITHUB_IP_RANGES:-false} + - TRUSTED_PROXY_IP=${TRUSTED_PROXY_IP} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost:3000} + - SYNC_INTERVAL=${SYNC_INTERVAL:-5m} - CACHE_TTL=5m - READ_TIMEOUT=10s - WRITE_TIMEOUT=10s - - # Security: read-only filesystem (except for tmp) read_only: true tmpfs: - /tmp - - # Security: drop all capabilities cap_drop: - ALL - - # Security: prevent privilege escalation security_opt: - no-new-privileges:true - - # Resource limits (adjust based on your needs) deploy: resources: limits: @@ -49,33 +37,14 @@ services: reservations: cpus: '0.25' memory: 64M - - # Health check healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] interval: 30s timeout: 3s retries: 3 - start_period: 5s - - # Logging configuration + start_period: 10s logging: driver: "json-file" options: max-size: "10m" max-file: "3" - - # Network configuration - networks: - - vaultwarden-network - -networks: - vaultwarden-network: - driver: bridge - -# For production deployment with Portainer Stack: -# 1. Copy this file to your Portainer environment -# 2. Set environment variables in Portainer's stack configuration -# 3. Or create a .env file (but NEVER commit it to git!) -# 4. Deploy behind a reverse proxy (Nginx Proxy Manager, Traefik, etc.) -# 5. Ensure HTTPS is enabled on the reverse proxy diff --git a/go.mod b/go.mod index d614bf5..194b4de 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,14 @@ module github.com/thijsherman/vaultwarden-api go 1.25.3 -require github.com/gofiber/fiber/v2 v2.52.9 +require ( + github.com/gofiber/fiber/v2 v2.52.9 + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.48.0 +) require ( github.com/andybalholm/brotli v1.1.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -17,5 +20,5 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index e95f6a6..442c701 100644 --- a/go.sum +++ b/go.sum @@ -25,7 +25,9 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 02eb3c5..d9491b0 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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() diff --git a/internal/vaultwarden/api_client.go b/internal/vaultwarden/api_client.go new file mode 100644 index 0000000..838cde1 --- /dev/null +++ b/internal/vaultwarden/api_client.go @@ -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 +} diff --git a/internal/vaultwarden/client.go b/internal/vaultwarden/client.go index ec7bb6f..7516677 100644 --- a/internal/vaultwarden/client.go +++ b/internal/vaultwarden/client.go @@ -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 "" +} diff --git a/internal/vaultwarden/client_cli.go b/internal/vaultwarden/client_cli.go deleted file mode 100644 index 31e03db..0000000 --- a/internal/vaultwarden/client_cli.go +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/internal/vaultwarden/crypto.go b/internal/vaultwarden/crypto.go new file mode 100644 index 0000000..d9ecff9 --- /dev/null +++ b/internal/vaultwarden/crypto.go @@ -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: ".||" +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 +} diff --git a/internal/vaultwarden/crypto_test.go b/internal/vaultwarden/crypto_test.go new file mode 100644 index 0000000..13e9797 --- /dev/null +++ b/internal/vaultwarden/crypto_test.go @@ -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) + } +} diff --git a/internal/vaultwarden/init.go b/internal/vaultwarden/init.go index b678e32..51f787d 100644 --- a/internal/vaultwarden/init.go +++ b/internal/vaultwarden/init.go @@ -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) }