feat: replace Bitwarden CLI with native Go API client (#2)
BREAKING: Removes Node.js/Bitwarden CLI dependency entirely. ## What changed - New native HTTP client talks directly to Vaultwarden API - Bitwarden-compatible crypto in pure Go: - AES-256-CBC + HMAC-SHA256 decryption - PBKDF2-SHA256 and Argon2id key derivation - HKDF key stretching (with legacy fallback) - Auto token refresh (never expires) - Background vault sync (configurable interval, default 5min) - Docker image: ~233MB → ~15MB (pure Alpine, no Node.js) ## New env vars - VAULTWARDEN_EMAIL (replaces CLIENT_ID/CLIENT_SECRET) - SYNC_INTERVAL (default: 5m) ## Removed - internal/vaultwarden/client_cli.go (CLI interaction) - Bitwarden CLI dependency (Node.js + npm package) - VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET env vars ## Added - internal/vaultwarden/crypto.go (encryption/decryption) - internal/vaultwarden/crypto_test.go (11 unit tests) - internal/vaultwarden/api_client.go (HTTP client) ## Unchanged - External API surface (GET /secret/:name, POST /refresh, GET /health) - All security middleware (auth, IP whitelist, rate limiting)
This commit is contained in:
1 parent
e320f95d9f
commit
9f8ebf2408
14 files changed
+1500
-937
No files matched your search
+32
-50
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
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
|
||||
```
|
||||
|
||||
2. **Create environment file**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
### 2. Fetch a secret
|
||||
|
||||
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"
|
||||
}
|
||||
```
|
||||
|
||||
### Get 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
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**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}` }
|
||||
const res = await fetch(`https://api.yourdomain.com/secret/DATABASE_URL`, {
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
});
|
||||
return response.data.value;
|
||||
}
|
||||
|
||||
// Usage
|
||||
(async () => {
|
||||
const dbUrl = await getSecret('DATABASE_URL');
|
||||
console.log(`Database URL: ${dbUrl}`);
|
||||
})();
|
||||
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.
|
||||
+49
-54
@@ -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,58 +32,56 @@ 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 email == "" || password == "" {
|
||||
logger.Error.Fatal("VAULTWARDEN_EMAIL and VAULTWARDEN_PASSWORD are required")
|
||||
}
|
||||
|
||||
if vaultwardenClientID != "" && vaultwardenClientSecret != "" && vaultwardenPassword != "" {
|
||||
logger.Info.Println("Initializing Bitwarden CLI with API key")
|
||||
sessionToken, err := vaultwarden.InitializeBitwardenCLI(cfg.VaultwardenURL, vaultwardenClientID, vaultwardenClientSecret, vaultwardenPassword)
|
||||
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 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")
|
||||
logger.Error.Fatalf("Failed to initialize Vaultwarden client: %v", err)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
// 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
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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 err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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,19 +8,19 @@ 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",
|
||||
@@ -28,7 +28,7 @@ func (h *Handler) HealthCheck(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// GetSecret handles GET /secret/:name
|
||||
// GetSecret handles GET /secret/:name.
|
||||
func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
secretName := c.Params("name")
|
||||
|
||||
@@ -48,20 +48,19 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||
|
||||
value, err := h.vaultClient.GetSecret(secretName)
|
||||
if err != nil {
|
||||
logger.Error.Printf("Failed to fetch secret (requested by IP: %s): %v", c.IP(), err)
|
||||
logger.Error.Printf("Failed to fetch secret (requested by IP: %s)", c.IP())
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||
"error": "secret not found",
|
||||
})
|
||||
}
|
||||
|
||||
// Return the secret value
|
||||
return c.JSON(fiber.Map{
|
||||
"name": secretName,
|
||||
"value": value,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshCache handles POST /refresh
|
||||
// RefreshCache handles POST /refresh.
|
||||
func (h *Handler) RefreshCache(c *fiber.Ctx) error {
|
||||
h.vaultClient.ClearCache()
|
||||
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||
)
|
||||
|
||||
// PreloginResponse contains the KDF parameters from the server.
|
||||
type PreloginResponse struct {
|
||||
KDF int `json:"kdf"`
|
||||
KDFIterations int `json:"kdfIterations"`
|
||||
KDFMemory *int `json:"kdfMemory"`
|
||||
KDFParallelism *int `json:"kdfParallelism"`
|
||||
}
|
||||
|
||||
// TokenResponse is returned by the /identity/connect/token endpoint.
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Key string `json:"Key"`
|
||||
PrivateKey string `json:"PrivateKey"`
|
||||
}
|
||||
|
||||
// SyncResponse contains the full vault sync data.
|
||||
type SyncResponse struct {
|
||||
Profile SyncProfile `json:"profile"`
|
||||
Ciphers []SyncCipher `json:"ciphers"`
|
||||
}
|
||||
|
||||
// SyncProfile contains user profile info.
|
||||
type SyncProfile struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// SyncCipher represents an encrypted vault item from the sync response.
|
||||
type SyncCipher struct {
|
||||
ID string `json:"id"`
|
||||
Type int `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Login *SyncLogin `json:"login"`
|
||||
Card *SyncCard `json:"card"`
|
||||
Fields []SyncField `json:"fields"`
|
||||
}
|
||||
|
||||
// SyncLogin contains encrypted login data.
|
||||
type SyncLogin struct {
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
URI *string `json:"uri"`
|
||||
URIs []struct {
|
||||
URI *string `json:"uri"`
|
||||
} `json:"uris"`
|
||||
}
|
||||
|
||||
// SyncCard contains encrypted card data.
|
||||
type SyncCard struct {
|
||||
CardholderName *string `json:"cardholderName"`
|
||||
Number *string `json:"number"`
|
||||
Code *string `json:"code"`
|
||||
}
|
||||
|
||||
// SyncField contains encrypted custom field data.
|
||||
type SyncField struct {
|
||||
Name *string `json:"name"`
|
||||
Value *string `json:"value"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
// Bitwarden cipher types.
|
||||
const (
|
||||
CipherTypeLogin = 1
|
||||
CipherTypeSecureNote = 2
|
||||
CipherTypeCard = 3
|
||||
CipherTypeIdentity = 4
|
||||
)
|
||||
|
||||
// APIClient communicates directly with the Vaultwarden HTTP API.
|
||||
type APIClient struct {
|
||||
baseURL string
|
||||
email string
|
||||
password string
|
||||
clientID string // Optional: for API key login (bypasses 2FA)
|
||||
clientSecret string // Optional: for API key login (bypasses 2FA)
|
||||
httpClient *http.Client
|
||||
deviceID string
|
||||
|
||||
mu sync.RWMutex
|
||||
accessToken string
|
||||
refreshToken string
|
||||
tokenExpiry time.Time
|
||||
symKey SymmetricKey
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new Vaultwarden API client.
|
||||
// clientID and clientSecret are optional — if provided, API key login is used (bypasses 2FA).
|
||||
func NewAPIClient(baseURL, email, password, clientID, clientSecret string) *APIClient {
|
||||
return &APIClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
email: email,
|
||||
password: password,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
deviceID: uuid.New().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate performs the full login flow.
|
||||
// If API key credentials are set, uses client_credentials grant (bypasses 2FA).
|
||||
// Otherwise, uses password grant (requires 2FA to be disabled or handled).
|
||||
func (ac *APIClient) Authenticate() error {
|
||||
// Step 1: Get KDF parameters.
|
||||
prelogin, err := ac.prelogin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("prelogin: %w", err)
|
||||
}
|
||||
|
||||
logger.Info.Printf("KDF type: %d, iterations: %d", prelogin.KDF, prelogin.KDFIterations)
|
||||
|
||||
// Step 2: Derive master key (always needed for decryption).
|
||||
masterKey, err := MakeMasterKey(ac.password, ac.email, prelogin.KDF, prelogin.KDFIterations, prelogin.KDFMemory, prelogin.KDFParallelism)
|
||||
if err != nil {
|
||||
return fmt.Errorf("derive master key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Login.
|
||||
var tokenResp *TokenResponse
|
||||
if ac.clientID != "" && ac.clientSecret != "" {
|
||||
// API key login — bypasses 2FA.
|
||||
logger.Info.Println("Using API key authentication (2FA bypass)")
|
||||
tokenResp, err = ac.loginWithAPIKey()
|
||||
} else {
|
||||
// Password login — requires no 2FA or 2FA handling.
|
||||
hashedPassword := HashPassword(ac.password, masterKey)
|
||||
tokenResp, err = ac.loginWithPassword(hashedPassword)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Get the encrypted symmetric key.
|
||||
// API key login doesn't return the Key in the token response,
|
||||
// so we get it from the sync/profile endpoint.
|
||||
encryptedKey := tokenResp.Key
|
||||
if encryptedKey == "" {
|
||||
// Fetch from sync profile.
|
||||
ac.mu.Lock()
|
||||
ac.accessToken = tokenResp.AccessToken
|
||||
ac.refreshToken = tokenResp.RefreshToken
|
||||
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
ac.mu.Unlock()
|
||||
|
||||
encryptedKey, err = ac.fetchProfileKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch profile key: %w", err)
|
||||
}
|
||||
} else {
|
||||
ac.mu.Lock()
|
||||
ac.accessToken = tokenResp.AccessToken
|
||||
ac.refreshToken = tokenResp.RefreshToken
|
||||
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
ac.mu.Unlock()
|
||||
}
|
||||
|
||||
// Step 5: Decrypt the symmetric key.
|
||||
symKey, err := DecryptSymmetricKey(encryptedKey, masterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt symmetric key: %w", err)
|
||||
}
|
||||
|
||||
ac.mu.Lock()
|
||||
ac.symKey = symKey
|
||||
ac.mu.Unlock()
|
||||
|
||||
logger.Info.Println("Authentication successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshAccessToken uses the refresh token to get a new access token.
|
||||
func (ac *APIClient) RefreshAccessToken() error {
|
||||
ac.mu.RLock()
|
||||
rt := ac.refreshToken
|
||||
ac.mu.RUnlock()
|
||||
|
||||
if rt == "" {
|
||||
return fmt.Errorf("no refresh token available, re-authentication required")
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {rt},
|
||||
"client_id": {"web"},
|
||||
}
|
||||
|
||||
resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return fmt.Errorf("decode refresh response: %w", err)
|
||||
}
|
||||
|
||||
ac.mu.Lock()
|
||||
ac.accessToken = tokenResp.AccessToken
|
||||
if tokenResp.RefreshToken != "" {
|
||||
ac.refreshToken = tokenResp.RefreshToken
|
||||
}
|
||||
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||
ac.mu.Unlock()
|
||||
|
||||
logger.Debug.Println("Token refreshed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureValidToken refreshes the access token if it's expired or about to expire.
|
||||
func (ac *APIClient) EnsureValidToken() error {
|
||||
ac.mu.RLock()
|
||||
expiry := ac.tokenExpiry
|
||||
ac.mu.RUnlock()
|
||||
|
||||
// Refresh 60 seconds before actual expiry.
|
||||
if time.Now().After(expiry.Add(-60 * time.Second)) {
|
||||
logger.Debug.Println("Token expiring soon, refreshing...")
|
||||
if err := ac.RefreshAccessToken(); err != nil {
|
||||
// If refresh fails, try full re-authentication.
|
||||
logger.Warn.Println("Token refresh failed, attempting full re-authentication")
|
||||
return ac.Authenticate()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync fetches and decrypts all vault items.
|
||||
func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
||||
if err := ac.EnsureValidToken(); err != nil {
|
||||
return nil, fmt.Errorf("ensure valid token: %w", err)
|
||||
}
|
||||
|
||||
ac.mu.RLock()
|
||||
token := ac.accessToken
|
||||
key := ac.symKey
|
||||
ac.mu.RUnlock()
|
||||
|
||||
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create sync request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := ac.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// Token might be invalid, try to refresh and retry once.
|
||||
if err := ac.RefreshAccessToken(); err != nil {
|
||||
return nil, fmt.Errorf("sync auth failed, refresh failed: %w", err)
|
||||
}
|
||||
ac.mu.RLock()
|
||||
token = ac.accessToken
|
||||
ac.mu.RUnlock()
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = ac.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sync retry: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var syncResp SyncResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
|
||||
return nil, fmt.Errorf("decode sync response: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt all ciphers.
|
||||
items := make([]DecryptedItem, 0, len(syncResp.Ciphers))
|
||||
for _, c := range syncResp.Ciphers {
|
||||
item, err := decryptCipher(c, key)
|
||||
if err != nil {
|
||||
logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err)
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
logger.Info.Printf("Synced and decrypted %d vault items", len(items))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// DecryptedItem is a decrypted vault item ready for cache lookup.
|
||||
type DecryptedItem struct {
|
||||
ID string
|
||||
Type int
|
||||
Name string
|
||||
Username string
|
||||
Password string
|
||||
Notes string
|
||||
URI string
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// decryptCipher decrypts a single vault cipher into a DecryptedItem.
|
||||
func decryptCipher(c SyncCipher, key SymmetricKey) (DecryptedItem, error) {
|
||||
item := DecryptedItem{
|
||||
ID: c.ID,
|
||||
Type: c.Type,
|
||||
Fields: make(map[string]string),
|
||||
}
|
||||
|
||||
var err error
|
||||
item.Name, err = DecryptStr(c.Name, key)
|
||||
if err != nil {
|
||||
return item, fmt.Errorf("decrypt name: %w", err)
|
||||
}
|
||||
|
||||
if c.Notes != nil {
|
||||
item.Notes, _ = DecryptStr(*c.Notes, key)
|
||||
}
|
||||
|
||||
if c.Login != nil {
|
||||
if c.Login.Username != nil {
|
||||
item.Username, _ = DecryptStr(*c.Login.Username, key)
|
||||
}
|
||||
if c.Login.Password != nil {
|
||||
item.Password, _ = DecryptStr(*c.Login.Password, key)
|
||||
}
|
||||
if c.Login.URI != nil {
|
||||
item.URI, _ = DecryptStr(*c.Login.URI, key)
|
||||
}
|
||||
if item.URI == "" && len(c.Login.URIs) > 0 && c.Login.URIs[0].URI != nil {
|
||||
item.URI, _ = DecryptStr(*c.Login.URIs[0].URI, key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range c.Fields {
|
||||
var name, value string
|
||||
if f.Name != nil {
|
||||
name, _ = DecryptStr(*f.Name, key)
|
||||
}
|
||||
if f.Value != nil {
|
||||
value, _ = DecryptStr(*f.Value, key)
|
||||
}
|
||||
if name != "" {
|
||||
item.Fields[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// prelogin fetches KDF parameters for the given email.
|
||||
func (ac *APIClient) prelogin() (*PreloginResponse, error) {
|
||||
body := fmt.Sprintf(`{"email":"%s"}`, ac.email)
|
||||
resp, err := ac.httpClient.Post(
|
||||
ac.baseURL+"/identity/accounts/prelogin",
|
||||
"application/json",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prelogin request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("prelogin failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result PreloginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode prelogin: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// loginWithPassword authenticates with email + hashed password (requires no 2FA or 2FA handling).
|
||||
func (ac *APIClient) loginWithPassword(hashedPassword string) (*TokenResponse, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"password"},
|
||||
"username": {ac.email},
|
||||
"password": {hashedPassword},
|
||||
"scope": {"api offline_access"},
|
||||
"client_id": {"web"},
|
||||
"deviceType": {"10"},
|
||||
"deviceIdentifier": {ac.deviceID},
|
||||
"deviceName": {"vaultwarden-api"},
|
||||
}
|
||||
|
||||
return ac.doTokenRequest(data)
|
||||
}
|
||||
|
||||
// loginWithAPIKey authenticates with API key (client_credentials). Bypasses 2FA.
|
||||
func (ac *APIClient) loginWithAPIKey() (*TokenResponse, error) {
|
||||
data := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {ac.clientID},
|
||||
"client_secret": {ac.clientSecret},
|
||||
"scope": {"api"},
|
||||
"deviceType": {"10"},
|
||||
"deviceIdentifier": {ac.deviceID},
|
||||
"deviceName": {"vaultwarden-api"},
|
||||
}
|
||||
|
||||
return ac.doTokenRequest(data)
|
||||
}
|
||||
|
||||
// doTokenRequest sends a token request and parses the response.
|
||||
func (ac *APIClient) doTokenRequest(data url.Values) (*TokenResponse, error) {
|
||||
resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("decode login response: %w", err)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// fetchProfileKey gets the encrypted symmetric key from the user's profile.
|
||||
// Used when API key login doesn't return the Key in the token response.
|
||||
func (ac *APIClient) fetchProfileKey() (string, error) {
|
||||
ac.mu.RLock()
|
||||
token := ac.accessToken
|
||||
ac.mu.RUnlock()
|
||||
|
||||
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create sync request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := ac.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sync request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var syncResp SyncResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
|
||||
return "", fmt.Errorf("decode sync: %w", err)
|
||||
}
|
||||
|
||||
if syncResp.Profile.Key == "" {
|
||||
return "", fmt.Errorf("profile key is empty")
|
||||
}
|
||||
|
||||
return syncResp.Profile.Key, nil
|
||||
}
|
||||
+113
-100
@@ -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
|
||||
|
||||
// secretCache provides a simple in-memory cache with TTL
|
||||
type secretCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]*cacheItem
|
||||
ttl time.Duration
|
||||
enabled bool
|
||||
}
|
||||
items map[string]DecryptedItem // keyed by lowercase name
|
||||
byID map[string]DecryptedItem // keyed by ID
|
||||
|
||||
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)
|
||||
stopSync chan struct{}
|
||||
}
|
||||
|
||||
// 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
|
||||
return "", fmt.Errorf("secret not found")
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
if c.cache.enabled {
|
||||
c.cache.set(name, value)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// Stop stops the background sync goroutine.
|
||||
func (c *Client) Stop() {
|
||||
close(c.stopSync)
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(item.expiresAt) {
|
||||
return "", false
|
||||
// syncVault fetches and decrypts all items from the vault.
|
||||
func (c *Client) syncVault() error {
|
||||
items, err := c.api.Sync()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return item.value, true
|
||||
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
|
||||
}
|
||||
|
||||
func (sc *secretCache) set(key, value string) {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
c.mu.Lock()
|
||||
c.items = newItems
|
||||
c.byID = newByID
|
||||
c.mu.Unlock()
|
||||
|
||||
sc.items[key] = &cacheItem{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(sc.ttl),
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeExpired removes all expired items from the cache
|
||||
func (sc *secretCache) removeExpired() {
|
||||
sc.mu.Lock()
|
||||
defer sc.mu.Unlock()
|
||||
// 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
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for key, item := range sc.items {
|
||||
if now.After(item.expiresAt) {
|
||||
delete(sc.items, key)
|
||||
// Check custom fields by priority.
|
||||
for _, name := range []string{"value", "secret", "api_key", "apikey", "token"} {
|
||||
if v, ok := item.Fields[name]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
if item.Notes != "" {
|
||||
return item.Notes
|
||||
}
|
||||
|
||||
// Return first non-empty field value.
|
||||
for _, v := range item.Fields {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// Package vaultwarden provides CLI-based secret retrieval
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||
)
|
||||
|
||||
type BitwardenItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Login *struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
} `json:"login,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
} `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) FetchSecretViaCLI(name string) (string, error) {
|
||||
if len(name) == 0 || len(name) > 255 {
|
||||
return "", fmt.Errorf("invalid secret name length")
|
||||
}
|
||||
|
||||
for _, ch := range name {
|
||||
if !((ch >= 'a' && ch <= 'z') ||
|
||||
(ch >= 'A' && ch <= 'Z') ||
|
||||
(ch >= '0' && ch <= '9') ||
|
||||
ch == '-' || ch == '_' || ch == '.' || ch == '/') {
|
||||
return "", fmt.Errorf("invalid character in secret name")
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("bw", "get", "item", name)
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
|
||||
output, err := cmd.Output()
|
||||
|
||||
if err != nil {
|
||||
logger.Info.Printf("Exact match failed, searching for: %s", name)
|
||||
|
||||
searchCmd := exec.Command("bw", "list", "items", "--search", name)
|
||||
searchCmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
|
||||
searchOutput, searchErr := searchCmd.Output()
|
||||
if searchErr != nil {
|
||||
return "", fmt.Errorf("failed to search for item: %w", searchErr)
|
||||
}
|
||||
|
||||
var items []BitwardenItem
|
||||
if err := json.Unmarshal(searchOutput, &items); err != nil {
|
||||
return "", fmt.Errorf("failed to parse search results: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
return "", fmt.Errorf("secret not found: %s", name)
|
||||
}
|
||||
|
||||
if len(items) > 1 {
|
||||
logger.Warn.Printf("Multiple items found for '%s', using first match: '%s' (ID: %s)", name, items[0].Name, items[0].ID)
|
||||
}
|
||||
|
||||
output, err = json.Marshal(items[0])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal item: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var item BitwardenItem
|
||||
if err := json.Unmarshal(output, &item); err != nil {
|
||||
return "", fmt.Errorf("failed to parse item: %w", err)
|
||||
}
|
||||
|
||||
return c.extractValueFromItem(item)
|
||||
}
|
||||
|
||||
func (c *Client) extractValueFromItem(item BitwardenItem) (string, error) {
|
||||
if item.Login != nil && item.Login.Password != "" {
|
||||
return item.Login.Password, nil
|
||||
}
|
||||
|
||||
for _, field := range item.Fields {
|
||||
fieldName := strings.ToLower(field.Name)
|
||||
if fieldName == "value" || fieldName == "secret" || fieldName == "api_key" {
|
||||
return field.Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
if item.Notes != "" {
|
||||
return item.Notes, nil
|
||||
}
|
||||
|
||||
if len(item.Fields) > 0 {
|
||||
return item.Fields[0].Value, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no secret value found in item")
|
||||
}
|
||||
|
||||
func (c *Client) SyncVault() error {
|
||||
cmd := exec.Command("bw", "sync")
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync vault: %w, output: %s", err, output)
|
||||
}
|
||||
logger.Info.Println("Vault synced successfully")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package vaultwarden provides native Bitwarden-compatible encryption and decryption.
|
||||
// This replaces the Bitwarden CLI dependency with pure Go crypto.
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// Encryption types as defined by the Bitwarden protocol.
|
||||
const (
|
||||
EncTypeAesCbc256_B64 = 0
|
||||
EncTypeAesCbc128_HmacSha256_B64 = 1
|
||||
EncTypeAesCbc256_HmacSha256_B64 = 2
|
||||
EncTypeRsa2048_OaepSha256_B64 = 3
|
||||
EncTypeRsa2048_OaepSha1_B64 = 4
|
||||
)
|
||||
|
||||
// KDF types.
|
||||
const (
|
||||
KdfPBKDF2 = 0
|
||||
KdfArgon2id = 1
|
||||
)
|
||||
|
||||
// SymmetricKey holds the encryption and MAC keys for AES-CBC + HMAC-SHA256.
|
||||
type SymmetricKey struct {
|
||||
EncKey []byte // 32 bytes for AES-256
|
||||
MacKey []byte // 32 bytes for HMAC-SHA256
|
||||
}
|
||||
|
||||
// CipherString represents an encrypted Bitwarden value.
|
||||
// Format: "<encType>.<iv>|<ciphertext>|<mac>"
|
||||
type CipherString struct {
|
||||
Type int
|
||||
IV []byte
|
||||
CT []byte
|
||||
MAC []byte
|
||||
}
|
||||
|
||||
// ParseCipherString parses a Bitwarden encrypted string.
|
||||
func ParseCipherString(s string) (*CipherString, error) {
|
||||
if s == "" {
|
||||
return nil, errors.New("empty cipher string")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(s, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid cipher string: missing type separator")
|
||||
}
|
||||
|
||||
encType, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid encryption type: %w", err)
|
||||
}
|
||||
|
||||
cs := &CipherString{Type: encType}
|
||||
pieces := strings.Split(parts[1], "|")
|
||||
|
||||
switch encType {
|
||||
case EncTypeAesCbc256_B64:
|
||||
if len(pieces) != 2 {
|
||||
return nil, fmt.Errorf("AesCbc256_B64 expects 2 parts, got %d", len(pieces))
|
||||
}
|
||||
if cs.IV, err = base64.StdEncoding.DecodeString(pieces[0]); err != nil {
|
||||
return nil, fmt.Errorf("invalid IV: %w", err)
|
||||
}
|
||||
if cs.CT, err = base64.StdEncoding.DecodeString(pieces[1]); err != nil {
|
||||
return nil, fmt.Errorf("invalid ciphertext: %w", err)
|
||||
}
|
||||
|
||||
case EncTypeAesCbc256_HmacSha256_B64:
|
||||
if len(pieces) != 3 {
|
||||
return nil, fmt.Errorf("AesCbc256_HmacSha256_B64 expects 3 parts, got %d", len(pieces))
|
||||
}
|
||||
if cs.IV, err = base64.StdEncoding.DecodeString(pieces[0]); err != nil {
|
||||
return nil, fmt.Errorf("invalid IV: %w", err)
|
||||
}
|
||||
if cs.CT, err = base64.StdEncoding.DecodeString(pieces[1]); err != nil {
|
||||
return nil, fmt.Errorf("invalid ciphertext: %w", err)
|
||||
}
|
||||
if cs.MAC, err = base64.StdEncoding.DecodeString(pieces[2]); err != nil {
|
||||
return nil, fmt.Errorf("invalid MAC: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported encryption type: %d", encType)
|
||||
}
|
||||
|
||||
return cs, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a CipherString using the provided symmetric key.
|
||||
func (cs *CipherString) Decrypt(key SymmetricKey) ([]byte, error) {
|
||||
if len(cs.IV) != aes.BlockSize {
|
||||
return nil, fmt.Errorf("invalid IV length: %d", len(cs.IV))
|
||||
}
|
||||
if len(cs.CT) == 0 || len(cs.CT)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("invalid ciphertext length: %d", len(cs.CT))
|
||||
}
|
||||
|
||||
// Verify MAC if present (type 2).
|
||||
if cs.Type == EncTypeAesCbc256_HmacSha256_B64 {
|
||||
if len(key.MacKey) == 0 {
|
||||
return nil, errors.New("MAC key required for type 2 decryption")
|
||||
}
|
||||
mac := hmac.New(sha256.New, key.MacKey)
|
||||
mac.Write(cs.IV)
|
||||
mac.Write(cs.CT)
|
||||
expectedMAC := mac.Sum(nil)
|
||||
if !hmac.Equal(expectedMAC, cs.MAC) {
|
||||
return nil, errors.New("MAC verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
// AES-CBC decrypt.
|
||||
block, err := aes.NewCipher(key.EncKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aes cipher: %w", err)
|
||||
}
|
||||
|
||||
plaintext := make([]byte, len(cs.CT))
|
||||
mode := cipher.NewCBCDecrypter(block, cs.IV)
|
||||
mode.CryptBlocks(plaintext, cs.CT)
|
||||
|
||||
// Remove PKCS7 padding.
|
||||
plaintext, err = pkcs7Unpad(plaintext, aes.BlockSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pkcs7 unpad: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// DecryptToString decrypts and returns the plaintext as a string.
|
||||
func (cs *CipherString) DecryptToString(key SymmetricKey) (string, error) {
|
||||
b, err := cs.Decrypt(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// DecryptStr is a convenience function to parse and decrypt a cipher string in one call.
|
||||
func DecryptStr(s string, key SymmetricKey) (string, error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
cs, err := ParseCipherString(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cs.DecryptToString(key)
|
||||
}
|
||||
|
||||
// MakeMasterKey derives the master key from the user's password and email.
|
||||
func MakeMasterKey(password, email string, kdfType, iterations int, memory, parallelism *int) ([]byte, error) {
|
||||
salt := []byte(strings.ToLower(strings.TrimSpace(email)))
|
||||
|
||||
switch kdfType {
|
||||
case KdfPBKDF2:
|
||||
if iterations < 1 {
|
||||
return nil, fmt.Errorf("PBKDF2 iterations must be >= 1, got %d", iterations)
|
||||
}
|
||||
return pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New), nil
|
||||
|
||||
case KdfArgon2id:
|
||||
mem := 64 * 1024 // default 64 MiB
|
||||
par := 4 // default parallelism
|
||||
if memory != nil {
|
||||
mem = *memory * 1024 // API returns MiB, argon2 wants KiB
|
||||
}
|
||||
if parallelism != nil {
|
||||
par = *parallelism
|
||||
}
|
||||
return argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(mem), uint8(par), 32), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported KDF type: %d", kdfType)
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword creates the password hash for authentication.
|
||||
// hash = Base64(PBKDF2(masterKey, password, 1, 32, SHA256))
|
||||
func HashPassword(password string, masterKey []byte) string {
|
||||
hash := pbkdf2.Key(masterKey, []byte(password), 1, 32, sha256.New)
|
||||
return base64.StdEncoding.EncodeToString(hash)
|
||||
}
|
||||
|
||||
// StretchKey expands a 32-byte master key into a 64-byte stretched key
|
||||
// using HKDF-Expand with SHA-256. Returns (encKey, macKey).
|
||||
func StretchKey(masterKey []byte) (SymmetricKey, error) {
|
||||
encKey := make([]byte, 32)
|
||||
r := hkdf.Expand(sha256.New, masterKey, []byte("enc"))
|
||||
if _, err := io.ReadFull(r, encKey); err != nil {
|
||||
return SymmetricKey{}, fmt.Errorf("hkdf expand enc: %w", err)
|
||||
}
|
||||
|
||||
macKey := make([]byte, 32)
|
||||
r = hkdf.Expand(sha256.New, masterKey, []byte("mac"))
|
||||
if _, err := io.ReadFull(r, macKey); err != nil {
|
||||
return SymmetricKey{}, fmt.Errorf("hkdf expand mac: %w", err)
|
||||
}
|
||||
|
||||
return SymmetricKey{EncKey: encKey, MacKey: macKey}, nil
|
||||
}
|
||||
|
||||
// DecryptSymmetricKey decrypts the user's encrypted symmetric key from the login response.
|
||||
// It tries HKDF-stretched key first, then falls back to legacy (unstretched) key.
|
||||
func DecryptSymmetricKey(encryptedKey string, masterKey []byte) (SymmetricKey, error) {
|
||||
cs, err := ParseCipherString(encryptedKey)
|
||||
if err != nil {
|
||||
return SymmetricKey{}, fmt.Errorf("parse encrypted key: %w", err)
|
||||
}
|
||||
|
||||
// Try modern approach: HKDF-stretched key.
|
||||
stretched, err := StretchKey(masterKey)
|
||||
if err != nil {
|
||||
return SymmetricKey{}, fmt.Errorf("stretch key: %w", err)
|
||||
}
|
||||
|
||||
decrypted, err := cs.Decrypt(stretched)
|
||||
if err != nil {
|
||||
// Fallback: legacy mode (master key used directly as enc key, no MAC).
|
||||
legacy := SymmetricKey{EncKey: masterKey}
|
||||
decrypted, err = cs.Decrypt(legacy)
|
||||
if err != nil {
|
||||
return SymmetricKey{}, fmt.Errorf("decrypt symmetric key (tried stretched + legacy): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The decrypted key is 64 bytes: first 32 = encKey, last 32 = macKey.
|
||||
if len(decrypted) != 64 {
|
||||
return SymmetricKey{}, fmt.Errorf("unexpected symmetric key length: %d (expected 64)", len(decrypted))
|
||||
}
|
||||
|
||||
return SymmetricKey{
|
||||
EncKey: decrypted[:32],
|
||||
MacKey: decrypted[32:],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pkcs7Unpad removes PKCS#7 padding.
|
||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("empty data")
|
||||
}
|
||||
if len(data)%blockSize != 0 {
|
||||
return nil, errors.New("data not block-aligned")
|
||||
}
|
||||
|
||||
padLen := int(data[len(data)-1])
|
||||
if padLen == 0 || padLen > blockSize {
|
||||
return nil, fmt.Errorf("invalid padding length: %d", padLen)
|
||||
}
|
||||
|
||||
for i := len(data) - padLen; i < len(data); i++ {
|
||||
if data[i] != byte(padLen) {
|
||||
return nil, errors.New("invalid PKCS7 padding")
|
||||
}
|
||||
}
|
||||
|
||||
return data[:len(data)-padLen], nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package vaultwarden
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
func TestParseCipherString_Type2(t *testing.T) {
|
||||
// Build a known cipher string of type 2 (AES-CBC-256 + HMAC-SHA256).
|
||||
iv := make([]byte, 16)
|
||||
ct := make([]byte, 32) // 2 blocks
|
||||
mac := make([]byte, 32)
|
||||
for i := range iv {
|
||||
iv[i] = byte(i)
|
||||
}
|
||||
for i := range ct {
|
||||
ct[i] = byte(i + 16)
|
||||
}
|
||||
for i := range mac {
|
||||
mac[i] = byte(i + 48)
|
||||
}
|
||||
|
||||
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||
base64.StdEncoding.EncodeToString(ct) + "|" +
|
||||
base64.StdEncoding.EncodeToString(mac)
|
||||
|
||||
cs, err := ParseCipherString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCipherString failed: %v", err)
|
||||
}
|
||||
if cs.Type != 2 {
|
||||
t.Errorf("expected type 2, got %d", cs.Type)
|
||||
}
|
||||
if len(cs.IV) != 16 {
|
||||
t.Errorf("expected IV length 16, got %d", len(cs.IV))
|
||||
}
|
||||
if len(cs.CT) != 32 {
|
||||
t.Errorf("expected CT length 32, got %d", len(cs.CT))
|
||||
}
|
||||
if len(cs.MAC) != 32 {
|
||||
t.Errorf("expected MAC length 32, got %d", len(cs.MAC))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCipherString_Type0(t *testing.T) {
|
||||
iv := make([]byte, 16)
|
||||
ct := make([]byte, 32)
|
||||
s := "0." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||
base64.StdEncoding.EncodeToString(ct)
|
||||
|
||||
cs, err := ParseCipherString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCipherString failed: %v", err)
|
||||
}
|
||||
if cs.Type != 0 {
|
||||
t.Errorf("expected type 0, got %d", cs.Type)
|
||||
}
|
||||
if cs.MAC != nil {
|
||||
t.Error("expected nil MAC for type 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCipherString_Invalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"no type", "abcdef"},
|
||||
{"bad type", "x.abc|def|ghi"},
|
||||
{"type 2 missing mac", "2.abc|def"},
|
||||
{"type 0 extra parts", "0.abc|def|ghi"},
|
||||
{"unsupported type", "5.abc|def"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseCipherString(tt.input)
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCS7Unpad(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []byte
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "1 byte padding",
|
||||
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x01},
|
||||
want: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F},
|
||||
},
|
||||
{
|
||||
name: "full block padding",
|
||||
input: append(make([]byte, 0), 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10),
|
||||
want: []byte{},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: []byte{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "zero padding byte",
|
||||
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x00},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := pkcs7Unpad(tt.input, 16)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("length mismatch: got %d, want %d", len(got), len(tt.want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_Type2_RoundTrip(t *testing.T) {
|
||||
// Create a known key, encrypt some data, then decrypt it.
|
||||
encKey := make([]byte, 32)
|
||||
macKey := make([]byte, 32)
|
||||
for i := range encKey {
|
||||
encKey[i] = byte(i)
|
||||
}
|
||||
for i := range macKey {
|
||||
macKey[i] = byte(i + 32)
|
||||
}
|
||||
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
|
||||
|
||||
plaintext := []byte("Hello, Vaultwarden!")
|
||||
|
||||
// Pad the plaintext with PKCS7.
|
||||
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
||||
padded := make([]byte, len(plaintext)+padLen)
|
||||
copy(padded, plaintext)
|
||||
for i := len(plaintext); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
|
||||
// Encrypt with AES-CBC.
|
||||
iv := make([]byte, 16)
|
||||
for i := range iv {
|
||||
iv[i] = byte(i + 100)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(encKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ct := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
|
||||
|
||||
// Compute MAC.
|
||||
mac := hmac.New(sha256.New, macKey)
|
||||
mac.Write(iv)
|
||||
mac.Write(ct)
|
||||
macBytes := mac.Sum(nil)
|
||||
|
||||
// Build cipher string.
|
||||
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||
base64.StdEncoding.EncodeToString(ct) + "|" +
|
||||
base64.StdEncoding.EncodeToString(macBytes)
|
||||
|
||||
cs, err := ParseCipherString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
result, err := cs.DecryptToString(key)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
|
||||
if result != "Hello, Vaultwarden!" {
|
||||
t.Errorf("got %q, want %q", result, "Hello, Vaultwarden!")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_MACVerificationFails(t *testing.T) {
|
||||
encKey := make([]byte, 32)
|
||||
macKey := make([]byte, 32)
|
||||
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
|
||||
|
||||
// Create a valid ciphertext but with a wrong MAC.
|
||||
iv := make([]byte, 16)
|
||||
ct := make([]byte, 16)
|
||||
wrongMAC := make([]byte, 32)
|
||||
for i := range wrongMAC {
|
||||
wrongMAC[i] = 0xFF
|
||||
}
|
||||
|
||||
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||
base64.StdEncoding.EncodeToString(ct) + "|" +
|
||||
base64.StdEncoding.EncodeToString(wrongMAC)
|
||||
|
||||
cs, err := ParseCipherString(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = cs.Decrypt(key)
|
||||
if err == nil {
|
||||
t.Error("expected MAC verification error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MAC verification failed") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMasterKey_PBKDF2(t *testing.T) {
|
||||
key, err := MakeMasterKey("password123", "user@example.com", KdfPBKDF2, 600000, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("MakeMasterKey failed: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32 bytes, got %d", len(key))
|
||||
}
|
||||
|
||||
// Verify it matches raw PBKDF2.
|
||||
expected := pbkdf2.Key([]byte("password123"), []byte("user@example.com"), 600000, 32, sha256.New)
|
||||
for i := range key {
|
||||
if key[i] != expected[i] {
|
||||
t.Fatalf("key mismatch at byte %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeMasterKey_Argon2id(t *testing.T) {
|
||||
mem := 64
|
||||
par := 4
|
||||
key, err := MakeMasterKey("password123", "user@example.com", KdfArgon2id, 3, &mem, &par)
|
||||
if err != nil {
|
||||
t.Fatalf("MakeMasterKey failed: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32 bytes, got %d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
|
||||
hash := HashPassword("password123", masterKey)
|
||||
if hash == "" {
|
||||
t.Error("empty hash")
|
||||
}
|
||||
|
||||
// Should be valid base64.
|
||||
_, err := base64.StdEncoding.DecodeString(hash)
|
||||
if err != nil {
|
||||
t.Errorf("hash is not valid base64: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStretchKey(t *testing.T) {
|
||||
masterKey := make([]byte, 32)
|
||||
for i := range masterKey {
|
||||
masterKey[i] = byte(i)
|
||||
}
|
||||
|
||||
key, err := StretchKey(masterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("StretchKey failed: %v", err)
|
||||
}
|
||||
|
||||
if len(key.EncKey) != 32 {
|
||||
t.Errorf("expected 32-byte enc key, got %d", len(key.EncKey))
|
||||
}
|
||||
if len(key.MacKey) != 32 {
|
||||
t.Errorf("expected 32-byte mac key, got %d", len(key.MacKey))
|
||||
}
|
||||
|
||||
// Enc and Mac keys should be different.
|
||||
same := true
|
||||
for i := range key.EncKey {
|
||||
if key.EncKey[i] != key.MacKey[i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Error("enc and mac keys should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptStr_Empty(t *testing.T) {
|
||||
key := SymmetricKey{EncKey: make([]byte, 32), MacKey: make([]byte, 32)}
|
||||
result, err := DecryptStr("", key)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptStr empty should not error: %v", err)
|
||||
}
|
||||
if result != "" {
|
||||
t.Errorf("expected empty string, got %q", result)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,23 @@
|
||||
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()
|
||||
|
||||
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...")
|
||||
// Authenticate and perform initial sync with retry.
|
||||
maxRetries := 3
|
||||
var loginErr error
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
@@ -59,56 +26,15 @@ func InitializeBitwardenCLI(serverURL, clientID, clientSecret, password string)
|
||||
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 {
|
||||
if err := client.Initialize(); err != nil {
|
||||
logger.Warn.Printf("Initialization failed (attempt %d/%d): %v", attempt, maxRetries, err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info.Println("Vaultwarden client initialized successfully")
|
||||
return client, nil
|
||||
}
|
||||
|
||||
if loginErr != nil {
|
||||
return "", loginErr
|
||||
}
|
||||
} else {
|
||||
logger.Info.Println("Already logged in, skipping login")
|
||||
}
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd = exec.CommandContext(ctx, "bw", "unlock", "--passwordenv", "BW_PASSWORD", "--raw")
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_PASSWORD=%s", password))
|
||||
sessionToken, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unlock failed: %w - %s", err, sessionToken)
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(string(sessionToken))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("received empty session token")
|
||||
}
|
||||
|
||||
logger.Info.Println("Bitwarden CLI initialized successfully")
|
||||
return token, nil
|
||||
return nil, fmt.Errorf("failed to initialize after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
Reference in new issue
Block a user