feat: replace Bitwarden CLI with native Go API client (#2)
BREAKING: Removes Node.js/Bitwarden CLI dependency entirely. ## What changed - New native HTTP client talks directly to Vaultwarden API - Bitwarden-compatible crypto in pure Go: - AES-256-CBC + HMAC-SHA256 decryption - PBKDF2-SHA256 and Argon2id key derivation - HKDF key stretching (with legacy fallback) - Auto token refresh (never expires) - Background vault sync (configurable interval, default 5min) - Docker image: ~233MB → ~15MB (pure Alpine, no Node.js) ## New env vars - VAULTWARDEN_EMAIL (replaces CLIENT_ID/CLIENT_SECRET) - SYNC_INTERVAL (default: 5m) ## Removed - internal/vaultwarden/client_cli.go (CLI interaction) - Bitwarden CLI dependency (Node.js + npm package) - VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET env vars ## Added - internal/vaultwarden/crypto.go (encryption/decryption) - internal/vaultwarden/crypto_test.go (11 unit tests) - internal/vaultwarden/api_client.go (HTTP client) ## Unchanged - External API surface (GET /secret/:name, POST /refresh, GET /health) - All security middleware (auth, IP whitelist, rate limiting)
This commit is contained in:
1 parent
e320f95d9f
commit
9f8ebf2408
14 files changed
+1527
-964
No files matched your search
+32
-50
@@ -1,58 +1,40 @@
|
|||||||
# Server Configuration
|
# ===========================================
|
||||||
API_PORT=8080
|
# Vaultwarden API Configuration
|
||||||
ENVIRONMENT=development # IMPORTANT: Change to 'production' for production deployments!
|
# ===========================================
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# NEVER commit your actual .env file!
|
||||||
|
|
||||||
# Security - REQUIRED
|
# --- Required ---
|
||||||
API_KEY=CHANGE-THIS-USE-32-CHARS-MINIMUM!!
|
|
||||||
|
|
||||||
# IP Whitelist (Optional but RECOMMENDED for production)
|
# Your Vaultwarden instance URL
|
||||||
# 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)
|
|
||||||
VAULTWARDEN_URL=https://vault.yourdomain.com
|
VAULTWARDEN_URL=https://vault.yourdomain.com
|
||||||
|
|
||||||
# Authentication - Choose ONE method:
|
# Your Vaultwarden login email
|
||||||
# Method 1: API Key (Recommended - bypasses 2FA, auto-login on startup)
|
VAULTWARDEN_EMAIL=you@example.com
|
||||||
# Get your API key from: Account Settings -> Security -> Keys -> View API Key
|
|
||||||
VAULTWARDEN_CLIENT_ID=user.xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
# Your Vaultwarden master password
|
||||||
VAULTWARDEN_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
||||||
VAULTWARDEN_PASSWORD=your-master-password
|
VAULTWARDEN_PASSWORD=your-master-password
|
||||||
|
|
||||||
# Method 2: Pre-generated session token (Alternative - will expire)
|
# API key for authenticating to this service (min 32 chars)
|
||||||
# VAULTWARDEN_ACCESS_TOKEN=your-session-token-here
|
# Generate one: openssl rand -base64 32
|
||||||
|
API_KEY=
|
||||||
|
|
||||||
# Performance & Caching
|
# --- Optional ---
|
||||||
# How long to cache secrets in memory (reduces API calls)
|
|
||||||
# Set to 0 to disable caching (more secure but slower)
|
|
||||||
CACHE_TTL=5m
|
|
||||||
|
|
||||||
# Timeouts
|
# Restrict access to specific IPs/CIDRs
|
||||||
READ_TIMEOUT=10s
|
# ALLOWED_IPS=192.168.1.0/24,10.0.0.1
|
||||||
WRITE_TIMEOUT=10s
|
|
||||||
|
# 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
|
# Build stage
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
# Install build dependencies
|
RUN apk add --no-cache git ca-certificates
|
||||||
RUN apk add --no-cache git ca-certificates tzdata
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Copy go mod files
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
# Download dependencies
|
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
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 \
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||||
-ldflags="-s -w" \
|
-ldflags="-s -w" \
|
||||||
-o /build/vaultwarden-api \
|
-o /build/vaultwarden-api \
|
||||||
./cmd/api
|
./cmd/api
|
||||||
|
|
||||||
# Final stage - minimal runtime image
|
# Runtime stage — pure Alpine, no Node.js
|
||||||
FROM node:24-alpine
|
FROM alpine:3.21
|
||||||
|
|
||||||
# Install CA certificates and wget
|
|
||||||
RUN apk --no-cache add ca-certificates wget && \
|
RUN apk --no-cache add ca-certificates wget && \
|
||||||
npm install -g @bitwarden/cli && \
|
adduser -D -H appuser
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy binary from builder
|
|
||||||
COPY --from=builder /build/vaultwarden-api .
|
COPY --from=builder /build/vaultwarden-api .
|
||||||
|
|
||||||
# Change ownership to existing node user
|
USER appuser
|
||||||
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
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health check
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
|
||||||
ENTRYPOINT ["/app/vaultwarden-api"]
|
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
|
## ✨ Highlights
|
||||||
- **Lightweight**: ~15MB Docker image, minimal resource usage
|
|
||||||
- **Fast**: Built-in caching with configurable TTL
|
- **🚀 Zero external dependencies** — Pure Go binary, no Node.js, no Bitwarden CLI
|
||||||
- **Simple**: Clean architecture, easy to deploy and maintain
|
- **🔒 Native Bitwarden crypto** — AES-256-CBC + HMAC-SHA256, PBKDF2/Argon2id key derivation
|
||||||
- **Production-ready**: Rate limiting, health checks, graceful shutdown, security headers
|
- **♻️ Auto token refresh** — Never worry about expired sessions again
|
||||||
- **IP Whitelisting**: Restrict access by IP/CIDR with optional GitHub Actions support
|
- **📦 ~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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ HTTPS + API Key ┌──────────────────┐
|
┌─────────────┐ HTTPS + API Key ┌──────────────────────┐ Native Go ┌──────────────┐
|
||||||
│ Your App │ ───────────────────────> │ Vaultwarden API │
|
│ Your App │ ────────────────────> │ Vaultwarden API │ ──────────────> │ Vaultwarden │
|
||||||
└─────────────┘ │ Proxy │
|
│ (any lang) │ <──────────────────── │ (Go, ~15MB image) │ <────────────── │ Server │
|
||||||
└──────────────────┘
|
└─────────────┘ JSON response │ │ Encrypted API └──────────────┘
|
||||||
│
|
│ • Auto token refresh│
|
||||||
│ Vaultwarden API
|
│ • Background sync │
|
||||||
▼
|
│ • In-memory cache │
|
||||||
┌──────────────────┐
|
│ • IP whitelisting │
|
||||||
│ Vaultwarden │
|
└──────────────────────┘
|
||||||
│ Server │
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
**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
|
||||||
├── cmd/
|
4. Serves secrets via a simple REST API with API key authentication
|
||||||
│ └── api/
|
5. Periodically re-syncs the vault and auto-refreshes auth tokens
|
||||||
│ └── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### 1. Pull and run with Docker
|
||||||
|
|
||||||
- Go 1.25+ (for local development)
|
|
||||||
- Docker & Docker Compose (for containerized deployment)
|
|
||||||
- Vaultwarden instance with API access
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/turbootzz/vaultwarden-api.git
|
|
||||||
cd vaultwarden-api
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create environment file**
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configure your `.env` file**
|
|
||||||
```bash
|
|
||||||
# Generate a strong API key
|
|
||||||
make generate-api-key
|
|
||||||
|
|
||||||
# Edit .env and add:
|
|
||||||
# - API_KEY (generated above)
|
|
||||||
# - VAULTWARDEN_URL (your Vaultwarden instance URL)
|
|
||||||
# - VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET, VAULTWARDEN_PASSWORD
|
|
||||||
# (from Vaultwarden Account Settings → Security → Keys → View API Key)
|
|
||||||
# - ALLOWED_IPS (your IP address or CIDR range for security)
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Run the application**
|
|
||||||
```bash
|
|
||||||
make run
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Deployment
|
|
||||||
|
|
||||||
1. **Build the Docker image**
|
|
||||||
```bash
|
|
||||||
make docker-build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run with Docker Compose**
|
|
||||||
```bash
|
|
||||||
make docker-compose-up
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:8080/health
|
docker run -d \
|
||||||
```
|
--name vaultwarden-api \
|
||||||
**Response:**
|
-p 8080:8080 \
|
||||||
```json
|
-e VAULTWARDEN_URL=https://vault.yourdomain.com \
|
||||||
{
|
-e VAULTWARDEN_EMAIL=you@example.com \
|
||||||
"status": "ok",
|
-e VAULTWARDEN_PASSWORD=your-master-password \
|
||||||
"service": "vaultwarden-api"
|
-e API_KEY=$(openssl rand -base64 32) \
|
||||||
}
|
ghcr.io/turbootzz/vaultwarden-api:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Get Secret
|
### 2. Fetch a secret
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||||
http://localhost:8080/secret/DATABASE_URL
|
http://localhost:8080/secret/DATABASE_URL
|
||||||
```
|
```
|
||||||
**Response:**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "DATABASE_URL",
|
"name": "DATABASE_URL",
|
||||||
"value": "postgresql://user:pass@localhost:5432/db"
|
"value": "postgresql://user:pass@db:5432/myapp"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Refresh Cache
|
That's it. Your app reads secrets from the API instead of `.env` files.
|
||||||
```bash
|
|
||||||
curl -X POST \
|
## API Endpoints
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
http://localhost:8080/refresh
|
| Method | Path | Auth | Description |
|
||||||
```
|
|--------|------|------|-------------|
|
||||||
**Response:**
|
| `GET` | `/health` | No | Health check |
|
||||||
```json
|
| `GET` | `/secret/:name` | API Key | Fetch a secret by name |
|
||||||
{
|
| `POST` | `/refresh` | API Key | Force vault re-sync |
|
||||||
"status": "ok",
|
|
||||||
"message": "cache cleared successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is done via environment variables:
|
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
|----------|----------|---------|-------------|
|
||||||
| `API_PORT` | No | `8080` | Port the API listens on |
|
| `VAULTWARDEN_URL` | **Yes** | — | Your Vaultwarden instance URL |
|
||||||
| `ENVIRONMENT` | No | `development` | Environment (development/production) |
|
| `VAULTWARDEN_EMAIL` | **Yes** | — | Your Vaultwarden email |
|
||||||
| `API_KEY` | **Yes** | - | API key for authentication (min 32 chars) |
|
| `VAULTWARDEN_PASSWORD` | **Yes** | — | Your master password |
|
||||||
| `VAULTWARDEN_URL` | **Yes** | - | Vaultwarden instance URL |
|
| `API_KEY` | **Yes** | — | API key for this service (min 32 chars) |
|
||||||
| `VAULTWARDEN_CLIENT_ID` | **Yes*** | - | Vaultwarden API client ID |
|
| `ALLOWED_IPS` | No | (all) | Comma-separated IPs/CIDRs to whitelist |
|
||||||
| `VAULTWARDEN_CLIENT_SECRET` | **Yes*** | - | Vaultwarden API client secret |
|
| `ENABLE_GITHUB_IP_RANGES` | No | `false` | Auto-whitelist GitHub Actions IPs |
|
||||||
| `VAULTWARDEN_PASSWORD` | **Yes*** | - | Your Vaultwarden master password |
|
| `SYNC_INTERVAL` | No | `5m` | How often to re-sync the vault |
|
||||||
| `VAULTWARDEN_ACCESS_TOKEN` | **Yes*** | - | Alternative: pre-generated session token |
|
| `CACHE_TTL` | No | `5m` | Secret cache duration |
|
||||||
| `ALLOWED_IPS` | No | - | Comma-separated IPs/CIDRs to whitelist |
|
| `TRUSTED_PROXY_IP` | No | `localhost` | Trusted reverse proxy IPs |
|
||||||
| `ENABLE_GITHUB_IP_RANGES` | No | `false` | Allow GitHub Actions IPs (updates every 24h) |
|
| `ENVIRONMENT` | No | `development` | Set to `production` to hide errors |
|
||||||
| `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 |
|
|
||||||
| `DEBUG` | No | `false` | Enable debug logging |
|
| `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
|
```yaml
|
||||||
|
services:
|
||||||
### Recommended Method: API Key Authentication
|
vaultwarden-api:
|
||||||
|
image: ghcr.io/turbootzz/vaultwarden-api:latest
|
||||||
1. **Get API Credentials from Vaultwarden Web UI**:
|
container_name: vaultwarden-api
|
||||||
- Log into your Vaultwarden instance
|
restart: unless-stopped
|
||||||
- Go to **Account Settings** → **Security** → **Keys**
|
ports:
|
||||||
- Click **View API Key**
|
- "127.0.0.1:8080:8080"
|
||||||
- You'll see:
|
environment:
|
||||||
- `client_id` (starts with `user.`)
|
- VAULTWARDEN_URL=${VAULTWARDEN_URL}
|
||||||
- `client_secret` (long random string)
|
- VAULTWARDEN_EMAIL=${VAULTWARDEN_EMAIL}
|
||||||
- Add these to your `.env` as `VAULTWARDEN_CLIENT_ID` and `VAULTWARDEN_CLIENT_SECRET`
|
- VAULTWARDEN_PASSWORD=${VAULTWARDEN_PASSWORD}
|
||||||
- Add your master password as `VAULTWARDEN_PASSWORD`
|
- API_KEY=${API_KEY}
|
||||||
|
- ENVIRONMENT=production
|
||||||
2. **Benefits**:
|
- ALLOWED_IPS=${ALLOWED_IPS}
|
||||||
- ✅ Bypasses 2FA
|
read_only: true
|
||||||
- ✅ Automatically logs in on startup
|
tmpfs:
|
||||||
- ✅ Never expires
|
- /tmp
|
||||||
- ✅ More secure than session tokens
|
cap_drop:
|
||||||
|
- ALL
|
||||||
### Alternative Method: Session Token
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
1. **Generate a session token manually**:
|
deploy:
|
||||||
```bash
|
resources:
|
||||||
# Login and unlock your vault
|
limits:
|
||||||
bw config server https://vault.yourdomain.com
|
cpus: '0.5'
|
||||||
bw login
|
memory: 128M
|
||||||
SESSION_TOKEN=$(bw unlock --raw)
|
healthcheck:
|
||||||
echo $SESSION_TOKEN
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
|
||||||
```
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
2. **Add to `.env`**:
|
retries: 3
|
||||||
```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
|
## Usage Examples
|
||||||
|
|
||||||
### From a Shell Script
|
### Shell / CI Pipeline
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
DB_URL=$(curl -sf -H "Authorization: Bearer $API_KEY" \
|
||||||
|
https://api.yourdomain.com/secret/DATABASE_URL | jq -r '.value')
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Python
|
### Python
|
||||||
```python
|
```python
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = "your-api-key"
|
|
||||||
API_URL = "https://api.yourdomain.com"
|
|
||||||
|
|
||||||
def get_secret(name):
|
def get_secret(name):
|
||||||
headers = {"Authorization": f"Bearer {API_KEY}"}
|
r = requests.get(f"https://api.yourdomain.com/secret/{name}",
|
||||||
response = requests.get(f"{API_URL}/secret/{name}", headers=headers)
|
headers={"Authorization": f"Bearer {API_KEY}"})
|
||||||
return response.json()["value"]
|
return r.json()["value"]
|
||||||
|
|
||||||
# Usage
|
|
||||||
db_url = get_secret("DATABASE_URL")
|
db_url = get_secret("DATABASE_URL")
|
||||||
print(f"Database URL: {db_url}")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Node.js
|
### Node.js
|
||||||
```javascript
|
```javascript
|
||||||
const axios = require('axios');
|
const res = await fetch(`https://api.yourdomain.com/secret/DATABASE_URL`, {
|
||||||
|
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||||
const API_KEY = 'your-api-key';
|
});
|
||||||
const API_URL = 'https://api.yourdomain.com';
|
const { value } = await res.json();
|
||||||
|
|
||||||
async function getSecret(name) {
|
|
||||||
const response = await axios.get(`${API_URL}/secret/${name}`, {
|
|
||||||
headers: { 'Authorization': `Bearer ${API_KEY}` }
|
|
||||||
});
|
|
||||||
return response.data.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
(async () => {
|
|
||||||
const dbUrl = await getSecret('DATABASE_URL');
|
|
||||||
console.log(`Database URL: ${dbUrl}`);
|
|
||||||
})();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
make help # Show available commands
|
# Build
|
||||||
make build # Build the binary
|
go build -o vaultwarden-api ./cmd/api
|
||||||
make run # Run locally (requires .env)
|
|
||||||
make dev # Run with hot reload
|
# Test
|
||||||
make test # Run tests
|
go test ./...
|
||||||
make clean # Clean build artifacts
|
|
||||||
make docker-build # Build Docker image
|
# Docker
|
||||||
make docker-run # Run Docker container
|
docker build -t vaultwarden-api .
|
||||||
make docker-push # Push to Docker Hub
|
|
||||||
make generate-api-key # Generate a random API key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## How Secrets are Matched
|
||||||
|
|
||||||
### "Failed to load configuration: API_KEY is required"
|
When you request `/secret/DATABASE_URL`, the API:
|
||||||
- Make sure you have a `.env` file in the project root
|
|
||||||
- Copy `.env.example` to `.env` and fill in all required values
|
|
||||||
|
|
||||||
### "Secret not found"
|
1. **Exact match** (case-insensitive) against vault item names
|
||||||
- Verify the secret name matches exactly (case-sensitive)
|
2. **Partial match** if no exact match is found
|
||||||
- Check that your Vaultwarden token has access to the vault
|
3. Returns the most relevant value: password → custom field → notes
|
||||||
- Use the Vaultwarden web UI to confirm the secret exists
|
|
||||||
|
|
||||||
### "Vaultwarden api returned status 401"
|
This means you can name your Vaultwarden items naturally (e.g., "Database URL") and fetch them with any casing.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please:
|
Contributions welcome! Fork → branch → PR.
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make your changes
|
|
||||||
4. Add tests if applicable
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see LICENSE file for details
|
MIT — see [LICENSE](LICENSE)
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Made by Thijs Herman**
|
**Built by [Thijs Herman](https://github.com/Turbootzz)** — because `.env` files are so 2020.
|
||||||
+55
-60
@@ -24,7 +24,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Load configuration
|
// Load configuration.
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error.Fatalf("Failed to load configuration: %v", err)
|
logger.Error.Fatalf("Failed to load configuration: %v", err)
|
||||||
@@ -32,59 +32,57 @@ func main() {
|
|||||||
|
|
||||||
logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment)
|
logger.Info.Printf("Starting Vaultwarden API on port %s (environment: %s)", cfg.Port, cfg.Environment)
|
||||||
|
|
||||||
// Initialize Vaultwarden client
|
// Initialize Vaultwarden client.
|
||||||
var vaultClient *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")
|
if email == "" || password == "" {
|
||||||
vaultwardenClientSecret := os.Getenv("VAULTWARDEN_CLIENT_SECRET")
|
logger.Error.Fatal("VAULTWARDEN_EMAIL and VAULTWARDEN_PASSWORD are required")
|
||||||
vaultwardenPassword := os.Getenv("VAULTWARDEN_PASSWORD")
|
|
||||||
|
|
||||||
if vaultwardenClientID != "" && vaultwardenClientSecret != "" && vaultwardenPassword != "" {
|
|
||||||
logger.Info.Println("Initializing Bitwarden CLI with API key")
|
|
||||||
sessionToken, err := vaultwarden.InitializeBitwardenCLI(cfg.VaultwardenURL, vaultwardenClientID, vaultwardenClientSecret, vaultwardenPassword)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error.Fatalf("Failed to initialize Bitwarden CLI: %v", err)
|
|
||||||
}
|
|
||||||
vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, sessionToken, cfg.CacheTTL)
|
|
||||||
} else if cfg.VaultwardenToken != "" {
|
|
||||||
logger.Warn.Println("Using provided session token (will expire)")
|
|
||||||
vaultClient = vaultwarden.NewClient(cfg.VaultwardenURL, cfg.VaultwardenToken, cfg.CacheTTL)
|
|
||||||
} else {
|
|
||||||
logger.Error.Fatal("No authentication configured. Set either (VAULTWARDEN_CLIENT_ID + VAULTWARDEN_CLIENT_SECRET + VAULTWARDEN_PASSWORD) or VAULTWARDEN_ACCESS_TOKEN")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize handlers
|
syncInterval := parseDurationEnv("SYNC_INTERVAL", "5m")
|
||||||
|
|
||||||
|
vaultClient, err := vaultwarden.InitializeClient(
|
||||||
|
cfg.VaultwardenURL,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
clientID,
|
||||||
|
clientSecret,
|
||||||
|
cfg.CacheTTL,
|
||||||
|
syncInterval,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error.Fatalf("Failed to initialize Vaultwarden client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize handlers.
|
||||||
h := handlers.NewHandler(vaultClient)
|
h := handlers.NewHandler(vaultClient)
|
||||||
|
|
||||||
// Initialize IP whitelist
|
// Initialize IP whitelist.
|
||||||
ipWhitelist, err := ipwhitelist.New(cfg.AllowedIPs, cfg.EnableGitHubIPRanges)
|
ipWhitelist, err := ipwhitelist.New(cfg.AllowedIPs, cfg.EnableGitHubIPRanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error.Fatalf("Failed to initialize IP whitelist: %v", err)
|
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()
|
var stopIPUpdate func()
|
||||||
if cfg.EnableGitHubIPRanges {
|
if cfg.EnableGitHubIPRanges {
|
||||||
stopIPUpdate = ipWhitelist.StartPeriodicUpdate(24 * time.Hour)
|
stopIPUpdate = ipWhitelist.StartPeriodicUpdate(24 * time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Fiber app with security configurations
|
// Create Fiber app with security configurations.
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
AppName: "Vaultwarden API v1.0",
|
AppName: "Vaultwarden API v2.0",
|
||||||
DisableStartupMessage: false,
|
DisableStartupMessage: false,
|
||||||
ReadTimeout: cfg.ReadTimeout,
|
ReadTimeout: cfg.ReadTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout,
|
WriteTimeout: cfg.WriteTimeout,
|
||||||
// Disable server header for security
|
ServerHeader: "",
|
||||||
ServerHeader: "",
|
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
||||||
// Don't expose stack traces in production
|
|
||||||
ErrorHandler: customErrorHandler(cfg.IsProd()),
|
|
||||||
// Enable trusted proxy check to prevent IP spoofing
|
|
||||||
EnableTrustedProxyCheck: true,
|
EnableTrustedProxyCheck: true,
|
||||||
// IMPORTANT: Only trust your actual reverse proxy IP
|
TrustedProxies: getTrustedProxies(),
|
||||||
// Default to localhost - update TRUSTED_PROXY_IP env var with your Nginx Proxy Manager IP
|
ProxyHeader: fiber.HeaderXForwardedFor,
|
||||||
TrustedProxies: getTrustedProxies(),
|
|
||||||
// Use X-Forwarded-For header to get real client IP
|
|
||||||
ProxyHeader: fiber.HeaderXForwardedFor,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.Use(helmet.New())
|
app.Use(helmet.New())
|
||||||
@@ -100,10 +98,10 @@ func main() {
|
|||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Public routes (no authentication or IP whitelist required)
|
// Public routes.
|
||||||
app.Get("/health", h.HealthCheck)
|
app.Get("/health", h.HealthCheck)
|
||||||
|
|
||||||
// Protected routes (IP whitelist + API key required)
|
// Protected routes.
|
||||||
api := app.Group("/")
|
api := app.Group("/")
|
||||||
api.Use(ipWhitelist.Middleware())
|
api.Use(ipWhitelist.Middleware())
|
||||||
api.Use(limiter.New(limiter.Config{
|
api.Use(limiter.New(limiter.Config{
|
||||||
@@ -119,7 +117,7 @@ func main() {
|
|||||||
api.Get("/secret/:name", h.GetSecret)
|
api.Get("/secret/:name", h.GetSecret)
|
||||||
api.Post("/refresh", h.RefreshCache)
|
api.Post("/refresh", h.RefreshCache)
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown.
|
||||||
go func() {
|
go func() {
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
@@ -127,7 +125,8 @@ func main() {
|
|||||||
|
|
||||||
logger.Info.Println("Shutting down gracefully...")
|
logger.Info.Println("Shutting down gracefully...")
|
||||||
|
|
||||||
// Stop IP update goroutine if running
|
vaultClient.Stop()
|
||||||
|
|
||||||
if stopIPUpdate != nil {
|
if stopIPUpdate != nil {
|
||||||
stopIPUpdate()
|
stopIPUpdate()
|
||||||
}
|
}
|
||||||
@@ -137,10 +136,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start server
|
// Start server.
|
||||||
addr := fmt.Sprintf(":%s", cfg.Port)
|
addr := fmt.Sprintf(":%s", cfg.Port)
|
||||||
if err := app.Listen(addr); err != nil {
|
if err := app.Listen(addr); err != nil {
|
||||||
// Clean up before exit
|
|
||||||
if stopIPUpdate != nil {
|
if stopIPUpdate != nil {
|
||||||
stopIPUpdate()
|
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 {
|
func getTrustedProxies() []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
result := []string{}
|
result := []string{}
|
||||||
|
|
||||||
// Add defaults
|
|
||||||
for _, ip := range []string{"127.0.0.1", "::1"} {
|
for _, ip := range []string{"127.0.0.1", "::1"} {
|
||||||
result = append(result, ip)
|
result = append(result, ip)
|
||||||
seen[ip] = true
|
seen[ip] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add from environment variable
|
|
||||||
if proxyIP := os.Getenv("TRUSTED_PROXY_IP"); proxyIP != "" {
|
if proxyIP := os.Getenv("TRUSTED_PROXY_IP"); proxyIP != "" {
|
||||||
proxies := strings.Split(proxyIP, ",")
|
proxies := strings.Split(proxyIP, ",")
|
||||||
|
|
||||||
for _, proxy := range proxies {
|
for _, proxy := range proxies {
|
||||||
trimmed := strings.TrimSpace(proxy)
|
trimmed := strings.TrimSpace(proxy)
|
||||||
if trimmed == "" || seen[trimmed] {
|
if trimmed == "" || seen[trimmed] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: check if it's a valid IP or CIDR
|
|
||||||
if err := validateIPOrCIDR(trimmed); err != nil {
|
if err := validateIPOrCIDR(trimmed); err != nil {
|
||||||
logger.Warn.Printf("Ignoring invalid IP/CIDR in TRUSTED_PROXY_IP: %s (%v)", trimmed, err)
|
logger.Warn.Printf("Ignoring invalid IP/CIDR in TRUSTED_PROXY_IP: %s (%v)", trimmed, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result = append(result, trimmed)
|
result = append(result, trimmed)
|
||||||
seen[trimmed] = true
|
seen[trimmed] = true
|
||||||
}
|
}
|
||||||
@@ -184,38 +189,28 @@ func getTrustedProxies() []string {
|
|||||||
return result
|
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 {
|
func validateIPOrCIDR(s string) error {
|
||||||
// Check if it's a CIDR range
|
|
||||||
if strings.Contains(s, "/") {
|
if strings.Contains(s, "/") {
|
||||||
_, _, err := net.ParseCIDR(s)
|
_, _, err := net.ParseCIDR(s)
|
||||||
if err != nil {
|
return err
|
||||||
return fmt.Errorf("invalid CIDR: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a valid IP
|
|
||||||
if net.ParseIP(s) == nil {
|
if net.ParseIP(s) == nil {
|
||||||
return fmt.Errorf("invalid IP address")
|
return fmt.Errorf("invalid IP address")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// customErrorHandler creates a custom error handler
|
// customErrorHandler creates a custom error handler.
|
||||||
func customErrorHandler(isProd bool) fiber.ErrorHandler {
|
func customErrorHandler(isProd bool) fiber.ErrorHandler {
|
||||||
return func(c *fiber.Ctx, err error) error {
|
return func(c *fiber.Ctx, err error) error {
|
||||||
code := fiber.StatusInternalServerError
|
code := fiber.StatusInternalServerError
|
||||||
|
|
||||||
if e, ok := err.(*fiber.Error); ok {
|
if e, ok := err.(*fiber.Error); ok {
|
||||||
code = e.Code
|
code = e.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the error
|
|
||||||
logger.Error.Printf("Request error (status %d): %v", code, err)
|
logger.Error.Printf("Request error (status %d): %v", code, err)
|
||||||
|
|
||||||
// Don't expose internal errors in production
|
|
||||||
message := "Internal Server Error"
|
message := "Internal Server Error"
|
||||||
if !isProd {
|
if !isProd {
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
|
|||||||
+8
-39
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
vaultwarden-api:
|
vaultwarden-api:
|
||||||
build:
|
build:
|
||||||
@@ -10,37 +8,27 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
environment:
|
environment:
|
||||||
# Server configuration
|
|
||||||
- API_PORT=8080
|
- API_PORT=8080
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
|
|
||||||
# Security - REQUIRED
|
|
||||||
# IMPORTANT: Change these values before deploying!
|
|
||||||
- API_KEY=${API_KEY}
|
- API_KEY=${API_KEY}
|
||||||
|
|
||||||
# Vaultwarden configuration
|
|
||||||
- VAULTWARDEN_URL=${VAULTWARDEN_URL}
|
- VAULTWARDEN_URL=${VAULTWARDEN_URL}
|
||||||
- VAULTWARDEN_ACCESS_TOKEN=${VAULTWARDEN_ACCESS_TOKEN}
|
- VAULTWARDEN_EMAIL=${VAULTWARDEN_EMAIL}
|
||||||
|
- VAULTWARDEN_PASSWORD=${VAULTWARDEN_PASSWORD}
|
||||||
# Performance
|
- 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
|
- CACHE_TTL=5m
|
||||||
- READ_TIMEOUT=10s
|
- READ_TIMEOUT=10s
|
||||||
- WRITE_TIMEOUT=10s
|
- WRITE_TIMEOUT=10s
|
||||||
|
|
||||||
# Security: read-only filesystem (except for tmp)
|
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
|
|
||||||
# Security: drop all capabilities
|
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
|
||||||
# Security: prevent privilege escalation
|
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
# Resource limits (adjust based on your needs)
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -49,33 +37,14 @@ services:
|
|||||||
reservations:
|
reservations:
|
||||||
cpus: '0.25'
|
cpus: '0.25'
|
||||||
memory: 64M
|
memory: 64M
|
||||||
|
|
||||||
# Health check
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 5s
|
start_period: 10s
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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
|
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 (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
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/klauspost/compress v1.17.9 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
github.com/valyala/tcplisten v1.0.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/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 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,27 +8,27 @@ import (
|
|||||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler contains all HTTP handlers
|
// Handler contains all HTTP handlers.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
vaultClient *vaultwarden.Client
|
vaultClient *vaultwarden.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new handler instance
|
// NewHandler creates a new handler instance.
|
||||||
func NewHandler(vaultClient *vaultwarden.Client) *Handler {
|
func NewHandler(vaultClient *vaultwarden.Client) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
vaultClient: vaultClient,
|
vaultClient: vaultClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheck handles GET /health
|
// HealthCheck handles GET /health.
|
||||||
func (h *Handler) HealthCheck(c *fiber.Ctx) error {
|
func (h *Handler) HealthCheck(c *fiber.Ctx) error {
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "vaultwarden-api",
|
"service": "vaultwarden-api",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecret handles GET /secret/:name
|
// GetSecret handles GET /secret/:name.
|
||||||
func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
||||||
secretName := c.Params("name")
|
secretName := c.Params("name")
|
||||||
|
|
||||||
@@ -48,20 +48,19 @@ func (h *Handler) GetSecret(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
value, err := h.vaultClient.GetSecret(secretName)
|
value, err := h.vaultClient.GetSecret(secretName)
|
||||||
if err != nil {
|
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{
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
"error": "secret not found",
|
"error": "secret not found",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the secret value
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"name": secretName,
|
"name": secretName,
|
||||||
"value": value,
|
"value": value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshCache handles POST /refresh
|
// RefreshCache handles POST /refresh.
|
||||||
func (h *Handler) RefreshCache(c *fiber.Ctx) error {
|
func (h *Handler) RefreshCache(c *fiber.Ctx) error {
|
||||||
h.vaultClient.ClearCache()
|
h.vaultClient.ClearCache()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,495 @@
|
|||||||
|
package vaultwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PreloginResponse contains the KDF parameters from the server.
|
||||||
|
type PreloginResponse struct {
|
||||||
|
KDF int `json:"kdf"`
|
||||||
|
KDFIterations int `json:"kdfIterations"`
|
||||||
|
KDFMemory *int `json:"kdfMemory"`
|
||||||
|
KDFParallelism *int `json:"kdfParallelism"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse is returned by the /identity/connect/token endpoint.
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Key string `json:"Key"`
|
||||||
|
PrivateKey string `json:"PrivateKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncResponse contains the full vault sync data.
|
||||||
|
type SyncResponse struct {
|
||||||
|
Profile SyncProfile `json:"profile"`
|
||||||
|
Ciphers []SyncCipher `json:"ciphers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncProfile contains user profile info.
|
||||||
|
type SyncProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncCipher represents an encrypted vault item from the sync response.
|
||||||
|
type SyncCipher struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
Login *SyncLogin `json:"login"`
|
||||||
|
Card *SyncCard `json:"card"`
|
||||||
|
Fields []SyncField `json:"fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncLogin contains encrypted login data.
|
||||||
|
type SyncLogin struct {
|
||||||
|
Username *string `json:"username"`
|
||||||
|
Password *string `json:"password"`
|
||||||
|
URI *string `json:"uri"`
|
||||||
|
URIs []struct {
|
||||||
|
URI *string `json:"uri"`
|
||||||
|
} `json:"uris"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncCard contains encrypted card data.
|
||||||
|
type SyncCard struct {
|
||||||
|
CardholderName *string `json:"cardholderName"`
|
||||||
|
Number *string `json:"number"`
|
||||||
|
Code *string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncField contains encrypted custom field data.
|
||||||
|
type SyncField struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Value *string `json:"value"`
|
||||||
|
Type int `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitwarden cipher types.
|
||||||
|
const (
|
||||||
|
CipherTypeLogin = 1
|
||||||
|
CipherTypeSecureNote = 2
|
||||||
|
CipherTypeCard = 3
|
||||||
|
CipherTypeIdentity = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIClient communicates directly with the Vaultwarden HTTP API.
|
||||||
|
type APIClient struct {
|
||||||
|
baseURL string
|
||||||
|
email string
|
||||||
|
password string
|
||||||
|
clientID string // Optional: for API key login (bypasses 2FA)
|
||||||
|
clientSecret string // Optional: for API key login (bypasses 2FA)
|
||||||
|
httpClient *http.Client
|
||||||
|
deviceID string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
accessToken string
|
||||||
|
refreshToken string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
symKey SymmetricKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIClient creates a new Vaultwarden API client.
|
||||||
|
// clientID and clientSecret are optional — if provided, API key login is used (bypasses 2FA).
|
||||||
|
func NewAPIClient(baseURL, email, password, clientID, clientSecret string) *APIClient {
|
||||||
|
return &APIClient{
|
||||||
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
clientID: clientID,
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
deviceID: uuid.New().String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate performs the full login flow.
|
||||||
|
// If API key credentials are set, uses client_credentials grant (bypasses 2FA).
|
||||||
|
// Otherwise, uses password grant (requires 2FA to be disabled or handled).
|
||||||
|
func (ac *APIClient) Authenticate() error {
|
||||||
|
// Step 1: Get KDF parameters.
|
||||||
|
prelogin, err := ac.prelogin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prelogin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info.Printf("KDF type: %d, iterations: %d", prelogin.KDF, prelogin.KDFIterations)
|
||||||
|
|
||||||
|
// Step 2: Derive master key (always needed for decryption).
|
||||||
|
masterKey, err := MakeMasterKey(ac.password, ac.email, prelogin.KDF, prelogin.KDFIterations, prelogin.KDFMemory, prelogin.KDFParallelism)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("derive master key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Login.
|
||||||
|
var tokenResp *TokenResponse
|
||||||
|
if ac.clientID != "" && ac.clientSecret != "" {
|
||||||
|
// API key login — bypasses 2FA.
|
||||||
|
logger.Info.Println("Using API key authentication (2FA bypass)")
|
||||||
|
tokenResp, err = ac.loginWithAPIKey()
|
||||||
|
} else {
|
||||||
|
// Password login — requires no 2FA or 2FA handling.
|
||||||
|
hashedPassword := HashPassword(ac.password, masterKey)
|
||||||
|
tokenResp, err = ac.loginWithPassword(hashedPassword)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Get the encrypted symmetric key.
|
||||||
|
// API key login doesn't return the Key in the token response,
|
||||||
|
// so we get it from the sync/profile endpoint.
|
||||||
|
encryptedKey := tokenResp.Key
|
||||||
|
if encryptedKey == "" {
|
||||||
|
// Fetch from sync profile.
|
||||||
|
ac.mu.Lock()
|
||||||
|
ac.accessToken = tokenResp.AccessToken
|
||||||
|
ac.refreshToken = tokenResp.RefreshToken
|
||||||
|
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
ac.mu.Unlock()
|
||||||
|
|
||||||
|
encryptedKey, err = ac.fetchProfileKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch profile key: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ac.mu.Lock()
|
||||||
|
ac.accessToken = tokenResp.AccessToken
|
||||||
|
ac.refreshToken = tokenResp.RefreshToken
|
||||||
|
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
ac.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Decrypt the symmetric key.
|
||||||
|
symKey, err := DecryptSymmetricKey(encryptedKey, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypt symmetric key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ac.mu.Lock()
|
||||||
|
ac.symKey = symKey
|
||||||
|
ac.mu.Unlock()
|
||||||
|
|
||||||
|
logger.Info.Println("Authentication successful")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAccessToken uses the refresh token to get a new access token.
|
||||||
|
func (ac *APIClient) RefreshAccessToken() error {
|
||||||
|
ac.mu.RLock()
|
||||||
|
rt := ac.refreshToken
|
||||||
|
ac.mu.RUnlock()
|
||||||
|
|
||||||
|
if rt == "" {
|
||||||
|
return fmt.Errorf("no refresh token available, re-authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data := url.Values{
|
||||||
|
"grant_type": {"refresh_token"},
|
||||||
|
"refresh_token": {rt},
|
||||||
|
"client_id": {"web"},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("refresh request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp TokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
|
return fmt.Errorf("decode refresh response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ac.mu.Lock()
|
||||||
|
ac.accessToken = tokenResp.AccessToken
|
||||||
|
if tokenResp.RefreshToken != "" {
|
||||||
|
ac.refreshToken = tokenResp.RefreshToken
|
||||||
|
}
|
||||||
|
ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
||||||
|
ac.mu.Unlock()
|
||||||
|
|
||||||
|
logger.Debug.Println("Token refreshed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureValidToken refreshes the access token if it's expired or about to expire.
|
||||||
|
func (ac *APIClient) EnsureValidToken() error {
|
||||||
|
ac.mu.RLock()
|
||||||
|
expiry := ac.tokenExpiry
|
||||||
|
ac.mu.RUnlock()
|
||||||
|
|
||||||
|
// Refresh 60 seconds before actual expiry.
|
||||||
|
if time.Now().After(expiry.Add(-60 * time.Second)) {
|
||||||
|
logger.Debug.Println("Token expiring soon, refreshing...")
|
||||||
|
if err := ac.RefreshAccessToken(); err != nil {
|
||||||
|
// If refresh fails, try full re-authentication.
|
||||||
|
logger.Warn.Println("Token refresh failed, attempting full re-authentication")
|
||||||
|
return ac.Authenticate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync fetches and decrypts all vault items.
|
||||||
|
func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
||||||
|
if err := ac.EnsureValidToken(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure valid token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ac.mu.RLock()
|
||||||
|
token := ac.accessToken
|
||||||
|
key := ac.symKey
|
||||||
|
ac.mu.RUnlock()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create sync request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := ac.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sync request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
// Token might be invalid, try to refresh and retry once.
|
||||||
|
if err := ac.RefreshAccessToken(); err != nil {
|
||||||
|
return nil, fmt.Errorf("sync auth failed, refresh failed: %w", err)
|
||||||
|
}
|
||||||
|
ac.mu.RLock()
|
||||||
|
token = ac.accessToken
|
||||||
|
ac.mu.RUnlock()
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err = ac.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("sync retry: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncResp SyncResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode sync response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt all ciphers.
|
||||||
|
items := make([]DecryptedItem, 0, len(syncResp.Ciphers))
|
||||||
|
for _, c := range syncResp.Ciphers {
|
||||||
|
item, err := decryptCipher(c, key)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info.Printf("Synced and decrypted %d vault items", len(items))
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptedItem is a decrypted vault item ready for cache lookup.
|
||||||
|
type DecryptedItem struct {
|
||||||
|
ID string
|
||||||
|
Type int
|
||||||
|
Name string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Notes string
|
||||||
|
URI string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptCipher decrypts a single vault cipher into a DecryptedItem.
|
||||||
|
func decryptCipher(c SyncCipher, key SymmetricKey) (DecryptedItem, error) {
|
||||||
|
item := DecryptedItem{
|
||||||
|
ID: c.ID,
|
||||||
|
Type: c.Type,
|
||||||
|
Fields: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
item.Name, err = DecryptStr(c.Name, key)
|
||||||
|
if err != nil {
|
||||||
|
return item, fmt.Errorf("decrypt name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Notes != nil {
|
||||||
|
item.Notes, _ = DecryptStr(*c.Notes, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Login != nil {
|
||||||
|
if c.Login.Username != nil {
|
||||||
|
item.Username, _ = DecryptStr(*c.Login.Username, key)
|
||||||
|
}
|
||||||
|
if c.Login.Password != nil {
|
||||||
|
item.Password, _ = DecryptStr(*c.Login.Password, key)
|
||||||
|
}
|
||||||
|
if c.Login.URI != nil {
|
||||||
|
item.URI, _ = DecryptStr(*c.Login.URI, key)
|
||||||
|
}
|
||||||
|
if item.URI == "" && len(c.Login.URIs) > 0 && c.Login.URIs[0].URI != nil {
|
||||||
|
item.URI, _ = DecryptStr(*c.Login.URIs[0].URI, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range c.Fields {
|
||||||
|
var name, value string
|
||||||
|
if f.Name != nil {
|
||||||
|
name, _ = DecryptStr(*f.Name, key)
|
||||||
|
}
|
||||||
|
if f.Value != nil {
|
||||||
|
value, _ = DecryptStr(*f.Value, key)
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
item.Fields[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prelogin fetches KDF parameters for the given email.
|
||||||
|
func (ac *APIClient) prelogin() (*PreloginResponse, error) {
|
||||||
|
body := fmt.Sprintf(`{"email":"%s"}`, ac.email)
|
||||||
|
resp, err := ac.httpClient.Post(
|
||||||
|
ac.baseURL+"/identity/accounts/prelogin",
|
||||||
|
"application/json",
|
||||||
|
strings.NewReader(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prelogin request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("prelogin failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result PreloginResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode prelogin: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginWithPassword authenticates with email + hashed password (requires no 2FA or 2FA handling).
|
||||||
|
func (ac *APIClient) loginWithPassword(hashedPassword string) (*TokenResponse, error) {
|
||||||
|
data := url.Values{
|
||||||
|
"grant_type": {"password"},
|
||||||
|
"username": {ac.email},
|
||||||
|
"password": {hashedPassword},
|
||||||
|
"scope": {"api offline_access"},
|
||||||
|
"client_id": {"web"},
|
||||||
|
"deviceType": {"10"},
|
||||||
|
"deviceIdentifier": {ac.deviceID},
|
||||||
|
"deviceName": {"vaultwarden-api"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return ac.doTokenRequest(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginWithAPIKey authenticates with API key (client_credentials). Bypasses 2FA.
|
||||||
|
func (ac *APIClient) loginWithAPIKey() (*TokenResponse, error) {
|
||||||
|
data := url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"client_id": {ac.clientID},
|
||||||
|
"client_secret": {ac.clientSecret},
|
||||||
|
"scope": {"api"},
|
||||||
|
"deviceType": {"10"},
|
||||||
|
"deviceIdentifier": {ac.deviceID},
|
||||||
|
"deviceName": {"vaultwarden-api"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return ac.doTokenRequest(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doTokenRequest sends a token request and parses the response.
|
||||||
|
func (ac *APIClient) doTokenRequest(data url.Values) (*TokenResponse, error) {
|
||||||
|
resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp TokenResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode login response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokenResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchProfileKey gets the encrypted symmetric key from the user's profile.
|
||||||
|
// Used when API key login doesn't return the Key in the token response.
|
||||||
|
func (ac *APIClient) fetchProfileKey() (string, error) {
|
||||||
|
ac.mu.RLock()
|
||||||
|
token := ac.accessToken
|
||||||
|
ac.mu.RUnlock()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create sync request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
resp, err := ac.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("sync request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var syncResp SyncResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil {
|
||||||
|
return "", fmt.Errorf("decode sync: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if syncResp.Profile.Key == "" {
|
||||||
|
return "", fmt.Errorf("profile key is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncResp.Profile.Key, nil
|
||||||
|
}
|
||||||
+124
-111
@@ -1,152 +1,165 @@
|
|||||||
// Package vaultwarden provides a client for interacting with Vaultwarden via CLI
|
// Package vaultwarden provides a production-ready client for retrieving secrets
|
||||||
|
// from Vaultwarden using native Go HTTP and crypto (no CLI dependency).
|
||||||
package vaultwarden
|
package vaultwarden
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
"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 {
|
type Client struct {
|
||||||
baseURL string
|
api *APIClient
|
||||||
token string // Session token from CLI authentication
|
cacheTTL time.Duration
|
||||||
cache *secretCache
|
syncEvery time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]DecryptedItem // keyed by lowercase name
|
||||||
|
byID map[string]DecryptedItem // keyed by ID
|
||||||
|
|
||||||
|
stopSync chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// secretCache provides a simple in-memory cache with TTL
|
// NewClient creates a new vault client backed by the native API client.
|
||||||
type secretCache struct {
|
func NewClient(api *APIClient, cacheTTL, syncInterval time.Duration) *Client {
|
||||||
mu sync.RWMutex
|
|
||||||
items map[string]*cacheItem
|
|
||||||
ttl time.Duration
|
|
||||||
enabled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type cacheItem struct {
|
|
||||||
value string
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient creates a new Vaultwarden client with CLI session token
|
|
||||||
func NewClient(baseURL, token string, cacheTTL time.Duration) *Client {
|
|
||||||
cache := &secretCache{
|
|
||||||
items: make(map[string]*cacheItem),
|
|
||||||
ttl: cacheTTL,
|
|
||||||
enabled: cacheTTL > 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if cacheTTL > 0 {
|
|
||||||
go cache.startCleanup(cacheTTL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
api: api,
|
||||||
token: token,
|
cacheTTL: cacheTTL,
|
||||||
cache: cache,
|
syncEvery: syncInterval,
|
||||||
|
items: make(map[string]DecryptedItem),
|
||||||
|
byID: make(map[string]DecryptedItem),
|
||||||
|
stopSync: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecret retrieves a secret by name from Vaultwarden
|
// Initialize authenticates and performs the initial vault sync.
|
||||||
// It first checks the cache, then queries the API if needed
|
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) {
|
func (c *Client) GetSecret(name string) (string, error) {
|
||||||
// Validate input
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return "", fmt.Errorf("secret name cannot be empty")
|
return "", fmt.Errorf("secret name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache first
|
c.mu.RLock()
|
||||||
if c.cache.enabled {
|
defer c.mu.RUnlock()
|
||||||
if value, found := c.cache.get(name); found {
|
|
||||||
logger.Debug.Printf("Cache hit for secret")
|
key := strings.ToLower(name)
|
||||||
return value, nil
|
|
||||||
|
// 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
|
return "", fmt.Errorf("secret not found")
|
||||||
logger.Debug.Printf("Fetching secret from Vaultwarden (cache miss)")
|
|
||||||
value, err := c.fetchSecret(name)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
if c.cache.enabled {
|
|
||||||
c.cache.set(name, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchSecret queries the Vaultwarden for a secret via CLI
|
// ClearCache triggers a fresh vault sync.
|
||||||
func (c *Client) fetchSecret(name string) (string, error) {
|
|
||||||
return c.FetchSecretViaCLI(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearCache clears all cached secrets
|
|
||||||
func (c *Client) ClearCache() {
|
func (c *Client) ClearCache() {
|
||||||
if c.cache.enabled {
|
if err := c.syncVault(); err != nil {
|
||||||
c.cache.clear()
|
logger.Error.Printf("Cache refresh sync failed: %v", err)
|
||||||
logger.Info.Println("Cache cleared")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache methods
|
// Stop stops the background sync goroutine.
|
||||||
func (sc *secretCache) get(key string) (string, bool) {
|
func (c *Client) Stop() {
|
||||||
sc.mu.RLock()
|
close(c.stopSync)
|
||||||
defer sc.mu.RUnlock()
|
|
||||||
|
|
||||||
item, found := sc.items[key]
|
|
||||||
if !found {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if time.Now().After(item.expiresAt) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return item.value, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sc *secretCache) set(key, value string) {
|
// syncVault fetches and decrypts all items from the vault.
|
||||||
sc.mu.Lock()
|
func (c *Client) syncVault() error {
|
||||||
defer sc.mu.Unlock()
|
items, err := c.api.Sync()
|
||||||
|
if err != nil {
|
||||||
sc.items[key] = &cacheItem{
|
return err
|
||||||
value: value,
|
|
||||||
expiresAt: time.Now().Add(sc.ttl),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newItems := make(map[string]DecryptedItem, len(items))
|
||||||
|
newByID := make(map[string]DecryptedItem, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
key := strings.ToLower(item.Name)
|
||||||
|
newItems[key] = item
|
||||||
|
newByID[item.ID] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.items = newItems
|
||||||
|
c.byID = newByID
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sc *secretCache) clear() {
|
// backgroundSync periodically syncs the vault to pick up changes.
|
||||||
sc.mu.Lock()
|
func (c *Client) backgroundSync() {
|
||||||
defer sc.mu.Unlock()
|
ticker := time.NewTicker(c.syncEvery)
|
||||||
|
|
||||||
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)
|
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
sc.removeExpired()
|
select {
|
||||||
}
|
case <-ticker.C:
|
||||||
}
|
if err := c.syncVault(); err != nil {
|
||||||
|
logger.Warn.Printf("Background sync failed: %v", err)
|
||||||
// removeExpired removes all expired items from the cache
|
} else {
|
||||||
func (sc *secretCache) removeExpired() {
|
logger.Debug.Println("Background vault sync completed")
|
||||||
sc.mu.Lock()
|
}
|
||||||
defer sc.mu.Unlock()
|
case <-c.stopSync:
|
||||||
|
logger.Info.Println("Background sync stopped")
|
||||||
now := time.Now()
|
return
|
||||||
for key, item := range sc.items {
|
|
||||||
if now.After(item.expiresAt) {
|
|
||||||
delete(sc.items, key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractSecret extracts the most relevant secret value from a decrypted item.
|
||||||
|
// Priority: password > field named "value"/"secret"/"api_key" > notes > first field.
|
||||||
|
func extractSecret(item DecryptedItem) string {
|
||||||
|
if item.Password != "" {
|
||||||
|
return item.Password
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom fields by priority.
|
||||||
|
for _, name := range []string{"value", "secret", "api_key", "apikey", "token"} {
|
||||||
|
if v, ok := item.Fields[name]; ok && v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.Notes != "" {
|
||||||
|
return item.Notes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first non-empty field value.
|
||||||
|
for _, v := range item.Fields {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// Package vaultwarden provides CLI-based secret retrieval
|
|
||||||
package vaultwarden
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type BitwardenItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type int `json:"type"`
|
|
||||||
Login *struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
} `json:"login,omitempty"`
|
|
||||||
Notes string `json:"notes,omitempty"`
|
|
||||||
Fields []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
} `json:"fields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) FetchSecretViaCLI(name string) (string, error) {
|
|
||||||
if len(name) == 0 || len(name) > 255 {
|
|
||||||
return "", fmt.Errorf("invalid secret name length")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ch := range name {
|
|
||||||
if !((ch >= 'a' && ch <= 'z') ||
|
|
||||||
(ch >= 'A' && ch <= 'Z') ||
|
|
||||||
(ch >= '0' && ch <= '9') ||
|
|
||||||
ch == '-' || ch == '_' || ch == '.' || ch == '/') {
|
|
||||||
return "", fmt.Errorf("invalid character in secret name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("bw", "get", "item", name)
|
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
|
|
||||||
output, err := cmd.Output()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Info.Printf("Exact match failed, searching for: %s", name)
|
|
||||||
|
|
||||||
searchCmd := exec.Command("bw", "list", "items", "--search", name)
|
|
||||||
searchCmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
|
|
||||||
searchOutput, searchErr := searchCmd.Output()
|
|
||||||
if searchErr != nil {
|
|
||||||
return "", fmt.Errorf("failed to search for item: %w", searchErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []BitwardenItem
|
|
||||||
if err := json.Unmarshal(searchOutput, &items); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse search results: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
return "", fmt.Errorf("secret not found: %s", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) > 1 {
|
|
||||||
logger.Warn.Printf("Multiple items found for '%s', using first match: '%s' (ID: %s)", name, items[0].Name, items[0].ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
output, err = json.Marshal(items[0])
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to marshal item: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var item BitwardenItem
|
|
||||||
if err := json.Unmarshal(output, &item); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse item: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.extractValueFromItem(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) extractValueFromItem(item BitwardenItem) (string, error) {
|
|
||||||
if item.Login != nil && item.Login.Password != "" {
|
|
||||||
return item.Login.Password, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, field := range item.Fields {
|
|
||||||
fieldName := strings.ToLower(field.Name)
|
|
||||||
if fieldName == "value" || fieldName == "secret" || fieldName == "api_key" {
|
|
||||||
return field.Value, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.Notes != "" {
|
|
||||||
return item.Notes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(item.Fields) > 0 {
|
|
||||||
return item.Fields[0].Value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no secret value found in item")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) SyncVault() error {
|
|
||||||
cmd := exec.Command("bw", "sync")
|
|
||||||
cmd.Env = append(os.Environ(), fmt.Sprintf("BW_SESSION=%s", c.token))
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to sync vault: %w, output: %s", err, output)
|
|
||||||
}
|
|
||||||
logger.Info.Println("Vault synced successfully")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// Package vaultwarden provides native Bitwarden-compatible encryption and decryption.
|
||||||
|
// This replaces the Bitwarden CLI dependency with pure Go crypto.
|
||||||
|
package vaultwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
"golang.org/x/crypto/hkdf"
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Encryption types as defined by the Bitwarden protocol.
|
||||||
|
const (
|
||||||
|
EncTypeAesCbc256_B64 = 0
|
||||||
|
EncTypeAesCbc128_HmacSha256_B64 = 1
|
||||||
|
EncTypeAesCbc256_HmacSha256_B64 = 2
|
||||||
|
EncTypeRsa2048_OaepSha256_B64 = 3
|
||||||
|
EncTypeRsa2048_OaepSha1_B64 = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// KDF types.
|
||||||
|
const (
|
||||||
|
KdfPBKDF2 = 0
|
||||||
|
KdfArgon2id = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// SymmetricKey holds the encryption and MAC keys for AES-CBC + HMAC-SHA256.
|
||||||
|
type SymmetricKey struct {
|
||||||
|
EncKey []byte // 32 bytes for AES-256
|
||||||
|
MacKey []byte // 32 bytes for HMAC-SHA256
|
||||||
|
}
|
||||||
|
|
||||||
|
// CipherString represents an encrypted Bitwarden value.
|
||||||
|
// Format: "<encType>.<iv>|<ciphertext>|<mac>"
|
||||||
|
type CipherString struct {
|
||||||
|
Type int
|
||||||
|
IV []byte
|
||||||
|
CT []byte
|
||||||
|
MAC []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCipherString parses a Bitwarden encrypted string.
|
||||||
|
func ParseCipherString(s string) (*CipherString, error) {
|
||||||
|
if s == "" {
|
||||||
|
return nil, errors.New("empty cipher string")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(s, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid cipher string: missing type separator")
|
||||||
|
}
|
||||||
|
|
||||||
|
encType, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid encryption type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cs := &CipherString{Type: encType}
|
||||||
|
pieces := strings.Split(parts[1], "|")
|
||||||
|
|
||||||
|
switch encType {
|
||||||
|
case EncTypeAesCbc256_B64:
|
||||||
|
if len(pieces) != 2 {
|
||||||
|
return nil, fmt.Errorf("AesCbc256_B64 expects 2 parts, got %d", len(pieces))
|
||||||
|
}
|
||||||
|
if cs.IV, err = base64.StdEncoding.DecodeString(pieces[0]); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid IV: %w", err)
|
||||||
|
}
|
||||||
|
if cs.CT, err = base64.StdEncoding.DecodeString(pieces[1]); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ciphertext: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case EncTypeAesCbc256_HmacSha256_B64:
|
||||||
|
if len(pieces) != 3 {
|
||||||
|
return nil, fmt.Errorf("AesCbc256_HmacSha256_B64 expects 3 parts, got %d", len(pieces))
|
||||||
|
}
|
||||||
|
if cs.IV, err = base64.StdEncoding.DecodeString(pieces[0]); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid IV: %w", err)
|
||||||
|
}
|
||||||
|
if cs.CT, err = base64.StdEncoding.DecodeString(pieces[1]); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid ciphertext: %w", err)
|
||||||
|
}
|
||||||
|
if cs.MAC, err = base64.StdEncoding.DecodeString(pieces[2]); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid MAC: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported encryption type: %d", encType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts a CipherString using the provided symmetric key.
|
||||||
|
func (cs *CipherString) Decrypt(key SymmetricKey) ([]byte, error) {
|
||||||
|
if len(cs.IV) != aes.BlockSize {
|
||||||
|
return nil, fmt.Errorf("invalid IV length: %d", len(cs.IV))
|
||||||
|
}
|
||||||
|
if len(cs.CT) == 0 || len(cs.CT)%aes.BlockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid ciphertext length: %d", len(cs.CT))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MAC if present (type 2).
|
||||||
|
if cs.Type == EncTypeAesCbc256_HmacSha256_B64 {
|
||||||
|
if len(key.MacKey) == 0 {
|
||||||
|
return nil, errors.New("MAC key required for type 2 decryption")
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, key.MacKey)
|
||||||
|
mac.Write(cs.IV)
|
||||||
|
mac.Write(cs.CT)
|
||||||
|
expectedMAC := mac.Sum(nil)
|
||||||
|
if !hmac.Equal(expectedMAC, cs.MAC) {
|
||||||
|
return nil, errors.New("MAC verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES-CBC decrypt.
|
||||||
|
block, err := aes.NewCipher(key.EncKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aes cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := make([]byte, len(cs.CT))
|
||||||
|
mode := cipher.NewCBCDecrypter(block, cs.IV)
|
||||||
|
mode.CryptBlocks(plaintext, cs.CT)
|
||||||
|
|
||||||
|
// Remove PKCS7 padding.
|
||||||
|
plaintext, err = pkcs7Unpad(plaintext, aes.BlockSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("pkcs7 unpad: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptToString decrypts and returns the plaintext as a string.
|
||||||
|
func (cs *CipherString) DecryptToString(key SymmetricKey) (string, error) {
|
||||||
|
b, err := cs.Decrypt(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptStr is a convenience function to parse and decrypt a cipher string in one call.
|
||||||
|
func DecryptStr(s string, key SymmetricKey) (string, error) {
|
||||||
|
if s == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
cs, err := ParseCipherString(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return cs.DecryptToString(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MakeMasterKey derives the master key from the user's password and email.
|
||||||
|
func MakeMasterKey(password, email string, kdfType, iterations int, memory, parallelism *int) ([]byte, error) {
|
||||||
|
salt := []byte(strings.ToLower(strings.TrimSpace(email)))
|
||||||
|
|
||||||
|
switch kdfType {
|
||||||
|
case KdfPBKDF2:
|
||||||
|
if iterations < 1 {
|
||||||
|
return nil, fmt.Errorf("PBKDF2 iterations must be >= 1, got %d", iterations)
|
||||||
|
}
|
||||||
|
return pbkdf2.Key([]byte(password), salt, iterations, 32, sha256.New), nil
|
||||||
|
|
||||||
|
case KdfArgon2id:
|
||||||
|
mem := 64 * 1024 // default 64 MiB
|
||||||
|
par := 4 // default parallelism
|
||||||
|
if memory != nil {
|
||||||
|
mem = *memory * 1024 // API returns MiB, argon2 wants KiB
|
||||||
|
}
|
||||||
|
if parallelism != nil {
|
||||||
|
par = *parallelism
|
||||||
|
}
|
||||||
|
return argon2.IDKey([]byte(password), salt, uint32(iterations), uint32(mem), uint8(par), 32), nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported KDF type: %d", kdfType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword creates the password hash for authentication.
|
||||||
|
// hash = Base64(PBKDF2(masterKey, password, 1, 32, SHA256))
|
||||||
|
func HashPassword(password string, masterKey []byte) string {
|
||||||
|
hash := pbkdf2.Key(masterKey, []byte(password), 1, 32, sha256.New)
|
||||||
|
return base64.StdEncoding.EncodeToString(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StretchKey expands a 32-byte master key into a 64-byte stretched key
|
||||||
|
// using HKDF-Expand with SHA-256. Returns (encKey, macKey).
|
||||||
|
func StretchKey(masterKey []byte) (SymmetricKey, error) {
|
||||||
|
encKey := make([]byte, 32)
|
||||||
|
r := hkdf.Expand(sha256.New, masterKey, []byte("enc"))
|
||||||
|
if _, err := io.ReadFull(r, encKey); err != nil {
|
||||||
|
return SymmetricKey{}, fmt.Errorf("hkdf expand enc: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
macKey := make([]byte, 32)
|
||||||
|
r = hkdf.Expand(sha256.New, masterKey, []byte("mac"))
|
||||||
|
if _, err := io.ReadFull(r, macKey); err != nil {
|
||||||
|
return SymmetricKey{}, fmt.Errorf("hkdf expand mac: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymmetricKey{EncKey: encKey, MacKey: macKey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptSymmetricKey decrypts the user's encrypted symmetric key from the login response.
|
||||||
|
// It tries HKDF-stretched key first, then falls back to legacy (unstretched) key.
|
||||||
|
func DecryptSymmetricKey(encryptedKey string, masterKey []byte) (SymmetricKey, error) {
|
||||||
|
cs, err := ParseCipherString(encryptedKey)
|
||||||
|
if err != nil {
|
||||||
|
return SymmetricKey{}, fmt.Errorf("parse encrypted key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try modern approach: HKDF-stretched key.
|
||||||
|
stretched, err := StretchKey(masterKey)
|
||||||
|
if err != nil {
|
||||||
|
return SymmetricKey{}, fmt.Errorf("stretch key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := cs.Decrypt(stretched)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: legacy mode (master key used directly as enc key, no MAC).
|
||||||
|
legacy := SymmetricKey{EncKey: masterKey}
|
||||||
|
decrypted, err = cs.Decrypt(legacy)
|
||||||
|
if err != nil {
|
||||||
|
return SymmetricKey{}, fmt.Errorf("decrypt symmetric key (tried stretched + legacy): %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decrypted key is 64 bytes: first 32 = encKey, last 32 = macKey.
|
||||||
|
if len(decrypted) != 64 {
|
||||||
|
return SymmetricKey{}, fmt.Errorf("unexpected symmetric key length: %d (expected 64)", len(decrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
return SymmetricKey{
|
||||||
|
EncKey: decrypted[:32],
|
||||||
|
MacKey: decrypted[32:],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7Unpad removes PKCS#7 padding.
|
||||||
|
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, errors.New("empty data")
|
||||||
|
}
|
||||||
|
if len(data)%blockSize != 0 {
|
||||||
|
return nil, errors.New("data not block-aligned")
|
||||||
|
}
|
||||||
|
|
||||||
|
padLen := int(data[len(data)-1])
|
||||||
|
if padLen == 0 || padLen > blockSize {
|
||||||
|
return nil, fmt.Errorf("invalid padding length: %d", padLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(data) - padLen; i < len(data); i++ {
|
||||||
|
if data[i] != byte(padLen) {
|
||||||
|
return nil, errors.New("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[:len(data)-padLen], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package vaultwarden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCipherString_Type2(t *testing.T) {
|
||||||
|
// Build a known cipher string of type 2 (AES-CBC-256 + HMAC-SHA256).
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
ct := make([]byte, 32) // 2 blocks
|
||||||
|
mac := make([]byte, 32)
|
||||||
|
for i := range iv {
|
||||||
|
iv[i] = byte(i)
|
||||||
|
}
|
||||||
|
for i := range ct {
|
||||||
|
ct[i] = byte(i + 16)
|
||||||
|
}
|
||||||
|
for i := range mac {
|
||||||
|
mac[i] = byte(i + 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(ct) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(mac)
|
||||||
|
|
||||||
|
cs, err := ParseCipherString(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCipherString failed: %v", err)
|
||||||
|
}
|
||||||
|
if cs.Type != 2 {
|
||||||
|
t.Errorf("expected type 2, got %d", cs.Type)
|
||||||
|
}
|
||||||
|
if len(cs.IV) != 16 {
|
||||||
|
t.Errorf("expected IV length 16, got %d", len(cs.IV))
|
||||||
|
}
|
||||||
|
if len(cs.CT) != 32 {
|
||||||
|
t.Errorf("expected CT length 32, got %d", len(cs.CT))
|
||||||
|
}
|
||||||
|
if len(cs.MAC) != 32 {
|
||||||
|
t.Errorf("expected MAC length 32, got %d", len(cs.MAC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCipherString_Type0(t *testing.T) {
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
ct := make([]byte, 32)
|
||||||
|
s := "0." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(ct)
|
||||||
|
|
||||||
|
cs, err := ParseCipherString(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCipherString failed: %v", err)
|
||||||
|
}
|
||||||
|
if cs.Type != 0 {
|
||||||
|
t.Errorf("expected type 0, got %d", cs.Type)
|
||||||
|
}
|
||||||
|
if cs.MAC != nil {
|
||||||
|
t.Error("expected nil MAC for type 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCipherString_Invalid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{"empty", ""},
|
||||||
|
{"no type", "abcdef"},
|
||||||
|
{"bad type", "x.abc|def|ghi"},
|
||||||
|
{"type 2 missing mac", "2.abc|def"},
|
||||||
|
{"type 0 extra parts", "0.abc|def|ghi"},
|
||||||
|
{"unsupported type", "5.abc|def"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := ParseCipherString(tt.input)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPKCS7Unpad(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
want []byte
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "1 byte padding",
|
||||||
|
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x01},
|
||||||
|
want: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full block padding",
|
||||||
|
input: append(make([]byte, 0), 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10),
|
||||||
|
want: []byte{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: []byte{},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero padding byte",
|
||||||
|
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x00},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := pkcs7Unpad(tt.input, 16)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("length mismatch: got %d, want %d", len(got), len(tt.want))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrypt_Type2_RoundTrip(t *testing.T) {
|
||||||
|
// Create a known key, encrypt some data, then decrypt it.
|
||||||
|
encKey := make([]byte, 32)
|
||||||
|
macKey := make([]byte, 32)
|
||||||
|
for i := range encKey {
|
||||||
|
encKey[i] = byte(i)
|
||||||
|
}
|
||||||
|
for i := range macKey {
|
||||||
|
macKey[i] = byte(i + 32)
|
||||||
|
}
|
||||||
|
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
|
||||||
|
|
||||||
|
plaintext := []byte("Hello, Vaultwarden!")
|
||||||
|
|
||||||
|
// Pad the plaintext with PKCS7.
|
||||||
|
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
||||||
|
padded := make([]byte, len(plaintext)+padLen)
|
||||||
|
copy(padded, plaintext)
|
||||||
|
for i := len(plaintext); i < len(padded); i++ {
|
||||||
|
padded[i] = byte(padLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt with AES-CBC.
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
for i := range iv {
|
||||||
|
iv[i] = byte(i + 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(encKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ct := make([]byte, len(padded))
|
||||||
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
|
||||||
|
|
||||||
|
// Compute MAC.
|
||||||
|
mac := hmac.New(sha256.New, macKey)
|
||||||
|
mac.Write(iv)
|
||||||
|
mac.Write(ct)
|
||||||
|
macBytes := mac.Sum(nil)
|
||||||
|
|
||||||
|
// Build cipher string.
|
||||||
|
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(ct) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(macBytes)
|
||||||
|
|
||||||
|
cs, err := ParseCipherString(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := cs.DecryptToString(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != "Hello, Vaultwarden!" {
|
||||||
|
t.Errorf("got %q, want %q", result, "Hello, Vaultwarden!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecrypt_MACVerificationFails(t *testing.T) {
|
||||||
|
encKey := make([]byte, 32)
|
||||||
|
macKey := make([]byte, 32)
|
||||||
|
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
|
||||||
|
|
||||||
|
// Create a valid ciphertext but with a wrong MAC.
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
ct := make([]byte, 16)
|
||||||
|
wrongMAC := make([]byte, 32)
|
||||||
|
for i := range wrongMAC {
|
||||||
|
wrongMAC[i] = 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(ct) + "|" +
|
||||||
|
base64.StdEncoding.EncodeToString(wrongMAC)
|
||||||
|
|
||||||
|
cs, err := ParseCipherString(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cs.Decrypt(key)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected MAC verification error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "MAC verification failed") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeMasterKey_PBKDF2(t *testing.T) {
|
||||||
|
key, err := MakeMasterKey("password123", "user@example.com", KdfPBKDF2, 600000, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MakeMasterKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(key) != 32 {
|
||||||
|
t.Errorf("expected 32 bytes, got %d", len(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it matches raw PBKDF2.
|
||||||
|
expected := pbkdf2.Key([]byte("password123"), []byte("user@example.com"), 600000, 32, sha256.New)
|
||||||
|
for i := range key {
|
||||||
|
if key[i] != expected[i] {
|
||||||
|
t.Fatalf("key mismatch at byte %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeMasterKey_Argon2id(t *testing.T) {
|
||||||
|
mem := 64
|
||||||
|
par := 4
|
||||||
|
key, err := MakeMasterKey("password123", "user@example.com", KdfArgon2id, 3, &mem, &par)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MakeMasterKey failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(key) != 32 {
|
||||||
|
t.Errorf("expected 32 bytes, got %d", len(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashPassword(t *testing.T) {
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := HashPassword("password123", masterKey)
|
||||||
|
if hash == "" {
|
||||||
|
t.Error("empty hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be valid base64.
|
||||||
|
_, err := base64.StdEncoding.DecodeString(hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("hash is not valid base64: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStretchKey(t *testing.T) {
|
||||||
|
masterKey := make([]byte, 32)
|
||||||
|
for i := range masterKey {
|
||||||
|
masterKey[i] = byte(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := StretchKey(masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StretchKey failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key.EncKey) != 32 {
|
||||||
|
t.Errorf("expected 32-byte enc key, got %d", len(key.EncKey))
|
||||||
|
}
|
||||||
|
if len(key.MacKey) != 32 {
|
||||||
|
t.Errorf("expected 32-byte mac key, got %d", len(key.MacKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enc and Mac keys should be different.
|
||||||
|
same := true
|
||||||
|
for i := range key.EncKey {
|
||||||
|
if key.EncKey[i] != key.MacKey[i] {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
t.Error("enc and mac keys should differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptStr_Empty(t *testing.T) {
|
||||||
|
key := SymmetricKey{EncKey: make([]byte, 32), MacKey: make([]byte, 32)}
|
||||||
|
result, err := DecryptStr("", key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptStr empty should not error: %v", err)
|
||||||
|
}
|
||||||
|
if result != "" {
|
||||||
|
t.Errorf("expected empty string, got %q", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,114 +1,40 @@
|
|||||||
package vaultwarden
|
package vaultwarden
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/thijsherman/vaultwarden-api/pkg/logger"
|
"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 {
|
api := NewAPIClient(serverURL, email, password, clientID, clientSecret)
|
||||||
Status string `json:"status"`
|
client := NewClient(api, cacheTTL, syncInterval)
|
||||||
}
|
|
||||||
|
|
||||||
func InitializeBitwardenCLI(serverURL, clientID, clientSecret, password string) (string, error) {
|
// Authenticate and perform initial sync with retry.
|
||||||
bitwardenInitMutex.Lock()
|
maxRetries := 3
|
||||||
defer bitwardenInitMutex.Unlock()
|
var lastErr error
|
||||||
|
|
||||||
logger.Info.Println("Initializing Bitwarden CLI...")
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||||
|
if attempt > 1 {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
backoff := time.Duration(attempt*attempt) * 5 * time.Second
|
||||||
cmd := exec.CommandContext(ctx, "bw", "config", "server", serverURL)
|
logger.Info.Printf("Retry attempt %d/%d after %v...", attempt, maxRetries, backoff)
|
||||||
if output, err := cmd.CombinedOutput(); err != nil {
|
time.Sleep(backoff)
|
||||||
cancel()
|
|
||||||
return "", fmt.Errorf("config server failed: %w - %s", err, output)
|
|
||||||
}
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
cmd = exec.CommandContext(ctx, "bw", "status")
|
|
||||||
statusOutput, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn.Printf("Failed to check status: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var status BitwardenStatus
|
|
||||||
isLoggedIn := false
|
|
||||||
if err := json.Unmarshal(statusOutput, &status); err == nil {
|
|
||||||
isLoggedIn = status.Status == "unlocked" || status.Status == "locked"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isLoggedIn {
|
|
||||||
logger.Info.Println("Logging in to Bitwarden...")
|
|
||||||
maxRetries := 3
|
|
||||||
var loginErr error
|
|
||||||
|
|
||||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
|
||||||
if attempt > 1 {
|
|
||||||
backoff := time.Duration(attempt*attempt) * 5 * time.Second
|
|
||||||
logger.Info.Printf("Retry attempt %d/%d after %v...", attempt, maxRetries, backoff)
|
|
||||||
time.Sleep(backoff)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 15*time.Second)
|
|
||||||
cmd = exec.CommandContext(ctx, "bw", "login", "--apikey")
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
fmt.Sprintf("BW_CLIENTID=%s", clientID),
|
|
||||||
fmt.Sprintf("BW_CLIENTSECRET=%s", clientSecret),
|
|
||||||
)
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
outputStr := strings.TrimSpace(string(output))
|
|
||||||
if err == nil || strings.Contains(outputStr, "You are logged in!") {
|
|
||||||
logger.Info.Println("Login successful")
|
|
||||||
loginErr = nil
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(outputStr, "Rate limit") {
|
|
||||||
logger.Warn.Printf("Rate limited (attempt %d/%d)", attempt, maxRetries)
|
|
||||||
loginErr = fmt.Errorf("rate limited: %s", outputStr)
|
|
||||||
} else {
|
|
||||||
logger.Warn.Printf("Login failed (attempt %d/%d): %v - %s", attempt, maxRetries, err, outputStr)
|
|
||||||
loginErr = fmt.Errorf("login failed: %w - %s", err, outputStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt < maxRetries {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginErr != nil {
|
if err := client.Initialize(); err != nil {
|
||||||
return "", loginErr
|
logger.Warn.Printf("Initialization failed (attempt %d/%d): %v", attempt, maxRetries, err)
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.Info.Println("Already logged in, skipping login")
|
logger.Info.Println("Vaultwarden client initialized successfully")
|
||||||
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
return nil, fmt.Errorf("failed to initialize after %d attempts: %w", maxRetries, lastErr)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user