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

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

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

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

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

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

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

No files matched your search

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